From c06a70a5eeb358ec12c9beb6dfadb380c9f28aab Mon Sep 17 00:00:00 2001 From: SaladDay <1203511142@qq.com> Date: Wed, 7 Oct 2026 17:24:27 +0000 Subject: [PATCH] Delete unused admin and console paths --- .env.example | 43 ---- apps/web/DESIGN.md | 2 +- apps/web/PRODUCT.md | 2 +- apps/web/e2e/data/admin.mjs | 11 +- apps/web/e2e/data/resources.mjs | 50 +---- apps/web/e2e/fixture-console.mjs | 2 +- .../src/features/dashboard/runtime-history.ts | 5 +- .../web/src/features/files/file-operations.ts | 4 +- apps/web/src/features/fleet/fleet-queries.ts | 7 - .../features/fleet/use-sandbox-fleet.test.tsx | 5 +- .../src/features/fleet/use-sandbox-fleet.ts | 27 +-- .../src/features/metrics/AgentMetricsPage.tsx | 1 - .../features/metrics/SandboxMetricsPage.tsx | 3 +- .../features/metrics/agent-metrics-loader.ts | 8 +- .../features/metrics/agent-metrics.test.ts | 2 +- .../src/features/metrics/key-usage.test.ts | 2 +- .../src/features/metrics/metrics-queries.ts | 2 - .../features/metrics/project-sessions.test.ts | 12 +- .../src/features/metrics/project-sessions.ts | 24 +-- .../src/features/metrics/sandbox-runtime.ts | 5 +- .../src/features/overview/OverviewPage.tsx | 1 - .../src/features/overview/getting-started.ts | 4 +- .../features/overview/overview-loader.test.ts | 4 +- .../src/features/overview/test-fixtures.ts | 4 +- .../sandbox/SandboxDeploymentPage.tsx | 3 +- .../features/sandbox/SandboxManagerView.tsx | 15 +- .../features/sandbox/SandboxPageAccess.tsx | 15 -- .../features/sandbox/console-config.test.ts | 34 ++-- .../src/features/sandbox/console-config.ts | 30 +-- .../sandbox/sandbox-page-ownership.test.tsx | 2 +- .../src/features/sandbox/sandbox-queries.ts | 10 +- .../src/features/sessions/SessionLogPage.tsx | 46 +---- .../src/features/sessions/session-history.ts | 5 +- .../src/features/sessions/session-log.test.ts | 43 ++-- apps/web/src/features/sessions/session-log.ts | 72 ++----- .../src/features/sessions/session-runtime.ts | 5 +- .../features/skills/skill-operations.test.ts | 5 +- .../src/features/skills/skill-operations.ts | 9 +- .../src/features/vaults/vault-catalog.test.ts | 5 +- apps/web/src/features/vaults/vault-catalog.ts | 4 +- apps/web/src/i18n/locales/en/agents.ts | 2 +- apps/web/src/i18n/locales/en/common.ts | 4 +- apps/web/src/i18n/locales/en/keys.ts | 2 +- apps/web/src/i18n/locales/en/metrics.ts | 4 +- apps/web/src/i18n/locales/en/overview.ts | 1 - apps/web/src/i18n/locales/en/sessions.ts | 2 - apps/web/src/i18n/locales/zh-CN/agents.ts | 2 +- apps/web/src/i18n/locales/zh-CN/common.ts | 4 +- apps/web/src/i18n/locales/zh-CN/keys.ts | 2 +- apps/web/src/i18n/locales/zh-CN/metrics.ts | 4 +- apps/web/src/i18n/locales/zh-CN/overview.ts | 1 - apps/web/src/i18n/locales/zh-CN/sessions.ts | 2 - apps/web/src/lib/admin-view.ts | 10 +- apps/web/src/lib/docker-guide-config.test.ts | 95 --------- apps/web/src/lib/docker-guide-config.ts | 103 ---------- apps/web/src/lib/locale-strings.ts | 1 - apps/web/src/lib/projects.tsx | 62 ++++-- apps/web/src/lib/queries.ts | 6 +- apps/web/src/lib/vite-config.test.ts | 38 ---- apps/web/src/vite-env.d.ts | 26 --- apps/web/vite.config.ts | 18 -- contracts/agents-api/admin-api.md | 8 +- contracts/agents-api/core.openapi.yaml | 8 - contracts/agents-api/zh/admin-api.md | 10 +- docs/web/console-api-usage.md | 2 +- docs/zh/web/console-api-usage.md | 4 +- packages/agents-client/README.md | 2 +- .../agents-client/src/admin-client.test.ts | 20 +- .../agents-client/src/admin-projection.ts | 30 +-- packages/agents-client/src/admin-types.ts | 55 +----- packages/agents-client/src/client.ts | 84 +------- .../src/core-project-reader.test.ts | 63 ------ packages/agents-client/src/index.ts | 2 +- .../agents-client/src/sessions-list.test.ts | 186 ------------------ packages/agents-client/src/types.ts | 30 +-- scripts/build-core-distribution.sh | 2 +- services/core/internal/adminaudit/reader.go | 22 +-- .../core/internal/db/queries/admin_audit.sql | 4 +- .../internal/db/queries/admin_history.sql | 4 - .../core/internal/db/sqlc/admin_audit.sql.go | 6 +- .../internal/db/sqlc/admin_history.sql.go | 39 +--- services/core/internal/db/sqlc/models.go | 17 -- .../postgres/agentpg/store_test.go | 6 +- .../postgres/auditpg/admin_audit.go | 2 +- .../postgres/auditpg/auditpg_test.go | 49 ++--- .../persistence/postgres/auditpg/record.go | 5 +- .../postgres/auditpg/write_operations.go | 28 +-- .../postgres/filepg/filepg_test.go | 4 +- .../postgres/sessionpg/creation_test.go | 6 +- .../postgres/skillpg/audit_test.go | 12 +- .../postgres/templatepg/audit_test.go | 10 +- .../postgres/vaultpg/audit_test.go | 10 +- services/core/internal/writeaudit/reader.go | 10 +- services/core/internal/writeaudit/record.go | 28 +-- .../core/internal/writeaudit/record_test.go | 22 +-- .../migrations/000092_delete_admin_copies.sql | 24 +++ .../integration/admin_delete_audit_test.go | 12 +- .../integration/session_write_audit_test.go | 7 +- services/web/distribution_test.go | 59 ++++-- services/web/node_artifacts.go | 2 +- services/web/node_installation.go | 11 +- services/web/node_installation_test.go | 6 +- services/web/project_proxy_test.go | 9 - services/web/server.go | 4 - 104 files changed, 402 insertions(+), 1444 deletions(-) delete mode 100644 apps/web/src/features/sandbox/SandboxPageAccess.tsx delete mode 100644 apps/web/src/lib/docker-guide-config.test.ts delete mode 100644 apps/web/src/lib/docker-guide-config.ts delete mode 100644 apps/web/src/lib/vite-config.test.ts delete mode 100644 packages/agents-client/src/core-project-reader.test.ts delete mode 100644 packages/agents-client/src/sessions-list.test.ts create mode 100644 services/core/migrations/000092_delete_admin_copies.sql diff --git a/.env.example b/.env.example index fd5aa9360..9c82663c2 100644 --- a/.env.example +++ b/.env.example @@ -4,46 +4,3 @@ OAC_WEB_DEV_PROXY_TARGET=http://127.0.0.1:8091 # Core reads its own environment, not this file. Core settings, including # Runtime history sampling and export, are in docs/configuration.md. - -# Public, non-secret opt-in for the reviewed Codex self_hosted Session profile. -# Leave unset unless Core execution, its executor registry, and executor origin -# are configured. This flag is presentation policy, not capability discovery. -# OAC_WEB_SELF_HOSTED_SESSIONS=1 - -# Public, non-secret opt-in for the operator-qualified basic Codex -# openai_hosted Session profile. Leave unset unless Core was started with a -# qualified managed Runtime provider. This flag does not probe runtime readiness. -# OAC_WEB_OPENAI_HOSTED_SESSIONS=1 - -# Public, non-secret opt-in for the complete Environment Files profile. Leave -# unset for older Core revisions. Enable only after the connected Core has been -# qualified for Files.list and managed Files.create; self_hosted reads use its -# exact workspace_directory root. -# OAC_WEB_ENVIRONMENT_FILES=1 - -# Optional local-only Docker backend recovery guide shown in the connection -# panel. Web renders copyable `docker start` and loopback health commands; it -# never accesses the Docker socket or executes them. Values are compiled into -# the browser bundle and must contain non-secret container names only. -# OAC_WEB_DOCKER_BACKEND_GUIDE=1 -# OAC_WEB_DOCKER_DATABASE_CONTAINER=oac-web-smoke-db -# OAC_WEB_DOCKER_API_CONTAINER=oac-web-smoke-api -# OAC_WEB_DOCKER_DAEMON_CONTAINER=oac-web-smoke-daemon -# OAC_WEB_DOCKER_CORE_PORT=8091 - -# Optional local-only Docker connection recipe. This renders a copyable command; -# it never gives the browser Docker access or reads the credential file. Every -# value below is compiled into the browser bundle, so values must be non-secret. -# Enable only for the matching operator-controlled local stack. -# OAC_WEB_DOCKER_GUIDE=1 -# OAC_WEB_DOCKER_IMAGE=oac-web-smoke-executor:2b34ea46-codex-0.153.4 -# OAC_WEB_DOCKER_API_CONTAINER=oac-web-smoke-api -# OAC_WEB_DOCKER_USER=501:20 -# OAC_WEB_DOCKER_CREDENTIALS_HOME_PATH=.oac/web-smoke/executor-key.json -# OAC_WEB_DOCKER_RUNTIME_HOME_PATH=.oac/web-smoke/executors - -# Public, non-secret suggestions shown by the Create Agent model picker. The -# first entry is the default unless VITE_AGENT_DEFAULT_MODEL overrides it. These -# do not claim live availability; the connected runtime remains authoritative. -VITE_AGENT_MODEL_PRESETS=gpt-6-astra,gpt-5.6-sol,gpt-5.6-terra,gpt-5.6-luna,gpt-5.5,gpt-5.3-codex-spark -VITE_AGENT_DEFAULT_MODEL=gpt-5.6-sol diff --git a/apps/web/DESIGN.md b/apps/web/DESIGN.md index d4430a411..94ad4fd43 100644 --- a/apps/web/DESIGN.md +++ b/apps/web/DESIGN.md @@ -348,7 +348,7 @@ Every resource list, the Session log and the project list share one grammar: - **ListToolbar**: on project-scoped lists the project filter first, then the SearchField (280px, search icon, Paper with the control ring), then any further filters (segmented status or order, selects); the count sits on the right in 12.5px Pencil ("12 total", "3 of 12", "40 loaded" when more exist). - **Project column**: shown only while All projects is selected, right after the name; archived projects are muted. - **NameCell**: the first column. The name at 500 weight (a link that turns indigo on hover when the row opens a detail page; a muted fallback such as "Untitled" when the resource has no name) with the compact ID underneath in 11.5px mono. The ID's copy button appears on row hover or focus; the full ID lives in its tooltip. -- **Creator column**: the last column before the actions, headed "Creator" with a help tip. It shows the creating key's name (its prefix when unnamed) with a small "Revoked" flag for revoked keys, "Admin copy" in Graphite for an asset Core records as an administrator copy, "Unknown" in Graphite when Core has no record, and "—" while loading or when the lookup failed. +- **Creator column**: the last column before the actions, headed "Creator" with a help tip. It shows the creating key's name (its prefix when unnamed) with a small "Revoked" flag for revoked keys, "Unknown" in Graphite when Core has no record, and "—" while loading or when the lookup failed. - **RowActions**: text actions right-aligned at the end of the row, 16px apart, ending with Delete (red on hover). A row click opens the detail page; action clicks do not. - **Partial failure**: when some projects fail to load, one red line names them above the table; the other projects still show. - **Empty state**: an unframed, centered block with an optional 24px outline icon, a clear title, a visible short explanation and a relevant action. First-use states explain how data arrives; filtered states offer Clear search; failed reads retain their error and retry. Empty Overview activity links to Projects and keys for API onboarding. diff --git a/apps/web/PRODUCT.md b/apps/web/PRODUCT.md index b1fe280d9..5a8afa95e 100644 --- a/apps/web/PRODUCT.md +++ b/apps/web/PRODUCT.md @@ -49,7 +49,7 @@ The console runs beside the administrator's own Core, with execution, files and - **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 Sessions, send input or cancel work. Deletion follows the public deletion rules; a busy Session is not deletable and the console never cancels work to make it so. - **Secrets stay write-only.** Credential tokens, Template environment variables and setup commands are never returned, to the administrator included. An Agent's saved model provider shows its protocol, base URL, limits and whether a key is configured, never the key. -- **Creators.** Core records the key behind every write. The console shows the creating key of each asset and a project's write history; an asset Core records as an administrator copy (`admin_copy`) shows as Admin copy and an asset without a record as Unknown. +- **Creators.** Core records the key behind every write. The console shows the creating key of each asset and a project's write history; an asset without a record shows as Unknown. - **Waiting for results.** Overview, Session log and Session details name the function whose result the calling application must submit. The console cannot submit that result; environment connection waits stay distinct from function waits. - **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. - **Failure diagnostics.** Failed Session and Turn rows read Core diagnostics and translate its classified reason. The console never infers a cause from raw logs. Unavailable or mismatched diagnostics offer an explicit read retry; refreshing does not replay execution. Trace Timing keeps each Item's Core receipt interval separate from public Turn times and native tool duration. Historical missing timestamps stay unknown, negative clock intervals stay missing, and bounded response truncation remains visible. diff --git a/apps/web/e2e/data/admin.mjs b/apps/web/e2e/data/admin.mjs index b3a4ff179..058d35875 100644 --- a/apps/web/e2e/data/admin.mjs +++ b/apps/web/e2e/data/admin.mjs @@ -52,7 +52,7 @@ export function buildAdmin(now, base, resources) { const cacheKey = `${type}:${id}`; if (!creators.has(cacheKey)) { turn += 1; - creators.set(cacheKey, turn % 11 === 0 ? { copy: true } : turn % 7 === 0 ? null : keyRef(project, project.keys[turn % project.keys.length])); + creators.set(cacheKey, turn % 7 === 0 ? null : keyRef(project, project.keys[turn % project.keys.length])); } return creators.get(cacheKey); }; @@ -62,7 +62,6 @@ export function buildAdmin(now, base, resources) { const list = []; const push = (at, action, type, id, parent = "", creatorType = type) => { const creator = action === "create" ? creatorFor(project, creatorType, id) : keyRef(project, project.keys[list.length % project.keys.length]); - if (creator?.copy) return; // Administrator copies are in the audit log, not in key write history. list.push({ id: `op_${project.id.slice(5)}_${list.length}`, created_at: iso(at), api_key: creator, action, resource_type: type, resource_id: id, parent_id: parent, request_id: `req_${list.length}`, trace_id: `${list.length}`.padStart(32, "0") }); }; for (const agent of own.agents) { push(agent.created_at, "create", "agent", agent.id); if (agent.updated_at > agent.created_at) push(agent.updated_at, "update", "agent", agent.id); } @@ -81,11 +80,7 @@ export function buildAdmin(now, base, resources) { function resourceOwners(project, url) { const type = url.searchParams.get("resource_type"); const ids = (url.searchParams.get("resource_ids") ?? "").split(",").filter(Boolean).slice(0, 100); - return { data: ids.map((id) => { - const creator = creatorFor(project, type, id); - if (creator?.copy) return { resource_id: id, api_key: null, source: "admin_copy", admin_audit_id: `audit_${id.slice(-8)}` }; - return creator ? { resource_id: id, api_key: creator, source: "api_key", admin_audit_id: null } : { resource_id: id, api_key: null, source: null, admin_audit_id: null }; - }) }; + return { data: ids.map((id) => ({ resource_id: id, api_key: creatorFor(project, type, id) })) }; } function summarize(sessions) { @@ -143,7 +138,7 @@ export function buildAdmin(now, base, resources) { const auditLog = () => { const entries = []; let n = 0; - const add = (at, action, project, type, id, results = []) => entries.push({ id: `audit_${String(++n).padStart(4, "0")}`, created_at: iso(at), admin_credential_id: "a1b2c3d4", actor_label: "admin", action, project_id: project.id, resource_type: type, resource_id: id, result_ids: results, request_id: `req_admin_${n}`, trace_id: `${n}`.padStart(32, "a") }); + const add = (at, action, project, type, id) => entries.push({ id: `audit_${String(++n).padStart(4, "0")}`, created_at: iso(at), admin_credential_id: "a1b2c3d4", actor_label: "admin", action, project_id: project.id, resource_type: type, resource_id: id, request_id: `req_admin_${n}`, trace_id: `${n}`.padStart(32, "a") }); for (const project of projects) { add(project.created_at, "create_project", project, "project", project.id); for (const k of project.keys) { add(k.created_at, "issue_key", project, "api_key", k.id); if (k.revoked_at) add(k.revoked_at, "revoke_key", project, "api_key", k.id); } diff --git a/apps/web/e2e/data/resources.mjs b/apps/web/e2e/data/resources.mjs index 79bd37bc0..a542ef781 100644 --- a/apps/web/e2e/data/resources.mjs +++ b/apps/web/e2e/data/resources.mjs @@ -3,22 +3,9 @@ let seed = 7; const rand = () => ((seed = (seed * 1664525 + 1013904223) % 4294967296) / 4294967296); const hex = (n) => Array.from({ length: n }, () => Math.floor(rand() * 16).toString(16)).join(""); const uuid = () => `${hex(8)}-${hex(4)}-4${hex(3)}-8${hex(3)}-${hex(12)}`; -const iso = (seconds) => new Date(seconds * 1000).toISOString().replace(/\.\d{3}Z$/, "Z"); -export function buildResources(now, agents, sessions) { +export function buildResources(now) { seed = 7; - const keys = [ - { id: "fb533e99-524f-4e44-94bc-8f6e571646a7", name: "Production app", prefix: "pc_live_7Hq", created_at: iso(now - 86400 * 21), revoked_at: null }, - { id: "3c1d9e20-7a41-4b8e-9f02-5d6e7f8a9b10", name: "CI pipeline", prefix: "pc_live_Qm4", created_at: iso(now - 86400 * 16), revoked_at: null }, - { id: "a47e2b19-0c3d-4e5f-8a6b-7c8d9e0f1a2b", name: "Data team notebook", prefix: "pc_live_k9T", created_at: iso(now - 86400 * 9), revoked_at: null }, - { id: "0b533e99-524f-4e44-94bc-8f6e571646a7", name: "Staging", prefix: "pc_live_2Xa", created_at: iso(now - 86400 * 30), revoked_at: iso(now - 86400 * 6) }, - ]; - const refOf = (key) => ({ type: "project_api_key", id: key.id, name: key.name, prefix: key.prefix, revoked_at: key.revoked_at }); - const consoleRef = { type: "console", id: null, name: null, prefix: null, revoked_at: null }; - // Weighted owner choice: most traffic from production, some unknown (created before recording). - const owners = [refOf(keys[0]), refOf(keys[0]), refOf(keys[0]), refOf(keys[1]), refOf(keys[1]), refOf(keys[2]), refOf(keys[3]), consoleRef, null]; - const ownerOf = () => owners[Math.floor(rand() * owners.length)]; - const skills = [ ["report", "Create quarterly and incident reports from structured notes.", 3, 2], ["triage", "Sort incoming issues by severity and owner.", 2, 2], @@ -72,41 +59,8 @@ export function buildResources(now, agents, sessions) { created_at: created, updated_at: created + (index % 2 ? 86400 : 0) }; }).reverse()])); - const ownership = new Map(); - const own = (type, id, created) => { const owner = ownerOf(); ownership.set(`${type}:${id}`, { resource_type: type, resource_id: id, owner, created_at: owner ? iso(created) : null }); return owner; }; - const activity = []; - const record = (owner, action, type, id, at, parent = null) => { - if (!owner) return; - activity.push({ id: `act_${uuid()}`, object: "api_key.activity", created_at: iso(at), actor: owner, action, resource_type: type, resource_id: id, parent_resource_id: parent, trace_id: hex(32) }); - }; - for (const agent of agents) { const owner = own("agent", agent.id, agent.created_at); record(owner, "create", "agent", agent.id, agent.created_at); if (agent.updated_at > agent.created_at) record(owner, "update", "agent", agent.id, agent.updated_at); } - for (const session of sessions) { const owner = own("session", session.id, session.created_at); record(owner, "create", "session", session.id, session.created_at); if (session.last_active_at > session.created_at + 60) record(owner, "send", "session", session.id, session.last_active_at); } - for (const skill of skills) { - const owner = own("skill", skill.id, skill.created_at); - record(owner, "create", "skill", skill.id, skill.created_at); - for (const version of skillVersions.get(skill.id).slice(0, -1)) record(owner, "create", "skill_version", version.id, version.created_at, skill.id); - if (skill.default_version !== skill.latest_version) record(owner, "update", "skill", skill.id, skill.created_at + 86400); - } - for (const file of files) record(own("file", file.id, file.created_at), "create", "file", file.id, file.created_at); - for (const template of templates) { const owner = own("environment_template", template.id, template.created_at); record(owner, "create", "environment_template", template.id, template.created_at); record(owner, "update", "environment_template", template.id, template.updated_at); } - for (const { vault } of vaults) { - const owner = own("vault", vault.id, vault.created_at); - record(owner, "create", "vault", vault.id, vault.created_at); - for (const credential of credentials.get(vault.id)) { - own("vault_credential", credential.id, credential.created_at); - record(owner, "create", "vault_credential", credential.id, credential.created_at, vault.id); - if (credential.updated_at > credential.created_at) record(owner, "update", "vault_credential", credential.id, credential.updated_at, vault.id); - } - } - // Deleted resources still appear in the log. - record(refOf(keys[3]), "delete", "agent", `agent_${hex(8)}`, now - 86400 * 7); - record(refOf(keys[1]), "delete", "file", `file-${uuid()}`, now - 86400 * 2 - 600); - record(refOf(keys[1]), "delete", "skill_version", `skillver_${uuid()}`, now - 86400 * 3, skills[1].id); - activity.sort((a, b) => Date.parse(b.created_at) - Date.parse(a.created_at)); - return { - keys, skills, skillVersions, files, templates, + skills, skillVersions, files, templates, vaults: vaults.map(({ vault }) => vault), credentials, - ownership, activity, }; } diff --git a/apps/web/e2e/fixture-console.mjs b/apps/web/e2e/fixture-console.mjs index 6d57e3cc2..1145cd52d 100644 --- a/apps/web/e2e/fixture-console.mjs +++ b/apps/web/e2e/fixture-console.mjs @@ -97,7 +97,7 @@ function reset(mode = "login", fresh = false, sandbox = "configured", nodes = "d const screenshots = process.env.OAC_WEB_SCREENSHOT_DEMO === "1"; const now = Math.floor(Date.now() / 1000); const base = (screenshots ? buildScreenshotDemo : buildDemo)(now, address === "local" ? LOCAL_URL : PUBLIC_URL); - const resources = buildResources(now, base.agents, base.sessions); + const resources = buildResources(now); const admin = buildAdmin(now, base, resources); // A fresh install: no project, Session or Runtime yet; Getting started leads. if (fresh) for (const list of [admin.projects, base.sessions, base.observations, base.allocations]) list.splice(0); diff --git a/apps/web/src/features/dashboard/runtime-history.ts b/apps/web/src/features/dashboard/runtime-history.ts index 0f07a386b..4a19e37ed 100644 --- a/apps/web/src/features/dashboard/runtime-history.ts +++ b/apps/web/src/features/dashboard/runtime-history.ts @@ -1,5 +1,6 @@ -import { AgentCoreError, type AgentSession, type CoreProjectReader, type RuntimeHistory } from "@oac/agents-client"; +import { AgentCoreError, type AgentSession, type RuntimeHistory } from "@oac/agents-client"; +import type { ProjectClient } from "../../lib/projects"; import type { RuntimeDashboardSnapshot } from "./runtime-snapshot"; import { deriveTokenThroughput, type RuntimeTrendSample, type RuntimeTrendTarget } from "./runtime-trends"; @@ -160,7 +161,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 a130db446..f7570c530 100644 --- a/apps/web/src/features/files/file-operations.ts +++ b/apps/web/src/features/files/file-operations.ts @@ -1,10 +1,10 @@ import { AgentCoreError, - type CoreProjectReader, type PageOrder, type SourceFileListEntry, } from "@oac/agents-client"; +import type { ProjectClient } from "../../lib/projects"; import { appendCollectionPage } from "../../lib/collection-pagination"; /** Core's single-upload bound for user_data Files. */ @@ -74,7 +74,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/fleet/fleet-queries.ts b/apps/web/src/features/fleet/fleet-queries.ts index 99c65e6f0..65176ad25 100644 --- a/apps/web/src/features/fleet/fleet-queries.ts +++ b/apps/web/src/features/fleet/fleet-queries.ts @@ -2,7 +2,6 @@ import { queryOptions, type QueryClient } from "@tanstack/react-query"; import { SandboxAdminClient, type SandboxAllocation, type SandboxDeployment, type SandboxNode, type SandboxNodeHistoryRange } from "@oac/agents-client"; import { sandboxDeploymentQuery } from "../sandbox/sandbox-queries"; -import { sandboxConsoleConfig } from "../sandbox/console-config"; export interface FleetSnapshot { deployment: SandboxDeployment; @@ -18,12 +17,6 @@ function client(): SandboxAdminClient { return sandboxClient; } -/** The console's own configuration: whether it holds a sandbox administration credential. */ -export const consoleConfigQuery = queryOptions({ - queryKey: ["console-config"], - queryFn: ({ signal }) => sandboxConsoleConfig(signal), -}); - async function loadFleet(readAllocations: boolean, signal: AbortSignal, cache: QueryClient): Promise { const sandbox = client(); const [deployment, nodes] = await Promise.all([ diff --git a/apps/web/src/features/fleet/use-sandbox-fleet.test.tsx b/apps/web/src/features/fleet/use-sandbox-fleet.test.tsx index 966d264d8..c97c61b5e 100644 --- a/apps/web/src/features/fleet/use-sandbox-fleet.test.tsx +++ b/apps/web/src/features/fleet/use-sandbox-fleet.test.tsx @@ -5,9 +5,8 @@ import { describe, expect, it } from "vitest"; import { gettingStartedSteps } from "../overview/getting-started"; import { node } from "../overview/test-fixtures"; -import type { SandboxConsoleConfig } from "../sandbox/console-config"; import { sandboxDeploymentQuery } from "../sandbox/sandbox-queries"; -import { consoleConfigQuery, fleetQuery, type FleetSnapshot } from "./fleet-queries"; +import { fleetQuery, type FleetSnapshot } from "./fleet-queries"; import { FleetReadNotice } from "./FleetReadNotice"; import { fleetSnapshot, useSandboxFleet } from "./use-sandbox-fleet"; @@ -23,8 +22,6 @@ function Probe() { function render(latest: SandboxDeployment, previous = configured, failed = false) { const cache = new QueryClient({ defaultOptions: { queries: { retry: false } } }); const snapshot: FleetSnapshot = { deployment: previous, nodes: [node("n1")], allocations: [], loadedAt: 1 }; - const config: SandboxConsoleConfig = { sandbox_admin: true, node_installer: false, node_installer_sha256: "" }; - cache.setQueryData(consoleConfigQuery.queryKey, () => config); cache.setQueryData(fleetQuery(false).queryKey, snapshot); cache.setQueryData(sandboxDeploymentQuery.queryKey, latest); if (failed) cache.getQueryCache().find({ queryKey: fleetQuery(false).queryKey })?.setState({ status: "error", error: new Error("inventory read failed") }); diff --git a/apps/web/src/features/fleet/use-sandbox-fleet.ts b/apps/web/src/features/fleet/use-sandbox-fleet.ts index 3e13bbdea..c7e68dd22 100644 --- a/apps/web/src/features/fleet/use-sandbox-fleet.ts +++ b/apps/web/src/features/fleet/use-sandbox-fleet.ts @@ -3,14 +3,11 @@ import { useCallback, useEffect } from "react"; import type { SandboxDeployment } from "@oac/agents-client"; import { sandboxDeploymentQuery } from "../sandbox/sandbox-queries"; -import { consoleConfigQuery, fleetQuery, type FleetSnapshot } from "./fleet-queries"; +import { fleetQuery, type FleetSnapshot } from "./fleet-queries"; export type { FleetSnapshot }; export type FleetState = - | { status: "checking" } - /** The console has no sandbox administration credential. */ - | { status: "unconfigured" } | { status: "loading" } | { status: "ready"; snapshot: FleetSnapshot; targetGeneration: number; refreshing: boolean; error: unknown | null } | { status: "failed"; error: unknown }; @@ -24,14 +21,9 @@ export const FLEET_REFRESH_MS = 30_000; * refresh keeps the last snapshot on screen. Node writes stay on the Nodes page. */ export function useSandboxFleet({ poll = true, allocations = false }: { poll?: boolean; allocations?: boolean } = {}) { - const config = useQuery(consoleConfigQuery); - // A failed configuration read is a failure, not "no sandbox administration". - const configFailed = config.isError && config.data === undefined; - const adminAvailable = config.isPending || configFailed ? null : config.data?.sandbox_admin === true; - const deployment = useQuery({ ...sandboxDeploymentQuery, enabled: adminAvailable === true }); + const deployment = useQuery(sandboxDeploymentQuery); const fleet = useQuery({ ...fleetQuery(allocations), - enabled: adminAvailable === true, refetchInterval: poll ? FLEET_REFRESH_MS : false, refetchIntervalInBackground: false, }); @@ -43,23 +35,16 @@ export function useSandboxFleet({ poll = true, allocations = false }: { poll?: b // Compatible prior-generation inventory remains visible as an older observation. // A reset or backend lifecycle change makes that earlier inventory invalid. useEffect(() => { - if (adminAvailable === true && (differentDeployment || changedGeneration)) void refetchFleet(); - }, [adminAvailable, differentDeployment, changedGeneration, deployment.data?.installation_id, deployment.data?.owner_epoch, deployment.data?.generation, deployment.data?.provider, deployment.data?.mode, deployment.data?.reset?.requested_at, refetchFleet]); + if (differentDeployment || changedGeneration) void refetchFleet(); + }, [differentDeployment, changedGeneration, deployment.data?.installation_id, deployment.data?.owner_epoch, deployment.data?.generation, deployment.data?.provider, deployment.data?.mode, deployment.data?.reset?.requested_at, refetchFleet]); let state: FleetState; - if (configFailed) state = config.isFetching ? { status: "checking" } : { status: "failed", error: config.error }; - else if (adminAvailable === null) state = { status: "checking" }; - else if (!adminAvailable) state = { status: "unconfigured" }; - else if (differentDeployment) state = fleet.isError && !fleet.isFetching ? { status: "failed", error: fleet.error } : { status: "loading" }; + if (differentDeployment) state = fleet.isError && !fleet.isFetching ? { status: "failed", error: fleet.error } : { status: "loading" }; else if (fleet.data) state = { status: "ready", snapshot: fleet.data, targetGeneration: deployment.data?.generation ?? fleet.data.deployment.generation, refreshing: fleet.isFetching, error: fleet.isError ? fleet.error : null }; else if (fleet.isError && !fleet.isFetching) state = { status: "failed", error: fleet.error }; else state = { status: "loading" }; - const { refetch: refetchConfig } = config; - // Without sandbox administration a refresh asks the console again whether it has it. - const refresh = useCallback(() => { - void (adminAvailable === true ? refetchFleet() : refetchConfig()); - }, [adminAvailable, refetchConfig, refetchFleet]); + const refresh = useCallback(() => { void refetchFleet(); }, [refetchFleet]); return { state, refresh, deployment }; } diff --git a/apps/web/src/features/metrics/AgentMetricsPage.tsx b/apps/web/src/features/metrics/AgentMetricsPage.tsx index 9e31660c1..833e35435 100644 --- a/apps/web/src/features/metrics/AgentMetricsPage.tsx +++ b/apps/web/src/features/metrics/AgentMetricsPage.tsx @@ -153,7 +153,6 @@ function coverageNote(loaded: Loaded, t: TFunction<"metrics">): string | null { const { coverage } = loaded.metrics; const parts: string[] = []; if (loaded.truncatedLists.length) parts.push(t("coverage.listTruncated", { names: loaded.truncatedLists.map((project) => project.name).join(", ") })); - if (loaded.unrecognizedSessions) parts.push(t("coverage.unrecognized", { count: loaded.unrecognizedSessions })); if (coverage.skippedSessions) parts.push(t("coverage.skipped", { loaded: coverage.loadedSessions, total: coverage.candidateSessions })); if (coverage.truncatedSessions) parts.push(t("coverage.truncated", { count: coverage.truncatedSessions })); if (coverage.failedSessions) parts.push(t("coverage.failed", { count: coverage.failedSessions })); diff --git a/apps/web/src/features/metrics/SandboxMetricsPage.tsx b/apps/web/src/features/metrics/SandboxMetricsPage.tsx index 1e1262607..e3b70c6a8 100644 --- a/apps/web/src/features/metrics/SandboxMetricsPage.tsx +++ b/apps/web/src/features/metrics/SandboxMetricsPage.tsx @@ -87,7 +87,6 @@ const loadHistory = (snapshot: RuntimeDashboardSnapshot, range: RuntimeDurableRa }, snapshot, range, signal); function fleetMessage(state: FleetState, t: TFunction<"metrics">): string { - if (state.status === "unconfigured") return t("sandbox.fleetUnconfigured"); if (state.status === "failed") return t("sandbox.fleetFailed"); return t("sandbox.fleetLoading"); } @@ -193,7 +192,7 @@ export function SandboxMetricsPage() { action={} /> ) - ) : fleetState.status === "checking" || fleetState.status === "loading" + ) : fleetState.status === "loading" ? :

{message}

} } diff --git a/apps/web/src/features/metrics/agent-metrics-loader.ts b/apps/web/src/features/metrics/agent-metrics-loader.ts index a89f17c5c..b7e6ba23a 100644 --- a/apps/web/src/features/metrics/agent-metrics-loader.ts +++ b/apps/web/src/features/metrics/agent-metrics-loader.ts @@ -1,5 +1,6 @@ -import type { AgentSession, AgentTurn, ListPage, CoreProjectReader, PageOptions, SessionItem } from "@oac/agents-client"; +import type { AgentSession, AgentTurn, ListPage, PageOptions, SessionItem } from "@oac/agents-client"; +import type { ProjectClient } from "../../lib/projects"; import type { MetricsCoverage, MetricsWindow, SessionActivity } from "./agent-metrics"; import { readProjectsSessions, type InProject, type ProjectReadFailure, type SessionLister } from "./project-sessions"; import { type Project, type ProjectSummary } from "../../lib/admin-view"; @@ -200,7 +201,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 { @@ -230,8 +231,6 @@ export interface ProjectAgentMetricsLoad extends AgentMetricsLoad { /** Projects whose Session list was longer than the list cap. */ truncatedLists: Project[]; listFailures: ProjectReadFailure[]; - /** Listed Sessions the client could not recognize; their Turns are not counted. */ - unrecognizedSessions: number; } /** @@ -258,6 +257,5 @@ export async function loadProjectAgentMetrics( coverage: load.coverage, truncatedLists: reads.filter((read) => !read.complete).map((read) => read.project), listFailures: failures, - unrecognizedSessions: reads.reduce((sum, read) => sum + read.unrecognized, 0), }; } diff --git a/apps/web/src/features/metrics/agent-metrics.test.ts b/apps/web/src/features/metrics/agent-metrics.test.ts index d8fcf96ce..e9d18775e 100644 --- a/apps/web/src/features/metrics/agent-metrics.test.ts +++ b/apps/web/src/features/metrics/agent-metrics.test.ts @@ -365,7 +365,7 @@ describe("Agent metrics across projects", () => { window, { clientFor: (target) => { - if (target.id === "down") return { ...clients.busy!, listSessionsTolerant: async () => { throw new Error("HTTP 502"); } }; + if (target.id === "down") return { ...clients.busy!, listSessions: async () => { throw new Error("HTTP 502"); } }; return clients[target.id]!; }, summary: [ diff --git a/apps/web/src/features/metrics/key-usage.test.ts b/apps/web/src/features/metrics/key-usage.test.ts index 5af3632c0..ce8bfb7d7 100644 --- a/apps/web/src/features/metrics/key-usage.test.ts +++ b/apps/web/src/features/metrics/key-usage.test.ts @@ -5,7 +5,7 @@ import { summary } from "../overview/test-fixtures"; import { keyUsageRows } from "./key-usage"; import { type KeyRef } from "../../lib/admin-view"; -const key = (id: string): KeyRef => ({ id, name: id, prefix: `pc_${id}`, kind: "issued", revoked_at: null }); +const key = (id: string): KeyRef => ({ id, name: id, prefix: `pc_${id}`, revoked_at: null }); const sessions = (total: number) => ({ total, idle: total, in_progress: 0, requires_action: 0, failed: 0 }); const usage = (total: number) => ({ input_tokens: total, output_tokens: 0, total_tokens: total, cached_tokens: 0, reasoning_tokens: 0 }); diff --git a/apps/web/src/features/metrics/metrics-queries.ts b/apps/web/src/features/metrics/metrics-queries.ts index 6ebb85207..d76ce332d 100644 --- a/apps/web/src/features/metrics/metrics-queries.ts +++ b/apps/web/src/features/metrics/metrics-queries.ts @@ -22,7 +22,6 @@ export interface LoadedAgentMetrics { metrics: AgentMetrics; truncatedLists: Project[]; listFailures: ProjectReadFailure[]; - unrecognizedSessions: number; /** Epoch milliseconds. */ loadedAt: number; } @@ -47,7 +46,6 @@ export function agentMetricsQuery(targets: readonly Project[], filter: string, r metrics: aggregateAgentMetrics(window, load.activities, load.coverage), truncatedLists: load.truncatedLists, listFailures: load.listFailures, - unrecognizedSessions: load.unrecognizedSessions, loadedAt: Date.now(), }; }, diff --git a/apps/web/src/features/metrics/project-sessions.test.ts b/apps/web/src/features/metrics/project-sessions.test.ts index eb2c47cf9..c4cffbd5d 100644 --- a/apps/web/src/features/metrics/project-sessions.test.ts +++ b/apps/web/src/features/metrics/project-sessions.test.ts @@ -9,7 +9,7 @@ describe("readSessions", () => { it("walks pages newest first until the list ends", async () => { const lister = sessionLister(many(250)); const read = await readSessions(lister, { maxSessions: 1_000 }); - expect(read).toMatchObject({ complete: true, unrecognized: 0 }); + expect(read.complete).toBe(true); expect(read.sessions).toHaveLength(250); expect(lister.calls).toEqual(["first", "s99", "s199"]); }); @@ -22,21 +22,13 @@ describe("readSessions", () => { expect(capped.complete).toBe(false); expect(capped.sessions).toHaveLength(150); }); - - it("counts unrecognized entries without keeping them", async () => { - const read = await readSessions({ - listSessionsTolerant: async () => ({ object: "list", data: [session("a")], unrecognized: [{ index: 1, id: null }], has_more: false, first_id: "a", last_id: "a" }), - }, { maxSessions: 100 }); - expect(read).toMatchObject({ unrecognized: 1, complete: true }); - expect(read.sessions).toHaveLength(1); - }); }); describe("readProjectsSessions", () => { it("reads projects in parallel and reports a failing project by name", async () => { const { reads, failures } = await readProjectsSessions( [project("ok"), project("down")], - (target) => (target.id === "ok" ? sessionLister(many(3)) : { listSessionsTolerant: async () => { throw new Error("HTTP 503"); } }), + (target) => (target.id === "ok" ? sessionLister(many(3)) : { listSessions: async () => { throw new Error("HTTP 503"); } }), () => ({ maxSessions: 100 }), ); expect(reads.map((read) => [read.project.id, read.sessions.length])).toEqual([["ok", 3]]); diff --git a/apps/web/src/features/metrics/project-sessions.ts b/apps/web/src/features/metrics/project-sessions.ts index ba9153e00..c5c38fe89 100644 --- a/apps/web/src/features/metrics/project-sessions.ts +++ b/apps/web/src/features/metrics/project-sessions.ts @@ -1,6 +1,6 @@ -import type { AgentSession, CoreProjectReader } from "@oac/agents-client"; +import type { AgentSession } from "@oac/agents-client"; -import type { Owned } from "../../lib/projects"; +import type { Owned, ProjectClient } from "../../lib/projects"; import { type Project } from "../../lib/admin-view"; /** @@ -12,15 +12,13 @@ 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; export interface SessionRead { sessions: AgentSession[]; - /** Entries the client did not recognize; they are not counted anywhere. */ - unrecognized: number; /** False when the read stopped at `maxSessions` before the list ended or `enough` held. */ complete: boolean; } @@ -34,20 +32,16 @@ export interface SessionReadOptions { export async function readSessions(client: SessionLister, options: SessionReadOptions): Promise { const sessions: AgentSession[] = []; - let unrecognized = 0; let after: string | undefined; - let read = 0; - while (read < options.maxSessions) { - const limit = Math.min(SESSION_PAGE_SIZE, options.maxSessions - read); - const page = await client.listSessionsTolerant({ order: "desc", limit, after, signal: options.signal }); + while (sessions.length < options.maxSessions) { + const limit = Math.min(SESSION_PAGE_SIZE, options.maxSessions - sessions.length); + const page = await client.listSessions({ order: "desc", limit, after, signal: options.signal }); sessions.push(...page.data); - unrecognized += page.unrecognized.length; - read += page.data.length + page.unrecognized.length; - if (!page.has_more || !page.last_id || page.last_id === after) return { sessions, unrecognized, complete: true }; - if (options.enough?.(sessions)) return { sessions, unrecognized, complete: true }; + if (!page.has_more || !page.last_id || page.last_id === after) return { sessions, complete: true }; + if (options.enough?.(sessions)) return { sessions, complete: true }; after = page.last_id; } - return { sessions, unrecognized, complete: false }; + return { sessions, complete: false }; } export interface ProjectSessionRead extends SessionRead { diff --git a/apps/web/src/features/metrics/sandbox-runtime.ts b/apps/web/src/features/metrics/sandbox-runtime.ts index a9645d827..82883ec2a 100644 --- a/apps/web/src/features/metrics/sandbox-runtime.ts +++ b/apps/web/src/features/metrics/sandbox-runtime.ts @@ -1,5 +1,6 @@ -import type { AgentSession, CoreProjectReader, SandboxAllocation, SandboxNode } from "@oac/agents-client"; +import type { AgentSession, SandboxAllocation, SandboxNode } from "@oac/agents-client"; +import type { ProjectClient } from "../../lib/projects"; import type { RuntimeDashboardSnapshot } from "../dashboard/runtime-snapshot"; import { type OwnedRuntimeObservation } from "../../lib/admin-view"; @@ -14,7 +15,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/overview/OverviewPage.tsx b/apps/web/src/features/overview/OverviewPage.tsx index 4b459679c..e0e366b71 100644 --- a/apps/web/src/features/overview/OverviewPage.tsx +++ b/apps/web/src/features/overview/OverviewPage.tsx @@ -308,7 +308,6 @@ function MetricTile({ label, help, value, sub }: { label: string; help?: ReactNo } function fleetDetail(state: FleetState, t: TFunction<"overview">): string { - if (state.status === "unconfigured") return t("fleet.unconfigured"); if (state.status === "failed") return t("fleet.failed"); return t("fleet.loading"); } diff --git a/apps/web/src/features/overview/getting-started.ts b/apps/web/src/features/overview/getting-started.ts index 24d47382f..8a781e398 100644 --- a/apps/web/src/features/overview/getting-started.ts +++ b/apps/web/src/features/overview/getting-started.ts @@ -63,7 +63,7 @@ export function gettingStartedSteps(input: { */ function sandboxStep(fleet: FleetState): GettingStartedSteps["sandboxes"] { if (fleet.status !== "ready") { - return { state: fleet.status === "failed" || fleet.status === "unconfigured" ? "unknown" : null, action: "nodes", cloud: false }; + return { state: fleet.status === "failed" ? "unknown" : null, action: "nodes", cloud: false }; } if (fleet.error) return { state: "unknown", action: "nodes", cloud: fleet.snapshot.deployment.provider === "e2b" }; const { deployment, nodes } = fleet.snapshot; @@ -142,7 +142,7 @@ const INSTALLATION_KEY = "oac-web.last-installation"; */ export function checklistStorageKey(fleet: FleetState): string | null { const installation = fleet.status === "ready" ? fleet.snapshot.deployment.installation_id - : fleet.status === "failed" || fleet.status === "unconfigured" ? readStored(INSTALLATION_KEY) ?? "" + : fleet.status === "failed" ? readStored(INSTALLATION_KEY) ?? "" : null; if (installation === null) return null; return installation ? `${MEMORY_KEY}.${installation}` : MEMORY_KEY; diff --git a/apps/web/src/features/overview/overview-loader.test.ts b/apps/web/src/features/overview/overview-loader.test.ts index bcb002a91..c9c80a131 100644 --- a/apps/web/src/features/overview/overview-loader.test.ts +++ b/apps/web/src/features/overview/overview-loader.test.ts @@ -64,7 +64,7 @@ describe("loadOverview", () => { const good = sessionLister(hourly("g", 30)); const data = await loadOverview([project("good"), project("broken")], { summary: async () => { throw new Error("HTTP 500"); }, - sessions: (target) => (target.id === "good" ? good : { listSessionsTolerant: async () => { throw new Error("boom"); } }), + sessions: (target) => (target.id === "good" ? good : { listSessions: async () => { throw new Error("boom"); } }), }, NOW, new AbortController().signal); expect(data.summary.status).toBe("failed"); expect(data.sessions.sessions).toHaveLength(30); @@ -88,7 +88,7 @@ describe("Overview refresh retention", () => { summary: async () => projects.map(({ id }) => summary(id, { sessions: { total: 1, idle: 0, in_progress: 0, failed: 1, requires_action: 0 }, last_active_at: NOW })), sessions: ({ id }) => sessionLister([session(id, { status: "failed", created_at: NOW, last_active_at: NOW })]), }, NOW, controller.signal); - const unavailable = { listSessionsTolerant: async () => { throw new Error("unavailable"); } }; + const unavailable = { listSessions: async () => { throw new Error("unavailable"); } }; it("retains a failed summary and failed project's rows while replacing successful project reads", async () => { const prior = await original(); diff --git a/apps/web/src/features/overview/test-fixtures.ts b/apps/web/src/features/overview/test-fixtures.ts index 9b54d3e6f..96ed55aac 100644 --- a/apps/web/src/features/overview/test-fixtures.ts +++ b/apps/web/src/features/overview/test-fixtures.ts @@ -62,12 +62,12 @@ export function hostedObservation(sessionId: string, projectId: string, override export function sessionLister(sessions: readonly AgentSession[], calls: string[] = []) { return { calls, - async listSessionsTolerant(options?: { after?: string; limit?: number; signal?: AbortSignal }) { + async listSessions(options?: { after?: string; limit?: number; signal?: AbortSignal }) { options?.signal?.throwIfAborted(); const start = options?.after ? sessions.findIndex((entry) => entry.id === options.after) + 1 : 0; const data = sessions.slice(start, start + (options?.limit ?? 20)); calls.push(options?.after ?? "first"); - return { object: "list" as const, data, unrecognized: [], has_more: start + data.length < sessions.length, first_id: data[0]?.id ?? null, last_id: data.at(-1)?.id ?? null }; + return { object: "list" as const, data, has_more: start + data.length < sessions.length, first_id: data[0]?.id ?? null, last_id: data.at(-1)?.id ?? null }; }, }; } diff --git a/apps/web/src/features/sandbox/SandboxDeploymentPage.tsx b/apps/web/src/features/sandbox/SandboxDeploymentPage.tsx index e06289e53..64c4d616a 100644 --- a/apps/web/src/features/sandbox/SandboxDeploymentPage.tsx +++ b/apps/web/src/features/sandbox/SandboxDeploymentPage.tsx @@ -12,7 +12,6 @@ import { useFailureToast, useToast } from "../../components/Toast"; import { useConsoleNavigation } from "../../lib/console-navigation"; import { sandboxConfigurationRejection, sandboxRequestError, sandboxWriteUncertain } from "../../lib/sandbox-labels"; import { sandboxAdmin } from "./sandbox-queries"; -import { SandboxPageAccess } from "./SandboxPageAccess"; import { useSandboxPageState } from "./use-sandbox-page-state"; import { sandboxWriteOwnershipQuery } from "./sandbox-write-ownership"; import { writeSandboxDeployment } from "./sandbox-deployment-write"; @@ -36,7 +35,7 @@ function DeploymentHeader({ actions }: { actions?: ReactNode }) { export function SandboxDeploymentPage() { const { i18n } = useTranslation("sandbox"); return
- }>{() => } +
; } diff --git a/apps/web/src/features/sandbox/SandboxManagerView.tsx b/apps/web/src/features/sandbox/SandboxManagerView.tsx index 64a1dfcdb..00feda0eb 100644 --- a/apps/web/src/features/sandbox/SandboxManagerView.tsx +++ b/apps/web/src/features/sandbox/SandboxManagerView.tsx @@ -1,6 +1,6 @@ import { useCallback, useEffect, useRef, useState, type ReactNode, type RefObject } from "react"; import { type SandboxNode } from "@oac/agents-client"; -import { useQueryClient } from "@tanstack/react-query"; +import { useQuery, useQueryClient } from "@tanstack/react-query"; import { ArrowLeft, Pencil, Plus, Server, Trash2 } from "lucide-react"; import { useTranslation } from "react-i18next"; import { ConfirmDialog } from "../../components/ConfirmDialog"; @@ -12,9 +12,8 @@ import { InstallationNotice } from "../../components/InstallationNotice"; import { installationQuery } from "../../lib/installation"; import { sandboxRequestError } from "../../lib/sandbox-labels"; import type { SandboxConsoleConfig } from "./console-config"; -import { sandboxAdmin } from "./sandbox-queries"; +import { sandboxAdmin, sandboxConsoleConfigQuery } from "./sandbox-queries"; import { useSandboxPageState } from "./use-sandbox-page-state"; -import { SandboxPageAccess } from "./SandboxPageAccess"; import { NodeEnrollment } from "./NodeEnrollment"; import { NodeList, onOldAddress } from "./NodeList"; import { NodeDetail } from "./NodeDetail"; @@ -25,10 +24,16 @@ import "./SandboxManagerView.css"; /** Nodes owns node enrollment, the list and individual node management. */ export function SandboxManagerView() { - const { i18n } = useTranslation("sandbox"); + const { t, i18n } = useTranslation("sandbox"); const locale = i18n.resolvedLanguage?.startsWith("zh") ? "zh" : "en"; + const { data: config, isError, isFetching, refetch } = useQuery(sandboxConsoleConfigQuery); return
- }>{(config) => } + {config ? : <> + +
{isError + ? <>

{t("The console configuration could not be read. Refresh to try again.")}

+ :

{t("Connecting to this console's Core…")}

}
+ }
; } diff --git a/apps/web/src/features/sandbox/SandboxPageAccess.tsx b/apps/web/src/features/sandbox/SandboxPageAccess.tsx deleted file mode 100644 index 277d8867b..000000000 --- a/apps/web/src/features/sandbox/SandboxPageAccess.tsx +++ /dev/null @@ -1,15 +0,0 @@ -import type { ReactNode } from "react"; -import { useQuery } from "@tanstack/react-query"; -import { useTranslation } from "react-i18next"; -import type { SandboxConsoleConfig } from "./console-config"; -import { sandboxConsoleConfigQuery } from "./sandbox-queries"; - -/** Both sandbox pages use the same console capability gate. */ -export function SandboxPageAccess({ header, children }: { header: ReactNode; children: (config: SandboxConsoleConfig) => ReactNode }) { - const { t } = useTranslation("sandbox"); - const { data: config, isPending: checking, isFetching, isError, refetch } = useQuery(sandboxConsoleConfigQuery); - if (isError && config === undefined) return <>{header}

{t("The console configuration could not be read. Refresh to try again.")}

; - if (checking) return <>{header}

{t("Connecting to this console's Core…")}

; - if (!config?.sandbox_admin) return <>{header}

{t("Sandbox administration is not configured on this console.")}

; - return children(config); -} diff --git a/apps/web/src/features/sandbox/console-config.test.ts b/apps/web/src/features/sandbox/console-config.test.ts index d95d292cb..23834faba 100644 --- a/apps/web/src/features/sandbox/console-config.test.ts +++ b/apps/web/src/features/sandbox/console-config.test.ts @@ -4,45 +4,33 @@ import { nodeFilesAvailable, sandboxConsoleConfig } from "./console-config"; afterEach(() => vi.unstubAllGlobals()); describe("bundled console capabilities", () => { it("uses the existing console login without sending a project or admin bearer", async () => { - const fetch = vi.fn().mockResolvedValue(new Response(JSON.stringify({ node_installer: true, node_installer_sha256: "a".repeat(64) }))); + const fetch = vi.fn().mockResolvedValue(new Response(JSON.stringify({ node_installer: true, node_installer_sha256: "a".repeat(64), node_artifacts: ["docker"] }))); vi.stubGlobal("fetch", fetch); const controller = new AbortController(); - expect(await sandboxConsoleConfig(controller.signal)).toEqual({ sandbox_admin: true, node_installer: true, node_installer_sha256: "a".repeat(64) }); + expect(await sandboxConsoleConfig(controller.signal)).toEqual({ node_installer: true, node_installer_sha256: "a".repeat(64), node_artifacts: ["docker"] }); expect(fetch).toHaveBeenCalledWith("/console/config", { credentials: "include", signal: controller.signal }); }); - it.each([{}, { sandbox_admin: "true", node_installer: true }, { sandbox_admin: false, node_installer: true, node_installer_sha256: "bad" }])("does not enable installation without a verified digest %j", async (body) => { + it.each([{}, { node_installer: "true", node_installer_sha256: "a".repeat(64) }, { node_installer: true, node_installer_sha256: "bad" }])("does not enable installation without a verified digest %j", async (body) => { vi.stubGlobal("fetch", vi.fn().mockResolvedValue(new Response(JSON.stringify(body)))); - expect((await sandboxConsoleConfig(new AbortController().signal))?.node_installer).toBe(false); + expect((await sandboxConsoleConfig(new AbortController().signal)).node_installer).toBe(false); }); - it("reports unavailable capability on an absent console endpoint", async () => { - vi.stubGlobal("fetch", vi.fn().mockResolvedValue(new Response("Not found", { status: 404 }))); - expect(await sandboxConsoleConfig(new AbortController().signal)).toBeNull(); - }); - it("reports a failed read as a failure, not as an unconfigured console", async () => { - vi.stubGlobal("fetch", vi.fn().mockResolvedValue(new Response("Bad gateway", { status: 502 }))); + it.each([404, 502])("reports a failed read (HTTP %i) as a failure", async (status) => { + vi.stubGlobal("fetch", vi.fn().mockResolvedValue(new Response("Failed", { status }))); await expect(sandboxConsoleConfig(new AbortController().signal)).rejects.toThrow(); }); - it("blocks a provider's command only when the console reports no node files for it", async () => { + it("blocks a provider's command when the console reports no node files for it", async () => { const read = async (body: object) => { vi.stubGlobal("fetch", vi.fn().mockResolvedValue(new Response(JSON.stringify({ node_installer: true, node_installer_sha256: "a".repeat(64), ...body })))); - return (await sandboxConsoleConfig(new AbortController().signal))!; + return sandboxConsoleConfig(new AbortController().signal); }; - // An older console doesn't report them: nothing is blocked. - const older = await read({}); - expect(older.node_artifacts).toBeUndefined(); - expect(nodeFilesAvailable(older, "docker")).toBe(true); const docker = await read({ node_artifacts: ["docker"] }); expect(nodeFilesAvailable(docker, "docker")).toBe(true); expect(nodeFilesAvailable(docker, "microsandbox")).toBe(false); - // null, like any malformed value, reports none. - for (const node_artifacts of [null, "docker", { docker: true }]) { - const config = await read({ node_artifacts }); + // An absent, null or malformed value reports none. + for (const body of [{}, { node_artifacts: null }, { node_artifacts: "docker" }, { node_artifacts: { docker: true } }]) { + const config = await read(body); expect(config.node_artifacts).toEqual([]); expect(nodeFilesAvailable(config, "docker")).toBe(false); } }); - it("disables sandbox administration only when the console says so", async () => { - vi.stubGlobal("fetch", vi.fn().mockResolvedValue(new Response(JSON.stringify({ sandbox_admin: false })))); - expect((await sandboxConsoleConfig(new AbortController().signal))?.sandbox_admin).toBe(false); - }); }); diff --git a/apps/web/src/features/sandbox/console-config.ts b/apps/web/src/features/sandbox/console-config.ts index ed139bbe5..cf9188f5f 100644 --- a/apps/web/src/features/sandbox/console-config.ts +++ b/apps/web/src/features/sandbox/console-config.ts @@ -2,39 +2,27 @@ export type NodeArtifactProvider = "docker" | "microsandbox"; export interface SandboxConsoleConfig { - sandbox_admin: boolean; node_installer: boolean; node_installer_sha256: string; - /** - * The providers whose node files this console serves. Absent when the console - * does not report them (an older console), which blocks nothing; a reported - * null or malformed value reads as none. - */ - node_artifacts?: NodeArtifactProvider[]; + /** The providers whose node files this console serves; a null or malformed value reads as none. */ + node_artifacts: NodeArtifactProvider[]; } const SHA256 = /^[a-f0-9]{64}$/; /** - * The console's capability flags. Signing in with the Core key grants - * administration, so Core reports only its node installer and digest and the providers it has node files for; - * sandbox administration is available - * unless the console says `sandbox_admin: false`. An installer is offered only - * with a well-formed SHA-256 digest. - * An absent endpoint (404, an older console) means no sandbox administration; - * any other failure is thrown so callers report a failed read instead of - * "not configured". + * The console's node installer, its digest and the providers it has node + * files for. An installer is offered only with a well-formed SHA-256 digest. + * A failed read is thrown so callers report it. */ -export async function sandboxConsoleConfig(signal: AbortSignal): Promise { +export async function sandboxConsoleConfig(signal: AbortSignal): Promise { const response = await fetch("/console/config", { credentials: "include", signal }); - if (response.status === 404) return null; if (!response.ok) throw new Error(`The console configuration could not be read (HTTP ${response.status}).`); const config = await response.json() as Partial> & { node_artifacts?: unknown }; return { - sandbox_admin: config.sandbox_admin !== false, node_installer: config.node_installer === true && SHA256.test(config.node_installer_sha256 ?? ""), node_installer_sha256: config.node_installer_sha256 ?? "", - ...(config.node_artifacts === undefined ? {} : { node_artifacts: nodeArtifacts(config.node_artifacts) }), + node_artifacts: nodeArtifacts(config.node_artifacts), }; } @@ -43,7 +31,7 @@ function nodeArtifacts(value: unknown): NodeArtifactProvider[] { return Array.isArray(value) ? value.filter((entry): entry is NodeArtifactProvider => entry === "docker" || entry === "microsandbox") : []; } -/** Whether a node of this provider can install from the console's files; true when the console doesn't report them. */ +/** Whether a node of this provider can install from the console's files. */ export function nodeFilesAvailable(config: SandboxConsoleConfig, provider: string): boolean { - return config.node_artifacts === undefined || config.node_artifacts.some((entry) => entry === provider); + return config.node_artifacts.some((entry) => entry === provider); } diff --git a/apps/web/src/features/sandbox/sandbox-page-ownership.test.tsx b/apps/web/src/features/sandbox/sandbox-page-ownership.test.tsx index be052a149..db2fc3937 100644 --- a/apps/web/src/features/sandbox/sandbox-page-ownership.test.tsx +++ b/apps/web/src/features/sandbox/sandbox-page-ownership.test.tsx @@ -21,7 +21,7 @@ function cache(provider: SandboxDeployment["provider"]) { mode: provider === "e2b" ? "direct" : "nodes", reset: null, resources: { allocations: 0, pending: 0 }, suspension: null, rollout: { state: "settled", previous_generation_sandboxes: 0, nodes: null }, }; - const config: SandboxConsoleConfig = { sandbox_admin: true, node_installer: false, node_installer_sha256: "" }; + const config: SandboxConsoleConfig = { node_installer: false, node_installer_sha256: "", node_artifacts: [] }; client.setQueryData(sandboxConsoleConfigQuery.queryKey, () => config); client.setQueryData(sandboxDeploymentQuery.queryKey, deployment); client.setQueryData(sandboxSnapshotQuery.queryKey, { deployment, nodes: [], allocations: [], nodesError: null, readAt: 0 }); diff --git a/apps/web/src/features/sandbox/sandbox-queries.ts b/apps/web/src/features/sandbox/sandbox-queries.ts index 9b3c8f766..00266196a 100644 --- a/apps/web/src/features/sandbox/sandbox-queries.ts +++ b/apps/web/src/features/sandbox/sandbox-queries.ts @@ -13,7 +13,7 @@ export const sandboxAdmin = new SandboxAdminClient({ baseUrl: "/core/v1/sandbox" export const sandboxScope = ["sandbox"] as const; -/** Whether this console may administer sandboxes, and its node installer. */ +/** This console's node installer and node files. */ export const sandboxConsoleConfigQuery = queryOptions({ queryKey: ["console-config"], queryFn: async ({ signal }) => { @@ -45,13 +45,11 @@ export function sandboxResetPollInterval(deployment: SandboxDeployment | undefin /** * The deployment's sandbox provider from the cached deployment read: "" before - * setup, null while unknown or when this console has no sandbox administration. - * Pages that differ for E2B (no machines) and own nodes read it. + * setup, null while unknown. Pages that differ for E2B (no machines) and own + * nodes read it. */ export function useSandboxProvider(): SandboxProvider | "" | null { - const config = useQuery(sandboxConsoleConfigQuery); - const deployment = useQuery({ ...sandboxDeploymentQuery, enabled: config.data?.sandbox_admin === true }); - return deployment.data?.provider ?? null; + return useQuery(sandboxDeploymentQuery).data?.provider ?? null; } export interface SandboxSnapshot { diff --git a/apps/web/src/features/sessions/SessionLogPage.tsx b/apps/web/src/features/sessions/SessionLogPage.tsx index 2038e2b64..53cf494e0 100644 --- a/apps/web/src/features/sessions/SessionLogPage.tsx +++ b/apps/web/src/features/sessions/SessionLogPage.tsx @@ -1,10 +1,11 @@ +import type { AgentSession } from "@oac/agents-client"; import { MessageSquareText } from "lucide-react"; import { useEffect, useMemo, useState } from "react"; import { useTranslation } from "react-i18next"; import { ReadFailure } from "../../components/ReadFailure"; import { useFailureToast } from "../../components/Toast"; -import { EmptyState, HelpTip, PageBody, PageHeader, RefreshButton, SegmentedControl } from "../../components/console-ui"; +import { EmptyState, PageBody, PageHeader, RefreshButton, SegmentedControl } from "../../components/console-ui"; import { ListToolbar, listSummary, NameCell, RowActions, SearchField } from "../../components/list-ui"; import { useConsoleIntent, useConsoleNavigation } from "../../lib/console-navigation"; import { formatClock, formatCompact, formatDateTime, formatInteger, formatRelative, MISSING } from "../../lib/format"; @@ -19,10 +20,8 @@ import { initialSessionLogFilters, isDeletable, isLogTruncated, - readSessionLog, sessionStatuses, statusCounts, - type SessionLogEntry, type SessionLogFilters, } from "./session-log"; import "./sessions.css"; @@ -39,8 +38,8 @@ const PAGE_SIZE = 50; /** Filters survive a visit to a Session and back within the same page load. */ let remembered: { project: ProjectFilterValue; filters: SessionLogFilters } = { project: "", filters: initialSessionLogFilters }; -function rowKey(row: Owned): string { - return `${row.project.id}:${row.value.kind === "session" ? row.value.session.id : row.value.key}`; +function rowKey(row: Owned): string { + return `${row.project.id}:${row.value.id}`; } /** Monitor › Session log: every Session of one project or of all projects, read-only with deletion of idle ones. */ @@ -81,7 +80,7 @@ export function SessionLogPage() { const counts = useMemo(() => statusCounts(rows, filters), [filters, rows]); const agents = useMemo(() => agentOptions(rows, t("common.untitledAgent")), [rows, t]); const visible = useMemo(() => filtered.slice(0, limit), [filtered, limit]); - const creatorRows = useMemo(() => visible.flatMap((row) => (row.value.kind === "session" ? [{ projectId: row.project.id, id: row.value.session.id }] : [])), [visible]); + const creatorRows = useMemo(() => visible.map((row) => ({ projectId: row.project.id, id: row.value.id })), [visible]); const creators = useCreators("session", creatorRows); const allProjects = selected === ""; const loading = collection.status === "loading" || (projects.status === "loading" && !projects.projects.length); @@ -233,7 +232,7 @@ function SessionLogRow({ onOpen, onDelete, }: { - row: Owned; + row: Owned; allProjects: boolean; creators: Creators; now: number; @@ -243,38 +242,7 @@ function SessionLogRow({ }) { const { t } = useTranslation("sessions"); const projectCell = allProjects ? : null; - const entry = row.value; - - if (entry.kind === "unrecognized") { - const name = ( - - {t("log.unrecognized")} - {t("log.unrecognizedHelp")} - - ); - return ( - - {entry.id ? {t("log.unrecognizedHelp")} : {name}} - {projectCell} - {MISSING} - {MISSING} - {MISSING} - {MISSING} - {MISSING} - {MISSING} - {MISSING} - - {entry.id ? ( - - - - ) : null} - - - ); - } - - const session = entry.session; + const session = row.value; const open = () => onOpen(row.project.id, session.id); return ( diff --git a/apps/web/src/features/sessions/session-history.ts b/apps/web/src/features/sessions/session-history.ts index 07a1a788e..ec41ec12c 100644 --- a/apps/web/src/features/sessions/session-history.ts +++ b/apps/web/src/features/sessions/session-history.ts @@ -2,10 +2,11 @@ import type { AgentSession, AgentTurn, ListPage, - CoreProjectReader, SessionItem, } from "@oac/agents-client"; +import type { ProjectClient } from "../../lib/projects"; + /** * Read-only Session history for the administrator. The Web API offers no event * stream, so the page polls the history endpoints while the Session has work in @@ -87,7 +88,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.test.ts b/apps/web/src/features/sessions/session-log.test.ts index d1949a1ad..bd2b6abbe 100644 --- a/apps/web/src/features/sessions/session-log.test.ts +++ b/apps/web/src/features/sessions/session-log.test.ts @@ -1,6 +1,6 @@ import { describe, expect, it } from "vitest"; -import type { AgentSession, TolerantSessionList } from "@oac/agents-client"; +import type { AgentSession, ListPage } from "@oac/agents-client"; import type { Owned } from "../../lib/projects"; import { type Project } from "../../lib/admin-view"; @@ -13,12 +13,11 @@ import { isLogTruncated, readSessionLog, statusCounts, - type SessionLogEntry, type SessionLogFilters, } from "./session-log"; function project(id: string, name: string): Project { - return { id, name, source: "console", status: "active", created_at: 1, archived_at: null, active_key_count: 1 } as Project; + return { id, name, status: "active", created_at: 1, archived_at: null, active_key_count: 1 }; } const production = project("proj_prod", "Production"); @@ -53,27 +52,26 @@ function session(id: string, overrides: Partial & { agentId?: stri }; } -function row(owner: Project, value: AgentSession): Owned { - return { project: owner, value: { kind: "session", session: value } }; +function row(owner: Project, value: AgentSession): Owned { + return { project: owner, value }; } -function ids(rows: readonly Owned[]): string[] { - return rows.map((entry) => (entry.value.kind === "session" ? entry.value.session.id : `?${entry.value.id ?? entry.value.key}`)); +function ids(rows: readonly Owned[]): string[] { + return rows.map((entry) => entry.value.id); } const filters = (patch: Partial = {}): SessionLogFilters => ({ ...initialSessionLogFilters, ...patch }); describe("Session log across projects", () => { - const rows: Owned[] = [ + const rows: Owned[] = [ row(production, session("s1", { status: "failed", error: "Sandbox allocation failed", last_active_at: 30 })), row(production, session("s2", { status: "in_progress", last_active_at: 50, environment: { type: "openai_hosted" } as AgentSession["environment"] })), row(data, session("s3", { status: "idle", last_active_at: 40, agentId: "agent_b", agentName: "Analyst" })), row(data, session("s4", { status: "requires_action", last_active_at: 50, agentId: "agent_b", agentName: "Analyst" })), - { project: data, value: { kind: "unrecognized", id: "5f0c0e0e-0000-4000-8000-000000000000", key: "5f0c0e0e-0000-4000-8000-000000000000" } }, ]; - it("merges every project's Sessions by most recent activity, ties by ID, unrecognized last", () => { - expect(ids(filterSessionLog(rows, filters()))).toEqual(["s2", "s4", "s3", "s1", "?5f0c0e0e-0000-4000-8000-000000000000"]); + it("merges every project's Sessions by most recent activity, ties by ID", () => { + expect(ids(filterSessionLog(rows, filters()))).toEqual(["s2", "s4", "s3", "s1"]); }); it("filters by status, Agent, environment and search", () => { @@ -82,13 +80,11 @@ describe("Session log across projects", () => { expect(ids(filterSessionLog(rows, filters({ environment: "openai_hosted" })))).toEqual(["s2"]); expect(ids(filterSessionLog(rows, filters({ query: "ALLOCATION" })))).toEqual(["s1"]); expect(ids(filterSessionLog(rows, filters({ query: "analyst" })))).toEqual(["s4", "s3"]); - // An unrecognized entry has no status, Agent or environment; only its ID can match. - expect(ids(filterSessionLog(rows, filters({ query: "5f0c0e0e" })))).toEqual(["?5f0c0e0e-0000-4000-8000-000000000000"]); expect(ids(filterSessionLog(rows, filters({ status: "idle" })))).toEqual(["s3"]); }); it("counts statuses for the rows the other filters keep", () => { - expect(statusCounts(rows, filters())).toEqual({ all: 5, in_progress: 1, requires_action: 1, failed: 1, idle: 1 }); + expect(statusCounts(rows, filters())).toEqual({ all: 4, in_progress: 1, requires_action: 1, failed: 1, idle: 1 }); expect(statusCounts(rows, filters({ agentId: "agent_b", status: "failed" }))).toEqual({ all: 2, in_progress: 0, requires_action: 1, failed: 0, idle: 1 }); }); @@ -121,25 +117,22 @@ describe("Session log across projects", () => { }); describe("Reading a project's Session log", () => { - function page(data: AgentSession[], unrecognized: TolerantSessionList["unrecognized"], hasMore: boolean, lastId: string | null): TolerantSessionList { - return { object: "list", data, unrecognized, has_more: hasMore, first_id: data[0]?.id ?? null, last_id: lastId }; + function page(data: AgentSession[], hasMore: boolean): ListPage { + return { object: "list", data, has_more: hasMore, first_id: data[0]?.id ?? null, last_id: data.at(-1)?.id ?? null }; } - it("walks every page and lists unreadable entries without failing", async () => { + it("walks every page", async () => { const calls: Array = []; - const pages = [ - page([session("a"), session("b")], [{ index: 2, id: null }], true, "c"), - page([session("d")], [{ index: 0, id: "e" }], false, "d"), - ]; - const client = { listSessionsTolerant: async (options?: { after?: string }) => { calls.push(options?.after); return pages[calls.length - 1]!; } }; + const pages = [page([session("a"), session("b")], true), page([session("d")], false)]; + const client = { listSessions: async (options?: { after?: string }) => { calls.push(options?.after); return pages[calls.length - 1]!; } }; const entries = await readSessionLog(client); - expect(calls).toEqual([undefined, "c"]); - expect(entries.map((entry) => (entry.kind === "session" ? entry.session.id : `?${entry.key}`))).toEqual(["a", "b", "?0:2", "d", "?e"]); + expect(calls).toEqual([undefined, "b"]); + expect(entries.map((entry) => entry.id)).toEqual(["a", "b", "d"]); }); it("stops at the read bound and reports it", async () => { let calls = 0; - const client = { listSessionsTolerant: async () => { calls += 1; return page([session(`s${calls}a`), session(`s${calls}b`)], [], true, `s${calls}b`); } }; + const client = { listSessions: async () => { calls += 1; return page([session(`s${calls}a`), session(`s${calls}b`)], true); } }; const entries = await readSessionLog(client, undefined, 3); expect(entries).toHaveLength(3); expect(calls).toBe(2); diff --git a/apps/web/src/features/sessions/session-log.ts b/apps/web/src/features/sessions/session-log.ts index ff8c00360..b859265da 100644 --- a/apps/web/src/features/sessions/session-log.ts +++ b/apps/web/src/features/sessions/session-log.ts @@ -1,11 +1,10 @@ -import type { AgentSession, CoreProjectReader } from "@oac/agents-client"; +import type { AgentSession } from "@oac/agents-client"; -import type { Owned } from "../../lib/projects"; +import type { Owned, ProjectClient } from "../../lib/projects"; /** - * Session log model: every Session of one project or of all projects, read - * tolerantly so one malformed Session is listed as unrecognized instead of - * failing the page, then filtered and ordered in the browser. + * Session log model: every Session of one project or of all projects, + * filtered and ordered in the browser. */ export const SESSION_LOG_LIMIT = 10_000; @@ -18,11 +17,6 @@ export type StatusFilter = "all" | SessionStatusKey; export const environmentKinds = ["openai_hosted", "self_hosted", "none"] as const; export type EnvironmentKind = (typeof environmentKinds)[number] | "other"; -export type SessionLogEntry = - | { kind: "session"; session: AgentSession } - /** A listed entry this console cannot read; only its Session ID (when it has one) is kept. */ - | { kind: "unrecognized"; id: string | null; key: string }; - export interface SessionLogFilters { status: StatusFilter; agentId: string; @@ -32,20 +26,17 @@ export interface SessionLogFilters { export const initialSessionLogFilters: SessionLogFilters = { status: "all", agentId: "", environment: "", query: "" }; -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 { - const entries: SessionLogEntry[] = []; +/** Walks every Session page of one project, newest first, bounded at `limit` Sessions. */ +export async function readSessionLog(client: Pick, signal?: AbortSignal, limit = SESSION_LOG_LIMIT): Promise { + const sessions: AgentSession[] = []; let after: string | undefined; - for (let page = 0; entries.length < limit; page += 1) { - const result = await client.listSessionsTolerant({ after, limit: SESSION_PAGE_SIZE, order: "desc", signal }); - for (const session of result.data) entries.push({ kind: "session", session }); - for (const entry of result.unrecognized) entries.push({ kind: "unrecognized", id: entry.id, key: entry.id ?? `${page}:${entry.index}` }); + while (sessions.length < limit) { + const result = await client.listSessions({ after, limit: SESSION_PAGE_SIZE, order: "desc", signal }); + sessions.push(...result.data); if (!result.has_more || !result.last_id || result.last_id === after) break; after = result.last_id; } - return entries.slice(0, limit); + return sessions.slice(0, limit); } export function environmentKind(session: AgentSession): EnvironmentKind { @@ -79,40 +70,18 @@ function matches(session: AgentSession, filters: SessionLogFilters, query: strin && matchesQuery(session, query); } -function neutral(filters: SessionLogFilters): boolean { - return filters.status === "all" && !filters.agentId && !filters.environment; -} - -/** - * Rows of every selected project merged into one list: most recent activity first - * (ties by Session ID), unrecognized entries last. Unrecognized entries carry no - * status, Agent or environment, so any such filter hides them; search matches their ID. - */ -export function filterSessionLog(rows: readonly Owned[], filters: SessionLogFilters): Owned[] { +/** Rows of every selected project merged into one list: most recent activity first, ties by Session ID. */ +export function filterSessionLog(rows: readonly Owned[], filters: SessionLogFilters): Owned[] { const query = filters.query.trim().toLowerCase(); - const sessions: Array & { value: { kind: "session" } }> = []; - const unrecognized: Owned[] = []; - for (const row of rows) { - if (row.value.kind === "session") { - if (matches(row.value.session, filters, query)) sessions.push(row as Owned & { value: { kind: "session" } }); - } else if (neutral(filters) && (!query || (row.value.id ?? "").toLowerCase().includes(query))) { - unrecognized.push(row); - } - } - sessions.sort((a, b) => b.value.session.last_active_at - a.value.session.last_active_at || a.value.session.id.localeCompare(b.value.session.id)); - return [...sessions, ...unrecognized]; + return rows.filter((row) => matches(row.value, filters, query)) + .sort((a, b) => b.value.last_active_at - a.value.last_active_at || a.value.id.localeCompare(b.value.id)); } /** Counts per status for the rows the other filters (search, Agent, environment) keep. */ -export function statusCounts(rows: readonly Owned[], filters: SessionLogFilters): Record { +export function statusCounts(rows: readonly Owned[], filters: SessionLogFilters): Record { const query = filters.query.trim().toLowerCase(); const counts: Record = { all: 0, in_progress: 0, requires_action: 0, failed: 0, idle: 0 }; - for (const row of rows) { - if (row.value.kind !== "session") { - if (neutral({ ...filters, status: "all" }) && (!query || (row.value.id ?? "").toLowerCase().includes(query))) counts.all += 1; - continue; - } - const session = row.value.session; + for (const { value: session } of rows) { if (!matches(session, filters, query, true)) continue; counts.all += 1; const key = statusKey(session.status); @@ -131,11 +100,10 @@ export interface AgentOption { * project is shown, a name used by Agents of different projects carries the * project name. */ -export function agentOptions(rows: readonly Owned[], fallback: string): AgentOption[] { +export function agentOptions(rows: readonly Owned[], fallback: string): AgentOption[] { const agents = new Map(); for (const row of rows) { - if (row.value.kind !== "session") continue; - const agent = row.value.session.agent; + const agent = row.value.agent; if (!agents.has(agent.id)) agents.set(agent.id, { name: agent.name?.trim() || fallback, project: row.project.name }); } const names = new Map>(); @@ -146,7 +114,7 @@ export function agentOptions(rows: readonly Owned[], fallback: } /** True when some project hit the read bound, so more Sessions exist than are shown. */ -export function isLogTruncated(rows: readonly Owned[], limit = SESSION_LOG_LIMIT): boolean { +export function isLogTruncated(rows: readonly Owned[], limit = SESSION_LOG_LIMIT): boolean { const perProject = new Map(); for (const row of rows) perProject.set(row.project.id, (perProject.get(row.project.id) ?? 0) + 1); return [...perProject.values()].some((count) => count >= limit); diff --git a/apps/web/src/features/sessions/session-runtime.ts b/apps/web/src/features/sessions/session-runtime.ts index 250bd63ba..09a3b57bf 100644 --- a/apps/web/src/features/sessions/session-runtime.ts +++ b/apps/web/src/features/sessions/session-runtime.ts @@ -1,5 +1,6 @@ -import { AgentCoreError, type AgentSession, type CoreProjectReader } from "@oac/agents-client"; +import { AgentCoreError, type AgentSession } from "@oac/agents-client"; +import type { ProjectClient } from "../../lib/projects"; import { RUNTIME_DURABLE_MAX_POINTS, RUNTIME_DURABLE_RANGES, runtimeDurableTrendSamples, type RuntimeDurableRange } from "../dashboard/runtime-history"; import type { RuntimeTrendSample } from "../dashboard/runtime-trends"; @@ -24,7 +25,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 19b33cf65..735538e08 100644 --- a/apps/web/src/features/skills/skill-operations.test.ts +++ b/apps/web/src/features/skills/skill-operations.test.ts @@ -1,7 +1,8 @@ import { describe, expect, it, vi } from "vitest"; -import { AgentCoreError, type CoreProjectReader, type Skill, type SkillList, type SkillVersion, type SkillVersionList } from "@oac/agents-client"; +import { AgentCoreError, type Skill, type SkillList, type SkillVersion, type SkillVersionList } from "@oac/agents-client"; +import type { ProjectClient } from "../../lib/projects"; import i18n from "../../i18n"; import { downloadSkillArchive, @@ -94,7 +95,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 5d1c00cf6..5bb0fa45d 100644 --- a/apps/web/src/features/skills/skill-operations.ts +++ b/apps/web/src/features/skills/skill-operations.ts @@ -1,5 +1,6 @@ -import { AgentCoreError, type CoreProjectReader, type Skill, type SkillVersion } from "@oac/agents-client"; +import { AgentCoreError, type Skill, type SkillVersion } from "@oac/agents-client"; +import type { ProjectClient } from "../../lib/projects"; import { appendCollectionPage } from "../../lib/collection-pagination"; /** @@ -100,7 +101,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, @@ -118,7 +119,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, @@ -129,7 +130,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 fe2054743..71f88428a 100644 --- a/apps/web/src/features/vaults/vault-catalog.test.ts +++ b/apps/web/src/features/vaults/vault-catalog.test.ts @@ -1,7 +1,8 @@ import { describe, expect, it, vi } from "vitest"; -import type { CoreProjectReader, SavedAgent, Vault, VaultCredential } from "@oac/agents-client"; +import type { SavedAgent, Vault, VaultCredential } from "@oac/agents-client"; +import type { ProjectClient } from "../../lib/projects"; import { deriveSessionVaultPlan, loadVaultCatalog, matchingCredentials, type VaultCatalog } from "./vault-catalog"; const vaultA: Vault = { id: "11111111-1111-4111-8111-111111111111", object: "vault", created_at: 2, name: "A", metadata: {} }; @@ -65,7 +66,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 CoreProjectReader; + const core = { listVaults, listVaultCredentials } as unknown as ProjectClient; 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 4170ea55b..97bc3cf4c 100644 --- a/apps/web/src/features/vaults/vault-catalog.ts +++ b/apps/web/src/features/vaults/vault-catalog.ts @@ -1,10 +1,10 @@ import type { - CoreProjectReader, SavedAgent, Vault, VaultCredential, } from "@oac/agents-client"; +import type { ProjectClient } from "../../lib/projects"; import { listAllCollectionPages } from "../../lib/collection-pagination"; import i18n from "../../i18n"; @@ -40,7 +40,7 @@ export interface SessionVaultPlan { const CREDENTIAL_READ_CONCURRENCY = 4; -export async function loadVaultCatalog(core: Pick, 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/agents.ts b/apps/web/src/i18n/locales/en/agents.ts index ae5cb5630..53223f27f 100644 --- a/apps/web/src/i18n/locales/en/agents.ts +++ b/apps/web/src/i18n/locales/en/agents.ts @@ -81,7 +81,7 @@ export const agents = { title: "Usage", rangeLabel: "Sessions created", ranges: { all: "All time", "7d": "Last 7 days", "30d": "Last 30 days" }, help: "Usage is each Session's cumulative total as reported by Core; it is not split by day. A Session belongs to the range in which it was created, and all of its usage counts there.", caveat: "Data comes from cumulative Session usage reported by Core. It excludes unreported usage and is not a basis for billing.", - reading: "Reading Sessions… {{formattedCount}} read", unrecognized_one: "{{formattedCount}} Session not recognized", unrecognized_other: "{{formattedCount}} Sessions not recognized", unrecognizedUpTo_one: "Up to {{formattedCount}} Session not recognized", unrecognizedUpTo_other: "Up to {{formattedCount}} Sessions not recognized", unrecognizedHelp: "Core returned these Sessions in a shape this console cannot read. They are left out of every total rather than counted as zero. For a time range their creation time is unknown, so the count is an upper bound.", cancel: "Cancel", cancelled_one: "Stopped after reading {{formattedCount}} Session.", cancelled_other: "Stopped after reading {{formattedCount}} Sessions.", continue: "Continue", failed: "Couldn’t read Sessions.", retry: "Retry", + reading: "Reading Sessions… {{formattedCount}} read", cancel: "Cancel", cancelled_one: "Stopped after reading {{formattedCount}} Session.", cancelled_other: "Stopped after reading {{formattedCount}} Sessions.", continue: "Continue", failed: "Couldn’t read Sessions.", retry: "Retry", large: "More than {{formattedCount}} Sessions: statistics may be slow. Try a shorter time range first.", largeShortest: "More than {{formattedCount}} Sessions: statistics may be slow.", sessions: "Sessions", tokens: "Tokens", coverage: "Coverage", lastActive: "Last active", noData: "No data", cardLabel: "Usage of {{name}}", coverageHelp: "Sessions whose usage Core reported, out of all Sessions in the range. Sessions without reported usage are left out of token totals, not counted as zero.", diff --git a/apps/web/src/i18n/locales/en/common.ts b/apps/web/src/i18n/locales/en/common.ts index edb2758c3..5cc163979 100644 --- a/apps/web/src/i18n/locales/en/common.ts +++ b/apps/web/src/i18n/locales/en/common.ts @@ -47,10 +47,8 @@ export const common = { column: "Creator", help: "The API key that created this asset, as recorded by Core for every write.", unknown: "Unknown", - unknownHelp: "Core has no creation record: the asset predates recording or an administrator copied it.", + unknownHelp: "Core has no creation record: the asset predates recording.", revoked: "Revoked", - adminCopy: "Admin copy", - adminCopyHelp: "An administrator copied this asset from another project.", }, list: { search: "Search", diff --git a/apps/web/src/i18n/locales/en/keys.ts b/apps/web/src/i18n/locales/en/keys.ts index e61ba4802..7a7faf8d1 100644 --- a/apps/web/src/i18n/locales/en/keys.ts +++ b/apps/web/src/i18n/locales/en/keys.ts @@ -173,7 +173,7 @@ export const keys = { }, operations: { title: "Write operations", - help: "Every successful write in this project, with the key that made it, recorded by Core, newest first. Reads are not recorded; request bodies and secrets are never stored. Unknown: an administrator copy or no recorded key.", + help: "Every successful write in this project, with the key that made it, recorded by Core, newest first. Reads are not recorded; request bodies and secrets are never stored. Unknown: no recorded key.", filterLabel: "Filter write operations", allTypes: "All resources", allKeys: "All keys", diff --git a/apps/web/src/i18n/locales/en/metrics.ts b/apps/web/src/i18n/locales/en/metrics.ts index dce21cf33..427b768f2 100644 --- a/apps/web/src/i18n/locales/en/metrics.ts +++ b/apps/web/src/i18n/locales/en/metrics.ts @@ -23,7 +23,6 @@ export const metrics = { method: "Figures are aggregated in the browser from each project's Session, Turn and Item lists. A request is one root Agent Turn; duration runs from start to finish. Subagent Turns and deleted Sessions are not counted.", summary: "Aggregated in the browser from {{sessions}} Sessions active in the last {{range}}. A request is one Agent Turn.", listTruncated: "{{names}} have more Sessions than the console reads; only the newest were considered.", - unrecognized: "{{count}} listed Sessions could not be recognized and are not counted.", skipped: "{{total}} Sessions were active; only the {{loaded}} most recently active were read.", truncated: "{{count}} Sessions have more history than the read limit, so their earliest Turns in the range are missing.", failed: "{{count}} Sessions could not be read.", @@ -101,7 +100,7 @@ export const metrics = { coverage: "Coverage", lastActive: "Last active", unknown: "Unknown", - unknownHelp: "Sessions without a creation record: created before recording started or copied by an administrator.", + unknownHelp: "Sessions without a creation record: created before recording started.", revoked: "Revoked", coverageDetail: "{{reported}} of {{total}} Sessions reported usage", }, @@ -210,7 +209,6 @@ export const metrics = { openSession: "Open Session", noSession: "The Session of this sandbox could not be read, so its history cannot be shown.", }, - fleetUnconfigured: "This console has no sandbox administration.", fleetFailed: "Hosts could not be loaded.", fleetLoading: "Loading hosts…", kpiLabel: "Sandbox capacity", diff --git a/apps/web/src/i18n/locales/en/overview.ts b/apps/web/src/i18n/locales/en/overview.ts index 62fcd20ea..2f05205bb 100644 --- a/apps/web/src/i18n/locales/en/overview.ts +++ b/apps/web/src/i18n/locales/en/overview.ts @@ -146,7 +146,6 @@ export const overview = { more: "{{count}} more nodes on the Nodes page", more_one: "{{count}} more node on the Nodes page", more_other: "{{count}} more nodes on the Nodes page", - unconfigured: "This console has no sandbox administration.", loading: "Loading nodes…", failed: "Nodes could not be loaded.", noNodes: "No sandbox nodes yet. Hosted Sessions need at least one.", diff --git a/apps/web/src/i18n/locales/en/sessions.ts b/apps/web/src/i18n/locales/en/sessions.ts index a839ef932..fdf8078f3 100644 --- a/apps/web/src/i18n/locales/en/sessions.ts +++ b/apps/web/src/i18n/locales/en/sessions.ts @@ -46,8 +46,6 @@ export const sessions = { waitingLabel: "What the Session waits for", exactTokens: "{{tokens}} tokens", open: "Open Session {{id}}", - unrecognized: "Unrecognized Session", - unrecognizedHelp: "Core listed a Session this console cannot read, for example one with a field it does not know. Nothing else is shown for it.", more: "Show more", }, detail: { diff --git a/apps/web/src/i18n/locales/zh-CN/agents.ts b/apps/web/src/i18n/locales/zh-CN/agents.ts index b7c3ecff2..88990d8e7 100644 --- a/apps/web/src/i18n/locales/zh-CN/agents.ts +++ b/apps/web/src/i18n/locales/zh-CN/agents.ts @@ -68,7 +68,7 @@ export const agents: TranslationShape = { title: "用量", rangeLabel: "Session 创建时间", ranges: { all: "全部", "7d": "近 7 天", "30d": "近 30 天" }, help: "用量是 Core 报告的每个 Session 的累计值,不按天拆分。Session 按创建时间归入时间范围,其全部用量都计入该范围。", caveat: "数据来自 Core 报告的 Session 累计用量,不包含未报告的部分,不能作为计费依据。", - reading: "正在读取 Session…已读取 {{formattedCount}} 个", unrecognized_one: "{{formattedCount}} 个 Session 无法识别", unrecognized_other: "{{formattedCount}} 个 Session 无法识别", unrecognizedUpTo_one: "最多 {{formattedCount}} 个 Session 无法识别", unrecognizedUpTo_other: "最多 {{formattedCount}} 个 Session 无法识别", unrecognizedHelp: "Core 返回的这些 Session 格式无法识别,没有计入任何合计,也不会当作 0。选择时间范围时无法确定它们的创建时间,所以数量是上限。", cancel: "取消", cancelled_one: "已在读取 {{formattedCount}} 个 Session 后停止。", cancelled_other: "已在读取 {{formattedCount}} 个 Session 后停止。", continue: "继续", failed: "无法读取 Session。", retry: "重试", + reading: "正在读取 Session…已读取 {{formattedCount}} 个", cancel: "取消", cancelled_one: "已在读取 {{formattedCount}} 个 Session 后停止。", cancelled_other: "已在读取 {{formattedCount}} 个 Session 后停止。", continue: "继续", failed: "无法读取 Session。", retry: "重试", large: "Session 超过 {{formattedCount}} 个,统计可能较慢。建议先缩小时间范围。", largeShortest: "Session 超过 {{formattedCount}} 个,统计可能较慢。", sessions: "Session", tokens: "Token", coverage: "覆盖率", lastActive: "最近活跃", noData: "无数据", cardLabel: "{{name}} 的用量", coverageHelp: "Core 报告了用量的 Session 占范围内全部 Session 的比例。没有报告用量的 Session 不计入 Token 合计,也不按 0 计算。", diff --git a/apps/web/src/i18n/locales/zh-CN/common.ts b/apps/web/src/i18n/locales/zh-CN/common.ts index 531ad6e29..026112ab8 100644 --- a/apps/web/src/i18n/locales/zh-CN/common.ts +++ b/apps/web/src/i18n/locales/zh-CN/common.ts @@ -47,10 +47,8 @@ export const common = { column: "创建者", help: "创建这个资产的 API key,由 Core 在每次写入时记录。", unknown: "未知", - unknownHelp: "Core 没有创建记录:资产创建于开始记录之前,或由管理员复制而来。", + unknownHelp: "Core 没有创建记录:资产创建于开始记录之前。", revoked: "已撤销", - adminCopy: "管理员复制", - adminCopyHelp: "管理员从其他项目复制而来。", }, list: { search: "搜索", diff --git a/apps/web/src/i18n/locales/zh-CN/keys.ts b/apps/web/src/i18n/locales/zh-CN/keys.ts index dd38c5907..1a458c22b 100644 --- a/apps/web/src/i18n/locales/zh-CN/keys.ts +++ b/apps/web/src/i18n/locales/zh-CN/keys.ts @@ -175,7 +175,7 @@ export const keys: TranslationShape = { }, operations: { title: "写操作记录", - help: "这个项目里每一次成功的写操作,以及发起它的 key,由 Core 记录,按时间倒序。读操作不记录,也不保存请求内容和密钥。“未知”表示管理员复制或没有记录 key。", + help: "这个项目里每一次成功的写操作,以及发起它的 key,由 Core 记录,按时间倒序。读操作不记录,也不保存请求内容和密钥。“未知”表示没有记录 key。", filterLabel: "筛选写操作记录", allTypes: "全部资源", allKeys: "全部 key", diff --git a/apps/web/src/i18n/locales/zh-CN/metrics.ts b/apps/web/src/i18n/locales/zh-CN/metrics.ts index 7d7584065..638f67afb 100644 --- a/apps/web/src/i18n/locales/zh-CN/metrics.ts +++ b/apps/web/src/i18n/locales/zh-CN/metrics.ts @@ -23,7 +23,6 @@ export const metrics = { method: "数字由浏览器根据各项目的 Session、Turn 和 Item 列表汇总。一次请求即一个根 Agent Turn;耗时从开始计到结束。不含子 Agent 的 Turn 和已删除的 Session。", summary: "由浏览器汇总最近 {{range}} 内活跃的 {{sessions}} 个 Session。一次请求即一个 Agent Turn。", listTruncated: "{{names}} 的 Session 超过控制台的读取上限,只统计了最近创建的部分。", - unrecognized: "有 {{count}} 个 Session 无法识别,未计入。", skipped: "共 {{total}} 个 Session 活跃,只读取了最近活跃的 {{loaded}} 个。", truncated: "{{count}} 个 Session 的历史超过读取上限,时间范围内较早的 Turn 未计入。", failed: "{{count}} 个 Session 读取失败。", @@ -101,7 +100,7 @@ export const metrics = { coverage: "用量覆盖", lastActive: "最近活跃", unknown: "未知", - unknownHelp: "没有创建记录的 Session:在开始记录之前创建,或由管理员复制。", + unknownHelp: "没有创建记录的 Session:在开始记录之前创建。", revoked: "已撤销", coverageDetail: "{{total}} 个 Session 中有 {{reported}} 个上报了用量", }, @@ -210,7 +209,6 @@ export const metrics = { openSession: "打开 Session", noSession: "无法读取这个沙箱所属的 Session,因此无法显示它的历史。", }, - fleetUnconfigured: "此控制台未配置沙箱管理。", fleetFailed: "无法加载宿主机。", fleetLoading: "正在加载宿主机…", kpiLabel: "沙箱容量", diff --git a/apps/web/src/i18n/locales/zh-CN/overview.ts b/apps/web/src/i18n/locales/zh-CN/overview.ts index 4ecbc010d..a579781db 100644 --- a/apps/web/src/i18n/locales/zh-CN/overview.ts +++ b/apps/web/src/i18n/locales/zh-CN/overview.ts @@ -146,7 +146,6 @@ export const overview = { more: "另有 {{count}} 个节点,在节点页查看", more_one: "另有 {{count}} 个节点,在节点页查看", more_other: "另有 {{count}} 个节点,在节点页查看", - unconfigured: "此控制台未配置沙箱管理。", loading: "正在加载节点…", failed: "无法加载节点。", noNodes: "还没有沙箱节点。托管 Session 至少需要一个。", diff --git a/apps/web/src/i18n/locales/zh-CN/sessions.ts b/apps/web/src/i18n/locales/zh-CN/sessions.ts index afaccc9e6..82569748e 100644 --- a/apps/web/src/i18n/locales/zh-CN/sessions.ts +++ b/apps/web/src/i18n/locales/zh-CN/sessions.ts @@ -43,8 +43,6 @@ export const sessions = { waitingLabel: "Session 在等待什么", exactTokens: "{{tokens}} 个 Token", open: "打开 Session {{id}}", - unrecognized: "无法识别的 Session", - unrecognizedHelp: "Core 列出了一个控制台无法读取的 Session,例如带有未知字段。除 ID 外不显示其他内容。", more: "显示更多", }, detail: { diff --git a/apps/web/src/lib/admin-view.ts b/apps/web/src/lib/admin-view.ts index 9af9ead20..86c5a3154 100644 --- a/apps/web/src/lib/admin-view.ts +++ b/apps/web/src/lib/admin-view.ts @@ -49,14 +49,12 @@ export interface KeyRef { id: string; name: string | null; prefix: string | null; - kind: "issued" | "static" | "console"; revoked_at: number | null; } -/** Who created a resource: a key, an administrator copy, or unknown. */ +/** Who created a resource: a key, or null when Core has no creation record. */ export interface Creator { key: KeyRef | null; - source: "api_key" | "admin_copy" | null; } export interface SpaceUsage { @@ -128,7 +126,7 @@ function keyView(key: AdminAPIKey): AdminKey { function keyRef(key: AdminKeyProvenance | null): KeyRef | null { if (!key) return null; - return { id: key.id, name: key.name || null, prefix: key.prefix || null, kind: key.kind, revoked_at: maybeSeconds(key.revoked_at) }; + return { id: key.id, name: key.name || null, prefix: key.prefix || null, revoked_at: maybeSeconds(key.revoked_at) }; } export async function listAllProjects(signal?: AbortSignal): Promise { @@ -185,7 +183,7 @@ function summaryView(entry: AdminSummaryEntry, keys: ReadonlyMap { - it("is disabled unless the operator opts in exactly", () => { - expect(loadLocalDockerGuideProfile({})).toBeNull(); - expect(loadLocalDockerGuideProfile({ ...valid, OAC_WEB_DOCKER_GUIDE: "true" })).toBeNull(); - }); - - it("accepts a complete non-secret local profile", () => { - expect(loadLocalDockerGuideProfile(valid)).toEqual({ - image: valid.OAC_WEB_DOCKER_IMAGE, - apiContainer: valid.OAC_WEB_DOCKER_API_CONTAINER, - user: valid.OAC_WEB_DOCKER_USER, - credentialsHomePath: valid.OAC_WEB_DOCKER_CREDENTIALS_HOME_PATH, - runtimeHomePath: valid.OAC_WEB_DOCKER_RUNTIME_HOME_PATH, - }); - }); - - it("fails closed for partial or command-bearing values", () => { - expect(() => loadLocalDockerGuideProfile({ - ...valid, - OAC_WEB_DOCKER_IMAGE: "image; docker rm -f victim", - })).toThrow("safe Docker image reference"); - expect(() => loadLocalDockerGuideProfile({ - ...valid, - OAC_WEB_DOCKER_CREDENTIALS_HOME_PATH: "../executor-key.json", - })).toThrow("safe HOME-relative path"); - expect(() => loadLocalDockerGuideProfile({ - ...valid, - OAC_WEB_DOCKER_USER: "0:0", - })).toThrow("numeric non-root"); - - const partial: Record = { ...valid }; - delete partial.OAC_WEB_DOCKER_RUNTIME_HOME_PATH; - expect(() => loadLocalDockerGuideProfile(partial)).toThrow("OAC_WEB_DOCKER_RUNTIME_HOME_PATH is required"); - }); -}); - -const validBackend = { - OAC_WEB_DOCKER_BACKEND_GUIDE: "1", - OAC_WEB_DOCKER_DATABASE_CONTAINER: "oac-web-smoke-db", - OAC_WEB_DOCKER_API_CONTAINER: "oac-web-smoke-api", - OAC_WEB_DOCKER_DAEMON_CONTAINER: "oac-web-smoke-daemon", - OAC_WEB_DOCKER_CORE_PORT: "8091", -}; - -describe("local Docker backend guide configuration", () => { - it("is disabled unless the operator explicitly opts in", () => { - expect(loadLocalDockerBackendGuideProfile({})).toBeNull(); - expect(loadLocalDockerBackendGuideProfile({ - ...validBackend, - OAC_WEB_DOCKER_BACKEND_GUIDE: "true", - })).toBeNull(); - }); - - it("accepts only non-secret container names and a loopback Core port", () => { - expect(loadLocalDockerBackendGuideProfile(validBackend)).toEqual({ - databaseContainer: "oac-web-smoke-db", - apiContainer: "oac-web-smoke-api", - daemonContainer: "oac-web-smoke-daemon", - corePort: 8091, - }); - }); - - it("fails closed for incomplete or command-bearing configuration", () => { - expect(() => loadLocalDockerBackendGuideProfile({ - ...validBackend, - OAC_WEB_DOCKER_DAEMON_CONTAINER: "daemon; docker rm victim", - })).toThrow("safe Docker container name"); - expect(() => loadLocalDockerBackendGuideProfile({ - ...validBackend, - OAC_WEB_DOCKER_CORE_PORT: "70000", - })).toThrow("valid TCP port"); - - const partial: Record = { ...validBackend }; - delete partial.OAC_WEB_DOCKER_DATABASE_CONTAINER; - expect(() => loadLocalDockerBackendGuideProfile(partial)).toThrow( - "OAC_WEB_DOCKER_DATABASE_CONTAINER is required", - ); - }); -}); diff --git a/apps/web/src/lib/docker-guide-config.ts b/apps/web/src/lib/docker-guide-config.ts deleted file mode 100644 index 204714cbe..000000000 --- a/apps/web/src/lib/docker-guide-config.ts +++ /dev/null @@ -1,103 +0,0 @@ -export interface LocalDockerGuideProfile { - image: string; - apiContainer: string; - user: string; - credentialsHomePath: string; - runtimeHomePath: string; -} - -export interface LocalDockerBackendGuideProfile { - databaseContainer: string; - apiContainer: string; - daemonContainer: string; - corePort: number; -} - -const dockerImagePattern = /^[A-Za-z0-9][A-Za-z0-9._/:@-]*$/; -const dockerContainerPattern = /^[A-Za-z0-9][A-Za-z0-9_.-]*$/; -const dockerUserPattern = /^[1-9][0-9]*:[1-9][0-9]*$/; -const homePathSegmentPattern = /^[A-Za-z0-9._-]+$/; - -function required( - env: Record, - name: string, - feature = "OAC_WEB_DOCKER_GUIDE", -): string { - const value = env[name]; - if (!value) throw new Error(`${name} is required when ${feature}=1.`); - return value; -} - -function validHomeRelativePath(value: string): boolean { - if (value.startsWith("/") || value.endsWith("/") || value.includes("//")) return false; - const segments = value.split("/"); - return segments.length > 0 && segments.every((segment) => ( - segment !== "." && segment !== ".." && homePathSegmentPattern.test(segment) - )); -} - -export function loadLocalDockerGuideProfile( - env: Record, -): LocalDockerGuideProfile | null { - if (env.OAC_WEB_DOCKER_GUIDE !== "1") return null; - - const profile: LocalDockerGuideProfile = { - image: required(env, "OAC_WEB_DOCKER_IMAGE"), - apiContainer: required(env, "OAC_WEB_DOCKER_API_CONTAINER"), - user: required(env, "OAC_WEB_DOCKER_USER"), - credentialsHomePath: required(env, "OAC_WEB_DOCKER_CREDENTIALS_HOME_PATH"), - runtimeHomePath: required(env, "OAC_WEB_DOCKER_RUNTIME_HOME_PATH"), - }; - - if (!dockerImagePattern.test(profile.image)) { - throw new Error("OAC_WEB_DOCKER_IMAGE is not a safe Docker image reference."); - } - if (!dockerContainerPattern.test(profile.apiContainer)) { - throw new Error("OAC_WEB_DOCKER_API_CONTAINER is not a safe Docker container name."); - } - if (!dockerUserPattern.test(profile.user)) { - throw new Error("OAC_WEB_DOCKER_USER must be a numeric non-root uid:gid pair."); - } - if (!validHomeRelativePath(profile.credentialsHomePath)) { - throw new Error("OAC_WEB_DOCKER_CREDENTIALS_HOME_PATH must be a safe HOME-relative path."); - } - if (!validHomeRelativePath(profile.runtimeHomePath)) { - throw new Error("OAC_WEB_DOCKER_RUNTIME_HOME_PATH must be a safe HOME-relative path."); - } - - return profile; -} - -function validPort(value: string): number | null { - if (!/^[1-9][0-9]{0,4}$/.test(value)) return null; - const port = Number(value); - return port <= 65_535 ? port : null; -} - -export function loadLocalDockerBackendGuideProfile( - env: Record, -): LocalDockerBackendGuideProfile | null { - if (env.OAC_WEB_DOCKER_BACKEND_GUIDE !== "1") return null; - - const feature = "OAC_WEB_DOCKER_BACKEND_GUIDE"; - const databaseContainer = required(env, "OAC_WEB_DOCKER_DATABASE_CONTAINER", feature); - const apiContainer = required(env, "OAC_WEB_DOCKER_API_CONTAINER", feature); - const daemonContainer = required(env, "OAC_WEB_DOCKER_DAEMON_CONTAINER", feature); - const corePortValue = required(env, "OAC_WEB_DOCKER_CORE_PORT", feature); - const corePort = validPort(corePortValue); - - for (const [name, value] of [ - ["OAC_WEB_DOCKER_DATABASE_CONTAINER", databaseContainer], - ["OAC_WEB_DOCKER_API_CONTAINER", apiContainer], - ["OAC_WEB_DOCKER_DAEMON_CONTAINER", daemonContainer], - ] as const) { - if (!dockerContainerPattern.test(value)) { - throw new Error(`${name} is not a safe Docker container name.`); - } - } - if (corePort === null) { - throw new Error("OAC_WEB_DOCKER_CORE_PORT must be a valid TCP port."); - } - - return { databaseContainer, apiContainer, daemonContainer, corePort }; -} diff --git a/apps/web/src/lib/locale-strings.ts b/apps/web/src/lib/locale-strings.ts index 715e1e4d8..708c873fa 100644 --- a/apps/web/src/lib/locale-strings.ts +++ b/apps/web/src/lib/locale-strings.ts @@ -260,7 +260,6 @@ export const chinese = { "The node has active allocations or retained resources. Clear allocations, snapshots, reservations and pending cleanup before removal.": "节点仍有活跃分配或保留资源。请先清理资源分配、快照、预留资源和待清理项,再移除节点。", "The selected sandbox node is unavailable or has no capacity.": "所选沙箱节点不可用或容量不足。", "Sign in to the console again to access sandbox management.": "请重新登录控制台以访问沙箱管理。", - "Sandbox administration is not configured on this console.": "此控制台尚未配置沙箱管理权限。", "Core rejected the sandbox change": "Core 拒绝了此次沙箱更改", "Core rejected the sandbox configuration.": "Core 拒绝了这个沙箱配置。", "The console configuration could not be read. Refresh to try again.": "无法读取控制台配置。请刷新重试。", diff --git a/apps/web/src/lib/projects.tsx b/apps/web/src/lib/projects.tsx index 3f3554bdb..b8ac7df0d 100644 --- a/apps/web/src/lib/projects.tsx +++ b/apps/web/src/lib/projects.tsx @@ -1,4 +1,9 @@ -import type { CoreProjectReader } from "@oac/agents-client"; +import type { + AgentDeleted, AgentSession, AgentTurn, EnvironmentTemplateDeleted, EnvironmentTemplateList, EnvironmentTemplateResource, ListPage, PageOptions, ReadOptions, + RuntimeHistory, RuntimeHistoryQuery, RuntimeObservation, SavedAgent, SessionDeleted, SessionItem, SessionListOptions, Skill, SkillContent, SkillDeleted, SkillList, + SkillListOptions, SkillVersionDeleted, SkillVersionList, SourceFileDeleted, SourceFileList, SourceFileListOptions, Vault, VaultCredentialDeleted, + VaultCredentialList, VaultDeleted, VaultList, VaultListOptions, +} from "@oac/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"; @@ -113,8 +118,39 @@ export interface ProjectCollection { refresh: () => void; } -/** The Core reads and deletes a project page uses, bound to one project. */ -export type ProjectClient = CoreProjectReader; +/** + * The Core reads and deletes a project page uses, bound to one project: each + * method is a management client method with its project ID bound. + */ +export interface ProjectClient { + listAgents(options?: PageOptions): Promise>; + retrieveAgent(agentId: string): Promise; + deleteAgent(agentId: string): Promise; + listSkills(options?: SkillListOptions): Promise; + retrieveSkill(skillId: string, options?: ReadOptions): Promise; + deleteSkill(skillId: string, options?: ReadOptions): Promise; + listSkillVersions(skillId: string, options?: SkillListOptions): Promise; + deleteSkillVersion(skillId: string, version: string, options?: ReadOptions): Promise; + downloadSkill(skillId: string, options?: ReadOptions): Promise; + downloadSkillVersion(skillId: string, version: string, options?: ReadOptions): Promise; + listEnvironmentTemplates(options?: PageOptions): Promise; + retrieveEnvironmentTemplate(templateId: string, options?: ReadOptions): Promise; + deleteEnvironmentTemplate(templateId: string, options?: ReadOptions): Promise; + listSourceFiles(options?: SourceFileListOptions): Promise; + deleteSourceFile(fileId: string, options?: ReadOptions): Promise; + listVaults(options?: VaultListOptions): Promise; + retrieveVault(vaultId: string, options?: ReadOptions): Promise; + listVaultCredentials(vaultId: string, options?: VaultListOptions): Promise; + deleteVault(vaultId: string): Promise; + deleteVaultCredential(vaultId: string, credentialId: string): Promise; + listSessions(options?: SessionListOptions): Promise>; + retrieveSession(sessionId: string, options?: ReadOptions): Promise; + deleteSession(sessionId: string): Promise; + listTurns(sessionId: string, options?: PageOptions): Promise>; + listItems(sessionId: string, options?: PageOptions): Promise>; + retrieveRuntimeObservation(sessionId: string, options?: ReadOptions): Promise; + retrieveRuntimeHistory(sessionId: string, query: RuntimeHistoryQuery): Promise; +} async function content(result: Promise<{ blob: Blob; contentType: string | null; contentDisposition: string | null }>) { const value = await result; @@ -126,11 +162,7 @@ async function content(result: Promise<{ blob: Blob; contentType: string | null; * shapes, so pages read a project through `/core/v1/projects/{id}`. * Deletions only; no creation or editing exists here. */ -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 }; - }; +function createProjectClient(projectId: string): ProjectClient { return { listAgents: (options) => admin.listAgents(projectId, options), retrieveAgent: (agentId: string) => admin.retrieveAgent(projectId, agentId), @@ -152,16 +184,17 @@ function createProjectClient(projectId: string): CoreProjectReader { listVaultCredentials: (vaultId, options) => admin.listVaultCredentials(projectId, vaultId, options), deleteVault: (vaultId) => admin.deleteVault(projectId, vaultId), deleteVaultCredential: (vaultId, credentialId) => admin.deleteVaultCredential(projectId, vaultId, credentialId), - listSessions, - // The management list is strict: a malformed Session fails the page rather than being skipped. - listSessionsTolerant: async (options) => ({ ...(await listSessions(options)), unrecognized: [] }), + listSessions: async (options = {}) => { + 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 }; + }, retrieveSession: (sessionId, options) => admin.retrieveSession(projectId, sessionId, options), deleteSession: (sessionId) => admin.deleteSession(projectId, sessionId), listTurns: (sessionId, options) => admin.listTurns(projectId, sessionId, options), listItems: (sessionId, options) => admin.listItems(projectId, sessionId, options), retrieveRuntimeObservation: (sessionId, options) => admin.retrieveRuntimeObservation(projectId, sessionId, options), retrieveRuntimeHistory: (sessionId, query) => admin.retrieveRuntimeHistory(projectId, sessionId, query), - } satisfies CoreProjectReader; + }; } const clients = new Map(); @@ -266,7 +299,7 @@ export function useCreators(type: OwnerResourceType, rows: ReadonlyArray<{ proje const controller = new AbortController(); void Promise.allSettled([...byProject].map(async ([projectId, ids]) => { const creators = await listCreators(projectId, type, ids, controller.signal); - for (const id of ids) creatorCache.set(creatorKey(type, projectId, id), creators.get(id) ?? { key: null, source: null }); + for (const id of ids) creatorCache.set(creatorKey(type, projectId, id), creators.get(id) ?? { key: null }); })).then(() => { if (!controller.signal.aborted) setVersion((value) => value + 1); }); return () => controller.abort(); // eslint-disable-next-line react-hooks/exhaustive-deps @@ -282,11 +315,10 @@ export function CreatorHeading() { return {t("creator.column")}{t("creator.help")}; } -/** The creating key's name, "Admin copy" for a copied asset, "Unknown" when Core has no record. */ +/** The creating key's name, or "Unknown" when Core has no record. */ export function CreatorCell({ creator }: { creator: Creator | undefined }) { const { t } = useTranslation("common"); if (creator === undefined) return —; - if (creator.source === "admin_copy") return {t("creator.adminCopy")}; const key = creator.key; if (!key) return {t("creator.unknown")}; const label = key.name ?? (key.prefix ? `${key.prefix}…` : t("creator.unknown")); diff --git a/apps/web/src/lib/queries.ts b/apps/web/src/lib/queries.ts index f5b007d7f..a63039f47 100644 --- a/apps/web/src/lib/queries.ts +++ b/apps/web/src/lib/queries.ts @@ -1,7 +1,7 @@ import { QueryClient, queryOptions } from "@tanstack/react-query"; -import type { EnvironmentTemplateResource, SavedAgent, Skill, SourceFileListEntry, Vault } from "@oac/agents-client"; +import type { AgentSession, EnvironmentTemplateResource, SavedAgent, Skill, SourceFileListEntry, Vault } from "@oac/agents-client"; -import { readSessionLog, type SessionLogEntry } from "../features/sessions/session-log"; +import { readSessionLog } from "../features/sessions/session-log"; import { listAllProjects } from "./admin-view"; import { projectClient, readAllPages, type ProjectClient } from "./projects"; @@ -39,7 +39,7 @@ export const collections = { templates: { key: ["templates"], load: (client, signal) => readAllPages((after) => client.listEnvironmentTemplates({ after, limit: PAGE, signal })) } satisfies CollectionSpec, skills: { key: ["skills"], load: (client, signal) => readAllPages((after) => client.listSkills({ after, limit: PAGE, signal })) } satisfies CollectionSpec, vaults: { key: ["vaults"], load: (client, signal) => readAllPages((after) => client.listVaults({ after, limit: PAGE, signal })) } satisfies CollectionSpec, - sessions: { key: ["sessions"], load: (client, signal) => readSessionLog(client, signal) } satisfies CollectionSpec, + sessions: { key: ["sessions"], load: (client, signal) => readSessionLog(client, signal) } satisfies CollectionSpec, }; /** Files are read in the order the page shows, so each order has its own cache entry. */ diff --git a/apps/web/src/lib/vite-config.test.ts b/apps/web/src/lib/vite-config.test.ts deleted file mode 100644 index ad3d57c71..000000000 --- a/apps/web/src/lib/vite-config.test.ts +++ /dev/null @@ -1,38 +0,0 @@ -import { describe, expect, it, vi } from "vitest"; -import { loadEnv, type ConfigEnv } from "vite"; - -import webConfig from "../../vite.config.ts"; - -vi.mock("vite", async (importOriginal) => ({ - ...await importOriginal(), - loadEnv: vi.fn(), -})); - -function configure(env: Record, command: ConfigEnv["command"] = "serve") { - vi.mocked(loadEnv).mockReturnValue(env); - if (typeof webConfig !== "function") throw new Error("Expected a Vite configuration factory"); - return webConfig({ command, mode: "development" }); -} - -describe("Web Vite settings boundary", () => { - it("uses current flags and keeps the proxy address out of browser definitions", async () => { - const config = await configure({ - OAC_WEB_DEV_PROXY_TARGET: "https://private-host-marker.example", - OAC_WEB_SELF_HOSTED_SESSIONS: "1", - OAC_WEB_OPENAI_HOSTED_SESSIONS: "1", - OAC_WEB_ENVIRONMENT_FILES: "1", - }); - expect(config.define).toEqual({ - __OAC_WEB_SELF_HOSTED_SESSIONS__: "true", - __OAC_WEB_OPENAI_HOSTED_SESSIONS__: "true", - __OAC_WEB_ENVIRONMENT_FILES__: "true", - __OAC_WEB_DOCKER_GUIDE__: "null", - __OAC_WEB_DOCKER_BACKEND_GUIDE__: "null", - }); - expect(Object.keys(config.server?.proxy ?? {})).toEqual(["/console", "/node-install", "/core/v1"]); - expect(config.server?.proxy?.["/core/v1"]).toEqual({ target: "https://private-host-marker.example", changeOrigin: true }); - expect(JSON.stringify(config.define)).not.toContain("private-"); - }); - - -}); diff --git a/apps/web/src/vite-env.d.ts b/apps/web/src/vite-env.d.ts index d6371b55b..11f02fe2a 100644 --- a/apps/web/src/vite-env.d.ts +++ b/apps/web/src/vite-env.d.ts @@ -1,27 +1 @@ /// - -declare const __OAC_WEB_SELF_HOSTED_SESSIONS__: boolean; -declare const __OAC_WEB_OPENAI_HOSTED_SESSIONS__: boolean; -declare const __OAC_WEB_ENVIRONMENT_FILES__: boolean; -declare const __OAC_WEB_DOCKER_GUIDE__: null | { - readonly image: string; - readonly apiContainer: string; - readonly user: string; - readonly credentialsHomePath: string; - readonly runtimeHomePath: string; -}; -declare const __OAC_WEB_DOCKER_BACKEND_GUIDE__: null | { - readonly databaseContainer: string; - readonly apiContainer: string; - readonly daemonContainer: string; - readonly corePort: number; -}; - -interface ImportMetaEnv { - readonly VITE_AGENT_MODEL_PRESETS?: string; - readonly VITE_AGENT_DEFAULT_MODEL?: string; -} - -interface ImportMeta { - readonly env: ImportMetaEnv; -} diff --git a/apps/web/vite.config.ts b/apps/web/vite.config.ts index 21dae1a27..4f24a353e 100644 --- a/apps/web/vite.config.ts +++ b/apps/web/vite.config.ts @@ -5,31 +5,13 @@ import react from "@vitejs/plugin-react"; import tailwindcss from "@tailwindcss/vite"; import { fileURLToPath } from "node:url"; -import { - loadLocalDockerBackendGuideProfile, - loadLocalDockerGuideProfile, -} from "./src/lib/docker-guide-config.ts"; - const repositoryRoot = fileURLToPath(new URL("../..", import.meta.url)); export default defineConfig(({ mode }) => { const env = loadEnv(mode, repositoryRoot, ""); const target = env.OAC_WEB_DEV_PROXY_TARGET ?? "http://127.0.0.1:8091"; - const selfHostedSessionsEnabled = env.OAC_WEB_SELF_HOSTED_SESSIONS === "1"; - const openAIHostedSessionsEnabled = env.OAC_WEB_OPENAI_HOSTED_SESSIONS === "1"; - const environmentFilesEnabled = env.OAC_WEB_ENVIRONMENT_FILES === "1"; - const localDockerGuide = loadLocalDockerGuideProfile(env); - const localDockerBackendGuide = loadLocalDockerBackendGuideProfile(env); return { - define: { - __OAC_WEB_SELF_HOSTED_SESSIONS__: JSON.stringify(selfHostedSessionsEnabled), - __OAC_WEB_OPENAI_HOSTED_SESSIONS__: JSON.stringify(openAIHostedSessionsEnabled), - __OAC_WEB_ENVIRONMENT_FILES__: JSON.stringify(environmentFilesEnabled), - __OAC_WEB_DOCKER_GUIDE__: JSON.stringify(localDockerGuide), - __OAC_WEB_DOCKER_BACKEND_GUIDE__: JSON.stringify(localDockerBackendGuide), - }, - envDir: repositoryRoot, plugins: [react(), tailwindcss()], resolve: { alias: { "@": fileURLToPath(new URL("./src", import.meta.url)) }, diff --git a/contracts/agents-api/admin-api.md b/contracts/agents-api/admin-api.md index f31771c1f..81d361121 100644 --- a/contracts/agents-api/admin-api.md +++ b/contracts/agents-api/admin-api.md @@ -181,12 +181,12 @@ Both routes accept only the parameters listed; an unknown, repeated or empty par ```json {"data":[ - {"resource_id":"id1","api_key":{"id":"key-uuid","name":"SDK","prefix":"pc_example","kind":"issued","revoked_at":null},"source":"api_key","admin_audit_id":null}, - {"resource_id":"id2","api_key":null,"source":null,"admin_audit_id":null} + {"resource_id":"id1","api_key":{"id":"key-uuid","name":"SDK","prefix":"pc_example","kind":"issued","revoked_at":null}}, + {"resource_id":"id2","api_key":null} ]} ``` -`api_key` and `source` are null when Core has no creation record, including for resources in another Project. `source: "admin_copy"` with an `admin_audit_id` marks a resource recorded by a `copy` entry in the audit log; no current route writes one. +`api_key` is null when Core has no creation record, including for resources in another Project. `GET /projects/{project_id}/write-operations` lists writes newest first by `(created_at, id)`. Filters: `key_id`, `resource_type`, `resource_id`, inclusive `created_after` and exclusive `created_before` (RFC 3339). `limit` is 1–100, default 50. Pass the previous `next_cursor` as `after` with unchanged filters. The response is `{data, has_more, next_cursor}`; each entry has `id`, `created_at`, `api_key`, `action`, `resource_type`, `resource_id`, `parent_id` (empty when absent), `request_id` and `trace_id`. @@ -217,7 +217,7 @@ The [Runtime telemetry API](./runtime-observability-api.md) owns current observa `GET /audit-log` lists administrator writes newest first. Filters: `project_id`, `resource_type`, `resource_id`, `action`, inclusive `created_after` and exclusive `created_before` (RFC 3339). `limit` is 1–100, default 50, with the opaque `after` cursor. The response is `{data, has_more, next_cursor}`. -Each entry has `id`, `created_at`, `admin_credential_id` (the first 8 hex characters of the Core key digest), `actor_label`, `action`, `project_id`, `resource_type`, `resource_id`, `result_ids`, `request_id` and `trace_id`. `result_ids` is an empty array except on `copy` entries. Deployment-wide entries have `project_id: null`, and a `project_id` filter excludes them. +Each entry has `id`, `created_at`, `admin_credential_id` (the first 8 hex characters of the Core key digest), `actor_label`, `action`, `project_id`, `resource_type`, `resource_id`, `request_id` and `trace_id`. Deployment-wide entries have `project_id: null`, and a `project_id` filter excludes them. | `resource_type` | `action` | `resource_id` | | --- | --- | --- | diff --git a/contracts/agents-api/core.openapi.yaml b/contracts/agents-api/core.openapi.yaml index f7c597938..c04c1097c 100644 --- a/contracts/agents-api/core.openapi.yaml +++ b/contracts/agents-api/core.openapi.yaml @@ -21,10 +21,6 @@ definitions: type: string resource_type: type: string - result_ids: - items: - type: object - type: array trace_id: type: string type: object @@ -3393,14 +3389,10 @@ definitions: type: object writeaudit.ResourceOwner: properties: - admin_audit_id: - type: string api_key: $ref: '#/definitions/writeaudit.APIKey' resource_id: type: string - source: - type: string type: object info: contact: {} diff --git a/contracts/agents-api/zh/admin-api.md b/contracts/agents-api/zh/admin-api.md index eabea2d5b..389c07997 100644 --- a/contracts/agents-api/zh/admin-api.md +++ b/contracts/agents-api/zh/admin-api.md @@ -1,7 +1,7 @@ --- title: "Core 管理 API" source: contracts/agents-api/admin-api.md -source_hash: 22ac83d8596bcee671a4f94c81d2fe65e109ab9c0b3759e17cf30626bd5d41df +source_hash: 7759541dbc59dab917499f506c9e9c11184e8065fd1ed6fb418baaf4a478faf3 --- Core 管理 API(`/core/v1`)用于管理安装实例:Project 及其 API 密钥、Project 资源的读取和删除、执行器凭据、部署默认模型、沙箱部署及其节点、监控和审计。Web 的[控制台服务器](../../../docs/zh/web/console-server.md#forwarding-to-core)会为已登录的管理员调用它;运维人员则从 Core 主机上的脚本调用它([编写 Core API 脚本](../../../docs/zh/getting-started/operations.md#script-the-core-api))。生成的架构是 [core.openapi.yaml](../core.openapi.yaml),所有错误都使用 [Core 错误封装](core-errors.md)。 @@ -183,12 +183,12 @@ Core 会记录是哪个 Project API 密钥完成了每次成功的公共写入 ```json {"data":[ - {"resource_id":"id1","api_key":{"id":"key-uuid","name":"SDK","prefix":"pc_example","kind":"issued","revoked_at":null},"source":"api_key","admin_audit_id":null}, - {"resource_id":"id2","api_key":null,"source":null,"admin_audit_id":null} + {"resource_id":"id1","api_key":{"id":"key-uuid","name":"SDK","prefix":"pc_example","kind":"issued","revoked_at":null}}, + {"resource_id":"id2","api_key":null} ]} ``` -当 Core 没有创建记录时,`api_key` 和 `source` 为 null,这包括另一个 Project 中的资源。带有 `admin_audit_id` 的 `source: "admin_copy"` 表示该资源由审计日志中的 `copy` 条目记录;当前没有路由会写入此类记录。 +当 Core 没有创建记录时,`api_key` 为 null,这包括另一个 Project 中的资源。 `GET /projects/{project_id}/write-operations` 按 `(created_at, id)` 从新到旧列出写入记录。过滤条件包括:`key_id`、`resource_type`、`resource_id`、包含起始时间的 `created_after` 和不包含结束时间的 `created_before`(RFC 3339)。`limit` 为 1–100,默认值为 50。在过滤条件不变的情况下,将上一个 `next_cursor` 作为 `after` 传入。响应为 `{data, has_more, next_cursor}`;每个条目包含 `id`、`created_at`、`api_key`、`action`、`resource_type`、`resource_id`、`parent_id`(不存在时为空)、`request_id` 和 `trace_id`。 @@ -219,7 +219,7 @@ Core 会记录是哪个 Project API 密钥完成了每次成功的公共写入 `GET /audit-log` 按从新到旧的顺序列出管理员写入。过滤条件包括 `project_id`、`resource_type`、`resource_id`、`action`、包含起始时间的 `created_after` 和不包含结束时间的 `created_before`(RFC 3339)。`limit` 为 1–100,默认值为 50,并使用不透明的 `after` 游标。响应为 `{data, has_more, next_cursor}`。 -每个条目包含 `id`、`created_at`、`admin_credential_id`(Core 密钥摘要的前 8 个十六进制字符)、`actor_label`、`action`、`project_id`、`resource_type`、`resource_id`、`result_ids`、`request_id` 和 `trace_id`。除 `copy` 条目外,`result_ids` 都是空数组。部署范围条目为 `project_id: null`,使用 `project_id` 过滤时会排除这些条目。 +每个条目包含 `id`、`created_at`、`admin_credential_id`(Core 密钥摘要的前 8 个十六进制字符)、`actor_label`、`action`、`project_id`、`resource_type`、`resource_id`、`request_id` 和 `trace_id`。部署范围条目为 `project_id: null`,使用 `project_id` 过滤时会排除这些条目。 | `resource_type` | `action` | `resource_id` | | --- | --- | --- | diff --git a/docs/web/console-api-usage.md b/docs/web/console-api-usage.md index ba8faa171..b81088cf4 100644 --- a/docs/web/console-api-usage.md +++ b/docs/web/console-api-usage.md @@ -69,7 +69,7 @@ In an archived project the section hides **Issue credential** and **Rotate** beh | Operation | Route | Console use | | --- | --- | --- | -| 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: the creating key's name, **Admin copy** for an owner with source `admin_copy`, or **Unknown** when Core has no record | +| 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: the creating key's name, or **Unknown** when Core has no record | | 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) | | Installation | `GET /core/v1/installation` | System's Installation facts (`public_url`, `api_base_url`, `installation_id`, `source_commit`) and read-only Startup settings (`configuration.settings`; a sensitive setting shows only whether it is `configured`); `api_base_url` in the call samples; `public_url` as the download origin and `--source-url` of the node install and uninstall commands (and the install command's `--core-url`). A sensitive setting with a value, or an unknown member, fails the read | diff --git a/docs/zh/web/console-api-usage.md b/docs/zh/web/console-api-usage.md index d855d7ee6..a6037092a 100644 --- a/docs/zh/web/console-api-usage.md +++ b/docs/zh/web/console-api-usage.md @@ -1,7 +1,7 @@ --- title: "控制台 API 使用" source: docs/web/console-api-usage.md -source_hash: 3b54852843f5afccd6ce47a532a9c82f6a4a0dbffdac9b96892c5026f5f78c20 +source_hash: 50edc63c79976e9aad9590604a0e361aae178144bc8a6009989b2da5d031408d --- 本页列出各控制台页面读取和写入的 Core 路由,以及控制台如何限定读取范围。[administrator API contract](../../../contracts/agents-api/zh/admin-api.md) 定义了路由、响应结构、分页和审计记录;[API namespaces and credentials](../api/index.md) 定义了本文使用的术语。 @@ -71,7 +71,7 @@ source_hash: 3b54852843f5afccd6ce47a532a9c82f6a4a0dbffdac9b96892c5026f5f78c20 | 操作 | 路由 | 控制台用途 | | --- | --- | --- | -| 资源所有者 | `GET /core/v1/projects/{project_id}/resource-owners` | 每个资源列表的 Creator 列和详情页的创建者信息,每批最多处理 100 个 ID:创建密钥的名称;来源为 `admin_copy` 的所有者显示 **Admin copy**;Core 无记录时显示 **Unknown** | +| 资源所有者 | `GET /core/v1/projects/{project_id}/resource-owners` | 每个资源列表的 Creator 列和详情页的创建者信息,每批最多处理 100 个 ID:创建密钥的名称;Core 无记录时显示 **Unknown** | | 写入操作 | `GET /core/v1/projects/{project_id}/write-operations` | 项目的写入历史,按最新优先,可按密钥和资源类型筛选,每页 50 条 | | 汇总 | `GET /core/v1/summary` | Overview(按项目)、Agents 列表(`group_by=agent`)、项目页面(按项目并使用 `group_by=key`)、Agent 指标(跳过空闲项目,并统计从范围开始以来按创建密钥划分的使用量)、Projects 列表(最近活动) | | 安装 | `GET /core/v1/installation` | System 的 Installation 信息(`public_url`、`api_base_url`、`installation_id`、`source_commit`)和只读 Startup 设置(`configuration.settings`;敏感设置仅显示其是否为 `configured`);调用示例中的 `api_base_url`;作为下载来源以及节点安装和卸载命令中 `--source-url` 的 `public_url`(还包括安装命令中的 `--core-url`)。如果敏感设置包含值,或存在未知成员,读取会失败 | diff --git a/packages/agents-client/README.md b/packages/agents-client/README.md index c8fa7f2e2..456f75318 100644 --- a/packages/agents-client/README.md +++ b/packages/agents-client/README.md @@ -20,7 +20,7 @@ Every constructor also takes `baseUrl` and `fetch`. A token may be a string or a Behavior shared by the clients: -- **Strict responses.** Session, history, event, Environment and Core API responses are checked against their pinned shapes before they are returned. A malformed one throws `AgentCoreError` with status 502 and a code such as `invalid_session_resource` or `invalid_admin_response` (`CoreMetricsClient`: status 0, `invalid_response`) instead of passing on a guessed value. `listSessionsTolerant` reports Sessions it cannot recognise in `unrecognized` instead of failing. Agent responses are typed but not checked at run time. +- **Strict responses.** Session, history, event, Environment and Core API responses are checked against their pinned shapes before they are returned. A malformed one throws `AgentCoreError` with status 502 and a code such as `invalid_session_resource` or `invalid_admin_response` (`CoreMetricsClient`: status 0, `invalid_response`) instead of passing on a guessed value. Agent responses are typed but not checked at run time. - **Errors.** A non-2xx response throws `AgentCoreError` with `status`, `code`, `param`, `errorType` and, from the Core API, the optional `details` of the [Core error envelope](../../contracts/agents-api/core-errors.md). Invalid caller input throws `TypeError` before any request. - **No retries or timeouts.** No client retries a request. Pass `signal` to cancel one. - **Idempotency.** `createSession` takes an idempotency key and generates one when omitted; pass your own to retry a creation safely. `sendMessage`, `submitEvents`, `cancelTurn` and `submitFunctionResult` require a key of at most 128 bytes. `createIdempotencyKey()` makes one. diff --git a/packages/agents-client/src/admin-client.test.ts b/packages/agents-client/src/admin-client.test.ts index aba044f90..9b0d05466 100644 --- a/packages/agents-client/src/admin-client.test.ts +++ b/packages/agents-client/src/admin-client.test.ts @@ -176,7 +176,7 @@ describe("AdminClient transport boundary", () => { }); it("accepts deployment-wide audit entries without a Project", async () => { - const entry = { id: "audit", created_at: "2026-09-26T08:00:00Z", admin_credential_id: "digest", actor_label: "console", action: "set", project_id: null, resource_type: "deployment_model_provider", resource_id: "codex", result_ids: [], request_id: "request", trace_id: "trace" }; + const entry = { id: "audit", created_at: "2026-09-26T08:00:00Z", admin_credential_id: "digest", actor_label: "console", action: "set", project_id: null, resource_type: "deployment_model_provider", resource_id: "codex", request_id: "request", trace_id: "trace" }; const audit = { data: [entry], has_more: false, next_cursor: "" }; expect(await clientWith(audit).client.listAuditLog()).toEqual(audit); await expect(clientWith({ ...audit, data: [{ ...entry, project_id: 1 }] }).client.listAuditLog()).rejects.toMatchObject({ code: "invalid_admin_response" }); @@ -266,7 +266,7 @@ describe("AdminClient response contracts", () => { }); it("retains owner ordering and strips no unexpected secret fields", async () => { - const owners = { data: [{ resource_id: "a", api_key: null, source: null, admin_audit_id: null }] }; + const owners = { data: [{ resource_id: "a", api_key: null }] }; expect(await clientWith(owners).client.retrieveResourceOwners(projectId, "agent", ["a"])).toEqual(owners); await expect(clientWith(owners).client.retrieveResourceOwners(projectId, "agent", ["b"])).rejects.toBeInstanceOf(AgentCoreError); await expect(clientWith({ data: [{ resource_id: "a", api_key: { id: projectId, name: "SDK", prefix: "p", kind: "issued", revoked_at: null, key: "leak" } }] }).client.retrieveResourceOwners(projectId, "agent", ["a"])).rejects.toBeInstanceOf(AgentCoreError); @@ -294,13 +294,11 @@ describe("AdminClient deployment read models", () => { await expect(clientWith({ ...summary, data: [{ ...summary.data[0], coverage: { measured_sessions: 3, total_sessions: 2, ratio: 1.5 } }] }).client.retrieveSummary()).rejects.toBeInstanceOf(AgentCoreError); }); - it("validates historical copy provenance and safe audit mappings", async () => { - const owners = { data: [{ resource_id: "a", api_key: null, source: "admin_copy", admin_audit_id: "audit" }] }; - expect(await clientWith(owners).client.retrieveResourceOwners(projectId, "agent", ["a"])).toEqual(owners); - const audit = { data: [{ id: "audit", created_at: "2026-09-24T00:00:00Z", admin_credential_id: "digest", actor_label: "admin", action: "copy", project_id: projectId, resource_type: "agent", resource_id: "a", result_ids: [{ type: "agent", source_id: "a", target_id: "b" }], request_id: "request", trace_id: "trace" }], has_more: false, next_cursor: "" }; + it("passes audit filters and rejects audit entries with unexpected fields", async () => { + const audit = { data: [{ id: "audit", created_at: "2026-09-24T00:00:00Z", admin_credential_id: "digest", actor_label: "admin", action: "delete", project_id: projectId, resource_type: "agent", resource_id: "a", request_id: "request", trace_id: "trace" }], has_more: false, next_cursor: "" }; const { client, fetch } = clientWith(audit); - expect(await client.listAuditLog({ action: "copy", resource_type: "agent", project_id: projectId, after: "cursor" })).toEqual(audit); - expect(fetch.mock.calls[0]![0]).toBe(`/core/v1/audit-log?after=cursor&project_id=${projectId}&resource_type=agent&action=copy`); + expect(await client.listAuditLog({ action: "delete", resource_type: "agent", project_id: projectId, after: "cursor" })).toEqual(audit); + expect(fetch.mock.calls[0]![0]).toBe(`/core/v1/audit-log?after=cursor&project_id=${projectId}&resource_type=agent&action=delete`); await expect(clientWith({ ...audit, data: [{ ...audit.data[0], request_body: { token: "leak" } }] }).client.listAuditLog()).rejects.toBeInstanceOf(AgentCoreError); }); @@ -476,8 +474,10 @@ describe("AdminClient database-owned identities", () => { expect(await client.listSkillVersions(projectId, "skill", { limit: 0 })).toEqual(page); }); - it.each(["issued", "static", "console"])("preserves %s key provenance in historical ownership records", async (kind) => { - const owner = { resource_id: resourceId, api_key: { id: keyId, name: "Original key", prefix: "p", kind, revoked_at: null }, source: "api_key", admin_audit_id: null }; + it("preserves issued key provenance and rejects other key kinds", async () => { + const owner = { resource_id: resourceId, api_key: { id: keyId, name: "Original key", prefix: "p", kind: "issued", revoked_at: null } }; expect(await clientWith({ data: [owner] }).client.retrieveResourceOwners(projectId, "agent", [resourceId])).toEqual({ data: [owner] }); + const other = { ...owner, api_key: { ...owner.api_key, kind: "static" } }; + await expect(clientWith({ data: [other] }).client.retrieveResourceOwners(projectId, "agent", [resourceId])).rejects.toMatchObject({ code: "invalid_admin_response" }); }); }); diff --git a/packages/agents-client/src/admin-projection.ts b/packages/agents-client/src/admin-projection.ts index a4196bb8c..6b94de644 100644 --- a/packages/agents-client/src/admin-projection.ts +++ b/packages/agents-client/src/admin-projection.ts @@ -4,7 +4,7 @@ import { projectTokenUsage } from "./usage-projection"; import { safeProvider } from "./execution-configuration-projection"; import { canonicalUuid, exactFields, isNonnegativeInteger, isRecord, onlyFields, sameResourceId } from "./response-projection"; import type { CoreHarness, CoreHarnessKind, HarnessModelConfiguration, ProviderObservationErrorCode, ListPage, SavedAgent } from "./types"; -import type { AdminAPIKey, AdminProject, AdminAuditPage, AdminSummary, AdminRuntimeObservation, RuntimeDiskObservation, AdminKeyProvenance, AdminResourceOwner, AdminWriteOperationPage, AdminAuditResultID, AdminDeleted, AdminIssuedAPIKey, AdminPage, AdminSessionArchive, SessionArtifact, Skill, SkillVersion, ExecutorCredentialList, ExecutorConnection, IssuedExecutorCredential, CoreInstallation, CoreInstallationSetting } from "./admin-types"; +import type { AdminAPIKey, AdminProject, AdminAuditPage, AdminSummary, AdminRuntimeObservation, RuntimeDiskObservation, AdminKeyProvenance, AdminResourceOwner, AdminWriteOperationPage, AdminDeleted, AdminIssuedAPIKey, AdminPage, AdminSessionArchive, SessionArtifact, Skill, SkillVersion, ExecutorCredentialList, ExecutorConnection, IssuedExecutorCredential, CoreInstallation, CoreInstallationSetting } from "./admin-types"; export function invalidAdminResponse(): never { throw new AgentCoreError("Core returned an invalid administration response.", 502, "invalid_admin_response"); @@ -98,36 +98,20 @@ export function projectArtifact(value: unknown, sessionId: string, expectedId?: !sameResourceId(artifact.session_id as string, sessionId) || (expectedId !== undefined && !sameResourceId(artifact.id as string, expectedId))) return invalidAdminResponse(); return { ...artifact } as unknown as SessionArtifact; } -// Historical copy audit entries retain their result mappings. -function projectAuditResultIDs(value: unknown): AdminAuditResultID[] { - if (!Array.isArray(value)) return invalidAdminResponse(); - const types = new Set(["agent", "skill", "skill_version", "environment_template", "file", "vault", "credential"]); - return value.map((entry) => { - const item = record(entry, ["type", "source_id", "target_id"]); - strings(item, ["type", "source_id", "target_id"]); - if (!types.has(item.type as string)) return invalidAdminResponse(); - return { ...item } as unknown as AdminAuditResultID; - }); -} - function projectProvenance(value: unknown): AdminKeyProvenance | null { if (value === null) return null; const key = record(value, ["id", "name", "prefix", "kind", "revoked_at"]); strings(key, ["id", "name", "prefix"]); - if ((key.kind !== "issued" && key.kind !== "static" && key.kind !== "console") || !date(key.revoked_at)) return invalidAdminResponse(); + if (key.kind !== "issued" || !date(key.revoked_at)) return invalidAdminResponse(); return { ...key } as unknown as AdminKeyProvenance; } export function projectResourceOwners(value: unknown, ids: string[]): { data: AdminResourceOwner[] } { const page = record(value, ["data"]); if (!Array.isArray(page.data) || page.data.length !== ids.length) return invalidAdminResponse(); return { data: page.data.map((entry, index) => { - const owner = record(entry, ["resource_id", "api_key", "source", "admin_audit_id"]); + const owner = record(entry, ["resource_id", "api_key"]); if (owner.resource_id !== ids[index]) return invalidAdminResponse(); - if (!(owner.source === null || owner.source === "api_key" || owner.source === "admin_copy") || - !(owner.admin_audit_id === null || typeof owner.admin_audit_id === "string")) return invalidAdminResponse(); - const apiKey = projectProvenance(owner.api_key); - if ((owner.source === "api_key") !== (apiKey !== null) || (owner.source === "admin_copy") !== (owner.admin_audit_id !== null)) return invalidAdminResponse(); - return { resource_id: owner.resource_id as string, api_key: apiKey, source: owner.source, admin_audit_id: owner.admin_audit_id } as AdminResourceOwner; + return { resource_id: owner.resource_id as string, api_key: projectProvenance(owner.api_key) }; }) }; } export function projectWriteOperations(value: unknown): AdminWriteOperationPage { @@ -192,12 +176,12 @@ export function projectAdminAudit(value: unknown): AdminAuditPage { const page = record(value, ["data", "has_more", "next_cursor"]); if (!Array.isArray(page.data) || typeof page.has_more !== "boolean" || typeof page.next_cursor !== "string") return invalidAdminResponse(); const data = page.data.map((entry) => { - const audit = record(entry, ["id", "created_at", "admin_credential_id", "actor_label", "action", "project_id", "resource_type", "resource_id", "result_ids", "request_id", "trace_id"]); + const audit = record(entry, ["id", "created_at", "admin_credential_id", "actor_label", "action", "project_id", "resource_type", "resource_id", "request_id", "trace_id"]); strings(audit, ["id", "created_at", "admin_credential_id", "actor_label", "action", "resource_type", "resource_id", "request_id", "trace_id"]); if (!date(audit.created_at) || !(audit.project_id === null || typeof audit.project_id === "string")) return invalidAdminResponse(); - return { ...audit, result_ids: projectAuditResultIDs(audit.result_ids) }; + return audit; }); - return { data, has_more: page.has_more, next_cursor: page.next_cursor } as AdminAuditPage; + return { data, has_more: page.has_more, next_cursor: page.next_cursor } as unknown as AdminAuditPage; } export function projectExecutorCredentials(value: unknown): ExecutorCredentialList { const page = record(value, ["data", "connection"]); diff --git a/packages/agents-client/src/admin-types.ts b/packages/agents-client/src/admin-types.ts index e09c60f7a..6949f9c4c 100644 --- a/packages/agents-client/src/admin-types.ts +++ b/packages/agents-client/src/admin-types.ts @@ -1,9 +1,4 @@ -import type { - AgentDeleted, AgentSession, AgentTurn, EnvironmentTemplateDeleted, EnvironmentTemplateList, EnvironmentTemplateResource, ListPage, PageOptions, ReadOptions, - RuntimeHistory, RuntimeHistoryQuery, RuntimeObservation, SavedAgent, SessionDeleted, SessionItem, SessionListOptions, SkillContent, SkillDeleted, SkillList, - SkillListOptions, SkillVersionDeleted, SkillVersionList, SourceFileDeleted, SourceFileList, SourceFileListOptions, TolerantSessionList, Vault, - VaultCredentialDeleted, VaultCredentialList, VaultDeleted, VaultList, VaultListOptions, -} from "./types"; +import type { PageOptions } from "./types"; export interface AdminClientOptions { /** Prefix that request paths are appended to; defaults to `/core/v1`. */ @@ -76,14 +71,13 @@ export interface AdminKeyProvenance { id: string; name: string; prefix: string; - kind: "issued" | "static" | "console"; + kind: "issued"; revoked_at: string | null; } +/** `api_key` is null when Core has no creation record. */ export interface AdminResourceOwner { resource_id: string; api_key: AdminKeyProvenance | null; - source: "api_key" | "admin_copy" | null; - admin_audit_id: string | null; } export interface AdminWriteOperation { id: string; @@ -136,11 +130,6 @@ export interface AdminAuditOptions extends Omit>; - retrieveAgent(agentId: string): Promise; - deleteAgent(agentId: string): Promise; - listSkills(options?: SkillListOptions): Promise; - retrieveSkill(skillId: string, options?: ReadOptions): Promise; - deleteSkill(skillId: string, options?: ReadOptions): Promise; - listSkillVersions(skillId: string, options?: SkillListOptions): Promise; - deleteSkillVersion(skillId: string, version: string, options?: ReadOptions): Promise; - downloadSkill(skillId: string, options?: ReadOptions): Promise; - downloadSkillVersion(skillId: string, version: string, options?: ReadOptions): Promise; - listEnvironmentTemplates(options?: PageOptions): Promise; - retrieveEnvironmentTemplate(templateId: string, options?: ReadOptions): Promise; - deleteEnvironmentTemplate(templateId: string, options?: ReadOptions): Promise; - listSourceFiles(options?: SourceFileListOptions): Promise; - deleteSourceFile(fileId: string, options?: ReadOptions): Promise; - listVaults(options?: VaultListOptions): Promise; - retrieveVault(vaultId: string, options?: ReadOptions): Promise; - listVaultCredentials(vaultId: string, options?: VaultListOptions): Promise; - deleteVault(vaultId: string): Promise; - deleteVaultCredential(vaultId: string, credentialId: string): Promise; - listSessions(options?: SessionListOptions): Promise>; - listSessionsTolerant(options?: SessionListOptions): Promise; - retrieveSession(sessionId: string, options?: ReadOptions): Promise; - deleteSession(sessionId: string): Promise; - listTurns(sessionId: string, options?: PageOptions): Promise>; - listItems(sessionId: string, options?: PageOptions): Promise>; - retrieveRuntimeObservation(sessionId: string, options?: ReadOptions): Promise; - retrieveRuntimeHistory(sessionId: string, query: RuntimeHistoryQuery): Promise; -} diff --git a/packages/agents-client/src/client.ts b/packages/agents-client/src/client.ts index c7298d75b..2e4e8e13b 100644 --- a/packages/agents-client/src/client.ts +++ b/packages/agents-client/src/client.ts @@ -60,7 +60,6 @@ import type { InputMessage, ListPage, PageOptions, - PageOrder, ReadOptions, SavedAgent, SavedAgentCore, @@ -90,8 +89,6 @@ import type { SkillVersionUploadOptions, StreamOptions, StreamError, - TolerantSessionList, - UnrecognizedSession, UpdateAgentInput, ReplaceVaultCredentialTokenInput, RuntimeObservation, @@ -260,10 +257,6 @@ const sessionFields = new Set([ "id", "object", "agent", "environment", "status", "error", "metadata", "required_actions", "vault_ids", "usage", "created_at", "last_active_at", ]); -const sessionListFields = new Set(["object", "data", "has_more", "first_id", "last_id"]); -// Core's Session list bound; it defaults to 20. -const maxSessionListLimit = 100; -const defaultSessionListLimit = 20; const agentSnapshotFields = new Set([ "id", "model", "name", "instructions", "multi_agent", "reasoning", "service_tier", "text", "tools", @@ -987,60 +980,6 @@ export function projectAgentSession( return session; } -function invalidSessionList(): never { - throw new AgentCoreError("OpenAgentCore returned an invalid Session list.", 502, "invalid_session_list"); -} - -/** - * Projects a Session page tolerantly. Each entry is projected exactly as a - * retrieved Session; an entry that fails is reported by its page index, with - * its raw ID only when that has Core's Session ID (UUID) form, and nothing - * else of it is kept. The page itself stays strict: its envelope, size, IDs, - * cursors and creation order must be consistent, or the whole page fails. - */ -function projectTolerantSessionList(value: unknown, limit: number, order: PageOrder): TolerantSessionList { - if ( - !isRecord(value) || !exactFields(value, sessionListFields) || value.object !== "list" || - !Array.isArray(value.data) || typeof value.has_more !== "boolean" || value.data.length > limit - ) return invalidSessionList(); - const data: AgentSession[] = []; - const unrecognized: UnrecognizedSession[] = []; - // Each entry's ID in page order; null when an unrecognized entry has none. - const ids: Array = []; - value.data.forEach((entry: unknown, index) => { - try { - const session = projectAgentSession(entry); - data.push(session); - ids.push(session.id); - } catch (error) { - if (!(error instanceof AgentCoreError)) throw error; - const id = isRecord(entry) && typeof entry.id === "string" && canonicalUuid(entry.id) !== null ? entry.id : null; - unrecognized.push({ index, id }); - ids.push(id); - } - }); - const knownIds = ids.filter((id): id is string => id !== null); - // A cursor must be an ID; it must equal its entry's ID whenever that is known. - const boundary = (cursor: unknown, id: string | null | undefined) => - typeof cursor === "string" && cursor.trim() !== "" && (id === null || id === undefined || cursor === id); - if ( - (ids.length === 0 - ? value.first_id !== null || value.last_id !== null || value.has_more - : !boundary(value.first_id, ids[0]) || !boundary(value.last_id, ids.at(-1))) || - new Set(knownIds).size !== knownIds.length || - // Public timestamps are whole seconds, so equal values cannot prove the ID tie-break. - data.some((session, index) => index > 0 && compareCreatedResource(data[index - 1]!, session, order) > 0) - ) return invalidSessionList(); - return { - object: "list", - data, - unrecognized, - has_more: value.has_more, - first_id: value.first_id as string | null, - last_id: value.last_id as string | null, - }; -} - function invalidRuntimeObservation(message = "OpenAgentCore returned an invalid Runtime observation."): never { throw new AgentCoreError(message, 502, "invalid_runtime_observation"); } @@ -2138,7 +2077,7 @@ export class OpenAIAgentsClient implements AgentCore { ); } - async listSessions(options?: PageOptions & { agentId?: string }): Promise> { + async listSessions(options?: SessionListOptions): Promise> { const params = new URLSearchParams(); addPageOptions(params, options); if (options?.agentId) params.set("agent_id", options.agentId); @@ -2149,27 +2088,6 @@ export class OpenAIAgentsClient implements AgentCore { return { ...page, data: page.data.map((session) => projectAgentSession(session)) }; } - /** - * Reads one Session page without letting a malformed Session fail it. - * Recognized Sessions are projected exactly as by listSessions and returned - * in page order; every other entry is reported in `unrecognized`. A - * malformed envelope, cursor or page still fails the whole request, so - * callers paginate with the returned `last_id` and `has_more`. - */ - async listSessionsTolerant(options?: SessionListOptions): Promise { - if ( - (options?.limit !== undefined && ( - !Number.isSafeInteger(options.limit) || options.limit < 1 || options.limit > maxSessionListLimit - )) || - (options?.order !== undefined && options.order !== "asc" && options.order !== "desc") - ) throw new TypeError("Session list limit must be an integer from 1 through 100 and order asc or desc."); - const params = new URLSearchParams(); - addPageOptions(params, options); - if (options?.agentId) params.set("agent_id", options.agentId); - const value = await this.request(withQuery("/agents/sessions", params), { signal: options?.signal }, 200); - return projectTolerantSessionList(value, options?.limit ?? defaultSessionListLimit, options?.order ?? "desc"); - } - async createSession(input: CreateSessionInput, idempotencyKey = createIdempotencyKey()): Promise { if ((input as { stream?: boolean }).stream === true) { throw new TypeError("createSession only supports the JSON response; connect streamEvents after creation."); diff --git a/packages/agents-client/src/core-project-reader.test.ts b/packages/agents-client/src/core-project-reader.test.ts deleted file mode 100644 index 6eff9a34a..000000000 --- a/packages/agents-client/src/core-project-reader.test.ts +++ /dev/null @@ -1,63 +0,0 @@ -import { describe, expect, it, vi } from "vitest"; - -import { AdminClient } from "./admin-client"; -import type { AdminContent, CoreProjectReader } from "./admin-types"; - -async function content(result: Promise) { - const value = await result; - return { data: value.blob, bytes: value.blob.size, content_type: "application/octet-stream" as const, content_disposition: value.contentDisposition ?? "" }; -} - -/** - * The console's project binding (apps/web createProjectClient), written without - * a cast: typechecking this file proves AdminClient's project-bound methods - * satisfy CoreProjectReader. - */ -function projectReader(admin: AdminClient, 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 }; - }; - const client: CoreProjectReader = { - listAgents: (options) => admin.listAgents(projectId, options), - retrieveAgent: (agentId: string) => admin.retrieveAgent(projectId, agentId), - deleteAgent: (agentId: string) => admin.deleteAgent(projectId, agentId), - listSkills: (options) => admin.listSkills(projectId, options), - retrieveSkill: (skillId, options) => admin.retrieveSkill(projectId, skillId, options), - deleteSkill: (skillId, options) => admin.deleteSkill(projectId, skillId, options), - listSkillVersions: (skillId, options) => admin.listSkillVersions(projectId, skillId, options), - deleteSkillVersion: (skillId, version, options) => admin.deleteSkillVersion(projectId, skillId, version, options), - downloadSkill: (skillId, options) => content(admin.downloadSkill(projectId, skillId, options)), - downloadSkillVersion: (skillId, version, options) => content(admin.downloadSkillVersion(projectId, skillId, version, options)), - listEnvironmentTemplates: (options) => admin.listEnvironmentTemplates(projectId, options), - retrieveEnvironmentTemplate: (templateId, options) => admin.retrieveEnvironmentTemplate(projectId, templateId, options), - deleteEnvironmentTemplate: (templateId, options) => admin.deleteEnvironmentTemplate(projectId, templateId, options), - listSourceFiles: (options) => admin.listSourceFiles(projectId, options), - deleteSourceFile: (fileId, options) => admin.deleteSourceFile(projectId, fileId, options), - listVaults: (options) => admin.listVaults(projectId, options), - retrieveVault: (vaultId, options) => admin.retrieveVault(projectId, vaultId, options), - listVaultCredentials: (vaultId, options) => admin.listVaultCredentials(projectId, vaultId, options), - deleteVault: (vaultId) => admin.deleteVault(projectId, vaultId), - deleteVaultCredential: (vaultId, credentialId) => admin.deleteVaultCredential(projectId, vaultId, credentialId), - listSessions, - listSessionsTolerant: async (options) => ({ ...(await listSessions(options)), unrecognized: [] }), - retrieveSession: (sessionId, options) => admin.retrieveSession(projectId, sessionId, options), - deleteSession: (sessionId) => admin.deleteSession(projectId, sessionId), - listTurns: (sessionId, options) => admin.listTurns(projectId, sessionId, options), - listItems: (sessionId, options) => admin.listItems(projectId, sessionId, options), - retrieveRuntimeObservation: (sessionId, options) => admin.retrieveRuntimeObservation(projectId, sessionId, options), - retrieveRuntimeHistory: (sessionId, query) => admin.retrieveRuntimeHistory(projectId, sessionId, query), - }; - return client; -} - -describe("CoreProjectReader", () => { - it("binds AdminClient to one project under /core/v1/projects without a cast", async () => { - const projectId = "66666666-6666-4666-8666-666666666666"; - const fetch = vi.fn().mockImplementation(async () => new Response(JSON.stringify({ object: "list", data: [], has_more: false, first_id: null, last_id: null }))); - const reader = projectReader(new AdminClient({ fetch }), projectId); - await expect(reader.listSkills()).resolves.toEqual({ object: "list", data: [], has_more: false, first_id: null, last_id: null }); - await reader.listSourceFiles({ purpose: "user_data" }); - expect(fetch.mock.calls.map(([url]) => url)).toEqual([`/core/v1/projects/${projectId}/skills`, `/core/v1/projects/${projectId}/files?purpose=user_data`]); - }); -}); diff --git a/packages/agents-client/src/index.ts b/packages/agents-client/src/index.ts index c65bc9604..b58933aa3 100644 --- a/packages/agents-client/src/index.ts +++ b/packages/agents-client/src/index.ts @@ -12,6 +12,6 @@ export { isOpenAIHostedSessionEnvironment } from "./session-environment-projecti export { compareSkillVersionNumbers, isSkillId, isSkillUploadPath, isSkillVersionId, isSkillVersionNumber, maxSkillUploadFiles } from "./skill-projection"; export { AdminClient } from "./admin-client"; // Skill and SkillVersion come from ./types; the admin projections use the same shapes. -export type { AdminClientOptions, AdminProject, CreateAdminProjectInput, RenameAdminProjectInput, AdminAPIKey, AdminIssuedAPIKey, IssueAdminAPIKeyInput, AdminPage, AdminDeleted, SessionArtifact, AdminContent, AdminResourceType, AdminKeyProvenance, AdminResourceOwner, AdminWriteOperation, AdminWriteOperationOptions, AdminWriteOperationPage, AdminSummaryOptions, AdminSummaryEntry, AdminSummary, AdminRuntimeObservation, AdminAuditOptions, AdminAuditResultID, AdminAuditEntry, AdminAuditPage, ExecutorCredential, ExecutorConnection, ExecutorCredentialList, IssueExecutorCredentialInput, IssuedExecutorCredential, CoreProjectReader, CoreInstallation, CoreInstallationConfiguration, CoreInstallationSetting, CoreAddressBindings } from "./admin-types"; +export type { AdminClientOptions, AdminProject, CreateAdminProjectInput, RenameAdminProjectInput, AdminAPIKey, AdminIssuedAPIKey, IssueAdminAPIKeyInput, AdminPage, AdminDeleted, SessionArtifact, AdminContent, AdminResourceType, AdminKeyProvenance, AdminResourceOwner, AdminWriteOperation, AdminWriteOperationOptions, AdminWriteOperationPage, AdminSummaryOptions, AdminSummaryEntry, AdminSummary, AdminRuntimeObservation, AdminAuditOptions, AdminAuditEntry, AdminAuditPage, ExecutorCredential, ExecutorConnection, ExecutorCredentialList, IssueExecutorCredentialInput, IssuedExecutorCredential, CoreInstallation, CoreInstallationConfiguration, CoreInstallationSetting, CoreAddressBindings } from "./admin-types"; export type { DiagnosticFailureCode, NativeFailureCode, ConnectionFailureParams, ProvisioningFailureParams, DiagnosticFailure, SessionDiagnosticFailure, SessionDiagnostics, ItemDiagnosticTiming, TurnDiagnostics } from "./session-diagnostics"; diff --git a/packages/agents-client/src/sessions-list.test.ts b/packages/agents-client/src/sessions-list.test.ts deleted file mode 100644 index c7197d9d5..000000000 --- a/packages/agents-client/src/sessions-list.test.ts +++ /dev/null @@ -1,186 +0,0 @@ -import { describe, expect, it } from "vitest"; - -import { OpenAIAgentsClient } from "./client"; -import templates from "./fixtures/parsar-d3f55046/environment-templates.json"; - -interface FetchCall { - input: RequestInfo | URL; - init?: RequestInit; -} - -function recordingClient(...bodies: unknown[]): { client: OpenAIAgentsClient; calls: FetchCall[] } { - const calls: FetchCall[] = []; - const client = new OpenAIAgentsClient({ - token: "test-token", - fetch: (async (input: RequestInfo | URL, init?: RequestInit) => { - calls.push({ input, init }); - const body = bodies[Math.min(calls.length - 1, bodies.length - 1)]; - return new Response(JSON.stringify(body), { status: 200, headers: { "Content-Type": "application/json" } }); - }) as typeof fetch, - }); - return { client, calls }; -} - -const ids = [ - "11111111-1111-4111-8111-111111111111", - "22222222-2222-4222-8222-222222222222", - "33333333-3333-4333-8333-333333333333", - "44444444-4444-4444-8444-444444444444", - "55555555-5555-4555-8555-555555555555", -] as const; - -function session(id: string, createdAt: number, overrides: Record = {}): Record { - return { - id, - object: "agent.session", - agent: { - id: "agent", - model: "provider/model", - name: null, - instructions: null, - multi_agent: { enabled: false, max_concurrent_subagents: null }, - reasoning: {}, - service_tier: "auto", - text: { format: { type: "text" }, verbosity: "medium" }, - tools: [], - }, - environment: { type: "none" }, - status: "idle", - error: null, - metadata: {}, - required_actions: [], - vault_ids: [], - usage: null, - created_at: createdAt, - last_active_at: createdAt, - ...overrides, - }; -} - -/** A Core page whose cursors name the first and last entries as returned. */ -function page(data: unknown[], hasMore = false): Record { - const entryId = (entry: unknown) => (entry as { id?: unknown } | null)?.id ?? null; - return { - object: "list", - data, - has_more: hasMore, - first_id: data.length > 0 ? entryId(data[0]) : null, - last_id: data.length > 0 ? entryId(data.at(-1)) : null, - }; -} - -const newest = session(ids[0], 500); -const malformed = session(ids[1], 400, { status: "paused" }); -const older = session(ids[2], 300); - -describe("tolerant Session list", () => { - it("keeps the other Sessions of a page and reports only the malformed entry", async () => { - const { client, calls } = recordingClient(page([newest, malformed, older])); - - const listed = await client.listSessionsTolerant({ limit: 3, agentId: "agent" }); - - expect(String(calls[0]?.input)).toBe("/v1/agents/sessions?limit=3&agent_id=agent"); - expect(new Headers(calls[0]?.init?.headers).get("OpenAI-Beta")).toBe("agents=v1"); - expect(listed.data.map((entry) => entry.id)).toEqual([ids[0], ids[2]]); - expect(listed.unrecognized).toEqual([{ index: 1, id: ids[1] }]); - expect(listed).toMatchObject({ object: "list", has_more: false, first_id: ids[0], last_id: ids[2] }); - expect(JSON.stringify(listed)).not.toContain("paused"); - }); - - it("reports an entry without a Session ID by index only", async () => { - const { client } = recordingClient(page([ - newest, - { ...older, id: "notes.txt", object: "file" }, - null, - session(ids[3], 200), - ])); - - const listed = await client.listSessionsTolerant(); - - expect(listed.data.map((entry) => entry.id)).toEqual([ids[0], ids[3]]); - expect(listed.unrecognized).toEqual([{ index: 1, id: null }, { index: 2, id: null }]); - }); - - it("continues pagination from a page that ends in an unrecognized Session", async () => { - const trailing = session(ids[2], 300, { environment: { type: "openai_hosted", id: "environment" } }); - const { client, calls } = recordingClient( - page([newest, session(ids[1], 400), trailing], true), - page([session(ids[3], 200), session(ids[4], 100)]), - ); - - const first = await client.listSessionsTolerant({ limit: 3 }); - const second = await client.listSessionsTolerant({ limit: 3, after: first.last_id! }); - - expect(first.unrecognized).toEqual([{ index: 2, id: ids[2] }]); - expect(first).toMatchObject({ has_more: true, last_id: ids[2] }); - expect(String(calls[1]?.input)).toBe(`/v1/agents/sessions?after=${ids[2]}&limit=3`); - expect(second.data.map((entry) => entry.id)).toEqual([ids[3], ids[4]]); - expect(second.unrecognized).toEqual([]); - expect(second.has_more).toBe(false); - }); - - it("keeps a page of only unrecognized Sessions readable", async () => { - const unknownFirst = { ...newest, future: true }; - const idless = { ...older, id: 7 }; - const { client } = recordingClient({ object: "list", data: [unknownFirst, idless], has_more: true, first_id: ids[0], last_id: ids[4] }); - - const listed = await client.listSessionsTolerant(); - - expect(listed.data).toEqual([]); - expect(listed.unrecognized).toEqual([{ index: 0, id: ids[0] }, { index: 1, id: null }]); - expect(listed.last_id).toBe(ids[4]); - }); - - it("recognizes a Session created from an advanced Template", async () => { - const environment = templates.responses.session_environment_from_advanced_template.body; - const { client } = recordingClient(page([session(ids[0], 500, { environment })])); - - const listed = await client.listSessionsTolerant(); - - expect(listed.unrecognized).toEqual([]); - expect(listed.data[0]?.environment).toEqual(environment); - }); - - it.each([ - ["a missing has_more", { object: "list", data: [newest], first_id: ids[0], last_id: ids[0] }], - ["an unexpected envelope field", { ...page([newest]), next: null }], - ["another object type", { ...page([newest]), object: "page" }], - ["data that is not a list", { ...page([]), data: {} }], - ["a first_id that differs from the first Session", { ...page([newest, older]), first_id: ids[2] }], - ["a last_id that differs from an unrecognized last entry", { ...page([newest, malformed]), last_id: ids[0] }], - ["a missing cursor beside an unidentified entry", { ...page([newest, null]), last_id: null }], - ["cursors on an empty page", { ...page([]), first_id: ids[0] }], - ["more after an empty page", page([], true)], - ["a duplicate Session", page([newest, newest])], - ["a duplicate unrecognized Session", page([newest, { ...malformed, id: ids[0] }])], - ["Sessions out of creation order", page([older, newest])], - ])("still fails the whole page for %s", async (_label, body) => { - const { client } = recordingClient(body); - - await expect(client.listSessionsTolerant()).rejects.toMatchObject({ status: 502, code: "invalid_session_list" }); - }); - - it("checks the page size and order against the request", async () => { - const oversized = recordingClient(page([newest, older])); - await expect(oversized.client.listSessionsTolerant({ limit: 1 })).rejects.toMatchObject({ code: "invalid_session_list" }); - - const ascending = recordingClient(page([older, newest])); - await expect(ascending.client.listSessionsTolerant({ order: "asc" })).resolves.toMatchObject({ data: [{ id: ids[2] }, { id: ids[0] }] }); - }); - - it.each([[{ limit: 0 }], [{ limit: 101 }], [{ limit: 1.5 }], [{ order: "newest" }]])( - "refuses %j before any request", - async (options) => { - const { client, calls } = recordingClient(page([])); - - await expect(client.listSessionsTolerant(options as never)).rejects.toBeInstanceOf(TypeError); - expect(calls).toHaveLength(0); - }, - ); - - it("leaves the strict list failing on the same malformed Session", async () => { - const { client } = recordingClient(page([newest, malformed, older])); - - await expect(client.listSessions()).rejects.toMatchObject({ status: 502, code: "invalid_session_resource" }); - }); -}); diff --git a/packages/agents-client/src/types.ts b/packages/agents-client/src/types.ts index 56346dee0..bb75dc520 100644 --- a/packages/agents-client/src/types.ts +++ b/packages/agents-client/src/types.ts @@ -696,32 +696,6 @@ export interface SessionListOptions extends PageOptions { agentId?: string; } -/** - * A listed Session this client does not recognize, for example one with an - * unknown field or value. Nothing of it is kept or guessed beyond its position - * and, when it has Core's Session ID form, its raw ID. - */ -export interface UnrecognizedSession { - /** Position of the entry in the page as Core returned it. */ - index: number; - /** Raw ID when it is a Session ID (a UUID); otherwise null. */ - id: string | null; -} - -/** - * One Session page read tolerantly: `data` holds the recognized Sessions in - * page order and `unrecognized` every other entry. The envelope and cursors - * are Core's own; `first_id` and `last_id` may name an unrecognized entry. - */ -export interface TolerantSessionList { - object: "list"; - data: AgentSession[]; - unrecognized: UnrecognizedSession[]; - has_more: boolean; - first_id: string | null; - last_id: string | null; -} - /** Common preparation for both managed and user-owned Runtime locations. */ export interface EnvironmentCapabilityArchiveInput { type: "inline"; @@ -1330,9 +1304,7 @@ export interface AgentCore { retrieveVaultCredential(vaultId: string, credentialId: string, options?: ReadOptions): Promise; replaceVaultCredentialToken(vaultId: string, credentialId: string, input: ReplaceVaultCredentialTokenInput): Promise; deleteVaultCredential(vaultId: string, credentialId: string): Promise; - listSessions(options?: PageOptions & { agentId?: string }): Promise>; - /** Like listSessions, but a malformed Session is reported instead of failing the page. */ - listSessionsTolerant(options?: SessionListOptions): Promise; + listSessions(options?: SessionListOptions): Promise>; createSession(input: CreateSessionInput, idempotencyKey?: string): Promise; createSessionStream( input: Omit, diff --git a/scripts/build-core-distribution.sh b/scripts/build-core-distribution.sh index e2db4d6dd..ac40a609a 100755 --- a/scripts/build-core-distribution.sh +++ b/scripts/build-core-distribution.sh @@ -144,7 +144,7 @@ docker run --rm --network none --entrypoint /bin/sh \ OAC_DEV_WEB_BUILD_DIR="$stage/web" scripts/build-web.sh pnpm --filter @oac/web... install --frozen-lockfile -OAC_WEB_OPENAI_HOSTED_SESSIONS=1 OAC_WEB_ENVIRONMENT_FILES=1 pnpm build:web +pnpm build:web cp -R apps/web/dist "$stage/web/dist" cp services/web/Dockerfile "$stage/web/Dockerfile" build_image web "$stage/web" diff --git a/services/core/internal/adminaudit/reader.go b/services/core/internal/adminaudit/reader.go index 1c4408259..81265e682 100644 --- a/services/core/internal/adminaudit/reader.go +++ b/services/core/internal/adminaudit/reader.go @@ -2,7 +2,6 @@ package adminaudit import ( "context" - "encoding/json" "errors" "time" @@ -30,17 +29,16 @@ type Filter struct { // Operation is one administrator write. ProjectID is null for // deployment-wide writes, such as deployment default model providers. type Operation struct { - ID string `json:"id"` - CreatedAt time.Time `json:"created_at"` - AdminCredentialID string `json:"admin_credential_id"` - ActorLabel string `json:"actor_label"` - Action string `json:"action"` - ProjectID *string `json:"project_id" extensions:"x-nullable"` - ResourceType string `json:"resource_type"` - ResourceID string `json:"resource_id"` - ResultIDs json.RawMessage `json:"result_ids" swaggertype:"array,object"` - RequestID string `json:"request_id"` - TraceID string `json:"trace_id"` + ID string `json:"id"` + CreatedAt time.Time `json:"created_at"` + AdminCredentialID string `json:"admin_credential_id"` + ActorLabel string `json:"actor_label"` + Action string `json:"action"` + ProjectID *string `json:"project_id" extensions:"x-nullable"` + ResourceType string `json:"resource_type"` + ResourceID string `json:"resource_id"` + RequestID string `json:"request_id"` + TraceID string `json:"trace_id"` } type Page struct { diff --git a/services/core/internal/db/queries/admin_audit.sql b/services/core/internal/db/queries/admin_audit.sql index 57d133e76..7b54205f5 100644 --- a/services/core/internal/db/queries/admin_audit.sql +++ b/services/core/internal/db/queries/admin_audit.sql @@ -1,4 +1,4 @@ -- name: InsertAdminAudit :one -INSERT INTO admin_audit_log (id,tenant_id,admin_credential_id,actor_label,action,project_id,resource_type,resource_id,result_ids,request_id,trace_id) -VALUES ($1,$2,$3,$4,$5,$6,$7,$8,$9,$10,$11) +INSERT INTO admin_audit_log (id,tenant_id,admin_credential_id,actor_label,action,project_id,resource_type,resource_id,request_id,trace_id) +VALUES ($1,$2,$3,$4,$5,$6,$7,$8,$9,$10) RETURNING id; diff --git a/services/core/internal/db/queries/admin_history.sql b/services/core/internal/db/queries/admin_history.sql index 79e0aadd2..38c69cfe7 100644 --- a/services/core/internal/db/queries/admin_history.sql +++ b/services/core/internal/db/queries/admin_history.sql @@ -11,7 +11,3 @@ ORDER BY created_at DESC,id DESC LIMIT sqlc.arg(page_limit); -- name: AdminAuditCursor :one SELECT created_at FROM admin_audit_log WHERE id=$1; - --- name: GetAdminResourceOwners :many -SELECT resource_id,audit_id FROM admin_resource_owners -WHERE tenant_id=$1 AND resource_type=$2 AND resource_id=ANY($3::text[]); diff --git a/services/core/internal/db/sqlc/admin_audit.sql.go b/services/core/internal/db/sqlc/admin_audit.sql.go index 534e5b7b7..74a6e7d91 100644 --- a/services/core/internal/db/sqlc/admin_audit.sql.go +++ b/services/core/internal/db/sqlc/admin_audit.sql.go @@ -12,8 +12,8 @@ import ( ) const insertAdminAudit = `-- name: InsertAdminAudit :one -INSERT INTO admin_audit_log (id,tenant_id,admin_credential_id,actor_label,action,project_id,resource_type,resource_id,result_ids,request_id,trace_id) -VALUES ($1,$2,$3,$4,$5,$6,$7,$8,$9,$10,$11) +INSERT INTO admin_audit_log (id,tenant_id,admin_credential_id,actor_label,action,project_id,resource_type,resource_id,request_id,trace_id) +VALUES ($1,$2,$3,$4,$5,$6,$7,$8,$9,$10) RETURNING id ` @@ -26,7 +26,6 @@ type InsertAdminAuditParams struct { ProjectID pgtype.UUID `json:"project_id"` ResourceType string `json:"resource_type"` ResourceID string `json:"resource_id"` - ResultIds []byte `json:"result_ids"` RequestID string `json:"request_id"` TraceID string `json:"trace_id"` } @@ -41,7 +40,6 @@ func (q *Queries) InsertAdminAudit(ctx context.Context, arg InsertAdminAuditPara arg.ProjectID, arg.ResourceType, arg.ResourceID, - arg.ResultIds, arg.RequestID, arg.TraceID, ) diff --git a/services/core/internal/db/sqlc/admin_history.sql.go b/services/core/internal/db/sqlc/admin_history.sql.go index 3dc787916..fdc4b6ee2 100644 --- a/services/core/internal/db/sqlc/admin_history.sql.go +++ b/services/core/internal/db/sqlc/admin_history.sql.go @@ -22,44 +22,8 @@ func (q *Queries) AdminAuditCursor(ctx context.Context, id pgtype.UUID) (pgtype. return created_at, err } -const getAdminResourceOwners = `-- name: GetAdminResourceOwners :many -SELECT resource_id,audit_id FROM admin_resource_owners -WHERE tenant_id=$1 AND resource_type=$2 AND resource_id=ANY($3::text[]) -` - -type GetAdminResourceOwnersParams struct { - TenantID pgtype.UUID `json:"tenant_id"` - ResourceType string `json:"resource_type"` - Column3 []string `json:"column_3"` -} - -type GetAdminResourceOwnersRow struct { - ResourceID string `json:"resource_id"` - AuditID pgtype.UUID `json:"audit_id"` -} - -func (q *Queries) GetAdminResourceOwners(ctx context.Context, arg GetAdminResourceOwnersParams) ([]GetAdminResourceOwnersRow, error) { - rows, err := q.db.Query(ctx, getAdminResourceOwners, arg.TenantID, arg.ResourceType, arg.Column3) - if err != nil { - return nil, err - } - defer rows.Close() - items := []GetAdminResourceOwnersRow{} - for rows.Next() { - var i GetAdminResourceOwnersRow - if err := rows.Scan(&i.ResourceID, &i.AuditID); err != nil { - return nil, err - } - items = append(items, i) - } - if err := rows.Err(); err != nil { - return nil, err - } - return items, nil -} - const listAdminAuditLog = `-- name: ListAdminAuditLog :many -SELECT id, tenant_id, project_id, admin_credential_id, actor_label, action, resource_type, resource_id, result_ids, request_id, trace_id, created_at FROM admin_audit_log +SELECT id, tenant_id, project_id, admin_credential_id, actor_label, action, resource_type, resource_id, request_id, trace_id, created_at FROM admin_audit_log WHERE ($1::text='' OR project_id::text=$1) AND ($2::text='' OR resource_type=$2) AND ($3::text='' OR resource_id=$3) @@ -110,7 +74,6 @@ func (q *Queries) ListAdminAuditLog(ctx context.Context, arg ListAdminAuditLogPa &i.Action, &i.ResourceType, &i.ResourceID, - &i.ResultIds, &i.RequestID, &i.TraceID, &i.CreatedAt, diff --git a/services/core/internal/db/sqlc/models.go b/services/core/internal/db/sqlc/models.go index eca6abc6f..134170554 100644 --- a/services/core/internal/db/sqlc/models.go +++ b/services/core/internal/db/sqlc/models.go @@ -8,14 +8,6 @@ import ( "github.com/jackc/pgx/v5/pgtype" ) -type AdminAssetCopy struct { - TargetTenantID pgtype.UUID `json:"target_tenant_id"` - IdempotencyKey string `json:"idempotency_key"` - RequestHash []byte `json:"request_hash"` - Result []byte `json:"result"` - AuditID pgtype.UUID `json:"audit_id"` -} - type AdminAuditLog struct { ID pgtype.UUID `json:"id"` TenantID pgtype.UUID `json:"tenant_id"` @@ -25,20 +17,11 @@ type AdminAuditLog struct { Action string `json:"action"` ResourceType string `json:"resource_type"` ResourceID string `json:"resource_id"` - ResultIds []byte `json:"result_ids"` RequestID string `json:"request_id"` TraceID string `json:"trace_id"` CreatedAt pgtype.Timestamptz `json:"created_at"` } -type AdminResourceOwner struct { - TenantID pgtype.UUID `json:"tenant_id"` - ResourceType string `json:"resource_type"` - ResourceID string `json:"resource_id"` - ParentID string `json:"parent_id"` - AuditID pgtype.UUID `json:"audit_id"` -} - type Agent struct { ID pgtype.UUID `json:"id"` TenantID pgtype.UUID `json:"tenant_id"` diff --git a/services/core/internal/persistence/postgres/agentpg/store_test.go b/services/core/internal/persistence/postgres/agentpg/store_test.go index f0f4bdcee..2d30e5b2e 100644 --- a/services/core/internal/persistence/postgres/agentpg/store_test.go +++ b/services/core/internal/persistence/postgres/agentpg/store_test.go @@ -511,8 +511,8 @@ func TestAgentModelExecutionConcurrentSnapshots(t *testing.T) { func auditContext(ctx context.Context, tenant, request, key string) context.Context { return writeaudit.WithSource(ctx, writeaudit.Source{ - KeyID: "static:" + strings.Repeat(key, 64), Name: "agent audit fixture", Prefix: strings.Repeat(key, 8), - Kind: "static", TenantID: tenant, RequestID: request, TraceID: "agent-audit-trace", + KeyID: strings.ReplaceAll("xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx", "x", key), Name: "agent audit fixture", Prefix: "pc_" + strings.Repeat(key, 8), + Kind: "issued", TenantID: tenant, RequestID: request, TraceID: "agent-audit-trace", }) } @@ -688,7 +688,7 @@ func TestAgentWriteRejectsInvalidAuditSource(t *testing.T) { pool := pgtest.Open(t) _, service := open(t, pool, nil) tenant := uuid.NewString() - ctx := writeaudit.WithSource(t.Context(), writeaudit.Source{KeyID: "static:" + strings.Repeat("a", 64), Prefix: "aaaaaaaa", Kind: "static", TenantID: tenant, RequestID: uuid.NewString()}) + ctx := writeaudit.WithSource(t.Context(), writeaudit.Source{KeyID: "aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa", Prefix: "pc_aaaaaaaa", Kind: "issued", TenantID: tenant, RequestID: uuid.NewString()}) if _, err := service.Create(ctx, agents.CreateCommand{TenantID: tenant, Configuration: []byte(`{"model":"x"}`)}); !errors.Is(err, writeaudit.ErrInvalidSource) { t.Fatalf("invalid source accepted: %v", err) } diff --git a/services/core/internal/persistence/postgres/auditpg/admin_audit.go b/services/core/internal/persistence/postgres/auditpg/admin_audit.go index 61e8dfea5..ff0dc6372 100644 --- a/services/core/internal/persistence/postgres/auditpg/admin_audit.go +++ b/services/core/internal/persistence/postgres/auditpg/admin_audit.go @@ -55,7 +55,7 @@ func (s *Store) ListAdminAudit(ctx context.Context, filter adminaudit.Filter) (a rows = rows[:filter.Limit] } for _, row := range rows { - page.Data = append(page.Data, adminaudit.Operation{ID: uuid.UUID(row.ID.Bytes).String(), CreatedAt: row.CreatedAt.Time, AdminCredentialID: row.AdminCredentialID, ActorLabel: row.ActorLabel, Action: row.Action, ProjectID: projectID(row.ProjectID), ResourceType: row.ResourceType, ResourceID: row.ResourceID, ResultIDs: row.ResultIds, RequestID: row.RequestID, TraceID: row.TraceID}) + page.Data = append(page.Data, adminaudit.Operation{ID: uuid.UUID(row.ID.Bytes).String(), CreatedAt: row.CreatedAt.Time, AdminCredentialID: row.AdminCredentialID, ActorLabel: row.ActorLabel, Action: row.Action, ProjectID: projectID(row.ProjectID), ResourceType: row.ResourceType, ResourceID: row.ResourceID, RequestID: row.RequestID, TraceID: row.TraceID}) } if page.HasMore { page.NextCursor = encodeCursor(page.Data[len(page.Data)-1].ID, scope) diff --git a/services/core/internal/persistence/postgres/auditpg/auditpg_test.go b/services/core/internal/persistence/postgres/auditpg/auditpg_test.go index bc079bd71..1c4e52a38 100644 --- a/services/core/internal/persistence/postgres/auditpg/auditpg_test.go +++ b/services/core/internal/persistence/postgres/auditpg/auditpg_test.go @@ -29,10 +29,8 @@ func openAudit(t *testing.T) (*pgunit.Pool, *auditpg.Store) { func uuidOf(id string) pgtype.UUID { return pgtype.UUID{Bytes: uuid.MustParse(id), Valid: true} } -func staticSource(tenant string) writeaudit.Source { - sum := sha256.Sum256([]byte(uuid.NewString())) - digest := hex.EncodeToString(sum[:]) - return writeaudit.Source{KeyID: "static:" + digest, Prefix: digest[:8], Name: "test key", Kind: "static", TenantID: tenant, RequestID: uuid.NewString(), TraceID: uuid.NewString()} +func issuedSource(tenant string) writeaudit.Source { + return writeaudit.Source{KeyID: uuid.NewString(), Prefix: "pc_" + uuid.NewString()[:8], Name: "test key", Kind: "issued", TenantID: tenant, RequestID: uuid.NewString(), TraceID: uuid.NewString()} } func adminSource(projectID string) adminaudit.Source { @@ -101,7 +99,7 @@ func exec(t *testing.T, pool *pgunit.Pool, sql string, args ...any) { func TestWriteAuditCommitsAndRollsBackWithTheBusinessWrite(t *testing.T) { pool, audit := openAudit(t) tenant := uuid.NewString() - source := staticSource(tenant) + source := issuedSource(tenant) for _, failure := range []string{"", "after_audit", "invalid_source", "database_audit_failure"} { t.Run(failure, func(t *testing.T) { id := uuid.NewString() @@ -169,7 +167,7 @@ func TestWriteWithoutProvenanceStaysUnattributed(t *testing.T) { t.Fatalf("unattributed write: %+v %v", page, err) } owners, err := audit.GetResourceOwners(t.Context(), tenant, "agent", []string{id}) - if err != nil || owners[0].APIKey != nil || owners[0].Source != nil { + if err != nil || owners[0].APIKey != nil { t.Fatalf("unattributed owner: %+v %v", owners, err) } } @@ -177,7 +175,7 @@ func TestWriteWithoutProvenanceStaysUnattributed(t *testing.T) { // Malformed provenance fails closed before any statement runs, so a nil q // shows that nothing reached the database. func TestMalformedProvenanceFailsClosed(t *testing.T) { - valid := staticSource(uuid.NewString()) + valid := issuedSource(uuid.NewString()) for _, field := range []string{"tenant", "key", "prefix", "kind", "request", "trace", "name", "action", "resource_type", "created_type", "resource_id"} { source, action, kind, id := valid, "create", "agent", "resource" created := []writeaudit.Resource{{Type: "agent", ID: "resource"}} @@ -185,11 +183,11 @@ func TestMalformedProvenanceFailsClosed(t *testing.T) { case "tenant": source.TenantID = uuid.NewString() case "key": - source.KeyID = "static:abcd" + source.KeyID = "key" case "prefix": source.Prefix = "bad" case "kind": - source.Kind = "unknown" + source.Kind = "static" case "request": source.RequestID = "" case "trace": @@ -238,7 +236,7 @@ func TestAdministratorProvenance(t *testing.T) { pool, audit := openAudit(t) p := createProject(t, pool) id := uuid.NewString() - ctx := adminaudit.WithSource(writeaudit.WithSource(t.Context(), staticSource(p.tenant)), adminSource(p.id)) + ctx := adminaudit.WithSource(writeaudit.WithSource(t.Context(), issuedSource(p.tenant)), adminSource(p.id)) if err := record(t, pool, ctx, func(ctx context.Context, q *sqlc.Queries) error { if err := createAgent(ctx, q, p.tenant, id); err != nil { return err @@ -283,14 +281,13 @@ func TestAdministratorProvenance(t *testing.T) { func TestWriteAuditOwnersIdentityReplayAndRevocation(t *testing.T) { pool, audit := openAudit(t) tenant := uuid.NewString() - a := staticSource(tenant) + a := issuedSource(tenant) id, implicit := uuid.NewString(), uuid.NewString() recordWrite(t, pool, a, "create", "session", id, writeaudit.Resource{Type: "session", ID: id}, writeaudit.Resource{Type: "environment", ID: implicit, ParentID: id}) // Same request may reach a commit receipt twice but cannot create another owner. replayID := uuid.NewString() recordWrite(t, pool, a, "create", "session", id, writeaudit.Resource{Type: "session", ID: replayID}) - b := staticSource(tenant) - b.Kind = "console" + b := issuedSource(tenant) recordWrite(t, pool, b, "update", "session", id) owners, err := audit.GetResourceOwners(t.Context(), tenant, "session", []string{replayID, id, id, "historical"}) if err != nil || len(owners) != 4 || owners[0].APIKey != nil || owners[1].APIKey.ID != a.KeyID || owners[2].APIKey.ID != a.KeyID || owners[3].APIKey != nil { @@ -305,7 +302,7 @@ func TestWriteAuditOwnersIdentityReplayAndRevocation(t *testing.T) { t.Fatalf("foreign owner: %+v %v", foreign, err) } page, err := audit.ListWriteOperations(t.Context(), tenant, writeaudit.Filter{ResourceID: id}) - if err != nil || len(page.Data) != 2 || page.Data[0].APIKey.Kind != "console" || page.Data[1].APIKey.ID != a.KeyID { + if err != nil || len(page.Data) != 2 || page.Data[0].APIKey.ID != b.KeyID || page.Data[1].APIKey.ID != a.KeyID { t.Fatalf("request dedup or key identity: %+v %v", page, err) } p := createProject(t, pool) @@ -317,8 +314,8 @@ func TestWriteAuditOwnersIdentityReplayAndRevocation(t *testing.T) { }); err != nil { t.Fatal(err) } - c := staticSource(p.tenant) - c.KeyID, c.Name, c.Prefix, c.Kind = keyID, "issued key", "pc_"+hex.EncodeToString(sum[:4]), "issued" + c := issuedSource(p.tenant) + c.KeyID, c.Name, c.Prefix = keyID, "issued key", "pc_"+hex.EncodeToString(sum[:4]) fileID := "file_" + uuid.NewString() recordWrite(t, pool, c, "create", "file", fileID, writeaudit.Resource{Type: "file", ID: fileID}) if err := record(t, pool, t.Context(), func(ctx context.Context, q *sqlc.Queries) error { @@ -337,28 +334,10 @@ func TestWriteAuditOwnersIdentityReplayAndRevocation(t *testing.T) { } } -// The copy operation was removed; its committed provenance must stay readable. -func TestHistoricalAdminCopyProvenance(t *testing.T) { - pool, audit := openAudit(t) - p := createProject(t, pool) - auditID, agentID := uuid.NewString(), uuid.NewString() - exec(t, pool, `INSERT INTO admin_audit_log(id,tenant_id,project_id,admin_credential_id,actor_label,action,resource_type,resource_id,result_ids,request_id,trace_id) - VALUES($1,$2,$3,'digest','admin','copy','agent','source-agent',$4::jsonb,'request','trace')`, auditID, p.tenant, p.id, `[{"type":"agent","source_id":"source-agent","target_id":"`+agentID+`"}]`) - exec(t, pool, "INSERT INTO admin_resource_owners(tenant_id,resource_type,resource_id,audit_id) VALUES($1,'agent',$2,$3)", p.tenant, agentID, auditID) - owners, err := audit.GetResourceOwners(t.Context(), p.tenant, "agent", []string{agentID}) - if err != nil || len(owners) != 1 || owners[0].APIKey != nil || owners[0].Source == nil || *owners[0].Source != "admin_copy" || owners[0].AdminAuditID == nil || *owners[0].AdminAuditID != auditID { - t.Fatalf("historical copy owner: %+v %v", owners, err) - } - page, err := audit.ListAdminAudit(t.Context(), adminaudit.Filter{ProjectID: p.id, Action: "copy"}) - if err != nil || len(page.Data) != 1 || page.Data[0].ID != auditID || !strings.Contains(string(page.Data[0].ResultIDs), agentID) { - t.Fatalf("historical copy audit: %+v %v", page, err) - } -} - func TestWriteAuditCursorFiltersAndRetention(t *testing.T) { pool, audit := openAudit(t) tenant, id := uuid.NewString(), uuid.NewString() - source := staticSource(tenant) + source := issuedSource(tenant) if err := record(t, pool, t.Context(), func(ctx context.Context, q *sqlc.Queries) error { return createAgent(ctx, q, tenant, id) }); err != nil { t.Fatal(err) } diff --git a/services/core/internal/persistence/postgres/auditpg/record.go b/services/core/internal/persistence/postgres/auditpg/record.go index 7543c214c..651afe523 100644 --- a/services/core/internal/persistence/postgres/auditpg/record.go +++ b/services/core/internal/persistence/postgres/auditpg/record.go @@ -80,8 +80,7 @@ func RecordAdminMutation(ctx context.Context, q *sqlc.Queries, tenant, action, r if err != nil { return adminaudit.ErrInvalidSource } - // result_ids is retained for historical copy mappings; current writes record none. - _, err = q.InsertAdminAudit(ctx, sqlc.InsertAdminAuditParams{ID: pgtype.UUID{Bytes: uuid.New(), Valid: true}, TenantID: tenantID, AdminCredentialID: source.CredentialID, ActorLabel: source.ActorLabel, Action: action, ProjectID: projectID, ResourceType: resourceType, ResourceID: resourceID, ResultIds: []byte(`[]`), RequestID: source.RequestID, TraceID: source.TraceID}) + _, err = q.InsertAdminAudit(ctx, sqlc.InsertAdminAuditParams{ID: pgtype.UUID{Bytes: uuid.New(), Valid: true}, TenantID: tenantID, AdminCredentialID: source.CredentialID, ActorLabel: source.ActorLabel, Action: action, ProjectID: projectID, ResourceType: resourceType, ResourceID: resourceID, RequestID: source.RequestID, TraceID: source.TraceID}) return err } @@ -96,6 +95,6 @@ func RecordDeploymentMutation(ctx context.Context, q *sqlc.Queries, action, reso if err := source.ValidateDeploymentMutation(action, resourceType, resourceID); err != nil { return err } - _, err := q.InsertAdminAudit(ctx, sqlc.InsertAdminAuditParams{ID: pgtype.UUID{Bytes: uuid.New(), Valid: true}, AdminCredentialID: source.CredentialID, ActorLabel: source.ActorLabel, Action: action, ResourceType: resourceType, ResourceID: resourceID, ResultIds: []byte(`[]`), RequestID: source.RequestID, TraceID: source.TraceID}) + _, err := q.InsertAdminAudit(ctx, sqlc.InsertAdminAuditParams{ID: pgtype.UUID{Bytes: uuid.New(), Valid: true}, AdminCredentialID: source.CredentialID, ActorLabel: source.ActorLabel, Action: action, ResourceType: resourceType, ResourceID: resourceID, RequestID: source.RequestID, TraceID: source.TraceID}) return err } diff --git a/services/core/internal/persistence/postgres/auditpg/write_operations.go b/services/core/internal/persistence/postgres/auditpg/write_operations.go index 806143272..30def5fac 100644 --- a/services/core/internal/persistence/postgres/auditpg/write_operations.go +++ b/services/core/internal/persistence/postgres/auditpg/write_operations.go @@ -24,9 +24,8 @@ func apiKey(id, name, prefix, kind string, revoked pgtype.Timestamptz) writeaudi return key } -// GetResourceOwners returns each resource's recorded creator in request order. -// A resource created through a removed administrator copy reports that audit -// entry instead of a key. +// GetResourceOwners returns each resource's recorded creating key in request +// order. func (s *Store) GetResourceOwners(ctx context.Context, tenantID, resourceType string, resourceIDs []string) ([]writeaudit.ResourceOwner, error) { tenant, err := pgunit.ParseID(tenantID) if err != nil { @@ -36,24 +35,12 @@ func (s *Store) GetResourceOwners(ctx context.Context, tenantID, resourceType st return nil, err } keys := make(map[string]writeaudit.APIKey, len(resourceIDs)) - admins := make(map[string]string) err = s.pool.Snapshot(ctx, func(ctx context.Context, tx pgx.Tx) error { - q := sqlc.New(tx) - rows, err := q.GetResourceOwners(ctx, sqlc.GetResourceOwnersParams{TenantID: tenant, ResourceType: resourceType, Column3: resourceIDs}) - if err != nil { - return err - } + rows, err := sqlc.New(tx).GetResourceOwners(ctx, sqlc.GetResourceOwnersParams{TenantID: tenant, ResourceType: resourceType, Column3: resourceIDs}) for _, row := range rows { keys[row.ResourceID] = apiKey(row.KeyID, row.KeyName, row.KeyPrefix, row.KeyKind, row.RevokedAt) } - adminRows, err := q.GetAdminResourceOwners(ctx, sqlc.GetAdminResourceOwnersParams{TenantID: tenant, ResourceType: resourceType, Column3: resourceIDs}) - if err != nil { - return err - } - for _, row := range adminRows { - admins[row.ResourceID] = uuid.UUID(row.AuditID.Bytes).String() - } - return nil + return err }) if err != nil { return nil, err @@ -63,13 +50,6 @@ func (s *Store) GetResourceOwners(ctx context.Context, tenantID, resourceType st owner := writeaudit.ResourceOwner{ResourceID: id} if key, ok := keys[id]; ok { owner.APIKey = &key - source := "api_key" - owner.Source = &source - } - if auditID, ok := admins[id]; ok && owner.APIKey == nil { - source := "admin_copy" - owner.Source = &source - owner.AdminAuditID = &auditID } result = append(result, owner) } diff --git a/services/core/internal/persistence/postgres/filepg/filepg_test.go b/services/core/internal/persistence/postgres/filepg/filepg_test.go index 806236399..1b5233769 100644 --- a/services/core/internal/persistence/postgres/filepg/filepg_test.go +++ b/services/core/internal/persistence/postgres/filepg/filepg_test.go @@ -153,8 +153,8 @@ func TestFilesRejectInvalidIdentifiers(t *testing.T) { func auditContext(ctx context.Context, tenant, request string) context.Context { return writeaudit.WithSource(ctx, writeaudit.Source{ - KeyID: "static:" + strings.Repeat("a", 64), Name: "file audit fixture", Prefix: "aaaaaaaa", - Kind: "static", TenantID: tenant, RequestID: request, TraceID: "file-audit-trace", + KeyID: "aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa", Name: "file audit fixture", Prefix: "pc_aaaaaaaa", + Kind: "issued", TenantID: tenant, RequestID: request, TraceID: "file-audit-trace", }) } diff --git a/services/core/internal/persistence/postgres/sessionpg/creation_test.go b/services/core/internal/persistence/postgres/sessionpg/creation_test.go index 1b857bb00..65ca06140 100644 --- a/services/core/internal/persistence/postgres/sessionpg/creation_test.go +++ b/services/core/internal/persistence/postgres/sessionpg/creation_test.go @@ -4,8 +4,6 @@ import ( "archive/zip" "bytes" "context" - "crypto/sha256" - "encoding/hex" "encoding/json" "errors" "fmt" @@ -608,9 +606,7 @@ func TestCreationAudit(t *testing.T) { pool := pgtest.Open(t) _, service := creationService(t, pool, nil) tenant := uuid.NewString() - sum := sha256.Sum256([]byte(uuid.NewString())) - digest := hex.EncodeToString(sum[:]) - source := writeaudit.Source{KeyID: "static:" + digest, Prefix: digest[:8], Name: "test key", Kind: "static", TenantID: uuid.NewString(), RequestID: uuid.NewString(), TraceID: uuid.NewString()} + source := writeaudit.Source{KeyID: uuid.NewString(), Prefix: "pc_aaaaaaaa", Name: "test key", Kind: "issued", TenantID: uuid.NewString(), RequestID: uuid.NewString(), TraceID: uuid.NewString()} input := sessions.CreateSession{Creator: creator, Engine: "codex", IdempotencyKey: "audited", InitialInputs: []sessions.Input{messageInput("first")}, Configuration: json.RawMessage(`{"environment":{"type":"self_hosted","workspace_directory":"/workspace"}}`)} if _, err := service.CreateSession(writeaudit.WithSource(t.Context(), source), tenant, input); !errors.Is(err, writeaudit.ErrInvalidSource) { diff --git a/services/core/internal/persistence/postgres/skillpg/audit_test.go b/services/core/internal/persistence/postgres/skillpg/audit_test.go index 12fca7ed9..de62c39d4 100644 --- a/services/core/internal/persistence/postgres/skillpg/audit_test.go +++ b/services/core/internal/persistence/postgres/skillpg/audit_test.go @@ -71,8 +71,8 @@ func prepareAuditMutation(t *testing.T, f fixture, tenant, name string, bundle [ func writeAuditContext(ctx context.Context, tenant, request string) context.Context { return writeaudit.WithSource(ctx, writeaudit.Source{ - KeyID: "static:" + strings.Repeat("a", 64), Name: "resource audit fixture", Prefix: "aaaaaaaa", - Kind: "static", TenantID: tenant, RequestID: request, TraceID: "resource-audit-trace", + KeyID: "aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa", Name: "resource audit fixture", Prefix: "pc_aaaaaaaa", + Kind: "issued", TenantID: tenant, RequestID: request, TraceID: "resource-audit-trace", }) } @@ -194,12 +194,12 @@ func TestAdminDeleteAuditTransactions(t *testing.T) { if err != nil { t.Fatal(err) } - var credential, actor, project, trace, action, kind, gotID, mappings, raw string - if err := pool.QueryRow(ctx, `SELECT admin_credential_id,actor_label,project_id,trace_id,action,resource_type,resource_id,result_ids::text,to_jsonb(a)::text - FROM admin_audit_log a WHERE tenant_id=$1 AND request_id=$2`, tenant, request).Scan(&credential, &actor, &project, &trace, &action, &kind, &gotID, &mappings, &raw); err != nil { + var credential, actor, project, trace, action, kind, gotID, raw string + if err := pool.QueryRow(ctx, `SELECT admin_credential_id,actor_label,project_id,trace_id,action,resource_type,resource_id,to_jsonb(a)::text + FROM admin_audit_log a WHERE tenant_id=$1 AND request_id=$2`, tenant, request).Scan(&credential, &actor, &project, &trace, &action, &kind, &gotID, &raw); err != nil { t.Fatal(err) } - if credential != "87654321" || actor != "administrator fixture" || project != tenant || trace != "admin-mutation-trace" || action != "delete" || kind != mutation.kind || gotID != id || mappings != "[]" { + if credential != "87654321" || actor != "administrator fixture" || project != tenant || trace != "admin-mutation-trace" || action != "delete" || kind != mutation.kind || gotID != id { t.Fatal("administrator audit identity differs") } if strings.Contains(raw, "admin-private-archive") { diff --git a/services/core/internal/persistence/postgres/templatepg/audit_test.go b/services/core/internal/persistence/postgres/templatepg/audit_test.go index c59553c32..b4611b238 100644 --- a/services/core/internal/persistence/postgres/templatepg/audit_test.go +++ b/services/core/internal/persistence/postgres/templatepg/audit_test.go @@ -48,8 +48,8 @@ func (f fixture) snapshot(t *testing.T) map[string]string { func writeSource(ctx context.Context, tenant, request string) context.Context { return writeaudit.WithSource(ctx, writeaudit.Source{ - KeyID: "static:" + strings.Repeat("a", 64), Name: "template audit fixture", Prefix: "aaaaaaaa", - Kind: "static", TenantID: tenant, RequestID: request, TraceID: "template-audit-trace", + KeyID: "aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa", Name: "template audit fixture", Prefix: "pc_aaaaaaaa", + Kind: "issued", TenantID: tenant, RequestID: request, TraceID: "template-audit-trace", }) } @@ -157,11 +157,11 @@ func TestAdminDeleteAuditCommitsWithTheDeletion(t *testing.T) { if err != nil || id != template.ID { t.Fatal(id, err) } - var credential, actor, project, trace, action, kind, gotID, mappings, raw string - if err := f.pool.QueryRow(t.Context(), `SELECT admin_credential_id,actor_label,project_id,trace_id,action,resource_type,resource_id,result_ids::text,to_jsonb(a)::text FROM admin_audit_log a WHERE tenant_id=$1 AND request_id=$2`, tenant, request).Scan(&credential, &actor, &project, &trace, &action, &kind, &gotID, &mappings, &raw); err != nil { + var credential, actor, project, trace, action, kind, gotID, raw string + if err := f.pool.QueryRow(t.Context(), `SELECT admin_credential_id,actor_label,project_id,trace_id,action,resource_type,resource_id,to_jsonb(a)::text FROM admin_audit_log a WHERE tenant_id=$1 AND request_id=$2`, tenant, request).Scan(&credential, &actor, &project, &trace, &action, &kind, &gotID, &raw); err != nil { t.Fatal(err) } - if credential != "87654321" || actor != "administrator fixture" || project != tenant || trace != "admin-mutation-trace" || action != "delete" || kind != "environment_template" || gotID != id || mappings != "[]" || strings.Contains(raw, "admin-private") { + if credential != "87654321" || actor != "administrator fixture" || project != tenant || trace != "admin-mutation-trace" || action != "delete" || kind != "environment_template" || gotID != id || strings.Contains(raw, "admin-private") { t.Fatal("administrator audit identity differs") } var operations, owners int diff --git a/services/core/internal/persistence/postgres/vaultpg/audit_test.go b/services/core/internal/persistence/postgres/vaultpg/audit_test.go index 8e5b5e06c..2c88daa5f 100644 --- a/services/core/internal/persistence/postgres/vaultpg/audit_test.go +++ b/services/core/internal/persistence/postgres/vaultpg/audit_test.go @@ -28,8 +28,8 @@ type auditMutation struct { func publicAuditContext(ctx context.Context, tenant, request string) context.Context { return writeaudit.WithSource(ctx, writeaudit.Source{ - KeyID: "static:" + strings.Repeat("a", 64), Name: "vault audit fixture", Prefix: "aaaaaaaa", - Kind: "static", TenantID: tenant, RequestID: request, TraceID: "vault-audit-trace", + KeyID: "aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa", Name: "vault audit fixture", Prefix: "pc_aaaaaaaa", + Kind: "issued", TenantID: tenant, RequestID: request, TraceID: "vault-audit-trace", }) } @@ -195,11 +195,11 @@ func TestVaultMutationsRollBackWithTheirAudit(t *testing.T) { if public != 0 || admin != 1 || owners != 0 { t.Fatalf("public audit rows %d, admin rows %d, owners %d", public, admin, owners) } - var credential, actor, project, trace, results string - if err := pool.QueryRow(t.Context(), `SELECT admin_credential_id,actor_label,project_id,trace_id,result_ids::text,action,resource_type,resource_id,to_jsonb(a)::text FROM admin_audit_log a WHERE tenant_id=$1 AND request_id=$2`, tenant, request).Scan(&credential, &actor, &project, &trace, &results, &action, &kind, &gotID, &raw); err != nil { + var credential, actor, project, trace string + if err := pool.QueryRow(t.Context(), `SELECT admin_credential_id,actor_label,project_id,trace_id,action,resource_type,resource_id,to_jsonb(a)::text FROM admin_audit_log a WHERE tenant_id=$1 AND request_id=$2`, tenant, request).Scan(&credential, &actor, &project, &trace, &action, &kind, &gotID, &raw); err != nil { t.Fatal(err) } - if credential != "87654321" || actor != "administrator fixture" || project != tenant || trace != "admin-mutation-trace" || results != "[]" { + if credential != "87654321" || actor != "administrator fixture" || project != tenant || trace != "admin-mutation-trace" { t.Fatal("administrator audit identity differs") } parent = mutation.parent diff --git a/services/core/internal/writeaudit/reader.go b/services/core/internal/writeaudit/reader.go index ed9964e72..e1a4be872 100644 --- a/services/core/internal/writeaudit/reader.go +++ b/services/core/internal/writeaudit/reader.go @@ -27,13 +27,11 @@ type APIKey struct { RevokedAt *time.Time `json:"revoked_at"` } -// ResourceOwner is the recorded creator of one resource. Both creator fields -// are null for a resource without recorded creation provenance. +// ResourceOwner is the recorded creator of one resource. APIKey is null for a +// resource without recorded creation provenance. type ResourceOwner struct { - ResourceID string `json:"resource_id"` - APIKey *APIKey `json:"api_key"` - Source *string `json:"source"` - AdminAuditID *string `json:"admin_audit_id"` + ResourceID string `json:"resource_id"` + APIKey *APIKey `json:"api_key"` } // Operation is one committed write. diff --git a/services/core/internal/writeaudit/record.go b/services/core/internal/writeaudit/record.go index 6256aa14b..e7a256a9e 100644 --- a/services/core/internal/writeaudit/record.go +++ b/services/core/internal/writeaudit/record.go @@ -1,8 +1,6 @@ package writeaudit import ( - "crypto/sha256" - "encoding/hex" "errors" "fmt" "strings" @@ -37,13 +35,6 @@ func ValidText(value string, max int, required bool) bool { return (!required || value != "") && utf8.ValidString(value) && utf8.RuneCountInString(value) <= max && !strings.ContainsFunc(value, unicode.IsControl) } -// ValidKeyDigest reports whether digest is the lowercase hexadecimal SHA-256 -// digest that identifies a Project key. -func ValidKeyDigest(digest string) bool { - decoded, err := hex.DecodeString(digest) - return err == nil && len(decoded) == sha256.Size && hex.EncodeToString(decoded) == digest -} - // Validate checks that s is well-formed provenance of a write in tenant. func (s Source) Validate(tenant string) error { if !s.valid(tenant) { @@ -75,21 +66,14 @@ func ValidateRecord(source Source, tenant, action string, resources []Resource) func (s Source) valid(tenant string) bool { actual, err := parseID(s.TenantID) expected, expectedErr := parseID(tenant) - valid := err == nil && expectedErr == nil && actual == expected && + _, idErr := parseID(s.KeyID) + valid := err == nil && expectedErr == nil && actual == expected && s.Kind == "issued" && idErr == nil && + len(s.Prefix) == 11 && strings.HasPrefix(s.Prefix, "pc_") && ValidText(s.Name, 80, false) && ValidText(s.RequestID, 128, true) && ValidText(s.TraceID, 128, true) - switch s.Kind { - case "static", "console": - digest := strings.TrimPrefix(s.KeyID, "static:") - return valid && strings.HasPrefix(s.KeyID, "static:") && ValidKeyDigest(digest) && s.Prefix == digest[:min(len(digest), 8)] - case "issued": - _, idErr := parseID(s.KeyID) - valid = valid && idErr == nil && len(s.Prefix) == 11 && strings.HasPrefix(s.Prefix, "pc_") - for _, c := range strings.TrimPrefix(s.Prefix, "pc_") { - valid = valid && (c >= 'A' && c <= 'Z' || c >= 'a' && c <= 'z' || c >= '0' && c <= '9' || c == '_' || c == '-') - } - return valid + for _, c := range strings.TrimPrefix(s.Prefix, "pc_") { + valid = valid && (c >= 'A' && c <= 'Z' || c >= 'a' && c <= 'z' || c >= '0' && c <= '9' || c == '_' || c == '-') } - return false + return valid } func parseID(value string) (uuid.UUID, error) { diff --git a/services/core/internal/writeaudit/record_test.go b/services/core/internal/writeaudit/record_test.go index 5b0bc232b..12b721844 100644 --- a/services/core/internal/writeaudit/record_test.go +++ b/services/core/internal/writeaudit/record_test.go @@ -1,8 +1,6 @@ package writeaudit import ( - "crypto/sha256" - "encoding/hex" "errors" "strings" "testing" @@ -11,33 +9,29 @@ import ( "github.com/google/uuid" ) -func staticSource(tenant string) Source { - sum := sha256.Sum256([]byte("key")) - digest := hex.EncodeToString(sum[:]) - return Source{KeyID: "static:" + digest, Prefix: digest[:8], Name: "key", Kind: "static", TenantID: tenant, RequestID: "request", TraceID: "trace"} +func issuedSource(tenant string) Source { + return Source{KeyID: uuid.NewString(), Prefix: "pc_Ab3_-xyz", Name: "key", Kind: "issued", TenantID: tenant, RequestID: "request", TraceID: "trace"} } func TestValidateRecord(t *testing.T) { tenant := uuid.NewString() agent := []Resource{{Type: "agent", ID: "agent"}} - issued := staticSource(tenant) - issued.KeyID, issued.Prefix, issued.Kind = uuid.NewString(), "pc_Ab3_-xyz", "issued" - if err := ValidateRecord(staticSource(tenant), tenant, "create", agent); err != nil { + if err := ValidateRecord(issuedSource(tenant), tenant, "create", agent); err != nil { t.Fatal(err) } - if err := ValidateRecord(issued, tenant, "update_default_version", []Resource{{Type: "skill_version", ID: "1", ParentID: "skill"}}); err != nil { + if err := ValidateRecord(issuedSource(tenant), tenant, "update_default_version", []Resource{{Type: "skill_version", ID: "1", ParentID: "skill"}}); err != nil { t.Fatal(err) } for name, change := range map[string]func(*Source, *string, *[]Resource){ "other tenant": func(s *Source, _ *string, _ *[]Resource) { s.TenantID = uuid.NewString() }, "nil tenant": func(s *Source, _ *string, _ *[]Resource) { s.TenantID = uuid.Nil.String() }, - "short digest": func(s *Source, _ *string, _ *[]Resource) { s.KeyID = "static:abcd" }, + "key ID": func(s *Source, _ *string, _ *[]Resource) { s.KeyID = "key" }, "prefix": func(s *Source, _ *string, _ *[]Resource) { s.Prefix = "bad" }, - "kind": func(s *Source, _ *string, _ *[]Resource) { s.Kind = "unknown" }, + "prefix chars": func(s *Source, _ *string, _ *[]Resource) { s.Prefix = "pc_Ab3_-xy!" }, + "kind": func(s *Source, _ *string, _ *[]Resource) { s.Kind = "static" }, "request": func(s *Source, _ *string, _ *[]Resource) { s.RequestID = "" }, "trace": func(s *Source, _ *string, _ *[]Resource) { s.TraceID = "bad\x01" }, "name": func(s *Source, _ *string, _ *[]Resource) { s.Name = strings.Repeat("x", 81) }, - "issued key ID": func(s *Source, _ *string, _ *[]Resource) { s.Kind = "issued" }, "action": func(_ *Source, a *string, _ *[]Resource) { *a = "copy" }, "resource type": func(_ *Source, _ *string, r *[]Resource) { *r = []Resource{{Type: "project", ID: "p"}} }, "resource ID": func(_ *Source, _ *string, r *[]Resource) { *r = []Resource{{Type: "agent"}} }, @@ -45,7 +39,7 @@ func TestValidateRecord(t *testing.T) { *r = []Resource{{Type: "agent", ID: "a", ParentID: strings.Repeat("x", 257)}} }, } { - source, action, resources := staticSource(tenant), "create", agent + source, action, resources := issuedSource(tenant), "create", agent change(&source, &action, &resources) if err := ValidateRecord(source, tenant, action, resources); !errors.Is(err, ErrInvalidSource) { t.Errorf("%s accepted: %v", name, err) diff --git a/services/core/migrations/000092_delete_admin_copies.sql b/services/core/migrations/000092_delete_admin_copies.sql new file mode 100644 index 000000000..046f74a72 --- /dev/null +++ b/services/core/migrations/000092_delete_admin_copies.sql @@ -0,0 +1,24 @@ +-- +goose Up +DROP TABLE admin_resource_owners; +DROP TABLE admin_asset_copies; +ALTER TABLE admin_audit_log DROP COLUMN result_ids; + +-- +goose Down +ALTER TABLE admin_audit_log ADD COLUMN result_ids jsonb NOT NULL DEFAULT '[]'; +CREATE TABLE admin_asset_copies ( + target_tenant_id uuid NOT NULL, + idempotency_key text NOT NULL, + request_hash bytea NOT NULL, + result jsonb NOT NULL, + audit_id uuid NOT NULL REFERENCES admin_audit_log(id), + PRIMARY KEY (target_tenant_id, idempotency_key) +); +CREATE TABLE admin_resource_owners ( + tenant_id uuid NOT NULL, + resource_type text NOT NULL, + resource_id text NOT NULL, + parent_id text NOT NULL DEFAULT '', + audit_id uuid NOT NULL REFERENCES admin_audit_log(id), + PRIMARY KEY (tenant_id, resource_type, resource_id) +); +CREATE INDEX admin_resource_owners_audit ON admin_resource_owners(audit_id); diff --git a/services/core/tests/integration/admin_delete_audit_test.go b/services/core/tests/integration/admin_delete_audit_test.go index 183713c19..bceb94c40 100644 --- a/services/core/tests/integration/admin_delete_audit_test.go +++ b/services/core/tests/integration/admin_delete_audit_test.go @@ -57,8 +57,8 @@ func adminDeleteContext(ctx context.Context, tenant, request string) context.Con // Even an inherited public provenance context must not turn an administrator // operation into a user-key operation. public := writeaudit.WithSource(ctx, writeaudit.Source{ - KeyID: "static:" + strings.Repeat("a", 64), Name: "resource audit fixture", Prefix: "aaaaaaaa", - Kind: "static", TenantID: tenant, RequestID: request, TraceID: "resource-audit-trace", + KeyID: "aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa", Name: "resource audit fixture", Prefix: "pc_aaaaaaaa", + Kind: "issued", TenantID: tenant, RequestID: request, TraceID: "resource-audit-trace", }) return adminaudit.WithSource(public, adminaudit.Source{ CredentialID: "87654321", ActorLabel: "administrator fixture", ProjectID: tenant, RequestID: request, TraceID: "admin-mutation-trace", @@ -82,13 +82,13 @@ func adminMutationSnapshot(t *testing.T, s *Store, tables ...string) map[string] func assertAdminMutationAudit(t *testing.T, s *Store, tenant, request, action, kind, id string) { t.Helper() - var credential, actor, key, trace, gotAction, gotKind, gotID, mappings, raw string - if err := s.pool.QueryRow(t.Context(), `SELECT admin_credential_id,actor_label,project_id,trace_id,action,resource_type,resource_id,result_ids::text,to_jsonb(a)::text - FROM admin_audit_log a WHERE tenant_id=$1 AND request_id=$2`, tenant, request).Scan(&credential, &actor, &key, &trace, &gotAction, &gotKind, &gotID, &mappings, &raw); err != nil { + var credential, actor, key, trace, gotAction, gotKind, gotID, raw string + if err := s.pool.QueryRow(t.Context(), `SELECT admin_credential_id,actor_label,project_id,trace_id,action,resource_type,resource_id,to_jsonb(a)::text + FROM admin_audit_log a WHERE tenant_id=$1 AND request_id=$2`, tenant, request).Scan(&credential, &actor, &key, &trace, &gotAction, &gotKind, &gotID, &raw); err != nil { t.Fatal(err) } expectedKey := tenant - if credential != "87654321" || actor != "administrator fixture" || key != expectedKey || trace != "admin-mutation-trace" || gotAction != action || gotKind != kind || gotID != id || mappings != "[]" { + if credential != "87654321" || actor != "administrator fixture" || key != expectedKey || trace != "admin-mutation-trace" || gotAction != action || gotKind != kind || gotID != id { t.Fatal("administrator audit identity differs") } for _, secret := range []string{"admin-private-body", "private-agent-canary", "audit-private-token"} { diff --git a/services/core/tests/integration/session_write_audit_test.go b/services/core/tests/integration/session_write_audit_test.go index e4679b03e..eb5b167d6 100644 --- a/services/core/tests/integration/session_write_audit_test.go +++ b/services/core/tests/integration/session_write_audit_test.go @@ -15,9 +15,8 @@ import ( func sessionAuditContext(t *testing.T, tenant, key string) context.Context { t.Helper() - digest := strings.Repeat(key, 64) - return writeaudit.WithSource(t.Context(), writeaudit.Source{TenantID: tenant, KeyID: "static:" + digest, - Name: "safe key", Prefix: digest[:8], Kind: "static", RequestID: uuid.NewString(), TraceID: "shared-trace"}) + return writeaudit.WithSource(t.Context(), writeaudit.Source{TenantID: tenant, KeyID: strings.ReplaceAll("xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx", "x", key), + Name: "safe key", Prefix: "pc_" + strings.Repeat(key, 8), Kind: "issued", RequestID: uuid.NewString(), TraceID: "shared-trace"}) } func sessionAuditCount(t *testing.T, s *Store, tenant string, want int) { @@ -66,7 +65,7 @@ func TestSessionWriteAuditCreationReplayNoopAndDeletion(t *testing.T) { sessionAuditCount(t, s, tenant, 2) for _, resource := range []string{created.ID, env.ID} { var key string - if err := s.pool.QueryRow(t.Context(), `SELECT o.key_id FROM write_audit_owners a JOIN write_audit_operations o ON o.id=a.operation_id WHERE a.tenant_id=$1 AND a.resource_id=$2`, tenant, resource).Scan(&key); err != nil || key != "static:"+strings.Repeat("a", 64) { + if err := s.pool.QueryRow(t.Context(), `SELECT o.key_id FROM write_audit_owners a JOIN write_audit_operations o ON o.id=a.operation_id WHERE a.tenant_id=$1 AND a.resource_id=$2`, tenant, resource).Scan(&key); err != nil || key != "aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa" { t.Fatal("retry replaced creator", key, err) } } diff --git a/services/web/distribution_test.go b/services/web/distribution_test.go index efe22cc62..7e36414b7 100644 --- a/services/web/distribution_test.go +++ b/services/web/distribution_test.go @@ -10,23 +10,48 @@ import ( "testing" ) +// activeRelease publishes an empty node payload release under root the way +// `oac init` does and returns the release directory. +func activeRelease(t *testing.T, root string, manifest map[string]any) string { + t.Helper() + revision := strings.Repeat("a", 40) + release := filepath.Join(root, "releases", revision) + if err := os.MkdirAll(release, 0700); err != nil { + t.Fatal(err) + } + manifest["source_commit"] = revision + raw, _ := json.Marshal(manifest) + if os.WriteFile(filepath.Join(release, "manifest.json"), raw, 0600) != nil || + os.WriteFile(filepath.Join(root, "active.json"), []byte(`{"source_commit":"`+revision+`"}`), 0600) != nil { + t.Fatal("cannot publish the node payload") + } + return release +} + +func TestConsoleRequiresPublishedNodePayload(t *testing.T) { + payload := t.TempDir() + if err := os.WriteFile(filepath.Join(payload, "node-install.pyz"), []byte("bootstrap"), 0600); err != nil { + t.Fatal(err) + } + upstream, _ := url.Parse("http://127.0.0.1:1") + if _, err := newConsole(config{origin: testOrigin, upstream: upstream, dist: t.TempDir(), coreKey: testCoreKey, nodePayloadDir: payload}); err == nil { + t.Fatal("console started from a node payload without active.json") + } +} + func TestOfflineArtifactsAreManifestAllowlisted(t *testing.T) { dist, payload := t.TempDir(), t.TempDir() - for _, item := range []struct{ root, name, body string }{{dist, "index.html", "console"}, {payload, "node-install.pyz", "bootstrap"}} { + release := activeRelease(t, payload, map[string]any{"artifacts": map[string]any{"native/bin/oac-node": map[string]string{"filename": "matched-node"}, "private/key": map[string]string{"filename": "private-key"}, "native/bin/oac-selfhost": map[string]string{"filename": "retired-launcher"}}}) + for _, item := range []struct{ root, name, body string }{{dist, "index.html", "console"}, {release, "node-install.pyz", "bootstrap"}} { if err := os.WriteFile(filepath.Join(item.root, item.name), []byte(item.body), 0600); err != nil { t.Fatal(err) } } - if err := os.Mkdir(filepath.Join(payload, "artifacts"), 0700); err != nil { - t.Fatal(err) - } - manifest := map[string]any{"artifacts": map[string]any{"native/bin/oac-node": map[string]string{"filename": "matched-node"}, "private/key": map[string]string{"filename": "private-key"}, "native/bin/oac-selfhost": map[string]string{"filename": "retired-launcher"}}} - raw, _ := json.Marshal(manifest) - if err := os.WriteFile(filepath.Join(payload, "manifest.json"), raw, 0600); err != nil { + if err := os.Mkdir(filepath.Join(release, "artifacts"), 0700); err != nil { t.Fatal(err) } for _, name := range []string{"matched-node", "private-key", "undeclared", "retired-launcher"} { - if err := os.WriteFile(filepath.Join(payload, "artifacts", name), []byte("payload"), 0600); err != nil { + if err := os.WriteFile(filepath.Join(release, "artifacts", name), []byte("payload"), 0600); err != nil { t.Fatal(err) } } @@ -65,16 +90,16 @@ func TestConsoleReportsServableNodeProviders(t *testing.T) { t.Fatal(err) } } - write(filepath.Join(dist, "index.html"), "console") - write(filepath.Join(payload, "node-install.pyz"), "bootstrap") artifacts := map[string]any{} - for logical := range map[string]bool{"native/bin/oac-node": true, "images/runtime.tar.gz": true, "native/microsandbox/msb": true, "runtime/seccomp.json": true} { - name := strings.ReplaceAll(logical, "/", "-") - artifacts[logical] = map[string]any{"filename": name, "size": len("runtime-bytes")} - write(filepath.Join(payload, "artifacts", name), "runtime-bytes") + for _, logical := range []string{"native/bin/oac-node", "images/runtime.tar.gz", "native/microsandbox/msb", "runtime/seccomp.json"} { + artifacts[logical] = map[string]any{"filename": strings.ReplaceAll(logical, "/", "-"), "size": len("runtime-bytes")} + } + release := activeRelease(t, payload, map[string]any{"artifacts": artifacts}) + write(filepath.Join(dist, "index.html"), "console") + write(filepath.Join(release, "node-install.pyz"), "bootstrap") + for logical := range artifacts { + write(filepath.Join(release, "artifacts", strings.ReplaceAll(logical, "/", "-")), "runtime-bytes") } - raw, _ := json.Marshal(map[string]any{"artifacts": artifacts}) - write(filepath.Join(payload, "manifest.json"), string(raw)) upstream, _ := url.Parse("http://127.0.0.1:1") h, err := newConsole(config{origin: testOrigin, upstream: upstream, dist: dist, coreKey: testCoreKey, nodePayloadDir: payload}) if err != nil { @@ -90,7 +115,7 @@ func TestConsoleReportsServableNodeProviders(t *testing.T) { if response, body := responseBody(t, server, request); response.StatusCode != 206 || body != "bytes" { t.Fatal("artifact download cannot resume", response.StatusCode, body) } - if err := os.RemoveAll(filepath.Join(payload, "artifacts")); err != nil { + if err := os.RemoveAll(filepath.Join(release, "artifacts")); err != nil { t.Fatal(err) } if _, body := responseBody(t, server, consoleRequest(t, server, "GET", "/console/config")); !strings.Contains(body, `"node_artifacts":[]`) { diff --git a/services/web/node_artifacts.go b/services/web/node_artifacts.go index 566d276af..c17869b87 100644 --- a/services/web/node_artifacts.go +++ b/services/web/node_artifacts.go @@ -33,7 +33,7 @@ func (h *console) readNodeManifest(prefix string) (nodeManifest, error) { if err != nil || len(raw) > 1024*1024 || json.Unmarshal(raw, &manifest) != nil { return manifest, errors.New("invalid node manifest") } - if prefix != "" && prefix != "releases/"+manifest.SourceCommit+"/" { + if prefix != "releases/"+manifest.SourceCommit+"/" { return manifest, errors.New("node manifest release mismatch") } return manifest, nil diff --git a/services/web/node_installation.go b/services/web/node_installation.go index f9bfb2518..2747292ba 100644 --- a/services/web/node_installation.go +++ b/services/web/node_installation.go @@ -35,13 +35,9 @@ var optionalPayloadFiles = func() map[string]bool { var payloadRevision = regexp.MustCompile(`^[0-9a-f]{40}$`) -// activePayloadPrefix reads one atomic pointer per request. Legacy flat payloads -// remain readable until the installer publishes its first versioned release. +// activePayloadPrefix reads the installer's atomic release pointer once per request. func activePayloadPrefix(root *os.Root) (string, error) { raw, err := root.ReadFile("active.json") - if errors.Is(err, os.ErrNotExist) { - return "", nil - } if err != nil || len(raw) > 256 { return "", errors.New("invalid active node payload") } @@ -55,7 +51,7 @@ func activePayloadPrefix(root *os.Root) (string, error) { } func (h *console) resolveNodePayload(name string) (string, bool) { - prefix := "" + var prefix string if strings.HasPrefix(name, "releases/") { parts := strings.SplitN(name, "/", 3) if len(parts) != 3 || !payloadRevision.MatchString(parts[1]) { @@ -69,9 +65,6 @@ func (h *console) resolveNodePayload(name string) (string, bool) { return "", false } } - if prefix == "" && nodePayloadFiles[name] { - return name, true - } if !nodePayloadFiles[name] && (!strings.HasPrefix(name, "artifacts/") || strings.Contains(strings.TrimPrefix(name, "artifacts/"), "/")) { return "", false } diff --git a/services/web/node_installation_test.go b/services/web/node_installation_test.go index 5867e412a..6ee331c8f 100644 --- a/services/web/node_installation_test.go +++ b/services/web/node_installation_test.go @@ -35,7 +35,8 @@ func TestPairedConsoleProxiesOnlyAdministration(t *testing.T) { defer upstream.Close() u, _ := url.Parse(upstream.URL) dist, payload := t.TempDir(), t.TempDir() - for _, file := range []struct{ path, value string }{{filepath.Join(dist, "index.html"), "console"}, {filepath.Join(payload, "node-install.pyz"), "print('installer')"}, {filepath.Join(payload, "self-hosted-install.pyz"), "print('self-hosted')"}, {filepath.Join(payload, "caller.key"), "must-not-be-served"}} { + release := activeRelease(t, payload, map[string]any{}) + for _, file := range []struct{ path, value string }{{filepath.Join(dist, "index.html"), "console"}, {filepath.Join(release, "node-install.pyz"), "print('installer')"}, {filepath.Join(release, "self-hosted-install.pyz"), "print('self-hosted')"}, {filepath.Join(release, "caller.key"), "must-not-be-served"}} { if err := os.WriteFile(file.path, []byte(file.value), 0600); err != nil { t.Fatal(err) } @@ -89,8 +90,7 @@ func TestPairedConsoleProxiesOnlyAdministration(t *testing.T) { // Web verifies each downloaded installer against these digests before running it. nodeDigest := sha256.Sum256([]byte("print('installer')")) if tc.path == "/console/config" && tc.status == 200 && (!strings.Contains(body, `"node_installer":true`) || - !strings.Contains(body, `"node_installer_sha256":"`+hex.EncodeToString(nodeDigest[:])+`"`) || strings.Contains(body, "self_hosted_installer") || - strings.Contains(body, "sandbox_admin") || strings.Contains(body, "api_keys")) { + !strings.Contains(body, `"node_installer_sha256":"`+hex.EncodeToString(nodeDigest[:])+`"`) || strings.Contains(body, "self_hosted_installer")) { t.Fatalf("console configuration = %s", body) } } diff --git a/services/web/project_proxy_test.go b/services/web/project_proxy_test.go index 218e24fd2..69fac32a3 100644 --- a/services/web/project_proxy_test.go +++ b/services/web/project_proxy_test.go @@ -39,15 +39,6 @@ func TestCoreDirectRoutesPassThroughWithCallerCredential(t *testing.T) { } } } - r := httptest.NewRequest("GET", "/console/api-keys", nil) - r.Host = h.host - r.Header.Set("Origin", h.origin) - r.AddCookie(cookie) - w := httptest.NewRecorder() - h.ServeHTTP(w, r) - if w.Code != 404 { - t.Errorf("/console/api-keys = %d", w.Code) - } } // Encoded or doubled separators and dot segments cannot turn a /core/v1 diff --git a/services/web/server.go b/services/web/server.go index c159fb603..01d43dd7c 100644 --- a/services/web/server.go +++ b/services/web/server.go @@ -140,10 +140,6 @@ func (h *console) ServeHTTP(w http.ResponseWriter, r *http.Request) { h.direct.ServeHTTP(w, r) return } - if r.URL.Path == "/console/api-keys" || strings.HasPrefix(r.URL.Path, "/console/api-keys/") { - http.NotFound(w, r) - return - } if h.nodePayload != nil && strings.HasPrefix(r.URL.Path, "/node-install/") { if h.requestOrigin(r) == "" || !safePath(r.URL.Path) || r.URL.IsAbs() { http.NotFound(w, r)