diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index df602d4..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 diff --git a/AGENTS.md b/AGENTS.md index ceb7a63..17b3224 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -3,9 +3,23 @@ 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 +`30322101411` passed seven workflow jobs plus the Codecov patch check (eight +reported checks). Slice C is delivered through upstream PR +#63 and Slice D is delivered through stacked upstream PR #64, satisfying the +independent-PR scope gate. The historical Slice A T005 ordering deviation +remains explicitly recorded in `specs/027-console-trusted-publication/tasks.md` +and is not retroactively claimed as satisfied. The older Spec 026-only status +paragraph below is retained as historical context and is superseded by this +current delivery record. + 本文件是整个仓库的长期项目宪章,适用于所有目录、模块和参与者。它记录稳定的产品目标、领域语言、架构边界和交付标准,不替代具体需求、API 文档或 ADR。 本文件应根据项目当前状态、已验证需求和正式架构决策持续自我更新迭代,但任何更新都必须说明原因与影响,并保持核心边界和兼容性变化清晰可追溯。 diff --git a/README.md b/README.md index 147154d..59708ef 100644 --- a/README.md +++ b/README.md @@ -59,14 +59,21 @@ 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 +`30322101411` passed seven workflow jobs plus the Codecov patch check (eight +reported checks), including the fresh Compose backend +acceptance and production `console-browser-acceptance`. The reverse backend +slice is tracked by [PR #63](https://github.com/NeKiro-project/NeKiro/pull/63) +and the stacked Console/CI integration by +[PR #64](https://github.com/NeKiro-project/NeKiro/pull/64). The standalone +Console source and its independently reviewed UI/browser PRs remain in +[NeKiro-Console](https://github.com/NeKiro-project/NeKiro-Console). The +historical Slice A T005 ordering deviation remains a process record, 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/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..2214f6d --- /dev/null +++ b/apps/console/e2e/console.spec.ts @@ -0,0 +1,424 @@ +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); + const traceResponsePromise = page.waitForResponse((response) => response.url().includes('/v4/workspaces/' + workspaceId + '/traces/' + result.traceId) && response.request().method() === 'GET'); + await page.getByRole('button', {name: 'Read', exact: true}).last().click(); + const traceResponse = await traceResponsePromise; + expect(traceResponse.status()).toBe(200); + const tracePayload = await traceResponse.json() as { + traceId: string; + invocations: Array<{invocationId: string; parentInvocationId?: string; rootTaskId: string; traceId: string; targetAgentId: string}>; + }; + const rootInvocation = tracePayload.invocations.find((invocation) => invocation.invocationId === result.invocationId); + const childInvocation = tracePayload.invocations.find((invocation) => invocation.targetAgentId === runtimeA.id && invocation.invocationId !== result.invocationId); + expect(tracePayload.traceId).toBe(result.traceId); + expect(rootInvocation).toBeDefined(); + expect(childInvocation).toBeDefined(); + expect(childInvocation?.parentInvocationId).toBe(rootInvocation?.invocationId); + expect(childInvocation?.rootTaskId).toBe(rootInvocation?.rootTaskId); + expect(childInvocation?.traceId).toBe(rootInvocation?.traceId); + 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); + + const gatewayOrigin = new URL(apiBaseURL).origin; + expect(apiRequests.length).toBeGreaterThan(0); + expect(apiRequests.every((url) => new URL(url).origin === gatewayOrigin)).toBe(true); + expect(requestUrls.some((url) => { + const parsed = new URL(url); + return /\/internal\/|\/agent\//.test(parsed.pathname) || parsed.hostname === 'runtime-a' || parsed.hostname === 'runtime-b'; + })).toBe(false); + + 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..844c15e --- /dev/null +++ b/apps/console/package.json @@ -0,0 +1,36 @@ +{ + "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", + "motion": "^12.23.24", + "react": "^19.0.1", + "react-dom": "^19.0.1", + "semver": "7.8.5" + }, + "devDependencies": { + "@playwright/test": "^1.52.0", + "@types/semver": "7.7.0", + "@types/node": "^22.14.0", + "autoprefixer": "^10.4.21", + "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..c9f1b79 --- /dev/null +++ b/apps/console/playwright.config.ts @@ -0,0 +1,33 @@ +import {defineConfig} from '@playwright/test'; + +const baseURL = process.env.NEKIRO_E2E_BASE_URL; +const previewOrigin = 'http://127.0.0.1:4173'; +if (!baseURL || baseURL !== previewOrigin) { + throw new Error(`NEKIRO_E2E_BASE_URL must equal the production preview origin ${previewOrigin}`); +} + +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..9c08b46 --- /dev/null +++ b/apps/console/src/App.tsx @@ -0,0 +1,437 @@ +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 activeWorkspaceRef = useRef(null); + activeWorkspaceRef.current = workspace; + 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 defaultWorkspaceInitialized = useRef(false); + + 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; + setAgents([]); + setCatalogReady(false); + 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; + setProviderAgents([]); + 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; + installationRequestGeneration.current = nextRequestGeneration(installationRequestGeneration.current); + setInstallations([]); + setInstallationLoading(false); + 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; + setInstallations([]); + 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(() => { + if (defaultWorkspaceInitialized.current) return; + const defaultWorkspaceId = import.meta.env.VITE_NEKIRO_DEFAULT_WORKSPACE_ID; + if (!defaultWorkspaceId) return; + defaultWorkspaceInitialized.current = true; + void loadWorkspace(defaultWorkspaceId).then((value) => value && loadInstallations(value.workspaceId)); + }, [loadInstallations, loadWorkspace]); + + const handleCreateWorkspace = async () => { + const generation = nextRequestGeneration(workspaceRequestGeneration.current); + workspaceRequestGeneration.current = generation; + installationRequestGeneration.current = nextRequestGeneration(installationRequestGeneration.current); + setInstallations([]); + setInstallationLoading(false); + setWorkspaceLoading(true); + setWorkspaceError(null); + try { + const value = await ownerClient.createWorkspace(workspaceDraft); + if (!isCurrentRequest(generation, workspaceRequestGeneration.current)) return; + setWorkspace(value); + setWorkspaceDraft(value.workspaceId); + await loadInstallations(value.workspaceId); + } catch (error) { + if (isCurrentRequest(generation, workspaceRequestGeneration.current)) { + setWorkspaceError(toPlatformErrorView(error, 'Unable to create Workspace.')); + } + } finally { + if (isCurrentRequest(generation, workspaceRequestGeneration.current)) 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 operationWorkspaceId = workspace.workspaceId; + const installation = await ownerClient.installAgent(operationWorkspaceId, { + agentId: agent.id, + versionConstraint: release.agentCardVersion, + acceptedPermissions, + }); + try { + validateTrustedInstallation(installation, release, agent.id); + } finally { + if (activeWorkspaceRef.current?.workspaceId === operationWorkspaceId) { + await loadInstallations(operationWorkspaceId); + } + } + }; + + const handleUpdateInstallation = async (installation: Installation, status: Exclude) => { + const operationWorkspaceId = workspace?.workspaceId; + const operationGeneration = workspaceRequestGeneration.current; + const operationInstallationGeneration = installationRequestGeneration.current; + if (!operationWorkspaceId) { + return; + } + setInstallationError(null); + try { + await ownerClient.updateInstallation(operationWorkspaceId, installation.installationId, status); + if (isCurrentRequest(operationGeneration, workspaceRequestGeneration.current) + && isCurrentRequest(operationInstallationGeneration, installationRequestGeneration.current) + && activeWorkspaceRef.current?.workspaceId === operationWorkspaceId) { + await loadInstallations(operationWorkspaceId); + } + } catch (error) { + if (isCurrentRequest(operationGeneration, workspaceRequestGeneration.current) + && isCurrentRequest(operationInstallationGeneration, installationRequestGeneration.current)) { + setInstallations([]); + setInstallationError(toPlatformErrorView(error, 'Unable to update Installation.')); + } + } + }; + + const handleUninstall = async (installation: Installation) => { + const operationWorkspaceId = workspace?.workspaceId; + const operationGeneration = workspaceRequestGeneration.current; + const operationInstallationGeneration = installationRequestGeneration.current; + if (!operationWorkspaceId) { + return false; + } + setInstallationError(null); + try { + await ownerClient.uninstallAgent(operationWorkspaceId, installation.installationId); + if (isCurrentRequest(operationGeneration, workspaceRequestGeneration.current) + && isCurrentRequest(operationInstallationGeneration, installationRequestGeneration.current) + && activeWorkspaceRef.current?.workspaceId === operationWorkspaceId) { + await loadInstallations(operationWorkspaceId); + } + return true; + } catch (error) { + if (isCurrentRequest(operationGeneration, workspaceRequestGeneration.current) + && isCurrentRequest(operationInstallationGeneration, installationRequestGeneration.current)) { + setInstallations([]); + 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..7f80b5a --- /dev/null +++ b/apps/console/src/api/nekiro.test.ts @@ -0,0 +1,746 @@ +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, headers: {'Content-Type': 'application/json'}}); + }, + }); + + 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, headers: {'Content-Type': 'application/json'}}), + }); + 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, headers: {'Content-Type': 'application/json'}})}); + 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, acceptedPermissions: ['READ LOGS']}; + await assert.rejects(() => client.getInstallation('workspace.alpha', 'installation-1'), /safe identifier/); + 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 evaluates the active SemVer range forms and prerelease rule', async () => { + const base = { + installationId: 'installation-1', workspaceId: 'workspace.alpha', agentId: 'agent.echo', versionConstraint: '1.2.3', installedVersion: '1.2.3', acceptedPermissions: [], 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, headers: {'Content-Type': 'application/json'}})}); + for (const [versionConstraint, installedVersion, expected] of [ + ['1.2.3 - 2.3', '2.3.9', true], + ['~1.2.3', '1.2.9', true], + ['^0.2.3', '0.3.0', false], + ['>= 1.2.3 < 2.0.0', '1.5.0', true], + ['>= 1.2.3 < 2.0.0', '1.2.3-alpha', false], + ['>= 1.2.3-alpha < 2.0.0', '1.2.3-beta', true], + ['1.2.x || 2.0.0', '2.0.0', true], + ['>=0-0', '0.0.0-alpha', true], + ['>=0.0-0', '0.0.0-alpha', true], + ['~1.1-alpha', '1.1.5', true], + ['>=1.2.3-alpha < 2.0.0', '1.3.0-alpha', false], + ['!=1.x', '1.2.3', false], + ['!=1.x', '2.0.0', true], + ['!=1.2.x', '1.2.9', false], + ['!=1.2.x', '1.3.0', true], + ['1.2.3 ||', '1.2.3', false], + ['1.2.3,,2.0.0', '1.2.3', false], + ['01.2.3', '1.2.3', false], + ['999999999999999999999999.1.1', '1.2.3', false], + ['9007199254740992.0.0', '9007199254740992.0.0', true], + ['>=9007199254740992.0.0 <9007199254740994.0.0', '9007199254740993.0.0', true], + ['>=9007199254740992.0.0 <9007199254740994.0.0', '9007199254740994.0.0', false], + ['^9007199254740992.0.0', '9007199254740992.1.0', true], + ['^9007199254740992.0.0', '9007199254740993.0.0', false], + ['9007199254740991.0.0', '9007199254740991.0.0', true], + ['^9007199254740991.0.0', '9007199254740991.0.1', true], + ['^9007199254740991.0.0', '9007199254740992.0.0', false], + ['9007199254740991.x', '9007199254740991.1.0', true], + ['9007199254740991.x', '9007199254740992.0.0', false], + ['18446744073709551615.0.0', '18446744073709551615.0.0', true], + ['18446744073709551616.0.0', '18446744073709551616.0.0', false], + ] as const) { + response = {...base, versionConstraint, installedVersion}; + if (expected) { + await assert.doesNotReject(() => client.getInstallation('workspace.alpha', 'installation-1')); + } else { + await assert.rejects(() => client.getInstallation('workspace.alpha', 'installation-1'), /does not satisfy/); + } + } +}); + +test('NekiroApiClient enforces Agent Card semantic rules on Catalog responses', async () => { + let card = catalogCard(); + const client = new NekiroApiClient({ + baseUrl: 'https://api.example.test', + token: 'owner-token', + fetchImpl: async () => new Response(JSON.stringify({items: [{card, publicationStatus: 'published', registeredAt: '2026-07-26T00:00:00Z'}]}), {status: 200, headers: {'Content-Type': 'application/json'}}), + }); + assert.equal((await client.searchAgents()).items.length, 1); + + card = {...catalogCard(), skills: []}; + await assert.rejects(() => client.searchAgents(), /skills must contain at least one/); + card = {...catalogCard(), skills: [catalogCard().skills[0], {...catalogCard().skills[0], id: 'runtime.echo'}]}; + await assert.rejects(() => client.searchAgents(), /duplicate skill id/); + card = {...catalogCard(), permissions: [{id: 'READ_LOGS', description: 'Read logs.'}, {id: 'READ_LOGS', description: 'Duplicate.'}]}; + await assert.rejects(() => client.searchAgents(), /duplicate permission id/); + card = {...catalogCard(), skills: [{...catalogCard().skills[0], requiredPermissions: ['MISSING']}]}; + await assert.rejects(() => client.searchAgents(), /not declared/); + card = {...catalogCard(), name: 'x'.repeat(121)}; + await assert.rejects(() => client.searchAgents(), /non-empty string/); +}); + +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, headers: {'Content-Type': 'application/json'}}); + }, + }); + 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('NekiroApiClient rejects Installation lifecycle responses that change immutable pins', async () => { + const initial = { + installationId: 'installation-1', workspaceId: 'workspace.alpha', agentId: 'agent.echo', versionConstraint: '^1.0.0', installedVersion: '1.2.3', installedReleaseId: 'release-1', acceptedPermissions: [], status: 'enabled', installedAt: '2026-07-26T00:00:00Z', updatedAt: '2026-07-26T00:00:00Z', + }; + const mutations = [ + {...initial, versionConstraint: '^1.1.0'}, + {...initial, installedVersion: '1.3.0'}, + {...initial, acceptedPermissions: ['READ_LOGS']}, + {...initial, installedReleaseId: 'release-2'}, + ]; + for (const operation of ['update', 'uninstall'] as const) { + for (const mutated of mutations) { + let call = 0; + const client = new NekiroApiClient({ + baseUrl: 'https://api.example.test', + token: 'owner-token', + fetchImpl: async () => new Response(JSON.stringify(call++ === 0 ? initial : mutated), {status: 200, headers: {'Content-Type': 'application/json'}}), + }); + const request = operation === 'update' + ? client.updateInstallation('workspace.alpha', 'installation-1', 'disabled') + : client.uninstallAgent('workspace.alpha', 'installation-1'); + await assert.rejects(() => request, /immutable pin fields/); + } + } +}); + +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, headers: {'Content-Type': 'application/json'}}); + }, + }); + 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, headers: {'Content-Type': 'application/json'}}), + }); + 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, headers: {'Content-Type': 'application/json'}}); + }}); + 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, headers: {'Content-Type': 'application/json'}})}); + 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 accepts standard SSE comments, fields, and multiline data framing', 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 completedJSON = JSON.stringify(completed); + const splitAt = completedJSON.indexOf(',"rootTaskId"'); + const body = `: keep-alive\nretry: 1000\nevent: result\ndata: ${JSON.stringify(accepted)}\n\ndata: ${completedJSON.slice(0, splitAt)}\ndata: ${completedJSON.slice(splitAt)}\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 events = await client.invokeStream('workspace.alpha', {agentId: 'runtime.echo', capability: 'runtime.echo', input: {}}); + assert.deepEqual(events.map((event) => event.type), ['accepted', 'completed']); +}); + +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(), /invalid JSON success response/); +}); + +test('NekiroApiClient rejects Gateway redirects for regular requests', async () => { + let redirect: RequestRedirect | undefined; + const client = new NekiroApiClient({ + baseUrl: 'https://api.example.test', + token: 'test-token', + fetchImpl: async (_input, init) => { + redirect = init?.redirect; + return new Response(JSON.stringify({items: []}), {status: 200, headers: {'Content-Type': 'application/json'}}); + }, + }); + await client.searchAgents(); + assert.equal(redirect, 'error'); +}); + +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'}), + /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 catalogCard(): AgentCardV02 { + return { + schemaVersion: '0.2', + agentId: 'agent.echo', + name: 'Echo Agent', + description: 'Echoes structured input.', + owner: {id: 'provider.main', displayName: 'Provider Main'}, + version: '1.2.3', + protocol: {type: 'a2a', version: '0.3.0', transport: 'JSONRPC', endpoint: 'https://agent.example/a2a'}, + skills: [{id: 'runtime.echo', name: 'Echo', description: 'Echo input.', inputSchema: {type: 'object'}, outputSchema: {type: 'object'}, requiredPermissions: ['READ_LOGS']}], + authentication: {type: 'none'}, + permissions: [{id: 'READ_LOGS', description: 'Read logs.'}], + limits, + }; +} + +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..2f81b5f --- /dev/null +++ b/apps/console/src/api/nekiro.ts @@ -0,0 +1,1735 @@ +import {satisfies as semverSatisfies, valid as semverValid, validRange as semverValidRange} from 'semver'; + +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' | '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'; + +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).then((value) => validateCatalogSearchResponse(value)); + } + + registerAgent(card: AgentCardV02): Promise { + return this.request('/v3/agents', { + method: 'POST', + body: JSON.stringify({card}), + }, 201).then((value) => validateCatalogEntry(value)); + } + + getAgentVersion(agentId: string, version: string): Promise { + return this.request(this.versionPath(agentId, version)).then((value) => validateCatalogEntry(value)); + } + + publishAgentVersion(agentId: string, version: string): Promise { + return this.request(this.versionPath(agentId, version) + '/publish', {method: 'POST'}).then((value) => validateCatalogEntry(value)); + } + + disableAgentVersion(agentId: string, version: string): Promise { + return this.request(this.versionPath(agentId, version) + '/disable', {method: 'POST'}).then((value) => validateCatalogEntry(value)); + } + + 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: readIdentifier(workspaceId, 'workspaceId')}), + }, 201).then((value) => validateWorkspace(value)); + } + + getWorkspace(workspaceId: string): Promise { + return this.request('/v3/workspaces/' + encodeURIComponent(readIdentifier(workspaceId, 'workspaceId'))).then((value) => validateWorkspace(value)); + } + + installAgent(workspaceId: string, request: InstallAgentRequest): Promise { + return this.request(this.workspaceInstallationPath(workspaceId), { + method: 'POST', + body: JSON.stringify({ + agentId: readIdentifier(request.agentId, 'agentId'), + versionConstraint: readText(request.versionConstraint, 'versionConstraint'), + acceptedPermissions: request.acceptedPermissions, + }), + }, 201).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)); + } + + async updateInstallation(workspaceId: string, installationId: string, status: Exclude): Promise { + const previous = await this.getInstallation(workspaceId, installationId); + const value = await this.request(this.installationPath(workspaceId, installationId), { + method: 'PATCH', + body: JSON.stringify({status}), + }).then((response) => validateInstallation(response, workspaceId)); + return validateInstallationLifecycleResponse(value, previous); + } + + async uninstallAgent(workspaceId: string, installationId: string): Promise { + const previous = await this.getInstallation(workspaceId, installationId); + const value = await this.request(this.installationPath(workspaceId, installationId), {method: 'DELETE'}).then((response) => validateInstallation(response, workspaceId)); + return validateInstallationLifecycleResponse(value, previous); + } + + 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 dataLines: string[] = []; + let expectedSequence = 0; + let expectedChunkIndex = 0; + let terminal = false; + const events: InvocationResultStreamEventV2[] = []; + const consume = (data: string) => { + const event = validateResultStreamEvent(parseJsonValue(data, '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); + }; + const dispatchEvent = () => { + if (dataLines.length === 0) return; + const data = dataLines.join('\n'); + dataLines = []; + consume(data); + }; + const consumeLine = (line: string) => { + if (line === '') { + dispatchEvent(); + return; + } + if (line.startsWith(':')) return; + const separator = line.indexOf(':'); + if (separator < 0) throw new NekiroApiError(response.status, 'NeKiro stream contains an invalid field.', 'INVALID_RESPONSE'); + const field = line.slice(0, separator); + let value = line.slice(separator + 1); + if (value.startsWith(' ')) value = value.slice(1); + if (field === 'data') { + dataLines.push(value); + } else if (field !== 'event' && field !== 'id' && field !== 'retry') { + throw new NekiroApiError(response.status, 'NeKiro stream contains an invalid field.', 'INVALID_RESPONSE'); + } + }; + 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); + consumeLine(line); + newline = buffer.indexOf('\n'); + } + if (result.done) break; + } + if (buffer !== '') consumeLine(buffer.replace(/\r$/, '')); + dispatchEvent(); + 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(readIdentifier(workspaceId, 'workspaceId')) + '/installations'; + } + + private installationPath(workspaceId: string, installationId: string): string { + return this.workspaceInstallationPath(workspaceId) + '/' + encodeURIComponent(readIdentifier(installationId, 'installationId')); + } + + private invocationPath(workspaceId: string): string { + return '/v4/workspaces/' + encodeURIComponent(readIdentifier(workspaceId, 'workspaceId')) + '/invocations'; + } + + private tracePath(workspaceId: string, traceId: string): string { + return '/v4/workspaces/' + encodeURIComponent(readIdentifier(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 = {}, expectedStatus = 200): 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 !== expectedStatus) { + throw new NekiroApiError(response.status, 'NeKiro Control Plane API returned an unexpected HTTP status.', 'INVALID_RESPONSE'); + } + if (response.status === 204) { + return undefined as T; + } + const mediaType = response.headers.get('content-type')?.split(';', 1)[0].trim(); + if (responseText.length === 0 || mediaType !== 'application/json' || payload === undefined) { + throw new NekiroApiError(response.status, 'NeKiro Control Plane API returned an invalid JSON success response.', '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, redirect: 'error'}); + } catch { + throw new NekiroApiError(0, 'NeKiro API request failed.', 'NETWORK_ERROR'); + } + } + + private async errorFromResponse(response: Response, knownPayload?: unknown): Promise { + if (response.headers.get('content-type')?.split(';', 1)[0].trim() !== 'application/json') { + return new NekiroApiError(response.status, 'NeKiro Control Plane API returned an invalid Platform Error payload.', 'INVALID_RESPONSE'); + } + 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 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 Control Plane API returned inconsistent trace correlation.', '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.', + AGENT_RELEASE_UNPUBLISHED: 'The Agent release is not published.', + AGENT_RELEASE_SUSPENDED: 'The Agent release is suspended.', + AGENT_RELEASE_REVOKED: 'The Agent release is revoked.', + 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 || hasUriUserinfo(value)) 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 || hasUriUserinfo(endpoint)) { + 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) throw new Error('limits.maxInputBytes must be a positive integer'); + if (!Number.isInteger(value.maxOutputBytes) || value.maxOutputBytes < 1) throw new Error('limits.maxOutputBytes must be a positive integer'); + 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 + ']'); +} + +function validateCatalogEntry(value: unknown): CatalogEntry { + const record = requireRecord(value, 'Catalog entry'); + assertAllowedKeys(record, ['card', 'publicationStatus', 'registeredAt', 'publishedAt'], 'Catalog entry'); + const result: CatalogEntry = { + card: validateCatalogCard(record.card), + publicationStatus: requireEnum(record.publicationStatus, ['draft', 'published', 'disabled'], 'publicationStatus') as PublicationStatus, + registeredAt: requireDateValue(record.registeredAt, 'registeredAt'), + }; + if ('publishedAt' in record) result.publishedAt = requireDateValue(record.publishedAt, 'publishedAt'); + return result; +} + +function validateCatalogSearchResponse(value: unknown): CatalogSearchResponse { + const record = requireRecord(value, 'Catalog search response'); + assertAllowedKeys(record, ['items', 'nextCursor'], 'Catalog search response'); + if (!Array.isArray(record.items)) throw new Error('Catalog search items must be an array'); + const result: CatalogSearchResponse = {items: record.items.map((item) => validateCatalogEntry(item))}; + if ('nextCursor' in record) result.nextCursor = readText(record.nextCursor, 'nextCursor'); + return result; +} + +function validateCatalogCard(value: unknown): AgentCardV02 { + const record = requireRecord(value, 'Agent Card'); + assertAllowedKeys(record, ['schemaVersion', 'agentId', 'name', 'description', 'owner', 'version', 'protocol', 'skills', 'authentication', 'permissions', 'limits'], 'Agent Card'); + if (record.schemaVersion !== '0.2') throw new Error('Agent Card schemaVersion is invalid'); + const owner = requireRecord(record.owner, 'Agent Card owner'); + assertAllowedKeys(owner, ['id', 'displayName'], 'Agent Card owner'); + const protocol = requireRecord(record.protocol, 'Agent Card protocol'); + assertAllowedKeys(protocol, ['type', 'version', 'transport', 'endpoint'], 'Agent Card protocol'); + if (protocol.type !== 'a2a' || protocol.version !== '0.3.0' || protocol.transport !== 'JSONRPC') throw new Error('Agent Card protocol is invalid'); + const authentication = requireRecord(record.authentication, 'Agent Card authentication'); + assertAllowedKeys(authentication, ['type'], 'Agent Card authentication'); + const permissions = readRecordArray(record.permissions, '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 skillRecords = readRecordArray(record.skills, 'skills'); + if (skillRecords.length === 0) throw new Error('skills must contain at least one skill'); + const skillIds = new Set(); + const skills = skillRecords.map((skill, index) => { + assertAllowedKeys(skill, ['id', 'name', 'description', 'inputSchema', 'outputSchema', 'requiredPermissions'], `skills[${index}]`); + const id = readIdentifier(skill.id, `skills[${index}].id`); + if (skillIds.has(id)) throw new Error('duplicate skill id: ' + id); + skillIds.add(id); + const requiredPermissions = readStringArray(skill.requiredPermissions, `skills[${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(skill.name, `skills[${index}].name`, 120), + description: readText(skill.description, `skills[${index}].description`, 2000), + inputSchema: requireRecord(skill.inputSchema, `skills[${index}].inputSchema`), + outputSchema: requireRecord(skill.outputSchema, `skills[${index}].outputSchema`), + requiredPermissions, + }; + }); + validateAgentLimits(record.limits as AgentCardV02['limits']); + return { + schemaVersion: '0.2', + agentId: readIdentifier(record.agentId, 'agentId'), + name: readText(record.name, 'name', 120), + description: readText(record.description, 'description', 4000), + owner: {id: readIdentifier(owner.id, 'owner.id'), displayName: readText(owner.displayName, 'owner.displayName', 120)}, + version: requireSemver(record.version, 'version'), + protocol: {type: 'a2a', version: '0.3.0', transport: 'JSONRPC', endpoint: requireHttpUri(readText(protocol.endpoint, 'protocol.endpoint', 2048), 'protocol.endpoint')}, + skills, + authentication: {type: requireEnum(authentication.type, ['none', 'api_key', 'http_bearer', 'oauth2_client_credentials', 'mutual_tls'], 'authentication.type') as AuthenticationType}, + permissions, + limits: record.limits as AgentCardV02['limits'], + }; +} + +function validateWorkspace(value: unknown): Workspace { + const record = requireRecord(value, 'Workspace'); + assertAllowedKeys(record, ['workspaceId', 'ownerId', 'createdAt', 'updatedAt'], 'Workspace'); + return { + workspaceId: readIdentifier(record.workspaceId, 'workspaceId'), + ownerId: readIdentifier(record.ownerId, 'ownerId'), + createdAt: requireDateValue(record.createdAt, 'createdAt'), + updatedAt: requireDateValue(record.updatedAt, 'updatedAt'), + }; +} + +function readRecordArray(value: unknown, field: string): Record[] { + if (!Array.isArray(value) || !value.every((item) => isRecord(item))) throw new Error(field + ' must be an array of JSON objects'); + return value; +} + +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').map((permission, index) => readIdentifier(permission, `acceptedPermissions[${index}]`)); + 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 validateInstallationLifecycleResponse(value: Installation, previous: Installation): Installation { + const samePermissions = value.acceptedPermissions.length === previous.acceptedPermissions.length + && value.acceptedPermissions.every((permission, index) => permission === previous.acceptedPermissions[index]); + if (value.installationId !== previous.installationId + || value.workspaceId !== previous.workspaceId + || value.agentId !== previous.agentId + || value.versionConstraint !== previous.versionConstraint + || value.installedVersion !== previous.installedVersion + || !samePermissions + || value.installedReleaseId !== previous.installedReleaseId) { + throw new NekiroApiError(200, 'NeKiro Installation lifecycle response changed immutable pin fields.', 'INVALID_RESPONSE'); + } + return value; +} + +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: fallbackMessage, + }; +} + +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(':'); +} + +function hasUriUserinfo(value: string): boolean { + const authority = /^[A-Za-z][A-Za-z0-9+.-]*:\/\/([^/?#]*)/.exec(value)?.[1]; + return authority?.includes('@') ?? false; +} + +function satisfiesSemverRange(version: string, range: string): boolean { + // Masterminds/semver accepts backend aliases and partial prerelease tokens; + // keep those syntax adapters narrow and delegate comparison semantics to the + // maintained npm parser, which rejects non-canonical numeric components. + const normalizedNumbers = normalizeLargeSemverNumbers(version, range); + if (!normalizedNumbers || semverValid(normalizedNumbers.version) === null) return false; + version = normalizedNumbers.version; + range = normalizedNumbers.range; + if (range.length > 512) return false; + const branches = range.split('||'); + if (branches.length > 32) return false; + const parsedBranches = branches.map((branch) => parseSemverBranch(branch)); + if (parsedBranches.some((branch) => branch === undefined)) return false; + return parsedBranches.some((branch) => branch !== undefined && satisfiesSemverBranch(version, branch)); +} + +const MAX_UINT64 = 18446744073709551615n; +const MAX_SAFE_INTEGER_BIGINT = BigInt(Number.MAX_SAFE_INTEGER); + +interface SemverTokenParts { + prefix: string; + major: string; + minor?: string; + patch?: string; + prerelease?: string; + build?: string; +} + +interface SemverNumericMaps { + core: [Map, Map, Map]; + prerelease: Map>; +} + +function normalizeLargeSemverNumbers(version: string, range: string): {version: string; range: string} | undefined { + const normalizedRange = range.replace(/=>/g, '>=').replace(/= map.set('0', '0')); + const needsCoreMapping = maps.core.map((map) => finalizeSemverNumberMap(map)).some(Boolean); + const needsPrereleaseMapping = [...maps.prerelease.values()].map((map) => finalizeSemverNumberMap(map)).some(Boolean); + const needsMapping = needsCoreMapping || needsPrereleaseMapping; + if (!needsMapping) return {version, range}; + return { + version: replaceLargeSemverTokenNumbers(version, maps), + range: replaceLargeSemverTokenNumbers(normalizePartialPrerelease(normalizedRange), maps), + }; +} + +function collectSemverTokenNumbers(value: string, maps: SemverNumericMaps): boolean { + const tokenPattern = /(^|[\s,|])((?:>=|<=|!=|=>|=<|>|<|=|~>|~|\^)?)(v?(?:\d+|[xX*])(?:\.(?:\d+|[xX*]))?(?:\.(?:\d+|[xX*]))?(?:-[0-9A-Za-z-]+(?:\.[0-9A-Za-z-]+)*)?(?:\+[0-9A-Za-z-]+(?:\.[0-9A-Za-z-]+)*)?)(?=$|[\s,|])/g; + let match: RegExpExecArray | null; + while ((match = tokenPattern.exec(value)) !== null) { + const token = parseSemverTokenParts(match[3]); + if (!token) continue; + for (const [index, component] of [token.major, token.minor, token.patch].entries()) { + if (component === undefined || isSemverWildcard(component)) continue; + if (!recordSemverNumber(component, maps.core[index])) return false; + } + if (token.prerelease) { + for (const [index, identifier] of token.prerelease.split('.').entries()) { + if (/^0\d/.test(identifier)) return false; + if (/^\d+$/.test(identifier) && !recordSemverNumber(identifier, getPrereleaseMap(maps, index))) return false; + } + } + } + return true; +} + +function recordSemverNumber(value: string, map: Map): boolean { + if (/^0\d/.test(value)) return false; + try { + const numeric = BigInt(value); + if (numeric > MAX_UINT64) return false; + } catch { + return false; + } + map.set(value, value); + return true; +} + +function finalizeSemverNumberMap(map: Map): boolean { + const values = [...map.keys()]; + const needsMapping = values.some((value) => BigInt(value) >= MAX_SAFE_INTEGER_BIGINT); + if (!needsMapping) return false; + values.sort((left, right) => (BigInt(left) < BigInt(right) ? -1 : BigInt(left) > BigInt(right) ? 1 : 0)); + values.forEach((value, index) => map.set(value, String(index))); + return true; +} + +function replaceLargeSemverTokenNumbers(value: string, maps: SemverNumericMaps): string { + const tokenPattern = /(^|[\s,|])((?:>=|<=|!=|=>|=<|>|<|=|~>|~|\^)?)(v?(?:\d+|[xX*])(?:\.(?:\d+|[xX*]))?(?:\.(?:\d+|[xX*]))?(?:-[0-9A-Za-z-]+(?:\.[0-9A-Za-z-]+)*)?(?:\+[0-9A-Za-z-]+(?:\.[0-9A-Za-z-]+)*)?)(?=$|[\s,|])/g; + return value.replace(tokenPattern, (_match, prefix: string, operator: string, tokenValue: string) => prefix + operator + renderSemverToken(tokenValue, maps)); +} + +function renderSemverToken(value: string, maps: SemverNumericMaps): string { + const token = parseSemverTokenParts(value); + if (!token) return value; + const core = [token.major, token.minor, token.patch].map((component, index) => component === undefined || isSemverWildcard(component) ? component : maps.core[index].get(component) ?? component); + const prerelease = token.prerelease?.split('.').map((identifier, index) => /^\d+$/.test(identifier) ? getPrereleaseMap(maps, index).get(identifier) ?? identifier : identifier).join('.'); + return token.prefix + core[0] + (core[1] === undefined ? '' : '.' + core[1]) + (core[2] === undefined ? '' : '.' + core[2]) + (prerelease === undefined ? '' : '-' + prerelease) + (token.build === undefined ? '' : '+' + token.build); +} + +function parseSemverTokenParts(value: string): SemverTokenParts | undefined { + const match = /^(v?)(\d+|[xX*])(?:\.(\d+|[xX*]))?(?:\.(\d+|[xX*]))?(?:-([0-9A-Za-z-]+(?:\.[0-9A-Za-z-]+)*))?(?:\+([0-9A-Za-z-]+(?:\.[0-9A-Za-z-]+)*))?$/.exec(value); + if (!match) return undefined; + return {prefix: match[1], major: match[2], minor: match[3], patch: match[4], prerelease: match[5], build: match[6]}; +} + +function getPrereleaseMap(maps: SemverNumericMaps, index: number): Map { + const existing = maps.prerelease.get(index); + if (existing) return existing; + const created = new Map(); + maps.prerelease.set(index, created); + return created; +} + +interface SemverRangeBranch { + baseRange: string; + exclusions: string[]; +} + +function satisfiesSemverBranch(version: string, branch: SemverRangeBranch): boolean { + if (!semverSatisfies(version, branch.baseRange)) return false; + return branch.exclusions.every((exclusion) => !semverSatisfies(version, exclusion)); +} + +function parseSemverBranch(branch: string): SemverRangeBranch | undefined { + const normalized = normalizeSemverRangeSyntax(branch); + if (!normalized || normalized.split(',').some((part) => part.trim() === '')) return undefined; + const rawTokens = normalized.split(/[\s,]+/).filter(Boolean); + if (rawTokens.length === 0) return undefined; + const baseTokens: string[] = []; + const exclusions: string[] = []; + for (let index = 0; index < rawTokens.length; index += 1) { + let token = rawTokens[index]; + if (token === '!=') { + token = rawTokens[++index] ?? ''; + if (!token) return undefined; + const exclusion = semverExclusionRange(token); + if (!exclusion) return undefined; + exclusions.push(exclusion.range); + continue; + } + if (token.startsWith('!=')) { + const exclusion = semverExclusionRange(token.slice(2)); + if (!exclusion) return undefined; + exclusions.push(exclusion.range); + continue; + } + baseTokens.push(token); + } + const baseRange = semverValidRange(normalizePartialPrerelease(baseTokens.join(' ') || '*'), {loose: false}); + if (baseRange === null) return undefined; + return {baseRange, exclusions}; +} + +function normalizeSemverRangeSyntax(value: string): string | undefined { + const trimmed = value.trim(); + if (trimmed.length === 0) return undefined; + return trimmed.replace(/=>/g, '>=').replace(/==${major}.0.0 <${major + 1}.0.0` + : patchWildcard + ? `>=${major}.${minor}.0 <${major}.${minor + 1}.0` + : normalized; + return {range}; +} + +function normalizePartialPrerelease(value: string): string { + return value.replace(/(^|[\s,])((?:>=|<=|!=|=>|=<|>|<|=|~>|~|\^)?)(v?)(\d+|[xX*])(?:\.(\d+|[xX*]))?(?:\.(\d+|[xX*]))?(-[0-9A-Za-z-]+(?:\.[0-9A-Za-z-]+)*)(?=$|[\s,])/g, (_match, prefix: string, operator: string, versionPrefix: string, major: string, minor: string | undefined, patch: string | undefined, prerelease: string) => { + return prefix + operator + versionPrefix + major + '.' + (minor ?? '0') + '.' + (patch ?? '0') + prerelease; + }); +} + +function isSemverWildcard(value: string | undefined): boolean { + return value === undefined || value === 'x' || value === 'X' || value === '*'; +} + +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..280b39e --- /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..cf5025d --- /dev/null +++ b/apps/console/src/components/InstallationsTab.tsx @@ -0,0 +1,300 @@ +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); + setPreflightLoading(false); + }; + + useEffect(() => { + if (selectedAgentKey && !publishedAgents.some((agent) => agentKey(agent) === selectedAgentKey)) { + invalidatePreflight(); + setSelectedAgentKey(''); + setVersionConstraint(''); + setAcceptedPermissions([]); + setReleaseId(''); + setLocalError(null); + } + }, [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([]); + 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..e15fd17 --- /dev/null +++ b/apps/console/src/components/InvocationsTab.tsx @@ -0,0 +1,172 @@ +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)) { + requestGeneration.current = nextRequestGeneration(requestGeneration.current); + setLoading(false); + setResult(null); + setEvents([]); + setError(null); + 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" /> + +