diff --git a/contracts/agents-api/sessions-events.md b/contracts/agents-api/sessions-events.md index 5b43214e9..88ebb0f37 100644 --- a/contracts/agents-api/sessions-events.md +++ b/contracts/agents-api/sessions-events.md @@ -61,7 +61,7 @@ On `openai_hosted` and `self_hosted` Sessions, messages sent while a Turn runs j ### Input errors -Checks run in this order: request validation, Session lookup, retry lookup, the Environment file-write gate for batches with a message, the pending-input gate, then each event in batch order. A rejected batch writes nothing and leaves any pending action unchanged. Every 409 has type `conflict_error` ([error envelope](./wire-semantics.md)). +Checks run in this order: [request validation](./wire-semantics.md#configuration-validation), whose paths start with `events[i]`, Session lookup, retry lookup, the Environment file-write gate for batches with a message, the pending-input gate, then each event in batch order. A rejected batch writes nothing and leaves any pending action unchanged. Every 409 has type `conflict_error` ([error envelope](./wire-semantics.md)). | Case | Status and code | Message | | --- | --- | --- | diff --git a/contracts/agents-api/vaults.md b/contracts/agents-api/vaults.md index 30b4c03d0..bd43c2cfa 100644 --- a/contracts/agents-api/vaults.md +++ b/contracts/agents-api/vaults.md @@ -58,7 +58,7 @@ A malformed, missing or foreign ID returns 404 `not_found_error`, and so does a | Field | Rules | | --- | --- | -| `name` | Optional. Omitted stays `null`; explicit `null` is rejected. A string is trimmed and must then hold 1–256 UTF-8 bytes | +| `name` | Optional. Omitted stays `null`; explicit `null` is rejected. A string is trimmed and must then hold 1–256 UTF-8 bytes (param `name`) | | `metadata` | Omitted or `null` becomes `{}`. Values must be strings; another type returns 400 `invalid_request_error` with param `metadata.`. The encoded object is limited to 64 KiB, with no pair-count or length limits | A Vault reads as `id`, `object: "vault"`, `created_at`, `name` and `metadata`. There is no update route. @@ -67,7 +67,7 @@ Deleting a Vault removes it and all its Credentials in one transaction, whatever ## Credentials -Creation takes a required `name` (trimmed, 1–256 UTF-8 bytes) and an `auth` object whose `type` is `static_bearer` or `mcp_oauth`. `mcp_server_url` must be an absolute HTTPS URL without userinfo or fragment; Core keeps it byte for byte, including any query, and performs no DNS or HTTP request. +Creation takes a required `name` (trimmed, 1–256 UTF-8 bytes) and an `auth` object whose `type` is `static_bearer` or `mcp_oauth`. `mcp_server_url` must be an absolute HTTPS URL without userinfo or fragment; Core keeps it byte for byte, including any query, and performs no DNS or HTTP request. Field errors name the field as param, such as `auth.type`, `auth.access_token` or `auth.mcp_server_url`, and an unknown or missing member has the official `Unknown parameter` or `Missing required parameter` message ([request validation](./wire-semantics.md#configuration-validation)). A Credential reads as `id`, `vault_id`, `name`, `object: "vault.credential"`, `created_at`, `updated_at` and `auth`. `auth` holds `type` and `mcp_server_url`; an OAuth Credential adds `expires_at` and `refresh` with `client_id`, `token_endpoint`, `token_endpoint_auth.type`, `resource` and `scope`. Reads and lists need no storage key. diff --git a/contracts/agents-api/wire-semantics.md b/contracts/agents-api/wire-semantics.md index 182915c32..0fbc8d00c 100644 --- a/contracts/agents-api/wire-semantics.md +++ b/contracts/agents-api/wire-semantics.md @@ -176,6 +176,8 @@ Agent create and update bodies and the inline `agent` of Session create are chec | Function `parameters` with a string root `type` other than `object` | null | `Invalid schema for function '': schema must be a JSON Schema of 'type: "object"', got 'type: ""'.` | | `text.format` JSON schema with a string root `type` other than `object` | null | `agent.text.format.schema must have top-level type "object"; got ""`, also on Agent requests | +Session create and update, Vault create, Credential create and update, Environment Template create and update and Session events are checked the same way against their pinned bodies, before their own decoding. The param is the JSON path, such as `metadata`, `input[0].role`, `input[0].content[0].type`, `events[0].input[0].role`, `auth.type`, `auth.access_token`, `env.KEY` or `name`. `input` and a function result `output` take a string or an array, and any other type reports `expected a string or an array`. The Session `agent` and `environment` and the Template `network` keep their own parsers and are first checked to be objects. Rules the pinned schema does not express report the field as param with a message that names the rule: Vault and Credential `name` (1–256 UTF-8 bytes after trimming), Template `name` (1–256 characters), a nonempty `auth.token` and `auth.access_token`, an HTTPS `auth.mcp_server_url` and `auth.refresh.token_endpoint`, an RFC 3339 `auth.expires_at`, environment variable names (`env.`) and a Credential update with nothing to change (param `auth`). + Within one object Core reports a union's `type` first, then unknown members, then member values in document order, then missing members in the pinned schema's order; tools before `text`, and the whole object before the duplicate and schema-root checks. Schemas without a string root `type` are not checked. Function and output schemas, `request_metadata` and `x_agents_core` keep their own parsers. Update bodies and the inline Session agent are validated before the Agent lookup, so owned, foreign, missing and malformed Agent IDs give the same response. Core saves values the pinned shapes allow even when the selected Harness cannot run them: function names of any length and the reasoning, service tier, web search and programmatic tool calling values a Harness does not declare. diff --git a/contracts/agents-api/zh/sessions-events.md b/contracts/agents-api/zh/sessions-events.md index 099ea2ed7..618f0b60b 100644 --- a/contracts/agents-api/zh/sessions-events.md +++ b/contracts/agents-api/zh/sessions-events.md @@ -1,7 +1,7 @@ --- title: "Session、事件和历史" source: contracts/agents-api/sessions-events.md -source_hash: e8dfd2f21bdcf16832c60fb9fac2df42bc96553d7f1294dc2742c3eb455772a1 +source_hash: 39fe5deab865e778bd8d3fa50a5d66707e10df4652b868e47f17126d962291eb --- 本契约涵盖 Session 内部发生的事情:发送输入、实时事件流,以及读取 Turn、Item 和使用量的持久化历史。Session 资源本身(创建配置、重试标识、更新、列出和删除)见 [API 行为](wire-semantics.md)。消息和函数结果内容见[消息内容](message-content.md)。[Agents API 指南](../../../docs/zh/api/public-agent-api.md)展示了使用 SDK 和 HTTP 的调用方式。 @@ -63,7 +63,7 @@ Turn 失败后 Session 仍可使用:新输入会启动一个新 Turn。后来 ### 输入错误 {#input-errors} -检查按以下顺序执行:请求验证、Session 查找、重试查找、针对包含消息的批次的 Environment 文件写入门控、待处理输入门控,最后按批次顺序检查每个事件。被拒绝的批次不会写入任何内容,也不会改变任何待处理操作。所有 409 响应的 `type` 都是 `conflict_error`([错误封装](wire-semantics.md))。 +检查按以下顺序执行:[请求验证](wire-semantics.md#configuration-validation)(路径以 `events[i]` 开头)、Session 查找、重试查找、针对包含消息的批次的 Environment 文件写入门控、待处理输入门控,最后按批次顺序检查每个事件。被拒绝的批次不会写入任何内容,也不会改变任何待处理操作。所有 409 响应的 `type` 都是 `conflict_error`([错误封装](wire-semantics.md))。 | 情况 | 状态和代码 | 消息 | | --- | --- | --- | diff --git a/contracts/agents-api/zh/vaults.md b/contracts/agents-api/zh/vaults.md index 4f598f528..9aee59336 100644 --- a/contracts/agents-api/zh/vaults.md +++ b/contracts/agents-api/zh/vaults.md @@ -1,7 +1,7 @@ --- title: "Vault 与 Credential" source: contracts/agents-api/vaults.md -source_hash: b27509949dca26f064cda765ea97229e3325dee3ff650b17fa332a756409cf34 +source_hash: 1a57ba70173f8a000b6b0cb74eb7fa6a6c688b4f71d7e617b4fdfb60e6d6fef5 --- Vault 是项目所有的 Credential 容器。Credential 保存一个 HTTPS MCP server 的秘密:`static_bearer` token 或 `mcp_oauth` grant。Session 在 `vault_ids` 中关联 Vault;Core 在创建 Session 时为每个 HTTP MCP server 选择一个 Credential,只在分派工作时将解密 token 交给 Runtime。秘密只能写入:任何读取都不返回 token、refresh token、client secret 或密文。 @@ -60,7 +60,7 @@ MCP tool 可以通过 `credential_id` 指定 Credential;未指定时,Core | 字段 | 规则 | | --- | --- | -| `name` | 可选;省略保持 `null`,显式 `null` 被拒绝。字符串去除首尾空白后必须为 1–256 UTF-8 字节 | +| `name` | 可选;省略保持 `null`,显式 `null` 被拒绝。字符串去除首尾空白后必须为 1–256 UTF-8 字节(param 为 `name`) | | `metadata` | 省略或 `null` 转为 `{}`。值必须为字符串;其他类型返回 400 `invalid_request_error`,param 为 `metadata.`。编码后对象上限 64 KiB,无键值对数量或长度限制 | Vault 读取包含 `id`、`object: "vault"`、`created_at`、`name` 和 `metadata`。没有更新路由。 @@ -69,7 +69,7 @@ Vault 读取包含 `id`、`object: "vault"`、`created_at`、`name` 和 `metadat ## Credential {#credentials} -创建需要 `name`(去除首尾空白后 1–256 UTF-8 字节)和 `auth` 对象,`type` 为 `static_bearer` 或 `mcp_oauth`。`mcp_server_url` 必须是无 userinfo、无 fragment 的绝对 HTTPS URL;Core 逐字节保留,包括 query,不执行 DNS 或 HTTP 请求。 +创建需要 `name`(去除首尾空白后 1–256 UTF-8 字节)和 `auth` 对象,`type` 为 `static_bearer` 或 `mcp_oauth`。`mcp_server_url` 必须是无 userinfo、无 fragment 的绝对 HTTPS URL;Core 逐字节保留,包括 query,不执行 DNS 或 HTTP 请求。字段错误以该字段作为 param,例如 `auth.type`、`auth.access_token` 或 `auth.mcp_server_url`;未知成员或缺失成员使用官方的 `Unknown parameter` 或 `Missing required parameter` 消息([请求验证](wire-semantics.md#configuration-validation))。 Credential 读取包含 `id`、`vault_id`、`name`、`object: "vault.credential"`、`created_at`、`updated_at` 和 `auth`。`auth` 包含 `type`、`mcp_server_url`;OAuth Credential 还包含 `expires_at` 和 `refresh`,后者包含 `client_id`、`token_endpoint`、`token_endpoint_auth.type`、`resource` 和 `scope`。读取和列表无需 storage key。 diff --git a/contracts/agents-api/zh/wire-semantics.md b/contracts/agents-api/zh/wire-semantics.md index 978886b12..8aeb6c232 100644 --- a/contracts/agents-api/zh/wire-semantics.md +++ b/contracts/agents-api/zh/wire-semantics.md @@ -1,7 +1,7 @@ --- title: "API 行为" source: contracts/agents-api/wire-semantics.md -source_hash: 851e09252847f11040524f329419587687e81d361e4e92c0a3e912d2fd887d45 +source_hash: 6f57c50e736d1837b3609209812438063cc96c24c145ffe9302a93d5f9c7746f --- 已锁定版本的 OpenAI Python SDK([upstream.json](https://github.com/MiniMax-AI/OpenAgentCore/blob/main/contracts/agents-api/upstream.json))定义了 `/v1` 路由、字段和类型。本页面说明这些类型未作规定之处 Core 的行为,例如状态码、错误字段、默认值和列表边界,以及 Core 与官方服务存在差异的地方。[OpenAI 兼容性](index.md) 列出了这些差异和尚存缺口;[Sessions, events and history](sessions-events.md)、[message content](message-content.md)、[Vaults](vaults.md)、[source Files and Skills](source-files.md) 和 [Environment files and Artifacts](environment-files.md) 分别负责各自资源的规则。 @@ -178,6 +178,8 @@ Agent 创建和更新正文以及 Session 创建中的内联 `agent`,会在其 | Function `parameters` 的字符串根 `type` 不是 `object` | null | `Invalid schema for function '': schema must be a JSON Schema of 'type: "object"', got 'type: ""'.` | | `text.format` JSON schema 的字符串根 `type` 不是 `object` | null | `agent.text.format.schema must have top-level type "object"; got ""`,Agent 请求上也会返回此消息 | +Session 创建和更新、Vault 创建、Credential 创建和更新、Environment Template 创建和更新以及 Session events,会在各自解码之前,按同样方式根据已锁定的正文形状进行检查。param 是 JSON 路径,例如 `metadata`、`input[0].role`、`input[0].content[0].type`、`events[0].input[0].role`、`auth.type`、`auth.access_token`、`env.KEY` 或 `name`。`input` 和函数结果的 `output` 接受字符串或数组,其他类型会报告 `expected a string or an array`。Session 的 `agent` 和 `environment` 以及 Template 的 `network` 使用各自的解析器,并先检查其是否为对象。已锁定 schema 未表达的规则以字段作为 param,并给出说明该规则的消息:Vault 和 Credential 的 `name`(去除首尾空白后 1–256 UTF-8 字节)、Template 的 `name`(1–256 个字符)、非空的 `auth.token` 和 `auth.access_token`、HTTPS 的 `auth.mcp_server_url` 和 `auth.refresh.token_endpoint`、RFC 3339 的 `auth.expires_at`、环境变量名(`env.`),以及没有任何可更改内容的 Credential 更新(param 为 `auth`)。 + 在同一个对象内,Core 会先报告联合类型的 `type`,然后报告未知成员,再按文档顺序报告成员值,最后按固定 schema 的顺序报告缺失成员;先检查 tools,再检查 `text`,并在重复项和 schema 根检查之前检查整个对象。没有字符串根 `type` 的 schema 不会被检查。Function 和 output schema、`request_metadata` 和 `x_agents_core` 使用各自的解析器。更新正文和 Session 内联 Agent 会在查找 Agent 之前进行验证,因此属于当前租户、属于外部租户、缺失和格式错误的 Agent ID 会得到相同的响应。 即使所选 Harness 无法执行,Core 也会保存已锁定形状允许的值:任意长度的函数名称,以及 Harness 未声明的 reasoning、service tier、Web 搜索和 programmatic tool calling 取值。 diff --git a/scripts/generate-public-api.py b/scripts/generate-public-api.py index a9621d7f6..22aca5703 100644 --- a/scripts/generate-public-api.py +++ b/scripts/generate-public-api.py @@ -13,8 +13,19 @@ SHAPES = ROOT / 'services/core/internal/api/official_shapes.gen.go' CLIENT_TYPES = ROOT / 'packages/agents-client/src/generated/public-api.ts' CORE_CLIENT_TYPES = ROOT / 'packages/agents-client/src/generated/core-api.ts' -# Agent configuration request bodies checked by Core's shape walker. -REQUEST_SHAPES = ('CreateAgentParams', 'UpdateAgentParams', 'SessionAgentConfigParam') +# Request bodies checked by Core's shape walker, with the members whose values +# have their own Core parser and are only checked to be objects here. +REQUEST_SHAPES = { + 'CreateAgentParams': (), 'UpdateAgentParams': (), 'SessionAgentConfigParam': (), + 'CreateAgentSessionParams': ('agent', 'environment'), + 'UpdateAgentSessionParams': (), + 'CreateVaultParams': (), + 'CreateVaultCredentialParams': (), + 'RotateVaultCredentialParams': (), + 'CreateEnvironmentTemplateParams': ('network',), + 'UpdateEnvironmentTemplateParams': ('network',), + 'CreateSessionEventsParams': (), +} HTTP_METHODS = {'get', 'post', 'put', 'patch', 'delete', 'head', 'options'} @@ -192,7 +203,13 @@ def go_shape(document, schema, required=False): kind = value.get('type') if isinstance(kind, list): kind, = set(kind) - {'null'} - if 'oneOf' in value: + if 'oneOf' in value and 'discriminator' not in value: + # A string or an array, such as the Session input. + string, array = value['oneOf'] + if (string['type'], array['type']) != ('string', 'array'): + raise ValueError(f'Unsupported union: {value["oneOf"]}') + fields = ['kind: stringOrArrayValue', 'items: &' + go_shape(document, array['items'])] + elif 'oneOf' in value: if value['discriminator']['propertyName'] != 'type': raise ValueError(f'Unsupported union discriminator: {value["discriminator"]}') variants = {} @@ -224,19 +241,24 @@ def go_shape(document, schema, required=False): return 'shape{' + ', '.join(fields) + '}' -def go_members(document, schema, skip=None): +def go_members(document, schema, skip=None, opaque=()): # The walker rejects unknown members, so only closed objects project. if schema.get('additionalProperties') is not False: raise ValueError(f'Unsupported open object: {sorted(schema["properties"])}') required = schema.get('required', []) - return '[]member{\n' + ''.join(f'{{{json.dumps(name)}, {go_shape(document, value, name in required)}}},\n' for name, value in schema['properties'].items() if name != skip) + '}' + def member(name, value): + if name not in opaque: + return go_shape(document, value, name in required) + fields = ['kind: openObject'] + (['required: true'] if name in required else []) + (['nullable: true'] if nullable(document, value) else []) + return 'shape{' + ', '.join(fields) + '}' + return '[]member{\n' + ''.join(f'{{{json.dumps(name)}, {member(name, value)}}},\n' for name, value in schema['properties'].items() if name != skip) + '}' def request_shapes(document, owners): lines = ['// Code generated by scripts/generate-public-api.py; DO NOT EDIT.', 'package api', '', '// Pinned request shapes; x_agents_core is checked by its own parser.', 'var ('] - for name in REQUEST_SHAPES: - members = go_members(document, resolve(document, '#/components/schemas/' + name)) + for name, opaque in REQUEST_SHAPES.items(): + members = go_members(document, resolve(document, '#/components/schemas/' + name), opaque=opaque) if name in owners: members = members[:-1] + '{"x_agents_core", shape{}},\n}' lines.append(f'{name[0].lower() + name[1:]} = shape{{kind: objectValue, members: {members}}}') diff --git a/services/core/internal/api/configuration_validation.go b/services/core/internal/api/configuration_validation.go index f78dcd771..8220ef533 100644 --- a/services/core/internal/api/configuration_validation.go +++ b/services/core/internal/api/configuration_validation.go @@ -9,28 +9,29 @@ import ( "strings" ) -// Agent configuration protocol validation is owned by Core. It covers saved -// Agent create and update bodies and the inline Session agent, and runs before -// the configuration parsers (which keep Core's local limits and codes) and -// before harness admission. It walks the raw JSON along the request shapes that -// scripts/generate-public-api.py projects from the pinned schema -// (official_shapes.gen.go) and reports the first violation as a fieldError with -// the JSON path as param and the official message forms. x_agents_core keeps -// its own parser. +// Request protocol validation is owned by Core. It covers the Agent, Session, +// Vault, Credential, Environment Template and Session event bodies and the +// inline Session agent, and runs before the local parsers (which keep Core's +// limits and codes) and before harness admission. It walks the raw JSON along +// the request shapes that scripts/generate-public-api.py projects from the +// pinned schema (official_shapes.gen.go) and reports the first violation as a +// fieldError with the JSON path as param and the official message forms. +// x_agents_core keeps its own parser. type valueKind uint8 const ( - anyValue valueKind = iota // validated by its own parser - stringValue // str - booleanValue // bool - integerValue // int with an inclusive minimum - enumValue // Literal[...] of strings - arrayValue // Iterable/SequenceNotStr of items - mapValue // Dict[str, object], or Dict[str, str] with items - openObject // an object whose members are validated elsewhere - objectValue // TypedDict members - unionValue // TypedDicts selected by their "type" member + anyValue valueKind = iota // validated by its own parser + stringValue // str + booleanValue // bool + integerValue // int with an inclusive minimum + enumValue // Literal[...] of strings + arrayValue // Iterable/SequenceNotStr of items + mapValue // Dict[str, object], or Dict[str, str] with items + openObject // an object whose members are validated elsewhere + objectValue // TypedDict members + unionValue // TypedDicts selected by their "type" member + stringOrArrayValue // a str, or an Iterable of items ) type shape struct { @@ -51,8 +52,17 @@ type member struct { var requiredString = shape{kind: stringValue, required: true} -// validateSavedAgentBody checks a saved Agent create or update body. Malformed -// and non-object bodies keep the existing whole-body error. +// validateBody checks a request body against its pinned shape. Malformed and +// non-object bodies keep the existing whole-body error. +func validateBody(raw []byte, root shape) error { + if !json.Valid(raw) || jsonValueKind(raw) != "an object" { + return nil + } + return checkValue("", raw, root) +} + +// validateSavedAgentBody checks a saved Agent create or update body, then the +// semantic rules of its configuration. func validateSavedAgentBody(raw []byte, root shape) error { if !json.Valid(raw) || jsonValueKind(raw) != "an object" { return nil @@ -143,9 +153,16 @@ func checkValue(path string, raw json.RawMessage, s shape) error { return invalidType(path, "an integer", got) } return checkMinimum(path, string(bytes.TrimSpace(raw)), s.minimum) - case arrayValue: + case arrayValue, stringOrArrayValue: + expected := "an array" + if s.kind == stringOrArrayValue { + if got == "a string" { + return nil + } + expected = "a string or an array" + } if got != "an array" { - return invalidType(path, "an array", got) + return invalidType(path, expected, got) } var items []json.RawMessage if err := json.Unmarshal(raw, &items); err != nil { @@ -211,15 +228,13 @@ func checkValue(path string, raw json.RawMessage, s shape) error { // which encoding/json would merge, never reach it: the shared body gate // (readJSONObject) rejects them first. func checkMembers(path string, raw json.RawMessage, members []member) error { - keys, fields := orderedMembers(raw) - for _, key := range keys { - if _, known := findMember(members, key); !known { - if !echoableField(key) { - return errUnknownParameter - } - return &fieldError{param: joinPath(path, key), message: fmt.Sprintf("Unknown parameter: '%s'.", joinPath(path, key))} + if key, unknown := unknownMember(raw, members); unknown { + if !echoableField(key) { + return errUnknownParameter } + return &fieldError{param: joinPath(path, key), message: fmt.Sprintf("Unknown parameter: '%s'.", joinPath(path, key))} } + keys, fields := orderedMembers(raw) for _, key := range keys { spec, _ := findMember(members, key) if err := checkValue(joinPath(path, key), fields[key], spec.shape); err != nil { @@ -234,6 +249,31 @@ func checkMembers(path string, raw json.RawMessage, members []member) error { return nil } +// unknownMember returns the first key outside members in document order. It +// stops there and keeps no map, so a body of many unknown keys costs no more +// than its first one. +func unknownMember(raw json.RawMessage, members []member) (string, bool) { + decoder := json.NewDecoder(bytes.NewReader(raw)) + if token, err := decoder.Token(); err != nil || token != json.Delim('{') { + return "", false + } + for decoder.More() { + token, err := decoder.Token() + key, isKey := token.(string) + if err != nil || !isKey { + return "", false + } + if _, known := findMember(members, key); !known { + return key, true + } + var value json.RawMessage + if decoder.Decode(&value) != nil { + return "", false + } + } + return "", false +} + // configurationConflict reports the official semantic rejections of a // well-formed configuration: a non-object schema root, a repeated function name // and a repeated web_search or tool_search declaration. Schemas without a string diff --git a/services/core/internal/api/credentials.go b/services/core/internal/api/credentials.go index 62d0cf985..ec2cb94a4 100644 --- a/services/core/internal/api/credentials.go +++ b/services/core/internal/api/credentials.go @@ -1,6 +1,7 @@ package api import ( + "cmp" "encoding/json" "net/http" @@ -13,42 +14,33 @@ import ( func (h *Handler) createCredential(w http.ResponseWriter, r *http.Request) { vaultID := chi.URLParam(r, "vault_id") raw, ok := readJSONObject(w, r) - if !ok { + if !ok || writeFieldError(w, validateBody(raw, createVaultCredentialParams)) { return } var request struct { - Name *string `json:"name"` + Name string `json:"name"` Auth json.RawMessage `json:"auth"` } - if decodeInputObject(raw, &request, "name", "auth") != nil || request.Name == nil { - writeError(w, http.StatusBadRequest, "invalid_request", "name and supported auth are required.") - return - } - name, err := normalizedVaultName(*request.Name) - if err != nil { - writeError(w, http.StatusBadRequest, "invalid_request", err.Error()) + _ = json.Unmarshal(raw, &request) + name, err := normalizedVaultName(request.Name) + if writeFieldError(w, err) { return } + var auth v1.CredentialAuthInput + _ = json.Unmarshal(request.Auth, &auth) var credential vaults.Credential - switch credentialAuthType(request.Auth) { - case "static_bearer": - var auth v1.CredentialAuthInput - if decodeInputObject(request.Auth, &auth, "type", "mcp_server_url", "token") != nil || auth.Token == nil || *auth.Token == "" || !credentialHTTPSURL(auth.MCPServerURL) { - writeError(w, http.StatusBadRequest, "invalid_request", "static_bearer requires a nonempty string token and an absolute HTTPS mcp_server_url without userinfo or a fragment.") + if auth.Type == "static_bearer" { + if writeFieldError(w, cmp.Or(httpsURL("auth.mcp_server_url", *auth.MCPServerURL), nonEmptyString("auth.token", *auth.Token))) { return } credential, err = h.Vaults.CreateStaticCredential(r.Context(), vaults.CreateStaticCredential{TenantID: tenantID(r), VaultID: vaultID, Name: name, MCPServerURL: *auth.MCPServerURL, Token: *auth.Token}) - case "mcp_oauth": - command, parseErr := oauthCredentialCreate(request.Auth, name) - if parseErr != nil { - writeVaultsError(w, r, parseErr) + } else { + command, fieldErr := oauthCredentialCreate(auth, name) + if writeFieldError(w, fieldErr) { return } command.TenantID, command.VaultID = tenantID(r), vaultID credential, err = h.Vaults.CreateOAuthCredential(r.Context(), command) - default: - writeError(w, http.StatusBadRequest, "invalid_request", "auth requires type static_bearer or mcp_oauth.") - return } if err != nil { writeVaultsError(w, r, err) diff --git a/services/core/internal/api/credentials_oauth.go b/services/core/internal/api/credentials_oauth.go index 4e384f66a..fa2db1407 100644 --- a/services/core/internal/api/credentials_oauth.go +++ b/services/core/internal/api/credentials_oauth.go @@ -1,6 +1,7 @@ package api import ( + "cmp" "encoding/json" "net/url" "regexp" @@ -10,115 +11,78 @@ import ( "github.com/MiniMax-AI/OpenAgentCore/services/core/internal/vaults" ) -func credentialHTTPSURL(value *string) bool { - if value == nil { - return false +// The helpers below check the Core-local rules that the pinned schema does not +// express; each names its field. +func nonEmptyString(path, value string) error { + if value == "" { + return &fieldError{param: path, message: path + " must be a nonempty string."} } - u, err := url.Parse(*value) - return err == nil && u.Scheme == "https" && u.Hostname() != "" && u.User == nil && u.Fragment == "" + return nil } -var credentialTimestamp = regexp.MustCompile(`^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}(?:\.\d+)?(?:Z|[+-](?:[01]\d|2[0-3]):[0-5]\d)$`) - -func credentialExpiry(value *string) bool { - if value == nil { - return true - } - if !credentialTimestamp.MatchString(*value) { - return false +func httpsURL(path, value string) error { + u, err := url.Parse(value) + if err != nil || u.Scheme != "https" || u.Hostname() == "" || u.User != nil || u.Fragment != "" { + return &fieldError{param: path, message: path + " must be an absolute HTTPS URL without userinfo or a fragment."} } - _, err := time.Parse(time.RFC3339Nano, *value) - return err == nil + return nil } -// credentialAuthType reads the exact type member; a case variant is unknown. -func credentialAuthType(raw json.RawMessage) string { - var fields map[string]json.RawMessage - var value string - if json.Unmarshal(raw, &fields) != nil || json.Unmarshal(fields["type"], &value) != nil { - return "" +var credentialTimestamp = regexp.MustCompile(`^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}(?:\.\d+)?(?:Z|[+-](?:[01]\d|2[0-3]):[0-5]\d)$`) + +func credentialExpiry(path string, value *string) error { + if value != nil { + if _, err := time.Parse(time.RFC3339Nano, *value); err != nil || !credentialTimestamp.MatchString(*value) { + return &fieldError{param: path, message: path + " must be an RFC 3339 timestamp."} + } } - return value + return nil } -// oauthCredentialCreate parses an mcp_oauth creation; the caller sets the -// tenant and Vault. -func oauthCredentialCreate(raw json.RawMessage, name string) (vaults.CreateOAuthCredential, error) { - var auth v1.CredentialAuthInput - input := vaults.CreateOAuthCredential{Name: name} - if decodeInputObject(raw, &auth, "type", "mcp_server_url", "access_token", "expires_at", "refresh") != nil || auth.Type != "mcp_oauth" || auth.AccessToken == nil || *auth.AccessToken == "" || !credentialHTTPSURL(auth.MCPServerURL) || !credentialExpiry(auth.ExpiresAt) { - return input, vaults.ErrInvalidInput - } - input.MCPServerURL, input.AccessToken = *auth.MCPServerURL, *auth.AccessToken +// oauthCredentialCreate parses an mcp_oauth creation whose shape is checked; +// the caller sets the tenant and Vault. +func oauthCredentialCreate(auth v1.CredentialAuthInput, name string) (vaults.CreateOAuthCredential, error) { + input := vaults.CreateOAuthCredential{Name: name, MCPServerURL: *auth.MCPServerURL, AccessToken: *auth.AccessToken} input.OAuth.ExpiresAt = auth.ExpiresAt - if r := auth.Refresh; r != nil { - if r.ClientID == nil || r.RefreshToken == nil || !credentialHTTPSURL(r.TokenEndpoint) || r.TokenEndpointAuth == nil { - return input, vaults.ErrInvalidInput - } + err := cmp.Or(httpsURL("auth.mcp_server_url", input.MCPServerURL), nonEmptyString("auth.access_token", input.AccessToken), credentialExpiry("auth.expires_at", auth.ExpiresAt)) + if r := auth.Refresh; err == nil && r != nil { a := r.TokenEndpointAuth - switch a.Type { - case "none": - // Presence of client_secret is invalid even when explicitly null. - var fields struct { - Refresh struct { - Auth json.RawMessage `json:"token_endpoint_auth"` - } `json:"refresh"` - } - if json.Unmarshal(raw, &fields) != nil || decodeInputObject(fields.Refresh.Auth, &struct { - Type string `json:"type"` - }{}, "type") != nil { - return input, vaults.ErrInvalidInput - } - case "client_secret_basic", "client_secret_post": - if a.ClientSecret == nil { - return input, vaults.ErrInvalidInput - } + if a.ClientSecret != nil { input.ClientSecret = *a.ClientSecret - default: - return input, vaults.ErrInvalidInput } input.RefreshToken = *r.RefreshToken input.OAuth.Refresh = &vaults.OAuthRefreshMetadata{ClientID: *r.ClientID, TokenEndpoint: *r.TokenEndpoint, TokenEndpointAuth: a.Type, Resource: r.Resource, Scope: r.Scope} + err = httpsURL("auth.refresh.token_endpoint", *r.TokenEndpoint) } - return input, nil + return input, err } -// oauthCredentialUpdate parses an mcp_oauth patch; the caller sets the -// Credential's identity. -func oauthCredentialUpdate(raw json.RawMessage) (vaults.UpdateOAuthCredential, error) { - var auth v1.CredentialAuthReplacement - var input vaults.UpdateOAuthCredential - if decodeInputObject(raw, &auth, "type", "access_token", "expires_at", "refresh") != nil || auth.Type != "mcp_oauth" { - return input, vaults.ErrInvalidInput - } - input.AccessToken = auth.AccessToken - if input.AccessToken != nil && *input.AccessToken == "" { - return input, vaults.ErrInvalidInput +// oauthCredentialUpdate parses an mcp_oauth patch whose shape is checked; the +// caller sets the Credential's identity. +func oauthCredentialUpdate(auth v1.CredentialAuthReplacement) (vaults.UpdateOAuthCredential, error) { + input := vaults.UpdateOAuthCredential{AccessToken: auth.AccessToken} + var err error + if input.AccessToken != nil { + err = nonEmptyString("auth.access_token", *input.AccessToken) } if len(auth.ExpiresAt) > 0 { input.ExpiresAtSet = true - if json.Unmarshal(auth.ExpiresAt, &input.ExpiresAt) != nil || !credentialExpiry(input.ExpiresAt) { - return input, vaults.ErrInvalidInput - } + _ = json.Unmarshal(auth.ExpiresAt, &input.ExpiresAt) + err = cmp.Or(err, credentialExpiry("auth.expires_at", input.ExpiresAt)) } if r := auth.Refresh; r != nil { input.Refresh = &vaults.OAuthRefreshUpdate{RefreshToken: r.RefreshToken} if len(r.Scope) > 0 { input.Refresh.ScopeSet = true - if json.Unmarshal(r.Scope, &input.Refresh.Scope) != nil { - return input, vaults.ErrInvalidInput - } + _ = json.Unmarshal(r.Scope, &input.Refresh.Scope) } if a := r.TokenEndpointAuth; a != nil { - if a.Type != "client_secret_basic" && a.Type != "client_secret_post" { - return input, vaults.ErrInvalidInput - } input.Refresh.TokenEndpointAuthType, input.Refresh.ClientSecret = a.Type, a.ClientSecret } } - if input.AccessToken == nil && !input.ExpiresAtSet && (input.Refresh == nil || + if err == nil && input.AccessToken == nil && !input.ExpiresAtSet && (input.Refresh == nil || (input.Refresh.RefreshToken == nil && input.Refresh.ClientSecret == nil && !input.Refresh.ScopeSet)) { - return input, vaults.ErrInvalidInput + err = &fieldError{param: "auth", message: "auth requires access_token, expires_at or refresh to update."} } - return input, nil + return input, err } diff --git a/services/core/internal/api/credentials_update.go b/services/core/internal/api/credentials_update.go index a47108a35..75c3b81a1 100644 --- a/services/core/internal/api/credentials_update.go +++ b/services/core/internal/api/credentials_update.go @@ -12,37 +12,28 @@ import ( func (h *Handler) updateCredential(w http.ResponseWriter, r *http.Request) { vaultID, id := chi.URLParam(r, "vault_id"), chi.URLParam(r, "credential_id") raw, ok := readJSONObject(w, r) - if !ok { + if !ok || writeFieldError(w, validateBody(raw, rotateVaultCredentialParams)) { return } var request struct { - Auth json.RawMessage `json:"auth"` - } - if decodeInputObject(raw, &request, "auth") != nil { - writeError(w, http.StatusBadRequest, "invalid_request", "auth with a supported type is required.") - return + Auth v1.CredentialAuthReplacement `json:"auth"` } + _ = json.Unmarshal(raw, &request) + auth := request.Auth var credential vaults.Credential var err error - switch credentialAuthType(request.Auth) { - case "static_bearer": - var auth v1.CredentialAuthReplacement - if decodeInputObject(request.Auth, &auth, "type", "token") != nil || auth.Token == nil || *auth.Token == "" { - writeError(w, http.StatusBadRequest, "invalid_request", "static_bearer auth requires a nonempty string token.") + if auth.Type == "static_bearer" { + if writeFieldError(w, nonEmptyString("auth.token", *auth.Token)) { return } credential, err = h.Vaults.UpdateStaticCredential(r.Context(), vaults.UpdateStaticCredential{TenantID: tenantID(r), VaultID: vaultID, CredentialID: id, Token: *auth.Token}) - case "mcp_oauth": - command, parseErr := oauthCredentialUpdate(request.Auth) - if parseErr != nil { - writeVaultsError(w, r, parseErr) + } else { + command, fieldErr := oauthCredentialUpdate(auth) + if writeFieldError(w, fieldErr) { return } command.TenantID, command.VaultID, command.CredentialID = tenantID(r), vaultID, id credential, err = h.Vaults.UpdateOAuthCredential(r.Context(), command) - default: - writeError(w, http.StatusBadRequest, "invalid_request", "auth requires type static_bearer or mcp_oauth.") - return } if err != nil { writeVaultsError(w, r, err) diff --git a/services/core/internal/api/environment_templates.go b/services/core/internal/api/environment_templates.go index 5104f895f..c0240d057 100644 --- a/services/core/internal/api/environment_templates.go +++ b/services/core/internal/api/environment_templates.go @@ -3,9 +3,13 @@ package api import ( "context" "encoding/json" + "fmt" + "maps" "net/http" + "slices" v1 "github.com/MiniMax-AI/OpenAgentCore/contracts/agents-api/v1" + "github.com/MiniMax-AI/OpenAgentCore/services/core/internal/environmentconfig" "github.com/MiniMax-AI/OpenAgentCore/services/core/internal/environmenttemplates" "github.com/go-chi/chi/v5" ) @@ -33,11 +37,17 @@ func decodeTemplateInput(raw []byte) (environmenttemplates.Input, error) { in := environmenttemplates.Input{} if value, supplied := fields["name"]; supplied { in.SetName = true - if json.Unmarshal(value, &in.Name) != nil || environmenttemplates.ValidateName(in.Name) != nil { + if json.Unmarshal(value, &in.Name) != nil { return in, environmenttemplates.ErrInvalidInput } + if environmenttemplates.ValidateName(in.Name) != nil { + return in, &fieldError{param: "name", message: fmt.Sprintf("name must contain 1 to %d characters.", environmenttemplates.MaxNameLength)} + } } delete(fields, "name") + if err := templateEnvError(fields["env"]); err != nil { + return in, err + } _, in.SetNetwork = fields["network"] _, in.SetFiles = fields["files"] _, in.SetEnv = fields["env"] @@ -70,13 +80,29 @@ func decodeTemplateInput(raw []byte) (environmenttemplates.Input, error) { return in, nil } +// templateEnvError names the first environment variable Core cannot set. +func templateEnvError(raw json.RawMessage) error { + var env map[string]string + _ = json.Unmarshal(raw, &env) + for _, name := range slices.Sorted(maps.Keys(env)) { + if !environmentconfig.ValidEnvironmentEntry(name, env[name]) { + param := "env" + if echoableField(name) { + param += "." + name + } + return &fieldError{param: param, message: "Environment variable names must match [A-Za-z_][A-Za-z0-9_]* and must not be PATH, OPENAI_API_KEY or start with CODEX_ or OAC_; values must not contain U+0000."} + } + } + return nil +} + func templateResponse(t environmenttemplates.Template) v1.EnvironmentTemplate { return v1.EnvironmentTemplate{ID: t.ID, Object: "agent.environment.template", Name: t.Name, CreatedAt: t.CreatedAt.Unix(), UpdatedAt: t.UpdatedAt.Unix(), CapabilityDirectories: append([]string{}, t.CapabilityDirectories...), Network: v1.EnvironmentNetwork{Access: t.NetworkAccess, AllowedDomains: append([]string{}, t.AllowedDomains...)}, Packages: packageMetadata(&t.Packages), Files: templateFileResponse(t.Files), Plugins: pluginResponse(t.Plugins), Skills: skillResponse(t.Skills)} } -func readTemplateInput(w http.ResponseWriter, r *http.Request) (environmenttemplates.Input, bool) { +func readTemplateInput(w http.ResponseWriter, r *http.Request, pinned shape) (environmenttemplates.Input, bool) { raw, ok := readJSONObjectLimit(w, r, 16*1024*1024, "Request exceeds 16 MiB.") - if !ok { + if !ok || writeFieldError(w, validateBody(raw, pinned)) { return environmenttemplates.Input{}, false } in, err := decodeTemplateInput(raw) @@ -91,7 +117,7 @@ func readTemplateInput(w http.ResponseWriter, r *http.Request) (environmenttempl } func (h *Handler) createEnvironmentTemplate(w http.ResponseWriter, r *http.Request) { - in, ok := readTemplateInput(w, r) + in, ok := readTemplateInput(w, r, createEnvironmentTemplateParams) if !ok { return } @@ -113,7 +139,7 @@ func (h *Handler) getEnvironmentTemplate(w http.ResponseWriter, r *http.Request) } func (h *Handler) updateEnvironmentTemplate(w http.ResponseWriter, r *http.Request) { - in, ok := readTemplateInput(w, r) + in, ok := readTemplateInput(w, r, updateEnvironmentTemplateParams) if !ok { return } diff --git a/services/core/internal/api/function_inputs.go b/services/core/internal/api/function_inputs.go index ab62b47e2..5df7fb2c9 100644 --- a/services/core/internal/api/function_inputs.go +++ b/services/core/internal/api/function_inputs.go @@ -24,26 +24,6 @@ func decodeInputEvent(raw json.RawMessage) (decodedInputEvent, error) { switch event.Type { case "agent.session.input.message": fields = append(fields, "input") - var messages struct { - Input []struct { - Type json.RawMessage `json:"type"` - Content json.RawMessage `json:"content"` - } `json:"input"` - } - if json.Unmarshal(raw, &messages) != nil { - return event, sessions.ErrInvalidInput - } - for _, message := range messages.Input { - if len(message.Type) > 0 { - var kind string - if json.Unmarshal(message.Type, &kind) != nil || kind != "message" { - return event, sessions.ErrInvalidInput - } - } - if err := validateInputContent(message.Content); err != nil { - return event, err - } - } case "agent.session.input.cancel": case "agent.session.input.tool_result": fields = append(fields, "call_id", "turn_id", "success", "error", "output") @@ -80,15 +60,6 @@ func functionResultInput(event decodedInputEvent) (sessions.Input, error) { if event.CallID == "" || event.TurnID == "" || event.Success == nil { return sessions.Input{}, sessions.ErrInvalidInput } - if len(event.Error) > 0 && !bytes.Equal(bytes.TrimSpace(event.Error), []byte("null")) { - var message string - if json.Unmarshal(event.Error, &message) != nil { - return sessions.Input{}, sessions.ErrInvalidInput - } - } - if err := validateFunctionOutput(event.Output); err != nil { - return sessions.Input{}, err - } result, err := json.Marshal(struct { Success bool `json:"success"` Error json.RawMessage `json:"error,omitempty"` @@ -100,49 +71,3 @@ func functionResultInput(event decodedInputEvent) (sessions.Input, error) { payload, err := json.Marshal(sessions.FunctionResultInput{TurnID: event.TurnID, CallID: event.CallID, Result: result}) return sessions.Input{Kind: "tool_result", Payload: payload}, err } - -func validateFunctionOutput(raw json.RawMessage) error { - if len(raw) == 0 || bytes.Equal(bytes.TrimSpace(raw), []byte("null")) { - return nil - } - var text string - if json.Unmarshal(raw, &text) == nil { - return nil - } - return validateInputContent(raw) -} - -func validateInputContent(raw json.RawMessage) error { - var parts []json.RawMessage - if json.Unmarshal(raw, &parts) != nil { - return sessions.ErrInvalidInput - } - for _, part := range parts { - var value struct { - Type string `json:"type"` - Text *string `json:"text"` - ImageURL *string `json:"image_url"` - } - if json.Unmarshal(part, &value) != nil { - return sessions.ErrInvalidInput - } - field := "text" - switch value.Type { - case "input_text": - if value.Text == nil { - return sessions.ErrInvalidInput - } - case "input_image": - field = "image_url" - if value.ImageURL == nil { - return sessions.ErrInvalidInput - } - default: - return sessions.ErrInvalidInput - } - if err := decodeInputObject(part, &value, "type", field); err != nil { - return err - } - } - return nil -} diff --git a/services/core/internal/api/handler.go b/services/core/internal/api/handler.go index 8f7d59d4b..2f5f3b303 100644 --- a/services/core/internal/api/handler.go +++ b/services/core/internal/api/handler.go @@ -130,7 +130,7 @@ func (h *Handler) createSession(w http.ResponseWriter, r *http.Request) { if !ok { return } - if writeFieldError(w, metadataTypeError(raw)) { + if writeFieldError(w, validateBody(raw, createAgentSessionParams)) { return } var request decodedSessionRequest diff --git a/services/core/internal/api/inputs.go b/services/core/internal/api/inputs.go index a55265be1..7b20a09bf 100644 --- a/services/core/internal/api/inputs.go +++ b/services/core/internal/api/inputs.go @@ -1,11 +1,9 @@ package api import ( - "bytes" "context" "encoding/json" "net/http" - "reflect" "github.com/MiniMax-AI/OpenAgentCore/internal/agentdaemon/proto" "github.com/MiniMax-AI/OpenAgentCore/services/core/internal/sessions" @@ -22,20 +20,13 @@ type InputAdmission interface { func (h *Handler) createEvents(w http.ResponseWriter, r *http.Request) { raw, ok := readJSONObject(w, r) - if !ok { + if !ok || writeFieldError(w, validateBody(raw, createSessionEventsParams)) { return } var request struct { Events []json.RawMessage `json:"events"` } - decoder := json.NewDecoder(bytes.NewReader(raw)) - decoder.DisallowUnknownFields() - // An unknown member, including a case variant of events, is rejected before - // decoding; see inexactMember. - if inexactMember(raw, reflect.TypeOf(request)) || decoder.Decode(&request) != nil { - writeError(w, http.StatusBadRequest, "invalid_request", "Invalid Session input event request.") - return - } + _ = json.Unmarshal(raw, &request) key := r.Header.Get("Idempotency-Key") if key == "" { key = uuid.NewString() diff --git a/services/core/internal/api/json_members.go b/services/core/internal/api/json_members.go index bac5922cf..22c52c110 100644 --- a/services/core/internal/api/json_members.go +++ b/services/core/internal/api/json_members.go @@ -187,28 +187,3 @@ func jsonFields(t reflect.Type) map[string]reflect.Type { jsonFieldCache.Store(t, fields) return fields } - -// objectMember returns the value of the exact member name of a valid JSON -// object without decoding the other members, or nil. -func objectMember(raw []byte, name string) []byte { - i := skipSpace(raw, 0) - if i == len(raw) || raw[i] != '{' { - return nil - } - var scratch []byte - for i = skipSpace(raw, i+1); raw[i] != '}'; { - end := stringEnd(raw, i) - key := raw[i+1 : end-1] - if bytes.IndexByte(key, '\\') >= 0 { - scratch = appendUnescaped(scratch[:0], key) - key = scratch - } - i = skipSpace(raw, skipSpace(raw, end)+1) - valueEnd := skipValue(raw, i) - if string(key) == name { - return raw[i:valueEnd] - } - i = skipComma(raw, skipSpace(raw, valueEnd)) - } - return nil -} diff --git a/services/core/internal/api/json_members_test.go b/services/core/internal/api/json_members_test.go index ca6c28447..8fd367e8d 100644 --- a/services/core/internal/api/json_members_test.go +++ b/services/core/internal/api/json_members_test.go @@ -60,14 +60,16 @@ func TestInexactMember(t *testing.T) { func TestCaseVariantMembersAreUnknown(t *testing.T) { h, s := validationHandler(t) session := `{"agent":{"model":"m"},"environment":{"type":"none"},"input":"hi"` - unknownSession := `{"error":{"message":"Request must be a JSON object containing supported fields.","type":"invalid_request_error","code":"invalid_request","param":null}}` + "\n" - for _, body := range []string{ - session + `,"Metadata":{"k":"v"}}`, - session + `,"metadata":{"k":"v"},"Metadata":{"k":"w"}}`, - session + `,"Input":"replaced"}`, - session + `,"STREAM":true}`, + unknown := func(name string) string { + return `{"error":{"message":"Unknown parameter: '` + name + `'.","type":"invalid_request_error","code":"invalid_request_error","param":"` + name + `"}}` + "\n" + } + for body, name := range map[string]string{ + session + `,"Metadata":{"k":"v"}}`: "Metadata", + session + `,"metadata":{"k":"v"},"Metadata":{"k":"w"}}`: "Metadata", + session + `,"Input":"replaced"}`: "Input", + session + `,"STREAM":true}`: "STREAM", } { - if w := bodyGateRequest(h, "/v1/agents/sessions", "application/json", []byte(body)); w.Code != http.StatusBadRequest || w.Body.String() != unknownSession { + if w := bodyGateRequest(h, "/v1/agents/sessions", "application/json", []byte(body)); w.Code != http.StatusBadRequest || w.Body.String() != unknown(name) { t.Errorf("%s: %d %s", body, w.Code, w.Body) } } @@ -82,8 +84,7 @@ func TestCaseVariantMembersAreUnknown(t *testing.T) { } } events := "/v1/agents/sessions/" + uuid.NewString() + "/events" - unknownEvents := `{"error":{"message":"Invalid Session input event request.","type":"invalid_request_error","code":"invalid_request","param":null}}` + "\n" - if w := bodyGateRequest(h, events, "application/json", []byte(`{"Events":[]}`)); w.Code != http.StatusBadRequest || w.Body.String() != unknownEvents { + if w := bodyGateRequest(h, events, "application/json", []byte(`{"Events":[]}`)); w.Code != http.StatusBadRequest || w.Body.String() != unknown("Events") { t.Errorf("Events: %d %s", w.Code, w.Body) } message := `{"events":[{"type":"agent.session.input.message","input":[{"role":"assistant","Role":"user","content":[{"type":"input_text","text":"hi"}]}]}]}` @@ -140,8 +141,8 @@ func TestUnknownMembersRejectWithLinearAllocation(t *testing.T) { path, message string body []byte }{ - {"/v1/agents/sessions", "Request must be a JSON object containing supported fields.", unknownKeys(`{"agent":{"model":"m"},"environment":{"type":"none"},"input":"hi","metadata":{"k":"v"},`, 16<<20-64)}, - {"/v1/agents/sessions/" + uuid.NewString() + "/events", "Invalid Session input event request.", unknownKeys(`{"events":[],`, 1<<20-64)}, + {"/v1/agents/sessions", "Unknown parameter: 'k0000000'.", unknownKeys(`{"agent":{"model":"m"},"environment":{"type":"none"},"input":"hi","metadata":{"k":"v"},`, 16<<20-64)}, + {"/v1/agents/sessions/" + uuid.NewString() + "/events", "Unknown parameter: 'k0000000'.", unknownKeys(`{"events":[],`, 1<<20-64)}, } { runtime.GC() var before, after runtime.MemStats diff --git a/services/core/internal/api/official_shapes.gen.go b/services/core/internal/api/official_shapes.gen.go index b4ba45f34..ae71e6d90 100644 --- a/services/core/internal/api/official_shapes.gen.go +++ b/services/core/internal/api/official_shapes.gen.go @@ -207,4 +207,224 @@ var ( }}, nullable: true}}, {"x_agents_core", shape{}}, }} + createAgentSessionParams = shape{kind: objectValue, members: []member{ + {"metadata", shape{kind: mapValue, items: &shape{kind: stringValue}, nullable: true}}, + {"agent", shape{kind: openObject}}, + {"agent_id", shape{kind: stringValue}}, + {"environment", shape{kind: openObject, required: true}}, + {"vault_ids", shape{kind: arrayValue, items: &shape{kind: stringValue}, nullable: true}}, + {"input", shape{kind: stringOrArrayValue, items: &shape{kind: objectValue, members: []member{ + {"type", shape{kind: enumValue, values: []string{"message"}}}, + {"role", shape{kind: enumValue, values: []string{"user"}, required: true}}, + {"content", shape{kind: arrayValue, items: &shape{kind: unionValue, values: []string{"input_text", "input_image"}, variants: map[string][]member{ + "input_text": { + {"text", shape{kind: stringValue, required: true}}, + }, + "input_image": { + {"image_url", shape{kind: stringValue, required: true}}, + }, + }}, required: true}}, + }}, nullable: true}}, + {"stream", shape{kind: booleanValue}}, + {"x_agents_core", shape{}}, + }} + updateAgentSessionParams = shape{kind: objectValue, members: []member{ + {"metadata", shape{kind: mapValue, items: &shape{kind: stringValue}, nullable: true}}, + }} + createVaultParams = shape{kind: objectValue, members: []member{ + {"name", shape{kind: stringValue}}, + {"metadata", shape{kind: mapValue, items: &shape{kind: stringValue}, nullable: true}}, + }} + createVaultCredentialParams = shape{kind: objectValue, members: []member{ + {"name", shape{kind: stringValue, required: true}}, + {"auth", shape{kind: unionValue, values: []string{"mcp_oauth", "static_bearer"}, variants: map[string][]member{ + "mcp_oauth": { + {"mcp_server_url", shape{kind: stringValue, required: true}}, + {"access_token", shape{kind: stringValue, required: true}}, + {"expires_at", shape{kind: stringValue, nullable: true}}, + {"refresh", shape{kind: objectValue, members: []member{ + {"token_endpoint", shape{kind: stringValue, required: true}}, + {"client_id", shape{kind: stringValue, required: true}}, + {"resource", shape{kind: stringValue, nullable: true}}, + {"scope", shape{kind: stringValue, nullable: true}}, + {"refresh_token", shape{kind: stringValue, required: true}}, + {"token_endpoint_auth", shape{kind: unionValue, values: []string{"none", "client_secret_basic", "client_secret_post"}, variants: map[string][]member{ + "none": {}, + "client_secret_basic": { + {"client_secret", shape{kind: stringValue, required: true}}, + }, + "client_secret_post": { + {"client_secret", shape{kind: stringValue, required: true}}, + }, + }, required: true}}, + }, nullable: true}}, + }, + "static_bearer": { + {"mcp_server_url", shape{kind: stringValue, required: true}}, + {"token", shape{kind: stringValue, required: true}}, + }, + }, required: true}}, + }} + rotateVaultCredentialParams = shape{kind: objectValue, members: []member{ + {"auth", shape{kind: unionValue, values: []string{"mcp_oauth", "static_bearer"}, variants: map[string][]member{ + "mcp_oauth": { + {"access_token", shape{kind: stringValue, nullable: true}}, + {"expires_at", shape{kind: stringValue, nullable: true}}, + {"refresh", shape{kind: objectValue, members: []member{ + {"refresh_token", shape{kind: stringValue, nullable: true}}, + {"scope", shape{kind: stringValue, nullable: true}}, + {"token_endpoint_auth", shape{kind: unionValue, values: []string{"client_secret_basic", "client_secret_post"}, variants: map[string][]member{ + "client_secret_basic": { + {"client_secret", shape{kind: stringValue, nullable: true}}, + }, + "client_secret_post": { + {"client_secret", shape{kind: stringValue, nullable: true}}, + }, + }, nullable: true}}, + }, nullable: true}}, + }, + "static_bearer": { + {"token", shape{kind: stringValue, required: true}}, + }, + }, required: true}}, + }} + createEnvironmentTemplateParams = shape{kind: objectValue, members: []member{ + {"packages", shape{kind: objectValue, members: []member{ + {"python", shape{kind: arrayValue, items: &shape{kind: stringValue}, nullable: true}}, + {"system", shape{kind: arrayValue, items: &shape{kind: stringValue}, nullable: true}}, + {"npm", shape{kind: arrayValue, items: &shape{kind: stringValue}, nullable: true}}, + }, nullable: true}}, + {"setup_commands", shape{kind: arrayValue, items: &shape{kind: objectValue, members: []member{ + {"command", shape{kind: stringValue, required: true}}, + {"cwd", shape{kind: stringValue, nullable: true}}, + }}, nullable: true}}, + {"network", shape{kind: openObject, nullable: true}}, + {"env", shape{kind: mapValue, items: &shape{kind: stringValue}, nullable: true}}, + {"capability_directories", shape{kind: arrayValue, items: &shape{kind: stringValue}, nullable: true}}, + {"skills", shape{kind: arrayValue, items: &shape{kind: unionValue, values: []string{"skill_reference", "inline"}, variants: map[string][]member{ + "skill_reference": { + {"skill_id", shape{kind: stringValue, required: true}}, + {"version", shape{kind: stringValue, nullable: true}}, + }, + "inline": { + {"name", shape{kind: stringValue, required: true}}, + {"description", shape{kind: stringValue, required: true}}, + {"source", shape{kind: unionValue, values: []string{"base64"}, variants: map[string][]member{ + "base64": { + {"media_type", shape{kind: enumValue, values: []string{"application/zip"}, required: true}}, + {"data", shape{kind: stringValue, required: true}}, + }, + }, required: true}}, + }, + }}, nullable: true}}, + {"plugins", shape{kind: arrayValue, items: &shape{kind: unionValue, values: []string{"inline"}, variants: map[string][]member{ + "inline": { + {"name", shape{kind: stringValue, required: true}}, + {"description", shape{kind: stringValue, required: true}}, + {"source", shape{kind: unionValue, values: []string{"base64"}, variants: map[string][]member{ + "base64": { + {"media_type", shape{kind: enumValue, values: []string{"application/zip"}, required: true}}, + {"data", shape{kind: stringValue, required: true}}, + }, + }, required: true}}, + }, + }}, nullable: true}}, + {"files", shape{kind: arrayValue, items: &shape{kind: unionValue, values: []string{"file_id", "inline"}, variants: map[string][]member{ + "file_id": { + {"file_id", shape{kind: stringValue, required: true}}, + {"path", shape{kind: stringValue, required: true}}, + }, + "inline": { + {"data", shape{kind: stringValue, required: true}}, + {"path", shape{kind: stringValue, required: true}}, + }, + }}, nullable: true}}, + {"name", shape{kind: stringValue, nullable: true}}, + }} + updateEnvironmentTemplateParams = shape{kind: objectValue, members: []member{ + {"name", shape{kind: stringValue, nullable: true}}, + {"packages", shape{kind: objectValue, members: []member{ + {"python", shape{kind: arrayValue, items: &shape{kind: stringValue}, nullable: true}}, + {"system", shape{kind: arrayValue, items: &shape{kind: stringValue}, nullable: true}}, + {"npm", shape{kind: arrayValue, items: &shape{kind: stringValue}, nullable: true}}, + }, nullable: true}}, + {"setup_commands", shape{kind: arrayValue, items: &shape{kind: objectValue, members: []member{ + {"command", shape{kind: stringValue, required: true}}, + {"cwd", shape{kind: stringValue, nullable: true}}, + }}, nullable: true}}, + {"network", shape{kind: openObject, nullable: true}}, + {"env", shape{kind: mapValue, items: &shape{kind: stringValue}, nullable: true}}, + {"capability_directories", shape{kind: arrayValue, items: &shape{kind: stringValue}, nullable: true}}, + {"skills", shape{kind: arrayValue, items: &shape{kind: unionValue, values: []string{"skill_reference", "inline"}, variants: map[string][]member{ + "skill_reference": { + {"skill_id", shape{kind: stringValue, required: true}}, + {"version", shape{kind: stringValue, nullable: true}}, + }, + "inline": { + {"name", shape{kind: stringValue, required: true}}, + {"description", shape{kind: stringValue, required: true}}, + {"source", shape{kind: unionValue, values: []string{"base64"}, variants: map[string][]member{ + "base64": { + {"media_type", shape{kind: enumValue, values: []string{"application/zip"}, required: true}}, + {"data", shape{kind: stringValue, required: true}}, + }, + }, required: true}}, + }, + }}, nullable: true}}, + {"plugins", shape{kind: arrayValue, items: &shape{kind: unionValue, values: []string{"inline"}, variants: map[string][]member{ + "inline": { + {"name", shape{kind: stringValue, required: true}}, + {"description", shape{kind: stringValue, required: true}}, + {"source", shape{kind: unionValue, values: []string{"base64"}, variants: map[string][]member{ + "base64": { + {"media_type", shape{kind: enumValue, values: []string{"application/zip"}, required: true}}, + {"data", shape{kind: stringValue, required: true}}, + }, + }, required: true}}, + }, + }}, nullable: true}}, + {"files", shape{kind: arrayValue, items: &shape{kind: unionValue, values: []string{"file_id", "inline"}, variants: map[string][]member{ + "file_id": { + {"file_id", shape{kind: stringValue, required: true}}, + {"path", shape{kind: stringValue, required: true}}, + }, + "inline": { + {"data", shape{kind: stringValue, required: true}}, + {"path", shape{kind: stringValue, required: true}}, + }, + }}, nullable: true}}, + }} + createSessionEventsParams = shape{kind: objectValue, members: []member{ + {"events", shape{kind: arrayValue, items: &shape{kind: unionValue, values: []string{"agent.session.input.message", "agent.session.input.cancel", "agent.session.input.tool_result"}, variants: map[string][]member{ + "agent.session.input.message": { + {"input", shape{kind: arrayValue, items: &shape{kind: objectValue, members: []member{ + {"type", shape{kind: enumValue, values: []string{"message"}}}, + {"role", shape{kind: enumValue, values: []string{"user"}, required: true}}, + {"content", shape{kind: arrayValue, items: &shape{kind: unionValue, values: []string{"input_text", "input_image"}, variants: map[string][]member{ + "input_text": { + {"text", shape{kind: stringValue, required: true}}, + }, + "input_image": { + {"image_url", shape{kind: stringValue, required: true}}, + }, + }}, required: true}}, + }}, required: true}}, + }, + "agent.session.input.cancel": {}, + "agent.session.input.tool_result": { + {"turn_id", shape{kind: stringValue, required: true}}, + {"call_id", shape{kind: stringValue, required: true}}, + {"success", shape{kind: booleanValue, required: true}}, + {"output", shape{kind: stringOrArrayValue, items: &shape{kind: unionValue, values: []string{"input_text", "input_image"}, variants: map[string][]member{ + "input_text": { + {"text", shape{kind: stringValue, required: true}}, + }, + "input_image": { + {"image_url", shape{kind: stringValue, required: true}}, + }, + }}, nullable: true}}, + {"error", shape{kind: stringValue, nullable: true}}, + }, + }}, required: true}}, + }} ) diff --git a/services/core/internal/api/request_shapes_test.go b/services/core/internal/api/request_shapes_test.go new file mode 100644 index 000000000..0c9d49b1a --- /dev/null +++ b/services/core/internal/api/request_shapes_test.go @@ -0,0 +1,53 @@ +package api + +import ( + "net/http" + "testing" + + "github.com/google/uuid" +) + +// The Session, Vault, Credential, Template and event bodies follow the pinned +// request shapes: a field error names the offending field as param. +func TestRequestShapesReportFieldErrors(t *testing.T) { + validation, _ := validationHandler(t) + credentials, f, _ := credentialHandler(t) + session := uuid.NewString() + credentialPath := "/v1/vaults/" + f.credential.VaultID + "/credentials" + const agent = `"agent":{"model":"m"},"environment":{"type":"none"}` + for _, tc := range []struct { + name string + handler http.Handler + path, body string + param, error string + }{ + {"session unknown member", validation, "/v1/agents/sessions", `{` + agent + `,"x":1}`, "x", "Unknown parameter: 'x'."}, + {"session metadata", validation, "/v1/agents/sessions", `{` + agent + `,"metadata":[]}`, "metadata", "Invalid type for 'metadata': expected an object with string keys and string values, but got an array instead."}, + {"session role", validation, "/v1/agents/sessions", `{` + agent + `,"input":[{"role":"assistant","content":[]}]}`, "input[0].role", "Invalid value: 'assistant'. Supported values are: 'user'."}, + {"session content type", validation, "/v1/agents/sessions", `{` + agent + `,"input":[{"role":"user","content":[{"type":"input_audio"}]}]}`, "input[0].content[0].type", "Invalid value: 'input_audio'. Supported values are: 'input_text' and 'input_image'."}, + {"session update unknown member", validation, "/v1/agents/sessions/" + session, `{"x":1}`, "x", "Unknown parameter: 'x'."}, + {"session update metadata", validation, "/v1/agents/sessions/" + session, `{"metadata":1}`, "metadata", "Invalid type for 'metadata': expected an object with string keys and string values, but got an integer instead."}, + {"event role", validation, "/v1/agents/sessions/" + session + "/events", `{"events":[{"type":"agent.session.input.message","input":[{"role":"system","content":[]}]}]}`, "events[0].input[0].role", "Invalid value: 'system'. Supported values are: 'user'."}, + {"vault name", validation, "/v1/vaults", `{"name":1}`, "name", "Invalid type for 'name': expected a string, but got an integer instead."}, + {"vault unknown member", validation, "/v1/vaults", `{"foo":1}`, "foo", "Unknown parameter: 'foo'."}, + {"template name", validation, "/v1/agents/environments/templates", `{"name":1}`, "name", "Invalid type for 'name': expected a string, but got an integer instead."}, + {"template name length", validation, "/v1/agents/environments/templates", `{"name":""}`, "name", "name must contain 1 to 256 characters."}, + {"template env value", validation, "/v1/agents/environments/templates/" + uuid.NewString(), `{"env":{"KEY":1}}`, "env.KEY", "Invalid type for 'env.KEY': expected a string, but got an integer instead."}, + {"template env name", validation, "/v1/agents/environments/templates", `{"env":{"PATH":"x"}}`, "env.PATH", "Environment variable names must match [A-Za-z_][A-Za-z0-9_]* and must not be PATH, OPENAI_API_KEY or start with CODEX_ or OAC_; values must not contain U+0000."}, + {"credential missing token", credentials, credentialPath, `{"name":"n","auth":{"type":"static_bearer","mcp_server_url":"https://mcp.example/tools"}}`, "auth.token", "Missing required parameter: 'auth.token'."}, + {"credential auth type", credentials, credentialPath, `{"name":"n","auth":{"type":"basic"}}`, "auth.type", "Invalid value: 'basic'. Supported values are: 'mcp_oauth' and 'static_bearer'."}, + {"credential missing access token", credentials, credentialPath, `{"name":"n","auth":{"type":"mcp_oauth","mcp_server_url":"https://mcp.example/tools"}}`, "auth.access_token", "Missing required parameter: 'auth.access_token'."}, + {"credential url", credentials, credentialPath, `{"name":"n","auth":{"type":"static_bearer","mcp_server_url":"http://mcp.example","token":"t"}}`, "auth.mcp_server_url", "auth.mcp_server_url must be an absolute HTTPS URL without userinfo or a fragment."}, + {"credential empty token", credentials, credentialPath, `{"name":"n","auth":{"type":"static_bearer","mcp_server_url":"https://mcp.example","token":""}}`, "auth.token", "auth.token must be a nonempty string."}, + {"rotate unknown member", credentials, credentialPath + "/" + f.credential.ID, `{"auth":{"type":"static_bearer","token":"t","mcp_server_url":"https://mcp.example"}}`, "auth.mcp_server_url", "Unknown parameter: 'auth.mcp_server_url'."}, + {"rotate empty update", credentials, credentialPath + "/" + f.credential.ID, `{"auth":{"type":"mcp_oauth"}}`, "auth", "auth requires access_token, expires_at or refresh to update."}, + } { + t.Run(tc.name, func(t *testing.T) { + w := credentialRequest(tc.handler, http.MethodPost, tc.path, tc.body) + code, param, message := errorFields(t, w) + if w.Code != http.StatusBadRequest || code != "invalid_request_error" || param == nil || *param != tc.param || message != tc.error { + t.Fatalf("%d %s", w.Code, w.Body) + } + }) + } +} diff --git a/services/core/internal/api/session_credentials_test.go b/services/core/internal/api/session_credentials_test.go index c39771700..56b6fd285 100644 --- a/services/core/internal/api/session_credentials_test.go +++ b/services/core/internal/api/session_credentials_test.go @@ -24,20 +24,16 @@ func TestMCPCredentialReferenceIsSchemaNotAuthorization(t *testing.T) { } func TestSessionVaultTypesAndCreationIntent(t *testing.T) { - for _, field := range []string{"", `,"vault_ids":null`, `,"vault_ids":[]`, `,"vault_ids":["vault"]`, `,"vault_ids":[null]`, `,"vault_ids":[3]`, `,"vault_ids":{}`, `,"vault_ids":"vault"`} { + for _, field := range []string{"", `,"vault_ids":null`, `,"vault_ids":[]`, `,"vault_ids":["vault"]`} { var decoded decodedSessionRequest err := json.Unmarshal([]byte(`{"agent":{"model":"model"},"environment":{"type":"none"}`+field+`}`), &decoded) if err != nil { t.Fatal(err) } input, err := decoded.validated() - invalid := strings.Contains(field, "[null]") || strings.Contains(field, "[3]") || strings.Contains(field, "{}") || strings.Contains(field, `:"vault"`) - if (err != nil) != invalid { + if err != nil { t.Fatal("Vault IDs type/default decision differed", field) } - if invalid { - continue - } intent, err := sessionCreationRequest(input, nil) if err != nil || (len(intent) != 0) != (len(input.VaultIDs) > 0) { t.Fatal("attached inline intent missing or unrelated inline identity changed", err) diff --git a/services/core/internal/api/session_initial_input_test.go b/services/core/internal/api/session_initial_input_test.go index b778c836f..4c990879a 100644 --- a/services/core/internal/api/session_initial_input_test.go +++ b/services/core/internal/api/session_initial_input_test.go @@ -36,7 +36,7 @@ func TestInitialInputUsesExecutionAdmissionAndSharedMessageValidation(t *testing t.Fatal(execution.input, err) } } - for _, value := range []string{`[{"type":null,"role":"user","content":[{"type":"input_text","text":"x"}]}]`, `[{"type":"","role":"user","content":[{"type":"input_text","text":"x"}]}]`, `0`, `true`, `{}`, `[]`, `""`, `[{"role":"user","content":[]}]`, `[{"role":"user","content":[{"type":"input_text","text":""}]}]`, `[{"role":"user","unknown":true,"content":[{"type":"input_text","text":"x"}]}]`, `[{"role":"user","content":[{"type":"input_text","text":"x","unknown":true}]}]`, `[{"role":"assistant","content":[{"type":"input_text","text":"x"}]}]`} { + for _, value := range []string{`0`, `true`, `{}`, `[]`, `""`, `[{"role":"user","content":[]}]`, `[{"role":"user","content":[{"type":"input_text","text":""}]}]`, `[{"role":"user","unknown":true,"content":[{"type":"input_text","text":"x"}]}]`, `[{"role":"user","content":[{"type":"input_text","text":"x","unknown":true}]}]`, `[{"role":"assistant","content":[{"type":"input_text","text":"x"}]}]`} { if _, err := initialSessionInputs(json.RawMessage(value)); err == nil { t.Fatal("invalid initial input accepted", value) } diff --git a/services/core/internal/api/session_metadata.go b/services/core/internal/api/session_metadata.go index 213fbd667..c4caabc54 100644 --- a/services/core/internal/api/session_metadata.go +++ b/services/core/internal/api/session_metadata.go @@ -16,36 +16,21 @@ import ( func (h *Handler) updateSession(w http.ResponseWriter, r *http.Request) { raw, ok := readJSONObject(w, r) - if !ok { - return - } - if writeFieldError(w, metadataTypeError(raw)) { + if !ok || writeFieldError(w, validateBody(raw, updateAgentSessionParams)) { return } var request struct { Metadata json.RawMessage `json:"metadata"` } - if err := decodeInputObject(raw, &request, "metadata"); err != nil { - writeError(w, http.StatusBadRequest, "invalid_request", "Request must be a JSON object containing supported fields.") - return - } + _ = json.Unmarshal(raw, &request) if len(request.Metadata) == 0 { writeError(w, http.StatusBadRequest, "invalid_request_error", "At least one update field is required") return } - var pairs map[string]*string - if err := json.Unmarshal(request.Metadata, &pairs); err != nil { - writeError(w, http.StatusBadRequest, "invalid_request", "metadata must be null or an object with string values.") - return - } - values, err := stringMetadata(pairs) - if err == nil { - err = metadataFieldError(metadata.Validate(values)) - } - if err != nil { - if !writeFieldError(w, err) { - writeError(w, http.StatusBadRequest, "invalid_request", "metadata must be null or an object with string values.") - } + var values map[string]string + _ = json.Unmarshal(request.Metadata, &values) + values = metadataOrEmpty(values) + if writeFieldError(w, metadataFieldError(metadata.Validate(values))) { return } session, err := h.Sessions.UpdateSessionMetadata(r.Context(), sessions.UpdateSessionMetadataCommand{TenantID: tenantID(r), SessionID: chi.URLParam(r, "session_id"), Metadata: values}) @@ -56,29 +41,6 @@ func (h *Handler) updateSession(w http.ResponseWriter, r *http.Request) { h.respondSession(w, r, session) } -// metadataTypeError reports the first non-string value of a request body's -// top-level metadata object in document order, before generic body decoding -// can reject it. Other body and metadata shapes keep their existing errors. -// The shared body gate has already rejected repeated keys. -func metadataTypeError(body []byte) error { - decoder := json.NewDecoder(bytes.NewReader(objectMember(body, "metadata"))) - if token, err := decoder.Token(); err != nil || token != json.Delim('{') { - return nil - } - for decoder.More() { - token, err := decoder.Token() - key, isKey := token.(string) - var value json.RawMessage - if err != nil || !isKey || decoder.Decode(&value) != nil { - return nil - } - if kind := jsonValueKind(value); kind != "a string" { - return &fieldError{param: "metadata." + key, message: fmt.Sprintf("Invalid type for 'metadata.%s': expected a string, but got %s instead.", key, kind)} - } - } - return nil -} - func jsonValueKind(value json.RawMessage) string { value = bytes.TrimSpace(value) if len(value) == 0 { @@ -113,6 +75,14 @@ func stringMetadata(values map[string]*string) (map[string]string, error) { return metadata, nil } +// metadataOrEmpty keeps omitted and null metadata equal to an empty map. +func metadataOrEmpty(values map[string]string) map[string]string { + if values == nil { + return map[string]string{} + } + return values +} + // metadataFieldError renders a metadata violation with the pinned messages // and params. U+0000 is a documented local limit; the official service // accepts it. diff --git a/services/core/internal/api/session_request.go b/services/core/internal/api/session_request.go index b4e091ee8..776dab025 100644 --- a/services/core/internal/api/session_request.go +++ b/services/core/internal/api/session_request.go @@ -14,14 +14,11 @@ import ( // otherwise erase it. The embedded wire type retains strict nested decoding. type decodedSessionRequest struct { v1.CreateSessionRequest - Execution json.RawMessage `json:"x_agents_core"` - Input json.RawMessage `json:"input"` - Agent json.RawMessage `json:"agent"` - AgentID json.RawMessage `json:"agent_id"` - Environment json.RawMessage `json:"environment"` - Stream json.RawMessage `json:"stream"` - Metadata map[string]*string `json:"metadata"` - VaultIDs json.RawMessage `json:"vault_ids"` + Execution json.RawMessage `json:"x_agents_core"` + Input json.RawMessage `json:"input"` + Agent json.RawMessage `json:"agent"` + Environment json.RawMessage `json:"environment"` + VaultIDs []string `json:"vault_ids"` } type sessionRequest struct { @@ -50,17 +47,7 @@ func (request decodedSessionRequest) validated() (sessionRequest, error) { _ = json.Unmarshal(request.Execution, &fields) input.modelProviderNull = bytes.Equal(bytes.TrimSpace(fields["model_provider"]), []byte("null")) } - var vaultIDs []*string - if len(request.VaultIDs) != 0 && json.Unmarshal(request.VaultIDs, &vaultIDs) != nil { - return input, sessions.ErrInvalidInput - } - input.VaultIDs = make([]string, 0, len(vaultIDs)) - for _, id := range vaultIDs { - if id == nil { - return input, sessions.ErrInvalidInput - } - input.VaultIDs = append(input.VaultIDs, *id) - } + input.VaultIDs = append([]string{}, request.VaultIDs...) canonicalEnvironment, err := preparationEnvironmentInput(request.Environment, input.XAgentsCore) if err != nil { return input, err @@ -94,18 +81,6 @@ func (request decodedSessionRequest) validated() (sessionRequest, error) { return input, sessions.ErrInvalidInput } } - if len(request.AgentID) > 0 { - var id string - if bytes.Equal(bytes.TrimSpace(request.AgentID), []byte("null")) || json.Unmarshal(request.AgentID, &id) != nil { - return input, sessions.ErrInvalidInput - } - input.AgentID = &id - } - if len(request.Stream) > 0 { - if bytes.Equal(bytes.TrimSpace(request.Stream), []byte("null")) || json.Unmarshal(request.Stream, &input.Stream) != nil { - return input, sessions.ErrInvalidInput - } - } - input.Metadata, err = stringMetadata(request.Metadata) - return input, err + input.Metadata = metadataOrEmpty(request.Metadata) + return input, nil } diff --git a/services/core/internal/api/session_request_test.go b/services/core/internal/api/session_request_test.go index ea17672e1..61431eff1 100644 --- a/services/core/internal/api/session_request_test.go +++ b/services/core/internal/api/session_request_test.go @@ -23,19 +23,19 @@ func TestSessionCreateFieldPresence(t *testing.T) { }{ {"omitted", ``, 201, nil, ""}, {"false stream", `,"stream":false`, 201, nil, ""}, - {"null stream", `,"stream":null`, 400, nil, ""}, - {"whitespace null stream", `,"stream": null `, 400, nil, ""}, - {"string stream", `,"stream":"false"`, 400, nil, ""}, - {"numeric stream", `,"stream":0`, 400, nil, ""}, - {"null agent ID", `,"agent_id":null`, 400, nil, ""}, - {"numeric agent ID", `,"agent_id":0`, 400, nil, ""}, + {"null stream", `,"stream":null`, 400, nil, "stream"}, + {"whitespace null stream", `,"stream": null `, 400, nil, "stream"}, + {"string stream", `,"stream":"false"`, 400, nil, "stream"}, + {"numeric stream", `,"stream":0`, 400, nil, "stream"}, + {"null agent ID", `,"agent_id":null`, 400, nil, "agent_id"}, + {"numeric agent ID", `,"agent_id":0`, 400, nil, "agent_id"}, {"null metadata", `,"metadata":null`, 201, nil, ""}, {"empty metadata", `,"metadata":{}`, 201, nil, ""}, {"string metadata", `,"metadata":{"empty":"","label":"中文🧪"}`, 201, map[string]string{"empty": "", "label": "中文🧪"}, ""}, {"null metadata value", `,"metadata":{"label":null}`, 400, nil, "metadata.label"}, {"mixed metadata values", `,"metadata":{"empty":"","label":null}`, 400, nil, "metadata.label"}, {"numeric metadata value", `,"metadata":{"label":0}`, 400, nil, "metadata.label"}, - {"array metadata", `,"metadata":[]`, 400, nil, ""}, + {"array metadata", `,"metadata":[]`, 400, nil, "metadata"}, } { t.Run(tc.name, func(t *testing.T) { handler, saved, _ := testHandler(t, admitSessions) diff --git a/services/core/internal/api/validation_errors_test.go b/services/core/internal/api/validation_errors_test.go index 0c84bda60..01c769bb8 100644 --- a/services/core/internal/api/validation_errors_test.go +++ b/services/core/internal/api/validation_errors_test.go @@ -166,16 +166,11 @@ func TestMetadataValidationUsesOfficialFields(t *testing.T) { if want := map[bool]int{true: 0, false: 3}[op.limited]; s.writes != want { t.Fatalf("rejected metadata reached storage: %d writes", s.writes) } - // Type errors precede the generic whole-body error. Agent bodies follow - // the pinned shape walk, which reports unknown members first. + // The pinned shape walk reports unknown members first. body := strings.Replace(fmt.Sprintf(op.body, `{"k":1}`), "{", `{"unsupported_field":true,`, 1) - want := "metadata.k" - if strings.HasPrefix(op.name, "agent") { - want = "unsupported_field" - } w := credentialRequest(h, op.method, op.path, body) - if code, param, _ := errorFields(t, w); w.Code != http.StatusBadRequest || code != "invalid_request_error" || param == nil || *param != want { - t.Fatalf("metadata type did not precede generic error: %d %s", w.Code, w.Body) + if code, param, _ := errorFields(t, w); w.Code != http.StatusBadRequest || code != "invalid_request_error" || param == nil || *param != "unsupported_field" { + t.Fatalf("unknown member did not precede metadata type: %d %s", w.Code, w.Body) } for _, tc := range accepted { before := s.writes @@ -250,8 +245,8 @@ func TestTemplateNetworkRejectionsUseOfficialCode(t *testing.T) { if s.writes != 0 { t.Fatal("rejected network reached storage") } - // Other unsupported template installation fields keep their local code. - for _, body := range []string{`{"unsupported":true,"network":{"access":"restricted","allowed_domains":["*.example.com"]}}`, `{"network":{"access":"restricted","allowed_domains":"example.com"}}`, `{"packages":{"cargo":["x"]}}`} { + // A network the walker leaves to the local parser keeps its local code. + for _, body := range []string{`{"network":{"access":"restricted","allowed_domains":"example.com"}}`} { w := credentialRequest(h, http.MethodPost, "/v1/agents/environments/templates", body) if code, _, _ := errorFields(t, w); w.Code != http.StatusBadRequest || code != "unsupported_or_invalid_configuration" { t.Fatalf("%s: %d %s", body, w.Code, w.Body) diff --git a/services/core/internal/api/vaults.go b/services/core/internal/api/vaults.go index 699ae5ebd..95b463a47 100644 --- a/services/core/internal/api/vaults.go +++ b/services/core/internal/api/vaults.go @@ -3,7 +3,6 @@ package api import ( "context" "encoding/json" - "errors" "net/http" "strings" @@ -35,44 +34,26 @@ type VaultsReader interface { func (h *Handler) createVault(w http.ResponseWriter, r *http.Request) { raw, ok := readJSONObject(w, r) - if !ok { - return - } - if writeFieldError(w, metadataTypeError(raw)) { + if !ok || writeFieldError(w, validateBody(raw, createVaultParams)) { return } var request struct { - Name json.RawMessage `json:"name"` - Metadata map[string]*string `json:"metadata"` - } - if decodeInputObject(raw, &request, "name", "metadata") != nil { - writeError(w, http.StatusBadRequest, "invalid_request", "Request must be a JSON object containing supported fields.") - return + Name *string `json:"name"` + Metadata map[string]string `json:"metadata"` } + _ = json.Unmarshal(raw, &request) input := vaults.CreateVault{TenantID: tenantID(r)} - if len(request.Name) > 0 { - var name *string - if json.Unmarshal(request.Name, &name) != nil || name == nil { - writeError(w, http.StatusBadRequest, "invalid_request", "name must be a string.") - return - } - trimmed, err := normalizedVaultName(*name) + if request.Name != nil { + name, err := normalizedVaultName(*request.Name) if err != nil { - writeError(w, http.StatusBadRequest, "invalid_request", err.Error()) + writeFieldError(w, err) return } - input.Name = &trimmed + input.Name = &name } // Vault metadata has no pair or character limits, only the storage limits. - var err error - input.Metadata, err = stringMetadata(request.Metadata) - if err == nil { - err = metadataFieldError(metadata.ValidateStorable(input.Metadata)) - } - if err != nil { - if !writeFieldError(w, err) { - writeError(w, http.StatusBadRequest, "invalid_request", "metadata values must be strings.") - } + input.Metadata = metadataOrEmpty(request.Metadata) + if writeFieldError(w, metadataFieldError(metadata.ValidateStorable(input.Metadata))) { return } vault, err := h.Vaults.CreateVault(r.Context(), input) @@ -103,7 +84,7 @@ func vaultResponse(vault vaults.Vault) v1.Vault { func normalizedVaultName(name string) (string, error) { name = strings.TrimSpace(name) if len(name) == 0 || len(name) > 256 { - return "", errors.New("name must contain 1 to 256 UTF-8 bytes after trimming.") + return "", &fieldError{param: "name", message: "name must contain 1 to 256 UTF-8 bytes after trimming."} } return name, nil } diff --git a/services/core/internal/environmentconfig/setup.go b/services/core/internal/environmentconfig/setup.go index a7b1442b8..0cc62fe92 100644 --- a/services/core/internal/environmentconfig/setup.go +++ b/services/core/internal/environmentconfig/setup.go @@ -45,6 +45,13 @@ func (s Setup) ValidateInstalled() error { return s.validate(true) } +// ValidEnvironmentEntry reports whether an environment variable may be set. +func ValidEnvironmentEntry(name, value string) bool { + // The first three reservations are explicitly part of the public guide; + // OAC_* identifies the actual deployment authority and binding. + return environmentName.MatchString(name) && name != "PATH" && name != "OPENAI_API_KEY" && !strings.HasPrefix(name, "CODEX_") && !strings.HasPrefix(name, "OAC_") && !strings.ContainsRune(value, 0) +} + func (s Setup) validate(installed bool) error { if validateSkills(s.Skills, installed) != nil || ValidatePlugins(s.Plugins) != nil || proto.ValidateSourceDirectories(s.CapabilityDirectories) != nil { return ErrInvalid @@ -57,9 +64,7 @@ func (s Setup) validate(installed bool) error { return ErrInvalid } for name, value := range s.Env { - // The first three reservations are explicitly part of the public guide; - // OAC_* identifies the actual deployment authority and binding. - if !environmentName.MatchString(name) || name == "PATH" || name == "OPENAI_API_KEY" || strings.HasPrefix(name, "CODEX_") || strings.HasPrefix(name, "OAC_") || strings.ContainsRune(value, 0) { + if !ValidEnvironmentEntry(name, value) { return ErrInvalid } } diff --git a/services/core/tests/official_session_metadata.py b/services/core/tests/official_session_metadata.py index 6b37c4cfd..e99136752 100644 --- a/services/core/tests/official_session_metadata.py +++ b/services/core/tests/official_session_metadata.py @@ -51,13 +51,14 @@ def assert_metadata(session, expected): expect_error(ConflictError, lambda: sessions.create(**spec, metadata=metadata, extra_headers=retry)) invalid_fields = [ - {"metadata": []}, {"metadata": "value"}, {"metadata": False}, - {"agent": spec["agent"]}, {"environment": spec["environment"]}, - {"tenant_id": str(uuid.uuid4())}, {"Metadata": {}}, + ({"metadata": []}, "metadata"), ({"metadata": "value"}, "metadata"), ({"metadata": False}, "metadata"), + ({"agent": spec["agent"]}, "agent"), ({"environment": spec["environment"]}, "environment"), + ({"tenant_id": str(uuid.uuid4())}, "tenant_id"), ({"Metadata": {}}, "Metadata"), ] - for fields in invalid_fields: + for fields, param in invalid_fields: response = raw.post(url, headers=headers, json=fields) - assert response.status_code == 400 and response.json()["error"]["code"] == "invalid_request" + assert response.status_code == 400 and response.json()["error"]["code"] == "invalid_request_error" + assert response.json()["error"]["param"] == param, response.text expect_error(BadRequestError, lambda: sessions.update(first.id, extra_body=fields)) assert sessions.retrieve(first.id) == current # Metadata values and limits report the official code, param and message. diff --git a/services/core/tests/official_session_requests.py b/services/core/tests/official_session_requests.py index f2fc66020..70d7087fc 100644 --- a/services/core/tests/official_session_requests.py +++ b/services/core/tests/official_session_requests.py @@ -11,16 +11,16 @@ def verify_session_create_requests(client, spec): # agent_id is str and stream is a boolean literal union without None. sessions = client.beta.agents.sessions before = {session.id for session in sessions.list()} - # Non-string metadata values use the official code and metadata. param. + # Field errors use the official code and the field path as param. invalid = [ - ({"stream": None}, None), ({"stream": "false"}, None), ({"stream": 0}, None), - ({"agent_id": None}, None), ({"agent_id": 0}, None), + ({"stream": None}, "stream"), ({"stream": "false"}, "stream"), ({"stream": 0}, "stream"), + ({"agent_id": None}, "agent_id"), ({"agent_id": 0}, "agent_id"), ({"metadata": {"label": None}}, "metadata.label"), ({"metadata": {"empty": "", "label": None}}, "metadata.label"), - ({"metadata": {"label": 0}}, "metadata.label"), ({"metadata": []}, None), + ({"metadata": {"label": 0}}, "metadata.label"), ({"metadata": []}, "metadata"), # Member names match exactly: a case variant is an unknown member, not an # alias that replaces the field (req_6ba2a50c71a4410f87a1baac855e82df). - ({"Metadata": {"label": "case"}}, None), ({"Input": "Case variant."}, None), ({"STREAM": False}, None), + ({"Metadata": {"label": "case"}}, "Metadata"), ({"Input": "Case variant."}, "Input"), ({"STREAM": False}, "STREAM"), ({"environment": {**spec["environment"], "Type": "self_hosted"}}, None), ] headers = {"Authorization": f"Bearer {client.api_key}", "OpenAI-Beta": "agents=v1"}