From 023909ec9f75c41ca2dd6928fb1e8106a526fd1f Mon Sep 17 00:00:00 2001 From: SaladDay <1203511142@qq.com> Date: Fri, 25 Sep 2026 13:02:05 +0000 Subject: [PATCH 01/30] Serve exactly the pinned official routes under /v1 /v1 now has the 58 method and path pairs of the pinned OpenAI Python SDK 3.13.0. The eight Core extension routes are removed from /v1: startup configuration, Runtime observations (list and per Session), Runtime history (capabilities and per Session), Session execution configuration, Session sandbox placement and the sandbox node directory. Their administrator equivalents under /core/v1/admin keep the same handlers. Placement is automatic only: x_agents_core.sandbox_node_id is removed and is now rejected as an unknown member (400). The store no longer selects an explicit node, and the unused per-Session placement read is gone. The shared client drops SandboxProjectClient and the public /v1 readers for the removed routes; AdminClient keeps its /core equivalents. scripts/extract-agents-api-upstream.py writes upstream-routes.json and upstream-fields.json from the pinned SDK. Contract tests require the public OpenAPI to have exactly those routes and to keep every non-official field inside x_agents_core, with three shared-schema mismatches listed for a decision. --- CONTRIBUTING.md | 17 +- contracts/agents-api/README.md | 16 +- contracts/agents-api/admin-api.md | 13 +- .../agents-api/execution-configuration.md | 20 +- contracts/agents-api/model-execution.md | 4 +- contracts/agents-api/openapi.yaml | 1337 +---------- contracts/agents-api/runtime-history-api.md | 27 +- .../agents-api/runtime-observability-api.md | 101 +- .../agents-api/sandbox-manager.openapi.yaml | 17 +- contracts/agents-api/startup-configuration.md | 14 +- contracts/agents-api/upstream-fields.json | 2121 +++++++++++++++++ contracts/agents-api/upstream-routes.json | 70 + contracts/agents-api/v1/model_execution.go | 1 - .../agents-api/v1/runtime_history_test.go | 2 +- .../agents-api/v1/runtime_observations.go | 8 - .../agents-api/v1/upstream_contract_test.go | 226 ++ docs/api/README.md | 11 +- docs/api/public-agent-api.md | 28 +- docs/web/protocol-coverage.md | 3 +- .../agents-client/saved-agent-defaults.md | 5 +- packages/agents-client/src/client.test.ts | 72 +- packages/agents-client/src/client.ts | 101 - .../src/execution-configuration.test.ts | 17 +- .../agents-client/src/runtime-history.test.ts | 23 +- .../agents-client/src/sandbox-client.test.ts | 16 +- packages/agents-client/src/sandbox-client.ts | 18 - .../src/startup-configuration.test.ts | 20 +- packages/agents-client/src/types.ts | 14 +- scripts/extract-agents-api-upstream.py | 181 ++ services/agents-api/HOSTED-SANDBOX-MANAGER.md | 16 +- services/agents-api/README.md | 2 - .../internal/api/admin_resources.go | 7 +- .../internal/api/admin_resources_test.go | 24 + .../internal/api/admin_sessions_routes.go | 6 +- services/agents-api/internal/api/handler.go | 13 - .../agents-api/internal/api/routing_test.go | 22 +- .../internal/api/runtime_history.go | 24 +- .../internal/api/runtime_history_test.go | 36 +- .../internal/api/runtime_observations.go | 70 +- .../internal/api/runtime_observations_test.go | 127 +- .../internal/api/sandbox_placement.go | 73 - .../internal/api/sandbox_selector_test.go | 49 +- .../api/session_execution_configuration.go | 11 +- .../session_execution_configuration_test.go | 6 +- .../internal/api/session_model_defaults.go | 9 +- .../internal/api/session_request.go | 2 +- .../internal/api/startup_configuration.go | 10 +- .../api/startup_configuration_test.go | 18 +- .../store/admin_session_archive_race_test.go | 8 +- .../store/admin_session_archive_test.go | 8 +- .../store/device_bootstrap_binding_test.go | 2 +- .../internal/store/runtime_adoption_test.go | 4 +- .../internal/store/runtime_idle_clock_test.go | 2 +- .../store/runtime_lifecycle_nodes_test.go | 2 +- .../runtime_node_lifecycle_fixture_test.go | 10 +- .../internal/store/runtime_node_types.go | 8 - .../internal/store/runtime_nodes_test.go | 75 +- .../store/runtime_observation_test.go | 2 +- .../internal/store/runtime_placements.go | 34 +- .../sandbox_deployment_resources_test.go | 4 +- .../store/sandbox_deployment_setup_test.go | 2 +- .../store/sandbox_deployment_switch_test.go | 13 +- .../store/sandbox_specification_store_test.go | 4 +- .../internal/store/session_deletion_test.go | 2 +- .../internal/store/session_initial_input.go | 12 +- .../agents-api/internal/store/sessions.go | 6 +- .../agents-api/tests/runtime_history_live.py | 175 -- 67 files changed, 3124 insertions(+), 2277 deletions(-) create mode 100644 contracts/agents-api/upstream-fields.json create mode 100644 contracts/agents-api/upstream-routes.json create mode 100644 contracts/agents-api/v1/upstream_contract_test.go create mode 100644 scripts/extract-agents-api-upstream.py delete mode 100644 services/agents-api/internal/api/sandbox_placement.go delete mode 100644 services/agents-api/tests/runtime_history_live.py diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 2caa61f2b..ff3cc139a 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -905,10 +905,8 @@ capacity transactions and revision/one-shot receipts remain authoritative, with no external operation holding a database lock. Commit environment-to-node placement with Session creation and its creation retry -identity. Automatic selection chooses an eligible node; explicit -`x_agents_core.sandbox_node_id` fails if unavailable or full. The optional -model-provider extension remains independent. Existing retries keep their original -node even when it is offline. Node capacity counts pending reservations and +identity. Placement is automatic: Core chooses an eligible node, and callers cannot +select one. Existing retries keep their original node even when it is offline. Node capacity counts pending reservations and unresolved resources; new placement and suspended-to-restoring admission share a database lock. Confirmed cleanup releases placement capacity. Retained ownership requires exact provider evidence; a socket path, missing instance or empty listing cannot prove @@ -2232,16 +2230,16 @@ replaced; do not carry obsolete compatibility code forward to satisfy this secti retain one source. Project reads redact all deployment provider details. Old Sessions expose persisted model/harness with unknown sources and unavailable provider metadata, without backfill. Projection metadata does not alter retry - identity; retries cannot replace it. Keep this query separate from runtime - observations and do not touch activity or wake sandboxes. The versioned contract - is `contracts/agents-api/execution-configuration.md`. + identity; retries cannot replace it. Keep this administrator query separate from + runtime observations and do not touch activity or wake sandboxes. The versioned + contract is `contracts/agents-api/execution-configuration.md`. - Provider input validation uses the adapter-owned rules in `internal/harnessconfig`. Keep one internal registry for protocol and token-limit validation; Core owns credential environment and endpoint admission policy. These rules are not a public discovery API or Runtime registration descriptor. Operation qualification and live readiness retain their existing owners. The Core startup view keeps its basic supported/configured deployment snapshot and accepts no query parameters. - Session frozen execution-configuration reads remain a separate Core extension. + Session frozen execution-configuration reads remain a separate administrator read. - Public Agent updates use `POST /v1/agents/{agent_id}` with the same tenant/Beta boundary and shared saved-field validation. Preserve omission separately from null; only supplied fields replace saved values. Metadata is a separate whole-map @@ -3444,8 +3442,7 @@ retain the official Agent response shape. See the [extension contract](contracts for null/retry behavior and operator configuration. Hosted provider selection belongs to deployment configuration and is independent -of the engine. Session creation fixes a node through a Core extension or automatic -placement; retained allocations keep that node and provider identity. Runtime images must satisfy their existing qualification rules. +of the engine. Session creation fixes a node through automatic placement; retained allocations keep that node and provider identity. Runtime images must satisfy their existing qualification rules. Transient model options are partitioned by engine and must not expose another engine's credentials. Do not infer an engine from a model name or template. diff --git a/contracts/agents-api/README.md b/contracts/agents-api/README.md index 009e82007..0eff49a4c 100644 --- a/contracts/agents-api/README.md +++ b/contracts/agents-api/README.md @@ -11,6 +11,10 @@ The external reference is [openai-python beta/agents](https://github.com/openai/ pinned in `upstream.json`. Its resource methods, corresponding types, pagination and streaming helpers define the compatibility target. This directory records the boundary; it does not imply that every upstream feature is implemented. +`upstream-routes.json` and `upstream-fields.json` are extracted from that SDK by +`scripts/extract-agents-api-upstream.py` (run it with the pinned SDK installed). +Contract tests require `openapi.yaml` to have exactly those method and path pairs +and to keep every non-official field inside `x_agents_core`. Parsar owns product Agents and Teams. This service owns upstream execution resources, including reusable Agents and protocol subagents. The OpenAI Agents @@ -118,14 +122,16 @@ paths start at `/vaults`, not `/agents/vaults`. The operations below are implemented Core extensions. They are excluded from the 42-operation upstream inventory and must not be counted as OpenAI Agents -compatibility. +compatibility. None is under `/v1`: [upstream-routes.json](upstream-routes.json) +pins the exact `/v1` route set, and `/v1` objects carry Core fields only inside +`x_agents_core`. | Extension | Operations | Current coverage | | --- | --- | --- | | Administration | `/core/v1/admin/**` | Deployment-authenticated Projects with shared keys, read/delete projections, atomic asset copies, summary, runtime observations and audit. Separate from caller authority; see [Administrator API](admin-api.md). | | API-key provenance | Project-scoped `/core/v1/admin/projects/{project_id}/resource-owners` and `/write-operations` | Batch ownership and retained cursor-paginated write history; see [write provenance](write-audit.md). | -| Runtime observations | `GET /v1/agents/runtime-observations`; `GET /v1/agents/sessions/{session_id}/runtime-observation` | Current, read-only, tenant-scoped Session contexts with stable Session-keyset pagination, bounded concurrent sampling, Docker and microsandbox metrics, explicit unsupported/unavailable states, strict `packages/agents-client` projection, and no lifecycle mutation. Kubernetes, E2B, self-hosted telemetry, and automatic idle policy remain unimplemented. See [Runtime observation API](runtime-observability-api.md). | -| Runtime history | `GET /v1/agents/runtime-history/capabilities`; `GET /v1/agents/sessions/{session_id}/runtime-history` | Optional backend-neutral capability and bounded tenant/Session-scoped history contract with allocation/incarnation fencing, explicit coverage and strict client projection. Disabled by default until a production Reader and qualified periodic collection are configured; Durable Web rendering remains pending. See [Runtime history API](runtime-history-api.md). | +| Runtime observations | `GET /core/v1/admin/runtime-observations`; `GET /core/v1/admin/projects/{project_id}/sessions/{session_id}/runtime-observation` | Administrator only; the `/v1` forms are removed. Current, read-only Session contexts labelled by Project, with stable Session-keyset pagination, bounded concurrent sampling, Docker and microsandbox metrics, explicit unsupported/unavailable states, strict `AdminClient` projection, and no lifecycle mutation. Kubernetes, E2B, self-hosted telemetry, and automatic idle policy remain unimplemented. See [Runtime observation API](runtime-observability-api.md). | +| Runtime history | `GET /core/v1/admin/runtime-history/capabilities`; `GET /core/v1/admin/projects/{project_id}/sessions/{session_id}/runtime-history` | Administrator only; the `/v1` forms are removed. Optional backend-neutral capability and bounded Project/Session-scoped history contract with allocation/incarnation fencing, explicit coverage and strict client projection. Disabled by default until a production Reader and qualified periodic collection are configured; Durable Web rendering remains pending. See [Runtime history API](runtime-history-api.md). | For each resource, verify the referenced request/response unions and observable behavior, not just the route. Non-text initial input, configuration @@ -909,8 +915,8 @@ establish complete ownership, hosted key lifecycle or error compatibility. See t Core documents its optional [harness selection extension](harness-selection.md) separately from the pinned upstream contract. -The [Core startup configuration extension](startup-configuration.md) exposes only -safe build support and process configuration facts. It does not report Runtime, +The administrator [Core startup configuration](startup-configuration.md) read +exposes only safe build support and process configuration facts. It does not report Runtime, Session or Environment observations and is not a readiness endpoint. Model endpoints and credentials may be supplied at Session creation through the diff --git a/contracts/agents-api/admin-api.md b/contracts/agents-api/admin-api.md index feab614d4..9de2d1145 100644 --- a/contracts/agents-api/admin-api.md +++ b/contracts/agents-api/admin-api.md @@ -160,13 +160,16 @@ simultaneous deployment-wide snapshot. Totals are not billing records. returns `{object:"list", data:[{project_id, observation}], has_more, first_id, last_id}`. It reuses the bounded read-only Runtime sampler and never provisions compute; a provider with a batch metrics read (E2B) samples the page in one bounded request. -Each `observation` is the project Runtime observation plus `disk: +Each `observation` is the Runtime observation plus `disk: {usage_bytes, limit_bytes}` with memory's null rules: E2B fills it from its reported disk usage and capacity, Docker returns null, and microsandbox returns -null until its disk semantics are designed. The project-scoped observation -routes, including the per-Session administrator read, keep their shape. -`GET /runtime-history/capabilities` and `GET /startup-configuration` reuse the -existing non-secret project projections. +null until its disk semantics are designed. The per-Session administrator +observation read keeps the shape without `disk`. +`GET /runtime-history/capabilities` and `GET /startup-configuration` return the +non-secret [history capabilities](runtime-history-api.md) and +[startup configuration](startup-configuration.md). These reads and the per-Session +execution configuration, Runtime observation and Runtime history exist only here; +`/v1` has no equivalents. `GET /audit-log` lists administrator writes newest first with `project_id`, `resource_type`, `resource_id`, `action`, inclusive `created_after`, exclusive diff --git a/contracts/agents-api/execution-configuration.md b/contracts/agents-api/execution-configuration.md index 7fe6e4a6c..10811c65d 100644 --- a/contracts/agents-api/execution-configuration.md +++ b/contracts/agents-api/execution-configuration.md @@ -1,12 +1,15 @@ # Execution configuration queries -These read-only Core extensions describe configuration, not execution health. -They require the existing project bearer authentication and `OpenAI-Beta: -agents=v1`. They never contact a model provider, start a Turn or wake a sandbox. +These read-only administrator reads describe configuration, not execution health. +They require the deployment administrator credential. They never contact a model +provider, start a Turn or wake a sandbox. Applications read a Session's effective +harness from its `agent.x_agents_core`; the former project route +`GET /v1/agents/sessions/{session_id}/execution-configuration` is removed. ## Frozen Session selections -`GET /v1/agents/sessions/{session_id}/execution-configuration` returns: +`GET /core/v1/admin/projects/{project_id}/sessions/{session_id}/execution-configuration` +returns: ```json { @@ -39,8 +42,7 @@ The provider's `status` describes visibility: - `available`: a safe Session- or Agent-supplied bundle. Configuration contains protocol, endpoint, configured-key flag and optional token limits. - `redacted`: deployment-owned configuration. Source is `deployment` and - configuration is null. Project bearer credentials do not grant deployment - secret access; this API has no endpoint-reveal override. + configuration is null; this read has no endpoint-reveal override. - `unavailable`: no trustworthy safe provider projection was recorded. Source is `unknown` and configuration is null. This does not mean that execution failed. @@ -54,20 +56,20 @@ return the original Session and do not repair or overwrite its provenance. Historical Sessions without the projection report their persisted model/harness when present, with `unknown` sources; a missing value is null. Their provider view is `unavailable`, even if a private credential row exists. No backfill guesses -provenance. Missing, deleted and foreign-tenant Session IDs share the existing +provenance. Missing, deleted and other Projects' Session IDs share the existing not-found response. Responses are `Cache-Control: no-store`. Keys, ciphertext, secret references, native headers, query parameters and permissions are excluded. ## Discovery boundary Provider configuration discovery is not exposed. The Core startup-configuration -extension retains its basic supported/configured deployment snapshot and accepts +read retains its basic supported/configured deployment snapshot and accepts no query parameters, including the retired `include=configuration_capabilities`. Provider inputs are still validated against internal adapter-owned rules and Core admission policy. Removing discovery does not change the supported inputs or create/update/execute behavior. -This Session query and the startup view are Core extensions, not OpenAI Agents +This Session query and the startup view are administrator reads, not OpenAI Agents API operations. Ordinary Agent and Session operations retain their pinned upstream contracts. Neither query mutates configuration, rotates keys, migrates Sessions, exposes a model catalog or accepts arbitrary native options. See diff --git a/contracts/agents-api/model-execution.md b/contracts/agents-api/model-execution.md index 803b3c910..42450f1d9 100644 --- a/contracts/agents-api/model-execution.md +++ b/contracts/agents-api/model-execution.md @@ -86,7 +86,9 @@ See the [TypeScript client example](../../packages/agents-client/saved-agent-def The endpoint must use HTTPS without embedded credentials, a query or a fragment. Keys must be nonempty, at most 16 KiB, and contain no NUL/CR/LF. Unknown fields and unsupported protocol/Harness/environment combinations are rejected before creating -a Session. Context/output limits are optional nonnegative integers, with output no +a Session. The Session's `x_agents_core` accepts only `model_provider`; hosted node +placement is automatic, and the removed `sandbox_node_id` is rejected with 400 like +any other unknown member. Context/output limits are optional nonnegative integers, with output no larger than context; both must be positive for MiniMax Code. Use the actual model's limits. Native provider availability is checked during execution, not by a new probe. `agent.model` retains its exact meaning; this extension never changes model identity. diff --git a/contracts/agents-api/openapi.yaml b/contracts/agents-api/openapi.yaml index b7b463bbd..288a0c388 100644 --- a/contracts/agents-api/openapi.yaml +++ b/contracts/agents-api/openapi.yaml @@ -1,36 +1,5 @@ basePath: /v1 definitions: - api.SandboxNodeDirectory: - properties: - data: - items: - $ref: '#/definitions/api.SandboxNodeDirectoryEntry' - type: array - type: object - api.SandboxNodeDirectoryEntry: - properties: - available: - type: boolean - id: - type: string - name: - type: string - type: object - api.SandboxSessionPlacement: - properties: - available: - type: boolean - compute_phase: - type: string - diagnostic: - type: string - node_id: - type: string - node_name: - type: string - state: - type: string - type: object v1.APIError: properties: code: @@ -128,112 +97,6 @@ definitions: required: - harness type: object - v1.CoreConfiguredStartupConfiguration: - properties: - daemon_gateway: - type: boolean - default_harness: - enum: - - codex - - claude_sdk - - mcode - type: string - enabled_harnesses: - items: - enum: - - claude_sdk - - codex - - mcode - type: string - type: array - managed_sandbox: - $ref: '#/definitions/v1.CoreManagedSandboxConfiguration' - model_providers: - items: - $ref: '#/definitions/v1.CoreHarnessModelProviderConfiguration' - type: array - self_hosted: - type: boolean - required: - - daemon_gateway - - default_harness - - enabled_harnesses - - managed_sandbox - - model_providers - - self_hosted - type: object - v1.CoreHarnessModelProviderConfiguration: - properties: - endpoint_configured: - type: boolean - harness: - enum: - - codex - - claude_sdk - - mcode - type: string - required: - - endpoint_configured - - harness - type: object - v1.CoreManagedSandboxConfiguration: - properties: - enabled: - type: boolean - maintenance: - type: boolean - provider: - enum: - - docker - - microsandbox - type: string - x-nullable: true - required: - - enabled - - maintenance - - provider - type: object - v1.CoreStartupConfiguration: - properties: - configured: - $ref: '#/definitions/v1.CoreConfiguredStartupConfiguration' - object: - enum: - - agents.core.startup_configuration - type: string - schema_version: - enum: - - 1 - type: integer - supported: - $ref: '#/definitions/v1.CoreSupportedConfiguration' - required: - - configured - - object - - schema_version - - supported - type: object - v1.CoreSupportedConfiguration: - properties: - harnesses: - items: - enum: - - claude_sdk - - codex - - mcode - type: string - type: array - managed_sandbox_providers: - items: - enum: - - docker - - microsandbox - type: string - type: array - required: - - harnesses - - managed_sandbox_providers - type: object v1.CreateAgentRequest: properties: instructions: @@ -832,46 +695,6 @@ definitions: required: - error type: object - v1.ExecutionProviderSelection: - properties: - configuration: - allOf: - - $ref: '#/definitions/v1.ModelProviderView' - x-nullable: true - source: - enum: - - session - - agent - - deployment - - unknown - type: string - status: - enum: - - available - - redacted - - unavailable - type: string - required: - - configuration - - source - - status - type: object - v1.ExecutionSelection: - properties: - source: - enum: - - session - - agent - - deployment - - unknown - type: string - value: - type: string - x-nullable: true - required: - - source - - value - type: object v1.InlineAgent: properties: instructions: @@ -1157,647 +980,100 @@ definitions: scope: type: string x-nullable: true - token_endpoint: - type: string - token_endpoint_auth: - $ref: '#/definitions/v1.OAuthEndpointAuthInput' - required: - - client_id - - refresh_token - - token_endpoint - - token_endpoint_auth - type: object - v1.OAuthCredentialRefreshReplacement: - properties: - refresh_token: - type: string - x-nullable: true - scope: - type: string - x-nullable: true - token_endpoint_auth: - allOf: - - $ref: '#/definitions/v1.OAuthEndpointAuthReplacement' - x-nullable: true - type: object - v1.OAuthEndpointAuth: - properties: - type: - enum: - - none - - client_secret_basic - - client_secret_post - type: string - required: - - type - type: object - v1.OAuthEndpointAuthInput: - properties: - client_secret: - type: string - type: - enum: - - none - - client_secret_basic - - client_secret_post - type: string - required: - - type - type: object - v1.OAuthEndpointAuthReplacement: - properties: - client_secret: - type: string - x-nullable: true - type: - enum: - - client_secret_basic - - client_secret_post - type: string - required: - - type - type: object - v1.OutputTokenDetails: - properties: - reasoning_tokens: - type: integer - required: - - reasoning_tokens - type: object - v1.Reasoning: - properties: - effort: - type: string - x-nullable: true - summary: - type: string - x-nullable: true - type: object - v1.RequiredAction: - properties: - arguments: {} - call_id: - type: string - environment_id: - type: string - name: - type: string - turn_id: - type: string - type: - enum: - - function_call - - environment_connection - type: string - required: - - type - type: object - v1.RuntimeCPUObservation: - properties: - capacity_cores: - minimum: 5e-324 - type: number - x-nullable: true - usage_cores: - minimum: 0 - type: number - x-nullable: true - usage_seconds_total: - minimum: 0 - type: number - x-nullable: true - utilization_ratio: - minimum: 0 - type: number - x-nullable: true - required: - - capacity_cores - - usage_cores - - usage_seconds_total - - utilization_ratio - type: object - v1.RuntimeHistory: - properties: - coverage: - $ref: '#/definitions/v1.RuntimeHistoryCoverage' - generated_at: - maximum: 9007199254740991 - minimum: 0 - type: integer - object: - enum: - - agent.runtime_history - type: string - requested_range: - $ref: '#/definitions/v1.RuntimeHistoryRange' - resolution_seconds: - maximum: 9007199254740991 - minimum: 1 - type: integer - series: - items: - $ref: '#/definitions/v1.RuntimeHistorySeries' - maxItems: 1000 - type: array - session_id: - format: uuid - type: string - source: - enum: - - durable - type: string - token_usage: - items: - $ref: '#/definitions/v1.RuntimeHistoryTokenUsagePoint' - maxItems: 10000 - type: array - required: - - coverage - - generated_at - - object - - requested_range - - resolution_seconds - - series - - session_id - - source - - token_usage - type: object - v1.RuntimeHistoryCPU: - properties: - capacity_cores: - minimum: 5e-324 - type: number - x-nullable: true - contributor_count: - maximum: 9007199254740991 - minimum: 1 - type: integer - utilization_ratio: - minimum: 0 - type: number - x-nullable: true - required: - - capacity_cores - - contributor_count - - utilization_ratio - type: object - v1.RuntimeHistoryCapabilities: - properties: - available: - type: boolean - collection_mode: - enum: - - on_read - - periodic - type: string - x-nullable: true - maximum_points: - maximum: 10000 - minimum: 2 - type: integer - x-nullable: true - maximum_range_seconds: - maximum: 9007199254740991 - minimum: 1 - type: integer - x-nullable: true - metrics: - items: - enum: - - cpu - - memory - - tokens - type: string - maxItems: 3 - type: array - minimum_step_seconds: - maximum: 9007199254740991 - minimum: 1 - type: integer - x-nullable: true - object: - enum: - - agent.runtime_history_capabilities - type: string - reason: - enum: - - not_configured - - periodic_collection_required - type: string - x-nullable: true - retention_seconds: - maximum: 9007199254740991 - minimum: 1 - type: integer - x-nullable: true - sample_interval_seconds: - maximum: 9007199254740991 - minimum: 1 - type: integer - x-nullable: true - required: - - available - - collection_mode - - maximum_points - - maximum_range_seconds - - metrics - - minimum_step_seconds - - object - - reason - - retention_seconds - - sample_interval_seconds - type: object - v1.RuntimeHistoryCoverage: - properties: - buckets: - items: - $ref: '#/definitions/v1.RuntimeHistoryCoveragePoint' - maxItems: 10000 - type: array - expected_sample_count: - maximum: 9007199254740991 - minimum: 0 - type: integer - first_sample_at: - maximum: 9007199254740991 - minimum: 0 - type: integer - x-nullable: true - last_sample_at: - maximum: 9007199254740991 - minimum: 0 - type: integer - x-nullable: true - retained_start: - maximum: 9007199254740991 - minimum: 0 - type: integer - sample_count: - maximum: 9007199254740991 - minimum: 0 - type: integer - required: - - buckets - - expected_sample_count - - first_sample_at - - last_sample_at - - retained_start - - sample_count - type: object - v1.RuntimeHistoryCoveragePoint: - properties: - end: - maximum: 9007199254740991 - minimum: 1 - type: integer - first_observed_at: - maximum: 9007199254740991 - minimum: 0 - type: integer - x-nullable: true - last_observed_at: - maximum: 9007199254740991 - minimum: 0 - type: integer - x-nullable: true - observation_count: - maximum: 9007199254740991 - minimum: 0 - type: integer - observed_count: - maximum: 9007199254740991 - minimum: 0 - type: integer - start: - maximum: 9007199254740991 - minimum: 0 - type: integer - unavailable_count: - maximum: 9007199254740991 - minimum: 0 - type: integer - required: - - end - - first_observed_at - - last_observed_at - - observation_count - - observed_count - - start - - unavailable_count - type: object - v1.RuntimeHistoryMemory: - properties: - contributor_count: - maximum: 9007199254740991 - minimum: 1 - type: integer - limit_bytes: - maximum: 9007199254740991 - minimum: 1 - type: integer - x-nullable: true - usage_bytes: - maximum: 9007199254740991 - minimum: 0 - type: integer - x-nullable: true - required: - - contributor_count - - limit_bytes - - usage_bytes - type: object - v1.RuntimeHistoryPoint: - properties: - cpu: - allOf: - - $ref: '#/definitions/v1.RuntimeHistoryCPU' - x-nullable: true - end: - maximum: 9007199254740991 - minimum: 1 - type: integer - first_observed_at: - maximum: 9007199254740991 - minimum: 0 - type: integer - x-nullable: true - last_observed_at: - maximum: 9007199254740991 - minimum: 0 - type: integer - x-nullable: true - memory: - allOf: - - $ref: '#/definitions/v1.RuntimeHistoryMemory' - x-nullable: true - observation_count: - maximum: 9007199254740991 - minimum: 0 - type: integer - observed_count: - maximum: 9007199254740991 - minimum: 0 - type: integer - start: - maximum: 9007199254740991 - minimum: 0 - type: integer - unavailable_count: - maximum: 9007199254740991 - minimum: 0 - type: integer - required: - - cpu - - end - - first_observed_at - - last_observed_at - - memory - - observation_count - - observed_count - - start - - unavailable_count - type: object - v1.RuntimeHistoryRange: - properties: - end: - maximum: 9007199254740991 - minimum: 1 - type: integer - start: - maximum: 9007199254740991 - minimum: 0 - type: integer - required: - - end - - start - type: object - v1.RuntimeHistorySeries: - properties: - allocation_id: - format: uuid - type: string - environment_id: - format: uuid - type: string - points: - items: - $ref: '#/definitions/v1.RuntimeHistoryPoint' - maxItems: 10000 - type: array - provider_type: - pattern: '^[a-z][a-z0-9_]{0,31}$' - type: string - started_at: - $ref: '#/definitions/v1.RuntimeHistoryTime' - required: - - allocation_id - - environment_id - - points - - provider_type - - started_at - type: object - v1.RuntimeHistoryTime: - properties: - nanoseconds: - maximum: 999999999 - minimum: 0 - type: integer - seconds: - maximum: 9007199254740991 - minimum: 0 - type: integer - required: - - nanoseconds - - seconds - type: object - v1.RuntimeHistoryTokenUsagePoint: - properties: - end: - maximum: 9007199254740991 - minimum: 1 - type: integer - input_tokens: - maximum: 9007199254740991 - minimum: 0 - type: integer - output_tokens: - maximum: 9007199254740991 - minimum: 0 - type: integer - sampled_at: - maximum: 9007199254740991 - minimum: 0 - type: integer - start: - maximum: 9007199254740991 - minimum: 0 - type: integer - required: - - end - - input_tokens - - output_tokens - - sampled_at - - start - type: object - v1.RuntimeInstance: - properties: - allocation_id: - format: uuid - type: string - x-nullable: true - connection_generation: - format: uuid - type: string - x-nullable: true - device_id: - format: uuid - type: string - x-nullable: true - kind: - enum: - - managed_allocation - - self_hosted_connection - - none - type: string - required: - - allocation_id - - connection_generation - - device_id - - kind - type: object - v1.RuntimeMemoryObservation: - properties: - limit_bytes: - minimum: 1 - type: integer - x-nullable: true - usage_bytes: - minimum: 0 - type: integer - x-nullable: true + token_endpoint: + type: string + token_endpoint_auth: + $ref: '#/definitions/v1.OAuthEndpointAuthInput' required: - - limit_bytes - - usage_bytes + - client_id + - refresh_token + - token_endpoint + - token_endpoint_auth type: object - v1.RuntimeObservation: + v1.OAuthCredentialRefreshReplacement: properties: - allocation_created_at: - minimum: 0 - type: integer - x-nullable: true - cpu: - allOf: - - $ref: '#/definitions/v1.RuntimeCPUObservation' - x-nullable: true - environment_id: - format: uuid + refresh_token: type: string x-nullable: true - id: - format: uuid - type: string - instance: - $ref: '#/definitions/v1.RuntimeInstance' - lifecycle_state: - enum: - - active - - sleeping - - transitioning - - pending - - stopped + scope: type: string x-nullable: true - memory: + token_endpoint_auth: allOf: - - $ref: '#/definitions/v1.RuntimeMemoryObservation' + - $ref: '#/definitions/v1.OAuthEndpointAuthReplacement' x-nullable: true - mode: + type: object + v1.OAuthEndpointAuth: + properties: + type: enum: - none - - self_hosted - - openai_hosted - type: string - object: - enum: - - agent.runtime_observation + - client_secret_basic + - client_secret_post type: string - observed_at: - minimum: 0 - type: integer - x-nullable: true - provider_type: + required: + - type + type: object + v1.OAuthEndpointAuthInput: + properties: + client_secret: type: string - x-nullable: true - reason: + type: enum: - - runtime_mode_not_observable - - allocation_pending - - runtime_not_running - - source_not_configured - - sample_timeout - - sample_unavailable + - none + - client_secret_basic + - client_secret_post type: string - x-nullable: true - resolved_at: - minimum: 0 - type: integer - session_id: - format: uuid + required: + - type + type: object + v1.OAuthEndpointAuthReplacement: + properties: + client_secret: type: string - started_at: - minimum: 0 - type: integer x-nullable: true - status: + type: enum: - - observed - - unsupported - - unavailable + - client_secret_basic + - client_secret_post type: string required: - - allocation_created_at - - cpu - - environment_id - - id - - instance - - lifecycle_state - - memory - - mode - - object - - observed_at - - provider_type - - reason - - resolved_at - - session_id - - started_at - - status + - type type: object - v1.RuntimeObservationList: + v1.OutputTokenDetails: properties: - data: - items: - $ref: '#/definitions/v1.RuntimeObservation' - type: array - first_id: - format: uuid + reasoning_tokens: + type: integer + required: + - reasoning_tokens + type: object + v1.Reasoning: + properties: + effort: type: string x-nullable: true - has_more: - type: boolean - last_id: - format: uuid + summary: type: string x-nullable: true - object: + type: object + v1.RequiredAction: + properties: + arguments: {} + call_id: + type: string + environment_id: + type: string + name: + type: string + turn_id: + type: string + type: enum: - - list + - function_call + - environment_connection type: string required: - - data - - first_id - - has_more - - last_id - - object + - type type: object v1.SavedAgent: properties: @@ -2187,38 +1463,10 @@ definitions: - event_id - type type: object - v1.SessionExecutionConfiguration: - properties: - harness: - $ref: '#/definitions/v1.ExecutionSelection' - model: - $ref: '#/definitions/v1.ExecutionSelection' - model_provider: - $ref: '#/definitions/v1.ExecutionProviderSelection' - object: - enum: - - agent.session.execution_configuration - type: string - schema_version: - enum: - - 1 - type: integer - session_id: - type: string - required: - - harness - - model - - model_provider - - object - - schema_version - - session_id - type: object v1.SessionExecutionInput: properties: model_provider: $ref: '#/definitions/v1.ModelProviderInput' - sandbox_node_id: - type: string type: object v1.SessionInput: properties: @@ -3155,43 +2403,6 @@ paths: summary: Update a reusable Agent tags: - Agents - /agents/core/startup-configuration: - get: - description: Returns a secret-free snapshot of supported build capabilities - and validated process startup selections. It does not inspect or aggregate - daemon heartbeats, Sessions, Environments or Runtime state, and does not prove - model-provider reachability, credentials, native readiness, sandbox isolation - or successful execution. - parameters: - - description: agents=v1 - in: header - name: OpenAI-Beta - required: true - type: string - produces: - - application/json - responses: - "200": - description: OK - schema: - $ref: '#/definitions/v1.CoreStartupConfiguration' - "400": - description: Bad Request - schema: - $ref: '#/definitions/v1.ErrorResponse' - "401": - description: Unauthorized - schema: - $ref: '#/definitions/v1.ErrorResponse' - "503": - description: Service Unavailable - schema: - $ref: '#/definitions/v1.ErrorResponse' - security: - - BearerAuth: [] - summary: Retrieve safe Core startup configuration - tags: - - Core extensions /agents/environments/{environment_id}: get: description: Returns durable connection status and safe installed metadata for @@ -3564,110 +2775,7 @@ paths: type: string - description: Template ID in: path - name: environment_template_id - required: true - type: string - produces: - - application/json - responses: - "200": - description: OK - schema: - $ref: '#/definitions/v1.EnvironmentTemplate' - "400": - description: Bad Request - schema: - $ref: '#/definitions/v1.ErrorResponse' - "401": - description: Unauthorized - schema: - $ref: '#/definitions/v1.ErrorResponse' - "404": - description: Not Found - schema: - $ref: '#/definitions/v1.ErrorResponse' - "500": - description: Internal Server Error - schema: - $ref: '#/definitions/v1.ErrorResponse' - security: - - BearerAuth: [] - summary: Retrieve an Environment Template - tags: - - Environment Templates - post: - consumes: - - application/json - description: Supplied fields replace atomically; omitted fields remain unchanged. - Null name clears and null network resets to the pinned enabled default. Existing - Session snapshots and creation retries remain unchanged. Initial files replace - as a list; null/empty clears. File data is encrypted separately and excluded - from response metadata. Skills replace as a list; null/empty clears. Skill - archives are encrypted separately and omitted from responses. Plugins and - capability directories replace as lists; null/empty clears. Plugin archives - are encrypted and omitted from responses. Capability directories are snapshotted - after setup. Environment MCP execution requires a qualified native transport - and runtime network policy. Empty updates advance updated_at without changing - saved fields or confidential contents. Network policy rejections return invalid_request_error - with a null param. - parameters: - - description: agents=v1 - in: header - name: OpenAI-Beta - required: true - type: string - - description: Template ID - in: path - name: environment_template_id - required: true - type: string - - description: Configuration replacements - in: body - name: body - required: true - schema: - $ref: '#/definitions/v1.EnvironmentTemplateRequest' - produces: - - application/json - responses: - "200": - description: OK - schema: - $ref: '#/definitions/v1.EnvironmentTemplate' - "400": - description: Bad Request - schema: - $ref: '#/definitions/v1.ErrorResponse' - "401": - description: Unauthorized - schema: - $ref: '#/definitions/v1.ErrorResponse' - "404": - description: Not Found - schema: - $ref: '#/definitions/v1.ErrorResponse' - "413": - description: Request Entity Too Large - schema: - $ref: '#/definitions/v1.ErrorResponse' - "500": - description: Internal Server Error - schema: - $ref: '#/definitions/v1.ErrorResponse' - security: - - BearerAuth: [] - summary: Update an Environment Template - tags: - - Environment Templates - /agents/runtime-history/capabilities: - get: - description: Core extension advertising only safe backend-neutral Durable history - availability and bounds. Available is true only when a query Reader and qualified - periodic collection are both configured. - parameters: - - description: agents=v1 - in: header - name: OpenAI-Beta + name: environment_template_id required: true type: string produces: @@ -3676,7 +2784,7 @@ paths: "200": description: OK schema: - $ref: '#/definitions/v1.RuntimeHistoryCapabilities' + $ref: '#/definitions/v1.EnvironmentTemplate' "400": description: Bad Request schema: @@ -3685,48 +2793,58 @@ paths: description: Unauthorized schema: $ref: '#/definitions/v1.ErrorResponse' + "404": + description: Not Found + schema: + $ref: '#/definitions/v1.ErrorResponse' + "500": + description: Internal Server Error + schema: + $ref: '#/definitions/v1.ErrorResponse' security: - BearerAuth: [] - summary: Retrieve Runtime history capabilities + summary: Retrieve an Environment Template tags: - - Runtime history - /agents/runtime-observations: - get: - description: Core extension listing one current Runtime context per tenant-owned - Session in Session creation order. Each row has an independent resolved_at - and optional provider observed_at; the page is not an atomic telemetry snapshot. + - Environment Templates + post: + consumes: + - application/json + description: Supplied fields replace atomically; omitted fields remain unchanged. + Null name clears and null network resets to the pinned enabled default. Existing + Session snapshots and creation retries remain unchanged. Initial files replace + as a list; null/empty clears. File data is encrypted separately and excluded + from response metadata. Skills replace as a list; null/empty clears. Skill + archives are encrypted separately and omitted from responses. Plugins and + capability directories replace as lists; null/empty clears. Plugin archives + are encrypted and omitted from responses. Capability directories are snapshotted + after setup. Environment MCP execution requires a qualified native transport + and runtime network policy. Empty updates advance updated_at without changing + saved fields or confidential contents. Network policy rejections return invalid_request_error + with a null param. parameters: - description: agents=v1 in: header name: OpenAI-Beta required: true type: string - - description: Last observation ID from the previous page - in: query - name: after - type: string - - default: 20 - description: Page size - in: query - maximum: 100 - minimum: 1 - name: limit - type: integer - - default: desc - description: Session creation order - enum: - - asc - - desc - in: query - name: order + - description: Template ID + in: path + name: environment_template_id + required: true type: string + - description: Configuration replacements + in: body + name: body + required: true + schema: + $ref: '#/definitions/v1.EnvironmentTemplateRequest' produces: - application/json responses: "200": description: OK schema: - $ref: '#/definitions/v1.RuntimeObservationList' + $ref: '#/definitions/v1.EnvironmentTemplate' "400": description: Bad Request schema: @@ -3739,19 +2857,19 @@ paths: description: Not Found schema: $ref: '#/definitions/v1.ErrorResponse' - "500": - description: Internal Server Error + "413": + description: Request Entity Too Large schema: $ref: '#/definitions/v1.ErrorResponse' - "503": - description: Service Unavailable + "500": + description: Internal Server Error schema: $ref: '#/definitions/v1.ErrorResponse' security: - BearerAuth: [] - summary: List current Runtime observations + summary: Update an Environment Template tags: - - Runtime observations + - Environment Templates /agents/sessions: get: description: Cursor and results are scoped to the authenticated execution tenant; @@ -4537,56 +3655,6 @@ paths: summary: Submit Session input events tags: - Sessions - /agents/sessions/{session_id}/execution-configuration: - get: - description: Returns the committed model, harness and safe provider selection - with recorded sources. This read never decrypts credentials, resolves current - defaults or probes execution health. Deployment provider details remain redacted - for project callers. Historical provenance and missing provider projections - are explicitly unknown/unavailable. - parameters: - - description: agents=v1 - in: header - name: OpenAI-Beta - required: true - type: string - - description: Session ID - in: path - name: session_id - required: true - type: string - produces: - - application/json - responses: - "200": - description: OK - schema: - $ref: '#/definitions/v1.SessionExecutionConfiguration' - "400": - description: Bad Request - schema: - $ref: '#/definitions/v1.ErrorResponse' - "401": - description: Unauthorized - schema: - $ref: '#/definitions/v1.ErrorResponse' - "404": - description: Not Found - schema: - $ref: '#/definitions/v1.ErrorResponse' - "500": - description: Internal Server Error - schema: - $ref: '#/definitions/v1.ErrorResponse' - "503": - description: Service Unavailable - schema: - $ref: '#/definitions/v1.ErrorResponse' - security: - - BearerAuth: [] - summary: Retrieve frozen Session execution configuration - tags: - - Core extensions /agents/sessions/{session_id}/items: get: description: Returns supported message and tool Items in first-observation order. @@ -4652,163 +3720,6 @@ paths: summary: List persisted execution Items tags: - Items - /agents/sessions/{session_id}/runtime-history: - get: - description: Core extension returning tenant-scoped stored Runtime observations - for one Session. End is exclusive; the server selects a bounded resolution. - Responses contain at most 1,000 series, 10,000 points per coverage/series - array, and 100,000 total coverage plus series points. It never reads or changes - live compute. - parameters: - - description: agents=v1 - in: header - name: OpenAI-Beta - required: true - type: string - - description: Session ID - in: path - name: session_id - required: true - type: string - - description: Inclusive Unix-second start - in: query - maximum: 9007199254740991 - minimum: 0 - name: start - required: true - type: integer - - description: Exclusive Unix-second end - in: query - maximum: 9007199254740991 - minimum: 1 - name: end - required: true - type: integer - - description: Maximum points per series; defaults to the lower of 120 and the - advertised service maximum - in: query - maximum: 10000 - minimum: 2 - name: max_points - type: integer - produces: - - application/json - responses: - "200": - description: OK - schema: - $ref: '#/definitions/v1.RuntimeHistory' - "400": - description: Bad Request - schema: - $ref: '#/definitions/v1.ErrorResponse' - "401": - description: Unauthorized - schema: - $ref: '#/definitions/v1.ErrorResponse' - "404": - description: Not Found - schema: - $ref: '#/definitions/v1.ErrorResponse' - "409": - description: Conflict - schema: - $ref: '#/definitions/v1.ErrorResponse' - "503": - description: Service Unavailable - schema: - $ref: '#/definitions/v1.ErrorResponse' - security: - - BearerAuth: [] - summary: Retrieve Session Runtime history - tags: - - Runtime history - /agents/sessions/{session_id}/runtime-observation: - get: - description: Core extension returning one tenant-scoped, read-only current Runtime - observation. It never provisions, renews, restarts, pauses or stops compute. - parameters: - - description: agents=v1 - in: header - name: OpenAI-Beta - required: true - type: string - - description: Session ID - in: path - name: session_id - required: true - type: string - produces: - - application/json - responses: - "200": - description: OK - schema: - $ref: '#/definitions/v1.RuntimeObservation' - "400": - description: Bad Request - schema: - $ref: '#/definitions/v1.ErrorResponse' - "401": - description: Unauthorized - schema: - $ref: '#/definitions/v1.ErrorResponse' - "404": - description: Not Found - schema: - $ref: '#/definitions/v1.ErrorResponse' - "500": - description: Internal Server Error - schema: - $ref: '#/definitions/v1.ErrorResponse' - "503": - description: Service Unavailable - schema: - $ref: '#/definitions/v1.ErrorResponse' - security: - - BearerAuth: [] - summary: Retrieve a Session Runtime observation - tags: - - Runtime observations - /agents/sessions/{session_id}/sandbox-placement: - get: - description: Core extension. Reads the caller-owned Session's fixed sandbox - node, independently of node connectivity. - parameters: - - description: agents=v1 - in: header - name: OpenAI-Beta - required: true - type: string - - description: Session ID - in: path - name: session_id - required: true - type: string - produces: - - application/json - responses: - "200": - description: OK - schema: - $ref: '#/definitions/api.SandboxSessionPlacement' - "401": - description: Unauthorized - schema: - $ref: '#/definitions/v1.ErrorResponse' - "404": - description: Not Found - schema: - $ref: '#/definitions/v1.ErrorResponse' - "500": - description: Internal Server Error - schema: - $ref: '#/definitions/v1.ErrorResponse' - security: - - BearerAuth: [] - summary: Retrieve a Session sandbox placement - tags: - - Core /agents/sessions/{session_id}/subagents: get: description: Includes nested and closed Subagents. Cursors are Subagents of @@ -5576,36 +4487,6 @@ paths: summary: Download source file bytes tags: - Files - /sandbox/nodes: - get: - description: Core extension. Returns only selector identity, label and admission - availability for project clients. - parameters: - - description: agents=v1 - in: header - name: OpenAI-Beta - required: true - type: string - produces: - - application/json - responses: - "200": - description: OK - schema: - $ref: '#/definitions/api.SandboxNodeDirectory' - "401": - description: Unauthorized - schema: - $ref: '#/definitions/v1.ErrorResponse' - "500": - description: Internal Server Error - schema: - $ref: '#/definitions/v1.ErrorResponse' - security: - - BearerAuth: [] - summary: List hosted sandbox node selectors - tags: - - Core /skills: get: description: Lists tenant-owned metadata in timestamp order. Default page size diff --git a/contracts/agents-api/runtime-history-api.md b/contracts/agents-api/runtime-history-api.md index aba2c10a5..8f7266828 100644 --- a/contracts/agents-api/runtime-history-api.md +++ b/contracts/agents-api/runtime-history-api.md @@ -5,15 +5,17 @@ History ranges implemented. Core uses its existing database; the execution owner samples every 30 seconds by default. API-only processes without an execution worker advertise on-read collection rather than claiming periodic coverage. -This is an Agents Core extension. It is read-only and backend-neutral. The browser +These are read-only, backend-neutral administrator reads under `/core/v1/admin`, +authenticated by the deployment administrator credential. The former project routes +`GET /v1/agents/runtime-history/capabilities` and +`GET /v1/agents/sessions/{session_id}/runtime-history` are removed. The browser never receives a storage endpoint, OTLP credential, provider-native identity or tenant selector. ## Capability discovery ```http -GET /v1/agents/runtime-history/capabilities -OpenAI-Beta: agents=v1 +GET /core/v1/admin/runtime-history/capabilities Authorization: Bearer ... ``` @@ -44,8 +46,7 @@ URLs, credentials, table names, and tenant data are never capability fields. ## Session history ```http -GET /v1/agents/sessions/{session_id}/runtime-history?start=1789951200&end=1789954800&max_points=120 -OpenAI-Beta: agents=v1 +GET /core/v1/admin/projects/{project_id}/sessions/{session_id}/runtime-history?start=1789951200&end=1789954800&max_points=120 Authorization: Bearer ... ``` @@ -55,9 +56,9 @@ selects an effective whole-second resolution. Unknown parameters, duplicate parameters, negative timestamps, invalid ranges, and invalid point limits are rejected before a Reader query. -The caller supplies only a Session ID. Core obtains the tenant from authentication, -resolves the Session and Environment from its store, and only then calls the -Reader. Missing and foreign Sessions remain indistinguishable. Allocation IDs, +The caller supplies a Project ID and a Session ID. Core resolves the Project's +tenant, then the Session and Environment from its store, and only then calls the +Reader. Missing Sessions and other Projects' Sessions remain indistinguishable. Allocation IDs, provider IDs, and backend labels are result identity, never authority-bearing query inputs. @@ -136,8 +137,8 @@ separate sources and are never silently merged. | HTTP | Code | Meaning | | --- | --- | --- | | 400 | `unsupported_parameter` or `invalid_request` | Invalid query shape or range. | -| 401 | null (type `invalid_request_error`) | Missing or invalid authentication. | -| 404 | `not_found` | Missing or foreign Session. | +| 401 | `invalid_admin_key` | Missing or invalid administrator credential. | +| 404 | `not_found` | Missing Project, or a Session outside it. | | 409 | `runtime_history_unsupported` | The Session has no supported managed Runtime history scope. | | 503 | `runtime_history_unavailable` | Durable history is unconfigured, timed out, unavailable, or returned malformed data. | @@ -151,9 +152,9 @@ inside the half-open request range, and contain valid safe JSON values. `packages/agents-client` exposes: ```ts -interface AgentCore { +class AdminClient { getRuntimeHistoryCapabilities(options?: ReadOptions): Promise; - retrieveRuntimeHistory(sessionId: string, query: { + retrieveRuntimeHistory(projectId: string, sessionId: string, query: { start: number; end: number; maxPoints?: number; @@ -165,7 +166,7 @@ interface AgentCore { The client validates exact fields, capability consistency, requested-range echo, Session identity, half-open bucket ordering, coverage totals, allocation identity, contributor counts, token usage ordering, nullability, finite numbers, and response size. Unknown fields -or malformed data reject the entire response with a 502 client projection error. +or malformed data reject the entire response with a 502 `invalid_admin_response` error. ## Explicit boundaries diff --git a/contracts/agents-api/runtime-observability-api.md b/contracts/agents-api/runtime-observability-api.md index 86cae296a..6c9bbd840 100644 --- a/contracts/agents-api/runtime-observability-api.md +++ b/contracts/agents-api/runtime-observability-api.md @@ -7,17 +7,17 @@ identity joins. Durable history uses the separate optional [Runtime history API](runtime-history-api.md); lifecycle controls remain outside this phase. -This is an Agents Core extension, not an upstream OpenAI Agents resource. The -implementation must record that status in the coverage ledger and generated -OpenAPI contract. +These are administrator reads under `/core/v1/admin`, authenticated by the +deployment administrator credential, not upstream OpenAI Agents resources. The +former project routes `GET /v1/agents/runtime-observations` and +`GET /v1/agents/sessions/{session_id}/runtime-observation` are removed. ## Routes ### List current Runtime observations ```http -GET /v1/agents/runtime-observations?after={target_id}&limit=20&order=desc -OpenAI-Beta: agents=v1 +GET /core/v1/admin/runtime-observations?after={session_id}&limit=20&order=desc Authorization: Bearer ... ``` @@ -27,9 +27,9 @@ Authorization: Bearer ... | `limit` | Integer 1–100, default 20. | | `order` | `asc` or `desc`, default `desc`. | -The list contains one current Runtime context for every Session visible to the -authenticated tenant, including explicit `none`, unsupported `self_hosted`, and -released managed contexts. Ordering uses the same Session creation-time and ID +The list contains one current Runtime context for every Session of every managed +Project, labelled with its owning `project_id`, including explicit `none`, +unsupported `self_hosted`, and released managed contexts. Ordering uses the same Session creation-time and ID keyset as the Session list. An observation ID is the Session UUID, so pagination does not change when the underlying Runtime incarnation changes. Pages are not an atomic telemetry snapshot; every row has its own `resolved_at`, and a successful @@ -41,33 +41,37 @@ before publishing a new Dashboard snapshot. "object": "list", "data": [ { - "id": "6c77d3a2-71d6-4ed5-884f-687aecda02a3", - "object": "agent.runtime_observation", - "session_id": "6c77d3a2-71d6-4ed5-884f-687aecda02a3", - "environment_id": "6c02fb71-5fa8-4298-93e8-57c6625a3fc2", - "mode": "openai_hosted", - "provider_type": "docker", - "instance": { - "kind": "managed_allocation", - "allocation_id": "d23ab94e-e40b-45bd-93a2-444f1f74642b", - "device_id": "2e434f4f-76aa-4e54-a707-4757036d90ef", - "connection_generation": null - }, - "status": "observed", - "reason": null, - "allocation_created_at": 1789951200, - "resolved_at": 1789953021, - "observed_at": 1789953020, - "started_at": 1789951220, - "cpu": { - "usage_seconds_total": 482.75, - "capacity_cores": 2.0, - "usage_cores": null, - "utilization_ratio": null - }, - "memory": { - "usage_bytes": 805306368, - "limit_bytes": 2147483648 + "project_id": "3f0c2a9e-2b7d-4d0f-9a51-1c8e4b6d7a20", + "observation": { + "id": "6c77d3a2-71d6-4ed5-884f-687aecda02a3", + "object": "agent.runtime_observation", + "session_id": "6c77d3a2-71d6-4ed5-884f-687aecda02a3", + "environment_id": "6c02fb71-5fa8-4298-93e8-57c6625a3fc2", + "mode": "openai_hosted", + "provider_type": "docker", + "instance": { + "kind": "managed_allocation", + "allocation_id": "d23ab94e-e40b-45bd-93a2-444f1f74642b", + "device_id": "2e434f4f-76aa-4e54-a707-4757036d90ef", + "connection_generation": null + }, + "status": "observed", + "reason": null, + "allocation_created_at": 1789951200, + "resolved_at": 1789953021, + "observed_at": 1789953020, + "started_at": 1789951220, + "cpu": { + "usage_seconds_total": 482.75, + "capacity_cores": 2.0, + "usage_cores": null, + "utilization_ratio": null + }, + "memory": { + "usage_bytes": 805306368, + "limit_bytes": 2147483648 + }, + "disk": null } } ], @@ -80,17 +84,17 @@ before publishing a new Dashboard snapshot. ### Retrieve one Session's current Runtime observation ```http -GET /v1/agents/sessions/{session_id}/runtime-observation -OpenAI-Beta: agents=v1 +GET /core/v1/admin/projects/{project_id}/sessions/{session_id}/runtime-observation Authorization: Bearer ... ``` -This returns the same object shape as a list item. It never starts a Turn, creates +This returns the same object shape as a list item's `observation`, without the +administrator list's `disk` (see the [administrator contract](admin-api.md)). It never starts a Turn, creates an Environment, provisions compute, renews a lease, or changes lifecycle state. A valid `environment:none` Session returns `200` with status `unsupported`; the -Session exists but has no attributable Runtime instance. A missing or foreign -Session returns the existing indistinguishable not-found error. +Session exists but has no attributable Runtime instance. A missing Session, or one +outside the Project, returns the existing indistinguishable not-found error. ## Resource schema @@ -185,7 +189,7 @@ Use the existing Agents API error envelope. | --- | --- | --- | | 400 | `invalid_request_error` / `invalid_request_error` | List: a repeated supported query key, or an empty or invalid limit or order, with the shared Beta list messages. Unknown list query keys are ignored. | | 400 | `invalid_request_error` / `unsupported_parameter` | Single-Session retrieval with any query parameter. | -| 401 | `invalid_request_error` / null code | Missing or invalid API authentication. | +| 401 | `invalid_request_error` / `invalid_admin_key` | Missing or invalid administrator credential. | | 404 | `not_found_error` / `not_found_error` | Missing, malformed or foreign Session/cursor, indistinguishably, as for the [Session list cursor](list-query-semantics.md#list-cursor-errors--september-23-2026). | | 500 | `server_error` / `internal_error` | Integrity, ownership, or invalid provider evidence. | | 503 | `server_error` / `execution_unavailable` | Required Runtime observation service is not configured, or list collection exceeded its request budget. | @@ -221,22 +225,19 @@ type RuntimeObservation = | RuntimeNoneObservation | RuntimeSelfHostedObservation; -interface RuntimeObservationList { - object: "list"; - data: RuntimeObservation[]; - has_more: boolean; - first_id: string | null; - last_id: string | null; +interface AdminRuntimeObservation { + project_id: string; + observation: RuntimeObservation & { disk: RuntimeDiskObservation | null }; } -interface AgentCore { +class AdminClient { listRuntimeObservations(options?: { after?: string; limit?: number; order?: "asc" | "desc"; - }): Promise; + }): Promise>; - retrieveRuntimeObservation(sessionId: string): Promise; + retrieveRuntimeObservation(projectId: string, sessionId: string): Promise; } ``` diff --git a/contracts/agents-api/sandbox-manager.openapi.yaml b/contracts/agents-api/sandbox-manager.openapi.yaml index 2f4fd86d9..6181c16ac 100644 --- a/contracts/agents-api/sandbox-manager.openapi.yaml +++ b/contracts/agents-api/sandbox-manager.openapi.yaml @@ -4418,7 +4418,7 @@ paths: - Artifacts /core/v1/admin/projects/{project_id}/sessions/{session_id}/execution-configuration: get: - description: Deployment administrator only. Reuses the public resource projection and operation rules; the key selects the target space and does not authenticate. + description: Deployment administrator only; the Project ID selects the target space and does not authenticate. Returns the committed model, harness and safe provider selection with recorded sources. This read never decrypts credentials, resolves current defaults or probes execution health. Deployment provider details remain redacted. Historical provenance and missing provider projections are explicitly unknown/unavailable. parameters: - description: Session ID in: path @@ -4524,7 +4524,7 @@ paths: - Items /core/v1/admin/projects/{project_id}/sessions/{session_id}/runtime-history: get: - description: Deployment administrator only. Reuses the public resource projection and operation rules; the key selects the target space and does not authenticate. + description: Deployment administrator only; the Project ID selects the target space and does not authenticate. Returns stored Runtime observations for one Session. End is exclusive; the server selects a bounded resolution. Responses contain at most 1,000 series, 10,000 points per coverage/series array, and 100,000 total coverage plus series points. It never reads or changes live compute. parameters: - description: Session ID in: path @@ -4590,7 +4590,7 @@ paths: - Runtime history /core/v1/admin/projects/{project_id}/sessions/{session_id}/runtime-observation: get: - description: Deployment administrator only. Reuses the public resource projection and operation rules; the key selects the target space and does not authenticate. + description: Deployment administrator only; the Project ID selects the target space and does not authenticate. Returns one read-only current Runtime observation. It never provisions, renews, restarts, pauses or stops compute. parameters: - description: Session ID in: path @@ -5392,6 +5392,7 @@ paths: - Write Audit /core/v1/admin/runtime-history/capabilities: get: + description: Deployment administrator only. Advertises only safe backend-neutral durable history availability and bounds. Available is true only when a query Reader and qualified periodic collection are both configured. produces: - application/json responses: @@ -5399,6 +5400,10 @@ paths: description: OK schema: $ref: '#/definitions/v1.RuntimeHistoryCapabilities' + "400": + description: Bad Request + schema: + $ref: '#/definitions/v1.ErrorResponse' "401": description: Unauthorized schema: @@ -5465,7 +5470,7 @@ paths: - Core Administration /core/v1/admin/startup-configuration: get: - description: Deployment administrator only. Returns the same non-secret configuration metadata as the project extension. + description: Deployment administrator only. Returns a secret-free snapshot of supported build capabilities and validated process startup selections. It does not inspect or aggregate daemon heartbeats, Sessions, Environments or Runtime state, and does not prove model-provider reachability, credentials, native readiness, sandbox isolation or successful execution. produces: - application/json responses: @@ -5473,6 +5478,10 @@ paths: description: OK schema: $ref: '#/definitions/v1.CoreStartupConfiguration' + "400": + description: Bad Request + schema: + $ref: '#/definitions/v1.ErrorResponse' "401": description: Unauthorized schema: diff --git a/contracts/agents-api/startup-configuration.md b/contracts/agents-api/startup-configuration.md index 7b6fc4caf..2418b36c1 100644 --- a/contracts/agents-api/startup-configuration.md +++ b/contracts/agents-api/startup-configuration.md @@ -1,8 +1,10 @@ -# Core startup configuration extension +# Core startup configuration -`GET /v1/agents/core/startup-configuration` returns a read-only, project-authenticated -snapshot of safe configuration facts established when the Core process starts. It -is a Core extension outside the pinned upstream Agents API. +`GET /core/v1/admin/startup-configuration` returns a read-only snapshot of safe +configuration facts established when the Core process starts. It is an +administrator read outside the pinned upstream Agents API. The former project route +`GET /v1/agents/core/startup-configuration` is removed; `/v1` serves only the +pinned upstream routes. The response separates: @@ -27,5 +29,5 @@ not reflected in this process-level endpoint. The response has a fixed `schema_version`. Clients should reject unknown or incomplete shapes rather than infer configuration. The endpoint rejects query -parameters, returns `Cache-Control: no-store`, and requires the standard project -bearer key plus `OpenAI-Beta: agents=v1`. +parameters, returns `Cache-Control: no-store`, and requires the deployment +administrator credential. diff --git a/contracts/agents-api/upstream-fields.json b/contracts/agents-api/upstream-fields.json new file mode 100644 index 000000000..c095d0531 --- /dev/null +++ b/contracts/agents-api/upstream-fields.json @@ -0,0 +1,2121 @@ +{ + "sdk_version": "3.13.0", + "commit": "d7c41efee1b0802b79f3f88a678ef2052b06e9ce", + "generator": "scripts/extract-agents-api-upstream.py", + "operations": { + "DELETE /agents/environments/templates/{}": { + "request": [], + "response": [ + "beta.agents.environments.environment_template_deleted.EnvironmentTemplateDeleted" + ] + }, + "DELETE /agents/sessions/{}": { + "request": [], + "response": [ + "beta.agent_session_deleted.AgentSessionDeleted" + ] + }, + "DELETE /agents/sessions/{}/artifacts/{}": { + "request": [], + "response": [ + "beta.agents.sessions.session_artifact_deleted.SessionArtifactDeleted" + ] + }, + "DELETE /agents/{}": { + "request": [], + "response": [ + "beta.agent_deleted.AgentDeleted" + ] + }, + "DELETE /files/{}": { + "request": [], + "response": [ + "file_deleted.FileDeleted" + ] + }, + "DELETE /skills/{}": { + "request": [], + "response": [ + "deleted_skill.DeletedSkill" + ] + }, + "DELETE /skills/{}/versions/{}": { + "request": [], + "response": [ + "skills.deleted_skill_version.DeletedSkillVersion" + ] + }, + "DELETE /vaults/{}": { + "request": [], + "response": [ + "beta.agents.vault_deleted.VaultDeleted" + ] + }, + "DELETE /vaults/{}/credentials/{}": { + "request": [], + "response": [ + "beta.agents.vaults.credential_deleted.CredentialDeleted" + ] + }, + "GET /agents": { + "request": [], + "response": [ + "pagination.SyncCursorPage[beta.agent.Agent]" + ] + }, + "GET /agents/environments/templates": { + "request": [], + "response": [ + "pagination.SyncCursorPage[beta.agents.environments.environment_template.EnvironmentTemplate]" + ] + }, + "GET /agents/environments/templates/{}": { + "request": [], + "response": [ + "beta.agents.environments.environment_template.EnvironmentTemplate" + ] + }, + "GET /agents/environments/{}": { + "request": [], + "response": [ + "beta.agents.environment_info.EnvironmentInfo" + ] + }, + "GET /agents/environments/{}/files": { + "request": [], + "response": [ + "pagination.SyncTokenPage[beta.agents.environments.environment_file.EnvironmentFile]" + ] + }, + "GET /agents/sessions": { + "request": [], + "response": [ + "pagination.SyncCursorPage[beta.agent_session.AgentSession]" + ] + }, + "GET /agents/sessions/{}": { + "request": [], + "response": [ + "beta.agent_session.AgentSession" + ] + }, + "GET /agents/sessions/{}/artifacts": { + "request": [], + "response": [ + "pagination.SyncCursorPage[beta.agents.sessions.session_artifact.SessionArtifact]" + ] + }, + "GET /agents/sessions/{}/artifacts/{}": { + "request": [], + "response": [ + "beta.agents.sessions.session_artifact.SessionArtifact" + ] + }, + "GET /agents/sessions/{}/artifacts/{}/content": { + "request": [], + "response": [] + }, + "GET /agents/sessions/{}/events": { + "request": [], + "response": [ + "beta.agent_output_command_execution_output_delta_event.AgentOutputCommandExecutionOutputDeltaEvent", + "beta.agent_session_created_event.AgentSessionCreatedEvent", + "beta.agent_session_environment_connected_event.AgentSessionEnvironmentConnectedEvent", + "beta.agent_session_environment_disconnected_event.AgentSessionEnvironmentDisconnectedEvent", + "beta.agent_session_environment_failed_event.AgentSessionEnvironmentFailedEvent", + "beta.agent_session_environment_pending_event.AgentSessionEnvironmentPendingEvent", + "beta.agent_session_environment_ready_event.AgentSessionEnvironmentReadyEvent", + "beta.agent_session_error_event.AgentSessionErrorEvent", + "beta.agent_session_failed_event.AgentSessionFailedEvent", + "beta.agent_session_idle_event.AgentSessionIdleEvent", + "beta.agent_session_in_progress_event.AgentSessionInProgressEvent", + "beta.agent_session_requires_action_event.AgentSessionRequiresActionEvent", + "beta.agent_session_subagent_active_event.AgentSessionSubagentActiveEvent", + "beta.agent_session_subagent_closed_event.AgentSessionSubagentClosedEvent", + "beta.agent_session_subagent_created_event.AgentSessionSubagentCreatedEvent", + "beta.agent_session_turn_cancelled_event.AgentSessionTurnCancelledEvent", + "beta.agent_session_turn_completed_event.AgentSessionTurnCompletedEvent", + "beta.agent_session_turn_content_part_added_event.AgentSessionTurnContentPartAddedEvent", + "beta.agent_session_turn_content_part_done_event.AgentSessionTurnContentPartDoneEvent", + "beta.agent_session_turn_created_event.AgentSessionTurnCreatedEvent", + "beta.agent_session_turn_failed_event.AgentSessionTurnFailedEvent", + "beta.agent_session_turn_in_progress_event.AgentSessionTurnInProgressEvent", + "beta.agent_session_turn_item_added_event.AgentSessionTurnItemAddedEvent", + "beta.agent_session_turn_item_done_event.AgentSessionTurnItemDoneEvent", + "beta.agent_session_turn_output_text_delta_event.AgentSessionTurnOutputTextDeltaEvent", + "beta.agent_session_turn_output_text_done_event.AgentSessionTurnOutputTextDoneEvent", + "beta.agent_session_turn_reasoning_summary_part_added_event.AgentSessionTurnReasoningSummaryPartAddedEvent", + "beta.agent_session_turn_reasoning_summary_part_done_event.AgentSessionTurnReasoningSummaryPartDoneEvent", + "beta.agent_session_turn_reasoning_summary_text_delta_event.AgentSessionTurnReasoningSummaryTextDeltaEvent", + "beta.agent_session_turn_reasoning_summary_text_done_event.AgentSessionTurnReasoningSummaryTextDoneEvent" + ] + }, + "GET /agents/sessions/{}/items": { + "request": [], + "response": [ + "pagination.SyncCursorPage[beta.agent_close_subagent_call_item.AgentCloseSubagentCallItem | beta.agent_command_execution_item.AgentCommandExecutionItem | beta.agent_create_subagent_call_item.AgentCreateSubagentCallItem | beta.agent_function_call_item.AgentFunctionCallItem | beta.agent_interrupt_subagent_call_item.AgentInterruptSubagentCallItem | beta.agent_mcp_call_item.AgentMcpCallItem | beta.agent_reasoning_item.AgentReasoningItem | beta.agent_resume_subagent_call_item.AgentResumeSubagentCallItem | beta.agent_send_subagent_input_call_item.AgentSendSubagentInputCallItem | beta.agent_session_item.AgentMessageItemResource | beta.agent_session_item.FunctionCallOutputItemResource | beta.agent_session_message.AgentSessionMessage | beta.agent_wait_for_subagents_call_item.AgentWaitForSubagentsCallItem | beta.agent_web_search_call_item.AgentWebSearchCallItem]" + ] + }, + "GET /agents/sessions/{}/subagents": { + "request": [], + "response": [ + "pagination.SyncCursorPage[beta.subagent.Subagent]" + ] + }, + "GET /agents/sessions/{}/subagents/{}": { + "request": [], + "response": [ + "beta.subagent.Subagent" + ] + }, + "GET /agents/sessions/{}/subagents/{}/items": { + "request": [], + "response": [ + "pagination.SyncCursorPage[beta.agent_close_subagent_call_item.AgentCloseSubagentCallItem | beta.agent_command_execution_item.AgentCommandExecutionItem | beta.agent_create_subagent_call_item.AgentCreateSubagentCallItem | beta.agent_function_call_item.AgentFunctionCallItem | beta.agent_interrupt_subagent_call_item.AgentInterruptSubagentCallItem | beta.agent_mcp_call_item.AgentMcpCallItem | beta.agent_reasoning_item.AgentReasoningItem | beta.agent_resume_subagent_call_item.AgentResumeSubagentCallItem | beta.agent_send_subagent_input_call_item.AgentSendSubagentInputCallItem | beta.agent_session_item.AgentMessageItemResource | beta.agent_session_item.FunctionCallOutputItemResource | beta.agent_session_message.AgentSessionMessage | beta.agent_wait_for_subagents_call_item.AgentWaitForSubagentsCallItem | beta.agent_web_search_call_item.AgentWebSearchCallItem]" + ] + }, + "GET /agents/sessions/{}/subagents/{}/turns": { + "request": [], + "response": [ + "pagination.SyncCursorPage[beta.agents.sessions.turn.Turn]" + ] + }, + "GET /agents/sessions/{}/subagents/{}/turns/{}": { + "request": [], + "response": [ + "beta.agents.sessions.turn.Turn" + ] + }, + "GET /agents/sessions/{}/subagents/{}/turns/{}/items": { + "request": [], + "response": [ + "pagination.SyncCursorPage[beta.agent_close_subagent_call_item.AgentCloseSubagentCallItem | beta.agent_command_execution_item.AgentCommandExecutionItem | beta.agent_create_subagent_call_item.AgentCreateSubagentCallItem | beta.agent_function_call_item.AgentFunctionCallItem | beta.agent_interrupt_subagent_call_item.AgentInterruptSubagentCallItem | beta.agent_mcp_call_item.AgentMcpCallItem | beta.agent_reasoning_item.AgentReasoningItem | beta.agent_resume_subagent_call_item.AgentResumeSubagentCallItem | beta.agent_send_subagent_input_call_item.AgentSendSubagentInputCallItem | beta.agent_session_item.AgentMessageItemResource | beta.agent_session_item.FunctionCallOutputItemResource | beta.agent_session_message.AgentSessionMessage | beta.agent_wait_for_subagents_call_item.AgentWaitForSubagentsCallItem | beta.agent_web_search_call_item.AgentWebSearchCallItem]" + ] + }, + "GET /agents/sessions/{}/turns": { + "request": [], + "response": [ + "pagination.SyncCursorPage[beta.agents.sessions.turn.Turn]" + ] + }, + "GET /agents/sessions/{}/turns/{}": { + "request": [], + "response": [ + "beta.agents.sessions.turn.Turn" + ] + }, + "GET /agents/{}": { + "request": [], + "response": [ + "beta.agent.Agent" + ] + }, + "GET /files": { + "request": [], + "response": [ + "pagination.SyncCursorPage[file_object.FileObject]" + ] + }, + "GET /files/{}": { + "request": [], + "response": [ + "file_object.FileObject" + ] + }, + "GET /files/{}/content": { + "request": [], + "response": [] + }, + "GET /skills": { + "request": [], + "response": [ + "pagination.SyncCursorPage[skill.Skill]" + ] + }, + "GET /skills/{}": { + "request": [], + "response": [ + "skill.Skill" + ] + }, + "GET /skills/{}/content": { + "request": [], + "response": [] + }, + "GET /skills/{}/versions": { + "request": [], + "response": [ + "pagination.SyncCursorPage[skills.skill_version.SkillVersion]" + ] + }, + "GET /skills/{}/versions/{}": { + "request": [], + "response": [ + "skills.skill_version.SkillVersion" + ] + }, + "GET /skills/{}/versions/{}/content": { + "request": [], + "response": [] + }, + "GET /vaults": { + "request": [], + "response": [ + "pagination.SyncCursorPage[beta.agents.vault.Vault]" + ] + }, + "GET /vaults/{}": { + "request": [], + "response": [ + "beta.agents.vault.Vault" + ] + }, + "GET /vaults/{}/credentials": { + "request": [], + "response": [ + "pagination.SyncCursorPage[beta.agents.vaults.credential.Credential]" + ] + }, + "GET /vaults/{}/credentials/{}": { + "request": [], + "response": [ + "beta.agents.vaults.credential.Credential" + ] + }, + "POST /agents": { + "request": [ + "beta.agent_create_params.AgentCreateParams" + ], + "response": [ + "beta.agent.Agent" + ] + }, + "POST /agents/environments/templates": { + "request": [ + "beta.agents.environments.template_create_params.TemplateCreateParams" + ], + "response": [ + "beta.agents.environments.environment_template.EnvironmentTemplate" + ] + }, + "POST /agents/environments/templates/{}": { + "request": [ + "beta.agents.environments.template_update_params.TemplateUpdateParams" + ], + "response": [ + "beta.agents.environments.environment_template.EnvironmentTemplate" + ] + }, + "POST /agents/environments/{}/files": { + "request": [ + "beta.agents.environments.file_create_params.HostedEnvironmentFileParamFileID", + "beta.agents.environments.file_create_params.HostedEnvironmentFileParamInline" + ], + "response": [ + "beta.agents.environments.environment_file.EnvironmentFile" + ] + }, + "POST /agents/sessions": { + "request": [ + "beta.agents.session_create_params.SessionCreateParamsNonStreaming", + "beta.agents.session_create_params.SessionCreateParamsStreaming" + ], + "response": [ + "beta.agent_session.AgentSession" + ] + }, + "POST /agents/sessions/{}": { + "request": [ + "beta.agents.session_update_params.SessionUpdateParams" + ], + "response": [ + "beta.agent_session.AgentSession" + ] + }, + "POST /agents/sessions/{}/events": { + "request": [ + "beta.agents.sessions.event_create_params.EventCreateParams" + ], + "response": [] + }, + "POST /agents/{}": { + "request": [ + "beta.agent_update_params.AgentUpdateParams" + ], + "response": [ + "beta.agent.Agent" + ] + }, + "POST /files": { + "request": [ + "file_create_params.FileCreateParams" + ], + "response": [ + "file_object.FileObject" + ] + }, + "POST /skills": { + "request": [ + "skill_create_params.SkillCreateParams" + ], + "response": [ + "skill.Skill" + ] + }, + "POST /skills/{}": { + "request": [ + "skill_update_params.SkillUpdateParams" + ], + "response": [ + "skill.Skill" + ] + }, + "POST /skills/{}/versions": { + "request": [ + "skills.version_create_params.VersionCreateParams" + ], + "response": [ + "skills.skill_version.SkillVersion" + ] + }, + "POST /vaults": { + "request": [ + "beta.agents.vault_create_params.VaultCreateParams" + ], + "response": [ + "beta.agents.vault.Vault" + ] + }, + "POST /vaults/{}/credentials": { + "request": [ + "beta.agents.vaults.credential_create_params.CredentialCreateParams" + ], + "response": [ + "beta.agents.vaults.credential.Credential" + ] + }, + "POST /vaults/{}/credentials/{}": { + "request": [ + "beta.agents.vaults.credential_update_params.CredentialUpdateParams" + ], + "response": [ + "beta.agents.vaults.credential.Credential" + ] + } + }, + "types": { + "beta.agent.Agent": { + "created_at": [], + "id": [], + "instructions": [], + "metadata": [], + "model": [], + "multi_agent": [ + "beta.multi_agent_config.MultiAgentConfig" + ], + "name": [], + "object": [], + "reasoning": [ + "beta.agent_reasoning.AgentReasoning" + ], + "service_tier": [], + "text": [ + "beta.agent_text.AgentText" + ], + "tools": [ + "beta.persisted_agent_tool.PersistedAgentToolResourceFunction", + "beta.persisted_agent_tool.PersistedAgentToolResourceMcp", + "beta.persisted_agent_tool.PersistedAgentToolResourceProgrammaticToolCalling", + "beta.persisted_agent_tool.PersistedAgentToolResourceToolSearch", + "beta.persisted_agent_tool.PersistedAgentToolResourceWebSearch" + ], + "updated_at": [] + }, + "beta.agent_close_subagent_call_item.AgentCloseSubagentCallItem": { + "id": [], + "recipient_agent_id": [], + "sender_agent_id": [], + "status": [], + "turn_id": [], + "type": [] + }, + "beta.agent_command_execution_item.AgentCommandExecutionItem": { + "command": [], + "cwd": [], + "duration_ms": [], + "exit_code": [], + "id": [], + "output": [], + "status": [], + "turn_id": [], + "type": [] + }, + "beta.agent_content.EncryptedContentResource": { + "encrypted_content": [], + "type": [] + }, + "beta.agent_create_params.AgentCreateParams": { + "instructions": [], + "metadata": [], + "model": [], + "multi_agent": [ + "beta.multi_agent_config_param.MultiAgentConfigParam" + ], + "name": [], + "reasoning": [ + "beta.agent_reasoning_param.AgentReasoningParam" + ], + "service_tier": [], + "text": [ + "beta.agent_text_param.AgentTextParam" + ], + "tools": [ + "beta.persisted_agent_tool_param.PersistedAgentToolConfigParamFunction", + "beta.persisted_agent_tool_param.PersistedAgentToolConfigParamMcp", + "beta.persisted_agent_tool_param.PersistedAgentToolConfigParamProgrammaticToolCalling", + "beta.persisted_agent_tool_param.PersistedAgentToolConfigParamToolSearch", + "beta.persisted_agent_tool_param.PersistedAgentToolConfigParamWebSearch" + ] + }, + "beta.agent_create_subagent_call_item.AgentCreateSubagentCallItem": { + "agent_id": [], + "content": [ + "beta.agent_content.EncryptedContentResource", + "beta.output_text.OutputText" + ], + "id": [], + "model": [], + "reasoning_effort": [], + "status": [], + "turn_id": [], + "type": [] + }, + "beta.agent_deleted.AgentDeleted": { + "deleted": [], + "id": [], + "object": [] + }, + "beta.agent_function_call_item.AgentFunctionCallItem": { + "arguments": [], + "call_id": [], + "id": [], + "name": [], + "status": [], + "turn_id": [], + "type": [] + }, + "beta.agent_interrupt_subagent_call_item.AgentInterruptSubagentCallItem": { + "id": [], + "recipient_agent_id": [], + "sender_agent_id": [], + "status": [], + "turn_id": [], + "type": [] + }, + "beta.agent_mcp_call_item.AgentMcpCallItem": { + "arguments": [], + "error": [], + "id": [], + "name": [], + "output": [], + "server_label": [], + "status": [], + "turn_id": [], + "type": [] + }, + "beta.agent_output_command_execution_output_delta_event.AgentOutputCommandExecutionOutputDeltaEvent": { + "delta": [], + "event_id": [], + "item_id": [], + "output_index": [], + "session_id": [], + "turn_id": [], + "type": [] + }, + "beta.agent_reasoning.AgentReasoning": { + "effort": [], + "summary": [] + }, + "beta.agent_reasoning_item.AgentReasoningItem": { + "id": [], + "status": [], + "summary": [ + "beta.summary_text.SummaryText" + ], + "turn_id": [], + "type": [] + }, + "beta.agent_reasoning_param.AgentReasoningParam": { + "effort": [], + "summary": [] + }, + "beta.agent_resume_subagent_call_item.AgentResumeSubagentCallItem": { + "id": [], + "recipient_agent_id": [], + "sender_agent_id": [], + "status": [], + "turn_id": [], + "type": [] + }, + "beta.agent_send_subagent_input_call_item.AgentSendSubagentInputCallItem": { + "content": [ + "beta.agent_content.EncryptedContentResource", + "beta.output_text.OutputText" + ], + "id": [], + "recipient_agent_id": [], + "sender_agent_id": [], + "status": [], + "turn_id": [], + "type": [] + }, + "beta.agent_session.Agent": { + "id": [], + "instructions": [], + "model": [], + "multi_agent": [ + "beta.multi_agent_config.MultiAgentConfig" + ], + "name": [], + "reasoning": [ + "beta.agent_reasoning.AgentReasoning" + ], + "service_tier": [], + "text": [ + "beta.agent_text.AgentText" + ], + "tools": [ + "beta.agent_tool.AgentToolResourceFunction", + "beta.agent_tool.AgentToolResourceMcp", + "beta.agent_tool.AgentToolResourceProgrammaticToolCalling", + "beta.agent_tool.AgentToolResourceWebSearch" + ] + }, + "beta.agent_session.AgentSession": { + "agent": [ + "beta.agent_session.Agent" + ], + "created_at": [], + "environment": [ + "beta.environment.EnvironmentResourceNone", + "beta.environment.EnvironmentResourceOpenAIHosted", + "beta.environment.EnvironmentResourceSelfHosted" + ], + "error": [], + "id": [], + "last_active_at": [], + "metadata": [], + "object": [], + "required_actions": [ + "beta.agent_session.RequiredActionSessionRequiredActionResourceEnvironmentConnection", + "beta.agent_session.RequiredActionSessionRequiredActionResourceFunctionCall" + ], + "status": [], + "usage": [ + "beta.token_usage.TokenUsage" + ], + "vault_ids": [] + }, + "beta.agent_session.RequiredActionSessionRequiredActionResourceEnvironmentConnection": { + "environment_id": [], + "type": [] + }, + "beta.agent_session.RequiredActionSessionRequiredActionResourceFunctionCall": { + "arguments": [], + "call_id": [], + "name": [], + "turn_id": [], + "type": [] + }, + "beta.agent_session_assistant_message.AgentSessionAssistantMessage": { + "content": [ + "beta.output_text.OutputText" + ], + "id": [], + "phase": [], + "role": [], + "status": [], + "turn_id": [], + "type": [] + }, + "beta.agent_session_created_event.AgentSessionCreatedEvent": { + "event_id": [], + "session": [ + "beta.agent_session.AgentSession" + ], + "type": [] + }, + "beta.agent_session_deleted.AgentSessionDeleted": { + "deleted": [], + "id": [], + "object": [] + }, + "beta.agent_session_environment_connected_event.AgentSessionEnvironmentConnectedEvent": { + "environment": [ + "beta.agent_session_environment_state.AgentSessionEnvironmentState" + ], + "event_id": [], + "session_id": [], + "turn_id": [], + "type": [] + }, + "beta.agent_session_environment_disconnected_event.AgentSessionEnvironmentDisconnectedEvent": { + "environment": [ + "beta.agent_session_environment_state.AgentSessionEnvironmentState" + ], + "event_id": [], + "session_id": [], + "turn_id": [], + "type": [] + }, + "beta.agent_session_environment_failed_event.AgentSessionEnvironmentFailedEvent": { + "environment": [ + "beta.agent_session_environment_state.AgentSessionEnvironmentState" + ], + "event_id": [], + "session_id": [], + "turn_id": [], + "type": [] + }, + "beta.agent_session_environment_pending_event.AgentSessionEnvironmentPendingEvent": { + "environment": [ + "beta.agent_session_environment_state.AgentSessionEnvironmentState" + ], + "event_id": [], + "session_id": [], + "turn_id": [], + "type": [] + }, + "beta.agent_session_environment_ready_event.AgentSessionEnvironmentReadyEvent": { + "environment": [ + "beta.agent_session_environment_state.AgentSessionEnvironmentState" + ], + "event_id": [], + "session_id": [], + "turn_id": [], + "type": [] + }, + "beta.agent_session_environment_state.AgentSessionEnvironmentState": { + "error": [ + "beta.agent_session_environment_state.Error" + ], + "id": [], + "status": [], + "type": [] + }, + "beta.agent_session_environment_state.Error": { + "code": [], + "message": [], + "type": [] + }, + "beta.agent_session_error_event.AgentSessionErrorEvent": { + "error": [ + "beta.session_error.SessionError" + ], + "event_id": [], + "session_id": [], + "type": [] + }, + "beta.agent_session_failed_event.AgentSessionFailedEvent": { + "event_id": [], + "session": [ + "beta.agent_session.AgentSession" + ], + "type": [] + }, + "beta.agent_session_idle_event.AgentSessionIdleEvent": { + "event_id": [], + "session": [ + "beta.agent_session.AgentSession" + ], + "type": [] + }, + "beta.agent_session_in_progress_event.AgentSessionInProgressEvent": { + "event_id": [], + "session": [ + "beta.agent_session.AgentSession" + ], + "type": [] + }, + "beta.agent_session_input_message_param.AgentSessionInputMessageParam": { + "content": [ + "beta.input_content_param.InputContentParamInputImage", + "beta.input_content_param.InputContentParamInputText" + ], + "role": [], + "type": [] + }, + "beta.agent_session_input_param.SessionInputParamAgentSessionInputCancel": { + "type": [] + }, + "beta.agent_session_input_param.SessionInputParamAgentSessionInputMessage": { + "input": [ + "beta.agent_session_input_message_param.AgentSessionInputMessageParam" + ], + "type": [] + }, + "beta.agent_session_input_param.SessionInputParamAgentSessionInputToolResult": { + "call_id": [], + "error": [], + "output": [ + "beta.input_content_param.InputContentParamInputImage", + "beta.input_content_param.InputContentParamInputText" + ], + "success": [], + "turn_id": [], + "type": [] + }, + "beta.agent_session_item.AgentMessageItemResource": { + "content": [ + "beta.agent_content.EncryptedContentResource", + "beta.output_text.OutputText" + ], + "id": [], + "recipient_agent_id": [], + "sender_agent_id": [], + "turn_id": [], + "type": [] + }, + "beta.agent_session_item.FunctionCallOutputItemResource": { + "call_id": [], + "error": [], + "id": [], + "output": [ + "beta.input_content.InputContentResourceInputImage", + "beta.input_content.InputContentResourceInputText" + ], + "status": [], + "turn_id": [], + "type": [] + }, + "beta.agent_session_message.AgentSessionMessage": { + "content": [ + "beta.agent_session_message_content.MessageContentResourceInputImage", + "beta.agent_session_message_content.MessageContentResourceInputText", + "beta.agent_session_message_content.MessageContentResourceOutputText" + ], + "id": [], + "phase": [], + "role": [], + "status": [], + "turn_id": [], + "type": [] + }, + "beta.agent_session_message_content.MessageContentResourceInputImage": { + "image_url": [], + "type": [] + }, + "beta.agent_session_message_content.MessageContentResourceInputText": { + "text": [], + "type": [] + }, + "beta.agent_session_message_content.MessageContentResourceOutputText": { + "text": [], + "type": [] + }, + "beta.agent_session_requires_action_event.AgentSessionRequiresActionEvent": { + "event_id": [], + "session": [ + "beta.agent_session.AgentSession" + ], + "type": [] + }, + "beta.agent_session_subagent_active_event.AgentSessionSubagentActiveEvent": { + "event_id": [], + "subagent": [ + "beta.subagent.Subagent" + ], + "type": [] + }, + "beta.agent_session_subagent_closed_event.AgentSessionSubagentClosedEvent": { + "event_id": [], + "subagent": [ + "beta.subagent.Subagent" + ], + "type": [] + }, + "beta.agent_session_subagent_created_event.AgentSessionSubagentCreatedEvent": { + "event_id": [], + "subagent": [ + "beta.subagent.Subagent" + ], + "type": [] + }, + "beta.agent_session_turn_cancelled_event.AgentSessionTurnCancelledEvent": { + "event_id": [], + "session_id": [], + "turn": [ + "beta.agents.sessions.turn.Turn" + ], + "turn_id": [], + "type": [], + "usage": [ + "beta.token_usage.TokenUsage" + ] + }, + "beta.agent_session_turn_completed_event.AgentSessionTurnCompletedEvent": { + "event_id": [], + "session_id": [], + "turn": [ + "beta.agents.sessions.turn.Turn" + ], + "turn_id": [], + "type": [], + "usage": [ + "beta.token_usage.TokenUsage" + ] + }, + "beta.agent_session_turn_content_part_added_event.AgentSessionTurnContentPartAddedEvent": { + "content_index": [], + "event_id": [], + "item_id": [], + "output_index": [], + "part": [ + "beta.output_text.OutputText" + ], + "session_id": [], + "turn_id": [], + "type": [] + }, + "beta.agent_session_turn_content_part_done_event.AgentSessionTurnContentPartDoneEvent": { + "content_index": [], + "event_id": [], + "item_id": [], + "output_index": [], + "part": [ + "beta.output_text.OutputText" + ], + "session_id": [], + "turn_id": [], + "type": [] + }, + "beta.agent_session_turn_created_event.AgentSessionTurnCreatedEvent": { + "event_id": [], + "session_id": [], + "turn": [ + "beta.agents.sessions.turn.Turn" + ], + "turn_id": [], + "type": [] + }, + "beta.agent_session_turn_failed_event.AgentSessionTurnFailedEvent": { + "event_id": [], + "session_id": [], + "turn": [ + "beta.agents.sessions.turn.Turn" + ], + "turn_id": [], + "type": [], + "usage": [ + "beta.token_usage.TokenUsage" + ] + }, + "beta.agent_session_turn_in_progress_event.AgentSessionTurnInProgressEvent": { + "event_id": [], + "session_id": [], + "turn": [ + "beta.agents.sessions.turn.Turn" + ], + "turn_id": [], + "type": [] + }, + "beta.agent_session_turn_item_added_event.AgentSessionTurnItemAddedEvent": { + "event_id": [], + "item": [ + "beta.agent_close_subagent_call_item.AgentCloseSubagentCallItem", + "beta.agent_command_execution_item.AgentCommandExecutionItem", + "beta.agent_create_subagent_call_item.AgentCreateSubagentCallItem", + "beta.agent_function_call_item.AgentFunctionCallItem", + "beta.agent_interrupt_subagent_call_item.AgentInterruptSubagentCallItem", + "beta.agent_mcp_call_item.AgentMcpCallItem", + "beta.agent_reasoning_item.AgentReasoningItem", + "beta.agent_resume_subagent_call_item.AgentResumeSubagentCallItem", + "beta.agent_send_subagent_input_call_item.AgentSendSubagentInputCallItem", + "beta.agent_session_item.AgentMessageItemResource", + "beta.agent_session_item.FunctionCallOutputItemResource", + "beta.agent_session_message.AgentSessionMessage", + "beta.agent_wait_for_subagents_call_item.AgentWaitForSubagentsCallItem", + "beta.agent_web_search_call_item.AgentWebSearchCallItem" + ], + "output_index": [], + "session_id": [], + "turn_id": [], + "type": [] + }, + "beta.agent_session_turn_item_done_event.AgentSessionTurnItemDoneEvent": { + "event_id": [], + "item": [ + "beta.agent_close_subagent_call_item.AgentCloseSubagentCallItem", + "beta.agent_command_execution_item.AgentCommandExecutionItem", + "beta.agent_create_subagent_call_item.AgentCreateSubagentCallItem", + "beta.agent_function_call_item.AgentFunctionCallItem", + "beta.agent_interrupt_subagent_call_item.AgentInterruptSubagentCallItem", + "beta.agent_mcp_call_item.AgentMcpCallItem", + "beta.agent_reasoning_item.AgentReasoningItem", + "beta.agent_resume_subagent_call_item.AgentResumeSubagentCallItem", + "beta.agent_send_subagent_input_call_item.AgentSendSubagentInputCallItem", + "beta.agent_session_assistant_message.AgentSessionAssistantMessage", + "beta.agent_wait_for_subagents_call_item.AgentWaitForSubagentsCallItem", + "beta.agent_web_search_call_item.AgentWebSearchCallItem" + ], + "output_index": [], + "session_id": [], + "turn_id": [], + "type": [] + }, + "beta.agent_session_turn_output_text_delta_event.AgentSessionTurnOutputTextDeltaEvent": { + "content_index": [], + "delta": [], + "event_id": [], + "item_id": [], + "output_index": [], + "session_id": [], + "turn_id": [], + "type": [] + }, + "beta.agent_session_turn_output_text_done_event.AgentSessionTurnOutputTextDoneEvent": { + "content_index": [], + "event_id": [], + "item_id": [], + "output_index": [], + "session_id": [], + "text": [], + "turn_id": [], + "type": [] + }, + "beta.agent_session_turn_reasoning_summary_part_added_event.AgentSessionTurnReasoningSummaryPartAddedEvent": { + "event_id": [], + "item_id": [], + "output_index": [], + "part": [ + "beta.summary_text.SummaryText" + ], + "session_id": [], + "summary_index": [], + "turn_id": [], + "type": [] + }, + "beta.agent_session_turn_reasoning_summary_part_done_event.AgentSessionTurnReasoningSummaryPartDoneEvent": { + "event_id": [], + "item_id": [], + "output_index": [], + "part": [ + "beta.summary_text.SummaryText" + ], + "session_id": [], + "status": [], + "summary_index": [], + "turn_id": [], + "type": [] + }, + "beta.agent_session_turn_reasoning_summary_text_delta_event.AgentSessionTurnReasoningSummaryTextDeltaEvent": { + "delta": [], + "event_id": [], + "item_id": [], + "output_index": [], + "session_id": [], + "summary_index": [], + "turn_id": [], + "type": [] + }, + "beta.agent_session_turn_reasoning_summary_text_done_event.AgentSessionTurnReasoningSummaryTextDoneEvent": { + "event_id": [], + "item_id": [], + "output_index": [], + "session_id": [], + "summary_index": [], + "text": [], + "turn_id": [], + "type": [] + }, + "beta.agent_text.AgentText": { + "format": [ + "beta.text_format.TextFormatResourceJSONSchema", + "beta.text_format.TextFormatResourceText" + ], + "verbosity": [] + }, + "beta.agent_text_param.AgentTextParam": { + "format": [ + "beta.text_format_param.TextFormatParamJSONSchema", + "beta.text_format_param.TextFormatParamText" + ], + "verbosity": [] + }, + "beta.agent_tool.AgentToolResourceFunction": { + "defer_loading": [], + "description": [], + "name": [], + "parameters": [], + "type": [] + }, + "beta.agent_tool.AgentToolResourceMcp": { + "allowed_tools": [], + "connection_origin": [], + "credential_id": [], + "request_metadata": [], + "required": [], + "server_label": [], + "transport": [ + "beta.mcp_transport.McpTransportResourceHTTP", + "beta.mcp_transport.McpTransportResourceStdio" + ], + "type": [] + }, + "beta.agent_tool.AgentToolResourceProgrammaticToolCalling": { + "enabled": [], + "type": [] + }, + "beta.agent_tool.AgentToolResourceWebSearch": { + "allowed_domains": [], + "context_size": [], + "location": [ + "beta.agent_tool.AgentToolResourceWebSearchLocation" + ], + "mode": [], + "type": [] + }, + "beta.agent_tool.AgentToolResourceWebSearchLocation": { + "city": [], + "country": [], + "region": [], + "timezone": [] + }, + "beta.agent_tool_param.AgentToolConfigParamFunction": { + "defer_loading": [], + "description": [], + "name": [], + "parameters": [], + "type": [] + }, + "beta.agent_tool_param.AgentToolConfigParamMcp": { + "allowed_tools": [], + "connection_origin": [], + "credential_id": [], + "request_metadata": [], + "required": [], + "server_label": [], + "transport": [ + "beta.mcp_transport_param.McpTransportConfigParamHTTP", + "beta.mcp_transport_param.McpTransportConfigParamStdio" + ], + "type": [] + }, + "beta.agent_tool_param.AgentToolConfigParamProgrammaticToolCalling": { + "enabled": [], + "type": [] + }, + "beta.agent_tool_param.AgentToolConfigParamToolSearch": { + "type": [] + }, + "beta.agent_tool_param.AgentToolConfigParamWebSearch": { + "allowed_domains": [], + "context_size": [], + "location": [ + "beta.agent_tool_param.AgentToolConfigParamWebSearchLocation" + ], + "mode": [], + "type": [] + }, + "beta.agent_tool_param.AgentToolConfigParamWebSearchLocation": { + "city": [], + "country": [], + "region": [], + "timezone": [] + }, + "beta.agent_update_params.AgentUpdateParams": { + "instructions": [], + "metadata": [], + "model": [], + "multi_agent": [ + "beta.multi_agent_config_param.MultiAgentConfigParam" + ], + "name": [], + "reasoning": [ + "beta.agent_reasoning_param.AgentReasoningParam" + ], + "service_tier": [], + "text": [ + "beta.agent_text_param.AgentTextParam" + ], + "tools": [ + "beta.persisted_agent_tool_param.PersistedAgentToolConfigParamFunction", + "beta.persisted_agent_tool_param.PersistedAgentToolConfigParamMcp", + "beta.persisted_agent_tool_param.PersistedAgentToolConfigParamProgrammaticToolCalling", + "beta.persisted_agent_tool_param.PersistedAgentToolConfigParamToolSearch", + "beta.persisted_agent_tool_param.PersistedAgentToolConfigParamWebSearch" + ] + }, + "beta.agent_wait_for_subagents_call_item.AgentWaitForSubagentsCallItem": { + "id": [], + "recipient_agent_ids": [], + "sender_agent_id": [], + "status": [], + "turn_id": [], + "type": [] + }, + "beta.agent_web_search_call_item.AgentWebSearchCallItem": { + "action": [ + "beta.web_search_action.WebSearchActionResourceFindInPage", + "beta.web_search_action.WebSearchActionResourceOpenPage", + "beta.web_search_action.WebSearchActionResourceOther", + "beta.web_search_action.WebSearchActionResourceSearch" + ], + "id": [], + "status": [], + "turn_id": [], + "type": [] + }, + "beta.agents.environment_info.EnvironmentInfo": { + "files": [ + "beta.hosted_environment_file.HostedEnvironmentFileResourceInline", + "beta.hosted_environment_file_id.HostedEnvironmentFileID" + ], + "id": [], + "object": [], + "plugins": [ + "beta.hosted_plugin.HostedPlugin" + ], + "skills": [ + "beta.hosted_skill.HostedSkillResourceInline", + "beta.hosted_skill_reference.HostedSkillReference" + ], + "status": [], + "type": [] + }, + "beta.agents.environments.environment_file.EnvironmentFile": { + "environment_id": [], + "object": [], + "path": [], + "size_bytes": [] + }, + "beta.agents.environments.environment_template.EnvironmentTemplate": { + "capability_directories": [], + "created_at": [], + "files": [ + "beta.agents.environments.environment_template.FileHostedTemplateFileResourceFileID", + "beta.agents.environments.environment_template.FileHostedTemplateFileResourceInline" + ], + "id": [], + "name": [], + "network": [ + "beta.agents.environments.environment_template.Network" + ], + "object": [], + "packages": [ + "beta.agents.environments.environment_template.Packages" + ], + "plugins": [ + "beta.hosted_plugin.HostedPlugin" + ], + "skills": [ + "beta.agents.environments.environment_template.SkillHostedTemplateSkillResourceInline", + "beta.agents.environments.environment_template.SkillHostedTemplateSkillResourceSkillReference" + ], + "updated_at": [] + }, + "beta.agents.environments.environment_template.FileHostedTemplateFileResourceFileID": { + "file_id": [], + "path": [], + "type": [] + }, + "beta.agents.environments.environment_template.FileHostedTemplateFileResourceInline": { + "path": [], + "size_bytes": [], + "type": [] + }, + "beta.agents.environments.environment_template.Network": { + "access": [], + "allowed_domains": [] + }, + "beta.agents.environments.environment_template.Packages": { + "npm": [], + "python": [], + "system": [] + }, + "beta.agents.environments.environment_template.SkillHostedTemplateSkillResourceInline": { + "description": [], + "name": [], + "type": [] + }, + "beta.agents.environments.environment_template.SkillHostedTemplateSkillResourceSkillReference": { + "skill_id": [], + "type": [], + "version": [] + }, + "beta.agents.environments.environment_template_deleted.EnvironmentTemplateDeleted": { + "deleted": [], + "id": [], + "object": [] + }, + "beta.agents.environments.file_create_params.HostedEnvironmentFileParamFileID": { + "file_id": [], + "path": [], + "type": [] + }, + "beta.agents.environments.file_create_params.HostedEnvironmentFileParamInline": { + "data": [], + "path": [], + "type": [] + }, + "beta.agents.environments.template_create_params.Network": { + "access": [], + "allowed_domains": [] + }, + "beta.agents.environments.template_create_params.Packages": { + "npm": [], + "python": [], + "system": [] + }, + "beta.agents.environments.template_create_params.TemplateCreateParams": { + "capability_directories": [], + "env": [], + "files": [ + "beta.hosted_environment_file_param.HostedEnvironmentFileParamFileID", + "beta.hosted_environment_file_param.HostedEnvironmentFileParamInline" + ], + "name": [], + "network": [ + "beta.agents.environments.template_create_params.Network" + ], + "packages": [ + "beta.agents.environments.template_create_params.Packages" + ], + "plugins": [ + "beta.hosted_plugin_param.HostedPluginParam" + ], + "setup_commands": [ + "beta.setup_command_param.SetupCommandParam" + ], + "skills": [ + "beta.hosted_skill_param.HostedSkillParamInline", + "beta.hosted_skill_param.HostedSkillParamSkillReference" + ] + }, + "beta.agents.environments.template_update_params.Network": { + "access": [], + "allowed_domains": [] + }, + "beta.agents.environments.template_update_params.Packages": { + "npm": [], + "python": [], + "system": [] + }, + "beta.agents.environments.template_update_params.TemplateUpdateParams": { + "capability_directories": [], + "env": [], + "files": [ + "beta.hosted_environment_file_param.HostedEnvironmentFileParamFileID", + "beta.hosted_environment_file_param.HostedEnvironmentFileParamInline" + ], + "name": [], + "network": [ + "beta.agents.environments.template_update_params.Network" + ], + "packages": [ + "beta.agents.environments.template_update_params.Packages" + ], + "plugins": [ + "beta.hosted_plugin_param.HostedPluginParam" + ], + "setup_commands": [ + "beta.setup_command_param.SetupCommandParam" + ], + "skills": [ + "beta.hosted_skill_param.HostedSkillParamInline", + "beta.hosted_skill_param.HostedSkillParamSkillReference" + ] + }, + "beta.agents.session_create_params.Agent": { + "instructions": [], + "model": [], + "multi_agent": [ + "beta.multi_agent_config_param.MultiAgentConfigParam" + ], + "reasoning": [ + "beta.agent_reasoning_param.AgentReasoningParam" + ], + "service_tier": [], + "text": [ + "beta.agent_text_param.AgentTextParam" + ], + "tools": [ + "beta.agent_tool_param.AgentToolConfigParamFunction", + "beta.agent_tool_param.AgentToolConfigParamMcp", + "beta.agent_tool_param.AgentToolConfigParamProgrammaticToolCalling", + "beta.agent_tool_param.AgentToolConfigParamToolSearch", + "beta.agent_tool_param.AgentToolConfigParamWebSearch" + ] + }, + "beta.agents.session_create_params.SessionCreateParamsNonStreaming": { + "agent": [ + "beta.agents.session_create_params.Agent" + ], + "agent_id": [], + "environment": [ + "beta.environment_param.EnvironmentParamNone", + "beta.environment_param.EnvironmentParamOpenAIHosted", + "beta.environment_param.EnvironmentParamSelfHosted" + ], + "input": [ + "beta.agent_session_input_message_param.AgentSessionInputMessageParam" + ], + "metadata": [], + "stream": [], + "vault_ids": [] + }, + "beta.agents.session_create_params.SessionCreateParamsStreaming": { + "agent": [ + "beta.agents.session_create_params.Agent" + ], + "agent_id": [], + "environment": [ + "beta.environment_param.EnvironmentParamNone", + "beta.environment_param.EnvironmentParamOpenAIHosted", + "beta.environment_param.EnvironmentParamSelfHosted" + ], + "input": [ + "beta.agent_session_input_message_param.AgentSessionInputMessageParam" + ], + "metadata": [], + "stream": [], + "vault_ids": [] + }, + "beta.agents.session_update_params.SessionUpdateParams": { + "metadata": [] + }, + "beta.agents.sessions.event_create_params.EventCreateParams": { + "Idempotency-Key": [], + "events": [ + "beta.agent_session_input_param.SessionInputParamAgentSessionInputCancel", + "beta.agent_session_input_param.SessionInputParamAgentSessionInputMessage", + "beta.agent_session_input_param.SessionInputParamAgentSessionInputToolResult" + ] + }, + "beta.agents.sessions.session_artifact.SessionArtifact": { + "created_at": [], + "environment_id": [], + "id": [], + "object": [], + "path": [], + "session_id": [], + "size_bytes": [], + "turn_id": [] + }, + "beta.agents.sessions.session_artifact_deleted.SessionArtifactDeleted": { + "deleted": [], + "id": [], + "object": [] + }, + "beta.agents.sessions.turn.Turn": { + "agent_id": [], + "completed_at": [], + "created_at": [], + "error": [ + "beta.session_turn_error.SessionTurnError" + ], + "id": [], + "object": [], + "session_id": [], + "started_at": [], + "status": [], + "subagent_id": [], + "usage": [ + "beta.token_usage.TokenUsage" + ] + }, + "beta.agents.vault.Vault": { + "created_at": [], + "id": [], + "metadata": [], + "name": [], + "object": [] + }, + "beta.agents.vault_create_params.VaultCreateParams": { + "metadata": [], + "name": [] + }, + "beta.agents.vault_deleted.VaultDeleted": { + "deleted": [], + "id": [], + "object": [] + }, + "beta.agents.vaults.credential.Credential": { + "auth": [ + "beta.agents.vaults.credential_auth.VaultCredentialAuthResourceMcpOauth", + "beta.agents.vaults.credential_auth.VaultCredentialAuthResourceStaticBearer" + ], + "created_at": [], + "id": [], + "name": [], + "object": [], + "updated_at": [], + "vault_id": [] + }, + "beta.agents.vaults.credential_auth.VaultCredentialAuthResourceMcpOauth": { + "expires_at": [], + "mcp_server_url": [], + "refresh": [ + "beta.agents.vaults.credential_auth.VaultCredentialAuthResourceMcpOauthRefresh" + ], + "type": [] + }, + "beta.agents.vaults.credential_auth.VaultCredentialAuthResourceMcpOauthRefresh": { + "client_id": [], + "resource": [], + "scope": [], + "token_endpoint": [], + "token_endpoint_auth": [ + "beta.agents.vaults.mcp_oauth_token_endpoint_auth.McpOauthTokenEndpointAuthResourceClientSecretBasic", + "beta.agents.vaults.mcp_oauth_token_endpoint_auth.McpOauthTokenEndpointAuthResourceClientSecretPost", + "beta.agents.vaults.mcp_oauth_token_endpoint_auth.McpOauthTokenEndpointAuthResourceNone" + ] + }, + "beta.agents.vaults.credential_auth.VaultCredentialAuthResourceStaticBearer": { + "mcp_server_url": [], + "type": [] + }, + "beta.agents.vaults.credential_auth_create_param.CreateVaultCredentialAuthParamMcpOauth": { + "access_token": [], + "expires_at": [], + "mcp_server_url": [], + "refresh": [ + "beta.agents.vaults.credential_auth_create_param.CreateVaultCredentialAuthParamMcpOauthRefresh" + ], + "type": [] + }, + "beta.agents.vaults.credential_auth_create_param.CreateVaultCredentialAuthParamMcpOauthRefresh": { + "client_id": [], + "refresh_token": [], + "resource": [], + "scope": [], + "token_endpoint": [], + "token_endpoint_auth": [ + "beta.agents.vaults.mcp_oauth_token_endpoint_auth_create_param.CreateMcpOauthTokenEndpointAuthParamClientSecretBasic", + "beta.agents.vaults.mcp_oauth_token_endpoint_auth_create_param.CreateMcpOauthTokenEndpointAuthParamClientSecretPost", + "beta.agents.vaults.mcp_oauth_token_endpoint_auth_create_param.CreateMcpOauthTokenEndpointAuthParamNone" + ] + }, + "beta.agents.vaults.credential_auth_create_param.CreateVaultCredentialAuthParamStaticBearer": { + "mcp_server_url": [], + "token": [], + "type": [] + }, + "beta.agents.vaults.credential_auth_rotate_param.RotateVaultCredentialAuthParamMcpOauth": { + "access_token": [], + "expires_at": [], + "refresh": [ + "beta.agents.vaults.credential_auth_rotate_param.RotateVaultCredentialAuthParamMcpOauthRefresh" + ], + "type": [] + }, + "beta.agents.vaults.credential_auth_rotate_param.RotateVaultCredentialAuthParamMcpOauthRefresh": { + "refresh_token": [], + "scope": [], + "token_endpoint_auth": [ + "beta.agents.vaults.mcp_oauth_token_endpoint_auth_rotate_param.RotateMcpOauthTokenEndpointAuthParamClientSecretBasic", + "beta.agents.vaults.mcp_oauth_token_endpoint_auth_rotate_param.RotateMcpOauthTokenEndpointAuthParamClientSecretPost" + ] + }, + "beta.agents.vaults.credential_auth_rotate_param.RotateVaultCredentialAuthParamStaticBearer": { + "token": [], + "type": [] + }, + "beta.agents.vaults.credential_create_params.CredentialCreateParams": { + "auth": [ + "beta.agents.vaults.credential_auth_create_param.CreateVaultCredentialAuthParamMcpOauth", + "beta.agents.vaults.credential_auth_create_param.CreateVaultCredentialAuthParamStaticBearer" + ], + "name": [] + }, + "beta.agents.vaults.credential_deleted.CredentialDeleted": { + "deleted": [], + "id": [], + "object": [] + }, + "beta.agents.vaults.credential_update_params.CredentialUpdateParams": { + "auth": [ + "beta.agents.vaults.credential_auth_rotate_param.RotateVaultCredentialAuthParamMcpOauth", + "beta.agents.vaults.credential_auth_rotate_param.RotateVaultCredentialAuthParamStaticBearer" + ], + "vault_id": [] + }, + "beta.agents.vaults.mcp_oauth_token_endpoint_auth.McpOauthTokenEndpointAuthResourceClientSecretBasic": { + "type": [] + }, + "beta.agents.vaults.mcp_oauth_token_endpoint_auth.McpOauthTokenEndpointAuthResourceClientSecretPost": { + "type": [] + }, + "beta.agents.vaults.mcp_oauth_token_endpoint_auth.McpOauthTokenEndpointAuthResourceNone": { + "type": [] + }, + "beta.agents.vaults.mcp_oauth_token_endpoint_auth_create_param.CreateMcpOauthTokenEndpointAuthParamClientSecretBasic": { + "client_secret": [], + "type": [] + }, + "beta.agents.vaults.mcp_oauth_token_endpoint_auth_create_param.CreateMcpOauthTokenEndpointAuthParamClientSecretPost": { + "client_secret": [], + "type": [] + }, + "beta.agents.vaults.mcp_oauth_token_endpoint_auth_create_param.CreateMcpOauthTokenEndpointAuthParamNone": { + "type": [] + }, + "beta.agents.vaults.mcp_oauth_token_endpoint_auth_rotate_param.RotateMcpOauthTokenEndpointAuthParamClientSecretBasic": { + "client_secret": [], + "type": [] + }, + "beta.agents.vaults.mcp_oauth_token_endpoint_auth_rotate_param.RotateMcpOauthTokenEndpointAuthParamClientSecretPost": { + "client_secret": [], + "type": [] + }, + "beta.environment.EnvironmentResourceNone": { + "type": [] + }, + "beta.environment.EnvironmentResourceOpenAIHosted": { + "capability_directories": [], + "files": [ + "beta.hosted_environment_file.HostedEnvironmentFileResourceInline", + "beta.hosted_environment_file_id.HostedEnvironmentFileID" + ], + "id": [], + "network": [ + "beta.environment.EnvironmentResourceOpenAIHostedNetwork" + ], + "packages": [ + "beta.environment.EnvironmentResourceOpenAIHostedPackages" + ], + "plugins": [ + "beta.hosted_plugin.HostedPlugin" + ], + "skills": [ + "beta.hosted_skill.HostedSkillResourceInline", + "beta.hosted_skill_reference.HostedSkillReference" + ], + "type": [] + }, + "beta.environment.EnvironmentResourceOpenAIHostedNetwork": { + "access": [], + "allowed_domains": [] + }, + "beta.environment.EnvironmentResourceOpenAIHostedPackages": { + "npm": [], + "python": [], + "system": [] + }, + "beta.environment.EnvironmentResourceSelfHosted": { + "capability_directories": [], + "id": [], + "remote_url": [], + "type": [], + "workspace_directory": [] + }, + "beta.environment_param.EnvironmentParamNone": { + "type": [] + }, + "beta.environment_param.EnvironmentParamOpenAIHosted": { + "capability_directories": [], + "env": [], + "environment_template_id": [], + "files": [ + "beta.hosted_environment_file_param.HostedEnvironmentFileParamFileID", + "beta.hosted_environment_file_param.HostedEnvironmentFileParamInline" + ], + "network": [ + "beta.environment_param.EnvironmentParamOpenAIHostedNetwork" + ], + "packages": [ + "beta.environment_param.EnvironmentParamOpenAIHostedPackages" + ], + "plugins": [ + "beta.hosted_plugin_param.HostedPluginParam" + ], + "setup_commands": [ + "beta.setup_command_param.SetupCommandParam" + ], + "skills": [ + "beta.hosted_skill_param.HostedSkillParamInline", + "beta.hosted_skill_param.HostedSkillParamSkillReference" + ], + "type": [] + }, + "beta.environment_param.EnvironmentParamOpenAIHostedNetwork": { + "access": [], + "allowed_domains": [] + }, + "beta.environment_param.EnvironmentParamOpenAIHostedPackages": { + "npm": [], + "python": [], + "system": [] + }, + "beta.environment_param.EnvironmentParamSelfHosted": { + "capability_directories": [], + "type": [], + "workspace_directory": [] + }, + "beta.hosted_environment_file.HostedEnvironmentFileResourceInline": { + "id": [], + "path": [], + "size_bytes": [], + "type": [] + }, + "beta.hosted_environment_file_id.HostedEnvironmentFileID": { + "file_id": [], + "id": [], + "path": [], + "size_bytes": [], + "type": [] + }, + "beta.hosted_environment_file_param.HostedEnvironmentFileParamFileID": { + "file_id": [], + "path": [], + "type": [] + }, + "beta.hosted_environment_file_param.HostedEnvironmentFileParamInline": { + "data": [], + "path": [], + "type": [] + }, + "beta.hosted_plugin.HostedPlugin": { + "description": [], + "name": [], + "type": [] + }, + "beta.hosted_plugin_param.HostedPluginParam": { + "description": [], + "name": [], + "source": [ + "beta.inline_capability_source_param.InlineCapabilitySourceParam" + ], + "type": [] + }, + "beta.hosted_skill.HostedSkillResourceInline": { + "description": [], + "name": [], + "type": [] + }, + "beta.hosted_skill_param.HostedSkillParamInline": { + "description": [], + "name": [], + "source": [ + "beta.inline_capability_source_param.InlineCapabilitySourceParam" + ], + "type": [] + }, + "beta.hosted_skill_param.HostedSkillParamSkillReference": { + "skill_id": [], + "type": [], + "version": [] + }, + "beta.hosted_skill_reference.HostedSkillReference": { + "description": [], + "name": [], + "skill_id": [], + "type": [], + "version": [] + }, + "beta.inline_capability_source_param.InlineCapabilitySourceParam": { + "data": [], + "media_type": [], + "type": [] + }, + "beta.input_content.InputContentResourceInputImage": { + "image_url": [], + "type": [] + }, + "beta.input_content.InputContentResourceInputText": { + "text": [], + "type": [] + }, + "beta.input_content_param.InputContentParamInputImage": { + "image_url": [], + "type": [] + }, + "beta.input_content_param.InputContentParamInputText": { + "text": [], + "type": [] + }, + "beta.mcp_transport.McpTransportResourceHTTP": { + "server_url": [], + "type": [] + }, + "beta.mcp_transport.McpTransportResourceStdio": { + "args": [], + "command": [], + "cwd": [], + "env_vars": [], + "type": [] + }, + "beta.mcp_transport_param.McpTransportConfigParamHTTP": { + "authorization": [], + "headers": [], + "server_url": [], + "type": [] + }, + "beta.mcp_transport_param.McpTransportConfigParamStdio": { + "args": [], + "command": [], + "cwd": [], + "env": [], + "env_vars": [], + "type": [] + }, + "beta.multi_agent_config.MultiAgentConfig": { + "enabled": [], + "max_concurrent_subagents": [] + }, + "beta.multi_agent_config_param.MultiAgentConfigParam": { + "enabled": [], + "max_concurrent_subagents": [] + }, + "beta.output_text.OutputText": { + "text": [], + "type": [] + }, + "beta.persisted_agent_tool.PersistedAgentToolResourceFunction": { + "defer_loading": [], + "description": [], + "name": [], + "parameters": [], + "type": [] + }, + "beta.persisted_agent_tool.PersistedAgentToolResourceMcp": { + "allowed_tools": [], + "connection_origin": [], + "credential_id": [], + "request_metadata": [], + "required": [], + "server_label": [], + "transport": [ + "beta.persisted_mcp_transport.PersistedMcpTransportResourceHTTP", + "beta.persisted_mcp_transport.PersistedMcpTransportResourceStdio" + ], + "type": [] + }, + "beta.persisted_agent_tool.PersistedAgentToolResourceProgrammaticToolCalling": { + "enabled": [], + "type": [] + }, + "beta.persisted_agent_tool.PersistedAgentToolResourceToolSearch": { + "type": [] + }, + "beta.persisted_agent_tool.PersistedAgentToolResourceWebSearch": { + "allowed_domains": [], + "context_size": [], + "location": [ + "beta.persisted_agent_tool.PersistedAgentToolResourceWebSearchLocation" + ], + "mode": [], + "type": [] + }, + "beta.persisted_agent_tool.PersistedAgentToolResourceWebSearchLocation": { + "city": [], + "country": [], + "region": [], + "timezone": [] + }, + "beta.persisted_agent_tool_param.PersistedAgentToolConfigParamFunction": { + "defer_loading": [], + "description": [], + "name": [], + "parameters": [], + "type": [] + }, + "beta.persisted_agent_tool_param.PersistedAgentToolConfigParamMcp": { + "allowed_tools": [], + "connection_origin": [], + "credential_id": [], + "request_metadata": [], + "required": [], + "server_label": [], + "transport": [ + "beta.persisted_mcp_transport_param.PersistedMcpTransportConfigParamHTTP", + "beta.persisted_mcp_transport_param.PersistedMcpTransportConfigParamStdio" + ], + "type": [] + }, + "beta.persisted_agent_tool_param.PersistedAgentToolConfigParamProgrammaticToolCalling": { + "enabled": [], + "type": [] + }, + "beta.persisted_agent_tool_param.PersistedAgentToolConfigParamToolSearch": { + "type": [] + }, + "beta.persisted_agent_tool_param.PersistedAgentToolConfigParamWebSearch": { + "allowed_domains": [], + "context_size": [], + "location": [ + "beta.persisted_agent_tool_param.PersistedAgentToolConfigParamWebSearchLocation" + ], + "mode": [], + "type": [] + }, + "beta.persisted_agent_tool_param.PersistedAgentToolConfigParamWebSearchLocation": { + "city": [], + "country": [], + "region": [], + "timezone": [] + }, + "beta.persisted_mcp_transport.PersistedMcpTransportResourceHTTP": { + "headers": [], + "server_url": [], + "type": [] + }, + "beta.persisted_mcp_transport.PersistedMcpTransportResourceStdio": { + "args": [], + "command": [], + "cwd": [], + "env_vars": [], + "type": [] + }, + "beta.persisted_mcp_transport_param.PersistedMcpTransportConfigParamHTTP": { + "headers": [], + "server_url": [], + "type": [] + }, + "beta.persisted_mcp_transport_param.PersistedMcpTransportConfigParamStdio": { + "args": [], + "command": [], + "cwd": [], + "env_vars": [], + "type": [] + }, + "beta.session_error.SessionError": { + "code": [], + "message": [], + "param": [], + "type": [] + }, + "beta.session_turn_error.SessionTurnError": { + "code": [], + "message": [] + }, + "beta.setup_command_param.SetupCommandParam": { + "command": [], + "cwd": [] + }, + "beta.subagent.Subagent": { + "closed_at": [], + "id": [], + "instructions": [ + "beta.agent_content.EncryptedContentResource", + "beta.output_text.OutputText" + ], + "name": [], + "object": [], + "opened_at": [], + "parent_agent_id": [], + "session_id": [], + "status": [] + }, + "beta.summary_text.SummaryText": { + "text": [], + "type": [] + }, + "beta.text_format.TextFormatResourceJSONSchema": { + "schema": [], + "type": [] + }, + "beta.text_format.TextFormatResourceText": { + "type": [] + }, + "beta.text_format_param.TextFormatParamJSONSchema": { + "schema": [], + "type": [] + }, + "beta.text_format_param.TextFormatParamText": { + "type": [] + }, + "beta.token_usage.InputTokensDetails": { + "cached_tokens": [] + }, + "beta.token_usage.OutputTokensDetails": { + "reasoning_tokens": [] + }, + "beta.token_usage.TokenUsage": { + "input_tokens": [], + "input_tokens_details": [ + "beta.token_usage.InputTokensDetails" + ], + "output_tokens": [], + "output_tokens_details": [ + "beta.token_usage.OutputTokensDetails" + ], + "total_tokens": [] + }, + "beta.web_search_action.WebSearchActionResourceFindInPage": { + "pattern": [], + "type": [], + "url": [] + }, + "beta.web_search_action.WebSearchActionResourceOpenPage": { + "type": [], + "url": [] + }, + "beta.web_search_action.WebSearchActionResourceOther": { + "type": [] + }, + "beta.web_search_action.WebSearchActionResourceSearch": { + "queries": [], + "query": [], + "type": [] + }, + "deleted_skill.DeletedSkill": { + "deleted": [], + "id": [], + "object": [] + }, + "file_create_params.ExpiresAfter": { + "anchor": [], + "seconds": [] + }, + "file_create_params.FileCreateParams": { + "expires_after": [ + "file_create_params.ExpiresAfter" + ], + "file": [], + "purpose": [] + }, + "file_deleted.FileDeleted": { + "deleted": [], + "id": [], + "object": [] + }, + "file_object.FileObject": { + "bytes": [], + "created_at": [], + "expires_at": [], + "filename": [], + "id": [], + "object": [], + "purpose": [], + "status": [], + "status_details": [] + }, + "pagination.SyncCursorPage[beta.agent.Agent]": { + "data": [ + "beta.agent.Agent" + ], + "has_more": [] + }, + "pagination.SyncCursorPage[beta.agent_close_subagent_call_item.AgentCloseSubagentCallItem | beta.agent_command_execution_item.AgentCommandExecutionItem | beta.agent_create_subagent_call_item.AgentCreateSubagentCallItem | beta.agent_function_call_item.AgentFunctionCallItem | beta.agent_interrupt_subagent_call_item.AgentInterruptSubagentCallItem | beta.agent_mcp_call_item.AgentMcpCallItem | beta.agent_reasoning_item.AgentReasoningItem | beta.agent_resume_subagent_call_item.AgentResumeSubagentCallItem | beta.agent_send_subagent_input_call_item.AgentSendSubagentInputCallItem | beta.agent_session_item.AgentMessageItemResource | beta.agent_session_item.FunctionCallOutputItemResource | beta.agent_session_message.AgentSessionMessage | beta.agent_wait_for_subagents_call_item.AgentWaitForSubagentsCallItem | beta.agent_web_search_call_item.AgentWebSearchCallItem]": { + "data": [ + "beta.agent_close_subagent_call_item.AgentCloseSubagentCallItem", + "beta.agent_command_execution_item.AgentCommandExecutionItem", + "beta.agent_create_subagent_call_item.AgentCreateSubagentCallItem", + "beta.agent_function_call_item.AgentFunctionCallItem", + "beta.agent_interrupt_subagent_call_item.AgentInterruptSubagentCallItem", + "beta.agent_mcp_call_item.AgentMcpCallItem", + "beta.agent_reasoning_item.AgentReasoningItem", + "beta.agent_resume_subagent_call_item.AgentResumeSubagentCallItem", + "beta.agent_send_subagent_input_call_item.AgentSendSubagentInputCallItem", + "beta.agent_session_item.AgentMessageItemResource", + "beta.agent_session_item.FunctionCallOutputItemResource", + "beta.agent_session_message.AgentSessionMessage", + "beta.agent_wait_for_subagents_call_item.AgentWaitForSubagentsCallItem", + "beta.agent_web_search_call_item.AgentWebSearchCallItem" + ], + "has_more": [] + }, + "pagination.SyncCursorPage[beta.agent_session.AgentSession]": { + "data": [ + "beta.agent_session.AgentSession" + ], + "has_more": [] + }, + "pagination.SyncCursorPage[beta.agents.environments.environment_template.EnvironmentTemplate]": { + "data": [ + "beta.agents.environments.environment_template.EnvironmentTemplate" + ], + "has_more": [] + }, + "pagination.SyncCursorPage[beta.agents.sessions.session_artifact.SessionArtifact]": { + "data": [ + "beta.agents.sessions.session_artifact.SessionArtifact" + ], + "has_more": [] + }, + "pagination.SyncCursorPage[beta.agents.sessions.turn.Turn]": { + "data": [ + "beta.agents.sessions.turn.Turn" + ], + "has_more": [] + }, + "pagination.SyncCursorPage[beta.agents.vault.Vault]": { + "data": [ + "beta.agents.vault.Vault" + ], + "has_more": [] + }, + "pagination.SyncCursorPage[beta.agents.vaults.credential.Credential]": { + "data": [ + "beta.agents.vaults.credential.Credential" + ], + "has_more": [] + }, + "pagination.SyncCursorPage[beta.subagent.Subagent]": { + "data": [ + "beta.subagent.Subagent" + ], + "has_more": [] + }, + "pagination.SyncCursorPage[file_object.FileObject]": { + "data": [ + "file_object.FileObject" + ], + "has_more": [] + }, + "pagination.SyncCursorPage[skill.Skill]": { + "data": [ + "skill.Skill" + ], + "has_more": [] + }, + "pagination.SyncCursorPage[skills.skill_version.SkillVersion]": { + "data": [ + "skills.skill_version.SkillVersion" + ], + "has_more": [] + }, + "pagination.SyncTokenPage[beta.agents.environments.environment_file.EnvironmentFile]": { + "data": [ + "beta.agents.environments.environment_file.EnvironmentFile" + ], + "has_more": [], + "next": [] + }, + "skill.Skill": { + "created_at": [], + "default_version": [], + "description": [], + "id": [], + "latest_version": [], + "name": [], + "object": [] + }, + "skill_create_params.SkillCreateParams": { + "files": [] + }, + "skill_update_params.SkillUpdateParams": { + "default_version": [] + }, + "skills.deleted_skill_version.DeletedSkillVersion": { + "deleted": [], + "id": [], + "object": [], + "version": [] + }, + "skills.skill_version.SkillVersion": { + "created_at": [], + "description": [], + "id": [], + "name": [], + "object": [], + "skill_id": [], + "version": [] + }, + "skills.version_create_params.VersionCreateParams": { + "default": [], + "files": [] + } + } +} diff --git a/contracts/agents-api/upstream-routes.json b/contracts/agents-api/upstream-routes.json new file mode 100644 index 000000000..3326834f2 --- /dev/null +++ b/contracts/agents-api/upstream-routes.json @@ -0,0 +1,70 @@ +{ + "sdk_version": "3.13.0", + "commit": "d7c41efee1b0802b79f3f88a678ef2052b06e9ce", + "generator": "scripts/extract-agents-api-upstream.py", + "resources": [ + "openai.resources.beta.agents", + "openai.resources.files", + "openai.resources.skills" + ], + "routes": [ + "DELETE /agents/environments/templates/{}", + "DELETE /agents/sessions/{}", + "DELETE /agents/sessions/{}/artifacts/{}", + "DELETE /agents/{}", + "DELETE /files/{}", + "DELETE /skills/{}", + "DELETE /skills/{}/versions/{}", + "DELETE /vaults/{}", + "DELETE /vaults/{}/credentials/{}", + "GET /agents", + "GET /agents/environments/templates", + "GET /agents/environments/templates/{}", + "GET /agents/environments/{}", + "GET /agents/environments/{}/files", + "GET /agents/sessions", + "GET /agents/sessions/{}", + "GET /agents/sessions/{}/artifacts", + "GET /agents/sessions/{}/artifacts/{}", + "GET /agents/sessions/{}/artifacts/{}/content", + "GET /agents/sessions/{}/events", + "GET /agents/sessions/{}/items", + "GET /agents/sessions/{}/subagents", + "GET /agents/sessions/{}/subagents/{}", + "GET /agents/sessions/{}/subagents/{}/items", + "GET /agents/sessions/{}/subagents/{}/turns", + "GET /agents/sessions/{}/subagents/{}/turns/{}", + "GET /agents/sessions/{}/subagents/{}/turns/{}/items", + "GET /agents/sessions/{}/turns", + "GET /agents/sessions/{}/turns/{}", + "GET /agents/{}", + "GET /files", + "GET /files/{}", + "GET /files/{}/content", + "GET /skills", + "GET /skills/{}", + "GET /skills/{}/content", + "GET /skills/{}/versions", + "GET /skills/{}/versions/{}", + "GET /skills/{}/versions/{}/content", + "GET /vaults", + "GET /vaults/{}", + "GET /vaults/{}/credentials", + "GET /vaults/{}/credentials/{}", + "POST /agents", + "POST /agents/environments/templates", + "POST /agents/environments/templates/{}", + "POST /agents/environments/{}/files", + "POST /agents/sessions", + "POST /agents/sessions/{}", + "POST /agents/sessions/{}/events", + "POST /agents/{}", + "POST /files", + "POST /skills", + "POST /skills/{}", + "POST /skills/{}/versions", + "POST /vaults", + "POST /vaults/{}/credentials", + "POST /vaults/{}/credentials/{}" + ] +} diff --git a/contracts/agents-api/v1/model_execution.go b/contracts/agents-api/v1/model_execution.go index 72e6c5e78..4edf6dd27 100644 --- a/contracts/agents-api/v1/model_execution.go +++ b/contracts/agents-api/v1/model_execution.go @@ -11,7 +11,6 @@ import ( // SessionExecutionInput is a write-only execution extension, not a provider resource. type SessionExecutionInput struct { ModelProvider *ModelProviderInput `json:"model_provider,omitempty"` - SandboxNodeID *string `json:"sandbox_node_id,omitempty"` } type ModelProviderInput struct { diff --git a/contracts/agents-api/v1/runtime_history_test.go b/contracts/agents-api/v1/runtime_history_test.go index 70e8f06b3..72848f4be 100644 --- a/contracts/agents-api/v1/runtime_history_test.go +++ b/contracts/agents-api/v1/runtime_history_test.go @@ -11,7 +11,7 @@ import ( func TestRuntimeHistoryOpenAPICollectionLimits(t *testing.T) { t.Parallel() - raw, err := os.ReadFile("../openapi.yaml") + raw, err := os.ReadFile("../sandbox-manager.openapi.yaml") if err != nil { t.Fatalf("read generated OpenAPI contract: %v", err) } diff --git a/contracts/agents-api/v1/runtime_observations.go b/contracts/agents-api/v1/runtime_observations.go index 56d42834f..7b47d8720 100644 --- a/contracts/agents-api/v1/runtime_observations.go +++ b/contracts/agents-api/v1/runtime_observations.go @@ -37,11 +37,3 @@ type RuntimeMemoryObservation struct { UsageBytes *uint64 `json:"usage_bytes" extensions:"x-nullable" binding:"required" minimum:"0"` LimitBytes *uint64 `json:"limit_bytes" extensions:"x-nullable" binding:"required" minimum:"1"` } - -type RuntimeObservationList struct { - Object string `json:"object" enums:"list" binding:"required"` - Data []RuntimeObservation `json:"data" binding:"required"` - HasMore bool `json:"has_more" binding:"required"` - FirstID *string `json:"first_id" extensions:"x-nullable" binding:"required" format:"uuid"` - LastID *string `json:"last_id" extensions:"x-nullable" binding:"required" format:"uuid"` -} diff --git a/contracts/agents-api/v1/upstream_contract_test.go b/contracts/agents-api/v1/upstream_contract_test.go new file mode 100644 index 000000000..a2a991a3d --- /dev/null +++ b/contracts/agents-api/v1/upstream_contract_test.go @@ -0,0 +1,226 @@ +package v1 + +import ( + "encoding/json" + "os" + "regexp" + "slices" + "sort" + "strings" + "testing" + + "gopkg.in/yaml.v3" +) + +// upstream-routes.json and upstream-fields.json are produced from the pinned +// SDK by scripts/extract-agents-api-upstream.py. + +var openAPIPathParameter = regexp.MustCompile(`\{[^}]*\}`) + +func readContractJSON(t *testing.T, name string, value any) { + t.Helper() + raw, err := os.ReadFile("../" + name) + if err != nil { + t.Fatal(err) + } + if err := json.Unmarshal(raw, value); err != nil { + t.Fatalf("parse %s: %v", name, err) + } +} + +// publicOperations indexes the public OpenAPI operations by "METHOD /path", +// with path parameters normalised to {} like the pinned route set. +func publicOperations(t *testing.T) (map[string]map[string]any, map[string]any) { + t.Helper() + raw, err := os.ReadFile("../openapi.yaml") + if err != nil { + t.Fatal(err) + } + var document map[string]any + if err := yaml.Unmarshal(raw, &document); err != nil { + t.Fatal(err) + } + operations := map[string]map[string]any{} + for path, item := range openAPIMap(t, document, "paths") { + for method, operation := range item.(map[string]any) { + if method == "parameters" { + continue + } + operations[strings.ToUpper(method)+" "+openAPIPathParameter.ReplaceAllString(path, "{}")] = operation.(map[string]any) + } + } + return operations, openAPIMap(t, document, "definitions") +} + +func TestPublicOpenAPIRoutesMatchPinnedUpstream(t *testing.T) { + var pinned struct { + Routes []string `json:"routes"` + } + readContractJSON(t, "upstream-routes.json", &pinned) + operations, _ := publicOperations(t) + var missing, extra []string + for _, route := range pinned.Routes { + if operations[route] == nil { + missing = append(missing, route) + } + } + for route := range operations { + if !slices.Contains(pinned.Routes, route) { + extra = append(extra, route) + } + } + sort.Strings(extra) + if len(pinned.Routes) == 0 || len(missing) != 0 || len(extra) != 0 { + t.Fatalf("public OpenAPI differs from the pinned SDK route set: missing %v, extra %v", missing, extra) + } +} + +// fieldPlacementPending temporarily lists properties that are neither in the +// pinned SDK type nor inside x_agents_core, keyed by schema.property. Each one +// awaits a decision: remove it, move it into x_agents_core or rename it to the +// official field. Remove an entry once it is resolved; stale entries fail. +var fieldPlacementPending = map[string]string{ + // Naming mismatch, not an extension: v1.ItemContent is shared by Item + // content, where these fields are official, and the content_part and + // reasoning_summary_part event parts, whose official types have only text + // and type. + "v1.ItemContent.encrypted_content": "shared with Item content; not on official event parts", + "v1.ItemContent.image_url": "shared with Item content; not on official event parts", + // Naming mismatch, not an extension: v1.StreamError is shared by error + // events (official SessionError.param) and Environment state errors, whose + // official type has no param; Core omits it there. + "v1.StreamError.param": "shared with error events; not on official Environment state errors", +} + +// Official list pages also carry object, first_id and last_id (recorded in +// official-semantics-alignment.md), which the SDK's hand-written page classes +// do not declare. +var listEnvelopeFields = []string{"object", "first_id", "last_id"} + +type fieldAudit struct { + definitions map[string]any + types map[string]map[string][]string + visited map[string]bool + violations map[string][]string +} + +func TestPublicOpenAPIFieldsAreOfficialOrCoreExtensions(t *testing.T) { + var pinned struct { + Operations map[string]struct{ Request, Response []string } `json:"operations"` + Types map[string]map[string][]string `json:"types"` + } + readContractJSON(t, "upstream-fields.json", &pinned) + operations, definitions := publicOperations(t) + audit := fieldAudit{definitions: definitions, types: pinned.Types, visited: map[string]bool{}, violations: map[string][]string{}} + for route, operation := range operations { + official, ok := pinned.Operations[route] + if !ok { + continue // The route set test reports it. + } + form := map[string]any{} + for _, parameter := range asSlice(operation["parameters"]) { + parameter := parameter.(map[string]any) + switch parameter["in"] { + case "body": + audit.compare(route+" body", parameter["schema"].(map[string]any), official.Request) + case "formData": + form[parameter["name"].(string)] = map[string]any{} + } + } + if len(form) != 0 { + audit.compare(route+" form", map[string]any{"properties": form}, official.Request) + } + for code, response := range asMap(operation["responses"]) { + if schema, ok := asMap(response)["schema"].(map[string]any); ok && strings.HasPrefix(code, "2") { + audit.compare(route+" response", schema, official.Response) + } + } + } + for field, sources := range audit.violations { + if _, ok := fieldPlacementPending[field]; !ok { + t.Errorf("%s is not an official field of %v; put Core-only fields inside x_agents_core", field, sources) + } + } + for field := range fieldPlacementPending { + if audit.violations[field] == nil { + t.Errorf("resolved field %s is still listed in fieldPlacementPending", field) + } + } +} + +// compare checks one of our schemas against the union of the official types +// that describe the same value, following nested official models. +func (a *fieldAudit) compare(name string, schema map[string]any, official []string) { + name, schema = a.resolve(name, schema) + key := name + "|" + strings.Join(official, ",") + if schema == nil || a.visited[key] { + return + } + a.visited[key] = true + for property, value := range a.properties(schema) { + var children []string + known := false + for _, typeName := range official { + if nested, ok := a.types[typeName][property]; ok { + known = true + for _, child := range nested { + if !slices.Contains(children, child) { + children = append(children, child) + } + } + } + } + switch { + case known && len(children) != 0: + sort.Strings(children) + a.compare(name+"."+property, value, children) + case known || property == "x_agents_core": + case slices.Contains(listEnvelopeFields, property) && slices.ContainsFunc(official, func(typeName string) bool { return strings.HasPrefix(typeName, "pagination.") }): + default: + a.violations[name+"."+property] = official + } + } +} + +// resolve follows references, single allOf wrappers and arrays to the object +// schema, naming it by its definition when it has one. +func (a *fieldAudit) resolve(name string, schema map[string]any) (string, map[string]any) { + for schema != nil { + switch { + case schema["$ref"] != nil: + name = strings.TrimPrefix(schema["$ref"].(string), "#/definitions/") + schema = asMap(a.definitions[name]) + case len(asSlice(schema["allOf"])) == 1: + schema = asMap(asSlice(schema["allOf"])[0]) + case schema["items"] != nil: + schema = asMap(schema["items"]) + default: + return name, schema + } + } + return name, nil +} + +func (a *fieldAudit) properties(schema map[string]any) map[string]map[string]any { + result := map[string]map[string]any{} + for property, value := range asMap(schema["properties"]) { + result[property] = asMap(value) + } + for _, part := range asSlice(schema["allOf"]) { + _, resolved := a.resolve("", asMap(part)) + for property, value := range a.properties(resolved) { + result[property] = value + } + } + return result +} + +func asMap(value any) map[string]any { + result, _ := value.(map[string]any) + return result +} + +func asSlice(value any) []any { + result, _ := value.([]any) + return result +} diff --git a/docs/api/README.md b/docs/api/README.md index ec84800d3..9ec9fb417 100644 --- a/docs/api/README.md +++ b/docs/api/README.md @@ -1,12 +1,12 @@ # API documentation -Choose the API by its caller and authority. A route prefix alone does not establish -OpenAI compatibility: Core also has explicitly documented extensions under `/v1`. +Choose the API by its caller and authority. `/v1` has exactly the pinned official +routes; its only Core additions are the `x_agents_core` fields described in the +[public API](public-agent-api.md#core-extensions). | Surface | Caller and credential | Entry point | Reference | | --- | --- | --- | --- | | Public Agents API | Applications; a database-issued Project API key | Direct Core `/v1` | [Public API](public-agent-api.md) | -| Project-scoped Core extensions | Applications; the same Project API key | Direct Core; extension-specific paths | [Extension index](public-agent-api.md#core-extensions) | | Administrator resources | Web's server or administrative automation; deployment credential | Core `/core/v1/admin` | [Management contract](../../contracts/agents-api/admin-api.md) | | Hosted sandbox administration | Web's server or administrative automation; deployment credential | Core `/core/v1/sandbox` management routes | [Web API](web-management.md#sandbox-administration) | | Console authentication | Browser; local console sign-in and session cookie | Console `/console/auth` | [Web API](web-management.md#browser-to-console) | @@ -27,6 +27,11 @@ when given an application key or machine credential. - [Pinned upstream baseline](../../contracts/agents-api/upstream.json): OpenAI Python SDK 3.13.0, exact upstream commit and `agents=v1`. +- [Pinned routes](../../contracts/agents-api/upstream-routes.json) and + [fields](../../contracts/agents-api/upstream-fields.json): extracted from the + pinned SDK by `scripts/extract-agents-api-upstream.py`. Contract tests require the + public OpenAPI to have exactly these routes and to keep every other field inside + `x_agents_core`. - [Public OpenAPI](../../contracts/agents-api/openapi.yaml): public schema snapshot; combine it with the fixed SDK and [operation evidence](../../contracts/agents-api/operation-evidence.md). - [Core extension OpenAPI](../../contracts/agents-api/sandbox-manager.openapi.yaml): diff --git a/docs/api/public-agent-api.md b/docs/api/public-agent-api.md index 7b15ded30..2dfd04bf8 100644 --- a/docs/api/public-agent-api.md +++ b/docs/api/public-agent-api.md @@ -56,17 +56,23 @@ native execution. Do not automatically replay uncertain tool or file effects. ## Core extensions -These are application-facing Core capabilities, not official OpenAI operations. -They retain Project authentication and scope. Web administrators use the separate -management equivalents where provided. - -| Extension | Reference | -| --- | --- | -| Harness selection and write-only model access | [Harness selection](../../contracts/agents-api/harness-selection.md), [model execution](../../contracts/agents-api/model-execution.md) | -| Effective Session execution configuration | [Configuration query](../../contracts/agents-api/execution-configuration.md) | -| Startup support and process configuration | [Startup configuration](../../contracts/agents-api/startup-configuration.md) | -| Current Runtime observations and stored telemetry | [Observation API](../../contracts/agents-api/runtime-observability-api.md), [history API](../../contracts/agents-api/runtime-history-api.md) | -| Environment-bound Runtime credential issuance/revocation | [Executor credentials](../../contracts/agents-api/environment-executor-credentials.md); `/core/v1/environments/{environment_id}/executor-credentials` is Project-authenticated despite its `/core` prefix | +The `/v1` route set is exactly the pinned SDK's operations, listed in +[upstream-routes.json](../../contracts/agents-api/upstream-routes.json). Core adds +fields only inside `x_agents_core`, because it runs several harnesses and accepts +custom model access: + +| Field | Where | Reference | +| --- | --- | --- | +| `x_agents_core.harness` | Saved Agent create, update and read; the inline Session `agent`; the Session's effective `agent` | [Harness selection](../../contracts/agents-api/harness-selection.md) | +| `x_agents_core.model_provider` | Saved Agent create, update and read; Session creation. It takes `protocol`, `base_url`, optional `context_window` and `max_output_tokens`, and a write-only `api_key`; reads return `api_key_configured` instead of the key | [Model execution](../../contracts/agents-api/model-execution.md) | + +Any other member of `x_agents_core` is rejected with 400. Deployment, placement +and operational reads are not part of `/v1`; they belong to the administrator API +under `/core/v1` (see the [API index](README.md)). + +A `self_hosted` Runtime authenticates with an Environment-bound credential issued +at `/core/v1/environments/{environment_id}/executor-credentials` with the Project +key; see [executor credentials](../../contracts/agents-api/environment-executor-credentials.md). `openai_hosted` keeps the official wire name and means Core-managed compute here. The deployment chooses E2B or its own Docker/microsandbox nodes. A public diff --git a/docs/web/protocol-coverage.md b/docs/web/protocol-coverage.md index a8ba98f00..6d281e771 100644 --- a/docs/web/protocol-coverage.md +++ b/docs/web/protocol-coverage.md @@ -47,7 +47,8 @@ storage, URLs or logs. There is no project deletion and no plaintext recovery. Routes are relative to `/core/v1/admin/projects/{project_id}` and return the same objects as the corresponding public `/v1` operations, so the console applies the -public client's strict projections. Archived projects remain readable. +public client's strict projections. Runtime observation and history exist only +here. Archived projects remain readable. | Resource | Reads used | Deletion | Creator | Console surface | | --- | --- | --- | --- | --- | diff --git a/packages/agents-client/saved-agent-defaults.md b/packages/agents-client/saved-agent-defaults.md index 5ef22df5f..88662c36f 100644 --- a/packages/agents-client/saved-agent-defaults.md +++ b/packages/agents-client/saved-agent-defaults.md @@ -45,10 +45,11 @@ defers protocol compatibility to Session admission. Existing Sessions retain the configuration snapshots. Session inline `agent.x_agents_core` remains harness-only; one-off provider overrides belong in the Session's top-level `x_agents_core`. -Inspect the configuration committed for one Session without reading credentials: +The Session's effective harness is in its `agent.x_agents_core`. Administrators +can inspect the configuration committed for one Session without reading credentials: ```ts -const frozen = await client.retrieveSessionExecutionConfiguration(session.id); +const frozen = await admin.retrieveSessionExecutionConfiguration(projectId, session.id); console.log(frozen.model.value, frozen.model.source, frozen.harness.value); // Deployment details are redacted; historical provider snapshots may be unavailable. if (frozen.model_provider.status === "available") { diff --git a/packages/agents-client/src/client.test.ts b/packages/agents-client/src/client.test.ts index 016c7dd44..0b021e45a 100644 --- a/packages/agents-client/src/client.test.ts +++ b/packages/agents-client/src/client.test.ts @@ -1,5 +1,6 @@ import { afterEach, describe, expect, it, vi } from "vitest"; +import { AdminClient } from "./admin-client"; import { AgentCoreError, CreationStreamRetryError, createIdempotencyKey, isSessionDeletionConflict, OpenAIAgentsClient } from "./client"; import hostedDadf64 from "./fixtures/parsar-dadf64a7/openai-hosted.json"; import eventBatchDadf64 from "./fixtures/parsar-dadf64a7/session-event-batch.json"; @@ -103,6 +104,7 @@ function messageItem(overrides: Record = {}): Record { it("retrieves a Runtime observation, preserves observed zeroes, and encodes the Session ID", async () => { const calls: FetchCall[] = []; - const client = new OpenAIAgentsClient({ - baseUrl: "https://core.example/v1", + const client = new AdminClient({ + baseUrl: "https://core.example/core/v1/admin", fetch: recordingFetch(jsonResponse(runtimeObservation()), calls), }); - await expect(client.retrieveRuntimeObservation(runtimeSessionId)).resolves.toMatchObject({ + await expect(client.retrieveRuntimeObservation(runtimeProjectId, runtimeSessionId)).resolves.toMatchObject({ id: runtimeSessionId, cpu: { usage_seconds_total: 0 }, memory: { usage_bytes: 0 }, }); expect(String(calls[0]?.input)).toBe( - `https://core.example/v1/agents/sessions/${runtimeSessionId}/runtime-observation`, - ); - }); - - it("lists Runtime observations with stable pagination metadata and query serialization", async () => { - const calls: FetchCall[] = []; - const body = { - object: "list", - data: [runtimeObservation()], - has_more: true, - first_id: runtimeSessionId, - last_id: runtimeSessionId, - }; - const client = new OpenAIAgentsClient({ - baseUrl: "https://core.example/v1/", - fetch: recordingFetch(jsonResponse(body), calls), - }); - - await expect(client.listRuntimeObservations({ - after: runtimeSessionId, limit: 1, order: "asc", - })).resolves.toMatchObject(body); - expect(String(calls[0]?.input)).toBe( - `https://core.example/v1/agents/runtime-observations?after=${runtimeSessionId}&limit=1&order=asc`, + `https://core.example/core/v1/admin/projects/${runtimeProjectId}/sessions/${runtimeSessionId}/runtime-observation`, ); }); @@ -2851,8 +2831,8 @@ describe("OpenAIAgentsClient", () => { cpu: null, memory: null, }); - const client = new OpenAIAgentsClient({ fetch: recordingFetch(jsonResponse(value), []) }); - await expect(client.retrieveRuntimeObservation(runtimeSessionId)).resolves.toMatchObject(value); + const client = new AdminClient({ fetch: recordingFetch(jsonResponse(value), []) }); + await expect(client.retrieveRuntimeObservation(runtimeProjectId, runtimeSessionId)).resolves.toMatchObject(value); }); it.each([ @@ -2877,42 +2857,10 @@ describe("OpenAIAgentsClient", () => { usage_bytes: 1, limit_bytes: 0, } })], ])("rejects a Runtime observation with %s", async (_label, build) => { - const client = new OpenAIAgentsClient({ fetch: recordingFetch(jsonResponse(build()), []) }); - await expect(client.retrieveRuntimeObservation(runtimeSessionId)).rejects.toMatchObject({ - status: 502, - code: "invalid_runtime_observation", - }); - }); - - it.each([ - ["mismatched first_id", { - object: "list", data: [runtimeObservation()], has_more: false, - first_id: runtimeEnvironmentId, last_id: runtimeSessionId, - }], - ["duplicate IDs", { - object: "list", data: [runtimeObservation(), runtimeObservation()], has_more: false, - first_id: runtimeSessionId, last_id: runtimeSessionId, - }], - ["empty continuation", { - object: "list", data: [], has_more: true, first_id: null, last_id: null, - }], - ])("rejects a Runtime observation list with %s", async (_label, body) => { - const client = new OpenAIAgentsClient({ fetch: recordingFetch(jsonResponse(body), []) }); - await expect(client.listRuntimeObservations()).rejects.toMatchObject({ + const client = new AdminClient({ fetch: recordingFetch(jsonResponse(build()), []) }); + await expect(client.retrieveRuntimeObservation(runtimeProjectId, runtimeSessionId)).rejects.toMatchObject({ status: 502, code: "invalid_runtime_observation", }); }); - - it.each([ - { after: "not-a-uuid" }, - { limit: 0 }, - { limit: 101 }, - { order: "sideways" }, - ])("rejects invalid Runtime observation pagination before fetch", async (options) => { - const calls: FetchCall[] = []; - const client = new OpenAIAgentsClient({ fetch: recordingFetch(jsonResponse({}), calls) }); - await expect(client.listRuntimeObservations(options as never)).rejects.toThrow(TypeError); - expect(calls).toHaveLength(0); - }); }); diff --git a/packages/agents-client/src/client.ts b/packages/agents-client/src/client.ts index c103ec315..21ae30ce2 100644 --- a/packages/agents-client/src/client.ts +++ b/packages/agents-client/src/client.ts @@ -1,8 +1,6 @@ -import { projectExecutionConfiguration } from "./execution-configuration-projection"; import { exactFields, onlyFields, isRecord, hasOwn, canonicalUuid, isNonnegativeInteger, sameResourceId } from "./response-projection"; import { projectTokenUsage } from "./usage-projection"; import { projectAgentTurn, projectSessionItem, projectItemContent, projectHistoryPage, validateHistoryPageOptions } from "./history-projection"; -import { projectRuntimeHistory, projectRuntimeHistoryCapabilities } from "./runtime-history-projection"; import { projectOpenAIHostedSessionEnvironment } from "./session-environment-projection"; import { createSSEDecoder } from "./sse"; import { projectVaultCredentialAuth, validCredentialURL } from "./vault-credential-auth"; @@ -52,7 +50,6 @@ import type { CreateSessionInput, CreateSessionStreamOptions, CoreStartupConfiguration, - SessionExecutionConfiguration, CoreHarnessKind, CoreManagedSandboxProvider, FunctionResultContent, @@ -94,10 +91,6 @@ import type { UpdateAgentInput, ReplaceVaultCredentialTokenInput, RuntimeObservation, - RuntimeObservationList, - RuntimeHistory, - RuntimeHistoryCapabilities, - RuntimeHistoryQuery, Vault, VaultCredential, VaultCredentialDeleted, @@ -1062,14 +1055,6 @@ function invalidRuntimeObservation(message = "Agent Core returned an invalid Run throw new AgentCoreError(message, 502, "invalid_runtime_observation"); } -function invalidRuntimeHistoryCapabilities(message = "Agent Core returned invalid Runtime history capabilities."): never { - throw new AgentCoreError(message, 502, "invalid_runtime_history_capabilities"); -} - -function invalidRuntimeHistory(message = "Agent Core returned invalid Runtime history."): never { - throw new AgentCoreError(message, 502, "invalid_runtime_history"); -} - function nullableRuntimeNumber(value: unknown): number | null { if (value === null) return null; if (typeof value !== "number" || !Number.isFinite(value) || value < 0) { @@ -1205,28 +1190,6 @@ export function projectRuntimeObservation(value: unknown, expectedSessionId?: st } as RuntimeObservation; } -function projectRuntimeObservationList(value: unknown, options?: PageOptions): RuntimeObservationList { - if ( - !isRecord(value) || !exactFields(value, vaultListFields) || value.object !== "list" || - !Array.isArray(value.data) || typeof value.has_more !== "boolean" - ) return invalidRuntimeObservation("Agent Core returned an invalid Runtime observation list."); - const limit = options?.limit ?? 20; - if ( - !Number.isSafeInteger(limit) || limit < 1 || limit > 100 || - (options?.order !== undefined && options.order !== "asc" && options.order !== "desc") || - value.data.length > limit - ) return invalidRuntimeObservation("Agent Core returned an invalid Runtime observation list."); - const data = value.data.map((entry) => projectRuntimeObservation(entry)); - const firstId = data[0]?.id ?? null; - const lastId = data[data.length - 1]?.id ?? null; - if ( - new Set(data.map((entry) => entry.id)).size !== data.length || - value.first_id !== firstId || value.last_id !== lastId || - (value.has_more && data.length === 0) - ) return invalidRuntimeObservation("Agent Core returned an invalid Runtime observation list."); - return { object: "list", data, has_more: value.has_more, first_id: firstId, last_id: lastId }; -} - function projectStreamError(value: unknown): StreamError { if ( !isRecord(value) || !onlyFields(value, sessionErrorFields) || @@ -1950,11 +1913,6 @@ function skillVersionPath(skillId: string, version: string): string { } export class OpenAIAgentsClient implements AgentCore { - async retrieveStartupConfiguration(options?: ReadOptions): Promise { - const value = await this.request("/agents/core/startup-configuration", { signal: options?.signal }, 200); - return projectStartupConfiguration(value); - } - private readonly baseUrl: string; private readonly token: OpenAIAgentsClientOptions["token"]; private readonly fetchImpl: typeof fetch; @@ -2222,58 +2180,6 @@ export class OpenAIAgentsClient implements AgentCore { return projectTolerantSessionList(value, options?.limit ?? defaultSessionListLimit, options?.order ?? "desc"); } - async listRuntimeObservations(options?: PageOptions): Promise { - if ( - (options?.after !== undefined && canonicalUuid(options.after) === null) || - (options?.limit !== undefined && ( - !Number.isSafeInteger(options.limit) || options.limit < 1 || options.limit > 100 - )) || - (options?.order !== undefined && options.order !== "asc" && options.order !== "desc") - ) throw new TypeError("Runtime observation pagination options are invalid."); - const params = new URLSearchParams(); - addPageOptions(params, options); - const value = await this.request( - withQuery("/agents/runtime-observations", params), - { signal: options?.signal }, - ); - return projectRuntimeObservationList(value, options); - } - - async retrieveRuntimeObservation(sessionId: string, options?: ReadOptions): Promise { - const value = await this.request( - `/agents/sessions/${encodeURIComponent(sessionId)}/runtime-observation`, - { signal: options?.signal }, - ); - return projectRuntimeObservation(value, sessionId); - } - - async getRuntimeHistoryCapabilities(options?: ReadOptions): Promise { - const value = await this.request( - "/agents/runtime-history/capabilities", - { signal: options?.signal }, - ); - return projectRuntimeHistoryCapabilities(value, invalidRuntimeHistoryCapabilities); - } - - async retrieveRuntimeHistory(sessionId: string, query: RuntimeHistoryQuery): Promise { - const canonicalSessionId = canonicalUuid(sessionId); - if ( - query == null || canonicalSessionId === null || !isNonnegativeInteger(query.start) || !isNonnegativeInteger(query.end) || - query.end <= query.start || (query.maxPoints !== undefined && ( - !Number.isSafeInteger(query.maxPoints) || query.maxPoints < 2 || query.maxPoints > 10_000 - )) - ) throw new TypeError("Runtime history query is invalid."); - const params = new URLSearchParams(); - params.set("start", String(query.start)); - params.set("end", String(query.end)); - if (query.maxPoints !== undefined) params.set("max_points", String(query.maxPoints)); - const value = await this.request( - withQuery(`/agents/sessions/${encodeURIComponent(canonicalSessionId)}/runtime-history`, params), - { signal: query.signal }, - ); - return projectRuntimeHistory(value, canonicalSessionId, query, invalidRuntimeHistory); - } - 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."); @@ -2343,13 +2249,6 @@ export class OpenAIAgentsClient implements AgentCore { }); } - async retrieveSessionExecutionConfiguration(sessionId: string, options?: ReadOptions): Promise { - const value = await this.request(`/agents/sessions/${encodeURIComponent(sessionId)}/execution-configuration`, { signal: options?.signal }); - return projectExecutionConfiguration(value, sessionId, () => { - throw new AgentCoreError("Agent Core returned an invalid execution configuration.", 502, "invalid_execution_configuration"); - }); - } - async retrieveSession(sessionId: string, options?: ReadOptions): Promise { const value = await this.request(`/agents/sessions/${encodeURIComponent(sessionId)}`, { signal: options?.signal }); return projectAgentSession(value, undefined, sessionId); diff --git a/packages/agents-client/src/execution-configuration.test.ts b/packages/agents-client/src/execution-configuration.test.ts index fe2f39298..50a229e97 100644 --- a/packages/agents-client/src/execution-configuration.test.ts +++ b/packages/agents-client/src/execution-configuration.test.ts @@ -1,7 +1,8 @@ import { describe, expect, it } from "vitest"; -import { OpenAIAgentsClient } from "./client"; +import { AdminClient } from "./admin-client"; import type { SessionExecutionConfiguration } from "./types"; +const projectId = "11111111-1111-4111-8111-111111111111"; const id = "013773a9-44b9-4f84-baca-b51c04a01201"; const snapshot: SessionExecutionConfiguration = { object: "agent.session.execution_configuration", schema_version: 1, session_id: id, @@ -9,7 +10,7 @@ const snapshot: SessionExecutionConfiguration = { model_provider: { status: "available", source: "agent", configuration: { protocol: "responses", base_url: "https://model.example/v1", api_key_configured: true } }, }; function clientReturning(value: unknown, seen?: (url: string, init?: RequestInit) => void) { - return new OpenAIAgentsClient({ token: "project-token", fetch: (async (url: RequestInfo | URL, init?: RequestInit) => { + return new AdminClient({ adminToken: "admin-token", fetch: (async (url: RequestInfo | URL, init?: RequestInit) => { seen?.(String(url), init); return new Response(JSON.stringify(value), { headers: { "Content-Type": "application/json" } }); }) as typeof fetch }); @@ -19,18 +20,18 @@ describe("frozen execution configuration", () => { it("reads a Session-scoped snapshot with auth and abort signal", async () => { const abort = new AbortController(); const client = clientReturning(snapshot, (url, init) => { - expect(url).toContain(`/agents/sessions/${id}/execution-configuration`); + expect(url).toBe(`/core/v1/admin/projects/${projectId}/sessions/${id}/execution-configuration`); expect(init?.signal).toBe(abort.signal); - expect(new Headers(init?.headers).get("Authorization")).toBe("Bearer project-token"); + expect(new Headers(init?.headers).get("Authorization")).toBe("Bearer admin-token"); }); - expect(await client.retrieveSessionExecutionConfiguration(id, { signal: abort.signal })).toEqual(snapshot); + expect(await client.retrieveSessionExecutionConfiguration(projectId, id, { signal: abort.signal })).toEqual(snapshot); }); it.each([ { status: "redacted", source: "deployment", configuration: null }, { status: "unavailable", source: "unknown", configuration: null }, ])("preserves explicit $status provider state", async (provider) => { const value = { ...snapshot, model_provider: provider }; - expect(await clientReturning(value).retrieveSessionExecutionConfiguration(id)).toEqual(value); + expect(await clientReturning(value).retrieveSessionExecutionConfiguration(projectId, id)).toEqual(value); }); it.each([ (value: any) => { value.model_provider.configuration.api_key = "secret-canary"; }, @@ -42,7 +43,7 @@ describe("frozen execution configuration", () => { (value: any) => { value.model.source = "guessed"; }, ])("rejects unsafe or inconsistent responses without echoing them", async (mutate) => { const value = structuredClone(snapshot); mutate(value); - await expect(clientReturning(value).retrieveSessionExecutionConfiguration(id)).rejects.toMatchObject({ code: "invalid_execution_configuration" }); - await expect(clientReturning(value).retrieveSessionExecutionConfiguration(id)).rejects.not.toThrow(/secret-canary/u); + await expect(clientReturning(value).retrieveSessionExecutionConfiguration(projectId, id)).rejects.toMatchObject({ code: "invalid_admin_response" }); + await expect(clientReturning(value).retrieveSessionExecutionConfiguration(projectId, id)).rejects.not.toThrow(/secret-canary/u); }); }); diff --git a/packages/agents-client/src/runtime-history.test.ts b/packages/agents-client/src/runtime-history.test.ts index 2b7f5f652..22fc7fad2 100644 --- a/packages/agents-client/src/runtime-history.test.ts +++ b/packages/agents-client/src/runtime-history.test.ts @@ -1,7 +1,8 @@ import { describe, expect, it } from "vitest"; -import { OpenAIAgentsClient } from "./client"; +import { AdminClient } from "./admin-client"; +const projectId = "66666666-6666-4666-8666-666666666666"; const sessionId = "11111111-1111-4111-8111-111111111111"; const environmentId = "22222222-2222-4222-8222-222222222222"; const allocationId = "33333333-3333-4333-8333-333333333333"; @@ -15,9 +16,9 @@ function jsonResponse(body: unknown): Response { return new Response(JSON.stringify(body), { headers: { "Content-Type": "application/json" } }); } -function clientFor(body: unknown, calls: FetchCall[] = []): OpenAIAgentsClient { - return new OpenAIAgentsClient({ - baseUrl: "https://core.example/v1", +function clientFor(body: unknown, calls: FetchCall[] = []): AdminClient { + return new AdminClient({ + baseUrl: "https://core.example/core/v1/admin", fetch: (async (input: RequestInfo | URL, init?: RequestInit) => { calls.push({ input, init }); return jsonResponse(body); @@ -98,21 +99,21 @@ describe("Runtime history client", () => { it("retrieves strict capabilities without exposing backend details", async () => { const calls: FetchCall[] = []; await expect(clientFor(capabilities(), calls).getRuntimeHistoryCapabilities()).resolves.toEqual(capabilities()); - expect(String(calls[0]?.input)).toBe("https://core.example/v1/agents/runtime-history/capabilities"); + expect(String(calls[0]?.input)).toBe("https://core.example/core/v1/admin/runtime-history/capabilities"); }); it("retrieves bounded Session history and preserves observed zeroes", async () => { const calls: FetchCall[] = []; const controller = new AbortController(); const response = history(); - const value = await clientFor(response, calls).retrieveRuntimeHistory(sessionId.toUpperCase(), { + const value = await clientFor(response, calls).retrieveRuntimeHistory(projectId, sessionId, { start: 1000, end: 1120, maxPoints: 60, signal: controller.signal, }); expect(String(calls[0]?.input)).toBe( - `https://core.example/v1/agents/sessions/${sessionId}/runtime-history?start=1000&end=1120&max_points=60`, + `https://core.example/core/v1/admin/projects/${projectId}/sessions/${sessionId}/runtime-history?start=1000&end=1120&max_points=60`, ); expect(calls[0]?.init?.signal).toBe(controller.signal); expect(value.series[0]?.points[0]?.cpu?.utilization_ratio).toBe(0); @@ -132,7 +133,7 @@ describe("Runtime history client", () => { [sessionId, { start: 1000, end: 1120, maxPoints: 1 }], [sessionId, { start: 1000, end: 1120, maxPoints: 10001 }], ] as const) { - await expect(client.retrieveRuntimeHistory(id, query)).rejects.toThrow(TypeError); + await expect(client.retrieveRuntimeHistory(projectId, id, query)).rejects.toThrow(TypeError); } expect(calls).toHaveLength(0); }); @@ -148,7 +149,7 @@ describe("Runtime history client", () => { it(`rejects ${name} in capabilities`, async () => { await expect(clientFor(body).getRuntimeHistoryCapabilities()).rejects.toMatchObject({ status: 502, - code: "invalid_runtime_history_capabilities", + code: "invalid_admin_response", }); }); } @@ -258,11 +259,11 @@ describe("Runtime history client", () => { it(`rejects ${name} in history`, async () => { const value = history(); mutate(value); - await expect(clientFor(value).retrieveRuntimeHistory(sessionId, { + await expect(clientFor(value).retrieveRuntimeHistory(projectId, sessionId, { start: 1000, end: 1120, maxPoints: 60, - })).rejects.toMatchObject({ status: 502, code: "invalid_runtime_history" }); + })).rejects.toMatchObject({ status: 502, code: "invalid_admin_response" }); }); } }); diff --git a/packages/agents-client/src/sandbox-client.test.ts b/packages/agents-client/src/sandbox-client.test.ts index 70c8cefe9..75e055ee4 100644 --- a/packages/agents-client/src/sandbox-client.test.ts +++ b/packages/agents-client/src/sandbox-client.test.ts @@ -1,5 +1,5 @@ import { describe, expect, expectTypeOf, it, vi } from "vitest"; -import { SandboxAdminClient, SandboxProjectClient, type SandboxNode } from "./sandbox-client"; +import { SandboxAdminClient, type SandboxNode } from "./sandbox-client"; function response(value: unknown, status = 200) { return new Response(JSON.stringify(value), { status }); } @@ -76,20 +76,6 @@ describe("Core sandbox credential boundaries", () => { expect(fetch.mock.calls[3]?.[1]?.body).toBe("{}"); expect(fetch.mock.calls[4]?.[1]?.method).toBe("DELETE"); }); - it("keeps project extension reads on project routes and forwards cancellation", async () => { - const fetch = vi.fn().mockImplementation(async () => response({ data: [] })); - const client = new SandboxProjectClient({ baseUrl: "/v1", token: () => "project-only", fetch }); - const controller = new AbortController(); - await client.listSandboxNodes({ signal: controller.signal }); - await client.retrieveSandboxPlacement("session/a"); - expect(fetch.mock.calls[0]?.[0]).toBe("/v1/sandbox/nodes"); - expect(fetch.mock.calls[0]?.[1]?.signal).toBe(controller.signal); - expect(fetch.mock.calls[1]?.[0]).toBe("/v1/agents/sessions/session%2Fa/sandbox-placement"); - for (const [, init] of fetch.mock.calls) { - expect(new Headers(init?.headers).get("Authorization")).toBe("Bearer project-only"); - expect(new Headers(init?.headers).get("OpenAI-Beta")).toBe("agents=v1"); - } - }); it("preserves resource conflict errors without retry or fallback", async () => { const fetch = vi.fn().mockResolvedValue(response({ error: { code: "runtime_node_in_use", message: "Node has retained resources.", type: "conflict_error" } }, 409)); const client = new SandboxAdminClient({ token: "admin-only", fetch }); diff --git a/packages/agents-client/src/sandbox-client.ts b/packages/agents-client/src/sandbox-client.ts index 039464288..caaf73000 100644 --- a/packages/agents-client/src/sandbox-client.ts +++ b/packages/agents-client/src/sandbox-client.ts @@ -111,24 +111,6 @@ export interface SandboxAllocation { initialization: string; created_at: string; } -export interface SandboxDirectoryNode { id: string; name: string; available: boolean } -export interface SandboxPlacement { - node_id: string; - node_name: string; - available: boolean; - state: string; - compute_phase: string; - diagnostic: SandboxDiagnostic; -} -/** Project-scoped extensions, using the ordinary /v1 project credential. */ -export class SandboxProjectClient extends OpenAIAgentsClient { - listSandboxNodes(options?: ReadOptions): Promise<{ data: SandboxDirectoryNode[] }> { - return this.request("/sandbox/nodes", { signal: options?.signal }); - } - retrieveSandboxPlacement(sessionId: string, options?: ReadOptions): Promise { - return this.request(`/agents/sessions/${encodeURIComponent(sessionId)}/sandbox-placement`, { signal: options?.signal }); - } -} /** Deployment administration uses /core/v1/sandbox, through an authenticated console or an explicit server credential. */ export class SandboxAdminClient extends OpenAIAgentsClient { retrieveDeployment(options?: ReadOptions): Promise { diff --git a/packages/agents-client/src/startup-configuration.test.ts b/packages/agents-client/src/startup-configuration.test.ts index 31054fba1..079ad612b 100644 --- a/packages/agents-client/src/startup-configuration.test.ts +++ b/packages/agents-client/src/startup-configuration.test.ts @@ -1,6 +1,6 @@ import { describe, expect, it } from "vitest"; -import { OpenAIAgentsClient } from "./client"; +import { AdminClient } from "./admin-client"; function response(body: unknown): Response { return new Response(JSON.stringify(body), { status: 200, headers: { "Content-Type": "application/json" } }); @@ -29,12 +29,12 @@ function fixture(): Record { } describe("Core startup configuration", () => { - it("reads the exact authenticated extension path and returns a defensive projection", async () => { + it("reads the exact administrator path and returns a defensive projection", async () => { const calls: Array<{ input: RequestInfo | URL; init?: RequestInit }> = []; const body = fixture(); - const client = new OpenAIAgentsClient({ - baseUrl: "https://core.example/v1/", - token: "project-key", + const client = new AdminClient({ + baseUrl: "https://core.example/core/v1/admin/", + adminToken: "admin-key", fetch: (async (input: RequestInfo | URL, init?: RequestInit) => { calls.push({ input, init }); return response(body); @@ -45,9 +45,9 @@ describe("Core startup configuration", () => { const result = await client.retrieveStartupConfiguration({ signal: abort.signal }); (body.supported as { harnesses: string[] }).harnesses[0] = "mutated"; - expect(String(calls[0]?.input)).toBe("https://core.example/v1/agents/core/startup-configuration"); + expect(String(calls[0]?.input)).toBe("https://core.example/core/v1/admin/startup-configuration"); expect(calls[0]?.init?.signal).toBe(abort.signal); - expect(new Headers(calls[0]?.init?.headers).get("Authorization")).toBe("Bearer project-key"); + expect(new Headers(calls[0]?.init?.headers).get("Authorization")).toBe("Bearer admin-key"); expect(result.supported.harnesses).toEqual(["claude_sdk", "codex", "mcode"]); expect(result.configured.managed_sandbox).toEqual({ enabled: true, provider: "docker", maintenance: false }); }); @@ -70,7 +70,7 @@ describe("Core startup configuration", () => { ])("rejects %s", async (_name, mutate) => { const body = fixture(); mutate(body); - const client = new OpenAIAgentsClient({ fetch: (async () => response(body)) as typeof fetch }); + const client = new AdminClient({ fetch: (async () => response(body)) as typeof fetch }); await expect(client.retrieveStartupConfiguration()).rejects.toMatchObject({ code: "invalid_startup_configuration", status: 502, @@ -80,7 +80,7 @@ describe("Core startup configuration", () => { it("does not reflect rejected private fields in its error", async () => { const body = fixture() as any; body.configured.private_endpoint = "https://user:secret@example.test/v1?token=private"; - const client = new OpenAIAgentsClient({ fetch: (async () => response(body)) as typeof fetch }); + const client = new AdminClient({ fetch: (async () => response(body)) as typeof fetch }); await expect(client.retrieveStartupConfiguration()).rejects.not.toThrow(/user:secret|example\.test|token=private/u); }); @@ -92,7 +92,7 @@ describe("Core startup configuration", () => { body.configured.managed_sandbox = { enabled: false, provider: null, maintenance: false }; body.configured.enabled_harnesses = []; body.configured.model_providers = []; - const client = new OpenAIAgentsClient({ fetch: (async () => response(body)) as typeof fetch }); + const client = new AdminClient({ fetch: (async () => response(body)) as typeof fetch }); await expect(client.retrieveStartupConfiguration()).resolves.toMatchObject({ configured: { daemon_gateway: false, enabled_harnesses: [], model_providers: [] }, diff --git a/packages/agents-client/src/types.ts b/packages/agents-client/src/types.ts index c9f737346..764f782bc 100644 --- a/packages/agents-client/src/types.ts +++ b/packages/agents-client/src/types.ts @@ -713,7 +713,7 @@ export interface TolerantSessionList { } export interface CreateSessionInput { - x_agents_core?: { sandbox_node_id?: string; model_provider?: ModelProviderInput | null }; + x_agents_core?: { model_provider?: ModelProviderInput | null }; agent_id?: string; agent?: InlineAgentInput; environment: AgentEnvironmentInput; @@ -1098,12 +1098,6 @@ export type RuntimeObservation = | RuntimeNoneObservation | RuntimeSelfHostedObservation; -export interface RuntimeObservationList extends ListPage { - object: "list"; - first_id: string | null; - last_id: string | null; -} - export type RuntimeHistoryCollectionMode = "on_read" | "periodic"; export type RuntimeHistoryCapabilityReason = "not_configured" | "periodic_collection_required"; export type RuntimeHistoryMetric = "cpu" | "memory" | "tokens"; @@ -1283,8 +1277,6 @@ export interface CoreStartupConfiguration { } export interface AgentCore { - retrieveSessionExecutionConfiguration(sessionId: string, options?: ReadOptions): Promise; - retrieveStartupConfiguration(options?: ReadOptions): Promise; listAgents(options?: PageOptions): Promise>; createAgent(input: CreateAgentInput): Promise; retrieveAgent(agentId: string): Promise; @@ -1302,10 +1294,6 @@ export interface AgentCore { listSessions(options?: PageOptions & { agentId?: string }): Promise>; /** Like listSessions, but a malformed Session is reported instead of failing the page. */ listSessionsTolerant(options?: SessionListOptions): Promise; - listRuntimeObservations(options?: PageOptions): Promise; - retrieveRuntimeObservation(sessionId: string, options?: ReadOptions): Promise; - getRuntimeHistoryCapabilities(options?: ReadOptions): Promise; - retrieveRuntimeHistory(sessionId: string, query: RuntimeHistoryQuery): Promise; createSession(input: CreateSessionInput, idempotencyKey?: string): Promise; createSessionStream( input: Omit, diff --git a/scripts/extract-agents-api-upstream.py b/scripts/extract-agents-api-upstream.py new file mode 100644 index 000000000..a0a687c22 --- /dev/null +++ b/scripts/extract-agents-api-upstream.py @@ -0,0 +1,181 @@ +#!/usr/bin/env python3 +"""Extract the pinned official Agents API route set and field names. + +Run this with a Python environment where the OpenAI Python SDK version pinned in +contracts/agents-api/upstream.json is installed (``pip install openai==3.13.0`` +from that commit). It reads only the installed SDK source and types: + + python3 scripts/extract-agents-api-upstream.py \ + contracts/agents-api/upstream.json contracts/agents-api + +and writes ``upstream-routes.json`` and ``upstream-fields.json`` to the output +directory. The public contract covers the Beta Agents resources +(``openai.resources.beta.agents``), Files (``openai.resources.files``) and +Skills (``openai.resources.skills``); see docs/api/public-agent-api.md. + +Routes come from each synchronous resource method's ``self._get``/``_post``/ +``_delete``/``_get_api_list`` call, with path parameters normalised to ``{}``. +Field names come from the request params TypedDicts (``body=maybe_transform``) +and the response models (``cast_to`` or ``page``) of the same calls, followed +through nested models, TypedDicts, lists and unions. JSON names use the SDK's +wire alias. Page wrappers are the SDK's hand-written classes, not generated +wire types. +""" + +import ast +import importlib +import inspect +import json +import pkgutil +import re +import sys +import typing +from pathlib import Path + +import openai +import pydantic +import typing_extensions +from openai._utils import PropertyInfo + +RESOURCE_PACKAGES = ("openai.resources.beta.agents", "openai.resources.files", "openai.resources.skills") +HTTP_CALLS = {"_get": "GET", "_post": "POST", "_delete": "DELETE", "_put": "PUT", "_patch": "PATCH", "_get_api_list": "GET"} +PARAMETER = re.compile(r"\{[^}]*\}") + + +def modules(): + for name in RESOURCE_PACKAGES: + package = importlib.import_module(name) + yield package + if hasattr(package, "__path__"): + for info in pkgutil.walk_packages(package.__path__, name + "."): + yield importlib.import_module(info.name) + + +def literal_path(node): + if isinstance(node, ast.Call) and getattr(node.func, "id", None) == "path_template": + node = node.args[0] + if isinstance(node, ast.Constant) and isinstance(node.value, str): + return PARAMETER.sub("{}", node.value) + if isinstance(node, ast.JoinedStr): + return "".join(part.value if isinstance(part, ast.Constant) else "{}" for part in node.values) + raise ValueError(f"unsupported path expression {ast.unparse(node)}") + + +def evaluate(node, namespace): + return eval(compile(ast.Expression(node), "", "eval"), namespace) # SDK-owned names only + + +def is_model(value): + return inspect.isclass(value) and (issubclass(value, pydantic.BaseModel) or typing_extensions.is_typeddict(value)) + + +def type_id(value): + generic = getattr(value, "__pydantic_generic_metadata__", None) or {} + if generic.get("origin") is not None: + items = sorted(type_id(item) for argument in generic["args"] for item in references(argument)) + return f"{type_id(generic['origin'])}[{' | '.join(items)}]" + module = value.__module__.removeprefix("openai.").removeprefix("types.") + return f"{module}.{value.__qualname__}" + + +def references(annotation): + """Return nested model and TypedDict classes, unwrapping aliases and containers.""" + if is_model(annotation): + return [annotation] + origin = typing_extensions.get_origin(annotation) + if origin is None or origin in (typing.Literal, typing_extensions.Literal): + return [] + found = [] + arguments = typing_extensions.get_args(annotation) + if origin in (typing.Annotated, typing_extensions.Annotated): + arguments = arguments[:1] + for argument in arguments: + found.extend(value for value in references(argument) if value not in found) + return found + + +def alias(annotation, name): + if typing_extensions.get_origin(annotation) in (typing.Annotated, typing_extensions.Annotated): + for metadata in typing_extensions.get_args(annotation)[1:]: + if isinstance(metadata, PropertyInfo) and metadata.alias: + return metadata.alias + return name + + +def fields(value): + if typing_extensions.is_typeddict(value): + hints = typing_extensions.get_type_hints(value, include_extras=True) + return {alias(annotation, name): annotation for name, annotation in hints.items()} + return {field.alias or name: field.annotation for name, field in value.model_fields.items()} + + +def request_types(call, namespace): + body = next((keyword.value for keyword in call.keywords if keyword.arg == "body"), None) + found = [] + for node in ast.walk(body) if body is not None else []: + if isinstance(node, ast.Call) and getattr(node.func, "id", "").endswith("maybe_transform") and len(node.args) == 2: + for candidate in ast.walk(node.args[1]): + if isinstance(candidate, ast.Attribute): + found.extend(value for value in references(evaluate(candidate, namespace)) if value not in found) + return found + + +def response_types(call, namespace): + for keyword in call.keywords: + if keyword.arg == "page": + return [evaluate(keyword.value, namespace)] + if keyword.arg == "cast_to": + node = keyword.value + if isinstance(node, ast.Call) and getattr(node.func, "id", None) == "cast": + node = node.args[1] + return references(evaluate(node, namespace)) + return [] + + +def operations(): + found = {} + for module in modules(): + tree = ast.parse(inspect.getsource(module)) + for resource in tree.body: + if not isinstance(resource, ast.ClassDef) or not any(getattr(base, "id", None) == "SyncAPIResource" for base in resource.bases): + continue + for call in ast.walk(resource): + if not (isinstance(call, ast.Call) and isinstance(call.func, ast.Attribute) and call.func.attr in HTTP_CALLS + and getattr(call.func.value, "id", None) == "self"): + continue + route = f"{HTTP_CALLS[call.func.attr]} {literal_path(call.args[0])}" + entry = found.setdefault(route, {"request": [], "response": []}) + for key, values in (("request", request_types(call, vars(module))), ("response", response_types(call, vars(module)))): + entry[key].extend(value for value in values if value not in entry[key]) + return found + + +def main(): + if len(sys.argv) != 3: + sys.exit("usage: extract-agents-api-upstream.py UPSTREAM_JSON OUTPUT_DIRECTORY") + pin = json.loads(Path(sys.argv[1]).read_text(encoding="utf-8")) + if openai.__version__ != pin["sdk_version"]: + sys.exit(f"installed openai {openai.__version__} does not match pinned {pin['sdk_version']}") + found = operations() + header = {"sdk_version": pin["sdk_version"], "commit": pin["commit"], "generator": "scripts/extract-agents-api-upstream.py"} + types, pending = {}, [value for entry in found.values() for key in ("request", "response") for value in entry[key]] + while pending: + value = pending.pop() + if type_id(value) in types: + continue + nested = {} + for name, annotation in fields(value).items(): + children = references(annotation) + nested[name] = sorted(type_id(child) for child in children) + pending.extend(children) + types[type_id(value)] = dict(sorted(nested.items())) + output = Path(sys.argv[2]) + routes = {**header, "resources": list(RESOURCE_PACKAGES), "routes": sorted(found)} + (output / "upstream-routes.json").write_text(json.dumps(routes, indent=2) + "\n", encoding="utf-8") + operation_types = {route: {key: sorted(type_id(value) for value in entry[key]) for key in ("request", "response")} for route, entry in sorted(found.items())} + document = {**header, "operations": operation_types, "types": dict(sorted(types.items()))} + (output / "upstream-fields.json").write_text(json.dumps(document, indent=2) + "\n", encoding="utf-8") + + +if __name__ == "__main__": + main() diff --git a/services/agents-api/HOSTED-SANDBOX-MANAGER.md b/services/agents-api/HOSTED-SANDBOX-MANAGER.md index 32c268aef..c8ff6e899 100644 --- a/services/agents-api/HOSTED-SANDBOX-MANAGER.md +++ b/services/agents-api/HOSTED-SANDBOX-MANAGER.md @@ -220,18 +220,10 @@ identity. The enrollment token is not the node credential. ## Placement and recovery -Session creation chooses an available node automatically. The advanced node -selector requests a particular node; unavailable or full selection fails rather -than silently falling back. API callers can use the explicit Core extension: - -```json -"x_agents_core": {"sandbox_node_id": "NODE_UUID"} -``` - -Placement commits with Session creation and remains fixed across creation -retries, later Turns and resume. Session details show that placement. The narrow -project-authenticated node directory and Session placement endpoint do not grant -administration privileges or expose other tenants' resources. +Session creation chooses an available node automatically; callers cannot select +one. Placement commits with Session creation and remains fixed across creation +retries, later Turns and resume. Administrators see each node's allocations under +`/core/v1/sandbox/nodes`. Microsandbox suspends only after a Turn has finished, no work is pending, and the idle interval has passed. Core measures terminal activity from its database's diff --git a/services/agents-api/README.md b/services/agents-api/README.md index 573585443..bb55bfa8d 100644 --- a/services/agents-api/README.md +++ b/services/agents-api/README.md @@ -208,8 +208,6 @@ The SDK base URL is `http://127.0.0.1:8091/v1`. Requests require a bearer key. Agents and Vault routes also require `OpenAI-Beta: agents=v1` (set by their SDK resources); general Files routes do not. Supported operations include: -- Safe read-only [Core startup configuration](../../contracts/agents-api/startup-configuration.md) - for build support and process selections, without Runtime or Session observations. - Saved Agent create/retrieve/update/list/delete. - Session create/retrieve/list/delete and metadata-only update. Creation supports inline configuration or a saved `agent_id`, field replacements, initial text diff --git a/services/agents-api/internal/api/admin_resources.go b/services/agents-api/internal/api/admin_resources.go index 112f99407..bda34644c 100644 --- a/services/agents-api/internal/api/admin_resources.go +++ b/services/agents-api/internal/api/admin_resources.go @@ -106,23 +106,24 @@ func (h *Handler) registerAdminResourceRoutes(router chi.Router) { } // @Summary Retrieve deployment startup metadata -// @Description Deployment administrator only. Returns the same non-secret configuration metadata as the project extension. +// @Description Deployment administrator only. Returns a secret-free snapshot of supported build capabilities and validated process startup selections. It does not inspect or aggregate daemon heartbeats, Sessions, Environments or Runtime state, and does not prove model-provider reachability, credentials, native readiness, sandbox isolation or successful execution. // @Tags Core Administration // @Produce json // @Security DeploymentAdminAuth // @Success 200 {object} v1.CoreStartupConfiguration -// @Failure 401,503 {object} v1.ErrorResponse +// @Failure 400,401,503 {object} v1.ErrorResponse // @Router /core/v1/admin/startup-configuration [get] func (h *Handler) adminStartupConfiguration(w http.ResponseWriter, r *http.Request) { h.getStartupConfiguration(w, r) } // @Summary Retrieve durable Runtime history capabilities +// @Description Deployment administrator only. Advertises only safe backend-neutral durable history availability and bounds. Available is true only when a query Reader and qualified periodic collection are both configured. // @Tags Core Administration // @Produce json // @Security DeploymentAdminAuth // @Success 200 {object} v1.RuntimeHistoryCapabilities -// @Failure 401 {object} v1.ErrorResponse +// @Failure 400,401 {object} v1.ErrorResponse // @Router /core/v1/admin/runtime-history/capabilities [get] func (h *Handler) adminRuntimeHistoryCapabilities(w http.ResponseWriter, r *http.Request) { h.getRuntimeHistoryCapabilities(w, r) diff --git a/services/agents-api/internal/api/admin_resources_test.go b/services/agents-api/internal/api/admin_resources_test.go index 3b40a7de1..5170760f0 100644 --- a/services/agents-api/internal/api/admin_resources_test.go +++ b/services/agents-api/internal/api/admin_resources_test.go @@ -36,6 +36,30 @@ func (s *adminProjectFixture) ResolveProjectAPIKey(_ context.Context, _ string) return store.ProjectAPIKeyBinding{}, store.ErrNotFound } +// adminTestHandler serves the administrator routes, authenticated by "Bearer +// admin", for managementProjectID over the same recording store as testHandler. +// It returns that Project's tenant. +func adminTestHandler(t *testing.T, options ...Option) (http.Handler, *recordingStore, string) { + t.Helper() + key := callerBinding() + auth, err := NewAuthenticator([]APIKey{key}) + if err != nil { + t.Fatal(err) + } + admin, err := NewDeploymentAuthenticator([]string{device.HashCredential("admin")}) + if err != nil { + t.Fatal(err) + } + s := &recordingStore{} + h, err := NewHandler(s, auth, "codex", append([]Option{WithProjectAPIKeys(managementProjectStore(key), admin)}, options...)...) + if err != nil { + t.Fatal(err) + } + return h, s, key.TenantID +} + +const adminSessionsPath = "/core/v1/admin/projects/" + managementProjectID + "/sessions/" + type adminReadFixture struct { ResourceStore seenTenant string diff --git a/services/agents-api/internal/api/admin_sessions_routes.go b/services/agents-api/internal/api/admin_sessions_routes.go index 65d5838da..0ea1cfcdf 100644 --- a/services/agents-api/internal/api/admin_sessions_routes.go +++ b/services/agents-api/internal/api/admin_sessions_routes.go @@ -160,7 +160,7 @@ func (h *Handler) adminSessionArtifactContent(w http.ResponseWriter, r *http.Req } // @Summary Retrieve frozen Session execution configuration in a managed key space -// @Description Deployment administrator only. Reuses the public resource projection and operation rules; the key selects the target space and does not authenticate. +// @Description Deployment administrator only; the Project ID selects the target space and does not authenticate. Returns the committed model, harness and safe provider selection with recorded sources. This read never decrypts credentials, resolves current defaults or probes execution health. Deployment provider details remain redacted. Historical provenance and missing provider projections are explicitly unknown/unavailable. // @Tags Core extensions // @Produce json // @Security DeploymentAdminAuth @@ -174,7 +174,7 @@ func (h *Handler) adminGetSessionExecutionConfiguration(w http.ResponseWriter, r } // @Summary Retrieve a Session Runtime observation in a managed key space -// @Description Deployment administrator only. Reuses the public resource projection and operation rules; the key selects the target space and does not authenticate. +// @Description Deployment administrator only; the Project ID selects the target space and does not authenticate. Returns one read-only current Runtime observation. It never provisions, renews, restarts, pauses or stops compute. // @Tags Runtime observations // @Produce json // @Security DeploymentAdminAuth @@ -188,7 +188,7 @@ func (h *Handler) adminGetRuntimeObservation(w http.ResponseWriter, r *http.Requ } // @Summary Retrieve Session Runtime history in a managed key space -// @Description Deployment administrator only. Reuses the public resource projection and operation rules; the key selects the target space and does not authenticate. +// @Description Deployment administrator only; the Project ID selects the target space and does not authenticate. Returns stored Runtime observations for one Session. End is exclusive; the server selects a bounded resolution. Responses contain at most 1,000 series, 10,000 points per coverage/series array, and 100,000 total coverage plus series points. It never reads or changes live compute. // @Tags Runtime history // @Produce json // @Security DeploymentAdminAuth diff --git a/services/agents-api/internal/api/handler.go b/services/agents-api/internal/api/handler.go index 6a6c9f5b1..db6b59f36 100644 --- a/services/agents-api/internal/api/handler.go +++ b/services/agents-api/internal/api/handler.go @@ -113,7 +113,6 @@ func (h *Handler) routes() *chi.Mux { h.registerEnvironmentExecutorRoutes(router) router.Route("/v1", func(r chi.Router) { r.Use(h.authenticate) - h.registerSandboxProjectRoutes(r) r.Post("/vaults", h.createVault) r.Get("/vaults", h.listVaults) r.Get("/vaults/{vault_id}", h.getVault) @@ -128,7 +127,6 @@ func (h *Handler) routes() *chi.Mux { r.Get("/agents/{agent_id}", h.getAgent) r.Post("/agents/{agent_id}", h.updateAgent) r.Delete("/agents/{agent_id}", h.deleteAgent) - r.Get("/agents/core/startup-configuration", h.getStartupConfiguration) r.Post("/agents/environments/templates", h.createEnvironmentTemplate) r.Get("/agents/environments/templates", h.listEnvironmentTemplates) r.Get("/agents/environments/templates/{environment_template_id}", h.getEnvironmentTemplate) @@ -141,14 +139,6 @@ func (h *Handler) routes() *chi.Mux { r.Post("/agents/sessions", h.createSession) r.Get("/agents/sessions", h.listSessions) r.Get("/agents/sessions/{session_id}", h.getSession) - r.Get("/agents/sessions/{session_id}/execution-configuration", h.getSessionExecutionConfiguration) - r.Get("/agents/sessions/{session_id}/runtime-observation", h.getRuntimeObservation) - r.Head("/agents/sessions/{session_id}/runtime-observation", methodNotAllowed) - r.Get("/agents/runtime-observations", h.listRuntimeObservations) - r.Head("/agents/runtime-observations", methodNotAllowed) - r.Get("/agents/runtime-history/capabilities", h.getRuntimeHistoryCapabilities) - r.Get("/agents/sessions/{session_id}/runtime-history", h.getRuntimeHistory) - r.Head("/agents/sessions/{session_id}/runtime-history", methodNotAllowed) r.Post("/agents/sessions/{session_id}", h.updateSession) r.Delete("/agents/sessions/{session_id}", h.deleteSession) r.Post("/agents/sessions/{session_id}/events", h.createEvents) @@ -292,9 +282,6 @@ func (h *Handler) createSession(w http.ResponseWriter, r *http.Request) { Creator: sessionCreator(r), InitialFiles: input.initialFiles, Initialization: input.initialization, Engine: selectedEngine, IdempotencyKey: key, Metadata: input.Metadata, Configuration: configuration, InitialInputs: initialInputs, CreationRequest: creationRequest, } - if input.XAgentsCore != nil && input.XAgentsCore.SandboxNodeID != nil { - createInput.SandboxNodeID = *input.XAgentsCore.SandboxNodeID - } if input.Stream { h.createSessionStream(w, r, createInput) return diff --git a/services/agents-api/internal/api/routing_test.go b/services/agents-api/internal/api/routing_test.go index 05a162b1c..64eafb3c4 100644 --- a/services/agents-api/internal/api/routing_test.go +++ b/services/agents-api/internal/api/routing_test.go @@ -554,10 +554,6 @@ func TestHeadRequests(t *testing.T) { for _, test := range []struct{ path, allow string }{ {session + "/events", "GET,POST"}, {"/v1/agents/environments/" + uuid.NewString() + "/files", "GET,POST"}, - {session + "/runtime-observation", "GET"}, - // POST and DELETE on this path route to Agent update and deletion. - {"/v1/agents/runtime-observations", "GET,POST,DELETE"}, - {session + "/runtime-history", "GET"}, {session + "/artifacts/" + uuid.NewString() + "/content", "GET"}, {"/v1/files/file-missing/content", "GET"}, {"/v1/skills/skill-missing/content", "GET"}, @@ -573,6 +569,24 @@ func TestHeadRequests(t *testing.T) { } } +// Retired Core extensions are unknown /v1 paths; the administrator reads stay +// under /core/v1. +func TestRetiredV1ExtensionsAreNotFound(t *testing.T) { + handler, _, _ := routingFixture(t) + session := "/v1/agents/sessions/" + uuid.NewString() + unknown := serve(handler, http.MethodGet, session+"/unknown", "", withHeaders(project, beta)) + for _, path := range []string{"/v1/agents/core/startup-configuration", "/v1/agents/runtime-history/capabilities", + session + "/runtime-observation", session + "/runtime-history", session + "/execution-configuration", session + "/sandbox-placement", "/v1/sandbox/nodes"} { + if got := serve(handler, http.MethodGet, path, "", withHeaders(project, beta)); got.Code != http.StatusNotFound || got.Body.String() != unknown.Body.String() { + t.Errorf("GET %s = %d %s", path, got.Code, got.Body) + } + } + // This spelling is an Agent ID now. + if got := serve(handler, http.MethodGet, "/v1/agents/runtime-observations", "", withHeaders(project, beta)); got.Code != http.StatusNotFound { + t.Errorf("GET /v1/agents/runtime-observations = %d %s", got.Code, got.Body) + } +} + // Every response carries a fresh request ID and the observed OpenAI headers // (HP-23/HP-24), next to Core's traceparent and Cache-Control extensions. func TestAgentsResponseHeaders(t *testing.T) { diff --git a/services/agents-api/internal/api/runtime_history.go b/services/agents-api/internal/api/runtime_history.go index e21caa5a4..6ab5e1bc1 100644 --- a/services/agents-api/internal/api/runtime_history.go +++ b/services/agents-api/internal/api/runtime_history.go @@ -27,15 +27,7 @@ func WithRuntimeHistory(service RuntimeHistoryService) Option { return func(h *Handler) { h.runtimeHistory = service } } -// @Summary Retrieve Runtime history capabilities -// @Description Core extension advertising only safe backend-neutral Durable history availability and bounds. Available is true only when a query Reader and qualified periodic collection are both configured. -// @Tags Runtime history -// @Produce json -// @Security BearerAuth -// @Param OpenAI-Beta header string true "agents=v1" -// @Success 200 {object} v1.RuntimeHistoryCapabilities -// @Failure 400,401 {object} v1.ErrorResponse -// @Router /agents/runtime-history/capabilities [get] +// getRuntimeHistoryCapabilities serves the administrator capability read. func (h *Handler) getRuntimeHistoryCapabilities(w http.ResponseWriter, r *http.Request) { if len(r.URL.Query()) != 0 { writeError(w, http.StatusBadRequest, "unsupported_parameter", "Runtime history capabilities do not accept query parameters.") @@ -44,19 +36,7 @@ func (h *Handler) getRuntimeHistoryCapabilities(w http.ResponseWriter, r *http.R writeJSON(w, http.StatusOK, runtimeHistoryCapabilitiesResponse(h.runtimeHistory)) } -// @Summary Retrieve Session Runtime history -// @Description Core extension returning tenant-scoped stored Runtime observations for one Session. End is exclusive; the server selects a bounded resolution. Responses contain at most 1,000 series, 10,000 points per coverage/series array, and 100,000 total coverage plus series points. It never reads or changes live compute. -// @Tags Runtime history -// @Produce json -// @Security BearerAuth -// @Param OpenAI-Beta header string true "agents=v1" -// @Param session_id path string true "Session ID" -// @Param start query integer true "Inclusive Unix-second start" minimum(0) maximum(9007199254740991) -// @Param end query integer true "Exclusive Unix-second end" minimum(1) maximum(9007199254740991) -// @Param max_points query integer false "Maximum points per series; defaults to the lower of 120 and the advertised service maximum" minimum(2) maximum(10000) -// @Success 200 {object} v1.RuntimeHistory -// @Failure 400,401,404,409,503 {object} v1.ErrorResponse -// @Router /agents/sessions/{session_id}/runtime-history [get] +// getRuntimeHistory serves the administrator per-Session history read. func (h *Handler) getRuntimeHistory(w http.ResponseWriter, r *http.Request) { if h.runtimeHistory == nil { writeError(w, http.StatusServiceUnavailable, "runtime_history_unavailable", "Durable Runtime history is not configured on this service.") diff --git a/services/agents-api/internal/api/runtime_history_test.go b/services/agents-api/internal/api/runtime_history_test.go index 7369552ce..c2007cfa9 100644 --- a/services/agents-api/internal/api/runtime_history_test.go +++ b/services/agents-api/internal/api/runtime_history_test.go @@ -45,8 +45,8 @@ func historyCapabilities(mode runtimehistory.CollectionMode) runtimehistory.Capa } func TestRuntimeHistoryCapabilitiesAreSafeAndDisabledByDefault(t *testing.T) { - handler, _, _ := testHandler(t) - response := runtimeObservationRequest(handler, "/v1/agents/runtime-history/capabilities") + handler, _, _ := adminTestHandler(t) + response := runtimeObservationRequest(handler, "/core/v1/admin/runtime-history/capabilities") if response.Code != http.StatusOK { t.Fatalf("capabilities returned %d: %s", response.Code, response.Body) } @@ -54,7 +54,7 @@ func TestRuntimeHistoryCapabilitiesAreSafeAndDisabledByDefault(t *testing.T) { if json.Unmarshal(response.Body.Bytes(), &value) != nil || value.Object != "agent.runtime_history_capabilities" || value.Available || value.Reason == nil || *value.Reason != "not_configured" || value.CollectionMode != nil || value.SampleIntervalSeconds != nil || value.RetentionSeconds != nil || value.MaximumPoints != nil || value.Metrics == nil || len(value.Metrics) != 0 { t.Fatalf("unsafe disabled capabilities: %s", response.Body) } - invalid := runtimeObservationRequest(handler, "/v1/agents/runtime-history/capabilities?backend=clickhouse") + invalid := runtimeObservationRequest(handler, "/core/v1/admin/runtime-history/capabilities?backend=clickhouse") if invalid.Code != http.StatusBadRequest { t.Fatalf("capability query was accepted: %d %s", invalid.Code, invalid.Body) } @@ -62,13 +62,13 @@ func TestRuntimeHistoryCapabilitiesAreSafeAndDisabledByDefault(t *testing.T) { func TestRuntimeHistoryRequiresQualifiedPeriodicCollection(t *testing.T) { service := &runtimeHistoryFixture{capabilities: historyCapabilities(runtimehistory.CollectionOnRead)} - handler, _, _ := testHandler(t, WithRuntimeHistory(service)) - capabilityResponse := runtimeObservationRequest(handler, "/v1/agents/runtime-history/capabilities") + handler, _, _ := adminTestHandler(t, WithRuntimeHistory(service)) + capabilityResponse := runtimeObservationRequest(handler, "/core/v1/admin/runtime-history/capabilities") var capabilities v1.RuntimeHistoryCapabilities if capabilityResponse.Code != http.StatusOK || json.Unmarshal(capabilityResponse.Body.Bytes(), &capabilities) != nil || capabilities.Available || capabilities.Reason == nil || *capabilities.Reason != "periodic_collection_required" || capabilities.CollectionMode == nil || *capabilities.CollectionMode != "on_read" || capabilities.SampleIntervalSeconds != nil { t.Fatalf("on-read capability was advertised as durable: %d %s", capabilityResponse.Code, capabilityResponse.Body) } - response := runtimeObservationRequest(handler, "/v1/agents/sessions/"+uuid.NewString()+"/runtime-history?start=1&end=2") + response := runtimeObservationRequest(handler, adminSessionsPath+uuid.NewString()+"/runtime-history?start=1&end=2") if response.Code != http.StatusServiceUnavailable || service.calls != 0 { t.Fatalf("on-read history reached query service: %d calls=%d body=%s", response.Code, service.calls, response.Body) } @@ -77,13 +77,13 @@ func TestRuntimeHistoryRequiresQualifiedPeriodicCollection(t *testing.T) { func TestRuntimeHistoryFailsClosedForMalformedCapabilities(t *testing.T) { service := &runtimeHistoryFixture{capabilities: historyCapabilities(runtimehistory.CollectionPeriodic)} service.capabilities.Retention = 0 - handler, _, _ := testHandler(t, WithRuntimeHistory(service)) - capabilityResponse := runtimeObservationRequest(handler, "/v1/agents/runtime-history/capabilities") + handler, _, _ := adminTestHandler(t, WithRuntimeHistory(service)) + capabilityResponse := runtimeObservationRequest(handler, "/core/v1/admin/runtime-history/capabilities") var capabilities v1.RuntimeHistoryCapabilities if capabilityResponse.Code != http.StatusOK || json.Unmarshal(capabilityResponse.Body.Bytes(), &capabilities) != nil || capabilities.Available || capabilities.Reason == nil || *capabilities.Reason != "not_configured" || capabilities.CollectionMode != nil || len(capabilities.Metrics) != 0 { t.Fatalf("malformed capabilities did not fail closed: %d %s", capabilityResponse.Code, capabilityResponse.Body) } - response := runtimeObservationRequest(handler, "/v1/agents/sessions/"+uuid.NewString()+"/runtime-history?start=1&end=2") + response := runtimeObservationRequest(handler, adminSessionsPath+uuid.NewString()+"/runtime-history?start=1&end=2") if response.Code != http.StatusServiceUnavailable || service.calls != 0 { t.Fatalf("malformed capabilities reached query service: %d calls=%d body=%s", response.Code, service.calls, response.Body) } @@ -108,7 +108,7 @@ func TestRuntimeHistoryRouteBindsAuthenticatedSessionAndPreservesCoverage(t *tes CPUUtilizationRatio: &zeroRatio, CPUCapacityCores: &capacity, MemoryUsageBytes: &zeroMemory, MemoryLimitBytes: &limit, } service := &runtimeHistoryFixture{capabilities: historyCapabilities(runtimehistory.CollectionPeriodic)} - handler, _, tenant := testHandler(t, WithRuntimeHistory(service)) + handler, _, tenant := adminTestHandler(t, WithRuntimeHistory(service)) scope.TenantID = tenant service.response = runtimehistory.Response{ Capabilities: service.capabilities, Scope: scope, @@ -117,7 +117,7 @@ func TestRuntimeHistoryRouteBindsAuthenticatedSessionAndPreservesCoverage(t *tes Series: []runtimehistory.Series{{Scope: scope, AllocationID: allocationID, StartedAt: start.Add(-time.Minute), ProviderType: "docker", Points: []runtimehistory.Point{resourcePoint}}}, TokenUsage: []runtimehistory.TokenUsagePoint{{Start: start, End: start.Add(time.Minute), SampledAt: start.Add(10 * time.Second), InputTokens: 120, OutputTokens: 30}}, } - response := runtimeObservationRequest(handler, "/v1/agents/sessions/"+sessionID+"/runtime-history?start="+timeString(start)+"&end="+timeString(now)+"&max_points=60") + response := runtimeObservationRequest(handler, adminSessionsPath+sessionID+"/runtime-history?start="+timeString(start)+"&end="+timeString(now)+"&max_points=60") if response.Code != http.StatusOK { t.Fatalf("history returned %d: %s", response.Code, response.Body) } @@ -136,12 +136,12 @@ func TestRuntimeHistoryRouteBindsAuthenticatedSessionAndPreservesCoverage(t *tes func TestRuntimeHistoryRejectsUnsafeQueriesAndFailures(t *testing.T) { service := &runtimeHistoryFixture{capabilities: historyCapabilities(runtimehistory.CollectionPeriodic)} - handler, _, _ := testHandler(t, WithRuntimeHistory(service)) + handler, _, _ := adminTestHandler(t, WithRuntimeHistory(service)) sessionID := uuid.NewString() for _, query := range []string{ "", "?start=1", "?start=2&end=1", "?start=x&end=2", "?start=1&end=2&provider=docker", "?start=1&start=1&end=2", "?start=1&end=2&max_points=x", "?start=1&end=2&max_points=1", "?start=1&end=2&max_points=1001", "?start=1&end=90002", "?start=1&end=9223372036854775807", } { - response := runtimeObservationRequest(handler, "/v1/agents/sessions/"+sessionID+"/runtime-history"+query) + response := runtimeObservationRequest(handler, adminSessionsPath+sessionID+"/runtime-history"+query) if response.Code != http.StatusBadRequest { t.Fatalf("unsafe query %q returned %d: %s", query, response.Code, response.Body) } @@ -151,7 +151,7 @@ func TestRuntimeHistoryRejectsUnsafeQueriesAndFailures(t *testing.T) { } now := time.Now().UTC().Truncate(time.Second) - path := "/v1/agents/sessions/" + sessionID + "/runtime-history?start=" + timeString(now.Add(-time.Hour)) + "&end=" + timeString(now) + path := adminSessionsPath + sessionID + "/runtime-history?start=" + timeString(now.Add(-time.Hour)) + "&end=" + timeString(now) for _, tc := range []struct { err error code int @@ -181,7 +181,7 @@ func TestRuntimeHistoryRejectsMismatchedServiceResponses(t *testing.T) { start := now.Add(-time.Hour) sessionID := uuid.NewString() service := &runtimeHistoryFixture{capabilities: historyCapabilities(runtimehistory.CollectionPeriodic)} - handler, _, tenant := testHandler(t, WithRuntimeHistory(service)) + handler, _, tenant := adminTestHandler(t, WithRuntimeHistory(service)) base := runtimehistory.Response{ Capabilities: service.capabilities, Scope: runtimehistory.Scope{ @@ -190,7 +190,7 @@ func TestRuntimeHistoryRejectsMismatchedServiceResponses(t *testing.T) { Requested: runtimehistory.Range{Start: start, End: now, MaxPoints: 60}, Resolution: time.Minute, GeneratedAt: now, } - path := "/v1/agents/sessions/" + sessionID + "/runtime-history?start=" + timeString(start) + "&end=" + timeString(now) + "&max_points=60" + path := adminSessionsPath + sessionID + "/runtime-history?start=" + timeString(start) + "&end=" + timeString(now) + "&max_points=60" for name, mutate := range map[string]func(*runtimehistory.Response){ "tenant": func(value *runtimehistory.Response) { value.TenantID = uuid.NewString() }, @@ -214,9 +214,9 @@ func TestRuntimeHistoryDefaultPointBudgetRespectsCapabilities(t *testing.T) { capabilities := historyCapabilities(runtimehistory.CollectionPeriodic) capabilities.MaximumPoints = 60 service := &runtimeHistoryFixture{capabilities: capabilities, err: runtimehistory.ErrUnavailable} - handler, _, _ := testHandler(t, WithRuntimeHistory(service)) + handler, _, _ := adminTestHandler(t, WithRuntimeHistory(service)) now := time.Now().UTC().Truncate(time.Second) - response := runtimeObservationRequest(handler, "/v1/agents/sessions/"+uuid.NewString()+"/runtime-history?start="+timeString(now.Add(-time.Hour))+"&end="+timeString(now)) + response := runtimeObservationRequest(handler, adminSessionsPath+uuid.NewString()+"/runtime-history?start="+timeString(now.Add(-time.Hour))+"&end="+timeString(now)) if response.Code != http.StatusServiceUnavailable || service.calls != 1 || service.requested.MaxPoints != 60 { t.Fatalf("default point budget ignored capabilities: status=%d calls=%d requested=%+v", response.Code, service.calls, service.requested) } diff --git a/services/agents-api/internal/api/runtime_observations.go b/services/agents-api/internal/api/runtime_observations.go index 51b0f7bb6..53fe0086d 100644 --- a/services/agents-api/internal/api/runtime_observations.go +++ b/services/agents-api/internal/api/runtime_observations.go @@ -25,7 +25,7 @@ type RuntimeObservationService interface { ObserveSessions(context.Context, []runtimeobs.SessionIdentity, runtimeobs.PageOptions) ([]runtimeobs.Observation, []error) } -// runtimeObservationPage is the bounded fan-out shared by both list routes. +// runtimeObservationPage bounds the administrator list fan-out. var runtimeObservationPage = runtimeobs.PageOptions{Concurrency: runtimeObservationConcurrency, SourceTimeout: runtimeObservationSourceBudget} func firstRuntimeObservationError(errs []error) error { @@ -41,16 +41,7 @@ func WithRuntimeObservations(service RuntimeObservationService) Option { return func(h *Handler) { h.runtimeObservations = service } } -// @Summary Retrieve a Session Runtime observation -// @Description Core extension returning one tenant-scoped, read-only current Runtime observation. It never provisions, renews, restarts, pauses or stops compute. -// @Tags Runtime observations -// @Produce json -// @Security BearerAuth -// @Param OpenAI-Beta header string true "agents=v1" -// @Param session_id path string true "Session ID" -// @Success 200 {object} v1.RuntimeObservation -// @Failure 400,401,404,500,503 {object} v1.ErrorResponse -// @Router /agents/sessions/{session_id}/runtime-observation [get] +// getRuntimeObservation serves the administrator per-Session observation read. func (h *Handler) getRuntimeObservation(w http.ResponseWriter, r *http.Request) { if len(r.URL.Query()) != 0 { writeError(w, http.StatusBadRequest, "unsupported_parameter", "Runtime observation retrieval does not accept query parameters.") @@ -75,63 +66,6 @@ func (h *Handler) getRuntimeObservation(w http.ResponseWriter, r *http.Request) writeJSON(w, http.StatusOK, response) } -// @Summary List current Runtime observations -// @Description Core extension listing one current Runtime context per tenant-owned Session in Session creation order. Each row has an independent resolved_at and optional provider observed_at; the page is not an atomic telemetry snapshot. -// @Tags Runtime observations -// @Produce json -// @Security BearerAuth -// @Param OpenAI-Beta header string true "agents=v1" -// @Param after query string false "Last observation ID from the previous page" -// @Param limit query int false "Page size" minimum(1) maximum(100) default(20) -// @Param order query string false "Session creation order" Enums(asc,desc) default(desc) -// @Success 200 {object} v1.RuntimeObservationList -// @Failure 400,401,404,500,503 {object} v1.ErrorResponse -// @Router /agents/runtime-observations [get] -func (h *Handler) listRuntimeObservations(w http.ResponseWriter, r *http.Request) { - if h.runtimeObservations == nil { - writeError(w, http.StatusServiceUnavailable, "execution_unavailable", "Runtime observation is not configured on this service.") - return - } - options, ok := readPage(w, r) - if !ok { - return - } - ctx, cancel := context.WithTimeout(r.Context(), runtimeObservationRequestBudget) - defer cancel() - page, err := h.store.ListSessions(ctx, tenantID(r), options.after, options.limit, options.ascending, nil) - if err != nil { - writeStoreError(w, r, err) - return - } - sessions := make([]runtimeobs.SessionIdentity, len(page.Sessions)) - for index, session := range page.Sessions { - sessions[index] = runtimeobs.SessionIdentity{TenantID: tenantID(r), SessionID: session.ID} - } - observations, errs := h.runtimeObservations.ObserveSessions(ctx, sessions, runtimeObservationPage) - if err := ctx.Err(); err != nil { - writeError(w, http.StatusServiceUnavailable, "execution_unavailable", "Runtime observation collection exceeded its request budget.") - return - } - if err := firstRuntimeObservationError(errs); err != nil { - writeStoreError(w, r, err) - return - } - response := v1.RuntimeObservationList{Object: "list", Data: make([]v1.RuntimeObservation, 0, len(observations)), HasMore: page.NextCursor != ""} - for _, observation := range observations { - item, err := runtimeObservationResponse(observation) - if err != nil { - writeStoreError(w, r, err) - return - } - response.Data = append(response.Data, item) - } - if len(response.Data) > 0 { - response.FirstID = &response.Data[0].ID - response.LastID = &response.Data[len(response.Data)-1].ID - } - writeJSON(w, http.StatusOK, response) -} - func runtimeObservationResponse(observation runtimeobs.Observation) (v1.RuntimeObservation, error) { if observation.Target.SessionID == "" || observation.ResolvedAt.IsZero() || observation.ResolvedAt.Unix() < 0 { return v1.RuntimeObservation{}, errors.New("invalid Runtime observation identity") diff --git a/services/agents-api/internal/api/runtime_observations_test.go b/services/agents-api/internal/api/runtime_observations_test.go index 0e98efdad..9b0169067 100644 --- a/services/agents-api/internal/api/runtime_observations_test.go +++ b/services/agents-api/internal/api/runtime_observations_test.go @@ -3,7 +3,6 @@ package api import ( "context" "encoding/json" - "errors" "net/http" "net/http/httptest" "strings" @@ -12,7 +11,6 @@ import ( v1 "github.com/MiniMax-AI-Dev/parsar/contracts/agents-api/v1" "github.com/MiniMax-AI-Dev/parsar/services/agents-api/internal/runtimeobs" - "github.com/MiniMax-AI-Dev/parsar/services/agents-api/internal/store" "github.com/google/uuid" ) @@ -61,8 +59,7 @@ func (r runtimeObservationPageRecorder) ObserveSessions(ctx context.Context, ses func runtimeObservationRequest(handler http.Handler, path string) *httptest.ResponseRecorder { request := httptest.NewRequest(http.MethodGet, path, nil) - request.Header.Set("Authorization", "Bearer test-api-key") - request.Header.Set("OpenAI-Beta", "agents=v1") + request.Header.Set("Authorization", "Bearer admin") response := httptest.NewRecorder() handler.ServeHTTP(response, request) return response @@ -74,31 +71,21 @@ func TestRuntimeObservationRoutesUseSessionIdentityAndExactNullability(t *testin service := runtimeObservationFixture{values: map[string]runtimeobs.Observation{ sessionID: {Target: runtimeobs.Target{SessionID: sessionID, Mode: runtimeobs.ModeNone}, Status: runtimeobs.StatusUnsupported, Reason: "runtime_mode_not_observable", ResolvedAt: now}, }} - handler, saved, _ := testHandler(t, WithRuntimeObservations(service)) - saved.sessions = []store.Session{{ID: sessionID, CreatedAt: now}} + handler, _, _ := adminTestHandler(t, WithRuntimeObservations(service)) - for _, path := range []string{"/v1/agents/sessions/" + sessionID + "/runtime-observation", "/v1/agents/runtime-observations?limit=1"} { - response := runtimeObservationRequest(handler, path) - if response.Code != http.StatusOK { - t.Fatalf("%s returned %d: %s", path, response.Code, response.Body) - } - if path[len(path)-7:] == "limit=1" { - var page v1.RuntimeObservationList - if json.Unmarshal(response.Body.Bytes(), &page) != nil || len(page.Data) != 1 || page.FirstID == nil || *page.FirstID != sessionID || page.LastID == nil || *page.LastID != sessionID { - t.Fatalf("invalid observation page: %s", response.Body) - } - continue - } - var value v1.RuntimeObservation - if json.Unmarshal(response.Body.Bytes(), &value) != nil || value.ID != sessionID || value.SessionID != sessionID || value.Instance.Kind != "none" || value.EnvironmentID != nil || value.ProviderType != nil || value.ObservedAt != nil || value.CPU != nil || value.Memory != nil || value.Reason == nil || *value.Reason != "runtime_mode_not_observable" { - t.Fatalf("invalid unsupported observation: %s", response.Body) - } + response := runtimeObservationRequest(handler, adminSessionsPath+sessionID+"/runtime-observation") + if response.Code != http.StatusOK { + t.Fatalf("observation returned %d: %s", response.Code, response.Body) + } + var value v1.RuntimeObservation + if json.Unmarshal(response.Body.Bytes(), &value) != nil || value.ID != sessionID || value.SessionID != sessionID || value.Instance.Kind != "none" || value.EnvironmentID != nil || value.ProviderType != nil || value.ObservedAt != nil || value.CPU != nil || value.Memory != nil || value.Reason == nil || *value.Reason != "runtime_mode_not_observable" { + t.Fatalf("invalid unsupported observation: %s", response.Body) } } func TestRuntimeObservationRoutesRequireConfiguredServiceAndRejectQueries(t *testing.T) { - handler, _, _ := testHandler(t) - missing := runtimeObservationRequest(handler, "/v1/agents/runtime-observations") + handler, _, _ := adminTestHandler(t) + missing := runtimeObservationRequest(handler, adminSessionsPath+uuid.NewString()+"/runtime-observation") if missing.Code != http.StatusServiceUnavailable { t.Fatalf("unconfigured service returned %d: %s", missing.Code, missing.Body) } @@ -108,8 +95,8 @@ func TestRuntimeObservationRoutesRequireConfiguredServiceAndRejectQueries(t *tes service := runtimeObservationFixture{values: map[string]runtimeobs.Observation{ sessionID: {Target: runtimeobs.Target{SessionID: sessionID, Mode: runtimeobs.ModeNone}, Status: runtimeobs.StatusUnsupported, Reason: "runtime_mode_not_observable", ResolvedAt: now}, }} - handler, _, _ = testHandler(t, WithRuntimeObservations(service)) - invalid := runtimeObservationRequest(handler, "/v1/agents/sessions/"+sessionID+"/runtime-observation?provider=docker") + handler, _, _ = adminTestHandler(t, WithRuntimeObservations(service)) + invalid := runtimeObservationRequest(handler, adminSessionsPath+sessionID+"/runtime-observation?provider=docker") if invalid.Code != http.StatusBadRequest { t.Fatalf("unsupported query returned %d: %s", invalid.Code, invalid.Body) } @@ -182,94 +169,6 @@ func TestRuntimeObservationResponseRejectsTimesOutsidePublicContract(t *testing. func timePointer(value time.Time) *time.Time { return &value } -func TestRuntimeObservationListPreservesStoreOrderAndTenantPagination(t *testing.T) { - now := time.Date(2026, 9, 22, 8, 0, 0, 0, time.UTC) - sessionIDs := []string{uuid.NewString(), uuid.NewString(), uuid.NewString()} - var expectedTenant string - service := runtimeObservationServiceFunc(func(_ context.Context, tenant, session string) (runtimeobs.Observation, error) { - if tenant != expectedTenant { - return runtimeobs.Observation{}, errors.New("unexpected tenant") - } - if session == sessionIDs[0] { - time.Sleep(20 * time.Millisecond) - } - return runtimeobs.Observation{ - Target: runtimeobs.Target{SessionID: session, Mode: runtimeobs.ModeNone}, - Status: runtimeobs.StatusUnsupported, Reason: "runtime_mode_not_observable", ResolvedAt: now, - }, nil - }) - handler, saved, tenant := testHandler(t, WithRuntimeObservations(service)) - expectedTenant = tenant - for _, id := range sessionIDs { - saved.sessions = append(saved.sessions, store.Session{ID: id, CreatedAt: now}) - } - saved.nextSessionCursor = "next" - - response := runtimeObservationRequest(handler, "/v1/agents/runtime-observations?after=cursor&limit=3&order=asc") - if response.Code != http.StatusOK { - t.Fatalf("list returned %d: %s", response.Code, response.Body) - } - var page v1.RuntimeObservationList - if err := json.Unmarshal(response.Body.Bytes(), &page); err != nil { - t.Fatal(err) - } - if len(page.Data) != len(sessionIDs) || !page.HasMore || saved.listTenant != tenant || saved.listAfter != "cursor" || saved.listLimit != 3 || !saved.listAscending { - t.Fatalf("pagination binding was not preserved: page=%+v store=%+v", page, saved) - } - for index, item := range page.Data { - if item.ID != sessionIDs[index] { - t.Fatalf("concurrent collection reordered page: %+v", page.Data) - } - } -} - -func TestRuntimeObservationListBoundsCollectionConcurrency(t *testing.T) { - now := time.Date(2026, 9, 22, 8, 0, 0, 0, time.UTC) - var options runtimeobs.PageOptions - service := runtimeObservationPageRecorder{options: &options, runtimeObservationServiceFunc: func(_ context.Context, _, session string) (runtimeobs.Observation, error) { - return runtimeobs.Observation{ - Target: runtimeobs.Target{SessionID: session, Mode: runtimeobs.ModeNone}, - Status: runtimeobs.StatusUnsupported, Reason: "runtime_mode_not_observable", ResolvedAt: now, - }, nil - }} - handler, saved, _ := testHandler(t, WithRuntimeObservations(service)) - for range 20 { - saved.sessions = append(saved.sessions, store.Session{ID: uuid.NewString(), CreatedAt: now}) - } - response := runtimeObservationRequest(handler, "/v1/agents/runtime-observations?limit=20") - if response.Code != http.StatusOK { - t.Fatalf("list returned %d: %s", response.Code, response.Body) - } - if options.Concurrency != runtimeObservationConcurrency || options.SourceTimeout != runtimeObservationSourceBudget { - t.Fatalf("collection bounds = %+v", options) - } -} - -func TestRuntimeObservationListRejectsWholePageOnIntegrityFailure(t *testing.T) { - now := time.Date(2026, 9, 22, 8, 0, 0, 0, time.UTC) - validID, invalidID := uuid.NewString(), uuid.NewString() - service := runtimeObservationFixture{values: map[string]runtimeobs.Observation{ - validID: { - Target: runtimeobs.Target{SessionID: validID, Mode: runtimeobs.ModeNone}, - Status: runtimeobs.StatusUnsupported, Reason: "runtime_mode_not_observable", ResolvedAt: now, - }, - invalidID: {Target: runtimeobs.Target{Mode: runtimeobs.ModeNone}, Status: runtimeobs.StatusUnsupported, ResolvedAt: now}, - }} - handler, saved, _ := testHandler(t, WithRuntimeObservations(service)) - saved.sessions = []store.Session{{ID: validID, CreatedAt: now}, {ID: invalidID, CreatedAt: now}} - - response := runtimeObservationRequest(handler, "/v1/agents/runtime-observations?limit=2") - if response.Code != http.StatusInternalServerError { - t.Fatalf("integrity failure returned %d: %s", response.Code, response.Body) - } - var envelope struct { - Error map[string]json.RawMessage `json:"error"` - } - if err := json.Unmarshal(response.Body.Bytes(), &envelope); err != nil || len(envelope.Error) == 0 { - t.Fatalf("integrity failure leaked a partial page: %s", response.Body) - } -} - func TestAdminRuntimeObservationProjectsReportedUtilizationAndDisk(t *testing.T) { now := time.Date(2026, 9, 25, 10, 31, 37, 0, time.UTC) ratio, cores := .1955, 2.0 diff --git a/services/agents-api/internal/api/sandbox_placement.go b/services/agents-api/internal/api/sandbox_placement.go deleted file mode 100644 index 213c49a17..000000000 --- a/services/agents-api/internal/api/sandbox_placement.go +++ /dev/null @@ -1,73 +0,0 @@ -package api - -import ( - "net/http" - - "github.com/MiniMax-AI-Dev/parsar/services/agents-api/internal/store" - "github.com/go-chi/chi/v5" -) - -type SandboxNodeDirectoryEntry struct { - ID string `json:"id"` - Name string `json:"name"` - Available bool `json:"available"` -} -type SandboxNodeDirectory struct { - Data []SandboxNodeDirectoryEntry `json:"data"` -} -type SandboxSessionPlacement = store.RuntimePlacement - -// Project nodes expose only selector labels and availability, never other tenants' allocations. -func (h *Handler) registerSandboxProjectRoutes(r chi.Router) { - if h.sandboxStore == nil { - return - } - r.Get("/sandbox/nodes", h.sandboxNodeDirectory) - r.Get("/agents/sessions/{session_id}/sandbox-placement", h.sandboxSessionPlacement) -} - -// @Summary List hosted sandbox node selectors -// @Description Core extension. Returns only selector identity, label and admission availability for project clients. -// @Tags Core -// @Produce json -// @Security BearerAuth -// @Param OpenAI-Beta header string true "agents=v1" -// @Success 200 {object} api.SandboxNodeDirectory -// @Failure 401,500 {object} v1.ErrorResponse -// @Router /sandbox/nodes [get] -func (h *Handler) sandboxNodeDirectory(w http.ResponseWriter, r *http.Request) { - nodes, err := h.sandboxStore.ListRuntimeNodes(r.Context()) - if err != nil { - writeStoreError(w, r, err) - return - } - deployment, err := h.sandboxStore.GetRuntimeDeployment(r.Context()) - if err != nil { - writeStoreError(w, r, err) - return - } - result := make([]SandboxNodeDirectoryEntry, 0, len(nodes)) - for _, n := range nodes { - result = append(result, SandboxNodeDirectoryEntry{ID: n.ID, Name: n.Name, Available: n.Online && n.ProviderReady && !deployment.Maintenance && n.Active < int64(n.MaxActive) && n.Retained < int64(n.MaxRetained)}) - } - writeJSON(w, http.StatusOK, SandboxNodeDirectory{Data: result}) -} - -// @Summary Retrieve a Session sandbox placement -// @Description Core extension. Reads the caller-owned Session's fixed sandbox node, independently of node connectivity. -// @Tags Core -// @Produce json -// @Security BearerAuth -// @Param OpenAI-Beta header string true "agents=v1" -// @Param session_id path string true "Session ID" -// @Success 200 {object} api.SandboxSessionPlacement -// @Failure 401,404,500 {object} v1.ErrorResponse -// @Router /agents/sessions/{session_id}/sandbox-placement [get] -func (h *Handler) sandboxSessionPlacement(w http.ResponseWriter, r *http.Request) { - result, err := h.sandboxStore.GetSessionRuntimePlacement(r.Context(), tenantID(r), chi.URLParam(r, "session_id")) - if err != nil { - writeStoreError(w, r, err) - return - } - writeJSON(w, http.StatusOK, result) -} diff --git a/services/agents-api/internal/api/sandbox_selector_test.go b/services/agents-api/internal/api/sandbox_selector_test.go index f08227249..15af1f351 100644 --- a/services/agents-api/internal/api/sandbox_selector_test.go +++ b/services/agents-api/internal/api/sandbox_selector_test.go @@ -15,42 +15,31 @@ import ( type sandboxCreationRecorder struct { inputRecorder calls int - input store.CreateSessionInput } -func (r *sandboxCreationRecorder) CreateSession(_ context.Context, _ string, input store.CreateSessionInput) (store.Session, error) { +func (r *sandboxCreationRecorder) CreateSession(context.Context, string, store.CreateSessionInput) (store.Session, error) { r.calls++ - r.input = input return store.Session{}, store.ErrInvalidInput } -func TestSandboxSelectorUsesOnlyCoreSessionExtension(t *testing.T) { + +// Placement is automatic. A node selector is an unknown member wherever it appears. +func TestSessionCreationRejectsSandboxNodeSelector(t *testing.T) { node := uuid.NewString() - for _, tc := range []struct { - name, body string - accepted bool - }{ - {"selector only", fmt.Sprintf(`{"agent":{"model":"model"},"environment":{"type":"openai_hosted"},"x_agents_core":{"sandbox_node_id":%q}}`, node), true}, - {"empty selector", `{"agent":{"model":"model"},"environment":{"type":"openai_hosted"},"x_agents_core":{"sandbox_node_id":""}}`, false}, - {"non hosted", fmt.Sprintf(`{"agent":{"model":"model"},"environment":{"type":"none"},"input":"hello","x_agents_core":{"sandbox_node_id":%q}}`, node), false}, - {"official environment", fmt.Sprintf(`{"agent":{"model":"model"},"environment":{"type":"openai_hosted","sandbox_node_id":%q}}`, node), false}, - {"agent extension", fmt.Sprintf(`{"agent":{"model":"model","x_agents_core":{"sandbox_node_id":%q}},"environment":{"type":"openai_hosted"}}`, node), false}, + for _, body := range []string{ + fmt.Sprintf(`{"agent":{"model":"model"},"environment":{"type":"openai_hosted"},"x_agents_core":{"sandbox_node_id":%q}}`, node), + fmt.Sprintf(`{"agent":{"model":"model"},"environment":{"type":"openai_hosted","sandbox_node_id":%q}}`, node), + fmt.Sprintf(`{"agent":{"model":"model","x_agents_core":{"sandbox_node_id":%q}},"environment":{"type":"openai_hosted"}}`, node), } { - t.Run(tc.name, func(t *testing.T) { - recorder := &sandboxCreationRecorder{} - handler, _ := environmentCreationHandler(t, "codex", WithHostedEnvironments(), WithExecution(recorder)) - request := httptest.NewRequest(http.MethodPost, "/v1/agents/sessions", strings.NewReader(tc.body)) - request.Header.Set("Authorization", "Bearer key") - request.Header.Set("OpenAI-Beta", "agents=v1") - request.Header.Set("Content-Type", "application/json") - response := httptest.NewRecorder() - handler.ServeHTTP(response, request) - if tc.accepted { - if recorder.calls != 1 || recorder.input.SandboxNodeID != node || strings.Contains(string(recorder.input.Configuration), "sandbox_node_id") || !strings.Contains(string(recorder.input.CreationRequest), node) { - t.Fatal("selector routing or retry identity invalid", recorder.input, response.Body.String()) - } - } else if recorder.calls != 0 || response.Code != 400 { - t.Fatal("invalid selector admitted", response.Code, response.Body.String()) - } - }) + recorder := &sandboxCreationRecorder{} + handler, _ := environmentCreationHandler(t, "codex", WithHostedEnvironments(), WithExecution(recorder)) + request := httptest.NewRequest(http.MethodPost, "/v1/agents/sessions", strings.NewReader(body)) + request.Header.Set("Authorization", "Bearer key") + request.Header.Set("OpenAI-Beta", "agents=v1") + request.Header.Set("Content-Type", "application/json") + response := httptest.NewRecorder() + handler.ServeHTTP(response, request) + if recorder.calls != 0 || response.Code != http.StatusBadRequest { + t.Fatal("node selector admitted", body, response.Code, response.Body.String()) + } } } diff --git a/services/agents-api/internal/api/session_execution_configuration.go b/services/agents-api/internal/api/session_execution_configuration.go index e3955d3d9..75f95e3a0 100644 --- a/services/agents-api/internal/api/session_execution_configuration.go +++ b/services/agents-api/internal/api/session_execution_configuration.go @@ -53,16 +53,7 @@ func sessionExecutionProjection(input sessionRequest, saved *v1.SavedAgent, inhe } } -// @Summary Retrieve frozen Session execution configuration -// @Description Returns the committed model, harness and safe provider selection with recorded sources. This read never decrypts credentials, resolves current defaults or probes execution health. Deployment provider details remain redacted for project callers. Historical provenance and missing provider projections are explicitly unknown/unavailable. -// @Tags Core extensions -// @Produce json -// @Security BearerAuth -// @Param OpenAI-Beta header string true "agents=v1" -// @Param session_id path string true "Session ID" -// @Success 200 {object} v1.SessionExecutionConfiguration -// @Failure 400,401,404,500,503 {object} v1.ErrorResponse -// @Router /agents/sessions/{session_id}/execution-configuration [get] +// getSessionExecutionConfiguration serves the administrator per-Session read. func (h *Handler) getSessionExecutionConfiguration(w http.ResponseWriter, r *http.Request) { source, ok := h.store.(sessionExecutionConfigurationStore) if !ok { diff --git a/services/agents-api/internal/api/session_execution_configuration_test.go b/services/agents-api/internal/api/session_execution_configuration_test.go index feffbee6e..ec867695c 100644 --- a/services/agents-api/internal/api/session_execution_configuration_test.go +++ b/services/agents-api/internal/api/session_execution_configuration_test.go @@ -67,17 +67,17 @@ func (s *executionConfigurationReader) GetSessionExecutionConfiguration(_ contex func TestExecutionConfigurationReadBoundary(t *testing.T) { s := &executionConfigurationReader{value: v1.SessionExecutionConfiguration{Object: "agent.session.execution_configuration", SchemaVersion: 1, SessionID: "frozen"}} - h, _, tenant := testHandler(t, func(h *Handler) { h.store = s }) + h, _, tenant := adminTestHandler(t, func(h *Handler) { h.store = s }) for _, tc := range []struct { auth string err error status int }{ - {"", nil, 401}, {"Bearer test-api-key", nil, 200}, {"Bearer test-api-key", store.ErrNotFound, 404}, + {"", nil, 401}, {"Bearer admin", nil, 200}, {"Bearer admin", store.ErrNotFound, 404}, } { s.err = tc.err before := s.calls - r := httptest.NewRequest(http.MethodGet, "/v1/agents/sessions/frozen/execution-configuration?ignored=true", nil) + r := httptest.NewRequest(http.MethodGet, adminSessionsPath+"frozen/execution-configuration?ignored=true", nil) r.Header.Set("Authorization", tc.auth) r.Header.Set("OpenAI-Beta", "agents=v1") w := httptest.NewRecorder() diff --git a/services/agents-api/internal/api/session_model_defaults.go b/services/agents-api/internal/api/session_model_defaults.go index ef5a232c5..6846ebd64 100644 --- a/services/agents-api/internal/api/session_model_defaults.go +++ b/services/agents-api/internal/api/session_model_defaults.go @@ -7,7 +7,6 @@ import ( v1 "github.com/MiniMax-AI-Dev/parsar/contracts/agents-api/v1" "github.com/MiniMax-AI-Dev/parsar/services/agents-api/internal/store" - "github.com/google/uuid" ) // ModelProviderDefaults resolves deployment configuration at Session creation, @@ -59,18 +58,12 @@ func (h *Handler) resolveSessionExecution(ctx context.Context, input sessionRequ provider := inherited var options map[string]any if extension := input.XAgentsCore; extension != nil { - if extension.ModelProvider == nil && extension.SandboxNodeID == nil && !input.modelProviderNull { + if extension.ModelProvider == nil && !input.modelProviderNull { return "", nil, nil, errors.New("x_agents_core requires an execution option") } if extension.ModelProvider != nil { provider = extension.ModelProvider } - if extension.SandboxNodeID != nil { - id, parseErr := uuid.Parse(*extension.SandboxNodeID) - if parseErr != nil || id == uuid.Nil || id.String() != *extension.SandboxNodeID || input.Environment.Type != "openai_hosted" { - return "", nil, nil, errors.New("sandbox_node_id requires a canonical UUID and hosted environment") - } - } } if provider == nil && input.Environment.Type == "openai_hosted" && h.modelProviderDefaults != nil { var cfg configuration diff --git a/services/agents-api/internal/api/session_request.go b/services/agents-api/internal/api/session_request.go index a33cfaa38..40db4f215 100644 --- a/services/agents-api/internal/api/session_request.go +++ b/services/agents-api/internal/api/session_request.go @@ -37,7 +37,7 @@ type sessionRequest struct { func (request decodedSessionRequest) validated() (sessionRequest, error) { input := sessionRequest{CreateSessionRequest: request.CreateSessionRequest, Input: request.Input} if len(request.Execution) > 0 && !bytes.Equal(bytes.TrimSpace(request.Execution), []byte("null")) { - if decodeInputObject(request.Execution, &input.XAgentsCore, "model_provider", "sandbox_node_id") != nil { + if decodeInputObject(request.Execution, &input.XAgentsCore, "model_provider") != nil { return input, store.ErrInvalidInput } var fields map[string]json.RawMessage diff --git a/services/agents-api/internal/api/startup_configuration.go b/services/agents-api/internal/api/startup_configuration.go index 15445a8bd..1b9cddb88 100644 --- a/services/agents-api/internal/api/startup_configuration.go +++ b/services/agents-api/internal/api/startup_configuration.go @@ -24,15 +24,7 @@ func WithStartupConfiguration(configuration v1.CoreStartupConfiguration) Option } } -// @Summary Retrieve safe Core startup configuration -// @Description Returns a secret-free snapshot of supported build capabilities and validated process startup selections. It does not inspect or aggregate daemon heartbeats, Sessions, Environments or Runtime state, and does not prove model-provider reachability, credentials, native readiness, sandbox isolation or successful execution. -// @Tags Core extensions -// @Produce json -// @Security BearerAuth -// @Param OpenAI-Beta header string true "agents=v1" -// @Success 200 {object} v1.CoreStartupConfiguration -// @Failure 400,401,503 {object} v1.ErrorResponse -// @Router /agents/core/startup-configuration [get] +// getStartupConfiguration serves the administrator startup configuration read. func (h *Handler) getStartupConfiguration(w http.ResponseWriter, r *http.Request) { if r.URL.RawQuery != "" { writeError(w, http.StatusBadRequest, "unsupported_parameter", "Core startup configuration does not accept query parameters.") diff --git a/services/agents-api/internal/api/startup_configuration_test.go b/services/agents-api/internal/api/startup_configuration_test.go index ad70b5202..8834fc3c5 100644 --- a/services/agents-api/internal/api/startup_configuration_test.go +++ b/services/agents-api/internal/api/startup_configuration_test.go @@ -21,13 +21,13 @@ func TestStartupConfigurationHTTP(t *testing.T) { ModelProviders: []v1.CoreHarnessModelProviderConfiguration{{Harness: "claude_sdk"}, {Harness: "codex", EndpointConfigured: true}}, }, } - h, _, _ := testHandler(t, WithStartupConfiguration(configuration)) + h, _, _ := adminTestHandler(t, WithStartupConfiguration(configuration)) configuration.Supported.Harnesses[0] = "mutated" configuration.Configured.EnabledHarnesses[0] = "mutated" *configuration.Configured.ManagedSandbox.Provider = "mutated" - request := httptest.NewRequest(http.MethodGet, "/v1/agents/core/startup-configuration", nil) - request.Header.Set("Authorization", "Bearer test-api-key") + request := httptest.NewRequest(http.MethodGet, "/core/v1/admin/startup-configuration", nil) + request.Header.Set("Authorization", "Bearer admin") request.Header.Set("OpenAI-Beta", "agents=v1") w := httptest.NewRecorder() h.ServeHTTP(w, request) @@ -59,14 +59,14 @@ func TestStartupConfigurationRejectsUnsupportedReads(t *testing.T) { auth string status int }{ - {name: "missing auth", option: []Option{WithStartupConfiguration(configured)}, path: "/v1/agents/core/startup-configuration", status: http.StatusUnauthorized}, - {name: "retired discovery", option: []Option{WithStartupConfiguration(configured)}, path: "/v1/agents/core/startup-configuration?include=configuration_capabilities", auth: "Bearer test-api-key", status: http.StatusBadRequest}, - {name: "query", option: []Option{WithStartupConfiguration(configured)}, path: "/v1/agents/core/startup-configuration?raw=true", auth: "Bearer test-api-key", status: http.StatusBadRequest}, - {name: "malformed query", option: []Option{WithStartupConfiguration(configured)}, path: "/v1/agents/core/startup-configuration?raw;private", auth: "Bearer test-api-key", status: http.StatusBadRequest}, - {name: "composition missing", path: "/v1/agents/core/startup-configuration", auth: "Bearer test-api-key", status: http.StatusServiceUnavailable}, + {name: "missing auth", option: []Option{WithStartupConfiguration(configured)}, path: "/core/v1/admin/startup-configuration", status: http.StatusUnauthorized}, + {name: "retired discovery", option: []Option{WithStartupConfiguration(configured)}, path: "/core/v1/admin/startup-configuration?include=configuration_capabilities", auth: "Bearer admin", status: http.StatusBadRequest}, + {name: "query", option: []Option{WithStartupConfiguration(configured)}, path: "/core/v1/admin/startup-configuration?raw=true", auth: "Bearer admin", status: http.StatusBadRequest}, + {name: "malformed query", option: []Option{WithStartupConfiguration(configured)}, path: "/core/v1/admin/startup-configuration?raw;private", auth: "Bearer admin", status: http.StatusBadRequest}, + {name: "composition missing", path: "/core/v1/admin/startup-configuration", auth: "Bearer admin", status: http.StatusServiceUnavailable}, } { t.Run(test.name, func(t *testing.T) { - h, _, _ := testHandler(t, test.option...) + h, _, _ := adminTestHandler(t, test.option...) request := httptest.NewRequest(http.MethodGet, test.path, nil) request.Header.Set("Authorization", test.auth) request.Header.Set("OpenAI-Beta", "agents=v1") diff --git a/services/agents-api/internal/store/admin_session_archive_race_test.go b/services/agents-api/internal/store/admin_session_archive_race_test.go index e2ae9d1fc..31b823509 100644 --- a/services/agents-api/internal/store/admin_session_archive_race_test.go +++ b/services/agents-api/internal/store/admin_session_archive_race_test.go @@ -29,7 +29,7 @@ func TestManagedSessionArchiveReleasesPendingNodePlacement(t *testing.T) { t.Fatal(err) } onlineManagerNode(t, s, nodeID) - tenant, session := managedArchiveSession(t, s, managerSessionInput(uuid.NewString(), nodeID)) + tenant, session := managedArchiveSession(t, s, managerSessionInput(uuid.NewString())) archiveMaintenance(t, w, installation, true) result, err := w.ArchiveManagedSession(adminDeleteContext(t.Context(), tenant, uuid.NewString()), tenant, session.ID, 1) if err != nil || result.State != "released" { @@ -48,7 +48,7 @@ func TestManagedSessionArchiveOrdersConcurrentInput(t *testing.T) { s, w, installation := managedArchiveFixture(t) for range 8 { archiveMaintenance(t, w, installation, false) - tenant, session := managedArchiveSession(t, s, managerSessionInput(uuid.NewString(), "")) + tenant, session := managedArchiveSession(t, s, managerSessionInput(uuid.NewString())) archiveMaintenance(t, w, installation, true) start := make(chan struct{}) var wg sync.WaitGroup @@ -82,8 +82,8 @@ func TestManagedSessionArchiveOrdersConcurrentInput(t *testing.T) { } func TestManagedSessionArchiveRejectsFileManagedDeployment(t *testing.T) { - s, w, installation := managerFixture(t, 1, 1) - tenant, session := managedArchiveSession(t, s, managerSessionInput(uuid.NewString(), installation.LocalNodeID)) + s, w, _ := managerFixture(t, 1, 1) + tenant, session := managedArchiveSession(t, s, managerSessionInput(uuid.NewString())) if _, err := w.ArchiveManagedSession(adminDeleteContext(t.Context(), tenant, uuid.NewString()), tenant, session.ID, 0); !errors.Is(err, ErrSandboxDeploymentConflict) { t.Fatal("archive accepted file-managed deployment", err) } diff --git a/services/agents-api/internal/store/admin_session_archive_test.go b/services/agents-api/internal/store/admin_session_archive_test.go index 07aa70cdc..52e57baec 100644 --- a/services/agents-api/internal/store/admin_session_archive_test.go +++ b/services/agents-api/internal/store/admin_session_archive_test.go @@ -66,7 +66,7 @@ func archiveAllocation(t *testing.T, w *Store, tenant string, session Session, i func TestManagedSessionArchiveUnallocatedAndGuards(t *testing.T) { s, w, installation := managedArchiveFixture(t) - input := managerSessionInput(uuid.NewString(), "") + input := managerSessionInput(uuid.NewString()) input.InitialInputs = []Input{{Kind: "message", Payload: json.RawMessage(`{"text":"waiting"}`)}} tenant, session := managedArchiveSession(t, s, input) ctx := adminDeleteContext(t.Context(), tenant, uuid.NewString()) @@ -135,7 +135,7 @@ func TestManagedSessionArchiveUnallocatedAndGuards(t *testing.T) { func TestManagedSessionArchiveRetainsHistoryAndSettledResources(t *testing.T) { s, w, installation := managedArchiveFixture(t) - tenant, session := managedArchiveSession(t, s, managerSessionInput(uuid.NewString(), "")) + tenant, session := managedArchiveSession(t, s, managerSessionInput(uuid.NewString())) owner := archiveAllocation(t, w, tenant, session, installation) file, err := s.CreateSourceFile(t.Context(), tenant, uploadSource([]byte("retained source file"))) if err != nil { @@ -214,7 +214,7 @@ func TestManagedSessionArchiveRetainsHistoryAndSettledResources(t *testing.T) { func TestManagedSessionArchiveAuditFailureRollsBack(t *testing.T) { s, w, installation := managedArchiveFixture(t) - tenant, session := managedArchiveSession(t, s, managerSessionInput(uuid.NewString(), "")) + tenant, session := managedArchiveSession(t, s, managerSessionInput(uuid.NewString())) archiveAllocation(t, w, tenant, session, installation) input := submitMessage(t, s, tenant, session.ID, "running") transition(t, w, tenant, session.ID, input.TurnID, TurnQueued, TurnInProgress) @@ -239,7 +239,7 @@ func TestManagedSessionArchiveAuditFailureRollsBack(t *testing.T) { func TestManagedSessionArchivePreservesFailuresAndRejectsSelfHosted(t *testing.T) { s, w, installation := managedArchiveFixture(t) - tenant, session := managedArchiveSession(t, s, managerSessionInput(uuid.NewString(), "")) + tenant, session := managedArchiveSession(t, s, managerSessionInput(uuid.NewString())) owner := archiveAllocation(t, w, tenant, session, installation) if _, err := w.FailRuntimeInitialization(t.Context(), owner, ProvisioningFailure{Step: ProvisioningSetupCommand, Index: 0, ExitCode: 2}); err != nil { t.Fatal(err) diff --git a/services/agents-api/internal/store/device_bootstrap_binding_test.go b/services/agents-api/internal/store/device_bootstrap_binding_test.go index 329e288ea..79dfe8841 100644 --- a/services/agents-api/internal/store/device_bootstrap_binding_test.go +++ b/services/agents-api/internal/store/device_bootstrap_binding_test.go @@ -26,7 +26,7 @@ func TestDeviceCredentialCarriesPersistedAllocationNode(t *testing.T) { for _, nodeID := range []string{deployment.LocalNodeID, remote} { t.Run(nodeID, func(t *testing.T) { tenant, bearer := uuid.NewString(), uuid.NewString() - session, err := s.CreateSession(t.Context(), tenant, managerSessionInput(uuid.NewString(), nodeID)) + session, err := createSessionOnNode(t, s, tenant, managerSessionInput(uuid.NewString()), nodeID) if err != nil { t.Fatal(err) } diff --git a/services/agents-api/internal/store/runtime_adoption_test.go b/services/agents-api/internal/store/runtime_adoption_test.go index 6f115f8ef..6e6c9a310 100644 --- a/services/agents-api/internal/store/runtime_adoption_test.go +++ b/services/agents-api/internal/store/runtime_adoption_test.go @@ -132,13 +132,13 @@ func TestRuntimeLegacyAdoptionRetainsStatesAndIgnoresReleasedHistory(t *testing. if calls != 0 || got.NodeID != "" { t.Fatal("released history was assigned", calls, got) } - if _, err := s.GetSessionRuntimePlacement(t.Context(), a.TenantID, a.SessionID); !errors.Is(err, ErrNotFound) { + if _, err := sessionRuntimePlacement(t.Context(), s, a.TenantID, a.SessionID); !errors.Is(err, ErrNotFound) { t.Fatal("released history placement fabricated", err) } } else if calls != 1 || got.NodeID != d.LocalNodeID { t.Fatal(calls, got) } - if placement, err := s.GetSessionRuntimePlacement(t.Context(), a.TenantID, pending.ID); err != nil || placement.NodeID != d.LocalNodeID { + if placement, err := sessionRuntimePlacement(t.Context(), s, a.TenantID, pending.ID); err != nil || placement.NodeID != d.LocalNodeID { t.Fatal(placement, err) } if err := w.ConfigureRuntimeDeployment(t.Context(), &d, func(context.Context, RuntimeAllocation) error { t.Fatal("reverified fixed placement"); return nil }); err != nil { diff --git a/services/agents-api/internal/store/runtime_idle_clock_test.go b/services/agents-api/internal/store/runtime_idle_clock_test.go index 079701876..a30bf748b 100644 --- a/services/agents-api/internal/store/runtime_idle_clock_test.go +++ b/services/agents-api/internal/store/runtime_idle_clock_test.go @@ -19,7 +19,7 @@ func managedIdleClockFixture(t *testing.T) (*Store, *Store, RuntimeAllocation) { t.Helper() s, w, d := managerFixture(t, 1, 4) tenant := uuid.NewString() - input := managerSessionInput("idle-clock", d.LocalNodeID) + input := managerSessionInput("idle-clock") input.Configuration = json.RawMessage(`{"agent":{"id":"agent_root","model":"test","multi_agent":{"enabled":true}},"environment":{"type":"openai_hosted"}}`) session, err := s.CreateSession(t.Context(), tenant, input) if err != nil { diff --git a/services/agents-api/internal/store/runtime_lifecycle_nodes_test.go b/services/agents-api/internal/store/runtime_lifecycle_nodes_test.go index c88f34d15..0e12126a0 100644 --- a/services/agents-api/internal/store/runtime_lifecycle_nodes_test.go +++ b/services/agents-api/internal/store/runtime_lifecycle_nodes_test.go @@ -27,7 +27,7 @@ func lifecycleTestNode(t *testing.T, s *Store) string { func lifecycleTestSession(t *testing.T, s *Store, node string) (string, Session) { t.Helper() tenant := uuid.NewString() - session, err := s.CreateSession(t.Context(), tenant, managerSessionInput(uuid.NewString(), node)) + session, err := createSessionOnNode(t, s, tenant, managerSessionInput(uuid.NewString()), node) if err != nil { t.Fatal(err) } diff --git a/services/agents-api/internal/store/runtime_node_lifecycle_fixture_test.go b/services/agents-api/internal/store/runtime_node_lifecycle_fixture_test.go index 033650929..d30427a2c 100644 --- a/services/agents-api/internal/store/runtime_node_lifecycle_fixture_test.go +++ b/services/agents-api/internal/store/runtime_node_lifecycle_fixture_test.go @@ -166,11 +166,19 @@ func (f *nodeIsolationFixture) online(id string) { func (f *nodeIsolationFixture) session(node string, initialize bool) (string, store.Session, store.Environment) { f.t.Helper() tenant := uuid.NewString() - input := store.CreateSessionInput{Creator: store.FixtureCreator(), Engine: "codex", IdempotencyKey: uuid.NewString(), SandboxNodeID: node, Configuration: json.RawMessage("{\"agent\":{\"model\":\"test\"},\"environment\":{\"type\":\"openai_hosted\"}}")} + input := store.CreateSessionInput{Creator: store.FixtureCreator(), Engine: "codex", IdempotencyKey: uuid.NewString(), Configuration: json.RawMessage("{\"agent\":{\"model\":\"test\"},\"environment\":{\"type\":\"openai_hosted\"}}")} if initialize { input.InitialFiles = []store.InitialFile{{Type: "inline", Path: "/workspace/seed", Data: []byte("retained")}} } + // Placement is automatic: only node stays provider-ready while it is created. + var others []string + if err := f.pool.QueryRow(f.t.Context(), "WITH changed AS (UPDATE runtime_nodes SET provider_ready=false WHERE provider_ready AND id<>$1 RETURNING id) SELECT coalesce(array_agg(id::text),'{}') FROM changed", node).Scan(&others); err != nil { + f.t.Fatal(err) + } session, err := f.store.CreateSession(f.t.Context(), tenant, input) + if _, restoreErr := f.pool.Exec(f.t.Context(), "UPDATE runtime_nodes SET provider_ready=true WHERE id::text=ANY($1)", others); restoreErr != nil { + f.t.Fatal(restoreErr) + } if err != nil { f.t.Fatal(err) } diff --git a/services/agents-api/internal/store/runtime_node_types.go b/services/agents-api/internal/store/runtime_node_types.go index dc7bc53de..d6fe495df 100644 --- a/services/agents-api/internal/store/runtime_node_types.go +++ b/services/agents-api/internal/store/runtime_node_types.go @@ -68,14 +68,6 @@ type RuntimeNodeUpdate struct { MaxActive int `json:"max_active"` MaxRetained int `json:"max_retained"` } -type RuntimePlacement struct { - Diagnostic string `json:"diagnostic"` - NodeID string `json:"node_id"` - NodeName string `json:"node_name"` - Available bool `json:"available"` - State string `json:"state"` - ComputePhase string `json:"compute_phase"` -} type SandboxDeploymentResources struct { Allocations int64 `json:"allocations"` Pending int64 `json:"pending"` diff --git a/services/agents-api/internal/store/runtime_nodes_test.go b/services/agents-api/internal/store/runtime_nodes_test.go index 5dc8899bb..0212477aa 100644 --- a/services/agents-api/internal/store/runtime_nodes_test.go +++ b/services/agents-api/internal/store/runtime_nodes_test.go @@ -11,6 +11,7 @@ import ( "github.com/MiniMax-AI-Dev/parsar/internal/agentdaemon/device" "github.com/google/uuid" + "github.com/jackc/pgx/v5" ) func managerFixture(t *testing.T, active, retained int) (*Store, *Store, RuntimeDeployment) { @@ -38,10 +39,49 @@ func onlineManagerNode(t *testing.T, s *Store, id string) string { } return connection } -func managerSessionInput(key, node string) CreateSessionInput { - input := environmentInput(key, "openai_hosted", "/workspace") - input.SandboxNodeID = node - return input +func managerSessionInput(key string) CreateSessionInput { + return environmentInput(key, "openai_hosted", "/workspace") +} + +// createSessionOnNode steers automatic placement in multi-node tests: only node +// stays provider-ready while the Session is created. +func createSessionOnNode(t *testing.T, s *Store, tenant string, input CreateSessionInput, node string) (Session, error) { + t.Helper() + var others []string + if err := s.pool.QueryRow(t.Context(), "WITH changed AS (UPDATE runtime_nodes SET provider_ready=false WHERE provider_ready AND id<>$1 RETURNING id) SELECT coalesce(array_agg(id::text),'{}') FROM changed", node).Scan(&others); err != nil { + t.Fatal(err) + } + defer func() { + if _, err := s.pool.Exec(context.WithoutCancel(t.Context()), "UPDATE runtime_nodes SET provider_ready=true WHERE id::text=ANY($1)", others); err != nil { + t.Fatal(err) + } + }() + return s.CreateSession(t.Context(), tenant, input) +} + +type sessionPlacement struct { + NodeID string + Available bool +} + +// sessionRuntimePlacement reads the node a Session was placed on. +func sessionRuntimePlacement(ctx context.Context, s *Store, tenant, session string) (sessionPlacement, error) { + value, err := s.GetSession(ctx, tenant, session) + if err != nil { + return sessionPlacement{}, err + } + if value.Environment == nil { + return sessionPlacement{}, ErrNotFound + } + id, err := parseID(value.Environment.ID) + if err != nil { + return sessionPlacement{}, err + } + p, err := s.queries.GetRuntimePlacement(ctx, id) + if errors.Is(err, pgx.ErrNoRows) { + return sessionPlacement{}, ErrNotFound + } + return sessionPlacement{NodeID: runtimeUUID(p.NodeID), Available: p.Available && !p.ReleasedAt.Valid}, err } func TestRuntimeNodesAtomicPlacementAndRetry(t *testing.T) { s, _, d := managerFixture(t, 1, 4) @@ -53,7 +93,7 @@ func TestRuntimeNodesAtomicPlacementAndRetry(t *testing.T) { wg.Add(1) go func() { defer wg.Done() - result, err := s.CreateSession(t.Context(), tenant, managerSessionInput(uuid.NewString(), "")) + result, err := s.CreateSession(t.Context(), tenant, managerSessionInput(uuid.NewString())) results <- result failures <- err }() @@ -88,7 +128,7 @@ func TestRuntimeNodesAtomicPlacementAndRetry(t *testing.T) { if err := s.DeleteSession(t.Context(), tenant, retained.ID); err != nil { t.Fatal(err) } - input := managerSessionInput("retry", d.LocalNodeID) + input := managerSessionInput("retry") first, err := s.CreateSession(t.Context(), tenant, input) if err != nil { t.Fatal(err) @@ -100,14 +140,10 @@ func TestRuntimeNodesAtomicPlacementAndRetry(t *testing.T) { if err != nil || replay.ID != first.ID { t.Fatal("offline retry changed Session", replay, err) } - input.SandboxNodeID = uuid.NewString() - if _, err := s.CreateSession(t.Context(), tenant, input); !errors.Is(err, ErrIdempotencyConflict) { - t.Fatal("retry changed selector", err) - } - if _, err := s.GetSessionRuntimePlacement(t.Context(), uuid.NewString(), first.ID); !errors.Is(err, ErrNotFound) { + if _, err := sessionRuntimePlacement(t.Context(), s, uuid.NewString(), first.ID); !errors.Is(err, ErrNotFound) { t.Fatal("foreign placement leaked", err) } - placement, err := s.GetSessionRuntimePlacement(t.Context(), tenant, first.ID) + placement, err := sessionRuntimePlacement(t.Context(), s, tenant, first.ID) if err != nil || placement.NodeID != d.LocalNodeID || placement.Available { t.Fatal(placement, err) } @@ -158,13 +194,10 @@ func TestRuntimeNodesEnrollmentAndEpoch(t *testing.T) { if err := s.HeartbeatRuntimeNode(t.Context(), input.NodeID, connection, epoch, RuntimeNodeHealth{ProviderReady: true}); !errors.Is(err, ErrRuntimeNodeCredential) { t.Fatal("old epoch heartbeat revived node", err) } - if _, err := s.CreateSession(t.Context(), uuid.NewString(), managerSessionInput("stale", "")); !errors.Is(err, ErrRuntimeNodeUnavailable) { + if _, err := s.CreateSession(t.Context(), uuid.NewString(), managerSessionInput("stale")); !errors.Is(err, ErrRuntimeNodeUnavailable) { t.Fatal("stale node admitted", err) } onlineManagerNode(t, s, d.LocalNodeID) - if _, err := s.CreateSession(t.Context(), uuid.NewString(), managerSessionInput("manual-offline", input.NodeID)); !errors.Is(err, ErrRuntimeNodeUnavailable) { - t.Fatal("manual placement fell back", err) - } if err := s.RemoveRuntimeNode(t.Context(), input.NodeID); err != nil { t.Fatal(err) } @@ -204,7 +237,7 @@ func TestRuntimeNodesLegacyAdoptionAndRetention(t *testing.T) { t.Fatal("adoption lost identity", retained, err) } for _, session := range []Session{first, pending} { - placement, err := s.GetSessionRuntimePlacement(t.Context(), tenant, session.ID) + placement, err := sessionRuntimePlacement(t.Context(), s, tenant, session.ID) if err != nil || placement.NodeID != next.LocalNodeID { t.Fatal("legacy Session reassigned", placement, err) } @@ -253,7 +286,7 @@ func TestRuntimeNodesLegacyAdoptionAndRetention(t *testing.T) { func TestRuntimeNodesRestoreAndCreationShareCapacity(t *testing.T) { s, w, d := managerFixture(t, 1, 4) tenant := uuid.NewString() - session, err := s.CreateSession(t.Context(), tenant, managerSessionInput("first", d.LocalNodeID)) + session, err := s.CreateSession(t.Context(), tenant, managerSessionInput("first")) if err != nil { t.Fatal(err) } @@ -278,7 +311,7 @@ func TestRuntimeNodesRestoreAndCreationShareCapacity(t *testing.T) { }() go func() { <-start - _, err := s.CreateSession(t.Context(), tenant, managerSessionInput("second", d.LocalNodeID)) + _, err := s.CreateSession(t.Context(), tenant, managerSessionInput("second")) results <- err }() close(start) @@ -311,7 +344,7 @@ func managerEpoch(t *testing.T, s *Store) uint64 { func TestRuntimeNodesLongOfflineRetainsExactAllocation(t *testing.T) { s, w, d := managerFixture(t, 1, 4) tenant := uuid.NewString() - session, err := s.CreateSession(t.Context(), tenant, managerSessionInput("long-offline", d.LocalNodeID)) + session, err := s.CreateSession(t.Context(), tenant, managerSessionInput("long-offline")) if err != nil { t.Fatal(err) } @@ -364,7 +397,7 @@ func TestRuntimeNodesLongOfflineRetainsExactAllocation(t *testing.T) { if err != nil || same.ID != owner.ID || string(same.ComputeState) != string(retained.ComputeState) { t.Fatal("snapshot changed across reconnect", same, err) } - placement, err := s.GetSessionRuntimePlacement(t.Context(), tenant, session.ID) + placement, err := sessionRuntimePlacement(t.Context(), s, tenant, session.ID) if err != nil || placement.NodeID != d.LocalNodeID { t.Fatal(placement, err) } diff --git a/services/agents-api/internal/store/runtime_observation_test.go b/services/agents-api/internal/store/runtime_observation_test.go index df10b3c09..1feedffb4 100644 --- a/services/agents-api/internal/store/runtime_observation_test.go +++ b/services/agents-api/internal/store/runtime_observation_test.go @@ -11,7 +11,7 @@ import ( func TestRuntimeNodeObservationRetainsResourcesAndFencesStaleResults(t *testing.T) { s, w, d := managerFixture(t, 1, 4) tenant := uuid.NewString() - session, err := s.CreateSession(t.Context(), tenant, managerSessionInput("observation", d.LocalNodeID)) + session, err := s.CreateSession(t.Context(), tenant, managerSessionInput("observation")) if err != nil { t.Fatal(err) } diff --git a/services/agents-api/internal/store/runtime_placements.go b/services/agents-api/internal/store/runtime_placements.go index 2c6b92192..99275328f 100644 --- a/services/agents-api/internal/store/runtime_placements.go +++ b/services/agents-api/internal/store/runtime_placements.go @@ -2,31 +2,26 @@ package store import ( "context" - "errors" "github.com/MiniMax-AI-Dev/parsar/services/agents-api/internal/db/sqlc" - "github.com/jackc/pgx/v5" "github.com/jackc/pgx/v5/pgtype" ) // reserveRuntimePlacement shares the deployment lock with restore and node removal. // A Session creation retry never reaches this function. -func reserveRuntimePlacement(ctx context.Context, q *sqlc.Queries, session pgtype.UUID, selected string) error { +func reserveRuntimePlacement(ctx context.Context, q *sqlc.Queries, session pgtype.UUID) error { d, err := q.LockRuntimeDeployment(ctx) if err != nil { return err } if d.Mode == "direct" { - if selected != "" { - return ErrInvalidInput - } if d.Maintenance { return ErrRuntimeNodeUnavailable } return nil } if d.ProviderKind == "" { - if d.WebManaged || selected != "" { + if d.WebManaged { return ErrRuntimeNodeUnavailable } return nil @@ -41,9 +36,6 @@ func reserveRuntimePlacement(ctx context.Context, q *sqlc.Queries, session pgtyp var chosen *sqlc.ListRuntimeNodesRow for i := range rows { n := &rows[i] - if selected != "" && runtimeUUID(n.ID) != selected { - continue - } if !n.Online || !n.ProviderReady || n.Active >= int64(n.MaxActive) || n.Retained >= int64(n.MaxRetained) { continue } @@ -57,28 +49,6 @@ func reserveRuntimePlacement(ctx context.Context, q *sqlc.Queries, session pgtyp return q.CreateSessionRuntimePlacement(ctx, sqlc.CreateSessionRuntimePlacementParams{SessionID: session, NodeID: chosen.ID}) } -func (s *Store) GetSessionRuntimePlacement(ctx context.Context, tenant, session string) (RuntimePlacement, error) { - value, err := s.GetSession(ctx, tenant, session) - if err != nil { - return RuntimePlacement{}, err - } - if value.Environment == nil { - return RuntimePlacement{}, ErrNotFound - } - id, err := parseID(value.Environment.ID) - if err != nil { - return RuntimePlacement{}, err - } - p, err := s.queries.GetRuntimePlacement(ctx, id) - if errors.Is(err, pgx.ErrNoRows) { - return RuntimePlacement{}, ErrNotFound - } - if err != nil { - return RuntimePlacement{}, err - } - return RuntimePlacement{Diagnostic: p.ObservationError, NodeID: runtimeUUID(p.NodeID), NodeName: p.Name, Available: p.Available && !p.ReleasedAt.Valid, State: p.State, ComputePhase: p.ComputePhase}, nil -} - // ResolveRuntimeNode includes deleted Sessions so owned cleanup remains routable. func (s *Store) ResolveRuntimeNode(ctx context.Context, tenant, environment string) (string, error) { allocation, err := s.GetRuntimeAllocation(ctx, tenant, environment) diff --git a/services/agents-api/internal/store/sandbox_deployment_resources_test.go b/services/agents-api/internal/store/sandbox_deployment_resources_test.go index 38f54d37f..25ea8006d 100644 --- a/services/agents-api/internal/store/sandbox_deployment_resources_test.go +++ b/services/agents-api/internal/store/sandbox_deployment_resources_test.go @@ -26,14 +26,14 @@ func TestSandboxDeploymentMutationViewsIncludeActualResources(t *testing.T) { t.Fatal(err) } tenant := uuid.NewString() - session, err := s.CreateSession(t.Context(), tenant, managerSessionInput(uuid.NewString(), "")) + session, err := s.CreateSession(t.Context(), tenant, managerSessionInput(uuid.NewString())) if err != nil { t.Fatal(err) } if _, err := w.ReserveRuntimeAllocation(t.Context(), tenant, session.Environment.ID, installation, device.HashCredential(uuid.NewString())); err != nil { t.Fatal(err) } - if _, err := s.CreateSession(t.Context(), tenant, managerSessionInput(uuid.NewString(), "")); err != nil { + if _, err := s.CreateSession(t.Context(), tenant, managerSessionInput(uuid.NewString())); err != nil { t.Fatal(err) } want := SandboxDeploymentResources{Allocations: 1, Pending: 1} diff --git a/services/agents-api/internal/store/sandbox_deployment_setup_test.go b/services/agents-api/internal/store/sandbox_deployment_setup_test.go index 764bfb71e..ee660524d 100644 --- a/services/agents-api/internal/store/sandbox_deployment_setup_test.go +++ b/services/agents-api/internal/store/sandbox_deployment_setup_test.go @@ -41,7 +41,7 @@ func TestSandboxDeploymentSetupPersistsWithoutExecution(t *testing.T) { if err != nil || before.InstallationID != id || before.Provider != "" || before.CoreURL != "" { t.Fatal(before, err) } - if _, err := s.CreateSession(t.Context(), uuid.NewString(), managerSessionInput(uuid.NewString(), "")); !errors.Is(err, ErrRuntimeNodeUnavailable) { + if _, err := s.CreateSession(t.Context(), uuid.NewString(), managerSessionInput(uuid.NewString())); !errors.Is(err, ErrRuntimeNodeUnavailable) { t.Fatal("uninitialized hosted admission", err) } input := SandboxDeploymentSetupRequest{DeploymentSpec: SandboxDeploymentTestSpec("microsandbox"), Provider: "microsandbox", CoreURL: "https://core.example"} diff --git a/services/agents-api/internal/store/sandbox_deployment_switch_test.go b/services/agents-api/internal/store/sandbox_deployment_switch_test.go index b02d4f670..70f18b886 100644 --- a/services/agents-api/internal/store/sandbox_deployment_switch_test.go +++ b/services/agents-api/internal/store/sandbox_deployment_switch_test.go @@ -49,10 +49,7 @@ func TestSandboxDirectDeploymentOwnershipAndCleanSwitch(t *testing.T) { t.Fatal("cloud enrolled a machine", err) } tenant := uuid.NewString() - if _, err := s.CreateSession(t.Context(), tenant, managerSessionInput(uuid.NewString(), uuid.NewString())); !errors.Is(err, ErrInvalidInput) { - t.Fatal("direct accepted node selection", err) - } - session, err := s.CreateSession(t.Context(), tenant, managerSessionInput(uuid.NewString(), "")) + session, err := s.CreateSession(t.Context(), tenant, managerSessionInput(uuid.NewString())) if err != nil { t.Fatal(err) } @@ -198,7 +195,7 @@ func TestSandboxMaintenanceSerializesFreshDirectSessions(t *testing.T) { wg.Add(1) go func() { defer wg.Done() - _, err := s.CreateSession(t.Context(), uuid.NewString(), managerSessionInput(uuid.NewString(), "")) + _, err := s.CreateSession(t.Context(), uuid.NewString(), managerSessionInput(uuid.NewString())) if err != nil && !errors.Is(err, ErrEnvironmentUnavailable) && !errors.Is(err, ErrRuntimeNodeUnavailable) { t.Error(err) } @@ -209,7 +206,7 @@ func TestSandboxMaintenanceSerializesFreshDirectSessions(t *testing.T) { } wg.Wait() for range 3 { - if _, err := s.CreateSession(t.Context(), uuid.NewString(), managerSessionInput(uuid.NewString(), "")); !errors.Is(err, ErrEnvironmentUnavailable) { + if _, err := s.CreateSession(t.Context(), uuid.NewString(), managerSessionInput(uuid.NewString())); !errors.Is(err, ErrEnvironmentUnavailable) { t.Fatal("fresh creation bypassed maintenance", err) } } @@ -237,7 +234,7 @@ func TestSandboxSwitchPreservesReleasedAllocationAndItemHistory(t *testing.T) { t.Fatal(err) } tenant := uuid.NewString() - session, err := s.CreateSession(t.Context(), tenant, managerSessionInput(uuid.NewString(), "")) + session, err := s.CreateSession(t.Context(), tenant, managerSessionInput(uuid.NewString())) if err != nil { t.Fatal(err) } @@ -346,7 +343,7 @@ func TestUnspecifiedNodeDeploymentDrainsBeforeReplacement(t *testing.T) { if _, err := s.RuntimeNodeConfiguration(t.Context(), node.NodeID, node.Credential); !errors.Is(err, ErrRuntimeSpecificationMismatch) { t.Fatal("node configuration served without a specification", err) } - if _, err := s.CreateSession(t.Context(), uuid.NewString(), managerSessionInput(uuid.NewString(), "")); !errors.Is(err, ErrEnvironmentUnavailable) { + if _, err := s.CreateSession(t.Context(), uuid.NewString(), managerSessionInput(uuid.NewString())); !errors.Is(err, ErrEnvironmentUnavailable) { t.Fatal("unspecified deployment admitted a fresh sandbox", err) } if _, _, err := s.CreateRuntimeEnrollment(t.Context(), RuntimeNodeCapacity{MaxActive: 2, MaxRetained: 4}); !errors.Is(err, ErrSandboxDeploymentConflict) { diff --git a/services/agents-api/internal/store/sandbox_specification_store_test.go b/services/agents-api/internal/store/sandbox_specification_store_test.go index 51b43c0ce..12577adaf 100644 --- a/services/agents-api/internal/store/sandbox_specification_store_test.go +++ b/services/agents-api/internal/store/sandbox_specification_store_test.go @@ -155,7 +155,7 @@ func TestSandboxSpecificationChangesWaitForEveryRetainedResource(t *testing.T) { s, w, view, input := webSpecificationFixture(t, "microsandbox") node := specificationNode(t, s, view) tenant := uuid.NewString() - session, err := s.CreateSession(t.Context(), tenant, managerSessionInput(uuid.NewString(), node.NodeID)) + session, err := createSessionOnNode(t, s, tenant, managerSessionInput(uuid.NewString()), node.NodeID) if err != nil { t.Fatal(err) } @@ -283,7 +283,7 @@ func TestSandboxSpecificationAllocationRaceWithMaintenance(t *testing.T) { tenant := uuid.NewString() var sessions []Session for range 12 { - session, err := s.CreateSession(t.Context(), tenant, managerSessionInput(uuid.NewString(), "")) + session, err := s.CreateSession(t.Context(), tenant, managerSessionInput(uuid.NewString())) if err != nil { t.Fatal(err) } diff --git a/services/agents-api/internal/store/session_deletion_test.go b/services/agents-api/internal/store/session_deletion_test.go index b9435823f..0f9ee0fb2 100644 --- a/services/agents-api/internal/store/session_deletion_test.go +++ b/services/agents-api/internal/store/session_deletion_test.go @@ -371,7 +371,7 @@ func TestSessionDeletionKeepsProvisioningInputPlacementUntilSettled(t *testing.T s, w, d := managerFixture(t, 2, 4) ctx := t.Context() tenant := uuid.NewString() - input := managerSessionInput("reserved-input", d.LocalNodeID) + input := managerSessionInput("reserved-input") input.InitialInputs = []Input{messageInput("reserved")} session, err := s.CreateSession(ctx, tenant, input) if err != nil { diff --git a/services/agents-api/internal/store/session_initial_input.go b/services/agents-api/internal/store/session_initial_input.go index aad6328b0..cf148c288 100644 --- a/services/agents-api/internal/store/session_initial_input.go +++ b/services/agents-api/internal/store/session_initial_input.go @@ -23,7 +23,7 @@ func validateInitialInputs(inputs []Input) ([]Input, json.RawMessage, error) { // The Session upsert locks retries. Only the new row reserves or admits work, so a // retry after completion or later Turns cannot submit the original input again. -func (s *Store) createSessionResources(ctx context.Context, tenant string, params sqlc.CreateSessionParams, inputs []Input, encodedInput json.RawMessage, files []InitialFile, setup EnvironmentSetup, provider *v1.ModelProviderInput, modelOptions map[string]any, sandboxNodeID string, executionConfiguration *v1.SessionExecutionConfiguration) (sqlc.Session, *Environment, error) { +func (s *Store) createSessionResources(ctx context.Context, tenant string, params sqlc.CreateSessionParams, inputs []Input, encodedInput json.RawMessage, files []InitialFile, setup EnvironmentSetup, provider *v1.ModelProviderInput, modelOptions map[string]any, executionConfiguration *v1.SessionExecutionConfiguration) (sqlc.Session, *Environment, error) { var row sqlc.Session var environment *Environment err := pgx.BeginFunc(ctx, s.pool, func(tx pgx.Tx) error { @@ -42,14 +42,6 @@ func (s *Store) createSessionResources(ctx context.Context, tenant string, param if err := json.Unmarshal(row.Configuration, &placement); err != nil { return err } - if sandboxNodeID != "" && placement.Environment.Type != "openai_hosted" { - return ErrInvalidInput - } - if sandboxNodeID != "" { - if _, err := parseConnectionGeneration(sandboxNodeID); err != nil { - return err - } - } if placement.Environment.Type == "openai_hosted" { if err := checkRuntimeDeploymentAdmission(ctx, q, ""); err != nil { return err @@ -104,7 +96,7 @@ func (s *Store) createSessionResources(ctx context.Context, tenant string, param return err } if placement.Environment.Type == "openai_hosted" { - if err := reserveRuntimePlacement(ctx, q, row.ID, sandboxNodeID); err != nil { + if err := reserveRuntimePlacement(ctx, q, row.ID); err != nil { return err } } diff --git a/services/agents-api/internal/store/sessions.go b/services/agents-api/internal/store/sessions.go index d627401d3..67357c8e8 100644 --- a/services/agents-api/internal/store/sessions.go +++ b/services/agents-api/internal/store/sessions.go @@ -58,7 +58,6 @@ type Session struct { type CreateSessionInput struct { ExecutionConfiguration *v1.SessionExecutionConfiguration - SandboxNodeID string ModelProvider *v1.ModelProviderInput // ModelOptions is a private snapshot of trusted deployment execution options. ModelOptions map[string]any @@ -159,7 +158,6 @@ func (s *Store) createSession(ctx context.Context, tenantID string, input Create } // JSON map keys are sorted by encoding/json, so key order does not affect retries. canonical, err := json.Marshal(struct { - SandboxNodeID string `json:",omitempty"` ModelProvider *v1.ModelProviderInput `json:",omitempty"` ModelOptions map[string]any `json:",omitempty"` Engine string @@ -168,7 +166,7 @@ func (s *Store) createSession(ctx context.Context, tenantID string, input Create InitialInputs json.RawMessage `json:",omitempty"` InitialFiles []InitialFile `json:",omitempty"` Initialization *EnvironmentSetup `json:",omitempty"` - }{input.SandboxNodeID, input.ModelProvider, input.ModelOptions, input.Engine, input.Metadata, configuration, encodedInput, input.InitialFiles, initialization}) + }{input.ModelProvider, input.ModelOptions, input.Engine, input.Metadata, configuration, encodedInput, input.InitialFiles, initialization}) if err != nil { return SessionCreation{}, fmt.Errorf("%w: input: %v", ErrInvalidInput, err) } @@ -183,7 +181,7 @@ func (s *Store) createSession(ctx context.Context, tenantID string, input Create Configuration: configuration, CreationRequestHash: creationHash, CreatorKind: pgtype.Text{String: input.Creator.Kind, Valid: true}, CreatorID: pgtype.Text{String: input.Creator.ID, Valid: true}, } - row, environment, err := s.createSessionResources(ctx, tenantID, params, batch, encodedInput, input.InitialFiles, input.Initialization, input.ModelProvider, input.ModelOptions, input.SandboxNodeID, input.ExecutionConfiguration) + row, environment, err := s.createSessionResources(ctx, tenantID, params, batch, encodedInput, input.InitialFiles, input.Initialization, input.ModelProvider, input.ModelOptions, input.ExecutionConfiguration) if errors.Is(err, pgx.ErrNoRows) { return SessionCreation{}, ErrIdempotencyConflict } diff --git a/services/agents-api/tests/runtime_history_live.py b/services/agents-api/tests/runtime_history_live.py deleted file mode 100644 index ba06f92fc..000000000 --- a/services/agents-api/tests/runtime_history_live.py +++ /dev/null @@ -1,175 +0,0 @@ -#!/usr/bin/env python3 -"""Opt-in real-model Runtime history acceptance against an isolated deployment. - -Run `collect`, restart the owned Core externally, then run `verify-restart`. -The caller owns deployment, private model configuration and final Session cleanup. -Use a dedicated report directory per provider. This never restarts any service. -Raw native/SQL sample comparisons are separate operator evidence: successive live -observations cannot be expected to have identical counter values. -""" - -import argparse -import importlib.util -import json -import os -from pathlib import Path -import time -import uuid - - -ROOT = Path(__file__).resolve().parents[3] -SPEC = importlib.util.spec_from_file_location("installer_acceptance", ROOT / "deploy/install/acceptance.py") -base = importlib.util.module_from_spec(SPEC) -SPEC.loader.exec_module(base) - - -class SafeAPI(base.API): - def __init__(self, origin, token, evidence, secrets): - super().__init__(origin, token, evidence) - self.secrets = secrets - - def request(self, *args, **kwargs): - value = super().request(*args, **kwargs) - encoded = json.dumps(value) - base.require(not any(secret in encoded for secret in self.secrets), "public_response_exposed_secret") - base.require(not any(marker in encoded for marker in ( - "postgres://", "postgresql://", "runtime_history_samples", "token_sha256", - "unix:///", "host.microsandbox.internal", "/home/parsar-acceptance/")), - "public_response_exposed_backend_configuration") - return value - - -def stable_history(value): - return {key: item for key, item in value.items() if key != "generated_at"} - - -def check_isolation(api, foreign, record, query): - path = base.session_path(record) - for suffix in ("/runtime-observation", "/runtime-history"): - try: - foreign.get(path + suffix, "foreign" + suffix, query if suffix.endswith("history") else None) - except base.Failure: - base.require(foreign.evidence[-1].get("http_status") == 404, "foreign_resource_not_hidden") - else: - raise base.Failure("foreign_resource_visible") - page = foreign.get("/runtime-observations", "foreign.observations") - base.require(not any(row["session_id"] == record["session_id"] for row in page["data"]), - "foreign_observation_list_exposed_session") - record["checks"].append("foreign_history_and_current_observation_hidden") - - -def collect(api, foreign, record, config, args, save): - base.require("session_id" not in record, "collection_already_attempted") - capabilities = api.get("/runtime-history/capabilities", "history.capabilities") - base.require(capabilities.get("available") is True and capabilities.get("collection_mode") == "periodic", - "builtin_periodic_history_unavailable") - record["capabilities"] = capabilities - record["start"] = int(time.time()) - 60 - nonce = uuid.uuid4().hex - prompt = ("Use your native shell tool to run exactly once: python3 -c 'import time; " - "end=time.monotonic()+45; x=0\nwhile time.monotonic()= 2, "insufficient_live_observations") - base.require(all(sample["provider_type"] == args.provider for sample in observed), "wrong_runtime_provider") - base.require(all(sample["cpu"]["usage_seconds_total"] is not None and sample["memory"]["usage_bytes"] is not None - for sample in observed), "missing_native_counters") - base.require(any(sample["cpu"]["usage_seconds_total"] > observed[0]["cpu"]["usage_seconds_total"] - for sample in observed[1:]), "cpu_counter_did_not_advance_during_execution") - record["observations"] = observations - record["turn"] = completed - record["checks"].append("provider_current_cpu_and_memory_observed_during_execution") - time.sleep(min(capabilities["sample_interval_seconds"] + 2, 60)) - query = {"start": record["start"], "end": int(time.time()), "max_points": 120} - history = api.get(base.session_path(record) + "/runtime-history", "history.before_restart", query) - base.require(history.get("source") == "durable" and history["coverage"]["sample_count"] >= 2, - "periodic_history_not_persisted") - base.require(bool(history["series"]), "native_resource_history_missing") - base.require(bool(history["token_usage"]), "canonical_usage_history_missing") - record.update(query=query, history=history) - check_isolation(api, foreign, record, query) - record["checks"].append("periodic_cpu_memory_and_usage_history_read") - - -def main(): - parser = argparse.ArgumentParser(description=__doc__) - parser.add_argument("stage", choices=("collect", "verify-restart")) - parser.add_argument("--base-url", required=True) - parser.add_argument("--caller-key-file", type=Path, required=True) - parser.add_argument("--foreign-key-file", type=Path, required=True) - parser.add_argument("--model-config-file", type=Path, required=True) - parser.add_argument("--report-dir", type=Path, required=True) - parser.add_argument("--provider", choices=("docker", "microsandbox"), required=True) - parser.add_argument("--harness", choices=base.HARNESSES, default="codex") - parser.add_argument("--timeout", type=int, default=300) - args = parser.parse_args() - os.umask(0o077) - token, models, secrets = base.settings(args) - foreign_token = base.private_file(args.foreign_key_file).strip() - secrets.append(foreign_token) - origin = base.validate_origin(args.base_url) - base.require(args.report_dir.is_absolute(), "absolute_report_directory_required") - args.report_dir.mkdir(mode=0o700, parents=True, exist_ok=True) - base.require(args.report_dir.stat().st_mode & 0o077 == 0, "report_directory_must_be_private") - path = args.report_dir / "history.json" - record = json.loads(base.private_file(path)) if path.exists() else { - "provider": args.provider, "harness": args.harness, "checks": [], "stages": {}} - base.require(record["provider"] == args.provider and record["harness"] == args.harness, - "report_configuration_mismatch") - base.require(args.stage not in record["stages"], "stage_already_attempted") - stage = {"passed": False, "requests": []} - record["stages"][args.stage] = stage - - def save(): - encoded = json.dumps(record, indent=2) + "\n" - base.require(not any(secret in encoded for secret in secrets), "secret_in_report") - path.write_text(encoded) - path.chmod(0o600) - - api = SafeAPI(origin, token, stage["requests"], secrets) - foreign = SafeAPI(origin, foreign_token, stage["requests"], secrets) - try: - if args.stage == "collect": - collect(api, foreign, record, models[args.harness], args, save) - else: - base.require(record["stages"].get("collect", {}).get("passed"), "collection_did_not_pass") - history = api.get(base.session_path(record) + "/runtime-history", "history.after_restart", record["query"]) - base.require(stable_history(history) == stable_history(record["history"]), "history_changed_across_restart") - record["after_restart_history"] = history - check_isolation(api, foreign, record, record["query"]) - record["checks"].append("fixed_range_history_survives_operator_core_restart_and_new_reader") - stage["passed"] = True - except Exception as error: - stage["failure"] = str(error) if isinstance(error, base.Failure) else "unexpected_local_or_response_error" - stage["cleanup"] = base.settle_failed_work(api, record) - save() - print(args.provider + " " + args.stage + (" passed" if stage["passed"] else " FAILED: " + stage["failure"])) - return 0 if stage["passed"] else 1 - - -if __name__ == "__main__": - try: - raise SystemExit(main()) - except Exception: - raise SystemExit("Acceptance configuration failed; private values withheld.") from None From 8724394b6c44ad0f64522b816b953eedf5b89c21 Mon Sep 17 00:00:00 2001 From: SaladDay <1203511142@qq.com> Date: Fri, 25 Sep 2026 13:13:36 +0000 Subject: [PATCH 02/30] Test the administrator Runtime observation list Port the ordering, concurrency-limit and whole-page failure tests of the removed /v1 list to GET /core/v1/admin/runtime-observations, including its Project labels and tenant binding. --- .../internal/api/admin_runtime_test.go | 144 ++++++++++++++++++ 1 file changed, 144 insertions(+) create mode 100644 services/agents-api/internal/api/admin_runtime_test.go diff --git a/services/agents-api/internal/api/admin_runtime_test.go b/services/agents-api/internal/api/admin_runtime_test.go new file mode 100644 index 000000000..e2ba712ce --- /dev/null +++ b/services/agents-api/internal/api/admin_runtime_test.go @@ -0,0 +1,144 @@ +package api + +import ( + "context" + "encoding/json" + "errors" + "net/http" + "slices" + "testing" + "time" + + "github.com/MiniMax-AI-Dev/parsar/internal/agentdaemon/device" + "github.com/MiniMax-AI-Dev/parsar/services/agents-api/internal/runtimeobs" + "github.com/MiniMax-AI-Dev/parsar/services/agents-api/internal/store" + "github.com/google/uuid" +) + +type adminRuntimeProjects struct { + ProjectAPIKeyStore + projects []store.Project +} + +func (s adminRuntimeProjects) ListProjects(context.Context, string, int, bool) (store.ProjectPage, error) { + return store.ProjectPage{Data: s.projects}, nil +} + +type adminRuntimeTargets struct { + AdminManagementStore + page store.AdminRuntimeTargetPage + tenants []string + after string + limit int + ascending bool +} + +func (s *adminRuntimeTargets) ListAdminRuntimeTargets(_ context.Context, tenants []string, after string, limit int, ascending bool) (store.AdminRuntimeTargetPage, error) { + s.tenants, s.after, s.limit, s.ascending = tenants, after, limit, ascending + return s.page, nil +} + +const adminRuntimeObservationsPath = "/core/v1/admin/runtime-observations" + +// adminRuntimeFixture serves the administrator observation list over one +// Project per tenant and the given Session targets. +func adminRuntimeFixture(t *testing.T, projects []store.Project, targets []store.AdminRuntimeTarget, service RuntimeObservationService) (http.Handler, *adminRuntimeTargets) { + t.Helper() + auth, err := NewAuthenticator([]APIKey{callerBinding()}) + if err != nil { + t.Fatal(err) + } + admin, err := NewDeploymentAuthenticator([]string{device.HashCredential("admin")}) + if err != nil { + t.Fatal(err) + } + management := &adminRuntimeTargets{page: store.AdminRuntimeTargetPage{Data: targets, HasMore: true}} + h, err := NewHandler(&recordingStore{}, auth, "codex", WithProjectAPIKeys(adminRuntimeProjects{projects: projects}, admin), WithAdminManagement(management), WithRuntimeObservations(service)) + if err != nil { + t.Fatal(err) + } + return h, management +} + +func unsupportedObservation(session string, at time.Time) runtimeobs.Observation { + return runtimeobs.Observation{ + Target: runtimeobs.Target{SessionID: session, Mode: runtimeobs.ModeNone}, + Status: runtimeobs.StatusUnsupported, Reason: "runtime_mode_not_observable", ResolvedAt: at, + } +} + +func TestAdminRuntimeObservationListKeepsTargetOrderAndProjects(t *testing.T) { + now := time.Date(2026, 9, 22, 8, 0, 0, 0, time.UTC) + projects := []store.Project{{ID: uuid.NewString(), TenantID: uuid.NewString()}, {ID: uuid.NewString(), TenantID: uuid.NewString()}} + var targets []store.AdminRuntimeTarget + for index := range 3 { + targets = append(targets, store.AdminRuntimeTarget{SessionID: uuid.NewString(), TenantID: projects[index%2].TenantID}) + } + service := runtimeObservationServiceFunc(func(_ context.Context, tenant, session string) (runtimeobs.Observation, error) { + if !slices.Contains(targets, store.AdminRuntimeTarget{SessionID: session, TenantID: tenant}) { + return runtimeobs.Observation{}, errors.New("unexpected tenant") + } + return unsupportedObservation(session, now), nil + }) + handler, management := adminRuntimeFixture(t, projects, targets, service) + + response := runtimeObservationRequest(handler, adminRuntimeObservationsPath+"?after=cursor&limit=3&order=asc") + var page AdminRuntimeObservationList + if response.Code != http.StatusOK || json.Unmarshal(response.Body.Bytes(), &page) != nil { + t.Fatalf("list returned %d: %s", response.Code, response.Body) + } + if !slices.Equal(management.tenants, []string{projects[0].TenantID, projects[1].TenantID}) || management.after != "cursor" || management.limit != 3 || !management.ascending { + t.Fatalf("pagination binding was not preserved: %+v", management) + } + if len(page.Data) != len(targets) || !page.HasMore || page.FirstID == nil || *page.FirstID != targets[0].SessionID || page.LastID == nil || *page.LastID != targets[2].SessionID { + t.Fatalf("invalid page: %s", response.Body) + } + for index, item := range page.Data { + if item.Observation.ID != targets[index].SessionID || item.ProjectID != projects[index%2].ID { + t.Fatalf("concurrent collection reordered or mislabelled the page: %s", response.Body) + } + } +} + +// The page is read in one batch with the shared concurrency and per-source bounds. +func TestAdminRuntimeObservationListBoundsCollection(t *testing.T) { + now := time.Date(2026, 9, 22, 8, 0, 0, 0, time.UTC) + project := store.Project{ID: uuid.NewString(), TenantID: uuid.NewString()} + var targets []store.AdminRuntimeTarget + for range 20 { + targets = append(targets, store.AdminRuntimeTarget{SessionID: uuid.NewString(), TenantID: project.TenantID}) + } + var options runtimeobs.PageOptions + service := runtimeObservationPageRecorder{options: &options, runtimeObservationServiceFunc: func(_ context.Context, _, session string) (runtimeobs.Observation, error) { + return unsupportedObservation(session, now), nil + }} + handler, _ := adminRuntimeFixture(t, []store.Project{project}, targets, service) + if response := runtimeObservationRequest(handler, adminRuntimeObservationsPath+"?limit=20"); response.Code != http.StatusOK { + t.Fatalf("list returned %d: %s", response.Code, response.Body) + } + if options.Concurrency != runtimeObservationConcurrency || options.SourceTimeout != runtimeObservationSourceBudget { + t.Fatalf("collection bounds = %+v", options) + } +} + +func TestAdminRuntimeObservationListRejectsWholePageOnIntegrityFailure(t *testing.T) { + now := time.Date(2026, 9, 22, 8, 0, 0, 0, time.UTC) + project := store.Project{ID: uuid.NewString(), TenantID: uuid.NewString()} + valid, invalid := uuid.NewString(), uuid.NewString() + service := runtimeObservationServiceFunc(func(_ context.Context, _, session string) (runtimeobs.Observation, error) { + if session == invalid { + return runtimeobs.Observation{Target: runtimeobs.Target{Mode: runtimeobs.ModeNone}, Status: runtimeobs.StatusUnsupported, ResolvedAt: now}, nil + } + return unsupportedObservation(session, now), nil + }) + handler, _ := adminRuntimeFixture(t, []store.Project{project}, []store.AdminRuntimeTarget{{SessionID: valid, TenantID: project.TenantID}, {SessionID: invalid, TenantID: project.TenantID}}, service) + + response := runtimeObservationRequest(handler, adminRuntimeObservationsPath+"?limit=2") + var envelope struct { + Data json.RawMessage `json:"data"` + Error map[string]json.RawMessage `json:"error"` + } + if response.Code != http.StatusInternalServerError || json.Unmarshal(response.Body.Bytes(), &envelope) != nil || len(envelope.Error) == 0 || envelope.Data != nil { + t.Fatalf("integrity failure returned %d or leaked a partial page: %s", response.Code, response.Body) + } +} From aab4d6ee5f9eddead420ee5fd7e602b8f35830b8 Mon Sep 17 00:00:00 2001 From: SaladDay <1203511142@qq.com> Date: Fri, 25 Sep 2026 13:43:27 +0000 Subject: [PATCH 03/30] Hold /v1 fields and routes to the pinned SDK The field audit now accepts x_agents_core only on the Agent, saved-Agent and Session definitions, with harness and model_provider as its only members, and compares query parameters with the SDK's query params types, which the extractor now records. A test proves the audit catches sandbox_node_id in the Session or Agent extension and x_agents_core on a Vault, and the extracted operations must equal the pinned route set. The router walk compares the served non-HEAD /v1 routes with the pinned set. Administrator Runtime tests cover the unconfigured 503 and HEAD 405, and the client tests cover the observation page invariants. Docs no longer mention explicit node selection or Project node reads, say when a Session carries agent.x_agents_core, list executor credential issuance in the API index and drop the removed /v1 Runtime reads from the HEAD exceptions. --- CONTRIBUTING.md | 4 +- README.md | 6 +- contracts/agents-api/README.md | 6 +- contracts/agents-api/admin-api.md | 5 +- .../agents-api/execution-configuration.md | 8 +- .../official-semantics-alignment.md | 2 +- contracts/agents-api/upstream-fields.json | 180 ++++++++++++++++++ .../agents-api/v1/upstream_contract_test.go | 83 ++++++-- docs/api/README.md | 1 + .../agents-client/saved-agent-defaults.md | 6 +- .../agents-client/src/admin-client.test.ts | 7 + scripts/extract-agents-api-upstream.py | 28 ++- .../internal/api/admin_runtime_test.go | 32 +++- .../agents-api/internal/api/routing_test.go | 21 ++ 14 files changed, 354 insertions(+), 35 deletions(-) diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index ff3cc139a..a76a387ef 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -827,8 +827,8 @@ The [Hosted Sandbox Manager](services/agents-api/HOSTED-SANDBOX-MANAGER.md) is a deployment-level admin surface, separate from Project credentials. The paired console's administrator token stays on its server. Enrollment credentials authorize initial node configuration reads and registration; durable node credentials authorize -retained configuration reads and node transport. Project keys can read a narrow node -directory and their own Session placement, never global allocations. +retained configuration reads and node transport. Project keys cannot read nodes, +placement or allocations. One execution owner manages local and remote nodes through the same finite Provider protocol. Local opt-in uses the same standalone node installer and diff --git a/README.md b/README.md index d1b313a0b..f444d5e89 100644 --- a/README.md +++ b/README.md @@ -19,9 +19,9 @@ credentials are supplied through the existing write-only API extension. Hosted deployments select E2B cloud or one provider across their own local/remote nodes (Docker or microsandbox). The -Hosted Sandbox Manager shows node health, capacity and Session placement. New -Sessions use automatic placement by default or an explicitly selected node; -existing Sessions retain their node across disconnects and resume. +Hosted Sandbox Manager shows node health, capacity and Session placement. Core +places new Sessions automatically; existing Sessions retain their node across +disconnects and resume. ## Start here diff --git a/contracts/agents-api/README.md b/contracts/agents-api/README.md index 0eff49a4c..5a13cb4f0 100644 --- a/contracts/agents-api/README.md +++ b/contracts/agents-api/README.md @@ -13,8 +13,10 @@ and streaming helpers define the compatibility target. This directory records the boundary; it does not imply that every upstream feature is implemented. `upstream-routes.json` and `upstream-fields.json` are extracted from that SDK by `scripts/extract-agents-api-upstream.py` (run it with the pinned SDK installed). -Contract tests require `openapi.yaml` to have exactly those method and path pairs -and to keep every non-official field inside `x_agents_core`. +Contract tests require `openapi.yaml` and the live router to have exactly those +method and path pairs, every query parameter to be official, and every other +field to sit inside `x_agents_core` on Agents and Sessions, whose only members are +`harness` and `model_provider`. Parsar owns product Agents and Teams. This service owns upstream execution resources, including reusable Agents and protocol subagents. The OpenAI Agents diff --git a/contracts/agents-api/admin-api.md b/contracts/agents-api/admin-api.md index 9de2d1145..fe794b695 100644 --- a/contracts/agents-api/admin-api.md +++ b/contracts/agents-api/admin-api.md @@ -65,8 +65,9 @@ Source File content, Session events/SSE, arbitrary creation/update and execution operations are deliberately absent. Session deletion still requires idle state; management deletion never cancels implicitly. Deleting a Credential does not revoke its provider authorization. Deleting a default Skill version retains the public -constraint. Skill and Artifact downloads and Runtime reads reject HEAD just as the -corresponding project operations do. +constraint. Skill and Artifact downloads reject HEAD like the corresponding project +operations; the Runtime observation (single and list) and Runtime history reads also +reject HEAD with 405, so HEAD never samples a provider or queries telemetry. ## Administrative Session archive diff --git a/contracts/agents-api/execution-configuration.md b/contracts/agents-api/execution-configuration.md index 10811c65d..52056368c 100644 --- a/contracts/agents-api/execution-configuration.md +++ b/contracts/agents-api/execution-configuration.md @@ -2,9 +2,11 @@ These read-only administrator reads describe configuration, not execution health. They require the deployment administrator credential. They never contact a model -provider, start a Turn or wake a sandbox. Applications read a Session's effective -harness from its `agent.x_agents_core`; the former project route -`GET /v1/agents/sessions/{session_id}/execution-configuration` is removed. +provider, start a Turn or wake a sandbox. The former project route +`GET /v1/agents/sessions/{session_id}/execution-configuration` is removed. A +Session read includes `agent.x_agents_core.harness` only when its Agent selected a +harness, inline or saved; Sessions on the deployment default keep the official +Agent shape, and no read returns the provider selection. ## Frozen Session selections diff --git a/contracts/agents-api/official-semantics-alignment.md b/contracts/agents-api/official-semantics-alignment.md index de5347f36..a581097d2 100644 --- a/contracts/agents-api/official-semantics-alignment.md +++ b/contracts/agents-api/official-semantics-alignment.md @@ -806,7 +806,7 @@ ledger records. | --- | --- | --- | | RH1 | `//`, `.` or `..` path segments (HP-17: `R10`, `R11`, `R17`, `R18`) | Served on the canonical path, never redirected. Empty and dot segments resolve with ServeMux semantics and a trailing slash is kept, so `/v1/agents/x/../` still reaches the trailing-slash 404. The former 301 made the pinned SDK resend an update as a GET and drop it. | | RH2 | A percent-encoded unreserved character in the path (HP-18: `R12`) | Decoded before routing, including `%2E` dot segments. The canonical path is built from the request's own path spelling; bytes that are invalid in an escaped path (such as `{`, `"`, a backslash or non-ASCII) are percent-encoded first. Other escapes, such as `%2F`, `%2f`, `%5C` and double encodings, stay encoded and never separate segments. Malformed, missing and foreign IDs keep the single 404. | -| RH3 | HEAD on a GET route (HP-19: `R13`, `R16`) | The GET route runs after the same Beta and authentication checks; 200 with its headers and no body. `Content-Length` is present when Go buffers the whole body (about 2 KiB) and omitted for larger responses. The events stream, the File, Skill, Skill version and Artifact content downloads, the live Environment Files directory list and the Core Runtime observation (single and list) and Runtime history reads answer HEAD with Core's 405 instead, so HEAD never holds a stream open, reads content, samples a provider or queries telemetry. That exclusion is a documented Core difference; official HEAD on those routes is unobserved. | +| RH3 | HEAD on a GET route (HP-19: `R13`, `R16`) | The GET route runs after the same Beta and authentication checks; 200 with its headers and no body. `Content-Length` is present when Go buffers the whole body (about 2 KiB) and omitted for larger responses. The events stream, the File, Skill, Skill version and Artifact content downloads, and the live Environment Files directory list answer HEAD with Core's 405 instead, so HEAD never holds a stream open or reads content. That exclusion is a documented Core difference; official HEAD on those routes is unobserved. | | RH4 | Unsupported method (HP-20: `R04`–`R06`) | Unchanged 405 JSON `unsupported_operation`, now with `Allow` listing the route's methods in the observed order, such as `GET,HEAD,POST,DELETE`. Routes outside the Beta group, such as `/healthz` and executor credentials, and methods chi does not know, such as `FOO`, now use the same JSON 405 instead of chi's empty one; an unknown method is answered before the Beta and authentication checks, as before. | | RH5 | `X-Request-Id` (HP-23) | Every response of the Agents API handler, including 400, 401, 404, 405 and SSE streams, carries a fresh random `req_` plus 32 lowercase hex characters. The ID is attached to the request log context as `request_id` next to the trace carrier. | | RH6 | No or invalid credentials without OpenAI-Beta on a Beta route (HP-05: `A06`, `A11`) | 400 `invalid_beta`: the constant Beta check now precedes authentication. Files, Skills and Core project extensions still ignore the header. | diff --git a/contracts/agents-api/upstream-fields.json b/contracts/agents-api/upstream-fields.json index c095d0531..f1668c995 100644 --- a/contracts/agents-api/upstream-fields.json +++ b/contracts/agents-api/upstream-fields.json @@ -5,118 +5,148 @@ "operations": { "DELETE /agents/environments/templates/{}": { "request": [], + "query": [], "response": [ "beta.agents.environments.environment_template_deleted.EnvironmentTemplateDeleted" ] }, "DELETE /agents/sessions/{}": { "request": [], + "query": [], "response": [ "beta.agent_session_deleted.AgentSessionDeleted" ] }, "DELETE /agents/sessions/{}/artifacts/{}": { "request": [], + "query": [], "response": [ "beta.agents.sessions.session_artifact_deleted.SessionArtifactDeleted" ] }, "DELETE /agents/{}": { "request": [], + "query": [], "response": [ "beta.agent_deleted.AgentDeleted" ] }, "DELETE /files/{}": { "request": [], + "query": [], "response": [ "file_deleted.FileDeleted" ] }, "DELETE /skills/{}": { "request": [], + "query": [], "response": [ "deleted_skill.DeletedSkill" ] }, "DELETE /skills/{}/versions/{}": { "request": [], + "query": [], "response": [ "skills.deleted_skill_version.DeletedSkillVersion" ] }, "DELETE /vaults/{}": { "request": [], + "query": [], "response": [ "beta.agents.vault_deleted.VaultDeleted" ] }, "DELETE /vaults/{}/credentials/{}": { "request": [], + "query": [], "response": [ "beta.agents.vaults.credential_deleted.CredentialDeleted" ] }, "GET /agents": { "request": [], + "query": [ + "beta.agent_list_params.AgentListParams" + ], "response": [ "pagination.SyncCursorPage[beta.agent.Agent]" ] }, "GET /agents/environments/templates": { "request": [], + "query": [ + "beta.agents.environments.template_list_params.TemplateListParams" + ], "response": [ "pagination.SyncCursorPage[beta.agents.environments.environment_template.EnvironmentTemplate]" ] }, "GET /agents/environments/templates/{}": { "request": [], + "query": [], "response": [ "beta.agents.environments.environment_template.EnvironmentTemplate" ] }, "GET /agents/environments/{}": { "request": [], + "query": [], "response": [ "beta.agents.environment_info.EnvironmentInfo" ] }, "GET /agents/environments/{}/files": { "request": [], + "query": [ + "beta.agents.environments.file_list_params.FileListParams" + ], "response": [ "pagination.SyncTokenPage[beta.agents.environments.environment_file.EnvironmentFile]" ] }, "GET /agents/sessions": { "request": [], + "query": [ + "beta.agents.session_list_params.SessionListParams" + ], "response": [ "pagination.SyncCursorPage[beta.agent_session.AgentSession]" ] }, "GET /agents/sessions/{}": { "request": [], + "query": [], "response": [ "beta.agent_session.AgentSession" ] }, "GET /agents/sessions/{}/artifacts": { "request": [], + "query": [ + "beta.agents.sessions.artifact_list_params.ArtifactListParams" + ], "response": [ "pagination.SyncCursorPage[beta.agents.sessions.session_artifact.SessionArtifact]" ] }, "GET /agents/sessions/{}/artifacts/{}": { "request": [], + "query": [], "response": [ "beta.agents.sessions.session_artifact.SessionArtifact" ] }, "GET /agents/sessions/{}/artifacts/{}/content": { "request": [], + "query": [], "response": [] }, "GET /agents/sessions/{}/events": { "request": [], + "query": [], "response": [ "beta.agent_output_command_execution_output_delta_event.AgentOutputCommandExecutionOutputDeltaEvent", "beta.agent_session_created_event.AgentSessionCreatedEvent", @@ -152,132 +182,177 @@ }, "GET /agents/sessions/{}/items": { "request": [], + "query": [ + "beta.agents.sessions.item_list_params.ItemListParams" + ], "response": [ "pagination.SyncCursorPage[beta.agent_close_subagent_call_item.AgentCloseSubagentCallItem | beta.agent_command_execution_item.AgentCommandExecutionItem | beta.agent_create_subagent_call_item.AgentCreateSubagentCallItem | beta.agent_function_call_item.AgentFunctionCallItem | beta.agent_interrupt_subagent_call_item.AgentInterruptSubagentCallItem | beta.agent_mcp_call_item.AgentMcpCallItem | beta.agent_reasoning_item.AgentReasoningItem | beta.agent_resume_subagent_call_item.AgentResumeSubagentCallItem | beta.agent_send_subagent_input_call_item.AgentSendSubagentInputCallItem | beta.agent_session_item.AgentMessageItemResource | beta.agent_session_item.FunctionCallOutputItemResource | beta.agent_session_message.AgentSessionMessage | beta.agent_wait_for_subagents_call_item.AgentWaitForSubagentsCallItem | beta.agent_web_search_call_item.AgentWebSearchCallItem]" ] }, "GET /agents/sessions/{}/subagents": { "request": [], + "query": [ + "beta.agents.sessions.subagent_list_params.SubagentListParams" + ], "response": [ "pagination.SyncCursorPage[beta.subagent.Subagent]" ] }, "GET /agents/sessions/{}/subagents/{}": { "request": [], + "query": [], "response": [ "beta.subagent.Subagent" ] }, "GET /agents/sessions/{}/subagents/{}/items": { "request": [], + "query": [ + "beta.agents.sessions.subagents.item_list_params.ItemListParams" + ], "response": [ "pagination.SyncCursorPage[beta.agent_close_subagent_call_item.AgentCloseSubagentCallItem | beta.agent_command_execution_item.AgentCommandExecutionItem | beta.agent_create_subagent_call_item.AgentCreateSubagentCallItem | beta.agent_function_call_item.AgentFunctionCallItem | beta.agent_interrupt_subagent_call_item.AgentInterruptSubagentCallItem | beta.agent_mcp_call_item.AgentMcpCallItem | beta.agent_reasoning_item.AgentReasoningItem | beta.agent_resume_subagent_call_item.AgentResumeSubagentCallItem | beta.agent_send_subagent_input_call_item.AgentSendSubagentInputCallItem | beta.agent_session_item.AgentMessageItemResource | beta.agent_session_item.FunctionCallOutputItemResource | beta.agent_session_message.AgentSessionMessage | beta.agent_wait_for_subagents_call_item.AgentWaitForSubagentsCallItem | beta.agent_web_search_call_item.AgentWebSearchCallItem]" ] }, "GET /agents/sessions/{}/subagents/{}/turns": { "request": [], + "query": [ + "beta.agents.sessions.subagents.turn_list_params.TurnListParams" + ], "response": [ "pagination.SyncCursorPage[beta.agents.sessions.turn.Turn]" ] }, "GET /agents/sessions/{}/subagents/{}/turns/{}": { "request": [], + "query": [], "response": [ "beta.agents.sessions.turn.Turn" ] }, "GET /agents/sessions/{}/subagents/{}/turns/{}/items": { "request": [], + "query": [ + "beta.agents.sessions.subagents.turns.item_list_params.ItemListParams" + ], "response": [ "pagination.SyncCursorPage[beta.agent_close_subagent_call_item.AgentCloseSubagentCallItem | beta.agent_command_execution_item.AgentCommandExecutionItem | beta.agent_create_subagent_call_item.AgentCreateSubagentCallItem | beta.agent_function_call_item.AgentFunctionCallItem | beta.agent_interrupt_subagent_call_item.AgentInterruptSubagentCallItem | beta.agent_mcp_call_item.AgentMcpCallItem | beta.agent_reasoning_item.AgentReasoningItem | beta.agent_resume_subagent_call_item.AgentResumeSubagentCallItem | beta.agent_send_subagent_input_call_item.AgentSendSubagentInputCallItem | beta.agent_session_item.AgentMessageItemResource | beta.agent_session_item.FunctionCallOutputItemResource | beta.agent_session_message.AgentSessionMessage | beta.agent_wait_for_subagents_call_item.AgentWaitForSubagentsCallItem | beta.agent_web_search_call_item.AgentWebSearchCallItem]" ] }, "GET /agents/sessions/{}/turns": { "request": [], + "query": [ + "beta.agents.sessions.turn_list_params.TurnListParams" + ], "response": [ "pagination.SyncCursorPage[beta.agents.sessions.turn.Turn]" ] }, "GET /agents/sessions/{}/turns/{}": { "request": [], + "query": [], "response": [ "beta.agents.sessions.turn.Turn" ] }, "GET /agents/{}": { "request": [], + "query": [], "response": [ "beta.agent.Agent" ] }, "GET /files": { "request": [], + "query": [ + "file_list_params.FileListParams" + ], "response": [ "pagination.SyncCursorPage[file_object.FileObject]" ] }, "GET /files/{}": { "request": [], + "query": [], "response": [ "file_object.FileObject" ] }, "GET /files/{}/content": { "request": [], + "query": [], "response": [] }, "GET /skills": { "request": [], + "query": [ + "skill_list_params.SkillListParams" + ], "response": [ "pagination.SyncCursorPage[skill.Skill]" ] }, "GET /skills/{}": { "request": [], + "query": [], "response": [ "skill.Skill" ] }, "GET /skills/{}/content": { "request": [], + "query": [], "response": [] }, "GET /skills/{}/versions": { "request": [], + "query": [ + "skills.version_list_params.VersionListParams" + ], "response": [ "pagination.SyncCursorPage[skills.skill_version.SkillVersion]" ] }, "GET /skills/{}/versions/{}": { "request": [], + "query": [], "response": [ "skills.skill_version.SkillVersion" ] }, "GET /skills/{}/versions/{}/content": { "request": [], + "query": [], "response": [] }, "GET /vaults": { "request": [], + "query": [ + "beta.agents.vault_list_params.VaultListParams" + ], "response": [ "pagination.SyncCursorPage[beta.agents.vault.Vault]" ] }, "GET /vaults/{}": { "request": [], + "query": [], "response": [ "beta.agents.vault.Vault" ] }, "GET /vaults/{}/credentials": { "request": [], + "query": [ + "beta.agents.vaults.credential_list_params.CredentialListParams" + ], "response": [ "pagination.SyncCursorPage[beta.agents.vaults.credential.Credential]" ] }, "GET /vaults/{}/credentials/{}": { "request": [], + "query": [], "response": [ "beta.agents.vaults.credential.Credential" ] @@ -286,6 +361,7 @@ "request": [ "beta.agent_create_params.AgentCreateParams" ], + "query": [], "response": [ "beta.agent.Agent" ] @@ -294,6 +370,7 @@ "request": [ "beta.agents.environments.template_create_params.TemplateCreateParams" ], + "query": [], "response": [ "beta.agents.environments.environment_template.EnvironmentTemplate" ] @@ -302,6 +379,7 @@ "request": [ "beta.agents.environments.template_update_params.TemplateUpdateParams" ], + "query": [], "response": [ "beta.agents.environments.environment_template.EnvironmentTemplate" ] @@ -311,6 +389,7 @@ "beta.agents.environments.file_create_params.HostedEnvironmentFileParamFileID", "beta.agents.environments.file_create_params.HostedEnvironmentFileParamInline" ], + "query": [], "response": [ "beta.agents.environments.environment_file.EnvironmentFile" ] @@ -320,6 +399,7 @@ "beta.agents.session_create_params.SessionCreateParamsNonStreaming", "beta.agents.session_create_params.SessionCreateParamsStreaming" ], + "query": [], "response": [ "beta.agent_session.AgentSession" ] @@ -328,6 +408,7 @@ "request": [ "beta.agents.session_update_params.SessionUpdateParams" ], + "query": [], "response": [ "beta.agent_session.AgentSession" ] @@ -336,12 +417,14 @@ "request": [ "beta.agents.sessions.event_create_params.EventCreateParams" ], + "query": [], "response": [] }, "POST /agents/{}": { "request": [ "beta.agent_update_params.AgentUpdateParams" ], + "query": [], "response": [ "beta.agent.Agent" ] @@ -350,6 +433,7 @@ "request": [ "file_create_params.FileCreateParams" ], + "query": [], "response": [ "file_object.FileObject" ] @@ -358,6 +442,7 @@ "request": [ "skill_create_params.SkillCreateParams" ], + "query": [], "response": [ "skill.Skill" ] @@ -366,6 +451,7 @@ "request": [ "skill_update_params.SkillUpdateParams" ], + "query": [], "response": [ "skill.Skill" ] @@ -374,6 +460,7 @@ "request": [ "skills.version_create_params.VersionCreateParams" ], + "query": [], "response": [ "skills.skill_version.SkillVersion" ] @@ -382,6 +469,7 @@ "request": [ "beta.agents.vault_create_params.VaultCreateParams" ], + "query": [], "response": [ "beta.agents.vault.Vault" ] @@ -390,6 +478,7 @@ "request": [ "beta.agents.vaults.credential_create_params.CredentialCreateParams" ], + "query": [], "response": [ "beta.agents.vaults.credential.Credential" ] @@ -398,6 +487,7 @@ "request": [ "beta.agents.vaults.credential_update_params.CredentialUpdateParams" ], + "query": [], "response": [ "beta.agents.vaults.credential.Credential" ] @@ -512,6 +602,11 @@ "turn_id": [], "type": [] }, + "beta.agent_list_params.AgentListParams": { + "after": [], + "limit": [], + "order": [] + }, "beta.agent_mcp_call_item.AgentMcpCallItem": { "arguments": [], "error": [], @@ -1258,6 +1353,12 @@ "path": [], "type": [] }, + "beta.agents.environments.file_list_params.FileListParams": { + "limit": [], + "order": [], + "page": [], + "path": [] + }, "beta.agents.environments.template_create_params.Network": { "access": [], "allowed_domains": [] @@ -1292,6 +1393,11 @@ "beta.hosted_skill_param.HostedSkillParamSkillReference" ] }, + "beta.agents.environments.template_list_params.TemplateListParams": { + "after": [], + "limit": [], + "order": [] + }, "beta.agents.environments.template_update_params.Network": { "access": [], "allowed_domains": [] @@ -1381,9 +1487,21 @@ "stream": [], "vault_ids": [] }, + "beta.agents.session_list_params.SessionListParams": { + "after": [], + "agent_id": [], + "limit": [], + "order": [] + }, "beta.agents.session_update_params.SessionUpdateParams": { "metadata": [] }, + "beta.agents.sessions.artifact_list_params.ArtifactListParams": { + "after": [], + "environment_id": [], + "limit": [], + "order": [] + }, "beta.agents.sessions.event_create_params.EventCreateParams": { "Idempotency-Key": [], "events": [ @@ -1392,6 +1510,11 @@ "beta.agent_session_input_param.SessionInputParamAgentSessionInputToolResult" ] }, + "beta.agents.sessions.item_list_params.ItemListParams": { + "after": [], + "limit": [], + "order": [] + }, "beta.agents.sessions.session_artifact.SessionArtifact": { "created_at": [], "environment_id": [], @@ -1407,6 +1530,30 @@ "id": [], "object": [] }, + "beta.agents.sessions.subagent_list_params.SubagentListParams": { + "after": [], + "limit": [], + "order": [] + }, + "beta.agents.sessions.subagents.item_list_params.ItemListParams": { + "after": [], + "limit": [], + "order": [], + "session_id": [] + }, + "beta.agents.sessions.subagents.turn_list_params.TurnListParams": { + "after": [], + "limit": [], + "order": [], + "session_id": [] + }, + "beta.agents.sessions.subagents.turns.item_list_params.ItemListParams": { + "after": [], + "limit": [], + "order": [], + "session_id": [], + "subagent_id": [] + }, "beta.agents.sessions.turn.Turn": { "agent_id": [], "completed_at": [], @@ -1424,6 +1571,11 @@ "beta.token_usage.TokenUsage" ] }, + "beta.agents.sessions.turn_list_params.TurnListParams": { + "after": [], + "limit": [], + "order": [] + }, "beta.agents.vault.Vault": { "created_at": [], "id": [], @@ -1440,6 +1592,12 @@ "id": [], "object": [] }, + "beta.agents.vault_list_params.VaultListParams": { + "after": [], + "limit": [], + "order": [], + "status": [] + }, "beta.agents.vaults.credential.Credential": { "auth": [ "beta.agents.vaults.credential_auth.VaultCredentialAuthResourceMcpOauth", @@ -1533,6 +1691,12 @@ "id": [], "object": [] }, + "beta.agents.vaults.credential_list_params.CredentialListParams": { + "after": [], + "limit": [], + "order": [], + "status": [] + }, "beta.agents.vaults.credential_update_params.CredentialUpdateParams": { "auth": [ "beta.agents.vaults.credential_auth_rotate_param.RotateVaultCredentialAuthParamMcpOauth", @@ -1980,6 +2144,12 @@ "id": [], "object": [] }, + "file_list_params.FileListParams": { + "after": [], + "limit": [], + "order": [], + "purpose": [] + }, "file_object.FileObject": { "bytes": [], "created_at": [], @@ -2095,6 +2265,11 @@ "skill_create_params.SkillCreateParams": { "files": [] }, + "skill_list_params.SkillListParams": { + "after": [], + "limit": [], + "order": [] + }, "skill_update_params.SkillUpdateParams": { "default_version": [] }, @@ -2116,6 +2291,11 @@ "skills.version_create_params.VersionCreateParams": { "default": [], "files": [] + }, + "skills.version_list_params.VersionListParams": { + "after": [], + "limit": [], + "order": [] } } } diff --git a/contracts/agents-api/v1/upstream_contract_test.go b/contracts/agents-api/v1/upstream_contract_test.go index a2a991a3d..ca853eef0 100644 --- a/contracts/agents-api/v1/upstream_contract_test.go +++ b/contracts/agents-api/v1/upstream_contract_test.go @@ -97,6 +97,18 @@ var fieldPlacementPending = map[string]string{ // do not declare. var listEnvelopeFields = []string{"object", "first_id", "last_id"} +// Core fields live only inside x_agents_core on these Agent and Session +// definitions, and only as these members. +var ( + coreExtensionOwners = []string{"v1.Agent", "v1.InlineAgent", "v1.SavedAgent", "v1.CreateAgentRequest", "v1.UpdateAgentRequest", "v1.Session", "v1.CreateSessionRequest"} + coreExtensionMembers = []string{"harness", "model_provider"} +) + +type upstreamFields struct { + Operations map[string]struct{ Request, Query, Response []string } `json:"operations"` + Types map[string]map[string][]string `json:"types"` +} + type fieldAudit struct { definitions map[string]any types map[string]map[string][]string @@ -104,20 +116,16 @@ type fieldAudit struct { violations map[string][]string } -func TestPublicOpenAPIFieldsAreOfficialOrCoreExtensions(t *testing.T) { - var pinned struct { - Operations map[string]struct{ Request, Response []string } `json:"operations"` - Types map[string]map[string][]string `json:"types"` - } - readContractJSON(t, "upstream-fields.json", &pinned) - operations, definitions := publicOperations(t) +// auditPublicFields returns every public property, query parameter or +// extension member that the pinned SDK types do not have, keyed by schema.field. +func auditPublicFields(operations map[string]map[string]any, definitions map[string]any, pinned upstreamFields) map[string][]string { audit := fieldAudit{definitions: definitions, types: pinned.Types, visited: map[string]bool{}, violations: map[string][]string{}} for route, operation := range operations { official, ok := pinned.Operations[route] if !ok { continue // The route set test reports it. } - form := map[string]any{} + form, query := map[string]any{}, map[string]any{} for _, parameter := range asSlice(operation["parameters"]) { parameter := parameter.(map[string]any) switch parameter["in"] { @@ -125,29 +133,75 @@ func TestPublicOpenAPIFieldsAreOfficialOrCoreExtensions(t *testing.T) { audit.compare(route+" body", parameter["schema"].(map[string]any), official.Request) case "formData": form[parameter["name"].(string)] = map[string]any{} + case "query": + // The SDK sends list parameters with brackets, as status[]. + query[strings.TrimSuffix(parameter["name"].(string), "[]")] = map[string]any{} } } if len(form) != 0 { audit.compare(route+" form", map[string]any{"properties": form}, official.Request) } + if len(query) != 0 { + audit.compare(route+" query", map[string]any{"properties": query}, official.Query) + } for code, response := range asMap(operation["responses"]) { if schema, ok := asMap(response)["schema"].(map[string]any); ok && strings.HasPrefix(code, "2") { audit.compare(route+" response", schema, official.Response) } } } - for field, sources := range audit.violations { + return audit.violations +} + +func TestPublicOpenAPIFieldsAreOfficialOrCoreExtensions(t *testing.T) { + var pinned upstreamFields + readContractJSON(t, "upstream-fields.json", &pinned) + var routes struct { + Routes []string `json:"routes"` + } + readContractJSON(t, "upstream-routes.json", &routes) + var extracted []string + for route := range pinned.Operations { + extracted = append(extracted, route) + } + sort.Strings(extracted) + if !slices.Equal(extracted, routes.Routes) { + t.Fatalf("upstream-fields.json operations %v differ from the pinned routes %v", extracted, routes.Routes) + } + operations, definitions := publicOperations(t) + violations := auditPublicFields(operations, definitions, pinned) + for field, sources := range violations { if _, ok := fieldPlacementPending[field]; !ok { t.Errorf("%s is not an official field of %v; put Core-only fields inside x_agents_core", field, sources) } } for field := range fieldPlacementPending { - if audit.violations[field] == nil { + if violations[field] == nil { t.Errorf("resolved field %s is still listed in fieldPlacementPending", field) } } } +// The audit rejects a Core field outside x_agents_core, a new x_agents_core +// member and x_agents_core on another resource. +func TestPublicFieldAuditRejectsMisplacedCoreFields(t *testing.T) { + var pinned upstreamFields + readContractJSON(t, "upstream-fields.json", &pinned) + for _, test := range []struct { + definition, property, want string + }{ + {"v1.SessionExecutionInput", "sandbox_node_id", "v1.CreateSessionRequest.x_agents_core.sandbox_node_id"}, + {"v1.AgentsCore", "sandbox_node_id", "v1.Agent.x_agents_core.sandbox_node_id"}, + {"v1.Vault", "x_agents_core", "v1.Vault.x_agents_core"}, + } { + operations, definitions := publicOperations(t) + asMap(asMap(definitions[test.definition])["properties"])[test.property] = map[string]any{"type": "string"} + if violations := auditPublicFields(operations, definitions, pinned); violations[test.want] == nil { + t.Errorf("%s.%s was not reported as %s; got %v", test.definition, test.property, test.want, violations) + } + } +} + // compare checks one of our schemas against the union of the official types // that describe the same value, following nested official models. func (a *fieldAudit) compare(name string, schema map[string]any, official []string) { @@ -174,7 +228,14 @@ func (a *fieldAudit) compare(name string, schema map[string]any, official []stri case known && len(children) != 0: sort.Strings(children) a.compare(name+"."+property, value, children) - case known || property == "x_agents_core": + case known: + case property == "x_agents_core" && slices.Contains(coreExtensionOwners, name): + _, extension := a.resolve(name+"."+property, value) + for member := range a.properties(extension) { + if !slices.Contains(coreExtensionMembers, member) { + a.violations[name+".x_agents_core."+member] = coreExtensionMembers + } + } case slices.Contains(listEnvelopeFields, property) && slices.ContainsFunc(official, func(typeName string) bool { return strings.HasPrefix(typeName, "pagination.") }): default: a.violations[name+"."+property] = official diff --git a/docs/api/README.md b/docs/api/README.md index 9ec9fb417..b529bba04 100644 --- a/docs/api/README.md +++ b/docs/api/README.md @@ -7,6 +7,7 @@ routes; its only Core additions are the `x_agents_core` fields described in the | Surface | Caller and credential | Entry point | Reference | | --- | --- | --- | --- | | Public Agents API | Applications; a database-issued Project API key | Direct Core `/v1` | [Public API](public-agent-api.md) | +| Self-hosted Runtime credential issuance | Applications; the Project API key (until it moves under `/core/v1/projects`) | Core `/core/v1/environments/{environment_id}/executor-credentials` | [Executor credentials](../../contracts/agents-api/environment-executor-credentials.md) | | Administrator resources | Web's server or administrative automation; deployment credential | Core `/core/v1/admin` | [Management contract](../../contracts/agents-api/admin-api.md) | | Hosted sandbox administration | Web's server or administrative automation; deployment credential | Core `/core/v1/sandbox` management routes | [Web API](web-management.md#sandbox-administration) | | Console authentication | Browser; local console sign-in and session cookie | Console `/console/auth` | [Web API](web-management.md#browser-to-console) | diff --git a/packages/agents-client/saved-agent-defaults.md b/packages/agents-client/saved-agent-defaults.md index 88662c36f..33a32317f 100644 --- a/packages/agents-client/saved-agent-defaults.md +++ b/packages/agents-client/saved-agent-defaults.md @@ -45,8 +45,10 @@ defers protocol compatibility to Session admission. Existing Sessions retain the configuration snapshots. Session inline `agent.x_agents_core` remains harness-only; one-off provider overrides belong in the Session's top-level `x_agents_core`. -The Session's effective harness is in its `agent.x_agents_core`. Administrators -can inspect the configuration committed for one Session without reading credentials: +A Session read includes `agent.x_agents_core.harness` only when its Agent selected +a harness; Sessions on the deployment default keep the official Agent shape. +Administrators can inspect the configuration committed for one Session, including +the provider selection, without reading credentials: ```ts const frozen = await admin.retrieveSessionExecutionConfiguration(projectId, session.id); diff --git a/packages/agents-client/src/admin-client.test.ts b/packages/agents-client/src/admin-client.test.ts index cd161dffe..90e4ec080 100644 --- a/packages/agents-client/src/admin-client.test.ts +++ b/packages/agents-client/src/admin-client.test.ts @@ -324,6 +324,13 @@ describe("AdminClient project monitoring", () => { const value = { object: "list", data: [{ project_id: projectId, observation }], has_more: false, first_id: sessionId, last_id: sessionId }; expect(await clientWith(value).client.listRuntimeObservations()).toEqual(value); await expect(clientWith({ ...value, data: [{ key_id: keyId, observation }] }).client.listRuntimeObservations()).rejects.toMatchObject({ code: "invalid_admin_response" }); + for (const invalid of [ + { ...value, first_id: resourceId }, + { ...value, data: [value.data[0], value.data[0]] }, + { ...value, data: [], has_more: true, first_id: null, last_id: null }, + ]) { + await expect(clientWith(invalid).client.listRuntimeObservations()).rejects.toMatchObject({ code: "invalid_admin_response" }); + } }); it("projects E2B utilization and disk on global Runtime observations", async () => { diff --git a/scripts/extract-agents-api-upstream.py b/scripts/extract-agents-api-upstream.py index a0a687c22..b35c3bd34 100644 --- a/scripts/extract-agents-api-upstream.py +++ b/scripts/extract-agents-api-upstream.py @@ -15,7 +15,8 @@ Routes come from each synchronous resource method's ``self._get``/``_post``/ ``_delete``/``_get_api_list`` call, with path parameters normalised to ``{}``. -Field names come from the request params TypedDicts (``body=maybe_transform``) +Field names come from the request params TypedDicts (``body=maybe_transform``), +the query params TypedDicts (``make_request_options(query=maybe_transform(...))``) and the response models (``cast_to`` or ``page``) of the same calls, followed through nested models, TypedDicts, lists and unions. JSON names use the SDK's wire alias. Page wrappers are the SDK's hand-written classes, not generated @@ -109,10 +110,19 @@ def fields(value): return {field.alias or name: field.annotation for name, field in value.model_fields.items()} -def request_types(call, namespace): - body = next((keyword.value for keyword in call.keywords if keyword.arg == "body"), None) +def keyword_value(call, name): + return next((keyword.value for keyword in call.keywords if keyword.arg == name), None) + + +def request_types(call, namespace, name="body"): + """Return the params types transformed into the call's body, or its query.""" + value = keyword_value(call, "body") if name == "body" else None + if name == "query": + options = keyword_value(call, "options") + if isinstance(options, ast.Call): + value = keyword_value(options, "query") found = [] - for node in ast.walk(body) if body is not None else []: + for node in ast.walk(value) if value is not None else []: if isinstance(node, ast.Call) and getattr(node.func, "id", "").endswith("maybe_transform") and len(node.args) == 2: for candidate in ast.walk(node.args[1]): if isinstance(candidate, ast.Attribute): @@ -144,8 +154,10 @@ def operations(): and getattr(call.func.value, "id", None) == "self"): continue route = f"{HTTP_CALLS[call.func.attr]} {literal_path(call.args[0])}" - entry = found.setdefault(route, {"request": [], "response": []}) - for key, values in (("request", request_types(call, vars(module))), ("response", response_types(call, vars(module)))): + entry = found.setdefault(route, {"request": [], "query": [], "response": []}) + namespace = vars(module) + for key, values in (("request", request_types(call, namespace)), ("query", request_types(call, namespace, "query")), + ("response", response_types(call, namespace))): entry[key].extend(value for value in values if value not in entry[key]) return found @@ -158,7 +170,7 @@ def main(): sys.exit(f"installed openai {openai.__version__} does not match pinned {pin['sdk_version']}") found = operations() header = {"sdk_version": pin["sdk_version"], "commit": pin["commit"], "generator": "scripts/extract-agents-api-upstream.py"} - types, pending = {}, [value for entry in found.values() for key in ("request", "response") for value in entry[key]] + types, pending = {}, [value for entry in found.values() for key in ("request", "query", "response") for value in entry[key]] while pending: value = pending.pop() if type_id(value) in types: @@ -172,7 +184,7 @@ def main(): output = Path(sys.argv[2]) routes = {**header, "resources": list(RESOURCE_PACKAGES), "routes": sorted(found)} (output / "upstream-routes.json").write_text(json.dumps(routes, indent=2) + "\n", encoding="utf-8") - operation_types = {route: {key: sorted(type_id(value) for value in entry[key]) for key in ("request", "response")} for route, entry in sorted(found.items())} + operation_types = {route: {key: sorted(type_id(value) for value in entry[key]) for key in ("request", "query", "response")} for route, entry in sorted(found.items())} document = {**header, "operations": operation_types, "types": dict(sorted(types.items()))} (output / "upstream-fields.json").write_text(json.dumps(document, indent=2) + "\n", encoding="utf-8") diff --git a/services/agents-api/internal/api/admin_runtime_test.go b/services/agents-api/internal/api/admin_runtime_test.go index e2ba712ce..07f872678 100644 --- a/services/agents-api/internal/api/admin_runtime_test.go +++ b/services/agents-api/internal/api/admin_runtime_test.go @@ -53,13 +53,43 @@ func adminRuntimeFixture(t *testing.T, projects []store.Project, targets []store t.Fatal(err) } management := &adminRuntimeTargets{page: store.AdminRuntimeTargetPage{Data: targets, HasMore: true}} - h, err := NewHandler(&recordingStore{}, auth, "codex", WithProjectAPIKeys(adminRuntimeProjects{projects: projects}, admin), WithAdminManagement(management), WithRuntimeObservations(service)) + options := []Option{WithProjectAPIKeys(adminRuntimeProjects{ProjectAPIKeyStore: managementProjectStore(callerBinding()), projects: projects}, admin), WithAdminManagement(management)} + if service != nil { + options = append(options, WithRuntimeObservations(service)) + } + h, err := NewHandler(&recordingStore{}, auth, "codex", options...) if err != nil { t.Fatal(err) } return h, management } +func TestAdminRuntimeObservationListRequiresConfiguredService(t *testing.T) { + handler, _ := adminRuntimeFixture(t, nil, nil, nil) + if response := runtimeObservationRequest(handler, adminRuntimeObservationsPath); response.Code != http.StatusServiceUnavailable { + t.Fatalf("unconfigured service returned %d: %s", response.Code, response.Body) + } +} + +// HEAD never samples Runtime or queries history on the administrator routes. +func TestAdminRuntimeRoutesRejectHead(t *testing.T) { + calls := 0 + service := runtimeObservationServiceFunc(func(context.Context, string, string) (runtimeobs.Observation, error) { + calls++ + return runtimeobs.Observation{}, errors.New("sampled") + }) + handler, _ := adminRuntimeFixture(t, nil, nil, service) + for _, path := range []string{adminRuntimeObservationsPath, adminSessionsPath + uuid.NewString() + "/runtime-observation", adminSessionsPath + uuid.NewString() + "/runtime-history?start=1&end=2"} { + response := serve(handler, http.MethodHead, path, "", withHeaders([]string{"Authorization", "Bearer admin"})) + if response.Code != http.StatusMethodNotAllowed || response.Header().Get("Allow") != "GET" || response.Header().Get("Content-Type") != "application/json" { + t.Errorf("HEAD %s = %d %q", path, response.Code, response.Header().Get("Allow")) + } + } + if calls != 0 { + t.Fatal("HEAD sampled a Runtime") + } +} + func unsupportedObservation(session string, at time.Time) runtimeobs.Observation { return runtimeobs.Observation{ Target: runtimeobs.Target{SessionID: session, Mode: runtimeobs.ModeNone}, diff --git a/services/agents-api/internal/api/routing_test.go b/services/agents-api/internal/api/routing_test.go index 64eafb3c4..d58867412 100644 --- a/services/agents-api/internal/api/routing_test.go +++ b/services/agents-api/internal/api/routing_test.go @@ -10,6 +10,7 @@ import ( "net/http" "net/http/httptest" "net/url" + "os" "regexp" "slices" "strconv" @@ -356,6 +357,26 @@ func TestEveryRouteAuthenticatesItsCanonicalPath(t *testing.T) { t.Errorf("self-authenticated route %s is no longer registered", route) } } + // /v1 serves exactly the pinned official method and path set. + var pinned struct { + Routes []string `json:"routes"` + } + raw, err := os.ReadFile("../../../../contracts/agents-api/upstream-routes.json") + if err != nil || json.Unmarshal(raw, &pinned) != nil || len(pinned.Routes) == 0 { + t.Fatal("read pinned routes", err) + } + served := []string{} + for route := range walked { + method, path, _ := strings.Cut(route, " ") + if method != http.MethodHead && strings.HasPrefix(path, "/v1/") { + served = append(served, method+" "+regexp.MustCompile(`\{[^}]*\}`).ReplaceAllString(strings.TrimPrefix(path, "/v1"), "{}")) + } + } + slices.Sort(served) + slices.Sort(pinned.Routes) + if !slices.Equal(served, pinned.Routes) { + t.Errorf("/v1 routes differ from the pinned set:\nserved %v\npinned %v", served, pinned.Routes) + } if routes < 73 || len(s.lookups) != 0 || len(s.updates) != 0 { t.Fatalf("walked %d routes; store lookups %v updates %v", routes, s.lookups, s.updates) } From 47e0e9e4eee3f97fa9ecdbc988fa5122978f8437 Mon Sep 17 00:00:00 2001 From: SaladDay <1203511142@qq.com> Date: Fri, 25 Sep 2026 14:09:50 +0000 Subject: [PATCH 04/30] Remove the startup configuration and history capability reads left without a route P1 removed their /core/v1/admin routes and P4 removed their /v1 routes, so after the merge the handlers, Core types, server composition, client projections and tests had no caller. --- contracts/agents-api/v1/runtime_history.go | 13 --- .../agents-api/v1/runtime_history_test.go | 19 ---- .../agents-api/v1/startup_configuration.go | 35 ------ packages/agents-client/src/client.ts | 75 ------------- .../src/runtime-history-projection.ts | 59 ---------- .../agents-client/src/runtime-history.test.ts | 38 ------- .../src/startup-configuration.test.ts | 101 ------------------ packages/agents-client/src/types.ts | 42 -------- .../cmd/server/execution_options.go | 46 ++------ .../cmd/server/execution_options_test.go | 48 --------- services/agents-api/cmd/server/main.go | 7 +- .../agents-api/cmd/server/managed_runtimes.go | 10 -- .../cmd/server/startup_configuration.go | 33 ------ .../cmd/server/startup_configuration_test.go | 54 ---------- services/agents-api/internal/api/handler.go | 1 - .../internal/api/runtime_history.go | 48 --------- .../internal/api/runtime_history_test.go | 26 ----- .../internal/api/startup_configuration.go | 39 ------- .../api/startup_configuration_test.go | 80 -------------- 19 files changed, 7 insertions(+), 767 deletions(-) delete mode 100644 contracts/agents-api/v1/startup_configuration.go delete mode 100644 packages/agents-client/src/startup-configuration.test.ts delete mode 100644 services/agents-api/cmd/server/managed_runtimes.go delete mode 100644 services/agents-api/cmd/server/startup_configuration.go delete mode 100644 services/agents-api/cmd/server/startup_configuration_test.go delete mode 100644 services/agents-api/internal/api/startup_configuration.go delete mode 100644 services/agents-api/internal/api/startup_configuration_test.go diff --git a/contracts/agents-api/v1/runtime_history.go b/contracts/agents-api/v1/runtime_history.go index 9b80df950..9274bb485 100644 --- a/contracts/agents-api/v1/runtime_history.go +++ b/contracts/agents-api/v1/runtime_history.go @@ -1,18 +1,5 @@ package v1 -type RuntimeHistoryCapabilities struct { - Object string `json:"object" enums:"agent.runtime_history_capabilities" binding:"required"` - Available bool `json:"available" binding:"required"` - Reason *string `json:"reason" extensions:"x-nullable" binding:"required" enums:"not_configured,periodic_collection_required"` - CollectionMode *string `json:"collection_mode" extensions:"x-nullable" binding:"required" enums:"on_read,periodic"` - SampleIntervalSeconds *int64 `json:"sample_interval_seconds" extensions:"x-nullable" binding:"required" minimum:"1" maximum:"9007199254740991"` - RetentionSeconds *int64 `json:"retention_seconds" extensions:"x-nullable" binding:"required" minimum:"1" maximum:"9007199254740991"` - MinimumStepSeconds *int64 `json:"minimum_step_seconds" extensions:"x-nullable" binding:"required" minimum:"1" maximum:"9007199254740991"` - MaximumRangeSeconds *int64 `json:"maximum_range_seconds" extensions:"x-nullable" binding:"required" minimum:"1" maximum:"9007199254740991"` - MaximumPoints *int `json:"maximum_points" extensions:"x-nullable" binding:"required" minimum:"2" maximum:"10000"` - Metrics []string `json:"metrics" binding:"required" validate:"max=3" enums:"cpu,memory,tokens"` -} - type RuntimeHistory struct { Object string `json:"object" enums:"agent.runtime_history" binding:"required"` Source string `json:"source" enums:"durable" binding:"required"` diff --git a/contracts/agents-api/v1/runtime_history_test.go b/contracts/agents-api/v1/runtime_history_test.go index 72848f4be..ef223d239 100644 --- a/contracts/agents-api/v1/runtime_history_test.go +++ b/contracts/agents-api/v1/runtime_history_test.go @@ -22,14 +22,9 @@ func TestRuntimeHistoryOpenAPICollectionLimits(t *testing.T) { } definitions := openAPIMap(t, document, "definitions") - assertOpenAPIArrayLimit(t, definitions, "v1.RuntimeHistoryCapabilities", "metrics", 3) assertOpenAPIArrayLimit(t, definitions, "v1.RuntimeHistory", "series", 1000) assertOpenAPIArrayLimit(t, definitions, "v1.RuntimeHistoryCoverage", "buckets", 10000) assertOpenAPIArrayLimit(t, definitions, "v1.RuntimeHistorySeries", "points", 10000) - capabilities := openAPIMap(t, definitions, "v1.RuntimeHistoryCapabilities") - metrics := openAPIMap(t, openAPIMap(t, capabilities, "properties"), "metrics") - metricItems := openAPIMap(t, metrics, "items") - assertOpenAPIStrings(t, metricItems, "enum", []string{"cpu", "memory", "tokens"}) series := openAPIMap(t, definitions, "v1.RuntimeHistorySeries") providerType := openAPIMap(t, openAPIMap(t, series, "properties"), "provider_type") if actual, ok := providerType["pattern"].(string); !ok || actual != "^[a-z][a-z0-9_]{0,31}$" { @@ -41,20 +36,6 @@ func TestRuntimeHistoryOpenAPICollectionLimits(t *testing.T) { } } -func assertOpenAPIStrings(t *testing.T, parent map[string]any, key string, expected []string) { - t.Helper() - - values, ok := parent[key].([]any) - if !ok || len(values) != len(expected) { - t.Fatalf("OpenAPI key %q = %#v, want %v", key, parent[key], expected) - } - for index, expectedValue := range expected { - if actual, ok := values[index].(string); !ok || actual != expectedValue { - t.Fatalf("OpenAPI key %q item %d = %#v, want %q", key, index, values[index], expectedValue) - } - } -} - func assertOpenAPIArrayLimit(t *testing.T, definitions map[string]any, definition, property string, expected int) { t.Helper() diff --git a/contracts/agents-api/v1/startup_configuration.go b/contracts/agents-api/v1/startup_configuration.go deleted file mode 100644 index 6036bbbc4..000000000 --- a/contracts/agents-api/v1/startup_configuration.go +++ /dev/null @@ -1,35 +0,0 @@ -package v1 - -// CoreStartupConfiguration is a secret-free snapshot of the validated process -// configuration. It never describes a Session, Environment or live Runtime. -type CoreStartupConfiguration struct { - Object string `json:"object" binding:"required" enums:"agents.core.startup_configuration"` - SchemaVersion int `json:"schema_version" binding:"required" enums:"1"` - Supported CoreSupportedConfiguration `json:"supported" binding:"required"` - Configured CoreConfiguredStartupConfiguration `json:"configured" binding:"required"` -} - -type CoreSupportedConfiguration struct { - Harnesses []string `json:"harnesses" binding:"required" enums:"claude_sdk,codex,mcode"` - ManagedSandboxProviders []string `json:"managed_sandbox_providers" binding:"required" enums:"docker,microsandbox"` -} - -type CoreConfiguredStartupConfiguration struct { - DefaultHarness string `json:"default_harness" binding:"required" enums:"codex,claude_sdk,mcode"` - EnabledHarnesses []string `json:"enabled_harnesses" binding:"required" enums:"claude_sdk,codex,mcode"` - DaemonGateway bool `json:"daemon_gateway" binding:"required"` - SelfHosted bool `json:"self_hosted" binding:"required"` - ManagedSandbox CoreManagedSandboxConfiguration `json:"managed_sandbox" binding:"required"` - ModelProviders []CoreHarnessModelProviderConfiguration `json:"model_providers" binding:"required"` -} - -type CoreManagedSandboxConfiguration struct { - Enabled bool `json:"enabled" binding:"required"` - Provider *string `json:"provider" binding:"required" enums:"docker,microsandbox" extensions:"x-nullable"` - Maintenance bool `json:"maintenance" binding:"required"` -} - -type CoreHarnessModelProviderConfiguration struct { - Harness string `json:"harness" binding:"required" enums:"codex,claude_sdk,mcode"` - EndpointConfigured bool `json:"endpoint_configured" binding:"required"` -} diff --git a/packages/agents-client/src/client.ts b/packages/agents-client/src/client.ts index 3fec5a201..8515168f7 100644 --- a/packages/agents-client/src/client.ts +++ b/packages/agents-client/src/client.ts @@ -49,9 +49,7 @@ import type { CreateAgentInput, CreateSessionInput, CreateSessionStreamOptions, - CoreStartupConfiguration, CoreHarnessKind, - CoreManagedSandboxProvider, FunctionResultContent, FunctionResultInput, InputMessage, @@ -169,85 +167,12 @@ function trimTrailingSlash(value: string): string { return value.replace(/\/+$/, ""); } -const startupConfigurationFields = new Set(["object", "schema_version", "supported", "configured"]); -const startupSupportedFields = new Set(["harnesses", "managed_sandbox_providers"]); -const startupConfiguredFields = new Set(["default_harness", "enabled_harnesses", "daemon_gateway", "self_hosted", "managed_sandbox", "model_providers"]); -const startupManagedSandboxFields = new Set(["enabled", "provider", "maintenance"]); -const startupModelProviderFields = new Set(["harness", "endpoint_configured"]); const harnessKinds = new Set(["claude_sdk", "codex", "mcode"]); -const sandboxProviders = new Set(["docker", "microsandbox"]); function isHarnessKind(value: unknown): value is CoreHarnessKind { return typeof value === "string" && harnessKinds.has(value as CoreHarnessKind); } -function isSandboxProvider(value: unknown): value is CoreManagedSandboxProvider { - return typeof value === "string" && sandboxProviders.has(value as CoreManagedSandboxProvider); -} - -function sortedUnique(value: unknown, accept: (entry: unknown) => entry is T): value is T[] { - return Array.isArray(value) && value.every(accept) && new Set(value).size === value.length && - value.every((entry, index) => index === 0 || value[index - 1]! < entry); -} - -function invalidStartupConfiguration(): never { - throw new AgentCoreError("Agent Core returned an invalid startup configuration.", 502, "invalid_startup_configuration"); -} - -function projectStartupConfiguration(value: unknown): CoreStartupConfiguration { - if (!isRecord(value) || !exactFields(value, startupConfigurationFields) || value.object !== "agents.core.startup_configuration" || value.schema_version !== 1 || - !isRecord(value.supported) || !exactFields(value.supported, startupSupportedFields) || - !sortedUnique(value.supported.harnesses, isHarnessKind) || !sortedUnique(value.supported.managed_sandbox_providers, isSandboxProvider) || - !isRecord(value.configured) || !exactFields(value.configured, startupConfiguredFields) || - !isHarnessKind(value.configured.default_harness) || !sortedUnique(value.configured.enabled_harnesses, isHarnessKind) || typeof value.configured.daemon_gateway !== "boolean" || - typeof value.configured.self_hosted !== "boolean" || value.configured.self_hosted !== value.configured.daemon_gateway || - !isRecord(value.configured.managed_sandbox) || !exactFields(value.configured.managed_sandbox, startupManagedSandboxFields) || - typeof value.configured.managed_sandbox.enabled !== "boolean" || typeof value.configured.managed_sandbox.maintenance !== "boolean" || - !Array.isArray(value.configured.model_providers)) { - return invalidStartupConfiguration(); - } - const configured = value.configured; - const managed = configured.managed_sandbox as Record; - const supportedHarnesses = value.supported.harnesses as CoreHarnessKind[]; - const supportedSandboxProviders = value.supported.managed_sandbox_providers as CoreManagedSandboxProvider[]; - const enabledHarnesses = configured.enabled_harnesses as CoreHarnessKind[]; - if (configured.daemon_gateway - ? !enabledHarnesses.includes(configured.default_harness as CoreHarnessKind) - : enabledHarnesses.length !== 0) { - return invalidStartupConfiguration(); - } - if (managed.enabled - ? !isSandboxProvider(managed.provider) || !supportedSandboxProviders.includes(managed.provider) || !configured.daemon_gateway - : managed.provider !== null || managed.maintenance) { - return invalidStartupConfiguration(); - } - if (enabledHarnesses.some((harness) => !supportedHarnesses.includes(harness))) { - return invalidStartupConfiguration(); - } - const modelProviders = configured.model_providers as unknown[]; - if (modelProviders.length !== enabledHarnesses.length || modelProviders.some((entry, index) => - !isRecord(entry) || !exactFields(entry, startupModelProviderFields) || entry.harness !== enabledHarnesses[index] || typeof entry.endpoint_configured !== "boolean")) { - return invalidStartupConfiguration(); - } - const projectedProviders = modelProviders as Array>; - return { - object: "agents.core.startup_configuration", - schema_version: 1, - supported: { - harnesses: [...supportedHarnesses], - managed_sandbox_providers: [...supportedSandboxProviders], - }, - configured: { - default_harness: configured.default_harness as CoreHarnessKind, - enabled_harnesses: [...enabledHarnesses], - daemon_gateway: configured.daemon_gateway as boolean, - self_hosted: configured.self_hosted as boolean, - managed_sandbox: { enabled: managed.enabled as boolean, provider: managed.provider as CoreManagedSandboxProvider | null, maintenance: managed.maintenance as boolean }, - model_providers: projectedProviders.map((entry) => ({ harness: entry.harness as CoreHarnessKind, endpoint_configured: entry.endpoint_configured as boolean })), - }, - }; -} - export function createIdempotencyKey(): string { if (typeof crypto !== "undefined" && "randomUUID" in crypto) { return crypto.randomUUID(); diff --git a/packages/agents-client/src/runtime-history-projection.ts b/packages/agents-client/src/runtime-history-projection.ts index b88af1e33..82dfea325 100644 --- a/packages/agents-client/src/runtime-history-projection.ts +++ b/packages/agents-client/src/runtime-history-projection.ts @@ -2,7 +2,6 @@ import { canonicalUuid, exactFields, isNonnegativeInteger, isRecord, sameResourc import type { RuntimeHistory, RuntimeHistoryCPU, - RuntimeHistoryCapabilities, RuntimeHistoryCoveragePoint, RuntimeHistoryMemory, RuntimeHistoryPoint, @@ -13,10 +12,6 @@ import type { type InvalidRuntimeHistory = (message?: string) => never; -const capabilityFields = new Set([ - "object", "available", "reason", "collection_mode", "sample_interval_seconds", - "retention_seconds", "minimum_step_seconds", "maximum_range_seconds", "maximum_points", "metrics", -]); const historyFields = new Set([ "object", "source", "session_id", "requested_range", "resolution_seconds", "generated_at", "coverage", "series", "token_usage", ]); @@ -34,8 +29,6 @@ const cpuFields = new Set(["contributor_count", "utilization_ratio", "capacity_c const memoryFields = new Set(["contributor_count", "usage_bytes", "limit_bytes"]); const tokenUsageFields = new Set(["start", "end", "sampled_at", "input_tokens", "output_tokens"]); const providerTypePattern = /^[a-z][a-z0-9_]{0,31}$/; -const metrics = new Set(["cpu", "memory", "tokens"]); -const reasons = new Set(["not_configured", "periodic_collection_required"]); const maximumSeries = 1_000; const maximumTotalPoints = 100_000; @@ -67,58 +60,6 @@ function nullablePositiveNumber(value: unknown, invalid: InvalidRuntimeHistory): return projected; } -export function projectRuntimeHistoryCapabilities( - value: unknown, - invalid: InvalidRuntimeHistory, -): RuntimeHistoryCapabilities { - if ( - !isRecord(value) || !exactFields(value, capabilityFields) || - value.object !== "agent.runtime_history_capabilities" || typeof value.available !== "boolean" || - !(value.reason === null || (typeof value.reason === "string" && reasons.has(value.reason))) || - !(value.collection_mode === null || value.collection_mode === "on_read" || value.collection_mode === "periodic") || - !Array.isArray(value.metrics) || value.metrics.some((metric) => typeof metric !== "string" || !metrics.has(metric)) || - new Set(value.metrics).size !== value.metrics.length || value.metrics.length > metrics.size - ) return invalid(); - - const sampleInterval = nullablePositiveInteger(value.sample_interval_seconds, invalid); - const retention = nullablePositiveInteger(value.retention_seconds, invalid); - const minimumStep = nullablePositiveInteger(value.minimum_step_seconds, invalid); - const maximumRange = nullablePositiveInteger(value.maximum_range_seconds, invalid); - const maximumPoints = nullablePositiveInteger(value.maximum_points, invalid); - const disabled = value.reason === "not_configured"; - const onRead = value.reason === "periodic_collection_required"; - if ( - (disabled && ( - value.available || value.collection_mode !== null || sampleInterval !== null || retention !== null || - minimumStep !== null || maximumRange !== null || maximumPoints !== null || value.metrics.length !== 0 - )) || - (onRead && ( - value.available || value.collection_mode !== "on_read" || sampleInterval !== null || retention === null || - minimumStep === null || maximumRange === null || maximumPoints === null || value.metrics.length === 0 - )) || - (value.available && ( - value.reason !== null || value.collection_mode !== "periodic" || sampleInterval === null || retention === null || - minimumStep === null || maximumRange === null || maximumPoints === null || value.metrics.length === 0 - )) || - (!value.available && value.reason === null) || - (retention !== null && maximumRange !== null && maximumRange > retention) || - (maximumPoints !== null && (maximumPoints < 2 || maximumPoints > 10_000)) - ) return invalid(); - - return { - object: "agent.runtime_history_capabilities", - available: value.available, - reason: value.reason as RuntimeHistoryCapabilities["reason"], - collection_mode: value.collection_mode as RuntimeHistoryCapabilities["collection_mode"], - sample_interval_seconds: sampleInterval, - retention_seconds: retention, - minimum_step_seconds: minimumStep, - maximum_range_seconds: maximumRange, - maximum_points: maximumPoints, - metrics: [...value.metrics] as RuntimeHistoryCapabilities["metrics"], - }; -} - function projectCoveragePoint( value: unknown, fields: Set, diff --git a/packages/agents-client/src/runtime-history.test.ts b/packages/agents-client/src/runtime-history.test.ts index 22fc7fad2..783e86406 100644 --- a/packages/agents-client/src/runtime-history.test.ts +++ b/packages/agents-client/src/runtime-history.test.ts @@ -26,22 +26,6 @@ function clientFor(body: unknown, calls: FetchCall[] = []): AdminClient { }); } -function capabilities(overrides: Record = {}): Record { - return { - object: "agent.runtime_history_capabilities", - available: true, - reason: null, - collection_mode: "periodic", - sample_interval_seconds: 30, - retention_seconds: 604800, - minimum_step_seconds: 30, - maximum_range_seconds: 86400, - maximum_points: 1000, - metrics: ["cpu", "memory", "tokens"], - ...overrides, - }; -} - function history(overrides: Record = {}): Record { const first = { start: 1000, @@ -96,12 +80,6 @@ function history(overrides: Record = {}): Record { - it("retrieves strict capabilities without exposing backend details", async () => { - const calls: FetchCall[] = []; - await expect(clientFor(capabilities(), calls).getRuntimeHistoryCapabilities()).resolves.toEqual(capabilities()); - expect(String(calls[0]?.input)).toBe("https://core.example/core/v1/admin/runtime-history/capabilities"); - }); - it("retrieves bounded Session history and preserves observed zeroes", async () => { const calls: FetchCall[] = []; const controller = new AbortController(); @@ -138,22 +116,6 @@ describe("Runtime history client", () => { expect(calls).toHaveLength(0); }); - for (const [name, body] of [ - ["unknown field", capabilities({ backend: "postgres" })], - ["inconsistent availability", capabilities({ available: false })], - ["duplicate metrics", capabilities({ metrics: ["cpu", "cpu"] })], - ["invalid retention", capabilities({ retention_seconds: 60, maximum_range_seconds: 120 })], - ["unsafe maximum", capabilities({ maximum_points: 10001 })], - ["unqualified periodic", capabilities({ sample_interval_seconds: null })], - ] as const) { - it(`rejects ${name} in capabilities`, async () => { - await expect(clientFor(body).getRuntimeHistoryCapabilities()).rejects.toMatchObject({ - status: 502, - code: "invalid_admin_response", - }); - }); - } - for (const [name, mutate] of [ ["unknown field", (value: Record) => { value.tokens = 10; }], ["foreign Session", (value: Record) => { value.session_id = "55555555-5555-4555-8555-555555555555"; }], diff --git a/packages/agents-client/src/startup-configuration.test.ts b/packages/agents-client/src/startup-configuration.test.ts deleted file mode 100644 index 079ad612b..000000000 --- a/packages/agents-client/src/startup-configuration.test.ts +++ /dev/null @@ -1,101 +0,0 @@ -import { describe, expect, it } from "vitest"; - -import { AdminClient } from "./admin-client"; - -function response(body: unknown): Response { - return new Response(JSON.stringify(body), { status: 200, headers: { "Content-Type": "application/json" } }); -} - -function fixture(): Record { - return { - object: "agents.core.startup_configuration", - schema_version: 1, - supported: { - harnesses: ["claude_sdk", "codex", "mcode"], - managed_sandbox_providers: ["docker", "microsandbox"], - }, - configured: { - default_harness: "codex", - enabled_harnesses: ["claude_sdk", "codex"], - daemon_gateway: true, - self_hosted: true, - managed_sandbox: { enabled: true, provider: "docker", maintenance: false }, - model_providers: [ - { harness: "claude_sdk", endpoint_configured: false }, - { harness: "codex", endpoint_configured: true }, - ], - }, - }; -} - -describe("Core startup configuration", () => { - it("reads the exact administrator path and returns a defensive projection", async () => { - const calls: Array<{ input: RequestInfo | URL; init?: RequestInit }> = []; - const body = fixture(); - const client = new AdminClient({ - baseUrl: "https://core.example/core/v1/admin/", - adminToken: "admin-key", - fetch: (async (input: RequestInfo | URL, init?: RequestInit) => { - calls.push({ input, init }); - return response(body); - }) as typeof fetch, - }); - - const abort = new AbortController(); - const result = await client.retrieveStartupConfiguration({ signal: abort.signal }); - (body.supported as { harnesses: string[] }).harnesses[0] = "mutated"; - - expect(String(calls[0]?.input)).toBe("https://core.example/core/v1/admin/startup-configuration"); - expect(calls[0]?.init?.signal).toBe(abort.signal); - expect(new Headers(calls[0]?.init?.headers).get("Authorization")).toBe("Bearer admin-key"); - expect(result.supported.harnesses).toEqual(["claude_sdk", "codex", "mcode"]); - expect(result.configured.managed_sandbox).toEqual({ enabled: true, provider: "docker", maintenance: false }); - }); - - it.each([ - ["removed configuration discovery", (value: any) => { value.configuration_capabilities = {}; }], - ["unknown field", (value: any) => { value.secret = "private"; }], - ["unknown harness", (value: any) => { value.supported.harnesses[0] = "future"; }], - ["unsorted harnesses", (value: any) => { value.supported.harnesses.reverse(); }], - ["enabled harness missing from supported", (value: any) => { value.supported.harnesses = ["codex", "mcode"]; }], - ["default not enabled", (value: any) => { value.configured.enabled_harnesses = ["claude_sdk"]; value.configured.model_providers = [{ harness: "claude_sdk", endpoint_configured: false }]; }], - ["enabled harness without gateway", (value: any) => { - value.configured.daemon_gateway = false; - value.configured.self_hosted = false; - value.configured.managed_sandbox = { enabled: false, provider: null, maintenance: false }; - }], - ["managed provider without gateway", (value: any) => { value.configured.daemon_gateway = false; value.configured.self_hosted = false; }], - ["managed provider missing from supported", (value: any) => { value.supported.managed_sandbox_providers = ["microsandbox"]; }], - ["mismatched provider projection", (value: any) => { value.configured.model_providers[0].harness = "codex"; }], - ])("rejects %s", async (_name, mutate) => { - const body = fixture(); - mutate(body); - const client = new AdminClient({ fetch: (async () => response(body)) as typeof fetch }); - await expect(client.retrieveStartupConfiguration()).rejects.toMatchObject({ - code: "invalid_startup_configuration", - status: 502, - }); - }); - - it("does not reflect rejected private fields in its error", async () => { - const body = fixture() as any; - body.configured.private_endpoint = "https://user:secret@example.test/v1?token=private"; - const client = new AdminClient({ fetch: (async () => response(body)) as typeof fetch }); - - await expect(client.retrieveStartupConfiguration()).rejects.not.toThrow(/user:secret|example\.test|token=private/u); - }); - - it("accepts a fully unconfigured execution surface", async () => { - const body = fixture() as any; - body.configured.daemon_gateway = false; - body.configured.self_hosted = false; - body.configured.managed_sandbox = { enabled: false, provider: null, maintenance: false }; - body.configured.enabled_harnesses = []; - body.configured.model_providers = []; - const client = new AdminClient({ fetch: (async () => response(body)) as typeof fetch }); - - await expect(client.retrieveStartupConfiguration()).resolves.toMatchObject({ - configured: { daemon_gateway: false, enabled_harnesses: [], model_providers: [] }, - }); - }); -}); diff --git a/packages/agents-client/src/types.ts b/packages/agents-client/src/types.ts index 764f782bc..6c8b22c1e 100644 --- a/packages/agents-client/src/types.ts +++ b/packages/agents-client/src/types.ts @@ -1098,23 +1098,6 @@ export type RuntimeObservation = | RuntimeNoneObservation | RuntimeSelfHostedObservation; -export type RuntimeHistoryCollectionMode = "on_read" | "periodic"; -export type RuntimeHistoryCapabilityReason = "not_configured" | "periodic_collection_required"; -export type RuntimeHistoryMetric = "cpu" | "memory" | "tokens"; - -export interface RuntimeHistoryCapabilities { - object: "agent.runtime_history_capabilities"; - available: boolean; - reason: RuntimeHistoryCapabilityReason | null; - collection_mode: RuntimeHistoryCollectionMode | null; - sample_interval_seconds: number | null; - retention_seconds: number | null; - minimum_step_seconds: number | null; - maximum_range_seconds: number | null; - maximum_points: number | null; - metrics: RuntimeHistoryMetric[]; -} - export interface RuntimeHistoryQuery extends ReadOptions { /** Inclusive Unix-second boundary. */ start: number; @@ -1200,7 +1183,6 @@ export interface RuntimeHistory { } export type CoreHarnessKind = "claude_sdk" | "codex" | "mcode"; -export type CoreManagedSandboxProvider = "docker" | "microsandbox"; /** A complete replacement bundle. API keys are write-only. */ export interface ModelProviderInput { @@ -1252,30 +1234,6 @@ export interface SessionExecutionConfiguration { }; } -export interface CoreStartupConfiguration { - object: "agents.core.startup_configuration"; - schema_version: 1; - supported: { - harnesses: CoreHarnessKind[]; - managed_sandbox_providers: CoreManagedSandboxProvider[]; - }; - configured: { - default_harness: CoreHarnessKind; - enabled_harnesses: CoreHarnessKind[]; - daemon_gateway: boolean; - self_hosted: boolean; - managed_sandbox: { - enabled: boolean; - provider: CoreManagedSandboxProvider | null; - maintenance: boolean; - }; - model_providers: Array<{ - harness: CoreHarnessKind; - endpoint_configured: boolean; - }>; - }; -} - export interface AgentCore { listAgents(options?: PageOptions): Promise>; createAgent(input: CreateAgentInput): Promise; diff --git a/services/agents-api/cmd/server/execution_options.go b/services/agents-api/cmd/server/execution_options.go index 734e08168..ba0456846 100644 --- a/services/agents-api/cmd/server/execution_options.go +++ b/services/agents-api/cmd/server/execution_options.go @@ -5,7 +5,6 @@ import ( "encoding/json" "errors" "os" - "strings" "github.com/MiniMax-AI-Dev/parsar/services/agents-api/internal/store" ) @@ -14,22 +13,17 @@ import ( // are never public Session configuration. Hosted creation freezes its model // provider bundle separately in the encrypted Session credential snapshot. func executionOptions() (func(context.Context, store.Session) (map[string]any, error), error) { - resolve, _, err := executionOptionsConfiguration() - return resolve, err -} - -func executionOptionsConfiguration() (func(context.Context, store.Session) (map[string]any, error), map[string]bool, error) { file := os.Getenv("AGENTS_API_EXECUTION_OPTIONS_FILE") if file == "" { - return nil, map[string]bool{}, nil + return nil, nil } raw, err := os.ReadFile(file) if err != nil { - return nil, nil, errors.New("cannot read AGENTS_API_EXECUTION_OPTIONS_FILE") + return nil, errors.New("cannot read AGENTS_API_EXECUTION_OPTIONS_FILE") } var check map[string]any if json.Unmarshal(raw, &check) != nil || check == nil { - return nil, nil, errors.New("execution options must contain a JSON object") + return nil, errors.New("execution options must contain a JSON object") } defaultEngine := os.Getenv("AGENTS_API_ENGINE") if defaultEngine == "" { @@ -39,25 +33,15 @@ func executionOptionsConfiguration() (func(context.Context, store.Session) (map[ if value, exists := check["by_harness"]; exists { encoded, _ := json.Marshal(value) if len(check) != 1 || json.Unmarshal(encoded, &byHarness) != nil || byHarness == nil { - return nil, nil, errors.New("execution by_harness options must be an exclusive object") + return nil, errors.New("execution by_harness options must be an exclusive object") } for _, entry := range byHarness { var object map[string]any if json.Unmarshal(entry, &object) != nil || object == nil { - return nil, nil, errors.New("execution harness options must be objects") + return nil, errors.New("execution harness options must be objects") } } } - configured := make(map[string]bool) - if byHarness != nil { - for harness, entry := range byHarness { - var object map[string]any - _ = json.Unmarshal(entry, &object) - configured[harness] = modelProviderEndpointConfigured(harness, object) - } - } else { - configured[defaultEngine] = modelProviderEndpointConfigured(defaultEngine, check) - } resolve := func(_ context.Context, session store.Session) (map[string]any, error) { selected := raw if byHarness != nil { @@ -74,23 +58,5 @@ func executionOptionsConfiguration() (func(context.Context, store.Session) (map[ err := json.Unmarshal(selected, &options) return options, err } - return resolve, configured, nil -} - -func modelProviderEndpointConfigured(harness string, options map[string]any) bool { - key := map[string]string{"codex": "codex_provider", "claude_sdk": "claude_provider", "mcode": "mcode_provider"}[harness] - provider, ok := options[key].(map[string]any) - if !ok { - return false - } - if harness == "mcode" { - nativeOptions, ok := provider["options"].(map[string]any) - if !ok { - return false - } - baseURL, ok := nativeOptions["baseURL"].(string) - return ok && strings.TrimSpace(baseURL) != "" - } - baseURL, ok := provider["base_url"].(string) - return ok && strings.TrimSpace(baseURL) != "" + return resolve, nil } diff --git a/services/agents-api/cmd/server/execution_options_test.go b/services/agents-api/cmd/server/execution_options_test.go index f996cdbd7..9d7a769b2 100644 --- a/services/agents-api/cmd/server/execution_options_test.go +++ b/services/agents-api/cmd/server/execution_options_test.go @@ -1,7 +1,6 @@ package main import ( - "encoding/json" "os" "path/filepath" "strings" @@ -72,50 +71,3 @@ func TestExecutionOptionsSeparateHarnessCredentials(t *testing.T) { t.Fatal("legacy credentials crossed harness boundary") } } - -func TestExecutionOptionsConfigurationReportsOnlyEndpointPresence(t *testing.T) { - file := filepath.Join(t.TempDir(), "options.json") - t.Setenv("AGENTS_API_EXECUTION_OPTIONS_FILE", file) - t.Setenv("AGENTS_API_ENGINE", "codex") - raw := `{"by_harness":{"codex":{"codex_provider":{"base_url":"https://user:secret@example.test/v1?token=private","bearer_token":"codex-secret"}},"claude_sdk":{"claude_provider":{"base_url":" ","bearer_token":"claude-secret"}},"mcode":{"mcode_provider":{"options":{"baseURL":"https://mcode.example.test","apiKey":"mcode-secret"}}}}}` - if err := os.WriteFile(file, []byte(raw), 0600); err != nil { - t.Fatal(err) - } - _, configured, err := executionOptionsConfiguration() - if err != nil { - t.Fatal(err) - } - if !configured["codex"] || configured["claude_sdk"] || !configured["mcode"] { - t.Fatalf("endpoint presence = %#v", configured) - } - for _, value := range configured { - if value != true && value != false { - t.Fatal("non-boolean projection") - } - } - encoded, err := json.Marshal(coreStartupConfiguration("codex", []string{"claude_sdk", "codex", "mcode"}, true, configured, "", nil)) - if err != nil { - t.Fatal(err) - } - for _, private := range []string{"example.test", "user:secret", "token=private", "codex-secret", "claude-secret", "mcode-secret", `"base_url":"`, `"api_key":`, "baseURL", "apiKey", "bearer_token"} { - if strings.Contains(string(encoded), private) { - t.Fatalf("private execution option %q leaked into startup projection: %s", private, encoded) - } - } -} - -func TestExecutionOptionsConfigurationRejectsLegacyMCodeEndpointShape(t *testing.T) { - file := filepath.Join(t.TempDir(), "options.json") - t.Setenv("AGENTS_API_EXECUTION_OPTIONS_FILE", file) - t.Setenv("AGENTS_API_ENGINE", "mcode") - if err := os.WriteFile(file, []byte(`{"mcode_provider":{"base_url":"https://legacy.example.test","api_key":"private"}}`), 0600); err != nil { - t.Fatal(err) - } - _, configured, err := executionOptionsConfiguration() - if err != nil { - t.Fatal(err) - } - if configured["mcode"] { - t.Fatalf("legacy mcode endpoint shape reported as configured: %#v", configured) - } -} diff --git a/services/agents-api/cmd/server/main.go b/services/agents-api/cmd/server/main.go index c0920d4ee..0a0edf3ba 100644 --- a/services/agents-api/cmd/server/main.go +++ b/services/agents-api/cmd/server/main.go @@ -89,7 +89,7 @@ func run() error { if err != nil { return err } - transientOptions, modelProviderEndpoints, err := executionOptionsConfiguration() + transientOptions, err := executionOptions() if err != nil { return err } @@ -278,11 +278,6 @@ func run() error { metricsDone := make(chan struct{}) go func() { defer close(metricsDone); metrics.Run(metricsCtx) }() defer func() { cancelMetrics(); <-metricsDone }() - startupManaged := managed - if managedNodes != nil && managedNodes.setup != nil { - startupManaged = managedNodes.setup.selected.Load() - } - options = append(options, api.WithStartupConfiguration(coreStartupConfiguration(engine, kinds, registry != nil, modelProviderEndpoints, managedRuntimeProviderKind(startupManaged), startupManaged))) handler, err := api.NewHandler(executionStore, auth, engine, options...) if err != nil { return err diff --git a/services/agents-api/cmd/server/managed_runtimes.go b/services/agents-api/cmd/server/managed_runtimes.go deleted file mode 100644 index bb51bb7da..000000000 --- a/services/agents-api/cmd/server/managed_runtimes.go +++ /dev/null @@ -1,10 +0,0 @@ -package main - -import "github.com/MiniMax-AI-Dev/parsar/services/agents-api/internal/execution" - -func managedRuntimeProviderKind(runtime *execution.RuntimeProvider) string { - if runtime == nil { - return "" - } - return runtime.ProviderKind -} diff --git a/services/agents-api/cmd/server/startup_configuration.go b/services/agents-api/cmd/server/startup_configuration.go deleted file mode 100644 index c9cc93d26..000000000 --- a/services/agents-api/cmd/server/startup_configuration.go +++ /dev/null @@ -1,33 +0,0 @@ -package main - -import ( - v1 "github.com/MiniMax-AI-Dev/parsar/contracts/agents-api/v1" - "github.com/MiniMax-AI-Dev/parsar/services/agents-api/internal/engine" - "github.com/MiniMax-AI-Dev/parsar/services/agents-api/internal/execution" -) - -func coreStartupConfiguration(defaultHarness string, enabledHarnesses []string, daemonGateway bool, endpoints map[string]bool, managedProviderKind string, managed *execution.RuntimeProvider) v1.CoreStartupConfiguration { - if !daemonGateway { - enabledHarnesses = []string{} - managed = nil - } - modelProviders := make([]v1.CoreHarnessModelProviderConfiguration, 0, len(enabledHarnesses)) - for _, kind := range enabledHarnesses { - modelProviders = append(modelProviders, v1.CoreHarnessModelProviderConfiguration{Harness: kind, EndpointConfigured: endpoints[kind]}) - } - managedConfiguration := v1.CoreManagedSandboxConfiguration{} - if managed != nil && managedProviderKind != "" { - provider := managedProviderKind - managedConfiguration = v1.CoreManagedSandboxConfiguration{Enabled: true, Provider: &provider, Maintenance: managed.Maintenance} - } - return v1.CoreStartupConfiguration{ - Object: "agents.core.startup_configuration", SchemaVersion: 1, - Supported: v1.CoreSupportedConfiguration{ - Harnesses: (engine.Catalog{}).Kinds(), ManagedSandboxProviders: []string{"docker", "microsandbox"}, - }, - Configured: v1.CoreConfiguredStartupConfiguration{ - DefaultHarness: defaultHarness, EnabledHarnesses: enabledHarnesses, DaemonGateway: daemonGateway, SelfHosted: daemonGateway, - ManagedSandbox: managedConfiguration, ModelProviders: modelProviders, - }, - } -} diff --git a/services/agents-api/cmd/server/startup_configuration_test.go b/services/agents-api/cmd/server/startup_configuration_test.go deleted file mode 100644 index 9ef04b997..000000000 --- a/services/agents-api/cmd/server/startup_configuration_test.go +++ /dev/null @@ -1,54 +0,0 @@ -package main - -import ( - "reflect" - "testing" - - "github.com/MiniMax-AI-Dev/parsar/services/agents-api/internal/execution" -) - -func TestCoreStartupConfigurationSeparatesSupportAndConfiguration(t *testing.T) { - managed := &execution.RuntimeProvider{Maintenance: true} - got := coreStartupConfiguration("codex", []string{"claude_sdk", "codex"}, true, map[string]bool{"codex": true}, "docker", managed) - if got.Object != "agents.core.startup_configuration" || got.SchemaVersion != 1 || got.Configured.DefaultHarness != "codex" || !got.Configured.DaemonGateway || !got.Configured.SelfHosted { - t.Fatalf("unexpected configuration: %#v", got) - } - if !reflect.DeepEqual(got.Supported.Harnesses, []string{"claude_sdk", "codex", "mcode"}) || !reflect.DeepEqual(got.Supported.ManagedSandboxProviders, []string{"docker", "microsandbox"}) { - t.Fatalf("unexpected supported surface: %#v", got.Supported) - } - if got.Configured.ManagedSandbox.Provider == nil || *got.Configured.ManagedSandbox.Provider != "docker" || !got.Configured.ManagedSandbox.Enabled || !got.Configured.ManagedSandbox.Maintenance { - t.Fatalf("unexpected managed sandbox: %#v", got.Configured.ManagedSandbox) - } - if len(got.Configured.ModelProviders) != 2 || got.Configured.ModelProviders[0].EndpointConfigured || !got.Configured.ModelProviders[1].EndpointConfigured { - t.Fatalf("unexpected provider projection: %#v", got.Configured.ModelProviders) - } -} - -func TestCoreStartupConfigurationWithoutExecution(t *testing.T) { - managed := &execution.RuntimeProvider{Maintenance: true} - got := coreStartupConfiguration("codex", []string{"claude_sdk", "codex"}, false, map[string]bool{"codex": true}, "docker", managed) - if got.Configured.DaemonGateway || got.Configured.SelfHosted || got.Configured.EnabledHarnesses == nil || len(got.Configured.EnabledHarnesses) != 0 || got.Configured.ManagedSandbox.Enabled || got.Configured.ManagedSandbox.Provider != nil || got.Configured.ManagedSandbox.Maintenance || len(got.Configured.ModelProviders) != 0 { - t.Fatalf("unconfigured execution was reported: %#v", got.Configured) - } -} - -func TestCoreStartupConfigurationReportsMicrosandboxSelection(t *testing.T) { - managed := &execution.RuntimeProvider{} - got := coreStartupConfiguration("mcode", []string{"mcode"}, true, map[string]bool{"mcode": true}, "microsandbox", managed) - if got.Configured.ManagedSandbox.Provider == nil || *got.Configured.ManagedSandbox.Provider != "microsandbox" || !got.Configured.ManagedSandbox.Enabled { - t.Fatalf("unexpected managed sandbox: %#v", got.Configured.ManagedSandbox) - } - if len(got.Configured.ModelProviders) != 1 || !got.Configured.ModelProviders[0].EndpointConfigured { - t.Fatalf("unexpected model endpoint projection: %#v", got.Configured.ModelProviders) - } -} - -func TestCoreStartupConfigurationReportsRemoteOnlyProvider(t *testing.T) { - for _, kind := range []string{"docker", "microsandbox"} { - managed := &execution.RuntimeProvider{ProviderKind: kind, Maintenance: true} - got := coreStartupConfiguration("codex", []string{"codex"}, true, nil, managedRuntimeProviderKind(managed), managed) - if got.Configured.ManagedSandbox.Provider == nil || *got.Configured.ManagedSandbox.Provider != kind || !got.Configured.ManagedSandbox.Enabled || !got.Configured.ManagedSandbox.Maintenance { - t.Fatalf("remote-only provider configuration lost: %#v", got.Configured.ManagedSandbox) - } - } -} diff --git a/services/agents-api/internal/api/handler.go b/services/agents-api/internal/api/handler.go index db6b59f36..1b77386e3 100644 --- a/services/agents-api/internal/api/handler.go +++ b/services/agents-api/internal/api/handler.go @@ -64,7 +64,6 @@ type Handler struct { subagents SubagentStore runtimeObservations RuntimeObservationService runtimeHistory RuntimeHistoryService - startup *v1.CoreStartupConfiguration } func NewHandler(s ResourceStore, auth *Authenticator, engine string, options ...Option) (http.Handler, error) { diff --git a/services/agents-api/internal/api/runtime_history.go b/services/agents-api/internal/api/runtime_history.go index 6ab5e1bc1..7cf7d39a6 100644 --- a/services/agents-api/internal/api/runtime_history.go +++ b/services/agents-api/internal/api/runtime_history.go @@ -27,15 +27,6 @@ func WithRuntimeHistory(service RuntimeHistoryService) Option { return func(h *Handler) { h.runtimeHistory = service } } -// getRuntimeHistoryCapabilities serves the administrator capability read. -func (h *Handler) getRuntimeHistoryCapabilities(w http.ResponseWriter, r *http.Request) { - if len(r.URL.Query()) != 0 { - writeError(w, http.StatusBadRequest, "unsupported_parameter", "Runtime history capabilities do not accept query parameters.") - return - } - writeJSON(w, http.StatusOK, runtimeHistoryCapabilitiesResponse(h.runtimeHistory)) -} - // getRuntimeHistory serves the administrator per-Session history read. func (h *Handler) getRuntimeHistory(w http.ResponseWriter, r *http.Request) { if h.runtimeHistory == nil { @@ -108,45 +99,6 @@ func readRuntimeHistoryRange(w http.ResponseWriter, r *http.Request, capabilitie return runtimehistory.Range{Start: time.Unix(start, 0).UTC(), End: time.Unix(end, 0).UTC(), MaxPoints: points}, true } -func runtimeHistoryCapabilitiesResponse(service RuntimeHistoryService) v1.RuntimeHistoryCapabilities { - response := v1.RuntimeHistoryCapabilities{Object: "agent.runtime_history_capabilities", Metrics: []string{}} - if service == nil { - reason := "not_configured" - response.Reason = &reason - return response - } - capabilities := service.Capabilities() - if capabilities.Validate() != nil { - reason := "not_configured" - response.Reason = &reason - return response - } - mode := string(capabilities.CollectionMode) - retention := int64(capabilities.Retention / time.Second) - minimumStep := int64(capabilities.MinimumStep / time.Second) - maximumRange := int64(capabilities.MaximumRange / time.Second) - maximumPoints := capabilities.MaximumPoints - response.CollectionMode = &mode - response.RetentionSeconds = &retention - response.MinimumStepSeconds = &minimumStep - response.MaximumRangeSeconds = &maximumRange - response.MaximumPoints = &maximumPoints - response.Metrics = make([]string, len(capabilities.Metrics)) - for index, metric := range capabilities.Metrics { - response.Metrics[index] = string(metric) - } - if capabilities.SampleInterval > 0 { - seconds := int64(capabilities.SampleInterval / time.Second) - response.SampleIntervalSeconds = &seconds - } - response.Available = capabilities.Durable() - if !response.Available { - reason := "periodic_collection_required" - response.Reason = &reason - } - return response -} - func runtimeHistoryResponse(value runtimehistory.Response, expectedTenantID, expectedSessionID string, expectedRange runtimehistory.Range) (v1.RuntimeHistory, error) { if err := value.Validate(time.Now().UTC()); err != nil || !value.Durable() || value.TenantID != expectedTenantID || value.SessionID != expectedSessionID || diff --git a/services/agents-api/internal/api/runtime_history_test.go b/services/agents-api/internal/api/runtime_history_test.go index c2007cfa9..fa7aa8240 100644 --- a/services/agents-api/internal/api/runtime_history_test.go +++ b/services/agents-api/internal/api/runtime_history_test.go @@ -44,30 +44,9 @@ func historyCapabilities(mode runtimehistory.CollectionMode) runtimehistory.Capa return value } -func TestRuntimeHistoryCapabilitiesAreSafeAndDisabledByDefault(t *testing.T) { - handler, _, _ := adminTestHandler(t) - response := runtimeObservationRequest(handler, "/core/v1/admin/runtime-history/capabilities") - if response.Code != http.StatusOK { - t.Fatalf("capabilities returned %d: %s", response.Code, response.Body) - } - var value v1.RuntimeHistoryCapabilities - if json.Unmarshal(response.Body.Bytes(), &value) != nil || value.Object != "agent.runtime_history_capabilities" || value.Available || value.Reason == nil || *value.Reason != "not_configured" || value.CollectionMode != nil || value.SampleIntervalSeconds != nil || value.RetentionSeconds != nil || value.MaximumPoints != nil || value.Metrics == nil || len(value.Metrics) != 0 { - t.Fatalf("unsafe disabled capabilities: %s", response.Body) - } - invalid := runtimeObservationRequest(handler, "/core/v1/admin/runtime-history/capabilities?backend=clickhouse") - if invalid.Code != http.StatusBadRequest { - t.Fatalf("capability query was accepted: %d %s", invalid.Code, invalid.Body) - } -} - func TestRuntimeHistoryRequiresQualifiedPeriodicCollection(t *testing.T) { service := &runtimeHistoryFixture{capabilities: historyCapabilities(runtimehistory.CollectionOnRead)} handler, _, _ := adminTestHandler(t, WithRuntimeHistory(service)) - capabilityResponse := runtimeObservationRequest(handler, "/core/v1/admin/runtime-history/capabilities") - var capabilities v1.RuntimeHistoryCapabilities - if capabilityResponse.Code != http.StatusOK || json.Unmarshal(capabilityResponse.Body.Bytes(), &capabilities) != nil || capabilities.Available || capabilities.Reason == nil || *capabilities.Reason != "periodic_collection_required" || capabilities.CollectionMode == nil || *capabilities.CollectionMode != "on_read" || capabilities.SampleIntervalSeconds != nil { - t.Fatalf("on-read capability was advertised as durable: %d %s", capabilityResponse.Code, capabilityResponse.Body) - } response := runtimeObservationRequest(handler, adminSessionsPath+uuid.NewString()+"/runtime-history?start=1&end=2") if response.Code != http.StatusServiceUnavailable || service.calls != 0 { t.Fatalf("on-read history reached query service: %d calls=%d body=%s", response.Code, service.calls, response.Body) @@ -78,11 +57,6 @@ func TestRuntimeHistoryFailsClosedForMalformedCapabilities(t *testing.T) { service := &runtimeHistoryFixture{capabilities: historyCapabilities(runtimehistory.CollectionPeriodic)} service.capabilities.Retention = 0 handler, _, _ := adminTestHandler(t, WithRuntimeHistory(service)) - capabilityResponse := runtimeObservationRequest(handler, "/core/v1/admin/runtime-history/capabilities") - var capabilities v1.RuntimeHistoryCapabilities - if capabilityResponse.Code != http.StatusOK || json.Unmarshal(capabilityResponse.Body.Bytes(), &capabilities) != nil || capabilities.Available || capabilities.Reason == nil || *capabilities.Reason != "not_configured" || capabilities.CollectionMode != nil || len(capabilities.Metrics) != 0 { - t.Fatalf("malformed capabilities did not fail closed: %d %s", capabilityResponse.Code, capabilityResponse.Body) - } response := runtimeObservationRequest(handler, adminSessionsPath+uuid.NewString()+"/runtime-history?start=1&end=2") if response.Code != http.StatusServiceUnavailable || service.calls != 0 { t.Fatalf("malformed capabilities reached query service: %d calls=%d body=%s", response.Code, service.calls, response.Body) diff --git a/services/agents-api/internal/api/startup_configuration.go b/services/agents-api/internal/api/startup_configuration.go deleted file mode 100644 index 1b9cddb88..000000000 --- a/services/agents-api/internal/api/startup_configuration.go +++ /dev/null @@ -1,39 +0,0 @@ -package api - -import ( - "net/http" - "slices" - - v1 "github.com/MiniMax-AI-Dev/parsar/contracts/agents-api/v1" -) - -// WithStartupConfiguration exposes a defensive copy of startup facts already -// validated by composition. It must not contain raw operator configuration. -func WithStartupConfiguration(configuration v1.CoreStartupConfiguration) Option { - return func(h *Handler) { - copy := configuration - copy.Supported.Harnesses = slices.Clone(configuration.Supported.Harnesses) - copy.Supported.ManagedSandboxProviders = slices.Clone(configuration.Supported.ManagedSandboxProviders) - copy.Configured.EnabledHarnesses = slices.Clone(configuration.Configured.EnabledHarnesses) - copy.Configured.ModelProviders = slices.Clone(configuration.Configured.ModelProviders) - if configuration.Configured.ManagedSandbox.Provider != nil { - provider := *configuration.Configured.ManagedSandbox.Provider - copy.Configured.ManagedSandbox.Provider = &provider - } - h.startup = © - } -} - -// getStartupConfiguration serves the administrator startup configuration read. -func (h *Handler) getStartupConfiguration(w http.ResponseWriter, r *http.Request) { - if r.URL.RawQuery != "" { - writeError(w, http.StatusBadRequest, "unsupported_parameter", "Core startup configuration does not accept query parameters.") - return - } - if h.startup == nil { - writeError(w, http.StatusServiceUnavailable, "startup_configuration_unavailable", "Core startup configuration is unavailable.") - return - } - w.Header().Set("Cache-Control", "no-store") - writeJSON(w, http.StatusOK, h.startup) -} diff --git a/services/agents-api/internal/api/startup_configuration_test.go b/services/agents-api/internal/api/startup_configuration_test.go deleted file mode 100644 index 8834fc3c5..000000000 --- a/services/agents-api/internal/api/startup_configuration_test.go +++ /dev/null @@ -1,80 +0,0 @@ -package api - -import ( - "encoding/json" - "net/http" - "net/http/httptest" - "strings" - "testing" - - v1 "github.com/MiniMax-AI-Dev/parsar/contracts/agents-api/v1" -) - -func TestStartupConfigurationHTTP(t *testing.T) { - provider := "docker" - configuration := v1.CoreStartupConfiguration{ - Object: "agents.core.startup_configuration", SchemaVersion: 1, - Supported: v1.CoreSupportedConfiguration{Harnesses: []string{"claude_sdk", "codex", "mcode"}, ManagedSandboxProviders: []string{"docker", "microsandbox"}}, - Configured: v1.CoreConfiguredStartupConfiguration{ - DefaultHarness: "codex", EnabledHarnesses: []string{"claude_sdk", "codex"}, DaemonGateway: true, SelfHosted: true, - ManagedSandbox: v1.CoreManagedSandboxConfiguration{Enabled: true, Provider: &provider}, - ModelProviders: []v1.CoreHarnessModelProviderConfiguration{{Harness: "claude_sdk"}, {Harness: "codex", EndpointConfigured: true}}, - }, - } - h, _, _ := adminTestHandler(t, WithStartupConfiguration(configuration)) - configuration.Supported.Harnesses[0] = "mutated" - configuration.Configured.EnabledHarnesses[0] = "mutated" - *configuration.Configured.ManagedSandbox.Provider = "mutated" - - request := httptest.NewRequest(http.MethodGet, "/core/v1/admin/startup-configuration", nil) - request.Header.Set("Authorization", "Bearer admin") - request.Header.Set("OpenAI-Beta", "agents=v1") - w := httptest.NewRecorder() - h.ServeHTTP(w, request) - var response v1.CoreStartupConfiguration - if w.Code != http.StatusOK || json.Unmarshal(w.Body.Bytes(), &response) != nil { - t.Fatalf("response = %d %s", w.Code, w.Body) - } - if response.Object != "agents.core.startup_configuration" || response.SchemaVersion != 1 || response.Configured.DefaultHarness != "codex" || response.Configured.ManagedSandbox.Provider == nil || *response.Configured.ManagedSandbox.Provider != "docker" { - t.Fatalf("unexpected response: %#v", response) - } - if strings.Contains(w.Body.String(), "mutated") || strings.Contains(w.Body.String(), "base_url") || strings.Contains(w.Body.String(), "token") { - t.Fatalf("mutable or private configuration leaked: %s", w.Body) - } - var fields map[string]json.RawMessage - if json.Unmarshal(w.Body.Bytes(), &fields) != nil || len(fields) != 4 || fields["configuration_capabilities"] != nil { - t.Fatal("startup response includes provider discovery") - } - if w.Header().Get("Cache-Control") != "no-store" { - t.Fatalf("Cache-Control = %q", w.Header().Get("Cache-Control")) - } -} - -func TestStartupConfigurationRejectsUnsupportedReads(t *testing.T) { - configured := v1.CoreStartupConfiguration{Object: "agents.core.startup_configuration", SchemaVersion: 1} - for _, test := range []struct { - name string - option []Option - path string - auth string - status int - }{ - {name: "missing auth", option: []Option{WithStartupConfiguration(configured)}, path: "/core/v1/admin/startup-configuration", status: http.StatusUnauthorized}, - {name: "retired discovery", option: []Option{WithStartupConfiguration(configured)}, path: "/core/v1/admin/startup-configuration?include=configuration_capabilities", auth: "Bearer admin", status: http.StatusBadRequest}, - {name: "query", option: []Option{WithStartupConfiguration(configured)}, path: "/core/v1/admin/startup-configuration?raw=true", auth: "Bearer admin", status: http.StatusBadRequest}, - {name: "malformed query", option: []Option{WithStartupConfiguration(configured)}, path: "/core/v1/admin/startup-configuration?raw;private", auth: "Bearer admin", status: http.StatusBadRequest}, - {name: "composition missing", path: "/core/v1/admin/startup-configuration", auth: "Bearer admin", status: http.StatusServiceUnavailable}, - } { - t.Run(test.name, func(t *testing.T) { - h, _, _ := adminTestHandler(t, test.option...) - request := httptest.NewRequest(http.MethodGet, test.path, nil) - request.Header.Set("Authorization", test.auth) - request.Header.Set("OpenAI-Beta", "agents=v1") - w := httptest.NewRecorder() - h.ServeHTTP(w, request) - if w.Code != test.status { - t.Fatalf("response = %d %s", w.Code, w.Body) - } - }) - } -} From 20f4025a9e0ac7454326d559ada241386d51c0e0 Mon Sep 17 00:00:00 2001 From: SaladDay <1203511142@qq.com> Date: Fri, 25 Sep 2026 14:20:39 +0000 Subject: [PATCH 05/30] Serve /core/v1 as one Core-key tree and issue executor credentials there /core/v1/admin/** moves to /core/v1/**: projects, summary, audit-log, metrics (was core-metrics) and sandbox/runtime-observations. One Core-key router serves the whole tree, so every /core/v1 request, including an unknown path, is authenticated before routing, and old /core/v1/admin paths return 404. Executor credentials move from the Project-key route /core/v1/environments/{id}/executor-credentials to /core/v1/projects/{project_id}/environments/{environment_id}/executor-credentials with the Core key: GET lists metadata, POST issues or rotates, DELETE revokes. The target must be a self_hosted Environment of the Project whose Session exists. The Project's principal remains the execution principal, the scope stays daemon enrollment and connection for that Environment, and each write records an administrator audit entry in its transaction. --- .../agents-api/sandbox-manager.openapi.yaml | 568 ++++++++++-------- deploy/install/install.py | 4 +- deploy/install/test_install.py | 4 +- .../agents-api/cmd/server/http_routes_test.go | 4 +- .../internal/api/admin_agents_routes.go | 6 +- .../api/admin_environment_templates_routes.go | 6 +- .../internal/api/admin_files_routes.go | 6 +- .../agents-api/internal/api/admin_history.go | 2 +- .../internal/api/admin_resources.go | 9 +- .../internal/api/admin_resources_test.go | 8 +- .../agents-api/internal/api/admin_runtime.go | 2 +- .../internal/api/admin_runtime_test.go | 2 +- .../internal/api/admin_session_archive.go | 4 +- .../api/admin_session_archive_test.go | 2 +- .../internal/api/admin_sessions_routes.go | 26 +- .../internal/api/admin_skills_routes.go | 16 +- .../agents-api/internal/api/admin_summary.go | 2 +- .../internal/api/admin_vaults_routes.go | 12 +- services/agents-api/internal/api/auth.go | 6 +- .../agents-api/internal/api/core_metrics.go | 2 +- .../internal/api/core_metrics_test.go | 2 +- .../agents-api/internal/api/core_routes.go | 26 + .../api/environment_executor_management.go | 91 ++- .../environment_executor_management_test.go | 93 +-- services/agents-api/internal/api/handler.go | 11 +- .../internal/api/project_api_keys.go | 16 +- .../internal/api/project_api_keys_test.go | 4 +- .../agents-api/internal/api/routing_test.go | 28 +- .../internal/api/sandbox_manager.go | 41 +- .../agents-api/internal/api/write_audit.go | 4 +- .../internal/api/write_audit_test.go | 4 +- .../environment_executor_credentials.sql | 7 + .../environment_executor_credentials.sql.go | 46 ++ .../admin_session_archive_worker_http_test.go | 2 +- .../store/environment_executor_credentials.go | 16 + .../store/environment_executor_management.go | 94 ++- .../environment_executor_management_test.go | 134 +++-- .../store/project_api_keys_http_test.go | 2 +- .../store/request_body_public_test.go | 10 - .../tests/official_admin_management.py | 22 +- services/agents-api/tests/official_client.py | 2 +- 41 files changed, 829 insertions(+), 517 deletions(-) create mode 100644 services/agents-api/internal/api/core_routes.go diff --git a/contracts/agents-api/sandbox-manager.openapi.yaml b/contracts/agents-api/sandbox-manager.openapi.yaml index 7e15b7c52..048c23d30 100644 --- a/contracts/agents-api/sandbox-manager.openapi.yaml +++ b/contracts/agents-api/sandbox-manager.openapi.yaml @@ -183,10 +183,18 @@ definitions: api.EnvironmentExecutorCredentialRequest: properties: key_id: + format: uuid type: string rotate: type: boolean type: object + api.ExecutorCredentialList: + properties: + data: + items: + $ref: '#/definitions/store.ExecutorCredential' + type: array + type: object api.ProjectAPIKeyRequest: properties: name: @@ -587,6 +595,17 @@ definitions: revoked_at: type: string type: object + store.ExecutorCredential: + properties: + created_at: + type: string + key_id: + format: uuid + type: string + revoked_at: + type: string + x-nullable: true + type: object store.IssuedExecutorCredential: properties: environment_id: @@ -2791,7 +2810,7 @@ info: title: Core Extensions version: "1" paths: - /core/v1/admin/audit-log: + /core/v1/audit-log: get: description: Deployment administrator only. Newest-first cursor pagination of safe metadata. Actor labels are unverified console labels, not authorization identities. Request bodies and secrets are never recorded. parameters: @@ -2854,7 +2873,7 @@ paths: summary: Query committed administrator mutations tags: - Core Administration - /core/v1/admin/core-metrics: + /core/v1/metrics: get: description: Deployment administrator only. Complete UTC buckets; unknown measurements are null. Samples are process-local and are not backfilled after a restart. parameters: @@ -2891,7 +2910,7 @@ paths: summary: Retrieve Core operational metrics tags: - Core Administration - /core/v1/admin/projects: + /core/v1/projects: get: parameters: - description: Project ID cursor @@ -2972,7 +2991,7 @@ paths: summary: Create an empty independent Project tags: - Administrator Projects - /core/v1/admin/projects/{project_id}: + /core/v1/projects/{project_id}: post: consumes: - application/json @@ -3020,7 +3039,7 @@ paths: summary: Rename a Project tags: - Administrator Projects - /core/v1/admin/projects/{project_id}/agents: + /core/v1/projects/{project_id}/agents: get: description: Deployment administrator only. Reuses the public resource projection and operation rules; the key selects the target space and does not authenticate. parameters: @@ -3075,7 +3094,7 @@ paths: summary: List reusable Agents in a managed key space tags: - Agents - /core/v1/admin/projects/{project_id}/agents/{agent_id}: + /core/v1/projects/{project_id}/agents/{agent_id}: delete: description: Deployment administrator only. Reuses the public resource projection and operation rules; the key selects the target space and does not authenticate. parameters: @@ -3162,7 +3181,7 @@ paths: summary: Retrieve a reusable Agent in a managed key space tags: - Agents - /core/v1/admin/projects/{project_id}/archive: + /core/v1/projects/{project_id}/archive: post: parameters: - description: Project UUID @@ -3198,7 +3217,7 @@ paths: summary: Archive a Project and revoke all its keys while retaining assets tags: - Administrator Projects - /core/v1/admin/projects/{project_id}/environment-templates: + /core/v1/projects/{project_id}/environment-templates: get: description: Deployment administrator only. Reuses the public resource projection and operation rules; the key selects the target space and does not authenticate. parameters: @@ -3253,7 +3272,7 @@ paths: summary: List Environment Templates in a managed key space tags: - Environment Templates - /core/v1/admin/projects/{project_id}/environment-templates/{environment_template_id}: + /core/v1/projects/{project_id}/environment-templates/{environment_template_id}: delete: description: Deployment administrator only. Reuses the public resource projection and operation rules; the key selects the target space and does not authenticate. parameters: @@ -3336,7 +3355,137 @@ paths: summary: Retrieve an Environment Template in a managed key space tags: - Environment Templates - /core/v1/admin/projects/{project_id}/files: + /core/v1/projects/{project_id}/environments/{environment_id}/executor-credentials: + get: + description: Core key only. Returns metadata of the credentials restricted to this Environment, oldest first; secrets are never listed. The Environment must be a self_hosted Environment of the Project whose Session exists; otherwise 404. + parameters: + - description: Project UUID + in: path + name: project_id + required: true + type: string + - description: Environment UUID + in: path + name: environment_id + required: true + type: string + produces: + - application/json + responses: + "200": + description: OK + schema: + $ref: '#/definitions/api.ExecutorCredentialList' + "401": + description: Unauthorized + schema: + $ref: '#/definitions/v1.ErrorResponse' + "404": + description: Not Found + schema: + $ref: '#/definitions/v1.ErrorResponse' + "500": + description: Internal Server Error + schema: + $ref: '#/definitions/v1.ErrorResponse' + security: + - DeploymentAdminAuth: [] + summary: List a self_hosted Environment's executor credentials + tags: + - Executor Credentials + post: + consumes: + - application/json + description: Core key only. Returns a connect-only secret once, restricted to daemon enrollment and connection for this Environment, with the Project's principal as its execution principal. Repeating an issuance key_id returns 409 executor_credential_exists; after an uncertain response, list the credentials and rotate that key_id explicitly. Rotation keeps the key's Environment, invalidates the old secret and restores a revoked key; rotating an unknown key_id returns 404. The Environment must be a self_hosted Environment of the Project whose Session exists; otherwise 404. Each write records an administrator audit entry without the secret. + parameters: + - description: Project UUID + in: path + name: project_id + required: true + type: string + - description: Environment UUID + in: path + name: environment_id + required: true + type: string + - description: Request + in: body + name: body + required: true + schema: + $ref: '#/definitions/api.EnvironmentExecutorCredentialRequest' + produces: + - application/json + responses: + "201": + description: Created + schema: + $ref: '#/definitions/store.IssuedExecutorCredential' + "400": + description: Bad Request + schema: + $ref: '#/definitions/v1.ErrorResponse' + "401": + description: Unauthorized + schema: + $ref: '#/definitions/v1.ErrorResponse' + "404": + description: Not Found + schema: + $ref: '#/definitions/v1.ErrorResponse' + "409": + description: Conflict + schema: + $ref: '#/definitions/v1.ErrorResponse' + "500": + description: Internal Server Error + schema: + $ref: '#/definitions/v1.ErrorResponse' + security: + - DeploymentAdminAuth: [] + summary: Issue or explicitly rotate a self_hosted Environment executor credential + tags: + - Executor Credentials + /core/v1/projects/{project_id}/environments/{environment_id}/executor-credentials/{key_id}: + delete: + description: Core key only. Revokes one credential restricted to this Environment; repeated revocation is safe. Revocation denies future enrollment and connection but does not stop executor-owned compute. The Environment must be a self_hosted Environment of the Project whose Session exists; otherwise 404. + parameters: + - description: Project UUID + in: path + name: project_id + required: true + type: string + - description: Environment UUID + in: path + name: environment_id + required: true + type: string + - description: Executor key UUID + in: path + name: key_id + required: true + type: string + responses: + "204": + description: No Content + "401": + description: Unauthorized + schema: + $ref: '#/definitions/v1.ErrorResponse' + "404": + description: Not Found + schema: + $ref: '#/definitions/v1.ErrorResponse' + "500": + description: Internal Server Error + schema: + $ref: '#/definitions/v1.ErrorResponse' + security: + - DeploymentAdminAuth: [] + summary: Revoke a self_hosted Environment executor credential + tags: + - Executor Credentials + /core/v1/projects/{project_id}/files: get: description: Deployment administrator only. Reuses the public resource projection and operation rules; the key selects the target space and does not authenticate. parameters: @@ -3400,7 +3549,7 @@ paths: summary: List source files in a managed key space tags: - Files - /core/v1/admin/projects/{project_id}/files/{file_id}: + /core/v1/projects/{project_id}/files/{file_id}: delete: description: Deployment administrator only. Reuses the public resource projection and operation rules; the key selects the target space and does not authenticate. parameters: @@ -3491,7 +3640,7 @@ paths: summary: Retrieve source file metadata in a managed key space tags: - Files - /core/v1/admin/projects/{project_id}/keys: + /core/v1/projects/{project_id}/keys: get: parameters: - description: Project UUID @@ -3586,7 +3735,7 @@ paths: summary: Issue an independent secret in an existing Project tags: - Administrator Projects - /core/v1/admin/projects/{project_id}/keys/{key_id}: + /core/v1/projects/{project_id}/keys/{key_id}: delete: parameters: - description: Project UUID @@ -3627,7 +3776,7 @@ paths: summary: Revoke one Project key while retaining shared assets tags: - Administrator Projects - /core/v1/admin/projects/{project_id}/resource-owners: + /core/v1/projects/{project_id}/resource-owners: get: description: Deployment administrator only. The key path selects its independent space. Returns null for resources without recorded creation provenance, including historical and foreign resources. No key secret is returned. parameters: @@ -3674,7 +3823,7 @@ paths: summary: Batch lookup resource creation keys tags: - Write Audit - /core/v1/admin/projects/{project_id}/sessions: + /core/v1/projects/{project_id}/sessions: get: description: Deployment administrator only. Reuses the public resource projection and operation rules; the key selects the target space and does not authenticate. parameters: @@ -3733,7 +3882,7 @@ paths: summary: List execution Sessions in a managed key space tags: - Sessions - /core/v1/admin/projects/{project_id}/sessions/{session_id}: + /core/v1/projects/{project_id}/sessions/{session_id}: delete: description: Deployment administrator only. Reuses the public resource projection and operation rules; the key selects the target space and does not authenticate. parameters: @@ -3824,7 +3973,7 @@ paths: summary: Retrieve an execution Session in a managed key space tags: - Sessions - /core/v1/admin/projects/{project_id}/sessions/{session_id}/archive: + /core/v1/projects/{project_id}/sessions/{session_id}/archive: get: description: Deployment administrator only. Reports actual resource disposition, including expiry and failure cleanup. This is not archive provenance and does not assert active Turn settlement. Read this after an uncertain archive response; never infer released from a missing sandbox alone. parameters: @@ -3931,7 +4080,7 @@ paths: summary: Release a managed Session's execution resources while retaining history tags: - Core Administration - /core/v1/admin/projects/{project_id}/sessions/{session_id}/artifacts: + /core/v1/projects/{project_id}/sessions/{session_id}/artifacts: get: description: Deployment administrator only. Reuses the public resource projection and operation rules; the key selects the target space and does not authenticate. parameters: @@ -4000,7 +4149,7 @@ paths: summary: List immutable Session artifacts in a managed key space tags: - Artifacts - /core/v1/admin/projects/{project_id}/sessions/{session_id}/artifacts/{artifact_id}: + /core/v1/projects/{project_id}/sessions/{session_id}/artifacts/{artifact_id}: delete: description: Deployment administrator only. Reuses the public resource projection and operation rules; the key selects the target space and does not authenticate. parameters: @@ -4101,7 +4250,7 @@ paths: summary: Retrieve immutable artifact metadata in a managed key space tags: - Artifacts - /core/v1/admin/projects/{project_id}/sessions/{session_id}/artifacts/{artifact_id}/content: + /core/v1/projects/{project_id}/sessions/{session_id}/artifacts/{artifact_id}/content: get: description: Deployment administrator only. Reuses the public resource projection and operation rules; the key selects the target space and does not authenticate. parameters: @@ -4152,7 +4301,7 @@ paths: summary: Download immutable artifact bytes in a managed key space tags: - Artifacts - /core/v1/admin/projects/{project_id}/sessions/{session_id}/execution-configuration: + /core/v1/projects/{project_id}/sessions/{session_id}/execution-configuration: get: description: Deployment administrator only; the Project ID selects the target space and does not authenticate. Returns the committed model, harness and safe provider selection with recorded sources. This read never decrypts credentials, resolves current defaults or probes execution health. Deployment provider details remain redacted. Historical provenance and missing provider projections are explicitly unknown/unavailable. parameters: @@ -4198,7 +4347,7 @@ paths: summary: Retrieve frozen Session execution configuration in a managed key space tags: - Core extensions - /core/v1/admin/projects/{project_id}/sessions/{session_id}/items: + /core/v1/projects/{project_id}/sessions/{session_id}/items: get: description: Deployment administrator only. Reuses the public resource projection and operation rules; the key selects the target space and does not authenticate. parameters: @@ -4258,7 +4407,7 @@ paths: summary: List persisted execution Items in a managed key space tags: - Items - /core/v1/admin/projects/{project_id}/sessions/{session_id}/runtime-history: + /core/v1/projects/{project_id}/sessions/{session_id}/runtime-history: get: description: Deployment administrator only; the Project ID selects the target space and does not authenticate. Returns stored Runtime observations for one Session. End is exclusive; the server selects a bounded resolution. Responses contain at most 1,000 series, 10,000 points per coverage/series array, and 100,000 total coverage plus series points. It never reads or changes live compute. parameters: @@ -4324,7 +4473,7 @@ paths: summary: Retrieve Session Runtime history in a managed key space tags: - Runtime history - /core/v1/admin/projects/{project_id}/sessions/{session_id}/runtime-observation: + /core/v1/projects/{project_id}/sessions/{session_id}/runtime-observation: get: description: Deployment administrator only; the Project ID selects the target space and does not authenticate. Returns one read-only current Runtime observation. It never provisions, renews, restarts, pauses or stops compute. parameters: @@ -4370,7 +4519,7 @@ paths: summary: Retrieve a Session Runtime observation in a managed key space tags: - Runtime observations - /core/v1/admin/projects/{project_id}/sessions/{session_id}/turns: + /core/v1/projects/{project_id}/sessions/{session_id}/turns: get: description: Deployment administrator only. Reuses the public resource projection and operation rules; the key selects the target space and does not authenticate. parameters: @@ -4431,7 +4580,7 @@ paths: summary: List execution Turns in a managed key space tags: - Turns - /core/v1/admin/projects/{project_id}/sessions/{session_id}/turns/{turn_id}: + /core/v1/projects/{project_id}/sessions/{session_id}/turns/{turn_id}: get: description: Deployment administrator only. Reuses the public resource projection and operation rules; the key selects the target space and does not authenticate. parameters: @@ -4478,7 +4627,7 @@ paths: summary: Retrieve an execution Turn in a managed key space tags: - Turns - /core/v1/admin/projects/{project_id}/skills: + /core/v1/projects/{project_id}/skills: get: description: Deployment administrator only. Reuses the public resource projection and operation rules; the key selects the target space and does not authenticate. parameters: @@ -4517,7 +4666,7 @@ paths: summary: List Skills in a managed key space tags: - Skills - /core/v1/admin/projects/{project_id}/skills/{skill_id}: + /core/v1/projects/{project_id}/skills/{skill_id}: delete: description: Deployment administrator only. Reuses the public resource projection and operation rules; the key selects the target space and does not authenticate. parameters: @@ -4568,7 +4717,7 @@ paths: summary: Retrieve Skill metadata in a managed key space tags: - Skills - /core/v1/admin/projects/{project_id}/skills/{skill_id}/content: + /core/v1/projects/{project_id}/skills/{skill_id}/content: get: description: Deployment administrator only. Reuses the public resource projection and operation rules; the key selects the target space and does not authenticate. parameters: @@ -4594,7 +4743,7 @@ paths: summary: Download Skill content in a managed key space tags: - Skills - /core/v1/admin/projects/{project_id}/skills/{skill_id}/versions: + /core/v1/projects/{project_id}/skills/{skill_id}/versions: get: description: Deployment administrator only. Reuses the public resource projection and operation rules; the key selects the target space and does not authenticate. parameters: @@ -4638,7 +4787,7 @@ paths: summary: List Skill versions in a managed key space tags: - Skills - /core/v1/admin/projects/{project_id}/skills/{skill_id}/versions/{version}: + /core/v1/projects/{project_id}/skills/{skill_id}/versions/{version}: delete: description: Deployment administrator only. Reuses the public resource projection and operation rules; the key selects the target space and does not authenticate. parameters: @@ -4699,7 +4848,7 @@ paths: summary: Retrieve Skill version metadata in a managed key space tags: - Skills - /core/v1/admin/projects/{project_id}/skills/{skill_id}/versions/{version}/content: + /core/v1/projects/{project_id}/skills/{skill_id}/versions/{version}/content: get: description: Deployment administrator only. Reuses the public resource projection and operation rules; the key selects the target space and does not authenticate. parameters: @@ -4730,7 +4879,7 @@ paths: summary: Download immutable Skill version content in a managed key space tags: - Skills - /core/v1/admin/projects/{project_id}/vaults: + /core/v1/projects/{project_id}/vaults: get: description: Deployment administrator only. Reuses the public resource projection and operation rules; the key selects the target space and does not authenticate. parameters: @@ -4801,7 +4950,7 @@ paths: summary: List Vaults in a managed key space tags: - Vaults - /core/v1/admin/projects/{project_id}/vaults/{vault_id}: + /core/v1/projects/{project_id}/vaults/{vault_id}: delete: description: Deployment administrator only. Reuses the public resource projection and operation rules; the key selects the target space and does not authenticate. parameters: @@ -4888,7 +5037,7 @@ paths: summary: Retrieve a Vault in a managed key space tags: - Vaults - /core/v1/admin/projects/{project_id}/vaults/{vault_id}/credentials: + /core/v1/projects/{project_id}/vaults/{vault_id}/credentials: get: description: Deployment administrator only. Reuses the public resource projection and operation rules; the key selects the target space and does not authenticate. parameters: @@ -4964,7 +5113,7 @@ paths: summary: List safe Vault Credential metadata in a managed key space tags: - Credentials - /core/v1/admin/projects/{project_id}/vaults/{vault_id}/credentials/{credential_id}: + /core/v1/projects/{project_id}/vaults/{vault_id}/credentials/{credential_id}: delete: description: Deployment administrator only. Reuses the public resource projection and operation rules; the key selects the target space and does not authenticate. parameters: @@ -5061,7 +5210,7 @@ paths: summary: Retrieve safe Vault Credential metadata in a managed key space tags: - Credentials - /core/v1/admin/projects/{project_id}/write-operations: + /core/v1/projects/{project_id}/write-operations: get: description: Deployment administrator only. Reverse chronological keyset pagination over committed writes. Creation records remain; other records follow configured retention. The key path selects its independent space, never a caller-supplied tenant. parameters: @@ -5126,220 +5275,6 @@ paths: summary: Query API-key write operations tags: - Write Audit - /core/v1/admin/runtime-observations: - get: - description: Deployment administrator only. Each observation is labelled with its owning Project ID. Uses the existing read-only Runtime sampler, with bounded concurrency and no execution or provisioning. A provider with a batch metrics read, such as E2B, samples the page's running sandboxes in one bounded request. - parameters: - - description: Last Session ID from the preceding page - in: query - name: after - type: string - - default: 20 - description: Page size - in: query - maximum: 100 - minimum: 1 - name: limit - type: integer - - default: desc - description: Session creation order - enum: - - asc - - desc - in: query - name: order - type: string - produces: - - application/json - responses: - "200": - description: OK - schema: - $ref: '#/definitions/api.AdminRuntimeObservationList' - "400": - description: Bad Request - schema: - $ref: '#/definitions/v1.ErrorResponse' - "401": - description: Unauthorized - schema: - $ref: '#/definitions/v1.ErrorResponse' - "404": - description: Not Found - schema: - $ref: '#/definitions/v1.ErrorResponse' - "500": - description: Internal Server Error - schema: - $ref: '#/definitions/v1.ErrorResponse' - "503": - description: Service Unavailable - schema: - $ref: '#/definitions/v1.ErrorResponse' - security: - - DeploymentAdminAuth: [] - summary: List Runtime observations across managed Projects - tags: - - Core Administration - /core/v1/admin/summary: - get: - description: Administrator only. after/limit/order paginate Projects. Agent grouping returns groups within those spaces. Date bounds filter Session creation, not current asset counts. Usage sums only non-null public Session usage; coverage includes every selected Session. Each Project is read in a consistent database snapshot. Totals are not billing records. - parameters: - - description: One API Project - in: query - name: project_id - type: string - - default: project - description: Grouping - enum: - - project - - agent - - key - in: query - name: group_by - type: string - - description: Inclusive RFC3339 Session creation time - in: query - name: created_after - type: string - - description: Exclusive RFC3339 Session creation time - in: query - name: created_before - type: string - - description: Last Project ID in preceding page - in: query - name: after - type: string - - default: 20 - description: Number of Projects - in: query - maximum: 100 - minimum: 1 - name: limit - type: integer - - default: desc - description: Project order - enum: - - asc - - desc - in: query - name: order - type: string - produces: - - application/json - responses: - "200": - description: OK - schema: - $ref: '#/definitions/api.AdminSummaryResponse' - "400": - description: Bad Request - schema: - $ref: '#/definitions/v1.ErrorResponse' - "401": - description: Unauthorized - schema: - $ref: '#/definitions/v1.ErrorResponse' - "404": - description: Not Found - schema: - $ref: '#/definitions/v1.ErrorResponse' - "500": - description: Internal Server Error - schema: - $ref: '#/definitions/v1.ErrorResponse' - security: - - DeploymentAdminAuth: [] - summary: Summarize resource counts and Session usage by Project, Agent or creator key - tags: - - Core Administration - /core/v1/environments/{environment_id}/executor-credentials: - post: - consumes: - - application/json - description: Core extension, not an upstream Agents API operation. Uses the project caller key and exact live self_hosted Session creator. Returns a connect-only secret once, restricted to this Environment. Repeating an issuance key_id returns 409; after an uncertain response explicitly rotate that same key_id. Rotation retains immutable ownership and invalidates the old secret. No Session API authority is granted. - parameters: - - description: Environment UUID - in: path - name: environment_id - required: true - type: string - - description: Request - in: body - name: body - required: true - schema: - $ref: '#/definitions/api.EnvironmentExecutorCredentialRequest' - produces: - - application/json - responses: - "201": - description: Created - schema: - $ref: '#/definitions/store.IssuedExecutorCredential' - "400": - description: Bad Request - schema: - $ref: '#/definitions/v1.ErrorResponse' - "401": - description: Unauthorized - schema: - $ref: '#/definitions/v1.ErrorResponse' - "404": - description: Not Found - schema: - $ref: '#/definitions/v1.ErrorResponse' - "409": - description: Conflict - schema: - $ref: '#/definitions/v1.ErrorResponse' - "500": - description: Internal Server Error - schema: - $ref: '#/definitions/v1.ErrorResponse' - security: - - BearerAuth: [] - summary: Issue or explicitly rotate an Environment executor credential - tags: - - Environment Executor - /core/v1/environments/{environment_id}/executor-credentials/{key_id}: - delete: - description: Core extension authenticated by the project caller key. Revokes only the caller's exact Environment-restricted credential. Repeated revocation is safe. Revocation denies future enrollment and dispatch but does not stop caller-owned compute or prove process quiescence. - parameters: - - description: Environment UUID - in: path - name: environment_id - required: true - type: string - - description: Executor key UUID - in: path - name: key_id - required: true - type: string - responses: - "204": - description: No Content - "400": - description: Bad Request - schema: - $ref: '#/definitions/v1.ErrorResponse' - "401": - description: Unauthorized - schema: - $ref: '#/definitions/v1.ErrorResponse' - "404": - description: Not Found - schema: - $ref: '#/definitions/v1.ErrorResponse' - "500": - description: Internal Server Error - schema: - $ref: '#/definitions/v1.ErrorResponse' - security: - - BearerAuth: [] - summary: Revoke an Environment executor credential - tags: - - Environment Executor /core/v1/sandbox/deployment: get: description: Core deployment extension. Does not grant project resource access. Responses contain only explicit safe fields. E2B template_build values are those Core read when the selection was saved; this read does not call E2B. @@ -5786,14 +5721,137 @@ paths: summary: List retained allocations on a sandbox node tags: - Sandbox Manager + /core/v1/sandbox/runtime-observations: + get: + description: Deployment administrator only. Each observation is labelled with its owning Project ID. Uses the existing read-only Runtime sampler, with bounded concurrency and no execution or provisioning. A provider with a batch metrics read, such as E2B, samples the page's running sandboxes in one bounded request. + parameters: + - description: Last Session ID from the preceding page + in: query + name: after + type: string + - default: 20 + description: Page size + in: query + maximum: 100 + minimum: 1 + name: limit + type: integer + - default: desc + description: Session creation order + enum: + - asc + - desc + in: query + name: order + type: string + produces: + - application/json + responses: + "200": + description: OK + schema: + $ref: '#/definitions/api.AdminRuntimeObservationList' + "400": + description: Bad Request + schema: + $ref: '#/definitions/v1.ErrorResponse' + "401": + description: Unauthorized + schema: + $ref: '#/definitions/v1.ErrorResponse' + "404": + description: Not Found + schema: + $ref: '#/definitions/v1.ErrorResponse' + "500": + description: Internal Server Error + schema: + $ref: '#/definitions/v1.ErrorResponse' + "503": + description: Service Unavailable + schema: + $ref: '#/definitions/v1.ErrorResponse' + security: + - DeploymentAdminAuth: [] + summary: List Runtime observations across managed Projects + tags: + - Core Administration + /core/v1/summary: + get: + description: Administrator only. after/limit/order paginate Projects. Agent grouping returns groups within those spaces. Date bounds filter Session creation, not current asset counts. Usage sums only non-null public Session usage; coverage includes every selected Session. Each Project is read in a consistent database snapshot. Totals are not billing records. + parameters: + - description: One API Project + in: query + name: project_id + type: string + - default: project + description: Grouping + enum: + - project + - agent + - key + in: query + name: group_by + type: string + - description: Inclusive RFC3339 Session creation time + in: query + name: created_after + type: string + - description: Exclusive RFC3339 Session creation time + in: query + name: created_before + type: string + - description: Last Project ID in preceding page + in: query + name: after + type: string + - default: 20 + description: Number of Projects + in: query + maximum: 100 + minimum: 1 + name: limit + type: integer + - default: desc + description: Project order + enum: + - asc + - desc + in: query + name: order + type: string + produces: + - application/json + responses: + "200": + description: OK + schema: + $ref: '#/definitions/api.AdminSummaryResponse' + "400": + description: Bad Request + schema: + $ref: '#/definitions/v1.ErrorResponse' + "401": + description: Unauthorized + schema: + $ref: '#/definitions/v1.ErrorResponse' + "404": + description: Not Found + schema: + $ref: '#/definitions/v1.ErrorResponse' + "500": + description: Internal Server Error + schema: + $ref: '#/definitions/v1.ErrorResponse' + security: + - DeploymentAdminAuth: [] + summary: Summarize resource counts and Session usage by Project, Agent or creator key + tags: + - Core Administration schemes: - http - https securityDefinitions: - BearerAuth: - in: header - name: Authorization - type: apiKey DeploymentAdminAuth: in: header name: Authorization diff --git a/deploy/install/install.py b/deploy/install/install.py index ae7ad2384..dded594c1 100644 --- a/deploy/install/install.py +++ b/deploy/install/install.py @@ -392,7 +392,7 @@ def main(argv=None): raise InstallError("Web authentication is unavailable. Inspect private console state") core_url = state.get("core_url") or f'http://127.0.0.1:{state["core_port"]}' token = (root / "admin/sandbox-admin.key").read_text().strip() - if not wait_http(core_url + "/core/v1/admin/projects", {"Authorization": "Bearer " + token}): + if not wait_http(core_url + "/core/v1/projects", {"Authorization": "Bearer " + token}): raise InstallError("Core administrator authentication failed. Inspect private configuration; no model was called") print("Console: " + (state.get("public_url") or url)) if (root / "state/console/admin.json").exists(): @@ -402,7 +402,7 @@ def main(argv=None): print("Keep your administrator username and password safe; there is no email password reset.") else: auth = base64.b64encode(("admin:" + (root / "config/console.password").read_text()).encode()).decode() - if not wait_http(url + "/core/v1/admin/projects", {"Authorization": "Basic " + auth, + if not wait_http(url + "/core/v1/projects", {"Authorization": "Basic " + auth, "Host": host}): raise InstallError("Web could not authenticate to Core. Inspect private configuration; no model was called") print("Console: " + (state.get("public_url") or url) + " (user: admin)") diff --git a/deploy/install/test_install.py b/deploy/install/test_install.py index 8674d709c..e8af19a29 100644 --- a/deploy/install/test_install.py +++ b/deploy/install/test_install.py @@ -694,7 +694,7 @@ def test_default_main_skips_kvm_native_service_and_runtime_import(self): self.assertFalse((self.root / "config/keys.json").exists()) self.assertEqual([call.args[0] for call in health.call_args_list], [ "http://127.0.0.1:8091/healthz", "http://127.0.0.1:8080/console/auth", - "http://127.0.0.1:8091/core/v1/admin/projects", + "http://127.0.0.1:8091/core/v1/projects", ]) admin_token = (self.root / "admin/sandbox-admin.key").read_text() self.assertEqual(health.call_args_list[-1].args[1], {"Authorization": "Bearer " + admin_token}) @@ -723,7 +723,7 @@ def external_command(args, **_kwargs): imports = [call for call in calls if call[:2] == ["docker", "load"]] self.assertEqual(imports, [["docker", "load", "--input", str(bundle / "images/web.tar")]]) self.assertFalse(any(call[:2] == ["docker", "run"] for call in calls)) - self.assertEqual([call.args[0] for call in health.call_args_list], ["http://127.0.0.1:8080/console/auth", "http://127.0.0.1:9091/core/v1/admin/projects"]) + self.assertEqual([call.args[0] for call in health.call_args_list], ["http://127.0.0.1:8080/console/auth", "http://127.0.0.1:9091/core/v1/projects"]) self.assertEqual(health.call_args_list[-1].args[1], {"Authorization": "Bearer " + source.read_text()}) self.assertNotIn(source.read_text(), output.getvalue()) services = self.document("compose.json")["services"] diff --git a/services/agents-api/cmd/server/http_routes_test.go b/services/agents-api/cmd/server/http_routes_test.go index 8d0d608e1..1f7090d2a 100644 --- a/services/agents-api/cmd/server/http_routes_test.go +++ b/services/agents-api/cmd/server/http_routes_test.go @@ -136,7 +136,7 @@ func TestServerHandlerRawPathsKeepEncodedSeparators(t *testing.T) { {"/v1/\xc3\xa9/..%2F..%2Fapi/v1/sandbox-node/connect", "400"}, {"/v1/x{/..%2F..%2Fcore/v1/sandbox/nodes", "400"}, {"/v1/x{/..%2F..%2Fcore/v1/project-api-keys/x", "400"}, - {"/v1/x{/../../core/v1/admin/api-keys/x", "401"}, + {"/v1/x{/../../core/v1/api-keys/x", "401"}, {"/v1/x\\/..%5C..%5Capi/v1/agent-daemon/ws", "400"}, {"http://example.test/v1/x{/..%252F..%252Fapi/v1/agent-daemon/enroll", "400"}, {"/v1/x{/../../api/v1/agent-daemon/enroll", "204"}, @@ -170,7 +170,7 @@ func FuzzServerHandlerRoutesLikeCanonicalForm(f *testing.F) { for _, seed := range []string{"v1//agents", "v1/x{/..%2F..%2Fapi/v1/agent-daemon/enroll", "api/v1/agent-daemon%2Fenroll", "v1/\xc3\xa9/../../api/v1/agent-daemon/ws", "api/v1/sandbox-node/%2E%2E/sandbox-node/connect", "0\"%2F", "api/v1/agent-daemon", "v1/x\\/..%5C..%5Capi/v1/agent-daemon/connection", "v1/agents/%252F%2e%2E/x", "v1/x{/..%2F..%2Fcore/v1/project-api-keys/x", - "core/v1/admin/api-keys/x/%2E%2E/%2E%2E/%2E%2E/%2E%2E/api/v1/agent-daemon/enroll"} { + "core/v1/projects/x/%2E%2E/%2E%2E/%2E%2E/%2E%2E/api/v1/agent-daemon/enroll"} { f.Add(seed) } handler := daemonComposition(f) diff --git a/services/agents-api/internal/api/admin_agents_routes.go b/services/agents-api/internal/api/admin_agents_routes.go index d4be948ca..13ff02bc5 100644 --- a/services/agents-api/internal/api/admin_agents_routes.go +++ b/services/agents-api/internal/api/admin_agents_routes.go @@ -13,7 +13,7 @@ import "net/http" // @Success 200 {object} v1.SavedAgentList // @Failure 400,401,404,500 {object} v1.ErrorResponse // @Param project_id path string true "Project ID" -// @Router /core/v1/admin/projects/{project_id}/agents [get] +// @Router /core/v1/projects/{project_id}/agents [get] func (h *Handler) adminListAgents(w http.ResponseWriter, r *http.Request) { h.listAgents(w, r) } @@ -27,7 +27,7 @@ func (h *Handler) adminListAgents(w http.ResponseWriter, r *http.Request) { // @Success 200 {object} v1.SavedAgent // @Failure 400,401,404,500 {object} v1.ErrorResponse // @Param project_id path string true "Project ID" -// @Router /core/v1/admin/projects/{project_id}/agents/{agent_id} [get] +// @Router /core/v1/projects/{project_id}/agents/{agent_id} [get] func (h *Handler) adminGetAgent(w http.ResponseWriter, r *http.Request) { h.getAgent(w, r) } @@ -41,7 +41,7 @@ func (h *Handler) adminGetAgent(w http.ResponseWriter, r *http.Request) { // @Success 200 {object} v1.AgentDeleted // @Failure 400,401,404,413,500 {object} v1.ErrorResponse // @Param project_id path string true "Project ID" -// @Router /core/v1/admin/projects/{project_id}/agents/{agent_id} [delete] +// @Router /core/v1/projects/{project_id}/agents/{agent_id} [delete] func (h *Handler) adminDeleteAgent(w http.ResponseWriter, r *http.Request) { h.deleteAgent(w, r) } diff --git a/services/agents-api/internal/api/admin_environment_templates_routes.go b/services/agents-api/internal/api/admin_environment_templates_routes.go index b5aebf7fa..d9fb70232 100644 --- a/services/agents-api/internal/api/admin_environment_templates_routes.go +++ b/services/agents-api/internal/api/admin_environment_templates_routes.go @@ -13,7 +13,7 @@ import "net/http" // @Success 200 {object} v1.EnvironmentTemplateList // @Failure 400,401,404,500 {object} v1.ErrorResponse // @Param project_id path string true "Project ID" -// @Router /core/v1/admin/projects/{project_id}/environment-templates [get] +// @Router /core/v1/projects/{project_id}/environment-templates [get] func (h *Handler) adminListEnvironmentTemplates(w http.ResponseWriter, r *http.Request) { h.listEnvironmentTemplates(w, r) } @@ -27,7 +27,7 @@ func (h *Handler) adminListEnvironmentTemplates(w http.ResponseWriter, r *http.R // @Success 200 {object} v1.EnvironmentTemplate // @Failure 400,401,404,500 {object} v1.ErrorResponse // @Param project_id path string true "Project ID" -// @Router /core/v1/admin/projects/{project_id}/environment-templates/{environment_template_id} [get] +// @Router /core/v1/projects/{project_id}/environment-templates/{environment_template_id} [get] func (h *Handler) adminGetEnvironmentTemplate(w http.ResponseWriter, r *http.Request) { h.getEnvironmentTemplate(w, r) } @@ -41,7 +41,7 @@ func (h *Handler) adminGetEnvironmentTemplate(w http.ResponseWriter, r *http.Req // @Success 200 {object} v1.EnvironmentTemplateDeleted // @Failure 400,401,404,500 {object} v1.ErrorResponse // @Param project_id path string true "Project ID" -// @Router /core/v1/admin/projects/{project_id}/environment-templates/{environment_template_id} [delete] +// @Router /core/v1/projects/{project_id}/environment-templates/{environment_template_id} [delete] func (h *Handler) adminDeleteEnvironmentTemplate(w http.ResponseWriter, r *http.Request) { h.deleteEnvironmentTemplate(w, r) } diff --git a/services/agents-api/internal/api/admin_files_routes.go b/services/agents-api/internal/api/admin_files_routes.go index faecef924..e10de9fcb 100644 --- a/services/agents-api/internal/api/admin_files_routes.go +++ b/services/agents-api/internal/api/admin_files_routes.go @@ -14,7 +14,7 @@ import "net/http" // @Success 200 {object} v1.SourceFileList // @Failure 400,401,404,500,503 {object} v1.ErrorResponse // @Param project_id path string true "Project ID" -// @Router /core/v1/admin/projects/{project_id}/files [get] +// @Router /core/v1/projects/{project_id}/files [get] func (h *Handler) adminListSourceFiles(w http.ResponseWriter, r *http.Request) { h.listSourceFiles(w, r) } @@ -28,7 +28,7 @@ func (h *Handler) adminListSourceFiles(w http.ResponseWriter, r *http.Request) { // @Success 200 {object} v1.SourceFile // @Failure 400,401,404,500,503 {object} v1.ErrorResponse // @Param project_id path string true "Project ID" -// @Router /core/v1/admin/projects/{project_id}/files/{file_id} [get] +// @Router /core/v1/projects/{project_id}/files/{file_id} [get] func (h *Handler) adminGetSourceFile(w http.ResponseWriter, r *http.Request) { h.getSourceFile(w, r) } @@ -42,7 +42,7 @@ func (h *Handler) adminGetSourceFile(w http.ResponseWriter, r *http.Request) { // @Success 200 {object} v1.SourceFileDeleted // @Failure 400,401,404,500,503 {object} v1.ErrorResponse // @Param project_id path string true "Project ID" -// @Router /core/v1/admin/projects/{project_id}/files/{file_id} [delete] +// @Router /core/v1/projects/{project_id}/files/{file_id} [delete] func (h *Handler) adminDeleteSourceFile(w http.ResponseWriter, r *http.Request) { h.deleteSourceFile(w, r) } diff --git a/services/agents-api/internal/api/admin_history.go b/services/agents-api/internal/api/admin_history.go index 5aa4e7a34..4789fd15f 100644 --- a/services/agents-api/internal/api/admin_history.go +++ b/services/agents-api/internal/api/admin_history.go @@ -23,7 +23,7 @@ import ( // @Param after query string false "Opaque next_cursor from the preceding page" // @Success 200 {object} store.AdminAuditPage // @Failure 400,401,500 {object} v1.ErrorResponse -// @Router /core/v1/admin/audit-log [get] +// @Router /core/v1/audit-log [get] func (h *Handler) listAdminAudit(w http.ResponseWriter, r *http.Request) { values, err := url.ParseQuery(r.URL.RawQuery) if err != nil { diff --git a/services/agents-api/internal/api/admin_resources.go b/services/agents-api/internal/api/admin_resources.go index 5e2dcba0b..0ef019caf 100644 --- a/services/agents-api/internal/api/admin_resources.go +++ b/services/agents-api/internal/api/admin_resources.go @@ -34,16 +34,15 @@ func (h *Handler) adminResourceScope(next http.Handler) http.Handler { } func (h *Handler) registerAdminResourceRoutes(router chi.Router) { - if h.deploymentAuth == nil || h.projectKeys == nil { + if h.projectKeys == nil { return } router.Group(func(r chi.Router) { - - r.Get("/core-metrics", h.getCoreMetrics) + r.Get("/metrics", h.getCoreMetrics) if h.adminManagement != nil { r.Get("/summary", h.adminSummary) - r.Get("/runtime-observations", h.adminRuntimeObservations) - r.Head("/runtime-observations", methodNotAllowed) + r.Get("/sandbox/runtime-observations", h.adminRuntimeObservations) + r.Head("/sandbox/runtime-observations", methodNotAllowed) r.Get("/audit-log", h.listAdminAudit) } r.Group(func(r chi.Router) { diff --git a/services/agents-api/internal/api/admin_resources_test.go b/services/agents-api/internal/api/admin_resources_test.go index 5170760f0..14cee34d2 100644 --- a/services/agents-api/internal/api/admin_resources_test.go +++ b/services/agents-api/internal/api/admin_resources_test.go @@ -58,7 +58,7 @@ func adminTestHandler(t *testing.T, options ...Option) (http.Handler, *recording return h, s, key.TenantID } -const adminSessionsPath = "/core/v1/admin/projects/" + managementProjectID + "/sessions/" +const adminSessionsPath = "/core/v1/projects/" + managementProjectID + "/sessions/" type adminReadFixture struct { ResourceStore @@ -90,7 +90,7 @@ func TestAdminResourcesHaveExplicitTargetWithoutCallerImpersonation(t *testing.T if err != nil { t.Fatal(err) } - base := "/core/v1/admin/projects/" + managementProjectID + base := "/core/v1/projects/" + managementProjectID for _, test := range []struct { method, path string status int @@ -112,7 +112,7 @@ func TestAdminResourcesHaveExplicitTargetWithoutCallerImpersonation(t *testing.T if resources.seenTenant != key.TenantID || !resources.administrative || resources.impersonated { t.Fatal("administrator target became a caller or lost audit scope") } - for _, path := range []string{base + "/agents", "/core/v1/admin/not-an-operation"} { + for _, path := range []string{base + "/agents", "/core/v1/not-an-operation"} { for _, token := range []string{"caller", "issued-project-key", ""} { if w := projectKeyHTTP(h, "GET", path, token, ""); w.Code != 401 { t.Fatalf("unauthorized management path returned %d", w.Code) @@ -154,7 +154,7 @@ func TestAdminSummaryUsesPublicStateAndNullUsageCoverage(t *testing.T) { if err != nil { t.Fatal(err) } - base := "/core/v1/admin/summary?project_id=" + managementProjectID + "&created_after=1970-01-01T00:00:00Z&created_before=2030-01-01T00:00:00Z" + base := "/core/v1/summary?project_id=" + managementProjectID + "&created_after=1970-01-01T00:00:00Z&created_before=2030-01-01T00:00:00Z" for _, group := range []string{"project", "key", "agent"} { w := projectKeyHTTP(h, http.MethodGet, base+"&group_by="+group, "admin", "") var response AdminSummaryResponse diff --git a/services/agents-api/internal/api/admin_runtime.go b/services/agents-api/internal/api/admin_runtime.go index b48fb6138..824f6c7b3 100644 --- a/services/agents-api/internal/api/admin_runtime.go +++ b/services/agents-api/internal/api/admin_runtime.go @@ -45,7 +45,7 @@ type AdminRuntimeObservationList struct { // @Param order query string false "Session creation order" Enums(asc,desc) default(desc) // @Success 200 {object} api.AdminRuntimeObservationList // @Failure 400,401,404,500,503 {object} v1.ErrorResponse -// @Router /core/v1/admin/runtime-observations [get] +// @Router /core/v1/sandbox/runtime-observations [get] func (h *Handler) adminRuntimeObservations(w http.ResponseWriter, r *http.Request) { if h.runtimeObservations == nil { writeError(w, http.StatusServiceUnavailable, "execution_unavailable", "Runtime observation is not configured on this service.") diff --git a/services/agents-api/internal/api/admin_runtime_test.go b/services/agents-api/internal/api/admin_runtime_test.go index 07f872678..245439e9b 100644 --- a/services/agents-api/internal/api/admin_runtime_test.go +++ b/services/agents-api/internal/api/admin_runtime_test.go @@ -38,7 +38,7 @@ func (s *adminRuntimeTargets) ListAdminRuntimeTargets(_ context.Context, tenants return s.page, nil } -const adminRuntimeObservationsPath = "/core/v1/admin/runtime-observations" +const adminRuntimeObservationsPath = "/core/v1/sandbox/runtime-observations" // adminRuntimeFixture serves the administrator observation list over one // Project per tenant and the given Session targets. diff --git a/services/agents-api/internal/api/admin_session_archive.go b/services/agents-api/internal/api/admin_session_archive.go index 81c6f9f12..cdfcec9a3 100644 --- a/services/agents-api/internal/api/admin_session_archive.go +++ b/services/agents-api/internal/api/admin_session_archive.go @@ -28,7 +28,7 @@ type AdminSessionArchiveRequest struct { // @Param body body api.AdminSessionArchiveRequest true "Current deployment generation" // @Success 200 {object} store.ManagedSessionArchive // @Failure 400,401,404,409,413,500,503 {object} v1.ErrorResponse -// @Router /core/v1/admin/projects/{project_id}/sessions/{session_id}/archive [post] +// @Router /core/v1/projects/{project_id}/sessions/{session_id}/archive [post] func (h *Handler) adminArchiveSession(w http.ResponseWriter, r *http.Request) { raw, ok := readJSONBodyLimit(w, r, 4096, "Archive request is too large.") if !ok { @@ -60,7 +60,7 @@ func (h *Handler) adminArchiveSession(w http.ResponseWriter, r *http.Request) { // @Param session_id path string true "Session ID" // @Success 200 {object} store.ManagedSessionArchive // @Failure 400,401,404,500,503 {object} v1.ErrorResponse -// @Router /core/v1/admin/projects/{project_id}/sessions/{session_id}/archive [get] +// @Router /core/v1/projects/{project_id}/sessions/{session_id}/archive [get] func (h *Handler) adminGetSessionArchive(w http.ResponseWriter, r *http.Request) { result, err := h.adminManagement.GetManagedSessionArchive(r.Context(), tenantID(r), chi.URLParam(r, "session_id")) if err != nil { diff --git a/services/agents-api/internal/api/admin_session_archive_test.go b/services/agents-api/internal/api/admin_session_archive_test.go index 6d930ddf1..28e24eb82 100644 --- a/services/agents-api/internal/api/admin_session_archive_test.go +++ b/services/agents-api/internal/api/admin_session_archive_test.go @@ -44,7 +44,7 @@ func TestAdminSessionArchiveAuthorityAndValidation(t *testing.T) { if err != nil { t.Fatal(err) } - path := "/core/v1/admin/projects/" + managementProjectID + "/sessions/11111111-1111-4111-8111-111111111111/archive" + path := "/core/v1/projects/" + managementProjectID + "/sessions/11111111-1111-4111-8111-111111111111/archive" for _, body := range []string{`{}`, `{"expected_generation":null}`, `{"expected_generation":0}`, `{"expected_generation":-1}`, `{"expected_generation":1.5}`, `{"expected_generation":"1"}`, `{"Expected_Generation":1}`} { if w := projectKeyHTTP(h, http.MethodPost, path, "admin", body); w.Code != 400 { t.Fatalf("invalid archive body accepted: %s: %d %s", body, w.Code, w.Body) diff --git a/services/agents-api/internal/api/admin_sessions_routes.go b/services/agents-api/internal/api/admin_sessions_routes.go index 0ea1cfcdf..c145df724 100644 --- a/services/agents-api/internal/api/admin_sessions_routes.go +++ b/services/agents-api/internal/api/admin_sessions_routes.go @@ -14,7 +14,7 @@ import "net/http" // @Success 200 {object} v1.SessionList // @Failure 400,401,404,500 {object} v1.ErrorResponse // @Param project_id path string true "Project ID" -// @Router /core/v1/admin/projects/{project_id}/sessions [get] +// @Router /core/v1/projects/{project_id}/sessions [get] func (h *Handler) adminListSessions(w http.ResponseWriter, r *http.Request) { h.listSessions(w, r) } @@ -28,7 +28,7 @@ func (h *Handler) adminListSessions(w http.ResponseWriter, r *http.Request) { // @Success 200 {object} v1.Session // @Failure 400,401,404,500 {object} v1.ErrorResponse // @Param project_id path string true "Project ID" -// @Router /core/v1/admin/projects/{project_id}/sessions/{session_id} [get] +// @Router /core/v1/projects/{project_id}/sessions/{session_id} [get] func (h *Handler) adminGetSession(w http.ResponseWriter, r *http.Request) { h.getSession(w, r) } @@ -42,7 +42,7 @@ func (h *Handler) adminGetSession(w http.ResponseWriter, r *http.Request) { // @Success 200 {object} v1.SessionDeleted // @Failure 400,401,404,409,413,500 {object} v1.ErrorResponse // @Param project_id path string true "Project ID" -// @Router /core/v1/admin/projects/{project_id}/sessions/{session_id} [delete] +// @Router /core/v1/projects/{project_id}/sessions/{session_id} [delete] func (h *Handler) adminDeleteSession(w http.ResponseWriter, r *http.Request) { h.deleteSession(w, r) } @@ -59,7 +59,7 @@ func (h *Handler) adminDeleteSession(w http.ResponseWriter, r *http.Request) { // @Success 200 {object} v1.TurnList // @Failure 400,401,404,500 {object} v1.ErrorResponse // @Param project_id path string true "Project ID" -// @Router /core/v1/admin/projects/{project_id}/sessions/{session_id}/turns [get] +// @Router /core/v1/projects/{project_id}/sessions/{session_id}/turns [get] func (h *Handler) adminListTurns(w http.ResponseWriter, r *http.Request) { h.listTurns(w, r) } @@ -74,7 +74,7 @@ func (h *Handler) adminListTurns(w http.ResponseWriter, r *http.Request) { // @Success 200 {object} v1.Turn // @Failure 400,401,404,500 {object} v1.ErrorResponse // @Param project_id path string true "Project ID" -// @Router /core/v1/admin/projects/{project_id}/sessions/{session_id}/turns/{turn_id} [get] +// @Router /core/v1/projects/{project_id}/sessions/{session_id}/turns/{turn_id} [get] func (h *Handler) adminGetTurn(w http.ResponseWriter, r *http.Request) { h.getTurn(w, r) } @@ -91,7 +91,7 @@ func (h *Handler) adminGetTurn(w http.ResponseWriter, r *http.Request) { // @Success 200 {object} v1.ItemList // @Failure 400,401,404,500 {object} v1.ErrorResponse // @Param project_id path string true "Project ID" -// @Router /core/v1/admin/projects/{project_id}/sessions/{session_id}/items [get] +// @Router /core/v1/projects/{project_id}/sessions/{session_id}/items [get] func (h *Handler) adminListItems(w http.ResponseWriter, r *http.Request) { h.listItems(w, r) } @@ -109,7 +109,7 @@ func (h *Handler) adminListItems(w http.ResponseWriter, r *http.Request) { // @Success 200 {object} v1.SessionArtifactList // @Failure 400,401,404,500,503 {object} v1.ErrorResponse // @Param project_id path string true "Project ID" -// @Router /core/v1/admin/projects/{project_id}/sessions/{session_id}/artifacts [get] +// @Router /core/v1/projects/{project_id}/sessions/{session_id}/artifacts [get] func (h *Handler) adminListSessionArtifacts(w http.ResponseWriter, r *http.Request) { h.listSessionArtifacts(w, r) } @@ -124,7 +124,7 @@ func (h *Handler) adminListSessionArtifacts(w http.ResponseWriter, r *http.Reque // @Success 200 {object} v1.SessionArtifact // @Failure 400,401,404,500,503 {object} v1.ErrorResponse // @Param project_id path string true "Project ID" -// @Router /core/v1/admin/projects/{project_id}/sessions/{session_id}/artifacts/{artifact_id} [get] +// @Router /core/v1/projects/{project_id}/sessions/{session_id}/artifacts/{artifact_id} [get] func (h *Handler) adminGetSessionArtifact(w http.ResponseWriter, r *http.Request) { h.getSessionArtifact(w, r) } @@ -139,7 +139,7 @@ func (h *Handler) adminGetSessionArtifact(w http.ResponseWriter, r *http.Request // @Success 200 {object} v1.SessionArtifactDeleted // @Failure 400,401,404,500,503 {object} v1.ErrorResponse // @Param project_id path string true "Project ID" -// @Router /core/v1/admin/projects/{project_id}/sessions/{session_id}/artifacts/{artifact_id} [delete] +// @Router /core/v1/projects/{project_id}/sessions/{session_id}/artifacts/{artifact_id} [delete] func (h *Handler) adminDeleteSessionArtifact(w http.ResponseWriter, r *http.Request) { h.deleteSessionArtifact(w, r) } @@ -154,7 +154,7 @@ func (h *Handler) adminDeleteSessionArtifact(w http.ResponseWriter, r *http.Requ // @Success 200 {file} binary // @Failure 400,401,404,500,503 {object} v1.ErrorResponse // @Param project_id path string true "Project ID" -// @Router /core/v1/admin/projects/{project_id}/sessions/{session_id}/artifacts/{artifact_id}/content [get] +// @Router /core/v1/projects/{project_id}/sessions/{session_id}/artifacts/{artifact_id}/content [get] func (h *Handler) adminSessionArtifactContent(w http.ResponseWriter, r *http.Request) { h.sessionArtifactContent(w, r) } @@ -168,7 +168,7 @@ func (h *Handler) adminSessionArtifactContent(w http.ResponseWriter, r *http.Req // @Success 200 {object} v1.SessionExecutionConfiguration // @Failure 400,401,404,500,503 {object} v1.ErrorResponse // @Param project_id path string true "Project ID" -// @Router /core/v1/admin/projects/{project_id}/sessions/{session_id}/execution-configuration [get] +// @Router /core/v1/projects/{project_id}/sessions/{session_id}/execution-configuration [get] func (h *Handler) adminGetSessionExecutionConfiguration(w http.ResponseWriter, r *http.Request) { h.getSessionExecutionConfiguration(w, r) } @@ -182,7 +182,7 @@ func (h *Handler) adminGetSessionExecutionConfiguration(w http.ResponseWriter, r // @Success 200 {object} v1.RuntimeObservation // @Failure 400,401,404,500,503 {object} v1.ErrorResponse // @Param project_id path string true "Project ID" -// @Router /core/v1/admin/projects/{project_id}/sessions/{session_id}/runtime-observation [get] +// @Router /core/v1/projects/{project_id}/sessions/{session_id}/runtime-observation [get] func (h *Handler) adminGetRuntimeObservation(w http.ResponseWriter, r *http.Request) { h.getRuntimeObservation(w, r) } @@ -199,7 +199,7 @@ func (h *Handler) adminGetRuntimeObservation(w http.ResponseWriter, r *http.Requ // @Success 200 {object} v1.RuntimeHistory // @Failure 400,401,404,409,503 {object} v1.ErrorResponse // @Param project_id path string true "Project ID" -// @Router /core/v1/admin/projects/{project_id}/sessions/{session_id}/runtime-history [get] +// @Router /core/v1/projects/{project_id}/sessions/{session_id}/runtime-history [get] func (h *Handler) adminGetRuntimeHistory(w http.ResponseWriter, r *http.Request) { h.getRuntimeHistory(w, r) } diff --git a/services/agents-api/internal/api/admin_skills_routes.go b/services/agents-api/internal/api/admin_skills_routes.go index 4329316ae..1f6d1d997 100644 --- a/services/agents-api/internal/api/admin_skills_routes.go +++ b/services/agents-api/internal/api/admin_skills_routes.go @@ -12,7 +12,7 @@ import "net/http" // @Param order query string false "Creation order; omit for descending, explicit empty values are invalid" Enums(asc,desc) // @Success 200 {object} v1.SkillList // @Param project_id path string true "Project ID" -// @Router /core/v1/admin/projects/{project_id}/skills [get] +// @Router /core/v1/projects/{project_id}/skills [get] func (h *Handler) adminListSkills(w http.ResponseWriter, r *http.Request) { h.listSkills(w, r) } @@ -25,7 +25,7 @@ func (h *Handler) adminListSkills(w http.ResponseWriter, r *http.Request) { // @Param skill_id path string true "Skill ID" // @Success 200 {object} v1.Skill // @Param project_id path string true "Project ID" -// @Router /core/v1/admin/projects/{project_id}/skills/{skill_id} [get] +// @Router /core/v1/projects/{project_id}/skills/{skill_id} [get] func (h *Handler) adminGetSkill(w http.ResponseWriter, r *http.Request) { h.getSkill(w, r) } @@ -38,7 +38,7 @@ func (h *Handler) adminGetSkill(w http.ResponseWriter, r *http.Request) { // @Param skill_id path string true "Skill ID" // @Success 200 {object} v1.SkillDeleted // @Param project_id path string true "Project ID" -// @Router /core/v1/admin/projects/{project_id}/skills/{skill_id} [delete] +// @Router /core/v1/projects/{project_id}/skills/{skill_id} [delete] func (h *Handler) adminDeleteSkill(w http.ResponseWriter, r *http.Request) { h.deleteSkill(w, r) } @@ -51,7 +51,7 @@ func (h *Handler) adminDeleteSkill(w http.ResponseWriter, r *http.Request) { // @Param skill_id path string true "Skill ID" // @Success 200 {file} binary // @Param project_id path string true "Project ID" -// @Router /core/v1/admin/projects/{project_id}/skills/{skill_id}/content [get] +// @Router /core/v1/projects/{project_id}/skills/{skill_id}/content [get] func (h *Handler) adminSkillContent(w http.ResponseWriter, r *http.Request) { h.skillContent(w, r) } @@ -67,7 +67,7 @@ func (h *Handler) adminSkillContent(w http.ResponseWriter, r *http.Request) { // @Param order query string false "Version order; omit for descending, explicit empty values are invalid" Enums(asc,desc) // @Success 200 {object} v1.SkillVersionList // @Param project_id path string true "Project ID" -// @Router /core/v1/admin/projects/{project_id}/skills/{skill_id}/versions [get] +// @Router /core/v1/projects/{project_id}/skills/{skill_id}/versions [get] func (h *Handler) adminListSkillVersions(w http.ResponseWriter, r *http.Request) { h.listSkillVersions(w, r) } @@ -81,7 +81,7 @@ func (h *Handler) adminListSkillVersions(w http.ResponseWriter, r *http.Request) // @Param version path string true "Concrete version number" // @Success 200 {object} v1.SkillVersion // @Param project_id path string true "Project ID" -// @Router /core/v1/admin/projects/{project_id}/skills/{skill_id}/versions/{version} [get] +// @Router /core/v1/projects/{project_id}/skills/{skill_id}/versions/{version} [get] func (h *Handler) adminGetSkillVersion(w http.ResponseWriter, r *http.Request) { h.getSkillVersion(w, r) } @@ -95,7 +95,7 @@ func (h *Handler) adminGetSkillVersion(w http.ResponseWriter, r *http.Request) { // @Param version path string true "Concrete version number" // @Success 200 {object} v1.SkillVersionDeleted // @Param project_id path string true "Project ID" -// @Router /core/v1/admin/projects/{project_id}/skills/{skill_id}/versions/{version} [delete] +// @Router /core/v1/projects/{project_id}/skills/{skill_id}/versions/{version} [delete] func (h *Handler) adminDeleteSkillVersion(w http.ResponseWriter, r *http.Request) { h.deleteSkillVersion(w, r) } @@ -109,7 +109,7 @@ func (h *Handler) adminDeleteSkillVersion(w http.ResponseWriter, r *http.Request // @Param version path string true "Concrete version number" // @Success 200 {file} binary // @Param project_id path string true "Project ID" -// @Router /core/v1/admin/projects/{project_id}/skills/{skill_id}/versions/{version}/content [get] +// @Router /core/v1/projects/{project_id}/skills/{skill_id}/versions/{version}/content [get] func (h *Handler) adminSkillVersionContent(w http.ResponseWriter, r *http.Request) { h.skillVersionContent(w, r) } diff --git a/services/agents-api/internal/api/admin_summary.go b/services/agents-api/internal/api/admin_summary.go index 558ac7a8f..00efc5994 100644 --- a/services/agents-api/internal/api/admin_summary.go +++ b/services/agents-api/internal/api/admin_summary.go @@ -67,7 +67,7 @@ func adminSummaryTime(r *http.Request, name string) (*time.Time, error) { // @Param order query string false "Project order" Enums(asc,desc) default(desc) // @Success 200 {object} api.AdminSummaryResponse // @Failure 400,401,404,500 {object} v1.ErrorResponse -// @Router /core/v1/admin/summary [get] +// @Router /core/v1/summary [get] func (h *Handler) adminSummary(w http.ResponseWriter, r *http.Request) { options, ok := readPage(w, r, "project_id", "group_by", "created_after", "created_before") if !ok { diff --git a/services/agents-api/internal/api/admin_vaults_routes.go b/services/agents-api/internal/api/admin_vaults_routes.go index 64499a2e9..70933bd82 100644 --- a/services/agents-api/internal/api/admin_vaults_routes.go +++ b/services/agents-api/internal/api/admin_vaults_routes.go @@ -15,7 +15,7 @@ import "net/http" // @Success 200 {object} v1.VaultList // @Failure 400,401,404,500 {object} v1.ErrorResponse // @Param project_id path string true "Project ID" -// @Router /core/v1/admin/projects/{project_id}/vaults [get] +// @Router /core/v1/projects/{project_id}/vaults [get] func (h *Handler) adminListVaults(w http.ResponseWriter, r *http.Request) { h.listVaults(w, r) } @@ -29,7 +29,7 @@ func (h *Handler) adminListVaults(w http.ResponseWriter, r *http.Request) { // @Success 200 {object} v1.Vault // @Failure 400,401,404,500 {object} v1.ErrorResponse // @Param project_id path string true "Project ID" -// @Router /core/v1/admin/projects/{project_id}/vaults/{vault_id} [get] +// @Router /core/v1/projects/{project_id}/vaults/{vault_id} [get] func (h *Handler) adminGetVault(w http.ResponseWriter, r *http.Request) { h.getVault(w, r) } @@ -43,7 +43,7 @@ func (h *Handler) adminGetVault(w http.ResponseWriter, r *http.Request) { // @Success 200 {object} v1.VaultDeleted // @Failure 400,401,404,413,500 {object} v1.ErrorResponse // @Param project_id path string true "Project ID" -// @Router /core/v1/admin/projects/{project_id}/vaults/{vault_id} [delete] +// @Router /core/v1/projects/{project_id}/vaults/{vault_id} [delete] func (h *Handler) adminDeleteVault(w http.ResponseWriter, r *http.Request) { h.deleteVault(w, r) } @@ -62,7 +62,7 @@ func (h *Handler) adminDeleteVault(w http.ResponseWriter, r *http.Request) { // @Success 200 {object} v1.CredentialList // @Failure 400,401,404,500 {object} v1.ErrorResponse // @Param project_id path string true "Project ID" -// @Router /core/v1/admin/projects/{project_id}/vaults/{vault_id}/credentials [get] +// @Router /core/v1/projects/{project_id}/vaults/{vault_id}/credentials [get] func (h *Handler) adminListCredentials(w http.ResponseWriter, r *http.Request) { h.listCredentials(w, r) } @@ -77,7 +77,7 @@ func (h *Handler) adminListCredentials(w http.ResponseWriter, r *http.Request) { // @Success 200 {object} v1.Credential // @Failure 400,401,404,500 {object} v1.ErrorResponse // @Param project_id path string true "Project ID" -// @Router /core/v1/admin/projects/{project_id}/vaults/{vault_id}/credentials/{credential_id} [get] +// @Router /core/v1/projects/{project_id}/vaults/{vault_id}/credentials/{credential_id} [get] func (h *Handler) adminGetCredential(w http.ResponseWriter, r *http.Request) { h.getCredential(w, r) } @@ -92,7 +92,7 @@ func (h *Handler) adminGetCredential(w http.ResponseWriter, r *http.Request) { // @Success 200 {object} v1.CredentialDeleted // @Failure 400,401,404,413,500 {object} v1.ErrorResponse // @Param project_id path string true "Project ID" -// @Router /core/v1/admin/projects/{project_id}/vaults/{vault_id}/credentials/{credential_id} [delete] +// @Router /core/v1/projects/{project_id}/vaults/{vault_id}/credentials/{credential_id} [delete] func (h *Handler) adminDeleteCredential(w http.ResponseWriter, r *http.Request) { h.deleteCredential(w, r) } diff --git a/services/agents-api/internal/api/auth.go b/services/agents-api/internal/api/auth.go index ed115c579..b12777c16 100644 --- a/services/agents-api/internal/api/auth.go +++ b/services/agents-api/internal/api/auth.go @@ -98,9 +98,9 @@ func (h *Handler) authenticate(next http.Handler) http.Handler { }) } -// authenticateProject guards Files, Skills and Core project extensions, which -// ignore OpenAI-Beta. As observed on Files and Skills (HP-07), a 401 has a null -// code without a Bearer credential and invalid_api_key for a rejected one. +// authenticateProject guards Files and Skills, which ignore OpenAI-Beta. As +// observed on them (HP-07), a 401 has a null code without a Bearer credential +// and invalid_api_key for a rejected one. func (h *Handler) authenticateProject(next http.Handler) http.Handler { return h.authenticateCaller(next, true) } diff --git a/services/agents-api/internal/api/core_metrics.go b/services/agents-api/internal/api/core_metrics.go index 414204d9d..7e776859c 100644 --- a/services/agents-api/internal/api/core_metrics.go +++ b/services/agents-api/internal/api/core_metrics.go @@ -25,7 +25,7 @@ func WithCoreMetrics(service CoreMetricsService) Option { // @Param range query string false "Time range (default 1h)" Enums(1h,6h,24h,7d) // @Success 200 {object} coremetrics.View // @Failure 400,401,503 {object} v1.ErrorResponse -// @Router /core/v1/admin/core-metrics [get] +// @Router /core/v1/metrics [get] func (h *Handler) getCoreMetrics(w http.ResponseWriter, r *http.Request) { values, err := url.ParseQuery(r.URL.RawQuery) if err != nil || len(values) > 1 || (len(values) == 1 && len(values["range"]) != 1) { diff --git a/services/agents-api/internal/api/core_metrics_test.go b/services/agents-api/internal/api/core_metrics_test.go index 4653a42b0..e0827d3d4 100644 --- a/services/agents-api/internal/api/core_metrics_test.go +++ b/services/agents-api/internal/api/core_metrics_test.go @@ -33,7 +33,7 @@ func TestCoreMetricsAdministratorContract(t *testing.T) { if err != nil { t.Fatal(err) } - path := "/core/v1/admin/core-metrics" + path := "/core/v1/metrics" for _, token := range []string{"", "unknown", "caller"} { w := projectKeyHTTP(h, "GET", path, token, "") if w.Code != 401 || f.calls != 0 { diff --git a/services/agents-api/internal/api/core_routes.go b/services/agents-api/internal/api/core_routes.go new file mode 100644 index 000000000..566517c2d --- /dev/null +++ b/services/agents-api/internal/api/core_routes.go @@ -0,0 +1,26 @@ +package api + +import ( + "net/http" + + "github.com/go-chi/chi/v5" +) + +// registerCoreRoutes serves /core/v1 to Core Web's server and operator scripts. +// Every request, including one for an unknown path, must carry the Core key; +// Project API keys and machine credentials are rejected here. +func (h *Handler) registerCoreRoutes(router chi.Router) { + if h.deploymentAuth == nil { + return + } + router.Route("/core/v1", func(r chi.Router) { + r.Use(h.deploymentAuth.authenticate) + r.NotFound(func(w http.ResponseWriter, _ *http.Request) { + writeError(w, http.StatusNotFound, "not_found", "This Core operation does not exist.") + }) + h.registerProjectAPIKeyRoutes(r) + h.registerAdminResourceRoutes(r) + h.registerExecutorCredentialRoutes(r) + h.registerSandboxManagerRoutes(r) + }) +} diff --git a/services/agents-api/internal/api/environment_executor_management.go b/services/agents-api/internal/api/environment_executor_management.go index f520870ff..16345c96c 100644 --- a/services/agents-api/internal/api/environment_executor_management.go +++ b/services/agents-api/internal/api/environment_executor_management.go @@ -12,40 +12,78 @@ import ( "github.com/google/uuid" ) +// EnvironmentExecutorStore manages the executor credentials of a Project's +// self_hosted Environments. The Project's principal is the credential's +// execution principal; the Core key that authorizes the request is not. type EnvironmentExecutorStore interface { - IssueEnvironmentExecutorCredential(context.Context, identity.Principal, string, string, bool) (store.IssuedExecutorCredential, error) - RevokeEnvironmentExecutorCredential(context.Context, identity.Principal, string, string) error + ListProjectExecutorCredentials(context.Context, identity.Principal, string) ([]store.ExecutorCredential, error) + IssueProjectExecutorCredential(context.Context, identity.Principal, string, string, bool) (store.IssuedExecutorCredential, error) + RevokeProjectExecutorCredential(context.Context, identity.Principal, string, string) error } type EnvironmentExecutorCredentialRequest struct { - KeyID string `json:"key_id"` + KeyID string `json:"key_id" format:"uuid"` Rotate bool `json:"rotate,omitempty"` } -func (h *Handler) registerEnvironmentExecutorRoutes(r chi.Router) { +// ExecutorCredentialList holds credential metadata only, never a secret. +type ExecutorCredentialList struct { + Data []store.ExecutorCredential `json:"data"` +} + +// registerExecutorCredentialRoutes adds executor credential issuance to the +// Core-key-authenticated /core/v1 router. +func (h *Handler) registerExecutorCredentialRoutes(r chi.Router) { s, ok := h.store.(EnvironmentExecutorStore) + if !ok || h.projectKeys == nil { + return + } + const path = "/projects/{project_id}/environments/{environment_id}/executor-credentials" + r.Get(path, func(w http.ResponseWriter, r *http.Request) { h.listExecutorCredentials(w, r, s) }) + r.Post(path, func(w http.ResponseWriter, r *http.Request) { h.issueExecutorCredential(w, r, s) }) + r.Delete(path+"/{key_id}", func(w http.ResponseWriter, r *http.Request) { h.revokeExecutorCredential(w, r, s) }) +} + +// @Summary List a self_hosted Environment's executor credentials +// @Description Core key only. Returns metadata of the credentials restricted to this Environment, oldest first; secrets are never listed. The Environment must be a self_hosted Environment of the Project whose Session exists; otherwise 404. +// @Tags Executor Credentials +// @Produce json +// @Security DeploymentAdminAuth +// @Param project_id path string true "Project UUID" +// @Param environment_id path string true "Environment UUID" +// @Success 200 {object} api.ExecutorCredentialList +// @Failure 401,404,500 {object} v1.ErrorResponse +// @Router /core/v1/projects/{project_id}/environments/{environment_id}/executor-credentials [get] +func (h *Handler) listExecutorCredentials(w http.ResponseWriter, r *http.Request, s EnvironmentExecutorStore) { + binding, ok := h.adminProjectScope(w, r) if !ok { return } - r.Route("/core/v1/environments/{environment_id}/executor-credentials", func(r chi.Router) { - r.Use(h.authenticateProject) - r.Post("/", func(w http.ResponseWriter, r *http.Request) { h.issueEnvironmentExecutorCredential(w, r, s) }) - r.Delete("/{key_id}", func(w http.ResponseWriter, r *http.Request) { h.revokeEnvironmentExecutorCredential(w, r, s) }) - }) + credentials, err := s.ListProjectExecutorCredentials(r.Context(), binding.Principal, chi.URLParam(r, "environment_id")) + if err != nil { + writeStoreError(w, r, err) + return + } + writeJSON(w, http.StatusOK, ExecutorCredentialList{Data: credentials}) } -// @Summary Issue or explicitly rotate an Environment executor credential -// @Description Core extension, not an upstream Agents API operation. Uses the project caller key and exact live self_hosted Session creator. Returns a connect-only secret once, restricted to this Environment. Repeating an issuance key_id returns 409; after an uncertain response explicitly rotate that same key_id. Rotation retains immutable ownership and invalidates the old secret. No Session API authority is granted. -// @Tags Environment Executor +// @Summary Issue or explicitly rotate a self_hosted Environment executor credential +// @Description Core key only. Returns a connect-only secret once, restricted to daemon enrollment and connection for this Environment, with the Project's principal as its execution principal. Repeating an issuance key_id returns 409 executor_credential_exists; after an uncertain response, list the credentials and rotate that key_id explicitly. Rotation keeps the key's Environment, invalidates the old secret and restores a revoked key; rotating an unknown key_id returns 404. The Environment must be a self_hosted Environment of the Project whose Session exists; otherwise 404. Each write records an administrator audit entry without the secret. +// @Tags Executor Credentials // @Accept json // @Produce json -// @Security BearerAuth +// @Security DeploymentAdminAuth +// @Param project_id path string true "Project UUID" // @Param environment_id path string true "Environment UUID" // @Param body body api.EnvironmentExecutorCredentialRequest true "Request" // @Success 201 {object} store.IssuedExecutorCredential // @Failure 400,401,404,409,500 {object} v1.ErrorResponse -// @Router /core/v1/environments/{environment_id}/executor-credentials [post] -func (h *Handler) issueEnvironmentExecutorCredential(w http.ResponseWriter, r *http.Request, s EnvironmentExecutorStore) { +// @Router /core/v1/projects/{project_id}/environments/{environment_id}/executor-credentials [post] +func (h *Handler) issueExecutorCredential(w http.ResponseWriter, r *http.Request, s EnvironmentExecutorStore) { + binding, ok := h.adminProjectScope(w, r) + if !ok { + return + } raw, ok := readJSONBody(w, r) if !ok { return @@ -56,7 +94,7 @@ func (h *Handler) issueEnvironmentExecutorCredential(w http.ResponseWriter, r *h writeStoreError(w, r, store.ErrInvalidInput) return } - credential, err := s.IssueEnvironmentExecutorCredential(r.Context(), r.Context().Value(principalContextKey{}).(identity.Principal), chi.URLParam(r, "environment_id"), input.KeyID, input.Rotate) + credential, err := s.IssueProjectExecutorCredential(r.Context(), binding.Principal, chi.URLParam(r, "environment_id"), input.KeyID, input.Rotate) if err != nil { writeStoreError(w, r, err) return @@ -64,22 +102,27 @@ func (h *Handler) issueEnvironmentExecutorCredential(w http.ResponseWriter, r *h writeJSON(w, http.StatusCreated, credential) } -// @Summary Revoke an Environment executor credential -// @Description Core extension authenticated by the project caller key. Revokes only the caller's exact Environment-restricted credential. Repeated revocation is safe. Revocation denies future enrollment and dispatch but does not stop caller-owned compute or prove process quiescence. -// @Tags Environment Executor -// @Security BearerAuth +// @Summary Revoke a self_hosted Environment executor credential +// @Description Core key only. Revokes one credential restricted to this Environment; repeated revocation is safe. Revocation denies future enrollment and connection but does not stop executor-owned compute. The Environment must be a self_hosted Environment of the Project whose Session exists; otherwise 404. +// @Tags Executor Credentials +// @Security DeploymentAdminAuth +// @Param project_id path string true "Project UUID" // @Param environment_id path string true "Environment UUID" // @Param key_id path string true "Executor key UUID" // @Success 204 -// @Failure 400,401,404,500 {object} v1.ErrorResponse -// @Router /core/v1/environments/{environment_id}/executor-credentials/{key_id} [delete] -func (h *Handler) revokeEnvironmentExecutorCredential(w http.ResponseWriter, r *http.Request, s EnvironmentExecutorStore) { +// @Failure 401,404,500 {object} v1.ErrorResponse +// @Router /core/v1/projects/{project_id}/environments/{environment_id}/executor-credentials/{key_id} [delete] +func (h *Handler) revokeExecutorCredential(w http.ResponseWriter, r *http.Request, s EnvironmentExecutorStore) { + binding, ok := h.adminProjectScope(w, r) + if !ok { + return + } keyID := chi.URLParam(r, "key_id") if !executorManagementID(keyID) { writeStoreError(w, r, store.ErrNotFound) return } - if err := s.RevokeEnvironmentExecutorCredential(r.Context(), r.Context().Value(principalContextKey{}).(identity.Principal), chi.URLParam(r, "environment_id"), keyID); err != nil { + if err := s.RevokeProjectExecutorCredential(r.Context(), binding.Principal, chi.URLParam(r, "environment_id"), keyID); err != nil { writeStoreError(w, r, err) return } diff --git a/services/agents-api/internal/api/environment_executor_management_test.go b/services/agents-api/internal/api/environment_executor_management_test.go index 332de2c2a..207a2e063 100644 --- a/services/agents-api/internal/api/environment_executor_management_test.go +++ b/services/agents-api/internal/api/environment_executor_management_test.go @@ -3,11 +3,12 @@ package api import ( "context" "encoding/json" - "net/http/httptest" "strings" "testing" + "time" "github.com/MiniMax-AI-Dev/parsar/internal/agentdaemon/device" + "github.com/MiniMax-AI-Dev/parsar/services/agents-api/internal/adminaudit" "github.com/MiniMax-AI-Dev/parsar/services/agents-api/internal/identity" "github.com/MiniMax-AI-Dev/parsar/services/agents-api/internal/store" "github.com/google/uuid" @@ -17,78 +18,98 @@ type executorManagementFixture struct { ResourceStore principal identity.Principal environment, key string - rotate bool + rotate, audited bool calls int err error } -func (f *executorManagementFixture) IssueEnvironmentExecutorCredential(_ context.Context, principal identity.Principal, environment, key string, rotate bool) (store.IssuedExecutorCredential, error) { - f.principal, f.environment, f.key, f.rotate = principal, environment, key, rotate +func (f *executorManagementFixture) record(ctx context.Context, principal identity.Principal, environment, key string) { + f.principal, f.environment, f.key = principal, environment, key + _, f.audited = adminaudit.FromContext(ctx) f.calls++ +} +func (f *executorManagementFixture) ListProjectExecutorCredentials(ctx context.Context, principal identity.Principal, environment string) ([]store.ExecutorCredential, error) { + f.record(ctx, principal, environment, "") + return []store.ExecutorCredential{{KeyID: "listed", CreatedAt: time.Unix(1, 0).UTC()}}, f.err +} +func (f *executorManagementFixture) IssueProjectExecutorCredential(ctx context.Context, principal identity.Principal, environment, key string, rotate bool) (store.IssuedExecutorCredential, error) { + f.record(ctx, principal, environment, key) + f.rotate = rotate return store.IssuedExecutorCredential{KeyID: key, EnvironmentID: environment, Token: "synthetic-connect-only"}, f.err } -func (f *executorManagementFixture) RevokeEnvironmentExecutorCredential(_ context.Context, principal identity.Principal, environment, key string) error { - f.principal, f.environment, f.key = principal, environment, key - f.calls++ +func (f *executorManagementFixture) RevokeProjectExecutorCredential(ctx context.Context, principal identity.Principal, environment, key string) error { + f.record(ctx, principal, environment, key) return f.err } -func TestEnvironmentExecutorManagementHTTP(t *testing.T) { +func TestProjectExecutorCredentialsHTTP(t *testing.T) { f := &executorManagementFixture{} - tenant, environment, keyID := uuid.NewString(), uuid.NewString(), uuid.NewString() - auth, err := NewAuthenticator([]APIKey{{TokenSHA256: device.HashCredential("caller"), TenantID: tenant, OrganizationID: "org", ProjectID: "project", SubjectKind: "user", SubjectID: "creator"}}) + key := callerBinding() + auth, err := NewAuthenticator([]APIKey{key}) if err != nil { t.Fatal(err) } - h, err := NewHandler(f, auth, "codex") + admin, err := NewDeploymentAuthenticator([]string{device.HashCredential("admin")}) if err != nil { t.Fatal(err) } - path := "/core/v1/environments/" + environment + "/executor-credentials" - request := func(method, path, body, token string) *httptest.ResponseRecorder { - t.Helper() - r := httptest.NewRequest(method, path, strings.NewReader(body)) - r.Header.Set("Authorization", "Bearer "+token) - r.Header.Set("Content-Type", "application/json") - r.Header.Set("X-Tenant-ID", "untrusted") - w := httptest.NewRecorder() - h.ServeHTTP(w, r) - return w + h, err := NewHandler(f, auth, "codex", WithProjectAPIKeys(managementProjectStore(key), admin)) + if err != nil { + t.Fatal(err) } + environment, keyID := uuid.NewString(), uuid.NewString() + path := "/core/v1/projects/" + managementProjectID + "/environments/" + environment + "/executor-credentials" body := `{"key_id":"` + keyID + `"}` - for _, token := range []string{"", "synthetic-connect-only", "admin"} { - if w := request("POST", path, body, token); w.Code != 401 || f.calls != 0 { - t.Fatal("non-caller authority accepted", w.Code) + // Only the Core key authorizes; the Project API key and the old route are refused. + for _, token := range []string{"", "caller", "synthetic-connect-only"} { + if w := projectKeyHTTP(h, "POST", path, token, body); w.Code != 401 || f.calls != 0 { + t.Fatal("non-Core credential accepted", w.Code) } } - w := request("POST", path, body, "caller") + if w := projectKeyHTTP(h, "POST", "/core/v1/environments/"+environment+"/executor-credentials", "caller", body); w.Code != 401 || f.calls != 0 { + t.Fatal("old Project-key route", w.Code) + } + if w := projectKeyHTTP(h, "POST", "/core/v1/environments/"+environment+"/executor-credentials", "admin", body); w.Code != 404 || f.calls != 0 { + t.Fatal("old route still served", w.Code) + } + if w := projectKeyHTTP(h, "GET", "/core/v1/projects/"+uuid.NewString()+"/environments/"+environment+"/executor-credentials", "admin", ""); w.Code != 404 || f.calls != 0 { + t.Fatal("unknown Project", w.Code) + } + + w := projectKeyHTTP(h, "GET", path, "admin", "") + if w.Code != 200 || w.Body.String() != `{"data":[{"key_id":"listed","created_at":"1970-01-01T00:00:01Z","revoked_at":null}]}`+"\n" { + t.Fatal("list", w.Code, w.Body) + } + w = projectKeyHTTP(h, "POST", path, "admin", body) var got map[string]string if w.Code != 201 || w.Header().Get("Cache-Control") != "no-store" || json.Unmarshal(w.Body.Bytes(), &got) != nil || len(got) != 3 || got["environment_id"] != environment || got["key_id"] != keyID || got["executor_token"] != "synthetic-connect-only" { - t.Fatal("credential response", w.Code) + t.Fatal("credential response", w.Code, w.Body) } - if f.principal.TenantID != tenant || f.principal.SubjectID != "creator" || f.rotate { - t.Fatal("authority not derived from caller") + // The Project's principal, not a caller, owns the credential; the write is audited. + if f.principal.TenantID != key.TenantID || f.principal.SubjectID != key.SubjectID || f.environment != environment || f.rotate || !f.audited { + t.Fatal("credential not bound to the Project") } - w = request("POST", path, `{"key_id":"`+keyID+`","rotate":true}`, "caller") - if w.Code != 201 || !f.rotate { + if w := projectKeyHTTP(h, "POST", path, "admin", `{"key_id":"`+keyID+`","rotate":true}`); w.Code != 201 || !f.rotate { t.Fatal("explicit rotation", w.Code) } for _, body := range []string{`{}`, `{"key_id":"invalid"}`, `{"key_id":null}`, `{"key_id":"` + keyID + `","environment_id":"other"}`, `{"key_id":"` + keyID + `","executor_token":"import"}`, `{"key_id":"` + keyID + `","rotate":null}`} { before := f.calls - if w := request("POST", path, body, "caller"); w.Code != 400 || before != f.calls { + if w := projectKeyHTTP(h, "POST", path, "admin", body); w.Code != 400 || before != f.calls { t.Fatal("invalid request accepted", w.Code, body) } } f.err = store.ErrExecutorCredentialExists - if w := request("POST", path, body, "caller"); w.Code != 409 || strings.Contains(w.Body.String(), "synthetic-connect-only") { - t.Fatal("uncertain retry", w.Code) + if w := projectKeyHTTP(h, "POST", path, "admin", body); w.Code != 409 || !strings.Contains(w.Body.String(), `"code":"executor_credential_exists"`) || strings.Contains(w.Body.String(), "synthetic-connect-only") { + t.Fatal("uncertain retry", w.Code, w.Body) } f.err = store.ErrNotFound - if w := request("DELETE", path+"/"+keyID, "", "caller"); w.Code != 404 { + if w := projectKeyHTTP(h, "DELETE", path+"/"+keyID, "admin", ""); w.Code != 404 { t.Fatal("foreign revocation", w.Code) } f.err = nil - if w := request("DELETE", path+"/"+keyID, "", "caller"); w.Code != 204 || w.Body.Len() != 0 { - t.Fatal("revocation", w.Code) + for range 2 { + if w := projectKeyHTTP(h, "DELETE", path+"/"+keyID, "admin", ""); w.Code != 204 || w.Body.Len() != 0 || f.key != keyID { + t.Fatal("revocation", w.Code) + } } } diff --git a/services/agents-api/internal/api/handler.go b/services/agents-api/internal/api/handler.go index 1b77386e3..7542545e4 100644 --- a/services/agents-api/internal/api/handler.go +++ b/services/agents-api/internal/api/handler.go @@ -101,15 +101,8 @@ func (h *Handler) routes() *chi.Mux { r.Head("/v1/files/{file_id}/content", methodNotAllowed) r.Delete("/v1/files/{file_id}", h.deleteSourceFile) }) - h.registerSandboxManagerRoutes(router) - if h.deploymentAuth != nil && h.projectKeys != nil { - router.Route("/core/v1/admin", func(r chi.Router) { - r.Use(h.deploymentAuth.authenticate) - h.registerProjectAPIKeyRoutes(r) - h.registerAdminResourceRoutes(r) - }) - } - h.registerEnvironmentExecutorRoutes(router) + h.registerSandboxNodeRoutes(router) + h.registerCoreRoutes(router) router.Route("/v1", func(r chi.Router) { r.Use(h.authenticate) r.Post("/vaults", h.createVault) diff --git a/services/agents-api/internal/api/project_api_keys.go b/services/agents-api/internal/api/project_api_keys.go index 5d7e3f4cb..8fa4c4847 100644 --- a/services/agents-api/internal/api/project_api_keys.go +++ b/services/agents-api/internal/api/project_api_keys.go @@ -40,7 +40,7 @@ func WithProjectAPIKeys(s ProjectAPIKeyStore, auth *DeploymentAuthenticator) Opt } } func (h *Handler) registerProjectAPIKeyRoutes(r chi.Router) { - if h.projectKeys == nil || h.deploymentAuth == nil { + if h.projectKeys == nil { return } r.Get("/projects", h.listProjects) @@ -109,7 +109,7 @@ func adminCatalogPage(r *http.Request) (string, int, bool, error) { // @Param order query string false "asc or desc by Project ID" // @Success 200 {object} store.ProjectPage // @Failure 400,401,404,500 {object} v1.ErrorResponse -// @Router /core/v1/admin/projects [get] +// @Router /core/v1/projects [get] func (h *Handler) listProjects(w http.ResponseWriter, r *http.Request) { after, limit, ascending, err := adminCatalogPage(r) if err != nil { @@ -132,7 +132,7 @@ func (h *Handler) listProjects(w http.ResponseWriter, r *http.Request) { // @Param body body api.ProjectRequest true "Project display name" // @Success 201 {object} store.Project // @Failure 400,401,409,500 {object} v1.ErrorResponse -// @Router /core/v1/admin/projects [post] +// @Router /core/v1/projects [post] func (h *Handler) createProject(w http.ResponseWriter, r *http.Request) { raw, ok := readJSONBodyLimit(w, r, 4096, "Project request is too large.") if !ok { @@ -162,7 +162,7 @@ func (h *Handler) createProject(w http.ResponseWriter, r *http.Request) { // @Param body body api.ProjectRequest true "Project display name" // @Success 200 {object} store.Project // @Failure 400,401,404,409,500 {object} v1.ErrorResponse -// @Router /core/v1/admin/projects/{project_id} [post] +// @Router /core/v1/projects/{project_id} [post] func (h *Handler) renameProject(w http.ResponseWriter, r *http.Request) { binding, ok := h.adminProjectScope(w, r) if !ok { @@ -192,7 +192,7 @@ func (h *Handler) renameProject(w http.ResponseWriter, r *http.Request) { // @Param project_id path string true "Project UUID" // @Success 200 {object} store.Project // @Failure 401,404,409,500 {object} v1.ErrorResponse -// @Router /core/v1/admin/projects/{project_id}/archive [post] +// @Router /core/v1/projects/{project_id}/archive [post] func (h *Handler) archiveProject(w http.ResponseWriter, r *http.Request) { binding, ok := h.adminProjectScope(w, r) if !ok { @@ -216,7 +216,7 @@ func (h *Handler) archiveProject(w http.ResponseWriter, r *http.Request) { // @Param order query string false "asc or desc by key ID" // @Success 200 {object} store.ProjectAPIKeyPage // @Failure 400,401,404,500 {object} v1.ErrorResponse -// @Router /core/v1/admin/projects/{project_id}/keys [get] +// @Router /core/v1/projects/{project_id}/keys [get] func (h *Handler) listProjectAPIKeys(w http.ResponseWriter, r *http.Request) { binding, ok := h.adminProjectScope(w, r) if !ok { @@ -244,7 +244,7 @@ func (h *Handler) listProjectAPIKeys(w http.ResponseWriter, r *http.Request) { // @Param body body api.ProjectAPIKeyRequest true "Key display name" // @Success 201 {object} store.IssuedProjectAPIKey // @Failure 400,401,404,409,500 {object} v1.ErrorResponse -// @Router /core/v1/admin/projects/{project_id}/keys [post] +// @Router /core/v1/projects/{project_id}/keys [post] func (h *Handler) createProjectAPIKey(w http.ResponseWriter, r *http.Request) { binding, ok := h.adminProjectScope(w, r) if !ok { @@ -275,7 +275,7 @@ func (h *Handler) createProjectAPIKey(w http.ResponseWriter, r *http.Request) { // @Param key_id path string true "API key UUID" // @Success 200 {object} api.SandboxMutationResponse // @Failure 401,404,409,500 {object} v1.ErrorResponse -// @Router /core/v1/admin/projects/{project_id}/keys/{key_id} [delete] +// @Router /core/v1/projects/{project_id}/keys/{key_id} [delete] func (h *Handler) revokeProjectAPIKey(w http.ResponseWriter, r *http.Request) { binding, ok := h.adminProjectScope(w, r) if !ok { diff --git a/services/agents-api/internal/api/project_api_keys_test.go b/services/agents-api/internal/api/project_api_keys_test.go index 6cd47c81e..046bed20c 100644 --- a/services/agents-api/internal/api/project_api_keys_test.go +++ b/services/agents-api/internal/api/project_api_keys_test.go @@ -115,11 +115,11 @@ func TestAdministratorCredentialSeparation(t *testing.T) { } func TestAdminCatalogPageLimits(t *testing.T) { for _, query := range []string{"limit=101", "limit=0", "limit=bad", "limit=1&limit=2", "order=sideways", "order=asc&order=desc", "after=a&after=b"} { - if _, _, _, err := adminCatalogPage(httptest.NewRequest("GET", "/core/v1/admin/projects?"+query, nil)); err == nil { + if _, _, _, err := adminCatalogPage(httptest.NewRequest("GET", "/core/v1/projects?"+query, nil)); err == nil { t.Errorf("invalid page accepted: %s", query) } } - _, limit, ascending, err := adminCatalogPage(httptest.NewRequest("GET", "/core/v1/admin/projects?limit=100&order=asc", nil)) + _, limit, ascending, err := adminCatalogPage(httptest.NewRequest("GET", "/core/v1/projects?limit=100&order=asc", nil)) if err != nil || limit != 100 || !ascending { t.Fatal("valid maximum page rejected", err) } diff --git a/services/agents-api/internal/api/routing_test.go b/services/agents-api/internal/api/routing_test.go index d58867412..4dd4c6967 100644 --- a/services/agents-api/internal/api/routing_test.go +++ b/services/agents-api/internal/api/routing_test.go @@ -318,7 +318,7 @@ func TestEveryRouteAuthenticatesItsCanonicalPath(t *testing.T) { } routes++ clean := concretePath(route) - admin := strings.HasPrefix(route, "/core/v1/sandbox/") || strings.HasPrefix(route, "/core/v1/admin/") + admin := strings.HasPrefix(route, "/core/v1/") betaGroup := strings.HasPrefix(route, "/v1/") && !strings.HasPrefix(route, "/v1/files") && !strings.HasPrefix(route, "/v1/skills") for _, header := range credentials { projectKey := header.Get("Authorization") == "Bearer "+routingKey || header.Get("Authorization") == "Bearer "+routingDerivedKey @@ -346,8 +346,8 @@ func TestEveryRouteAuthenticatesItsCanonicalPath(t *testing.T) { if err != nil { t.Fatal(err) } - for _, route := range []string{"GET /core/v1/admin/projects", "POST /core/v1/admin/projects", - "DELETE /core/v1/admin/projects/{project_id}/keys/{key_id}", "GET /core/v1/sandbox/nodes", "DELETE /core/v1/environments/{environment_id}/executor-credentials/{key_id}"} { + for _, route := range []string{"GET /core/v1/projects", "POST /core/v1/projects", + "DELETE /core/v1/projects/{project_id}/keys/{key_id}", "GET /core/v1/sandbox/nodes", "DELETE /core/v1/projects/{project_id}/environments/{environment_id}/executor-credentials/{key_id}"} { if !walked[route] { t.Errorf("route %s was not walked", route) } @@ -393,10 +393,10 @@ func TestEveryRouteAuthenticatesItsCanonicalPath(t *testing.T) { {"/core/v1/sandbox/%2E%2E/%2E%2E/%2E%2E/v1/agents", withHeaders([]string{"Authorization", "Bearer " + routingAdminKey}, beta), http.StatusUnauthorized, ""}, {"/core/v1/sandbox/nodes%2F..%2F..%2F..%2Fv1%2Fagents", withHeaders([]string{"Authorization", "Bearer " + routingAdminKey}, beta), http.StatusNotFound, ""}, // Project API key management keeps deployment administrator authority. - {"/v1/%2E%2E/core/v1/admin/projects/" + device.HashCredential(routingKey), withHeaders(project, beta), http.StatusUnauthorized, "invalid_admin_key"}, - {"/v1/agents//../../core/v1/admin/projects/" + device.HashCredential(routingKey), withHeaders([]string{"Authorization", "Bearer " + routingDerivedKey}, beta), http.StatusUnauthorized, "invalid_admin_key"}, + {"/v1/%2E%2E/core/v1/projects/" + device.HashCredential(routingKey), withHeaders(project, beta), http.StatusUnauthorized, "invalid_admin_key"}, + {"/v1/agents//../../core/v1/projects/" + device.HashCredential(routingKey), withHeaders([]string{"Authorization", "Bearer " + routingDerivedKey}, beta), http.StatusUnauthorized, "invalid_admin_key"}, {"/v1/x{/..%2F..%2Fcore/v1/project-api-keys/" + device.HashCredential(routingKey), http.Header{}, http.StatusBadRequest, "invalid_beta"}, - {"/core/v1/admin/projects/" + device.HashCredential(routingKey) + "/%2E%2E/%2E%2E/%2E%2E/%2E%2E/%2E%2E/v1/agents", withHeaders([]string{"Authorization", "Bearer " + routingAdminKey}, beta), http.StatusUnauthorized, ""}, + {"/core/v1/projects/" + device.HashCredential(routingKey) + "/%2E%2E/%2E%2E/%2E%2E/%2E%2E/%2E%2E/v1/agents", withHeaders([]string{"Authorization", "Bearer " + routingAdminKey}, beta), http.StatusUnauthorized, ""}, } { got := serve(handler, http.MethodGet, test.target, "", test.header) if got.Code != test.status || (test.code != "" && !strings.Contains(got.Body.String(), `"code":"`+test.code+`"`)) { @@ -443,8 +443,8 @@ func TestOpenAIBetaHeaderPrecedesAuthentication(t *testing.T) { } // Every 401 is invalid_request_error (HP-07). Beta 401s have a null code; -// Files, Skills and Core project extensions report invalid_api_key only for a -// rejected Bearer credential. WWW-Authenticate and the message are unchanged. +// Files and Skills report invalid_api_key only for a rejected Bearer +// credential. WWW-Authenticate and the message are unchanged. func TestUnauthorizedEnvelopes(t *testing.T) { handler, _, s := routingFixture(t) invalidKey := strings.Replace(unauthorizedV1, `"code":null`, `"code":"invalid_api_key"`, 1) @@ -469,7 +469,6 @@ func TestUnauthorizedEnvelopes(t *testing.T) { {http.MethodGet, "/v1/agents/" + s.agent.ID, unauthorizedV1}, {http.MethodGet, "/v1/files/file-missing", test.project}, {http.MethodGet, "/v1/skills/skill-missing", test.project}, - {http.MethodDelete, "/core/v1/environments/" + uuid.NewString() + "/executor-credentials/" + uuid.NewString(), test.project}, } { got := serve(handler, route.method, route.path, "", header) if got.Code != http.StatusUnauthorized || got.Body.String() != route.want || got.Header().Get("WWW-Authenticate") != "Bearer" { @@ -505,14 +504,15 @@ func TestMethodNotAllowedListsRouteMethods(t *testing.T) { } } // Routes outside the Beta group and unknown methods use the same 405. - executor := "/core/v1/environments/" + uuid.NewString() + "/executor-credentials" + executor := "/core/v1/projects/" + uuid.NewString() + "/environments/" + uuid.NewString() + "/executor-credentials" + core := withHeaders([]string{"Authorization", "Bearer " + routingAdminKey}) for _, test := range []struct { method, path, allow string header http.Header }{ {http.MethodPost, "/healthz", "GET,HEAD", nil}, - {http.MethodPut, executor, "POST", withHeaders(project)}, - {http.MethodGet, executor + "/" + uuid.NewString(), "DELETE", withHeaders(project)}, + {http.MethodPut, executor, "GET,HEAD,POST", core}, + {http.MethodGet, executor + "/" + uuid.NewString(), "DELETE", core}, {"FOO", "/v1/agents", "GET,HEAD,POST", nil}, {"FOO", "/v1//agents/" + s.agent.ID, "GET,HEAD,POST,DELETE", nil}, } { @@ -793,9 +793,9 @@ var canonicalPathSeeds = []string{ "v1/x\\/..%5C..%5Ccore/v1/sandbox/nodes", "v1/\xc3\xa9/../../core/v1/sandbox/nodes", "v1/%2E%2E/core/v1/sandbox/nodes", "v1/files/..%2F..%2Fv1/skills", "0\"%2F", "core/v1/sandbox/nodes{%2F..%2F..%2F..%2Fv1/agents", "v1/agents/%252F..", "api/v1/agent-daemon/%2E%2E/%2E%2E/%2E%2E/v1/agents", "v1/x{/%2e./api/v1/sandbox-node/identity", "v1/agents/%7E%5F%2D%41", - "core/v1/environments/x/executor-credentials/%2E%2E/%2E%2E/%2E%2E/%2E%2E/core/v1/sandbox/nodes", + "core/v1/projects/x/environments/y/executor-credentials/%2E%2E/%2E%2E/%2E%2E/%2E%2E/%2E%2E/%2E%2E/%2E%2E/v1/agents", "v1/x{/..%2F..%2Fcore/v1/project-api-keys/x", "core/v1/project-api-keys/x/%2E%2E/%2E%2E/%2E%2E/%2E%2E/v1/agents", - "v1/%2E%2E/core/v1/admin/projects/x/", "core/v1/project-api-keys//x/y", + "v1/%2E%2E/core/v1/projects/x/", "core/v1/project-api-keys//x/y", } // Differential property over arbitrary request paths: the routed outcome for diff --git a/services/agents-api/internal/api/sandbox_manager.go b/services/agents-api/internal/api/sandbox_manager.go index e251dea62..fde7ce70d 100644 --- a/services/agents-api/internal/api/sandbox_manager.go +++ b/services/agents-api/internal/api/sandbox_manager.go @@ -31,31 +31,34 @@ type SandboxEnrollmentTokenRequest struct { func WithSandboxManager(s *store.Store, auth *DeploymentAuthenticator) Option { return func(h *Handler) { h.sandboxStore = s; h.deploymentAuth = auth } } -func (h *Handler) registerSandboxManagerRoutes(r chi.Router) { + +// registerSandboxNodeRoutes serves node machine connections. They authenticate +// with an enrollment token or node credential, never the Core key. +func (h *Handler) registerSandboxNodeRoutes(r chi.Router) { if h.sandboxStore == nil { return } - // Machine connections authenticate with an enrollment token or node - // credential, never the Core key. r.Post("/api/v1/sandbox-node/enroll", h.enrollSandboxNode) r.Get("/api/v1/sandbox-node/identity", h.sandboxNodeIdentity) r.Get("/api/v1/sandbox-node/configuration", h.sandboxNodeConfiguration) - if h.deploymentAuth == nil { - return - } - r.Route("/core/v1/sandbox", func(r chi.Router) { - r.Use(h.deploymentAuth.authenticate) - r.Get("/deployment", h.sandboxDeployment) - r.Post("/deployment", h.initializeSandboxDeployment) - r.Put("/deployment", h.updateSandboxDeployment) - r.Patch("/deployment/maintenance", h.setSandboxMaintenance) - r.Get("/nodes", h.sandboxNodes) - r.Get("/nodes/{node_id}", h.sandboxNodeDetail) - r.Patch("/nodes/{node_id}", h.updateSandboxNode) - r.Delete("/nodes/{node_id}", h.removeSandboxNode) - r.Get("/nodes/{node_id}/allocations", h.sandboxAllocations) - r.Post("/enrollment-tokens", h.createSandboxEnrollment) - }) +} + +// registerSandboxManagerRoutes adds sandbox deployment and node administration +// to the Core-key-authenticated /core/v1 router. +func (h *Handler) registerSandboxManagerRoutes(r chi.Router) { + if h.sandboxStore == nil { + return + } + r.Get("/sandbox/deployment", h.sandboxDeployment) + r.Post("/sandbox/deployment", h.initializeSandboxDeployment) + r.Put("/sandbox/deployment", h.updateSandboxDeployment) + r.Patch("/sandbox/deployment/maintenance", h.setSandboxMaintenance) + r.Get("/sandbox/nodes", h.sandboxNodes) + r.Get("/sandbox/nodes/{node_id}", h.sandboxNodeDetail) + r.Patch("/sandbox/nodes/{node_id}", h.updateSandboxNode) + r.Delete("/sandbox/nodes/{node_id}", h.removeSandboxNode) + r.Get("/sandbox/nodes/{node_id}/allocations", h.sandboxAllocations) + r.Post("/sandbox/enrollment-tokens", h.createSandboxEnrollment) } // @Summary Retrieve sandbox deployment diff --git a/services/agents-api/internal/api/write_audit.go b/services/agents-api/internal/api/write_audit.go index e989da6b1..aeba71f1c 100644 --- a/services/agents-api/internal/api/write_audit.go +++ b/services/agents-api/internal/api/write_audit.go @@ -66,7 +66,7 @@ func (h *Handler) writeAuditScope(w http.ResponseWriter, r *http.Request, allowe // @Param resource_ids query string true "Comma-separated public resource IDs, maximum 100" // @Success 200 {object} api.ResourceOwnerList // @Failure 400,401,404,500 {object} v1.ErrorResponse -// @Router /core/v1/admin/projects/{project_id}/resource-owners [get] +// @Router /core/v1/projects/{project_id}/resource-owners [get] func (h *Handler) getResourceOwners(w http.ResponseWriter, r *http.Request) { values, tenant, ok := h.writeAuditScope(w, r, "resource_type", "resource_ids") if !ok { @@ -109,7 +109,7 @@ func (h *Handler) getResourceOwners(w http.ResponseWriter, r *http.Request) { // @Param after query string false "Opaque next_cursor from the preceding page" // @Success 200 {object} store.WriteOperationPage // @Failure 400,401,404,500 {object} v1.ErrorResponse -// @Router /core/v1/admin/projects/{project_id}/write-operations [get] +// @Router /core/v1/projects/{project_id}/write-operations [get] func (h *Handler) listWriteOperations(w http.ResponseWriter, r *http.Request) { values, tenant, ok := h.writeAuditScope(w, r, "key_id", "resource_type", "resource_id", "created_after", "created_before", "limit", "after") if !ok { diff --git a/services/agents-api/internal/api/write_audit_test.go b/services/agents-api/internal/api/write_audit_test.go index 08b40a4ec..c4d0e09d8 100644 --- a/services/agents-api/internal/api/write_audit_test.go +++ b/services/agents-api/internal/api/write_audit_test.go @@ -56,7 +56,7 @@ func TestWriteAuditQueriesDeploymentScopeAndValidation(t *testing.T) { if err != nil { t.Fatal(err) } - owners := "/core/v1/admin/projects/" + key.ProjectID + "/resource-owners?resource_type=agent&resource_ids=first,second" + owners := "/core/v1/projects/" + key.ProjectID + "/resource-owners?resource_type=agent&resource_ids=first,second" for _, token := range []string{"", "caller", "foreign"} { w := projectKeyHTTP(h, "GET", owners, token, "") if w.Code != 401 || queries.calls != 0 { @@ -70,7 +70,7 @@ func TestWriteAuditQueriesDeploymentScopeAndValidation(t *testing.T) { if !strings.Contains(w.Body.String(), `"api_key":null`) { t.Fatalf("history must be null: %s", w.Body) } - history := "/core/v1/admin/projects/" + key.ProjectID + "/write-operations?" + history := "/core/v1/projects/" + key.ProjectID + "/write-operations?" w = projectKeyHTTP(h, "GET", history+"key_id=some-key&resource_type=credential&resource_id=resource&limit=2&after=cursor&created_after=2026-01-01T00:00:00Z&created_before=2026-02-01T00:00:00Z", "admin", "") if w.Code != 200 || queries.filter.Limit != 2 || queries.filter.KeyID != "some-key" || queries.filter.After != "cursor" || queries.filter.CreatedAfter == nil || queries.filter.CreatedBefore == nil { t.Fatalf("history %d %s filter%+v", w.Code, w.Body, queries.filter) diff --git a/services/agents-api/internal/db/queries/environment_executor_credentials.sql b/services/agents-api/internal/db/queries/environment_executor_credentials.sql index b9fdc745e..bc77eb818 100644 --- a/services/agents-api/internal/db/queries/environment_executor_credentials.sql +++ b/services/agents-api/internal/db/queries/environment_executor_credentials.sql @@ -50,3 +50,10 @@ JOIN sessions s ON s.id = e.session_id WHERE c.token_sha256 = sqlc.arg(token_sha256) AND c.revoked_at IS NULL AND c.tenant_id = s.tenant_id AND c.subject_kind = s.creator_kind AND c.subject_id = s.creator_id AND (c.environment_id IS NULL OR c.environment_id = e.id) AND s.deleted_at IS NULL; + +-- name: ListEnvironmentExecutorCredentials :many +SELECT key_id, created_at, revoked_at +FROM environment_executor_credentials +WHERE tenant_id = sqlc.arg(tenant_id) AND environment_id = sqlc.arg(environment_id) + AND subject_kind = sqlc.arg(subject_kind) AND subject_id = sqlc.arg(subject_id) +ORDER BY created_at, key_id; diff --git a/services/agents-api/internal/db/sqlc/environment_executor_credentials.sql.go b/services/agents-api/internal/db/sqlc/environment_executor_credentials.sql.go index 0f9e24144..866850148 100644 --- a/services/agents-api/internal/db/sqlc/environment_executor_credentials.sql.go +++ b/services/agents-api/internal/db/sqlc/environment_executor_credentials.sql.go @@ -136,6 +136,52 @@ func (q *Queries) IssueExecutorCredential(ctx context.Context, arg IssueExecutor return i, err } +const listEnvironmentExecutorCredentials = `-- name: ListEnvironmentExecutorCredentials :many +SELECT key_id, created_at, revoked_at +FROM environment_executor_credentials +WHERE tenant_id = $1 AND environment_id = $2 + AND subject_kind = $3 AND subject_id = $4 +ORDER BY created_at, key_id +` + +type ListEnvironmentExecutorCredentialsParams struct { + TenantID pgtype.UUID `json:"tenant_id"` + EnvironmentID pgtype.UUID `json:"environment_id"` + SubjectKind pgtype.Text `json:"subject_kind"` + SubjectID pgtype.Text `json:"subject_id"` +} + +type ListEnvironmentExecutorCredentialsRow struct { + KeyID pgtype.UUID `json:"key_id"` + CreatedAt pgtype.Timestamptz `json:"created_at"` + RevokedAt pgtype.Timestamptz `json:"revoked_at"` +} + +func (q *Queries) ListEnvironmentExecutorCredentials(ctx context.Context, arg ListEnvironmentExecutorCredentialsParams) ([]ListEnvironmentExecutorCredentialsRow, error) { + rows, err := q.db.Query(ctx, listEnvironmentExecutorCredentials, + arg.TenantID, + arg.EnvironmentID, + arg.SubjectKind, + arg.SubjectID, + ) + if err != nil { + return nil, err + } + defer rows.Close() + items := []ListEnvironmentExecutorCredentialsRow{} + for rows.Next() { + var i ListEnvironmentExecutorCredentialsRow + if err := rows.Scan(&i.KeyID, &i.CreatedAt, &i.RevokedAt); err != nil { + return nil, err + } + items = append(items, i) + } + if err := rows.Err(); err != nil { + return nil, err + } + return items, nil +} + const revokeExecutorCredential = `-- name: RevokeExecutorCredential :execrows UPDATE environment_executor_credentials SET revoked_at = COALESCE(revoked_at, clock_timestamp()) WHERE key_id = $1 AND tenant_id = $2 diff --git a/services/agents-api/internal/store/admin_session_archive_worker_http_test.go b/services/agents-api/internal/store/admin_session_archive_worker_http_test.go index 5f7c32179..8e6dbe7a6 100644 --- a/services/agents-api/internal/store/admin_session_archive_worker_http_test.go +++ b/services/agents-api/internal/store/admin_session_archive_worker_http_test.go @@ -105,7 +105,7 @@ func TestAdminSessionArchiveWorkerHTTPPostgres(t *testing.T) { } request := func(method, session string) *httptest.ResponseRecorder { t.Helper() - r := httptest.NewRequest(method, "/core/v1/admin/projects/"+projectID+"/sessions/"+session+"/archive", strings.NewReader(`{"expected_generation":1}`)) + r := httptest.NewRequest(method, "/core/v1/projects/"+projectID+"/sessions/"+session+"/archive", strings.NewReader(`{"expected_generation":1}`)) r.Header.Set("Authorization", "Bearer archive-administrator") r.Header.Set("Content-Type", "application/json") w := httptest.NewRecorder() diff --git a/services/agents-api/internal/store/environment_executor_credentials.go b/services/agents-api/internal/store/environment_executor_credentials.go index fdb7c981a..0b12bdf5b 100644 --- a/services/agents-api/internal/store/environment_executor_credentials.go +++ b/services/agents-api/internal/store/environment_executor_credentials.go @@ -24,6 +24,11 @@ type IssuedExecutorCredential struct { // IssueExecutorCredential returns a new connect-only secret once, without replacing an existing ID. func (s *Store) IssueExecutorCredential(ctx context.Context, principal identity.Principal, keyID, environment string) (IssuedExecutorCredential, error) { + return s.issueExecutorCredential(ctx, principal, keyID, environment, nil) +} + +// issueExecutorCredential runs record, when given, in the issuing transaction. +func (s *Store) issueExecutorCredential(ctx context.Context, principal identity.Principal, keyID, environment string, record func(context.Context, *sqlc.Queries) error) (IssuedExecutorCredential, error) { tenant, id, err := executorCredentialIdentity(principal, keyID) if err != nil { return IssuedExecutorCredential{}, err @@ -59,6 +64,9 @@ func (s *Store) IssueExecutorCredential(ctx context.Context, principal identity. return err } result = issuedExecutorCredential(row.KeyID, row.EnvironmentID, token) + if record != nil { + return record(ctx, q) + } return nil }) if err != nil { @@ -68,6 +76,11 @@ func (s *Store) IssueExecutorCredential(ctx context.Context, principal identity. } func (s *Store) RotateExecutorCredential(ctx context.Context, principal identity.Principal, keyID string) (IssuedExecutorCredential, error) { + return s.rotateExecutorCredential(ctx, principal, keyID, nil) +} + +// rotateExecutorCredential runs record, when given, in the rotating transaction. +func (s *Store) rotateExecutorCredential(ctx context.Context, principal identity.Principal, keyID string, record func(context.Context, *sqlc.Queries) error) (IssuedExecutorCredential, error) { tenant, id, err := executorCredentialIdentity(principal, keyID) if err != nil { return IssuedExecutorCredential{}, err @@ -90,6 +103,9 @@ func (s *Store) RotateExecutorCredential(ctx context.Context, principal identity return err } result = issuedExecutorCredential(row.KeyID, row.EnvironmentID, token) + if record != nil { + return record(ctx, q) + } return nil }) if err != nil { diff --git a/services/agents-api/internal/store/environment_executor_management.go b/services/agents-api/internal/store/environment_executor_management.go index 8ac764f45..ac21903c3 100644 --- a/services/agents-api/internal/store/environment_executor_management.go +++ b/services/agents-api/internal/store/environment_executor_management.go @@ -3,33 +3,101 @@ package store import ( "context" "encoding/json" + "time" + "github.com/MiniMax-AI-Dev/parsar/services/agents-api/internal/db/sqlc" "github.com/MiniMax-AI-Dev/parsar/services/agents-api/internal/identity" + "github.com/google/uuid" + "github.com/jackc/pgx/v5" + "github.com/jackc/pgx/v5/pgtype" ) -// IssueEnvironmentExecutorCredential exposes only exact self-hosted targets to -// project callers. The operator issuer remains available for broader principals. -func (s *Store) IssueEnvironmentExecutorCredential(ctx context.Context, principal identity.Principal, environment, keyID string, rotate bool) (IssuedExecutorCredential, error) { - if err := s.selfHostedExecutorTarget(ctx, principal, environment); err != nil { +// ExecutorCredential is the metadata of one Environment executor credential. +// Its secret is returned only when issued or rotated. +type ExecutorCredential struct { + KeyID string `json:"key_id" format:"uuid"` + CreatedAt time.Time `json:"created_at"` + RevokedAt *time.Time `json:"revoked_at" extensions:"x-nullable"` +} + +// The Project* methods serve Core-key executor credential management. project +// is the Project's own principal, which becomes the credential's execution +// principal; the target must be a self_hosted Environment of that Project whose +// Session is not deleted, and only credentials restricted to it are managed. + +func (s *Store) ListProjectExecutorCredentials(ctx context.Context, project identity.Principal, environment string) ([]ExecutorCredential, error) { + if err := s.selfHostedExecutorTarget(ctx, project, environment); err != nil { + return nil, err + } + tenant, err := parseID(project.TenantID) + if err != nil { + return nil, err + } + rows, err := s.queries.ListEnvironmentExecutorCredentials(ctx, sqlc.ListEnvironmentExecutorCredentialsParams{ + TenantID: tenant, EnvironmentID: parsePathID(environment), + SubjectKind: pgtype.Text{String: project.SubjectKind, Valid: true}, SubjectID: pgtype.Text{String: project.SubjectID, Valid: true}, + }) + if err != nil { + return nil, err + } + result := make([]ExecutorCredential, 0, len(rows)) + for _, row := range rows { + credential := ExecutorCredential{KeyID: uuid.UUID(row.KeyID.Bytes).String(), CreatedAt: row.CreatedAt.Time} + if row.RevokedAt.Valid { + revoked := row.RevokedAt.Time + credential.RevokedAt = &revoked + } + result = append(result, credential) + } + return result, nil +} + +// IssueProjectExecutorCredential issues a new key or, with rotate, replaces the +// secret of an existing key restricted to the Environment. The administrator +// audit entry commits in the same transaction and never contains the secret. +func (s *Store) IssueProjectExecutorCredential(ctx context.Context, project identity.Principal, environment, keyID string, rotate bool) (IssuedExecutorCredential, error) { + if err := s.selfHostedExecutorTarget(ctx, project, environment); err != nil { return IssuedExecutorCredential{}, err } if !rotate { - return s.IssueExecutorCredential(ctx, principal, keyID, environment) + return s.issueExecutorCredential(ctx, project, keyID, environment, executorCredentialAudit(project, "issue", keyID)) } - if err := s.exactExecutorRestriction(ctx, principal, environment, keyID); err != nil { + if err := s.exactExecutorRestriction(ctx, project, environment, keyID); err != nil { return IssuedExecutorCredential{}, err } - return s.RotateExecutorCredential(ctx, principal, keyID) + return s.rotateExecutorCredential(ctx, project, keyID, executorCredentialAudit(project, "rotate", keyID)) } -func (s *Store) RevokeEnvironmentExecutorCredential(ctx context.Context, principal identity.Principal, environment, keyID string) error { - if err := s.selfHostedExecutorTarget(ctx, principal, environment); err != nil { +// RevokeProjectExecutorCredential is idempotent; each successful request is audited. +func (s *Store) RevokeProjectExecutorCredential(ctx context.Context, project identity.Principal, environment, keyID string) error { + if err := s.selfHostedExecutorTarget(ctx, project, environment); err != nil { + return err + } + if err := s.exactExecutorRestriction(ctx, project, environment, keyID); err != nil { return err } - if err := s.exactExecutorRestriction(ctx, principal, environment, keyID); err != nil { + tenant, id, err := executorCredentialIdentity(project, keyID) + if err != nil { return err } - return s.RevokeExecutorCredential(ctx, principal, keyID) + record := executorCredentialAudit(project, "revoke", keyID) + return pgx.BeginFunc(ctx, s.pool, func(tx pgx.Tx) error { + q := s.queries.WithTx(tx) + n, err := q.RevokeExecutorCredential(ctx, sqlc.RevokeExecutorCredentialParams{KeyID: id, TenantID: tenant, SubjectKind: pgtype.Text{String: project.SubjectKind, Valid: true}, SubjectID: pgtype.Text{String: project.SubjectID, Valid: true}}) + if err != nil { + return err + } + if n == 0 { + return ErrNotFound + } + return record(ctx, q) + }) +} + +func executorCredentialAudit(project identity.Principal, action, keyID string) func(context.Context, *sqlc.Queries) error { + return func(ctx context.Context, q *sqlc.Queries) error { + return recordAdminMutation(ctx, q, project.TenantID, action, "executor_credential", keyID) + } } func (s *Store) selfHostedExecutorTarget(ctx context.Context, principal identity.Principal, environment string) error { @@ -59,8 +127,8 @@ func (s *Store) exactExecutorRestriction(ctx context.Context, principal identity if err != nil { return err } - // Restrictions and principals are immutable, so checking before the existing - // rotation/revocation transaction cannot authorize a different target. + // Restrictions and principals are immutable, so checking before the + // rotation or revocation transaction cannot authorize a different target. if !actual.Valid || actual != want { return ErrNotFound } diff --git a/services/agents-api/internal/store/environment_executor_management_test.go b/services/agents-api/internal/store/environment_executor_management_test.go index 7fd2e2868..0600f3312 100644 --- a/services/agents-api/internal/store/environment_executor_management_test.go +++ b/services/agents-api/internal/store/environment_executor_management_test.go @@ -1,82 +1,95 @@ package store import ( + "context" "errors" + "strings" "testing" - "github.com/MiniMax-AI-Dev/parsar/services/agents-api/internal/identity" "github.com/google/uuid" ) func TestProjectEnvironmentExecutorManagement(t *testing.T) { - s, _ := testStore(t) + s, pool := testStore(t) ctx := t.Context() - p := FixtureExecutorPrincipal(t, s, uuid.NewString()) - create := func(principal identity.Principal, kind string) (Session, Environment) { + project := createTestProject(t, s) + binding, err := s.GetProject(ctx, project.ID) + if err != nil { + t.Fatal(err) + } + p := binding.Principal + foreignProject := createTestProject(t, s) + foreign, err := s.GetProject(ctx, foreignProject.ID) + if err != nil { + t.Fatal(err) + } + // Each administrator request has its own request ID. + admin := func() context.Context { return keyAdminContext(ctx, project.ID) } + create := func(kind string) (Session, Environment) { t.Helper() input := environmentInput(uuid.NewString(), kind, "/workspace") - input.Creator = principal.Subject() - session, err := s.CreateSession(ctx, principal.TenantID, input) + input.Creator = p.Subject() + session, err := s.CreateSession(ctx, p.TenantID, input) if err != nil { t.Fatal(err) } - environment, err := s.GetSessionEnvironment(ctx, principal.TenantID, session.ID) + environment, err := s.GetSessionEnvironment(ctx, p.TenantID, session.ID) if err != nil { t.Fatal(err) } return session, environment } - session, one := create(p, "self_hosted") - _, two := create(p, "self_hosted") - _, hosted := create(p, "openai_hosted") + session, one := create("self_hosted") + _, two := create("self_hosted") + _, hosted := create("openai_hosted") keyID := uuid.NewString() - issued, err := s.IssueEnvironmentExecutorCredential(ctx, p, one.ID, keyID, false) - if err != nil || issued.EnvironmentID != one.ID { + + // The audit entry commits with the write: without an audit source nothing is issued. + if _, err := s.IssueProjectExecutorCredential(ctx, p, one.ID, keyID, false); !errors.Is(err, ErrInvalidInput) { + t.Fatal("unaudited issue", err) + } + issued, err := s.IssueProjectExecutorCredential(admin(), p, one.ID, keyID, false) + if err != nil || issued.KeyID != keyID || issued.EnvironmentID != one.ID || issued.Token == "" { t.Fatal("issue", err) } + if _, err := s.AuthenticateEnvironmentExecutor(ctx, one.ID, executorDigest(issued.Token)); err != nil { + t.Fatal("issued credential does not authenticate", err) + } // A lost issuance response must not cause a new key or replace the old secret. - if _, err := s.IssueEnvironmentExecutorCredential(ctx, p, one.ID, keyID, false); !errors.Is(err, ErrExecutorCredentialExists) { + if _, err := s.IssueProjectExecutorCredential(admin(), p, one.ID, keyID, false); !errors.Is(err, ErrExecutorCredentialExists) { t.Fatal("uncertain retry", err) } - if _, err := s.AuthenticateEnvironmentExecutor(ctx, one.ID, executorDigest(issued.Token)); err != nil { - t.Fatal("retry changed credential", err) - } - other := p - other.SubjectID = "different-creator" - foreign := FixtureExecutorPrincipal(t, s, uuid.NewString()) - for _, principal := range []identity.Principal{other, foreign} { - if _, err := s.IssueEnvironmentExecutorCredential(ctx, principal, one.ID, uuid.NewString(), false); !errors.Is(err, ErrNotFound) { - t.Fatal("foreign issue", err) - } - if _, err := s.IssueEnvironmentExecutorCredential(ctx, principal, one.ID, keyID, true); !errors.Is(err, ErrNotFound) { - t.Fatal("foreign rotate", err) - } - if err := s.RevokeEnvironmentExecutorCredential(ctx, principal, one.ID, keyID); !errors.Is(err, ErrNotFound) { - t.Fatal("foreign revoke", err) - } + listed, err := s.ListProjectExecutorCredentials(ctx, p, one.ID) + if err != nil || len(listed) != 1 || listed[0].KeyID != keyID || listed[0].CreatedAt.IsZero() || listed[0].RevokedAt != nil { + t.Fatal("list", listed, err) + } + if listed, err := s.ListProjectExecutorCredentials(ctx, p, two.ID); err != nil || len(listed) != 0 { + t.Fatal("other Environment list", listed, err) + } + + // Another Project, a hosted or unknown Environment and a key restricted elsewhere are not found. + if _, err := s.ListProjectExecutorCredentials(ctx, foreign.Principal, one.ID); !errors.Is(err, ErrNotFound) { + t.Fatal("foreign list", err) + } + if _, err := s.IssueProjectExecutorCredential(keyAdminContext(ctx, foreignProject.ID), foreign.Principal, one.ID, uuid.NewString(), false); !errors.Is(err, ErrNotFound) { + t.Fatal("foreign issue", err) } for _, environment := range []string{two.ID, hosted.ID, uuid.NewString()} { - if _, err := s.IssueEnvironmentExecutorCredential(ctx, p, environment, keyID, true); !errors.Is(err, ErrNotFound) { + if _, err := s.IssueProjectExecutorCredential(admin(), p, environment, keyID, true); !errors.Is(err, ErrNotFound) { t.Fatal("wrong target rotate", err) } - if err := s.RevokeEnvironmentExecutorCredential(ctx, p, environment, keyID); !errors.Is(err, ErrNotFound) { + if err := s.RevokeProjectExecutorCredential(admin(), p, environment, keyID); !errors.Is(err, ErrNotFound) { t.Fatal("wrong target revoke", err) } } - if _, err := s.IssueEnvironmentExecutorCredential(ctx, p, hosted.ID, uuid.NewString(), false); !errors.Is(err, ErrNotFound) { + if _, err := s.IssueProjectExecutorCredential(admin(), p, hosted.ID, uuid.NewString(), false); !errors.Is(err, ErrNotFound) { t.Fatal("hosted issuance", err) } - broad, err := s.IssueExecutorCredential(ctx, p, uuid.NewString(), "") - if err != nil { - t.Fatal(err) - } - if _, err := s.IssueEnvironmentExecutorCredential(ctx, p, one.ID, broad.KeyID, true); !errors.Is(err, ErrNotFound) { - t.Fatal("broad rotation", err) + if _, err := s.IssueProjectExecutorCredential(admin(), p, one.ID, uuid.NewString(), true); !errors.Is(err, ErrNotFound) { + t.Fatal("unknown key rotation", err) } - if err := s.RevokeEnvironmentExecutorCredential(ctx, p, one.ID, broad.KeyID); !errors.Is(err, ErrNotFound) { - t.Fatal("broad revocation", err) - } - rotated, err := s.IssueEnvironmentExecutorCredential(ctx, p, one.ID, keyID, true) + + rotated, err := s.IssueProjectExecutorCredential(admin(), p, one.ID, keyID, true) if err != nil || rotated.Token == issued.Token { t.Fatal("rotation", err) } @@ -84,20 +97,49 @@ func TestProjectEnvironmentExecutorManagement(t *testing.T) { t.Fatal("old secret", err) } for range 2 { - if err := s.RevokeEnvironmentExecutorCredential(ctx, p, one.ID, keyID); err != nil { + if err := s.RevokeProjectExecutorCredential(admin(), p, one.ID, keyID); err != nil { t.Fatal(err) } } if _, err := s.AuthenticateEnvironmentExecutor(ctx, one.ID, executorDigest(rotated.Token)); !errors.Is(err, ErrNotFound) { t.Fatal("revoked secret", err) } - if _, err := s.IssueEnvironmentExecutorCredential(ctx, p, one.ID, keyID, false); !errors.Is(err, ErrExecutorCredentialExists) { + if listed, err := s.ListProjectExecutorCredentials(ctx, p, one.ID); err != nil || len(listed) != 1 || listed[0].RevokedAt == nil { + t.Fatal("revoked list", listed, err) + } + if _, err := s.IssueProjectExecutorCredential(admin(), p, one.ID, keyID, false); !errors.Is(err, ErrExecutorCredentialExists) { t.Fatal("resurrected secret", err) } + + var actions []string + rows, err := pool.Query(ctx, "SELECT action, row_to_json(a)::text FROM admin_audit_log a WHERE resource_type='executor_credential' AND resource_id=$1 ORDER BY created_at", keyID) + if err != nil { + t.Fatal(err) + } + for rows.Next() { + var action, row string + if err := rows.Scan(&action, &row); err != nil { + t.Fatal(err) + } + if strings.Contains(row, issued.Token) || strings.Contains(row, rotated.Token) { + t.Fatal("audit contains a secret") + } + actions = append(actions, action) + } + if rows.Err() != nil || strings.Join(actions, ",") != "issue,rotate,revoke,revoke" { + t.Fatal("audit actions", actions, rows.Err()) + } + if err := s.DeleteSession(ctx, p.TenantID, session.ID); err != nil { t.Fatal(err) } - if _, err := s.IssueEnvironmentExecutorCredential(ctx, p, one.ID, keyID, true); !errors.Is(err, ErrNotFound) { - t.Fatal("deleted target", err) + if _, err := s.ListProjectExecutorCredentials(ctx, p, one.ID); !errors.Is(err, ErrNotFound) { + t.Fatal("deleted target list", err) + } + if _, err := s.IssueProjectExecutorCredential(admin(), p, one.ID, keyID, true); !errors.Is(err, ErrNotFound) { + t.Fatal("deleted target rotate", err) + } + if err := s.RevokeProjectExecutorCredential(admin(), p, one.ID, keyID); !errors.Is(err, ErrNotFound) { + t.Fatal("deleted target revoke", err) } } diff --git a/services/agents-api/internal/store/project_api_keys_http_test.go b/services/agents-api/internal/store/project_api_keys_http_test.go index aa63cf0da..a01976abc 100644 --- a/services/agents-api/internal/store/project_api_keys_http_test.go +++ b/services/agents-api/internal/store/project_api_keys_http_test.go @@ -40,7 +40,7 @@ func TestProjectAndSharedKeysHTTPManagement(t *testing.T) { } return w } - base := "/core/v1/admin/projects" + base := "/core/v1/projects" response := call("POST", base, adminToken, `{"name":"Default"}`, 201) var p store.Project if json.Unmarshal(response.Body.Bytes(), &p) != nil || p.ID == "" { diff --git a/services/agents-api/internal/store/request_body_public_test.go b/services/agents-api/internal/store/request_body_public_test.go index 0b4d3fc9a..127b61719 100644 --- a/services/agents-api/internal/store/request_body_public_test.go +++ b/services/agents-api/internal/store/request_body_public_test.go @@ -221,16 +221,6 @@ func TestRequestBodyGateExcludedRoutesPostgres(t *testing.T) { if status, response := client.do(token, http.MethodPost, "/v1/skills/"+skill.ID, "text/plain", []byte(`{"default_version":`)); status != http.StatusBadRequest || gated(response) { t.Fatalf("malformed Skills update: %d %s", status, response) } - // Executor credential issuance keeps its own reader and errors. - environment := "/core/v1/environments/" + uuid.NewString() + "/executor-credentials" - for _, request := range []struct { - contentType, body string - status int - }{{"", `{"key_id":"` + uuid.NewString() + `"}`, http.StatusNotFound}, {"text/plain", `{"key_id":`, http.StatusBadRequest}, {"", "", http.StatusBadRequest}} { - if status, response := client.do(token, http.MethodPost, environment, request.contentType, []byte(request.body)); status != request.status || gated(response) || !strings.Contains(response, `"error"`) { - t.Errorf("executor credential %q: %d %s", request.body, status, response) - } - } // DELETE keeps its empty-body rule and needs no Content-Type. agent := client.created(token, "/v1/agents", `{"model":"excluded-model"}`) vault := client.created(token, "/v1/vaults", `{"name":"excluded"}`) diff --git a/services/agents-api/tests/official_admin_management.py b/services/agents-api/tests/official_admin_management.py index d50b7b737..549cf929b 100644 --- a/services/agents-api/tests/official_admin_management.py +++ b/services/agents-api/tests/official_admin_management.py @@ -75,7 +75,7 @@ def safe(response): return response.json() def project_path(key): - return "/core/v1/admin/projects/" + key["project_id"] + return "/core/v1/projects/" + key["project_id"] def project_metadata(response): value = safe(response) @@ -85,7 +85,7 @@ def project_metadata(response): return value def issue(project, name): - key = request("POST", "/core/v1/admin/projects/" + project["id"] + "/keys", + key = request("POST", "/core/v1/projects/" + project["id"] + "/keys", expected=201, json={"name": name}).json() keys.append(key) secret_values.append(key["key"]) @@ -137,7 +137,7 @@ def wait_idle(client, sid, wanted_turns): try: for name in ("source", "target"): - created = request("POST", "/core/v1/admin/projects", expected=201, json={"name": "admin-proof-" + name}) + created = request("POST", "/core/v1/projects", expected=201, json={"name": "admin-proof-" + name}) project = created.json() projects.append(project) project_metadata(created) @@ -150,7 +150,7 @@ def wait_idle(client, sid, wanted_turns): http_client=DefaultHttpxClient(trust_env=False), _strict_response_validation=True)) a, b, a_peer = clients assert source["id"] != peer["id"] and source["project_id"] == peer["project_id"] != target["project_id"] - catalog = safe(request("GET", "/core/v1/admin/projects", params={"limit": 100})) + catalog = safe(request("GET", "/core/v1/projects", params={"limit": 100})) assert isinstance(catalog["has_more"], bool) assert {project["id"] for project in projects} <= {project["id"] for project in catalog["data"]} for key, count in ((source, 2), (target, 1)): @@ -159,7 +159,7 @@ def wait_idle(client, sid, wanted_turns): assert all("key" not in item and item["project_id"] == key["project_id"] for item in listed["data"]) for path in PUBLIC_PATHS.values(): assert safe(request("GET", "/v1/" + path, token=target["key"]))["data"] == [] - request("GET", "/core/v1/admin/projects", expected=401, token=source["key"]) + request("GET", "/core/v1/projects", expected=401, token=source["key"]) request("GET", "/v1/agents", expected=401, headers={"OpenAI-Beta": "agents=v1"}) file = a.files.create(file=("copy-source.txt", private.encode()), purpose="user_data") @@ -317,7 +317,7 @@ def wait_idle(client, sid, wanted_turns): {"role": "user", "content": [{"type": "input_text", "text": "foreign"}]}]}]}) assert owner(source, "session", session_id)["api_key"]["id"] == creation_key assert {creation_key, replacement["id"]} <= {item["api_key"]["id"] for item in operations(source, "session", session_id)} - key_summary = safe(request("GET", "/core/v1/admin/summary", params={ + key_summary = safe(request("GET", "/core/v1/summary", params={ "group_by": "key", "project_id": source["project_id"]}))["data"] assert sum(row["sessions"]["total"] for row in key_summary) == 1 group = next(row for row in key_summary if row["key_id"] == creation_key) @@ -345,17 +345,17 @@ def wait_idle(client, sid, wanted_turns): assert request("GET", admin_resource(source, kind, resource)).content == before_archive[kind] request("POST", project_path(source) + "/keys", expected=409, json={"name": "archived"}) for group_by in ("project", "agent", "key"): - summary = safe(request("GET", "/core/v1/admin/summary", params={ + summary = safe(request("GET", "/core/v1/summary", params={ "group_by": group_by, "project_id": target["project_id"]})) assert all(row["project_id"] == target["project_id"] for row in summary["data"]) - safe(request("GET", "/core/v1/admin/audit-log", params={"project_id": target["project_id"]})) + safe(request("GET", "/core/v1/audit-log", params={"project_id": target["project_id"]})) print("Project rename, archive and retained reads passed.", flush=True) finally: failures = [] if session_id is not None: # Read through the administrator route; cancel only with a key this run # has not revoked, in a Project that is not archived. - session_path = base + f"/core/v1/admin/projects/{projects[0]['id']}/sessions/{session_id}" + session_path = base + f"/core/v1/projects/{projects[0]['id']}/sessions/{session_id}" try: state = http.get(session_path, headers=admin).json()["status"] if state == "in_progress": @@ -376,14 +376,14 @@ def wait_idle(client, sid, wanted_turns): priority = {kind: index for index, kind in enumerate(("session", "agent", "environment_template", "skill", "vault", "file"))} for project_id, kind, resource in sorted(owned, key=lambda value: priority[value[1]]): try: - response = http.delete(base + f"/core/v1/admin/projects/{project_id}/{ADMIN_PATHS[kind]}/{resource}", headers=admin) + response = http.delete(base + f"/core/v1/projects/{project_id}/{ADMIN_PATHS[kind]}/{resource}", headers=admin) if response.status_code not in (200, 204, 404): failures.append(kind) except httpx.HTTPError: failures.append(kind) for project in projects: if project["archived_at"] is None: - response = http.post(base + "/core/v1/admin/projects/" + project["id"] + "/archive", headers=admin) + response = http.post(base + "/core/v1/projects/" + project["id"] + "/archive", headers=admin) if response.status_code != 200: failures.append("project_archive") for key in keys: diff --git a/services/agents-api/tests/official_client.py b/services/agents-api/tests/official_client.py index c9ae22b8b..0c1068480 100644 --- a/services/agents-api/tests/official_client.py +++ b/services/agents-api/tests/official_client.py @@ -156,7 +156,7 @@ def expect_error(error, operation): try: process = start() - with httpx2.Client(base_url=base + "/core/v1/admin/", trust_env=False, timeout=10, + with httpx2.Client(base_url=base + "/core/v1/", trust_env=False, timeout=10, headers={"Authorization": "Bearer " + admin_token}) as admin: def issue_key(project_id, name): response = admin.post(f"projects/{project_id}/keys", json={"name": name}) From 3d53520d9e808ee0ceaafec32f31dcf547515e7d Mon Sep 17 00:00:00 2001 From: SaladDay <1203511142@qq.com> Date: Fri, 25 Sep 2026 14:20:53 +0000 Subject: [PATCH 06/30] Forward every signed-in /core/v1 request from the console with the Core key The per-route allowlists in admin_routes.go and sandbox_admin.go and the /core/v1/environments special case are replaced by one rule: after console sign-in and the existing origin and CSRF checks, a /core/v1/* request is forwarded with the Core key and Core decides whether the route exists. /v1 and /api/v1 still return 404, and the path checks that keep dot segments and encoded separators from leaving /core/v1 are unchanged. --- services/core-console/admin_routes.go | 91 ------------------- services/core-console/api_keys_test.go | 11 ++- services/core-console/core_routes.go | 27 ++++++ .../core-console/node_installation_test.go | 3 +- services/core-console/project_proxy_test.go | 27 +----- services/core-console/sandbox_admin.go | 47 ---------- services/core-console/server.go | 4 +- services/core-console/server_test.go | 6 +- 8 files changed, 45 insertions(+), 171 deletions(-) delete mode 100644 services/core-console/admin_routes.go create mode 100644 services/core-console/core_routes.go delete mode 100644 services/core-console/sandbox_admin.go diff --git a/services/core-console/admin_routes.go b/services/core-console/admin_routes.go deleted file mode 100644 index 503975512..000000000 --- a/services/core-console/admin_routes.go +++ /dev/null @@ -1,91 +0,0 @@ -package main - -import ( - "net/http" - "strings" -) - -// adminAPIRequest exposes only the management contract; asset creation and -// execution remain on the direct Core API. -func adminAPIRequest(r *http.Request) bool { - const prefix = "/core/v1/admin/" - if !strings.HasPrefix(r.URL.Path, prefix) { - return false - } - parts := strings.Split(strings.TrimPrefix(r.URL.Path, prefix), "/") - for _, part := range parts { - if part == "" { - return false - } - } - read := r.Method == http.MethodGet || r.Method == http.MethodHead - remove := r.Method == http.MethodDelete - if len(parts) == 1 { - switch parts[0] { - case "projects": - return read || r.Method == http.MethodPost - case "summary", "runtime-observations", "audit-log", "core-metrics": - return read - } - } - if len(parts) < 2 || parts[0] != "projects" { - return false - } - if len(parts) == 2 { - return r.Method == http.MethodPost - } - if len(parts) == 3 && parts[2] == "archive" { - return r.Method == http.MethodPost - } - if parts[2] == "keys" { - return len(parts) == 3 && (read || r.Method == http.MethodPost) || len(parts) == 4 && remove - } - resource := parts[2] - if len(parts) == 3 { - switch resource { - case "agents", "skills", "environment-templates", "files", "vaults", "sessions", "resource-owners", "write-operations": - return read - } - } - if len(parts) == 4 { - switch resource { - case "agents", "skills", "environment-templates", "files", "vaults", "sessions": - return read || remove - } - } - switch resource { - case "skills": - if len(parts) == 5 && (parts[4] == "content" || parts[4] == "versions") { - return read - } - if len(parts) == 6 && parts[4] == "versions" { - return read || remove - } - return len(parts) == 7 && parts[4] == "versions" && parts[6] == "content" && read - case "vaults": - if len(parts) == 5 && parts[4] == "credentials" { - return read - } - return len(parts) == 6 && parts[4] == "credentials" && (read || remove) - case "sessions": - if len(parts) == 5 && parts[4] == "archive" { - return read || r.Method == http.MethodPost - } - if len(parts) == 5 { - switch parts[4] { - case "turns", "items", "artifacts", "execution-configuration", "runtime-observation", "runtime-history": - return read - } - } - if len(parts) == 6 { - if parts[4] == "turns" { - return read - } - if parts[4] == "artifacts" { - return read || remove - } - } - return len(parts) == 7 && parts[4] == "artifacts" && parts[6] == "content" && read - } - return false -} diff --git a/services/core-console/api_keys_test.go b/services/core-console/api_keys_test.go index e22459f70..a00f41c01 100644 --- a/services/core-console/api_keys_test.go +++ b/services/core-console/api_keys_test.go @@ -18,7 +18,9 @@ func TestAdminProxyUsesAccountIdentityAndServerCredential(t *testing.T) { w.WriteHeader(200) })) h := accountConsole(t, c) - paths := []struct{ method, path string }{{"GET", "/core/v1/admin/projects?limit=5"}, {"POST", "/core/v1/admin/projects"}, {"POST", "/core/v1/admin/projects/project/keys"}, {"POST", "/core/v1/admin/projects/project/archive"}, {"DELETE", "/core/v1/admin/projects/project/keys/key"}, {"GET", "/core/v1/admin/projects/key/sessions/session/artifacts/artifact/content"}, {"DELETE", "/core/v1/admin/projects/key/skills/skill/versions/1"}, {"GET", "/core/v1/admin/summary"}, {"GET", "/core/v1/admin/core-metrics?range=6h"}} + // Every /core/v1 operation is forwarded; Core alone decides which routes exist. + paths := []struct{ method, path string }{{"GET", "/core/v1/projects?limit=5"}, {"POST", "/core/v1/projects"}, {"POST", "/core/v1/projects/project/keys"}, {"POST", "/core/v1/projects/project/archive"}, {"DELETE", "/core/v1/projects/project/keys/key"}, {"GET", "/core/v1/projects/key/sessions/session/artifacts/artifact/content"}, {"DELETE", "/core/v1/projects/key/skills/skill/versions/1"}, {"GET", "/core/v1/summary"}, {"GET", "/core/v1/metrics?range=6h"}, {"GET", "/core/v1/sandbox/runtime-observations"}, + {"POST", "/core/v1/projects/p/environments/e/executor-credentials"}, {"DELETE", "/core/v1/projects/p/environments/e/executor-credentials/k"}, {"PATCH", "/core/v1/future/operation"}, {"GET", "/core/v1/admin/projects"}} for _, tc := range paths { if w := authRequest(h, tc.method, tc.path, `{}`, nil); w.Code != 401 { t.Errorf("unauthenticated %s = %d", tc.path, w.Code) @@ -41,13 +43,14 @@ func TestAdminProxyUsesAccountIdentityAndServerCredential(t *testing.T) { if calls.Load() != int32(len(paths)) { t.Fatal("unexpected admin proxy count") } - for _, tc := range []struct{ method, path string }{{"POST", "/core/v1/admin/projects/key/agents"}, {"PATCH", "/core/v1/admin/projects/key/agents/agent"}, {"POST", "/core/v1/admin/projects/key/sessions"}, {"POST", "/core/v1/admin/projects/key/sessions/session/events"}, {"GET", "/core/v1/admin/projects/key/sessions/session/events"}, {"GET", "/core/v1/admin/projects/key/files/file/content"}, {"GET", "/core/v1/admin/unknown"}, {"POST", "/core/v1/admin/core-metrics"}} { + // Only /core/v1 is forwarded. + for _, tc := range []struct{ method, path string }{{"GET", "/core"}, {"GET", "/core/"}, {"GET", "/core/v1"}, {"GET", "/core/v2/projects"}, {"GET", "/core/projects"}} { if w := authRequest(h, tc.method, tc.path, `{}`, cookie); w.Code != 404 { - t.Errorf("unsupported %s %s = %d", tc.method, tc.path, w.Code) + t.Errorf("outside /core/v1 %s %s = %d", tc.method, tc.path, w.Code) } } if calls.Load() != int32(len(paths)) { - t.Fatal("unsupported management operation reached Core") + t.Fatal("a request outside /core/v1 reached Core") } } func TestConsoleRequiresAdministratorCredential(t *testing.T) { diff --git a/services/core-console/core_routes.go b/services/core-console/core_routes.go new file mode 100644 index 000000000..cba40dd34 --- /dev/null +++ b/services/core-console/core_routes.go @@ -0,0 +1,27 @@ +package main + +import ( + "net/http" + "strings" +) + +// coreDirectRequest reports the application (/v1) and machine connection +// (/api/v1) namespaces. The reverse proxy routes them straight to Core; the +// console never forwards them, whatever credential they carry. +func coreDirectRequest(r *http.Request) bool { + for _, prefix := range []string{"/v1", "/api/v1"} { + if r.URL.Path == prefix || strings.HasPrefix(r.URL.Path, prefix+"/") { + return true + } + } + return false +} + +// coreRequest reports a /core/v1 operation. After console sign-in and the +// same-origin checks, the console forwards each one with the Core key; Core +// alone decides whether the route exists. ServeHTTP has already rejected +// literal or encoded dot segments, empty segments, backslashes, double +// encoding and upgrades, so the request cannot leave /core/v1 on Core. +func coreRequest(r *http.Request) bool { + return strings.HasPrefix(r.URL.Path, "/core/v1/") +} diff --git a/services/core-console/node_installation_test.go b/services/core-console/node_installation_test.go index 5d7b58954..b73777910 100644 --- a/services/core-console/node_installation_test.go +++ b/services/core-console/node_installation_test.go @@ -47,8 +47,7 @@ func TestPairedConsoleProxiesOnlyAdministration(t *testing.T) { {"PATCH", "/core/v1/sandbox/deployment/maintenance", "basic", 200}, {"PATCH", "/core/v1/sandbox/deployment/maintenance", "none", 401}, {"GET", "/core/v1/sandbox/nodes", "node", 401}, - {"GET", "/core/v1/admin/projects", "basic", 200}, - {"POST", "/core/v1/sandbox/enroll", "basic", 404}, + {"GET", "/core/v1/projects", "basic", 200}, {"POST", "/api/v1/sandbox-node/enroll", "node", 404}, {"GET", "/console/config", "basic", 200}, {"GET", "/console/config", "none", 401}, diff --git a/services/core-console/project_proxy_test.go b/services/core-console/project_proxy_test.go index 44370116c..c23dfc071 100644 --- a/services/core-console/project_proxy_test.go +++ b/services/core-console/project_proxy_test.go @@ -13,7 +13,7 @@ func TestCoreDirectRoutesNeverPassThroughConsole(t *testing.T) { c := accountConsoleConfig(t, http.HandlerFunc(func(http.ResponseWriter, *http.Request) { t.Error("direct Core route reached Core through console") })) h := accountConsole(t, c) cookie := setupAccount(t, h) - for _, path := range []string{"/v1", "/v1/agents", "/v1/agents/sessions", "/v1/files/file/content", "/core/v1/environments/env/executor-credentials", "/console/api-keys", + for _, path := range []string{"/v1", "/v1/agents", "/v1/agents/sessions", "/v1/files/file/content", "/console/api-keys", "/api/v1", "/api/v1/sandbox-node/enroll", "/api/v1/sandbox-node/configuration", "/api/v1/sandbox-node/connect", "/api/v1/agent-daemon/enroll", "/api/v1/agent-daemon/ws"} { for _, method := range []string{"GET", "POST", "DELETE"} { for _, authorization := range []string{"", "Bearer project-key", "Bearer node-token", "Basic YWRtaW46cGFzc3dvcmQ="} { @@ -35,32 +35,15 @@ func TestCoreDirectRoutesNeverPassThroughConsole(t *testing.T) { } } -func TestManagedSessionArchiveAllowlist(t *testing.T) { - path := "/core/v1/admin/projects/project/sessions/session/archive" - for _, test := range []struct { - method, path string - allowed bool - }{ - {"GET", path, true}, {"HEAD", path, true}, {"POST", path, true}, - {"DELETE", path, false}, {"PUT", path, false}, {"PATCH", path, false}, - {"POST", path + "/extra", false}, - {"POST", "/core/v1/admin/projects/project/sessions/session/events", false}, - {"POST", "/v1/agents/sessions/session/archive", false}, - } { - if got := adminAPIRequest(httptest.NewRequest(test.method, test.path, nil)); got != test.allowed { - t.Errorf("%s %s: allowed=%v", test.method, test.path, got) - } - } -} - -// Encoded or doubled separators cannot turn a /core request into a forwarded -// /api/v1 request. +// Encoded or doubled separators and dot segments cannot turn a /core/v1 +// request into a forwarded /v1 or /api/v1 request. func TestConsoleRejectsSmuggledMachinePaths(t *testing.T) { c := accountConsoleConfig(t, http.HandlerFunc(func(http.ResponseWriter, *http.Request) { t.Error("smuggled path reached Core") })) h := accountConsole(t, c) cookie := setupAccount(t, h) for _, target := range []string{"/core/v1/sandbox/..%2F..%2F..%2Fapi/v1/sandbox-node/enroll", "//api/v1/sandbox-node/enroll", - "/core/v1/sandbox/nodes/%2E%2E/%2E%2E/%2E%2E/%2E%2E/api/v1/agent-daemon/ws"} { + "/core/v1/sandbox/nodes/%2E%2E/%2E%2E/%2E%2E/%2E%2E/api/v1/agent-daemon/ws", "/core/v1/../../v1/agents", "/core/v1/%2E%2E/%2E%2E/v1/agents", + "/core/v1/projects/p/..%2F..%2F..%2F..%2Fv1/agents", "/core/v1/projects/%252E%252E/v1", "/core/v1//v1/agents", "/core/v1/projects/%5C..%5C..%5Cv1"} { r := httptest.NewRequest("POST", target, strings.NewReader(`{}`)) r.Host = h.host r.Header.Set("Origin", h.origin) diff --git a/services/core-console/sandbox_admin.go b/services/core-console/sandbox_admin.go deleted file mode 100644 index b012f5940..000000000 --- a/services/core-console/sandbox_admin.go +++ /dev/null @@ -1,47 +0,0 @@ -package main - -import ( - "net/http" - "strings" -) - -// coreDirectRequest reports the application (/v1) and machine connection -// (/api/v1) namespaces. The reverse proxy routes them straight to Core; the -// console never forwards them, whatever credential they carry. -func coreDirectRequest(r *http.Request) bool { - for _, prefix := range []string{"/v1", "/api/v1"} { - if r.URL.Path == prefix || strings.HasPrefix(r.URL.Path, prefix+"/") { - return true - } - } - return false -} - -// Only deployment administration is exposed through the console. -func sandboxAdminRequest(r *http.Request) bool { - const base = "/core/v1/sandbox/" - if !strings.HasPrefix(r.URL.Path, base) { - return false - } - parts := strings.Split(strings.TrimPrefix(r.URL.Path, base), "/") - if len(parts) == 1 { - switch parts[0] { - case "deployment": - return r.Method == http.MethodGet || r.Method == http.MethodPost || r.Method == http.MethodPut - case "nodes": - return r.Method == http.MethodGet - case "enrollment-tokens": - return r.Method == http.MethodPost - } - } - if len(parts) == 2 && parts[0] == "deployment" && parts[1] == "maintenance" { - return r.Method == http.MethodPatch - } - if len(parts) >= 2 && parts[0] == "nodes" && parts[1] != "" { - if len(parts) == 2 { - return r.Method == http.MethodGet || r.Method == http.MethodPatch || r.Method == http.MethodDelete - } - return len(parts) == 3 && parts[2] == "allocations" && r.Method == http.MethodGet - } - return false -} diff --git a/services/core-console/server.go b/services/core-console/server.go index 921123311..2e4ed0e97 100644 --- a/services/core-console/server.go +++ b/services/core-console/server.go @@ -123,7 +123,7 @@ func (h *console) ServeHTTP(w http.ResponseWriter, r *http.Request) { _, _ = io.WriteString(w, "ok\n") return } - if coreDirectRequest(r) || r.URL.Path == "/console/api-keys" || strings.HasPrefix(r.URL.Path, "/console/api-keys/") || strings.HasPrefix(r.URL.Path, "/core/v1/environments/") { + if coreDirectRequest(r) || r.URL.Path == "/console/api-keys" || strings.HasPrefix(r.URL.Path, "/console/api-keys/") { http.NotFound(w, r) return } @@ -180,7 +180,7 @@ func (h *console) ServeHTTP(w http.ResponseWriter, r *http.Request) { return } if r.URL.Path == "/core" || strings.HasPrefix(r.URL.Path, "/core/") { - if !sandboxAdminRequest(r) && !adminAPIRequest(r) { + if !coreRequest(r) { http.NotFound(w, r) return } diff --git a/services/core-console/server_test.go b/services/core-console/server_test.go index 2ca6aa96a..29fd53fb4 100644 --- a/services/core-console/server_test.go +++ b/services/core-console/server_test.go @@ -98,7 +98,7 @@ func TestAuthenticationAndCrossSiteAdmission(t *testing.T) { } for _, tc := range cases { t.Run(tc.name, func(t *testing.T) { - r := consoleRequest(t, server, tc.method, "/core/v1/admin/projects") + r := consoleRequest(t, server, tc.method, "/core/v1/projects") tc.change(r) response, body := responseBody(t, server, r) if response.StatusCode != tc.status { @@ -173,7 +173,7 @@ func TestProxyRejectsRedirectWithoutFollowingOrExposingIt(t *testing.T) { server, _ := testConsole(t, http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { http.Redirect(w, r, destination.URL+"/?token=private-admin-token", http.StatusTemporaryRedirect) })) - response, body := responseBody(t, server, consoleRequest(t, server, "GET", "/core/v1/admin/projects")) + response, body := responseBody(t, server, consoleRequest(t, server, "GET", "/core/v1/projects")) if response.StatusCode != 502 || response.Header.Get("Location") != "" || strings.Contains(body, "private-admin-token") || destinationCalls.Load() != 0 { t.Fatal("upstream redirect escaped the fixed proxy") } @@ -190,7 +190,7 @@ func TestArtifactProxyFlushesContentAndCancelsUpstream(t *testing.T) { })) ctx, cancel := context.WithTimeout(context.Background(), 3*time.Second) defer cancel() - r := consoleRequest(t, server, "GET", "/core/v1/admin/projects/key/sessions/session/artifacts/artifact/content").WithContext(ctx) + r := consoleRequest(t, server, "GET", "/core/v1/projects/key/sessions/session/artifacts/artifact/content").WithContext(ctx) response, err := server.Client().Do(r) if err != nil { t.Fatal(err) From 8af26cedb60d3e5c1d8603dc18e1e885a628ac32 Mon Sep 17 00:00:00 2001 From: SaladDay <1203511142@qq.com> Date: Fri, 25 Sep 2026 14:21:09 +0000 Subject: [PATCH 07/30] Give the Core clients their own /core/v1 request layer AdminClient, SandboxAdminClient and CoreMetricsClient keep their names and methods but no longer extend the /v1 OpenAIAgentsClient. They share an internal Core request layer that reuses AdminClient's error projection and AgentCoreError, never sends OpenAI-Beta and sends a bearer only when a Core key is given. Defaults are /core/v1, /core/v1/sandbox and /core/v1/metrics; listRuntimeObservations reads /core/v1/sandbox/runtime-observations. Export CoreProjectReader as agreed with Web, tighten listSkills, listSkillVersions and listSourceFiles to SkillList, SkillVersionList and SourceFileList so a project-bound AdminClient satisfies it without a cast, and add listExecutorCredentials, issueExecutorCredential and revokeExecutorCredential with ExecutorCredential, IssueExecutorCredentialInput and IssuedExecutorCredential. SandboxNode.diagnostic is optional because Core omits it when ready. --- .../agents-client/src/admin-client.test.ts | 41 +++++++-- packages/agents-client/src/admin-client.ts | 84 +++++++++---------- .../agents-client/src/admin-projection.ts | 23 ++++- .../src/admin-session-archive.test.ts | 2 +- packages/agents-client/src/admin-types.ts | 52 +++++++++++- packages/agents-client/src/client.test.ts | 4 +- .../agents-client/src/core-metrics.test.ts | 22 ++++- packages/agents-client/src/core-metrics.ts | 22 +++-- .../src/core-project-reader.test.ts | 63 ++++++++++++++ packages/agents-client/src/core-request.ts | 61 ++++++++++++++ .../src/execution-configuration.test.ts | 2 +- packages/agents-client/src/index.ts | 3 +- .../agents-client/src/runtime-history.test.ts | 4 +- .../agents-client/src/sandbox-client.test.ts | 13 ++- packages/agents-client/src/sandbox-client.ts | 47 +++++++---- 15 files changed, 358 insertions(+), 85 deletions(-) create mode 100644 packages/agents-client/src/core-project-reader.test.ts create mode 100644 packages/agents-client/src/core-request.ts diff --git a/packages/agents-client/src/admin-client.test.ts b/packages/agents-client/src/admin-client.test.ts index 1c9e995f5..d24d763d1 100644 --- a/packages/agents-client/src/admin-client.test.ts +++ b/packages/agents-client/src/admin-client.test.ts @@ -33,7 +33,7 @@ const routeCases: Array<[string, string, (client: AdminClient) => Promise client.revokeAPIKey(projectId, keyId)], ["GET", "/audit-log", (client) => client.listAuditLog()], ["GET", "/summary", (client) => client.retrieveSummary()], - ["GET", "/runtime-observations", (client) => client.listRuntimeObservations()], + ["GET", "/sandbox/runtime-observations", (client) => client.listRuntimeObservations()], ["GET", `/projects/${projectId}/agents`, (client) => client.listAgents(projectId)], ["GET", `/projects/${projectId}/agents/a`, (client) => client.retrieveAgent(projectId, "a")], ["DELETE", `/projects/${projectId}/agents/a`, (client) => client.deleteAgent(projectId, "a")], @@ -72,6 +72,9 @@ const routeCases: Array<[string, string, (client: AdminClient) => Promise client.retrieveRuntimeHistory(projectId, sessionId, { start: 1, end: 2 })], ["GET", `/projects/${projectId}/resource-owners?resource_type=agent&resource_ids=a`, (client) => client.retrieveResourceOwners(projectId, "agent", ["a"])], ["GET", `/projects/${projectId}/write-operations`, (client) => client.listWriteOperations(projectId)], + ["GET", `/projects/${projectId}/environments/${resourceId}/executor-credentials`, (client) => client.listExecutorCredentials(projectId, resourceId)], + ["POST", `/projects/${projectId}/environments/${resourceId}/executor-credentials`, (client) => client.issueExecutorCredential(projectId, resourceId, { key_id: keyId })], + ["DELETE", `/projects/${projectId}/environments/${resourceId}/executor-credentials/${keyId}`, (client) => client.revokeExecutorCredential(projectId, resourceId, keyId)], ]; describe("AdminClient transport boundary", () => { @@ -81,7 +84,7 @@ describe("AdminClient transport boundary", () => { await expect(call(client)).rejects.toMatchObject({ status: 404, code: "not_found" }); expect(fetch).toHaveBeenCalledOnce(); const [url, init] = fetch.mock.calls[0]!; - expect(url).toBe(`/core/v1/admin${path}`); + expect(url).toBe(`/core/v1${path}`); expect(init).toMatchObject({ method, credentials: "same-origin", redirect: "error" }); expect(new Headers(init?.headers).has("Authorization")).toBe(false); expect(new Headers(init?.headers).has("OpenAI-Beta")).toBe(false); @@ -98,10 +101,10 @@ describe("AdminClient transport boundary", () => { it("only uses an explicitly supplied admin credential and forwards cancellation", async () => { const fetch = vi.fn().mockImplementation(async () => json({ data: [], has_more: false })); const signal = new AbortController().signal; - const client = new AdminClient({ baseUrl: "https://core.test/core/v1/admin/", adminToken: () => "deployment-secret", fetch }); + const client = new AdminClient({ baseUrl: "https://core.test/core/v1/", adminToken: () => "deployment-secret", fetch }); await client.listAPIKeys(projectId, { after: keyId, limit: 5, order: "asc", signal }); const [url, init] = fetch.mock.calls[0]!; - expect(url).toBe(`https://core.test/core/v1/admin/projects/${projectId}/keys?after=${keyId}&limit=5&order=asc`); + expect(url).toBe(`https://core.test/core/v1/projects/${projectId}/keys?after=${keyId}&limit=5&order=asc`); expect(init?.signal).toBe(signal); expect(new Headers(init?.headers).get("Authorization")).toBe("Bearer deployment-secret"); }); @@ -114,10 +117,34 @@ describe("AdminClient transport boundary", () => { expect(JSON.parse(fetch.mock.calls[0]![1]!.body as string)).toEqual({ name: "SDK" }); }); + it("issues an executor credential once with the caller's key ID and binds the result to it", async () => { + const issued = { key_id: keyId, environment_id: resourceId, executor_token: "once-only" }; + const { client, fetch } = clientWith(issued); + expect(await client.issueExecutorCredential(projectId, resourceId, { key_id: keyId, rotate: true })).toEqual(issued); + expect(await client.issueExecutorCredential(projectId, resourceId, { key_id: keyId })).toEqual(issued); + expect(fetch.mock.calls.map(([, init]) => JSON.parse(init!.body as string))).toEqual([{ key_id: keyId, rotate: true }, { key_id: keyId, rotate: false }]); + for (const other of [{ ...issued, key_id: sessionId }, { ...issued, environment_id: sessionId }, { ...issued, executor_token: "" }, { ...issued, extra: 1 }]) { + await expect(clientWith(other).client.issueExecutorCredential(projectId, resourceId, { key_id: keyId })).rejects.toMatchObject({ code: "invalid_admin_response" }); + } + const failing = vi.fn().mockRejectedValue(new TypeError("connection closed")); + await expect(new AdminClient({ fetch: failing }).issueExecutorCredential(projectId, resourceId, { key_id: keyId })).rejects.toThrow("connection closed"); + expect(failing).toHaveBeenCalledOnce(); + }); + + it("lists executor credential metadata only and revokes with 204", async () => { + const credential = { key_id: keyId, created_at: "2026-09-25T00:00:00Z", revoked_at: null }; + expect(await clientWith({ data: [credential] }).client.listExecutorCredentials(projectId, resourceId)).toEqual({ data: [credential] }); + for (const entry of [{ ...credential, executor_token: "leak" }, { ...credential, revoked_at: "later" }, { key_id: keyId, created_at: "2026-09-25T00:00:00Z" }]) { + await expect(clientWith({ data: [entry] }).client.listExecutorCredentials(projectId, resourceId)).rejects.toMatchObject({ code: "invalid_admin_response" }); + } + const fetch = vi.fn().mockImplementation(async () => new Response(null, { status: 204 })); + await expect(new AdminClient({ fetch }).revokeExecutorCredential(projectId, resourceId, keyId)).resolves.toBeUndefined(); + }); + it("encodes identifiers and prevents normalized dot path traversal", async () => { const { client, fetch } = clientWith(key); await expect(client.listAPIKeys("../other")).rejects.toBeInstanceOf(AgentCoreError); - expect(fetch.mock.calls[0]![0]).toBe("/core/v1/admin/projects/..%2Fother/keys"); + expect(fetch.mock.calls[0]![0]).toBe("/core/v1/projects/..%2Fother/keys"); expect(() => client.deleteAgent(projectId, "..")).toThrow(TypeError); expect(fetch).toHaveBeenCalledOnce(); }); @@ -203,7 +230,7 @@ describe("AdminClient deployment read models", () => { 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: "" }; 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/admin/audit-log?after=cursor&project_id=${projectId}&resource_type=agent&action=copy`); + expect(fetch.mock.calls[0]![0]).toBe(`/core/v1/audit-log?after=cursor&project_id=${projectId}&resource_type=agent&action=copy`); await expect(clientWith({ ...audit, data: [{ ...audit.data[0], request_body: { token: "leak" } }] }).client.listAuditLog()).rejects.toBeInstanceOf(AgentCoreError); }); @@ -291,7 +318,7 @@ describe("AdminClient project monitoring", () => { const summary = { data, has_more: false, next_cursor: "" }; const { client, fetch } = clientWith(summary); expect(await client.retrieveSummary({ project_id: projectId, group_by: groupBy })).toEqual(summary); - expect(fetch.mock.calls[0]![0]).toBe(`/core/v1/admin/summary?project_id=${projectId}&group_by=${groupBy}`); + expect(fetch.mock.calls[0]![0]).toBe(`/core/v1/summary?project_id=${projectId}&group_by=${groupBy}`); const { project_id: _, ...oldRow } = row; await expect(clientWith({ ...summary, data: [oldRow] }).client.retrieveSummary()).rejects.toMatchObject({ code: "invalid_admin_response" }); }); diff --git a/packages/agents-client/src/admin-client.ts b/packages/agents-client/src/admin-client.ts index 799d7dd7b..2d81b63d4 100644 --- a/packages/agents-client/src/admin-client.ts +++ b/packages/agents-client/src/admin-client.ts @@ -1,5 +1,5 @@ import { - AgentCoreError, addVaultPageOptions, projectAgentSession, projectRuntimeObservation, + addVaultPageOptions, projectAgentSession, projectRuntimeObservation, projectEnvironmentTemplate, projectEnvironmentTemplateList, projectVault, projectVaultList, projectVaultCredential, projectVaultCredentialList, projectSourceFile, projectSourceFileDeleted, } from "./client"; @@ -7,14 +7,17 @@ import { projectExecutionConfiguration } from "./execution-configuration-project import { projectAgentTurn, projectSessionItem, projectHistoryPage, validateHistoryPageOptions } from "./history-projection"; import { projectRuntimeHistory } from "./runtime-history-projection"; import { canonicalUuid, isNonnegativeInteger, isRecord } from "./response-projection"; +import { CoreRequester } from "./core-request"; import { invalidAdminResponse, projectAdminProject, projectAdminKey, projectIssuedAdminKey, projectAdminPage, projectAdminDeleted, projectAdminSessionArchive, projectResourcePage, projectSavedAgent, projectSkill, projectSkillVersion, projectArtifact, projectSummary, projectAdminRuntimePage, projectResourceOwners, projectWriteOperations, projectAdminAudit, + projectExecutorCredentials, projectIssuedExecutorCredential, } from "./admin-projection"; -import type { PageOptions, ReadOptions, RuntimeHistoryQuery, VaultListOptions } from "./types"; +import type { PageOptions, ReadOptions, RuntimeHistoryQuery, SkillList, SkillVersionDeleted, SkillVersionList, SourceFileList, VaultListOptions } from "./types"; import type { AdminClientOptions, AdminAuditOptions, AdminContent, ArchiveAdminSessionInput, CreateAdminProjectInput, RenameAdminProjectInput, IssueAdminAPIKeyInput, AdminSummaryOptions, AdminResourceType, AdminResourceOwner, AdminWriteOperationOptions, AdminWriteOperationPage, + ExecutorCredential, IssueExecutorCredentialInput, IssuedExecutorCredential, } from "./admin-types"; function segment(value: string): string { @@ -39,44 +42,22 @@ function vaultQuery(path: string, options?: VaultListOptions): string { return params.size ? `${path}?${params}` : path; } -/** Management-only client. Browser callers authenticate through the same-origin console session. */ +/** + * Core's `/core/v1` administration client. Browser callers authenticate through + * the same-origin console session; trusted server callers pass the Core key. + */ export class AdminClient { - readonly #baseUrl: string; - readonly #adminToken: AdminClientOptions["adminToken"]; - readonly #fetch: typeof fetch; + readonly #core: CoreRequester; constructor(options: AdminClientOptions = {}) { - this.#baseUrl = (options.baseUrl ?? "/core/v1/admin").replace(/\/+$/, ""); - this.#adminToken = options.adminToken; - this.#fetch = options.fetch ?? globalThis.fetch.bind(globalThis); + this.#core = new CoreRequester(options.baseUrl ?? "/core/v1", options.adminToken, options.fetch, invalidAdminResponse); } - async #response(path: string, options?: ReadOptions, method = "GET", body?: unknown): Promise { - const headers = new Headers({ Accept: "application/json" }); - const token = typeof this.#adminToken === "function" ? this.#adminToken() : this.#adminToken; - if (token) headers.set("Authorization", `Bearer ${token}`); - if (body !== undefined) headers.set("Content-Type", "application/json"); - const response = await this.#fetch(`${this.#baseUrl}${path}`, { - method, headers, signal: options?.signal, credentials: "same-origin", redirect: "error", - ...(body === undefined ? {} : { body: JSON.stringify(body) }), - }); - if (!response.ok) { - let envelope: unknown; - try { envelope = await response.json(); } catch { /* An intermediary may return a non-JSON error. */ } - const error = isRecord(envelope) && isRecord(envelope.error) ? envelope.error : {}; - throw new AgentCoreError( - typeof error.message === "string" ? error.message : `Core administration request failed (${response.status}).`, - response.status, - typeof error.code === "string" || error.code === null ? error.code : undefined, - typeof error.param === "string" || error.param === null ? error.param : undefined, - typeof error.type === "string" ? error.type : undefined, - ); - } - return response; - } - async #json(path: string, options?: ReadOptions, method?: string, body?: unknown): Promise { - const response = await this.#response(path, options, method, body); - try { return await response.json(); } catch { return invalidAdminResponse(); } + #response(path: string, options?: ReadOptions, method?: string, body?: unknown): Promise { + return this.#core.response(path, options, method, body); + } + #json(path: string, options?: ReadOptions, method?: string, body?: unknown): Promise { + return this.#core.json(path, options, method, body); } async #delete(path: string, id: string, object: O, options?: ReadOptions) { return projectAdminDeleted(await this.#json(path, options, "DELETE"), id, object); @@ -117,7 +98,7 @@ export class AdminClient { return projectSummary(await this.#json(path, options)); } async listRuntimeObservations(options?: PageOptions) { - return projectAdminRuntimePage(await this.#json(pageQuery("/runtime-observations", options), options)); + return projectAdminRuntimePage(await this.#json(pageQuery("/sandbox/runtime-observations", options), options)); } async listAgents(projectId: string, options?: PageOptions) { @@ -129,7 +110,7 @@ export class AdminClient { deleteAgent(projectId: string, agentId: string, options?: ReadOptions) { return this.#delete(`${scope(projectId)}/agents/${segment(agentId)}`, agentId, "agent.deleted", options); } - async listSkills(projectId: string, options?: PageOptions) { + async listSkills(projectId: string, options?: PageOptions): Promise { return projectResourcePage(await this.#json(pageQuery(`${scope(projectId)}/skills`, options), options), (value) => projectSkill(value)); } async retrieveSkill(projectId: string, skillId: string, options?: ReadOptions) { @@ -138,17 +119,17 @@ export class AdminClient { deleteSkill(projectId: string, skillId: string, options?: ReadOptions) { return this.#delete(`${scope(projectId)}/skills/${segment(skillId)}`, skillId, "skill.deleted", options); } - async listSkillVersions(projectId: string, skillId: string, options?: PageOptions) { + async listSkillVersions(projectId: string, skillId: string, options?: PageOptions): Promise { return projectResourcePage(await this.#json(pageQuery(`${scope(projectId)}/skills/${segment(skillId)}/versions`, options), options), (value) => projectSkillVersion(value, skillId)); } async retrieveSkillVersion(projectId: string, skillId: string, version: string, options?: ReadOptions) { return projectSkillVersion(await this.#json(`${scope(projectId)}/skills/${segment(skillId)}/versions/${segment(version)}`, options), skillId, version); } - async deleteSkillVersion(projectId: string, skillId: string, version: string, options?: ReadOptions) { + async deleteSkillVersion(projectId: string, skillId: string, version: string, options?: ReadOptions): Promise { const value = await this.#json(`${scope(projectId)}/skills/${segment(skillId)}/versions/${segment(version)}`, options, "DELETE"); if (!isRecord(value) || value.version !== version || typeof value.id !== "string") return invalidAdminResponse(); - const { version: deletedVersion, ...receipt } = value; - return { ...projectAdminDeleted(receipt, value.id, "skill.version.deleted"), version: deletedVersion }; + const { version: _, ...receipt } = value; + return { ...projectAdminDeleted(receipt, value.id, "skill.version.deleted"), version }; } downloadSkill(projectId: string, skillId: string, options?: ReadOptions) { return this.#content(`${scope(projectId)}/skills/${segment(skillId)}/content`, options); @@ -166,7 +147,7 @@ export class AdminClient { deleteEnvironmentTemplate(projectId: string, templateId: string, options?: ReadOptions) { return this.#delete(`${scope(projectId)}/environment-templates/${segment(templateId)}`, templateId, "agent.environment.template.deleted", options); } - async listSourceFiles(projectId: string, options?: PageOptions & { purpose?: string }) { + async listSourceFiles(projectId: string, options?: PageOptions & { purpose?: string }): Promise { return projectResourcePage(await this.#json(pageQuery(`${scope(projectId)}/files`, options, { purpose: options?.purpose }), options), (value) => projectSourceFile(value)); } async retrieveSourceFile(projectId: string, fileId: string, options?: ReadOptions) { @@ -270,4 +251,23 @@ export class AdminClient { }); return projectWriteOperations(await this.#json(path, options)); } + + /** Credential metadata for one self_hosted Environment; the credentials themselves are never listed. */ + async listExecutorCredentials(projectId: string, environmentId: string, options?: ReadOptions): Promise<{ data: ExecutorCredential[] }> { + return projectExecutorCredentials(await this.#json(`${scope(projectId)}/environments/${segment(environmentId)}/executor-credentials`, options)); + } + /** + * Issues, or with `rotate: true` replaces, the credential with the caller's key ID and returns it once. + * Sent once: after an uncertain result, refresh the list and reissue the same key ID with `rotate: true`. + */ + async issueExecutorCredential(projectId: string, environmentId: string, input: IssueExecutorCredentialInput, options?: ReadOptions): Promise { + const value = await this.#json(`${scope(projectId)}/environments/${segment(environmentId)}/executor-credentials`, options, "POST", { + key_id: input.key_id, rotate: input.rotate === true, + }); + return projectIssuedExecutorCredential(value, input.key_id, environmentId); + } + /** Revokes the credential; revoking it again is safe. */ + async revokeExecutorCredential(projectId: string, environmentId: string, keyId: string, options?: ReadOptions): Promise { + await this.#response(`${scope(projectId)}/environments/${segment(environmentId)}/executor-credentials/${segment(keyId)}`, options, "DELETE"); + } } diff --git a/packages/agents-client/src/admin-projection.ts b/packages/agents-client/src/admin-projection.ts index ede8c0220..53a9f8326 100644 --- a/packages/agents-client/src/admin-projection.ts +++ b/packages/agents-client/src/admin-projection.ts @@ -2,7 +2,7 @@ import { AgentCoreError, projectAgentSnapshot, projectRuntimeObservation } from import { projectTokenUsage } from "./usage-projection"; import { canonicalUuid, exactFields, isNonnegativeInteger, isRecord, sameResourceId } from "./response-projection"; import type { ListPage, SavedAgent } from "./types"; -import type { AdminAPIKey, AdminProject, AdminAuditPage, AdminSummary, AdminRuntimeObservation, RuntimeDiskObservation, AdminKeyProvenance, AdminResourceOwner, AdminWriteOperationPage, AdminAuditResultID, AdminDeleted, AdminIssuedAPIKey, AdminPage, AdminSessionArchive, SessionArtifact, Skill, SkillVersion } from "./admin-types"; +import type { AdminAPIKey, AdminProject, AdminAuditPage, AdminSummary, AdminRuntimeObservation, RuntimeDiskObservation, AdminKeyProvenance, AdminResourceOwner, AdminWriteOperationPage, AdminAuditResultID, AdminDeleted, AdminIssuedAPIKey, AdminPage, AdminSessionArchive, SessionArtifact, Skill, SkillVersion, ExecutorCredential, IssuedExecutorCredential } from "./admin-types"; export function invalidAdminResponse(): never { throw new AgentCoreError("Core returned an invalid administration response.", 502, "invalid_admin_response"); @@ -44,7 +44,9 @@ export function projectAdminPage(value: unknown, project: (entry: unknown) => if (!Array.isArray(page.data) || typeof page.has_more !== "boolean" || (page.has_more && page.data.length === 0)) return invalidAdminResponse(); return { data: page.data.map(project), has_more: page.has_more }; } -export function projectResourcePage(value: unknown, project: (entry: unknown) => T): ListPage { +/** A public resource page: `object`, `first_id` and `last_id` are always present. */ +export type ResourcePage = ListPage & { object: "list"; first_id: string | null; last_id: string | null }; +export function projectResourcePage(value: unknown, project: (entry: unknown) => T): ResourcePage { const page = record(value, ["object", "data", "has_more", "first_id", "last_id"]); if (page.object !== "list" || !Array.isArray(page.data) || typeof page.has_more !== "boolean") return invalidAdminResponse(); const data = page.data.map(project); @@ -195,3 +197,20 @@ export function projectAdminAudit(value: unknown): AdminAuditPage { }); return { data, has_more: page.has_more, next_cursor: page.next_cursor } as AdminAuditPage; } +export function projectExecutorCredentials(value: unknown): { data: ExecutorCredential[] } { + const page = record(value, ["data"]); + if (!Array.isArray(page.data)) return invalidAdminResponse(); + return { data: page.data.map((entry) => { + const credential = record(entry, ["key_id", "created_at", "revoked_at"]); + if (typeof credential.key_id !== "string" || typeof credential.created_at !== "string" || !date(credential.created_at) || !date(credential.revoked_at)) return invalidAdminResponse(); + return { key_id: credential.key_id, created_at: credential.created_at, revoked_at: credential.revoked_at as string | null }; + }) }; +} +/** The one-time credential must belong to the requested key ID and Environment. */ +export function projectIssuedExecutorCredential(value: unknown, keyId: string, environmentId: string): IssuedExecutorCredential { + const issued = record(value, ["key_id", "environment_id", "executor_token"]); + if (typeof issued.key_id !== "string" || !sameResourceId(issued.key_id, keyId) || + typeof issued.environment_id !== "string" || !sameResourceId(issued.environment_id, environmentId) || + typeof issued.executor_token !== "string" || !issued.executor_token) return invalidAdminResponse(); + return { key_id: issued.key_id, environment_id: issued.environment_id, executor_token: issued.executor_token }; +} diff --git a/packages/agents-client/src/admin-session-archive.test.ts b/packages/agents-client/src/admin-session-archive.test.ts index f452ea4db..eeeeaacb6 100644 --- a/packages/agents-client/src/admin-session-archive.test.ts +++ b/packages/agents-client/src/admin-session-archive.test.ts @@ -18,7 +18,7 @@ describe("administrative Session archive", () => { await expect(client.retrieveSessionArchive(projectId, sessionId, { signal })).resolves.toEqual(disposition); expect(fetch).toHaveBeenCalledTimes(2); for (const [url, init] of fetch.mock.calls) { - expect(url).toBe(`/core/v1/admin/projects/${projectId}/sessions/${sessionId}/archive`); + expect(url).toBe(`/core/v1/projects/${projectId}/sessions/${sessionId}/archive`); expect(init).toMatchObject({ signal, credentials: "same-origin", redirect: "error" }); expect(new Headers(init?.headers).get("Authorization")).toBe("Bearer test-admin"); expect(new Headers(init?.headers).has("OpenAI-Beta")).toBe(false); diff --git a/packages/agents-client/src/admin-types.ts b/packages/agents-client/src/admin-types.ts index 9559797a9..1cbacb32f 100644 --- a/packages/agents-client/src/admin-types.ts +++ b/packages/agents-client/src/admin-types.ts @@ -1,8 +1,13 @@ -import type { PageOptions } from "./types"; +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"; export interface AdminClientOptions { baseUrl?: string; - /** Deployment credential for trusted server callers; console browsers use their same-origin session. */ + /** Core key for trusted server callers; console browsers use their same-origin session instead. */ adminToken?: string | (() => string | undefined); fetch?: typeof fetch; } @@ -149,3 +154,46 @@ export interface AdminAuditEntry { trace_id: string; } export interface AdminAuditPage extends AdminPage { next_cursor: string } + +/** Executor credential metadata for one self_hosted environment; the credential itself is never listed. */ +export interface ExecutorCredential { key_id: string; created_at: string; revoked_at: string | null } +/** `key_id` is chosen by the caller, so an uncertain issuance can be reissued with the same ID and `rotate: true`. */ +export interface IssueExecutorCredentialInput { key_id: string; rotate?: boolean } +/** Returned once, on issuance or rotation. */ +export interface IssuedExecutorCredential { key_id: string; environment_id: string; executor_token: string } + +/** + * The project-bound reads and deletions the console makes through Core, in + * the public projections' shapes: each method is a Core client method with + * its project ID bound. + */ +export interface CoreProjectReader { + 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>; + 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.test.ts b/packages/agents-client/src/client.test.ts index 0b021e45a..5fb94cf7f 100644 --- a/packages/agents-client/src/client.test.ts +++ b/packages/agents-client/src/client.test.ts @@ -2802,7 +2802,7 @@ describe("OpenAIAgentsClient", () => { it("retrieves a Runtime observation, preserves observed zeroes, and encodes the Session ID", async () => { const calls: FetchCall[] = []; const client = new AdminClient({ - baseUrl: "https://core.example/core/v1/admin", + baseUrl: "https://core.example/core/v1", fetch: recordingFetch(jsonResponse(runtimeObservation()), calls), }); @@ -2812,7 +2812,7 @@ describe("OpenAIAgentsClient", () => { memory: { usage_bytes: 0 }, }); expect(String(calls[0]?.input)).toBe( - `https://core.example/core/v1/admin/projects/${runtimeProjectId}/sessions/${runtimeSessionId}/runtime-observation`, + `https://core.example/core/v1/projects/${runtimeProjectId}/sessions/${runtimeSessionId}/runtime-observation`, ); }); diff --git a/packages/agents-client/src/core-metrics.test.ts b/packages/agents-client/src/core-metrics.test.ts index 12a58fffd..61d50d295 100644 --- a/packages/agents-client/src/core-metrics.test.ts +++ b/packages/agents-client/src/core-metrics.test.ts @@ -1,6 +1,24 @@ -import { describe, expect, it } from "vitest"; +import { describe, expect, it, vi } from "vitest"; -import { projectCoreMetrics } from "./core-metrics"; +import { OpenAIAgentsClient } from "./client"; +import { CoreMetricsClient, projectCoreMetrics } from "./core-metrics"; + +describe("Core metrics client", () => { + it("reads /core/v1/metrics without the Beta header, with a bearer only when given", async () => { + const body = { object: "core.metrics", range: { start: "2026-09-24T00:00:00Z", end: "2026-09-24T01:00:00Z", resolution_seconds: 60 }, service: { status: "running" } }; + const fetch = vi.fn().mockImplementation(async () => new Response(JSON.stringify(body))); + const client = new CoreMetricsClient({ fetch }); + expect(client).not.toBeInstanceOf(OpenAIAgentsClient); + await client.retrieveCoreMetrics("6h"); + await new CoreMetricsClient({ token: "core-key", fetch }).retrieveCoreMetrics("1h"); + const [[url, init], [, authorized]] = fetch.mock.calls as [[string, RequestInit], [string, RequestInit]]; + expect(url).toBe("/core/v1/metrics?range=6h"); + expect(init).toMatchObject({ credentials: "same-origin", redirect: "error" }); + expect(new Headers(init.headers).has("Authorization")).toBe(false); + expect(new Headers(init.headers).has("OpenAI-Beta")).toBe(false); + expect(new Headers(authorized.headers).get("Authorization")).toBe("Bearer core-key"); + }); +}); describe("Core metrics projection", () => { it("keeps unmeasured figures null instead of zero", () => { diff --git a/packages/agents-client/src/core-metrics.ts b/packages/agents-client/src/core-metrics.ts index 3b9c9e654..949ef5f81 100644 --- a/packages/agents-client/src/core-metrics.ts +++ b/packages/agents-client/src/core-metrics.ts @@ -1,11 +1,16 @@ -import { AgentCoreError, OpenAIAgentsClient } from "./client"; +import { AgentCoreError } from "./client"; +import { CoreRequester, type CoreClientOptions } from "./core-request"; import type { ReadOptions } from "./types"; +function invalidCoreMetrics(): never { + throw new AgentCoreError("Core metrics: the response is not JSON.", 0, "invalid_response"); +} + /** * Core's own health as the one `agents-api` process sees it: execution slots * and the Turn queue (a Postgres table polled by the worker), connected * daemons, the PostgreSQL database, background jobs and the process itself. - * `GET /core/v1/admin/core-metrics`, defined in + * `GET /core/v1/metrics`, defined in * contracts/agents-api/core-metrics.md; every figure Core cannot measure is * null, never zero. */ @@ -208,10 +213,15 @@ export function projectCoreMetrics(value: unknown): CoreMetrics { }; } -/** Reads Core's own metrics through the console's Web API (`/core/v1/admin`). */ -export class CoreMetricsClient extends OpenAIAgentsClient { +/** Reads Core's own metrics from `/core/v1/metrics`, through an authenticated console or an explicit Core key. */ +export class CoreMetricsClient { + readonly #core: CoreRequester; + + constructor(options: CoreClientOptions = {}) { + this.#core = new CoreRequester(options.baseUrl ?? "/core/v1/metrics", options.token, options.fetch, invalidCoreMetrics); + } + async retrieveCoreMetrics(range: CoreMetricsRange, options?: ReadOptions): Promise { - const value: unknown = await this.request(`/core-metrics?range=${encodeURIComponent(range)}`, { signal: options?.signal }, undefined, false); - return projectCoreMetrics(value); + return projectCoreMetrics(await this.#core.json(`?range=${encodeURIComponent(range)}`, options)); } } diff --git a/packages/agents-client/src/core-project-reader.test.ts b/packages/agents-client/src/core-project-reader.test.ts new file mode 100644 index 000000000..6eff9a34a --- /dev/null +++ b/packages/agents-client/src/core-project-reader.test.ts @@ -0,0 +1,63 @@ +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/core-request.ts b/packages/agents-client/src/core-request.ts new file mode 100644 index 000000000..5036af74c --- /dev/null +++ b/packages/agents-client/src/core-request.ts @@ -0,0 +1,61 @@ +import { AgentCoreError } from "./client"; +import { isRecord } from "./response-projection"; +import type { ReadOptions } from "./types"; + +/** Constructor options of the Core clients that take a plain bearer `token`. */ +export interface CoreClientOptions { + baseUrl?: string; + /** Core key for trusted server callers; console browsers use their same-origin session instead. */ + token?: string | (() => string | undefined); + fetch?: typeof fetch; +} + +/** + * The internal request layer of the `/core/v1` clients. It sends no OpenAI-Beta + * header, attaches a bearer only when a token is supplied, keeps browser + * requests same-origin, refuses redirects and never retries. + */ +export class CoreRequester { + readonly #baseUrl: string; + readonly #token: CoreClientOptions["token"]; + readonly #fetch: typeof fetch; + readonly #invalid: () => never; + + constructor(baseUrl: string, token: CoreClientOptions["token"], fetchImpl: typeof fetch | undefined, invalid: () => never) { + this.#baseUrl = baseUrl.replace(/\/+$/, ""); + this.#token = token; + this.#fetch = fetchImpl ?? globalThis.fetch.bind(globalThis); + this.#invalid = invalid; + } + + /** Sends one request; a non-2xx response becomes an AgentCoreError with Core's error envelope. */ + async response(path: string, options?: ReadOptions, method = "GET", body?: unknown): Promise { + const headers = new Headers({ Accept: "application/json" }); + const token = typeof this.#token === "function" ? this.#token() : this.#token; + if (token) headers.set("Authorization", `Bearer ${token}`); + if (body !== undefined) headers.set("Content-Type", "application/json"); + const response = await this.#fetch(`${this.#baseUrl}${path}`, { + method, headers, signal: options?.signal, credentials: "same-origin", redirect: "error", + ...(body === undefined ? {} : { body: JSON.stringify(body) }), + }); + if (!response.ok) { + let envelope: unknown; + try { envelope = await response.json(); } catch { /* An intermediary may return a non-JSON error. */ } + const error = isRecord(envelope) && isRecord(envelope.error) ? envelope.error : {}; + throw new AgentCoreError( + typeof error.message === "string" ? error.message : `Core request failed (${response.status}).`, + response.status, + typeof error.code === "string" || error.code === null ? error.code : undefined, + typeof error.param === "string" || error.param === null ? error.param : undefined, + typeof error.type === "string" ? error.type : undefined, + ); + } + return response; + } + + /** Sends one request and parses its JSON body; an unparsable body is an invalid response. */ + async json(path: string, options?: ReadOptions, method?: string, body?: unknown): Promise { + const response = await this.response(path, options, method, body); + try { return await response.json(); } catch { return this.#invalid(); } + } +} diff --git a/packages/agents-client/src/execution-configuration.test.ts b/packages/agents-client/src/execution-configuration.test.ts index 50a229e97..9b2d07b76 100644 --- a/packages/agents-client/src/execution-configuration.test.ts +++ b/packages/agents-client/src/execution-configuration.test.ts @@ -20,7 +20,7 @@ describe("frozen execution configuration", () => { it("reads a Session-scoped snapshot with auth and abort signal", async () => { const abort = new AbortController(); const client = clientReturning(snapshot, (url, init) => { - expect(url).toBe(`/core/v1/admin/projects/${projectId}/sessions/${id}/execution-configuration`); + expect(url).toBe(`/core/v1/projects/${projectId}/sessions/${id}/execution-configuration`); expect(init?.signal).toBe(abort.signal); expect(new Headers(init?.headers).get("Authorization")).toBe("Bearer admin-token"); }); diff --git a/packages/agents-client/src/index.ts b/packages/agents-client/src/index.ts index 92f1eaf71..3f2c90970 100644 --- a/packages/agents-client/src/index.ts +++ b/packages/agents-client/src/index.ts @@ -5,9 +5,10 @@ export type { SSEDecoder, SSEMessage } from "./sse"; export type * from "./types"; export * from "./sandbox-client"; export * from "./core-metrics"; +export type { CoreClientOptions } from "./core-request"; export { isEnvironmentTemplateName, isRecognizedEnvironmentTemplate } from "./environment-template-projection"; export { isOpenAIHostedSessionEnvironment } from "./session-environment-projection"; 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 } 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, AdminAuditResultID, AdminAuditEntry, AdminAuditPage, ExecutorCredential, IssueExecutorCredentialInput, IssuedExecutorCredential, CoreProjectReader } from "./admin-types"; diff --git a/packages/agents-client/src/runtime-history.test.ts b/packages/agents-client/src/runtime-history.test.ts index 783e86406..d82ff9512 100644 --- a/packages/agents-client/src/runtime-history.test.ts +++ b/packages/agents-client/src/runtime-history.test.ts @@ -18,7 +18,7 @@ function jsonResponse(body: unknown): Response { function clientFor(body: unknown, calls: FetchCall[] = []): AdminClient { return new AdminClient({ - baseUrl: "https://core.example/core/v1/admin", + baseUrl: "https://core.example/core/v1", fetch: (async (input: RequestInfo | URL, init?: RequestInit) => { calls.push({ input, init }); return jsonResponse(body); @@ -91,7 +91,7 @@ describe("Runtime history client", () => { signal: controller.signal, }); expect(String(calls[0]?.input)).toBe( - `https://core.example/core/v1/admin/projects/${projectId}/sessions/${sessionId}/runtime-history?start=1000&end=1120&max_points=60`, + `https://core.example/core/v1/projects/${projectId}/sessions/${sessionId}/runtime-history?start=1000&end=1120&max_points=60`, ); expect(calls[0]?.init?.signal).toBe(controller.signal); expect(value.series[0]?.points[0]?.cpu?.utilization_ratio).toBe(0); diff --git a/packages/agents-client/src/sandbox-client.test.ts b/packages/agents-client/src/sandbox-client.test.ts index 75e055ee4..5e6fa48b0 100644 --- a/packages/agents-client/src/sandbox-client.test.ts +++ b/packages/agents-client/src/sandbox-client.test.ts @@ -1,9 +1,20 @@ import { describe, expect, expectTypeOf, it, vi } from "vitest"; +import { OpenAIAgentsClient } from "./client"; import { SandboxAdminClient, type SandboxNode } from "./sandbox-client"; function response(value: unknown, status = 200) { return new Response(JSON.stringify(value), { status }); } describe("Core sandbox credential boundaries", () => { + it("defaults to /core/v1/sandbox and is not a /v1 client", async () => { + const fetch = vi.fn().mockImplementation(async () => response({ data: [] })); + const client = new SandboxAdminClient({ fetch }); + expect(client).not.toBeInstanceOf(OpenAIAgentsClient); + await client.listNodes(); + const [url, init] = fetch.mock.calls[0]!; + expect(url).toBe("/core/v1/sandbox/nodes"); + expect(init).toMatchObject({ credentials: "same-origin", redirect: "error" }); + expect(new Headers(init?.headers).has("OpenAI-Beta")).toBe(false); + }); it("uses console authentication without sending a browser bearer and forwards cancellation", async () => { const fetch = vi.fn().mockImplementation(async () => response({ data: [] })); const client = new SandboxAdminClient({ baseUrl: "/core/v1/sandbox", fetch }); @@ -17,7 +28,7 @@ describe("Core sandbox credential boundaries", () => { const fetch = vi.fn().mockResolvedValue(response({ data: [{ id: "node", provider_ready: false, diagnostic: "kvm_unavailable" }] })); const { data } = await new SandboxAdminClient({ baseUrl: "/core/v1/sandbox", fetch }).listNodes(); expect(data[0]?.diagnostic).toBe("kvm_unavailable"); - expectTypeOf().toEqualTypeOf<"" | "provider_unavailable" | "docker_unavailable" | "docker_limits_unsupported" | "runtime_image_unavailable" | "kvm_unavailable" | "microsandbox_artifacts_unavailable" | "capacity_insufficient">(); + expectTypeOf().toEqualTypeOf(); }); it("does not retry an enrollment write with an uncertain outcome", async () => { const fetch = vi.fn().mockRejectedValue(new TypeError("Network failed")); diff --git a/packages/agents-client/src/sandbox-client.ts b/packages/agents-client/src/sandbox-client.ts index caaf73000..8bce98a95 100644 --- a/packages/agents-client/src/sandbox-client.ts +++ b/packages/agents-client/src/sandbox-client.ts @@ -1,4 +1,5 @@ -import { AgentCoreError, OpenAIAgentsClient } from "./client"; +import { AgentCoreError } from "./client"; +import { CoreRequester, type CoreClientOptions } from "./core-request"; import type { ReadOptions } from "./types"; export type SandboxDiagnostic = "" | "node_unavailable" | "resource_missing" | "compute_unconfirmed" | "ownership_mismatch" | "provider_unavailable"; @@ -56,7 +57,8 @@ export interface SandboxNode { provider: string; online: boolean; provider_ready: boolean; - diagnostic: SandboxNodeDiagnostic; + /** Absent while the provider is ready. */ + diagnostic?: SandboxNodeDiagnostic; cpu_count: number | null; available_memory_bytes: number | null; available_disk_bytes: number | null; @@ -111,23 +113,36 @@ export interface SandboxAllocation { initialization: string; created_at: string; } -/** Deployment administration uses /core/v1/sandbox, through an authenticated console or an explicit server credential. */ -export class SandboxAdminClient extends OpenAIAgentsClient { +function invalidSandboxResponse(): never { + throw new AgentCoreError("Core returned an invalid sandbox administration response.", 502, "invalid_admin_response"); +} + +/** Deployment administration under `/core/v1/sandbox`, through an authenticated console or an explicit Core key. */ +export class SandboxAdminClient { + readonly #core: CoreRequester; + + constructor(options: CoreClientOptions = {}) { + this.#core = new CoreRequester(options.baseUrl ?? "/core/v1/sandbox", options.token, options.fetch, invalidSandboxResponse); + } + + #json(path: string, options?: ReadOptions, method?: string, body?: unknown): Promise { + return this.#core.json(path, options, method, body) as Promise; + } retrieveDeployment(options?: ReadOptions): Promise { - return this.request("/deployment", { signal: options?.signal }, undefined, false); + return this.#json("/deployment", options); } initializeDeployment(input: InitializeSandboxDeployment, options?: ReadOptions): Promise { - return this.writeDeployment("POST", input, options); + return this.#writeDeployment("POST", input, options); } updateDeployment(input: UpdateSandboxDeployment, options?: ReadOptions): Promise { - return this.writeDeployment("PUT", input, options); + return this.#writeDeployment("PUT", input, options); } setMaintenance(input: SetSandboxMaintenance, options?: ReadOptions): Promise { - return this.request("/deployment/maintenance", { method: "PATCH", body: JSON.stringify(input), signal: options?.signal }, undefined, false); + return this.#json("/deployment/maintenance", options, "PATCH", input); } - private async writeDeployment(method: "POST" | "PUT", input: InitializeSandboxDeployment | UpdateSandboxDeployment, options?: ReadOptions): Promise { + async #writeDeployment(method: "POST" | "PUT", input: InitializeSandboxDeployment | UpdateSandboxDeployment, options?: ReadOptions): Promise { try { - return await this.request("/deployment", { method, body: JSON.stringify(input), signal: options?.signal }, undefined, false); + return await this.#json("/deployment", options, method, input); } catch (error) { if (!input.e2b) throw error; // A credential-bearing rejection may reflect the key in any error field. @@ -135,21 +150,21 @@ export class SandboxAdminClient extends OpenAIAgentsClient { } } listNodes(options?: ReadOptions): Promise<{ data: SandboxNode[] }> { - return this.request("/nodes", { signal: options?.signal }, undefined, false); + return this.#json("/nodes", options); } retrieveNode(nodeId: string, range: SandboxNodeHistoryRange, options?: ReadOptions): Promise { - return this.request(`/nodes/${encodeURIComponent(nodeId)}?range=${range}`, { signal: options?.signal }, undefined, false); + return this.#json(`/nodes/${encodeURIComponent(nodeId)}?range=${range}`, options); } listAllocations(nodeId: string, options?: ReadOptions): Promise<{ data: SandboxAllocation[] }> { - return this.request(`/nodes/${encodeURIComponent(nodeId)}/allocations`, { signal: options?.signal }, undefined, false); + return this.#json(`/nodes/${encodeURIComponent(nodeId)}/allocations`, options); } createEnrollment(options?: ReadOptions, capacity: { max_active?: number; max_retained?: number } = {}): Promise<{ token: string; expires_at: string }> { - return this.request("/enrollment-tokens", { method: "POST", body: JSON.stringify(capacity), signal: options?.signal }, undefined, false); + return this.#json("/enrollment-tokens", options, "POST", capacity); } updateNode(nodeId: string, input: SandboxNodeUpdate, options?: ReadOptions): Promise<{ id: string; updated: boolean }> { - return this.request(`/nodes/${encodeURIComponent(nodeId)}`, { method: "PATCH", body: JSON.stringify(input), signal: options?.signal }, undefined, false); + return this.#json(`/nodes/${encodeURIComponent(nodeId)}`, options, "PATCH", input); } removeNode(nodeId: string, options?: ReadOptions): Promise<{ id: string; deleted: boolean }> { - return this.request(`/nodes/${encodeURIComponent(nodeId)}`, { method: "DELETE", signal: options?.signal }, undefined, false); + return this.#json(`/nodes/${encodeURIComponent(nodeId)}`, options, "DELETE"); } } From 80cd3570ad52bd6e92b971784b5a98ac3ad7917f Mon Sep 17 00:00:00 2001 From: SaladDay <1203511142@qq.com> Date: Fri, 25 Sep 2026 14:22:03 +0000 Subject: [PATCH 08/30] Generate the /core/v1 contract as core.openapi.yaml sandbox-manager.openapi.yaml held every /core/v1 route, not only sandbox administration. make openapi now splits the generated document by namespace into openapi.yaml (/v1), core.openapi.yaml (/core/v1) and runtime.openapi.yaml (/api/v1); the public openapi.yaml is unchanged. --- CONTRIBUTING.md | 6 ++-- Makefile | 2 +- ...manager.openapi.yaml => core.openapi.yaml} | 4 +-- contracts/agents-api/sandbox-deployment.md | 2 +- .../agents-api/v1/runtime_history_test.go | 2 +- docs/api/README.md | 5 ++-- docs/api/web-management.md | 2 +- scripts/openapi-split/main.go | 29 ++++++++++--------- scripts/openapi-split/main_test.go | 12 ++++---- 9 files changed, 32 insertions(+), 32 deletions(-) rename contracts/agents-api/{sandbox-manager.openapi.yaml => core.openapi.yaml} (99%) diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 33a1ccc0a..df7f6756b 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -190,9 +190,9 @@ migrations. The public protocol schema is `contracts/agents-api/openapi.yaml`; there is no product swaggo contract in this repository. Preserve its pinned types, coverage ledgers and official SDK/raw HTTP tests when changing API behavior. Run `make openapi` after handler annotation changes. It reuses the original -Core-only swaggo v1.16.4 generator, then separates project paths under `/v1` from -`/core/v1` administration in `sandbox-manager.openapi.yaml` and `/api/v1` machine -connections in `runtime.openapi.yaml` (both base path `/`), each keeping only the +Core-only swaggo v1.16.4 generator, then splits the result by namespace: `/v1` +into `openapi.yaml`, `/core/v1` into `core.openapi.yaml` and `/api/v1` into +`runtime.openapi.yaml` (the last two with base path `/`), each keeping only the security schemes its operations use. All generated schemas remain free of product routes. diff --git a/Makefile b/Makefile index db71a446c..b0964a7eb 100644 --- a/Makefile +++ b/Makefile @@ -28,7 +28,7 @@ openapi: --output "$$output" \ --outputTypes yaml --parseInternal; \ python3 scripts/patch-agents-openapi.py "$$output/swagger.yaml"; \ - go run ./scripts/openapi-split "$$output/swagger.yaml" contracts/agents-api/openapi.yaml contracts/agents-api/sandbox-manager.openapi.yaml contracts/agents-api/runtime.openapi.yaml + go run ./scripts/openapi-split "$$output/swagger.yaml" contracts/agents-api/openapi.yaml contracts/agents-api/core.openapi.yaml contracts/agents-api/runtime.openapi.yaml check-sqlc: python3 scripts/check-sqlc.py diff --git a/contracts/agents-api/sandbox-manager.openapi.yaml b/contracts/agents-api/core.openapi.yaml similarity index 99% rename from contracts/agents-api/sandbox-manager.openapi.yaml rename to contracts/agents-api/core.openapi.yaml index 048c23d30..a3bf1cae1 100644 --- a/contracts/agents-api/sandbox-manager.openapi.yaml +++ b/contracts/agents-api/core.openapi.yaml @@ -2803,11 +2803,11 @@ definitions: type: object info: contact: {} - description: Core extensions outside the upstream Agents API. Environment executor credential operations use project caller authentication. Sandbox administration uses the deployment administrator credential. See each operation's security requirements. + description: Deployment and operations routes under /core/v1 for Core Web's server and operator scripts. Every operation requires the Core key; Project API keys and machine credentials are not accepted. license: name: Apache 2.0 url: https://www.apache.org/licenses/LICENSE-2.0.html - title: Core Extensions + title: Core API version: "1" paths: /core/v1/audit-log: diff --git a/contracts/agents-api/sandbox-deployment.md b/contracts/agents-api/sandbox-deployment.md index 37d4bfd49..aaf4131a3 100644 --- a/contracts/agents-api/sandbox-deployment.md +++ b/contracts/agents-api/sandbox-deployment.md @@ -12,7 +12,7 @@ A deployment can remain unconfigured, with no execution nodes or hosted admissio See [Hosted Sandbox Manager](../../services/agents-api/HOSTED-SANDBOX-MANAGER.md) for the operator workflow. Generated schemas cover the -[administrator routes](sandbox-manager.openapi.yaml) and the +[administrator routes](core.openapi.yaml) and the [node machine connection routes](runtime.openapi.yaml). ## Authority and routes diff --git a/contracts/agents-api/v1/runtime_history_test.go b/contracts/agents-api/v1/runtime_history_test.go index ef223d239..542e08560 100644 --- a/contracts/agents-api/v1/runtime_history_test.go +++ b/contracts/agents-api/v1/runtime_history_test.go @@ -11,7 +11,7 @@ import ( func TestRuntimeHistoryOpenAPICollectionLimits(t *testing.T) { t.Parallel() - raw, err := os.ReadFile("../sandbox-manager.openapi.yaml") + raw, err := os.ReadFile("../core.openapi.yaml") if err != nil { t.Fatalf("read generated OpenAPI contract: %v", err) } diff --git a/docs/api/README.md b/docs/api/README.md index c8e55c5ae..d7ce7a7c0 100644 --- a/docs/api/README.md +++ b/docs/api/README.md @@ -35,9 +35,8 @@ when given an application key or machine credential. `x_agents_core`. - [Public OpenAPI](../../contracts/agents-api/openapi.yaml): public schema snapshot; combine it with the fixed SDK and [operation evidence](../../contracts/agents-api/operation-evidence.md). -- [Core extension OpenAPI](../../contracts/agents-api/sandbox-manager.openapi.yaml): - generated management and executor-credential routes. The filename does not mean - that all its routes are sandbox-administrator operations; use the authority table above. +- [Core OpenAPI](../../contracts/agents-api/core.openapi.yaml): generated `/core/v1` + routes, all authenticated by the Core key. - [Machine connection OpenAPI](../../contracts/agents-api/runtime.openapi.yaml): generated `/api/v1` sandbox node routes. The node WebSocket and the private daemon transport are described in the node and Runtime credential guides. diff --git a/docs/api/web-management.md b/docs/api/web-management.md index 374ec2385..a0fadaf72 100644 --- a/docs/api/web-management.md +++ b/docs/api/web-management.md @@ -56,7 +56,7 @@ it is operational attribution, not per-key billing. The [deployment configuration contract](../../contracts/agents-api/sandbox-deployment.md), [Hosted Sandbox Manager reference](../../services/agents-api/HOSTED-SANDBOX-MANAGER.md) -and [generated OpenAPI](../../contracts/agents-api/sandbox-manager.openapi.yaml) +and [generated OpenAPI](../../contracts/agents-api/core.openapi.yaml) define deployment and node operations: - `GET/POST/PUT /core/v1/sandbox/deployment` and diff --git a/scripts/openapi-split/main.go b/scripts/openapi-split/main.go index c032e3546..128838202 100644 --- a/scripts/openapi-split/main.go +++ b/scripts/openapi-split/main.go @@ -1,5 +1,6 @@ -// Command openapi-split separates the project API (/v1), Core administration -// (/core/v1) and machine connections (/api/v1) into their own documents. +// Command openapi-split separates the generated document by namespace: the +// application API (/v1), the Core API (/core/v1) and machine connections +// (/api/v1) each get their own document. package main import ( @@ -13,13 +14,13 @@ import ( const ( projectSurface = iota - managerSurface + coreSurface runtimeSurface ) func main() { if len(os.Args) != 5 { - fmt.Fprintln(os.Stderr, "usage: openapi-split INPUT PROJECT_OUTPUT MANAGER_OUTPUT RUNTIME_OUTPUT") + fmt.Fprintln(os.Stderr, "usage: openapi-split INPUT PROJECT_OUTPUT CORE_OUTPUT RUNTIME_OUTPUT") os.Exit(1) } if err := run(os.Args[1], os.Args[2], os.Args[3], os.Args[4]); err != nil { @@ -27,24 +28,24 @@ func main() { os.Exit(1) } } -func run(input, projectOutput, managerOutput, runtimeOutput string) error { +func run(input, projectOutput, coreOutput, runtimeOutput string) error { raw, err := os.ReadFile(input) if err != nil { return err } - var project, manager, runtime yaml.Node - for _, doc := range []*yaml.Node{&project, &manager, &runtime} { + var project, core, runtime yaml.Node + for _, doc := range []*yaml.Node{&project, &core, &runtime} { if err = yaml.Unmarshal(raw, doc); err != nil { return err } } - p, m, rt := project.Content[0], manager.Content[0], runtime.Content[0] + p, m, rt := project.Content[0], core.Content[0], runtime.Content[0] filterPaths(field(p, "paths"), projectSurface) - filterPaths(field(m, "paths"), managerSurface) + filterPaths(field(m, "paths"), coreSurface) filterPaths(field(rt, "paths"), runtimeSurface) field(m, "basePath").Value = "/" - field(field(m, "info"), "title").Value = "Core Extensions" - field(field(m, "info"), "description").Value = "Core extensions outside the upstream Agents API. Environment executor credential operations use project caller authentication. Sandbox administration uses the deployment administrator credential. See each operation's security requirements." + field(field(m, "info"), "title").Value = "Core API" + field(field(m, "info"), "description").Value = "Deployment and operations routes under /core/v1 for Core Web's server and operator scripts. Every operation requires the Core key; Project API keys and machine credentials are not accepted." field(rt, "basePath").Value = "/" field(field(rt, "info"), "title").Value = "Core Machine Connections" field(field(rt, "info"), "description").Value = "Machine connection routes under /api/v1. Sandbox nodes authenticate with a one-use enrollment token or their node credential; Project API keys and the deployment administrator credential are not accepted. See each operation's security requirements." @@ -57,7 +58,7 @@ func run(input, projectOutput, managerOutput, runtimeOutput string) error { pruneSecurityDefinitions(m) pruneSecurityDefinitions(rt) // Keep unrelated existing project definitions and the generator's formatting. - // Only definitions exclusive to the management or machine surfaces are removed. + // Only definitions exclusive to the Core or machine surfaces are removed. var original yaml.Node if err := yaml.Unmarshal(raw, &original); err != nil { return err @@ -77,7 +78,7 @@ func run(input, projectOutput, managerOutput, runtimeOutput string) error { for _, out := range []struct { path string doc *yaml.Node - }{{projectOutput, &project}, {managerOutput, &manager}, {runtimeOutput, &runtime}} { + }{{projectOutput, &project}, {coreOutput, &core}, {runtimeOutput, &runtime}} { if out.path == projectOutput { if err := os.WriteFile(out.path, preserveProjectFormatting(raw, original.Content[0], p), 0644); err != nil { return err @@ -112,7 +113,7 @@ func field(n *yaml.Node, key string) *yaml.Node { func surface(path string) int { switch { case strings.HasPrefix(path, "/core/v1/"): - return managerSurface + return coreSurface case strings.HasPrefix(path, "/api/v1/"): return runtimeSurface } diff --git a/scripts/openapi-split/main_test.go b/scripts/openapi-split/main_test.go index 68db025b2..20794049b 100644 --- a/scripts/openapi-split/main_test.go +++ b/scripts/openapi-split/main_test.go @@ -9,11 +9,11 @@ import ( "gopkg.in/yaml.v3" ) -func TestSeparatePublicAndAdministrationRoots(t *testing.T) { +func TestSeparatesTheThreeNamespaces(t *testing.T) { d := t.TempDir() input := filepath.Join(d, "combined.yaml") project := filepath.Join(d, "project.yaml") - manager := filepath.Join(d, "manager.yaml") + core := filepath.Join(d, "core.yaml") machine := filepath.Join(d, "runtime.yaml") raw := `swagger: "2.0" basePath: /v1 @@ -69,14 +69,14 @@ securityDefinitions: if err := os.WriteFile(input, []byte(raw), 0600); err != nil { t.Fatal(err) } - if err := run(input, project, manager, machine); err != nil { + if err := run(input, project, core, machine); err != nil { t.Fatal(err) } for _, check := range []struct{ path, base, own, other, definition, absent string }{ {project, "/v1", "/agents/sessions", "/core/v1/sandbox/nodes", "Session", "Node"}, {project, "/v1", "/agents/sessions", "/api/v1/sandbox-node/identity", "Session", "NodeIdentity"}, - {manager, "/", "/core/v1/sandbox/nodes", "/agents/sessions", "Node", "Session"}, - {manager, "/", "/core/v1/sandbox/nodes", "/api/v1/sandbox-node/identity", "Node", "NodeIdentity"}, + {core, "/", "/core/v1/sandbox/nodes", "/agents/sessions", "Node", "Session"}, + {core, "/", "/core/v1/sandbox/nodes", "/api/v1/sandbox-node/identity", "Node", "NodeIdentity"}, {machine, "/", "/api/v1/sandbox-node/identity", "/core/v1/sandbox/nodes", "NodeIdentity", "Node"}, {machine, "/", "/api/v1/sandbox-node/identity", "/agents/sessions", "NodeIdentity", "Session"}, } { @@ -99,7 +99,7 @@ securityDefinitions: } // Generated surfaces keep only the schemes their operations use; the project // document is left as generated. - for path, want := range map[string]string{project: "BearerAuth DeploymentAdminAuth NodeAuth", manager: "DeploymentAdminAuth", machine: "NodeAuth"} { + for path, want := range map[string]string{project: "BearerAuth DeploymentAdminAuth NodeAuth", core: "DeploymentAdminAuth", machine: "NodeAuth"} { raw, err := os.ReadFile(path) if err != nil { t.Fatal(err) From d828a740c8aa018a39e52736a1d89875963612ad Mon Sep 17 00:00:00 2001 From: SaladDay <1203511142@qq.com> Date: Fri, 25 Sep 2026 14:24:15 +0000 Subject: [PATCH 09/30] Test that each credential works only in its own namespace A table over the served composition shows a Project API key reaches only /v1, the Core key only /core/v1, and node and executor credentials only their own /api/v1 machine connection routes; every other cell is 401. --- .../store/credential_matrix_http_test.go | 147 ++++++++++++++++++ 1 file changed, 147 insertions(+) create mode 100644 services/agents-api/internal/store/credential_matrix_http_test.go diff --git a/services/agents-api/internal/store/credential_matrix_http_test.go b/services/agents-api/internal/store/credential_matrix_http_test.go new file mode 100644 index 000000000..71cdb4db1 --- /dev/null +++ b/services/agents-api/internal/store/credential_matrix_http_test.go @@ -0,0 +1,147 @@ +package store_test + +import ( + "context" + "encoding/json" + "net/http" + "net/http/httptest" + "strings" + "sync" + "testing" + + "github.com/MiniMax-AI-Dev/parsar/internal/agentdaemon/device" + "github.com/MiniMax-AI-Dev/parsar/internal/agentdaemon/gateway" + "github.com/MiniMax-AI-Dev/parsar/services/agents-api/internal/api" + "github.com/MiniMax-AI-Dev/parsar/services/agents-api/internal/execution" + "github.com/MiniMax-AI-Dev/parsar/services/agents-api/internal/runtimeenrollment" + "github.com/MiniMax-AI-Dev/parsar/services/agents-api/internal/sandbox" + "github.com/MiniMax-AI-Dev/parsar/services/agents-api/internal/store" + "github.com/google/uuid" +) + +// Each credential works only in its own namespace: a Project API key in /v1, +// the Core key in /core/v1, and node and executor credentials only on their +// own /api/v1 machine connection routes. +func TestCredentialNamespaceMatrix(t *testing.T) { + s, _ := store.NewManagedTestStore(t) + ctx := t.Context() + coreKey := uuid.NewString() + admin, err := api.NewDeploymentAuthenticator([]string{device.HashCredential(coreKey)}) + if err != nil { + t.Fatal(err) + } + auth, err := api.NewDatabaseAuthenticator(s) + if err != nil { + t.Fatal(err) + } + handler, err := api.NewHandler(s, auth, "codex", api.WithSandboxManager(s, admin), api.WithProjectAPIKeys(s, admin)) + if err != nil { + t.Fatal(err) + } + // The server composition: daemon transport beside the API handler. + mux := http.NewServeMux() + mux.Handle("/api/v1/agent-daemon/enroll", runtimeenrollment.EnrollmentHandler(s)) + mux.Handle("/api/v1/agent-daemon/connection", runtimeenrollment.ConnectionHandler(s, gateway.NewRegistry())) + mux.Handle("/", handler) + server := api.CanonicalPaths(mux) + call := func(method, path, token, body string) *httptest.ResponseRecorder { + t.Helper() + r := httptest.NewRequest(method, path, strings.NewReader(body)) + r.Header.Set("Authorization", "Bearer "+token) + r.Header.Set("OpenAI-Beta", "agents=v1") + r.Header.Set("Content-Type", "application/json") + w := httptest.NewRecorder() + server.ServeHTTP(w, r) + return w + } + created := func(method, path, token, body string, value any) { + t.Helper() + if w := call(method, path, token, body); w.Code != http.StatusCreated || json.Unmarshal(w.Body.Bytes(), value) != nil { + t.Fatalf("%s %s = %d %s", method, path, w.Code, w.Body) + } + } + + // A Project and its API key, issued with the Core key. + var project store.Project + created("POST", "/core/v1/projects", coreKey, `{"name":"Matrix"}`, &project) + var projectKey store.IssuedProjectAPIKey + created("POST", "/core/v1/projects/"+project.ID+"/keys", coreKey, `{"name":"application"}`, &projectKey) + + // An executor credential for a self_hosted Session of that Project. + binding, err := s.GetProject(ctx, project.ID) + if err != nil { + t.Fatal(err) + } + session, err := s.CreateSession(ctx, binding.Principal.TenantID, store.CreateSessionInput{Creator: binding.Principal.Subject(), Engine: "codex", IdempotencyKey: uuid.NewString(), + Configuration: json.RawMessage(`{"agent":{"model":"test"},"environment":{"type":"self_hosted","workspace_directory":"/workspace","capability_directories":[]}}`)}) + if err != nil { + t.Fatal(err) + } + environment, err := s.GetSessionEnvironment(ctx, binding.Principal.TenantID, session.ID) + if err != nil { + t.Fatal(err) + } + var executor store.IssuedExecutorCredential + created("POST", "/core/v1/projects/"+project.ID+"/environments/"+environment.ID+"/executor-credentials", coreKey, `{"key_id":"`+uuid.NewString()+`"}`, &executor) + + // A node credential: a Docker deployment, an enrollment token issued with the Core key, and an enrolled node. + installation := uuid.NewString() + provider := &lifecycleProvider{resources: map[string]sandbox.Info{}} + runtimes := execution.NewDeferredRuntimeProvider(installation, func(ctx context.Context) (*execution.RuntimeProvider, error) { + setup, err := s.GetSandboxSetup(ctx) + if err != nil || setup.Provider == "" { + return nil, err + } + return &execution.RuntimeProvider{InstallationID: setup.InstallationID, ProviderKind: setup.Provider, BackendFingerprint: setup.BackendFingerprint, CoreURL: setup.CoreURL + "/api/v1", Provider: provider}, nil + }, func(_ context.Context, setup store.SandboxSetup) (execution.PreparedRuntimeDeployment, error) { + return execution.PreparedRuntimeDeployment{Config: &execution.RuntimeProvider{InstallationID: setup.InstallationID, ProviderKind: setup.Provider, Mode: setup.Mode, Maintenance: setup.Maintenance, CoreURL: setup.CoreURL + "/api/v1", BackendFingerprint: setup.BackendFingerprint, Provider: provider}}, nil + }) + worker, err := execution.StartWorker(ctx, &execution.Dispatcher{Store: s, Registry: gateway.NewRegistry(), ManagedRuntimes: runtimes}) + if err != nil { + t.Fatal(err) + } + var stop sync.Once + t.Cleanup(func() { + stop.Do(func() { + cancelled, cancel := context.WithCancel(context.Background()) + cancel() + _ = worker.Run(cancelled) + }) + }) + specification := store.SandboxDeploymentTestSpec("docker") + if _, err := worker.InitializeSandboxDeployment(ctx, store.SandboxDeploymentSetupRequest{DeploymentSpec: specification, Provider: "docker", CoreURL: "https://core.example"}); err != nil { + t.Fatal(err) + } + var enrollment api.SandboxEnrollmentToken + created("POST", "/core/v1/sandbox/enrollment-tokens", coreKey, `{}`, &enrollment) + nodeID, nodeCredential := uuid.NewString(), strings.Repeat("n", 64) + enroll, _ := json.Marshal(store.RuntimeNodeEnrollment{NodeID: nodeID, Credential: nodeCredential, Name: "Matrix node", Provider: "docker", BackendFingerprint: strings.Repeat("b", 64), + DeploymentGeneration: 1, SpecificationDigest: specification.Digest("docker")}) + var node store.RuntimeNodeIdentity + created("POST", "/api/v1/sandbox-node/enroll", enrollment.Token, string(enroll), &node) + + routes := []struct{ name, method, path string }{ + {"/v1", "GET", "/v1/agents"}, + {"/core/v1", "GET", "/core/v1/projects"}, + {"/api/v1 node", "GET", "/api/v1/sandbox-node/identity?node_id=" + nodeID}, + {"/api/v1 executor", "GET", "/api/v1/agent-daemon/connection?environment_id=" + environment.ID}, + } + for _, credential := range []struct { + name, token, own string + }{ + {"Project API key", projectKey.Key, "/v1"}, + {"Core key", coreKey, "/core/v1"}, + {"node credential", nodeCredential, "/api/v1 node"}, + {"executor credential", executor.Token, "/api/v1 executor"}, + } { + for _, route := range routes { + want := http.StatusUnauthorized + if route.name == credential.own { + want = http.StatusOK + } + if w := call(route.method, route.path, credential.token, ""); w.Code != want { + t.Errorf("%s on %s = %d, want %d: %s", credential.name, route.name, w.Code, want, w.Body) + } + } + } +} From 2ed7318ef1db37ee03f8521e2b217f040d8b3c5a Mon Sep 17 00:00:00 2001 From: SaladDay <1203511142@qq.com> Date: Fri, 25 Sep 2026 14:32:43 +0000 Subject: [PATCH 10/30] Document the three namespaces and the Core-key executor credentials AGENTS.md is replaced with the agreed draft adjusted to the final facts. docs/api/README.md becomes the /v1, /core/v1 and /api/v1 table with a /core/v1 route-group index; CONTRIBUTING.md, design principles, the Web and public API guides and getting-started describe Web as a /core/v1-only client behind a prefix proxy. Contracts move from /core/v1/admin to /core/v1, the executor credential contract and self-hosted installation steps describe Core-key issuance by the operator, and the removed startup configuration and history capability reads are noted. --- AGENTS.md | 65 ++++++++++++---- CONTRIBUTING.md | 57 ++++++++------ contracts/agents-api/README.md | 21 +++-- contracts/agents-api/admin-api.md | 37 ++++++--- contracts/agents-api/core-metrics.md | 3 +- .../environment-executor-credentials.md | 78 ++++++++++++------- .../agents-api/execution-configuration.md | 18 ++--- contracts/agents-api/runtime-history-api.md | 60 ++++---------- .../agents-api/runtime-observability-api.md | 10 +-- contracts/agents-api/sandbox-deployment.md | 18 ++--- contracts/agents-api/startup-configuration.md | 41 ++-------- contracts/agents-api/write-audit.md | 7 +- docs/api/README.md | 69 +++++++++------- docs/api/public-agent-api.md | 16 ++-- docs/api/web-management.md | 40 +++++----- docs/design-principles.md | 24 ++++-- docs/getting-started/install.md | 31 ++++++-- docs/getting-started/operations.md | 17 ++-- docs/getting-started/quickstart.md | 4 +- services/agents-api/HOSTED-SANDBOX-MANAGER.md | 6 +- services/agents-api/README.md | 11 ++- services/agents-api/RELEASE.md | 4 + 22 files changed, 355 insertions(+), 282 deletions(-) diff --git a/AGENTS.md b/AGENTS.md index 8a417e636..a4f48b8e9 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -1,18 +1,55 @@ # Parsar Core development -Read [CONTRIBUTING.md](CONTRIBUTING.md) before changing code. Work in an isolated -Git worktree and submit a PR. Keep product business code in the Parsar repository. - -Preserve the pinned public Agent API and documented extensions. Do not add product -database dependencies or bypass Core execution ownership. Core Web must consume the administrator API through the `AdminClient` in -`packages/agents-client`; applications consume the public contract separately. -Neither client may reproduce Core execution truth or expose server-side -administrator credentials in the browser. See `docs/design-principles.md`. Update -architecture and generated contracts with changes. Run `make check` before -reporting completion. - -Choose independent blind review according to change risk; follow CONTRIBUTING.md -for reviewer context and re-review criteria. Small, verified fixes may use self-review. -Never use `codex exec` as a substitute reviewer. +Parsar Core is the independent execution service behind Parsar. Product business +code, its database and migrations stay in the Parsar repository. Read +[CONTRIBUTING.md](CONTRIBUTING.md) before changing code. + +## Interfaces and credentials + +Core serves three namespaces. Each has one kind of caller and its own credential; +no credential works in another namespace. + +| Namespace | Caller | Credential | Contents | +| --- | --- | --- | --- | +| `/v1` | Applications | Project API key | Exactly the pinned OpenAI Agents API route set ([upstream.json](contracts/agents-api/upstream.json): SDK 3.13.0, `agents=v1`). Fields the official types lack live only in `x_agents_core` on Agents and Sessions (`harness`, `model_provider`). Nothing about deployment belongs here. | +| `/core/v1` | Core Web's server and operator scripts | Core key; Core stores its digest | Everything about operating the deployment: Projects and keys, resource reads and deletion, Session archive, credential issuance (node enrollment tokens, executor credentials), observability, audit, sandbox deployment and nodes. | +| `/api/v1` | Nodes, Runtime daemons, self-hosted executors | Machine credentials issued through `/core/v1` | Machine connections only; each credential works only on its own routes. | + +- Core Web calls only `/core/v1`, through the Core clients in + `packages/agents-client` (`AdminClient`, `SandboxAdminClient`, + `CoreMetricsClient`). It never calls `/v1` or `/api/v1`, never uses + `OpenAIAgentsClient` and never forwards machine transport. +- Users sign in to Web with the Core key. Web's server forwards signed-in, + same-origin `/core/v1` requests with that key and never sends it to a browser. + The Core key cannot call `/v1`; a Project API key cannot call `/core/v1`. +- A new console need becomes a `/core/v1` route. Never add console or operations + features to `/v1`, or application features to `/core/v1`. +- List every route with its caller and credential in [docs/api/README.md](docs/api/README.md). + +## Ownership + +Backend: Core, `contracts/`, `packages/agents-client`, `services/core-console`, +the daemon, adapters, node service and installer. Frontend: `apps/web` and +`docs/web`. Agree on API shapes across the boundary; do not edit the other side's +code to make a test pass. + +## Making changes + +- Work in an isolated Git worktree on a feature branch and submit a PR. +- Run `make sqlc-generate` after query changes and `make openapi` after handler + annotation changes; review the generated diffs. `make openapi` writes + `openapi.yaml` (`/v1`), `core.openapi.yaml` (`/core/v1`) and + `runtime.openapi.yaml` (`/api/v1`) in `contracts/agents-api/`. Update the + matching contract there and the API index in the same change. +- Do not add product database dependencies, bypass Core execution ownership, or + reproduce execution truth in a client. +- Run `make check` before reporting completion. + +## Review + +Choose independent blind review by risk: security, shared lifecycle ownership or +cross-package behavior. Give the reviewer only requirements, acceptance criteria, +boundaries, the repository path and the comparison baseline. Small verified fixes +may use self-review. Never use `codex exec` as a reviewer. Documentation and code comments are English; user-facing copy may be bilingual. diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index df7f6756b..02fd5c2a7 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -28,21 +28,23 @@ fixes may use self-review, including focused corrections after a blind review; repeat independent review when a correction materially changes the design or risk. Fix in-scope blockers before delivery. Do not use `codex exec` as a substitute reviewer. -The [API documentation index](docs/api/README.md) separates application, -administrator and Runtime transport contracts. New or changed routes must identify -their caller and authentication authority there, and link their detailed contract. +The [API documentation index](docs/api/README.md) lists the three namespaces: +`/v1` for applications (Project API key), `/core/v1` for Core Web's server and +operator scripts (Core key) and `/api/v1` for machine connections (credentials +issued through `/core/v1`). New or changed routes must identify their caller and +credential there, and link their detailed contract. Keep current integration guidance separate from historical qualification evidence. -The Core Web is an administrator console. Its server authenticates to the explicit -`/core/v1/admin` management surface and existing sandbox administration, never to -`/v1` on behalf of a browser. Applications use an API key issued inside a Project. One Project owns one execution +The Core Web is an administrator console. Web calls only `/core/v1`, with the Core +key held on its server, and never `/v1` or `/api/v1`. Applications use an API key issued inside a Project. One Project owns one execution tenant and principal; all its keys share assets and permissions while writes retain individual key provenance. Projects and keys are database-owned, with no static business keys or configuration synchronization. Revocation affects one key; archiving a Project revokes all its keys, retaining assets and admitted execution. Do not add Core users, roles, memberships or cross-Project sharing. Management -provides safe reads, public deletion preconditions, explicit hosted Session archive -and Project and key operations; it cannot copy, execute or edit arbitrary assets. +provides safe reads, public deletion preconditions, explicit hosted Session archive, +Project and key operations and credential issuance; it cannot copy, execute or edit +arbitrary assets. Keep administrator target scope separate from caller principals. See [design principles](docs/design-principles.md) and the [administrator contract](contracts/agents-api/admin-api.md). @@ -156,9 +158,8 @@ trailing slash kept, and other escapes such as `%2F` and `%5C` left encoded; or authorize on a path outside that wrapper. On the Beta group the constant OpenAI-Beta check (exactly one `agents=v1` value) runs before authentication, and authentication still precedes every Beta handler, 404 and 405. -Every Agents API 401 has type `invalid_request_error`: null code on Beta routes; on Files, -Skills and Core project extensions `invalid_api_key` only for a rejected Bearer -credential. Agents API responses carry a fresh `X-Request-Id` (also in the log +Every Agents API 401 has type `invalid_request_error`: null code on Beta routes; on Files +and Skills `invalid_api_key` only for a rejected Bearer credential. Agents API responses carry a fresh `X-Request-Id` (also in the log context), `OpenAI-Version`, `OpenAI-Processing-Ms` and nosniff through the API router's own middleware, not the shared log middleware. HEAD runs GET routes; streaming, content-download, live directory, Runtime observation and Runtime @@ -207,7 +208,7 @@ current validation entrypoints. ## Core operational metrics -The administrator-only `/core/v1/admin/core-metrics` contract is documented in +The Core-key-only `/core/v1/metrics` contract is documented in [core-metrics.md](contracts/agents-api/core-metrics.md). Keep this separate from Agent outcome and Sandbox capacity views. Instrument existing worker and job owners without changing scheduling, lease or retention behavior. Periodic pool @@ -733,7 +734,7 @@ does not qualify its isolation or enable public creation. Default installation includes Core, Web and PostgreSQL but no execution node. It always creates a separate deployment administrator credential. Core receives only its digest; the paired console server receives the private token and injects -it only on approved management routes after console login and same-origin checks. +it only on `/core/v1` requests after console login and same-origin checks. The browser never receives that token. Node and daemon connections use `/api/v1` with their own credentials; the reverse proxy sends them directly to Core, never through Web. Zero-node Core receives neither the Docker socket nor KVM. @@ -1768,11 +1769,15 @@ image containing a private test CA or model credential as a release input. Repository visibility is independent of publication. Do not add repository credentials to installed node/Runtime configuration to bypass download access. -Project-authenticated executor-credential extensions remain outside the upstream -API namespace and reuse the existing restricted issuer. They require the exact -creator of a live self-hosted Environment; deployment administrator authority and -shared Session read access do not grant credential issuance. The console does not -serve these routes; callers reach Core directly. Self-hosted installation reuses +Executor credentials are issued by the operator with the Core key, through Web or +a Core-key script, under +`/core/v1/projects/{project_id}/environments/{environment_id}/executor-credentials` +and reuse the existing restricted issuer. The target must be a self_hosted +Environment of that Project whose Session exists; anything else is 404. The +Project's principal is the credential's execution principal, its scope stays +daemon enrollment and connection for that one Environment, and issue, rotate and +revoke each record an administrator audit entry in the write's transaction without +the secret. Project API keys cannot issue them. Self-hosted installation reuses Docker Runtime isolation, owns no sandbox node or Core allocation, and retains user-owned native history after uncertain launches. Report started, connected and real execution success separately. Self-hosted installation confirms connection through the private daemon transport @@ -1840,17 +1845,18 @@ existing write-only model execution extension, with the installation's persisten credential encryption key. Provider identity/backend namespace and native history must not change on a repeated install. -`services/core-console` serves the production Web build and proxies only finite -administrator and sandbox-management routes after console login. It requires a +`services/core-console` serves the production Web build and, after console login +and same-origin checks, forwards every `/core/v1` request with the Core key; Core +decides whether the route exists. It requires a private `CORE_CONSOLE_ADMIN_TOKEN_FILE` and holds no project caller credential. Every `/v1` and `/api/v1` request returns 404, including explicit Bearer and WebSocket requests; Web forwards no node or daemon transport. The installer mounts only the administrator key into Web and only its digest into Core. The browser receives safe capability flags, never that key. The deployment's TLS reverse proxy routes `/v1` (applications) and `/api/v1` (nodes and Runtime -daemons, with their own credentials) directly to Core and everything else to Web, -except the Project-authenticated `/core/v1/environments/*/executor-credentials` -routes, which also go straight to Core. +daemons, with their own credentials) directly to Core and everything else, +including `/core/v1`, to Web. Operator scripts call `/core/v1` on Core's loopback +port. Nodes and Core come from one distribution; older nodes using the removed `/core/v1/sandbox` node paths cannot connect and are replaced through the drained upgrade and re-enrollment workflow. @@ -1878,8 +1884,9 @@ The command verifies the installer checksum before execution, retains normal TLS verification, and passes the enrollment credential only to the installer process. -Management proxy paths retain fixed-origin, cross-site, safe-path, redirect and Upgrade -restrictions through the standard Go reverse proxy with streaming/cancellation. +The `/core/v1` proxy retains fixed-origin, cross-site, safe-path, redirect and Upgrade +restrictions through the standard Go reverse proxy with streaming/cancellation; +literal or encoded dot segments can never move a request out of `/core/v1`. The console implements no product identity, resource semantics, Runtime discovery or execution loop. Its local administrator account grants the complete console surface; do not introduce Web roles, invitations or per-project Web identities. diff --git a/contracts/agents-api/README.md b/contracts/agents-api/README.md index 6b872b910..709b6bb87 100644 --- a/contracts/agents-api/README.md +++ b/contracts/agents-api/README.md @@ -130,10 +130,10 @@ pins the exact `/v1` route set, and `/v1` objects carry Core fields only inside | Extension | Operations | Current coverage | | --- | --- | --- | -| Administration | `/core/v1/admin/**` | Deployment-authenticated Projects with shared keys, read/delete projections, summary, runtime observations and audit. Separate from caller authority; see [Administrator API](admin-api.md). | -| API-key provenance | Project-scoped `/core/v1/admin/projects/{project_id}/resource-owners` and `/write-operations` | Batch ownership and retained cursor-paginated write history; see [write provenance](write-audit.md). | -| Runtime observations | `GET /core/v1/admin/runtime-observations`; `GET /core/v1/admin/projects/{project_id}/sessions/{session_id}/runtime-observation` | Administrator only; the `/v1` forms are removed. Current, read-only Session contexts labelled by Project, with stable Session-keyset pagination, bounded concurrent sampling, Docker and microsandbox metrics, explicit unsupported/unavailable states, strict `AdminClient` projection, and no lifecycle mutation. Kubernetes, E2B, self-hosted telemetry, and automatic idle policy remain unimplemented. See [Runtime observation API](runtime-observability-api.md). | -| Runtime history | `GET /core/v1/admin/runtime-history/capabilities`; `GET /core/v1/admin/projects/{project_id}/sessions/{session_id}/runtime-history` | Administrator only; the `/v1` forms are removed. Optional backend-neutral capability and bounded Project/Session-scoped history contract with allocation/incarnation fencing, explicit coverage and strict client projection. Disabled by default until a production Reader and qualified periodic collection are configured; Durable Web rendering remains pending. See [Runtime history API](runtime-history-api.md). | +| Administration | `/core/v1/**` | Core-key Projects with shared keys, read/delete projections, executor credentials, summary, metrics, runtime observations, audit and sandbox deployment. Separate from caller authority; see [Administrator API](admin-api.md). | +| API-key provenance | Project-scoped `/core/v1/projects/{project_id}/resource-owners` and `/write-operations` | Batch ownership and retained cursor-paginated write history; see [write provenance](write-audit.md). | +| Runtime observations | `GET /core/v1/sandbox/runtime-observations`; `GET /core/v1/projects/{project_id}/sessions/{session_id}/runtime-observation` | Administrator only; the `/v1` forms are removed. Current, read-only Session contexts labelled by Project, with stable Session-keyset pagination, bounded concurrent sampling, Docker and microsandbox metrics, explicit unsupported/unavailable states, strict `AdminClient` projection, and no lifecycle mutation. Kubernetes, E2B, self-hosted telemetry, and automatic idle policy remain unimplemented. See [Runtime observation API](runtime-observability-api.md). | +| Runtime history | `GET /core/v1/projects/{project_id}/sessions/{session_id}/runtime-history` | Administrator only; the `/v1` forms and the capability read are removed. Optional backend-neutral bounded Project/Session-scoped history contract with allocation/incarnation fencing, explicit coverage and strict client projection. Disabled by default until a production Reader and qualified periodic collection are configured; Durable Web rendering remains pending. See [Runtime history API](runtime-history-api.md). | For each resource, verify the referenced request/response unions and observable behavior, not just the route. Non-text initial input, configuration @@ -897,9 +897,8 @@ Project share the same scope and Session creator. Deployment configuration defin no Projects or business keys. Administrator credentials cannot authenticate `/v1`. Optional official organization/project headers must match the key's authorized scope; ambiguous or conflicting headers use the existing 401 response. Every Agents API 401 has type `invalid_request_error`, as observed -officially; Beta routes report a null code, while Files, Skills and Core project -extensions report `invalid_api_key` for a rejected Bearer credential and a null -code without one. This scope-header policy is an implementation choice, not +officially; Beta routes report a null code, while Files and Skills report +`invalid_api_key` for a rejected Bearer credential and a null code without one. This scope-header policy is an implementation choice, not verified hosted error parity. Project resource access remains shared within the authorized project. New Sessions persist immutable creator kind/ID from the authenticated principal; ordinary and streaming creation retries require the @@ -917,14 +916,12 @@ establish complete ownership, hosted key lifecycle or error compatibility. See t Core documents its optional [harness selection extension](harness-selection.md) separately from the pinned upstream contract. -The administrator [Core startup configuration](startup-configuration.md) read -exposes only safe build support and process configuration facts. It does not report Runtime, -Session or Environment observations and is not a readiness endpoint. +The former [Core startup configuration](startup-configuration.md) read is removed. Model endpoints and credentials may be supplied at Session creation through the [write-only execution extension](model-execution.md). Provider catalogs and their business permissions remain client/product responsibilities. API-key ownership and write history are documented in -[write-audit.md](write-audit.md). These are deployment-authenticated Core console -extensions; the pinned public `/v1` schema is unchanged. +[write-audit.md](write-audit.md). These are Core-key `/core/v1` reads; the pinned +public `/v1` schema is unchanged. diff --git a/contracts/agents-api/admin-api.md b/contracts/agents-api/admin-api.md index 689830acc..47f6aa8e3 100644 --- a/contracts/agents-api/admin-api.md +++ b/contracts/agents-api/admin-api.md @@ -1,9 +1,12 @@ # Administrator API -This Core extension uses deployment Bearer authentication under `/core/v1/admin`. -It does not change `/v1`, the fixed Python SDK, or native Runtime interfaces. -API keys cannot authenticate these routes; the administrator credential cannot -authenticate `/v1`. The console server supplies `X-Core-Console-Actor` from its +These routes live under `/core/v1` and are called by Core Web's server and +operator scripts. Every `/core/v1` route requires the Core key as a Bearer +credential; an unknown `/core/v1` path returns 404 only after authentication. +They do not change `/v1`, the fixed Python SDK, or native Runtime interfaces. +Project API keys and machine credentials cannot authenticate these routes; the +Core key cannot authenticate `/v1` or `/api/v1`. Paths below are relative to +`/core/v1`. The console server supplies `X-Core-Console-Actor` from its signed-in account. Core records it only as an unverified display label. ## Projects and keys @@ -11,8 +14,8 @@ signed-in account. Core records it only as an unverified display label. A Project owns one tenant and shared principal. Its keys have equal access to all its assets. Projects and keys are database-owned; deployment configuration defines neither. There are no API users, roles or configuration-managed business keys. -Core requires a separate deployment administrator credential at startup for -bootstrap and management. +Core requires the Core key, configured separately from Project keys, at startup +for bootstrap and management. | Operation | Path | Result | | --- | --- | --- | @@ -23,8 +26,13 @@ bootstrap and management. | List keys | `GET /projects/{project_id}/keys` | `{data, has_more}` | | Issue key | `POST /projects/{project_id}/keys` with `{name}` | Key metadata and one-time `key`; HTTP 201 | | Revoke key | `DELETE /projects/{project_id}/keys/{key_id}` | `{id, deleted: true}` | +| List executor credentials | `GET /projects/{project_id}/environments/{environment_id}/executor-credentials` | `{data}` metadata only | +| Issue or rotate executor credential | `POST /projects/{project_id}/environments/{environment_id}/executor-credentials` with `{key_id, rotate}` | One-time credential; HTTP 201 | +| Revoke executor credential | `DELETE /projects/{project_id}/environments/{environment_id}/executor-credentials/{key_id}` | HTTP 204 | -IDs are server-generated UUIDs. Project metadata contains `id`, `name`, +Executor credentials apply only to a `self_hosted` Environment of the Project +whose Session exists; see [executor credentials](environment-executor-credentials.md). +Project and key IDs are server-generated UUIDs. Project metadata contains `id`, `name`, `created_at`, nullable `archived_at`, and `active_key_count`. Key metadata contains `id`, `project_id`, `name`, `prefix`, `created_at`, and nullable `revoked_at`. Project names contain 1–128 characters; key names contain 1–80. Names are display @@ -73,7 +81,7 @@ reject HEAD with 405, so HEAD never samples a provider or queries telemetry. `POST /projects/{project_id}/sessions/{session_id}/archive` takes `{"expected_generation": N}`, where N is a positive uint64 from the current -sandbox deployment. It requires deployment administrator authentication, a +sandbox deployment. It requires the Core key, a Web-managed deployment in maintenance and the current generation. Missing maintenance or a stale generation returns 409. Only Core-managed `openai_hosted` Sessions are eligible; other environment types return 400. A @@ -130,7 +138,7 @@ Null public Session usage contributes no tokens but counts in the coverage denominator. Each Project is read from one database snapshot; the page is not a simultaneous deployment-wide snapshot. Totals are not billing records. -`GET /runtime-observations` uses existing Session creation-order pagination and +`GET /sandbox/runtime-observations` uses existing Session creation-order pagination and returns `{object:"list", data:[{project_id, observation}], has_more, first_id, last_id}`. It reuses the bounded read-only Runtime sampler and never provisions compute; a provider with a batch metrics read (E2B) samples the page in one bounded request. @@ -142,6 +150,9 @@ observation read keeps the shape without `disk`. The per-Session execution configuration, Runtime observation and Runtime history exist only here; `/v1` has no equivalents. +`GET /metrics?range=1h|6h|24h|7d` returns Core's own process metrics; see +[Core metrics](core-metrics.md). + `GET /audit-log` lists administrator writes newest first with `project_id`, `resource_type`, `resource_id`, `action`, inclusive `created_after`, exclusive `created_before`, `limit=1..100` (default 50), and opaque `after` filters. Response @@ -149,19 +160,21 @@ is `{data, has_more, next_cursor}`. Each row has `id`, `created_at`, `admin_credential_id` (credential digest prefix), `actor_label`, `action`, `project_id`, `resource_type`, `resource_id`, `result_ids`, `request_id`, `trace_id`. `result_ids` is an empty array except on historical `copy` entries. +Executor credential writes appear with `resource_type:"executor_credential"`, +the key ID as `resource_id` and action `issue`, `rotate` or `revoke`. No credential values or request bodies are recorded. Logs and historical copy ownership do not cascade away on resource removal or key revocation. ## Private installation transition The old configured business keys, inherited-binding issuer and key-space -management routes are removed. Deployment administrator credentials remain -separately configured. The +management routes are removed. The Core key remains separately configured. The migration refuses an installation containing old issued key records instead of silently changing their ownership or deleting data. Use a clean private deployment, or explicitly retire old key records after preserving the assets and evidence you need. There is no automatic data migration or historical ownership backfill. -The typed `AdminClient` in `packages/agents-client` uses this management surface. +The typed `AdminClient`, `SandboxAdminClient` and `CoreMetricsClient` in +`packages/agents-client` use `/core/v1`. Public SDK applications continue to use the existing Agents API client and their own API key. See [design principles](../../docs/design-principles.md). diff --git a/contracts/agents-api/core-metrics.md b/contracts/agents-api/core-metrics.md index 756635267..b8410503a 100644 --- a/contracts/agents-api/core-metrics.md +++ b/contracts/agents-api/core-metrics.md @@ -1,7 +1,6 @@ # Core operational metrics -`GET /core/v1/admin/core-metrics?range=1h|6h|24h|7d` is a deployment-administrator -read. It implements the response shape agreed with Core Web PR #96 +`GET /core/v1/metrics?range=1h|6h|24h|7d` is a Core-key read. It implements the response shape agreed with Core Web PR #96 (`53dc9d646bc6e3cc2cd9b8bbb353bc53c3513ecf`). It changes neither public `/v1` resources nor Agent or Sandbox metrics. Project API keys cannot call it. diff --git a/contracts/agents-api/environment-executor-credentials.md b/contracts/agents-api/environment-executor-credentials.md index e58a4572f..44d30bb25 100644 --- a/contracts/agents-api/environment-executor-credentials.md +++ b/contracts/agents-api/environment-executor-credentials.md @@ -1,19 +1,34 @@ # Environment executor credentials -These Core extensions are outside the pinned upstream Agents API. They use the -same project caller authentication as Session creation, without `OpenAI-Beta`. -They require the exact authenticated creator of a live `self_hosted` Session; -project-shared Session read access does not grant issuance authority. +An executor credential lets one self-hosted executor host enroll its daemon and +connect for one `self_hosted` Environment. The application creates the +`self_hosted` Session with its Project API key; the operator then issues the +credential with the Core key, through Web or a Core-key script, and gives the +returned credential file to the executor host. Project API keys cannot issue +credentials; the former Project-key route +`/core/v1/environments/{environment_id}/executor-credentials` is removed. -`POST /core/v1/environments/{environment_id}/executor-credentials` accepts: +## Routes -```json -{"key_id":"CLIENT_GENERATED_UUID","rotate":false} -``` +All routes are under +`/core/v1/projects/{project_id}/environments/{environment_id}/executor-credentials` +and require the Core key. They apply only to a `self_hosted` Environment of that +Project whose Session exists (is not deleted); any other Project, Environment +type, missing Environment or deleted Session returns 404. + +| Operation | Request | Result | +| --- | --- | --- | +| List | `GET …/executor-credentials` | `{"data":[{"key_id","created_at","revoked_at"}]}` | +| Issue or rotate | `POST …/executor-credentials` with `{"key_id":"UUID","rotate":false}` | 201 credential file, returned once | +| Revoke | `DELETE …/executor-credentials/{key_id}` | 204 | + +The list holds metadata only, oldest first, for the credentials restricted to this +Environment; `revoked_at` is null while a credential is active. It never contains +a secret. `key_id` is a canonical nonzero UUID chosen and retained before the request. -`rotate` is optional and defaults to false. The 201 response is the existing -daemon credential-file format: +`rotate` is optional and defaults to false. The 201 response is the daemon +credential-file format: ```json {"key_id":"UUID","environment_id":"ENVIRONMENT_UUID","executor_token":"ONE_TIME_SECRET"} @@ -21,24 +36,31 @@ daemon credential-file format: Responses use `Cache-Control: no-store`. Save the response directly to an owned mode-0600 file; never place it in shell arguments, logs, a workspace or source. -Only its digest is persisted in Core. The secret authorizes daemon enrollment -and connection for this exact Environment, never public Session API calls, -sandbox-node enrollment or project resource operations. The application caller -key stays on the application machine and is never given to the Runtime. - -Ordinary issuance with an existing `key_id` returns 409, even after revocation. -After an uncertain issuance response, explicitly send the same `key_id` with -`rotate:true` to replace the secret; do not automatically retry a rotation or -choose another key. Rotation preserves principal and Environment restriction, -invalidates the previous secret, and can restore an explicitly revoked key. -Credentials created without an exact Environment restriction by the operator -CLI cannot be managed through these routes. - -`DELETE /core/v1/environments/{environment_id}/executor-credentials/{key_id}` -returns 204 and revokes the exact caller-owned restricted key. Repeated -revocation is safe. Other principals, targets and deleted Sessions return 404. -Revocation denies further authorized connection and dispatch; it does not stop -user-owned compute or prove that an existing process has stopped. +Only its digest is persisted in Core. The Project's principal is the credential's +execution principal. The secret authorizes daemon enrollment and connection +(`/api/v1/agent-daemon/*`) for this exact Environment only, never `/v1`, `/core/v1`, +sandbox-node enrollment or project resource operations. + +The only write conflict is 409 `executor_credential_exists`: an issuance whose +`key_id` already exists and does not set `rotate:true`, even after revocation. +Rotation replaces the secret of an existing key restricted to this Environment, +keeps that Environment, invalidates the previous secret and restores a revoked +key; rotating an unknown `key_id` returns 404. Revocation is idempotent and +returns 204 each time. It denies further enrollment and connection; it does not +stop executor-owned compute or prove that an existing process has stopped. + +After an uncertain result, such as a timeout, do not retry automatically. List +the credentials, then either rotate the same `key_id` (it was issued but its +secret was lost) or issue it again (it was not issued). + +Issue, rotate and revoke each record an administrator audit entry +(`resource_type:"executor_credential"`, the key ID as `resource_id`, action +`issue`, `rotate` or `revoke`) in the same transaction as the write. The audit +never contains the secret. Credentials issued by the operator CLI +(`agents-api-environment-key`) without an Environment restriction cannot be +managed through these routes. + +## Executor host The distribution's `self-hosted-install.pyz` downloads the matching `parsar-runtime` launcher, Runtime image and seccomp profile. The local launcher diff --git a/contracts/agents-api/execution-configuration.md b/contracts/agents-api/execution-configuration.md index 52056368c..df5f9b0db 100644 --- a/contracts/agents-api/execution-configuration.md +++ b/contracts/agents-api/execution-configuration.md @@ -1,7 +1,7 @@ # Execution configuration queries -These read-only administrator reads describe configuration, not execution health. -They require the deployment administrator credential. They never contact a model +This read-only administrator read describes configuration, not execution health. +It requires the Core key. They never contact a model provider, start a Turn or wake a sandbox. The former project route `GET /v1/agents/sessions/{session_id}/execution-configuration` is removed. A Session read includes `agent.x_agents_core.harness` only when its Agent selected a @@ -10,7 +10,7 @@ Agent shape, and no read returns the provider selection. ## Frozen Session selections -`GET /core/v1/admin/projects/{project_id}/sessions/{session_id}/execution-configuration` +`GET /core/v1/projects/{project_id}/sessions/{session_id}/execution-configuration` returns: ```json @@ -64,15 +64,13 @@ secret references, native headers, query parameters and permissions are excluded ## Discovery boundary -Provider configuration discovery is not exposed. The Core startup-configuration -read retains its basic supported/configured deployment snapshot and accepts -no query parameters, including the retired `include=configuration_capabilities`. -Provider inputs are still validated against internal adapter-owned rules and Core +Provider configuration discovery is not exposed, and the former Core startup +configuration read is removed. Provider inputs are still validated against internal adapter-owned rules and Core admission policy. Removing discovery does not change the supported inputs or create/update/execute behavior. -This Session query and the startup view are administrator reads, not OpenAI Agents -API operations. Ordinary Agent and Session operations retain their pinned upstream -contracts. Neither query mutates configuration, rotates keys, migrates Sessions, +This Session query is an administrator read, not an OpenAI Agents API +operation. Ordinary Agent and Session operations retain their pinned upstream +contracts. The query never mutates configuration, rotates keys, migrates Sessions, exposes a model catalog or accepts arbitrary native options. See [model-execution.md](model-execution.md) for write and inheritance semantics. diff --git a/contracts/agents-api/runtime-history-api.md b/contracts/agents-api/runtime-history-api.md index 0afb63d35..5ee610c46 100644 --- a/contracts/agents-api/runtime-history-api.md +++ b/contracts/agents-api/runtime-history-api.md @@ -3,55 +3,29 @@ Status: public contract, strict TypeScript client, PostgreSQL history and Core Web History ranges implemented. Core uses its existing database; the execution owner samples every 30 seconds by default. API-only processes without an execution worker -advertise on-read collection rather than claiming periodic coverage. +collect only on read; their Session history read returns +`503 runtime_history_unavailable` rather than claiming Durable coverage. -These are read-only, backend-neutral administrator reads under `/core/v1/admin`, -authenticated by the deployment administrator credential. The former project routes +This is a read-only, backend-neutral administrator read under `/core/v1`, +authenticated by the Core key. The former project routes `GET /v1/agents/runtime-history/capabilities` and -`GET /v1/agents/sessions/{session_id}/runtime-history` are removed. The browser -never receives a storage endpoint, OTLP credential, provider-native identity or -tenant selector. - -## Capability discovery - -```http -GET /core/v1/admin/runtime-history/capabilities -Authorization: Bearer ... -``` - -The route accepts no query parameters. An unconfigured Core returns: - -```json -{ - "object": "agent.runtime_history_capabilities", - "available": false, - "reason": "not_configured", - "collection_mode": null, - "sample_interval_seconds": null, - "retention_seconds": null, - "minimum_step_seconds": null, - "maximum_range_seconds": null, - "maximum_points": null, - "metrics": [] -} -``` - -`available=true` requires `collection_mode=periodic`, a qualified positive sample -interval, a validated Reader, retention and query bounds, and at least one of -`cpu`, `memory`, or `tokens`. A Reader backed only by request-triggered samples returns -`reason=periodic_collection_required`; Web must not call that data Durable. -Malformed capability configuration fails closed as unconfigured. Backend names, -URLs, credentials, table names, and tenant data are never capability fields. +`GET /v1/agents/sessions/{session_id}/runtime-history`, and the administrator +capability read, are removed. History is available only with a validated Reader, +retention and query bounds and qualified periodic collection; otherwise, or when +that configuration is malformed, the Session read fails closed with 503. Backend +names, URLs, credentials, table names and tenant data are never returned. The +browser never receives a storage endpoint, OTLP credential, provider-native +identity or tenant selector. ## Session history ```http -GET /core/v1/admin/projects/{project_id}/sessions/{session_id}/runtime-history?start=1789951200&end=1789954800&max_points=120 +GET /core/v1/projects/{project_id}/sessions/{session_id}/runtime-history?start=1789951200&end=1789954800&max_points=120 Authorization: Bearer ... ``` `start` is inclusive and `end` is exclusive, both in whole Unix seconds. -`max_points` defaults to the lower of 120 and the advertised maximum. Core +`max_points` defaults to the lower of 120 and the configured maximum. Core selects an effective whole-second resolution. Unknown parameters, duplicate parameters, negative timestamps, invalid ranges, and invalid point limits are rejected before a Reader query. @@ -128,8 +102,7 @@ are measured model usage, not price, cost, or billing records. Core Web queries each current managed Session through the administrator Session route with bounded concurrency and an all-or-nothing target budget, offering 1h, -6h, and 24h History ranges. Capability discovery exists only on the application -route above; the administrator API has none. Reloading Web reconstructs the +6h, and 24h History ranges. Reloading Web reconstructs the charts from the backend; Live browser samples and Durable buckets remain explicit separate sources and are never silently merged. @@ -138,7 +111,7 @@ separate sources and are never silently merged. | HTTP | Code | Meaning | | --- | --- | --- | | 400 | `unsupported_parameter` or `invalid_request` | Invalid query shape or range. | -| 401 | `invalid_admin_key` | Missing or invalid administrator credential. | +| 401 | `invalid_admin_key` | Missing or invalid Core key. | | 404 | `not_found` | Missing Project, or a Session outside it. | | 409 | `runtime_history_unsupported` | The Session has no supported managed Runtime history scope. | | 503 | `runtime_history_unavailable` | Durable history is unconfigured, timed out, unavailable, or returned malformed data. | @@ -154,7 +127,6 @@ inside the half-open request range, and contain valid safe JSON values. ```ts class AdminClient { - getRuntimeHistoryCapabilities(options?: ReadOptions): Promise; retrieveRuntimeHistory(projectId: string, sessionId: string, query: { start: number; end: number; @@ -164,7 +136,7 @@ class AdminClient { } ``` -The client validates exact fields, capability consistency, requested-range echo, +The client validates exact fields, requested-range echo, Session identity, half-open bucket ordering, coverage totals, allocation identity, contributor counts, token usage ordering, nullability, finite numbers, and response size. Unknown fields or malformed data reject the entire response with a 502 `invalid_admin_response` error. diff --git a/contracts/agents-api/runtime-observability-api.md b/contracts/agents-api/runtime-observability-api.md index 6c9bbd840..b7b655ffe 100644 --- a/contracts/agents-api/runtime-observability-api.md +++ b/contracts/agents-api/runtime-observability-api.md @@ -7,8 +7,8 @@ identity joins. Durable history uses the separate optional [Runtime history API](runtime-history-api.md); lifecycle controls remain outside this phase. -These are administrator reads under `/core/v1/admin`, authenticated by the -deployment administrator credential, not upstream OpenAI Agents resources. The +These are administrator reads under `/core/v1`, authenticated by the Core key, +not upstream OpenAI Agents resources. The former project routes `GET /v1/agents/runtime-observations` and `GET /v1/agents/sessions/{session_id}/runtime-observation` are removed. @@ -17,7 +17,7 @@ former project routes `GET /v1/agents/runtime-observations` and ### List current Runtime observations ```http -GET /core/v1/admin/runtime-observations?after={session_id}&limit=20&order=desc +GET /core/v1/sandbox/runtime-observations?after={session_id}&limit=20&order=desc Authorization: Bearer ... ``` @@ -84,7 +84,7 @@ before publishing a new Dashboard snapshot. ### Retrieve one Session's current Runtime observation ```http -GET /core/v1/admin/projects/{project_id}/sessions/{session_id}/runtime-observation +GET /core/v1/projects/{project_id}/sessions/{session_id}/runtime-observation Authorization: Bearer ... ``` @@ -189,7 +189,7 @@ Use the existing Agents API error envelope. | --- | --- | --- | | 400 | `invalid_request_error` / `invalid_request_error` | List: a repeated supported query key, or an empty or invalid limit or order, with the shared Beta list messages. Unknown list query keys are ignored. | | 400 | `invalid_request_error` / `unsupported_parameter` | Single-Session retrieval with any query parameter. | -| 401 | `invalid_request_error` / `invalid_admin_key` | Missing or invalid administrator credential. | +| 401 | `invalid_request_error` / `invalid_admin_key` | Missing or invalid Core key. | | 404 | `not_found_error` / `not_found_error` | Missing, malformed or foreign Session/cursor, indistinguishably, as for the [Session list cursor](list-query-semantics.md#list-cursor-errors--september-23-2026). | | 500 | `server_error` / `internal_error` | Integrity, ownership, or invalid provider evidence. | | 503 | `server_error` / `execution_unavailable` | Required Runtime observation service is not configured, or list collection exceeded its request budget. | diff --git a/contracts/agents-api/sandbox-deployment.md b/contracts/agents-api/sandbox-deployment.md index aaf4131a3..d5da585aa 100644 --- a/contracts/agents-api/sandbox-deployment.md +++ b/contracts/agents-api/sandbox-deployment.md @@ -19,18 +19,18 @@ for the operator workflow. Generated schemas cover the | Method and route | Authority | Effect | | --- | --- | --- | -| `GET /core/v1/sandbox/deployment` | Deployment administrator | Read the safe active configuration and retained-resource counts | -| `POST /core/v1/sandbox/deployment` | Deployment administrator | Select the initial provider, resources, Runtime and Core origin | -| `PUT /core/v1/sandbox/deployment` | Deployment administrator | Replace a fully drained selection while maintenance is enabled | -| `PATCH /core/v1/sandbox/deployment/maintenance` | Deployment administrator | Pause or resume fresh hosted admission at the expected generation | +| `GET /core/v1/sandbox/deployment` | Core key | Read the safe active configuration and retained-resource counts | +| `POST /core/v1/sandbox/deployment` | Core key | Select the initial provider, resources, Runtime and Core origin | +| `PUT /core/v1/sandbox/deployment` | Core key | Replace a fully drained selection while maintenance is enabled | +| `PATCH /core/v1/sandbox/deployment/maintenance` | Core key | Pause or resume fresh hosted admission at the expected generation | | `GET /api/v1/sandbox-node/configuration` | Enrollment token or retained node credential | Read the active node installation configuration without consuming enrollment | -The paired console injects its administrator Bearer credential server-side for -management routes. The browser never receives that credential. Node configuration +The paired console injects the Core key server-side on every signed-in `/core/v1` +request. The browser never receives that key. Node configuration and enrollment are machine connection routes under `/api/v1`, which the reverse proxy sends directly to Core; the console does not serve them. They use their own -Bearer credential; a console login, the administrator credential or a Project key -does not grant node enrollment authority. +Bearer credential; a console login, the Core key or a Project key does not grant +node enrollment authority. Node capacity is separate from the deployment specification. The administrator's `POST /core/v1/sandbox/enrollment-tokens` accepts optional `max_active` and @@ -182,7 +182,7 @@ For a replacement: Resume requires the committed generation to be active. To release a retained hosted Session, explicitly POST -`/core/v1/admin/projects/{project_id}/sessions/{session_id}/archive` with +`/core/v1/projects/{project_id}/sessions/{session_id}/archive` with `{"expected_generation": N}` while maintenance is enabled. Poll GET on the same path until its resource state is `released`, then recheck the deployment counts. The [administrator archive contract](admin-api.md#administrative-session-archive) diff --git a/contracts/agents-api/startup-configuration.md b/contracts/agents-api/startup-configuration.md index 2418b36c1..2259310f7 100644 --- a/contracts/agents-api/startup-configuration.md +++ b/contracts/agents-api/startup-configuration.md @@ -1,33 +1,8 @@ -# Core startup configuration - -`GET /core/v1/admin/startup-configuration` returns a read-only snapshot of safe -configuration facts established when the Core process starts. It is an -administrator read outside the pinned upstream Agents API. The former project route -`GET /v1/agents/core/startup-configuration` is removed; `/v1` serves only the -pinned upstream routes. - -The response separates: - -- `supported`: harness and managed sandbox provider kinds compiled into this Core - build; and -- `configured`: the default and enabled harnesses, daemon gateway/self-hosted - composition, selected managed provider and maintenance state, plus whether an - operator-supplied model endpoint is present for each enabled harness. - -Model endpoint reporting is boolean. Core never returns the URL, credentials, -headers, query parameters, raw execution options or their file path. Managed -sandbox output is limited to `docker`, `microsandbox` or no selected provider; it -does not expose installation IDs, socket/runtime paths, image references or -provider-native identifiers. - -This snapshot never reads or aggregates daemon heartbeats, Runtime registrations, -Sessions, Environments or allocations. `configured` does not mean reachable, -authenticated, ready, isolated or successfully executed. Those observations belong -to the scoped Session or Environment. A Session may also supply the separate -[write-only model execution extension](model-execution.md); that private input is -not reflected in this process-level endpoint. - -The response has a fixed `schema_version`. Clients should reject unknown or -incomplete shapes rather than infer configuration. The endpoint rejects query -parameters, returns `Cache-Control: no-store`, and requires the deployment -administrator credential. +# Core startup configuration (removed) + +The startup configuration read is removed, with no replacement route. Neither +`GET /core/v1/admin/startup-configuration` nor the earlier +`GET /v1/agents/core/startup-configuration` exists; `/v1` serves only the pinned +upstream routes and `/core/v1` has no equivalent. A Session's committed harness +and model selection remain available through the administrator +[execution configuration](execution-configuration.md) read. diff --git a/contracts/agents-api/write-audit.md b/contracts/agents-api/write-audit.md index 3221210cf..aa29a04e0 100644 --- a/contracts/agents-api/write-audit.md +++ b/contracts/agents-api/write-audit.md @@ -70,14 +70,13 @@ a separate public write. Public resource IDs retain their existing formats. ## Console queries -Both endpoints require deployment Bearer authentication under -`/core/v1/admin/projects/{project_id}`. The path identifies the Project, including an +Both endpoints require the Core key under `/core/v1/projects/{project_id}`. The path identifies the Project, including an archived Project; it does not authenticate. API keys cannot call these routes. ### Batch ownership -`GET /core/v1/admin/projects/{project_id}/resource-owners?resource_type=agent&resource_ids=id1,id2` +`GET /core/v1/projects/{project_id}/resource-owners?resource_type=agent&resource_ids=id1,id2` `resource_type` is one of `agent`, `session`, `environment`, `environment_template`, `skill`, `skill_version`, `file`, `vault`, `credential`, @@ -96,7 +95,7 @@ had a public API-key write. See [historical copy provenance](admin-api.md#histor ### Operations -`GET /core/v1/admin/projects/{project_id}/write-operations?limit=50` +`GET /core/v1/projects/{project_id}/write-operations?limit=50` Optional filters: `key_id`, `resource_type`, `resource_id`, `created_after` (inclusive RFC3339 timestamp), `created_before` (exclusive RFC3339 timestamp). diff --git a/docs/api/README.md b/docs/api/README.md index d7ce7a7c0..6fa90180e 100644 --- a/docs/api/README.md +++ b/docs/api/README.md @@ -1,28 +1,46 @@ # API documentation -Choose the API by its caller and authority. `/v1` has exactly the pinned official -routes; its only Core additions are the `x_agents_core` fields described in the -[public API](public-agent-api.md#core-extensions). - -| Surface | Caller and credential | Entry point | Reference | -| --- | --- | --- | --- | -| Public Agents API | Applications; a database-issued Project API key | Direct Core `/v1` | [Public API](public-agent-api.md) | -| Self-hosted Runtime credential issuance | Applications; the Project API key (until it moves under `/core/v1/projects`) | Core `/core/v1/environments/{environment_id}/executor-credentials` | [Executor credentials](../../contracts/agents-api/environment-executor-credentials.md) | -| Administrator resources | Web's server or administrative automation; deployment credential | Core `/core/v1/admin` | [Management contract](../../contracts/agents-api/admin-api.md) | -| Hosted sandbox administration | Web's server or administrative automation; deployment credential | Core `/core/v1/sandbox` management routes | [Web API](web-management.md#sandbox-administration) | -| Console authentication | Browser; local console sign-in and session cookie | Console `/console/auth` | [Web API](web-management.md#browser-to-console) | -| Node and daemon transport | Installed node/Runtime; its own enrollment or connection credential | Direct Core `/api/v1`: `/api/v1/sandbox-node/*` and `/api/v1/agent-daemon/*`, including WebSockets | [Runtime credentials](../../contracts/agents-api/environment-executor-credentials.md), [node operations](../../services/agents-api/HOSTED-SANDBOX-MANAGER.md#register-a-host), [machine OpenAPI](../../contracts/agents-api/runtime.openapi.yaml) | - -Projects own assets. Multiple equally privileged keys share the Project's assets -and execution principal; provenance retains the key that performed each write. -Projects and application keys live in PostgreSQL, never deployment configuration. -Administrator credentials cannot authenticate public Agent API operations, and -Project keys cannot authenticate administrator operations. - -The console server stores its deployment credential privately and proxies only -allowlisted management operations. Browsers do not receive it. Applications and -machines call Core directly; the console returns 404 for `/v1` and `/api/v1`, even -when given an application key or machine credential. +Core serves three namespaces. Each has one kind of caller and its own credential; +no credential works in another namespace. + +| Namespace | Caller | Credential | Contents | Reference | +| --- | --- | --- | --- | --- | +| `/v1` | Applications (business systems, SDKs) | Project API key | Exactly the pinned official Agents API routes. Core-only fields live only in `x_agents_core` (`harness`, `model_provider`) | [Public API](public-agent-api.md) | +| `/core/v1` | Core Web's server and operator scripts | Core key | Projects and keys, resource reads and deletion, Session archive, credential issuance, metrics, audit, sandbox deployment and nodes | [Core API](#core-api), [Web API](web-management.md), [Core OpenAPI](../../contracts/agents-api/core.openapi.yaml) | +| `/api/v1` | Nodes, Runtime daemons, self-hosted executors | Machine credentials issued through `/core/v1` | Machine connections only: `/api/v1/sandbox-node/*` and `/api/v1/agent-daemon/*`, including WebSockets; each credential works only on its own routes | [Node operations](../../services/agents-api/HOSTED-SANDBOX-MANAGER.md#register-a-host), [executor credentials](../../contracts/agents-api/environment-executor-credentials.md), [machine OpenAPI](../../contracts/agents-api/runtime.openapi.yaml) | + +A Project API key gets 401 on `/core/v1` and `/api/v1`; the Core key gets 401 on +`/v1` and `/api/v1`. Projects own assets. Multiple equally privileged keys share +the Project's assets and execution principal; provenance retains the key that +performed each write. Projects and application keys live in PostgreSQL, never +deployment configuration. + +The browser talks only to Web's server: `/console/auth` and `/console/config`, +`/node-install/*`, the static pages, and `/core/v1/*`, which the server forwards +with the Core key after sign-in and same-origin checks. Users sign in to Web with +the Core key; the browser never receives it. Core decides which `/core/v1` routes +exist; Web returns 404 for `/v1` and `/api/v1`, whatever credential a request +carries. + +The deployment's reverse proxy sends `/v1`, `/v1/*` and `/api/v1/*` to Core and +every other path, including `/core/v1/*`, to Web. Operator scripts call `/core/v1` +with the Core key on Core's loopback port, not through the public entry. + +## Core API + +All routes are under `/core/v1` and require the Core key. + +| Routes | Contents | Contract | +| --- | --- | --- | +| `projects`, `projects/{project_id}[/archive]`, `projects/{project_id}/keys[/{key_id}]` | Projects and their API keys | [Administrator contract](../../contracts/agents-api/admin-api.md) | +| `projects/{project_id}/{agents,environment-templates,skills,files,vaults,sessions}/**` | Resource reads and deletion, Session history, artifacts and archive | [Administrator contract](../../contracts/agents-api/admin-api.md) | +| `projects/{project_id}/sessions/{session_id}/execution-configuration` | Committed harness and model selection | [Execution configuration](../../contracts/agents-api/execution-configuration.md) | +| `projects/{project_id}/sessions/{session_id}/runtime-observation`, `sandbox/runtime-observations` | Current Runtime observations | [Runtime observations](../../contracts/agents-api/runtime-observability-api.md) | +| `projects/{project_id}/sessions/{session_id}/runtime-history` | Stored Runtime history | [Runtime history](../../contracts/agents-api/runtime-history-api.md) | +| `projects/{project_id}/{resource-owners,write-operations}`, `audit-log`, `summary` | Provenance, write history, administrator audit and usage summary | [Write audit](../../contracts/agents-api/write-audit.md), [administrator contract](../../contracts/agents-api/admin-api.md) | +| `projects/{project_id}/environments/{environment_id}/executor-credentials[/{key_id}]` | Executor credentials for a self-hosted Environment | [Executor credentials](../../contracts/agents-api/environment-executor-credentials.md) | +| `metrics` | Core's own process metrics | [Core metrics](../../contracts/agents-api/core-metrics.md) | +| `sandbox/deployment[/maintenance]`, `sandbox/enrollment-tokens`, `sandbox/nodes[/{node_id}[/allocations]]` | Sandbox deployment, node enrollment tokens and nodes | [Sandbox deployment](../../contracts/agents-api/sandbox-deployment.md), [node operations](../../services/agents-api/HOSTED-SANDBOX-MANAGER.md), [node host history](../../contracts/agents-api/node-host-history.md) | ## Contract sources @@ -43,7 +61,7 @@ when given an application key or machine credential. - [Administrator contract](../../contracts/agents-api/admin-api.md): Project/key lifecycle, resources, explicit hosted Session archive, summary, errors/deletion preconditions, audit and historical copy provenance. -- [Web integration](web-management.md): browser/console/Core boundaries and frontend handoff. +- [Web integration](web-management.md): browser, console and Core boundaries and frontend handoff. - [Design rules](../design-principles.md) and [contributor guide](../../CONTRIBUTING.md): ownership, security and change requirements. @@ -51,6 +69,3 @@ Generated schemas do not establish complete compatibility or real execution support. The [coverage record](../../contracts/agents-api/README.md) identifies qualified workflows, native differences and unresolved behavior. Update the relevant contract and this index when adding or moving an API surface. - -Node host observations and retained charts are deployment-administrator reads; -see [node host history](../../contracts/agents-api/node-host-history.md). diff --git a/docs/api/public-agent-api.md b/docs/api/public-agent-api.md index 2dfd04bf8..f4c58a783 100644 --- a/docs/api/public-agent-api.md +++ b/docs/api/public-agent-api.md @@ -2,7 +2,7 @@ Applications call Core directly with an API key issued inside a Project. They use the public API to create and execute Agents, query history, and manage their assets. -They do not use the console's administrator credential or browser session cookie. +They never use the Core key or the console's browser session cookie. ## Authentication and base URL @@ -66,13 +66,15 @@ custom model access: | `x_agents_core.harness` | Saved Agent create, update and read; the inline Session `agent`; the Session's effective `agent` | [Harness selection](../../contracts/agents-api/harness-selection.md) | | `x_agents_core.model_provider` | Saved Agent create, update and read; Session creation. It takes `protocol`, `base_url`, optional `context_window` and `max_output_tokens`, and a write-only `api_key`; reads return `api_key_configured` instead of the key | [Model execution](../../contracts/agents-api/model-execution.md) | -Any other member of `x_agents_core` is rejected with 400. Deployment, placement -and operational reads are not part of `/v1`; they belong to the administrator API -under `/core/v1` (see the [API index](README.md)). +Any other member of `x_agents_core` is rejected with 400. Deployment, placement, +credential issuance and operational reads are not part of `/v1`; they belong to +`/core/v1`, which only the Core key can call (see the [API index](README.md)). -A `self_hosted` Runtime authenticates with an Environment-bound credential issued -at `/core/v1/environments/{environment_id}/executor-credentials` with the Project -key; see [executor credentials](../../contracts/agents-api/environment-executor-credentials.md). +An application creates a `self_hosted` Session with its Project key. The +deployment operator then issues the Environment's executor credential with the +Core key under `/core/v1/projects/{project_id}/environments/{environment_id}/executor-credentials` +and gives it to the executor host; see +[executor credentials](../../contracts/agents-api/environment-executor-credentials.md). `openai_hosted` keeps the official wire name and means Core-managed compute here. The deployment chooses E2B or its own Docker/microsandbox nodes. A public diff --git a/docs/api/web-management.md b/docs/api/web-management.md index a0fadaf72..c12c9d1bc 100644 --- a/docs/api/web-management.md +++ b/docs/api/web-management.md @@ -1,8 +1,9 @@ # Web management API -Core Web is an administrator console. Resource inspection, Project/key management, -audit, usage and sandbox operations use management authority. The console has no -Agent execution, copy or arbitrary asset editing operation. +Core Web is an administrator console over `/core/v1`, authorized by the Core key: +resource inspection, Project and key management, credential issuance, audit, usage +and sandbox operations. It never calls `/v1` or `/api/v1`, and has no Agent +execution, copy or arbitrary asset editing operation. ## Browser to console @@ -25,15 +26,18 @@ frontend work. ## Console to Core -The console server injects its private deployment Bearer credential on allowlisted -management requests. It removes browser Authorization and forwarding-sensitive +After sign-in and the same-origin checks, the console server forwards every +`/core/v1/*` request with its private Core key as the Bearer credential; Core +alone decides whether the route exists. It removes browser Authorization and forwarding-sensitive headers, and supplies the signed-in account name as `X-Core-Console-Actor`. Core treats that name as an audit display label, not an authorization input. -Use [AdminClient](../../packages/agents-client/src/admin-client.ts) for the typed -management client and [the complete administrator reference](../../contracts/agents-api/admin-api.md) +Web calls Core only through the typed Core clients in `packages/agents-client`: +[AdminClient](../../packages/agents-client/src/admin-client.ts) (`/core/v1`), +`SandboxAdminClient` (`/core/v1/sandbox`) and `CoreMetricsClient` +(`/core/v1/metrics`). See [the complete administrator reference](../../contracts/agents-api/admin-api.md) for methods, fields, filters, pagination and response shapes. -Routes below are relative to `/core/v1/admin`: +Routes below are relative to `/core/v1`: | Workflow | Routes | | --- | --- | @@ -42,7 +46,8 @@ Routes below are relative to `/core/v1/admin`: | Resource lists/details/deletion | `/projects/{id}/agents`, `/sessions`, `/environment-templates`, `/skills`, `/files`, `/vaults`, including the documented nested reads | | Asset ownership | `GET /projects/{id}/resource-owners` with batched resource IDs | | Key operation history | `GET /projects/{id}/write-operations` with key/resource/time filters | -| Usage and health | `GET /summary`, `/runtime-observations` | +| Executor credentials | `GET/POST /projects/{id}/environments/{environment_id}/executor-credentials`, `DELETE …/executor-credentials/{key_id}` ([contract](../../contracts/agents-api/environment-executor-credentials.md)) | +| Usage and health | `GET /summary`, `/sandbox/runtime-observations`, `/metrics` | | Administrator audit | `GET /audit-log` | A Project UUID in a management path selects the target; it is not a credential. @@ -107,12 +112,11 @@ from node configuration and safe deployment views. ## Frontend handoff and errors Frontend screen implementation is owned by the separate frontend task. The -backend provides the management contract, typed client and console allowlist. -Existing React screens that call `/v1` must switch before the paired application -release; backend tests do not qualify those screens. +backend provides the `/core/v1` contract, the typed Core clients and the console +proxy; backend tests do not qualify the screens. -The console returns 404 for `/v1`, old `/console/api-keys` routes and application -executor-credential routes, even with an explicit application Bearer key. Invalid +The console returns 404 for `/v1`, `/api/v1` and old `/console/api-keys` routes, +even with an explicit application or machine Bearer credential. Invalid Origin/Host requests are rejected; unavailable Core or rejected upstream redirects return 502. Console authentication uses its own error envelope. Management resource errors and deletion constraints are documented in the administrator reference and @@ -120,9 +124,9 @@ generated schema; do not interpret every empty or failed read as an absent resou ## Core metrics -`GET /core/v1/admin/core-metrics?range=1h|6h|24h|7d` returns Core process, execution -queue/slots, PostgreSQL and background-job measurements. It uses deployment -administrator authentication, rejects arbitrary query filters and never grants +`GET /core/v1/metrics?range=1h|6h|24h|7d` returns Core process, execution +queue/slots, PostgreSQL and background-job measurements. It requires the Core key, +rejects arbitrary query filters and never grants Agent execution access. See the [exact measurement contract](../../contracts/agents-api/core-metrics.md) for complete buckets, null values, units and process-local retention. Frontend implementation is maintained separately; this backend change does not modify @@ -130,7 +134,7 @@ Agent metrics or the public Agent API. ## Node host history -Deployment administrators can read a node and its host history through +The Core key reads a node and its host history through `GET /core/v1/sandbox/nodes/{node_id}?range=1h|6h|24h`. See the [node host history contract](../../contracts/agents-api/node-host-history.md) for nullable observations, freshness and aggregation. The node list is unchanged. diff --git a/docs/design-principles.md b/docs/design-principles.md index 788483ff5..56ca09fe7 100644 --- a/docs/design-principles.md +++ b/docs/design-principles.md @@ -5,13 +5,20 @@ contract follows the repository's pinned upstream baseline; documented native harness differences remain explicit. Core extensions must not silently change upstream resource shapes or execution semantics. -## Separate data and management APIs +## Three namespaces, three credentials -Applications use `/v1` with an API key. The administrator console uses -`/core/v1/admin` and the existing sandbox management API with a deployment -credential kept on the console server. Core enforces this separation: deployment -credentials cannot call the Agent API, and API keys cannot call administrator -operations. Node and daemon transport retain their own credentials. +Core serves three namespaces, each with one kind of caller and credential: + +- `/v1`: applications with a Project API key. Exactly the pinned official routes; + Core-only fields live only in `x_agents_core`. +- `/core/v1`: Core Web's server and operator scripts with the Core key. Everything + about operating the deployment, including credential issuance. +- `/api/v1`: nodes, Runtime daemons and self-hosted executors with machine + credentials issued through `/core/v1`; each works only on its own routes. + +Core enforces the separation: the Core key cannot call `/v1`, API keys cannot call +`/core/v1`, and neither works on `/api/v1`. Web calls only `/core/v1` and keeps +the Core key on its server; users sign in to Web with it. This is a separation of API authority, not a claim that a deployment administrator cannot possess application credentials. An administrator can issue an API key and @@ -36,7 +43,7 @@ Administrators can still inspect and delete resources in archived Projects. Store only API-key digests and necessary metadata. Return plaintext once, on issuance, and never replay it after an uncertain response. Project/key mutations and administrator audit commit together. Database authentication failures fail -closed. Deployment administrator credentials remain separately managed. +closed. The Core key is managed separately, in deployment configuration. Parsar product identities, workspaces, business permissions and collaboration remain outside Core. Parsar is an ordinary API-key holder in a Project. @@ -50,7 +57,8 @@ and startup settings are deployment infrastructure, not shared business assets. Administrators can inspect resources and execution history, delete resources under the same rules as their public deletion operations, manage Projects and API keys, -and query operational counts and usage. They cannot use management endpoints to +issue node enrollment tokens and self-hosted executor credentials, and query +operational counts and usage. They cannot use management endpoints to create, copy or edit arbitrary assets, start a Session, send an event, cancel work, or read stored credentials. Project keys share all assets within their Project; nothing is shared or copied across Projects. diff --git a/docs/getting-started/install.md b/docs/getting-started/install.md index ae4cd296d..e12c17036 100644 --- a/docs/getting-started/install.md +++ b/docs/getting-started/install.md @@ -188,11 +188,26 @@ credentials are supplied with execution requests, not during node installation. ## Connect a user-managed Runtime A `self_hosted` Session uses your own execution machine; it does not enroll that -machine as a shared sandbox node. Create the Session through the public API, then -use your project caller credential to obtain a restricted credential for its -Environment. The [credential contract](../../contracts/agents-api/environment-executor-credentials.md) -describes issuance, rotation and revocation. Save the response to an owned, -mode-0600 file. Keep the project caller key on your application machine. +machine as a shared sandbox node. The application creates the Session through +`/v1` with its Project API key. The deployment operator then issues a restricted +credential for the Session's Environment with the Core key, in Web or with a +script against Core's loopback port: + +```sh +umask 077 +curl -fsS -X POST \ + "http://127.0.0.1:8091/core/v1/projects/$PROJECT_ID/environments/$ENVIRONMENT_ID/executor-credentials" \ + -H "Authorization: Bearer $CORE_KEY" -H "Content-Type: application/json" \ + -d "{\"key_id\":\"$(python3 -c 'import uuid; print(uuid.uuid4())')\"}" \ + -o executor-key.json +``` + +The response is the credential file; its secret appears only once. If the +outcome is uncertain, list the credentials and rotate the same `key_id` instead +of issuing another. The [credential contract](../../contracts/agents-api/environment-executor-credentials.md) +describes issuance, rotation and revocation. Give the file to the executor host +as an owned, mode-0600 file. The Core key and the application's +`$PROJECT_API_KEY` never leave their own machines. After saving the restricted credential on your execution machine, run one command with your Core origin, Environment ID and returned `remote_url`. This downloads @@ -329,9 +344,9 @@ hostname. Configure your TLS reverse proxy with these routes: | `/api/v1/*` | Core (loopback 8091) | Nodes and Runtime daemons, with their own machine credentials | | Everything else, including `/core/v1/*` and `/node-install/*` | Web (loopback 8080) | Browsers and the console | -The project executor-credential API (`/core/v1/environments/{id}/executor-credentials`) -is the one exception under `/core`: route it to Core as well. Preserve Host and -support WebSocket upgrades. Web answers 404 on `/v1` and `/api/v1` and never +Web forwards signed-in `/core/v1` requests to Core with the Core key; operator +scripts call `/core/v1` on Core's loopback port instead of the public entry. +Preserve Host and support WebSocket upgrades. Web answers 404 on `/v1` and `/api/v1` and never forwards them. A proxy that sends those paths to Web breaks application calls, node enrollment and every Runtime daemon connection (`/api/v1/agent-daemon/ws`) for Docker, microsandbox, self-hosted and E2B sandboxes alike. When upgrading from diff --git a/docs/getting-started/operations.md b/docs/getting-started/operations.md index 97ef2e1bf..191c03bcf 100644 --- a/docs/getting-started/operations.md +++ b/docs/getting-started/operations.md @@ -73,8 +73,9 @@ docker compose -f "$HOME/.parsar/core/compose.json" ps --all ## Projects and API keys -Use the [administrator API](../../contracts/agents-api/admin-api.md) to create a -Project and issue its first key after installation. The Web management migration +Use Web, or the [Core API](../../contracts/agents-api/admin-api.md) under +`/core/v1/projects` with the Core key, to create a Project and issue its first key +after installation. The Web management migration is still pending; see [integration status](../web/README.md). No configuration file defines Projects or application keys. API-key plaintext is returned once at issuance, with only its digest stored in the database. @@ -193,12 +194,12 @@ persisted Files/Artifacts remain. E2B deployments from that period are not cover API and console bind to host loopback. With native Core, PostgreSQL publishes an installation-specific loopback port; with container Core it has no published port. -The production Web proxy forwards only allowlisted administrator and sandbox -management routes after console login. Its deployment credential stays on the -server. The TLS reverse proxy routes `/v1` (applications) and `/api/v1` (node and -Runtime daemon connections) directly to Core, together with the project -executor-credential API under `/core/v1/environments/`. Everything else, including -console pages, authentication and administration, goes to Web. Web returns 404 for +After console login and same-origin checks, the production Web proxy forwards every +`/core/v1` request to Core with the Core key, which stays on the server; Core +decides which routes exist. The TLS reverse proxy routes `/v1` (applications) and +`/api/v1` (node and Runtime daemon connections) directly to Core. Everything else, +including console pages, authentication and `/core/v1`, goes to Web. Operator +scripts call `/core/v1` with the Core key on Core's loopback port. Web returns 404 for `/v1` and `/api/v1`, whatever credential a request carries, and forwards no node or daemon traffic. Machine routes keep their own enrollment and connection credentials. `/node-install/` serves only the matched non-secret node payload. Web has no Docker diff --git a/docs/getting-started/quickstart.md b/docs/getting-started/quickstart.md index e2f6a5b66..d24a531ad 100644 --- a/docs/getting-started/quickstart.md +++ b/docs/getting-started/quickstart.md @@ -13,8 +13,8 @@ pip install openai==3.13.0 ``` The deployment administrator first creates a Project through -`POST /core/v1/admin/projects` with `{"name":"Default"}`, then issues a key through -`POST /core/v1/admin/projects/{project_id}/keys` with a descriptive `{"name":"..."}`. +`POST /core/v1/projects` with `{"name":"Default"}`, then issues a key through +`POST /core/v1/projects/{project_id}/keys` with a descriptive `{"name":"..."}`. These requests use the separate deployment credential. The management UI has not yet migrated; see [integration status](../web/README.md). diff --git a/services/agents-api/HOSTED-SANDBOX-MANAGER.md b/services/agents-api/HOSTED-SANDBOX-MANAGER.md index c8ff6e899..84d38aa49 100644 --- a/services/agents-api/HOSTED-SANDBOX-MANAGER.md +++ b/services/agents-api/HOSTED-SANDBOX-MANAGER.md @@ -94,8 +94,8 @@ gateway using `AGENTS_API_DAEMON_WS_URL`. Do not also set bootstrap and its public daemon WebSocket address; it never uses request Host or forwarded headers. Preserve the installation UUID and database together. -The startup configuration API remains a startup snapshot. Use the live sandbox -deployment response for a selection made after startup. +Use the live sandbox deployment response (`GET /core/v1/sandbox/deployment`) for +the current selection, including one made after startup. ## Resources and Runtime @@ -273,7 +273,7 @@ Provider, resource-limit and Runtime changes share one deployment-wide procedure before another write. Saving never automatically deletes compute. During maintenance, explicitly archive each retained Core-managed hosted Session -through `POST /core/v1/admin/projects/{project_id}/sessions/{session_id}/archive` +through `POST /core/v1/projects/{project_id}/sessions/{session_id}/archive` with the current `expected_generation`. This requests cancellation and revokes Runtime authority; the existing lifecycle releases compute and snapshots after provider verification. Poll GET on the same path for `released`, then verify both diff --git a/services/agents-api/README.md b/services/agents-api/README.md index 439840782..a0e230549 100644 --- a/services/agents-api/README.md +++ b/services/agents-api/README.md @@ -166,8 +166,8 @@ Projects and API keys live only in the database. Configuration files contain infrastructure settings and deployment credentials, not business identities. Installation creates no Project or application key. Using the [administrator API](../../contracts/agents-api/admin-api.md), create a Project with -`POST /core/v1/admin/projects` and issue a named key with -`POST /core/v1/admin/projects/{project_id}/keys`. Both requests accept a JSON object containing `name`; +`POST /core/v1/projects` and issue a named key with +`POST /core/v1/projects/{project_id}/keys`. Both requests accept a JSON object containing `name`; Core generates the identifiers. The Web management screens still need migration; see [integration status](../../docs/web/README.md). @@ -486,7 +486,12 @@ reuse the same exact binding and `LocalEnvironment` preparation path. Service-or HTTP MCP is rejected on this placement; `none` MCP and separately qualified hosted Environment Plugin MCP remain available within their own limits. -An operator issues a connect-only principal executor key using the existing issuer: +An operator issues the Environment's connect-only executor credential with the +Core key, through Web or +`POST /core/v1/projects/{project_id}/environments/{environment_id}/executor-credentials` +([executor credentials](../../contracts/agents-api/environment-executor-credentials.md)), +and gives the returned credential file to the executor host. With direct database +access, the operator CLI can instead issue a principal executor key: ```bash umask 077 diff --git a/services/agents-api/RELEASE.md b/services/agents-api/RELEASE.md index bcf30856c..91a1f8dcd 100644 --- a/services/agents-api/RELEASE.md +++ b/services/agents-api/RELEASE.md @@ -104,6 +104,10 @@ umask 077 > "$PARSAR_HOME/executor-key.json" ``` +With the Core key, the same restricted credential can instead be issued through +`POST /core/v1/projects/{project_id}/environments/{environment_id}/executor-credentials`; +see [executor credentials](../../contracts/agents-api/environment-executor-credentials.md). + Deploy the qualified V1 Runtime containing our daemon, selected native harness, local tools and workspace. Transfer only its scoped key into the protected daemon state directory as an owned mode-0600 file. Keep API caller and database credentials From 4900b8cebafb574ff165ecfcc816f34b4fe814ea Mon Sep 17 00:00:00 2001 From: SaladDay <1203511142@qq.com> Date: Fri, 25 Sep 2026 14:33:01 +0000 Subject: [PATCH 11/30] Name the Core key in the machine connection contract --- contracts/agents-api/runtime.openapi.yaml | 2 +- scripts/openapi-split/main.go | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/contracts/agents-api/runtime.openapi.yaml b/contracts/agents-api/runtime.openapi.yaml index 08d0e56ea..113b8ae8f 100644 --- a/contracts/agents-api/runtime.openapi.yaml +++ b/contracts/agents-api/runtime.openapi.yaml @@ -132,7 +132,7 @@ definitions: type: object info: contact: {} - description: Machine connection routes under /api/v1. Sandbox nodes authenticate with a one-use enrollment token or their node credential; Project API keys and the deployment administrator credential are not accepted. See each operation's security requirements. + description: Machine connection routes under /api/v1. Sandbox nodes authenticate with a one-use enrollment token or their node credential; Project API keys and the Core key are not accepted. See each operation's security requirements. license: name: Apache 2.0 url: https://www.apache.org/licenses/LICENSE-2.0.html diff --git a/scripts/openapi-split/main.go b/scripts/openapi-split/main.go index 128838202..bda8f1208 100644 --- a/scripts/openapi-split/main.go +++ b/scripts/openapi-split/main.go @@ -48,7 +48,7 @@ func run(input, projectOutput, coreOutput, runtimeOutput string) error { field(field(m, "info"), "description").Value = "Deployment and operations routes under /core/v1 for Core Web's server and operator scripts. Every operation requires the Core key; Project API keys and machine credentials are not accepted." field(rt, "basePath").Value = "/" field(field(rt, "info"), "title").Value = "Core Machine Connections" - field(field(rt, "info"), "description").Value = "Machine connection routes under /api/v1. Sandbox nodes authenticate with a one-use enrollment token or their node credential; Project API keys and the deployment administrator credential are not accepted. See each operation's security requirements." + field(field(rt, "info"), "description").Value = "Machine connection routes under /api/v1. Sandbox nodes authenticate with a one-use enrollment token or their node credential; Project API keys and the Core key are not accepted. See each operation's security requirements." // Retain exactly the definitions referenced by each surface, including shared // error DTOs. Follow nested references instead of duplicating the project schema. pruneDefinitions(p) From 6219ad0ae4fcab7bd714b5a8f1066543bbf3ad2b Mon Sep 17 00:00:00 2001 From: SaladDay <1203511142@qq.com> Date: Fri, 25 Sep 2026 14:50:49 +0000 Subject: [PATCH 12/30] Test executor credential key_id form and unknown-key rotation over HTTP A key_id that is not a canonical lowercase, non-nil UUID is 400 before any store call, and rotate:true for a key_id that was never issued is 404. --- .../internal/api/environment_executor_management_test.go | 8 +++++++- 1 file changed, 7 insertions(+), 1 deletion(-) diff --git a/services/agents-api/internal/api/environment_executor_management_test.go b/services/agents-api/internal/api/environment_executor_management_test.go index 207a2e063..fd0cfa695 100644 --- a/services/agents-api/internal/api/environment_executor_management_test.go +++ b/services/agents-api/internal/api/environment_executor_management_test.go @@ -92,7 +92,9 @@ func TestProjectExecutorCredentialsHTTP(t *testing.T) { if w := projectKeyHTTP(h, "POST", path, "admin", `{"key_id":"`+keyID+`","rotate":true}`); w.Code != 201 || !f.rotate { t.Fatal("explicit rotation", w.Code) } - for _, body := range []string{`{}`, `{"key_id":"invalid"}`, `{"key_id":null}`, `{"key_id":"` + keyID + `","environment_id":"other"}`, `{"key_id":"` + keyID + `","executor_token":"import"}`, `{"key_id":"` + keyID + `","rotate":null}`} { + // key_id must be a canonical lowercase, non-nil UUID. + for _, body := range []string{`{}`, `{"key_id":"invalid"}`, `{"key_id":null}`, `{"key_id":"` + strings.ToUpper(keyID) + `"}`, + `{"key_id":"00000000-0000-0000-0000-000000000000"}`, `{"key_id":"{` + keyID + `}"}`, `{"key_id":"urn:uuid:` + keyID + `"}`, `{"key_id":"` + strings.ReplaceAll(keyID, "-", "") + `"}`, `{"key_id":"` + keyID + `","environment_id":"other"}`, `{"key_id":"` + keyID + `","executor_token":"import"}`, `{"key_id":"` + keyID + `","rotate":null}`} { before := f.calls if w := projectKeyHTTP(h, "POST", path, "admin", body); w.Code != 400 || before != f.calls { t.Fatal("invalid request accepted", w.Code, body) @@ -103,6 +105,10 @@ func TestProjectExecutorCredentialsHTTP(t *testing.T) { t.Fatal("uncertain retry", w.Code, w.Body) } f.err = store.ErrNotFound + // Rotating a key_id that was never issued is not found. + if w := projectKeyHTTP(h, "POST", path, "admin", `{"key_id":"`+uuid.NewString()+`","rotate":true}`); w.Code != 404 || !f.rotate { + t.Fatal("unknown key rotation", w.Code) + } if w := projectKeyHTTP(h, "DELETE", path+"/"+keyID, "admin", ""); w.Code != 404 { t.Fatal("foreign revocation", w.Code) } From 24c0b483c071a4f63c58001847d8147dc2d8c7aa Mon Sep 17 00:00:00 2001 From: SaladDay <1203511142@qq.com> Date: Fri, 25 Sep 2026 14:54:10 +0000 Subject: [PATCH 13/30] Refuse executor credential issuance and rotation in an archived Project Issue and rotate return the existing 409 project_archived. The check share-locks the Project inside the issuing transaction, before the write and its audit entry, so a concurrent archive either follows the issuance or is seen by it. Listing and revocation stay available, because revoking must always work. --- contracts/agents-api/admin-api.md | 4 +- contracts/agents-api/core.openapi.yaml | 4 +- .../environment-executor-credentials.md | 7 ++- .../api/environment_executor_management.go | 4 +- .../environment_executor_management_test.go | 4 ++ .../internal/db/queries/projects.sql | 3 ++ .../internal/db/sqlc/projects.sql.go | 19 ++++++++ .../store/environment_executor_credentials.go | 26 ++++++----- .../store/environment_executor_management.go | 36 ++++++++++++--- .../environment_executor_management_test.go | 44 +++++++++++++++++++ 10 files changed, 129 insertions(+), 22 deletions(-) diff --git a/contracts/agents-api/admin-api.md b/contracts/agents-api/admin-api.md index 1e2c02fa6..b0a70054b 100644 --- a/contracts/agents-api/admin-api.md +++ b/contracts/agents-api/admin-api.md @@ -33,7 +33,9 @@ startup for bootstrap and management. | Revoke executor credential | `DELETE /projects/{project_id}/environments/{environment_id}/executor-credentials/{key_id}` | HTTP 204 | Executor credentials apply only to a `self_hosted` Environment of the Project -whose Session exists; see [executor credentials](environment-executor-credentials.md). +whose Session exists. An archived Project returns 409 `project_archived` for +issuance and rotation but still lists and revokes; see +[executor credentials](environment-executor-credentials.md). Project and key IDs are server-generated UUIDs. Project metadata contains `id`, `name`, `created_at`, nullable `archived_at`, and `active_key_count`. Key metadata contains `id`, `project_id`, `name`, `prefix`, `created_at`, and nullable `revoked_at`. diff --git a/contracts/agents-api/core.openapi.yaml b/contracts/agents-api/core.openapi.yaml index 090579c87..2a84397a4 100644 --- a/contracts/agents-api/core.openapi.yaml +++ b/contracts/agents-api/core.openapi.yaml @@ -3402,7 +3402,7 @@ paths: post: consumes: - application/json - description: Core key only. Returns a connect-only secret once, restricted to daemon enrollment and connection for this Environment, with the Project's principal as its execution principal. Repeating an issuance key_id returns 409 executor_credential_exists; after an uncertain response, list the credentials and rotate that key_id explicitly. Rotation keeps the key's Environment, invalidates the old secret and restores a revoked key; rotating an unknown key_id returns 404. The Environment must be a self_hosted Environment of the Project whose Session exists; otherwise 404. Each write records an administrator audit entry without the secret. + description: Core key only. Returns a connect-only secret once, restricted to daemon enrollment and connection for this Environment, with the Project's principal as its execution principal. Repeating an issuance key_id returns 409 executor_credential_exists; after an uncertain response, list the credentials and rotate that key_id explicitly. Rotation keeps the key's Environment, invalidates the old secret and restores a revoked key; rotating an unknown key_id returns 404. In an archived Project, issuance and rotation return 409 project_archived. The Environment must be a self_hosted Environment of the Project whose Session exists; otherwise 404. Each write records an administrator audit entry without the secret. parameters: - description: Project UUID in: path @@ -3454,7 +3454,7 @@ paths: - Executor Credentials /core/v1/projects/{project_id}/environments/{environment_id}/executor-credentials/{key_id}: delete: - description: Core key only. Revokes one credential restricted to this Environment; repeated revocation is safe. Revocation denies future enrollment and connection but does not stop executor-owned compute. The Environment must be a self_hosted Environment of the Project whose Session exists; otherwise 404. + description: Core key only. Revokes one credential restricted to this Environment; repeated revocation is safe and it also works in an archived Project. Revocation denies future enrollment and connection but does not stop executor-owned compute. The Environment must be a self_hosted Environment of the Project whose Session exists; otherwise 404. parameters: - description: Project UUID in: path diff --git a/contracts/agents-api/environment-executor-credentials.md b/contracts/agents-api/environment-executor-credentials.md index 44d30bb25..7ceb45ade 100644 --- a/contracts/agents-api/environment-executor-credentials.md +++ b/contracts/agents-api/environment-executor-credentials.md @@ -41,8 +41,11 @@ execution principal. The secret authorizes daemon enrollment and connection (`/api/v1/agent-daemon/*`) for this exact Environment only, never `/v1`, `/core/v1`, sandbox-node enrollment or project resource operations. -The only write conflict is 409 `executor_credential_exists`: an issuance whose -`key_id` already exists and does not set `rotate:true`, even after revocation. +Writes have two conflicts, both 409. `executor_credential_exists`: an issuance +whose `key_id` already exists and does not set `rotate:true`, even after +revocation. `project_archived`: the Project is archived, so it gets no new or +rotated credential; listing and revocation remain available there, because +revoking must always work. Rotation replaces the secret of an existing key restricted to this Environment, keeps that Environment, invalidates the previous secret and restores a revoked key; rotating an unknown `key_id` returns 404. Revocation is idempotent and diff --git a/services/agents-api/internal/api/environment_executor_management.go b/services/agents-api/internal/api/environment_executor_management.go index 16345c96c..b97e8f868 100644 --- a/services/agents-api/internal/api/environment_executor_management.go +++ b/services/agents-api/internal/api/environment_executor_management.go @@ -68,7 +68,7 @@ func (h *Handler) listExecutorCredentials(w http.ResponseWriter, r *http.Request } // @Summary Issue or explicitly rotate a self_hosted Environment executor credential -// @Description Core key only. Returns a connect-only secret once, restricted to daemon enrollment and connection for this Environment, with the Project's principal as its execution principal. Repeating an issuance key_id returns 409 executor_credential_exists; after an uncertain response, list the credentials and rotate that key_id explicitly. Rotation keeps the key's Environment, invalidates the old secret and restores a revoked key; rotating an unknown key_id returns 404. The Environment must be a self_hosted Environment of the Project whose Session exists; otherwise 404. Each write records an administrator audit entry without the secret. +// @Description Core key only. Returns a connect-only secret once, restricted to daemon enrollment and connection for this Environment, with the Project's principal as its execution principal. Repeating an issuance key_id returns 409 executor_credential_exists; after an uncertain response, list the credentials and rotate that key_id explicitly. Rotation keeps the key's Environment, invalidates the old secret and restores a revoked key; rotating an unknown key_id returns 404. In an archived Project, issuance and rotation return 409 project_archived. The Environment must be a self_hosted Environment of the Project whose Session exists; otherwise 404. Each write records an administrator audit entry without the secret. // @Tags Executor Credentials // @Accept json // @Produce json @@ -103,7 +103,7 @@ func (h *Handler) issueExecutorCredential(w http.ResponseWriter, r *http.Request } // @Summary Revoke a self_hosted Environment executor credential -// @Description Core key only. Revokes one credential restricted to this Environment; repeated revocation is safe. Revocation denies future enrollment and connection but does not stop executor-owned compute. The Environment must be a self_hosted Environment of the Project whose Session exists; otherwise 404. +// @Description Core key only. Revokes one credential restricted to this Environment; repeated revocation is safe and it also works in an archived Project. Revocation denies future enrollment and connection but does not stop executor-owned compute. The Environment must be a self_hosted Environment of the Project whose Session exists; otherwise 404. // @Tags Executor Credentials // @Security DeploymentAdminAuth // @Param project_id path string true "Project UUID" diff --git a/services/agents-api/internal/api/environment_executor_management_test.go b/services/agents-api/internal/api/environment_executor_management_test.go index fd0cfa695..e8a5d6a96 100644 --- a/services/agents-api/internal/api/environment_executor_management_test.go +++ b/services/agents-api/internal/api/environment_executor_management_test.go @@ -104,6 +104,10 @@ func TestProjectExecutorCredentialsHTTP(t *testing.T) { if w := projectKeyHTTP(h, "POST", path, "admin", body); w.Code != 409 || !strings.Contains(w.Body.String(), `"code":"executor_credential_exists"`) || strings.Contains(w.Body.String(), "synthetic-connect-only") { t.Fatal("uncertain retry", w.Code, w.Body) } + f.err = store.ErrProjectArchived + if w := projectKeyHTTP(h, "POST", path, "admin", body); w.Code != 409 || !strings.Contains(w.Body.String(), `"code":"project_archived"`) { + t.Fatal("archived Project", w.Code, w.Body) + } f.err = store.ErrNotFound // Rotating a key_id that was never issued is not found. if w := projectKeyHTTP(h, "POST", path, "admin", `{"key_id":"`+uuid.NewString()+`","rotate":true}`); w.Code != 404 || !f.rotate { diff --git a/services/agents-api/internal/db/queries/projects.sql b/services/agents-api/internal/db/queries/projects.sql index 2e5eaa9ad..6ad5f616a 100644 --- a/services/agents-api/internal/db/queries/projects.sql +++ b/services/agents-api/internal/db/queries/projects.sql @@ -21,6 +21,9 @@ LIMIT sqlc.arg(page_limit)::int; -- name: LockProject :one SELECT * FROM projects WHERE id=$1 FOR SHARE; +-- name: LockProjectByTenant :one +SELECT * FROM projects WHERE tenant_id=$1 FOR SHARE; + -- name: LockProjectForUpdate :one SELECT * FROM projects WHERE id=$1 FOR UPDATE; diff --git a/services/agents-api/internal/db/sqlc/projects.sql.go b/services/agents-api/internal/db/sqlc/projects.sql.go index 0e3ada085..bf4dd44e0 100644 --- a/services/agents-api/internal/db/sqlc/projects.sql.go +++ b/services/agents-api/internal/db/sqlc/projects.sql.go @@ -170,6 +170,25 @@ func (q *Queries) LockProject(ctx context.Context, id pgtype.UUID) (Project, err return i, err } +const lockProjectByTenant = `-- name: LockProjectByTenant :one +SELECT id, name, tenant_id, subject_kind, subject_id, created_at, archived_at FROM projects WHERE tenant_id=$1 FOR SHARE +` + +func (q *Queries) LockProjectByTenant(ctx context.Context, tenantID pgtype.UUID) (Project, error) { + row := q.db.QueryRow(ctx, lockProjectByTenant, tenantID) + var i Project + err := row.Scan( + &i.ID, + &i.Name, + &i.TenantID, + &i.SubjectKind, + &i.SubjectID, + &i.CreatedAt, + &i.ArchivedAt, + ) + return i, err +} + const lockProjectForUpdate = `-- name: LockProjectForUpdate :one SELECT id, name, tenant_id, subject_kind, subject_id, created_at, archived_at FROM projects WHERE id=$1 FOR UPDATE ` diff --git a/services/agents-api/internal/store/environment_executor_credentials.go b/services/agents-api/internal/store/environment_executor_credentials.go index 0b12bdf5b..24974d06e 100644 --- a/services/agents-api/internal/store/environment_executor_credentials.go +++ b/services/agents-api/internal/store/environment_executor_credentials.go @@ -27,8 +27,9 @@ func (s *Store) IssueExecutorCredential(ctx context.Context, principal identity. return s.issueExecutorCredential(ctx, principal, keyID, environment, nil) } -// issueExecutorCredential runs record, when given, in the issuing transaction. -func (s *Store) issueExecutorCredential(ctx context.Context, principal identity.Principal, keyID, environment string, record func(context.Context, *sqlc.Queries) error) (IssuedExecutorCredential, error) { +// issueExecutorCredential runs before, when given, first in the issuing +// transaction; its error aborts the issuance. +func (s *Store) issueExecutorCredential(ctx context.Context, principal identity.Principal, keyID, environment string, before func(context.Context, *sqlc.Queries) error) (IssuedExecutorCredential, error) { tenant, id, err := executorCredentialIdentity(principal, keyID) if err != nil { return IssuedExecutorCredential{}, err @@ -53,6 +54,11 @@ func (s *Store) issueExecutorCredential(ctx context.Context, principal identity. } var result IssuedExecutorCredential err = s.withExecutorCredentialTarget(ctx, principal, restriction, func(ctx context.Context, q *sqlc.Queries) error { + if before != nil { + if err := before(ctx, q); err != nil { + return err + } + } row, err := q.IssueExecutorCredential(ctx, sqlc.IssueExecutorCredentialParams{ KeyID: id, TenantID: tenant, SubjectKind: pgtype.Text{String: principal.SubjectKind, Valid: true}, SubjectID: pgtype.Text{String: principal.SubjectID, Valid: true}, OrganizationID: principal.OrganizationID, ProjectID: principal.ProjectID, EnvironmentID: restriction, TokenSha256: digest, @@ -64,9 +70,6 @@ func (s *Store) issueExecutorCredential(ctx context.Context, principal identity. return err } result = issuedExecutorCredential(row.KeyID, row.EnvironmentID, token) - if record != nil { - return record(ctx, q) - } return nil }) if err != nil { @@ -79,8 +82,9 @@ func (s *Store) RotateExecutorCredential(ctx context.Context, principal identity return s.rotateExecutorCredential(ctx, principal, keyID, nil) } -// rotateExecutorCredential runs record, when given, in the rotating transaction. -func (s *Store) rotateExecutorCredential(ctx context.Context, principal identity.Principal, keyID string, record func(context.Context, *sqlc.Queries) error) (IssuedExecutorCredential, error) { +// rotateExecutorCredential runs before, when given, first in the rotating +// transaction; its error aborts the rotation. +func (s *Store) rotateExecutorCredential(ctx context.Context, principal identity.Principal, keyID string, before func(context.Context, *sqlc.Queries) error) (IssuedExecutorCredential, error) { tenant, id, err := executorCredentialIdentity(principal, keyID) if err != nil { return IssuedExecutorCredential{}, err @@ -95,6 +99,11 @@ func (s *Store) rotateExecutorCredential(ctx context.Context, principal identity } var result IssuedExecutorCredential err = s.withExecutorCredentialTarget(ctx, principal, restriction, func(ctx context.Context, q *sqlc.Queries) error { + if before != nil { + if err := before(ctx, q); err != nil { + return err + } + } row, err := q.RotateExecutorCredential(ctx, sqlc.RotateExecutorCredentialParams{KeyID: id, TenantID: tenant, SubjectKind: pgtype.Text{String: principal.SubjectKind, Valid: true}, SubjectID: pgtype.Text{String: principal.SubjectID, Valid: true}, TokenSha256: digest}) if errors.Is(err, pgx.ErrNoRows) { return ErrNotFound @@ -103,9 +112,6 @@ func (s *Store) rotateExecutorCredential(ctx context.Context, principal identity return err } result = issuedExecutorCredential(row.KeyID, row.EnvironmentID, token) - if record != nil { - return record(ctx, q) - } return nil }) if err != nil { diff --git a/services/agents-api/internal/store/environment_executor_management.go b/services/agents-api/internal/store/environment_executor_management.go index ac21903c3..8d500f3c0 100644 --- a/services/agents-api/internal/store/environment_executor_management.go +++ b/services/agents-api/internal/store/environment_executor_management.go @@ -3,6 +3,7 @@ package store import ( "context" "encoding/json" + "errors" "time" "github.com/MiniMax-AI-Dev/parsar/services/agents-api/internal/db/sqlc" @@ -53,22 +54,24 @@ func (s *Store) ListProjectExecutorCredentials(ctx context.Context, project iden } // IssueProjectExecutorCredential issues a new key or, with rotate, replaces the -// secret of an existing key restricted to the Environment. The administrator -// audit entry commits in the same transaction and never contains the secret. +// secret of an existing key restricted to the Environment. An archived Project +// gets neither (ErrProjectArchived). The administrator audit entry commits in +// the same transaction and never contains the secret. func (s *Store) IssueProjectExecutorCredential(ctx context.Context, project identity.Principal, environment, keyID string, rotate bool) (IssuedExecutorCredential, error) { if err := s.selfHostedExecutorTarget(ctx, project, environment); err != nil { return IssuedExecutorCredential{}, err } if !rotate { - return s.issueExecutorCredential(ctx, project, keyID, environment, executorCredentialAudit(project, "issue", keyID)) + return s.issueExecutorCredential(ctx, project, keyID, environment, activeProjectAudit(project, "issue", keyID)) } if err := s.exactExecutorRestriction(ctx, project, environment, keyID); err != nil { return IssuedExecutorCredential{}, err } - return s.rotateExecutorCredential(ctx, project, keyID, executorCredentialAudit(project, "rotate", keyID)) + return s.rotateExecutorCredential(ctx, project, keyID, activeProjectAudit(project, "rotate", keyID)) } -// RevokeProjectExecutorCredential is idempotent; each successful request is audited. +// RevokeProjectExecutorCredential is idempotent and also works in an archived +// Project; each successful request is audited. func (s *Store) RevokeProjectExecutorCredential(ctx context.Context, project identity.Principal, environment, keyID string) error { if err := s.selfHostedExecutorTarget(ctx, project, environment); err != nil { return err @@ -100,6 +103,29 @@ func executorCredentialAudit(project identity.Principal, action, keyID string) f } } +// activeProjectAudit share-locks the Project, which archiving updates, so an +// issuance or rotation either commits before the archive or sees it and fails. +func activeProjectAudit(project identity.Principal, action, keyID string) func(context.Context, *sqlc.Queries) error { + audit := executorCredentialAudit(project, action, keyID) + return func(ctx context.Context, q *sqlc.Queries) error { + tenant, err := parseID(project.TenantID) + if err != nil { + return err + } + row, err := q.LockProjectByTenant(ctx, tenant) + if errors.Is(err, pgx.ErrNoRows) { + return ErrNotFound + } + if err != nil { + return err + } + if row.ArchivedAt.Valid { + return ErrProjectArchived + } + return audit(ctx, q) + } +} + func (s *Store) selfHostedExecutorTarget(ctx context.Context, principal identity.Principal, environment string) error { if err := principal.Validate(); err != nil { return ErrInvalidInput diff --git a/services/agents-api/internal/store/environment_executor_management_test.go b/services/agents-api/internal/store/environment_executor_management_test.go index 0600f3312..d7fd48ac8 100644 --- a/services/agents-api/internal/store/environment_executor_management_test.go +++ b/services/agents-api/internal/store/environment_executor_management_test.go @@ -143,3 +143,47 @@ func TestProjectEnvironmentExecutorManagement(t *testing.T) { t.Fatal("deleted target revoke", err) } } + +// An archived Project gets no new or rotated credential; listing and revoking still work. +func TestArchivedProjectExecutorCredentials(t *testing.T) { + s, _ := testStore(t) + ctx := t.Context() + project := createTestProject(t, s) + binding, err := s.GetProject(ctx, project.ID) + if err != nil { + t.Fatal(err) + } + p := binding.Principal + input := environmentInput(uuid.NewString(), "self_hosted", "/workspace") + input.Creator = p.Subject() + session, err := s.CreateSession(ctx, p.TenantID, input) + if err != nil { + t.Fatal(err) + } + environment, err := s.GetSessionEnvironment(ctx, p.TenantID, session.ID) + if err != nil { + t.Fatal(err) + } + keyID := uuid.NewString() + if _, err := s.IssueProjectExecutorCredential(keyAdminContext(ctx, project.ID), p, environment.ID, keyID, false); err != nil { + t.Fatal(err) + } + if _, err := s.ArchiveProject(keyAdminContext(ctx, project.ID), project.ID); err != nil { + t.Fatal(err) + } + if _, err := s.IssueProjectExecutorCredential(keyAdminContext(ctx, project.ID), p, environment.ID, uuid.NewString(), false); !errors.Is(err, ErrProjectArchived) { + t.Fatal("archived issue", err) + } + if _, err := s.IssueProjectExecutorCredential(keyAdminContext(ctx, project.ID), p, environment.ID, keyID, true); !errors.Is(err, ErrProjectArchived) { + t.Fatal("archived rotation", err) + } + if listed, err := s.ListProjectExecutorCredentials(ctx, p, environment.ID); err != nil || len(listed) != 1 { + t.Fatal("archived list", listed, err) + } + if err := s.RevokeProjectExecutorCredential(keyAdminContext(ctx, project.ID), p, environment.ID, keyID); err != nil { + t.Fatal("archived revoke", err) + } + if listed, err := s.ListProjectExecutorCredentials(ctx, p, environment.ID); err != nil || len(listed) != 1 || listed[0].RevokedAt == nil { + t.Fatal("revoked list", listed, err) + } +} From b734d518fff6580cd643fe4115fb6d4737c4696a Mon Sep 17 00:00:00 2001 From: SaladDay <1203511142@qq.com> Date: Fri, 25 Sep 2026 14:55:26 +0000 Subject: [PATCH 14/30] Say "Core key only" in the /core/v1 operation descriptions The generated Core contract described its operations as deployment administrator only; it now names the Core key, as the docs do, and says the Project ID, not a key, selects the target space. openapi.yaml is unchanged. --- contracts/agents-api/core.openapi.yaml | 102 +++++++++--------- .../internal/api/admin_agents_routes.go | 6 +- .../api/admin_environment_templates_routes.go | 6 +- .../internal/api/admin_files_routes.go | 6 +- .../agents-api/internal/api/admin_history.go | 2 +- .../agents-api/internal/api/admin_runtime.go | 2 +- .../internal/api/admin_session_archive.go | 4 +- .../internal/api/admin_sessions_routes.go | 26 ++--- .../internal/api/admin_skills_routes.go | 16 +-- .../agents-api/internal/api/admin_summary.go | 2 +- .../internal/api/admin_vaults_routes.go | 12 +-- .../agents-api/internal/api/core_metrics.go | 2 +- .../internal/api/sandbox_manager.go | 12 +-- .../internal/api/sandbox_node_detail.go | 2 +- .../agents-api/internal/api/write_audit.go | 4 +- 15 files changed, 102 insertions(+), 102 deletions(-) diff --git a/contracts/agents-api/core.openapi.yaml b/contracts/agents-api/core.openapi.yaml index 2a84397a4..9dc0b5879 100644 --- a/contracts/agents-api/core.openapi.yaml +++ b/contracts/agents-api/core.openapi.yaml @@ -2818,7 +2818,7 @@ info: paths: /core/v1/audit-log: get: - description: Deployment administrator only. Newest-first cursor pagination of safe metadata. Actor labels are unverified console labels, not authorization identities. Request bodies and secrets are never recorded. + description: Core key only. Newest-first cursor pagination of safe metadata. Actor labels are unverified console labels, not authorization identities. Request bodies and secrets are never recorded. parameters: - description: Target Project ID in: query @@ -2881,7 +2881,7 @@ paths: - Core Administration /core/v1/metrics: get: - description: Deployment administrator only. Complete UTC buckets; unknown measurements are null. Samples are process-local and are not backfilled after a restart. + description: Core key only. Complete UTC buckets; unknown measurements are null. Samples are process-local and are not backfilled after a restart. parameters: - description: Time range (default 1h) enum: @@ -3047,7 +3047,7 @@ paths: - Administrator Projects /core/v1/projects/{project_id}/agents: get: - description: Deployment administrator only. Reuses the public resource projection and operation rules; the key selects the target space and does not authenticate. + description: Core key only. Reuses the public resource projection and operation rules; the Project ID selects the target space and does not authenticate. parameters: - description: Last Agent ID from the previous page in: query @@ -3102,7 +3102,7 @@ paths: - Agents /core/v1/projects/{project_id}/agents/{agent_id}: delete: - description: Deployment administrator only. Reuses the public resource projection and operation rules; the key selects the target space and does not authenticate. + description: Core key only. Reuses the public resource projection and operation rules; the Project ID selects the target space and does not authenticate. parameters: - description: Agent ID in: path @@ -3147,7 +3147,7 @@ paths: tags: - Agents get: - description: Deployment administrator only. Reuses the public resource projection and operation rules; the key selects the target space and does not authenticate. + description: Core key only. Reuses the public resource projection and operation rules; the Project ID selects the target space and does not authenticate. parameters: - description: Agent ID in: path @@ -3225,7 +3225,7 @@ paths: - Administrator Projects /core/v1/projects/{project_id}/environment-templates: get: - description: Deployment administrator only. Reuses the public resource projection and operation rules; the key selects the target space and does not authenticate. + description: Core key only. Reuses the public resource projection and operation rules; the Project ID selects the target space and does not authenticate. parameters: - description: Previous Template ID in: query @@ -3280,7 +3280,7 @@ paths: - Environment Templates /core/v1/projects/{project_id}/environment-templates/{environment_template_id}: delete: - description: Deployment administrator only. Reuses the public resource projection and operation rules; the key selects the target space and does not authenticate. + description: Core key only. Reuses the public resource projection and operation rules; the Project ID selects the target space and does not authenticate. parameters: - description: Template ID in: path @@ -3321,7 +3321,7 @@ paths: tags: - Environment Templates get: - description: Deployment administrator only. Reuses the public resource projection and operation rules; the key selects the target space and does not authenticate. + description: Core key only. Reuses the public resource projection and operation rules; the Project ID selects the target space and does not authenticate. parameters: - description: Template ID in: path @@ -3493,7 +3493,7 @@ paths: - Executor Credentials /core/v1/projects/{project_id}/files: get: - description: Deployment administrator only. Reuses the public resource projection and operation rules; the key selects the target space and does not authenticate. + description: Core key only. Reuses the public resource projection and operation rules; the Project ID selects the target space and does not authenticate. parameters: - description: Last File ID from the previous page in: query @@ -3557,7 +3557,7 @@ paths: - Files /core/v1/projects/{project_id}/files/{file_id}: delete: - description: Deployment administrator only. Reuses the public resource projection and operation rules; the key selects the target space and does not authenticate. + description: Core key only. Reuses the public resource projection and operation rules; the Project ID selects the target space and does not authenticate. parameters: - description: Source file ID in: path @@ -3602,7 +3602,7 @@ paths: tags: - Files get: - description: Deployment administrator only. Reuses the public resource projection and operation rules; the key selects the target space and does not authenticate. + description: Core key only. Reuses the public resource projection and operation rules; the Project ID selects the target space and does not authenticate. parameters: - description: Source file ID in: path @@ -3784,7 +3784,7 @@ paths: - Administrator Projects /core/v1/projects/{project_id}/resource-owners: get: - description: Deployment administrator only. The key path selects its independent space. Returns null for resources without recorded creation provenance, including historical and foreign resources. No key secret is returned. + description: Core key only. The Project ID path selects its space. Returns null for resources without recorded creation provenance, including historical and foreign resources. No key secret is returned. parameters: - description: Project ID in: path @@ -3831,7 +3831,7 @@ paths: - Write Audit /core/v1/projects/{project_id}/sessions: get: - description: Deployment administrator only. Reuses the public resource projection and operation rules; the key selects the target space and does not authenticate. + description: Core key only. Reuses the public resource projection and operation rules; the Project ID selects the target space and does not authenticate. parameters: - description: Root Agent ID whose Sessions to return in: query @@ -3890,7 +3890,7 @@ paths: - Sessions /core/v1/projects/{project_id}/sessions/{session_id}: delete: - description: Deployment administrator only. Reuses the public resource projection and operation rules; the key selects the target space and does not authenticate. + description: Core key only. Reuses the public resource projection and operation rules; the Project ID selects the target space and does not authenticate. parameters: - description: Session ID in: path @@ -3939,7 +3939,7 @@ paths: tags: - Sessions get: - description: Deployment administrator only. Reuses the public resource projection and operation rules; the key selects the target space and does not authenticate. + description: Core key only. Reuses the public resource projection and operation rules; the Project ID selects the target space and does not authenticate. parameters: - description: Session ID in: path @@ -3981,7 +3981,7 @@ paths: - Sessions /core/v1/projects/{project_id}/sessions/{session_id}/archive: get: - description: Deployment administrator only. Reports actual resource disposition, including expiry and failure cleanup. This is not archive provenance and does not assert active Turn settlement. Read this after an uncertain archive response; never infer released from a missing sandbox alone. + description: Core key only. Reports actual resource disposition, including expiry and failure cleanup. This is not archive provenance and does not assert active Turn settlement. Read this after an uncertain archive response; never infer released from a missing sandbox alone. parameters: - description: Project ID in: path @@ -4028,7 +4028,7 @@ paths: post: consumes: - application/json - description: Deployment administrator only. Requires maintenance and the current deployment generation. Permanently closes execution, requests cancellation and releases sandbox/snapshots through existing cleanup. Session history and persisted files/artifacts remain; unpersisted workspace contents are lost. A cleanup_pending response is not proof of resource release. Does not affect caller-managed Runtime. + description: Core key only. Requires maintenance and the current deployment generation. Permanently closes execution, requests cancellation and releases sandbox/snapshots through existing cleanup. Session history and persisted files/artifacts remain; unpersisted workspace contents are lost. A cleanup_pending response is not proof of resource release. Does not affect caller-managed Runtime. parameters: - description: Project ID in: path @@ -4088,7 +4088,7 @@ paths: - Core Administration /core/v1/projects/{project_id}/sessions/{session_id}/artifacts: get: - description: Deployment administrator only. Reuses the public resource projection and operation rules; the key selects the target space and does not authenticate. + description: Core key only. Reuses the public resource projection and operation rules; the Project ID selects the target space and does not authenticate. parameters: - description: Session ID in: path @@ -4157,7 +4157,7 @@ paths: - Artifacts /core/v1/projects/{project_id}/sessions/{session_id}/artifacts/{artifact_id}: delete: - description: Deployment administrator only. Reuses the public resource projection and operation rules; the key selects the target space and does not authenticate. + description: Core key only. Reuses the public resource projection and operation rules; the Project ID selects the target space and does not authenticate. parameters: - description: Session ID in: path @@ -4207,7 +4207,7 @@ paths: tags: - Artifacts get: - description: Deployment administrator only. Reuses the public resource projection and operation rules; the key selects the target space and does not authenticate. + description: Core key only. Reuses the public resource projection and operation rules; the Project ID selects the target space and does not authenticate. parameters: - description: Session ID in: path @@ -4258,7 +4258,7 @@ paths: - Artifacts /core/v1/projects/{project_id}/sessions/{session_id}/artifacts/{artifact_id}/content: get: - description: Deployment administrator only. Reuses the public resource projection and operation rules; the key selects the target space and does not authenticate. + description: Core key only. Reuses the public resource projection and operation rules; the Project ID selects the target space and does not authenticate. parameters: - description: Session ID in: path @@ -4309,7 +4309,7 @@ paths: - Artifacts /core/v1/projects/{project_id}/sessions/{session_id}/execution-configuration: get: - description: Deployment administrator only; the Project ID selects the target space and does not authenticate. Returns the committed model, harness and safe provider selection with recorded sources. This read never decrypts credentials, resolves current defaults or probes execution health. Deployment provider details remain redacted. Historical provenance and missing provider projections are explicitly unknown/unavailable. + description: Core key only; the Project ID selects the target space and does not authenticate. Returns the committed model, harness and safe provider selection with recorded sources. This read never decrypts credentials, resolves current defaults or probes execution health. Deployment provider details remain redacted. Historical provenance and missing provider projections are explicitly unknown/unavailable. parameters: - description: Session ID in: path @@ -4355,7 +4355,7 @@ paths: - Core extensions /core/v1/projects/{project_id}/sessions/{session_id}/items: get: - description: Deployment administrator only. Reuses the public resource projection and operation rules; the key selects the target space and does not authenticate. + description: Core key only. Reuses the public resource projection and operation rules; the Project ID selects the target space and does not authenticate. parameters: - description: Session ID in: path @@ -4415,7 +4415,7 @@ paths: - Items /core/v1/projects/{project_id}/sessions/{session_id}/runtime-history: get: - description: Deployment administrator only; the Project ID selects the target space and does not authenticate. Returns stored Runtime observations for one Session. End is exclusive; the server selects a bounded resolution. Responses contain at most 1,000 series, 10,000 points per coverage/series array, and 100,000 total coverage plus series points. It never reads or changes live compute. + description: Core key only; the Project ID selects the target space and does not authenticate. Returns stored Runtime observations for one Session. End is exclusive; the server selects a bounded resolution. Responses contain at most 1,000 series, 10,000 points per coverage/series array, and 100,000 total coverage plus series points. It never reads or changes live compute. parameters: - description: Session ID in: path @@ -4481,7 +4481,7 @@ paths: - Runtime history /core/v1/projects/{project_id}/sessions/{session_id}/runtime-observation: get: - description: Deployment administrator only; the Project ID selects the target space and does not authenticate. Returns one read-only current Runtime observation. It never provisions, renews, restarts, pauses or stops compute. + description: Core key only; the Project ID selects the target space and does not authenticate. Returns one read-only current Runtime observation. It never provisions, renews, restarts, pauses or stops compute. parameters: - description: Session ID in: path @@ -4527,7 +4527,7 @@ paths: - Runtime observations /core/v1/projects/{project_id}/sessions/{session_id}/turns: get: - description: Deployment administrator only. Reuses the public resource projection and operation rules; the key selects the target space and does not authenticate. + description: Core key only. Reuses the public resource projection and operation rules; the Project ID selects the target space and does not authenticate. parameters: - description: Session ID in: path @@ -4588,7 +4588,7 @@ paths: - Turns /core/v1/projects/{project_id}/sessions/{session_id}/turns/{turn_id}: get: - description: Deployment administrator only. Reuses the public resource projection and operation rules; the key selects the target space and does not authenticate. + description: Core key only. Reuses the public resource projection and operation rules; the Project ID selects the target space and does not authenticate. parameters: - description: Session ID in: path @@ -4635,7 +4635,7 @@ paths: - Turns /core/v1/projects/{project_id}/skills: get: - description: Deployment administrator only. Reuses the public resource projection and operation rules; the key selects the target space and does not authenticate. + description: Core key only. Reuses the public resource projection and operation rules; the Project ID selects the target space and does not authenticate. parameters: - description: Skill resource cursor in: query @@ -4674,7 +4674,7 @@ paths: - Skills /core/v1/projects/{project_id}/skills/{skill_id}: delete: - description: Deployment administrator only. Reuses the public resource projection and operation rules; the key selects the target space and does not authenticate. + description: Core key only. Reuses the public resource projection and operation rules; the Project ID selects the target space and does not authenticate. parameters: - description: Skill ID in: path @@ -4699,7 +4699,7 @@ paths: tags: - Skills get: - description: Deployment administrator only. Reuses the public resource projection and operation rules; the key selects the target space and does not authenticate. + description: Core key only. Reuses the public resource projection and operation rules; the Project ID selects the target space and does not authenticate. parameters: - description: Skill ID in: path @@ -4725,7 +4725,7 @@ paths: - Skills /core/v1/projects/{project_id}/skills/{skill_id}/content: get: - description: Deployment administrator only. Reuses the public resource projection and operation rules; the key selects the target space and does not authenticate. + description: Core key only. Reuses the public resource projection and operation rules; the Project ID selects the target space and does not authenticate. parameters: - description: Skill ID in: path @@ -4751,7 +4751,7 @@ paths: - Skills /core/v1/projects/{project_id}/skills/{skill_id}/versions: get: - description: Deployment administrator only. Reuses the public resource projection and operation rules; the key selects the target space and does not authenticate. + description: Core key only. Reuses the public resource projection and operation rules; the Project ID selects the target space and does not authenticate. parameters: - description: Skill ID in: path @@ -4795,7 +4795,7 @@ paths: - Skills /core/v1/projects/{project_id}/skills/{skill_id}/versions/{version}: delete: - description: Deployment administrator only. Reuses the public resource projection and operation rules; the key selects the target space and does not authenticate. + description: Core key only. Reuses the public resource projection and operation rules; the Project ID selects the target space and does not authenticate. parameters: - description: Skill ID in: path @@ -4825,7 +4825,7 @@ paths: tags: - Skills get: - description: Deployment administrator only. Reuses the public resource projection and operation rules; the key selects the target space and does not authenticate. + description: Core key only. Reuses the public resource projection and operation rules; the Project ID selects the target space and does not authenticate. parameters: - description: Skill ID in: path @@ -4856,7 +4856,7 @@ paths: - Skills /core/v1/projects/{project_id}/skills/{skill_id}/versions/{version}/content: get: - description: Deployment administrator only. Reuses the public resource projection and operation rules; the key selects the target space and does not authenticate. + description: Core key only. Reuses the public resource projection and operation rules; the Project ID selects the target space and does not authenticate. parameters: - description: Skill ID in: path @@ -4887,7 +4887,7 @@ paths: - Skills /core/v1/projects/{project_id}/vaults: get: - description: Deployment administrator only. Reuses the public resource projection and operation rules; the key selects the target space and does not authenticate. + description: Core key only. Reuses the public resource projection and operation rules; the Project ID selects the target space and does not authenticate. parameters: - description: Last Vault ID from the previous page in: query @@ -4958,7 +4958,7 @@ paths: - Vaults /core/v1/projects/{project_id}/vaults/{vault_id}: delete: - description: Deployment administrator only. Reuses the public resource projection and operation rules; the key selects the target space and does not authenticate. + description: Core key only. Reuses the public resource projection and operation rules; the Project ID selects the target space and does not authenticate. parameters: - description: Vault ID in: path @@ -5003,7 +5003,7 @@ paths: tags: - Vaults get: - description: Deployment administrator only. Reuses the public resource projection and operation rules; the key selects the target space and does not authenticate. + description: Core key only. Reuses the public resource projection and operation rules; the Project ID selects the target space and does not authenticate. parameters: - description: Vault ID in: path @@ -5045,7 +5045,7 @@ paths: - Vaults /core/v1/projects/{project_id}/vaults/{vault_id}/credentials: get: - description: Deployment administrator only. Reuses the public resource projection and operation rules; the key selects the target space and does not authenticate. + description: Core key only. Reuses the public resource projection and operation rules; the Project ID selects the target space and does not authenticate. parameters: - description: Vault ID in: path @@ -5121,7 +5121,7 @@ paths: - Credentials /core/v1/projects/{project_id}/vaults/{vault_id}/credentials/{credential_id}: delete: - description: Deployment administrator only. Reuses the public resource projection and operation rules; the key selects the target space and does not authenticate. + description: Core key only. Reuses the public resource projection and operation rules; the Project ID selects the target space and does not authenticate. parameters: - description: Vault ID in: path @@ -5171,7 +5171,7 @@ paths: tags: - Credentials get: - description: Deployment administrator only. Reuses the public resource projection and operation rules; the key selects the target space and does not authenticate. + description: Core key only. Reuses the public resource projection and operation rules; the Project ID selects the target space and does not authenticate. parameters: - description: Vault ID in: path @@ -5218,7 +5218,7 @@ paths: - Credentials /core/v1/projects/{project_id}/write-operations: get: - description: Deployment administrator only. Reverse chronological keyset pagination over committed writes. Creation records remain; other records follow configured retention. The key path selects its independent space, never a caller-supplied tenant. + description: Core key only. Reverse chronological keyset pagination over committed writes. Creation records remain; other records follow configured retention. The key path selects its independent space, never a caller-supplied tenant. parameters: - description: Project ID in: path @@ -5283,7 +5283,7 @@ paths: - Write Audit /core/v1/sandbox/deployment: get: - description: Core deployment extension. Does not grant project resource access. Responses contain only explicit safe fields. E2B template_build values are those Core read when the selection was saved; this read does not call E2B. + description: Core key only. Does not grant project resource access. Responses contain only explicit safe fields. E2B template_build values are those Core read when the selection was saved; this read does not call E2B. produces: - application/json responses: @@ -5454,7 +5454,7 @@ paths: post: consumes: - application/json - description: Core deployment extension. Does not grant project resource access. Responses contain only explicit safe fields. + description: Core key only. Does not grant project resource access. Responses contain only explicit safe fields. parameters: - description: Request in: body @@ -5500,7 +5500,7 @@ paths: - Sandbox Manager /core/v1/sandbox/nodes: get: - description: Core deployment extension. Does not grant project resource access. Responses contain only explicit safe fields. + description: Core key only. Does not grant project resource access. Responses contain only explicit safe fields. produces: - application/json responses: @@ -5539,7 +5539,7 @@ paths: - Sandbox Manager /core/v1/sandbox/nodes/{node_id}: delete: - description: Core deployment extension. Does not grant project resource access. Responses contain only explicit safe fields. + description: Core key only. Does not grant project resource access. Responses contain only explicit safe fields. parameters: - description: Sandbox node UUID in: path @@ -5583,7 +5583,7 @@ paths: tags: - Sandbox Manager get: - description: Deployment administrator only. Complete UTC buckets. Missing host measurements and offline history are null; reads never sample or backfill. + description: Core key only. Complete UTC buckets. Missing host measurements and offline history are null; reads never sample or backfill. parameters: - description: Sandbox node UUID in: path @@ -5633,7 +5633,7 @@ paths: patch: consumes: - application/json - description: Core deployment extension. Does not grant project resource access. Responses contain only explicit safe fields. + description: Core key only. Does not grant project resource access. Responses contain only explicit safe fields. parameters: - description: Sandbox node UUID in: path @@ -5684,7 +5684,7 @@ paths: - Sandbox Manager /core/v1/sandbox/nodes/{node_id}/allocations: get: - description: Core deployment extension. Does not grant project resource access. Responses contain only explicit safe fields. + description: Core key only. Does not grant project resource access. Responses contain only explicit safe fields. parameters: - description: Sandbox node UUID in: path @@ -5729,7 +5729,7 @@ paths: - Sandbox Manager /core/v1/sandbox/runtime-observations: get: - description: Deployment administrator only. Each observation is labelled with its owning Project ID. Uses the existing read-only Runtime sampler, with bounded concurrency and no execution or provisioning. A provider with a batch metrics read, such as E2B, samples the page's running sandboxes in one bounded request. + description: Core key only. Each observation is labelled with its owning Project ID. Uses the existing read-only Runtime sampler, with bounded concurrency and no execution or provisioning. A provider with a batch metrics read, such as E2B, samples the page's running sandboxes in one bounded request. parameters: - description: Last Session ID from the preceding page in: query @@ -5784,7 +5784,7 @@ paths: - Core Administration /core/v1/summary: get: - description: Administrator only. after/limit/order paginate Projects. Agent grouping returns groups within those spaces. Date bounds filter Session creation, not current asset counts. Usage sums only non-null public Session usage; coverage includes every selected Session. Each Project is read in a consistent database snapshot. Totals are not billing records. + description: Core key only. after/limit/order paginate Projects. Agent grouping returns groups within those spaces. Date bounds filter Session creation, not current asset counts. Usage sums only non-null public Session usage; coverage includes every selected Session. Each Project is read in a consistent database snapshot. Totals are not billing records. parameters: - description: One API Project in: query diff --git a/services/agents-api/internal/api/admin_agents_routes.go b/services/agents-api/internal/api/admin_agents_routes.go index 13ff02bc5..8dc8bf665 100644 --- a/services/agents-api/internal/api/admin_agents_routes.go +++ b/services/agents-api/internal/api/admin_agents_routes.go @@ -3,7 +3,7 @@ package api import "net/http" // @Summary List reusable Agents in a managed key space -// @Description Deployment administrator only. Reuses the public resource projection and operation rules; the key selects the target space and does not authenticate. +// @Description Core key only. Reuses the public resource projection and operation rules; the Project ID selects the target space and does not authenticate. // @Tags Agents // @Produce json // @Security DeploymentAdminAuth @@ -19,7 +19,7 @@ func (h *Handler) adminListAgents(w http.ResponseWriter, r *http.Request) { } // @Summary Retrieve a reusable Agent in a managed key space -// @Description Deployment administrator only. Reuses the public resource projection and operation rules; the key selects the target space and does not authenticate. +// @Description Core key only. Reuses the public resource projection and operation rules; the Project ID selects the target space and does not authenticate. // @Tags Agents // @Produce json // @Security DeploymentAdminAuth @@ -33,7 +33,7 @@ func (h *Handler) adminGetAgent(w http.ResponseWriter, r *http.Request) { } // @Summary Delete a reusable Agent in a managed key space -// @Description Deployment administrator only. Reuses the public resource projection and operation rules; the key selects the target space and does not authenticate. +// @Description Core key only. Reuses the public resource projection and operation rules; the Project ID selects the target space and does not authenticate. // @Tags Agents // @Produce json // @Security DeploymentAdminAuth diff --git a/services/agents-api/internal/api/admin_environment_templates_routes.go b/services/agents-api/internal/api/admin_environment_templates_routes.go index d9fb70232..b50f9a83a 100644 --- a/services/agents-api/internal/api/admin_environment_templates_routes.go +++ b/services/agents-api/internal/api/admin_environment_templates_routes.go @@ -3,7 +3,7 @@ package api import "net/http" // @Summary List Environment Templates in a managed key space -// @Description Deployment administrator only. Reuses the public resource projection and operation rules; the key selects the target space and does not authenticate. +// @Description Core key only. Reuses the public resource projection and operation rules; the Project ID selects the target space and does not authenticate. // @Tags Environment Templates // @Produce json // @Security DeploymentAdminAuth @@ -19,7 +19,7 @@ func (h *Handler) adminListEnvironmentTemplates(w http.ResponseWriter, r *http.R } // @Summary Retrieve an Environment Template in a managed key space -// @Description Deployment administrator only. Reuses the public resource projection and operation rules; the key selects the target space and does not authenticate. +// @Description Core key only. Reuses the public resource projection and operation rules; the Project ID selects the target space and does not authenticate. // @Tags Environment Templates // @Produce json // @Security DeploymentAdminAuth @@ -33,7 +33,7 @@ func (h *Handler) adminGetEnvironmentTemplate(w http.ResponseWriter, r *http.Req } // @Summary Delete an Environment Template in a managed key space -// @Description Deployment administrator only. Reuses the public resource projection and operation rules; the key selects the target space and does not authenticate. +// @Description Core key only. Reuses the public resource projection and operation rules; the Project ID selects the target space and does not authenticate. // @Tags Environment Templates // @Produce json // @Security DeploymentAdminAuth diff --git a/services/agents-api/internal/api/admin_files_routes.go b/services/agents-api/internal/api/admin_files_routes.go index e10de9fcb..bfeb9574f 100644 --- a/services/agents-api/internal/api/admin_files_routes.go +++ b/services/agents-api/internal/api/admin_files_routes.go @@ -3,7 +3,7 @@ package api import "net/http" // @Summary List source files in a managed key space -// @Description Deployment administrator only. Reuses the public resource projection and operation rules; the key selects the target space and does not authenticate. +// @Description Core key only. Reuses the public resource projection and operation rules; the Project ID selects the target space and does not authenticate. // @Tags Files // @Produce json // @Security DeploymentAdminAuth @@ -20,7 +20,7 @@ func (h *Handler) adminListSourceFiles(w http.ResponseWriter, r *http.Request) { } // @Summary Retrieve source file metadata in a managed key space -// @Description Deployment administrator only. Reuses the public resource projection and operation rules; the key selects the target space and does not authenticate. +// @Description Core key only. Reuses the public resource projection and operation rules; the Project ID selects the target space and does not authenticate. // @Tags Files // @Produce json // @Security DeploymentAdminAuth @@ -34,7 +34,7 @@ func (h *Handler) adminGetSourceFile(w http.ResponseWriter, r *http.Request) { } // @Summary Delete a source file in a managed key space -// @Description Deployment administrator only. Reuses the public resource projection and operation rules; the key selects the target space and does not authenticate. +// @Description Core key only. Reuses the public resource projection and operation rules; the Project ID selects the target space and does not authenticate. // @Tags Files // @Produce json // @Security DeploymentAdminAuth diff --git a/services/agents-api/internal/api/admin_history.go b/services/agents-api/internal/api/admin_history.go index 4789fd15f..ca3d26551 100644 --- a/services/agents-api/internal/api/admin_history.go +++ b/services/agents-api/internal/api/admin_history.go @@ -9,7 +9,7 @@ import ( ) // @Summary Query committed administrator mutations -// @Description Deployment administrator only. Newest-first cursor pagination of safe metadata. Actor labels are unverified console labels, not authorization identities. Request bodies and secrets are never recorded. +// @Description Core key only. Newest-first cursor pagination of safe metadata. Actor labels are unverified console labels, not authorization identities. Request bodies and secrets are never recorded. // @Tags Core Administration // @Produce json // @Security DeploymentAdminAuth diff --git a/services/agents-api/internal/api/admin_runtime.go b/services/agents-api/internal/api/admin_runtime.go index 824f6c7b3..82bb03bd9 100644 --- a/services/agents-api/internal/api/admin_runtime.go +++ b/services/agents-api/internal/api/admin_runtime.go @@ -36,7 +36,7 @@ type AdminRuntimeObservationList struct { } // @Summary List Runtime observations across managed Projects -// @Description Deployment administrator only. Each observation is labelled with its owning Project ID. Uses the existing read-only Runtime sampler, with bounded concurrency and no execution or provisioning. A provider with a batch metrics read, such as E2B, samples the page's running sandboxes in one bounded request. +// @Description Core key only. Each observation is labelled with its owning Project ID. Uses the existing read-only Runtime sampler, with bounded concurrency and no execution or provisioning. A provider with a batch metrics read, such as E2B, samples the page's running sandboxes in one bounded request. // @Tags Core Administration // @Produce json // @Security DeploymentAdminAuth diff --git a/services/agents-api/internal/api/admin_session_archive.go b/services/agents-api/internal/api/admin_session_archive.go index cdfcec9a3..1b44122b7 100644 --- a/services/agents-api/internal/api/admin_session_archive.go +++ b/services/agents-api/internal/api/admin_session_archive.go @@ -18,7 +18,7 @@ type AdminSessionArchiveRequest struct { } // @Summary Release a managed Session's execution resources while retaining history -// @Description Deployment administrator only. Requires maintenance and the current deployment generation. Permanently closes execution, requests cancellation and releases sandbox/snapshots through existing cleanup. Session history and persisted files/artifacts remain; unpersisted workspace contents are lost. A cleanup_pending response is not proof of resource release. Does not affect caller-managed Runtime. +// @Description Core key only. Requires maintenance and the current deployment generation. Permanently closes execution, requests cancellation and releases sandbox/snapshots through existing cleanup. Session history and persisted files/artifacts remain; unpersisted workspace contents are lost. A cleanup_pending response is not proof of resource release. Does not affect caller-managed Runtime. // @Tags Core Administration // @Accept json // @Produce json @@ -52,7 +52,7 @@ func (h *Handler) adminArchiveSession(w http.ResponseWriter, r *http.Request) { } // @Summary Retrieve a managed Session's resource cleanup state -// @Description Deployment administrator only. Reports actual resource disposition, including expiry and failure cleanup. This is not archive provenance and does not assert active Turn settlement. Read this after an uncertain archive response; never infer released from a missing sandbox alone. +// @Description Core key only. Reports actual resource disposition, including expiry and failure cleanup. This is not archive provenance and does not assert active Turn settlement. Read this after an uncertain archive response; never infer released from a missing sandbox alone. // @Tags Core Administration // @Produce json // @Security DeploymentAdminAuth diff --git a/services/agents-api/internal/api/admin_sessions_routes.go b/services/agents-api/internal/api/admin_sessions_routes.go index c145df724..660a0e3f0 100644 --- a/services/agents-api/internal/api/admin_sessions_routes.go +++ b/services/agents-api/internal/api/admin_sessions_routes.go @@ -3,7 +3,7 @@ package api import "net/http" // @Summary List execution Sessions in a managed key space -// @Description Deployment administrator only. Reuses the public resource projection and operation rules; the key selects the target space and does not authenticate. +// @Description Core key only. Reuses the public resource projection and operation rules; the Project ID selects the target space and does not authenticate. // @Tags Sessions // @Produce json // @Security DeploymentAdminAuth @@ -20,7 +20,7 @@ func (h *Handler) adminListSessions(w http.ResponseWriter, r *http.Request) { } // @Summary Retrieve an execution Session in a managed key space -// @Description Deployment administrator only. Reuses the public resource projection and operation rules; the key selects the target space and does not authenticate. +// @Description Core key only. Reuses the public resource projection and operation rules; the Project ID selects the target space and does not authenticate. // @Tags Sessions // @Produce json // @Security DeploymentAdminAuth @@ -34,7 +34,7 @@ func (h *Handler) adminGetSession(w http.ResponseWriter, r *http.Request) { } // @Summary Delete an execution Session in a managed key space -// @Description Deployment administrator only. Reuses the public resource projection and operation rules; the key selects the target space and does not authenticate. +// @Description Core key only. Reuses the public resource projection and operation rules; the Project ID selects the target space and does not authenticate. // @Tags Sessions // @Produce json // @Security DeploymentAdminAuth @@ -48,7 +48,7 @@ func (h *Handler) adminDeleteSession(w http.ResponseWriter, r *http.Request) { } // @Summary List execution Turns in a managed key space -// @Description Deployment administrator only. Reuses the public resource projection and operation rules; the key selects the target space and does not authenticate. +// @Description Core key only. Reuses the public resource projection and operation rules; the Project ID selects the target space and does not authenticate. // @Tags Turns // @Produce json // @Security DeploymentAdminAuth @@ -65,7 +65,7 @@ func (h *Handler) adminListTurns(w http.ResponseWriter, r *http.Request) { } // @Summary Retrieve an execution Turn in a managed key space -// @Description Deployment administrator only. Reuses the public resource projection and operation rules; the key selects the target space and does not authenticate. +// @Description Core key only. Reuses the public resource projection and operation rules; the Project ID selects the target space and does not authenticate. // @Tags Turns // @Produce json // @Security DeploymentAdminAuth @@ -80,7 +80,7 @@ func (h *Handler) adminGetTurn(w http.ResponseWriter, r *http.Request) { } // @Summary List persisted execution Items in a managed key space -// @Description Deployment administrator only. Reuses the public resource projection and operation rules; the key selects the target space and does not authenticate. +// @Description Core key only. Reuses the public resource projection and operation rules; the Project ID selects the target space and does not authenticate. // @Tags Items // @Produce json // @Security DeploymentAdminAuth @@ -97,7 +97,7 @@ func (h *Handler) adminListItems(w http.ResponseWriter, r *http.Request) { } // @Summary List immutable Session artifacts in a managed key space -// @Description Deployment administrator only. Reuses the public resource projection and operation rules; the key selects the target space and does not authenticate. +// @Description Core key only. Reuses the public resource projection and operation rules; the Project ID selects the target space and does not authenticate. // @Tags Artifacts // @Produce json // @Security DeploymentAdminAuth @@ -115,7 +115,7 @@ func (h *Handler) adminListSessionArtifacts(w http.ResponseWriter, r *http.Reque } // @Summary Retrieve immutable artifact metadata in a managed key space -// @Description Deployment administrator only. Reuses the public resource projection and operation rules; the key selects the target space and does not authenticate. +// @Description Core key only. Reuses the public resource projection and operation rules; the Project ID selects the target space and does not authenticate. // @Tags Artifacts // @Produce json // @Security DeploymentAdminAuth @@ -130,7 +130,7 @@ func (h *Handler) adminGetSessionArtifact(w http.ResponseWriter, r *http.Request } // @Summary Delete a published artifact in a managed key space -// @Description Deployment administrator only. Reuses the public resource projection and operation rules; the key selects the target space and does not authenticate. +// @Description Core key only. Reuses the public resource projection and operation rules; the Project ID selects the target space and does not authenticate. // @Tags Artifacts // @Produce json // @Security DeploymentAdminAuth @@ -145,7 +145,7 @@ func (h *Handler) adminDeleteSessionArtifact(w http.ResponseWriter, r *http.Requ } // @Summary Download immutable artifact bytes in a managed key space -// @Description Deployment administrator only. Reuses the public resource projection and operation rules; the key selects the target space and does not authenticate. +// @Description Core key only. Reuses the public resource projection and operation rules; the Project ID selects the target space and does not authenticate. // @Tags Artifacts // @Produce octet-stream // @Security DeploymentAdminAuth @@ -160,7 +160,7 @@ func (h *Handler) adminSessionArtifactContent(w http.ResponseWriter, r *http.Req } // @Summary Retrieve frozen Session execution configuration in a managed key space -// @Description Deployment administrator only; the Project ID selects the target space and does not authenticate. Returns the committed model, harness and safe provider selection with recorded sources. This read never decrypts credentials, resolves current defaults or probes execution health. Deployment provider details remain redacted. Historical provenance and missing provider projections are explicitly unknown/unavailable. +// @Description Core key only; the Project ID selects the target space and does not authenticate. Returns the committed model, harness and safe provider selection with recorded sources. This read never decrypts credentials, resolves current defaults or probes execution health. Deployment provider details remain redacted. Historical provenance and missing provider projections are explicitly unknown/unavailable. // @Tags Core extensions // @Produce json // @Security DeploymentAdminAuth @@ -174,7 +174,7 @@ func (h *Handler) adminGetSessionExecutionConfiguration(w http.ResponseWriter, r } // @Summary Retrieve a Session Runtime observation in a managed key space -// @Description Deployment administrator only; the Project ID selects the target space and does not authenticate. Returns one read-only current Runtime observation. It never provisions, renews, restarts, pauses or stops compute. +// @Description Core key only; the Project ID selects the target space and does not authenticate. Returns one read-only current Runtime observation. It never provisions, renews, restarts, pauses or stops compute. // @Tags Runtime observations // @Produce json // @Security DeploymentAdminAuth @@ -188,7 +188,7 @@ func (h *Handler) adminGetRuntimeObservation(w http.ResponseWriter, r *http.Requ } // @Summary Retrieve Session Runtime history in a managed key space -// @Description Deployment administrator only; the Project ID selects the target space and does not authenticate. Returns stored Runtime observations for one Session. End is exclusive; the server selects a bounded resolution. Responses contain at most 1,000 series, 10,000 points per coverage/series array, and 100,000 total coverage plus series points. It never reads or changes live compute. +// @Description Core key only; the Project ID selects the target space and does not authenticate. Returns stored Runtime observations for one Session. End is exclusive; the server selects a bounded resolution. Responses contain at most 1,000 series, 10,000 points per coverage/series array, and 100,000 total coverage plus series points. It never reads or changes live compute. // @Tags Runtime history // @Produce json // @Security DeploymentAdminAuth diff --git a/services/agents-api/internal/api/admin_skills_routes.go b/services/agents-api/internal/api/admin_skills_routes.go index 1f6d1d997..7ba03e6c6 100644 --- a/services/agents-api/internal/api/admin_skills_routes.go +++ b/services/agents-api/internal/api/admin_skills_routes.go @@ -3,7 +3,7 @@ package api import "net/http" // @Summary List Skills in a managed key space -// @Description Deployment administrator only. Reuses the public resource projection and operation rules; the key selects the target space and does not authenticate. +// @Description Core key only. Reuses the public resource projection and operation rules; the Project ID selects the target space and does not authenticate. // @Tags Skills // @Produce json // @Security DeploymentAdminAuth @@ -18,7 +18,7 @@ func (h *Handler) adminListSkills(w http.ResponseWriter, r *http.Request) { } // @Summary Retrieve Skill metadata in a managed key space -// @Description Deployment administrator only. Reuses the public resource projection and operation rules; the key selects the target space and does not authenticate. +// @Description Core key only. Reuses the public resource projection and operation rules; the Project ID selects the target space and does not authenticate. // @Tags Skills // @Produce json // @Security DeploymentAdminAuth @@ -31,7 +31,7 @@ func (h *Handler) adminGetSkill(w http.ResponseWriter, r *http.Request) { } // @Summary Delete a Skill and its versions in a managed key space -// @Description Deployment administrator only. Reuses the public resource projection and operation rules; the key selects the target space and does not authenticate. +// @Description Core key only. Reuses the public resource projection and operation rules; the Project ID selects the target space and does not authenticate. // @Tags Skills // @Produce json // @Security DeploymentAdminAuth @@ -44,7 +44,7 @@ func (h *Handler) adminDeleteSkill(w http.ResponseWriter, r *http.Request) { } // @Summary Download Skill content in a managed key space -// @Description Deployment administrator only. Reuses the public resource projection and operation rules; the key selects the target space and does not authenticate. +// @Description Core key only. Reuses the public resource projection and operation rules; the Project ID selects the target space and does not authenticate. // @Tags Skills // @Produce octet-stream // @Security DeploymentAdminAuth @@ -57,7 +57,7 @@ func (h *Handler) adminSkillContent(w http.ResponseWriter, r *http.Request) { } // @Summary List Skill versions in a managed key space -// @Description Deployment administrator only. Reuses the public resource projection and operation rules; the key selects the target space and does not authenticate. +// @Description Core key only. Reuses the public resource projection and operation rules; the Project ID selects the target space and does not authenticate. // @Tags Skills // @Produce json // @Security DeploymentAdminAuth @@ -73,7 +73,7 @@ func (h *Handler) adminListSkillVersions(w http.ResponseWriter, r *http.Request) } // @Summary Retrieve Skill version metadata in a managed key space -// @Description Deployment administrator only. Reuses the public resource projection and operation rules; the key selects the target space and does not authenticate. +// @Description Core key only. Reuses the public resource projection and operation rules; the Project ID selects the target space and does not authenticate. // @Tags Skills // @Produce json // @Security DeploymentAdminAuth @@ -87,7 +87,7 @@ func (h *Handler) adminGetSkillVersion(w http.ResponseWriter, r *http.Request) { } // @Summary Delete a Skill version in a managed key space -// @Description Deployment administrator only. Reuses the public resource projection and operation rules; the key selects the target space and does not authenticate. +// @Description Core key only. Reuses the public resource projection and operation rules; the Project ID selects the target space and does not authenticate. // @Tags Skills // @Produce json // @Security DeploymentAdminAuth @@ -101,7 +101,7 @@ func (h *Handler) adminDeleteSkillVersion(w http.ResponseWriter, r *http.Request } // @Summary Download immutable Skill version content in a managed key space -// @Description Deployment administrator only. Reuses the public resource projection and operation rules; the key selects the target space and does not authenticate. +// @Description Core key only. Reuses the public resource projection and operation rules; the Project ID selects the target space and does not authenticate. // @Tags Skills // @Produce octet-stream // @Security DeploymentAdminAuth diff --git a/services/agents-api/internal/api/admin_summary.go b/services/agents-api/internal/api/admin_summary.go index 00efc5994..ec6f4a6bd 100644 --- a/services/agents-api/internal/api/admin_summary.go +++ b/services/agents-api/internal/api/admin_summary.go @@ -54,7 +54,7 @@ func adminSummaryTime(r *http.Request, name string) (*time.Time, error) { } // @Summary Summarize resource counts and Session usage by Project, Agent or creator key -// @Description Administrator only. after/limit/order paginate Projects. Agent grouping returns groups within those spaces. Date bounds filter Session creation, not current asset counts. Usage sums only non-null public Session usage; coverage includes every selected Session. Each Project is read in a consistent database snapshot. Totals are not billing records. +// @Description Core key only. after/limit/order paginate Projects. Agent grouping returns groups within those spaces. Date bounds filter Session creation, not current asset counts. Usage sums only non-null public Session usage; coverage includes every selected Session. Each Project is read in a consistent database snapshot. Totals are not billing records. // @Tags Core Administration // @Produce json // @Security DeploymentAdminAuth diff --git a/services/agents-api/internal/api/admin_vaults_routes.go b/services/agents-api/internal/api/admin_vaults_routes.go index 70933bd82..c9d51a026 100644 --- a/services/agents-api/internal/api/admin_vaults_routes.go +++ b/services/agents-api/internal/api/admin_vaults_routes.go @@ -3,7 +3,7 @@ package api import "net/http" // @Summary List Vaults in a managed key space -// @Description Deployment administrator only. Reuses the public resource projection and operation rules; the key selects the target space and does not authenticate. +// @Description Core key only. Reuses the public resource projection and operation rules; the Project ID selects the target space and does not authenticate. // @Tags Vaults // @Produce json // @Security DeploymentAdminAuth @@ -21,7 +21,7 @@ func (h *Handler) adminListVaults(w http.ResponseWriter, r *http.Request) { } // @Summary Retrieve a Vault in a managed key space -// @Description Deployment administrator only. Reuses the public resource projection and operation rules; the key selects the target space and does not authenticate. +// @Description Core key only. Reuses the public resource projection and operation rules; the Project ID selects the target space and does not authenticate. // @Tags Vaults // @Produce json // @Security DeploymentAdminAuth @@ -35,7 +35,7 @@ func (h *Handler) adminGetVault(w http.ResponseWriter, r *http.Request) { } // @Summary Delete a Vault and all its Credentials in a managed key space -// @Description Deployment administrator only. Reuses the public resource projection and operation rules; the key selects the target space and does not authenticate. +// @Description Core key only. Reuses the public resource projection and operation rules; the Project ID selects the target space and does not authenticate. // @Tags Vaults // @Produce json // @Security DeploymentAdminAuth @@ -49,7 +49,7 @@ func (h *Handler) adminDeleteVault(w http.ResponseWriter, r *http.Request) { } // @Summary List safe Vault Credential metadata in a managed key space -// @Description Deployment administrator only. Reuses the public resource projection and operation rules; the key selects the target space and does not authenticate. +// @Description Core key only. Reuses the public resource projection and operation rules; the Project ID selects the target space and does not authenticate. // @Tags Credentials // @Produce json // @Security DeploymentAdminAuth @@ -68,7 +68,7 @@ func (h *Handler) adminListCredentials(w http.ResponseWriter, r *http.Request) { } // @Summary Retrieve safe Vault Credential metadata in a managed key space -// @Description Deployment administrator only. Reuses the public resource projection and operation rules; the key selects the target space and does not authenticate. +// @Description Core key only. Reuses the public resource projection and operation rules; the Project ID selects the target space and does not authenticate. // @Tags Credentials // @Produce json // @Security DeploymentAdminAuth @@ -83,7 +83,7 @@ func (h *Handler) adminGetCredential(w http.ResponseWriter, r *http.Request) { } // @Summary Delete a Vault Credential in a managed key space -// @Description Deployment administrator only. Reuses the public resource projection and operation rules; the key selects the target space and does not authenticate. +// @Description Core key only. Reuses the public resource projection and operation rules; the Project ID selects the target space and does not authenticate. // @Tags Credentials // @Produce json // @Security DeploymentAdminAuth diff --git a/services/agents-api/internal/api/core_metrics.go b/services/agents-api/internal/api/core_metrics.go index 7e776859c..7f19bf72e 100644 --- a/services/agents-api/internal/api/core_metrics.go +++ b/services/agents-api/internal/api/core_metrics.go @@ -18,7 +18,7 @@ func WithCoreMetrics(service CoreMetricsService) Option { } // @Summary Retrieve Core operational metrics -// @Description Deployment administrator only. Complete UTC buckets; unknown measurements are null. Samples are process-local and are not backfilled after a restart. +// @Description Core key only. Complete UTC buckets; unknown measurements are null. Samples are process-local and are not backfilled after a restart. // @Tags Core Administration // @Produce json // @Security DeploymentAdminAuth diff --git a/services/agents-api/internal/api/sandbox_manager.go b/services/agents-api/internal/api/sandbox_manager.go index 23ae7d73d..e26422e9d 100644 --- a/services/agents-api/internal/api/sandbox_manager.go +++ b/services/agents-api/internal/api/sandbox_manager.go @@ -63,7 +63,7 @@ func (h *Handler) registerSandboxManagerRoutes(r chi.Router) { } // @Summary Retrieve sandbox deployment -// @Description Core deployment extension. Does not grant project resource access. Responses contain only explicit safe fields. E2B template_build values are those Core read when the selection was saved; this read does not call E2B. +// @Description Core key only. Does not grant project resource access. Responses contain only explicit safe fields. E2B template_build values are those Core read when the selection was saved; this read does not call E2B. // @Tags Sandbox Manager // @Produce json // @Security DeploymentAdminAuth @@ -80,7 +80,7 @@ func (h *Handler) sandboxDeployment(w http.ResponseWriter, r *http.Request) { } // @Summary List deployment sandbox nodes -// @Description Core deployment extension. Does not grant project resource access. Responses contain only explicit safe fields. +// @Description Core key only. Does not grant project resource access. Responses contain only explicit safe fields. // @Tags Sandbox Manager // @Produce json // @Security DeploymentAdminAuth @@ -97,7 +97,7 @@ func (h *Handler) sandboxNodes(w http.ResponseWriter, r *http.Request) { } // @Summary Update sandbox node name and capacity -// @Description Core deployment extension. Does not grant project resource access. Responses contain only explicit safe fields. +// @Description Core key only. Does not grant project resource access. Responses contain only explicit safe fields. // @Tags Sandbox Manager // @Produce json // @Security DeploymentAdminAuth @@ -126,7 +126,7 @@ func (h *Handler) updateSandboxNode(w http.ResponseWriter, r *http.Request) { } // @Summary Remove a sandbox node with no retained resources -// @Description Core deployment extension. Does not grant project resource access. Responses contain only explicit safe fields. +// @Description Core key only. Does not grant project resource access. Responses contain only explicit safe fields. // @Tags Sandbox Manager // @Produce json // @Security DeploymentAdminAuth @@ -144,7 +144,7 @@ func (h *Handler) removeSandboxNode(w http.ResponseWriter, r *http.Request) { } // @Summary List retained allocations on a sandbox node -// @Description Core deployment extension. Does not grant project resource access. Responses contain only explicit safe fields. +// @Description Core key only. Does not grant project resource access. Responses contain only explicit safe fields. // @Tags Sandbox Manager // @Produce json // @Security DeploymentAdminAuth @@ -162,7 +162,7 @@ func (h *Handler) sandboxAllocations(w http.ResponseWriter, r *http.Request) { } // @Summary Create a ten-minute one-use node enrollment token -// @Description Core deployment extension. Does not grant project resource access. Responses contain only explicit safe fields. +// @Description Core key only. Does not grant project resource access. Responses contain only explicit safe fields. // @Tags Sandbox Manager // @Produce json // @Security DeploymentAdminAuth diff --git a/services/agents-api/internal/api/sandbox_node_detail.go b/services/agents-api/internal/api/sandbox_node_detail.go index c84df767c..3d47db1eb 100644 --- a/services/agents-api/internal/api/sandbox_node_detail.go +++ b/services/agents-api/internal/api/sandbox_node_detail.go @@ -11,7 +11,7 @@ import ( ) // @Summary Retrieve sandbox node and host history -// @Description Deployment administrator only. Complete UTC buckets. Missing host measurements and offline history are null; reads never sample or backfill. +// @Description Core key only. Complete UTC buckets. Missing host measurements and offline history are null; reads never sample or backfill. // @Tags Sandbox Manager // @Produce json // @Security DeploymentAdminAuth diff --git a/services/agents-api/internal/api/write_audit.go b/services/agents-api/internal/api/write_audit.go index aeba71f1c..eb3d88e88 100644 --- a/services/agents-api/internal/api/write_audit.go +++ b/services/agents-api/internal/api/write_audit.go @@ -57,7 +57,7 @@ func (h *Handler) writeAuditScope(w http.ResponseWriter, r *http.Request, allowe } // @Summary Batch lookup resource creation keys -// @Description Deployment administrator only. The key path selects its independent space. Returns null for resources without recorded creation provenance, including historical and foreign resources. No key secret is returned. +// @Description Core key only. The Project ID path selects its space. Returns null for resources without recorded creation provenance, including historical and foreign resources. No key secret is returned. // @Tags Write Audit // @Produce json // @Security DeploymentAdminAuth @@ -95,7 +95,7 @@ func (h *Handler) getResourceOwners(w http.ResponseWriter, r *http.Request) { } // @Summary Query API-key write operations -// @Description Deployment administrator only. Reverse chronological keyset pagination over committed writes. Creation records remain; other records follow configured retention. The key path selects its independent space, never a caller-supplied tenant. +// @Description Core key only. Reverse chronological keyset pagination over committed writes. Creation records remain; other records follow configured retention. The key path selects its independent space, never a caller-supplied tenant. // @Tags Write Audit // @Produce json // @Security DeploymentAdminAuth From 8f4be6565041ecadcedf4aaea9df29a3b2989474 Mon Sep 17 00:00:00 2001 From: SaladDay <1203511142@qq.com> Date: Fri, 25 Sep 2026 14:59:48 +0000 Subject: [PATCH 15/30] Check executor credential writes in the agreed order Issue and rotate now validate the request body (400), then the target Environment (404), then an archived Project (409 project_archived), and only then the key: 409 executor_credential_exists without rotate, or 404 for rotating a key_id that was never issued. Previously the Project was resolved before the body, and rotation checked the key before the archive. The in-transaction archive check stays for concurrent archives. --- .../environment-executor-credentials.md | 7 ++++ .../api/environment_executor_management.go | 10 +++-- .../environment_executor_management_test.go | 10 ++++- .../store/environment_executor_management.go | 41 ++++++++++++------- .../environment_executor_management_test.go | 21 +++++++--- 5 files changed, 65 insertions(+), 24 deletions(-) diff --git a/contracts/agents-api/environment-executor-credentials.md b/contracts/agents-api/environment-executor-credentials.md index 7ceb45ade..7b33194ba 100644 --- a/contracts/agents-api/environment-executor-credentials.md +++ b/contracts/agents-api/environment-executor-credentials.md @@ -46,6 +46,13 @@ whose `key_id` already exists and does not set `rotate:true`, even after revocation. `project_archived`: the Project is archived, so it gets no new or rotated credential; listing and revocation remain available there, because revoking must always work. + +An issuance or rotation is checked in this order, and the first failure is +returned: the request body (400); the target Environment, which must be a +`self_hosted` Environment of this Project whose Session exists (404); an archived +Project (409 `project_archived`); then the key itself (409 +`executor_credential_exists` without `rotate`, or 404 when rotating a `key_id` +that was never issued). Rotation replaces the secret of an existing key restricted to this Environment, keeps that Environment, invalidates the previous secret and restores a revoked key; rotating an unknown `key_id` returns 404. Revocation is idempotent and diff --git a/services/agents-api/internal/api/environment_executor_management.go b/services/agents-api/internal/api/environment_executor_management.go index b97e8f868..46bf5b156 100644 --- a/services/agents-api/internal/api/environment_executor_management.go +++ b/services/agents-api/internal/api/environment_executor_management.go @@ -80,10 +80,8 @@ func (h *Handler) listExecutorCredentials(w http.ResponseWriter, r *http.Request // @Failure 400,401,404,409,500 {object} v1.ErrorResponse // @Router /core/v1/projects/{project_id}/environments/{environment_id}/executor-credentials [post] func (h *Handler) issueExecutorCredential(w http.ResponseWriter, r *http.Request, s EnvironmentExecutorStore) { - binding, ok := h.adminProjectScope(w, r) - if !ok { - return - } + // Check order: request body (400), target (404), archived Project (409), + // then the key itself (409 exists, or 404 for rotating an unknown key). raw, ok := readJSONBody(w, r) if !ok { return @@ -94,6 +92,10 @@ func (h *Handler) issueExecutorCredential(w http.ResponseWriter, r *http.Request writeStoreError(w, r, store.ErrInvalidInput) return } + binding, ok := h.adminProjectScope(w, r) + if !ok { + return + } credential, err := s.IssueProjectExecutorCredential(r.Context(), binding.Principal, chi.URLParam(r, "environment_id"), input.KeyID, input.Rotate) if err != nil { writeStoreError(w, r, err) diff --git a/services/agents-api/internal/api/environment_executor_management_test.go b/services/agents-api/internal/api/environment_executor_management_test.go index e8a5d6a96..d5c0e73c5 100644 --- a/services/agents-api/internal/api/environment_executor_management_test.go +++ b/services/agents-api/internal/api/environment_executor_management_test.go @@ -72,9 +72,17 @@ func TestProjectExecutorCredentialsHTTP(t *testing.T) { if w := projectKeyHTTP(h, "POST", "/core/v1/environments/"+environment+"/executor-credentials", "admin", body); w.Code != 404 || f.calls != 0 { t.Fatal("old route still served", w.Code) } - if w := projectKeyHTTP(h, "GET", "/core/v1/projects/"+uuid.NewString()+"/environments/"+environment+"/executor-credentials", "admin", ""); w.Code != 404 || f.calls != 0 { + unknown := "/core/v1/projects/" + uuid.NewString() + "/environments/" + environment + "/executor-credentials" + if w := projectKeyHTTP(h, "GET", unknown, "admin", ""); w.Code != 404 || f.calls != 0 { t.Fatal("unknown Project", w.Code) } + // The request body is validated before the target is resolved. + if w := projectKeyHTTP(h, "POST", unknown, "admin", `{"key_id":"invalid"}`); w.Code != 400 || f.calls != 0 { + t.Fatal("body before target", w.Code) + } + if w := projectKeyHTTP(h, "POST", unknown, "admin", body); w.Code != 404 || f.calls != 0 { + t.Fatal("unknown Project issue", w.Code) + } w := projectKeyHTTP(h, "GET", path, "admin", "") if w.Code != 200 || w.Body.String() != `{"data":[{"key_id":"listed","created_at":"1970-01-01T00:00:01Z","revoked_at":null}]}`+"\n" { diff --git a/services/agents-api/internal/store/environment_executor_management.go b/services/agents-api/internal/store/environment_executor_management.go index 8d500f3c0..62c8e9380 100644 --- a/services/agents-api/internal/store/environment_executor_management.go +++ b/services/agents-api/internal/store/environment_executor_management.go @@ -58,9 +58,13 @@ func (s *Store) ListProjectExecutorCredentials(ctx context.Context, project iden // gets neither (ErrProjectArchived). The administrator audit entry commits in // the same transaction and never contains the secret. func (s *Store) IssueProjectExecutorCredential(ctx context.Context, project identity.Principal, environment, keyID string, rotate bool) (IssuedExecutorCredential, error) { + // The target is checked first, then the archived Project, then the key. if err := s.selfHostedExecutorTarget(ctx, project, environment); err != nil { return IssuedExecutorCredential{}, err } + if err := activeProject(ctx, s.queries, project); err != nil { + return IssuedExecutorCredential{}, err + } if !rotate { return s.issueExecutorCredential(ctx, project, keyID, environment, activeProjectAudit(project, "issue", keyID)) } @@ -103,29 +107,38 @@ func executorCredentialAudit(project identity.Principal, action, keyID string) f } } -// activeProjectAudit share-locks the Project, which archiving updates, so an -// issuance or rotation either commits before the archive or sees it and fails. +// activeProjectAudit repeats the archive check in the writing transaction. It +// share-locks the Project, which archiving updates, so an issuance or rotation +// either commits before the archive or sees it and fails. func activeProjectAudit(project identity.Principal, action, keyID string) func(context.Context, *sqlc.Queries) error { audit := executorCredentialAudit(project, action, keyID) return func(ctx context.Context, q *sqlc.Queries) error { - tenant, err := parseID(project.TenantID) - if err != nil { + if err := activeProject(ctx, q, project); err != nil { return err } - row, err := q.LockProjectByTenant(ctx, tenant) - if errors.Is(err, pgx.ErrNoRows) { - return ErrNotFound - } - if err != nil { - return err - } - if row.ArchivedAt.Valid { - return ErrProjectArchived - } return audit(ctx, q) } } +// activeProject returns ErrProjectArchived for an archived Project. +func activeProject(ctx context.Context, q *sqlc.Queries, project identity.Principal) error { + tenant, err := parseID(project.TenantID) + if err != nil { + return err + } + row, err := q.LockProjectByTenant(ctx, tenant) + if errors.Is(err, pgx.ErrNoRows) { + return ErrNotFound + } + if err != nil { + return err + } + if row.ArchivedAt.Valid { + return ErrProjectArchived + } + return nil +} + func (s *Store) selfHostedExecutorTarget(ctx context.Context, principal identity.Principal, environment string) error { if err := principal.Validate(); err != nil { return ErrInvalidInput diff --git a/services/agents-api/internal/store/environment_executor_management_test.go b/services/agents-api/internal/store/environment_executor_management_test.go index d7fd48ac8..f40b60ccf 100644 --- a/services/agents-api/internal/store/environment_executor_management_test.go +++ b/services/agents-api/internal/store/environment_executor_management_test.go @@ -171,11 +171,22 @@ func TestArchivedProjectExecutorCredentials(t *testing.T) { if _, err := s.ArchiveProject(keyAdminContext(ctx, project.ID), project.ID); err != nil { t.Fatal(err) } - if _, err := s.IssueProjectExecutorCredential(keyAdminContext(ctx, project.ID), p, environment.ID, uuid.NewString(), false); !errors.Is(err, ErrProjectArchived) { - t.Fatal("archived issue", err) - } - if _, err := s.IssueProjectExecutorCredential(keyAdminContext(ctx, project.ID), p, environment.ID, keyID, true); !errors.Is(err, ErrProjectArchived) { - t.Fatal("archived rotation", err) + // Order: the target (404) first, then the archived Project (409), before + // the key's own exists conflict or unknown-key rotation 404. + for _, test := range []struct { + environment, key string + rotate bool + want error + }{ + {uuid.NewString(), uuid.NewString(), false, ErrNotFound}, + {environment.ID, uuid.NewString(), false, ErrProjectArchived}, + {environment.ID, keyID, false, ErrProjectArchived}, + {environment.ID, keyID, true, ErrProjectArchived}, + {environment.ID, uuid.NewString(), true, ErrProjectArchived}, + } { + if _, err := s.IssueProjectExecutorCredential(keyAdminContext(ctx, project.ID), p, test.environment, test.key, test.rotate); !errors.Is(err, test.want) { + t.Fatal("archived write", test, err) + } } if listed, err := s.ListProjectExecutorCredentials(ctx, p, environment.ID); err != nil || len(listed) != 1 { t.Fatal("archived list", listed, err) From 158f6b96de1fbfd2fb6aae9db1c1040acc6b9e9c Mon Sep 17 00:00:00 2001 From: SaladDay <1203511142@qq.com> Date: Fri, 25 Sep 2026 15:32:18 +0000 Subject: [PATCH 16/30] Issue the release guide's executor credential with the Core key The Core-key POST under /core/v1/projects/{p}/environments/{e} is now the instruction; keys.json no longer exists. agents-api-environment-key remains only as a marked break-glass database path that skips the archived-Project check and writes no administrator audit entry. --- services/agents-api/RELEASE.md | 36 +++++++++++++++++++++++----------- 1 file changed, 25 insertions(+), 11 deletions(-) diff --git a/services/agents-api/RELEASE.md b/services/agents-api/RELEASE.md index ea740528b..bdb0ba416 100644 --- a/services/agents-api/RELEASE.md +++ b/services/agents-api/RELEASE.md @@ -89,23 +89,37 @@ session = client.beta.agents.sessions.create( print(session.id, session.environment.id, session.environment.remote_url) ``` -Keep the API running. In a separate operator shell with the private database -configuration, issue an executor key restricted to this Environment using the -principal IDs from `keys.json`. `KEY_ID` is a new canonical nonzero UUID retained -for rotation/revocation. Save the output to a new private file: +Keep the API running. In a separate operator shell on the API host, issue an +executor credential restricted to this Environment with the Core key, read from +the private file named by `CORE_KEY_FILE`. `PROJECT_ID` is the Project whose key +created the Session; `KEY_ID` is a new canonical +lowercase UUID that you retain for listing, rotation and revocation. The response +is the credential file and is returned only once, so save it to a new private +file: ```sh umask 077 -"$AGENTS_API_BIN_DIR/agents-api-environment-key" \ - --tenant "$TENANT_ID" --organization "$ORGANIZATION_ID" \ - --project "$PROJECT_ID" --subject-kind service_account \ - --subject-id "$SUBJECT_ID" --key-id "$KEY_ID" --environment "$ENVIRONMENT_ID" \ +KEY_ID=$(python3 -c 'import uuid; print(uuid.uuid4())') +curl -fsS -X POST \ + -H @<(printf 'Authorization: Bearer %s\n' "$(cat "$CORE_KEY_FILE")") \ + -H 'Content-Type: application/json' -d "{\"key_id\":\"$KEY_ID\"}" \ + "http://127.0.0.1:8091/core/v1/projects/$PROJECT_ID/environments/$ENVIRONMENT_ID/executor-credentials" \ > "$PARSAR_HOME/executor-key.json" ``` -With the Core key, the same restricted credential can instead be issued through -`POST /core/v1/projects/{project_id}/environments/{environment_id}/executor-credentials`; -see [executor credentials](../../contracts/agents-api/environment-executor-credentials.md). +If the response is uncertain, do not retry automatically: list the credentials +with `GET` on the same path, then rotate the same `KEY_ID` with `"rotate":true` or +issue it again. Revoke with `DELETE …/executor-credentials/$KEY_ID`. See +[executor credentials](https://github.com/MiniMax-AI/parsar-core/blob/@SOURCE_REVISION@/contracts/agents-api/environment-executor-credentials.md) +for the rules, including the 404 and 409 cases. + +Break-glass only: `agents-api-environment-key` issues, rotates or revokes the +same credential directly in the database when the Core API is unavailable. It +needs the private database configuration and the Project's execution principal +(tenant UUID from the `projects` table, organization `core`, project +`proj_`, subject `service_account/project:`). It +bypasses the Core API: it skips the archived-Project check and writes no +administrator audit entry, so use the Core-key route whenever Core is running. Deploy the qualified V1 Runtime containing our daemon, selected native harness, local tools and workspace. Transfer only its scoped key into the protected daemon From 09070b8b9684cda269640c309ca370b31de38f78 Mon Sep 17 00:00:00 2001 From: SaladDay <1203511142@qq.com> Date: Fri, 25 Sep 2026 15:32:55 +0000 Subject: [PATCH 17/30] Treat CoreMetricsClient baseUrl as a prefix like the other Core clients The default is now /core/v1 and the client requests /metrics?range=..., so new CoreMetricsClient() still reads /core/v1/metrics. Option names are unchanged; each option has a one-line doc comment. --- packages/agents-client/src/admin-types.ts | 2 ++ packages/agents-client/src/core-metrics.test.ts | 6 ++++-- packages/agents-client/src/core-metrics.ts | 9 ++++++--- packages/agents-client/src/core-request.ts | 2 ++ 4 files changed, 14 insertions(+), 5 deletions(-) diff --git a/packages/agents-client/src/admin-types.ts b/packages/agents-client/src/admin-types.ts index 1cbacb32f..1f1a5efae 100644 --- a/packages/agents-client/src/admin-types.ts +++ b/packages/agents-client/src/admin-types.ts @@ -6,9 +6,11 @@ import type { } from "./types"; export interface AdminClientOptions { + /** Prefix that request paths are appended to; defaults to `/core/v1`. */ baseUrl?: string; /** Core key for trusted server callers; console browsers use their same-origin session instead. */ adminToken?: string | (() => string | undefined); + /** Fetch implementation; defaults to the global fetch. */ fetch?: typeof fetch; } diff --git a/packages/agents-client/src/core-metrics.test.ts b/packages/agents-client/src/core-metrics.test.ts index 61d50d295..9599b6365 100644 --- a/packages/agents-client/src/core-metrics.test.ts +++ b/packages/agents-client/src/core-metrics.test.ts @@ -10,9 +10,11 @@ describe("Core metrics client", () => { const client = new CoreMetricsClient({ fetch }); expect(client).not.toBeInstanceOf(OpenAIAgentsClient); await client.retrieveCoreMetrics("6h"); - await new CoreMetricsClient({ token: "core-key", fetch }).retrieveCoreMetrics("1h"); - const [[url, init], [, authorized]] = fetch.mock.calls as [[string, RequestInit], [string, RequestInit]]; + // baseUrl is a prefix, like the other Core clients. + await new CoreMetricsClient({ baseUrl: "https://core.example/core/v1/", token: "core-key", fetch }).retrieveCoreMetrics("1h"); + const [[url, init], [prefixed, authorized]] = fetch.mock.calls as [[string, RequestInit], [string, RequestInit]]; expect(url).toBe("/core/v1/metrics?range=6h"); + expect(prefixed).toBe("https://core.example/core/v1/metrics?range=1h"); expect(init).toMatchObject({ credentials: "same-origin", redirect: "error" }); expect(new Headers(init.headers).has("Authorization")).toBe(false); expect(new Headers(init.headers).has("OpenAI-Beta")).toBe(false); diff --git a/packages/agents-client/src/core-metrics.ts b/packages/agents-client/src/core-metrics.ts index 949ef5f81..b51000512 100644 --- a/packages/agents-client/src/core-metrics.ts +++ b/packages/agents-client/src/core-metrics.ts @@ -213,15 +213,18 @@ export function projectCoreMetrics(value: unknown): CoreMetrics { }; } -/** Reads Core's own metrics from `/core/v1/metrics`, through an authenticated console or an explicit Core key. */ +/** + * Reads Core's own metrics from `/metrics` under `baseUrl` (default `/core/v1`), + * through an authenticated console or an explicit Core key. + */ export class CoreMetricsClient { readonly #core: CoreRequester; constructor(options: CoreClientOptions = {}) { - this.#core = new CoreRequester(options.baseUrl ?? "/core/v1/metrics", options.token, options.fetch, invalidCoreMetrics); + this.#core = new CoreRequester(options.baseUrl ?? "/core/v1", options.token, options.fetch, invalidCoreMetrics); } async retrieveCoreMetrics(range: CoreMetricsRange, options?: ReadOptions): Promise { - return projectCoreMetrics(await this.#core.json(`?range=${encodeURIComponent(range)}`, options)); + return projectCoreMetrics(await this.#core.json(`/metrics?range=${encodeURIComponent(range)}`, options)); } } diff --git a/packages/agents-client/src/core-request.ts b/packages/agents-client/src/core-request.ts index 5036af74c..8da5742eb 100644 --- a/packages/agents-client/src/core-request.ts +++ b/packages/agents-client/src/core-request.ts @@ -4,9 +4,11 @@ import type { ReadOptions } from "./types"; /** Constructor options of the Core clients that take a plain bearer `token`. */ export interface CoreClientOptions { + /** Prefix that request paths are appended to: `/core/v1/sandbox` for SandboxAdminClient, `/core/v1` for CoreMetricsClient by default. */ baseUrl?: string; /** Core key for trusted server callers; console browsers use their same-origin session instead. */ token?: string | (() => string | undefined); + /** Fetch implementation; defaults to the global fetch. */ fetch?: typeof fetch; } From 7dba5e7e84d3b4ef1fc7b5f708a9b81905d179d2 Mon Sep 17 00:00:00 2001 From: SaladDay <1203511142@qq.com> Date: Fri, 25 Sep 2026 15:33:31 +0000 Subject: [PATCH 18/30] Add the node enrollment token to the credential matrix An unconsumed enrollment token reads node configuration and gets 401 on /v1, /core/v1, node identity and both agent-daemon routes. The matrix now also covers daemon enrollment, which only the executor credential passes. --- .../store/credential_matrix_http_test.go | 33 ++++++++++++------- 1 file changed, 21 insertions(+), 12 deletions(-) diff --git a/services/agents-api/internal/store/credential_matrix_http_test.go b/services/agents-api/internal/store/credential_matrix_http_test.go index 71cdb4db1..03b8064c4 100644 --- a/services/agents-api/internal/store/credential_matrix_http_test.go +++ b/services/agents-api/internal/store/credential_matrix_http_test.go @@ -5,6 +5,7 @@ import ( "encoding/json" "net/http" "net/http/httptest" + "slices" "strings" "sync" "testing" @@ -120,26 +121,34 @@ func TestCredentialNamespaceMatrix(t *testing.T) { var node store.RuntimeNodeIdentity created("POST", "/api/v1/sandbox-node/enroll", enrollment.Token, string(enroll), &node) - routes := []struct{ name, method, path string }{ - {"/v1", "GET", "/v1/agents"}, - {"/core/v1", "GET", "/core/v1/projects"}, - {"/api/v1 node", "GET", "/api/v1/sandbox-node/identity?node_id=" + nodeID}, - {"/api/v1 executor", "GET", "/api/v1/agent-daemon/connection?environment_id=" + environment.ID}, + // A second, unconsumed enrollment token; its only uses are enroll and configuration. + var unused api.SandboxEnrollmentToken + created("POST", "/core/v1/sandbox/enrollment-tokens", coreKey, `{}`, &unused) + + routes := []struct{ name, method, path, body string }{ + {"/v1", "GET", "/v1/agents", ""}, + {"/core/v1", "GET", "/core/v1/projects", ""}, + {"node identity", "GET", "/api/v1/sandbox-node/identity?node_id=" + nodeID, ""}, + {"node configuration", "GET", "/api/v1/sandbox-node/configuration", ""}, + {"daemon connection", "GET", "/api/v1/agent-daemon/connection?environment_id=" + environment.ID, ""}, + {"daemon enroll", "POST", "/api/v1/agent-daemon/enroll", `{"environment_id":"` + environment.ID + `"}`}, } for _, credential := range []struct { - name, token, own string + name, token string + own []string }{ - {"Project API key", projectKey.Key, "/v1"}, - {"Core key", coreKey, "/core/v1"}, - {"node credential", nodeCredential, "/api/v1 node"}, - {"executor credential", executor.Token, "/api/v1 executor"}, + {"Project API key", projectKey.Key, []string{"/v1"}}, + {"Core key", coreKey, []string{"/core/v1"}}, + {"node enrollment token", unused.Token, []string{"node configuration"}}, + {"node credential", nodeCredential, []string{"node identity"}}, + {"executor credential", executor.Token, []string{"daemon connection", "daemon enroll"}}, } { for _, route := range routes { want := http.StatusUnauthorized - if route.name == credential.own { + if slices.Contains(credential.own, route.name) { want = http.StatusOK } - if w := call(route.method, route.path, credential.token, ""); w.Code != want { + if w := call(route.method, route.path, credential.token, route.body); w.Code != want { t.Errorf("%s on %s = %d, want %d: %s", credential.name, route.name, w.Code, want, w.Body) } } From f2ab0aca6c09fbea5d6a70213cd386ad54273311 Mon Sep 17 00:00:00 2001 From: SaladDay <1203511142@qq.com> Date: Fri, 25 Sep 2026 15:33:53 +0000 Subject: [PATCH 19/30] Tag the Core execution configuration read like the other /core/v1 reads It was the last Core operation tagged "Core extensions"; it is now "Execution configuration", beside "Runtime observations" and "Runtime history". Project resource summaries say "in a Project" instead of the stale "in a managed key space". openapi.yaml is unchanged. --- contracts/agents-api/core.openapi.yaml | 74 +++++++++---------- .../internal/api/admin_agents_routes.go | 6 +- .../api/admin_environment_templates_routes.go | 6 +- .../internal/api/admin_files_routes.go | 6 +- .../internal/api/admin_sessions_routes.go | 28 +++---- .../internal/api/admin_skills_routes.go | 16 ++-- .../internal/api/admin_vaults_routes.go | 12 +-- 7 files changed, 74 insertions(+), 74 deletions(-) diff --git a/contracts/agents-api/core.openapi.yaml b/contracts/agents-api/core.openapi.yaml index 9dc0b5879..4a5e157fa 100644 --- a/contracts/agents-api/core.openapi.yaml +++ b/contracts/agents-api/core.openapi.yaml @@ -3097,7 +3097,7 @@ paths: $ref: '#/definitions/v1.ErrorResponse' security: - DeploymentAdminAuth: [] - summary: List reusable Agents in a managed key space + summary: List reusable Agents in a Project tags: - Agents /core/v1/projects/{project_id}/agents/{agent_id}: @@ -3143,7 +3143,7 @@ paths: $ref: '#/definitions/v1.ErrorResponse' security: - DeploymentAdminAuth: [] - summary: Delete a reusable Agent in a managed key space + summary: Delete a reusable Agent in a Project tags: - Agents get: @@ -3184,7 +3184,7 @@ paths: $ref: '#/definitions/v1.ErrorResponse' security: - DeploymentAdminAuth: [] - summary: Retrieve a reusable Agent in a managed key space + summary: Retrieve a reusable Agent in a Project tags: - Agents /core/v1/projects/{project_id}/archive: @@ -3275,7 +3275,7 @@ paths: $ref: '#/definitions/v1.ErrorResponse' security: - DeploymentAdminAuth: [] - summary: List Environment Templates in a managed key space + summary: List Environment Templates in a Project tags: - Environment Templates /core/v1/projects/{project_id}/environment-templates/{environment_template_id}: @@ -3317,7 +3317,7 @@ paths: $ref: '#/definitions/v1.ErrorResponse' security: - DeploymentAdminAuth: [] - summary: Delete an Environment Template in a managed key space + summary: Delete an Environment Template in a Project tags: - Environment Templates get: @@ -3358,7 +3358,7 @@ paths: $ref: '#/definitions/v1.ErrorResponse' security: - DeploymentAdminAuth: [] - summary: Retrieve an Environment Template in a managed key space + summary: Retrieve an Environment Template in a Project tags: - Environment Templates /core/v1/projects/{project_id}/environments/{environment_id}/executor-credentials: @@ -3552,7 +3552,7 @@ paths: $ref: '#/definitions/v1.ErrorResponse' security: - DeploymentAdminAuth: [] - summary: List source files in a managed key space + summary: List source files in a Project tags: - Files /core/v1/projects/{project_id}/files/{file_id}: @@ -3598,7 +3598,7 @@ paths: $ref: '#/definitions/v1.ErrorResponse' security: - DeploymentAdminAuth: [] - summary: Delete a source file in a managed key space + summary: Delete a source file in a Project tags: - Files get: @@ -3643,7 +3643,7 @@ paths: $ref: '#/definitions/v1.ErrorResponse' security: - DeploymentAdminAuth: [] - summary: Retrieve source file metadata in a managed key space + summary: Retrieve source file metadata in a Project tags: - Files /core/v1/projects/{project_id}/keys: @@ -3885,7 +3885,7 @@ paths: $ref: '#/definitions/v1.ErrorResponse' security: - DeploymentAdminAuth: [] - summary: List execution Sessions in a managed key space + summary: List execution Sessions in a Project tags: - Sessions /core/v1/projects/{project_id}/sessions/{session_id}: @@ -3935,7 +3935,7 @@ paths: $ref: '#/definitions/v1.ErrorResponse' security: - DeploymentAdminAuth: [] - summary: Delete an execution Session in a managed key space + summary: Delete an execution Session in a Project tags: - Sessions get: @@ -3976,7 +3976,7 @@ paths: $ref: '#/definitions/v1.ErrorResponse' security: - DeploymentAdminAuth: [] - summary: Retrieve an execution Session in a managed key space + summary: Retrieve an execution Session in a Project tags: - Sessions /core/v1/projects/{project_id}/sessions/{session_id}/archive: @@ -4152,7 +4152,7 @@ paths: $ref: '#/definitions/v1.ErrorResponse' security: - DeploymentAdminAuth: [] - summary: List immutable Session artifacts in a managed key space + summary: List immutable Session artifacts in a Project tags: - Artifacts /core/v1/projects/{project_id}/sessions/{session_id}/artifacts/{artifact_id}: @@ -4203,7 +4203,7 @@ paths: $ref: '#/definitions/v1.ErrorResponse' security: - DeploymentAdminAuth: [] - summary: Delete a published artifact in a managed key space + summary: Delete a published artifact in a Project tags: - Artifacts get: @@ -4253,7 +4253,7 @@ paths: $ref: '#/definitions/v1.ErrorResponse' security: - DeploymentAdminAuth: [] - summary: Retrieve immutable artifact metadata in a managed key space + summary: Retrieve immutable artifact metadata in a Project tags: - Artifacts /core/v1/projects/{project_id}/sessions/{session_id}/artifacts/{artifact_id}/content: @@ -4304,7 +4304,7 @@ paths: $ref: '#/definitions/v1.ErrorResponse' security: - DeploymentAdminAuth: [] - summary: Download immutable artifact bytes in a managed key space + summary: Download immutable artifact bytes in a Project tags: - Artifacts /core/v1/projects/{project_id}/sessions/{session_id}/execution-configuration: @@ -4350,9 +4350,9 @@ paths: $ref: '#/definitions/v1.ErrorResponse' security: - DeploymentAdminAuth: [] - summary: Retrieve frozen Session execution configuration in a managed key space + summary: Retrieve a Session's frozen execution configuration in a Project tags: - - Core extensions + - Execution configuration /core/v1/projects/{project_id}/sessions/{session_id}/items: get: description: Core key only. Reuses the public resource projection and operation rules; the Project ID selects the target space and does not authenticate. @@ -4410,7 +4410,7 @@ paths: $ref: '#/definitions/v1.ErrorResponse' security: - DeploymentAdminAuth: [] - summary: List persisted execution Items in a managed key space + summary: List persisted execution Items in a Project tags: - Items /core/v1/projects/{project_id}/sessions/{session_id}/runtime-history: @@ -4476,7 +4476,7 @@ paths: $ref: '#/definitions/v1.ErrorResponse' security: - DeploymentAdminAuth: [] - summary: Retrieve Session Runtime history in a managed key space + summary: Retrieve Session Runtime history in a Project tags: - Runtime history /core/v1/projects/{project_id}/sessions/{session_id}/runtime-observation: @@ -4522,7 +4522,7 @@ paths: $ref: '#/definitions/v1.ErrorResponse' security: - DeploymentAdminAuth: [] - summary: Retrieve a Session Runtime observation in a managed key space + summary: Retrieve a Session Runtime observation in a Project tags: - Runtime observations /core/v1/projects/{project_id}/sessions/{session_id}/turns: @@ -4583,7 +4583,7 @@ paths: $ref: '#/definitions/v1.ErrorResponse' security: - DeploymentAdminAuth: [] - summary: List execution Turns in a managed key space + summary: List execution Turns in a Project tags: - Turns /core/v1/projects/{project_id}/sessions/{session_id}/turns/{turn_id}: @@ -4630,7 +4630,7 @@ paths: $ref: '#/definitions/v1.ErrorResponse' security: - DeploymentAdminAuth: [] - summary: Retrieve an execution Turn in a managed key space + summary: Retrieve an execution Turn in a Project tags: - Turns /core/v1/projects/{project_id}/skills: @@ -4669,7 +4669,7 @@ paths: $ref: '#/definitions/v1.SkillList' security: - DeploymentAdminAuth: [] - summary: List Skills in a managed key space + summary: List Skills in a Project tags: - Skills /core/v1/projects/{project_id}/skills/{skill_id}: @@ -4695,7 +4695,7 @@ paths: $ref: '#/definitions/v1.SkillDeleted' security: - DeploymentAdminAuth: [] - summary: Delete a Skill and its versions in a managed key space + summary: Delete a Skill and its versions in a Project tags: - Skills get: @@ -4720,7 +4720,7 @@ paths: $ref: '#/definitions/v1.Skill' security: - DeploymentAdminAuth: [] - summary: Retrieve Skill metadata in a managed key space + summary: Retrieve Skill metadata in a Project tags: - Skills /core/v1/projects/{project_id}/skills/{skill_id}/content: @@ -4746,7 +4746,7 @@ paths: type: file security: - DeploymentAdminAuth: [] - summary: Download Skill content in a managed key space + summary: Download Skill content in a Project tags: - Skills /core/v1/projects/{project_id}/skills/{skill_id}/versions: @@ -4790,7 +4790,7 @@ paths: $ref: '#/definitions/v1.SkillVersionList' security: - DeploymentAdminAuth: [] - summary: List Skill versions in a managed key space + summary: List Skill versions in a Project tags: - Skills /core/v1/projects/{project_id}/skills/{skill_id}/versions/{version}: @@ -4821,7 +4821,7 @@ paths: $ref: '#/definitions/v1.SkillVersionDeleted' security: - DeploymentAdminAuth: [] - summary: Delete a Skill version in a managed key space + summary: Delete a Skill version in a Project tags: - Skills get: @@ -4851,7 +4851,7 @@ paths: $ref: '#/definitions/v1.SkillVersion' security: - DeploymentAdminAuth: [] - summary: Retrieve Skill version metadata in a managed key space + summary: Retrieve Skill version metadata in a Project tags: - Skills /core/v1/projects/{project_id}/skills/{skill_id}/versions/{version}/content: @@ -4882,7 +4882,7 @@ paths: type: file security: - DeploymentAdminAuth: [] - summary: Download immutable Skill version content in a managed key space + summary: Download immutable Skill version content in a Project tags: - Skills /core/v1/projects/{project_id}/vaults: @@ -4953,7 +4953,7 @@ paths: $ref: '#/definitions/v1.ErrorResponse' security: - DeploymentAdminAuth: [] - summary: List Vaults in a managed key space + summary: List Vaults in a Project tags: - Vaults /core/v1/projects/{project_id}/vaults/{vault_id}: @@ -4999,7 +4999,7 @@ paths: $ref: '#/definitions/v1.ErrorResponse' security: - DeploymentAdminAuth: [] - summary: Delete a Vault and all its Credentials in a managed key space + summary: Delete a Vault and all its Credentials in a Project tags: - Vaults get: @@ -5040,7 +5040,7 @@ paths: $ref: '#/definitions/v1.ErrorResponse' security: - DeploymentAdminAuth: [] - summary: Retrieve a Vault in a managed key space + summary: Retrieve a Vault in a Project tags: - Vaults /core/v1/projects/{project_id}/vaults/{vault_id}/credentials: @@ -5116,7 +5116,7 @@ paths: $ref: '#/definitions/v1.ErrorResponse' security: - DeploymentAdminAuth: [] - summary: List safe Vault Credential metadata in a managed key space + summary: List safe Vault Credential metadata in a Project tags: - Credentials /core/v1/projects/{project_id}/vaults/{vault_id}/credentials/{credential_id}: @@ -5167,7 +5167,7 @@ paths: $ref: '#/definitions/v1.ErrorResponse' security: - DeploymentAdminAuth: [] - summary: Delete a Vault Credential in a managed key space + summary: Delete a Vault Credential in a Project tags: - Credentials get: @@ -5213,7 +5213,7 @@ paths: $ref: '#/definitions/v1.ErrorResponse' security: - DeploymentAdminAuth: [] - summary: Retrieve safe Vault Credential metadata in a managed key space + summary: Retrieve safe Vault Credential metadata in a Project tags: - Credentials /core/v1/projects/{project_id}/write-operations: diff --git a/services/agents-api/internal/api/admin_agents_routes.go b/services/agents-api/internal/api/admin_agents_routes.go index 8dc8bf665..14b787eab 100644 --- a/services/agents-api/internal/api/admin_agents_routes.go +++ b/services/agents-api/internal/api/admin_agents_routes.go @@ -2,7 +2,7 @@ package api import "net/http" -// @Summary List reusable Agents in a managed key space +// @Summary List reusable Agents in a Project // @Description Core key only. Reuses the public resource projection and operation rules; the Project ID selects the target space and does not authenticate. // @Tags Agents // @Produce json @@ -18,7 +18,7 @@ func (h *Handler) adminListAgents(w http.ResponseWriter, r *http.Request) { h.listAgents(w, r) } -// @Summary Retrieve a reusable Agent in a managed key space +// @Summary Retrieve a reusable Agent in a Project // @Description Core key only. Reuses the public resource projection and operation rules; the Project ID selects the target space and does not authenticate. // @Tags Agents // @Produce json @@ -32,7 +32,7 @@ func (h *Handler) adminGetAgent(w http.ResponseWriter, r *http.Request) { h.getAgent(w, r) } -// @Summary Delete a reusable Agent in a managed key space +// @Summary Delete a reusable Agent in a Project // @Description Core key only. Reuses the public resource projection and operation rules; the Project ID selects the target space and does not authenticate. // @Tags Agents // @Produce json diff --git a/services/agents-api/internal/api/admin_environment_templates_routes.go b/services/agents-api/internal/api/admin_environment_templates_routes.go index b50f9a83a..9b0dea39a 100644 --- a/services/agents-api/internal/api/admin_environment_templates_routes.go +++ b/services/agents-api/internal/api/admin_environment_templates_routes.go @@ -2,7 +2,7 @@ package api import "net/http" -// @Summary List Environment Templates in a managed key space +// @Summary List Environment Templates in a Project // @Description Core key only. Reuses the public resource projection and operation rules; the Project ID selects the target space and does not authenticate. // @Tags Environment Templates // @Produce json @@ -18,7 +18,7 @@ func (h *Handler) adminListEnvironmentTemplates(w http.ResponseWriter, r *http.R h.listEnvironmentTemplates(w, r) } -// @Summary Retrieve an Environment Template in a managed key space +// @Summary Retrieve an Environment Template in a Project // @Description Core key only. Reuses the public resource projection and operation rules; the Project ID selects the target space and does not authenticate. // @Tags Environment Templates // @Produce json @@ -32,7 +32,7 @@ func (h *Handler) adminGetEnvironmentTemplate(w http.ResponseWriter, r *http.Req h.getEnvironmentTemplate(w, r) } -// @Summary Delete an Environment Template in a managed key space +// @Summary Delete an Environment Template in a Project // @Description Core key only. Reuses the public resource projection and operation rules; the Project ID selects the target space and does not authenticate. // @Tags Environment Templates // @Produce json diff --git a/services/agents-api/internal/api/admin_files_routes.go b/services/agents-api/internal/api/admin_files_routes.go index bfeb9574f..b37410233 100644 --- a/services/agents-api/internal/api/admin_files_routes.go +++ b/services/agents-api/internal/api/admin_files_routes.go @@ -2,7 +2,7 @@ package api import "net/http" -// @Summary List source files in a managed key space +// @Summary List source files in a Project // @Description Core key only. Reuses the public resource projection and operation rules; the Project ID selects the target space and does not authenticate. // @Tags Files // @Produce json @@ -19,7 +19,7 @@ func (h *Handler) adminListSourceFiles(w http.ResponseWriter, r *http.Request) { h.listSourceFiles(w, r) } -// @Summary Retrieve source file metadata in a managed key space +// @Summary Retrieve source file metadata in a Project // @Description Core key only. Reuses the public resource projection and operation rules; the Project ID selects the target space and does not authenticate. // @Tags Files // @Produce json @@ -33,7 +33,7 @@ func (h *Handler) adminGetSourceFile(w http.ResponseWriter, r *http.Request) { h.getSourceFile(w, r) } -// @Summary Delete a source file in a managed key space +// @Summary Delete a source file in a Project // @Description Core key only. Reuses the public resource projection and operation rules; the Project ID selects the target space and does not authenticate. // @Tags Files // @Produce json diff --git a/services/agents-api/internal/api/admin_sessions_routes.go b/services/agents-api/internal/api/admin_sessions_routes.go index 660a0e3f0..58d179e13 100644 --- a/services/agents-api/internal/api/admin_sessions_routes.go +++ b/services/agents-api/internal/api/admin_sessions_routes.go @@ -2,7 +2,7 @@ package api import "net/http" -// @Summary List execution Sessions in a managed key space +// @Summary List execution Sessions in a Project // @Description Core key only. Reuses the public resource projection and operation rules; the Project ID selects the target space and does not authenticate. // @Tags Sessions // @Produce json @@ -19,7 +19,7 @@ func (h *Handler) adminListSessions(w http.ResponseWriter, r *http.Request) { h.listSessions(w, r) } -// @Summary Retrieve an execution Session in a managed key space +// @Summary Retrieve an execution Session in a Project // @Description Core key only. Reuses the public resource projection and operation rules; the Project ID selects the target space and does not authenticate. // @Tags Sessions // @Produce json @@ -33,7 +33,7 @@ func (h *Handler) adminGetSession(w http.ResponseWriter, r *http.Request) { h.getSession(w, r) } -// @Summary Delete an execution Session in a managed key space +// @Summary Delete an execution Session in a Project // @Description Core key only. Reuses the public resource projection and operation rules; the Project ID selects the target space and does not authenticate. // @Tags Sessions // @Produce json @@ -47,7 +47,7 @@ func (h *Handler) adminDeleteSession(w http.ResponseWriter, r *http.Request) { h.deleteSession(w, r) } -// @Summary List execution Turns in a managed key space +// @Summary List execution Turns in a Project // @Description Core key only. Reuses the public resource projection and operation rules; the Project ID selects the target space and does not authenticate. // @Tags Turns // @Produce json @@ -64,7 +64,7 @@ func (h *Handler) adminListTurns(w http.ResponseWriter, r *http.Request) { h.listTurns(w, r) } -// @Summary Retrieve an execution Turn in a managed key space +// @Summary Retrieve an execution Turn in a Project // @Description Core key only. Reuses the public resource projection and operation rules; the Project ID selects the target space and does not authenticate. // @Tags Turns // @Produce json @@ -79,7 +79,7 @@ func (h *Handler) adminGetTurn(w http.ResponseWriter, r *http.Request) { h.getTurn(w, r) } -// @Summary List persisted execution Items in a managed key space +// @Summary List persisted execution Items in a Project // @Description Core key only. Reuses the public resource projection and operation rules; the Project ID selects the target space and does not authenticate. // @Tags Items // @Produce json @@ -96,7 +96,7 @@ func (h *Handler) adminListItems(w http.ResponseWriter, r *http.Request) { h.listItems(w, r) } -// @Summary List immutable Session artifacts in a managed key space +// @Summary List immutable Session artifacts in a Project // @Description Core key only. Reuses the public resource projection and operation rules; the Project ID selects the target space and does not authenticate. // @Tags Artifacts // @Produce json @@ -114,7 +114,7 @@ func (h *Handler) adminListSessionArtifacts(w http.ResponseWriter, r *http.Reque h.listSessionArtifacts(w, r) } -// @Summary Retrieve immutable artifact metadata in a managed key space +// @Summary Retrieve immutable artifact metadata in a Project // @Description Core key only. Reuses the public resource projection and operation rules; the Project ID selects the target space and does not authenticate. // @Tags Artifacts // @Produce json @@ -129,7 +129,7 @@ func (h *Handler) adminGetSessionArtifact(w http.ResponseWriter, r *http.Request h.getSessionArtifact(w, r) } -// @Summary Delete a published artifact in a managed key space +// @Summary Delete a published artifact in a Project // @Description Core key only. Reuses the public resource projection and operation rules; the Project ID selects the target space and does not authenticate. // @Tags Artifacts // @Produce json @@ -144,7 +144,7 @@ func (h *Handler) adminDeleteSessionArtifact(w http.ResponseWriter, r *http.Requ h.deleteSessionArtifact(w, r) } -// @Summary Download immutable artifact bytes in a managed key space +// @Summary Download immutable artifact bytes in a Project // @Description Core key only. Reuses the public resource projection and operation rules; the Project ID selects the target space and does not authenticate. // @Tags Artifacts // @Produce octet-stream @@ -159,9 +159,9 @@ func (h *Handler) adminSessionArtifactContent(w http.ResponseWriter, r *http.Req h.sessionArtifactContent(w, r) } -// @Summary Retrieve frozen Session execution configuration in a managed key space +// @Summary Retrieve a Session's frozen execution configuration in a Project // @Description Core key only; the Project ID selects the target space and does not authenticate. Returns the committed model, harness and safe provider selection with recorded sources. This read never decrypts credentials, resolves current defaults or probes execution health. Deployment provider details remain redacted. Historical provenance and missing provider projections are explicitly unknown/unavailable. -// @Tags Core extensions +// @Tags Execution configuration // @Produce json // @Security DeploymentAdminAuth // @Param session_id path string true "Session ID" @@ -173,7 +173,7 @@ func (h *Handler) adminGetSessionExecutionConfiguration(w http.ResponseWriter, r h.getSessionExecutionConfiguration(w, r) } -// @Summary Retrieve a Session Runtime observation in a managed key space +// @Summary Retrieve a Session Runtime observation in a Project // @Description Core key only; the Project ID selects the target space and does not authenticate. Returns one read-only current Runtime observation. It never provisions, renews, restarts, pauses or stops compute. // @Tags Runtime observations // @Produce json @@ -187,7 +187,7 @@ func (h *Handler) adminGetRuntimeObservation(w http.ResponseWriter, r *http.Requ h.getRuntimeObservation(w, r) } -// @Summary Retrieve Session Runtime history in a managed key space +// @Summary Retrieve Session Runtime history in a Project // @Description Core key only; the Project ID selects the target space and does not authenticate. Returns stored Runtime observations for one Session. End is exclusive; the server selects a bounded resolution. Responses contain at most 1,000 series, 10,000 points per coverage/series array, and 100,000 total coverage plus series points. It never reads or changes live compute. // @Tags Runtime history // @Produce json diff --git a/services/agents-api/internal/api/admin_skills_routes.go b/services/agents-api/internal/api/admin_skills_routes.go index 7ba03e6c6..583968e5b 100644 --- a/services/agents-api/internal/api/admin_skills_routes.go +++ b/services/agents-api/internal/api/admin_skills_routes.go @@ -2,7 +2,7 @@ package api import "net/http" -// @Summary List Skills in a managed key space +// @Summary List Skills in a Project // @Description Core key only. Reuses the public resource projection and operation rules; the Project ID selects the target space and does not authenticate. // @Tags Skills // @Produce json @@ -17,7 +17,7 @@ func (h *Handler) adminListSkills(w http.ResponseWriter, r *http.Request) { h.listSkills(w, r) } -// @Summary Retrieve Skill metadata in a managed key space +// @Summary Retrieve Skill metadata in a Project // @Description Core key only. Reuses the public resource projection and operation rules; the Project ID selects the target space and does not authenticate. // @Tags Skills // @Produce json @@ -30,7 +30,7 @@ func (h *Handler) adminGetSkill(w http.ResponseWriter, r *http.Request) { h.getSkill(w, r) } -// @Summary Delete a Skill and its versions in a managed key space +// @Summary Delete a Skill and its versions in a Project // @Description Core key only. Reuses the public resource projection and operation rules; the Project ID selects the target space and does not authenticate. // @Tags Skills // @Produce json @@ -43,7 +43,7 @@ func (h *Handler) adminDeleteSkill(w http.ResponseWriter, r *http.Request) { h.deleteSkill(w, r) } -// @Summary Download Skill content in a managed key space +// @Summary Download Skill content in a Project // @Description Core key only. Reuses the public resource projection and operation rules; the Project ID selects the target space and does not authenticate. // @Tags Skills // @Produce octet-stream @@ -56,7 +56,7 @@ func (h *Handler) adminSkillContent(w http.ResponseWriter, r *http.Request) { h.skillContent(w, r) } -// @Summary List Skill versions in a managed key space +// @Summary List Skill versions in a Project // @Description Core key only. Reuses the public resource projection and operation rules; the Project ID selects the target space and does not authenticate. // @Tags Skills // @Produce json @@ -72,7 +72,7 @@ func (h *Handler) adminListSkillVersions(w http.ResponseWriter, r *http.Request) h.listSkillVersions(w, r) } -// @Summary Retrieve Skill version metadata in a managed key space +// @Summary Retrieve Skill version metadata in a Project // @Description Core key only. Reuses the public resource projection and operation rules; the Project ID selects the target space and does not authenticate. // @Tags Skills // @Produce json @@ -86,7 +86,7 @@ func (h *Handler) adminGetSkillVersion(w http.ResponseWriter, r *http.Request) { h.getSkillVersion(w, r) } -// @Summary Delete a Skill version in a managed key space +// @Summary Delete a Skill version in a Project // @Description Core key only. Reuses the public resource projection and operation rules; the Project ID selects the target space and does not authenticate. // @Tags Skills // @Produce json @@ -100,7 +100,7 @@ func (h *Handler) adminDeleteSkillVersion(w http.ResponseWriter, r *http.Request h.deleteSkillVersion(w, r) } -// @Summary Download immutable Skill version content in a managed key space +// @Summary Download immutable Skill version content in a Project // @Description Core key only. Reuses the public resource projection and operation rules; the Project ID selects the target space and does not authenticate. // @Tags Skills // @Produce octet-stream diff --git a/services/agents-api/internal/api/admin_vaults_routes.go b/services/agents-api/internal/api/admin_vaults_routes.go index c9d51a026..d9ec9d0fc 100644 --- a/services/agents-api/internal/api/admin_vaults_routes.go +++ b/services/agents-api/internal/api/admin_vaults_routes.go @@ -2,7 +2,7 @@ package api import "net/http" -// @Summary List Vaults in a managed key space +// @Summary List Vaults in a Project // @Description Core key only. Reuses the public resource projection and operation rules; the Project ID selects the target space and does not authenticate. // @Tags Vaults // @Produce json @@ -20,7 +20,7 @@ func (h *Handler) adminListVaults(w http.ResponseWriter, r *http.Request) { h.listVaults(w, r) } -// @Summary Retrieve a Vault in a managed key space +// @Summary Retrieve a Vault in a Project // @Description Core key only. Reuses the public resource projection and operation rules; the Project ID selects the target space and does not authenticate. // @Tags Vaults // @Produce json @@ -34,7 +34,7 @@ func (h *Handler) adminGetVault(w http.ResponseWriter, r *http.Request) { h.getVault(w, r) } -// @Summary Delete a Vault and all its Credentials in a managed key space +// @Summary Delete a Vault and all its Credentials in a Project // @Description Core key only. Reuses the public resource projection and operation rules; the Project ID selects the target space and does not authenticate. // @Tags Vaults // @Produce json @@ -48,7 +48,7 @@ func (h *Handler) adminDeleteVault(w http.ResponseWriter, r *http.Request) { h.deleteVault(w, r) } -// @Summary List safe Vault Credential metadata in a managed key space +// @Summary List safe Vault Credential metadata in a Project // @Description Core key only. Reuses the public resource projection and operation rules; the Project ID selects the target space and does not authenticate. // @Tags Credentials // @Produce json @@ -67,7 +67,7 @@ func (h *Handler) adminListCredentials(w http.ResponseWriter, r *http.Request) { h.listCredentials(w, r) } -// @Summary Retrieve safe Vault Credential metadata in a managed key space +// @Summary Retrieve safe Vault Credential metadata in a Project // @Description Core key only. Reuses the public resource projection and operation rules; the Project ID selects the target space and does not authenticate. // @Tags Credentials // @Produce json @@ -82,7 +82,7 @@ func (h *Handler) adminGetCredential(w http.ResponseWriter, r *http.Request) { h.getCredential(w, r) } -// @Summary Delete a Vault Credential in a managed key space +// @Summary Delete a Vault Credential in a Project // @Description Core key only. Reuses the public resource projection and operation rules; the Project ID selects the target space and does not authenticate. // @Tags Credentials // @Produce json From f9e59967e36254e256bd77d6ed4d1b14a8781b59 Mon Sep 17 00:00:00 2001 From: saladday <1203511142@qq.com> Date: Fri, 25 Sep 2026 13:17:44 +0000 Subject: [PATCH 20/30] Read the console's data through Core's new routes --- apps/web/.impeccable/surfaces/src-app-tsx.md | 2 +- apps/web/PRODUCT.md | 16 +++-- apps/web/README.md | 5 +- apps/web/e2e/data/admin.mjs | 2 +- apps/web/e2e/data/core-metrics.mjs | 2 +- apps/web/e2e/fixture-console.mjs | 69 +++++++++++++++---- .../src/features/dashboard/runtime-history.ts | 4 +- .../web/src/features/files/file-operations.ts | 6 +- .../features/metrics/agent-metrics-loader.ts | 4 +- .../src/features/metrics/metrics-queries.ts | 2 +- .../src/features/metrics/project-sessions.ts | 4 +- .../src/features/metrics/sandbox-runtime.ts | 4 +- .../src/features/sessions/session-history.ts | 4 +- apps/web/src/features/sessions/session-log.ts | 4 +- .../src/features/sessions/session-runtime.ts | 4 +- .../features/skills/skill-operations.test.ts | 4 +- .../src/features/skills/skill-operations.ts | 8 +-- .../src/features/vaults/vault-catalog.test.ts | 4 +- apps/web/src/features/vaults/vault-catalog.ts | 4 +- apps/web/src/i18n/locales/en/metrics.ts | 2 +- apps/web/src/i18n/locales/zh-CN/metrics.ts | 2 +- apps/web/src/lib/admin-view.ts | 2 +- apps/web/src/lib/projects.tsx | 28 +++----- apps/web/vite.config.ts | 3 +- .../web/admin-metrics-backend-requirements.md | 32 ++++----- ...dmin-metrics-backend-requirements.zh-CN.md | 22 +++--- docs/web/architecture.md | 7 +- docs/web/core-connection.md | 7 +- docs/web/core-process-metrics-requirements.md | 2 +- docs/web/protocol-coverage.md | 64 ++++++++++------- 30 files changed, 188 insertions(+), 135 deletions(-) diff --git a/apps/web/.impeccable/surfaces/src-app-tsx.md b/apps/web/.impeccable/surfaces/src-app-tsx.md index 2da2a95df..3b8127401 100644 --- a/apps/web/.impeccable/surfaces/src-app-tsx.md +++ b/apps/web/.impeccable/surfaces/src-app-tsx.md @@ -9,7 +9,7 @@ related_targets: ["src/ConsoleApp.tsx"] Scope: the signed-in console shell and every page behind it, plus the first-run step. Visitor mode: Operate. Audience: the administrator of one Parsar Core deployment. Task: judge health, capacity, usage and failures; inspect and delete or copy project assets; manage projects, keys and nodes. -Constraints: Web API only (`/core/v1/admin`, `/core/v1/sandbox`); missing data stays visibly missing; no small print, explanations live in help tips; API terms stay English in Chinese copy; zh-CN and English, light and dark. +Constraints: Web API only (`/core/v1`); missing data stays visibly missing; no small print, explanations live in help tips; API terms stay English in Chinese copy; zh-CN and English, light and dark. Information architecture: Monitor (Overview, Agent metrics, Sandbox metrics, Session log) · Resources (Agents, Environment templates, Skills, Files, Vaults) · Platform (Projects and keys, Nodes, System). diff --git a/apps/web/PRODUCT.md b/apps/web/PRODUCT.md index 8151b28a6..7f88d7842 100644 --- a/apps/web/PRODUCT.md +++ b/apps/web/PRODUCT.md @@ -47,13 +47,15 @@ workbench. [Core key](../../docs/getting-started/operations.md#core-key)). There are no console accounts or usernames. The browser sends the key only to sign in and keeps only the session cookie; the console server holds the Core key and forwards - the Web API (`/core/v1/admin/**`) and sandbox administration - (`/core/v1/sandbox/**`). The console never calls `/v1`. + the Web API (`/core/v1/**`, including sandbox administration under + `/core/v1/sandbox/**`). The console never calls `/v1`. - The Core key is not an Agents API identity and cannot call `/v1`. An administrator who wants to call the Agents API issues a project API key like any other caller. -- `/console/config` reports whether sandbox administration is available; without - it the Nodes page explains that it is not configured and the fleet figures show - as unavailable. +- `/console/config` reports the node installer (`node_installer`, + `node_installer_sha256`). Signing in grants administration, so sandbox + administration is available unless the console explicitly reports + `sandbox_admin: false`; then the Nodes page explains that it is not configured + and the fleet figures show as unavailable. - Chinese and English UI; light and dark themes; reduced motion honored. ## Information Architecture @@ -106,8 +108,8 @@ workbench. a key never touches assets. Archiving a project revokes every key and keeps its assets viewable and deletable. Key plaintext is shown once, at issuance, and never stored by the console. -- **Web API only.** Every read and write goes through `/core/v1/admin/**` or - `/core/v1/sandbox/**`. The console holds no API key and sends nothing to `/v1`. +- **Web API only.** Every read and write goes through `/core/v1/**`. The console + holds no API key and sends nothing to `/v1`. - **No asset writes except delete.** Assets are created and changed only by a project's keys through the Agents API. The console does not create or edit Agents or Templates, upload Skills or Files, create or replace Credentials, start diff --git a/apps/web/README.md b/apps/web/README.md index bc2db8a6c..035b8db4e 100644 --- a/apps/web/README.md +++ b/apps/web/README.md @@ -7,8 +7,9 @@ described in [DESIGN.md](DESIGN.md) and its product scope in [PRODUCT.md](PRODUC ## Integration contract -Browser management requests use same-origin `/core/v1/admin` through `AdminClient`, -plus the existing sandbox management client for allowed `/core/v1/sandbox` routes. +Browser management requests use same-origin `/core/v1` through `AdminClient`, +`CoreMetricsClient` (`/core/v1/metrics`) and the sandbox management client +(`/core/v1/sandbox`). The administrator signs in with the deployment's Core key; the console keeps the key server-side and gives the browser only a session cookie. Applications use their own Project keys directly against Core's public `/v1` API. diff --git a/apps/web/e2e/data/admin.mjs b/apps/web/e2e/data/admin.mjs index d4e06b57b..b3a4ff179 100644 --- a/apps/web/e2e/data/admin.mjs +++ b/apps/web/e2e/data/admin.mjs @@ -1,4 +1,4 @@ -// Synthetic management-plane data (/core/v1/admin/**) for the browser acceptance fixture. +// Synthetic management-plane data (/core/v1/**) for the browser acceptance fixture. // Projects own isolated assets shared by their named keys; the base demo's // resources are split across projects so every page can be filtered. import { agentProject } from "./agents.mjs"; diff --git a/apps/web/e2e/data/core-metrics.mjs b/apps/web/e2e/data/core-metrics.mjs index 5640f334f..60a1e9412 100644 --- a/apps/web/e2e/data/core-metrics.mjs +++ b/apps/web/e2e/data/core-metrics.mjs @@ -1,4 +1,4 @@ -// Synthetic Core metrics for the browser acceptance fixture (GET /core/v1/admin/core-metrics). +// Synthetic Core metrics for the browser acceptance fixture (GET /core/v1/metrics). const RANGES = { "1h": [60, 60], "6h": [72, 300], "24h": [96, 900], "7d": [84, 7200] }; export function coreMetrics(range = "1h", now = Math.floor(Date.now() / 1000)) { diff --git a/apps/web/e2e/fixture-console.mjs b/apps/web/e2e/fixture-console.mjs index a68cc810c..2a7856d2e 100644 --- a/apps/web/e2e/fixture-console.mjs +++ b/apps/web/e2e/fixture-console.mjs @@ -1,8 +1,9 @@ // Browser acceptance fixture: the console service's routes (/console/**) and the -// management surfaces it forwards (/core/v1/admin/**, /core/v1/sandbox/**), with -// synthetic, deterministic data and in-memory writes. It never serves /v1; any -// /v1 request, and any browser-supplied Authorization header, is recorded so a -// test can assert that the console stays on its management boundary. +// Core management tree it forwards (/core/v1/**: projects, summary, audit log, +// metrics and /core/v1/sandbox/**), with synthetic, deterministic data and +// in-memory writes. It never serves /v1; any /v1 request, and any +// browser-supplied Authorization header, is recorded so a test can assert that +// the console stays on its management boundary. import http from "node:http"; import { buildAdmin } from "./data/admin.mjs"; @@ -47,6 +48,8 @@ function reset(mode = "login", fresh = false, sandbox = "configured") { // "authenticated": the console holds a session for the fixture cookie; "login": it holds none. auth: { mode: mode === "authenticated" ? "authenticated" : "login", failures: 0, lockedUntil: 0 }, violations: [], writes: [], failNext: null, nextId: 1, + // Executor credential metadata by environment ID; tokens are never kept. + executorCredentials: new Map(), // "none": the deployment is not configured yet, so the Nodes page offers setup. deployment: sandbox === "none" ? null : sandbox === "e2b" ? e2bDeployment() : configuredDeployment(), }; @@ -116,6 +119,7 @@ async function consoleRoute(request, response, url) { return send(response, 200, { mode: "login" }, { "set-cookie": `${SESSION_COOKIE.split("=")[0]}=; Path=/; Max-Age=0` }); } if (url.pathname === "/console/config") { + // Signing in grants administration, so the console reports only its node installer. return send(response, 200, { node_installer: true, node_installer_sha256: "a".repeat(64) }); } return error(response, 404, "Not found."); @@ -166,13 +170,7 @@ function adminRead(response, path, url) { const a = state.admin; if (path === "/projects") return send(response, 200, { data: a.projects.map(a.publicProject), has_more: false }); if (path === "/summary") return send(response, 200, a.summary(url)); - if (path === "/runtime-observations") { - // Only E2B reports a sandbox's disk. - const e2b = state.deployment?.provider === "e2b"; - const data = a.runtimeObservations().map((entry) => ({ ...entry, observation: { ...entry.observation, disk: e2b && entry.observation.status === "observed" ? { usage_bytes: 3 * 2 ** 30, limit_bytes: 10 * 2 ** 30 } : null } })); - return send(response, 200, { object: "list", data, has_more: false, first_id: data[0]?.observation.id ?? null, last_id: data.at(-1)?.observation.id ?? null }); - } - if (path === "/core-metrics") return send(response, 200, coreMetrics(url.searchParams.get("range") ?? "1h")); + if (path === "/metrics") return send(response, 200, coreMetrics(url.searchParams.get("range") ?? "1h")); const match = path.match(/^\/projects\/([^/]+)(\/.*)?$/); const project = match && a.projects.find((entry) => entry.id === match[1]); if (!project) return error(response, 404, "No such project."); @@ -226,6 +224,12 @@ function nodeDetail(node) { } async function sandboxRoute(request, response, path) { + if (path === "/runtime-observations" && request.method === "GET") { + // Only E2B reports a sandbox's disk. + const e2b = state.deployment?.provider === "e2b"; + const data = state.admin.runtimeObservations().map((entry) => ({ ...entry, observation: { ...entry.observation, disk: e2b && entry.observation.status === "observed" ? { usage_bytes: 3 * 2 ** 30, limit_bytes: 10 * 2 ** 30 } : null } })); + return send(response, 200, { object: "list", data, has_more: false, first_id: data[0]?.observation.id ?? null, last_id: data.at(-1)?.observation.id ?? null }); + } if (path === "/deployment" && request.method === "POST") { const input = await body(request); if (state.deployment) return error(response, 409, "The sandbox deployment is already configured.", "sandbox_deployment_conflict"); @@ -272,6 +276,41 @@ async function sandboxRoute(request, response, path) { return error(response, 404, "Not found."); } +const UUID = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i; +const EXECUTOR_CREDENTIALS = /^\/projects\/([^/]+)\/environments\/([^/]+)\/executor-credentials(?:\/([^/]+))?$/; + +/** + * Executor credentials as Core issues them: only for the self_hosted + * environment of a Session in the project; the token is returned once; an + * existing key_id is reissued only with rotate:true, and a revoked one never; + * revoking again is safe. + */ +async function executorCredentialRoute(request, response, projectId, environmentId, keyId) { + const project = state.admin.projects.find((entry) => entry.id === projectId); + const session = project && state.admin.collections(project.id).sessions.find((entry) => entry.environment.type === "self_hosted" && entry.environment.id === environmentId); + if (!session) return error(response, 404, "No such self-hosted environment.", "not_found"); + if (!state.executorCredentials.has(environmentId)) state.executorCredentials.set(environmentId, []); + const credentials = state.executorCredentials.get(environmentId); + if (!keyId && request.method === "GET") return send(response, 200, { data: credentials.map((entry) => ({ ...entry })) }); + if (!keyId && request.method === "POST") { + const input = await body(request); + if (typeof input.key_id !== "string" || !UUID.test(input.key_id) || (input.rotate !== undefined && typeof input.rotate !== "boolean")) return error(response, 400, "key_id must be a UUID.", "invalid_request"); + const existing = credentials.find((entry) => entry.key_id === input.key_id); + if (existing && input.rotate !== true) return error(response, 409, "The executor credential exists; rotate it instead.", "executor_credential_exists"); + if (existing?.revoked_at) return error(response, 409, "The executor credential is revoked.", "executor_credential_revoked"); + if (!existing) credentials.push({ key_id: input.key_id, created_at: new Date().toISOString(), revoked_at: null }); + return send(response, 201, { key_id: input.key_id, environment_id: environmentId, executor_token: `exec_fixture_${state.nextId++}` }); + } + if (keyId && request.method === "DELETE") { + const existing = credentials.find((entry) => entry.key_id === keyId); + if (!existing) return error(response, 404, "No such executor credential.", "not_found"); + existing.revoked_at ??= new Date().toISOString(); + response.writeHead(204, { "cache-control": "no-store" }); + return response.end(); + } + return error(response, 404, "Not found."); +} + /** Test controls: reset state, inject one failure, and read what the browser sent. */ async function fixtureRoute(request, response, url) { if (url.pathname === "/__fixture/health") return send(response, 200, { ok: true }); @@ -308,11 +347,13 @@ http.createServer(async (request, response) => { state.failNext = null; return error(response, fail.status, fail.message ?? "Injected failure.", fail.code ?? null); } - if (url.pathname.startsWith("/core/v1/admin/")) { - const path = url.pathname.slice("/core/v1/admin".length); + if (url.pathname.startsWith("/core/v1/sandbox/")) return await sandboxRoute(request, response, url.pathname.slice("/core/v1/sandbox".length)); + if (url.pathname.startsWith("/core/v1/")) { + const path = url.pathname.slice("/core/v1".length); + const credentials = path.match(EXECUTOR_CREDENTIALS); + if (credentials) return await executorCredentialRoute(request, response, credentials[1], credentials[2], credentials[3]); return write ? await adminWrite(request, response, path) : adminRead(response, path, url); } - if (url.pathname.startsWith("/core/v1/sandbox/")) return await sandboxRoute(request, response, url.pathname.slice("/core/v1/sandbox".length)); return error(response, 404, "Not found."); } catch (caught) { error(response, 500, String(caught)); diff --git a/apps/web/src/features/dashboard/runtime-history.ts b/apps/web/src/features/dashboard/runtime-history.ts index 880fcc5e9..4e7560ec5 100644 --- a/apps/web/src/features/dashboard/runtime-history.ts +++ b/apps/web/src/features/dashboard/runtime-history.ts @@ -1,4 +1,4 @@ -import { AgentCoreError, type AgentCore, type AgentSession, type RuntimeHistory } from "@agents-core-web/agents-client"; +import { AgentCoreError, type AgentSession, type CoreProjectReader, type RuntimeHistory } from "@agents-core-web/agents-client"; import type { RuntimeDashboardSnapshot } from "./runtime-snapshot"; import { deriveTokenThroughput, type RuntimeTrendSample, type RuntimeTrendTarget } from "./runtime-trends"; @@ -160,7 +160,7 @@ export function runtimeDurableTrendSamples( return deriveTokenThroughput(samples); } -export type RuntimeHistoryReader = Pick; +export type RuntimeHistoryReader = Pick; function isNotFound(error: unknown): boolean { return error instanceof AgentCoreError && error.status === 404; diff --git a/apps/web/src/features/files/file-operations.ts b/apps/web/src/features/files/file-operations.ts index e9cc3c97f..73ea8a936 100644 --- a/apps/web/src/features/files/file-operations.ts +++ b/apps/web/src/features/files/file-operations.ts @@ -1,6 +1,6 @@ import { AgentCoreError, - type AgentCore, + type CoreProjectReader, type PageOrder, type SourceFileListEntry, } from "@agents-core-web/agents-client"; @@ -12,8 +12,6 @@ export const maxFileUploadBytes = 512 * 1024 * 1024; /** One page of the Files list; Core allows up to 10000, but the console reads 100 at a time. */ export const filesPageSize = 100; -export type FilesOperations = Pick; - export type UploadPrecheck = "too-large" | "bad-name" | null; /** Browser checks before any byte is sent; Core still validates the upload. */ @@ -78,7 +76,7 @@ export function filterFiles(files: readonly SourceFileListEntry[], query: string /** Reads one page after the loaded rows and applies the shared identity and cursor checks. */ export async function readFilesPage( - core: Pick, + core: Pick, loaded: readonly SourceFileListEntry[], order: PageOrder, after: string | undefined, diff --git a/apps/web/src/features/metrics/agent-metrics-loader.ts b/apps/web/src/features/metrics/agent-metrics-loader.ts index 1b174b0f4..7134b5c43 100644 --- a/apps/web/src/features/metrics/agent-metrics-loader.ts +++ b/apps/web/src/features/metrics/agent-metrics-loader.ts @@ -1,4 +1,4 @@ -import type { AgentSession, AgentTurn, ListPage, OpenAIAgentsClient, PageOptions, SessionItem } from "@agents-core-web/agents-client"; +import type { AgentSession, AgentTurn, ListPage, CoreProjectReader, PageOptions, SessionItem } from "@agents-core-web/agents-client"; import type { MetricsCoverage, MetricsWindow, SessionActivity } from "./agent-metrics"; import { readProjectsSessions, type InProject, type ProjectReadFailure, type SessionLister } from "./project-sessions"; @@ -200,7 +200,7 @@ export async function loadAgentMetricsActivity( /** Most Sessions listed per project when looking for Sessions active in the range. */ export const PROJECT_SESSION_LIST_CAP = 2_000; -type ProjectReader = SessionLister & Pick; +type ProjectReader = SessionLister & Pick; /** Routes each Session's Turn and Item reads to the admin scope of its project. */ export function projectMetricsSource(sessions: readonly InProject[], clientFor: (project: Project) => AgentMetricsSource): AgentMetricsSource { diff --git a/apps/web/src/features/metrics/metrics-queries.ts b/apps/web/src/features/metrics/metrics-queries.ts index adda7d95b..7443ff012 100644 --- a/apps/web/src/features/metrics/metrics-queries.ts +++ b/apps/web/src/features/metrics/metrics-queries.ts @@ -65,7 +65,7 @@ export function keyUsageQuery(filter: string, range: AgentMetricsRange) { }); } -const coreMetricsClient = new CoreMetricsClient({ baseUrl: "/core/v1/admin" }); +const coreMetricsClient = new CoreMetricsClient(); /** Core's own metrics over a range, aggregated by Core. */ export function coreMetricsQuery(range: CoreMetricsRange) { diff --git a/apps/web/src/features/metrics/project-sessions.ts b/apps/web/src/features/metrics/project-sessions.ts index 5ead6710f..754865eaa 100644 --- a/apps/web/src/features/metrics/project-sessions.ts +++ b/apps/web/src/features/metrics/project-sessions.ts @@ -1,4 +1,4 @@ -import type { AgentSession, OpenAIAgentsClient } from "@agents-core-web/agents-client"; +import type { AgentSession, CoreProjectReader } from "@agents-core-web/agents-client"; import type { Owned } from "../../lib/projects"; import { type Project } from "../../lib/admin-view"; @@ -12,7 +12,7 @@ import { type Project } from "../../lib/admin-view"; export const SESSION_PAGE_SIZE = 100; -export type SessionLister = Pick; +export type SessionLister = Pick; /** A value and the project it belongs to. */ export type InProject = Owned; diff --git a/apps/web/src/features/metrics/sandbox-runtime.ts b/apps/web/src/features/metrics/sandbox-runtime.ts index 4b315a1d2..c68cb4b16 100644 --- a/apps/web/src/features/metrics/sandbox-runtime.ts +++ b/apps/web/src/features/metrics/sandbox-runtime.ts @@ -1,4 +1,4 @@ -import type { AgentSession, OpenAIAgentsClient, SandboxAllocation, SandboxNode } from "@agents-core-web/agents-client"; +import type { AgentSession, CoreProjectReader, SandboxAllocation, SandboxNode } from "@agents-core-web/agents-client"; import type { RuntimeDashboardSnapshot } from "../dashboard/runtime-snapshot"; import { type OwnedRuntimeObservation } from "../../lib/admin-view"; @@ -14,7 +14,7 @@ import { type OwnedRuntimeObservation } from "../../lib/admin-view"; export const HOSTED_SESSION_LIMIT = 100; const SESSION_READ_CONCURRENCY = 6; -export type SessionReader = Pick; +export type SessionReader = Pick; export interface HostedRuntimeLoad { observations: OwnedRuntimeObservation[]; diff --git a/apps/web/src/features/sessions/session-history.ts b/apps/web/src/features/sessions/session-history.ts index ee1d6abbe..a343f7057 100644 --- a/apps/web/src/features/sessions/session-history.ts +++ b/apps/web/src/features/sessions/session-history.ts @@ -2,7 +2,7 @@ import type { AgentSession, AgentTurn, ListPage, - OpenAIAgentsClient, + CoreProjectReader, SessionItem, } from "@agents-core-web/agents-client"; @@ -82,7 +82,7 @@ export interface SessionHistory { loadedAt: number; } -type HistoryReader = Pick; +type HistoryReader = Pick; function message(error: unknown): string { return error instanceof Error ? error.message : String(error); diff --git a/apps/web/src/features/sessions/session-log.ts b/apps/web/src/features/sessions/session-log.ts index 024ff076f..4f81f8df6 100644 --- a/apps/web/src/features/sessions/session-log.ts +++ b/apps/web/src/features/sessions/session-log.ts @@ -1,4 +1,4 @@ -import type { AgentSession, OpenAIAgentsClient } from "@agents-core-web/agents-client"; +import type { AgentSession, CoreProjectReader } from "@agents-core-web/agents-client"; import type { Owned } from "../../lib/projects"; @@ -32,7 +32,7 @@ export interface SessionLogFilters { export const initialSessionLogFilters: SessionLogFilters = { status: "all", agentId: "", environment: "", query: "" }; -type TolerantLister = Pick; +type TolerantLister = Pick; /** Walks every Session page of one project, newest first, bounded at `limit` entries. */ export async function readSessionLog(client: TolerantLister, signal?: AbortSignal, limit = SESSION_LOG_LIMIT): Promise { diff --git a/apps/web/src/features/sessions/session-runtime.ts b/apps/web/src/features/sessions/session-runtime.ts index 90454e9fc..e04927601 100644 --- a/apps/web/src/features/sessions/session-runtime.ts +++ b/apps/web/src/features/sessions/session-runtime.ts @@ -1,4 +1,4 @@ -import { AgentCoreError, type AgentSession, type OpenAIAgentsClient } from "@agents-core-web/agents-client"; +import { AgentCoreError, type AgentSession, type CoreProjectReader } from "@agents-core-web/agents-client"; import { RUNTIME_DURABLE_MAX_POINTS, RUNTIME_DURABLE_RANGES, runtimeDurableTrendSamples, type RuntimeDurableRange } from "../dashboard/runtime-history"; import type { RuntimeTrendSample } from "../dashboard/runtime-trends"; @@ -24,7 +24,7 @@ export function hasObservableRuntime(session: AgentSession): boolean { * ending now. Resolves to null when Core keeps no history for it (404). */ export async function loadSessionRuntimeHistory( - client: Pick, + client: Pick, session: AgentSession, range: SessionRuntimeRange, signal?: AbortSignal, diff --git a/apps/web/src/features/skills/skill-operations.test.ts b/apps/web/src/features/skills/skill-operations.test.ts index a1a574d5d..de933463f 100644 --- a/apps/web/src/features/skills/skill-operations.test.ts +++ b/apps/web/src/features/skills/skill-operations.test.ts @@ -1,6 +1,6 @@ import { describe, expect, it, vi } from "vitest"; -import { AgentCoreError, type AgentCore, type Skill, type SkillList, type SkillVersion, type SkillVersionList } from "@agents-core-web/agents-client"; +import { AgentCoreError, type CoreProjectReader, type Skill, type SkillList, type SkillVersion, type SkillVersionList } from "@agents-core-web/agents-client"; import i18n from "../../i18n"; import { @@ -94,7 +94,7 @@ describe("Skill list helpers", () => { const core = { downloadSkill: vi.fn(async () => content), downloadSkillVersion: vi.fn(async () => content), - } satisfies Pick; + } satisfies Pick; const save = vi.fn(); const skill = skillFixture({ default_version: "2", latest_version: "3" }); diff --git a/apps/web/src/features/skills/skill-operations.ts b/apps/web/src/features/skills/skill-operations.ts index 5055de207..a4b7b0354 100644 --- a/apps/web/src/features/skills/skill-operations.ts +++ b/apps/web/src/features/skills/skill-operations.ts @@ -1,4 +1,4 @@ -import { AgentCoreError, type AgentCore, type Skill, type SkillVersion } from "@agents-core-web/agents-client"; +import { AgentCoreError, type CoreProjectReader, type Skill, type SkillVersion } from "@agents-core-web/agents-client"; import { appendCollectionPage } from "../../lib/collection-pagination"; @@ -93,7 +93,7 @@ export function saveBlob(blob: Blob, filename: string): void { /** Downloads the default version, or one exact version, through the client. */ export async function downloadSkillArchive( - core: Pick, + core: Pick, skill: Skill, version?: SkillVersion, signal?: AbortSignal, @@ -111,7 +111,7 @@ export async function downloadSkillArchive( /** Reads the next Skill page (newest first) and appends it to the loaded rows. */ export async function readSkillsPage( - core: Pick, + core: Pick, loaded: readonly Skill[], after: string | undefined, signal?: AbortSignal, @@ -122,7 +122,7 @@ export async function readSkillsPage( /** Reads the next version page (highest version first) and appends it to the loaded rows. */ export async function readSkillVersionsPage( - core: Pick, + core: Pick, skillId: string, loaded: readonly SkillVersion[], after: string | undefined, diff --git a/apps/web/src/features/vaults/vault-catalog.test.ts b/apps/web/src/features/vaults/vault-catalog.test.ts index 0babacde9..192f2e65c 100644 --- a/apps/web/src/features/vaults/vault-catalog.test.ts +++ b/apps/web/src/features/vaults/vault-catalog.test.ts @@ -1,6 +1,6 @@ import { describe, expect, it, vi } from "vitest"; -import type { AgentCore, SavedAgent, Vault, VaultCredential } from "@agents-core-web/agents-client"; +import type { CoreProjectReader, SavedAgent, Vault, VaultCredential } from "@agents-core-web/agents-client"; import { deriveSessionVaultPlan, loadVaultCatalog, matchingCredentials, type VaultCatalog } from "./vault-catalog"; @@ -65,7 +65,7 @@ describe("Vault catalog", () => { first_id: vaultId === vaultA.id ? credentialA.id : credentialB.id, last_id: vaultId === vaultA.id ? credentialA.id : credentialB.id, })); - const core = { listVaults, listVaultCredentials } as unknown as AgentCore; + const core = { listVaults, listVaultCredentials } as unknown as CoreProjectReader; await expect(loadVaultCatalog(core)).resolves.toEqual({ vaults: [vaultA, vaultB], diff --git a/apps/web/src/features/vaults/vault-catalog.ts b/apps/web/src/features/vaults/vault-catalog.ts index b5ea02979..d04367db7 100644 --- a/apps/web/src/features/vaults/vault-catalog.ts +++ b/apps/web/src/features/vaults/vault-catalog.ts @@ -1,5 +1,5 @@ import type { - AgentCore, + CoreProjectReader, SavedAgent, Vault, VaultCredential, @@ -40,7 +40,7 @@ export interface SessionVaultPlan { const CREDENTIAL_READ_CONCURRENCY = 4; -export async function loadVaultCatalog(core: AgentCore, signal?: AbortSignal): Promise { +export async function loadVaultCatalog(core: Pick, signal?: AbortSignal): Promise { const vaults = await listAllCollectionPages( (options) => core.listVaults(options), signal, diff --git a/apps/web/src/i18n/locales/en/metrics.ts b/apps/web/src/i18n/locales/en/metrics.ts index 00c760e77..12122a13e 100644 --- a/apps/web/src/i18n/locales/en/metrics.ts +++ b/apps/web/src/i18n/locales/en/metrics.ts @@ -107,7 +107,7 @@ export const metrics = { failed: "Core metrics could not be read. {{reason}}", stale: "Refresh failed; showing the last metrics read. {{reason}}", missingTitle: "Core does not report its own metrics yet", - missingDescription: "Core needs to provide /core/v1/admin/core-metrics before execution, database and background jobs can show.", + missingDescription: "Core needs to provide /core/v1/metrics before execution, database and background jobs can show.", kpiLabel: "Core summary", meta: "{{status}} · {{revision}} · up {{uptime}}", notOwner: "Does not hold the execution lease", diff --git a/apps/web/src/i18n/locales/zh-CN/metrics.ts b/apps/web/src/i18n/locales/zh-CN/metrics.ts index 8e6bbde9c..9177c42b2 100644 --- a/apps/web/src/i18n/locales/zh-CN/metrics.ts +++ b/apps/web/src/i18n/locales/zh-CN/metrics.ts @@ -107,7 +107,7 @@ export const metrics = { failed: "无法读取 Core 指标。{{reason}}", stale: "刷新失败,显示的是上次读取的指标。{{reason}}", missingTitle: "Core 尚未上报自身指标", - missingDescription: "需要 Core 提供 /core/v1/admin/core-metrics,才能显示执行、数据库和后台任务。", + missingDescription: "需要 Core 提供 /core/v1/metrics,才能显示执行、数据库和后台任务。", kpiLabel: "Core 概况", meta: "{{status}} · {{revision}} · 已运行 {{uptime}}", notOwner: "未持有执行归属", diff --git a/apps/web/src/lib/admin-view.ts b/apps/web/src/lib/admin-view.ts index 94b011358..455502696 100644 --- a/apps/web/src/lib/admin-view.ts +++ b/apps/web/src/lib/admin-view.ts @@ -11,7 +11,7 @@ import { } from "@agents-core-web/agents-client"; /** - * View models over the typed management client (`AdminClient`, `/core/v1/admin`). + * View models over the typed management client (`AdminClient`, `/core/v1`). * Pages format Unix seconds and never treat missing data as zero, so this * layer converts RFC 3339 timestamps, resolves creating keys by name, walks * cursor pages and turns an unmeasured usage sum into `null`. diff --git a/apps/web/src/lib/projects.tsx b/apps/web/src/lib/projects.tsx index c565ad03d..897384629 100644 --- a/apps/web/src/lib/projects.tsx +++ b/apps/web/src/lib/projects.tsx @@ -1,4 +1,4 @@ -import type { OpenAIAgentsClient } from "@agents-core-web/agents-client"; +import type { CoreProjectReader } from "@agents-core-web/agents-client"; import { QueryClientProvider, useQueries, useQuery, useQueryClient } from "@tanstack/react-query"; import { createContext, useCallback, useContext, useEffect, useMemo, useRef, useState, type ReactNode } from "react"; import { useTranslation } from "react-i18next"; @@ -12,7 +12,7 @@ export { admin }; export type { Project }; /** - * The console reaches Core only through the Web API (`/core/v1/admin`). A + * The console reaches Core only through the Web API (`/core/v1`). A * project owns an isolated set of assets shared by all of its named API keys; * pages filter by project and read each project through `projectClient`. */ @@ -108,15 +108,8 @@ export interface ProjectCollection { refresh: () => void; } -/** The public-client reads and deletes a project page uses, bound to one project. */ -export type ProjectClient = Pick; +/** The Core reads and deletes a project page uses, bound to one project. */ +export type ProjectClient = CoreProjectReader; async function content(result: Promise<{ blob: Blob; contentType: string | null; contentDisposition: string | null }>) { const value = await result; @@ -124,13 +117,12 @@ async function content(result: Promise<{ blob: Blob; contentType: string | null; } /** - * Binds the management client to one project with the public client's method - * shapes, so pages written against the public projections read a project - * through `/core/v1/admin/projects/{id}` without change. Deletions only; no - * creation or editing exists here. + * Binds the management client to one project in the public projections' + * shapes, so pages read a project through `/core/v1/projects/{id}`. + * Deletions only; no creation or editing exists here. */ -function createProjectClient(projectId: string): ProjectClient { - const listSessions = async (options: Parameters[0] = {}) => { +function createProjectClient(projectId: string): CoreProjectReader { + const listSessions = async (options: Parameters[0] = {}) => { const page = await admin.listSessions(projectId, { after: options.after, limit: options.limit, order: options.order, agentId: options.agentId, signal: options.signal }); return { ...page, object: "list" as const, first_id: page.first_id ?? null, last_id: page.last_id ?? null }; }; @@ -164,7 +156,7 @@ function createProjectClient(projectId: string): ProjectClient { listItems: (sessionId, options) => admin.listItems(projectId, sessionId, options), retrieveRuntimeObservation: (sessionId, options) => admin.retrieveRuntimeObservation(projectId, sessionId, options), retrieveRuntimeHistory: (sessionId, query) => admin.retrieveRuntimeHistory(projectId, sessionId, query), - } as ProjectClient; + } as CoreProjectReader; return client; } diff --git a/apps/web/vite.config.ts b/apps/web/vite.config.ts index 04d40b805..c141e599f 100644 --- a/apps/web/vite.config.ts +++ b/apps/web/vite.config.ts @@ -53,8 +53,7 @@ export default defineConfig(({ command, mode }) => { // management surfaces it forwards, as in production. "/console": { target, changeOrigin: true }, "/node-install": { target, changeOrigin: true }, - "/core/v1/sandbox": { target, changeOrigin: true }, - "/core/v1/admin": { target, changeOrigin: true }, + "/core/v1": { target, changeOrigin: true }, "/v1": { target, changeOrigin: true, diff --git a/docs/web/admin-metrics-backend-requirements.md b/docs/web/admin-metrics-backend-requirements.md index 38b95eb2f..fe80f8237 100644 --- a/docs/web/admin-metrics-backend-requirements.md +++ b/docs/web/admin-metrics-backend-requirements.md @@ -5,9 +5,9 @@ implemented beyond the Web API routes it names as existing. [简体中文](admin-metrics-backend-requirements.zh-CN.md) The console is a management tool: Monitor (Overview, Agent metrics, Sandbox -metrics, Session log) leads. It reads only the Web API (`/core/v1/admin/**`) -and the sandbox administration routes (`/core/v1/sandbox/**`); it never calls -`/v1`. Some figures come from Web API aggregates, others are still assembled +metrics, Session log) leads. It reads only the Web API (`/core/v1/**`, +including the sandbox administration routes under `/core/v1/sandbox/**`); it +never calls `/v1`. Some figures come from Web API aggregates, others are still assembled in the browser from bounded reads of each project. This document records what that costs, where it is incomplete, and which Web API endpoints would replace the browser work. @@ -17,7 +17,7 @@ the browser work. 1. **Public Agents API** (`/v1/**`): must stay identical to the pinned OpenAI Agents API and its documented Core extensions. Metrics work must not add fields, routes or behavior here. -2. **Web API** (`/core/v1/admin/**`, plus `/core/v1/sandbox/**` for sandbox +2. **Web API** (`/core/v1/**`, including `/core/v1/sandbox/**` for sandbox administration): called only by the console server with the deployment administrator credential. Every endpoint proposed below belongs here and is forwarded by `services/core-console`. @@ -28,7 +28,7 @@ the browser work. | --- | --- | --- | | Overview | `GET /summary` (per project: asset counts, Sessions by status, usage, coverage, last activity); `/core/v1/sandbox` deployment and nodes; each project's Session list for the 24-hour activity chart and the Sessions needing attention | Session lists stop once they pass the 24-hour window and have found every Session the summary counts as needing attention, at most 1,000 per project; projects idle since before the window are not read | | Agent metrics | `GET /summary` to skip idle projects; each project's Session list, then Turns and Items of the most recently active Sessions through the project's scope; `GET /summary?group_by=key` for usage by API key | 2,000 Sessions listed per project; 200 Sessions read per load, 10 Turn pages and 5 Item pages per Session, 15 s per Session and 45 s per load | -| Sandbox metrics | `/core/v1/sandbox` nodes and allocations; `GET /runtime-observations` (every project); each hosted Session read by ID through its project; Runtime history per hosted Session | 100 hosted Sessions read per refresh; history covers at most 24 hosted Sessions; host figures are free memory/disk and CPU count only | +| Sandbox metrics | `/core/v1/sandbox` nodes and allocations; `GET /core/v1/sandbox/runtime-observations` (every project); each hosted Session read by ID through its project; Runtime history per hosted Session | 100 hosted Sessions read per refresh; history covers at most 24 hosted Sessions; host figures are free memory/disk and CPU count only | Consequences the console states in its help tips and warnings: @@ -54,7 +54,7 @@ operational evidence, not billing. ### P0 — Agent run aggregates -`GET /core/v1/admin/metrics/agent-runs?start=&end=&step=&group_by=model|agent|harness|project|key&project_id=` +`GET /core/v1/metrics/agent-runs?start=&end=&step=&group_by=model|agent|harness|project|key&project_id=` Per bucket, and per group when `group_by` is set: @@ -69,7 +69,7 @@ This replaces the Turn fan-out on Agent metrics and makes long ranges complete. ### P0 — Tool call aggregates -`GET /core/v1/admin/metrics/tool-calls?start=&end=&step=&group_by=tool|kind|agent&project_id=` +`GET /core/v1/metrics/tool-calls?start=&end=&step=&group_by=tool|kind|agent&project_id=` Per bucket and tool (`function` name, MCP `server_label` + name, shell command, web search, Subagent): calls, failures, and duration where the Item reports one. @@ -82,7 +82,7 @@ sandboxes, so it has no slots; the operator asked for its CPU and memory instead. Nothing reports them, so the console shows the Web API's reachability and maintenance state and "Not reported" for CPU and memory. -`GET /core/v1/admin/core-status` +`GET /core/v1/core-status` - Core version and process uptime. - Process CPU utilization (ratio of one core, averaged over a short window) and @@ -98,15 +98,15 @@ and maintenance state and "Not reported" for CPU and memory. key. Two Overview parts still read Session lists: - Sessions created and failed per bucket: - `GET /core/v1/admin/metrics/sessions?start=&end=&step=&project_id=`. + `GET /core/v1/metrics/sessions?start=&end=&step=&project_id=`. - Sessions needing attention across projects: - `GET /core/v1/admin/sessions?status=failed,requires_action&order=last_active_desc&limit=` + `GET /core/v1/sessions?status=failed,requires_action&order=last_active_desc&limit=` (each entry labelled with its project), or a `status` filter on the per-project Session list. ### P1 — Agents API request metrics -`GET /core/v1/admin/metrics/api-requests?start=&end=&step=&group_by=route|status_class|project|key` +`GET /core/v1/metrics/api-requests?start=&end=&step=&group_by=route|status_class|project|key` Recorded by the API router middleware: request count, 4xx/5xx counts and latency percentiles per route family (Sessions, events stream, Turns, Items, files …). @@ -123,16 +123,16 @@ history pattern, with the optional OTLP exporter as a secondary sink. ### P2 — Hosted Runtime rows with their Sessions -`GET /runtime-observations` labels each observation with its project but not -its Session's title, Agent, status or usage, so Sandbox metrics reads every -hosted Session by ID (bounded at 100 per refresh). An `expand=session` option -returning those fields would remove the reads. +`GET /core/v1/sandbox/runtime-observations` labels each observation with its +project but not its Session's title, Agent, status or usage, so Sandbox metrics +reads every hosted Session by ID (bounded at 100 per refresh). An +`expand=session` option returning those fields would remove the reads. ### P2 — Deployment configuration writes The console can only read whether each harness has a model endpoint configured at startup. A Web API write (for example -`PUT /core/v1/admin/deployment/model-providers/{harness}`, write-only credentials) +`PUT /core/v1/deployment/model-providers/{harness}`, write-only credentials) would let operators set deployment defaults. It must keep the precedence Session → Agent → deployment and never return secrets. diff --git a/docs/web/admin-metrics-backend-requirements.zh-CN.md b/docs/web/admin-metrics-backend-requirements.zh-CN.md index 06dcea1ad..93e54d164 100644 --- a/docs/web/admin-metrics-backend-requirements.zh-CN.md +++ b/docs/web/admin-metrics-backend-requirements.zh-CN.md @@ -2,12 +2,12 @@ 状态:待与 Core 负责人讨论的提案。除文中注明已存在的 Web API 路由外,其余内容均未实现。[English](admin-metrics-backend-requirements.md) -控制台是管理工具,以监控为先:概览、Agent 监控、沙箱监控、Session 日志。它只读取 Web API(`/core/v1/admin/**`)和沙箱管理路由(`/core/v1/sandbox/**`),从不调用 `/v1`。部分数字来自 Web API 的汇总,其余仍由浏览器对各项目做有上限的读取后自行汇总。本文说明这样做的代价、哪里不完整,以及哪些 Web API 接口可以替代浏览器端的计算。 +控制台是管理工具,以监控为先:概览、Agent 监控、沙箱监控、Session 日志。它只读取 Web API(`/core/v1/**`,包括 `/core/v1/sandbox/**` 下的沙箱管理路由),从不调用 `/v1`。部分数字来自 Web API 的汇总,其余仍由浏览器对各项目做有上限的读取后自行汇总。本文说明这样做的代价、哪里不完整,以及哪些 Web API 接口可以替代浏览器端的计算。 ## Core 的两类接口 1. **面向用户的 Agents API**(`/v1/**`):必须与锁定版本的 OpenAI Agents API 及已记录的 Core 扩展保持一致。指标相关工作不得在这里新增字段、路由或行为。 -2. **Web API**(`/core/v1/admin/**`,以及沙箱管理用的 `/core/v1/sandbox/**`):只由控制台服务端用部署管理员凭据调用。下文提议的所有接口都属于这一类,由 `services/core-console` 转发。 +2. **Web API**(`/core/v1/**`,包括沙箱管理用的 `/core/v1/sandbox/**`):只由控制台服务端用部署管理员凭据调用。下文提议的所有接口都属于这一类,由 `services/core-console` 转发。 ## 控制台目前如何计算 @@ -15,7 +15,7 @@ | --- | --- | --- | | 概览 | `GET /summary`(按项目:资产数量、各状态 Session 数、用量、覆盖率、最近活跃);`/core/v1/sandbox` 的部署与节点;读取各项目的 Session 列表,用于 24 小时活动图和需要处理的 Session | Session 列表读过 24 小时窗口、并找到汇总所计的全部需要处理的 Session 后即停止,每个项目最多 1,000 个;窗口开始前就不再活跃的项目不读取 | | Agent 监控 | 用 `GET /summary` 跳过不活跃的项目;读取各项目的 Session 列表,再经项目作用域读取最近活跃 Session 的 Turn 与 Item;按 API 密钥的用量来自 `GET /summary?group_by=key` | 每个项目最多列出 2,000 个 Session;每次最多读取 200 个 Session,每个 Session 最多 10 页 Turn、5 页 Item,每个 Session 限时 15 秒、每次加载 45 秒 | -| 沙箱监控 | `/core/v1/sandbox` 的节点与分配;`GET /runtime-observations`(全部项目);经所属项目按 ID 读取每个托管 Session;按托管 Session 读取运行时历史 | 每次刷新最多读取 100 个托管 Session;历史最多覆盖 24 个托管 Session;主机指标只有可用内存/磁盘和 CPU 核数 | +| 沙箱监控 | `/core/v1/sandbox` 的节点与分配;`GET /core/v1/sandbox/runtime-observations`(全部项目);经所属项目按 ID 读取每个托管 Session;按托管 Session 读取运行时历史 | 每次刷新最多读取 100 个托管 Session;历史最多覆盖 24 个托管 Session;主机指标只有可用内存/磁盘和 CPU 核数 | 控制台在问号提示和告警中说明的后果: @@ -32,7 +32,7 @@ ### P0:Agent 运行聚合 -`GET /core/v1/admin/metrics/agent-runs?start=&end=&step=&group_by=model|agent|harness|project|key&project_id=` +`GET /core/v1/metrics/agent-runs?start=&end=&step=&group_by=model|agent|harness|project|key&project_id=` 每个时间桶(设置 `group_by` 时再按分组)返回: @@ -47,7 +47,7 @@ ### P0:工具调用聚合 -`GET /core/v1/admin/metrics/tool-calls?start=&end=&step=&group_by=tool|kind|agent&project_id=` +`GET /core/v1/metrics/tool-calls?start=&end=&step=&group_by=tool|kind|agent&project_id=` 按时间桶和工具(`function` 名称、MCP 的 `server_label` 加名称、Shell 命令、网页搜索、子 Agent)返回调用数、失败数,以及 Item 上报时的耗时。它可以替代逐 Session 读取 Item。 @@ -55,7 +55,7 @@ 概览在沙箱宿主机旁边单独显示 Core 本身。Core 不运行沙箱,因此没有槽位;负责人要求改为显示它的 CPU 和内存。目前没有任何接口上报这些数据,所以控制台只显示 Web API 是否可访问、是否处于维护模式,CPU 和内存显示“未报告”。 -`GET /core/v1/admin/core-status` +`GET /core/v1/core-status` - Core 版本和进程运行时长。 - 进程 CPU 利用率(以一个核为单位,按短时间窗口平均),以及主机的 CPU 核数和利用率。 @@ -67,12 +67,12 @@ `GET /summary` 已经按项目、Agent 和 key 给出各状态的 Session 数。概览仍有两部分需要读取 Session 列表: -- 每个时间桶新建和失败的 Session:`GET /core/v1/admin/metrics/sessions?start=&end=&step=&project_id=`。 -- 跨项目需要处理的 Session:`GET /core/v1/admin/sessions?status=failed,requires_action&order=last_active_desc&limit=`(每条标明所属项目),或者在各项目的 Session 列表上支持 `status` 过滤。 +- 每个时间桶新建和失败的 Session:`GET /core/v1/metrics/sessions?start=&end=&step=&project_id=`。 +- 跨项目需要处理的 Session:`GET /core/v1/sessions?status=failed,requires_action&order=last_active_desc&limit=`(每条标明所属项目),或者在各项目的 Session 列表上支持 `status` 过滤。 ### P1:Agents API 请求指标 -`GET /core/v1/admin/metrics/api-requests?start=&end=&step=&group_by=route|status_class|project|key` +`GET /core/v1/metrics/api-requests?start=&end=&step=&group_by=route|status_class|project|key` 由 API 路由中间件记录:按路由族(Session、事件流、Turn、Item、文件等)统计请求数、4xx/5xx 数和延迟分位数。这就是托管平台控制台里的“请求数”和“错误率”。建议参照运行时历史,把汇总写入现有 PostgreSQL 并设保留期,可选 OTLP 导出作为第二去向。 @@ -83,11 +83,11 @@ ### P2:托管运行时连同 Session 信息 -`GET /runtime-observations` 为每条观测标明所属项目,但不含 Session 的标题、Agent、状态和用量,所以沙箱监控要按 ID 逐个读取托管 Session(每次刷新最多 100 个)。增加 `expand=session` 选项返回这些字段,就可以省掉这些读取。 +`GET /core/v1/sandbox/runtime-observations` 为每条观测标明所属项目,但不含 Session 的标题、Agent、状态和用量,所以沙箱监控要按 ID 逐个读取托管 Session(每次刷新最多 100 个)。增加 `expand=session` 选项返回这些字段,就可以省掉这些读取。 ### P2:部署配置写入 -控制台目前只能读到每个执行引擎在启动时是否配置了模型端点。Web API 的写接口(例如 `PUT /core/v1/admin/deployment/model-providers/{harness}`,凭据只写)可以让运维设置部署默认值。优先级必须保持“Session → Agent → 部署”,且绝不返回密钥。 +控制台目前只能读到每个执行引擎在启动时是否配置了模型端点。Web API 的写接口(例如 `PUT /core/v1/deployment/model-providers/{harness}`,凭据只写)可以让运维设置部署默认值。优先级必须保持“Session → Agent → 部署”,且绝不返回密钥。 ### P2:密钥 diff --git a/docs/web/architecture.md b/docs/web/architecture.md index c113ded25..1695a86d5 100644 --- a/docs/web/architecture.md +++ b/docs/web/architecture.md @@ -20,14 +20,15 @@ flowchart LR runtime["Runtime and native adapters"] browser -->|"Same-origin management requests; console login"| console - console -->|"/core/v1/admin and sandbox management; deployment credential"| core + console -->|"/core/v1 management, including sandbox; deployment credential"| core application -->|"/v1; Project API key"| core core <--> database core <--> runtime ``` -React management code must use `AdminClient` from `packages/agents-client`, plus the -existing sandbox management client for `/core/v1/sandbox`. The console service +React management code must use the Core clients from `packages/agents-client`: +`AdminClient` for `/core/v1`, `CoreMetricsClient` for `/core/v1/metrics` and the +sandbox management client for `/core/v1/sandbox`. The console service returns 404 for `/v1` and `/api/v1`, including requests with an explicit Bearer token. It has no application key and does not impersonate the selected Project. diff --git a/docs/web/core-connection.md b/docs/web/core-connection.md index ae79ed356..7a4748620 100644 --- a/docs/web/core-connection.md +++ b/docs/web/core-connection.md @@ -6,8 +6,9 @@ React screens that use it are implemented; the console has no execution controls ## Connection model -The browser calls same-origin `/core/v1/admin` through `AdminClient` and existing -`/core/v1/sandbox` management routes through the sandbox client. The console server +The browser calls same-origin `/core/v1` through `AdminClient` and +`CoreMetricsClient`, and the `/core/v1/sandbox` management routes through the +sandbox client. The console server supplies the deployment administrator credential to its configured Core upstream. Browser code must never receive that credential. @@ -68,7 +69,7 @@ work. Ordinary metadata reads cannot recover plaintext. ## Verification and diagnosis 1. Core `/healthz` proves process liveness only. -2. Console login followed by `GET /core/v1/admin/projects` proves the authenticated +2. Console login followed by `GET /core/v1/projects` proves the authenticated browser-to-console and console-to-Core path. 3. A Project key must work on its public resources and fail on management routes. The deployment credential must fail on `/v1`; `/v1` through the console stays 404. diff --git a/docs/web/core-process-metrics-requirements.md b/docs/web/core-process-metrics-requirements.md index 6cc86b46e..638993109 100644 --- a/docs/web/core-process-metrics-requirements.md +++ b/docs/web/core-process-metrics-requirements.md @@ -14,7 +14,7 @@ charges the process nor what a container limit is compared against. ## Contract -Reuse `GET /core/v1/admin/core-metrics?range=1h|6h|24h|7d`: add fields to +Reuse `GET /core/v1/metrics?range=1h|6h|24h|7d`: add fields to `process`; change nothing else. `memory_bytes` and `goroutines` keep their meaning. Every figure Core cannot measure is `null`, never `0`. Measured zero remains zero. New current values expire after 60 seconds without diff --git a/docs/web/protocol-coverage.md b/docs/web/protocol-coverage.md index 93440cded..c9b37be37 100644 --- a/docs/web/protocol-coverage.md +++ b/docs/web/protocol-coverage.md @@ -6,8 +6,8 @@ that inventory, its pinned baseline and its evidence live in the [Agents API contract](../../contracts/agents-api/README.md). The console is a management tool. It reads and deletes each project's assets and -manages projects and keys through the administrator API (`/core/v1/admin/**`), and -it administers sandbox nodes through `/core/v1/sandbox/**`. It sends no request to +manages projects and keys through the administrator API (`/core/v1/**`), and it +administers sandbox nodes through `/core/v1/sandbox/**`. It sends no request to the Agents API (`/v1/**`). Routes, response shapes, pagination and audit records of the administrator API are defined by the [administrator API contract](../../contracts/agents-api/admin-api.md). @@ -15,9 +15,9 @@ the administrator API are defined by the [administrator API contract](../../cont | Interface | Paths | Authentication | Console use | | --- | --- | --- | --- | -| Console server | `/console/auth`, `/console/auth/{login,logout}`, `/console/config` | Core key at sign-in, then the console session cookie | Sign-in with the Core key and sign-out; non-secret capability flags such as `sandbox_admin` | -| Administrator API | `/core/v1/admin/**` | Deployment administrator credential, added by the console server | Projects, keys, resource reads and deletion, provenance, summaries, Runtime observations | -| Sandbox administration | `/core/v1/sandbox/**` | Deployment administrator credential, added by the console server | Nodes page; fleet and capacity figures on Overview and Sandbox metrics | +| Console server | `/console/auth`, `/console/auth/{login,logout}`, `/console/config` | Core key at sign-in, then the console session cookie | Sign-in with the Core key and sign-out; the node installer (`node_installer`, `node_installer_sha256`) | +| Administrator API | `/core/v1/**` outside `/core/v1/sandbox` | Deployment administrator credential, added by the console server | Projects, keys, resource reads and deletion, executor credentials, provenance, summaries, Core metrics | +| Sandbox administration | `/core/v1/sandbox/**` | Deployment administrator credential, added by the console server | Nodes page; fleet and capacity figures on Overview and Sandbox metrics; Runtime observations of every project | | Agents API | `/v1/**` | Project API key | Not used. The first-run screen shows a `curl` example for `/v1/agents` with a `$PROJECT_API_KEY` placeholder; the console never sends it | Browser requests are same-origin and carry only the console session. The browser @@ -30,13 +30,13 @@ value. | Operation | Route | Console use | | --- | --- | --- | -| List projects | `GET /projects` | Project filter on every project-scoped page; Projects and keys list; first-run detection (no project opens the first-run screen) | -| Create project | `POST /projects` | **Create project**; first run (default name `Default`) | -| Rename project | `POST /projects/{project_id}` | **Rename** on an active project; the ID stays the same | -| Archive project | `POST /projects/{project_id}/archive` | **Archive**: revokes every key; the project's assets stay readable and deletable | -| List keys | `GET /projects/{project_id}/keys` | Key table of a project: name, prefix, status, creation and revocation time | -| Issue key | `POST /projects/{project_id}/keys` | **Issue key** on an active project and the first-run screen; the plaintext is shown once | -| Revoke key | `DELETE /projects/{project_id}/keys/{key_id}` | **Revoke**, with a warning when it is the project's last active key | +| List projects | `GET /core/v1/projects` | Project filter on every project-scoped page; Projects and keys list; first-run detection (no project opens the first-run screen) | +| Create project | `POST /core/v1/projects` | **Create project**; first run (default name `Default`) | +| Rename project | `POST /core/v1/projects/{project_id}` | **Rename** on an active project; the ID stays the same | +| Archive project | `POST /core/v1/projects/{project_id}/archive` | **Archive**: revokes every key; the project's assets stay readable and deletable | +| List keys | `GET /core/v1/projects/{project_id}/keys` | Key table of a project: name, prefix, status, creation and revocation time | +| Issue key | `POST /core/v1/projects/{project_id}/keys` | **Issue key** on an active project and the first-run screen; the plaintext is shown once | +| Revoke key | `DELETE /core/v1/projects/{project_id}/keys/{key_id}` | **Revoke**, with a warning when it is the project's last active key | Names are checked for length (projects 1–128 characters, keys 1–80) and control characters before sending. An issued key's plaintext stays in component memory @@ -45,7 +45,7 @@ storage, URLs or logs. There is no project deletion and no plaintext recovery. ## Project resources -Routes are relative to `/core/v1/admin/projects/{project_id}` and return the same +Routes are relative to `/core/v1/projects/{project_id}` and return the same objects as the corresponding public `/v1` operations, so the console applies the public client's strict projections. Runtime observation and history exist only here. Archived projects remain readable. @@ -79,20 +79,32 @@ Resource-specific boundaries: skipped. The console does not read single Turns, Artifacts, execution configuration or Environment resources. +## Executor credentials + +Core issues the credentials of a self_hosted executor. The routes accept only +the self_hosted environment of a Session that is still running; any other +environment returns 404 or 409. + +| Operation | Route | Console use | +| --- | --- | --- | +| List credentials | `GET /core/v1/projects/{project_id}/environments/{environment_id}/executor-credentials` | Reserved for the Session page's executor credential section: `key_id`, creation and revocation time; the credential itself is never listed | +| Issue or rotate | `POST /core/v1/projects/{project_id}/environments/{environment_id}/executor-credentials` with `{"key_id", "rotate"}` | Reserved: the console generates `key_id` when the administrator issues; Core returns the credential once (`201`, `Cache-Control: no-store`). An existing `key_id` without `rotate: true` returns 409 | +| Revoke | `DELETE /core/v1/projects/{project_id}/environments/{environment_id}/executor-credentials/{key_id}` | Reserved: confirmed revocation; revoking again returns 204 | + ## Provenance and monitoring | Operation | Route | Console use | | --- | --- | --- | -| Resource owners | `GET /projects/{project_id}/resource-owners` | The Creator column of every resource list and the creator fact of detail pages, in batches of up to 100 IDs. An asset an administrator copied in an earlier release shows **Admin copy**; a resource without a record shows **Unknown** | -| Write operations | `GET /projects/{project_id}/write-operations` | A project's write history, newest first, filtered by key and resource type, 50 per page | -| Summary | `GET /summary` | Overview (per project), the Agents list (`group_by=agent`), a project's page (per project and `group_by=key`), Agent metrics (to skip idle projects, and usage by creating key since the start of the range), the Projects list (last activity) | -| Runtime observations | `GET /runtime-observations` | Sandbox metrics: hosted Runtimes of every project, each labelled with its project; an E2B sandbox's dialog adds its `observation.disk` as used / limit (null elsewhere) | -| Core metrics | `GET /core-metrics?range=` | Core metrics page; the Core popover on Overview. A Core without the route (404) is shown as not reporting; the popover then shows only Core's status. Measurements are defined in the [Core metrics contract](../../contracts/agents-api/core-metrics.md); the Process section's CPU and resident memory are a [requested extension](core-process-metrics-requirements.md) and show as missing until Core reports them | +| Resource owners | `GET /core/v1/projects/{project_id}/resource-owners` | The Creator column of every resource list and the creator fact of detail pages, in batches of up to 100 IDs. An asset an administrator copied in an earlier release shows **Admin copy**; a resource without a record shows **Unknown** | +| Write operations | `GET /core/v1/projects/{project_id}/write-operations` | A project's write history, newest first, filtered by key and resource type, 50 per page | +| Summary | `GET /core/v1/summary` | Overview (per project), the Agents list (`group_by=agent`), a project's page (per project and `group_by=key`), Agent metrics (to skip idle projects, and usage by creating key since the start of the range), the Projects list (last activity) | +| Core metrics | `GET /core/v1/metrics?range=` | Core metrics page; the Core popover on Overview. A Core without the route (404) is shown as not reporting; the popover then shows only Core's status. Measurements are defined in the [Core metrics contract](../../contracts/agents-api/core-metrics.md); the Process section's CPU and resident memory are a [requested extension](core-process-metrics-requirements.md) and show as missing until Core reports them | Summary figures are cumulative per Session and are not billing records. Sessions without reported usage count toward coverage but not toward token sums, and the console shows missing values as missing, never as zero. The administrator audit -log (`GET /audit-log`) is not consumed; System shows the sandbox deployment only. +log (`GET /core/v1/audit-log`) is not consumed; System shows the sandbox +deployment only. ## Sandbox administration @@ -106,8 +118,11 @@ log (`GET /audit-log`) is not consumed; System shows the sandbox deployment only | Enrollment | `POST /core/v1/sandbox/enrollment-tokens` | **Add node**: the administrator sets the node's sandbox limits (`max_active`; `max_retained` only for microsandbox, equal to `max_active` for Docker) before Core issues a single-use token inside a command that verifies the installer checksum | | Update node | `PATCH /core/v1/sandbox/nodes/{node_id}` | **Edit node**: the name and sandbox limits together (the retained limit only for microsandbox; under Docker, Core sets it to the active limit) | | Remove node | `DELETE /core/v1/sandbox/nodes/{node_id}` | Confirmed node removal; the row goes only after Core acknowledges the deletion | +| Runtime observations | `GET /core/v1/sandbox/runtime-observations` | Sandbox metrics: hosted Runtimes of every project, each labelled with its project; an E2B sandbox's dialog adds its `observation.disk` as used / limit (null elsewhere) | -These pages appear only when `/console/config` reports `sandbox_admin: true`. An E2B +Signing in grants administration, so `/console/config` reports only the node +installer (`node_installer`, `node_installer_sha256`). These pages appear unless +the console has no `/console/config` (404) or reports `sandbox_admin: false`. An E2B deployment has no nodes; its API key is write-only. The Runtime release sent for Docker and microsandbox comes from the console's own `GET /node-install/manifest.json` (the distribution manifest the node installer uses); without it the administrator @@ -121,7 +136,10 @@ enters the release under advanced settings. as uncertain and followed by a fresh read. - The console offers Session deletion only for idle or failed Sessions without required actions and never cancels work to make a Session deletable. -- Project, key, deletion and sandbox writes are never retried automatically. +- Project, key, executor credential, deletion and sandbox writes are never + retried automatically. After an uncertain executor credential issuance the + console refreshes the list; the administrator may reissue the same `key_id` + with `rotate: true`. ## Read bounds @@ -143,7 +161,7 @@ The aggregate endpoints that would replace these browser reads are proposed in stream, message input, function results and cancellation. - Creation or update of Agents, Environment templates, Skills, Files, Vaults or Credentials, including uploads and Credential token replacement. -- Environment resources, Environment Files, executor credentials and Artifacts. +- Environment resources, Environment Files and Artifacts. ## Terminology @@ -151,7 +169,7 @@ The aggregate endpoints that would replace these browser reads are proposed in [Agents guide](https://developers.openai.com/api/docs/guides/agents). Parsar Core implements part of its pinned beta resource shape under `/v1`. - **Administrator API** (also called the Web API) is Parsar Core's management - extension under `/core/v1/admin`. It is not part of the public Agents API. + extension under `/core/v1`. It is not part of the public Agents API. - **OpenAI Agents SDK** and **Responses API** are different interfaces and are not used by the console. From b13f18cd7f3fb7465f6a0703ce383c4a993e4427 Mon Sep 17 00:00:00 2001 From: saladday <1203511142@qq.com> Date: Fri, 25 Sep 2026 15:38:09 +0000 Subject: [PATCH 21/30] Check the project reader against Core's client type --- apps/web/src/lib/projects.tsx | 5 ++--- 1 file changed, 2 insertions(+), 3 deletions(-) diff --git a/apps/web/src/lib/projects.tsx b/apps/web/src/lib/projects.tsx index 897384629..5a565c72d 100644 --- a/apps/web/src/lib/projects.tsx +++ b/apps/web/src/lib/projects.tsx @@ -126,7 +126,7 @@ function createProjectClient(projectId: string): CoreProjectReader { const page = await admin.listSessions(projectId, { after: options.after, limit: options.limit, order: options.order, agentId: options.agentId, signal: options.signal }); return { ...page, object: "list" as const, first_id: page.first_id ?? null, last_id: page.last_id ?? null }; }; - const client = { + return { listAgents: (options) => admin.listAgents(projectId, options), retrieveAgent: (agentId: string) => admin.retrieveAgent(projectId, agentId), deleteAgent: (agentId: string) => admin.deleteAgent(projectId, agentId), @@ -156,8 +156,7 @@ function createProjectClient(projectId: string): CoreProjectReader { listItems: (sessionId, options) => admin.listItems(projectId, sessionId, options), retrieveRuntimeObservation: (sessionId, options) => admin.retrieveRuntimeObservation(projectId, sessionId, options), retrieveRuntimeHistory: (sessionId, query) => admin.retrieveRuntimeHistory(projectId, sessionId, query), - } as CoreProjectReader; - return client; + } satisfies CoreProjectReader; } const clients = new Map(); From e56a5dffb55db0d4e65a1eec4b2a1cb40e886ca5 Mon Sep 17 00:00:00 2001 From: saladday <1203511142@qq.com> Date: Fri, 25 Sep 2026 13:33:16 +0000 Subject: [PATCH 22/30] Issue, rotate and revoke executor credentials from a self-hosted Session --- apps/web/PRODUCT.md | 10 +- apps/web/e2e/monitoring.spec.ts | 25 ++ .../sessions/ExecutorCredentialsSection.tsx | 311 ++++++++++++++++++ .../web/src/features/sessions/SessionPage.tsx | 13 +- .../src/features/sessions/session-queries.ts | 20 +- apps/web/src/features/sessions/sessions.css | 73 ++++ apps/web/src/i18n/locales/en/sessions.ts | 53 +++ apps/web/src/i18n/locales/zh-CN/sessions.ts | 53 +++ docs/web/protocol-coverage.md | 23 +- 9 files changed, 568 insertions(+), 13 deletions(-) create mode 100644 apps/web/src/features/sessions/ExecutorCredentialsSection.tsx diff --git a/apps/web/PRODUCT.md b/apps/web/PRODUCT.md index 7f88d7842..05a1efc37 100644 --- a/apps/web/PRODUCT.md +++ b/apps/web/PRODUCT.md @@ -66,7 +66,8 @@ workbench. duration, tokens, models, tools, Agents and API keys for 1 h / 6 h / 24 h / 7 d), Sandbox metrics (node capacity and hosted Runtimes across projects; a node or a sandbox opens in a dialog with its figures and CPU and memory charts), Session log - (every Session, read-only, opening one Session's history). + (every Session, read-only, opening one Session's history; a self-hosted + Session's page also has its environment's executor credentials). - **Resources**: Agents, Environment templates, Skills, Files, Vaults. Each list shows one project or all projects, with a Project column when all are shown and a Creator column naming the creating key. Detail pages show the resource's facts @@ -123,6 +124,13 @@ workbench. without a record as Unknown. - **Session history is read-only.** A Session page reads the Session, its Items and Turns and polls while work is in flight; there is no live event stream. +- **Executor credentials.** Only Core issues the credential file a self-hosted + executor needs, with the deployment's Core key. A Session page whose environment + is self-hosted has an Executor credentials section: issue a credential (shown + once as the credential file, to copy or download, never stored), rotate it (the + old one stops working immediately) or revoke it (the executor can no longer + connect; a running process is not stopped). The file lets one executor connect + for that environment only; it cannot call the Agents API. - **Figures.** Project, Agent and key usage comes from Core's summary; Agent run, tool and activity figures are still assembled in the browser from bounded reads and state their coverage. Metrics that need new Core endpoints are recorded as diff --git a/apps/web/e2e/monitoring.spec.ts b/apps/web/e2e/monitoring.spec.ts index 309b0cae8..737041e36 100644 --- a/apps/web/e2e/monitoring.spec.ts +++ b/apps/web/e2e/monitoring.spec.ts @@ -32,6 +32,31 @@ test("opens a Session's conversation from the Session log, read-only", async ({ await expect(page.getByRole("textbox")).toHaveCount(0); }); +test("issues an executor credential once on a self-hosted Session and revokes it", async ({ page, request }) => { + await openConsole(page, request, "sessions"); + await page.getByRole("row").filter({ hasText: "Self-hosted" }).first().getByRole("button", { name: /^Open Session / }).click(); + const section = page.getByRole("region", { name: "Executor credentials" }); + await expect(section).toContainText("No executor credentials yet"); + + await section.getByRole("button", { name: "Issue credential" }).first().click(); + const issued = page.getByRole("dialog", { name: "Executor credential" }); + await expect(issued).toContainText("shown only once"); + await expect(issued.getByLabel("Executor credential file")).toContainText("exec_fixture_"); + const download = page.waitForEvent("download"); + await issued.getByRole("button", { name: "Download credential file" }).click(); + expect((await download).suggestedFilename()).toMatch(/^executor-credential-[0-9a-f]{8}\.json$/); + await issued.getByRole("button", { name: "Done" }).click(); + await expect(page.getByLabel("Executor credential file")).toHaveCount(0); + expect(await page.evaluate(() => JSON.stringify({ ...window.localStorage, ...window.sessionStorage }))).not.toContain("exec_fixture_"); + + const credentials = section.getByRole("table", { name: "Executor credentials" }); + await expect(credentials).toContainText("Active"); + await credentials.getByRole("button", { name: /^Revoke credential / }).click(); + await page.getByRole("dialog", { name: "Revoke credential?" }).getByRole("button", { name: "Revoke" }).click(); + await expect(credentials).toContainText("Revoked"); + await expect(credentials.getByRole("button", { name: /^Rotate credential / })).toHaveCount(0); +}); + test("opens a node and a sandbox in dialogs from Sandbox metrics", async ({ page, request }) => { await openConsole(page, request, "sandbox-metrics"); await page.getByRole("button", { name: "Show core-01" }).click(); diff --git a/apps/web/src/features/sessions/ExecutorCredentialsSection.tsx b/apps/web/src/features/sessions/ExecutorCredentialsSection.tsx new file mode 100644 index 000000000..3d2766b11 --- /dev/null +++ b/apps/web/src/features/sessions/ExecutorCredentialsSection.tsx @@ -0,0 +1,311 @@ +import { AgentCoreError, type IssuedExecutorCredential } from "@agents-core-web/agents-client"; +import { useQuery } from "@tanstack/react-query"; +import { Check, Copy, Download, Plus } from "lucide-react"; +import { useEffect, useState } from "react"; +import { useTranslation } from "react-i18next"; + +import { ConfirmDialog } from "../../components/ConfirmDialog"; +import { EmptyState, Section, StatusDot } from "../../components/console-ui"; +import { ErrorDialog } from "../../components/ErrorDialog"; +import { CopyableId, RowActions } from "../../components/list-ui"; +import { Modal } from "../../components/Modal"; +import { TableSkeleton } from "../../components/Skeleton"; +import { failedLast, useFailureToast, useToast } from "../../components/Toast"; +import { useDeleteFlow } from "../../lib/delete-flow"; +import { formatDateTime, shortId } from "../../lib/format"; +import { admin } from "../../lib/projects"; +import { useCopy } from "../api-keys/IssuedKey"; +import { saveBlob } from "../skills/skill-operations"; +import { executorCredentialsQuery } from "./session-queries"; + +/** An issuance that gets no answer in this time has an unknown outcome. */ +const WRITE_TIMEOUT_MS = 30_000; + +/** A key ID for a new credential. Plain-HTTP consoles lack `crypto.randomUUID`, so it falls back to a v4 UUID. */ +function newKeyId(): string { + if (typeof crypto.randomUUID === "function") return crypto.randomUUID(); + const bytes = crypto.getRandomValues(new Uint8Array(16)); + bytes[6] = (bytes[6]! & 0x0f) | 0x40; + bytes[8] = (bytes[8]! & 0x3f) | 0x80; + const hex = [...bytes].map((byte) => byte.toString(16).padStart(2, "0")).join(""); + return `${hex.slice(0, 8)}-${hex.slice(8, 12)}-${hex.slice(12, 16)}-${hex.slice(16, 20)}-${hex.slice(20)}`; +} + +/** The self-hosted executor's credential file. */ +function credentialFile(credential: IssuedExecutorCredential): string { + const { key_id, environment_id, executor_token } = credential; + return `${JSON.stringify({ key_id, environment_id, executor_token }, null, 2)}\n`; +} + +/** + * A write either failed before Core acted ("rejected", with Core's reason) or + * has an unknown outcome: no answer, a timeout, a 5xx or an unreadable reply. + */ +type WriteFailure = { kind: "rejected"; message: string; code: string | null } | { kind: "uncertain" }; + +function writeFailure(error: unknown): WriteFailure { + if (error instanceof AgentCoreError && error.status >= 400 && error.status < 500 && error.status !== 408) { + return { kind: "rejected", message: error.message, code: error.code ?? null }; + } + return { kind: "uncertain" }; +} + +function message(error: unknown): string { + return error instanceof Error ? error.message : String(error ?? ""); +} + +function seconds(value: string | null): number | null { + return value === null ? null : Math.floor(Date.parse(value) / 1000); +} + +/** + * Executor credentials of a self-hosted Session's environment: Core issues, + * rotates and revokes them with the deployment's Core key. An issued + * credential is shown once, lives only in this component's state and is + * forgotten when its dialog closes. Writes are never retried automatically: + * an issuance with an unknown outcome keeps its key ID, and after the list is + * refreshed the administrator rotates it (issued, secret lost) or issues it + * again (not issued). + */ +export function ExecutorCredentialsSection({ projectId, sessionId, environmentId }: { projectId: string; sessionId: string; environmentId: string }) { + const { t, i18n } = useTranslation("sessions"); + const locale = i18n.resolvedLanguage; + const toast = useToast(); + const query = useQuery(executorCredentialsQuery(projectId, sessionId, environmentId)); + const credentials = query.data ?? null; + const { refetch } = query; + const reread = () => { void refetch(); }; + useFailureToast(credentials && failedLast(query) ? message(query.error) : null, t("executor.refreshFailed"), "executor-credentials-read"); + + // The key ID of an issuance in flight or with an unknown outcome; the next Issue sends it again. + const [kept, setKept] = useState(null); + const [issuing, setIssuing] = useState(false); + // The unknown-outcome dialog; the key ID stays for its closing animation. + const [uncertain, setUncertain] = useState<{ keyId: string; open: boolean } | null>(null); + const [rotation, setRotation] = useState<{ keyId: string; lost: boolean } | null>(null); + const [rotating, setRotating] = useState(false); + const [rotationError, setRotationError] = useState(null); + const [shown, setShown] = useState<{ credential: IssuedExecutorCredential; open: boolean } | null>(null); + const revoke = useDeleteFlow( + (keyId) => admin.revokeExecutorCredential(projectId, environmentId, keyId), + reread, + { uncertain: t("executor.revokeDialog.uncertain") }, + ); + const busy = issuing || rotating || revoke.busy; + + // Closing the dialog forgets the credential once the dialog has faded out. + useEffect(() => { + if (!shown || shown.open) return; + const timer = window.setTimeout(() => setShown(null), 250); + return () => window.clearTimeout(timer); + }, [shown]); + // A reload or a closed tab would lose a credential that is shown only once. + const showing = shown?.open ?? false; + useEffect(() => { + if (!showing) return; + const guard = (event: BeforeUnloadEvent) => { event.preventDefault(); }; + window.addEventListener("beforeunload", guard); + return () => window.removeEventListener("beforeunload", guard); + }, [showing]); + + const openRotation = (keyId: string, lost: boolean) => { setRotationError(null); setRotation({ keyId, lost }); }; + + const issue = async () => { + if (busy) return; + const keyId = kept ?? newKeyId(); + setKept(keyId); + setIssuing(true); + try { + const credential = await admin.issueExecutorCredential(projectId, environmentId, { key_id: keyId, rotate: false }, { signal: AbortSignal.timeout(WRITE_TIMEOUT_MS) }); + setKept(null); + setShown({ credential, open: true }); + reread(); + } catch (error) { + const failure = writeFailure(error); + if (failure.kind === "uncertain") { + setUncertain({ keyId, open: true }); + return; + } + setKept(null); + // An earlier attempt with this key ID was issued after all; its secret never arrived. + if (failure.code === "executor_credential_exists") openRotation(keyId, true); + else toast.show(t("executor.issueRejected"), { tone: "error", detail: failure.message }); + reread(); + } finally { + setIssuing(false); + } + }; + + // After an unknown outcome: issued (rotate it for a fresh secret) or not (the next Issue reuses the key ID). + const checkKept = async (keyId: string) => { + const result = await refetch(); + if (!result.data || result.isError) return; + const found = result.data.find((credential) => credential.key_id.toLowerCase() === keyId.toLowerCase()); + if (!found) { + toast.show(t("executor.notIssued", { id: shortId(keyId) }), { tone: "info" }); + return; + } + setKept(null); + if (found.revoked_at === null) openRotation(found.key_id, true); + }; + + const rotate = async () => { + if (!rotation || busy) return; + setRotating(true); + setRotationError(null); + try { + const credential = await admin.issueExecutorCredential(projectId, environmentId, { key_id: rotation.keyId, rotate: true }, { signal: AbortSignal.timeout(WRITE_TIMEOUT_MS) }); + setRotation(null); + setShown({ credential, open: true }); + } catch (error) { + const failure = writeFailure(error); + setRotationError(failure.kind === "uncertain" ? t("executor.rotateDialog.uncertain") : t("executor.rotateDialog.rejected", { reason: failure.message })); + } finally { + setRotating(false); + reread(); + } + }; + + const issueButton = ( + + ); + + let body; + if (!credentials) { + body = query.isError + ? {t("detail.retry")}} /> + : ; + } else if (!credentials.length) { + body = ; + } else { + body = ( +
+ + + + + + + + + + + {credentials.map((credential) => { + const revoked = credential.revoked_at !== null; + const id = shortId(credential.key_id); + return ( + + + + + + + ); + })} + +
{t("executor.columns.credential")}{t("executor.columns.issued")}{t("executor.columns.status")}{t("log.actions")}
{formatDateTime(seconds(credential.created_at), locale)} + + + {revoked ? {formatDateTime(seconds(credential.revoked_at), locale)} : null} + + + {revoked ? null : ( + + + + + )} +
+
+ ); + } + + const hideShown = () => setShown((current) => current && { ...current, open: false }); + + return ( +
+ {body} + {t("executor.issued.done")}} + > + {shown ? : null} + + void checkKept(uncertain.keyId) } : undefined} + onClose={() => setUncertain((current) => current && { ...current, open: false })} + > +

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

+

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

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

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

+

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

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

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

+

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

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

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

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

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

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

{t("executor.archived")}

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

{t("executor.archived")}

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