Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 6 additions & 0 deletions .dockerignore
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
.git
.github
dist
node_modules
playwright-report
test-results
87 changes: 87 additions & 0 deletions .github/workflows/release.yml
Original file line number Diff line number Diff line change
@@ -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"
16 changes: 16 additions & 0 deletions Dockerfile
Original file line number Diff line number Diff line change
@@ -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"]
21 changes: 21 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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:

Expand Down Expand Up @@ -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
Expand All @@ -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:

Expand Down
41 changes: 21 additions & 20 deletions e2e/console.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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()));

Expand All @@ -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;
Expand All @@ -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());
Expand All @@ -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);
Expand All @@ -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());
Expand All @@ -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);
Expand All @@ -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();
Expand All @@ -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);
Expand All @@ -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);
Expand All @@ -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';
Expand All @@ -254,7 +255,7 @@ async function createWorkspace(page: Page): Promise<void> {
}

async function registerCard(page: Page, fixture: AgentFixture): Promise<PublicShare> {
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);
Expand All @@ -267,7 +268,7 @@ async function registerCard(page: Page, fixture: AgentFixture): Promise<PublicSh
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: fixture.permissions ?? []},
]}, null, 2));
const responsePromise = page.waitForResponse((response) => 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);
Expand All @@ -276,7 +277,7 @@ async function registerCard(page: Page, fixture: AgentFixture): Promise<PublicSh
expect(body.publicUrl).toBe(`${publicAgentOrigin}/a/${body.publicAgentId}`);
await expect(page.getByText(fixture.id, {exact: true}).first()).toBeVisible();
await expect(page.getByRole('link', {name: body.publicUrl, exact: true})).toBeVisible();
const publishResponsePromise = page.waitForResponse((candidate) => 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();
Expand All @@ -285,7 +286,7 @@ async function registerCard(page: Page, fixture: AgentFixture): Promise<PublicSh
}

async function publishTrustedRelease(page: Page, fixture: AgentFixture, leakTracker: BrowserLeakTracker): Promise<ReleaseEvidence> {
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();
Expand Down Expand Up @@ -333,7 +334,7 @@ async function publishTrustedRelease(page: Page, fixture: AgentFixture, leakTrac
}

async function installRelease(page: Page, fixture: AgentFixture, releaseId: string): Promise<void> {
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);
Expand Down Expand Up @@ -445,7 +446,7 @@ async function logInvocationTraceDiagnostic(page: Page, body: string): Promise<v
return;
}
try {
const response = await page.request.get(`${apiBaseURL}/v4/workspaces/${encodeURIComponent(workspaceId)}/traces/${encodeURIComponent(traceId)}`, {
const response = await page.request.get(`${apiBaseURL}/v1/workspaces/${encodeURIComponent(workspaceId)}/traces/${encodeURIComponent(traceId)}`, {
headers: {Authorization: `Bearer ${ownerToken}`, Accept: 'application/json'},
});
const traceBody = await response.text();
Expand Down
1 change: 1 addition & 0 deletions index.html
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@
</head>
<body>
<div id="root"></div>
<script src="/config.js"></script>
<script type="module" src="/src/main.tsx"></script>
</body>
</html>
Loading
Loading