diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 79300071d..ba934b144 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -1908,6 +1908,29 @@ replaced; do not carry obsolete compatibility code forward to satisfy this secti binding failures never fall back to anonymous execution. Exact URL equality, immutable selection timing, implicit response population and hosted error/redirect semantics remain local decisions or unverified gaps. No new MCP loop is permitted. +- Saved Agent execution defaults use separate input and safe-output Core extensions. + Keep model-provider bundles whole at every replacement boundary: endpoint, key, + protocol and limits must never be independently inherited. Ordinary Agent JSON + contains only safe provider fields and an output-only configured flag; encrypt the + complete bundle separately with tenant/Agent binding and a distinct purpose. + Commit configuration and secret changes together under the Agent row lock. Merge + only the extension's supplied members; omission preserves, provider null clears + its bundle, and extension null clears both defaults and secret. Model-only edits + 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`. - Public Agent updates use `POST /v1/agents/{agent_id}` with the same tenant/Beta boundary and shared saved-field validation. Preserve omission separately from null; only supplied fields replace saved values. Metadata is a separate whole-map @@ -1915,7 +1938,8 @@ replaced; do not carry obsolete compatibility code forward to satisfy this secti merging validated fields and enforcing the complete configuration bound, then commit configuration, metadata and update timestamp together. Never write a stale full snapshot over another update. No-field updates read without changing timestamps. - Supplied nested fields currently replace the whole field and explicit null uses + Except for the Core execution-default extension described above, supplied nested + fields replace the whole field and explicit null uses existing saved defaults; exact hosted nested/null and no-op timestamp semantics remain unverified. Model-derived reasoning defaults remain a separate gap. Neither updates nor retries modify existing Session snapshots or execution state. diff --git a/contracts/agents-api/harness-selection.md b/contracts/agents-api/harness-selection.md index fa7287ea9..8da7ee586 100644 --- a/contracts/agents-api/harness-selection.md +++ b/contracts/agents-api/harness-selection.md @@ -77,8 +77,10 @@ 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. Credentials stay in private operator files and -transient adapter requests, never Agent defaults, metadata or effective responses. +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. diff --git a/contracts/agents-api/model-execution.md b/contracts/agents-api/model-execution.md index 0f9acb1e5..803b3c910 100644 --- a/contracts/agents-api/model-execution.md +++ b/contracts/agents-api/model-execution.md @@ -1,10 +1,71 @@ -# Session model execution extension +# Agent defaults and Session model execution -Core accepts optional top-level `x_agents_core.model_provider` on Session creation. +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. +## Saved defaults and precedence + +A saved Agent is editable configuration, not a permanently bound runtime. Create +or update it with `model`, optional `x_agents_core.harness`, and an optional complete +`x_agents_core.model_provider`. Create/update/retrieve/list responses return safe +provider fields and the output-only `api_key_configured` flag. They never return +`api_key`, ciphertext or a reusable credential reference. Normal Agent JSON stores +only the safe view; the secret bundle has a separate encrypted row bound to the +tenant and Agent with a distinct encryption purpose. Agent writes commit safe +configuration and ciphertext together. A model-only edit does not require a key. + +Session creation resolves each explicit model/harness override before saved defaults; +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. + +| Operation | Omitted | Explicit null | +| --- | --- | --- | +| Agent update `x_agents_core` | Preserve both defaults | Clear harness and provider, including its secret | +| Agent update nested `model_provider` | Preserve the existing bundle | Clear the entire saved bundle | +| Agent update nested `harness` | Preserve existing harness | Reject; use extension null to reset | +| Session top-level `x_agents_core` | Inherit provider defaults | Inherit provider defaults | +| Session nested `model_provider` | Inherit provider defaults | Inherit provider defaults | +| Session inline `agent.x_agents_core` | Inherit saved harness | Reset to deployment harness | + +An empty Session execution extension remains invalid. An explicitly null provider +is a defined inheritance request; an empty/partial provider object is invalid. +Unknown, duplicate or output-only saved-provider input fields are rejected. Saved +Agent creation without a harness may save a valid bundle, with final harness +compatibility checked at Session admission. Provider-only Agent updates preserve +the saved harness and validate their merged compatibility under the row lock. +Session inline `agent.x_agents_core` remains harness-only; the provider override +belongs at the Session request's top level. + +Read the Agent configuration and encrypted bundle from one coherent database +snapshot. Explicit complete Session overrides do not need to decrypt a saved +bundle. Persist a new Session-owned encrypted snapshot atomically with Session and +environment creation. Existing Sessions never consult the Agent again: edits, key +replacement, deletion, suspend/resume and process restarts cannot change their +model, harness or provider. A missing/wrong encryption key fails closed. Retain the +same deployment credential-encryption key across restarts. V1 has no Turn override, +provider catalog, Session migration or new execution loop. + +All new hosted requests record caller intent before resolving mutable defaults, +including inline requests that use deployment defaults. Matching creation retries +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 +rewrite them. Omitted and explicit fields retain the existing local intent-hash +semantics rather than promising upstream equivalence. + +See the [TypeScript client example](../../packages/agents-client/saved-agent-defaults.md). + +## Session override example + ```json { "agent": {"model": "exact-provider-model", "x_agents_core": {"harness": "mcode"}}, @@ -30,7 +91,7 @@ larger than context; both must be positive for MiniMax Code. Use the actual mode limits. Native provider availability is checked during execution, not by a new probe. `agent.model` retains its exact meaning; this extension never changes model identity. -The entire supplied configuration is frozen and encrypted in the Session creation +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 @@ -40,7 +101,18 @@ 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. Omission preserves the existing operator-options behavior. +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 diff --git a/contracts/agents-api/openapi.yaml b/contracts/agents-api/openapi.yaml index 1bbe6c048..0bdb19524 100644 --- a/contracts/agents-api/openapi.yaml +++ b/contracts/agents-api/openapi.yaml @@ -277,7 +277,7 @@ definitions: x-nullable: true x_agents_core: allOf: - - $ref: '#/definitions/v1.AgentsCore' + - $ref: '#/definitions/v1.SavedAgentCoreInput' x-nullable: true required: - model @@ -1050,6 +1050,30 @@ definitions: - anthropic - responses type: string + required: + - api_key + - base_url + - protocol + type: object + v1.ModelProviderView: + properties: + api_key_configured: + type: boolean + base_url: + type: string + context_window: + type: integer + max_output_tokens: + type: integer + protocol: + enum: + - anthropic + - responses + type: string + required: + - api_key_configured + - base_url + - protocol type: object v1.MultiAgentConfig: properties: @@ -1768,7 +1792,7 @@ definitions: type: integer x_agents_core: allOf: - - $ref: '#/definitions/v1.AgentsCore' + - $ref: '#/definitions/v1.SavedAgentCore' x-nullable: true required: - created_at @@ -1783,6 +1807,30 @@ definitions: - tools - updated_at type: object + v1.SavedAgentCore: + properties: + harness: + enum: + - codex + - claude_sdk + - mcode + type: string + model_provider: + $ref: '#/definitions/v1.ModelProviderView' + type: object + v1.SavedAgentCoreInput: + properties: + harness: + enum: + - codex + - claude_sdk + - mcode + type: string + model_provider: + allOf: + - $ref: '#/definitions/v1.ModelProviderInput' + x-nullable: true + type: object v1.SavedAgentList: properties: data: @@ -2627,7 +2675,7 @@ definitions: x-nullable: true x_agents_core: allOf: - - $ref: '#/definitions/v1.AgentsCore' + - $ref: '#/definitions/v1.SavedAgentCoreInput' x-nullable: true type: object v1.UpdateCredentialRequest: @@ -3674,33 +3722,37 @@ paths: post: consumes: - application/json - description: 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 explicit service origin, native allowed_tools and boolean required - defaulting to false. Session vault_ids attach only project-owned Vaults; credential_id - selects an attached static bearer credential for the exact HTTPS URL, while - null/omission selects a unique match or remains anonymous. Ambiguous selection - rejects creation. Frozen private selections never populate an omitted public - credential_id; 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 OAuth 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 explicit 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. + 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 explicit service origin, native + allowed_tools and boolean required defaulting to false. Session vault_ids + attach only project-owned Vaults; credential_id selects an attached static + bearer credential for the exact HTTPS URL, while null/omission selects a unique + match or remains anonymous. Ambiguous selection rejects creation. Frozen private + selections never populate an omitted public credential_id; 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 OAuth 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 explicit 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 @@ -3729,50 +3781,52 @@ paths: 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; unrelated 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 remain unqualified. Omitted programmatic configuration - preserves native behavior, a documented difference from the official default-on - behavior. Other combinations remain unqualified; see the operation coverage. + 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 remain unqualified. + 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/v1/agents.go b/contracts/agents-api/v1/agents.go index 232082f95..5eafba9d2 100644 --- a/contracts/agents-api/v1/agents.go +++ b/contracts/agents-api/v1/agents.go @@ -5,7 +5,7 @@ import "encoding/json" // CreateAgentRequest describes reusable configuration, not an execution request. // MCP/web-search tools and model-derived reasoning defaults remain incomplete. type CreateAgentRequest struct { - XAgentsCore *AgentsCore `json:"x_agents_core,omitempty" extensions:"x-nullable"` + XAgentsCore *SavedAgentCoreInput `json:"x_agents_core,omitempty" extensions:"x-nullable"` Model *string `json:"model" binding:"required"` Name *string `json:"name,omitempty" extensions:"x-nullable" maxLength:"128"` Instructions *string `json:"instructions,omitempty" extensions:"x-nullable"` @@ -19,7 +19,7 @@ type CreateAgentRequest struct { // UpdateAgentRequest replaces supplied fields and preserves omitted fields. type UpdateAgentRequest struct { - XAgentsCore *AgentsCore `json:"x_agents_core,omitempty" extensions:"x-nullable"` + XAgentsCore *SavedAgentCoreInput `json:"x_agents_core,omitempty" extensions:"x-nullable"` Model *string `json:"model,omitempty"` Name *string `json:"name,omitempty" extensions:"x-nullable" maxLength:"128"` Instructions *string `json:"instructions,omitempty" extensions:"x-nullable"` @@ -49,7 +49,7 @@ type SavedAgentTextFormat struct { // SavedAgentConfiguration excludes resource identity and mutable metadata. It is // not the immutable effective configuration of an execution Session. type SavedAgentConfiguration struct { - XAgentsCore *AgentsCore `json:"x_agents_core,omitempty" extensions:"x-nullable"` + XAgentsCore *SavedAgentCore `json:"x_agents_core,omitempty" extensions:"x-nullable"` Model string `json:"model" binding:"required"` Name *string `json:"name" extensions:"x-nullable"` Instructions *string `json:"instructions" extensions:"x-nullable"` diff --git a/contracts/agents-api/v1/model_execution.go b/contracts/agents-api/v1/model_execution.go index 79481c298..7731dcdf2 100644 --- a/contracts/agents-api/v1/model_execution.go +++ b/contracts/agents-api/v1/model_execution.go @@ -13,9 +13,9 @@ type SessionExecutionInput struct { } type ModelProviderInput struct { - Protocol string `json:"protocol" enums:"anthropic,responses"` - BaseURL string `json:"base_url"` - APIKey string `json:"api_key"` + Protocol string `json:"protocol" enums:"anthropic,responses" binding:"required"` + BaseURL string `json:"base_url" binding:"required"` + APIKey string `json:"api_key" binding:"required"` ContextWindow int32 `json:"context_window,omitempty"` MaxOutputTokens int32 `json:"max_output_tokens,omitempty"` } @@ -44,13 +44,7 @@ func (p *ModelProviderInput) ValidateHarness(harness string) error { if err := p.Validate(); err != nil { return err } - if err := ValidateModelProtocol(p.Protocol, harness); err != nil { - return err - } - if harness == "mcode" && (p.ContextWindow == 0 || p.MaxOutputTokens == 0) { - return errors.New("MiniMax Code requires model context_window and max_output_tokens") - } - return nil + return p.SafeView().ValidateHarness(harness) } func ValidateModelProtocol(protocol, harness string) error { diff --git a/contracts/agents-api/v1/saved_core_extension.go b/contracts/agents-api/v1/saved_core_extension.go new file mode 100644 index 000000000..72b8651db --- /dev/null +++ b/contracts/agents-api/v1/saved_core_extension.go @@ -0,0 +1,73 @@ +package v1 + +import "errors" + +// SavedAgentCoreInput carries defaults for future Sessions. The provider bundle +// is replaced as a whole; its API key is write-only. +type SavedAgentCoreInput struct { + Harness string `json:"harness,omitempty" enums:"codex,claude_sdk,mcode"` + ModelProvider *ModelProviderInput `json:"model_provider,omitempty" extensions:"x-nullable"` +} + +// SavedAgentCore is the non-confidential representation of saved defaults. +type SavedAgentCore struct { + Harness string `json:"harness,omitempty" enums:"codex,claude_sdk,mcode"` + ModelProvider *ModelProviderView `json:"model_provider,omitempty"` +} + +type ModelProviderView struct { + 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"` +} + +func (x *SavedAgentCoreInput) Validate() error { + if x == nil { + return nil + } + if x.Harness != "" { + if err := (&AgentsCore{Harness: x.Harness}).Validate(); err != nil { + return err + } + } + if x.ModelProvider == nil { + return nil + } + if x.Harness != "" { + return x.ModelProvider.ValidateHarness(x.Harness) + } + return x.ModelProvider.Validate() +} + +func (x *SavedAgentCoreInput) SafeView() *SavedAgentCore { + if x == nil { + return nil + } + return &SavedAgentCore{Harness: x.Harness, ModelProvider: x.ModelProvider.SafeView()} +} + +func (p *ModelProviderInput) SafeView() *ModelProviderView { + if p == nil { + return nil + } + return &ModelProviderView{ + Protocol: p.Protocol, BaseURL: p.BaseURL, ContextWindow: p.ContextWindow, + MaxOutputTokens: p.MaxOutputTokens, APIKeyConfigured: p.APIKey != "", + } +} + +// ValidateHarness checks non-confidential protocol and limit compatibility. +func (p *ModelProviderView) ValidateHarness(harness string) error { + if p == nil { + return errors.New("model_provider is required") + } + if err := ValidateModelProtocol(p.Protocol, harness); err != nil { + return err + } + if harness == "mcode" && (p.ContextWindow == 0 || p.MaxOutputTokens == 0) { + return errors.New("MiniMax Code requires model context_window and max_output_tokens") + } + return nil +} diff --git a/contracts/agents-api/v1/saved_core_extension_test.go b/contracts/agents-api/v1/saved_core_extension_test.go new file mode 100644 index 000000000..1f393be21 --- /dev/null +++ b/contracts/agents-api/v1/saved_core_extension_test.go @@ -0,0 +1,53 @@ +package v1 + +import ( + "encoding/json" + "strings" + "testing" +) + +func TestSavedProviderSafeView(t *testing.T) { + input := &SavedAgentCoreInput{Harness: "codex", ModelProvider: &ModelProviderInput{ + Protocol: "responses", BaseURL: "https://example.test/v1", APIKey: "write-only-fixture", + ContextWindow: 200000, MaxOutputTokens: 8000, + }} + if err := input.Validate(); err != nil { + t.Fatal(err) + } + raw, err := json.Marshal(input.SafeView()) + if err != nil || strings.Contains(string(raw), "write-only-fixture") || strings.Contains(string(raw), `"api_key":`) { + t.Fatalf("unsafe view: %s %v", raw, err) + } + if !strings.Contains(string(raw), `"api_key_configured":true`) { + t.Fatalf("missing credential status: %s", raw) + } + input.ModelProvider.APIKey = "changed" + if input.SafeView().ModelProvider.BaseURL != "https://example.test/v1" { + t.Fatal("missing safe endpoint") + } +} + +func TestSavedProviderExplicitHarnessCompatibility(t *testing.T) { + for _, tc := range []struct { + harness, protocol string + valid bool + }{ + {"", "responses", true}, {"", "anthropic", true}, + {"codex", "responses", true}, {"codex", "anthropic", false}, + {"claude_sdk", "anthropic", true}, {"claude_sdk", "responses", false}, + {"mcode", "anthropic", true}, {"mcode", "responses", false}, + } { + t.Run(tc.harness+"/"+tc.protocol, func(t *testing.T) { + x := &SavedAgentCoreInput{Harness: tc.harness, ModelProvider: &ModelProviderInput{ + Protocol: tc.protocol, BaseURL: "https://example.test", APIKey: "fixture", ContextWindow: 100, MaxOutputTokens: 20, + }} + if err := x.Validate(); (err == nil) != tc.valid { + t.Fatalf("valid=%v error=%v", tc.valid, err) + } + }) + } + x := &SavedAgentCoreInput{Harness: "mcode", ModelProvider: &ModelProviderInput{Protocol: "anthropic", BaseURL: "https://example.test", APIKey: "fixture"}} + if x.Validate() == nil { + t.Fatal("MiniMax limits must be complete") + } +} diff --git a/packages/agents-client/README.md b/packages/agents-client/README.md index 5acb6a258..55d1bafb4 100644 --- a/packages/agents-client/README.md +++ b/packages/agents-client/README.md @@ -54,3 +54,6 @@ and depends on `openai-agents-python`, not this client package. `services/agents-api/tests/official_client.py` runs `TestService` with fresh tenants and a dedicated PostgreSQL database. It validates Go-created Sessions through the official Python SDK as well. No product database or model calls are involved. + +The TypeScript client also supports [saved Agent execution defaults](saved-agent-defaults.md), +with separate write-only provider inputs and safe read types. diff --git a/packages/agents-client/saved-agent-defaults.md b/packages/agents-client/saved-agent-defaults.md new file mode 100644 index 000000000..5aca46240 --- /dev/null +++ b/packages/agents-client/saved-agent-defaults.md @@ -0,0 +1,46 @@ +# Saved Agent execution defaults + +The TypeScript client accepts a complete provider bundle when creating or updating +a saved Agent. Keep its key in private application configuration: + +```ts +import { OpenAIAgentsClient } from "@agents-core-web/agents-client"; + +const client = new OpenAIAgentsClient({ baseUrl: coreURL, token: tenantToken }); +const agent = await client.createAgent({ + model: "requested-model", + x_agents_core: { + harness: "codex", + model_provider: { + protocol: "responses", + base_url: modelBaseURL, + api_key: modelAPIKey, + }, + }, +}); + +// Future Sessions inherit the saved defaults; saving itself does not execute. +await client.createSession({ + agent_id: agent.id, + environment: { type: "openai_hosted" }, + input: "Follow the saved Agent instructions.", +}); + +// Change only the model; the saved harness and provider remain configured. +await client.updateAgent(agent.id, { model: "another-model" }); + +// Clear only the provider, retaining the harness. +await client.updateAgent(agent.id, { x_agents_core: { model_provider: null } }); +``` + +Reads return `ModelProviderView`, containing safe endpoint/limit fields and +`api_key_configured`, never `api_key`. It is distinct from `ModelProviderInput`: +do not submit a read response as an update. Replacing a provider requires its full +protocol, endpoint and key; MiniMax Code also requires both token limits. + +On update, omitting the extension preserves all defaults; omitting either nested +member preserves that member. A null provider clears its saved bundle, while +`x_agents_core: null` clears the extension and its secret. An omitted harness +defers protocol compatibility to Session admission. Existing Sessions retain their +configuration snapshots. Session inline `agent.x_agents_core` remains harness-only; +one-off provider overrides belong in the Session's top-level `x_agents_core`. diff --git a/packages/agents-client/src/protocol-types.test.ts b/packages/agents-client/src/protocol-types.test.ts index 1918bf477..ea6e9c682 100644 --- a/packages/agents-client/src/protocol-types.test.ts +++ b/packages/agents-client/src/protocol-types.test.ts @@ -13,6 +13,12 @@ import toolProfiles from "./fixtures/parsar-2b34ea46/tool-profiles.json"; import type { AnonymousHttpMcpToolInput, AgentsCoreSelection, + SavedAgentCoreInput, + SavedAgentCore, + SavedAgent, + AgentSnapshot, + ModelProviderInput, + ModelProviderView, AgentCore, AgentSession, AgentEnvironmentResource, @@ -179,9 +185,14 @@ describe("Parsar 2b34ea46 bounded tool profiles", () => { }); describe("Core harness selection extension", () => { - it("uses the same nullable selection shape for saved, create, update, and inline Agents", () => { - expectTypeOf().toEqualTypeOf(); - expectTypeOf().toEqualTypeOf(); + it("separates saved provider inputs and safe reads from Session harness selection", () => { + expectTypeOf().toEqualTypeOf(); + expectTypeOf().toEqualTypeOf(); + expectTypeOf().toEqualTypeOf(); + expectTypeOf().toEqualTypeOf(); + expectTypeOf().not.toExtend(); + expectTypeOf().not.toExtend(); + expectTypeOf().not.toExtend(); expectTypeOf().toEqualTypeOf(); }); }); diff --git a/packages/agents-client/src/saved-agent-defaults.test.ts b/packages/agents-client/src/saved-agent-defaults.test.ts new file mode 100644 index 000000000..b071fc84a --- /dev/null +++ b/packages/agents-client/src/saved-agent-defaults.test.ts @@ -0,0 +1,44 @@ +import { describe, expect, it } from "vitest"; + +import { OpenAIAgentsClient } from "./client"; +import type { ModelProviderInput, SavedAgentCore } from "./types"; + +describe("saved Agent execution defaults", () => { + it("sends complete replacement inputs and preserves omission versus null", async () => { + const calls: unknown[] = []; + const safe: SavedAgentCore = { + harness: "codex", + model_provider: { + protocol: "responses", base_url: "https://model.example/v1", api_key_configured: true, + }, + }; + const client = new OpenAIAgentsClient({ + token: "tenant-token", + fetch: (async (_input: RequestInfo | URL, init?: RequestInit) => { + calls.push(JSON.parse(String(init?.body))); + return new Response(JSON.stringify({ id: "saved-agent", object: "agent", x_agents_core: safe }), { + status: 200, headers: { "Content-Type": "application/json" }, + }); + }) as typeof fetch, + }); + const provider: ModelProviderInput = { + protocol: "responses", base_url: "https://model.example/v1", api_key: "write-only-fixture", + }; + const agent = await client.createAgent({ model: "example-model", x_agents_core: { harness: "codex", model_provider: provider } }); + expect(agent.x_agents_core).toEqual(safe); + expect(JSON.stringify(agent)).not.toContain("write-only-fixture"); + await client.updateAgent(agent.id, { model: "new-model" }); + await client.updateAgent(agent.id, { x_agents_core: { harness: "codex" } }); + await client.updateAgent(agent.id, { x_agents_core: { model_provider: provider } }); + await client.updateAgent(agent.id, { x_agents_core: { model_provider: null } }); + await client.updateAgent(agent.id, { x_agents_core: null }); + expect(calls).toEqual([ + { model: "example-model", x_agents_core: { harness: "codex", model_provider: provider } }, + { model: "new-model" }, + { x_agents_core: { harness: "codex" } }, + { x_agents_core: { model_provider: provider } }, + { x_agents_core: { model_provider: null } }, + { x_agents_core: null }, + ]); + }); +}); diff --git a/packages/agents-client/src/types.ts b/packages/agents-client/src/types.ts index 7ab4305c6..030fd35f3 100644 --- a/packages/agents-client/src/types.ts +++ b/packages/agents-client/src/types.ts @@ -173,7 +173,7 @@ export interface VaultCredentialDeleted { export interface SavedAgent { id: string; object: "agent"; - x_agents_core?: AgentsCoreSelection | null; + x_agents_core?: SavedAgentCore | null; model: string; name: string | null; instructions: string | null; @@ -191,7 +191,7 @@ export interface SavedAgent { } export interface CreateAgentInput { - x_agents_core?: AgentsCoreSelection | null; + x_agents_core?: SavedAgentCoreInput | null; model: string; name?: string | null; instructions?: string | null; @@ -216,7 +216,9 @@ export interface InlineAgentInput { multi_agent?: MultiAgentInput | null; } -export type AgentSnapshot = Omit; +export type AgentSnapshot = Omit & { + x_agents_core?: AgentsCoreSelection | null; +}; declare const unknownEnvironmentType: unique symbol; declare const unknownItemType: unique symbol; @@ -476,7 +478,7 @@ export interface AgentSession { } export interface CreateSessionInput { - x_agents_core?: { sandbox_node_id?: string; model_provider?: { protocol: "anthropic" | "responses"; base_url: string; api_key: string; context_window?: number; max_output_tokens?: number } }; + x_agents_core?: { sandbox_node_id?: string; model_provider?: ModelProviderInput | null }; agent_id?: string; agent?: InlineAgentInput; environment: AgentEnvironmentInput; @@ -950,6 +952,36 @@ export interface RuntimeHistory { export type CoreHarnessKind = "claude_sdk" | "codex" | "mcode"; export type CoreManagedSandboxProvider = "docker" | "microsandbox"; +/** A complete replacement bundle. API keys are write-only. */ +export interface ModelProviderInput { + protocol: "anthropic" | "responses"; + base_url: string; + api_key: string; + context_window?: number; + max_output_tokens?: number; + api_key_configured?: never; +} + +export interface ModelProviderView { + protocol: "anthropic" | "responses"; + base_url: string; + context_window?: number; + max_output_tokens?: number; + api_key_configured: boolean; + api_key?: never; +} + +/** Omitted members preserve saved defaults on update; null provider clears it. */ +export interface SavedAgentCoreInput { + harness?: CoreHarnessKind; + model_provider?: ModelProviderInput | null; +} + +export interface SavedAgentCore { + harness?: CoreHarnessKind; + model_provider?: ModelProviderView; +} + export interface AgentsCoreSelection { harness: CoreHarnessKind; } diff --git a/services/agents-api/cmd/server/execution_options.go b/services/agents-api/cmd/server/execution_options.go index 42725dd4c..734e08168 100644 --- a/services/agents-api/cmd/server/execution_options.go +++ b/services/agents-api/cmd/server/execution_options.go @@ -11,7 +11,8 @@ import ( ) // Operator options use the existing transient adapter configuration path. They -// are not public Session configuration and are never persisted with its snapshot. +// are never public Session configuration. Hosted creation freezes its model +// provider bundle separately in the encrypted Session credential snapshot. func executionOptions() (func(context.Context, store.Session) (map[string]any, error), error) { resolve, _, err := executionOptionsConfiguration() return resolve, err diff --git a/services/agents-api/cmd/server/main.go b/services/agents-api/cmd/server/main.go index 87263e35a..3993c485d 100644 --- a/services/agents-api/cmd/server/main.go +++ b/services/agents-api/cmd/server/main.go @@ -211,7 +211,7 @@ func run() error { } }() options = append(options, api.WithExecution(worker), api.WithEnvironmentDirectoryReader(worker), api.WithEnvironmentFileWriter(worker)) - options = append(options, api.WithHarnesses(kinds)) + options = append(options, api.WithHarnesses(kinds), api.WithModelProviderDefaults(deploymentModelDefaults(transientOptions))) 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 new file mode 100644 index 000000000..5d96d69be --- /dev/null +++ b/services/agents-api/cmd/server/model_defaults.go @@ -0,0 +1,84 @@ +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 new file mode 100644 index 000000000..dea07c4d1 --- /dev/null +++ b/services/agents-api/cmd/server/model_defaults_test.go @@ -0,0 +1,80 @@ +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/internal/api/agents.go b/services/agents-api/internal/api/agents.go index 63c4e7de8..ff95c17e0 100644 --- a/services/agents-api/internal/api/agents.go +++ b/services/agents-api/internal/api/agents.go @@ -38,6 +38,10 @@ func (h *Handler) createAgent(w http.ResponseWriter, r *http.Request) { if writeFieldError(w, metadataTypeError(raw)) || writeFieldError(w, validateSavedAgentBody(raw, savedAgentCreate)) { return } + if err := validateSavedCoreInput(raw); err != nil { + writeError(w, http.StatusBadRequest, "unsupported_or_invalid_configuration", err.Error()) + return + } var request v1.CreateAgentRequest if decodeInputObject(raw, &request, "model", "name", "instructions", "metadata", "multi_agent", "reasoning", "service_tier", "text", "tools", "x_agents_core") != nil { writeError(w, http.StatusBadRequest, "invalid_request", "Request must be a JSON object containing supported fields.") diff --git a/services/agents-api/internal/api/agents_update.go b/services/agents-api/internal/api/agents_update.go index a3cf80bb5..45f5a0ec1 100644 --- a/services/agents-api/internal/api/agents_update.go +++ b/services/agents-api/internal/api/agents_update.go @@ -54,6 +54,9 @@ func resolveAgentUpdate(raw []byte) (store.UpdateAgentInput, error) { if err := validateSavedAgentBody(raw, savedAgentUpdate); err != nil { return store.UpdateAgentInput{}, err } + if err := validateSavedCoreInput(raw); err != nil { + return store.UpdateAgentInput{}, err + } var request v1.UpdateAgentRequest if decodeInputObject(raw, &request, "model", "name", "instructions", "metadata", "multi_agent", "reasoning", "service_tier", "text", "tools", "x_agents_core") != nil { return store.UpdateAgentInput{}, errors.New("Request must be a JSON object containing supported fields.") @@ -81,7 +84,23 @@ func resolveAgentUpdate(raw []byte) (store.UpdateAgentInput, error) { delete(patch, field) } } - result := store.UpdateAgentInput{} + result := store.UpdateAgentInput{ModelProvider: normalized.ModelProvider} + if extension, supplied := fields["x_agents_core"]; supplied { + if request.XAgentsCore == nil { + result.ModelProviderSet = true + } else { + _, coreFields := orderedMembers(extension) + _, result.ModelProviderSet = coreFields["model_provider"] + if result.ModelProviderSet && request.XAgentsCore.ModelProvider == nil { + _, corePatch := orderedMembers(patch["x_agents_core"]) + corePatch["model_provider"] = json.RawMessage(`null`) + patch["x_agents_core"], err = json.Marshal(corePatch) + if err != nil { + return store.UpdateAgentInput{}, err + } + } + } + } if _, supplied := fields["metadata"]; supplied { result.Metadata = &normalized.Metadata } diff --git a/services/agents-api/internal/api/handler.go b/services/agents-api/internal/api/handler.go index 02b6367da..efa9ba580 100644 --- a/services/agents-api/internal/api/handler.go +++ b/services/agents-api/internal/api/handler.go @@ -36,26 +36,27 @@ type ResourceStore interface { } type Handler struct { - sandboxStore *store.Store - deploymentAuth *DeploymentAuthenticator - sandboxSetup func(context.Context, store.SandboxDeploymentSetupRequest) (store.RuntimeDeploymentView, error) - policy execution.Policy - store ResourceStore - auth *Authenticator - harnesses map[string]bool - engine string - inputs InputSubmitter - executorURL string - hostedEnvironments bool - directoryReader EnvironmentDirectoryReader - fileWriter EnvironmentFileWriter - skills SkillStore - sourceFiles SourceFileStore - artifacts SessionArtifactStore - subagents SubagentStore - runtimeObservations RuntimeObservationService - runtimeHistory RuntimeHistoryService - startup *v1.CoreStartupConfiguration + sandboxStore *store.Store + deploymentAuth *DeploymentAuthenticator + sandboxSetup func(context.Context, store.SandboxDeploymentSetupRequest) (store.RuntimeDeploymentView, error) + policy execution.Policy + store ResourceStore + auth *Authenticator + harnesses map[string]bool + modelProviderDefaults ModelProviderDefaults + engine string + inputs InputSubmitter + executorURL string + hostedEnvironments bool + directoryReader EnvironmentDirectoryReader + fileWriter EnvironmentFileWriter + skills SkillStore + sourceFiles SourceFileStore + artifacts SessionArtifactStore + subagents SubagentStore + runtimeObservations RuntimeObservationService + runtimeHistory RuntimeHistoryService + startup *v1.CoreStartupConfiguration } func NewHandler(s ResourceStore, auth *Authenticator, engine string, options ...Option) (http.Handler, error) { @@ -145,7 +146,7 @@ func NewHandler(s ResourceStore, auth *Authenticator, engine string, options ... // createSession atomically reserves or admits initial text with the Session. // @Summary Create an execution Session -// @Description 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 explicit service origin, native allowed_tools and boolean required defaulting to false. Session vault_ids attach only project-owned Vaults; credential_id selects an attached static bearer credential for the exact HTTPS URL, while null/omission selects a unique match or remains anonymous. Ambiguous selection rejects creation. Frozen private selections never populate an omitted public credential_id; 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 OAuth 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 explicit 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; unrelated 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 remain unqualified. 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 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 explicit service origin, native allowed_tools and boolean required defaulting to false. Session vault_ids attach only project-owned Vaults; credential_id selects an attached static bearer credential for the exact HTTPS URL, while null/omission selects a unique match or remains anonymous. Ambiguous selection rejects creation. Frozen private selections never populate an omitted public credential_id; 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 OAuth 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 explicit 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 remain unqualified. 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 @@ -213,21 +214,12 @@ func (h *Handler) createSession(w http.ResponseWriter, r *http.Request) { } return } - var saved *v1.SavedAgent - if input.AgentID != nil { - resource, err := h.lookupAgent(r.Context(), tenantID(r), *input.AgentID) - if err != nil { - if h.recoverSessionCreation(w, r, key, creationRequest, input.Stream) { - return - } - writeStoreError(w, r, err) - return - } - saved = &v1.SavedAgent{ID: resource.ID} - if err := json.Unmarshal(resource.Configuration, &saved.SavedAgentConfiguration); err != nil { + saved, inheritedProvider, err := h.sessionAgentDefaults(r.Context(), tenantID(r), input) + if err != nil { + if !h.recoverSessionCreation(w, r, key, creationRequest, input.Stream) { writeStoreError(w, r, err) - return } + return } configuration, err := resolve(input, tenantID(r), key, saved) if err == nil { @@ -240,26 +232,10 @@ func (h *Handler) createSession(w http.ResponseWriter, r *http.Request) { } } selectedEngine := h.engine + var provider *v1.ModelProviderInput + var providerOptions map[string]any if err == nil { - selectedEngine, err = h.sessionHarness(configuration) - } - if err == nil && input.XAgentsCore != nil && input.XAgentsCore.ModelProvider != nil { - err = input.XAgentsCore.ModelProvider.ValidateHarness(selectedEngine) - if err == nil && input.Environment.Type != "openai_hosted" { - err = fmt.Errorf("caller model credentials currently require a hosted environment") - } - } - if err == nil && input.XAgentsCore != nil { - extension := input.XAgentsCore - if extension.ModelProvider == nil && extension.SandboxNodeID == nil { - err = fmt.Errorf("x_agents_core requires an execution option") - } - if extension.SandboxNodeID != nil { - id, parseErr := uuid.Parse(*extension.SandboxNodeID) - if parseErr != nil || id == uuid.Nil || id.String() != *extension.SandboxNodeID || input.Environment.Type != "openai_hosted" { - err = fmt.Errorf("sandbox_node_id requires a canonical UUID and hosted environment") - } - } + selectedEngine, provider, providerOptions, err = h.resolveSessionExecution(r.Context(), input, inheritedProvider, configuration) } if err == nil { if invalid := h.policy.ValidateSessionConfiguration(selectedEngine, configuration); invalid != nil { @@ -283,12 +259,9 @@ func (h *Handler) createSession(w http.ResponseWriter, r *http.Request) { writeError(w, http.StatusServiceUnavailable, "execution_unavailable", "Hosted execution is not configured on this service.") return } - var provider *v1.ModelProviderInput - if input.XAgentsCore != nil { - provider = input.XAgentsCore.ModelProvider - } createInput := store.CreateSessionInput{ ModelProvider: provider, + ModelOptions: providerOptions, 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/harness_test.go b/services/agents-api/internal/api/harness_test.go index 7d8052029..d560affee 100644 --- a/services/agents-api/internal/api/harness_test.go +++ b/services/agents-api/internal/api/harness_test.go @@ -50,7 +50,7 @@ func TestSessionHarnessAdmission(t *testing.T) { func TestSavedHarnessReplacementAndEffectiveRead(t *testing.T) { model := "fixture" - resolved, err := resolveSavedAgent(v1.CreateAgentRequest{Model: &model, XAgentsCore: &v1.AgentsCore{Harness: "claude_sdk"}}) + resolved, err := resolveSavedAgent(v1.CreateAgentRequest{Model: &model, XAgentsCore: &v1.SavedAgentCoreInput{Harness: "claude_sdk"}}) if err != nil { t.Fatal(err) } diff --git a/services/agents-api/internal/api/saved_configuration.go b/services/agents-api/internal/api/saved_configuration.go index 32421a0fa..8c372eaec 100644 --- a/services/agents-api/internal/api/saved_configuration.go +++ b/services/agents-api/internal/api/saved_configuration.go @@ -36,7 +36,7 @@ func resolveSavedFields(input v1.CreateAgentRequest) (store.CreateAgentInput, er if err := input.XAgentsCore.Validate(); err != nil { return store.CreateAgentInput{}, err } - cfg := v1.SavedAgentConfiguration{XAgentsCore: input.XAgentsCore, Name: input.Name, Instructions: input.Instructions, ServiceTier: "auto"} + cfg := v1.SavedAgentConfiguration{XAgentsCore: input.XAgentsCore.SafeView(), Name: input.Name, Instructions: input.Instructions, ServiceTier: "auto"} if input.Model != nil { cfg.Model = *input.Model } @@ -70,7 +70,11 @@ func resolveSavedFields(input v1.CreateAgentRequest) (store.CreateAgentInput, er return store.CreateAgentInput{}, err } configuration, err := json.Marshal(cfg) - return store.CreateAgentInput{Metadata: metadata, Configuration: configuration}, err + result := store.CreateAgentInput{Metadata: metadata, Configuration: configuration} + if input.XAgentsCore != nil { + result.ModelProvider = input.XAgentsCore.ModelProvider + } + return result, err } func resolveSavedMultiAgent(raw json.RawMessage) (v1.MultiAgentConfig, error) { diff --git a/services/agents-api/internal/api/saved_core_input.go b/services/agents-api/internal/api/saved_core_input.go new file mode 100644 index 000000000..00a250c31 --- /dev/null +++ b/services/agents-api/internal/api/saved_core_input.go @@ -0,0 +1,29 @@ +package api + +import ( + "encoding/json" + "errors" +) + +// Validate presence before decoding: a supplied empty/null harness is invalid, +// and a provider replacement needs the complete write-only credential bundle. +// Extension errors never echo user values or unknown member names. +func validateSavedCoreInput(raw []byte) error { + _, fields := orderedMembers(raw) + extension, supplied := fields["x_agents_core"] + if !supplied { + return nil + } + coreShape := shape{kind: objectValue, nullable: true, members: []member{ + {"harness", shape{kind: enumValue, values: []string{"codex", "claude_sdk", "mcode"}}}, + {"model_provider", shape{kind: objectValue, nullable: true, 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}}, + }}}, + }} + if !json.Valid(extension) || checkValue("x_agents_core", extension, coreShape) != nil { + return errors.New("x_agents_core must contain an optional supported harness and an optional complete model_provider input.") + } + return nil +} diff --git a/services/agents-api/internal/api/saved_provider_test.go b/services/agents-api/internal/api/saved_provider_test.go new file mode 100644 index 000000000..fcd8960a3 --- /dev/null +++ b/services/agents-api/internal/api/saved_provider_test.go @@ -0,0 +1,145 @@ +package api + +import ( + "context" + "encoding/json" + "net/http" + "strings" + "testing" + + v1 "github.com/MiniMax-AI-Dev/parsar/contracts/agents-api/v1" + "github.com/MiniMax-AI-Dev/parsar/services/agents-api/internal/store" + "github.com/google/uuid" +) + +const savedProviderFixture = `{"protocol":"responses","base_url":"https://example.test/v1","api_key":"saved-provider-secret","context_window":100000,"max_output_tokens":8000}` + +type savedProviderStore struct { + ResourceStore + saved store.SavedAgent + provider *v1.ModelProviderInput +} + +func (s *savedProviderStore) CreateAgent(_ context.Context, tenant string, input store.CreateAgentInput) (store.SavedAgent, error) { + s.provider = input.ModelProvider + s.saved = store.SavedAgent{ID: uuid.NewString(), TenantID: tenant, Configuration: input.Configuration, Metadata: input.Metadata} + return s.saved, nil +} + +func (s *savedProviderStore) UpdateAgent(_ context.Context, _, _ string, input store.UpdateAgentInput) (store.SavedAgent, error) { + s.provider = input.ModelProvider + s.saved.Configuration = input.Configuration + return s.saved, nil +} + +func (s *savedProviderStore) GetAgent(context.Context, string, string) (store.SavedAgent, error) { + return s.saved, nil +} + +func (s *savedProviderStore) ListAgents(context.Context, string, string, int, bool) (store.AgentPage, error) { + return store.AgentPage{Agents: []store.SavedAgent{s.saved}}, nil +} + +func TestSavedProviderReadRedaction(t *testing.T) { + h, recording, _ := testHandler(t) + s := &savedProviderStore{} + recording.ResourceStore = s + body := `{"model":"fixture","x_agents_core":{"harness":"codex","model_provider":` + savedProviderFixture + `}}` + created := credentialRequest(h, http.MethodPost, "/v1/agents", body) + if created.Code != http.StatusCreated || s.provider == nil || s.provider.APIKey != "saved-provider-secret" { + t.Fatalf("create failed: %d %s", created.Code, created.Body) + } + for _, tc := range []struct { + method, path, body string + status int + }{ + {http.MethodGet, "/v1/agents/" + s.saved.ID, "", http.StatusOK}, + {http.MethodGet, "/v1/agents", "", http.StatusOK}, + {http.MethodPost, "/v1/agents/" + s.saved.ID, body, http.StatusOK}, + } { + response := credentialRequest(h, tc.method, tc.path, tc.body) + if response.Code != tc.status { + t.Fatalf("%s: %d %s", tc.path, response.Code, response.Body) + } + assertSavedProviderRedacted(t, response.Body.String()) + } + assertSavedProviderRedacted(t, created.Body.String()) + assertSavedProviderRedacted(t, string(s.saved.Configuration)) +} + +func assertSavedProviderRedacted(t *testing.T, raw string) { + t.Helper() + if strings.Contains(raw, "saved-provider-secret") || strings.Contains(raw, `"api_key":`) || !strings.Contains(raw, `"api_key_configured":true`) || !strings.Contains(raw, `"base_url":"https://example.test/v1"`) { + t.Fatalf("incorrect safe provider view: %s", raw) + } +} + +func TestSavedProviderWithoutHarnessDefersCompatibility(t *testing.T) { + h, recording, _ := testHandler(t) + s := &savedProviderStore{} + recording.ResourceStore = s + provider := strings.Replace(savedProviderFixture, `"responses"`, `"anthropic"`, 1) + response := credentialRequest(h, http.MethodPost, "/v1/agents", `{"model":"fixture","x_agents_core":{"model_provider":`+provider+`}}`) + if response.Code != http.StatusCreated || s.provider == nil || s.provider.Protocol != "anthropic" { + t.Fatalf("deployment default must not constrain saved provider: %d %s", response.Code, response.Body) + } + if strings.Contains(response.Body.String(), `"harness"`) { + t.Fatal("saving a provider must not infer a harness") + } +} + +func TestSavedProviderUpdatePresence(t *testing.T) { + for _, tc := range []struct { + name, body, patch string + set, provider bool + }{ + {"omitted extension", `{"model":"new"}`, `{"model":"new"}`, false, false}, + {"harness only", `{"x_agents_core":{"harness":"codex"}}`, `{"x_agents_core":{"harness":"codex"}}`, false, false}, + {"empty extension", `{"x_agents_core":{}}`, `{"x_agents_core":{}}`, false, false}, + {"clear extension", `{"x_agents_core":null}`, `{"x_agents_core":null}`, true, false}, + {"clear provider", `{"x_agents_core":{"model_provider":null}}`, `{"x_agents_core":{"model_provider":null}}`, true, false}, + {"replace provider", `{"x_agents_core":{"model_provider":` + savedProviderFixture + `}}`, "", true, true}, + } { + t.Run(tc.name, func(t *testing.T) { + input, err := resolveAgentUpdate([]byte(tc.body)) + if err != nil || input.ModelProviderSet != tc.set || (input.ModelProvider != nil) != tc.provider { + t.Fatalf("set=%v provider=%v err=%v", input.ModelProviderSet, input.ModelProvider != nil, err) + } + if tc.patch != "" && string(input.Configuration) != tc.patch { + t.Fatalf("patch=%s", input.Configuration) + } + if tc.provider { + assertSavedProviderRedacted(t, string(input.Configuration)) + var cfg v1.SavedAgentConfiguration + if json.Unmarshal(input.Configuration, &cfg) != nil || cfg.XAgentsCore.Harness != "" { + t.Fatal("provider-only patch must preserve the stored harness") + } + } + }) + } +} + +func TestSavedProviderInvalidInput(t *testing.T) { + for _, extension := range []string{ + `{"harness":""}`, `{"harness":null}`, `{"harness":"saved-provider-secret"}`, + `{"model_provider":{}}`, `{"model_provider":{"protocol":"responses"}}`, + `{"model_provider":{"protocol":"responses","base_url":"https://example.test","api_key_configured":true}}`, + `{"model_provider":{"protocol":"responses","base_url":"https://example.test","api_key":"saved-provider-secret","api_key_configured":true}}`, + `{"model_provider":{"protocol":"responses","base_url":"https://example.test","api_key":"saved-provider-secret","context_window":null}}`, + `{"model_provider":{"protocol":"responses","base_url":"https://example.test","api_key":null}}`, + `{"model_provider":{"protocol":"responses","base_url":"http://example.test","api_key":"saved-provider-secret"}}`, + `{"model_provider":{"protocol":"responses","base_url":"https://user:saved-provider-secret@example.test","api_key":"saved-provider-secret"}}`, + `{"model_provider":{"protocol":"responses","base_url":"https://example.test","api_key":"saved-provider-secret","API_KEY":"secret"}}`, + `{"model_provider":{"protocol":"responses","base_url":"https://example.test","api_key":"saved-provider-secret","api_key":"other"}}`, + `{"harness":"claude_sdk","model_provider":` + savedProviderFixture + `}`, + `{"model_provider":` + strings.Replace(savedProviderFixture, `"max_output_tokens":8000`, `"max_output_tokens":100001`, 1) + `}`, + } { + for _, path := range []string{"/v1/agents", "/v1/agents/" + uuid.NewString()} { + h, s := validationHandler(t) + response := credentialRequest(h, http.MethodPost, path, `{"model":"fixture","x_agents_core":`+extension+`}`) + if response.Code != http.StatusBadRequest || s.writes != 0 || strings.Contains(response.Body.String(), "saved-provider-secret") { + t.Fatalf("extension=%s status=%d writes=%d body=%s", extension, response.Code, s.writes, response.Body) + } + } + } +} diff --git a/services/agents-api/internal/api/session_agent.go b/services/agents-api/internal/api/session_agent.go index 08e15faca..0c89044a2 100644 --- a/services/agents-api/internal/api/session_agent.go +++ b/services/agents-api/internal/api/session_agent.go @@ -14,7 +14,12 @@ import ( func resolveSessionAgent(input sessionRequest, saved *v1.SavedAgent) (v1.Agent, error) { request := v1.CreateAgentRequest{} if agent := input.Agent; agent != nil { - request.XAgentsCore = agent.XAgentsCore + if err := agent.XAgentsCore.Validate(); err != nil { + return v1.Agent{}, err + } + if agent.XAgentsCore != nil { + request.XAgentsCore = &v1.SavedAgentCoreInput{Harness: agent.XAgentsCore.Harness} + } request.Model, request.Instructions = agent.Model, agent.Instructions request.MultiAgent, request.Reasoning, request.ServiceTier = agent.MultiAgent, agent.Reasoning, agent.ServiceTier request.Text, request.Tools = agent.Text, agent.Tools @@ -83,7 +88,11 @@ func admitSessionAgent(cfg v1.SavedAgentConfiguration) (v1.Agent, error) { if err != nil { return v1.Agent{}, err } - return v1.Agent{XAgentsCore: cfg.XAgentsCore, Model: cfg.Model, Name: cfg.Name, Instructions: cfg.Instructions, + var extension *v1.AgentsCore + if cfg.XAgentsCore != nil && cfg.XAgentsCore.Harness != "" { + extension = &v1.AgentsCore{Harness: cfg.XAgentsCore.Harness} + } + return v1.Agent{XAgentsCore: extension, Model: cfg.Model, Name: cfg.Name, Instructions: cfg.Instructions, MultiAgent: cfg.MultiAgent, Reasoning: cfg.Reasoning, ServiceTier: cfg.ServiceTier, Text: text, Tools: tools}, nil } diff --git a/services/agents-api/internal/api/session_creation_identity.go b/services/agents-api/internal/api/session_creation_identity.go index 320869c02..0e48d93fd 100644 --- a/services/agents-api/internal/api/session_creation_identity.go +++ b/services/agents-api/internal/api/session_creation_identity.go @@ -11,7 +11,7 @@ import ( ) func sessionCreationRequest(input sessionRequest, initial []store.Input) (json.RawMessage, error) { - if input.XAgentsCore == nil && input.AgentID == nil && input.templateID == "" && len(input.initialFiles) == 0 && input.initialization.Empty() && !inlineCredentialIntent(input) && input.agentFields["x_agents_core"] == nil { + if (input.Environment == nil || input.Environment.Type != "openai_hosted") && input.XAgentsCore == nil && input.AgentID == nil && input.templateID == "" && len(input.initialFiles) == 0 && input.initialization.Empty() && !inlineCredentialIntent(input) && input.agentFields["x_agents_core"] == nil { return nil, nil } agentID := "" diff --git a/services/agents-api/internal/api/session_model_defaults.go b/services/agents-api/internal/api/session_model_defaults.go new file mode 100644 index 000000000..73fc41233 --- /dev/null +++ b/services/agents-api/internal/api/session_model_defaults.go @@ -0,0 +1,94 @@ +package api + +import ( + "context" + "encoding/json" + "errors" + + v1 "github.com/MiniMax-AI-Dev/parsar/contracts/agents-api/v1" + "github.com/MiniMax-AI-Dev/parsar/services/agents-api/internal/store" + "github.com/google/uuid" +) + +// ModelProviderDefaults resolves deployment configuration at Session creation, +// before the encrypted Session snapshot is committed. +type ModelProviderDefaults func(context.Context, string, string) (*v1.ModelProviderInput, map[string]any, error) + +func WithModelProviderDefaults(resolve ModelProviderDefaults) Option { + return func(h *Handler) { h.modelProviderDefaults = resolve } +} + +type agentDefaultsStore interface { + GetAgentForSession(context.Context, string, string, bool) (store.SavedAgent, *v1.ModelProviderInput, error) +} + +func (h *Handler) sessionAgentDefaults(ctx context.Context, tenant string, input sessionRequest) (*v1.SavedAgent, *v1.ModelProviderInput, error) { + if input.AgentID == nil { + return nil, nil, nil + } + if !validAgentID(*input.AgentID) { + return nil, nil, store.ErrNotFound + } + inherit := input.XAgentsCore == nil || input.XAgentsCore.ModelProvider == nil + var resource store.SavedAgent + var provider *v1.ModelProviderInput + var err error + if source, ok := h.store.(agentDefaultsStore); ok { + resource, provider, err = source.GetAgentForSession(ctx, tenant, *input.AgentID, inherit) + } else { + resource, err = h.lookupAgent(ctx, tenant, *input.AgentID) + } + if err != nil { + return nil, nil, err + } + saved := &v1.SavedAgent{ID: resource.ID} + if err := json.Unmarshal(resource.Configuration, &saved.SavedAgentConfiguration); err != nil { + return nil, nil, err + } + if inherit && saved.XAgentsCore != nil && saved.XAgentsCore.ModelProvider != nil && provider == nil { + return nil, nil, store.ErrCredentialStorageUnavailable + } + 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) { + engine, err := h.sessionHarness(raw) + if err != nil { + return "", nil, nil, err + } + provider := inherited + var options map[string]any + if extension := input.XAgentsCore; extension != nil { + if extension.ModelProvider == nil && extension.SandboxNodeID == nil && !input.modelProviderNull { + return "", nil, nil, errors.New("x_agents_core requires an execution option") + } + if extension.ModelProvider != nil { + provider = extension.ModelProvider + } + if extension.SandboxNodeID != nil { + id, parseErr := uuid.Parse(*extension.SandboxNodeID) + if parseErr != nil || id == uuid.Nil || id.String() != *extension.SandboxNodeID || input.Environment.Type != "openai_hosted" { + return "", nil, nil, errors.New("sandbox_node_id requires a canonical UUID and hosted environment") + } + } + } + if provider == nil && input.Environment.Type == "openai_hosted" && h.modelProviderDefaults != nil { + var cfg configuration + if err := json.Unmarshal(raw, &cfg); err != nil { + return "", nil, nil, err + } + provider, options, err = h.modelProviderDefaults(ctx, engine, cfg.Agent.Model) + if err != nil { + return "", nil, nil, errors.New("deployment model provider configuration is unavailable") + } + } + if provider != nil { + if input.Environment.Type != "openai_hosted" { + return "", nil, nil, errors.New("caller model credentials currently require a hosted environment") + } + if err := provider.ValidateHarness(engine); err != nil { + return "", nil, nil, err + } + } + return engine, provider, options, nil +} diff --git a/services/agents-api/internal/api/session_request.go b/services/agents-api/internal/api/session_request.go index 33fea6d29..a33cfaa38 100644 --- a/services/agents-api/internal/api/session_request.go +++ b/services/agents-api/internal/api/session_request.go @@ -12,6 +12,7 @@ import ( // otherwise erase it. The embedded wire type retains strict nested decoding. type decodedSessionRequest struct { v1.CreateSessionRequest + Execution json.RawMessage `json:"x_agents_core"` Input json.RawMessage `json:"input"` Agent json.RawMessage `json:"agent"` AgentID json.RawMessage `json:"agent_id"` @@ -25,6 +26,7 @@ type sessionRequest struct { initialFiles []store.InitialFile initialization store.EnvironmentSetup originalEnvironment json.RawMessage + modelProviderNull bool v1.CreateSessionRequest Input json.RawMessage templateID string @@ -34,6 +36,14 @@ type sessionRequest struct { func (request decodedSessionRequest) validated() (sessionRequest, error) { input := sessionRequest{CreateSessionRequest: request.CreateSessionRequest, Input: request.Input} + if len(request.Execution) > 0 && !bytes.Equal(bytes.TrimSpace(request.Execution), []byte("null")) { + if decodeInputObject(request.Execution, &input.XAgentsCore, "model_provider", "sandbox_node_id") != nil { + return input, store.ErrInvalidInput + } + var fields map[string]json.RawMessage + _ = json.Unmarshal(request.Execution, &fields) + input.modelProviderNull = bytes.Equal(bytes.TrimSpace(fields["model_provider"]), []byte("null")) + } var vaultIDs []*string if len(request.VaultIDs) != 0 && json.Unmarshal(request.VaultIDs, &vaultIDs) != nil { return input, store.ErrInvalidInput diff --git a/services/agents-api/internal/credentialcrypto/agent_model_execution.go b/services/agents-api/internal/credentialcrypto/agent_model_execution.go new file mode 100644 index 000000000..05ab2ac7f --- /dev/null +++ b/services/agents-api/internal/credentialcrypto/agent_model_execution.go @@ -0,0 +1,29 @@ +package credentialcrypto + +import ( + "encoding/json" + "unicode/utf8" +) + +func agentModelExecutionData(tenantID, agentID string) ([]byte, error) { + if tenantID == "" || agentID == "" || !utf8.ValidString(tenantID) || !utf8.ValidString(agentID) { + return nil, errInvalidBinding + } + return json.Marshal([]string{"parsar.agents-api.agent-model-execution.v1", tenantID, agentID}) +} + +func (c *Cipher) SealAgentModelExecution(plaintext []byte, tenantID, agentID string) ([]byte, error) { + aad, err := agentModelExecutionData(tenantID, agentID) + if err != nil { + return nil, err + } + return c.seal(plaintext, aad) +} + +func (c *Cipher) OpenAgentModelExecution(ciphertext []byte, tenantID, agentID string) ([]byte, error) { + aad, err := agentModelExecutionData(tenantID, agentID) + if err != nil { + return nil, err + } + return c.open(ciphertext, aad) +} diff --git a/services/agents-api/internal/credentialcrypto/agent_model_execution_test.go b/services/agents-api/internal/credentialcrypto/agent_model_execution_test.go new file mode 100644 index 000000000..3973d8f72 --- /dev/null +++ b/services/agents-api/internal/credentialcrypto/agent_model_execution_test.go @@ -0,0 +1,41 @@ +package credentialcrypto + +import ( + "bytes" + "testing" +) + +func TestAgentModelExecutionBinding(t *testing.T) { + c, err := New(bytes.Repeat([]byte{23}, 32)) + if err != nil { + t.Fatal(err) + } + raw := []byte("private-provider-canary") + encrypted, err := c.SealAgentModelExecution(raw, "tenant", "agent") + if err != nil || bytes.Contains(encrypted, raw) { + t.Fatal("credential encryption failed") + } + opened, err := c.OpenAgentModelExecution(encrypted, "tenant", "agent") + if err != nil || !bytes.Equal(raw, opened) { + t.Fatal("credential round trip failed") + } + for _, binding := range [][2]string{{"foreign", "agent"}, {"tenant", "other"}, {"", "agent"}, {"tenant", "\xff"}} { + if _, err := c.OpenAgentModelExecution(encrypted, binding[0], binding[1]); err == nil { + t.Fatal("incorrect binding accepted") + } + } + if _, err := c.OpenModelExecution(encrypted, "tenant", "agent"); err == nil { + t.Fatal("Agent ciphertext accepted for Session purpose") + } + session, err := c.SealModelExecution(raw, "tenant", "agent") + if err != nil { + t.Fatal(err) + } + if _, err := c.OpenAgentModelExecution(session, "tenant", "agent"); err == nil { + t.Fatal("Session ciphertext accepted for Agent purpose") + } + encrypted[len(encrypted)-1] ^= 1 + if _, err := c.OpenAgentModelExecution(encrypted, "tenant", "agent"); err == nil { + t.Fatal("tampered ciphertext accepted") + } +} diff --git a/services/agents-api/internal/db/queries/agent_model_execution.sql b/services/agents-api/internal/db/queries/agent_model_execution.sql new file mode 100644 index 000000000..e90e0f1b9 --- /dev/null +++ b/services/agents-api/internal/db/queries/agent_model_execution.sql @@ -0,0 +1,11 @@ +-- name: SaveAgentModelExecution :exec +INSERT INTO agent_model_execution(agent_id, encrypted_config) VALUES (@agent_id, @encrypted_config) +ON CONFLICT (agent_id) DO UPDATE SET encrypted_config = EXCLUDED.encrypted_config; + +-- name: DeleteAgentModelExecution :exec +DELETE FROM agent_model_execution WHERE agent_id = @agent_id; + +-- name: GetAgentForSession :one +SELECT a.*, e.encrypted_config FROM agents a +LEFT JOIN agent_model_execution e ON e.agent_id = a.id +WHERE a.tenant_id = @tenant_id AND a.id = @agent_id; diff --git a/services/agents-api/internal/db/sqlc/agent_model_execution.sql.go b/services/agents-api/internal/db/sqlc/agent_model_execution.sql.go new file mode 100644 index 000000000..0051b6768 --- /dev/null +++ b/services/agents-api/internal/db/sqlc/agent_model_execution.sql.go @@ -0,0 +1,72 @@ +// Code generated by sqlc. DO NOT EDIT. +// versions: +// sqlc v1.29.0 +// source: agent_model_execution.sql + +package sqlc + +import ( + "context" + + "github.com/jackc/pgx/v5/pgtype" +) + +const deleteAgentModelExecution = `-- name: DeleteAgentModelExecution :exec +DELETE FROM agent_model_execution WHERE agent_id = $1 +` + +func (q *Queries) DeleteAgentModelExecution(ctx context.Context, agentID pgtype.UUID) error { + _, err := q.db.Exec(ctx, deleteAgentModelExecution, agentID) + return err +} + +const getAgentForSession = `-- name: GetAgentForSession :one +SELECT a.id, a.tenant_id, a.metadata, a.configuration, a.created_at, a.updated_at, e.encrypted_config FROM agents a +LEFT JOIN agent_model_execution e ON e.agent_id = a.id +WHERE a.tenant_id = $1 AND a.id = $2 +` + +type GetAgentForSessionParams struct { + TenantID pgtype.UUID `json:"tenant_id"` + AgentID pgtype.UUID `json:"agent_id"` +} + +type GetAgentForSessionRow struct { + ID pgtype.UUID `json:"id"` + TenantID pgtype.UUID `json:"tenant_id"` + Metadata []byte `json:"metadata"` + Configuration []byte `json:"configuration"` + CreatedAt pgtype.Timestamptz `json:"created_at"` + UpdatedAt pgtype.Timestamptz `json:"updated_at"` + EncryptedConfig []byte `json:"encrypted_config"` +} + +func (q *Queries) GetAgentForSession(ctx context.Context, arg GetAgentForSessionParams) (GetAgentForSessionRow, error) { + row := q.db.QueryRow(ctx, getAgentForSession, arg.TenantID, arg.AgentID) + var i GetAgentForSessionRow + err := row.Scan( + &i.ID, + &i.TenantID, + &i.Metadata, + &i.Configuration, + &i.CreatedAt, + &i.UpdatedAt, + &i.EncryptedConfig, + ) + return i, err +} + +const saveAgentModelExecution = `-- name: SaveAgentModelExecution :exec +INSERT INTO agent_model_execution(agent_id, encrypted_config) VALUES ($1, $2) +ON CONFLICT (agent_id) DO UPDATE SET encrypted_config = EXCLUDED.encrypted_config +` + +type SaveAgentModelExecutionParams struct { + AgentID pgtype.UUID `json:"agent_id"` + EncryptedConfig []byte `json:"encrypted_config"` +} + +func (q *Queries) SaveAgentModelExecution(ctx context.Context, arg SaveAgentModelExecutionParams) error { + _, err := q.db.Exec(ctx, saveAgentModelExecution, arg.AgentID, arg.EncryptedConfig) + return err +} diff --git a/services/agents-api/internal/db/sqlc/models.go b/services/agents-api/internal/db/sqlc/models.go index 190ca6ab7..2c85af614 100644 --- a/services/agents-api/internal/db/sqlc/models.go +++ b/services/agents-api/internal/db/sqlc/models.go @@ -17,6 +17,11 @@ type Agent struct { UpdatedAt pgtype.Timestamptz `json:"updated_at"` } +type AgentModelExecution struct { + AgentID pgtype.UUID `json:"agent_id"` + EncryptedConfig []byte `json:"encrypted_config"` +} + type Device struct { ID pgtype.UUID `json:"id"` TenantID pgtype.UUID `json:"tenant_id"` diff --git a/services/agents-api/internal/execution/model_execution.go b/services/agents-api/internal/execution/model_execution.go index affa2a176..2b68cc29b 100644 --- a/services/agents-api/internal/execution/model_execution.go +++ b/services/agents-api/internal/execution/model_execution.go @@ -4,6 +4,7 @@ import ( "context" "errors" + v1 "github.com/MiniMax-AI-Dev/parsar/contracts/agents-api/v1" "github.com/MiniMax-AI-Dev/parsar/services/agents-api/internal/store" ) @@ -11,14 +12,21 @@ func (d *Dispatcher) sessionModelOptions(ctx context.Context, session store.Sess if d.Store == nil { return nil, errors.New("session model configuration is unavailable") } - provider, err := d.Store.SessionModelExecution(ctx, session.TenantID, session.ID) + provider, frozenOptions, err := d.Store.SessionModelExecutionWithOptions(ctx, session.TenantID, session.ID) if err != nil { return nil, err } - if err := provider.ValidateHarness(session.Engine); err != nil { + return resolvedSessionModelOptions(provider, frozenOptions, session.Engine, model) +} + +func resolvedSessionModelOptions(provider *v1.ModelProviderInput, frozenOptions map[string]any, engine, model string) (map[string]any, error) { + if err := provider.ValidateHarness(engine); err != nil { return nil, err } - switch session.Engine { + if frozenOptions != nil { + return frozenOptions, nil + } + switch engine { case "codex": return map[string]any{"codex_provider": map[string]any{"base_url": provider.BaseURL, "bearer_token": provider.APIKey, "wire_api": "responses"}}, nil case "claude_sdk": diff --git a/services/agents-api/internal/execution/model_execution_test.go b/services/agents-api/internal/execution/model_execution_test.go index 20fd6b548..3ccb00d4d 100644 --- a/services/agents-api/internal/execution/model_execution_test.go +++ b/services/agents-api/internal/execution/model_execution_test.go @@ -2,8 +2,10 @@ package execution import ( "context" + 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" + "reflect" "testing" ) @@ -21,3 +23,27 @@ func TestSessionModelExecutionNeverFallsBackToOperatorCredentials(t *testing.T) t.Fatal("legacy operator configuration lost", err) } } + +func TestFrozenDeploymentOptionsRetainNativeProviderSettings(t *testing.T) { + provider := &v1.ModelProviderInput{Protocol: "responses", BaseURL: "https://example.com/v1", APIKey: "private-key"} + frozen := 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-custom-header": "private-header"}, + "query_params": map[string]any{"api-version": "2026-01-01"}, + }, "mode": "default"} + got, err := resolvedSessionModelOptions(provider, frozen, "codex", "actual-model") + if err != nil || !reflect.DeepEqual(got, frozen) { + t.Fatal("native deployment settings were lost", err) + } + if _, err := resolvedSessionModelOptions(provider, frozen, "claude_sdk", "actual-model"); err == nil { + t.Fatal("frozen options bypassed provider/harness validation") + } + explicit, err := resolvedSessionModelOptions(provider, nil, "codex", "actual-model") + if err != nil { + t.Fatal(err) + } + codex, ok := explicit["codex_provider"].(map[string]any) + if !ok || codex["base_url"] != provider.BaseURL || codex["bearer_token"] != provider.APIKey || len(codex) != 3 { + t.Fatal("explicit provider mapping changed") + } +} 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 new file mode 100644 index 000000000..1ba42b3d6 --- /dev/null +++ b/services/agents-api/internal/store/agent_execution_defaults_http_test.go @@ -0,0 +1,136 @@ +package store_test + +import ( + "bytes" + "context" + "encoding/json" + "net/http/httptest" + "strings" + "testing" + + 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/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" +) + +func TestAgentExecutionDefaultsPublicSnapshotAndPrecedence(t *testing.T) { + _, pool := store.NewTestStore(t) + cipher, _ := credentialcrypto.New(bytes.Repeat([]byte{19}, 32)) + st := store.NewWithCredentialCipher(pool, cipher) + tenant, token := uuid.NewString(), uuid.NewString() + auth, err := api.NewAuthenticator([]api.APIKey{{OrganizationID: "test-org", ProjectID: uuid.NewString(), SubjectKind: "service_account", SubjectID: "defaults-test", TokenSHA256: device.HashCredential(token), TenantID: tenant}}) + if err != nil { + t.Fatal(err) + } + 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) { + defaultsCalls++ + copy := *deployment + return ©, nil, nil + })) + if err != nil { + t.Fatal(err) + } + call := func(method, path, body, key string, status int) map[string]json.RawMessage { + t.Helper() + r := httptest.NewRequest(method, path, strings.NewReader(body)) + r.Header.Set("Authorization", "Bearer "+token) + r.Header.Set("OpenAI-Beta", "agents=v1") + r.Header.Set("Idempotency-Key", key) + w := httptest.NewRecorder() + handler.ServeHTTP(w, r) + for _, forbidden := range []string{"saved-canary", "override-canary", "deployment-canary", `"api_key":`, "encrypted_config"} { + if strings.Contains(w.Body.String(), forbidden) { + t.Fatal("provider secret leaked") + } + } + 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 + if json.Unmarshal(w.Body.Bytes(), &result) != nil { + t.Fatal("invalid response") + } + return result + } + id := func(result map[string]json.RawMessage) string { + var s string + _ = json.Unmarshal(result["id"], &s) + return s + } + createAgent := `{"model":"model-original","x_agents_core":{"harness":"codex","model_provider":{"protocol":"responses","base_url":"https://saved.example/v1","api_key":"saved-canary"}}}` + agent := call("POST", "/v1/agents", createAgent, "", 201) + agentID := id(agent) + if !bytes.Contains(agent["x_agents_core"], []byte(`"api_key_configured":true`)) { + t.Fatal("missing safe configured flag") + } + call("GET", "/v1/agents/"+agentID, "", "", 200) + call("GET", "/v1/agents", "", "", 200) + body := `{"agent_id":"` + agentID + `","environment":{"type":"openai_hosted"}}` + key := uuid.NewString() + sessionID := id(call("POST", "/v1/agents/sessions", body, key, 201)) + assertSnapshot := func(sessionID, model, endpoint, key string) { + t.Helper() + session, err := st.GetSession(t.Context(), tenant, sessionID) + if err != nil { + t.Fatal(err) + } + var cfg struct{ Agent struct{ Model string } } + if json.Unmarshal(session.Configuration, &cfg) != nil || cfg.Agent.Model != model { + t.Fatal("model snapshot changed") + } + provider, err := st.SessionModelExecution(t.Context(), tenant, sessionID) + if err != nil || provider.BaseURL != endpoint || provider.APIKey != key { + t.Fatal("provider snapshot mismatch", err) + } + } + assertSnapshot(sessionID, "model-original", "https://saved.example/v1", "saved-canary") + modelOnly := `{"agent_id":"` + agentID + `","agent":{"model":"model-override"},"environment":{"type":"openai_hosted"}}` + sid := id(call("POST", "/v1/agents/sessions", modelOnly, uuid.NewString(), 201)) + assertSnapshot(sid, "model-override", "https://saved.example/v1", "saved-canary") + replacement := `{"agent_id":"` + agentID + `","environment":{"type":"openai_hosted"},"x_agents_core":{"model_provider":{"protocol":"responses","base_url":"https://override.example/v1","api_key":"override-canary"}}}` + sid = id(call("POST", "/v1/agents/sessions", replacement, uuid.NewString(), 201)) + assertSnapshot(sid, "model-original", "https://override.example/v1", "override-canary") + for _, raw := range []string{ + strings.Replace(replacement, `,"api_key":"override-canary"`, "", 1), + 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) + } + if defaultsCalls != 0 { + t.Fatal("explicit or inherited bundle consulted deployment") + } + nullProvider := strings.TrimSuffix(body, "}") + `,"x_agents_core":{"model_provider":null}}` + sid = id(call("POST", "/v1/agents/sessions", nullProvider, uuid.NewString(), 201)) + assertSnapshot(sid, "model-original", "https://saved.example/v1", "saved-canary") + call("POST", "/v1/agents/"+agentID, `{"model":"model-new","x_agents_core":{"model_provider":{"protocol":"responses","base_url":"https://override.example/v1","api_key":"override-canary"}}}`, "", 200) + fresh := id(call("POST", "/v1/agents/sessions", body, uuid.NewString(), 201)) + assertSnapshot(fresh, "model-new", "https://override.example/v1", "override-canary") + call("DELETE", "/v1/agents/"+agentID, "", "", 200) + if id(call("POST", "/v1/agents/sessions", body, key, 201)) != sessionID { + t.Fatal("retry created another Session") + } + call("POST", "/v1/agents/sessions", body, uuid.NewString(), 404) + st = store.NewWithCredentialCipher(pool, cipher) + assertSnapshot(sessionID, "model-original", "https://saved.example/v1", "saved-canary") + assertSnapshot(fresh, "model-new", "https://override.example/v1", "override-canary") + inline := `{"agent":{"model":"inline-model"},"environment":{"type":"openai_hosted"}}` + inlineKey := uuid.NewString() + sid = id(call("POST", "/v1/agents/sessions", inline, inlineKey, 201)) + assertSnapshot(sid, "inline-model", "https://deployment.example/v1", "deployment-canary") + deployment.BaseURL = "https://changed.example/v1" + deployment.APIKey = "changed-key" + if id(call("POST", "/v1/agents/sessions", inline, inlineKey, 201)) != sid || defaultsCalls != 1 { + t.Fatal("retry re-resolved deployment defaults") + } + assertSnapshot(sid, "inline-model", "https://deployment.example/v1", "deployment-canary") + newID := id(call("POST", "/v1/agents/sessions", inline, uuid.NewString(), 201)) + assertSnapshot(newID, "inline-model", "https://changed.example/v1", "changed-key") +} diff --git a/services/agents-api/internal/store/agent_model_execution.go b/services/agents-api/internal/store/agent_model_execution.go new file mode 100644 index 000000000..171c9a10b --- /dev/null +++ b/services/agents-api/internal/store/agent_model_execution.go @@ -0,0 +1,119 @@ +package store + +import ( + "context" + "encoding/json" + "errors" + "fmt" + + 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/google/uuid" + "github.com/jackc/pgx/v5" + "github.com/jackc/pgx/v5/pgtype" +) + +func (s *Store) saveAgentModelExecution(ctx context.Context, q *sqlc.Queries, tenant string, agent pgtype.UUID, provider *v1.ModelProviderInput) error { + if provider == nil { + return q.DeleteAgentModelExecution(ctx, agent) + } + raw, err := json.Marshal(provider) + if err != nil { + return err + } + encrypted, err := s.credentialCipher.SealAgentModelExecution(raw, tenant, uuid.UUID(agent.Bytes).String()) + if err != nil { + return ErrCredentialStorageUnavailable + } + return q.SaveAgentModelExecution(ctx, sqlc.SaveAgentModelExecutionParams{AgentID: agent, EncryptedConfig: encrypted}) +} + +// GetAgentForSession reads the safe configuration and secret from one database +// snapshot. Explicit Session provider overrides never require Agent decryption. +func (s *Store) GetAgentForSession(ctx context.Context, tenantID, agentID string, inheritProvider bool) (SavedAgent, *v1.ModelProviderInput, error) { + tenant, err := parseID(tenantID) + if err != nil { + return SavedAgent{}, nil, err + } + id, err := parseID(agentID) + if err != nil { + return SavedAgent{}, nil, err + } + row, err := s.queries.GetAgentForSession(ctx, sqlc.GetAgentForSessionParams{TenantID: tenant, AgentID: id}) + if errors.Is(err, pgx.ErrNoRows) { + return SavedAgent{}, nil, ErrNotFound + } + if err != nil { + return SavedAgent{}, nil, fmt.Errorf("get agent for session: %w", err) + } + agent, err := agentFromRow(sqlc.Agent{ID: row.ID, TenantID: row.TenantID, Metadata: row.Metadata, Configuration: row.Configuration, CreatedAt: row.CreatedAt, UpdatedAt: row.UpdatedAt}) + if err != nil || !inheritProvider { + return agent, nil, err + } + var config struct { + Core *v1.SavedAgentCore `json:"x_agents_core"` + } + if err := json.Unmarshal(agent.Configuration, &config); err != nil { + return SavedAgent{}, nil, ErrInvalidInput + } + if config.Core == nil || config.Core.ModelProvider == nil { + return agent, nil, nil + } + raw, err := s.credentialCipher.OpenAgentModelExecution(row.EncryptedConfig, uuid.UUID(tenant.Bytes).String(), uuid.UUID(id.Bytes).String()) + if err != nil { + return SavedAgent{}, nil, ErrCredentialStorageUnavailable + } + var provider v1.ModelProviderInput + if json.Unmarshal(raw, &provider) != nil || provider.Validate() != nil { + return SavedAgent{}, nil, ErrCredentialStorageUnavailable + } + return agent, &provider, nil +} + +func validateAgentModelExecution(configuration []byte, provider *v1.ModelProviderInput) error { + if provider != nil { + if err := provider.Validate(); err != nil { + return fmt.Errorf("%w: %s", ErrInvalidInput, err) + } + } + var config struct { + Core *v1.SavedAgentCore `json:"x_agents_core"` + } + if err := json.Unmarshal(configuration, &config); err != nil { + return ErrInvalidInput + } + if config.Core == nil || config.Core.ModelProvider == nil || config.Core.Harness == "" { + return nil + } + if err := config.Core.ModelProvider.ValidateHarness(config.Core.Harness); err != nil { + return fmt.Errorf("%w: %s", ErrInvalidInput, err) + } + return nil +} + +func mergeAgentConfiguration(configuration, patch map[string]json.RawMessage) error { + for field, value := range patch { + if field == "x_agents_core" && string(value) != "null" { + core := map[string]json.RawMessage{} + if old := configuration[field]; len(old) != 0 && string(old) != "null" { + if err := json.Unmarshal(old, &core); err != nil { + return err + } + } + var changes map[string]json.RawMessage + if err := json.Unmarshal(value, &changes); err != nil { + return err + } + for key, replacement := range changes { + core[key] = replacement + } + merged, err := json.Marshal(core) + if err != nil { + return err + } + value = merged + } + configuration[field] = value + } + return nil +} diff --git a/services/agents-api/internal/store/agent_model_execution_test.go b/services/agents-api/internal/store/agent_model_execution_test.go new file mode 100644 index 000000000..0b8fc607f --- /dev/null +++ b/services/agents-api/internal/store/agent_model_execution_test.go @@ -0,0 +1,207 @@ +package store + +import ( + "bytes" + "encoding/json" + "errors" + "fmt" + "sync" + "testing" + + v1 "github.com/MiniMax-AI-Dev/parsar/contracts/agents-api/v1" + "github.com/MiniMax-AI-Dev/parsar/services/agents-api/internal/credentialcrypto" + "github.com/google/uuid" +) + +func agentProviderFixture(sequence int) *v1.ModelProviderInput { + return &v1.ModelProviderInput{Protocol: "responses", BaseURL: fmt.Sprintf("https://provider-%d.example/v1", sequence), APIKey: fmt.Sprintf("private-agent-canary-%d", sequence)} +} + +func agentProviderConfiguration(t *testing.T, provider *v1.ModelProviderInput, harness string) json.RawMessage { + t.Helper() + raw, err := json.Marshal(map[string]any{"model": "actual-model", "x_agents_core": v1.SavedAgentCore{Harness: harness, ModelProvider: provider.SafeView()}}) + if err != nil { + t.Fatal(err) + } + return raw +} + +func TestAgentModelExecutionAtomicEncryptedSnapshot(t *testing.T) { + _, pool := testStore(t) + c, err := credentialcrypto.New(bytes.Repeat([]byte{31}, 32)) + if err != nil { + t.Fatal(err) + } + s := NewWithCredentialCipher(pool, c) + ctx, tenant := t.Context(), uuid.NewString() + provider := agentProviderFixture(0) + input := CreateAgentInput{Configuration: agentProviderConfiguration(t, provider, "codex"), ModelProvider: provider} + agent, err := s.CreateAgent(ctx, tenant, input) + if err != nil { + t.Fatal(err) + } + var encrypted []byte + if err := pool.QueryRow(ctx, "SELECT encrypted_config FROM agent_model_execution WHERE agent_id=$1", agent.ID).Scan(&encrypted); err != nil { + t.Fatal(err) + } + if bytes.Contains(encrypted, []byte(provider.APIKey)) || bytes.Contains(agent.Configuration, []byte(provider.APIKey)) { + t.Fatal("provider secret exposed") + } + if _, err := c.OpenAgentModelExecution(encrypted, uuid.NewString(), agent.ID); err == nil { + t.Fatal("ciphertext was not tenant bound") + } + if _, err := c.OpenAgentModelExecution(encrypted, tenant, uuid.NewString()); err == nil { + t.Fatal("ciphertext was not Agent bound") + } + if _, _, err := s.GetAgentForSession(ctx, uuid.NewString(), agent.ID, true); !errors.Is(err, ErrNotFound) { + t.Fatal("foreign tenant lookup succeeded") + } + _, inherited, err := s.GetAgentForSession(ctx, tenant, agent.ID, true) + if err != nil || inherited == nil || *inherited != *provider { + t.Fatal("provider snapshot mismatch", err) + } + session, err := s.CreateSession(ctx, tenant, CreateSessionInput{Creator: FixtureCreator(), Engine: "codex", IdempotencyKey: uuid.NewString(), Configuration: []byte(`{"agent":{"model":"actual-model"},"environment":{"type":"openai_hosted"}}`), ModelProvider: inherited}) + if err != nil { + t.Fatal(err) + } + // Omitted provider updates do not require access to the encryption key. + withoutKey := New(pool) + if _, err := withoutKey.UpdateAgent(ctx, tenant, agent.ID, UpdateAgentInput{Configuration: []byte(`{"model":"new-model"}`)}); err != nil { + t.Fatal(err) + } + if _, _, err := withoutKey.GetAgentForSession(ctx, tenant, agent.ID, false); err != nil { + t.Fatal("explicit override required Agent decryption", err) + } + if _, _, err := withoutKey.GetAgentForSession(ctx, tenant, agent.ID, true); !errors.Is(err, ErrCredentialStorageUnavailable) { + t.Fatal("missing cipher accepted", err) + } + if _, err := withoutKey.CreateAgent(ctx, tenant, input); !errors.Is(err, ErrCredentialStorageUnavailable) { + t.Fatal("unencrypted Agent create accepted", err) + } + replacement := agentProviderFixture(1) + if _, err := s.UpdateAgent(ctx, uuid.NewString(), agent.ID, UpdateAgentInput{Configuration: agentProviderConfiguration(t, replacement, "codex"), ModelProvider: replacement, ModelProviderSet: true}); !errors.Is(err, ErrNotFound) { + t.Fatal("foreign tenant replacement accepted", err) + } + if _, err := withoutKey.UpdateAgent(ctx, tenant, agent.ID, UpdateAgentInput{Configuration: agentProviderConfiguration(t, replacement, "codex"), ModelProvider: replacement, ModelProviderSet: true}); !errors.Is(err, ErrCredentialStorageUnavailable) { + t.Fatal("unencrypted replacement accepted", err) + } + current, inherited, err := s.GetAgentForSession(ctx, tenant, agent.ID, true) + if err != nil || inherited == nil || *inherited != *provider || !bytes.Contains(current.Configuration, []byte("new-model")) { + t.Fatal("failed replacement changed snapshot", err) + } + // A database rejection after secret replacement rolls both writes back. + invalidPatch, _ := json.Marshal(map[string]any{"model": "invalid\x00model", "x_agents_core": v1.SavedAgentCore{Harness: "codex", ModelProvider: replacement.SafeView()}}) + if _, err := s.UpdateAgent(ctx, tenant, agent.ID, UpdateAgentInput{Configuration: invalidPatch, ModelProvider: replacement, ModelProviderSet: true}); err == nil { + t.Fatal("unstorable configuration accepted") + } + current, inherited, err = s.GetAgentForSession(ctx, tenant, agent.ID, true) + if err != nil || inherited == nil || *inherited != *provider || !bytes.Contains(current.Configuration, []byte("new-model")) { + t.Fatal("database rejection left a partial replacement", err) + } + var count int + if err := pool.QueryRow(ctx, "SELECT count(*) FROM agents WHERE tenant_id=$1", tenant).Scan(&count); err != nil || count != 1 { + t.Fatal("failed create persisted partial Agent", err) + } + if _, err := s.UpdateAgent(ctx, tenant, agent.ID, UpdateAgentInput{Configuration: []byte(`{"x_agents_core":{"harness":"claude_sdk"}}`)}); !errors.Is(err, ErrInvalidInput) { + t.Fatal("incompatible merged provider accepted", err) + } + // Provider-only replacement preserves the existing harness. + patch, _ := json.Marshal(map[string]any{"x_agents_core": map[string]any{"model_provider": replacement.SafeView()}}) + if _, err := s.UpdateAgent(ctx, tenant, agent.ID, UpdateAgentInput{Configuration: patch, ModelProvider: replacement, ModelProviderSet: true}); err != nil { + t.Fatal(err) + } + current, inherited, err = s.GetAgentForSession(ctx, tenant, agent.ID, true) + if err != nil || inherited == nil || *inherited != *replacement || !bytes.Contains(current.Configuration, []byte(`"harness": "codex"`)) { + t.Fatal("provider-only replacement failed", err) + } + if _, err := withoutKey.UpdateAgent(ctx, tenant, agent.ID, UpdateAgentInput{Configuration: []byte(`{"x_agents_core":{"harness":"codex"}}`)}); err != nil { + t.Fatal(err) + } + _, inherited, err = s.GetAgentForSession(ctx, tenant, agent.ID, true) + if err != nil || inherited == nil || *inherited != *replacement { + t.Fatal("harness-only update lost provider", err) + } + if _, err := withoutKey.UpdateAgent(ctx, tenant, agent.ID, UpdateAgentInput{Configuration: []byte(`{"x_agents_core":{"model_provider":null}}`), ModelProviderSet: true}); err != nil { + t.Fatal(err) + } + current, inherited, err = s.GetAgentForSession(ctx, tenant, agent.ID, true) + if err != nil || inherited != nil || !bytes.Contains(current.Configuration, []byte(`"harness": "codex"`)) { + t.Fatal("clear lost harness or retained provider", err) + } + if _, err := s.UpdateAgent(ctx, tenant, agent.ID, UpdateAgentInput{Configuration: agentProviderConfiguration(t, replacement, "codex"), ModelProvider: replacement, ModelProviderSet: true}); err != nil { + t.Fatal(err) + } + if _, err := s.UpdateAgent(ctx, tenant, agent.ID, UpdateAgentInput{Configuration: []byte(`{"x_agents_core":null}`), ModelProviderSet: true}); err != nil { + t.Fatal(err) + } + if err := pool.QueryRow(ctx, "SELECT count(*) FROM agent_model_execution WHERE agent_id=$1", agent.ID).Scan(&count); err != nil || count != 0 { + t.Fatal("extension clear retained secret", err) + } + if _, err := s.UpdateAgent(ctx, tenant, agent.ID, UpdateAgentInput{Configuration: agentProviderConfiguration(t, replacement, "codex"), ModelProvider: replacement, ModelProviderSet: true}); err != nil { + t.Fatal(err) + } + if _, err := s.DeleteAgent(ctx, tenant, agent.ID); err != nil { + t.Fatal(err) + } + if err := pool.QueryRow(ctx, "SELECT count(*) FROM agent_model_execution WHERE agent_id=$1", agent.ID).Scan(&count); err != nil || count != 0 { + t.Fatal("Agent delete retained secret", err) + } + frozen, err := s.SessionModelExecution(ctx, tenant, session.ID) + if err != nil || frozen == nil || *frozen != *provider { + t.Fatal("Agent mutation changed Session snapshot", err) + } +} + +func TestAgentModelExecutionConcurrentSnapshots(t *testing.T) { + _, pool := testStore(t) + c, err := credentialcrypto.New(bytes.Repeat([]byte{32}, 32)) + if err != nil { + t.Fatal(err) + } + s := NewWithCredentialCipher(pool, c) + ctx, tenant := t.Context(), uuid.NewString() + p := agentProviderFixture(0) + agent, err := s.CreateAgent(ctx, tenant, CreateAgentInput{Configuration: agentProviderConfiguration(t, p, "codex"), ModelProvider: p}) + if err != nil { + t.Fatal(err) + } + configurations := make([]json.RawMessage, 31) + for i := range configurations { + configurations[i] = agentProviderConfiguration(t, agentProviderFixture(i), "codex") + } + var wg sync.WaitGroup + failures := make(chan error, 2) + wg.Add(2) + go func() { + defer wg.Done() + for i := 1; i <= 30; i++ { + p := agentProviderFixture(i) + if _, err := s.UpdateAgent(ctx, tenant, agent.ID, UpdateAgentInput{Configuration: configurations[i], ModelProvider: p, ModelProviderSet: true}); err != nil { + failures <- err + return + } + } + }() + go func() { + defer wg.Done() + for i := 0; i < 80; i++ { + a, p, err := s.GetAgentForSession(ctx, tenant, agent.ID, true) + if err != nil { + failures <- err + return + } + var config struct { + Core v1.SavedAgentCore `json:"x_agents_core"` + } + if json.Unmarshal(a.Configuration, &config) != nil || p == nil || config.Core.ModelProvider == nil || config.Core.ModelProvider.BaseURL != p.BaseURL { + failures <- errors.New("concurrent read mixed safe and secret snapshots") + return + } + } + }() + wg.Wait() + close(failures) + for err := range failures { + t.Error(err) + } +} diff --git a/services/agents-api/internal/store/agents.go b/services/agents-api/internal/store/agents.go index 79601d2f6..34b5b75d5 100644 --- a/services/agents-api/internal/store/agents.go +++ b/services/agents-api/internal/store/agents.go @@ -7,6 +7,7 @@ import ( "fmt" "time" + v1 "github.com/MiniMax-AI-Dev/parsar/contracts/agents-api/v1" "github.com/google/uuid" "github.com/jackc/pgx/v5" "github.com/jackc/pgx/v5/pgtype" @@ -26,6 +27,7 @@ type SavedAgent struct { } type CreateAgentInput struct { + ModelProvider *v1.ModelProviderInput Metadata map[string]string Configuration json.RawMessage } @@ -49,14 +51,29 @@ func (s *Store) CreateAgent(ctx context.Context, tenantID string, input CreateAg if err != nil { return SavedAgent{}, err } - row, err := s.queries.CreateAgent(ctx, sqlc.CreateAgentParams{ - ID: pgtype.UUID{Bytes: uuid.New(), Valid: true}, TenantID: tenant, - Metadata: metadata, Configuration: configuration, + if err := validateAgentModelExecution(configuration, input.ModelProvider); err != nil { + return SavedAgent{}, err + } + var created SavedAgent + err = pgx.BeginFunc(ctx, s.pool, func(tx pgx.Tx) error { + q := s.queries.WithTx(tx) + row, err := q.CreateAgent(ctx, sqlc.CreateAgentParams{ + ID: pgtype.UUID{Bytes: uuid.New(), Valid: true}, TenantID: tenant, + Metadata: metadata, Configuration: configuration, + }) + if err != nil { + return err + } + if err := s.saveAgentModelExecution(ctx, q, uuid.UUID(tenant.Bytes).String(), row.ID, input.ModelProvider); err != nil { + return err + } + created, err = agentFromRow(row) + return err }) if err != nil { return SavedAgent{}, fmt.Errorf("create agent: %w", err) } - return agentFromRow(row) + return created, nil } // GetAgent scopes every lookup to the authenticated caller's tenant. diff --git a/services/agents-api/internal/store/agents_update.go b/services/agents-api/internal/store/agents_update.go index 9f4a4c449..2b5c6da0f 100644 --- a/services/agents-api/internal/store/agents_update.go +++ b/services/agents-api/internal/store/agents_update.go @@ -6,15 +6,21 @@ import ( "errors" "fmt" + 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/google/uuid" "github.com/jackc/pgx/v5" ) -// UpdateAgentInput contains validated top-level replacements, not a full snapshot. -// A nil metadata pointer preserves the existing map; a supplied map replaces it. +// UpdateAgentInput contains validated field replacements, not a full snapshot. +// Core extension subfields merge independently. A nil metadata pointer preserves +// the existing map; a supplied map replaces it. ModelProviderSet distinguishes +// omission from replacement or an explicit nil provider, which clears the secret. type UpdateAgentInput struct { - Configuration json.RawMessage - Metadata *map[string]string + ModelProvider *v1.ModelProviderInput + ModelProviderSet bool + Configuration json.RawMessage + Metadata *map[string]string } func (s *Store) UpdateAgent(ctx context.Context, tenantID, agentID string, input UpdateAgentInput) (SavedAgent, error) { @@ -62,8 +68,8 @@ func (s *Store) UpdateAgent(ctx context.Context, tenantID, agentID string, input if err := json.Unmarshal(row.Configuration, &configuration); err != nil { return err } - for field, value := range patch { - configuration[field] = value + if err := mergeAgentConfiguration(configuration, patch); err != nil { + return err } merged, err := json.Marshal(configuration) if err != nil { @@ -76,6 +82,14 @@ func (s *Store) UpdateAgent(ctx context.Context, tenantID, agentID string, input if len(merged) > 512*1024 { return ErrInvalidInput } + if err := validateAgentModelExecution(merged, input.ModelProvider); err != nil { + return err + } + if input.ModelProviderSet { + if err := s.saveAgentModelExecution(ctx, q, uuid.UUID(tenant.Bytes).String(), id, input.ModelProvider); err != nil { + return err + } + } if input.Metadata == nil { metadata = row.Metadata } diff --git a/services/agents-api/internal/store/session_initial_input.go b/services/agents-api/internal/store/session_initial_input.go index 222fcbf1d..6166350b8 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, sandboxNodeID string) (sqlc.Session, *Environment, error) { +func (s *Store) createSessionResources(ctx context.Context, tenant string, params sqlc.CreateSessionParams, inputs []Input, encodedInput json.RawMessage, files []InitialFile, setup EnvironmentSetup, provider *v1.ModelProviderInput, modelOptions map[string]any, sandboxNodeID string) (sqlc.Session, *Environment, error) { var row sqlc.Session var environment *Environment err := pgx.BeginFunc(ctx, s.pool, func(tx pgx.Tx) error { @@ -59,7 +59,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); err != nil { + if err := s.saveSessionModelExecution(ctx, q, tenant, row.ID, provider, modelOptions); err != nil { return err } if len(files) > 0 { diff --git a/services/agents-api/internal/store/session_model_execution.go b/services/agents-api/internal/store/session_model_execution.go index d4c50872c..27f03081d 100644 --- a/services/agents-api/internal/store/session_model_execution.go +++ b/services/agents-api/internal/store/session_model_execution.go @@ -11,11 +11,18 @@ import ( "github.com/jackc/pgx/v5/pgtype" ) -func (s *Store) saveSessionModelExecution(ctx context.Context, q *sqlc.Queries, tenant string, session pgtype.UUID, provider *v1.ModelProviderInput) error { +// The embedded provider preserves the original flat ciphertext format. Native +// options are private deployment defaults and never enter public configuration. +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 { if provider == nil { return nil } - raw, err := json.Marshal(provider) + raw, err := json.Marshal(sessionModelExecution{ModelProviderInput: *provider, NativeOptions: options}) if err != nil { return err } @@ -27,25 +34,33 @@ func (s *Store) saveSessionModelExecution(ctx context.Context, q *sqlc.Queries, } func (s *Store) SessionModelExecution(ctx context.Context, tenant, session string) (*v1.ModelProviderInput, error) { + provider, _, err := s.SessionModelExecutionWithOptions(ctx, tenant, session) + return provider, err +} + +func (s *Store) SessionModelExecutionWithOptions(ctx context.Context, tenant, session string) (*v1.ModelProviderInput, map[string]any, error) { tenantID, err := parseID(tenant) if err != nil { - return nil, err + return nil, nil, err } sessionID, err := parseID(session) if err != nil { - return nil, err + return nil, nil, err } ciphertext, err := s.queries.GetSessionModelExecution(ctx, sqlc.GetSessionModelExecutionParams{TenantID: tenantID, SessionID: sessionID}) if err != nil { - return nil, errors.New("session model execution configuration is unavailable") + return nil, nil, errors.New("session model execution configuration is unavailable") } raw, err := s.credentialCipher.OpenModelExecution(ciphertext, tenant, session) if err != nil { - return nil, errors.New("session model execution decryption is unavailable") + return nil, nil, errors.New("session model execution decryption is unavailable") + } + var frozen sessionModelExecution + if json.Unmarshal(raw, &frozen) != nil { + return nil, nil, errors.New("invalid stored model execution configuration") } - var provider v1.ModelProviderInput - if json.Unmarshal(raw, &provider) != nil { - return nil, errors.New("invalid stored model execution configuration") + if err := frozen.ModelProviderInput.Validate(); err != nil { + return nil, nil, err } - return &provider, provider.Validate() + return &frozen.ModelProviderInput, frozen.NativeOptions, nil } diff --git a/services/agents-api/internal/store/session_model_options_test.go b/services/agents-api/internal/store/session_model_options_test.go new file mode 100644 index 000000000..bf6ee9638 --- /dev/null +++ b/services/agents-api/internal/store/session_model_options_test.go @@ -0,0 +1,82 @@ +package store + +import ( + "bytes" + "encoding/json" + "errors" + "reflect" + "testing" + + v1 "github.com/MiniMax-AI-Dev/parsar/contracts/agents-api/v1" + "github.com/MiniMax-AI-Dev/parsar/services/agents-api/internal/credentialcrypto" + "github.com/google/uuid" +) + +func TestSessionDeploymentModelOptionsEncryptedAndFrozen(t *testing.T) { + _, pool := testStore(t) + cipher, err := credentialcrypto.New(bytes.Repeat([]byte{33}, 32)) + if err != nil { + t.Fatal(err) + } + 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} + 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) + } + 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) + if err != nil { + t.Fatal(err) + } + flatCiphertext, err := cipher.SealModelExecution(flat, 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 { + 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) + } +} diff --git a/services/agents-api/internal/store/sessions.go b/services/agents-api/internal/store/sessions.go index d306c9100..ed1567881 100644 --- a/services/agents-api/internal/store/sessions.go +++ b/services/agents-api/internal/store/sessions.go @@ -54,8 +54,10 @@ type Session struct { } type CreateSessionInput struct { - SandboxNodeID string - ModelProvider *v1.ModelProviderInput + SandboxNodeID string + ModelProvider *v1.ModelProviderInput + // ModelOptions is a private snapshot of trusted deployment execution options. + ModelOptions map[string]any Initialization EnvironmentSetup InitialFiles []InitialFile Creator identity.Subject @@ -125,6 +127,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) + } if input.ModelProvider != nil { if err := input.ModelProvider.ValidateHarness(input.Engine); err != nil { return SessionCreation{}, fmt.Errorf("%w: %s", ErrInvalidInput, err) @@ -151,13 +156,14 @@ func (s *Store) createSession(ctx context.Context, tenantID string, input Create canonical, err := json.Marshal(struct { SandboxNodeID string `json:",omitempty"` 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.SandboxNodeID, input.ModelProvider, input.Engine, input.Metadata, configuration, encodedInput, input.InitialFiles, initialization}) + }{input.SandboxNodeID, input.ModelProvider, input.ModelOptions, input.Engine, input.Metadata, configuration, encodedInput, input.InitialFiles, initialization}) if err != nil { return SessionCreation{}, fmt.Errorf("%w: input: %v", ErrInvalidInput, err) } @@ -172,7 +178,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.SandboxNodeID) + row, environment, err := s.createSessionResources(ctx, tenantID, params, batch, encodedInput, input.InitialFiles, input.Initialization, input.ModelProvider, input.ModelOptions, input.SandboxNodeID) if errors.Is(err, pgx.ErrNoRows) { return SessionCreation{}, ErrIdempotencyConflict } diff --git a/services/agents-api/migrations/000059_agent_model_execution.sql b/services/agents-api/migrations/000059_agent_model_execution.sql new file mode 100644 index 000000000..22e383591 --- /dev/null +++ b/services/agents-api/migrations/000059_agent_model_execution.sql @@ -0,0 +1,8 @@ +-- +goose Up +CREATE TABLE agent_model_execution ( + agent_id uuid PRIMARY KEY REFERENCES agents(id) ON DELETE CASCADE, + encrypted_config bytea NOT NULL +); + +-- +goose Down +DROP TABLE agent_model_execution;