diff --git a/.dockerignore b/.dockerignore new file mode 100644 index 0000000..964409f --- /dev/null +++ b/.dockerignore @@ -0,0 +1,6 @@ +.git +.github +dist +node_modules +playwright-report +test-results diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml new file mode 100644 index 0000000..ce13e23 --- /dev/null +++ b/.github/workflows/release.yml @@ -0,0 +1,87 @@ +name: Release + +on: + push: + tags: + - 'v*' + +permissions: + contents: write + packages: write + +jobs: + release: + runs-on: ubuntu-latest + timeout-minutes: 30 + steps: + - name: Check out the tagged revision + uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2 + with: + fetch-depth: 0 + + - name: Verify annotated tag and package version + shell: bash + env: + RELEASE_TAG: ${{ github.ref_name }} + run: | + test "$(git cat-file -t "$RELEASE_TAG")" = tag + test "$(git rev-list -n 1 "$RELEASE_TAG")" = "$GITHUB_SHA" + test "v$(node -p "require('./package.json').version")" = "$RELEASE_TAG" + + - name: Install pnpm + uses: pnpm/action-setup@a7487c7e89a18df4991f7f222e4898a00d66ddda # v4.1.0 + with: + version: 11.3.0 + run_install: false + + - name: Set up Node.js + uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4.4.0 + with: + node-version: 24.16.0 + cache: pnpm + cache-dependency-path: pnpm-lock.yaml + + - name: Verify release source + run: | + pnpm install --frozen-lockfile + pnpm typecheck + pnpm test + pnpm build + + - name: Authenticate to GHCR + shell: bash + env: + GH_TOKEN: ${{ github.token }} + run: echo "$GH_TOKEN" | docker login ghcr.io -u "${{ github.actor }}" --password-stdin + + - name: Build and publish immutable Console image + shell: bash + env: + IMAGE: ghcr.io/nekiro-project/nekiro-console + RELEASE_TAG: ${{ github.ref_name }} + run: | + docker buildx create --use + docker buildx build \ + --platform linux/amd64,linux/arm64 \ + --provenance=mode=max \ + --sbom=true \ + --metadata-file image-metadata.json \ + --tag "$IMAGE:$RELEASE_TAG" \ + --push . + digest="$(jq -r '.["containerimage.digest"]' image-metadata.json)" + [[ "$digest" =~ ^sha256:[0-9a-f]{64}$ ]] + jq -n \ + --arg tag "$RELEASE_TAG" \ + --arg commit "$GITHUB_SHA" \ + --arg image "$IMAGE:$RELEASE_TAG" \ + --arg digest "$digest" \ + --arg platformApiVersion v1 \ + '{schemaVersion:"1",tag:$tag,commitSha:$commit,platformApiVersion:$platformApiVersion,images:{console:{reference:$image,digest:$digest}}}' > images.json + sha256sum images.json > checksums.txt + + - name: Publish GitHub Release + shell: bash + env: + GH_TOKEN: ${{ github.token }} + RELEASE_TAG: ${{ github.ref_name }} + run: gh release create "$RELEASE_TAG" images.json checksums.txt --verify-tag --generate-notes --title "NeKiro Console $RELEASE_TAG" diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000..fefcfc3 --- /dev/null +++ b/Dockerfile @@ -0,0 +1,16 @@ +FROM node:24.16.0-alpine AS build +WORKDIR /workspace +RUN corepack enable +COPY package.json pnpm-lock.yaml ./ +RUN pnpm install --frozen-lockfile +COPY . . +RUN pnpm run build + +FROM node:24.16.0-alpine +ENV NODE_ENV=production +WORKDIR /app +COPY --from=build --chown=node:node /workspace/dist ./dist +COPY --chown=node:node server.mjs ./server.mjs +USER node +EXPOSE 8080 +ENTRYPOINT ["node", "server.mjs"] diff --git a/README.md b/README.md index 080f95b..c7fcb65 100644 --- a/README.md +++ b/README.md @@ -7,6 +7,8 @@ repository does not maintain a production UI copy after the repository split. The Console talks only to the NeKiro Gateway. It supports trusted Agent publication, public Agent share URLs, Catalog discovery, exact Release installation, managed JSON/SSE invocation, and Workspace-scoped Ledger reads. +Every Gateway request uses Platform API `/v1`; the Console does not probe or +fall back to retired `/v2`, `/v3`, or `/v4` paths. The authenticated Console presents those capabilities as one guided journey: @@ -36,6 +38,18 @@ Provider and Workspace credentials are sent only as authorization headers and are not written to browser storage. Missing, blank, whitespace-padded, or otherwise invalid required configuration fails at startup. +The production image reads the same names at container startup, so one image +can be promoted without rebuilding browser assets. It also requires an +explicit listen address: + +```text +NEKIRO_CONSOLE_LISTEN_ADDRESS=0.0.0.0:8080 +``` + +`GET /readyz` reports readiness. `/config.js` is generated in memory with +`no-store`; credentials are never printed by the server. These browser tokens +are evaluation/operator credentials, not a substitute for Gateway policy. + ## Development ```text @@ -58,6 +72,13 @@ A successful local verification has all of these observable results: - The Node test runner reports no failed tests. - Vite exits with code `0` and creates the production `dist/` directory. +## Releases + +Annotated semantic tags publish the multi-architecture Console image to GHCR +and attach its immutable digest and checksum to the GitHub Release. The tag +must equal the version in `package.json`. The v0.1 release line supports only +Platform API `/v1`; old route probing is intentionally absent. + The Playwright suite is intentionally not a standalone mock test. It requires the exact Core, Samples, and Stack environment prepared by NeKiro-Stack: diff --git a/e2e/console.spec.ts b/e2e/console.spec.ts index 7f070d6..f224152 100644 --- a/e2e/console.spec.ts +++ b/e2e/console.spec.ts @@ -71,7 +71,7 @@ test('production Console completes trusted publication, invocation, trace, and i 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()); + if (/\/v1\//.test(request.url())) apiRequests.push(request.url()); }); page.on('console', (message) => consoleMessages.push(message.text())); @@ -90,7 +90,7 @@ test('production Console completes trusted publication, invocation, trace, and i const ownerCatalogResponsePromise = page.waitForResponse((response) => { const url = new URL(response.url()); - return response.request().method() === 'GET' && url.pathname.endsWith('/v3/agents') && url.search === ''; + return response.request().method() === 'GET' && url.pathname.endsWith('/v1/agents') && url.search === ''; }); await page.reload(); const ownerCatalogResponse = await ownerCatalogResponsePromise; @@ -108,7 +108,7 @@ test('production Console completes trusted publication, invocation, trace, and i ])); await expect(page.getByRole('heading', {name: 'Agent Card Catalog'})).toBeVisible(); const publicResolutionRequests: string[] = []; - const directPublicRequestPromise = page.waitForRequest((request) => request.method() === 'GET' && request.url().endsWith(`/v4/public/agents/${shareA.publicAgentId}`)); + const directPublicRequestPromise = page.waitForRequest((request) => request.method() === 'GET' && request.url().endsWith(`/v1/public/agents/${shareA.publicAgentId}`)); await page.goto(`/a/${shareA.publicAgentId}`); const directPublicRequest = await directPublicRequestPromise; publicResolutionRequests.push(directPublicRequest.url()); @@ -118,7 +118,7 @@ test('production Console completes trusted publication, invocation, trace, and i const directReleaseSelect = directPanel.getByLabel('Exact public Release', {exact: true}); await expect(directReleaseSelect).toHaveValue(''); await directReleaseSelect.selectOption(releaseA.releaseId); - const directInstallResponsePromise = page.waitForResponse((response) => response.url().includes(`/v3/workspaces/${workspaceId}/installations`) && response.request().method() === 'POST'); + const directInstallResponsePromise = page.waitForResponse((response) => response.url().includes(`/v1/workspaces/${workspaceId}/installations`) && response.request().method() === 'POST'); await directPanel.getByRole('button', {name: 'Install exact Release', exact: true}).click(); const directInstallResponse = await directInstallResponsePromise; expect(directInstallResponse.status()).toBe(201); @@ -128,10 +128,10 @@ test('production Console completes trusted publication, invocation, trace, and i await page.goto('/'); await expect(page.getByRole('heading', {name: 'Agent Card Catalog'})).toBeVisible(); - await page.getByRole('button', {name: 'Install', exact: true}).click(); + await page.locator('#sidebar').getByRole('button', {name: 'Install', exact: true}).click(); const publicPanel = page.locator('section').filter({hasText: 'Public Share'}); await publicPanel.getByLabel('Public Agent URL', {exact: true}).fill(shareB.publicUrl); - const pastedPublicRequestPromise = page.waitForRequest((request) => request.method() === 'GET' && request.url().endsWith(`/v4/public/agents/${shareB.publicAgentId}`)); + const pastedPublicRequestPromise = page.waitForRequest((request) => request.method() === 'GET' && request.url().endsWith(`/v1/public/agents/${shareB.publicAgentId}`)); await publicPanel.getByRole('button', {name: 'Resolve', exact: true}).click(); const pastedPublicRequest = await pastedPublicRequestPromise; publicResolutionRequests.push(pastedPublicRequest.url()); @@ -141,19 +141,19 @@ test('production Console completes trusted publication, invocation, trace, and i await pastedReleaseSelect.selectOption(releaseB.releaseId); await expect(publicPanel.getByRole('checkbox', {name: /text\.read/})).not.toBeChecked(); await publicPanel.getByRole('checkbox', {name: /text\.read/}).check(); - const pastedInstallResponsePromise = page.waitForResponse((response) => response.url().includes(`/v3/workspaces/${workspaceId}/installations`) && response.request().method() === 'POST'); + const pastedInstallResponsePromise = page.waitForResponse((response) => response.url().includes(`/v1/workspaces/${workspaceId}/installations`) && response.request().method() === 'POST'); await publicPanel.getByRole('button', {name: 'Install exact Release', exact: true}).click(); const pastedInstallResponse = await pastedInstallResponsePromise; expect(pastedInstallResponse.status()).toBe(201); const pastedInstallation = await pastedInstallResponse.json() as {installationId: string; installedReleaseId: string; agentId: string; acceptedPermissions: string[]}; expect(pastedInstallation).toMatchObject({installedReleaseId: releaseB.releaseId, agentId: runtimeB.id, acceptedPermissions: ['text.read']}); await expect(publicPanel.getByText(`Installed exact Release ${releaseB.releaseId}.`, {exact: true})).toBeVisible(); - expect(publicResolutionRequests).toEqual([`${apiBaseURL}/v4/public/agents/${shareA.publicAgentId}`, `${apiBaseURL}/v4/public/agents/${shareB.publicAgentId}`]); + expect(publicResolutionRequests).toEqual([`${apiBaseURL}/v1/public/agents/${shareA.publicAgentId}`, `${apiBaseURL}/v1/public/agents/${shareB.publicAgentId}`]); - await page.getByRole('button', {name: 'Install', exact: true}).click(); + await page.locator('#sidebar').getByRole('button', {name: 'Install', exact: true}).click(); await selectOptionContaining(page.getByLabel('Published Agent', {exact: true}), runtimeA.id); 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'); + const preflightResponsePromise = page.waitForResponse((response) => response.url().includes('/v1/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); @@ -171,7 +171,7 @@ test('production Console completes trusted publication, invocation, trace, and i await expect(installationSelect).toHaveValue(pastedInstallation.installationId); await page.getByLabel('Capability', {exact: true}).selectOption(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')); + const jsonResponsePromise = page.waitForResponse((response) => response.url().includes('/v1/workspaces/' + workspaceId + '/invocations') && response.request().method() === 'POST' && (response.request().postData() ?? '').includes('"stream":false')); await page.getByRole('button', {name: 'Invoke Agent', exact: true}).click(); const jsonResponse = await jsonResponsePromise; const jsonResponseBody = await jsonResponse.text(); @@ -185,7 +185,7 @@ test('production Console completes trusted publication, invocation, trace, and i expect(result.rootTaskId).toBeTruthy(); expect(result.traceId).toBeTruthy(); - const traceResponsePromise = page.waitForResponse((response) => response.url().includes('/v4/workspaces/' + workspaceId + '/traces/' + result.traceId) && response.request().method() === 'GET'); + const traceResponsePromise = page.waitForResponse((response) => response.url().includes('/v1/workspaces/' + workspaceId + '/traces/' + result.traceId) && response.request().method() === 'GET'); await page.getByRole('button', {name: 'Open correlated trace', exact: true}).click(); const traceResponse = await traceResponsePromise; expect(traceResponse.status()).toBe(200); @@ -212,12 +212,12 @@ test('production Console completes trusted publication, invocation, trace, and i expect(ledgerText).toContain(releaseA.cardDigest); expect(ledgerText).toContain(releaseB.cardDigest); - await page.getByRole('button', {name: 'Invoke', exact: true}).click(); + await page.locator('#sidebar').getByRole('button', {name: 'Invoke', exact: true}).click(); await selectOptionContaining(installationSelect, runtimeB.id); await page.getByLabel('Capability', {exact: true}).selectOption(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')); + const sseResponsePromise = page.waitForResponse((response) => response.url().includes('/v1/workspaces/' + workspaceId + '/invocations') && response.request().method() === 'POST' && (response.request().postData() ?? '').includes('"stream":true')); await page.getByRole('button', {name: 'Invoke Agent', exact: true}).click(); const sseResponse = await sseResponsePromise; expect(sseResponse.status()).toBe(200); @@ -228,6 +228,7 @@ test('production Console completes trusted publication, invocation, trace, and i 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) => /\/v[234]\//.test(new URL(url).pathname))).toBe(false); expect(requestUrls.some((url) => { const parsed = new URL(url); return /\/internal\/|\/agent\//.test(parsed.pathname) || parsed.hostname === 'runtime-a' || parsed.hostname === 'runtime-b'; @@ -254,7 +255,7 @@ async function createWorkspace(page: Page): Promise { } async function registerCard(page: Page, fixture: AgentFixture): Promise { - await page.getByRole('button', {name: 'Agents', exact: true}).click(); + await page.locator('#sidebar').getByRole('button', {name: 'Agents', 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); @@ -267,7 +268,7 @@ async function registerCard(page: Page, fixture: AgentFixture): Promise response.url().endsWith('/v3/agents') && response.request().method() === 'POST'); + const responsePromise = page.waitForResponse((response) => response.url().endsWith('/v1/agents') && response.request().method() === 'POST'); await page.getByRole('button', {name: 'Submit draft', exact: true}).click(); const response = await responsePromise; expect(response.status()).toBe(201); @@ -276,7 +277,7 @@ async function registerCard(page: Page, fixture: AgentFixture): Promise candidate.url().includes(`/v3/agents/${fixture.id}/versions/1.0.0/publish`) && candidate.request().method() === 'POST'); + const publishResponsePromise = page.waitForResponse((candidate) => candidate.url().includes(`/v1/agents/${fixture.id}/versions/1.0.0/publish`) && candidate.request().method() === 'POST'); await page.getByRole('button', {name: 'Publish to Catalog', exact: true}).click(); expect((await publishResponsePromise).status()).toBe(200); await page.getByRole('button', {name: 'Continue to Publish', exact: true}).click(); @@ -285,7 +286,7 @@ async function registerCard(page: Page, fixture: AgentFixture): Promise { - await page.getByRole('button', {name: 'Publish', exact: true}).click(); + await page.locator('#sidebar').getByRole('button', {name: 'Publish', 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(); @@ -333,7 +334,7 @@ async function publishTrustedRelease(page: Page, fixture: AgentFixture, leakTrac } async function installRelease(page: Page, fixture: AgentFixture, releaseId: string): Promise { - await page.getByRole('button', {name: 'Install', exact: true}).click(); + await page.locator('#sidebar').getByRole('button', {name: 'Install', 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); @@ -445,7 +446,7 @@ async function logInvocationTraceDiagnostic(page: Page, body: string): Promise
+ diff --git a/package.json b/package.json index 64e05a5..a073257 100644 --- a/package.json +++ b/package.json @@ -1,17 +1,17 @@ { "name": "@nekiro/console", "private": true, - "version": "0.0.0", + "version": "0.1.0", "type": "module", "packageManager": "pnpm@11.3.0", "scripts": { "dev": "vite --port=3000 --host=0.0.0.0", "build": "vite build", - "preview": "vite preview", - "clean": "rm -rf dist server.js", + "preview": "node server.mjs", + "clean": "rm -rf dist", "typecheck": "tsc --noEmit", "lint": "tsc --noEmit", - "test": "tsx --test src/api/nekiro.test.ts src/consoleConfig.test.ts src/consolePolicy.test.ts src/publicAgentUrl.test.ts src/components/consoleSurface.test.tsx src/demos/routing.test.ts", + "test": "tsx --test src/api/nekiro.test.ts src/consoleConfig.test.ts src/consolePolicy.test.ts src/publicAgentUrl.test.ts src/components/consoleSurface.test.tsx src/demos/routing.test.ts && node --test server.test.mjs", "test:e2e": "playwright test" }, "dependencies": { diff --git a/public/config.js b/public/config.js new file mode 100644 index 0000000..78e3889 --- /dev/null +++ b/public/config.js @@ -0,0 +1,2 @@ +// The production server replaces this response at runtime. Vite development uses build-time variables. +window.__NEKIRO_CONFIG__ = undefined; diff --git a/server.mjs b/server.mjs new file mode 100644 index 0000000..3e4ac2c --- /dev/null +++ b/server.mjs @@ -0,0 +1,152 @@ +import {createReadStream} from 'node:fs'; +import {readFile, stat} from 'node:fs/promises'; +import {createServer} from 'node:http'; +import {extname, resolve, sep} from 'node:path'; +import {fileURLToPath, pathToFileURL} from 'node:url'; + +const requiredRuntimeNames = [ + 'VITE_NEKIRO_API_BASE_URL', + 'VITE_NEKIRO_PROVIDER_ID', + 'VITE_NEKIRO_PROVIDER_TOKEN', + 'VITE_NEKIRO_OWNER_TOKEN', + 'VITE_NEKIRO_DEFAULT_WORKSPACE_ID', + 'VITE_NEKIRO_PUBLIC_AGENT_ORIGIN', +]; + +const contentTypes = new Map([ + ['.css', 'text/css; charset=utf-8'], + ['.html', 'text/html; charset=utf-8'], + ['.js', 'text/javascript; charset=utf-8'], + ['.json', 'application/json; charset=utf-8'], + ['.svg', 'image/svg+xml'], + ['.webp', 'image/webp'], +]); + +function requireExactOrigin(name, value) { + let parsed; + try { + parsed = new URL(value); + } catch { + throw new Error(`${name} must be an exact HTTP or HTTPS origin`); + } + if (!['http:', 'https:'].includes(parsed.protocol) + || parsed.username + || parsed.password + || parsed.pathname !== '/' + || parsed.search + || parsed.hash + || parsed.origin !== value) { + throw new Error(`${name} must be an exact HTTP or HTTPS origin`); + } +} + +export function readRuntimeConfiguration(environment) { + const configuration = {}; + for (const name of requiredRuntimeNames) { + const value = environment[name]; + if (typeof value !== 'string' || value.length === 0 || value !== value.trim()) { + throw new Error(`${name} is required and must not contain surrounding whitespace`); + } + configuration[name] = value; + } + const providerName = environment.VITE_NEKIRO_PROVIDER_NAME; + if (providerName !== undefined) { + if (typeof providerName !== 'string' || providerName.length === 0 || providerName !== providerName.trim()) { + throw new Error('VITE_NEKIRO_PROVIDER_NAME must be non-empty and must not contain surrounding whitespace'); + } + configuration.VITE_NEKIRO_PROVIDER_NAME = providerName; + } + requireExactOrigin('VITE_NEKIRO_API_BASE_URL', configuration.VITE_NEKIRO_API_BASE_URL); + requireExactOrigin('VITE_NEKIRO_PUBLIC_AGENT_ORIGIN', configuration.VITE_NEKIRO_PUBLIC_AGENT_ORIGIN); + return configuration; +} + +export function renderRuntimeConfiguration(configuration) { + const encoded = JSON.stringify(configuration).replaceAll('\u2028', '\\u2028').replaceAll('\u2029', '\\u2029'); + return `window.__NEKIRO_CONFIG__ = ${encoded};\n`; +} + +export function parseListenAddress(value) { + if (typeof value !== 'string' || value !== value.trim()) { + throw new Error('NEKIRO_CONSOLE_LISTEN_ADDRESS is required as host:port'); + } + const match = /^([^:]+):(\d+)$/.exec(value); + if (!match) throw new Error('NEKIRO_CONSOLE_LISTEN_ADDRESS is required as host:port'); + const port = Number(match[2]); + if (!Number.isSafeInteger(port) || port < 1 || port > 65535) { + throw new Error('NEKIRO_CONSOLE_LISTEN_ADDRESS port must be between 1 and 65535'); + } + return {host: match[1], port}; +} + +export function createConsoleServer({configuration, distDirectory}) { + const configScript = renderRuntimeConfiguration(configuration); + const root = resolve(distDirectory); + return createServer(async (request, response) => { + if (!['GET', 'HEAD'].includes(request.method ?? '')) { + response.writeHead(405, {'content-type': 'text/plain; charset=utf-8', allow: 'GET, HEAD'}).end('method not allowed\n'); + return; + } + const pathname = new URL(request.url ?? '/', 'http://console.invalid').pathname; + if (pathname === '/readyz') { + response.writeHead(200, {'content-type': 'text/plain; charset=utf-8', 'cache-control': 'no-store'}).end(request.method === 'HEAD' ? undefined : 'ready\n'); + return; + } + if (pathname === '/config.js') { + response.writeHead(200, { + 'content-type': 'text/javascript; charset=utf-8', + 'cache-control': 'no-store', + 'x-content-type-options': 'nosniff', + }).end(request.method === 'HEAD' ? undefined : configScript); + return; + } + + let requested; + try { + requested = decodeURIComponent(pathname); + } catch { + response.writeHead(400, {'content-type': 'text/plain; charset=utf-8'}).end('bad request\n'); + return; + } + const candidate = resolve(root, `.${requested === '/' ? '/index.html' : requested}`); + if (candidate !== root && !candidate.startsWith(`${root}${sep}`)) { + response.writeHead(400, {'content-type': 'text/plain; charset=utf-8'}).end('bad request\n'); + return; + } + let asset = candidate; + try { + if (!(await stat(asset)).isFile()) asset = resolve(root, 'index.html'); + } catch { + asset = resolve(root, 'index.html'); + } + const body = request.method === 'HEAD' ? undefined : createReadStream(asset); + response.writeHead(200, { + 'content-type': contentTypes.get(extname(asset)) ?? 'application/octet-stream', + 'cache-control': asset.endsWith('index.html') ? 'no-cache' : 'public, max-age=31536000, immutable', + 'content-security-policy': "default-src 'self'; connect-src 'self' http: https:; img-src 'self' data:; style-src 'self' 'unsafe-inline'; script-src 'self'; object-src 'none'; base-uri 'none'; frame-ancestors 'none'", + 'x-content-type-options': 'nosniff', + }); + if (body) { + body.once('error', () => response.destroy()); + body.pipe(response); + } else { + response.end(); + } + }); +} + +async function main() { + const configuration = readRuntimeConfiguration(process.env); + const {host, port} = parseListenAddress(process.env.NEKIRO_CONSOLE_LISTEN_ADDRESS); + const distDirectory = resolve(fileURLToPath(new URL('./dist', import.meta.url))); + await readFile(resolve(distDirectory, 'index.html')); + const server = createConsoleServer({configuration, distDirectory}); + server.listen(port, host, () => process.stdout.write(`NeKiro Console listening on ${host}:${port}\n`)); +} + +if (process.argv[1] && import.meta.url === pathToFileURL(resolve(process.argv[1])).href) { + main().catch((error) => { + process.stderr.write(`NeKiro Console failed to start: ${error.message}\n`); + process.exitCode = 1; + }); +} diff --git a/server.test.mjs b/server.test.mjs new file mode 100644 index 0000000..d4359a9 --- /dev/null +++ b/server.test.mjs @@ -0,0 +1,61 @@ +import assert from 'node:assert/strict'; +import {mkdir, mkdtemp, rm, writeFile} from 'node:fs/promises'; +import {tmpdir} from 'node:os'; +import {join} from 'node:path'; +import test from 'node:test'; + +import {createConsoleServer, parseListenAddress, readRuntimeConfiguration, renderRuntimeConfiguration} from './server.mjs'; + +const valid = { + VITE_NEKIRO_API_BASE_URL: 'https://gateway.example.test', + VITE_NEKIRO_PROVIDER_ID: 'provider.main', + VITE_NEKIRO_PROVIDER_TOKEN: 'provider-token', + VITE_NEKIRO_OWNER_TOKEN: 'owner-token', + VITE_NEKIRO_DEFAULT_WORKSPACE_ID: 'workspace.main', + VITE_NEKIRO_PUBLIC_AGENT_ORIGIN: 'https://agents.example.test', +}; + +test('runtime configuration fails closed when a required value is absent', () => { + for (const name of Object.keys(valid)) { + const candidate = {...valid}; + delete candidate[name]; + assert.throws(() => readRuntimeConfiguration(candidate), new RegExp(name)); + } +}); + +test('runtime configuration requires exact origins', () => { + assert.throws(() => readRuntimeConfiguration({...valid, VITE_NEKIRO_API_BASE_URL: 'https://gateway.example.test/v1'})); + assert.throws(() => readRuntimeConfiguration({...valid, VITE_NEKIRO_PUBLIC_AGENT_ORIGIN: 'https://agents.example.test/'})); +}); + +test('runtime configuration is rendered without logging or transforming credentials', () => { + const script = renderRuntimeConfiguration(readRuntimeConfiguration(valid)); + assert.match(script, /^window\.__NEKIRO_CONFIG__ = /); + assert.match(script, /"VITE_NEKIRO_PROVIDER_TOKEN":"provider-token"/); +}); + +test('listen address has no inferred default', () => { + assert.deepEqual(parseListenAddress('0.0.0.0:8080'), {host: '0.0.0.0', port: 8080}); + assert.throws(() => parseListenAddress(undefined)); + assert.throws(() => parseListenAddress('0.0.0.0:0')); +}); + +test('static server treats an existing directory as an SPA route without crashing', async (context) => { + const root = await mkdtemp(join(tmpdir(), 'nekiro-console-')); + await writeFile(join(root, 'index.html'), '

NeKiro Console

'); + await mkdir(join(root, 'assets')); + const server = createConsoleServer({configuration: valid, distDirectory: root}); + await new Promise((resolve, reject) => { + server.once('error', reject); + server.listen(0, '127.0.0.1', resolve); + }); + context.after(async () => { + await new Promise((resolve) => server.close(resolve)); + await rm(root, {recursive: true, force: true}); + }); + const address = server.address(); + assert.equal(typeof address, 'object'); + const response = await fetch(`http://127.0.0.1:${address.port}/assets`); + assert.equal(response.status, 200); + assert.equal(await response.text(), '

NeKiro Console

'); +}); diff --git a/src/App.tsx b/src/App.tsx index 304f0d7..1866fb7 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -12,11 +12,12 @@ 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 {consoleEnvironment, requireConsoleConfiguration} from './consoleConfig'; import type {Agent, AgentIntent, ConsoleTab, Installation, InstallationStatus, InstallIntent, InvocationIntent, LedgerIntent, PlatformErrorView, Workspace} from './types'; export default function App() { - requireConsoleConfiguration(import.meta.env); + const consoleEnv = consoleEnvironment(); + requireConsoleConfiguration(consoleEnv); const [activeTab, setActiveTab] = useState('registry'); const [searchQuery, setSearchQuery] = useState(''); const [agents, setAgents] = useState([]); @@ -29,7 +30,7 @@ export default function App() { 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 [workspaceDraft, setWorkspaceDraft] = useState(consoleEnv.VITE_NEKIRO_DEFAULT_WORKSPACE_ID as string ?? ''); const [workspaceLoading, setWorkspaceLoading] = useState(false); const [workspaceError, setWorkspaceError] = useState(null); const [installations, setInstallations] = useState([]); @@ -61,17 +62,17 @@ export default function App() { const providerClient = useMemo( () => new NekiroApiClient({ - baseUrl: import.meta.env.VITE_NEKIRO_API_BASE_URL, - token: import.meta.env.VITE_NEKIRO_PROVIDER_TOKEN, - publicAgentOrigin: import.meta.env.VITE_NEKIRO_PUBLIC_AGENT_ORIGIN, + baseUrl: consoleEnv.VITE_NEKIRO_API_BASE_URL as string, + token: consoleEnv.VITE_NEKIRO_PROVIDER_TOKEN as string, + publicAgentOrigin: consoleEnv.VITE_NEKIRO_PUBLIC_AGENT_ORIGIN as string, }), [], ); const ownerClient = useMemo( () => new NekiroApiClient({ - baseUrl: import.meta.env.VITE_NEKIRO_API_BASE_URL, - token: import.meta.env.VITE_NEKIRO_OWNER_TOKEN, - publicAgentOrigin: import.meta.env.VITE_NEKIRO_PUBLIC_AGENT_ORIGIN, + baseUrl: consoleEnv.VITE_NEKIRO_API_BASE_URL as string, + token: consoleEnv.VITE_NEKIRO_OWNER_TOKEN as string, + publicAgentOrigin: consoleEnv.VITE_NEKIRO_PUBLIC_AGENT_ORIGIN as string, }), [], ); @@ -101,7 +102,7 @@ export default function App() { providerCatalogRequestGeneration.current = generation; setProviderCatalogError(null); try { - const providerId = import.meta.env.VITE_NEKIRO_PROVIDER_ID; + const providerId = consoleEnv.VITE_NEKIRO_PROVIDER_ID as string; 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)); @@ -174,7 +175,7 @@ export default function App() { useEffect(() => { if (defaultWorkspaceInitialized.current) return; - const defaultWorkspaceId = import.meta.env.VITE_NEKIRO_DEFAULT_WORKSPACE_ID; + const defaultWorkspaceId = consoleEnv.VITE_NEKIRO_DEFAULT_WORKSPACE_ID as string; if (!defaultWorkspaceId) return; defaultWorkspaceInitialized.current = true; void loadWorkspace(defaultWorkspaceId).then((value) => value && loadInstallations(value.workspaceId)); @@ -224,8 +225,8 @@ export default function App() { 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)]); + setDraftAgents((current) => current.filter((draft) => agentKey(draft) !== agentKey(agent))); }; const handleInstallAgent = async (agent: Agent, release: AgentRelease, acceptedPermissions: string[]) => { @@ -349,7 +350,7 @@ export default function App() { onReadWorkspace={handleReadWorkspace} onCreateWorkspace={handleCreateWorkspace} userLabel={workspace?.ownerId ?? 'Workspace owner'} - apiConfigured={Boolean(import.meta.env.VITE_NEKIRO_API_BASE_URL && import.meta.env.VITE_NEKIRO_PROVIDER_ID && import.meta.env.VITE_NEKIRO_PROVIDER_TOKEN && import.meta.env.VITE_NEKIRO_OWNER_TOKEN && import.meta.env.VITE_NEKIRO_DEFAULT_WORKSPACE_ID)} + apiConfigured={Boolean(consoleEnv.VITE_NEKIRO_API_BASE_URL && consoleEnv.VITE_NEKIRO_PROVIDER_ID && consoleEnv.VITE_NEKIRO_PROVIDER_TOKEN && consoleEnv.VITE_NEKIRO_OWNER_TOKEN && consoleEnv.VITE_NEKIRO_DEFAULT_WORKSPACE_ID)} />
@@ -373,8 +374,8 @@ export default function App() { catalogLoading={catalogLoading} catalogError={catalogError} catalogReady={catalogReady} - defaultOwnerId={import.meta.env.VITE_NEKIRO_PROVIDER_ID ?? ''} - defaultOwnerName={import.meta.env.VITE_NEKIRO_PROVIDER_NAME ?? ''} + defaultOwnerId={consoleEnv.VITE_NEKIRO_PROVIDER_ID as string ?? ''} + defaultOwnerName={consoleEnv.VITE_NEKIRO_PROVIDER_NAME as string ?? ''} searchQuery={searchQuery} onContinueToTrusted={(agent) => { setTrustedSelection({agentKey: agentKey(agent), sequence: nextIntentSequence()}); @@ -387,7 +388,7 @@ export default function App() { {activeTab === 'trusted' && ( } onClose={() => setShowSettings(false)}>
-

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

+

Base URL: {consoleEnv.VITE_NEKIRO_API_BASE_URL as string || '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'}

+

Default Workspace: {consoleEnv.VITE_NEKIRO_DEFAULT_WORKSPACE_ID as string || 'manual selection'}

)} diff --git a/src/api/nekiro.test.ts b/src/api/nekiro.test.ts index 1e5029f..f938ee7 100644 --- a/src/api/nekiro.test.ts +++ b/src/api/nekiro.test.ts @@ -136,7 +136,7 @@ test('mapCatalogEntry maps Catalog entries to the Console view model without dep assert.equal(JSON.parse(agent.schema).agentId, 'runtime.disabled'); }); -test('NekiroApiClient sends v3 Catalog search requests with auth and decodes platform errors', async () => { +test('NekiroApiClient sends Platform API v1 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/', @@ -165,7 +165,7 @@ test('NekiroApiClient sends v3 Catalog search requests with auth and decodes pla }, ); - assert.equal(requests[0]?.url, 'https://api.example.test/v3/agents?query=echo'); + assert.equal(requests[0]?.url, 'https://api.example.test/v1/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'); @@ -191,12 +191,12 @@ test('NekiroApiClient resolves public Agent shares anonymously and preserves exa }); const result = await client.resolvePublicAgent(publicAgentId); assert.equal(result.releases[0]?.releaseId, 'release-1'); - assert.equal(requests[0]?.url, 'https://api.example.test/v4/public/agents/' + publicAgentId); + assert.equal(requests[0]?.url, 'https://api.example.test/v1/public/agents/' + publicAgentId); const headers = new Headers(requests[0]?.init?.headers); assert.equal(headers.get('Authorization'), null); }); -test('NekiroApiClient covers Workspace and Installation v3 paths', async () => { +test('NekiroApiClient covers Platform API v1 Workspace and Installation paths', async () => { const requests: Array<{url: string; init?: RequestInit}> = []; const client = new NekiroApiClient({ baseUrl: 'https://api.example.test', @@ -212,7 +212,7 @@ test('NekiroApiClient covers Workspace and Installation v3 paths', async () => { 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'); + assert.equal(requests[0]?.url, 'https://api.example.test/v1/workspaces/workspace.alpha/installations?limit=50&cursor=next'); }); test('NekiroApiClient strictly maps every Installation read response', async () => { @@ -231,7 +231,7 @@ test('NekiroApiClient strictly maps every Installation read response', async () await assert.rejects(() => client.getInstallation('workspace.alpha', 'installation-1'), /unknown field/); }); -test('NekiroApiClient enforces Installation v2 semantic response rules', async () => { +test('NekiroApiClient enforces Installation response semantic 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', }; @@ -341,7 +341,7 @@ test('NekiroApiClient installs an exact trusted version and preserves Release pr }); 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.equal(requests[0]?.url, 'https://api.example.test/v1/workspaces/workspace.alpha/installations'); assert.deepEqual(JSON.parse(String(requests[0]?.init?.body)), {agentId: 'agent.echo', versionConstraint: '1.2.3', acceptedPermissions: []}); }); @@ -411,7 +411,7 @@ test('provider and Workspace-owner clients keep bearer contexts separate', async assert.deepEqual(authorization, ['Bearer provider-token', 'Bearer owner-token']); }); -test('NekiroApiClient constructs a strict v4 JSON invocation request', async () => { +test('NekiroApiClient constructs a strict Platform API v1 JSON invocation request', async () => { const requests: Array<{url: string; init?: RequestInit}> = []; const client = new NekiroApiClient({ baseUrl: 'https://api.example.test', @@ -423,7 +423,7 @@ test('NekiroApiClient constructs a strict v4 JSON invocation request', async () }); 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.equal(requests[0]?.url, 'https://api.example.test/v1/workspaces/workspace.alpha/invocations'); assert.deepEqual(JSON.parse(String(requests[0]?.init?.body)), {agentId: 'runtime.echo', capability: 'runtime.echo', input: {message: 'hello'}, stream: false}); }); @@ -443,7 +443,7 @@ test('NekiroApiClient preserves correlated Platform Error v4 fields', async () = }); }); -test('NekiroApiClient reads Workspace-scoped v4 Invocation and Trace paths', async () => { +test('NekiroApiClient reads Workspace-scoped Platform API v1 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'}; @@ -453,7 +453,7 @@ test('NekiroApiClient reads Workspace-scoped v4 Invocation and Trace paths', asy }}); 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']); + assert.deepEqual(requests, ['https://api.example.test/v1/workspaces/workspace.alpha/invocations/inv-1', 'https://api.example.test/v1/workspaces/workspace.alpha/traces/trace-1']); }); test('NekiroApiClient rejects Invocation Detail provenance changes', async () => { @@ -568,16 +568,16 @@ test('NekiroApiClient constructs every Trusted Publication Gateway route without 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', + 'https://api.example.test/v1/providers/provider.main/agents/agent.echo/endpoint-bindings', + 'https://api.example.test/v1/providers/provider.main/endpoint-bindings/binding-1', + 'https://api.example.test/v1/providers/provider.main/endpoint-bindings/binding-1/challenges', + 'https://api.example.test/v1/providers/provider.main/endpoint-bindings/binding-1/challenges/challenge-1/complete', + 'https://api.example.test/v1/providers/provider.main/agents/agent.echo/releases', + 'https://api.example.test/v1/releases/release-1', + 'https://api.example.test/v1/releases/release-1/verify', + 'https://api.example.test/v1/releases/release-1/publish', + 'https://api.example.test/v1/releases/release-1/suspend', + 'https://api.example.test/v1/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'}); diff --git a/src/api/nekiro.ts b/src/api/nekiro.ts index 9e924ae..5ec27ab 100644 --- a/src/api/nekiro.ts +++ b/src/api/nekiro.ts @@ -373,17 +373,17 @@ export class NekiroApiClient { searchAgents(params: CatalogSearchParams = {}): Promise { const suffix = this.queryString(params); - return this.request('/v3/agents' + suffix).then((value) => validateCatalogSearchResponse(value, this.publicAgentOrigin)); + return this.request('/v1/agents' + suffix).then((value) => validateCatalogSearchResponse(value, this.publicAgentOrigin)); } resolvePublicAgent(publicAgentId: string): Promise { const safePublicAgentID = readPublicAgentID(publicAgentId); - return this.publicRequest('/v4/public/agents/' + encodeURIComponent(safePublicAgentID)) + return this.publicRequest('/v1/public/agents/' + encodeURIComponent(safePublicAgentID)) .then((value) => validatePublicAgentShare(value, safePublicAgentID, this.publicAgentOrigin)); } registerAgent(card: AgentCardV02): Promise { - return this.request('/v3/agents', { + return this.request('/v1/agents', { method: 'POST', body: JSON.stringify({card}), }, 201).then((value) => validateCatalogEntry(value, this.publicAgentOrigin)); @@ -406,7 +406,7 @@ export class NekiroApiClient { const safeAgentId = readIdentifier(agentId, 'agentId'); const safeRequest = validateCreateEndpointBindingRequest(request); return this.trustedRequest( - '/v4/providers/' + encodeURIComponent(safeProviderId) + '/agents/' + encodeURIComponent(safeAgentId) + '/endpoint-bindings', + '/v1/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, @@ -417,7 +417,7 @@ export class NekiroApiClient { const safeProviderId = readIdentifier(providerId, 'providerId'); const safeBindingId = readIdentifier(bindingId, 'bindingId'); return this.trustedRequest( - '/v4/providers/' + encodeURIComponent(safeProviderId) + '/endpoint-bindings/' + encodeURIComponent(safeBindingId), + '/v1/providers/' + encodeURIComponent(safeProviderId) + '/endpoint-bindings/' + encodeURIComponent(safeBindingId), {}, (value) => validateEndpointBinding(value, {providerId: safeProviderId, bindingId: safeBindingId}), ); @@ -427,7 +427,7 @@ export class NekiroApiClient { const safeProviderId = readIdentifier(providerId, 'providerId'); const safeBindingId = readIdentifier(bindingId, 'bindingId'); return this.trustedRequest( - '/v4/providers/' + encodeURIComponent(safeProviderId) + '/endpoint-bindings/' + encodeURIComponent(safeBindingId) + '/challenges', + '/v1/providers/' + encodeURIComponent(safeProviderId) + '/endpoint-bindings/' + encodeURIComponent(safeBindingId) + '/challenges', {method: 'POST'}, (value) => validateVerificationChallenge(value, safeBindingId), 201, @@ -439,7 +439,7 @@ export class NekiroApiClient { 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', + '/v1/providers/' + encodeURIComponent(safeProviderId) + '/endpoint-bindings/' + encodeURIComponent(safeBindingId) + '/challenges/' + encodeURIComponent(safeChallengeId) + '/complete', {method: 'POST'}, (value) => validateEndpointBinding(value, {providerId: safeProviderId, bindingId: safeBindingId}), ); @@ -450,7 +450,7 @@ export class NekiroApiClient { const safeAgentId = readIdentifier(agentId, 'agentId'); const safeRequest = validateCreateAgentReleaseRequest(request); return this.trustedRequest( - '/v4/providers/' + encodeURIComponent(safeProviderId) + '/agents/' + encodeURIComponent(safeAgentId) + '/releases', + '/v1/providers/' + encodeURIComponent(safeProviderId) + '/agents/' + encodeURIComponent(safeAgentId) + '/releases', {method: 'POST', body: JSON.stringify(safeRequest)}, (value) => validateAgentRelease(value, { providerId: safeProviderId, @@ -465,7 +465,7 @@ export class NekiroApiClient { getAgentRelease(releaseId: string): Promise { const safeReleaseId = readIdentifier(releaseId, 'releaseId'); return this.trustedRequest( - '/v4/releases/' + encodeURIComponent(safeReleaseId), + '/v1/releases/' + encodeURIComponent(safeReleaseId), {}, (value) => validateAgentRelease(value, {releaseId: safeReleaseId}), ); @@ -488,14 +488,14 @@ export class NekiroApiClient { } createWorkspace(workspaceId: string): Promise { - return this.request('/v3/workspaces', { + return this.request('/v1/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)); + return this.request('/v1/workspaces/' + encodeURIComponent(readIdentifier(workspaceId, 'workspaceId'))).then((value) => validateWorkspace(value)); } installAgent(workspaceId: string, request: InstallAgentRequest): Promise { @@ -653,11 +653,11 @@ export class NekiroApiClient { } private versionPath(agentId: string, version: string): string { - return '/v3/agents/' + encodeURIComponent(agentId) + '/versions/' + encodeURIComponent(version); + return '/v1/agents/' + encodeURIComponent(agentId) + '/versions/' + encodeURIComponent(version); } private workspaceInstallationPath(workspaceId: string): string { - return '/v3/workspaces/' + encodeURIComponent(readIdentifier(workspaceId, 'workspaceId')) + '/installations'; + return '/v1/workspaces/' + encodeURIComponent(readIdentifier(workspaceId, 'workspaceId')) + '/installations'; } private installationPath(workspaceId: string, installationId: string): string { @@ -665,17 +665,17 @@ export class NekiroApiClient { } private invocationPath(workspaceId: string): string { - return '/v4/workspaces/' + encodeURIComponent(readIdentifier(workspaceId, 'workspaceId')) + '/invocations'; + return '/v1/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')); + return '/v1/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, + '/v1/releases/' + encodeURIComponent(safeReleaseId) + '/' + action, {method: 'POST'}, (value) => validateAgentRelease(value, {releaseId: safeReleaseId}), ); diff --git a/src/components/PublicAgentInstallPanel.tsx b/src/components/PublicAgentInstallPanel.tsx index 64c37a7..04454c3 100644 --- a/src/components/PublicAgentInstallPanel.tsx +++ b/src/components/PublicAgentInstallPanel.tsx @@ -2,6 +2,7 @@ import React, {useEffect, useState} from 'react'; import {AlertTriangle, Loader2, ShieldCheck} from 'lucide-react'; import {toPlatformErrorView, validatePublicInstallation, type NekiroApiClient, type PublicAgentRelease, type PublicAgentShare} from '../api/nekiro'; +import {consoleEnvironment} from '../consoleConfig'; import {parsePublicAgentUrl} from '../publicAgentUrl'; import type {PlatformErrorView, Workspace} from '../types'; @@ -31,7 +32,7 @@ export default function PublicAgentInstallPanel({client, workspace, onInstalled, setAcceptedPermissions([]); setInstalledReleaseID(''); try { - const publicAgentID = parsePublicAgentUrl(candidate, import.meta.env.VITE_NEKIRO_PUBLIC_AGENT_ORIGIN); + const publicAgentID = parsePublicAgentUrl(candidate, consoleEnvironment().VITE_NEKIRO_PUBLIC_AGENT_ORIGIN as string); const value = await client.resolvePublicAgent(publicAgentID); setShare(value); } catch (value) { diff --git a/src/components/PublicAgentPage.tsx b/src/components/PublicAgentPage.tsx index 13bbab7..a015e4a 100644 --- a/src/components/PublicAgentPage.tsx +++ b/src/components/PublicAgentPage.tsx @@ -1,21 +1,23 @@ import React, {useEffect, useMemo, useState} from 'react'; import {NekiroApiClient, toPlatformErrorView} from '../api/nekiro'; +import {consoleEnvironment} from '../consoleConfig'; import type {PlatformErrorView, Workspace} from '../types'; import PublicAgentInstallPanel from './PublicAgentInstallPanel'; export default function PublicAgentPage() { - const ownerToken = import.meta.env.VITE_NEKIRO_OWNER_TOKEN; - const defaultWorkspaceID = import.meta.env.VITE_NEKIRO_DEFAULT_WORKSPACE_ID; + const consoleEnv = consoleEnvironment(); + const ownerToken = consoleEnv.VITE_NEKIRO_OWNER_TOKEN as string; + const defaultWorkspaceID = consoleEnv.VITE_NEKIRO_DEFAULT_WORKSPACE_ID as string; const hasOwnerContext = typeof ownerToken === 'string' && ownerToken !== '' && typeof defaultWorkspaceID === 'string' && defaultWorkspaceID !== ''; const client = useMemo(() => new NekiroApiClient({ - baseUrl: import.meta.env.VITE_NEKIRO_API_BASE_URL, - publicAgentOrigin: import.meta.env.VITE_NEKIRO_PUBLIC_AGENT_ORIGIN, + baseUrl: consoleEnv.VITE_NEKIRO_API_BASE_URL as string, + publicAgentOrigin: consoleEnv.VITE_NEKIRO_PUBLIC_AGENT_ORIGIN as string, ...(hasOwnerContext ? {token: ownerToken} : {anonymousOnly: true}), }), [hasOwnerContext, ownerToken]); const [workspace, setWorkspace] = useState(null); const [workspaceError, setWorkspaceError] = useState(null); - const initialURL = import.meta.env.VITE_NEKIRO_PUBLIC_AGENT_ORIGIN + const initialURL = (consoleEnv.VITE_NEKIRO_PUBLIC_AGENT_ORIGIN as string) + window.location.pathname + window.location.search + window.location.hash; diff --git a/src/components/RegistryTab.tsx b/src/components/RegistryTab.tsx index 1d8b3d1..3630559 100644 --- a/src/components/RegistryTab.tsx +++ b/src/components/RegistryTab.tsx @@ -126,7 +126,7 @@ export default function RegistryTab(props: RegistryTabProps) {
Registry

Agent Card Catalog

-

Live v3 Catalog surface for Agent Card v0.2 registration and discovery. Trust and Release lifecycle operations are handled in Trusted Publication.

+

Live Platform API v1 Catalog surface for Agent Card v0.2 registration and discovery. Trust and Release lifecycle operations are handled in Trusted Publication.

} {tab === 'catalog' && <>
setQuery(event.target.value)} placeholder="Search by name, capability, or owner" className="flex-1 outline-none text-sm placeholder:text-slate-400" />⌘K
} label="Published agents" value={String(DEMO_AGENTS.filter((a) => a.status === 'published').length)} />} label="Enabled installs" value={String(DEMO_INSTALLATIONS.filter((i) => i.status === 'enabled').length)} />} label="Capabilities" value={String(new Set(DEMO_AGENTS.flatMap((a) => a.tags)).size)} />
{agents.map((agent) =>
{agent.status}

{agent.name}

{agent.id} · v{agent.version}

{agent.description}

{agent.tags.map((tag) => {tag})}
by {agent.owner}
)}
} {tab === 'workspace' &&
Active Workspace
{DEMO_WORKSPACE.workspaceId}
Owner

Installed Agents

{DEMO_INSTALLATIONS.filter((item) => item.status !== 'uninstalled').map((item) =>
{item.agentId}
Pinned {item.installedVersion}
{item.status}
)}
} - {tab === 'activity' &&

Invocation and Ledger

Comparison fixture only. Production metadata reads stay on the live Console route.
} + {tab === 'activity' &&

Invocation and Ledger

Comparison fixture only. Production metadata reads stay on the live Console route.
}
; } diff --git a/src/demos/terminal/TerminalDemo.tsx b/src/demos/terminal/TerminalDemo.tsx index 41dd060..819c06b 100644 --- a/src/demos/terminal/TerminalDemo.tsx +++ b/src/demos/terminal/TerminalDemo.tsx @@ -29,7 +29,7 @@ export default function TerminalDemo() { {label}{id === 'registry' ? '06' : id === 'installations' ? '04' : '—'} ))} -
STRICT MODE
NO FABRICATED EVENTS
NORTHBOUND API v4
+
STRICT MODE
NO FABRICATED EVENTS
PLATFORM API v1
@@ -61,5 +61,5 @@ function Installations({query, setQuery}: {query: string; setQuery: (value: stri } function RuntimePanel({kind}: {kind: 'invocations' | 'ledger'}) { - return
{kind === 'invocations' ? 'POST /v4/workspaces/{workspaceId}/invocations' : 'GET /v4/workspaces/{workspaceId}/traces/{traceId}'}

Live Owner-only surface. Runtime facts are read from the Gateway; the demo intentionally shows the contract boundary without inventing an execution.

AUTH
OWNER
MODE
JSON / SSE
STORAGE
METADATA ONLY
; + return
{kind === 'invocations' ? 'POST /v1/workspaces/{workspaceId}/invocations' : 'GET /v1/workspaces/{workspaceId}/traces/{traceId}'}

Live Owner-only surface. Runtime facts are read from the Gateway; the demo intentionally shows the contract boundary without inventing an execution.

AUTH
OWNER
MODE
JSON / SSE
STORAGE
METADATA ONLY
; } diff --git a/src/main.tsx b/src/main.tsx index f3c3f3a..faab50b 100644 --- a/src/main.tsx +++ b/src/main.tsx @@ -3,7 +3,7 @@ import {createRoot} from 'react-dom/client'; import App from './App.tsx'; import PublicAgentPage from './components/PublicAgentPage.tsx'; -import {requirePublicConsoleConfiguration} from './consoleConfig.ts'; +import {consoleEnvironment, requirePublicConsoleConfiguration} from './consoleConfig.ts'; import {demoFromHash, type DemoId} from './demos/routing'; import './index.css'; @@ -24,7 +24,7 @@ function Root() { const demo = demoFromHash(hash); if (demo) return }>; if (window.location.pathname.startsWith('/a/')) { - requirePublicConsoleConfiguration(import.meta.env); + requirePublicConsoleConfiguration(consoleEnvironment()); return ; } return ; diff --git a/src/vite-env.d.ts b/src/vite-env.d.ts index e4b8362..7b71632 100644 --- a/src/vite-env.d.ts +++ b/src/vite-env.d.ts @@ -7,3 +7,7 @@ interface ImportMetaEnv { interface ImportMeta { readonly env: ImportMetaEnv; } + +interface Window { + readonly __NEKIRO_CONFIG__?: Record; +}