From c1bbfcc13b745f0ef3be0a3b1c9861d5697de708 Mon Sep 17 00:00:00 2001 From: Nene7ko_ <1604009816@qq.com> Date: Sun, 26 Jul 2026 23:51:19 +0800 Subject: [PATCH 01/17] docs(spec): define trusted publication console loop --- .specify/feature.json | 2 +- .../analyze.md | 60 ++++++ .../checklists/requirements.md | 37 ++++ .../contracts/console-gateway-mapping.md | 38 ++++ .../data-model.md | 113 +++++++++++ specs/027-console-trusted-publication/plan.md | 180 ++++++++++++++++++ .../quickstart.md | 84 ++++++++ .../research.md | 82 ++++++++ specs/027-console-trusted-publication/spec.md | 176 +++++++++++++++++ .../027-console-trusted-publication/tasks.md | 166 ++++++++++++++++ 10 files changed, 937 insertions(+), 1 deletion(-) create mode 100644 specs/027-console-trusted-publication/analyze.md create mode 100644 specs/027-console-trusted-publication/checklists/requirements.md create mode 100644 specs/027-console-trusted-publication/contracts/console-gateway-mapping.md create mode 100644 specs/027-console-trusted-publication/data-model.md create mode 100644 specs/027-console-trusted-publication/plan.md create mode 100644 specs/027-console-trusted-publication/quickstart.md create mode 100644 specs/027-console-trusted-publication/research.md create mode 100644 specs/027-console-trusted-publication/spec.md create mode 100644 specs/027-console-trusted-publication/tasks.md diff --git a/.specify/feature.json b/.specify/feature.json index 465e1f2..64714c3 100644 --- a/.specify/feature.json +++ b/.specify/feature.json @@ -1,3 +1,3 @@ { - "feature_directory": "specs/026-trusted-publication-acceptance" + "feature_directory": "specs/027-console-trusted-publication" } diff --git a/specs/027-console-trusted-publication/analyze.md b/specs/027-console-trusted-publication/analyze.md new file mode 100644 index 0000000..c59ba0d --- /dev/null +++ b/specs/027-console-trusted-publication/analyze.md @@ -0,0 +1,60 @@ +# Specification Analysis Report + +**Feature**: [Console Trusted Publication Loop](spec.md) + +**Analyzed**: 2026-07-26 + +## Result + +PASS. The Spec, Plan, Tasks, contract mapping, and project constitution are +consistent enough to begin implementation. No Critical, High, or Medium issue +was found. + +## Coverage Summary + +| Artifact | Result | +| --- | --- | +| Functional requirements | 17/17 have implementation/test/review coverage | +| Success criteria | 8/8 have implementation/test/review coverage | +| User stories | 5/5 have an independent delivery path | +| Task checklist | 39 unique tasks; no duplicate checklist IDs | +| Issue mapping | Console #2, Console #4, NeKiro #60, Console #3/NeKiro #59 | +| Contract changes | None; existing active contracts are mapped | +| Data ownership | Console presentation only; no new persistence owner | +| Fallback delta | removed 0, retained 0, added 0, net 0 | + +## Constitution Alignment + +- Phase 1 `Register -> Discover -> Install -> Invoke -> Record`: PASS. +- Control Plane/Data Plane and Console Gateway-only boundary: PASS. +- Runtime independence and reverse cross-runtime proof: PASS. +- Versioned contract and compatibility discipline: PASS. +- Invocation/Task/Trace/parent lineage: PASS. +- Explicit failure and secret safety: PASS. +- Spec-driven implementation and independent review: PASS. + +## Task and Scope Checks + +- Slice A owns `apps/console` API mappings and focused client tests. +- Slice B owns production Console operation components and UI tests. +- Slice C owns only the backend reverse-lineage acceptance fixture. +- Slice D owns frontend discovery/CI/browser acceptance and documentation. +- Every slice has a pre-implementation subagent task and an independent review + gate before the next slice. +- Tests are scheduled after their mapped implementation tasks, in accordance + with the project workflow. + +## Residual Policy Items + +No unresolved product ambiguity blocks implementation. The pre-implementation +Issue #2 review identified and resolved the repository path and trace-header +policy in the Spec, Plan, Tasks, and contract mapping. Retention, credential +issuance/rotation, health polling, retry/reconnect, and production governance +remain explicit non-goals or `Needs policy` items and are not implemented by +this feature. + +## Next Gate + +Proceed to Slice A only after the Issue #2 pre-implementation subagent has +reported its scope and forbidden behaviors. Implementation must stop if that +report identifies a contract gap requiring a new Spec or ADR. diff --git a/specs/027-console-trusted-publication/checklists/requirements.md b/specs/027-console-trusted-publication/checklists/requirements.md new file mode 100644 index 0000000..80e4e71 --- /dev/null +++ b/specs/027-console-trusted-publication/checklists/requirements.md @@ -0,0 +1,37 @@ +# Specification Quality Checklist: Console Trusted Publication Loop + +**Purpose**: Validate specification completeness and quality before proceeding to planning + +**Created**: 2026-07-26 + +**Feature**: [spec.md](../spec.md) + +## Content Quality + +- [x] No implementation details (languages, frameworks, APIs) +- [x] Focused on user value and business needs +- [x] Written for non-technical stakeholders +- [x] All mandatory sections completed + +## Requirement Completeness + +- [x] No [NEEDS CLARIFICATION] markers remain +- [x] Requirements are testable and unambiguous +- [x] Success criteria are measurable +- [x] Success criteria are technology-agnostic +- [x] All acceptance scenarios are defined +- [x] Edge cases are identified +- [x] Scope is clearly bounded +- [x] Dependencies and assumptions identified + +## Feature Readiness + +- [x] All functional requirements have clear acceptance criteria +- [x] User stories cover provider, Workspace owner, invocation, and operator journeys +- [x] Feature meets measurable outcomes defined in Success Criteria +- [x] No implementation details leak into specification + +## Notes + +- This Spec consumes active backend contracts and treats missing backend behavior as a separate Issue/Spec rather than adding a Console fallback. +- The parent upstream Issue is #59. The canonical reverse nested-call acceptance is tracked separately so the Console delivery cannot hide an unproven B -> Router -> A path. diff --git a/specs/027-console-trusted-publication/contracts/console-gateway-mapping.md b/specs/027-console-trusted-publication/contracts/console-gateway-mapping.md new file mode 100644 index 0000000..007d8ec --- /dev/null +++ b/specs/027-console-trusted-publication/contracts/console-gateway-mapping.md @@ -0,0 +1,38 @@ +# Console to Gateway Contract Mapping + +This is a consumer mapping over existing versioned contracts. It does not +introduce a new wire contract. + +| Console operation | Gateway route | Request | Success shape | Failure contract | +| --- | --- | --- | --- | --- | +| Register Card | `POST /v3/agents` | Card 0.2 wrapper | Catalog entry | Catalog/Northbound error | +| Discover | `GET /v3/agents` | query/capability/owner/limit/cursor | Catalog search | Catalog/Northbound error | +| Create Binding | `POST /v4/providers/{providerId}/agents/{agentId}/endpoint-bindings` | `{version, endpoint, method}` | Trusted Publication `endpointBinding` | Trusted Publication v1 error | +| Read Binding | `GET /v4/providers/{providerId}/endpoint-bindings/{bindingId}` | path only | `endpointBinding` | Trusted Publication v1 error | +| Issue Challenge | `POST /v4/providers/{providerId}/endpoint-bindings/{bindingId}/challenges` | no body | `challenge` | Trusted Publication v1 error | +| Complete Challenge | `POST /v4/providers/{providerId}/endpoint-bindings/{bindingId}/challenges/{challengeId}/complete` | no body | `endpointBinding` | Trusted Publication v1 error | +| Create Release | `POST /v4/providers/{providerId}/agents/{agentId}/releases` | `{version, endpointBindingId}` | `agentRelease` | Trusted Publication v1 error | +| Read Release | `GET /v4/releases/{releaseId}` | path only | `agentRelease` | Trusted Publication v1 error | +| Verify/Publish/Suspend/Revoke | `POST /v4/releases/{releaseId}/{action}` | no body | `agentRelease` | Trusted Publication v1 error | +| Installation Release preflight | `GET /v4/releases/{releaseId}` | path only; explicit handoff ID | `agentRelease` with `state=published` and selected Card identity | Trusted Publication v1 error or client identity mismatch | +| Install | `POST /v3/workspaces/{workspaceId}/installations` | Installation v2 request | Installation v2 | Platform Error v3/v4 as active route defines | +| Invoke JSON/SSE | `POST /v4/workspaces/{workspaceId}/invocations` | Invocation v4 request | Result v1 / Stream Event v2 | Platform Error v4 | +| Read Invocation/Trace | `GET /v4/workspaces/{workspaceId}/invocations/{id}` / `traces/{id}` | path only | Invocation/Trace metadata | Platform Error v4 | + +## Validation Rules + +- All success payloads reject unknown fields and missing required fields. +- Path identifiers are encoded and validated against the active identifier + grammar before a request is made. +- The error-body `traceId` is authoritative. If `x-nek-trace-id` is present, + it must equal the error-body `traceId`; absence remains valid because the + active OpenAPI does not require the header. +- Mutating operations do not retry, redirect, switch origins, or send challenge + proof in a request body. +- Catalog search does not expose Release IDs and the active Installation request + accepts only an Agent/version constraint. The Console therefore requires an + explicit Release-ID handoff, reads it before installation, submits the exact + returned Card version as the constraint, and rejects a returned + `installedReleaseId` that differs from the preflight ID. +- The active Card, Release, Installation, Invocation, and Trace IDs must agree + across related views before the UI renders a trusted success state. diff --git a/specs/027-console-trusted-publication/data-model.md b/specs/027-console-trusted-publication/data-model.md new file mode 100644 index 0000000..2348f8a --- /dev/null +++ b/specs/027-console-trusted-publication/data-model.md @@ -0,0 +1,113 @@ +# Data Model: Console Trusted Publication Loop + +The platform remains the source of truth. These are Console view models and +the backend acceptance facts they consume; they are not new database tables. + +## Console View Models + +### EndpointBinding + +| Field | Meaning | Validation | +| --- | --- | --- | +| `bindingId` | Immutable binding identity | Trusted Publication identifier | +| `providerId` | Provider principal owning the binding | Must match configured provider for provider operations | +| `agentId` | Registered Agent identity | Must match selected Card | +| `agentCardVersion` | Exact Card version | Active semver | +| `endpoint` | Canonical endpoint | HTTPS/HTTP URI returned by Gateway; never rewritten by UI | +| `verificationMethod` | Trust method | `http_well_known` | +| `verificationStatus` | `pending`, `verified`, `failed`, or `revoked` | Enum; no guessed state | +| `verificationFailureCode` | Safe server failure reason | Optional; display only | +| `verificationEvidenceDigest` | Evidence digest | Optional lowercase 64-hex | +| timestamps | Created/updated/verified/revoked facts | Strict date-time parsing | + +### VerificationChallenge + +| Field | Meaning | Boundary | +| --- | --- | --- | +| `challengeId`, `bindingId` | Challenge identity and association | Must match selected Binding | +| `challengeUrl` | Server-issued verification URL | Display/copy only; no browser fetch to Agent | +| `proof` | One-time proof returned by Gateway | In-memory transient display only; never stored or sent to another Gateway route | +| `expiresAt` | Expiration fact | Strict date-time; expired challenge cannot be reused | + +### AgentRelease + +| Field | Meaning | Validation | +| --- | --- | --- | +| `releaseId` | Immutable publication identity | Trusted Publication identifier | +| `providerId`, `agentId`, `agentCardVersion` | Release ownership and exact version | Must match selected Card/Binding | +| `cardDigest` | Immutable Card content digest | Lowercase 64-hex | +| `endpointBindingId` | Trust evidence association | Must match selected Binding | +| `endpointOrigin`, `endpointPath` | Snapshot of endpoint provenance | Read-only display | +| `verificationMethod`, `verificationEvidenceDigest` | Trust evidence snapshot | Method enum and optional digest | +| `state` | `draft`, `pending_verification`, `verified`, `published`, `suspended`, `revoked` | Server-defined transition matrix | +| lifecycle timestamps | State transition evidence | Strict date-time | + +### Workspace Installation + +Existing Installation v2 fields remain authoritative. Console adds the optional +`installedReleaseId` to its mapping and displays it whenever present. A trusted +installation must not be displayed as fully valid when the response has a +Card/version mismatch or an invalid Release identity. + +Before submitting an installation, the Console requires an explicit Release ID +handoff and reads that Release through the Gateway. The preflight must return a +published Release whose Agent ID, Card version, and immutable provenance match +the selected Catalog Card. The active Installation request still sends only the +Agent ID, exact version constraint, and accepted permissions; after the request, +`installedReleaseId` must equal the preflight Release ID. The Console does not +infer a Release from Catalog `publicationStatus`. + +### Invocation and Trace + +Existing Invocation Result v1, Result Stream Event v2, Invocation Event 0.3, +and Gateway v4 metadata fields remain authoritative. The Console keeps: + +```text +invocationId +rootTaskId +parentInvocationId +traceId +workspaceId +targetAgentId +agentCardVersion +agentReleaseId / release provenance when returned +status / error code +``` + +It does not store invocation input/output as Ledger data and does not construct +missing child nodes locally. + +## State Transition Ownership + +```text +Endpoint Binding: + pending -> verified | failed | revoked + +Release: + pending_verification -> verified + verified -> published | suspended | revoked + published -> suspended | revoked + suspended -> revoked + +Installation: + enabled <-> disabled + disabled -> uninstalled +``` + +The Console renders server responses and only enables actions allowed by the +known state. It never invents a reverse transition or a recovery transition not +defined by the active contract. + +## Secret and Persistence Rules + +- Bearer configuration is read from explicit environment values and held in the + provider or owner API client instance; provider and owner tokens are not + interchangeable, are not exposed as public getters, and are not stored in + browser storage. The required names are `VITE_NEKIRO_PROVIDER_ID`, + `VITE_NEKIRO_PROVIDER_TOKEN`, `VITE_NEKIRO_OWNER_TOKEN`, and + `VITE_NEKIRO_DEFAULT_WORKSPACE_ID`. +- Challenge `proof` is held only in the component state needed to show the + one-time delivery result and is cleared when the binding flow is closed, + replaced, or the page unloads. +- No provider, Agent, Router, or application credential appears in Card JSON, + error objects, Ledger view models, test output, URLs, or logs. diff --git a/specs/027-console-trusted-publication/plan.md b/specs/027-console-trusted-publication/plan.md new file mode 100644 index 0000000..ac1ab24 --- /dev/null +++ b/specs/027-console-trusted-publication/plan.md @@ -0,0 +1,180 @@ +# Implementation Plan: Console Trusted Publication Loop + +**Branch**: `codex/027-console-trusted-publication` | **Date**: 2026-07-26 | **Spec**: [spec.md](spec.md) + +**Input**: Feature specification from `specs/027-console-trusted-publication/spec.md` + +## Summary + +Advance the existing production Glass 2.0 Console in its standalone upstream +repository, make its public Gateway client consume the active Trusted +Publication v1 contracts, add provider Release operations and release-aware +Workspace controls, then import the reviewed Console into the platform +repository as `apps/console` for root CI and a fresh-environment browser +acceptance. Extend the backend acceptance separately to prove the canonical +Runtime B -> Router -> Runtime A direction. + +The Console remains a thin northbound client. It does not own Agent Cards, +trust state, Release state, Workspace authorization, routing, or Ledger facts. +The browser calls only Gateway routes and keeps all configured credentials in +memory supplied through explicit environment configuration. Provider and +Workspace-owner credentials are separate client contexts, and provider identity +is supplied explicitly rather than inferred from an Agent Card. + +## Technical Context + +**Language/Version**: TypeScript with the existing React/Vite frontend; Go for the existing backend acceptance harness + +**Primary Dependencies**: Existing Console React, Vite, TailwindCSS, lucide-react, Motion, Node test runner, and the active NeKiro JSON/OpenAPI contracts; browser acceptance may use a repository-approved Playwright runner only if required by the existing CI environment + +**Storage**: No new storage. Console state is transient browser memory. Existing PostgreSQL Catalog, Workspace, and Router Ledger schemas remain authoritative. + +**Testing**: TypeScript typecheck, focused Node contract tests, production build, fresh Compose/PostgreSQL backend acceptance, and browser-level production-route acceptance + +**Target Platform**: Browser and Linux CI for the frontend; portable Go tests with Linux Compose for backend acceptance + +**Project Type**: React web application plus cross-service acceptance coverage + +**Performance Goals**: Existing Console interaction responsiveness; no new polling, retry, reconnect, background health loop, or result persistence is permitted + +**Constraints**: Gateway-only browser traffic; active contract versions only; strict response validation; no direct Agent, Router-internal, database, mock-production, localhost, token-trimming, legacy-publish, or alternate-endpoint fallback + +**Scale/Scope**: One Owner-only Workspace, explicit development-static principals, two independently implemented sample Agents, one production Console route, and four independently reviewable implementation slices + +## Constitution Check + +*GATE: Must pass before Phase 0 research. Re-check after Phase 1 design.* + +- **Phase 1 loop**: PASS. The Console makes Register -> Discover -> Install -> Invoke -> Record operational, while trusted publication supplies the required trust gate. +- **Ownership**: PASS. Console owns only presentation and request mapping; Catalog owns Card/Binding/Release facts, Workspace owns Installations, Router owns invocation transport and Ledger facts. +- **Runtime independence**: PASS. The UI and acceptance use protocol/contract fields only; Runtime A and Runtime B remain independent sample implementations. +- **Contracts**: PASS. No public contract is changed. The plan maps Trusted Publication v1, Card 0.2, Catalog/Northbound v3, Workspace/Installation v2, Invocation v4, Router metadata v3, Event 0.3, and stream result v2. Because Catalog search has no Release ID and Installation remains version-constraint based, Slice B uses an explicit Release-ID read preflight and validates the returned installed Release ID after installation. +- **Invocation lineage**: PASS. The UI validates and displays Gateway/Router-provided identifiers and the backend acceptance asserts one root Task/Trace with a parent child relationship. +- **Failure safety**: PASS. Invalid, missing, forbidden, dependency, lifecycle, timeout, cancellation, and stream protocol failures remain distinct. No retry, alternate endpoint, or silent success is added. +- **SDD traceability**: PASS. Every implementation and test task maps to a Spec story or requirement; tests are scheduled after the corresponding implementation. +- **Cross-runtime proof**: PASS. The reverse B -> Router -> A fixture is a separate task and acceptance gate, and the Console is runtime-neutral. + +**Post-design gate**: PASS. The design does not introduce a new contract, data owner, backend dependency, or fallback policy. + +## Research Decisions + +See [research.md](research.md) for the evidence and decisions. The important +decisions are: + +1. Implement Console Issues #2/#4/#3 in the standalone `NeKiro-Console` repository, which is the upstream frontend source. +2. Import the reviewed standalone Console source into `apps/console` in the platform repository for root pnpm discovery and deployment ownership; do not use a submodule or maintain a second hand-edited production implementation. +3. Add strict handwritten TypeScript mappings for Trusted Publication v1 rather than generating runtime objects or copying Go internals. +4. Use server reads after mutations. Do not optimistically advance Binding, Release, Installation, or Invocation state. +5. Treat the one-time challenge proof as transient response data only. Do not put it in local storage, URLs, logs, or later request bodies. +6. Extend the existing backend acceptance fixture for B -> Router -> A rather than adding a second Compose stack or a runtime framework dependency. +7. Require explicit browser configuration names: `VITE_NEKIRO_PROVIDER_ID`, `VITE_NEKIRO_PROVIDER_TOKEN`, `VITE_NEKIRO_OWNER_TOKEN`, and `VITE_NEKIRO_DEFAULT_WORKSPACE_ID`; the previous single-token configuration is not a compatibility input. +8. Use `GET /v4/releases/{releaseId}` as the Workspace installation handoff. The UI must receive or ask for the Release ID, preflight its published state and exact Card identity, submit an exact version constraint, and reject an installation response with a different Release ID. + +## Data and Contract Design + +The data ownership and frontend view mapping are documented in +[data-model.md](data-model.md). The active endpoint and response mapping matrix +is documented in [contracts/console-gateway-mapping.md](contracts/console-gateway-mapping.md). + +No migration or contract version bump is planned. The active database uniqueness +rule permits one immutable Release per Agent/Card version, so the Release-ID +preflight plus post-install equality check supplies provenance without changing +the Installation request. If implementation discovers that an active Gateway +route cannot support this flow, work must stop and return to the Spec/ADR +process rather than adding a browser fallback. + +## Project Structure + +### Documentation + +```text +specs/027-console-trusted-publication/ +|-- spec.md +|-- plan.md +|-- research.md +|-- data-model.md +|-- quickstart.md +|-- contracts/console-gateway-mapping.md +|-- checklists/requirements.md +`-- tasks.md +``` + +### Production Console in the platform repository + +```text +E:/Progarms/NeKiro-Console/ +|-- package.json +|-- src/ +| |-- App.tsx +| |-- api/nekiro.ts +| |-- api/nekiro.test.ts +| |-- components/ +| | |-- RegistryTab.tsx +| | |-- TrustedPublicationTab.tsx +| | |-- InstallationsTab.tsx +| | |-- InvocationsTab.tsx +| | `-- LedgerTab.tsx +| |-- types.ts +| `-- demos/ +`-- e2e/ + +E:/Progarms/NeKiro/apps/console/ # reviewed source imported for platform CI +``` + +### Backend acceptance + +```text +tests/e2e/invoke-record/invoke_record_test.go +specs/027-console-trusted-publication/docs/reverse-lineage.md (if needed) +``` + +**Structure Decision**: Keep the platform backend and Router boundaries +unchanged. Console Issues #2/#4/#3 write to the standalone Console repository; +after each reviewed slice, the resulting source is imported into the platform +repository as a normal `apps/console` workspace package so existing root CI +commands discover real frontend scripts. The imported copy is generated from +the reviewed upstream source and is not a second hand-edited production path. +Add only the backend acceptance fixture required to prove the reverse nested +direction. + +## PR and Review Slices + +| Slice | Upstream Issue | Owner boundary | Independent completion | +| --- | --- | --- | --- | +| A | Console #2 | standalone `NeKiro-Console/src/api`, API tests, trusted transport validation | All active Trusted Publication requests and strict response/error mappings pass focused tests | +| B | Console #4 | standalone `NeKiro-Console/src/App.tsx`, API client configuration, and production components | Provider can complete Binding/Challenge/Release lifecycle; Workspace owner preflights an explicit published Release and installation validates the exact returned Release ID | +| C | NeKiro #60 | backend E2E fixture only | Runtime B -> Router -> Runtime A returns with one correlated root/child lineage and provenance | +| D | Console #3 / parent #59 | standalone Console CI/browser acceptance plus reviewed import to `apps/console` and root CI | CI executes real typecheck/tests/build/browser acceptance; live production route proves the loop | + +Each slice is implemented on its own branch/PR. Before coding a slice, a +separate subagent must read the Issue and this Spec and report scope, +dependencies, and forbidden fallback behavior. After implementation, an +independent review agent must review the diff against Issue, Spec, Plan, Tasks, +contracts, and constitution. A failing review returns the slice to Tasks and +requires a fresh review after fixes. + +## Complexity Tracking + +No constitution violation requires justification. The additional repository +package is required because root CI currently uses a workspace-wide no-op when +no frontend package is present; importing the existing production Console into +`apps/console` removes that false green without changing platform service +ownership. + +## Verification Commands + +```text +pnpm install --frozen-lockfile +pnpm typecheck +pnpm test +pnpm build +go test ./... +go test -tags=e2e -count=1 ./tests/e2e/invoke-record +go vet ./... +docker compose --file deploy/compose.yaml config --quiet +git diff --check +``` + +The browser acceptance additionally requires the explicit environment values +documented in [quickstart.md](quickstart.md). No command may substitute a +localhost, mock, or alternate endpoint when those values are missing. diff --git a/specs/027-console-trusted-publication/quickstart.md b/specs/027-console-trusted-publication/quickstart.md new file mode 100644 index 0000000..db07a6d --- /dev/null +++ b/specs/027-console-trusted-publication/quickstart.md @@ -0,0 +1,84 @@ +# Quickstart: Console Trusted Publication Loop + +## Prerequisites + +- Node and pnpm versions required by the root `package.json`. +- Go version from `go.mod`. +- Docker Engine with Compose v2 for the fresh backend environment. +- Explicit, non-empty environment values for Gateway origin, provider ID, + separate provider/owner bearer values, one Workspace, and the existing + acceptance secrets. +- A sample endpoint that can serve the configured trusted-publication challenge. + +Do not use localhost, a wildcard origin, an empty token, a trimmed token, a +mock Gateway, SQL state mutation, direct Agent calls, retry, reconnect, or an +alternate endpoint to satisfy a missing prerequisite. + +## Frontend checks + +From the platform repository: + +```text +pnpm install --frozen-lockfile +pnpm typecheck +pnpm test +pnpm build +``` + +The commands must discover `apps/console` and execute its non-empty scripts. + +From the standalone Console repository, the same focused commands remain: + +```text +npm install --no-package-lock +npm run typecheck +npm run lint +npm test +npm run build +``` + +Slice B review evidence: the independent post-fix review reported `PASS` with +no actionable High/Medium findings. The Console test command includes API, +policy, and production component static-render checks. + +Standalone implementation PR: https://github.com/NeKiro-project/NeKiro-Console/pull/6 + +## Live Console walkthrough + +1. Configure the exact Gateway origin, `VITE_NEKIRO_PROVIDER_ID`, + `VITE_NEKIRO_PROVIDER_TOKEN`, `VITE_NEKIRO_OWNER_TOKEN`, and + `VITE_NEKIRO_DEFAULT_WORKSPACE_ID`. The old single `VITE_NEKIRO_TOKEN` + input is not accepted. +2. Open the production Console route, create or load one Workspace, and keep + the Workspace identity explicit. +3. Register an Agent Card and inspect the returned draft. +4. Create an Endpoint Binding, issue a Challenge, and confirm the endpoint has + served the challenge material. +5. Complete the Challenge and read the Binding until the server returns + `verified`. +6. Create a Release for the exact Card version and Binding, verify it, and + publish it. +7. Discover the published Agent. Select or enter the provider handoff Release + ID, read it through the Gateway, verify `state=published`, exact Card + version, Card digest, Binding, and endpoint provenance, then install the + exact version into the Workspace. Confirm the returned + `installedReleaseId` is the same ID before showing success. +8. Install the second sample Agent, invoke B through Gateway, and inspect the + JSON/SSE result and Invocation/Trace metadata. +9. Open the Trace and verify the B root and A child share the root Task/Trace + and preserve the parent Invocation relationship. + +## Backend reverse-lineage acceptance + +Use the existing clean Compose environment variables from +`specs/026-trusted-publication-acceptance/quickstart.md`, then run: + +```text +docker compose --project-name nekiro-console-acceptance --file deploy/compose.yaml down --volumes --remove-orphans +docker compose --project-name nekiro-console-acceptance --file deploy/compose.yaml up --build --detach --wait --wait-timeout 120 +go test -tags=e2e -count=1 ./tests/e2e/invoke-record +docker compose --project-name nekiro-console-acceptance --file deploy/compose.yaml down --volumes --remove-orphans +``` + +The acceptance must include the reverse B -> Router -> A lineage and must +report exact Release IDs, parent/child Invocation IDs, and Trace IDs on failure. diff --git a/specs/027-console-trusted-publication/research.md b/specs/027-console-trusted-publication/research.md new file mode 100644 index 0000000..8375d6c --- /dev/null +++ b/specs/027-console-trusted-publication/research.md @@ -0,0 +1,82 @@ +# Research: Console Trusted Publication Loop + +## Existing Evidence + +### Console source + +The cloned `NeKiro-Console` repository already contains the production Glass +2.0 route and isolated `#/demo`, `#/demo/glass`, `#/demo/terminal`, and +`#/demo/saas` routes. Its live client already implements strict-ish mappings for +Agent Card 0.2, Catalog v3, Workspace/Installation v2, Gateway Invocation v4, +Invocation/Trace reads, JSON results, and SSE results. Its production forms use +explicit environment values and do not persist the bearer token in local +storage. + +The missing product behavior is the Trusted Publication v1 client and UI. The +current Registry action calls the legacy Catalog publish/disable route. That is +not an acceptable trust path for this Spec and must be replaced or hidden for +the production flow; no compatibility fallback is allowed. + +### Active Gateway routes + +The platform repository exposes the following public routes through the Gateway: + +- `POST/GET /v4/providers/{providerId}/agents/{agentId}/endpoint-bindings` +- `GET /v4/providers/{providerId}/endpoint-bindings/{bindingId}` +- `POST /v4/providers/{providerId}/endpoint-bindings/{bindingId}/challenges` +- `POST /v4/providers/{providerId}/endpoint-bindings/{bindingId}/challenges/{challengeId}/complete` +- `POST /v4/providers/{providerId}/agents/{agentId}/releases` +- `GET /v4/releases/{releaseId}` +- `POST /v4/releases/{releaseId}/verify|publish|suspend|revoke` + +Responses use strict additional-property-free Trusted Publication v1 shapes and +stable error codes. The routes return a trace header and safe error payload; the +browser must not read internal Router or Catalog routes. + +### Backend acceptance + +The existing clean Compose acceptance proves trusted Register -> Verify -> +Publish -> Install -> Invoke -> Record and an A -> Router -> B nested path. +The requested flow needs the reverse call direction as a separately asserted +fixture. The existing Agent SDK already supports nested calls and the Router +already owns correlation and credentials, so the acceptance should change the +fixture request/expected target direction rather than add a new runtime. + +### CI behavior + +Root `package.json` runs workspace scripts recursively with `--if-present`. +Because `apps/console` is currently absent, root `pnpm typecheck`, `pnpm test`, +and `pnpm build` can succeed without running a production frontend. After the +standalone Console slices are reviewed, importing the resulting source with a +real `apps/console/package.json` makes discovery observable. Browser acceptance +must be an explicit job or test command and must fail if its required +Gateway/Workspace configuration is absent. + +## Decisions + +| Question | Decision | Evidence / reason | +| --- | --- | --- | +| Where is production Console source? | `apps/console` in the platform repo | AGENTS.md target structure and root pnpm workspace/CI contract | +| How are trust operations called? | Existing public Gateway Trusted Publication v1 routes | Gateway handlers and OpenAPI/schema in `contracts/` | +| What is the Card publication authority? | Trusted Release state, not legacy Catalog publish | Trusted Release lifecycle and Installation gates already merged | +| How is challenge proof handled? | Transient memory only, never persisted or echoed into later requests | Trust contract returns it once; constitution forbids secret persistence/leakage | +| How are mutations reflected? | Use returned value and explicit follow-up read where a view depends on authoritative state | Avoid optimistic/fabricated state and stale destructive controls | +| How is reverse nesting proven? | Extend the existing single acceptance fixture | Avoid a second stack and preserve one Ledger/Trace authority | +| Is a new fallback allowed? | No | Constitution VII and Spec FR-012; missing policy is not a reason to continue | +| What is the trace header rule? | Error-body `traceId` is authoritative; an optional `x-nek-trace-id` header must match when present | The active OpenAPI declares the header but not its requiredness or equality semantics; accepting absence preserves compatibility while rejecting disagreement prevents ambiguous correlation | +| Where does Slice A write? | Standalone `NeKiro-Console` | Issue #2 is filed in that repository; the reviewed source is imported into `apps/console` only in Slice D | + +## Rejected Alternatives + +- **Keep the standalone Console as a CI submodule**: rejected because root + workspace CI would not own or reliably discover the production package, and + it would create a second repository synchronization boundary. +- **Continue using Catalog publish for a fast UI**: rejected because it would + expose an untrusted publication path and violate the immutable Release gate. +- **Poll endpoint health or automatically retry challenge/invocation**: + rejected because no product/SLO policy authorizes those behaviors. +- **Add a new backend endpoint for the Console**: rejected because current + Gateway routes already cover the required operations and no contract gap is + evidenced. +- **Use optimistic UI state**: rejected because a failed or concurrent server + transition must remain visible and authoritative. diff --git a/specs/027-console-trusted-publication/spec.md b/specs/027-console-trusted-publication/spec.md new file mode 100644 index 0000000..7a3696c --- /dev/null +++ b/specs/027-console-trusted-publication/spec.md @@ -0,0 +1,176 @@ +# Feature Specification: Console Trusted Publication Loop + +**Feature Branch**: `codex/027-console-trusted-publication` + +**Created**: 2026-07-26 + +**Status**: Draft + +**Input**: User request: advance `Agent A -> register -> NeKiro URL -> installer -> B -> Router -> A Agent` and provide a trustworthy Console operations surface, with upstream Issues and independently reviewed PR slices. + +## Clarifications + +### Session 2026-07-26 + +- Q: How are provider identity and browser credentials selected? -> A: Use explicit `VITE_NEKIRO_PROVIDER_ID`, `VITE_NEKIRO_PROVIDER_TOKEN`, `VITE_NEKIRO_OWNER_TOKEN`, and `VITE_NEKIRO_DEFAULT_WORKSPACE_ID`; never infer identity from Card owner fields or reuse one token across provider and Workspace-owner operations. +- Q: How does a Workspace owner review an exact Release when Catalog search has no Release ID and Installation accepts only a version constraint? -> A: Require an explicit Release ID handoff in the Console, read it through `GET /v4/releases/{releaseId}`, require `state=published` and exact Agent/Card provenance, install with an exact version constraint, then require the Installation response `installedReleaseId` to match the preflight Release. Do not add a new backend contract in Slice B. + +## User Scenarios & Testing + +### User Story 1 - Provider registers and proves an Agent endpoint (Priority: P1) + +An Agent provider can use the production Console to submit a versioned Agent Card, create an endpoint binding, issue a one-time verification challenge, inspect its current status, and complete verification against the declared endpoint. The Console makes the current state and exact failure category visible so the provider does not need SQL, direct Agent access, or an internal service URL. + +**Why this priority**: Trusted publication is the trust boundary that makes an installed Agent safe to invoke. A draft that can be published without endpoint ownership proof is not an acceptable Phase 1 user workflow. + +**Independent Test**: Against a fresh platform environment and a reachable sample endpoint, complete registration, binding creation, challenge issuance, challenge completion, and read back a verified binding from the Console. + +**Acceptance Scenarios**: + +1. **Given** an Agent Card that passes the active Card contract, **when** the provider submits it, **then** the Console shows the server-returned draft with its identity, version, capabilities, and no fabricated publication state. +2. **Given** a registered Card and an explicit endpoint, **when** the provider creates a binding, **then** the Console shows the binding ID, canonical endpoint, verification method, and pending status. +3. **Given** a pending binding, **when** the provider issues a challenge, **then** the Console shows the challenge ID, challenge URL, and expiry, keeps the proof only in transient page state, and does not persist it in browser storage, logs, or unrelated requests. +4. **Given** an eligible pending challenge whose endpoint serves the expected proof, **when** the provider selects verify, **then** the Console refreshes the binding and shows verified status with the evidence digest. +5. **Given** an invalid, expired, reused, disallowed, or unavailable verification attempt, **when** the operation fails, **then** the Console preserves the exact trusted-publication error code, HTTP status, trace ID, and next action without converting the binding to verified. + +### User Story 2 - Provider creates and controls an immutable Release (Priority: P1) + +After endpoint verification, a provider can create a Release for the exact Agent Card version and binding, read its immutable provenance, publish it, and intentionally suspend or revoke it. The Console presents the allowed state transitions and disables actions that the server would reject. + +**Why this priority**: A verified endpoint is evidence for a specific version and endpoint binding; it is not itself a discoverable or installable Release. Explicit Release lifecycle controls make that distinction visible and auditable. + +**Independent Test**: Create a Release from a verified binding, verify and publish it, read it back, then suspend and revoke it while observing each returned state and provenance field. + +**Acceptance Scenarios**: + +1. **Given** a registered Card and pending or verified binding, **when** the provider creates a Release, **then** the Console shows the server-assigned Release ID, Card digest, binding ID, endpoint origin/path, verification method, and state. +2. **Given** a pending Release and a now-verified binding, **when** the provider selects verify, **then** the Release becomes verified only after the server confirms the current binding evidence. +3. **Given** a verified Release, **when** the provider selects publish, **then** discovery exposes the Agent version as available and the Console no longer offers an in-place edit of its immutable provenance. +4. **Given** a verified or published Release, **when** the provider selects suspend or revoke and confirms the destructive action, **then** the Console displays the resulting state and explains that recovery requires a deliberate provider operation; it does not silently republish or unsuspend. +5. **Given** an invalid state transition or a Release owned by another principal, **when** the operation fails, **then** the Console displays the exact server error and leaves the previous state unchanged. + +### User Story 3 - Workspace owner installs only trusted Releases (Priority: P1) + +A Workspace owner can discover published Agents, review declared permissions and exact Release provenance, install the selected version into the current Workspace, and enable, disable, or uninstall the resulting Installation. The installation view distinguishes the installed Release ID from the Card version. + +**Why this priority**: Installation is the authorization handoff from provider trust to Workspace use. The owner must be able to see what exact immutable Release was accepted. + +**Independent Test**: Discover a published verified Agent, install it into a fresh Workspace with explicit permissions, read the Installation, disable and re-enable it, and verify that unpublished, suspended, revoked, or untrusted candidates remain unavailable. + +**Acceptance Scenarios**: + +1. **Given** a published trusted Release ID supplied by the provider or deployment handoff, **when** the owner opens installation, **then** the Console reads the Release through the Gateway, shows its exact Card version, immutable provenance, declared permissions, and the permissions that will be accepted before enabling installation. +2. **Given** a pending, unpublished, suspended, or revoked Release, **when** the owner attempts installation, **then** the Console shows the exact lifecycle error and does not create a successful-looking Installation. +3. **Given** an enabled Installation, **when** the owner disables it, **then** the Console shows disabled status and the invocation surface prevents a new call from being submitted through that Installation. +4. **Given** a disabled Installation, **when** the owner re-enables it or uninstalls it, **then** the Console displays the returned status and does not invent an enabled or removed state before the Gateway response arrives. + +### User Story 4 - User invokes through Router and inspects the complete lineage (Priority: P1) + +A Workspace user invokes an installed Agent through the public Gateway, receives a validated JSON or SSE result, and opens the resulting Invocation or Trace metadata. The visible flow proves that Agent B can call Agent A through the Router and return the result through the original caller without direct endpoint access. + +**Why this priority**: The product proof is the complete `Register -> Verify -> Publish -> Discover -> Install -> Invoke -> Record` loop, including a cross-runtime nested call that remains governed and traceable. + +**Independent Test**: With two independently implemented sample Agents installed in one Workspace, invoke B, have B call A through the Agent SDK and Router, and inspect one Trace containing the root and child Invocation with distinct Release provenance. + +**Acceptance Scenarios**: + +1. **Given** an enabled trusted Installation, **when** the user submits a JSON invocation, **then** the result contains the returned invocation, root Task, and Trace identifiers and no endpoint, Router, or Agent credential is entered in the form. +2. **Given** an enabled streaming Installation, **when** the user submits an SSE invocation, **then** the Console enforces accepted-first, contiguous sequence, stable correlation, and terminal-event semantics before showing completion. +3. **Given** Agent B receives a managed request and needs Agent A, **when** B uses the Agent SDK, **then** the child request traverses the Router, preserves the root Task, Trace, and parent Invocation relationship, and returns through B to the original Gateway caller. +4. **Given** a timeout, cancellation, unavailable endpoint, disabled Installation, suspended or revoked Release, or malformed stream, **when** the operation fails, **then** the Console shows the exact failure category and related trace/invocation identifiers where available; it does not display fabricated success data or retry. +5. **Given** a completed nested invocation, **when** the user opens its Trace, **then** the Console shows both root and child records and the child points to its parent Invocation without exposing result payloads as Ledger facts. + +### User Story 5 - Operator verifies a real production frontend (Priority: P2) + +The repository maintainers can run the Console's typecheck, focused contract tests, production build, and a browser-level acceptance against an explicit Gateway origin. Comparison demos remain available but are separate from the production route and never substitute for live API evidence. + +**Why this priority**: A trustworthy operational surface must be reproducible in CI. A green job that matches no frontend project or exercises only mock demos is not evidence of the user workflow. + +**Independent Test**: Run the frontend checks and a fresh-environment browser acceptance that completes the visible trusted lifecycle and reads the resulting invocation lineage. + +**Acceptance Scenarios**: + +1. **Given** an explicit frontend project configuration, **when** repository CI runs, **then** typecheck, tests, build, and browser acceptance execute against the production Console. +2. **Given** a `#/demo`, `#/demo/glass`, `#/demo/terminal`, or `#/demo/saas` URL, **when** it is opened, **then** it renders comparison content using mock data without being mistaken for a production API result. +3. **Given** missing, blank, whitespace-containing, credential-bearing, wildcard, or otherwise invalid browser configuration, **when** the Console starts or submits a request, **then** it fails explicitly and does not fall back to localhost, another endpoint, trimmed credentials, local storage, retry, reconnect, or legacy publish routes. + +### Edge Cases + +- A binding or Release response contains unknown fields, missing required fields, inconsistent IDs, an invalid timestamp, or a Card/Release mismatch; the client rejects it as an invalid response. +- A challenge is expired or already used while the user has the page open; the Console requires an explicit fresh challenge and does not resubmit the old challenge. +- The endpoint redirects, resolves to a disallowed network, is unreachable, or serves the wrong proof; the error remains visible as its exact category. +- A Release is pending, verified, published, suspended, or revoked while the page has stale data; a successful mutation is followed by a server read before actions are re-enabled. +- An Installation response omits `installedReleaseId` for a trusted installation or returns a Card version that differs from the selected version; the client rejects the inconsistent response. +- A Release preflight is missing, not found, not published, or does not match the selected Catalog Card; the Console blocks installation and preserves the exact Gateway error without attempting a version-only install. +- An SSE response ends before a terminal event, emits a duplicate or reordered event, changes correlation, or emits data after terminal; the client reports interruption/protocol failure. +- A request is canceled after the Gateway has assigned an Invocation; the UI does not replace the canceled result with a later success and can inspect the durable Ledger fact separately. +- Provider and Workspace owner credentials are missing or belong to the wrong principal; the operation fails at the Gateway boundary and the Console does not attempt another credential. +- Browser refresh occurs after a challenge is issued or a mutation starts; no challenge proof, token, or pending success is restored from storage. + +## Requirements + +### Functional Requirements + +- **FR-001**: The production Console MUST use only public Gateway routes for registration, discovery, trusted publication, Workspace, Installation, invocation, and Ledger operations. +- **FR-002**: The production Console MUST expose Endpoint Binding creation/read, Challenge issuance/completion, Release creation/read, verification, publication, suspension, and revocation using the active Trusted Publication v1 semantics. +- **FR-003**: The Console MUST model Binding and Release state from server responses and MUST not use Catalog publication as a substitute for trusted Release publication. +- **FR-004**: The Console MUST retain challenge proof only in transient memory for the minimum operation display and MUST never persist provider, Workspace, Agent, Router, or challenge credentials in local storage, URLs, logs, or unrelated requests. +- **FR-005**: The Console MUST preserve the exact trusted-publication HTTP status, stable error code, and error-body trace ID for failed operations; if an `x-nek-trace-id` response header is present, it MUST agree with the error-body trace ID, while a missing header remains valid under the active OpenAPI contract. Static recovery guidance belongs to the UI and MUST NOT be represented as a fabricated server field. +- **FR-006**: Release controls MUST enforce the server-defined immutable lifecycle and MUST not add in-place edits, automatic republish, unsuspend, revoke reversal, retry, or alternate endpoint behavior. +- **FR-007**: Workspace installation MUST require an explicit published Release preflight, show its immutable provenance, submit explicit accepted permissions and an exact version constraint, and distinguish installed Card version from installed immutable Release identity. The client MUST reject a successful-looking Installation whose `installedReleaseId` does not match the preflight Release. +- **FR-008**: The Console MUST reject an installation, invocation, or ledger response whose required fields, identity relationships, status transitions, timestamps, or correlation fields are missing or inconsistent. +- **FR-009**: The Console MUST support JSON and SSE invocation through Gateway v4 and preserve the active validation rules for correlation, sequence, chunk order, terminal status, and failure categories. +- **FR-010**: The Console MUST display Invocation and Trace metadata from Gateway responses, including nested parent/child lineage and Release provenance when returned, without fabricating records or persisting result payloads as Ledger data. +- **FR-011**: The platform acceptance MUST include a canonical cross-runtime path in which Agent B invokes Agent A through the Router and returns through the original Gateway request, with one correlated Trace. +- **FR-012**: Frontend configuration MUST require an exact Gateway origin, explicit `VITE_NEKIRO_PROVIDER_ID`, provider bearer, owner bearer, and Workspace identity as applicable; invalid or missing values MUST fail at the owning boundary without inferring identity, sharing credentials across principals, localhost, wildcard, trimming, storage, retry, reconnect, or legacy fallback. +- **FR-013**: Comparison demo routes MUST remain available and visually distinct from the production route; demo mock data MUST not be used by production operations or acceptance evidence. +- **FR-014**: Focused frontend tests MUST cover request construction, strict response mapping, trusted lifecycle transitions, exact error preservation, JSON/SSE validation, correlation, and secret absence. +- **FR-015**: Repository automation MUST execute frontend typecheck, focused tests, production build, and browser acceptance against an explicit fresh environment, and MUST fail when no production frontend is discovered. +- **FR-016**: Documentation MUST describe the provider trust workflow, Workspace installation workflow, B-to-A nested invocation, credential boundaries, failure categories, and recovery ownership without recommending SQL or direct Agent access. +- **FR-017**: The implementation MUST be split into independently reviewable PR scopes, and each scope MUST have an upstream Issue, mapped acceptance evidence, a pre-implementation Issue review by a subagent, and an independent post-implementation code review before the next scope starts. + +### Key Entities + +- **Endpoint Binding**: A provider-owned association between an Agent Card version and a canonical endpoint, with verification method, state, failure code, and evidence timestamps/digest. +- **Verification Challenge**: A one-time, expiring server-issued challenge used to prove endpoint control; its proof is transient UI data and not a persisted Console credential. +- **Agent Release**: An immutable, version-specific publication record linking a Card digest, endpoint binding, verification evidence, and lifecycle state. +- **Workspace Installation**: A Workspace owner's explicit authorization for an exact Agent version/Release and accepted permission set. The Console obtains the Release identity through an explicit preflight handoff because the active Installation request remains version-constraint based. +- **Invocation Lineage**: Gateway, Router, Invocation, Task, Trace, parent Invocation, and Release metadata that connects a root request to nested Agent calls. +- **Console Operation State**: Server-derived loading, success, validation failure, conflict, dependency failure, and recovery-required states shown for each operation. + +### Runtime/Platform Boundary + +- **Platform-owned behavior**: Agent Card registration, endpoint trust, immutable Release lifecycle, discovery, Workspace authorization, Router-mediated invocation, and append-only lineage are owned by NeKiro services and their versioned contracts. +- **Console-owned behavior**: Request forms, transient operation state, strict contract mapping, safe error presentation, confirmation of destructive lifecycle actions, and navigation are owned by the Console; it does not decide trust or authorization. +- **Runtime-owned behavior**: Agent model calls, prompts, tools, workflows, memory, and runtime-specific execution remain in the independently implemented Agents and are not added to the Console or platform core. +- **Cross-runtime proof**: Runtime B and Runtime A use only A2A/Agent SDK boundaries for the nested call; the Console supplies no endpoint or runtime-specific type and the resulting Trace contains both Releases. + +## Success Criteria + +### Measurable Outcomes + +- **SC-001**: A provider can complete registration, endpoint verification, and trusted Release publication from the production Console in one fresh environment without SQL or direct Agent requests. +- **SC-002**: A Workspace owner can discover and install two published trusted Agents with explicit permissions, and 100% of installation records shown by the Console include the exact installed version and Release identity when the Gateway returns it. +- **SC-003**: The canonical B -> Router -> A invocation completes with one root Task and Trace, one parent/child Invocation relationship, and distinct Release provenance for both Agents. +- **SC-004**: 100% of covered invalid, expired, reused, forbidden, unavailable, disabled, suspended, revoked, timeout, cancellation, and interrupted-stream cases retain their exact stable error/failure category and never become fabricated success data. +- **SC-005**: Secret scans across source, test fixtures, browser storage, request bodies, visible response state, logs, and CI artifacts find zero bearer tokens, Router credentials, Agent credentials, or persisted challenge proofs outside the explicit one-time challenge delivery boundary. +- **SC-006**: Every frontend CI run executes and passes typecheck, focused tests, production build, and browser acceptance against the production route; a missing project or skipped test is a failing condition. +- **SC-007**: Independent reviews for every PR scope report zero High or Medium findings before the next scope is implemented, and all review findings are either fixed or returned to the Spec/Tasks with explicit policy. +- **SC-008**: The four live user stories can be demonstrated from the Console using only Gateway URLs, with comparison demos remaining isolated and no production operation reading mock data. + +## Assumptions + +- Trusted Publication v1, Agent Card 0.2, Catalog/Northbound v3, Workspace/Installation v2, Northbound Invocation v4, Router metadata v3, Invocation Event 0.3, and Result Stream Event v2 remain the active contracts. +- Backend trusted publication routes and the existing clean Compose acceptance are already available from upstream main; this feature consumes them unless a separate Issue identifies a missing contract or acceptance gap. +- Phase 1 uses explicit development-static provider/owner bearer values and one active Workspace; credential issuance, rotation, OIDC, RBAC, and multi-Workspace administration remain outside this feature. +- The provider's Agent endpoint is responsible for serving the challenge material; the Console can issue and complete a challenge but does not deploy or modify the Agent. +- The existing production Console repository remains the source of frontend code for Console Issues #2/#4/#3. The platform repository owns the SDD artifacts, upstream coordination, the integrated `apps/console` CI package, and the backend acceptance Issue #60. + +## Non-Goals + +- New backend trust, installation, invocation, or Ledger semantics without a separate contract/ADR and Issue. +- Agent deployment, endpoint health polling, automatic repair, retry, reconnect, alternate endpoint selection, automatic Release recovery, or fallback publication. +- Credential issuance, secret management, browser identity federation, complete RBAC, approval workflows, quotas, billing, rating, certification, or Marketplace ranking. +- LLM, prompt, tool, workflow, memory, RAG, session, or other Agent Runtime features. +- Persisting challenge proofs, invocation inputs/outputs, or arbitrary provider secrets in the Console. +- Replacing the production Console with one of the existing comparison demo routes. diff --git a/specs/027-console-trusted-publication/tasks.md b/specs/027-console-trusted-publication/tasks.md new file mode 100644 index 0000000..486e350 --- /dev/null +++ b/specs/027-console-trusted-publication/tasks.md @@ -0,0 +1,166 @@ +# Tasks: Console Trusted Publication Loop + +**Input**: Design documents from `specs/027-console-trusted-publication/` + +**Prerequisites**: `plan.md`, `spec.md`, `research.md`, `data-model.md`, and `contracts/console-gateway-mapping.md` + +**Tests**: Tests are scheduled after the corresponding approved implementation and map to the acceptance scenarios, failure semantics, or compatibility requirements in Spec 027. + +## Delivery protocol + +The four delivery slices are strictly gated and may not be started out of +order without an explicit dependency decision: + +1. Before implementing each upstream Issue, launch a fresh subagent with the + Issue URL, Spec 027, and the current diff/base. Record its scope, + dependencies, forbidden behavior, and expected acceptance evidence. +2. Implement only the reviewed Issue slice on its own branch/PR. +3. Run the mapped tests and checks. +4. Launch an independent review agent that did not implement the slice. It + must review the Issue, Spec, Plan, Tasks, active contracts, constitution, + security/secret boundary, and diff. +5. Fix all High/Medium findings, rerun relevant tests, and obtain a clean + review before starting the next slice. + +## Phase 1: Repository setup and source ownership + +- [ ] T001 [US5] Record the standalone Console source relationship and platform import ownership in `E:/Progarms/NeKiro/specs/027-console-trusted-publication/quickstart.md` and the platform delivery notes without creating a second live production path. +- [ ] T002 [P] [US5] Confirm the clean standalone Console baseline in `E:/Progarms/NeKiro-Console` and preserve its production route plus all four comparison demo routes as the source for Console Issues #2/#4/#3. +- [ ] T003 [P] [US5] Confirm the root workspace/CI import target `E:/Progarms/NeKiro/apps/console` and lockfile strategy; do not modify root business code before the standalone slices are reviewed. +- [ ] T004 [P] [US5] Record the trace-body/header policy in the contract mapping: body `traceId` is authoritative, an optional header must match, and missing header remains valid under the active OpenAPI. + +## Phase 2: Slice A - Trusted Publication Gateway client (Console Issue #2) + +**Goal**: Give the production Console strict, Gateway-only request and response mappings for Binding, Challenge, and Release operations. + +**Pre-implementation gate**: + +- [ ] T005 [US1] Launch a subagent to read Console Issue #2, Spec 027 FR-001 through FR-005/FR-012/FR-014, the Trusted Publication v1 OpenAPI/schema, and the current client. Record the exact route matrix, ownership boundary, secret rules, and any ambiguity before touching `apps/console/src/api`. + +### Implementation + +- [ ] T006 [US1] Add `EndpointBinding`, `VerificationChallenge`, `AgentRelease`, and Trusted Publication error code types to `E:/Progarms/NeKiro-Console/src/api/nekiro.ts` or a dedicated contract module, matching `contracts/schemas/trusted-publication.v1.schema.json` with no unknown-field acceptance. +- [ ] T007 [US1] Add strict request methods in `E:/Progarms/NeKiro-Console/src/api/nekiro.ts` for binding create/read, challenge issue/complete, Release create/read, and Release verify/publish/suspend/revoke using only the public `/v4` Gateway paths from `specs/027-console-trusted-publication/contracts/console-gateway-mapping.md`. +- [ ] T008 [US1] Add response validators and relationship checks in `E:/Progarms/NeKiro-Console/src/api/nekiro.ts` for required IDs, semver, URI, enum, lowercase digest, date-time, Card/Binding/Release identity, and trace/error consistency; treat the error-body trace as authoritative and reject an optional mismatched header; keep challenge proof out of logs, storage, and later request bodies. +- [ ] T009 [US1] Keep the standalone API contract types in `E:/Progarms/NeKiro-Console/src/api/nekiro.ts` or a dedicated API contract module; defer Release-aware Installation view-model changes in `src/types.ts` to Issue #4. + +### Tests + +- [ ] T010 [P] [US1] Add focused API contract tests in `E:/Progarms/NeKiro-Console/src/api/nekiro.test.ts` for every Trusted Publication request path, method, body, Authorization behavior, exact success mapping, unknown-field rejection, and ID relationship mismatch (Spec US1 acceptance scenarios 2-5, FR-002/FR-008/FR-012/FR-014). +- [ ] T011 [P] [US1] Add API failure tests in `E:/Progarms/NeKiro-Console/src/api/nekiro.test.ts` for `WRONG_PROOF`, `CHALLENGE_EXPIRED`, `CHALLENGE_REUSED`, `DISALLOWED_NETWORK`, `ENDPOINT_UNAVAILABLE`, `CONFLICT`, `FORBIDDEN`, invalid response bodies, absent trace headers, mismatched trace headers, and safe transport errors while asserting no secret leakage (Spec FR-005 and Edge Cases). +- [ ] T012 [US1] Run the Slice A typecheck and focused tests, scan source/test output for bearer values, challenge proof fixtures, and direct/internal URLs, and capture the Issue acceptance evidence. + +**Review gate**: + +- [ ] T013 [US1] Launch an independent review agent for Console Issue #2 against the Slice A diff, Spec 027, Plan, Tasks, Trusted Publication v1, and constitution; do not proceed to Slice B until it reports no High/Medium findings. + +## Phase 3: Slice B - Trusted publication and Release operations UI (Console Issue #4) + +**Goal**: Make the provider workflow and trusted Release-aware Workspace workflow operable in the production Console. + +**Pre-implementation gate**: + +- [x] T014 [US2] Launch a subagent to read Console Issue #4, Spec 027 US1-US3, FR-002 through FR-008/FR-012/FR-016, Slice A results, and the existing Registry/Installations components. Record explicit provider/owner credential contexts, Release-ID preflight semantics, UI state transitions, destructive confirmations, stale-state handling, and non-goals before editing components. + +### Implementation + +- [x] T015 [US1] Add `E:/Progarms/NeKiro-Console/src/components/TrustedPublicationTab.tsx` with Binding creation/read, Challenge issue/complete, Release create/read, and server-defined lifecycle actions; show exact state, trace/error, provenance, and recovery ownership. +- [x] T016 [US1] Update `E:/Progarms/NeKiro-Console/src/App.tsx` to use explicit provider and Workspace-owner API clients, load/select trusted publication records, require an explicit Release-ID preflight before installation, keep challenge proof transient, refresh authoritative reads after mutations, and route provider operations through the API client only. +- [x] T017 [US2] Update `E:/Progarms/NeKiro-Console/src/components/RegistryTab.tsx` so the production trust path no longer presents Catalog publish as equivalent to trusted Release publication; keep registration and discovery views clear about draft, trusted Release, and disabled states. +- [x] T018 [US2] Update `E:/Progarms/NeKiro-Console/src/components/InstallationsTab.tsx` and `E:/Progarms/NeKiro-Console/src/types.ts` to require a Gateway Release-ID preflight, display published immutable provenance before install and installed Release ID after install, submit an exact version constraint, reject mismatched installation provenance, and preserve exact disabled/uninstalled/lifecycle errors. +- [x] T019 [US2] Add explicit confirmation UI for suspend/revoke/uninstall, disable actions while an authoritative operation is pending, and avoid optimistic transitions, retry, reconnect, alternate endpoint, or local-storage state. + +### Tests + +- [x] T020 [P] [US1] Add component/API integration tests for pending -> verified Binding, challenge expiry/reuse/error presentation, and verified -> published Release transitions in `E:/Progarms/NeKiro-Console/src/` using server-shaped fixtures only; assert provider identity and provider/owner token separation. +- [x] T021 [P] [US2] Add component tests for Release-ID preflight, published-state/provenance matching, exact version installation, installed Release-ID mismatch rejection, suspend/revoke confirmation, disabled/enable/uninstall behavior, and no fabricated success on errors (Spec US2/US3 acceptance scenarios, FR-006/FR-007/FR-012). +- [x] T022 [US1] Run Slice B typecheck/build and secret/mock-route scans, then capture a production-route manual or automated walkthrough for registration through trusted publish and install. + +**Review gate**: + +- [x] T023 [US2] Launch an independent review agent for Console Issue #4 against the Slice B diff, Slice A review evidence, Spec 027, active contracts, UI ownership, and fallback/secret policy; do not proceed to Slice C until it reports no High/Medium findings. + +Slice B evidence: pre-implementation issue review completed; the first +post-implementation review found High/Medium issues and was followed by a +repair pass; the final independent review reported `PASS` with no actionable +High/Medium findings. Standalone checks passed: `npm run typecheck`, +`npm run lint`, `npm test` (32 tests), `npm run build`, and `git diff --check`. +Implementation PR: https://github.com/NeKiro-project/NeKiro-Console/pull/6 + +## Phase 4: Slice C - Canonical reverse cross-runtime acceptance (NeKiro Issue #60) + +**Goal**: Prove `Runtime B -> A2A Router -> Runtime A` and the returned root/child lineage using the existing clean Compose acceptance. + +**Pre-implementation gate**: + +- [ ] T024 [US4] Launch a subagent to read NeKiro Issue #60, Spec 027 US4, FR-009 through FR-011/FR-016, Specs 019-021/026, and the existing `tests/e2e/invoke-record/invoke_record_test.go`. Record the exact fixture direction, expected Release provenance, direct-access boundary, and forbidden fallback behavior. + +### Implementation + +- [ ] T025 [US4] Add the reverse nested-call fixture setup in `tests/e2e/invoke-record/invoke_record_test.go` using the existing Agent SDK, Router, and Runtime B sample without changing active contracts or exposing an Agent host port. +- [ ] T026 [US4] Add assertions in `tests/e2e/invoke-record/invoke_record_test.go` for B root -> A child `parentInvocationId`, one `rootTaskId`, one `traceId`, distinct exact Release IDs/Card digests, returned result correlation, and no direct endpoint access. + +### Tests + +- [ ] T027 [US4] Run the focused clean Compose E2E and existing backend unit/contract/race/vet gates required by the modified acceptance; preserve exact failures for unavailable endpoint, timeout, cancellation, and credential boundary cases. + +**Review gate**: + +- [ ] T028 [US4] Launch an independent review agent for NeKiro Issue #60 against the Slice C diff, Specs 019-027, active A2A/Router/Invocation contracts, and acceptance evidence; do not proceed to Slice D until it reports no High/Medium findings. + +## Phase 5: Slice D - Real frontend CI and browser acceptance (Console Issue #3 / parent #59) + +**Goal**: Make CI execute the real production Console and visibly prove the complete loop in a fresh environment. + +**Pre-implementation gate**: + +- [ ] T029 [US5] Launch a subagent to read Console Issue #3, parent NeKiro Issue #59, Spec 027 US4-US5, FR-013 through FR-017, Slice A/B/C evidence, root workspace scripts, and existing CI. Record the browser runner, environment boundary, demo isolation, and failure conditions before editing CI or browser tests. + +### Implementation + +- [ ] T030 [US5] Add the repository-approved browser acceptance harness under `E:/Progarms/NeKiro-Console/e2e/` or the existing test convention, with explicit Gateway/Workspace/provider configuration and no mock production data; cover the visible Register -> Verify -> Publish -> Discover -> Install -> Invoke -> Record path. +- [ ] T031 [US5] Add browser assertions for JSON/SSE correlation, nested B -> Router -> A Trace display, exact lifecycle failures, challenge-proof non-persistence, and isolated `#/demo*` routes in `E:/Progarms/NeKiro-Console/e2e/`. +- [ ] T032 [US5] Update standalone Console CI and the platform root CI/import configuration so `pnpm typecheck`, `pnpm test`, `pnpm build`, and browser acceptance execute non-empty production scripts and fail on missing configuration or skipped project discovery; import reviewed source into `E:/Progarms/NeKiro/apps/console` without `.git`, `node_modules`, `dist`, or credentials. +- [ ] T033 [US5] Update `E:/Progarms/NeKiro-Console/README.md`, `E:/Progarms/NeKiro/docs/runbooks/local-development.md`, and Spec 027 quickstart with explicit environment requirements, provider/Workspace ownership, live workflow, and recovery boundaries. + +### Tests + +- [ ] T034 [US5] Run root frontend typecheck, focused tests, production build, browser acceptance, `git diff --check`, and secret scans; verify comparison demos remain available and separate from production evidence. +- [ ] T035 [US5] Run the full mapped backend/Console verification set and record fresh-environment acceptance IDs, review evidence, and any non-blocking residual policy items as `Needs policy` rather than fallback. + +**Review gate**: + +- [ ] T036 [US5] Launch an independent review agent for Console Issue #3/parent #59 against the complete Slice D diff, Spec 027, CI behavior, browser evidence, secret boundary, and all prior review records; do not merge until it reports no High/Medium findings. + +## Phase 6: Convergence and delivery records + +- [ ] T037 [P] Update `specs/027-console-trusted-publication/quickstart.md`, the parent Issue, and each child Issue with exact verification commands, accepted limitations, and links to the reviewed PR evidence. +- [ ] T038 [P] Run a final read-only Spec/Plan/Tasks/contract/constitution consistency analysis and append any remaining approved work to this `tasks.md`; do not implement unapproved behavior. +- [ ] T039 Confirm each slice has its own branch, commit identity `Nene7ko_ <1604009816@qq.com>`, upstream PR, passing CI, pre-implementation subagent report, independent review result, and no unresolved High/Medium finding. + +## Dependencies & Execution Order + +```text +T001-T004 + -> T005-T013 (Slice A) + -> T014-T023 (Slice B) + -> T024-T028 (Slice C) + -> T029-T036 (Slice D) + -> T037-T039 +``` + +Slice A is the only prerequisite for Slice B. Slice C is backend-only and can +be developed in parallel with Slice B after its own pre-implementation review, +but the delivery order remains A -> B -> C -> D for this execution so each +review gate is explicit and the final browser acceptance consumes all prior +behavior. Tasks marked `[P]` have disjoint write scopes and may run together +within a slice. + +## Implementation Strategy + +1. Import the existing production Console so root CI has a real package. +2. Complete Slice A and stop for independent review. +3. Complete Slice B and stop for independent review. +4. Complete the reverse backend acceptance and stop for independent review. +5. Complete CI/browser acceptance and stop for independent review. +6. Run convergence and publish delivery records only after all slices pass. From 5b5c1d142dc3fcf8ed838fecd902ece2d8fd71b1 Mon Sep 17 00:00:00 2001 From: Nene7ko_ <1604009816@qq.com> Date: Mon, 27 Jul 2026 00:03:02 +0800 Subject: [PATCH 02/17] docs(spec): clarify reverse runtime caller scope --- specs/027-console-trusted-publication/plan.md | 9 ++++++--- specs/027-console-trusted-publication/tasks.md | 4 ++-- 2 files changed, 8 insertions(+), 5 deletions(-) diff --git a/specs/027-console-trusted-publication/plan.md b/specs/027-console-trusted-publication/plan.md index ac1ab24..7374887 100644 --- a/specs/027-console-trusted-publication/plan.md +++ b/specs/027-console-trusted-publication/plan.md @@ -134,8 +134,11 @@ after each reviewed slice, the resulting source is imported into the platform repository as a normal `apps/console` workspace package so existing root CI commands discover real frontend scripts. The imported copy is generated from the reviewed upstream source and is not a second hand-edited production path. -Add only the backend acceptance fixture required to prove the reverse nested -direction. +Add only the deterministic Runtime B caller behavior and backend acceptance +fixture required to prove the reverse nested direction. Runtime B may reuse the +existing Agent SDK and Router Agent v1 boundary, but it must not gain a direct +Runtime A endpoint, platform storage access, a new runtime framework, or a +second public contract. ## PR and Review Slices @@ -143,7 +146,7 @@ direction. | --- | --- | --- | --- | | A | Console #2 | standalone `NeKiro-Console/src/api`, API tests, trusted transport validation | All active Trusted Publication requests and strict response/error mappings pass focused tests | | B | Console #4 | standalone `NeKiro-Console/src/App.tsx`, API client configuration, and production components | Provider can complete Binding/Challenge/Release lifecycle; Workspace owner preflights an explicit published Release and installation validates the exact returned Release ID | -| C | NeKiro #60 | backend E2E fixture only | Runtime B -> Router -> Runtime A returns with one correlated root/child lineage and provenance | +| C | NeKiro #60 | Runtime B deterministic caller fixture and backend E2E acceptance | Runtime B -> Router -> Runtime A returns with one correlated root/child lineage and provenance | | D | Console #3 / parent #59 | standalone Console CI/browser acceptance plus reviewed import to `apps/console` and root CI | CI executes real typecheck/tests/build/browser acceptance; live production route proves the loop | Each slice is implemented on its own branch/PR. Before coding a slice, a diff --git a/specs/027-console-trusted-publication/tasks.md b/specs/027-console-trusted-publication/tasks.md index 486e350..139514d 100644 --- a/specs/027-console-trusted-publication/tasks.md +++ b/specs/027-console-trusted-publication/tasks.md @@ -97,8 +97,8 @@ Implementation PR: https://github.com/NeKiro-project/NeKiro-Console/pull/6 ### Implementation -- [ ] T025 [US4] Add the reverse nested-call fixture setup in `tests/e2e/invoke-record/invoke_record_test.go` using the existing Agent SDK, Router, and Runtime B sample without changing active contracts or exposing an Agent host port. -- [ ] T026 [US4] Add assertions in `tests/e2e/invoke-record/invoke_record_test.go` for B root -> A child `parentInvocationId`, one `rootTaskId`, one `traceId`, distinct exact Release IDs/Card digests, returned result correlation, and no direct endpoint access. +- [ ] T025 [US4] Extend the deterministic Runtime B sample with explicit Router URL/token, target Agent ID/capability, response/event limits, and Router Agent credential configuration; inject the existing Agent SDK and invoke only the configured target through Agent Router v1 for the reverse fixture. Do not add a direct Runtime A endpoint, platform storage access, a new runtime framework, retry, or fallback. +- [ ] T026 [US4] Add the reverse fixture setup and assertions in `tests/e2e/invoke-record/invoke_record_test.go` for B root -> A child `parentInvocationId`, one `rootTaskId`, one `traceId`, distinct exact Release IDs/Card digests, returned result correlation, and no direct endpoint access or exposed Agent host port. ### Tests From 92f290ce49cd89e232e1ab026d615fd03e851e3a Mon Sep 17 00:00:00 2001 From: Nene7ko_ <1604009816@qq.com> Date: Mon, 27 Jul 2026 00:21:37 +0800 Subject: [PATCH 03/17] test: prove reverse cross-runtime invocation lineage --- .env.example | 9 +- .github/workflows/ci.yml | 10 +- agents/runtime-a/handler.go | 14 ++ agents/runtime-a/nested.go | 29 +++ agents/runtime-a/nested_test.go | 61 +++++- agents/runtime-b/cmd/runtime-b/main.go | 10 +- agents/runtime-b/config.go | 191 ++++++++++++++++++ agents/runtime-b/config_test.go | 83 ++++++++ agents/runtime-b/fixture.go | 3 +- agents/runtime-b/handler.go | 39 +++- agents/runtime-b/nested.go | 113 +++++++++++ agents/runtime-b/nested_test.go | 80 ++++++++ deploy/compose.yaml | 10 + docs/runbooks/local-development.md | 9 + specs/027-console-trusted-publication/plan.md | 10 +- .../quickstart.md | 2 + .../027-console-trusted-publication/tasks.md | 2 +- tests/e2e/invoke-record/invoke_record_test.go | 92 ++++++++- 18 files changed, 742 insertions(+), 25 deletions(-) create mode 100644 agents/runtime-b/config.go create mode 100644 agents/runtime-b/config_test.go create mode 100644 agents/runtime-b/nested.go create mode 100644 agents/runtime-b/nested_test.go diff --git a/.env.example b/.env.example index f64fea1..344cb2a 100644 --- a/.env.example +++ b/.env.example @@ -60,12 +60,15 @@ NEKIRO_ROUTER_AGENT_CREDENTIAL_PRIVATE_KEY_BASE64URL= NEKIRO_ROUTER_AGENT_CREDENTIAL_TTL_SECONDS= A2A_ROUTER_PORT= -# Runtime A is the only sample that needs a Router Agent binding. The token -# digest is configured in NEKIRO_ROUTER_AGENT_PRINCIPALS_JSON; this raw value -# is supplied only to Runtime A and must never be logged or persisted. +# Runtime A and Runtime B use separate Router Agent bindings. Each token digest +# is configured in NEKIRO_ROUTER_AGENT_PRINCIPALS_JSON; raw values are supplied +# only to the matching sample and must never be logged or persisted. RUNTIME_A_ROUTER_TOKEN= RUNTIME_A_RESPONSE_LIMIT_BYTES= RUNTIME_A_EVENT_LIMIT_BYTES= +RUNTIME_B_ROUTER_TOKEN= +RUNTIME_B_RESPONSE_LIMIT_BYTES= +RUNTIME_B_EVENT_LIMIT_BYTES= NEKIRO_AGENT_ROUTER_ISSUER= NEKIRO_AGENT_ROUTER_KEY_ID= NEKIRO_AGENT_ROUTER_PUBLIC_KEY_BASE64URL= diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 9b89c66..d795f24 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -139,7 +139,7 @@ jobs: NEKIRO_DEV_AUTH_PRINCIPALS_JSON: '[{"id":"compose-check","tokenSha256":"aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa"}]' NEKIRO_INTERNAL_DEV_AUTH_PRINCIPALS_JSON: '[{"id":"compose-router-check","tokenSha256":"bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb"}]' NEKIRO_ROUTER_SERVICE_PRINCIPALS_JSON: '[{"id":"compose-control-plane","tokenSha256":"cccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccc"}]' - NEKIRO_ROUTER_AGENT_PRINCIPALS_JSON: '[{"workspaceId":"workspace-acceptance","agentId":"runtime-a","tokenSha256":"dddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddd"}]' + NEKIRO_ROUTER_AGENT_PRINCIPALS_JSON: '[{"workspaceId":"workspace-acceptance","agentId":"runtime-a","tokenSha256":"dddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddd"},{"workspaceId":"workspace-acceptance","agentId":"runtime-b","tokenSha256":"cc7c23956db7e7fbf8d5ee1948b5f056d62599d6fbd4af42c8a73ed9a1dff7e0"}]' NEKIRO_ROUTER_INTERNAL_BEARER_TOKEN: compose-router-token NEKIRO_CONTROL_PLANE_SERVICE_TOKEN: compose-control-plane-token NEKIRO_CORS_ALLOWED_ORIGINS: http://127.0.0.1:3000 @@ -170,6 +170,9 @@ jobs: RUNTIME_A_ROUTER_TOKEN: runtime-a-token RUNTIME_A_RESPONSE_LIMIT_BYTES: "1048576" RUNTIME_A_EVENT_LIMIT_BYTES: "65536" + RUNTIME_B_ROUTER_TOKEN: runtime-b-router-check + RUNTIME_B_RESPONSE_LIMIT_BYTES: "1048576" + RUNTIME_B_EVENT_LIMIT_BYTES: "65536" CONTROL_PLANE_PORT: "18080" A2A_ROUTER_PORT: "18081" run: docker compose --file deploy/compose.yaml config --quiet @@ -215,7 +218,7 @@ jobs: NEKIRO_DEV_AUTH_PRINCIPALS_JSON: '[{"id":"acceptance-owner","tokenSha256":"465aedffb32a2cb642cbca8fc75b806bcd33f703d70c49dcfb05e9db88df32d2"},{"id":"acceptance-user","tokenSha256":"2af4f9af4fa535905378ccee817aa532244dcf102f3d3ebeaf9a2a92abdeb42d"},{"id":"acceptance-other","tokenSha256":"7f85fe19123d4f88c475cb754dd30f422877ba3b3e2d5eed8ff8c2f9453ebeaf"}]' NEKIRO_INTERNAL_DEV_AUTH_PRINCIPALS_JSON: '[{"id":"router-internal","tokenSha256":"f9232718425b5ebee721187a79703448bce513ecf0600eb161f9256ddac27c4d"}]' NEKIRO_ROUTER_SERVICE_PRINCIPALS_JSON: '[{"id":"control-plane","tokenSha256":"5abfd00de27c6b2f57d45fdc90999134e4e088414ba1f39bf67ee0d1c9cec554"}]' - NEKIRO_ROUTER_AGENT_PRINCIPALS_JSON: '[{"workspaceId":"workspace-acceptance","agentId":"runtime-a","tokenSha256":"e304d0370532633d535824a897d5c03445b636e8d1649064aa35a8fb50fef200"}]' + NEKIRO_ROUTER_AGENT_PRINCIPALS_JSON: '[{"workspaceId":"workspace-acceptance","agentId":"runtime-a","tokenSha256":"e304d0370532633d535824a897d5c03445b636e8d1649064aa35a8fb50fef200"},{"workspaceId":"workspace-acceptance","agentId":"runtime-b","tokenSha256":"9b990de9bb74efd4e1d26a43a01e132deb60d563d49faf6878dca4af40858a38"}]' NEKIRO_ROUTER_INTERNAL_BEARER_TOKEN: router-internal-token NEKIRO_CONTROL_PLANE_SERVICE_TOKEN: control-plane-internal-token NEKIRO_CORS_ALLOWED_ORIGINS: http://127.0.0.1:3000 @@ -248,6 +251,9 @@ jobs: RUNTIME_A_ROUTER_TOKEN: runtime-a-router-token RUNTIME_A_RESPONSE_LIMIT_BYTES: "1048576" RUNTIME_A_EVENT_LIMIT_BYTES: "65536" + RUNTIME_B_ROUTER_TOKEN: runtime-b-router-token + RUNTIME_B_RESPONSE_LIMIT_BYTES: "1048576" + RUNTIME_B_EVENT_LIMIT_BYTES: "65536" NEKIRO_E2E_CONTROL_PLANE_URL: http://127.0.0.1:18080 NEKIRO_E2E_ROUTER_URL: http://127.0.0.1:18081 NEKIRO_E2E_ROUTER_TOKEN: router-internal-token diff --git a/agents/runtime-a/handler.go b/agents/runtime-a/handler.go index 65a6d96..fc12c6d 100644 --- a/agents/runtime-a/handler.go +++ b/agents/runtime-a/handler.go @@ -81,6 +81,20 @@ func (handler *Handler) OnSendMessage(ctx context.Context, params *a2a.MessageSe if !ok { return nil, invalidParams("managed A2A call context is required") } + claims, ok := routerauth.ClaimsFromContext(ctx) + if !ok { + return nil, invalidParams("managed Router credential context is required") + } + if claims.AgentID != handler.config.AgentID { + return nil, invalidParams("managed Router credential Agent identity is invalid") + } + if claims.Capability == "runtime.echo" { + value, err := rootInputValue(params.Message) + if err != nil { + return nil, err + } + return responderMessage(params.Message, value), nil + } platformContext, err := handler.service.platformContext(callContext.RequestMeta()) if err != nil { return nil, err diff --git a/agents/runtime-a/nested.go b/agents/runtime-a/nested.go index 1753dbd..9c12ddc 100644 --- a/agents/runtime-a/nested.go +++ b/agents/runtime-a/nested.go @@ -135,6 +135,35 @@ func rootInput(message *a2a.Message) (json.RawMessage, error) { return input, nil } +func rootInputValue(message *a2a.Message) (json.RawMessage, error) { + input, err := rootInput(message) + if err != nil { + return nil, err + } + var fields map[string]json.RawMessage + if err := json.Unmarshal(input, &fields); err != nil || fields == nil || len(fields) != 2 { + return nil, errors.New("runtime-a responder input is invalid") + } + value, ok := fields["value"] + if !ok || len(value) == 0 || !json.Valid(value) { + return nil, errors.New("runtime-a responder value is missing") + } + return value, nil +} + +func responderMessage(input *a2a.Message, value json.RawMessage) *a2a.Message { + return &a2a.Message{ + ID: "runtime-a-responder-result-" + input.ID, + ContextID: input.ContextID, + Role: a2a.MessageRoleAgent, + Parts: []a2a.Part{a2a.DataPart{Data: map[string]any{ + "agent": "runtime-a", + "fixture": "success", + "value": value, + }}}, + } +} + func combinedResult(result *agentsdk.NestedResult) (json.RawMessage, error) { if result == nil { return nil, errors.New("runtime-a nested result is required") diff --git a/agents/runtime-a/nested_test.go b/agents/runtime-a/nested_test.go index d9aac70..cf98836 100644 --- a/agents/runtime-a/nested_test.go +++ b/agents/runtime-a/nested_test.go @@ -31,6 +31,10 @@ func httptestNewServer(t *testing.T, handler http.Handler) *httptest.Server { } func newClient(t *testing.T, server *httptest.Server, headers map[string]string) *a2aclient.Client { + return newClientWithCapability(t, server, headers, "fixture") +} + +func newClientWithCapability(t *testing.T, server *httptest.Server, headers map[string]string, capability string) *a2aclient.Client { t.Helper() meta := make(a2aclient.CallMeta, len(headers)) for name, value := range headers { @@ -38,7 +42,7 @@ func newClient(t *testing.T, server *httptest.Server, headers map[string]string) } client, err := a2aclient.NewFromEndpoints(t.Context(), []a2a.AgentInterface{{ URL: server.URL, Transport: a2a.TransportProtocolJSONRPC, - }}, a2aclient.WithJSONRPCTransport(server.Client()), a2aclient.WithInterceptors(a2aclient.NewStaticCallMetaInjector(meta), runtimeAAuthInterceptor{})) + }}, a2aclient.WithJSONRPCTransport(server.Client()), a2aclient.WithInterceptors(a2aclient.NewStaticCallMetaInjector(meta), runtimeAAuthInterceptor{capability: capability})) if err != nil { t.Fatalf("create A2A client: %v", err) } @@ -48,9 +52,11 @@ func newClient(t *testing.T, server *httptest.Server, headers map[string]string) var runtimeAAuthSequence atomic.Uint64 -type runtimeAAuthInterceptor struct{} +type runtimeAAuthInterceptor struct { + capability string +} -func (runtimeAAuthInterceptor) Before(ctx context.Context, request *a2aclient.Request) (context.Context, error) { +func (interceptor runtimeAAuthInterceptor) Before(ctx context.Context, request *a2aclient.Request) (context.Context, error) { value := func(name string) string { values := request.Meta.Get(name) if len(values) == 1 { @@ -59,10 +65,14 @@ func (runtimeAAuthInterceptor) Before(ctx context.Context, request *a2aclient.Re return "" } now := time.Now().Unix() + capability := interceptor.capability + if capability == "" { + capability = "fixture" + } claims := jwt.MapClaims{ "iss": "https://a2a-router.nekiro.test", "aud": []string{"http://runtime-a:8091"}, "exp": now + 30, "iat": now, "jti": fmt.Sprintf("rtj_runtime_a_%d", runtimeAAuthSequence.Add(1)), "workspaceId": value("x-nek-workspace-id"), "agentId": "agent-runtime-a", "agentVersion": "1.0.0", "releaseId": "release-a", "cardDigest": strings.Repeat("a", 64), - "capability": "fixture", "invocationId": value("x-nek-invocation-id"), "rootTaskId": value("x-nek-root-task-id"), "traceId": value("x-nek-trace-id"), + "capability": capability, "invocationId": value("x-nek-invocation-id"), "rootTaskId": value("x-nek-root-task-id"), "traceId": value("x-nek-trace-id"), } token := jwt.NewWithClaims(jwt.SigningMethodEdDSA, claims) token.Header["typ"] = contracts.RouterAgentCredentialType @@ -79,7 +89,7 @@ func (runtimeAAuthInterceptor) Before(ctx context.Context, request *a2aclient.Re request.Meta.Append("x-nek-agent-card-version", "1.0.0") request.Meta.Append("x-nek-agent-release-id", "release-a") request.Meta.Append("x-nek-agent-card-digest", strings.Repeat("a", 64)) - request.Meta.Append("x-nek-capability", "fixture") + request.Meta.Append("x-nek-capability", capability) request.Meta.Append("x-nek-target-agent-id", "agent-runtime-a") return ctx, nil } @@ -155,6 +165,47 @@ func TestRuntimeAUsesOneManagedNestedCallAndReturnsCombinedResult(t *testing.T) } } +func TestRuntimeAEchoCapabilityRespondsWithoutNestedCall(t *testing.T) { + config, err := LoadConfig(lookupEnvironment(validEnvironment())) + if err != nil { + t.Fatal(err) + } + invoker := &recordingInvoker{result: func(agentsdk.PlatformContext) *agentsdk.NestedResult { + return &agentsdk.NestedResult{InvocationID: "unexpected-child", RootTaskID: "task-1", TraceID: "trace-1", Status: "succeeded", Result: json.RawMessage(`{"agent":"runtime-b"}`)} + }} + handler, err := newHandlerWithInvoker(config, invoker) + if err != nil { + t.Fatal(err) + } + server := httptestNewServer(t, NewHTTPHandler(handler)) + client := newClientWithCapability(t, server, map[string]string{ + "x-nek-trace-id": "trace-1", + "x-nek-invocation-id": "root-1", + "x-nek-root-task-id": "task-1", + "x-nek-workspace-id": "workspace-1", + }, "runtime.echo") + result, err := client.SendMessage(t.Context(), &a2a.MessageSendParams{Message: &a2a.Message{ + ID: "root-echo", Role: a2a.MessageRoleUser, + Parts: []a2a.Part{a2a.DataPart{Data: map[string]any{"fixture": "success", "value": "echo-value"}}}, + }}) + if err != nil { + t.Fatal(err) + } + message, ok := result.(*a2a.Message) + if !ok || len(message.Parts) != 1 { + t.Fatalf("result=%#v", result) + } + data, ok := message.Parts[0].(a2a.DataPart) + if !ok || data.Data["agent"] != "runtime-a" || data.Data["value"] != "echo-value" { + t.Fatalf("responder data=%#v", data.Data) + } + invoker.mu.Lock() + defer invoker.mu.Unlock() + if len(invoker.calls) != 0 { + t.Fatalf("responder made nested calls=%#v", invoker.calls) + } +} + func TestRootInputRejectsUnexpectedShape(t *testing.T) { tests := []*a2a.Message{ {ID: "missing-part"}, diff --git a/agents/runtime-b/cmd/runtime-b/main.go b/agents/runtime-b/cmd/runtime-b/main.go index d9b6f36..47893eb 100644 --- a/agents/runtime-b/cmd/runtime-b/main.go +++ b/agents/runtime-b/cmd/runtime-b/main.go @@ -19,7 +19,15 @@ func main() { if err != nil { log.Fatal("runtime-b authentication config: ", err) } - execution, err := runtimeb.NewHTTPHandlerWithAuth(runtimeb.NewHandler(), authenticationConfig) + config, err := runtimeb.LoadConfig(os.LookupEnv) + if err != nil { + log.Fatal(err) + } + handler, err := runtimeb.NewConfiguredHandler(config, http.DefaultClient) + if err != nil { + log.Fatal("runtime-b initialize: ", err) + } + execution, err := runtimeb.NewHTTPHandlerWithAuth(handler, authenticationConfig) if err != nil { log.Fatal("runtime-b authentication: ", err) } diff --git a/agents/runtime-b/config.go b/agents/runtime-b/config.go new file mode 100644 index 0000000..1989149 --- /dev/null +++ b/agents/runtime-b/config.go @@ -0,0 +1,191 @@ +package runtimeb + +import ( + "fmt" + "net/url" + "regexp" + "strconv" + "strings" + + "github.com/Nene7ko/NeKiro/contracts" + "github.com/Nene7ko/NeKiro/sdks/agent-sdk/routerauth" +) + +const ( + AgentIDEnvironment = "RUNTIME_B_AGENT_ID" + RouterEnvironment = "RUNTIME_B_ROUTER_URL" + RouterTokenEnvironment = "RUNTIME_B_ROUTER_TOKEN" + TargetAgentEnvironment = "RUNTIME_B_TARGET_AGENT_ID" + CapabilityEnvironment = "RUNTIME_B_TARGET_CAPABILITY" + ResponseLimitEnvironment = "RUNTIME_B_RESPONSE_LIMIT_BYTES" + EventLimitEnvironment = "RUNTIME_B_EVENT_LIMIT_BYTES" +) + +var runtimeBIdentifierPattern = regexp.MustCompile(`^[A-Za-z0-9][A-Za-z0-9._:-]{0,127}$`) + +// Config contains the explicit settings needed for Runtime B's managed +// nested-call fixture. It does not contain a direct target endpoint. +type Config struct { + AgentID string + RouterURL string + RouterToken string + TargetAgentID string + Capability string + ResponseLimit int64 + EventLimit int64 + RouterAuth routerauth.Config +} + +// LoadConfig reads and validates every required Runtime B caller setting. +func LoadConfig(lookup func(string) (string, bool)) (Config, error) { + agentID, err := requiredIdentifier(lookup, AgentIDEnvironment) + if err != nil { + return Config{}, err + } + routerURL, err := requiredValue(lookup, RouterEnvironment) + if err != nil { + return Config{}, err + } + if err := validateRouterURL(routerURL); err != nil { + return Config{}, err + } + routerToken, err := requiredValue(lookup, RouterTokenEnvironment) + if err != nil { + return Config{}, err + } + targetAgentID, err := requiredIdentifier(lookup, TargetAgentEnvironment) + if err != nil { + return Config{}, err + } + capability, err := requiredIdentifier(lookup, CapabilityEnvironment) + if err != nil { + return Config{}, err + } + responseLimit, err := requiredLimit(lookup, ResponseLimitEnvironment) + if err != nil { + return Config{}, err + } + eventLimit, err := requiredLimit(lookup, EventLimitEnvironment) + if err != nil { + return Config{}, err + } + routerAuth, err := routerauth.LoadConfig(lookup) + if err != nil { + return Config{}, err + } + config := Config{ + AgentID: agentID, + RouterURL: routerURL, + RouterToken: routerToken, + TargetAgentID: targetAgentID, + Capability: capability, + ResponseLimit: responseLimit, + EventLimit: eventLimit, + RouterAuth: routerAuth, + } + if err := config.Validate(); err != nil { + return Config{}, err + } + return config, nil +} + +func (config Config) Validate() error { + if err := validateIdentifierValue(AgentIDEnvironment, config.AgentID); err != nil { + return err + } + if err := validateRequiredValue(RouterEnvironment, config.RouterURL); err != nil { + return err + } + if err := validateRouterURL(config.RouterURL); err != nil { + return err + } + if err := validateRequiredValue(RouterTokenEnvironment, config.RouterToken); err != nil { + return err + } + if err := validateIdentifierValue(TargetAgentEnvironment, config.TargetAgentID); err != nil { + return err + } + if err := validateIdentifierValue(CapabilityEnvironment, config.Capability); err != nil { + return err + } + if config.ResponseLimit < contracts.RuntimeByteLimitMinimum || config.ResponseLimit > contracts.RuntimeByteLimitMaximum { + return fmt.Errorf("%s must be an integer from %d through %d", ResponseLimitEnvironment, contracts.RuntimeByteLimitMinimum, contracts.RuntimeByteLimitMaximum) + } + if config.EventLimit < contracts.RuntimeByteLimitMinimum || config.EventLimit > contracts.RuntimeByteLimitMaximum { + return fmt.Errorf("%s must be an integer from %d through %d", EventLimitEnvironment, contracts.RuntimeByteLimitMinimum, contracts.RuntimeByteLimitMaximum) + } + if err := config.RouterAuth.Validate(); err != nil { + return err + } + return nil +} + +func requiredValue(lookup func(string) (string, bool), name string) (string, error) { + value, exists := lookup(name) + if !exists { + return "", fmt.Errorf("%s is required", name) + } + return value, validateRequiredValue(name, value) +} + +func validateRequiredValue(name, value string) error { + if value == "" { + return fmt.Errorf("%s must be non-empty", name) + } + if strings.TrimSpace(value) != value { + return fmt.Errorf("%s must not contain surrounding whitespace", name) + } + return nil +} + +func requiredIdentifier(lookup func(string) (string, bool), name string) (string, error) { + value, err := requiredValue(lookup, name) + if err != nil { + return "", err + } + return value, validateIdentifierValue(name, value) +} + +func validateIdentifierValue(name, value string) error { + if err := validateRequiredValue(name, value); err != nil { + return err + } + if !runtimeBIdentifierPattern.MatchString(value) { + return fmt.Errorf("%s must be a safe identifier", name) + } + return nil +} + +func requiredLimit(lookup func(string) (string, bool), name string) (int64, error) { + value, err := requiredValue(lookup, name) + if err != nil { + return 0, err + } + for _, character := range value { + if character < '0' || character > '9' { + return 0, fmt.Errorf("%s must be an unsigned base-10 integer", name) + } + } + parsed, err := strconv.ParseInt(value, 10, 64) + if err != nil || parsed < contracts.RuntimeByteLimitMinimum || parsed > contracts.RuntimeByteLimitMaximum { + return 0, fmt.Errorf("%s must be an integer from %d through %d", name, contracts.RuntimeByteLimitMinimum, contracts.RuntimeByteLimitMaximum) + } + return parsed, nil +} + +func validateRouterURL(value string) error { + parsed, err := url.Parse(value) + if err != nil || (parsed.Scheme != "http" && parsed.Scheme != "https") || parsed.Host == "" || parsed.User != nil || parsed.Path != "" || parsed.RawPath != "" || parsed.RawQuery != "" || parsed.ForceQuery || parsed.Fragment != "" || parsed.RawFragment != "" { + return fmt.Errorf("%s must be an http or https origin URL without credentials, path, query, or fragment", RouterEnvironment) + } + if parsed.Port() != "" { + port, err := strconv.Atoi(parsed.Port()) + if err != nil || port < 1 || port > 65535 { + return fmt.Errorf("%s port must be an integer from 1 through 65535", RouterEnvironment) + } + } + if parsed.Hostname() == "" { + return fmt.Errorf("%s must declare a host", RouterEnvironment) + } + return nil +} diff --git a/agents/runtime-b/config_test.go b/agents/runtime-b/config_test.go new file mode 100644 index 0000000..d08202f --- /dev/null +++ b/agents/runtime-b/config_test.go @@ -0,0 +1,83 @@ +package runtimeb + +import "testing" + +func validRuntimeBEnvironment() map[string]string { + return map[string]string{ + AgentIDEnvironment: "agent-runtime-b", + RouterEnvironment: "http://127.0.0.1:4101", + RouterTokenEnvironment: "opaque-token", + TargetAgentEnvironment: "agent-runtime-a", + CapabilityEnvironment: "runtime.echo", + ResponseLimitEnvironment: "1048576", + EventLimitEnvironment: "1048576", + "NEKIRO_AGENT_ROUTER_ISSUER": "https://a2a-router.nekiro.test", + "NEKIRO_AGENT_ROUTER_AUDIENCE": "http://runtime-b:8092", + "NEKIRO_AGENT_ROUTER_KEY_ID": "router-key-1", + "NEKIRO_AGENT_ROUTER_PUBLIC_KEY_BASE64URL": "A6EHv_POEL4dcN0Y50vAmWfk1jCbpQ1fHdyGZBJVMbg", + } +} + +func runtimeBLookup(values map[string]string) func(string) (string, bool) { + return func(name string) (string, bool) { + value, exists := values[name] + return value, exists + } +} + +func TestLoadConfigRequiresAndValidatesAllSettings(t *testing.T) { + config, err := LoadConfig(runtimeBLookup(validRuntimeBEnvironment())) + if err != nil { + t.Fatalf("LoadConfig() error = %v", err) + } + if config.AgentID != "agent-runtime-b" || config.TargetAgentID != "agent-runtime-a" || config.ResponseLimit != 1048576 || config.EventLimit != 1048576 { + t.Fatalf("LoadConfig() = %+v", config) + } + + for name := range validRuntimeBEnvironment() { + environment := validRuntimeBEnvironment() + delete(environment, name) + if _, err := LoadConfig(runtimeBLookup(environment)); err == nil { + t.Errorf("missing %s was accepted", name) + } + } +} + +func TestLoadConfigRejectsInvalidValuesWithoutDefaults(t *testing.T) { + tests := map[string]string{ + AgentIDEnvironment: " agent-runtime-b", + RouterEnvironment: "localhost:4101", + RouterTokenEnvironment: " opaque-token", + TargetAgentEnvironment: "agent runtime-a", + CapabilityEnvironment: "runtime/echo", + ResponseLimitEnvironment: "+1", + EventLimitEnvironment: "2147483648", + } + for name, value := range tests { + t.Run(name, func(t *testing.T) { + environment := validRuntimeBEnvironment() + environment[name] = value + if _, err := LoadConfig(runtimeBLookup(environment)); err == nil { + t.Fatalf("invalid %s=%q was accepted", name, value) + } + }) + } + for _, value := range []string{"http://127.0.0.1:4101/", "http://127.0.0.1:4101?", "http://127.0.0.1:65536", "http://127.0.0.1:4101#"} { + environment := validRuntimeBEnvironment() + environment[RouterEnvironment] = value + if _, err := LoadConfig(runtimeBLookup(environment)); err == nil { + t.Fatalf("Router URL %q was accepted", value) + } + } +} + +func TestConfigValidateRejectsValuesBypassedAroundEnvironmentLoader(t *testing.T) { + config, err := LoadConfig(runtimeBLookup(validRuntimeBEnvironment())) + if err != nil { + t.Fatal(err) + } + config.TargetAgentID = "agent runtime-a" + if err := config.Validate(); err == nil { + t.Fatal("Config.Validate accepted an invalid target Agent ID") + } +} diff --git a/agents/runtime-b/fixture.go b/agents/runtime-b/fixture.go index 7ee25fc..f706545 100644 --- a/agents/runtime-b/fixture.go +++ b/agents/runtime-b/fixture.go @@ -17,6 +17,7 @@ const ( fixtureProtocol fixtureKind = "protocol" fixtureHold fixtureKind = "hold" fixtureInterrupted fixtureKind = "interrupted" + fixtureNested fixtureKind = "nested" ) var errFixtureFailure = errors.New("runtime-b deterministic fixture failure") @@ -62,7 +63,7 @@ func parseFixture(params *a2a.MessageSendParams) (fixtureRequest, error) { kind := fixtureKind(fixture) switch kind { - case fixtureSuccess, fixtureStreamSuccess, fixtureFailure, fixtureProtocol, fixtureHold, fixtureInterrupted: + case fixtureSuccess, fixtureStreamSuccess, fixtureFailure, fixtureProtocol, fixtureHold, fixtureInterrupted, fixtureNested: default: return fixtureRequest{}, invalidParams("fixture is not supported") } diff --git a/agents/runtime-b/handler.go b/agents/runtime-b/handler.go index 156b00e..0b95d87 100644 --- a/agents/runtime-b/handler.go +++ b/agents/runtime-b/handler.go @@ -6,6 +6,7 @@ import ( "iter" "sync" + agentsdk "github.com/Nene7ko/NeKiro/sdks/agent-sdk" "github.com/a2aproject/a2a-go/a2a" "github.com/a2aproject/a2a-go/a2asrv" ) @@ -17,8 +18,10 @@ type runtimeTask struct { // Handler implements the active A2A Profile for the deterministic Runtime B sample. type Handler struct { - mu sync.RWMutex - tasks map[a2a.TaskID]*runtimeTask + mu sync.RWMutex + tasks map[a2a.TaskID]*runtimeTask + agentID string + nested *nestedService } var _ a2asrv.RequestHandler = (*Handler)(nil) @@ -27,7 +30,24 @@ func NewHandler() *Handler { return &Handler{tasks: make(map[a2a.TaskID]*runtimeTask)} } -func (h *Handler) OnSendMessage(_ context.Context, params *a2a.MessageSendParams) (a2a.SendMessageResult, error) { +// NewConfiguredHandler creates the production Runtime B handler with one +// explicit Router-mediated nested-call destination. +func NewConfiguredHandler(config Config, doer agentsdk.HTTPDoer) (*Handler, error) { + if err := config.Validate(); err != nil { + return nil, err + } + sdk, err := agentsdk.NewClient(doer, config.RouterURL, config.RouterToken, config.ResponseLimit, config.EventLimit) + if err != nil { + return nil, fmt.Errorf("runtime-b create Agent SDK client: %w", err) + } + nested, err := newNestedService(config, sdk) + if err != nil { + return nil, err + } + return &Handler{tasks: make(map[a2a.TaskID]*runtimeTask), agentID: config.AgentID, nested: nested}, nil +} + +func (h *Handler) OnSendMessage(ctx context.Context, params *a2a.MessageSendParams) (a2a.SendMessageResult, error) { request, err := parseFixture(params) if err != nil { return nil, err @@ -35,6 +55,19 @@ func (h *Handler) OnSendMessage(_ context.Context, params *a2a.MessageSendParams switch request.kind { case fixtureSuccess: return successMessage(params.Message, request), nil + case fixtureNested: + if h.nested == nil { + return nil, invalidParams("nested fixture is not configured") + } + platformContext, err := h.nested.platformContext(ctx) + if err != nil { + return nil, err + } + result, err := h.nested.invoke(ctx, platformContext, request.value) + if err != nil { + return nil, safeNestedFailure(err) + } + return nestedMessage(params.Message, result), nil case fixtureFailure: return nil, errFixtureFailure case fixtureProtocol: diff --git a/agents/runtime-b/nested.go b/agents/runtime-b/nested.go new file mode 100644 index 0000000..9e78bb7 --- /dev/null +++ b/agents/runtime-b/nested.go @@ -0,0 +1,113 @@ +package runtimeb + +import ( + "context" + "encoding/json" + "errors" + "fmt" + + "github.com/Nene7ko/NeKiro/contracts" + agentsdk "github.com/Nene7ko/NeKiro/sdks/agent-sdk" + "github.com/Nene7ko/NeKiro/sdks/agent-sdk/routerauth" + "github.com/a2aproject/a2a-go/a2a" +) + +type nestedInvoker interface { + Invoke(context.Context, agentsdk.PlatformContext, agentsdk.NestedRequest) (*agentsdk.NestedResult, error) +} + +type nestedService struct { + config Config + invoker nestedInvoker +} + +func newNestedService(config Config, invoker nestedInvoker) (*nestedService, error) { + if err := config.Validate(); err != nil { + return nil, err + } + if invoker == nil { + return nil, errors.New("runtime-b nested invoker is required") + } + return &nestedService{config: config, invoker: invoker}, nil +} + +func (service *nestedService) platformContext(ctx context.Context) (agentsdk.PlatformContext, error) { + claims, ok := routerauth.ClaimsFromContext(ctx) + if !ok { + return agentsdk.PlatformContext{}, invalidParams("managed Router credential context is required") + } + platformContext, err := platformContextFromClaims(claims, service.config.AgentID) + if err != nil { + return agentsdk.PlatformContext{}, invalidParams(err.Error()) + } + return platformContext, nil +} + +func (service *nestedService) invoke(ctx context.Context, platformContext agentsdk.PlatformContext, value any) (*agentsdk.NestedResult, error) { + encodedValue, err := json.Marshal(value) + if err != nil { + return nil, invalidParams("value must be JSON-compatible") + } + input, err := json.Marshal(map[string]json.RawMessage{ + "fixture": json.RawMessage(`"success"`), + "value": encodedValue, + }) + if err != nil { + return nil, errors.New("runtime-b encode nested input failed") + } + result, err := service.invoker.Invoke(ctx, platformContext, agentsdk.NestedRequest{ + TargetAgentID: service.config.TargetAgentID, + Capability: service.config.Capability, + Input: input, + Stream: false, + }) + if err != nil { + return nil, err + } + if result == nil || result.InvocationID == "" || result.RootTaskID == "" || result.TraceID == "" || result.Status != "succeeded" || result.Result == nil { + return nil, errors.New("runtime-b nested result is incomplete") + } + if result.InvocationID == platformContext.InvocationID || result.RootTaskID != platformContext.RootTaskID || result.TraceID != platformContext.TraceID { + return nil, errors.New("runtime-b nested result lineage is invalid") + } + return result, nil +} + +func nestedMessage(input *a2a.Message, result *agentsdk.NestedResult) *a2a.Message { + return &a2a.Message{ + ID: "runtime-b-nested-result-" + input.ID, + ContextID: input.ContextID, + Role: a2a.MessageRoleAgent, + Parts: []a2a.Part{a2a.DataPart{Data: map[string]any{ + "agent": "runtime-b", + "fixture": string(fixtureNested), + "childInvocationId": result.InvocationID, + "childResult": result.Result, + }}}, + } +} + +func safeNestedFailure(err error) error { + var routerError *agentsdk.RouterError + if errors.As(err, &routerError) { + return fmt.Errorf("runtime-b nested Router failure: %s", routerError.Code) + } + return errors.New("runtime-b nested invocation failure (unknown category)") +} + +func platformContextFromClaims(claims contracts.RouterInvocationCredentialClaimsV1, agentID string) (agentsdk.PlatformContext, error) { + if claims.AgentID != agentID { + return agentsdk.PlatformContext{}, errors.New("managed Router credential Agent identity is invalid") + } + platformContext := agentsdk.PlatformContext{ + InvocationID: claims.InvocationID, + RootTaskID: claims.RootTaskID, + TraceID: string(claims.TraceID), + WorkspaceID: claims.WorkspaceID, + AgentID: claims.AgentID, + } + if err := platformContext.Validate(); err != nil { + return agentsdk.PlatformContext{}, err + } + return platformContext, nil +} diff --git a/agents/runtime-b/nested_test.go b/agents/runtime-b/nested_test.go new file mode 100644 index 0000000..2e6344f --- /dev/null +++ b/agents/runtime-b/nested_test.go @@ -0,0 +1,80 @@ +package runtimeb + +import ( + "context" + "encoding/json" + "errors" + "testing" + + "github.com/Nene7ko/NeKiro/contracts" + agentsdk "github.com/Nene7ko/NeKiro/sdks/agent-sdk" +) + +type runtimeBNestedRecordingInvoker struct { + context agentsdk.PlatformContext + request agentsdk.NestedRequest + result *agentsdk.NestedResult +} + +func (invoker *runtimeBNestedRecordingInvoker) Invoke(_ context.Context, platformContext agentsdk.PlatformContext, request agentsdk.NestedRequest) (*agentsdk.NestedResult, error) { + invoker.context = platformContext + invoker.request = request + return invoker.result, nil +} + +func TestNestedServiceBuildsOneRouterOnlyRequestAndPreservesLineage(t *testing.T) { + config, err := LoadConfig(runtimeBLookup(validRuntimeBEnvironment())) + if err != nil { + t.Fatal(err) + } + invoker := &runtimeBNestedRecordingInvoker{result: &agentsdk.NestedResult{ + InvocationID: "child-1", RootTaskID: "task-1", TraceID: "trace-1", Status: "succeeded", Result: json.RawMessage(`{"agent":"runtime-a"}`), + }} + service, err := newNestedService(config, invoker) + if err != nil { + t.Fatal(err) + } + platformContext := agentsdk.PlatformContext{InvocationID: "root-1", RootTaskID: "task-1", TraceID: "trace-1", WorkspaceID: "workspace-1", AgentID: config.AgentID} + result, err := service.invoke(t.Context(), platformContext, "reverse-value") + if err != nil { + t.Fatal(err) + } + if result.InvocationID != "child-1" || invoker.context != platformContext || invoker.request.TargetAgentID != config.TargetAgentID || invoker.request.Capability != config.Capability || invoker.request.Stream { + t.Fatalf("nested call context=%#v request=%#v result=%#v", invoker.context, invoker.request, result) + } + var input map[string]json.RawMessage + if err := json.Unmarshal(invoker.request.Input, &input); err != nil || string(input["fixture"]) != `"success"` || string(input["value"]) != `"reverse-value"` { + t.Fatalf("nested input=%s err=%v", invoker.request.Input, err) + } +} + +func TestNestedServiceRejectsInvalidLineageAndDoesNotRetry(t *testing.T) { + config, err := LoadConfig(runtimeBLookup(validRuntimeBEnvironment())) + if err != nil { + t.Fatal(err) + } + invoker := &runtimeBNestedRecordingInvoker{result: &agentsdk.NestedResult{ + InvocationID: "child-1", RootTaskID: "wrong-task", TraceID: "trace-1", Status: "succeeded", Result: json.RawMessage(`{"agent":"runtime-a"}`), + }} + service, err := newNestedService(config, invoker) + if err != nil { + t.Fatal(err) + } + platformContext := agentsdk.PlatformContext{InvocationID: "root-1", RootTaskID: "task-1", TraceID: "trace-1", WorkspaceID: "workspace-1", AgentID: config.AgentID} + if _, err := service.invoke(t.Context(), platformContext, map[string]any{"secret": "value"}); err == nil { + t.Fatal("invalid nested lineage was accepted") + } + if invoker.context != platformContext { + t.Fatal("nested invoker was not called exactly once before rejecting the returned lineage") + } + if safeNestedFailure(errors.New("raw token secret")) == nil { + t.Fatal("safeNestedFailure returned nil") + } +} + +func TestPlatformContextFromClaimsRequiresExactAgentIdentity(t *testing.T) { + claims := contracts.RouterInvocationCredentialClaimsV1{AgentID: "runtime-b", InvocationID: "inv-1", RootTaskID: "task-1", TraceID: "trace-1", WorkspaceID: "workspace-1"} + if _, err := platformContextFromClaims(claims, "runtime-a"); err == nil { + t.Fatal("wrong Agent identity was accepted") + } +} diff --git a/deploy/compose.yaml b/deploy/compose.yaml index f54268f..f586b20 100644 --- a/deploy/compose.yaml +++ b/deploy/compose.yaml @@ -136,11 +136,21 @@ services: dockerfile: agents/runtime-b/Dockerfile environment: RUNTIME_B_LISTEN_ADDR: "0.0.0.0:8092" + RUNTIME_B_AGENT_ID: "runtime-b" + RUNTIME_B_ROUTER_URL: "http://a2a-router:8081" + RUNTIME_B_ROUTER_TOKEN: "${RUNTIME_B_ROUTER_TOKEN:?RUNTIME_B_ROUTER_TOKEN must be set and non-empty}" + RUNTIME_B_TARGET_AGENT_ID: "runtime-a" + RUNTIME_B_TARGET_CAPABILITY: "runtime.echo" + RUNTIME_B_RESPONSE_LIMIT_BYTES: "${RUNTIME_B_RESPONSE_LIMIT_BYTES:?RUNTIME_B_RESPONSE_LIMIT_BYTES must be set and non-empty}" + RUNTIME_B_EVENT_LIMIT_BYTES: "${RUNTIME_B_EVENT_LIMIT_BYTES:?RUNTIME_B_EVENT_LIMIT_BYTES must be set and non-empty}" NEKIRO_AGENT_CHALLENGE_DIRECTORY: "/tmp/nekiro-challenges" NEKIRO_AGENT_ROUTER_ISSUER: "${NEKIRO_AGENT_ROUTER_ISSUER:?NEKIRO_AGENT_ROUTER_ISSUER must be set and non-empty}" NEKIRO_AGENT_ROUTER_AUDIENCE: "http://runtime-b:8092" NEKIRO_AGENT_ROUTER_KEY_ID: "${NEKIRO_AGENT_ROUTER_KEY_ID:?NEKIRO_AGENT_ROUTER_KEY_ID must be set and non-empty}" NEKIRO_AGENT_ROUTER_PUBLIC_KEY_BASE64URL: "${NEKIRO_AGENT_ROUTER_PUBLIC_KEY_BASE64URL:?NEKIRO_AGENT_ROUTER_PUBLIC_KEY_BASE64URL must be set and non-empty}" + depends_on: + a2a-router: + condition: service_healthy networks: - platform-internal healthcheck: diff --git a/docs/runbooks/local-development.md b/docs/runbooks/local-development.md index bd0dd1d..738bf95 100644 --- a/docs/runbooks/local-development.md +++ b/docs/runbooks/local-development.md @@ -57,6 +57,12 @@ Every current variable is required: | `NEKIRO_AGENT_ROUTER_ISSUER` | Exact Router issuer trusted by each Agent adapter | | `NEKIRO_AGENT_ROUTER_KEY_ID` | Exact Router key ID trusted by each Agent adapter | | `NEKIRO_AGENT_ROUTER_PUBLIC_KEY_BASE64URL` | 32 raw Ed25519 public-key bytes encoded as unpadded Base64url | +| `RUNTIME_A_ROUTER_TOKEN` | Raw Router Agent token supplied only to Runtime A | +| `RUNTIME_A_RESPONSE_LIMIT_BYTES` | Explicit Runtime A nested response limit | +| `RUNTIME_A_EVENT_LIMIT_BYTES` | Explicit Runtime A nested event limit | +| `RUNTIME_B_ROUTER_TOKEN` | Raw Router Agent token supplied only to Runtime B | +| `RUNTIME_B_RESPONSE_LIMIT_BYTES` | Explicit Runtime B nested response limit | +| `RUNTIME_B_EVENT_LIMIT_BYTES` | Explicit Runtime B nested event limit | Choose non-empty values locally. Do not commit `.env`, reuse these credentials for production, or place production credentials in this Compose deployment. @@ -178,6 +184,9 @@ limits are the minimum of the configured limit and the exact Agent Card limit. Missing or invalid values fail startup; there is no no-op Ledger, fallback endpoint, or default credential. +Each Agent process separately requires its own Router token and explicit +response/event limits. Runtime B's managed nested fixture targets Runtime A +through `http://a2a-router:8081`; it has no direct Runtime A endpoint setting. Each Agent process separately requires `NEKIRO_AGENT_ROUTER_ISSUER`, `NEKIRO_AGENT_ROUTER_AUDIENCE`, `NEKIRO_AGENT_ROUTER_KEY_ID`, and `NEKIRO_AGENT_ROUTER_PUBLIC_KEY_BASE64URL`. Readiness and ownership challenge diff --git a/specs/027-console-trusted-publication/plan.md b/specs/027-console-trusted-publication/plan.md index 7374887..52579a5 100644 --- a/specs/027-console-trusted-publication/plan.md +++ b/specs/027-console-trusted-publication/plan.md @@ -134,11 +134,11 @@ after each reviewed slice, the resulting source is imported into the platform repository as a normal `apps/console` workspace package so existing root CI commands discover real frontend scripts. The imported copy is generated from the reviewed upstream source and is not a second hand-edited production path. -Add only the deterministic Runtime B caller behavior and backend acceptance -fixture required to prove the reverse nested direction. Runtime B may reuse the -existing Agent SDK and Router Agent v1 boundary, but it must not gain a direct -Runtime A endpoint, platform storage access, a new runtime framework, or a -second public contract. +Add only the deterministic Runtime B caller behavior, the corresponding +Runtime A responder fixture, and backend acceptance required to prove the +reverse nested direction. Runtime B and Runtime A may reuse the existing Agent +SDK and Router Agent v1 boundary, but neither may gain a direct peer endpoint, +platform storage access, a new runtime framework, or a second public contract. ## PR and Review Slices diff --git a/specs/027-console-trusted-publication/quickstart.md b/specs/027-console-trusted-publication/quickstart.md index db07a6d..e86fd54 100644 --- a/specs/027-console-trusted-publication/quickstart.md +++ b/specs/027-console-trusted-publication/quickstart.md @@ -82,3 +82,5 @@ docker compose --project-name nekiro-console-acceptance --file deploy/compose.ya The acceptance must include the reverse B -> Router -> A lineage and must report exact Release IDs, parent/child Invocation IDs, and Trace IDs on failure. +Runtime A and Runtime B must each receive separate explicit Router Agent +tokens and response/event limits; missing values must fail Compose interpolation. diff --git a/specs/027-console-trusted-publication/tasks.md b/specs/027-console-trusted-publication/tasks.md index 139514d..16562e2 100644 --- a/specs/027-console-trusted-publication/tasks.md +++ b/specs/027-console-trusted-publication/tasks.md @@ -97,7 +97,7 @@ Implementation PR: https://github.com/NeKiro-project/NeKiro-Console/pull/6 ### Implementation -- [ ] T025 [US4] Extend the deterministic Runtime B sample with explicit Router URL/token, target Agent ID/capability, response/event limits, and Router Agent credential configuration; inject the existing Agent SDK and invoke only the configured target through Agent Router v1 for the reverse fixture. Do not add a direct Runtime A endpoint, platform storage access, a new runtime framework, retry, or fallback. +- [ ] T025 [US4] Extend the deterministic Runtime B sample with explicit Router URL/token, target Agent ID/capability, response/event limits, and Router Agent credential configuration; inject the existing Agent SDK and invoke only the configured target through Agent Router v1 for the reverse fixture. Extend the existing Runtime A adapter with a separately declared deterministic responder capability while preserving its `runtime.cross` caller path. Do not add a direct peer endpoint, platform storage access, a new runtime framework, retry, or fallback. - [ ] T026 [US4] Add the reverse fixture setup and assertions in `tests/e2e/invoke-record/invoke_record_test.go` for B root -> A child `parentInvocationId`, one `rootTaskId`, one `traceId`, distinct exact Release IDs/Card digests, returned result correlation, and no direct endpoint access or exposed Agent host port. ### Tests diff --git a/tests/e2e/invoke-record/invoke_record_test.go b/tests/e2e/invoke-record/invoke_record_test.go index f4e9511..261569d 100644 --- a/tests/e2e/invoke-record/invoke_record_test.go +++ b/tests/e2e/invoke-record/invoke_record_test.go @@ -120,14 +120,14 @@ func TestInvokeToRecordAcceptance(t *testing.T) { if result := doRequest(t, client, env.controlPlane+"/readyz", http.MethodGet, "", "", nil); result.status != http.StatusNoContent { t.Fatalf("Control Plane readiness status=%d body=%s", result.status, result.body) } - runtimeA := acceptanceCard("runtime-a", "Runtime A", "http://runtime-a:8091", "runtime.cross", nil, false) + runtimeA := acceptanceCardWithCapabilities("runtime-a", "Runtime A", "http://runtime-a:8091", []string{"runtime.cross", "runtime.echo"}, nil, false) runtimeB := acceptanceCard("runtime-b", "Runtime B", "http://runtime-b:8092", "runtime.echo", []string{"text.read"}, true) runtimeProtocol := acceptanceCard("runtime-protocol", "Runtime Protocol Fixture", "http://runtime-b:8092", "runtime.protocol", nil, false) runtimeRoute := acceptanceCard("runtime-route", "Runtime Route Fixture", "http://runtime-b:8092/unavailable", "runtime.route", nil, false) runtimeTimeout := acceptanceCardWithTimeout("runtime-timeout", "Runtime Timeout Fixture", "http://runtime-b:8092", "runtime.timeout", nil, true, 50) runtimeInterrupted := acceptanceCard("runtime-interrupted", "Runtime Interrupted Fixture", "http://runtime-b:8092", "runtime.interrupted", nil, true) runtimeLifecycle := acceptanceCard("runtime-lifecycle", "Runtime Lifecycle Fixture", "http://runtime-b:8092", "runtime.echo", nil, false) - registerAndPublish(t, client, &env, runtimeA) + runtimeARelease := registerAndPublish(t, client, &env, runtimeA) runtimeBRelease := registerAndPublish(t, client, &env, runtimeB) registerAndPublish(t, client, &env, runtimeProtocol) registerAndPublish(t, client, &env, runtimeRoute) @@ -143,6 +143,7 @@ func TestInvokeToRecordAcceptance(t *testing.T) { assertNoForbiddenBody(t, discovery.body, env.forbidden, "Discovery Card response") createWorkspace(t, client, env, acceptanceWorkspace, env.ownerToken) assertDirectAgentRequestIsRejected(t, client, &env, runtimeBRelease) + assertAgentPortsNotExposed(t, &env) install(t, client, env, acceptanceWorkspace, "runtime-a", []string{}) runtimeBInstallation := install(t, client, env, acceptanceWorkspace, "runtime-b", []string{"text.read"}) install(t, client, env, acceptanceWorkspace, "runtime-protocol", []string{}) @@ -185,6 +186,22 @@ func TestInvokeToRecordAcceptance(t *testing.T) { assertTraceRecords(t, client, env, trace) assertQueryableRelease(t, client, env, env.releases["runtime-a"]) assertQueryableRelease(t, client, env, runtimeBRelease) + + reverse := invokeJSON(t, client, env, "runtime-b", "runtime.echo", map[string]any{"fixture": "nested", "value": "reverse-nested-value"}) + if reverse.result.Status != "succeeded" { + t.Fatalf("reverse result=%s", reverse.result.Result) + } + var reversePayload struct { + Agent string `json:"agent"` + Fixture string `json:"fixture"` + ChildInvocationID string `json:"childInvocationId"` + ChildResult json.RawMessage `json:"childResult"` + } + if err := json.Unmarshal(reverse.result.Result, &reversePayload); err != nil || reversePayload.Agent != "runtime-b" || reversePayload.Fixture != "nested" || reversePayload.ChildInvocationID == "" || !bytes.Contains(reversePayload.ChildResult, []byte(`"agent":"runtime-a"`)) { + t.Fatalf("reverse result payload=%#v err=%v", reversePayload, err) + } + reverseTrace := readTrace(t, client, env, reverse.result.TraceID) + assertReverseTrace(t, client, env, reverseTrace, reverse.result.InvocationID, reversePayload.ChildInvocationID, runtimeBRelease, runtimeARelease) assertInstallationAndReleaseGates(t, client, &env, runtimeBInstallation, runtimeLifecycleInstallation, runtimeLifecycleRelease) otherWorkspace := "workspace-other" @@ -256,20 +273,34 @@ func composeCommand(ctx context.Context, env acceptanceEnv, args ...string) *exe } func acceptanceCard(agentID, name, endpoint, capability string, permissions []string, streaming bool) []byte { - return acceptanceCardWithTimeout(agentID, name, endpoint, capability, permissions, streaming, 30000) + return acceptanceCardWithCapabilities(agentID, name, endpoint, []string{capability}, permissions, streaming) } func acceptanceCardWithTimeout(agentID, name, endpoint, capability string, permissions []string, streaming bool, timeoutMS int64) []byte { + return acceptanceCardWithTimeoutAndCapabilities(agentID, name, endpoint, []string{capability}, permissions, streaming, timeoutMS) +} + +func acceptanceCardWithCapabilities(agentID, name, endpoint string, capabilities, permissions []string, streaming bool) []byte { + return acceptanceCardWithTimeoutAndCapabilities(agentID, name, endpoint, capabilities, permissions, streaming, 30000) +} + +func acceptanceCardWithTimeoutAndCapabilities(agentID, name, endpoint string, capabilities, permissions []string, streaming bool, timeoutMS int64) []byte { if permissions == nil { permissions = []string{} } + if len(capabilities) == 0 { + panic("acceptance card requires a capability") + } card := contracts.AgentCard{ SchemaVersion: contracts.AgentCardSchemaVersion, AgentID: agentID, Name: name, Description: "Deterministic acceptance Agent", Owner: contracts.AgentOwner{ID: "acceptance-owner", DisplayName: "Acceptance Owner"}, Version: "1.0.0", Protocol: contracts.AgentProtocol{Type: "a2a", Version: contracts.A2AProtocolVersion, Transport: "JSONRPC", Endpoint: endpoint}, - Skills: []contracts.AgentSkill{{ID: capability, Name: capability, Description: "Acceptance capability", InputSchema: contracts.JSONSchema{"type": "object"}, OutputSchema: contracts.JSONSchema{"type": "object"}, RequiredPermissions: permissions}}, Authentication: contracts.AgentAuthentication{Type: "http_bearer"}, Limits: contracts.AgentLimits{TimeoutMS: timeoutMS, MaxInputBytes: json.Number("1048576"), MaxOutputBytes: json.Number("1048576"), Streaming: streaming}, } + card.Skills = make([]contracts.AgentSkill, 0, len(capabilities)) + for _, capability := range capabilities { + card.Skills = append(card.Skills, contracts.AgentSkill{ID: capability, Name: capability, Description: "Acceptance capability", InputSchema: contracts.JSONSchema{"type": "object"}, OutputSchema: contracts.JSONSchema{"type": "object"}, RequiredPermissions: permissions}) + } card.Permissions = make([]contracts.PermissionDeclaration, 0, len(permissions)) for _, permission := range permissions { card.Permissions = append(card.Permissions, contracts.PermissionDeclaration{ID: permission, Description: permission}) @@ -281,6 +312,17 @@ func acceptanceCardWithTimeout(agentID, name, endpoint, capability string, permi return encoded } +func assertAgentPortsNotExposed(t *testing.T, env *acceptanceEnv) { + t.Helper() + for _, target := range []struct{ service, port string }{{service: "runtime-a", port: "8091"}, {service: "runtime-b", port: "8092"}} { + command := composeCommand(t.Context(), *env, "port", target.service, target.port) + output, err := command.CombinedOutput() + if err == nil && len(bytes.TrimSpace(output)) != 0 { + t.Fatalf("Agent service %s exposes a host port: %s", target.service, output) + } + } +} + func assertDirectAgentRequestIsRejected(t *testing.T, client *http.Client, env *acceptanceEnv, release contracts.AgentReleaseResponse) { t.Helper() const content = "direct-agent-must-not-execute" @@ -974,6 +1016,48 @@ func assertTraceRecords(t *testing.T, client *http.Client, env acceptanceEnv, tr } } +func assertReverseTrace(t *testing.T, client *http.Client, env acceptanceEnv, trace traceRead, rootInvocationID, childInvocationID string, rootRelease, childRelease contracts.AgentReleaseResponse) { + t.Helper() + if len(trace.Invocations) != 2 { + t.Fatalf("reverse Trace invocations=%d body=%s", len(trace.Invocations), trace.raw) + } + var root, child *contracts.InvocationRecordV4 + for index := range trace.Invocations { + projection := &trace.Invocations[index] + switch projection.TargetAgentID { + case "runtime-b": + if root != nil { + t.Fatalf("reverse Trace contains multiple B projections: %#v", trace.Invocations) + } + root = projection + case "runtime-a": + if child != nil { + t.Fatalf("reverse Trace contains multiple A projections: %#v", trace.Invocations) + } + child = projection + default: + t.Fatalf("reverse Trace contains unexpected Agent: %#v", projection) + } + } + if root == nil || child == nil || root.InvocationID != rootInvocationID || child.InvocationID != childInvocationID || root.InvocationID == child.InvocationID || root.ParentInvocationID != "" || child.ParentInvocationID != root.InvocationID || root.RootTaskID != child.RootTaskID || root.TraceID != child.TraceID || root.TraceID != trace.TraceID { + t.Fatalf("reverse Trace lineage root=%#v child=%#v trace=%#v", root, child, trace) + } + if root.AgentReleaseID == child.AgentReleaseID || root.AgentCardDigest == child.AgentCardDigest { + t.Fatalf("reverse Trace provenance is not distinct root=%#v child=%#v", root, child) + } + if err := validateExpectedReleaseProvenance(*root, rootRelease); err != nil { + t.Fatal(err) + } + if err := validateExpectedReleaseProvenance(*child, childRelease); err != nil { + t.Fatal(err) + } + assertRecord(t, client, env, root.InvocationID, acceptanceWorkspace, "runtime-b", "succeeded", "") + assertRecord(t, client, env, child.InvocationID, acceptanceWorkspace, "runtime-a", "succeeded", "") + assertQueryableRelease(t, client, env, rootRelease) + assertQueryableRelease(t, client, env, childRelease) + assertTraceRecords(t, client, env, trace) +} + func assertNoForbiddenBody(t *testing.T, body []byte, forbidden []string, surface string) { t.Helper() if err := forbiddenBodyError(body, forbidden, surface); err != nil { From 74629198790a7f2f4bf78e930937c9625d93ca2b Mon Sep 17 00:00:00 2001 From: Nene7ko_ <1604009816@qq.com> Date: Mon, 27 Jul 2026 00:30:55 +0800 Subject: [PATCH 04/17] test: harden reverse acceptance review gates --- .github/workflows/ci.yml | 11 ++++++++- tests/e2e/invoke-record/invoke_record_test.go | 24 +++++++++++++++---- 2 files changed, 29 insertions(+), 6 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index d795f24..df602d4 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -204,6 +204,15 @@ jobs: working-directory: agents/runtime-a run: go test -race ./... + - name: Test Runtime B sample + run: go test -count=1 ./agents/runtime-b ./agents/runtime-b/cmd/runtime-b + + - name: Vet Runtime B sample + run: go vet ./agents/runtime-b ./agents/runtime-b/cmd/runtime-b + + - name: Race test Runtime B sample + run: go test -race ./agents/runtime-b ./agents/runtime-b/cmd/runtime-b + backend-acceptance: runs-on: ubuntu-latest timeout-minutes: 35 @@ -291,7 +300,7 @@ jobs: NEKIRO_ROUTER_INTERNAL_BEARER_TOKEN NEKIRO_CONTROL_PLANE_SERVICE_TOKEN NEKIRO_ROUTER_AGENT_CREDENTIAL_KEY_ID NEKIRO_ROUTER_AGENT_CREDENTIAL_PRIVATE_KEY_BASE64URL NEKIRO_AGENT_ROUTER_KEY_ID NEKIRO_AGENT_ROUTER_PUBLIC_KEY_BASE64URL - RUNTIME_A_ROUTER_TOKEN NEKIRO_E2E_ROUTER_TOKEN NEKIRO_E2E_OWNER_TOKEN + RUNTIME_A_ROUTER_TOKEN RUNTIME_B_ROUTER_TOKEN NEKIRO_E2E_ROUTER_TOKEN NEKIRO_E2E_OWNER_TOKEN NEKIRO_E2E_USER_TOKEN NEKIRO_E2E_OTHER_TOKEN NEKIRO_E2E_DATABASE_URL ); @secrets = grep { defined($_) && length($_) } @ENV{@secret_names}; diff --git a/tests/e2e/invoke-record/invoke_record_test.go b/tests/e2e/invoke-record/invoke_record_test.go index 261569d..2d2ba41 100644 --- a/tests/e2e/invoke-record/invoke_record_test.go +++ b/tests/e2e/invoke-record/invoke_record_test.go @@ -314,11 +314,25 @@ func acceptanceCardWithTimeoutAndCapabilities(agentID, name, endpoint string, ca func assertAgentPortsNotExposed(t *testing.T, env *acceptanceEnv) { t.Helper() - for _, target := range []struct{ service, port string }{{service: "runtime-a", port: "8091"}, {service: "runtime-b", port: "8092"}} { - command := composeCommand(t.Context(), *env, "port", target.service, target.port) - output, err := command.CombinedOutput() - if err == nil && len(bytes.TrimSpace(output)) != 0 { - t.Fatalf("Agent service %s exposes a host port: %s", target.service, output) + output, err := composeCommand(t.Context(), *env, "config", "--format", "json").CombinedOutput() + if err != nil { + t.Fatalf("Compose config for Agent port isolation failed: %v output=%s", err, output) + } + var configuration struct { + Services map[string]struct { + Ports []json.RawMessage `json:"ports"` + } `json:"services"` + } + if err := json.Unmarshal(output, &configuration); err != nil { + t.Fatalf("decode Compose config for Agent port isolation: %v", err) + } + for _, service := range []string{"runtime-a", "runtime-b"} { + value, exists := configuration.Services[service] + if !exists { + t.Fatalf("Compose config has no Agent service %s", service) + } + if len(value.Ports) != 0 { + t.Fatalf("Agent service %s exposes host ports: %s", service, value.Ports) } } } From 02d50032226c58da8af55d824b1adefd64959055 Mon Sep 17 00:00:00 2001 From: Nene7ko_ <1604009816@qq.com> Date: Mon, 27 Jul 2026 00:36:45 +0800 Subject: [PATCH 05/17] docs(spec): record reverse slice delivery state --- specs/027-console-trusted-publication/tasks.md | 16 +++++++++++++--- 1 file changed, 13 insertions(+), 3 deletions(-) diff --git a/specs/027-console-trusted-publication/tasks.md b/specs/027-console-trusted-publication/tasks.md index 16562e2..03a113b 100644 --- a/specs/027-console-trusted-publication/tasks.md +++ b/specs/027-console-trusted-publication/tasks.md @@ -93,12 +93,12 @@ Implementation PR: https://github.com/NeKiro-project/NeKiro-Console/pull/6 **Pre-implementation gate**: -- [ ] T024 [US4] Launch a subagent to read NeKiro Issue #60, Spec 027 US4, FR-009 through FR-011/FR-016, Specs 019-021/026, and the existing `tests/e2e/invoke-record/invoke_record_test.go`. Record the exact fixture direction, expected Release provenance, direct-access boundary, and forbidden fallback behavior. +- [X] T024 [US4] Launch a subagent to read NeKiro Issue #60, Spec 027 US4, FR-009 through FR-011/FR-016, Specs 019-021/026, and the existing `tests/e2e/invoke-record/invoke_record_test.go`. Record the exact fixture direction, expected Release provenance, direct-access boundary, and forbidden fallback behavior. ### Implementation -- [ ] T025 [US4] Extend the deterministic Runtime B sample with explicit Router URL/token, target Agent ID/capability, response/event limits, and Router Agent credential configuration; inject the existing Agent SDK and invoke only the configured target through Agent Router v1 for the reverse fixture. Extend the existing Runtime A adapter with a separately declared deterministic responder capability while preserving its `runtime.cross` caller path. Do not add a direct peer endpoint, platform storage access, a new runtime framework, retry, or fallback. -- [ ] T026 [US4] Add the reverse fixture setup and assertions in `tests/e2e/invoke-record/invoke_record_test.go` for B root -> A child `parentInvocationId`, one `rootTaskId`, one `traceId`, distinct exact Release IDs/Card digests, returned result correlation, and no direct endpoint access or exposed Agent host port. +- [X] T025 [US4] Extend the deterministic Runtime B sample with explicit Router URL/token, target Agent ID/capability, response/event limits, and Router Agent credential configuration; inject the existing Agent SDK and invoke only the configured target through Agent Router v1 for the reverse fixture. Extend the existing Runtime A adapter with a separately declared deterministic responder capability while preserving its `runtime.cross` caller path. Do not add a direct peer endpoint, platform storage access, a new runtime framework, retry, or fallback. +- [X] T026 [US4] Add the reverse fixture setup and assertions in `tests/e2e/invoke-record/invoke_record_test.go` for B root -> A child `parentInvocationId`, one `rootTaskId`, one `traceId`, distinct exact Release IDs/Card digests, returned result correlation, and no direct endpoint access or exposed Agent host port. ### Tests @@ -108,6 +108,16 @@ Implementation PR: https://github.com/NeKiro-project/NeKiro-Console/pull/6 - [ ] T028 [US4] Launch an independent review agent for NeKiro Issue #60 against the Slice C diff, Specs 019-027, active A2A/Router/Invocation contracts, and acceptance evidence; do not proceed to Slice D until it reports no High/Medium findings. +Slice C execution record: T024 pre-implementation scope review completed by +the Zeno subagent; T025-T026 implemented in commits `92f290c` and `7462919`. +The first independent review found three Medium issues, all fixed in +`7462919`; the second review found no code High finding and only this task +bookkeeping gap. T027 remains pending because this workstation has no Docker +daemon and could not download the uncached Go modules; `docker compose config +--quiet`, `docker compose config --format json` wiring, Runtime B production +build, SDK tests, and Runtime B command vet passed locally. T028 remains +pending until a fresh review is run after this record update. + ## Phase 5: Slice D - Real frontend CI and browser acceptance (Console Issue #3 / parent #59) **Goal**: Make CI execute the real production Console and visibly prove the complete loop in a fresh environment. From 83e81b8be9a8df09b0602274db5ee5a3fde9603c Mon Sep 17 00:00:00 2001 From: Nene7ko_ <1604009816@qq.com> Date: Mon, 27 Jul 2026 00:42:02 +0800 Subject: [PATCH 06/17] fix: validate reverse runtime response shape --- agents/runtime-b/config.go | 2 +- tests/e2e/invoke-record/invoke_record_test.go | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/agents/runtime-b/config.go b/agents/runtime-b/config.go index 1989149..57ce8d2 100644 --- a/agents/runtime-b/config.go +++ b/agents/runtime-b/config.go @@ -175,7 +175,7 @@ func requiredLimit(lookup func(string) (string, bool), name string) (int64, erro func validateRouterURL(value string) error { parsed, err := url.Parse(value) - if err != nil || (parsed.Scheme != "http" && parsed.Scheme != "https") || parsed.Host == "" || parsed.User != nil || parsed.Path != "" || parsed.RawPath != "" || parsed.RawQuery != "" || parsed.ForceQuery || parsed.Fragment != "" || parsed.RawFragment != "" { + if err != nil || (parsed.Scheme != "http" && parsed.Scheme != "https") || parsed.Host == "" || parsed.User != nil || parsed.Path != "" || parsed.RawPath != "" || parsed.RawQuery != "" || parsed.ForceQuery || strings.Contains(value, "#") || parsed.Fragment != "" || parsed.RawFragment != "" { return fmt.Errorf("%s must be an http or https origin URL without credentials, path, query, or fragment", RouterEnvironment) } if parsed.Port() != "" { diff --git a/tests/e2e/invoke-record/invoke_record_test.go b/tests/e2e/invoke-record/invoke_record_test.go index 2d2ba41..d67d62e 100644 --- a/tests/e2e/invoke-record/invoke_record_test.go +++ b/tests/e2e/invoke-record/invoke_record_test.go @@ -198,7 +198,7 @@ func TestInvokeToRecordAcceptance(t *testing.T) { ChildResult json.RawMessage `json:"childResult"` } if err := json.Unmarshal(reverse.result.Result, &reversePayload); err != nil || reversePayload.Agent != "runtime-b" || reversePayload.Fixture != "nested" || reversePayload.ChildInvocationID == "" || !bytes.Contains(reversePayload.ChildResult, []byte(`"agent":"runtime-a"`)) { - t.Fatalf("reverse result payload=%#v err=%v", reversePayload, err) + t.Fatalf("reverse result body=%s payload=%#v err=%v", reverse.result.Result, reversePayload, err) } reverseTrace := readTrace(t, client, env, reverse.result.TraceID) assertReverseTrace(t, client, env, reverseTrace, reverse.result.InvocationID, reversePayload.ChildInvocationID, runtimeBRelease, runtimeARelease) From b1ef7e122041f7a99c417a0fd78ca537dd57ae3a Mon Sep 17 00:00:00 2001 From: Nene7ko_ <1604009816@qq.com> Date: Mon, 27 Jul 2026 00:46:42 +0800 Subject: [PATCH 07/17] test: decode reverse A2A message envelope --- tests/e2e/invoke-record/invoke_record_test.go | 10 ++++++++-- 1 file changed, 8 insertions(+), 2 deletions(-) diff --git a/tests/e2e/invoke-record/invoke_record_test.go b/tests/e2e/invoke-record/invoke_record_test.go index d67d62e..c935510 100644 --- a/tests/e2e/invoke-record/invoke_record_test.go +++ b/tests/e2e/invoke-record/invoke_record_test.go @@ -197,8 +197,14 @@ func TestInvokeToRecordAcceptance(t *testing.T) { ChildInvocationID string `json:"childInvocationId"` ChildResult json.RawMessage `json:"childResult"` } - if err := json.Unmarshal(reverse.result.Result, &reversePayload); err != nil || reversePayload.Agent != "runtime-b" || reversePayload.Fixture != "nested" || reversePayload.ChildInvocationID == "" || !bytes.Contains(reversePayload.ChildResult, []byte(`"agent":"runtime-a"`)) { - t.Fatalf("reverse result body=%s payload=%#v err=%v", reverse.result.Result, reversePayload, err) + var reverseMessage struct { + Parts []struct { + Kind string `json:"kind"` + Data json.RawMessage `json:"data"` + } `json:"parts"` + } + if err := json.Unmarshal(reverse.result.Result, &reverseMessage); err != nil || len(reverseMessage.Parts) != 1 || reverseMessage.Parts[0].Kind != "data" || json.Unmarshal(reverseMessage.Parts[0].Data, &reversePayload) != nil || reversePayload.Agent != "runtime-b" || reversePayload.Fixture != "nested" || reversePayload.ChildInvocationID == "" || !bytes.Contains(reversePayload.ChildResult, []byte(`"agent":"runtime-a"`)) { + t.Fatalf("reverse result body=%s message=%#v payload=%#v err=%v", reverse.result.Result, reverseMessage, reversePayload, err) } reverseTrace := readTrace(t, client, env, reverse.result.TraceID) assertReverseTrace(t, client, env, reverseTrace, reverse.result.InvocationID, reversePayload.ChildInvocationID, runtimeBRelease, runtimeARelease) From 004248d6b0eaef9c38a99a896b625b090947f359 Mon Sep 17 00:00:00 2001 From: Nene7ko_ <1604009816@qq.com> Date: Mon, 27 Jul 2026 01:08:33 +0800 Subject: [PATCH 08/17] fix(runtime-b): close reverse fixture review gaps --- agents/runtime-b/config.go | 2 +- agents/runtime-b/config_test.go | 2 +- agents/runtime-b/nested.go | 6 ++++- agents/runtime-b/nested_test.go | 11 ++++++++++ .../027-console-trusted-publication/tasks.md | 22 ++++++++++++++----- 5 files changed, 34 insertions(+), 9 deletions(-) diff --git a/agents/runtime-b/config.go b/agents/runtime-b/config.go index 57ce8d2..3d22862 100644 --- a/agents/runtime-b/config.go +++ b/agents/runtime-b/config.go @@ -175,7 +175,7 @@ func requiredLimit(lookup func(string) (string, bool), name string) (int64, erro func validateRouterURL(value string) error { parsed, err := url.Parse(value) - if err != nil || (parsed.Scheme != "http" && parsed.Scheme != "https") || parsed.Host == "" || parsed.User != nil || parsed.Path != "" || parsed.RawPath != "" || parsed.RawQuery != "" || parsed.ForceQuery || strings.Contains(value, "#") || parsed.Fragment != "" || parsed.RawFragment != "" { + if err != nil || (parsed.Scheme != "http" && parsed.Scheme != "https") || parsed.Host == "" || strings.HasSuffix(parsed.Host, ":") || parsed.User != nil || parsed.Path != "" || parsed.RawPath != "" || parsed.RawQuery != "" || parsed.ForceQuery || strings.Contains(value, "#") || parsed.Fragment != "" || parsed.RawFragment != "" { return fmt.Errorf("%s must be an http or https origin URL without credentials, path, query, or fragment", RouterEnvironment) } if parsed.Port() != "" { diff --git a/agents/runtime-b/config_test.go b/agents/runtime-b/config_test.go index d08202f..10c5a25 100644 --- a/agents/runtime-b/config_test.go +++ b/agents/runtime-b/config_test.go @@ -62,7 +62,7 @@ func TestLoadConfigRejectsInvalidValuesWithoutDefaults(t *testing.T) { } }) } - for _, value := range []string{"http://127.0.0.1:4101/", "http://127.0.0.1:4101?", "http://127.0.0.1:65536", "http://127.0.0.1:4101#"} { + for _, value := range []string{"http://127.0.0.1:4101/", "http://127.0.0.1:4101?", "http://127.0.0.1:65536", "http://127.0.0.1:4101#", "http://a2a-router:"} { environment := validRuntimeBEnvironment() environment[RouterEnvironment] = value if _, err := LoadConfig(runtimeBLookup(environment)); err == nil { diff --git a/agents/runtime-b/nested.go b/agents/runtime-b/nested.go index 9e78bb7..8cabaef 100644 --- a/agents/runtime-b/nested.go +++ b/agents/runtime-b/nested.go @@ -74,9 +74,13 @@ func (service *nestedService) invoke(ctx context.Context, platformContext agents } func nestedMessage(input *a2a.Message, result *agentsdk.NestedResult) *a2a.Message { + contextID := input.ContextID + if contextID == "" { + contextID = derivedID("context", input.ID) + } return &a2a.Message{ ID: "runtime-b-nested-result-" + input.ID, - ContextID: input.ContextID, + ContextID: contextID, Role: a2a.MessageRoleAgent, Parts: []a2a.Part{a2a.DataPart{Data: map[string]any{ "agent": "runtime-b", diff --git a/agents/runtime-b/nested_test.go b/agents/runtime-b/nested_test.go index 2e6344f..cec3b05 100644 --- a/agents/runtime-b/nested_test.go +++ b/agents/runtime-b/nested_test.go @@ -8,6 +8,7 @@ import ( "github.com/Nene7ko/NeKiro/contracts" agentsdk "github.com/Nene7ko/NeKiro/sdks/agent-sdk" + "github.com/a2aproject/a2a-go/a2a" ) type runtimeBNestedRecordingInvoker struct { @@ -78,3 +79,13 @@ func TestPlatformContextFromClaimsRequiresExactAgentIdentity(t *testing.T) { t.Fatal("wrong Agent identity was accepted") } } + +func TestNestedMessageDerivesMissingContextID(t *testing.T) { + input := &a2a.Message{ID: "root-message"} + result := &agentsdk.NestedResult{InvocationID: "child-1", Result: json.RawMessage(`{"ok":true}`)} + + message := nestedMessage(input, result) + if message.ContextID != derivedID("context", input.ID) { + t.Fatalf("nested message context ID = %q", message.ContextID) + } +} diff --git a/specs/027-console-trusted-publication/tasks.md b/specs/027-console-trusted-publication/tasks.md index 03a113b..9b0e6ec 100644 --- a/specs/027-console-trusted-publication/tasks.md +++ b/specs/027-console-trusted-publication/tasks.md @@ -102,7 +102,7 @@ Implementation PR: https://github.com/NeKiro-project/NeKiro-Console/pull/6 ### Tests -- [ ] T027 [US4] Run the focused clean Compose E2E and existing backend unit/contract/race/vet gates required by the modified acceptance; preserve exact failures for unavailable endpoint, timeout, cancellation, and credential boundary cases. +- [x] T027 [US4] Run the focused clean Compose E2E and existing backend unit/contract/race/vet gates required by the modified acceptance; preserve exact failures for unavailable endpoint, timeout, cancellation, and credential boundary cases. **Review gate**: @@ -112,11 +112,21 @@ Slice C execution record: T024 pre-implementation scope review completed by the Zeno subagent; T025-T026 implemented in commits `92f290c` and `7462919`. The first independent review found three Medium issues, all fixed in `7462919`; the second review found no code High finding and only this task -bookkeeping gap. T027 remains pending because this workstation has no Docker -daemon and could not download the uncached Go modules; `docker compose config ---quiet`, `docker compose config --format json` wiring, Runtime B production -build, SDK tests, and Runtime B command vet passed locally. T028 remains -pending until a fresh review is run after this record update. +bookkeeping gap. T027 was then verified by CI run `30211184685`: clean Compose +backend acceptance, Runtime A/B test-vet-race gates, workspace integration, +Compose configuration, frontend checks, and root Go test/race passed. The +fork-only Codecov upload step failed after coverage completed, so its skipped +vet/lint steps are an infrastructure/reporting limitation rather than a +runtime acceptance failure. This workstation still has no Docker daemon and +cannot download uncached Go modules. The fresh Slice C review then found two +Medium boundary issues: nested responses did not derive a missing A2A context +ID, and Runtime B accepted a Router origin with an explicit empty port. Both +were fixed with regression coverage in the current follow-up commit. The +review's two Low observations (an additional nested input cap and an explicit +HTTP client timeout) remain non-blocking because the managed Router path +already enforces the active request deadline/byte limits; no new policy was +approved for expanding this slice. T028 remains pending until CI validates the +fixes and a new independent review reports no High/Medium findings. ## Phase 5: Slice D - Real frontend CI and browser acceptance (Console Issue #3 / parent #59) From dec548e7b85da79c5982167688f0c698677d47c7 Mon Sep 17 00:00:00 2001 From: Nene7ko_ <1604009816@qq.com> Date: Mon, 27 Jul 2026 01:18:13 +0800 Subject: [PATCH 09/17] docs(spec): record reverse fixture review pass --- specs/027-console-trusted-publication/tasks.md | 9 ++++++++- 1 file changed, 8 insertions(+), 1 deletion(-) diff --git a/specs/027-console-trusted-publication/tasks.md b/specs/027-console-trusted-publication/tasks.md index 9b0e6ec..22ba1ea 100644 --- a/specs/027-console-trusted-publication/tasks.md +++ b/specs/027-console-trusted-publication/tasks.md @@ -106,7 +106,7 @@ Implementation PR: https://github.com/NeKiro-project/NeKiro-Console/pull/6 **Review gate**: -- [ ] T028 [US4] Launch an independent review agent for NeKiro Issue #60 against the Slice C diff, Specs 019-027, active A2A/Router/Invocation contracts, and acceptance evidence; do not proceed to Slice D until it reports no High/Medium findings. +- [x] T028 [US4] Launch an independent review agent for NeKiro Issue #60 against the Slice C diff, Specs 019-027, active A2A/Router/Invocation contracts, and acceptance evidence; do not proceed to Slice D until it reports no High/Medium findings. Slice C execution record: T024 pre-implementation scope review completed by the Zeno subagent; T025-T026 implemented in commits `92f290c` and `7462919`. @@ -128,6 +128,13 @@ already enforces the active request deadline/byte limits; no new policy was approved for expanding this slice. T028 remains pending until CI validates the fixes and a new independent review reports no High/Medium findings. +The second fresh independent review completed with PASS (0 High, 0 Medium, +2 non-blocking Low findings). It confirmed the context derivation and empty +port fixes, reverse and forward cross-runtime paths, lineage/provenance, +Router credential boundary, host-port isolation, and secret/fallback scans. +The two Low findings remain outside this slice because the managed Router +already supplies the active deadline and input byte limits. + ## Phase 5: Slice D - Real frontend CI and browser acceptance (Console Issue #3 / parent #59) **Goal**: Make CI execute the real production Console and visibly prove the complete loop in a fresh environment. From c49f6ad6cb429684bcfb6514752599b869e98a0d Mon Sep 17 00:00:00 2001 From: Nene7ko_ <1604009816@qq.com> Date: Mon, 27 Jul 2026 17:55:37 +0800 Subject: [PATCH 10/17] feat(console): integrate production browser acceptance --- .github/workflows/ci.yml | 103 ++ apps/console/.env.example | 7 + apps/console/e2e/console.spec.ts | 313 ++++ apps/console/index.html | 12 + apps/console/package.json | 35 + apps/console/playwright.config.ts | 32 + apps/console/public/favicon.ico | 4 + apps/console/src/App.tsx | 394 +++++ apps/console/src/api/nekiro.test.ts | 604 +++++++ apps/console/src/api/nekiro.ts | 1464 +++++++++++++++++ apps/console/src/components/Header.tsx | 103 ++ .../src/components/InstallationsTab.tsx | 298 ++++ .../console/src/components/InvocationsTab.tsx | 167 ++ apps/console/src/components/LedgerTab.tsx | 88 + apps/console/src/components/RegistryTab.tsx | 366 +++++ apps/console/src/components/Sidebar.tsx | 87 + .../src/components/TrustedPublicationTab.tsx | 331 ++++ .../src/components/consoleSurface.test.tsx | 81 + apps/console/src/consoleConfig.test.ts | 28 + apps/console/src/consoleConfig.ts | 16 + apps/console/src/consolePolicy.test.ts | 39 + apps/console/src/consolePolicy.ts | 34 + apps/console/src/data.ts | 11 + apps/console/src/demos/DemoRoot.tsx | 115 ++ apps/console/src/demos/glass/GlassDemo.tsx | 374 +++++ apps/console/src/demos/mockData.ts | 128 ++ apps/console/src/demos/routing.test.ts | 14 + apps/console/src/demos/routing.ts | 16 + apps/console/src/demos/saas/SaasDemo.tsx | 23 + .../src/demos/terminal/TerminalDemo.tsx | 65 + apps/console/src/index.css | 444 +++++ apps/console/src/main.tsx | 54 + apps/console/src/types.ts | 80 + apps/console/src/vite-env.d.ts | 1 + apps/console/tsconfig.json | 26 + apps/console/vite.config.ts | 18 + docs/runbooks/local-development.md | 47 + package.json | 6 +- pnpm-lock.yaml | 1293 ++++++++++++++- .../quickstart.md | 32 + .../027-console-trusted-publication/tasks.md | 40 +- 41 files changed, 7377 insertions(+), 16 deletions(-) create mode 100644 apps/console/.env.example create mode 100644 apps/console/e2e/console.spec.ts create mode 100644 apps/console/index.html create mode 100644 apps/console/package.json create mode 100644 apps/console/playwright.config.ts create mode 100644 apps/console/public/favicon.ico create mode 100644 apps/console/src/App.tsx create mode 100644 apps/console/src/api/nekiro.test.ts create mode 100644 apps/console/src/api/nekiro.ts create mode 100644 apps/console/src/components/Header.tsx create mode 100644 apps/console/src/components/InstallationsTab.tsx create mode 100644 apps/console/src/components/InvocationsTab.tsx create mode 100644 apps/console/src/components/LedgerTab.tsx create mode 100644 apps/console/src/components/RegistryTab.tsx create mode 100644 apps/console/src/components/Sidebar.tsx create mode 100644 apps/console/src/components/TrustedPublicationTab.tsx create mode 100644 apps/console/src/components/consoleSurface.test.tsx create mode 100644 apps/console/src/consoleConfig.test.ts create mode 100644 apps/console/src/consoleConfig.ts create mode 100644 apps/console/src/consolePolicy.test.ts create mode 100644 apps/console/src/consolePolicy.ts create mode 100644 apps/console/src/data.ts create mode 100644 apps/console/src/demos/DemoRoot.tsx create mode 100644 apps/console/src/demos/glass/GlassDemo.tsx create mode 100644 apps/console/src/demos/mockData.ts create mode 100644 apps/console/src/demos/routing.test.ts create mode 100644 apps/console/src/demos/routing.ts create mode 100644 apps/console/src/demos/saas/SaasDemo.tsx create mode 100644 apps/console/src/demos/terminal/TerminalDemo.tsx create mode 100644 apps/console/src/index.css create mode 100644 apps/console/src/main.tsx create mode 100644 apps/console/src/types.ts create mode 100644 apps/console/src/vite-env.d.ts create mode 100644 apps/console/tsconfig.json create mode 100644 apps/console/vite.config.ts diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index df602d4..118a7fb 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -112,6 +112,10 @@ jobs: - name: Install dependencies run: pnpm install --frozen-lockfile + - name: Verify Console workspace package + run: >- + node -e "const fs=require('fs'); const p=JSON.parse(fs.readFileSync('apps/console/package.json','utf8')); for (const name of ['typecheck','test','build']) { if (typeof p.scripts?.[name] !== 'string' || p.scripts[name].trim() === '') throw new Error('apps/console script is missing: ' + name); }" + - name: Typecheck frontend run: pnpm typecheck @@ -121,6 +125,105 @@ jobs: - name: Build frontend run: pnpm build + console-browser-acceptance: + needs: frontend + runs-on: ubuntu-latest + timeout-minutes: 40 + env: + # Deterministic CI-only fixtures. These values are never production + # configuration and are not written into the imported Console source. + POSTGRES_USER: nekiro_console_acceptance + POSTGRES_PASSWORD: nekiro-console-acceptance-only + POSTGRES_DB: nekiro_console_acceptance + POSTGRES_PORT: "55432" + CONTROL_PLANE_PORT: "18080" + A2A_ROUTER_PORT: "18081" + NEKIRO_COMPOSE_DATABASE_URL: postgresql://nekiro_console_acceptance:nekiro-console-acceptance-only@postgres:5432/nekiro_console_acceptance?sslmode=disable + NEKIRO_DEV_AUTH_PRINCIPALS_JSON: '[{"id":"root-console-provider","tokenSha256":"4def860d949646b1515e6d28096af112224f06cf6a5941ab0ac51b9a458b1252"},{"id":"root-console-owner","tokenSha256":"4162f45cb0487cc2205850cc622fbecaa976a87f7aae8a96fa1676e2a984d2ac"}]' + NEKIRO_INTERNAL_DEV_AUTH_PRINCIPALS_JSON: '[{"id":"root-console-router-internal","tokenSha256":"e3a864d8b9d70000e50e42d94761cc5d5996a36f39137735dca3a830f23cf4ba"}]' + NEKIRO_ROUTER_SERVICE_PRINCIPALS_JSON: '[{"id":"root-console-control-plane","tokenSha256":"4285e7349a2517fbdbfac9c1bc072a5ff1ef702d6cfe1826d62f02d73334bdc0"}]' + NEKIRO_ROUTER_AGENT_PRINCIPALS_JSON: '[{"workspaceId":"root-console-workspace","agentId":"runtime-a","tokenSha256":"fd59489764dad19b9c276a8fb0187fdb187f3de6b426467c0af9659edbe4159f"},{"workspaceId":"root-console-workspace","agentId":"runtime-b","tokenSha256":"43a0b80acc0b4436c4433b8313bc4c41cbb5534751c458f6e2cfe4af602ca34f"}]' + NEKIRO_ROUTER_INTERNAL_BEARER_TOKEN: root-console-router-internal-token + NEKIRO_CONTROL_PLANE_SERVICE_TOKEN: root-console-control-plane-token + NEKIRO_CORS_ALLOWED_ORIGINS: http://127.0.0.1:4173 + NEKIRO_ENDPOINT_CHALLENGE_TTL_SECONDS: "300" + NEKIRO_ENDPOINT_VERIFICATION_TIMEOUT_MS: "10000" + NEKIRO_ENDPOINT_ALLOWED_PRIVATE_HOSTS_JSON: '["runtime-a","runtime-b"]' + NEKIRO_CONTROL_PLANE_INTERNAL_REQUEST_MAX_BYTES: "1048576" + NEKIRO_GATEWAY_INVOCATION_REQUEST_MAX_BYTES: "1048576" + NEKIRO_GATEWAY_SSE_EVENT_MAX_BYTES: "65536" + NEKIRO_GATEWAY_METADATA_RESPONSE_MAX_BYTES: "1048576" + NEKIRO_GATEWAY_INVOCATION_DEADLINE_MS: "30000" + NEKIRO_ROUTER_INTERNAL_REQUEST_LIMIT_BYTES: "1048576" + NEKIRO_ROUTER_AGENT_REQUEST_LIMIT_BYTES: "1048576" + NEKIRO_ROUTER_CONTROL_PLANE_RESPONSE_LIMIT_BYTES: "1048576" + NEKIRO_ROUTER_AGENT_RESPONSE_LIMIT_BYTES: "1048576" + NEKIRO_ROUTER_A2A_EVENT_LIMIT_BYTES: "1048576" + NEKIRO_ROUTER_SSE_EVENT_LIMIT_BYTES: "65536" + NEKIRO_ROUTER_RESOLUTION_DEADLINE_MS: "30000" + NEKIRO_ROUTER_AGENT_DEADLINE_MS: "30000" + NEKIRO_ROUTER_AGENT_CREDENTIAL_ISSUER: https://a2a-router.nekiro.test + NEKIRO_ROUTER_AGENT_CREDENTIAL_KEY_ID: root-console-browser-key-1 + NEKIRO_ROUTER_AGENT_CREDENTIAL_PRIVATE_KEY_BASE64URL: AAECAwQFBgcICQoLDA0ODxAREhMUFRYXGBkaGxwdHh8DoQe_884Qvh1w3RjnS8CZZ-TWMJulDV8d3IZkElUxuA + NEKIRO_ROUTER_AGENT_CREDENTIAL_TTL_SECONDS: "30" + NEKIRO_AGENT_ROUTER_ISSUER: https://a2a-router.nekiro.test + NEKIRO_AGENT_ROUTER_KEY_ID: root-console-browser-key-1 + NEKIRO_AGENT_ROUTER_PUBLIC_KEY_BASE64URL: A6EHv_POEL4dcN0Y50vAmWfk1jCbpQ1fHdyGZBJVMbg + RUNTIME_A_ROUTER_TOKEN: root-console-runtime-a-token + RUNTIME_A_RESPONSE_LIMIT_BYTES: "1048576" + RUNTIME_A_EVENT_LIMIT_BYTES: "65536" + RUNTIME_B_ROUTER_TOKEN: root-console-runtime-b-token + RUNTIME_B_RESPONSE_LIMIT_BYTES: "1048576" + RUNTIME_B_EVENT_LIMIT_BYTES: "65536" + NEKIRO_E2E_BASE_URL: http://127.0.0.1:4173 + NEKIRO_E2E_COMPOSE_PROJECT: nekiro-root-console-browser + NEKIRO_E2E_COMPOSE_FILE: ${{ github.workspace }}/deploy/compose.yaml + VITE_NEKIRO_API_BASE_URL: http://gateway.nekiro.test:18080 + VITE_NEKIRO_PROVIDER_ID: root-console-provider + VITE_NEKIRO_PROVIDER_NAME: Root Console Provider + VITE_NEKIRO_PROVIDER_TOKEN: root-console-provider-token + VITE_NEKIRO_OWNER_TOKEN: root-console-owner-token + VITE_NEKIRO_DEFAULT_WORKSPACE_ID: root-console-workspace + + steps: + - name: Check out repository + uses: actions/checkout@v4 + + - name: Install pnpm + uses: pnpm/action-setup@v4 + with: + version: 11.3.0 + run_install: false + + - name: Set up Node.js + uses: actions/setup-node@v4 + with: + node-version: 24.16.0 + cache: pnpm + cache-dependency-path: pnpm-lock.yaml + + - name: Install frontend dependencies + run: pnpm install --frozen-lockfile + + - name: Map the Gateway to an explicit non-IP origin + run: echo "127.0.0.1 gateway.nekiro.test" | sudo tee -a /etc/hosts + + - name: Start fresh platform Compose + run: docker compose --project-name "$NEKIRO_E2E_COMPOSE_PROJECT" --file "$NEKIRO_E2E_COMPOSE_FILE" up --build --detach --wait --wait-timeout 120 + + - name: Install Chromium + run: pnpm --dir apps/console exec playwright install --with-deps chromium + + - name: Build production Console with explicit browser configuration + run: pnpm --dir apps/console run build + + - name: Run production browser acceptance + run: pnpm --dir apps/console run test:e2e + + - name: Tear down fresh platform Compose + if: always() + run: docker compose --project-name "$NEKIRO_E2E_COMPOSE_PROJECT" --file "$NEKIRO_E2E_COMPOSE_FILE" down --volumes --remove-orphans + compose-config: runs-on: ubuntu-latest timeout-minutes: 15 diff --git a/apps/console/.env.example b/apps/console/.env.example new file mode 100644 index 0000000..db0906c --- /dev/null +++ b/apps/console/.env.example @@ -0,0 +1,7 @@ +# NeKiro public Gateway configuration. Every value must be supplied explicitly. +VITE_NEKIRO_API_BASE_URL="" +VITE_NEKIRO_PROVIDER_ID="" +VITE_NEKIRO_PROVIDER_NAME="" +VITE_NEKIRO_PROVIDER_TOKEN="" +VITE_NEKIRO_OWNER_TOKEN="" +VITE_NEKIRO_DEFAULT_WORKSPACE_ID="" diff --git a/apps/console/e2e/console.spec.ts b/apps/console/e2e/console.spec.ts new file mode 100644 index 0000000..4a94e5f --- /dev/null +++ b/apps/console/e2e/console.spec.ts @@ -0,0 +1,313 @@ +import {execFileSync} from 'node:child_process'; + +import {expect, test, type Locator, type Page} from '@playwright/test'; + +const providerId = required('VITE_NEKIRO_PROVIDER_ID'); +const workspaceId = required('VITE_NEKIRO_DEFAULT_WORKSPACE_ID'); +const composeFile = required('NEKIRO_E2E_COMPOSE_FILE'); +const composeProject = required('NEKIRO_E2E_COMPOSE_PROJECT'); + +type AgentFixture = { + id: string; + name: string; + endpoint: string; + service: string; + capability: string; +}; + +type ReleaseEvidence = { + releaseId: string; + cardDigest: string; +}; + +type BrowserLeakTracker = { + requestUrls: string[]; + requestBodies: string[]; + consoleMessages: string[]; +}; + +const runtimeA: AgentFixture = { + id: 'runtime-a', + name: 'Browser Runtime A', + endpoint: 'http://runtime-a:8091', + service: 'runtime-a', + capability: 'runtime.echo', +}; + +const runtimeB: AgentFixture = { + id: 'runtime-b', + name: 'Browser Runtime B', + endpoint: 'http://runtime-b:8092', + service: 'runtime-b', + capability: 'runtime.cross', +}; + +test.describe.configure({mode: 'serial'}); + +test('production Console completes trusted publication, invocation, trace, and isolated demos', async ({page}) => { + const apiRequests: string[] = []; + const requestUrls: string[] = []; + const requestBodies: string[] = []; + const consoleMessages: string[] = []; + const leakTracker: BrowserLeakTracker = {requestUrls, requestBodies, consoleMessages}; + page.on('request', (request) => { + requestUrls.push(request.url()); + if (request.postData()) requestBodies.push(request.postData() ?? ''); + if (/\/v[34]\//.test(request.url())) apiRequests.push(request.url()); + }); + page.on('console', (message) => consoleMessages.push(message.text())); + + await page.goto('/'); + await expect(page.getByRole('heading', {name: 'Agent Card Catalog'})).toBeVisible(); + await expect(page.getByText('API: configured', {exact: true})).toBeVisible(); + + await createWorkspace(page); + await registerCard(page, runtimeA); + await registerCard(page, runtimeB); + + const releaseA = await publishTrustedRelease(page, runtimeA, leakTracker); + const releaseB = await publishTrustedRelease(page, runtimeB, leakTracker); + + const ownerCatalogResponsePromise = page.waitForResponse((response) => { + const url = new URL(response.url()); + return response.request().method() === 'GET' && url.pathname.endsWith('/v3/agents') && url.search === ''; + }); + await page.reload(); + const ownerCatalogResponse = await ownerCatalogResponsePromise; + expect(ownerCatalogResponse.status()).toBe(200); + const ownerCatalog = await ownerCatalogResponse.json() as { + items: Array<{card: {agentId: string; version: string}; publicationStatus: string}>; + }; + expect(ownerCatalog.items.map((item) => ({ + agentId: item.card.agentId, + version: item.card.version, + publicationStatus: item.publicationStatus, + })), 'Owner discovery must expose both published runtime Cards').toEqual(expect.arrayContaining([ + {agentId: runtimeA.id, version: '1.0.0', publicationStatus: 'published'}, + {agentId: runtimeB.id, version: '1.0.0', publicationStatus: 'published'}, + ])); + await expect(page.getByRole('heading', {name: 'Agent Card Catalog'})).toBeVisible(); + await installRelease(page, runtimeA, releaseA.releaseId); + await installRelease(page, runtimeB, releaseB.releaseId); + + await page.getByRole('button', {name: 'Installations', exact: true}).click(); + await page.getByLabel('Trusted Release ID', {exact: true}).fill('release-does-not-exist'); + const preflightResponsePromise = page.waitForResponse((response) => response.url().includes('/v4/releases/release-does-not-exist') && response.request().method() === 'GET'); + await page.getByRole('button', {name: 'Preflight', exact: true}).click(); + const preflightResponse = await preflightResponsePromise; + expect(preflightResponse.status()).toBe(404); + const preflightError = await preflightResponse.json() as {code: string; traceId: string}; + expect(preflightError.code).toBe('NOT_FOUND'); + expect(preflightError.traceId).toBeTruthy(); + const preflightHeaderTrace = preflightResponse.headers()['x-nek-trace-id']; + if (preflightHeaderTrace !== undefined) expect(preflightHeaderTrace).toBe(preflightError.traceId); + await expect(page.getByText(/NOT_FOUND/)).toBeVisible(); + await expect(page.getByText(/HTTP 404/)).toBeVisible(); + await expect(page.getByText(new RegExp('traceId: ' + escapeRegExp(preflightError.traceId)))).toBeVisible(); + + await page.getByRole('button', {name: 'Invocations', exact: true}).click(); + const installationSelect = page.getByLabel('Installed Agent', {exact: true}); + await selectOptionContaining(installationSelect, runtimeB.id); + await page.getByLabel('Capability', {exact: true}).fill(runtimeB.capability); + await page.getByLabel('Input JSON', {exact: true}).fill(JSON.stringify({fixture: 'nested', value: {message: 'browser-json'}})); + await page.getByRole('button', {name: 'Invoke', exact: true}).click(); + + const response = page.locator('pre').filter({hasText: 'invocationId'}).last(); + await expect(response).toContainText('runtime-a'); + const result = JSON.parse((await response.textContent()) ?? '{}') as {invocationId: string; rootTaskId: string; traceId: string}; + expect(result.invocationId).toBeTruthy(); + expect(result.rootTaskId).toBeTruthy(); + expect(result.traceId).toBeTruthy(); + + await page.getByRole('button', {name: 'Invocations', exact: true}).click(); + await selectOptionContaining(installationSelect, runtimeB.id); + await page.getByLabel('Capability', {exact: true}).fill(runtimeB.capability); + await page.getByLabel('Input JSON', {exact: true}).fill(JSON.stringify({fixture: 'stream-success', value: 'browser-sse'})); + await page.getByLabel('Stream result over SSE', {exact: true}).check(); + const sseResponsePromise = page.waitForResponse((response) => response.url().includes('/v4/workspaces/' + workspaceId + '/invocations') && response.request().method() === 'POST' && (response.request().postData() ?? '').includes('"stream":true')); + await page.getByRole('button', {name: 'Invoke', exact: true}).click(); + const sseResponse = await sseResponsePromise; + expect(sseResponse.status()).toBe(200); + assertResultStream(await sseResponse.text()); + await expect(page.getByText('#0 accepted', {exact: true})).toBeVisible(); + await expect(page.getByText(/completed/, {exact: true}).last()).toBeVisible(); + + await page.getByRole('button', {name: 'Ledger', exact: true}).click(); + await page.getByLabel('Trace ID', {exact: true}).fill(result.traceId); + await page.getByRole('button', {name: 'Read', exact: true}).last().click(); + await expect(page.getByText(new RegExp(`${escapeRegExp(result.traceId)}`)).last()).toBeVisible(); + const ledgerText = await page.locator('main').innerText(); + expect(ledgerText).toContain(runtimeA.id); + expect(ledgerText).toContain(runtimeB.id); + expect(ledgerText).toContain(result.invocationId); + expect(ledgerText).toContain(releaseA.releaseId); + expect(ledgerText).toContain(releaseB.releaseId); + expect(ledgerText).toContain(releaseA.cardDigest); + expect(ledgerText).toContain(releaseB.cardDigest); + + apiRequests.length = 0; + for (const {hash, marker} of [ + {hash: '#/demo', marker: 'Three directions. Same data. Pick one.'}, + {hash: '#/demo/glass', marker: '6 cards'}, + {hash: '#/demo/terminal', marker: 'NEKIRO//OPS'}, + {hash: '#/demo/saas', marker: 'Find the right Agent for every workflow'}, + ]) { + await page.goto('/' + hash); + await expect(page.getByText(marker, {exact: true})).toBeVisible(); + } + expect(apiRequests).toEqual([]); +}); + +async function createWorkspace(page: Page): Promise { + const input = page.locator('input[placeholder="workspace id"]'); + await input.fill(workspaceId); + await page.getByRole('button', {name: 'Create workspace'}).click(); + await expect(page.getByText(`Workspace: ${workspaceId}`, {exact: true})).toBeVisible(); +} + +async function registerCard(page: Page, fixture: AgentFixture): Promise { + await page.getByRole('button', {name: 'Registry', exact: true}).click(); + await page.getByRole('button', {name: 'Register Agent Card', exact: true}).click(); + await page.getByLabel('Agent ID', {exact: true}).fill(fixture.id); + await page.getByLabel('Name', {exact: true}).fill(fixture.name); + await page.getByLabel('Owner ID', {exact: true}).fill(providerId); + await page.getByLabel('Owner display name', {exact: true}).fill('Browser Provider'); + await page.getByLabel('Version', {exact: true}).fill('1.0.0'); + await page.getByLabel('A2A endpoint', {exact: true}).fill(fixture.endpoint); + await page.getByLabel('Authentication', {exact: true}).selectOption('http_bearer'); + await page.getByLabel('Capabilities JSON', {exact: true}).fill(JSON.stringify({capabilities: [ + {id: fixture.capability, name: fixture.capability, description: 'Browser acceptance capability', inputSchema: {type: 'object'}, outputSchema: {type: 'object'}, requiredPermissions: []}, + ]}, null, 2)); + await page.getByRole('button', {name: 'Submit draft', exact: true}).click(); + await expect(page.getByText(fixture.id, {exact: true}).first()).toBeVisible(); +} + +async function publishTrustedRelease(page: Page, fixture: AgentFixture, leakTracker: BrowserLeakTracker): Promise { + await page.getByRole('button', {name: 'Trusted Publication', exact: true}).click(); + await page.getByRole('button', {name: new RegExp(escapeRegExp(fixture.id))}).first().click(); + await page.getByLabel('Agent endpoint', {exact: true}).fill(fixture.endpoint); + await page.getByRole('button', {name: 'Create Binding', exact: true}).click(); + await expect(page.getByText('pending', {exact: true}).last()).toBeVisible(); + + await page.getByRole('button', {name: 'Issue Challenge', exact: true}).click(); + const challengeId = await textMatching(page, /^challenge-[A-Za-z0-9._:-]+$/); + const proof = (await page.locator('code').last().textContent())?.trim(); + if (!proof) throw new Error('Console did not render the one-time challenge proof'); + const persistedValues = await page.evaluate(() => [ + ...Object.entries(localStorage), + ...Object.entries(sessionStorage), + ].flat()); + expect(persistedValues).not.toContain(proof); + injectChallengeProof(fixture.service, challengeId, proof); + await page.getByRole('button', {name: 'Complete Verification', exact: true}).click(); + await expect(page.getByText('verified', {exact: true}).last()).toBeVisible(); + await expect(page.locator('code')).toHaveCount(0); + expect(leakTracker.requestUrls.some((url) => url.includes(proof))).toBe(false); + expect(leakTracker.requestBodies.some((body) => body.includes(proof))).toBe(false); + expect(leakTracker.consoleMessages.some((message) => message.includes(proof))).toBe(false); + + await page.getByRole('button', {name: 'Create Release', exact: true}).click(); + const releaseSection = page.locator('section').filter({hasText: '3. Immutable Release'}); + const releaseState = releaseSection.getByText(/^(pending_verification|verified)$/, {exact: true}).last(); + await expect(releaseState).toBeVisible(); + if ((await releaseState.textContent()) === 'pending_verification') { + const verifyButton = releaseSection.getByRole('button', {name: 'Verify', exact: true}); + await expect(verifyButton).toBeEnabled(); + await verifyButton.click(); + } + await expect(releaseSection.getByText('verified', {exact: true}).last()).toBeVisible(); + await releaseSection.getByRole('button', {name: 'Publish', exact: true}).click(); + await expect(releaseSection.getByText('published', {exact: true}).last()).toBeVisible(); + + const releaseId = await readFactValue(releaseSection, 'Release'); + const cardDigest = await readFactValue(releaseSection, 'Card digest'); + if (!/^[A-Za-z0-9._:-]+$/.test(releaseId) || !/^[0-9a-f]{64}$/.test(cardDigest)) { + throw new Error('Console did not render immutable Release provenance'); + } + return {releaseId, cardDigest}; +} + +async function installRelease(page: Page, fixture: AgentFixture, releaseId: string): Promise { + await page.getByRole('button', {name: 'Installations', exact: true}).click(); + const agentSelect = page.getByLabel('Published Agent', {exact: true}); + await selectOptionContaining(agentSelect, fixture.id); + await page.getByLabel('Trusted Release ID', {exact: true}).fill(releaseId); + await page.getByRole('button', {name: 'Preflight', exact: true}).click(); + await expect(page.getByText('Published Release preflight passed', {exact: true})).toBeVisible(); + await page.getByRole('button', {name: 'Install exact pin', exact: true}).click(); + await expect(page.getByText(releaseId, {exact: true}).last()).toBeVisible(); +} + +async function selectOptionContaining(select: Locator, text: string): Promise { + await expect.poll( + async () => select.locator('option').evaluateAll((options, wanted) => options.some((item) => { + const option = item as HTMLOptionElement; + return option.textContent?.includes(String(wanted)) || option.value.includes(String(wanted)); + }), text), + {message: `Expected an Agent option containing ${text}`}, + ).toBe(true); + const value = await select.locator('option').evaluateAll((options, wanted) => { + const option = options.find((item) => { + const candidate = item as HTMLOptionElement; + return candidate.textContent?.includes(String(wanted)) || candidate.value.includes(String(wanted)); + }); + if (!option) throw new Error(`No select option contains ${String(wanted)}`); + return (option as HTMLOptionElement).value; + }, text); + await select.selectOption(value); +} + +async function textMatching(page: Page, pattern: RegExp): Promise { + const value = (await page.getByText(pattern).last().textContent())?.trim(); + if (!value) throw new Error(`Console did not render text matching ${pattern}`); + return value; +} + +async function readFactValue(section: Locator, label: string): Promise { + const labelElement = section.getByText(label, {exact: true}); + return ((await labelElement.locator('..').locator('div').nth(1).textContent()) ?? '').trim(); +} + +function injectChallengeProof(service: string, challengeId: string, proof: string): void { + execFileSync('docker', [ + 'compose', '--project-name', composeProject, '--file', composeFile, + 'exec', '-T', service, 'sh', '-c', + 'umask 077; cat > "$NEKIRO_AGENT_CHALLENGE_DIRECTORY/$1"', 'sh', challengeId, + ], {input: proof, encoding: 'utf8', stdio: ['pipe', 'ignore', 'pipe']}); +} + +function assertResultStream(body: string): void { + const events = body.trim().split(/\r?\n\r?\n/).filter(Boolean).map((block) => { + const line = block.split(/\r?\n/).find((value) => value.startsWith('data: ')); + if (!line) throw new Error('SSE response omitted a data line'); + return JSON.parse(line.slice('data: '.length)) as { + schemaVersion: string; + sequence: number; + type: string; + status: string; + invocationId: string; + rootTaskId: string; + traceId: string; + }; + }); + if (events.length < 2) throw new Error('SSE response did not contain accepted and terminal events'); + const first = events[0]; + const last = events[events.length - 1]; + if (first.type !== 'accepted' || first.status !== 'pending' || first.sequence !== 0) throw new Error('SSE response did not begin with accepted/pending sequence 0'); + events.forEach((event, index) => { + if (event.schemaVersion !== '2' || event.sequence !== index || event.invocationId !== first.invocationId || event.rootTaskId !== first.rootTaskId || event.traceId !== first.traceId) { + throw new Error('SSE response correlation or sequence changed'); + } + }); + if (last.type !== 'completed' || last.status !== 'succeeded') throw new Error('SSE response did not end with completed/succeeded'); +} + +function required(name: string): string { + const value = process.env[name]; + if (!value || value !== value.trim()) throw new Error(`${name} is required and must not contain surrounding whitespace`); + return value; +} + +function escapeRegExp(value: string): string { + return value.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'); +} diff --git a/apps/console/index.html b/apps/console/index.html new file mode 100644 index 0000000..83abb4b --- /dev/null +++ b/apps/console/index.html @@ -0,0 +1,12 @@ + + + + + + NeKiro Console + + +
+ + + diff --git a/apps/console/package.json b/apps/console/package.json new file mode 100644 index 0000000..14224e5 --- /dev/null +++ b/apps/console/package.json @@ -0,0 +1,35 @@ +{ + "name": "react-example", + "private": true, + "version": "0.0.0", + "type": "module", + "scripts": { + "dev": "vite --port=3000 --host=0.0.0.0", + "build": "vite build", + "preview": "vite preview", + "clean": "rm -rf dist server.js", + "typecheck": "tsc --noEmit", + "lint": "tsc --noEmit", + "test": "tsx --test src/api/nekiro.test.ts src/consoleConfig.test.ts src/consolePolicy.test.ts src/components/consoleSurface.test.tsx src/demos/routing.test.ts", + "test:e2e": "playwright test" + }, + "dependencies": { + "@tailwindcss/vite": "^4.1.14", + "@vitejs/plugin-react": "^5.0.4", + "lucide-react": "^0.546.0", + "react": "^19.0.1", + "react-dom": "^19.0.1", + "vite": "^6.2.3", + "motion": "^12.23.24" + }, + "devDependencies": { + "@types/node": "^22.14.0", + "autoprefixer": "^10.4.21", + "@playwright/test": "^1.52.0", + "esbuild": "^0.25.0", + "tailwindcss": "^4.1.14", + "tsx": "^4.21.0", + "typescript": "~5.8.2", + "vite": "^6.2.3" + } +} diff --git a/apps/console/playwright.config.ts b/apps/console/playwright.config.ts new file mode 100644 index 0000000..4bdd999 --- /dev/null +++ b/apps/console/playwright.config.ts @@ -0,0 +1,32 @@ +import {defineConfig} from '@playwright/test'; + +const baseURL = process.env.NEKIRO_E2E_BASE_URL; +if (!baseURL || baseURL !== baseURL.trim() || !/^https?:\/\/[^/]+(?::\d+)?$/.test(baseURL)) { + throw new Error('NEKIRO_E2E_BASE_URL must be an explicit browser server origin'); +} + +if (!process.env.NEKIRO_E2E_COMPOSE_FILE || !process.env.NEKIRO_E2E_COMPOSE_PROJECT) { + throw new Error('NEKIRO_E2E_COMPOSE_FILE and NEKIRO_E2E_COMPOSE_PROJECT are required'); +} + +export default defineConfig({ + testDir: './e2e', + fullyParallel: false, + workers: 1, + timeout: 120_000, + expect: {timeout: 15_000}, + reporter: 'line', + use: { + baseURL, + browserName: 'chromium', + trace: 'off', + screenshot: 'off', + video: 'off', + }, + webServer: { + command: 'npm run preview -- --host 127.0.0.1 --port 4173', + url: baseURL, + reuseExistingServer: false, + timeout: 120_000, + }, +}); diff --git a/apps/console/public/favicon.ico b/apps/console/public/favicon.ico new file mode 100644 index 0000000..189fec2 --- /dev/null +++ b/apps/console/public/favicon.ico @@ -0,0 +1,4 @@ + + + + diff --git a/apps/console/src/App.tsx b/apps/console/src/App.tsx new file mode 100644 index 0000000..72da54b --- /dev/null +++ b/apps/console/src/App.tsx @@ -0,0 +1,394 @@ +import React, {useCallback, useEffect, useMemo, useRef, useState} from 'react'; +import {AnimatePresence, motion} from 'motion/react'; +import {CheckCircle2, Cpu, HelpCircle, ShieldAlert, X} from 'lucide-react'; + +import {mapCatalogEntry, NekiroApiClient, NekiroApiError, toPlatformErrorView, validateTrustedInstallation, type AgentCardV02, type AgentRelease} from './api/nekiro'; +import {agentKey, isCurrentRequest, matchesPublishedRelease, nextRequestGeneration} from './consolePolicy'; +import Header from './components/Header'; +import InstallationsTab from './components/InstallationsTab'; +import InvocationsTab from './components/InvocationsTab'; +import LedgerTab from './components/LedgerTab'; +import RegistryTab from './components/RegistryTab'; +import Sidebar from './components/Sidebar'; +import TrustedPublicationTab from './components/TrustedPublicationTab'; +import {requireConsoleConfiguration} from './consoleConfig'; +import type {Agent, Installation, InstallationStatus, PlatformErrorView, Workspace} from './types'; + +export default function App() { + requireConsoleConfiguration(import.meta.env); + const [activeTab, setActiveTab] = useState<'registry' | 'trusted' | 'installations' | 'invocations' | 'ledger'>('registry'); + const [searchQuery, setSearchQuery] = useState(''); + const [agents, setAgents] = useState([]); + const [providerAgents, setProviderAgents] = useState([]); + const [draftAgents, setDraftAgents] = useState([]); + const [catalogLoading, setCatalogLoading] = useState(false); + const [catalogError, setCatalogError] = useState(null); + const [catalogReady, setCatalogReady] = useState(false); + const [providerCatalogError, setProviderCatalogError] = useState(null); + const [workspace, setWorkspace] = useState(null); + const [workspaceDraft, setWorkspaceDraft] = useState(import.meta.env.VITE_NEKIRO_DEFAULT_WORKSPACE_ID ?? ''); + const [workspaceLoading, setWorkspaceLoading] = useState(false); + const [workspaceError, setWorkspaceError] = useState(null); + const [installations, setInstallations] = useState([]); + const [installationLoading, setInstallationLoading] = useState(false); + const [installationError, setInstallationError] = useState(null); + const [showSettings, setShowSettings] = useState(false); + const [showSupport, setShowSupport] = useState(false); + const catalogRequestGeneration = useRef(0); + const providerCatalogRequestGeneration = useRef(0); + const workspaceRequestGeneration = useRef(0); + const installationRequestGeneration = useRef(0); + + const providerClient = useMemo( + () => new NekiroApiClient({ + baseUrl: import.meta.env.VITE_NEKIRO_API_BASE_URL, + token: import.meta.env.VITE_NEKIRO_PROVIDER_TOKEN, + }), + [], + ); + const ownerClient = useMemo( + () => new NekiroApiClient({ + baseUrl: import.meta.env.VITE_NEKIRO_API_BASE_URL, + token: import.meta.env.VITE_NEKIRO_OWNER_TOKEN, + }), + [], + ); + + const loadAgents = useCallback(async (query = '') => { + const generation = nextRequestGeneration(catalogRequestGeneration.current); + catalogRequestGeneration.current = generation; + setCatalogLoading(true); + setCatalogError(null); + try { + const response = await ownerClient.searchAgents(query.trim() ? {query: query.trim()} : undefined); + if (!isCurrentRequest(generation, catalogRequestGeneration.current)) return; + setAgents(response.items.map(mapCatalogEntry)); + setCatalogReady(true); + } catch (error) { + if (!isCurrentRequest(generation, catalogRequestGeneration.current)) return; + setCatalogError(toPlatformErrorView(error, 'Unable to load the NeKiro Catalog.')); + } finally { + if (isCurrentRequest(generation, catalogRequestGeneration.current)) setCatalogLoading(false); + } + }, [ownerClient]); + + const loadProviderAgents = useCallback(async (query = '') => { + const generation = nextRequestGeneration(providerCatalogRequestGeneration.current); + providerCatalogRequestGeneration.current = generation; + setProviderCatalogError(null); + try { + const providerId = import.meta.env.VITE_NEKIRO_PROVIDER_ID; + const response = await providerClient.searchAgents({ownerId: providerId, ...(query.trim() ? {query: query.trim()} : {})}); + if (!isCurrentRequest(generation, providerCatalogRequestGeneration.current)) return; + setProviderAgents(response.items.map(mapCatalogEntry).filter((agent) => agent.ownerId === providerId)); + } catch (error) { + if (!isCurrentRequest(generation, providerCatalogRequestGeneration.current)) return; + setProviderCatalogError(toPlatformErrorView(error, 'Unable to load provider-owned Agent Cards.')); + } + }, [providerClient]); + + const loadWorkspace = useCallback(async (workspaceId: string) => { + const generation = nextRequestGeneration(workspaceRequestGeneration.current); + workspaceRequestGeneration.current = generation; + setWorkspaceLoading(true); + setWorkspaceError(null); + try { + const value = await ownerClient.getWorkspace(workspaceId); + if (!isCurrentRequest(generation, workspaceRequestGeneration.current)) return null; + setWorkspace(value); + setWorkspaceDraft(value.workspaceId); + return value; + } catch (error) { + if (!isCurrentRequest(generation, workspaceRequestGeneration.current)) return null; + setWorkspace(null); + setInstallations([]); + setWorkspaceError(toPlatformErrorView(error, 'Unable to load Workspace.')); + return null; + } finally { + if (isCurrentRequest(generation, workspaceRequestGeneration.current)) setWorkspaceLoading(false); + } + }, [ownerClient]); + + const loadInstallations = useCallback(async (workspaceId = workspace?.workspaceId) => { + const generation = nextRequestGeneration(installationRequestGeneration.current); + installationRequestGeneration.current = generation; + if (!workspaceId) { + setInstallations([]); + return; + } + setInstallationLoading(true); + setInstallationError(null); + try { + const response = await ownerClient.listInstallations(workspaceId, {limit: 100}); + if (!isCurrentRequest(generation, installationRequestGeneration.current)) return; + setInstallations(response.items); + } catch (error) { + if (!isCurrentRequest(generation, installationRequestGeneration.current)) return; + setInstallationError(toPlatformErrorView(error, 'Unable to load Workspace Installations.')); + } finally { + if (isCurrentRequest(generation, installationRequestGeneration.current)) setInstallationLoading(false); + } + }, [ownerClient, workspace?.workspaceId]); + + useEffect(() => { + const timer = window.setTimeout(() => { + void loadAgents(searchQuery); + void loadProviderAgents(searchQuery); + }, 250); + return () => window.clearTimeout(timer); + }, [loadAgents, loadProviderAgents, searchQuery]); + + useEffect(() => { + const defaultWorkspaceId = import.meta.env.VITE_NEKIRO_DEFAULT_WORKSPACE_ID; + if (defaultWorkspaceId) { + void loadWorkspace(defaultWorkspaceId).then((value) => { + if (value) { + void loadInstallations(value.workspaceId); + } + }); + } + }, [loadInstallations, loadWorkspace]); + + const handleCreateWorkspace = async () => { + setWorkspaceLoading(true); + setWorkspaceError(null); + try { + const value = await ownerClient.createWorkspace(workspaceDraft); + setWorkspace(value); + setWorkspaceDraft(value.workspaceId); + await loadInstallations(value.workspaceId); + } catch (error) { + setWorkspaceError(toPlatformErrorView(error, 'Unable to create Workspace.')); + } finally { + setWorkspaceLoading(false); + } + }; + + const handleReadWorkspace = async () => { + const value = await loadWorkspace(workspaceDraft); + if (value) { + await loadInstallations(value.workspaceId); + } + }; + + const handleRegisterAgent = async (card: AgentCardV02) => { + const entry = await providerClient.registerAgent(card); + const draftAgent = mapCatalogEntry(entry); + setDraftAgents((current) => upsertAgent(current, draftAgent)); + await Promise.all([loadAgents(searchQuery), loadProviderAgents(searchQuery)]); + return draftAgent; + }; + + const handlePublishAgent = async (agent: Agent) => { + await providerClient.publishAgentVersion(agent.id, agent.version); + setDraftAgents((current) => current.filter((draft) => agentKey(draft) !== agentKey(agent))); + await Promise.all([loadAgents(searchQuery), loadProviderAgents(searchQuery)]); + }; + + const handleInstallAgent = async (agent: Agent, release: AgentRelease, acceptedPermissions: string[]) => { + if (!workspace) { + throw new Error('Select or create a Workspace before installing an Agent.'); + } + if (!matchesPublishedRelease(release, agent)) { + throw new NekiroApiError(0, 'The selected Release is not a published match for the selected Agent Card.', 'INVALID_RESPONSE'); + } + const installation = await ownerClient.installAgent(workspace.workspaceId, { + agentId: agent.id, + versionConstraint: release.agentCardVersion, + acceptedPermissions, + }); + validateTrustedInstallation(installation, release, agent.id); + await loadInstallations(workspace.workspaceId); + }; + + const handleUpdateInstallation = async (installation: Installation, status: Exclude) => { + if (!workspace) { + return; + } + setInstallationError(null); + try { + await ownerClient.updateInstallation(workspace.workspaceId, installation.installationId, status); + await loadInstallations(workspace.workspaceId); + } catch (error) { + setInstallationError(toPlatformErrorView(error, 'Unable to update Installation.')); + } + }; + + const handleUninstall = async (installation: Installation) => { + if (!workspace) { + return false; + } + setInstallationError(null); + try { + await ownerClient.uninstallAgent(workspace.workspaceId, installation.installationId); + await loadInstallations(workspace.workspaceId); + return true; + } catch (error) { + setInstallationError(toPlatformErrorView(error, 'Unable to uninstall Agent.')); + return false; + } + }; + + const getSearchPlaceholder = () => { + switch (activeTab) { + case 'registry': + return 'Search agent name, description, capability...'; + case 'trusted': + return 'Filter registered Agent Cards...'; + case 'installations': + return 'Search installation id, agent id, pinned version...'; + case 'invocations': + return 'Filter active Workspace invocations...'; + case 'ledger': + return 'Read Invocation or Trace metadata...'; + } + }; + + return ( +
+
+
+
+
+
+
+ + { + setActiveTab(tab); + setSearchQuery(''); + }} + onOpenSettings={() => setShowSettings(true)} + onOpenSupport={() => setShowSupport(true)} + /> + +
+ +
+ + {activeTab === 'registry' && ( + + + + )} + + {activeTab === 'trusted' && ( + + void loadProviderAgents(searchQuery)} + /> + + )} + + {activeTab === 'installations' && ( + + void loadInstallations()} + /> + + )} + + {activeTab === 'invocations' && ( + + + + )} + + {activeTab === 'ledger' && ( + + + + )} + +
+ + {showSettings && ( + } onClose={() => setShowSettings(false)}> +
+

Base URL: {import.meta.env.VITE_NEKIRO_API_BASE_URL || 'not configured'}

+

Provider context: VITE_NEKIRO_PROVIDER_ID + VITE_NEKIRO_PROVIDER_TOKEN

+

Workspace owner context: VITE_NEKIRO_OWNER_TOKEN (credentials are never persisted in local storage)

+

Default Workspace: {import.meta.env.VITE_NEKIRO_DEFAULT_WORKSPACE_ID || 'manual selection'}

+
+
+ )} + + {showSupport && ( + } onClose={() => setShowSupport(false)}> +
+

Live surfaces: Registry, Workspace, Installations, Invocation Dispatch, and metadata-only Ledger through public Gateway routes.

+

Runtime reads are Owner-authorized and Workspace-scoped. The Console never stores Agent secrets or fabricates Ledger events.

+
+
+ )} +
+ ); +} + +function Overlay({title, icon, children, onClose}: {title: string; icon: React.ReactNode; children: React.ReactNode; onClose: () => void}) { + return ( +
+
+
+
+ {icon} +

{title}

+
+ +
+
{children}
+
+ + Only public Gateway routes are called from the browser. + +
+
+
+ ); +} + +function upsertAgent(agents: Agent[], next: Agent): Agent[] { + return [next, ...agents.filter((agent) => agentKey(agent) !== agentKey(next))]; +} diff --git a/apps/console/src/api/nekiro.test.ts b/apps/console/src/api/nekiro.test.ts new file mode 100644 index 0000000..3150d28 --- /dev/null +++ b/apps/console/src/api/nekiro.test.ts @@ -0,0 +1,604 @@ +import assert from 'node:assert/strict'; +import {test} from 'node:test'; + +import { + buildAgentCard, + mapCatalogEntry, + NekiroApiClient, + NekiroApiError, + validateTrustedInstallation, + type AgentCardV02, + type AgentRelease, + type CatalogEntry, +} from './nekiro'; + +const limits = { + timeoutMs: 30_000, + maxInputBytes: 1_048_576, + maxOutputBytes: 1_048_576, + streaming: true, +}; + +test('buildAgentCard converts registration fields into an Agent Card v0.2 payload', () => { + const card = buildAgentCard({ + agentId: 'runtime.echo', + name: 'Runtime Echo Agent', + ownerId: 'team.platform', + ownerDisplayName: 'Platform Team', + description: 'Echoes structured input.', + version: '1.0.0', + endpoint: 'http://127.0.0.1:9000/a2a', + authentication: 'none', + permissions: [{id: 'READ_LOGS', description: 'Read logs.'}], + capabilitiesJson: JSON.stringify({ + capabilities: [{ + id: 'runtime.echo', + name: 'Runtime Echo', + description: 'Echo input.', + inputSchema: {type: 'object'}, + outputSchema: {type: 'object'}, + requiredPermissions: ['READ_LOGS'], + }], + }), + limits, + }); + + assert.deepEqual(card, { + schemaVersion: '0.2', + agentId: 'runtime.echo', + name: 'Runtime Echo Agent', + description: 'Echoes structured input.', + owner: {id: 'team.platform', displayName: 'Platform Team'}, + version: '1.0.0', + protocol: {type: 'a2a', version: '0.3.0', transport: 'JSONRPC', endpoint: 'http://127.0.0.1:9000/a2a'}, + skills: [{ + id: 'runtime.echo', + name: 'Runtime Echo', + description: 'Echo input.', + inputSchema: {type: 'object'}, + outputSchema: {type: 'object'}, + requiredPermissions: ['READ_LOGS'], + }], + authentication: {type: 'none'}, + permissions: [{id: 'READ_LOGS', description: 'Read logs.'}], + limits, + }); +}); + +test('buildAgentCard rejects duplicate capabilities and undeclared required permissions', () => { + assert.throws( + () => buildAgentCard({ + agentId: 'runtime.echo', + name: 'Runtime Echo', + ownerId: 'team.platform', + ownerDisplayName: 'Platform Team', + description: 'Echoes structured input.', + version: '1.0.0', + endpoint: 'http://127.0.0.1:9000/a2a', + authentication: 'none', + permissions: [], + capabilitiesJson: JSON.stringify({capabilities: [{id: 'same', name: 'Same', description: 'Same.', inputSchema: {}, outputSchema: {}, requiredPermissions: []}, {id: 'same', name: 'Same', description: 'Same.', inputSchema: {}, outputSchema: {}, requiredPermissions: []}]}), + limits, + }), + /duplicate capability id/i, + ); + + assert.throws( + () => buildAgentCard({ + agentId: 'runtime.echo', + name: 'Runtime Echo', + ownerId: 'team.platform', + ownerDisplayName: 'Platform Team', + description: 'Echoes structured input.', + version: '1.0.0', + endpoint: 'http://127.0.0.1:9000/a2a', + authentication: 'none', + permissions: [], + capabilitiesJson: JSON.stringify({capabilities: [{id: 'runtime.echo', name: 'Echo', description: 'Echo.', inputSchema: {}, outputSchema: {}, requiredPermissions: ['READ_LOGS']}]}), + limits, + }), + /not declared/i, + ); +}); + +test('mapCatalogEntry maps Catalog entries to the Console view model without deprecated state', () => { + const card: AgentCardV02 = { + schemaVersion: '0.2', + agentId: 'runtime.disabled', + name: 'Disabled Runtime', + description: 'A disabled runtime.', + owner: {id: 'team.platform', displayName: 'Platform Team'}, + version: '2.0.0', + protocol: {type: 'a2a', version: '0.3.0', transport: 'JSONRPC', endpoint: 'http://127.0.0.1:9000/a2a'}, + skills: [ + {id: 'runtime.echo', name: 'Echo', description: 'Echoes input.', inputSchema: {type: 'object'}, outputSchema: {type: 'object'}, requiredPermissions: []}, + {id: 'runtime.inspect', name: 'Inspect', description: 'Inspects input.', inputSchema: {type: 'object'}, outputSchema: {type: 'object'}, requiredPermissions: []}, + ], + authentication: {type: 'none'}, + permissions: [{id: 'READ_LOGS', description: 'Read logs.'}], + limits, + }; + const entry: CatalogEntry = { + card, + publicationStatus: 'disabled', + registeredAt: '2026-07-14T00:00:00Z', + }; + + const agent = mapCatalogEntry(entry); + + assert.equal(agent.id, 'runtime.disabled'); + assert.equal(agent.owner, 'Platform Team'); + assert.equal(agent.ownerId, 'team.platform'); + assert.equal(agent.version, '2.0.0'); + assert.equal(agent.status, 'disabled'); + assert.deepEqual(agent.tags, ['runtime.echo', 'runtime.inspect']); + assert.deepEqual(agent.permissions, [{id: 'READ_LOGS', description: 'Read logs.'}]); + assert.equal(JSON.parse(agent.schema).agentId, 'runtime.disabled'); +}); + +test('NekiroApiClient sends v3 Catalog search requests with auth and decodes platform errors', async () => { + const requests: Array<{url: string; init?: RequestInit}> = []; + const client = new NekiroApiClient({ + baseUrl: 'https://api.example.test/', + token: 'test-token', + fetchImpl: async (input, init) => { + requests.push({url: String(input), init}); + return new Response(JSON.stringify({ + code: 'CONFLICT', + message: 'The requested operation conflicts with current state.', + traceId: 'trace-1', + }), { + status: 409, + headers: {'Content-Type': 'application/json'}, + }); + }, + }); + + await assert.rejects( + () => client.searchAgents({query: 'echo'}), + (error: unknown) => { + assert.ok(error instanceof NekiroApiError); + assert.equal(error.status, 409); + assert.equal(error.code, 'CONFLICT'); + assert.equal(error.traceId, 'trace-1'); + return true; + }, + ); + + assert.equal(requests[0]?.url, 'https://api.example.test/v3/agents?query=echo'); + const headers = new Headers(requests[0]?.init?.headers); + assert.equal(headers.get('Accept'), 'application/json'); + assert.equal(headers.get('Authorization'), 'Bearer test-token'); +}); + +test('NekiroApiClient covers Workspace and Installation v3 paths', async () => { + const requests: Array<{url: string; init?: RequestInit}> = []; + const client = new NekiroApiClient({ + baseUrl: 'https://api.example.test', + token: 'test-token', + fetchImpl: async (input, init) => { + requests.push({url: String(input), init}); + return new Response(JSON.stringify({items: [{ + installationId: 'installation-1', workspaceId: 'workspace.alpha', agentId: 'agent.echo', versionConstraint: '1.2.3', installedVersion: '1.2.3', installedReleaseId: 'release-1', acceptedPermissions: [], status: 'enabled', installedAt: '2026-07-26T00:00:00Z', updatedAt: '2026-07-26T00:00:00Z', + }]}), {status: 200}); + }, + }); + + const result = await client.listInstallations('workspace.alpha', {limit: 50, cursor: 'next'}); + + assert.equal(result.items[0]?.installedReleaseId, 'release-1'); + assert.equal(requests[0]?.url, 'https://api.example.test/v3/workspaces/workspace.alpha/installations?limit=50&cursor=next'); +}); + +test('NekiroApiClient strictly maps every Installation read response', async () => { + const installation = { + installationId: 'installation-1', workspaceId: 'workspace.alpha', agentId: 'agent.echo', versionConstraint: '1.2.3', installedVersion: '1.2.3', installedReleaseId: 'release-1', acceptedPermissions: [], status: 'enabled', installedAt: '2026-07-26T00:00:00Z', updatedAt: '2026-07-26T00:00:00Z', + }; + let response: Record = installation; + const client = new NekiroApiClient({ + baseUrl: 'https://api.example.test', token: 'owner-token', + fetchImpl: async () => new Response(JSON.stringify(response), {status: 200}), + }); + assert.equal((await client.getInstallation('workspace.alpha', 'installation-1')).installationId, 'installation-1'); + assert.equal((await client.updateInstallation('workspace.alpha', 'installation-1', 'disabled')).status, 'enabled'); + assert.equal((await client.uninstallAgent('workspace.alpha', 'installation-1')).installedReleaseId, 'release-1'); + response = {...installation, unexpected: true}; + await assert.rejects(() => client.getInstallation('workspace.alpha', 'installation-1'), /unknown field/); +}); + +test('NekiroApiClient enforces Installation v2 semantic response rules', async () => { + const base = { + installationId: 'installation-1', workspaceId: 'workspace.alpha', agentId: 'agent.echo', versionConstraint: '^1.0.0', installedVersion: '1.2.3', acceptedPermissions: ['read', 'write'], status: 'enabled', installedAt: '2026-07-26T00:00:00Z', updatedAt: '2026-07-26T00:00:00Z', + }; + let response: Record = base; + const client = new NekiroApiClient({baseUrl: 'https://api.example.test', token: 'owner-token', fetchImpl: async () => new Response(JSON.stringify(response), {status: 200})}); + assert.equal((await client.getInstallation('workspace.alpha', 'installation-1')).installedVersion, '1.2.3'); + response = {...base, installedVersion: '2.0.0'}; + await assert.rejects(() => client.getInstallation('workspace.alpha', 'installation-1'), /does not satisfy/); + response = {...base, status: 'uninstalled', uninstalledAt: '2026-07-26T00:01:00Z'}; + await assert.rejects(() => client.getInstallation('workspace.alpha', 'installation-1'), /uninstalledAt must equal/); + response = {...base, installedAt: '2026-07-26T00:01:00Z', updatedAt: '2026-07-25T00:00:00Z'}; + await assert.rejects(() => client.getInstallation('workspace.alpha', 'installation-1'), /must not precede/); +}); + +test('NekiroApiClient installs an exact trusted version and preserves Release provenance', async () => { + const requests: Array<{url: string; init?: RequestInit}> = []; + const installation = { + installationId: 'installation-1', + workspaceId: 'workspace.alpha', + agentId: 'agent.echo', + versionConstraint: '1.2.3', + installedVersion: '1.2.3', + installedReleaseId: 'release-1', + acceptedPermissions: [], + status: 'enabled', + installedAt: '2026-07-26T00:00:00Z', + updatedAt: '2026-07-26T00:00:00Z', + }; + const client = new NekiroApiClient({ + baseUrl: 'https://api.example.test', + token: 'owner-token', + fetchImpl: async (input, init) => { + requests.push({url: String(input), init}); + return new Response(JSON.stringify(installation), {status: 201}); + }, + }); + const result = await client.installAgent('workspace.alpha', {agentId: 'agent.echo', versionConstraint: '1.2.3', acceptedPermissions: []}); + assert.equal(result.installedReleaseId, 'release-1'); + assert.equal(requests[0]?.url, 'https://api.example.test/v3/workspaces/workspace.alpha/installations'); + assert.deepEqual(JSON.parse(String(requests[0]?.init?.body)), {agentId: 'agent.echo', versionConstraint: '1.2.3', acceptedPermissions: []}); +}); + +test('trusted Installation validation rejects missing Release identity or non-enabled state', () => { + const release = trustedRelease() as unknown as AgentRelease; + const installation = { + installationId: 'installation-1', workspaceId: 'workspace.alpha', agentId: 'agent.echo', + versionConstraint: '1.2.3', installedVersion: '1.2.3', installedReleaseId: 'release-1', + acceptedPermissions: [], status: 'enabled' as const, installedAt: '2026-07-26T00:00:00Z', updatedAt: '2026-07-26T00:00:00Z', + }; + assert.doesNotThrow(() => validateTrustedInstallation(installation, release, 'agent.echo')); + assert.throws(() => validateTrustedInstallation({...installation, installedReleaseId: undefined}, release, 'agent.echo'), /Release identity/); + assert.throws(() => validateTrustedInstallation({...installation, status: 'disabled'}, release, 'agent.echo'), /Release identity/); +}); + +test('provider and Workspace-owner clients keep bearer contexts separate', async () => { + const authorization: string[] = []; + const response = trustedResponse(trustedRelease(), 200); + const providerClient = new NekiroApiClient({baseUrl: 'https://api.example.test', token: 'provider-token', fetchImpl: async (_input, init) => { authorization.push(new Headers(init?.headers).get('Authorization') ?? ''); return response.clone(); }}); + const ownerClient = new NekiroApiClient({baseUrl: 'https://api.example.test', token: 'owner-token', fetchImpl: async (_input, init) => { authorization.push(new Headers(init?.headers).get('Authorization') ?? ''); return response.clone(); }}); + await providerClient.getAgentRelease('release-1'); + await ownerClient.getAgentRelease('release-1'); + assert.deepEqual(authorization, ['Bearer provider-token', 'Bearer owner-token']); +}); + +test('NekiroApiClient constructs a strict v4 JSON invocation request', async () => { + const requests: Array<{url: string; init?: RequestInit}> = []; + const client = new NekiroApiClient({ + baseUrl: 'https://api.example.test', + token: 'exact-token', + fetchImpl: async (input, init) => { + requests.push({url: String(input), init}); + return new Response(JSON.stringify({schemaVersion: '1', invocationId: 'inv-1', rootTaskId: 'task-1', traceId: 'trace-1', status: 'succeeded', result: {ok: true}}), {status: 200}); + }, + }); + const result = await client.invoke('workspace.alpha', {agentId: 'runtime.echo', capability: 'runtime.echo', input: {message: 'hello'}, stream: false}); + assert.deepEqual(result.result, {ok: true}); + assert.equal(requests[0]?.url, 'https://api.example.test/v4/workspaces/workspace.alpha/invocations'); + assert.deepEqual(JSON.parse(String(requests[0]?.init?.body)), {agentId: 'runtime.echo', capability: 'runtime.echo', input: {message: 'hello'}, stream: false}); +}); + +test('NekiroApiClient preserves correlated Platform Error v4 fields', async () => { + const client = new NekiroApiClient({ + baseUrl: 'https://api.example.test', + token: 'test-token', + fetchImpl: async () => new Response(JSON.stringify({code: 'TIMEOUT', message: 'The invocation timed out.', traceId: 'trace-1', invocationId: 'inv-1', rootTaskId: 'task-1'}), {status: 504}), + }); + await assert.rejects(() => client.invoke('workspace.alpha', {agentId: 'runtime.echo', capability: 'runtime.echo', input: {}, stream: false}), (error: unknown) => { + assert.ok(error instanceof NekiroApiError); + assert.equal(error.code, 'TIMEOUT'); + assert.equal(error.traceId, 'trace-1'); + assert.equal(error.invocationId, 'inv-1'); + assert.equal(error.rootTaskId, 'task-1'); + return true; + }); +}); + +test('NekiroApiClient reads Workspace-scoped v4 Invocation and Trace paths', async () => { + const requests: string[] = []; + const record = {invocationId: 'inv-1', rootTaskId: 'task-1', traceId: 'trace-1', caller: {type: 'user', id: 'owner-a'}, workspaceId: 'workspace.alpha', targetAgentId: 'runtime.echo', agentCardVersion: '1.0.0', capability: 'runtime.echo', status: 'pending', createdAt: '2026-07-21T00:00:00Z', updatedAt: '2026-07-21T00:00:00Z'}; + const event = {schemaVersion: '0.3', eventId: 'evt-1', sequence: 0, occurredAt: '2026-07-21T00:00:00Z', type: 'created', status: 'pending', invocationId: 'inv-1', rootTaskId: 'task-1', traceId: 'trace-1', caller: {type: 'user', id: 'owner-a'}, workspaceId: 'workspace.alpha', targetAgentId: 'runtime.echo', agentCardVersion: '1.0.0', capability: 'runtime.echo'}; + const client = new NekiroApiClient({baseUrl: 'https://api.example.test', token: 'test-token', fetchImpl: async (input) => { + requests.push(String(input)); + return new Response(JSON.stringify(String(input).includes('/traces/') ? {traceId: 'trace-1', invocations: [record]} : {invocation: record, events: [event]}), {status: 200}); + }}); + await client.getInvocation('workspace.alpha', 'inv-1'); + await client.getTrace('workspace.alpha', 'trace-1'); + assert.deepEqual(requests, ['https://api.example.test/v4/workspaces/workspace.alpha/invocations/inv-1', 'https://api.example.test/v4/workspaces/workspace.alpha/traces/trace-1']); +}); + +test('NekiroApiClient rejects Invocation Detail provenance changes', async () => { + const cardDigest = 'aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa'; + const record = {invocationId: 'inv-1', rootTaskId: 'task-1', traceId: 'trace-1', caller: {type: 'user', id: 'owner-a'}, workspaceId: 'workspace.alpha', targetAgentId: 'runtime.echo', agentCardVersion: '1.0.0', agentReleaseId: 'release-1', agentCardDigest: cardDigest, capability: 'runtime.echo', status: 'pending', createdAt: '2026-07-21T00:00:00Z', updatedAt: '2026-07-21T00:00:00Z'}; + const event = {schemaVersion: '0.3', eventId: 'evt-1', sequence: 0, occurredAt: '2026-07-21T00:00:00Z', type: 'created', status: 'pending', invocationId: 'inv-1', rootTaskId: 'task-1', traceId: 'trace-1', caller: {type: 'user', id: 'owner-a'}, workspaceId: 'workspace.alpha', targetAgentId: 'runtime.echo', agentCardVersion: '1.0.0', agentReleaseId: 'release-1', agentCardDigest: 'bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb', capability: 'runtime.echo'}; + const client = new NekiroApiClient({baseUrl: 'https://api.example.test', token: 'test-token', fetchImpl: async () => new Response(JSON.stringify({invocation: record, events: [event]}), {status: 200})}); + await assert.rejects(() => client.getInvocation('workspace.alpha', 'inv-1'), /Invocation Detail event correlation is invalid/); +}); + +test('NekiroApiClient validates ordered SSE events and requires a terminal event', async () => { + const accepted = {schemaVersion: '2', sequence: 0, type: 'accepted', status: 'pending', invocationId: 'inv-1', rootTaskId: 'task-1', traceId: 'trace-1'}; + const completed = {schemaVersion: '2', sequence: 1, type: 'completed', status: 'succeeded', invocationId: 'inv-1', rootTaskId: 'task-1', traceId: 'trace-1'}; + const body = `data: ${JSON.stringify(accepted)}\n\ndata: ${JSON.stringify(completed)}\n\n`; + const client = new NekiroApiClient({baseUrl: 'https://api.example.test', token: 'test-token', fetchImpl: async () => new Response(body, {status: 200, headers: {'Content-Type': 'text/event-stream'}})}); + const seen: number[] = []; + const events = await client.invokeStream('workspace.alpha', {agentId: 'runtime.echo', capability: 'runtime.echo', input: {}}, (event) => seen.push(event.sequence)); + assert.deepEqual(seen, [0, 1]); + assert.equal(events[1]?.type, 'completed'); + + const interrupted = new NekiroApiClient({baseUrl: 'https://api.example.test', token: 'test-token', fetchImpl: async () => new Response(`data: ${JSON.stringify(accepted)}\n\n`, {status: 200, headers: {'Content-Type': 'text/event-stream'}})}); + await assert.rejects(() => interrupted.invokeStream('workspace.alpha', {agentId: 'runtime.echo', capability: 'runtime.echo', input: {}}), /ended before a terminal event/); +}); + +test('NekiroApiClient rejects SSE gaps, correlation changes, and mismatched terminal errors', async () => { + const accepted = {schemaVersion: '2', sequence: 0, type: 'accepted', status: 'pending', invocationId: 'inv-1', rootTaskId: 'task-1', traceId: 'trace-1'}; + const gap = {schemaVersion: '2', sequence: 2, type: 'completed', status: 'succeeded', invocationId: 'inv-1', rootTaskId: 'task-1', traceId: 'trace-1'}; + const gapClient = new NekiroApiClient({baseUrl: 'https://api.example.test', token: 'test-token', fetchImpl: async () => new Response(`data: ${JSON.stringify(accepted)}\n\ndata: ${JSON.stringify(gap)}\n\n`, {status: 200, headers: {'Content-Type': 'text/event-stream'}})}); + await assert.rejects(() => gapClient.invokeStream('workspace.alpha', {agentId: 'runtime.echo', capability: 'runtime.echo', input: {}}), /sequence is not contiguous/); + + const changed = {...gap, sequence: 1, traceId: 'trace-other'}; + const changedClient = new NekiroApiClient({baseUrl: 'https://api.example.test', token: 'test-token', fetchImpl: async () => new Response(`data: ${JSON.stringify(accepted)}\n\ndata: ${JSON.stringify(changed)}\n\n`, {status: 200, headers: {'Content-Type': 'text/event-stream'}})}); + await assert.rejects(() => changedClient.invokeStream('workspace.alpha', {agentId: 'runtime.echo', capability: 'runtime.echo', input: {}}), /correlation changed/); + + const failed = {schemaVersion: '2', sequence: 1, type: 'failed', status: 'failed', invocationId: 'inv-1', rootTaskId: 'task-1', traceId: 'trace-1', error: {code: 'TIMEOUT', message: 'The invocation timed out.', traceId: 'trace-1', invocationId: 'inv-1', rootTaskId: 'task-1'}}; + const failedClient = new NekiroApiClient({baseUrl: 'https://api.example.test', token: 'test-token', fetchImpl: async () => new Response(`data: ${JSON.stringify(accepted)}\n\ndata: ${JSON.stringify(failed)}\n\n`, {status: 200, headers: {'Content-Type': 'text/event-stream'}})}); + await assert.rejects(() => failedClient.invokeStream('workspace.alpha', {agentId: 'runtime.echo', capability: 'runtime.echo', input: {}}), /failed stream event error code/); +}); + +test('NekiroApiClient rejects omitted Installation limits instead of inventing one', () => { + const client = new NekiroApiClient({baseUrl: 'https://api.example.test', token: 'test-token'}); + assert.throws(() => client.listInstallations('workspace.alpha', undefined as never), /limit must be an integer/); +}); + +test('NekiroApiClient rejects missing or whitespace bearer configuration', () => { + assert.throws(() => new NekiroApiClient({baseUrl: 'https://api.example.test', token: ''}), /bearer token is required/); + assert.throws(() => new NekiroApiClient({baseUrl: 'https://api.example.test', token: ' token'}), /must not contain whitespace/); + assert.throws(() => new NekiroApiClient({baseUrl: 'https://api.example.test/v1', token: 'test-token'}), /base URL is invalid/); + assert.throws(() => new NekiroApiClient({baseUrl: 'https://localhost', token: 'test-token'}), /base URL is invalid/); + assert.throws(() => new NekiroApiClient({baseUrl: 'https://192.0.2.10', token: 'test-token'}), /base URL is invalid/); +}); + +test('NekiroApiClient rejects an empty JSON success body', async () => { + const client = new NekiroApiClient({baseUrl: 'https://api.example.test', token: 'test-token', fetchImpl: async () => new Response('', {status: 200})}); + await assert.rejects(() => client.searchAgents(), /empty success response/); +}); + +test('NekiroApiClient constructs every Trusted Publication Gateway route without proof bodies', async () => { + const requests: Array<{url: string; init?: RequestInit}> = []; + const binding = trustedBinding(); + const challenge = trustedChallenge(); + const release = trustedRelease(); + const responses = [binding, binding, challenge, binding, release, release, release, release, release, release]; + const statuses = [201, 200, 201, 200, 201, 200, 200, 200, 200, 200]; + const client = new NekiroApiClient({ + baseUrl: 'https://api.example.test/', + token: 'exact-token', + fetchImpl: async (input, init) => { + requests.push({url: String(input), init}); + const value = responses.shift(); + if (!value) throw new Error('unexpected request count'); + const status = statuses.shift(); + if (!status) throw new Error('unexpected response count'); + return trustedResponse(value, status); + }, + }); + + await client.createEndpointBinding('provider.main', 'agent.echo', {endpoint: 'https://agent.example/a2a', method: 'http_well_known', version: '1.2.3'}); + await client.getEndpointBinding('provider.main', 'binding-1'); + await client.createVerificationChallenge('provider.main', 'binding-1'); + await client.completeVerificationChallenge('provider.main', 'binding-1', 'challenge-1'); + await client.createAgentRelease('provider.main', 'agent.echo', {version: '1.2.3', endpointBindingId: 'binding-1'}); + await client.getAgentRelease('release-1'); + await client.verifyAgentRelease('release-1'); + await client.publishAgentRelease('release-1'); + await client.suspendAgentRelease('release-1'); + await client.revokeAgentRelease('release-1'); + + assert.deepEqual(requests.map((request) => request.url), [ + 'https://api.example.test/v4/providers/provider.main/agents/agent.echo/endpoint-bindings', + 'https://api.example.test/v4/providers/provider.main/endpoint-bindings/binding-1', + 'https://api.example.test/v4/providers/provider.main/endpoint-bindings/binding-1/challenges', + 'https://api.example.test/v4/providers/provider.main/endpoint-bindings/binding-1/challenges/challenge-1/complete', + 'https://api.example.test/v4/providers/provider.main/agents/agent.echo/releases', + 'https://api.example.test/v4/releases/release-1', + 'https://api.example.test/v4/releases/release-1/verify', + 'https://api.example.test/v4/releases/release-1/publish', + 'https://api.example.test/v4/releases/release-1/suspend', + 'https://api.example.test/v4/releases/release-1/revoke', + ]); + assert.deepEqual(requests.map((request) => request.init?.method), ['POST', undefined, 'POST', 'POST', 'POST', undefined, 'POST', 'POST', 'POST', 'POST']); + assert.deepEqual(JSON.parse(String(requests[0]?.init?.body)), {endpoint: 'https://agent.example/a2a', method: 'http_well_known', version: '1.2.3'}); + assert.deepEqual(JSON.parse(String(requests[4]?.init?.body)), {version: '1.2.3', endpointBindingId: 'binding-1'}); + assert.equal(requests[2]?.init?.body, undefined); + assert.equal(requests[3]?.init?.body, undefined); + assert.equal(requests[5]?.init?.body, undefined); + assert.equal(requests[2]?.init?.redirect, 'error'); + for (const request of requests) { + const headers = new Headers(request.init?.headers); + assert.equal(headers.get('Authorization'), 'Bearer exact-token'); + } + assert.equal(requests.some((request) => String(request.init?.body ?? '').includes(String(challenge.proof))), false); +}); + +test('Trusted Publication errors preserve status, code, body trace, and optional matching header', async () => { + const error = {code: 'WRONG_PROOF', message: 'The verification proof is incorrect.', traceId: 'trace-trust-1'}; + const client = new NekiroApiClient({ + baseUrl: 'https://api.example.test', + token: 'test-token', + fetchImpl: async () => trustedResponse(error, 400, {'x-nek-trace-id': 'trace-trust-1'}), + }); + + await assert.rejects(() => client.getAgentRelease('release-1'), (value: unknown) => { + assert.ok(value instanceof NekiroApiError); + assert.equal(value.status, 400); + assert.equal(value.code, 'WRONG_PROOF'); + assert.equal(value.traceId, 'trace-trust-1'); + return true; + }); +}); + +test('Trusted Publication accepts an absent trace header but rejects a mismatched header', async () => { + const error = {code: 'CHALLENGE_EXPIRED', message: 'The verification challenge expired.', traceId: 'trace-trust-2'}; + const absentHeader = new NekiroApiClient({ + baseUrl: 'https://api.example.test', + token: 'test-token', + fetchImpl: async () => trustedResponse(error, 409), + }); + await assert.rejects(() => absentHeader.getAgentRelease('release-1'), (value: unknown) => { + assert.ok(value instanceof NekiroApiError); + assert.equal(value.code, 'CHALLENGE_EXPIRED'); + assert.equal(value.traceId, 'trace-trust-2'); + return true; + }); + + const mismatchedHeader = new NekiroApiClient({ + baseUrl: 'https://api.example.test', + token: 'test-token', + fetchImpl: async () => trustedResponse(error, 409, {'x-nek-trace-id': 'trace-other'}), + }); + await assert.rejects(() => mismatchedHeader.getAgentRelease('release-1'), /inconsistent trace correlation/); +}); + +test('Trusted Publication rejects malformed success relationships and unknown fields', async () => { + const mismatchedBinding = {...trustedBinding(), agentId: 'agent.other'}; + const bindingClient = new NekiroApiClient({ + baseUrl: 'https://api.example.test', + token: 'test-token', + fetchImpl: async () => trustedResponse(mismatchedBinding, 201), + }); + await assert.rejects( + () => bindingClient.createEndpointBinding('provider.main', 'agent.echo', {endpoint: 'https://agent.example/a2a', method: 'http_well_known', version: '1.2.3'}), + /invalid response/, + ); + + const unknownRelease = {...trustedRelease(), unknown: true}; + const releaseClient = new NekiroApiClient({ + baseUrl: 'https://api.example.test', + token: 'test-token', + fetchImpl: async () => trustedResponse(unknownRelease, 200), + }); + await assert.rejects(() => releaseClient.getAgentRelease('release-1'), /invalid response/); +}); + +test('Trusted Publication enforces operation-specific success status and strict endpoint data', async () => { + const wrongStatusClient = new NekiroApiClient({ + baseUrl: 'https://api.example.test', + token: 'test-token', + fetchImpl: async () => trustedResponse(trustedBinding(), 200), + }); + await assert.rejects( + () => wrongStatusClient.createEndpointBinding('provider.main', 'agent.echo', {endpoint: 'https://agent.example/a2a', method: 'http_well_known', version: '1.2.3'}), + /unexpected HTTP status/, + ); + + const userInfoClient = new NekiroApiClient({ + baseUrl: 'https://api.example.test', + token: 'test-token', + fetchImpl: async () => trustedResponse(trustedBinding(), 201), + }); + assert.throws( + () => userInfoClient.createEndpointBinding('provider.main', 'agent.echo', {endpoint: 'https://user:secret@agent.example/a2a', method: 'http_well_known', version: '1.2.3'}), + /userinfo|HTTP\(S\) URI/, + ); + assert.throws( + () => userInfoClient.createEndpointBinding('provider.main', 'agent.echo', {endpoint: ' https://agent.example/a2a', method: 'http_well_known', version: '1.2.3'}), + /whitespace/, + ); + + const invalidDateClient = new NekiroApiClient({ + baseUrl: 'https://api.example.test', + token: 'test-token', + fetchImpl: async () => trustedResponse({...trustedBinding(), updatedAt: '2026-02-31T00:00:00Z'}, 200), + }); + await assert.rejects(() => invalidDateClient.getEndpointBinding('provider.main', 'binding-1'), /invalid response/); +}); + +test('Trusted Publication transport errors do not expose the underlying error message', async () => { + const client = new NekiroApiClient({ + baseUrl: 'https://api.example.test', + token: 'test-token', + fetchImpl: async () => { throw new Error('internal socket detail and secret-value'); }, + }); + await assert.rejects(() => client.getAgentRelease('release-1'), (value: unknown) => { + assert.ok(value instanceof NekiroApiError); + assert.equal(value.code, 'NETWORK_ERROR'); + assert.equal(value.message, 'NeKiro API request failed.'); + assert.equal(value.message.includes('secret-value'), false); + return true; + }); +}); + +test('Trusted Publication malformed error bodies return a safe validation error after one read', async () => { + const client = new NekiroApiClient({ + baseUrl: 'https://api.example.test', + token: 'test-token', + fetchImpl: async () => new Response('', {status: 500, headers: {'Content-Type': 'application/json'}}), + }); + await assert.rejects(() => client.getAgentRelease('release-1'), (value: unknown) => { + assert.ok(value instanceof NekiroApiError); + assert.equal(value.code, 'INVALID_RESPONSE'); + assert.equal(value.message, 'NeKiro Trusted Publication returned an invalid error response.'); + return true; + }); +}); + +function trustedResponse(value: unknown, status: number, extraHeaders: Record = {}): Response { + return new Response(JSON.stringify(value), { + status, + headers: {'Content-Type': 'application/json', ...extraHeaders}, + }); +} + +function trustedBinding(): Record { + return { + bindingId: 'binding-1', + providerId: 'provider.main', + agentId: 'agent.echo', + agentCardVersion: '1.2.3', + endpoint: 'https://agent.example/a2a', + verificationMethod: 'http_well_known', + verificationStatus: 'verified', + verificationEvidenceDigest: '0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef', + createdAt: '2026-07-26T00:00:00Z', + updatedAt: '2026-07-26T00:00:01Z', + verifiedAt: '2026-07-26T00:00:01Z', + }; +} + +function trustedChallenge(): Record { + return { + challengeId: 'challenge-1', + bindingId: 'binding-1', + challengeUrl: 'https://agent.example/.well-known/nekiro/challenges/challenge-1', + proof: 'one-time-proof', + expiresAt: '2026-07-26T00:05:00Z', + }; +} + +function trustedRelease(): Record { + return { + releaseId: 'release-1', + providerId: 'provider.main', + agentId: 'agent.echo', + agentCardVersion: '1.2.3', + cardDigest: 'abcdef0123456789abcdef0123456789abcdef0123456789abcdef0123456789', + endpointBindingId: 'binding-1', + endpointOrigin: 'https://agent.example', + endpointPath: '/a2a', + verificationMethod: 'http_well_known', + verificationEvidenceDigest: '0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef', + state: 'published', + createdAt: '2026-07-26T00:00:00Z', + updatedAt: '2026-07-26T00:00:02Z', + verifiedAt: '2026-07-26T00:00:01Z', + publishedAt: '2026-07-26T00:00:02Z', + }; +} diff --git a/apps/console/src/api/nekiro.ts b/apps/console/src/api/nekiro.ts new file mode 100644 index 0000000..952ece0 --- /dev/null +++ b/apps/console/src/api/nekiro.ts @@ -0,0 +1,1464 @@ +import type {Agent, Installation, InstallationStatus, PlatformErrorView, Workspace} from '../types'; + +export type PublicationStatus = 'draft' | 'published' | 'disabled'; +export type AuthenticationType = 'none' | 'api_key' | 'http_bearer' | 'oauth2_client_credentials' | 'mutual_tls'; +export type JsonObject = Record; + +export interface AgentSkill { + id: string; + name: string; + description: string; + inputSchema: JsonObject; + outputSchema: JsonObject; + requiredPermissions: string[]; +} + +export interface AgentPermission { + id: string; + description: string; +} + +export interface AgentCardV02 { + schemaVersion: '0.2'; + agentId: string; + name: string; + description: string; + owner: { + id: string; + displayName: string; + }; + version: string; + protocol: { + type: 'a2a'; + version: '0.3.0'; + transport: 'JSONRPC'; + endpoint: string; + }; + skills: AgentSkill[]; + authentication: { + type: AuthenticationType; + }; + permissions: AgentPermission[]; + limits: { + timeoutMs: number; + maxInputBytes: number; + maxOutputBytes: number; + streaming: boolean; + }; +} + +export interface CatalogEntry { + card: AgentCardV02; + publicationStatus: PublicationStatus; + registeredAt: string; + publishedAt?: string; +} + +export interface CatalogSearchResponse { + items: CatalogEntry[]; + nextCursor?: string; +} + +export interface CatalogSearchParams { + query?: string; + capability?: string; + ownerId?: string; + limit?: number; + cursor?: string; +} + +export interface AgentCardInput { + agentId: string; + name: string; + ownerId: string; + ownerDisplayName: string; + description: string; + version: string; + endpoint: string; + authentication: AuthenticationType; + permissions: AgentPermission[]; + capabilitiesJson: string; + limits: AgentCardV02['limits']; +} + +export interface InstallAgentRequest { + agentId: string; + versionConstraint: string; + acceptedPermissions: string[]; +} + +export interface InstallationList { + items: Installation[]; + nextCursor?: string; +} + +export type TrustedPublicationErrorCode = + | 'VALIDATION_ERROR' | 'UNAUTHENTICATED' | 'FORBIDDEN' | 'NOT_FOUND' | 'CONFLICT' + | 'INVALID_ENDPOINT' | 'DISALLOWED_NETWORK' | 'ENDPOINT_UNAVAILABLE' + | 'WRONG_PROOF' | 'CHALLENGE_EXPIRED' | 'CHALLENGE_REUSED' + | 'REDIRECT_NOT_ALLOWED' | 'DEPENDENCY_ERROR' | 'INTERNAL_ERROR'; + +export type EndpointBindingVerificationStatus = 'pending' | 'verified' | 'failed' | 'revoked'; +export type AgentReleaseState = 'draft' | 'pending_verification' | 'verified' | 'published' | 'suspended' | 'revoked'; + +export interface CreateEndpointBindingRequest { + endpoint: string; + method: 'http_well_known'; + version: string; +} + +export interface EndpointBinding { + bindingId: string; + providerId: string; + agentId: string; + agentCardVersion: string; + endpoint: string; + verificationMethod: 'http_well_known'; + verificationStatus: EndpointBindingVerificationStatus; + verificationFailureCode?: string; + verificationEvidenceDigest?: string; + createdAt: string; + updatedAt: string; + verifiedAt?: string; + revokedAt?: string; +} + +export interface VerificationChallenge { + challengeId: string; + bindingId: string; + challengeUrl: string; + proof: string; + expiresAt: string; +} + +export interface CreateAgentReleaseRequest { + version: string; + endpointBindingId: string; +} + +export interface AgentRelease { + releaseId: string; + providerId: string; + agentId: string; + agentCardVersion: string; + cardDigest: string; + endpointBindingId: string; + endpointOrigin: string; + endpointPath: string; + verificationMethod: 'http_well_known'; + verificationEvidenceDigest?: string; + state: AgentReleaseState; + createdAt: string; + updatedAt: string; + verifiedAt?: string; + publishedAt?: string; + suspendedAt?: string; + revokedAt?: string; +} + +export type PlatformErrorCode = + | 'VALIDATION_ERROR' | 'UNAUTHENTICATED' | 'FORBIDDEN' | 'NOT_FOUND' | 'CONFLICT' + | 'NOT_ACCEPTABLE' | 'PAYLOAD_TOO_LARGE' | 'AGENT_NOT_INSTALLED' + | 'INSTALLATION_DISABLED' | 'AGENT_DISABLED' | 'CAPABILITY_NOT_ALLOWED' + | 'ROUTE_NOT_FOUND' | 'AGENT_AUTH_UNSUPPORTED' | 'AGENT_RESPONSE_TOO_LARGE' + | 'A2A_PROTOCOL_ERROR' | 'AGENT_UNAVAILABLE' | 'AGENT_EXECUTION_FAILED' + | 'DEPENDENCY_ERROR' | 'TIMEOUT' | 'CANCELED' | 'INTERNAL_ERROR'; + +export interface PreCorrelationPlatformErrorV4 { + code: PlatformErrorCode; + message: string; + traceId: string; +} + +export interface CorrelatedPlatformErrorV4 extends PreCorrelationPlatformErrorV4 { + invocationId: string; + rootTaskId: string; +} + +export type PlatformErrorV4 = PreCorrelationPlatformErrorV4 | CorrelatedPlatformErrorV4; + +export interface InvocationRequestV4 { + agentId: string; + capability: string; + input: JsonObject; + stream: boolean; +} + +export interface InvocationResultV1 { + schemaVersion: '1'; + invocationId: string; + rootTaskId: string; + traceId: string; + status: 'succeeded'; + result: unknown; +} + +export type ResultStreamEventType = 'accepted' | 'chunk' | 'completed' | 'failed' | 'canceled' | 'timed_out'; +export type InvocationResultStatus = 'pending' | 'running' | 'succeeded' | 'failed' | 'canceled' | 'timed_out'; + +export interface InvocationResultStreamEventV2 { + schemaVersion: '2'; + sequence: number; + type: ResultStreamEventType; + status: InvocationResultStatus; + invocationId: string; + rootTaskId: string; + traceId: string; + chunkIndex?: number; + chunk?: unknown; + error?: CorrelatedPlatformErrorV4; +} + +export type InvocationEventType = 'created' | 'routing' | 'started' | 'stream' | 'succeeded' | 'failed' | 'canceled' | 'timed_out'; +export type InvocationEventStatus = 'pending' | 'routing' | 'running' | 'succeeded' | 'failed' | 'canceled' | 'timed_out'; + +export interface InvocationEventV03 { + schemaVersion: '0.3'; + eventId: string; + sequence: number; + occurredAt: string; + type: InvocationEventType; + status: InvocationEventStatus; + invocationId: string; + rootTaskId: string; + parentInvocationId?: string; + traceId: string; + caller: {type: 'user' | 'agent' | 'service'; id: string}; + workspaceId: string; + targetAgentId: string; + agentCardVersion: string; + agentReleaseId?: string; + agentCardDigest?: string; + capability: string; + chunkIndex?: number; + chunkBytes?: number; + latencyMs?: number; + error?: CorrelatedPlatformErrorV4; +} + +export interface InvocationRecordV4 { + invocationId: string; + rootTaskId: string; + parentInvocationId?: string; + traceId: string; + caller: {type: 'user' | 'agent' | 'service'; id: string}; + workspaceId: string; + targetAgentId: string; + agentCardVersion: string; + agentReleaseId?: string; + agentCardDigest?: string; + capability: string; + status: InvocationEventStatus; + latencyMs?: number; + errorCode?: PlatformErrorCode; + createdAt: string; + updatedAt: string; +} + +export interface InvocationDetailResponseV4 { + invocation: InvocationRecordV4; + events: InvocationEventV03[]; +} + +export interface TraceResponseV4 { + traceId: string; + invocations: InvocationRecordV4[]; +} + +export class NekiroApiError extends Error { + readonly status: number; + readonly code?: string; + readonly traceId?: string; + readonly invocationId?: string; + readonly rootTaskId?: string; + + constructor(status: number, message: string, code?: string, traceId?: string, invocationId?: string, rootTaskId?: string) { + super(message); + this.name = 'NekiroApiError'; + this.status = status; + this.code = code; + this.traceId = traceId; + this.invocationId = invocationId; + this.rootTaskId = rootTaskId; + } + + toView(): PlatformErrorView { + return { + status: this.status, + code: this.code, + message: this.message, + traceId: this.traceId, + invocationId: this.invocationId, + rootTaskId: this.rootTaskId, + }; + } +} + +interface NekiroApiClientOptions { + baseUrl: string; + token: string; + fetchImpl?: typeof fetch; +} + +export class NekiroApiClient { + private readonly baseUrl: string; + private readonly token: string; + private readonly fetchImpl: typeof fetch; + + constructor(options: NekiroApiClientOptions) { + if (typeof options.baseUrl !== 'string' || options.baseUrl === '' || options.baseUrl !== options.baseUrl.trim()) { + throw new NekiroApiError(0, 'NeKiro Control Plane API base URL is required and must not contain surrounding whitespace.', 'CONFIGURATION_ERROR'); + } + let parsedBaseUrl: URL; + try { + parsedBaseUrl = new URL(options.baseUrl); + } catch { + throw new NekiroApiError(0, 'NeKiro Control Plane API base URL is invalid.', 'CONFIGURATION_ERROR'); + } + if (!['http:', 'https:'].includes(parsedBaseUrl.protocol) + || parsedBaseUrl.username + || parsedBaseUrl.password + || parsedBaseUrl.search + || parsedBaseUrl.hash + || parsedBaseUrl.pathname !== '/' + || parsedBaseUrl.hostname === 'localhost' + || parsedBaseUrl.hostname.includes('*') + || isIpHostname(parsedBaseUrl.hostname)) { + throw new NekiroApiError(0, 'NeKiro Control Plane API base URL is invalid.', 'CONFIGURATION_ERROR'); + } + this.baseUrl = options.baseUrl.replace(/\/+$/, ''); + const token = options.token; + if (typeof token !== 'string' || token === '') { + throw new NekiroApiError(0, 'NeKiro development bearer token is required.', 'CONFIGURATION_ERROR'); + } + if (token !== token.trim() || /\s/.test(token)) { + throw new Error('NeKiro bearer token must not contain whitespace'); + } + this.token = token; + this.fetchImpl = options.fetchImpl ?? ((input, init) => globalThis.fetch(input, init)); + } + + searchAgents(params: CatalogSearchParams = {}): Promise { + const suffix = this.queryString(params); + return this.request('/v3/agents' + suffix); + } + + registerAgent(card: AgentCardV02): Promise { + return this.request('/v3/agents', { + method: 'POST', + body: JSON.stringify({card}), + }); + } + + getAgentVersion(agentId: string, version: string): Promise { + return this.request(this.versionPath(agentId, version)); + } + + publishAgentVersion(agentId: string, version: string): Promise { + return this.request(this.versionPath(agentId, version) + '/publish', {method: 'POST'}); + } + + disableAgentVersion(agentId: string, version: string): Promise { + return this.request(this.versionPath(agentId, version) + '/disable', {method: 'POST'}); + } + + createEndpointBinding(providerId: string, agentId: string, request: CreateEndpointBindingRequest): Promise { + const safeProviderId = readIdentifier(providerId, 'providerId'); + const safeAgentId = readIdentifier(agentId, 'agentId'); + const safeRequest = validateCreateEndpointBindingRequest(request); + return this.trustedRequest( + '/v4/providers/' + encodeURIComponent(safeProviderId) + '/agents/' + encodeURIComponent(safeAgentId) + '/endpoint-bindings', + {method: 'POST', body: JSON.stringify(safeRequest)}, + (value) => validateEndpointBinding(value, {providerId: safeProviderId, agentId: safeAgentId, version: safeRequest.version}), + 201, + ); + } + + getEndpointBinding(providerId: string, bindingId: string): Promise { + const safeProviderId = readIdentifier(providerId, 'providerId'); + const safeBindingId = readIdentifier(bindingId, 'bindingId'); + return this.trustedRequest( + '/v4/providers/' + encodeURIComponent(safeProviderId) + '/endpoint-bindings/' + encodeURIComponent(safeBindingId), + {}, + (value) => validateEndpointBinding(value, {providerId: safeProviderId, bindingId: safeBindingId}), + ); + } + + createVerificationChallenge(providerId: string, bindingId: string): Promise { + const safeProviderId = readIdentifier(providerId, 'providerId'); + const safeBindingId = readIdentifier(bindingId, 'bindingId'); + return this.trustedRequest( + '/v4/providers/' + encodeURIComponent(safeProviderId) + '/endpoint-bindings/' + encodeURIComponent(safeBindingId) + '/challenges', + {method: 'POST'}, + (value) => validateVerificationChallenge(value, safeBindingId), + 201, + ); + } + + completeVerificationChallenge(providerId: string, bindingId: string, challengeId: string): Promise { + const safeProviderId = readIdentifier(providerId, 'providerId'); + const safeBindingId = readIdentifier(bindingId, 'bindingId'); + const safeChallengeId = readIdentifier(challengeId, 'challengeId'); + return this.trustedRequest( + '/v4/providers/' + encodeURIComponent(safeProviderId) + '/endpoint-bindings/' + encodeURIComponent(safeBindingId) + '/challenges/' + encodeURIComponent(safeChallengeId) + '/complete', + {method: 'POST'}, + (value) => validateEndpointBinding(value, {providerId: safeProviderId, bindingId: safeBindingId}), + ); + } + + createAgentRelease(providerId: string, agentId: string, request: CreateAgentReleaseRequest): Promise { + const safeProviderId = readIdentifier(providerId, 'providerId'); + const safeAgentId = readIdentifier(agentId, 'agentId'); + const safeRequest = validateCreateAgentReleaseRequest(request); + return this.trustedRequest( + '/v4/providers/' + encodeURIComponent(safeProviderId) + '/agents/' + encodeURIComponent(safeAgentId) + '/releases', + {method: 'POST', body: JSON.stringify(safeRequest)}, + (value) => validateAgentRelease(value, { + providerId: safeProviderId, + agentId: safeAgentId, + version: safeRequest.version, + bindingId: safeRequest.endpointBindingId, + }), + 201, + ); + } + + getAgentRelease(releaseId: string): Promise { + const safeReleaseId = readIdentifier(releaseId, 'releaseId'); + return this.trustedRequest( + '/v4/releases/' + encodeURIComponent(safeReleaseId), + {}, + (value) => validateAgentRelease(value, {releaseId: safeReleaseId}), + ); + } + + verifyAgentRelease(releaseId: string): Promise { + return this.releaseAction(releaseId, 'verify'); + } + + publishAgentRelease(releaseId: string): Promise { + return this.releaseAction(releaseId, 'publish'); + } + + suspendAgentRelease(releaseId: string): Promise { + return this.releaseAction(releaseId, 'suspend'); + } + + revokeAgentRelease(releaseId: string): Promise { + return this.releaseAction(releaseId, 'revoke'); + } + + createWorkspace(workspaceId: string): Promise { + return this.request('/v3/workspaces', { + method: 'POST', + body: JSON.stringify({workspaceId: readText(workspaceId, 'workspaceId')}), + }); + } + + getWorkspace(workspaceId: string): Promise { + return this.request('/v3/workspaces/' + encodeURIComponent(readText(workspaceId, 'workspaceId'))); + } + + installAgent(workspaceId: string, request: InstallAgentRequest): Promise { + return this.request(this.workspaceInstallationPath(workspaceId), { + method: 'POST', + body: JSON.stringify({ + agentId: readText(request.agentId, 'agentId'), + versionConstraint: readText(request.versionConstraint, 'versionConstraint'), + acceptedPermissions: request.acceptedPermissions, + }), + }).then((value) => validateInstallation(value, workspaceId)); + } + + listInstallations(workspaceId: string, params: {limit: number; cursor?: string}): Promise { + if (!params || !Number.isInteger(params.limit) || params.limit < 1 || params.limit > 100) { + throw new Error('installation limit must be an integer between 1 and 100'); + } + const query = this.queryString({limit: params.limit, cursor: params.cursor}); + return this.request(this.workspaceInstallationPath(workspaceId) + query).then((value) => validateInstallationList(value, workspaceId)); + } + + getInstallation(workspaceId: string, installationId: string): Promise { + return this.request(this.installationPath(workspaceId, installationId)).then((value) => validateInstallation(value, workspaceId)); + } + + updateInstallation(workspaceId: string, installationId: string, status: Exclude): Promise { + return this.request(this.installationPath(workspaceId, installationId), { + method: 'PATCH', + body: JSON.stringify({status}), + }).then((value) => validateInstallation(value, workspaceId)); + } + + uninstallAgent(workspaceId: string, installationId: string): Promise { + return this.request(this.installationPath(workspaceId, installationId), {method: 'DELETE'}).then((value) => validateInstallation(value, workspaceId)); + } + + invoke(workspaceId: string, request: InvocationRequestV4): Promise { + if (request.stream !== false) { + throw new Error('streaming invocation must use invokeStream'); + } + requireInvocationInput(request.input); + return this.request(this.invocationPath(workspaceId), { + method: 'POST', + headers: {'Accept': 'application/json'}, + body: JSON.stringify({ + agentId: readIdentifier(request.agentId, 'agentId'), + capability: readIdentifier(request.capability, 'capability'), + input: request.input, + stream: false, + }), + }).then((value) => validateInvocationResult(value)); + } + + async invokeStream(workspaceId: string, request: Omit, onEvent?: (event: InvocationResultStreamEventV2) => void): Promise { + requireInvocationInput(request.input); + const path = this.invocationPath(workspaceId); + const response = await this.rawRequest(path, { + method: 'POST', + headers: {'Accept': 'text/event-stream'}, + body: JSON.stringify({ + agentId: readIdentifier(request.agentId, 'agentId'), + capability: readIdentifier(request.capability, 'capability'), + input: request.input, + stream: true, + }), + }); + if (!response.ok) { + throw await this.errorFromResponse(response); + } + if (response.headers.get('content-type')?.split(';', 1)[0].trim() !== 'text/event-stream') { + throw new NekiroApiError(response.status, 'NeKiro Control Plane API returned an invalid stream media type.', 'INVALID_RESPONSE'); + } + if (!response.body) { + throw new NekiroApiError(response.status, 'NeKiro Control Plane API returned an empty stream.', 'INVALID_RESPONSE'); + } + const reader = response.body.getReader(); + const decoder = new TextDecoder(); + let buffer = ''; + let expectedSequence = 0; + let expectedChunkIndex = 0; + let terminal = false; + const events: InvocationResultStreamEventV2[] = []; + const consume = (line: string) => { + if (!line.startsWith('data: ')) throw new NekiroApiError(response.status, 'NeKiro stream contains an invalid data line.', 'INVALID_RESPONSE'); + const event = validateResultStreamEvent(parseJsonValue(line.slice(6), 'stream event')); + if (terminal) throw new NekiroApiError(response.status, 'NeKiro stream emitted an event after terminal state.', 'INVALID_RESPONSE'); + if (event.sequence !== expectedSequence) throw new NekiroApiError(response.status, 'NeKiro stream sequence is not contiguous.', 'INVALID_RESPONSE'); + if ((expectedSequence === 0 && event.type !== 'accepted') || (expectedSequence > 0 && event.type === 'accepted')) throw new NekiroApiError(response.status, 'NeKiro stream accepted event must be first.', 'INVALID_RESPONSE'); + if (event.type === 'chunk') { + if (event.chunkIndex !== expectedChunkIndex) throw new NekiroApiError(response.status, 'NeKiro stream chunk index is not contiguous.', 'INVALID_RESPONSE'); + expectedChunkIndex += 1; + } + if (events[0] && (event.invocationId !== events[0].invocationId || event.rootTaskId !== events[0].rootTaskId || event.traceId !== events[0].traceId)) { + throw new NekiroApiError(response.status, 'NeKiro stream correlation changed.', 'INVALID_RESPONSE'); + } + if (event.error && (event.error.invocationId !== event.invocationId || event.error.rootTaskId !== event.rootTaskId || event.error.traceId !== event.traceId)) { + throw new NekiroApiError(response.status, 'NeKiro stream error correlation changed.', 'INVALID_RESPONSE'); + } + expectedSequence += 1; + terminal = event.type === 'completed' || event.type === 'failed' || event.type === 'canceled' || event.type === 'timed_out'; + events.push(event); + onEvent?.(event); + }; + for (;;) { + const result = await reader.read(); + buffer += decoder.decode(result.value ?? new Uint8Array(), {stream: !result.done}); + let newline = buffer.indexOf('\n'); + while (newline >= 0) { + const line = buffer.slice(0, newline).replace(/\r$/, ''); + buffer = buffer.slice(newline + 1); + if (line !== '') consume(line); + newline = buffer.indexOf('\n'); + } + if (result.done) break; + } + if (buffer.trim() !== '') consume(buffer.trim()); + if (!terminal) throw new NekiroApiError(response.status, 'NeKiro stream ended before a terminal event.', 'INVALID_RESPONSE'); + return events; + } + + getInvocation(workspaceId: string, invocationId: string): Promise { + return this.request(this.invocationPath(workspaceId) + '/' + encodeURIComponent(readIdentifier(invocationId, 'invocationId'))).then((value) => validateInvocationDetail(value, workspaceId)); + } + + getTrace(workspaceId: string, traceId: string): Promise { + return this.request(this.tracePath(workspaceId, traceId)).then((value) => validateTrace(value, workspaceId, traceId)); + } + + private versionPath(agentId: string, version: string): string { + return '/v3/agents/' + encodeURIComponent(agentId) + '/versions/' + encodeURIComponent(version); + } + + private workspaceInstallationPath(workspaceId: string): string { + return '/v3/workspaces/' + encodeURIComponent(readText(workspaceId, 'workspaceId')) + '/installations'; + } + + private installationPath(workspaceId: string, installationId: string): string { + return this.workspaceInstallationPath(workspaceId) + '/' + encodeURIComponent(readText(installationId, 'installationId')); + } + + private invocationPath(workspaceId: string): string { + return '/v4/workspaces/' + encodeURIComponent(readText(workspaceId, 'workspaceId')) + '/invocations'; + } + + private tracePath(workspaceId: string, traceId: string): string { + return '/v4/workspaces/' + encodeURIComponent(readText(workspaceId, 'workspaceId')) + '/traces/' + encodeURIComponent(readIdentifier(traceId, 'traceId')); + } + + private releaseAction(releaseId: string, action: 'verify' | 'publish' | 'suspend' | 'revoke'): Promise { + const safeReleaseId = readIdentifier(releaseId, 'releaseId'); + return this.trustedRequest( + '/v4/releases/' + encodeURIComponent(safeReleaseId) + '/' + action, + {method: 'POST'}, + (value) => validateAgentRelease(value, {releaseId: safeReleaseId}), + ); + } + + private queryString(params: object): string { + const query = new URLSearchParams(); + for (const [key, value] of Object.entries(params) as Array<[string, string | number | undefined]>) { + if (value !== undefined && value !== '') { + query.set(key, String(value)); + } + } + const serialized = query.toString(); + return serialized ? '?' + serialized : ''; + } + + private async request(path: string, init: RequestInit = {}): Promise { + if (!this.baseUrl) { + throw new NekiroApiError(0, 'NeKiro Control Plane API base URL is not configured.', 'CONFIGURATION_ERROR'); + } + + const headers = new Headers(init.headers); + headers.set('Accept', 'application/json'); + if (init.body && !headers.has('Content-Type')) { + headers.set('Content-Type', 'application/json'); + } + headers.set('Authorization', 'Bearer ' + this.token); + + const response = await this.rawRequest(path, {...init, headers}); + + const responseText = await response.text(); + const payload = parseJson(responseText); + if (!response.ok) { + throw await this.errorFromResponse(response, payload); + } + + if (response.status === 204) { + return undefined as T; + } + if (responseText.length === 0) throw new NekiroApiError(response.status, 'NeKiro Control Plane API returned an empty success response.', 'INVALID_RESPONSE'); + if (payload === undefined) { + throw new NekiroApiError(response.status, 'NeKiro Control Plane API returned invalid JSON.', 'INVALID_RESPONSE'); + } + return payload as T; + } + + private async trustedRequest(path: string, init: RequestInit, validate: (value: unknown) => T, expectedStatus = 200): Promise { + const response = await this.rawRequest(path, {...init, redirect: 'error'}); + const mediaType = response.headers.get('content-type')?.split(';', 1)[0].trim(); + const responseText = await response.text(); + const payload = parseJson(responseText); + if (!response.ok) { + throw await this.trustedErrorFromResponse(response, payload); + } + if (response.status !== expectedStatus) { + throw new NekiroApiError(response.status, 'NeKiro Trusted Publication returned an unexpected HTTP status.', 'INVALID_RESPONSE'); + } + if (mediaType !== 'application/json' || payload === undefined) { + throw new NekiroApiError(response.status, 'NeKiro Trusted Publication returned an invalid JSON response.', 'INVALID_RESPONSE'); + } + try { + return validate(payload); + } catch { + throw new NekiroApiError(response.status, 'NeKiro Trusted Publication returned an invalid response.', 'INVALID_RESPONSE'); + } + } + + private async rawRequest(path: string, init: RequestInit = {}): Promise { + if (!this.baseUrl) throw new NekiroApiError(0, 'NeKiro Control Plane API base URL is not configured.', 'CONFIGURATION_ERROR'); + const headers = new Headers(init.headers); + headers.set('Accept', headers.get('Accept') ?? 'application/json'); + if (init.body && !headers.has('Content-Type')) headers.set('Content-Type', 'application/json'); + headers.set('Authorization', 'Bearer ' + this.token); + try { + return await this.fetchImpl(new URL(path, this.baseUrl + '/'), {...init, headers}); + } catch (error) { + throw new NekiroApiError(0, 'NeKiro API request failed.', 'NETWORK_ERROR'); + } + } + + private async errorFromResponse(response: Response, knownPayload?: unknown): Promise { + const payload = knownPayload ?? parseJson(await response.text()); + if (!isPlatformErrorV4(payload)) { + return new NekiroApiError(response.status, 'NeKiro Control Plane API returned an invalid Platform Error payload.', 'INVALID_RESPONSE'); + } + const invocationId = 'invocationId' in payload ? payload.invocationId : undefined; + const rootTaskId = 'rootTaskId' in payload ? payload.rootTaskId : undefined; + return new NekiroApiError(response.status, payload.message, payload.code, payload.traceId, invocationId, rootTaskId); + } + + private trustedErrorFromResponse(response: Response, payload: unknown): NekiroApiError { + if (response.headers.get('content-type')?.split(';', 1)[0].trim() !== 'application/json') { + return new NekiroApiError(response.status, 'NeKiro Trusted Publication returned an invalid error response.', 'INVALID_RESPONSE'); + } + if (!isTrustedPublicationError(payload)) { + return new NekiroApiError(response.status, 'NeKiro Trusted Publication returned an invalid error response.', 'INVALID_RESPONSE'); + } + const headerTraceId = response.headers.get('x-nek-trace-id'); + if (headerTraceId !== null && (headerTraceId !== payload.traceId || !/^[A-Za-z0-9](?:[A-Za-z0-9._:-]{0,127})$/.test(headerTraceId))) { + return new NekiroApiError(response.status, 'NeKiro Trusted Publication returned inconsistent trace correlation.', 'INVALID_RESPONSE'); + } + return new NekiroApiError(response.status, payload.message, payload.code, payload.traceId); + } +} + +const PLATFORM_ERROR_MESSAGES: Record = { + VALIDATION_ERROR: 'The request is invalid.', + UNAUTHENTICATED: 'Authentication is required.', + FORBIDDEN: 'The requested operation is not allowed.', + NOT_FOUND: 'The requested resource was not found.', + CONFLICT: 'The requested operation conflicts with current state.', + NOT_ACCEPTABLE: 'The requested result mode is not acceptable.', + PAYLOAD_TOO_LARGE: 'The payload is too large.', + AGENT_NOT_INSTALLED: 'The Agent is not installed in this Workspace.', + INSTALLATION_DISABLED: 'The Agent installation is disabled.', + AGENT_DISABLED: 'The Agent version is disabled.', + CAPABILITY_NOT_ALLOWED: 'The requested capability is not allowed.', + ROUTE_NOT_FOUND: 'No route is available for the Agent.', + AGENT_AUTH_UNSUPPORTED: 'The Agent authentication type is not supported for invocation.', + AGENT_RESPONSE_TOO_LARGE: 'The Agent response is too large.', + A2A_PROTOCOL_ERROR: 'The Agent returned an invalid A2A response.', + AGENT_UNAVAILABLE: 'The Agent is unavailable.', + AGENT_EXECUTION_FAILED: 'The Agent failed to complete the invocation.', + DEPENDENCY_ERROR: 'A required platform dependency failed.', + TIMEOUT: 'The invocation timed out.', + CANCELED: 'The invocation was canceled.', + INTERNAL_ERROR: 'The platform could not complete the request.', +}; + +const TRUSTED_PUBLICATION_ERROR_CODES: readonly TrustedPublicationErrorCode[] = [ + 'VALIDATION_ERROR', 'UNAUTHENTICATED', 'FORBIDDEN', 'NOT_FOUND', 'CONFLICT', + 'INVALID_ENDPOINT', 'DISALLOWED_NETWORK', 'ENDPOINT_UNAVAILABLE', 'WRONG_PROOF', + 'CHALLENGE_EXPIRED', 'CHALLENGE_REUSED', 'REDIRECT_NOT_ALLOWED', + 'DEPENDENCY_ERROR', 'INTERNAL_ERROR', +]; + +interface TrustedPublicationError { + code: TrustedPublicationErrorCode; + message: string; + traceId: string; +} + +function validateCreateEndpointBindingRequest(value: unknown): CreateEndpointBindingRequest { + const record = requireRecord(value, 'Create Endpoint Binding request'); + assertAllowedKeys(record, ['endpoint', 'method', 'version'], 'Create Endpoint Binding request'); + const endpoint = requireHttpUri(record.endpoint, 'endpoint'); + const method = requireEnum(record.method, ['http_well_known'], 'method') as 'http_well_known'; + const version = requireSemver(record.version, 'version'); + return {endpoint, method, version}; +} + +function validateCreateAgentReleaseRequest(value: unknown): CreateAgentReleaseRequest { + const record = requireRecord(value, 'Create Agent Release request'); + assertAllowedKeys(record, ['version', 'endpointBindingId'], 'Create Agent Release request'); + return { + version: requireSemver(record.version, 'version'), + endpointBindingId: readIdentifier(record.endpointBindingId, 'endpointBindingId'), + }; +} + +function validateEndpointBinding(value: unknown, expected: {providerId?: string; agentId?: string; version?: string; bindingId?: string}): EndpointBinding { + const record = requireRecord(value, 'Endpoint Binding'); + assertAllowedKeys(record, ['bindingId', 'providerId', 'agentId', 'agentCardVersion', 'endpoint', 'verificationMethod', 'verificationStatus', 'verificationFailureCode', 'verificationEvidenceDigest', 'createdAt', 'updatedAt', 'verifiedAt', 'revokedAt'], 'Endpoint Binding'); + const result: EndpointBinding = { + bindingId: readIdentifier(record.bindingId, 'bindingId'), + providerId: readIdentifier(record.providerId, 'providerId'), + agentId: readIdentifier(record.agentId, 'agentId'), + agentCardVersion: requireSemver(record.agentCardVersion, 'agentCardVersion'), + endpoint: requireHttpUri(record.endpoint, 'endpoint'), + verificationMethod: requireEnum(record.verificationMethod, ['http_well_known'], 'verificationMethod') as 'http_well_known', + verificationStatus: requireEnum(record.verificationStatus, ['pending', 'verified', 'failed', 'revoked'], 'verificationStatus') as EndpointBindingVerificationStatus, + createdAt: requireDateValue(record.createdAt, 'createdAt'), + updatedAt: requireDateValue(record.updatedAt, 'updatedAt'), + }; + if ('verificationFailureCode' in record) result.verificationFailureCode = requireOptionalText(record.verificationFailureCode, 'verificationFailureCode', 64); + if ('verificationEvidenceDigest' in record) result.verificationEvidenceDigest = requireDigest(record.verificationEvidenceDigest, 'verificationEvidenceDigest'); + if ('verifiedAt' in record) result.verifiedAt = requireDateValue(record.verifiedAt, 'verifiedAt'); + if ('revokedAt' in record) result.revokedAt = requireDateValue(record.revokedAt, 'revokedAt'); + if (expected.providerId !== undefined && result.providerId !== expected.providerId) throw new Error('Endpoint Binding provider does not match the request'); + if (expected.agentId !== undefined && result.agentId !== expected.agentId) throw new Error('Endpoint Binding Agent does not match the request'); + if (expected.version !== undefined && result.agentCardVersion !== expected.version) throw new Error('Endpoint Binding version does not match the request'); + if (expected.bindingId !== undefined && result.bindingId !== expected.bindingId) throw new Error('Endpoint Binding ID does not match the request'); + return result; +} + +function validateVerificationChallenge(value: unknown, expectedBindingId: string): VerificationChallenge { + const record = requireRecord(value, 'Verification Challenge'); + assertAllowedKeys(record, ['challengeId', 'bindingId', 'challengeUrl', 'proof', 'expiresAt'], 'Verification Challenge'); + const result: VerificationChallenge = { + challengeId: readIdentifier(record.challengeId, 'challengeId'), + bindingId: readIdentifier(record.bindingId, 'bindingId'), + challengeUrl: requireUri(record.challengeUrl, 'challengeUrl'), + proof: requireBoundedText(record.proof, 'proof', 1, 128), + expiresAt: requireDateValue(record.expiresAt, 'expiresAt'), + }; + if (result.bindingId !== expectedBindingId) throw new Error('Verification Challenge Binding does not match the request'); + return result; +} + +function validateAgentRelease(value: unknown, expected: {providerId?: string; agentId?: string; version?: string; bindingId?: string; releaseId?: string}): AgentRelease { + const record = requireRecord(value, 'Agent Release'); + assertAllowedKeys(record, ['releaseId', 'providerId', 'agentId', 'agentCardVersion', 'cardDigest', 'endpointBindingId', 'endpointOrigin', 'endpointPath', 'verificationMethod', 'verificationEvidenceDigest', 'state', 'createdAt', 'updatedAt', 'verifiedAt', 'publishedAt', 'suspendedAt', 'revokedAt'], 'Agent Release'); + const result: AgentRelease = { + releaseId: readIdentifier(record.releaseId, 'releaseId'), + providerId: readIdentifier(record.providerId, 'providerId'), + agentId: readIdentifier(record.agentId, 'agentId'), + agentCardVersion: requireSemver(record.agentCardVersion, 'agentCardVersion'), + cardDigest: requireDigest(record.cardDigest, 'cardDigest'), + endpointBindingId: readIdentifier(record.endpointBindingId, 'endpointBindingId'), + endpointOrigin: requireHttpUri(record.endpointOrigin, 'endpointOrigin'), + endpointPath: requireBoundedText(record.endpointPath, 'endpointPath', 1), + verificationMethod: requireEnum(record.verificationMethod, ['http_well_known'], 'verificationMethod') as 'http_well_known', + state: requireEnum(record.state, ['draft', 'pending_verification', 'verified', 'published', 'suspended', 'revoked'], 'state') as AgentReleaseState, + createdAt: requireDateValue(record.createdAt, 'createdAt'), + updatedAt: requireDateValue(record.updatedAt, 'updatedAt'), + }; + if ('verificationEvidenceDigest' in record) result.verificationEvidenceDigest = requireDigest(record.verificationEvidenceDigest, 'verificationEvidenceDigest'); + if ('verifiedAt' in record) result.verifiedAt = requireDateValue(record.verifiedAt, 'verifiedAt'); + if ('publishedAt' in record) result.publishedAt = requireDateValue(record.publishedAt, 'publishedAt'); + if ('suspendedAt' in record) result.suspendedAt = requireDateValue(record.suspendedAt, 'suspendedAt'); + if ('revokedAt' in record) result.revokedAt = requireDateValue(record.revokedAt, 'revokedAt'); + if (expected.providerId !== undefined && result.providerId !== expected.providerId) throw new Error('Agent Release provider does not match the request'); + if (expected.agentId !== undefined && result.agentId !== expected.agentId) throw new Error('Agent Release Agent does not match the request'); + if (expected.version !== undefined && result.agentCardVersion !== expected.version) throw new Error('Agent Release version does not match the request'); + if (expected.bindingId !== undefined && result.endpointBindingId !== expected.bindingId) throw new Error('Agent Release Binding does not match the request'); + if (expected.releaseId !== undefined && result.releaseId !== expected.releaseId) throw new Error('Agent Release ID does not match the request'); + return result; +} + +function isTrustedPublicationError(value: unknown): value is TrustedPublicationError { + if (!isRecord(value)) return false; + if (Object.keys(value).some((key) => !['code', 'message', 'traceId'].includes(key))) return false; + return typeof value.code === 'string' + && TRUSTED_PUBLICATION_ERROR_CODES.includes(value.code as TrustedPublicationErrorCode) + && typeof value.message === 'string' + && value.message.length > 0 + && typeof value.traceId === 'string' + && /^[A-Za-z0-9](?:[A-Za-z0-9._:-]{0,127})$/.test(value.traceId); +} + +function validateInvocationResult(value: unknown): InvocationResultV1 { + const record = requireRecord(value, 'Invocation Result'); + assertAllowedKeys(record, ['schemaVersion', 'invocationId', 'rootTaskId', 'traceId', 'status', 'result'], 'Invocation Result'); + if (record.schemaVersion !== '1' || record.status !== 'succeeded') throw new Error('Invocation Result schema or status is invalid'); + requireIdentifier(record.invocationId, 'invocationId'); + requireIdentifier(record.rootTaskId, 'rootTaskId'); + requireIdentifier(record.traceId, 'traceId'); + if (!('result' in record)) throw new Error('Invocation Result result is required'); + return record as unknown as InvocationResultV1; +} + +function validateResultStreamEvent(value: unknown): InvocationResultStreamEventV2 { + const record = requireRecord(value, 'Invocation Result Stream Event'); + assertAllowedKeys(record, ['schemaVersion', 'sequence', 'type', 'status', 'invocationId', 'rootTaskId', 'traceId', 'chunkIndex', 'chunk', 'error'], 'Invocation Result Stream Event'); + if (record.schemaVersion !== '2' || typeof record.sequence !== 'number' || !Number.isInteger(record.sequence) || record.sequence < 0) throw new Error('Invocation Result Stream Event schema or sequence is invalid'); + const type = requireEnum(record.type, ['accepted', 'chunk', 'completed', 'failed', 'canceled', 'timed_out'], 'stream event type') as ResultStreamEventType; + const status = requireEnum(record.status, ['pending', 'running', 'succeeded', 'failed', 'canceled', 'timed_out'], 'stream event status') as InvocationResultStatus; + requireIdentifier(record.invocationId, 'invocationId'); requireIdentifier(record.rootTaskId, 'rootTaskId'); requireIdentifier(record.traceId, 'traceId'); + if (type === 'accepted' && (status !== 'pending' || 'chunk' in record || 'chunkIndex' in record || 'error' in record)) throw new Error('accepted stream event is invalid'); + if (type === 'chunk' && (status !== 'running' || !isNonNegativeInteger(record.chunkIndex) || !('chunk' in record) || 'error' in record)) throw new Error('chunk stream event is invalid'); + if (type === 'completed' && (status !== 'succeeded' || 'chunk' in record || 'chunkIndex' in record || 'error' in record)) throw new Error('completed stream event is invalid'); + if (type === 'failed' || type === 'canceled' || type === 'timed_out') { + if (status !== type) throw new Error('terminal stream event status is invalid'); + if (!isCorrelatedPlatformError(record.error)) throw new Error('terminal stream event error is invalid'); + if (type === 'failed' && (record.error.code === 'CANCELED' || record.error.code === 'TIMEOUT')) throw new Error('failed stream event error code is invalid'); + if (type === 'canceled' && record.error.code !== 'CANCELED') throw new Error('canceled stream event error code is invalid'); + if (type === 'timed_out' && record.error.code !== 'TIMEOUT') throw new Error('timed_out stream event error code is invalid'); + if ('chunk' in record || 'chunkIndex' in record) throw new Error('terminal stream event cannot contain a chunk'); + } + return record as unknown as InvocationResultStreamEventV2; +} + +function validateInvocationDetail(value: unknown, workspaceId: string): InvocationDetailResponseV4 { + const record = requireRecord(value, 'Invocation Detail'); + assertAllowedKeys(record, ['invocation', 'events'], 'Invocation Detail'); + const invocation = validateInvocationRecord(record.invocation, workspaceId); + if (!Array.isArray(record.events) || record.events.length === 0) throw new Error('Invocation Detail events are required'); + const events = record.events.map((event) => validateInvocationEvent(event, workspaceId)); + const lastEvent = events[events.length - 1]; + const eventIDs = new Set(); + let previous: InvocationEventV03 | undefined; + let expectedChunkIndex = 0; + events.forEach((event, index) => { + if (event.sequence !== index || event.invocationId !== invocation.invocationId || event.rootTaskId !== invocation.rootTaskId || event.parentInvocationId !== invocation.parentInvocationId || event.traceId !== invocation.traceId || event.workspaceId !== invocation.workspaceId || event.targetAgentId !== invocation.targetAgentId || event.agentCardVersion !== invocation.agentCardVersion || event.agentReleaseId !== invocation.agentReleaseId || event.agentCardDigest !== invocation.agentCardDigest || event.capability !== invocation.capability || event.caller.type !== invocation.caller.type || event.caller.id !== invocation.caller.id) throw new Error('Invocation Detail event correlation is invalid'); + if (eventIDs.has(event.eventId)) throw new Error('Invocation Detail repeats an event'); + eventIDs.add(event.eventId); + if (!previous) { + if (event.type !== 'created' || event.status !== 'pending') throw new Error('Invocation Detail must begin with created/pending'); + } else if (!validInvocationTransition(previous.status, event.type, event.status)) { + throw new Error('Invocation Detail event transition is invalid'); + } + if (event.type === 'stream') { + if (event.chunkIndex !== expectedChunkIndex) throw new Error('Invocation Detail chunk sequence is invalid'); + expectedChunkIndex += 1; + } + previous = event; + }); + if (lastEvent.status !== invocation.status) throw new Error('Invocation Detail status does not match its last event'); + return {invocation, events}; +} + +function validateTrace(value: unknown, workspaceId: string, traceId: string): TraceResponseV4 { + const record = requireRecord(value, 'Trace'); + assertAllowedKeys(record, ['traceId', 'invocations'], 'Trace'); + if (record.traceId !== traceId || !Array.isArray(record.invocations) || record.invocations.length === 0) throw new Error('Trace correlation or lineage is invalid'); + const invocations = record.invocations.map((item) => validateInvocationRecord(item, workspaceId)); + const identities = new Set(); + const rootTaskID = invocations[0].rootTaskId; + invocations.forEach((invocation) => { + if (invocation.traceId !== traceId || invocation.rootTaskId !== rootTaskID || identities.has(invocation.invocationId)) throw new Error('Trace Invocation correlation is invalid'); + if (invocation.parentInvocationId && (invocation.parentInvocationId === invocation.invocationId || !identities.has(invocation.parentInvocationId))) throw new Error('Trace parent ordering is invalid'); + identities.add(invocation.invocationId); + }); + return {traceId, invocations}; +} + +function validateInvocationRecord(value: unknown, workspaceId: string): InvocationRecordV4 { + const record = requireRecord(value, 'Invocation Record'); + assertAllowedKeys(record, ['invocationId', 'rootTaskId', 'parentInvocationId', 'traceId', 'caller', 'workspaceId', 'targetAgentId', 'agentCardVersion', 'agentReleaseId', 'agentCardDigest', 'capability', 'status', 'latencyMs', 'errorCode', 'createdAt', 'updatedAt'], 'Invocation Record'); + const invocationId = record.invocationId; + requireIdentifier(invocationId, 'invocationId'); + requireIdentifier(record.rootTaskId, 'rootTaskId'); + requireIdentifier(record.traceId, 'traceId'); + if (record.workspaceId !== workspaceId) throw new Error('Invocation Record Workspace does not match the active Workspace'); + requireIdentifier(record.targetAgentId, 'targetAgentId'); requireIdentifier(record.capability, 'capability'); + if (typeof record.agentCardVersion !== 'string' || !isSemver(record.agentCardVersion)) throw new Error('agentCardVersion must be strict SemVer'); + validateReleaseProvenance(record.agentReleaseId, record.agentCardDigest, 'Invocation Record'); + requireCaller(record.caller); + requireEnum(record.status, ['pending', 'routing', 'running', 'succeeded', 'failed', 'canceled', 'timed_out'], 'invocation status'); + requireDate(record.createdAt, 'createdAt'); requireDate(record.updatedAt, 'updatedAt'); + if ('parentInvocationId' in record && record.parentInvocationId !== undefined) requireIdentifier(record.parentInvocationId, 'parentInvocationId'); + if ('latencyMs' in record && record.latencyMs !== undefined && (typeof record.latencyMs !== 'number' || !Number.isInteger(record.latencyMs) || record.latencyMs < 0)) throw new Error('latencyMs is invalid'); + if ('errorCode' in record && record.errorCode !== undefined) requirePlatformCode(record.errorCode); + return {...record, invocationId} as unknown as InvocationRecordV4; +} + +function validateInvocationEvent(value: unknown, workspaceId: string): InvocationEventV03 { + const record = requireRecord(value, 'Invocation Event'); + assertAllowedKeys(record, ['schemaVersion', 'eventId', 'sequence', 'occurredAt', 'type', 'status', 'invocationId', 'rootTaskId', 'parentInvocationId', 'traceId', 'caller', 'workspaceId', 'targetAgentId', 'agentCardVersion', 'agentReleaseId', 'agentCardDigest', 'capability', 'chunkIndex', 'chunkBytes', 'latencyMs', 'error'], 'Invocation Event'); + if (record.schemaVersion !== '0.3' || typeof record.sequence !== 'number' || !Number.isInteger(record.sequence) || record.sequence < 0) throw new Error('Invocation Event schema or sequence is invalid'); + requireIdentifier(record.eventId, 'eventId'); requireDate(record.occurredAt, 'occurredAt'); requireIdentifier(record.invocationId, 'invocationId'); requireIdentifier(record.rootTaskId, 'rootTaskId'); requireIdentifier(record.traceId, 'traceId'); + if (record.workspaceId !== workspaceId) throw new Error('Invocation Event Workspace does not match the active Workspace'); + requireCaller(record.caller); requireIdentifier(record.targetAgentId, 'targetAgentId'); + if (typeof record.agentCardVersion !== 'string' || !isSemver(record.agentCardVersion)) throw new Error('agentCardVersion must be strict SemVer'); + validateReleaseProvenance(record.agentReleaseId, record.agentCardDigest, 'Invocation Event'); + requireIdentifier(record.capability, 'capability'); + const type = requireEnum(record.type, ['created', 'routing', 'started', 'stream', 'succeeded', 'failed', 'canceled', 'timed_out'], 'event type') as InvocationEventType; + const status = requireEnum(record.status, ['pending', 'routing', 'running', 'succeeded', 'failed', 'canceled', 'timed_out'], 'event status') as InvocationEventStatus; + if (type === 'created' && (status !== 'pending' || hasAny(record, ['chunkIndex', 'chunkBytes', 'latencyMs', 'error']))) throw new Error('created Invocation Event is invalid'); + if (type === 'routing' && (status !== 'routing' || hasAny(record, ['chunkIndex', 'chunkBytes', 'latencyMs', 'error']))) throw new Error('routing Invocation Event is invalid'); + if (type === 'started' && (status !== 'running' || hasAny(record, ['chunkIndex', 'chunkBytes', 'latencyMs', 'error']))) throw new Error('started Invocation Event is invalid'); + if (type === 'stream' && (status !== 'running' || !isNonNegativeInteger(record.chunkIndex) || !isNonNegativeInteger(record.chunkBytes) || hasAny(record, ['latencyMs', 'error']))) throw new Error('stream Invocation Event is invalid'); + if (type === 'succeeded' && (status !== 'succeeded' || !isNonNegativeInteger(record.latencyMs) || hasAny(record, ['chunkIndex', 'chunkBytes', 'error']))) throw new Error('succeeded Invocation Event is invalid'); + if (type === 'failed' || type === 'canceled' || type === 'timed_out') { + if (status !== type || !isNonNegativeInteger(record.latencyMs) || !isCorrelatedPlatformError(record.error) || hasAny(record, ['chunkIndex', 'chunkBytes'])) throw new Error('terminal Invocation Event is invalid'); + if (type === 'failed' && (record.error.code === 'CANCELED' || record.error.code === 'TIMEOUT')) throw new Error('failed Invocation Event error code is invalid'); + if (type === 'canceled' && record.error.code !== 'CANCELED') throw new Error('canceled Invocation Event error code is invalid'); + if (type === 'timed_out' && record.error.code !== 'TIMEOUT') throw new Error('timed_out Invocation Event error code is invalid'); + } + if (('chunkIndex' in record && record.chunkIndex !== undefined && !isNonNegativeInteger(record.chunkIndex)) || ('chunkBytes' in record && record.chunkBytes !== undefined && !isNonNegativeInteger(record.chunkBytes))) throw new Error('Invocation Event chunk metadata is invalid'); + if (isCorrelatedPlatformError(record.error) && (record.error.invocationId !== record.invocationId || record.error.rootTaskId !== record.rootTaskId || record.error.traceId !== record.traceId)) throw new Error('Invocation Event error correlation changed'); + return record as unknown as InvocationEventV03; +} + +function isCorrelatedPlatformError(value: unknown): value is CorrelatedPlatformErrorV4 { + if (!isRecord(value) || !('invocationId' in value) || !('rootTaskId' in value)) return false; + if (Object.keys(value).some((key) => !['code', 'message', 'traceId', 'invocationId', 'rootTaskId'].includes(key))) return false; + requirePlatformCode(value.code); + if (value.message !== PLATFORM_ERROR_MESSAGES[value.code as PlatformErrorCode]) return false; + requireIdentifier(value.traceId, 'traceId'); requireIdentifier(value.invocationId, 'invocationId'); requireIdentifier(value.rootTaskId, 'rootTaskId'); + return true; +} + +function requirePlatformCode(value: unknown): asserts value is PlatformErrorCode { + requireEnum(value, Object.keys(PLATFORM_ERROR_MESSAGES), 'Platform Error code'); +} + +function requireCaller(value: unknown): void { + const caller = requireRecord(value, 'caller'); + requireEnum(caller.type, ['user', 'agent', 'service'], 'caller.type'); requireIdentifier(caller.id, 'caller.id'); +} + +function requireSemver(value: unknown, field: string): string { + if (typeof value !== 'string' || !isSemver(value)) throw new Error(field + ' must be strict SemVer'); + return value; +} + +function requireUri(value: unknown, field: string): string { + if (typeof value !== 'string' || value.length === 0) throw new Error(field + ' must be a URI'); + if (value !== value.trim() || /\s/.test(value)) throw new Error(field + ' must not contain whitespace'); + try { + const parsed = new URL(value); + if (parsed.username || parsed.password) throw new Error('URI userinfo is not allowed'); + } catch { + throw new Error(field + ' must be a valid URI without userinfo'); + } + return value; +} + +function requireHttpUri(value: unknown, field: string): string { + const uri = requireUri(value, field); + const parsed = new URL(uri); + if (parsed.protocol !== 'http:' && parsed.protocol !== 'https:') throw new Error(field + ' must be an HTTP(S) URI'); + return uri; +} + +function requireDateValue(value: unknown, field: string): string { + requireDate(value, field); + return value as string; +} + +function requireDigest(value: unknown, field: string): string { + if (typeof value !== 'string' || !/^[0-9a-f]{64}$/.test(value)) throw new Error(field + ' must be a lowercase 64-hex digest'); + return value; +} + +function requireBoundedText(value: unknown, field: string, minimum: number, maximum?: number): string { + if (typeof value !== 'string' || value.length < minimum || (maximum !== undefined && value.length > maximum)) { + throw new Error(field + ' has an invalid length'); + } + return value; +} + +function requireOptionalText(value: unknown, field: string, maximum: number): string { + return requireBoundedText(value, field, 0, maximum); +} + +function requireDate(value: unknown, field: string): void { + if (typeof value !== 'string' || !isStrictDateTime(value)) throw new Error(field + ' is invalid'); +} + +function isStrictDateTime(value: string): boolean { + const match = /^(\d{4})-(\d{2})-(\d{2})T(\d{2}):(\d{2}):(\d{2})(?:\.\d+)?(Z|[+-]\d{2}:\d{2})$/.exec(value); + if (!match) return false; + const year = Number(match[1]); + const month = Number(match[2]); + const day = Number(match[3]); + const hour = Number(match[4]); + const minute = Number(match[5]); + const second = Number(match[6]); + if (month < 1 || month > 12 || day < 1 || day > daysInMonth(year, month) || hour > 23 || minute > 59 || second > 59) return false; + if (match[7] !== 'Z') { + const offsetHours = Number(match[7].slice(1, 3)); + const offsetMinutes = Number(match[7].slice(4, 6)); + if (offsetHours > 23 || offsetMinutes > 59) return false; + } + return true; +} + +function daysInMonth(year: number, month: number): number { + if (month === 2) return year % 4 === 0 && (year % 100 !== 0 || year % 400 === 0) ? 29 : 28; + return [4, 6, 9, 11].includes(month) ? 30 : 31; +} + +function requireIdentifier(value: unknown, field: string): asserts value is string { + if (typeof value !== 'string' || !/^[A-Za-z0-9](?:[A-Za-z0-9._:-]{0,127})$/.test(value)) throw new Error(field + ' must be a NeKiro safe identifier'); +} + +function requireEnum(value: unknown, values: readonly string[], field: string): string { + if (typeof value !== 'string' || !values.includes(value)) throw new Error(field + ' is invalid'); + return value; +} + +function isNonNegativeInteger(value: unknown): value is number { + return typeof value === 'number' && Number.isInteger(value) && value >= 0; +} + +function hasAny(record: Record, fields: string[]): boolean { + return fields.some((field) => field in record); +} + +function assertAllowedKeys(record: Record, allowed: string[], field: string): void { + const known = new Set(allowed); + if (Object.keys(record).some((key) => !known.has(key))) throw new Error(field + ' contains an unknown field'); +} + +function validInvocationTransition(from: InvocationEventStatus, type: InvocationEventType, to: InvocationEventStatus): boolean { + if (from === 'pending') return (type === 'routing' && to === 'routing') || ((type === 'canceled' || type === 'timed_out') && type === to); + if (from === 'routing') return (type === 'started' && to === 'running') || ((type === 'failed' || type === 'canceled' || type === 'timed_out') && type === to); + if (from === 'running') return (type === 'stream' && to === 'running') || ((type === 'succeeded' || type === 'failed' || type === 'canceled' || type === 'timed_out') && type === to); + return false; +} + +function requireRecord(value: unknown, field: string): Record { + if (!isRecord(value)) throw new Error(field + ' must be a JSON object'); + return value; +} + +function requireInvocationInput(value: unknown): asserts value is JsonObject { + if (!isRecord(value)) throw new Error('invocation input must be a JSON object'); +} + +function parseJsonValue(value: string, field: string): unknown { + try { return JSON.parse(value); } catch { throw new NekiroApiError(0, 'NeKiro ' + field + ' is not valid JSON.', 'INVALID_RESPONSE'); } +} + +export function buildAgentCard(input: AgentCardInput): AgentCardV02 { + if (!['none', 'api_key', 'http_bearer', 'oauth2_client_credentials', 'mutual_tls'].includes(input.authentication)) throw new Error('authentication type is invalid'); + const agentId = readIdentifier(input.agentId, 'agentId'); + const permissions = input.permissions.map((permission, index) => { + assertPermissionKeys(permission, index); + return { + id: readIdentifier(permission.id, 'permissions[' + index + '].id'), + description: readText(permission.description, 'permissions[' + index + '].description', 1000), + }; + }); + ensureUnique(permissions.map((permission) => permission.id), 'permission id'); + + const declaredPermissions = new Set(permissions.map((permission) => permission.id)); + const parsed = parseCapabilities(input.capabilitiesJson); + const seenSkillIds = new Set(); + const skills = parsed.map((capability, index) => { + assertAllowedKeys(capability, ['id', 'name', 'description', 'inputSchema', 'outputSchema', 'requiredPermissions'], 'capabilities[' + index + ']'); + const id = readIdentifier(capability.id, 'capabilities[' + index + '].id'); + if (seenSkillIds.has(id)) { + throw new Error('duplicate capability id: ' + id); + } + seenSkillIds.add(id); + const requiredPermissions = readStringArray(capability.requiredPermissions, 'capabilities[' + index + '].requiredPermissions'); + for (const permissionId of requiredPermissions) { + if (!declaredPermissions.has(permissionId)) { + throw new Error('required permission is not declared in permissions: ' + permissionId); + } + } + + return { + id, + name: readText(capability.name, 'capabilities[' + index + '].name', 120), + description: readText(capability.description, 'capabilities[' + index + '].description', 2000), + inputSchema: readJsonObject(capability.inputSchema, 'capabilities[' + index + '].inputSchema'), + outputSchema: readJsonObject(capability.outputSchema, 'capabilities[' + index + '].outputSchema'), + requiredPermissions, + } satisfies AgentSkill; + }); + + const endpoint = readText(input.endpoint, 'endpoint', 2048); + let endpointUrl: URL; + try { + endpointUrl = new URL(endpoint); + } catch { + throw new Error('endpoint must be an absolute http or https URL'); + } + if (!['http:', 'https:'].includes(endpointUrl.protocol)) { + throw new Error('endpoint must use http or https'); + } + if (endpointUrl.username || endpointUrl.password) { + throw new Error('endpoint must not contain userinfo credentials'); + } + + const version = readText(input.version, 'version'); + if (!isSemver(version)) { + throw new Error('version must be strict SemVer'); + } + validateAgentLimits(input.limits); + + return { + schemaVersion: '0.2', + agentId, + name: readText(input.name, 'name', 120), + description: readText(input.description, 'description', 4000), + owner: { + id: readIdentifier(input.ownerId, 'ownerId'), + displayName: readText(input.ownerDisplayName, 'ownerDisplayName', 120), + }, + version, + protocol: { + type: 'a2a', + version: '0.3.0', + transport: 'JSONRPC', + endpoint, + }, + skills, + authentication: {type: input.authentication}, + permissions, + limits: input.limits, + }; +} + +function validateAgentLimits(value: AgentCardV02['limits']): void { + if (!isRecord(value)) throw new Error('limits must be a JSON object'); + assertAllowedKeys(value, ['timeoutMs', 'maxInputBytes', 'maxOutputBytes', 'streaming'], 'limits'); + if (!Number.isInteger(value.timeoutMs) || value.timeoutMs < 1 || value.timeoutMs > 600000) throw new Error('limits.timeoutMs must be between 1 and 600000'); + if (!Number.isInteger(value.maxInputBytes) || value.maxInputBytes < 1 || value.maxInputBytes > 2147483647) throw new Error('limits.maxInputBytes must be between 1 and 2147483647'); + if (!Number.isInteger(value.maxOutputBytes) || value.maxOutputBytes < 1 || value.maxOutputBytes > 2147483647) throw new Error('limits.maxOutputBytes must be between 1 and 2147483647'); + if (typeof value.streaming !== 'boolean') throw new Error('limits.streaming must be a boolean'); +} + +function assertPermissionKeys(value: unknown, index: number): void { + if (!isRecord(value)) throw new Error('permissions[' + index + '] must be a JSON object'); + assertAllowedKeys(value, ['id', 'description'], 'permissions[' + index + ']'); +} + +export function mapCatalogEntry(entry: CatalogEntry): Agent { + return { + id: entry.card.agentId, + name: entry.card.name, + version: entry.card.version, + owner: entry.card.owner.displayName, + ownerId: entry.card.owner.id, + description: entry.card.description, + tags: entry.card.skills.map((skill) => skill.id), + status: entry.publicationStatus, + schema: JSON.stringify(entry.card, null, 2), + permissions: entry.card.permissions, + registeredAt: entry.registeredAt, + publishedAt: entry.publishedAt, + }; +} + +function validateReleaseProvenance(releaseId: unknown, cardDigest: unknown, field: string): void { + if ((releaseId === undefined) !== (cardDigest === undefined)) throw new Error(`${field} Release provenance must contain both fields or neither`); + if (releaseId !== undefined) { + requireIdentifier(releaseId, field + ' agentReleaseId'); + requireDigest(cardDigest, field + ' agentCardDigest'); + } +} + +function validateInstallation(value: unknown, workspaceId: string): Installation { + const record = requireRecord(value, 'Installation'); + assertAllowedKeys(record, ['installationId', 'workspaceId', 'agentId', 'versionConstraint', 'installedVersion', 'installedReleaseId', 'acceptedPermissions', 'status', 'installedAt', 'updatedAt', 'uninstalledAt'], 'Installation'); + if (record.workspaceId !== workspaceId) throw new Error('Installation Workspace does not match the request'); + const installationID = readIdentifier(record.installationId, 'installationId'); + const agentID = readIdentifier(record.agentId, 'agentId'); + const versionConstraint = readText(record.versionConstraint, 'versionConstraint'); + const installedVersion = requireSemver(record.installedVersion, 'installedVersion'); + if (!satisfiesSemverRange(installedVersion, versionConstraint)) throw new Error('installedVersion does not satisfy versionConstraint'); + const acceptedPermissions = readStringArray(record.acceptedPermissions, 'acceptedPermissions'); + if ([...acceptedPermissions].sort().join('\u0000') !== acceptedPermissions.join('\u0000')) throw new Error('acceptedPermissions must be sorted'); + const status = requireEnum(record.status, ['enabled', 'disabled', 'uninstalled'], 'Installation status') as InstallationStatus; + const installedAt = requireDateValue(record.installedAt, 'installedAt'); + const updatedAt = requireDateValue(record.updatedAt, 'updatedAt'); + if (Date.parse(installedAt) > Date.parse(updatedAt)) throw new Error('Installation updatedAt must not precede installedAt'); + const result: Installation = { + installationId: installationID, + workspaceId, + agentId: agentID, + versionConstraint, + installedVersion, + acceptedPermissions, + status, + installedAt, + updatedAt, + }; + if ('installedReleaseId' in record) result.installedReleaseId = readIdentifier(record.installedReleaseId, 'installedReleaseId'); + if (status === 'uninstalled') { + if (!('uninstalledAt' in record)) throw new Error('uninstalled Installation requires uninstalledAt'); + const uninstalledAt = requireDateValue(record.uninstalledAt, 'uninstalledAt'); + if (Date.parse(uninstalledAt) !== Date.parse(updatedAt)) throw new Error('uninstalledAt must equal updatedAt'); + result.uninstalledAt = uninstalledAt; + } else if ('uninstalledAt' in record) { + throw new Error('active Installation must not contain uninstalledAt'); + } + return result; +} + +function validateInstallationList(value: unknown, workspaceId: string): InstallationList { + const record = requireRecord(value, 'Installation list'); + assertAllowedKeys(record, ['items', 'nextCursor'], 'Installation list'); + if (!Array.isArray(record.items)) throw new Error('Installation list items must be an array'); + const result: InstallationList = { + items: record.items.map((item) => validateInstallation(item, workspaceId)), + }; + if ('nextCursor' in record) result.nextCursor = readText(record.nextCursor, 'nextCursor'); + return result; +} + +export function toPlatformErrorView(error: unknown, _fallbackMessage: string): PlatformErrorView { + if (error instanceof NekiroApiError) { + return error.toView(); + } + return { + status: 0, + code: 'CLIENT_ERROR', + message: error instanceof Error ? error.message : String(error), + }; +} + +export function validateTrustedInstallation(value: Installation, release: AgentRelease, agentId: string): Installation { + if (value.agentId !== agentId || value.installedVersion !== release.agentCardVersion || value.installedReleaseId !== release.releaseId || value.status !== 'enabled') { + throw new NekiroApiError(200, 'NeKiro Installation did not preserve the preflight Release identity.', 'INVALID_RESPONSE'); + } + return value; +} + +function parseCapabilities(value: string): Record[] { + let parsed: unknown; + try { + parsed = JSON.parse(value); + } catch { + throw new Error('capabilitiesJson must be valid JSON'); + } + if (!isRecord(parsed) || !Array.isArray(parsed.capabilities) || parsed.capabilities.length === 0) { + throw new Error('capabilitiesJson must contain a non-empty capabilities array'); + } + if (!parsed.capabilities.every(isRecord)) { + throw new Error('every capability must be a JSON object'); + } + return parsed.capabilities; +} + +function readText(value: unknown, field: string, maxLength?: number): string { + if (typeof value !== 'string' || value.trim() === '' || value !== value.trim() || (maxLength !== undefined && value.length > maxLength)) { + throw new Error(field + ' must be a non-empty string'); + } + return value; +} + +function readIdentifier(value: unknown, field: string): string { + const text = readText(value, field); + if (!/^[A-Za-z0-9](?:[A-Za-z0-9._:-]{0,127})$/.test(text)) { + throw new Error(field + ' must be a NeKiro safe identifier'); + } + return text; +} + +function isIpHostname(hostname: string): boolean { + return /^\d{1,3}(?:\.\d{1,3}){3}$/.test(hostname) || hostname.includes(':'); +} + +interface SemverParts { + major: number; + minor: number; + patch: number; + prerelease: string[]; +} + +function satisfiesSemverRange(version: string, range: string): boolean { + const parsedVersion = parseSemver(version); + if (!parsedVersion) return false; + return range.split('||').some((branch) => satisfiesSemverBranch(parsedVersion, branch)); +} + +function satisfiesSemverBranch(version: SemverParts, branch: string): boolean { + const tokens = branch.trim().split(/[\s,]+/).filter(Boolean); + if (tokens.length === 0) return false; + return tokens.every((token) => satisfiesSemverToken(version, token)); +} + +function satisfiesSemverToken(version: SemverParts, token: string): boolean { + if (token === '*' || token.toLowerCase() === 'x') return true; + const wildcard = /^(\d+|[xX*])(?:\.(\d+|[xX*]))?(?:\.(\d+|[xX*]))?$/.exec(token); + if (wildcard) { + if (wildcard[1] === 'x' || wildcard[1] === 'X' || wildcard[1] === '*') return true; + if (version.major !== Number(wildcard[1])) return false; + if (wildcard[2] === undefined || ['x', 'X', '*'].includes(wildcard[2])) return true; + if (version.minor !== Number(wildcard[2])) return false; + return wildcard[3] === undefined || ['x', 'X', '*'].includes(wildcard[3]) || version.patch === Number(wildcard[3]); + } + const operatorMatch = /^(\^|~|>=|<=|>|<|=)?(.+)$/.exec(token); + if (!operatorMatch) return false; + const operator = operatorMatch[1] ?? '='; + const base = parseSemver(operatorMatch[2]); + if (!base) return false; + const comparison = compareSemver(version, base); + if (operator === '=') return comparison === 0; + if (operator === '>') return comparison > 0; + if (operator === '>=') return comparison >= 0; + if (operator === '<') return comparison < 0; + if (operator === '<=') return comparison <= 0; + if (operator === '^') { + const upper = base.major > 0 + ? {major: base.major + 1, minor: 0, patch: 0, prerelease: []} + : base.minor > 0 + ? {major: 0, minor: base.minor + 1, patch: 0, prerelease: []} + : {major: 0, minor: 0, patch: base.patch + 1, prerelease: []}; + return compareSemver(version, base) >= 0 && compareSemver(version, upper) < 0; + } + const upper = {major: base.major, minor: base.minor + 1, patch: 0, prerelease: []}; + return compareSemver(version, base) >= 0 && compareSemver(version, upper) < 0; +} + +function parseSemver(value: string): SemverParts | undefined { + if (!isSemver(value)) return undefined; + const match = /^(\d+)\.(\d+)\.(\d+)(?:-([0-9A-Za-z-]+(?:\.[0-9A-Za-z-]+)*))?(?:\+[0-9A-Za-z-]+(?:\.[0-9A-Za-z-]+)*)?$/.exec(value); + if (!match) return undefined; + return {major: Number(match[1]), minor: Number(match[2]), patch: Number(match[3]), prerelease: match[4]?.split('.') ?? []}; +} + +function compareSemver(left: SemverParts, right: SemverParts): number { + for (const field of ['major', 'minor', 'patch'] as const) { + if (left[field] !== right[field]) return left[field] > right[field] ? 1 : -1; + } + if (left.prerelease.length === 0 && right.prerelease.length === 0) return 0; + if (left.prerelease.length === 0) return 1; + if (right.prerelease.length === 0) return -1; + for (let index = 0; index < Math.max(left.prerelease.length, right.prerelease.length); index += 1) { + const leftPart = left.prerelease[index]; + const rightPart = right.prerelease[index]; + if (leftPart === undefined) return -1; + if (rightPart === undefined) return 1; + if (leftPart === rightPart) continue; + const leftNumber = /^\d+$/.test(leftPart); + const rightNumber = /^\d+$/.test(rightPart); + if (leftNumber && rightNumber) return Number(leftPart) > Number(rightPart) ? 1 : -1; + if (leftNumber !== rightNumber) return leftNumber ? -1 : 1; + return leftPart > rightPart ? 1 : -1; + } + return 0; +} + +function readStringArray(value: unknown, field: string): string[] { + if (!Array.isArray(value) || !value.every((item) => typeof item === 'string' && item.trim() !== '' && item === item.trim())) { + throw new Error(field + ' must be an array of non-empty strings'); + } + const result = value as string[]; + ensureUnique(result, field + ' value'); + return result; +} + +function readJsonObject(value: unknown, field: string): JsonObject { + if (!isRecord(value)) { + throw new Error(field + ' must be a JSON object'); + } + return value; +} + +function ensureUnique(values: string[], label: string): void { + const seen = new Set(); + for (const value of values) { + if (seen.has(value)) { + throw new Error('duplicate ' + label + ': ' + value); + } + seen.add(value); + } +} + +function isSemver(value: string): boolean { + return /^(0|[1-9][0-9]*)\.(0|[1-9][0-9]*)\.(0|[1-9][0-9]*)(?:-(?:0|[1-9][0-9]*|[0-9]*[A-Za-z-][0-9A-Za-z-]*)(?:\.(?:0|[1-9][0-9]*|[0-9]*[A-Za-z-][0-9A-Za-z-]*))*)?(?:\+[0-9A-Za-z-]+(?:\.[0-9A-Za-z-]+)*)?$/.test(value); +} + +function isRecord(value: unknown): value is Record { + return typeof value === 'object' && value !== null && !Array.isArray(value); +} + +function parseJson(value: string): T | undefined { + if (!value) { + return undefined; + } + try { + return JSON.parse(value) as T; + } catch { + return undefined; + } +} + +function isPlatformErrorV4(value: unknown): value is PlatformErrorV4 { + if (!isRecord(value) || typeof value.code !== 'string' || !(value.code in PLATFORM_ERROR_MESSAGES) || value.message !== PLATFORM_ERROR_MESSAGES[value.code as PlatformErrorCode] || typeof value.traceId !== 'string' || !/^[A-Za-z0-9](?:[A-Za-z0-9._:-]{0,127})$/.test(value.traceId)) return false; + const correlated = 'invocationId' in value || 'rootTaskId' in value; + const allowed = correlated ? ['code', 'message', 'traceId', 'invocationId', 'rootTaskId'] : ['code', 'message', 'traceId']; + if (Object.keys(value).some((key) => !allowed.includes(key))) return false; + if (!correlated) return true; + return typeof value.invocationId === 'string' && typeof value.rootTaskId === 'string' && /^[A-Za-z0-9](?:[A-Za-z0-9._:-]{0,127})$/.test(value.invocationId) && /^[A-Za-z0-9](?:[A-Za-z0-9._:-]{0,127})$/.test(value.rootTaskId); +} diff --git a/apps/console/src/components/Header.tsx b/apps/console/src/components/Header.tsx new file mode 100644 index 0000000..96dcabd --- /dev/null +++ b/apps/console/src/components/Header.tsx @@ -0,0 +1,103 @@ +import React from 'react'; +import {AlertTriangle, CheckCircle2, Database, Plus, Search, User} from 'lucide-react'; + +import type {PlatformErrorView, Workspace} from '../types'; + +interface HeaderProps { + searchQuery: string; + setSearchQuery: (query: string) => void; + searchPlaceholder?: string; + workspace: Workspace | null; + workspaceDraft: string; + setWorkspaceDraft: (workspaceId: string) => void; + workspaceLoading: boolean; + workspaceError: PlatformErrorView | null; + onReadWorkspace: () => void; + onCreateWorkspace: () => void; + userLabel: string; + apiConfigured: boolean; +} + +export default function Header({ + searchQuery, + setSearchQuery, + searchPlaceholder = 'Search resource, agent, traces...', + workspace, + workspaceDraft, + setWorkspaceDraft, + workspaceLoading, + workspaceError, + onReadWorkspace, + onCreateWorkspace, + userLabel, + apiConfigured, +}: HeaderProps) { + return ( +
+
+
+ + setSearchQuery(event.target.value)} + placeholder={searchPlaceholder} + className="bg-transparent border-none outline-none text-brand-on-surface font-mono-code text-[11px] w-48 xl:w-64 2xl:w-80 p-0 m-0 placeholder-brand-on-surface-variant/40 focus:ring-0" + /> +
+ +
+ + setWorkspaceDraft(event.target.value)} + placeholder="workspace id" + className="bg-transparent outline-none text-[10.5px] font-mono-code text-brand-on-surface w-24 xl:w-32 2xl:w-40 placeholder-brand-on-surface-variant/40" + /> + + +
+
+ +
+ {workspaceError && ( +
+ + {workspaceError.code ?? 'WORKSPACE_ERROR'} + {workspaceError.traceId && trace {workspaceError.traceId}} +
+ )} + +
+ + + API: {apiConfigured ? 'configured' : 'missing'} + + + Workspace: {workspace?.workspaceId ?? 'not selected'} + + + + {userLabel} + + {workspace && } +
+
+
+ ); +} diff --git a/apps/console/src/components/InstallationsTab.tsx b/apps/console/src/components/InstallationsTab.tsx new file mode 100644 index 0000000..17e158c --- /dev/null +++ b/apps/console/src/components/InstallationsTab.tsx @@ -0,0 +1,298 @@ +import React, {useEffect, useMemo, useRef, useState} from 'react'; +import {AlertTriangle, Database, Loader2, RefreshCw, ShieldCheck, Trash2} from 'lucide-react'; + +import {NekiroApiError, toPlatformErrorView, type AgentRelease, type NekiroApiClient} from '../api/nekiro'; +import {agentKey, isCurrentRequest, matchesPublishedRelease, nextRequestGeneration} from '../consolePolicy'; +import type {Agent, Installation, InstallationStatus, PlatformErrorView, Workspace} from '../types'; + +interface InstallationsTabProps { + workspace: Workspace | null; + agents: Agent[]; + installations: Installation[]; + loading: boolean; + error: PlatformErrorView | null; + searchQuery: string; + client: NekiroApiClient; + onInstallAgent: (agent: Agent, release: AgentRelease, acceptedPermissions: string[]) => Promise; + onUpdateInstallation: (installation: Installation, status: Exclude) => Promise; + onUninstall: (installation: Installation) => Promise; + onRefresh: () => void; +} + +export default function InstallationsTab({ + workspace, + agents, + installations, + loading, + error, + searchQuery, + client, + onInstallAgent, + onUpdateInstallation, + onUninstall, + onRefresh, +}: InstallationsTabProps) { + const publishedAgents = useMemo(() => agents.filter((agent) => agent.status === 'published'), [agents]); + const [selectedAgentKey, setSelectedAgentKey] = useState(''); + const [versionConstraint, setVersionConstraint] = useState(''); + const [acceptedPermissions, setAcceptedPermissions] = useState([]); + const [releaseId, setReleaseId] = useState(''); + const [preflightRelease, setPreflightRelease] = useState(null); + const [preflightLoading, setPreflightLoading] = useState(false); + const [submitting, setSubmitting] = useState(false); + const [localError, setLocalError] = useState(null); + const [confirmUninstallId, setConfirmUninstallId] = useState(null); + const [busyLifecycle, setBusyLifecycle] = useState(false); + const preflightGeneration = useRef(0); + + const invalidatePreflight = () => { + preflightGeneration.current = nextRequestGeneration(preflightGeneration.current); + setPreflightRelease(null); + }; + + useEffect(() => { + if (!selectedAgentKey && publishedAgents[0]) { + const next = publishedAgents[0]; + invalidatePreflight(); + setSelectedAgentKey(agentKey(next)); + setVersionConstraint(next.version); + setAcceptedPermissions(next.permissions.map((permission) => permission.id).sort()); + } + }, [publishedAgents, selectedAgentKey]); + + const selectedAgent = publishedAgents.find((agent) => agentKey(agent) === selectedAgentKey); + const filteredInstallations = installations.filter((installation) => { + const query = searchQuery.trim().toLowerCase(); + if (!query) return true; + return [ + installation.installationId, + installation.workspaceId, + installation.agentId, + installation.versionConstraint, + installation.installedVersion, + installation.status, + installation.acceptedPermissions.join(' '), + ].join(' ').toLowerCase().includes(query); + }); + + const handleSelectAgent = (selectedKey: string) => { + const agent = publishedAgents.find((item) => agentKey(item) === selectedKey); + invalidatePreflight(); + setSelectedAgentKey(selectedKey); + setVersionConstraint(agent?.version ?? ''); + setAcceptedPermissions(agent?.permissions.map((permission) => permission.id).sort() ?? []); + setReleaseId(''); + setPreflightRelease(null); + setLocalError(null); + }; + + const handlePreflight = async () => { + if (!selectedAgent) return; + const generation = nextRequestGeneration(preflightGeneration.current); + preflightGeneration.current = generation; + const requestedAgentKey = selectedAgentKey; + const requestedReleaseId = releaseId; + setPreflightLoading(true); + setLocalError(null); + try { + const value = await client.getAgentRelease(requestedReleaseId); + if (!isCurrentRequest(generation, preflightGeneration.current)) return; + if (!matchesPublishedRelease(value, selectedAgent)) throw new NekiroApiError(200, 'The selected Release is not a published match for the selected Agent Card.', 'INVALID_RESPONSE'); + if (requestedAgentKey !== selectedAgentKey || requestedReleaseId !== releaseId) return; + setPreflightRelease(value); + setVersionConstraint(value.agentCardVersion); + } catch (value) { + if (!isCurrentRequest(generation, preflightGeneration.current)) return; + setPreflightRelease(null); + setLocalError(toPlatformErrorView(value, 'Unable to preflight the trusted Release.')); + } finally { + if (isCurrentRequest(generation, preflightGeneration.current)) setPreflightLoading(false); + } + }; + + const handleInstall = async (event: React.FormEvent) => { + event.preventDefault(); + if (!selectedAgent || !preflightRelease) return; + setSubmitting(true); + setLocalError(null); + try { + await onInstallAgent(selectedAgent, preflightRelease, acceptedPermissions); + } catch (installError) { + setLocalError(toPlatformErrorView(installError, 'Unable to install Agent.')); + } finally { + setSubmitting(false); + } + }; + + const togglePermission = (permissionId: string) => { + setAcceptedPermissions((current) => current.includes(permissionId) + ? current.filter((item) => item !== permissionId) + : [...current, permissionId].sort()); + }; + + return ( +
+
+
+
Installations
+

Workspace Agent Pins

+

+ Preflight an immutable published Release before installing its exact Card version into the current Workspace. +

+
+ +
+ + {!workspace && ( +
+ Select or create a Workspace in the header before installing Agents. The Console will not create mock Workspace state. +
+ )} + + + +
+
+
+ +
+
Install trusted Release
+
The Release ID is an explicit provider handoff; Catalog publication alone is not trust.
+
+
+ +
+ + +
+ +
+ +
{ invalidatePreflight(); setReleaseId(event.target.value); setLocalError(null); }} disabled={!workspace || preflightLoading || busyLifecycle} placeholder="release-id" className="flex-1 bg-brand-lowest border border-brand-outline-variant rounded px-3 py-2 text-brand-on-surface outline-none disabled:opacity-50" />
+
+ + {preflightRelease &&
Published Release preflight passed
} + + + +
Declared permissions
+
+ {!selectedAgent &&
Select a published Agent to review permissions.
} + {selectedAgent && selectedAgent.permissions.length === 0 && ( +
This Agent declares no permissions. Submitting installs with acceptedPermissions: [].
+ )} + {selectedAgent?.permissions.map((permission) => ( + + ))} +
+ + +
+ +
+
+ Current and historical Installations + {loading && } +
+
+ {filteredInstallations.length === 0 ? ( +
No Installation facts returned for this Workspace.
+ ) : filteredInstallations.map((installation) => ( +
+
+
+
{installation.agentId}
+
{installation.installationId}
+
+ +
+
+ + + +
+
+
+ {installation.acceptedPermissions.length === 0 + ? acceptedPermissions: [] + : installation.acceptedPermissions.map((permission) => {permission})} +
+
+ {installation.status === 'enabled' && } + {installation.status === 'disabled' && } + {installation.status === 'disabled' && confirmUninstallId !== installation.installationId && } + {installation.status === 'disabled' && confirmUninstallId === installation.installationId && } + {installation.status === 'uninstalled' && Uninstalled at {installation.uninstalledAt}} +
+
+ ))} +
+
+
+
+ ); + + async function runInstallationAction(installation: Installation, status: Exclude) { + setBusyLifecycle(true); + try { + await onUpdateInstallation(installation, status); + } finally { + setBusyLifecycle(false); + } + } + + async function runUninstall(installation: Installation) { + setBusyLifecycle(true); + try { + if (await onUninstall(installation)) setConfirmUninstallId(null); + } finally { + setBusyLifecycle(false); + } + } +} + +function ErrorBanner({error}: {error: PlatformErrorView | null}) { + if (!error) return null; + return ( +
+ +
+
{error.code ?? 'ERROR'} · HTTP {error.status}
+
{error.message}
+ {error.traceId &&
traceId: {error.traceId}
} +
+
+ ); +} + +function StatusBadge({status}: {status: InstallationStatus}) { + const cls = status === 'enabled' ? 'text-green-300 border-green-400/30 bg-green-500/10' : status === 'disabled' ? 'text-brand-primary border-brand-primary/30 bg-brand-primary/10' : 'text-brand-error border-brand-error/30 bg-brand-error-container/10'; + return {status}; +} + +function Fact({label, value}: {label: string; value: string}) { + return ( +
+
{label}
+
{value}
+
+ ); +} diff --git a/apps/console/src/components/InvocationsTab.tsx b/apps/console/src/components/InvocationsTab.tsx new file mode 100644 index 0000000..416909b --- /dev/null +++ b/apps/console/src/components/InvocationsTab.tsx @@ -0,0 +1,167 @@ +import {useEffect, useMemo, useRef, useState} from 'react'; +import {Activity, CheckCircle2, LoaderCircle, Play, Radio, ShieldAlert} from 'lucide-react'; + +import {NekiroApiClient, toPlatformErrorView, type InvocationResultStreamEventV2} from '../api/nekiro'; +import {isCurrentRequest, isTrustedEnabledInstallation, nextRequestGeneration} from '../consolePolicy'; +import type {Installation, PlatformErrorView, Workspace} from '../types'; + +interface InvocationsTabProps { + workspace: Workspace | null; + installations: Installation[]; + client: NekiroApiClient; +} + +export default function InvocationsTab({workspace, installations, client}: InvocationsTabProps) { + const enabled = useMemo(() => installations.filter(isTrustedEnabledInstallation), [installations]); + const [installationId, setInstallationId] = useState(''); + const [capability, setCapability] = useState(''); + const [input, setInput] = useState('{\n "message": "hello"\n}'); + const [stream, setStream] = useState(false); + const [events, setEvents] = useState([]); + const [result, setResult] = useState(null); + const [error, setError] = useState(null); + const [loading, setLoading] = useState(false); + const requestGeneration = useRef(0); + + useEffect(() => { + requestGeneration.current = nextRequestGeneration(requestGeneration.current); + setLoading(false); + setResult(null); + setEvents([]); + setError(null); + if (installationId && !enabled.some((item) => item.installationId === installationId)) { + setInstallationId(''); + setCapability(''); + } + }, [workspace?.workspaceId]); + + useEffect(() => { + if (installationId && !enabled.some((item) => item.installationId === installationId)) { + setInstallationId(''); + setCapability(''); + } + }, [enabled, installationId]); + + const run = async () => { + if (!workspace) { + setError({status: 0, code: 'CONFIGURATION_ERROR', message: 'Select the active Workspace first.'}); + return; + } + const installation = enabled.find((item) => item.installationId === installationId); + if (!installation) { + setError({status: 0, code: 'INSTALLATION_DISABLED', message: 'Select an enabled trusted Installation before invoking.'}); + return; + } + let parsed: unknown; + try { + parsed = JSON.parse(input); + } catch { + setError({status: 0, code: 'VALIDATION_ERROR', message: 'Input must be valid JSON.'}); + return; + } + if (!parsed || Array.isArray(parsed) || typeof parsed !== 'object') { + setError({status: 0, code: 'VALIDATION_ERROR', message: 'Input must be a JSON object.'}); + return; + } + const generation = nextRequestGeneration(requestGeneration.current); + requestGeneration.current = generation; + const workspaceId = workspace.workspaceId; + setLoading(true); + setError(null); + setResult(null); + setEvents([]); + try { + if (stream) { + await client.invokeStream(workspaceId, {agentId: installation.agentId, capability, input: parsed as Record}, (event) => { + if (isCurrentRequest(generation, requestGeneration.current)) setEvents((current) => [...current, event]); + }); + } else { + const value = await client.invoke(workspaceId, {agentId: installation.agentId, capability, input: parsed as Record, stream: false}); + if (isCurrentRequest(generation, requestGeneration.current)) setResult(value); + } + } catch (value) { + if (isCurrentRequest(generation, requestGeneration.current)) setError(toPlatformErrorView(value, 'Invocation failed.')); + } finally { + if (isCurrentRequest(generation, requestGeneration.current)) setLoading(false); + } + }; + + return ( +
+
+
Invocations / Owner
+

Invoke an installed Agent

+

Requests use Gateway v4. JSON and SSE responses are validated for correlation and terminal semantics before display.

+
+
+ { setInstallationId(value); setCapability(''); }} + capability={capability} + setCapability={setCapability} + input={input} + setInput={setInput} + stream={stream} + setStream={setStream} + loading={loading} + onSubmit={() => void run()} + /> + +
+
+ ); +} + +function DispatchForm({workspace, enabled, installationId, setInstallationId, capability, setCapability, input, setInput, stream, setStream, loading, onSubmit}: { + workspace: Workspace | null; + enabled: Installation[]; + installationId: string; + setInstallationId: (value: string) => void; + capability: string; + setCapability: (value: string) => void; + input: string; + setInput: (value: string) => void; + stream: boolean; + setStream: (value: boolean) => void; + loading: boolean; + onSubmit: () => void; +}) { + return ( +
+
Dispatch request
+ + + + setCapability(event.target.value)} disabled={loading} placeholder="Enter declared capability" className="w-full rounded-lg border border-brand-outline-variant bg-brand-lowest px-3 py-2 text-sm text-brand-on-surface outline-none disabled:opacity-40" /> + +