From 80c3e555ac890d201fbceada5c2dacf5ca19c3d8 Mon Sep 17 00:00:00 2001 From: SaladDay <1203511142@qq.com> Date: Fri, 25 Sep 2026 18:42:28 +0000 Subject: [PATCH 1/6] Store deployment model providers in Core and require a provider for hosted and self-hosted Sessions Model credentials get one visible home and the self-hosted model gap from the fc19bd5a acceptance (D2, D3) is closed. - Deployment default model providers are a runtime setting: one complete bundle per harness in PostgreSQL (migration 000075), sealed with a new harness-bound credential purpose, managed only with the Core key through GET /core/v1/harnesses and GET/PUT/DELETE /core/v1/harnesses/{harness}/model-provider. Reads return the safe view (api_key_configured, updated_at), never the key. Writes are audited as deployment-wide admin_audit_log entries with a null tenant/project scope. - Provider precedence stays Session, saved Agent, deployment default. The default applies to openai_hosted and none Sessions and is frozen through the existing encrypted Session snapshot; later edits never reach existing Sessions. self_hosted Sessions now accept Session and Agent bundles, delivered over the existing daemon connection, and never use the deployment default. - openai_hosted and self_hosted Sessions without a bundle are rejected at creation with 400 model_provider_required. Legacy rows without a snapshot are rejected at message admission and at dispatch instead of falling back to a built-in endpoint. deploy/install/model_provider_sessions.py counts them read-only before an upgrade. - AGENTS_API_EXECUTION_OPTIONS_FILE is retired and stops startup with the replacement named. Historical snapshots keep their frozen native options. The execution-configuration read shows the safe view of new deployment selections. - packages/agents-client: listHarnesses, retrieveHarnessModelProvider, setHarnessModelProvider, deleteHarnessModelProvider, CoreHarness, HarnessModelProvider; audit entries may have project_id null. - Contracts, docs and the Core OpenAPI are updated. Tests that create hosted or self-hosted Sessions use shared provider fixtures instead of relaxing the check. --- CONTRIBUTING.md | 43 +-- contracts/agents-api/README.md | 4 +- contracts/agents-api/admin-api.md | 5 +- contracts/agents-api/core.openapi.yaml | 247 +++++++++++++++++- .../environment-executor-credentials.md | 12 + .../agents-api/execution-configuration.md | 10 +- contracts/agents-api/harness-selection.md | 24 +- contracts/agents-api/model-execution.md | 94 +++++-- contracts/agents-api/openapi.yaml | 226 ++++++++-------- .../agents-api/user-managed-runtime-v1.md | 6 + .../agents-api/v1/model_provider_admission.go | 36 ++- deploy/install/model_provider_sessions.py | 82 ++++++ .../install/test_model_provider_sessions.py | 38 +++ docs/api/README.md | 3 +- docs/api/public-agent-api.md | 8 + docs/api/web-management.md | 3 +- docs/configuration.md | 21 +- docs/getting-started/install.md | 21 +- docs/getting-started/quickstart.md | 11 +- .../web/admin-metrics-backend-requirements.md | 8 - ...dmin-metrics-backend-requirements.zh-CN.md | 4 - .../agents-client/src/admin-client.test.ts | 33 +++ packages/agents-client/src/admin-client.ts | 30 ++- .../agents-client/src/admin-projection.ts | 37 ++- packages/agents-client/src/admin-types.ts | 3 +- .../src/execution-configuration-projection.ts | 7 +- .../src/execution-configuration.test.ts | 5 +- packages/agents-client/src/types.ts | 19 ++ scripts/build-core-distribution.sh | 2 +- services/agents-api/HOSTED-RELEASE.md | 37 +-- .../cmd/server/execution_options.go | 62 ----- .../cmd/server/execution_options_test.go | 73 ------ services/agents-api/cmd/server/main.go | 8 +- .../agents-api/cmd/server/model_defaults.go | 84 ------ .../cmd/server/model_defaults_test.go | 80 ------ .../cmd/server/process_configuration.go | 5 +- .../cmd/server/process_configuration_test.go | 10 + services/agents-api/deploy/claude/README.md | 25 +- services/agents-api/deploy/codex/README.md | 12 +- services/agents-api/deploy/mcode/README.md | 36 +-- .../agents-api/deploy/microsandbox/README.md | 5 +- .../internal/api/admin_sessions_routes.go | 2 +- .../agents-api/internal/api/core_routes.go | 1 + .../internal/api/environment_creation_test.go | 6 +- .../internal/api/environment_input_test.go | 2 +- services/agents-api/internal/api/errors.go | 2 + services/agents-api/internal/api/handler.go | 17 +- .../agents-api/internal/api/handler_test.go | 2 +- .../internal/api/harness_model_providers.go | 202 ++++++++++++++ .../agents-api/internal/api/harness_test.go | 2 +- .../api/model_provider_fixture_test.go | 31 +++ .../api/session_execution_configuration.go | 7 +- .../session_execution_configuration_test.go | 2 +- .../internal/api/session_model_defaults.go | 71 +++-- .../deployment_model_provider.go | 32 +++ .../deployment_model_provider_test.go | 37 +++ .../db/queries/deployment_model_providers.sql | 17 ++ .../db/sqlc/deployment_model_providers.sql.go | 124 +++++++++ .../agents-api/internal/db/sqlc/models.go | 10 + .../internal/execution/dispatcher.go | 6 +- .../execution/environment_admission.go | 7 + .../execution/model_execution_test.go | 23 +- .../agents-api/internal/execution/request.go | 5 + .../agents-api/internal/execution/worker.go | 6 +- .../agents-api/internal/store/admin_audit.go | 11 + .../internal/store/admin_history.go | 15 +- .../agent_execution_defaults_http_test.go | 5 +- .../creation_stream_settlement_public_test.go | 6 +- .../store/deployment_model_providers.go | 108 ++++++++ .../deployment_model_providers_http_test.go | 190 ++++++++++++++ .../store/deployment_model_providers_test.go | 90 +++++++ .../internal/store/dispatch_test.go | 4 +- .../store/environment_admission_test.go | 4 +- .../store/environment_retrieve_public_test.go | 4 +- .../store/environment_worker_helpers_test.go | 2 +- .../agents-api/internal/store/export_test.go | 47 ++++ ...sted_initialization_failure_public_test.go | 2 +- .../internal/store/initial_files_http_test.go | 2 +- .../internal/store/managed_test_store_test.go | 3 +- .../store/model_provider_fixture_test.go | 28 ++ .../store/remote_mcp_credentials_test.go | 5 +- .../internal/store/remote_mcp_test.go | 3 +- .../internal/store/runtime_lifecycle_test.go | 2 +- .../store/self_hosted_cancel_public_test.go | 4 +- .../store/self_hosted_initial_public_test.go | 4 +- .../store/session_execution_configuration.go | 12 +- .../session_execution_configuration_test.go | 23 +- .../internal/store/session_initial_input.go | 4 +- .../internal/store/session_model_execution.go | 7 +- .../store/session_model_options_test.go | 65 ++--- .../agents-api/internal/store/sessions.go | 33 +-- .../store/template_composition_public_test.go | 2 +- .../template_null_selection_public_test.go | 2 +- .../store/whitespace_input_public_test.go | 2 +- .../000075_deployment_model_providers.sql | 30 +++ services/agents-api/tests/official_client.py | 5 +- .../tests/official_environment_retrieve.py | 11 +- .../tests/official_self_hosted_cancel.py | 13 +- .../tests/official_self_hosted_initial.py | 19 +- 99 files changed, 2093 insertions(+), 767 deletions(-) create mode 100644 deploy/install/model_provider_sessions.py create mode 100644 deploy/install/test_model_provider_sessions.py delete mode 100644 services/agents-api/cmd/server/execution_options.go delete mode 100644 services/agents-api/cmd/server/execution_options_test.go delete mode 100644 services/agents-api/cmd/server/model_defaults.go delete mode 100644 services/agents-api/cmd/server/model_defaults_test.go create mode 100644 services/agents-api/internal/api/harness_model_providers.go create mode 100644 services/agents-api/internal/api/model_provider_fixture_test.go create mode 100644 services/agents-api/internal/credentialcrypto/deployment_model_provider.go create mode 100644 services/agents-api/internal/credentialcrypto/deployment_model_provider_test.go create mode 100644 services/agents-api/internal/db/queries/deployment_model_providers.sql create mode 100644 services/agents-api/internal/db/sqlc/deployment_model_providers.sql.go create mode 100644 services/agents-api/internal/store/deployment_model_providers.go create mode 100644 services/agents-api/internal/store/deployment_model_providers_http_test.go create mode 100644 services/agents-api/internal/store/deployment_model_providers_test.go create mode 100644 services/agents-api/internal/store/model_provider_fixture_test.go create mode 100644 services/agents-api/migrations/000075_deployment_model_providers.sql diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 2ff4c0452..f86e88d6f 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -2226,18 +2226,27 @@ replaced; do not carry obsolete compatibility code forward to satisfy this secti require no key. Validate the merged harness/protocol/limits without reading keys. Read safe defaults and ciphertext in one database snapshot for Session creation; a complete Session override need not decrypt the inherited bundle. -- Hosted Session provider selection resolves explicit bundle, saved bundle, then - deployment bundle, and freezes it in the existing encrypted Session-owned row. - Convert existing native operator options only at server composition, never in - scheduling. Retain the complete selected operator options in the private encrypted - Session snapshot for deployment fallback, preserving headers, query parameters - and native settings; do not reconstruct them from a smaller public input type. Keep runtime dispatch on the common adapter path and fail closed for - missing/decryption-failed snapshots. Agent edits/deletion, restart and idle - suspend/resume never resolve defaults again. Record caller intent for every new - hosted Session before resolving defaults, including inline deployment fallback; - matching retries return committed state without replay. No Turn-level overrides, - provider catalog or self-hosted/none credential expansion is included. Public - input/null semantics and examples live in `contracts/agents-api/model-execution.md`. +- Session provider selection resolves explicit bundle, saved bundle, then the + deployment default of the resolved harness, and freezes it in the existing + encrypted Session-owned row. The deployment default is a runtime setting: one + complete bundle per harness in PostgreSQL, encrypted with its own + harness-bound purpose, managed only with the Core key through + `/core/v1/harnesses/{harness}/model-provider`, audited as a deployment-wide + write without the key, and never read back. It applies to `openai_hosted` and + operator-registered `none` Sessions, never to `self_hosted`, whose executor host + belongs to the application. Caller bundles apply to `openai_hosted` and + `self_hosted`. Hosted and self-hosted Sessions with no bundle are rejected at + creation with `model_provider_required`; legacy rows without a snapshot are + rejected at message admission and dispatch, never sent to a harness that would + fall back to a built-in endpoint. There is no operator options file: its + retirement fails startup, and historical snapshots keep their frozen native + options. Keep runtime dispatch on the common adapter path and fail closed for + missing/decryption-failed snapshots. Agent edits/deletion, default changes, + restart and idle suspend/resume never resolve defaults again. Record caller + intent for every new Session before resolving defaults; matching retries return + committed state without replay. No Turn-level overrides or provider catalog is + included. Public input/null semantics and examples live in + `contracts/agents-api/model-execution.md`. - Session execution-configuration reads use a separate immutable safe projection, written with provenance in the same creation transaction as Session resources. Read no credential ciphertext and never recompute sources from current Agents @@ -2473,7 +2482,7 @@ replaced; do not carry obsolete compatibility code forward to satisfy this secti before binding/claiming work. Older peers with only option-based capabilities must not receive controls they would ignore. The API sends resolved search and text verbosity values; native option names belong to adapters. Codex translates - them using its existing validation/catalog path after cloning operator options, + them using its existing validation/catalog path after cloning adapter options, so explicit controls take precedence without mutating those options. Omitting the entire block preserves ordinary product behavior; a supplied block requires both valid fields. This internal contract does not add public configuration or @@ -3281,10 +3290,10 @@ success-dependent limitations remain in the profile. Image-bearing delivery alon requires Runtime function-result image support; text results and function declarations do not acquire that requirement. These are implementation limits, not changes to the upstream contract. Do not bypass them by dropping fields, changing model identity or fabricating usage. -Operators may configure the daemon provider environment or the existing transient -`AGENTS_API_EXECUTION_OPTIONS_FILE` with adapter-owned `claude_provider` -(`base_url` HTTPS and `bearer_token`). Core forwards these opaque options without -persisting them in Session configuration. The adapter exclusively selects the +Operators may configure the daemon provider environment or the deployment default +model provider for `claude_sdk` (HTTPS `base_url` and a write-only key), which Core +freezes in the Session's encrypted snapshot and delivers as the adapter-owned +`claude_provider`; it never enters public Session configuration. The adapter exclusively selects the provider environment and removes credentials from native tool environments. Product `claude_code` and product execution are unchanged. The `none` public profile accepts only text, explicit model/system instructions, managed state, exact native resume and diff --git a/contracts/agents-api/README.md b/contracts/agents-api/README.md index 709b6bb87..20745ded7 100644 --- a/contracts/agents-api/README.md +++ b/contracts/agents-api/README.md @@ -485,7 +485,9 @@ operation and placement; native support is not public admission by itself. All three profiles implement user-managed `self_hosted` enrollment at `/workspace` through our private daemon transport; [separate real acceptance](user-managed-runtime-v1.md) -records qualified deployments and limits. Service-origin HTTP MCP is rejected on `self_hosted` and hosted local +records qualified deployments and limits. A `self_hosted` Session supplies its own +model provider in the request or through a saved Agent; deployment defaults apply +only to hosted placement ([model execution](model-execution.md)). Service-origin HTTP MCP is rejected on `self_hosted` and hosted local placements. This does not remove separately qualified Environment Plugin MCP. The [Docker lifecycle](environments.md#basic-public-docker-hosted-profile) retains workspace Files/Artifacts, cancellation and recovery with native isolation. diff --git a/contracts/agents-api/admin-api.md b/contracts/agents-api/admin-api.md index b0a70054b..a945ec6e6 100644 --- a/contracts/agents-api/admin-api.md +++ b/contracts/agents-api/admin-api.md @@ -166,7 +166,10 @@ display label: normally `console` from Web and empty from direct Core key reques `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`. +the key ID as `resource_id` and action `issue`, `rotate` or `revoke`. Deployment +default model provider writes are deployment-wide: `project_id` is null, +`resource_type:"deployment_model_provider"`, the harness as `resource_id` and +action `set` or `delete`; a `project_id` filter excludes them. No credential values or request bodies are recorded. Logs and historical copy ownership do not cascade away on resource removal or key revocation. diff --git a/contracts/agents-api/core.openapi.yaml b/contracts/agents-api/core.openapi.yaml index 4a5e157fa..39bc9c1a4 100644 --- a/contracts/agents-api/core.openapi.yaml +++ b/contracts/agents-api/core.openapi.yaml @@ -180,6 +180,47 @@ definitions: total_sessions: type: integer type: object + api.CoreHarness: + properties: + default: + type: boolean + enabled: + type: boolean + id: + enum: + - claude_sdk + - codex + - mcode + type: string + model_provider: + allOf: + - $ref: '#/definitions/api.HarnessModelProvider' + x-nullable: true + object: + enum: + - core.harness + type: string + required: + - default + - enabled + - id + - model_provider + - object + type: object + api.CoreHarnessList: + properties: + data: + items: + $ref: '#/definitions/api.CoreHarness' + type: array + object: + enum: + - list + type: string + required: + - data + - object + type: object api.EnvironmentExecutorCredentialRequest: properties: key_id: @@ -195,6 +236,41 @@ definitions: $ref: '#/definitions/store.ExecutorCredential' type: array type: object + api.HarnessModelProvider: + properties: + api_key_configured: + type: boolean + base_url: + type: string + context_window: + type: integer + harness: + enum: + - claude_sdk + - codex + - mcode + type: string + max_output_tokens: + type: integer + object: + enum: + - core.model_provider + type: string + protocol: + enum: + - anthropic + - responses + type: string + updated_at: + type: string + required: + - api_key_configured + - base_url + - harness + - object + - protocol + - updated_at + type: object api.ProjectAPIKeyRequest: properties: name: @@ -559,6 +635,7 @@ definitions: type: string project_id: type: string + x-nullable: true request_id: type: string resource_id: @@ -1469,6 +1546,26 @@ definitions: - has_more - object type: object + v1.ModelProviderInput: + properties: + api_key: + type: string + base_url: + type: string + context_window: + type: integer + max_output_tokens: + type: integer + protocol: + enum: + - anthropic + - responses + type: string + required: + - api_key + - base_url + - protocol + type: object v1.ModelProviderView: properties: api_key_configured: @@ -2879,6 +2976,154 @@ paths: summary: Query committed administrator mutations tags: - Core Administration + /core/v1/harnesses: + get: + description: Core key only. Returns every harness this build supports, in name order. enabled and default are read-only views of the process configuration (AGENTS_API_ENGINE and AGENTS_API_HARNESSES). model_provider is the harness's deployment default, stored in Core, or null. Keys are never returned; api_key_configured reports that one is set. + produces: + - application/json + responses: + "200": + description: OK + schema: + $ref: '#/definitions/api.CoreHarnessList' + "401": + description: Unauthorized + schema: + $ref: '#/definitions/v1.ErrorResponse' + "500": + description: Internal Server Error + schema: + $ref: '#/definitions/v1.ErrorResponse' + security: + - DeploymentAdminAuth: [] + summary: List harnesses and their deployment default model providers + tags: + - Deployment Model Providers + /core/v1/harnesses/{harness}/model-provider: + delete: + description: Core key only. Idempotent; each successful request is audited. Sessions that already froze the default keep it. Afterwards new openai_hosted Sessions for this harness need a Session or Agent bundle. + parameters: + - description: Harness + enum: + - claude_sdk + - codex + - mcode + in: path + name: harness + 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: Remove a harness's deployment default model provider + tags: + - Deployment Model Providers + get: + description: Core key only. Returns the safe view; the key is never returned. 404 when the harness does not exist or has no deployment default. + parameters: + - description: Harness + enum: + - claude_sdk + - codex + - mcode + in: path + name: harness + required: true + type: string + produces: + - application/json + responses: + "200": + description: OK + schema: + $ref: '#/definitions/api.HarnessModelProvider' + "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: Retrieve a harness's deployment default model provider + tags: + - Deployment Model Providers + put: + consumes: + - application/json + description: 'Core key only. The body is the complete x_agents_core.model_provider bundle, including the write-only api_key; there is no partial update and bundles are never merged. The provider is validated for this harness: an HTTPS base_url without credentials, query or fragment, the harness''s protocol (responses for codex, anthropic for claude_sdk and mcode) and, for mcode, positive context_window and max_output_tokens. New openai_hosted and none Sessions that resolve no Session or Agent bundle freeze this default into their encrypted snapshot; existing Sessions never change. self_hosted Sessions never use it. The key is encrypted and never returned. Each write records an administrator audit entry without the key.' + parameters: + - description: Harness + enum: + - claude_sdk + - codex + - mcode + in: path + name: harness + required: true + type: string + - description: Complete model provider bundle + in: body + name: body + required: true + schema: + $ref: '#/definitions/v1.ModelProviderInput' + produces: + - application/json + responses: + "200": + description: OK + schema: + $ref: '#/definitions/api.HarnessModelProvider' + "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' + "503": + description: Service Unavailable + schema: + $ref: '#/definitions/v1.ErrorResponse' + security: + - DeploymentAdminAuth: [] + summary: Replace a harness's deployment default model provider + tags: + - Deployment Model Providers /core/v1/metrics: get: description: Core key only. Complete UTC buckets; unknown measurements are null. Samples are process-local and are not backfilled after a restart. @@ -4309,7 +4554,7 @@ paths: - Artifacts /core/v1/projects/{project_id}/sessions/{session_id}/execution-configuration: get: - 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. + 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 defaults frozen after they moved into Core show their safe view; older deployment selections remain redacted. Historical provenance and missing provider projections are explicitly unknown/unavailable. parameters: - description: Session ID in: path diff --git a/contracts/agents-api/environment-executor-credentials.md b/contracts/agents-api/environment-executor-credentials.md index 7b33194ba..5e4a88079 100644 --- a/contracts/agents-api/environment-executor-credentials.md +++ b/contracts/agents-api/environment-executor-credentials.md @@ -70,6 +70,18 @@ never contains the secret. Credentials issued by the operator CLI (`agents-api-environment-key`) without an Environment restriction cannot be managed through these routes. +## Model provider + +The Session carries its own model provider: `x_agents_core.model_provider` at +creation or a saved Agent that has one. Deployment default model providers do not +apply to `self_hosted` Sessions, and creation without a provider fails with 400 +`model_provider_required`. Core freezes the bundle in the Session's encrypted +snapshot and sends it only over the connection of the executor enrolled for this +Environment with a current credential of the Session creator's principal. The +executor keeps it in the Runtime's native harness home, which tools and public +Files cannot read; the executor host's owner can. Revocation does not erase a +bundle already delivered. + ## Executor host The distribution's `self-hosted-install.pyz` downloads the matching diff --git a/contracts/agents-api/execution-configuration.md b/contracts/agents-api/execution-configuration.md index df5f9b0db..80725071f 100644 --- a/contracts/agents-api/execution-configuration.md +++ b/contracts/agents-api/execution-configuration.md @@ -41,9 +41,13 @@ inline or inherited from a saved Agent; it has no implicit deployment default. 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 +- `available`: a safe view of the frozen bundle. Configuration contains protocol, + endpoint, configured-key flag and optional token limits. +- `available` with source `deployment`: the harness's deployment default as it was + frozen at creation. Deployment defaults are readable with the same Core key + through `/core/v1/harnesses`, so the safe view is recorded too. +- `redacted`: a deployment selection frozen before deployment defaults moved into + Core, from the retired operator options file. Source is `deployment` and 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. diff --git a/contracts/agents-api/harness-selection.md b/contracts/agents-api/harness-selection.md index ecf217592..d67f839af 100644 --- a/contracts/agents-api/harness-selection.md +++ b/contracts/agents-api/harness-selection.md @@ -62,16 +62,20 @@ generation. Switching never migrates Sessions or deletes resources automatically See the [deployment procedure](../../services/agents-api/HOSTED-SANDBOX-MANAGER.md#removal-and-maintenance). -For multiple engines, use an exclusive `by_harness` object in the private -`AGENTS_API_EXECUTION_OPTIONS_FILE`, with one existing adapter-options object per -engine. No engine inherits another engine's credentials. A legacy flat options -object is usable only by the deployment default engine. Missing options for a -selected engine fail execution. Operator credentials stay in private files and -encrypted hosted Session snapshots. Saved Agent provider credentials use separately -encrypted defaults; public reads return only safe fields and a configured flag. -Credentials never enter metadata or ordinary effective responses. -A Session may instead provide the [write-only model execution extension](model-execution.md); -its frozen configuration takes precedence without operator fallback. +Each engine has at most one deployment default model provider, stored encrypted in +PostgreSQL and managed with the Core key in Web or through +`/core/v1/harnesses/{harness}/model-provider`; `GET /core/v1/harnesses` lists every +engine with its enabled and default flags and its safe provider view. No engine +inherits another engine's credentials. New `openai_hosted` and `none` Sessions that +resolve no Session or Agent bundle freeze their engine's default; `self_hosted` +Sessions never use it. Saved Agent provider credentials use separately encrypted +defaults. Public reads return only safe fields and a configured flag; credentials +never enter metadata or ordinary effective responses. A Session may instead provide +the [write-only model execution extension](model-execution.md); its frozen +configuration takes precedence. See +[model execution](model-execution.md#deployment-defaults) for precedence and where +each source applies. The former `AGENTS_API_EXECUTION_OPTIONS_FILE` is rejected at +startup. Model names remain explicit `agent.model` values. The selected native adapter uses its configured provider and rejects unsupported model settings without changing diff --git a/contracts/agents-api/model-execution.md b/contracts/agents-api/model-execution.md index 42450f1d9..a5833e8fc 100644 --- a/contracts/agents-api/model-execution.md +++ b/contracts/agents-api/model-execution.md @@ -3,8 +3,9 @@ Core accepts optional `x_agents_core.model_provider` on saved Agent creation and update, and the same top-level bundle as an explicit Session creation override. This is a Core extension, not part of the pinned upstream protocol. It supplies -execution input only: there is no Provider CRUD, catalog, model alias resolution or -product permission model in Core. +execution input only: there is no provider catalog, model alias resolution or +product permission model in Core. Besides Session and saved Agent bundles, the only +stored bundle is one [deployment default](#deployment-defaults) per harness. ## Saved defaults and precedence @@ -21,11 +22,27 @@ Session creation resolves each explicit model/harness override before saved defa when no harness is selected, the deployment harness applies. The pinned API still requires a model for an inline Agent and when creating a saved Agent. There is no model-name inference. Provider precedence is: complete Session bundle, complete -saved bundle, then configured deployment bundle. Never merge a replacement endpoint -with an inherited key. A model-only override reuses the entire inherited bundle. -Codex requires `responses`; Claude SDK and MiniMax Code require `anthropic`, with -positive context/output limits for MiniMax Code. Validate the resolved combination -before writing a Session. Core-supplied provider credentials remain hosted-only. +saved bundle, then the deployment default for the resolved harness. Never merge a +replacement endpoint with an inherited key. A model-only override reuses the entire +inherited bundle. Codex requires `responses`; Claude SDK and MiniMax Code require +`anthropic`, with positive context/output limits for MiniMax Code. Validate the +resolved combination before writing a Session. + +Where each source applies depends on who owns the compute that receives the key: + +| Environment | Session or saved Agent bundle | Deployment default | No bundle resolved | +| --- | --- | --- | --- | +| `openai_hosted` | Accepted | Applied | 400 `model_provider_required` | +| `self_hosted` | Accepted | Never applied | 400 `model_provider_required` | +| `none` | Rejected with 400 | Applied when configured | Accepted; the device's own environment supplies the model | + +The deployment default holds the operator's key, so it stays on operator compute: +Core-managed sandboxes and operator-registered `none` devices. A `self_hosted` +executor belongs to the application; the caller supplies its own bundle. Hosted and +self-hosted Runtimes carry no model configuration of their own, so a Session there +without a bundle is rejected before any write, with `param` +`x_agents_core.model_provider` and a message that says what to configure, instead +of starting a harness that would fall back to a built-in endpoint. | Operation | Omitted | Explicit null | | --- | --- | --- | @@ -102,24 +119,51 @@ Environment, event or ordinary configuration contains the key. The top-level extension is write-only and has no update endpoint. At dispatch, Core resolves its encrypted snapshot into the existing native adapter -options. It does not fall back to operator credentials when a snapshot is missing -or cannot decrypt. For hosted Sessions, omission resolves saved defaults first and then the configured -deployment provider bundle. Deployment provider defaults from -`AGENTS_API_EXECUTION_OPTIONS_FILE` are converted at the server composition boundary -and frozen using the same encrypted Session snapshot. That snapshot also retains -the complete selected private operator options, including native request headers, -query parameters and permission settings; dispatch does not reconstruct a lossy -subset or reread those defaults. New hosted Sessions validate this deployment -bundle using the same HTTPS/key/protocol rules: legacy operator HTTP endpoints -must move to HTTPS; invalid defaults fail admission rather than being silently -rewritten. Explicit and saved provider bundles continue to -use the existing typed adapter mapping. Other environments and -historical Sessions retain their existing operator-options behavior. -Core needs its configured credential encryption key to accept and resume these -Sessions; retaining the same key is required across restarts. Native harness homes -may contain private provider configuration under the existing qualified hosted -isolation rules; tools and public Files must not access those homes. This extension -does not qualify a new runtime placement or self-hosted credential path. +options. It does not fall back to other credentials when a snapshot is missing or +cannot decrypt. The same snapshot path serves every environment: Core sends the +options only over the daemon connection bound to the Session. For `self_hosted`, +that is the executor enrolled for the Session's own Environment with a current +executor credential of the Session creator's principal; rotation or revocation +closes the socket before further dispatch. The executor host stores the bundle in +its native harness home, as hosted Runtimes do; tools and public Files cannot reach +that home. Revocation does not erase a bundle already delivered. + +## Deployment defaults + +The deployment default is a runtime setting stored in Core, one complete bundle per +harness, managed with the Core key through Web or `/core/v1`: + +| Method and route | Result | +| --- | --- | +| `GET /core/v1/harnesses` | Every harness this build supports, with `enabled` and `default` from the process configuration and its `model_provider` (safe view) or null | +| `GET /core/v1/harnesses/{harness}/model-provider` | The safe view; 404 when none is set | +| `PUT /core/v1/harnesses/{harness}/model-provider` | Replace it with a complete `x_agents_core.model_provider` bundle, validated for the harness | +| `DELETE /core/v1/harnesses/{harness}/model-provider` | Remove it; idempotent, 204 | + +Reads return `protocol`, `base_url`, optional limits, `api_key_configured` and +`updated_at`, never the key. The bundle is encrypted with its own +credential-encryption purpose, bound to the harness, and each write records an +administrator audit entry (`resource_type: deployment_model_provider`, the harness +as `resource_id`, action `set` or `delete`, `project_id` null) without the key. A +missing or wrong encryption key fails closed: writes and Session creation that +needs the default return 503 `credential_storage_unavailable`. + +Session creation decrypts the default for the resolved harness and freezes it in the +Session's encrypted snapshot, like any other bundle. Changing or removing the +default never reaches existing Sessions, so a Session's first and later Turns always +use the same provider. The execution-configuration read shows the frozen safe view +with source `deployment`. + +The former `AGENTS_API_EXECUTION_OPTIONS_FILE` is retired: setting it stops Core at +startup with the replacement named. Only its provider identity (endpoint, key, +protocol and MiniMax Code limits) has a home in the deployment default; its other +native options (headers, query parameters, environment, MCP servers, feature and +permission settings) are dropped. Sessions frozen from that file keep their +complete private snapshot. Historical `openai_hosted` and `self_hosted` Sessions +created without any snapshot cannot start new work: message input returns 400 +`model_provider_required`, while cancellation and history reads keep working. +Recreate them with a bundle. Run `deploy/install/model_provider_sessions.py` +against an installation before upgrading to count them; it only reads. Parsar manages its own workspace catalog and encrypted keys, sends this extension only on the first Core Session request, and retains a private encrypted snapshot diff --git a/contracts/agents-api/openapi.yaml b/contracts/agents-api/openapi.yaml index 6671b1562..c7bbef5ef 100644 --- a/contracts/agents-api/openapi.yaml +++ b/contracts/agents-api/openapi.yaml @@ -2939,117 +2939,121 @@ paths: consumes: - application/json description: The optional Core model_provider bundle resolves from the Session - override, saved Agent defaults, then deployment defaults. Hosted Sessions - encrypt and freeze the resolved bundle; later Agent edits and same-key retries - cannot change it. Keys are never returned. Supports inline configuration or - a tenant-owned saved agent_id with per-Session field replacements. Execution - supports model/instructions, text verbosity, non-deferred function tools, - adapter-qualified multi_agent with persisted Subagent reads, implicit reasoning, - service tier auto and environment type none, subject to the configured engine. - Codex additionally supports HTTP MCP with service origin (omitted or null - on HTTP transport is saved as service), native allowed_tools and boolean required - defaulting to false. Session vault_ids attach only project-owned Vaults; credential_id - selects an attached static bearer or OAuth credential for the exact HTTPS - URL, while null/omission selects a unique match or remains anonymous. Session - reads, lists and event snapshots show that implicitly selected credential - ID in a null or omitted credential_id, also after the credential is deleted; - anonymous selections stay null and the stored caller intent is unchanged. - After the input requirement and before any write, a credential_id without - vault_ids, one outside the attached Vaults (one message for missing, foreign - and unattached IDs) or one for another server_url returns 400 invalid_request_error, - and several implicit matches return 409 conflict_error. Missing decryption - configuration fails dispatch without anonymous fallback. Required initialization - uses native startup before the first native Turn, including cold resume, and - requires a separately advertised capability; exact hosted creation timing - and error parity remain unverified. Other MCP origins and native OAuth login - remain unsupported. The self_hosted profile requires Codex, an absolute workspace_directory - and empty capability_directories, with optional non-deferred function tools - and HTTP MCP using service origin, optionally authenticated by the attached - Vault rules. Remote MCP and remote Bearer authentication each require separately - advertised combination support; old peers cannot receive unsupported work. - Omitted/null capability_directories use the empty-list default; self_hosted - requires configured execution plus executor registry. Claude SDK currently - requires medium verbosity and object-root function schemas. It supports anonymous - or attached static-bearer service-origin HTTP MCP on none with boolean required - and separately advertised MCP/bearer/required runtime support. Required servers - must be connected before the first native input is released; pending or failed - startup rejects execution. The shared Vault selection and immutable binding - rules apply; unsupported native labels/tool names reject before persistence. - An attached Vault with no matching credential may remain anonymous; missing - keys or failed credential lookup/decryption never fall back to anonymous execution. - Omitted stream defaults to false; stream and agent_id cannot be null. Metadata - may be null; non-string values and limit violations return invalid_request_error - with a metadata or metadata. param. The inline agent uses the Agent create - configuration validation with agent.-prefixed params, reported before the - input requirement and saved-Agent lookup; saved configurations with conflicting - tools or schema roots reject admission with the same errors, and execution - limits keep unsupported_or_invalid_configuration. Hosted network policy rejections - return invalid_request_error with a null param. Initial input accepts a string - or ordered user-message array. Codex and Claude SDK on none and qualified - openai_hosted also accept inline PNG/JPEG image content; other image combinations - and remote URLs are unsupported. None initial input atomically starts a Turn; - self_hosted initial input is reserved while returning its Environment connection - target, with execution deferred to native readiness and Session failure on - initial timeout. Initial input is required for none and for streamed creation - outside self_hosted. Omitted/null input remains valid for non-streaming hosted - and self_hosted creation. With stream=true, returns live Session events starting - with the committed creation snapshot and closes right after the first agent.session.idle - recorded when a Turn ends or an input reservation stops being pending, or - any agent.session.failed, without sending later events. A creation that admitted - nothing closes after the snapshot; a settlement that records no event closes - after events up to the cursor read with a settled Session projection. Required - actions keep it open; disconnect does not cancel execution. The GET events - stream remains live-only. New Sessions retain their authenticated creator; - all creation retries require the same typed subject, including across key - rotation. Saved-Agent retries and inline requests using Vault attachments - or credential references retain caller intent independently of later resource - changes; new hosted inline requests also freeze caller intent before deployment - defaults resolve; unrelated non-hosted inline retries preserve resolved/default - equivalences. Unknown historical creators reject retries; known creators without - recorded intent retain resolved-snapshot retry rules. These conflict policies - are local and not verified hosted parity. A same-key stream=true retry of - an existing creation returns 201 with no events and closes at once; retry - with stream=false or use the GET events stream to recover. Claude SDK on none - and Core-managed Docker openai_hosted supports qualified object-root json_schema - output with medium verbosity, single-Agent execution and ordinary functions. - Hosted execution reuses native workspace tools and Files/Artifacts; Skills, - Plugins, capability directories, HTTP MCP, Subagent and tool_search combinations - remain unqualified, including inherited template contents. Other non-text - initial input remains unsupported. Basic Codex and Claude SDK openai_hosted - creation requires an explicitly configured managed provider. The Claude workspace - profile supports non-deferred function tools with text or successful inline - PNG/JPEG results alongside native workspace tools; HTTP MCP remains unsupported. - Idle Sessions provision automatically; initial provisioning has no caller - connection action. Network defaults to enabled; disabled and restricted exact - ASCII hostnames are supported. Restricted policy requires 1–100 allowed domains. - Unsupported hostname forms and startup installations are rejected. Confidential - env, system/npm/Python packages and ordered setup commands use the shared - initialization lifecycle; requested network applies after setup. Initial inline - and tenant-owned file_id files freeze encrypted bytes before provisioning, - then install through the common Core lifecycle before native execution or - live Files access. With a template reference, omitted/null files, env, packages - and setup_commands inherit. Non-null files and command lists replace; env - overlays by key; each package manager inherits on omission/null and otherwise - replaces its list. Empty lists clear their selected field. Tenant-owned environment_template_id - references inherit omitted/null network and allow only narrowing overrides. - Inline hosted network:null retains the enabled default; updating a Template - with network:null resets its saved policy to enabled. Core freezes effective - configuration; template updates/deletion do not alter Session snapshots or - same-intent creation retries. Inline or tenant-owned skill_reference Skills - share initialization. Templates preserve default/latest/explicit selectors; - Session creation freezes concrete metadata and encrypted content atomically. - Skill, Plugin and capability-directory list omission/null inherit; a non-null - list replaces, including empty-list clearing. Omitted/null Skill version selectors - resolve the default version. Source deletion/default updates cannot change - committed Session Skill contents. Deferred function discovery uses type-only - tool_search and per-function defer_loading in the qualified single-agent Claude - environment:none function profile, including qualified inline image messages - and text results. Explicit web_search mode disabled and programmatic_tool_calling - enabled false use frozen common Runtime controls. Enabled forms, including - those saved on an Agent, remain unqualified and reject before any write unless - the Session replaces tools. Omitted programmatic configuration preserves native - behavior, a documented difference from the official default-on behavior. Other - combinations remain unqualified; see the operation coverage. + override, saved Agent defaults, then, for openai_hosted and none, the deployment + default of the resolved harness; self_hosted never uses the deployment default + and none accepts only it. openai_hosted and self_hosted Sessions that resolve + no bundle return 400 model_provider_required with param x_agents_core.model_provider + before any write. Core encrypts and freezes the resolved bundle; later Agent + or deployment default edits and same-key retries cannot change it. Keys are + never returned. Supports inline configuration or a tenant-owned saved agent_id + with per-Session field replacements. Execution supports model/instructions, + text verbosity, non-deferred function tools, adapter-qualified multi_agent + with persisted Subagent reads, implicit reasoning, service tier auto and environment + type none, subject to the configured engine. Codex additionally supports HTTP + MCP with service origin (omitted or null on HTTP transport is saved as service), + native allowed_tools and boolean required defaulting to false. Session vault_ids + attach only project-owned Vaults; credential_id selects an attached static + bearer or OAuth credential for the exact HTTPS URL, while null/omission selects + a unique match or remains anonymous. Session reads, lists and event snapshots + show that implicitly selected credential ID in a null or omitted credential_id, + also after the credential is deleted; anonymous selections stay null and the + stored caller intent is unchanged. After the input requirement and before + any write, a credential_id without vault_ids, one outside the attached Vaults + (one message for missing, foreign and unattached IDs) or one for another server_url + returns 400 invalid_request_error, and several implicit matches return 409 + conflict_error. Missing decryption configuration fails dispatch without anonymous + fallback. Required initialization uses native startup before the first native + Turn, including cold resume, and requires a separately advertised capability; + exact hosted creation timing and error parity remain unverified. Other MCP + origins and native OAuth login remain unsupported. The self_hosted profile + requires Codex, an absolute workspace_directory and empty capability_directories, + with optional non-deferred function tools and HTTP MCP using service origin, + optionally authenticated by the attached Vault rules. Remote MCP and remote + Bearer authentication each require separately advertised combination support; + old peers cannot receive unsupported work. Omitted/null capability_directories + use the empty-list default; self_hosted requires configured execution plus + executor registry. Claude SDK currently requires medium verbosity and object-root + function schemas. It supports anonymous or attached static-bearer service-origin + HTTP MCP on none with boolean required and separately advertised MCP/bearer/required + runtime support. Required servers must be connected before the first native + input is released; pending or failed startup rejects execution. The shared + Vault selection and immutable binding rules apply; unsupported native labels/tool + names reject before persistence. An attached Vault with no matching credential + may remain anonymous; missing keys or failed credential lookup/decryption + never fall back to anonymous execution. Omitted stream defaults to false; + stream and agent_id cannot be null. Metadata may be null; non-string values + and limit violations return invalid_request_error with a metadata or metadata. + param. The inline agent uses the Agent create configuration validation with + agent.-prefixed params, reported before the input requirement and saved-Agent + lookup; saved configurations with conflicting tools or schema roots reject + admission with the same errors, and execution limits keep unsupported_or_invalid_configuration. + Hosted network policy rejections return invalid_request_error with a null + param. Initial input accepts a string or ordered user-message array. Codex + and Claude SDK on none and qualified openai_hosted also accept inline PNG/JPEG + image content; other image combinations and remote URLs are unsupported. None + initial input atomically starts a Turn; self_hosted initial input is reserved + while returning its Environment connection target, with execution deferred + to native readiness and Session failure on initial timeout. Initial input + is required for none and for streamed creation outside self_hosted. Omitted/null + input remains valid for non-streaming hosted and self_hosted creation. With + stream=true, returns live Session events starting with the committed creation + snapshot and closes right after the first agent.session.idle recorded when + a Turn ends or an input reservation stops being pending, or any agent.session.failed, + without sending later events. A creation that admitted nothing closes after + the snapshot; a settlement that records no event closes after events up to + the cursor read with a settled Session projection. Required actions keep it + open; disconnect does not cancel execution. The GET events stream remains + live-only. New Sessions retain their authenticated creator; all creation retries + require the same typed subject, including across key rotation. Saved-Agent + retries and inline requests using Vault attachments or credential references + retain caller intent independently of later resource changes; new hosted inline + requests also freeze caller intent before deployment defaults resolve; unrelated + non-hosted inline retries preserve resolved/default equivalences. Unknown + historical creators reject retries; known creators without recorded intent + retain resolved-snapshot retry rules. These conflict policies are local and + not verified hosted parity. A same-key stream=true retry of an existing creation + returns 201 with no events and closes at once; retry with stream=false or + use the GET events stream to recover. Claude SDK on none and Core-managed + Docker openai_hosted supports qualified object-root json_schema output with + medium verbosity, single-Agent execution and ordinary functions. Hosted execution + reuses native workspace tools and Files/Artifacts; Skills, Plugins, capability + directories, HTTP MCP, Subagent and tool_search combinations remain unqualified, + including inherited template contents. Other non-text initial input remains + unsupported. Basic Codex and Claude SDK openai_hosted creation requires an + explicitly configured managed provider. The Claude workspace profile supports + non-deferred function tools with text or successful inline PNG/JPEG results + alongside native workspace tools; HTTP MCP remains unsupported. Idle Sessions + provision automatically; initial provisioning has no caller connection action. + Network defaults to enabled; disabled and restricted exact ASCII hostnames + are supported. Restricted policy requires 1–100 allowed domains. Unsupported + hostname forms and startup installations are rejected. Confidential env, system/npm/Python + packages and ordered setup commands use the shared initialization lifecycle; + requested network applies after setup. Initial inline and tenant-owned file_id + files freeze encrypted bytes before provisioning, then install through the + common Core lifecycle before native execution or live Files access. With a + template reference, omitted/null files, env, packages and setup_commands inherit. + Non-null files and command lists replace; env overlays by key; each package + manager inherits on omission/null and otherwise replaces its list. Empty lists + clear their selected field. Tenant-owned environment_template_id references + inherit omitted/null network and allow only narrowing overrides. Inline hosted + network:null retains the enabled default; updating a Template with network:null + resets its saved policy to enabled. Core freezes effective configuration; + template updates/deletion do not alter Session snapshots or same-intent creation + retries. Inline or tenant-owned skill_reference Skills share initialization. + Templates preserve default/latest/explicit selectors; Session creation freezes + concrete metadata and encrypted content atomically. Skill, Plugin and capability-directory + list omission/null inherit; a non-null list replaces, including empty-list + clearing. Omitted/null Skill version selectors resolve the default version. + Source deletion/default updates cannot change committed Session Skill contents. + Deferred function discovery uses type-only tool_search and per-function defer_loading + in the qualified single-agent Claude environment:none function profile, including + qualified inline image messages and text results. Explicit web_search mode + disabled and programmatic_tool_calling enabled false use frozen common Runtime + controls. Enabled forms, including those saved on an Agent, remain unqualified + and reject before any write unless the Session replaces tools. Omitted programmatic + configuration preserves native behavior, a documented difference from the + official default-on behavior. Other combinations remain unqualified; see the + operation coverage. parameters: - description: agents=v1 in: header diff --git a/contracts/agents-api/user-managed-runtime-v1.md b/contracts/agents-api/user-managed-runtime-v1.md index 57a141aaa..4ae241186 100644 --- a/contracts/agents-api/user-managed-runtime-v1.md +++ b/contracts/agents-api/user-managed-runtime-v1.md @@ -91,6 +91,12 @@ recorded real models. It does not establish arbitrary-host isolation, high availability, full Agents API conformance, Anthropic-model acceptance for Claude, or identical optional capabilities across harnesses. +These runs received their model through the operator options file, which is now +retired. A self-hosted Session now carries its own model provider, from the request +or a saved Agent, frozen and delivered over the same daemon transport; see +[model execution](model-execution.md). Deployment default model providers never +reach self-hosted executors. + The public self-hosted profile accepts `/workspace` and empty capability directories. Service-origin HTTP MCP on self-hosted remains explicitly rejected; none-environment HTTP MCP and separately qualified hosted Plugin MCP keep their own diff --git a/contracts/agents-api/v1/model_provider_admission.go b/contracts/agents-api/v1/model_provider_admission.go index c9e6141f0..26df168d3 100644 --- a/contracts/agents-api/v1/model_provider_admission.go +++ b/contracts/agents-api/v1/model_provider_admission.go @@ -5,11 +5,41 @@ import ( "strings" ) -const providerEnvironment = "openai_hosted" const providerScheme = "https" -func ModelProviderEnvironmentSupported(environment string) bool { - return environment == providerEnvironment +// Model provider sources, as recorded in a Session's execution configuration. +const ( + ModelProviderSourceSession = "session" + ModelProviderSourceAgent = "agent" + ModelProviderSourceDeployment = "deployment" +) + +// ModelProviderAllowed reports whether a provider bundle from source may be +// frozen into a Session placed in environment. Caller bundles (Session or saved +// Agent) run on Core-managed or caller-owned compute. The deployment default +// holds the operator's key, so it stays on operator compute: openai_hosted and +// operator-registered none devices. An unknown source is allowed only where +// every source is. +func ModelProviderAllowed(environment, source string) bool { + caller := source == ModelProviderSourceSession || source == ModelProviderSourceAgent + deployment := source == ModelProviderSourceDeployment + switch environment { + case "openai_hosted": + return true + case "self_hosted": + return caller + case "none": + return deployment + default: + return false + } +} + +// ModelProviderRequired reports whether a Session in environment cannot run +// without a frozen provider bundle. Hosted and self-hosted Runtimes carry no +// model configuration of their own; a none device may use its own environment. +func ModelProviderRequired(environment string) bool { + return environment == "openai_hosted" || environment == "self_hosted" } func validModelProviderBaseURL(base string) bool { diff --git a/deploy/install/model_provider_sessions.py b/deploy/install/model_provider_sessions.py new file mode 100644 index 000000000..fe6edb60e --- /dev/null +++ b/deploy/install/model_provider_sessions.py @@ -0,0 +1,82 @@ +#!/usr/bin/env python3 +"""Count hosted and self-hosted Sessions that have no frozen model provider. + +Read-only. Run it against an existing installation before upgrading to a Core that +stores deployment model providers itself: these Sessions cannot start new work +afterwards and must be recreated with x_agents_core.model_provider or an Agent that +has one saved. Cancelling their work and reading their history keep working. +""" +import argparse +import json +from pathlib import Path +import subprocess +import sys + +ENVIRONMENTS = ("openai_hosted", "self_hosted") +# A Session froze a provider when Core marked its configuration at creation. +QUERY = """SELECT configuration->'environment'->>'type', count(*) +FROM sessions +WHERE deleted_at IS NULL + AND configuration->'environment'->>'type' IN ('openai_hosted', 'self_hosted') + AND COALESCE(configuration->>'model_provider_configured', '') <> 'true' +GROUP BY 1 ORDER BY 1""" + + +class CheckError(Exception): + pass + + +def count(root): + """Return {environment: count} for Sessions without a frozen provider.""" + compose = Path(root) / "compose.json" + try: + services = json.loads(compose.read_text())["services"] + except (OSError, ValueError, KeyError, TypeError): + raise CheckError("Cannot read " + str(compose) + "; pass the installation directory with --install-dir") from None + if "database" not in services: + raise CheckError("This installation has no Core database (Web-only); run the check on the Core installation") + command = ["docker", "compose", "-f", str(compose), "exec", "-T", + "-e", "PGOPTIONS=-c default_transaction_read_only=on", "database", + "psql", "-X", "-q", "-A", "-t", "-F", "|", "-v", "ON_ERROR_STOP=1", + "-U", "agents_api", "-d", "agents_api", "-c", QUERY] + try: + result = subprocess.run(command, stdin=subprocess.DEVNULL, capture_output=True, text=True, timeout=60, check=False) + except (OSError, subprocess.SubprocessError): + raise CheckError("Cannot run the database query; is Docker available?") from None + if result.returncode: + raise CheckError("The database query failed; start the installation (install.sh --status shows its state) and retry") + counts = dict.fromkeys(ENVIRONMENTS, 0) + for line in result.stdout.splitlines(): + if not line.strip(): + continue + environment, _, value = line.partition("|") + if environment not in counts or not value.isdigit(): + raise CheckError("Unexpected database output") + counts[environment] = int(value) + return counts + + +def main(argv=None): + parser = argparse.ArgumentParser(description=__doc__, formatter_class=argparse.RawDescriptionHelpFormatter) + parser.add_argument("--install-dir", type=Path, default=Path.home() / ".parsar/core") + parser.add_argument("--json", action="store_true", help="print {environment: count} as JSON") + args = parser.parse_args(argv) + counts = count(args.install_dir) + if args.json: + print(json.dumps(counts, sort_keys=True)) + return + if not any(counts.values()): + print("Every hosted and self-hosted Session has a frozen model provider.") + return + print("Sessions without a frozen model provider (they cannot start new work after the upgrade):") + for environment in ENVIRONMENTS: + print(f" {environment}: {counts[environment]}") + print("Recreate them with x_agents_core.model_provider or an Agent that has one saved. " + "Cancelling their work and reading their history keep working.") + + +if __name__ == "__main__": + try: + main() + except CheckError as error: + raise SystemExit(str(error)) from None diff --git a/deploy/install/test_model_provider_sessions.py b/deploy/install/test_model_provider_sessions.py new file mode 100644 index 000000000..9f67430f9 --- /dev/null +++ b/deploy/install/test_model_provider_sessions.py @@ -0,0 +1,38 @@ +"""Verify the pre-upgrade check is read-only and reports each environment.""" +import json +from pathlib import Path +import subprocess +import tempfile +import unittest +from unittest import mock + +import model_provider_sessions + + +class ModelProviderSessionsTests(unittest.TestCase): + def setUp(self): + temporary = tempfile.TemporaryDirectory() + self.addCleanup(temporary.cleanup) + self.root = Path(temporary.name) + (self.root / "compose.json").write_text(json.dumps({"services": {"database": {}, "core": {}}})) + + def test_counts_through_a_read_only_query(self): + result = subprocess.CompletedProcess([], 0, stdout="self_hosted|2\n", stderr="") + with mock.patch.object(model_provider_sessions.subprocess, "run", return_value=result) as run: + self.assertEqual(model_provider_sessions.count(self.root), {"openai_hosted": 0, "self_hosted": 2}) + command = run.call_args.args[0] + self.assertIn("PGOPTIONS=-c default_transaction_read_only=on", command) + self.assertTrue(command[-1].lstrip().startswith("SELECT")) + + def test_refuses_web_only_and_unexpected_output(self): + with mock.patch.object(model_provider_sessions.subprocess, "run", + return_value=subprocess.CompletedProcess([], 0, stdout="none|1\n", stderr="")): + with self.assertRaises(model_provider_sessions.CheckError): + model_provider_sessions.count(self.root) + (self.root / "compose.json").write_text(json.dumps({"services": {"web": {}}})) + with self.assertRaises(model_provider_sessions.CheckError): + model_provider_sessions.count(self.root) + + +if __name__ == "__main__": + unittest.main() diff --git a/docs/api/README.md b/docs/api/README.md index 6e31401eb..b6f61685b 100644 --- a/docs/api/README.md +++ b/docs/api/README.md @@ -6,7 +6,7 @@ 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](../getting-started/operations.md#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) | +| `/core/v1` | Core Web's server and operator scripts | [Core key](../getting-started/operations.md#core-key) | Projects and keys, resource reads and deletion, Session archive, credential issuance, metrics, audit, sandbox deployment and nodes, deployment model providers | [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: node enrollment tokens and executor credentials issued through `/core/v1`, node credentials registered with an enrollment token, and daemon credentials Core writes into hosted sandboxes | 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 @@ -51,6 +51,7 @@ with the Core key. | `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) | +| `harnesses`, `harnesses/{harness}/model-provider` | Supported harnesses and each harness's deployment default model provider (write-only key) | [Model execution](../../contracts/agents-api/model-execution.md#deployment-defaults) | ## Machine connection API diff --git a/docs/api/public-agent-api.md b/docs/api/public-agent-api.md index 40e6c521f..9626d26f3 100644 --- a/docs/api/public-agent-api.md +++ b/docs/api/public-agent-api.md @@ -70,6 +70,14 @@ 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 Session's model provider comes from the Session's `x_agents_core.model_provider`, +then a saved Agent's, then, for `openai_hosted` only, the deployment default that +the administrator sets per harness. `openai_hosted` and `self_hosted` Sessions +must end up with one: otherwise creation returns 400 with code +`model_provider_required` and param `x_agents_core.model_provider`, and nothing is +written. `none` Sessions accept only the deployment default. See +[model execution](../../contracts/agents-api/model-execution.md). + An application reads a Session's model from `agent.model` and an explicitly selected harness from `agent.x_agents_core.harness`; a Session on the deployment default harness omits `agent.x_agents_core`. `/v1` has no execution-configuration diff --git a/docs/api/web-management.md b/docs/api/web-management.md index d42bbbe83..60b6cd6da 100644 --- a/docs/api/web-management.md +++ b/docs/api/web-management.md @@ -56,8 +56,9 @@ Routes below are relative to `/core/v1`: | Asset ownership | `GET /projects/{id}/resource-owners` with batched resource IDs | | Key operation history | `GET /projects/{id}/write-operations` with key/resource/time filters | | Executor credentials | `GET/POST /projects/{id}/environments/{environment_id}/executor-credentials`, `DELETE …/executor-credentials/{key_id}` ([contract](../../contracts/agents-api/environment-executor-credentials.md)) | +| Deployment model providers | `GET /harnesses`, `GET/PUT/DELETE /harnesses/{harness}/model-provider`; the key is write-only ([contract](../../contracts/agents-api/model-execution.md#deployment-defaults)) | | Usage and health | `GET /summary`, `/sandbox/runtime-observations`, `/metrics` | -| Administrator audit | `GET /audit-log` | +| Administrator audit | `GET /audit-log`; deployment-wide entries have `project_id: null` | A Project UUID in a management path selects the target; it is not a credential. API-key plaintext is returned only by successful issuance, so display it once and diff --git a/docs/configuration.md b/docs/configuration.md index 4444383f9..4d8cbb2fe 100644 --- a/docs/configuration.md +++ b/docs/configuration.md @@ -65,15 +65,16 @@ flags. Changing `core.env` does not require editing those launcher files. | `AGENTS_API_SANDBOX_INSTALLATION_ID` | Installer-generated canonical UUID | Stable identity pinned to the database, not provider selection; preserve it | | `AGENTS_API_CORE_KEY_DIGESTS_FILE` | Generated private `admin/core-key-digests.json` path | JSON array of [Core key](getting-started/operations.md#core-key) SHA-256 digests that authorize `/core/v1`; the key itself stays in Web's `CORE_CONSOLE_CORE_KEY_FILE`. The old name `AGENTS_API_SANDBOX_ADMIN_DIGESTS_FILE` fails startup | | `AGENTS_API_CREDENTIAL_KEY_FILE` | Generated private encryption-key path | Preserve with the database; never regenerate to repair credentials | -| `AGENTS_API_EXECUTION_OPTIONS_FILE` | Unset | Optional existing adapter-options JSON; see [model execution](../contracts/agents-api/model-execution.md) and [harness selection](../contracts/agents-api/harness-selection.md) | | `AGENTS_API_RUNTIME_HISTORY_FILE` | Unset | Optional existing Runtime history/export JSON; local collection defaults to 30 seconds, retention to 7 days; see [history contract](../contracts/agents-api/runtime-history-api.md) | | `AGENTS_API_E2B_PROVIDER_BIN` / `AGENTS_API_E2B_STATE_DIR` | Matching helper / private persistent receipt directory from installer | Paths only; the E2B account credential and template belong to the database | | `AGENTS_API_OAUTH_TRUSTED_ORIGINS` | Public HTTPS origins | Additional exact trusted HTTPS origins for private issuers; use the [OAuth contract](../services/agents-api/oauth-credentials.md) | Core reports loaded configuration paths on startup, without environment values or -file contents. Existing adapter-options and history JSON formats remain separate -specialized files referenced from `core.env`; this change does not introduce a -new loader or consolidate secrets into one file. Internal polling/queue controls +file contents. The existing history JSON format remains a separate specialized +file referenced from `core.env`; this change does not introduce a new loader or +consolidate secrets into one file. The former `AGENTS_API_EXECUTION_OPTIONS_FILE` +is retired and stops startup; deployment model providers are +[database-owned](#deployment-model-providers). Internal polling/queue controls remain internal. Runtime history retention remains its existing fixed policy. A process-only change requires restart. In Compose, recreate Core so it rereads @@ -91,6 +92,18 @@ with `systemctl --user restart`. Do not change installation UUID or backend path as a substitute for provider maintenance. Core refuses a missing installation setting when its database already has a claimed deployment. +## Deployment model providers + +Each harness has at most one deployment default model provider, stored encrypted in +PostgreSQL and set with the Core key in Web or through +`PUT /core/v1/harnesses/{harness}/model-provider`. It takes the same complete +bundle as `x_agents_core.model_provider` (`protocol`, HTTPS `base_url`, write-only +`api_key` and, for MiniMax Code, `context_window` and `max_output_tokens`). New +`openai_hosted` and `none` Sessions without a Session or Agent bundle freeze it; +`self_hosted` Sessions never use it, and a hosted or self-hosted Session with no +bundle is rejected with 400 `model_provider_required`. Reads never return the key. +See [model execution](../contracts/agents-api/model-execution.md#deployment-defaults). + ## Database-owned deployment Choose exactly one provider through Web or `/core/v1/sandbox/deployment`: diff --git a/docs/getting-started/install.md b/docs/getting-started/install.md index 2ffd6bcc1..c59eeb7fe 100644 --- a/docs/getting-started/install.md +++ b/docs/getting-started/install.md @@ -170,8 +170,11 @@ When a Session needs a sandbox, Core asks the enabled Provider to create one from the prepared Runtime image and initializes the colocated daemon, native harness and workspace. -Once a node is ready, you can [make an API request](quickstart.md). The model -credentials are supplied with execution requests, not during node installation. +Once a node is ready, you can [make an API request](quickstart.md). Hosted Sessions +take their model from the request, a saved Agent or the deployment default model +provider that you set per harness in Web (System) with the Core key; node +installation never needs a model credential. Without any of these, hosted Session +creation fails with 400 `model_provider_required`. ## Connect a user-managed Runtime @@ -220,8 +223,13 @@ and verifies the matching bootstrap before starting it: Use the exact Environment ID and reachable `remote_url` returned by your Session. The Linux amd64 host needs Python 3.9+ and Docker access as a non-root user. The installer prepares the matched daemon, native harnesses and local workspace -inside the same isolated Runtime used for hosted execution. No shared node, -model credential or source build is needed. Model access remains execution input. +inside the same isolated Runtime used for hosted execution. No shared node or +source build is needed. The Session itself must carry its model: create it with +`x_agents_core.model_provider` or from a saved Agent that has one. Deployment +default model providers do not apply to self-hosted Sessions, and creation without +a provider fails with 400 `model_provider_required`. Core delivers the frozen +provider only over this Environment's executor connection; the executor host keeps +it in the Runtime's harness home, which tools and public Files cannot read. The command waits for Core to confirm that this Environment and its restricted credential are connected. It distinguishes a running container from a connected @@ -364,8 +372,9 @@ sandboxes without a node installer. Own machines use the existing node command. Provider, per-sandbox resources and Runtime changes all require maintenance and completed resource cleanup; see [provider switching](../../services/agents-api/HOSTED-SANDBOX-MANAGER.md#removal-and-maintenance). -Session creation supplies the model, -harness and write-only model credentials. Core owns sandbox preparation and +Session creation supplies the model and +harness; model credentials come from the request, a saved Agent or the deployment +default model provider. Core owns sandbox preparation and Runtime startup. Configuration is never injected into a public Agent instruction or baked into a Runtime image. diff --git a/docs/getting-started/quickstart.md b/docs/getting-started/quickstart.md index 477370dad..d1af37426 100644 --- a/docs/getting-started/quickstart.md +++ b/docs/getting-started/quickstart.md @@ -59,8 +59,11 @@ install or start a separate daemon for a Core-managed Session. The public discri `openai_hosted`; in this deployment it means the sandbox managed by Parsar Core. The installation's provider can be microsandbox or Docker. -For a Codex-compatible Responses endpoint, provide your actual model name, -endpoint and key through your application's private configuration: +If the administrator set a deployment default model provider for the harness (Web, +System), a hosted Session needs only `agent.model`. Otherwise, or to use your own +endpoint, pass the provider with the request. For a Codex-compatible Responses +endpoint, provide your actual model name, endpoint and key through your +application's private configuration: ```python import os @@ -89,7 +92,9 @@ Running this example makes a real model request and may incur provider charges. `extra_body` carries existing Core extensions: harness selection and write-only model configuration. They are not fields in the official SDK 3.13.0 protocol. The service encrypts model configuration with tenant/Session binding and never -returns the secret through public resource reads. Keep the installation's +returns the secret through public resource reads. A `self_hosted` Session must +always carry its own provider this way or through a saved Agent; deployment +defaults apply only to `openai_hosted`. Keep the installation's credential encryption key and database together across restarts. Keep the complete `agent` object together: SDK 3.13.0 replaces an ordinary body field with the corresponding `extra_body` field rather than merging nested fields. diff --git a/docs/web/admin-metrics-backend-requirements.md b/docs/web/admin-metrics-backend-requirements.md index 4437381bb..100733997 100644 --- a/docs/web/admin-metrics-backend-requirements.md +++ b/docs/web/admin-metrics-backend-requirements.md @@ -128,14 +128,6 @@ 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 cannot read or change the deployment's per-harness model provider -defaults; Core takes them from `AGENTS_API_EXECUTION_OPTIONS_FILE`. A Web API -write (for example `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. - ### P2 — Keys - API key `last_used_at` and per-key request counts (with the API request diff --git a/docs/web/admin-metrics-backend-requirements.zh-CN.md b/docs/web/admin-metrics-backend-requirements.zh-CN.md index c0aa67fab..5388d667e 100644 --- a/docs/web/admin-metrics-backend-requirements.zh-CN.md +++ b/docs/web/admin-metrics-backend-requirements.zh-CN.md @@ -85,10 +85,6 @@ `GET /core/v1/sandbox/runtime-observations` 为每条观测标明所属项目,但不含 Session 的标题、Agent、状态和用量,所以沙箱监控要按 ID 逐个读取托管 Session(每次刷新最多 100 个)。增加 `expand=session` 选项返回这些字段,就可以省掉这些读取。 -### P2:部署配置写入 - -控制台无法读取或修改各执行引擎的部署默认模型 provider,Core 从 `AGENTS_API_EXECUTION_OPTIONS_FILE` 读取它们。Web API 的写接口(例如 `PUT /core/v1/deployment/model-providers/{harness}`,凭据只写)可以让运维设置部署默认值。优先级必须保持“Session → Agent → 部署”,且绝不返回密钥。 - ### P2:密钥 - API 密钥的 `last_used_at` 和按密钥统计的请求数(随上面的 API 请求指标一起提供)。 diff --git a/packages/agents-client/src/admin-client.test.ts b/packages/agents-client/src/admin-client.test.ts index d24d763d1..1b0e4c8e3 100644 --- a/packages/agents-client/src/admin-client.test.ts +++ b/packages/agents-client/src/admin-client.test.ts @@ -75,6 +75,10 @@ const routeCases: Array<[string, string, (client: AdminClient) => Promise 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)], + ["GET", "/harnesses", (client) => client.listHarnesses()], + ["GET", "/harnesses/codex/model-provider", (client) => client.retrieveHarnessModelProvider("codex")], + ["PUT", "/harnesses/codex/model-provider", (client) => client.setHarnessModelProvider("codex", { protocol: "responses", base_url: "https://model.example/v1", api_key: "k" })], + ["DELETE", "/harnesses/codex/model-provider", (client) => client.deleteHarnessModelProvider("codex")], ]; describe("AdminClient transport boundary", () => { @@ -141,6 +145,35 @@ describe("AdminClient transport boundary", () => { await expect(new AdminClient({ fetch }).revokeExecutorCredential(projectId, resourceId, keyId)).resolves.toBeUndefined(); }); + it("manages deployment default model providers without ever reading a key", async () => { + const provider = { object: "core.model_provider", harness: "codex", protocol: "responses", base_url: "https://model.example/v1", api_key_configured: true, updated_at: "2026-09-26T08:00:00Z" }; + const harnesses = { object: "list", data: [ + { object: "core.harness", id: "claude_sdk", enabled: false, default: false, model_provider: null }, + { object: "core.harness", id: "codex", enabled: true, default: true, model_provider: provider }, + ] }; + expect(await clientWith(harnesses).client.listHarnesses()).toEqual(harnesses); + expect(await clientWith(provider).client.retrieveHarnessModelProvider("codex")).toEqual(provider); + const { client, fetch } = clientWith(provider); + const input = { protocol: "responses", base_url: "https://model.example/v1", api_key: "write-only", context_window: 200000 } as const; + expect(await client.setHarnessModelProvider("codex", input)).toEqual(provider); + expect(JSON.parse(fetch.mock.calls[0]![1]!.body as string)).toEqual(input); + for (const unsafe of [{ ...provider, api_key: "leak" }, { ...provider, harness: "mcode" }, { ...provider, api_key_configured: false }, { ...provider, base_url: "http://model.example/v1" }, { ...provider, extra: 1 }]) { + await expect(clientWith(unsafe).client.retrieveHarnessModelProvider("codex")).rejects.toMatchObject({ code: "invalid_admin_response" }); + } + for (const unsafe of [{ ...harnesses, data: [{ ...harnesses.data[1], model_provider: { ...provider, api_key: "leak" } }] }, { ...harnesses, data: [harnesses.data[1], harnesses.data[1]] }, { data: harnesses.data }]) { + await expect(clientWith(unsafe).client.listHarnesses()).rejects.toMatchObject({ code: "invalid_admin_response" }); + } + const deleted = vi.fn().mockImplementation(async () => new Response(null, { status: 204 })); + await expect(new AdminClient({ fetch: deleted }).deleteHarnessModelProvider("codex")).resolves.toBeUndefined(); + }); + + it("accepts deployment-wide audit entries without a Project", async () => { + const entry = { id: "audit", created_at: "2026-09-26T08:00:00Z", admin_credential_id: "digest", actor_label: "console", action: "set", project_id: null, resource_type: "deployment_model_provider", resource_id: "codex", result_ids: [], request_id: "request", trace_id: "trace" }; + const audit = { data: [entry], has_more: false, next_cursor: "" }; + expect(await clientWith(audit).client.listAuditLog()).toEqual(audit); + await expect(clientWith({ ...audit, data: [{ ...entry, project_id: 1 }] }).client.listAuditLog()).rejects.toMatchObject({ code: "invalid_admin_response" }); + }); + it("encodes identifiers and prevents normalized dot path traversal", async () => { const { client, fetch } = clientWith(key); await expect(client.listAPIKeys("../other")).rejects.toBeInstanceOf(AgentCoreError); diff --git a/packages/agents-client/src/admin-client.ts b/packages/agents-client/src/admin-client.ts index 2d81b63d4..0aa8a78ba 100644 --- a/packages/agents-client/src/admin-client.ts +++ b/packages/agents-client/src/admin-client.ts @@ -11,9 +11,12 @@ 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, + projectExecutorCredentials, projectIssuedExecutorCredential, projectCoreHarnessList, projectHarnessModelProvider, } from "./admin-projection"; -import type { PageOptions, ReadOptions, RuntimeHistoryQuery, SkillList, SkillVersionDeleted, SkillVersionList, SourceFileList, VaultListOptions } from "./types"; +import type { + CoreHarness, CoreHarnessKind, HarnessModelProvider, ModelProviderInput, PageOptions, ReadOptions, RuntimeHistoryQuery, SkillList, SkillVersionDeleted, + SkillVersionList, SourceFileList, VaultListOptions, +} from "./types"; import type { AdminClientOptions, AdminAuditOptions, AdminContent, ArchiveAdminSessionInput, CreateAdminProjectInput, RenameAdminProjectInput, IssueAdminAPIKeyInput, AdminSummaryOptions, AdminResourceType, AdminResourceOwner, AdminWriteOperationOptions, AdminWriteOperationPage, @@ -270,4 +273,27 @@ export class AdminClient { 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"); } + + /** Every harness this Core build supports, with its deployment default model provider or null. */ + async listHarnesses(options?: ReadOptions): Promise<{ object: "list"; data: CoreHarness[] }> { + return projectCoreHarnessList(await this.#json("/harnesses", options)); + } + /** The harness's deployment default; Core answers 404 when none is set. The key is never returned. */ + async retrieveHarnessModelProvider(harness: CoreHarnessKind, options?: ReadOptions): Promise { + return projectHarnessModelProvider(await this.#json(`/harnesses/${segment(harness)}/model-provider`, options), harness); + } + /** + * Replaces the harness's deployment default with the complete bundle, including the write-only key. + * New openai_hosted Sessions freeze it; existing Sessions and self_hosted Sessions never use a changed default. + */ + async setHarnessModelProvider(harness: CoreHarnessKind, input: ModelProviderInput, options?: ReadOptions): Promise { + const body: ModelProviderInput = { protocol: input.protocol, base_url: input.base_url, api_key: input.api_key }; + if (input.context_window !== undefined) body.context_window = input.context_window; + if (input.max_output_tokens !== undefined) body.max_output_tokens = input.max_output_tokens; + return projectHarnessModelProvider(await this.#json(`/harnesses/${segment(harness)}/model-provider`, options, "PUT", body), harness); + } + /** Removes the harness's deployment default; removing it again is safe. Existing Sessions keep their frozen provider. */ + async deleteHarnessModelProvider(harness: CoreHarnessKind, options?: ReadOptions): Promise { + await this.#response(`/harnesses/${segment(harness)}/model-provider`, options, "DELETE"); + } } diff --git a/packages/agents-client/src/admin-projection.ts b/packages/agents-client/src/admin-projection.ts index 53a9f8326..1b64c4b94 100644 --- a/packages/agents-client/src/admin-projection.ts +++ b/packages/agents-client/src/admin-projection.ts @@ -1,7 +1,8 @@ import { AgentCoreError, projectAgentSnapshot, projectRuntimeObservation } from "./client"; import { projectTokenUsage } from "./usage-projection"; -import { canonicalUuid, exactFields, isNonnegativeInteger, isRecord, sameResourceId } from "./response-projection"; -import type { ListPage, SavedAgent } from "./types"; +import { projectModelProviderView } from "./execution-configuration-projection"; +import { canonicalUuid, exactFields, isNonnegativeInteger, isRecord, onlyFields, sameResourceId } from "./response-projection"; +import type { CoreHarness, CoreHarnessKind, HarnessModelProvider, ListPage, SavedAgent } from "./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 { @@ -191,8 +192,8 @@ export function projectAdminAudit(value: unknown): AdminAuditPage { if (!Array.isArray(page.data) || typeof page.has_more !== "boolean" || typeof page.next_cursor !== "string") return invalidAdminResponse(); const data = page.data.map((entry) => { const audit = record(entry, ["id", "created_at", "admin_credential_id", "actor_label", "action", "project_id", "resource_type", "resource_id", "result_ids", "request_id", "trace_id"]); - strings(audit, ["id", "created_at", "admin_credential_id", "actor_label", "action", "project_id", "resource_type", "resource_id", "request_id", "trace_id"]); - if (!date(audit.created_at)) return invalidAdminResponse(); + strings(audit, ["id", "created_at", "admin_credential_id", "actor_label", "action", "resource_type", "resource_id", "request_id", "trace_id"]); + if (!date(audit.created_at) || !(audit.project_id === null || typeof audit.project_id === "string")) return invalidAdminResponse(); return { ...audit, result_ids: projectAuditResultIDs(audit.result_ids) }; }); return { data, has_more: page.has_more, next_cursor: page.next_cursor } as AdminAuditPage; @@ -214,3 +215,31 @@ export function projectIssuedExecutorCredential(value: unknown, keyId: string, e 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 }; } + +const harnessKinds = new Set(["claude_sdk", "codex", "mcode"]); +/** A deployment default model provider: exactly the safe view, never `api_key`. */ +export function projectHarnessModelProvider(value: unknown, harness?: CoreHarnessKind): HarnessModelProvider { + if (!isRecord(value) || !onlyFields(value, new Set(["object", "harness", "updated_at", "protocol", "base_url", "context_window", "max_output_tokens", "api_key_configured"]))) return invalidAdminResponse(); + const { object, harness: kind, updated_at, ...view } = value; + if (object !== "core.model_provider" || typeof kind !== "string" || !harnessKinds.has(kind) || (harness !== undefined && kind !== harness) || + typeof updated_at !== "string" || !date(updated_at)) return invalidAdminResponse(); + const provider = projectModelProviderView(view, invalidAdminResponse); + if (!provider.api_key_configured) return invalidAdminResponse(); + return { object, harness: kind as CoreHarnessKind, ...provider, updated_at }; +} +export function projectCoreHarnessList(value: unknown): { object: "list"; data: CoreHarness[] } { + const page = record(value, ["object", "data"]); + if (page.object !== "list" || !Array.isArray(page.data)) return invalidAdminResponse(); + const data = page.data.map((entry): CoreHarness => { + const harness = record(entry, ["object", "id", "enabled", "default", "model_provider"]); + if (harness.object !== "core.harness" || typeof harness.id !== "string" || !harnessKinds.has(harness.id) || + typeof harness.enabled !== "boolean" || typeof harness.default !== "boolean" || (harness.default && !harness.enabled)) return invalidAdminResponse(); + const id = harness.id as CoreHarnessKind; + return { + object: "core.harness", id, enabled: harness.enabled, default: harness.default, + model_provider: harness.model_provider === null ? null : projectHarnessModelProvider(harness.model_provider, id), + }; + }); + if (new Set(data.map((entry) => entry.id)).size !== data.length || data.filter((entry) => entry.default).length > 1) return invalidAdminResponse(); + return { object: "list", data }; +} diff --git a/packages/agents-client/src/admin-types.ts b/packages/agents-client/src/admin-types.ts index 1f1a5efae..d41b5b81a 100644 --- a/packages/agents-client/src/admin-types.ts +++ b/packages/agents-client/src/admin-types.ts @@ -147,7 +147,8 @@ export interface AdminAuditEntry { admin_credential_id: string; actor_label: string; action: string; - project_id: string; + /** Null for deployment-wide writes, such as deployment default model providers. */ + project_id: string | null; resource_type: string; resource_id: string; /** Non-empty only on historical `copy` entries from the removed copy operation. */ diff --git a/packages/agents-client/src/execution-configuration-projection.ts b/packages/agents-client/src/execution-configuration-projection.ts index 8d577b7a5..f79f5fe1d 100644 --- a/packages/agents-client/src/execution-configuration-projection.ts +++ b/packages/agents-client/src/execution-configuration-projection.ts @@ -13,7 +13,8 @@ function selection(value: unknown, invalid: Invalid): SessionExecutionConfigurat return { value: value.value as string | null, source: value.source as ExecutionConfigurationSource }; } -function safeProvider(value: unknown, invalid: Invalid): ModelProviderView { +/** A safe provider view: known members only, never a key, and a plain HTTPS endpoint. */ +export function projectModelProviderView(value: unknown, invalid: Invalid): ModelProviderView { if (!isRecord(value) || !onlyFields(value, providerFields) || (value.protocol !== "responses" && value.protocol !== "anthropic") || typeof value.base_url !== "string" || typeof value.api_key_configured !== "boolean" || @@ -38,8 +39,8 @@ export function projectExecutionConfiguration(value: unknown, sessionId: string, !isRecord(value.model_provider) || !exactFields(value.model_provider, new Set(["source", "status", "configuration"]))) return invalid(); const provider = value.model_provider; let configuration: ModelProviderView | null = null; - if (provider.status === "available" && (provider.source === "session" || provider.source === "agent")) { - configuration = safeProvider(provider.configuration, invalid); + if (provider.status === "available" && (provider.source === "session" || provider.source === "agent" || provider.source === "deployment")) { + configuration = projectModelProviderView(provider.configuration, invalid); } else if (!((provider.status === "redacted" && provider.source === "deployment") || (provider.status === "unavailable" && provider.source === "unknown")) || provider.configuration !== null) return invalid(); return { diff --git a/packages/agents-client/src/execution-configuration.test.ts b/packages/agents-client/src/execution-configuration.test.ts index 9b2d07b76..549544c30 100644 --- a/packages/agents-client/src/execution-configuration.test.ts +++ b/packages/agents-client/src/execution-configuration.test.ts @@ -28,15 +28,16 @@ describe("frozen execution configuration", () => { }); it.each([ { status: "redacted", source: "deployment", configuration: null }, + { status: "available", source: "deployment", configuration: { protocol: "responses", base_url: "https://deployment.example/v1", api_key_configured: true } }, { status: "unavailable", source: "unknown", configuration: null }, - ])("preserves explicit $status provider state", async (provider) => { + ])("preserves explicit $status $source provider state", async (provider) => { const value = { ...snapshot, model_provider: provider }; expect(await clientReturning(value).retrieveSessionExecutionConfiguration(projectId, id)).toEqual(value); }); it.each([ (value: any) => { value.model_provider.configuration.api_key = "secret-canary"; }, (value: any) => { value.model_provider.configuration.headers = { Authorization: "secret-canary" }; }, - (value: any) => { value.model_provider.source = "deployment"; }, + (value: any) => { value.model_provider.source = "deployment"; value.model_provider.configuration = null; }, (value: any) => { value.model_provider.configuration.base_url = "https://user:secret-canary@host/v1"; }, (value: any) => { value.model_provider.configuration.base_url += "?key=secret-canary"; }, (value: any) => { value.session_id = "wrong-session"; }, diff --git a/packages/agents-client/src/types.ts b/packages/agents-client/src/types.ts index 6c8b22c1e..a074e4ef4 100644 --- a/packages/agents-client/src/types.ts +++ b/packages/agents-client/src/types.ts @@ -1203,6 +1203,25 @@ export interface ModelProviderView { api_key?: never; } +/** A harness's deployment default model provider in Core. The key is never returned. */ +export interface HarnessModelProvider extends ModelProviderView { + object: "core.model_provider"; + harness: CoreHarnessKind; + updated_at: string; +} + +/** + * A harness this Core build supports. `enabled` and `default` are read-only views of + * the process configuration; `model_provider` is the deployment default, or null. + */ +export interface CoreHarness { + object: "core.harness"; + id: CoreHarnessKind; + enabled: boolean; + default: boolean; + model_provider: HarnessModelProvider | null; +} + /** Omitted members preserve saved defaults on update; null provider clears it. */ export interface SavedAgentCoreInput { harness?: CoreHarnessKind; diff --git a/scripts/build-core-distribution.sh b/scripts/build-core-distribution.sh index 505dcd59f..817a6b773 100755 --- a/scripts/build-core-distribution.sh +++ b/scripts/build-core-distribution.sh @@ -98,7 +98,7 @@ if [[ "$(go env GOVERSION)" != "$required_go" ]]; then printf 'Distribution build requires %s\n' "$required_go" >&2 exit 1 fi -for file in install.sh install.py configuration.py native_service.py node_install.py node_spec.py local_node.py distribution.py self_hosted_install.py; do +for file in install.sh install.py configuration.py native_service.py node_install.py node_spec.py local_node.py distribution.py self_hosted_install.py model_provider_sessions.py; do cp "deploy/install/$file" "$bundle/$file" done python3 scripts/core-distribution-manifest.py bootstraps "$bundle" "$source_epoch" diff --git a/services/agents-api/HOSTED-RELEASE.md b/services/agents-api/HOSTED-RELEASE.md index 0daa8ee2a..9d979b0f8 100644 --- a/services/agents-api/HOSTED-RELEASE.md +++ b/services/agents-api/HOSTED-RELEASE.md @@ -61,36 +61,25 @@ nodes and sandbox guests reach through your TLS proxy: export AGENTS_API_ADDR=0.0.0.0:8091 export AGENTS_API_SANDBOX_INSTALLATION_ID="" export AGENTS_API_DAEMON_WS_URL="wss://core.example/api/v1/agent-daemon/ws" -export AGENTS_API_EXECUTION_OPTIONS_FILE="$PARSAR_HOME/execution-options.json" -``` - -Create `execution-options.json` with your trusted native model configuration. -For a Responses-compatible model endpoint, the existing Codex adapter accepts: - -```json -{ - "codex_provider": { - "name": "Configured model provider", - "base_url": "https:///v1", - "bearer_token": "", - "wire_api": "responses" - } -} +chmod 0600 "$AGENTS_API_CORE_KEY_DIGESTS_FILE" +"$AGENTS_API_BIN_DIR/agents-api-migrate" +"$AGENTS_API_BIN_DIR/agents-api" ``` -Keep it mode 0600 outside the extracted package. The model endpoint must -be reachable from the Runtime; a host loopback address is not the container's -host. Core copies these options into trusted execution preparation without storing -them in public Session configuration. Never put model credentials in Agent -instructions, public requests, image layers or a workspace. Configuration changes -require a Core restart. +Set the deployment default model provider with the Core key, for a +Responses-compatible endpoint reachable from the Runtime (a host loopback address +is not the container's host): ```sh -chmod 0600 "$AGENTS_API_CORE_KEY_DIGESTS_FILE" "$AGENTS_API_EXECUTION_OPTIONS_FILE" -"$AGENTS_API_BIN_DIR/agents-api-migrate" -"$AGENTS_API_BIN_DIR/agents-api" +curl -fsS -X PUT http://127.0.0.1:8091/core/v1/harnesses/codex/model-provider \ + -H "Authorization: Bearer $CORE_KEY" -H "Content-Type: application/json" \ + -d '{"protocol":"responses","base_url":"https:///v1","api_key":""}' ``` +Core encrypts it, never returns the key and freezes it into each new hosted +Session. Never put model credentials in Agent instructions, image layers or a +workspace. + Then select `docker` with `https://core.example` as `core_url`, the per-sandbox resources and the complete Runtime release, and register a node on a host where this image is loaded. Hosted admission starts when a ready node has capacity. diff --git a/services/agents-api/cmd/server/execution_options.go b/services/agents-api/cmd/server/execution_options.go deleted file mode 100644 index ba0456846..000000000 --- a/services/agents-api/cmd/server/execution_options.go +++ /dev/null @@ -1,62 +0,0 @@ -package main - -import ( - "context" - "encoding/json" - "errors" - "os" - - "github.com/MiniMax-AI-Dev/parsar/services/agents-api/internal/store" -) - -// Operator options use the existing transient adapter configuration path. They -// 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) { - file := os.Getenv("AGENTS_API_EXECUTION_OPTIONS_FILE") - if file == "" { - return nil, nil - } - raw, err := os.ReadFile(file) - if err != nil { - 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, errors.New("execution options must contain a JSON object") - } - defaultEngine := os.Getenv("AGENTS_API_ENGINE") - if defaultEngine == "" { - defaultEngine = "codex" - } - var byHarness map[string]json.RawMessage - if value, exists := check["by_harness"]; exists { - encoded, _ := json.Marshal(value) - if len(check) != 1 || json.Unmarshal(encoded, &byHarness) != nil || byHarness == nil { - 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, errors.New("execution harness options must be objects") - } - } - } - resolve := func(_ context.Context, session store.Session) (map[string]any, error) { - selected := raw - if byHarness != nil { - var ok bool - selected, ok = byHarness[session.Engine] - if !ok { - return nil, errors.New("execution options are unavailable for the selected harness") - } - } else if session.Engine != "" && session.Engine != defaultEngine { - return nil, errors.New("configure separate execution options for the selected harness") - } - // Request assembly may extend the map; no Session may mutate another's options. - var options map[string]any - err := json.Unmarshal(selected, &options) - return options, err - } - 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 deleted file mode 100644 index 9d7a769b2..000000000 --- a/services/agents-api/cmd/server/execution_options_test.go +++ /dev/null @@ -1,73 +0,0 @@ -package main - -import ( - "os" - "path/filepath" - "strings" - "testing" - - "github.com/MiniMax-AI-Dev/parsar/services/agents-api/internal/store" -) - -func TestExecutionOptionsStayTransientAndPerRequest(t *testing.T) { - t.Setenv("AGENTS_API_EXECUTION_OPTIONS_FILE", "") - if options, err := executionOptions(); options != nil || err != nil { - t.Fatal("implicit options", err) - } - file := filepath.Join(t.TempDir(), "options.json") - t.Setenv("AGENTS_API_EXECUTION_OPTIONS_FILE", file) - if err := os.WriteFile(file, []byte(`{"codex_provider":{"bearer_token":"synthetic-private-canary","wire_api":"responses"}}`), 0600); err != nil { - t.Fatal(err) - } - resolve, err := executionOptions() - if err != nil { - t.Fatal(err) - } - first, err := resolve(t.Context(), store.Session{ID: "first"}) - if err != nil { - t.Fatal(err) - } - first["codex_provider"].(map[string]any)["bearer_token"] = "modified" - next, err := resolve(t.Context(), store.Session{ID: "second"}) - if err != nil || next["codex_provider"].(map[string]any)["bearer_token"] != "synthetic-private-canary" { - t.Fatal("options shared mutable state", err) - } - for _, raw := range []string{`null`, `[]`, `{"secret":"synthetic-private-canary"`} { - if err := os.WriteFile(file, []byte(raw), 0600); err != nil { - t.Fatal(err) - } - if _, err := executionOptions(); err == nil || strings.Contains(err.Error(), "synthetic-private-canary") { - t.Fatal("invalid or sensitive options error") - } - } -} - -func TestExecutionOptionsSeparateHarnessCredentials(t *testing.T) { - file := filepath.Join(t.TempDir(), "options.json") - t.Setenv("AGENTS_API_EXECUTION_OPTIONS_FILE", file) - t.Setenv("AGENTS_API_ENGINE", "codex") - if err := os.WriteFile(file, []byte(`{"by_harness":{"codex":{"codex_provider":{"bearer_token":"codex-secret"}},"mcode":{"mcode_provider":{"api_key":"mcode-secret"}}}}`), 0600); err != nil { - t.Fatal(err) - } - resolve, err := executionOptions() - if err != nil { - t.Fatal(err) - } - options, err := resolve(t.Context(), store.Session{Engine: "mcode"}) - if err != nil || options["codex_provider"] != nil || options["mcode_provider"] == nil { - t.Fatalf("wrong credential partition: %v", err) - } - if _, err := resolve(t.Context(), store.Session{Engine: "claude_sdk"}); err == nil { - t.Fatal("missing harness fell back") - } - if err := os.WriteFile(file, []byte(`{"codex_provider":{"bearer_token":"codex-secret"}}`), 0600); err != nil { - t.Fatal(err) - } - resolve, err = executionOptions() - if err != nil { - t.Fatal(err) - } - if _, err := resolve(t.Context(), store.Session{Engine: "mcode"}); err == nil { - t.Fatal("legacy credentials crossed harness boundary") - } -} diff --git a/services/agents-api/cmd/server/main.go b/services/agents-api/cmd/server/main.go index 0a0edf3ba..901b79146 100644 --- a/services/agents-api/cmd/server/main.go +++ b/services/agents-api/cmd/server/main.go @@ -89,10 +89,6 @@ func run() error { if err != nil { return err } - transientOptions, err := executionOptions() - if err != nil { - return err - } oauthClient, err := oauthRefreshClient() if err != nil { return err @@ -211,7 +207,7 @@ func run() error { } if registry != nil { dispatcher := &execution.Dispatcher{Store: executionStore, Registry: registry, - ManagedRuntimes: managed, Options: transientOptions, MaxConcurrentExecutions: concurrency} + ManagedRuntimes: managed, MaxConcurrentExecutions: concurrency} worker, err = execution.StartWorker(ctx, dispatcher) if err != nil { @@ -229,7 +225,7 @@ func run() error { } }() options = append(options, api.WithExecution(worker), api.WithSessionArchive(worker.ArchiveManagedSession), api.WithEnvironmentDirectoryReader(worker), api.WithEnvironmentFileWriter(worker)) - options = append(options, api.WithHarnesses(kinds), api.WithModelProviderDefaults(deploymentModelDefaults(transientOptions))) + options = append(options, api.WithHarnesses(kinds), api.WithModelProviderDefaults(executionStore.DeploymentModelProvider)) if managed != nil { options = append(options, api.WithHostedEnvironments()) } diff --git a/services/agents-api/cmd/server/model_defaults.go b/services/agents-api/cmd/server/model_defaults.go deleted file mode 100644 index 5d96d69be..000000000 --- a/services/agents-api/cmd/server/model_defaults.go +++ /dev/null @@ -1,84 +0,0 @@ -package main - -import ( - "context" - "encoding/json" - "errors" - "strings" - - v1 "github.com/MiniMax-AI-Dev/parsar/contracts/agents-api/v1" - "github.com/MiniMax-AI-Dev/parsar/services/agents-api/internal/api" - "github.com/MiniMax-AI-Dev/parsar/services/agents-api/internal/store" -) - -// Adapt the existing operator configuration only at the server composition -// boundary. Core admission and scheduling consume one provider bundle. -func deploymentModelDefaults(options func(context.Context, store.Session) (map[string]any, error)) api.ModelProviderDefaults { - if options == nil { - return nil - } - return func(ctx context.Context, harness, model string) (*v1.ModelProviderInput, map[string]any, error) { - native, err := options(ctx, store.Session{Engine: harness}) - if err != nil { - return nil, nil, err - } - key := map[string]string{"codex": "codex_provider", "claude_sdk": "claude_provider", "mcode": "mcode_provider"}[harness] - raw, exists := native[key] - if !exists { - return nil, nil, nil - } - encoded, err := json.Marshal(raw) - if err != nil { - return nil, nil, errors.New("invalid deployment model provider") - } - provider := &v1.ModelProviderInput{} - if harness == "mcode" { - var cfg struct { - NPM *string `json:"npm"` - Enabled *bool `json:"enabled"` - Options struct { - BaseURL string `json:"baseURL"` - APIKey string `json:"apiKey"` - } `json:"options"` - Models map[string]struct { - Limit struct { - Context int32 `json:"context"` - Output int32 `json:"output"` - } `json:"limit"` - } `json:"models"` - } - if json.Unmarshal(encoded, &cfg) != nil { - return nil, nil, errors.New("invalid deployment model provider") - } - if (cfg.NPM != nil && *cfg.NPM != "@ai-sdk/anthropic") || (cfg.Enabled != nil && !*cfg.Enabled) { - return nil, nil, errors.New("unsupported deployment model provider") - } - provider.Protocol, provider.BaseURL, provider.APIKey = "anthropic", cfg.Options.BaseURL, cfg.Options.APIKey - limits, exists := cfg.Models[model] - if !exists { - return nil, nil, errors.New("deployment model limits are unavailable") - } - provider.ContextWindow, provider.MaxOutputTokens = limits.Limit.Context, limits.Limit.Output - } else { - var cfg struct { - BaseURL string `json:"base_url"` - BearerToken string `json:"bearer_token"` - WireAPI string `json:"wire_api"` - } - if json.Unmarshal(encoded, &cfg) != nil { - return nil, nil, errors.New("invalid deployment model provider") - } - provider.Protocol, provider.BaseURL, provider.APIKey = "anthropic", cfg.BaseURL, cfg.BearerToken - if harness == "codex" { - provider.Protocol = "responses" - if wireAPI := strings.TrimSpace(cfg.WireAPI); wireAPI != "" && wireAPI != "responses" { - return nil, nil, errors.New("unsupported deployment model protocol") - } - } - } - if err := provider.ValidateHarness(harness); err != nil { - return nil, nil, err - } - return provider, native, nil - } -} diff --git a/services/agents-api/cmd/server/model_defaults_test.go b/services/agents-api/cmd/server/model_defaults_test.go deleted file mode 100644 index dea07c4d1..000000000 --- a/services/agents-api/cmd/server/model_defaults_test.go +++ /dev/null @@ -1,80 +0,0 @@ -package main - -import ( - "context" - "encoding/json" - "strings" - "testing" - - "github.com/MiniMax-AI-Dev/parsar/services/agents-api/internal/store" -) - -func TestDeploymentProviderBundleForEachHarness(t *testing.T) { - for _, tc := range []struct { - harness, raw, protocol string - context, output int32 - }{ - {"codex", `{"codex_provider":{"base_url":"https://models.example/v1","bearer_token":"secret-canary","wire_api":" responses ","http_headers":{"X-Provider":"required"},"query_params":{"api-version":"2025-01-01"}}}`, "responses", 0, 0}, - {"claude_sdk", `{"claude_provider":{"base_url":"https://models.example/anthropic","bearer_token":"secret-canary"}}`, "anthropic", 0, 0}, - {"mcode", `{"mcode_provider":{"options":{"baseURL":"https://models.example/anthropic","apiKey":"secret-canary"},"models":{"selected":{"limit":{"context":200000,"output":8000}}}}}`, "anthropic", 200000, 8000}, - } { - t.Run(tc.harness, func(t *testing.T) { - resolver := deploymentModelDefaults(func(_ context.Context, s store.Session) (map[string]any, error) { - if s.Engine != tc.harness { - t.Fatal("wrong harness") - } - var opts map[string]any - err := json.Unmarshal([]byte(tc.raw), &opts) - return opts, err - }) - provider, native, err := resolver(t.Context(), tc.harness, "selected") - if native == nil || err != nil || provider.Protocol != tc.protocol || provider.APIKey != "secret-canary" || provider.ContextWindow != tc.context || provider.MaxOutputTokens != tc.output { - t.Fatal("provider conversion failed", err) - } - if tc.harness == "codex" { - providerOptions := native["codex_provider"].(map[string]any) - if providerOptions["http_headers"] == nil || providerOptions["query_params"] == nil { - t.Fatal("native deployment options lost") - } - } - provider.APIKey = "changed" - again, _, err := resolver(t.Context(), tc.harness, "selected") - if err != nil || again.APIKey != "secret-canary" { - t.Fatal("mutable defaults shared", err) - } - }) - } -} - -func TestDeploymentProviderMissingAndInvalidConfiguration(t *testing.T) { - if deploymentModelDefaults(nil) != nil { - t.Fatal("invented default") - } - for _, tc := range []struct { - harness, raw string - valid bool - }{ - {"codex", `{}`, true}, - {"codex", `{"codex_provider":{"base_url":"https://user:secret-canary@example.test","bearer_token":"secret-canary"}}`, false}, - {"codex", `{"codex_provider":{"base_url":"https://example.test","bearer_token":"secret-canary","wire_api":"chat"}}`, false}, - {"codex", `{"codex_provider":{"base_url":"https://example.test"}}`, false}, - {"mcode", `{"mcode_provider":{"options":{"baseURL":"https://example.test","apiKey":"secret-canary"},"models":{"other":{"limit":{"context":200000,"output":8000}}}}}`, false}, - {"mcode", `{"mcode_provider":{"npm":"@ai-sdk/openai","options":{"baseURL":"https://example.test","apiKey":"secret-canary"},"models":{"selected":{"limit":{"context":200000,"output":8000}}}}}`, false}, - {"mcode", `{"mcode_provider":{"enabled":false,"options":{"baseURL":"https://example.test","apiKey":"secret-canary"},"models":{"selected":{"limit":{"context":200000,"output":8000}}}}}`, false}, - {"mcode", `{"mcode_provider":{"enabled":"true","options":{"baseURL":"https://example.test","apiKey":"secret-canary"},"models":{"selected":{"limit":{"context":200000,"output":8000}}}}}`, false}, - } { - resolver := deploymentModelDefaults(func(context.Context, store.Session) (map[string]any, error) { - var opts map[string]any - err := json.Unmarshal([]byte(tc.raw), &opts) - return opts, err - }) - provider, native, err := resolver(t.Context(), tc.harness, "selected") - if tc.valid { - if err != nil || provider != nil || native != nil { - t.Fatal("omitted provider changed", err) - } - } else if err == nil || strings.Contains(err.Error(), "secret-canary") { - t.Fatal("invalid provider accepted or secret leaked") - } - } -} diff --git a/services/agents-api/cmd/server/process_configuration.go b/services/agents-api/cmd/server/process_configuration.go index 8e85a0a5e..f76d60212 100644 --- a/services/agents-api/cmd/server/process_configuration.go +++ b/services/agents-api/cmd/server/process_configuration.go @@ -32,6 +32,9 @@ func validateProcessConfiguration() error { return errors.New(retired + " is retired; configure deployment in Core and enroll a separate node") } } + if _, present := os.LookupEnv("AGENTS_API_EXECUTION_OPTIONS_FILE"); present { + return errors.New("AGENTS_API_EXECUTION_OPTIONS_FILE is retired. Deployment model providers are stored in Core: set them in Web (System) or with PUT /core/v1/harnesses/{harness}/model-provider. To import the old file once, run `parsar apply` and accept the import; then this setting is removed") + } if path := os.Getenv("AGENTS_API_CONFIG_FILE"); path != "" && !filepath.IsAbs(path) { return errors.New("AGENTS_API_CONFIG_FILE must be an absolute diagnostic path") } @@ -46,7 +49,7 @@ func logConfigurationSources() { } else { log.Bg().Info("Core process configuration loaded from the process environment") } - for _, key := range []string{"AGENTS_API_EXECUTION_OPTIONS_FILE", "AGENTS_API_RUNTIME_HISTORY_FILE"} { + for _, key := range []string{"AGENTS_API_RUNTIME_HISTORY_FILE"} { if path := os.Getenv(key); path != "" { log.Bg().Info("Core auxiliary configuration", "setting", key, "path", path) } diff --git a/services/agents-api/cmd/server/process_configuration_test.go b/services/agents-api/cmd/server/process_configuration_test.go index 88b055df1..5083df2ee 100644 --- a/services/agents-api/cmd/server/process_configuration_test.go +++ b/services/agents-api/cmd/server/process_configuration_test.go @@ -47,3 +47,13 @@ func TestRenamedCoreKeyDigestSettingNamesItsReplacement(t *testing.T) { t.Fatal(err) } } + +func TestRetiredExecutionOptionsFileNamesItsReplacement(t *testing.T) { + for _, value := range []string{"/private/execution-options.json", ""} { + t.Setenv("AGENTS_API_EXECUTION_OPTIONS_FILE", value) + err := validateProcessConfiguration() + if err == nil || !strings.Contains(err.Error(), "/core/v1/harnesses/{harness}/model-provider") || strings.Contains(err.Error(), "/private/") { + t.Fatal(err) + } + } +} diff --git a/services/agents-api/deploy/claude/README.md b/services/agents-api/deploy/claude/README.md index 10262274c..1872588e4 100644 --- a/services/agents-api/deploy/claude/README.md +++ b/services/agents-api/deploy/claude/README.md @@ -23,21 +23,20 @@ private Docker network, the existing `deploy/codex/seccomp.json`, and layout required by native bubblewrap; all native sandbox restrictions remain on. It is disabled for existing deployments unless explicitly selected. -Set `AGENTS_API_ENGINE=claude_sdk`. In a private file selected by -`AGENTS_API_EXECUTION_OPTIONS_FILE`, configure the provider: - -```json -{ - "claude_provider": { - "base_url": "https://api.anthropic.com", - "bearer_token": "REPLACE_WITH_OPERATOR_SECRET" - } -} +Set `AGENTS_API_ENGINE=claude_sdk`. Configure the deployment default model provider +with the Core key, in Web or through Core's API: + +```sh +curl -fsS -X PUT http://127.0.0.1:8091/core/v1/harnesses/claude_sdk/model-provider \ + -H "Authorization: Bearer $CORE_KEY" -H "Content-Type: application/json" \ + -d '{"protocol":"anthropic","base_url":"https://api.anthropic.com","api_key":"REPLACE_WITH_OPERATOR_SECRET"}' ``` -Use the endpoint and model supported by your actual provider. Do not bake keys -into the image. Core forwards this adapter-owned option transiently; it is not a -public Agent field. Workspace tool processes cannot inherit the provider secret. +A Session may instead supply its own `x_agents_core.model_provider`. Use the +endpoint and model supported by your actual provider. Do not bake keys into the +image. Core freezes the bundle in the Session's encrypted snapshot and delivers it +to the adapter; it is not a public Agent field. Workspace tool processes cannot +inherit the provider secret. Follow the existing Core setup for independent PostgreSQL credentials, migrations, API authentication and managed Runtime enrollment. Parsar is not a dependency. diff --git a/services/agents-api/deploy/codex/README.md b/services/agents-api/deploy/codex/README.md index 1f70e9274..15f700b9a 100644 --- a/services/agents-api/deploy/codex/README.md +++ b/services/agents-api/deploy/codex/README.md @@ -171,12 +171,12 @@ Node providers use explicit local Unix Docker sockets, ignoring ambient socket is mounted in a Runtime. Caller-managed `self_hosted` enrollment retains its separate public lifecycle. -For a trusted model endpoint, `AGENTS_API_EXECUTION_OPTIONS_FILE` can supply the -existing adapter options as a JSON object, including `codex_provider` with `name`, -`base_url`, `bearer_token` and `wire_api`. Protect this file with mode 0600; it is -read at startup, copied for each execution and never persisted as public Session -configuration. Omit it for the adapter's existing model configuration. Changes -require a Core restart. Do not place credentials in public requests or images. +Hosted Codex needs a Responses-compatible model provider: either the Session's +`x_agents_core.model_provider` or the deployment default for `codex`, set with the +Core key in Web or through `PUT /core/v1/harnesses/codex/model-provider`. Core +freezes the bundle in the Session's encrypted snapshot and delivers it as the +adapter's `codex_provider`; a hosted Session without one is rejected with 400 +`model_provider_required`. Do not place credentials in images. With the qualified Codex image and Docker provider configured, create an idle or initial-text Session using `environment: {"type": "openai_hosted"}`. Core commits diff --git a/services/agents-api/deploy/mcode/README.md b/services/agents-api/deploy/mcode/README.md index f2cdcec4b..1f4396e5e 100644 --- a/services/agents-api/deploy/mcode/README.md +++ b/services/agents-api/deploy/mcode/README.md @@ -60,33 +60,19 @@ or function calls. ## Provider configuration -Keep `AGENTS_API_EXECUTION_OPTIONS_FILE` private (mode 0600). Supply the existing -`mcode_provider` native custom-provider shape, for example: - -```json -{ - "mcode_provider": { - "name": "Parsar", - "kind": "custom", - "enabled": true, - "npm": "@ai-sdk/anthropic", - "options": { - "baseURL": "https://api.moonshot.cn/anthropic", - "apiKey": "" - }, - "models": { - "kimi-k3": { - "name": "Kimi K3", - "tool_call": true, - "limit": {"context": 64000, "output": 4096} - } - } - } -} +Supply an Anthropic-compatible bundle with the model's context and output limits, +either per Session as `x_agents_core.model_provider` or as the deployment default, +set with the Core key in Web or through Core's API: + +```sh +curl -fsS -X PUT http://127.0.0.1:8091/core/v1/harnesses/mcode/model-provider \ + -H "Authorization: Bearer $CORE_KEY" -H "Content-Type: application/json" \ + -d '{"protocol":"anthropic","base_url":"https://api.moonshot.cn/anthropic","api_key":"","context_window":64000,"max_output_tokens":4096}' ``` -The public Agent model must appear in this managed provider's native model list. -The adapter selects it explicitly; it does not use a native account fallback. +Core maps the bundle to the native custom provider for the Session's exact +`agent.model`, with these limits. The adapter selects it explicitly; it does not +use a native account fallback. Use the real provider endpoint for the selected credential. Test proxies may forward requests unchanged and capture tool names/status, but must not synthesize model responses or log keys. diff --git a/services/agents-api/deploy/microsandbox/README.md b/services/agents-api/deploy/microsandbox/README.md index 6d8120f1f..eb560acad 100644 --- a/services/agents-api/deploy/microsandbox/README.md +++ b/services/agents-api/deploy/microsandbox/README.md @@ -128,8 +128,9 @@ controls. Never repoint a retained backend namespace or overwrite node identity to bypass a configuration mismatch. Use the existing [standalone Core setup](../../README.md) for the database, -migrations, administrator credentials and encryption key. Model provider settings -retain the private `AGENTS_API_EXECUTION_OPTIONS_FILE` contract. The saved public +migrations, administrator credentials and encryption key. Model providers come from +the Session request, a saved Agent or the deployment default stored in Core; see +[model execution](../../../../contracts/agents-api/model-execution.md#deployment-defaults). The saved public Core origin must be reachable from the guest; `localhost` in a microVM refers to the guest itself. Do not configure a Core-local managed-runtimes file. diff --git a/services/agents-api/internal/api/admin_sessions_routes.go b/services/agents-api/internal/api/admin_sessions_routes.go index 58d179e13..5ad0e5924 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 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. +// @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 defaults frozen after they moved into Core show their safe view; older deployment selections remain redacted. Historical provenance and missing provider projections are explicitly unknown/unavailable. // @Tags Execution configuration // @Produce json // @Security DeploymentAdminAuth diff --git a/services/agents-api/internal/api/core_routes.go b/services/agents-api/internal/api/core_routes.go index 566517c2d..03445bcc3 100644 --- a/services/agents-api/internal/api/core_routes.go +++ b/services/agents-api/internal/api/core_routes.go @@ -22,5 +22,6 @@ func (h *Handler) registerCoreRoutes(router chi.Router) { h.registerAdminResourceRoutes(r) h.registerExecutorCredentialRoutes(r) h.registerSandboxManagerRoutes(r) + h.registerHarnessRoutes(r) }) } diff --git a/services/agents-api/internal/api/environment_creation_test.go b/services/agents-api/internal/api/environment_creation_test.go index 35a71755a..2425e6eff 100644 --- a/services/agents-api/internal/api/environment_creation_test.go +++ b/services/agents-api/internal/api/environment_creation_test.go @@ -59,7 +59,7 @@ func environmentCreationHandler(t *testing.T, engine string, options ...Option) if err != nil { t.Fatal(err) } - handler, err := NewHandler(fixture, auth, engine, options...) + handler, err := NewHandler(fixture, auth, engine, append([]Option{withFixtureDeploymentProvider()}, options...)...) if err != nil { t.Fatal(err) } @@ -77,7 +77,7 @@ func TestSelfHostedEmptyCreationAndStream(t *testing.T) { defer server.Close() ctx, cancel := context.WithTimeout(t.Context(), 5*time.Second) defer cancel() - body := fmt.Sprintf(`{"agent":{"model":"MiniMax-M3"},"environment":{"type":"self_hosted","workspace_directory":"/workspace"%s},"stream":%t%s}`, capability, stream, input) + body := fmt.Sprintf(`{"agent":{"model":"MiniMax-M3"},"environment":{"type":"self_hosted","workspace_directory":"/workspace"%s},"stream":%t%s,%s}`, capability, stream, input, fixtureSessionProvider) request, err := http.NewRequestWithContext(ctx, http.MethodPost, server.URL+"/v1/agents/sessions", strings.NewReader(body)) if err != nil { t.Fatal(err) @@ -200,7 +200,7 @@ func TestSelfHostedCreationRequiresOperatorExecution(t *testing.T) { for _, options := range [][]Option{nil, {WithExecution(&inputRecorder{})}, {WithEnvironmentRemoteURL(environmentOrigin)}} { for _, stream := range []bool{false, true} { handler, fixture := environmentCreationHandler(t, "codex", options...) - body := fmt.Sprintf(`{"agent":{"model":"MiniMax-M3"},"environment":{"type":"self_hosted","workspace_directory":"/workspace"},"stream":%t}`, stream) + body := fmt.Sprintf(`{"agent":{"model":"MiniMax-M3"},"environment":{"type":"self_hosted","workspace_directory":"/workspace"},"stream":%t,%s}`, stream, fixtureSessionProvider) request := httptest.NewRequest(http.MethodPost, "/v1/agents/sessions", strings.NewReader(body)) request.Header.Set("Authorization", "Bearer key") request.Header.Set("OpenAI-Beta", "agents=v1") diff --git a/services/agents-api/internal/api/environment_input_test.go b/services/agents-api/internal/api/environment_input_test.go index 3397dc2fc..2a6c26def 100644 --- a/services/agents-api/internal/api/environment_input_test.go +++ b/services/agents-api/internal/api/environment_input_test.go @@ -66,7 +66,7 @@ func TestPreparedEnvironmentInputWaitExtendsOnlyItsResponseDeadline(t *testing.T options := []Option{WithExecution(waiting)} environmentJSON := `{"type":"none"}` if environment == "self_hosted" { - environmentJSON = `{"type":"self_hosted","workspace_directory":"/workspace"}` + environmentJSON = `{"type":"self_hosted","workspace_directory":"/workspace"},` + fixtureSessionProvider options = append(options, WithEnvironmentRemoteURL(environmentOrigin)) } handler, fixture := environmentCreationHandler(t, "codex", options...) diff --git a/services/agents-api/internal/api/errors.go b/services/agents-api/internal/api/errors.go index 22bd002ec..937d4e810 100644 --- a/services/agents-api/internal/api/errors.go +++ b/services/agents-api/internal/api/errors.go @@ -121,6 +121,8 @@ func writeStoreError(w http.ResponseWriter, r *http.Request, err error, notFound writeError(w, http.StatusRequestEntityTooLarge, "request_too_large", "File exceeds this operation's content limit.") case errors.Is(err, store.ErrCredentialStorageUnavailable): writeError(w, http.StatusServiceUnavailable, "credential_storage_unavailable", "Credential encryption is not configured on this service.") + case errors.Is(err, store.ErrModelProviderRequired): + writeError(w, http.StatusBadRequest, "model_provider_required", "This Session was created without a model provider and cannot run. Create a new Session with x_agents_core.model_provider or an Agent that has one saved.") case errors.Is(err, store.ErrHostedEnvironmentFailed): // Observed official status, type, code, null param and message. writeError(w, http.StatusConflict, "conflict_error", "the hosted environment failed to provision") diff --git a/services/agents-api/internal/api/handler.go b/services/agents-api/internal/api/handler.go index 7542545e4..be466cfed 100644 --- a/services/agents-api/internal/api/handler.go +++ b/services/agents-api/internal/api/handler.go @@ -155,7 +155,7 @@ func (h *Handler) routes() *chi.Mux { // createSession atomically reserves or admits initial text with the Session. // @Summary Create an execution Session -// @Description The optional Core model_provider bundle resolves from the Session override, saved Agent defaults, then deployment defaults. Hosted Sessions encrypt and freeze the resolved bundle; later Agent edits and same-key retries cannot change it. Keys are never returned. Supports inline configuration or a tenant-owned saved agent_id with per-Session field replacements. Execution supports model/instructions, text verbosity, non-deferred function tools, adapter-qualified multi_agent with persisted Subagent reads, implicit reasoning, service tier auto and environment type none, subject to the configured engine. Codex additionally supports HTTP MCP with service origin (omitted or null on HTTP transport is saved as service), native allowed_tools and boolean required defaulting to false. Session vault_ids attach only project-owned Vaults; credential_id selects an attached static bearer or OAuth credential for the exact HTTPS URL, while null/omission selects a unique match or remains anonymous. Session reads, lists and event snapshots show that implicitly selected credential ID in a null or omitted credential_id, also after the credential is deleted; anonymous selections stay null and the stored caller intent is unchanged. After the input requirement and before any write, a credential_id without vault_ids, one outside the attached Vaults (one message for missing, foreign and unattached IDs) or one for another server_url returns 400 invalid_request_error, and several implicit matches return 409 conflict_error. Missing decryption configuration fails dispatch without anonymous fallback. Required initialization uses native startup before the first native Turn, including cold resume, and requires a separately advertised capability; exact hosted creation timing and error parity remain unverified. Other MCP origins and native OAuth login remain unsupported. The self_hosted profile requires Codex, an absolute workspace_directory and empty capability_directories, with optional non-deferred function tools and HTTP MCP using service origin, optionally authenticated by the attached Vault rules. Remote MCP and remote Bearer authentication each require separately advertised combination support; old peers cannot receive unsupported work. Omitted/null capability_directories use the empty-list default; self_hosted requires configured execution plus executor registry. Claude SDK currently requires medium verbosity and object-root function schemas. It supports anonymous or attached static-bearer service-origin HTTP MCP on none with boolean required and separately advertised MCP/bearer/required runtime support. Required servers must be connected before the first native input is released; pending or failed startup rejects execution. The shared Vault selection and immutable binding rules apply; unsupported native labels/tool names reject before persistence. An attached Vault with no matching credential may remain anonymous; missing keys or failed credential lookup/decryption never fall back to anonymous execution. Omitted stream defaults to false; stream and agent_id cannot be null. Metadata may be null; non-string values and limit violations return invalid_request_error with a metadata or metadata. param. The inline agent uses the Agent create configuration validation with agent.-prefixed params, reported before the input requirement and saved-Agent lookup; saved configurations with conflicting tools or schema roots reject admission with the same errors, and execution limits keep unsupported_or_invalid_configuration. Hosted network policy rejections return invalid_request_error with a null param. Initial input accepts a string or ordered user-message array. Codex and Claude SDK on none and qualified openai_hosted also accept inline PNG/JPEG image content; other image combinations and remote URLs are unsupported. None initial input atomically starts a Turn; self_hosted initial input is reserved while returning its Environment connection target, with execution deferred to native readiness and Session failure on initial timeout. Initial input is required for none and for streamed creation outside self_hosted. Omitted/null input remains valid for non-streaming hosted and self_hosted creation. With stream=true, returns live Session events starting with the committed creation snapshot and closes right after the first agent.session.idle recorded when a Turn ends or an input reservation stops being pending, or any agent.session.failed, without sending later events. A creation that admitted nothing closes after the snapshot; a settlement that records no event closes after events up to the cursor read with a settled Session projection. Required actions keep it open; disconnect does not cancel execution. The GET events stream remains live-only. New Sessions retain their authenticated creator; all creation retries require the same typed subject, including across key rotation. Saved-Agent retries and inline requests using Vault attachments or credential references retain caller intent independently of later resource changes; new hosted inline requests also freeze caller intent before deployment defaults resolve; unrelated non-hosted inline retries preserve resolved/default equivalences. Unknown historical creators reject retries; known creators without recorded intent retain resolved-snapshot retry rules. These conflict policies are local and not verified hosted parity. A same-key stream=true retry of an existing creation returns 201 with no events and closes at once; retry with stream=false or use the GET events stream to recover. Claude SDK on none and Core-managed Docker openai_hosted supports qualified object-root json_schema output with medium verbosity, single-Agent execution and ordinary functions. Hosted execution reuses native workspace tools and Files/Artifacts; Skills, Plugins, capability directories, HTTP MCP, Subagent and tool_search combinations remain unqualified, including inherited template contents. Other non-text initial input remains unsupported. Basic Codex and Claude SDK openai_hosted creation requires an explicitly configured managed provider. The Claude workspace profile supports non-deferred function tools with text or successful inline PNG/JPEG results alongside native workspace tools; HTTP MCP remains unsupported. Idle Sessions provision automatically; initial provisioning has no caller connection action. Network defaults to enabled; disabled and restricted exact ASCII hostnames are supported. Restricted policy requires 1–100 allowed domains. Unsupported hostname forms and startup installations are rejected. Confidential env, system/npm/Python packages and ordered setup commands use the shared initialization lifecycle; requested network applies after setup. Initial inline and tenant-owned file_id files freeze encrypted bytes before provisioning, then install through the common Core lifecycle before native execution or live Files access. With a template reference, omitted/null files, env, packages and setup_commands inherit. Non-null files and command lists replace; env overlays by key; each package manager inherits on omission/null and otherwise replaces its list. Empty lists clear their selected field. Tenant-owned environment_template_id references inherit omitted/null network and allow only narrowing overrides. Inline hosted network:null retains the enabled default; updating a Template with network:null resets its saved policy to enabled. Core freezes effective configuration; template updates/deletion do not alter Session snapshots or same-intent creation retries. Inline or tenant-owned skill_reference Skills share initialization. Templates preserve default/latest/explicit selectors; Session creation freezes concrete metadata and encrypted content atomically. Skill, Plugin and capability-directory list omission/null inherit; a non-null list replaces, including empty-list clearing. Omitted/null Skill version selectors resolve the default version. Source deletion/default updates cannot change committed Session Skill contents. Deferred function discovery uses type-only tool_search and per-function defer_loading in the qualified single-agent Claude environment:none function profile, including qualified inline image messages and text results. Explicit web_search mode disabled and programmatic_tool_calling enabled false use frozen common Runtime controls. Enabled forms, including those saved on an Agent, remain unqualified and reject before any write unless the Session replaces tools. Omitted programmatic configuration preserves native behavior, a documented difference from the official default-on behavior. Other combinations remain unqualified; see the operation coverage. +// @Description The optional Core model_provider bundle resolves from the Session override, saved Agent defaults, then, for openai_hosted and none, the deployment default of the resolved harness; self_hosted never uses the deployment default and none accepts only it. openai_hosted and self_hosted Sessions that resolve no bundle return 400 model_provider_required with param x_agents_core.model_provider before any write. Core encrypts and freezes the resolved bundle; later Agent or deployment default edits and same-key retries cannot change it. Keys are never returned. Supports inline configuration or a tenant-owned saved agent_id with per-Session field replacements. Execution supports model/instructions, text verbosity, non-deferred function tools, adapter-qualified multi_agent with persisted Subagent reads, implicit reasoning, service tier auto and environment type none, subject to the configured engine. Codex additionally supports HTTP MCP with service origin (omitted or null on HTTP transport is saved as service), native allowed_tools and boolean required defaulting to false. Session vault_ids attach only project-owned Vaults; credential_id selects an attached static bearer or OAuth credential for the exact HTTPS URL, while null/omission selects a unique match or remains anonymous. Session reads, lists and event snapshots show that implicitly selected credential ID in a null or omitted credential_id, also after the credential is deleted; anonymous selections stay null and the stored caller intent is unchanged. After the input requirement and before any write, a credential_id without vault_ids, one outside the attached Vaults (one message for missing, foreign and unattached IDs) or one for another server_url returns 400 invalid_request_error, and several implicit matches return 409 conflict_error. Missing decryption configuration fails dispatch without anonymous fallback. Required initialization uses native startup before the first native Turn, including cold resume, and requires a separately advertised capability; exact hosted creation timing and error parity remain unverified. Other MCP origins and native OAuth login remain unsupported. The self_hosted profile requires Codex, an absolute workspace_directory and empty capability_directories, with optional non-deferred function tools and HTTP MCP using service origin, optionally authenticated by the attached Vault rules. Remote MCP and remote Bearer authentication each require separately advertised combination support; old peers cannot receive unsupported work. Omitted/null capability_directories use the empty-list default; self_hosted requires configured execution plus executor registry. Claude SDK currently requires medium verbosity and object-root function schemas. It supports anonymous or attached static-bearer service-origin HTTP MCP on none with boolean required and separately advertised MCP/bearer/required runtime support. Required servers must be connected before the first native input is released; pending or failed startup rejects execution. The shared Vault selection and immutable binding rules apply; unsupported native labels/tool names reject before persistence. An attached Vault with no matching credential may remain anonymous; missing keys or failed credential lookup/decryption never fall back to anonymous execution. Omitted stream defaults to false; stream and agent_id cannot be null. Metadata may be null; non-string values and limit violations return invalid_request_error with a metadata or metadata. param. The inline agent uses the Agent create configuration validation with agent.-prefixed params, reported before the input requirement and saved-Agent lookup; saved configurations with conflicting tools or schema roots reject admission with the same errors, and execution limits keep unsupported_or_invalid_configuration. Hosted network policy rejections return invalid_request_error with a null param. Initial input accepts a string or ordered user-message array. Codex and Claude SDK on none and qualified openai_hosted also accept inline PNG/JPEG image content; other image combinations and remote URLs are unsupported. None initial input atomically starts a Turn; self_hosted initial input is reserved while returning its Environment connection target, with execution deferred to native readiness and Session failure on initial timeout. Initial input is required for none and for streamed creation outside self_hosted. Omitted/null input remains valid for non-streaming hosted and self_hosted creation. With stream=true, returns live Session events starting with the committed creation snapshot and closes right after the first agent.session.idle recorded when a Turn ends or an input reservation stops being pending, or any agent.session.failed, without sending later events. A creation that admitted nothing closes after the snapshot; a settlement that records no event closes after events up to the cursor read with a settled Session projection. Required actions keep it open; disconnect does not cancel execution. The GET events stream remains live-only. New Sessions retain their authenticated creator; all creation retries require the same typed subject, including across key rotation. Saved-Agent retries and inline requests using Vault attachments or credential references retain caller intent independently of later resource changes; new hosted inline requests also freeze caller intent before deployment defaults resolve; unrelated non-hosted inline retries preserve resolved/default equivalences. Unknown historical creators reject retries; known creators without recorded intent retain resolved-snapshot retry rules. These conflict policies are local and not verified hosted parity. A same-key stream=true retry of an existing creation returns 201 with no events and closes at once; retry with stream=false or use the GET events stream to recover. Claude SDK on none and Core-managed Docker openai_hosted supports qualified object-root json_schema output with medium verbosity, single-Agent execution and ordinary functions. Hosted execution reuses native workspace tools and Files/Artifacts; Skills, Plugins, capability directories, HTTP MCP, Subagent and tool_search combinations remain unqualified, including inherited template contents. Other non-text initial input remains unsupported. Basic Codex and Claude SDK openai_hosted creation requires an explicitly configured managed provider. The Claude workspace profile supports non-deferred function tools with text or successful inline PNG/JPEG results alongside native workspace tools; HTTP MCP remains unsupported. Idle Sessions provision automatically; initial provisioning has no caller connection action. Network defaults to enabled; disabled and restricted exact ASCII hostnames are supported. Restricted policy requires 1–100 allowed domains. Unsupported hostname forms and startup installations are rejected. Confidential env, system/npm/Python packages and ordered setup commands use the shared initialization lifecycle; requested network applies after setup. Initial inline and tenant-owned file_id files freeze encrypted bytes before provisioning, then install through the common Core lifecycle before native execution or live Files access. With a template reference, omitted/null files, env, packages and setup_commands inherit. Non-null files and command lists replace; env overlays by key; each package manager inherits on omission/null and otherwise replaces its list. Empty lists clear their selected field. Tenant-owned environment_template_id references inherit omitted/null network and allow only narrowing overrides. Inline hosted network:null retains the enabled default; updating a Template with network:null resets its saved policy to enabled. Core freezes effective configuration; template updates/deletion do not alter Session snapshots or same-intent creation retries. Inline or tenant-owned skill_reference Skills share initialization. Templates preserve default/latest/explicit selectors; Session creation freezes concrete metadata and encrypted content atomically. Skill, Plugin and capability-directory list omission/null inherit; a non-null list replaces, including empty-list clearing. Omitted/null Skill version selectors resolve the default version. Source deletion/default updates cannot change committed Session Skill contents. Deferred function discovery uses type-only tool_search and per-function defer_loading in the qualified single-agent Claude environment:none function profile, including qualified inline image messages and text results. Explicit web_search mode disabled and programmatic_tool_calling enabled false use frozen common Runtime controls. Enabled forms, including those saved on an Agent, remain unqualified and reject before any write unless the Session replaces tools. Omitted programmatic configuration preserves native behavior, a documented difference from the official default-on behavior. Other combinations remain unqualified; see the operation coverage. // @Tags Sessions // @Accept json // @Produce json,text/event-stream @@ -240,9 +240,9 @@ func (h *Handler) createSession(w http.ResponseWriter, r *http.Request) { } selectedEngine := h.engine var provider *v1.ModelProviderInput - var providerOptions map[string]any + var providerSource string if err == nil { - selectedEngine, provider, providerOptions, err = h.resolveSessionExecution(r.Context(), input, inheritedProvider, configuration) + selectedEngine, provider, providerSource, err = h.resolveSessionExecution(r.Context(), input, inheritedProvider, configuration) } if err == nil { if invalid := h.policy.ValidateSessionConfiguration(selectedEngine, configuration); invalid != nil { @@ -253,7 +253,14 @@ func (h *Handler) createSession(w http.ResponseWriter, r *http.Request) { if h.recoverSessionCreation(w, r, key, creationRequest, input.Stream) { return } - if !writeFieldError(w, err) { + var required *modelProviderRequiredError + var defaults *modelProviderDefaultsError + switch { + case errors.As(err, &required): + writeError(w, http.StatusBadRequest, "model_provider_required", required.message, "x_agents_core.model_provider") + case errors.As(err, &defaults): + writeStoreError(w, r, defaults.err) + case !writeFieldError(w, err): writeError(w, http.StatusBadRequest, "unsupported_or_invalid_configuration", err.Error()) } return @@ -270,7 +277,7 @@ func (h *Handler) createSession(w http.ResponseWriter, r *http.Request) { createInput := store.CreateSessionInput{ ExecutionConfiguration: &executionConfiguration, ModelProvider: provider, - ModelOptions: providerOptions, + ModelProviderSource: providerSource, Creator: sessionCreator(r), InitialFiles: input.initialFiles, Initialization: input.initialization, Engine: selectedEngine, IdempotencyKey: key, Metadata: input.Metadata, Configuration: configuration, InitialInputs: initialInputs, CreationRequest: creationRequest, } diff --git a/services/agents-api/internal/api/handler_test.go b/services/agents-api/internal/api/handler_test.go index e7c281bfe..25ea08800 100644 --- a/services/agents-api/internal/api/handler_test.go +++ b/services/agents-api/internal/api/handler_test.go @@ -130,7 +130,7 @@ func TestHTTPRejectsUntrustedOrUnsupportedRequests(t *testing.T) { {"invalid auth", "Bearer wrong", "agents=v1", "/v1/agents/sessions", valid, 401}, {"missing beta", "Bearer test-api-key", "", "/v1/agents/sessions", valid, 400}, {"tenant body", "Bearer test-api-key", "agents=v1", "/v1/agents/sessions", strings.Replace(valid, `"agent":`, `"tenant_id":"other","agent":`, 1), 400}, - {"hosted environment without managed deployment", "Bearer test-api-key", "agents=v1", "/v1/agents/sessions", strings.Replace(valid, `"none"`, `"openai_hosted"`, 1), 503}, + {"hosted environment without managed deployment", "Bearer test-api-key", "agents=v1", "/v1/agents/sessions", strings.Replace(strings.Replace(valid, `"none"`, `"openai_hosted"`, 1), `"input":`, fixtureSessionProvider+`,"input":`, 1), 503}, {"self-hosted environment", "Bearer test-api-key", "agents=v1", "/v1/agents/sessions", strings.Replace(valid, `"none"`, `"self_hosted"`, 1), 400}, {"initial input", "Bearer test-api-key", "agents=v1", "/v1/agents/sessions", valid, 503}, {"stream unavailable", "Bearer test-api-key", "agents=v1", "/v1/agents/sessions", strings.Replace(valid, `"agent":`, `"stream":true,"agent":`, 1), 503}, diff --git a/services/agents-api/internal/api/harness_model_providers.go b/services/agents-api/internal/api/harness_model_providers.go new file mode 100644 index 000000000..901005569 --- /dev/null +++ b/services/agents-api/internal/api/harness_model_providers.go @@ -0,0 +1,202 @@ +package api + +import ( + "context" + "net/http" + "slices" + "time" + + 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/store" + "github.com/go-chi/chi/v5" +) + +// DeploymentModelProviderStore holds one deployment default model provider per +// harness. Keys are write-only and encrypted. +type DeploymentModelProviderStore interface { + ListDeploymentModelProviders(context.Context) ([]store.DeploymentModelProvider, error) + SetDeploymentModelProvider(context.Context, string, v1.ModelProviderInput) (store.DeploymentModelProvider, error) + DeleteDeploymentModelProvider(context.Context, string) error +} + +// HarnessModelProvider is a harness's deployment default model provider. It +// never contains the API key, only whether one is configured. +type HarnessModelProvider struct { + Object string `json:"object" enums:"core.model_provider" binding:"required"` + Harness string `json:"harness" enums:"claude_sdk,codex,mcode" binding:"required"` + Protocol string `json:"protocol" enums:"anthropic,responses" binding:"required"` + BaseURL string `json:"base_url" binding:"required"` + ContextWindow int32 `json:"context_window,omitempty"` + MaxOutputTokens int32 `json:"max_output_tokens,omitempty"` + APIKeyConfigured bool `json:"api_key_configured" binding:"required"` + UpdatedAt time.Time `json:"updated_at" binding:"required"` +} + +// CoreHarness describes one harness this build supports. Enabled and default +// come from the process configuration; the model provider is the deployment +// default stored in Core, or null. +type CoreHarness struct { + Object string `json:"object" enums:"core.harness" binding:"required"` + ID string `json:"id" enums:"claude_sdk,codex,mcode" binding:"required"` + Enabled bool `json:"enabled" binding:"required"` + Default bool `json:"default" binding:"required"` + ModelProvider *HarnessModelProvider `json:"model_provider" extensions:"x-nullable" binding:"required"` +} + +type CoreHarnessList struct { + Object string `json:"object" enums:"list" binding:"required"` + Data []CoreHarness `json:"data" binding:"required"` +} + +func harnessModelProvider(value store.DeploymentModelProvider) *HarnessModelProvider { + return &HarnessModelProvider{Object: "core.model_provider", Harness: value.Harness, + Protocol: value.Provider.Protocol, BaseURL: value.Provider.BaseURL, + ContextWindow: value.Provider.ContextWindow, MaxOutputTokens: value.Provider.MaxOutputTokens, + APIKeyConfigured: value.Provider.APIKeyConfigured, UpdatedAt: value.UpdatedAt.UTC()} +} + +// registerHarnessRoutes adds harness and deployment model provider management +// to the Core-key-authenticated /core/v1 router. +func (h *Handler) registerHarnessRoutes(r chi.Router) { + s, ok := h.store.(DeploymentModelProviderStore) + if !ok { + return + } + r.Get("/harnesses", func(w http.ResponseWriter, r *http.Request) { h.listHarnesses(w, r, s) }) + r.Get("/harnesses/{harness}/model-provider", func(w http.ResponseWriter, r *http.Request) { h.getHarnessModelProvider(w, r, s) }) + r.Put("/harnesses/{harness}/model-provider", func(w http.ResponseWriter, r *http.Request) { h.setHarnessModelProvider(w, r, s) }) + r.Delete("/harnesses/{harness}/model-provider", func(w http.ResponseWriter, r *http.Request) { h.deleteHarnessModelProvider(w, r, s) }) +} + +// knownHarness reports the path harness, writing 404 for one this build lacks. +func knownHarness(w http.ResponseWriter, r *http.Request) (string, bool) { + harness := chi.URLParam(r, "harness") + if !slices.Contains((engine.Catalog{}).Kinds(), harness) { + writeError(w, http.StatusNotFound, "not_found", "This harness does not exist.") + return "", false + } + return harness, true +} + +// @Summary List harnesses and their deployment default model providers +// @Description Core key only. Returns every harness this build supports, in name order. enabled and default are read-only views of the process configuration (AGENTS_API_ENGINE and AGENTS_API_HARNESSES). model_provider is the harness's deployment default, stored in Core, or null. Keys are never returned; api_key_configured reports that one is set. +// @Tags Deployment Model Providers +// @Produce json +// @Security DeploymentAdminAuth +// @Success 200 {object} api.CoreHarnessList +// @Failure 401,500 {object} v1.ErrorResponse +// @Router /core/v1/harnesses [get] +func (h *Handler) listHarnesses(w http.ResponseWriter, r *http.Request, s DeploymentModelProviderStore) { + providers, err := s.ListDeploymentModelProviders(r.Context()) + if err != nil { + writeStoreError(w, r, err) + return + } + list := CoreHarnessList{Object: "list", Data: []CoreHarness{}} + for _, kind := range (engine.Catalog{}).Kinds() { + harness := CoreHarness{Object: "core.harness", ID: kind, Enabled: kind == h.engine || h.harnesses[kind], Default: kind == h.engine} + for _, provider := range providers { + if provider.Harness == kind { + harness.ModelProvider = harnessModelProvider(provider) + } + } + list.Data = append(list.Data, harness) + } + writeJSON(w, http.StatusOK, list) +} + +// @Summary Retrieve a harness's deployment default model provider +// @Description Core key only. Returns the safe view; the key is never returned. 404 when the harness does not exist or has no deployment default. +// @Tags Deployment Model Providers +// @Produce json +// @Security DeploymentAdminAuth +// @Param harness path string true "Harness" Enums(claude_sdk,codex,mcode) +// @Success 200 {object} api.HarnessModelProvider +// @Failure 401,404,500 {object} v1.ErrorResponse +// @Router /core/v1/harnesses/{harness}/model-provider [get] +func (h *Handler) getHarnessModelProvider(w http.ResponseWriter, r *http.Request, s DeploymentModelProviderStore) { + harness, ok := knownHarness(w, r) + if !ok { + return + } + providers, err := s.ListDeploymentModelProviders(r.Context()) + if err != nil { + writeStoreError(w, r, err) + return + } + for _, provider := range providers { + if provider.Harness == harness { + writeJSON(w, http.StatusOK, harnessModelProvider(provider)) + return + } + } + writeError(w, http.StatusNotFound, "not_found", "This harness has no deployment default model provider.") +} + +var harnessModelProviderShape = shape{kind: objectValue, members: []member{ + {"protocol", requiredString}, {"base_url", requiredString}, {"api_key", requiredString}, + {"context_window", shape{kind: integerValue, minimum: 0}}, + {"max_output_tokens", shape{kind: integerValue, minimum: 0}}, +}} + +// @Summary Replace a harness's deployment default model provider +// @Description Core key only. The body is the complete x_agents_core.model_provider bundle, including the write-only api_key; there is no partial update and bundles are never merged. The provider is validated for this harness: an HTTPS base_url without credentials, query or fragment, the harness's protocol (responses for codex, anthropic for claude_sdk and mcode) and, for mcode, positive context_window and max_output_tokens. New openai_hosted and none Sessions that resolve no Session or Agent bundle freeze this default into their encrypted snapshot; existing Sessions never change. self_hosted Sessions never use it. The key is encrypted and never returned. Each write records an administrator audit entry without the key. +// @Tags Deployment Model Providers +// @Accept json +// @Produce json +// @Security DeploymentAdminAuth +// @Param harness path string true "Harness" Enums(claude_sdk,codex,mcode) +// @Param body body v1.ModelProviderInput true "Complete model provider bundle" +// @Success 200 {object} api.HarnessModelProvider +// @Failure 400,401,404,413,500,503 {object} v1.ErrorResponse +// @Router /core/v1/harnesses/{harness}/model-provider [put] +func (h *Handler) setHarnessModelProvider(w http.ResponseWriter, r *http.Request, s DeploymentModelProviderStore) { + harness, ok := knownHarness(w, r) + if !ok { + return + } + raw, ok := readJSONObjectLimit(w, r, 32*1024, "Request exceeds 32 KiB.") + if !ok { + return + } + var input v1.ModelProviderInput + // Neither message echoes submitted values, which may include the key. + if checkValue("model_provider", raw, harnessModelProviderShape) != nil || decodeInputObject(raw, &input, "protocol", "base_url", "api_key", "context_window", "max_output_tokens") != nil { + writeError(w, http.StatusBadRequest, "invalid_request_error", "The body must be a complete model provider: protocol, base_url, api_key and optional nonnegative context_window and max_output_tokens.") + return + } + if err := input.ValidateHarness(harness); err != nil { + writeError(w, http.StatusBadRequest, "invalid_request_error", err.Error()) + return + } + setAdminAuditSource(r, "") + provider, err := s.SetDeploymentModelProvider(r.Context(), harness, input) + if err != nil { + writeStoreError(w, r, err) + return + } + writeJSON(w, http.StatusOK, harnessModelProvider(provider)) +} + +// @Summary Remove a harness's deployment default model provider +// @Description Core key only. Idempotent; each successful request is audited. Sessions that already froze the default keep it. Afterwards new openai_hosted Sessions for this harness need a Session or Agent bundle. +// @Tags Deployment Model Providers +// @Security DeploymentAdminAuth +// @Param harness path string true "Harness" Enums(claude_sdk,codex,mcode) +// @Success 204 +// @Failure 401,404,500 {object} v1.ErrorResponse +// @Router /core/v1/harnesses/{harness}/model-provider [delete] +func (h *Handler) deleteHarnessModelProvider(w http.ResponseWriter, r *http.Request, s DeploymentModelProviderStore) { + harness, ok := knownHarness(w, r) + if !ok { + return + } + setAdminAuditSource(r, "") + if err := s.DeleteDeploymentModelProvider(r.Context(), harness); err != nil { + writeStoreError(w, r, err) + return + } + w.Header().Set("Cache-Control", "no-store") + w.WriteHeader(http.StatusNoContent) +} diff --git a/services/agents-api/internal/api/harness_test.go b/services/agents-api/internal/api/harness_test.go index 5c1bb35a4..8f182b3eb 100644 --- a/services/agents-api/internal/api/harness_test.go +++ b/services/agents-api/internal/api/harness_test.go @@ -26,7 +26,7 @@ func TestSessionHarnessAdmission(t *testing.T) { {"empty", `,"x_agents_core":{}`, "", `{"type":"none"}`, "", true, 400}, {"unknown nested", `,"x_agents_core":{"harness":"codex","model":"wrong"}`, "", `{"type":"none"}`, "", true, 400}, {"claude verbosity", `,"x_agents_core":{"harness":"claude_sdk"}`, `,"text":{"verbosity":"high"}`, `{"type":"none"}`, "", true, 400}, - {"mcode self-hosted requires executor configuration", `,"x_agents_core":{"harness":"mcode"}`, "", `{"type":"self_hosted","workspace_directory":"/workspace"}`, "", true, 503}, + {"mcode self-hosted requires executor configuration", `,"x_agents_core":{"harness":"mcode"}`, "", `{"type":"self_hosted","workspace_directory":"/workspace"},` + fixtureAnthropicSessionProvider, "", true, 503}, } { t.Run(tc.name, func(t *testing.T) { var options []Option diff --git a/services/agents-api/internal/api/model_provider_fixture_test.go b/services/agents-api/internal/api/model_provider_fixture_test.go new file mode 100644 index 000000000..e81fc208b --- /dev/null +++ b/services/agents-api/internal/api/model_provider_fixture_test.go @@ -0,0 +1,31 @@ +package api + +import ( + "context" + + v1 "github.com/MiniMax-AI-Dev/parsar/contracts/agents-api/v1" +) + +// Hosted and self-hosted Sessions must freeze a model provider. Tests that +// exercise other behavior supply these fixtures instead of relaxing that check. + +// fixtureModelProvider returns a valid bundle for the harness. +func fixtureModelProvider(harness string) *v1.ModelProviderInput { + if harness == "codex" { + return &v1.ModelProviderInput{Protocol: "responses", BaseURL: "https://model.fixture.example/v1", APIKey: "fixture-model-key"} + } + return &v1.ModelProviderInput{Protocol: "anthropic", BaseURL: "https://model.fixture.example/anthropic", APIKey: "fixture-model-key", ContextWindow: 200000, MaxOutputTokens: 8000} +} + +// withFixtureDeploymentProvider configures a deployment default for every harness. +func withFixtureDeploymentProvider() Option { + return WithModelProviderDefaults(func(_ context.Context, harness string) (*v1.ModelProviderInput, error) { + return fixtureModelProvider(harness), nil + }) +} + +// fixtureSessionProvider is a top-level Session request member for Codex. +const fixtureSessionProvider = `"x_agents_core":{"model_provider":{"protocol":"responses","base_url":"https://model.fixture.example/v1","api_key":"fixture-model-key"}}` + +// fixtureAnthropicSessionProvider is the Claude Code and MiniMax Code equivalent. +const fixtureAnthropicSessionProvider = `"x_agents_core":{"model_provider":{"protocol":"anthropic","base_url":"https://model.fixture.example/anthropic","api_key":"fixture-model-key","context_window":200000,"max_output_tokens":8000}}` diff --git a/services/agents-api/internal/api/session_execution_configuration.go b/services/agents-api/internal/api/session_execution_configuration.go index 75f95e3a0..58296a4e6 100644 --- a/services/agents-api/internal/api/session_execution_configuration.go +++ b/services/agents-api/internal/api/session_execution_configuration.go @@ -36,15 +36,14 @@ func sessionExecutionProjection(input sessionRequest, saved *v1.SavedAgent, inhe } selection := v1.ExecutionProviderSelection{Source: "unknown", Status: "unavailable"} if provider != nil { - selection.Source, selection.Status = "deployment", "redacted" + // Deployment defaults are readable with the same Core key, so new + // Sessions record their safe view too; historical rows stay redacted. + selection.Source, selection.Status, selection.Configuration = "deployment", "available", provider.SafeView() if input.XAgentsCore != nil && input.XAgentsCore.ModelProvider != nil { selection.Source = "session" } else if inherited != nil { selection.Source = "agent" } - if selection.Source != "deployment" { - selection.Status, selection.Configuration = "available", provider.SafeView() - } } return v1.SessionExecutionConfiguration{ Object: "agent.session.execution_configuration", SchemaVersion: 1, 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 ec867695c..f204fb175 100644 --- a/services/agents-api/internal/api/session_execution_configuration_test.go +++ b/services/agents-api/internal/api/session_execution_configuration_test.go @@ -27,7 +27,7 @@ func TestExecutionConfigurationSources(t *testing.T) { {"harness reset", `,"agent":{"x_agents_core":null}`, ``, saved, provider, provider, "agent", "deployment", "agent", "available"}, {"explicit bundle", ``, `,"x_agents_core":{"model_provider":{"protocol":"responses","base_url":"https://explicit.example/v1","api_key":"explicit-secret"}}`, saved, nil, provider, "agent", "agent", "session", "available"}, {"provider null inherits", ``, `,"x_agents_core":{"model_provider":null}`, saved, provider, provider, "agent", "agent", "agent", "available"}, - {"inline deployment", `,"agent":{"model":"inline"}`, ``, nil, nil, provider, "session", "deployment", "deployment", "redacted"}, + {"inline deployment", `,"agent":{"model":"inline"}`, ``, nil, nil, provider, "session", "deployment", "deployment", "available"}, {"inline explicit harness", `,"agent":{"model":"inline","x_agents_core":{"harness":"codex"}}`, ``, nil, nil, nil, "session", "session", "unknown", "unavailable"}, } { t.Run(tc.name, func(t *testing.T) { diff --git a/services/agents-api/internal/api/session_model_defaults.go b/services/agents-api/internal/api/session_model_defaults.go index 6846ebd64..9c3bb5f10 100644 --- a/services/agents-api/internal/api/session_model_defaults.go +++ b/services/agents-api/internal/api/session_model_defaults.go @@ -9,9 +9,10 @@ import ( "github.com/MiniMax-AI-Dev/parsar/services/agents-api/internal/store" ) -// ModelProviderDefaults resolves deployment configuration at Session creation, -// before the encrypted Session snapshot is committed. -type ModelProviderDefaults func(context.Context, string, string) (*v1.ModelProviderInput, map[string]any, error) +// ModelProviderDefaults decrypts the deployment default model provider for a +// harness at Session creation, before the encrypted Session snapshot is +// committed. It returns nil when the harness has no default. +type ModelProviderDefaults func(context.Context, string) (*v1.ModelProviderInput, error) func WithModelProviderDefaults(resolve ModelProviderDefaults) Option { return func(h *Handler) { h.modelProviderDefaults = resolve } @@ -50,38 +51,62 @@ func (h *Handler) sessionAgentDefaults(ctx context.Context, tenant string, input return saved, provider, nil } -func (h *Handler) resolveSessionExecution(ctx context.Context, input sessionRequest, inherited *v1.ModelProviderInput, raw json.RawMessage) (string, *v1.ModelProviderInput, map[string]any, error) { +// modelProviderRequiredError reports a hosted or self-hosted Session that +// would have no model provider. The message says what to configure. +type modelProviderRequiredError struct{ message string } + +func (e *modelProviderRequiredError) Error() string { return e.message } + +// modelProviderDefaultsError carries a storage or decryption failure while +// reading the deployment default; it is reported as a service error. +type modelProviderDefaultsError struct{ err error } + +func (e *modelProviderDefaultsError) Error() string { return e.err.Error() } +func (e *modelProviderDefaultsError) Unwrap() error { return e.err } + +func modelProviderRequired(environment, engine string) error { + if environment == "self_hosted" { + return &modelProviderRequiredError{"self_hosted Sessions need a model provider for harness " + engine + ": pass x_agents_core.model_provider or use an Agent that has one saved. Deployment default model providers apply only to openai_hosted Sessions."} + } + return &modelProviderRequiredError{"No model provider is configured for harness " + engine + ". Pass x_agents_core.model_provider, use an Agent that has one saved, or ask the Core administrator to set a deployment default model provider for " + engine + "."} +} + +// resolveSessionExecution applies provider precedence: the Session bundle, the +// saved Agent bundle, then the deployment default where the environment allows +// it. Bundles are never merged. +func (h *Handler) resolveSessionExecution(ctx context.Context, input sessionRequest, inherited *v1.ModelProviderInput, raw json.RawMessage) (string, *v1.ModelProviderInput, string, error) { engine, err := h.sessionHarness(raw) if err != nil { - return "", nil, nil, err + return "", nil, "", err } - provider := inherited - var options map[string]any + provider, source := inherited, v1.ModelProviderSourceAgent if extension := input.XAgentsCore; extension != nil { if extension.ModelProvider == nil && !input.modelProviderNull { - return "", nil, nil, errors.New("x_agents_core requires an execution option") + return "", nil, "", errors.New("x_agents_core requires an execution option") } if extension.ModelProvider != nil { - provider = extension.ModelProvider + provider, source = extension.ModelProvider, v1.ModelProviderSourceSession } } - if provider == nil && input.Environment.Type == "openai_hosted" && h.modelProviderDefaults != nil { - var cfg configuration - if err := json.Unmarshal(raw, &cfg); err != nil { - return "", nil, nil, err - } - provider, options, err = h.modelProviderDefaults(ctx, engine, cfg.Agent.Model) + environment := input.Environment.Type + if provider == nil && h.modelProviderDefaults != nil && v1.ModelProviderAllowed(environment, v1.ModelProviderSourceDeployment) { + provider, err = h.modelProviderDefaults(ctx, engine) if err != nil { - return "", nil, nil, errors.New("deployment model provider configuration is unavailable") + return "", nil, "", &modelProviderDefaultsError{err} } + source = v1.ModelProviderSourceDeployment } - if provider != nil { - if !v1.ModelProviderEnvironmentSupported(input.Environment.Type) { - return "", nil, nil, errors.New("caller model credentials currently require a hosted environment") - } - if err := provider.ValidateHarness(engine); err != nil { - return "", nil, nil, err + if provider == nil { + if v1.ModelProviderRequired(environment) { + return "", nil, "", modelProviderRequired(environment, engine) } + return engine, nil, "", nil + } + if !v1.ModelProviderAllowed(environment, source) { + return "", nil, "", errors.New("caller model credentials require an openai_hosted or self_hosted environment") + } + if err := provider.ValidateHarness(engine); err != nil { + return "", nil, "", err } - return engine, provider, options, nil + return engine, provider, source, nil } diff --git a/services/agents-api/internal/credentialcrypto/deployment_model_provider.go b/services/agents-api/internal/credentialcrypto/deployment_model_provider.go new file mode 100644 index 000000000..07db34fc9 --- /dev/null +++ b/services/agents-api/internal/credentialcrypto/deployment_model_provider.go @@ -0,0 +1,32 @@ +package credentialcrypto + +import ( + "encoding/json" + "unicode/utf8" +) + +// The deployment default model provider is bound to its harness, so one +// harness's ciphertext cannot be read as another's, as an Agent default or as +// a Session snapshot. +func deploymentModelProviderData(harness string) ([]byte, error) { + if harness == "" || !utf8.ValidString(harness) { + return nil, errInvalidBinding + } + return json.Marshal([]string{"parsar.agents-api.deployment-model-provider.v1", harness}) +} + +func (c *Cipher) SealDeploymentModelProvider(plaintext []byte, harness string) ([]byte, error) { + aad, err := deploymentModelProviderData(harness) + if err != nil { + return nil, err + } + return c.seal(plaintext, aad) +} + +func (c *Cipher) OpenDeploymentModelProvider(ciphertext []byte, harness string) ([]byte, error) { + aad, err := deploymentModelProviderData(harness) + if err != nil { + return nil, err + } + return c.open(ciphertext, aad) +} diff --git a/services/agents-api/internal/credentialcrypto/deployment_model_provider_test.go b/services/agents-api/internal/credentialcrypto/deployment_model_provider_test.go new file mode 100644 index 000000000..05e7a2754 --- /dev/null +++ b/services/agents-api/internal/credentialcrypto/deployment_model_provider_test.go @@ -0,0 +1,37 @@ +package credentialcrypto + +import ( + "bytes" + "testing" +) + +func TestDeploymentModelProviderBinding(t *testing.T) { + c, err := New(bytes.Repeat([]byte{29}, 32)) + if err != nil { + t.Fatal(err) + } + raw := []byte("deployment-provider-canary") + encrypted, err := c.SealDeploymentModelProvider(raw, "codex") + if err != nil || bytes.Contains(encrypted, raw) { + t.Fatal("credential encryption failed") + } + if opened, err := c.OpenDeploymentModelProvider(encrypted, "codex"); err != nil || !bytes.Equal(raw, opened) { + t.Fatal("credential round trip failed") + } + if _, err := c.OpenDeploymentModelProvider(encrypted, "mcode"); err == nil { + t.Fatal("another harness opened the ciphertext") + } + if _, err := c.OpenAgentModelExecution(encrypted, "codex", "codex"); err == nil { + t.Fatal("deployment ciphertext accepted for Agent purpose") + } + if _, err := c.OpenModelExecution(encrypted, "codex", "codex"); err == nil { + t.Fatal("deployment ciphertext accepted for Session purpose") + } + session, err := c.SealModelExecution(raw, "codex", "codex") + if err != nil { + t.Fatal(err) + } + if _, err := c.OpenDeploymentModelProvider(session, "codex"); err == nil { + t.Fatal("Session ciphertext accepted for deployment purpose") + } +} diff --git a/services/agents-api/internal/db/queries/deployment_model_providers.sql b/services/agents-api/internal/db/queries/deployment_model_providers.sql new file mode 100644 index 000000000..bda5a2919 --- /dev/null +++ b/services/agents-api/internal/db/queries/deployment_model_providers.sql @@ -0,0 +1,17 @@ +-- name: ListDeploymentModelProviders :many +SELECT harness, protocol, base_url, context_window, max_output_tokens, updated_at +FROM deployment_model_providers ORDER BY harness; + +-- name: GetDeploymentModelProviderSecret :one +SELECT encrypted_config FROM deployment_model_providers WHERE harness = @harness; + +-- name: UpsertDeploymentModelProvider :one +INSERT INTO deployment_model_providers (harness, protocol, base_url, context_window, max_output_tokens, encrypted_config, updated_at) +VALUES (@harness, @protocol, @base_url, @context_window, @max_output_tokens, @encrypted_config, clock_timestamp()) +ON CONFLICT (harness) DO UPDATE SET protocol = EXCLUDED.protocol, base_url = EXCLUDED.base_url, + context_window = EXCLUDED.context_window, max_output_tokens = EXCLUDED.max_output_tokens, + encrypted_config = EXCLUDED.encrypted_config, updated_at = EXCLUDED.updated_at +RETURNING harness, protocol, base_url, context_window, max_output_tokens, updated_at; + +-- name: DeleteDeploymentModelProvider :execrows +DELETE FROM deployment_model_providers WHERE harness = @harness; diff --git a/services/agents-api/internal/db/sqlc/deployment_model_providers.sql.go b/services/agents-api/internal/db/sqlc/deployment_model_providers.sql.go new file mode 100644 index 000000000..c4ba3687a --- /dev/null +++ b/services/agents-api/internal/db/sqlc/deployment_model_providers.sql.go @@ -0,0 +1,124 @@ +// Code generated by sqlc. DO NOT EDIT. +// versions: +// sqlc v1.29.0 +// source: deployment_model_providers.sql + +package sqlc + +import ( + "context" + + "github.com/jackc/pgx/v5/pgtype" +) + +const deleteDeploymentModelProvider = `-- name: DeleteDeploymentModelProvider :execrows +DELETE FROM deployment_model_providers WHERE harness = $1 +` + +func (q *Queries) DeleteDeploymentModelProvider(ctx context.Context, harness string) (int64, error) { + result, err := q.db.Exec(ctx, deleteDeploymentModelProvider, harness) + if err != nil { + return 0, err + } + return result.RowsAffected(), nil +} + +const getDeploymentModelProviderSecret = `-- name: GetDeploymentModelProviderSecret :one +SELECT encrypted_config FROM deployment_model_providers WHERE harness = $1 +` + +func (q *Queries) GetDeploymentModelProviderSecret(ctx context.Context, harness string) ([]byte, error) { + row := q.db.QueryRow(ctx, getDeploymentModelProviderSecret, harness) + var encrypted_config []byte + err := row.Scan(&encrypted_config) + return encrypted_config, err +} + +const listDeploymentModelProviders = `-- name: ListDeploymentModelProviders :many +SELECT harness, protocol, base_url, context_window, max_output_tokens, updated_at +FROM deployment_model_providers ORDER BY harness +` + +type ListDeploymentModelProvidersRow struct { + Harness string `json:"harness"` + Protocol string `json:"protocol"` + BaseUrl string `json:"base_url"` + ContextWindow int32 `json:"context_window"` + MaxOutputTokens int32 `json:"max_output_tokens"` + UpdatedAt pgtype.Timestamptz `json:"updated_at"` +} + +func (q *Queries) ListDeploymentModelProviders(ctx context.Context) ([]ListDeploymentModelProvidersRow, error) { + rows, err := q.db.Query(ctx, listDeploymentModelProviders) + if err != nil { + return nil, err + } + defer rows.Close() + items := []ListDeploymentModelProvidersRow{} + for rows.Next() { + var i ListDeploymentModelProvidersRow + if err := rows.Scan( + &i.Harness, + &i.Protocol, + &i.BaseUrl, + &i.ContextWindow, + &i.MaxOutputTokens, + &i.UpdatedAt, + ); err != nil { + return nil, err + } + items = append(items, i) + } + if err := rows.Err(); err != nil { + return nil, err + } + return items, nil +} + +const upsertDeploymentModelProvider = `-- name: UpsertDeploymentModelProvider :one +INSERT INTO deployment_model_providers (harness, protocol, base_url, context_window, max_output_tokens, encrypted_config, updated_at) +VALUES ($1, $2, $3, $4, $5, $6, clock_timestamp()) +ON CONFLICT (harness) DO UPDATE SET protocol = EXCLUDED.protocol, base_url = EXCLUDED.base_url, + context_window = EXCLUDED.context_window, max_output_tokens = EXCLUDED.max_output_tokens, + encrypted_config = EXCLUDED.encrypted_config, updated_at = EXCLUDED.updated_at +RETURNING harness, protocol, base_url, context_window, max_output_tokens, updated_at +` + +type UpsertDeploymentModelProviderParams struct { + Harness string `json:"harness"` + Protocol string `json:"protocol"` + BaseUrl string `json:"base_url"` + ContextWindow int32 `json:"context_window"` + MaxOutputTokens int32 `json:"max_output_tokens"` + EncryptedConfig []byte `json:"encrypted_config"` +} + +type UpsertDeploymentModelProviderRow struct { + Harness string `json:"harness"` + Protocol string `json:"protocol"` + BaseUrl string `json:"base_url"` + ContextWindow int32 `json:"context_window"` + MaxOutputTokens int32 `json:"max_output_tokens"` + UpdatedAt pgtype.Timestamptz `json:"updated_at"` +} + +func (q *Queries) UpsertDeploymentModelProvider(ctx context.Context, arg UpsertDeploymentModelProviderParams) (UpsertDeploymentModelProviderRow, error) { + row := q.db.QueryRow(ctx, upsertDeploymentModelProvider, + arg.Harness, + arg.Protocol, + arg.BaseUrl, + arg.ContextWindow, + arg.MaxOutputTokens, + arg.EncryptedConfig, + ) + var i UpsertDeploymentModelProviderRow + err := row.Scan( + &i.Harness, + &i.Protocol, + &i.BaseUrl, + &i.ContextWindow, + &i.MaxOutputTokens, + &i.UpdatedAt, + ) + return i, err +} diff --git a/services/agents-api/internal/db/sqlc/models.go b/services/agents-api/internal/db/sqlc/models.go index 54b6a59a4..62d7e0ad6 100644 --- a/services/agents-api/internal/db/sqlc/models.go +++ b/services/agents-api/internal/db/sqlc/models.go @@ -53,6 +53,16 @@ type AgentModelExecution struct { EncryptedConfig []byte `json:"encrypted_config"` } +type DeploymentModelProvider struct { + Harness string `json:"harness"` + Protocol string `json:"protocol"` + BaseUrl string `json:"base_url"` + ContextWindow int32 `json:"context_window"` + MaxOutputTokens int32 `json:"max_output_tokens"` + EncryptedConfig []byte `json:"encrypted_config"` + UpdatedAt pgtype.Timestamptz `json:"updated_at"` +} + type Device struct { ID pgtype.UUID `json:"id"` TenantID pgtype.UUID `json:"tenant_id"` diff --git a/services/agents-api/internal/execution/dispatcher.go b/services/agents-api/internal/execution/dispatcher.go index bddbd80e5..11ec43c75 100644 --- a/services/agents-api/internal/execution/dispatcher.go +++ b/services/agents-api/internal/execution/dispatcher.go @@ -32,7 +32,11 @@ type Dispatcher struct { Policy Store *store.Store Registry *gateway.Registry - // Options resolves transient engine credentials; they are never stored here. + // Options optionally supplies native adapter options for Sessions that need + // no frozen model provider (environment none and legacy daemon Sessions). + // The server command leaves it nil since the operator options file was + // retired; native tests use it to reach synthetic model servers. It is never + // consulted for openai_hosted or self_hosted Sessions. Options func(context.Context, store.Session) (map[string]any, error) // ManagedRuntimes is optional internal provisioning; it does not admit hosted API requests. ManagedRuntimes *RuntimeProvider diff --git a/services/agents-api/internal/execution/environment_admission.go b/services/agents-api/internal/execution/environment_admission.go index a0ee51c96..b915e37af 100644 --- a/services/agents-api/internal/execution/environment_admission.go +++ b/services/agents-api/internal/execution/environment_admission.go @@ -87,6 +87,13 @@ func (w *Worker) submitEnvironmentInputs(ctx context.Context, session store.Sess // Neither kind creates a Turn. The Session lock preserves target and retry identity. return w.admission.SubmitInputs(ctx, session.TenantID, session.ID, key, inputs) } + // Messages start work. A Session from before deployment defaults moved into + // Core may have no frozen provider; reject it here instead of queueing work + // its harness cannot run. Cancellation and results above stay available. + var snapshot Snapshot + if json.Unmarshal(session.Configuration, &snapshot) != nil || !snapshot.ModelProviderConfigured { + return nil, store.ErrModelProviderRequired + } reserve, cancel := context.WithTimeout(ctx, 5*time.Second) reservation, err := w.admission.ReserveEnvironmentInput(reserve, session.TenantID, session.ID, key, inputs) cancel() diff --git a/services/agents-api/internal/execution/model_execution_test.go b/services/agents-api/internal/execution/model_execution_test.go index 3ccb00d4d..62af0db2c 100644 --- a/services/agents-api/internal/execution/model_execution_test.go +++ b/services/agents-api/internal/execution/model_execution_test.go @@ -2,6 +2,7 @@ package execution import ( "context" + "errors" v1 "github.com/MiniMax-AI-Dev/parsar/contracts/agents-api/v1" "github.com/MiniMax-AI-Dev/parsar/internal/agentdaemon/device" "github.com/MiniMax-AI-Dev/parsar/services/agents-api/internal/store" @@ -9,18 +10,26 @@ import ( "testing" ) -func TestSessionModelExecutionNeverFallsBackToOperatorCredentials(t *testing.T) { +func TestSessionModelExecutionNeverFallsBack(t *testing.T) { called := false d := Dispatcher{Options: func(context.Context, store.Session) (map[string]any, error) { called = true - return map[string]any{"model": "operator-fallback"}, nil + return map[string]any{"codex_provider": map[string]any{"base_url": "http://127.0.0.1:1/v1"}}, nil }} - _, err := d.executionRequest(t.Context(), store.Session{Engine: "codex"}, Snapshot{ModelProviderConfigured: true}, device.KindCapabilities{}, store.SessionExecutionBinding{}) - if err == nil || called { - t.Fatal("missing Session credentials used operator fallback") + if _, err := d.executionRequest(t.Context(), store.Session{Engine: "codex"}, Snapshot{ModelProviderConfigured: true}, device.KindCapabilities{}, store.SessionExecutionBinding{}); err == nil || called { + t.Fatal("missing Session credentials fell back") } - if _, err = d.executionRequest(t.Context(), store.Session{Engine: "codex"}, Snapshot{}, device.KindCapabilities{}, store.SessionExecutionBinding{}); err != nil || !called { - t.Fatal("legacy operator configuration lost", err) + // Hosted and self-hosted Runtimes have no model configuration of their own. + for _, environment := range []string{"openai_hosted", "self_hosted"} { + snapshot := Snapshot{Environment: &v1.Environment{Type: environment}} + if _, err := d.executionRequest(t.Context(), store.Session{Engine: "codex"}, snapshot, device.KindCapabilities{}, store.SessionExecutionBinding{}); !errors.Is(err, store.ErrModelProviderRequired) || called { + t.Fatal("provider-free Session dispatched", environment, err) + } + } + // A none device may supply its own provider environment. + request, err := d.executionRequest(t.Context(), store.Session{Engine: "codex"}, Snapshot{Environment: &v1.Environment{Type: "none"}}, device.KindCapabilities{}, store.SessionExecutionBinding{}) + if err != nil || !called || request.AgentOptions["codex_provider"] == nil { + t.Fatal("none Session lost its adapter options", err) } } diff --git a/services/agents-api/internal/execution/request.go b/services/agents-api/internal/execution/request.go index cab8e3759..8d398e622 100644 --- a/services/agents-api/internal/execution/request.go +++ b/services/agents-api/internal/execution/request.go @@ -5,6 +5,7 @@ import ( "errors" "maps" + v1 "github.com/MiniMax-AI-Dev/parsar/contracts/agents-api/v1" "github.com/MiniMax-AI-Dev/parsar/internal/agentdaemon/device" "github.com/MiniMax-AI-Dev/parsar/internal/agentdaemon/proto" "github.com/MiniMax-AI-Dev/parsar/services/agents-api/internal/store" @@ -25,6 +26,10 @@ func (d *Dispatcher) executionRequest(ctx context.Context, session store.Session if err != nil { return proto.PromptRequestPayload{}, err } + } else if snapshot.Environment != nil && v1.ModelProviderRequired(snapshot.Environment.Type) { + // A Session from before deployment defaults moved into Core. Without a + // bundle its harness would fall back to a built-in endpoint and hang. + return proto.PromptRequestPayload{}, store.ErrModelProviderRequired } else if d.Options != nil { options, err = d.Options(ctx, session) if err != nil { diff --git a/services/agents-api/internal/execution/worker.go b/services/agents-api/internal/execution/worker.go index dc741ab5e..f4ba8b8ec 100644 --- a/services/agents-api/internal/execution/worker.go +++ b/services/agents-api/internal/execution/worker.go @@ -304,6 +304,10 @@ func (w *Worker) runClaim(ctx context.Context, item store.ExecutionWork) error { if err == nil || errors.Is(err, store.ErrTurnConflict) { return nil } + outcome := json.RawMessage(`{"error_code":"execution_unavailable"}`) + if errors.Is(err, store.ErrModelProviderRequired) { + outcome = json.RawMessage(`{"error_code":"model_provider_required"}`) + } finish, cancel := context.WithTimeout(context.Background(), 10*time.Second) defer cancel() turn, err := w.dispatcher.Store.GetTurn(finish, item.TenantID, item.SessionID, item.TurnID) @@ -314,7 +318,7 @@ func (w *Worker) runClaim(ctx context.Context, item store.ExecutionWork) error { return nil } log.Ctx(ctx).Error("agents-api dispatch did not complete", "turn_id", item.TurnID) - _, err = w.dispatcher.Store.TransitionTurn(finish, item.TenantID, item.SessionID, item.TurnID, store.TurnTransition{ExpectedStatus: turn.Status, Status: store.TurnFailed, Outcome: json.RawMessage(`{"error_code":"execution_unavailable"}`)}) + _, err = w.dispatcher.Store.TransitionTurn(finish, item.TenantID, item.SessionID, item.TurnID, store.TurnTransition{ExpectedStatus: turn.Status, Status: store.TurnFailed, Outcome: outcome}) if errors.Is(err, store.ErrTurnConflict) { return nil } diff --git a/services/agents-api/internal/store/admin_audit.go b/services/agents-api/internal/store/admin_audit.go index 9535e5c2c..9b9570a68 100644 --- a/services/agents-api/internal/store/admin_audit.go +++ b/services/agents-api/internal/store/admin_audit.go @@ -28,3 +28,14 @@ func recordAdminMutation(ctx context.Context, q *sqlc.Queries, tenant, action, r _, err = q.InsertAdminAudit(ctx, sqlc.InsertAdminAuditParams{ID: pgtype.UUID{Bytes: uuid.New(), Valid: true}, TenantID: tenantID, AdminCredentialID: source.CredentialID, ActorLabel: source.ActorLabel, Action: action, ProjectID: projectID, ResourceType: resourceType, ResourceID: resourceID, ResultIds: []byte(`[]`), RequestID: source.RequestID, TraceID: source.TraceID}) return err } + +// recordDeploymentMutation audits a deployment-wide write, which has no Project +// or tenant, in the business transaction. +func recordDeploymentMutation(ctx context.Context, q *sqlc.Queries, action, resourceType, resourceID string) error { + source, ok := adminaudit.FromContext(ctx) + if !ok || source.ProjectID != "" || !auditText(source.CredentialID, 64, true) || !auditText(source.ActorLabel, 128, false) || !auditText(source.RequestID, 128, true) || !auditText(source.TraceID, 128, true) || !auditText(action, 64, true) || !auditText(resourceType, 64, true) || !auditText(resourceID, 256, true) { + return fmt.Errorf("%w: invalid administrator audit source", ErrInvalidInput) + } + _, err := q.InsertAdminAudit(ctx, sqlc.InsertAdminAuditParams{ID: pgtype.UUID{Bytes: uuid.New(), Valid: true}, AdminCredentialID: source.CredentialID, ActorLabel: source.ActorLabel, Action: action, ResourceType: resourceType, ResourceID: resourceID, ResultIds: []byte(`[]`), RequestID: source.RequestID, TraceID: source.TraceID}) + return err +} diff --git a/services/agents-api/internal/store/admin_history.go b/services/agents-api/internal/store/admin_history.go index 102e4512d..fb6545a5a 100644 --- a/services/agents-api/internal/store/admin_history.go +++ b/services/agents-api/internal/store/admin_history.go @@ -20,13 +20,16 @@ type AdminAuditFilter struct { CreatedAfter, CreatedBefore *time.Time Limit int } + +// AdminAuditOperation is one administrator write. ProjectID is null for +// deployment-wide writes, such as deployment default model providers. type AdminAuditOperation struct { ID string `json:"id"` CreatedAt time.Time `json:"created_at"` AdminCredentialID string `json:"admin_credential_id"` ActorLabel string `json:"actor_label"` Action string `json:"action"` - ProjectID string `json:"project_id"` + ProjectID *string `json:"project_id" extensions:"x-nullable"` ResourceType string `json:"resource_type"` ResourceID string `json:"resource_id"` ResultIDs json.RawMessage `json:"result_ids" swaggertype:"array,object"` @@ -89,7 +92,7 @@ func (s *Store) ListAdminAudit(ctx context.Context, filter AdminAuditFilter) (Ad rows = rows[:filter.Limit] } for _, row := range rows { - page.Data = append(page.Data, AdminAuditOperation{ID: uuid.UUID(row.ID.Bytes).String(), CreatedAt: row.CreatedAt.Time, AdminCredentialID: row.AdminCredentialID, ActorLabel: row.ActorLabel, Action: row.Action, ProjectID: uuid.UUID(row.ProjectID.Bytes).String(), ResourceType: row.ResourceType, ResourceID: row.ResourceID, ResultIDs: row.ResultIds, RequestID: row.RequestID, TraceID: row.TraceID}) + page.Data = append(page.Data, AdminAuditOperation{ID: uuid.UUID(row.ID.Bytes).String(), CreatedAt: row.CreatedAt.Time, AdminCredentialID: row.AdminCredentialID, ActorLabel: row.ActorLabel, Action: row.Action, ProjectID: auditProjectID(row.ProjectID), ResourceType: row.ResourceType, ResourceID: row.ResourceID, ResultIDs: row.ResultIds, RequestID: row.RequestID, TraceID: row.TraceID}) } if page.HasMore { raw, _ := json.Marshal(writeAuditCursor{ID: page.Data[len(page.Data)-1].ID, Scope: scope}) @@ -97,3 +100,11 @@ func (s *Store) ListAdminAudit(ctx context.Context, filter AdminAuditFilter) (Ad } return page, nil } + +func auditProjectID(id pgtype.UUID) *string { + if !id.Valid { + return nil + } + value := uuid.UUID(id.Bytes).String() + return &value +} diff --git a/services/agents-api/internal/store/agent_execution_defaults_http_test.go b/services/agents-api/internal/store/agent_execution_defaults_http_test.go index 19c23f2c1..df7a70eb4 100644 --- a/services/agents-api/internal/store/agent_execution_defaults_http_test.go +++ b/services/agents-api/internal/store/agent_execution_defaults_http_test.go @@ -27,10 +27,10 @@ func TestAgentExecutionDefaultsPublicSnapshotAndPrecedence(t *testing.T) { } deployment := &v1.ModelProviderInput{Protocol: "responses", BaseURL: "https://deployment.example/v1", APIKey: "deployment-canary"} defaultsCalls := 0 - handler, err := api.NewHandler(st, auth, "codex", api.WithHarnesses([]string{"codex", "claude_sdk", "mcode"}), api.WithHostedEnvironments(), api.WithExecution(st), api.WithModelProviderDefaults(func(context.Context, string, string) (*v1.ModelProviderInput, map[string]any, error) { + handler, err := api.NewHandler(st, auth, "codex", api.WithHarnesses([]string{"codex", "claude_sdk", "mcode"}), api.WithHostedEnvironments(), api.WithExecution(st), api.WithModelProviderDefaults(func(context.Context, string) (*v1.ModelProviderInput, error) { defaultsCalls++ copy := *deployment - return ©, nil, nil + return ©, nil })) if err != nil { t.Fatal(err) @@ -101,7 +101,6 @@ func TestAgentExecutionDefaultsPublicSnapshotAndPrecedence(t *testing.T) { strings.Replace(replacement, `"protocol":"responses"`, `"protocol":"anthropic"`, 1), strings.Replace(modelOnly, `"model":"model-override"`, `"model":"model-override","x_agents_core":{"harness":"claude_sdk"}`, 1), strings.TrimSuffix(strings.Replace(body, `"type":"openai_hosted"`, `"type":"none"`, 1), "}") + `,"input":"test"}`, - strings.Replace(body, `"type":"openai_hosted"`, `"type":"self_hosted","workspace_directory":"/workspace"`, 1), } { call("POST", "/v1/agents/sessions", raw, uuid.NewString(), 400) } diff --git a/services/agents-api/internal/store/creation_stream_settlement_public_test.go b/services/agents-api/internal/store/creation_stream_settlement_public_test.go index eebe95ce5..8f6ab0ca3 100644 --- a/services/agents-api/internal/store/creation_stream_settlement_public_test.go +++ b/services/agents-api/internal/store/creation_stream_settlement_public_test.go @@ -118,7 +118,7 @@ func (s sseLines) open(t *testing.T) { // creation stream whose initial reservation is cancelled without a Session event // ends through the committed projection, while GET stays open. func TestCreationStreamPublicLifetimes(t *testing.T) { - s, pool := store.NewTestStore(t) + s, pool := store.NewModelTestStore(t) tenant, token := uuid.NewString(), uuid.NewString() auth, err := newTestAuthenticator([]testAPIKey{{OrganizationID: "test-org", ProjectID: tenant, SubjectKind: "service_account", SubjectID: "test-runner", TokenSHA256: device.HashCredential(token), TenantID: tenant}}) if err != nil { @@ -174,8 +174,8 @@ func TestCreationStreamPublicLifetimes(t *testing.T) { } return event } - const idle = `{"agent":{"model":"test-model"},"environment":{"type":"self_hosted","workspace_directory":"/workspace"},"stream":true}` - const initial = `{"agent":{"model":"test-model"},"environment":{"type":"self_hosted","workspace_directory":"/workspace"},"input":"initial","stream":true}` + idle := `{"agent":{"model":"test-model"},"environment":{"type":"self_hosted","workspace_directory":"/workspace"},"stream":true,` + fixtureSessionProvider("codex") + `}` + initial := `{"agent":{"model":"test-model"},"environment":{"type":"self_hosted","workspace_directory":"/workspace"},"input":"initial","stream":true,` + fixtureSessionProvider("codex") + `}` created := openStream(t, server, token, http.MethodPost, "/v1/agents/sessions", idle, "no-input") defer created.stop() diff --git a/services/agents-api/internal/store/deployment_model_providers.go b/services/agents-api/internal/store/deployment_model_providers.go new file mode 100644 index 000000000..582048fed --- /dev/null +++ b/services/agents-api/internal/store/deployment_model_providers.go @@ -0,0 +1,108 @@ +package store + +import ( + "context" + "encoding/json" + "errors" + "fmt" + "time" + + v1 "github.com/MiniMax-AI-Dev/parsar/contracts/agents-api/v1" + "github.com/MiniMax-AI-Dev/parsar/services/agents-api/internal/db/sqlc" + "github.com/jackc/pgx/v5" +) + +// ErrModelProviderRequired reports a hosted or self-hosted Session that has no +// frozen model provider and therefore cannot run. +var ErrModelProviderRequired = errors.New("the Session has no model provider") + +// DeploymentModelProvider is the safe view of one harness's deployment default. +// The API key is write-only; only its presence is reported. +type DeploymentModelProvider struct { + Harness string + Provider v1.ModelProviderView + UpdatedAt time.Time +} + +func deploymentModelProvider(harness, protocol, baseURL string, contextWindow, maxOutputTokens int32, updatedAt time.Time) DeploymentModelProvider { + return DeploymentModelProvider{Harness: harness, UpdatedAt: updatedAt, Provider: v1.ModelProviderView{ + Protocol: protocol, BaseURL: baseURL, ContextWindow: contextWindow, MaxOutputTokens: maxOutputTokens, APIKeyConfigured: true, + }} +} + +// ListDeploymentModelProviders reads the configured defaults without decryption. +func (s *Store) ListDeploymentModelProviders(ctx context.Context) ([]DeploymentModelProvider, error) { + rows, err := s.queries.ListDeploymentModelProviders(ctx) + if err != nil { + return nil, err + } + result := make([]DeploymentModelProvider, 0, len(rows)) + for _, row := range rows { + result = append(result, deploymentModelProvider(row.Harness, row.Protocol, row.BaseUrl, row.ContextWindow, row.MaxOutputTokens, row.UpdatedAt.Time)) + } + return result, nil +} + +// SetDeploymentModelProvider replaces a harness's complete default bundle and +// audits the write in the same transaction, without the key. +func (s *Store) SetDeploymentModelProvider(ctx context.Context, harness string, provider v1.ModelProviderInput) (DeploymentModelProvider, error) { + if err := provider.ValidateHarness(harness); err != nil { + return DeploymentModelProvider{}, fmt.Errorf("%w: %s", ErrInvalidInput, err) + } + raw, err := json.Marshal(provider) + if err != nil { + return DeploymentModelProvider{}, err + } + encrypted, err := s.credentialCipher.SealDeploymentModelProvider(raw, harness) + if err != nil { + return DeploymentModelProvider{}, ErrCredentialStorageUnavailable + } + var result DeploymentModelProvider + err = pgx.BeginFunc(ctx, s.pool, func(tx pgx.Tx) error { + q := s.queries.WithTx(tx) + row, err := q.UpsertDeploymentModelProvider(ctx, sqlc.UpsertDeploymentModelProviderParams{ + Harness: harness, Protocol: provider.Protocol, BaseUrl: provider.BaseURL, + ContextWindow: provider.ContextWindow, MaxOutputTokens: provider.MaxOutputTokens, EncryptedConfig: encrypted, + }) + if err != nil { + return err + } + result = deploymentModelProvider(row.Harness, row.Protocol, row.BaseUrl, row.ContextWindow, row.MaxOutputTokens, row.UpdatedAt.Time) + return recordDeploymentMutation(ctx, q, "set", "deployment_model_provider", harness) + }) + return result, err +} + +// DeleteDeploymentModelProvider is idempotent; each successful call is audited. +// Sessions that already froze the default keep their snapshot. +func (s *Store) DeleteDeploymentModelProvider(ctx context.Context, harness string) error { + return pgx.BeginFunc(ctx, s.pool, func(tx pgx.Tx) error { + q := s.queries.WithTx(tx) + if _, err := q.DeleteDeploymentModelProvider(ctx, harness); err != nil { + return err + } + return recordDeploymentMutation(ctx, q, "delete", "deployment_model_provider", harness) + }) +} + +// DeploymentModelProvider decrypts a harness's default for Session creation. It +// returns nil when none is configured and fails closed when the bundle cannot +// be decrypted. +func (s *Store) DeploymentModelProvider(ctx context.Context, harness string) (*v1.ModelProviderInput, error) { + ciphertext, err := s.queries.GetDeploymentModelProviderSecret(ctx, harness) + if errors.Is(err, pgx.ErrNoRows) { + return nil, nil + } + if err != nil { + return nil, err + } + raw, err := s.credentialCipher.OpenDeploymentModelProvider(ciphertext, harness) + if err != nil { + return nil, ErrCredentialStorageUnavailable + } + var provider v1.ModelProviderInput + if json.Unmarshal(raw, &provider) != nil || provider.ValidateHarness(harness) != nil { + return nil, ErrCredentialStorageUnavailable + } + return &provider, nil +} diff --git a/services/agents-api/internal/store/deployment_model_providers_http_test.go b/services/agents-api/internal/store/deployment_model_providers_http_test.go new file mode 100644 index 000000000..fbbe0c24f --- /dev/null +++ b/services/agents-api/internal/store/deployment_model_providers_http_test.go @@ -0,0 +1,190 @@ +package store_test + +import ( + "bytes" + "context" + "encoding/json" + "errors" + "net/http/httptest" + "strings" + "testing" + + "github.com/MiniMax-AI-Dev/parsar/internal/agentdaemon/device" + "github.com/MiniMax-AI-Dev/parsar/services/agents-api/internal/api" + "github.com/MiniMax-AI-Dev/parsar/services/agents-api/internal/credentialcrypto" + "github.com/MiniMax-AI-Dev/parsar/services/agents-api/internal/execution" + "github.com/MiniMax-AI-Dev/parsar/services/agents-api/internal/store" + "github.com/google/uuid" +) + +// Deployment defaults live in Core, are managed with the Core key and are +// frozen only into hosted Sessions; self-hosted Sessions bring their own +// provider, and a Session with no provider is rejected before any write. +func TestDeploymentModelProvidersHTTP(t *testing.T) { + _, pool := store.NewTestStore(t) + if _, err := pool.Exec(t.Context(), "DELETE FROM deployment_model_providers"); err != nil { + t.Fatal(err) + } + cipher, _ := credentialcrypto.New(bytes.Repeat([]byte{53}, 32)) + st := store.NewWithCredentialCipher(pool, cipher) + tenant, projectKey, coreKey := uuid.NewString(), uuid.NewString(), uuid.NewString() + auth, err := newTestAuthenticator([]testAPIKey{{OrganizationID: "test-org", ProjectID: uuid.NewString(), SubjectKind: "service_account", SubjectID: "defaults-http", TokenSHA256: device.HashCredential(projectKey), TenantID: tenant}}) + if err != nil { + t.Fatal(err) + } + admin, err := api.NewDeploymentAuthenticator([]string{device.HashCredential(coreKey)}) + if err != nil { + t.Fatal(err) + } + handler, err := api.NewHandler(st, auth, "codex", api.WithProjectAPIKeys(st, admin), api.WithHarnesses([]string{"codex", "mcode"}), + api.WithHostedEnvironments(), api.WithExecution(st), api.WithEnvironmentRemoteURL("wss://core.example/api/v1/agent-daemon/ws"), + api.WithModelProviderDefaults(st.DeploymentModelProvider)) + if err != nil { + t.Fatal(err) + } + call := func(method, path, key, body string, status int) map[string]json.RawMessage { + t.Helper() + r := httptest.NewRequest(method, path, strings.NewReader(body)) + r.Header.Set("Authorization", "Bearer "+key) + r.Header.Set("OpenAI-Beta", "agents=v1") + r.Header.Set("Content-Type", "application/json") + w := httptest.NewRecorder() + handler.ServeHTTP(w, r) + for _, secret := range []string{"deployment-canary", "session-canary", "agent-canary", "invalid-canary", `"api_key":`} { + if strings.Contains(w.Body.String(), secret) { + t.Fatalf("%s %s returned a key", method, path) + } + } + if w.Code != status { + t.Fatalf("%s %s: status %d, expected %d: %s", method, path, w.Code, status, w.Body) + } + var result map[string]json.RawMessage + _ = json.Unmarshal(w.Body.Bytes(), &result) + return result + } + text := func(raw json.RawMessage) string { + var value string + _ = json.Unmarshal(raw, &value) + return value + } + providerOf := func(sessionID string) string { + t.Helper() + provider, err := st.SessionModelExecution(t.Context(), tenant, sessionID) + if err != nil { + t.Fatal(err) + } + return provider.APIKey + } + const path = "/core/v1/harnesses/codex/model-provider" + codexDefault := `{"protocol":"responses","base_url":"https://deployment.example/v1","api_key":"deployment-canary"}` + + // Only the Core key manages defaults. + call("GET", "/core/v1/harnesses", projectKey, "", 401) + call("PUT", path, projectKey, codexDefault, 401) + list := call("GET", "/core/v1/harnesses", coreKey, "", 200) + if string(list["object"]) != `"list"` || !strings.Contains(string(list["data"]), `{"object":"core.harness","id":"claude_sdk","enabled":false,"default":false,"model_provider":null}`) || + !strings.Contains(string(list["data"]), `{"object":"core.harness","id":"codex","enabled":true,"default":true,"model_provider":null}`) { + t.Fatalf("unexpected harness list: %s", list["data"]) + } + call("GET", path, coreKey, "", 404) + call("PUT", "/core/v1/harnesses/other/model-provider", coreKey, codexDefault, 404) + for _, invalid := range []string{ + `{"protocol":"responses","base_url":"http://deployment.example/v1","api_key":"invalid-canary"}`, + `{"protocol":"anthropic","base_url":"https://deployment.example/v1","api_key":"invalid-canary"}`, + `{"protocol":"responses","base_url":"https://deployment.example/v1"}`, + `{"protocol":"responses","base_url":"https://deployment.example/v1","api_key":"invalid-canary","api_key_configured":true}`, + `{"protocol":"responses","base_url":"https://deployment.example/v1","api_key":"invalid-canary","context_window":-1}`, + } { + call("PUT", path, coreKey, invalid, 400) + } + call("PUT", "/core/v1/harnesses/mcode/model-provider", coreKey, `{"protocol":"anthropic","base_url":"https://deployment.example/anthropic","api_key":"invalid-canary"}`, 400) + + // Without any provider, hosted and self-hosted creation fail before any write. + hosted := `{"agent":{"model":"hosted-model"},"environment":{"type":"openai_hosted"}}` + selfHosted := `{"agent":{"model":"self-hosted-model"},"environment":{"type":"self_hosted","workspace_directory":"/workspace"}}` + for _, body := range []string{hosted, selfHosted} { + failure := call("POST", "/v1/agents/sessions", projectKey, body, 400) + if !strings.Contains(string(failure["error"]), `"code":"model_provider_required","param":"x_agents_core.model_provider"`) { + t.Fatalf("unclear failure: %s", failure["error"]) + } + } + + saved := call("PUT", path, coreKey, codexDefault, 200) + if text(saved["object"]) != "core.model_provider" || text(saved["harness"]) != "codex" || text(saved["base_url"]) != "https://deployment.example/v1" || string(saved["api_key_configured"]) != "true" || text(saved["updated_at"]) == "" { + t.Fatalf("unexpected provider view: %v", saved) + } + if retrieved := call("GET", path, coreKey, "", 200); text(retrieved["base_url"]) != "https://deployment.example/v1" { + t.Fatal("retrieved view differs") + } + + // Hosted Sessions freeze the default; later edits never reach them. + hostedID := text(call("POST", "/v1/agents/sessions", projectKey, hosted, 201)["id"]) + if providerOf(hostedID) != "deployment-canary" { + t.Fatal("hosted Session did not freeze the deployment default") + } + projection, err := st.GetSessionExecutionConfiguration(t.Context(), tenant, hostedID) + if err != nil || projection.ModelProvider.Source != "deployment" || projection.ModelProvider.Status != "available" || projection.ModelProvider.Configuration == nil || projection.ModelProvider.Configuration.BaseURL != "https://deployment.example/v1" { + t.Fatal("deployment selection not recorded", projection, err) + } + call("PUT", path, coreKey, strings.Replace(codexDefault, "deployment.example", "changed.example", 1), 200) + if providerOf(hostedID) != "deployment-canary" { + t.Fatal("a changed default reached an existing Session") + } + + // Self-hosted Sessions take the request or saved Agent bundle, never the default. + failure := call("POST", "/v1/agents/sessions", projectKey, selfHosted, 400) + if !strings.Contains(string(failure["error"]), "apply only to openai_hosted") { + t.Fatalf("self-hosted Session used or misreported the deployment default: %s", failure["error"]) + } + requestProvider := strings.TrimSuffix(selfHosted, "}") + `,"x_agents_core":{"model_provider":{"protocol":"responses","base_url":"https://session.example/v1","api_key":"session-canary"}}}` + if id := text(call("POST", "/v1/agents/sessions", projectKey, requestProvider, 201)["id"]); providerOf(id) != "session-canary" { + t.Fatal("self-hosted Session lost its request provider") + } + agentID := text(call("POST", "/v1/agents", projectKey, `{"model":"agent-model","x_agents_core":{"model_provider":{"protocol":"responses","base_url":"https://agent.example/v1","api_key":"agent-canary"}}}`, 201)["id"]) + fromAgent := `{"agent_id":"` + agentID + `","environment":{"type":"self_hosted","workspace_directory":"/workspace"}}` + if id := text(call("POST", "/v1/agents/sessions", projectKey, fromAgent, 201)["id"]); providerOf(id) != "agent-canary" { + t.Fatal("self-hosted Session lost its saved Agent provider") + } + + // Removal is idempotent and audited; hosted creation then fails fast again. + call("DELETE", path, coreKey, "", 204) + call("DELETE", path, coreKey, "", 204) + call("GET", path, coreKey, "", 404) + call("POST", "/v1/agents/sessions", projectKey, hosted, 400) + if providerOf(hostedID) != "deployment-canary" { + t.Fatal("removing the default changed an existing Session") + } + page, err := st.ListAdminAudit(t.Context(), store.AdminAuditFilter{ResourceType: "deployment_model_provider", ResourceID: "codex"}) + if err != nil || len(page.Data) < 4 || page.Data[0].Action != "delete" || page.Data[0].ProjectID != nil { + t.Fatal("deployment writes not audited", page, err) + } +} + +// A hosted or self-hosted Session created before providers were required has +// no frozen provider: new work is rejected before anything is queued. +func TestLegacySessionWithoutProviderCannotStartWork(t *testing.T) { + h := newDispatchHarnessForSession(t, []byte(`{"agent":{"model":"test-model"},"environment":{"type":"self_hosted","workspace_directory":"/workspace"}}`), true) + legacy, err := h.s.CreateSession(t.Context(), h.tenant, store.CreateSessionInput{Creator: store.FixtureCreator(), Engine: "codex", IdempotencyKey: uuid.NewString(), + Configuration: []byte(`{"agent":{"model":"test-model"},"environment":{"type":"self_hosted","workspace_directory":"/workspace"}}`)}) + if err != nil { + t.Fatal(err) + } + worker, err := execution.StartWorker(t.Context(), h.d) + if err != nil { + t.Fatal(err) + } + t.Cleanup(func() { + ctx, cancel := context.WithCancel(context.Background()) + cancel() + _ = worker.Run(ctx) + }) + message := []store.Input{{Kind: "message", Payload: json.RawMessage(`{"text":"start"}`)}} + if _, err := worker.SubmitInputs(t.Context(), h.tenant, legacy.ID, uuid.NewString(), message); !errors.Is(err, store.ErrModelProviderRequired) { + t.Fatal("provider-free Session accepted work", err) + } + _, pool := store.NewTestStore(t) + var reservations int + if err := pool.QueryRow(t.Context(), "SELECT count(*) FROM environment_input_reservations WHERE session_id=$1", legacy.ID).Scan(&reservations); err != nil || reservations != 0 { + t.Fatal("rejected work was queued", reservations, err) + } +} diff --git a/services/agents-api/internal/store/deployment_model_providers_test.go b/services/agents-api/internal/store/deployment_model_providers_test.go new file mode 100644 index 000000000..7feaca9bd --- /dev/null +++ b/services/agents-api/internal/store/deployment_model_providers_test.go @@ -0,0 +1,90 @@ +package store + +import ( + "bytes" + "errors" + "testing" + "time" + + v1 "github.com/MiniMax-AI-Dev/parsar/contracts/agents-api/v1" + "github.com/MiniMax-AI-Dev/parsar/services/agents-api/internal/adminaudit" + "github.com/MiniMax-AI-Dev/parsar/services/agents-api/internal/credentialcrypto" +) + +func TestDeploymentModelProviderEncryptedAuditedAndReplaced(t *testing.T) { + _, pool := testStore(t) + cipher, err := credentialcrypto.New(bytes.Repeat([]byte{47}, 32)) + if err != nil { + t.Fatal(err) + } + s := NewWithCredentialCipher(pool, cipher) + // The table is deployment-wide; start from no defaults. + if _, err := pool.Exec(t.Context(), "DELETE FROM deployment_model_providers"); err != nil { + t.Fatal(err) + } + started := time.Now() + ctx := adminaudit.WithSource(t.Context(), adminaudit.Source{CredentialID: "abcd1234", ActorLabel: "console", RequestID: "request-set", TraceID: "trace-set"}) + provider := v1.ModelProviderInput{Protocol: "responses", BaseURL: "https://deployment.example/v1", APIKey: "deployment-key-canary"} + if _, err := s.SetDeploymentModelProvider(ctx, "codex", v1.ModelProviderInput{Protocol: "anthropic", BaseURL: provider.BaseURL, APIKey: "k"}); !errors.Is(err, ErrInvalidInput) { + t.Fatal("protocol unsupported by the harness accepted", err) + } + if _, err := New(pool).SetDeploymentModelProvider(ctx, "codex", provider); !errors.Is(err, ErrCredentialStorageUnavailable) { + t.Fatal("key stored without encryption", err) + } + saved, err := s.SetDeploymentModelProvider(ctx, "codex", provider) + if err != nil || saved.Harness != "codex" || saved.Provider != *provider.SafeView() || saved.UpdatedAt.IsZero() { + t.Fatal("default not saved", saved, err) + } + var row []byte + if err := pool.QueryRow(ctx, "SELECT to_jsonb(p)::text FROM deployment_model_providers p WHERE harness='codex'").Scan(&row); err != nil || bytes.Contains(row, []byte("deployment-key-canary")) { + t.Fatal("key stored in plaintext", err) + } + listed, err := New(pool).ListDeploymentModelProviders(ctx) + if err != nil || len(listed) != 1 || listed[0].Provider != *provider.SafeView() { + t.Fatal("reader without the key could not list safe fields", listed, err) + } + if got, err := s.DeploymentModelProvider(ctx, "codex"); err != nil || got == nil || *got != provider { + t.Fatal("default did not decrypt", err) + } + if got, err := s.DeploymentModelProvider(ctx, "mcode"); err != nil || got != nil { + t.Fatal("unset harness returned a default", err) + } + other, _ := credentialcrypto.New(bytes.Repeat([]byte{48}, 32)) + if _, err := NewWithCredentialCipher(pool, other).DeploymentModelProvider(ctx, "codex"); !errors.Is(err, ErrCredentialStorageUnavailable) { + t.Fatal("wrong encryption key did not fail closed", err) + } + replacement := v1.ModelProviderInput{Protocol: "responses", BaseURL: "https://replacement.example/v1", APIKey: "replacement-key"} + if _, err := s.SetDeploymentModelProvider(ctx, "codex", replacement); err != nil { + t.Fatal(err) + } + if got, err := s.DeploymentModelProvider(ctx, "codex"); err != nil || *got != replacement { + t.Fatal("replacement not complete", err) + } + deleteCtx := adminaudit.WithSource(t.Context(), adminaudit.Source{CredentialID: "abcd1234", RequestID: "request-delete", TraceID: "trace-delete"}) + for range 2 { + if err := s.DeleteDeploymentModelProvider(deleteCtx, "codex"); err != nil { + t.Fatal("delete is not idempotent", err) + } + } + if got, err := s.DeploymentModelProvider(ctx, "codex"); err != nil || got != nil { + t.Fatal("deleted default remained", err) + } + page, err := s.ListAdminAudit(ctx, AdminAuditFilter{ResourceType: "deployment_model_provider", CreatedAfter: &started}) + if err != nil || len(page.Data) != 4 { + t.Fatal("deployment writes not audited", len(page.Data), err) + } + for _, entry := range page.Data { + if entry.ProjectID != nil || entry.ResourceID != "codex" || (entry.Action != "set" && entry.Action != "delete") { + t.Fatal("unexpected deployment audit entry", entry) + } + } + if scoped, err := s.ListAdminAudit(ctx, AdminAuditFilter{ProjectID: "00000000-0000-4000-8000-000000000001"}); err != nil || len(scoped.Data) != 0 { + t.Fatal("Project filter returned deployment entries", err) + } + if _, err := s.SetDeploymentModelProvider(adminaudit.WithSource(t.Context(), adminaudit.Source{CredentialID: "abcd1234", RequestID: "r", TraceID: "t", ProjectID: "00000000-0000-4000-8000-000000000001"}), "codex", provider); !errors.Is(err, ErrInvalidInput) { + t.Fatal("Project-scoped audit source accepted for a deployment write", err) + } + if got, _ := s.DeploymentModelProvider(ctx, "codex"); got != nil { + t.Fatal("failed audit left the write committed") + } +} diff --git a/services/agents-api/internal/store/dispatch_test.go b/services/agents-api/internal/store/dispatch_test.go index 259712a6f..ed371e923 100644 --- a/services/agents-api/internal/store/dispatch_test.go +++ b/services/agents-api/internal/store/dispatch_test.go @@ -42,11 +42,11 @@ func newDispatchHarness(t *testing.T) *dispatchHarness { func newDispatchHarnessForSession(t *testing.T, configuration []byte, local bool) *dispatchHarness { t.Helper() - s, _ := store.NewTestStore(t) + s, _ := store.NewModelTestStore(t) h := &dispatchHarness{t: t, s: s, tenant: uuid.NewString(), environments: map[string]*dispatchHarness{}} ctx := context.Background() var err error - h.session, err = s.CreateSession(ctx, h.tenant, store.CreateSessionInput{Creator: store.FixtureCreator(), Engine: "codex", IdempotencyKey: "session", Configuration: configuration}) + h.session, err = s.CreateSession(ctx, h.tenant, store.WithFixtureModelProvider(store.CreateSessionInput{Creator: store.FixtureCreator(), Engine: "codex", IdempotencyKey: "session", Configuration: configuration})) if err != nil { t.Fatal(err) } diff --git a/services/agents-api/internal/store/environment_admission_test.go b/services/agents-api/internal/store/environment_admission_test.go index f1ea665fe..a3f2a37da 100644 --- a/services/agents-api/internal/store/environment_admission_test.go +++ b/services/agents-api/internal/store/environment_admission_test.go @@ -32,10 +32,10 @@ func newEnvironmentAdmission(t *testing.T) (*dispatchHarness, *execution.Worker) cancel() _ = worker.Run(ctx) }) - h.session, err = worker.CreateSession(t.Context(), h.tenant, store.CreateSessionInput{ + h.session, err = worker.CreateSession(t.Context(), h.tenant, store.WithFixtureModelProvider(store.CreateSessionInput{ Creator: store.FixtureCreator(), Engine: "codex", IdempotencyKey: uuid.NewString(), Configuration: json.RawMessage(`{"agent":{"model":"test-model"},"environment":{"type":"self_hosted","workspace_directory":"/workspace"}}`), - }) + })) if err != nil { t.Fatal(err) } diff --git a/services/agents-api/internal/store/environment_retrieve_public_test.go b/services/agents-api/internal/store/environment_retrieve_public_test.go index 283d56830..7eb2c98c1 100644 --- a/services/agents-api/internal/store/environment_retrieve_public_test.go +++ b/services/agents-api/internal/store/environment_retrieve_public_test.go @@ -23,7 +23,7 @@ func TestEnvironmentRetrievalOfficialClient(t *testing.T) { if python == "" { t.Skip("pinned official Python SDK required") } - s, pool := store.NewTestStore(t) + s, pool := store.NewModelTestStore(t) tenant, foreignTenant := uuid.NewString(), uuid.NewString() principal := store.FixtureExecutorPrincipal(t, s, tenant) token, peer, foreign := uuid.NewString(), uuid.NewString(), uuid.NewString() @@ -90,7 +90,7 @@ func TestEnvironmentRetrievalOfficialClient(t *testing.T) { revoked = true server.Close() pool.Close() - reopened, reopenedPool := store.NewTestStore(t) + reopened, reopenedPool := store.NewModelTestStore(t) handler, err = api.NewHandler(reopened, auth, "codex") if err != nil { t.Fatal(err) diff --git a/services/agents-api/internal/store/environment_worker_helpers_test.go b/services/agents-api/internal/store/environment_worker_helpers_test.go index f107c9cf6..3687d4637 100644 --- a/services/agents-api/internal/store/environment_worker_helpers_test.go +++ b/services/agents-api/internal/store/environment_worker_helpers_test.go @@ -42,7 +42,7 @@ func workerEnvironmentReservation(t *testing.T, h *dispatchHarness) store.Enviro func unboundWorkerEnvironmentReservation(t *testing.T, h *dispatchHarness) store.EnvironmentInputReservation { t.Helper() - session, err := h.s.CreateSession(t.Context(), h.tenant, store.CreateSessionInput{Creator: store.FixtureCreator(), Engine: "codex", IdempotencyKey: uuid.NewString(), Configuration: json.RawMessage(`{"agent":{"model":"test-model"},"environment":{"type":"self_hosted","workspace_directory":"/workspace"}}`)}) + session, err := h.s.CreateSession(t.Context(), h.tenant, store.WithFixtureModelProvider(store.CreateSessionInput{Creator: store.FixtureCreator(), Engine: "codex", IdempotencyKey: uuid.NewString(), Configuration: json.RawMessage(`{"agent":{"model":"test-model"},"environment":{"type":"self_hosted","workspace_directory":"/workspace"}}`)})) if err != nil { t.Fatal(err) } diff --git a/services/agents-api/internal/store/export_test.go b/services/agents-api/internal/store/export_test.go index 97b98fcea..08a6df914 100644 --- a/services/agents-api/internal/store/export_test.go +++ b/services/agents-api/internal/store/export_test.go @@ -1,8 +1,12 @@ package store import ( + "bytes" "context" + "encoding/json" + v1 "github.com/MiniMax-AI-Dev/parsar/contracts/agents-api/v1" + "github.com/MiniMax-AI-Dev/parsar/services/agents-api/internal/credentialcrypto" "github.com/MiniMax-AI-Dev/parsar/services/agents-api/internal/identity" "github.com/jackc/pgx/v5/pgxpool" "testing" @@ -10,6 +14,49 @@ import ( func NewTestStore(t *testing.T) (*Store, *pgxpool.Pool) { return testStore(t) } +var fixtureCipher, _ = credentialcrypto.New(bytes.Repeat([]byte{61}, 32)) + +// FixtureCipher is the credential key of NewModelTestStore, for reopened stores. +func FixtureCipher() *credentialcrypto.Cipher { return fixtureCipher } + +// NewModelTestStore is NewTestStore with a fixture credential key, so Sessions +// can freeze a model provider. +func NewModelTestStore(t *testing.T) (*Store, *pgxpool.Pool) { + _, pool := testStore(t) + return NewWithCredentialCipher(pool, fixtureCipher), pool +} + +// FixtureModelProvider is a valid bundle for the harness. Hosted and self-hosted +// Sessions cannot run without one, so fixtures supply it instead of relaxing +// that check. +func FixtureModelProvider(harness string) *v1.ModelProviderInput { + if harness == "codex" { + return &v1.ModelProviderInput{Protocol: "responses", BaseURL: "https://model.fixture.example/v1", APIKey: "fixture-model-key"} + } + return &v1.ModelProviderInput{Protocol: "anthropic", BaseURL: "https://model.fixture.example/anthropic", APIKey: "fixture-model-key", ContextWindow: 200000, MaxOutputTokens: 8000} +} + +// WithFixtureModelProvider adds the fixture provider, as a Session-supplied +// bundle, to a hosted or self-hosted creation that has none. The store must +// have a credential key; other inputs are returned unchanged. +func WithFixtureModelProvider(input CreateSessionInput) CreateSessionInput { + var configuration struct { + Environment struct { + Type string `json:"type"` + } `json:"environment"` + } + if input.ModelProvider != nil || json.Unmarshal(input.Configuration, &configuration) != nil || !v1.ModelProviderRequired(configuration.Environment.Type) { + return input + } + input.ModelProvider, input.ModelProviderSource = FixtureModelProvider(input.Engine), v1.ModelProviderSourceSession + if input.ExecutionConfiguration != nil { + projection := *input.ExecutionConfiguration + projection.ModelProvider = v1.ExecutionProviderSelection{Source: "session", Status: "available", Configuration: input.ModelProvider.SafeView()} + input.ExecutionConfiguration = &projection + } + return input +} + // FixtureCreator is an explicit synthetic principal for newly created test Sessions. func FixtureCreator() identity.Subject { return identity.Subject{Kind: "service_account", ID: "test-runner"} diff --git a/services/agents-api/internal/store/hosted_initialization_failure_public_test.go b/services/agents-api/internal/store/hosted_initialization_failure_public_test.go index 5b4fc2dd8..26a1ea8d9 100644 --- a/services/agents-api/internal/store/hosted_initialization_failure_public_test.go +++ b/services/agents-api/internal/store/hosted_initialization_failure_public_test.go @@ -111,7 +111,7 @@ func hostedFailureSession(t *testing.T, s *store.Store, tenant string, input sto if input.Initialization.Env == nil { input.Initialization.Env = map[string]string{"SCAN_VALUE": hostedFailureCanary} } - session, err := s.CreateSession(t.Context(), tenant, input) + session, err := s.CreateSession(t.Context(), tenant, store.WithFixtureModelProvider(input)) if err != nil { t.Fatal(err) } diff --git a/services/agents-api/internal/store/initial_files_http_test.go b/services/agents-api/internal/store/initial_files_http_test.go index ac85cbb9c..30f5c1601 100644 --- a/services/agents-api/internal/store/initial_files_http_test.go +++ b/services/agents-api/internal/store/initial_files_http_test.go @@ -28,7 +28,7 @@ func TestInitialFilesHTTPInlineLimitsAndRetry(t *testing.T) { t.Fatal(err) } // Exercise HTTP parsing and durable storage without starting a Runtime. - handler, err := api.NewHandler(s, auth, "codex", api.WithHostedEnvironments(), api.WithExecution(s)) + handler, err := api.NewHandler(s, auth, "codex", api.WithHostedEnvironments(), api.WithExecution(s), fixtureDeploymentProvider()) if err != nil { t.Fatal(err) } diff --git a/services/agents-api/internal/store/managed_test_store_test.go b/services/agents-api/internal/store/managed_test_store_test.go index bfb2b8c7e..042c87b4d 100644 --- a/services/agents-api/internal/store/managed_test_store_test.go +++ b/services/agents-api/internal/store/managed_test_store_test.go @@ -49,7 +49,8 @@ func newManagedTestStore(t *testing.T) (*Store, *pgxpool.Pool) { t.Fatal(err) } t.Cleanup(pool.Close) - return New(pool), pool + // Hosted Sessions freeze a model provider, which needs a credential key. + return NewWithCredentialCipher(pool, fixtureCipher), pool } func NewManagedTestStore(t *testing.T) (*Store, *pgxpool.Pool) { diff --git a/services/agents-api/internal/store/model_provider_fixture_test.go b/services/agents-api/internal/store/model_provider_fixture_test.go new file mode 100644 index 000000000..e19d0e2aa --- /dev/null +++ b/services/agents-api/internal/store/model_provider_fixture_test.go @@ -0,0 +1,28 @@ +package store_test + +import ( + "context" + "encoding/json" + + v1 "github.com/MiniMax-AI-Dev/parsar/contracts/agents-api/v1" + "github.com/MiniMax-AI-Dev/parsar/services/agents-api/internal/api" + "github.com/MiniMax-AI-Dev/parsar/services/agents-api/internal/store" +) + +// Hosted and self-hosted Sessions must freeze a model provider. HTTP fixtures +// supply one here instead of relaxing that check; the store needs a credential +// key (store.NewModelTestStore). + +// fixtureDeploymentProvider configures a deployment default for every harness. +func fixtureDeploymentProvider() api.Option { + return api.WithModelProviderDefaults(func(_ context.Context, harness string) (*v1.ModelProviderInput, error) { + return store.FixtureModelProvider(harness), nil + }) +} + +// fixtureSessionProvider is the top-level Session request member that supplies +// the harness's fixture bundle, for self-hosted Sessions. +func fixtureSessionProvider(harness string) string { + raw, _ := json.Marshal(map[string]any{"model_provider": store.FixtureModelProvider(harness)}) + return `"x_agents_core":` + string(raw) +} diff --git a/services/agents-api/internal/store/remote_mcp_credentials_test.go b/services/agents-api/internal/store/remote_mcp_credentials_test.go index 4ef583fc7..0a03970dd 100644 --- a/services/agents-api/internal/store/remote_mcp_credentials_test.go +++ b/services/agents-api/internal/store/remote_mcp_credentials_test.go @@ -33,8 +33,9 @@ func TestSelfHostedServiceMCPRejectionDoesNotRequireCredentialDecryption(t *test "type": "mcp", "server_label": "tools", "connection_origin": "service", "credential_id": credential.ID, "transport": map[string]string{"type": "http", "server_url": "https://tools.example/mcp"}, }}}, - "environment": map[string]string{"type": "self_hosted", "workspace_directory": "/workspace"}, - "vault_ids": []string{vault.ID}, + "environment": map[string]string{"type": "self_hosted", "workspace_directory": "/workspace"}, + "vault_ids": []string{vault.ID}, + "x_agents_core": map[string]any{"model_provider": store.FixtureModelProvider("codex")}, } if initial { body["input"] = "must not execute" diff --git a/services/agents-api/internal/store/remote_mcp_test.go b/services/agents-api/internal/store/remote_mcp_test.go index b334c23cd..f070d0c95 100644 --- a/services/agents-api/internal/store/remote_mcp_test.go +++ b/services/agents-api/internal/store/remote_mcp_test.go @@ -26,7 +26,8 @@ func TestSelfHostedServiceMCPRejectedWithoutWrites(t *testing.T) { tool["credential_id"] = credential.ID } tool["required"] = strings.HasPrefix(mode, "required") - body := map[string]any{"agent": map[string]any{"model": "model", "tools": []any{tool}}, "environment": map[string]string{"type": "self_hosted", "workspace_directory": "/workspace"}, "vault_ids": []string{vault.ID}} + body := map[string]any{"agent": map[string]any{"model": "model", "tools": []any{tool}}, "environment": map[string]string{"type": "self_hosted", "workspace_directory": "/workspace"}, "vault_ids": []string{vault.ID}, + "x_agents_core": map[string]any{"model_provider": store.FixtureModelProvider("codex")}} switch mode { case "anonymous", "required anonymous", "unattached": body["vault_ids"] = []string{} diff --git a/services/agents-api/internal/store/runtime_lifecycle_test.go b/services/agents-api/internal/store/runtime_lifecycle_test.go index 648c14060..1134fa4b1 100644 --- a/services/agents-api/internal/store/runtime_lifecycle_test.go +++ b/services/agents-api/internal/store/runtime_lifecycle_test.go @@ -90,7 +90,7 @@ func managedWorkerMode(t *testing.T, s *store.Store, key string, p sandbox.Provi func managedSession(t *testing.T, s *store.Store) (string, store.Session, store.Environment) { t.Helper() tenant := uuid.NewString() - v, e := s.CreateSession(t.Context(), tenant, store.CreateSessionInput{Creator: store.FixtureCreator(), Engine: "codex", IdempotencyKey: uuid.NewString(), Configuration: json.RawMessage(`{"agent":{"model":"test"},"environment":{"type":"openai_hosted","network":{"access":"disabled"}}}`)}) + v, e := s.CreateSession(t.Context(), tenant, store.WithFixtureModelProvider(store.CreateSessionInput{Creator: store.FixtureCreator(), Engine: "codex", IdempotencyKey: uuid.NewString(), Configuration: json.RawMessage(`{"agent":{"model":"test"},"environment":{"type":"openai_hosted","network":{"access":"disabled"}}}`)})) if e != nil { t.Fatal(e) } diff --git a/services/agents-api/internal/store/self_hosted_cancel_public_test.go b/services/agents-api/internal/store/self_hosted_cancel_public_test.go index 3aa278ab7..4c7afa1eb 100644 --- a/services/agents-api/internal/store/self_hosted_cancel_public_test.go +++ b/services/agents-api/internal/store/self_hosted_cancel_public_test.go @@ -22,7 +22,7 @@ func TestSelfHostedCancellationOfficialClient(t *testing.T) { if python == "" { t.Skip("pinned official Python SDK required") } - s, pool := store.NewTestStore(t) + s, pool := store.NewModelTestStore(t) tenant, foreignTenant := uuid.NewString(), uuid.NewString() token, foreign := uuid.NewString(), uuid.NewString() auth, err := newTestAuthenticator([]testAPIKey{ @@ -176,7 +176,7 @@ func TestSelfHostedCancellationOfficialClient(t *testing.T) { stop(false) server.Close() pool.Close() - s, pool = store.NewTestStore(t) + s, pool = store.NewModelTestStore(t) server, stop = serve() settings["base"] = server.URL } diff --git a/services/agents-api/internal/store/self_hosted_initial_public_test.go b/services/agents-api/internal/store/self_hosted_initial_public_test.go index 02f767bb0..aaed1c45c 100644 --- a/services/agents-api/internal/store/self_hosted_initial_public_test.go +++ b/services/agents-api/internal/store/self_hosted_initial_public_test.go @@ -29,7 +29,7 @@ func TestSelfHostedInitialCreationOfficialClient(t *testing.T) { if python == "" { t.Skip("pinned official Python SDK required") } - s, pool := store.NewTestStore(t) + s, pool := store.NewModelTestStore(t) tenant, foreignTenant := uuid.NewString(), uuid.NewString() token, peer, foreign := uuid.NewString(), uuid.NewString(), uuid.NewString() auth, err := newTestAuthenticator([]testAPIKey{ @@ -144,7 +144,7 @@ func TestSelfHostedInitialCreationOfficialClient(t *testing.T) { stop(false) server.Close() pool.Close() - reopened, reopenedPool := store.NewTestStore(t) + reopened, reopenedPool := store.NewModelTestStore(t) worker, stop = publicInitialWorker(t, reopened) server = serve(reopened, worker) settings["base"], settings["accepted"] = server.URL, accepted diff --git a/services/agents-api/internal/store/session_execution_configuration.go b/services/agents-api/internal/store/session_execution_configuration.go index b93e0bdf3..593987609 100644 --- a/services/agents-api/internal/store/session_execution_configuration.go +++ b/services/agents-api/internal/store/session_execution_configuration.go @@ -32,10 +32,11 @@ func saveSessionExecutionConfiguration(ctx context.Context, q *sqlc.Queries, ses if provider == nil { return fmt.Errorf("%w: execution projection has no model provider", ErrInvalidInput) } - // Deployment endpoints and all native options remain private, even if an - // internal caller accidentally supplies a public provider view. - frozen.ModelProvider.Status = "redacted" - frozen.ModelProvider.Configuration = nil + // The deployment default is readable with the same Core key, so the + // safe view is recorded from the frozen bundle itself. Native options + // are never part of it. + frozen.ModelProvider.Status = "available" + frozen.ModelProvider.Configuration = provider.SafeView() case "session", "agent": if provider == nil || frozen.ModelProvider.Status != "available" || frozen.ModelProvider.Configuration == nil || *frozen.ModelProvider.Configuration != *provider.SafeView() { return fmt.Errorf("%w: execution projection does not match model provider", ErrInvalidInput) @@ -92,7 +93,8 @@ func normalizeExecutionProjection(projection *v1.SessionExecutionConfiguration, projection.Object = "agent.session.execution_configuration" projection.SchemaVersion = 1 projection.SessionID = sessionID - if projection.ModelProvider.Source == "deployment" { + if projection.ModelProvider.Source == "deployment" && (projection.ModelProvider.Status != "available" || projection.ModelProvider.Configuration == nil) { + // Sessions created before deployment defaults moved into Core stay redacted. projection.ModelProvider.Status = "redacted" projection.ModelProvider.Configuration = nil } else if projection.ModelProvider.Status != "available" { diff --git a/services/agents-api/internal/store/session_execution_configuration_test.go b/services/agents-api/internal/store/session_execution_configuration_test.go index 31c507521..b24683700 100644 --- a/services/agents-api/internal/store/session_execution_configuration_test.go +++ b/services/agents-api/internal/store/session_execution_configuration_test.go @@ -41,7 +41,6 @@ func TestSessionExecutionConfigurationFrozenAcrossCreationPathsAndRetry(t *testi tenant := uuid.NewString() input := executionProjectionInput(source) input.ModelProvider = &v1.ModelProviderInput{Protocol: "responses", BaseURL: "https://private-deployment.example/v1", APIKey: "private-projection-key-canary"} - input.ModelOptions = map[string]any{"private_headers": map[string]any{"authorization": "private-header-canary"}} input.ExecutionConfiguration.ModelProvider = v1.ExecutionProviderSelection{Source: source, Status: "available", Configuration: input.ModelProvider.SafeView()} input.ExecutionConfiguration.Object = "untrusted-object" input.ExecutionConfiguration.SchemaVersion = 99 @@ -66,25 +65,20 @@ func TestSessionExecutionConfigurationFrozenAcrossCreationPathsAndRetry(t *testi if frozen.Object != "agent.session.execution_configuration" || frozen.SchemaVersion != 1 || frozen.SessionID != session.ID || frozen.Model.Source != source || frozen.Harness.Source != source { t.Fatal("incorrect frozen projection identity or provenance") } - if source == "deployment" { - if frozen.ModelProvider.Status != "redacted" || frozen.ModelProvider.Configuration != nil { - t.Fatal("deployment provider was not redacted") - } - } else if frozen.ModelProvider.Status != "available" || !reflect.DeepEqual(frozen.ModelProvider.Configuration, input.ModelProvider.SafeView()) { + // Deployment defaults are readable with the same Core key, so every + // source records the safe view of the frozen bundle. + if frozen.ModelProvider.Status != "available" || !reflect.DeepEqual(frozen.ModelProvider.Configuration, input.ModelProvider.SafeView()) { t.Fatal("safe provider projection changed") } var stored []byte if err := pool.QueryRow(t.Context(), "SELECT configuration FROM session_execution_configuration WHERE session_id=$1", session.ID).Scan(&stored); err != nil { t.Fatal(err) } - for _, secret := range []string{"private-projection-key-canary", "private-header-canary", "private_headers", "native_options"} { + for _, secret := range []string{"private-projection-key-canary", "native_options"} { if bytes.Contains(stored, []byte(secret)) { t.Fatal("secret entered safe projection") } } - if source == "deployment" && bytes.Contains(stored, []byte("private-deployment.example")) { - t.Fatal("deployment endpoint persisted publicly") - } // Source-only changes and invalid replacement metadata never alter the // existing Session's retry identity or projection. input.ExecutionConfiguration.Model.Source = "different-on-retry" @@ -103,6 +97,15 @@ func TestSessionExecutionConfigurationFrozenAcrossCreationPathsAndRetry(t *testi if err != nil || !reflect.DeepEqual(got, frozen) { t.Fatal("snapshot changed or reader decrypted a secret", err) } + if source == "deployment" { + // Sessions frozen before deployment defaults moved into Core stay redacted. + if _, err := pool.Exec(t.Context(), `UPDATE session_execution_configuration SET configuration = jsonb_set(configuration, '{model_provider}', '{"source":"deployment","status":"redacted","configuration":null}') WHERE session_id=$1`, session.ID); err != nil { + t.Fatal(err) + } + if historical, err := reader.GetSessionExecutionConfiguration(t.Context(), tenant, session.ID); err != nil || historical.ModelProvider.Status != "redacted" || historical.ModelProvider.Configuration != nil { + t.Fatal("historical deployment selection changed", err) + } + } for _, lookup := range []struct{ tenant, id string }{{uuid.NewString(), session.ID}, {tenant, uuid.NewString()}, {tenant, "malformed"}} { if _, err := reader.GetSessionExecutionConfiguration(t.Context(), lookup.tenant, lookup.id); !errors.Is(err, ErrNotFound) { t.Fatal("foreign/missing projection read differed", err) diff --git a/services/agents-api/internal/store/session_initial_input.go b/services/agents-api/internal/store/session_initial_input.go index cf148c288..43f842182 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, 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, 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 { @@ -51,7 +51,7 @@ func (s *Store) createSessionResources(ctx context.Context, tenant string, param if err != nil { return err } - if err := s.saveSessionModelExecution(ctx, q, tenant, row.ID, provider, modelOptions); err != nil { + if err := s.saveSessionModelExecution(ctx, q, tenant, row.ID, provider); err != nil { return err } if err := saveSessionExecutionConfiguration(ctx, q, row, executionConfiguration, provider); err != nil { diff --git a/services/agents-api/internal/store/session_model_execution.go b/services/agents-api/internal/store/session_model_execution.go index 27f03081d..d66df3c2c 100644 --- a/services/agents-api/internal/store/session_model_execution.go +++ b/services/agents-api/internal/store/session_model_execution.go @@ -12,17 +12,18 @@ import ( ) // The embedded provider preserves the original flat ciphertext format. Native -// options are private deployment defaults and never enter public configuration. +// options exist only in historical snapshots frozen from the retired operator +// options file; they never enter public configuration and are no longer written. type sessionModelExecution struct { v1.ModelProviderInput NativeOptions map[string]any `json:"native_options,omitempty"` } -func (s *Store) saveSessionModelExecution(ctx context.Context, q *sqlc.Queries, tenant string, session pgtype.UUID, provider *v1.ModelProviderInput, options map[string]any) error { +func (s *Store) saveSessionModelExecution(ctx context.Context, q *sqlc.Queries, tenant string, session pgtype.UUID, provider *v1.ModelProviderInput) error { if provider == nil { return nil } - raw, err := json.Marshal(sessionModelExecution{ModelProviderInput: *provider, NativeOptions: options}) + raw, err := json.Marshal(sessionModelExecution{ModelProviderInput: *provider}) if err != nil { return err } diff --git a/services/agents-api/internal/store/session_model_options_test.go b/services/agents-api/internal/store/session_model_options_test.go index bf6ee9638..1b43ecc43 100644 --- a/services/agents-api/internal/store/session_model_options_test.go +++ b/services/agents-api/internal/store/session_model_options_test.go @@ -3,7 +3,6 @@ package store import ( "bytes" "encoding/json" - "errors" "reflect" "testing" @@ -12,7 +11,9 @@ import ( "github.com/google/uuid" ) -func TestSessionDeploymentModelOptionsEncryptedAndFrozen(t *testing.T) { +// Sessions frozen from the retired operator options file keep their native +// options; new Sessions store only the flat provider bundle. +func TestHistoricalSessionNativeOptionsRemainReadable(t *testing.T) { _, pool := testStore(t) cipher, err := credentialcrypto.New(bytes.Repeat([]byte{33}, 32)) if err != nil { @@ -20,63 +21,33 @@ func TestSessionDeploymentModelOptionsEncryptedAndFrozen(t *testing.T) { } st := NewWithCredentialCipher(pool, cipher) ctx, tenant := t.Context(), uuid.NewString() - options := map[string]any{"codex_provider": map[string]any{ - "base_url": "https://example.com/v1", "bearer_token": "provider-key-canary", "wire_api": "responses", - "http_headers": map[string]any{"x-deployment-secret": "header-secret-canary"}, - "query_params": map[string]any{"api-version": "2026-01-01"}, - }, "mode": "trusted-deployment-mode"} - input := CreateSessionInput{Creator: FixtureCreator(), Engine: "codex", IdempotencyKey: uuid.NewString(), Configuration: []byte(`{"agent":{"model":"actual-model"},"environment":{"type":"openai_hosted"}}`), ModelProvider: &v1.ModelProviderInput{Protocol: "responses", BaseURL: "https://example.com/v1", APIKey: "provider-key-canary"}, ModelOptions: options} + provider := &v1.ModelProviderInput{Protocol: "responses", BaseURL: "https://example.com/v1", APIKey: "provider-key-canary"} + input := CreateSessionInput{Creator: FixtureCreator(), Engine: "codex", IdempotencyKey: uuid.NewString(), Configuration: []byte(`{"agent":{"model":"actual-model"},"environment":{"type":"openai_hosted"}}`), ModelProvider: provider} session, err := st.CreateSession(ctx, tenant, input) if err != nil { t.Fatal(err) } - var ciphertext []byte - if err := pool.QueryRow(ctx, "SELECT encrypted_config FROM session_model_execution WHERE session_id=$1", session.ID).Scan(&ciphertext); err != nil { - t.Fatal(err) - } - for _, private := range []string{"provider-key-canary", "header-secret-canary", "native_options", "http_headers", "query_params", "trusted-deployment-mode"} { - if bytes.Contains(ciphertext, []byte(private)) || bytes.Contains(session.Configuration, []byte(private)) { - t.Fatal("deployment options exposed in plaintext") - } - } - restarted := NewWithCredentialCipher(pool, cipher) - provider, frozen, err := restarted.SessionModelExecutionWithOptions(ctx, tenant, session.ID) - if err != nil || provider == nil || *provider != *input.ModelProvider || !reflect.DeepEqual(frozen, options) { - t.Fatal("restart changed encrypted deployment options", err) - } - // Updating an operator's in-memory options cannot affect the saved Session. - options["codex_provider"].(map[string]any)["http_headers"].(map[string]any)["x-deployment-secret"] = "rotated-header-canary" - options["mode"] = "new-mode" - _, again, err := restarted.SessionModelExecutionWithOptions(ctx, tenant, session.ID) - if err != nil || !reflect.DeepEqual(again, frozen) { - t.Fatal("operator edit changed frozen Session options", err) + flat, frozen, err := st.SessionModelExecutionWithOptions(ctx, tenant, session.ID) + if err != nil || flat == nil || *flat != *provider || frozen != nil { + t.Fatal("new Session did not store a flat provider bundle", err) } - if _, err := st.CreateSession(ctx, tenant, input); !errors.Is(err, ErrIdempotencyConflict) { - t.Fatal("changed deployment options accepted as identical retry", err) - } - input.ModelOptions = frozen - if replay, err := st.CreateSession(ctx, tenant, input); err != nil || replay.ID != session.ID { - t.Fatal("identical options changed retry identity", err) - } - input.IdempotencyKey = uuid.NewString() - input.ModelProvider = nil - if _, err := st.CreateSession(ctx, tenant, input); !errors.Is(err, ErrInvalidInput) { - t.Fatal("provider-free native options accepted", err) - } - // Existing flat provider ciphertext remains readable without native options. - flat, err := json.Marshal(provider) + options := map[string]any{"codex_provider": map[string]any{ + "base_url": provider.BaseURL, "bearer_token": provider.APIKey, "wire_api": "responses", + "http_headers": map[string]any{"x-deployment-secret": "header-secret-canary"}, + }, "mode": "trusted-deployment-mode"} + historical, err := json.Marshal(sessionModelExecution{ModelProviderInput: *provider, NativeOptions: options}) if err != nil { t.Fatal(err) } - flatCiphertext, err := cipher.SealModelExecution(flat, tenant, session.ID) + ciphertext, err := cipher.SealModelExecution(historical, tenant, session.ID) if err != nil { t.Fatal(err) } - if _, err := pool.Exec(ctx, "UPDATE session_model_execution SET encrypted_config=$2 WHERE session_id=$1", session.ID, flatCiphertext); err != nil { + if _, err := pool.Exec(ctx, "UPDATE session_model_execution SET encrypted_config=$2 WHERE session_id=$1", session.ID, ciphertext); err != nil { t.Fatal(err) } - legacy, legacyOptions, err := restarted.SessionModelExecutionWithOptions(ctx, tenant, session.ID) - if err != nil || legacy == nil || *legacy != *provider || legacyOptions != nil { - t.Fatal("legacy flat provider snapshot could not be read", err) + legacy, legacyOptions, err := NewWithCredentialCipher(pool, cipher).SessionModelExecutionWithOptions(ctx, tenant, session.ID) + if err != nil || legacy == nil || *legacy != *provider || !reflect.DeepEqual(legacyOptions, options) { + t.Fatal("historical native options could not be read", err) } } diff --git a/services/agents-api/internal/store/sessions.go b/services/agents-api/internal/store/sessions.go index 67357c8e8..7ab09eeda 100644 --- a/services/agents-api/internal/store/sessions.go +++ b/services/agents-api/internal/store/sessions.go @@ -59,17 +59,16 @@ type Session struct { type CreateSessionInput struct { ExecutionConfiguration *v1.SessionExecutionConfiguration ModelProvider *v1.ModelProviderInput - // ModelOptions is a private snapshot of trusted deployment execution options. - ModelOptions map[string]any - Initialization EnvironmentSetup - InitialFiles []InitialFile - Creator identity.Subject - CreationRequest json.RawMessage - Engine string - Metadata map[string]string - IdempotencyKey string - Configuration json.RawMessage - InitialInputs []Input + ModelProviderSource string // session, agent or deployment; empty allows only openai_hosted + Initialization EnvironmentSetup + InitialFiles []InitialFile + Creator identity.Subject + CreationRequest json.RawMessage + Engine string + Metadata map[string]string + IdempotencyKey string + Configuration json.RawMessage + InitialInputs []Input } type SessionPage struct { @@ -130,9 +129,6 @@ func (s *Store) createSession(ctx context.Context, tenantID string, input Create return SessionCreation{}, err } } - if input.ModelOptions != nil && input.ModelProvider == nil { - return SessionCreation{}, fmt.Errorf("%w: model options require a provider", ErrInvalidInput) - } if input.ModelProvider != nil { if err := input.ModelProvider.ValidateHarness(input.Engine); err != nil { return SessionCreation{}, fmt.Errorf("%w: %s", ErrInvalidInput, err) @@ -143,8 +139,8 @@ func (s *Store) createSession(ctx context.Context, tenantID string, input Create } environment, _ := fields["environment"].(map[string]any) environmentType, _ := environment["type"].(string) - if !v1.ModelProviderEnvironmentSupported(environmentType) { - return SessionCreation{}, fmt.Errorf("%w: model credentials require a hosted environment", ErrInvalidInput) + if !v1.ModelProviderAllowed(environmentType, input.ModelProviderSource) { + return SessionCreation{}, fmt.Errorf("%w: this model provider source is not supported for the Session environment", ErrInvalidInput) } fields["model_provider_configured"] = true configuration, err = json.Marshal(fields) @@ -159,14 +155,13 @@ 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 { ModelProvider *v1.ModelProviderInput `json:",omitempty"` - ModelOptions map[string]any `json:",omitempty"` Engine string Metadata map[string]string Configuration json.RawMessage `json:",omitempty"` InitialInputs json.RawMessage `json:",omitempty"` InitialFiles []InitialFile `json:",omitempty"` Initialization *EnvironmentSetup `json:",omitempty"` - }{input.ModelProvider, input.ModelOptions, input.Engine, input.Metadata, configuration, encodedInput, input.InitialFiles, initialization}) + }{input.ModelProvider, input.Engine, input.Metadata, configuration, encodedInput, input.InitialFiles, initialization}) if err != nil { return SessionCreation{}, fmt.Errorf("%w: input: %v", ErrInvalidInput, err) } @@ -181,7 +176,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.ExecutionConfiguration) + row, environment, err := s.createSessionResources(ctx, tenantID, params, batch, encodedInput, input.InitialFiles, input.Initialization, input.ModelProvider, input.ExecutionConfiguration) if errors.Is(err, pgx.ErrNoRows) { return SessionCreation{}, ErrIdempotencyConflict } diff --git a/services/agents-api/internal/store/template_composition_public_test.go b/services/agents-api/internal/store/template_composition_public_test.go index 21b4c10b4..4ffe77504 100644 --- a/services/agents-api/internal/store/template_composition_public_test.go +++ b/services/agents-api/internal/store/template_composition_public_test.go @@ -43,7 +43,7 @@ func TestTemplateCompositionOfficialClientPostgres(t *testing.T) { serve := func(current *store.Store) *httptest.Server { t.Helper() // Hosted admission and freezing use the real Store; no Runtime or model runs. - h, err := api.NewHandler(current, auth, "codex", api.WithHostedEnvironments(), api.WithExecution(current), api.WithSourceFiles(current)) + h, err := api.NewHandler(current, auth, "codex", api.WithHostedEnvironments(), api.WithExecution(current), api.WithSourceFiles(current), fixtureDeploymentProvider()) if err != nil { t.Fatal(err) } diff --git a/services/agents-api/internal/store/template_null_selection_public_test.go b/services/agents-api/internal/store/template_null_selection_public_test.go index 53e25128c..38630d109 100644 --- a/services/agents-api/internal/store/template_null_selection_public_test.go +++ b/services/agents-api/internal/store/template_null_selection_public_test.go @@ -44,7 +44,7 @@ func TestTemplateNullSelectionOfficialClientPostgres(t *testing.T) { } serve := func(current *store.Store) *httptest.Server { t.Helper() - h, err := api.NewHandler(current, auth, "codex", api.WithHostedEnvironments(), api.WithExecution(current), api.WithSourceFiles(current), api.WithSkills(current)) + h, err := api.NewHandler(current, auth, "codex", api.WithHostedEnvironments(), api.WithExecution(current), api.WithSourceFiles(current), api.WithSkills(current), fixtureDeploymentProvider()) if err != nil { t.Fatal(err) } diff --git a/services/agents-api/internal/store/whitespace_input_public_test.go b/services/agents-api/internal/store/whitespace_input_public_test.go index 42795640f..5dcee7368 100644 --- a/services/agents-api/internal/store/whitespace_input_public_test.go +++ b/services/agents-api/internal/store/whitespace_input_public_test.go @@ -157,7 +157,7 @@ func TestWhitespaceOnlyTextHarnessAdmissionPostgres(t *testing.T) { `{"agent":{"model":"m"},"environment":{"type":"none"},"input":[{"role":"user","content":[{"type":"input_text","text":"x"}]},{"role":"user","content":[{"type":"input_text","text":""},{"type":"input_text","text":" "}]}]}`, `{"agent":{"model":"m"},"environment":{"type":"none"},"stream":true,"input":" "}`, // The self-hosted initial reservation is never created. - `{"agent":{"model":"m"},"environment":{"type":"self_hosted","workspace_directory":"/workspace"},"input":" "}`, + `{"agent":{"model":"m"},"environment":{"type":"self_hosted","workspace_directory":"/workspace"},"input":" ",` + fixtureSessionProvider(engine) + `}`, } { if status, response := client.do(token, http.MethodPost, "/v1/agents/sessions", "application/json", []byte(body)); status != http.StatusBadRequest || response != rejection { t.Errorf("%s create %s: %d %s", engine, body, status, response) diff --git a/services/agents-api/migrations/000075_deployment_model_providers.sql b/services/agents-api/migrations/000075_deployment_model_providers.sql new file mode 100644 index 000000000..31d51e8aa --- /dev/null +++ b/services/agents-api/migrations/000075_deployment_model_providers.sql @@ -0,0 +1,30 @@ +-- +goose Up +-- One deployment default model provider per harness. The safe columns serve +-- reads without decryption; encrypted_config holds the complete bundle. +CREATE TABLE deployment_model_providers ( + harness text PRIMARY KEY, + protocol text NOT NULL, + base_url text NOT NULL, + context_window integer NOT NULL DEFAULT 0 CHECK (context_window >= 0), + max_output_tokens integer NOT NULL DEFAULT 0 CHECK (max_output_tokens >= 0), + encrypted_config bytea NOT NULL, + updated_at timestamptz NOT NULL DEFAULT clock_timestamp() +); + +-- Deployment-wide administrator writes have no Project. +ALTER TABLE admin_audit_log ALTER COLUMN tenant_id DROP NOT NULL; +ALTER TABLE admin_audit_log ALTER COLUMN project_id DROP NOT NULL; +ALTER TABLE admin_audit_log ADD CONSTRAINT admin_audit_log_scope_check CHECK ((tenant_id IS NULL) = (project_id IS NULL)); + +-- +goose Down +-- +goose StatementBegin +DO $$ BEGIN + IF EXISTS (SELECT 1 FROM admin_audit_log WHERE project_id IS NULL) THEN + RAISE EXCEPTION 'Deployment-wide audit entries prevent this downgrade; restore a complete database backup instead.'; + END IF; +END $$; +-- +goose StatementEnd +ALTER TABLE admin_audit_log DROP CONSTRAINT admin_audit_log_scope_check; +ALTER TABLE admin_audit_log ALTER COLUMN project_id SET NOT NULL; +ALTER TABLE admin_audit_log ALTER COLUMN tenant_id SET NOT NULL; +DROP TABLE deployment_model_providers; diff --git a/services/agents-api/tests/official_client.py b/services/agents-api/tests/official_client.py index da2b814e1..8da8cbd0f 100644 --- a/services/agents-api/tests/official_client.py +++ b/services/agents-api/tests/official_client.py @@ -222,7 +222,10 @@ def issue_key(project_id, name): expect_error(AuthenticationError, lambda: invalid.beta.agents.sessions.retrieve(first.id)) expect_error(BadRequestError, lambda: sessions.retrieve(first.id, extra_headers={"OpenAI-Beta": ""})) expect_error(BadRequestError, lambda: sessions.create(**{**spec, "input": [{"role": "user", "content": [{"type": "input_image", "image_url": "https://example.com/image.png"}]}]})) - self_hosted = sessions.create(agent=spec["agent"], environment={"type": "self_hosted", "workspace_directory": "/workspace"}) + # A self-hosted Session carries its own write-only model provider; nothing here calls it. + provider = {"protocol": "responses", "base_url": "https://model.fixture.example/v1", "api_key": "fixture-model-key"} + self_hosted = sessions.create(agent=spec["agent"], environment={"type": "self_hosted", "workspace_directory": "/workspace"}, + extra_body={"x_agents_core": {"model_provider": provider}}) assert self_hosted.environment.type == "self_hosted" assert list(sessions.turns.list(self_hosted.id)) == [] assert sessions.delete(self_hosted.id).deleted diff --git a/services/agents-api/tests/official_environment_retrieve.py b/services/agents-api/tests/official_environment_retrieve.py index 780d8632d..0e71ffa3b 100644 --- a/services/agents-api/tests/official_environment_retrieve.py +++ b/services/agents-api/tests/official_environment_retrieve.py @@ -23,6 +23,11 @@ def verify_environment(value, environment_id, status=None): return value +# Self-hosted Sessions carry their own write-only model provider; nothing here calls it. +PROVIDER = {"x_agents_core": {"model_provider": {"protocol": "responses", "base_url": "https://model.fixture.example/v1", + "api_key": "fixture-model-key"}}} + + def main(): settings = json.load(sys.stdin) pin = json.loads((Path(__file__).resolve().parents[3] / "contracts/agents-api/upstream.json").read_text()) @@ -43,9 +48,9 @@ def client(key): else: creation = {"agent": {"model": "test-model"}, "environment": { "type": "self_hosted", "workspace_directory": "/workspace"}} - session = api.beta.agents.sessions.create(**creation) - removed = api.beta.agents.sessions.create(**creation) - other = foreign.beta.agents.sessions.create(**creation) + session = api.beta.agents.sessions.create(**creation, extra_body=PROVIDER) + removed = api.beta.agents.sessions.create(**creation, extra_body=PROVIDER) + other = foreign.beta.agents.sessions.create(**creation, extra_body=PROVIDER) result = {"environment_id": session.environment.id, "deleted_environment_id": removed.environment.id, "foreign_environment_id": other.environment.id} verify_environment(api.beta.agents.environments.retrieve(removed.environment.id).to_dict(), removed.environment.id, "pending") diff --git a/services/agents-api/tests/official_self_hosted_cancel.py b/services/agents-api/tests/official_self_hosted_cancel.py index d5c000a1e..cb12af1d4 100644 --- a/services/agents-api/tests/official_self_hosted_cancel.py +++ b/services/agents-api/tests/official_self_hosted_cancel.py @@ -14,6 +14,11 @@ from openai import APIStatusError, OpenAI +# Self-hosted Sessions carry their own write-only model provider; nothing here calls it. +PROVIDER = {"x_agents_core": {"model_provider": {"protocol": "responses", "base_url": "https://model.fixture.example/v1", + "api_key": "fixture-model-key"}}} + + def main(): settings = json.load(sys.stdin) pin = json.loads((Path(__file__).resolve().parents[3] / "contracts/agents-api/upstream.json").read_text()) @@ -73,10 +78,10 @@ def current(session_id): if phase == "create": request = {"agent": {"model": "test-model", "instructions": "Controlled cancellation admission."}, "environment": {"type": "self_hosted", "workspace_directory": "/workspace"}} - main_session = sessions.create(**request) - initial = sessions.create(**request, input="Keep this pending initial input.") - later = sessions.create(**request) - deleted = sessions.create(**request) + main_session = sessions.create(**request, extra_body=PROVIDER) + initial = sessions.create(**request, input="Keep this pending initial input.", extra_body=PROVIDER) + later = sessions.create(**request, extra_body=PROVIDER) + deleted = sessions.create(**request, extra_body=PROVIDER) sessions.delete(deleted.id) result = {"id": main_session.id, "environment_id": main_session.environment.id, "initial_id": initial.id, "later_id": later.id, "deleted_id": deleted.id, diff --git a/services/agents-api/tests/official_self_hosted_initial.py b/services/agents-api/tests/official_self_hosted_initial.py index 82835fb13..2e52b57ba 100644 --- a/services/agents-api/tests/official_self_hosted_initial.py +++ b/services/agents-api/tests/official_self_hosted_initial.py @@ -15,6 +15,11 @@ from openai import OpenAI +# Self-hosted Sessions carry their own write-only model provider; nothing here calls it. +PROVIDER = {"x_agents_core": {"model_provider": {"protocol": "responses", "base_url": "https://model.fixture.example/v1", + "api_key": "fixture-model-key"}}} + + def main(): settings = json.load(sys.stdin) pin = json.loads((Path(__file__).resolve().parents[3] / "contracts/agents-api/upstream.json").read_text()) @@ -44,7 +49,7 @@ def check(value, status, environment_id=None): endpoint = base + "/v1/agents/sessions" def post(request, key, url=endpoint, key_token=token): - return raw.post(url, json=request, headers={"Idempotency-Key": key, "Authorization": "Bearer " + key_token}) + return raw.post(url, json={**request, **PROVIDER}, headers={"Idempotency-Key": key, "Authorization": "Bearer " + key_token}) def current(case, status="requires_action"): value = sessions.retrieve(case["id"]).to_dict() @@ -56,7 +61,7 @@ def current(case, status="requires_action"): def retry(case, status="requires_action"): value = current(case, status) - assert sessions.create(**case["request"], extra_headers={"Idempotency-Key": case["key"]}).to_dict() == value + assert sessions.create(**case["request"], extra_body=PROVIDER, extra_headers={"Idempotency-Key": case["key"]}).to_dict() == value response = post(case["request"], case["key"]) assert response.status_code == 201 and response.json() == value return value @@ -78,7 +83,7 @@ def retry(case, status="requires_action"): def create_once(index): if index == 0: with client() as creator: - return creator.beta.agents.sessions.create(**request, extra_headers={"Idempotency-Key": key}).to_dict() + return creator.beta.agents.sessions.create(**request, extra_body=PROVIDER, extra_headers={"Idempotency-Key": key}).to_dict() reply = post(request, key) assert reply.status_code == 201 return reply.json() @@ -88,7 +93,7 @@ def create_once(index): value = replies[0] assert all(reply == value for reply in replies) elif mode == "sdk_stream": - with sessions.create(**request, stream=True, extra_headers={"Idempotency-Key": key}) as stream: + with sessions.create(**request, stream=True, extra_body=PROVIDER, extra_headers={"Idempotency-Key": key}) as stream: events = iter(stream) created, waiting = next(events).to_dict(), next(events).to_dict() assert created["type"] == "agent.session.created" and waiting["type"] == "agent.session.requires_action" @@ -100,7 +105,7 @@ def create_once(index): value = waiting["session"] assert created["session"] == value elif mode == "raw_disconnect": - with raw.stream("POST", endpoint, json={**request, "stream": True}, headers={"Idempotency-Key": key}) as response: + with raw.stream("POST", endpoint, json={**request, **PROVIDER, "stream": True}, headers={"Idempotency-Key": key}) as response: assert response.status_code == 201 and response.headers["content-type"] == "text/event-stream" created = next(json.loads(line[6:]) for line in response.iter_lines() if line.startswith("data: ")) assert created["type"] == "agent.session.created" @@ -108,7 +113,7 @@ def create_once(index): value = sessions.retrieve(created["session"]["id"]).to_dict() assert created["session"] == value else: - value = sessions.create(**request, extra_headers={"Idempotency-Key": key}).to_dict() + value = sessions.create(**request, extra_body=PROVIDER, extra_headers={"Idempotency-Key": key}).to_dict() assert time.monotonic() - began < 8, "creation waited for offline execution" check(value, "requires_action") case = {"id": value["id"], "environment_id": value["environment"]["id"], "key": key, "request": request, @@ -140,7 +145,7 @@ def create_once(index): observations, failures = {}, [] # A same-key stream retry of the pending creation ends at once without events. with client() as observer: - with observer.beta.agents.sessions.create(**case["request"], stream=True, extra_headers={"Idempotency-Key": case["key"]}) as stream: + with observer.beta.agents.sessions.create(**case["request"], stream=True, extra_body=PROVIDER, extra_headers={"Idempotency-Key": case["key"]}) as stream: assert list(stream) == [] ready = {name: threading.Event() for name in ("sdk", "raw")} From 534b4728a332ba556bf77bb597dcbbc50f0891be Mon Sep 17 00:00:00 2001 From: SaladDay <1203511142@qq.com> Date: Fri, 25 Sep 2026 19:04:47 +0000 Subject: [PATCH 2/6] Name only existing steps in the retired execution options startup error The error for AGENTS_API_EXECUTION_OPTIONS_FILE now says to remove the setting and set deployment default model providers in Web (System) or with PUT /core/v1/harnesses/{harness}/model-provider. The import command it named does not exist yet; the import work adds it back. --- contracts/agents-api/model-execution.md | 3 ++- docs/configuration.md | 4 ++-- services/agents-api/cmd/server/process_configuration.go | 2 +- services/agents-api/cmd/server/process_configuration_test.go | 2 +- 4 files changed, 6 insertions(+), 5 deletions(-) diff --git a/contracts/agents-api/model-execution.md b/contracts/agents-api/model-execution.md index a5833e8fc..82da276af 100644 --- a/contracts/agents-api/model-execution.md +++ b/contracts/agents-api/model-execution.md @@ -155,7 +155,8 @@ use the same provider. The execution-configuration read shows the frozen safe vi with source `deployment`. The former `AGENTS_API_EXECUTION_OPTIONS_FILE` is retired: setting it stops Core at -startup with the replacement named. Only its provider identity (endpoint, key, +startup with the replacement named; remove it and set the deployment defaults +in Web (System) or with `PUT /core/v1/harnesses/{harness}/model-provider`. Only its provider identity (endpoint, key, protocol and MiniMax Code limits) has a home in the deployment default; its other native options (headers, query parameters, environment, MCP servers, feature and permission settings) are dropped. Sessions frozen from that file keep their diff --git a/docs/configuration.md b/docs/configuration.md index 4d8cbb2fe..aaed94e60 100644 --- a/docs/configuration.md +++ b/docs/configuration.md @@ -73,8 +73,8 @@ Core reports loaded configuration paths on startup, without environment values o file contents. The existing history JSON format remains a separate specialized file referenced from `core.env`; this change does not introduce a new loader or consolidate secrets into one file. The former `AGENTS_API_EXECUTION_OPTIONS_FILE` -is retired and stops startup; deployment model providers are -[database-owned](#deployment-model-providers). Internal polling/queue controls +is retired and stops startup: remove it and set +[deployment model providers](#deployment-model-providers) in Web or through Core. Internal polling/queue controls remain internal. Runtime history retention remains its existing fixed policy. A process-only change requires restart. In Compose, recreate Core so it rereads diff --git a/services/agents-api/cmd/server/process_configuration.go b/services/agents-api/cmd/server/process_configuration.go index f76d60212..ce163e16b 100644 --- a/services/agents-api/cmd/server/process_configuration.go +++ b/services/agents-api/cmd/server/process_configuration.go @@ -33,7 +33,7 @@ func validateProcessConfiguration() error { } } if _, present := os.LookupEnv("AGENTS_API_EXECUTION_OPTIONS_FILE"); present { - return errors.New("AGENTS_API_EXECUTION_OPTIONS_FILE is retired. Deployment model providers are stored in Core: set them in Web (System) or with PUT /core/v1/harnesses/{harness}/model-provider. To import the old file once, run `parsar apply` and accept the import; then this setting is removed") + return errors.New("AGENTS_API_EXECUTION_OPTIONS_FILE is retired; remove it and set deployment default model providers in Web (System) or with PUT /core/v1/harnesses/{harness}/model-provider") } if path := os.Getenv("AGENTS_API_CONFIG_FILE"); path != "" && !filepath.IsAbs(path) { return errors.New("AGENTS_API_CONFIG_FILE must be an absolute diagnostic path") diff --git a/services/agents-api/cmd/server/process_configuration_test.go b/services/agents-api/cmd/server/process_configuration_test.go index 5083df2ee..145d65415 100644 --- a/services/agents-api/cmd/server/process_configuration_test.go +++ b/services/agents-api/cmd/server/process_configuration_test.go @@ -52,7 +52,7 @@ func TestRetiredExecutionOptionsFileNamesItsReplacement(t *testing.T) { for _, value := range []string{"/private/execution-options.json", ""} { t.Setenv("AGENTS_API_EXECUTION_OPTIONS_FILE", value) err := validateProcessConfiguration() - if err == nil || !strings.Contains(err.Error(), "/core/v1/harnesses/{harness}/model-provider") || strings.Contains(err.Error(), "/private/") { + if err == nil || !strings.Contains(err.Error(), "remove it") || !strings.Contains(err.Error(), "/core/v1/harnesses/{harness}/model-provider") || strings.Contains(err.Error(), "/private/") { t.Fatal(err) } } From b24e3a44c4115372797dfd3194adf5a80362b7df Mon Sep 17 00:00:00 2001 From: SaladDay <1203511142@qq.com> Date: Fri, 25 Sep 2026 19:34:24 +0000 Subject: [PATCH 3/6] Record caller intent wherever the deployment default applies and address review P2-1: A same-key retry of a none Session conflicted after the deployment default changed, because the resolved provider fed the retry hash while caller intent was recorded only for openai_hosted. Caller intent is now recorded whenever the deployment default may apply (openai_hosted and none), and a provider key enters any stored hash only as a fingerprint keyed by the credential key (new Cipher.Fingerprint), never directly. Without the key, an intent lookup cannot match and creation reports the missing key. none Sessions now compare caller intent on retry, like hosted ones; rows without recorded intent keep the resolved-equivalence rule. Review follow-ups: - Input reserved before providers were required fails with model_provider_required (new environment_input_reservations.failure_code, migration 000075) instead of waiting for its deadline, and the Session reports that reason. - The 000075 down migration also refuses while deployment model providers exist. - Scope wording: the deployment default applies to openai_hosted and none, never to self_hosted, in the public API guide, the self_hosted 400 message, the client JSDoc and related docs. - Tests: none retry after the default is rotated and removed; a self-hosted preparation carries the frozen provider only to the bound executor; legacy reservations settle as failed. - Docs: a saved Agent's provider key reaches every self_hosted executor created with that Agent in the Project; legacy none Sessions run with the device's own environment and are not counted by the pre-upgrade check. --- contracts/agents-api/README.md | 2 +- .../environment-executor-credentials.md | 5 +- contracts/agents-api/model-execution.md | 15 ++- contracts/agents-api/openapi.yaml | 17 +-- deploy/install/model_provider_sessions.py | 2 + docs/api/public-agent-api.md | 11 +- docs/getting-started/quickstart.md | 2 +- packages/agents-client/src/admin-client.ts | 2 +- services/agents-api/internal/api/handler.go | 2 +- .../internal/api/session_creation_identity.go | 6 +- .../api/session_creation_stream_test.go | 5 + .../internal/api/session_model_defaults.go | 2 +- .../internal/api/session_response.go | 5 +- .../internal/credentialcrypto/cipher.go | 26 ++++- .../internal/credentialcrypto/cipher_test.go | 20 ++++ .../db/queries/environment_input_activity.sql | 2 +- .../db/queries/environment_inputs.sql | 5 + .../db/sqlc/environment_input_activity.sql.go | 6 +- .../db/sqlc/environment_inputs.sql.go | 31 +++++- .../agents-api/internal/db/sqlc/models.go | 1 + .../internal/execution/prepared_dispatch.go | 5 + .../internal/execution/worker_schedule.go | 3 + .../deployment_model_providers_http_test.go | 103 +++++++++++++++--- .../store/environment_input_activity.go | 3 + .../internal/store/environment_inputs.go | 14 +++ .../mcp_credential_selection_public_test.go | 14 ++- .../internal/store/prepared_dispatch_test.go | 37 +++++++ .../store/session_creation_identity.go | 53 ++++++++- .../agents-api/internal/store/sessions.go | 9 +- .../000075_deployment_model_providers.sql | 9 ++ 30 files changed, 363 insertions(+), 54 deletions(-) diff --git a/contracts/agents-api/README.md b/contracts/agents-api/README.md index 20745ded7..94baa9c28 100644 --- a/contracts/agents-api/README.md +++ b/contracts/agents-api/README.md @@ -487,7 +487,7 @@ All three profiles implement user-managed `self_hosted` enrollment at `/workspac through our private daemon transport; [separate real acceptance](user-managed-runtime-v1.md) records qualified deployments and limits. A `self_hosted` Session supplies its own model provider in the request or through a saved Agent; deployment defaults apply -only to hosted placement ([model execution](model-execution.md)). Service-origin HTTP MCP is rejected on `self_hosted` and hosted local +to `openai_hosted` and `none`, never to `self_hosted` ([model execution](model-execution.md)). Service-origin HTTP MCP is rejected on `self_hosted` and hosted local placements. This does not remove separately qualified Environment Plugin MCP. The [Docker lifecycle](environments.md#basic-public-docker-hosted-profile) retains workspace Files/Artifacts, cancellation and recovery with native isolation. diff --git a/contracts/agents-api/environment-executor-credentials.md b/contracts/agents-api/environment-executor-credentials.md index 5e4a88079..429731ecf 100644 --- a/contracts/agents-api/environment-executor-credentials.md +++ b/contracts/agents-api/environment-executor-credentials.md @@ -80,7 +80,10 @@ snapshot and sends it only over the connection of the executor enrolled for this Environment with a current credential of the Session creator's principal. The executor keeps it in the Runtime's native harness home, which tools and public Files cannot read; the executor host's owner can. Revocation does not erase a -bundle already delivered. +bundle already delivered. A saved Agent's provider key is delivered to the +executor of every `self_hosted` Session created with that Agent in the Project, so +anyone who can create `self_hosted` Sessions in the Project and run an executor +can read it. ## Executor host diff --git a/contracts/agents-api/model-execution.md b/contracts/agents-api/model-execution.md index 82da276af..8034dd4a9 100644 --- a/contracts/agents-api/model-execution.md +++ b/contracts/agents-api/model-execution.md @@ -71,8 +71,10 @@ model, harness or provider. A missing/wrong encryption key fails closed. Retain same deployment credential-encryption key across restarts. V1 has no Turn override, provider catalog, Session migration or new execution loop. -All new hosted requests record caller intent before resolving mutable defaults, -including inline requests that use deployment defaults. Matching creation retries +Every new request that may use a deployment default (`openai_hosted` and `none`), +and every request with a saved Agent, template or provider bundle, records caller +intent before resolving mutable defaults. A retry therefore returns the committed +Session even after the deployment default was replaced or removed. Matching creation retries recover the committed Session before resolving the Agent or provider again and do not enqueue another input. Streaming remains outside the retry identity. Existing historical rows keep their documented retry limitations; this change does not @@ -113,7 +115,8 @@ limits. Native provider availability is checked during execution, not by a new p The entire resolved provider configuration is frozen and encrypted in the Session creation transaction, with a distinct credential-crypto purpose and tenant/Session binding. Creation retries include this intent in their request hash; changing the key or -endpoint under the same idempotency key conflicts. Recovery reads the committed +endpoint under the same idempotency key conflicts. A key enters any stored hash +only as a fingerprint keyed by the deployment credential key, never directly. Recovery reads the committed Session before mutable Agent/template resolution. No public Session, Agent, Environment, event or ordinary configuration contains the key. The top-level extension is write-only and has no update endpoint. @@ -163,8 +166,12 @@ permission settings) are dropped. Sessions frozen from that file keep their complete private snapshot. Historical `openai_hosted` and `self_hosted` Sessions created without any snapshot cannot start new work: message input returns 400 `model_provider_required`, while cancellation and history reads keep working. +Input they reserved before the upgrade settles as failed with that reason, and the +Session reports it, instead of waiting for its deadline. Recreate them with a bundle. Run `deploy/install/model_provider_sessions.py` -against an installation before upgrading to count them; it only reads. +against an installation before upgrading to count them; it only reads. Historical +`none` Sessions that relied on the retired options file now run with the device's +own environment, and the check does not count them. Parsar manages its own workspace catalog and encrypted keys, sends this extension only on the first Core Session request, and retains a private encrypted snapshot diff --git a/contracts/agents-api/openapi.yaml b/contracts/agents-api/openapi.yaml index c7bbef5ef..0bf83ac51 100644 --- a/contracts/agents-api/openapi.yaml +++ b/contracts/agents-api/openapi.yaml @@ -3006,14 +3006,15 @@ paths: live-only. New Sessions retain their authenticated creator; all creation retries require the same typed subject, including across key rotation. Saved-Agent retries and inline requests using Vault attachments or credential references - retain caller intent independently of later resource changes; new hosted inline - requests also freeze caller intent before deployment defaults resolve; unrelated - non-hosted inline retries preserve resolved/default equivalences. Unknown - historical creators reject retries; known creators without recorded intent - retain resolved-snapshot retry rules. These conflict policies are local and - not verified hosted parity. A same-key stream=true retry of an existing creation - returns 201 with no events and closes at once; retry with stream=false or - use the GET events stream to recover. Claude SDK on none and Core-managed + retain caller intent independently of later resource changes; new openai_hosted + and none inline requests also freeze caller intent before deployment defaults + resolve; unrelated self_hosted inline retries preserve resolved/default equivalences. + Provider keys enter retry hashes only as fingerprints keyed by the credential + key. Unknown historical creators reject retries; known creators without recorded + intent retain resolved-snapshot retry rules. These conflict policies are local + and not verified hosted parity. A same-key stream=true retry of an existing + creation returns 201 with no events and closes at once; retry with stream=false + or use the GET events stream to recover. Claude SDK on none and Core-managed Docker openai_hosted supports qualified object-root json_schema output with medium verbosity, single-Agent execution and ordinary functions. Hosted execution reuses native workspace tools and Files/Artifacts; Skills, Plugins, capability diff --git a/deploy/install/model_provider_sessions.py b/deploy/install/model_provider_sessions.py index fe6edb60e..015c3b042 100644 --- a/deploy/install/model_provider_sessions.py +++ b/deploy/install/model_provider_sessions.py @@ -5,6 +5,8 @@ stores deployment model providers itself: these Sessions cannot start new work afterwards and must be recreated with x_agents_core.model_provider or an Agent that has one saved. Cancelling their work and reading their history keep working. +Historical none Sessions are not counted: without a frozen provider they run with +their device's own environment. """ import argparse import json diff --git a/docs/api/public-agent-api.md b/docs/api/public-agent-api.md index 9626d26f3..a06a34672 100644 --- a/docs/api/public-agent-api.md +++ b/docs/api/public-agent-api.md @@ -71,11 +71,12 @@ 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 Session's model provider comes from the Session's `x_agents_core.model_provider`, -then a saved Agent's, then, for `openai_hosted` only, the deployment default that -the administrator sets per harness. `openai_hosted` and `self_hosted` Sessions -must end up with one: otherwise creation returns 400 with code -`model_provider_required` and param `x_agents_core.model_provider`, and nothing is -written. `none` Sessions accept only the deployment default. See +then a saved Agent's, then the deployment default that the administrator sets per +harness. The deployment default applies to `openai_hosted` and `none` Sessions, +never to `self_hosted`; `none` Sessions accept only the deployment default. +`openai_hosted` and `self_hosted` Sessions must end up with one: otherwise creation +returns 400 with code `model_provider_required` and param +`x_agents_core.model_provider`, and nothing is written. See [model execution](../../contracts/agents-api/model-execution.md). An application reads a Session's model from `agent.model` and an explicitly diff --git a/docs/getting-started/quickstart.md b/docs/getting-started/quickstart.md index d1af37426..979d27fec 100644 --- a/docs/getting-started/quickstart.md +++ b/docs/getting-started/quickstart.md @@ -94,7 +94,7 @@ model configuration. They are not fields in the official SDK 3.13.0 protocol. The service encrypts model configuration with tenant/Session binding and never returns the secret through public resource reads. A `self_hosted` Session must always carry its own provider this way or through a saved Agent; deployment -defaults apply only to `openai_hosted`. Keep the installation's +defaults apply to `openai_hosted` and `none`, never to `self_hosted`. Keep the installation's credential encryption key and database together across restarts. Keep the complete `agent` object together: SDK 3.13.0 replaces an ordinary body field with the corresponding `extra_body` field rather than merging nested fields. diff --git a/packages/agents-client/src/admin-client.ts b/packages/agents-client/src/admin-client.ts index 0aa8a78ba..9158b76df 100644 --- a/packages/agents-client/src/admin-client.ts +++ b/packages/agents-client/src/admin-client.ts @@ -284,7 +284,7 @@ export class AdminClient { } /** * Replaces the harness's deployment default with the complete bundle, including the write-only key. - * New openai_hosted Sessions freeze it; existing Sessions and self_hosted Sessions never use a changed default. + * New openai_hosted and none Sessions freeze it; self_hosted Sessions never use it, and existing Sessions keep the provider they froze. */ async setHarnessModelProvider(harness: CoreHarnessKind, input: ModelProviderInput, options?: ReadOptions): Promise { const body: ModelProviderInput = { protocol: input.protocol, base_url: input.base_url, api_key: input.api_key }; diff --git a/services/agents-api/internal/api/handler.go b/services/agents-api/internal/api/handler.go index be466cfed..3fff61800 100644 --- a/services/agents-api/internal/api/handler.go +++ b/services/agents-api/internal/api/handler.go @@ -155,7 +155,7 @@ func (h *Handler) routes() *chi.Mux { // createSession atomically reserves or admits initial text with the Session. // @Summary Create an execution Session -// @Description The optional Core model_provider bundle resolves from the Session override, saved Agent defaults, then, for openai_hosted and none, the deployment default of the resolved harness; self_hosted never uses the deployment default and none accepts only it. openai_hosted and self_hosted Sessions that resolve no bundle return 400 model_provider_required with param x_agents_core.model_provider before any write. Core encrypts and freezes the resolved bundle; later Agent or deployment default edits and same-key retries cannot change it. Keys are never returned. Supports inline configuration or a tenant-owned saved agent_id with per-Session field replacements. Execution supports model/instructions, text verbosity, non-deferred function tools, adapter-qualified multi_agent with persisted Subagent reads, implicit reasoning, service tier auto and environment type none, subject to the configured engine. Codex additionally supports HTTP MCP with service origin (omitted or null on HTTP transport is saved as service), native allowed_tools and boolean required defaulting to false. Session vault_ids attach only project-owned Vaults; credential_id selects an attached static bearer or OAuth credential for the exact HTTPS URL, while null/omission selects a unique match or remains anonymous. Session reads, lists and event snapshots show that implicitly selected credential ID in a null or omitted credential_id, also after the credential is deleted; anonymous selections stay null and the stored caller intent is unchanged. After the input requirement and before any write, a credential_id without vault_ids, one outside the attached Vaults (one message for missing, foreign and unattached IDs) or one for another server_url returns 400 invalid_request_error, and several implicit matches return 409 conflict_error. Missing decryption configuration fails dispatch without anonymous fallback. Required initialization uses native startup before the first native Turn, including cold resume, and requires a separately advertised capability; exact hosted creation timing and error parity remain unverified. Other MCP origins and native OAuth login remain unsupported. The self_hosted profile requires Codex, an absolute workspace_directory and empty capability_directories, with optional non-deferred function tools and HTTP MCP using service origin, optionally authenticated by the attached Vault rules. Remote MCP and remote Bearer authentication each require separately advertised combination support; old peers cannot receive unsupported work. Omitted/null capability_directories use the empty-list default; self_hosted requires configured execution plus executor registry. Claude SDK currently requires medium verbosity and object-root function schemas. It supports anonymous or attached static-bearer service-origin HTTP MCP on none with boolean required and separately advertised MCP/bearer/required runtime support. Required servers must be connected before the first native input is released; pending or failed startup rejects execution. The shared Vault selection and immutable binding rules apply; unsupported native labels/tool names reject before persistence. An attached Vault with no matching credential may remain anonymous; missing keys or failed credential lookup/decryption never fall back to anonymous execution. Omitted stream defaults to false; stream and agent_id cannot be null. Metadata may be null; non-string values and limit violations return invalid_request_error with a metadata or metadata. param. The inline agent uses the Agent create configuration validation with agent.-prefixed params, reported before the input requirement and saved-Agent lookup; saved configurations with conflicting tools or schema roots reject admission with the same errors, and execution limits keep unsupported_or_invalid_configuration. Hosted network policy rejections return invalid_request_error with a null param. Initial input accepts a string or ordered user-message array. Codex and Claude SDK on none and qualified openai_hosted also accept inline PNG/JPEG image content; other image combinations and remote URLs are unsupported. None initial input atomically starts a Turn; self_hosted initial input is reserved while returning its Environment connection target, with execution deferred to native readiness and Session failure on initial timeout. Initial input is required for none and for streamed creation outside self_hosted. Omitted/null input remains valid for non-streaming hosted and self_hosted creation. With stream=true, returns live Session events starting with the committed creation snapshot and closes right after the first agent.session.idle recorded when a Turn ends or an input reservation stops being pending, or any agent.session.failed, without sending later events. A creation that admitted nothing closes after the snapshot; a settlement that records no event closes after events up to the cursor read with a settled Session projection. Required actions keep it open; disconnect does not cancel execution. The GET events stream remains live-only. New Sessions retain their authenticated creator; all creation retries require the same typed subject, including across key rotation. Saved-Agent retries and inline requests using Vault attachments or credential references retain caller intent independently of later resource changes; new hosted inline requests also freeze caller intent before deployment defaults resolve; unrelated non-hosted inline retries preserve resolved/default equivalences. Unknown historical creators reject retries; known creators without recorded intent retain resolved-snapshot retry rules. These conflict policies are local and not verified hosted parity. A same-key stream=true retry of an existing creation returns 201 with no events and closes at once; retry with stream=false or use the GET events stream to recover. Claude SDK on none and Core-managed Docker openai_hosted supports qualified object-root json_schema output with medium verbosity, single-Agent execution and ordinary functions. Hosted execution reuses native workspace tools and Files/Artifacts; Skills, Plugins, capability directories, HTTP MCP, Subagent and tool_search combinations remain unqualified, including inherited template contents. Other non-text initial input remains unsupported. Basic Codex and Claude SDK openai_hosted creation requires an explicitly configured managed provider. The Claude workspace profile supports non-deferred function tools with text or successful inline PNG/JPEG results alongside native workspace tools; HTTP MCP remains unsupported. Idle Sessions provision automatically; initial provisioning has no caller connection action. Network defaults to enabled; disabled and restricted exact ASCII hostnames are supported. Restricted policy requires 1–100 allowed domains. Unsupported hostname forms and startup installations are rejected. Confidential env, system/npm/Python packages and ordered setup commands use the shared initialization lifecycle; requested network applies after setup. Initial inline and tenant-owned file_id files freeze encrypted bytes before provisioning, then install through the common Core lifecycle before native execution or live Files access. With a template reference, omitted/null files, env, packages and setup_commands inherit. Non-null files and command lists replace; env overlays by key; each package manager inherits on omission/null and otherwise replaces its list. Empty lists clear their selected field. Tenant-owned environment_template_id references inherit omitted/null network and allow only narrowing overrides. Inline hosted network:null retains the enabled default; updating a Template with network:null resets its saved policy to enabled. Core freezes effective configuration; template updates/deletion do not alter Session snapshots or same-intent creation retries. Inline or tenant-owned skill_reference Skills share initialization. Templates preserve default/latest/explicit selectors; Session creation freezes concrete metadata and encrypted content atomically. Skill, Plugin and capability-directory list omission/null inherit; a non-null list replaces, including empty-list clearing. Omitted/null Skill version selectors resolve the default version. Source deletion/default updates cannot change committed Session Skill contents. Deferred function discovery uses type-only tool_search and per-function defer_loading in the qualified single-agent Claude environment:none function profile, including qualified inline image messages and text results. Explicit web_search mode disabled and programmatic_tool_calling enabled false use frozen common Runtime controls. Enabled forms, including those saved on an Agent, remain unqualified and reject before any write unless the Session replaces tools. Omitted programmatic configuration preserves native behavior, a documented difference from the official default-on behavior. Other combinations remain unqualified; see the operation coverage. +// @Description The optional Core model_provider bundle resolves from the Session override, saved Agent defaults, then, for openai_hosted and none, the deployment default of the resolved harness; self_hosted never uses the deployment default and none accepts only it. openai_hosted and self_hosted Sessions that resolve no bundle return 400 model_provider_required with param x_agents_core.model_provider before any write. Core encrypts and freezes the resolved bundle; later Agent or deployment default edits and same-key retries cannot change it. Keys are never returned. Supports inline configuration or a tenant-owned saved agent_id with per-Session field replacements. Execution supports model/instructions, text verbosity, non-deferred function tools, adapter-qualified multi_agent with persisted Subagent reads, implicit reasoning, service tier auto and environment type none, subject to the configured engine. Codex additionally supports HTTP MCP with service origin (omitted or null on HTTP transport is saved as service), native allowed_tools and boolean required defaulting to false. Session vault_ids attach only project-owned Vaults; credential_id selects an attached static bearer or OAuth credential for the exact HTTPS URL, while null/omission selects a unique match or remains anonymous. Session reads, lists and event snapshots show that implicitly selected credential ID in a null or omitted credential_id, also after the credential is deleted; anonymous selections stay null and the stored caller intent is unchanged. After the input requirement and before any write, a credential_id without vault_ids, one outside the attached Vaults (one message for missing, foreign and unattached IDs) or one for another server_url returns 400 invalid_request_error, and several implicit matches return 409 conflict_error. Missing decryption configuration fails dispatch without anonymous fallback. Required initialization uses native startup before the first native Turn, including cold resume, and requires a separately advertised capability; exact hosted creation timing and error parity remain unverified. Other MCP origins and native OAuth login remain unsupported. The self_hosted profile requires Codex, an absolute workspace_directory and empty capability_directories, with optional non-deferred function tools and HTTP MCP using service origin, optionally authenticated by the attached Vault rules. Remote MCP and remote Bearer authentication each require separately advertised combination support; old peers cannot receive unsupported work. Omitted/null capability_directories use the empty-list default; self_hosted requires configured execution plus executor registry. Claude SDK currently requires medium verbosity and object-root function schemas. It supports anonymous or attached static-bearer service-origin HTTP MCP on none with boolean required and separately advertised MCP/bearer/required runtime support. Required servers must be connected before the first native input is released; pending or failed startup rejects execution. The shared Vault selection and immutable binding rules apply; unsupported native labels/tool names reject before persistence. An attached Vault with no matching credential may remain anonymous; missing keys or failed credential lookup/decryption never fall back to anonymous execution. Omitted stream defaults to false; stream and agent_id cannot be null. Metadata may be null; non-string values and limit violations return invalid_request_error with a metadata or metadata. param. The inline agent uses the Agent create configuration validation with agent.-prefixed params, reported before the input requirement and saved-Agent lookup; saved configurations with conflicting tools or schema roots reject admission with the same errors, and execution limits keep unsupported_or_invalid_configuration. Hosted network policy rejections return invalid_request_error with a null param. Initial input accepts a string or ordered user-message array. Codex and Claude SDK on none and qualified openai_hosted also accept inline PNG/JPEG image content; other image combinations and remote URLs are unsupported. None initial input atomically starts a Turn; self_hosted initial input is reserved while returning its Environment connection target, with execution deferred to native readiness and Session failure on initial timeout. Initial input is required for none and for streamed creation outside self_hosted. Omitted/null input remains valid for non-streaming hosted and self_hosted creation. With stream=true, returns live Session events starting with the committed creation snapshot and closes right after the first agent.session.idle recorded when a Turn ends or an input reservation stops being pending, or any agent.session.failed, without sending later events. A creation that admitted nothing closes after the snapshot; a settlement that records no event closes after events up to the cursor read with a settled Session projection. Required actions keep it open; disconnect does not cancel execution. The GET events stream remains live-only. New Sessions retain their authenticated creator; all creation retries require the same typed subject, including across key rotation. Saved-Agent retries and inline requests using Vault attachments or credential references retain caller intent independently of later resource changes; new openai_hosted and none inline requests also freeze caller intent before deployment defaults resolve; unrelated self_hosted inline retries preserve resolved/default equivalences. Provider keys enter retry hashes only as fingerprints keyed by the credential key. Unknown historical creators reject retries; known creators without recorded intent retain resolved-snapshot retry rules. These conflict policies are local and not verified hosted parity. A same-key stream=true retry of an existing creation returns 201 with no events and closes at once; retry with stream=false or use the GET events stream to recover. Claude SDK on none and Core-managed Docker openai_hosted supports qualified object-root json_schema output with medium verbosity, single-Agent execution and ordinary functions. Hosted execution reuses native workspace tools and Files/Artifacts; Skills, Plugins, capability directories, HTTP MCP, Subagent and tool_search combinations remain unqualified, including inherited template contents. Other non-text initial input remains unsupported. Basic Codex and Claude SDK openai_hosted creation requires an explicitly configured managed provider. The Claude workspace profile supports non-deferred function tools with text or successful inline PNG/JPEG results alongside native workspace tools; HTTP MCP remains unsupported. Idle Sessions provision automatically; initial provisioning has no caller connection action. Network defaults to enabled; disabled and restricted exact ASCII hostnames are supported. Restricted policy requires 1–100 allowed domains. Unsupported hostname forms and startup installations are rejected. Confidential env, system/npm/Python packages and ordered setup commands use the shared initialization lifecycle; requested network applies after setup. Initial inline and tenant-owned file_id files freeze encrypted bytes before provisioning, then install through the common Core lifecycle before native execution or live Files access. With a template reference, omitted/null files, env, packages and setup_commands inherit. Non-null files and command lists replace; env overlays by key; each package manager inherits on omission/null and otherwise replaces its list. Empty lists clear their selected field. Tenant-owned environment_template_id references inherit omitted/null network and allow only narrowing overrides. Inline hosted network:null retains the enabled default; updating a Template with network:null resets its saved policy to enabled. Core freezes effective configuration; template updates/deletion do not alter Session snapshots or same-intent creation retries. Inline or tenant-owned skill_reference Skills share initialization. Templates preserve default/latest/explicit selectors; Session creation freezes concrete metadata and encrypted content atomically. Skill, Plugin and capability-directory list omission/null inherit; a non-null list replaces, including empty-list clearing. Omitted/null Skill version selectors resolve the default version. Source deletion/default updates cannot change committed Session Skill contents. Deferred function discovery uses type-only tool_search and per-function defer_loading in the qualified single-agent Claude environment:none function profile, including qualified inline image messages and text results. Explicit web_search mode disabled and programmatic_tool_calling enabled false use frozen common Runtime controls. Enabled forms, including those saved on an Agent, remain unqualified and reject before any write unless the Session replaces tools. Omitted programmatic configuration preserves native behavior, a documented difference from the official default-on behavior. Other combinations remain unqualified; see the operation coverage. // @Tags Sessions // @Accept json // @Produce json,text/event-stream diff --git a/services/agents-api/internal/api/session_creation_identity.go b/services/agents-api/internal/api/session_creation_identity.go index a669b678f..f1a949031 100644 --- a/services/agents-api/internal/api/session_creation_identity.go +++ b/services/agents-api/internal/api/session_creation_identity.go @@ -10,8 +10,12 @@ import ( "github.com/MiniMax-AI-Dev/parsar/services/agents-api/internal/store" ) +// sessionCreationRequest records caller intent before mutable sources resolve: +// saved Agents, templates, credentials and, wherever it may apply, the deployment +// default model provider. A retry then returns the committed Session even after +// those sources change. func sessionCreationRequest(input sessionRequest, initial []store.Input) (json.RawMessage, error) { - if (input.Environment == nil || input.Environment.Type != "openai_hosted") && input.XAgentsCore == nil && input.AgentID == nil && input.templateID == "" && len(input.initialFiles) == 0 && input.initialization.Empty() && !inlineCredentialIntent(input) && input.agentFields["x_agents_core"] == nil { + if (input.Environment == nil || !v1.ModelProviderAllowed(input.Environment.Type, v1.ModelProviderSourceDeployment)) && input.XAgentsCore == nil && input.AgentID == nil && input.templateID == "" && len(input.initialFiles) == 0 && input.initialization.Empty() && !inlineCredentialIntent(input) && input.agentFields["x_agents_core"] == nil { return nil, nil } agentID := "" diff --git a/services/agents-api/internal/api/session_creation_stream_test.go b/services/agents-api/internal/api/session_creation_stream_test.go index fc34143c3..fbce69835 100644 --- a/services/agents-api/internal/api/session_creation_stream_test.go +++ b/services/agents-api/internal/api/session_creation_stream_test.go @@ -697,6 +697,11 @@ func (eventOnlyStore) ListSessionEvents(context.Context, string, string, int64) return nil, nil } +// A none Session records caller intent, so creation looks it up first. +func (eventOnlyStore) FindSessionCreation(context.Context, string, string, json.RawMessage, identity.Subject) (store.SessionCreation, error) { + return store.SessionCreation{}, store.ErrNotFound +} + type countingStreamAdmission struct { inputRecorder calls atomic.Int32 diff --git a/services/agents-api/internal/api/session_model_defaults.go b/services/agents-api/internal/api/session_model_defaults.go index 9c3bb5f10..8e7b574fc 100644 --- a/services/agents-api/internal/api/session_model_defaults.go +++ b/services/agents-api/internal/api/session_model_defaults.go @@ -66,7 +66,7 @@ func (e *modelProviderDefaultsError) Unwrap() error { return e.err } func modelProviderRequired(environment, engine string) error { if environment == "self_hosted" { - return &modelProviderRequiredError{"self_hosted Sessions need a model provider for harness " + engine + ": pass x_agents_core.model_provider or use an Agent that has one saved. Deployment default model providers apply only to openai_hosted Sessions."} + return &modelProviderRequiredError{"self_hosted Sessions need a model provider for harness " + engine + ": pass x_agents_core.model_provider or use an Agent that has one saved. Deployment default model providers apply to openai_hosted and none Sessions, never to self_hosted."} } return &modelProviderRequiredError{"No model provider is configured for harness " + engine + ". Pass x_agents_core.model_provider, use an Agent that has one saved, or ask the Core administrator to set a deployment default model provider for " + engine + "."} } diff --git a/services/agents-api/internal/api/session_response.go b/services/agents-api/internal/api/session_response.go index c13c1b98a..18e717715 100644 --- a/services/agents-api/internal/api/session_response.go +++ b/services/agents-api/internal/api/session_response.go @@ -79,8 +79,11 @@ func sessionResponse(session store.Session, executorURL string) (v1.Session, err response.Status, response.Error = activity.Status, nil if activity.Status == "failed" { message := "The initial input timed out waiting for the environment connection." - if activity.Failure == "environment_unavailable" { + switch activity.Failure { + case "environment_unavailable": message = "The environment is no longer available for this input." + case "model_provider_required": + message = "This Session was created without a model provider and cannot run. Create a new Session with x_agents_core.model_provider or an Agent that has one saved." } response.Error = &message } diff --git a/services/agents-api/internal/credentialcrypto/cipher.go b/services/agents-api/internal/credentialcrypto/cipher.go index 56a2ece6c..c40dc0f77 100644 --- a/services/agents-api/internal/credentialcrypto/cipher.go +++ b/services/agents-api/internal/credentialcrypto/cipher.go @@ -4,6 +4,9 @@ package credentialcrypto import ( "crypto/aes" "crypto/cipher" + "crypto/hmac" + "crypto/sha256" + "encoding/hex" "encoding/json" "errors" "unicode/utf8" @@ -32,6 +35,8 @@ type Binding struct { type Cipher struct { aead cipher.AEAD + // fingerprint keys Fingerprint; it is derived from, never equal to, the key. + fingerprint []byte } // New requires a 32-byte AES key. The operator must limit each key to at most @@ -48,7 +53,26 @@ func New(key []byte) (*Cipher, error) { if err != nil { return nil, errUnavailable } - return &Cipher{aead: aead}, nil + derive := hmac.New(sha256.New, key) + derive.Write([]byte("parsar.agents-api.credential-fingerprint.v1")) + return &Cipher{aead: aead, fingerprint: derive.Sum(nil)}, nil +} + +// Fingerprint returns a keyed digest of secret for its purpose. It lets stored +// idempotency hashes tell secrets apart without hashing a secret directly, so a +// stored hash cannot be used to test guesses offline without this key. +func (c *Cipher) Fingerprint(purpose, secret string) (string, error) { + if c == nil || len(c.fingerprint) == 0 { + return "", errUnavailable + } + if purpose == "" || !utf8.ValidString(purpose) { + return "", errInvalidBinding + } + mac := hmac.New(sha256.New, c.fingerprint) + mac.Write([]byte(purpose)) + mac.Write([]byte{0}) + mac.Write([]byte(secret)) + return hex.EncodeToString(mac.Sum(nil)), nil } // Seal returns version || nonce || ciphertext || tag. The AEAD generates and diff --git a/services/agents-api/internal/credentialcrypto/cipher_test.go b/services/agents-api/internal/credentialcrypto/cipher_test.go index 37704d4e3..4a6e4bfcb 100644 --- a/services/agents-api/internal/credentialcrypto/cipher_test.go +++ b/services/agents-api/internal/credentialcrypto/cipher_test.go @@ -2,6 +2,7 @@ package credentialcrypto import ( "bytes" + "strings" "testing" ) @@ -147,3 +148,22 @@ func TestCipherRejectsInvalidConstructionAndBinding(t *testing.T) { } } } + +func TestFingerprintIsKeyedAndPurposeBound(t *testing.T) { + c, _ := New(bytes.Repeat([]byte{3}, 32)) + other, _ := New(bytes.Repeat([]byte{4}, 32)) + first, err := c.Fingerprint("model-provider", "secret-canary") + if err != nil || len(first) != 64 || strings.Contains(first, "secret-canary") { + t.Fatal("invalid fingerprint", err) + } + again, _ := c.Fingerprint("model-provider", "secret-canary") + changed, _ := c.Fingerprint("model-provider", "secret-canary2") + purpose, _ := c.Fingerprint("other", "secret-canary") + foreign, _ := other.Fingerprint("model-provider", "secret-canary") + if again != first || changed == first || purpose == first || foreign == first { + t.Fatal("fingerprint is not stable, secret-, purpose- and key-bound") + } + if _, err := (*Cipher)(nil).Fingerprint("model-provider", "secret-canary"); err == nil { + t.Fatal("fingerprint without a key") + } +} diff --git a/services/agents-api/internal/db/queries/environment_input_activity.sql b/services/agents-api/internal/db/queries/environment_input_activity.sql index 16ea36f05..013fb07b9 100644 --- a/services/agents-api/internal/db/queries/environment_input_activity.sql +++ b/services/agents-api/internal/db/queries/environment_input_activity.sql @@ -1,5 +1,5 @@ -- name: GetEnvironmentInputActivity :one -SELECT r.state, r.is_initial, r.created_at, r.settled_at, e.id AS environment_id, e.status AS connection_status, +SELECT r.state, r.is_initial, r.created_at, r.settled_at, r.failure_code, e.id AS environment_id, e.status AS connection_status, COALESCE(s.configuration->'environment'->>'type', '')::text AS environment_type FROM environments e JOIN sessions s ON s.id = e.session_id diff --git a/services/agents-api/internal/db/queries/environment_inputs.sql b/services/agents-api/internal/db/queries/environment_inputs.sql index de509300a..e39d41236 100644 --- a/services/agents-api/internal/db/queries/environment_inputs.sql +++ b/services/agents-api/internal/db/queries/environment_inputs.sql @@ -40,3 +40,8 @@ WHERE session_id = $1 AND state = 'pending'; -- name: FailSessionEnvironmentInput :exec UPDATE environment_input_reservations SET state = 'failed', settled_at = clock_timestamp() WHERE session_id = $1 AND state = 'pending'; + +-- name: FailEnvironmentInputWithoutModelProvider :execrows +UPDATE environment_input_reservations +SET state = 'failed', settled_at = clock_timestamp(), failure_code = 'model_provider_required' +WHERE session_id = @session_id AND id = @id AND state = 'pending'; diff --git a/services/agents-api/internal/db/sqlc/environment_input_activity.sql.go b/services/agents-api/internal/db/sqlc/environment_input_activity.sql.go index 9d2f3ac4a..a9609eb7f 100644 --- a/services/agents-api/internal/db/sqlc/environment_input_activity.sql.go +++ b/services/agents-api/internal/db/sqlc/environment_input_activity.sql.go @@ -12,12 +12,12 @@ import ( ) const getEnvironmentInputActivity = `-- name: GetEnvironmentInputActivity :one -SELECT r.state, r.is_initial, r.created_at, r.settled_at, e.id AS environment_id, e.status AS connection_status, +SELECT r.state, r.is_initial, r.created_at, r.settled_at, r.failure_code, e.id AS environment_id, e.status AS connection_status, COALESCE(s.configuration->'environment'->>'type', '')::text AS environment_type FROM environments e JOIN sessions s ON s.id = e.session_id JOIN LATERAL ( - SELECT id, session_id, idempotency_key, batch, state, created_at, deadline, settled_at, is_initial FROM environment_input_reservations + SELECT id, session_id, idempotency_key, batch, state, created_at, deadline, settled_at, is_initial, failure_code FROM environment_input_reservations WHERE session_id = e.session_id ORDER BY created_at DESC, id DESC LIMIT 1 ) r ON true @@ -33,6 +33,7 @@ type GetEnvironmentInputActivityRow struct { IsInitial bool `json:"is_initial"` CreatedAt pgtype.Timestamptz `json:"created_at"` SettledAt pgtype.Timestamptz `json:"settled_at"` + FailureCode pgtype.Text `json:"failure_code"` EnvironmentID pgtype.UUID `json:"environment_id"` ConnectionStatus string `json:"connection_status"` EnvironmentType string `json:"environment_type"` @@ -46,6 +47,7 @@ func (q *Queries) GetEnvironmentInputActivity(ctx context.Context, sessionID pgt &i.IsInitial, &i.CreatedAt, &i.SettledAt, + &i.FailureCode, &i.EnvironmentID, &i.ConnectionStatus, &i.EnvironmentType, diff --git a/services/agents-api/internal/db/sqlc/environment_inputs.sql.go b/services/agents-api/internal/db/sqlc/environment_inputs.sql.go index bf3c88b87..2a3d7edb2 100644 --- a/services/agents-api/internal/db/sqlc/environment_inputs.sql.go +++ b/services/agents-api/internal/db/sqlc/environment_inputs.sql.go @@ -55,7 +55,7 @@ const createEnvironmentInputReservation = `-- name: CreateEnvironmentInputReserv WITH accepted AS (SELECT clock_timestamp() AS at) INSERT INTO environment_input_reservations(id, session_id, idempotency_key, batch, is_initial, created_at, deadline) SELECT $1, $2, $3, $4, $5, at, at + interval '5 minutes' FROM accepted -RETURNING id, session_id, idempotency_key, batch, state, created_at, deadline, settled_at, is_initial +RETURNING id, session_id, idempotency_key, batch, state, created_at, deadline, settled_at, is_initial, failure_code ` type CreateEnvironmentInputReservationParams struct { @@ -85,6 +85,7 @@ func (q *Queries) CreateEnvironmentInputReservation(ctx context.Context, arg Cre &i.Deadline, &i.SettledAt, &i.IsInitial, + &i.FailureCode, ) return i, err } @@ -104,6 +105,25 @@ func (q *Queries) ExpireEnvironmentInputReservation(ctx context.Context, arg Exp return err } +const failEnvironmentInputWithoutModelProvider = `-- name: FailEnvironmentInputWithoutModelProvider :execrows +UPDATE environment_input_reservations +SET state = 'failed', settled_at = clock_timestamp(), failure_code = 'model_provider_required' +WHERE session_id = $1 AND id = $2 AND state = 'pending' +` + +type FailEnvironmentInputWithoutModelProviderParams struct { + SessionID pgtype.UUID `json:"session_id"` + ID pgtype.UUID `json:"id"` +} + +func (q *Queries) FailEnvironmentInputWithoutModelProvider(ctx context.Context, arg FailEnvironmentInputWithoutModelProviderParams) (int64, error) { + result, err := q.db.Exec(ctx, failEnvironmentInputWithoutModelProvider, arg.SessionID, arg.ID) + if err != nil { + return 0, err + } + return result.RowsAffected(), nil +} + const failSessionEnvironmentInput = `-- name: FailSessionEnvironmentInput :exec UPDATE environment_input_reservations SET state = 'failed', settled_at = clock_timestamp() WHERE session_id = $1 AND state = 'pending' @@ -115,7 +135,7 @@ func (q *Queries) FailSessionEnvironmentInput(ctx context.Context, sessionID pgt } const findEnvironmentInputReservation = `-- name: FindEnvironmentInputReservation :one -SELECT r.id, r.session_id, r.idempotency_key, r.batch, r.state, r.created_at, r.deadline, r.settled_at, r.is_initial, r.batch = $1::jsonb AS matches +SELECT r.id, r.session_id, r.idempotency_key, r.batch, r.state, r.created_at, r.deadline, r.settled_at, r.is_initial, r.failure_code, r.batch = $1::jsonb AS matches FROM environment_input_reservations r WHERE r.session_id = $2 AND r.idempotency_key = $3 ` @@ -144,13 +164,14 @@ func (q *Queries) FindEnvironmentInputReservation(ctx context.Context, arg FindE &i.EnvironmentInputReservation.Deadline, &i.EnvironmentInputReservation.SettledAt, &i.EnvironmentInputReservation.IsInitial, + &i.EnvironmentInputReservation.FailureCode, &i.Matches, ) return i, err } const getEnvironmentInputReservation = `-- name: GetEnvironmentInputReservation :one -SELECT id, session_id, idempotency_key, batch, state, created_at, deadline, settled_at, is_initial FROM environment_input_reservations +SELECT id, session_id, idempotency_key, batch, state, created_at, deadline, settled_at, is_initial, failure_code FROM environment_input_reservations WHERE session_id = $1 AND id = $2 ` @@ -172,6 +193,7 @@ func (q *Queries) GetEnvironmentInputReservation(ctx context.Context, arg GetEnv &i.Deadline, &i.SettledAt, &i.IsInitial, + &i.FailureCode, ) return i, err } @@ -179,7 +201,7 @@ func (q *Queries) GetEnvironmentInputReservation(ctx context.Context, arg GetEnv const settleEnvironmentInputReservation = `-- name: SettleEnvironmentInputReservation :one UPDATE environment_input_reservations SET state = $1, settled_at = clock_timestamp() WHERE session_id = $2 AND id = $3 AND state = 'pending' -RETURNING id, session_id, idempotency_key, batch, state, created_at, deadline, settled_at, is_initial +RETURNING id, session_id, idempotency_key, batch, state, created_at, deadline, settled_at, is_initial, failure_code ` type SettleEnvironmentInputReservationParams struct { @@ -201,6 +223,7 @@ func (q *Queries) SettleEnvironmentInputReservation(ctx context.Context, arg Set &i.Deadline, &i.SettledAt, &i.IsInitial, + &i.FailureCode, ) return i, err } diff --git a/services/agents-api/internal/db/sqlc/models.go b/services/agents-api/internal/db/sqlc/models.go index 62d7e0ad6..e0b913ca3 100644 --- a/services/agents-api/internal/db/sqlc/models.go +++ b/services/agents-api/internal/db/sqlc/models.go @@ -123,6 +123,7 @@ type EnvironmentInputReservation struct { Deadline pgtype.Timestamptz `json:"deadline"` SettledAt pgtype.Timestamptz `json:"settled_at"` IsInitial bool `json:"is_initial"` + FailureCode pgtype.Text `json:"failure_code"` } type EnvironmentSetup struct { diff --git a/services/agents-api/internal/execution/prepared_dispatch.go b/services/agents-api/internal/execution/prepared_dispatch.go index acf024e96..453d85745 100644 --- a/services/agents-api/internal/execution/prepared_dispatch.go +++ b/services/agents-api/internal/execution/prepared_dispatch.go @@ -6,6 +6,7 @@ import ( "errors" "strings" + v1 "github.com/MiniMax-AI-Dev/parsar/contracts/agents-api/v1" "github.com/MiniMax-AI-Dev/parsar/internal/agentdaemon/proto" "github.com/MiniMax-AI-Dev/parsar/services/agents-api/internal/store" ) @@ -36,6 +37,10 @@ func (d *Dispatcher) RunEnvironmentInput(ctx context.Context, tenantID, sessionI if json.Unmarshal(session.Configuration, &snapshot) != nil || snapshot.Daemon != nil || strings.TrimSpace(snapshot.Agent.Model) == "" { return run, store.ErrInvalidInput } + if !snapshot.ModelProviderConfigured && snapshot.Environment != nil && v1.ModelProviderRequired(snapshot.Environment.Type) { + // Reserved before providers were required; the caller settles it as failed. + return run, store.ErrModelProviderRequired + } bound, err := d.Store.GetSessionExecutionBinding(ctx, tenantID, sessionID) if err != nil { return run, err diff --git a/services/agents-api/internal/execution/worker_schedule.go b/services/agents-api/internal/execution/worker_schedule.go index dcc0f11a6..2a9348d21 100644 --- a/services/agents-api/internal/execution/worker_schedule.go +++ b/services/agents-api/internal/execution/worker_schedule.go @@ -89,6 +89,9 @@ func (w *Worker) runEnvironmentInput(ctx context.Context, item scheduledWork) er if err == nil { return nil } + if errors.Is(err, store.ErrModelProviderRequired) { + return w.dispatcher.Store.FailEnvironmentInputWithoutModelProvider(ctx, item.TenantID, item.SessionID, item.reservationID) + } if run.Reservation.State == store.EnvironmentInputAdmitted { return err } diff --git a/services/agents-api/internal/store/deployment_model_providers_http_test.go b/services/agents-api/internal/store/deployment_model_providers_http_test.go index fbbe0c24f..40b76153e 100644 --- a/services/agents-api/internal/store/deployment_model_providers_http_test.go +++ b/services/agents-api/internal/store/deployment_model_providers_http_test.go @@ -2,17 +2,19 @@ package store_test import ( "bytes" - "context" "encoding/json" "errors" "net/http/httptest" "strings" "testing" + "time" + v1 "github.com/MiniMax-AI-Dev/parsar/contracts/agents-api/v1" "github.com/MiniMax-AI-Dev/parsar/internal/agentdaemon/device" + "github.com/MiniMax-AI-Dev/parsar/internal/agentdaemon/proto" + "github.com/MiniMax-AI-Dev/parsar/services/agents-api/internal/adminaudit" "github.com/MiniMax-AI-Dev/parsar/services/agents-api/internal/api" "github.com/MiniMax-AI-Dev/parsar/services/agents-api/internal/credentialcrypto" - "github.com/MiniMax-AI-Dev/parsar/services/agents-api/internal/execution" "github.com/MiniMax-AI-Dev/parsar/services/agents-api/internal/store" "github.com/google/uuid" ) @@ -133,7 +135,7 @@ func TestDeploymentModelProvidersHTTP(t *testing.T) { // Self-hosted Sessions take the request or saved Agent bundle, never the default. failure := call("POST", "/v1/agents/sessions", projectKey, selfHosted, 400) - if !strings.Contains(string(failure["error"]), "apply only to openai_hosted") { + if !strings.Contains(string(failure["error"]), "never to self_hosted") { t.Fatalf("self-hosted Session used or misreported the deployment default: %s", failure["error"]) } requestProvider := strings.TrimSuffix(selfHosted, "}") + `,"x_agents_core":{"model_provider":{"protocol":"responses","base_url":"https://session.example/v1","api_key":"session-canary"}}}` @@ -161,7 +163,8 @@ func TestDeploymentModelProvidersHTTP(t *testing.T) { } // A hosted or self-hosted Session created before providers were required has -// no frozen provider: new work is rejected before anything is queued. +// no frozen provider: new work is rejected before anything is queued, and input +// reserved before the upgrade fails with that reason instead of waiting. func TestLegacySessionWithoutProviderCannotStartWork(t *testing.T) { h := newDispatchHarnessForSession(t, []byte(`{"agent":{"model":"test-model"},"environment":{"type":"self_hosted","workspace_directory":"/workspace"}}`), true) legacy, err := h.s.CreateSession(t.Context(), h.tenant, store.CreateSessionInput{Creator: store.FixtureCreator(), Engine: "codex", IdempotencyKey: uuid.NewString(), @@ -169,22 +172,94 @@ func TestLegacySessionWithoutProviderCannotStartWork(t *testing.T) { if err != nil { t.Fatal(err) } - worker, err := execution.StartWorker(t.Context(), h.d) + executor := connectFixtureRuntime(t, h, legacy) + // Reserved directly, as a pre-upgrade Core did. + pending, err := h.s.ReserveEnvironmentInput(t.Context(), h.tenant, legacy.ID, "before-upgrade", []store.Input{{Kind: "message", Payload: json.RawMessage(`{"text":"old"}`)}}) if err != nil { t.Fatal(err) } - t.Cleanup(func() { - ctx, cancel := context.WithCancel(context.Background()) - cancel() - _ = worker.Run(ctx) - }) + worker, stop := startEnvironmentExpiryWorker(t, h.d) + defer stop() message := []store.Input{{Kind: "message", Payload: json.RawMessage(`{"text":"start"}`)}} if _, err := worker.SubmitInputs(t.Context(), h.tenant, legacy.ID, uuid.NewString(), message); !errors.Is(err, store.ErrModelProviderRequired) { t.Fatal("provider-free Session accepted work", err) } - _, pool := store.NewTestStore(t) - var reservations int - if err := pool.QueryRow(t.Context(), "SELECT count(*) FROM environment_input_reservations WHERE session_id=$1", legacy.ID).Scan(&reservations); err != nil || reservations != 0 { - t.Fatal("rejected work was queued", reservations, err) + awaitDaemonRemoteCondition(t, t.Context(), 5*time.Second, "legacy reservation settled", func() bool { + got, err := h.s.GetEnvironmentInputReservation(t.Context(), h.tenant, legacy.ID, pending.ID) + return err == nil && got.State == store.EnvironmentInputFailed + }) + session, err := h.s.GetSession(t.Context(), h.tenant, legacy.ID) + if err != nil || session.EnvironmentInputActivity == nil || session.EnvironmentInputActivity.Failure != "model_provider_required" { + t.Fatal("legacy reservation did not fail with its reason", session.EnvironmentInputActivity, err) + } + _ = executor.conn.SetReadDeadline(time.Now().Add(500 * time.Millisecond)) + for { + var frame proto.Envelope + if executor.conn.ReadJSON(&frame) != nil { + break + } + if frame.Type == proto.TypeExecutionPrepare || frame.Type == proto.TypePromptRequest { + t.Fatal("provider-free work reached the executor", frame.Type) + } + } +} + +// A none Session may freeze the deployment default, so its caller intent is +// recorded first: a same-key retry returns the committed Session after the +// default was replaced or removed. +func TestNoneSessionRetryAfterDeploymentDefaultChanges(t *testing.T) { + st, pool := store.NewModelTestStore(t) + if _, err := pool.Exec(t.Context(), "DELETE FROM deployment_model_providers"); err != nil { + t.Fatal(err) + } + tenant, token := uuid.NewString(), uuid.NewString() + auth, err := newTestAuthenticator([]testAPIKey{{OrganizationID: "test-org", ProjectID: uuid.NewString(), SubjectKind: "service_account", SubjectID: "none-retry", TokenSHA256: device.HashCredential(token), TenantID: tenant}}) + if err != nil { + t.Fatal(err) + } + handler, err := api.NewHandler(st, auth, "codex", api.WithExecution(st), api.WithModelProviderDefaults(st.DeploymentModelProvider)) + if err != nil { + t.Fatal(err) + } + admin := adminaudit.WithSource(t.Context(), adminaudit.Source{CredentialID: "abcd1234", RequestID: "none-retry", TraceID: "none-retry"}) + setDefault := func(key string) { + t.Helper() + if _, err := st.SetDeploymentModelProvider(admin, "codex", v1.ModelProviderInput{Protocol: "responses", BaseURL: "https://deployment.example/v1", APIKey: key}); err != nil { + t.Fatal(err) + } + } + create := func(key string) string { + t.Helper() + r := httptest.NewRequest("POST", "/v1/agents/sessions", strings.NewReader(`{"agent":{"model":"m"},"environment":{"type":"none"},"input":"hello"}`)) + r.Header.Set("Authorization", "Bearer "+token) + r.Header.Set("OpenAI-Beta", "agents=v1") + r.Header.Set("Content-Type", "application/json") + r.Header.Set("Idempotency-Key", key) + w := httptest.NewRecorder() + handler.ServeHTTP(w, r) + var session struct{ ID string } + if w.Code != 201 || json.Unmarshal(w.Body.Bytes(), &session) != nil { + t.Fatalf("creation %d: %s", w.Code, w.Body) + } + return session.ID + } + setDefault("first-default-key") + key := uuid.NewString() + original := create(key) + if provider, err := st.SessionModelExecution(t.Context(), tenant, original); err != nil || provider.APIKey != "first-default-key" { + t.Fatal("none Session did not freeze the deployment default", err) + } + setDefault("rotated-default-key") + if create(key) != original { + t.Fatal("retry after rotation created another Session") + } + if err := st.DeleteDeploymentModelProvider(admin, "codex"); err != nil { + t.Fatal(err) + } + if create(key) != original { + t.Fatal("retry after removal created another Session") + } + if provider, err := st.SessionModelExecution(t.Context(), tenant, original); err != nil || provider.APIKey != "first-default-key" { + t.Fatal("retry changed the frozen provider", err) } } diff --git a/services/agents-api/internal/store/environment_input_activity.go b/services/agents-api/internal/store/environment_input_activity.go index 2750709d7..31fe75d11 100644 --- a/services/agents-api/internal/store/environment_input_activity.go +++ b/services/agents-api/internal/store/environment_input_activity.go @@ -44,6 +44,9 @@ func environmentInputState(ctx context.Context, q *sqlc.Queries, session pgtype. } if row.State == EnvironmentInputFailed { activity.Status, activity.Failure = "failed", "environment_unavailable" + if row.FailureCode.Valid { + activity.Failure = row.FailureCode.String + } } if row.IsInitial && row.State == EnvironmentInputExpired { activity.Status = "failed" diff --git a/services/agents-api/internal/store/environment_inputs.go b/services/agents-api/internal/store/environment_inputs.go index a57423924..b53f174d4 100644 --- a/services/agents-api/internal/store/environment_inputs.go +++ b/services/agents-api/internal/store/environment_inputs.go @@ -174,6 +174,20 @@ func (s *Store) CancelEnvironmentInput(ctx context.Context, tenantID, sessionID, return s.settleEnvironmentInput(ctx, tenantID, sessionID, reservationID, EnvironmentInputCancelled) } +// FailEnvironmentInputWithoutModelProvider settles pending input of a Session +// that has no frozen model provider, reserved before providers were required, +// instead of letting it wait for its deadline. Settled input is unchanged. +func (s *Store) FailEnvironmentInputWithoutModelProvider(ctx context.Context, tenantID, sessionID, reservationID string) error { + id, err := parseID(reservationID) + if err != nil { + return err + } + return s.withEnvironmentInputSession(ctx, tenantID, sessionID, func(ctx context.Context, q *sqlc.Queries, session pgtype.UUID) error { + _, err := q.FailEnvironmentInputWithoutModelProvider(ctx, sqlc.FailEnvironmentInputWithoutModelProviderParams{SessionID: session, ID: id}) + return err + }) +} + func (s *Store) ExpireEnvironmentInput(ctx context.Context, tenantID, sessionID, reservationID string) (EnvironmentInputReservation, error) { return s.settleEnvironmentInput(ctx, tenantID, sessionID, reservationID, EnvironmentInputExpired) } diff --git a/services/agents-api/internal/store/mcp_credential_selection_public_test.go b/services/agents-api/internal/store/mcp_credential_selection_public_test.go index cf755bb16..ed8de2827 100644 --- a/services/agents-api/internal/store/mcp_credential_selection_public_test.go +++ b/services/agents-api/internal/store/mcp_credential_selection_public_test.go @@ -175,11 +175,19 @@ func TestMCPCredentialSelectionPublicPostgres(t *testing.T) { t.Fatalf("Session origin case %d: %d %s", index, created.status, created.body) } } - // A Session created with an explicit origin, as before this change, recovers - // from a same-key retry that omits it: the resolved configuration is equal. - if retry := send(tokenA, http.MethodPost, "/v1/agents/sessions", inline(tool("records", url, ""), ""), "origin-explicit"); retry.status != http.StatusCreated || retry.body != explicitSession.body { + // A none Session records caller intent, so a same-key retry that omits the + // explicit origin is a different request. + if retry := send(tokenA, http.MethodPost, "/v1/agents/sessions", inline(tool("records", url, ""), ""), "origin-explicit"); retry.status != http.StatusConflict || !strings.Contains(retry.body, `"code":"idempotency_conflict"`) { t.Fatal("same-key retry without origin", retry.status, retry.body) } + // A Session created before intent was recorded, with an explicit origin, + // recovers from a same-key retry that omits it: the resolved configuration is equal. + if _, err := pool.Exec(t.Context(), "UPDATE sessions SET creation_request_hash = NULL WHERE id = $1", idOf(explicitSession.body)); err != nil { + t.Fatal(err) + } + if retry := send(tokenA, http.MethodPost, "/v1/agents/sessions", inline(tool("records", url, ""), ""), "origin-explicit"); retry.status != http.StatusCreated || retry.body != explicitSession.body { + t.Fatal("historical same-key retry without origin", retry.status, retry.body) + } // Recorded caller intent (attached Vaults) keeps comparing the request itself. attachedExplicit := send(tokenA, http.MethodPost, "/v1/agents/sessions", inline(tool("records", url, `,"connection_origin":"service"`), vaults(attachedA)), "origin-attached") if attachedExplicit.status != http.StatusCreated { diff --git a/services/agents-api/internal/store/prepared_dispatch_test.go b/services/agents-api/internal/store/prepared_dispatch_test.go index b46643c5f..8f45995ac 100644 --- a/services/agents-api/internal/store/prepared_dispatch_test.go +++ b/services/agents-api/internal/store/prepared_dispatch_test.go @@ -3,6 +3,7 @@ package store_test import ( "context" "encoding/json" + "strings" "testing" "time" @@ -144,3 +145,39 @@ func TestPreparedDispatchOwnerOutlivesReservationDeadline(t *testing.T) { } assertPreparationReleased(t, h, frame.ID, handle) } + +// A self-hosted Session's frozen provider travels only in the preparation sent +// to the executor bound to that Session, not to another executor of the tenant. +func TestSelfHostedProviderReachesOnlyBoundExecutor(t *testing.T) { + h, pending := preparedDispatchHarness(t) + other, err := h.s.CreateSession(t.Context(), h.tenant, store.WithFixtureModelProvider(store.CreateSessionInput{Creator: store.FixtureCreator(), Engine: "codex", IdempotencyKey: uuid.NewString(), + Configuration: json.RawMessage(`{"agent":{"model":"test-model"},"environment":{"type":"self_hosted","workspace_directory":"/workspace"}}`)})) + if err != nil { + t.Fatal(err) + } + bystander := connectFixtureRuntime(t, h, other) + ctx, cancel := context.WithCancel(t.Context()) + result := runPreparedDispatch(h, ctx, pending) + frame := h.read(proto.TypeExecutionPrepare) + var prepare proto.ExecutionPreparePayload + if frame.DecodePayload(&prepare) != nil { + t.Fatal("invalid preparation") + } + provider, _ := prepare.Configuration.AgentOptions["codex_provider"].(map[string]any) + fixture := store.FixtureModelProvider("codex") + if provider["base_url"] != fixture.BaseURL || provider["bearer_token"] != fixture.APIKey { + t.Fatal("bound executor did not receive the frozen provider", prepare.Configuration.AgentOptions) + } + _ = bystander.conn.SetReadDeadline(time.Now().Add(500 * time.Millisecond)) + for { + var raw json.RawMessage + if bystander.conn.ReadJSON(&raw) != nil { + break + } + if strings.Contains(string(raw), fixture.APIKey) || strings.Contains(string(raw), proto.TypeExecutionPrepare) { + t.Fatal("another executor received the provider") + } + } + cancel() + awaitPreparedDispatch(t, result) +} diff --git a/services/agents-api/internal/store/session_creation_identity.go b/services/agents-api/internal/store/session_creation_identity.go index ae58c48af..347cc00c0 100644 --- a/services/agents-api/internal/store/session_creation_identity.go +++ b/services/agents-api/internal/store/session_creation_identity.go @@ -9,19 +9,62 @@ import ( "fmt" "strings" + v1 "github.com/MiniMax-AI-Dev/parsar/contracts/agents-api/v1" "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/jackc/pgx/v5" "github.com/jackc/pgx/v5/pgtype" ) -func creationRequestHash(raw json.RawMessage) (pgtype.Text, error) { +// modelProviderKeyPurpose separates model provider key fingerprints from any +// other use of the credential key. +const modelProviderKeyPurpose = "parsar.agents-api.model-provider-api-key.v1" + +// fingerprintedProvider replaces a bundle's key with its keyed fingerprint, so +// idempotency hashes tell keys apart without ever hashing a key itself. +func (s *Store) fingerprintedProvider(provider *v1.ModelProviderInput) (*v1.ModelProviderInput, error) { + if provider == nil { + return nil, nil + } + fingerprint, err := s.credentialCipher.Fingerprint(modelProviderKeyPurpose, provider.APIKey) + if err != nil { + return nil, ErrCredentialStorageUnavailable + } + copy := *provider + copy.APIKey = "fingerprint:" + fingerprint + return ©, nil +} + +// withoutProviderKey replaces a caller intent's x_agents_core.model_provider.api_key +// with its keyed fingerprint. Requests without a key are returned unchanged. +func (s *Store) withoutProviderKey(raw json.RawMessage) (json.RawMessage, error) { + var request, extension, provider map[string]json.RawMessage + var key string + if json.Unmarshal(raw, &request) != nil || json.Unmarshal(request["x_agents_core"], &extension) != nil || + json.Unmarshal(extension["model_provider"], &provider) != nil || json.Unmarshal(provider["api_key"], &key) != nil { + return raw, nil + } + fingerprinted, err := s.fingerprintedProvider(&v1.ModelProviderInput{APIKey: key}) + if err != nil { + return nil, err + } + provider["api_key"], _ = json.Marshal(fingerprinted.APIKey) + extension["model_provider"], _ = json.Marshal(provider) + request["x_agents_core"], _ = json.Marshal(extension) + return json.Marshal(request) +} + +func (s *Store) creationRequestHash(raw json.RawMessage) (pgtype.Text, error) { if len(raw) == 0 { return pgtype.Text{}, nil } if len(raw) > 16<<20 { return pgtype.Text{}, ErrInvalidInput } + raw, err := s.withoutProviderKey(raw) + if err != nil { + return pgtype.Text{}, err + } canonical, err := canonicalJSONObject(raw) if err != nil { return pgtype.Text{}, err @@ -42,7 +85,13 @@ func (s *Store) FindSessionCreation(ctx context.Context, tenantID, key string, r if strings.TrimSpace(key) == "" || len(key) > 128 { return SessionCreation{}, ErrInvalidInput } - hash, err := creationRequestHash(request) + hash, err := s.creationRequestHash(request) + if errors.Is(err, ErrCredentialStorageUnavailable) { + // Without the credential key no Session with a provider bundle can have + // been committed or can be created; creation reports the missing key + // after request validation. + return SessionCreation{}, ErrNotFound + } if err != nil { return SessionCreation{}, err } diff --git a/services/agents-api/internal/store/sessions.go b/services/agents-api/internal/store/sessions.go index 7ab09eeda..d2c784f99 100644 --- a/services/agents-api/internal/store/sessions.go +++ b/services/agents-api/internal/store/sessions.go @@ -152,6 +152,11 @@ func (s *Store) createSession(ctx context.Context, tenantID string, input Create if !input.Initialization.Empty() { initialization = &input.Initialization } + // The retry identity carries the provider key only as a keyed fingerprint. + fingerprinted, err := s.fingerprintedProvider(input.ModelProvider) + if err != nil { + return SessionCreation{}, err + } // JSON map keys are sorted by encoding/json, so key order does not affect retries. canonical, err := json.Marshal(struct { ModelProvider *v1.ModelProviderInput `json:",omitempty"` @@ -161,11 +166,11 @@ 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.ModelProvider, input.Engine, input.Metadata, configuration, encodedInput, input.InitialFiles, initialization}) + }{fingerprinted, input.Engine, input.Metadata, configuration, encodedInput, input.InitialFiles, initialization}) if err != nil { return SessionCreation{}, fmt.Errorf("%w: input: %v", ErrInvalidInput, err) } - creationHash, err := creationRequestHash(input.CreationRequest) + creationHash, err := s.creationRequestHash(input.CreationRequest) if err != nil { return SessionCreation{}, err } diff --git a/services/agents-api/migrations/000075_deployment_model_providers.sql b/services/agents-api/migrations/000075_deployment_model_providers.sql index 31d51e8aa..8c6064619 100644 --- a/services/agents-api/migrations/000075_deployment_model_providers.sql +++ b/services/agents-api/migrations/000075_deployment_model_providers.sql @@ -16,14 +16,23 @@ ALTER TABLE admin_audit_log ALTER COLUMN tenant_id DROP NOT NULL; ALTER TABLE admin_audit_log ALTER COLUMN project_id DROP NOT NULL; ALTER TABLE admin_audit_log ADD CONSTRAINT admin_audit_log_scope_check CHECK ((tenant_id IS NULL) = (project_id IS NULL)); +-- Input reserved before providers were required fails with this reason instead +-- of waiting for its deadline. +ALTER TABLE environment_input_reservations ADD COLUMN failure_code text + CHECK (failure_code IS NULL OR (state = 'failed' AND failure_code = 'model_provider_required')); + -- +goose Down -- +goose StatementBegin DO $$ BEGIN IF EXISTS (SELECT 1 FROM admin_audit_log WHERE project_id IS NULL) THEN RAISE EXCEPTION 'Deployment-wide audit entries prevent this downgrade; restore a complete database backup instead.'; END IF; + IF EXISTS (SELECT 1 FROM deployment_model_providers) THEN + RAISE EXCEPTION 'Deployment model providers prevent this downgrade; remove them or restore a complete database backup instead.'; + END IF; END $$; -- +goose StatementEnd +ALTER TABLE environment_input_reservations DROP COLUMN failure_code; ALTER TABLE admin_audit_log DROP CONSTRAINT admin_audit_log_scope_check; ALTER TABLE admin_audit_log ALTER COLUMN project_id SET NOT NULL; ALTER TABLE admin_audit_log ALTER COLUMN tenant_id SET NOT NULL; From b6373d1059104c12932ec6d8eb34c1eebcd135d8 Mon Sep 17 00:00:00 2001 From: SaladDay <1203511142@qq.com> Date: Fri, 25 Sep 2026 20:00:33 +0000 Subject: [PATCH 4/6] Test that intent is always recorded where the deployment default applies The vault intent unit test assumed a none Session without Vaults records no caller intent. none and openai_hosted now always record it; the unrelated inline identity case moves to self_hosted, where the default never applies. --- .../internal/api/session_credentials_test.go | 16 +++++++++++++++- 1 file changed, 15 insertions(+), 1 deletion(-) diff --git a/services/agents-api/internal/api/session_credentials_test.go b/services/agents-api/internal/api/session_credentials_test.go index 07b1a11e3..a1a5d5c45 100644 --- a/services/agents-api/internal/api/session_credentials_test.go +++ b/services/agents-api/internal/api/session_credentials_test.go @@ -25,7 +25,7 @@ func TestMCPCredentialReferenceIsSchemaNotAuthorization(t *testing.T) { func TestSessionVaultTypesAndCreationIntent(t *testing.T) { for _, field := range []string{"", `,"vault_ids":null`, `,"vault_ids":[]`, `,"vault_ids":["vault"]`, `,"vault_ids":[null]`, `,"vault_ids":[3]`, `,"vault_ids":{}`, `,"vault_ids":"vault"`} { var decoded decodedSessionRequest - err := json.Unmarshal([]byte(`{"agent":{"model":"model"},"environment":{"type":"none"}`+field+`}`), &decoded) + err := json.Unmarshal([]byte(`{"agent":{"model":"model"},"environment":{"type":"self_hosted","workspace_directory":"/workspace"}`+field+`}`), &decoded) if err != nil { t.Fatal(err) } @@ -42,6 +42,20 @@ func TestSessionVaultTypesAndCreationIntent(t *testing.T) { t.Fatal("attached inline intent missing or unrelated inline identity changed", err) } } + // Where the deployment default may apply, intent is always recorded. + for _, environment := range []string{`{"type":"none"}`, `{"type":"openai_hosted"}`} { + var decoded decodedSessionRequest + if err := json.Unmarshal([]byte(`{"agent":{"model":"model"},"environment":`+environment+`}`), &decoded); err != nil { + t.Fatal(err) + } + input, err := decoded.validated() + if err != nil { + t.Fatal(err) + } + if intent, err := sessionCreationRequest(input, nil); err != nil || len(intent) == 0 { + t.Fatal("deployment-default intent missing", environment, err) + } + } var request decodedSessionRequest body := `{"agent":{"model":"model","tools":[` + publicMCP + `]},"environment":{"type":"none"},"vault_ids":["vault"]}` if err := json.Unmarshal([]byte(body), &request); err != nil { From dcab9009fc979f9186be04e443981182658a0e3b Mon Sep 17 00:00:00 2001 From: SaladDay <1203511142@qq.com> Date: Fri, 25 Sep 2026 20:51:53 +0000 Subject: [PATCH 5/6] Renumber the deployment model providers migration to 000076 Main's public URL binding (#138) took 000075. --- ..._model_providers.sql => 000076_deployment_model_providers.sql} | 0 1 file changed, 0 insertions(+), 0 deletions(-) rename services/agents-api/migrations/{000075_deployment_model_providers.sql => 000076_deployment_model_providers.sql} (100%) diff --git a/services/agents-api/migrations/000075_deployment_model_providers.sql b/services/agents-api/migrations/000076_deployment_model_providers.sql similarity index 100% rename from services/agents-api/migrations/000075_deployment_model_providers.sql rename to services/agents-api/migrations/000076_deployment_model_providers.sql From e72acdd2a9b5bddef875175d24d711b0252c88d6 Mon Sep 17 00:00:00 2001 From: SaladDay <1203511142@qq.com> Date: Fri, 25 Sep 2026 21:32:11 +0000 Subject: [PATCH 6/6] Keep none retry equivalence and prove the keyed retry fingerprint P2-1: Recording raw caller intent for none Sessions broke the documented retry equivalence (omitted, null and explicit medium verbosity). none keeps the resolved-request rule again; caller intent stays hosted-only as before. The resolved hash now leaves out a deployment-sourced provider bundle and its configuration marker, so setting, rotating or removing the default does not change a none Session's retry identity. The earlier test edits for the intent-for-none rule are reverted. Review follow-ups: - withoutProviderKey fails closed: an intent whose extension or provider cannot be read is rejected, and the provider is re-encoded from its typed value with the key replaced by its keyed fingerprint. - Cipher.Fingerprint rejects a NUL in the purpose. - New test: the same request under two credential keys gets different request_hash and creation_request_hash; an unreadable provider intent is rejected. - The legacy test again asserts that a rejected submit queues nothing. - The none retry test also retries with a spelled-out default verbosity. - model-execution.md notes that key-bearing retries first sent before this release, or after a credential key change, conflict once. --- CONTRIBUTING.md | 6 +- contracts/agents-api/model-execution.md | 15 ++-- contracts/agents-api/openapi.yaml | 75 ++++++++++--------- services/agents-api/internal/api/handler.go | 2 +- .../internal/api/session_creation_identity.go | 9 ++- .../api/session_creation_stream_test.go | 5 -- .../internal/api/session_credentials_test.go | 16 +--- .../internal/credentialcrypto/cipher.go | 4 +- .../internal/credentialcrypto/cipher_test.go | 4 + .../deployment_model_providers_http_test.go | 23 +++++- .../store/deployment_model_providers_test.go | 39 ++++++++++ .../mcp_credential_selection_public_test.go | 14 +--- .../store/session_creation_identity.go | 44 ++++++++--- .../agents-api/internal/store/sessions.go | 17 ++++- 14 files changed, 175 insertions(+), 98 deletions(-) diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index d509009c8..75526f6af 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -2245,8 +2245,10 @@ replaced; do not carry obsolete compatibility code forward to satisfy this secti options. Keep runtime dispatch on the common adapter path and fail closed for missing/decryption-failed snapshots. Agent edits/deletion, default changes, restart and idle suspend/resume never resolve defaults again. Record caller - intent for every new Session before resolving defaults; matching retries return - committed state without replay. No Turn-level overrides or provider catalog is + intent for every new hosted Session before resolving defaults; other inline + Sessions keep the resolved-request rule, whose hash leaves out the deployment + default. Provider keys enter retry hashes only as keyed fingerprints. Matching + retries return committed state without replay. No Turn-level overrides or provider catalog is included. Public input/null semantics and examples live in `contracts/agents-api/model-execution.md`. - Session execution-configuration reads use a separate immutable safe projection, diff --git a/contracts/agents-api/model-execution.md b/contracts/agents-api/model-execution.md index 8034dd4a9..90c5d325d 100644 --- a/contracts/agents-api/model-execution.md +++ b/contracts/agents-api/model-execution.md @@ -71,10 +71,11 @@ model, harness or provider. A missing/wrong encryption key fails closed. Retain same deployment credential-encryption key across restarts. V1 has no Turn override, provider catalog, Session migration or new execution loop. -Every new request that may use a deployment default (`openai_hosted` and `none`), -and every request with a saved Agent, template or provider bundle, records caller -intent before resolving mutable defaults. A retry therefore returns the committed -Session even after the deployment default was replaced or removed. Matching creation retries +All new hosted requests record caller intent before resolving mutable defaults, +including inline requests that use deployment defaults. Other inline requests, +such as `none`, keep the resolved-request retry rule; that hash leaves out a +deployment default, so setting, replacing or removing the default does not change +their retry identity. Matching creation retries recover the committed Session before resolving the Agent or provider again and do not enqueue another input. Streaming remains outside the retry identity. Existing historical rows keep their documented retry limitations; this change does not @@ -167,7 +168,11 @@ complete private snapshot. Historical `openai_hosted` and `self_hosted` Sessions created without any snapshot cannot start new work: message input returns 400 `model_provider_required`, while cancellation and history reads keep working. Input they reserved before the upgrade settles as failed with that reason, and the -Session reports it, instead of waiting for its deadline. +Session reports it, instead of waiting for its deadline. A retry of a Session that +carried its own provider key, first sent before this release, conflicts once after +the upgrade, because its retry hash now holds a keyed fingerprint instead of the +key. Key-bearing retries likewise conflict after a credential key change, which is +not supported anyway. Recreate them with a bundle. Run `deploy/install/model_provider_sessions.py` against an installation before upgrading to count them; it only reads. Historical `none` Sessions that relied on the retired options file now run with the device's diff --git a/contracts/agents-api/openapi.yaml b/contracts/agents-api/openapi.yaml index 0bf83ac51..6b4ccbeab 100644 --- a/contracts/agents-api/openapi.yaml +++ b/contracts/agents-api/openapi.yaml @@ -3006,43 +3006,44 @@ paths: live-only. New Sessions retain their authenticated creator; all creation retries require the same typed subject, including across key rotation. Saved-Agent retries and inline requests using Vault attachments or credential references - retain caller intent independently of later resource changes; new openai_hosted - and none inline requests also freeze caller intent before deployment defaults - resolve; unrelated self_hosted inline retries preserve resolved/default equivalences. - Provider keys enter retry hashes only as fingerprints keyed by the credential - key. Unknown historical creators reject retries; known creators without recorded - intent retain resolved-snapshot retry rules. These conflict policies are local - and not verified hosted parity. A same-key stream=true retry of an existing - creation returns 201 with no events and closes at once; retry with stream=false - or use the GET events stream to recover. Claude SDK on none and Core-managed - Docker openai_hosted supports qualified object-root json_schema output with - medium verbosity, single-Agent execution and ordinary functions. Hosted execution - reuses native workspace tools and Files/Artifacts; Skills, Plugins, capability - directories, HTTP MCP, Subagent and tool_search combinations remain unqualified, - including inherited template contents. Other non-text initial input remains - unsupported. Basic Codex and Claude SDK openai_hosted creation requires an - explicitly configured managed provider. The Claude workspace profile supports - non-deferred function tools with text or successful inline PNG/JPEG results - alongside native workspace tools; HTTP MCP remains unsupported. Idle Sessions - provision automatically; initial provisioning has no caller connection action. - Network defaults to enabled; disabled and restricted exact ASCII hostnames - are supported. Restricted policy requires 1–100 allowed domains. Unsupported - hostname forms and startup installations are rejected. Confidential env, system/npm/Python - packages and ordered setup commands use the shared initialization lifecycle; - requested network applies after setup. Initial inline and tenant-owned file_id - files freeze encrypted bytes before provisioning, then install through the - common Core lifecycle before native execution or live Files access. With a - template reference, omitted/null files, env, packages and setup_commands inherit. - Non-null files and command lists replace; env overlays by key; each package - manager inherits on omission/null and otherwise replaces its list. Empty lists - clear their selected field. Tenant-owned environment_template_id references - inherit omitted/null network and allow only narrowing overrides. Inline hosted - network:null retains the enabled default; updating a Template with network:null - resets its saved policy to enabled. Core freezes effective configuration; - template updates/deletion do not alter Session snapshots or same-intent creation - retries. Inline or tenant-owned skill_reference Skills share initialization. - Templates preserve default/latest/explicit selectors; Session creation freezes - concrete metadata and encrypted content atomically. Skill, Plugin and capability-directory + retain caller intent independently of later resource changes; new hosted inline + requests also freeze caller intent before deployment defaults resolve; unrelated + non-hosted inline retries preserve resolved/default equivalences, and their + resolved hash leaves out any deployment default. Provider keys enter retry + hashes only as fingerprints keyed by the credential key. Unknown historical + creators reject retries; known creators without recorded intent retain resolved-snapshot + retry rules. These conflict policies are local and not verified hosted parity. + A same-key stream=true retry of an existing creation returns 201 with no events + and closes at once; retry with stream=false or use the GET events stream to + recover. Claude SDK on none and Core-managed Docker openai_hosted supports + qualified object-root json_schema output with medium verbosity, single-Agent + execution and ordinary functions. Hosted execution reuses native workspace + tools and Files/Artifacts; Skills, Plugins, capability directories, HTTP MCP, + Subagent and tool_search combinations remain unqualified, including inherited + template contents. Other non-text initial input remains unsupported. Basic + Codex and Claude SDK openai_hosted creation requires an explicitly configured + managed provider. The Claude workspace profile supports non-deferred function + tools with text or successful inline PNG/JPEG results alongside native workspace + tools; HTTP MCP remains unsupported. Idle Sessions provision automatically; + initial provisioning has no caller connection action. Network defaults to + enabled; disabled and restricted exact ASCII hostnames are supported. Restricted + policy requires 1–100 allowed domains. Unsupported hostname forms and startup + installations are rejected. Confidential env, system/npm/Python packages and + ordered setup commands use the shared initialization lifecycle; requested + network applies after setup. Initial inline and tenant-owned file_id files + freeze encrypted bytes before provisioning, then install through the common + Core lifecycle before native execution or live Files access. With a template + reference, omitted/null files, env, packages and setup_commands inherit. Non-null + files and command lists replace; env overlays by key; each package manager + inherits on omission/null and otherwise replaces its list. Empty lists clear + their selected field. Tenant-owned environment_template_id references inherit + omitted/null network and allow only narrowing overrides. Inline hosted network:null + retains the enabled default; updating a Template with network:null resets + its saved policy to enabled. Core freezes effective configuration; template + updates/deletion do not alter Session snapshots or same-intent creation retries. + Inline or tenant-owned skill_reference Skills share initialization. Templates + preserve default/latest/explicit selectors; Session creation freezes concrete + metadata and encrypted content atomically. Skill, Plugin and capability-directory list omission/null inherit; a non-null list replaces, including empty-list clearing. Omitted/null Skill version selectors resolve the default version. Source deletion/default updates cannot change committed Session Skill contents. diff --git a/services/agents-api/internal/api/handler.go b/services/agents-api/internal/api/handler.go index 8b5046c40..1bef9bdae 100644 --- a/services/agents-api/internal/api/handler.go +++ b/services/agents-api/internal/api/handler.go @@ -157,7 +157,7 @@ func (h *Handler) routes() *chi.Mux { // createSession atomically reserves or admits initial text with the Session. // @Summary Create an execution Session -// @Description The optional Core model_provider bundle resolves from the Session override, saved Agent defaults, then, for openai_hosted and none, the deployment default of the resolved harness; self_hosted never uses the deployment default and none accepts only it. openai_hosted and self_hosted Sessions that resolve no bundle return 400 model_provider_required with param x_agents_core.model_provider before any write. Core encrypts and freezes the resolved bundle; later Agent or deployment default edits and same-key retries cannot change it. Keys are never returned. Supports inline configuration or a tenant-owned saved agent_id with per-Session field replacements. Execution supports model/instructions, text verbosity, non-deferred function tools, adapter-qualified multi_agent with persisted Subagent reads, implicit reasoning, service tier auto and environment type none, subject to the configured engine. Codex additionally supports HTTP MCP with service origin (omitted or null on HTTP transport is saved as service), native allowed_tools and boolean required defaulting to false. Session vault_ids attach only project-owned Vaults; credential_id selects an attached static bearer or OAuth credential for the exact HTTPS URL, while null/omission selects a unique match or remains anonymous. Session reads, lists and event snapshots show that implicitly selected credential ID in a null or omitted credential_id, also after the credential is deleted; anonymous selections stay null and the stored caller intent is unchanged. After the input requirement and before any write, a credential_id without vault_ids, one outside the attached Vaults (one message for missing, foreign and unattached IDs) or one for another server_url returns 400 invalid_request_error, and several implicit matches return 409 conflict_error. Missing decryption configuration fails dispatch without anonymous fallback. Required initialization uses native startup before the first native Turn, including cold resume, and requires a separately advertised capability; exact hosted creation timing and error parity remain unverified. Other MCP origins and native OAuth login remain unsupported. The self_hosted profile requires Codex, an absolute workspace_directory and empty capability_directories, with optional non-deferred function tools and HTTP MCP using service origin, optionally authenticated by the attached Vault rules. Remote MCP and remote Bearer authentication each require separately advertised combination support; old peers cannot receive unsupported work. Omitted/null capability_directories use the empty-list default; self_hosted requires configured execution plus executor registry. Claude SDK currently requires medium verbosity and object-root function schemas. It supports anonymous or attached static-bearer service-origin HTTP MCP on none with boolean required and separately advertised MCP/bearer/required runtime support. Required servers must be connected before the first native input is released; pending or failed startup rejects execution. The shared Vault selection and immutable binding rules apply; unsupported native labels/tool names reject before persistence. An attached Vault with no matching credential may remain anonymous; missing keys or failed credential lookup/decryption never fall back to anonymous execution. Omitted stream defaults to false; stream and agent_id cannot be null. Metadata may be null; non-string values and limit violations return invalid_request_error with a metadata or metadata. param. The inline agent uses the Agent create configuration validation with agent.-prefixed params, reported before the input requirement and saved-Agent lookup; saved configurations with conflicting tools or schema roots reject admission with the same errors, and execution limits keep unsupported_or_invalid_configuration. Hosted network policy rejections return invalid_request_error with a null param. Initial input accepts a string or ordered user-message array. Codex and Claude SDK on none and qualified openai_hosted also accept inline PNG/JPEG image content; other image combinations and remote URLs are unsupported. None initial input atomically starts a Turn; self_hosted initial input is reserved while returning its Environment connection target, with execution deferred to native readiness and Session failure on initial timeout. Initial input is required for none and for streamed creation outside self_hosted. Omitted/null input remains valid for non-streaming hosted and self_hosted creation. With stream=true, returns live Session events starting with the committed creation snapshot and closes right after the first agent.session.idle recorded when a Turn ends or an input reservation stops being pending, or any agent.session.failed, without sending later events. A creation that admitted nothing closes after the snapshot; a settlement that records no event closes after events up to the cursor read with a settled Session projection. Required actions keep it open; disconnect does not cancel execution. The GET events stream remains live-only. New Sessions retain their authenticated creator; all creation retries require the same typed subject, including across key rotation. Saved-Agent retries and inline requests using Vault attachments or credential references retain caller intent independently of later resource changes; new openai_hosted and none inline requests also freeze caller intent before deployment defaults resolve; unrelated self_hosted inline retries preserve resolved/default equivalences. Provider keys enter retry hashes only as fingerprints keyed by the credential key. Unknown historical creators reject retries; known creators without recorded intent retain resolved-snapshot retry rules. These conflict policies are local and not verified hosted parity. A same-key stream=true retry of an existing creation returns 201 with no events and closes at once; retry with stream=false or use the GET events stream to recover. Claude SDK on none and Core-managed Docker openai_hosted supports qualified object-root json_schema output with medium verbosity, single-Agent execution and ordinary functions. Hosted execution reuses native workspace tools and Files/Artifacts; Skills, Plugins, capability directories, HTTP MCP, Subagent and tool_search combinations remain unqualified, including inherited template contents. Other non-text initial input remains unsupported. Basic Codex and Claude SDK openai_hosted creation requires an explicitly configured managed provider. The Claude workspace profile supports non-deferred function tools with text or successful inline PNG/JPEG results alongside native workspace tools; HTTP MCP remains unsupported. Idle Sessions provision automatically; initial provisioning has no caller connection action. Network defaults to enabled; disabled and restricted exact ASCII hostnames are supported. Restricted policy requires 1–100 allowed domains. Unsupported hostname forms and startup installations are rejected. Confidential env, system/npm/Python packages and ordered setup commands use the shared initialization lifecycle; requested network applies after setup. Initial inline and tenant-owned file_id files freeze encrypted bytes before provisioning, then install through the common Core lifecycle before native execution or live Files access. With a template reference, omitted/null files, env, packages and setup_commands inherit. Non-null files and command lists replace; env overlays by key; each package manager inherits on omission/null and otherwise replaces its list. Empty lists clear their selected field. Tenant-owned environment_template_id references inherit omitted/null network and allow only narrowing overrides. Inline hosted network:null retains the enabled default; updating a Template with network:null resets its saved policy to enabled. Core freezes effective configuration; template updates/deletion do not alter Session snapshots or same-intent creation retries. Inline or tenant-owned skill_reference Skills share initialization. Templates preserve default/latest/explicit selectors; Session creation freezes concrete metadata and encrypted content atomically. Skill, Plugin and capability-directory list omission/null inherit; a non-null list replaces, including empty-list clearing. Omitted/null Skill version selectors resolve the default version. Source deletion/default updates cannot change committed Session Skill contents. Deferred function discovery uses type-only tool_search and per-function defer_loading in the qualified single-agent Claude environment:none function profile, including qualified inline image messages and text results. Explicit web_search mode disabled and programmatic_tool_calling enabled false use frozen common Runtime controls. Enabled forms, including those saved on an Agent, remain unqualified and reject before any write unless the Session replaces tools. Omitted programmatic configuration preserves native behavior, a documented difference from the official default-on behavior. Other combinations remain unqualified; see the operation coverage. +// @Description The optional Core model_provider bundle resolves from the Session override, saved Agent defaults, then, for openai_hosted and none, the deployment default of the resolved harness; self_hosted never uses the deployment default and none accepts only it. openai_hosted and self_hosted Sessions that resolve no bundle return 400 model_provider_required with param x_agents_core.model_provider before any write. Core encrypts and freezes the resolved bundle; later Agent or deployment default edits and same-key retries cannot change it. Keys are never returned. Supports inline configuration or a tenant-owned saved agent_id with per-Session field replacements. Execution supports model/instructions, text verbosity, non-deferred function tools, adapter-qualified multi_agent with persisted Subagent reads, implicit reasoning, service tier auto and environment type none, subject to the configured engine. Codex additionally supports HTTP MCP with service origin (omitted or null on HTTP transport is saved as service), native allowed_tools and boolean required defaulting to false. Session vault_ids attach only project-owned Vaults; credential_id selects an attached static bearer or OAuth credential for the exact HTTPS URL, while null/omission selects a unique match or remains anonymous. Session reads, lists and event snapshots show that implicitly selected credential ID in a null or omitted credential_id, also after the credential is deleted; anonymous selections stay null and the stored caller intent is unchanged. After the input requirement and before any write, a credential_id without vault_ids, one outside the attached Vaults (one message for missing, foreign and unattached IDs) or one for another server_url returns 400 invalid_request_error, and several implicit matches return 409 conflict_error. Missing decryption configuration fails dispatch without anonymous fallback. Required initialization uses native startup before the first native Turn, including cold resume, and requires a separately advertised capability; exact hosted creation timing and error parity remain unverified. Other MCP origins and native OAuth login remain unsupported. The self_hosted profile requires Codex, an absolute workspace_directory and empty capability_directories, with optional non-deferred function tools and HTTP MCP using service origin, optionally authenticated by the attached Vault rules. Remote MCP and remote Bearer authentication each require separately advertised combination support; old peers cannot receive unsupported work. Omitted/null capability_directories use the empty-list default; self_hosted requires configured execution plus executor registry. Claude SDK currently requires medium verbosity and object-root function schemas. It supports anonymous or attached static-bearer service-origin HTTP MCP on none with boolean required and separately advertised MCP/bearer/required runtime support. Required servers must be connected before the first native input is released; pending or failed startup rejects execution. The shared Vault selection and immutable binding rules apply; unsupported native labels/tool names reject before persistence. An attached Vault with no matching credential may remain anonymous; missing keys or failed credential lookup/decryption never fall back to anonymous execution. Omitted stream defaults to false; stream and agent_id cannot be null. Metadata may be null; non-string values and limit violations return invalid_request_error with a metadata or metadata. param. The inline agent uses the Agent create configuration validation with agent.-prefixed params, reported before the input requirement and saved-Agent lookup; saved configurations with conflicting tools or schema roots reject admission with the same errors, and execution limits keep unsupported_or_invalid_configuration. Hosted network policy rejections return invalid_request_error with a null param. Initial input accepts a string or ordered user-message array. Codex and Claude SDK on none and qualified openai_hosted also accept inline PNG/JPEG image content; other image combinations and remote URLs are unsupported. None initial input atomically starts a Turn; self_hosted initial input is reserved while returning its Environment connection target, with execution deferred to native readiness and Session failure on initial timeout. Initial input is required for none and for streamed creation outside self_hosted. Omitted/null input remains valid for non-streaming hosted and self_hosted creation. With stream=true, returns live Session events starting with the committed creation snapshot and closes right after the first agent.session.idle recorded when a Turn ends or an input reservation stops being pending, or any agent.session.failed, without sending later events. A creation that admitted nothing closes after the snapshot; a settlement that records no event closes after events up to the cursor read with a settled Session projection. Required actions keep it open; disconnect does not cancel execution. The GET events stream remains live-only. New Sessions retain their authenticated creator; all creation retries require the same typed subject, including across key rotation. Saved-Agent retries and inline requests using Vault attachments or credential references retain caller intent independently of later resource changes; new hosted inline requests also freeze caller intent before deployment defaults resolve; unrelated non-hosted inline retries preserve resolved/default equivalences, and their resolved hash leaves out any deployment default. Provider keys enter retry hashes only as fingerprints keyed by the credential key. Unknown historical creators reject retries; known creators without recorded intent retain resolved-snapshot retry rules. These conflict policies are local and not verified hosted parity. A same-key stream=true retry of an existing creation returns 201 with no events and closes at once; retry with stream=false or use the GET events stream to recover. Claude SDK on none and Core-managed Docker openai_hosted supports qualified object-root json_schema output with medium verbosity, single-Agent execution and ordinary functions. Hosted execution reuses native workspace tools and Files/Artifacts; Skills, Plugins, capability directories, HTTP MCP, Subagent and tool_search combinations remain unqualified, including inherited template contents. Other non-text initial input remains unsupported. Basic Codex and Claude SDK openai_hosted creation requires an explicitly configured managed provider. The Claude workspace profile supports non-deferred function tools with text or successful inline PNG/JPEG results alongside native workspace tools; HTTP MCP remains unsupported. Idle Sessions provision automatically; initial provisioning has no caller connection action. Network defaults to enabled; disabled and restricted exact ASCII hostnames are supported. Restricted policy requires 1–100 allowed domains. Unsupported hostname forms and startup installations are rejected. Confidential env, system/npm/Python packages and ordered setup commands use the shared initialization lifecycle; requested network applies after setup. Initial inline and tenant-owned file_id files freeze encrypted bytes before provisioning, then install through the common Core lifecycle before native execution or live Files access. With a template reference, omitted/null files, env, packages and setup_commands inherit. Non-null files and command lists replace; env overlays by key; each package manager inherits on omission/null and otherwise replaces its list. Empty lists clear their selected field. Tenant-owned environment_template_id references inherit omitted/null network and allow only narrowing overrides. Inline hosted network:null retains the enabled default; updating a Template with network:null resets its saved policy to enabled. Core freezes effective configuration; template updates/deletion do not alter Session snapshots or same-intent creation retries. Inline or tenant-owned skill_reference Skills share initialization. Templates preserve default/latest/explicit selectors; Session creation freezes concrete metadata and encrypted content atomically. Skill, Plugin and capability-directory list omission/null inherit; a non-null list replaces, including empty-list clearing. Omitted/null Skill version selectors resolve the default version. Source deletion/default updates cannot change committed Session Skill contents. Deferred function discovery uses type-only tool_search and per-function defer_loading in the qualified single-agent Claude environment:none function profile, including qualified inline image messages and text results. Explicit web_search mode disabled and programmatic_tool_calling enabled false use frozen common Runtime controls. Enabled forms, including those saved on an Agent, remain unqualified and reject before any write unless the Session replaces tools. Omitted programmatic configuration preserves native behavior, a documented difference from the official default-on behavior. Other combinations remain unqualified; see the operation coverage. // @Tags Sessions // @Accept json // @Produce json,text/event-stream diff --git a/services/agents-api/internal/api/session_creation_identity.go b/services/agents-api/internal/api/session_creation_identity.go index f1a949031..52f79e9eb 100644 --- a/services/agents-api/internal/api/session_creation_identity.go +++ b/services/agents-api/internal/api/session_creation_identity.go @@ -11,11 +11,12 @@ import ( ) // sessionCreationRequest records caller intent before mutable sources resolve: -// saved Agents, templates, credentials and, wherever it may apply, the deployment -// default model provider. A retry then returns the committed Session even after -// those sources change. +// saved Agents, templates, credentials and hosted deployment defaults. A retry +// then returns the committed Session even after those sources change. Other +// inline requests keep the resolved-request retry rule; the store leaves the +// deployment default out of that hash, so it cannot change their identity. func sessionCreationRequest(input sessionRequest, initial []store.Input) (json.RawMessage, error) { - if (input.Environment == nil || !v1.ModelProviderAllowed(input.Environment.Type, v1.ModelProviderSourceDeployment)) && input.XAgentsCore == nil && input.AgentID == nil && input.templateID == "" && len(input.initialFiles) == 0 && input.initialization.Empty() && !inlineCredentialIntent(input) && input.agentFields["x_agents_core"] == nil { + if (input.Environment == nil || input.Environment.Type != "openai_hosted") && input.XAgentsCore == nil && input.AgentID == nil && input.templateID == "" && len(input.initialFiles) == 0 && input.initialization.Empty() && !inlineCredentialIntent(input) && input.agentFields["x_agents_core"] == nil { return nil, nil } agentID := "" diff --git a/services/agents-api/internal/api/session_creation_stream_test.go b/services/agents-api/internal/api/session_creation_stream_test.go index fbce69835..fc34143c3 100644 --- a/services/agents-api/internal/api/session_creation_stream_test.go +++ b/services/agents-api/internal/api/session_creation_stream_test.go @@ -697,11 +697,6 @@ func (eventOnlyStore) ListSessionEvents(context.Context, string, string, int64) return nil, nil } -// A none Session records caller intent, so creation looks it up first. -func (eventOnlyStore) FindSessionCreation(context.Context, string, string, json.RawMessage, identity.Subject) (store.SessionCreation, error) { - return store.SessionCreation{}, store.ErrNotFound -} - type countingStreamAdmission struct { inputRecorder calls atomic.Int32 diff --git a/services/agents-api/internal/api/session_credentials_test.go b/services/agents-api/internal/api/session_credentials_test.go index a1a5d5c45..07b1a11e3 100644 --- a/services/agents-api/internal/api/session_credentials_test.go +++ b/services/agents-api/internal/api/session_credentials_test.go @@ -25,7 +25,7 @@ func TestMCPCredentialReferenceIsSchemaNotAuthorization(t *testing.T) { func TestSessionVaultTypesAndCreationIntent(t *testing.T) { for _, field := range []string{"", `,"vault_ids":null`, `,"vault_ids":[]`, `,"vault_ids":["vault"]`, `,"vault_ids":[null]`, `,"vault_ids":[3]`, `,"vault_ids":{}`, `,"vault_ids":"vault"`} { var decoded decodedSessionRequest - err := json.Unmarshal([]byte(`{"agent":{"model":"model"},"environment":{"type":"self_hosted","workspace_directory":"/workspace"}`+field+`}`), &decoded) + err := json.Unmarshal([]byte(`{"agent":{"model":"model"},"environment":{"type":"none"}`+field+`}`), &decoded) if err != nil { t.Fatal(err) } @@ -42,20 +42,6 @@ func TestSessionVaultTypesAndCreationIntent(t *testing.T) { t.Fatal("attached inline intent missing or unrelated inline identity changed", err) } } - // Where the deployment default may apply, intent is always recorded. - for _, environment := range []string{`{"type":"none"}`, `{"type":"openai_hosted"}`} { - var decoded decodedSessionRequest - if err := json.Unmarshal([]byte(`{"agent":{"model":"model"},"environment":`+environment+`}`), &decoded); err != nil { - t.Fatal(err) - } - input, err := decoded.validated() - if err != nil { - t.Fatal(err) - } - if intent, err := sessionCreationRequest(input, nil); err != nil || len(intent) == 0 { - t.Fatal("deployment-default intent missing", environment, err) - } - } var request decodedSessionRequest body := `{"agent":{"model":"model","tools":[` + publicMCP + `]},"environment":{"type":"none"},"vault_ids":["vault"]}` if err := json.Unmarshal([]byte(body), &request); err != nil { diff --git a/services/agents-api/internal/credentialcrypto/cipher.go b/services/agents-api/internal/credentialcrypto/cipher.go index c40dc0f77..8a9136545 100644 --- a/services/agents-api/internal/credentialcrypto/cipher.go +++ b/services/agents-api/internal/credentialcrypto/cipher.go @@ -9,6 +9,7 @@ import ( "encoding/hex" "encoding/json" "errors" + "strings" "unicode/utf8" ) @@ -65,7 +66,8 @@ func (c *Cipher) Fingerprint(purpose, secret string) (string, error) { if c == nil || len(c.fingerprint) == 0 { return "", errUnavailable } - if purpose == "" || !utf8.ValidString(purpose) { + // The purpose is delimited by NUL, so it must not contain one. + if purpose == "" || !utf8.ValidString(purpose) || strings.ContainsRune(purpose, 0) { return "", errInvalidBinding } mac := hmac.New(sha256.New, c.fingerprint) diff --git a/services/agents-api/internal/credentialcrypto/cipher_test.go b/services/agents-api/internal/credentialcrypto/cipher_test.go index 4a6e4bfcb..40aa33682 100644 --- a/services/agents-api/internal/credentialcrypto/cipher_test.go +++ b/services/agents-api/internal/credentialcrypto/cipher_test.go @@ -166,4 +166,8 @@ func TestFingerprintIsKeyedAndPurposeBound(t *testing.T) { if _, err := (*Cipher)(nil).Fingerprint("model-provider", "secret-canary"); err == nil { t.Fatal("fingerprint without a key") } + // Without the NUL rule, ("a\x00b", "c") and ("a", "b\x00c") would collide. + if _, err := c.Fingerprint("model-provider\x00x", "secret-canary"); err == nil { + t.Fatal("purpose with NUL accepted") + } } diff --git a/services/agents-api/internal/store/deployment_model_providers_http_test.go b/services/agents-api/internal/store/deployment_model_providers_http_test.go index 40b76153e..c34321d03 100644 --- a/services/agents-api/internal/store/deployment_model_providers_http_test.go +++ b/services/agents-api/internal/store/deployment_model_providers_http_test.go @@ -180,10 +180,23 @@ func TestLegacySessionWithoutProviderCannotStartWork(t *testing.T) { } worker, stop := startEnvironmentExpiryWorker(t, h.d) defer stop() + _, pool := store.NewTestStore(t) + reservations := func() int { + t.Helper() + var count int + if err := pool.QueryRow(t.Context(), "SELECT count(*) FROM environment_input_reservations WHERE session_id=$1", legacy.ID).Scan(&count); err != nil { + t.Fatal(err) + } + return count + } + before := reservations() message := []store.Input{{Kind: "message", Payload: json.RawMessage(`{"text":"start"}`)}} if _, err := worker.SubmitInputs(t.Context(), h.tenant, legacy.ID, uuid.NewString(), message); !errors.Is(err, store.ErrModelProviderRequired) { t.Fatal("provider-free Session accepted work", err) } + if after := reservations(); after != before { + t.Fatal("rejected work was queued", before, after) + } awaitDaemonRemoteCondition(t, t.Context(), 5*time.Second, "legacy reservation settled", func() bool { got, err := h.s.GetEnvironmentInputReservation(t.Context(), h.tenant, legacy.ID, pending.ID) return err == nil && got.State == store.EnvironmentInputFailed @@ -228,9 +241,13 @@ func TestNoneSessionRetryAfterDeploymentDefaultChanges(t *testing.T) { t.Fatal(err) } } - create := func(key string) string { + create := func(key string, agent ...string) string { t.Helper() - r := httptest.NewRequest("POST", "/v1/agents/sessions", strings.NewReader(`{"agent":{"model":"m"},"environment":{"type":"none"},"input":"hello"}`)) + body := `{"agent":{"model":"m"},"environment":{"type":"none"},"input":"hello"}` + if len(agent) > 0 { + body = `{"agent":` + agent[0] + `,"environment":{"type":"none"},"input":"hello"}` + } + r := httptest.NewRequest("POST", "/v1/agents/sessions", strings.NewReader(body)) r.Header.Set("Authorization", "Bearer "+token) r.Header.Set("OpenAI-Beta", "agents=v1") r.Header.Set("Content-Type", "application/json") @@ -250,7 +267,7 @@ func TestNoneSessionRetryAfterDeploymentDefaultChanges(t *testing.T) { t.Fatal("none Session did not freeze the deployment default", err) } setDefault("rotated-default-key") - if create(key) != original { + if create(key) != original || create(key, `{"model":"m","text":{"verbosity":"medium"}}`) != original { t.Fatal("retry after rotation created another Session") } if err := st.DeleteDeploymentModelProvider(admin, "codex"); err != nil { diff --git a/services/agents-api/internal/store/deployment_model_providers_test.go b/services/agents-api/internal/store/deployment_model_providers_test.go index 7feaca9bd..bae4b3662 100644 --- a/services/agents-api/internal/store/deployment_model_providers_test.go +++ b/services/agents-api/internal/store/deployment_model_providers_test.go @@ -2,6 +2,7 @@ package store import ( "bytes" + "encoding/json" "errors" "testing" "time" @@ -9,6 +10,7 @@ import ( v1 "github.com/MiniMax-AI-Dev/parsar/contracts/agents-api/v1" "github.com/MiniMax-AI-Dev/parsar/services/agents-api/internal/adminaudit" "github.com/MiniMax-AI-Dev/parsar/services/agents-api/internal/credentialcrypto" + "github.com/google/uuid" ) func TestDeploymentModelProviderEncryptedAuditedAndReplaced(t *testing.T) { @@ -88,3 +90,40 @@ func TestDeploymentModelProviderEncryptedAuditedAndReplaced(t *testing.T) { t.Fatal("failed audit left the write committed") } } + +// A provider key enters the retry hashes only through a fingerprint keyed by the +// credential key: the same request under two credential keys hashes differently, +// and an intent whose provider cannot be read is rejected, not hashed raw. +func TestProviderKeyEntersRetryHashesOnlyAsKeyedFingerprint(t *testing.T) { + _, pool := testStore(t) + provider := &v1.ModelProviderInput{Protocol: "responses", BaseURL: "https://example.com/v1", APIKey: "hash-key-canary"} + intent, err := json.Marshal(map[string]any{"agent": map[string]string{"model": "m"}, "environment": map[string]string{"type": "openai_hosted"}, "x_agents_core": map[string]any{"model_provider": provider}}) + if err != nil { + t.Fatal(err) + } + input := CreateSessionInput{Creator: FixtureCreator(), Engine: "codex", IdempotencyKey: "same-request", ModelProvider: provider, ModelProviderSource: v1.ModelProviderSourceSession, + Configuration: []byte(`{"agent":{"model":"m"},"environment":{"type":"openai_hosted"}}`), CreationRequest: intent} + var hashes [2][2]string + for index, seed := range []byte{71, 72} { + cipher, err := credentialcrypto.New(bytes.Repeat([]byte{seed}, 32)) + if err != nil { + t.Fatal(err) + } + session, err := NewWithCredentialCipher(pool, cipher).CreateSession(t.Context(), uuid.NewString(), input) + if err != nil { + t.Fatal(err) + } + if err := pool.QueryRow(t.Context(), "SELECT request_hash, creation_request_hash FROM sessions WHERE id=$1", session.ID).Scan(&hashes[index][0], &hashes[index][1]); err != nil { + t.Fatal(err) + } + } + if hashes[0][0] == hashes[1][0] || hashes[0][1] == hashes[1][1] { + t.Fatal("retry hashes do not depend on the credential key", hashes) + } + cipher, _ := credentialcrypto.New(bytes.Repeat([]byte{71}, 32)) + unreadable := input + unreadable.IdempotencyKey, unreadable.CreationRequest = "unreadable", json.RawMessage(`{"x_agents_core":{"model_provider":"hash-key-canary"}}`) + if _, err := NewWithCredentialCipher(pool, cipher).CreateSession(t.Context(), uuid.NewString(), unreadable); !errors.Is(err, ErrInvalidInput) { + t.Fatal("unreadable provider intent was hashed", err) + } +} diff --git a/services/agents-api/internal/store/mcp_credential_selection_public_test.go b/services/agents-api/internal/store/mcp_credential_selection_public_test.go index ed8de2827..cf755bb16 100644 --- a/services/agents-api/internal/store/mcp_credential_selection_public_test.go +++ b/services/agents-api/internal/store/mcp_credential_selection_public_test.go @@ -175,18 +175,10 @@ func TestMCPCredentialSelectionPublicPostgres(t *testing.T) { t.Fatalf("Session origin case %d: %d %s", index, created.status, created.body) } } - // A none Session records caller intent, so a same-key retry that omits the - // explicit origin is a different request. - if retry := send(tokenA, http.MethodPost, "/v1/agents/sessions", inline(tool("records", url, ""), ""), "origin-explicit"); retry.status != http.StatusConflict || !strings.Contains(retry.body, `"code":"idempotency_conflict"`) { - t.Fatal("same-key retry without origin", retry.status, retry.body) - } - // A Session created before intent was recorded, with an explicit origin, - // recovers from a same-key retry that omits it: the resolved configuration is equal. - if _, err := pool.Exec(t.Context(), "UPDATE sessions SET creation_request_hash = NULL WHERE id = $1", idOf(explicitSession.body)); err != nil { - t.Fatal(err) - } + // A Session created with an explicit origin, as before this change, recovers + // from a same-key retry that omits it: the resolved configuration is equal. if retry := send(tokenA, http.MethodPost, "/v1/agents/sessions", inline(tool("records", url, ""), ""), "origin-explicit"); retry.status != http.StatusCreated || retry.body != explicitSession.body { - t.Fatal("historical same-key retry without origin", retry.status, retry.body) + t.Fatal("same-key retry without origin", retry.status, retry.body) } // Recorded caller intent (attached Vaults) keeps comparing the request itself. attachedExplicit := send(tokenA, http.MethodPost, "/v1/agents/sessions", inline(tool("records", url, `,"connection_origin":"service"`), vaults(attachedA)), "origin-attached") diff --git a/services/agents-api/internal/store/session_creation_identity.go b/services/agents-api/internal/store/session_creation_identity.go index 347cc00c0..7bf2642d0 100644 --- a/services/agents-api/internal/store/session_creation_identity.go +++ b/services/agents-api/internal/store/session_creation_identity.go @@ -1,6 +1,7 @@ package store import ( + "bytes" "context" "crypto/sha256" "encoding/hex" @@ -35,25 +36,48 @@ func (s *Store) fingerprintedProvider(provider *v1.ModelProviderInput) (*v1.Mode return ©, nil } -// withoutProviderKey replaces a caller intent's x_agents_core.model_provider.api_key -// with its keyed fingerprint. Requests without a key are returned unchanged. +// withoutProviderKey replaces a caller intent's x_agents_core.model_provider +// with its typed value carrying a keyed fingerprint instead of the key. It fails +// closed: an intent whose extension or provider cannot be read is rejected +// rather than hashed as raw bytes that could carry a key. func (s *Store) withoutProviderKey(raw json.RawMessage) (json.RawMessage, error) { - var request, extension, provider map[string]json.RawMessage - var key string - if json.Unmarshal(raw, &request) != nil || json.Unmarshal(request["x_agents_core"], &extension) != nil || - json.Unmarshal(extension["model_provider"], &provider) != nil || json.Unmarshal(provider["api_key"], &key) != nil { + var request map[string]json.RawMessage + if json.Unmarshal(raw, &request) != nil || request == nil { + return nil, ErrInvalidInput + } + extensionRaw, present := request["x_agents_core"] + if !present || jsonNull(extensionRaw) { + return raw, nil + } + var extension map[string]json.RawMessage + if json.Unmarshal(extensionRaw, &extension) != nil || extension == nil { + return nil, ErrInvalidInput + } + providerRaw, present := extension["model_provider"] + if !present || jsonNull(providerRaw) { return raw, nil } - fingerprinted, err := s.fingerprintedProvider(&v1.ModelProviderInput{APIKey: key}) + var provider v1.ModelProviderInput + if json.Unmarshal(providerRaw, &provider) != nil { + return nil, ErrInvalidInput + } + fingerprinted, err := s.fingerprintedProvider(&provider) if err != nil { return nil, err } - provider["api_key"], _ = json.Marshal(fingerprinted.APIKey) - extension["model_provider"], _ = json.Marshal(provider) - request["x_agents_core"], _ = json.Marshal(extension) + if extension["model_provider"], err = json.Marshal(fingerprinted); err != nil { + return nil, err + } + if request["x_agents_core"], err = json.Marshal(extension); err != nil { + return nil, err + } return json.Marshal(request) } +func jsonNull(raw json.RawMessage) bool { + return string(bytes.TrimSpace(raw)) == "null" +} + func (s *Store) creationRequestHash(raw json.RawMessage) (pgtype.Text, error) { if len(raw) == 0 { return pgtype.Text{}, nil diff --git a/services/agents-api/internal/store/sessions.go b/services/agents-api/internal/store/sessions.go index 2f20114de..f70428746 100644 --- a/services/agents-api/internal/store/sessions.go +++ b/services/agents-api/internal/store/sessions.go @@ -132,6 +132,9 @@ func (s *Store) createSession(ctx context.Context, tenantID string, input Create return SessionCreation{}, err } } + // The retry identity covers the configuration as requested; the marker added + // for a deployment default below is not caller input. + requested := configuration if input.ModelProvider != nil { if err := input.ModelProvider.ValidateHarness(input.Engine); err != nil { return SessionCreation{}, fmt.Errorf("%w: %s", ErrInvalidInput, err) @@ -155,9 +158,15 @@ func (s *Store) createSession(ctx context.Context, tenantID string, input Create if !input.Initialization.Empty() { initialization = &input.Initialization } - // The retry identity carries the provider key only as a keyed fingerprint. - fingerprinted, err := s.fingerprintedProvider(input.ModelProvider) - if err != nil { + // The retry identity carries a caller's provider key only as a keyed + // fingerprint. A deployment default is not caller input: leaving it and its + // configuration marker out keeps retries equivalent when the default is set, + // replaced or removed. + var fingerprinted *v1.ModelProviderInput + hashed := configuration + if input.ModelProviderSource == v1.ModelProviderSourceDeployment { + hashed = requested + } else if fingerprinted, err = s.fingerprintedProvider(input.ModelProvider); err != nil { return SessionCreation{}, err } // JSON map keys are sorted by encoding/json, so key order does not affect retries. @@ -169,7 +178,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"` - }{fingerprinted, input.Engine, input.Metadata, configuration, encodedInput, input.InitialFiles, initialization}) + }{fingerprinted, input.Engine, input.Metadata, hashed, encodedInput, input.InitialFiles, initialization}) if err != nil { return SessionCreation{}, fmt.Errorf("%w: input: %v", ErrInvalidInput, err) }