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/AGENTS.md b/AGENTS.md index f292b1bb1..be5044f34 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -58,7 +58,7 @@ Do not multiply entities without necessity. The long-term goal is minimal code, Each setting and each piece of data is written in one place and read from that place: no second copy, no environment-variable or file fallback and no alias. A new setting joins its category and lives beside its peers. -The categories are [process settings](docs/configuration.md#process-settings-configjson), [derived files](docs/configuration.md#how-oac-apply-works), [secrets](docs/configuration.md#installation-directory), and Core's database for [runtime settings](docs/configuration.md#runtime-settings-web) and execution data. [Configuration](docs/configuration.md) owns the installation layout and the settings themselves. +The categories are [process settings](docs/configuration.md#process-settings), [derived files](docs/configuration.md#how-oac-apply-works), [secrets](docs/configuration.md#installation-directory), and Core's database for [runtime settings](docs/configuration.md#runtime-settings-web) and execution data. [Configuration](docs/configuration.md) owns the installation layout and the settings themselves. ### Pre-release: no compatibility layers diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 95a989fe2..e5dee072e 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -8,7 +8,7 @@ This guide owns how to work in the repository: documentation ownership, the repo | --- | --- | | Design principles, public API fidelity, settings and data ownership, documentation rules | [AGENTS.md](AGENTS.md) | | Protocol code and documents at each component boundary | [Protocol map](AGENTS.md#protocols-at-every-boundary) | -| Projects, keys, resource isolation, administrator authority, secrets and audit concepts | [Concepts and ownership](docs/concepts.md) | +| Projects, keys, resource isolation, administrator authority, secrets and audit concepts | [Concepts](docs/concepts.md) | | Component responsibilities and Session flow | [Architecture](docs/architecture.md) | | Developer setup, repository map and focused checks | [Develop OpenAgentCore](docs/development.md) | | API callers, credentials and route inventory | [API index](docs/api/index.md) | @@ -115,7 +115,7 @@ The role needs `CREATE DATABASE`: tests of database-wide state, such as the exec ### Contract and schema rules -- `internal/harnessconfig/builtin/catalog.json` is the single authored public Harness registration list. `make generate-harness-catalog` generates Go configuration/profile registration, client identifiers/names and the reference; `make openapi` derives the matching enums. `make check-harness-catalog` verifies freshness in the full gate. Native configuration rules stay in their adapter declarations; Core qualification and Runtime availability stay separate. +- `internal/harnessconfig/builtin/catalog.json` is the single authored public Harness registration list. `make generate-harness-catalog` generates Go configuration/profile registration, client identifiers/names and the reference, and projects the model-provider protocol names of `internal/modelprovider/config.go` to the client; `make openapi` derives the matching enums. `make check-harness-catalog` verifies freshness in the full gate. Native configuration rules stay in their adapter declarations; Core qualification and Runtime availability stay separate. - `make sqlc-generate` owns only `services/core/internal/db/sqlc` (sqlc v1.29.0). Do not rewrite landed migrations. - `make check-runtime-contract` is the focused Core–Runtime contract entry point; see [Contract verification](docs/runtime-protocol.md#contract-verification). It also runs through `check-go` and `check-core`. diff --git a/apps/web/DESIGN.md b/apps/web/DESIGN.md index 86c08ae95..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. @@ -399,7 +399,7 @@ On Overview and Session log, failed reads that leave a section unavailable repla A failed action whose outcome needs a decision (a sandbox change with no answer, a timeout or a 5xx) opens an error dialog with the reason and the next step as its primary button. Failed refreshes and project reads also raise an error toast; other failed actions, Core's clear refusal of a sandbox change among them, are reported there with the reason. A refusal leaves the page usable as it was. Errors inside a dialog or a form stay beside what they concern. Coverage notes (Margin Gray, Hairline ring, 8px corners, 12.5px Graphite) state bounded aggregation. Standing warnings that need action use an amber-tinted line at the top of the page body. On Nodes, this names nodes still bound to an old Core address; each of those nodes' status reads Old address (amber dot) with "Remove and add again" under it in 12px Graphite. Partial-data chips are amber-tinted pills with a help tip. Safety notices (a key shown once, a destructive consequence) stay visible in body text. -A local-only installation has the same amber notice on Overview, Nodes and System: other machines cannot connect, followed by Core's configuration path and apply command as copyable values. If Core has no configuration snapshot, state that those instructions are unavailable; never fill in a path or command. Add node is disabled with its reason beside the action, and Getting started leaves its first step to do with the address fix visible. A pending or failed installation read cannot complete that step; a failed read shows Unknown and Retry. +A local-only installation has the same amber notice on Overview, Nodes and System: other machines cannot connect, followed by Review the public address, which leads to System. Add node is disabled with its reason beside the action, and Getting started leaves its first step to do with the address fix visible. A pending or failed installation read cannot complete that step; a failed read shows Unknown and Retry. ### Onboarding Signing in and the console tour share one frame: a dark stage on the left (always dark, whatever the theme) and the task panel on the right, which follows the theme. The stage is the product's one authored moment: a flickering indigo dot grid under slow light rays (Magic UI's flickering grid and light rays), Core as the OpenAgentCore mark on a tile with a travelling border beam, and two orbits of Agents, Sessions, Skills, Vaults, files, templates and machines around it; the OpenAgentCore mark is itself nodes on a ring. Brand copy sits bottom-left in solid ink; it is a paragraph, not a heading, because the panel's title names the task. Signing in asks for one thing, the deployment's Core key, in a single password field; a copyable Docker Compose command to read the key stays visible beneath it, with a reminder to substitute a custom installation directory. The key’s authority stays in a help tip. A refused key, too many attempts or an unavailable console is an error beside the field. Signing in opens the console on the Overview. The optional tour has three chapters — Monitor, Resources, Platform — whose stage shows a real dark screenshot of those pages, tilted towards the panel; it takes the place of the console until its last button, Skip or Escape, and then returns the focus to the control that opened it. Entering the console or the tour, and leaving the tour, happen inside a View Transition: the old page dissolves forward and the new one is revealed in a circle growing from the pressed button. With reduced motion the orbits hold their places, the grid is a still frame and no transition runs. @@ -408,7 +408,7 @@ Signing in and the console tour share one frame: a dark stage on the left (alway The first card on the Overview while any step is to do: a card header ("Getting started", "n of 4 done", a help tip, then a ghost Take the tour button and an icon button that hides it) over four rows split by Faint Rules. Each row has a 22px numbered ring (a check on the tile wash when done), a 13px/600 title over one 12.5px Graphite line, a status dot (Done in green, To do in Pencil, Checking pending, Unknown for a failed read) and one outline action while the step is to do: Set up sandboxes, Add node, Open Nodes or Open sandbox backend; Open System; Create project (which continues to the new project's first key) or Issue key; See how to call (the newest active project, preferring one with an active key), or Projects and keys without an active project. Add node, Create project and Issue key open their page with the dialog already open; Open System brings the Default model provider section to the top of the page body and focuses the default harness's Set or Replace; See how to call opens the project and, once its keys, usage and address are read, brings its How to call heading to the top of the page body, focused. Only the page body scrolls; the page header stays. Every step done turns it into one line, "You're set", with Take the tour and Dismiss; it stays, through the tour, until dismissed, and the checklist does not come back on its own. The choice is kept per installation in the browser, also while the deployment cannot be read; Show Getting started, a quiet row above the sidebar's account controls, opens it again at any time. ### Sandbox setup -Setting up hosted sandboxes is a set of pages inside System’s Sandbox configuration secondary page, one decision each: where sandboxes run (own machines or E2B), then the backend or the E2B account, then the size of each sandbox (three presets; E2B skips it, since each sandbox takes the template build's size), then a review. Choices are large cards that advance on a click; short indigo dashes show the progress; pages slide and blur across. The backend page compares microsandbox and Docker behind a help tip; microsandbox comes first, preselected (a saved backend stays selected), with a neutral Recommended pill beside its title. Docker takes a confirmation (see Dialogs) once per visit to setup; a saved Docker deployment has already made it. The review states where sandboxes run, the size, the Runtime (taken from this console's distribution manifest) and the Core address, read-only: it is config.json's `public_url`, and the console never asks for it. A loopback address carries an amber line under it: only the Core machine reaches it. When Core rejects the configuration for it (E2B with a loopback `public_url`), a red-tinted block under the review keeps Core's message and adds the config file and apply command as copyable values. A save attempt clears the transient E2B key. Initial setup then asks for it again, with a link to that step; an update may leave it blank to keep the committed key. Advanced settings, one link away, hold the complete form: resources (not for E2B), the Runtime release and the E2B template. A change keeps the saved size and Runtime while the backend stays the same (a saved size outside the presets is offered as Current). Same-backend editing starts at size or E2B credentials with the provider fixed. It is an online configuration update, including when older sandboxes remain: existing node identities and resource ownership are retained. Changing the backend or E2B team requires reset and then a new setup. E2B updates can omit the key to retain it; every explicitly entered key takes the verified replacement path and advances the target generation on success, including the same value. Rejections remain inline with a safe reason and a deliberate way back to reset; never infer teams from a key, auto-reset or auto-resubmit. Optional explanations sit behind help tips; errors and safety consequences remain visible. +Setting up hosted sandboxes is a set of pages inside System’s Sandbox configuration secondary page, one decision each: where sandboxes run (own machines or E2B), then the backend or the E2B account, then the size of each sandbox (three presets; E2B skips it, since each sandbox takes the template build's size), then a review. Choices are large cards that advance on a click; short indigo dashes show the progress; pages slide and blur across. The backend page compares microsandbox and Docker behind a help tip; microsandbox comes first, preselected (a saved backend stays selected), with a neutral Recommended pill beside its title. Docker takes a confirmation (see Dialogs) once per visit to setup; a saved Docker deployment has already made it. The review states where sandboxes run, the size, the Runtime (taken from this console's distribution manifest) and the Core address, read-only: it is config.json's `public_url`, and the console never asks for it. A loopback address carries an amber line under it: only the Core machine reaches it. When Core rejects the configuration for it (E2B with a loopback `public_url`), a red-tinted block under the review keeps Core's message and adds Managed in System, which leads to System. A save attempt clears the transient E2B key. Initial setup then asks for it again, with a link to that step; an update may leave it blank to keep the committed key. Advanced settings, one link away, hold the complete form: resources (not for E2B), the Runtime release and the E2B template. A change keeps the saved size and Runtime while the backend stays the same (a saved size outside the presets is offered as Current). Same-backend editing starts at size or E2B credentials with the provider fixed. It is an online configuration update, including when older sandboxes remain: existing node identities and resource ownership are retained. Changing the backend or E2B team requires reset and then a new setup. E2B updates can omit the key to retain it; every explicitly entered key takes the verified replacement path and advances the target generation on success, including the same value. Rejections remain inline with a safe reason and a deliberate way back to reset; never infer teams from a key, auto-reset or auto-resubmit. Optional explanations sit behind help tips; errors and safety consequences remain visible. ### Configuration generations A single rollout row opens a details dialog for Core's target generation, previous-generation sandboxes and rollout counts. Poll rapidly only while Core reports preparing, or while the independent reset is active. Settled is preparation state, not proof that all nodes are ready or all older Sessions have ended. Retained old resources alone must not keep rapid polling alive. Render failed, update-required and unknown target states distinctly. Keep offline/live-provider status separate from a node's durable serving-generation pin; the pin alone never means the node is online or ready. Node detail shows the serving generation and target preparation; allocation detail shows the owned configuration generation. Do not calculate rollout completion from these rows or promise immediate placement on the target. @@ -423,7 +423,7 @@ A persistent progress panel uses Core's busy, idle and cleanup counts, deadline Read deployment progress independently of node details. Partial failures retain successful facts with a visible stale/unavailable notice. An uncertain write opens the recovery dialog and requires a new authoritative read before another mutation; refresh reads state and never resubmits the write. The connection's QueryClient owns both the authoritative deployment and pending or uncertain writes across route transitions. Leaving Sandbox configuration cannot cancel or forget a submitted reset, and a cached node snapshot cannot replace a newer reset or completion learned on Overview. Returning to Nodes or Sandbox configuration reads the shared deployment immediately and refreshes node evidence separately. Only a successful authoritative read begun after the write settles can release the mutation block; an earlier or still-pending read cannot. Submitting consumes the reset confirmation even if its outcome is uncertain; recovery uses the separate read-and-review dialog. Observation retries preserve applicable non-secret configuration drafts. A changed installation, owner epoch, backend, mode or generation discards the prior draft and confirmation. Logout clears this connection-scoped state. ### System page -Four sections, each saying where it changes. Installation: the public address, API base URL, installation ID and source commit as a fact card, with an outline action that opens the Domain and HTTPS secondary page. Default model configuration, the one section changed here: one card per harness in an auto-fill grid, its header holding the harness name and outline actions (Set, or Replace and Clear); fact rows give the harness's read-only startup state (a status dot and a Default pill, its source behind a help tip), then the default model ID, provider protocol, base URL, whether a key is configured, token limits when set and the update time, or Not set. Set and Replace open one form dialog. The model ID is required; advanced settings disclose an optional JSON object editor with formatting and inline syntax errors, plus token limits. The harness list supplies supported protocols, native protocols, JSON support and required limits from one adapter declaration. The form uses those fields without harness-specific branches. Nonempty JSON requires a native protocol; the form explains an incompatible selection beside the editor. Help tips explain the scope of native settings. Changing the model ID, provider URL or protocol clears the native JSON so settings cannot follow an unrelated model by accident. Re-entering the required write-only API key alone does not change model identity. The key field is a required password input, never prefilled or shown and forgotten when the form closes. Core's rejection stays in red inside the form; Clear is a ConfirmDialog. Usage details opens Core’s observations in a separate dialog. Sandboxes: one navigation row to the Sandbox configuration secondary page; do not repeat its configuration facts on System. Startup settings: a line naming the config file and the apply command as copyable chips, with when they were last applied, over a table of each setting, its value and the services a change restarts. Sensitive settings show only Configured or Not set; Default and Fixed after install are neutral pills beside the value. +Four sections; the page help says where sandboxes and startup settings change. Installation: the public address, API base URL, installation ID and source commit as a fact card, with an outline action that opens the Domain and HTTPS secondary page. Default model configuration, the one section changed here: one card per harness in an auto-fill grid, its header holding the harness name and outline actions (Set, or Replace and Clear); fact rows give the harness's read-only startup state (a status dot and a Default pill, its source behind a help tip), then the default model ID, provider protocol, base URL, whether a key is configured, token limits when set and the update time, or Not set. Set and Replace open one form dialog. The model ID is required; advanced settings disclose an optional JSON object editor with formatting and inline syntax errors, plus token limits. The harness list supplies supported protocols, native protocols, JSON support and required limits from one adapter declaration. The form uses those fields without harness-specific branches. Nonempty JSON requires a native protocol; the form explains an incompatible selection beside the editor. Help tips explain the scope of native settings. Changing the model ID, provider URL or protocol clears the native JSON so settings cannot follow an unrelated model by accident. Re-entering the required write-only API key alone does not change model identity. The key field is a required password input, never prefilled or shown and forgotten when the form closes. Core's rejection stays in red inside the form; Clear is a ConfirmDialog. Usage details opens Core’s observations in a separate dialog. Sandboxes: one navigation row to the Sandbox configuration secondary page; do not repeat its configuration facts on System. Startup settings: a line saying these are the settings Core loaded, over a table of each setting, its value and the services a change restarts. Sensitive settings show only Configured or Not set; Default and Fixed after install are neutral pills beside the value. ### One place for each task A configuration or operation has one home. Other pages link to it instead of repeating the same panel. System links to the Sandbox configuration secondary page; Nodes contains node management. Keep the configuration page flat: the resource editor is a dialog, and rollout is one status row with a details action. Put low-frequency counts and generation metadata in that dialog. Explanatory prose belongs in help tips, not rows of small print. Keep actionable errors and unresolved state visible without duplicating the whole workflow. diff --git a/apps/web/PRODUCT.md b/apps/web/PRODUCT.md index 090ca06fd..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. @@ -59,7 +59,7 @@ The console runs beside the administrator's own Core, with execution, files and - **Connect a host.** The Linux/macOS and PowerShell commands come from Core's installation read for the Session's environment; the console shows them as they are, with a link to the native installation guide, and never builds one itself. A command downloads the matching installer, installs the chosen Harnesses, starts the daemon and checks its connection. Its authorization expires after 30 minutes; the console reads a fresh one every 20 minutes, and says the command is unavailable when Core has none. No model readiness is implied. Rotating a credential requires stopping the installed daemon, replacing the configured file and starting that same daemon again. A disconnected daemon may still be running; `start` alone does not replace it. - **Typed write errors.** Known Core codes use shared bilingual copy and safe typed details. Exact Core field paths attach definite refusals to the relevant input. Unknown codes retain Core's fallback message; uncertain write outcomes stay form-level and are never retried automatically. - **Read failures.** Overview and Session log distinguish unavailable reads from successful empty results. Failed reads have a visible retry; retained or partial data says it may be incomplete or out of date, and Session filter totals stay missing while any required read has failed. Only successful empty reads show zero. -- **Local-only address.** Overview, Nodes and System warn when Core reports `local_only`, with the configuration path and apply command Core supplies as copyable instructions. Without a configuration snapshot they state what is missing. Add node is unavailable with a reason; Getting started keeps the first step to do until the public address is fixed. An unread installation address cannot complete that step, and a failed read offers Retry. +- **Local-only address.** Overview, Nodes and System warn when Core reports `local_only` and lead to System to review the public address. Add node is unavailable with a reason; Getting started keeps the first step to do until the public address is fixed. An unread installation address cannot complete that step, and a failed read offers Retry. - **Figures.** Project, Agent and key usage comes from Core's summary; Agent run, tool and activity figures are still assembled in the browser from bounded reads and state their coverage. Metrics that would need new Core endpoints are not simulated. Usage is cumulative per Session and is not billing. - Runtime CPU and memory exist only for Core-managed hosted sandboxes. - Preserve workflow safety: confirmed deletion, no automatic retry of uncertain writes, no secrets in browser storage. 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 a3de5cafa..1145cd52d 100644 --- a/apps/web/e2e/fixture-console.mjs +++ b/apps/web/e2e/fixture-console.mjs @@ -40,7 +40,7 @@ const publicUrl = () => (state.installation === "local" ? LOCAL_URL : PUBLIC_URL /** Core reports one installation ID, a canonical UUID, in the installation and the deployment. */ const INSTALLATION_ID = "7f3c2a90-5b1e-4c2d-9e3f-0a1b2c3d4e5f"; -/** GET /core/v1/installation: the address, the startup settings from config.json and what is bound to the address. */ +/** GET /core/v1/installation: the address, the startup settings Core loaded and what is bound to the address. */ function installation() { const local = state.installation === "local"; const setting = (key, value, fallback, restarts, extra = {}) => ({ key, value, default: fallback, changeable: true, sensitive: false, restarts, ...extra }); @@ -51,7 +51,6 @@ function installation() { object: "core.installation", installation_id: INSTALLATION_ID, public_url: publicUrl(), api_base_url: `${publicUrl()}/v1`, local_only: local, source_commit: release.source_commit, configuration: { - path: "/opt/oac/config.json", apply_command: "sudo oac apply", applied_at: "2026-09-24T09:30:00Z", settings: [ setting("public_url", publicUrl(), LOCAL_URL, ["core", "web"]), setting("listen_address", "127.0.0.1:8091", "127.0.0.1:8091", ["core"]), @@ -98,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/e2e/public-url.spec.ts b/apps/web/e2e/public-url.spec.ts index 14dda4215..2b3a36a27 100644 --- a/apps/web/e2e/public-url.spec.ts +++ b/apps/web/e2e/public-url.spec.ts @@ -42,12 +42,11 @@ test("explains an E2B rejection in the wizard, with a link to domain setup", asy await expect(page.getByRole("heading", { name: "Connect E2B" })).toBeVisible(); }); -test("lists the startup settings on System with where to change them", async ({ page, request }) => { +test("lists the startup settings on System", async ({ page, request }) => { await openConsole(page, request, "system"); const installation = page.getByRole("region", { name: "Installation" }); await expect(installation).toContainText("https://core.example.com/v1"); const startup = page.getByRole("region", { name: "Startup settings" }); - await expect(startup).toContainText("Change these in /opt/oac/config.json, then run sudo oac apply"); const settings = startup.getByRole("table", { name: "Startup settings" }); await expect(settings.getByRole("row", { name: /^log_level/ })).toContainText("debug"); await expect(settings.getByRole("row", { name: /^listen_address/ })).toContainText("Default"); diff --git a/apps/web/src/components/InstallationNotice.test.tsx b/apps/web/src/components/InstallationNotice.test.tsx index 68efdcef7..81a8ab535 100644 --- a/apps/web/src/components/InstallationNotice.test.tsx +++ b/apps/web/src/components/InstallationNotice.test.tsx @@ -5,7 +5,7 @@ import { InstallationNotice } from "./InstallationNotice"; const installation: CoreInstallation = { object: "core.installation", installation_id: null, public_url: "http://127.0.0.1:8091", api_base_url: "http://127.0.0.1:8091/v1", - source_commit: null, local_only: true, configuration: null, + source_commit: null, local_only: true, configuration: { settings: [] }, address_bindings: { nodes: 0, nodes_on_other_address: 0, hosted_sandboxes: 0, self_hosted_executors: 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/SandboxSetupWizard.tsx b/apps/web/src/features/sandbox/SandboxSetupWizard.tsx index d9ffce98a..79a29f2d7 100644 --- a/apps/web/src/features/sandbox/SandboxSetupWizard.tsx +++ b/apps/web/src/features/sandbox/SandboxSetupWizard.tsx @@ -1,4 +1,4 @@ -import { AgentCoreError, type InitializeSandboxDeployment, type UpdateSandboxDeployment, type SandboxE2BReadyBuild, type SandboxE2BTemplate, type SandboxProvider, type SandboxResources, type SandboxRuntimeRelease, type SandboxSpecification } from "@oac/agents-client"; +import { AgentCoreError, deploymentContract, type InitializeSandboxDeployment, type UpdateSandboxDeployment, type SandboxE2BReadyBuild, type SandboxE2BTemplate, type SandboxProvider, type SandboxResources, type SandboxRuntimeRelease, type SandboxSpecification } from "@oac/agents-client"; import { useQuery } from "@tanstack/react-query"; import { AnimatePresence } from "motion/react"; import * as m from "motion/react-m"; @@ -15,9 +15,8 @@ import { formatBytes } from "../../lib/format"; import { installationQuery } from "../../lib/installation"; import type { MessageKey } from "../../lib/locale-strings"; import { sandboxConfigurationRejection } from "../../lib/sandbox-labels"; -import { defaultSandboxResources, distributionRuntime, savedSpecification, validSandboxResources } from "./deployment-specification"; +import { defaultSandboxResources, distributionRuntime, isRuntimeRelease, isRuntimeReleaseField, RUNTIME_RELEASE_FIELDS, savedSpecification, validSandboxResources } from "./deployment-specification"; import { e2bKeyReady, e2bUpdateSelection } from "./sandbox-update"; -import { isRuntimeRelease, isRuntimeReleaseField, RUNTIME_RELEASE_FIELDS } from "./runtime-release"; import { sandboxAdmin } from "./sandbox-queries"; import "./sandbox-wizard.css"; @@ -41,6 +40,8 @@ function e2bService(apiURL?: string): E2BService { } const MIB = 2 ** 20; +const bounds = Object.fromEntries(deploymentContract.resources.map(({ name, min, max }) => [name, `${min}–${max}`])); +const resourceBounds = { cpus: bounds.cpus, memory: bounds.memory_mib, disk: deploymentContract.minimum_disk }; const EASE = [0.16, 1, 0.3, 1] as const; // Core accepts a template ID of up to 128 characters and a canonical, non-nil build UUID. const TEMPLATE = /^[a-zA-Z0-9_-]{1,128}:([0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12})$/; @@ -413,7 +414,7 @@ export function SandboxSetupWizard({ coreUrl, expectedGeneration, current, disab
{ event.preventDefault(); setStep("review"); }}> {sized ?
- {t("Each sandbox")}{t("1–255 CPUs, 512–1048576 MiB of memory. microsandbox disks are at least 1024 MiB.")} + {t("Each sandbox")}{t("{{cpus}} CPUs, {{memory}} MiB of memory. microsandbox disks are at least {{disk}} MiB.", resourceBounds)}
{ setSize("custom"); setResources({ ...resources, cpus }); setFieldRejection(null); }} /> { setSize("custom"); setResources({ ...resources, memory_mib }); setFieldRejection(null); }} /> 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/deployment-specification.test.ts b/apps/web/src/features/sandbox/deployment-specification.test.ts index f3e923f81..eac961bff 100644 --- a/apps/web/src/features/sandbox/deployment-specification.test.ts +++ b/apps/web/src/features/sandbox/deployment-specification.test.ts @@ -1,6 +1,5 @@ import { afterEach, describe, expect, it, vi } from "vitest"; -import { defaultSandboxResources, distributionRuntime, sandboxesThatFit, savedSpecification, validSandboxResources } from "./deployment-specification"; -import { isRuntimeReleaseField } from "./runtime-release"; +import { defaultSandboxResources, distributionRuntime, isRuntimeReleaseField, sandboxesThatFit, savedSpecification, validSandboxResources } from "./deployment-specification"; import standardSizes from "./standard-sizes.json"; import type { SandboxSpecification } from "@oac/agents-client"; @@ -49,14 +48,13 @@ describe("deployment resources and Runtime", () => { expect(validSandboxResources("e2b", { cpus: 2, memory_mib: 2048, root_disk_mib: 1024 })).toBe(false); expect(validSandboxResources("microsandbox", { cpus: 2, memory_mib: 2048, root_disk_mib: 1023, environment_disk_mib: 8192 })).toBe(false); }); - it("accepts any well-formed Runtime image name in the Runtime reference", async () => { + it("accepts only Core's Runtime image in the Runtime reference", async () => { const digest = "b".repeat(64); - for (const runtime_ref of [`oac-runtime@sha256:${digest}`, `custom-runtime@sha256:${digest}`]) { - vi.stubGlobal("fetch", vi.fn().mockResolvedValue(new Response(JSON.stringify({ ...manifest, runtime_ref })))); - expect((await distributionRuntime(new AbortController().signal)).microsandbox_ref).toBe(runtime_ref); - expect(isRuntimeReleaseField("microsandbox_ref", runtime_ref)).toBe(true); - } - for (const runtime_ref of [`oac-runtime:${digest}`, `oac-runtime@sha256:${"b".repeat(63)}`, `oac-runtime@sha256:${"B".repeat(64)}`, `@sha256:${digest}`]) { + const runtime_ref = `oac-runtime@sha256:${digest}`; + vi.stubGlobal("fetch", vi.fn().mockResolvedValue(new Response(JSON.stringify({ ...manifest, runtime_ref })))); + expect((await distributionRuntime(new AbortController().signal)).microsandbox_ref).toBe(runtime_ref); + expect(isRuntimeReleaseField("microsandbox_ref", runtime_ref)).toBe(true); + for (const runtime_ref of [`custom-runtime@sha256:${digest}`, `oac-runtime:${digest}`, `oac-runtime@sha256:${"b".repeat(63)}`, `oac-runtime@sha256:${"B".repeat(64)}`, `@sha256:${digest}`]) { vi.stubGlobal("fetch", vi.fn().mockResolvedValue(new Response(JSON.stringify({ ...manifest, runtime_ref })))); await expect(distributionRuntime(new AbortController().signal)).rejects.toThrow(); expect(isRuntimeReleaseField("microsandbox_ref", runtime_ref)).toBe(false); diff --git a/apps/web/src/features/sandbox/deployment-specification.ts b/apps/web/src/features/sandbox/deployment-specification.ts index 5fb2bc773..8859539f3 100644 --- a/apps/web/src/features/sandbox/deployment-specification.ts +++ b/apps/web/src/features/sandbox/deployment-specification.ts @@ -1,5 +1,4 @@ -import type { SandboxDeployment, SandboxE2BTemplateBuild, SandboxProvider, SandboxResources, SandboxRuntimeRelease, SandboxSpecification } from "@oac/agents-client"; -import { RUNTIME_REF_PATTERN } from "./runtime-release"; +import { deploymentContract, type SandboxDeployment, type SandboxE2BTemplateBuild, type SandboxProvider, type SandboxResources, type SandboxRuntimeRelease, type SandboxSpecification } from "@oac/agents-client"; import standardSizes from "./standard-sizes.json"; interface Manifest { @@ -15,12 +14,25 @@ export function defaultSandboxResources(provider: SandboxProvider): SandboxResou return { ...(provider === "microsandbox" ? standardSizes.microsandbox : standardSizes.docker) }; } +/** Core's resource rule: optional disk fields stay zero unless the provider supports disk limits. */ export function validSandboxResources(provider: SandboxProvider, resources: SandboxResources): boolean { - const bounded = (value: number | undefined, minimum: number, maximum: number) => Number.isInteger(value) && value! >= minimum && value! <= maximum; - if (!bounded(resources.cpus, 1, 255) || !bounded(resources.memory_mib, 512, 1048576)) return false; - return provider === "microsandbox" - ? bounded(resources.root_disk_mib, 1024, 4294967295) && bounded(resources.environment_disk_mib, 1024, 4294967295) - : (resources.root_disk_mib ?? 0) === 0 && (resources.environment_disk_mib ?? 0) === 0; + return deploymentContract.resources.every((rule) => { + const [min, max] = !rule.omit_zero ? [rule.min, rule.max] : provider === "microsandbox" ? [deploymentContract.minimum_disk, rule.max] : [0, 0]; + const value = resources[rule.name] ?? 0; + return Number.isInteger(value) && value >= min && value <= max; + }); +} + +const releasePatterns = Object.fromEntries(deploymentContract.runtime.map(({ name, pattern }) => [name, new RegExp(`^(?:${pattern})$`)])) as Record; + +export const RUNTIME_RELEASE_FIELDS = deploymentContract.runtime.map(({ name }) => name); + +export function isRuntimeReleaseField(field: keyof SandboxRuntimeRelease, value: string): boolean { + return releasePatterns[field].test(value); +} + +export function isRuntimeRelease(value: Partial): value is SandboxRuntimeRelease { + return RUNTIME_RELEASE_FIELDS.every((field) => typeof value[field] === "string" && releasePatterns[field].test(value[field])); } export function savedSpecification(provider: SandboxProvider, savedProvider?: SandboxProvider | "", specification?: SandboxSpecification): SandboxSpecification | null { @@ -59,12 +71,8 @@ export async function distributionRuntime(signal: AbortSignal): Promise@sha256:` shape. */ -export const RUNTIME_REF_PATTERN = /^[a-z0-9][a-z0-9._-]*@sha256:[0-9a-f]{64}$/; - -const patterns: Record = { - source_commit: /^[0-9a-f]{40}$/, - image_id: /^sha256:[0-9a-f]{64}$/, - image_manifest_digest: /^sha256:[0-9a-f]{64}$/, - microsandbox_ref: RUNTIME_REF_PATTERN, - runtime_sha256: /^[0-9a-f]{64}$/, - firmware_sha256: /^[0-9a-f]{64}$/, -}; - -export const RUNTIME_RELEASE_FIELDS = Object.keys(patterns) as (keyof SandboxRuntimeRelease)[]; - -export function isRuntimeReleaseField(field: keyof SandboxRuntimeRelease, value: string): boolean { - return patterns[field].test(value); -} - -export function isRuntimeRelease(value: Partial): value is SandboxRuntimeRelease { - return RUNTIME_RELEASE_FIELDS.every((field) => typeof value[field] === "string" && patterns[field].test(value[field]!)); -} 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/system/StartupSettings.tsx b/apps/web/src/features/system/StartupSettings.tsx index 9d999e2f5..b506b7364 100644 --- a/apps/web/src/features/system/StartupSettings.tsx +++ b/apps/web/src/features/system/StartupSettings.tsx @@ -1,10 +1,8 @@ import type { CoreInstallationConfiguration, CoreInstallationSetting } from "@oac/agents-client"; -import { Trans, useTranslation } from "react-i18next"; +import { useTranslation } from "react-i18next"; import { ValuePill } from "../../components/atoms/ValuePill"; import { Section } from "../../components/console-ui"; -import { CopyableId } from "../../components/list-ui"; -import { formatDateTime } from "../../lib/format"; function display(value: unknown): string | null { if (value === null || value === undefined) return null; @@ -16,62 +14,45 @@ function isDefault(setting: CoreInstallationSetting): boolean { } /** - * Platform › System: Core's startup settings, read-only. They live in - * config.json and take effect with the apply command; the console only says - * where to change them. A sensitive setting shows whether it is set, never - * its value. + * Platform › System: the process settings Core loaded, read-only. A + * sensitive setting shows whether it is set, never its value. */ -export function StartupSettings({ configuration }: { configuration: CoreInstallationConfiguration | null }) { - const { t, i18n } = useTranslation("system"); - const locale = i18n.resolvedLanguage; +export function StartupSettings({ configuration }: { configuration: CoreInstallationConfiguration }) { + const { t } = useTranslation("system"); return (
- {configuration === null ?

{t("startup.none")}

: <> -

- {configuration.path ? - , - command: , - }} - /> - : {t("startup.effective")}} - {configuration.applied_at ? {t("startup.appliedAt", { time: formatDateTime(Date.parse(configuration.applied_at) / 1000, locale) })} : null} -

-
- - - - - - - - - - {configuration.settings.filter((setting) => setting.key !== "public_url").map((setting) => { - const value = setting.sensitive ? null : display(setting.value); - return ( - - - - - - ); - })} - -
{t("startup.columns.key")}{t("startup.columns.value")}{t("startup.columns.restarts")}
{setting.key} - - {setting.sensitive - ? t(setting.configured ? "startup.configured" : "startup.notSet") - : value === null ? {t("startup.notSet")} : {value}} - {isDefault(setting) ? {t("startup.default")} : null} - {setting.changeable ? null : {t("startup.fixed")}} - - {setting.restarts.length ? setting.restarts.join(", ") : {t("startup.noRestart")}}
-
- } +

{t("startup.effective")}

+
+ + + + + + + + + + {configuration.settings.filter((setting) => setting.key !== "public_url").map((setting) => { + const value = setting.sensitive ? null : display(setting.value); + return ( + + + + + + ); + })} + +
{t("startup.columns.key")}{t("startup.columns.value")}{t("startup.columns.restarts")}
{setting.key} + + {setting.sensitive + ? t(setting.configured ? "startup.configured" : "startup.notSet") + : value === null ? {t("startup.notSet")} : {value}} + {isDefault(setting) ? {t("startup.default")} : null} + {setting.changeable ? null : {t("startup.fixed")}} + + {setting.restarts.length ? setting.restarts.join(", ") : {t("startup.noRestart")}}
+
); } diff --git a/apps/web/src/features/system/system.css b/apps/web/src/features/system/system.css index 1731597a4..07f783f17 100644 --- a/apps/web/src/features/system/system.css +++ b/apps/web/src/features/system/system.css @@ -59,45 +59,12 @@ color: var(--ink-3); } -/* Where startup settings are changed: the file and the apply command, both copyable. */ -.system-where { - display: flex; - flex-wrap: wrap; - align-items: baseline; - gap: 4px 12px; - margin: 0; - color: var(--ink-2); - font-size: 13px; - line-height: 24px; -} - -/* The file and the command read as chips that carry their own copy button. */ -.system-where .copyable-id { - margin: 0 2px; - padding-left: 8px; - vertical-align: middle; - background: var(--surface); - border-radius: var(--radius-chip); - box-shadow: var(--shadow-hairline); -} - -.system-where .copyable-id code { - color: var(--fg); - font-family: var(--font-mono); - font-size: 12px; -} - /* Setting names and values are the table's data, not secondary IDs. */ .data-table.system-settings code { color: var(--ink); font-size: 12px; } -.system-applied { - color: var(--ink-3); - font-size: 12.5px; -} - .system-note { margin: 0; color: var(--ink-2); 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..2f5d564a9 100644 --- a/apps/web/src/i18n/locales/en/common.ts +++ b/apps/web/src/i18n/locales/en/common.ts @@ -1,7 +1,8 @@ -import { coreErrors } from "./core-errors"; +import { coreErrorDetails, coreErrors } from "./core-errors"; export const common = { coreErrors, + coreErrorDetails, readFailure: { title: "Could not read the data", partial: "Some reads failed. Any figures and rows shown cover only data already read; they may be incomplete or out of date.", @@ -47,10 +48,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/core-errors.ts b/apps/web/src/i18n/locales/en/core-errors.ts index 802d3768f..1ffbc659e 100644 --- a/apps/web/src/i18n/locales/en/core-errors.ts +++ b/apps/web/src/i18n/locales/en/core-errors.ts @@ -1,3 +1,4 @@ +/** One message for each code in Core's shared catalog, services/core/internal/api/testdata/core-errors.json. */ export const coreErrors = { "invalid_admin_key": "The console's Core key was rejected. Rotate it on the Core host, then sign in again.", "console_sign_in_required": "Sign in to the console again.", @@ -5,23 +6,15 @@ export const coreErrors = { "console_request_invalid": "The console request was rejected. Reload the page.", "core_unreachable": "Core is unreachable. Check the Core service, then refresh.", "invalid_name": "Enter a nonempty name without control characters.", - "nameLimit": "Enter a name of at most {{max}} characters without control characters.", - "nodeNameLimit": "Enter a name of at most {{max}} UTF-8 bytes.", "invalid_node_capacity": "Enter a valid whole-number capacity; retained capacity must be at least active capacity.", - "capacityRange": "Enter a whole number from {{min}} to {{max}}; retained capacity must be at least active capacity.", "model_configuration_model_invalid": "Enter a model ID of at most 1024 UTF-8 bytes without control characters.", "harness_config_invalid": "Check the supported native fields and their values. The JSON object must be at most 16 KiB and cannot redefine Core-managed settings.", "invalid_model_provider": "Enter the complete model provider configuration.", "model_provider_base_url_invalid": "Use an HTTPS URL without credentials, query parameters or a fragment. For Anthropic Messages, leave out the /v1 version path.", "model_provider_protocol_unsupported": "This protocol is not supported by the harness.", - "protocols": "Allowed protocols: {{protocols}}.", "model_provider_api_key_invalid": "Enter a valid API key without control characters.", - "keyLimit": "Enter a valid API key of at most {{max}} characters.", "model_provider_token_limits_invalid": "Use valid token limits; output cannot exceed context, and required limits must be positive.", "invalid_sandbox_configuration": "Check the sandbox resources and Runtime release.", - "resourceRange": "Enter a whole number from {{min}} to {{max}}.", - "resourceMin": "Enter a whole number of at least {{min}}.", - "runtime": "Use a valid immutable Runtime release for this backend.", "project_archived": "This Project is archived.", "project_exists": "A Project with this name already exists.", "project_api_key_exists": "An active API key with this name already exists.", @@ -35,10 +28,31 @@ export const coreErrors = { "sandbox_in_use": "Hosted resources remain. Wait for confirmed cleanup before changing the configuration.", "runtime_node_in_use": "The node has active allocations or retained resources. Clear allocations, snapshots, reservations and pending cleanup before removal.", "runtime_node_unavailable": "The selected sandbox node is unavailable or has no capacity.", - "sandbox_admin_not_configured": "Sandbox administration is not configured on this console.", "sandbox_credential_ownership": "This E2B key cannot manage the retained deployment. Reset before changing teams.", "sandbox_credential_invalid": "The E2B API key was rejected. The saved configuration is unchanged.", "sandbox_configuration_invalid": "Select a ready immutable E2B template build with matching resources.", "sandbox_verification_unconfirmed": "E2B verification could not be confirmed. Refresh before submitting again.", - "sandbox_configuration_error": "E2B sandboxes need a public HTTPS address. Set OAC_PUBLIC_URL to an HTTPS origin." + "sandbox_configuration_error": "E2B sandboxes need a public HTTPS address. Set OAC_PUBLIC_URL to an HTTPS origin.", + "sandbox_deployment_conflict": "The sandbox deployment cannot change in its current state. Refresh and check its reset and resource state.", + "sandbox_specification_mismatch": "The saved sandbox specification does not match the deployment. Refresh to check the configuration.", + "sandbox_operation_unsupported": "The selected sandbox provider does not support this operation.", + "environment_unavailable": "The Session's environment is no longer available.", + "execution_unavailable": "Execution is not available on this Core.", + "runtime_history_unavailable": "Runtime history is unavailable on this Core.", + "runtime_history_unsupported": "Runtime history is not supported for this Session.", + "core_metrics_unavailable": "Core metrics could not be read. Try again later.", + "file_transfer_unavailable": "The file transfer is unavailable. Try again later.", + "not_found": "The requested Core resource does not exist." +} as const; + +/** Messages that format a catalogued code's typed details. */ +export const coreErrorDetails = { + "nameLimit": "Enter a name of at most {{max}} characters without control characters.", + "nodeNameLimit": "Enter a name of at most {{max}} UTF-8 bytes.", + "capacityRange": "Enter a whole number from {{min}} to {{max}}; retained capacity must be at least active capacity.", + "protocols": "Allowed protocols: {{protocols}}.", + "keyLimit": "Enter a valid API key of at most {{max}} characters.", + "resourceRange": "Enter a whole number from {{min}} to {{max}}.", + "resourceMin": "Enter a whole number of at least {{min}}.", + "runtime": "Use a valid immutable Runtime release for this backend." } as const; 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/en/system.ts b/apps/web/src/i18n/locales/en/system.ts index 01539ccb9..dda2183a5 100644 --- a/apps/web/src/i18n/locales/en/system.ts +++ b/apps/web/src/i18n/locales/en/system.ts @@ -19,12 +19,7 @@ export const system = { startup: { title: "Startup settings", help: "Core reports the process settings it loaded. A sensitive setting shows only whether it is set.", - none: "Core did not report startup settings.", effective: "These are the settings this Core process loaded.", - where: "Change these in , then run ", - copyPath: "Copy path", - copyCommand: "Copy command", - appliedAt: "Last applied {{time}}", columns: { key: "Setting", value: "Value", 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..b67c624ab 100644 --- a/apps/web/src/i18n/locales/zh-CN/common.ts +++ b/apps/web/src/i18n/locales/zh-CN/common.ts @@ -1,7 +1,8 @@ -import { coreErrors } from "./core-errors"; +import { coreErrorDetails, coreErrors } from "./core-errors"; export const common = { coreErrors, + coreErrorDetails, readFailure: { title: "无法读取数据", partial: "部分读取失败。当前数字和列表仅来自已读取的数据,可能不完整或已过期。", @@ -47,10 +48,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/core-errors.ts b/apps/web/src/i18n/locales/zh-CN/core-errors.ts index fb30419dd..0c42e7c88 100644 --- a/apps/web/src/i18n/locales/zh-CN/core-errors.ts +++ b/apps/web/src/i18n/locales/zh-CN/core-errors.ts @@ -5,23 +5,15 @@ export const coreErrors = { "console_request_invalid": "控制台请求被拒绝,请重新加载页面。", "core_unreachable": "无法连接 Core。请检查 Core 服务后刷新。", "invalid_name": "请输入非空名称,不要包含控制字符。", - "nameLimit": "名称最多 {{max}} 个字符,且不能包含控制字符。", - "nodeNameLimit": "节点名称最多 {{max}} 个 UTF-8 字节。", "invalid_node_capacity": "请输入有效的整数容量;保留容量不能小于运行容量。", - "capacityRange": "请输入 {{min}} 到 {{max}} 的整数;保留容量不能小于运行容量。", "model_configuration_model_invalid": "请输入模型 ID,最多 1024 个 UTF-8 字节,且不能包含控制字符。", "harness_config_invalid": "请检查支持的原生字段及其取值。JSON 对象不能超过 16 KiB,也不能重复定义 Core 管理的设置。", "invalid_model_provider": "请填写完整的模型服务配置。", "model_provider_base_url_invalid": "请使用 HTTPS 地址,不要包含凭证、查询参数或片段。Anthropic Messages 地址不要包含 /v1 版本路径。", "model_provider_protocol_unsupported": "此执行引擎不支持该协议。", - "protocols": "支持的协议:{{protocols}}。", "model_provider_api_key_invalid": "请输入有效的 API Key,不要包含控制字符。", - "keyLimit": "请输入有效的 API Key,长度最多 {{max}} 个字符。", "model_provider_token_limits_invalid": "请填写有效的 token 限制;输出不能超过上下文,必填限制必须大于零。", "invalid_sandbox_configuration": "请检查沙箱资源和 Runtime 版本。", - "resourceRange": "请输入 {{min}} 到 {{max}} 的整数。", - "resourceMin": "请输入不小于 {{min}} 的整数。", - "runtime": "请使用适用于此后端的有效不可变 Runtime 版本。", "project_archived": "此项目已归档。", "project_exists": "此项目名称已被使用。", "project_api_key_exists": "此名称已被使用中的 API Key 占用。", @@ -35,10 +27,30 @@ export const coreErrors = { "sandbox_in_use": "仍有托管资源。请等待 Core 确认清理完成后再修改配置。", "runtime_node_in_use": "节点仍有活跃分配或保留资源。请先清理资源分配、快照、预留资源和待清理项,再移除节点。", "runtime_node_unavailable": "所选沙箱节点不可用或容量不足。", - "sandbox_admin_not_configured": "此控制台尚未配置沙箱管理权限。", "sandbox_credential_ownership": "此 E2B 密钥无法管理当前保留的部署。更换团队前请先重置。", "sandbox_credential_invalid": "E2B API 密钥被拒绝。已保存的配置未改变。", "sandbox_configuration_invalid": "请选择已就绪且资源匹配的不可变 E2B 模板构建。", "sandbox_verification_unconfirmed": "无法确认 E2B 验证结果。请刷新后再提交。", - "sandbox_configuration_error": "E2B 沙箱需要可从互联网访问的 HTTPS 地址,请把 OAC_PUBLIC_URL 设为一个 HTTPS 源地址。" + "sandbox_configuration_error": "E2B 沙箱需要可从互联网访问的 HTTPS 地址,请把 OAC_PUBLIC_URL 设为一个 HTTPS 源地址。", + "sandbox_deployment_conflict": "沙箱部署在当前状态下无法更改。请刷新并检查重置和资源状态。", + "sandbox_specification_mismatch": "已保存的沙箱规格与部署不一致。请刷新检查配置。", + "sandbox_operation_unsupported": "所选沙箱提供商不支持此操作。", + "environment_unavailable": "此 Session 的环境已不可用。", + "execution_unavailable": "此 Core 不提供执行功能。", + "runtime_history_unavailable": "此 Core 上的 Runtime 历史不可用。", + "runtime_history_unsupported": "此 Session 不支持 Runtime 历史。", + "core_metrics_unavailable": "无法读取 Core 指标,请稍后重试。", + "file_transfer_unavailable": "文件传输不可用,请稍后重试。", + "not_found": "请求的 Core 资源不存在。" +} as const; + +export const coreErrorDetails = { + "nameLimit": "名称最多 {{max}} 个字符,且不能包含控制字符。", + "nodeNameLimit": "节点名称最多 {{max}} 个 UTF-8 字节。", + "capacityRange": "请输入 {{min}} 到 {{max}} 的整数;保留容量不能小于运行容量。", + "protocols": "支持的协议:{{protocols}}。", + "keyLimit": "请输入有效的 API Key,长度最多 {{max}} 个字符。", + "resourceRange": "请输入 {{min}} 到 {{max}} 的整数。", + "resourceMin": "请输入不小于 {{min}} 的整数。", + "runtime": "请使用适用于此后端的有效不可变 Runtime 版本。" } as const; 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/i18n/locales/zh-CN/system.ts b/apps/web/src/i18n/locales/zh-CN/system.ts index 072fd56c5..770c992ce 100644 --- a/apps/web/src/i18n/locales/zh-CN/system.ts +++ b/apps/web/src/i18n/locales/zh-CN/system.ts @@ -21,12 +21,7 @@ export const system: TranslationShape = { startup: { title: "启动设置", help: "Core 报告它加载的进程设置。敏感设置只显示是否已设置。", - none: "Core 没有报告启动设置。", effective: "这些是这个 Core 进程加载的设置。", - where: "在 中修改,然后运行 ", - copyPath: "复制路径", - copyCommand: "复制命令", - appliedAt: "上次应用于 {{time}}", columns: { key: "设置", value: "值", 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 new AgentCoreError("unparsed backend prose", status, code, param, undefined, details); describe("Core error catalog localization", () => { - it("covers both languages, without relying on backend prose", () => { - expect(Object.keys(chinese).sort()).toEqual(Object.keys(english).sort()); - for (const code of ["invalid_admin_key", "console_sign_in_required", "console_origin_rejected", "console_request_invalid", "core_unreachable", "invalid_name", "invalid_node_capacity", "invalid_model_provider", "model_provider_base_url_invalid", "model_provider_protocol_unsupported", "model_provider_api_key_invalid", "model_provider_token_limits_invalid", "invalid_sandbox_configuration", "sandbox_credential_invalid", "sandbox_configuration_invalid", "sandbox_credential_ownership", "sandbox_verification_unconfirmed", "sandbox_generation_stale", "sandbox_admin_not_configured", "project_archived", "project_exists", "project_api_key_exists"]) { + it("localizes exactly Core's shared catalog in both languages, without relying on backend prose", () => { + expect(Object.keys(english).sort()).toEqual([...catalog].sort()); + expect(Object.keys(chinese).sort()).toEqual([...catalog].sort()); + for (const code of catalog) { expect(knownCoreError(failure(code), en)).not.toBeNull(); expect(coreError(failure(code), en)).not.toContain("backend prose"); expect(coreError(failure(code), zh)).toMatch(/[\u4e00-\u9fff]/); diff --git a/apps/web/src/lib/core-error.ts b/apps/web/src/lib/core-error.ts index 2e0405cf1..3f7f303cf 100644 --- a/apps/web/src/lib/core-error.ts +++ b/apps/web/src/lib/core-error.ts @@ -1,39 +1,32 @@ -import { AgentCoreError } from "@oac/agents-client"; +import { AgentCoreError, deploymentContract, modelProviderProtocols } from "@oac/agents-client"; import type { TFunction } from "i18next"; -import type { coreErrors } from "../i18n/locales/en/core-errors"; +import { coreErrors } from "../i18n/locales/en/core-errors"; -const codes = new Set([ - "sandbox_credential_ownership", "sandbox_credential_invalid", "sandbox_configuration_invalid", "sandbox_verification_unconfirmed", "sandbox_configuration_error", - "sandbox_generation_stale", "sandbox_reset_required", "sandbox_reset_in_progress", "sandbox_not_configured", "sandbox_in_use", "runtime_node_in_use", "runtime_node_unavailable", "sandbox_admin_not_configured", - "invalid_admin_key", "console_sign_in_required", "console_origin_rejected", "console_request_invalid", "core_unreachable", - "invalid_name", "invalid_node_capacity", "invalid_model_provider", "model_configuration_model_invalid", "harness_config_invalid", "model_provider_base_url_invalid", - "model_provider_protocol_unsupported", "model_provider_api_key_invalid", "model_provider_token_limits_invalid", - "invalid_sandbox_configuration", "project_archived", "project_exists", "project_api_key_exists", - "executor_credential_exists", "credential_storage_unavailable", "internal_error", -]); +const protocols: ReadonlySet = new Set(modelProviderProtocols); +const resourceParams: ReadonlySet = new Set(deploymentContract.resources.map(({ name }) => `resources.${name}`)); /** Only catalogued, correctly typed detail keys can enter localized text. */ export function knownCoreError(error: unknown, t: TFunction<"common">, nameUnit: "characters" | "bytes" = "characters"): string | null { - if (!(error instanceof AgentCoreError) || !codes.has(error.code as keyof typeof coreErrors)) return null; + if (!(error instanceof AgentCoreError) || !error.code || !Object.hasOwn(coreErrors, error.code)) return null; const number = (key: string) => { const value = error.details?.[key]; return typeof value === "number" && Number.isSafeInteger(value) && value >= 0 ? value : undefined; }; const maxLength = number("max_length"); - if (error.code === "invalid_name" && maxLength !== undefined) return t(nameUnit === "bytes" ? "coreErrors.nodeNameLimit" : "coreErrors.nameLimit", { max: maxLength }); - if (error.code === "model_provider_api_key_invalid" && maxLength !== undefined) return t("coreErrors.keyLimit", { max: maxLength }); + if (error.code === "invalid_name" && maxLength !== undefined) return t(nameUnit === "bytes" ? "coreErrorDetails.nodeNameLimit" : "coreErrorDetails.nameLimit", { max: maxLength }); + if (error.code === "model_provider_api_key_invalid" && maxLength !== undefined) return t("coreErrorDetails.keyLimit", { max: maxLength }); const min = number("min"), max = number("max"); - if (error.code === "invalid_node_capacity" && min !== undefined && max !== undefined && min <= max) return t("coreErrors.capacityRange", { min, max }); + if (error.code === "invalid_node_capacity" && min !== undefined && max !== undefined && min <= max) return t("coreErrorDetails.capacityRange", { min, max }); if (error.code === "invalid_sandbox_configuration") { - if (error.param === "runtime") return t("coreErrors.runtime"); - if (["resources.cpus", "resources.memory_mib", "resources.root_disk_mib", "resources.environment_disk_mib"].includes(error.param ?? "") && min !== undefined) { - if (max !== undefined && min <= max) return t("coreErrors.resourceRange", { min, max }); - if (max === undefined) return t("coreErrors.resourceMin", { min }); + if (error.param === "runtime") return t("coreErrorDetails.runtime"); + if (resourceParams.has(error.param) && min !== undefined) { + if (max !== undefined && min <= max) return t("coreErrorDetails.resourceRange", { min, max }); + if (max === undefined) return t("coreErrorDetails.resourceMin", { min }); } } if (error.code === "model_provider_protocol_unsupported") { - const protocols = error.details?.allowed_protocols; - if (Array.isArray(protocols) && protocols.length > 0 && protocols.every((value) => ["responses", "anthropic"].includes(value))) return t("coreErrors.protocols", { protocols: protocols.join(", ") }); + const allowed = error.details?.allowed_protocols; + if (Array.isArray(allowed) && allowed.length > 0 && allowed.every((value) => protocols.has(value))) return t("coreErrorDetails.protocols", { protocols: allowed.join(", ") }); } return t(`coreErrors.${error.code as keyof typeof coreErrors}`); } diff --git a/apps/web/src/lib/docker-guide-config.test.ts b/apps/web/src/lib/docker-guide-config.test.ts deleted file mode 100644 index 4dfd769ad..000000000 --- a/apps/web/src/lib/docker-guide-config.test.ts +++ /dev/null @@ -1,95 +0,0 @@ -import { describe, expect, it } from "vitest"; - -import { - loadLocalDockerBackendGuideProfile, - loadLocalDockerGuideProfile, -} from "./docker-guide-config"; - -const valid = { - 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", -}; - -describe("local Docker guide configuration", () => { - 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 a9e7a6171..880588c9c 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.": "无法读取控制台配置。请刷新重试。", @@ -366,7 +365,7 @@ export const chinese = { "{{name}} will be removed from this deployment.": "{{name}} 将从此部署中移除。", "Open {{name}}": "打开 {{name}}", "Remove {{name}}": "移除 {{name}}", - "1–255 CPUs, 512–1048576 MiB of memory. microsandbox disks are at least 1024 MiB.": "CPU 1–255 核,内存 512–1048576 MiB;microsandbox 的磁盘至少 1024 MiB。", + "{{cpus}} CPUs, {{memory}} MiB of memory. microsandbox disks are at least {{disk}} MiB.": "CPU {{cpus}} 核,内存 {{memory}} MiB;microsandbox 的磁盘至少 {{disk}} MiB。", "Advanced settings": "高级设置", "CPUs": "CPU(核)", "Check this value": "请检查这个值", @@ -426,9 +425,6 @@ export const chinese = { "Change the sandbox configuration": "修改沙箱配置", "The address nodes and sandboxes use to reach Core.": "节点和沙箱访问 Core 使用的地址。", "Managed in System": "在系统中管理", - "Config file": "配置文件", - "Then run": "然后运行", - "Copy path": "复制路径", "Set a public address before connecting remote nodes; E2B sandboxes need an HTTPS one.": "连接远程节点前,请先设置公开地址;E2B 沙箱需要 HTTPS 地址。", "Enter the E2B key again to save.": "请重新输入 E2B key 后再保存。", "Enter the key": "输入 key", diff --git a/apps/web/src/lib/locale.test.ts b/apps/web/src/lib/locale.test.ts index fc47a3e83..439cefe4b 100644 --- a/apps/web/src/lib/locale.test.ts +++ b/apps/web/src/lib/locale.test.ts @@ -20,13 +20,12 @@ describe("sandbox localization", () => { } expect(sandboxDiagnosticMessage("", "zh")).toBeNull(); }); - it("shows Core's reason for a refusal, names an unconfigured console, and keeps other failures to the console's words", () => { - expect(sandboxRequestError(new AgentCoreError("raw secret", 503, "sandbox_admin_not_configured"), "zh")).toBe("此控制台尚未配置沙箱管理权限。"); + it("shows Core's reason for a refusal and keeps other failures to the console's words", () => { // A code with one exact meaning keeps the console's localized words. expect(sandboxRequestError(new AgentCoreError("Node node-edge still has allocations.", 409, "runtime_node_in_use"), "zh")).toBe("节点仍有活跃分配或保留资源。请先清理资源分配、快照、预留资源和待清理项,再移除节点。"); // A refusal is Core's to explain: one code, such as a 409 conflict, covers several reasons. expect(sandboxRequestError(new AgentCoreError("This console is read-only.", 403), "zh")).toBe("This console is read-only."); - expect(sandboxRequestError(new AgentCoreError("expected_generation is stale.", 409, "sandbox_deployment_conflict"), "zh")).toBe("expected_generation is stale."); + expect(sandboxRequestError(new AgentCoreError("The session is busy.", 409, "conflict_error"), "zh")).toBe("The session is busy."); // A sandbox refusal whose reason the client withheld is named without it. expect(sandboxRequestError(new AgentCoreError("withheld", 400, "sandbox_configuration_unconfirmed"), "zh")).toBe("Core 拒绝了这个沙箱配置。"); expect(sandboxRequestError(new AgentCoreError("raw secret", 502), "zh")).not.toContain("raw secret"); 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/sandbox-labels.ts b/apps/web/src/lib/sandbox-labels.ts index 0c92dc5f5..0995dd7aa 100644 --- a/apps/web/src/lib/sandbox-labels.ts +++ b/apps/web/src/lib/sandbox-labels.ts @@ -1,4 +1,4 @@ -import { AgentCoreError, type SandboxNode, type SandboxProvider } from "@oac/agents-client"; +import { AgentCoreError, sandboxConfigurationUnconfirmed, type SandboxNode, type SandboxProvider } from "@oac/agents-client"; import i18n from "../i18n"; import { knownCoreError } from "./core-error"; import { translate, type Locale } from "./locale"; @@ -19,7 +19,7 @@ export function sandboxRequestError(error: unknown, locale: Locale): string { let key: MessageKey = "The sandbox request failed. Refresh to check the current state before trying again."; if (error instanceof AgentCoreError) { const refused = !sandboxWriteUncertain(error); - if (error.code === "sandbox_configuration_unconfirmed") { if (refused) key = "Core rejected the sandbox configuration."; else if (error.status >= 500) key = "The sandbox service is unavailable. Refresh to check the current state."; } + if (error.code === sandboxConfigurationUnconfirmed) { if (refused) key = "Core rejected the sandbox configuration."; else if (error.status >= 500) key = "The sandbox service is unavailable. Refresh to check the current state."; } else if (refused) { if (error.message) return error.message; key = "The sandbox request was rejected. Refresh to check the current state."; 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 fe39e5e66..ca0356b5c 100644 --- a/contracts/agents-api/admin-api.md +++ b/contracts/agents-api/admin-api.md @@ -132,12 +132,12 @@ Core writes this record in the same transaction that creates the Session. Later | Field | Meaning | | --- | --- | | `object` | `core.installation` | -| `installation_id` | The installation ID from `state.json` ([installation directory](../../docs/configuration.md#installation-directory)); null when Core runs without the sandbox manager | +| `installation_id` | The installation ID from `OAC_INSTALLATION_ID_FILE` ([Compose installations](../../docs/configuration.md#compose-installations)); null when Core runs without the sandbox manager | | `public_url` | The [`public_url`](../../docs/configuration.md#settings) setting: the origin applications, nodes, sandboxes and self-hosted executors use. Null when unset | | `api_base_url` | `public_url` followed by `/v1`, the `OPENAI_BASE_URL` for Project API keys. Null when `public_url` is null | | `local_only` | True when `public_url` names a loopback host, which only the Core host reaches | | `source_commit` | The full source commit Core was built from; null for development builds | -| `configuration` | The process settings Core loaded from its environment. `path` and `apply_command` are empty, and `applied_at` is null | +| `configuration` | The process settings Core loaded from its environment, under `settings` | | `address_bindings` | What a change of `public_url` affects, counted on each read | `configuration.settings` has one entry per setting Core loaded, with its dotted `key`, effective `value`, `default`, whether it is `changeable`, whether it is `sensitive`, and the services it `restarts` (`core`, `web`, `database`). @@ -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-errors.md b/contracts/agents-api/core-errors.md index 00be4d1c5..3873dcdf5 100644 --- a/contracts/agents-api/core-errors.md +++ b/contracts/agents-api/core-errors.md @@ -8,7 +8,7 @@ Errors on `/core/v1` use this envelope. `message` is safe English text; `code` a {"error":{"message":"A valid Core key is required as the bearer credential.","type":"invalid_request_error","code":"invalid_admin_key","param":null}} ``` -Errors on `/v1` and `/api/v1` keep their own envelopes and never carry `details`. +Errors on `/v1` and `/api/v1` keep their own envelopes and never carry `details`. The tables below list every code a `/core/v1` or console caller can receive, except the [wire vocabulary](./wire-semantics.md#errors), such as `invalid_request`, `not_found_error` or `idempotency_conflict`, which keeps its `/v1` meaning. Codes of `/v1` Session input and of the machine routes, such as `turn_conflict` or `invalid_node_credential`, never reach these callers. The shared catalog `services/core/internal/api/testdata/core-errors.json` holds exactly the listed codes. A Go test requires a producer for each, an exact match with these tables and a catalog entry for every other code Core's error writers produce; Web's tests require a message for exactly these codes in each language. ## Optional details @@ -47,7 +47,7 @@ A `POST` or `PUT /core/v1/sandbox/deployment` ([sandbox deployment](./sandbox-de | 409 | `sandbox_credential_ownership` | The candidate credential cannot manage the retained deployment; reset before changing accounts | `credential` | | 503 | `sandbox_verification_unconfirmed` | Verification, receipt settlement or the credential fence could not be confirmed | null | -On every deployment write, the typed client replaces the message of these codes and of the other `sandbox_*` deployment codes with fixed local text. It keeps only the `current_generation`, `allocations`, `pending`, `min` and `max` details, and keeps `param` only when status, code and param match the table or the `invalid_sandbox_configuration` rows below exactly. `409 sandbox_configuration_error` becomes fixed public-URL guidance with a null `param`, even for a `PUT` without a key. Any other error becomes `sandbox_configuration_unconfirmed` and is not resent, because a rejection could echo the key. +On every deployment write, the typed client replaces the message of these codes and of the other `sandbox_*` deployment codes with fixed local text. It keeps only the `current_generation`, `allocations`, `pending`, `min` and `max` details, and keeps `param` only when status, code and param match the table or the `invalid_sandbox_configuration` rows below exactly. `409 sandbox_configuration_error` becomes fixed public-URL guidance with a null `param`, even for a `PUT` without a key. Any other error becomes the client-only code `sandbox_configuration_unconfirmed`, which Core never returns, and is not resent, because a rejection could echo the key. ## Operation validation @@ -71,6 +71,35 @@ Each code returns HTTP 400 with `type: "invalid_request_error"`. A missing, malf Bounds are validation constants, never submitted values. Node names are limited in bytes; Project and key names in trimmed Unicode characters without control characters. Only the first failure is reported, in this order: model provider URL, protocol, key, general limits, the Harness's protocol, then the Harness's required limits; sandbox resources CPU, memory, disk, then Runtime. Model-provider field errors inside a `model_provider` object keep that object's field as `param`. An unknown sandbox provider returns an error without these fields. +## Other administration errors + +These codes have null `param` and no `details`. [Sandbox deployment](./sandbox-deployment.md#errors) describes when each sandbox code occurs. + +| HTTP | Code | Meaning | +| --- | --- | --- | +| 400 | `sandbox_operation_unsupported` | The selected sandbox provider does not support the operation | +| 401 | `invalid_admin_key` | The bearer credential is not a valid Core key | +| 404 | `not_found` | The operation does not exist, the Harness is unknown, or the Harness has no deployment default model provider | +| 409 | `project_archived` | The target Project is archived | +| 409 | `project_exists` | The Project ID already exists | +| 409 | `project_api_key_exists` | The API key ID already exists | +| 409 | `executor_credential_exists` | The executor credential ID already exists; rotate it to replace the secret | +| 409 | `sandbox_not_configured` | The sandbox deployment is not configured | +| 409 or 503 | `sandbox_reset_in_progress` | A sandbox reset is in progress | +| 409 | `sandbox_configuration_error` | The installation cannot serve the selected provider, such as E2B while the public URL is loopback | +| 409 | `sandbox_deployment_conflict` | The sandbox deployment cannot change in its current state | +| 409 | `sandbox_specification_mismatch` | The saved deployment specification is no longer valid for its provider | +| 409 | `runtime_node_in_use` | The node still holds allocations, snapshots, reservations or pending cleanup | +| 409 | `environment_unavailable` | The Session's environment is no longer available, such as an archive on a Core without execution | +| 409 | `runtime_history_unsupported` | Runtime history is not supported for the Session | +| 500 | `internal_error` | Core could not complete the operation | +| 503 | `runtime_node_unavailable` | No sandbox node is available or has capacity | +| 503 | `credential_storage_unavailable` | Core has no credential encryption key | +| 503 | `execution_unavailable` | Execution is not available on this Core | +| 503 | `runtime_history_unavailable` | Durable Runtime history is not configured or temporarily unavailable | +| 503 | `core_metrics_unavailable` | Core metrics could not be read | +| 503 | `file_transfer_unavailable` | Bounded content transfer is unavailable | + ## Diagnostic failure categories The [Session and Turn diagnostics reads](./session-diagnostics.md) return these categories inside a successful 200 snapshot, not as an error envelope. Public `/v1` Turn errors do not change. `params` is `{}` unless the table says otherwise. diff --git a/contracts/agents-api/core.openapi.yaml b/contracts/agents-api/core.openapi.yaml index c3412ffd4..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 @@ -437,8 +433,7 @@ definitions: configuration: allOf: - $ref: '#/definitions/api.InstallationConfiguration' - description: The process settings Core loaded. path and apply_command are empty, and applied_at is null, because Core reports its environment rather than an installer file. - x-nullable: true + description: The process settings Core loaded. installation_id: description: The ID in OAC_INSTALLATION_ID_FILE; null when Core runs without the sandbox manager. type: string @@ -461,16 +456,6 @@ definitions: type: object api.InstallationConfiguration: properties: - applied_at: - description: Null when Core reports its own environment. - type: string - x-nullable: true - apply_command: - description: Command that applies config.json changes. Empty when Core reports its own environment. - type: string - path: - description: Absolute host path of config.json. Empty when Core reports its own environment. - type: string settings: items: $ref: '#/definitions/api.InstallationSetting' @@ -3404,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/environment-executor-credentials.md b/contracts/agents-api/environment-executor-credentials.md index fad59755c..bd58ce767 100644 --- a/contracts/agents-api/environment-executor-credentials.md +++ b/contracts/agents-api/environment-executor-credentials.md @@ -35,7 +35,7 @@ The installer calls these machine routes on Core: | `POST /api/v1/agent-daemon/installation` | Grant | The frozen binding: `version`, `protocol_version`, `environment_id`, `remote_url`, `workspace_directory`, `harness` | | `POST /api/v1/agent-daemon/installation/claim` | Grant | `{"executor_token":"SECRET"}`; 204 | -An invalid or expired grant returns 401 `installation_authorization_invalid`. Without matching installers the grant routes return 503 `installation_unavailable`. Core signs each grant with the installation's [`secrets/credential.key`](../../docs/configuration.md#installation-directory); without a configured key, the Session responses and Core-key read above and the grant routes return 503 `credential_storage_unavailable`. A malformed secret returns 400. Artifact routes carry no credential, and the grant is sent only to Core, never to an artifact host. +An invalid or expired grant returns 401 `installation_authorization_invalid`. Without matching installers the grant routes return 503 `installation_unavailable`. Core signs each grant with the installation's [`secrets/core/credential.key`](../../docs/configuration.md#compose-installations); without a configured key, the Session responses and Core-key read above and the grant routes return 503 `credential_storage_unavailable`. A malformed secret returns 400. Artifact routes carry no credential, and the grant is sent only to Core, never to an artifact host. ## Core-key routes diff --git a/contracts/agents-api/environments.md b/contracts/agents-api/environments.md index 23820a684..2686ed7c8 100644 --- a/contracts/agents-api/environments.md +++ b/contracts/agents-api/environments.md @@ -271,7 +271,7 @@ session = client.beta.agents.sessions.create( - Responses carry safe metadata and never `env`, `setup_commands` bodies or inline file data. - List uses `after`, `limit` (default 20; 0 is treated as 1 and values above 100 as 100) and `order` (default `desc`), ordered by creation time and ID. Missing and foreign Template IDs and cursors return the same 404. - Update: an omitted field keeps its value and a supplied field replaces it. Null clears `name` and every list and resets `network` to enabled. -- Writes and Session resolution that seal or open confidential content (files, env, setup commands, Skills, Plugins) need Core's [credential key](../../docs/configuration.md#installation-directory); metadata reads do not. +- Writes and Session resolution that seal or open confidential content (files, env, setup commands, Skills, Plugins) need Core's [credential key](../../docs/configuration.md#compose-installations); metadata reads do not. - A Session resolves `environment_template_id` within its Project once, at creation, freezes the effective configuration and never passes the Template ID to the Provider or Runtime. Updating or deleting a Template never changes an existing Session. Creation retries recover the recorded caller intent before reading the Template, even after it is deleted; a changed intent conflicts. ### Inheritance diff --git a/contracts/agents-api/harness-onboarding.md b/contracts/agents-api/harness-onboarding.md index 300464981..a11c7075e 100644 --- a/contracts/agents-api/harness-onboarding.md +++ b/contracts/agents-api/harness-onboarding.md @@ -1,5 +1,5 @@ --- -title: "Add a native Harness to OpenAgentCore" +title: "Add a Harness" --- A **Harness** is a native agent engine (Codex, Claude Code, MiniMax Code) that runs the model and tool loop. A **Harness adapter** translates the Runtime's Executor and Turn contract into that engine's SDK or protocol. This document is the Runtime–Harness protocol: the adapter interfaces and their lifecycle obligations, registration, Core qualification and acceptance. [Harness capabilities](./harness-capabilities.md) records what each current Harness supports. diff --git a/contracts/agents-api/model-execution.md b/contracts/agents-api/model-execution.md index 71a9deb39..73b879cff 100644 --- a/contracts/agents-api/model-execution.md +++ b/contracts/agents-api/model-execution.md @@ -57,7 +57,7 @@ The deployment default holds the operator's key, so it applies only to operator- An empty Session execution extension is invalid. An explicit null provider requests inheritance; an empty or partial provider object is invalid. Unknown, duplicate or output-only saved-provider fields are rejected. A saved Agent without a Harness may save a valid bundle; its Harness compatibility is checked at Session admission. A provider-only Agent update keeps the saved Harness and validates the merged combination under the row lock. The Session's inline `agent.x_agents_core` accepts `harness` and `harness_config`; the provider override belongs at the request's top level. -Core reads the Agent configuration and encrypted bundle from one database snapshot; an explicit complete Session override needs no decryption of the saved bundle. The Session's own encrypted snapshot is written atomically with the Session and its Environment. Existing Sessions never consult the Agent again: edits, key replacement, deletion, suspension and restarts cannot change their model, Harness or provider. A missing or wrong encryption key fails closed; keep the same [credential key](../../docs/configuration.md#installation-directory) across restarts. There is no Turn-level override. +Core reads the Agent configuration and encrypted bundle from one database snapshot; an explicit complete Session override needs no decryption of the saved bundle. The Session's own encrypted snapshot is written atomically with the Session and its Environment. Existing Sessions never consult the Agent again: edits, key replacement, deletion, suspension and restarts cannot change their model, Harness or provider. A missing or wrong encryption key fails closed; keep the same [credential key](../../docs/configuration.md#compose-installations) across restarts. There is no Turn-level override. New hosted requests, and requests that omit the inline model, record caller intent before resolving mutable defaults. Other inline requests, such as `none`, keep the resolved-request retry rule; that hash leaves out the deployment default, so changing the default does not change their retry identity. A matching creation retry recovers the committed Session before resolving the Agent or provider again and enqueues no further input. Streaming is outside the retry identity. The [TypeScript client](https://github.com/MiniMax-AI/OpenAgentCore/blob/main/packages/agents-client/README.md#saved-agent-and-deployment-defaults) shows saved Agents and deployment defaults. diff --git a/contracts/agents-api/runtime-observability.md b/contracts/agents-api/runtime-observability.md index 226fc2ca8..31fe8e4ff 100644 --- a/contracts/agents-api/runtime-observability.md +++ b/contracts/agents-api/runtime-observability.md @@ -89,7 +89,7 @@ Periodic collection runs only with the execution worker (Core started with `OAC_ The sampler sweeps once at startup and again each sampling interval after the previous sweep ends. A sweep is a keyset scan, in Session ID order, of the Sessions that are not deleted, are `openai_hosted` and have no released allocation. It reads pages of 32 Sessions through the same resolver and sources as current reads, with eight concurrent reads and two seconds per source. The sampler checks the lease before each page and every 100 ms during a sweep, cancels in-flight reads when ownership is lost, and checks it again before handing each record to export. A failed row does not stop the sweep, and an incomplete sweep is repeated at the next interval. -Every observation, current or periodic, is marked with its collection source, `on_read` or `periodic`, and handed to each exporter's bounded queue. A full queue drops the record, which becomes a missing sample, never a zero. The PostgreSQL history store and the optional OTLP exporter have independent queues, so an exporter outage cannot delay local history or execution. The [`core.runtime_history` settings](../../docs/configuration.md#settings) set the interval, queue capacity, timeout and OTLP destination. +Every observation, current or periodic, is marked with its collection source, `on_read` or `periodic`, and handed to each exporter's bounded queue. A full queue drops the record, which becomes a missing sample, never a zero. The PostgreSQL history store and the optional OTLP exporter have independent queues, so an exporter outage cannot delay local history or execution. The [Runtime history file](../../docs/configuration.md#runtime-history-file) sets the interval, queue capacity, timeout and OTLP destination. ### Stored history diff --git a/contracts/agents-api/sandbox-deployment.md b/contracts/agents-api/sandbox-deployment.md index 2af2c9244..94e62f090 100644 --- a/contracts/agents-api/sandbox-deployment.md +++ b/contracts/agents-api/sandbox-deployment.md @@ -224,6 +224,6 @@ Storage and credential failures stay errors: an empty or failed read never prove ## Canonical node specification -`sandbox/deployment_contract.go` owns the resource bounds, provider requirements, release patterns and canonical field order; `sandbox/deployment.go` applies them in Core. The installer consumes the generated declaration in `deploy/node/node_spec.py`, so there is no second set of limits or patterns. Regenerate it from the repository root with `go run ./services/core/cmd/specification-contract -write`; the sandbox Go tests, part of `make check`, reject a stale projection. +`sandbox/deployment_contract.go` owns the resource bounds, provider requirements, release patterns and canonical field order; `sandbox/deployment.go` applies them in Core. The installer consumes the generated declaration in `deploy/node/node_spec.py`, and the TypeScript client and Web the generated bounds and patterns in `packages/agents-client/src/deployment-contract.ts`, so there is no second set of limits or patterns. Regenerate both from the repository root with `go run ./services/core/cmd/specification-contract -write`; the sandbox Go tests, part of `make check`, reject a stale projection. The specification digest is the SHA-256 of compact UTF-8 JSON with `provider` first, then `resources`, then `runtime` when the provider requires it. Resource and Runtime fields follow the contract's declaration order; zero optional disk fields are omitted and required fields stay present. Release identities are lowercase ASCII, and the digest never depends on the incoming field order or whitespace. `services/core/internal/sandbox/testdata/deployment-contract.json` holds shared acceptance cases, exact canonical bytes and digests that both the Go and Python tests consume. diff --git a/contracts/agents-api/sessions-events.md b/contracts/agents-api/sessions-events.md index 1ce4367df..7615b7384 100644 --- a/contracts/agents-api/sessions-events.md +++ b/contracts/agents-api/sessions-events.md @@ -39,7 +39,7 @@ A Session stays usable after a Turn fails: new input starts a new Turn. Later re - **Cancellation.** A queued Turn is cancelled without a live Runtime. A running Turn is cancelled when the Runtime confirms it; completion can win that race. The Turn has stopped when it reads `cancelled`, not when the request returns. A cancellation on an idle Session with no pending input is accepted and has no effect; while an input reservation is pending, it returns 409. - **Function results.** `turn_id`, `call_id` and `success` are required; `output` and `error` are optional and nullable ([content rules](./message-content.md#function-results)). An identical repeated result returns 202 without another application or event. The result Item appears when the harness applies the result; a result that cancellation prevents from being applied stays stored but produces no Item. - **Queueing.** A queued Turn starts when a Runtime that supports the Session's harness and configuration is connected and one of Core's [`core.execution_concurrency`](../../docs/configuration.md#settings) work slots is free. A Session stays bound to the Runtime that first ran it. -- **Execution availability.** A service without execution returns 503 `execution_unavailable`, and a Worker that loses execution ownership returns 503. A Session created without a model provider rejects new messages with 400 `model_provider_required` ([model execution](./model-execution.md)). +- **Execution availability.** A service without execution returns 503 `execution_unavailable`, and a Worker that loses execution ownership returns 503. ### Sessions with an Environment diff --git a/contracts/agents-api/v1/items_test.go b/contracts/agents-api/v1/items_test.go index 5e5c154e9..a507c1319 100644 --- a/contracts/agents-api/v1/items_test.go +++ b/contracts/agents-api/v1/items_test.go @@ -64,15 +64,6 @@ func TestItemWireFieldsAreExplicitlyNull(t *testing.T) { expectField(t, got, "output", test.output) expectField(t, got, "error", test.error) expectField(t, got, "phase", "") - // Stored payloads keep the original field presence. - stored, err := item.MarshalStored() - if err != nil { - t.Fatal(err) - } - var original, persisted map[string]json.RawMessage - if json.Unmarshal([]byte(test.raw), &original) != nil || json.Unmarshal(stored, &persisted) != nil || len(original) != len(persisted) { - t.Fatalf("stored payload changed: %s", stored) - } }) } @@ -84,14 +75,6 @@ func TestItemWireFieldsAreExplicitlyNull(t *testing.T) { reasoning := fields(t, Item{ID: "rs", TurnID: "turn", Type: "reasoning"}) expectField(t, reasoning, "status", "null") expectField(t, reasoning, "summary", "[]") - - stored, err := user.MarshalStored() - if err != nil { - t.Fatal(err) - } - if string(stored) != `{"id":"user","turn_id":"turn","type":"message","status":"completed","role":"user","content":[{"type":"input_text","text":"question"}]}` { - t.Fatalf("stored message encoding changed: %s", stored) - } } func TestItemEventsCarryNullableOutputIndex(t *testing.T) { @@ -156,7 +139,7 @@ func TestReasoningResponsesCarryBothKeys(t *testing.T) { expectField(t, fields(t, stored["agent"]), "reasoning", `{"effort":"low"}`) } -func TestStoredSearchItemRoundTripPreservesPayload(t *testing.T) { +func TestSearchItemWireAction(t *testing.T) { for _, raw := range []string{ `{"id":"search","turn_id":"turn","type":"web_search_call","status":"completed","action":{"type":"search","query":"reference"}}`, `{"id":"search","turn_id":"turn","type":"web_search_call","status":"completed","action":{"type":"search"}}`, @@ -166,13 +149,6 @@ func TestStoredSearchItemRoundTripPreservesPayload(t *testing.T) { if err := json.Unmarshal([]byte(raw), &item); err != nil { t.Fatal(err) } - stored, err := item.MarshalStored() - if err != nil { - t.Fatal(err) - } - if string(stored) != raw { - t.Fatalf("stored replay changed: %s, want %s", stored, raw) - } wire := fields(t, item) if item.Action == nil { expectField(t, wire, "action", "null") diff --git a/contracts/agents-api/v1/subagent_items.go b/contracts/agents-api/v1/subagent_items.go index b9c4a1533..0c21e078b 100644 --- a/contracts/agents-api/v1/subagent_items.go +++ b/contracts/agents-api/v1/subagent_items.go @@ -5,9 +5,10 @@ import ( "errors" ) -// MarshalJSON renders the wire shape. Messages always carry content and a -// nullable phase, function results a nullable output and error, and web search -// a nullable action. Other variants use the stored encoding. +// MarshalJSON renders the wire shape, which is also the stored encoding. +// Messages always carry content and a nullable phase, function results a +// nullable output and error, and web search a nullable action. Coordination +// and reasoning variants keep their required fields and nulls. func (i Item) MarshalJSON() ([]byte, error) { type wire Item switch i.Type { @@ -36,23 +37,6 @@ func (i Item) MarshalJSON() ([]byte, error) { Output any `json:"output"` Error any `json:"error"` }{wire(i), i.Output, i.Error}) - } - return i.MarshalStored() -} - -// MarshalStored encodes a persisted Item payload. It keeps the encoding used -// before the wire nulls above, so stored payloads and the byte comparison of -// replayed child Items do not change. Coordination variants keep their -// required fields and nulls in both forms. -func (i Item) MarshalStored() ([]byte, error) { - switch i.Type { - case "web_search_call": - type wire Item - type storedAction WebSearchAction - return json.Marshal(struct { - wire - Action *storedAction `json:"action,omitempty"` - }{wire(i), (*storedAction)(i.Action)}) case "create_subagent_call", "send_subagent_input_call", "agent_message": content, err := coordinationContent(i.Content) if err != nil { @@ -84,10 +68,8 @@ func (i Item) MarshalStored() ([]byte, error) { summary = []SummaryText{} } return json.Marshal(ReasoningItem{ID: i.ID, TurnID: i.TurnID, Type: i.Type, Status: status, Summary: summary}) - default: - type wire Item - return json.Marshal(wire(i)) } + return json.Marshal(wire(i)) } func coordinationContent(parts []ItemContent) ([]AgentContent, error) { diff --git a/contracts/agents-api/vaults.md b/contracts/agents-api/vaults.md index 938ae2f43..a05e76511 100644 --- a/contracts/agents-api/vaults.md +++ b/contracts/agents-api/vaults.md @@ -137,7 +137,7 @@ Token endpoints must be HTTPS. Core resolves the host, rejects loopback, private ## Storage key -Core seals every token, refresh token and client secret with AES-256-GCM under the installation's [`secrets/credential.key`](../../docs/configuration.md#installation-directory), bound to the Project, Vault, Credential, auth type and `mcp_server_url`. A wrong key, a modified row or a row moved to another binding fails to decrypt. Names are metadata outside the binding. The key and plaintext tokens exist in trusted service memory; encryption protects stored secrets and does not protect against a compromised service host. +Core seals every token, refresh token and client secret with AES-256-GCM under the installation's [`secrets/core/credential.key`](../../docs/configuration.md#compose-installations), bound to the Project, Vault, Credential, auth type and `mcp_server_url`. A wrong key, a modified row or a row moved to another binding fails to decrypt. Names are metadata outside the binding. The key and plaintext tokens exist in trusted service memory; encryption protects stored secrets and does not protect against a compromised service host. Without a configured key, Credential creation and replacement return 503 `credential_storage_unavailable` before writing; reads, lists, deletion and Vault operations still work. An unreadable or malformed key file stops Core at startup. Losing or replacing the key makes every stored secret unusable; Core supports one key, with no rotation or re-encryption. diff --git a/contracts/agents-api/zh/admin-api.md b/contracts/agents-api/zh/admin-api.md index c86c1de67..8ce891d43 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: 3fc6573b19b9c78ca8a3b275122793b1a99e31f739d83ff25ff56624013dc428 +source_hash: 7eb295db6402db8dae91fcdd97f90a5900f740925caaee9d0172b9886544f6ec --- 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)。 @@ -134,12 +134,12 @@ Core 会在创建 Session 的同一事务中写入此记录。之后的 Agent | 字段 | 含义 | | --- | --- | | `object` | `core.installation` | -| `installation_id` | `state.json` 中的安装 ID([安装目录](../../../docs/zh/configuration.md#installation-directory));Core 在不使用沙箱管理器运行时为 null | +| `installation_id` | `OAC_INSTALLATION_ID_FILE` 中的安装 ID([Compose 安装](../../../docs/zh/configuration.md#compose-installations));Core 在不使用沙箱管理器运行时为 null | | `public_url` | `public_url` 设置([设置](../../../docs/zh/configuration.md#settings)):应用程序、节点、沙箱和自托管执行器使用的源地址。未设置时为 null | | `api_base_url` | 在 `public_url` 后附加 `/v1`,即 Project API 密钥使用的 `OPENAI_BASE_URL`。当 `public_url` 为 null 时为 null | | `local_only` | 当 `public_url` 指向回环主机时为 True,该主机只能由 Core 主机访问 | | `source_commit` | Core 构建所依据的完整源代码提交;开发构建为 null | -| `configuration` | Core 从环境加载的进程设置。`path` 和 `apply_command` 为空,`applied_at` 为 null | +| `configuration` | Core 从环境加载的进程设置,位于 `settings` 中 | | `address_bindings` | 更改 `public_url` 所影响的内容,每次读取都会重新统计 | `configuration.settings` 为 Core 加载的每项设置一条记录,包含以点分隔的 `key`、生效的 `value`、`default`、是否 `changeable`、是否 `sensitive`,以及会 `restarts` 的服务(`core`、`web`、`database`)。 @@ -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/contracts/agents-api/zh/core-errors.md b/contracts/agents-api/zh/core-errors.md index c7bc4301b..a0d533547 100644 --- a/contracts/agents-api/zh/core-errors.md +++ b/contracts/agents-api/zh/core-errors.md @@ -1,7 +1,7 @@ --- title: "Core 管理错误" source: contracts/agents-api/core-errors.md -source_hash: 3d8e6a03d6a54c7dc86a27164749ffae963b5ef52cbede0e0da843c7da0216ba +source_hash: 78fcbde855beafae4d1f5eb38b87596eeca25c99c025c6c54978db988d2a534a --- `/core/v1` 上的错误使用此封装结构。`message` 是安全的英文文本;`code` 和 `param` 可以为 null。客户端依据稳定的 `code` 和可选的 `param` 进行处理,对未知代码显示 `message`,绝不解析消息,也绝不自动重试被拒绝的写操作。 @@ -10,7 +10,7 @@ source_hash: 3d8e6a03d6a54c7dc86a27164749ffae963b5ef52cbede0e0da843c7da0216ba {"error":{"message":"A valid Core key is required as the bearer credential.","type":"invalid_request_error","code":"invalid_admin_key","param":null}} ``` -`/v1` 和 `/api/v1` 上的错误仍使用各自的封装结构,且绝不包含 `details`。 +`/v1` 和 `/api/v1` 上的错误仍使用各自的封装结构,且绝不包含 `details`。下面各表列出 `/core/v1` 或控制台调用方可能收到的所有代码,[线路词汇](wire-semantics.md#errors)除外;例如 `invalid_request`、`not_found_error` 或 `idempotency_conflict` 沿用其在 `/v1` 上的含义。`/v1` Session 输入和机器路由的代码(例如 `turn_conflict` 或 `invalid_node_credential`)不会到达这些调用方。共享目录 `services/core/internal/api/testdata/core-errors.json` 恰好包含所列代码。Go 测试要求每个代码都有生产者、与这些表完全一致,并要求 Core 错误写入函数产生的其他代码都登记在目录中;Web 测试要求每种语言恰好为这些代码提供消息。 ## 可选详细信息 {#optional-details} @@ -49,7 +49,7 @@ Web 的控制台服务器在 `/core` 路径上发生自身故障时使用此封 | 409 | `sandbox_credential_ownership` | 候选凭据无法管理保留的部署;更换账户前必须重置 | `credential` | | 503 | `sandbox_verification_unconfirmed` | 无法确认验证结果、回执结算结果或凭据隔离状态 | null | -每次写入部署时,类型化客户端都会将上述代码及其他 `sandbox_*` 部署代码的消息替换为固定的本地文本。`details` 中仅保留 `current_generation`、`allocations`、`pending`、`min` 和 `max`,并且仅当 status、code 和 param 与上表或下方 `invalid_sandbox_configuration` 各行完全匹配时,才保留 `param`。`409 sandbox_configuration_error` 会转换为有关公开 URL 的固定指引,并将 `param` 设为 null,即使对于未提供密钥的 `PUT` 也是如此。其他任何错误都会转换为 `sandbox_configuration_unconfirmed` 且不会重新发送,因为拒绝响应可能会回显密钥。 +每次写入部署时,类型化客户端都会将上述代码及其他 `sandbox_*` 部署代码的消息替换为固定的本地文本。`details` 中仅保留 `current_generation`、`allocations`、`pending`、`min` 和 `max`,并且仅当 status、code 和 param 与上表或下方 `invalid_sandbox_configuration` 各行完全匹配时,才保留 `param`。`409 sandbox_configuration_error` 会转换为有关公开 URL 的固定指引,并将 `param` 设为 null,即使对于未提供密钥的 `PUT` 也是如此。其他任何错误都会转换为仅客户端使用的代码 `sandbox_configuration_unconfirmed`(Core 从不返回它),且不会重新发送,因为拒绝响应可能会回显密钥。 ## 操作验证 {#operation-validation} @@ -73,6 +73,35 @@ Web 的控制台服务器在 `/core` 路径上发生自身故障时使用此封 这些边界是验证常量,绝不是提交的值。节点名称按字节数限制;Project 名称和键名称按去除首尾空白后的 Unicode 字符数限制,且不得包含控制字符。系统仅按以下顺序报告第一个失败项:模型提供商 URL、协议、密钥、常规限制、Harness 协议,然后是 Harness 的必需限制;沙箱资源依次为 CPU、内存、磁盘,然后是 Runtime。`model_provider` 对象内的模型提供商字段错误仍以该对象的相应字段作为 `param`。未知的沙箱提供商返回一个不含这些字段的错误。 +## 其他管理错误 {#other-administration-errors} + +这些代码的 `param` 为 null,且没有 `details`。[沙箱部署](./sandbox-deployment.md#errors)说明各沙箱代码何时出现。 + +| HTTP | 代码 | 含义 | +| --- | --- | --- | +| 400 | `sandbox_operation_unsupported` | 所选沙箱提供商不支持该操作 | +| 401 | `invalid_admin_key` | Bearer 凭据不是有效的 Core Key | +| 404 | `not_found` | 操作不存在、Harness 未知,或该 Harness 没有部署默认模型服务 | +| 409 | `project_archived` | 目标 Project 已归档 | +| 409 | `project_exists` | 该 Project ID 已存在 | +| 409 | `project_api_key_exists` | 该 API Key ID 已存在 | +| 409 | `executor_credential_exists` | 该执行器凭证 ID 已存在;要替换密钥,请轮换它 | +| 409 | `sandbox_not_configured` | 沙箱部署尚未配置 | +| 409 或 503 | `sandbox_reset_in_progress` | 沙箱正在重置 | +| 409 | `sandbox_configuration_error` | 当前安装无法支持所选提供商,例如公开 URL 为 loopback 时选择 E2B | +| 409 | `sandbox_deployment_conflict` | 沙箱部署在当前状态下无法更改 | +| 409 | `sandbox_specification_mismatch` | 已保存的部署规格对其提供商不再有效 | +| 409 | `runtime_node_in_use` | 节点仍有资源分配、快照、预留资源或待清理项 | +| 409 | `environment_unavailable` | Session 的环境已不可用,例如在不提供执行的 Core 上归档 | +| 409 | `runtime_history_unsupported` | 该 Session 不支持 Runtime 历史 | +| 500 | `internal_error` | Core 未能完成操作 | +| 503 | `runtime_node_unavailable` | 没有可用或有剩余容量的沙箱节点 | +| 503 | `credential_storage_unavailable` | Core 没有凭证加密密钥 | +| 503 | `execution_unavailable` | 此 Core 不提供执行功能 | +| 503 | `runtime_history_unavailable` | 持久 Runtime 历史未配置或暂时不可用 | +| 503 | `core_metrics_unavailable` | 无法读取 Core 指标 | +| 503 | `file_transfer_unavailable` | 有界内容传输不可用 | + ## 诊断失败类别 {#diagnostic-failure-categories} [Session and Turn diagnostics reads](session-diagnostics.md) 会在成功的 200 快照内返回以下类别,而不是以错误封装的形式返回。公开 `/v1` 的 Turn 错误保持不变。除非表格另有说明,否则 `params` 为 `{}`。 diff --git a/contracts/agents-api/zh/environment-executor-credentials.md b/contracts/agents-api/zh/environment-executor-credentials.md index d78e8c421..3d1fea6bf 100644 --- a/contracts/agents-api/zh/environment-executor-credentials.md +++ b/contracts/agents-api/zh/environment-executor-credentials.md @@ -1,7 +1,7 @@ --- title: "Environment 执行器凭证" source: contracts/agents-api/environment-executor-credentials.md -source_hash: 6c1db305481f9ab2a51bdd0c88243feaab48348b71f5f3ebbc8f00b17744f412 +source_hash: 725257a92518431a4b942ea35187e37bb171f890d7797e843a9f4eb62f47ad4b --- 执行器凭证允许 `oac-daemon` 为一个 `self_hosted` Environment 注册并连接。它只授权该 Environment 的私有 daemon 传输(`/api/v1/agent-daemon/*`),不授权 `/v1`、`/core/v1`、sandbox node 注册或 Project 资源。Project 的 principal 是其执行 principal。Core 只保存密钥摘要。 @@ -37,7 +37,7 @@ grant 绑定 Environment、Session 创建者的 principal 和 Core 构建版本 | `POST /api/v1/agent-daemon/installation` | Grant | 固定绑定:`version`、`protocol_version`、`environment_id`、`remote_url`、`workspace_directory`、`harness` | | `POST /api/v1/agent-daemon/installation/claim` | Grant | `{"executor_token":"SECRET"}`;204 | -无效或过期的 grant 返回 401 `installation_authorization_invalid`。没有匹配安装器时,grant 路由返回 503 `installation_unavailable`。Core 用安装的 [`secrets/credential.key`](../../../docs/zh/configuration.md#installation-directory) 签名每个 grant;未配置 key 时,上述 Session 响应、Core-key 查询和 grant 路由返回 503 `credential_storage_unavailable`。格式错误的密钥返回 400。产物路由不携带凭证,grant 只发送给 Core,不发送给产物主机。 +无效或过期的 grant 返回 401 `installation_authorization_invalid`。没有匹配安装器时,grant 路由返回 503 `installation_unavailable`。Core 用安装的 [`secrets/core/credential.key`](../../../docs/zh/configuration.md#compose-installations) 签名每个 grant;未配置 key 时,上述 Session 响应、Core-key 查询和 grant 路由返回 503 `credential_storage_unavailable`。格式错误的密钥返回 400。产物路由不携带凭证,grant 只发送给 Core,不发送给产物主机。 ## Core-key 路由 {#core-key-routes} diff --git a/contracts/agents-api/zh/environments.md b/contracts/agents-api/zh/environments.md index 676cb2675..bce0a76a4 100644 --- a/contracts/agents-api/zh/environments.md +++ b/contracts/agents-api/zh/environments.md @@ -1,7 +1,7 @@ --- title: "环境与模板" source: contracts/agents-api/environments.md -source_hash: a70ea3e004d5b7e2fc48c26296c792a5d888b754a012a7f0c624c3ff597369e7 +source_hash: 514100f17d7d2ab44fd6711104ee49460360ff150652f37566b3f1c00a7bb372 --- Environment 是 Session 的执行资源,包括 Harness 运行所在的机器、工作区以及已完成准备的能力。Session 通过其 `environment` 配置创建 Environment;不存在独立的 create 调用。Environment Template 是 Session 创建时解析的可复用准备配置。本契约涵盖这两类资源、两种放置方式、输入接纳、能力准备、Skills、Plugins 和 MCP 连接来源。 @@ -273,7 +273,7 @@ session = client.beta.agents.sessions.create( - 响应会携带安全元数据,绝不会包含 `env`、`setup_commands` 正文或内联文件数据。 - 列表操作使用 `after`、`limit`(默认 20;0 按 1 处理,超过 100 的值按 100 处理)和 `order`(默认 `desc`),并按创建时间和 ID 排序。不存在和属于外部 Project 的 Template ID 及游标都会返回相同的 404。 - 更新时,省略字段会保留原值,提供字段则会替换原值。Null 会清除 `name` 和每个列表,并将 `network` 重置为启用。 -- 封装或解封机密内容(文件、env、设置命令、Skills、Plugins)的写入操作和 Session 解析需要 Core 的 [credential key](../../../docs/zh/configuration.md#installation-directory);元数据读取则不需要。 +- 封装或解封机密内容(文件、env、设置命令、Skills、Plugins)的写入操作和 Session 解析需要 Core 的 [credential key](../../../docs/zh/configuration.md#compose-installations);元数据读取则不需要。 - Session 会在创建时于其 Project 内解析一次 `environment_template_id`,冻结有效配置,并且绝不将 Template ID 传递给 Provider 或 Runtime。更新或删除 Template 绝不会改变现有 Session。创建重试会在读取 Template 之前恢复已记录的调用方意图,即使 Template 已删除也是如此;意图发生变化时会产生冲突。 ### 继承 {#inheritance} diff --git a/contracts/agents-api/zh/harness-onboarding.md b/contracts/agents-api/zh/harness-onboarding.md index 649045c51..4dce152d7 100644 --- a/contracts/agents-api/zh/harness-onboarding.md +++ b/contracts/agents-api/zh/harness-onboarding.md @@ -1,7 +1,7 @@ --- -title: "将原生 Harness 添加到 OpenAgentCore" +title: "添加 Harness" source: contracts/agents-api/harness-onboarding.md -source_hash: 5a79758fec968dfed6d3c9aaee187109184c90417438b2d5465cc706875f687a +source_hash: c64405634d679ed6ed670d6ecd25b2d69d1efd58ccbca8cd2e8b00cfa003f6e3 --- **Harness** 是一种运行模型和工具循环的原生代理引擎(Codex、Claude Code、MiniMax Code)。**Harness 适配器**将 Runtime 的 Executor 和 Turn 契约转换到该引擎的 SDK 或协议。本文档定义 Runtime–Harness 协议:适配器接口及其生命周期义务、注册、Core 资格认定和验收。[Harness capabilities](harness-capabilities.md) 记录了当前每个 Harness 支持的功能。 diff --git a/contracts/agents-api/zh/model-execution.md b/contracts/agents-api/zh/model-execution.md index d3ea0c0e9..6047a438e 100644 --- a/contracts/agents-api/zh/model-execution.md +++ b/contracts/agents-api/zh/model-execution.md @@ -1,7 +1,7 @@ --- title: "模型执行" source: contracts/agents-api/model-execution.md -source_hash: 6b6a84f1355ef3a45e8ed8ed2f0b1b7ad9de1938abb49109b51d592d6ddf5ace +source_hash: b1f45b9c37da8e60eabf313e2eae28ffa8f4189e660deae7cfc18f6c33e57b17 --- 每个 Session 都运行一个 Harness,并使用一个模型提供商。Core 通过三个固定版本上游协议未定义的 Core 扩展来选择它们:`x_agents_core.harness` 选择 Harness,`x_agents_core.model_provider` 提供端点和密钥,`x_agents_core.harness_config` 携带原生模型参数。Core 没有提供商目录、模型别名解析或产品权限模型;除 Session 和已保存 Agent 配置包外,唯一存储的配置包是每个 Harness 的一个 [deployment default](#deployment-defaults)。本文档定义 Harness—模型提供商协议:[`internal/modelprovider/config.go`](https://github.com/MiniMax-AI/OpenAgentCore/blob/main/internal/modelprovider/config.go) 负责验证冻结的提供商连接并声明[凭据网关](#credential-gateway)转发的内容,每个 Harness 则通过 [`internal/harnessconfig/harness.go`](https://github.com/MiniMax-AI/OpenAgentCore/blob/main/internal/harnessconfig/harness.go) 声明其协议和原生参数。 @@ -59,7 +59,7 @@ MiniMax Code 要求上下文限制和输出限制均为正数。Core 会在写 空的 Session 执行扩展无效。显式 null 提供商会请求继承;空的或不完整的提供商对象无效。已保存提供商配置中的未知字段、重复字段或只读输出字段均会被拒绝。没有 Harness 的已保存 Agent 可以保存有效配置包;其 Harness 兼容性会在 Session 准入时检查。仅更新提供商的 Agent 更新会保留已保存的 Harness,并在行锁保护下验证合并后的组合。Session 内联的 `agent.x_agents_core` 接受 `harness` 和 `harness_config`;提供商覆盖值必须放在请求顶层。 -Core 从同一个数据库快照读取 Agent 配置和加密配置包;显式提供完整 Session 覆盖值时,无需解密已保存的配置包。Session 自身的加密快照会与 Session 及其 Environment 原子写入。现有 Session 绝不会再次查询 Agent:Agent 编辑、密钥替换、删除、暂停和重启均无法改变其模型、Harness 或提供商。加密密钥缺失或错误时会安全失败;重启前后应保持相同的 [credential key](../../../docs/zh/configuration.md#installation-directory)。不存在 Turn 级覆盖。 +Core 从同一个数据库快照读取 Agent 配置和加密配置包;显式提供完整 Session 覆盖值时,无需解密已保存的配置包。Session 自身的加密快照会与 Session 及其 Environment 原子写入。现有 Session 绝不会再次查询 Agent:Agent 编辑、密钥替换、删除、暂停和重启均无法改变其模型、Harness 或提供商。加密密钥缺失或错误时会安全失败;重启前后应保持相同的 [credential key](../../../docs/zh/configuration.md#compose-installations)。不存在 Turn 级覆盖。 新的托管请求以及省略内联模型的请求,会在解析可变默认值之前记录调用方意图。其他内联请求,例如 `none`,继续遵循已解析请求的重试规则;该哈希不包含部署默认值,因此更改默认值不会改变其重试标识。匹配的创建重试会在再次解析 Agent 或提供商之前恢复已提交的 Session,并且不会进一步加入输入。流式传输不参与重试标识的计算。[TypeScript client](https://github.com/MiniMax-AI/OpenAgentCore/blob/main/packages/agents-client/README.md#saved-agent-and-deployment-defaults) 展示了已保存 Agent 和部署默认值。 diff --git a/contracts/agents-api/zh/runtime-observability.md b/contracts/agents-api/zh/runtime-observability.md index 8e24b9a56..ddd10ea91 100644 --- a/contracts/agents-api/zh/runtime-observability.md +++ b/contracts/agents-api/zh/runtime-observability.md @@ -1,7 +1,7 @@ --- title: "运行时可观测性" source: contracts/agents-api/runtime-observability.md -source_hash: f79a077350118f5f9bb3f4e8874242af3cb716e92f5e090f6652b001ae5780a1 +source_hash: 103575f3971e77d5ba149cacb27e972e429a46713b2bda7da36cae43bbf313fa --- 这是面向贡献者的契约,规定 Core 如何观测 Runtime 并保留其历史。路由和响应字段见 [Runtime telemetry API](runtime-observability-api.md)。代码位于 `services/core/internal/runtimeobs`(解析、源、采样器和导出)、`internal/runtimehistory`(历史查询和 PostgreSQL 存储)以及 `internal/runtimeobs/otlpexporter`。 @@ -91,7 +91,7 @@ CPU 静默状态、心跳时龄、连接状态和保活时间都不是空闲时 采样器在启动时扫描一次,此后每次扫描结束后再经过一个采样间隔再次扫描。一次扫描按 Session ID 顺序,对未删除、状态为 `openai_hosted` 且没有已释放分配的 Session 执行 keyset 扫描。它通过与当前读取相同的解析器和源,以 32 个 Session 为一页进行读取,并发数为 8,每个源时限为 2 秒。采样器在每页之前以及扫描期间每 100 ms 检查租约;失去所有权时取消进行中的读取;将每条记录交给导出之前再次检查租约。失败的行不会停止扫描;未完成的扫描会在下一个间隔重复。 -每次观测,无论来自当前读取还是周期采集,都会标记采集源 `on_read` 或 `periodic`,并放入每个导出器的有界队列。队列已满时会丢弃记录;该记录将成为缺失采样,而绝不会成为零。PostgreSQL 历史存储和可选 OTLP 导出器使用彼此独立的队列,因此导出器故障不会延迟本地历史记录或执行。[`core.runtime_history` settings](../../../docs/zh/configuration.md#settings) 用于设置采样间隔、队列容量、超时和 OTLP 目标。 +每次观测,无论来自当前读取还是周期采集,都会标记采集源 `on_read` 或 `periodic`,并放入每个导出器的有界队列。队列已满时会丢弃记录;该记录将成为缺失采样,而绝不会成为零。PostgreSQL 历史存储和可选 OTLP 导出器使用彼此独立的队列,因此导出器故障不会延迟本地历史记录或执行。[Runtime 历史文件](../../../docs/zh/configuration.md#runtime-history-file) 用于设置采样间隔、队列容量、超时和 OTLP 目标。 ### 存储的历史记录 {#stored-history} diff --git a/contracts/agents-api/zh/sandbox-deployment.md b/contracts/agents-api/zh/sandbox-deployment.md index dea0cf983..cf026eb4b 100644 --- a/contracts/agents-api/zh/sandbox-deployment.md +++ b/contracts/agents-api/zh/sandbox-deployment.md @@ -1,7 +1,7 @@ --- title: "沙箱部署" source: contracts/agents-api/sandbox-deployment.md -source_hash: 06a69e3d0ba245ab27c0b5d7390364a35d10fb8478e2d0f6867749b29368f980 +source_hash: 6f765be45518f23ace6384938616eb12aba7554e7f8fbfadb89738f26c692dd5 --- 沙箱部署为 Core 管理的 `openai_hosted` 执行选择 Sandbox Provider、每个沙箱的资源以及不可变的 Runtime 发行版。PostgreSQL 为每个安装维护一个当前有效选择;Web 和 Core API 写入同一配置。节点文件保存其已安装副本和特定于主机的路径,且不能覆盖其资源或 Runtime。该选择独立于 Harness;部署可以保持未配置状态,既无节点,也不接受托管准入。 @@ -226,6 +226,6 @@ POST 会在持久保存候选配置之前对其进行验证,并且不会创建 ## 规范的节点规格 {#canonical-node-specification} -`sandbox/deployment_contract.go`负责资源边界、提供商要求、发行版模式和规范字段顺序;`sandbox/deployment.go`在 Core 中应用这些规则。安装程序会使用 `deploy/node/node_spec.py` 中生成的声明,因此不存在第二套限制或模式。请在仓库根目录运行 `go run ./services/core/cmd/specification-contract -write` 重新生成;作为 `make check` 一部分的沙箱 Go 测试会拒绝过时的投影。 +`sandbox/deployment_contract.go`负责资源边界、提供商要求、发行版模式和规范字段顺序;`sandbox/deployment.go`在 Core 中应用这些规则。安装程序会使用 `deploy/node/node_spec.py` 中生成的声明,TypeScript 客户端和 Web 使用 `packages/agents-client/src/deployment-contract.ts` 中生成的边界和模式,因此不存在第二套限制或模式。请在仓库根目录运行 `go run ./services/core/cmd/specification-contract -write` 重新生成两者;作为 `make check` 一部分的沙箱 Go 测试会拒绝过时的投影。 规范摘要是紧凑 UTF-8 JSON 的 SHA-256,其中 `provider` 位于首位,其次是 `resources`,然后在提供商需要时放置 `runtime`。资源和 Runtime 字段遵循契约的声明顺序;值为零的可选磁盘字段会被省略,必填字段则保持存在。发行版标识采用小写 ASCII,摘要绝不会受传入字段顺序或空白字符影响。`services/core/internal/sandbox/testdata/deployment-contract.json`保存共享验收用例、精确的规范字节和摘要,Go 与 Python 测试都会使用这些内容。 diff --git a/contracts/agents-api/zh/sessions-events.md b/contracts/agents-api/zh/sessions-events.md index 519727458..cb7230f99 100644 --- a/contracts/agents-api/zh/sessions-events.md +++ b/contracts/agents-api/zh/sessions-events.md @@ -1,7 +1,7 @@ --- title: "会话、事件和历史" source: contracts/agents-api/sessions-events.md -source_hash: d5d0928665f38105167e592f9561c3d3852a13fc11b976f15c1bc4cc2ca9cb14 +source_hash: c6141811b426fb0dfb95105b27cc381af5f22e3a7923a171f113f228ae0a5b33 --- 本契约涵盖会话(Session)内部发生的事情:发送输入、实时事件流,以及读取轮次(Turn)、条目(Item)和使用量的持久化历史。会话资源本身(创建配置、重试标识、更新、列出和删除)见 [Core 线协议行为](wire-semantics.md)。消息和函数结果内容见[消息内容](message-content.md)。[Agents API 指南](../../../docs/zh/api/public-agent-api.md)展示了使用 SDK 和 HTTP 的调用方式。 @@ -41,7 +41,7 @@ Turn 失败后会话仍可使用:新输入会启动一个新 Turn。后来预 - **取消。** 排队的 Turn 无需活动 Runtime 即可取消。正在运行的 Turn 只有在 Runtime 确认后才会取消;完成操作可能赢得该竞争。读取到 `cancelled` 时才表示该 Turn 已停止,而不是请求返回时。在没有待处理输入的情况下,对空闲会话执行的取消会被接受且不产生任何效果;而在输入预留待处理期间,取消会返回 409。 - **函数结果。** `turn_id`、`call_id` 和 `success` 为必填项;`output` 和 `error` 为可选项且可为空([内容规则](message-content.md#function-results))。重复提交完全相同的结果会返回 202,不会再次应用或发出事件。harness 应用结果时才会出现结果 Item;如果取消操作导致结果无法应用,结果仍会存储,但不会产生 Item。 - **排队。** 当一个已连接且支持该会话 harness 和配置的 Runtime 接入,并且 Core 的 [`core.execution_concurrency`](../../../docs/zh/configuration.md#settings) 工作槽位有一个空闲时,排队的 Turn 才会启动。会话始终绑定到首次运行它的 Runtime。 -- **执行可用性。** 不具备执行能力的服务会返回 503 `execution_unavailable`,失去执行所有权的 Worker 会返回 503。创建时未指定模型提供方的会话会拒绝新消息并返回 400 `model_provider_required`([模型执行](model-execution.md))。 +- **执行可用性。** 不具备执行能力的服务会返回 503 `execution_unavailable`,失去执行所有权的 Worker 会返回 503。 ### 包含 Environment 的会话 {#sessions-with-an-environment} diff --git a/contracts/agents-api/zh/vaults.md b/contracts/agents-api/zh/vaults.md index 0f5dd927d..81434de67 100644 --- a/contracts/agents-api/zh/vaults.md +++ b/contracts/agents-api/zh/vaults.md @@ -1,7 +1,7 @@ --- title: "Vault 与 Credential" source: contracts/agents-api/vaults.md -source_hash: 9e56ee84c782d1f9c0f5506f960a275d9afa3e554634131bf09a74e736135926 +source_hash: 95626340ee36faee3418dd1155a0e50c378ead09c09c91e86f30e09bd8f409e0 --- Vault 是 Project 所有的 Credential 容器。Credential 保存一个 HTTPS MCP server 的秘密:`static_bearer` token 或 `mcp_oauth` grant。Session 在 `vault_ids` 中关联 Vault;Core 在创建 Session 时为每个 HTTP MCP server 选择一个 Credential,只在分派工作时将解密 token 交给 Runtime。秘密只能写入:任何读取都不返回 token、refresh token、client secret 或密文。 @@ -139,7 +139,7 @@ Token endpoint 必须为 HTTPS。Core 解析主机,拒绝回环、私有、链 ## 存储密钥 {#storage-key} -Core 使用安装的 [`secrets/credential.key`](../../../docs/zh/configuration.md#installation-directory),以 AES-256-GCM 加密每个 token、refresh token 和 client secret,绑定 Project、Vault、Credential、auth type 和 `mcp_server_url`。错误密钥、修改的行或移动到其他绑定的行均无法解密。名称是不参与绑定的元数据。key 和明文 token 存在于可信服务内存中;加密保护存储的秘密,不保护已被攻破的服务主机。 +Core 使用安装的 [`secrets/core/credential.key`](../../../docs/zh/configuration.md#compose-installations),以 AES-256-GCM 加密每个 token、refresh token 和 client secret,绑定 Project、Vault、Credential、auth type 和 `mcp_server_url`。错误密钥、修改的行或移动到其他绑定的行均无法解密。名称是不参与绑定的元数据。key 和明文 token 存在于可信服务内存中;加密保护存储的秘密,不保护已被攻破的服务主机。 未配置 key 时,Credential 创建和替换在写入前返回 503 `credential_storage_unavailable`;读取、列表、删除和 Vault 操作仍可用。key 文件不可读或格式错误会使 Core 启动失败。丢失或替换 key 使全部已存储秘密无法使用;Core 只支持一个 key,不支持轮换或重新加密。 diff --git a/deploy/compose/compose.yaml b/deploy/compose/compose.yaml index 6fe45a4db..70471dc31 100644 --- a/deploy/compose/compose.yaml +++ b/deploy/compose/compose.yaml @@ -58,8 +58,6 @@ services: OAC_DATABASE_PASSWORD_FILE: /run/database/password OAC_CREDENTIAL_KEY_FILE: /run/oac/credential.key OAC_CORE_KEY_DIGESTS_FILE: /run/oac/core-key-digests.json - OAC_PROVIDER_STATE_ROOT: /state - OAC_NATIVE_INSTALLER_DIR: /opt/oac/native-installers OAC_EXECUTION_CONCURRENCY: ${OAC_EXECUTION_CONCURRENCY:-} OAC_DEFAULT_HARNESS: ${OAC_DEFAULT_HARNESS:-} OAC_HARNESSES: ${OAC_HARNESSES:-} @@ -99,8 +97,7 @@ services: depends_on: init: {condition: service_completed_successfully} environment: - OAC_WEB_ORIGIN: *public-url - OAC_WEB_UPSTREAM: http://core:8091 + OAC_PUBLIC_URL: *public-url OAC_WEB_CORE_KEY_FILE: /run/oac/core.key OAC_WEB_NODE_PAYLOAD_DIR: /node-payload OAC_LOG_LEVEL: ${OAC_LOG_LEVEL:-} diff --git a/deploy/compose/test_compose.py b/deploy/compose/test_compose.py index ce428f89a..4f14ef726 100644 --- a/deploy/compose/test_compose.py +++ b/deploy/compose/test_compose.py @@ -79,7 +79,7 @@ def test_public_url_can_be_configured_after_initial_startup(self): with self.subTest(public_url=value): configured = self.render(value) expected = value or 'http://localhost:8080' - for name, setting in (('core', 'OAC_PUBLIC_URL'), ('web', 'OAC_WEB_ORIGIN')): + for name, setting in (('core', 'OAC_PUBLIC_URL'), ('web', 'OAC_PUBLIC_URL')): self.assertEqual(configured['services'][name]['environment'][setting], expected) self.assertEqual( {service: [item.get('target') for item in spec.get('volumes', [])] diff --git a/deploy/distribution/Runtime.Dockerfile b/deploy/distribution/Runtime.Dockerfile index 913c1f4a0..886bc08d5 100644 --- a/deploy/distribution/Runtime.Dockerfile +++ b/deploy/distribution/Runtime.Dockerfile @@ -15,8 +15,7 @@ COPY --from=claude /opt/claude-sdk /opt/claude-sdk ENV OAC_RUNTIME_CODEX_BIN=/usr/local/bin/codex \ OAC_RUNTIME_CLAUDE_SDK_NODE=/usr/local/bin/node \ - OAC_RUNTIME_CLAUDE_SDK_ENTRYPOINT=/opt/claude-sdk/dist/main.js \ - OAC_RUNTIME_CLAUDE_SDK_WORKSPACE=managed + OAC_RUNTIME_CLAUDE_SDK_ENTRYPOINT=/opt/claude-sdk/dist/main.js USER 1000:1000 RUN test "$(codex --version)" = "codex-cli 0.153.4" \ diff --git a/deploy/install.dev.sh b/deploy/install.dev.sh index 7a3fb5135..93579cecf 100755 --- a/deploy/install.dev.sh +++ b/deploy/install.dev.sh @@ -5,7 +5,7 @@ set -euo pipefail repo_root="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" -install_dir="${OAC_INSTALL_DIR_DEFAULT:-$HOME/.oac/local}" +install_dir="$HOME/.oac/local" host_address="127.0.0.1" web_port="8080" diff --git a/docs/architecture.md b/docs/architecture.md index 889874b55..172a978d2 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -38,7 +38,7 @@ Dashed arrows show provisioning and installation. Solid arrows show component in | Model provider | Serve the model protocol selected for the Harness | [Model execution](../contracts/agents-api/model-execution.md) | | Web | Let administrators configure and observe the installation through a server-side Core API connection | [Console server](./web/console-server.md) | -The [repository map](./development.md#repository-map) locates these components. [Concepts and ownership](./concepts.md) explains Project boundaries, administrator authority and tool isolation. +The [repository map](./development.md#repository-map) locates these components. [Concepts](./concepts.md) explains Project boundaries, administrator authority and tool isolation. ## A Session, end to end diff --git a/docs/concepts.md b/docs/concepts.md index fbfb56ab3..7a92003f6 100644 --- a/docs/concepts.md +++ b/docs/concepts.md @@ -1,5 +1,5 @@ --- -title: "Concepts and ownership" +title: "Concepts" --- A Project is the execution tenant in OpenAgentCore. Applications use its API keys; operators manage the installation with a separate Core key. The [API index](./api/index.md) maps each caller to its namespace and credential. diff --git a/docs/configuration.md b/docs/configuration.md index 5b0a63367..42dba1214 100644 --- a/docs/configuration.md +++ b/docs/configuration.md @@ -2,16 +2,17 @@ title: "Configuration reference" --- -Every setting of a Core installation has exactly one home. There are two kinds: +Every setting of a Core installation has exactly one home, in one of three categories: -| Kind | Examples | Home | Change it with | Takes effect | +| Category | Examples | Home | Change it with | Takes effect | | --- | --- | --- | --- | --- | -| [Process settings](#process-settings-configjson) | Public URL, ports, logging, harnesses, execution concurrency, audit retention, OAuth origins, Runtime history export | `.env` in the installation directory (default `~/.oac/core`) | Edit `.env`, then run `oac apply` | `oac apply` recreates the services that read the changed settings | +| [Process settings](#process-settings) | Public URL, ports, logging, harnesses, execution concurrency, audit retention, OAuth origins, Runtime history | `.env` in the installation directory (default `~/.oac/core`) | Edit `.env`, then run `oac apply` | `oac apply` recreates the services that read the changed settings | +| [Secrets](#compose-installations) | Database password, credential encryption key, installation ID, Core key and the Core key digest derived from it | `secrets/` in the Compose data volume, one copy each | Initialization generates them once; `oac rotate-core-key` replaces the Core key and its digest | `oac rotate-core-key` restarts Core and Web | | [Runtime settings](#runtime-settings-web) | Sandbox backend and size, nodes, Projects and keys, default models, executor credentials | Core's PostgreSQL database | Web, or the Core API (`/core/v1`) with the Core key | Saved without a Core restart; nodes prepare Runtime changes asynchronously | -Web's **System** page shows the installation's addresses, the default models, the sandbox configuration and, under **Startup settings**, the process settings Core loaded. Secrets live in [`secrets/`](#compose-installations), one copy each. No configuration file defines Projects or API keys. +Web's **System** page shows the installation's addresses, the default models, the sandbox configuration and, under **Startup settings**, the process settings Core loaded. No configuration file defines Projects or API keys. -## Process settings {#process-settings-configjson} +## Process settings Installer flags in [installation options](./getting-started/install-options.md) write `.env` once. To change a setting, edit `.env` and apply it: @@ -38,11 +39,11 @@ To change it, point the reverse proxy at the new address first, then edit `OAC_P ### Settings -`OAC_HISTORY_SETTINGS_FILE` may point at a file whose headers hold export credentials. The file stays mode `0600`, and those headers never appear in `oac` output or in the installation report. Model providers are not process settings; see [Default models](#default-models). +Model providers are not process settings; see [Default models](#default-models). | Variable | Default | Meaning | | --- | --- | --- | -| `OAC_PUBLIC_URL` | `http://localhost:8080` | Origin applications, nodes, sandboxes and self-hosted executors use. See [changing the public URL](#changing-the-public-url) | +| `OAC_PUBLIC_URL` | `http://localhost:8080`, set by `compose.yaml`. Core started without it runs no Runtime gateway and executes no Sessions | Origin applications, nodes, sandboxes and self-hosted executors use. See [changing the public URL](#changing-the-public-url) | | `OAC_HOST` | `127.0.0.1` | Web bind address published by `compose.yaml`. The installer sets `0.0.0.0` | | `OAC_WEB_PORT` | `8080` | Host port of Web | | `OAC_LOG_LEVEL` | `info` | `debug`, `info`, `warn` or `error` | @@ -53,9 +54,23 @@ To change it, point the reverse proxy at the new address first, then edit `OAC_P | `OAC_HARNESSES` | Every registered Harness | Comma-separated Harnesses to enable besides the default one. Unknown names stop startup | | `OAC_WRITE_AUDIT_RETENTION` | `2160h` | Minimum `1h` | | `OAC_OAUTH_TRUSTED_ORIGINS` | unset | Comma-separated HTTPS origins | -| `OAC_HISTORY_SETTINGS_FILE` | unset | Optional Runtime history file. Sensitive; Core reports only whether it is configured | +| `OAC_HISTORY_SETTINGS_FILE` | unset | Optional [Runtime history file](#runtime-history-file). Sensitive; Core reports only whether it is configured | -An unset or empty value selects the default. Edit `.env`, then run `oac apply`. Core reports the process settings it loaded at `GET /core/v1/installation`. `oac-core check-config` validates the same environment without starting Core. Sensitive settings report only whether they are configured. How Core collects and keeps Runtime history is in [retained history](../contracts/agents-api/runtime-observability.md#retained-history-and-optional-export). +An unset or empty value selects the default. Edit `.env`, then run `oac apply`. Core reads every process setting, and every file a setting names, once at startup and reports what it loaded at `GET /core/v1/installation`. `oac-core check-config` loads and validates the same settings and files without starting Core. The native installer catalog under `OAC_PROVIDER_ROOT` is not a setting; Core checks it only when it starts. Errors name the variable, never its value. Sensitive settings report only whether they are configured. + +### Runtime history file + +`OAC_HISTORY_SETTINGS_FILE` names a JSON file that tunes [retained history](../contracts/agents-api/runtime-observability.md#retained-history-and-optional-export) and adds an optional OTLP export. Without it, Core keeps history in its database with the defaults below. In a Compose installation, put the file in the data volume's `secrets/core/` directory, owned by UID 65532 with mode `0600`, and set `OAC_HISTORY_SETTINGS_FILE=/run/oac/`: Core mounts that directory read-only at `/run/oac`. Headers may hold export credentials; they never appear in `oac` output or in the installation report. Unknown fields are rejected. + +| Field | Default | Meaning | +| --- | --- | --- | +| `sample_interval_seconds` | `30` | Periodic sampling interval, from 5 to 300 | +| `queue_capacity` | `256` | Records each exporter queues, at most 4096 | +| `timeout_seconds` | `2` | Export and history query timeout, at most 30 | +| `endpoint` | unset | Absolute OTLP/HTTP metrics URL, such as `https://collector.example/v1/metrics`. Unset, Core exports nothing and the other export fields must be unset | +| `transport` | unset | `otlp_http`; required with `endpoint` | +| `insecure` | `false` | `true` is required for an `http` endpoint and rejected for `https` | +| `headers` | none | Request headers for the endpoint. `Host`, `Content-Length`, `Content-Type` and `Content-Encoding` are reserved | ## Runtime settings: Web @@ -84,7 +99,7 @@ Set a default in **System** → **Default model configuration**, or use `PUT /co ## Compose installations -The [standalone Compose file](./getting-started/install-options.md#docker-compose-and-hosting-platforms) takes process settings from the platform's environment. An empty `OAC_PUBLIC_URL` selects `http://localhost:8080`. Set it to the exact public origin, without a trailing slash, and recreate Core and Web before adding nodes or executors. The platform terminates TLS and routes to `web:8080`. +The [standalone Compose file](./getting-started/install-options.md#docker-compose-and-hosting-platforms) takes process settings from the platform's environment. Set [`OAC_PUBLIC_URL`](#settings) to the exact public origin, without a trailing slash, and recreate Core and Web before adding nodes or executors. The platform terminates TLS and routes to `web:8080`. The initialization service generates secrets and the installation ID once, then verifies them on subsequent deployments. Each secret has one persistent source; Core's key digest is derived from Web's sign-in key. Initialization never replaces missing or changed secrets on an existing installation. Core reads the process environment from `.env`. @@ -94,7 +109,7 @@ The initialization service generates secrets and the installation ID once, then | `secrets/database/` | Generated database password | PostgreSQL and Core | | `secrets/core/` | Credential encryption key, installation ID and Core key digest | Core | | `secrets/web/` | Generated Core sign-in key | Web | -| `state/` | Private Provider state | Core | +| `state/` | Private Provider state, mounted in Core at `/state`. Each adapter owns a subdirectory; E2B uses `e2b/`, with no group or other access | Core | | `node-payload/` | Verified node installation metadata | Web | Initialization prepares this directory; application services receive their secret directories read-only. `docker compose exec web oac-web core-key` prints the Core key to the operator terminal without writing it to container logs. Database passwords and credential encryption keys are never printed. @@ -153,21 +168,17 @@ Core reads its process environment. Compose interpolates `.env` into it and moun | Variable | Set from | | --- | --- | -| `OAC_PUBLIC_URL` | The public origin. Core derives the daemon WebSocket URL, the self-hosted `remote_url`, the hosted sandbox address and the deployment's read-only `core_url` from it, never from request headers. Without it, Core runs no Runtime gateway and executes no Sessions | +| `OAC_PUBLIC_URL` | The [public URL](#settings). Core validates it once and derives the Agents API base, the daemon WebSocket URL, the self-hosted `remote_url`, the installer downloads, the hosted sandbox address and the deployment's read-only `core_url` from it, never from request headers | | `OAC_ADDR` | The image sets `:8091`. Independently started Core defaults to `127.0.0.1:8091` when unset or empty | -| `OAC_DATABASE_URL` | PostgreSQL without a password | +| `OAC_DATABASE_URL` | Required. PostgreSQL without a password | | `OAC_DATABASE_PASSWORD_FILE` | `/run/database/password`. The URL must then carry no password | | `OAC_CREDENTIAL_KEY_FILE` | `/run/oac/credential.key` | -| `OAC_CORE_KEY_DIGESTS_FILE` | `/run/oac/core-key-digests.json`: a JSON array with the SHA-256 of the Core key | -| `OAC_INSTALLATION_ID_FILE` | `/run/oac/installation.id`: the installation ID, a canonical UUID. It enables the sandbox deployment and node routes and requires `OAC_PUBLIC_URL` and `OAC_CORE_KEY_DIGESTS_FILE`. Core refuses an ID other than the one its database recorded | -| `OAC_EXECUTION_CONCURRENCY`, `OAC_DEFAULT_HARNESS`, `OAC_HARNESSES`, `OAC_WRITE_AUDIT_RETENTION`, `OAC_OAUTH_TRUSTED_ORIGINS` | The matching [process settings](#settings). `oac-core check-config` validates them without starting Core | -| `OAC_HISTORY_SETTINGS_FILE` | Optional Runtime history file. Sensitive; the installation report says only whether it is set | -| `OAC_LOG_LEVEL`, `OAC_LOG_FORMAT`, `OAC_LOG_ADD_SOURCE` | Logging; Web reads the same three | -| `OAC_PROVIDER_ROOT` | Absolute adapter artifact root. The Core image sets `/opt/oac`. Each adapter owns its helper paths beneath this root | -| `OAC_PROVIDER_STATE_ROOT` | Absolute private state root: `/state` in the Core image. Each adapter owns its subdirectory; E2B uses `e2b/`, owned by Core's user with no group or other access. Back it up with the database and `credential.key`; don't mount it into Web or a Runtime | -| `OAC_NATIVE_INSTALLER_DIR` | Self-hosted daemon installers: `/opt/oac/native-installers` in the Compose file. Unset, Core serves none. Core checks the catalog against its own release before serving it | +| `OAC_CORE_KEY_DIGESTS_FILE` | Required. `/run/oac/core-key-digests.json`: a JSON array with the SHA-256 of the Core key | +| `OAC_INSTALLATION_ID_FILE` | `/run/oac/installation.id`: the installation ID, a canonical UUID. It enables the sandbox deployment and node routes and requires `OAC_PUBLIC_URL`. Core refuses an ID other than the one its database recorded | +| `OAC_EXECUTION_CONCURRENCY`, `OAC_DEFAULT_HARNESS`, `OAC_HARNESSES`, `OAC_WRITE_AUDIT_RETENTION`, `OAC_OAUTH_TRUSTED_ORIGINS`, `OAC_HISTORY_SETTINGS_FILE`, `OAC_LOG_LEVEL`, `OAC_LOG_FORMAT`, `OAC_LOG_ADD_SOURCE` | The matching [process settings](#settings). Web reads the three log settings too | +| `OAC_PROVIDER_ROOT` | Absolute adapter artifact root. The Core image sets `/opt/oac`. Each adapter owns its helper paths beneath this root. Core serves self-hosted daemon installers from its `native-installers/` directory when that holds a `catalog.json`, after checking the catalog against its own release. Adapter state lives at `/state`, the data volume's [`state/`](#compose-installations) | -Core logs the file paths it loads, never environment values or file contents. +Core logs the history file path it loads, never environment values or file contents. Invalid explicit OAuth trusted origins stop Core at startup. Entries must be HTTPS origins without credentials, query or a non-root path. [Vaults](../contracts/agents-api/vaults.md) owns refresh and network policy. A private issuer also needs a trusted CA: independently managed Unix Core can use Go’s `SSL_CERT_FILE` PEM CA-bundle override, which preserves certificate verification. Managed installation has no custom-CA setting. @@ -177,11 +188,11 @@ Compose sets these for Web. Set them yourself only when you run the console with | Variable | Default | Meaning | | --- | --- | --- | -| `OAC_WEB_ADDR` | `:8080` | Listener address | -| `OAC_WEB_ORIGIN` | `http://127.0.0.1:8080` | The exact browser-facing origin, HTTP or HTTPS, without a path. Host and origin checks use it; HTTPS makes the session cookie `Secure` | -| `OAC_WEB_UPSTREAM` | `http://core:8091` | Core's origin, HTTP or HTTPS, without credentials, query or path | -| `OAC_WEB_CORE_KEY_FILE` | `/admin/core.key` | Absolute path of a regular file with no group or other permissions, holding the Core key: at least 32 characters, no whitespace, at most 4 KiB | +| `OAC_WEB_ADDR` | `:8080` | Listener address. The healthcheck probes it on `127.0.0.1` when its host is empty or unspecified | +| `OAC_PUBLIC_URL` | Required | The [public URL](#settings): the exact browser-facing origin, HTTP or HTTPS, without a path. Host and origin checks use it; HTTPS makes the session cookie `Secure` | +| `OAC_WEB_UPSTREAM` | `http://core:8091` | Core's origin, HTTP or HTTPS, without credentials, query or path. The healthcheck probes its `/healthz` | +| `OAC_WEB_CORE_KEY_FILE` | Required | Absolute path of a regular file with no group or other permissions, holding the Core key: at least 32 characters, no whitespace, at most 4 KiB | | `OAC_WEB_DIST` | `/www` | Absolute directory of the built console; must contain `index.html` | | `OAC_WEB_NODE_PAYLOAD_DIR` | unset | Absolute path of the matched distribution's node payload (the installer's `node-payload/`). Unset, `/node-install/*` is not served and Add node is unavailable | -Defaults apply when a variable is absent; an explicitly empty value is validated as supplied. An invalid `OAC_WEB_*` value stops the console at startup with a message naming the variable. The console also reads `OAC_LOG_LEVEL`, `OAC_LOG_FORMAT` and `OAC_LOG_ADD_SOURCE` ([Core environment](#appendix-core-environment-without-the-installer)); unknown values fall back to their defaults. Use HTTPS for any browser that is not on the same machine. +An unset or empty variable selects its default. An invalid value stops the console at startup with a message naming the variable. The console also reads the three log [process settings](#settings) and rejects the values Core rejects. Use HTTPS for any browser that is not on the same machine. diff --git a/docs/getting-started/index.md b/docs/getting-started/index.md index 035c9bf33..60d1cb117 100644 --- a/docs/getting-started/index.md +++ b/docs/getting-started/index.md @@ -1,5 +1,5 @@ --- -title: "OpenAgentCore documentation" +title: "Overview" --- OpenAgentCore runs AI agents on your own infrastructure behind the OpenAI Agents API. The [architecture overview](../architecture.md) explains its parts. Pick the guides for your role. diff --git a/docs/getting-started/install-options.md b/docs/getting-started/install-options.md index 08cdd34e5..3a626ae1f 100644 --- a/docs/getting-started/install-options.md +++ b/docs/getting-started/install-options.md @@ -1,5 +1,5 @@ --- -title: "Installation options and advanced deployments" +title: "Installation options" --- The [default installation](./install.md) needs no options. Use this page to set installation options, deploy with Compose or configure a reverse proxy. diff --git a/docs/getting-started/operations.md b/docs/getting-started/operations.md index a15d9174d..55760e76b 100644 --- a/docs/getting-started/operations.md +++ b/docs/getting-started/operations.md @@ -1,5 +1,5 @@ --- -title: "Operate your installation" +title: "Operations" --- The installation operator owns the Core host, its storage and its availability. Node hosts run their own services; see [Nodes](./nodes.md). Settings are described in the [configuration reference](../configuration.md). diff --git a/docs/web/console-api-usage.md b/docs/web/console-api-usage.md index 60195f8b6..b81088cf4 100644 --- a/docs/web/console-api-usage.md +++ b/docs/web/console-api-usage.md @@ -69,13 +69,13 @@ 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` under its `path`, `apply_command` and `applied_at`; 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`); `path` and `apply_command` beside a sandbox configuration Core rejected. A sensitive setting with a value, or an unknown member, fails the read; `configuration: null` shows a note | +| 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 | | Core metrics | `GET /core/v1/metrics?range=` | Core metrics page; the Core popover on Overview. A Core without the route (404) is shown as not reporting, and the popover then shows only Core's status. The [Core metrics contract](../../contracts/agents-api/core-metrics.md) defines every measurement | -`local_only`, or no `public_url`, stops Add node from issuing a command and Clean up the host from giving one. Overview, Nodes and System then show a visible warning with Core's configuration path and apply command as copyable values; when `configuration` is null, they state that the path and command are unavailable. Nodes disables Add node with a visible reason, and Getting started leaves its sandbox step to do. +`local_only`, or no `public_url`, stops Add node from issuing a command and Clean up the host from giving one. Overview, Nodes and System then show a visible warning, with Review the public address leading to System. Nodes disables Add node with a visible reason, and Getting started leaves its sandbox step to do. Wherever a new key is shown, and without any key on an active project's page, the console gives shell exports of `OPENAI_BASE_URL` (the installation's `api_base_url`) and `OPENAI_API_KEY` (the new key, or a placeholder for a key of the project), with `curl` and Python examples for `GET /v1/agents` and `POST /v1/agents/sessions`, and sends none of them. When the installation is `local_only` it says the API is reachable only on the Core machine, and without an `api_base_url` it says to set `public_url`. @@ -95,7 +95,7 @@ The list carries each harness's configuration, so the console does not read `GET | Operation | Route | Console use | | --- | --- | --- | -| Deployment | `GET`, `POST`, `PUT /core/v1/sandbox/deployment` | Read the provider, the read-only `core_url` (`OAC_PUBLIC_URL`, shown in the setup review and never sent), reset state, installation ID and specification; a 409 `sandbox_configuration_error` (E2B with a loopback `public_url`) shows the shared client's fixed safe address-configuration message in the setup wizard, with the installation's config file and apply command, and leaves nothing to confirm; initialize the deployment with `resources` and the Docker or microsandbox `runtime` release, or with the E2B account and no `resources` (Core adopts the template build's CPU and memory); change its settings with the expected generation. E2B's `metadata.template_build` (status, CPU, memory, disk) shows on System, the Sandbox configuration summary and Sandbox metrics, and sizes each sandbox when `specification.resources` is missing; microsandbox's `suspension` (idle and retention seconds) shows on System and the Nodes summary | +| Deployment | `GET`, `POST`, `PUT /core/v1/sandbox/deployment` | Read the provider, the read-only `core_url` (`OAC_PUBLIC_URL`, shown in the setup review and never sent), reset state, installation ID and specification; a 409 `sandbox_configuration_error` (E2B with a loopback `public_url`) shows the shared client's fixed safe address-configuration message in the setup wizard, with Managed in System leading to System, and leaves nothing to confirm; initialize the deployment with `resources` and the Docker or microsandbox `runtime` release, or with the E2B account and no `resources` (Core adopts the template build's CPU and memory); change its settings with the expected generation. E2B's `metadata.template_build` (status, CPU, memory, disk) shows on System, the Sandbox configuration summary and Sandbox metrics, and sizes each sandbox when `specification.resources` is missing; microsandbox's `suspension` (idle and retention seconds) shows on System and the Nodes summary | | E2B discovery | `POST /core/v1/sandbox/providers/e2b/discovery` | The setup wizard lists the templates the entered E2B key can see, then the selected template's ready builds. The key travels only in these request bodies and the deployment write | | Reset | `POST`, `DELETE /core/v1/sandbox/deployment/reset` | Explicitly clear hosted resources, or cancel the remaining clear at the observed generation; show Core's remaining and offline projection | | Nodes | `GET /core/v1/sandbox/nodes` | Nodes page; fleet on Overview; node capacity on Sandbox metrics. An online node's `diagnostic` (`docker_unavailable`, `docker_limits_unsupported`, `runtime_image_unavailable`, `kvm_unavailable`, `microsandbox_artifacts_unavailable`, `capacity_insufficient`, `provider_unavailable`; any other value reads as `provider_unavailable`) marks it degraded and names the reason and fix in the help tip beside its status on each of these and on the node's page. A node whose `core_url` (the address it enrolled with) differs from the deployment's `core_url` is named on the Nodes page as bound to an old address, to be removed and added again, and its status there and on its page reads Old address instead of its health; an empty `core_url` (a node Core did not enroll) is unknown, not old. **Add node** follows only the node whose `enrollment_id` equals its command's | diff --git a/docs/web/console-server.md b/docs/web/console-server.md index 41fa81d3d..52b89daa0 100644 --- a/docs/web/console-server.md +++ b/docs/web/console-server.md @@ -42,7 +42,7 @@ The deployment's reverse proxy sends every path to the console. The console forw Every request except `/healthz`, `/v1`, `/api/v1` and `/docs` must pass these checks first: -1. **Host and origin.** The `Host` header must equal the host of `OAC_WEB_ORIGIN`. An `Origin` header, when present, must equal that origin, and `Sec-Fetch-Site` must be `same-origin` or `none`. A write that carries neither `Origin` nor `Sec-Fetch-Site: same-origin` needs a same-origin `Referer`. Otherwise the console answers 403. `/node-install/*` checks only the host and the path. +1. **Host and origin.** The `Host` header must equal the host of `OAC_PUBLIC_URL`. An `Origin` header, when present, must equal that origin, and `Sec-Fetch-Site` must be `same-origin` or `none`. A write that carries neither `Origin` nor `Sec-Fetch-Site: same-origin` needs a same-origin `Referer`. Otherwise the console answers 403. `/node-install/*` checks only the host and the path. 2. **Safe request.** The path must start with `/` and contain no `%`, backslash, NUL, dot segment or empty segment. Absolute-form request targets, `CONNECT` and `TRACE` get 400. An `Upgrade` header gets 400 except on `/v1`, `/api/v1` and `/docs`, which are forwarded before these checks. A `/core/v1` request can therefore never leave that prefix. 3. **Sign-in.** Paths that need sign-in answer 401 without a valid session cookie. @@ -75,7 +75,7 @@ The console never retries a request. Browser code calls `/core/v1` through the t The administrator signs in with the deployment's [Core key](../getting-started/operations.md#core-key). There are no console accounts, usernames or setup step, and signing in grants the whole console. - The console compares SHA-256 digests of the submitted and configured keys in constant time. It never logs or returns the key. -- The session cookie `core_console_session` is HttpOnly, `SameSite=Strict`, and `Secure` when `OAC_WEB_ORIGIN` is HTTPS. It lasts 12 hours. +- The session cookie `core_console_session` is HttpOnly, `SameSite=Strict`, and `Secure` when `OAC_PUBLIC_URL` is HTTPS. It lasts 12 hours. - Sessions live only in the console's memory, at most 64 at a time; the oldest is dropped first. A console restart or a Core key rotation signs everyone out. - At most two sign-in checks run at once; another attempt gets 429 with `Retry-After: 1`. - Failed attempts share a budget of 10 per minute; beyond it, a wrong key gets 429 with `Retry-After: 60`. The correct key always signs in, which is why the console refuses to start with a Core key shorter than 32 characters. @@ -98,7 +98,7 @@ With `OAC_WEB_NODE_PAYLOAD_DIR` set, the console serves the matched distribution ## Public address -The console does not configure a domain or obtain certificates. The operator's reverse proxy or hosting platform terminates HTTPS and routes to the console, and `OAC_PUBLIC_URL` records the origin that applications, nodes and executors use. The console accepts only the host of `OAC_WEB_ORIGIN`, so DNS rebinding cannot reach it. +The console does not configure a domain or obtain certificates. The operator's reverse proxy or hosting platform terminates HTTPS and routes to the console, and `OAC_PUBLIC_URL` records the origin that browsers, applications, nodes and executors use. The console accepts only its host, so DNS rebinding cannot reach it. ## Verification diff --git a/docs/zh/architecture.md b/docs/zh/architecture.md index 8777d50e1..11b3c71fe 100644 --- a/docs/zh/architecture.md +++ b/docs/zh/architecture.md @@ -1,7 +1,7 @@ --- title: "架构" source: docs/architecture.md -source_hash: b8a00701caa83314115f28c3fa754291e8eaf734e88de8e0012e45d8b1893338 +source_hash: 630c00591d3980e59b37969d1a149f774da118b3fce13ad5d9f63dfb9a4e216f --- OpenAgentCore 将编排、计算资源和原生执行分开。Core 负责 API 和持久状态。Sandbox Provider 管理计算资源。Runtime daemon 准备 Environment 并运行选定的 Harness;Harness 的原生 SDK 或协议负责模型与工具循环。 @@ -40,7 +40,7 @@ flowchart TB | Model provider | 提供 Harness 选定的模型协议 | [模型执行](../../contracts/agents-api/zh/model-execution.md) | | Web | 让管理员通过服务端 Core API 连接配置与观察安装实例 | [控制台服务端](web/console-server.md) | -[仓库地图](development.md#repository-map) 标出这些组件的位置。[概念与所有权](concepts.md) 解释 Project 边界、管理员权限和工具隔离。 +[仓库地图](development.md#repository-map) 标出这些组件的位置。[概念](concepts.md) 解释 Project 边界、管理员权限和工具隔离。 ## Session 的完整流程 {#a-session-end-to-end} diff --git a/docs/zh/concepts.md b/docs/zh/concepts.md index c916e09a7..ea03bf7f1 100644 --- a/docs/zh/concepts.md +++ b/docs/zh/concepts.md @@ -1,7 +1,7 @@ --- -title: "概念与所有权" +title: "概念" source: docs/concepts.md -source_hash: f15758cc223f104393f3eca822dbc585c8c41bb5c7bdbe787e8af4e7bf1a0d95 +source_hash: 2d65b520ffc8ccce8836d6196fb9daa217a438e4f79ae2aee70d4cb77da4c606 --- Project 是 OpenAgentCore 的执行租户。应用使用其 API key;运维人员使用独立的 Core key 管理安装实例。[API 索引](api/index.md) 将每类调用方映射到对应命名空间和凭据。 diff --git a/docs/zh/configuration.md b/docs/zh/configuration.md index 7d6bfafaf..905f18b7e 100644 --- a/docs/zh/configuration.md +++ b/docs/zh/configuration.md @@ -1,19 +1,20 @@ --- title: "配置参考" source: docs/configuration.md -source_hash: 61cb35a25bbe3624a9a7dfea02e845b0c393f1e9609900225bb4e82d1b18f36d +source_hash: a94c6f992e5656f0cdfb46fd642501e3c59d513897c400c53ad8fdd47cb7ee8e --- -Core 安装的每项设置都恰好只有一个归属位置。共有两类: +Core 安装的每项设置都恰好只有一个归属位置,分属以下三类: -| 类型 | 示例 | 归属位置 | 修改方式 | 生效方式 | +| 类别 | 示例 | 归属位置 | 修改方式 | 生效方式 | | --- | --- | --- | --- | --- | -| [进程设置](#process-settings-configjson) | 公共 URL、端口、日志、Harness、执行并发度、审计保留期、OAuth 来源、Runtime 历史记录导出 | 安装目录中的 `.env`(默认 `~/.oac/core`) | 编辑 `.env`,然后运行 `oac apply` | `oac apply` 会重新创建读取了这些已更改设置的服务 | +| [进程设置](#process-settings) | 公共 URL、端口、日志、Harness、执行并发度、审计保留期、OAuth 来源、Runtime 历史记录 | 安装目录中的 `.env`(默认 `~/.oac/core`) | 编辑 `.env`,然后运行 `oac apply` | `oac apply` 会重新创建读取了这些已更改设置的服务 | +| [机密信息](#compose-installations) | 数据库密码、凭据加密密钥、安装 ID、Core 密钥及由其派生的 Core 密钥摘要 | Compose 数据卷中的 `secrets/`,每项一份 | 初始化时一次性生成;`oac rotate-core-key` 替换 Core 密钥及其摘要 | `oac rotate-core-key` 会重启 Core 和 Web | | [运行时设置](#runtime-settings-web) | 沙箱后端和大小、节点、项目和密钥、默认模型、执行器凭据 | Core 的 PostgreSQL 数据库 | 在 Web 中修改,或使用 Core 密钥调用 Core API(`/core/v1`) | 保存时无需重启 Core;节点会异步准备 Runtime 变更 | -Web 的 **System** 页面显示该安装的地址、默认模型和沙箱配置,并在 **Startup settings** 下以只读方式显示 Core 加载的进程设置。机密信息存放在 [`secrets/`](#compose-installations) 中,每项仅保存一份。没有任何配置文件定义项目或 API 密钥。 +Web 的 **System** 页面显示该安装的地址、默认模型和沙箱配置,并在 **Startup settings** 下以只读方式显示 Core 加载的进程设置。没有任何配置文件定义项目或 API 密钥。 -## 进程设置 {#process-settings-configjson} +## 进程设置 {#process-settings} [安装选项](getting-started/install-options.md)中的安装标志只会一次性写入 `.env`。要更改设置,请编辑 `.env` 并应用: @@ -40,13 +41,13 @@ Web 的 **System** 页面显示该安装的地址、默认模型和沙箱配置 ### 设置 {#settings} -`OAC_HISTORY_SETTINGS_FILE` 可以指向一个文件,其 headers 中含有导出凭据。该文件权限为 `0600`,这些 headers 绝不会出现在 `oac` 输出或安装报告中。模型提供商不属于进程设置;请参阅[默认模型](#default-models)。 +模型提供商不属于进程设置;请参阅[默认模型](#default-models)。 以下配置参考表保留英文原文。 | Variable | Default | Meaning | | --- | --- | --- | -| `OAC_PUBLIC_URL` | `http://localhost:8080` | 应用、节点、沙箱和自托管执行器使用的源地址。参阅[修改公开 URL](#changing-the-public-url) | +| `OAC_PUBLIC_URL` | `http://localhost:8080`,由 `compose.yaml` 设置。未设置时启动的 Core 不运行 Runtime 网关,也不执行任何 Session | 应用、节点、沙箱和自托管执行器使用的源地址。参阅[更改公共 URL](#changing-the-public-url) | | `OAC_HOST` | `127.0.0.1` | `compose.yaml` 发布的 Web 绑定地址。安装器设置为 `0.0.0.0` | | `OAC_WEB_PORT` | `8080` | Host port of Web | | `OAC_LOG_LEVEL` | `info` | `debug`, `info`, `warn` or `error` | @@ -57,9 +58,23 @@ Web 的 **System** 页面显示该安装的地址、默认模型和沙箱配置 | `OAC_HARNESSES` | Every registered Harness | Comma-separated Harnesses to enable besides the default one. Unknown names stop startup | | `OAC_WRITE_AUDIT_RETENTION` | `2160h` | Minimum `1h` | | `OAC_OAUTH_TRUSTED_ORIGINS` | unset | Comma-separated HTTPS origins | -| `OAC_HISTORY_SETTINGS_FILE` | unset | Optional Runtime history file. Sensitive; Core reports only whether it is configured | +| `OAC_HISTORY_SETTINGS_FILE` | unset | 可选的 [Runtime 历史文件](#runtime-history-file)。敏感;Core 只报告它是否已配置 | -未设置或为空的值使用默认值。编辑 `.env`,然后运行 `oac apply`。Core 会在 `GET /core/v1/installation` 报告它加载的进程设置。`oac-core check-config` 会在不启动 Core 的情况下校验同一组环境变量。敏感设置只报告是否已配置。有关 Core 如何收集和保留 Runtime 历史记录,请参阅[保留的历史记录](../../contracts/agents-api/zh/runtime-observability.md#retained-history-and-optional-export)。 +未设置或为空的值使用默认值。编辑 `.env`,然后运行 `oac apply`。Core 在启动时一次性读取所有进程设置及设置指向的文件,并在 `GET /core/v1/installation` 报告加载的结果。`oac-core check-config` 会在不启动 Core 的情况下加载并校验同样的设置和文件。`OAC_PROVIDER_ROOT` 下的原生安装程序目录清单不属于设置,Core 只在启动时检查它。错误信息只指明变量名,绝不包含其值。敏感设置只报告是否已配置。 + +### Runtime 历史文件 {#runtime-history-file} + +`OAC_HISTORY_SETTINGS_FILE` 指向一个 JSON 文件,用于调整[保留的历史记录](../../contracts/agents-api/zh/runtime-observability.md#retained-history-and-optional-export),并可添加 OTLP 导出。没有此文件时,Core 按下表默认值把历史记录保存在数据库中。在 Compose 安装中,把该文件放在数据卷的 `secrets/core/` 目录中,属主为 UID 65532,权限为 `0600`,并设置 `OAC_HISTORY_SETTINGS_FILE=/run/oac/`:Core 以只读方式把该目录挂载到 `/run/oac`。headers 中可以包含导出凭据,它们绝不会出现在 `oac` 输出或安装报告中。未知字段会被拒绝。 + +| 字段 | 默认值 | 含义 | +| --- | --- | --- | +| `sample_interval_seconds` | `30` | 定期采样间隔,范围为 5 到 300 | +| `queue_capacity` | `256` | 每个导出器排队的记录数,最大 4096 | +| `timeout_seconds` | `2` | 导出和历史查询超时,最大 30 | +| `endpoint` | 未设置 | 绝对 OTLP/HTTP 指标 URL,例如 `https://collector.example/v1/metrics`。未设置时 Core 不导出,其他导出字段也必须未设置 | +| `transport` | 未设置 | `otlp_http`;设置 `endpoint` 时必填 | +| `insecure` | `false` | `http` 端点必须设为 `true`,`https` 端点不允许设为 `true` | +| `headers` | 无 | 发往端点的请求标头。`Host`、`Content-Length`、`Content-Type` 和 `Content-Encoding` 为保留标头 | ## 运行时设置:Web {#runtime-settings-web} @@ -88,7 +103,7 @@ Web 的 **System** 页面显示该安装的地址、默认模型和沙箱配置 ## Compose 安装 {#compose-installations} -发行版中的[独立 Compose 文件](getting-started/install-options.md#docker-compose-and-hosting-platforms)从平台环境读取进程设置。`OAC_PUBLIC_URL` 为空时选用 `http://localhost:8080`。把它设成准确的公共源地址,不要带尾部斜杠,并在添加节点或执行器之前重新创建 Core 和 Web。平台终止 TLS,并把流量转到 `web:8080`。 +发行版中的[独立 Compose 文件](getting-started/install-options.md#docker-compose-and-hosting-platforms)从平台环境读取进程设置。把 [`OAC_PUBLIC_URL`](#settings) 设成准确的公共源地址,不要带尾部斜杠,并在添加节点或执行器之前重新创建 Core 和 Web。平台终止 TLS,并把流量转到 `web:8080`。 初始化服务首次生成机密信息和安装 ID,随后在后续部署中验证它们。每项机密信息都只有一个持久来源;Core 的密钥摘要派生自 Web 的登录密钥。对于现有安装,初始化绝不会替换缺失或已更改的机密信息。Core 从 `.env` 读取进程环境。 @@ -98,7 +113,7 @@ Web 的 **System** 页面显示该安装的地址、默认模型和沙箱配置 | `secrets/database/` | 生成的数据库密码 | PostgreSQL 和 Core | | `secrets/core/` | 凭据加密密钥、安装 ID 和 Core 密钥摘要 | Core | | `secrets/web/` | 生成的 Core 登录密钥 | Web | -| `state/` | 私有 Provider 状态 | Core | +| `state/` | 私有 Provider 状态,在 Core 中挂载到 `/state`。每个适配器拥有一个子目录;E2B 使用 `e2b/`,不允许组或其他用户访问 | Core | | `node-payload/` | 已验证的节点安装元数据 | Web | 初始化会准备该目录;应用服务以只读方式接收各自的机密目录。`docker compose exec web oac-web core-key` 把 Core 密钥打印到运维人员终端,不写入容器日志。数据库密码和凭据加密密钥绝不打印。 @@ -157,21 +172,17 @@ Core 读取进程环境。Compose 将 `.env` 插值到环境中,并把机密 | 变量 | 设置来源 | | --- | --- | -| `OAC_PUBLIC_URL` | `public_url`,或 Core 的回环源地址。Core 从中派生守护进程 WebSocket URL、自托管 `remote_url`、托管沙箱地址和部署的只读 `core_url`,绝不从请求标头派生。未设置时,Core 不运行 Runtime 网关,也不执行任何 Session | +| `OAC_PUBLIC_URL` | [公共 URL](#settings)。Core 只校验一次,并从中派生 Agents API 基地址、守护进程 WebSocket URL、自托管 `remote_url`、安装程序下载地址、托管沙箱地址和部署的只读 `core_url`,绝不从请求标头派生 | | `OAC_ADDR` | 安装程序在容器中设置为 `:8091`。独立启动的 Core 在未设置或为空时,默认使用 `127.0.0.1:8091` | -| `OAC_DATABASE_URL` | 该安装不含密码的 PostgreSQL URL,并将 `core.database_pool` 作为 `pool_*` 查询参数附加到其中 | +| `OAC_DATABASE_URL` | 必填。不含密码的 PostgreSQL URL | | `OAC_DATABASE_PASSWORD_FILE` | `/run/database/password`。此时 URL 不得包含密码 | | `OAC_CREDENTIAL_KEY_FILE` | `/run/oac/credential.key` | -| `OAC_CORE_KEY_DIGESTS_FILE` | `/run/oac/core-key-digests.json`:一个包含 Core 密钥 SHA-256 的 JSON 数组 | -| `OAC_INSTALLATION_ID_FILE` | `/run/oac/installation.id`:安装 ID,采用规范 UUID 格式。它会启用沙箱部署和节点路由,并要求设置 `OAC_PUBLIC_URL` 和 `OAC_CORE_KEY_DIGESTS_FILE`。如果 ID 与数据库记录的 ID 不一致,Core 会拒绝它,因此必须将两者一同保留 | -| `OAC_EXECUTION_CONCURRENCY`、`OAC_DEFAULT_HARNESS`、`OAC_HARNESSES`、`OAC_WRITE_AUDIT_RETENTION`、`OAC_OAUTH_TRUSTED_ORIGINS` | 对应的[进程设置](#settings)。`oac-core check-config` 会在不启动 Core 的情况下校验它们 | -| `OAC_HISTORY_SETTINGS_FILE` | 可选的 Runtime 历史文件。敏感;安装报告只说明它是否已设置 | -| `OAC_LOG_LEVEL`、`OAC_LOG_FORMAT`、`OAC_LOG_ADD_SOURCE` | `log.*`;Web 也读取这三个设置 | -| `OAC_PROVIDER_ROOT` | 适配器构件的绝对根目录。Core 镜像设置为 `/opt/oac`。每个适配器都拥有此根目录下的辅助路径 | -| `OAC_PROVIDER_STATE_ROOT` | 绝对私有状态根目录:Core 镜像中为 `/state`。每个适配器都拥有自己的子目录;E2B 使用 `e2b/`,该目录归 Core 的用户所有,不允许组或其他用户访问。将其与数据库和 `credential.key` 一起备份;不要将其挂载到 Web 或 Runtime 中 | -| `OAC_NATIVE_INSTALLER_DIR` | 自托管守护进程安装程序:Compose 文件中为 `/opt/oac/native-installers`。未设置时 Core 不提供安装程序。提供目录清单前,Core 会将其与自身发行版进行核对 | +| `OAC_CORE_KEY_DIGESTS_FILE` | 必填。`/run/oac/core-key-digests.json`:一个包含 Core 密钥 SHA-256 的 JSON 数组 | +| `OAC_INSTALLATION_ID_FILE` | `/run/oac/installation.id`:安装 ID,采用规范 UUID 格式。它会启用沙箱部署和节点路由,并要求设置 `OAC_PUBLIC_URL`。如果 ID 与数据库记录的 ID 不一致,Core 会拒绝它 | +| `OAC_EXECUTION_CONCURRENCY`、`OAC_DEFAULT_HARNESS`、`OAC_HARNESSES`、`OAC_WRITE_AUDIT_RETENTION`、`OAC_OAUTH_TRUSTED_ORIGINS`、`OAC_HISTORY_SETTINGS_FILE`、`OAC_LOG_LEVEL`、`OAC_LOG_FORMAT`、`OAC_LOG_ADD_SOURCE` | 对应的[进程设置](#settings)。Web 也读取三个日志设置 | +| `OAC_PROVIDER_ROOT` | 适配器构件的绝对根目录。Core 镜像设置为 `/opt/oac`。每个适配器都拥有此根目录下的辅助路径。当其中的 `native-installers/` 目录包含 `catalog.json` 时,Core 在核对该目录清单与自身发行版后提供自托管守护进程安装程序。适配器状态位于 `/state`,即数据卷的 [`state/`](#compose-installations) | -Core 会记录所加载文件的路径,但绝不记录环境变量的值或文件内容。 +Core 会记录所加载的历史文件路径,但绝不记录环境变量的值或文件内容。 显式 OAuth 受信任源无效时,Core 会停止启动。条目必须是不含凭据、查询参数和非根路径的 HTTPS 源地址。[Vaults](../../contracts/agents-api/zh/vaults.md) 负责刷新和网络策略。私有颁发者还需要受信任的 CA:独立管理的 Unix Core 可以使用 Go 的 `SSL_CERT_FILE` PEM CA-bundle 覆盖机制,从而保留证书验证。托管安装没有自定义 CA 设置。 @@ -181,11 +192,11 @@ Compose 为 Web 设置这些变量。仅在不使用 Compose 运行控制台时 | 变量 | 默认值 | 含义 | | --- | --- | --- | -| `OAC_WEB_ADDR` | `:8080` | 监听地址 | -| `OAC_WEB_ORIGIN` | `http://127.0.0.1:8080` | 面向浏览器的准确源地址,可以使用 HTTP 或 HTTPS,且不得包含路径。Host 和源地址检查使用此值;HTTPS 会使 Session Cookie 具备 `Secure` 属性 | -| `OAC_WEB_UPSTREAM` | `http://core:8091` | Core 的源地址,可以使用 HTTP 或 HTTPS,且不得包含凭据、查询参数或路径 | -| `OAC_WEB_CORE_KEY_FILE` | `/admin/core.key` | 常规文件的绝对路径,该文件没有组或其他用户权限,并保存 Core 密钥:至少 32 个字符、不含空白字符、最大 4 KiB | +| `OAC_WEB_ADDR` | `:8080` | 监听地址。主机部分为空或未指定时,健康检查在 `127.0.0.1` 上探测它 | +| `OAC_PUBLIC_URL` | 必填 | [公共 URL](#settings):面向浏览器的准确源地址,可以使用 HTTP 或 HTTPS,且不得包含路径。Host 和源地址检查使用此值;HTTPS 会使 Session Cookie 具备 `Secure` 属性 | +| `OAC_WEB_UPSTREAM` | `http://core:8091` | Core 的源地址,可以使用 HTTP 或 HTTPS,且不得包含凭据、查询参数或路径。健康检查探测其 `/healthz` | +| `OAC_WEB_CORE_KEY_FILE` | 必填 | 常规文件的绝对路径,该文件没有组或其他用户权限,并保存 Core 密钥:至少 32 个字符、不含空白字符、最大 4 KiB | | `OAC_WEB_DIST` | `/www` | 已构建控制台的绝对目录;必须包含 `index.html` | | `OAC_WEB_NODE_PAYLOAD_DIR` | 未设置 | 所匹配发行版的节点载荷(即安装程序的 `node-payload/`)的绝对路径。未设置时,不提供 `/node-install/*`,且 Add node 不可用 | -变量不存在时会应用默认值;显式空值会按已提供的值进行验证。无效的 `OAC_WEB_*` 值会阻止控制台启动,并显示一条指明变量名的消息。控制台还会读取 `OAC_LOG_LEVEL`、`OAC_LOG_FORMAT` 和 `OAC_LOG_ADD_SOURCE`([Core 环境](#appendix-core-environment-without-the-installer));未知值会回退到其默认值。对于不在同一台计算机上的任何浏览器,请使用 HTTPS。 +未设置或为空的变量使用其默认值。无效值会阻止控制台启动,并显示一条指明变量名的消息。控制台还会读取三个日志[进程设置](#settings),并拒绝 Core 拒绝的值。对于不在同一台计算机上的任何浏览器,请使用 HTTPS。 diff --git a/docs/zh/getting-started/index.md b/docs/zh/getting-started/index.md index 4f735b1de..92440e3ac 100644 --- a/docs/zh/getting-started/index.md +++ b/docs/zh/getting-started/index.md @@ -1,7 +1,7 @@ --- -title: "OpenAgentCore 文档" +title: "概览" source: docs/getting-started/index.md -source_hash: b00e7833d6dc323fc53a9e6dd145bd0e08069a421346ee48c9454397494aa5a8 +source_hash: 232aae563b4d7614f065118f2a625b7b06d4d7384295aab77ac072856b9af380 --- OpenAgentCore 在你自己的基础设施上运行 AI Agent,并提供 OpenAI Agents API。[架构概览](../architecture.md)介绍各个组成部分。根据你的角色选择指南。 diff --git a/docs/zh/getting-started/install-options.md b/docs/zh/getting-started/install-options.md index fe55a0610..bd5cd674c 100644 --- a/docs/zh/getting-started/install-options.md +++ b/docs/zh/getting-started/install-options.md @@ -1,7 +1,7 @@ --- -title: "安装选项与高级部署" +title: "安装选项" source: docs/getting-started/install-options.md -source_hash: 2e7717f76a46694af3c1ef33a56b195c0a321f54fd7318b488179b4b4c61f336 +source_hash: 6acbb4205e95aa5ad2f36fbfb3656a785b8d420c2ff754b81ae9cc7e07f10482 --- [默认安装](install.md)无需任何选项。本页介绍安装选项、Compose 部署和反向代理配置。 diff --git a/docs/zh/getting-started/operations.md b/docs/zh/getting-started/operations.md index 545f99a7b..e1b4d1112 100644 --- a/docs/zh/getting-started/operations.md +++ b/docs/zh/getting-started/operations.md @@ -1,7 +1,7 @@ --- -title: "管理你的安装" +title: "运维" source: docs/getting-started/operations.md -source_hash: f63789e0f3982b9f6633381d3c93441e5185b04398541b95c3e1d0505de588eb +source_hash: 97f5d49b9b39441a78026433d662120d46471ef5f9a0b6b4508998a3b4491d09 --- 安装运维人员负责 Core 主机、存储和可用性。节点主机运行各自的服务;参阅[节点](nodes.md)。设置见[配置参考](../configuration.md)。 diff --git a/docs/zh/web/console-api-usage.md b/docs/zh/web/console-api-usage.md index 09d91aff8..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: 2a4be7b081286e5a95c14380acc517d2d4b4ba955c0bd61338b0977d84c9df16 +source_hash: 50edc63c79976e9aad9590604a0e361aae178144bc8a6009989b2da5d031408d --- 本页列出各控制台页面读取和写入的 Core 路由,以及控制台如何限定读取范围。[administrator API contract](../../../contracts/agents-api/zh/admin-api.md) 定义了路由、响应结构、分页和审计记录;[API namespaces and credentials](../api/index.md) 定义了本文使用的术语。 @@ -71,13 +71,13 @@ source_hash: 2a4be7b081286e5a95c14380acc517d2d4b4ba955c0bd61338b0977d84c9df16 | 操作 | 路由 | 控制台用途 | | --- | --- | --- | -| 资源所有者 | `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 设置(`path` 下的 `configuration.settings`,以及 `apply_command` 和 `applied_at`;敏感设置仅显示其是否为 `configured`);调用示例中的 `api_base_url`;作为下载来源以及节点安装和卸载命令中 `--source-url` 的 `public_url`(还包括安装命令中的 `--core-url`);Core 拒绝的 Sandbox 配置旁的 `path` 和 `apply_command`。如果敏感设置包含值,或存在未知成员,读取会失败;`configuration: null` 会显示一条说明 | +| 安装 | `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`)。如果敏感设置包含值,或存在未知成员,读取会失败 | | Core 指标 | `GET /core/v1/metrics?range=` | Core 指标页面;Overview 上的 Core 弹出内容。不存在该路由的 Core(404)会显示为未报告数据,此时弹出内容仅显示 Core 状态。[Core metrics contract](../../../contracts/agents-api/zh/core-metrics.md) 定义了每项度量 | -如果为 `local_only`,或者没有 `public_url`,Add node 将无法签发命令,Clean up the host 也无法提供命令。随后 Overview、Nodes 和 System 会显示醒目警告,其中 Core 的配置路径和 apply command 为可复制值;当 `configuration` 为 null 时,它们会说明路径和命令不可用。Nodes 会禁用 Add node 并显示明确原因,Getting started 则将 sandbox 步骤保留为待办项。 +如果为 `local_only`,或者没有 `public_url`,Add node 将无法签发命令,Clean up the host 也无法提供命令。随后 Overview、Nodes 和 System 会显示醒目警告,并通过 Review the public address 前往 System。Nodes 会禁用 Add node 并显示明确原因,Getting started 则将 sandbox 步骤保留为待办项。 无论是在显示新密钥时,还是在活动项目页面没有显示任何密钥时,控制台都会提供 `OPENAI_BASE_URL`(安装的 `api_base_url`)和 `OPENAI_API_KEY`(新密钥,或项目密钥的占位符)的 shell 导出变量,以及针对 `GET /v1/agents` 和 `POST /v1/agents/sessions` 的 `curl` 和 Python 示例,但不会发送其中任何调用。当安装为 `local_only` 时,控制台会说明 API 只能在 Core 所在计算机上访问;当缺少 `api_base_url` 时,则会提示设置 `public_url`。 @@ -97,7 +97,7 @@ source_hash: 2a4be7b081286e5a95c14380acc517d2d4b4ba955c0bd61338b0977d84c9df16 | 操作 | 路由 | 控制台用途 | | --- | --- | --- | -| 部署 | `GET`、`POST`、`PUT /core/v1/sandbox/deployment` | 读取提供商、只读 `core_url`(即 `OAC_PUBLIC_URL`,会显示在设置审核中且绝不发送)、重置状态、安装 ID 和规范;409 `sandbox_configuration_error`(E2B 搭配回环地址形式的 `public_url`)会在设置向导中显示共享客户端固定的安全地址配置消息,并同时显示安装的配置文件和 apply command,且无需确认;使用 `resources` 以及 Docker 或 microsandbox 的 `runtime` release 初始化部署,或者使用 E2B 账户且不提供 `resources`(Core 采用模板构建的 CPU 和内存);使用预期的 generation 更改设置。E2B 的 `metadata.template_build`(状态、CPU、内存、磁盘)会显示在 System、Sandbox 配置摘要和 Sandbox metrics 中;当缺少 `specification.resources` 时,它还会确定每个 Sandbox 的大小;microsandbox 的 `suspension`(空闲和保留秒数)会显示在 System 和 Nodes 摘要中 | +| 部署 | `GET`、`POST`、`PUT /core/v1/sandbox/deployment` | 读取提供商、只读 `core_url`(即 `OAC_PUBLIC_URL`,会显示在设置审核中且绝不发送)、重置状态、安装 ID 和规范;409 `sandbox_configuration_error`(E2B 搭配回环地址形式的 `public_url`)会在设置向导中显示共享客户端固定的安全地址配置消息,并通过 Managed in System 前往 System,且无需确认;使用 `resources` 以及 Docker 或 microsandbox 的 `runtime` release 初始化部署,或者使用 E2B 账户且不提供 `resources`(Core 采用模板构建的 CPU 和内存);使用预期的 generation 更改设置。E2B 的 `metadata.template_build`(状态、CPU、内存、磁盘)会显示在 System、Sandbox 配置摘要和 Sandbox metrics 中;当缺少 `specification.resources` 时,它还会确定每个 Sandbox 的大小;microsandbox 的 `suspension`(空闲和保留秒数)会显示在 System 和 Nodes 摘要中 | | E2B 发现 | `POST /core/v1/sandbox/providers/e2b/discovery` | 设置向导先列出输入的 E2B 密钥可见的模板,再列出所选模板的可用构建。该密钥只会通过这些请求体和部署写入请求传输 | | 重置 | `POST`、`DELETE /core/v1/sandbox/deployment/reset` | 显式清除托管资源,或在观测到的 generation 处取消剩余清除;显示 Core 的剩余资源和离线预测 | | Nodes | `GET /core/v1/sandbox/nodes` | Nodes 页面;Overview 上的机群;Sandbox metrics 中的节点容量。在线节点的 `diagnostic`(`docker_unavailable`、`docker_limits_unsupported`、`runtime_image_unavailable`、`kvm_unavailable`、`microsandbox_artifacts_unavailable`、`capacity_insufficient`、`provider_unavailable`;任何其他值均读取为 `provider_unavailable`)会将其标记为降级,并在上述每个页面及节点页面中,紧邻状态的帮助提示里说明原因和修复方法。如果节点的 `core_url`(其注册时使用的地址)与部署的 `core_url` 不同,Nodes 页面会将其标记为绑定到旧地址,需要移除后重新添加;此时它在该页面和节点页面中的状态会显示 Old address,而不是健康状态;如果 `core_url` 为空(Core 未注册该节点),则状态为未知,而不是旧地址。**Add node** 仅跟踪 `enrollment_id` 与其命令所含 `enrollment_id` 相等的节点 | diff --git a/docs/zh/web/console-server.md b/docs/zh/web/console-server.md index e7b67fd2a..a152253ce 100644 --- a/docs/zh/web/console-server.md +++ b/docs/zh/web/console-server.md @@ -1,7 +1,7 @@ --- title: "控制台服务器" source: docs/web/console-server.md -source_hash: b0302f0cf27ccd116c4bbb9477d5853f4ae1bf6cea34c9a4e21af72d25656557 +source_hash: 2cc4b562301d95640d2b653ec522a5407a70a98e1f4e3c1fe89bd246ffd1199e --- 控制台服务器(`services/web`、`oac-web` 进程)提供构建后的控制台,使用 Core 密钥认证管理员,并将已登录浏览器的 `/core/v1` 请求携带该密钥转发到 Core。浏览器不持有 Core 密钥或任何 API 密钥。应用、节点和自托管执行器经控制台到达 Core,控制台原样转发 `/v1`、`/api/v1` 和 `/docs`。 @@ -44,7 +44,7 @@ flowchart LR 除 `/healthz`、`/v1`、`/api/v1` 和 `/docs` 外,每个请求首先必须通过这些检查: -1. **Host 与来源。** `Host` 请求头必须等于 `OAC_WEB_ORIGIN` 的主机。存在 `Origin` 时必须等于该来源,`Sec-Fetch-Site` 必须为 `same-origin` 或 `none`。写请求既无 `Origin` 又无 `Sec-Fetch-Site: same-origin` 时,需要同源 `Referer`。否则控制台返回 403。`/node-install/*` 仅检查主机和路径。 +1. **Host 与来源。** `Host` 请求头必须等于 `OAC_PUBLIC_URL` 的主机。存在 `Origin` 时必须等于该来源,`Sec-Fetch-Site` 必须为 `same-origin` 或 `none`。写请求既无 `Origin` 又无 `Sec-Fetch-Site: same-origin` 时,需要同源 `Referer`。否则控制台返回 403。`/node-install/*` 仅检查主机和路径。 2. **安全请求。** 路径必须以 `/` 开头,不含 `%`、反斜杠、NUL、点路径段或空路径段。绝对形式请求目标、`CONNECT` 和 `TRACE` 返回 400。`Upgrade` 头返回 400,但 `/v1`、`/api/v1` 和 `/docs` 在这些检查之前就被转发。因此 `/core/v1` 请求无法离开该前缀。 3. **登录。** 需要登录的路径在无有效会话 cookie 时返回 401。 @@ -77,7 +77,7 @@ flowchart LR 管理员使用部署的 [Core 密钥](../getting-started/operations.md#core-key)登录。没有控制台账号、用户名或设置步骤,登录授予整个控制台访问权限。 - 控制台以恒定时间比较提交密钥与配置密钥的 SHA-256 摘要,不记录或返回密钥。 -- 会话 cookie `core_console_session` 为 HttpOnly、`SameSite=Strict`,`OAC_WEB_ORIGIN` 为 HTTPS 时还设置 `Secure`。有效期 12 小时。 +- 会话 cookie `core_console_session` 为 HttpOnly、`SameSite=Strict`,`OAC_PUBLIC_URL` 为 HTTPS 时还设置 `Secure`。有效期 12 小时。 - 会话仅存在控制台内存中,最多同时 64 个,先移除最旧的。重启控制台或轮换 Core 密钥会让所有用户退出登录。 - 同时最多执行两次登录检查;额外尝试返回 429 和 `Retry-After: 1`。 - 失败尝试共享每分钟 10 次预算;超出后,错误密钥返回 429 和 `Retry-After: 60`。正确密钥始终可以登录,因此控制台拒绝使用少于 32 字符的 Core 密钥启动。 @@ -100,7 +100,7 @@ flowchart LR ## 公开地址 {#public-address} -控制台不配置域名,也不申请证书。运维人员的反向代理或托管平台终止 HTTPS 并把流量转到控制台,`OAC_PUBLIC_URL` 记录应用、节点和执行器使用的源地址。控制台只接受 `OAC_WEB_ORIGIN` 的主机,因此 DNS 重绑定不能访问它。 +控制台不配置域名,也不申请证书。运维人员的反向代理或托管平台终止 HTTPS 并把流量转到控制台,`OAC_PUBLIC_URL` 记录浏览器、应用、节点和执行器使用的源地址。控制台只接受该地址的主机,因此 DNS 重绑定不能访问它。 ## 验证 {#verification} diff --git a/internal/modelprovider/config.go b/internal/modelprovider/config.go index 34c64dcf6..25cf8d76d 100644 --- a/internal/modelprovider/config.go +++ b/internal/modelprovider/config.go @@ -31,15 +31,11 @@ type Provider struct { var ErrConfiguration = errors.New("invalid model provider configuration") -// Valid is the single vocabulary of supported upstream protocol formats. -func (p Protocol) Valid() bool { - switch p { - case Anthropic, Responses, ChatCompletions: - return true - default: - return false - } -} +// Protocols is the single vocabulary of supported upstream protocol formats. +// The Harness catalog generator projects it to the TypeScript client. +func Protocols() []Protocol { return []Protocol{Anthropic, Responses, ChatCompletions} } + +func (p Protocol) Valid() bool { return slices.Contains(Protocols(), p) } // ValidBasePath reports whether a base URL's path suits the protocol. The // anthropic routes begin with the version path, so an anthropic base URL diff --git a/internal/modelprovider/config_test.go b/internal/modelprovider/config_test.go index 1bb103644..58afc7561 100644 --- a/internal/modelprovider/config_test.go +++ b/internal/modelprovider/config_test.go @@ -7,7 +7,7 @@ import ( func TestProviderValidate(t *testing.T) { valid := Provider{Protocol: Responses, BaseURL: "https://model.example/api", APIKey: "fixture-upstream-key", ContextWindow: 64000, MaxOutputTokens: 4096} - for _, protocol := range []Protocol{Anthropic, Responses, ChatCompletions} { + for _, protocol := range Protocols() { p := valid p.Protocol = protocol if err := p.Validate(); err != nil { diff --git a/internal/modelprovider/routes_test.go b/internal/modelprovider/routes_test.go index f80647758..a65ebd9d2 100644 --- a/internal/modelprovider/routes_test.go +++ b/internal/modelprovider/routes_test.go @@ -13,7 +13,7 @@ func TestLookupRouteMatchesOnlyDeclaredRoutes(t *testing.T) { t.Fatalf("stripped header %q is not canonical", header) } } - for _, protocol := range []Protocol{Anthropic, Responses, ChatCompletions} { + for _, protocol := range Protocols() { routes := Routes(protocol) if len(routes) == 0 { t.Fatalf("%s declares no routes", protocol) @@ -59,7 +59,7 @@ func TestLookupRouteMatchesOnlyDeclaredRoutes(t *testing.T) { } func TestPlaceholderPassesProviderValidation(t *testing.T) { - for _, protocol := range []Protocol{Anthropic, Responses, ChatCompletions} { + for _, protocol := range Protocols() { gateway := Provider{Protocol: protocol, BaseURL: "http://127.0.0.1:41000", APIKey: Placeholder, ContextWindow: 64000, MaxOutputTokens: 4096} if err := gateway.Validate(); err != nil { t.Fatalf("%s rejected the placeholder: %v", protocol, err) diff --git a/internal/obs/log/init.go b/internal/obs/log/init.go index d570e56be..10c5194dd 100644 --- a/internal/obs/log/init.go +++ b/internal/obs/log/init.go @@ -1,10 +1,10 @@ package log import ( + "errors" "io" "log/slog" "os" - "strings" "sync" ) @@ -22,22 +22,42 @@ type Config struct { Out io.Writer } -// ConfigFromEnv reads: +// LoadConfig reads the logging settings Core and Web share: // -// OAC_LOG_FORMAT = json | text (default: auto) // OAC_LOG_LEVEL = debug | info | warn | error (default: info) +// OAC_LOG_FORMAT = auto | json | text (default: auto) // OAC_LOG_ADD_SOURCE = 0 | 1 (default: 0) // -// Unknown values fall back to defaults — Init runs before most -// error-handling exists, so "boot anyway" beats "panic on typo". -func ConfigFromEnv() Config { - cfg := Config{ - Format: strings.ToLower(strings.TrimSpace(os.Getenv("OAC_LOG_FORMAT"))), - Level: parseLevel(os.Getenv("OAC_LOG_LEVEL")), - AddSource: os.Getenv("OAC_LOG_ADD_SOURCE") == "1", - Out: os.Stderr, +// Unset or empty selects the default. Any other value is an error that names +// the variable and never echoes the value. +func LoadConfig() (Config, error) { + var cfg Config + switch os.Getenv("OAC_LOG_LEVEL") { + case "", "info": + case "debug": + cfg.Level = slog.LevelDebug + case "warn": + cfg.Level = slog.LevelWarn + case "error": + cfg.Level = slog.LevelError + default: + return Config{}, errors.New("OAC_LOG_LEVEL must be debug, info, warn or error") + } + switch format := os.Getenv("OAC_LOG_FORMAT"); format { + case "", "auto": + case "json", "text": + cfg.Format = format + default: + return Config{}, errors.New("OAC_LOG_FORMAT must be auto, json or text") + } + switch os.Getenv("OAC_LOG_ADD_SOURCE") { + case "", "0": + case "1": + cfg.AddSource = true + default: + return Config{}, errors.New("OAC_LOG_ADD_SOURCE must be 0 or 1") } - return cfg + return cfg, nil } // isTerminal reports whether f is a character device (TTY) so the JSON @@ -53,19 +73,6 @@ func isTerminal(f *os.File) bool { return info.Mode()&os.ModeCharDevice != 0 } -func parseLevel(s string) slog.Level { - switch strings.ToLower(strings.TrimSpace(s)) { - case "debug": - return slog.LevelDebug - case "warn", "warning": - return slog.LevelWarn - case "error", "err": - return slog.LevelError - default: - return slog.LevelInfo - } -} - // initOnce guarantees Init's slog.SetDefault side-effect runs at most // once per process so tests don't fight over the global handler. var initOnce sync.Once diff --git a/internal/obs/log/init_test.go b/internal/obs/log/init_test.go new file mode 100644 index 000000000..1d7fd7c4c --- /dev/null +++ b/internal/obs/log/init_test.go @@ -0,0 +1,30 @@ +package log + +import ( + "log/slog" + "strings" + "testing" +) + +func TestLoadConfigIsStrictAndEmptyMeansDefault(t *testing.T) { + t.Setenv("OAC_LOG_LEVEL", "") + t.Setenv("OAC_LOG_FORMAT", "auto") + t.Setenv("OAC_LOG_ADD_SOURCE", "") + if cfg, err := LoadConfig(); err != nil || cfg.Level != slog.LevelInfo || cfg.Format != "" || cfg.AddSource { + t.Fatal(cfg, err) + } + t.Setenv("OAC_LOG_LEVEL", "warn") + t.Setenv("OAC_LOG_FORMAT", "text") + t.Setenv("OAC_LOG_ADD_SOURCE", "1") + if cfg, err := LoadConfig(); err != nil || cfg.Level != slog.LevelWarn || cfg.Format != "text" || !cfg.AddSource { + t.Fatal(cfg, err) + } + for name, value := range map[string]string{"OAC_LOG_LEVEL": "warning", "OAC_LOG_FORMAT": "JSON", "OAC_LOG_ADD_SOURCE": "true"} { + t.Run(name, func(t *testing.T) { + t.Setenv(name, value) + if _, err := LoadConfig(); err == nil || !strings.Contains(err.Error(), name) || strings.Contains(err.Error(), value) { + t.Fatal(err) + } + }) + } +} 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 9970ca8a5..eae192ecb 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); }); @@ -328,19 +326,20 @@ describe("AdminClient installation", () => { const installation = { object: "core.installation", installation_id: resourceId, public_url: "https://core.example", api_base_url: "https://core.example/v1", local_only: false, source_commit: "a".repeat(40), - configuration: { path: "/home/alice/.oac/core/config.json", apply_command: "/home/alice/.oac/core/oac apply", applied_at: "2026-09-25T09:30:00Z", settings: [port, headers] }, + configuration: { settings: [port, headers] }, address_bindings: { nodes: 2, nodes_on_other_address: 1, hosted_sandboxes: 3, self_hosted_executors: 1 }, }; it("reads installation facts before any deployment and rejects inconsistent snapshots", async () => { expect(await clientWith(installation).client.retrieveInstallation()).toEqual(installation); - expect(await clientWith({ ...installation, installation_id: null, public_url: null, api_base_url: null, source_commit: null, configuration: null }).client.retrieveInstallation()).toMatchObject({ public_url: null }); - const configuration = (settings: unknown[]) => ({ ...installation, configuration: { ...installation.configuration, settings } }); + expect(await clientWith({ ...installation, installation_id: null, public_url: null, api_base_url: null, source_commit: null }).client.retrieveInstallation()).toMatchObject({ public_url: null }); + const configuration = (settings: unknown[]) => ({ ...installation, configuration: { settings } }); for (const invalid of [ configuration([port, { ...headers, value: { authorization: "leak" } }]), configuration([port, { key: headers.key, value: null, default: null, changeable: true, sensitive: true, restarts: ["core"] }]), configuration([port, port]), { ...installation, address_bindings: { ...installation.address_bindings, nodes_on_other_address: 3 } }, { ...installation, token: "leak" }, + { ...installation, configuration: null }, configuration([{ ...port, configured: true }]), ]) { await expect(clientWith(invalid).client.retrieveInstallation()).rejects.toMatchObject({ code: "invalid_admin_response" }); @@ -475,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 67f509a60..206502a28 100644 --- a/packages/agents-client/src/admin-projection.ts +++ b/packages/agents-client/src/admin-projection.ts @@ -1,10 +1,10 @@ -import { coreHarnessKinds } from "./harness-catalog"; +import { coreHarnessKinds, modelProviderProtocols } from "./harness-catalog"; import { AgentCoreError, projectRuntimeObservation, projectSavedAgentConfiguration } from "./client"; 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"]); @@ -243,7 +227,7 @@ export function projectHarnessModelConfiguration(value: unknown, harness?: CoreH } function projectModelConfigurationSupport(value: unknown): CoreHarness["model_configuration_support"] { const support = record(value, ["protocols", "accepts_harness_config", "token_limits_required"]); - const known = new Set(["anthropic", "responses", "chat_completions"]); + const known: ReadonlySet = new Set(modelProviderProtocols); const protocols = support.protocols; if (!Array.isArray(protocols) || protocols.length === 0 || protocols.some((protocol) => !known.has(protocol)) || new Set(protocols).size !== protocols.length || @@ -292,14 +276,9 @@ export function projectInstallation(value: unknown): CoreInstallation { const bindings = record(installation.address_bindings, ["nodes", "nodes_on_other_address", "hosted_sandboxes", "self_hosted_executors"]); if (![bindings.nodes, bindings.nodes_on_other_address, bindings.hosted_sandboxes, bindings.self_hosted_executors].every(isNonnegativeInteger) || (bindings.nodes_on_other_address as number) > (bindings.nodes as number)) return invalidAdminResponse(); - let configuration: CoreInstallation["configuration"] = null; - if (installation.configuration !== null) { - const applied = record(installation.configuration, ["path", "apply_command", "applied_at", "settings"]); - if (typeof applied.path !== "string" || (applied.path !== "" && !applied.path.startsWith("/")) || typeof applied.apply_command !== "string" || - (applied.applied_at !== null && (typeof applied.applied_at !== "string" || !date(applied.applied_at))) || !Array.isArray(applied.settings)) return invalidAdminResponse(); - const settings = applied.settings.map(projectInstallationSetting); - if (new Set(settings.map((setting) => setting.key)).size !== settings.length) return invalidAdminResponse(); - configuration = { path: applied.path, apply_command: applied.apply_command, applied_at: applied.applied_at, settings }; - } - return { ...installation, address_bindings: { ...bindings }, configuration } as unknown as CoreInstallation; + const configuration = record(installation.configuration, ["settings"]); + if (!Array.isArray(configuration.settings)) return invalidAdminResponse(); + const settings = configuration.settings.map(projectInstallationSetting); + if (new Set(settings.map((setting) => setting.key)).size !== settings.length) return invalidAdminResponse(); + return { ...installation, address_bindings: { ...bindings }, configuration: { settings } } as unknown as CoreInstallation; } diff --git a/packages/agents-client/src/admin-types.ts b/packages/agents-client/src/admin-types.ts index 53701d598..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/deployment-contract.ts b/packages/agents-client/src/deployment-contract.ts new file mode 100644 index 000000000..77ccf9d18 --- /dev/null +++ b/packages/agents-client/src/deployment-contract.ts @@ -0,0 +1,3 @@ +// Code generated by services/core/cmd/specification-contract from sandbox/deployment_contract.go; DO NOT EDIT. + +export const deploymentContract = {"resources":[{"name":"cpus","min":1,"max":255,"omit_zero":false},{"name":"memory_mib","min":512,"max":1048576,"omit_zero":false},{"name":"root_disk_mib","min":0,"max":4294967295,"omit_zero":true},{"name":"environment_disk_mib","min":0,"max":4294967295,"omit_zero":true}],"runtime":[{"name":"source_commit","pattern":"[0-9a-f]{40}"},{"name":"image_id","pattern":"sha256:[0-9a-f]{64}"},{"name":"image_manifest_digest","pattern":"sha256:[0-9a-f]{64}"},{"name":"microsandbox_ref","pattern":"oac-runtime@sha256:[0-9a-f]{64}"},{"name":"runtime_sha256","pattern":"[0-9a-f]{64}"},{"name":"firmware_sha256","pattern":"[0-9a-f]{64}"}],"minimum_disk":1024} as const; diff --git a/packages/agents-client/src/execution-configuration-projection.ts b/packages/agents-client/src/execution-configuration-projection.ts index 5ae4c5000..35ff00a33 100644 --- a/packages/agents-client/src/execution-configuration-projection.ts +++ b/packages/agents-client/src/execution-configuration-projection.ts @@ -1,3 +1,4 @@ +import { modelProviderProtocols } from "./harness-catalog"; import { canonicalUuid, exactFields, isNonnegativeInteger, isRecord, onlyFields, sameResourceId } from "./response-projection"; import type { ExecutionConfigurationSource, ModelProviderView, SessionExecutionConfiguration } from "./types"; @@ -5,6 +6,7 @@ type Invalid = () => never; const sources = new Set(["session", "agent", "deployment", "unknown"]); const selectionFields = new Set(["value", "source"]); const providerFields = new Set(["protocol", "base_url", "api_key_configured", "context_window", "max_output_tokens"]); +const protocols: ReadonlySet = new Set(modelProviderProtocols); function selection(value: unknown, invalid: Invalid): SessionExecutionConfiguration["model"] { if (!isRecord(value) || !exactFields(value, selectionFields) || !sources.has(String(value.source)) || @@ -32,14 +34,14 @@ function safeBaseURL(value: string): boolean { /** The safe provider view shared by frozen Session configuration and saved Agent reads. */ export function safeProvider(value: unknown, invalid: Invalid): ModelProviderView { if (!isRecord(value) || !onlyFields(value, providerFields) || - (value.protocol !== "responses" && value.protocol !== "anthropic" && value.protocol !== "chat_completions") || + !protocols.has(value.protocol) || typeof value.base_url !== "string" || typeof value.api_key_configured !== "boolean" || (value.context_window !== undefined && !isNonnegativeInteger(value.context_window)) || (value.max_output_tokens !== undefined && !isNonnegativeInteger(value.max_output_tokens)) || Number(value.max_output_tokens ?? 0) > Number(value.context_window ?? 0)) return invalid(); if (!safeBaseURL(value.base_url)) return invalid(); return { - protocol: value.protocol, base_url: value.base_url, api_key_configured: value.api_key_configured, + protocol: value.protocol as ModelProviderView["protocol"], base_url: value.base_url, api_key_configured: value.api_key_configured, ...(value.context_window === undefined ? {} : { context_window: value.context_window as number }), ...(value.max_output_tokens === undefined ? {} : { max_output_tokens: value.max_output_tokens as number }), }; diff --git a/packages/agents-client/src/harness-catalog.ts b/packages/agents-client/src/harness-catalog.ts index 2594b6442..3d1934a99 100644 --- a/packages/agents-client/src/harness-catalog.ts +++ b/packages/agents-client/src/harness-catalog.ts @@ -7,3 +7,5 @@ export const coreHarnessNames: Record = { "codex": "Codex", "mcode": "MiniMax Code", }; +export const modelProviderProtocols = ["anthropic", "responses", "chat_completions"] as const; +export type ModelProviderProtocol = (typeof modelProviderProtocols)[number]; diff --git a/packages/agents-client/src/index.ts b/packages/agents-client/src/index.ts index c65bc9604..669bdcd4b 100644 --- a/packages/agents-client/src/index.ts +++ b/packages/agents-client/src/index.ts @@ -1,4 +1,5 @@ -export { coreHarnessKinds, coreHarnessNames } from "./harness-catalog"; +export { coreHarnessKinds, coreHarnessNames, modelProviderProtocols } from "./harness-catalog"; +export { deploymentContract } from "./deployment-contract"; export { AgentCoreError, CreationStreamRetryError, createIdempotencyKey, isSessionDeletionConflict, OpenAIAgentsClient } from "./client"; export type { OpenAIAgentsClientOptions, CoreErrorDetail, CoreErrorDetails } from "./client"; export { createSSEDecoder } from "./sse"; @@ -12,6 +13,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/sandbox-client.ts b/packages/agents-client/src/sandbox-client.ts index ef18d0c02..256ee7001 100644 --- a/packages/agents-client/src/sandbox-client.ts +++ b/packages/agents-client/src/sandbox-client.ts @@ -1,5 +1,6 @@ import { AgentCoreError } from "./client"; import { CoreRequester, type CoreClientOptions } from "./core-request"; +import { deploymentContract } from "./deployment-contract"; import { hasOwn, isNonnegativeInteger, isRecord, onlyFields, sameResourceId } from "./response-projection"; import type { ReadOptions } from "./types"; @@ -25,6 +26,9 @@ export function normalizeSandboxNodeDiagnostic(value: string): Exclude(["runtime", ...deploymentContract.resources.map(({ name }) => `resources.${name}`)]); /** CPU and MiB limits for each sandbox, not node concurrency. */ export interface SandboxResources { cpus: number; memory_mib: number; root_disk_mib?: number; environment_disk_mib?: number } export interface SandboxRuntimeRelease { source_commit: string; image_id: string; image_manifest_digest: string; microsandbox_ref: string; runtime_sha256: string; firmware_sha256: string } @@ -389,7 +393,7 @@ export class SandboxAdminClient { const fields = error.code === "sandbox_generation_stale" ? ["current_generation"] : error.code === "sandbox_in_use" ? ["allocations", "pending"] : error.code === "invalid_sandbox_configuration" ? ["min", "max"] : []; const details = Object.fromEntries(fields.filter(field => isNonnegativeInteger(error.details?.[field])).map(field => [field, Number(error.details![field])])); const safeParam = error.status === 400 - ? error.code === "sandbox_credential_invalid" ? "credential" : error.code === "sandbox_configuration_invalid" ? "configuration" : error.code === "invalid_sandbox_configuration" && ["runtime", "resources.cpus", "resources.memory_mib", "resources.root_disk_mib", "resources.environment_disk_mib"].includes(error.param ?? "") ? error.param : null + ? error.code === "sandbox_credential_invalid" ? "credential" : error.code === "sandbox_configuration_invalid" ? "configuration" : error.code === "invalid_sandbox_configuration" && sandboxConfigurationParams.has(error.param) ? error.param : null : error.status === 409 && error.code === "sandbox_credential_ownership" ? "credential" : null; const param = error.param === safeParam ? safeParam : null; throw new AgentCoreError(messages[error.code]!, error.status, error.code, param, undefined, Object.keys(details).length ? details : undefined); @@ -401,7 +405,7 @@ export class SandboxAdminClient { throw new AgentCoreError("E2B sandboxes reach Core over the internet. Set an HTTPS public URL that is not loopback.", 409, "sandbox_configuration_error", null); } // Any other credential-bearing rejection may reflect the key in any error field. - throw new AgentCoreError("Sandbox configuration could not be confirmed. Refresh before submitting again.", error instanceof AgentCoreError ? error.status : 0, "sandbox_configuration_unconfirmed"); + throw new AgentCoreError("Sandbox configuration could not be confirmed. Refresh before submitting again.", error instanceof AgentCoreError ? error.status : 0, sandboxConfigurationUnconfirmed); } } async listNodes(options?: ReadOptions): Promise<{ data: SandboxNode[] }> { 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..ceac7b2c9 100644 --- a/packages/agents-client/src/types.ts +++ b/packages/agents-client/src/types.ts @@ -1,4 +1,4 @@ -import type { CoreHarnessKind } from "./harness-catalog"; +import type { CoreHarnessKind, ModelProviderProtocol } from "./harness-catalog"; export type PageOrder = "asc" | "desc"; export interface ListPage { @@ -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"; @@ -1211,11 +1185,11 @@ export interface RuntimeHistory { token_usage: RuntimeHistoryTokenUsagePoint[]; } -export type { CoreHarnessKind } from "./harness-catalog"; +export type { CoreHarnessKind, ModelProviderProtocol } from "./harness-catalog"; /** A complete replacement bundle. API keys are write-only. */ export interface ModelProviderInput { - protocol: "anthropic" | "responses" | "chat_completions"; + protocol: ModelProviderProtocol; base_url: string; api_key: string; context_window?: number; @@ -1224,7 +1198,7 @@ export interface ModelProviderInput { } export interface ModelProviderView { - protocol: "anthropic" | "responses" | "chat_completions"; + protocol: ModelProviderProtocol; base_url: string; context_window?: number; max_output_tokens?: number; @@ -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/scripts/generate-harness-catalog.py b/scripts/generate-harness-catalog.py index af3d2091d..0af1ba279 100644 --- a/scripts/generate-harness-catalog.py +++ b/scripts/generate-harness-catalog.py @@ -47,6 +47,20 @@ def render_installer(providers): "PROVIDERS = " + pformat(providers, sort_dicts=True, width=100) + "\n") +def render_client(entries, protocols): + kinds = ", ".join(json.dumps(entry["kind"]) for entry in entries) + labels = "\n".join(f' {json.dumps(entry["kind"])}: {json.dumps(entry["label"])},' for entry in entries) + return HEADER + f''' +export const coreHarnessKinds = [{kinds}] as const; +export type CoreHarnessKind = (typeof coreHarnessKinds)[number]; +export const coreHarnessNames: Record = {{ +{labels} +}}; +export const modelProviderProtocols = [{", ".join(json.dumps(protocol) for protocol in protocols)}] as const; +export type ModelProviderProtocol = (typeof modelProviderProtocols)[number]; +''' + + def render(entries): packages = sorted({entry["configuration"] for entry in entries}) imports = "\n".join(f'\t"{MODULE}/internal/harnessconfig/{package}"' for package in packages) @@ -56,7 +70,6 @@ def render(entries): kinds = ", ".join(json.dumps(entry["kind"]) for entry in entries) profiles = "\n".join( f'\t{json.dumps(entry["kind"])}: {entry["profile"]}(),' for entry in entries) - labels = "\n".join(f' {json.dumps(entry["kind"])}: {json.dumps(entry["label"])},' for entry in entries) tick = chr(96) rows = "\n".join( f'| {tick}{e["kind"]}{tick} | {e["label"]} | {tick}{e["configuration"]}.Configuration{tick} | {tick}{e["profile"]}{tick} |' @@ -96,13 +109,6 @@ def render(entries): {profiles} }}) '''), - Path("packages/agents-client/src/harness-catalog.ts"): HEADER + f''' -export const coreHarnessKinds = [{kinds}] as const; -export type CoreHarnessKind = (typeof coreHarnessKinds)[number]; -export const coreHarnessNames: Record = {{ -{labels} -}}; -''', Path("contracts/agents-api/harness-catalog.md"): f'''[//]: # (Generated by scripts/generate-harness-catalog.py; DO NOT EDIT.) # Built-in Harness registrations @@ -149,9 +155,10 @@ def projection(relative, content): # mode a stale registration must fail before its declarations can be projected. if stale: raise SystemExit("Stale Harness catalog projections; run make generate-harness-catalog:\n" + "\n".join(stale)) - providers = json.loads(subprocess.run(["go", "run", "./scripts/harness-catalog"], cwd=ROOT, - text=True, check=True, capture_output=True).stdout) - projection(Path("scripts/acceptance/harness_catalog.py"), render_installer(providers)) + declared = json.loads(subprocess.run(["go", "run", "./scripts/harness-catalog"], cwd=ROOT, + text=True, check=True, capture_output=True).stdout) + projection(Path("scripts/acceptance/harness_catalog.py"), render_installer(declared["harnesses"])) + projection(Path("packages/agents-client/src/harness-catalog.ts"), render_client(entries, declared["protocols"])) if stale: raise SystemExit("Stale Harness catalog projections; run make generate-harness-catalog:\n" + "\n".join(stale)) diff --git a/scripts/generate-harness-catalog.test.py b/scripts/generate-harness-catalog.test.py index a0ff636c3..70b9b7ce7 100644 --- a/scripts/generate-harness-catalog.test.py +++ b/scripts/generate-harness-catalog.test.py @@ -35,7 +35,9 @@ def test_new_registration_reaches_all_projections(self): with tempfile.TemporaryDirectory() as directory: path = Path(directory) / "catalog.json" path.write_text(json.dumps(entries)) - generated = catalog.render(catalog.load_catalog(path)) + loaded = catalog.load_catalog(path) + generated = catalog.render(loaded) + generated["client"] = catalog.render_client(loaded, ["responses"]) for content in generated.values(): self.assertIn("example", content) for old in ("codex", "claude_sdk", "mcode"): diff --git a/scripts/harness-catalog/main.go b/scripts/harness-catalog/main.go index 6ce96690f..dff896066 100644 --- a/scripts/harness-catalog/main.go +++ b/scripts/harness-catalog/main.go @@ -1,4 +1,5 @@ -// Command harness-catalog projects adapter-owned provider declarations for tooling. +// Command harness-catalog projects adapter-owned provider declarations and the +// model-provider protocol vocabulary for tooling. package main import ( @@ -6,6 +7,7 @@ import ( "os" "github.com/MiniMax-AI/OpenAgentCore/internal/harnessconfig/builtin" + "github.com/MiniMax-AI/OpenAgentCore/internal/modelprovider" ) type provider struct { @@ -25,7 +27,11 @@ func declarations() map[string]map[string]provider { } func main() { - if err := json.NewEncoder(os.Stdout).Encode(declarations()); err != nil { + projection := struct { + Harnesses map[string]map[string]provider `json:"harnesses"` + Protocols []modelprovider.Protocol `json:"protocols"` + }{declarations(), modelprovider.Protocols()} + if err := json.NewEncoder(os.Stdout).Encode(projection); err != nil { panic(err) } } diff --git a/services/core/IMPLEMENTATION.md b/services/core/IMPLEMENTATION.md index e80ea729c..e609040ff 100644 --- a/services/core/IMPLEMENTATION.md +++ b/services/core/IMPLEMENTATION.md @@ -138,7 +138,7 @@ Provider input validation uses the adapter rules in `internal/harnessconfig`: on - At dispatch, Core rechecks the tenant, attached Vault, selected ID, frozen auth type and exact URL before scoped decryption, and the token enters only the transient daemon request. A missing key or binding failure never falls back to anonymous execution. - `credentialcrypto` ciphertext is a format version byte followed by the standard AEAD nonce, ciphertext and tag. The authenticated data holds a fixed domain and version plus the binding (tenant, Vault, Credential, auth type, exact destination). Keep the domain string unchanged: existing rows must still decrypt. -- Random-nonce GCM allows at most 2^32 encryptions per key. `secrets/credential.key` also seals model providers, the E2B key, Skills, initial files and environment setup, so every sealed write counts toward that bound; there is no rotation or re-encryption path. +- Random-nonce GCM allows at most 2^32 encryptions per key. `secrets/core/credential.key` also seals model providers, the E2B key, Skills, initial files and environment setup, so every sealed write counts toward that bound; there is no rotation or re-encryption path. - OAuth dispatch refresh holds the Credential row lock and the external exchange under one 20-second context (`vaults.oauthRefreshTimeout`). The refresh HTTP client has a 10-second overall timeout and 5-second TLS handshake and response-header timeouts, uses no proxy and treats any redirect as failure. ## MCP @@ -177,7 +177,7 @@ A streaming Session creation reuses atomic input admission and the live event lo Public Items read a projection updated in the same Session transaction as admitted messages and journal batches. Item IDs derive from the Turn and source identity, and the first-observation timestamp and tie breakers never change when content or status does. Each new Item's Session position is allocated under the Session lock, preserving observation order for equal timestamps, and each Turn allocates its own zero-based `output_index`, which inputs do not consume; updates and retries keep both. -Item merging never mutates the incoming observation or the previous snapshot: public text delta events read the original fragment after merging, while the Item keeps the accumulated text, and the content slice is copied before its text pointer is replaced. A first observation without its own fragment carries its unchanged text in one delta. Wire-only explicit nulls come from response marshalling, while stored Item payloads keep their original encoding through `Item.MarshalStored`, so replayed child Items compare equal. Structured tool JSON is kept without float conversion, and an unfinished call never becomes a successful result. When a Turn ends, `sessions.EndTurn` makes its unfinished Items incomplete with their partial content and reports them in Session position order before the Turn's event and its settled Session activity; `sessionpg` gives them one shared settlement time. Function results are Session input Items: they emit `item.added` with a null `output_index` and never `item.done`, whose upstream union allows only agent output, and their public output and error come from the saved submission. +Item merging never mutates the incoming observation or the previous snapshot: public text delta events read the original fragment after merging, while the Item keeps the accumulated text, and the content slice is copied before its text pointer is replaced. A first observation without its own fragment carries its unchanged text in one delta. Stored Item payloads use the wire encoding, explicit nulls included, so a replayed child Item compares equal to its stored payload. Structured tool JSON is kept without float conversion, and an unfinished call never becomes a successful result. When a Turn ends, `sessions.EndTurn` makes its unfinished Items incomplete with their partial content and reports them in Session position order before the Turn's event and its settled Session activity; `sessionpg` gives them one shared settlement time. Function results are Session input Items: they emit `item.added` with a null `output_index` and never `item.done`, whose upstream union allows only agent output, and their public output and error come from the saved submission. ## Worker ownership diff --git a/services/core/README.md b/services/core/README.md index 19563a3c2..3ca7b19ec 100644 --- a/services/core/README.md +++ b/services/core/README.md @@ -10,7 +10,7 @@ | `cmd/device` | `oac-core-device` | Provisions or revokes an [operator device profile](../../contracts/agents-api/machine-api.md#operator-device-profile) for `environment: none` engine hosts | | `cmd/environment-key` | `oac-core-environment-key` | The [break-glass executor credential command](../../contracts/agents-api/environment-executor-credentials.md#break-glass-command) | | `cmd/sandbox-node` | `oac-node` | The sandbox node program; see the [nodes guide](../../docs/getting-started/nodes.md) | -| `cmd/specification-contract` | None | Regenerates the installer's node specification projection | +| `cmd/specification-contract` | None | Regenerates the deployment contract projections of the node installer and the TypeScript client | `make build-core` builds the four executables into `~/.oac/build/oac-core`; [Standalone Core builds](../../docs/maintainers.md#standalone-core-builds) describes the build and its options. `make build-daemon` builds `oac-daemon`. diff --git a/services/core/cmd/oac/main.go b/services/core/cmd/oac/main.go index f2b3393d3..69e15bad7 100644 --- a/services/core/cmd/oac/main.go +++ b/services/core/cmd/oac/main.go @@ -25,7 +25,7 @@ func main() { os.Exit(2) } if os.Args[1] == "init" { - log.Init(log.ConfigFromEnv()) + log.Init(log.Config{}) } ctx, stop := signal.NotifyContext(context.Background(), syscall.SIGINT, syscall.SIGTERM) defer stop() @@ -80,9 +80,6 @@ func run(ctx context.Context, command string, args []string) error { } func installDir() (string, error) { - if dir := os.Getenv("OAC_INSTALL_DIR"); dir != "" { - return dir, nil - } exe, err := os.Executable() if err != nil { return "", err diff --git a/services/core/cmd/server/core_metrics.go b/services/core/cmd/server/core_metrics.go index 84d4c59a4..52f81462a 100644 --- a/services/core/cmd/server/core_metrics.go +++ b/services/core/cmd/server/core_metrics.go @@ -4,6 +4,7 @@ import ( "context" "time" + "github.com/MiniMax-AI/OpenAgentCore/internal/obs/log" "github.com/MiniMax-AI/OpenAgentCore/services/core/internal/coremetrics" "github.com/MiniMax-AI/OpenAgentCore/services/core/internal/execution" "github.com/MiniMax-AI/OpenAgentCore/services/core/internal/persistence/postgres/coremetricspg" @@ -74,14 +75,19 @@ func (s *coreMetricsSource) History(ctx context.Context, start, end time.Time, s return s.store.ReadExecutionHistory(ctx, start, end, step) } -func reportCleanupResult(metrics *coremetrics.Service, job string, count int64, err error) { - if metrics == nil { - return - } - processed, failed := &count, int64(0) - if err != nil { - processed = nil - failed = 1 - } - metrics.ReportJob(job, time.Now(), processed, &failed, err) +// prune makes a retention pass, bounded by timeout, a job that runs every +// minute. A failed pass counts no rows and one failure. +func prune(id string, timeout time.Duration, run func(context.Context) (int64, error)) coremetrics.Periodic { + return coremetrics.Periodic{ID: id, Every: time.Minute, Run: func(ctx context.Context) (*int64, int64, error) { + pass, cancel := context.WithTimeout(ctx, timeout) + count, err := run(pass) + cancel() + if err != nil { + if ctx.Err() == nil { + log.Ctx(ctx).Warn("Retention cleanup failed", "job", id) + } + return nil, 1, err + } + return &count, 0, nil + }} } diff --git a/services/core/cmd/server/credential_cipher.go b/services/core/cmd/server/credential_cipher.go deleted file mode 100644 index 60a6754c7..000000000 --- a/services/core/cmd/server/credential_cipher.go +++ /dev/null @@ -1,26 +0,0 @@ -package main - -import ( - "encoding/base64" - "errors" - "os" - "strings" - - "github.com/MiniMax-AI/OpenAgentCore/services/core/internal/credentialcrypto" -) - -func credentialCipher() (*credentialcrypto.Cipher, error) { - path := os.Getenv("OAC_CREDENTIAL_KEY_FILE") - if path == "" { - return nil, nil - } - content, err := os.ReadFile(path) - if err != nil { - return nil, errors.New("cannot read OAC_CREDENTIAL_KEY_FILE") - } - key, err := base64.StdEncoding.Strict().DecodeString(strings.TrimSpace(string(content))) - if err != nil || len(key) != 32 { - return nil, errors.New("OAC_CREDENTIAL_KEY_FILE must contain a base64-encoded random 32-byte key") - } - return credentialcrypto.New(key) -} diff --git a/services/core/cmd/server/credential_cipher_test.go b/services/core/cmd/server/credential_cipher_test.go deleted file mode 100644 index 801d04c91..000000000 --- a/services/core/cmd/server/credential_cipher_test.go +++ /dev/null @@ -1,53 +0,0 @@ -package main - -import ( - "bytes" - "encoding/base64" - "os" - "path/filepath" - "strings" - "testing" - - "github.com/MiniMax-AI/OpenAgentCore/services/core/internal/credentialcrypto" -) - -func TestCredentialCipherConfiguration(t *testing.T) { - t.Setenv("OAC_CREDENTIAL_KEY_FILE", "") - if c, err := credentialCipher(); c != nil || err != nil { - t.Fatal("absent dedicated key must remain disabled", err) - } - path := filepath.Join(t.TempDir(), "credential.key") - t.Setenv("OAC_CREDENTIAL_KEY_FILE", path) - if _, err := credentialCipher(); err == nil { - t.Fatal("missing configured file accepted") - } - for _, content := range []string{"", "private-invalid-key", base64.StdEncoding.EncodeToString(make([]byte, 31))} { - if err := os.WriteFile(path, []byte(content), 0600); err != nil { - t.Fatal(err) - } - if _, err := credentialCipher(); err == nil || strings.Contains(err.Error(), "private-invalid-key") { - t.Fatal("invalid configuration accepted or leaked") - } - } - key := bytes.Repeat([]byte{0x91}, 32) - if err := os.WriteFile(path, []byte(base64.StdEncoding.EncodeToString(key)+"\n"), 0600); err != nil { - t.Fatal(err) - } - first, err := credentialCipher() - if err != nil { - t.Fatal(err) - } - binding := credentialcrypto.Binding{TenantID: "tenant", VaultID: "vault", CredentialID: "credential", AuthType: "static_bearer", Destination: "https://example.invalid/mcp"} - sealed, err := first.Seal([]byte("opaque storage test"), binding) - if err != nil { - t.Fatal(err) - } - reopened, err := credentialCipher() - if err != nil { - t.Fatal(err) - } - got, err := reopened.Open(sealed, binding) - if err != nil || string(got) != "opaque storage test" { - t.Fatal("persisted key did not recover ciphertext", err) - } -} diff --git a/services/core/cmd/server/daemon_bootstrap.go b/services/core/cmd/server/daemon_bootstrap.go deleted file mode 100644 index d4c0644ea..000000000 --- a/services/core/cmd/server/daemon_bootstrap.go +++ /dev/null @@ -1,25 +0,0 @@ -package main - -import ( - "errors" - "net/url" -) - -// runtimeWebSocketURL derives the daemon WebSocket URL from a Core origin or -// its /api/v1 base. -func runtimeWebSocketURL(coreURL string) (string, error) { - u, err := url.Parse(coreURL) - if err != nil || u.Hostname() == "" || u.User != nil || u.RawQuery != "" || u.Fragment != "" { - return "", errors.New("managed Runtime Core address is unavailable") - } - switch u.Scheme { - case "https": - u.Scheme = "wss" - case "http": - u.Scheme = "ws" - default: - return "", errors.New("managed Runtime Core address is unavailable") - } - u.Path = "/api/v1/agent-daemon/ws" - return u.String(), nil -} diff --git a/services/core/cmd/server/installation.go b/services/core/cmd/server/installation.go index d7bd089c4..11097db7f 100644 --- a/services/core/cmd/server/installation.go +++ b/services/core/cmd/server/installation.go @@ -12,27 +12,18 @@ var sourceCommit = regexp.MustCompile(`^[0-9a-f]{40}$`) // installationFacts reports what GET /core/v1/installation serves: Core's own // environment and build, plus the process settings it loaded. -func installationFacts(publicURL string) (api.Installation, error) { - var facts api.Installation - id, err := processconfig.InstallationID() - if err != nil { - return facts, err +func installationFacts(config processconfig.Config) api.Installation { + facts := api.Installation{Configuration: api.InstallationConfiguration{Settings: config.Settings()}} + if config.InstallationID != "" { + facts.InstallationID = &config.InstallationID } - if id != "" { - facts.InstallationID = &id - } - if publicURL != "" { - base := publicURL + "/v1" - facts.PublicURL, facts.APIBaseURL, facts.LocalOnly = &publicURL, &base, placement.LoopbackOrigin(publicURL) + if origin := config.PublicOrigin; origin != nil { + public, base := origin.String(), origin.API() + facts.PublicURL, facts.APIBaseURL, facts.LocalOnly = &public, &base, placement.LoopbackOrigin(public) } if sourceCommit.MatchString(buildRevision) { revision := buildRevision facts.SourceCommit = &revision } - settings, err := processconfig.Settings() - if err != nil { - return facts, err - } - facts.Configuration = &api.InstallationConfiguration{Settings: settings} - return facts, nil + return facts } diff --git a/services/core/cmd/server/main.go b/services/core/cmd/server/main.go index f734986f2..a297e4e58 100644 --- a/services/core/cmd/server/main.go +++ b/services/core/cmd/server/main.go @@ -35,7 +35,6 @@ import ( "github.com/MiniMax-AI/OpenAgentCore/services/core/internal/agents" "github.com/MiniMax-AI/OpenAgentCore/services/core/internal/api" "github.com/MiniMax-AI/OpenAgentCore/services/core/internal/coremetrics" - "github.com/MiniMax-AI/OpenAgentCore/services/core/internal/databaseurl" "github.com/MiniMax-AI/OpenAgentCore/services/core/internal/deployment" "github.com/MiniMax-AI/OpenAgentCore/services/core/internal/deployment/placement" "github.com/MiniMax-AI/OpenAgentCore/services/core/internal/environmenttemplates" @@ -43,6 +42,7 @@ import ( "github.com/MiniMax-AI/OpenAgentCore/services/core/internal/files" "github.com/MiniMax-AI/OpenAgentCore/services/core/internal/modelconfiguration" "github.com/MiniMax-AI/OpenAgentCore/services/core/internal/nativeinstaller" + "github.com/MiniMax-AI/OpenAgentCore/services/core/internal/oauthrefresh" "github.com/MiniMax-AI/OpenAgentCore/services/core/internal/persistence/postgres/agentpg" "github.com/MiniMax-AI/OpenAgentCore/services/core/internal/persistence/postgres/auditpg" "github.com/MiniMax-AI/OpenAgentCore/services/core/internal/persistence/postgres/coremetricspg" @@ -71,53 +71,39 @@ import ( ) func main() { + config, err := processconfig.Load() if len(os.Args) > 1 && os.Args[1] == "check-config" { - if err := processconfig.Check(); err != nil { + if err != nil { fmt.Fprintln(os.Stderr, err.Error()) os.Exit(1) } return } - if err := run(); err != nil { + if err == nil { + err = run(config) + } + if err != nil { log.Bg().Error("oac-core startup failed", "error", err) os.Exit(1) } } -func run() error { - if err := processconfig.Check(); err != nil { - return err - } - log.Init(log.ConfigFromEnv()) - public, err := processconfig.PublicURL() - if err != nil { - return err - } - concurrency, err := processconfig.ExecutionConcurrency() - if err != nil { - return err - } - logConfigurationSources() - databaseURL, err := databaseurl.FromEnvironment() - if err != nil { - return err - } - if databaseURL == "" { - return errors.New("OAC_DATABASE_URL is required") - } - credentialKey, err := credentialCipher() - if err != nil { - return err +func run(config processconfig.Config) error { + log.Init(config.Log) + log.Bg().Info("Core process configuration loaded from the process environment") + if file := config.RuntimeHistory.File; file != "" { + log.Bg().Info("Core auxiliary configuration", "setting", "OAC_HISTORY_SETTINGS_FILE", "path", file) } + credentialKey := config.CredentialKey ctx, stop := signal.NotifyContext(context.Background(), syscall.SIGINT, syscall.SIGTERM) defer stop() migrating, cancelMigration := context.WithTimeout(ctx, 2*time.Minute) - err = migrations.Apply(migrating, databaseURL) + err := migrations.Apply(migrating, config.DatabaseURL) cancelMigration() if err != nil { return fmt.Errorf("Agents API database migration failed: %w", err) } - pool, err := pgxpool.New(ctx, databaseURL) + pool, err := pgxpool.New(ctx, config.DatabaseURL) if err != nil { return errors.New("invalid Agents API database configuration") } @@ -127,15 +113,7 @@ func run() error { if err := pool.Ping(ready); err != nil { return errors.New("Agents API database connection failed") } - engine, err := processconfig.DefaultHarness() - if err != nil { - return err - } - kinds, err := processconfig.Harnesses(engine) - if err != nil { - return err - } - oauthClient, err := oauthRefreshClient() + oauthClient, err := oauthrefresh.NewClient(config.OAuthTrustedOrigins) if err != nil { return err } @@ -172,6 +150,10 @@ func run() error { return err } sandboxProviders := providers.Builtin() + var public string + if config.PublicOrigin != nil { + public = config.PublicOrigin.String() + } // The placement rules are built once: the provider declarations and the // public URL never change while Core runs. placementRules, err := placement.NewRules(sandboxProviders, public) @@ -188,34 +170,14 @@ func run() error { if err != nil { return err } - installation, err := installationFacts(public) - if err != nil { - return err - } - metricsSource := &coreMetricsSource{store: coremetricspg.New(units), pool: pool} - metrics := coremetrics.New(processStartedAt, buildRevision, metricsSource) - auditRetention, err := writeAuditRetention() - if err != nil { - return err - } - auditCleanupCtx, cancelAuditCleanup := context.WithCancel(ctx) - auditCleanupDone := make(chan struct{}) - go func() { - defer close(auditCleanupDone) - runWriteAuditCleanup(auditCleanupCtx, auditStore, auditRetention, metrics) - }() - defer func() { cancelAuditCleanup(); <-auditCleanupDone }() var workerDone chan error var worker *execution.Worker - managedNodes, err := configureManagedNodes(deploymentService, deploymentStore, sandboxProviders, public, func(ctx context.Context) error { + managedNodes := configureManagedNodes(deploymentService, deploymentStore, sandboxProviders, config, func(ctx context.Context) error { if worker == nil { return errors.New("sandbox execution owner is unavailable") } return worker.CheckOwnership(ctx) }) - if err != nil { - return err - } defer managedNodes.close() var managed *execution.RuntimeProvider observationSources := map[string]runtimeobs.SourceResolver{} @@ -227,7 +189,7 @@ func run() error { if err != nil { return err } - history, err := runtimeHistory(ctx, units, public != "") + history, err := runtimeHistory(ctx, units, config.RuntimeHistory, config.PublicOrigin != nil) if err != nil { return err } @@ -248,23 +210,7 @@ func run() error { closeRuntimeHistory(closeCtx, history.Exporter) } }() - cleanupCtx, cancelCleanup := context.WithCancel(ctx) - cleanupDone := make(chan struct{}) - go func() { - defer close(cleanupDone) - runHistoryCleanup(cleanupCtx, history.Prune, metrics) - }() - defer func() { cancelCleanup(); <-cleanupDone }() - var keyAdmin *api.DeploymentAuthenticator - if managedNodes != nil { - keyAdmin = managedNodes.admin - } else { - keyAdmin, err = deploymentAdminAuthenticator() - if err != nil { - return err - } - } - if err := api.ValidateCredentialSeparation(ctx, keyAdmin, projectStore); err != nil { + if err := api.ValidateCredentialSeparation(ctx, config.CoreKeys, projectStore); err != nil { return err } historyService, err := runtimehistory.NewService(sessionStore, history.Reader) @@ -275,25 +221,22 @@ func run() error { var registry *runtimegateway.Registry var executorURL string var nativeInstaller *api.NativeInstaller - if public != "" { - executorURL, err = runtimeWebSocketURL(public) - if err != nil { - return err - } + if origin := config.PublicOrigin; origin != nil { + executorURL = origin.DaemonWebSocket() daemonHandler, registry, err = runtime.NewGateway(sessionStore, sessionService, sessionStore, executorURL) if err != nil { return err } defer runtime.CloseConnections(registry) var catalog *nativeinstaller.Catalog - if directory := os.Getenv("OAC_NATIVE_INSTALLER_DIR"); directory != "" { - catalog, err = nativeinstaller.Load(directory, buildRevision) + if config.NativeInstallers != "" { + catalog, err = nativeinstaller.Load(config.NativeInstallers, buildRevision) if err != nil { return err } } if buildRevision != "" { - nativeInstaller = &api.NativeInstaller{Version: buildRevision, Catalog: catalog} + nativeInstaller = &api.NativeInstaller{Version: buildRevision, Base: origin.InstallerBase(), Catalog: catalog} } } var deploymentExecution *deployment.ExecutionOperations @@ -302,7 +245,7 @@ func run() error { Credentials: vaultService, Observer: modelConfigurationStore, Deployment: deploymentService, DeploymentReader: deploymentStore, Sessions: sessionService, SessionsReader: sessionStore, - ManagedRuntimes: managed, MaxConcurrentExecutions: concurrency} + ManagedRuntimes: managed, MaxConcurrentExecutions: config.ExecutionConcurrency} lease, err := pgunit.AcquireLease(ctx, pool) if err != nil { return err @@ -333,46 +276,40 @@ func run() error { } }() } - if history.SampleInterval == 0 { - metrics.StopJob("runtime_sampler") - } - if history.SampleInterval > 0 { - if worker == nil { - return errors.New("Runtime history periodic sampling requires the execution worker") - } - sampler, err := runtimeobs.NewSampler(observationResolver, observationService, worker, runtimeobs.SamplerOptions{ - Interval: history.SampleInterval, - Report: func(result runtimeobs.SweepResult) { - sampleCtx, cancel := context.WithTimeout(ctx, 2*time.Second) - sampleErr := worker.CheckOwnership(sampleCtx) - if sampleErr == nil { - _, sampleErr = deploymentStore.SampleHostHistory(sampleCtx) - } - cancel() - if !result.Complete { - sampleErr = errors.New("incomplete Runtime sampling sweep") - } - metrics.ReportJob("runtime_sampler", result.CompletedAt, metricPtr(int64(result.Observed)), metricPtr(int64(result.Failed)), sampleErr) - fields := []any{"listed", result.Listed, "observed", result.Observed, "failed", result.Failed, "complete", result.Complete} - if result.Complete { - log.Bg().Debug("Runtime history sampling sweep complete", fields...) - } else { - log.Bg().Warn("Runtime history sampling sweep incomplete", fields...) - } - }, - }) + // Sampling runs only with the Worker, which owns every sweep. + sampling := coremetrics.Periodic{ID: "runtime_sampler", Every: history.SampleInterval} + if worker != nil { + sampler, err := runtimeobs.NewSampler(observationResolver, observationService, worker, runtimeobs.SamplerOptions{}) if err != nil { return err } - samplerCtx, cancelSampler := context.WithCancel(ctx) - samplerDone := make(chan error, 1) - go func() { defer metrics.StopJob("runtime_sampler"); samplerDone <- sampler.Run(samplerCtx) }() - defer func() { - cancelSampler() - <-samplerDone - }() + sampling.Run = func(ctx context.Context) (*int64, int64, error) { + result := sampler.Sweep(ctx) + sampleCtx, cancel := context.WithTimeout(ctx, 2*time.Second) + err := worker.CheckOwnership(sampleCtx) + if err == nil { + _, err = deploymentStore.SampleHostHistory(sampleCtx) + } + cancel() + fields := []any{"listed", result.Listed, "observed", result.Observed, "failed", result.Failed, "complete", result.Complete} + if !result.Complete { + log.Bg().Warn("Runtime history sampling sweep incomplete", fields...) + err = errors.New("incomplete Runtime sampling sweep") + } else { + log.Bg().Debug("Runtime history sampling sweep complete", fields...) + } + return metricPtr(int64(result.Observed)), int64(result.Failed), err + } + } + metricsSource := &coreMetricsSource{store: coremetricspg.New(units), pool: pool, worker: worker, registry: registry} + metrics, err := coremetrics.New(processStartedAt, buildRevision, metricsSource, sampling, + prune("history_cleanup", 2*time.Second, history.Prune), + prune("audit_cleanup", 5*time.Second, func(ctx context.Context) (int64, error) { + return auditStore.DeleteExpiredWriteOperations(ctx, time.Now().Add(-config.WriteAuditRetention), 1000) + })) + if err != nil { + return err } - metricsSource.worker, metricsSource.registry = worker, registry metricsCtx, cancelMetrics := context.WithCancel(ctx) metricsDone := make(chan struct{}) go func() { defer close(metricsDone); metrics.Run(metricsCtx) }() @@ -383,8 +320,8 @@ func run() error { return err } deps := api.Dependencies{ - Engine: engine, Harnesses: kinds, CoreKeys: keyAdmin, - Installation: installation, InstallationBindings: deploymentService, + Engine: config.DefaultHarness, Harnesses: config.Harnesses, CoreKeys: config.CoreKeys, + Installation: installationFacts(config), InstallationBindings: deploymentService, Projects: projectService, ProjectsReader: projectStore, ModelProviders: modelConfigurationService, ModelProvidersReader: modelConfigurationStore, Vaults: vaultService, VaultsReader: vaultStore, @@ -438,8 +375,7 @@ func run() error { } handler = serverHandler(handler, &routes) } - addr := serverAddress() - server := &http.Server{Addr: addr, Handler: handler, ReadHeaderTimeout: 10 * time.Second, ReadTimeout: 30 * time.Second, WriteTimeout: 30 * time.Second, IdleTimeout: 60 * time.Second} + server := &http.Server{Addr: config.Addr, Handler: handler, ReadHeaderTimeout: 10 * time.Second, ReadTimeout: 30 * time.Second, WriteTimeout: 30 * time.Second, IdleTimeout: 60 * time.Second} done := make(chan error, 1) go func() { done <- server.ListenAndServe() }() select { diff --git a/services/core/cmd/server/managed_nodes.go b/services/core/cmd/server/managed_nodes.go index 8e9add39e..a6d8f434b 100644 --- a/services/core/cmd/server/managed_nodes.go +++ b/services/core/cmd/server/managed_nodes.go @@ -2,11 +2,8 @@ package main import ( "context" - "encoding/json" "errors" - "os" - "github.com/MiniMax-AI/OpenAgentCore/services/core/internal/api" "github.com/MiniMax-AI/OpenAgentCore/services/core/internal/deployment" "github.com/MiniMax-AI/OpenAgentCore/services/core/internal/execution" "github.com/MiniMax-AI/OpenAgentCore/services/core/internal/persistence/postgres/pgunit" @@ -17,40 +14,21 @@ import ( ) type managedNodes struct { - setup *managedSetup - runtime *execution.RuntimeProvider - hub *node.Hub - admin *api.DeploymentAuthenticator - closeProvider func() + setup *managedSetup + runtime *execution.RuntimeProvider + hub *node.Hub } // configureManagedNodes serves the nodes of the Web-managed deployment. Node // presence and health and the generation of each allocation go through the // deployment service; the owner epoch that fences connections and the -// allocations each generation retains are read from the deployment reader. -func configureManagedNodes(nodes *deployment.Service, reader deployment.Reader, registry *providers.Registry, publicURL string, owner func(context.Context) error) (*managedNodes, error) { - setupID, err := processconfig.InstallationID() - if err != nil || setupID == "" { - return nil, err - } - if publicURL == "" { - return nil, errors.New("OAC_INSTALLATION_ID_FILE requires OAC_PUBLIC_URL, the origin nodes and sandboxes use to reach Core") - } - closeProvider := func() {} - result := &managedNodes{closeProvider: closeProvider} - success := false - defer func() { - if !success { - closeProvider() - } - }() - result.admin, err = deploymentAdminAuthenticator() - if err != nil { - return nil, err - } - if result.admin == nil { - return nil, errors.New("Web sandbox setup requires OAC_CORE_KEY_DIGESTS_FILE with the Core key digest") +// allocations each generation retains are read from the deployment reader. It +// returns nil without an installation ID. +func configureManagedNodes(nodes *deployment.Service, reader deployment.Reader, registry *providers.Registry, config processconfig.Config, owner func(context.Context) error) *managedNodes { + if config.InstallationID == "" { + return nil } + result := &managedNodes{} result.hub = node.NewHub(node.HubOptions{ Generations: func(ctx context.Context, n node.Identity, connection string, epoch uint64, health node.Health) error { if err := owner(ctx); err != nil { @@ -96,41 +74,17 @@ func configureManagedNodes(nodes *deployment.Service, reader deployment.Reader, return nodes.Heartbeat(ctx, n.NodeID, connection, epoch, nodeHealthRecord(health)) }, }) - result.setup = &managedSetup{processPaths: providerProcessPaths(), registry: registry, deployment: nodes, allocations: reader, hub: result.hub, installationID: setupID, publicURL: publicURL} - result.runtime = execution.NewDeferredRuntimeProvider(setupID, result.setup.load, result.setup.prepare) + // Load requires OAC_PUBLIC_URL with an installation ID. + result.setup = &managedSetup{processPaths: config.ProviderPaths, registry: registry, deployment: nodes, allocations: reader, hub: result.hub, installationID: config.InstallationID, runtimeAPI: config.PublicOrigin.RuntimeAPI()} + result.runtime = execution.NewDeferredRuntimeProvider(config.InstallationID, result.setup.load, result.setup.prepare) result.runtime.PublishUnconfigured = result.setup.publishUnconfigured - success = true - return result, nil + return result } func (m *managedNodes) close() { if m != nil { m.hub.Close() - m.closeProvider() - } -} - -func deploymentAdminAuthenticator() (*api.DeploymentAuthenticator, error) { - path := os.Getenv("OAC_CORE_KEY_DIGESTS_FILE") - if path == "" { - return nil, nil - } - raw, err := os.ReadFile(path) - if err != nil { - return nil, errors.New("cannot read OAC_CORE_KEY_DIGESTS_FILE") - } - var digests []string - if json.Unmarshal(raw, &digests) != nil || len(digests) == 0 { - return nil, errors.New("OAC_CORE_KEY_DIGESTS_FILE must contain a JSON array of Core key SHA-256 digests") - } - return api.NewDeploymentAuthenticator(digests) -} - -func serverAddress() string { - if value := os.Getenv("OAC_ADDR"); value != "" { - return value } - return "127.0.0.1:8091" } func nodeHealthRecord(health node.Health) deployment.NodeHealth { diff --git a/services/core/cmd/server/managed_setup.go b/services/core/cmd/server/managed_setup.go index dfce8cdc6..0ca908728 100644 --- a/services/core/cmd/server/managed_setup.go +++ b/services/core/cmd/server/managed_setup.go @@ -29,8 +29,9 @@ type managedSetup struct { allocations generationAllocations hub *node.Hub installationID string - // publicURL is OAC_PUBLIC_URL; every sandbox reaches Core through it. - publicURL string + // runtimeAPI is the /api/v1 base of OAC_PUBLIC_URL; every sandbox reaches + // Core through it. + runtimeAPI string selected atomic.Pointer[managedSelection] providerCalls sandbox.CallFence } @@ -145,7 +146,7 @@ func (s *managedSetup) configuration(setup deployment.Setup) (execution.Prepared return execution.PreparedRuntimeDeployment{}, fmt.Errorf("%w: %v", execution.ErrExecutionUnavailable, err) } selected := &execution.RuntimeProvider{InstallationID: setup.InstallationID, ProviderKind: setup.Provider, Generation: setup.Generation, Mode: setup.Mode, AdmissionPaused: setup.AdmissionPaused, - CoreURL: s.publicURL + "/api/v1", BackendFingerprint: setup.BackendFingerprint, Provider: provider} + CoreURL: s.runtimeAPI, BackendFingerprint: setup.BackendFingerprint, Provider: provider} if setup.Suspension != nil { selected.Suspension = &execution.RuntimeSuspensionPolicy{IdleTimeout: time.Duration(setup.Suspension.IdleSeconds) * time.Second, Retention: time.Duration(setup.Suspension.RetentionSeconds) * time.Second, MaxActive: 4, MaxRetained: 16} diff --git a/services/core/cmd/server/managed_setup_test.go b/services/core/cmd/server/managed_setup_test.go index efc656985..d6d2ff663 100644 --- a/services/core/cmd/server/managed_setup_test.go +++ b/services/core/cmd/server/managed_setup_test.go @@ -2,17 +2,15 @@ package main import ( "context" - "crypto/sha256" - "encoding/hex" "errors" "os" "path/filepath" - "strings" "testing" "github.com/MiniMax-AI/OpenAgentCore/services/core/internal/deployment" "github.com/MiniMax-AI/OpenAgentCore/services/core/internal/deployment/placement" "github.com/MiniMax-AI/OpenAgentCore/services/core/internal/persistence/postgres/deploymentpg" + "github.com/MiniMax-AI/OpenAgentCore/services/core/internal/processconfig" "github.com/MiniMax-AI/OpenAgentCore/services/core/internal/providercontract" "github.com/MiniMax-AI/OpenAgentCore/services/core/internal/runtimeobs" "github.com/MiniMax-AI/OpenAgentCore/services/core/internal/sandbox/docker" @@ -27,32 +25,18 @@ import ( ) func TestWebSetupCreatesManagerWithoutLocalProvider(t *testing.T) { - idFile := filepath.Join(t.TempDir(), "installation.id") - if err := os.WriteFile(idFile, []byte(uuid.NewString()+"\n"), 0o600); err != nil { - t.Fatal(err) - } - t.Setenv("OAC_INSTALLATION_ID_FILE", idFile) - digest := sha256.Sum256([]byte("synthetic-admin")) - path := filepath.Join(t.TempDir(), "core-key-digests.json") - if err := os.WriteFile(path, []byte(`["`+hex.EncodeToString(digest[:])+`"]`), 0600); err != nil { - t.Fatal(err) + if configureManagedNodes(nil, nil, providers.Builtin(), processconfig.Config{}, nil) != nil { + t.Fatal("sandbox manager started without an installation ID") } - t.Setenv("OAC_CORE_KEY_DIGESTS_FILE", path) - if _, err := configureManagedNodes(nil, nil, providers.Builtin(), "", nil); err == nil || !strings.Contains(err.Error(), "OAC_PUBLIC_URL") { - t.Fatal("sandbox manager started without a public URL", err) - } - m, err := configureManagedNodes(nil, nil, providers.Builtin(), "https://core.example", func(context.Context) error { return nil }) + origin, err := deployment.NewPublicOrigin("https://core.example") if err != nil { t.Fatal(err) } + m := configureManagedNodes(nil, nil, providers.Builtin(), processconfig.Config{InstallationID: uuid.NewString(), PublicOrigin: &origin}, func(context.Context) error { return nil }) defer m.close() - if m.setup == nil || m.admin == nil || m.hub == nil || m.runtime == nil || m.runtime.Provider != nil { + if m.setup == nil || m.hub == nil || m.runtime == nil || m.runtime.Provider != nil || m.setup.runtimeAPI != "https://core.example/api/v1" { t.Fatal("zero-node setup unexpectedly instantiated local compute or omitted management") } - t.Setenv("OAC_CORE_KEY_DIGESTS_FILE", "") - if _, err := configureManagedNodes(nil, nil, providers.Builtin(), "https://core.example", nil); err == nil { - t.Fatal("setup accepted without admin authentication") - } } // fakeDeploymentSetups is a strict deploymentSetups: a call without a set @@ -182,7 +166,7 @@ func TestManagedSetupPreparesWithoutPublishing(t *testing.T) { id := uuid.NewString() hub := node.NewHub(node.HubOptions{}) defer hub.Close() - s := &managedSetup{registry: providers.Builtin(), installationID: id, hub: hub, deployment: &fakeDeploymentSetups{t: t}, allocations: &fakeGenerationAllocations{t: t}, publicURL: "https://core.example"} + s := &managedSetup{registry: providers.Builtin(), installationID: id, hub: hub, deployment: &fakeDeploymentSetups{t: t}, allocations: &fakeGenerationAllocations{t: t}, runtimeAPI: "https://core.example/api/v1"} previous := &execution.RuntimeProvider{InstallationID: id, Generation: 1, ProviderKind: "docker"} s.publish(previous) candidate, err := s.prepare(t.Context(), deployment.Setup{InstallationID: id, Provider: "microsandbox", Mode: "nodes", Operations: microsandbox.Operations(), Suspension: &deployment.Suspension{IdleSeconds: 300, RetentionSeconds: 86400}}) diff --git a/services/core/cmd/server/oauth_refresh.go b/services/core/cmd/server/oauth_refresh.go deleted file mode 100644 index 8871bacd6..000000000 --- a/services/core/cmd/server/oauth_refresh.go +++ /dev/null @@ -1,18 +0,0 @@ -package main - -import ( - "os" - "strings" - - "github.com/MiniMax-AI/OpenAgentCore/services/core/internal/oauthrefresh" -) - -func oauthRefreshClient() (*oauthrefresh.Client, error) { - var origins []string - if raw := os.Getenv("OAC_OAUTH_TRUSTED_ORIGINS"); raw != "" { - for _, origin := range strings.Split(raw, ",") { - origins = append(origins, strings.TrimSpace(origin)) - } - } - return oauthrefresh.NewClient(origins) -} diff --git a/services/core/cmd/server/oauth_refresh_test.go b/services/core/cmd/server/oauth_refresh_test.go deleted file mode 100644 index da8aaedf5..000000000 --- a/services/core/cmd/server/oauth_refresh_test.go +++ /dev/null @@ -1,18 +0,0 @@ -package main - -import "testing" - -func TestOAuthRefreshOperatorPolicy(t *testing.T) { - for _, raw := range []string{"", "https://issuer.example", "https://issuer.example, https://10.0.0.1:9443"} { - t.Setenv("OAC_OAUTH_TRUSTED_ORIGINS", raw) - if _, err := oauthRefreshClient(); err != nil { - t.Fatal(err) - } - } - for _, raw := range []string{"http://issuer.example", "https://issuer.example/token", "https://issuer.example,", "https://user:secret@issuer.example"} { - t.Setenv("OAC_OAUTH_TRUSTED_ORIGINS", raw) - if _, err := oauthRefreshClient(); err == nil { - t.Fatal("invalid issuer policy accepted") - } - } -} diff --git a/services/core/cmd/server/process_configuration.go b/services/core/cmd/server/process_configuration.go deleted file mode 100644 index 476973ac5..000000000 --- a/services/core/cmd/server/process_configuration.go +++ /dev/null @@ -1,23 +0,0 @@ -package main - -import ( - "os" - - "github.com/MiniMax-AI/OpenAgentCore/internal/obs/log" - "github.com/MiniMax-AI/OpenAgentCore/services/core/internal/sandbox" -) - -// The launcher loads core.env. Core reports its sources without parsing another -// configuration layer or logging environment values. -func logConfigurationSources() { - log.Bg().Info("Core process configuration loaded from the process environment") - for _, key := range []string{"OAC_HISTORY_SETTINGS_FILE"} { - if path := os.Getenv(key); path != "" { - log.Bg().Info("Core auxiliary configuration", "setting", key, "path", path) - } - } -} - -func providerProcessPaths() sandbox.ProcessPaths { - return sandbox.ProcessPaths{ArtifactRoot: os.Getenv("OAC_PROVIDER_ROOT"), StateRoot: os.Getenv("OAC_PROVIDER_STATE_ROOT")} -} diff --git a/services/core/cmd/server/process_configuration_test.go b/services/core/cmd/server/process_configuration_test.go deleted file mode 100644 index eef02edc8..000000000 --- a/services/core/cmd/server/process_configuration_test.go +++ /dev/null @@ -1,50 +0,0 @@ -package main - -import ( - "os" - "strings" - "testing" - - "github.com/MiniMax-AI/OpenAgentCore/services/core/internal/processconfig" -) - -func TestExecutionConcurrencyConfiguration(t *testing.T) { - t.Setenv("OAC_EXECUTION_CONCURRENCY", "unused") - if err := os.Unsetenv("OAC_EXECUTION_CONCURRENCY"); err != nil { - t.Fatal(err) - } - if got, err := processconfig.ExecutionConcurrency(); err != nil || got != 4 { - t.Fatal(got, err) - } - t.Setenv("OAC_EXECUTION_CONCURRENCY", "") - if got, err := processconfig.ExecutionConcurrency(); err != nil || got != 4 { - t.Fatal("empty concurrency did not keep the default", got, err) - } - for _, value := range []string{"1", "7", "1024"} { - t.Setenv("OAC_EXECUTION_CONCURRENCY", value) - if got, err := processconfig.ExecutionConcurrency(); err != nil || got < 1 { - t.Fatal(value, got, err) - } - } - for _, value := range []string{"0", "-1", "1025", "1.5", "secret-value"} { - t.Setenv("OAC_EXECUTION_CONCURRENCY", value) - if _, err := processconfig.ExecutionConcurrency(); err == nil || strings.Contains(err.Error(), "secret-value") { - t.Fatal("invalid concurrency accepted or echoed", err) - } - } -} - -func TestPublicURLMustBeACanonicalOrigin(t *testing.T) { - for _, value := range []string{"https://core.example", "https://core.example:8443", "http://127.0.0.1:8091", "http://core.example"} { - t.Setenv("OAC_PUBLIC_URL", value) - if got, err := processconfig.PublicURL(); err != nil || got != value { - t.Fatal(value, got, err) - } - } - for _, value := range []string{"https://core.example/", "https://Core.example", "wss://core.example", "https://core.example/v1"} { - t.Setenv("OAC_PUBLIC_URL", value) - if _, err := processconfig.PublicURL(); err == nil { - t.Fatal("accepted", value) - } - } -} diff --git a/services/core/cmd/server/runtime_history.go b/services/core/cmd/server/runtime_history.go index 11213fa2e..7d333c097 100644 --- a/services/core/cmd/server/runtime_history.go +++ b/services/core/cmd/server/runtime_history.go @@ -1,44 +1,17 @@ package main import ( - "bytes" "context" - "encoding/json" - "errors" - "io" - "net/url" - "os" - "strings" "time" - "github.com/MiniMax-AI/OpenAgentCore/services/core/internal/coremetrics" "github.com/MiniMax-AI/OpenAgentCore/services/core/internal/persistence/postgres/pgunit" "github.com/MiniMax-AI/OpenAgentCore/services/core/internal/persistence/postgres/runtimehistorypg" + "github.com/MiniMax-AI/OpenAgentCore/services/core/internal/processconfig" "github.com/MiniMax-AI/OpenAgentCore/services/core/internal/runtimehistory" "github.com/MiniMax-AI/OpenAgentCore/services/core/internal/runtimeobs" "github.com/MiniMax-AI/OpenAgentCore/services/core/internal/runtimeobs/otlpexporter" - "golang.org/x/net/http/httpguts" ) -const ( - defaultRuntimeHistoryQueueCapacity = 256 - defaultRuntimeHistoryTimeoutSeconds = 2 - maxRuntimeHistoryQueueCapacity = 4096 - maxRuntimeHistoryTimeoutSeconds = 30 - minRuntimeHistorySampleIntervalSeconds = 5 - maxRuntimeHistorySampleIntervalSeconds = 300 -) - -type runtimeHistoryConfig struct { - Transport string `json:"transport,omitempty"` - Endpoint string `json:"endpoint,omitempty"` - Insecure bool `json:"insecure,omitempty"` - Headers map[string]string `json:"headers,omitempty"` - QueueCapacity int `json:"queue_capacity,omitempty"` - TimeoutSeconds int `json:"timeout_seconds,omitempty"` - SampleIntervalSeconds int `json:"sample_interval_seconds,omitempty"` -} - type runtimeHistorySetup struct { Options []runtimeobs.ServiceOption Exporter runtimeHistoryExporter @@ -52,12 +25,8 @@ type runtimeHistoryExporter interface { Close(context.Context) error } -func runtimeHistory(ctx context.Context, units *pgunit.Pool, executionEnabled bool) (runtimeHistorySetup, error) { - config, err := loadRuntimeHistoryConfig() - if err != nil { - return runtimeHistorySetup{}, err - } - interval := time.Duration(config.SampleIntervalSeconds) * time.Second +func runtimeHistory(ctx context.Context, units *pgunit.Pool, config processconfig.RuntimeHistory, executionEnabled bool) (runtimeHistorySetup, error) { + interval := config.SampleInterval mode := runtimehistory.CollectionPeriodic if !executionEnabled { interval = 0 @@ -69,15 +38,14 @@ func runtimeHistory(ctx context.Context, units *pgunit.Pool, executionEnabled bo MaximumPoints: 1000, MaximumSeries: 64, MaximumTotalPoints: 10000, Metrics: []runtimehistory.Metric{runtimehistory.MetricCPU, runtimehistory.MetricMemory, runtimehistory.MetricTokens}, } - timeout := time.Duration(config.TimeoutSeconds) * time.Second - backend, err := runtimehistorypg.New(units, runtimehistorypg.Config{Capabilities: capabilities, QueryTimeout: timeout}) + backend, err := runtimehistorypg.New(units, runtimehistorypg.Config{Capabilities: capabilities, QueryTimeout: config.Timeout}) if err != nil { return runtimeHistorySetup{}, err } - options := runtimeobs.ExportOptions{QueueCapacity: config.QueueCapacity, Timeout: timeout} + options := runtimeobs.ExportOptions{QueueCapacity: config.QueueCapacity, Timeout: config.Timeout} setup := runtimeHistorySetup{Options: []runtimeobs.ServiceOption{runtimeobs.WithExporter(backend, options)}, Reader: backend, SampleInterval: interval, Prune: backend.Prune} if config.Endpoint != "" { - exporter, err := otlpexporter.New(ctx, otlpexporter.Config{Endpoint: config.Endpoint, Headers: config.Headers, Insecure: config.Insecure, RequestTimeout: timeout}) + exporter, err := otlpexporter.New(ctx, otlpexporter.Config{Endpoint: config.Endpoint, Headers: config.Headers, Insecure: config.Insecure, RequestTimeout: config.Timeout}) if err != nil { return runtimeHistorySetup{}, err } @@ -88,99 +56,7 @@ func runtimeHistory(ctx context.Context, units *pgunit.Pool, executionEnabled bo return setup, nil } -func loadRuntimeHistoryConfig() (runtimeHistoryConfig, error) { - var config runtimeHistoryConfig - if file := os.Getenv("OAC_HISTORY_SETTINGS_FILE"); file != "" { - raw, err := os.ReadFile(file) - if err != nil { - return config, errors.New("cannot read OAC_HISTORY_SETTINGS_FILE") - } - decoder := json.NewDecoder(bytes.NewReader(raw)) - decoder.DisallowUnknownFields() - if decoder.Decode(&config) != nil || decoder.Decode(new(any)) != io.EOF { - return config, errors.New("invalid Runtime history configuration") - } - } - if err := validateRuntimeHistoryConfig(config); err != nil { - return config, err - } - if config.QueueCapacity == 0 { - config.QueueCapacity = defaultRuntimeHistoryQueueCapacity - } - if config.TimeoutSeconds == 0 { - config.TimeoutSeconds = defaultRuntimeHistoryTimeoutSeconds - } - if config.SampleIntervalSeconds == 0 { - config.SampleIntervalSeconds = 30 - } - return config, nil -} - func closeRuntimeHistory(ctx context.Context, exporter runtimeHistoryExporter) { defer func() { _ = recover() }() _ = exporter.Close(ctx) } - -// Retention also runs without active Runtimes. Each bounded pass has its own deadline. -func runHistoryCleanup(ctx context.Context, prune func(context.Context) (int64, error), metrics *coremetrics.Service) { - if metrics != nil { - defer metrics.StopJob("history_cleanup") - } - ticker := time.NewTicker(time.Minute) - defer ticker.Stop() - for { - pruneCtx, cancel := context.WithTimeout(ctx, 2*time.Second) - count, err := prune(pruneCtx) - cancel() - reportCleanupResult(metrics, "history_cleanup", count, err) - select { - case <-ctx.Done(): - return - case <-ticker.C: - } - } -} - -func validateRuntimeHistoryConfig(config runtimeHistoryConfig) error { - if config.QueueCapacity < 0 || config.QueueCapacity > maxRuntimeHistoryQueueCapacity { - return errors.New("Runtime history queue_capacity is out of range") - } - if config.TimeoutSeconds < 0 || config.TimeoutSeconds > maxRuntimeHistoryTimeoutSeconds { - return errors.New("Runtime history timeout_seconds is out of range") - } - if config.SampleIntervalSeconds != 0 && (config.SampleIntervalSeconds < minRuntimeHistorySampleIntervalSeconds || config.SampleIntervalSeconds > maxRuntimeHistorySampleIntervalSeconds) { - return errors.New("Runtime history sample_interval_seconds is out of range") - } - if config.Endpoint == "" { - if config.Transport != "" || config.Insecure || len(config.Headers) != 0 { - return errors.New("Runtime history export options require an endpoint") - } - return nil - } - if config.Transport != "otlp_http" { - return errors.New("Runtime history transport must be otlp_http") - } - endpoint, err := url.Parse(config.Endpoint) - if err != nil || endpoint.Host == "" || endpoint.User != nil || endpoint.RawQuery != "" || endpoint.Fragment != "" || endpoint.RawPath != "" || endpoint.Path == "" || endpoint.String() != config.Endpoint { - return errors.New("Runtime history endpoint must be a canonical absolute OTLP metrics URL") - } - switch endpoint.Scheme { - case "https": - if config.Insecure { - return errors.New("Runtime history insecure transport requires an http endpoint") - } - case "http": - if !config.Insecure { - return errors.New("Runtime history http endpoint requires insecure=true") - } - default: - return errors.New("Runtime history endpoint scheme must be https or explicit insecure http") - } - for key, value := range config.Headers { - lower := strings.ToLower(key) - if !httpguts.ValidHeaderFieldName(key) || !httpguts.ValidHeaderFieldValue(value) || lower == "host" || lower == "content-length" || lower == "content-type" || lower == "content-encoding" { - return errors.New("Runtime history headers contain an invalid or reserved entry") - } - } - return nil -} diff --git a/services/core/cmd/server/runtime_history_test.go b/services/core/cmd/server/runtime_history_test.go index c640f2c92..17c748cb5 100644 --- a/services/core/cmd/server/runtime_history_test.go +++ b/services/core/cmd/server/runtime_history_test.go @@ -2,13 +2,12 @@ package main import ( "context" - "github.com/MiniMax-AI/OpenAgentCore/services/core/internal/persistence/postgres/pgunit" - "github.com/MiniMax-AI/OpenAgentCore/services/core/internal/runtimeobs" - "os" - "path/filepath" - "strings" "testing" "time" + + "github.com/MiniMax-AI/OpenAgentCore/services/core/internal/persistence/postgres/pgunit" + "github.com/MiniMax-AI/OpenAgentCore/services/core/internal/processconfig" + "github.com/MiniMax-AI/OpenAgentCore/services/core/internal/runtimeobs" ) type panicHistoryExporter struct{} @@ -16,10 +15,11 @@ type panicHistoryExporter struct{} func (panicHistoryExporter) Export(context.Context, runtimeobs.ExportRecord) error { return nil } func (panicHistoryExporter) Close(context.Context) error { panic("close") } +var defaultHistory = processconfig.RuntimeHistory{QueueCapacity: 256, Timeout: 2 * time.Second, SampleInterval: 30 * time.Second} + func TestRuntimeHistoryUsesCoreDatabaseByDefault(t *testing.T) { - t.Setenv("OAC_HISTORY_SETTINGS_FILE", "") for _, enabled := range []bool{true, false} { - setup, err := runtimeHistory(t.Context(), pgunit.NewPool(nil), enabled) + setup, err := runtimeHistory(t.Context(), pgunit.NewPool(nil), defaultHistory, enabled) if err != nil { t.Fatal(err) } @@ -40,12 +40,9 @@ func TestRuntimeHistoryUsesCoreDatabaseByDefault(t *testing.T) { } func TestRuntimeHistoryOptionalExportAndSamplingConfiguration(t *testing.T) { - file := filepath.Join(t.TempDir(), "history.json") - if err := os.WriteFile(file, []byte(`{"transport":"otlp_http","endpoint":"https://collector.example.test/v1/metrics","headers":{"Authorization":"Bearer private"},"sample_interval_seconds":60}`), 0600); err != nil { - t.Fatal(err) - } - t.Setenv("OAC_HISTORY_SETTINGS_FILE", file) - setup, err := runtimeHistory(t.Context(), pgunit.NewPool(nil), true) + config := defaultHistory + config.Endpoint, config.Headers, config.SampleInterval = "https://collector.example.test/v1/metrics", map[string]string{"Authorization": "Bearer private"}, time.Minute + setup, err := runtimeHistory(t.Context(), pgunit.NewPool(nil), config, true) if err != nil { t.Fatal(err) } @@ -55,51 +52,6 @@ func TestRuntimeHistoryOptionalExportAndSamplingConfiguration(t *testing.T) { } } -func TestRuntimeHistoryConfigFailsClosedWithoutLeakingSecrets(t *testing.T) { - tests := []struct { - name string - config string - }{ - {name: "unknown field", config: `{"transport":"otlp_http","endpoint":"https://collector.example.test/v1/metrics","secret":"must-not-leak"}`}, - {name: "implicit insecure", config: `{"transport":"otlp_http","endpoint":"http://collector.example.test/v1/metrics"}`}, - {name: "userinfo", config: `{"transport":"otlp_http","endpoint":"https://user:must-not-leak@collector.example.test/v1/metrics"}`}, - {name: "header newline", config: "{\"transport\":\"otlp_http\",\"endpoint\":\"https://collector.example.test/v1/metrics\",\"headers\":{\"Authorization\":\"Bearer must-not-leak\\n\"}}"}, - {name: "reserved header", config: `{"transport":"otlp_http","endpoint":"https://collector.example.test/v1/metrics","headers":{"Host":"must-not-leak"}}`}, - {name: "oversized queue", config: `{"transport":"otlp_http","endpoint":"https://collector.example.test/v1/metrics","queue_capacity":4097}`}, - {name: "oversized timeout", config: `{"transport":"otlp_http","endpoint":"https://collector.example.test/v1/metrics","timeout_seconds":31}`}, - {name: "too frequent sampling", config: `{"transport":"otlp_http","endpoint":"https://collector.example.test/v1/metrics","sample_interval_seconds":4}`}, - {name: "oversized sampling interval", config: `{"transport":"otlp_http","endpoint":"https://collector.example.test/v1/metrics","sample_interval_seconds":301}`}, - {name: "removed backend", config: `{"clickhouse":{"password":"must-not-leak"}}`}, - {name: "transport without endpoint", config: `{"transport":"otlp_http"}`}, - } - for _, test := range tests { - t.Run(test.name, func(t *testing.T) { - file := filepath.Join(t.TempDir(), "runtime-history.json") - if err := os.WriteFile(file, []byte(test.config), 0600); err != nil { - t.Fatal(err) - } - t.Setenv("OAC_HISTORY_SETTINGS_FILE", file) - _, err := loadRuntimeHistoryConfig() - if err == nil { - t.Fatal("unsafe history configuration accepted") - } - if strings.Contains(err.Error(), "must-not-leak") { - t.Fatalf("history error leaked config content: %v", err) - } - }) - } -} - -func TestRuntimeHistoryAllowsExplicitLocalHTTPCollector(t *testing.T) { - config := runtimeHistoryConfig{ - Transport: "otlp_http", Endpoint: "http://127.0.0.1:4318/v1/metrics", Insecure: true, - Headers: map[string]string{"X-Scope-OrgID": "operator-history"}, - } - if err := validateRuntimeHistoryConfig(config); err != nil { - t.Fatal(err) - } -} - func TestRuntimeHistoryClosePanicIsIsolated(t *testing.T) { closeRuntimeHistory(t.Context(), panicHistoryExporter{}) } diff --git a/services/core/cmd/server/write_audit.go b/services/core/cmd/server/write_audit.go deleted file mode 100644 index 481533521..000000000 --- a/services/core/cmd/server/write_audit.go +++ /dev/null @@ -1,49 +0,0 @@ -package main - -import ( - "context" - "errors" - "os" - "time" - - "github.com/MiniMax-AI/OpenAgentCore/internal/obs/log" - "github.com/MiniMax-AI/OpenAgentCore/services/core/internal/coremetrics" -) - -type writeAuditPruner interface { - DeleteExpiredWriteOperations(context.Context, time.Time, int) (int64, error) -} - -func writeAuditRetention() (time.Duration, error) { - value := os.Getenv("OAC_WRITE_AUDIT_RETENTION") - if value == "" { - return 90 * 24 * time.Hour, nil - } - duration, err := time.ParseDuration(value) - if err != nil || duration < time.Hour { - return 0, errors.New("OAC_WRITE_AUDIT_RETENTION must be a duration of at least 1h") - } - return duration, nil -} - -func runWriteAuditCleanup(ctx context.Context, s writeAuditPruner, retention time.Duration, metrics *coremetrics.Service) { - if metrics != nil { - defer metrics.StopJob("audit_cleanup") - } - ticker := time.NewTicker(time.Minute) - defer ticker.Stop() - for { - pruneCtx, cancel := context.WithTimeout(ctx, 5*time.Second) - count, err := s.DeleteExpiredWriteOperations(pruneCtx, time.Now().Add(-retention), 1000) - cancel() - reportCleanupResult(metrics, "audit_cleanup", count, err) - if err != nil && ctx.Err() == nil { - log.Ctx(ctx).Warn("Write audit retention cleanup failed") - } - select { - case <-ctx.Done(): - return - case <-ticker.C: - } - } -} diff --git a/services/core/cmd/server/write_audit_test.go b/services/core/cmd/server/write_audit_test.go deleted file mode 100644 index 3891684c1..000000000 --- a/services/core/cmd/server/write_audit_test.go +++ /dev/null @@ -1,51 +0,0 @@ -package main - -import ( - "context" - "errors" - "testing" - "time" -) - -func TestWriteAuditRetention(t *testing.T) { - for _, test := range []struct { - value string - want time.Duration - bad bool - }{{"", 90 * 24 * time.Hour, false}, {"24h", 24 * time.Hour, false}, {"0", 0, true}, {"30m", 0, true}, {"-1h", 0, true}, {"90d", 0, true}} { - t.Run(test.value, func(t *testing.T) { - t.Setenv("OAC_WRITE_AUDIT_RETENTION", test.value) - got, err := writeAuditRetention() - if (err != nil) != test.bad || (!test.bad && got != test.want) { - t.Fatalf("%v %v", got, err) - } - }) - } -} - -type auditPruneProbe struct { - cancel context.CancelFunc - called bool - cutoff time.Time - limit int - deadline bool -} - -func (p *auditPruneProbe) DeleteExpiredWriteOperations(ctx context.Context, cutoff time.Time, limit int) (int64, error) { - p.called = true - p.cutoff = cutoff - p.limit = limit - _, p.deadline = ctx.Deadline() - p.cancel() - return 0, errors.New("test") -} -func TestWriteAuditCleanupBoundedAndCancellable(t *testing.T) { - ctx, cancel := context.WithCancel(context.Background()) - defer cancel() - probe := &auditPruneProbe{cancel: cancel} - before := time.Now().Add(-24 * time.Hour) - runWriteAuditCleanup(ctx, probe, 24*time.Hour, nil) - if !probe.called || probe.limit != 1000 || !probe.deadline || probe.cutoff.Before(before) || probe.cutoff.After(time.Now().Add(-24*time.Hour)) { - t.Fatalf("bad cleanup %+v", probe) - } -} diff --git a/services/core/cmd/specification-contract/main.go b/services/core/cmd/specification-contract/main.go index 308224655..c79213dda 100644 --- a/services/core/cmd/specification-contract/main.go +++ b/services/core/cmd/specification-contract/main.go @@ -1,24 +1,28 @@ -// specification-contract maintains the generated node installer projection. +// specification-contract maintains the generated deployment contract +// projections of the node installer and the TypeScript client. package main import ( "flag" "fmt" + "github.com/MiniMax-AI/OpenAgentCore/services/core/internal/sandbox" "github.com/MiniMax-AI/OpenAgentCore/services/core/internal/sandbox/providers" "os" "strings" ) func main() { - write := flag.Bool("write", false, "update deploy/node/node_spec.py from the repository root") + write := flag.Bool("write", false, "update deploy/node/node_spec.py and packages/agents-client/src/deployment-contract.ts from the repository root") flag.Parse() projection, err := providers.Builtin().PythonDeploymentContract() if err != nil { fmt.Fprintln(os.Stderr, err) os.Exit(1) } + typescript := sandbox.TypeScriptDeploymentContract() if !*write { fmt.Println(projection) + fmt.Print(typescript) return } path := "deploy/node/node_spec.py" @@ -36,4 +40,7 @@ func main() { if err = os.WriteFile(path, []byte(source[:start]+projection+source[end:]), 0644); err != nil { panic(err) } + if err = os.WriteFile("packages/agents-client/src/deployment-contract.ts", []byte(typescript), 0644); err != nil { + panic(err) + } } diff --git a/services/core/deploy/claude/Dockerfile b/services/core/deploy/claude/Dockerfile index 08ffc4eee..115878e19 100644 --- a/services/core/deploy/claude/Dockerfile +++ b/services/core/deploy/claude/Dockerfile @@ -10,7 +10,6 @@ COPY claude-sdk /opt/claude-sdk ENV HOME=/home/runtime OAC_RUNTIME_HOME=/home/runtime/.oac \ OAC_RUNTIME_CLAUDE_SDK_NODE=/usr/local/bin/node \ OAC_RUNTIME_CLAUDE_SDK_ENTRYPOINT=/opt/claude-sdk/dist/main.js \ - OAC_RUNTIME_CLAUDE_SDK_WORKSPACE=managed \ OAC_RUNTIME_WORKSPACE=/environment/workspace \ OAC_RUNTIME_INITIALIZATION_DIRECTORY=/environment/initialization \ OAC_RUNTIME_PACKAGE_DIRECTORY=/environment/packages 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/api/contract_routes_test.go b/services/core/internal/api/contract_routes_test.go index 9d4fadb95..6b589bceb 100644 --- a/services/core/internal/api/contract_routes_test.go +++ b/services/core/internal/api/contract_routes_test.go @@ -69,7 +69,7 @@ func TestContractsPublishExactlyTheRegisteredCoreAndMachineRoutes(t *testing.T) // Every optional group that gates a route registration, as the server enables them. deps, fakes := testDependencies(t) deps.Execution, deps.Sandboxes = fakes.execution(), fakes.sandboxes() - deps.Execution.NativeInstaller = &NativeInstaller{Version: "contract-test", Catalog: &nativeinstaller.Catalog{}} + deps.Execution.NativeInstaller = &NativeInstaller{Version: "contract-test", Base: "https://core.example/api/v1/agent-daemon/install/", Catalog: &nativeinstaller.Catalog{}} h := &Handler{Dependencies: deps} contracts := map[string]string{"/v1": "openapi.yaml", "/core/v1": "core.openapi.yaml", "/api/v1": "runtime.openapi.yaml"} published := map[string]map[string]bool{} diff --git a/services/core/internal/api/core_error_catalog_test.go b/services/core/internal/api/core_error_catalog_test.go new file mode 100644 index 000000000..2d81518ab --- /dev/null +++ b/services/core/internal/api/core_error_catalog_test.go @@ -0,0 +1,142 @@ +package api + +import ( + "encoding/json" + "go/ast" + "go/parser" + "go/token" + "io/fs" + "maps" + "os" + "path/filepath" + "regexp" + "slices" + "strconv" + "strings" + "testing" +) + +// Codes the error writers produce that never reach an administration caller. +// Everything else they produce is in the catalog or in the wire vocabulary of +// wire-semantics.md, which keeps its /v1 meaning on every route. +var nonAdministrationCodes = []string{ + // Session creation and input admission on /v1. + "environment_input_cancelled", "environment_input_expired", "model_provider_required", "sandbox_nodes_preparing", "turn_conflict", + // Machine routes for nodes and native installers. + "installation_authorization_invalid", "installation_unavailable", "invalid_node_credential", "sandbox_node_address_mismatch", + // Removal of the file-managed local node, which the process deployment path owns. + "runtime_local_node_configured", +} + +// The shared catalog lists every code an administration caller (/core/v1 or +// the console's /core paths) can receive outside the wire vocabulary. +// core-errors.md documents and Web localizes exactly these codes. +func TestCoreErrorCatalog(t *testing.T) { + raw, err := os.ReadFile("testdata/core-errors.json") + if err != nil { + t.Fatal(err) + } + var catalog []string + if err := json.Unmarshal(raw, &catalog); err != nil { + t.Fatal(err) + } + // One pass collects every string literal, which covers codes carried by + // typed errors, and the literal codes passed to the error writers. + literals, written := map[string]bool{}, map[string]bool{} + files := token.NewFileSet() + for _, root := range []string{"../../../../services", "../../../../contracts"} { + err := filepath.WalkDir(root, func(path string, entry fs.DirEntry, err error) error { + switch { + case err != nil: + return err + case entry.IsDir() && (entry.Name() == "node_modules" || entry.Name() == "testdata"): + return filepath.SkipDir + case entry.IsDir() || !strings.HasSuffix(path, ".go") || strings.HasSuffix(path, "_test.go"): + return nil + } + file, err := parser.ParseFile(files, path, nil, parser.SkipObjectResolution) + if err != nil { + return err + } + ast.Inspect(file, func(node ast.Node) bool { + switch node := node.(type) { + case *ast.BasicLit: + if value, err := strconv.Unquote(node.Value); node.Kind == token.STRING && err == nil { + literals[value] = true + } + case *ast.CallExpr: + if name, ok := node.Fun.(*ast.Ident); ok && len(node.Args) > 2 && slices.Contains([]string{"writeError", "writeAPIError", "writeCoreError", "consoleCoreError"}, name.Name) { + if code, ok := node.Args[2].(*ast.BasicLit); ok { + value, _ := strconv.Unquote(code.Value) + written[value] = value != "" + } + } + } + return true + }) + return nil + }) + if err != nil { + t.Fatal(err) + } + } + for _, code := range slices.Concat(catalog, nonAdministrationCodes) { + if !literals[code] { + t.Errorf("code %q has no Go producer", code) + } + } + wire := backtickedCodes(t, "../../../../contracts/agents-api/wire-semantics.md") + for code, produced := range written { + if produced && !slices.Contains(catalog, code) && !slices.Contains(nonAdministrationCodes, code) && !wire[code] { + t.Errorf("produced code %q is missing from the catalog; list it in nonAdministrationCodes only if no administration caller can receive it", code) + } + } + documented := documentedCoreErrorCodes(t, "../../../../contracts/agents-api/core-errors.md") + slices.Sort(catalog) + if !slices.Equal(documented, catalog) { + t.Errorf("core-errors.md codes = %v, want shared catalog %v", documented, catalog) + } +} + +var backtickedCode = regexp.MustCompile("`([a-z_]+)`") + +func backtickedCodes(t *testing.T, path string) map[string]bool { + raw, err := os.ReadFile(path) + if err != nil { + t.Fatal(err) + } + codes := map[string]bool{} + for _, match := range backtickedCode.FindAllStringSubmatch(string(raw), -1) { + codes[match[1]] = true + } + return codes +} + +// documentedCoreErrorCodes reads the Code column of every error table before +// the diagnostics catalog, which is a separate vocabulary. +func documentedCoreErrorCodes(t *testing.T, path string) []string { + raw, err := os.ReadFile(path) + if err != nil { + t.Fatal(err) + } + text, _, _ := strings.Cut(string(raw), "## Diagnostic failure categories") + codes := map[string]bool{} + column := -1 + for _, line := range strings.Split(text, "\n") { + if !strings.HasPrefix(line, "|") { + column = -1 + continue + } + cells := strings.Split(strings.Trim(line, "|"), "|") + if column < 0 { + column = slices.IndexFunc(cells, func(cell string) bool { return strings.TrimSpace(cell) == "Code" }) + continue + } + if column < len(cells) { + for _, match := range backtickedCode.FindAllStringSubmatch(cells[column], -1) { + codes[match[1]] = true + } + } + } + return slices.Sorted(maps.Keys(codes)) +} diff --git a/services/core/internal/api/dependencies.go b/services/core/internal/api/dependencies.go index f4e2b408d..5dcb4f981 100644 --- a/services/core/internal/api/dependencies.go +++ b/services/core/internal/api/dependencies.go @@ -151,8 +151,8 @@ func (d Dependencies) validate() error { if e.ExecutorURL == "" { return errors.New("api: Execution.ExecutorURL is required") } - if e.NativeInstaller != nil && e.NativeInstaller.Version == "" { - return errors.New("api: Execution.NativeInstaller.Version is required") + if e.NativeInstaller != nil && (e.NativeInstaller.Version == "" || e.NativeInstaller.Base == "") { + return errors.New("api: Execution.NativeInstaller.Version and Base are required") } if err := required( field{"Execution.SessionAdmission", e.SessionAdmission}, diff --git a/services/core/internal/api/dependencies_test.go b/services/core/internal/api/dependencies_test.go index c7a5bf73b..a044d4988 100644 --- a/services/core/internal/api/dependencies_test.go +++ b/services/core/internal/api/dependencies_test.go @@ -179,7 +179,7 @@ func TestNewHandlerAcceptsCompleteDependencies(t *testing.T) { t.Fatal(err) } deps.Execution = f.execution() - deps.Execution.NativeInstaller = &NativeInstaller{Version: "build"} + deps.Execution.NativeInstaller = &NativeInstaller{Version: "build", Base: "https://core.example/api/v1/agent-daemon/install/"} deps.Sandboxes = f.sandboxes() if _, err := NewHandler(deps); err != nil { t.Fatal(err) diff --git a/services/core/internal/api/environment_installation.go b/services/core/internal/api/environment_installation.go index 8f60a15c9..caa666ef7 100644 --- a/services/core/internal/api/environment_installation.go +++ b/services/core/internal/api/environment_installation.go @@ -17,6 +17,8 @@ import ( type NativeInstaller struct { // Version is the build revision executors install and claim. Version string + // Base is the public URL prefix of the versioned installer downloads. + Base string // Catalog holds the matching installation artifacts. It is nil when the // operator installed none: installations then report unavailable and the // grant routes answer 503 installation_unavailable. @@ -46,9 +48,7 @@ func (h *Handler) installationFor(ctx context.Context, principal identity.Princi if err != nil { return nil, err } - origin := strings.TrimSuffix(h.Execution.ExecutorURL, "/api/v1/agent-daemon/ws") - origin = strings.Replace(strings.Replace(origin, "wss://", "https://", 1), "ws://", "http://", 1) - return &v1.EnvironmentInstallation{Status: "available", Version: installer.Version, ExpiresAt: expires, Commands: installer.Catalog.Commands(origin, token)}, nil + return &v1.EnvironmentInstallation{Status: "available", Version: installer.Version, ExpiresAt: expires, Commands: installer.Catalog.Commands(installer.Base, token)}, nil } func (h *Handler) addSessionInstallation(w http.ResponseWriter, r *http.Request, response *v1.Session) error { diff --git a/services/core/internal/api/environment_installation_test.go b/services/core/internal/api/environment_installation_test.go index 828342045..4cf35ef2d 100644 --- a/services/core/internal/api/environment_installation_test.go +++ b/services/core/internal/api/environment_installation_test.go @@ -40,7 +40,7 @@ func TestSelfHostedCreationReturnsInstallationWithoutWebCredential(t *testing.T) fakes.modelProviders.resolve = fixtureDeploymentProvider fakes.environments.authorizeEnvironmentInstallation, fakes.environments.validateEnvironmentInstallation = f.AuthorizeEnvironmentInstallation, f.ValidateEnvironmentInstallation deps.Execution = fakes.execution() - deps.Execution.NativeInstaller = &NativeInstaller{Version: "build", Catalog: &nativeinstaller.Catalog{Version: "build"}} + deps.Execution.NativeInstaller = &NativeInstaller{Version: "build", Base: "https://core.example/api/v1/agent-daemon/install/", Catalog: &nativeinstaller.Catalog{Version: "build"}} handler := newTestHandler(t, deps) body := `{"agent":{"model":"model"},"environment":{"type":"self_hosted","workspace_directory":"/workspace"},"x_agents_core":{"model_provider":{"protocol":"responses","base_url":"https://model.example/v1","api_key":"fixture-model"}}}` r := httptest.NewRequest(http.MethodPost, "/v1/agents/sessions", strings.NewReader(body)) diff --git a/services/core/internal/api/errors_sessions.go b/services/core/internal/api/errors_sessions.go index 8806156ee..64ed5b977 100644 --- a/services/core/internal/api/errors_sessions.go +++ b/services/core/internal/api/errors_sessions.go @@ -38,8 +38,6 @@ func writeSessionsError(w http.ResponseWriter, r *http.Request, err error) { writeError(w, http.StatusUnauthorized, "installation_authorization_invalid", sessions.ErrInstallationAuthorization.Error()) case errors.Is(err, sessions.ErrExecutorCredentialExists): writeError(w, http.StatusConflict, "executor_credential_exists", "This executor key ID already exists. Explicitly rotate it to replace the secret.") - case errors.Is(err, execution.ErrModelProviderRequired): - writeError(w, http.StatusBadRequest, "model_provider_required", "This Session was created without a model provider and cannot run. Create a new Session with x_agents_core.model_provider or an Agent that has one saved.") case errors.Is(err, sessions.ErrHostedEnvironmentFailed): // Observed official status, type, code, null param and message. writeError(w, http.StatusConflict, "conflict_error", "the hosted environment failed to provision") diff --git a/services/core/internal/api/installation.go b/services/core/internal/api/installation.go index 229da8835..ec1ba9f55 100644 --- a/services/core/internal/api/installation.go +++ b/services/core/internal/api/installation.go @@ -3,7 +3,6 @@ package api import ( "context" "net/http" - "time" "github.com/MiniMax-AI/OpenAgentCore/services/core/internal/deployment" ) @@ -22,22 +21,14 @@ type Installation struct { LocalOnly bool `json:"local_only"` // Full source commit Core was built from; null for development builds. SourceCommit *string `json:"source_commit" extensions:"x-nullable"` - // The process settings Core loaded. path and apply_command are empty, and applied_at is null, because Core reports its environment rather than an installer file. - Configuration *InstallationConfiguration `json:"configuration" extensions:"x-nullable"` + // The process settings Core loaded. + Configuration InstallationConfiguration `json:"configuration"` AddressBindings deployment.AddressBindings `json:"address_bindings"` } -// InstallationConfiguration is the process settings Core loaded. Path and -// ApplyCommand are empty, and AppliedAt is null, unless a caller built a -// snapshot itself. +// InstallationConfiguration is the process settings Core loaded. type InstallationConfiguration struct { - // Absolute host path of config.json. Empty when Core reports its own environment. - Path string `json:"path"` - // Command that applies config.json changes. Empty when Core reports its own environment. - ApplyCommand string `json:"apply_command"` - // Null when Core reports its own environment. - AppliedAt *time.Time `json:"applied_at" extensions:"x-nullable"` - Settings []InstallationSetting `json:"settings"` + Settings []InstallationSetting `json:"settings"` } type InstallationSetting struct { diff --git a/services/core/internal/api/installation_test.go b/services/core/internal/api/installation_test.go index 721703bf8..c8c047e89 100644 --- a/services/core/internal/api/installation_test.go +++ b/services/core/internal/api/installation_test.go @@ -19,7 +19,7 @@ func TestInstallationReadNeedsOnlyTheCoreKey(t *testing.T) { fakes.projectsReader.resolveAPIKey = projectKeys(t, callerBinding()).ResolveAPIKey deps.CoreKeys = coreKeys(t, "administrator") public, id := "https://core.example", "5b7c0f3e-0000-4000-8000-000000000001" - settings := &InstallationConfiguration{Path: "/home/alice/.oac/core/config.json", Settings: []InstallationSetting{{Key: "ports.core", Value: 8091, Default: 8091, Changeable: true, Restarts: []string{"core"}}}} + settings := InstallationConfiguration{Settings: []InstallationSetting{{Key: "ports.core", Value: 8091, Default: 8091, Changeable: true, Restarts: []string{"core"}}}} fakes.installationBindings.addressBindings = func(context.Context) (deployment.AddressBindings, error) { return deployment.AddressBindings{Nodes: 2, NodesOnOtherAddress: 1}, nil } @@ -40,7 +40,7 @@ func TestInstallationReadNeedsOnlyTheCoreKey(t *testing.T) { var body map[string]any if result.Code != http.StatusOK || json.Unmarshal(result.Body.Bytes(), &body) != nil || body["object"] != "core.installation" || body["public_url"] != public || body["address_bindings"].(map[string]any)["nodes_on_other_address"] != float64(1) || - body["configuration"].(map[string]any)["path"] != "/home/alice/.oac/core/config.json" { + body["configuration"].(map[string]any)["settings"].([]any)[0].(map[string]any)["key"] != "ports.core" { t.Fatal(result.Code, result.Body.String()) } } diff --git a/services/core/internal/api/native_classification_integration_test.go b/services/core/internal/api/native_classification_integration_test.go index 47a621d6a..0a13c9e78 100644 --- a/services/core/internal/api/native_classification_integration_test.go +++ b/services/core/internal/api/native_classification_integration_test.go @@ -26,7 +26,7 @@ func TestNativeClassificationPostgresRoundTripAndPublicPrivacy(t *testing.T) { t.Fatal(err) } session := created.Session - receipt := submitMessage(t, pool, tenant, session.ID, "input", json.RawMessage(`{"text":"test"}`)) + receipt := submitMessage(t, pool, tenant, session.ID, "input", "test") transitionTurn(t, pool, tenant, session.ID, receipt.TurnID, sessions.TurnTransition{ExpectedStatus: sessions.TurnQueued, Status: sessions.TurnInProgress}) status := 503 result := execution.Result{ErrorCode: "engine_failed", Error: "Bearer secret-canary https://private.example/key", EngineErrorCode: code, EngineHTTPStatus: &status, Done: proto.DonePayload{Usage: proto.Usage{InputTokens: 7, OutputTokens: 3}, Metadata: map[string]any{proto.DoneMetaAgentSessionID: "native-secret-canary"}}} diff --git a/services/core/internal/api/project_api_key_configuration.go b/services/core/internal/api/project_api_key_configuration.go index 19b88da7c..1467610ec 100644 --- a/services/core/internal/api/project_api_key_configuration.go +++ b/services/core/internal/api/project_api_key_configuration.go @@ -14,9 +14,6 @@ type projectKeyDigests interface { // ValidateCredentialSeparation rejects Core key collisions with persisted API keys. func ValidateCredentialSeparation(ctx context.Context, admin *DeploymentAuthenticator, keys projectKeyDigests) error { - if admin == nil { - return errors.New("OAC_CORE_KEY_DIGESTS_FILE is required; Core needs the Core key digest") - } for digest := range admin.digests { exists, err := keys.APIKeyDigestExists(ctx, digest) if err != nil { diff --git a/services/core/internal/api/project_api_keys_test.go b/services/core/internal/api/project_api_keys_test.go index a7e2e42bc..9847d0770 100644 --- a/services/core/internal/api/project_api_keys_test.go +++ b/services/core/internal/api/project_api_keys_test.go @@ -96,9 +96,6 @@ func (s *separationFixture) APIKeyDigestExists(_ context.Context, digest [sha256 } func TestAdministratorCredentialSeparation(t *testing.T) { s := &separationFixture{} - if err := ValidateCredentialSeparation(t.Context(), nil, s); err == nil { - t.Fatal("missing administrator accepted") - } admin, _ := NewDeploymentAuthenticator([]string{runtimedevice.HashCredential("admin")}) if err := ValidateCredentialSeparation(t.Context(), admin, s); err != nil || len(s.checked) != 1 || s.checked[0] != sha256.Sum256([]byte("admin")) { t.Fatal("administrator digest was not checked against persisted keys", err) diff --git a/services/core/internal/api/session_diagnostics_public_compat_test.go b/services/core/internal/api/session_diagnostics_public_compat_test.go index bb5fa50eb..4394dd0b2 100644 --- a/services/core/internal/api/session_diagnostics_public_compat_test.go +++ b/services/core/internal/api/session_diagnostics_public_compat_test.go @@ -9,6 +9,7 @@ import ( "strings" "testing" + v1 "github.com/MiniMax-AI/OpenAgentCore/contracts/agents-api/v1" "github.com/MiniMax-AI/OpenAgentCore/services/core/internal/db/sqlc" "github.com/MiniMax-AI/OpenAgentCore/services/core/internal/identity" "github.com/MiniMax-AI/OpenAgentCore/services/core/internal/persistence/postgres/pgunit" @@ -73,13 +74,15 @@ func databaseSessionReads(pool *pgxpool.Pool) func(*Dependencies, *testFakes) { } } -// submitMessage admits one message input through the Session service on pool. -func submitMessage(t *testing.T, pool *pgxpool.Pool, tenant, session, key string, payload json.RawMessage) sessions.InputReceipt { +// submitMessage admits one public text message through the Session service on +// pool. +func submitMessage(t *testing.T, pool *pgxpool.Pool, tenant, session, key, text string) sessions.InputReceipt { t.Helper() service, err := sessions.NewService(sessionpg.New(pgunit.NewPool(pool), nil), nil) if err != nil { t.Fatal(err) } + payload, _ := json.Marshal(v1.SessionInput{Type: "agent.session.input.message", Input: []v1.InputMessage{{Role: "user", Content: []v1.InputContent{{Type: "input_text", Text: &text}}}}}) receipts, err := service.SubmitInputs(t.Context(), tenant, session, key, []sessions.Input{{Kind: "message", Payload: payload}}) if err != nil { t.Fatal(err) diff --git a/services/core/internal/api/session_diagnostics_test.go b/services/core/internal/api/session_diagnostics_test.go index a9365ba25..21ad01899 100644 --- a/services/core/internal/api/session_diagnostics_test.go +++ b/services/core/internal/api/session_diagnostics_test.go @@ -20,7 +20,7 @@ func TestDiagnosticsCoreHandlerDatabaseBoundary(t *testing.T) { t.Fatal(err) } session := created.Session - receipt := submitMessage(t, pool, tenant, session.ID, "input", json.RawMessage(`{"text":"input-secret-canary"}`)) + receipt := submitMessage(t, pool, tenant, session.ID, "input", "input-secret-canary") transitionTurn(t, pool, tenant, session.ID, receipt.TurnID, sessions.TurnTransition{ExpectedStatus: sessions.TurnQueued, Status: sessions.TurnInProgress}) transitionTurn(t, pool, tenant, session.ID, receipt.TurnID, sessions.TurnTransition{ExpectedStatus: sessions.TurnInProgress, Status: sessions.TurnFailed, Outcome: json.RawMessage(`{"error_code":"device_disconnected","error":"Bearer raw-secret-canary https://private.example/key","done":{"native_id":"secret-native-canary"}}`)}) base := adminSessionsPath + session.ID diff --git a/services/core/internal/api/testdata/core-errors.json b/services/core/internal/api/testdata/core-errors.json new file mode 100644 index 000000000..256654003 --- /dev/null +++ b/services/core/internal/api/testdata/core-errors.json @@ -0,0 +1,45 @@ +[ + "console_origin_rejected", + "console_request_invalid", + "console_sign_in_required", + "core_metrics_unavailable", + "core_unreachable", + "credential_storage_unavailable", + "environment_unavailable", + "execution_unavailable", + "executor_credential_exists", + "file_transfer_unavailable", + "harness_config_invalid", + "internal_error", + "invalid_admin_key", + "invalid_model_provider", + "invalid_name", + "invalid_node_capacity", + "invalid_sandbox_configuration", + "model_configuration_model_invalid", + "model_provider_api_key_invalid", + "model_provider_base_url_invalid", + "model_provider_protocol_unsupported", + "model_provider_token_limits_invalid", + "not_found", + "project_api_key_exists", + "project_archived", + "project_exists", + "runtime_history_unavailable", + "runtime_history_unsupported", + "runtime_node_in_use", + "runtime_node_unavailable", + "sandbox_configuration_error", + "sandbox_configuration_invalid", + "sandbox_credential_invalid", + "sandbox_credential_ownership", + "sandbox_deployment_conflict", + "sandbox_generation_stale", + "sandbox_in_use", + "sandbox_not_configured", + "sandbox_operation_unsupported", + "sandbox_reset_in_progress", + "sandbox_reset_required", + "sandbox_specification_mismatch", + "sandbox_verification_unconfirmed" +] diff --git a/services/core/internal/coremetrics/service.go b/services/core/internal/coremetrics/service.go index b5433c329..cfb24849d 100644 --- a/services/core/internal/coremetrics/service.go +++ b/services/core/internal/coremetrics/service.go @@ -2,10 +2,13 @@ package coremetrics import ( "context" + "errors" "regexp" "runtime" "sync" "time" + + "github.com/MiniMax-AI/OpenAgentCore/internal/obs/log" ) const SampleInterval = 30 * time.Second @@ -14,7 +17,6 @@ const retention = 7 * 24 * time.Hour // The 7d window ends at a complete 2h bucket, so retain its leading padding too. const sampleCapacity = int((retention+2*time.Hour)/SampleInterval) + 2 -var jobIDs = []string{"scheduler", "runtime_sampler", "history_cleanup", "audit_cleanup"} var revisionPattern = regexp.MustCompile(`^[0-9a-f]{40}$`) type refusalSlot struct { @@ -31,19 +33,43 @@ type Service struct { nextSample int refusals [sampleCapacity]refusalSlot latest Sample + jobIDs []string jobs map[string]Job + periodic []Periodic process processSampler } -func New(started time.Time, revision string, source Source) *Service { - s := &Service{source: source, started: started.UTC(), now: time.Now, jobs: map[string]Job{}} +// Periodic is a background job the metrics report. Run makes one bounded pass +// and returns what it processed, nil when the pass counted nothing, and what +// failed. The next pass starts Every after a pass ends; a panic fails that pass +// only. A job without Run is disabled and reports stopped. +type Periodic struct { + ID string + Every time.Duration + Run func(context.Context) (processed *int64, failed int64, err error) +} + +// errPanicked is the error of a pass that panicked. +var errPanicked = errors.New("periodic job pass panicked") + +// New reports the scheduler, which the Source reads live, and then jobs in +// their order. Run runs the jobs. An enabled job needs a positive Every. +func New(started time.Time, revision string, source Source, jobs ...Periodic) (*Service, error) { + s := &Service{source: source, started: started.UTC(), now: time.Now, jobIDs: []string{"scheduler"}, jobs: map[string]Job{"scheduler": {ID: "scheduler", Status: "unknown"}}, periodic: jobs} if revisionPattern.MatchString(revision) { s.revision = &revision } - for _, id := range jobIDs { - s.jobs[id] = Job{ID: id, Status: "unknown"} + for _, job := range jobs { + status := "unknown" + if job.Run == nil { + status = "stopped" + } else if job.Every <= 0 { + return nil, errors.New("coremetrics: periodic job " + job.ID + " needs a positive interval") + } + s.jobIDs = append(s.jobIDs, job.ID) + s.jobs[job.ID] = Job{ID: job.ID, Status: status} } - return s + return s, nil } func slot(t time.Time) (int64, int) { tick := t.Unix() / int64(SampleInterval/time.Second) @@ -61,7 +87,7 @@ func (s *Service) RecordUnavailable() { } s.refusals[i].count++ } -func (s *Service) ReportJob(id string, at time.Time, processed *int64, failed *int64, err error) { +func (s *Service) reportJob(id string, at time.Time, processed *int64, failed *int64, err error) { status := "ok" if err != nil || (failed != nil && *failed > 0) { status = "failing" @@ -73,7 +99,7 @@ func (s *Service) ReportJob(id string, at time.Time, processed *int64, failed *i } s.jobs[id] = Job{ID: id, Status: status, LastRunAt: ptr(at.UTC()), Processed: processed, Failed: failed} } -func (s *Service) StopJob(id string) { +func (s *Service) stopJob(id string) { s.mu.Lock() defer s.mu.Unlock() if j, ok := s.jobs[id]; ok { @@ -81,7 +107,46 @@ func (s *Service) StopJob(id string) { s.jobs[id] = j } } + +// Run samples Core and runs every enabled job until ctx ends, then waits for +// them to stop. func (s *Service) Run(ctx context.Context) { + var jobs sync.WaitGroup + for _, job := range s.periodic { + if job.Run != nil { + jobs.Go(func() { s.runJob(ctx, job) }) + } + } + s.sample(ctx) + jobs.Wait() +} + +func (s *Service) runJob(ctx context.Context, job Periodic) { + defer s.stopJob(job.ID) + for { + s.pass(ctx, job) + select { + case <-ctx.Done(): + return + case <-time.After(job.Every): + } + } +} + +// pass runs and reports one pass of job. A panic is reported as a failed pass. +func (s *Service) pass(ctx context.Context, job Periodic) { + var processed *int64 + failed, err := int64(1), errPanicked + defer func() { + if recovered := recover(); recovered != nil { + log.Ctx(ctx).Error("Periodic job panicked", "job", job.ID, "panic", recovered) + } + s.reportJob(job.ID, s.now(), processed, &failed, err) + }() + processed, failed, err = job.Run(ctx) +} + +func (s *Service) sample(ctx context.Context) { ticker := time.NewTicker(SampleInterval) defer ticker.Stop() for { @@ -132,7 +197,7 @@ func (s *Service) Read(ctx context.Context, name string) (View, error) { } live := s.source.Live() view := View{Object: "core.metrics", Range: window, Service: ServiceState{Status: "running", Revision: s.revision, StartedAt: ptr(s.started), ExecutionOwner: live.ExecutionOwner}, - Execution: Execution{SlotsInUse: live.SlotsInUse, SlotsTotal: live.SlotsTotal, ConnectedDaemons: live.ConnectedDaemons}, Database: Database{Pool: live.Pool}, Jobs: make([]Job, 0, len(jobIDs))} + Execution: Execution{SlotsInUse: live.SlotsInUse, SlotsTotal: live.SlotsTotal, ConnectedDaemons: live.ConnectedDaemons}, Database: Database{Pool: live.Pool}, Jobs: make([]Job, 0, len(s.jobIDs))} s.mu.Lock() latest := s.latest if latest.At.IsZero() || now.Sub(latest.At) > 2*SampleInterval || !latest.Healthy { @@ -145,7 +210,7 @@ func (s *Service) Read(ctx context.Context, name string) (View, error) { view.Process = latest.Process } - for _, id := range jobIDs { + for _, id := range s.jobIDs { j := s.jobs[id] if id == "scheduler" && live.Scheduler.ID != "" { j = live.Scheduler diff --git a/services/core/internal/coremetrics/service_test.go b/services/core/internal/coremetrics/service_test.go index 44a3ab294..d582a2827 100644 --- a/services/core/internal/coremetrics/service_test.go +++ b/services/core/internal/coremetrics/service_test.go @@ -26,7 +26,10 @@ func fixtureService(t *testing.T) (*Service, *fixtureSource, time.Time) { t.Helper() now := time.Date(2026, 9, 25, 12, 0, 20, 0, time.UTC) source := &fixtureSource{history: History{Buckets: map[time.Time]*float64{}}, live: Live{ExecutionOwner: ptr(true), SlotsInUse: ptr(int64(2)), SlotsTotal: ptr(int64(4))}} - service := New(now.Add(-2*time.Hour), strings.Repeat("a", 40), source) + service, err := New(now.Add(-2*time.Hour), strings.Repeat("a", 40), source, Periodic{ID: "runtime_sampler"}, Periodic{ID: "history_cleanup"}, Periodic{ID: "audit_cleanup"}) + if err != nil { + t.Fatal(err) + } service.now = func() time.Time { return now } return service, source, now } @@ -121,17 +124,17 @@ func TestAllRangesAndBoundedRetention(t *testing.T) { func TestJobResultsAndConcurrentReads(t *testing.T) { s, _, now := fixtureService(t) s.record(Sample{At: now, Healthy: true}) - s.ReportJob("audit_cleanup", now, ptr(int64(12)), ptr(int64(0)), nil) + s.reportJob("audit_cleanup", now, ptr(int64(12)), ptr(int64(0)), nil) got, _ := s.Read(t.Context(), "1h") if got.Service.Status != "running" || *got.Jobs[3].Processed != 12 { t.Fatal(got.Service, got.Jobs) } - s.ReportJob("audit_cleanup", now, ptr(int64(2)), ptr(int64(1)), errors.New("failure")) + s.reportJob("audit_cleanup", now, ptr(int64(2)), ptr(int64(1)), errors.New("failure")) got, _ = s.Read(t.Context(), "1h") if got.Service.Status != "degraded" { t.Fatal(got.Service) } - s.StopJob("audit_cleanup") + s.stopJob("audit_cleanup") got, _ = s.Read(t.Context(), "1h") if got.Jobs[3].Status != "stopped" { t.Fatal(got.Jobs) @@ -143,7 +146,7 @@ func TestJobResultsAndConcurrentReads(t *testing.T) { defer wg.Done() for range 20 { s.RecordUnavailable() - s.ReportJob("history_cleanup", now, ptr(int64(0)), ptr(int64(0)), nil) + s.reportJob("history_cleanup", now, ptr(int64(0)), ptr(int64(0)), nil) if _, err := s.Read(context.Background(), "1h"); err != nil { t.Error(err) } @@ -152,10 +155,48 @@ func TestJobResultsAndConcurrentReads(t *testing.T) { } wg.Wait() } +func TestPeriodicJobsRunUntilStopped(t *testing.T) { + ctx, cancel := context.WithCancel(t.Context()) + run := func(context.Context) (*int64, int64, error) { + cancel() + return nil, 1, errors.New("failure") + } + if _, err := New(time.Now(), "", &fixtureSource{}, Periodic{ID: "audit_cleanup", Run: run}); err == nil { + t.Fatal("accepted a job without an interval") + } + s, err := New(time.Now(), "", &fixtureSource{}, Periodic{ID: "runtime_sampler"}, Periodic{ID: "audit_cleanup", Every: time.Hour, Run: run}) + if err != nil { + t.Fatal(err) + } + s.Run(ctx) + if strings.Join(s.jobIDs, ",") != "scheduler,runtime_sampler,audit_cleanup" || s.jobs["runtime_sampler"].Status != "stopped" { + t.Fatal(s.jobIDs, s.jobs) + } + if job := s.jobs["audit_cleanup"]; job.Status != "stopped" || job.LastRunAt == nil || job.Processed != nil || *job.Failed != 1 { + t.Fatal(job) + } +} +func TestPeriodicJobSurvivesAPanic(t *testing.T) { + ctx, cancel := context.WithCancel(t.Context()) + passes := 0 + s, err := New(time.Now(), "", &fixtureSource{}, Periodic{ID: "audit_cleanup", Every: time.Millisecond, Run: func(context.Context) (*int64, int64, error) { + if passes++; passes == 1 { + panic("test") + } + cancel() + return ptr(int64(3)), 0, nil + }}) + if err != nil { + t.Fatal(err) + } + s.Run(ctx) + if job := s.jobs["audit_cleanup"]; passes != 2 || job.Processed == nil || *job.Processed != 3 || *job.Failed != 0 { + t.Fatal(passes, job) + } +} func TestRevisionMustBeCommit(t *testing.T) { for _, revision := range []string{"", "unknown", "secret-value"} { - s := New(time.Now(), revision, &fixtureSource{}) - if s.revision != nil { + if s, _ := New(time.Now(), revision, &fixtureSource{}); s.revision != nil { t.Fatal("unverified build revision exposed") } } 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/deployment/public_url.go b/services/core/internal/deployment/public_url.go index 999bcd558..1bf556928 100644 --- a/services/core/internal/deployment/public_url.go +++ b/services/core/internal/deployment/public_url.go @@ -8,45 +8,66 @@ import ( "strings" ) -// ValidateCoreURL accepts a canonical public origin, never a path or +// PublicOrigin is OAC_PUBLIC_URL, the one origin applications, nodes, +// sandboxes and self-hosted executors use. Every Core address they are given +// is derived from it; none is parsed back into an origin. +type PublicOrigin struct{ origin string } + +// NewPublicOrigin accepts a canonical public origin, never a path or // credential. It may be http or https: a reverse proxy in front of Web // terminates TLS when the installation uses it. -// OAC_PUBLIC_URL must pass it. -func ValidateCoreURL(value string) error { +func NewPublicOrigin(value string) (PublicOrigin, error) { u, err := url.Parse(value) if err != nil || u.Hostname() == "" || u.User != nil || u.Path != "" || u.RawPath != "" || u.RawQuery != "" || u.ForceQuery || u.Fragment != "" || u.RawFragment != "" || u.Opaque != "" || u.String() != value || u.Host != strings.ToLower(u.Host) { - return ErrInvalidInput + return PublicOrigin{}, ErrInvalidInput } if strings.ContainsAny(u.Host, "\\% \t\r\n") || strings.HasSuffix(u.Host, ":") { - return ErrInvalidInput + return PublicOrigin{}, ErrInvalidInput } if port := u.Port(); port != "" { n, err := strconv.Atoi(port) if err != nil || n < 1 || n > 65535 || strconv.Itoa(n) != port { - return ErrInvalidInput + return PublicOrigin{}, ErrInvalidInput } } if net.ParseIP(u.Hostname()) == nil { if len(u.Hostname()) > 253 || strings.ContainsAny(u.Host, "[]") { - return ErrInvalidInput + return PublicOrigin{}, ErrInvalidInput } for _, label := range strings.Split(u.Hostname(), ".") { if len(label) == 0 || len(label) > 63 || label[0] == '-' || label[len(label)-1] == '-' { - return ErrInvalidInput + return PublicOrigin{}, ErrInvalidInput } for _, char := range label { if (char < 'a' || char > 'z') && (char < '0' || char > '9') && char != '-' { - return ErrInvalidInput + return PublicOrigin{}, ErrInvalidInput } } } } if u.Scheme != "https" && u.Scheme != "http" { - return ErrInvalidInput + return PublicOrigin{}, ErrInvalidInput } - return nil + return PublicOrigin{origin: value}, nil } +// String is the origin itself. +func (o PublicOrigin) String() string { return o.origin } + +// API is the base URL of the Agents API. +func (o PublicOrigin) API() string { return o.origin + "/v1" } + +// RuntimeAPI is the base URL sandboxes and nodes call. +func (o PublicOrigin) RuntimeAPI() string { return o.origin + "/api/v1" } + +// DaemonWebSocket is the daemon transport: ws on an http origin, wss on https. +func (o PublicOrigin) DaemonWebSocket() string { + return "ws" + strings.TrimPrefix(o.origin, "http") + "/api/v1/agent-daemon/ws" +} + +// InstallerBase is the prefix of the versioned native installer downloads. +func (o PublicOrigin) InstallerBase() string { return o.origin + "/api/v1/agent-daemon/install/" } + // AddressBindings counts what is bound to an installation address: nodes // connect to the address they enrolled with, hosted sandboxes were started with // the address current at the time, and self-hosted executors were installed diff --git a/services/core/internal/deployment/rules_test.go b/services/core/internal/deployment/rules_test.go index 66fd2af3f..ea2926e65 100644 --- a/services/core/internal/deployment/rules_test.go +++ b/services/core/internal/deployment/rules_test.go @@ -13,12 +13,18 @@ import ( "github.com/MiniMax-AI/OpenAgentCore/services/core/internal/sandbox" ) -func TestValidateCoreURL(t *testing.T) { +func TestPublicOrigin(t *testing.T) { for _, value := range []string{"https://core.example", "https://core.example:8443", "http://localhost:8091", "http://127.0.0.2:8091", "http://[::1]:8091", "https://[2001:db8::1]", "http://core.example", "http://core:8091", "http://10.0.0.5:8080"} { - if err := ValidateCoreURL(value); err != nil { + if origin, err := NewPublicOrigin(value); err != nil || origin.String() != value { t.Errorf("valid Core URL %q rejected: %v", value, err) } } + for value, socket := range map[string]string{"https://core.example": "wss://core.example/api/v1/agent-daemon/ws", "http://[::1]:8091": "ws://[::1]:8091/api/v1/agent-daemon/ws"} { + origin, err := NewPublicOrigin(value) + if err != nil || origin.DaemonWebSocket() != socket || origin.API() != value+"/v1" || origin.RuntimeAPI() != value+"/api/v1" || origin.InstallerBase() != value+"/api/v1/agent-daemon/install/" { + t.Errorf("addresses derived from %q: %+v %v", value, origin, err) + } + } for _, value := range []string{ "", "ftp://core.example", "ws://core.example", "https://core.example/", "https://user:secret@core.example", "https://core.example/path", "https://core.example?", "https://core.example?q=x", "https://core.example#x", "https://core.example#", @@ -26,7 +32,7 @@ func TestValidateCoreURL(t *testing.T) { "https://core.example\\evil", "https://[not-an-ip]", "https://-core.example", "https://core..example", "https://core_example", "https://core.example.", "https://bücher.example", "https://core.example:0443", } { - if err := ValidateCoreURL(value); !errors.Is(err, ErrInvalidInput) { + if _, err := NewPublicOrigin(value); !errors.Is(err, ErrInvalidInput) { t.Errorf("invalid Core URL %q accepted: %v", value, err) } } diff --git a/services/core/internal/environmentconfig/setup_test.go b/services/core/internal/environmentconfig/setup_test.go index 6b18c71a4..f6040532e 100644 --- a/services/core/internal/environmentconfig/setup_test.go +++ b/services/core/internal/environmentconfig/setup_test.go @@ -10,7 +10,7 @@ import ( ) func TestSetupReservesOpenAgentCoreNames(t *testing.T) { - for _, name := range []string{"OAC_ADDR", "OAC_RUNTIME_HOME", "OAC_WEB_ORIGIN", "OAC_LOG_LEVEL", "OAC_DEV_HOME", "OAC_TEST_DATABASE_URL"} { + for _, name := range []string{"OAC_ADDR", "OAC_RUNTIME_HOME", "OAC_PUBLIC_URL", "OAC_LOG_LEVEL", "OAC_DEV_HOME", "OAC_TEST_DATABASE_URL"} { if err := (Setup{Env: map[string]string{name: "value"}}).Validate(); !errors.Is(err, ErrInvalid) { t.Fatalf("reserved name %s accepted: %v", name, err) } diff --git a/services/core/internal/execution/archive_cancellation_cleanup_test.go b/services/core/internal/execution/archive_cancellation_cleanup_test.go index a0cde3cd0..95353a660 100644 --- a/services/core/internal/execution/archive_cancellation_cleanup_test.go +++ b/services/core/internal/execution/archive_cancellation_cleanup_test.go @@ -93,7 +93,7 @@ func TestArchiveWaitingCleanupReceiptBarrier(t *testing.T) { if err != nil { t.Fatal(err) } - inputs, err := service.SubmitInputs(t.Context(), project.TenantID, session.ID, "start", []sessions.Input{{Kind: "message", Payload: json.RawMessage(`{"text":"run"}`)}}) + inputs, err := service.SubmitInputs(t.Context(), project.TenantID, session.ID, "start", []sessions.Input{{Kind: "message", Payload: json.RawMessage(`{"input":[{"role":"user","content":[{"type":"input_text","text":"run"}]}]}`)}}) if err != nil { t.Fatal(err) } diff --git a/services/core/internal/execution/environment_admission.go b/services/core/internal/execution/environment_admission.go index d078186e7..52d1c3b05 100644 --- a/services/core/internal/execution/environment_admission.go +++ b/services/core/internal/execution/environment_admission.go @@ -87,13 +87,6 @@ func (w *Worker) submitEnvironmentInputs(ctx context.Context, session sessions.S // Neither kind creates a Turn. The Session lock preserves target and retry identity. return w.admitInputs(ctx, session.TenantID, session.ID, key, inputs) } - // Messages start work. A Session from before deployment defaults moved into - // Core may have no frozen provider; reject it here instead of queueing work - // its harness cannot run. Cancellation and results above stay available. - var snapshot Snapshot - if json.Unmarshal(session.Configuration, &snapshot) != nil || !snapshot.ModelProviderConfigured { - return nil, ErrModelProviderRequired - } changed, unsubscribe := w.dispatcher.notifications.subscribe(session.TenantID, session.ID) defer unsubscribe() reserve, cancel := context.WithTimeout(ctx, 5*time.Second) diff --git a/services/core/internal/execution/message_input.go b/services/core/internal/execution/message_input.go index 328567f6f..c0dd9e8db 100644 --- a/services/core/internal/execution/message_input.go +++ b/services/core/internal/execution/message_input.go @@ -10,17 +10,11 @@ import ( ) func messageInput(raw json.RawMessage) (proto.MessageInput, error) { - var input struct { - Text *string `json:"text"` - Input []v1.InputMessage `json:"input"` - } + var input v1.SessionInput if json.Unmarshal(raw, &input) != nil { return nil, sessions.ErrInvalidInput } var messages proto.MessageInput - if len(input.Input) == 0 && input.Text != nil { - messages = proto.TextInput(*input.Text) - } for _, message := range input.Input { if message.Role != "user" { return nil, sessions.ErrInvalidInput diff --git a/services/core/internal/execution/message_support_test.go b/services/core/internal/execution/message_support_test.go index 773893fe6..edccfaa8c 100644 --- a/services/core/internal/execution/message_support_test.go +++ b/services/core/internal/execution/message_support_test.go @@ -33,7 +33,7 @@ func TestMessageImageQualificationIsOperationSpecific(t *testing.T) { } // Message validation applies even when no function-result validator exists. raw, _ := json.Marshal(map[string]any{"input": []any{map[string]any{"role": "user", "content": input[0].Content}}}) - batch := []sessions.Input{{Kind: "message", Payload: json.RawMessage(`{"text":"valid first"}`)}, {Kind: "message", Payload: raw}} + batch := []sessions.Input{{Kind: "message", Payload: json.RawMessage(`{"input":[{"role":"user","content":[{"type":"input_text","text":"valid first"}]}]}`)}, {Kind: "message", Payload: raw}} if err := validateProfileInputs(enginetest.Profile(nil), "none", batch); !errors.Is(err, sessions.ErrInvalidInput) { t.Fatal("image escaped profile validation", err) } @@ -73,10 +73,6 @@ func TestWhitespaceOnlyTextQualificationUsesEngineProfiles(t *testing.T) { } } claude, _ := (engine.Catalog{}).Lookup("claude_sdk") - // Legacy text payloads use the same rule. - if err := validateProfileInputs(claude, "none", []sessions.Input{{Kind: "message", Payload: json.RawMessage(`{"text":" \t"}`)}}); !errors.Is(err, ErrWhitespaceOnlyText) { - t.Fatal(err) - } mixed, _ := json.Marshal(map[string]any{"input": []any{map[string]any{"role": "user", "content": []any{ map[string]any{"type": "input_text", "text": " "}, map[string]any{"type": "input_text", "text": "text"}}}, map[string]any{"role": "user", "content": []any{map[string]any{"type": "input_text", "text": " "}, map[string]any{"type": "input_image", "image_url": url}}}}}) diff --git a/services/core/internal/execution/model_execution_test.go b/services/core/internal/execution/model_execution_test.go index 2d4ea6af5..415477e9b 100644 --- a/services/core/internal/execution/model_execution_test.go +++ b/services/core/internal/execution/model_execution_test.go @@ -21,13 +21,6 @@ func TestSessionModelExecutionNeverFallsBack(t *testing.T) { if _, err := d.executionRequest(t.Context(), session, Snapshot{ModelProviderConfigured: true}, runtimedevice.KindCapabilities{}, sessions.ExecutionBinding{}); !errors.Is(err, sessions.ErrNotFound) { t.Fatal("missing Session credentials fell back", err) } - // Hosted and self-hosted Runtimes have no model configuration of their own. - for _, environment := range []string{"openai_hosted", "self_hosted"} { - snapshot := Snapshot{Environment: &v1.Environment{Type: environment}} - if _, err := d.executionRequest(t.Context(), sessions.Session{Engine: "codex"}, snapshot, runtimedevice.KindCapabilities{}, sessions.ExecutionBinding{}); !errors.Is(err, ErrModelProviderRequired) { - t.Fatal("provider-free Session dispatched", environment, err) - } - } // A none device without a frozen provider uses its own provider environment: // Core sends only the Agent's model and instructions. instructions := "Keep this instruction." diff --git a/services/core/internal/execution/prepared_dispatch.go b/services/core/internal/execution/prepared_dispatch.go index 751578383..fa2b11b0b 100644 --- a/services/core/internal/execution/prepared_dispatch.go +++ b/services/core/internal/execution/prepared_dispatch.go @@ -7,7 +7,6 @@ import ( "strings" "time" - v1 "github.com/MiniMax-AI/OpenAgentCore/contracts/agents-api/v1" "github.com/MiniMax-AI/OpenAgentCore/internal/agentdaemon/proto" "github.com/MiniMax-AI/OpenAgentCore/services/core/internal/sessions" ) @@ -42,10 +41,6 @@ func (d *Dispatcher) RunEnvironmentInput(ctx context.Context, lease Ownership, t if json.Unmarshal(session.Configuration, &snapshot) != nil || strings.TrimSpace(snapshot.Agent.Model) == "" { return run, sessions.ErrInvalidInput } - if !snapshot.ModelProviderConfigured && snapshot.Environment != nil && v1.ModelProviderRequired(snapshot.Environment.Type) { - // Reserved before providers were required; the caller settles it as failed. - return run, ErrModelProviderRequired - } bound, err := d.SessionsReader.GetSessionExecutionBinding(ctx, tenantID, sessionID) if err != nil { return run, err diff --git a/services/core/internal/execution/request.go b/services/core/internal/execution/request.go index 2373baffb..5524f7df3 100644 --- a/services/core/internal/execution/request.go +++ b/services/core/internal/execution/request.go @@ -4,7 +4,6 @@ import ( "context" "errors" - v1 "github.com/MiniMax-AI/OpenAgentCore/contracts/agents-api/v1" "github.com/MiniMax-AI/OpenAgentCore/internal/agentdaemon/proto" "github.com/MiniMax-AI/OpenAgentCore/internal/modelprovider" "github.com/MiniMax-AI/OpenAgentCore/services/core/internal/runtimedevice" @@ -12,10 +11,6 @@ import ( "github.com/MiniMax-AI/OpenAgentCore/services/core/internal/vaults" ) -// ErrModelProviderRequired reports a hosted or self-hosted Session that has no -// frozen model provider and therefore cannot run. -var ErrModelProviderRequired = errors.New("the Session has no model provider") - func (d *Dispatcher) executionRequest(ctx context.Context, session sessions.Session, snapshot Snapshot, caps runtimedevice.KindCapabilities, bound sessions.ExecutionBinding) (proto.PromptRequestPayload, error) { recoverNativeSession := bound.HasStartedTurn && bound.NativeSessionID == "" if recoverNativeSession && !caps.NativeSessionRecovery { @@ -31,10 +26,6 @@ func (d *Dispatcher) executionRequest(ctx context.Context, session sessions.Sess if err != nil { return proto.PromptRequestPayload{}, err } - } else if snapshot.Environment != nil && v1.ModelProviderRequired(snapshot.Environment.Type) { - // Require the frozen bundle before dispatch so the harness cannot - // select an implicit provider endpoint. - return proto.PromptRequestPayload{}, ErrModelProviderRequired } var harnessConfig proto.HarnessConfig if snapshot.Agent.XAgentsCore != nil { diff --git a/services/core/internal/execution/worker.go b/services/core/internal/execution/worker.go index 2ebb2c20f..137dd1a82 100644 --- a/services/core/internal/execution/worker.go +++ b/services/core/internal/execution/worker.go @@ -365,9 +365,6 @@ func (w *Worker) runClaim(ctx context.Context, item sessions.ExecutionWork) erro var rejection *preparationRejection capacityRejected := errors.As(err, &rejection) && rejection.operation == proto.TypeExecutionPrepare && rejection.code == "preparation_capacity" outcome := json.RawMessage(`{"error_code":"execution_unavailable"}`) - if errors.Is(err, ErrModelProviderRequired) { - outcome = json.RawMessage(`{"error_code":"model_provider_required"}`) - } finish, cancel := context.WithTimeout(context.Background(), 10*time.Second) defer cancel() turn, err := w.dispatcher.SessionsReader.GetTurn(finish, item.TenantID, item.SessionID, item.TurnID) diff --git a/services/core/internal/execution/worker_schedule.go b/services/core/internal/execution/worker_schedule.go index de5ac027a..bf3a821cc 100644 --- a/services/core/internal/execution/worker_schedule.go +++ b/services/core/internal/execution/worker_schedule.go @@ -93,9 +93,6 @@ func (w *Worker) runEnvironmentInput(ctx context.Context, item scheduledWork) er if err == nil { return nil } - if errors.Is(err, ErrModelProviderRequired) { - return w.dispatcher.sessionExecution.FailEnvironmentInput(ctx, item.TenantID, item.SessionID, item.reservationID, "model_provider_required") - } if errors.Is(err, errPreparationFailed) && run.Reservation.State == sessions.EnvironmentInputPending { return w.dispatcher.sessionExecution.FailEnvironmentInput(ctx, item.TenantID, item.SessionID, item.reservationID, "runtime_preparation_failed") } diff --git a/services/core/internal/items/changes_test.go b/services/core/internal/items/changes_test.go index 38cf514ca..96f98766a 100644 --- a/services/core/internal/items/changes_test.go +++ b/services/core/internal/items/changes_test.go @@ -57,7 +57,7 @@ func TestObserveDecidesItemChanges(t *testing.T) { }, { name: "an input message takes no output index", kind: "message", - update: project("message", `{"text":"hello"}`), + update: project("message", `{"type":"agent.session.input.message","input":[{"role":"user","content":[{"type":"input_text","text":"hello"}]}]}`), check: func(c Change) bool { return !c.Output && c.Item.Role == "user" }, }, } { diff --git a/services/core/internal/items/inputs.go b/services/core/internal/items/inputs.go index 62d0efe8d..67bd00d4d 100644 --- a/services/core/internal/items/inputs.go +++ b/services/core/internal/items/inputs.go @@ -9,20 +9,16 @@ import ( func inputMessages(turn string, sequence int64, raw json.RawMessage) []Update { var p struct { - Text *string `json:"text"` Input []struct { Role string `json:"role"` Content []v1.ItemContent `json:"content"` } `json:"input"` } - // Internal admission predates the public schema and accepts arbitrary objects. + // Session admission stores any JSON object; only public messages project. if err := json.Unmarshal(raw, &p); err != nil { return nil } key := "input:" + strconv.FormatInt(sequence, 10) - if p.Text != nil { - return []Update{{Item: message(turn, key, "user", *p.Text, "completed")}} - } var updates []Update for i, input := range p.Input { if input.Role != "user" || len(input.Content) == 0 { diff --git a/services/core/internal/nativeinstaller/bootstrap_interrupt_unix_test.go b/services/core/internal/nativeinstaller/bootstrap_interrupt_unix_test.go index bb6766c52..6455c6234 100644 --- a/services/core/internal/nativeinstaller/bootstrap_interrupt_unix_test.go +++ b/services/core/internal/nativeinstaller/bootstrap_interrupt_unix_test.go @@ -101,7 +101,7 @@ func TestBootstrapCommandDiscardsTimedOutResponse(t *testing.T) { t.Fatal(err) } catalog := Catalog{Version: "test"} - command := exec.Command("bash", "-c", catalog.Commands(server.URL, "fixture-grant")["posix"]) + command := exec.Command("bash", "-c", catalog.Commands(server.URL+"/api/v1/agent-daemon/install/", "fixture-grant")["posix"]) command.Env = append(os.Environ(), "PATH="+bin+string(os.PathListSeparator)+os.Getenv("PATH"), "NO_PROXY=127.0.0.1", "no_proxy=127.0.0.1") output, err := command.CombinedOutput() if err != nil || !bytes.Contains(output, []byte("entry-success")) || bytes.Contains(output, []byte("incomplete response")) || requests.Load() != 2 { diff --git a/services/core/internal/nativeinstaller/catalog.go b/services/core/internal/nativeinstaller/catalog.go index 2a40dc0a5..8c0049d88 100644 --- a/services/core/internal/nativeinstaller/catalog.go +++ b/services/core/internal/nativeinstaller/catalog.go @@ -42,8 +42,12 @@ var platformName = regexp.MustCompile(`^(linux|darwin|windows)-(amd64|arm64)$`) // Load checks the matched catalog without downloading execution payloads. Local // offline archives are verified once; the directory stays immutable while serving. +// A directory without catalog.json holds no installer and returns nil. func Load(directory, version string) (*Catalog, error) { raw, err := os.ReadFile(filepath.Join(directory, "catalog.json")) + if errors.Is(err, os.ErrNotExist) { + return nil, nil + } if err != nil { return nil, err } @@ -123,8 +127,9 @@ func (c *Catalog) ServeHTTP(w http.ResponseWriter, r *http.Request) { func shellQuote(s string) string { return "'" + strings.ReplaceAll(s, "'", "'\"'\"'") + "'" } func psQuote(s string) string { return "'" + strings.ReplaceAll(s, "'", "''") + "'" } -func (c *Catalog) Commands(origin, authorization string) map[string]string { - base := origin + "/api/v1/agent-daemon/install/" + c.Version +// Commands installs this catalog's version from the installer base URL. +func (c *Catalog) Commands(installerBase, authorization string) map[string]string { + base := installerBase + c.Version // Hold the small bootstrap in memory so an interrupted fetch leaves no file. // Only execute a complete successful response; preserve interactive stdin. posix := `set -e; script=; for attempt in 1 2 3; do if script=$(curl -fsS --connect-timeout 15 --max-time 60 --max-filesize 1048576 ` + shellQuote(base+"/bootstrap.sh") + `); then break; fi; [ "$attempt" -lt 3 ] || exit 1; sleep "$attempt"; done; bash -c "$script" -- "$@"` diff --git a/services/core/internal/nativeinstaller/catalog_test.go b/services/core/internal/nativeinstaller/catalog_test.go index 5f5f9cf45..ce6fd24f3 100644 --- a/services/core/internal/nativeinstaller/catalog_test.go +++ b/services/core/internal/nativeinstaller/catalog_test.go @@ -108,3 +108,9 @@ func TestOnlineCatalogRedirectsOnlyDeclaredMatchedArchives(t *testing.T) { t.Fatal("corruption must not fall back to online download") } } + +func TestMissingCatalogServesNoInstallers(t *testing.T) { + if catalog, err := Load(t.TempDir(), "build"); catalog != nil || err != nil { + t.Fatal(catalog, err) + } +} 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/sessionpg/execution_inputs_test.go b/services/core/internal/persistence/postgres/sessionpg/execution_inputs_test.go index 22807a559..80af513a5 100644 --- a/services/core/internal/persistence/postgres/sessionpg/execution_inputs_test.go +++ b/services/core/internal/persistence/postgres/sessionpg/execution_inputs_test.go @@ -308,7 +308,7 @@ func TestEnvironmentInputPromotionRollsBackHistoryAndSettlement(t *testing.T) { ctx := t.Context() pending := reserve(t, service, tenant, session, "pending") name := "reservation_failure_" + strings.ReplaceAll(uuid.NewString(), "-", "") - table, expression := "turn_inputs", "session_id <> '"+text(session)+"'::uuid OR payload->>'text' <> 'second'" + table, expression := "turn_inputs", "session_id <> '"+text(session)+"'::uuid OR payload#>>'{input,0,content,0,text}' <> 'second'" switch phase { case "settlement": table, expression = "environment_input_reservations", "id <> '"+pending.ID+"'::uuid OR state <> 'admitted'" diff --git a/services/core/internal/persistence/postgres/sessionpg/inputs_test.go b/services/core/internal/persistence/postgres/sessionpg/inputs_test.go index e2f9e1b2f..450660b4b 100644 --- a/services/core/internal/persistence/postgres/sessionpg/inputs_test.go +++ b/services/core/internal/persistence/postgres/sessionpg/inputs_test.go @@ -14,6 +14,7 @@ import ( "github.com/jackc/pgx/v5/pgtype" "github.com/jackc/pgx/v5/pgxpool" + v1 "github.com/MiniMax-AI/OpenAgentCore/contracts/agents-api/v1" "github.com/MiniMax-AI/OpenAgentCore/services/core/internal/persistence/postgres/pgtest" "github.com/MiniMax-AI/OpenAgentCore/services/core/internal/sessions" ) @@ -22,8 +23,10 @@ var messagePayload = json.RawMessage(`{"input":[{"role":"user","content":[{"type var cancelInput = sessions.Input{Kind: "cancel", Payload: json.RawMessage(`{}`)} +// messageInput is a public message event with one text part, as the events +// route stores it. func messageInput(text string) sessions.Input { - payload, _ := json.Marshal(map[string]string{"text": text}) + payload, _ := json.Marshal(v1.SessionInput{Type: "agent.session.input.message", Input: []v1.InputMessage{{Role: "user", Content: []v1.InputContent{{Type: "input_text", Text: &text}}}}}) return sessions.Input{Kind: "message", Payload: payload} } @@ -314,7 +317,7 @@ func TestInputBatchesAreOrderedAndIdempotentAcrossConnections(t *testing.T) { t.Fatalf("inputs=%d err=%v", len(inputs), err) } for i, input := range inputs { - var payload map[string]string + var payload v1.SessionInput if err := json.Unmarshal(input.Payload, &payload); err != nil { t.Fatal(err) } @@ -322,7 +325,7 @@ func TestInputBatchesAreOrderedAndIdempotentAcrossConnections(t *testing.T) { if i%2 == 1 { want = "second" } - if payload["text"] != want { + if *payload.Input[0].Content[0].Text != want { t.Fatalf("batch interleaved at %d: %v", i, payload) } } @@ -355,7 +358,7 @@ func TestBatchRetriesCompareTheWholeRequestAndRetainTargets(t *testing.T) { t.Fatalf("changed batch accepted: %v", err) } } - batch[1].Payload = json.RawMessage(`{ "text" : "one" }`) + batch[1].Payload = json.RawMessage(`{ "input" : [ { "content" : [ { "text" : "one", "type" : "input_text" } ], "role" : "user" } ], "type" : "agent.session.input.message" }`) pool.Close() restartedStore, restarted := stagingService(t, pgtest.Open(t)) retry, err := restarted.SubmitInputs(ctx, text(tenant), text(session), "mixed", batch) diff --git a/services/core/internal/persistence/postgres/sessionpg/items.go b/services/core/internal/persistence/postgres/sessionpg/items.go index 3e818822f..9c8072bc4 100644 --- a/services/core/internal/persistence/postgres/sessionpg/items.go +++ b/services/core/internal/persistence/postgres/sessionpg/items.go @@ -64,7 +64,7 @@ func (t *SessionTx) PutItem(ctx context.Context, turnID string, created time.Tim if err != nil { return nil, err } - payload, err := change.Item.MarshalStored() + payload, err := json.Marshal(change.Item) if err != nil { return nil, err } 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/processconfig/config.go b/services/core/internal/processconfig/config.go index c7f1805b7..eb5eb0117 100644 --- a/services/core/internal/processconfig/config.go +++ b/services/core/internal/processconfig/config.go @@ -1,10 +1,17 @@ -// Package processconfig is the process settings Core loads from its environment. -// Startup and `oac-core check-config` both call Check. Settings reports the -// effective values for GET /core/v1/installation. Errors name the variable and -// never include its value. +// Package processconfig is the process settings Core loads from its +// environment. Load reads every variable and every file it names exactly once, +// applies each default and validates the result; startup and `oac-core +// check-config` both call it. Errors name the variable and never include its +// value or the content of a file. package processconfig import ( + "bytes" + "cmp" + "encoding/base64" + "encoding/json" + "io" + "net/url" "os" "slices" "strconv" @@ -12,154 +19,264 @@ import ( "time" "github.com/google/uuid" + "golang.org/x/net/http/httpguts" + "github.com/MiniMax-AI/OpenAgentCore/internal/obs/log" "github.com/MiniMax-AI/OpenAgentCore/services/core/internal/api" + "github.com/MiniMax-AI/OpenAgentCore/services/core/internal/credentialcrypto" + "github.com/MiniMax-AI/OpenAgentCore/services/core/internal/databaseurl" "github.com/MiniMax-AI/OpenAgentCore/services/core/internal/deployment" "github.com/MiniMax-AI/OpenAgentCore/services/core/internal/engine" "github.com/MiniMax-AI/OpenAgentCore/services/core/internal/execution" "github.com/MiniMax-AI/OpenAgentCore/services/core/internal/oauthrefresh" + "github.com/MiniMax-AI/OpenAgentCore/services/core/internal/sandbox" ) -// Check validates every process setting Core loads. An unset or empty -// variable keeps its default, so Compose can pass every setting through. -func Check() error { - if _, err := PublicURL(); err != nil { - return err +const ( + defaultAddr = "127.0.0.1:8091" + defaultHarness = "codex" + defaultWriteAuditRetention = 90 * 24 * time.Hour + // providerStateRoot is where Compose mounts the data volume's state/. + providerStateRoot = "/state" +) + +// Config is Core's process configuration. +type Config struct { + // Addr is OAC_ADDR, the listener address. + Addr string + // PublicOrigin is OAC_PUBLIC_URL. Nil disables the Runtime gateway and + // the execution Worker. + PublicOrigin *deployment.PublicOrigin + // DatabaseURL is OAC_DATABASE_URL with the password from + // OAC_DATABASE_PASSWORD_FILE. + DatabaseURL string + // InstallationID comes from OAC_INSTALLATION_ID_FILE. Empty leaves the + // sandbox deployment and node routes off. + InstallationID string + // CredentialKey seals stored credentials. Nil when + // OAC_CREDENTIAL_KEY_FILE is unset. + CredentialKey *credentialcrypto.Cipher + // CoreKeys authenticates the Core key from OAC_CORE_KEY_DIGESTS_FILE. + CoreKeys *api.DeploymentAuthenticator + ExecutionConcurrency int + DefaultHarness string + // Harnesses is the sorted set of enabled Harnesses, the default included. + Harnesses []string + WriteAuditRetention time.Duration + OAuthTrustedOrigins []string + RuntimeHistory RuntimeHistory + // ProviderPaths locate adapter helpers under OAC_PROVIDER_ROOT and their + // private state under the Compose state mount. + ProviderPaths sandbox.ProcessPaths + // NativeInstallers is the native installer catalog directory under + // OAC_PROVIDER_ROOT; empty when the root is unset. + NativeInstallers string + Log log.Config +} + +// RuntimeHistory is the file named by OAC_HISTORY_SETTINGS_FILE, with its +// defaults applied. Without the file, history stays in Core's database and +// nothing is exported. +type RuntimeHistory struct { + // File is the path Core loaded, or empty. + File string + // Endpoint is an optional OTLP/HTTP metrics URL; Insecure allows http. + Endpoint string + Insecure bool + Headers map[string]string + QueueCapacity int + Timeout time.Duration + SampleInterval time.Duration +} + +// runtimeHistoryFile is the file's JSON schema. +type runtimeHistoryFile struct { + Transport string `json:"transport,omitempty"` + Endpoint string `json:"endpoint,omitempty"` + Insecure bool `json:"insecure,omitempty"` + Headers map[string]string `json:"headers,omitempty"` + QueueCapacity int `json:"queue_capacity,omitempty"` + TimeoutSeconds int `json:"timeout_seconds,omitempty"` + SampleIntervalSeconds int `json:"sample_interval_seconds,omitempty"` +} + +// Load reads and validates the process environment. An unset or empty +// variable selects its default. +func Load() (Config, error) { + var c Config + var err error + if c.Log, err = log.LoadConfig(); err != nil { + return Config{}, err } - if _, err := ExecutionConcurrency(); err != nil { - return err + c.Addr = cmp.Or(os.Getenv("OAC_ADDR"), defaultAddr) + if value := os.Getenv("OAC_PUBLIC_URL"); value != "" { + origin, err := deployment.NewPublicOrigin(value) + if err != nil { + return Config{}, configError("OAC_PUBLIC_URL must be a canonical http or https origin without path, credentials, query or fragment, such as https://core.example") + } + c.PublicOrigin = &origin } - if _, err := InstallationID(); err != nil { - return err + if c.DatabaseURL, err = databaseurl.FromEnvironment(); err != nil { + return Config{}, err } - engineName, err := DefaultHarness() - if err != nil { - return err - } - if _, err := Harnesses(engineName); err != nil { - return err - } - if _, err := writeAuditRetention(); err != nil { - return err - } - if err := oauthOrigins(); err != nil { - return err - } - return logSettings() -} - -// Settings is the effective process configuration. Sensitive file settings -// report only whether they are configured. -func Settings() ([]api.InstallationSetting, error) { - if err := Check(); err != nil { - return nil, err - } - public, _ := PublicURL() - concurrency, _ := ExecutionConcurrency() - engineName, _ := DefaultHarness() - enabled, _ := Harnesses(engineName) - retention := "2160h" - if value := os.Getenv("OAC_WRITE_AUDIT_RETENTION"); value != "" { - retention = value - } - level, format, addSource := logValues() - history := os.Getenv("OAC_HISTORY_SETTINGS_FILE") != "" - origins := []string{} - if raw := os.Getenv("OAC_OAUTH_TRUSTED_ORIGINS"); raw != "" { - for _, origin := range strings.Split(raw, ",") { - if origin = strings.TrimSpace(origin); origin != "" { - origins = append(origins, origin) - } - } + if c.DatabaseURL == "" { + return Config{}, configError("OAC_DATABASE_URL is required") + } + if c.InstallationID, err = installationID(); err != nil { + return Config{}, err + } + if c.InstallationID != "" && c.PublicOrigin == nil { + return Config{}, configError("OAC_INSTALLATION_ID_FILE requires OAC_PUBLIC_URL, the origin nodes and sandboxes use to reach Core") + } + if c.CredentialKey, err = credentialKey(); err != nil { + return Config{}, err + } + if c.CoreKeys, err = coreKeys(); err != nil { + return Config{}, err + } + if c.ExecutionConcurrency, err = executionConcurrency(); err != nil { + return Config{}, err + } + c.DefaultHarness = cmp.Or(os.Getenv("OAC_DEFAULT_HARNESS"), defaultHarness) + if _, known := (engine.Catalog{}).Lookup(c.DefaultHarness); !known { + return Config{}, configError("OAC_DEFAULT_HARNESS is not a known harness") + } + if c.Harnesses, err = harnesses(c.DefaultHarness); err != nil { + return Config{}, err + } + if c.WriteAuditRetention, err = writeAuditRetention(); err != nil { + return Config{}, err + } + if c.OAuthTrustedOrigins, err = oauthTrustedOrigins(); err != nil { + return Config{}, err } - var publicValue any - if public != "" { - publicValue = public + if c.RuntimeHistory, err = runtimeHistory(); err != nil { + return Config{}, err + } + c.ProviderPaths = sandbox.ProcessPaths{ArtifactRoot: os.Getenv("OAC_PROVIDER_ROOT"), StateRoot: providerStateRoot} + if c.ProviderPaths.ArtifactRoot != "" { + c.NativeInstallers = c.ProviderPaths.ArtifactRoot + "/native-installers" + } + return c, nil +} + +// Settings projects the configuration that GET /core/v1/installation +// reports. Sensitive file settings report only whether they are configured. +func (c Config) Settings() []api.InstallationSetting { + var public any + if c.PublicOrigin != nil { + public = c.PublicOrigin.String() + } + format := c.Log.Format + if format == "" { + format = "auto" + } + origins := c.OAuthTrustedOrigins + if origins == nil { + origins = []string{} } return []api.InstallationSetting{ - setting("public_url", publicValue, nil, true, []string{"core", "web"}), - setting("log.level", level, "info", true, []string{"core", "web"}), - setting("log.format", format, "auto", true, []string{"core", "web"}), - setting("log.add_source", addSource, false, true, []string{"core", "web"}), - setting("core.execution_concurrency", concurrency, execution.DefaultExecutionConcurrency, true, []string{"core"}), - setting("core.harnesses", enabled, (engine.Catalog{}).Kinds(), true, []string{"core"}), - setting("core.default_harness", engineName, "codex", true, []string{"core"}), - setting("core.write_audit_retention", retention, "2160h", true, []string{"core"}), - setting("core.oauth_trusted_origins", origins, []string{}, true, []string{"core"}), - sensitive("core.runtime_history", history, []string{"core"}), - }, nil + setting("public_url", public, nil, []string{"core", "web"}), + setting("log.level", strings.ToLower(c.Log.Level.String()), "info", []string{"core", "web"}), + setting("log.format", format, "auto", []string{"core", "web"}), + setting("log.add_source", c.Log.AddSource, false, []string{"core", "web"}), + setting("core.execution_concurrency", c.ExecutionConcurrency, execution.DefaultExecutionConcurrency, []string{"core"}), + setting("core.harnesses", c.Harnesses, (engine.Catalog{}).Kinds(), []string{"core"}), + setting("core.default_harness", c.DefaultHarness, defaultHarness, []string{"core"}), + setting("core.write_audit_retention", duration(c.WriteAuditRetention), duration(defaultWriteAuditRetention), []string{"core"}), + setting("core.oauth_trusted_origins", origins, []string{}, []string{"core"}), + sensitive("core.runtime_history", c.RuntimeHistory.File != "", []string{"core"}), + } } -func setting(key string, value, fallback any, changeable bool, restarts []string) api.InstallationSetting { - return api.InstallationSetting{Key: key, Value: value, Default: fallback, Changeable: changeable, Sensitive: false, Restarts: restarts} +// duration formats d as OAC_WRITE_AUDIT_RETENTION spells it: 2160h, not +// 2160h0m0s. +func duration(d time.Duration) string { + s := d.String() + if strings.HasSuffix(s, "m0s") { + s = s[:len(s)-2] + } + if strings.HasSuffix(s, "h0m") { + s = s[:len(s)-2] + } + return s +} + +func setting(key string, value, fallback any, restarts []string) api.InstallationSetting { + return api.InstallationSetting{Key: key, Value: value, Default: fallback, Changeable: true, Sensitive: false, Restarts: restarts} } func sensitive(key string, configured bool, restarts []string) api.InstallationSetting { return api.InstallationSetting{Key: key, Configured: &configured, Changeable: true, Sensitive: true, Restarts: restarts} } -// PublicURL reads OAC_PUBLIC_URL, the one origin applications, nodes, -// sandboxes and self-hosted executors use. An empty result disables daemon -// transport, as for a Core without execution. -func PublicURL() (string, error) { - value := os.Getenv("OAC_PUBLIC_URL") - if value == "" { +func installationID() (string, error) { + path := os.Getenv("OAC_INSTALLATION_ID_FILE") + if path == "" { return "", nil } - if deployment.ValidateCoreURL(value) != nil { - return "", configErr("OAC_PUBLIC_URL must be a canonical http or https origin without path, credentials, query or fragment, such as https://core.example") + raw, err := os.ReadFile(path) + if err != nil { + return "", configError("OAC_INSTALLATION_ID_FILE must name a readable file") + } + value := strings.TrimSpace(string(raw)) + if id, err := uuid.Parse(value); err != nil || id == uuid.Nil || id.String() != value { + return "", configError("OAC_INSTALLATION_ID_FILE must contain a canonical UUID") } return value, nil } -// InstallationID reads the file named by OAC_INSTALLATION_ID_FILE. The ID -// enables the sandbox deployment and node routes; unset leaves them off. -func InstallationID() (string, error) { - path := os.Getenv("OAC_INSTALLATION_ID_FILE") +func credentialKey() (*credentialcrypto.Cipher, error) { + path := os.Getenv("OAC_CREDENTIAL_KEY_FILE") if path == "" { - return "", nil + return nil, nil + } + content, err := os.ReadFile(path) + if err != nil { + return nil, configError("cannot read OAC_CREDENTIAL_KEY_FILE") + } + key, err := base64.StdEncoding.Strict().DecodeString(strings.TrimSpace(string(content))) + if err != nil || len(key) != 32 { + return nil, configError("OAC_CREDENTIAL_KEY_FILE must contain a base64-encoded random 32-byte key") + } + return credentialcrypto.New(key) +} + +func coreKeys() (*api.DeploymentAuthenticator, error) { + path := os.Getenv("OAC_CORE_KEY_DIGESTS_FILE") + if path == "" { + return nil, configError("OAC_CORE_KEY_DIGESTS_FILE is required; Core needs the Core key digest") } raw, err := os.ReadFile(path) if err != nil { - return "", configErr("OAC_INSTALLATION_ID_FILE must name a readable file") + return nil, configError("cannot read OAC_CORE_KEY_DIGESTS_FILE") } - value := strings.TrimSpace(string(raw)) - if id, err := uuid.Parse(value); err != nil || id == uuid.Nil || id.String() != value { - return "", configErr("OAC_INSTALLATION_ID_FILE must contain a canonical UUID") + var digests []string + if json.Unmarshal(raw, &digests) != nil { + return nil, configError("OAC_CORE_KEY_DIGESTS_FILE must contain a JSON array of Core key SHA-256 digests") } - return value, nil + keys, err := api.NewDeploymentAuthenticator(digests) + if err != nil { + return nil, configError("OAC_CORE_KEY_DIGESTS_FILE must contain a JSON array of Core key SHA-256 digests") + } + return keys, nil } -// ExecutionConcurrency reads OAC_EXECUTION_CONCURRENCY. Unset or empty keeps -// the default. -func ExecutionConcurrency() (int, error) { +func executionConcurrency() (int, error) { value := os.Getenv("OAC_EXECUTION_CONCURRENCY") if value == "" { return execution.DefaultExecutionConcurrency, nil } limit, err := strconv.Atoi(value) if err != nil || limit < 1 || limit > 1024 { - return 0, configErr("OAC_EXECUTION_CONCURRENCY must be an integer between 1 and 1024") + return 0, configError("OAC_EXECUTION_CONCURRENCY must be an integer between 1 and 1024") } return limit, nil } -// DefaultHarness reads OAC_DEFAULT_HARNESS, the Harness used when a request -// names none. -func DefaultHarness() (string, error) { - value := os.Getenv("OAC_DEFAULT_HARNESS") - if value == "" { - return "codex", nil - } - if _, known := (engine.Catalog{}).Lookup(value); !known { - return "", configErr("OAC_DEFAULT_HARNESS is not a known harness") - } - return value, nil -} - -// Harnesses reads OAC_HARNESSES. Unset enables every Harness this build +// harnesses reads OAC_HARNESSES. Unset enables every Harness this build // supports; a list supplements the default Harness. -func Harnesses(defaultEngine string) ([]string, error) { +func harnesses(defaultEngine string) ([]string, error) { kinds := []string{defaultEngine} if value := os.Getenv("OAC_HARNESSES"); value != "" { kinds = append(kinds, strings.Split(value, ",")...) @@ -169,7 +286,7 @@ func Harnesses(defaultEngine string) ([]string, error) { for i, kind := range kinds { kind = strings.TrimSpace(kind) if _, known := (engine.Catalog{}).Lookup(kind); !known { - return nil, configErr("OAC_HARNESSES contains an unknown harness") + return nil, configError("OAC_HARNESSES contains an unknown harness") } kinds[i] = kind } @@ -180,55 +297,97 @@ func Harnesses(defaultEngine string) ([]string, error) { func writeAuditRetention() (time.Duration, error) { value := os.Getenv("OAC_WRITE_AUDIT_RETENTION") if value == "" { - return 90 * 24 * time.Hour, nil + return defaultWriteAuditRetention, nil } - duration, parseErr := time.ParseDuration(value) - if parseErr != nil || duration < time.Hour { - return 0, configErr("OAC_WRITE_AUDIT_RETENTION must be a duration of at least 1h") + duration, err := time.ParseDuration(value) + if err != nil || duration < time.Hour { + return 0, configError("OAC_WRITE_AUDIT_RETENTION must be a duration of at least 1h") } return duration, nil } -func oauthOrigins() error { - var origins []string - if raw := os.Getenv("OAC_OAUTH_TRUSTED_ORIGINS"); raw != "" { - for _, origin := range strings.Split(raw, ",") { - origins = append(origins, strings.TrimSpace(origin)) - } +func oauthTrustedOrigins() ([]string, error) { + raw := os.Getenv("OAC_OAUTH_TRUSTED_ORIGINS") + if raw == "" { + return nil, nil + } + origins := strings.Split(raw, ",") + for i, origin := range origins { + origins[i] = strings.TrimSpace(origin) } if _, err := oauthrefresh.NewClient(origins); err != nil { - return configErr("OAC_OAUTH_TRUSTED_ORIGINS is invalid") + return nil, configError("OAC_OAUTH_TRUSTED_ORIGINS is invalid") } - return nil + return origins, nil } -func logSettings() error { - if value, ok := os.LookupEnv("OAC_LOG_LEVEL"); ok && value != "" && !slices.Contains([]string{"debug", "info", "warn", "warning", "error", "err"}, strings.ToLower(strings.TrimSpace(value))) { - return configErr("OAC_LOG_LEVEL must be debug, info, warn or error") - } - if value, ok := os.LookupEnv("OAC_LOG_FORMAT"); ok && value != "" && !slices.Contains([]string{"auto", "json", "text"}, strings.ToLower(strings.TrimSpace(value))) { - return configErr("OAC_LOG_FORMAT must be auto, json or text") +func runtimeHistory() (RuntimeHistory, error) { + var file runtimeHistoryFile + path := os.Getenv("OAC_HISTORY_SETTINGS_FILE") + if path != "" { + raw, err := os.ReadFile(path) + if err != nil { + return RuntimeHistory{}, configError("OAC_HISTORY_SETTINGS_FILE: the file cannot be read") + } + decoder := json.NewDecoder(bytes.NewReader(raw)) + decoder.DisallowUnknownFields() + if decoder.Decode(&file) != nil || decoder.Decode(new(any)) != io.EOF { + return RuntimeHistory{}, configError("OAC_HISTORY_SETTINGS_FILE: the file must hold one JSON object with known fields") + } } - if value, ok := os.LookupEnv("OAC_LOG_ADD_SOURCE"); ok && value != "" && value != "0" && value != "1" { - return configErr("OAC_LOG_ADD_SOURCE must be 0 or 1") + if err := validateRuntimeHistory(file); err != nil { + return RuntimeHistory{}, err } - return nil + return RuntimeHistory{File: path, Endpoint: file.Endpoint, Insecure: file.Insecure, Headers: file.Headers, + QueueCapacity: cmp.Or(file.QueueCapacity, 256), + Timeout: time.Duration(cmp.Or(file.TimeoutSeconds, 2)) * time.Second, + SampleInterval: time.Duration(cmp.Or(file.SampleIntervalSeconds, 30)) * time.Second}, nil } -func logValues() (string, string, bool) { - level := "info" - if value := strings.ToLower(strings.TrimSpace(os.Getenv("OAC_LOG_LEVEL"))); value != "" { - level = value +func validateRuntimeHistory(file runtimeHistoryFile) error { + if file.QueueCapacity < 0 || file.QueueCapacity > 4096 { + return configError("OAC_HISTORY_SETTINGS_FILE: queue_capacity must be from 0 to 4096") + } + if file.TimeoutSeconds < 0 || file.TimeoutSeconds > 30 { + return configError("OAC_HISTORY_SETTINGS_FILE: timeout_seconds must be from 0 to 30") } - format := "auto" - if value := strings.ToLower(strings.TrimSpace(os.Getenv("OAC_LOG_FORMAT"))); value != "" { - format = value + if file.SampleIntervalSeconds != 0 && (file.SampleIntervalSeconds < 5 || file.SampleIntervalSeconds > 300) { + return configError("OAC_HISTORY_SETTINGS_FILE: sample_interval_seconds must be from 5 to 300") + } + if file.Endpoint == "" { + if file.Transport != "" || file.Insecure || len(file.Headers) != 0 { + return configError("OAC_HISTORY_SETTINGS_FILE: transport, insecure and headers require an endpoint") + } + return nil } - return level, format, os.Getenv("OAC_LOG_ADD_SOURCE") == "1" + if file.Transport != "otlp_http" { + return configError("OAC_HISTORY_SETTINGS_FILE: transport must be otlp_http") + } + endpoint, err := url.Parse(file.Endpoint) + if err != nil || endpoint.Host == "" || endpoint.User != nil || endpoint.RawQuery != "" || endpoint.Fragment != "" || endpoint.RawPath != "" || endpoint.Path == "" || endpoint.String() != file.Endpoint { + return configError("OAC_HISTORY_SETTINGS_FILE: endpoint must be a canonical absolute OTLP metrics URL") + } + switch endpoint.Scheme { + case "https": + if file.Insecure { + return configError("OAC_HISTORY_SETTINGS_FILE: insecure requires an http endpoint") + } + case "http": + if !file.Insecure { + return configError("OAC_HISTORY_SETTINGS_FILE: an http endpoint requires insecure=true") + } + default: + return configError("OAC_HISTORY_SETTINGS_FILE: endpoint scheme must be https or explicit insecure http") + } + for key, value := range file.Headers { + lower := strings.ToLower(key) + if !httpguts.ValidHeaderFieldName(key) || !httpguts.ValidHeaderFieldValue(value) || lower == "host" || lower == "content-length" || lower == "content-type" || lower == "content-encoding" { + return configError("OAC_HISTORY_SETTINGS_FILE: headers contain an invalid or reserved entry") + } + } + return nil } type configError string func (e configError) Error() string { return string(e) } - -func configErr(message string) error { return configError(message) } diff --git a/services/core/internal/processconfig/config_test.go b/services/core/internal/processconfig/config_test.go index 9f8dfa533..d66edb2bc 100644 --- a/services/core/internal/processconfig/config_test.go +++ b/services/core/internal/processconfig/config_test.go @@ -1,69 +1,235 @@ package processconfig import ( + "bytes" + "encoding/base64" + "os" + "path/filepath" "strings" "testing" + "time" + + "github.com/MiniMax-AI/OpenAgentCore/services/core/internal/credentialcrypto" ) -func TestCheckRejectsInvalidValuesWithoutEchoingThem(t *testing.T) { - secret := "https://user:synthetic-secret@core.example" - t.Setenv("OAC_PUBLIC_URL", secret) - err := Check() - if err == nil || strings.Contains(err.Error(), "synthetic-secret") || !strings.Contains(err.Error(), "OAC_PUBLIC_URL") { +// required sets the settings Load requires and returns a file writer. +func required(t *testing.T) func(name, content string) string { + t.Helper() + dir := t.TempDir() + write := func(name, content string) string { + path := filepath.Join(dir, name) + if err := os.WriteFile(path, []byte(content), 0o600); err != nil { + t.Fatal(err) + } + return path + } + t.Setenv("OAC_DATABASE_URL", "postgres://core@database/core") + t.Setenv("OAC_CORE_KEY_DIGESTS_FILE", write("digests.json", `["`+strings.Repeat("ab", 32)+`"]`)) + return write +} + +// rejects asserts that Load fails, names variable and does not echo secret. +func rejects(t *testing.T, variable, secret string) { + t.Helper() + _, err := Load() + if err == nil || !strings.Contains(err.Error(), variable) || (secret != "" && strings.Contains(err.Error(), secret)) { + t.Fatalf("%s: %v", variable, err) + } +} + +func TestLoadAppliesDefaults(t *testing.T) { + required(t) + c, err := Load() + if err != nil { + t.Fatal(err) + } + if c.Addr != "127.0.0.1:8091" || c.PublicOrigin != nil || c.InstallationID != "" || c.CredentialKey != nil || c.CoreKeys == nil || + c.ExecutionConcurrency != 4 || c.DefaultHarness != "codex" || strings.Join(c.Harnesses, ",") != "claude_sdk,codex,mcode" || + c.WriteAuditRetention != 90*24*time.Hour || c.OAuthTrustedOrigins != nil || c.NativeInstallers != "" || c.ProviderPaths.StateRoot != "/state" { + t.Fatalf("%+v", c) + } + if h := c.RuntimeHistory; h.File != "" || h.Endpoint != "" || h.QueueCapacity != 256 || h.Timeout != 2*time.Second || h.SampleInterval != 30*time.Second { + t.Fatalf("%+v", h) + } + t.Setenv("OAC_PROVIDER_ROOT", "/opt/oac") + if c, err = Load(); err != nil || c.ProviderPaths.ArtifactRoot != "/opt/oac" || c.NativeInstallers != "/opt/oac/native-installers" { + t.Fatal(c, err) + } +} + +func TestLoadRejectsInvalidValuesWithoutEchoingThem(t *testing.T) { + write := required(t) + t.Setenv("OAC_DATABASE_URL", "") + rejects(t, "OAC_DATABASE_URL", "") + required(t) + t.Setenv("OAC_CORE_KEY_DIGESTS_FILE", "") + rejects(t, "OAC_CORE_KEY_DIGESTS_FILE", "") + t.Setenv("OAC_CORE_KEY_DIGESTS_FILE", write("bad-digests.json", `["synthetic-secret"]`)) + rejects(t, "OAC_CORE_KEY_DIGESTS_FILE", "synthetic-secret") + required(t) + for variable, value := range map[string]string{ + "OAC_PUBLIC_URL": "https://user:synthetic-secret@core.example", + "OAC_EXECUTION_CONCURRENCY": "synthetic-secret", + "OAC_DEFAULT_HARNESS": "synthetic-secret", + "OAC_HARNESSES": "codex,synthetic-secret", + "OAC_WRITE_AUDIT_RETENTION": "synthetic-secret", + "OAC_OAUTH_TRUSTED_ORIGINS": "https://synthetic-secret.example/token", + "OAC_LOG_LEVEL": "verbose", + "OAC_INSTALLATION_ID_FILE": write("installation.id", "synthetic-secret"), + "OAC_CREDENTIAL_KEY_FILE": write("credential.key", "synthetic-secret"), + "OAC_HISTORY_SETTINGS_FILE": write("history.json", `{"secret":"synthetic-secret"}`), + } { + t.Run(variable, func(t *testing.T) { + t.Setenv(variable, value) + rejects(t, variable, "synthetic-secret") + }) + } + t.Setenv("OAC_INSTALLATION_ID_FILE", write("valid.id", "8c5f4f5e-2c55-4c43-9a49-7f2f3f2d1d10\n")) + rejects(t, "OAC_PUBLIC_URL", "") + t.Setenv("OAC_PUBLIC_URL", "https://core.example") + if c, err := Load(); err != nil || c.InstallationID != "8c5f4f5e-2c55-4c43-9a49-7f2f3f2d1d10" { + t.Fatal(c.InstallationID, err) + } +} + +func TestPublicURLMustBeACanonicalOrigin(t *testing.T) { + required(t) + for _, value := range []string{"https://core.example", "https://core.example:8443", "http://127.0.0.1:8091", "http://core.example"} { + t.Setenv("OAC_PUBLIC_URL", value) + if c, err := Load(); err != nil || c.PublicOrigin.String() != value { + t.Fatal(value, err) + } + } + for _, value := range []string{"https://core.example/", "https://Core.example", "wss://core.example", "https://core.example/v1"} { + t.Setenv("OAC_PUBLIC_URL", value) + rejects(t, "OAC_PUBLIC_URL", "") + } +} + +func TestExecutionConcurrencyAndAuditRetention(t *testing.T) { + required(t) + for _, value := range []string{"1", "1024"} { + t.Setenv("OAC_EXECUTION_CONCURRENCY", value) + if _, err := Load(); err != nil { + t.Fatal(value, err) + } + } + for _, value := range []string{"0", "-1", "1025", "1.5"} { + t.Setenv("OAC_EXECUTION_CONCURRENCY", value) + rejects(t, "OAC_EXECUTION_CONCURRENCY", "") + } + t.Setenv("OAC_EXECUTION_CONCURRENCY", "") + t.Setenv("OAC_WRITE_AUDIT_RETENTION", "24h") + if c, err := Load(); err != nil || c.WriteAuditRetention != 24*time.Hour { + t.Fatal(c.WriteAuditRetention, err) + } + for _, value := range []string{"0", "30m", "-1h", "90d"} { + t.Setenv("OAC_WRITE_AUDIT_RETENTION", value) + rejects(t, "OAC_WRITE_AUDIT_RETENTION", "") + } +} + +func TestHarnessesDefaultToEveryQualifiedHarness(t *testing.T) { + required(t) + t.Setenv("OAC_HARNESSES", "mcode") + if c, err := Load(); err != nil || strings.Join(c.Harnesses, ",") != "codex,mcode" { + t.Fatal(c.Harnesses, err) + } +} + +func TestOAuthTrustedOrigins(t *testing.T) { + required(t) + t.Setenv("OAC_OAUTH_TRUSTED_ORIGINS", "https://issuer.example, https://10.0.0.1:9443") + if c, err := Load(); err != nil || strings.Join(c.OAuthTrustedOrigins, ",") != "https://issuer.example,https://10.0.0.1:9443" { + t.Fatal(c.OAuthTrustedOrigins, err) + } + for _, value := range []string{"http://issuer.example", "https://issuer.example/token", "https://issuer.example,", "https://user:secret@issuer.example"} { + t.Setenv("OAC_OAUTH_TRUSTED_ORIGINS", value) + rejects(t, "OAC_OAUTH_TRUSTED_ORIGINS", "") + } +} + +func TestCredentialKey(t *testing.T) { + write := required(t) + t.Setenv("OAC_CREDENTIAL_KEY_FILE", filepath.Join(t.TempDir(), "missing.key")) + rejects(t, "OAC_CREDENTIAL_KEY_FILE", "") + for _, content := range []string{"", base64.StdEncoding.EncodeToString(make([]byte, 31))} { + t.Setenv("OAC_CREDENTIAL_KEY_FILE", write("short.key", content)) + rejects(t, "OAC_CREDENTIAL_KEY_FILE", "") + } + t.Setenv("OAC_CREDENTIAL_KEY_FILE", write("credential.key", base64.StdEncoding.EncodeToString(bytes.Repeat([]byte{0x91}, 32))+"\n")) + first, err := Load() + if err != nil { t.Fatal(err) } - t.Setenv("OAC_PUBLIC_URL", "") - t.Setenv("OAC_EXECUTION_CONCURRENCY", "0") - if err := Check(); err == nil || !strings.Contains(err.Error(), "OAC_EXECUTION_CONCURRENCY") || strings.Contains(err.Error(), "synthetic") { + binding := credentialcrypto.Binding{TenantID: "tenant", VaultID: "vault", CredentialID: "credential", AuthType: "static_bearer", Destination: "https://example.invalid/mcp"} + sealed, err := first.CredentialKey.Seal([]byte("opaque storage test"), binding) + if err != nil { t.Fatal(err) } - t.Setenv("OAC_EXECUTION_CONCURRENCY", "4") - t.Setenv("OAC_LOG_LEVEL", "verbose") - if err := Check(); err == nil || !strings.Contains(err.Error(), "OAC_LOG_LEVEL") { + reopened, err := Load() + if err != nil { t.Fatal(err) } + if got, err := reopened.CredentialKey.Open(sealed, binding); err != nil || string(got) != "opaque storage test" { + t.Fatal("persisted key did not recover ciphertext", err) + } +} + +func TestRuntimeHistoryFile(t *testing.T) { + write := required(t) + t.Setenv("OAC_HISTORY_SETTINGS_FILE", write("history.json", `{"transport":"otlp_http","endpoint":"http://127.0.0.1:4318/v1/metrics","insecure":true,"headers":{"X-Scope-OrgID":"operator-history"},"sample_interval_seconds":60}`)) + c, err := Load() + if err != nil || c.RuntimeHistory.Endpoint != "http://127.0.0.1:4318/v1/metrics" || c.RuntimeHistory.SampleInterval != time.Minute || c.RuntimeHistory.QueueCapacity != 256 { + t.Fatal(c.RuntimeHistory, err) + } + for name, config := range map[string]string{ + "unknown field": `{"transport":"otlp_http","endpoint":"https://collector.example.test/v1/metrics","secret":"must-not-leak"}`, + "implicit insecure": `{"transport":"otlp_http","endpoint":"http://collector.example.test/v1/metrics"}`, + "userinfo": `{"transport":"otlp_http","endpoint":"https://user:must-not-leak@collector.example.test/v1/metrics"}`, + "header newline": "{\"transport\":\"otlp_http\",\"endpoint\":\"https://collector.example.test/v1/metrics\",\"headers\":{\"Authorization\":\"Bearer must-not-leak\\n\"}}", + "reserved header": `{"transport":"otlp_http","endpoint":"https://collector.example.test/v1/metrics","headers":{"Host":"must-not-leak"}}`, + "oversized queue": `{"queue_capacity":4097}`, + "oversized timeout": `{"timeout_seconds":31}`, + "too frequent sampling": `{"sample_interval_seconds":4}`, + "oversized sampling interval": `{"sample_interval_seconds":301}`, + "transport without endpoint": `{"transport":"otlp_http"}`, + "trailing value": `{} {}`, + } { + t.Run(name, func(t *testing.T) { + t.Setenv("OAC_HISTORY_SETTINGS_FILE", write("invalid.json", config)) + if _, err := Load(); err == nil || !strings.HasPrefix(err.Error(), "OAC_HISTORY_SETTINGS_FILE: ") || strings.Contains(err.Error(), "must-not-leak") { + t.Fatal(err) + } + }) + } } func TestSettingsReportEffectiveValuesAndHideHistory(t *testing.T) { + write := required(t) t.Setenv("OAC_PUBLIC_URL", "https://core.example") t.Setenv("OAC_EXECUTION_CONCURRENCY", "8") - t.Setenv("OAC_HISTORY_SETTINGS_FILE", "/tmp/history.json") - settings, err := Settings() + t.Setenv("OAC_LOG_LEVEL", "warn") + t.Setenv("OAC_WRITE_AUDIT_RETENTION", "1440m") + t.Setenv("OAC_HISTORY_SETTINGS_FILE", write("history.json", `{}`)) + c, err := Load() if err != nil { t.Fatal(err) } found := map[string]any{} - for _, setting := range settings { + for _, setting := range c.Settings() { if setting.Sensitive && (setting.Value != nil || setting.Configured == nil) { t.Fatalf("sensitive setting %s leaked a value", setting.Key) } found[setting.Key] = setting.Value + if setting.Key == "core.write_audit_retention" && setting.Default != "2160h" { + t.Fatal(setting.Default) + } if setting.Key == "core.runtime_history" && (setting.Configured == nil || !*setting.Configured) { t.Fatal("history file was not reported as configured") } } - if found["public_url"] != "https://core.example" || found["core.execution_concurrency"] != 8 { + if found["public_url"] != "https://core.example" || found["core.execution_concurrency"] != 8 || found["log.level"] != "warn" || found["log.format"] != "auto" || found["core.write_audit_retention"] != "24h" { t.Fatal(found) } } - -func TestHarnessesDefaultToEveryQualifiedHarness(t *testing.T) { - t.Setenv("OAC_DEFAULT_HARNESS", "") - t.Setenv("OAC_HARNESSES", "") - engineName, err := DefaultHarness() - if err != nil || engineName != "codex" { - t.Fatal(engineName, err) - } - kinds, err := Harnesses(engineName) - if err != nil || strings.Join(kinds, ",") != "claude_sdk,codex,mcode" { - t.Fatal(kinds, err) - } - t.Setenv("OAC_HARNESSES", "mcode") - if kinds, err = Harnesses("codex"); err != nil || strings.Join(kinds, ",") != "codex,mcode" { - t.Fatal(kinds, err) - } - t.Setenv("OAC_HARNESSES", "unqualified") - if _, err = Harnesses("codex"); err == nil { - t.Fatal("unqualified harness enabled") - } -} diff --git a/services/core/internal/runtime/gateway.go b/services/core/internal/runtime/gateway.go index 56a4a06e1..51c5b1163 100644 --- a/services/core/internal/runtime/gateway.go +++ b/services/core/internal/runtime/gateway.go @@ -4,7 +4,6 @@ package runtime import ( "errors" "net/http" - "net/url" "github.com/go-chi/chi/v5" @@ -17,9 +16,8 @@ import ( // receipts through cancellations. Its credentials never grant public Session // API access. func NewGateway(credentials runtimegateway.RuntimeStore, heartbeat runtimegateway.HeartbeatTouch, cancellations runtimegateway.ArchivedCancellationStore, publicWSURL string) (http.Handler, *runtimegateway.Registry, error) { - u, err := url.Parse(publicWSURL) - if err != nil || credentials == nil || heartbeat == nil || cancellations == nil || (u.Scheme != "ws" && u.Scheme != "wss") || u.Hostname() == "" || u.User != nil || u.RawQuery != "" || u.Fragment != "" || u.Path != "/api/v1/agent-daemon/ws" { - return nil, nil, errors.New("daemon URL must be an absolute ws(s) URL ending in /api/v1/agent-daemon/ws") + if credentials == nil || heartbeat == nil || cancellations == nil { + return nil, nil, errors.New("daemon gateway dependencies are required") } registry := runtimegateway.NewRegistry() h := runtimegateway.NewHandler(runtimegateway.HandlerConfig{ diff --git a/services/core/internal/runtimegateway/handler.go b/services/core/internal/runtimegateway/handler.go index 291715528..62c681060 100644 --- a/services/core/internal/runtimegateway/handler.go +++ b/services/core/internal/runtimegateway/handler.go @@ -127,7 +127,7 @@ func (h *Handler) WS(w http.ResponseWriter, r *http.Request) { h.cfg.Log("agentdaemon gateway: heartbeat on connect: %v", hbErr) } } - sess := NewSession(conn, auth.DeviceID, auth.WorkspaceID, version, h.cfg.Registry, h.cfg.Log) + sess := newSession(conn, auth.DeviceID, auth.WorkspaceID, version, h.cfg.Registry, h.cfg.Log) sess.heartbeat = h.cfg.Heartbeat sess.archivedCancellations = h.cfg.ArchivedCancellations sess.credentialHash = runtimedevice.HashCredential(token) diff --git a/services/core/internal/runtimegateway/session.go b/services/core/internal/runtimegateway/session.go index f237668dc..da91e574e 100644 --- a/services/core/internal/runtimegateway/session.go +++ b/services/core/internal/runtimegateway/session.go @@ -134,16 +134,10 @@ type Session struct { closed chan struct{} } -// NewSession wires a freshly-upgraded WS connection into a Session. +// newSession wires a freshly-upgraded WS connection into a Session. // The session does NOT start its goroutines automatically — Start runs // once the handler is ready so the session can't race with response writes. -func NewSession(conn WSConn, deviceID, workspaceID, daemonVersion string, reg *Registry, log SessionLogger) *Session { - if log == nil { - log = func(string, ...any) {} - } - if reg == nil { - reg = NewRegistry() - } +func newSession(conn WSConn, deviceID, workspaceID, daemonVersion string, reg *Registry, log SessionLogger) *Session { now := time.Now() return &Session{ DeviceID: deviceID, diff --git a/services/core/internal/runtimegateway/session_test.go b/services/core/internal/runtimegateway/session_test.go index 71eaca263..d6bde659f 100644 --- a/services/core/internal/runtimegateway/session_test.go +++ b/services/core/internal/runtimegateway/session_test.go @@ -14,6 +14,17 @@ import ( "github.com/MiniMax-AI/OpenAgentCore/services/core/internal/runtimedevice" ) +// NewSession builds a Session over a test connection. +func NewSession(conn WSConn, deviceID, workspaceID, daemonVersion string, reg *Registry, log SessionLogger) *Session { + if log == nil { + log = func(string, ...any) {} + } + if reg == nil { + reg = NewRegistry() + } + return newSession(conn, deviceID, workspaceID, daemonVersion, reg, log) +} + // fakeConn is the WSConn implementation used by session + registry // tests. Concurrency-safe. type fakeConn struct { diff --git a/services/core/internal/runtimeobs/sampler.go b/services/core/internal/runtimeobs/sampler.go index 2fa81163f..b55e205db 100644 --- a/services/core/internal/runtimeobs/sampler.go +++ b/services/core/internal/runtimeobs/sampler.go @@ -38,11 +38,9 @@ type OwnershipChecker interface { } type SamplerOptions struct { - Interval time.Duration PageSize int Concurrency int SourceTimeout time.Duration - Report func(SweepResult) } // SweepResult is deliberately low-cardinality. It reports collection coverage @@ -54,21 +52,17 @@ type SweepResult struct { } type Sampler struct { - lister SessionLister - observer HistoryObserver - owner OwnershipChecker - options SamplerOptions - now func() time.Time - afterSweep func(SweepResult) + lister SessionLister + observer HistoryObserver + owner OwnershipChecker + options SamplerOptions + now func() time.Time } func NewSampler(lister SessionLister, observer HistoryObserver, owner OwnershipChecker, options SamplerOptions) (*Sampler, error) { if lister == nil || observer == nil || owner == nil { return nil, errors.New("Runtime history sampler dependencies are required") } - if options.Interval <= 0 { - return nil, errors.New("Runtime history sampler interval must be positive") - } if options.PageSize == 0 { options.PageSize = defaultSamplerPageSize } @@ -87,37 +81,12 @@ func NewSampler(lister SessionLister, observer HistoryObserver, owner OwnershipC if options.SourceTimeout < time.Millisecond || options.SourceTimeout > 30*time.Second { return nil, errors.New("Runtime history sampler source timeout is out of range") } - return &Sampler{lister: lister, observer: observer, owner: owner, options: options, now: time.Now, afterSweep: options.Report}, nil -} - -// Run performs one immediate full keyset sweep and then repeats without overlap. -// A failed sweep is isolated from execution and retried at the next interval. -func (s *Sampler) Run(ctx context.Context) error { - for { - result := s.sweep(ctx) - s.report(result) - if err := ctx.Err(); err != nil { - return err - } - timer := time.NewTimer(s.options.Interval) - select { - case <-timer.C: - case <-ctx.Done(): - timer.Stop() - return ctx.Err() - } - } -} - -func (s *Sampler) report(result SweepResult) { - if s.afterSweep == nil { - return - } - defer func() { _ = recover() }() - s.afterSweep(result) + return &Sampler{lister: lister, observer: observer, owner: owner, options: options, now: time.Now}, nil } -func (s *Sampler) sweep(ctx context.Context) (result SweepResult) { +// Sweep performs one full keyset sweep. A failed sweep is isolated from +// execution; the caller repeats sweeps without overlap. +func (s *Sampler) Sweep(ctx context.Context) (result SweepResult) { result.StartedAt = s.now() defer func() { result.CompletedAt = s.now() }() sweepCtx, cancel := context.WithCancel(ctx) diff --git a/services/core/internal/runtimeobs/sampler_test.go b/services/core/internal/runtimeobs/sampler_test.go index 9b62231eb..2f1e119e6 100644 --- a/services/core/internal/runtimeobs/sampler_test.go +++ b/services/core/internal/runtimeobs/sampler_test.go @@ -125,13 +125,13 @@ func TestSamplerSweepsEveryPageAndIsolatesSessionFailures(t *testing.T) { "session-b": {Sessions: []SessionIdentity{{TenantID: "tenant-c", SessionID: "session-c"}}}, }} observer := &samplerObserver{fail: map[string]bool{"session-b": true}} - sampler, err := NewSampler(lister, observer, samplerOwner{}, SamplerOptions{Interval: time.Minute, PageSize: 2, Concurrency: 2}) + sampler, err := NewSampler(lister, observer, samplerOwner{}, SamplerOptions{PageSize: 2, Concurrency: 2}) if err != nil { t.Fatal(err) } now := time.Date(2026, 9, 23, 4, 0, 0, 0, time.UTC) sampler.now = func() time.Time { now = now.Add(time.Second); return now } - result := sampler.sweep(t.Context()) + result := sampler.Sweep(t.Context()) if !result.Complete || result.Listed != 3 || result.Observed != 2 || result.Failed != 1 { t.Fatalf("unexpected sweep result: %+v", result) } @@ -155,11 +155,11 @@ func TestSamplerBoundsConcurrencyAndSourceDeadline(t *testing.T) { {TenantID: "t", SessionID: "1"}, {TenantID: "t", SessionID: "2"}, {TenantID: "t", SessionID: "3"}, }}, }} - sampler, err := NewSampler(lister, service, samplerOwner{}, SamplerOptions{Interval: time.Minute, Concurrency: 2, SourceTimeout: 20 * time.Millisecond}) + sampler, err := NewSampler(lister, service, samplerOwner{}, SamplerOptions{Concurrency: 2, SourceTimeout: 20 * time.Millisecond}) if err != nil { t.Fatal(err) } - result := sampler.sweep(t.Context()) + result := sampler.Sweep(t.Context()) // Source deadlines are recorded as sample_timeout observations. if !result.Complete || result.Observed != 3 || result.Failed != 0 || source.max != 2 { t.Fatalf("unexpected bounded result: result=%+v max=%d", result, source.max) @@ -168,11 +168,11 @@ func TestSamplerBoundsConcurrencyAndSourceDeadline(t *testing.T) { func TestSamplerStopsBeforeListingWithoutOwnership(t *testing.T) { lister := &samplerLister{pages: map[string]SessionPage{}} - sampler, err := NewSampler(lister, &samplerObserver{}, samplerOwner{err: errors.New("lost")}, SamplerOptions{Interval: time.Minute}) + sampler, err := NewSampler(lister, &samplerObserver{}, samplerOwner{err: errors.New("lost")}, SamplerOptions{}) if err != nil { t.Fatal(err) } - result := sampler.sweep(t.Context()) + result := sampler.Sweep(t.Context()) if result.Complete || len(lister.cursors) != 0 { t.Fatalf("sampler ran without deployment ownership: %+v %#v", result, lister.cursors) } @@ -184,83 +184,27 @@ func TestSamplerRejectsInvalidContinuationWithoutLooping(t *testing.T) { NextCursor: "different-session", }}} observer := &samplerObserver{} - sampler, err := NewSampler(lister, observer, samplerOwner{}, SamplerOptions{Interval: time.Minute}) + sampler, err := NewSampler(lister, observer, samplerOwner{}, SamplerOptions{}) if err != nil { t.Fatal(err) } - result := sampler.sweep(t.Context()) + result := sampler.Sweep(t.Context()) if result.Complete || result.Listed != 0 || len(observer.sessions) != 0 || len(lister.cursors) != 1 { t.Fatalf("invalid continuation was accepted: result=%+v sessions=%#v cursors=%#v", result, observer.sessions, lister.cursors) } } -func TestSamplerReportPanicIsIsolated(t *testing.T) { - sampler, err := NewSampler( - &samplerLister{pages: map[string]SessionPage{}}, - &samplerObserver{}, - samplerOwner{}, - SamplerOptions{Interval: time.Minute, Report: func(SweepResult) { panic("test") }}, - ) - if err != nil { - t.Fatal(err) - } - sampler.report(SweepResult{Complete: true}) -} - -func TestSamplerRunDoesNotOverlapSweepsAndStops(t *testing.T) { - started := make(chan struct{}, 1) - release := make(chan struct{}) - observer := &samplerObserver{wait: release} - lister := &samplerLister{pages: map[string]SessionPage{"": {Sessions: []SessionIdentity{{TenantID: "t", SessionID: "s"}}}}} - sampler, err := NewSampler(lister, observer, samplerOwner{}, SamplerOptions{Interval: time.Millisecond, SourceTimeout: time.Second}) - if err != nil { - t.Fatal(err) - } - sampler.afterSweep = func(SweepResult) { started <- struct{}{} } - ctx, cancel := context.WithCancel(t.Context()) - done := make(chan error, 1) - go func() { done <- sampler.Run(ctx) }() - deadline := time.After(time.Second) - for { - observer.mu.Lock() - active := observer.active - max := observer.max - observer.mu.Unlock() - if active == 1 { - if max != 1 { - t.Fatalf("overlapping sweep observed: max=%d", max) - } - break - } - select { - case <-deadline: - t.Fatal("sampler did not start") - default: - time.Sleep(time.Millisecond) - } - } - cancel() - select { - case err := <-done: - if !errors.Is(err, context.Canceled) { - t.Fatalf("unexpected sampler exit: %v", err) - } - case <-time.After(time.Second): - t.Fatal("sampler did not stop") - } -} - func TestSamplerCancelsProviderReadWhenOwnershipIsLost(t *testing.T) { release := make(chan struct{}) observer := &samplerObserver{wait: release} owner := &sequenceOwner{} lister := &samplerLister{pages: map[string]SessionPage{"": {Sessions: []SessionIdentity{{TenantID: "t", SessionID: "s"}}}}} - sampler, err := NewSampler(lister, observer, owner, SamplerOptions{Interval: time.Minute, SourceTimeout: time.Second}) + sampler, err := NewSampler(lister, observer, owner, SamplerOptions{SourceTimeout: time.Second}) if err != nil { t.Fatal(err) } done := make(chan SweepResult, 1) - go func() { done <- sampler.sweep(t.Context()) }() + go func() { done <- sampler.Sweep(t.Context()) }() deadline := time.After(time.Second) for { observer.mu.Lock() @@ -310,12 +254,12 @@ func TestSamplerPreservesProviderTimeoutAndFinalFenceAfterSlowResolution(t *test } owner := &sequenceOwner{} sampler, err := NewSampler(resolver, service, owner, SamplerOptions{ - Interval: time.Minute, SourceTimeout: 10 * time.Millisecond, + SourceTimeout: 10 * time.Millisecond, }) if err != nil { t.Fatal(err) } - result := sampler.sweep(t.Context()) + result := sampler.Sweep(t.Context()) if !result.Complete || result.Observed != 1 || result.Failed != 0 { t.Fatalf("slow-resolution sweep lost the timeout observation: %+v", result) } diff --git a/services/core/internal/sandbox/deployment_contract.go b/services/core/internal/sandbox/deployment_contract.go index 4350100d4..e79ea2f40 100644 --- a/services/core/internal/sandbox/deployment_contract.go +++ b/services/core/internal/sandbox/deployment_contract.go @@ -8,7 +8,8 @@ import ( ) // This contract owns numeric bounds, release patterns and canonical field order. -// Python installers consume its generated projection in node_spec.py. +// The node installer (node_spec.py) and the TypeScript client +// (deployment-contract.ts) consume its generated projections. const minimumDiskMiB uint32 = 1024 type resourceRule struct { @@ -45,9 +46,31 @@ var runtimeContract = []runtimeRule{ {"firmware_sha256", "[0-9a-f]{64}"}, } -// PythonDeploymentContract generates the installer projection. Struct order is -// checked before generation because it also defines Go's canonical JSON bytes. +// PythonDeploymentContract generates the installer projection. func PythonDeploymentContract(policies map[string]DeploymentPolicy) string { + raw := projectDeploymentContract(struct { + Resources []resourceRule `json:"resources"` + Runtime []runtimeRule `json:"runtime"` + Providers map[string]DeploymentPolicy `json:"providers"` + MinimumDisk uint32 `json:"minimum_disk"` + }{resourceContract, runtimeContract, policies, minimumDiskMiB}) + return "# BEGIN GENERATED DEPLOYMENT CONTRACT\n# Generated from sandbox/deployment_contract.go; do not edit.\n_CONTRACT = json.loads(" + fmt.Sprintf("%q", string(raw)) + ")\n# END GENERATED DEPLOYMENT CONTRACT" +} + +// TypeScriptDeploymentContract generates the client projection of the bounds +// and patterns; provider policies are not part of it. +func TypeScriptDeploymentContract() string { + raw := projectDeploymentContract(struct { + Resources []resourceRule `json:"resources"` + Runtime []runtimeRule `json:"runtime"` + MinimumDisk uint32 `json:"minimum_disk"` + }{resourceContract, runtimeContract, minimumDiskMiB}) + return "// Code generated by services/core/cmd/specification-contract from sandbox/deployment_contract.go; DO NOT EDIT.\n\nexport const deploymentContract = " + string(raw) + " as const;\n" +} + +// projectDeploymentContract encodes a projection. Struct order is checked +// first because it also defines Go's canonical JSON bytes. +func projectDeploymentContract(projection any) []byte { for _, item := range []struct { value any names []string @@ -64,13 +87,8 @@ func PythonDeploymentContract(policies map[string]DeploymentPolicy) string { } } } - raw, _ := json.Marshal(struct { - Resources []resourceRule `json:"resources"` - Runtime []runtimeRule `json:"runtime"` - Providers map[string]DeploymentPolicy `json:"providers"` - MinimumDisk uint32 `json:"minimum_disk"` - }{resourceContract, runtimeContract, policies, minimumDiskMiB}) - return "# BEGIN GENERATED DEPLOYMENT CONTRACT\n# Generated from sandbox/deployment_contract.go; do not edit.\n_CONTRACT = json.loads(" + fmt.Sprintf("%q", string(raw)) + ")\n# END GENERATED DEPLOYMENT CONTRACT" + raw, _ := json.Marshal(projection) + return raw } func resourceNames() []string { var names []string diff --git a/services/core/internal/sandbox/providers/deployment_contract_test.go b/services/core/internal/sandbox/providers/deployment_contract_test.go index 15e80b5e4..ea6477748 100644 --- a/services/core/internal/sandbox/providers/deployment_contract_test.go +++ b/services/core/internal/sandbox/providers/deployment_contract_test.go @@ -9,9 +9,13 @@ import ( "testing" ) -func TestInstallerDeploymentProjectionIsCurrent(t *testing.T) { +func TestDeploymentContractProjectionsAreCurrent(t *testing.T) { registry := Builtin() - raw, err := os.ReadFile("../../../../../deploy/node/node_spec.py") + python, err := os.ReadFile("../../../../../deploy/node/node_spec.py") + if err != nil { + t.Fatal(err) + } + typescript, err := os.ReadFile("../../../../../packages/agents-client/src/deployment-contract.ts") if err != nil { t.Fatal(err) } @@ -19,8 +23,8 @@ func TestInstallerDeploymentProjectionIsCurrent(t *testing.T) { if err != nil { t.Fatal(err) } - if !strings.Contains(string(raw), expected) { - t.Fatal("node_spec.py contract is stale; regenerate with go run ./services/core/cmd/specification-contract -write") + if !strings.Contains(string(python), expected) || string(typescript) != sandbox.TypeScriptDeploymentContract() { + t.Fatal("deployment contract projection is stale; regenerate with go run ./services/core/cmd/specification-contract -write") } } func TestDeploymentContractFixtures(t *testing.T) { diff --git a/services/core/internal/sessions/creation_test.go b/services/core/internal/sessions/creation_test.go index 2fa58c0da..865fee9e1 100644 --- a/services/core/internal/sessions/creation_test.go +++ b/services/core/internal/sessions/creation_test.go @@ -423,7 +423,7 @@ func TestCreateSession(t *testing.T) { } want := []string{"UpsertSession create", "LockSkills skill_a", "ReadSkillVersion skill_a 3", "SaveModelExecution https://model.example/v1", "SaveExecutionConfiguration available " + uuid.Nil.String(), "SaveInitialFiles 1", "SaveSetup skill_a@3", "CreateEnvironment", - "LoadEnvironmentInput", `CreateInputReservation [{"kind":"message","payload":{"text":"hi"}}] initial`, "LoadEnvironmentInput", + "LoadEnvironmentInput", `CreateInputReservation [{"kind":"message","payload":` + hi + `}] initial`, "LoadEnvironmentInput", "PruneChanges", "AuditCreation session:session environment:environment:session", "LoadSession"} if strings.Join(calls, "\n") != strings.Join(want, "\n") { t.Fatalf("calls:\n%s\nwant:\n%s", strings.Join(calls, "\n"), strings.Join(want, "\n")) diff --git a/services/core/internal/sessions/environment_inputs_test.go b/services/core/internal/sessions/environment_inputs_test.go index f49fd2aaf..46b8cc9f7 100644 --- a/services/core/internal/sessions/environment_inputs_test.go +++ b/services/core/internal/sessions/environment_inputs_test.go @@ -51,7 +51,7 @@ func TestJoinsActiveTurn(t *testing.T) { } func TestReserveEnvironmentInput(t *testing.T) { - const batch = `[{"kind":"message","payload":{"text":"hi"}}]` + const batch = `[{"kind":"message","payload":` + hi + `}]` find := "FindInputReservation request " + batch reserve := func(t *testing.T, tx *fakeInputTx) (EnvironmentInputReservation, error) { tx.loadEnvironmentInput = inputs() diff --git a/services/core/internal/sessions/inputs_test.go b/services/core/internal/sessions/inputs_test.go index 22930d983..d847b3f82 100644 --- a/services/core/internal/sessions/inputs_test.go +++ b/services/core/internal/sessions/inputs_test.go @@ -10,6 +10,7 @@ import ( "testing" "time" + v1 "github.com/MiniMax-AI/OpenAgentCore/contracts/agents-api/v1" "github.com/MiniMax-AI/OpenAgentCore/services/core/internal/items" ) @@ -104,11 +105,16 @@ func (s *fakeStorage) WithInputs(ctx context.Context, tenant, session string, ap // inputKey is the idempotency key of the input batches under test. const inputKey = "request" +// messageInput is a public message event with one text part, as the events +// route stores it. func messageInput(text string) Input { - payload, _ := json.Marshal(map[string]string{"text": text}) + payload, _ := json.Marshal(v1.SessionInput{Type: "agent.session.input.message", Input: []v1.InputMessage{{Role: "user", Content: []v1.InputContent{{Type: "input_text", Text: &text}}}}}) return Input{Kind: "message", Payload: payload} } +// hi is the payload of messageInput("hi") as admission normalizes it. +const hi = `{"input":[{"content":[{"text":"hi","type":"input_text"}],"role":"user"}],"type":"agent.session.input.message"}` + var cancelInput = Input{Kind: "cancel", Payload: json.RawMessage(`{}`)} // sequences is a fake CreateTurnInput that allocates sequences from first. @@ -144,7 +150,7 @@ func TestValidateInputs(t *testing.T) { } func TestValidateMessageInputs(t *testing.T) { - if _, encoded, err := validateMessageInputs([]Input{messageInput("hi")}); err != nil || string(encoded) != `[{"kind":"message","payload":{"text":"hi"}}]` { + if _, encoded, err := validateMessageInputs([]Input{messageInput("hi")}); err != nil || string(encoded) != `[{"kind":"message","payload":`+hi+`}]` { t.Fatalf("batch %s, %v", encoded, err) } for name, inputs := range map[string][]Input{"cancel": {messageInput("hi"), cancelInput}, "no inputs": nil} { @@ -205,13 +211,13 @@ func TestAdmitInput(t *testing.T) { tx := newInputTx(t) tx.loadActiveTurn, tx.createTurn, tx.appendChanges = activeTurn(nil), returns(turnWith(TurnQueued)), collect(&changes) tx.createTurnInput, tx.loadUsage = sequences(7), returns(json.RawMessage(`{}`)) - tx.loadInputSource = returns(Source{Turn: testTurn, Kind: "message", Sequence: 7, Payload: json.RawMessage(`{"text":"hi"}`)}) + tx.loadInputSource = returns(Source{Turn: testTurn, Kind: "message", Sequence: 7, Payload: messageInput("hi").Payload}) tx.loadItem, tx.putItem = returns(items.Stored{}), func(items.Change) (*int32, error) { return nil, nil } receipt, err := admitInput(t.Context(), tx, inputKey, 0, messageInput("hi")) if err != nil || receipt != (InputReceipt{Sequence: 7, TurnID: testTurn}) { t.Fatalf("receipt %+v, %v", receipt, err) } - item := items.Identity(testTurn, "input:7") + item := items.Identity(testTurn, "input:7:0") assertCalls(t, tx.fakeTx, "LoadActiveTurn", "CreateTurn", "AppendChanges agent.session.turn.created", "CreateTurnInput "+testTurn+" request 0 message", "LoadInputSource 7", "LoadItem "+testTurn+" "+item, "PutItem "+testTurn+" "+item, "AppendChanges agent.session.turn.item.added", "LoadUsage", "AppendChanges agent.session.in_progress") @@ -255,7 +261,7 @@ func TestAdmitInput(t *testing.T) { } func TestSubmitInputs(t *testing.T) { - const batch = `[{"kind":"message","payload":{"text":"hi"}},{"kind":"cancel","payload":{}}]` + const batch = `[{"kind":"message","payload":` + hi + `},{"kind":"cancel","payload":{}}]` inputs := []Input{messageInput("hi"), cancelInput} running := turnWith(TurnInProgress) submit := func(t *testing.T, tx *fakeInputTx, inputs []Input) ([]InputReceipt, error) { diff --git a/services/core/internal/sessions/subagents.go b/services/core/internal/sessions/subagents.go index ba982ab0b..14327ff31 100644 --- a/services/core/internal/sessions/subagents.go +++ b/services/core/internal/sessions/subagents.go @@ -346,7 +346,7 @@ func projectSubagentItem(ctx context.Context, tx ProjectionTx, raw json.RawMessa // the Session stream carries root work, and child history is read through the // Subagent routes. func putChildItem(ctx context.Context, tx SubagentProjectionTx, turn ChildTurn, position int32, item v1.Item) error { - payload, err := item.MarshalStored() + payload, err := json.Marshal(item) if err != nil { return err } 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/fixtures/main.go b/services/core/tests/fixtures/main.go index b55810eca..c97d6f33b 100644 --- a/services/core/tests/fixtures/main.go +++ b/services/core/tests/fixtures/main.go @@ -58,7 +58,7 @@ func seed() error { return err } for _, status := range []string{sessions.TurnCompleted, sessions.TurnFailed, sessions.TurnCancelled, sessions.TurnInProgress} { - receipts, err := service.SubmitInputs(ctx, f.Tenant, f.Session, uuid.NewString(), []sessions.Input{{Kind: "message", Payload: json.RawMessage(`{"text":"recovery fixture"}`)}}) + receipts, err := service.SubmitInputs(ctx, f.Tenant, f.Session, uuid.NewString(), []sessions.Input{{Kind: "message", Payload: json.RawMessage(`{"type":"agent.session.input.message","input":[{"role":"user","content":[{"type":"input_text","text":"recovery fixture"}]}]}`)}}) if err != nil { return err } 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/admin_session_archive_race_test.go b/services/core/tests/integration/admin_session_archive_race_test.go index 2bd929df6..e973a7c72 100644 --- a/services/core/tests/integration/admin_session_archive_race_test.go +++ b/services/core/tests/integration/admin_session_archive_race_test.go @@ -1,7 +1,6 @@ package integration import ( - "encoding/json" "errors" "strings" "sync" @@ -59,7 +58,7 @@ func TestManagedSessionArchiveOrdersConcurrentInput(t *testing.T) { go func() { defer wg.Done() <-start - _, err := sessionService(t, s).ReserveEnvironmentInput(t.Context(), tenant, session.ID, "racing-input", []sessions.Input{{Kind: "message", Payload: json.RawMessage(`{"text":"racing"}`)}}) + _, err := sessionService(t, s).ReserveEnvironmentInput(t.Context(), tenant, session.ID, "racing-input", []sessions.Input{messageInput("racing")}) if err != nil && !errors.Is(err, sessions.ErrEnvironmentUnavailable) { t.Error(err) } diff --git a/services/core/tests/integration/admin_session_archive_test.go b/services/core/tests/integration/admin_session_archive_test.go index df75725eb..846c69b12 100644 --- a/services/core/tests/integration/admin_session_archive_test.go +++ b/services/core/tests/integration/admin_session_archive_test.go @@ -85,7 +85,7 @@ func archiveAllocation(t *testing.T, w *Store, tenant string, session sessions.S func TestManagedSessionArchiveUnallocatedAndGuards(t *testing.T) { s, w, installation := managedArchiveFixture(t) input := managerSessionInput(uuid.NewString()) - input.InitialInputs = []sessions.Input{{Kind: "message", Payload: json.RawMessage(`{"text":"waiting"}`)}} + input.InitialInputs = []sessions.Input{messageInput("waiting")} tenant, session := managedArchiveSession(t, s, input) ctx := adminDeleteContext(t.Context(), tenant, uuid.NewString()) active, err := sessionAdapter(s).GetManagedSessionArchive(t.Context(), tenant, session.ID) @@ -135,7 +135,7 @@ func TestManagedSessionArchiveUnallocatedAndGuards(t *testing.T) { if _, err := deploymentExecution(t, w).ReserveAllocation(t.Context(), deployment.AllocationKey{TenantID: tenant, EnvironmentID: session.Environment.ID}, installation, runtimedevice.HashCredential(uuid.NewString())); !errors.Is(err, deployment.ErrInvalidInput) { t.Fatal("archived Environment allocated after archive", err) } - if _, err := sessionService(t, s).ReserveEnvironmentInput(t.Context(), tenant, session.ID, "later", []sessions.Input{{Kind: "message", Payload: json.RawMessage(`{"text":"later"}`)}}); !errors.Is(err, sessions.ErrEnvironmentUnavailable) { + if _, err := sessionService(t, s).ReserveEnvironmentInput(t.Context(), tenant, session.ID, "later", []sessions.Input{messageInput("later")}); !errors.Is(err, sessions.ErrEnvironmentUnavailable) { t.Fatal("archived Environment accepted new input", err) } view, err := deploymentService(t, s).View(t.Context()) diff --git a/services/core/tests/integration/admin_session_archive_worker_http_test.go b/services/core/tests/integration/admin_session_archive_worker_http_test.go index 134fd92d0..00f830760 100644 --- a/services/core/tests/integration/admin_session_archive_worker_http_test.go +++ b/services/core/tests/integration/admin_session_archive_worker_http_test.go @@ -94,7 +94,7 @@ func TestAdminSessionArchiveWorkerHTTPPostgres(t *testing.T) { if err != nil { t.Fatal(err) } - input, err := sendMessage(t.Context(), s, project.TenantID, active.ID, "pending-turn", json.RawMessage(`{"text":"pending"}`)) + input, err := sendMessage(t.Context(), s, project.TenantID, active.ID, "pending-turn", messageText("pending")) if err != nil { t.Fatal(err) } diff --git a/services/core/tests/integration/archive_cancellation_test.go b/services/core/tests/integration/archive_cancellation_test.go index 0b64fef50..820ffb596 100644 --- a/services/core/tests/integration/archive_cancellation_test.go +++ b/services/core/tests/integration/archive_cancellation_test.go @@ -123,7 +123,7 @@ func TestArchiveWaitingCancellationReceipts(t *testing.T) { } time.Sleep(time.Millisecond) } - pending, err := sessionService(t, s).ReserveEnvironmentInput(t.Context(), h.tenant, session.ID, "pending", []sessions.Input{{Kind: "message", Payload: json.RawMessage(`{"text":"first"}`)}, {Kind: "message", Payload: json.RawMessage(`{"text":"second"}`)}}) + pending, err := sessionService(t, s).ReserveEnvironmentInput(t.Context(), h.tenant, session.ID, "pending", []sessions.Input{messageInput("first"), messageInput("second")}) if err != nil { t.Fatal(err) } diff --git a/services/core/tests/integration/claude_execution_test.go b/services/core/tests/integration/claude_execution_test.go index 648969f7c..c5c718d74 100644 --- a/services/core/tests/integration/claude_execution_test.go +++ b/services/core/tests/integration/claude_execution_test.go @@ -139,7 +139,7 @@ func TestClaudeInvalidImageResultRejectsWholeBatchBeforePersistence(t *testing.T } return sessions.Input{Kind: "tool_result", Payload: payload} } - batch := []sessions.Input{{Kind: "message", Payload: json.RawMessage(`{"text":"Follow up"}`)}, result(`{"success":true,"output":[{"type":"input_image","image_url":"data:image/png;base64,AA=="}]}`), {Kind: "cancel", Payload: json.RawMessage(`{}`)}} + batch := []sessions.Input{messageInput("Follow up"), result(`{"success":true,"output":[{"type":"input_image","image_url":"data:image/png;base64,AA=="}]}`), {Kind: "cancel", Payload: json.RawMessage(`{}`)}} if _, err := worker.SubmitInputs(t.Context(), h.tenant, h.session.ID, "batch", batch); !errors.Is(err, sessions.ErrInvalidInput) { t.Fatal(err) } diff --git a/services/core/tests/integration/command_output_test.go b/services/core/tests/integration/command_output_test.go index 01406737e..647d05650 100644 --- a/services/core/tests/integration/command_output_test.go +++ b/services/core/tests/integration/command_output_test.go @@ -21,7 +21,7 @@ func TestCommandOutputCommitsFragmentsSnapshotsAndRecovery(t *testing.T) { if err != nil { t.Fatal(err) } - input, err := sendMessage(ctx, s, tenant, session.ID, "start", json.RawMessage(`{"text":"run commands"}`)) + input, err := sendMessage(ctx, s, tenant, session.ID, "start", messageText("run commands")) if err != nil { t.Fatal(err) } diff --git a/services/core/tests/integration/creation_stream_settlement_public_test.go b/services/core/tests/integration/creation_stream_settlement_public_test.go index 63e64c863..954300f88 100644 --- a/services/core/tests/integration/creation_stream_settlement_public_test.go +++ b/services/core/tests/integration/creation_stream_settlement_public_test.go @@ -170,7 +170,7 @@ func TestCreationStreamPublicLifetimes(t *testing.T) { created.ended(t, 5*time.Second) connect(first.Session.Environment.ID) - if _, err := sessionService(t, s).ReserveEnvironmentInput(t.Context(), tenant, first.Session.ID, "later", []sessions.Input{{Kind: "message", Payload: json.RawMessage(`{"text":"later"}`)}}); err != nil { + if _, err := sessionService(t, s).ReserveEnvironmentInput(t.Context(), tenant, first.Session.ID, "later", []sessions.Input{messageInput("later")}); err != nil { t.Fatal(err) } if current, err := sessionAdapter(s).GetSession(t.Context(), tenant, first.Session.ID); err != nil || !current.PendingInput { diff --git a/services/core/tests/integration/deployment_model_providers_http_test.go b/services/core/tests/integration/deployment_model_providers_http_test.go index 21e30656b..d928f7c26 100644 --- a/services/core/tests/integration/deployment_model_providers_http_test.go +++ b/services/core/tests/integration/deployment_model_providers_http_test.go @@ -4,24 +4,19 @@ import ( "bytes" "context" "encoding/json" - "errors" "net/http/httptest" "strings" "testing" - "time" v1 "github.com/MiniMax-AI/OpenAgentCore/contracts/agents-api/v1" - "github.com/MiniMax-AI/OpenAgentCore/internal/agentdaemon/proto" "github.com/MiniMax-AI/OpenAgentCore/services/core/internal/adminaudit" "github.com/MiniMax-AI/OpenAgentCore/services/core/internal/api" "github.com/MiniMax-AI/OpenAgentCore/services/core/internal/credentialcrypto" - "github.com/MiniMax-AI/OpenAgentCore/services/core/internal/execution" "github.com/MiniMax-AI/OpenAgentCore/services/core/internal/modelconfiguration" "github.com/MiniMax-AI/OpenAgentCore/services/core/internal/persistence/postgres/auditpg" "github.com/MiniMax-AI/OpenAgentCore/services/core/internal/persistence/postgres/modelconfigurationpg" "github.com/MiniMax-AI/OpenAgentCore/services/core/internal/persistence/postgres/pgunit" "github.com/MiniMax-AI/OpenAgentCore/services/core/internal/runtimedevice" - "github.com/MiniMax-AI/OpenAgentCore/services/core/internal/sessions" "github.com/google/uuid" ) @@ -230,61 +225,6 @@ func TestDeploymentModelProvidersHTTP(t *testing.T) { } } -// A hosted or self-hosted Session created before providers were required has -// no frozen provider: new work is rejected before anything is queued, and input -// reserved before the upgrade fails with that reason instead of waiting. -func TestLegacySessionWithoutProviderCannotStartWork(t *testing.T) { - h := newDispatchHarnessForSession(t, []byte(`{"agent":{"model":"test-model"},"environment":{"type":"self_hosted","workspace_directory":"/workspace"}}`), true) - legacy, err := h.s.CreateSession(t.Context(), h.tenant, sessions.CreateSession{Creator: FixtureCreator(), Engine: "codex", IdempotencyKey: uuid.NewString(), - Configuration: []byte(`{"agent":{"model":"test-model"},"environment":{"type":"self_hosted","workspace_directory":"/workspace"}}`)}) - if err != nil { - t.Fatal(err) - } - executor := connectFixtureRuntime(t, h, legacy) - // Reserved directly, as a pre-upgrade Core did. - pending, err := sessionService(t, h.s).ReserveEnvironmentInput(t.Context(), h.tenant, legacy.ID, "before-upgrade", []sessions.Input{{Kind: "message", Payload: json.RawMessage(`{"text":"old"}`)}}) - if err != nil { - t.Fatal(err) - } - worker, stop := startEnvironmentExpiryWorker(t, h.s, h.d) - defer stop() - _, pool := testStore(t) - reservations := func() int { - t.Helper() - var count int - if err := pool.QueryRow(t.Context(), "SELECT count(*) FROM environment_input_reservations WHERE session_id=$1", legacy.ID).Scan(&count); err != nil { - t.Fatal(err) - } - return count - } - before := reservations() - message := []sessions.Input{{Kind: "message", Payload: json.RawMessage(`{"text":"start"}`)}} - if _, err := worker.SubmitInputs(t.Context(), h.tenant, legacy.ID, uuid.NewString(), message); !errors.Is(err, execution.ErrModelProviderRequired) { - t.Fatal("provider-free Session accepted work", err) - } - if after := reservations(); after != before { - t.Fatal("rejected work was queued", before, after) - } - awaitDaemonRemoteCondition(t, t.Context(), 5*time.Second, "legacy reservation settled", func() bool { - got, err := sessionAdapter(h.s).GetEnvironmentInputReservation(t.Context(), h.tenant, legacy.ID, pending.ID) - return err == nil && got.State == sessions.EnvironmentInputFailed - }) - session, err := sessionAdapter(h.s).GetSession(t.Context(), h.tenant, legacy.ID) - if err != nil || session.EnvironmentInputActivity == nil || session.EnvironmentInputActivity.Failure != "model_provider_required" { - t.Fatal("legacy reservation did not fail with its reason", session.EnvironmentInputActivity, err) - } - _ = executor.conn.SetReadDeadline(time.Now().Add(500 * time.Millisecond)) - for { - var frame proto.Envelope - if executor.conn.ReadJSON(&frame) != nil { - break - } - if frame.Type == proto.TypeExecutionPrepare { - t.Fatal("provider-free work reached the executor", frame.Type) - } - } -} - // A none Session may freeze the deployment default, so its caller intent is // recorded first: a same-key retry returns the committed Session after the // default was replaced or removed. diff --git a/services/core/tests/integration/dispatch_test.go b/services/core/tests/integration/dispatch_test.go index 35c0383a9..d60bedf1c 100644 --- a/services/core/tests/integration/dispatch_test.go +++ b/services/core/tests/integration/dispatch_test.go @@ -125,8 +125,7 @@ func newDispatchHarnessForSession(t *testing.T, configuration []byte, local bool func (h *dispatchHarness) message(key, text string) sessions.InputReceipt { h.t.Helper() - body, _ := json.Marshal(map[string]string{"text": text}) - r, err := sendMessage(context.Background(), h.s, h.tenant, h.session.ID, key, body) + r, err := sendMessage(context.Background(), h.s, h.tenant, h.session.ID, key, messageText(text)) if err != nil { h.t.Fatal(err) } diff --git a/services/core/tests/integration/environment_admission_test.go b/services/core/tests/integration/environment_admission_test.go index 27e191352..cfbc778fb 100644 --- a/services/core/tests/integration/environment_admission_test.go +++ b/services/core/tests/integration/environment_admission_test.go @@ -51,7 +51,7 @@ func submitEnvironmentAdmission(ctx context.Context, h *dispatchHarness, worker } func environmentAdmissionInputs() []sessions.Input { - return []sessions.Input{{Kind: "message", Payload: json.RawMessage(`{"text":"first"}`)}, {Kind: "message", Payload: json.RawMessage(`{"text":"second"}`)}} + return []sessions.Input{messageInput("first"), messageInput("second")} } func awaitEnvironmentAdmission(t *testing.T, result <-chan environmentAdmissionResult) environmentAdmissionResult { diff --git a/services/core/tests/integration/environment_directory_active_test.go b/services/core/tests/integration/environment_directory_active_test.go index 96696846f..6d63a0c74 100644 --- a/services/core/tests/integration/environment_directory_active_test.go +++ b/services/core/tests/integration/environment_directory_active_test.go @@ -10,7 +10,7 @@ import ( func TestEnvironmentDirectoryActiveRunUsesExistingOwner(t *testing.T) { h, w, environment := directoryWorker(t) awaitFixtureCapabilities(t, h, workerEnvironmentCapabilities()) - pending, err := sessionService(t, h.s).ReserveEnvironmentInput(t.Context(), h.tenant, h.session.ID, "execute", []sessions.Input{{Kind: "message", Payload: []byte(`{"text":"work"}`)}}) + pending, err := sessionService(t, h.s).ReserveEnvironmentInput(t.Context(), h.tenant, h.session.ID, "execute", []sessions.Input{messageInput("work")}) if err != nil { t.Fatal(err) } diff --git a/services/core/tests/integration/environment_expiry_dispatch_test.go b/services/core/tests/integration/environment_expiry_dispatch_test.go index 623fe0ea5..4bc66594b 100644 --- a/services/core/tests/integration/environment_expiry_dispatch_test.go +++ b/services/core/tests/integration/environment_expiry_dispatch_test.go @@ -1,7 +1,6 @@ package integration import ( - "encoding/json" "testing" "github.com/MiniMax-AI/OpenAgentCore/internal/agentdaemon/proto" @@ -24,7 +23,7 @@ func TestWorkerEnvironmentExpiryAtFullExecutionCapacity(t *testing.T) { var active []sessions.Session for _, key := range []string{"one", "two", "three", "four"} { session := publicSession(t, h, key) - if _, err := worker.SubmitInputs(t.Context(), h.tenant, session.ID, key, []sessions.Input{{Kind: "message", Payload: json.RawMessage(`{"text":"remain active"}`)}}); err != nil { + if _, err := worker.SubmitInputs(t.Context(), h.tenant, session.ID, key, []sessions.Input{messageInput("remain active")}); err != nil { t.Fatal(err) } requests = append(requests, h.read(testExecutionRequest)) @@ -66,7 +65,7 @@ func TestWorkerEnvironmentExpirySkipsBusySessionAndAllowsDispatch(t *testing.T) } worker, stop := startEnvironmentExpiryWorker(t, h.s, h.d) h.session = publicSession(t, h, "unrelated") - receipt, err := worker.SubmitInputs(t.Context(), h.tenant, h.session.ID, "work", []sessions.Input{{Kind: "message", Payload: json.RawMessage(`{"text":"make normal progress"}`)}}) + receipt, err := worker.SubmitInputs(t.Context(), h.tenant, h.session.ID, "work", []sessions.Input{messageInput("make normal progress")}) if err != nil { t.Fatal(err) } diff --git a/services/core/tests/integration/environment_expiry_worker_test.go b/services/core/tests/integration/environment_expiry_worker_test.go index a7d8b4fdd..04838a170 100644 --- a/services/core/tests/integration/environment_expiry_worker_test.go +++ b/services/core/tests/integration/environment_expiry_worker_test.go @@ -25,7 +25,7 @@ func newEnvironmentExpiryReservation(t *testing.T, s *Store) (string, sessions.E if err != nil { t.Fatal(err) } - pending, err := sessionService(t, s).ReserveEnvironmentInput(t.Context(), tenant, session.ID, "pending", []sessions.Input{{Kind: "message", Payload: json.RawMessage(`{"text":"wait for the environment"}`)}}) + pending, err := sessionService(t, s).ReserveEnvironmentInput(t.Context(), tenant, session.ID, "pending", []sessions.Input{messageInput("wait for the environment")}) if err != nil { t.Fatal(err) } diff --git a/services/core/tests/integration/environment_initial_input_test.go b/services/core/tests/integration/environment_initial_input_test.go index 5bd57dfff..da0f37d64 100644 --- a/services/core/tests/integration/environment_initial_input_test.go +++ b/services/core/tests/integration/environment_initial_input_test.go @@ -105,9 +105,11 @@ func TestEnvironmentInitialInputCreationRetainsCursorIdentityAndPromotion(t *tes t.Fatal("creation snapshot differs from the committed projection", session.EnvironmentInputActivity, session.PendingInput) } reservation := initialEnvironmentReservation(t, s, pool, tenant, session.ID) - storedBatch, marshalErr := json.Marshal(reservation.Inputs) - originalBatch, _ := json.Marshal(input.InitialInputs) - if marshalErr != nil || reservation.State != sessions.EnvironmentInputPending || reservation.Deadline.Sub(reservation.CreatedAt) != 5*time.Minute || string(storedBatch) != string(originalBatch) { + // jsonb keeps the batch's JSON value, not its key order. + var storedBatch, originalBatch any + stored, marshalErr := json.Marshal(reservation.Inputs) + original, _ := json.Marshal(input.InitialInputs) + if marshalErr != nil || json.Unmarshal(stored, &storedBatch) != nil || json.Unmarshal(original, &originalBatch) != nil || reservation.State != sessions.EnvironmentInputPending || reservation.Deadline.Sub(reservation.CreatedAt) != 5*time.Minute || !reflect.DeepEqual(storedBatch, originalBatch) { t.Fatal("initial batch/deadline changed", reservation) } environmentInputHistory(t, pool, session.ID, 0, 0) diff --git a/services/core/tests/integration/environment_initial_public_test.go b/services/core/tests/integration/environment_initial_public_test.go index a3b47fa76..30559672c 100644 --- a/services/core/tests/integration/environment_initial_public_test.go +++ b/services/core/tests/integration/environment_initial_public_test.go @@ -31,7 +31,7 @@ func TestEnvironmentInitialFailureOfficialClient(t *testing.T) { configuration := json.RawMessage(`{"agent":{"id":"agent_initial_failure","model":"fixture","tools":[],"multi_agent":{"enabled":false,"max_concurrent_subagents":null},"reasoning":{},"service_tier":"auto","text":{"format":{"type":"text"},"verbosity":"medium"}},"environment":{"type":"self_hosted","workspace_directory":"/workspace"}}`) session, err := s.CreateSession(t.Context(), tenant, sessions.CreateSession{ Creator: FixtureCreator(), Engine: "codex", IdempotencyKey: "initial", Configuration: configuration, - InitialInputs: []sessions.Input{{Kind: "message", Payload: json.RawMessage(`{"text":"private-input-marker"}`)}}, + InitialInputs: []sessions.Input{messageInput("private-input-marker")}, }) if err != nil { t.Fatal(err) diff --git a/services/core/tests/integration/environment_worker_helpers_test.go b/services/core/tests/integration/environment_worker_helpers_test.go index d587a3ca4..3ddd0529c 100644 --- a/services/core/tests/integration/environment_worker_helpers_test.go +++ b/services/core/tests/integration/environment_worker_helpers_test.go @@ -47,7 +47,7 @@ func unboundWorkerEnvironmentReservation(t *testing.T, h *dispatchHarness) sessi if err != nil { t.Fatal(err) } - pending, err := sessionService(t, h.s).ReserveEnvironmentInput(t.Context(), h.tenant, session.ID, "work", []sessions.Input{{Kind: "message", Payload: json.RawMessage(`{"text":"first"}`)}}) + pending, err := sessionService(t, h.s).ReserveEnvironmentInput(t.Context(), h.tenant, session.ID, "work", []sessions.Input{messageInput("first")}) if err != nil { t.Fatal(err) } diff --git a/services/core/tests/integration/environments_test.go b/services/core/tests/integration/environments_test.go index ee0c7a98f..430b829a1 100644 --- a/services/core/tests/integration/environments_test.go +++ b/services/core/tests/integration/environments_test.go @@ -189,7 +189,7 @@ func TestEnvironmentCreationFailureRollsBackAllResources(t *testing.T) { constraint := "environment_failure_" + strings.ReplaceAll(marker, "-", "") table, expression := "environments", "status <> 'pending'" if phase == "input" { - table, expression = "environment_input_reservations", "NOT (batch @> '[{\"payload\":{\"text\":\""+marker+"\"}}]'::jsonb)" + table, expression = "environment_input_reservations", "NOT (batch @> '[{\"payload\":{\"input\":[{\"content\":[{\"text\":\""+marker+"\"}]}]}}]'::jsonb)" } if phase == "activity" { table, expression = "session_events", "NOT (payload ? 'environment_input_activity')" diff --git a/services/core/tests/integration/execution_test.go b/services/core/tests/integration/execution_test.go index 0be73a2e7..8e73078db 100644 --- a/services/core/tests/integration/execution_test.go +++ b/services/core/tests/integration/execution_test.go @@ -276,7 +276,7 @@ func TestExecutionWriterSerializesWritesOnItsLease(t *testing.T) { ctx, cancel := context.WithTimeout(t.Context(), time.Second) defer cancel() task := tasks[0] - _, err := sendMessage(ctx, s, task.tenant, task.session, "public", json.RawMessage(`{"text":"additional"}`)) + _, err := sendMessage(ctx, s, task.tenant, task.session, "public", messageText("additional")) close(release) if err != nil { t.Fatal("public admission used owner gate", err) diff --git a/services/core/tests/integration/function_input_execution_test.go b/services/core/tests/integration/function_input_execution_test.go index 63dbe8d17..3d5aeeafa 100644 --- a/services/core/tests/integration/function_input_execution_test.go +++ b/services/core/tests/integration/function_input_execution_test.go @@ -18,7 +18,7 @@ func TestExecutionFunctionInputBatchStillSteersMessages(t *testing.T) { h.write(input.TurnID, proto.TypeFunctionCall, proto.FunctionCallPayload{CallID: "a", Name: "lookup_ticket", Arguments: json.RawMessage(`{}`)}) state := functionState(t, h, 1) raw, _ := json.Marshal(sessions.FunctionResultInput{TurnID: input.TurnID, CallID: state.RequiredActions[0].CallID, Result: json.RawMessage(`{"success":true,"output":"answer"}`)}) - batch := []sessions.Input{{Kind: "tool_result", Payload: raw}, {Kind: "message", Payload: json.RawMessage(`{"text":"Follow up"}`)}} + batch := []sessions.Input{{Kind: "tool_result", Payload: raw}, messageInput("Follow up")} receipts, err := submitInputs(t.Context(), h.s, h.tenant, h.session.ID, "mixed", batch) if err != nil { t.Fatal(err) diff --git a/services/core/tests/integration/function_inputs_public_test.go b/services/core/tests/integration/function_inputs_public_test.go index 486a53276..5b512cffe 100644 --- a/services/core/tests/integration/function_inputs_public_test.go +++ b/services/core/tests/integration/function_inputs_public_test.go @@ -33,7 +33,7 @@ func TestFunctionInputsOfficialClientAtomicAdmission(t *testing.T) { if err != nil { t.Fatal(err) } - input, err := sendMessage(ctx, s, tenant, session.ID, "start", json.RawMessage(`{"text":"fixture"}`)) + input, err := sendMessage(ctx, s, tenant, session.ID, "start", messageText("fixture")) if err != nil { t.Fatal(err) } @@ -110,7 +110,7 @@ func TestFunctionInputsOfficialClientAtomicAdmission(t *testing.T) { if _, err := transitionTurn(ctx, s, tenant, session.ID, input.TurnID, sessions.TurnTransition{ExpectedStatus: sessions.TurnWaiting, Status: sessions.TurnFailed}); err != nil { t.Fatal(err) } - next, err := sendMessage(ctx, s, tenant, session.ID, "next", json.RawMessage(`{"text":"next"}`)) + next, err := sendMessage(ctx, s, tenant, session.ID, "next", messageText("next")) if err != nil { t.Fatal(err) } diff --git a/services/core/tests/integration/function_inputs_test.go b/services/core/tests/integration/function_inputs_test.go index 435e1579f..8046aa039 100644 --- a/services/core/tests/integration/function_inputs_test.go +++ b/services/core/tests/integration/function_inputs_test.go @@ -39,7 +39,7 @@ func TestFunctionInputBatchesPersistAndReplayWithoutRetargeting(t *testing.T) { s, pool := testStore(t) tenant, session, turn := functionInputFixture(t, s, functionExecution(t)) full := `{"success":false,"output":[{"type":"input_text","text":""},{"type":"input_image","image_url":"data:image/png;base64,AA=="},{"type":"input_text","text":"after"}],"error":"failed"}` - batch := []sessions.Input{resultInput(t, turn, "a", full), {Kind: "message", Payload: json.RawMessage(`{"text":"Follow up"}`)}, resultInput(t, turn, "b", `{"success":true,"output":null,"error":null}`), {Kind: "cancel", Payload: json.RawMessage(`{}`)}} + batch := []sessions.Input{resultInput(t, turn, "a", full), messageInput("Follow up"), resultInput(t, turn, "b", `{"success":true,"output":null,"error":null}`), {Kind: "cancel", Payload: json.RawMessage(`{}`)}} receipts, err := submitInputs(t.Context(), s, tenant, session.ID, "batch", batch) if err != nil || len(receipts) != 4 { t.Fatal(receipts, err) @@ -105,7 +105,7 @@ func TestFunctionInputBatchFailureRollsBackEveryWrite(t *testing.T) { s, _ := testStore(t) functions := functionExecution(t) tenant, session, turn := functionInputFixture(t, s, functions) - message := sessions.Input{Kind: "message", Payload: json.RawMessage(`{"text":"Must roll back"}`)} + message := messageInput("Must roll back") cancel := sessions.Input{Kind: "cancel", Payload: json.RawMessage(`{}`)} first := resultInput(t, turn, "a", `{"success":true}`) batch := []sessions.Input{message, first, cancel} @@ -165,7 +165,7 @@ func TestFunctionInputConcurrentBatchesSelectOneResult(t *testing.T) { var wg sync.WaitGroup results := make(chan error, 2) for i := range 2 { - batch := []sessions.Input{{Kind: "message", Payload: json.RawMessage(fmt.Sprintf(`{"text":"message-%d"}`, i))}, resultInput(t, turn, "a", fmt.Sprintf(`{"success":true,"output":"%d"}`, i))} + batch := []sessions.Input{{Kind: "message", Payload: messageText(fmt.Sprintf("message-%d", i))}, resultInput(t, turn, "a", fmt.Sprintf(`{"success":true,"output":"%d"}`, i))} wg.Add(1) go func() { defer wg.Done() diff --git a/services/core/tests/integration/function_item_events_test.go b/services/core/tests/integration/function_item_events_test.go index 7fca61f85..edd9d16d7 100644 --- a/services/core/tests/integration/function_item_events_test.go +++ b/services/core/tests/integration/function_item_events_test.go @@ -64,7 +64,7 @@ func TestFunctionResultItemsRetainSubmittedFields(t *testing.T) { `{"success":false,"output":[{"type":"input_text","text":"before"},{"type":"input_image","image_url":"data:image/png;base64,AA=="}],"error":"failure"}`, } { t.Run(raw, func(t *testing.T) { - s, pool := testStore(t) + s, _ := testStore(t) functions := functionExecution(t) tenant, session := newTurnSession(t, s) turn := submitMessage(t, s, tenant, session.ID, "start").TurnID @@ -119,19 +119,6 @@ func TestFunctionResultItemsRetainSubmittedFields(t *testing.T) { results++ } } - // The stored payload keeps the submitted field presence. - var stored map[string]any - if err := pool.QueryRow(t.Context(), `SELECT payload FROM session_items WHERE turn_id = $1 AND payload->>'type' = 'function_call_output'`, turn).Scan(&stored); err != nil { - t.Fatal(err) - } - var submitted map[string]any - _ = json.Unmarshal([]byte(raw), &submitted) - for _, field := range []string{"output", "error"} { - _, present := submitted[field] - if _, exists := stored[field]; present != exists { - t.Fatalf("stored %s presence changed: %v", field, stored) - } - } if results != 2 { t.Fatal("missing saved or streamed result", results) } diff --git a/services/core/tests/integration/function_state_public_test.go b/services/core/tests/integration/function_state_public_test.go index 7c1b84cd7..538ccda86 100644 --- a/services/core/tests/integration/function_state_public_test.go +++ b/services/core/tests/integration/function_state_public_test.go @@ -30,7 +30,7 @@ func TestFunctionStateOfficialClientReadsAndLiveEvents(t *testing.T) { if err != nil { t.Fatal(err) } - input, err := sendMessage(ctx, s, tenant, session.ID, "start", json.RawMessage(`{"text":"fixture"}`)) + input, err := sendMessage(ctx, s, tenant, session.ID, "start", messageText("fixture")) if err != nil { t.Fatal(err) } diff --git a/services/core/tests/integration/hosted_initialization_failure_public_test.go b/services/core/tests/integration/hosted_initialization_failure_public_test.go index ee461f4e5..543f11500 100644 --- a/services/core/tests/integration/hosted_initialization_failure_public_test.go +++ b/services/core/tests/integration/hosted_initialization_failure_public_test.go @@ -220,7 +220,7 @@ func TestHostedInitializationFailureRecordsSafeSessionFailure(t *testing.T) { !last.EnvironmentFailure.FailedAt.Equal(read.EnvironmentFailure.FailedAt) || last.EnvironmentInputActivity != nil || !last.Settled { t.Fatal("failed snapshot", last) } - if _, err := sessionService(t, s).ReserveEnvironmentInput(t.Context(), tenant, session.ID, "later", []sessions.Input{{Kind: "message", Payload: json.RawMessage(`{"text":"later"}`)}}); !errors.Is(err, sessions.ErrHostedEnvironmentFailed) { + if _, err := sessionService(t, s).ReserveEnvironmentInput(t.Context(), tenant, session.ID, "later", []sessions.Input{messageInput("later")}); !errors.Is(err, sessions.ErrHostedEnvironmentFailed) { t.Fatal("failed hosted Environment admitted input", err) } raw, _ := json.Marshal(events) @@ -249,7 +249,7 @@ func TestHostedInitializationFailureSettlesPendingInitialInput(t *testing.T) { tenant := uuid.NewString() session, environment := hostedFailureSession(t, s, tenant, sessions.CreateSession{ Initialization: environmentconfig.Setup{Commands: []environmentconfig.SetupCommand{{Command: "exit 3"}}}, - InitialInputs: []sessions.Input{{Kind: "message", Payload: json.RawMessage(`{"text":"initial"}`)}}, + InitialInputs: []sessions.Input{messageInput("initial")}, }) p := &hostedFailureProvider{lifecycleProvider: lifecycleProvider{resources: map[string]sandbox.Info{}}, fail: "setup", result: failedInitialization(3)} diff --git a/services/core/tests/integration/input_conflicts_public_test.go b/services/core/tests/integration/input_conflicts_public_test.go index 41578612f..f390b0419 100644 --- a/services/core/tests/integration/input_conflicts_public_test.go +++ b/services/core/tests/integration/input_conflicts_public_test.go @@ -58,7 +58,7 @@ func TestSessionInputConflictsAndResultTargetsPostgres(t *testing.T) { input := sessions.CreateSession{Creator: FixtureCreator(), Engine: "codex", IdempotencyKey: uuid.NewString(), Configuration: json.RawMessage(`{` + conflictAgent + `,"environment":` + environment + `}`)} if initial { - input.InitialInputs = []sessions.Input{{Kind: "message", Payload: json.RawMessage(`{"text":"reserved"}`)}} + input.InitialInputs = []sessions.Input{messageInput("reserved")} } session, err := s.CreateSession(ctx, tenant, input) if err != nil { @@ -69,7 +69,7 @@ func TestSessionInputConflictsAndResultTargetsPostgres(t *testing.T) { // waiting starts a Turn that waits for one function result. waiting := func(session, key, call string) sessions.InputReceipt { t.Helper() - receipt, err := sendMessage(ctx, s, tenant, session, key, json.RawMessage(`{"text":"work"}`)) + receipt, err := sendMessage(ctx, s, tenant, session, key, messageText("work")) if err != nil { t.Fatal(err) } diff --git a/services/core/tests/integration/inputs_test.go b/services/core/tests/integration/inputs_test.go index 8dd0279e2..36dd59666 100644 --- a/services/core/tests/integration/inputs_test.go +++ b/services/core/tests/integration/inputs_test.go @@ -9,6 +9,7 @@ import ( "github.com/jackc/pgx/v5/pgtype" "github.com/jackc/pgx/v5/pgxpool" + v1 "github.com/MiniMax-AI/OpenAgentCore/contracts/agents-api/v1" "github.com/MiniMax-AI/OpenAgentCore/services/core/internal/db/sqlc" "github.com/MiniMax-AI/OpenAgentCore/services/core/internal/persistence/postgres/sessionpg" "github.com/MiniMax-AI/OpenAgentCore/services/core/internal/sessions" @@ -16,9 +17,15 @@ import ( var messagePayload = json.RawMessage(`{"input":[{"role":"user","content":[{"type":"input_text","text":"hello"}]}]}`) +// messageText is a public message event with one text part, as the events +// route stores it. +func messageText(text string) json.RawMessage { + payload, _ := json.Marshal(v1.SessionInput{Type: "agent.session.input.message", Input: []v1.InputMessage{{Role: "user", Content: []v1.InputContent{{Type: "input_text", Text: &text}}}}}) + return payload +} + func messageInput(text string) sessions.Input { - payload, _ := json.Marshal(map[string]string{"text": text}) - return sessions.Input{Kind: "message", Payload: payload} + return sessions.Input{Kind: "message", Payload: messageText(text)} } func newTurnSession(t *testing.T, s *Store) (string, sessions.Session) { diff --git a/services/core/tests/integration/item_order_test.go b/services/core/tests/integration/item_order_test.go index d7c152ac7..38b6c24d7 100644 --- a/services/core/tests/integration/item_order_test.go +++ b/services/core/tests/integration/item_order_test.go @@ -22,7 +22,7 @@ func TestItemObservationOrderSurvivesTiesUpdatesRetriesAndRecovery(t *testing.T) if err != nil { t.Fatal(err) } - input, err := sendMessage(ctx, s, tenant, session.ID, "first", json.RawMessage(`{"text":"question"}`)) + input, err := sendMessage(ctx, s, tenant, session.ID, "first", messageText("question")) if err != nil { t.Fatal(err) } @@ -51,7 +51,7 @@ func TestItemObservationOrderSurvivesTiesUpdatesRetriesAndRecovery(t *testing.T) t.Fatal(err) } } - if _, err = sendMessage(ctx, s, tenant, session.ID, "steer", json.RawMessage(`{"text":"continue"}`)); err != nil { + if _, err = sendMessage(ctx, s, tenant, session.ID, "steer", messageText("continue")); err != nil { t.Fatal(err) } page, err = sessionAdapter(s).ListItems(ctx, tenant, session.ID, "", 100, true) @@ -131,7 +131,7 @@ func TestItemObservationOrderSurvivesTiesUpdatesRetriesAndRecovery(t *testing.T) t.Fatal(err) } checkOrder() - next, err := sendMessage(ctx, s, tenant, session.ID, "next-turn", json.RawMessage(`{"text":"new turn"}`)) + next, err := sendMessage(ctx, s, tenant, session.ID, "next-turn", messageText("new turn")) if err != nil { t.Fatal(err) } diff --git a/services/core/tests/integration/item_reads_test.go b/services/core/tests/integration/item_reads_test.go index 31b01fa3b..27585bf45 100644 --- a/services/core/tests/integration/item_reads_test.go +++ b/services/core/tests/integration/item_reads_test.go @@ -22,7 +22,7 @@ func TestItemsRecoverSnapshotsPartialResultsPaginationAndIsolation(t *testing.T) if err != nil { t.Fatal(err) } - input, err := sendMessage(ctx, s, tenant, session.ID, "first", json.RawMessage(`{"text":"question"}`)) + input, err := sendMessage(ctx, s, tenant, session.ID, "first", messageText("question")) if err != nil { t.Fatal(err) } @@ -122,7 +122,7 @@ func TestItemProjectionFailureRollsBackJournalAndAggregateRecovers(t *testing.T) journal := executionOwner(t, s).Sessions tenant := uuid.NewString() session, _ := s.CreateSession(ctx, tenant, sessions.CreateSession{Creator: FixtureCreator(), Engine: "codex", IdempotencyKey: "legacy"}) - input, err := sendMessage(ctx, s, tenant, session.ID, "input", json.RawMessage(`{"text":"test"}`)) + input, err := sendMessage(ctx, s, tenant, session.ID, "input", messageText("test")) if err != nil { t.Fatal(err) } @@ -159,7 +159,7 @@ func TestReceiptOnlyTextRecoversWithoutInventingCompletion(t *testing.T) { tenant := uuid.NewString() for _, receiptOnly := range []bool{true, false} { session, _ := s.CreateSession(ctx, tenant, sessions.CreateSession{Creator: FixtureCreator(), Engine: "codex", IdempotencyKey: uuid.NewString()}) - input, err := sendMessage(ctx, s, tenant, session.ID, "first", json.RawMessage(`{"text":"test"}`)) + input, err := sendMessage(ctx, s, tenant, session.ID, "first", messageText("test")) if err != nil { t.Fatal(err) } @@ -193,7 +193,7 @@ func TestLegacyFailureRetainsPartialAnswerAcrossRecovery(t *testing.T) { if err != nil { t.Fatal(err) } - input, err := sendMessage(ctx, s, tenant, session.ID, "first", json.RawMessage(`{"text":"question"}`)) + input, err := sendMessage(ctx, s, tenant, session.ID, "first", messageText("question")) if err != nil { t.Fatal(err) } diff --git a/services/core/tests/integration/local_artifact_export_test.go b/services/core/tests/integration/local_artifact_export_test.go index 06775a674..056591d51 100644 --- a/services/core/tests/integration/local_artifact_export_test.go +++ b/services/core/tests/integration/local_artifact_export_test.go @@ -3,7 +3,6 @@ package integration import ( "archive/tar" "bytes" - "encoding/json" "testing" "github.com/MiniMax-AI/OpenAgentCore/internal/agentdaemon/proto" @@ -46,7 +45,7 @@ func completeLocalArtifactExport(t *testing.T, h *dispatchHarness, worker *execu t.Fatal("capture published before native completion", err) } completeCaptureDirectoryRead(t, h, worker, environment) - pending, err := sessionService(t, h.s).ReserveEnvironmentInput(t.Context(), h.tenant, h.session.ID, "during-artifact-capture", []sessions.Input{{Kind: "message", Payload: json.RawMessage(`{"text":"run after the completed native execution"}`)}}) + pending, err := sessionService(t, h.s).ReserveEnvironmentInput(t.Context(), h.tenant, h.session.ID, "during-artifact-capture", []sessions.Input{messageInput("run after the completed native execution")}) if err != nil || pending.State != sessions.EnvironmentInputPending || len(pending.Receipts) != 0 { t.Fatalf("input during artifact capture was assigned to the finished executor: %+v %v", pending, err) } diff --git a/services/core/tests/integration/local_environment_file_write_test.go b/services/core/tests/integration/local_environment_file_write_test.go index 0b802a30f..89d87e926 100644 --- a/services/core/tests/integration/local_environment_file_write_test.go +++ b/services/core/tests/integration/local_environment_file_write_test.go @@ -56,7 +56,7 @@ func TestLocalEnvironmentFileWriteOwnsMutationBeforeDispatch(t *testing.T) { if err != nil || intent.State != "pending" || intent.Identity.DeviceID != h.device.ID { t.Fatal("dispatch preceded durable ownership", intent, err) } - if _, err := sessionService(t, h.s).ReserveEnvironmentInput(t.Context(), h.tenant, h.session.ID, "concurrent", []sessions.Input{{Kind: "message", Payload: []byte(`{"text":"work"}`)}}); !errors.Is(err, sessions.ErrTurnConflict) { + if _, err := sessionService(t, h.s).ReserveEnvironmentInput(t.Context(), h.tenant, h.session.ID, "concurrent", []sessions.Input{messageInput("work")}); !errors.Is(err, sessions.ErrTurnConflict) { t.Fatal("upload admitted concurrent execution", err) } cancel() @@ -98,7 +98,7 @@ func TestLocalEnvironmentFileWriteLostReceiptRemainsPending(t *testing.T) { if err != nil || intent.State != "pending" { t.Fatal("disconnect guessed rejection", intent, err) } - if _, err := sessionService(t, h.s).ReserveEnvironmentInput(t.Context(), h.tenant, h.session.ID, "after-loss", []sessions.Input{{Kind: "message", Payload: []byte(`{"text":"work"}`)}}); !errors.Is(err, sessions.ErrTurnConflict) { + if _, err := sessionService(t, h.s).ReserveEnvironmentInput(t.Context(), h.tenant, h.session.ID, "after-loss", []sessions.Input{messageInput("work")}); !errors.Is(err, sessions.ErrTurnConflict) { t.Fatal("unknown upload admitted execution", err) } } diff --git a/services/core/tests/integration/local_environment_worker_test.go b/services/core/tests/integration/local_environment_worker_test.go index 494efc624..5d9b3214c 100644 --- a/services/core/tests/integration/local_environment_worker_test.go +++ b/services/core/tests/integration/local_environment_worker_test.go @@ -2,7 +2,6 @@ package integration import ( "context" - "encoding/json" "errors" "testing" "time" @@ -109,7 +108,7 @@ func TestLocalEnvironmentWorkerRejectsGeneralDeviceDespiteCapability(t *testing. func TestLocalEnvironmentWorkerSchedulesPreparationWithoutRemoteResolver(t *testing.T) { h, worker, environment := localWorker(t, true, true) - reservation, err := sessionService(t, h.s).ReserveEnvironmentInput(t.Context(), h.tenant, h.session.ID, "local-input", []sessions.Input{{Kind: "message", Payload: json.RawMessage(`{"text":"first"}`)}}) + reservation, err := sessionService(t, h.s).ReserveEnvironmentInput(t.Context(), h.tenant, h.session.ID, "local-input", []sessions.Input{messageInput("first")}) if err != nil { t.Fatal(err) } diff --git a/services/core/tests/integration/message_image_admission_test.go b/services/core/tests/integration/message_image_admission_test.go index 02024727a..1cf450a41 100644 --- a/services/core/tests/integration/message_image_admission_test.go +++ b/services/core/tests/integration/message_image_admission_test.go @@ -14,7 +14,7 @@ import ( func imageAdmissionBatch() []sessions.Input { return []sessions.Input{ - {Kind: "message", Payload: json.RawMessage(`{"text":"do not partially admit"}`)}, + messageInput("do not partially admit"), {Kind: "message", Payload: json.RawMessage(`{"input":[{"role":"user","content":[{"type":"input_image","image_url":"data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mP8/x8AAwMCAO+aXioAAAAASUVORK5CYII="}]}]}`)}, } } diff --git a/services/core/tests/integration/prepared_dispatch_test.go b/services/core/tests/integration/prepared_dispatch_test.go index f4bca9404..dadacf8a0 100644 --- a/services/core/tests/integration/prepared_dispatch_test.go +++ b/services/core/tests/integration/prepared_dispatch_test.go @@ -24,7 +24,7 @@ func preparedDispatchHarness(t *testing.T) (*dispatchHarness, sessions.Environme assertNoRuntimeAllocation(t, h) h.d, h.lease = h.bound(), h.owner().Lease enableWorkerEnvironment(t, h) - pending, err := sessionService(t, h.s).ReserveEnvironmentInput(t.Context(), h.tenant, h.session.ID, "pending", []sessions.Input{{Kind: "message", Payload: json.RawMessage(`{"text":"first"}`)}, {Kind: "message", Payload: json.RawMessage(`{"text":"second"}`)}}) + pending, err := sessionService(t, h.s).ReserveEnvironmentInput(t.Context(), h.tenant, h.session.ID, "pending", []sessions.Input{messageInput("first"), messageInput("second")}) if err != nil { t.Fatal(err) } diff --git a/services/core/tests/integration/public_execution_test.go b/services/core/tests/integration/public_execution_test.go index 2546c3d55..97b6df172 100644 --- a/services/core/tests/integration/public_execution_test.go +++ b/services/core/tests/integration/public_execution_test.go @@ -144,7 +144,7 @@ func TestWorkerRestartReconcilesClaimedButPreservesQueuedWork(t *testing.T) { } checkMeasurement(false) queued := publicSession(t, h, "queued") - if _, err := sendMessage(ctx, h.s, h.tenant, queued.ID, "first", json.RawMessage(`{"text":"Not sent"}`)); err != nil { + if _, err := sendMessage(ctx, h.s, h.tenant, queued.ID, "first", messageText("Not sent")); err != nil { t.Fatal(err) } worker := startOwnedWorker(t, ctx, h.s, h.d, h.owner()) diff --git a/services/core/tests/integration/runtime_input_admission_test.go b/services/core/tests/integration/runtime_input_admission_test.go index 66617e3db..8c45dac30 100644 --- a/services/core/tests/integration/runtime_input_admission_test.go +++ b/services/core/tests/integration/runtime_input_admission_test.go @@ -15,7 +15,7 @@ import ( func TestManagedRuntimeMaintenancePreservesCancelAndRetry(t *testing.T) { s, _ := newManagedTestStore(t) tenant, session, _ := managedSession(t, s) - inputs := []sessions.Input{{Kind: "message", Payload: json.RawMessage(`{"text":"accepted work"}`)}} + inputs := []sessions.Input{messageInput("accepted work")} accepted, err := submitInputs(t.Context(), s, tenant, session.ID, "work", inputs) if err != nil { t.Fatal(err) diff --git a/services/core/tests/integration/runtime_pending_test.go b/services/core/tests/integration/runtime_pending_test.go index 65128f89c..8245750f5 100644 --- a/services/core/tests/integration/runtime_pending_test.go +++ b/services/core/tests/integration/runtime_pending_test.go @@ -16,7 +16,7 @@ import ( func TestManagedRuntimeAutomaticBootstrapRecoversCommittedSessions(t *testing.T) { s, _ := newManagedTestStore(t) tenant, idle, idleEnvironment := managedSession(t, s) - initial, err := s.CreateSession(t.Context(), tenant, sessions.CreateSession{Creator: FixtureCreator(), Engine: "codex", IdempotencyKey: uuid.NewString(), Configuration: json.RawMessage(`{"agent":{"model":"test"},"environment":{"type":"openai_hosted"}}`), InitialInputs: []sessions.Input{{Kind: "message", Payload: json.RawMessage(`{"text":"hello"}`)}}}) + initial, err := s.CreateSession(t.Context(), tenant, sessions.CreateSession{Creator: FixtureCreator(), Engine: "codex", IdempotencyKey: uuid.NewString(), Configuration: json.RawMessage(`{"agent":{"model":"test"},"environment":{"type":"openai_hosted"}}`), InitialInputs: []sessions.Input{messageInput("hello")}}) if err != nil { t.Fatal(err) } diff --git a/services/core/tests/integration/runtime_wake_hint_integration_test.go b/services/core/tests/integration/runtime_wake_hint_integration_test.go index f31a2e4a7..0d29a6720 100644 --- a/services/core/tests/integration/runtime_wake_hint_integration_test.go +++ b/services/core/tests/integration/runtime_wake_hint_integration_test.go @@ -113,8 +113,7 @@ func newWakeHintIntegration(t *testing.T) *wakeHintIntegration { } func wakeHintInput(text string) []sessions.Input { - payload, _ := json.Marshal(map[string]string{"text": text}) - return []sessions.Input{{Kind: "message", Payload: payload}} + return []sessions.Input{messageInput(text)} } func (f *wakeHintIntegration) pending(t *testing.T, target wakeHintIntegrationTarget, key string) sessions.EnvironmentInputReservation { diff --git a/services/core/tests/integration/runtime_worker_recovery_test.go b/services/core/tests/integration/runtime_worker_recovery_test.go index 654f8a444..0a8b08e51 100644 --- a/services/core/tests/integration/runtime_worker_recovery_test.go +++ b/services/core/tests/integration/runtime_worker_recovery_test.go @@ -2,7 +2,6 @@ package integration import ( "context" - "encoding/json" "errors" "testing" "time" @@ -35,7 +34,7 @@ func runtimeWorkerHarness(t *testing.T) (*dispatchHarness, *pgxpool.Pool) { func TestPreparedDispatchKeepsPendingReservationAfterComputeConflict(t *testing.T) { h, _ := runtimeWorkerHarness(t) h.d, h.lease = h.bound(), h.owner().Lease - pending, err := sessionService(t, h.s).ReserveEnvironmentInput(t.Context(), h.tenant, h.session.ID, "pending", []sessions.Input{{Kind: "message", Payload: json.RawMessage(`{"text":"first"}`)}}) + pending, err := sessionService(t, h.s).ReserveEnvironmentInput(t.Context(), h.tenant, h.session.ID, "pending", []sessions.Input{messageInput("first")}) if err != nil { t.Fatal(err) } @@ -56,7 +55,7 @@ func TestPreparedDispatchKeepsPendingReservationAfterComputeConflict(t *testing. func TestWorkerWaitsForComputeAndSurvivesPromotionConflict(t *testing.T) { h, pool := runtimeWorkerHarness(t) - pending, err := sessionService(t, h.s).ReserveEnvironmentInput(t.Context(), h.tenant, h.session.ID, "pending", []sessions.Input{{Kind: "message", Payload: json.RawMessage(`{"text":"first"}`)}}) + pending, err := sessionService(t, h.s).ReserveEnvironmentInput(t.Context(), h.tenant, h.session.ID, "pending", []sessions.Input{messageInput("first")}) if err != nil { t.Fatal(err) } diff --git a/services/core/tests/integration/sandbox_deployment_switch_test.go b/services/core/tests/integration/sandbox_deployment_switch_test.go index 49ee8ba6a..7c88d88c0 100644 --- a/services/core/tests/integration/sandbox_deployment_switch_test.go +++ b/services/core/tests/integration/sandbox_deployment_switch_test.go @@ -311,7 +311,7 @@ func TestSandboxSwitchPreservesReleasedAllocationAndItemHistory(t *testing.T) { if err != nil { t.Fatal(err) } - input, err := sendMessage(t.Context(), s, tenant, history.ID, "history", json.RawMessage(`{"text":"retained request"}`)) + input, err := sendMessage(t.Context(), s, tenant, history.ID, "history", messageText("retained request")) if err != nil { t.Fatal(err) } diff --git a/services/core/tests/integration/self_hosted_cancel_public_test.go b/services/core/tests/integration/self_hosted_cancel_public_test.go index 31f6f8989..bf9551100 100644 --- a/services/core/tests/integration/self_hosted_cancel_public_test.go +++ b/services/core/tests/integration/self_hosted_cancel_public_test.go @@ -117,7 +117,7 @@ func TestSelfHostedCancellationOfficialClient(t *testing.T) { return value } idleReceipts := receipts(created.IdleKey, "") - later, err := sessionService(t, s).ReserveEnvironmentInput(t.Context(), tenant, created.LaterID, "controlled-later-input", []sessions.Input{{Kind: "message", Payload: json.RawMessage(`{"text":"Retain pending input."}`)}}) + later, err := sessionService(t, s).ReserveEnvironmentInput(t.Context(), tenant, created.LaterID, "controlled-later-input", []sessions.Input{messageInput("Retain pending input.")}) if err != nil || later.State != sessions.EnvironmentInputPending || later.IsInitial { t.Fatal("could not establish controlled later reservation", err) } @@ -131,7 +131,7 @@ func TestSelfHostedCancellationOfficialClient(t *testing.T) { start := func() string { t.Helper() // Controlled callbacks isolate HTTP admission; no daemon or model runs in this fixture. - input, err := sendMessage(t.Context(), s, tenant, created.ID, uuid.NewString(), json.RawMessage(`{"text":"Controlled active work."}`)) + input, err := sendMessage(t.Context(), s, tenant, created.ID, uuid.NewString(), messageText("Controlled active work.")) if err != nil { t.Fatal(err) } diff --git a/services/core/tests/integration/session_deletion_lifecycle_public_test.go b/services/core/tests/integration/session_deletion_lifecycle_public_test.go index 57216d5a3..440acbe68 100644 --- a/services/core/tests/integration/session_deletion_lifecycle_public_test.go +++ b/services/core/tests/integration/session_deletion_lifecycle_public_test.go @@ -45,7 +45,7 @@ func TestSessionDeletionLifecyclePostgres(t *testing.T) { input := sessions.CreateSession{Creator: FixtureCreator(), Engine: "codex", IdempotencyKey: uuid.NewString(), Configuration: json.RawMessage(`{` + deletionAgent + `,"environment":` + environment + `}`)} if initial { - input.InitialInputs = []sessions.Input{{Kind: "message", Payload: json.RawMessage(`{"text":"reserved"}`)}} + input.InitialInputs = []sessions.Input{messageInput("reserved")} } session, err := s.CreateSession(ctx, tenant, input) if err != nil { @@ -59,7 +59,7 @@ func TestSessionDeletionLifecyclePostgres(t *testing.T) { turn := func(to ...string) string { t.Helper() session := create(none, false) - receipt, err := sendMessage(ctx, s, tenant, session.ID, "input", json.RawMessage(`{"text":"work"}`)) + receipt, err := sendMessage(ctx, s, tenant, session.ID, "input", messageText("work")) if err != nil { t.Fatal(err) } @@ -84,7 +84,7 @@ func TestSessionDeletionLifecyclePostgres(t *testing.T) { } reserve := func(session sessions.Session) sessions.EnvironmentInputReservation { t.Helper() - reservation, err := sessionService(t, s).ReserveEnvironmentInput(ctx, tenant, session.ID, "later", []sessions.Input{{Kind: "message", Payload: json.RawMessage(`{"text":"later"}`)}}) + reservation, err := sessionService(t, s).ReserveEnvironmentInput(ctx, tenant, session.ID, "later", []sessions.Input{messageInput("later")}) if err != nil || reservation.State != sessions.EnvironmentInputPending { t.Fatal(reservation, err) } diff --git a/services/core/tests/integration/session_deletion_test.go b/services/core/tests/integration/session_deletion_test.go index 1484c5553..b62dc8629 100644 --- a/services/core/tests/integration/session_deletion_test.go +++ b/services/core/tests/integration/session_deletion_test.go @@ -2,7 +2,6 @@ package integration import ( "context" - "encoding/json" "errors" "strings" "sync" @@ -62,7 +61,7 @@ func TestSessionDeletionWaitsForSettledTurnAndRejectsAdmission(t *testing.T) { if err != nil { t.Fatal(err) } - receipt, err := sendMessage(ctx, s, tenant, session.ID, "input", json.RawMessage(`{"text":"retained"}`)) + receipt, err := sendMessage(ctx, s, tenant, session.ID, "input", messageText("retained")) if err != nil { t.Fatal(err) } @@ -139,7 +138,7 @@ func TestSessionDeletionWaitsForSettledTurnAndRejectsAdmission(t *testing.T) { if _, err := createSession(ctx, fresh, tenant, input); !errors.Is(err, sessions.ErrIdempotencyConflict) { t.Fatal(err) } - if _, err := sendMessage(ctx, fresh, tenant, session.ID, "input", json.RawMessage(`{"text":"retained"}`)); !errors.Is(err, sessions.ErrNotFound) { + if _, err := sendMessage(ctx, fresh, tenant, session.ID, "input", messageText("retained")); !errors.Is(err, sessions.ErrNotFound) { t.Fatal(err) } if _, err := requestCancel(ctx, fresh, tenant, session.ID, "late-cancel"); !errors.Is(err, sessions.ErrNotFound) { diff --git a/services/core/tests/integration/session_events_test.go b/services/core/tests/integration/session_events_test.go index b68308acb..210b755ab 100644 --- a/services/core/tests/integration/session_events_test.go +++ b/services/core/tests/integration/session_events_test.go @@ -56,7 +56,7 @@ func TestSessionEventsCommitSnapshotsRetriesAndIsolation(t *testing.T) { if err != nil { t.Fatal(err) } - input, err := sendMessage(ctx, s, tenant, session.ID, "start", json.RawMessage(`{"text":"question"}`)) + input, err := sendMessage(ctx, s, tenant, session.ID, "start", messageText("question")) if err != nil { t.Fatal(err) } @@ -64,7 +64,7 @@ func TestSessionEventsCommitSnapshotsRetriesAndIsolation(t *testing.T) { if err != nil { t.Fatal(err) } - if _, err = sendMessage(ctx, s, tenant, session.ID, "start", json.RawMessage(`{"text":"question"}`)); err != nil { + if _, err = sendMessage(ctx, s, tenant, session.ID, "start", messageText("question")); err != nil { t.Fatal(err) } after, _ := sessionAdapter(s).SessionEventCursor(ctx, tenant, session.ID) @@ -166,7 +166,7 @@ func TestSessionEventsRetentionAndQueuedCancellation(t *testing.T) { }) inputs := make([]sessions.Input, 64) for i := range inputs { - inputs[i] = sessions.Input{Kind: "message", Payload: json.RawMessage(`{"text":"input"}`)} + inputs[i] = messageInput("input") } for range 5 { if _, err = submitInputs(ctx, s, tenant, session.ID, uuid.NewString(), inputs); err != nil { diff --git a/services/core/tests/integration/session_metadata_test.go b/services/core/tests/integration/session_metadata_test.go index 021a87b0c..2dad9bbb9 100644 --- a/services/core/tests/integration/session_metadata_test.go +++ b/services/core/tests/integration/session_metadata_test.go @@ -114,7 +114,7 @@ func TestSessionMetadataPreservesTerminalActivity(t *testing.T) { t.Fatal(err) } for _, status := range []string{sessions.TurnCompleted, sessions.TurnFailed, sessions.TurnCancelled} { - receipt, err := sendMessage(ctx, s, tenant, session.ID, uuid.NewString(), []byte(`{"text":"metadata fixture"}`)) + receipt, err := sendMessage(ctx, s, tenant, session.ID, uuid.NewString(), messageText("metadata fixture")) if err != nil { t.Fatal(err) } 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/core/tests/integration/stream_authority_http_test.go b/services/core/tests/integration/stream_authority_http_test.go index 33dd4666a..3ca7984c7 100644 --- a/services/core/tests/integration/stream_authority_http_test.go +++ b/services/core/tests/integration/stream_authority_http_test.go @@ -100,7 +100,7 @@ func TestLiveStreamClosesAfterKeyRevocationOrProjectArchive(t *testing.T) { t.Fatal("revocation affected peer", valid.StatusCode) } // New events remain available to valid callers after the reader has closed. - if _, err := sessionService(t, s).ReserveEnvironmentInput(t.Context(), project.TenantID, session.ID, "after-revocation", []sessions.Input{{Kind: "message", Payload: json.RawMessage(`{"text":"new event"}`)}}); err != nil { + if _, err := sessionService(t, s).ReserveEnvironmentInput(t.Context(), project.TenantID, session.ID, "after-revocation", []sessions.Input{messageInput("new event")}); err != nil { t.Fatal(err) } } diff --git a/services/core/tests/integration/token_usage_integration_test.go b/services/core/tests/integration/token_usage_integration_test.go index 6b533dc6b..434b4f869 100644 --- a/services/core/tests/integration/token_usage_integration_test.go +++ b/services/core/tests/integration/token_usage_integration_test.go @@ -33,7 +33,7 @@ func TestTokenUsageDurableSnapshotsAndSessionTotals(t *testing.T) { } } for n, status := range []string{sessions.TurnFailed, sessions.TurnCancelled} { - admission, err := sendMessage(ctx, s, tenant, session.ID, fmt.Sprint(n), json.RawMessage(`{"text":"measure"}`)) + admission, err := sendMessage(ctx, s, tenant, session.ID, fmt.Sprint(n), messageText("measure")) if err != nil { t.Fatal(err) } @@ -113,7 +113,7 @@ func TestCancellationReceiptUsageSurvivesRecovery(t *testing.T) { if err != nil { t.Fatal(err) } - admission, err := sendMessage(ctx, s, tenant, session.ID, "start", json.RawMessage(`{"text":"measure"}`)) + admission, err := sendMessage(ctx, s, tenant, session.ID, "start", messageText("measure")) if err != nil { t.Fatal(err) } @@ -187,7 +187,7 @@ func TestSessionUsageRequiresEveryRootTurnEndedAndMeasured(t *testing.T) { } submit := func(key string) sessions.InputReceipt { t.Helper() - admission, err := sendMessage(ctx, s, tenant, session.ID, key, json.RawMessage(`{"text":"measure"}`)) + admission, err := sendMessage(ctx, s, tenant, session.ID, key, messageText("measure")) if err != nil { t.Fatal(err) } diff --git a/services/core/tests/integration/turn_events_test.go b/services/core/tests/integration/turn_events_test.go index e95bf1a40..5591fd3ac 100644 --- a/services/core/tests/integration/turn_events_test.go +++ b/services/core/tests/integration/turn_events_test.go @@ -20,7 +20,7 @@ func TestTurnEventBatchesAreOrderedIsolatedAndDurable(t *testing.T) { if err != nil { t.Fatal(err) } - input, err := sendMessage(ctx, s, tenant, session.ID, "start", json.RawMessage(`{"text":"test"}`)) + input, err := sendMessage(ctx, s, tenant, session.ID, "start", messageText("test")) if err != nil { t.Fatal(err) } diff --git a/services/core/tests/integration/worker_preparation_failure_test.go b/services/core/tests/integration/worker_preparation_failure_test.go index d1d266352..9d1223560 100644 --- a/services/core/tests/integration/worker_preparation_failure_test.go +++ b/services/core/tests/integration/worker_preparation_failure_test.go @@ -1,7 +1,6 @@ package integration import ( - "encoding/json" "testing" "time" @@ -15,7 +14,7 @@ func TestWorkerSettlesConfirmedPreparationFailureAndAcceptsNewInput(t *testing.T h := newDispatchHarnessForSession(t, []byte(`{"agent":{"model":"test-model"},"environment":{"type":"self_hosted","workspace_directory":"/workspace"}}`), false) enableWorkerEnvironment(t, h) frames := workerFrames(t, h) - pending, err := sessionService(t, h.s).ReserveEnvironmentInput(t.Context(), h.tenant, h.session.ID, "first", []sessions.Input{{Kind: "message", Payload: json.RawMessage(`{"text":"first"}`)}}) + pending, err := sessionService(t, h.s).ReserveEnvironmentInput(t.Context(), h.tenant, h.session.ID, "first", []sessions.Input{messageInput("first")}) if err != nil { t.Fatal(err) } @@ -58,7 +57,7 @@ func TestWorkerSettlesConfirmedPreparationFailureAndAcceptsNewInput(t *testing.T t.Fatal("failed input retried", frame.Type) case <-time.After(1200 * time.Millisecond): } - next, err := sessionService(t, h.s).ReserveEnvironmentInput(t.Context(), h.tenant, h.session.ID, "next", []sessions.Input{{Kind: "message", Payload: json.RawMessage(`{"text":"next"}`)}}) + next, err := sessionService(t, h.s).ReserveEnvironmentInput(t.Context(), h.tenant, h.session.ID, "next", []sessions.Input{messageInput("next")}) if err != nil { t.Fatal("new input remained blocked", err) } @@ -98,7 +97,7 @@ func TestWorkerRetriesUncertainPreparationFailure(t *testing.T) { h := newDispatchHarnessForSession(t, []byte(`{"agent":{"model":"test-model"},"environment":{"type":"self_hosted","workspace_directory":"/workspace"}}`), false) enableWorkerEnvironment(t, h) frames := workerFrames(t, h) - pending, err := sessionService(t, h.s).ReserveEnvironmentInput(t.Context(), h.tenant, h.session.ID, "retry", []sessions.Input{{Kind: "message", Payload: json.RawMessage(`{"text":"retry"}`)}}) + pending, err := sessionService(t, h.s).ReserveEnvironmentInput(t.Context(), h.tenant, h.session.ID, "retry", []sessions.Input{messageInput("retry")}) if err != nil { t.Fatal(err) } @@ -130,7 +129,7 @@ func TestWorkerPreparationRejectionPreservesCancellationAndNewerInput(t *testing h := newDispatchHarnessForSession(t, []byte(`{"agent":{"model":"test-model"},"environment":{"type":"self_hosted","workspace_directory":"/workspace"}}`), false) enableWorkerEnvironment(t, h) frames := workerFrames(t, h) - first, err := sessionService(t, h.s).ReserveEnvironmentInput(t.Context(), h.tenant, h.session.ID, "first", []sessions.Input{{Kind: "message", Payload: json.RawMessage(`{"text":"first"}`)}}) + first, err := sessionService(t, h.s).ReserveEnvironmentInput(t.Context(), h.tenant, h.session.ID, "first", []sessions.Input{messageInput("first")}) if err != nil { t.Fatal(err) } @@ -140,7 +139,7 @@ func TestWorkerPreparationRejectionPreservesCancellationAndNewerInput(t *testing if _, err := cancelEnvironmentInput(t.Context(), h.s, h.tenant, h.session.ID, first.ID); err != nil { t.Fatal(err) } - next, err := sessionService(t, h.s).ReserveEnvironmentInput(t.Context(), h.tenant, h.session.ID, "next", []sessions.Input{{Kind: "message", Payload: json.RawMessage(`{"text":"next"}`)}}) + next, err := sessionService(t, h.s).ReserveEnvironmentInput(t.Context(), h.tenant, h.session.ID, "next", []sessions.Input{messageInput("next")}) if err != nil { t.Fatal(err) } diff --git a/services/core/tests/integration/worker_wakeup_test.go b/services/core/tests/integration/worker_wakeup_test.go index 3596bbff1..3f4543fb6 100644 --- a/services/core/tests/integration/worker_wakeup_test.go +++ b/services/core/tests/integration/worker_wakeup_test.go @@ -2,7 +2,6 @@ package integration import ( "context" - "encoding/json" "strings" "sync/atomic" "testing" @@ -60,7 +59,7 @@ func TestWorkerSchedulerCommittedAdmissionWakesBeforeMaintenance(t *testing.T) { t.Error("worker did not stop") } }() - input := []sessions.Input{{Kind: "message", Payload: json.RawMessage(`{"text":"wake"}`)}} + input := []sessions.Input{messageInput("wake")} switch operation { case "submit": _, err = worker.SubmitInputs(ctx, h.tenant, h.session.ID, "wake", input) @@ -112,7 +111,7 @@ func TestWorkerSchedulerHintBypassesEnvironmentScanThrottle(t *testing.T) { admitted := make(chan error, 1) started := time.Now() go func() { - _, err := worker.SubmitInputs(ctx, h.tenant, h.session.ID, "wake", []sessions.Input{{Kind: "message", Payload: json.RawMessage(`{"text":"wake"}`)}}) + _, err := worker.SubmitInputs(ctx, h.tenant, h.session.ID, "wake", []sessions.Input{messageInput("wake")}) admitted <- err }() prepare := nextWorkerFrame(t, frames, proto.TypeExecutionPrepare) diff --git a/services/web/Dockerfile b/services/web/Dockerfile index cea7c0df0..ec2e587fd 100644 --- a/services/web/Dockerfile +++ b/services/web/Dockerfile @@ -3,7 +3,6 @@ FROM gcr.io/distroless/static-debian13:nonroot@sha256:e2e927ec666bae08560abb3c55 COPY --chmod=0555 oac-web /usr/local/bin/oac-web COPY dist /www -ENV OAC_WEB_ADDR=:8080 OAC_WEB_DIST=/www EXPOSE 8080 USER 65532:65532 CMD ["/usr/local/bin/oac-web"] diff --git a/services/web/config.go b/services/web/config.go index cd2f2413f..9d64eff35 100644 --- a/services/web/config.go +++ b/services/web/config.go @@ -1,6 +1,7 @@ package main import ( + "cmp" "errors" "io" "net/url" @@ -9,32 +10,41 @@ import ( "strings" "unicode" "unicode/utf8" + + "github.com/MiniMax-AI/OpenAgentCore/internal/obs/log" ) type config struct { addr, origin, dist string coreKey, nodePayloadDir string upstream *url.URL + log log.Config } +// loadConfig reads Web's settings. An unset or empty variable selects its +// default; OAC_PUBLIC_URL and OAC_WEB_CORE_KEY_FILE have none. func loadConfig() (config, error) { c := config{ - addr: envDefault("OAC_WEB_ADDR", ":8080"), - origin: envDefault("OAC_WEB_ORIGIN", "http://127.0.0.1:8080"), - dist: envDefault("OAC_WEB_DIST", "/www"), + addr: cmp.Or(os.Getenv("OAC_WEB_ADDR"), ":8080"), + origin: os.Getenv("OAC_PUBLIC_URL"), + dist: cmp.Or(os.Getenv("OAC_WEB_DIST"), "/www"), + } + var err error + if c.log, err = log.LoadConfig(); err != nil { + return config{}, err } origin, err := serverURL(c.origin) if err != nil || origin.Path != "" { - return config{}, errors.New("OAC_WEB_ORIGIN must be an HTTP(S) origin without a path") + return config{}, errors.New("OAC_PUBLIC_URL must be an HTTP(S) origin without a path") } - c.upstream, err = serverURL(envDefault("OAC_WEB_UPSTREAM", "http://core:8091")) + c.upstream, err = serverURL(cmp.Or(os.Getenv("OAC_WEB_UPSTREAM"), "http://core:8091")) if err != nil { return config{}, errors.New("OAC_WEB_UPSTREAM must be an HTTP(S) server URL without credentials, query or path") } if !filepath.IsAbs(c.dist) { return config{}, errors.New("OAC_WEB_DIST must be absolute") } - c.coreKey, err = readSecret(envDefault("OAC_WEB_CORE_KEY_FILE", "/admin/core.key")) + c.coreKey, err = readSecret(os.Getenv("OAC_WEB_CORE_KEY_FILE")) if err != nil { return config{}, errors.New("OAC_WEB_CORE_KEY_FILE must name a private regular file containing the Core key") } @@ -48,13 +58,6 @@ func loadConfig() (config, error) { return c, nil } -func envDefault(key, fallback string) string { - if value, exists := os.LookupEnv(key); exists { - return value - } - return fallback -} - func serverURL(value string) (*url.URL, error) { u, err := url.Parse(value) if err != nil || u.Host == "" || (u.Scheme != "http" && u.Scheme != "https") || diff --git a/services/web/config_test.go b/services/web/config_test.go index ae1e3ccac..978efebe0 100644 --- a/services/web/config_test.go +++ b/services/web/config_test.go @@ -15,14 +15,14 @@ func TestConfigRejectsUnsafeURLsAndSecretFiles(t *testing.T) { t.Fatal(err) } t.Setenv("OAC_WEB_CORE_KEY_FILE", key) - t.Setenv("OAC_WEB_ORIGIN", testOrigin) + t.Setenv("OAC_PUBLIC_URL", testOrigin) t.Setenv("OAC_WEB_UPSTREAM", "http://core:8091") t.Setenv("OAC_WEB_DIST", directory) c, err := loadConfig() if err != nil || c.coreKey != valid { t.Fatalf("valid configuration failed: %v", err) } - for _, value := range []string{"http://user:secret@core:8091", "http://core:8091/v1", "http://core:8091?token=secret", "http://core:8091#", "file:///config/caller.key", ""} { + for _, value := range []string{"http://user:secret@core:8091", "http://core:8091/v1", "http://core:8091?token=secret", "http://core:8091#", "file:///config/caller.key"} { t.Run(value, func(t *testing.T) { t.Setenv("OAC_WEB_UPSTREAM", value) _, err := loadConfig() @@ -31,6 +31,15 @@ func TestConfigRejectsUnsafeURLsAndSecretFiles(t *testing.T) { } }) } + t.Setenv("OAC_WEB_UPSTREAM", "") + if c, err := loadConfig(); err != nil || c.upstream.String() != "http://core:8091" { + t.Fatal("an empty upstream did not select the default", err) + } + t.Setenv("OAC_PUBLIC_URL", "") + if _, err := loadConfig(); err == nil || !strings.Contains(err.Error(), "OAC_PUBLIC_URL") { + t.Fatal("console configured without OAC_PUBLIC_URL", err) + } + t.Setenv("OAC_PUBLIC_URL", testOrigin) for _, value := range []string{"", "token with spaces", strings.Repeat("x", 4097), "token\x00", strings.Repeat("s", 31)} { if err := os.WriteFile(key, []byte(value), 0o600); err != nil { t.Fatal(err) @@ -58,7 +67,7 @@ func TestBootstrapFollowsManagedHTTPOrigin(t *testing.T) { } t.Setenv("OAC_WEB_CORE_KEY_FILE", key) t.Setenv("OAC_WEB_DIST", directory) - t.Setenv("OAC_WEB_ORIGIN", "http://localhost:8080") + t.Setenv("OAC_PUBLIC_URL", "http://localhost:8080") if _, err := loadConfig(); err != nil { t.Fatal(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/main.go b/services/web/main.go index a2459d7a4..3bc10bee6 100644 --- a/services/web/main.go +++ b/services/web/main.go @@ -5,7 +5,9 @@ import ( "context" "errors" "fmt" + "net" "net/http" + "net/url" "os" "os/signal" "syscall" @@ -38,22 +40,33 @@ func main() { // printCoreKey writes the sign-in key for `docker compose exec web oac-web // core-key`. Exec output never enters the container log. func printCoreKey() error { - key, err := readSecret(envDefault("OAC_WEB_CORE_KEY_FILE", "/admin/core.key")) + c, err := loadConfig() if err != nil { - return errors.New("cannot read the Core key from OAC_WEB_CORE_KEY_FILE") + return err } - fmt.Println(key) + fmt.Println(c.coreKey) return nil } // healthcheck reports the installation healthy once Core and Web's own listener // answer. Compose runs it inside the web container. func healthcheck() error { + c, err := loadConfig() + if err != nil { + return err + } + host, port, err := net.SplitHostPort(c.addr) + if err != nil { + return errors.New("OAC_WEB_ADDR must be a host:port listen address") + } + if ip := net.ParseIP(host); host == "" || (ip != nil && ip.IsUnspecified()) { + host = "127.0.0.1" + } ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second) defer cancel() client := &http.Client{Timeout: 3 * time.Second, Transport: &http.Transport{Proxy: nil}} - for _, host := range []string{"core:8091", "127.0.0.1:8080"} { - request, err := http.NewRequestWithContext(ctx, http.MethodGet, "http://"+host+"/healthz", nil) + for _, server := range []*url.URL{c.upstream, {Scheme: "http", Host: net.JoinHostPort(host, port)}} { + request, err := http.NewRequestWithContext(ctx, http.MethodGet, server.JoinPath("healthz").String(), nil) if err != nil { return err } @@ -63,18 +76,18 @@ func healthcheck() error { } response.Body.Close() if response.StatusCode != http.StatusOK { - return fmt.Errorf("%s returned HTTP %d", host, response.StatusCode) + return fmt.Errorf("%s returned HTTP %d", server.Host, response.StatusCode) } } return nil } func run() error { - log.Init(log.ConfigFromEnv()) c, err := loadConfig() if err != nil { return err } + log.Init(c.log) handler, err := newConsole(c) if err != nil { return err 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)