From f9e59967e36254e256bd77d6ed4d1b14a8781b59 Mon Sep 17 00:00:00 2001 From: saladday <1203511142@qq.com> Date: Fri, 25 Sep 2026 13:17:44 +0000 Subject: [PATCH 01/11] Read the console's data through Core's new routes --- apps/web/.impeccable/surfaces/src-app-tsx.md | 2 +- apps/web/PRODUCT.md | 16 +++-- apps/web/README.md | 5 +- apps/web/e2e/data/admin.mjs | 2 +- apps/web/e2e/data/core-metrics.mjs | 2 +- apps/web/e2e/fixture-console.mjs | 69 +++++++++++++++---- .../src/features/dashboard/runtime-history.ts | 4 +- .../web/src/features/files/file-operations.ts | 6 +- .../features/metrics/agent-metrics-loader.ts | 4 +- .../src/features/metrics/metrics-queries.ts | 2 +- .../src/features/metrics/project-sessions.ts | 4 +- .../src/features/metrics/sandbox-runtime.ts | 4 +- .../src/features/sessions/session-history.ts | 4 +- apps/web/src/features/sessions/session-log.ts | 4 +- .../src/features/sessions/session-runtime.ts | 4 +- .../features/skills/skill-operations.test.ts | 4 +- .../src/features/skills/skill-operations.ts | 8 +-- .../src/features/vaults/vault-catalog.test.ts | 4 +- apps/web/src/features/vaults/vault-catalog.ts | 4 +- apps/web/src/i18n/locales/en/metrics.ts | 2 +- apps/web/src/i18n/locales/zh-CN/metrics.ts | 2 +- apps/web/src/lib/admin-view.ts | 2 +- apps/web/src/lib/projects.tsx | 28 +++----- apps/web/vite.config.ts | 3 +- .../web/admin-metrics-backend-requirements.md | 32 ++++----- ...dmin-metrics-backend-requirements.zh-CN.md | 22 +++--- docs/web/architecture.md | 7 +- docs/web/core-connection.md | 7 +- docs/web/core-process-metrics-requirements.md | 2 +- docs/web/protocol-coverage.md | 64 ++++++++++------- 30 files changed, 188 insertions(+), 135 deletions(-) diff --git a/apps/web/.impeccable/surfaces/src-app-tsx.md b/apps/web/.impeccable/surfaces/src-app-tsx.md index 2da2a95df..3b8127401 100644 --- a/apps/web/.impeccable/surfaces/src-app-tsx.md +++ b/apps/web/.impeccable/surfaces/src-app-tsx.md @@ -9,7 +9,7 @@ related_targets: ["src/ConsoleApp.tsx"] Scope: the signed-in console shell and every page behind it, plus the first-run step. Visitor mode: Operate. Audience: the administrator of one Parsar Core deployment. Task: judge health, capacity, usage and failures; inspect and delete or copy project assets; manage projects, keys and nodes. -Constraints: Web API only (`/core/v1/admin`, `/core/v1/sandbox`); missing data stays visibly missing; no small print, explanations live in help tips; API terms stay English in Chinese copy; zh-CN and English, light and dark. +Constraints: Web API only (`/core/v1`); missing data stays visibly missing; no small print, explanations live in help tips; API terms stay English in Chinese copy; zh-CN and English, light and dark. Information architecture: Monitor (Overview, Agent metrics, Sandbox metrics, Session log) · Resources (Agents, Environment templates, Skills, Files, Vaults) · Platform (Projects and keys, Nodes, System). diff --git a/apps/web/PRODUCT.md b/apps/web/PRODUCT.md index 8151b28a6..7f88d7842 100644 --- a/apps/web/PRODUCT.md +++ b/apps/web/PRODUCT.md @@ -47,13 +47,15 @@ workbench. [Core key](../../docs/getting-started/operations.md#core-key)). There are no console accounts or usernames. The browser sends the key only to sign in and keeps only the session cookie; the console server holds the Core key and forwards - the Web API (`/core/v1/admin/**`) and sandbox administration - (`/core/v1/sandbox/**`). The console never calls `/v1`. + the Web API (`/core/v1/**`, including sandbox administration under + `/core/v1/sandbox/**`). The console never calls `/v1`. - The Core key is not an Agents API identity and cannot call `/v1`. An administrator who wants to call the Agents API issues a project API key like any other caller. -- `/console/config` reports whether sandbox administration is available; without - it the Nodes page explains that it is not configured and the fleet figures show - as unavailable. +- `/console/config` reports the node installer (`node_installer`, + `node_installer_sha256`). Signing in grants administration, so sandbox + administration is available unless the console explicitly reports + `sandbox_admin: false`; then the Nodes page explains that it is not configured + and the fleet figures show as unavailable. - Chinese and English UI; light and dark themes; reduced motion honored. ## Information Architecture @@ -106,8 +108,8 @@ workbench. a key never touches assets. Archiving a project revokes every key and keeps its assets viewable and deletable. Key plaintext is shown once, at issuance, and never stored by the console. -- **Web API only.** Every read and write goes through `/core/v1/admin/**` or - `/core/v1/sandbox/**`. The console holds no API key and sends nothing to `/v1`. +- **Web API only.** Every read and write goes through `/core/v1/**`. The console + holds no API key and sends nothing to `/v1`. - **No asset writes except delete.** Assets are created and changed only by a project's keys through the Agents API. The console does not create or edit Agents or Templates, upload Skills or Files, create or replace Credentials, start diff --git a/apps/web/README.md b/apps/web/README.md index bc2db8a6c..035b8db4e 100644 --- a/apps/web/README.md +++ b/apps/web/README.md @@ -7,8 +7,9 @@ described in [DESIGN.md](DESIGN.md) and its product scope in [PRODUCT.md](PRODUC ## Integration contract -Browser management requests use same-origin `/core/v1/admin` through `AdminClient`, -plus the existing sandbox management client for allowed `/core/v1/sandbox` routes. +Browser management requests use same-origin `/core/v1` through `AdminClient`, +`CoreMetricsClient` (`/core/v1/metrics`) and the sandbox management client +(`/core/v1/sandbox`). The administrator signs in with the deployment's Core key; the console keeps the key server-side and gives the browser only a session cookie. Applications use their own Project keys directly against Core's public `/v1` API. diff --git a/apps/web/e2e/data/admin.mjs b/apps/web/e2e/data/admin.mjs index d4e06b57b..b3a4ff179 100644 --- a/apps/web/e2e/data/admin.mjs +++ b/apps/web/e2e/data/admin.mjs @@ -1,4 +1,4 @@ -// Synthetic management-plane data (/core/v1/admin/**) for the browser acceptance fixture. +// Synthetic management-plane data (/core/v1/**) for the browser acceptance fixture. // Projects own isolated assets shared by their named keys; the base demo's // resources are split across projects so every page can be filtered. import { agentProject } from "./agents.mjs"; diff --git a/apps/web/e2e/data/core-metrics.mjs b/apps/web/e2e/data/core-metrics.mjs index 5640f334f..60a1e9412 100644 --- a/apps/web/e2e/data/core-metrics.mjs +++ b/apps/web/e2e/data/core-metrics.mjs @@ -1,4 +1,4 @@ -// Synthetic Core metrics for the browser acceptance fixture (GET /core/v1/admin/core-metrics). +// Synthetic Core metrics for the browser acceptance fixture (GET /core/v1/metrics). const RANGES = { "1h": [60, 60], "6h": [72, 300], "24h": [96, 900], "7d": [84, 7200] }; export function coreMetrics(range = "1h", now = Math.floor(Date.now() / 1000)) { diff --git a/apps/web/e2e/fixture-console.mjs b/apps/web/e2e/fixture-console.mjs index a68cc810c..2a7856d2e 100644 --- a/apps/web/e2e/fixture-console.mjs +++ b/apps/web/e2e/fixture-console.mjs @@ -1,8 +1,9 @@ // Browser acceptance fixture: the console service's routes (/console/**) and the -// management surfaces it forwards (/core/v1/admin/**, /core/v1/sandbox/**), with -// synthetic, deterministic data and in-memory writes. It never serves /v1; any -// /v1 request, and any browser-supplied Authorization header, is recorded so a -// test can assert that the console stays on its management boundary. +// Core management tree it forwards (/core/v1/**: projects, summary, audit log, +// metrics and /core/v1/sandbox/**), with synthetic, deterministic data and +// in-memory writes. It never serves /v1; any /v1 request, and any +// browser-supplied Authorization header, is recorded so a test can assert that +// the console stays on its management boundary. import http from "node:http"; import { buildAdmin } from "./data/admin.mjs"; @@ -47,6 +48,8 @@ function reset(mode = "login", fresh = false, sandbox = "configured") { // "authenticated": the console holds a session for the fixture cookie; "login": it holds none. auth: { mode: mode === "authenticated" ? "authenticated" : "login", failures: 0, lockedUntil: 0 }, violations: [], writes: [], failNext: null, nextId: 1, + // Executor credential metadata by environment ID; tokens are never kept. + executorCredentials: new Map(), // "none": the deployment is not configured yet, so the Nodes page offers setup. deployment: sandbox === "none" ? null : sandbox === "e2b" ? e2bDeployment() : configuredDeployment(), }; @@ -116,6 +119,7 @@ async function consoleRoute(request, response, url) { return send(response, 200, { mode: "login" }, { "set-cookie": `${SESSION_COOKIE.split("=")[0]}=; Path=/; Max-Age=0` }); } if (url.pathname === "/console/config") { + // Signing in grants administration, so the console reports only its node installer. return send(response, 200, { node_installer: true, node_installer_sha256: "a".repeat(64) }); } return error(response, 404, "Not found."); @@ -166,13 +170,7 @@ function adminRead(response, path, url) { const a = state.admin; if (path === "/projects") return send(response, 200, { data: a.projects.map(a.publicProject), has_more: false }); if (path === "/summary") return send(response, 200, a.summary(url)); - if (path === "/runtime-observations") { - // Only E2B reports a sandbox's disk. - const e2b = state.deployment?.provider === "e2b"; - const data = a.runtimeObservations().map((entry) => ({ ...entry, observation: { ...entry.observation, disk: e2b && entry.observation.status === "observed" ? { usage_bytes: 3 * 2 ** 30, limit_bytes: 10 * 2 ** 30 } : null } })); - return send(response, 200, { object: "list", data, has_more: false, first_id: data[0]?.observation.id ?? null, last_id: data.at(-1)?.observation.id ?? null }); - } - if (path === "/core-metrics") return send(response, 200, coreMetrics(url.searchParams.get("range") ?? "1h")); + if (path === "/metrics") return send(response, 200, coreMetrics(url.searchParams.get("range") ?? "1h")); const match = path.match(/^\/projects\/([^/]+)(\/.*)?$/); const project = match && a.projects.find((entry) => entry.id === match[1]); if (!project) return error(response, 404, "No such project."); @@ -226,6 +224,12 @@ function nodeDetail(node) { } async function sandboxRoute(request, response, path) { + if (path === "/runtime-observations" && request.method === "GET") { + // Only E2B reports a sandbox's disk. + const e2b = state.deployment?.provider === "e2b"; + const data = state.admin.runtimeObservations().map((entry) => ({ ...entry, observation: { ...entry.observation, disk: e2b && entry.observation.status === "observed" ? { usage_bytes: 3 * 2 ** 30, limit_bytes: 10 * 2 ** 30 } : null } })); + return send(response, 200, { object: "list", data, has_more: false, first_id: data[0]?.observation.id ?? null, last_id: data.at(-1)?.observation.id ?? null }); + } if (path === "/deployment" && request.method === "POST") { const input = await body(request); if (state.deployment) return error(response, 409, "The sandbox deployment is already configured.", "sandbox_deployment_conflict"); @@ -272,6 +276,41 @@ async function sandboxRoute(request, response, path) { return error(response, 404, "Not found."); } +const UUID = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i; +const EXECUTOR_CREDENTIALS = /^\/projects\/([^/]+)\/environments\/([^/]+)\/executor-credentials(?:\/([^/]+))?$/; + +/** + * Executor credentials as Core issues them: only for the self_hosted + * environment of a Session in the project; the token is returned once; an + * existing key_id is reissued only with rotate:true, and a revoked one never; + * revoking again is safe. + */ +async function executorCredentialRoute(request, response, projectId, environmentId, keyId) { + const project = state.admin.projects.find((entry) => entry.id === projectId); + const session = project && state.admin.collections(project.id).sessions.find((entry) => entry.environment.type === "self_hosted" && entry.environment.id === environmentId); + if (!session) return error(response, 404, "No such self-hosted environment.", "not_found"); + if (!state.executorCredentials.has(environmentId)) state.executorCredentials.set(environmentId, []); + const credentials = state.executorCredentials.get(environmentId); + if (!keyId && request.method === "GET") return send(response, 200, { data: credentials.map((entry) => ({ ...entry })) }); + if (!keyId && request.method === "POST") { + const input = await body(request); + if (typeof input.key_id !== "string" || !UUID.test(input.key_id) || (input.rotate !== undefined && typeof input.rotate !== "boolean")) return error(response, 400, "key_id must be a UUID.", "invalid_request"); + const existing = credentials.find((entry) => entry.key_id === input.key_id); + if (existing && input.rotate !== true) return error(response, 409, "The executor credential exists; rotate it instead.", "executor_credential_exists"); + if (existing?.revoked_at) return error(response, 409, "The executor credential is revoked.", "executor_credential_revoked"); + if (!existing) credentials.push({ key_id: input.key_id, created_at: new Date().toISOString(), revoked_at: null }); + return send(response, 201, { key_id: input.key_id, environment_id: environmentId, executor_token: `exec_fixture_${state.nextId++}` }); + } + if (keyId && request.method === "DELETE") { + const existing = credentials.find((entry) => entry.key_id === keyId); + if (!existing) return error(response, 404, "No such executor credential.", "not_found"); + existing.revoked_at ??= new Date().toISOString(); + response.writeHead(204, { "cache-control": "no-store" }); + return response.end(); + } + return error(response, 404, "Not found."); +} + /** Test controls: reset state, inject one failure, and read what the browser sent. */ async function fixtureRoute(request, response, url) { if (url.pathname === "/__fixture/health") return send(response, 200, { ok: true }); @@ -308,11 +347,13 @@ http.createServer(async (request, response) => { state.failNext = null; return error(response, fail.status, fail.message ?? "Injected failure.", fail.code ?? null); } - if (url.pathname.startsWith("/core/v1/admin/")) { - const path = url.pathname.slice("/core/v1/admin".length); + if (url.pathname.startsWith("/core/v1/sandbox/")) return await sandboxRoute(request, response, url.pathname.slice("/core/v1/sandbox".length)); + if (url.pathname.startsWith("/core/v1/")) { + const path = url.pathname.slice("/core/v1".length); + const credentials = path.match(EXECUTOR_CREDENTIALS); + if (credentials) return await executorCredentialRoute(request, response, credentials[1], credentials[2], credentials[3]); return write ? await adminWrite(request, response, path) : adminRead(response, path, url); } - if (url.pathname.startsWith("/core/v1/sandbox/")) return await sandboxRoute(request, response, url.pathname.slice("/core/v1/sandbox".length)); return error(response, 404, "Not found."); } catch (caught) { error(response, 500, String(caught)); diff --git a/apps/web/src/features/dashboard/runtime-history.ts b/apps/web/src/features/dashboard/runtime-history.ts index 880fcc5e9..4e7560ec5 100644 --- a/apps/web/src/features/dashboard/runtime-history.ts +++ b/apps/web/src/features/dashboard/runtime-history.ts @@ -1,4 +1,4 @@ -import { AgentCoreError, type AgentCore, type AgentSession, type RuntimeHistory } from "@agents-core-web/agents-client"; +import { AgentCoreError, type AgentSession, type CoreProjectReader, type RuntimeHistory } from "@agents-core-web/agents-client"; import type { RuntimeDashboardSnapshot } from "./runtime-snapshot"; import { deriveTokenThroughput, type RuntimeTrendSample, type RuntimeTrendTarget } from "./runtime-trends"; @@ -160,7 +160,7 @@ export function runtimeDurableTrendSamples( return deriveTokenThroughput(samples); } -export type RuntimeHistoryReader = Pick; +export type RuntimeHistoryReader = Pick; function isNotFound(error: unknown): boolean { return error instanceof AgentCoreError && error.status === 404; diff --git a/apps/web/src/features/files/file-operations.ts b/apps/web/src/features/files/file-operations.ts index e9cc3c97f..73ea8a936 100644 --- a/apps/web/src/features/files/file-operations.ts +++ b/apps/web/src/features/files/file-operations.ts @@ -1,6 +1,6 @@ import { AgentCoreError, - type AgentCore, + type CoreProjectReader, type PageOrder, type SourceFileListEntry, } from "@agents-core-web/agents-client"; @@ -12,8 +12,6 @@ export const maxFileUploadBytes = 512 * 1024 * 1024; /** One page of the Files list; Core allows up to 10000, but the console reads 100 at a time. */ export const filesPageSize = 100; -export type FilesOperations = Pick; - export type UploadPrecheck = "too-large" | "bad-name" | null; /** Browser checks before any byte is sent; Core still validates the upload. */ @@ -78,7 +76,7 @@ export function filterFiles(files: readonly SourceFileListEntry[], query: string /** Reads one page after the loaded rows and applies the shared identity and cursor checks. */ export async function readFilesPage( - core: Pick, + core: Pick, loaded: readonly SourceFileListEntry[], order: PageOrder, after: string | undefined, diff --git a/apps/web/src/features/metrics/agent-metrics-loader.ts b/apps/web/src/features/metrics/agent-metrics-loader.ts index 1b174b0f4..7134b5c43 100644 --- a/apps/web/src/features/metrics/agent-metrics-loader.ts +++ b/apps/web/src/features/metrics/agent-metrics-loader.ts @@ -1,4 +1,4 @@ -import type { AgentSession, AgentTurn, ListPage, OpenAIAgentsClient, PageOptions, SessionItem } from "@agents-core-web/agents-client"; +import type { AgentSession, AgentTurn, ListPage, CoreProjectReader, PageOptions, SessionItem } from "@agents-core-web/agents-client"; import type { MetricsCoverage, MetricsWindow, SessionActivity } from "./agent-metrics"; import { readProjectsSessions, type InProject, type ProjectReadFailure, type SessionLister } from "./project-sessions"; @@ -200,7 +200,7 @@ export async function loadAgentMetricsActivity( /** Most Sessions listed per project when looking for Sessions active in the range. */ export const PROJECT_SESSION_LIST_CAP = 2_000; -type ProjectReader = SessionLister & Pick; +type ProjectReader = SessionLister & Pick; /** Routes each Session's Turn and Item reads to the admin scope of its project. */ export function projectMetricsSource(sessions: readonly InProject[], clientFor: (project: Project) => AgentMetricsSource): AgentMetricsSource { diff --git a/apps/web/src/features/metrics/metrics-queries.ts b/apps/web/src/features/metrics/metrics-queries.ts index adda7d95b..7443ff012 100644 --- a/apps/web/src/features/metrics/metrics-queries.ts +++ b/apps/web/src/features/metrics/metrics-queries.ts @@ -65,7 +65,7 @@ export function keyUsageQuery(filter: string, range: AgentMetricsRange) { }); } -const coreMetricsClient = new CoreMetricsClient({ baseUrl: "/core/v1/admin" }); +const coreMetricsClient = new CoreMetricsClient(); /** Core's own metrics over a range, aggregated by Core. */ export function coreMetricsQuery(range: CoreMetricsRange) { diff --git a/apps/web/src/features/metrics/project-sessions.ts b/apps/web/src/features/metrics/project-sessions.ts index 5ead6710f..754865eaa 100644 --- a/apps/web/src/features/metrics/project-sessions.ts +++ b/apps/web/src/features/metrics/project-sessions.ts @@ -1,4 +1,4 @@ -import type { AgentSession, OpenAIAgentsClient } from "@agents-core-web/agents-client"; +import type { AgentSession, CoreProjectReader } from "@agents-core-web/agents-client"; import type { Owned } from "../../lib/projects"; import { type Project } from "../../lib/admin-view"; @@ -12,7 +12,7 @@ import { type Project } from "../../lib/admin-view"; export const SESSION_PAGE_SIZE = 100; -export type SessionLister = Pick; +export type SessionLister = Pick; /** A value and the project it belongs to. */ export type InProject = Owned; diff --git a/apps/web/src/features/metrics/sandbox-runtime.ts b/apps/web/src/features/metrics/sandbox-runtime.ts index 4b315a1d2..c68cb4b16 100644 --- a/apps/web/src/features/metrics/sandbox-runtime.ts +++ b/apps/web/src/features/metrics/sandbox-runtime.ts @@ -1,4 +1,4 @@ -import type { AgentSession, OpenAIAgentsClient, SandboxAllocation, SandboxNode } from "@agents-core-web/agents-client"; +import type { AgentSession, CoreProjectReader, SandboxAllocation, SandboxNode } from "@agents-core-web/agents-client"; import type { RuntimeDashboardSnapshot } from "../dashboard/runtime-snapshot"; import { type OwnedRuntimeObservation } from "../../lib/admin-view"; @@ -14,7 +14,7 @@ import { type OwnedRuntimeObservation } from "../../lib/admin-view"; export const HOSTED_SESSION_LIMIT = 100; const SESSION_READ_CONCURRENCY = 6; -export type SessionReader = Pick; +export type SessionReader = Pick; export interface HostedRuntimeLoad { observations: OwnedRuntimeObservation[]; diff --git a/apps/web/src/features/sessions/session-history.ts b/apps/web/src/features/sessions/session-history.ts index ee1d6abbe..a343f7057 100644 --- a/apps/web/src/features/sessions/session-history.ts +++ b/apps/web/src/features/sessions/session-history.ts @@ -2,7 +2,7 @@ import type { AgentSession, AgentTurn, ListPage, - OpenAIAgentsClient, + CoreProjectReader, SessionItem, } from "@agents-core-web/agents-client"; @@ -82,7 +82,7 @@ export interface SessionHistory { loadedAt: number; } -type HistoryReader = Pick; +type HistoryReader = Pick; function message(error: unknown): string { return error instanceof Error ? error.message : String(error); diff --git a/apps/web/src/features/sessions/session-log.ts b/apps/web/src/features/sessions/session-log.ts index 024ff076f..4f81f8df6 100644 --- a/apps/web/src/features/sessions/session-log.ts +++ b/apps/web/src/features/sessions/session-log.ts @@ -1,4 +1,4 @@ -import type { AgentSession, OpenAIAgentsClient } from "@agents-core-web/agents-client"; +import type { AgentSession, CoreProjectReader } from "@agents-core-web/agents-client"; import type { Owned } from "../../lib/projects"; @@ -32,7 +32,7 @@ export interface SessionLogFilters { export const initialSessionLogFilters: SessionLogFilters = { status: "all", agentId: "", environment: "", query: "" }; -type TolerantLister = Pick; +type TolerantLister = Pick; /** Walks every Session page of one project, newest first, bounded at `limit` entries. */ export async function readSessionLog(client: TolerantLister, signal?: AbortSignal, limit = SESSION_LOG_LIMIT): Promise { diff --git a/apps/web/src/features/sessions/session-runtime.ts b/apps/web/src/features/sessions/session-runtime.ts index 90454e9fc..e04927601 100644 --- a/apps/web/src/features/sessions/session-runtime.ts +++ b/apps/web/src/features/sessions/session-runtime.ts @@ -1,4 +1,4 @@ -import { AgentCoreError, type AgentSession, type OpenAIAgentsClient } from "@agents-core-web/agents-client"; +import { AgentCoreError, type AgentSession, type CoreProjectReader } from "@agents-core-web/agents-client"; import { RUNTIME_DURABLE_MAX_POINTS, RUNTIME_DURABLE_RANGES, runtimeDurableTrendSamples, type RuntimeDurableRange } from "../dashboard/runtime-history"; import type { RuntimeTrendSample } from "../dashboard/runtime-trends"; @@ -24,7 +24,7 @@ export function hasObservableRuntime(session: AgentSession): boolean { * ending now. Resolves to null when Core keeps no history for it (404). */ export async function loadSessionRuntimeHistory( - client: Pick, + client: Pick, session: AgentSession, range: SessionRuntimeRange, signal?: AbortSignal, diff --git a/apps/web/src/features/skills/skill-operations.test.ts b/apps/web/src/features/skills/skill-operations.test.ts index a1a574d5d..de933463f 100644 --- a/apps/web/src/features/skills/skill-operations.test.ts +++ b/apps/web/src/features/skills/skill-operations.test.ts @@ -1,6 +1,6 @@ import { describe, expect, it, vi } from "vitest"; -import { AgentCoreError, type AgentCore, type Skill, type SkillList, type SkillVersion, type SkillVersionList } from "@agents-core-web/agents-client"; +import { AgentCoreError, type CoreProjectReader, type Skill, type SkillList, type SkillVersion, type SkillVersionList } from "@agents-core-web/agents-client"; import i18n from "../../i18n"; import { @@ -94,7 +94,7 @@ describe("Skill list helpers", () => { const core = { downloadSkill: vi.fn(async () => content), downloadSkillVersion: vi.fn(async () => content), - } satisfies Pick; + } satisfies Pick; const save = vi.fn(); const skill = skillFixture({ default_version: "2", latest_version: "3" }); diff --git a/apps/web/src/features/skills/skill-operations.ts b/apps/web/src/features/skills/skill-operations.ts index 5055de207..a4b7b0354 100644 --- a/apps/web/src/features/skills/skill-operations.ts +++ b/apps/web/src/features/skills/skill-operations.ts @@ -1,4 +1,4 @@ -import { AgentCoreError, type AgentCore, type Skill, type SkillVersion } from "@agents-core-web/agents-client"; +import { AgentCoreError, type CoreProjectReader, type Skill, type SkillVersion } from "@agents-core-web/agents-client"; import { appendCollectionPage } from "../../lib/collection-pagination"; @@ -93,7 +93,7 @@ export function saveBlob(blob: Blob, filename: string): void { /** Downloads the default version, or one exact version, through the client. */ export async function downloadSkillArchive( - core: Pick, + core: Pick, skill: Skill, version?: SkillVersion, signal?: AbortSignal, @@ -111,7 +111,7 @@ export async function downloadSkillArchive( /** Reads the next Skill page (newest first) and appends it to the loaded rows. */ export async function readSkillsPage( - core: Pick, + core: Pick, loaded: readonly Skill[], after: string | undefined, signal?: AbortSignal, @@ -122,7 +122,7 @@ export async function readSkillsPage( /** Reads the next version page (highest version first) and appends it to the loaded rows. */ export async function readSkillVersionsPage( - core: Pick, + core: Pick, skillId: string, loaded: readonly SkillVersion[], after: string | undefined, diff --git a/apps/web/src/features/vaults/vault-catalog.test.ts b/apps/web/src/features/vaults/vault-catalog.test.ts index 0babacde9..192f2e65c 100644 --- a/apps/web/src/features/vaults/vault-catalog.test.ts +++ b/apps/web/src/features/vaults/vault-catalog.test.ts @@ -1,6 +1,6 @@ import { describe, expect, it, vi } from "vitest"; -import type { AgentCore, SavedAgent, Vault, VaultCredential } from "@agents-core-web/agents-client"; +import type { CoreProjectReader, SavedAgent, Vault, VaultCredential } from "@agents-core-web/agents-client"; import { deriveSessionVaultPlan, loadVaultCatalog, matchingCredentials, type VaultCatalog } from "./vault-catalog"; @@ -65,7 +65,7 @@ describe("Vault catalog", () => { first_id: vaultId === vaultA.id ? credentialA.id : credentialB.id, last_id: vaultId === vaultA.id ? credentialA.id : credentialB.id, })); - const core = { listVaults, listVaultCredentials } as unknown as AgentCore; + const core = { listVaults, listVaultCredentials } as unknown as CoreProjectReader; await expect(loadVaultCatalog(core)).resolves.toEqual({ vaults: [vaultA, vaultB], diff --git a/apps/web/src/features/vaults/vault-catalog.ts b/apps/web/src/features/vaults/vault-catalog.ts index b5ea02979..d04367db7 100644 --- a/apps/web/src/features/vaults/vault-catalog.ts +++ b/apps/web/src/features/vaults/vault-catalog.ts @@ -1,5 +1,5 @@ import type { - AgentCore, + CoreProjectReader, SavedAgent, Vault, VaultCredential, @@ -40,7 +40,7 @@ export interface SessionVaultPlan { const CREDENTIAL_READ_CONCURRENCY = 4; -export async function loadVaultCatalog(core: AgentCore, signal?: AbortSignal): Promise { +export async function loadVaultCatalog(core: Pick, signal?: AbortSignal): Promise { const vaults = await listAllCollectionPages( (options) => core.listVaults(options), signal, diff --git a/apps/web/src/i18n/locales/en/metrics.ts b/apps/web/src/i18n/locales/en/metrics.ts index 00c760e77..12122a13e 100644 --- a/apps/web/src/i18n/locales/en/metrics.ts +++ b/apps/web/src/i18n/locales/en/metrics.ts @@ -107,7 +107,7 @@ export const metrics = { failed: "Core metrics could not be read. {{reason}}", stale: "Refresh failed; showing the last metrics read. {{reason}}", missingTitle: "Core does not report its own metrics yet", - missingDescription: "Core needs to provide /core/v1/admin/core-metrics before execution, database and background jobs can show.", + missingDescription: "Core needs to provide /core/v1/metrics before execution, database and background jobs can show.", kpiLabel: "Core summary", meta: "{{status}} · {{revision}} · up {{uptime}}", notOwner: "Does not hold the execution lease", diff --git a/apps/web/src/i18n/locales/zh-CN/metrics.ts b/apps/web/src/i18n/locales/zh-CN/metrics.ts index 8e6bbde9c..9177c42b2 100644 --- a/apps/web/src/i18n/locales/zh-CN/metrics.ts +++ b/apps/web/src/i18n/locales/zh-CN/metrics.ts @@ -107,7 +107,7 @@ export const metrics = { failed: "无法读取 Core 指标。{{reason}}", stale: "刷新失败,显示的是上次读取的指标。{{reason}}", missingTitle: "Core 尚未上报自身指标", - missingDescription: "需要 Core 提供 /core/v1/admin/core-metrics,才能显示执行、数据库和后台任务。", + missingDescription: "需要 Core 提供 /core/v1/metrics,才能显示执行、数据库和后台任务。", kpiLabel: "Core 概况", meta: "{{status}} · {{revision}} · 已运行 {{uptime}}", notOwner: "未持有执行归属", diff --git a/apps/web/src/lib/admin-view.ts b/apps/web/src/lib/admin-view.ts index 94b011358..455502696 100644 --- a/apps/web/src/lib/admin-view.ts +++ b/apps/web/src/lib/admin-view.ts @@ -11,7 +11,7 @@ import { } from "@agents-core-web/agents-client"; /** - * View models over the typed management client (`AdminClient`, `/core/v1/admin`). + * View models over the typed management client (`AdminClient`, `/core/v1`). * Pages format Unix seconds and never treat missing data as zero, so this * layer converts RFC 3339 timestamps, resolves creating keys by name, walks * cursor pages and turns an unmeasured usage sum into `null`. diff --git a/apps/web/src/lib/projects.tsx b/apps/web/src/lib/projects.tsx index c565ad03d..897384629 100644 --- a/apps/web/src/lib/projects.tsx +++ b/apps/web/src/lib/projects.tsx @@ -1,4 +1,4 @@ -import type { OpenAIAgentsClient } from "@agents-core-web/agents-client"; +import type { CoreProjectReader } from "@agents-core-web/agents-client"; import { QueryClientProvider, useQueries, useQuery, useQueryClient } from "@tanstack/react-query"; import { createContext, useCallback, useContext, useEffect, useMemo, useRef, useState, type ReactNode } from "react"; import { useTranslation } from "react-i18next"; @@ -12,7 +12,7 @@ export { admin }; export type { Project }; /** - * The console reaches Core only through the Web API (`/core/v1/admin`). A + * The console reaches Core only through the Web API (`/core/v1`). A * project owns an isolated set of assets shared by all of its named API keys; * pages filter by project and read each project through `projectClient`. */ @@ -108,15 +108,8 @@ export interface ProjectCollection { refresh: () => void; } -/** The public-client reads and deletes a project page uses, bound to one project. */ -export type ProjectClient = Pick; +/** The Core reads and deletes a project page uses, bound to one project. */ +export type ProjectClient = CoreProjectReader; async function content(result: Promise<{ blob: Blob; contentType: string | null; contentDisposition: string | null }>) { const value = await result; @@ -124,13 +117,12 @@ async function content(result: Promise<{ blob: Blob; contentType: string | null; } /** - * Binds the management client to one project with the public client's method - * shapes, so pages written against the public projections read a project - * through `/core/v1/admin/projects/{id}` without change. Deletions only; no - * creation or editing exists here. + * Binds the management client to one project in the public projections' + * shapes, so pages read a project through `/core/v1/projects/{id}`. + * Deletions only; no creation or editing exists here. */ -function createProjectClient(projectId: string): ProjectClient { - const listSessions = async (options: Parameters[0] = {}) => { +function createProjectClient(projectId: string): CoreProjectReader { + const listSessions = async (options: Parameters[0] = {}) => { const page = await admin.listSessions(projectId, { after: options.after, limit: options.limit, order: options.order, agentId: options.agentId, signal: options.signal }); return { ...page, object: "list" as const, first_id: page.first_id ?? null, last_id: page.last_id ?? null }; }; @@ -164,7 +156,7 @@ function createProjectClient(projectId: string): ProjectClient { listItems: (sessionId, options) => admin.listItems(projectId, sessionId, options), retrieveRuntimeObservation: (sessionId, options) => admin.retrieveRuntimeObservation(projectId, sessionId, options), retrieveRuntimeHistory: (sessionId, query) => admin.retrieveRuntimeHistory(projectId, sessionId, query), - } as ProjectClient; + } as CoreProjectReader; return client; } diff --git a/apps/web/vite.config.ts b/apps/web/vite.config.ts index 04d40b805..c141e599f 100644 --- a/apps/web/vite.config.ts +++ b/apps/web/vite.config.ts @@ -53,8 +53,7 @@ export default defineConfig(({ command, mode }) => { // management surfaces it forwards, as in production. "/console": { target, changeOrigin: true }, "/node-install": { target, changeOrigin: true }, - "/core/v1/sandbox": { target, changeOrigin: true }, - "/core/v1/admin": { target, changeOrigin: true }, + "/core/v1": { target, changeOrigin: true }, "/v1": { target, changeOrigin: true, diff --git a/docs/web/admin-metrics-backend-requirements.md b/docs/web/admin-metrics-backend-requirements.md index 38b95eb2f..fe80f8237 100644 --- a/docs/web/admin-metrics-backend-requirements.md +++ b/docs/web/admin-metrics-backend-requirements.md @@ -5,9 +5,9 @@ implemented beyond the Web API routes it names as existing. [简体中文](admin-metrics-backend-requirements.zh-CN.md) The console is a management tool: Monitor (Overview, Agent metrics, Sandbox -metrics, Session log) leads. It reads only the Web API (`/core/v1/admin/**`) -and the sandbox administration routes (`/core/v1/sandbox/**`); it never calls -`/v1`. Some figures come from Web API aggregates, others are still assembled +metrics, Session log) leads. It reads only the Web API (`/core/v1/**`, +including the sandbox administration routes under `/core/v1/sandbox/**`); it +never calls `/v1`. Some figures come from Web API aggregates, others are still assembled in the browser from bounded reads of each project. This document records what that costs, where it is incomplete, and which Web API endpoints would replace the browser work. @@ -17,7 +17,7 @@ the browser work. 1. **Public Agents API** (`/v1/**`): must stay identical to the pinned OpenAI Agents API and its documented Core extensions. Metrics work must not add fields, routes or behavior here. -2. **Web API** (`/core/v1/admin/**`, plus `/core/v1/sandbox/**` for sandbox +2. **Web API** (`/core/v1/**`, including `/core/v1/sandbox/**` for sandbox administration): called only by the console server with the deployment administrator credential. Every endpoint proposed below belongs here and is forwarded by `services/core-console`. @@ -28,7 +28,7 @@ the browser work. | --- | --- | --- | | Overview | `GET /summary` (per project: asset counts, Sessions by status, usage, coverage, last activity); `/core/v1/sandbox` deployment and nodes; each project's Session list for the 24-hour activity chart and the Sessions needing attention | Session lists stop once they pass the 24-hour window and have found every Session the summary counts as needing attention, at most 1,000 per project; projects idle since before the window are not read | | Agent metrics | `GET /summary` to skip idle projects; each project's Session list, then Turns and Items of the most recently active Sessions through the project's scope; `GET /summary?group_by=key` for usage by API key | 2,000 Sessions listed per project; 200 Sessions read per load, 10 Turn pages and 5 Item pages per Session, 15 s per Session and 45 s per load | -| Sandbox metrics | `/core/v1/sandbox` nodes and allocations; `GET /runtime-observations` (every project); each hosted Session read by ID through its project; Runtime history per hosted Session | 100 hosted Sessions read per refresh; history covers at most 24 hosted Sessions; host figures are free memory/disk and CPU count only | +| Sandbox metrics | `/core/v1/sandbox` nodes and allocations; `GET /core/v1/sandbox/runtime-observations` (every project); each hosted Session read by ID through its project; Runtime history per hosted Session | 100 hosted Sessions read per refresh; history covers at most 24 hosted Sessions; host figures are free memory/disk and CPU count only | Consequences the console states in its help tips and warnings: @@ -54,7 +54,7 @@ operational evidence, not billing. ### P0 — Agent run aggregates -`GET /core/v1/admin/metrics/agent-runs?start=&end=&step=&group_by=model|agent|harness|project|key&project_id=` +`GET /core/v1/metrics/agent-runs?start=&end=&step=&group_by=model|agent|harness|project|key&project_id=` Per bucket, and per group when `group_by` is set: @@ -69,7 +69,7 @@ This replaces the Turn fan-out on Agent metrics and makes long ranges complete. ### P0 — Tool call aggregates -`GET /core/v1/admin/metrics/tool-calls?start=&end=&step=&group_by=tool|kind|agent&project_id=` +`GET /core/v1/metrics/tool-calls?start=&end=&step=&group_by=tool|kind|agent&project_id=` Per bucket and tool (`function` name, MCP `server_label` + name, shell command, web search, Subagent): calls, failures, and duration where the Item reports one. @@ -82,7 +82,7 @@ sandboxes, so it has no slots; the operator asked for its CPU and memory instead. Nothing reports them, so the console shows the Web API's reachability and maintenance state and "Not reported" for CPU and memory. -`GET /core/v1/admin/core-status` +`GET /core/v1/core-status` - Core version and process uptime. - Process CPU utilization (ratio of one core, averaged over a short window) and @@ -98,15 +98,15 @@ and maintenance state and "Not reported" for CPU and memory. key. Two Overview parts still read Session lists: - Sessions created and failed per bucket: - `GET /core/v1/admin/metrics/sessions?start=&end=&step=&project_id=`. + `GET /core/v1/metrics/sessions?start=&end=&step=&project_id=`. - Sessions needing attention across projects: - `GET /core/v1/admin/sessions?status=failed,requires_action&order=last_active_desc&limit=` + `GET /core/v1/sessions?status=failed,requires_action&order=last_active_desc&limit=` (each entry labelled with its project), or a `status` filter on the per-project Session list. ### P1 — Agents API request metrics -`GET /core/v1/admin/metrics/api-requests?start=&end=&step=&group_by=route|status_class|project|key` +`GET /core/v1/metrics/api-requests?start=&end=&step=&group_by=route|status_class|project|key` Recorded by the API router middleware: request count, 4xx/5xx counts and latency percentiles per route family (Sessions, events stream, Turns, Items, files …). @@ -123,16 +123,16 @@ history pattern, with the optional OTLP exporter as a secondary sink. ### P2 — Hosted Runtime rows with their Sessions -`GET /runtime-observations` labels each observation with its project but not -its Session's title, Agent, status or usage, so Sandbox metrics reads every -hosted Session by ID (bounded at 100 per refresh). An `expand=session` option -returning those fields would remove the reads. +`GET /core/v1/sandbox/runtime-observations` labels each observation with its +project but not its Session's title, Agent, status or usage, so Sandbox metrics +reads every hosted Session by ID (bounded at 100 per refresh). An +`expand=session` option returning those fields would remove the reads. ### P2 — Deployment configuration writes The console can only read whether each harness has a model endpoint configured at startup. A Web API write (for example -`PUT /core/v1/admin/deployment/model-providers/{harness}`, write-only credentials) +`PUT /core/v1/deployment/model-providers/{harness}`, write-only credentials) would let operators set deployment defaults. It must keep the precedence Session → Agent → deployment and never return secrets. diff --git a/docs/web/admin-metrics-backend-requirements.zh-CN.md b/docs/web/admin-metrics-backend-requirements.zh-CN.md index 06dcea1ad..93e54d164 100644 --- a/docs/web/admin-metrics-backend-requirements.zh-CN.md +++ b/docs/web/admin-metrics-backend-requirements.zh-CN.md @@ -2,12 +2,12 @@ 状态:待与 Core 负责人讨论的提案。除文中注明已存在的 Web API 路由外,其余内容均未实现。[English](admin-metrics-backend-requirements.md) -控制台是管理工具,以监控为先:概览、Agent 监控、沙箱监控、Session 日志。它只读取 Web API(`/core/v1/admin/**`)和沙箱管理路由(`/core/v1/sandbox/**`),从不调用 `/v1`。部分数字来自 Web API 的汇总,其余仍由浏览器对各项目做有上限的读取后自行汇总。本文说明这样做的代价、哪里不完整,以及哪些 Web API 接口可以替代浏览器端的计算。 +控制台是管理工具,以监控为先:概览、Agent 监控、沙箱监控、Session 日志。它只读取 Web API(`/core/v1/**`,包括 `/core/v1/sandbox/**` 下的沙箱管理路由),从不调用 `/v1`。部分数字来自 Web API 的汇总,其余仍由浏览器对各项目做有上限的读取后自行汇总。本文说明这样做的代价、哪里不完整,以及哪些 Web API 接口可以替代浏览器端的计算。 ## Core 的两类接口 1. **面向用户的 Agents API**(`/v1/**`):必须与锁定版本的 OpenAI Agents API 及已记录的 Core 扩展保持一致。指标相关工作不得在这里新增字段、路由或行为。 -2. **Web API**(`/core/v1/admin/**`,以及沙箱管理用的 `/core/v1/sandbox/**`):只由控制台服务端用部署管理员凭据调用。下文提议的所有接口都属于这一类,由 `services/core-console` 转发。 +2. **Web API**(`/core/v1/**`,包括沙箱管理用的 `/core/v1/sandbox/**`):只由控制台服务端用部署管理员凭据调用。下文提议的所有接口都属于这一类,由 `services/core-console` 转发。 ## 控制台目前如何计算 @@ -15,7 +15,7 @@ | --- | --- | --- | | 概览 | `GET /summary`(按项目:资产数量、各状态 Session 数、用量、覆盖率、最近活跃);`/core/v1/sandbox` 的部署与节点;读取各项目的 Session 列表,用于 24 小时活动图和需要处理的 Session | Session 列表读过 24 小时窗口、并找到汇总所计的全部需要处理的 Session 后即停止,每个项目最多 1,000 个;窗口开始前就不再活跃的项目不读取 | | Agent 监控 | 用 `GET /summary` 跳过不活跃的项目;读取各项目的 Session 列表,再经项目作用域读取最近活跃 Session 的 Turn 与 Item;按 API 密钥的用量来自 `GET /summary?group_by=key` | 每个项目最多列出 2,000 个 Session;每次最多读取 200 个 Session,每个 Session 最多 10 页 Turn、5 页 Item,每个 Session 限时 15 秒、每次加载 45 秒 | -| 沙箱监控 | `/core/v1/sandbox` 的节点与分配;`GET /runtime-observations`(全部项目);经所属项目按 ID 读取每个托管 Session;按托管 Session 读取运行时历史 | 每次刷新最多读取 100 个托管 Session;历史最多覆盖 24 个托管 Session;主机指标只有可用内存/磁盘和 CPU 核数 | +| 沙箱监控 | `/core/v1/sandbox` 的节点与分配;`GET /core/v1/sandbox/runtime-observations`(全部项目);经所属项目按 ID 读取每个托管 Session;按托管 Session 读取运行时历史 | 每次刷新最多读取 100 个托管 Session;历史最多覆盖 24 个托管 Session;主机指标只有可用内存/磁盘和 CPU 核数 | 控制台在问号提示和告警中说明的后果: @@ -32,7 +32,7 @@ ### P0:Agent 运行聚合 -`GET /core/v1/admin/metrics/agent-runs?start=&end=&step=&group_by=model|agent|harness|project|key&project_id=` +`GET /core/v1/metrics/agent-runs?start=&end=&step=&group_by=model|agent|harness|project|key&project_id=` 每个时间桶(设置 `group_by` 时再按分组)返回: @@ -47,7 +47,7 @@ ### P0:工具调用聚合 -`GET /core/v1/admin/metrics/tool-calls?start=&end=&step=&group_by=tool|kind|agent&project_id=` +`GET /core/v1/metrics/tool-calls?start=&end=&step=&group_by=tool|kind|agent&project_id=` 按时间桶和工具(`function` 名称、MCP 的 `server_label` 加名称、Shell 命令、网页搜索、子 Agent)返回调用数、失败数,以及 Item 上报时的耗时。它可以替代逐 Session 读取 Item。 @@ -55,7 +55,7 @@ 概览在沙箱宿主机旁边单独显示 Core 本身。Core 不运行沙箱,因此没有槽位;负责人要求改为显示它的 CPU 和内存。目前没有任何接口上报这些数据,所以控制台只显示 Web API 是否可访问、是否处于维护模式,CPU 和内存显示“未报告”。 -`GET /core/v1/admin/core-status` +`GET /core/v1/core-status` - Core 版本和进程运行时长。 - 进程 CPU 利用率(以一个核为单位,按短时间窗口平均),以及主机的 CPU 核数和利用率。 @@ -67,12 +67,12 @@ `GET /summary` 已经按项目、Agent 和 key 给出各状态的 Session 数。概览仍有两部分需要读取 Session 列表: -- 每个时间桶新建和失败的 Session:`GET /core/v1/admin/metrics/sessions?start=&end=&step=&project_id=`。 -- 跨项目需要处理的 Session:`GET /core/v1/admin/sessions?status=failed,requires_action&order=last_active_desc&limit=`(每条标明所属项目),或者在各项目的 Session 列表上支持 `status` 过滤。 +- 每个时间桶新建和失败的 Session:`GET /core/v1/metrics/sessions?start=&end=&step=&project_id=`。 +- 跨项目需要处理的 Session:`GET /core/v1/sessions?status=failed,requires_action&order=last_active_desc&limit=`(每条标明所属项目),或者在各项目的 Session 列表上支持 `status` 过滤。 ### P1:Agents API 请求指标 -`GET /core/v1/admin/metrics/api-requests?start=&end=&step=&group_by=route|status_class|project|key` +`GET /core/v1/metrics/api-requests?start=&end=&step=&group_by=route|status_class|project|key` 由 API 路由中间件记录:按路由族(Session、事件流、Turn、Item、文件等)统计请求数、4xx/5xx 数和延迟分位数。这就是托管平台控制台里的“请求数”和“错误率”。建议参照运行时历史,把汇总写入现有 PostgreSQL 并设保留期,可选 OTLP 导出作为第二去向。 @@ -83,11 +83,11 @@ ### P2:托管运行时连同 Session 信息 -`GET /runtime-observations` 为每条观测标明所属项目,但不含 Session 的标题、Agent、状态和用量,所以沙箱监控要按 ID 逐个读取托管 Session(每次刷新最多 100 个)。增加 `expand=session` 选项返回这些字段,就可以省掉这些读取。 +`GET /core/v1/sandbox/runtime-observations` 为每条观测标明所属项目,但不含 Session 的标题、Agent、状态和用量,所以沙箱监控要按 ID 逐个读取托管 Session(每次刷新最多 100 个)。增加 `expand=session` 选项返回这些字段,就可以省掉这些读取。 ### P2:部署配置写入 -控制台目前只能读到每个执行引擎在启动时是否配置了模型端点。Web API 的写接口(例如 `PUT /core/v1/admin/deployment/model-providers/{harness}`,凭据只写)可以让运维设置部署默认值。优先级必须保持“Session → Agent → 部署”,且绝不返回密钥。 +控制台目前只能读到每个执行引擎在启动时是否配置了模型端点。Web API 的写接口(例如 `PUT /core/v1/deployment/model-providers/{harness}`,凭据只写)可以让运维设置部署默认值。优先级必须保持“Session → Agent → 部署”,且绝不返回密钥。 ### P2:密钥 diff --git a/docs/web/architecture.md b/docs/web/architecture.md index c113ded25..1695a86d5 100644 --- a/docs/web/architecture.md +++ b/docs/web/architecture.md @@ -20,14 +20,15 @@ flowchart LR runtime["Runtime and native adapters"] browser -->|"Same-origin management requests; console login"| console - console -->|"/core/v1/admin and sandbox management; deployment credential"| core + console -->|"/core/v1 management, including sandbox; deployment credential"| core application -->|"/v1; Project API key"| core core <--> database core <--> runtime ``` -React management code must use `AdminClient` from `packages/agents-client`, plus the -existing sandbox management client for `/core/v1/sandbox`. The console service +React management code must use the Core clients from `packages/agents-client`: +`AdminClient` for `/core/v1`, `CoreMetricsClient` for `/core/v1/metrics` and the +sandbox management client for `/core/v1/sandbox`. The console service returns 404 for `/v1` and `/api/v1`, including requests with an explicit Bearer token. It has no application key and does not impersonate the selected Project. diff --git a/docs/web/core-connection.md b/docs/web/core-connection.md index ae79ed356..7a4748620 100644 --- a/docs/web/core-connection.md +++ b/docs/web/core-connection.md @@ -6,8 +6,9 @@ React screens that use it are implemented; the console has no execution controls ## Connection model -The browser calls same-origin `/core/v1/admin` through `AdminClient` and existing -`/core/v1/sandbox` management routes through the sandbox client. The console server +The browser calls same-origin `/core/v1` through `AdminClient` and +`CoreMetricsClient`, and the `/core/v1/sandbox` management routes through the +sandbox client. The console server supplies the deployment administrator credential to its configured Core upstream. Browser code must never receive that credential. @@ -68,7 +69,7 @@ work. Ordinary metadata reads cannot recover plaintext. ## Verification and diagnosis 1. Core `/healthz` proves process liveness only. -2. Console login followed by `GET /core/v1/admin/projects` proves the authenticated +2. Console login followed by `GET /core/v1/projects` proves the authenticated browser-to-console and console-to-Core path. 3. A Project key must work on its public resources and fail on management routes. The deployment credential must fail on `/v1`; `/v1` through the console stays 404. diff --git a/docs/web/core-process-metrics-requirements.md b/docs/web/core-process-metrics-requirements.md index 6cc86b46e..638993109 100644 --- a/docs/web/core-process-metrics-requirements.md +++ b/docs/web/core-process-metrics-requirements.md @@ -14,7 +14,7 @@ charges the process nor what a container limit is compared against. ## Contract -Reuse `GET /core/v1/admin/core-metrics?range=1h|6h|24h|7d`: add fields to +Reuse `GET /core/v1/metrics?range=1h|6h|24h|7d`: add fields to `process`; change nothing else. `memory_bytes` and `goroutines` keep their meaning. Every figure Core cannot measure is `null`, never `0`. Measured zero remains zero. New current values expire after 60 seconds without diff --git a/docs/web/protocol-coverage.md b/docs/web/protocol-coverage.md index 93440cded..c9b37be37 100644 --- a/docs/web/protocol-coverage.md +++ b/docs/web/protocol-coverage.md @@ -6,8 +6,8 @@ that inventory, its pinned baseline and its evidence live in the [Agents API contract](../../contracts/agents-api/README.md). The console is a management tool. It reads and deletes each project's assets and -manages projects and keys through the administrator API (`/core/v1/admin/**`), and -it administers sandbox nodes through `/core/v1/sandbox/**`. It sends no request to +manages projects and keys through the administrator API (`/core/v1/**`), and it +administers sandbox nodes through `/core/v1/sandbox/**`. It sends no request to the Agents API (`/v1/**`). Routes, response shapes, pagination and audit records of the administrator API are defined by the [administrator API contract](../../contracts/agents-api/admin-api.md). @@ -15,9 +15,9 @@ the administrator API are defined by the [administrator API contract](../../cont | Interface | Paths | Authentication | Console use | | --- | --- | --- | --- | -| Console server | `/console/auth`, `/console/auth/{login,logout}`, `/console/config` | Core key at sign-in, then the console session cookie | Sign-in with the Core key and sign-out; non-secret capability flags such as `sandbox_admin` | -| Administrator API | `/core/v1/admin/**` | Deployment administrator credential, added by the console server | Projects, keys, resource reads and deletion, provenance, summaries, Runtime observations | -| Sandbox administration | `/core/v1/sandbox/**` | Deployment administrator credential, added by the console server | Nodes page; fleet and capacity figures on Overview and Sandbox metrics | +| Console server | `/console/auth`, `/console/auth/{login,logout}`, `/console/config` | Core key at sign-in, then the console session cookie | Sign-in with the Core key and sign-out; the node installer (`node_installer`, `node_installer_sha256`) | +| Administrator API | `/core/v1/**` outside `/core/v1/sandbox` | Deployment administrator credential, added by the console server | Projects, keys, resource reads and deletion, executor credentials, provenance, summaries, Core metrics | +| Sandbox administration | `/core/v1/sandbox/**` | Deployment administrator credential, added by the console server | Nodes page; fleet and capacity figures on Overview and Sandbox metrics; Runtime observations of every project | | Agents API | `/v1/**` | Project API key | Not used. The first-run screen shows a `curl` example for `/v1/agents` with a `$PROJECT_API_KEY` placeholder; the console never sends it | Browser requests are same-origin and carry only the console session. The browser @@ -30,13 +30,13 @@ value. | Operation | Route | Console use | | --- | --- | --- | -| List projects | `GET /projects` | Project filter on every project-scoped page; Projects and keys list; first-run detection (no project opens the first-run screen) | -| Create project | `POST /projects` | **Create project**; first run (default name `Default`) | -| Rename project | `POST /projects/{project_id}` | **Rename** on an active project; the ID stays the same | -| Archive project | `POST /projects/{project_id}/archive` | **Archive**: revokes every key; the project's assets stay readable and deletable | -| List keys | `GET /projects/{project_id}/keys` | Key table of a project: name, prefix, status, creation and revocation time | -| Issue key | `POST /projects/{project_id}/keys` | **Issue key** on an active project and the first-run screen; the plaintext is shown once | -| Revoke key | `DELETE /projects/{project_id}/keys/{key_id}` | **Revoke**, with a warning when it is the project's last active key | +| List projects | `GET /core/v1/projects` | Project filter on every project-scoped page; Projects and keys list; first-run detection (no project opens the first-run screen) | +| Create project | `POST /core/v1/projects` | **Create project**; first run (default name `Default`) | +| Rename project | `POST /core/v1/projects/{project_id}` | **Rename** on an active project; the ID stays the same | +| Archive project | `POST /core/v1/projects/{project_id}/archive` | **Archive**: revokes every key; the project's assets stay readable and deletable | +| List keys | `GET /core/v1/projects/{project_id}/keys` | Key table of a project: name, prefix, status, creation and revocation time | +| Issue key | `POST /core/v1/projects/{project_id}/keys` | **Issue key** on an active project and the first-run screen; the plaintext is shown once | +| Revoke key | `DELETE /core/v1/projects/{project_id}/keys/{key_id}` | **Revoke**, with a warning when it is the project's last active key | Names are checked for length (projects 1–128 characters, keys 1–80) and control characters before sending. An issued key's plaintext stays in component memory @@ -45,7 +45,7 @@ storage, URLs or logs. There is no project deletion and no plaintext recovery. ## Project resources -Routes are relative to `/core/v1/admin/projects/{project_id}` and return the same +Routes are relative to `/core/v1/projects/{project_id}` and return the same objects as the corresponding public `/v1` operations, so the console applies the public client's strict projections. Runtime observation and history exist only here. Archived projects remain readable. @@ -79,20 +79,32 @@ Resource-specific boundaries: skipped. The console does not read single Turns, Artifacts, execution configuration or Environment resources. +## Executor credentials + +Core issues the credentials of a self_hosted executor. The routes accept only +the self_hosted environment of a Session that is still running; any other +environment returns 404 or 409. + +| Operation | Route | Console use | +| --- | --- | --- | +| List credentials | `GET /core/v1/projects/{project_id}/environments/{environment_id}/executor-credentials` | Reserved for the Session page's executor credential section: `key_id`, creation and revocation time; the credential itself is never listed | +| Issue or rotate | `POST /core/v1/projects/{project_id}/environments/{environment_id}/executor-credentials` with `{"key_id", "rotate"}` | Reserved: the console generates `key_id` when the administrator issues; Core returns the credential once (`201`, `Cache-Control: no-store`). An existing `key_id` without `rotate: true` returns 409 | +| Revoke | `DELETE /core/v1/projects/{project_id}/environments/{environment_id}/executor-credentials/{key_id}` | Reserved: confirmed revocation; revoking again returns 204 | + ## Provenance and monitoring | Operation | Route | Console use | | --- | --- | --- | -| Resource owners | `GET /projects/{project_id}/resource-owners` | The Creator column of every resource list and the creator fact of detail pages, in batches of up to 100 IDs. An asset an administrator copied in an earlier release shows **Admin copy**; a resource without a record shows **Unknown** | -| Write operations | `GET /projects/{project_id}/write-operations` | A project's write history, newest first, filtered by key and resource type, 50 per page | -| Summary | `GET /summary` | Overview (per project), the Agents list (`group_by=agent`), a project's page (per project and `group_by=key`), Agent metrics (to skip idle projects, and usage by creating key since the start of the range), the Projects list (last activity) | -| Runtime observations | `GET /runtime-observations` | Sandbox metrics: hosted Runtimes of every project, each labelled with its project; an E2B sandbox's dialog adds its `observation.disk` as used / limit (null elsewhere) | -| Core metrics | `GET /core-metrics?range=` | Core metrics page; the Core popover on Overview. A Core without the route (404) is shown as not reporting; the popover then shows only Core's status. Measurements are defined in the [Core metrics contract](../../contracts/agents-api/core-metrics.md); the Process section's CPU and resident memory are a [requested extension](core-process-metrics-requirements.md) and show as missing until Core reports them | +| Resource owners | `GET /core/v1/projects/{project_id}/resource-owners` | The Creator column of every resource list and the creator fact of detail pages, in batches of up to 100 IDs. An asset an administrator copied in an earlier release shows **Admin copy**; a resource without a record shows **Unknown** | +| Write operations | `GET /core/v1/projects/{project_id}/write-operations` | A project's write history, newest first, filtered by key and resource type, 50 per page | +| Summary | `GET /core/v1/summary` | Overview (per project), the Agents list (`group_by=agent`), a project's page (per project and `group_by=key`), Agent metrics (to skip idle projects, and usage by creating key since the start of the range), the Projects list (last activity) | +| Core metrics | `GET /core/v1/metrics?range=` | Core metrics page; the Core popover on Overview. A Core without the route (404) is shown as not reporting; the popover then shows only Core's status. Measurements are defined in the [Core metrics contract](../../contracts/agents-api/core-metrics.md); the Process section's CPU and resident memory are a [requested extension](core-process-metrics-requirements.md) and show as missing until Core reports them | Summary figures are cumulative per Session and are not billing records. Sessions without reported usage count toward coverage but not toward token sums, and the console shows missing values as missing, never as zero. The administrator audit -log (`GET /audit-log`) is not consumed; System shows the sandbox deployment only. +log (`GET /core/v1/audit-log`) is not consumed; System shows the sandbox +deployment only. ## Sandbox administration @@ -106,8 +118,11 @@ log (`GET /audit-log`) is not consumed; System shows the sandbox deployment only | Enrollment | `POST /core/v1/sandbox/enrollment-tokens` | **Add node**: the administrator sets the node's sandbox limits (`max_active`; `max_retained` only for microsandbox, equal to `max_active` for Docker) before Core issues a single-use token inside a command that verifies the installer checksum | | Update node | `PATCH /core/v1/sandbox/nodes/{node_id}` | **Edit node**: the name and sandbox limits together (the retained limit only for microsandbox; under Docker, Core sets it to the active limit) | | Remove node | `DELETE /core/v1/sandbox/nodes/{node_id}` | Confirmed node removal; the row goes only after Core acknowledges the deletion | +| Runtime observations | `GET /core/v1/sandbox/runtime-observations` | Sandbox metrics: hosted Runtimes of every project, each labelled with its project; an E2B sandbox's dialog adds its `observation.disk` as used / limit (null elsewhere) | -These pages appear only when `/console/config` reports `sandbox_admin: true`. An E2B +Signing in grants administration, so `/console/config` reports only the node +installer (`node_installer`, `node_installer_sha256`). These pages appear unless +the console has no `/console/config` (404) or reports `sandbox_admin: false`. An E2B deployment has no nodes; its API key is write-only. The Runtime release sent for Docker and microsandbox comes from the console's own `GET /node-install/manifest.json` (the distribution manifest the node installer uses); without it the administrator @@ -121,7 +136,10 @@ enters the release under advanced settings. as uncertain and followed by a fresh read. - The console offers Session deletion only for idle or failed Sessions without required actions and never cancels work to make a Session deletable. -- Project, key, deletion and sandbox writes are never retried automatically. +- Project, key, executor credential, deletion and sandbox writes are never + retried automatically. After an uncertain executor credential issuance the + console refreshes the list; the administrator may reissue the same `key_id` + with `rotate: true`. ## Read bounds @@ -143,7 +161,7 @@ The aggregate endpoints that would replace these browser reads are proposed in stream, message input, function results and cancellation. - Creation or update of Agents, Environment templates, Skills, Files, Vaults or Credentials, including uploads and Credential token replacement. -- Environment resources, Environment Files, executor credentials and Artifacts. +- Environment resources, Environment Files and Artifacts. ## Terminology @@ -151,7 +169,7 @@ The aggregate endpoints that would replace these browser reads are proposed in [Agents guide](https://developers.openai.com/api/docs/guides/agents). Parsar Core implements part of its pinned beta resource shape under `/v1`. - **Administrator API** (also called the Web API) is Parsar Core's management - extension under `/core/v1/admin`. It is not part of the public Agents API. + extension under `/core/v1`. It is not part of the public Agents API. - **OpenAI Agents SDK** and **Responses API** are different interfaces and are not used by the console. From b13f18cd7f3fb7465f6a0703ce383c4a993e4427 Mon Sep 17 00:00:00 2001 From: saladday <1203511142@qq.com> Date: Fri, 25 Sep 2026 15:38:09 +0000 Subject: [PATCH 02/11] Check the project reader against Core's client type --- apps/web/src/lib/projects.tsx | 5 ++--- 1 file changed, 2 insertions(+), 3 deletions(-) diff --git a/apps/web/src/lib/projects.tsx b/apps/web/src/lib/projects.tsx index 897384629..5a565c72d 100644 --- a/apps/web/src/lib/projects.tsx +++ b/apps/web/src/lib/projects.tsx @@ -126,7 +126,7 @@ function createProjectClient(projectId: string): CoreProjectReader { const page = await admin.listSessions(projectId, { after: options.after, limit: options.limit, order: options.order, agentId: options.agentId, signal: options.signal }); return { ...page, object: "list" as const, first_id: page.first_id ?? null, last_id: page.last_id ?? null }; }; - const client = { + return { listAgents: (options) => admin.listAgents(projectId, options), retrieveAgent: (agentId: string) => admin.retrieveAgent(projectId, agentId), deleteAgent: (agentId: string) => admin.deleteAgent(projectId, agentId), @@ -156,8 +156,7 @@ function createProjectClient(projectId: string): CoreProjectReader { listItems: (sessionId, options) => admin.listItems(projectId, sessionId, options), retrieveRuntimeObservation: (sessionId, options) => admin.retrieveRuntimeObservation(projectId, sessionId, options), retrieveRuntimeHistory: (sessionId, query) => admin.retrieveRuntimeHistory(projectId, sessionId, query), - } as CoreProjectReader; - return client; + } satisfies CoreProjectReader; } const clients = new Map(); From e56a5dffb55db0d4e65a1eec4b2a1cb40e886ca5 Mon Sep 17 00:00:00 2001 From: saladday <1203511142@qq.com> Date: Fri, 25 Sep 2026 13:33:16 +0000 Subject: [PATCH 03/11] Issue, rotate and revoke executor credentials from a self-hosted Session --- apps/web/PRODUCT.md | 10 +- apps/web/e2e/monitoring.spec.ts | 25 ++ .../sessions/ExecutorCredentialsSection.tsx | 311 ++++++++++++++++++ .../web/src/features/sessions/SessionPage.tsx | 13 +- .../src/features/sessions/session-queries.ts | 20 +- apps/web/src/features/sessions/sessions.css | 73 ++++ apps/web/src/i18n/locales/en/sessions.ts | 53 +++ apps/web/src/i18n/locales/zh-CN/sessions.ts | 53 +++ docs/web/protocol-coverage.md | 23 +- 9 files changed, 568 insertions(+), 13 deletions(-) create mode 100644 apps/web/src/features/sessions/ExecutorCredentialsSection.tsx diff --git a/apps/web/PRODUCT.md b/apps/web/PRODUCT.md index 7f88d7842..05a1efc37 100644 --- a/apps/web/PRODUCT.md +++ b/apps/web/PRODUCT.md @@ -66,7 +66,8 @@ workbench. duration, tokens, models, tools, Agents and API keys for 1 h / 6 h / 24 h / 7 d), Sandbox metrics (node capacity and hosted Runtimes across projects; a node or a sandbox opens in a dialog with its figures and CPU and memory charts), Session log - (every Session, read-only, opening one Session's history). + (every Session, read-only, opening one Session's history; a self-hosted + Session's page also has its environment's executor credentials). - **Resources**: Agents, Environment templates, Skills, Files, Vaults. Each list shows one project or all projects, with a Project column when all are shown and a Creator column naming the creating key. Detail pages show the resource's facts @@ -123,6 +124,13 @@ workbench. without a record as Unknown. - **Session history is read-only.** A Session page reads the Session, its Items and Turns and polls while work is in flight; there is no live event stream. +- **Executor credentials.** Only Core issues the credential file a self-hosted + executor needs, with the deployment's Core key. A Session page whose environment + is self-hosted has an Executor credentials section: issue a credential (shown + once as the credential file, to copy or download, never stored), rotate it (the + old one stops working immediately) or revoke it (the executor can no longer + connect; a running process is not stopped). The file lets one executor connect + for that environment only; it cannot call the Agents API. - **Figures.** Project, Agent and key usage comes from Core's summary; Agent run, tool and activity figures are still assembled in the browser from bounded reads and state their coverage. Metrics that need new Core endpoints are recorded as diff --git a/apps/web/e2e/monitoring.spec.ts b/apps/web/e2e/monitoring.spec.ts index 309b0cae8..737041e36 100644 --- a/apps/web/e2e/monitoring.spec.ts +++ b/apps/web/e2e/monitoring.spec.ts @@ -32,6 +32,31 @@ test("opens a Session's conversation from the Session log, read-only", async ({ await expect(page.getByRole("textbox")).toHaveCount(0); }); +test("issues an executor credential once on a self-hosted Session and revokes it", async ({ page, request }) => { + await openConsole(page, request, "sessions"); + await page.getByRole("row").filter({ hasText: "Self-hosted" }).first().getByRole("button", { name: /^Open Session / }).click(); + const section = page.getByRole("region", { name: "Executor credentials" }); + await expect(section).toContainText("No executor credentials yet"); + + await section.getByRole("button", { name: "Issue credential" }).first().click(); + const issued = page.getByRole("dialog", { name: "Executor credential" }); + await expect(issued).toContainText("shown only once"); + await expect(issued.getByLabel("Executor credential file")).toContainText("exec_fixture_"); + const download = page.waitForEvent("download"); + await issued.getByRole("button", { name: "Download credential file" }).click(); + expect((await download).suggestedFilename()).toMatch(/^executor-credential-[0-9a-f]{8}\.json$/); + await issued.getByRole("button", { name: "Done" }).click(); + await expect(page.getByLabel("Executor credential file")).toHaveCount(0); + expect(await page.evaluate(() => JSON.stringify({ ...window.localStorage, ...window.sessionStorage }))).not.toContain("exec_fixture_"); + + const credentials = section.getByRole("table", { name: "Executor credentials" }); + await expect(credentials).toContainText("Active"); + await credentials.getByRole("button", { name: /^Revoke credential / }).click(); + await page.getByRole("dialog", { name: "Revoke credential?" }).getByRole("button", { name: "Revoke" }).click(); + await expect(credentials).toContainText("Revoked"); + await expect(credentials.getByRole("button", { name: /^Rotate credential / })).toHaveCount(0); +}); + test("opens a node and a sandbox in dialogs from Sandbox metrics", async ({ page, request }) => { await openConsole(page, request, "sandbox-metrics"); await page.getByRole("button", { name: "Show core-01" }).click(); diff --git a/apps/web/src/features/sessions/ExecutorCredentialsSection.tsx b/apps/web/src/features/sessions/ExecutorCredentialsSection.tsx new file mode 100644 index 000000000..3d2766b11 --- /dev/null +++ b/apps/web/src/features/sessions/ExecutorCredentialsSection.tsx @@ -0,0 +1,311 @@ +import { AgentCoreError, type IssuedExecutorCredential } from "@agents-core-web/agents-client"; +import { useQuery } from "@tanstack/react-query"; +import { Check, Copy, Download, Plus } from "lucide-react"; +import { useEffect, useState } from "react"; +import { useTranslation } from "react-i18next"; + +import { ConfirmDialog } from "../../components/ConfirmDialog"; +import { EmptyState, Section, StatusDot } from "../../components/console-ui"; +import { ErrorDialog } from "../../components/ErrorDialog"; +import { CopyableId, RowActions } from "../../components/list-ui"; +import { Modal } from "../../components/Modal"; +import { TableSkeleton } from "../../components/Skeleton"; +import { failedLast, useFailureToast, useToast } from "../../components/Toast"; +import { useDeleteFlow } from "../../lib/delete-flow"; +import { formatDateTime, shortId } from "../../lib/format"; +import { admin } from "../../lib/projects"; +import { useCopy } from "../api-keys/IssuedKey"; +import { saveBlob } from "../skills/skill-operations"; +import { executorCredentialsQuery } from "./session-queries"; + +/** An issuance that gets no answer in this time has an unknown outcome. */ +const WRITE_TIMEOUT_MS = 30_000; + +/** A key ID for a new credential. Plain-HTTP consoles lack `crypto.randomUUID`, so it falls back to a v4 UUID. */ +function newKeyId(): string { + if (typeof crypto.randomUUID === "function") return crypto.randomUUID(); + const bytes = crypto.getRandomValues(new Uint8Array(16)); + bytes[6] = (bytes[6]! & 0x0f) | 0x40; + bytes[8] = (bytes[8]! & 0x3f) | 0x80; + const hex = [...bytes].map((byte) => byte.toString(16).padStart(2, "0")).join(""); + return `${hex.slice(0, 8)}-${hex.slice(8, 12)}-${hex.slice(12, 16)}-${hex.slice(16, 20)}-${hex.slice(20)}`; +} + +/** The self-hosted executor's credential file. */ +function credentialFile(credential: IssuedExecutorCredential): string { + const { key_id, environment_id, executor_token } = credential; + return `${JSON.stringify({ key_id, environment_id, executor_token }, null, 2)}\n`; +} + +/** + * A write either failed before Core acted ("rejected", with Core's reason) or + * has an unknown outcome: no answer, a timeout, a 5xx or an unreadable reply. + */ +type WriteFailure = { kind: "rejected"; message: string; code: string | null } | { kind: "uncertain" }; + +function writeFailure(error: unknown): WriteFailure { + if (error instanceof AgentCoreError && error.status >= 400 && error.status < 500 && error.status !== 408) { + return { kind: "rejected", message: error.message, code: error.code ?? null }; + } + return { kind: "uncertain" }; +} + +function message(error: unknown): string { + return error instanceof Error ? error.message : String(error ?? ""); +} + +function seconds(value: string | null): number | null { + return value === null ? null : Math.floor(Date.parse(value) / 1000); +} + +/** + * Executor credentials of a self-hosted Session's environment: Core issues, + * rotates and revokes them with the deployment's Core key. An issued + * credential is shown once, lives only in this component's state and is + * forgotten when its dialog closes. Writes are never retried automatically: + * an issuance with an unknown outcome keeps its key ID, and after the list is + * refreshed the administrator rotates it (issued, secret lost) or issues it + * again (not issued). + */ +export function ExecutorCredentialsSection({ projectId, sessionId, environmentId }: { projectId: string; sessionId: string; environmentId: string }) { + const { t, i18n } = useTranslation("sessions"); + const locale = i18n.resolvedLanguage; + const toast = useToast(); + const query = useQuery(executorCredentialsQuery(projectId, sessionId, environmentId)); + const credentials = query.data ?? null; + const { refetch } = query; + const reread = () => { void refetch(); }; + useFailureToast(credentials && failedLast(query) ? message(query.error) : null, t("executor.refreshFailed"), "executor-credentials-read"); + + // The key ID of an issuance in flight or with an unknown outcome; the next Issue sends it again. + const [kept, setKept] = useState(null); + const [issuing, setIssuing] = useState(false); + // The unknown-outcome dialog; the key ID stays for its closing animation. + const [uncertain, setUncertain] = useState<{ keyId: string; open: boolean } | null>(null); + const [rotation, setRotation] = useState<{ keyId: string; lost: boolean } | null>(null); + const [rotating, setRotating] = useState(false); + const [rotationError, setRotationError] = useState(null); + const [shown, setShown] = useState<{ credential: IssuedExecutorCredential; open: boolean } | null>(null); + const revoke = useDeleteFlow( + (keyId) => admin.revokeExecutorCredential(projectId, environmentId, keyId), + reread, + { uncertain: t("executor.revokeDialog.uncertain") }, + ); + const busy = issuing || rotating || revoke.busy; + + // Closing the dialog forgets the credential once the dialog has faded out. + useEffect(() => { + if (!shown || shown.open) return; + const timer = window.setTimeout(() => setShown(null), 250); + return () => window.clearTimeout(timer); + }, [shown]); + // A reload or a closed tab would lose a credential that is shown only once. + const showing = shown?.open ?? false; + useEffect(() => { + if (!showing) return; + const guard = (event: BeforeUnloadEvent) => { event.preventDefault(); }; + window.addEventListener("beforeunload", guard); + return () => window.removeEventListener("beforeunload", guard); + }, [showing]); + + const openRotation = (keyId: string, lost: boolean) => { setRotationError(null); setRotation({ keyId, lost }); }; + + const issue = async () => { + if (busy) return; + const keyId = kept ?? newKeyId(); + setKept(keyId); + setIssuing(true); + try { + const credential = await admin.issueExecutorCredential(projectId, environmentId, { key_id: keyId, rotate: false }, { signal: AbortSignal.timeout(WRITE_TIMEOUT_MS) }); + setKept(null); + setShown({ credential, open: true }); + reread(); + } catch (error) { + const failure = writeFailure(error); + if (failure.kind === "uncertain") { + setUncertain({ keyId, open: true }); + return; + } + setKept(null); + // An earlier attempt with this key ID was issued after all; its secret never arrived. + if (failure.code === "executor_credential_exists") openRotation(keyId, true); + else toast.show(t("executor.issueRejected"), { tone: "error", detail: failure.message }); + reread(); + } finally { + setIssuing(false); + } + }; + + // After an unknown outcome: issued (rotate it for a fresh secret) or not (the next Issue reuses the key ID). + const checkKept = async (keyId: string) => { + const result = await refetch(); + if (!result.data || result.isError) return; + const found = result.data.find((credential) => credential.key_id.toLowerCase() === keyId.toLowerCase()); + if (!found) { + toast.show(t("executor.notIssued", { id: shortId(keyId) }), { tone: "info" }); + return; + } + setKept(null); + if (found.revoked_at === null) openRotation(found.key_id, true); + }; + + const rotate = async () => { + if (!rotation || busy) return; + setRotating(true); + setRotationError(null); + try { + const credential = await admin.issueExecutorCredential(projectId, environmentId, { key_id: rotation.keyId, rotate: true }, { signal: AbortSignal.timeout(WRITE_TIMEOUT_MS) }); + setRotation(null); + setShown({ credential, open: true }); + } catch (error) { + const failure = writeFailure(error); + setRotationError(failure.kind === "uncertain" ? t("executor.rotateDialog.uncertain") : t("executor.rotateDialog.rejected", { reason: failure.message })); + } finally { + setRotating(false); + reread(); + } + }; + + const issueButton = ( + + ); + + let body; + if (!credentials) { + body = query.isError + ? {t("detail.retry")}} /> + : ; + } else if (!credentials.length) { + body = ; + } else { + body = ( +
+ + + + + + + + + + + {credentials.map((credential) => { + const revoked = credential.revoked_at !== null; + const id = shortId(credential.key_id); + return ( + + + + + + + ); + })} + +
{t("executor.columns.credential")}{t("executor.columns.issued")}{t("executor.columns.status")}{t("log.actions")}
{formatDateTime(seconds(credential.created_at), locale)} + + + {revoked ? {formatDateTime(seconds(credential.revoked_at), locale)} : null} + + + {revoked ? null : ( + + + + + )} +
+
+ ); + } + + const hideShown = () => setShown((current) => current && { ...current, open: false }); + + return ( +
+ {body} + {t("executor.issued.done")}} + > + {shown ? : null} + + void checkKept(uncertain.keyId) } : undefined} + onClose={() => setUncertain((current) => current && { ...current, open: false })} + > +

{t("executor.uncertain.body", { id: uncertain ? shortId(uncertain.keyId) : "" })}

+

{t("executor.uncertain.next")}

+
+ void rotate()} + onClose={() => { if (!rotating) setRotation(null); }} + > + {rotation ? ( + <> +

{t(rotation.lost ? "executor.rotateDialog.lost" : "executor.rotateDialog.prompt", { id: shortId(rotation.keyId) })}

+

{t("executor.rotateDialog.consequence")}

+ + ) : null} +
+ void revoke.confirm()} + onClose={revoke.cancel} + > + {revoke.target ? ( + <> +

{t("executor.revokeDialog.prompt", { id: shortId(revoke.target) })}

+

{t("executor.revokeDialog.consequence")}

+ + ) : null} +
+
+ ); +} + +/** The one-time credential file, with copy and download. */ +function CredentialFile({ credential }: { credential: IssuedExecutorCredential }) { + const { t } = useTranslation("sessions"); + const text = credentialFile(credential); + const { state, copy } = useCopy(text); + const download = () => saveBlob(new Blob([text], { type: "application/json" }), `executor-credential-${credential.environment_id.slice(0, 8)}.json`); + return ( +
+

{t("executor.issued.notice")}

+
{text}
+
+ + +
+ {state === "failed" ?

{t("executor.issued.copyFailed")}

: null} +
+ ); +} diff --git a/apps/web/src/features/sessions/SessionPage.tsx b/apps/web/src/features/sessions/SessionPage.tsx index 65347b629..d85763009 100644 --- a/apps/web/src/features/sessions/SessionPage.tsx +++ b/apps/web/src/features/sessions/SessionPage.tsx @@ -13,6 +13,7 @@ import { formatClock, formatDateTime, formatInteger, MISSING } from "../../lib/f import { CreatorCell, ProjectName, useCreators, useProjects } from "../../lib/projects"; import { collections } from "../../lib/queries"; import { forgetDeleted } from "../resources/detail-queries"; +import { ExecutorCredentialsSection } from "./ExecutorCredentialsSection"; import { SessionDeleteDialog, type SessionDeleteTarget } from "./SessionDeleteDialog"; import { sessionKey } from "./session-queries"; import { isSessionActive } from "./session-history"; @@ -38,7 +39,8 @@ function errorText(error: unknown): string { /** * Monitor › Session log › one Session: its facts, usage, conversation, trace, - * Turns and (for hosted sandboxes) runtime, read-only. The page polls while the + * Turns and (for hosted sandboxes) runtime, read-only; a self-hosted Session's + * environment also has its executor credentials. The page polls while the * Session has work in flight; there is no composer and no live stream. */ export function SessionPage() { @@ -66,7 +68,11 @@ export function SessionPage() { ); const session = history.history?.session ?? null; const loadedAt = history.history?.loadedAt ?? null; - const refresh = () => { setRefreshToken((value) => value + 1); history.refresh(); }; + const refresh = () => { + setRefreshToken((value) => value + 1); + history.refresh(); + if (projectId && sessionId) void queryClient.invalidateQueries({ queryKey: [...sessionKey(projectId, sessionId), "executor-credentials"] }); + }; const time = loadedAt ? formatClock(loadedAt, locale) : MISSING; const itemsError = session ? history.history?.itemsError ?? null : null; const turnsError = session ? history.history?.turnsError ?? null : null; @@ -142,6 +148,9 @@ export function SessionPage() { + {environment.type === "self_hosted" && typeof environment.id === "string" ? ( + + ) : null}
({ + queryKey: [...sessionKey(projectId, sessionId), "executor-credentials", environmentId], + queryFn: async ({ signal }) => { + const { data } = await admin.listExecutorCredentials(projectId, environmentId, { signal }); + return [...data].sort((a, b) => Number(a.revoked_at !== null) - Number(b.revoked_at !== null) || Date.parse(b.created_at) - Date.parse(a.created_at)); + }, + retry: retryTransient, + }); +} diff --git a/apps/web/src/features/sessions/sessions.css b/apps/web/src/features/sessions/sessions.css index d33cf34a7..978a79032 100644 --- a/apps/web/src/features/sessions/sessions.css +++ b/apps/web/src/features/sessions/sessions.css @@ -340,3 +340,76 @@ width: 232px; } + +/* Executor credentials of a self-hosted Session's environment. */ +.executor-credentials-table .name-cell .copyable-id code { + color: var(--fg); + font-size: 12px; +} + +.executor-credential-revoked .name-cell .copyable-id code { + color: var(--fg-muted); +} + +.executor-credential-status { + display: inline-flex; + flex-direction: column; + gap: 1px; +} + +.executor-credential-date { + padding-left: 13px; + color: var(--fg-muted); + font-size: 12px; + white-space: nowrap; + font-variant-numeric: tabular-nums; +} + +/* The one-time credential file. */ +.modal-card:has(.executor-credential) { + width: min(560px, 100%); +} + +.executor-credential { + display: grid; + gap: 12px; +} + +.executor-credential-notice { + margin: 0; + color: var(--fg); + font-size: 13px; + font-weight: 500; + line-height: 20px; +} + +.executor-credential-file { + margin: 0; + padding: 11px 13px; + overflow-x: auto; + color: var(--fg); + font-family: var(--font-mono); + font-size: 12px; + line-height: 19px; + background: var(--surface-subtle); + border: 1px solid var(--line); + border-radius: 8px; +} + +.executor-credential-file:focus-visible { + outline: 2px solid var(--accent); + outline-offset: 1px; +} + +.executor-credential-actions { + display: flex; + flex-wrap: wrap; + gap: 8px; +} + +.executor-credential-error { + margin: 0; + color: var(--danger); + font-size: 12.5px; + line-height: 18px; +} diff --git a/apps/web/src/i18n/locales/en/sessions.ts b/apps/web/src/i18n/locales/en/sessions.ts index e39650875..e32e28cf6 100644 --- a/apps/web/src/i18n/locales/en/sessions.ts +++ b/apps/web/src/i18n/locales/en/sessions.ts @@ -145,6 +145,59 @@ export const sessions = { historyFailed: "Runtime history could not be loaded: {{reason}}", historyLoading: "Loading runtime history…", }, + executor: { + title: "Executor credentials", + help: "A credential file lets one self-hosted executor connect to Core for this environment only. It cannot call the Agents API. Hand it to the executor host and keep it private. Core shows each credential once; rotate a credential to replace a lost file.", + issue: "Issue credential", + issuing: "Issuing…", + loading: "Loading executor credentials…", + loadFailed: "Executor credentials could not be loaded", + refreshFailed: "Refresh failed; showing the last loaded executor credentials.", + empty: "No executor credentials yet", + columns: { credential: "Credential", issued: "Issued", status: "Status" }, + status: { active: "Active", revoked: "Revoked" }, + copyKeyId: "Copy credential ID", + rotate: "Rotate", + rotateLabel: "Rotate credential {{id}}", + revoke: "Revoke", + revokeLabel: "Revoke credential {{id}}", + issueRejected: "The executor credential was not issued", + notIssued: "Credential {{id}} was not issued. Issue a credential again when you are ready.", + issued: { + title: "Executor credential", + notice: "This credential is shown only once. Copy it or download the file now: after you close this dialog it cannot be shown again.", + fileLabel: "Executor credential file", + copy: "Copy", + copied: "Copied", + copyFailed: "The credential could not be copied. Select the text and copy it yourself.", + download: "Download credential file", + done: "Done", + }, + uncertain: { + title: "Couldn't confirm the credential", + body: "Core did not confirm whether credential {{id}} was issued, and the console did not try again.", + next: "Refresh the list. If the credential appears, rotate it to receive its secret; if it does not, issue a credential again.", + refresh: "Refresh list", + }, + rotateDialog: { + title: "Rotate credential?", + prompt: "Credential {{id}} gets a new secret.", + lost: "Core issued credential {{id}}, but its secret never reached the console. Rotate it to receive a new secret.", + consequence: "The old credential stops working immediately.", + confirm: "Rotate", + busy: "Rotating…", + rejected: "Core rejected the rotation: {{reason}}", + uncertain: "The rotation was not confirmed. The old credential may already have stopped working; rotate again to receive a new secret.", + }, + revokeDialog: { + title: "Revoke credential?", + prompt: "Revoke credential {{id}}?", + consequence: "The executor can no longer connect; a running process is not stopped.", + confirm: "Revoke", + busy: "Revoking…", + uncertain: "The revocation was not confirmed. The list was read again; check it before trying again.", + }, + }, delete: { action: "Delete", actionLabel: "Delete Session {{id}}", diff --git a/apps/web/src/i18n/locales/zh-CN/sessions.ts b/apps/web/src/i18n/locales/zh-CN/sessions.ts index bbb39d076..12ac6339f 100644 --- a/apps/web/src/i18n/locales/zh-CN/sessions.ts +++ b/apps/web/src/i18n/locales/zh-CN/sessions.ts @@ -142,6 +142,59 @@ export const sessions = { historyFailed: "无法加载运行时历史:{{reason}}", historyLoading: "正在加载运行时历史…", }, + executor: { + title: "Executor 凭据", + help: "凭据文件让一个自托管 executor 连接 Core,只对这个环境有效,不能调用 Agents API。请把它交给 executor 所在的主机,并妥善保管。每份凭据只显示一次;文件丢失时,轮换凭据即可换一份新的。", + issue: "签发凭据", + issuing: "正在签发…", + loading: "正在加载 executor 凭据…", + loadFailed: "无法加载 executor 凭据", + refreshFailed: "刷新失败,显示的是上次加载的 executor 凭据。", + empty: "还没有 executor 凭据", + columns: { credential: "凭据", issued: "签发时间", status: "状态" }, + status: { active: "有效", revoked: "已吊销" }, + copyKeyId: "复制凭据 ID", + rotate: "轮换", + rotateLabel: "轮换凭据 {{id}}", + revoke: "吊销", + revokeLabel: "吊销凭据 {{id}}", + issueRejected: "未签发 executor 凭据", + notIssued: "凭据 {{id}} 没有签发。准备好后可以再次签发。", + issued: { + title: "Executor 凭据", + notice: "这份凭据只显示这一次。请现在复制或下载文件:关闭对话框后将无法再次查看。", + fileLabel: "Executor 凭据文件", + copy: "复制", + copied: "已复制", + copyFailed: "无法复制凭据。请选中文本后手动复制。", + download: "下载凭据文件", + done: "完成", + }, + uncertain: { + title: "无法确认凭据是否签发", + body: "Core 没有确认凭据 {{id}} 是否已签发,控制台也没有自动重试。", + next: "请刷新列表。如果列表中出现这份凭据,轮换它即可拿到凭据内容;如果没有出现,再次签发即可。", + refresh: "刷新列表", + }, + rotateDialog: { + title: "轮换凭据?", + prompt: "将为凭据 {{id}} 生成新的凭据内容。", + lost: "Core 已签发凭据 {{id}},但凭据内容没有送达控制台。轮换它可以拿到新的凭据内容。", + consequence: "旧凭据会立即失效。", + confirm: "轮换", + busy: "正在轮换…", + rejected: "Core 拒绝了轮换:{{reason}}", + uncertain: "轮换没有得到确认。旧凭据可能已经失效;再次轮换即可拿到新的凭据内容。", + }, + revokeDialog: { + title: "吊销凭据?", + prompt: "吊销凭据 {{id}}?", + consequence: "Executor 将无法再连接;正在运行的进程不会被停止。", + confirm: "吊销", + busy: "正在吊销…", + uncertain: "吊销没有得到确认。列表已重新读取,请先检查再决定是否重试。", + }, + }, delete: { action: "删除", actionLabel: "删除 Session {{id}}", diff --git a/docs/web/protocol-coverage.md b/docs/web/protocol-coverage.md index c9b37be37..9f87898ab 100644 --- a/docs/web/protocol-coverage.md +++ b/docs/web/protocol-coverage.md @@ -81,15 +81,18 @@ Resource-specific boundaries: ## Executor credentials -Core issues the credentials of a self_hosted executor. The routes accept only -the self_hosted environment of a Session that is still running; any other +Core issues the credentials of a self_hosted executor, and the console is +where the administrator does it: the **Executor credentials** section of a +Session page, shown only when the Session's environment is `self_hosted`, for +that Session's `project_id` and `environment.id`. The routes accept only the +self_hosted environment of a Session that is still running; any other environment returns 404 or 409. | Operation | Route | Console use | | --- | --- | --- | -| List credentials | `GET /core/v1/projects/{project_id}/environments/{environment_id}/executor-credentials` | Reserved for the Session page's executor credential section: `key_id`, creation and revocation time; the credential itself is never listed | -| Issue or rotate | `POST /core/v1/projects/{project_id}/environments/{environment_id}/executor-credentials` with `{"key_id", "rotate"}` | Reserved: the console generates `key_id` when the administrator issues; Core returns the credential once (`201`, `Cache-Control: no-store`). An existing `key_id` without `rotate: true` returns 409 | -| Revoke | `DELETE /core/v1/projects/{project_id}/environments/{environment_id}/executor-credentials/{key_id}` | Reserved: confirmed revocation; revoking again returns 204 | +| List credentials | `GET /core/v1/projects/{project_id}/environments/{environment_id}/executor-credentials` | The section's table, through the query cache: each credential's short `key_id` with its copy button, issue time and status (Active, or Revoked with its time), active first. The credential itself is never listed | +| Issue or rotate | `POST /core/v1/projects/{project_id}/environments/{environment_id}/executor-credentials` with `{"key_id", "rotate"}` | **Issue credential** generates `key_id` (`crypto.randomUUID()`) and keeps it before sending `rotate: false`. **Rotate**, confirmed (the old credential stops working immediately), sends an active credential's `key_id` with `rotate: true`; a revoked credential has no actions, as Core refuses to rotate it (409). Core returns the credential once (`201`, `Cache-Control: no-store`); the console shows it once in a dialog as the executor's credential file (`{key_id, environment_id, executor_token}`) to copy or download (`executor-credential-.json`), keeps it only in the dialog's state (never in browser storage or the query cache) and forgets it when the dialog closes. An existing `key_id` without `rotate: true` returns 409. Other rejections show Core's reason in an error toast (issue) or in the confirmation dialog (rotate) | +| Revoke | `DELETE /core/v1/projects/{project_id}/environments/{environment_id}/executor-credentials/{key_id}` | **Revoke**, confirmed (the executor can no longer connect; a running process is not stopped), then the list is read again and shows the credential as Revoked; revoking again returns 204 | ## Provenance and monitoring @@ -137,9 +140,13 @@ enters the release under advanced settings. - The console offers Session deletion only for idle or failed Sessions without required actions and never cancels work to make a Session deletable. - Project, key, executor credential, deletion and sandbox writes are never - retried automatically. After an uncertain executor credential issuance the - console refreshes the list; the administrator may reissue the same `key_id` - with `rotate: true`. + retried automatically. An executor credential issuance with an unknown + outcome (no answer, a 30-second timeout, a 5xx) opens an error dialog whose + next step is **Refresh list**. If the kept `key_id` is then listed, it was + issued and its secret lost: the console offers to rotate it (`rotate: true`) + for a fresh secret, shown once. If it is not listed, the next Issue sends the + same `key_id` with `rotate: false`; should that return 409 because the first + request was issued after all, the console offers the same rotation. ## Read bounds From b9dc469b4a1aba22736a4849d77d2fb197c5f3df Mon Sep 17 00:00:00 2001 From: saladday <1203511142@qq.com> Date: Fri, 25 Sep 2026 13:37:19 +0000 Subject: [PATCH 04/11] Rotate restores a revoked executor credential, as in Core --- apps/web/e2e/fixture-console.mjs | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/apps/web/e2e/fixture-console.mjs b/apps/web/e2e/fixture-console.mjs index 2a7856d2e..41b2c5926 100644 --- a/apps/web/e2e/fixture-console.mjs +++ b/apps/web/e2e/fixture-console.mjs @@ -297,7 +297,8 @@ async function executorCredentialRoute(request, response, projectId, environment if (typeof input.key_id !== "string" || !UUID.test(input.key_id) || (input.rotate !== undefined && typeof input.rotate !== "boolean")) return error(response, 400, "key_id must be a UUID.", "invalid_request"); const existing = credentials.find((entry) => entry.key_id === input.key_id); if (existing && input.rotate !== true) return error(response, 409, "The executor credential exists; rotate it instead.", "executor_credential_exists"); - if (existing?.revoked_at) return error(response, 409, "The executor credential is revoked.", "executor_credential_revoked"); + // As Core: the only conflict is an existing key without rotate; rotating a revoked key restores it. + if (existing) existing.revoked_at = null; if (!existing) credentials.push({ key_id: input.key_id, created_at: new Date().toISOString(), revoked_at: null }); return send(response, 201, { key_id: input.key_id, environment_id: environmentId, executor_token: `exec_fixture_${state.nextId++}` }); } From 144ed8b09653da189e4d483f745d18200fe8c4af Mon Sep 17 00:00:00 2001 From: saladday <1203511142@qq.com> Date: Fri, 25 Sep 2026 14:52:06 +0000 Subject: [PATCH 05/11] Match Core's executor credential rules in the fixture Rotating a key_id never issued for the environment returns 404, key IDs must be lowercase non-nil canonical UUIDs, and not-found responses use Core's not_found_error code. --- apps/web/e2e/fixture-console.mjs | 16 +++++++++------- 1 file changed, 9 insertions(+), 7 deletions(-) diff --git a/apps/web/e2e/fixture-console.mjs b/apps/web/e2e/fixture-console.mjs index 41b2c5926..32568bcc9 100644 --- a/apps/web/e2e/fixture-console.mjs +++ b/apps/web/e2e/fixture-console.mjs @@ -276,35 +276,37 @@ async function sandboxRoute(request, response, path) { return error(response, 404, "Not found."); } -const UUID = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i; +/** A key ID as Core accepts it: a canonical lowercase UUID other than the nil UUID. */ +const keyIdValid = (value) => typeof value === "string" && /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/.test(value) && value !== "00000000-0000-0000-0000-000000000000"; const EXECUTOR_CREDENTIALS = /^\/projects\/([^/]+)\/environments\/([^/]+)\/executor-credentials(?:\/([^/]+))?$/; /** * Executor credentials as Core issues them: only for the self_hosted * environment of a Session in the project; the token is returned once; an - * existing key_id is reissued only with rotate:true, and a revoked one never; - * revoking again is safe. + * existing key_id is reissued only with rotate:true, which also restores a + * revoked one; rotating an unknown key_id is not found; revoking again is safe. */ async function executorCredentialRoute(request, response, projectId, environmentId, keyId) { const project = state.admin.projects.find((entry) => entry.id === projectId); const session = project && state.admin.collections(project.id).sessions.find((entry) => entry.environment.type === "self_hosted" && entry.environment.id === environmentId); - if (!session) return error(response, 404, "No such self-hosted environment.", "not_found"); + if (!session) return error(response, 404, "No such self-hosted environment.", "not_found_error"); if (!state.executorCredentials.has(environmentId)) state.executorCredentials.set(environmentId, []); const credentials = state.executorCredentials.get(environmentId); if (!keyId && request.method === "GET") return send(response, 200, { data: credentials.map((entry) => ({ ...entry })) }); if (!keyId && request.method === "POST") { const input = await body(request); - if (typeof input.key_id !== "string" || !UUID.test(input.key_id) || (input.rotate !== undefined && typeof input.rotate !== "boolean")) return error(response, 400, "key_id must be a UUID.", "invalid_request"); + if (!keyIdValid(input.key_id) || (input.rotate !== undefined && typeof input.rotate !== "boolean")) return error(response, 400, "key_id must be a UUID.", "invalid_request"); const existing = credentials.find((entry) => entry.key_id === input.key_id); if (existing && input.rotate !== true) return error(response, 409, "The executor credential exists; rotate it instead.", "executor_credential_exists"); - // As Core: the only conflict is an existing key without rotate; rotating a revoked key restores it. + if (!existing && input.rotate === true) return error(response, 404, "No such executor credential.", "not_found_error"); + // As Core: rotating restores a revoked key. if (existing) existing.revoked_at = null; if (!existing) credentials.push({ key_id: input.key_id, created_at: new Date().toISOString(), revoked_at: null }); return send(response, 201, { key_id: input.key_id, environment_id: environmentId, executor_token: `exec_fixture_${state.nextId++}` }); } if (keyId && request.method === "DELETE") { const existing = credentials.find((entry) => entry.key_id === keyId); - if (!existing) return error(response, 404, "No such executor credential.", "not_found"); + if (!existing) return error(response, 404, "No such executor credential.", "not_found_error"); existing.revoked_at ??= new Date().toISOString(); response.writeHead(204, { "cache-control": "no-store" }); return response.end(); From 6341375a06f894cb49fb7496853cde222705bffd Mon Sep 17 00:00:00 2001 From: saladday <1203511142@qq.com> Date: Fri, 25 Sep 2026 14:56:42 +0000 Subject: [PATCH 06/11] Hide executor credential writes on an archived project Core refuses issue and rotate on an archived project with 409 project_archived while list and revoke still work. The fixture returns that 409, and the Session page hides Issue and Rotate with a note; a write that meets project_archived shows the same note as its error and rereads the projects instead of entering rotate recovery. --- apps/web/e2e/console.ts | 5 +++ apps/web/e2e/fixture-console.mjs | 2 + apps/web/e2e/monitoring.spec.ts | 9 ++++- .../sessions/ExecutorCredentialsSection.tsx | 40 ++++++++++++++----- apps/web/src/i18n/locales/en/sessions.ts | 1 + apps/web/src/i18n/locales/zh-CN/sessions.ts | 1 + 6 files changed, 46 insertions(+), 12 deletions(-) diff --git a/apps/web/e2e/console.ts b/apps/web/e2e/console.ts index b12ed654c..e870d5138 100644 --- a/apps/web/e2e/console.ts +++ b/apps/web/e2e/console.ts @@ -24,6 +24,11 @@ export async function failNext(request: APIRequestContext, failure: { method: st await request.post(`${fixture}/__fixture/fail-next`, { data: failure }); } +/** Archives a project behind the console's back, as another administrator would. */ +export async function archiveProject(request: APIRequestContext, projectId: string) { + await request.post(`${fixture}/core/v1/projects/${projectId}/archive`, { headers: { cookie: "core_console=fixture-session" } }); +} + /** Writes the browser sent through the console, as "METHOD /path". */ export async function writes(request: APIRequestContext): Promise { return (await (await request.get(`${fixture}/__fixture/requests`)).json()).writes; diff --git a/apps/web/e2e/fixture-console.mjs b/apps/web/e2e/fixture-console.mjs index 32568bcc9..55ca0e250 100644 --- a/apps/web/e2e/fixture-console.mjs +++ b/apps/web/e2e/fixture-console.mjs @@ -285,6 +285,7 @@ const EXECUTOR_CREDENTIALS = /^\/projects\/([^/]+)\/environments\/([^/]+)\/execu * environment of a Session in the project; the token is returned once; an * existing key_id is reissued only with rotate:true, which also restores a * revoked one; rotating an unknown key_id is not found; revoking again is safe. + * An archived project keeps listing and revoking but neither issues nor rotates. */ async function executorCredentialRoute(request, response, projectId, environmentId, keyId) { const project = state.admin.projects.find((entry) => entry.id === projectId); @@ -296,6 +297,7 @@ async function executorCredentialRoute(request, response, projectId, environment if (!keyId && request.method === "POST") { const input = await body(request); if (!keyIdValid(input.key_id) || (input.rotate !== undefined && typeof input.rotate !== "boolean")) return error(response, 400, "key_id must be a UUID.", "invalid_request"); + if (project.archived_at) return error(response, 409, "The target Project is archived.", "project_archived"); const existing = credentials.find((entry) => entry.key_id === input.key_id); if (existing && input.rotate !== true) return error(response, 409, "The executor credential exists; rotate it instead.", "executor_credential_exists"); if (!existing && input.rotate === true) return error(response, 404, "No such executor credential.", "not_found_error"); diff --git a/apps/web/e2e/monitoring.spec.ts b/apps/web/e2e/monitoring.spec.ts index 737041e36..5fa6418a6 100644 --- a/apps/web/e2e/monitoring.spec.ts +++ b/apps/web/e2e/monitoring.spec.ts @@ -1,6 +1,6 @@ import { expect, test } from "@playwright/test"; -import { expectManagementBoundary, openConsole } from "./console"; +import { archiveProject, expectManagementBoundary, openConsole } from "./console"; test.afterEach(async ({ request }) => expectManagementBoundary(request)); @@ -55,6 +55,13 @@ test("issues an executor credential once on a self-hosted Session and revokes it await page.getByRole("dialog", { name: "Revoke credential?" }).getByRole("button", { name: "Revoke" }).click(); await expect(credentials).toContainText("Revoked"); await expect(credentials.getByRole("button", { name: /^Rotate credential / })).toHaveCount(0); + + // Archived meanwhile: Core refuses the issuance and the console stops offering it. + await archiveProject(request, new URLSearchParams(new URL(page.url()).hash.split("?")[1]).get("project")!); + await section.getByRole("button", { name: "Issue credential" }).click(); + await expect(section).toContainText("This project is archived"); + await expect(section.getByRole("button", { name: "Issue credential" })).toHaveCount(0); + await expect(credentials).toContainText("Revoked"); }); test("opens a node and a sandbox in dialogs from Sandbox metrics", async ({ page, request }) => { diff --git a/apps/web/src/features/sessions/ExecutorCredentialsSection.tsx b/apps/web/src/features/sessions/ExecutorCredentialsSection.tsx index 3d2766b11..1dbb18b73 100644 --- a/apps/web/src/features/sessions/ExecutorCredentialsSection.tsx +++ b/apps/web/src/features/sessions/ExecutorCredentialsSection.tsx @@ -13,7 +13,7 @@ import { TableSkeleton } from "../../components/Skeleton"; import { failedLast, useFailureToast, useToast } from "../../components/Toast"; import { useDeleteFlow } from "../../lib/delete-flow"; import { formatDateTime, shortId } from "../../lib/format"; -import { admin } from "../../lib/projects"; +import { admin, useProjects } from "../../lib/projects"; import { useCopy } from "../api-keys/IssuedKey"; import { saveBlob } from "../skills/skill-operations"; import { executorCredentialsQuery } from "./session-queries"; @@ -65,12 +65,15 @@ function seconds(value: string | null): number | null { * forgotten when its dialog closes. Writes are never retried automatically: * an issuance with an unknown outcome keeps its key ID, and after the list is * refreshed the administrator rotates it (issued, secret lost) or issues it - * again (not issued). + * again (not issued). An archived project's credentials are listed and + * revoked but neither issued nor rotated. */ export function ExecutorCredentialsSection({ projectId, sessionId, environmentId }: { projectId: string; sessionId: string; environmentId: string }) { const { t, i18n } = useTranslation("sessions"); const locale = i18n.resolvedLanguage; const toast = useToast(); + const { byId, refresh: refreshProjects } = useProjects(); + const archived = byId.get(projectId)?.status === "archived"; const query = useQuery(executorCredentialsQuery(projectId, sessionId, environmentId)); const credentials = query.data ?? null; const { refetch } = query; @@ -127,9 +130,16 @@ export function ExecutorCredentialsSection({ projectId, sessionId, environmentId return; } setKept(null); - // An earlier attempt with this key ID was issued after all; its secret never arrived. - if (failure.code === "executor_credential_exists") openRotation(keyId, true); - else toast.show(t("executor.issueRejected"), { tone: "error", detail: failure.message }); + if (failure.code === "project_archived") { + // Archived meanwhile: read the project again so issuing and rotating disappear. + toast.show(t("executor.issueRejected"), { tone: "error", detail: t("executor.archived") }); + refreshProjects(); + } else if (failure.code === "executor_credential_exists") { + // An earlier attempt with this key ID was issued after all; its secret never arrived. + openRotation(keyId, true); + } else { + toast.show(t("executor.issueRejected"), { tone: "error", detail: failure.message }); + } reread(); } finally { setIssuing(false); @@ -146,7 +156,7 @@ export function ExecutorCredentialsSection({ projectId, sessionId, environmentId return; } setKept(null); - if (found.revoked_at === null) openRotation(found.key_id, true); + if (found.revoked_at === null && !archived) openRotation(found.key_id, true); }; const rotate = async () => { @@ -159,14 +169,19 @@ export function ExecutorCredentialsSection({ projectId, sessionId, environmentId setShown({ credential, open: true }); } catch (error) { const failure = writeFailure(error); - setRotationError(failure.kind === "uncertain" ? t("executor.rotateDialog.uncertain") : t("executor.rotateDialog.rejected", { reason: failure.message })); + if (failure.kind === "rejected" && failure.code === "project_archived") { + setRotationError(t("executor.archived")); + refreshProjects(); + } else { + setRotationError(failure.kind === "uncertain" ? t("executor.rotateDialog.uncertain") : t("executor.rotateDialog.rejected", { reason: failure.message })); + } } finally { setRotating(false); reread(); } }; - const issueButton = ( + const issueButton = archived ? null : ( @@ -208,9 +223,11 @@ export function ExecutorCredentialsSection({ projectId, sessionId, environmentId {revoked ? null : ( - + {archived ? null : ( + + )} @@ -230,6 +247,7 @@ export function ExecutorCredentialsSection({ projectId, sessionId, environmentId return (
+ {archived ?

{t("executor.archived")}

: null} {body} Date: Fri, 25 Sep 2026 15:00:16 +0000 Subject: [PATCH 07/11] Derive the fixture's error type as Core does --- apps/web/e2e/fixture-console.mjs | 5 ++++- apps/web/e2e/nodes.spec.ts | 2 +- 2 files changed, 5 insertions(+), 2 deletions(-) diff --git a/apps/web/e2e/fixture-console.mjs b/apps/web/e2e/fixture-console.mjs index 55ca0e250..d13d46788 100644 --- a/apps/web/e2e/fixture-console.mjs +++ b/apps/web/e2e/fixture-console.mjs @@ -62,7 +62,10 @@ function send(response, status, body, headers = {}) { response.end(JSON.stringify(body)); } function error(response, status, message, code = null) { - send(response, status, { error: { message, type: "invalid_request_error", code, param: null } }); + // Derive `type` as Core's writeError does (services/agents-api/internal/api/errors.go). + const type = status >= 500 ? "server_error" : status === 409 ? "conflict_error" + : code === "not_found_error" || code === "invalid_beta" ? code : "invalid_request_error"; + send(response, status, { error: { message, type, code, param: null } }); } async function body(request) { const chunks = []; diff --git a/apps/web/e2e/nodes.spec.ts b/apps/web/e2e/nodes.spec.ts index fc8aa9722..c196af769 100644 --- a/apps/web/e2e/nodes.spec.ts +++ b/apps/web/e2e/nodes.spec.ts @@ -92,7 +92,7 @@ test("reports a failed sandbox change in a dialog, then reads the state again", await page.route("**/core/v1/sandbox/deployment/maintenance", (route) => route.fulfill({ status: 409, contentType: "application/json", - body: JSON.stringify({ error: { message: "The deployment changed.", type: "invalid_request_error", code: "sandbox_deployment_conflict", param: null } }), + body: JSON.stringify({ error: { message: "The deployment changed.", type: "conflict_error", code: "sandbox_deployment_conflict", param: null } }), })); await page.getByRole("button", { name: "Enter maintenance to change provider" }).click(); const failed = page.getByRole("dialog", { name: "Couldn't confirm the sandbox change" }); From 5c93243092a2b2cdf4b4d23166f5e158a19fc68f Mon Sep 17 00:00:00 2001 From: saladday <1203511142@qq.com> Date: Fri, 25 Sep 2026 15:35:55 +0000 Subject: [PATCH 08/11] Describe the console's /core/v1 prefix forwarding in the Web docs --- docs/web/README.md | 7 +++-- .../web/admin-metrics-backend-requirements.md | 6 ++-- ...dmin-metrics-backend-requirements.zh-CN.md | 2 +- docs/web/architecture.md | 13 ++++---- docs/web/core-connection.md | 30 +++++++++++-------- docs/web/protocol-coverage.md | 12 ++++---- docs/web/roadmap.md | 3 +- 7 files changed, 41 insertions(+), 32 deletions(-) diff --git a/docs/web/README.md b/docs/web/README.md index 46dfd4603..43cb8951e 100644 --- a/docs/web/README.md +++ b/docs/web/README.md @@ -1,8 +1,9 @@ # Parsar Core Web Core Web is the administrator console for a Core deployment. Its Go service -provides console login and a restricted management proxy. Applications use Core's -public Agents API directly with their own Project API keys. +provides Core key login and forwards signed-in, same-origin `/core/v1` requests to +Core. Applications use Core's public Agents API directly with their own Project API +keys. The React console (`apps/web`) uses this contract: every browser request goes through the console's same-origin management routes with `AdminClient` and the sandbox @@ -49,7 +50,7 @@ through `/v1`. Core has no API users, roles or memberships. Follow the [installation guide](../getting-started/install.md) for Core, Web and PostgreSQL with zero execution nodes. Installation creates no Project or application key; an administrator creates them through the management API. The browser signs -in to the console, whose server keeps the deployment credential private. +in to the console with the Core key; only the console server sends it to Core. - [Connection and authentication](core-connection.md) - [Architecture and ownership](architecture.md) diff --git a/docs/web/admin-metrics-backend-requirements.md b/docs/web/admin-metrics-backend-requirements.md index fe80f8237..0fa4bec4b 100644 --- a/docs/web/admin-metrics-backend-requirements.md +++ b/docs/web/admin-metrics-backend-requirements.md @@ -18,9 +18,9 @@ the browser work. Agents API and its documented Core extensions. Metrics work must not add fields, routes or behavior here. 2. **Web API** (`/core/v1/**`, including `/core/v1/sandbox/**` for sandbox - administration): called only by the console server with the deployment - administrator credential. Every endpoint proposed below belongs here and is - forwarded by `services/core-console`. + administration): called by the console server and operator scripts with the + Core key. Every endpoint proposed below belongs here; `services/core-console` + forwards `/core/v1/*` by prefix, so a new endpoint needs no proxy change. ## What the console computes today diff --git a/docs/web/admin-metrics-backend-requirements.zh-CN.md b/docs/web/admin-metrics-backend-requirements.zh-CN.md index 93e54d164..bd9d383ec 100644 --- a/docs/web/admin-metrics-backend-requirements.zh-CN.md +++ b/docs/web/admin-metrics-backend-requirements.zh-CN.md @@ -7,7 +7,7 @@ ## Core 的两类接口 1. **面向用户的 Agents API**(`/v1/**`):必须与锁定版本的 OpenAI Agents API 及已记录的 Core 扩展保持一致。指标相关工作不得在这里新增字段、路由或行为。 -2. **Web API**(`/core/v1/**`,包括沙箱管理用的 `/core/v1/sandbox/**`):只由控制台服务端用部署管理员凭据调用。下文提议的所有接口都属于这一类,由 `services/core-console` 转发。 +2. **Web API**(`/core/v1/**`,包括沙箱管理用的 `/core/v1/sandbox/**`):由控制台服务端和运维脚本用 Core Key 调用。下文提议的所有接口都属于这一类;`services/core-console` 按前缀转发 `/core/v1/*`,新增接口无需改动代理。 ## 控制台目前如何计算 diff --git a/docs/web/architecture.md b/docs/web/architecture.md index 1695a86d5..9113d930c 100644 --- a/docs/web/architecture.md +++ b/docs/web/architecture.md @@ -20,7 +20,7 @@ flowchart LR runtime["Runtime and native adapters"] browser -->|"Same-origin management requests; console login"| console - console -->|"/core/v1 management, including sandbox; deployment credential"| core + console -->|"/core/v1/* by prefix, including sandbox; Core key"| core application -->|"/v1; Project API key"| core core <--> database core <--> runtime @@ -32,10 +32,11 @@ sandbox management client for `/core/v1/sandbox`. The console service returns 404 for `/v1` and `/api/v1`, including requests with an explicit Bearer token. It has no application key and does not impersonate the selected Project. -The console authenticates the browser, checks the request origin and forwards only -allowed management routes. It replaces browser authorization and actor headers, -strips browser cookies, and supplies its server-side deployment credential. Core -rejects application keys on management routes and deployment credentials on `/v1`. +The console signs the browser in with the Core key, checks the host and origin, and +forwards every signed-in `/core/v1/*` request to Core by prefix; Core alone decides +whether the route exists. It strips the browser's Authorization, Cookie, Origin and +Referer headers and supplies the Core key as its private upstream credential. Core +rejects application keys on management routes and the Core key on `/v1`. The audit actor label is declared by the caller and is display only, never Core authorization: the console server declares `console`, and operator scripts calling Core with the Core key directly leave it empty. Node and daemon connections use `/api/v1` with their own credentials. The reverse @@ -48,7 +49,7 @@ grant a browser execution authority. | --- | --- | | React frontend | Project selection, permitted management actions and operational views; cached reads (TanStack Query) that keep the last data on screen while refreshing | | `AdminClient` | Typed management requests and validation, sharing resource parsers with the public client | -| `services/core-console` | Console authentication, origin checks, route allowlist and private upstream credential | +| `services/core-console` | Core key login, host/origin checks, and prefix forwarding of `/core/v1/*` with the Core key as the private upstream credential | | Core API and PostgreSQL | Project isolation, resource state, deletion preconditions, audit and scheduling | | Runtime and native adapters | Existing allocation, process lifecycle and execution protocols | diff --git a/docs/web/core-connection.md b/docs/web/core-connection.md index 7a4748620..54c4ebb9b 100644 --- a/docs/web/core-connection.md +++ b/docs/web/core-connection.md @@ -1,16 +1,18 @@ # Connecting the administrator console to Core -`services/core-console` serves built Web assets, authenticates administrators and -proxies an explicit management allowlist to Core. The backend contract and the -React screens that use it are implemented; the console has no execution controls. +`services/core-console` serves built Web assets, signs administrators in with the +Core key and forwards every signed-in, same-origin `/core/v1/*` request to Core. The +backend contract and the React screens that use it are implemented; the console has +no execution controls. ## Connection model The browser calls same-origin `/core/v1` through `AdminClient` and `CoreMetricsClient`, and the `/core/v1/sandbox` management routes through the -sandbox client. The console server -supplies the deployment administrator credential to its configured Core upstream. -Browser code must never receive that credential. +sandbox client. The console server forwards each signed-in `/core/v1/*` request by +prefix to its configured Core upstream, with the Core key (`CORE_CONSOLE_CORE_KEY_FILE`) +as the upstream credential; Core alone decides whether the route exists. Browser +code must never receive that credential. Applications call Core's `/v1` directly with their own Project API keys and the public API's route-specific headers. Nodes and Runtime daemons call Core's `/api/v1` @@ -48,10 +50,14 @@ Core key cannot call `/v1`. `GET /console/config` provides safe console configuration to an authenticated browser. Use TLS for remote browser access and loopback listeners for local development. -Preserve host/origin checks and the management route allowlist. Browser authorization, -cookies and actor headers are replaced or removed before forwarding to Core. -A browser cannot choose the audit actor label the service reports: the console server declares `console`. The label is caller-declared and display only. Keep deployment, application, node and provider credentials -out of `VITE_*`, browser storage, source files, URLs and logs. +Preserve the host/origin checks and the forwarding rules. The console refuses +requests with an `Upgrade` header, CONNECT and TRACE, and any path that `safePath` +rejects: an encoded `%`, dot segments, empty segments or backslashes. Before +forwarding, it strips the browser's Authorization, Cookie, Origin and Referer headers +and overwrites the actor header (`X-Core-Console-Actor`) with `console`, so a browser +cannot choose the audit actor label the service reports. The label is caller-declared +and display only. Keep deployment, application, node and provider credentials out of +`VITE_*`, browser storage, source files, URLs and logs. ## Projects and application keys @@ -72,7 +78,7 @@ work. Ordinary metadata reads cannot recover plaintext. 2. Console login followed by `GET /core/v1/projects` proves the authenticated browser-to-console and console-to-Core path. 3. A Project key must work on its public resources and fail on management routes. - The deployment credential must fail on `/v1`; `/v1` through the console stays 404. + The Core key must fail on `/v1`; `/v1` through the console stays 404. 4. Cross-origin management writes must be rejected. Audit actor labels must ignore a forged browser header. 5. Runtime observations and history report execution state separately from startup @@ -80,6 +86,6 @@ work. Ordinary metadata reads cannot recover plaintext. or sandbox readiness. A console login failure belongs to console authentication. An upstream 401 on a -management request points to the deployment credential or Core connection. A resource +management request points to the console's Core key or Core connection. A resource deletion conflict must remain visible; it does not authorize an execution call. Node and daemon `/api/v1` routes and native Runtime interfaces retain their own authentication. diff --git a/docs/web/protocol-coverage.md b/docs/web/protocol-coverage.md index 9f87898ab..16c15c159 100644 --- a/docs/web/protocol-coverage.md +++ b/docs/web/protocol-coverage.md @@ -16,8 +16,8 @@ the administrator API are defined by the [administrator API contract](../../cont | Interface | Paths | Authentication | Console use | | --- | --- | --- | --- | | Console server | `/console/auth`, `/console/auth/{login,logout}`, `/console/config` | Core key at sign-in, then the console session cookie | Sign-in with the Core key and sign-out; the node installer (`node_installer`, `node_installer_sha256`) | -| Administrator API | `/core/v1/**` outside `/core/v1/sandbox` | Deployment administrator credential, added by the console server | Projects, keys, resource reads and deletion, executor credentials, provenance, summaries, Core metrics | -| Sandbox administration | `/core/v1/sandbox/**` | Deployment administrator credential, added by the console server | Nodes page; fleet and capacity figures on Overview and Sandbox metrics; Runtime observations of every project | +| Administrator API | `/core/v1/**` outside `/core/v1/sandbox` | Core key, added by the console server | Projects, keys, resource reads and deletion, executor credentials, provenance, summaries, Core metrics | +| Sandbox administration | `/core/v1/sandbox/**` | Core key, added by the console server | Nodes page; fleet and capacity figures on Overview and Sandbox metrics; Runtime observations of every project | | Agents API | `/v1/**` | Project API key | Not used. The first-run screen shows a `curl` example for `/v1/agents` with a `$PROJECT_API_KEY` placeholder; the console never sends it | Browser requests are same-origin and carry only the console session. The browser @@ -185,10 +185,10 @@ client tests and the console's fixtures in the same change. ## Evidence and changes -The console's route allowlists live in -[admin_routes.go](../../services/core-console/admin_routes.go) and -[sandbox_admin.go](../../services/core-console/sandbox_admin.go); authentication, -origin checks and header handling live in [server.go](../../services/core-console/server.go). +The console's `/core/v1/*` prefix forwarding lives in +[core_routes.go](../../services/core-console/core_routes.go); authentication, origin +and path checks and header handling live in [server.go](../../services/core-console/server.go), +with Core key sign-in in [auth.go](../../services/core-console/auth.go). Update this matrix when those boundaries or the console's reads change, and keep detailed wire semantics in the administrator contract. diff --git a/docs/web/roadmap.md b/docs/web/roadmap.md index edd516007..8be9b0ab1 100644 --- a/docs/web/roadmap.md +++ b/docs/web/roadmap.md @@ -12,7 +12,8 @@ contract. Public Agents API compatibility work is tracked in the ## Delivered - Console login and same-origin `AdminClient` and sandbox management requests; the - browser holds no deployment credential or application key. + browser holds no application key, and only the console server sends the Core key + to Core. - Sign-in with the deployment's Core key; the browser keeps only the session cookie. - First run: after signing in with no project, the first project and its API key From d7660395e6ef9965ef8c7997bde1deb8d4e6fb01 Mon Sep 17 00:00:00 2001 From: saladday <1203511142@qq.com> Date: Fri, 25 Sep 2026 16:00:48 +0000 Subject: [PATCH 09/11] Never recover an executor credential the administrator already managed A kept key ID from an issuance with an unknown outcome is no longer sent again once it is listed, and a successful rotation or revocation of it forgets it, so the next Issue starts a new credential instead of offering to rotate one that was just rotated or revoked. An existing key ID (409) opens rotation only after a fresh read shows it active in an active project; otherwise the issuance is reported as rejected. Dismissing the one-time credential dialog now keeps the credential on the page until Done, as the API-key flow does. The list's date column is "Created", and the empty state no longer repeats the Issue button. --- apps/web/e2e/monitoring.spec.ts | 41 ++++++- .../sessions/ExecutorCredentialsSection.tsx | 103 ++++++++++++------ apps/web/src/features/sessions/sessions.css | 10 ++ apps/web/src/i18n/locales/en/sessions.ts | 5 +- apps/web/src/i18n/locales/zh-CN/sessions.ts | 5 +- 5 files changed, 125 insertions(+), 39 deletions(-) diff --git a/apps/web/e2e/monitoring.spec.ts b/apps/web/e2e/monitoring.spec.ts index 5fa6418a6..b9f8f4e0b 100644 --- a/apps/web/e2e/monitoring.spec.ts +++ b/apps/web/e2e/monitoring.spec.ts @@ -38,14 +38,18 @@ test("issues an executor credential once on a self-hosted Session and revokes it const section = page.getByRole("region", { name: "Executor credentials" }); await expect(section).toContainText("No executor credentials yet"); - await section.getByRole("button", { name: "Issue credential" }).first().click(); + await section.getByRole("button", { name: "Issue credential" }).click(); const issued = page.getByRole("dialog", { name: "Executor credential" }); await expect(issued).toContainText("shown only once"); await expect(issued.getByLabel("Executor credential file")).toContainText("exec_fixture_"); const download = page.waitForEvent("download"); await issued.getByRole("button", { name: "Download credential file" }).click(); expect((await download).suggestedFilename()).toMatch(/^executor-credential-[0-9a-f]{8}\.json$/); - await issued.getByRole("button", { name: "Done" }).click(); + // Dismissing the dialog keeps the credential on the page; only Done forgets it. + await page.keyboard.press("Escape"); + const pending = section.getByRole("region", { name: "Executor credential", exact: true }); + await expect(pending.getByLabel("Executor credential file")).toContainText("exec_fixture_"); + await pending.getByRole("button", { name: "Done" }).click(); await expect(page.getByLabel("Executor credential file")).toHaveCount(0); expect(await page.evaluate(() => JSON.stringify({ ...window.localStorage, ...window.sessionStorage }))).not.toContain("exec_fixture_"); @@ -64,6 +68,39 @@ test("issues an executor credential once on a self-hosted Session and revokes it await expect(credentials).toContainText("Revoked"); }); +test("issues a new executor credential after the unanswered one was rotated from its row", async ({ page, request }) => { + await openConsole(page, request, "sessions"); + await page.getByRole("row").filter({ hasText: "Self-hosted" }).first().getByRole("button", { name: /^Open Session / }).click(); + const section = page.getByRole("region", { name: "Executor credentials" }); + await expect(section).toContainText("No executor credentials yet"); + const issuance = () => page.waitForRequest((sent) => sent.method() === "POST" && sent.url().endsWith("/executor-credentials")); + + // Core issues the credential, but its answer never reaches the console. + await page.route("**/executor-credentials", async (route) => { + if (route.request().method() !== "POST") return route.fallback(); + await route.fetch(); + await route.abort(); + }); + const unanswered = issuance(); + await section.getByRole("button", { name: "Issue credential" }).click(); + const lost = (await unanswered).postDataJSON().key_id; + await page.getByRole("dialog", { name: "Couldn't confirm the credential" }).getByRole("button", { name: "Close", exact: true }).click(); + await page.unroute("**/executor-credentials"); + + // The page's refresh lists it; the administrator rotates it from its row. + await page.getByRole("button", { name: "Refresh", exact: true }).click(); + const credentials = section.getByRole("table", { name: "Executor credentials" }); + await credentials.getByRole("button", { name: /^Rotate credential / }).click(); + await page.getByRole("dialog", { name: "Rotate credential?" }).getByRole("button", { name: "Rotate" }).click(); + await page.getByRole("dialog", { name: "Executor credential" }).getByRole("button", { name: "Done" }).click(); + + // The next Issue is a new credential, not a recovery of the rotated one. + const next = issuance(); + await section.getByRole("button", { name: "Issue credential" }).click(); + expect((await next).postDataJSON().key_id).not.toBe(lost); + await expect(page.getByRole("dialog", { name: "Executor credential" }).getByLabel("Executor credential file")).toContainText("exec_fixture_"); +}); + test("opens a node and a sandbox in dialogs from Sandbox metrics", async ({ page, request }) => { await openConsole(page, request, "sandbox-metrics"); await page.getByRole("button", { name: "Show core-01" }).click(); diff --git a/apps/web/src/features/sessions/ExecutorCredentialsSection.tsx b/apps/web/src/features/sessions/ExecutorCredentialsSection.tsx index 1dbb18b73..f91662382 100644 --- a/apps/web/src/features/sessions/ExecutorCredentialsSection.tsx +++ b/apps/web/src/features/sessions/ExecutorCredentialsSection.tsx @@ -1,4 +1,4 @@ -import { AgentCoreError, type IssuedExecutorCredential } from "@agents-core-web/agents-client"; +import { AgentCoreError, type ExecutorCredential, type IssuedExecutorCredential } from "@agents-core-web/agents-client"; import { useQuery } from "@tanstack/react-query"; import { Check, Copy, Download, Plus } from "lucide-react"; import { useEffect, useState } from "react"; @@ -54,6 +54,10 @@ function message(error: unknown): string { return error instanceof Error ? error.message : String(error ?? ""); } +function sameKey(a: string, b: string): boolean { + return a.toLowerCase() === b.toLowerCase(); +} + function seconds(value: string | null): number | null { return value === null ? null : Math.floor(Date.parse(value) / 1000); } @@ -62,11 +66,13 @@ function seconds(value: string | null): number | null { * Executor credentials of a self-hosted Session's environment: Core issues, * rotates and revokes them with the deployment's Core key. An issued * credential is shown once, lives only in this component's state and is - * forgotten when its dialog closes. Writes are never retried automatically: - * an issuance with an unknown outcome keeps its key ID, and after the list is - * refreshed the administrator rotates it (issued, secret lost) or issues it - * again (not issued). An archived project's credentials are listed and - * revoked but neither issued nor rotated. + * forgotten when the administrator presses Done; dismissing the dialog keeps + * it on the page. Writes are never retried automatically: an issuance with an + * unknown outcome keeps its key ID, and after the list is refreshed the + * administrator rotates it (issued, secret lost) or issues it again (not + * issued). A kept key ID that is listed is never sent again: its row's actions + * own it. An archived project's credentials are listed and revoked but neither + * issued nor rotated. */ export function ExecutorCredentialsSection({ projectId, sessionId, environmentId }: { projectId: string; sessionId: string; environmentId: string }) { const { t, i18n } = useTranslation("sessions"); @@ -80,48 +86,66 @@ export function ExecutorCredentialsSection({ projectId, sessionId, environmentId const reread = () => { void refetch(); }; useFailureToast(credentials && failedLast(query) ? message(query.error) : null, t("executor.refreshFailed"), "executor-credentials-read"); - // The key ID of an issuance in flight or with an unknown outcome; the next Issue sends it again. + // The key ID of an issuance in flight or with an unknown outcome; the next Issue sends it again unless it is listed. const [kept, setKept] = useState(null); + const forget = (keyId: string) => setKept((current) => (current !== null && sameKey(current, keyId) ? null : current)); const [issuing, setIssuing] = useState(false); // The unknown-outcome dialog; the key ID stays for its closing animation. const [uncertain, setUncertain] = useState<{ keyId: string; open: boolean } | null>(null); const [rotation, setRotation] = useState<{ keyId: string; lost: boolean } | null>(null); const [rotating, setRotating] = useState(false); const [rotationError, setRotationError] = useState(null); - const [shown, setShown] = useState<{ credential: IssuedExecutorCredential; open: boolean } | null>(null); + // The credential shown once: in its dialog, or on the page once the dialog is dismissed, until Done. + const [shown, setShown] = useState<{ credential: IssuedExecutorCredential; open: boolean; done: boolean } | null>(null); const revoke = useDeleteFlow( - (keyId) => admin.revokeExecutorCredential(projectId, environmentId, keyId), + (keyId) => admin.revokeExecutorCredential(projectId, environmentId, keyId).then(() => forget(keyId)), reread, { uncertain: t("executor.revokeDialog.uncertain") }, ); - const busy = issuing || rotating || revoke.busy; + const held = shown !== null && !shown.done; + // A held credential also blocks writes: another issuance would replace it. + const busy = issuing || rotating || revoke.busy || held; - // Closing the dialog forgets the credential once the dialog has faded out. + // Done forgets the credential once its dialog has faded out. + const done = shown?.done ?? false; useEffect(() => { - if (!shown || shown.open) return; + if (!done) return; const timer = window.setTimeout(() => setShown(null), 250); return () => window.clearTimeout(timer); - }, [shown]); + }, [done]); // A reload or a closed tab would lose a credential that is shown only once. - const showing = shown?.open ?? false; useEffect(() => { - if (!showing) return; + if (!held) return; const guard = (event: BeforeUnloadEvent) => { event.preventDefault(); }; window.addEventListener("beforeunload", guard); return () => window.removeEventListener("beforeunload", guard); - }, [showing]); + }, [held]); const openRotation = (keyId: string, lost: boolean) => { setRotationError(null); setRotation({ keyId, lost }); }; + // Reads the list again and finds a key ID in it; undefined when the read failed. + const findListed = async (keyId: string): Promise => { + const result = await refetch(); + if (!result.data || result.isError) return undefined; + return result.data.find((credential) => sameKey(credential.key_id, keyId)) ?? null; + }; + // An issued key ID whose secret never arrived is rotated for a fresh one, if it is still active. + const recoverLost = (found: ExecutorCredential): boolean => { + const rotatable = found.revoked_at === null && !archived; + if (rotatable) openRotation(found.key_id, true); + return rotatable; + }; + const issue = async () => { if (busy) return; - const keyId = kept ?? newKeyId(); + // A listed key ID is managed from its row; reusing it would recover a credential that was since rotated or revoked. + const keyId = kept !== null && !credentials?.some((credential) => sameKey(credential.key_id, kept)) ? kept : newKeyId(); setKept(keyId); setIssuing(true); try { const credential = await admin.issueExecutorCredential(projectId, environmentId, { key_id: keyId, rotate: false }, { signal: AbortSignal.timeout(WRITE_TIMEOUT_MS) }); setKept(null); - setShown({ credential, open: true }); + setShown({ credential, open: true, done: false }); reread(); } catch (error) { const failure = writeFailure(error); @@ -130,13 +154,18 @@ export function ExecutorCredentialsSection({ projectId, sessionId, environmentId return; } setKept(null); + if (failure.code === "executor_credential_exists") { + // An earlier attempt with this key ID may have been issued after all, its secret lost. + const found = await findListed(keyId); + if (!found || !recoverLost(found)) { + toast.show(t("executor.issueRejected"), { tone: "error", detail: found?.revoked_at ? t("executor.existsRevoked", { id: shortId(keyId) }) : failure.message }); + } + return; + } if (failure.code === "project_archived") { // Archived meanwhile: read the project again so issuing and rotating disappear. toast.show(t("executor.issueRejected"), { tone: "error", detail: t("executor.archived") }); refreshProjects(); - } else if (failure.code === "executor_credential_exists") { - // An earlier attempt with this key ID was issued after all; its secret never arrived. - openRotation(keyId, true); } else { toast.show(t("executor.issueRejected"), { tone: "error", detail: failure.message }); } @@ -148,15 +177,14 @@ export function ExecutorCredentialsSection({ projectId, sessionId, environmentId // After an unknown outcome: issued (rotate it for a fresh secret) or not (the next Issue reuses the key ID). const checkKept = async (keyId: string) => { - const result = await refetch(); - if (!result.data || result.isError) return; - const found = result.data.find((credential) => credential.key_id.toLowerCase() === keyId.toLowerCase()); - if (!found) { + const found = await findListed(keyId); + if (found === undefined) return; + if (found === null) { toast.show(t("executor.notIssued", { id: shortId(keyId) }), { tone: "info" }); return; } setKept(null); - if (found.revoked_at === null && !archived) openRotation(found.key_id, true); + recoverLost(found); }; const rotate = async () => { @@ -166,7 +194,8 @@ export function ExecutorCredentialsSection({ projectId, sessionId, environmentId try { const credential = await admin.issueExecutorCredential(projectId, environmentId, { key_id: rotation.keyId, rotate: true }, { signal: AbortSignal.timeout(WRITE_TIMEOUT_MS) }); setRotation(null); - setShown({ credential, open: true }); + forget(rotation.keyId); + setShown({ credential, open: true, done: false }); } catch (error) { const failure = writeFailure(error); if (failure.kind === "rejected" && failure.code === "project_archived") { @@ -193,7 +222,7 @@ export function ExecutorCredentialsSection({ projectId, sessionId, environmentId ? {t("detail.retry")}} /> : ; } else if (!credentials.length) { - body = ; + body = ; } else { body = (
@@ -201,7 +230,7 @@ export function ExecutorCredentialsSection({ projectId, sessionId, environmentId {t("executor.columns.credential")} - {t("executor.columns.issued")} + {t("executor.columns.created")} {t("executor.columns.status")} {t("log.actions")} @@ -243,17 +272,25 @@ export function ExecutorCredentialsSection({ projectId, sessionId, environmentId ); } - const hideShown = () => setShown((current) => current && { ...current, open: false }); + // Dismissing the dialog keeps the credential on the page; only Done forgets it. + const dismissShown = () => setShown((current) => current && { ...current, open: false }); + const finishShown = () => setShown((current) => current && { ...current, open: false, done: true }); return (
{archived ?

{t("executor.archived")}

: null} + {shown && !shown.open && !shown.done ? ( +
+ +
+
+ ) : null} {body} {t("executor.issued.done")}} + onClose={dismissShown} + footer={} > {shown ? : null} diff --git a/apps/web/src/features/sessions/sessions.css b/apps/web/src/features/sessions/sessions.css index 978a79032..319d0ebe5 100644 --- a/apps/web/src/features/sessions/sessions.css +++ b/apps/web/src/features/sessions/sessions.css @@ -413,3 +413,13 @@ font-size: 12.5px; line-height: 18px; } + +/* A credential whose dialog was dismissed stays on the page until Done. */ +.executor-credential-pending { + display: grid; + gap: 12px; + padding: 14px 16px; + background: var(--surface); + border: 1px solid var(--line-strong); + border-radius: var(--frame-radius); +} diff --git a/apps/web/src/i18n/locales/en/sessions.ts b/apps/web/src/i18n/locales/en/sessions.ts index a119ce936..45a855c71 100644 --- a/apps/web/src/i18n/locales/en/sessions.ts +++ b/apps/web/src/i18n/locales/en/sessions.ts @@ -154,7 +154,7 @@ export const sessions = { loadFailed: "Executor credentials could not be loaded", refreshFailed: "Refresh failed; showing the last loaded executor credentials.", empty: "No executor credentials yet", - columns: { credential: "Credential", issued: "Issued", status: "Status" }, + columns: { credential: "Credential", created: "Created", status: "Status" }, status: { active: "Active", revoked: "Revoked" }, copyKeyId: "Copy credential ID", rotate: "Rotate", @@ -164,9 +164,10 @@ export const sessions = { issueRejected: "The executor credential was not issued", archived: "This project is archived, so executor credentials can't be issued or rotated.", notIssued: "Credential {{id}} was not issued. Issue a credential again when you are ready.", + existsRevoked: "Credential {{id}} already exists and was revoked. Issue again for a new credential.", issued: { title: "Executor credential", - notice: "This credential is shown only once. Copy it or download the file now: after you close this dialog it cannot be shown again.", + notice: "This credential is shown only once. Copy it or download the file now: after you press Done it cannot be shown again.", fileLabel: "Executor credential file", copy: "Copy", copied: "Copied", diff --git a/apps/web/src/i18n/locales/zh-CN/sessions.ts b/apps/web/src/i18n/locales/zh-CN/sessions.ts index e3f3ab553..29bf896ac 100644 --- a/apps/web/src/i18n/locales/zh-CN/sessions.ts +++ b/apps/web/src/i18n/locales/zh-CN/sessions.ts @@ -151,7 +151,7 @@ export const sessions = { loadFailed: "无法加载 executor 凭据", refreshFailed: "刷新失败,显示的是上次加载的 executor 凭据。", empty: "还没有 executor 凭据", - columns: { credential: "凭据", issued: "签发时间", status: "状态" }, + columns: { credential: "凭据", created: "创建时间", status: "状态" }, status: { active: "有效", revoked: "已吊销" }, copyKeyId: "复制凭据 ID", rotate: "轮换", @@ -161,9 +161,10 @@ export const sessions = { issueRejected: "未签发 executor 凭据", archived: "项目已归档,不能再签发或轮换凭据。", notIssued: "凭据 {{id}} 没有签发。准备好后可以再次签发。", + existsRevoked: "凭据 {{id}} 已存在且已吊销。再次签发即可获得新凭据。", issued: { title: "Executor 凭据", - notice: "这份凭据只显示这一次。请现在复制或下载文件:关闭对话框后将无法再次查看。", + notice: "这份凭据只显示这一次。请现在复制或下载文件:点击“完成”后将无法再次查看。", fileLabel: "Executor 凭据文件", copy: "复制", copied: "已复制", From 05aa4d715b190b5758971d6bab220630eb27c98d Mon Sep 17 00:00:00 2001 From: saladday <1203511142@qq.com> Date: Fri, 25 Sep 2026 16:01:26 +0000 Subject: [PATCH 10/11] Reject a non-JSON executor credential body with 400, as Core does --- apps/web/e2e/fixture-console.mjs | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/apps/web/e2e/fixture-console.mjs b/apps/web/e2e/fixture-console.mjs index d13d46788..083571559 100644 --- a/apps/web/e2e/fixture-console.mjs +++ b/apps/web/e2e/fixture-console.mjs @@ -298,8 +298,9 @@ async function executorCredentialRoute(request, response, projectId, environment const credentials = state.executorCredentials.get(environmentId); if (!keyId && request.method === "GET") return send(response, 200, { data: credentials.map((entry) => ({ ...entry })) }); if (!keyId && request.method === "POST") { - const input = await body(request); - if (!keyIdValid(input.key_id) || (input.rotate !== undefined && typeof input.rotate !== "boolean")) return error(response, 400, "key_id must be a UUID.", "invalid_request"); + // As Core, a body that is not JSON is invalid input like any other: 400 with one message. + const input = await body(request).catch(() => null); + if (!keyIdValid(input?.key_id) || (input.rotate !== undefined && typeof input.rotate !== "boolean")) return error(response, 400, "Invalid resource identifier or request limits.", "invalid_request"); if (project.archived_at) return error(response, 409, "The target Project is archived.", "project_archived"); const existing = credentials.find((entry) => entry.key_id === input.key_id); if (existing && input.rotate !== true) return error(response, 409, "The executor credential exists; rotate it instead.", "executor_credential_exists"); From f9b145c63028dcf3c8da88e5e13933f494041c57 Mon Sep 17 00:00:00 2001 From: saladday <1203511142@qq.com> Date: Fri, 25 Sep 2026 16:01:58 +0000 Subject: [PATCH 11/11] Correct the executor credential rules in the Web docs Core accepts the self_hosted environment of any existing Session in the project and returns 404 otherwise; its only conflicts are project_archived and executor_credential_exists, and rotation restores a revoked key, which the console hides by choice. Record the archived-project view, the recovery rules and the Session page's credential management. --- docs/web/README.md | 6 ++++-- docs/web/protocol-coverage.md | 19 ++++++++++++++----- 2 files changed, 18 insertions(+), 7 deletions(-) diff --git a/docs/web/README.md b/docs/web/README.md index 43cb8951e..64e47222c 100644 --- a/docs/web/README.md +++ b/docs/web/README.md @@ -19,7 +19,7 @@ management client, and it sends nothing to `/v1`. | Monitor | Core metrics | The Core process: execution slots, the Turn queue, connected daemons, database latency and pool, background jobs | | Monitor | Agent metrics | Requests, errors, duration, tokens, models, tools, Agents and API keys over 1 h, 6 h, 24 h or 7 d | | Monitor | Sandbox metrics | Node capacity and hosted Runtime CPU and memory across Projects | -| Monitor | Session log | Every Session, read-only, opening one Session's conversation, trace and Turns | +| Monitor | Session log | Every Session, opening one Session's read-only conversation, trace and Turns; a self-hosted Session's page also manages its executor credentials | | Resources | Agents, Environment templates, Skills, Files, Vaults | Inspection and permitted deletion | | Platform | Projects and keys, Nodes, System | Project and key lifecycle, sandbox deployment and nodes, Core startup configuration | @@ -29,7 +29,9 @@ bounded is recorded in [management interface coverage](protocol-coverage.md). ## Management scope Administrators can create, rename and archive Projects; issue and revoke their -keys; inspect resources and execution history; and delete supported resources. +keys; inspect resources and execution history; delete supported resources; and +issue, rotate and revoke the executor credentials of a self-hosted Session's +environment on its Session page. They can also read summaries, Runtime observations and audit history, and manage deployment sandbox nodes. Deployment sandbox management selects E2B, Docker or microsandbox; caller-managed `self_hosted` Runtimes remain a separate application diff --git a/docs/web/protocol-coverage.md b/docs/web/protocol-coverage.md index 16c15c159..e8a84bb0b 100644 --- a/docs/web/protocol-coverage.md +++ b/docs/web/protocol-coverage.md @@ -85,13 +85,18 @@ Core issues the credentials of a self_hosted executor, and the console is where the administrator does it: the **Executor credentials** section of a Session page, shown only when the Session's environment is `self_hosted`, for that Session's `project_id` and `environment.id`. The routes accept only the -self_hosted environment of a Session that is still running; any other -environment returns 404 or 409. +`self_hosted` environment of an existing (not deleted) Session in that project; +anything else returns 404. Writes have two conflicts, both 409: +`project_archived` (issuing or rotating in an archived project) and +`executor_credential_exists` (issuing an existing `key_id` without +`rotate: true`). In an archived project the section hides **Issue credential** +and **Rotate** behind a note and keeps the list and **Revoke**, which Core still +allows. | Operation | Route | Console use | | --- | --- | --- | -| List credentials | `GET /core/v1/projects/{project_id}/environments/{environment_id}/executor-credentials` | The section's table, through the query cache: each credential's short `key_id` with its copy button, issue time and status (Active, or Revoked with its time), active first. The credential itself is never listed | -| Issue or rotate | `POST /core/v1/projects/{project_id}/environments/{environment_id}/executor-credentials` with `{"key_id", "rotate"}` | **Issue credential** generates `key_id` (`crypto.randomUUID()`) and keeps it before sending `rotate: false`. **Rotate**, confirmed (the old credential stops working immediately), sends an active credential's `key_id` with `rotate: true`; a revoked credential has no actions, as Core refuses to rotate it (409). Core returns the credential once (`201`, `Cache-Control: no-store`); the console shows it once in a dialog as the executor's credential file (`{key_id, environment_id, executor_token}`) to copy or download (`executor-credential-.json`), keeps it only in the dialog's state (never in browser storage or the query cache) and forgets it when the dialog closes. An existing `key_id` without `rotate: true` returns 409. Other rejections show Core's reason in an error toast (issue) or in the confirmation dialog (rotate) | +| List credentials | `GET /core/v1/projects/{project_id}/environments/{environment_id}/executor-credentials` | The section's table, through the query cache: each credential's short `key_id` with its copy button, creation time (`created_at`, which rotation does not change) and status (Active, or Revoked with its time), active first. The credential itself is never listed | +| Issue or rotate | `POST /core/v1/projects/{project_id}/environments/{environment_id}/executor-credentials` with `{"key_id", "rotate"}` | **Issue credential** generates `key_id` (`crypto.randomUUID()`) and keeps it before sending `rotate: false`. **Rotate**, confirmed (the old credential stops working immediately), sends an active credential's `key_id` with `rotate: true`. Core would also rotate a revoked credential, restoring it (`201`); the console hides Rotate on revoked rows by choice, so they have no actions. Core returns the credential once (`201`, `Cache-Control: no-store`); the console shows it once in a dialog as the executor's credential file (`{key_id, environment_id, executor_token}`) to copy or download (`executor-credential-.json`), keeps it only in the section's state (never in browser storage or the query cache) and forgets it when the administrator presses **Done**; dismissing the dialog keeps it on the page until then. An existing `key_id` without `rotate: true` returns 409 `executor_credential_exists`. Other rejections show Core's reason in an error toast (issue) or in the confirmation dialog (rotate) | | Revoke | `DELETE /core/v1/projects/{project_id}/environments/{environment_id}/executor-credentials/{key_id}` | **Revoke**, confirmed (the executor can no longer connect; a running process is not stopped), then the list is read again and shows the credential as Revoked; revoking again returns 204 | ## Provenance and monitoring @@ -146,7 +151,11 @@ enters the release under advanced settings. issued and its secret lost: the console offers to rotate it (`rotate: true`) for a fresh secret, shown once. If it is not listed, the next Issue sends the same `key_id` with `rotate: false`; should that return 409 because the first - request was issued after all, the console offers the same rotation. + request was issued after all, the console reads the list again and offers the + same rotation only if the credential is listed as active in an active project, + and otherwise reports the issuance as rejected. A kept `key_id` that is + already listed is never sent again, and rotating or revoking it from its row + forgets it: the next Issue generates a new `key_id`. ## Read bounds