diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index d1c1014e7..75526f6af 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -2228,18 +2228,29 @@ 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 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, written with provenance in the same creation transaction as Session resources. Read no credential ciphertext and never recompute sources from current Agents @@ -2475,7 +2486,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 @@ -3283,10 +3294,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..94baa9c28 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 +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/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 d5ff98856..83c2af347 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.Installation: properties: address_bindings: @@ -638,6 +714,7 @@ definitions: type: string project_id: type: string + x-nullable: true request_id: type: string resource_id: @@ -1559,6 +1636,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: @@ -2969,6 +3066,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/installation: get: description: Core key only; available before any sandbox deployment exists. Reports the public URL that applications, nodes, sandboxes and self-hosted executors use, the API base URL, Core's source commit and installation ID, the installer's settings snapshot with where to change it, and what is bound to the current public URL. Sensitive settings report only whether they are configured. @@ -4422,7 +4667,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..429731ecf 100644 --- a/contracts/agents-api/environment-executor-credentials.md +++ b/contracts/agents-api/environment-executor-credentials.md @@ -70,6 +70,21 @@ 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. 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 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..90c5d325d 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 | | --- | --- | --- | @@ -55,7 +72,10 @@ same deployment credential-encryption key across restarts. V1 has no Turn overri 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 +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 @@ -96,30 +116,67 @@ 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. 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; 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 +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. 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 +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 6671b1562..6b4ccbeab 100644 --- a/contracts/agents-api/openapi.yaml +++ b/contracts/agents-api/openapi.yaml @@ -2939,117 +2939,123 @@ 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, 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. 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 b158e99db..fa91a1cc2 100644 --- a/contracts/agents-api/v1/model_provider_admission.go +++ b/contracts/agents-api/v1/model_provider_admission.go @@ -9,15 +9,45 @@ import ( "golang.org/x/net/idna" ) -const providerEnvironment = "openai_hosted" const providerScheme = "https" // providerHost converts a domain as URL host parsing does (UTS #46 without // hyphen or STD3 restrictions), rejecting invalid labels such as bad punycode. var providerHost = idna.New(idna.MapForLookup(), idna.BidiRule(), idna.StrictDomainName(false), idna.CheckHyphens(false)) -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..015c3b042 --- /dev/null +++ b/deploy/install/model_provider_sessions.py @@ -0,0 +1,84 @@ +#!/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. +Historical none Sessions are not counted: without a frozen provider they run with +their device's own environment. +""" +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 4317660d5..0b1cd2a5a 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) | Installation facts, 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) | Installation facts, 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 @@ -52,6 +52,7 @@ with the Core key. | `installation` | Public URL, API base URL, source commit, the installer's process settings and what is bound to the public URL; available before any deployment | [Installation](../../contracts/agents-api/installation.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 d97b62a88..d62ff1a79 100644 --- a/docs/api/public-agent-api.md +++ b/docs/api/public-agent-api.md @@ -68,6 +68,15 @@ 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 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 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 e479ee8bf..01b541930 100644 --- a/docs/configuration.md +++ b/docs/configuration.md @@ -66,15 +66,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: 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 @@ -92,6 +93,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 357480f7f..1ccb98b6f 100644 --- a/docs/getting-started/quickstart.md +++ b/docs/getting-started/quickstart.md @@ -57,8 +57,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 @@ -87,7 +90,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 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/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 f86389702..a95b9f3e7 100644 --- a/packages/agents-client/src/admin-client.test.ts +++ b/packages/agents-client/src/admin-client.test.ts @@ -76,6 +76,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", () => { @@ -142,6 +146,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 215c3bcfb..28e073b57 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, projectInstallation, + projectExecutorCredentials, projectIssuedExecutorCredential, projectCoreHarnessList, projectHarnessModelProvider, projectInstallation, } 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, @@ -274,4 +277,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 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 }; + 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 6c67ff5c3..653bd1302 100644 --- a/packages/agents-client/src/admin-projection.ts +++ b/packages/agents-client/src/admin-projection.ts @@ -1,7 +1,8 @@ import { AgentCoreError, projectRuntimeObservation, projectSavedAgentConfiguration } from "./client"; import { projectTokenUsage } from "./usage-projection"; -import { canonicalUuid, exactFields, isNonnegativeInteger, isRecord, sameResourceId } from "./response-projection"; -import type { ListPage, SavedAgent } from "./types"; +import { safeProvider } 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, CoreInstallation, CoreInstallationSetting } 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; @@ -215,6 +216,33 @@ export function projectIssuedExecutorCredential(value: unknown, keyId: string, e 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 = safeProvider(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 }; +} const settingKey = /^[a-z][a-z0-9_]*(\.[a-z][a-z0-9_]*)*$/; const settingServices = new Set(["core", "web", "database"]); function projectInstallationSetting(value: unknown): CoreInstallationSetting { diff --git a/packages/agents-client/src/admin-types.ts b/packages/agents-client/src/admin-types.ts index f9ea9023b..2542df3c6 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 b288e9728..f794df086 100644 --- a/packages/agents-client/src/execution-configuration-projection.ts +++ b/packages/agents-client/src/execution-configuration-projection.ts @@ -52,7 +52,7 @@ 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")) { + if (provider.status === "available" && (provider.source === "session" || provider.source === "agent" || provider.source === "deployment")) { configuration = safeProvider(provider.configuration, invalid); } else if (!((provider.status === "redacted" && provider.source === "deployment") || (provider.status === "unavailable" && provider.source === "unknown")) || provider.configuration !== null) return invalid(); 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 5d62f9898..31d0da1c8 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_PUBLIC_URL="https://core.example" -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`, the per-sandbox resources and the complete Runtime release (the deployment's `core_url` comes from `AGENTS_API_PUBLIC_URL`), 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 95ede68b5..b52f22a47 100644 --- a/services/agents-api/cmd/server/main.go +++ b/services/agents-api/cmd/server/main.go @@ -98,10 +98,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 @@ -230,7 +226,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 { @@ -248,7 +244,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 45fad664e..42e1d114e 100644 --- a/services/agents-api/cmd/server/process_configuration.go +++ b/services/agents-api/cmd/server/process_configuration.go @@ -39,6 +39,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; 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_SETTINGS_FILE"); path != "" && !filepath.IsAbs(path) { return errors.New("AGENTS_API_SETTINGS_FILE must be an absolute path") } @@ -67,7 +70,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 e48011c50..661a3cb98 100644 --- a/services/agents-api/cmd/server/process_configuration_test.go +++ b/services/agents-api/cmd/server/process_configuration_test.go @@ -74,3 +74,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(), "remove it") || !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 ae4aa4c73..38391bd8a 100644 --- a/services/agents-api/internal/api/core_routes.go +++ b/services/agents-api/internal/api/core_routes.go @@ -25,5 +25,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 797101361..b3f73478e 100644 --- a/services/agents-api/internal/api/errors.go +++ b/services/agents-api/internal/api/errors.go @@ -123,6 +123,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 ee8212213..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 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, 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 @@ -242,9 +242,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 { @@ -255,7 +255,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 @@ -272,7 +279,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_creation_identity.go b/services/agents-api/internal/api/session_creation_identity.go index a669b678f..52f79e9eb 100644 --- a/services/agents-api/internal/api/session_creation_identity.go +++ b/services/agents-api/internal/api/session_creation_identity.go @@ -10,6 +10,11 @@ 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 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 || 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 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..8e7b574fc 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 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 + "."} +} + +// 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/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..8a9136545 100644 --- a/services/agents-api/internal/credentialcrypto/cipher.go +++ b/services/agents-api/internal/credentialcrypto/cipher.go @@ -4,8 +4,12 @@ package credentialcrypto import ( "crypto/aes" "crypto/cipher" + "crypto/hmac" + "crypto/sha256" + "encoding/hex" "encoding/json" "errors" + "strings" "unicode/utf8" ) @@ -32,6 +36,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 +54,27 @@ 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 + } + // 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) + 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..40aa33682 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,26 @@ 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") + } + // 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/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/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/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/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 58620ef72..a1ee51ee1 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"` @@ -113,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/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/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/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/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/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..c34321d03 --- /dev/null +++ b/services/agents-api/internal/store/deployment_model_providers_http_test.go @@ -0,0 +1,282 @@ +package store_test + +import ( + "bytes" + "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/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"]), "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"}}}` + 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, 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(), + Configuration: []byte(`{"agent":{"model":"test-model"},"environment":{"type":"self_hosted","workspace_directory":"/workspace"}}`)}) + if err != nil { + t.Fatal(err) + } + executor := connectFixtureRuntime(t, h, legacy) + // Reserved directly, as a pre-upgrade Core did. + pending, err := 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) + } + 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 + }) + 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, agent ...string) string { + t.Helper() + 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") + 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 || create(key, `{"model":"m","text":{"verbosity":"medium"}}`) != 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/deployment_model_providers_test.go b/services/agents-api/internal/store/deployment_model_providers_test.go new file mode 100644 index 000000000..bae4b3662 --- /dev/null +++ b/services/agents-api/internal/store/deployment_model_providers_test.go @@ -0,0 +1,129 @@ +package store + +import ( + "bytes" + "encoding/json" + "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" + "github.com/google/uuid" +) + +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") + } +} + +// 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/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_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/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/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/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_creation_identity.go b/services/agents-api/internal/store/session_creation_identity.go index ae58c48af..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" @@ -9,19 +10,85 @@ 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 +// 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 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 + } + var provider v1.ModelProviderInput + if json.Unmarshal(providerRaw, &provider) != nil { + return nil, ErrInvalidInput + } + fingerprinted, err := s.fingerprintedProvider(&provider) + if err != nil { + return nil, err + } + 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 } 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 +109,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/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 991eb5f3a..c8566e006 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 1e429f2a0..f70428746 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 { @@ -133,9 +132,9 @@ 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) - } + // 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) @@ -146,8 +145,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,21 +158,31 @@ func (s *Store) createSession(ctx context.Context, tenantID string, input Create if !input.Initialization.Empty() { initialization = &input.Initialization } + // 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. 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}) + }{fingerprinted, input.Engine, input.Metadata, hashed, 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 } @@ -184,7 +193,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/000076_deployment_model_providers.sql b/services/agents-api/migrations/000076_deployment_model_providers.sql new file mode 100644 index 000000000..8c6064619 --- /dev/null +++ b/services/agents-api/migrations/000076_deployment_model_providers.sql @@ -0,0 +1,39 @@ +-- +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)); + +-- 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; +DROP TABLE deployment_model_providers; diff --git a/services/agents-api/tests/official_client.py b/services/agents-api/tests/official_client.py index d8b42c905..7eb5de372 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")}