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..932f81b 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,132 @@ 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":"4285e7349a2517fbdbfac9c1bc072a5ff1ef702d6cfe1826d62f02d73334bdc0"}]' + NEKIRO_ROUTER_SERVICE_PRINCIPALS_JSON: '[{"id":"root-console-control-plane","tokenSha256":"e3a864d8b9d70000e50e42d94761cc5d5996a36f39137735dca3a830f23cf4ba"}]' + 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: Capture sanitized console acceptance backend logs + if: always() + shell: bash + run: | + docker compose --project-name "$NEKIRO_E2E_COMPOSE_PROJECT" --file "$NEKIRO_E2E_COMPOSE_FILE" logs --no-color 2>&1 | + perl -pe ' + BEGIN { + @secret_names = qw( + POSTGRES_PASSWORD NEKIRO_COMPOSE_DATABASE_URL + 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 RUNTIME_B_ROUTER_TOKEN + VITE_NEKIRO_PROVIDER_TOKEN VITE_NEKIRO_OWNER_TOKEN + ); + @secrets = grep { defined($_) && length($_) } @ENV{@secret_names}; + @fixtures = qw( + browser-json browser-sse + ); + } + for my $value (@secrets, @fixtures) { s/\Q$value\E/[REDACTED]/g; } + s/\b[0-9a-f]{64}\b/[REDACTED-64-HEX]/g; + s{(?:[A-Za-z0-9_-]+\.){2}[A-Za-z0-9_-]+}{[REDACTED-ROUTER-CREDENTIAL]}g; + s/\b[A-Za-z0-9_-]{86}\b/[REDACTED-ED25519-SIGNATURE]/g; + s/\brtj_[A-Za-z0-9._:-]*/[REDACTED-ROUTER-JTI]/g; + ' + + - 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 @@ -139,7 +269,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 +300,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 @@ -201,6 +334,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 @@ -215,7 +357,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 +390,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 @@ -285,7 +430,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/.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/AGENTS.md b/AGENTS.md index ceb7a63..cd2a405 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -3,9 +3,20 @@ For additional context about technologies to be used, project structure, shell commands, and other important information, read the current plan -at specs/026-trusted-publication-acceptance/plan.md +at specs/027-console-trusted-publication/plan.md +## Current delivery status + +Spec 027 has implemented the production Console, trusted publication operations, +the reverse Runtime B -> Router -> Runtime A acceptance, and real frontend CI. +The reviewed Console is imported under `apps/console`; root CI run +`30319275997` passed all seven checks. The prior Spec 026-only status sentence +below is historical and is superseded for the current checkout. Slice C and +Slice D are currently combined in root PR #62, so the literal independent-PR +closure gate remains open until that delivery scope is split or explicitly +re-approved. + 本文件是整个仓库的长期项目宪章,适用于所有目录、模块和参与者。它记录稳定的产品目标、领域语言、架构边界和交付标准,不替代具体需求、API 文档或 ADR。 本文件应根据项目当前状态、已验证需求和正式架构决策持续自我更新迭代,但任何更新都必须说明原因与影响,并保持核心边界和兼容性变化清晰可追溯。 diff --git a/README.md b/README.md index 147154d..8be4f20 100644 --- a/README.md +++ b/README.md @@ -59,14 +59,18 @@ capability, Invocation, Task, parent lineage, Trace, and endpoint origin. Both sample Runtimes verify the credential and reject direct execution before runtime logic; stream cancellation receives a separate one-time `jti`. -Frontend Console work remains paused and `apps/console` is not yet present. The -thin Go Agent SDK, Router-owned nested adapter, isolated Runtime A, cross-Runtime -nested invocation, and process/Compose wiring are implemented. CI run -`30060752722` passed root build/test/race/vet/lint, Runtime A test/vet/race, -PostgreSQL integration, Compose configuration, Frontend, Codecov, and the real -authenticated Invoke-to-Record acceptance. The repository therefore proves -the backend/headless Phase 1 loop, but not yet the user-facing Console or the -later production governance and deployment integration stages. +The production Console is now imported under `apps/console` and exercises the +Gateway-only trusted workflow: Register -> Verify -> Publish -> Discover -> +Install -> Invoke -> Record. It preserves the four isolated comparison demo +routes, keeps provider and Workspace-owner credentials separate and transient, +and displays Gateway-provided Invocation/Trace lineage. Root CI run +`30319275997` passed all seven checks, including the fresh Compose backend +acceptance and production `console-browser-acceptance`. The standalone Console +source and its independently reviewed UI/browser PRs remain in +[NeKiro-Console](https://github.com/NeKiro-project/NeKiro-Console); root +integration is tracked by [PR #62](https://github.com/NeKiro-project/NeKiro/pull/62). +Independent root PR separation for the backend reverse fixture and frontend +integration remains a delivery-closure item, not a runtime behavior gap. The Go Workspace Client SDK under `sdks/client-sdk` is the application-facing entry point for invoking an installed Agent through Gateway. One immutable 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..3d22862 --- /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 == "" || 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() != "" { + 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..10c5a25 --- /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#", "http://a2a-router:"} { + 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..8cabaef --- /dev/null +++ b/agents/runtime-b/nested.go @@ -0,0 +1,117 @@ +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 { + contextID := input.ContextID + if contextID == "" { + contextID = derivedID("context", input.ID) + } + return &a2a.Message{ + ID: "runtime-b-nested-result-" + input.ID, + ContextID: 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..cec3b05 --- /dev/null +++ b/agents/runtime-b/nested_test.go @@ -0,0 +1,91 @@ +package runtimeb + +import ( + "context" + "encoding/json" + "errors" + "testing" + + "github.com/Nene7ko/NeKiro/contracts" + agentsdk "github.com/Nene7ko/NeKiro/sdks/agent-sdk" + "github.com/a2aproject/a2a-go/a2a" +) + +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") + } +} + +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/apps/a2a-router/cmd/a2a-router/main.go b/apps/a2a-router/cmd/a2a-router/main.go index 5eb73a5..e2abafc 100644 --- a/apps/a2a-router/cmd/a2a-router/main.go +++ b/apps/a2a-router/cmd/a2a-router/main.go @@ -67,7 +67,7 @@ func serve(ctx context.Context, logger *slog.Logger) error { if err := ledgerStore.Check(ctx); err != nil { return fmt.Errorf("router Ledger schema is not ready: %w", err) } - handler, err := newHandler(cfg, http.DefaultClient, http.DefaultClient, ledgerStore) + handler, err := newHandler(cfg, http.DefaultClient, http.DefaultClient, ledgerStore, logger) if err != nil { return err } @@ -101,7 +101,7 @@ func migrate(ctx context.Context, direction string) (returnErr error) { return nil } -func newHandler(cfg config.Config, doer resolution.HTTPDoer, agentHTTPClient *http.Client, ledgerAppender api.InvocationLedgerAppender) (http.Handler, error) { +func newHandler(cfg config.Config, doer resolution.HTTPDoer, agentHTTPClient *http.Client, ledgerAppender api.InvocationLedgerAppender, logger *slog.Logger) (http.Handler, error) { authenticator, err := auth.NewStaticAuthenticator(cfg.RouterPrincipals) if err != nil { return nil, err @@ -130,7 +130,7 @@ func newHandler(cfg config.Config, doer resolution.HTTPDoer, agentHTTPClient *ht if !ok { return nil, errors.New("router nested Ledger reader is required") } - dispatch, err = api.NewDispatchHandlerWithTransportAndLedgerAndStreaming(authenticator, resolver, transport, ledgerAppender, cfg.SSEEventLimitBytes, cfg.InternalRequestLimitBytes, cfg.ResolutionDeadline) + dispatch, err = api.NewDispatchHandlerWithTransportAndLedgerAndStreaming(authenticator, resolver, transport, ledgerAppender, cfg.SSEEventLimitBytes, cfg.InternalRequestLimitBytes, cfg.ResolutionDeadline, logger) if err != nil { return nil, err } diff --git a/apps/a2a-router/cmd/a2a-router/main_test.go b/apps/a2a-router/cmd/a2a-router/main_test.go index f5641ef..784776e 100644 --- a/apps/a2a-router/cmd/a2a-router/main_test.go +++ b/apps/a2a-router/cmd/a2a-router/main_test.go @@ -3,6 +3,8 @@ package main import ( "context" "crypto/ed25519" + "io" + "log/slog" "net/http" "net/http/httptest" "strings" @@ -74,7 +76,7 @@ func TestNewHandlerAssemblesReadinessWithoutDependencyProbe(t *testing.T) { ResolutionDeadline: time.Second, AgentDeadline: time.Second, AgentCredential: credential.Config{Issuer: "https://a2a-router.nekiro.test", KeyID: "router-key-1", PrivateKey: ed25519.NewKeyFromSeed(make([]byte, ed25519.SeedSize)), TTL: 30 * time.Second}, - }, failingDoer{}, &http.Client{}, ledgerAppenderStub{}) + }, failingDoer{}, &http.Client{}, ledgerAppenderStub{}, slog.New(slog.NewTextHandler(io.Discard, nil))) if err != nil { t.Fatal(err) } diff --git a/apps/a2a-router/internal/api/agent_invocation_handler.go b/apps/a2a-router/internal/api/agent_invocation_handler.go index 786b117..553656e 100644 --- a/apps/a2a-router/internal/api/agent_invocation_handler.go +++ b/apps/a2a-router/internal/api/agent_invocation_handler.go @@ -88,12 +88,14 @@ func (handler *AgentInvocationHandler) serve(writer http.ResponseWriter, request // Step 1: Authenticate the Agent binding. Auth failures are pre-correlation. authenticatedAgent, err := handler.binding.Authenticate(request) if err != nil { + handler.dispatchHandler.logPreflight(request.Context(), "authentication_failed", contracts.ErrorCodeUnauthenticated, "") handler.writePreError(writer, contracts.ErrorCodeUnauthenticated) return } // Step 2: Read and strictly validate the nested request. if request.Header.Get("Content-Type") != "application/json" { + handler.dispatchHandler.logPreflight(request.Context(), "content_type_rejected", contracts.ErrorCodeValidationError, "") handler.writePreError(writer, contracts.ErrorCodeValidationError) return } @@ -103,6 +105,7 @@ func (handler *AgentInvocationHandler) serve(writer http.ResponseWriter, request if errors.Is(err, errPayloadTooLarge) { code = contracts.ErrorCodePayloadTooLarge } + handler.dispatchHandler.logPreflight(request.Context(), "request_parse_failed", code, "") handler.writePreError(writer, code) return } @@ -110,6 +113,7 @@ func (handler *AgentInvocationHandler) serve(writer http.ResponseWriter, request // Step 3: Negotiate result mode before acceptance. accept := request.Header.Get("Accept") if _, err := contracts.NegotiateInvocationResultMode(nestedRequest.Stream, accept); err != nil { + handler.dispatchHandler.logPreflight(request.Context(), "result_negotiation_failed", contracts.ErrorCodeNotAcceptable, "") handler.writePreError(writer, contracts.ErrorCodeNotAcceptable) return } @@ -139,6 +143,7 @@ func (handler *AgentInvocationHandler) serve(writer http.ResponseWriter, request if errors.Is(err, ledger.ErrNotFound) { code = contracts.ErrorCodeNotFound } + handler.dispatchHandler.logPreflight(request.Context(), "parent_lookup_failed", classifyNestedError(ctx, err, code), "") handler.writePreError(writer, classifyNestedError(ctx, err, code)) return } @@ -154,6 +159,7 @@ func (handler *AgentInvocationHandler) serve(writer http.ResponseWriter, request } else if errors.Is(err, nested.ErrParentTargetMismatch) || errors.Is(err, nested.ErrParentWorkspaceMismatch) { code = contracts.ErrorCodeForbidden } + handler.dispatchHandler.logPreflight(request.Context(), "parent_context_failed", code, childContext.TraceID) handler.writePreError(writer, code) return } @@ -171,7 +177,9 @@ func (handler *AgentInvocationHandler) serve(writer http.ResponseWriter, request Capability: nestedRequest.Capability, }) if err != nil { - handler.writePreError(writer, classifyNestedError(ctx, err, contracts.ErrorCodeDependency)) + code := classifyNestedError(ctx, err, contracts.ErrorCodeDependency) + handler.dispatchHandler.logPreflight(request.Context(), "resolution_failed", code, childContext.TraceID) + handler.writePreError(writer, code) return } diff --git a/apps/a2a-router/internal/api/agent_invocation_handler_test.go b/apps/a2a-router/internal/api/agent_invocation_handler_test.go index e19301c..14f401d 100644 --- a/apps/a2a-router/internal/api/agent_invocation_handler_test.go +++ b/apps/a2a-router/internal/api/agent_invocation_handler_test.go @@ -72,7 +72,7 @@ func newTestAgentHandler(t *testing.T, ledgerReader NestedLedgerReader, versionR } resolver := &mockResolver{} - dispatchHandler, err := NewDispatchHandler(serviceAuth, resolver, 1048576, 30000*1000000) + dispatchHandler, err := NewDispatchHandler(serviceAuth, resolver, 1048576, 30000*1000000, dispatchTestLogger) if err != nil { t.Fatalf("NewDispatchHandler() error = %v", err) } @@ -169,7 +169,7 @@ func TestAgentHandlerRejects100InvalidCasesWithoutChildSideEffects(t *testing.T) resolver := &resolverStub{} transport := &transportStub{} ledgerRecorder := &ledgerRecorder{} - dispatch, err := NewDispatchHandlerWithTransportAndLedger(serviceAuth, resolver, transport, ledgerRecorder, 1048576, 30*time.Second) + dispatch, err := NewDispatchHandlerWithTransportAndLedger(serviceAuth, resolver, transport, ledgerRecorder, 1048576, 30*time.Second, dispatchTestLogger) if err != nil { t.Fatal(err) } @@ -462,7 +462,7 @@ func TestNewAgentInvocationHandlerValidation(t *testing.T) { serviceAuth, _ := auth.NewStaticAuthenticator([]auth.Principal{ {ID: "service", TokenSHA256: agentTokenDigest("svc-token")}, }) - dispatchHandler, _ := NewDispatchHandler(serviceAuth, &mockResolver{}, 1048576, 30000*1000000) + dispatchHandler, _ := NewDispatchHandler(serviceAuth, &mockResolver{}, 1048576, 30000*1000000, dispatchTestLogger) ledgerReader := &mockNestedLedgerReader{} versionResolver := &mockVersionResolver{} @@ -539,7 +539,7 @@ func TestAgentHandlerNestedJSONSuccessPath(t *testing.T) { // Ledger records all appended events. ledgerRec := &ledgerRecorder{} - dispatchHandler, err := NewDispatchHandlerWithTransportAndLedger(serviceAuth, resolver, transport, ledgerRec, 1048576, 30*time.Second) + dispatchHandler, err := NewDispatchHandlerWithTransportAndLedger(serviceAuth, resolver, transport, ledgerRec, 1048576, 30*time.Second, dispatchTestLogger) if err != nil { t.Fatalf("NewDispatchHandlerWithTransportAndLedger() error = %v", err) } @@ -717,7 +717,7 @@ func TestAgentHandlerNestedSSESuccessPath(t *testing.T) { ledgerRec := &ledgerRecorder{} - dispatchHandler, err := NewDispatchHandlerWithTransportAndLedgerAndStreaming(serviceAuth, resolver, streamTransport, ledgerRec, 65536, 1048576, 30*time.Second) + dispatchHandler, err := NewDispatchHandlerWithTransportAndLedgerAndStreaming(serviceAuth, resolver, streamTransport, ledgerRec, 65536, 1048576, 30*time.Second, dispatchTestLogger) if err != nil { t.Fatalf("NewDispatchHandlerWithTransportAndLedgerAndStreaming() error = %v", err) } @@ -834,7 +834,7 @@ func TestAgentHandlerDispatchChildResolverFailure(t *testing.T) { transport := &transportStub{result: json.RawMessage(`{}`)} ledgerRec := &ledgerRecorder{} - dispatchHandler, err := NewDispatchHandlerWithTransportAndLedger(serviceAuth, resolver, transport, ledgerRec, 1048576, 30*time.Second) + dispatchHandler, err := NewDispatchHandlerWithTransportAndLedger(serviceAuth, resolver, transport, ledgerRec, 1048576, 30*time.Second, dispatchTestLogger) if err != nil { t.Fatalf("NewDispatchHandlerWithTransportAndLedger() error = %v", err) } diff --git a/apps/a2a-router/internal/api/dispatch_handler.go b/apps/a2a-router/internal/api/dispatch_handler.go index ebc6bfb..253811a 100644 --- a/apps/a2a-router/internal/api/dispatch_handler.go +++ b/apps/a2a-router/internal/api/dispatch_handler.go @@ -11,6 +11,7 @@ import ( "fmt" "io" "iter" + "log/slog" "net/http" "time" @@ -134,21 +135,22 @@ type DispatchHandler struct { deadline time.Duration sseEventLimitBytes int64 streamValidator *contracts.RuntimeContractValidator + logger *slog.Logger } -func NewDispatchHandler(authenticator Authenticator, resolver Resolver, requestLimit int64, deadline time.Duration) (*DispatchHandler, error) { - if authenticator == nil || resolver == nil || requestLimit < contracts.RuntimeByteLimitMinimum || requestLimit > contracts.RuntimeByteLimitMaximum || deadline < time.Duration(contracts.RuntimeDeadlineMinimumMS)*time.Millisecond || deadline > time.Duration(contracts.RuntimeDeadlineMaximumMS)*time.Millisecond { +func NewDispatchHandler(authenticator Authenticator, resolver Resolver, requestLimit int64, deadline time.Duration, logger *slog.Logger) (*DispatchHandler, error) { + if authenticator == nil || resolver == nil || logger == nil || requestLimit < contracts.RuntimeByteLimitMinimum || requestLimit > contracts.RuntimeByteLimitMaximum || deadline < time.Duration(contracts.RuntimeDeadlineMinimumMS)*time.Millisecond || deadline > time.Duration(contracts.RuntimeDeadlineMaximumMS)*time.Millisecond { return nil, errors.New("router dispatch dependencies are required") } streamValidator, err := contracts.NewRuntimeContractValidator() if err != nil { return nil, fmt.Errorf("router runtime stream validator is unavailable: %w", err) } - return &DispatchHandler{authenticator: authenticator, resolver: resolver, requestLimit: requestLimit, deadline: deadline, streamValidator: streamValidator}, nil + return &DispatchHandler{authenticator: authenticator, resolver: resolver, requestLimit: requestLimit, deadline: deadline, streamValidator: streamValidator, logger: logger}, nil } -func NewDispatchHandlerWithTransport(authenticator Authenticator, resolver Resolver, transport NonStreamingTransport, requestLimit int64, deadline time.Duration) (*DispatchHandler, error) { - handler, err := NewDispatchHandler(authenticator, resolver, requestLimit, deadline) +func NewDispatchHandlerWithTransport(authenticator Authenticator, resolver Resolver, transport NonStreamingTransport, requestLimit int64, deadline time.Duration, logger *slog.Logger) (*DispatchHandler, error) { + handler, err := NewDispatchHandler(authenticator, resolver, requestLimit, deadline, logger) if err != nil { return nil, err } @@ -162,8 +164,8 @@ func NewDispatchHandlerWithTransport(authenticator Authenticator, resolver Resol return handler, nil } -func NewDispatchHandlerWithTransportAndLedger(authenticator Authenticator, resolver Resolver, transport NonStreamingTransport, ledger InvocationLedgerAppender, requestLimit int64, deadline time.Duration) (*DispatchHandler, error) { - handler, err := NewDispatchHandlerWithTransport(authenticator, resolver, transport, requestLimit, deadline) +func NewDispatchHandlerWithTransportAndLedger(authenticator Authenticator, resolver Resolver, transport NonStreamingTransport, ledger InvocationLedgerAppender, requestLimit int64, deadline time.Duration, logger *slog.Logger) (*DispatchHandler, error) { + handler, err := NewDispatchHandlerWithTransport(authenticator, resolver, transport, requestLimit, deadline, logger) if err != nil { return nil, err } @@ -174,8 +176,8 @@ func NewDispatchHandlerWithTransportAndLedger(authenticator Authenticator, resol return handler, nil } -func NewDispatchHandlerWithTransportAndLedgerAndStreaming(authenticator Authenticator, resolver Resolver, transport NonStreamingTransport, ledger InvocationLedgerAppender, sseEventLimitBytes int64, requestLimit int64, deadline time.Duration) (*DispatchHandler, error) { - handler, err := NewDispatchHandlerWithTransportAndLedger(authenticator, resolver, transport, ledger, requestLimit, deadline) +func NewDispatchHandlerWithTransportAndLedgerAndStreaming(authenticator Authenticator, resolver Resolver, transport NonStreamingTransport, ledger InvocationLedgerAppender, sseEventLimitBytes int64, requestLimit int64, deadline time.Duration, logger *slog.Logger) (*DispatchHandler, error) { + handler, err := NewDispatchHandlerWithTransportAndLedger(authenticator, resolver, transport, ledger, requestLimit, deadline, logger) if err != nil { return nil, err } @@ -203,13 +205,16 @@ func (handler *DispatchHandler) RegisterRoutes(mux *http.ServeMux) { // "agent" and propagates ParentInvocationID to Ledger events. func (handler *DispatchHandler) DispatchChild(writer http.ResponseWriter, request *http.Request, dispatchRequest contracts.DispatchInvocationRequestV4, accept string) { if _, err := contracts.NegotiateInvocationResultMode(dispatchRequest.Stream, accept); err != nil { + handler.logPreflight(request.Context(), "result_negotiation_failed", contracts.ErrorCodeNotAcceptable, dispatchRequest.TraceID) handler.writePreError(writer, dispatchRequest.TraceID, contracts.ErrorCodeNotAcceptable) return } if err := validateChildDispatch(dispatchRequest); err != nil { + handler.logPreflight(request.Context(), "request_validation_failed", contracts.ErrorCodeValidationError, dispatchRequest.TraceID) handler.writePreError(writer, dispatchRequest.TraceID, contracts.ErrorCodeValidationError) return } + handler.logInvocation(request.Context(), dispatchRequest, "request_accepted") invocationStartedAt := time.Now() ctx, cancel := context.WithTimeout(request.Context(), handler.deadline) defer cancel() @@ -223,6 +228,7 @@ func (handler *DispatchHandler) DispatchChild(writer http.ResponseWriter, reques code := contracts.ErrorCodeDependency var failure *resolution.Failure if errors.As(err, &failure) { + handler.logInvocation(request.Context(), dispatchRequest, "resolution_failed", "code", string(failure.Code), "http_status", failure.StatusCode) // Map through the Agent boundary; never forward internal // Control Plane codes or body across Agent Router v1. code = mapControlPlaneCodeToAgentBoundary(failure.Code) @@ -231,10 +237,14 @@ func (handler *DispatchHandler) DispatchChild(writer http.ResponseWriter, reques } else if errors.Is(err, context.Canceled) { code = contracts.ErrorCodeCanceled } + if !errors.As(err, &failure) { + handler.logInvocation(request.Context(), dispatchRequest, "resolution_failed", "code", string(code)) + } handler.writePreError(writer, dispatchRequest.TraceID, code) return } if err := validateResolvedReleaseProvenance(dispatchRequest, resolved); err != nil { + handler.logInvocation(request.Context(), dispatchRequest, "provenance_mismatch", "code", string(contracts.ErrorCodeDependency)) handler.writePreError(writer, dispatchRequest.TraceID, contracts.ErrorCodeDependency) return } @@ -300,10 +310,12 @@ func (handler *DispatchHandler) DispatchChild(writer http.ResponseWriter, reques func (handler *DispatchHandler) dispatch(writer http.ResponseWriter, request *http.Request) { if _, err := handler.authenticator.Authenticate(request); err != nil { + handler.logPreflight(request.Context(), "authentication_failed", authErrorCode(err), "") handler.writeGeneratedPreError(writer, authErrorCode(err)) return } if request.Header.Get("Content-Type") != "application/json" { + handler.logPreflight(request.Context(), "content_type_rejected", contracts.ErrorCodeValidationError, "") handler.writeGeneratedPreError(writer, contracts.ErrorCodeValidationError) return } @@ -313,17 +325,21 @@ func (handler *DispatchHandler) dispatch(writer http.ResponseWriter, request *ht if errors.Is(err, errPayloadTooLarge) { code = contracts.ErrorCodePayloadTooLarge } + handler.logPreflight(request.Context(), "request_parse_failed", code, "") handler.writeGeneratedPreError(writer, code) return } if _, err := contracts.NegotiateInvocationResultMode(dispatchRequest.Stream, request.Header.Get("Accept")); err != nil { + handler.logPreflight(request.Context(), "result_negotiation_failed", contracts.ErrorCodeNotAcceptable, dispatchRequest.TraceID) handler.writePreError(writer, dispatchRequest.TraceID, contracts.ErrorCodeNotAcceptable) return } if err := validateDispatch(dispatchRequest); err != nil { + handler.logPreflight(request.Context(), "request_validation_failed", contracts.ErrorCodeValidationError, dispatchRequest.TraceID) handler.writePreError(writer, dispatchRequest.TraceID, contracts.ErrorCodeValidationError) return } + handler.logInvocation(request.Context(), dispatchRequest, "request_accepted") invocationStartedAt := time.Now() ctx, cancel := context.WithTimeout(request.Context(), handler.deadline) defer cancel() @@ -337,15 +353,18 @@ func (handler *DispatchHandler) dispatch(writer http.ResponseWriter, request *ht code := contracts.ErrorCodeDependency var failure *resolution.Failure if errors.As(err, &failure) { + handler.logInvocation(request.Context(), dispatchRequest, "resolution_failed", "code", string(failure.Code), "http_status", failure.StatusCode) writeRawJSON(writer, failure.StatusCode, failure.TraceID, failure.Body) return } else if errors.Is(err, context.DeadlineExceeded) { code = contracts.ErrorCodeTimeout } + handler.logInvocation(request.Context(), dispatchRequest, "resolution_failed", "code", string(code)) handler.writeCorrelatedError(writer, dispatchRequest, code) return } if err := validateResolvedReleaseProvenance(dispatchRequest, resolved); err != nil { + handler.logInvocation(request.Context(), dispatchRequest, "provenance_mismatch", "code", string(contracts.ErrorCodeDependency)) handler.writeCorrelatedError(writer, dispatchRequest, contracts.ErrorCodeDependency) return } @@ -587,6 +606,7 @@ func (handler *DispatchHandler) dispatchNonStreamingWithLedger(ctx context.Conte handler.writeCorrelatedError(writer, request, code) return } + handler.logInvocation(ctx, request, "transport_started") result, err := handler.transport.SendNonStreaming(ctx, request, resolved) if err == nil && ctx.Err() != nil { err = ctx.Err() @@ -634,6 +654,7 @@ func (handler *DispatchHandler) dispatchStreamingWithLedger(ctx context.Context, }, childMode) { return } + handler.logInvocation(ctx, request, "transport_started") appendEvent := func(event contracts.InvocationEventV03) error { return handler.ledger.Append(ctx, event) } @@ -811,6 +832,7 @@ func (handler *DispatchHandler) appendInitialLedgerEventsMode(ctx context.Contex if err := handler.ledger.Append(ctx, event); err == nil { continue } else if event.Sequence > 0 { + handler.logInvocation(ctx, request, "initial_ledger_append_failed", "sequence", event.Sequence, "code", string(contracts.ErrorCodeDependency)) if code, eventType, status, ok := contextTerminal(ctx); ok { terminal, buildErr := terminalLifecycleEvent(request, event.Sequence, eventType, status, terminalOccurredAt(startedAt, event.Sequence), time.Since(startedAt).Milliseconds(), code) appendCtx, release := terminalLedgerContext(ctx) @@ -825,6 +847,9 @@ func (handler *DispatchHandler) appendInitialLedgerEventsMode(ctx context.Contex } } } + if event.Sequence == 0 { + handler.logInvocation(ctx, request, "initial_ledger_append_failed", "sequence", event.Sequence, "code", string(contracts.ErrorCodeDependency)) + } // In child mode, sequence-0 failure means child acceptance never // occurred; emit a pre-correlation error per FR-008. if childMode && event.Sequence == 0 { @@ -837,6 +862,25 @@ func (handler *DispatchHandler) appendInitialLedgerEventsMode(ctx context.Contex return true } +func (handler *DispatchHandler) logInvocation(ctx context.Context, request contracts.DispatchInvocationRequestV4, stage string, attributes ...any) { + fields := []any{ + "stage", stage, + "invocation_id", request.InvocationID, + "root_task_id", request.RootTaskID, + "trace_id", request.TraceID, + } + fields = append(fields, attributes...) + handler.logger.InfoContext(ctx, "router invocation diagnostic", fields...) +} + +func (handler *DispatchHandler) logPreflight(ctx context.Context, stage string, code contracts.PlatformErrorCode, traceID contracts.TraceID) { + fields := []any{"stage", stage, "code", string(code)} + if traceID != "" { + fields = append(fields, "trace_id", traceID) + } + handler.logger.InfoContext(ctx, "router invocation diagnostic", fields...) +} + func contextTerminal(ctx context.Context) (contracts.PlatformErrorCode, string, string, bool) { switch { case errors.Is(ctx.Err(), context.DeadlineExceeded): diff --git a/apps/a2a-router/internal/api/dispatch_handler_test.go b/apps/a2a-router/internal/api/dispatch_handler_test.go index f052e8b..204b2d3 100644 --- a/apps/a2a-router/internal/api/dispatch_handler_test.go +++ b/apps/a2a-router/internal/api/dispatch_handler_test.go @@ -7,7 +7,9 @@ import ( "crypto/rand" "encoding/json" "errors" + "io" "iter" + "log/slog" "net/http" "net/http/httptest" "strings" @@ -24,6 +26,8 @@ import ( "github.com/a2aproject/a2a-go/a2asrv" ) +var dispatchTestLogger = slog.New(slog.NewTextHandler(io.Discard, nil)) + type authStub struct { caller auth.Caller err error @@ -410,7 +414,7 @@ func TestDispatchChildRejectsResolvedReleaseProvenanceMismatchBeforeLedger(t *te }} transport := &transportStub{result: json.RawMessage(`{"kind":"message"}`)} ledger := &ledgerRecorder{} - handler, err := NewDispatchHandlerWithTransportAndLedger(authStub{caller: auth.Caller{ID: "agent-a"}}, resolver, transport, ledger, 4096, time.Second) + handler, err := NewDispatchHandlerWithTransportAndLedger(authStub{caller: auth.Caller{ID: "agent-a"}}, resolver, transport, ledger, 4096, time.Second, dispatchTestLogger) if err != nil { t.Fatal(err) } @@ -571,7 +575,7 @@ func TestDispatchStreamingEmitsStrictCorrelatedFramesAndMetadataLedger(t *testin {Kind: "status-update", Payload: json.RawMessage(`{"kind":"status-update","taskId":"task-a","contextId":"ctx-a","status":{"state":"completed"},"final":true}`), TerminalType: contracts.ResultStreamEventCompleted, TerminalStatus: "succeeded"}, }} ledger := &ledgerRecorder{} - handler, err := NewDispatchHandlerWithTransportAndLedgerAndStreaming(authStub{caller: auth.Caller{ID: "control-plane"}}, resolver, transport, ledger, 4096, 4096, time.Second) + handler, err := NewDispatchHandlerWithTransportAndLedgerAndStreaming(authStub{caller: auth.Caller{ID: "control-plane"}}, resolver, transport, ledger, 4096, 4096, time.Second, dispatchTestLogger) if err != nil { t.Fatal(err) } @@ -636,7 +640,7 @@ func TestDispatchStreamingUsesStreamingTargetValidation(t *testing.T) { resolver := &resolverStub{response: contracts.ResolveAgentResponse{Card: card}} transport := &streamingTransportStub{transportStub: transportStub{targetErr: codedTransportError{code: contracts.ErrorCodeRouteNotFound}}, events: []streammodel.Event{{Kind: "message", Payload: json.RawMessage(`{"kind":"message"}`)}}} ledger := &ledgerRecorder{} - handler, err := NewDispatchHandlerWithTransportAndLedgerAndStreaming(authStub{caller: auth.Caller{ID: "control-plane"}}, resolver, transport, ledger, 4096, 4096, time.Second) + handler, err := NewDispatchHandlerWithTransportAndLedgerAndStreaming(authStub{caller: auth.Caller{ID: "control-plane"}}, resolver, transport, ledger, 4096, 4096, time.Second, dispatchTestLogger) if err != nil { t.Fatal(err) } @@ -672,7 +676,7 @@ func TestDispatchStreamingRuntimeBEndToEnd(t *testing.T) { Limits: contracts.AgentLimits{TimeoutMS: 1000, MaxInputBytes: "4096", MaxOutputBytes: "4096", Streaming: true}, }, Installation: contracts.ResolvedInstallation{InstallationID: "installation-a", WorkspaceID: "workspace-a", AgentID: "agent-a", InstalledVersion: "1.0.0", InstalledReleaseID: "release-a", AgentCardDigest: strings.Repeat("a", 64), Status: "enabled"}}} ledger := &ledgerRecorder{} - handler, err := NewDispatchHandlerWithTransportAndLedgerAndStreaming(authStub{caller: auth.Caller{ID: "control-plane"}}, resolver, transport, ledger, 4096, 4096, time.Second) + handler, err := NewDispatchHandlerWithTransportAndLedgerAndStreaming(authStub{caller: auth.Caller{ID: "control-plane"}}, resolver, transport, ledger, 4096, 4096, time.Second, dispatchTestLogger) if err != nil { t.Fatal(err) } @@ -692,7 +696,7 @@ func TestDispatchStreamingInterruptedEOFIsFailedAndNeverSucceeded(t *testing.T) resolver := &resolverStub{response: contracts.ResolveAgentResponse{Card: dispatchResolvedCard("https://agent.example/a2a")}} transport := &streamingTransportStub{events: []streammodel.Event{{Kind: "message", Payload: json.RawMessage(`{"kind":"message","messageId":"message-a","taskId":"task-a","contextId":"ctx-a","role":"agent","parts":[{"kind":"data","data":{"value":"partial"}}]}`)}}} ledger := &ledgerRecorder{} - handler, err := NewDispatchHandlerWithTransportAndLedgerAndStreaming(authStub{caller: auth.Caller{ID: "control-plane"}}, resolver, transport, ledger, 4096, 4096, time.Second) + handler, err := NewDispatchHandlerWithTransportAndLedgerAndStreaming(authStub{caller: auth.Caller{ID: "control-plane"}}, resolver, transport, ledger, 4096, 4096, time.Second, dispatchTestLogger) if err != nil { t.Fatal(err) } @@ -715,7 +719,7 @@ func TestDispatchStreamingSSEOverflowEmitsBoundedFailure(t *testing.T) { largePayload := json.RawMessage(`{"kind":"message","messageId":"message-a","taskId":"task-a","contextId":"ctx-a","role":"agent","parts":[{"kind":"data","data":{"value":"` + strings.Repeat("x", 700) + `"}}]}`) transport := &streamingTransportStub{events: []streammodel.Event{{Kind: "message", Payload: largePayload}}} ledger := &ledgerRecorder{} - handler, err := NewDispatchHandlerWithTransportAndLedgerAndStreaming(authStub{caller: auth.Caller{ID: "control-plane"}}, resolver, transport, ledger, 320, 4096, time.Second) + handler, err := NewDispatchHandlerWithTransportAndLedgerAndStreaming(authStub{caller: auth.Caller{ID: "control-plane"}}, resolver, transport, ledger, 320, 4096, time.Second, dispatchTestLogger) if err != nil { t.Fatal(err) } @@ -744,7 +748,7 @@ func TestDispatchStreamingClassifiesTimeoutAndCancellation(t *testing.T) { resolver := &resolverStub{response: contracts.ResolveAgentResponse{Card: dispatchResolvedCard("https://agent.example/a2a")}} transport := &streamingTransportStub{err: test.err} ledger := &ledgerRecorder{} - handler, err := NewDispatchHandlerWithTransportAndLedgerAndStreaming(authStub{caller: auth.Caller{ID: "control-plane"}}, resolver, transport, ledger, 4096, 4096, time.Second) + handler, err := NewDispatchHandlerWithTransportAndLedgerAndStreaming(authStub{caller: auth.Caller{ID: "control-plane"}}, resolver, transport, ledger, 4096, 4096, time.Second, dispatchTestLogger) if err != nil { t.Fatal(err) } @@ -769,7 +773,7 @@ func TestDispatchNonStreamingUsesResolvedCardDeadline(t *testing.T) { ledger := &ledgerRecorder{} handler, err := NewDispatchHandlerWithTransportAndLedger( authStub{caller: auth.Caller{ID: "control-plane"}}, resolver, transport, - ledger, 4096, time.Second, + ledger, 4096, time.Second, dispatchTestLogger, ) if err != nil { t.Fatal(err) @@ -797,7 +801,7 @@ func TestDispatchNonStreamingCardDeadlineIncludesResolutionTime(t *testing.T) { ledger := &ledgerRecorder{} handler, err := NewDispatchHandlerWithTransportAndLedger( authStub{caller: auth.Caller{ID: "control-plane"}}, resolver, transport, - ledger, 4096, 500*time.Millisecond, + ledger, 4096, 500*time.Millisecond, dispatchTestLogger, ) if err != nil { t.Fatal(err) @@ -872,7 +876,7 @@ func TestDispatchStreamingCancellationDuringTerminalCommitUsesBoundedLedgerConte resolver := &resolverStub{response: contracts.ResolveAgentResponse{Card: dispatchResolvedCard("https://agent.example/a2a")}} transport := &streamingTransportStub{err: codedTransportError{code: contracts.ErrorCodeCanceled, cause: context.Canceled}} ledger := &cancelDuringTerminalLedgerRecorder{cancel: cancel} - handler, err := NewDispatchHandlerWithTransportAndLedgerAndStreaming(authStub{caller: auth.Caller{ID: "control-plane"}}, resolver, transport, ledger, 4096, 4096, time.Second) + handler, err := NewDispatchHandlerWithTransportAndLedgerAndStreaming(authStub{caller: auth.Caller{ID: "control-plane"}}, resolver, transport, ledger, 4096, 4096, time.Second, dispatchTestLogger) if err != nil { t.Fatal(err) } @@ -895,7 +899,7 @@ func TestDispatchStreamingCancellationDuringChunkCommitRecordsCanceledTerminal(t resolver := &resolverStub{response: contracts.ResolveAgentResponse{Card: dispatchResolvedCard("https://agent.example/a2a")}} transport := &streamingTransportStub{events: []streammodel.Event{{Kind: "task", Payload: json.RawMessage(`{"kind":"task","id":"task-a","contextId":"ctx-a","status":{"state":"working"}}`)}}} ledger := &cancelDuringStreamLedgerRecorder{cancel: cancel} - handler, err := NewDispatchHandlerWithTransportAndLedgerAndStreaming(authStub{caller: auth.Caller{ID: "control-plane"}}, resolver, transport, ledger, 4096, 4096, time.Second) + handler, err := NewDispatchHandlerWithTransportAndLedgerAndStreaming(authStub{caller: auth.Caller{ID: "control-plane"}}, resolver, transport, ledger, 4096, 4096, time.Second, dispatchTestLogger) if err != nil { t.Fatal(err) } @@ -918,7 +922,7 @@ func TestDispatchStreamingLedgerFailureAfterAgentChunkDoesNotFabricateTerminalFa resolver := &resolverStub{response: contracts.ResolveAgentResponse{Card: dispatchResolvedCard("https://agent.example/a2a")}} transport := &streamingTransportStub{events: []streammodel.Event{{Kind: "message", Payload: json.RawMessage(`{"kind":"message","messageId":"message-a","taskId":"task-a","contextId":"ctx-a","role":"agent","parts":[{"kind":"data","data":{"value":"ok"}}]}`), TerminalType: contracts.ResultStreamEventCompleted, TerminalStatus: "succeeded"}}} ledger := &ledgerRecorder{failSequence: 4, err: errors.New("ledger unavailable")} - handler, err := NewDispatchHandlerWithTransportAndLedgerAndStreaming(authStub{caller: auth.Caller{ID: "control-plane"}}, resolver, transport, ledger, 4096, 4096, time.Second) + handler, err := NewDispatchHandlerWithTransportAndLedgerAndStreaming(authStub{caller: auth.Caller{ID: "control-plane"}}, resolver, transport, ledger, 4096, 4096, time.Second, dispatchTestLogger) if err != nil { t.Fatal(err) } @@ -937,7 +941,7 @@ func TestDispatchStreamingChunkLedgerFailureEmitsDeliveryFailure(t *testing.T) { resolver := &resolverStub{response: contracts.ResolveAgentResponse{Card: dispatchResolvedCard("https://agent.example/a2a")}} transport := &streamingTransportStub{events: []streammodel.Event{{Kind: "message", Payload: json.RawMessage(`{"kind":"message","messageId":"message-a","taskId":"task-a","contextId":"ctx-a","role":"agent","parts":[{"kind":"data","data":{"value":"ok"}}]}`)}}} ledger := &ledgerRecorder{failSequence: 3, err: errors.New("ledger unavailable")} - handler, err := NewDispatchHandlerWithTransportAndLedgerAndStreaming(authStub{caller: auth.Caller{ID: "control-plane"}}, resolver, transport, ledger, 4096, 4096, time.Second) + handler, err := NewDispatchHandlerWithTransportAndLedgerAndStreaming(authStub{caller: auth.Caller{ID: "control-plane"}}, resolver, transport, ledger, 4096, 4096, time.Second, dispatchTestLogger) if err != nil { t.Fatal(err) } @@ -956,7 +960,7 @@ func TestDispatchStreamingWriterFailureCommitsNonSuccessLedgerTerminal(t *testin resolver := &resolverStub{response: contracts.ResolveAgentResponse{Card: dispatchResolvedCard("https://agent.example/a2a")}} transport := &streamingTransportStub{events: []streammodel.Event{{Kind: "message", Payload: json.RawMessage(`{"kind":"message","messageId":"message-a","taskId":"task-a","contextId":"ctx-a","role":"agent","parts":[{"kind":"data","data":{"value":"ok"}}]}`)}}} ledger := &ledgerRecorder{} - handler, err := NewDispatchHandlerWithTransportAndLedgerAndStreaming(authStub{caller: auth.Caller{ID: "control-plane"}}, resolver, transport, ledger, 4096, 4096, time.Second) + handler, err := NewDispatchHandlerWithTransportAndLedgerAndStreaming(authStub{caller: auth.Caller{ID: "control-plane"}}, resolver, transport, ledger, 4096, 4096, time.Second, dispatchTestLogger) if err != nil { t.Fatal(err) } @@ -1082,9 +1086,109 @@ func TestDispatchRejectsCardInputOverflowBeforeLedgerAcceptance(t *testing.T) { } } +func TestDispatchDiagnosticsExposeStageWithoutRawFailureDetails(t *testing.T) { + if _, err := NewDispatchHandler(authStub{caller: auth.Caller{ID: "control-plane"}}, &resolverStub{}, 4096, time.Second, nil); err == nil { + t.Fatal("nil logger was accepted") + } + t.Run("resolution", func(t *testing.T) { + var output bytes.Buffer + logger := slog.New(slog.NewTextHandler(&output, nil)) + resolver := &resolverStub{err: errors.New("resolution secret")} + handler, err := NewDispatchHandler(authStub{caller: auth.Caller{ID: "control-plane"}}, resolver, 4096, time.Second, logger) + if err != nil { + t.Fatal(err) + } + mux := http.NewServeMux() + handler.RegisterRoutes(mux) + response := invokeDispatch(mux, "application/json", "application/json", validDispatchBody(false)) + if response.Code != http.StatusServiceUnavailable { + t.Fatalf("status=%d body=%s", response.Code, response.Body.String()) + } + logText := output.String() + for _, stage := range []string{"request_accepted", "resolution_failed"} { + if !strings.Contains(logText, "stage="+stage) { + t.Fatalf("missing %s in log %q", stage, logText) + } + } + if !strings.Contains(logText, "code=DEPENDENCY_ERROR") || strings.Contains(logText, "resolution secret") { + t.Fatalf("unsafe or incomplete log %q", logText) + } + }) + + t.Run("initial ledger append", func(t *testing.T) { + var output bytes.Buffer + logger := slog.New(slog.NewTextHandler(&output, nil)) + resolver := &resolverStub{response: contracts.ResolveAgentResponse{Card: dispatchResolvedCard("https://agent.example/a2a")}} + transport := &transportStub{result: json.RawMessage(`{"kind":"message"}`)} + ledger := &ledgerRecorder{err: errors.New("ledger secret")} + handler, err := NewDispatchHandlerWithTransportAndLedger(authStub{caller: auth.Caller{ID: "control-plane"}}, resolver, transport, ledger, 4096, time.Second, logger) + if err != nil { + t.Fatal(err) + } + mux := http.NewServeMux() + handler.RegisterRoutes(mux) + response := invokeDispatch(mux, "application/json", "application/json", validDispatchBody(false)) + if response.Code != http.StatusServiceUnavailable { + t.Fatalf("status=%d body=%s", response.Code, response.Body.String()) + } + logText := output.String() + if !strings.Contains(logText, "stage=initial_ledger_append_failed") || strings.Contains(logText, "ledger secret") { + t.Fatalf("unsafe or incomplete log %q", logText) + } + }) +} + +func TestDispatchDiagnosticsLogTerminalInitialLedgerRecovery(t *testing.T) { + var output bytes.Buffer + logger := slog.New(slog.NewTextHandler(&output, nil)) + requestContext, cancel := context.WithCancel(context.Background()) + defer cancel() + resolver := &resolverStub{response: contracts.ResolveAgentResponse{Card: dispatchResolvedCard("https://agent.example/a2a")}} + transport := &transportStub{result: json.RawMessage(`{"kind":"message"}`)} + ledger := &cancelingLedgerRecorder{cancel: cancel} + handler, err := NewDispatchHandlerWithTransportAndLedger(authStub{caller: auth.Caller{ID: "control-plane"}}, resolver, transport, ledger, 4096, time.Second, logger) + if err != nil { + t.Fatal(err) + } + mux := http.NewServeMux() + handler.RegisterRoutes(mux) + request := httptest.NewRequest(http.MethodPost, "/internal/v4/invocations", strings.NewReader(validDispatchBody(false))).WithContext(requestContext) + request.Header.Set("Content-Type", "application/json") + request.Header.Set("Accept", "application/json") + response := httptest.NewRecorder() + mux.ServeHTTP(response, request) + if response.Code != http.StatusConflict { + t.Fatalf("status=%d body=%s", response.Code, response.Body.String()) + } + logText := output.String() + if !strings.Contains(logText, "stage=initial_ledger_append_failed") || !strings.Contains(logText, "sequence=1") { + t.Fatalf("sequence 1 recovery was not diagnosed: %q", logText) + } +} + +func TestDispatchChildDiagnosticsLogValidationRejection(t *testing.T) { + var output bytes.Buffer + logger := slog.New(slog.NewTextHandler(&output, nil)) + handler, err := NewDispatchHandler(authStub{caller: auth.Caller{ID: "service"}}, &resolverStub{}, 4096, time.Second, logger) + if err != nil { + t.Fatal(err) + } + dispatchRequest := contracts.DispatchInvocationRequestV4{ + InvocationID: "inv-child", RootTaskID: "task-root", ParentInvocationID: "inv-parent", TraceID: "trace-a", + Caller: contracts.Caller{Type: "user", ID: "user-a"}, WorkspaceID: "workspace-a", TargetAgentID: "agent-a", + AgentCardVersion: "1.0.0", Capability: "capability-a", Input: json.RawMessage(`{}`), Stream: false, + } + request := httptest.NewRequest(http.MethodPost, "/agent/v1/invocations", strings.NewReader("{}")) + response := httptest.NewRecorder() + handler.DispatchChild(response, request, dispatchRequest, "application/json") + if response.Code != http.StatusBadRequest || !strings.Contains(output.String(), "stage=request_validation_failed") { + t.Fatalf("status=%d log=%q body=%s", response.Code, output.String(), response.Body.String()) + } +} + func newDispatchTestHandler(t *testing.T, authenticator Authenticator, resolver Resolver, limit int64) http.Handler { t.Helper() - handler, err := NewDispatchHandler(authenticator, resolver, limit, time.Second) + handler, err := NewDispatchHandler(authenticator, resolver, limit, time.Second, dispatchTestLogger) if err != nil { t.Fatal(err) } @@ -1095,7 +1199,7 @@ func newDispatchTestHandler(t *testing.T, authenticator Authenticator, resolver func newDispatchTransportTestHandler(t *testing.T, authenticator Authenticator, resolver Resolver, transport NonStreamingTransport, limit int64) http.Handler { t.Helper() - handler, err := NewDispatchHandlerWithTransport(authenticator, resolver, transport, limit, time.Second) + handler, err := NewDispatchHandlerWithTransport(authenticator, resolver, transport, limit, time.Second, dispatchTestLogger) if err != nil { t.Fatal(err) } @@ -1106,7 +1210,7 @@ func newDispatchTransportTestHandler(t *testing.T, authenticator Authenticator, func newDispatchLedgerTestHandler(t *testing.T, authenticator Authenticator, resolver Resolver, transport NonStreamingTransport, ledger InvocationLedgerAppender, limit int64) http.Handler { t.Helper() - handler, err := NewDispatchHandlerWithTransportAndLedger(authenticator, resolver, transport, ledger, limit, time.Second) + handler, err := NewDispatchHandlerWithTransportAndLedger(authenticator, resolver, transport, ledger, limit, time.Second, dispatchTestLogger) if err != nil { t.Fatal(err) } 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..094c1cd --- /dev/null +++ b/apps/console/e2e/console.spec.ts @@ -0,0 +1,401 @@ +import {execFileSync} from 'node:child_process'; + +import {expect, test, type Locator, type Page} from '@playwright/test'; + +const providerId = required('VITE_NEKIRO_PROVIDER_ID'); +const apiBaseURL = required('VITE_NEKIRO_API_BASE_URL'); +const ownerToken = required('VITE_NEKIRO_OWNER_TOKEN'); +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.echo', +}; + +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'}})); + const jsonResponsePromise = page.waitForResponse((response) => response.url().includes('/v4/workspaces/' + workspaceId + '/invocations') && response.request().method() === 'POST' && (response.request().postData() ?? '').includes('"stream":false')); + await page.getByRole('button', {name: 'Invoke', exact: true}).click(); + const jsonResponse = await jsonResponsePromise; + const jsonResponseBody = await jsonResponse.text(); + if (jsonResponse.status() !== 200) await logInvocationTraceDiagnostic(page, jsonResponseBody); + expect(jsonResponse.status(), `JSON invocation response: ${summarizeResponse(jsonResponse.status(), jsonResponseBody)}`).toBe(200); + + 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 summarizeResponse(status: number, body: string): string { + let value: unknown; + try { + value = JSON.parse(body); + } catch { + return `status=${status}, body=non-json`; + } + if (!value || typeof value !== 'object' || Array.isArray(value)) { + return `status=${status}, body=non-object-json`; + } + const record = value as Record; + const keys = Object.keys(record).sort().join(','); + const safeFields = ['code', 'traceId', 'invocationId', 'rootTaskId'] + .filter((key) => typeof record[key] === 'string') + .map((key) => `${key}=${record[key] as string}`) + .join(','); + return `status=${status}, keys=${keys}${safeFields ? ', ' + safeFields : ''}`; +} + +async function logInvocationTraceDiagnostic(page: Page, body: string): Promise { + let value: unknown; + try { + value = JSON.parse(body); + } catch { + console.log('JSON invocation trace diagnostic: error_body=non-json'); + return; + } + if (!value || typeof value !== 'object' || Array.isArray(value)) { + console.log('JSON invocation trace diagnostic: error_body=non-object-json'); + return; + } + const error = value as Record; + const traceId = typeof error.traceId === 'string' ? error.traceId : ''; + if (!traceId) { + console.log('JSON invocation trace diagnostic: trace_id=missing'); + return; + } + try { + const response = await page.request.get(`${apiBaseURL}/v4/workspaces/${encodeURIComponent(workspaceId)}/traces/${encodeURIComponent(traceId)}`, { + headers: {Authorization: `Bearer ${ownerToken}`, Accept: 'application/json'}, + }); + const traceBody = await response.text(); + console.log(`JSON invocation trace diagnostic: ${summarizeTrace(response.status(), traceBody)}`); + } catch (diagnosticError) { + const errorName = diagnosticError instanceof Error ? diagnosticError.name : 'unknown'; + console.log(`JSON invocation trace diagnostic: request_error=${errorName}`); + } +} + +function summarizeTrace(status: number, body: string): string { + let value: unknown; + try { + value = JSON.parse(body); + } catch { + return `status=${status}, body=non-json`; + } + if (!value || typeof value !== 'object' || Array.isArray(value)) { + return `status=${status}, body=non-object-json`; + } + const record = value as Record; + const invocations = Array.isArray(record.invocations) ? record.invocations : []; + const states = invocations.filter((item): item is Record => Boolean(item && typeof item === 'object' && !Array.isArray(item))) + .map((item) => [ + diagnosticIdentifier(item.invocationId), + item.parentInvocationId === undefined ? '' : diagnosticIdentifier(item.parentInvocationId), + diagnosticIdentifier(item.targetAgentId), + diagnosticEnum(item.status, ['pending', 'routing', 'running', 'succeeded', 'failed', 'canceled', 'timed_out']), + item.errorCode === undefined ? '' : diagnosticEnum(item.errorCode, ['VALIDATION_ERROR', 'UNAUTHENTICATED', 'FORBIDDEN', 'NOT_FOUND', 'CONFLICT', 'NOT_ACCEPTABLE', 'PAYLOAD_TOO_LARGE', 'AGENT_NOT_INSTALLED', 'INSTALLATION_DISABLED', 'AGENT_DISABLED', 'AGENT_RELEASE_UNPUBLISHED', 'AGENT_RELEASE_SUSPENDED', 'AGENT_RELEASE_REVOKED', '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']), + ].join('/')) + .join(';'); + return `status=${status}, invocation_states=${states || 'none'}`; +} + +function diagnosticIdentifier(value: unknown): string { + return typeof value === 'string' && /^[A-Za-z0-9][A-Za-z0-9._:-]{0,127}$/.test(value) ? value : 'invalid'; +} + +function diagnosticEnum(value: unknown, allowed: string[]): string { + return typeof value === 'string' && allowed.includes(value) ? value : 'invalid'; +} + +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" /> + +