diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index cf5e54c5..c71ec5f0 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -254,6 +254,19 @@ or an undocumented daemon installation requirement cannot replace `remote_url`. Keep harness cwd separate from the executor workspace where that accepted remote path still requires it. +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. +Updates and deletion cannot rewrite existing Session snapshots. The initial profile +admits name and enabled/disabled network, rejecting populated installation and +confidential fields before persistence. Do not store unsupported inputs for later +silent omission; expand both inline and template initialization together in separately +qualified batches. Resource reads need only tenant authorization, not a live Runtime. +See the [Template coverage and unresolved semantics](contracts/agents-api/environment-templates.md). + SandboxProvider has five operations: Create, GetInfo, Renew, Kill and RunCommand. Use maintained provider SDKs and thin adapters, Docker first and E2B after the MVP. Provider initialization creates the sandbox and starts its daemon/harness; diff --git a/contracts/agents-api/README.md b/contracts/agents-api/README.md index 7289a55a..17fd7ed9 100644 --- a/contracts/agents-api/README.md +++ b/contracts/agents-api/README.md @@ -62,9 +62,8 @@ business Team orchestration are separate from protocol coverage. This inventory is based on the pinned Python source, not our generated OpenAPI. It contains 42 distinct HTTP operations in 15 resource classes, excluding async -duplicates, overloads and client-side helpers. At merged PR #703 -(`6959654c94645aec9934ad682e277841492f7897`), 31 have handler entries and 11 are -missing: six Subagent reads and five Environment Template operations. The separate +duplicates, overloads and client-side helpers. There are 36 handler entries; the six +Subagent read operations remain missing. The separate general `/v1/files` source-file API is outside this 42-operation count. An implemented route is not complete semantic compatibility. **Accepted** below @@ -90,7 +89,7 @@ the Python SDK. Vault HTTP paths start at `/vaults`, not `/agents/vaults`. | sessions.subagents.turns.items | list | Missing | | environments | retrieve | Supported Codex self-hosted and three-harness Docker/E2B hosted profiles: durable status and safe empty installation metadata; populated installation inventory and full lifecycle parity remain gaps | | environments.files | create, list | [Bounded live listing and inline/source-file creation](environment-files.md) on qualified Docker/E2B workspaces; Codex self-hosted listing is a separate supported path. Full listing, overwrite and error semantics remain partial | -| environments.templates | create, retrieve, update, list, delete | Missing | +| environments.templates | create, retrieve, update, list, delete | [Basic reusable network configuration and Session snapshots](environment-templates.md); populated initialization and full semantics remain gaps | | vaults | create, retrieve, list, delete | Create/retrieve/list/delete with independent tenant persistence, stored status filtering, atomic Credential cascade and frozen Session attachments; archive semantics and full hosted lifecycle parity remain missing | | vaults.credentials | create, retrieve, update, list, delete | Static-bearer create/retrieve/list/token replacement/deletion with scoped encrypted storage; Session attachment and exact-URL HTTPS MCP binding; OAuth, archive semantics and full hosted lifecycle parity remain missing | @@ -156,7 +155,7 @@ user-managed enrollment remain outside this qualification. | Area | Missing or unverified scope | | --- | --- | | Subagents / multi_agent | Six public child read operations, enabled execution, child lifecycle/interactions and full recovery; deferred outside the MVP | -| Environment Templates | All five CRUD/list operations; populated installation metadata and additional Environment configurations remain separate gaps | +| Environment Templates | Populated initialization/confidential inputs, restricted network, referenced null override semantics and exact hosted errors; basic CRUD/list and Session references are supported | | Input and configuration | Non-text initial input, broader content/configuration unions, structured output and reasoning/verbosity combinations | | Tools and interactions | Deferred functions, other tool types, effective tool-set enforcement and result/cancel publication ordering; MiniMax public functions/MCP remain unsupported | | Vault and Credentials | OAuth/refresh, archive semantics, revocation/concurrent mutation and exact hosted selection/error behavior; static bearer CRUD/token replacement is already present | @@ -356,9 +355,13 @@ including further deployment qualification; this inventory describes merged beha describe native discovery or workspace files created by commands. Unknown installation configurations are rejected, not reported as empty. Reads use the owning live Session's project partition and do not require execution setup. - Populated installation metadata/configuration, templates, full hosted lifecycle and + Populated installation metadata/configuration, full hosted lifecycle and exact hosted error semantics remain gaps. +Basic [Environment Templates](environment-templates.md) provide tenant-owned CRUD/list +and immutable Session resolution through the same hosted initialization. They do not +select an E2B image or make unsupported initialization executable. + ## Delivery and verification | Capability | Current state | @@ -396,7 +399,7 @@ operator setup: [Codex](../../services/agents-api/deploy/codex/README.md), [MiniMax Code](../../services/agents-api/deploy/mcode/README.md). The [E2B guide](../../services/agents-api/deploy/e2b/README.md) packages those qualified images as pinned templates. -Populated startup installations, restricted domains, templates and hosted public +Populated startup installations, restricted domains and hosted public HTTP MCP remain outside these accepted profiles. MiniMax's private MCP tool bridge is internal transport, not public MCP support. diff --git a/contracts/agents-api/environment-templates.md b/contracts/agents-api/environment-templates.md new file mode 100644 index 00000000..0f2f4730 --- /dev/null +++ b/contracts/agents-api/environment-templates.md @@ -0,0 +1,82 @@ +# Environment Templates: basic hosted configuration + +Core owns reusable configuration through the five pinned +[Template operations](https://github.com/openai/openai-python/blob/d7c41efee1b0802b79f3f88a678ef2052b06e9ce/src/openai/resources/beta/agents/environments/templates.py). +Templates do not contain a running workspace and do not select a provider image. +An E2B `templateID:build_UUID` remains private operator packaging configuration. +Each referencing Session obtains its own Environment through the same initialization +and five-operation SandboxProvider path as inline configuration. + +## Supported batch + +- Create, retrieve, update, delete and list under `/v1/agents/environments/templates`. + Every operation requires project authentication and `OpenAI-Beta: agents=v1`. + CRUD/list works without an execution deployment. +- Optional nullable name, preserved verbatim, with a local 1–256 Unicode character + bound. Network supports `enabled` and `disabled`; omitted/null create network + defaults to the pinned enabled policy. Update omission preserves; supplied name + or network replaces, with null clearing name or resetting network. +- Empty/null installation fields retain empty defaults. Responses contain the + pinned metadata fields, empty arrays/objects as applicable and never `env` or + `setup_commands`. Populated confidential/installation inputs reject before storage. +- Listing uses `after`, `limit` (1–100, default 20), and `order` (default `desc`). + 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 disabled, never the reverse. 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; + changed intent conflicts. This is the existing local retry policy, not a claim of + complete upstream idempotency semantics. + +```python +from openai import OpenAI + +client = OpenAI(base_url="https://your-core.example/v1", api_key="your-project-key") +template = client.beta.agents.environments.templates.create( + name="Restricted outbound access", network={"access": "disabled"} +) +session = client.beta.agents.sessions.create( + agent={"model": "your-configured-model"}, + environment={"type": "openai_hosted", "environment_template_id": template.id}, + input="Create /workspace/outputs/report.txt containing the result of 6 * 7.", +) +# Inspect Session/Turn/Items and retrieve published Artifacts after completion. +# Delete the Session to reclaim its Environment; template deletion is independent. +``` + +## Explicit gaps and evidence boundaries + +Nonempty `env`, `setup_commands`, `files`, `packages`, `capability_directories`, +`skills` and `plugins`, plus restricted-domain network policy, remain unsupported +for both templates and inline initialization. Files can still be written through +the separately accepted live Files API after connection. Do not substitute that +later write for before-start template materialization. Unsupported requests reject +without echoing payloads; no secret-storage or initializer framework is introduced. + +The [hosted guide](https://developers.openai.com/api/docs/guides/agents-api/environments/openai-hosted) +clarifies that configured env values are readable by Agent code, files/packages +precede setup commands, nonzero setup prevents start, and runtime-reserved env names +must reject. Implementing those populated fields requires a separate batch with +common initialization, safe snapshots and failure/readiness acceptance. + +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. + +## Verification + +`official_environment_templates.py` checks all five fixed-SDK operations plus raw +HTTP, exact safe response shapes, field replacement/defaults, pagination, tenant +isolation and rejected confidential canaries. `official_e2b_v1.py` opts in with +private `verify_environment_templates: true`; it creates its actual native/model +Sessions from public templates, verifies frozen snapshots and creation retries +after update/delete, then reuses the existing execution, Files/Artifacts, isolation, +cancellation and crash/history-recovery assertions. Its disabled-network Session +inherits that policy from another template. Runtime and provider packaging are +unchanged. Database integration tests cover persistence and concurrent field updates; +API tests cover parsing and caller-intent distinctions. diff --git a/contracts/agents-api/environments.md b/contracts/agents-api/environments.md index 69357c64..2ae28edc 100644 --- a/contracts/agents-api/environments.md +++ b/contracts/agents-api/environments.md @@ -6,7 +6,8 @@ implementation plan with partial current coverage. Public execution admits profile, and operator-configured Docker/E2B hosted profiles for Codex, Claude Code and MiniMax Code below. Environment retrieval supports safe metadata for these environment profiles; -populated startup installations and templates remain missing. Live file listing +[basic reusable templates](environment-templates.md) share inline initialization, while +populated startup installations remain missing. Live file listing and local inline/source writes have [partial coverage and explicit local policies](environment-files.md). See [current coverage](README.md#public-semantics). @@ -59,7 +60,7 @@ an existing allocation's Create. Omitted/null network defaults to enabled; explicit enabled and disabled use the same image with adapter-selected immutable native policy. Unsupported restricted -domains, templates, populated env/packages/setup/files/plugins/skills/capability +domains and populated env/packages/setup/files/plugins/skills/capability paths fail explicitly. Empty/null installation defaults produce safe empty metadata, not a live workspace inventory. Hosted MCP combinations remain unimplemented. diff --git a/contracts/agents-api/openapi.yaml b/contracts/agents-api/openapi.yaml index 511e8b6e..db973c56 100644 --- a/contracts/agents-api/openapi.yaml +++ b/contracts/agents-api/openapi.yaml @@ -241,6 +241,8 @@ definitions: type: string type: array x-nullable: true + environment_template_id: + type: string network: allOf: - $ref: '#/definitions/v1.EnvironmentNetworkInput' @@ -397,6 +399,153 @@ definitions: - python - system type: object + v1.EnvironmentPackagesInput: + properties: + npm: + items: + type: string + type: array + x-nullable: true + python: + items: + type: string + type: array + x-nullable: true + system: + items: + type: string + type: array + x-nullable: true + type: object + v1.EnvironmentTemplate: + properties: + capability_directories: + items: + type: string + type: array + created_at: + type: integer + files: + items: + type: object + type: array + id: + type: string + name: + type: string + x-nullable: true + network: + $ref: '#/definitions/v1.EnvironmentNetwork' + object: + enum: + - agent.environment.template + type: string + packages: + $ref: '#/definitions/v1.EnvironmentPackages' + plugins: + items: + type: object + type: array + skills: + items: + type: object + type: array + updated_at: + type: integer + required: + - capability_directories + - created_at + - files + - id + - network + - object + - packages + - plugins + - skills + - updated_at + type: object + v1.EnvironmentTemplateDeleted: + properties: + deleted: + type: boolean + id: + type: string + object: + enum: + - agent.environment.template.deleted + type: string + required: + - deleted + - id + - object + type: object + v1.EnvironmentTemplateList: + properties: + data: + items: + $ref: '#/definitions/v1.EnvironmentTemplate' + type: array + first_id: + type: string + x-nullable: true + has_more: + type: boolean + last_id: + type: string + x-nullable: true + object: + enum: + - list + type: string + required: + - data + - has_more + - object + type: object + v1.EnvironmentTemplateRequest: + properties: + capability_directories: + items: + type: string + type: array + x-nullable: true + env: + additionalProperties: + type: string + type: object + x-nullable: true + files: + items: + type: object + type: array + x-nullable: true + name: + type: string + x-nullable: true + network: + allOf: + - $ref: '#/definitions/v1.EnvironmentNetworkInput' + x-nullable: true + packages: + allOf: + - $ref: '#/definitions/v1.EnvironmentPackagesInput' + x-nullable: true + plugins: + items: + type: object + type: array + x-nullable: true + setup_commands: + items: + type: object + type: array + x-nullable: true + skills: + items: + type: object + type: array + x-nullable: true + type: object v1.ErrorResponse: properties: error: @@ -1823,6 +1972,254 @@ paths: summary: Create an Environment file from inline bytes or a source file tags: - Environments + /agents/environments/templates: + get: + description: Lists tenant-owned safe template metadata in creation order with + ID tie-breaking. Defaults to limit 20 and descending order; limit must be + 1–100. Foreign and missing cursors reject identically. Concurrent-page and + exact hosted error behavior remain unverified. + parameters: + - description: agents=v1 + in: header + name: OpenAI-Beta + required: true + type: string + - description: Previous Template ID + in: query + name: after + type: string + - default: 20 + description: Page size + in: query + maximum: 100 + minimum: 1 + name: limit + type: integer + - default: desc + description: Creation order + enum: + - asc + - desc + in: query + name: order + type: string + produces: + - application/json + responses: + "200": + description: OK + schema: + $ref: '#/definitions/v1.EnvironmentTemplateList' + "400": + description: Bad Request + schema: + $ref: '#/definitions/v1.ErrorResponse' + "401": + description: Unauthorized + schema: + $ref: '#/definitions/v1.ErrorResponse' + "404": + description: Not Found + schema: + $ref: '#/definitions/v1.ErrorResponse' + "500": + description: Internal Server Error + schema: + $ref: '#/definitions/v1.ErrorResponse' + security: + - BearerAuth: [] + summary: List Environment Templates + tags: + - Environment Templates + post: + consumes: + - application/json + description: Saves tenant-owned basic hosted configuration. Supports nullable + name, enabled/disabled network and empty installation defaults. Omitted/null + network defaults to enabled. Populated confidential inputs, installations + and restricted network are rejected before persistence without echoing input. + No compute is allocated. Exact hosted error/retry semantics remain unverified. + parameters: + - description: agents=v1 + in: header + name: OpenAI-Beta + required: true + type: string + - description: Reusable configuration + in: body + name: body + required: true + schema: + $ref: '#/definitions/v1.EnvironmentTemplateRequest' + produces: + - application/json + responses: + "200": + description: OK + schema: + $ref: '#/definitions/v1.EnvironmentTemplate' + "400": + description: Bad Request + schema: + $ref: '#/definitions/v1.ErrorResponse' + "401": + description: Unauthorized + schema: + $ref: '#/definitions/v1.ErrorResponse' + "413": + description: Request Entity Too Large + schema: + $ref: '#/definitions/v1.ErrorResponse' + "500": + description: Internal Server Error + schema: + $ref: '#/definitions/v1.ErrorResponse' + security: + - BearerAuth: [] + summary: Create an Environment Template + tags: + - Environment Templates + /agents/environments/templates/{environment_template_id}: + delete: + description: Deletes the tenant-owned reusable configuration without changing + or deleting existing Sessions and their frozen configuration. + parameters: + - description: agents=v1 + in: header + name: OpenAI-Beta + required: true + type: string + - description: Template ID + in: path + name: environment_template_id + required: true + type: string + produces: + - application/json + responses: + "200": + description: OK + schema: + $ref: '#/definitions/v1.EnvironmentTemplateDeleted' + "400": + description: Bad Request + schema: + $ref: '#/definitions/v1.ErrorResponse' + "401": + description: Unauthorized + schema: + $ref: '#/definitions/v1.ErrorResponse' + "404": + description: Not Found + schema: + $ref: '#/definitions/v1.ErrorResponse' + "500": + description: Internal Server Error + schema: + $ref: '#/definitions/v1.ErrorResponse' + security: + - BearerAuth: [] + summary: Delete an Environment Template + tags: + - Environment Templates + get: + description: Returns safe tenant-owned configuration metadata without allocating + compute. Missing and foreign resources return the same not-found response. + parameters: + - description: agents=v1 + in: header + name: OpenAI-Beta + required: true + type: string + - description: Template ID + in: path + name: environment_template_id + required: true + type: string + produces: + - application/json + responses: + "200": + description: OK + schema: + $ref: '#/definitions/v1.EnvironmentTemplate' + "400": + description: Bad Request + schema: + $ref: '#/definitions/v1.ErrorResponse' + "401": + description: Unauthorized + schema: + $ref: '#/definitions/v1.ErrorResponse' + "404": + description: Not Found + schema: + $ref: '#/definitions/v1.ErrorResponse' + "500": + description: Internal Server Error + schema: + $ref: '#/definitions/v1.ErrorResponse' + security: + - BearerAuth: [] + summary: Retrieve an Environment Template + tags: + - Environment Templates + post: + consumes: + - application/json + description: Supplied fields replace atomically; omitted fields remain unchanged. + Null name clears and null network resets to the pinned enabled default. Existing + Session snapshots and creation retries remain unchanged. Populated installations + are unsupported. Exact hosted no-op timestamp behavior remains unverified. + parameters: + - description: agents=v1 + in: header + name: OpenAI-Beta + required: true + type: string + - description: Template ID + in: path + name: environment_template_id + required: true + type: string + - description: Configuration replacements + in: body + name: body + required: true + schema: + $ref: '#/definitions/v1.EnvironmentTemplateRequest' + produces: + - application/json + responses: + "200": + description: OK + schema: + $ref: '#/definitions/v1.EnvironmentTemplate' + "400": + description: Bad Request + schema: + $ref: '#/definitions/v1.ErrorResponse' + "401": + description: Unauthorized + schema: + $ref: '#/definitions/v1.ErrorResponse' + "404": + description: Not Found + schema: + $ref: '#/definitions/v1.ErrorResponse' + "413": + description: Request Entity Too Large + schema: + $ref: '#/definitions/v1.ErrorResponse' + "500": + description: Internal Server Error + schema: + $ref: '#/definitions/v1.ErrorResponse' + security: + - BearerAuth: [] + summary: Update an Environment Template + tags: + - Environment Templates /agents/sessions: get: description: Cursor and results are scoped to the authenticated execution tenant. @@ -1941,7 +2338,11 @@ paths: tools; HTTP MCP remains unsupported. Idle Sessions provision automatically; initial provisioning has no caller connection action. Network defaults to enabled; disabled is also supported, while restricted domains and populated - startup installations are rejected. + startup installations are rejected. 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. parameters: - description: agents=v1 in: header diff --git a/contracts/agents-api/v1/environment_templates.go b/contracts/agents-api/v1/environment_templates.go new file mode 100644 index 00000000..7298ecf0 --- /dev/null +++ b/contracts/agents-api/v1/environment_templates.go @@ -0,0 +1,54 @@ +package v1 + +import "encoding/json" + +// EnvironmentTemplateRequest exposes the pinned input fields. Populated installations +// and restricted networking are rejected until their initialization is qualified. +type EnvironmentTemplateRequest struct { + Name *string `json:"name,omitempty" extensions:"x-nullable"` + Network *EnvironmentNetworkInput `json:"network,omitempty" extensions:"x-nullable"` + CapabilityDirectories []string `json:"capability_directories,omitempty" extensions:"x-nullable"` + Env map[string]string `json:"env,omitempty" extensions:"x-nullable"` + Files []json.RawMessage `json:"files,omitempty" extensions:"x-nullable" swaggertype:"array,object"` + Packages *EnvironmentPackagesInput `json:"packages,omitempty" extensions:"x-nullable"` + Plugins []json.RawMessage `json:"plugins,omitempty" extensions:"x-nullable" swaggertype:"array,object"` + Skills []json.RawMessage `json:"skills,omitempty" extensions:"x-nullable" swaggertype:"array,object"` + SetupCommands []json.RawMessage `json:"setup_commands,omitempty" extensions:"x-nullable" swaggertype:"array,object"` +} + +// EnvironmentPackagesInput keeps optional nullable request defaults separate from +// the complete package lists returned by resource responses. +type EnvironmentPackagesInput struct { + NPM []string `json:"npm,omitempty" extensions:"x-nullable"` + Python []string `json:"python,omitempty" extensions:"x-nullable"` + System []string `json:"system,omitempty" extensions:"x-nullable"` +} + +// EnvironmentTemplate returns safe configuration metadata only. +type EnvironmentTemplate struct { + ID string `json:"id" binding:"required"` + Object string `json:"object" binding:"required" enums:"agent.environment.template"` + Name *string `json:"name" extensions:"x-nullable"` + CreatedAt int64 `json:"created_at" binding:"required"` + UpdatedAt int64 `json:"updated_at" binding:"required"` + CapabilityDirectories []string `json:"capability_directories" binding:"required"` + Network EnvironmentNetwork `json:"network" binding:"required"` + Packages EnvironmentPackages `json:"packages" binding:"required"` + Files []json.RawMessage `json:"files" binding:"required" swaggertype:"array,object"` + Plugins []json.RawMessage `json:"plugins" binding:"required" swaggertype:"array,object"` + Skills []json.RawMessage `json:"skills" binding:"required" swaggertype:"array,object"` +} + +type EnvironmentTemplateList struct { + Object string `json:"object" binding:"required" enums:"list"` + Data []EnvironmentTemplate `json:"data" binding:"required"` + HasMore bool `json:"has_more" binding:"required"` + FirstID *string `json:"first_id" extensions:"x-nullable"` + LastID *string `json:"last_id" extensions:"x-nullable"` +} + +type EnvironmentTemplateDeleted struct { + ID string `json:"id" binding:"required"` + Object string `json:"object" binding:"required" enums:"agent.environment.template.deleted"` + Deleted bool `json:"deleted" binding:"required"` +} diff --git a/contracts/agents-api/v1/sessions.go b/contracts/agents-api/v1/sessions.go index 260cbdb5..466f1d7f 100644 --- a/contracts/agents-api/v1/sessions.go +++ b/contracts/agents-api/v1/sessions.go @@ -35,6 +35,7 @@ type InlineAgent struct { // Environment contains supported request variants; self-hosted creation requires a workspace directory. type Environment struct { + EnvironmentTemplateID string `json:"environment_template_id,omitempty"` Type string `json:"type" enums:"none,self_hosted,openai_hosted" binding:"required"` WorkspaceDirectory string `json:"workspace_directory,omitempty"` CapabilityDirectories []string `json:"capability_directories,omitempty" extensions:"x-nullable"` diff --git a/services/agents-api/README.md b/services/agents-api/README.md index 51b40c99..e23fab9a 100644 --- a/services/agents-api/README.md +++ b/services/agents-api/README.md @@ -240,7 +240,7 @@ only completion snapshots. Pinned native 0.153.4 may also omit early process output from both notifications and its final aggregate; this remains an upstream execution gap. Recover missed output with Items queries, not SSE replay. -Non-text message input, Subagents, Environment templates and populated +Non-text message input, Subagents and populated installation metadata remain unsupported. Saving optional Agent configuration does not make it executable. Unsupported requests fail explicitly. `/healthz` reports liveness only. @@ -254,7 +254,8 @@ or [E2B template/provider setup](deploy/e2b/README.md). Core remains independently deployed with its own database. Public idle and initial text Sessions share the existing preparation, execution, Files and recovery paths. Networking defaults to enabled; disabled is also supported. Restricted domains, -templates and populated startup installations remain gaps. Additional harnesses +populated startup installations remain gaps. [Basic public Environment Templates](../../contracts/agents-api/environment-templates.md) +resolve to the same immutable hosted configuration, independently of provider templates. Additional harnesses require separate integration and qualification. Connected describes the authenticated Runtime connection, not native readiness. Exact hosted failure/expiry semantics remain unverified. @@ -576,7 +577,8 @@ existing native steering receipts; retries keep their original Turn after comple or during later work. No unlocked activity check can bypass idle preparation. Mixed events, deferred functions, nonempty capability directories, other placements, populated installation metadata -and Environment file/template routes remain unavailable. These are implementation gaps. +remain unavailable in this self-hosted profile. Public Environment Templates apply only +to hosted Sessions; Files coverage is recorded in the shared contract assessment. Retrieve the returned Environment with `client.beta.agents.environments.retrieve(session.environment.id)`. This read uses diff --git a/services/agents-api/internal/api/environment_templates.go b/services/agents-api/internal/api/environment_templates.go new file mode 100644 index 00000000..17fef153 --- /dev/null +++ b/services/agents-api/internal/api/environment_templates.go @@ -0,0 +1,201 @@ +package api + +import ( + "context" + "encoding/json" + "net/http" + "unicode/utf8" + + v1 "github.com/MiniMax-AI-Dev/parsar/contracts/agents-api/v1" + "github.com/MiniMax-AI-Dev/parsar/services/agents-api/internal/store" + "github.com/go-chi/chi/v5" +) + +type EnvironmentTemplateStore interface { + CreateEnvironmentTemplate(context.Context, string, store.EnvironmentTemplateInput) (store.EnvironmentTemplate, error) + GetEnvironmentTemplate(context.Context, string, string) (store.EnvironmentTemplate, error) + UpdateEnvironmentTemplate(context.Context, string, string, store.EnvironmentTemplateInput) (store.EnvironmentTemplate, error) + DeleteEnvironmentTemplate(context.Context, string, string) (string, error) + ListEnvironmentTemplates(context.Context, string, string, int, bool) (store.EnvironmentTemplatePage, error) +} + +func decodeTemplateInput(raw []byte) (store.EnvironmentTemplateInput, error) { + var fields map[string]json.RawMessage + if decodeInputObject(raw, &fields, "name", "network", "capability_directories", "env", "files", "packages", "plugins", "skills", "setup_commands") != nil { + return store.EnvironmentTemplateInput{}, store.ErrInvalidInput + } + in := store.EnvironmentTemplateInput{} + if value, supplied := fields["name"]; supplied { + in.SetName = true + if json.Unmarshal(value, &in.Name) != nil || (in.Name != nil && (!utf8.ValidString(*in.Name) || utf8.RuneCountInString(*in.Name) < 1 || utf8.RuneCountInString(*in.Name) > 256)) { + return in, store.ErrInvalidInput + } + } + delete(fields, "name") + _, in.SetNetwork = fields["network"] + fields["type"] = json.RawMessage(`"openai_hosted"`) + configuration, err := json.Marshal(fields) + if err != nil { + return in, err + } + environment, err := decodeHostedEnvironment(configuration) + if err != nil { + return in, err + } + in.NetworkAccess = environment.Network.Access + return in, nil +} + +func templateResponse(t store.EnvironmentTemplate) v1.EnvironmentTemplate { + return v1.EnvironmentTemplate{ID: t.ID, Object: "agent.environment.template", Name: t.Name, CreatedAt: t.CreatedAt.Unix(), UpdatedAt: t.UpdatedAt.Unix(), CapabilityDirectories: []string{}, Network: v1.EnvironmentNetwork{Access: t.NetworkAccess, AllowedDomains: []string{}}, Packages: v1.EnvironmentPackages{NPM: []string{}, Python: []string{}, System: []string{}}, Files: []json.RawMessage{}, Plugins: []json.RawMessage{}, Skills: []json.RawMessage{}} +} + +func templateNoQuery(w http.ResponseWriter, r *http.Request) bool { + if len(r.URL.Query()) > 0 { + writeError(w, http.StatusBadRequest, "unsupported_parameter", "This template operation does not accept query parameters.") + return false + } + return true +} + +func readTemplateInput(w http.ResponseWriter, r *http.Request) (store.EnvironmentTemplateInput, bool) { + if !templateNoQuery(w, r) { + return store.EnvironmentTemplateInput{}, false + } + raw, ok := readJSONBody(w, r) + if !ok { + return store.EnvironmentTemplateInput{}, false + } + in, err := decodeTemplateInput(raw) + if err != nil { + writeError(w, http.StatusBadRequest, "unsupported_or_invalid_configuration", "Template fields are invalid or require unsupported initialization. Only name, enabled/disabled network and empty installation defaults are supported.") + return in, false + } + return in, true +} + +// @Summary Create an Environment Template +// @Description Saves tenant-owned basic hosted configuration. Supports nullable name, enabled/disabled network and empty installation defaults. Omitted/null network defaults to enabled. Populated confidential inputs, installations and restricted network are rejected before persistence without echoing input. No compute is allocated. Exact hosted error/retry semantics remain unverified. +// @Tags Environment Templates +// @Accept json +// @Produce json +// @Security BearerAuth +// @Param OpenAI-Beta header string true "agents=v1" +// @Param body body v1.EnvironmentTemplateRequest true "Reusable configuration" +// @Success 200 {object} v1.EnvironmentTemplate +// @Failure 400,401,413,500 {object} v1.ErrorResponse +// @Router /agents/environments/templates [post] +func (h *Handler) createEnvironmentTemplate(w http.ResponseWriter, r *http.Request) { + in, ok := readTemplateInput(w, r) + if !ok { + return + } + value, err := h.store.CreateEnvironmentTemplate(r.Context(), tenantID(r), in) + if err != nil { + writeStoreError(w, r, err) + return + } + writeJSON(w, http.StatusOK, templateResponse(value)) +} + +// @Summary Retrieve an Environment Template +// @Description Returns safe tenant-owned configuration metadata without allocating compute. Missing and foreign resources return the same not-found response. +// @Tags Environment Templates +// @Produce json +// @Security BearerAuth +// @Param OpenAI-Beta header string true "agents=v1" +// @Param environment_template_id path string true "Template ID" +// @Success 200 {object} v1.EnvironmentTemplate +// @Failure 400,401,404,500 {object} v1.ErrorResponse +// @Router /agents/environments/templates/{environment_template_id} [get] +func (h *Handler) getEnvironmentTemplate(w http.ResponseWriter, r *http.Request) { + if !templateNoQuery(w, r) { + return + } + value, err := h.store.GetEnvironmentTemplate(r.Context(), tenantID(r), chi.URLParam(r, "environment_template_id")) + if err != nil { + writeStoreError(w, r, err) + return + } + writeJSON(w, http.StatusOK, templateResponse(value)) +} + +// @Summary Update an Environment Template +// @Description Supplied fields replace atomically; omitted fields remain unchanged. Null name clears and null network resets to the pinned enabled default. Existing Session snapshots and creation retries remain unchanged. Populated installations are unsupported. Exact hosted no-op timestamp behavior remains unverified. +// @Tags Environment Templates +// @Accept json +// @Produce json +// @Security BearerAuth +// @Param OpenAI-Beta header string true "agents=v1" +// @Param environment_template_id path string true "Template ID" +// @Param body body v1.EnvironmentTemplateRequest true "Configuration replacements" +// @Success 200 {object} v1.EnvironmentTemplate +// @Failure 400,401,404,413,500 {object} v1.ErrorResponse +// @Router /agents/environments/templates/{environment_template_id} [post] +func (h *Handler) updateEnvironmentTemplate(w http.ResponseWriter, r *http.Request) { + in, ok := readTemplateInput(w, r) + if !ok { + return + } + value, err := h.store.UpdateEnvironmentTemplate(r.Context(), tenantID(r), chi.URLParam(r, "environment_template_id"), in) + if err != nil { + writeStoreError(w, r, err) + return + } + writeJSON(w, http.StatusOK, templateResponse(value)) +} + +// @Summary Delete an Environment Template +// @Description Deletes the tenant-owned reusable configuration without changing or deleting existing Sessions and their frozen configuration. +// @Tags Environment Templates +// @Produce json +// @Security BearerAuth +// @Param OpenAI-Beta header string true "agents=v1" +// @Param environment_template_id path string true "Template ID" +// @Success 200 {object} v1.EnvironmentTemplateDeleted +// @Failure 400,401,404,500 {object} v1.ErrorResponse +// @Router /agents/environments/templates/{environment_template_id} [delete] +func (h *Handler) deleteEnvironmentTemplate(w http.ResponseWriter, r *http.Request) { + if !templateNoQuery(w, r) { + return + } + id, err := h.store.DeleteEnvironmentTemplate(r.Context(), tenantID(r), chi.URLParam(r, "environment_template_id")) + if err != nil { + writeStoreError(w, r, err) + return + } + writeJSON(w, http.StatusOK, v1.EnvironmentTemplateDeleted{ID: id, Object: "agent.environment.template.deleted", Deleted: true}) +} + +// @Summary List Environment Templates +// @Description Lists tenant-owned safe template metadata in creation order with ID tie-breaking. Defaults to limit 20 and descending order; limit must be 1–100. Foreign and missing cursors reject identically. Concurrent-page and exact hosted error behavior remain unverified. +// @Tags Environment Templates +// @Produce json +// @Security BearerAuth +// @Param OpenAI-Beta header string true "agents=v1" +// @Param after query string false "Previous Template ID" +// @Param limit query integer false "Page size" default(20) minimum(1) maximum(100) +// @Param order query string false "Creation order" Enums(asc,desc) default(desc) +// @Success 200 {object} v1.EnvironmentTemplateList +// @Failure 400,401,404,500 {object} v1.ErrorResponse +// @Router /agents/environments/templates [get] +func (h *Handler) listEnvironmentTemplates(w http.ResponseWriter, r *http.Request) { + options, ok := readPage(w, r) + if !ok { + return + } + page, err := h.store.ListEnvironmentTemplates(r.Context(), tenantID(r), options.after, options.limit, options.ascending) + if err != nil { + writeStoreError(w, r, err) + return + } + response := v1.EnvironmentTemplateList{Object: "list", Data: make([]v1.EnvironmentTemplate, 0, len(page.Templates)), HasMore: page.HasMore} + for _, value := range page.Templates { + response.Data = append(response.Data, templateResponse(value)) + } + if len(response.Data) > 0 { + response.FirstID = &response.Data[0].ID + response.LastID = &response.Data[len(response.Data)-1].ID + } + writeJSON(w, http.StatusOK, response) +} diff --git a/services/agents-api/internal/api/environment_templates_test.go b/services/agents-api/internal/api/environment_templates_test.go new file mode 100644 index 00000000..22f0824c --- /dev/null +++ b/services/agents-api/internal/api/environment_templates_test.go @@ -0,0 +1,92 @@ +package api + +import ( + "context" + "encoding/json" + "net/http" + "net/http/httptest" + "strings" + "testing" + + "github.com/MiniMax-AI-Dev/parsar/services/agents-api/internal/store" +) + +func TestTemplateConfigurationRejectsUnqualifiedInputs(t *testing.T) { + for _, raw := range []string{`{}`, `{"packages":{}}`, `{"packages":{"npm":null}}`, `{"name":null,"network":null}`, `{"name":"保存","network":{"access":"disabled"},"env":{},"files":[],"setup_commands":[],"packages":{"npm":null}}`} { + if _, err := decodeTemplateInput([]byte(raw)); err != nil { + t.Fatalf("supported input: %s: %v", raw, err) + } + } + for _, raw := range []string{`null`, `[]`, `{"name":""}`, `{"name":42}`, `{"type":"openai_hosted"}`, `{"network":{"access":"restricted","allowed_domains":["example.com"]}}`, `{"env":{"TOKEN":"confidential-canary"}}`, `{"setup_commands":[{"command":"confidential-canary"}]}`, `{"files":[{"type":"inline","path":"/workspace/a","data":"c2VjcmV0"}]}`, `{"packages":{"python":["requests"]}}`, `{"plugins":[{}]}`, `{"skills":[{}]}`, `{"capability_directories":["/workspace"]}`} { + if _, err := decodeTemplateInput([]byte(raw)); err == nil { + t.Fatalf("unsupported input accepted: %s", raw) + } + } + h, _, _ := testHandler(t) + req := httptest.NewRequest(http.MethodPost, "/v1/agents/environments/templates", strings.NewReader(`{"env":{"TOKEN":"confidential-canary"}}`)) + req.Header.Set("Authorization", "Bearer test-api-key") + req.Header.Set("OpenAI-Beta", "agents=v1") + response := httptest.NewRecorder() + h.ServeHTTP(response, req) + if response.Code != http.StatusBadRequest || strings.Contains(response.Body.String(), "confidential-canary") { + t.Fatal("confidential input not safely rejected", response.Code, response.Body.String()) + } +} + +type templateLookupStore struct { + ResourceStore + network string + tenant string +} + +func (s *templateLookupStore) GetEnvironmentTemplate(_ context.Context, tenant, id string) (store.EnvironmentTemplate, error) { + s.tenant = tenant + return store.EnvironmentTemplate{ID: id, NetworkAccess: s.network}, nil +} + +func TestTemplateResolutionAndCreationIntent(t *testing.T) { + lookup := &templateLookupStore{network: "disabled"} + h := Handler{store: lookup} + request := func(raw string) sessionRequest { + t.Helper() + var decoded decodedSessionRequest + if err := json.Unmarshal([]byte(`{"agent":{"model":"test"},"environment":`+raw+`}`), &decoded); err != nil { + t.Fatal(err) + } + input, err := decoded.validated() + if err != nil { + t.Fatal(err) + } + return input + } + inherited := request(`{"type":"openai_hosted","environment_template_id":"saved"}`) + intent, err := sessionCreationRequest(inherited, nil) + if err != nil || !strings.Contains(string(intent), `"environment_template_id":"saved"`) { + t.Fatal("missing caller intent", string(intent), err) + } + if err := h.resolveTemplateEnvironment(t.Context(), "tenant-a", &inherited); err != nil || inherited.Environment.Network.Access != "disabled" || lookup.tenant != "tenant-a" { + t.Fatal("inheritance failed", err) + } + raw, _ := json.Marshal(inherited.Environment) + if strings.Contains(string(raw), "template") { + t.Fatal("template leaked to execution", string(raw)) + } + broader := request(`{"type":"openai_hosted","environment_template_id":"saved","network":{"access":"enabled"}}`) + broaderIntent, _ := sessionCreationRequest(broader, nil) + if string(broaderIntent) == string(intent) { + t.Fatal("default erased caller override") + } + if err := h.resolveTemplateEnvironment(t.Context(), "tenant-a", &broader); err == nil { + t.Fatal("network broadened") + } + narrower := request(`{"type":"openai_hosted","environment_template_id":"saved","network":{"access":"disabled"}}`) + lookup.network = "enabled" + 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}`, `{"type":"openai_hosted","environment_template_id":"saved","env":{"KEY":"secret"}}`} { + 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 4c047bc3..945ef640 100644 --- a/services/agents-api/internal/api/handler.go +++ b/services/agents-api/internal/api/handler.go @@ -18,6 +18,7 @@ import ( type ResourceStore interface { AgentStore + EnvironmentTemplateStore VaultStore CredentialStore GetEnvironment(context.Context, string, string) (store.Environment, error) @@ -83,6 +84,11 @@ func NewHandler(s ResourceStore, auth *Authenticator, engine string, options ... r.Get("/agents/{agent_id}", h.getAgent) r.Post("/agents/{agent_id}", h.updateAgent) r.Delete("/agents/{agent_id}", h.deleteAgent) + r.Post("/agents/environments/templates", h.createEnvironmentTemplate) + r.Get("/agents/environments/templates", h.listEnvironmentTemplates) + r.Get("/agents/environments/templates/{environment_template_id}", h.getEnvironmentTemplate) + r.Post("/agents/environments/templates/{environment_template_id}", h.updateEnvironmentTemplate) + r.Delete("/agents/environments/templates/{environment_template_id}", h.deleteEnvironmentTemplate) r.Get("/agents/environments/{environment_id}", h.getEnvironment) r.Get("/agents/environments/{environment_id}/files", h.listEnvironmentFiles) r.Post("/agents/environments/{environment_id}/files", h.createEnvironmentFile) @@ -112,7 +118,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, disabled multi_agent, 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 user-message array containing text. 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. Omitted or null input creates an idle Session. 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. 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 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 is also supported, while restricted domains and populated startup installations are rejected. +// @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, disabled multi_agent, 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 user-message array containing text. 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. Omitted or null input creates an idle Session. 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. 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 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 is also supported, while restricted domains and populated startup installations are rejected. 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. // @Tags Sessions // @Accept json // @Produce json,text/event-stream @@ -166,6 +172,12 @@ func (h *Handler) createSession(w http.ResponseWriter, r *http.Request) { if h.recoverSessionCreation(w, r, key, creationRequest, input.Stream) { return } + if err := h.resolveTemplateEnvironment(r.Context(), tenantID(r), &input); err != nil { + if !h.recoverSessionCreation(w, r, key, creationRequest, input.Stream) { + writeStoreError(w, r, err) + } + return + } var saved *v1.SavedAgent if input.AgentID != nil { resource, err := h.lookupAgent(r.Context(), tenantID(r), *input.AgentID) diff --git a/services/agents-api/internal/api/session_creation_identity.go b/services/agents-api/internal/api/session_creation_identity.go index b5c52bb6..e58d5f11 100644 --- a/services/agents-api/internal/api/session_creation_identity.go +++ b/services/agents-api/internal/api/session_creation_identity.go @@ -5,26 +5,29 @@ import ( "errors" "net/http" - v1 "github.com/MiniMax-AI-Dev/parsar/contracts/agents-api/v1" "github.com/MiniMax-AI-Dev/parsar/services/agents-api/internal/store" ) func sessionCreationRequest(input sessionRequest, initial []store.Input) (json.RawMessage, error) { - if input.AgentID == nil && !inlineCredentialIntent(input) { + if input.AgentID == nil && input.templateID == "" && !inlineCredentialIntent(input) { return nil, nil } agentID := "" if input.AgentID != nil { agentID = *input.AgentID } + var environment any = input.Environment + if input.templateID != "" { + environment = input.templateEnvironment + } return json.Marshal(struct { AgentID string `json:"agent_id"` Agent map[string]json.RawMessage `json:"agent,omitempty"` - Environment *v1.Environment `json:"environment"` + Environment any `json:"environment"` Metadata map[string]string `json:"metadata,omitempty"` VaultIDs []string `json:"vault_ids,omitempty"` InitialInputs []store.Input `json:"initial_inputs,omitempty"` - }{agentID, input.agentFields, input.Environment, input.Metadata, input.VaultIDs, initial}) + }{agentID, input.agentFields, environment, input.Metadata, input.VaultIDs, initial}) } func (h *Handler) recoverSessionCreation(w http.ResponseWriter, r *http.Request, key string, request json.RawMessage, stream bool) bool { diff --git a/services/agents-api/internal/api/session_request.go b/services/agents-api/internal/api/session_request.go index c222b5f9..853b8259 100644 --- a/services/agents-api/internal/api/session_request.go +++ b/services/agents-api/internal/api/session_request.go @@ -23,8 +23,10 @@ type decodedSessionRequest struct { type sessionRequest struct { v1.CreateSessionRequest - Input json.RawMessage - agentFields map[string]json.RawMessage + Input json.RawMessage + templateID string + templateEnvironment json.RawMessage + agentFields map[string]json.RawMessage } func (request decodedSessionRequest) validated() (sessionRequest, error) { @@ -41,7 +43,7 @@ func (request decodedSessionRequest) validated() (sessionRequest, error) { input.VaultIDs = append(input.VaultIDs, *id) } var err error - input.Environment, err = decodeSessionEnvironment(request.Environment) + input.Environment, input.templateID, input.templateEnvironment, err = decodeTemplateEnvironment(request.Environment) if err != nil { return input, err } diff --git a/services/agents-api/internal/api/session_template.go b/services/agents-api/internal/api/session_template.go new file mode 100644 index 00000000..f1e1ebe8 --- /dev/null +++ b/services/agents-api/internal/api/session_template.go @@ -0,0 +1,60 @@ +package api + +import ( + "bytes" + "context" + "encoding/json" + + v1 "github.com/MiniMax-AI-Dev/parsar/contracts/agents-api/v1" + "github.com/MiniMax-AI-Dev/parsar/services/agents-api/internal/store" +) + +// Validate the reference and inline shape without looking up mutable resources. +// Caller intent remains available before any reference is resolved. +func decodeTemplateEnvironment(raw json.RawMessage) (*v1.Environment, string, json.RawMessage, error) { + var fields map[string]json.RawMessage + if json.Unmarshal(raw, &fields) != nil { + return nil, "", nil, store.ErrInvalidInput + } + reference, supplied := fields["environment_template_id"] + if !supplied { + environment, err := decodeSessionEnvironment(raw) + return environment, "", nil, err + } + var id, kind string + 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 + } + delete(fields, "environment_template_id") + inline, err := json.Marshal(fields) + if err != nil { + return nil, "", nil, err + } + environment, err := decodeHostedEnvironment(inline) + return environment, id, raw, err +} + +func (h *Handler) resolveTemplateEnvironment(ctx context.Context, tenant string, input *sessionRequest) error { + if input.templateID == "" { + return nil + } + template, err := h.store.GetEnvironmentTemplate(ctx, tenant, input.templateID) + if err != nil { + return err + } + var fields map[string]json.RawMessage + if json.Unmarshal(input.templateEnvironment, &fields) != nil { + return store.ErrInvalidInput + } + if _, supplied := fields["network"]; !supplied { + input.Environment.Network = &v1.EnvironmentNetworkInput{Access: template.NetworkAccess} + } else if template.NetworkAccess == "disabled" && input.Environment.Network.Access != "disabled" { + return store.ErrInvalidInput + } + // Runtime sees only the effective ordinary hosted configuration. + return nil +} diff --git a/services/agents-api/internal/db/queries/environment_templates.sql b/services/agents-api/internal/db/queries/environment_templates.sql new file mode 100644 index 00000000..2a9b69e2 --- /dev/null +++ b/services/agents-api/internal/db/queries/environment_templates.sql @@ -0,0 +1,30 @@ +-- name: CreateEnvironmentTemplate :one +INSERT INTO environment_templates (id, tenant_id, name, network_access) +VALUES ($1, $2, $3, $4) RETURNING *; + +-- name: GetEnvironmentTemplate :one +SELECT * FROM environment_templates WHERE tenant_id = $1 AND id = $2; + +-- name: UpdateEnvironmentTemplate :one +UPDATE environment_templates SET + name = CASE WHEN sqlc.arg(set_name)::boolean THEN sqlc.narg(name)::text ELSE name END, + network_access = CASE WHEN sqlc.arg(set_network)::boolean THEN sqlc.arg(network_access)::text ELSE network_access END, + updated_at = clock_timestamp() +WHERE tenant_id = sqlc.arg(tenant_id) AND id = sqlc.arg(id) +RETURNING *; + +-- name: DeleteEnvironmentTemplate :one +DELETE FROM environment_templates WHERE tenant_id = $1 AND id = $2 RETURNING id; + +-- name: ListEnvironmentTemplates :many +SELECT * FROM environment_templates +WHERE tenant_id = sqlc.arg(tenant_id) + AND (sqlc.narg(after_created)::timestamptz IS NULL + OR (NOT sqlc.arg(ascending)::boolean AND (created_at, id) < (sqlc.narg(after_created)::timestamptz, sqlc.arg(after_id)::uuid)) + OR (sqlc.arg(ascending)::boolean AND (created_at, id) > (sqlc.narg(after_created)::timestamptz, sqlc.arg(after_id)::uuid))) +ORDER BY + CASE WHEN sqlc.arg(ascending)::boolean THEN created_at END ASC, + CASE WHEN sqlc.arg(ascending)::boolean THEN id END ASC, + CASE WHEN NOT sqlc.arg(ascending)::boolean THEN created_at END DESC, + CASE WHEN NOT sqlc.arg(ascending)::boolean THEN id END DESC +LIMIT sqlc.arg(page_limit); diff --git a/services/agents-api/internal/db/sqlc/environment_templates.sql.go b/services/agents-api/internal/db/sqlc/environment_templates.sql.go new file mode 100644 index 00000000..e6865a08 --- /dev/null +++ b/services/agents-api/internal/db/sqlc/environment_templates.sql.go @@ -0,0 +1,176 @@ +// Code generated by sqlc. DO NOT EDIT. +// versions: +// sqlc v1.29.0 +// source: environment_templates.sql + +package sqlc + +import ( + "context" + + "github.com/jackc/pgx/v5/pgtype" +) + +const createEnvironmentTemplate = `-- name: CreateEnvironmentTemplate :one +INSERT INTO environment_templates (id, tenant_id, name, network_access) +VALUES ($1, $2, $3, $4) RETURNING id, tenant_id, name, network_access, created_at, updated_at +` + +type CreateEnvironmentTemplateParams struct { + ID pgtype.UUID `json:"id"` + TenantID pgtype.UUID `json:"tenant_id"` + Name pgtype.Text `json:"name"` + NetworkAccess string `json:"network_access"` +} + +func (q *Queries) CreateEnvironmentTemplate(ctx context.Context, arg CreateEnvironmentTemplateParams) (EnvironmentTemplate, error) { + row := q.db.QueryRow(ctx, createEnvironmentTemplate, + arg.ID, + arg.TenantID, + arg.Name, + arg.NetworkAccess, + ) + var i EnvironmentTemplate + err := row.Scan( + &i.ID, + &i.TenantID, + &i.Name, + &i.NetworkAccess, + &i.CreatedAt, + &i.UpdatedAt, + ) + return i, err +} + +const deleteEnvironmentTemplate = `-- name: DeleteEnvironmentTemplate :one +DELETE FROM environment_templates WHERE tenant_id = $1 AND id = $2 RETURNING id +` + +type DeleteEnvironmentTemplateParams struct { + TenantID pgtype.UUID `json:"tenant_id"` + ID pgtype.UUID `json:"id"` +} + +func (q *Queries) DeleteEnvironmentTemplate(ctx context.Context, arg DeleteEnvironmentTemplateParams) (pgtype.UUID, error) { + row := q.db.QueryRow(ctx, deleteEnvironmentTemplate, arg.TenantID, arg.ID) + var id pgtype.UUID + err := row.Scan(&id) + return id, err +} + +const getEnvironmentTemplate = `-- name: GetEnvironmentTemplate :one +SELECT id, tenant_id, name, network_access, created_at, updated_at FROM environment_templates WHERE tenant_id = $1 AND id = $2 +` + +type GetEnvironmentTemplateParams struct { + TenantID pgtype.UUID `json:"tenant_id"` + ID pgtype.UUID `json:"id"` +} + +func (q *Queries) GetEnvironmentTemplate(ctx context.Context, arg GetEnvironmentTemplateParams) (EnvironmentTemplate, error) { + row := q.db.QueryRow(ctx, getEnvironmentTemplate, arg.TenantID, arg.ID) + var i EnvironmentTemplate + err := row.Scan( + &i.ID, + &i.TenantID, + &i.Name, + &i.NetworkAccess, + &i.CreatedAt, + &i.UpdatedAt, + ) + return i, err +} + +const listEnvironmentTemplates = `-- name: ListEnvironmentTemplates :many +SELECT id, tenant_id, name, network_access, created_at, updated_at FROM environment_templates +WHERE tenant_id = $1 + AND ($2::timestamptz IS NULL + OR (NOT $3::boolean AND (created_at, id) < ($2::timestamptz, $4::uuid)) + OR ($3::boolean AND (created_at, id) > ($2::timestamptz, $4::uuid))) +ORDER BY + CASE WHEN $3::boolean THEN created_at END ASC, + CASE WHEN $3::boolean THEN id END ASC, + CASE WHEN NOT $3::boolean THEN created_at END DESC, + CASE WHEN NOT $3::boolean THEN id END DESC +LIMIT $5 +` + +type ListEnvironmentTemplatesParams struct { + TenantID pgtype.UUID `json:"tenant_id"` + AfterCreated pgtype.Timestamptz `json:"after_created"` + Ascending bool `json:"ascending"` + AfterID pgtype.UUID `json:"after_id"` + PageLimit int32 `json:"page_limit"` +} + +func (q *Queries) ListEnvironmentTemplates(ctx context.Context, arg ListEnvironmentTemplatesParams) ([]EnvironmentTemplate, error) { + rows, err := q.db.Query(ctx, listEnvironmentTemplates, + arg.TenantID, + arg.AfterCreated, + arg.Ascending, + arg.AfterID, + arg.PageLimit, + ) + if err != nil { + return nil, err + } + defer rows.Close() + items := []EnvironmentTemplate{} + for rows.Next() { + var i EnvironmentTemplate + if err := rows.Scan( + &i.ID, + &i.TenantID, + &i.Name, + &i.NetworkAccess, + &i.CreatedAt, + &i.UpdatedAt, + ); err != nil { + return nil, err + } + items = append(items, i) + } + if err := rows.Err(); err != nil { + return nil, err + } + return items, nil +} + +const updateEnvironmentTemplate = `-- name: UpdateEnvironmentTemplate :one +UPDATE environment_templates SET + name = CASE WHEN $1::boolean THEN $2::text ELSE name END, + network_access = CASE WHEN $3::boolean THEN $4::text ELSE network_access END, + updated_at = clock_timestamp() +WHERE tenant_id = $5 AND id = $6 +RETURNING id, tenant_id, name, network_access, created_at, updated_at +` + +type UpdateEnvironmentTemplateParams struct { + SetName bool `json:"set_name"` + Name pgtype.Text `json:"name"` + SetNetwork bool `json:"set_network"` + NetworkAccess string `json:"network_access"` + TenantID pgtype.UUID `json:"tenant_id"` + ID pgtype.UUID `json:"id"` +} + +func (q *Queries) UpdateEnvironmentTemplate(ctx context.Context, arg UpdateEnvironmentTemplateParams) (EnvironmentTemplate, error) { + row := q.db.QueryRow(ctx, updateEnvironmentTemplate, + arg.SetName, + arg.Name, + arg.SetNetwork, + arg.NetworkAccess, + arg.TenantID, + arg.ID, + ) + var i EnvironmentTemplate + err := row.Scan( + &i.ID, + &i.TenantID, + &i.Name, + &i.NetworkAccess, + &i.CreatedAt, + &i.UpdatedAt, + ) + return i, err +} diff --git a/services/agents-api/internal/db/sqlc/models.go b/services/agents-api/internal/db/sqlc/models.go index c812c18e..8418d636 100644 --- a/services/agents-api/internal/db/sqlc/models.go +++ b/services/agents-api/internal/db/sqlc/models.go @@ -75,6 +75,15 @@ type EnvironmentInputReservation struct { IsInitial bool `json:"is_initial"` } +type EnvironmentTemplate struct { + ID pgtype.UUID `json:"id"` + TenantID pgtype.UUID `json:"tenant_id"` + Name pgtype.Text `json:"name"` + NetworkAccess string `json:"network_access"` + CreatedAt pgtype.Timestamptz `json:"created_at"` + UpdatedAt pgtype.Timestamptz `json:"updated_at"` +} + type ExecutionProjectScope struct { TenantID pgtype.UUID `json:"tenant_id"` OrganizationID string `json:"organization_id"` diff --git a/services/agents-api/internal/store/environment_templates.go b/services/agents-api/internal/store/environment_templates.go new file mode 100644 index 00000000..d3d8f1f6 --- /dev/null +++ b/services/agents-api/internal/store/environment_templates.go @@ -0,0 +1,165 @@ +package store + +import ( + "context" + "errors" + "time" + "unicode/utf8" + + "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" +) + +// EnvironmentTemplate is configuration ownership, independent of provider images. +// This qualified profile cannot persist confidential installation inputs. +type EnvironmentTemplate struct { + ID string + Name *string + NetworkAccess string + CreatedAt time.Time + UpdatedAt time.Time +} + +type EnvironmentTemplateInput struct { + Name *string + SetName bool + NetworkAccess string + SetNetwork bool +} + +func (in EnvironmentTemplateInput) valid() bool { + return (in.Name == nil || (utf8.ValidString(*in.Name) && utf8.RuneCountInString(*in.Name) >= 1 && utf8.RuneCountInString(*in.Name) <= 256)) && + (!in.SetNetwork || in.NetworkAccess == "enabled" || in.NetworkAccess == "disabled") +} + +func templateFromRow(row sqlc.EnvironmentTemplate, err error) (EnvironmentTemplate, error) { + if errors.Is(err, pgx.ErrNoRows) { + return EnvironmentTemplate{}, ErrNotFound + } + if err != nil { + return EnvironmentTemplate{}, err + } + result := EnvironmentTemplate{ID: uuid.UUID(row.ID.Bytes).String(), NetworkAccess: row.NetworkAccess, CreatedAt: row.CreatedAt.Time, UpdatedAt: row.UpdatedAt.Time} + if row.Name.Valid { + result.Name = &row.Name.String + } + return result, nil +} + +func (s *Store) CreateEnvironmentTemplate(ctx context.Context, tenantID string, in EnvironmentTemplateInput) (EnvironmentTemplate, error) { + if !in.SetNetwork { + in.NetworkAccess = "enabled" + in.SetNetwork = true + } + if !in.valid() { + return EnvironmentTemplate{}, ErrInvalidInput + } + tenant, err := parseID(tenantID) + if err != nil { + return EnvironmentTemplate{}, err + } + var name pgtype.Text + if in.Name != nil { + name = pgtype.Text{String: *in.Name, Valid: true} + } + row, err := s.queries.CreateEnvironmentTemplate(ctx, sqlc.CreateEnvironmentTemplateParams{ID: pgtype.UUID{Bytes: uuid.New(), Valid: true}, TenantID: tenant, Name: name, NetworkAccess: in.NetworkAccess}) + return templateFromRow(row, err) +} + +func (s *Store) GetEnvironmentTemplate(ctx context.Context, tenantID, templateID string) (EnvironmentTemplate, error) { + tenant, err := parseID(tenantID) + if err != nil { + return EnvironmentTemplate{}, err + } + id, err := parseID(templateID) + if err != nil { + return EnvironmentTemplate{}, ErrNotFound + } + row, err := s.queries.GetEnvironmentTemplate(ctx, sqlc.GetEnvironmentTemplateParams{TenantID: tenant, ID: id}) + return templateFromRow(row, err) +} + +// Each supplied field replaces atomically, preserving concurrent unrelated updates. +func (s *Store) UpdateEnvironmentTemplate(ctx context.Context, tenantID, templateID string, in EnvironmentTemplateInput) (EnvironmentTemplate, error) { + if !in.valid() { + return EnvironmentTemplate{}, ErrInvalidInput + } + tenant, err := parseID(tenantID) + if err != nil { + return EnvironmentTemplate{}, err + } + id, err := parseID(templateID) + if err != nil { + return EnvironmentTemplate{}, ErrNotFound + } + if !in.SetName && !in.SetNetwork { + return s.GetEnvironmentTemplate(ctx, tenantID, templateID) + } + var name pgtype.Text + if in.Name != nil { + name = pgtype.Text{String: *in.Name, Valid: true} + } + row, err := s.queries.UpdateEnvironmentTemplate(ctx, sqlc.UpdateEnvironmentTemplateParams{TenantID: tenant, ID: id, Name: name, SetName: in.SetName, NetworkAccess: in.NetworkAccess, SetNetwork: in.SetNetwork}) + return templateFromRow(row, err) +} + +func (s *Store) DeleteEnvironmentTemplate(ctx context.Context, tenantID, templateID string) (string, error) { + tenant, err := parseID(tenantID) + if err != nil { + return "", err + } + id, err := parseID(templateID) + if err != nil { + return "", ErrNotFound + } + result, err := s.queries.DeleteEnvironmentTemplate(ctx, sqlc.DeleteEnvironmentTemplateParams{TenantID: tenant, ID: id}) + if errors.Is(err, pgx.ErrNoRows) { + return "", ErrNotFound + } + if err != nil { + return "", err + } + return uuid.UUID(result.Bytes).String(), nil +} + +type EnvironmentTemplatePage struct { + Templates []EnvironmentTemplate + HasMore bool +} + +func (s *Store) ListEnvironmentTemplates(ctx context.Context, tenantID, cursor string, limit int, ascending bool) (EnvironmentTemplatePage, error) { + tenant, err := parseID(tenantID) + if err != nil { + return EnvironmentTemplatePage{}, err + } + if limit < 1 || limit > 100 { + return EnvironmentTemplatePage{}, ErrInvalidInput + } + params := sqlc.ListEnvironmentTemplatesParams{TenantID: tenant, PageLimit: int32(limit + 1), AfterID: pgtype.UUID{Valid: true}, Ascending: ascending} + if cursor != "" { + after, err := s.GetEnvironmentTemplate(ctx, tenantID, cursor) + if err != nil { + return EnvironmentTemplatePage{}, err + } + params.AfterCreated = pgtype.Timestamptz{Time: after.CreatedAt, Valid: true} + params.AfterID, _ = parseID(after.ID) + } + rows, err := s.queries.ListEnvironmentTemplates(ctx, params) + if err != nil { + return EnvironmentTemplatePage{}, err + } + page := EnvironmentTemplatePage{Templates: make([]EnvironmentTemplate, 0, min(limit, len(rows))), HasMore: len(rows) > limit} + if len(rows) > limit { + rows = rows[:limit] + } + for _, row := range rows { + value, err := templateFromRow(row, nil) + if err != nil { + return EnvironmentTemplatePage{}, err + } + page.Templates = append(page.Templates, value) + } + return page, nil +} diff --git a/services/agents-api/internal/store/environment_templates_test.go b/services/agents-api/internal/store/environment_templates_test.go new file mode 100644 index 00000000..2b0f6562 --- /dev/null +++ b/services/agents-api/internal/store/environment_templates_test.go @@ -0,0 +1,88 @@ +package store + +import ( + "errors" + "github.com/google/uuid" + "sync" + "testing" +) + +func TestEnvironmentTemplatesDurabilityIsolationAndConcurrentUpdates(t *testing.T) { + s, pool := testStore(t) + ctx := t.Context() + tenant, foreign := uuid.NewString(), uuid.NewString() + name := " template " + created, err := s.CreateEnvironmentTemplate(ctx, tenant, EnvironmentTemplateInput{Name: &name}) + if err != nil || created.NetworkAccess != "enabled" || created.Name == nil || *created.Name != name || !created.CreatedAt.Equal(created.UpdatedAt) { + t.Fatal(created, err) + } + for _, target := range []string{foreign} { + if _, err := s.GetEnvironmentTemplate(ctx, target, created.ID); !errors.Is(err, ErrNotFound) { + t.Fatal("foreign read", err) + } + if _, err := s.UpdateEnvironmentTemplate(ctx, target, created.ID, EnvironmentTemplateInput{SetName: true}); !errors.Is(err, ErrNotFound) { + t.Fatal("foreign update", err) + } + if _, err := s.DeleteEnvironmentTemplate(ctx, target, created.ID); !errors.Is(err, ErrNotFound) { + t.Fatal("foreign delete", err) + } + if _, err := s.ListEnvironmentTemplates(ctx, target, created.ID, 1, false); !errors.Is(err, ErrNotFound) { + t.Fatal("foreign cursor", err) + } + } + newName := "changed" + var wg sync.WaitGroup + for _, in := range []EnvironmentTemplateInput{{Name: &newName, SetName: true}, {NetworkAccess: "disabled", SetNetwork: true}} { + wg.Add(1) + go func(in EnvironmentTemplateInput) { + defer wg.Done() + if _, err := s.UpdateEnvironmentTemplate(ctx, tenant, created.ID, in); err != nil { + t.Error(err) + } + }(in) + } + wg.Wait() + got, err := s.GetEnvironmentTemplate(ctx, tenant, created.ID) + if err != nil || got.NetworkAccess != "disabled" || got.Name == nil || *got.Name != newName || !got.CreatedAt.Equal(created.CreatedAt) { + t.Fatal("lost concurrent update", got, err) + } + pool.Close() + s, _ = testStore(t) + got, err = s.GetEnvironmentTemplate(ctx, tenant, created.ID) + if err != nil || got.NetworkAccess != "disabled" { + t.Fatal("lost durable template", got, err) + } + ids := []string{created.ID} + for range 3 { + v, err := s.CreateEnvironmentTemplate(ctx, tenant, EnvironmentTemplateInput{}) + if err != nil { + t.Fatal(err) + } + ids = append(ids, v.ID) + } + first, err := s.ListEnvironmentTemplates(ctx, tenant, "", 2, true) + if err != nil || !first.HasMore || len(first.Templates) != 2 || first.Templates[0].ID != ids[0] { + t.Fatal(first, err) + } + second, err := s.ListEnvironmentTemplates(ctx, tenant, first.Templates[1].ID, 2, true) + if err != nil || second.HasMore || len(second.Templates) != 2 || second.Templates[0].ID != ids[2] { + t.Fatal(second, err) + } + reverse, err := s.ListEnvironmentTemplates(ctx, tenant, "", 1, false) + if err != nil || reverse.Templates[0].ID != ids[3] { + t.Fatal(reverse, err) + } + cleared, err := s.UpdateEnvironmentTemplate(ctx, tenant, created.ID, EnvironmentTemplateInput{SetName: true, SetNetwork: true, NetworkAccess: "enabled"}) + if err != nil || cleared.Name != nil || cleared.NetworkAccess != "enabled" { + t.Fatal(cleared, err) + } + if id, err := s.DeleteEnvironmentTemplate(ctx, tenant, created.ID); err != nil || id != created.ID { + t.Fatal(id, err) + } + if _, err := s.GetEnvironmentTemplate(ctx, tenant, created.ID); !errors.Is(err, ErrNotFound) { + t.Fatal(err) + } + if _, err := s.CreateEnvironmentTemplate(ctx, tenant, EnvironmentTemplateInput{SetNetwork: true, NetworkAccess: "restricted"}); !errors.Is(err, ErrInvalidInput) { + t.Fatal(err) + } +} diff --git a/services/agents-api/migrations/000042_environment_templates.sql b/services/agents-api/migrations/000042_environment_templates.sql new file mode 100644 index 00000000..fb772148 --- /dev/null +++ b/services/agents-api/migrations/000042_environment_templates.sql @@ -0,0 +1,13 @@ +-- +goose Up +CREATE TABLE environment_templates ( + id uuid PRIMARY KEY, + tenant_id uuid NOT NULL, + name text CHECK (name IS NULL OR char_length(name) BETWEEN 1 AND 256), + network_access text NOT NULL CHECK (network_access IN ('enabled', 'disabled')), + created_at timestamptz NOT NULL DEFAULT statement_timestamp(), + updated_at timestamptz NOT NULL DEFAULT statement_timestamp() +); +CREATE INDEX environment_templates_tenant_order ON environment_templates (tenant_id, created_at, id); + +-- +goose Down +DROP TABLE environment_templates; diff --git a/services/agents-api/tests/official_e2b_v1.py b/services/agents-api/tests/official_e2b_v1.py index 44982636..28857b78 100644 --- a/services/agents-api/tests/official_e2b_v1.py +++ b/services/agents-api/tests/official_e2b_v1.py @@ -40,6 +40,7 @@ tokens = [secrets.token_hex(32), secrets.token_hex(32)] provider = str(uuid.uuid4()) created = [] +public_templates = [] sources = [] cloud = {} handles = [] @@ -218,10 +219,30 @@ def check(name): assert http.get(base + '/v1/agents/sessions', headers=h).status_code == 401 check('independent_deployment_and_authentication') agent = {'model': config['model'], 'instructions': 'Run the exact requested native shell commands. Never modify supplied scripts or repeat interrupted commands. Preserve conversation history.'} - session = sessions.create(agent=agent, environment={'type': 'openai_hosted'}, extra_headers={'Idempotency-Key': 'idle'}) + environment = {'type': 'openai_hosted'} + if config.get('verify_environment_templates'): + from official_environment_templates import verify_environment_templates, verify_template_session_rejections + enabled_template, disabled_template = verify_environment_templates(client, foreign, http) + public_templates.extend([enabled_template, disabled_template]) + verify_template_session_rejections(client, foreign, http, agent, enabled_template, disabled_template) + environment['environment_template_id'] = enabled_template + check('template_sdk_http_crud_pagination_redaction_and_tenant_isolation') + session = sessions.create(agent=agent, environment=environment, extra_headers={'Idempotency-Key': 'idle'}) created.append(session.id) eid = session.environment.id - assert sessions.create(agent=agent, environment={'type': 'openai_hosted'}, extra_headers={'Idempotency-Key': 'idle'}).id == session.id + assert sessions.create(agent=agent, environment=environment, extra_headers={'Idempotency-Key': 'idle'}).id == session.id + if public_templates: + api = client.beta.agents.environments.templates + api.update(enabled_template, network={'access': 'disabled'}) + assert sessions.retrieve(session.id).environment.network.access == 'enabled' + assert sessions.create(agent=agent, environment=environment, extra_headers={'Idempotency-Key': 'idle'}).id == session.id + api.delete(enabled_template) + public_templates.remove(enabled_template) + assert sessions.create(agent=agent, environment=environment, extra_headers={'Idempotency-Key': 'idle'}).id == session.id + changed = {**environment, 'network': {'access': 'enabled'}} + conflict = http.post(base + '/v1/agents/sessions', headers={**headers, 'Idempotency-Key': 'idle'}, json={'agent': agent, 'environment': changed}) + assert conflict.status_code == 409 + check('template_snapshot_and_creation_retry_survive_update_and_delete') connected(eid) vm = runtime(eid) expected_init = Path(__file__).parents[1] / 'deploy/e2b/init.py' @@ -326,7 +347,16 @@ def stable(): assert memory in ''.join(part.text for part in answers[-1].content if part.type == 'output_text') assert native_id(session.id) == identity check('same_native_history_continues_after_core_and_runtime_recovery') - disabled = sessions.create(agent=agent, environment={'type': 'openai_hosted', 'network': {'access': 'disabled'}}) + disabled_environment = {'type': 'openai_hosted', 'network': {'access': 'disabled'}} + if public_templates: + disabled_environment = {'type': 'openai_hosted', 'environment_template_id': disabled_template} + disabled = sessions.create(agent=agent, environment=disabled_environment) + assert disabled.environment.id != eid + assert disabled.environment.network.access == 'disabled' + if public_templates: + api.delete(disabled_template) + public_templates.remove(disabled_template) + check('template_inheritance_and_distinct_environment_ownership') created.append(disabled.id) connected(disabled.environment.id) restricted = runtime(disabled.environment.id) @@ -342,6 +372,11 @@ def stable(): finally: cleanup_errors = [] if process is not None and process.poll() is None: + for template_id in public_templates: + try: + client.beta.agents.environments.templates.delete(template_id) + except Exception: + cleanup_errors.append('template_delete_failed') for source_id in sources: try: client.files.delete(source_id) diff --git a/services/agents-api/tests/official_environment_templates.py b/services/agents-api/tests/official_environment_templates.py new file mode 100644 index 00000000..09fd3bae --- /dev/null +++ b/services/agents-api/tests/official_environment_templates.py @@ -0,0 +1,106 @@ +"""Pinned SDK and raw HTTP acceptance for the basic Environment Template profile. + +Run against the same independently deployed service used for actual native/model +acceptance. This module creates no fake Provider, Runtime or model endpoint. +""" +import uuid + + +def verify_environment_templates(client, foreign, http): + api = client.beta.agents.environments.templates + other = foreign.beta.agents.environments.templates + base = str(client.base_url).rstrip('/') + '/agents/environments/templates' + headers = {'Authorization': 'Bearer ' + client.api_key, 'OpenAI-Beta': 'agents=v1'} + foreign_headers = {**headers, 'Authorization': 'Bearer ' + foreign.api_key} + owned = [] + retained = [] + try: + assert http.get(base, headers={'OpenAI-Beta': 'agents=v1'}).status_code == 401 + assert http.get(base, headers={'Authorization': 'Bearer ' + client.api_key}).status_code == 400 + assert api.list().data == [] + for values in ({}, {'packages': {}}, {'packages': {'npm': None}}, {'name': None, 'network': None, 'env': None, 'setup_commands': None}, + {'name': ' preserved ', 'network': {'access': 'disabled'}, 'files': [], + 'plugins': [], 'skills': [], 'packages': {'python': [], 'npm': None}}): + response = api.with_raw_response.create(**values) + body, template = response.http_response.json(), response.parse() + owned.append(template.id) + assert set(body) == {'id', 'object', 'created_at', 'updated_at', 'name', 'network', + 'packages', 'capability_directories', 'files', 'plugins', 'skills'} + assert body['object'] == 'agent.environment.template' + assert body['name'] == values.get('name') + assert body['network'] == {'access': (values.get('network') or {}).get('access', 'enabled'), + 'allowed_domains': []} + assert body['packages'] == {'python': [], 'npm': [], 'system': []} + for field in ['capability_directories', 'files', 'plugins', 'skills']: + assert body[field] == [] + assert body['created_at'] == body['updated_at'] + assert api.retrieve(template.id) == template + assert http.get(base + '/' + template.id, headers=headers).json() == body + before = api.retrieve(owned[-1]) + updated = api.update(owned[-1], name='changed') + assert updated.name == 'changed' and updated.network == before.network + assert updated.created_at == before.created_at + updated = api.update(owned[-1], name=None, network=None) + assert updated.name is None and updated.network.access == 'enabled' + assert api.update(owned[-1]).to_dict() == updated.to_dict() + assert [v.id for v in api.list(order='asc', limit=1)] == owned + assert [v.id for v in api.list(order='desc', limit=2)] == owned[::-1] + page = api.list(order='asc', limit=2) + assert page.has_more and page.first_id == owned[0] and page.last_id == owned[1] + assert [v.id for v in api.list(order='asc', after=page.last_id).data] == owned[2:] + foreign_template = other.create() + try: + for method, path, body in [('GET', '/' + owned[0], None), + ('POST', '/' + owned[0], {'name': 'forbidden'}), + ('DELETE', '/' + owned[0], None), + ('GET', '?after=' + owned[0], None)]: + assert http.request(method, base + path, headers=foreign_headers, json=body).status_code == 404 + assert [v.id for v in other.list()] == [foreign_template.id] + finally: + other.delete(foreign_template.id) + for query in ['limit=0', 'limit=101', 'limit=bad', 'order=wrong', 'limit=1&limit=2', 'unknown=1']: + assert http.get(base + '?' + query, headers=headers).status_code == 400 + canary = 'template-private-' + uuid.uuid4().hex + for body in [{'env': {'TOKEN': canary}}, {'setup_commands': [{'command': canary}]}, + {'files': [{'type': 'inline', 'path': '/workspace/a', 'data': canary}]}, + {'packages': {'python': [canary]}}, {'skills': [{'type': 'inline', 'data': canary}]}, + {'plugins': [{'type': 'inline', 'data': canary}]}, + {'capability_directories': ['/workspace']}, + {'network': {'access': 'restricted', 'allowed_domains': ['example.com']}}, + {'name': ''}, {'unknown': canary}]: + for path in ['', '/' + owned[0]]: + response = http.post(base + path, headers=headers, json=body) + assert response.status_code == 400 and canary not in response.text + assert [v.id for v in api.list(order='asc')] == owned + assert canary not in http.get(base, headers=headers).text + deleted = api.delete(owned.pop()) + assert deleted.deleted and deleted.object == 'agent.environment.template.deleted' + for method in ['GET', 'DELETE']: + assert http.request(method, base + '/' + deleted.id, headers=headers).status_code == 404 + enabled = api.create(name='real execution', network={'access': 'enabled'}) + retained.append(enabled.id) + disabled = api.create(name='real network isolation', network={'access': 'disabled'}) + retained.append(disabled.id) + return enabled.id, disabled.id + except BaseException: + for template_id in retained: + api.delete(template_id) + raise + finally: + for template_id in owned: + api.delete(template_id) + + +def verify_template_session_rejections(client, foreign, http, agent, enabled, disabled): + base = str(client.base_url).rstrip('/') + '/agents/sessions' + 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), + ]: + response = http.post(base, headers={**headers, 'Authorization': 'Bearer ' + token}, + json={'agent': agent, 'environment': {'type': 'openai_hosted', + 'environment_template_id': reference, **override}}) + assert response.status_code == status, response.text