diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 90a743cf7..1e9b7fc69 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -308,9 +308,10 @@ not transfer ownership of user compute to Core or prove process quiescence. Public Environment Templates belong to Core and its execution database, independently of provider image/build templates. Resolve a tenant-owned reference once at Session creation, freeze the effective ordinary hosted configuration and reuse inline -initialization. Do not pass template IDs into Provider or Runtime. Omitted network -inherits; overrides may only narrow policy. Preserve unresolved caller intent for -creation retries and recover committed results before reading mutable templates. +initialization. Do not pass template IDs into Provider or Runtime. Omitted or null +network inherits the complete template policy; overrides may only narrow policy. +Preserve unresolved caller intent for creation retries and recover committed +results before reading mutable templates. For template-reference Session initialization, omitted/null env, files, commands and packages inherit. Overlay non-null env keys; replace non-null files and command lists, including empty lists. Select each package manager independently: omitted/null @@ -393,8 +394,12 @@ Templates preserve default, latest and explicit version selectors. An omitted or null reference version selects the default at Session creation and projects as `version: null` in Template responses. Session responses contain concrete versions; only validated installation metadata crosses the Runtime boundary. A supplied -Session Skill list replaces the template list; omission inherits. Null list -overrides remain unqualified and reject rather than silently changing selection. +Session Skill, Plugin or capability-directory list replaces its template list; +omission and null inherit, while an empty list clears that selection. This differs +from Template resource updates, where null clears lists and resets network to the +pinned enabled default. Preserve caller intent and frozen Session snapshots in +both cases. Public capability directories remain caller paths; adapter-owned +installation directories are not portable public paths. Inline and referenced Skill ZIPs use the same confidential initialization snapshot and installer. Core validates portable manifests and bounded regular-file archives, returns only diff --git a/contracts/agents-api/README.md b/contracts/agents-api/README.md index 043a46fa4..52983e3ca 100644 --- a/contracts/agents-api/README.md +++ b/contracts/agents-api/README.md @@ -174,7 +174,7 @@ user-managed enrollment remain outside this qualification. | Area | Missing or unverified scope | | --- | --- | | Subagents / multi_agent | Six reads and same-child recovery have three-harness Docker evidence; optional native operations, live child progress, full lifecycle/interactions and tool combinations remain explicit gaps | -| Environment Templates | Unsupported restricted hostname forms, null network/list selection and exact hosted errors remain gaps. Template-reference env/files/commands/packages composition follows [qualified field rules](environment-templates.md#template-and-inline-configuration-composition). CRUD/list, files, env/setup/system/npm/Python, inline/referenced Skills, Plugins, workspace capability directories and Session references have recorded coverage. Environment Plugin MCP transport and placement limits are [listed separately](environment-templates.md#environment-origin-mcp-plugins) | +| Environment Templates | Unsupported restricted hostname forms and exact hosted errors remain gaps. Referenced null network and capability-list selection follow [qualified inheritance rules](template-null-selection.md). Template-reference env/files/commands/packages composition follows [qualified field rules](environment-templates.md#template-and-inline-configuration-composition). CRUD/list, files, env/setup/system/npm/Python, inline/referenced Skills, Plugins, workspace capability directories and Session references have recorded coverage. Environment Plugin MCP transport and placement limits are [listed separately](environment-templates.md#environment-origin-mcp-plugins) | | Input and configuration | Non-text initial input, broader content/configuration unions and reasoning/verbosity combinations; [structured output](structured-output.md) has qualified Claude function profiles on none and Core-managed Docker openai_hosted, with other combinations remaining gaps | | Tools and interactions | [Deferred discovery qualification](tool-search.md), other tool types, effective tool-set enforcement and result/cancel publication ordering; MiniMax public functions and service-origin MCP remain unsupported | | Vault and Credentials | Archive semantics, in-flight token withdrawal and exact hosted selection/error behavior; static/OAuth CRUD, replacement and scoped dispatch-time refresh are implemented (see credential guide for qualification) | diff --git a/contracts/agents-api/environment-templates.md b/contracts/agents-api/environment-templates.md index 50ee721bc..5dc2f20f6 100644 --- a/contracts/agents-api/environment-templates.md +++ b/contracts/agents-api/environment-templates.md @@ -23,8 +23,8 @@ and five-operation SandboxProvider path as inline configuration. Creation timestamp plus ID supplies stable local ordering. Missing/foreign IDs and cursors return the same not-found result. No compute is allocated by CRUD. - Session `environment_template_id` resolves under the caller's tenant. Omitted - network inherits; enabled can narrow to restricted or disabled. Restricted can - narrow to an exact-host subset or disabled; disabled cannot widen. Effective + or null network inherits; enabled can narrow to restricted or disabled. + Restricted can narrow to an exact-host subset or disabled; disabled cannot widen. Effective configuration is frozen without passing the template ID to execution. - Updating/deleting a template does not change existing Sessions. Creation retries recover recorded caller intent before template lookup, including after deletion; @@ -123,10 +123,10 @@ creation, `"latest"` selects latest, and a positive version string selects that version. A Session freezes tenant-authorized bytes and concrete version metadata in its creation transaction. Later source deletion, default changes or template updates cannot change that Session or its committed creation retry. A supplied -Session Skill list replaces the template list; omission inherits. Template +Session Skill list replaces the template list; omission/null inherit. Template responses include `version: null` for an unresolved default selector; resolved -Session references retain a concrete version string. Null list overrides remain -unqualified and reject. See [resource selector qualification](resource-selector-semantics.md). +Session references retain a concrete version string. See [resource selector +qualification](resource-selector-semantics.md) and [null selection evidence](template-null-selection.md). References return type/skill_id/version/name/description in Session metadata, while template responses retain unresolved selectors. Confidential bundle content never appears in these metadata responses. The common Runtime installation path @@ -187,9 +187,9 @@ A hosted Skill directories with `skills`; complete package-relative resources are retained. The same parser, template resolution and encrypted Session snapshot serve inline and template requests. Only type/name/description appears in public Plugin metadata. -Template Plugin lists inherit on omission and replace when supplied. Template -updates accept null/empty clearing; explicit null Session overrides remain an -unconfirmed semantic and reject. +Referencing Session Plugin lists inherit on omission/null and replace when a +non-null list is supplied, including empty-list clearing. Template resource updates +continue to accept null/empty clearing. `capability_directories` currently accepts clean absolute paths within `/workspace`. Initial files and setup can populate them. The shared initializer snapshots these @@ -197,7 +197,7 @@ directories after setup, then publishes one protected installed manifest. Recove uses those installed bytes even if the source directory changes or is removed. This timing and workspace restriction are local implementation choices, not claims about unspecified upstream behavior. A supplied Session directory list replaces -the template list; omitted lists inherit. Missing, overlapping duplicate Skill +the template list; omitted/null lists inherit. Missing, overlapping duplicate Skill names, unsupported manifests and nonregular files reject initialization without publishing completion. Directory-discovered Skills do not become fabricated inline entries in public `skills` or `plugins` metadata. @@ -516,9 +516,9 @@ appear automatically in public metadata or initialization diagnostics. The [current Template reference](https://developers.openai.com/api/reference/python/resources/beta/subresources/agents/subresources/environments/subresources/templates) mentions different GA/beta defaults; this service retains `agents=v1` and the [fixed baseline](upstream.json), whose omitted network is enabled. Exact upstream -errors, no-op timestamps, concurrent pagination and referenced Session null-network -override semantics remain unverified. The last case explicitly rejects in this -batch rather than guessing inheritance. This batch is not full protocol compatibility. +errors, concurrent pagination and broader network combinations remain unverified. +Referenced null network follows the [qualified inheritance rule](template-null-selection.md); +unsupported hostname forms still reject. This is not full protocol compatibility. ## Template and inline configuration composition @@ -559,7 +559,9 @@ subdirectories `official-env-setup` and `official-files-packages`. Reports retai fixed-source snapshots, raw status/body/request IDs, command-output proofs, accounting, cleanup and credential scans. This covers the observed fixtures rather than all possible combinations. Null network and null Skill/Plugin/directory list -selection remain outside this batch. No new protocol version is introduced. +selection remained outside that composition batch; the +[subsequent qualification](template-null-selection.md) records those rules. +No new protocol version is introduced. ### Core checks for composition (2026-09-23) diff --git a/contracts/agents-api/openapi.yaml b/contracts/agents-api/openapi.yaml index a241f9664..7e7b6e46f 100644 --- a/contracts/agents-api/openapi.yaml +++ b/contracts/agents-api/openapi.yaml @@ -2883,22 +2883,24 @@ paths: 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 network and allow only narrowing overrides. Referenced - network:null is explicitly unsupported pending semantic verification. 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; + 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-list omission inherits and a supplied list replaces; null overrides - and null version selectors remain unqualified and reject. 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. + 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/operation-evidence.md b/contracts/agents-api/operation-evidence.md index 9c71c4c63..e5fd55e25 100644 --- a/contracts/agents-api/operation-evidence.md +++ b/contracts/agents-api/operation-evidence.md @@ -73,7 +73,7 @@ Paths in the appendix include `/v1`. SDK names here omit `client.`. `P` means pa | 28 | beta.agents.environments.files.list | P: direct regular-file directory, opaque cursor | None located | F/D/K recorded live workspace listing | 1,024-entry prefilter bound; no recursion/symlinks; exact defaults/path/errors/mutation invalidation unknown | | 29 | beta.agents.environments.templates.create | P: reusable network/files/env/setup/packages/Skills/Plugins/capability config, 201 | R `template-create`; V nullable Skill selector projection | C DB; I/K recorded real frozen-reference initialization | Restricted forms and unqualified combinations; complete hosted initialization semantics | | 30 | beta.agents.environments.templates.retrieve | P: safe resource read | R `template-read`, deleted owned read; V nullable Skill selector projection | C DB; I/K recorded reference workflow | Full field/default/redaction parity; no live-secret projection inference | -| 31 | beta.agents.environments.templates.update | P: field replacement/null clearing, empty timestamp touch | R `template-patch`, `template-null`, `template-noop`; V nullable Skill selector projection | C DB no-op; I/K recorded frozen Session behavior | Referenced Session files/env/setup/packages overrides reject; null network/list/Skill-version remains unresolved | +| 31 | beta.agents.environments.templates.update | P: field replacement/null clearing, empty timestamp touch | R `template-patch`, `template-null`, `template-noop`; V nullable Skill selector projection | C DB no-op; I/K recorded frozen Session behavior | Template update and referencing Session selection are distinct; composition and null inheritance are qualified separately in environment-templates.md/template-null-selection.md; uncommon fields/errors remain unverified | | 32 | beta.agents.environments.templates.list | P: scoped cursor list | R `template-list-empty-scoped` | Recorded resource DB checks; no positive C list replay | Full nonempty/multipage/mutation/default/error parity | | 33 | beta.agents.environments.templates.delete | P: delete resource, preserve committed Session snapshot | R `cleanup` at Template path, post-delete read | C DB; K recorded live deletion then continuation | Concurrent references/delete and exact errors | | 34 | beta.agents.vaults.create | P: tenant resource, 201 | R `vault-create`, `vault-empty-token-fixture` | C DB; O recorded MCP attachment workflow | Archive lifecycle, full defaults and selection parity | @@ -106,7 +106,7 @@ Paths in the appendix include `/v1`. SDK names here omit `client.`. `P` means pa 1. **Public generic semantics:** sampled create/event/envelope/error/no-op corrections are merged. Resource-by-resource omissions/null/default/error params, malformed queries, list caps, unknown/empty query handling, concurrent mutation and deletion require separate evidence. Metadata U+0000 remains an implementation-validation question from the prior audit, not a newly reproduced result here. 2. **Session differences:** The Session admission batch removes idle `none` creation and empty metadata update. Local durable creation idempotency remains an explicit difference. Whitespace-only input succeeds officially but is rejected by the existing Core message validator; this newly observed difference is queued separately. Session agent updates, newer Environment shapes and root Item turn_id are baseline-upgrade questions. -3. **Template/Skill composition:** referenced files/env/setup/packages override rejection is a known implementation gap; exact merge/replacement/null semantics need evidence. Null override lists, unversioned Skill content, top-level version metadata and last/default/latest deletion remain unresolved. +3. **Template/Skill composition:** shared env/files/setup/packages selection is covered by the composition batch; template-reference null network/capability lists are covered by the null-selection batch. Official derived capability-directory projection remains different. Skill content/default metadata are covered by file-resource-semantics.md; sole-version deletion, visibility and broader numbering/error behavior remain unverified. 4. **Execution coverage:** use T's qualified matrix, not a blanket missing-image/structured-output claim. MiniMax functions/service MCP, optional tool combinations, unsupported images/placements and broader native lifecycle are explicit restrictions. PTC omission retains approved native behavior; Claude/MiniMax public Usage remains null; child settlement cadence/native close limits remain visible. No second executor/model loop or guessed counters are justified. 5. **Workspace and resources:** live Files bounds, symlink/path/cursor choices, artifact overwrite/republishing/headers/cancellation edges, full Environment metadata/lifecycle and Vault archive/in-flight-token semantics remain partial or unknown. Retired Core-managed E2B acceptance cannot qualify current user enrollment. diff --git a/contracts/agents-api/template-null-selection.md b/contracts/agents-api/template-null-selection.md new file mode 100644 index 000000000..39d79de99 --- /dev/null +++ b/contracts/agents-api/template-null-selection.md @@ -0,0 +1,112 @@ +# Template-reference null selection + +This batch retains SDK 3.13.0, upstream `d7c41efee1b0802b79f3f88a678ef2052b06e9ce` and `agents=v1`. It changes shared Core configuration selection only; Provider and Runtime receive the existing frozen effective configuration. + +## Qualified selection rules + +| Operation / field | Omitted | Null | Non-null | +| --- | --- | --- | --- | +| Referencing Session network | Inherit complete policy | Inherit complete policy | Must narrow template authority | +| Referencing Session Skills, Plugins, capability directories | Inherit list | Inherit list | Replace list; empty clears | +| Inline hosted Session network | Enabled default | Enabled default | Existing policy validation | +| Template resource update network | Preserve | Reset to enabled | Replace saved policy | +| Template resource update capability lists | Preserve | Clear | Replace saved list | + +Resolve only the selected tenant-owned sources, using the existing encrypted initialization transaction. Keep unresolved caller intent distinct from the frozen snapshot. Template mutation/deletion cannot alter existing Sessions or same-intent creation retries. No new installer, native path, harness branch or compatibility reader is introduced. + +Official Session capability-directory responses can include automatically derived Skill/Plugin installation directories in addition to caller-selected paths. Core continues to return caller paths and keeps Runtime-owned installation locations private. The selection matrix does not qualify complete public directory projection parity; copying official internal paths would not establish usable Core paths. + +## Evidence and limits + +Owned official probes and Core acceptance records are under +`~/.parsar/remediation/20260923/template-null-selection/`. Together they made +82 HTTP requests and created six Templates and eleven Sessions. All seventeen +owned resources have successful public DELETE receipts; physical upstream +destruction was not independently observed. Credential scans passed. + +- `official-network/REPORT.md`: 46 requests, four Templates, six Sessions, zero + Turns. Disabled and populated restricted policies distinguish inheritance from + an enabled default. Narrowing succeeds; broadening rejects. Inline null and + Template create/update null produce enabled policy. Existing Sessions preserve + their policies after Template reset. Four provisioning-time DELETE conflicts + each succeeded on one later bounded cleanup attempt. This does not requalify + native network enforcement. +- `official-capabilities/REPORT.md`: 36 requests, two Templates, five Sessions, + one real official `gpt-6-astra` Turn. Four distinct request cases establish + list selection. The null case additionally has three completed, exit-zero + native commands reading unknown markers from inherited Skill, Plugin Skill + and caller directory contents. Empty/replacement native bytes were not newly + qualified against the official service. + +The first capability attempt stopped before any model call because its test +incorrectly equated caller directories with the entire official Session projection. +Its original script, raw results and cleanup remain. The bounded continuation +separated caller-selected paths from observed derived paths; it did not change +the requests or hide a model failure. Official Environment reads contain Skill +and Plugin metadata but no capability-directory field; do not equate them with +the richer Session environment projection. Mixed individual-field official null +cases and all native/provider combinations remain unqualified. + +## Core verification + +`TestTemplateNullSelectionOfficialClientPostgres` and +`official_template_null_selection.py` exercise the actual HTTP handler, isolated +PostgreSQL, pinned strict SDK and raw HTTP. Eleven successful selections cover +combined and mixed fields, disabled/restricted inheritance, narrowing, inline null, +Template resets and exclusion of an unselected foreign Skill. Eight rejected +creations leave no Session, Environment or initialization residue. Foreign and +missing template lookups remain indistinguishable; direct foreign snapshot reads +reject. Encrypted archive digests and unrelated initial file/env contents remain +frozen after source/default/template mutation and deletion and reopening the Store. +Changed retry intent conflicts. This test does not execute a native model. + +The first acceptance assertion incorrectly expected Session-only directory/network +fields on the Environment resource. The one-line test correction uses its pinned +projection; the original failure remains. Independent PostgreSQL acceptance passed +in 1.74 seconds. The complete server gate then passed at `65f9898`, including +228.891 seconds of Store tests, sqlc byte comparison, Go/build/native package and +Rust checks. No database query or migration changed. + +Fresh `make check-web` passed with Node22 and pinned pnpm10.30.3: 63 doctor, +287 client, 583 Web unit and 76 browser cases. Its initial dependency setup used +the app's pnpm11 fallback and stopped before tests; the tool-generated workspace +placeholder was removed and the unchanged repository was tested with its pinned +toolchain. No dependency or build-policy change was made. `make openapi` and +`git diff --check` passed. Optional 512 MiB/source streaming and packaged MiniMax +scratch/large-output live profiles were not enabled. + +The first Codex/Docker attempt initialized three Sessions. Its Kimi null-selection +Turn read the three inherited capability markers through native commands. The next +empty-selection Turn failed on provider HTTP 429 before a native command or reported +usage; replacement and restart checks were not submitted. These records are retained. + +An OpenAI-provider continuation was stopped when the user clarified that its key +was restricted to official Agents API probes. One submitted Core Turn was cancelled; +its stored usage was 10,576 input and 61 output tokens. This attempt is not acceptance. +Its owned resources and temporary credential copies were removed. Subsequent Core +model verification uses only the authorized Kimi or MiniMax providers. + +MiniMax-M3 completed the empty and replacement Turns. Each produced one completed, +exit-zero native command whose unmodified JSON output exactly matched the selected +capabilities, excluded markers, inherited confidential env and single setup trace. +The original runner still failed because the replacement assistant answer omitted +one trailing newline from that trace. Both native proofs and the mismatching answer +remain preserved; Core and model output were not changed. Initialization acceptance +uses the actual command output, with the assistant answer retained as an observation. + +The separate one-Session null continuation passed two MiniMax-M3 Turns. After the +first native proof, the test changed the source Skill version and Template, deleted +both resources, restarted Core, and retried the original creation key. The retry +returned the same Session; its second native command read the original markers, +confidential env and unchanged one-line setup trace. Initialization was not replayed. + +All Core native execution used production source `f86223f`; the final server gate +adds only the corrected resource-test assertion and documentation. The null +continuation reused the verified Core/daemon binaries and base image. Rebuilding +the deleted wrapper image changed its image ID; base identity, in-image daemon +hash, Dockerfile and runtime configuration established the same content provenance. +No native feature, model response or production behavior was changed for acceptance. +Reports in `live/attempt-minimax/` and `live/attempt-minimax-null/` retain the original +failures, command proofs, source hashes and owned-resource cleanup records. + +This batch does not widen hostname syntax, change native capability support, requalify every harness/Provider combination, or claim full protocol compatibility. Provisioning-delete retries and private test assertion failures remain in the evidence rather than being counted as successful first attempts. diff --git a/services/agents-api/internal/api/environment_plugins_test.go b/services/agents-api/internal/api/environment_plugins_test.go index 440268a54..21a824c1f 100644 --- a/services/agents-api/internal/api/environment_plugins_test.go +++ b/services/agents-api/internal/api/environment_plugins_test.go @@ -81,7 +81,7 @@ func TestPluginsSharedParsingConfidentialMetadataAndOverrides(t *testing.T) { } lookup := &templateLookupStore{network: "enabled", plugins: template.Initialization.Plugins, directories: template.Initialization.CapabilityDirectories} h := Handler{store: lookup} - for _, override := range []string{"", `,"plugins":[],"capability_directories":[]`} { + for _, override := range []string{"", `,"plugins":null,"capability_directories":null`, `,"plugins":[],"capability_directories":[]`} { if err = json.Unmarshal([]byte(`{"agent":{"model":"test"},"environment":{"type":"openai_hosted","environment_template_id":"template"`+override+`}}`), &decoded); err != nil { t.Fatal(err) } @@ -97,18 +97,13 @@ func TestPluginsSharedParsingConfidentialMetadataAndOverrides(t *testing.T) { t.Fatal(err) } want := 1 - if override != "" { + if strings.Contains(override, "[]") { want = 0 } if len(in.initialization.Plugins) != want || len(in.initialization.CapabilityDirectories) != want || len(in.Environment.Plugins) != want { t.Fatal("inheritance/replacement") } } - for _, field := range []string{`"plugins":null`, `"capability_directories":null`} { - if _, _, _, err := decodeTemplateEnvironment([]byte(`{"type":"openai_hosted","environment_template_id":"template",` + field + `}`)); err == nil { - t.Fatal("unqualified null override") - } - } for _, directory := range []string{`null`, `"/private"`, `"/workspace/../private"`, `"relative"`} { if _, err := decodeTemplateInput([]byte(`{"capability_directories":[` + directory + `]}`)); err == nil { t.Fatal("unsafe directory") diff --git a/services/agents-api/internal/api/environment_templates_test.go b/services/agents-api/internal/api/environment_templates_test.go index cf6f3eb3d..15143375c 100644 --- a/services/agents-api/internal/api/environment_templates_test.go +++ b/services/agents-api/internal/api/environment_templates_test.go @@ -88,7 +88,7 @@ func TestTemplateResolutionAndCreationIntent(t *testing.T) { if err := h.resolveTemplateEnvironment(t.Context(), "tenant-a", &narrower); err != nil { t.Fatal(err) } - for _, raw := range []string{`{"type":"openai_hosted","environment_template_id":null}`, `{"type":"none","environment_template_id":"saved"}`, `{"type":"openai_hosted","environment_template_id":"saved","network":null}`} { + for _, raw := range []string{`{"type":"openai_hosted","environment_template_id":null}`, `{"type":"none","environment_template_id":"saved"}`} { if _, _, _, err := decodeTemplateEnvironment(json.RawMessage(raw)); err == nil { t.Fatal("invalid reference accepted", raw) } diff --git a/services/agents-api/internal/api/handler.go b/services/agents-api/internal/api/handler.go index fb6202008..6952bd632 100644 --- a/services/agents-api/internal/api/handler.go +++ b/services/agents-api/internal/api/handler.go @@ -124,7 +124,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, but its values must be strings. 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 at creation; disconnect does not cancel execution. 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. Creation retries observe future events without replay; retry with stream=false to retrieve the Session. 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 network and allow only narrowing overrides. Referenced network:null is explicitly unsupported pending semantic verification. 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-list omission inherits and a supplied list replaces; null overrides and null version selectors remain unqualified and reject. 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 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, but its values must be strings. 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 at creation; disconnect does not cancel execution. 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. Creation retries observe future events without replay; retry with stream=false to retrieve the Session. 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 diff --git a/services/agents-api/internal/api/session_template.go b/services/agents-api/internal/api/session_template.go index 816ac5867..75d2dedfe 100644 --- a/services/agents-api/internal/api/session_template.go +++ b/services/agents-api/internal/api/session_template.go @@ -28,15 +28,6 @@ func decodeTemplateEnvironment(raw json.RawMessage) (*v1.Environment, string, js if json.Unmarshal(reference, &id) != nil || id == "" || json.Unmarshal(fields["type"], &kind) != nil || kind != "openai_hosted" { return nil, "", nil, store.ErrInvalidInput } - // Explicit null override semantics are unconfirmed; do not guess inheritance. - if value, exists := fields["network"]; exists && bytes.Equal(bytes.TrimSpace(value), []byte("null")) { - return nil, "", nil, store.ErrInvalidInput - } - for _, name := range []string{"skills", "plugins", "capability_directories"} { - if value, exists := fields[name]; exists && bytes.Equal(bytes.TrimSpace(value), []byte("null")) { - return nil, "", nil, store.ErrInvalidInput - } - } delete(fields, "environment_template_id") inline, err := json.Marshal(fields) if err != nil { @@ -58,7 +49,7 @@ func (h *Handler) resolveTemplateEnvironment(ctx context.Context, tenant string, if json.Unmarshal(input.templateEnvironment, &fields) != nil { return store.ErrInvalidInput } - if _, supplied := fields["network"]; !supplied { + if !templateFieldOverride(fields, "network") { input.Environment.Network = &v1.EnvironmentNetworkInput{Access: template.NetworkAccess, AllowedDomains: append([]string{}, template.AllowedDomains...)} } effective := agentnetwork.Policy{Access: input.Environment.Network.Access, AllowedDomains: input.Environment.Network.AllowedDomains} @@ -66,15 +57,15 @@ func (h *Handler) resolveTemplateEnvironment(ctx context.Context, tenant string, return store.ErrInvalidInput } skills := input.initialization.Skills - if _, supplied := fields["skills"]; !supplied { + if !templateFieldOverride(fields, "skills") { skills = template.Initialization.Skills } plugins := input.initialization.Plugins - if _, supplied := fields["plugins"]; !supplied { + if !templateFieldOverride(fields, "plugins") { plugins = template.Initialization.Plugins } directories := input.initialization.CapabilityDirectories - if _, supplied := fields["capability_directories"]; !supplied { + if !templateFieldOverride(fields, "capability_directories") { directories = template.Initialization.CapabilityDirectories } setup := template.Initialization diff --git a/services/agents-api/internal/api/session_template_null_test.go b/services/agents-api/internal/api/session_template_null_test.go new file mode 100644 index 000000000..3f71cc70e --- /dev/null +++ b/services/agents-api/internal/api/session_template_null_test.go @@ -0,0 +1,95 @@ +package api + +import ( + "bytes" + "encoding/json" + "reflect" + "testing" +) + +func TestTemplateNullSelectionRetainsInheritedCapabilitiesAndPolicy(t *testing.T) { + template, err := decodeTemplateInput([]byte(`{"network":{"access":"restricted","allowed_domains":["example.com"]},"skills":[` + string(skillInput(t, "private-skill-marker")) + `],"plugins":[` + string(pluginInput(t)) + `],"capability_directories":["/workspace/generated"]}`)) + if err != nil { + t.Fatal(err) + } + for _, test := range []struct { + name, fields string + clearSkills, clearPlugins, clearDirectories bool + }{ + {name: "omitted"}, + {name: "network null", fields: `,"network":null`}, + {name: "skills null", fields: `,"skills":null`}, + {name: "plugins null", fields: `,"plugins":null`}, + {name: "directories null", fields: `,"capability_directories":null`}, + {name: "all null", fields: `,"network": null ,"skills":null,"plugins":null,"capability_directories":null`}, + {name: "empty lists", fields: `,"network":null,"skills":[],"plugins":[],"capability_directories":[]`, clearSkills: true, clearPlugins: true, clearDirectories: true}, + {name: "clear only skills", fields: `,"skills":[],"plugins":null,"capability_directories":null`, clearSkills: true}, + {name: "clear only plugins", fields: `,"skills":null,"plugins":[],"capability_directories":null`, clearPlugins: true}, + {name: "clear only directories", fields: `,"skills":null,"plugins":null,"capability_directories":[]`, clearDirectories: true}, + } { + t.Run(test.name, func(t *testing.T) { + lookup := &templateLookupStore{network: "restricted", domains: []string{"example.com"}, skills: template.Initialization.Skills, plugins: template.Initialization.Plugins, directories: template.Initialization.CapabilityDirectories} + input := compositionRequest(t, test.fields) + intent, err := sessionCreationRequest(input, nil) + if err != nil { + t.Fatal(err) + } + before, _ := json.Marshal(template.Initialization) + h := Handler{store: lookup} + if err := h.resolveTemplateEnvironment(t.Context(), "tenant", &input); err != nil { + t.Fatal(err) + } + if input.Environment.Network.Access != "restricted" || !reflect.DeepEqual(input.Environment.Network.AllowedDomains, lookup.domains) { + t.Fatal("omitted/null network widened or lost template policy") + } + if test.clearSkills { + if len(input.initialization.Skills) != 0 { + t.Fatal("skills not cleared") + } + } else if !reflect.DeepEqual(input.initialization.Skills, template.Initialization.Skills) { + t.Fatal("skills not inherited") + } + if test.clearPlugins { + if len(input.initialization.Plugins) != 0 { + t.Fatal("plugins not cleared") + } + } else if !reflect.DeepEqual(input.initialization.Plugins, template.Initialization.Plugins) { + t.Fatal("plugins not inherited") + } + if test.clearDirectories { + if len(input.initialization.CapabilityDirectories) != 0 { + t.Fatal("directories not cleared") + } + } else if !reflect.DeepEqual(input.initialization.CapabilityDirectories, template.Initialization.CapabilityDirectories) { + t.Fatal("directories not inherited") + } + if !reflect.DeepEqual(input.Environment.Skills, skillResponse(input.initialization.SkillMetadata())) || !reflect.DeepEqual(input.Environment.Plugins, pluginResponse(input.initialization.PluginMetadata())) || len(input.Environment.CapabilityDirectories) != len(input.initialization.CapabilityDirectories) { + t.Fatal("public metadata does not match selected capabilities") + } + public, _ := json.Marshal(input.Environment) + for _, secret := range []string{`"source"`, "private-skill-marker", "private-plugin"} { + if bytes.Contains(public, []byte(secret)) { + t.Fatal("private initialization leaked to public metadata") + } + } + after, _ := json.Marshal(template.Initialization) + afterIntent, _ := sessionCreationRequest(input, nil) + if !bytes.Equal(before, after) || !bytes.Equal(intent, afterIntent) { + t.Fatal("selection mutated template or caller intent") + } + }) + } +} + +func TestTemplateNullSelectionDoesNotBypassValidation(t *testing.T) { + for _, fields := range []string{`,"network":{}`, `,"network":[]`, `,"skills":[null]`, `,"plugins":[null]`, `,"capability_directories":[null]`, `,"capability_directories":["/private"]`} { + if _, _, _, err := decodeTemplateEnvironment([]byte(`{"type":"openai_hosted","environment_template_id":"saved"` + fields + `}`)); err == nil { + t.Fatal("invalid nonnull override accepted", fields) + } + } + h := Handler{store: &templateLookupStore{network: "disabled"}} + input := compositionRequest(t, `,"network":{"access":"enabled"},"skills":null,"plugins":null,"capability_directories":null`) + if err := h.resolveTemplateEnvironment(t.Context(), "tenant", &input); err == nil { + t.Fatal("capability null overrides bypassed network narrowing") + } +} diff --git a/services/agents-api/internal/store/template_null_selection_public_test.go b/services/agents-api/internal/store/template_null_selection_public_test.go new file mode 100644 index 000000000..e44e55b81 --- /dev/null +++ b/services/agents-api/internal/store/template_null_selection_public_test.go @@ -0,0 +1,165 @@ +package store_test + +import ( + "bytes" + "context" + "crypto/sha256" + "encoding/hex" + "encoding/json" + "errors" + "net/http/httptest" + "os" + "os/exec" + "reflect" + "testing" + "time" + + "github.com/MiniMax-AI-Dev/parsar/internal/agentdaemon/device" + "github.com/MiniMax-AI-Dev/parsar/internal/agentplugin" + "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 TestTemplateNullSelectionOfficialClientPostgres(t *testing.T) { + python := os.Getenv("PARSAR_OFFICIAL_SDK_PYTHON") + if python == "" { + t.Skip("pinned official Python SDK required") + } + _, pool := store.NewTestStore(t) + cipher, err := credentialcrypto.New(bytes.Repeat([]byte{87}, 32)) + if err != nil { + t.Fatal(err) + } + s := store.NewWithCredentialCipher(pool, cipher) + reopenedStore := store.NewWithCredentialCipher(pool, cipher) + tenant, foreignTenant, token, foreign := uuid.NewString(), uuid.NewString(), uuid.NewString(), uuid.NewString() + auth, err := api.NewAuthenticator([]api.APIKey{ + {OrganizationID: "test-org", ProjectID: uuid.NewString(), SubjectKind: "service_account", SubjectID: "selection-owner", TokenSHA256: device.HashCredential(token), TenantID: tenant}, + {OrganizationID: "test-org", ProjectID: uuid.NewString(), SubjectKind: "service_account", SubjectID: "selection-foreign", TokenSHA256: device.HashCredential(foreign), TenantID: foreignTenant}, + }) + if err != nil { + t.Fatal(err) + } + serve := func(current *store.Store) *httptest.Server { + t.Helper() + h, err := api.NewHandler(current, auth, "codex", api.WithHostedEnvironments(), api.WithExecution(current), api.WithSourceFiles(current), api.WithSkills(current)) + if err != nil { + t.Fatal(err) + } + server := httptest.NewServer(h) + t.Cleanup(server.Close) + return server + } + server, reopened := serve(s), serve(reopenedStore) + marker := "private-selection-" + uuid.NewString() + settings, err := json.Marshal(map[string]string{"base": server.URL, "recovered": reopened.URL, "token": token, "foreign": foreign, "canary": marker}) + if err != nil { + t.Fatal(err) + } + ctx, cancel := context.WithTimeout(t.Context(), 2*time.Minute) + defer cancel() + command := exec.CommandContext(ctx, python, "../../tests/official_template_null_selection.py") + command.Stdin = bytes.NewReader(settings) + output, err := command.CombinedOutput() + if err != nil { + t.Fatalf("template null selection official client: %v %s", err, output) + } + var receipt struct { + Sessions map[string]string `json:"sessions"` + RejectedKeys []string `json:"rejected_keys"` + Expected map[string]struct { + Skills []store.EnvironmentSkillMetadata `json:"skills"` + Plugins []agentplugin.Metadata `json:"plugins"` + CapabilityDirectories []string `json:"capability_directories"` + SkillDigests []string `json:"skill_digests"` + PluginDigests []string `json:"plugin_digests"` + } `json:"expected"` + } + if err := json.Unmarshal(output, &receipt); err != nil { + t.Fatalf("invalid acceptance receipt: %v %s", err, output) + } + t.Cleanup(func() { + for _, id := range receipt.Sessions { + if err := s.DeleteSession(context.Background(), tenant, id); err != nil { + t.Error(err) + } + } + }) + if len(receipt.Sessions) != 11 || len(receipt.Expected) != 11 || len(receipt.RejectedKeys) != 8 { + t.Fatalf("incomplete acceptance receipt: sessions=%d expectations=%d rejections=%d", len(receipt.Sessions), len(receipt.Expected), len(receipt.RejectedKeys)) + } + for label, id := range receipt.Sessions { + want, ok := receipt.Expected[label] + if !ok { + t.Fatalf("missing expectation for %s", label) + } + for _, current := range []*store.Store{s, reopenedStore} { + setup, err := current.ReadEnvironmentSetup(t.Context(), tenant, id) + if err != nil { + t.Fatalf("%s frozen setup: %v", label, err) + } + if !reflect.DeepEqual(setup.Env, map[string]string{"PRIVATE_SELECTION": marker}) || + !reflect.DeepEqual(setup.SkillMetadata(), want.Skills) || !reflect.DeepEqual(setup.PluginMetadata(), want.Plugins) || + !reflect.DeepEqual(append([]string{}, setup.CapabilityDirectories...), want.CapabilityDirectories) { + t.Fatalf("%s frozen selection changed", label) + } + if len(setup.Skills) != len(want.SkillDigests) || len(setup.Plugins) != len(want.PluginDigests) { + t.Fatalf("%s frozen archive count differs", label) + } + for i, skill := range setup.Skills { + digest := sha256.Sum256(skill.Archive) + if hex.EncodeToString(digest[:]) != want.SkillDigests[i] { + t.Fatalf("%s frozen Skill bytes changed", label) + } + } + for i, plugin := range setup.Plugins { + digest := sha256.Sum256(plugin.Archive) + if hex.EncodeToString(digest[:]) != want.PluginDigests[i] { + t.Fatalf("%s frozen Plugin bytes changed", label) + } + } + if _, err := current.ReadEnvironmentSetup(t.Context(), foreignTenant, id); !errors.Is(err, store.ErrNotFound) { + t.Fatalf("%s foreign setup read: %v", label, err) + } + file, body, err := current.ReadInitialEnvironmentFile(t.Context(), tenant, id, 0) + if err != nil || string(body) != marker+"-source" || file.Path != "/workspace/source.txt" { + t.Fatalf("%s frozen source file changed: %v", label, err) + } + if _, _, err := current.ReadInitialEnvironmentFile(t.Context(), foreignTenant, id, 0); err == nil { + t.Fatalf("%s foreign initial file read: %v", label, err) + } + } + var encryptedSetup, encryptedFile, configuration []byte + err := pool.QueryRow(t.Context(), "SELECT e.contents,f.contents,s.configuration FROM environment_setups e JOIN sessions s ON s.id=e.session_id JOIN initial_environment_files f ON f.session_id=s.id WHERE s.id=$1", id).Scan(&encryptedSetup, &encryptedFile, &configuration) + if err != nil || len(encryptedSetup) == 0 || len(encryptedFile) == 0 || + bytes.Contains(encryptedSetup, []byte(marker)) || bytes.Contains(encryptedFile, []byte(marker)) || bytes.Contains(configuration, []byte(marker)) { + t.Fatalf("%s confidential initialization storage: %v", label, err) + } + } + // Counts include soft-deleted rows, so failed admission cannot hide partial state. + for _, check := range []struct { + query string + want int + }{ + {"SELECT count(*) FROM sessions WHERE tenant_id=$1", 11}, + {"SELECT count(*) FROM environments e JOIN sessions s ON s.id=e.session_id WHERE s.tenant_id=$1", 11}, + {"SELECT count(*) FROM environment_setups e JOIN sessions s ON s.id=e.session_id WHERE s.tenant_id=$1", 11}, + {"SELECT count(*) FROM initial_environment_files f JOIN sessions s ON s.id=f.session_id WHERE s.tenant_id=$1", 11}, + {"SELECT count(*) FROM turns t JOIN sessions s ON s.id=t.session_id WHERE s.tenant_id=$1", 0}, + {"SELECT count(*) FROM environment_input_reservations e JOIN sessions s ON s.id=e.session_id WHERE s.tenant_id=$1", 0}, + } { + var count int + if err := pool.QueryRow(t.Context(), check.query, tenant).Scan(&count); err != nil || count != check.want { + t.Fatalf("admission residue: got %d want %d: %v", count, check.want, err) + } + } + for _, owner := range []string{tenant, foreignTenant} { + var count int + if err := pool.QueryRow(t.Context(), "SELECT count(*) FROM sessions WHERE tenant_id=$1 AND idempotency_key=ANY($2::text[])", owner, receipt.RejectedKeys).Scan(&count); err != nil || count != 0 { + t.Fatalf("rejected creation persisted: %d %v", count, err) + } + } + t.Log("eleven selection cases passed SDK/raw HTTP, frozen encrypted bytes, tenant isolation, atomic rejection and reopened retries; no Runtime or model execution") +} diff --git a/services/agents-api/tests/official_environment_templates.py b/services/agents-api/tests/official_environment_templates.py index 7b59e1063..069b38ce5 100644 --- a/services/agents-api/tests/official_environment_templates.py +++ b/services/agents-api/tests/official_environment_templates.py @@ -101,7 +101,6 @@ def verify_template_session_rejections(client, foreign, http, agent, enabled, di headers = {'Authorization': 'Bearer ' + client.api_key, 'OpenAI-Beta': 'agents=v1'} for reference, override, status, token in [ (disabled, {'network': {'access': 'enabled'}}, 400, client.api_key), - (disabled, {'network': None}, 400, client.api_key), (str(uuid.uuid4()), {}, 404, client.api_key), (enabled, {}, 404, foreign.api_key), ]: diff --git a/services/agents-api/tests/official_template_null_selection.py b/services/agents-api/tests/official_template_null_selection.py new file mode 100644 index 000000000..21fad462a --- /dev/null +++ b/services/agents-api/tests/official_template_null_selection.py @@ -0,0 +1,244 @@ +"""Template selection acceptance over Core HTTP and PostgreSQL, without execution.""" + +import base64 +import copy +import hashlib +import importlib.metadata +import io +import json +import secrets +import sys +import zipfile +from contextlib import ExitStack +from pathlib import Path + +import httpx2 +from openai import DefaultHttpxClient, OpenAI + + +def bundle(files): + output = io.BytesIO() + with zipfile.ZipFile(output, "w", zipfile.ZIP_DEFLATED) as archive: + for name, content in files.items(): + archive.writestr("proof/" + name, content) + return output.getvalue() + + +def skill_manifest(label, marker): + return ("---\nname: selection-" + label + "\ndescription: Verify " + label + + " selection.\n---\n" + marker + "-" + label).encode() + + +def inline_skill(label, marker): + archive = bundle({"SKILL.md": skill_manifest(label, marker)}) + return {"type": "inline", "name": "selection-" + label, + "description": "Verify " + label + " selection.", + "source": {"type": "base64", "media_type": "application/zip", + "data": base64.b64encode(archive).decode()}}, archive + + +def inline_plugin(label, marker): + metadata = {"name": "plugin-" + label, "description": "Verify " + label + " Plugin selection."} + archive = bundle({".codex-plugin/plugin.json": json.dumps({**metadata, "skills": ["./skills"]}), + "skills/proof/SKILL.md": skill_manifest("plugin-" + label, marker)}) + return {"type": "inline", **metadata, + "source": {"type": "base64", "media_type": "application/zip", + "data": base64.b64encode(archive).decode()}}, archive + + +def main(): + settings = json.load(sys.stdin) + pin = json.loads((Path(__file__).resolve().parents[3] / "contracts/agents-api/upstream.json").read_text()) + distribution = importlib.metadata.distribution("openai") + assert distribution.version == pin["sdk_version"] == "3.13.0" + assert json.loads(distribution.read_text("direct_url.json"))["vcs_info"]["commit_id"] == pin["commit"] + marker = settings["canary"] + sessions, expected, replays, rejected_keys = {}, {}, {}, [] + templates, sources, skills = [], [], [] + private = [marker, settings["token"], settings["foreign"]] + succeeded = False + with ExitStack() as stack: + raw = stack.enter_context(httpx2.Client(trust_env=False, timeout=20)) + + def sdk(base, key): + return stack.enter_context(OpenAI(base_url=base + "/v1", api_key=key, max_retries=0, + _strict_response_validation=True, http_client=DefaultHttpxClient(trust_env=False))) + + owner = sdk(settings["base"], settings["token"]) + foreign = sdk(settings["base"], settings["foreign"]) + reopened = sdk(settings["recovered"], settings["token"]) + api = owner.beta.agents.environments.templates + headers = {"Authorization": "Bearer " + settings["token"], "OpenAI-Beta": "agents=v1"} + + def safe(response): + assert all(value not in response.text for value in private), "confidential response content" + + def request(body, key=None, token=None, base=None): + response = raw.post((base or settings["base"]) + "/v1/agents/sessions", json=body, + headers={**headers, "Authorization": "Bearer " + (token or settings["token"]), + "Idempotency-Key": key or secrets.token_hex(20)}) + safe(response) + return response + + def reject(body, status, token=None): + key = secrets.token_hex(20) + rejected_keys.append(key) + response = request(body, key, token) + assert response.status_code == status, (response.status_code, response.text) + return response.json() + + def projection(client, session_id, selection): + response = client.beta.agents.sessions.with_raw_response.retrieve(session_id) + safe(response.http_response) + assert response.status_code == 200 + parsed = response.parse() + body = response.http_response.json() + environment = body["environment"] + for field in ("skills", "plugins", "capability_directories", "network"): + assert environment[field] == selection[field], (field, environment[field], selection[field]) + assert parsed.environment.to_dict()[field] == selection[field] + assert "env" not in environment and "setup_commands" not in environment + assert len(environment["files"]) == 1 + file = environment["files"][0] + assert file["path"] == "/workspace/source.txt" and file["file_id"] == source.id + assert file["size_bytes"] == len(marker + "-source") + resource = client.beta.agents.environments.with_raw_response.retrieve(environment["id"]) + safe(resource.http_response) + assert resource.status_code == 200 + assert resource.parse().to_dict()["skills"] == selection["skills"] + for field in ("skills", "plugins", "files"): + assert resource.http_response.json()[field] == environment[field] + assert list(client.beta.agents.sessions.turns.list(session_id)) == [] + return body + + def create(label, body, selection, sdk_create=False): + key = secrets.token_hex(20) + if sdk_create: + result = owner.beta.agents.sessions.with_raw_response.create(**body, extra_headers={"Idempotency-Key": key}) + response = result.http_response + result.parse() + safe(response) + else: + response = request(body, key) + if response.status_code == 201: + sessions[label] = response.json()["id"] + assert response.status_code == 201, (label, response.status_code, response.text) + frozen = projection(owner, sessions[label], selection) + assert response.json() == frozen + expected[label] = selection + replays[label] = (body, key, frozen) + + try: + source = owner.files.create(file=("source.txt", (marker + "-source").encode()), purpose="user_data") + sources.append(source.id) + first = owner.skills.create(files=[("proof/SKILL.md", skill_manifest("template", marker), "text/markdown")]) + skills.append((owner, first.id)) + foreign_skill = foreign.skills.create(files=[("proof/SKILL.md", skill_manifest("foreign", marker), "text/markdown")]) + skills.append((foreign, foreign_skill.id)) + first_archive = owner.skills.versions.content.retrieve(skill_id=first.id, version="1").read() + plugin, plugin_archive = inline_plugin("template", marker) + replacement, replacement_archive = inline_skill("replacement", marker) + replacement_plugin, replacement_plugin_archive = inline_plugin("replacement", marker) + private.extend(item["source"]["data"] for item in (plugin, replacement, replacement_plugin)) + reference = {"type": "skill_reference", "skill_id": first.id} + template_skills = [{**reference, "version": "1", "name": first.name, "description": first.description}] + plugin_metadata = [{key: plugin[key] for key in ("type", "name", "description")}] + network = {"access": "restricted", "allowed_domains": ["example.com", "api.example.com"]} + files = [{"type": "file_id", "path": "/workspace/source.txt", "file_id": source.id}] + template = api.with_raw_response.create(name="selection", network=network, env={"PRIVATE_SELECTION": marker}, + files=files, skills=[reference], plugins=[plugin], capability_directories=["/workspace/template-capabilities"]) + template_id = template.http_response.json()["id"] + templates.append(template_id) + safe(template.http_response) + template.parse() + original = api.retrieve(template_id).to_dict() + base = {"agent": {"model": "selection-fixture"}, + "environment": {"type": "openai_hosted", "environment_template_id": template_id}} + default = {"skills": template_skills, "plugins": plugin_metadata, + "capability_directories": ["/workspace/template-capabilities"], "network": network, + "skill_digests": [hashlib.sha256(first_archive).hexdigest()], + "plugin_digests": [hashlib.sha256(plugin_archive).hexdigest()]} + replaced = {"skills": [{key: replacement[key] for key in ("type", "name", "description")}], + "plugins": [{key: replacement_plugin[key] for key in ("type", "name", "description")}], + "capability_directories": ["/workspace/replacement-capabilities"], + "network": {"access": "restricted", "allowed_domains": ["api.example.com"]}, + "skill_digests": [hashlib.sha256(replacement_archive).hexdigest()], + "plugin_digests": [hashlib.sha256(replacement_plugin_archive).hexdigest()]} + rows = [ + ("omitted", {}, default), + ("null", {key: None for key in ("network", "skills", "plugins", "capability_directories")}, default), + ("empty", {key: [] for key in ("skills", "plugins", "capability_directories")}, + {**default, "skills": [], "plugins": [], "capability_directories": [], "skill_digests": [], "plugin_digests": []}), + ("populated", {"network": replaced["network"], "skills": [replacement], "plugins": [replacement_plugin], + "capability_directories": replaced["capability_directories"]}, replaced), + ("mixed-skill", {"skills": None, "plugins": [], "capability_directories": replaced["capability_directories"]}, + {**default, "plugins": [], "plugin_digests": [], "capability_directories": replaced["capability_directories"]}), + ("mixed-plugin", {"skills": [], "plugins": None, "capability_directories": None}, + {**default, "skills": [], "skill_digests": []}), + ] + for label, overrides, selection in rows: + create(label, {**base, "environment": {**base["environment"], **overrides}}, selection, sdk_create=label == "null") + assert api.retrieve(template_id).to_dict() == original + # Core retains caller-declared paths. Official managed internal paths + # are a qualified projection difference, not values to copy locally. + denied = reject(base, 404, settings["foreign"]) + missing = copy.deepcopy(base) + missing["environment"]["environment_template_id"] = "00000000-0000-0000-0000-000000000001" + assert reject(missing, 404, settings["foreign"]) == denied + reject({**base, "environment": {**base["environment"], "network": {"access": "enabled"}}}, 400) + reject({**base, "environment": {**base["environment"], "skills": [{"type": "skill_reference", "skill_id": foreign_skill.id}]}}, 404) + reject({**base, "environment": {**base["environment"], "capability_directories": ["/outside"]}}, 400) + malformed = copy.deepcopy(plugin) + malformed["source"]["data"] = base64.b64encode(b"invalid ZIP").decode() + reject({**base, "environment": {**base["environment"], "plugins": [malformed]}}, 400) + disabled = {"access": "disabled", "allowed_domains": []} + api.update(template_id, network={"access": "disabled"}) + assert api.update(template_id, name="network omission preserves").network.to_dict() == disabled + create("disabled-omitted", base, {**default, "network": disabled}) + create("disabled-null", {**base, "environment": {**base["environment"], "network": None}}, {**default, "network": disabled}) + reject({**base, "environment": {**base["environment"], "network": {"access": "enabled"}}}, 400) + enabled = {"access": "enabled", "allowed_domains": []} + assert api.update(template_id, network=None).network.to_dict() == enabled + create("reset-enabled", {**base, "environment": {**base["environment"], "network": None}}, {**default, "network": enabled}) + inline = {"agent": base["agent"], "environment": {"type": "openai_hosted", "network": None, + "env": {"PRIVATE_SELECTION": marker}, "files": files}} + create("inline-null", inline, {**default, "skills": [], "plugins": [], "capability_directories": [], + "skill_digests": [], "plugin_digests": [], "network": enabled}) + foreign_ref = {"type": "skill_reference", "skill_id": foreign_skill.id} + api.update(template_id, skills=[foreign_ref]) + reject({**base, "environment": {**base["environment"], "skills": None}}, 404) + create("excluded-source", {**base, "environment": {**base["environment"], "skills": []}}, + {**default, "skills": [], "skill_digests": [], "network": enabled}) + owner.skills.versions.create(skill_id=first.id, default=True, + files=[("proof/SKILL.md", skill_manifest("mutated", marker), "text/markdown")]) + api.update(template_id, skills=[], plugins=[], capability_directories=[], env={}, files=[]) + api.delete(template_id) + templates.remove(template_id) + owner.skills.delete(first.id) + skills.remove((owner, first.id)) + owner.files.delete(source.id) + sources.remove(source.id) + for label, (body, key, frozen) in replays.items(): + response = request(body, key, base=settings["recovered"]) + assert response.status_code == 201 and response.json() == frozen, label + assert projection(reopened, sessions[label], expected[label]) == frozen + changed = copy.deepcopy(replays["null"][0]) + changed["environment"]["skills"] = [] + assert request(changed, replays["null"][1], base=settings["recovered"]).status_code == 409 + succeeded = True + print(json.dumps({"sessions": sessions, "expected": expected, "rejected_keys": rejected_keys, + "result": "passed", "postgres": True, "native_model_execution": False})) + finally: + for template_id in templates: + api.delete(template_id) + for source_id in sources: + owner.files.delete(source_id) + for client, skill_id in skills: + client.skills.delete(skill_id) + if not succeeded: + for session_id in sessions.values(): + owner.beta.agents.sessions.delete(session_id) + + +if __name__ == "__main__": + main()