diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 2c6479925..7ad825d54 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -95,8 +95,12 @@ keep their local codes; do not grow it into a JSON Schema engine. A malformed pa identifier must produce exactly the response of a well-formed missing one on that route, including invalid bodies, queries and storage availability: resolve it to the never-assigned maximum UUID and let the missing path run, or reject it -directly only where the lookup is the next check. Malformed list cursors and -request-body references keep their own errors. Reject U+0000 in metadata +directly only where the lookup is the next check. Request-body references keep +their own errors. An `after` cursor that does not resolve inside its already +resolved parent, malformed ones included, returns that list family's observed +error: the missing-resource 404 on lookup lists, otherwise the typed store cursor +error. Foreign and missing cursors stay identical; see +`contracts/agents-api/list-query-semantics.md`. Reject U+0000 in metadata explicitly with its `metadata.` param; other stored strings rely on the PostgreSQL error mapping, so keep each request's writes in one transaction. See `contracts/agents-api/official-semantics-alignment.md`. diff --git a/contracts/agents-api/list-query-semantics.md b/contracts/agents-api/list-query-semantics.md index c241172c2..68a49dfaf 100644 --- a/contracts/agents-api/list-query-semantics.md +++ b/contracts/agents-api/list-query-semantics.md @@ -193,3 +193,79 @@ streams, updates and deletions ignore unknown keys. This is resource and query acceptance with no model execution: query parsing does not affect execution, so live model acceptance does not apply. The independent batch acceptance, server gate and review are recorded separately when complete. + +## List cursor errors — September 23, 2026 + +The pin is unchanged: SDK 3.13.0, commit `d7c41ef`, `agents=v1`. This batch starts +from main `c5cb6b56` and aligns the response to an `after` cursor that does not +resolve within its list (ERROR-PROTOCOL-001). Findings with request IDs are retained +in `~/.parsar/remediation/20260923/campaign-scan-4/errors/findings.json` (ERR-01..06, +raw records labelled `cur-*` in `official/results.json`), with SAT-04 from campaign +scan 3 and HE-57 from campaign scan 2; the batch plan is +`~/.parsar/remediation/20260923/cursor-errors/PLAN.md`. "Unresolved" covers a random +well-formed ID, a malformed value, an ID of another resource type, a resource of +another parent, a deleted resource and a resource of another tenant. + +| Row | Lists | Core behavior for an unresolved cursor | Evidence (finding: request ID) | +| --- | --- | --- | --- | +| C1 | Agents, Sessions, Turns, Templates, Vaults, Credentials | 404, type and code `not_found_error`, param null, `Resource not found.` A malformed cursor takes the missing-cursor path instead of the former 400 `invalid_request`, so every unresolved cursor, including a foreign one, gives the same bytes | ERR-01: `req_68e57640f20d451e879f11e8882e9542`, `req_3aee69064ee544eeafaf1fead240e3cb`, `req_a6c9a7b7d84d4db49117a57be9b1e16d`; ERR-13 (random, other type, other parent): `req_a740ac9e3a55476ea9bf0ebf7e53272c`, `req_9df334089c33493584d3ceb5d8c86041`, `req_90aa82ac18ab4a82bf49f940c25a467e`, `req_44a2293ebaf049df8674ef5ea0379abd`, `req_ff3df24a0d4a4e21a2464b5c1ee38179` | +| C2 | Session Items, Subagent Items, Subagent Turn Items | 400, type and code `invalid_request_error`, param null, ``Invalid session item ID in `after` `` | ERR-02: `req_bab2c1aee7dd4415a814bcb94d0dac24`, `req_8d981f935c144b8ebe1a6c2866edb2ea`, `req_aa6340e02f1345758e7b28e83e8f1095`, `req_c71cb02b9363422d92222bf0a5b3df80`; ERR-03: `req_bf7fb4a67c004405b549e848d9dc4be8`, `req_0486db0d18a8415a96eb9410fc62d369`, `req_b21069abb6ad42608e8d3d1ca6f7fa1c`, `req_7531a7851063434098949ed171432af9` | +| C3 | Subagents, Subagent Turns | 400, type and code `invalid_request_error`, param null, ``Invalid resource ID in `after` `` | ERR-04: `req_6d21661de5a74df4bc35c27ad1d1dca8`, `req_6368d24f88dd4eb2acd230745e704621`, `req_5803615424dd424bbf8f007291f37cf9`, `req_f7f5232125bc49278ebde73738d00f7b`; SAT-04: `req_23bdf5f0b8f24813b191634eb6e69256`, `req_e76ce5d4c6494b06a788d3c342f76ad2`, `req_83b9d26129fd43eea67b795b684bbb55` | +| C4 | Session Artifacts | 400, type and code `invalid_request_error`, param null, `after is not a valid artifact ID` | ERR-05: `req_9c4aa6bfabfe4e6599cb317c416c04f4`, `req_333f2ca75b274ad7aedb1e854b28eb57`, `req_5ef5b49127c443f19f6a18d324d2e8e5`; HE-57: `req_22bb488390324d9ebd7779e0be6b7f6b` | +| C5 | Skill versions | A value that does not begin with `skillver`: 400, type `invalid_request_error`, code `invalid_value`, param `after`, ``Invalid 'after': ''. Expected an ID that begins with 'skillver'.`` (``Invalid 'after'. Expected an ID that begins with 'skillver'.`` when the value is not echoed). A version of another Skill in the tenant: the same fields with `Skill version cursor does not match this skill.` A missing, deleted or foreign version, or a `skillver` value with a malformed tail: unchanged 404, type `invalid_request_error`, null code and param | ERR-06: `req_0e7ac00f5359403b952b66d88afb8011`, `req_41d7bcc6522c4230a785b0f23e7043ae`, `req_9b3cd31591594c9797252408cc871512`, `req_7e78eb04a9d04c8497b93d9206a206be`, `req_5d037c1b64cb4475ae11b5b5ab572c84` | +| K1 | Files, Skills, Environment Files `page` | Unchanged: Files 404 with param `after`, Skills 404 with null code and param, Environment Files keeps its page token error | ERR-08: `req_e8a09b54bc804eaa9344270a69943252`; ERR-09: `req_a0b0414f74474cb2a1f1b61dac54edb7`; ERR-17: `req_5c2494e082714500bccbadbf60c6195d` | +| K2 | Valid cursors | Unchanged ordering, paging, `has_more`, first and last IDs and limits | — | +| K3 | Missing or foreign parent: Session, Vault, Subagent, child Turn, Skill | Still 404 before the cursor is read, including a Skill version cursor that does not begin with `skillver` | — | + +### Decisions + +- One typed store error carries the family message. The API chooses the fields by + path family, as for order errors: Skill versions use `invalid_value` with param + `after`, the Beta lists `invalid_request_error` with a null param. +- C1 lists reuse the malformed path-ID approach: a cursor that cannot name a + resource resolves to the never-assigned maximum UUID and the normal lookup runs, + so storage failures and missing rows behave exactly as for a well-formed cursor. + The Core Runtime observation list pages by Session ID and follows the same rule. +- Every cursor is resolved only inside its already resolved parent and tenant. + Other-parent, other-type and foreign cursors therefore take the same path as a + missing one and cannot reveal another tenant's resources. +- The Skill version cursor lookup is now tenant-wide, without a schema change, so + another Skill's version can be told apart from a missing one; another tenant's + version is still missing. The `skillver` prefix check is case-sensitive and uses + the observed prefix. +- Like the official message, the prefix error repeats the caller's value, but + only when it is at most 256 bytes of valid, printable UTF-8, the rule already + used for echoed field names. A longer, unprintable or invalid UTF-8 value is not + echoed, so the error body stays bounded; the Skills `order` error follows the + same rule (`Invalid value. Supported values are: 'asc' and 'desc'.`). +- A parent lookup and its cursor lookup that run as separate statements + (Artifacts and Skill versions) re-check the parent before reporting a 400, so a + parent deleted in between still gives its 404. Deleted Sessions and Skills never + reappear, so a parent found by the re-check also existed when the cursor was + read. Item and Subagent lists read both inside one locked Session transaction. +- Observed upstream failures are not copied: a Turn cursor from another Session + (ERR-11) and a Credential cursor equal to its Vault ID (ERR-12) stay 404, and a + Skills cursor that is not a Skill ID (ERR-10) stays the Skills 404. + +Unobserved and inferred: a Subagent Turn Items cursor naming another Turn of the +same child, another Session's Artifact, and every foreign-tenant cursor follow their +family's rule without an official sample. + +Deferred: deleted Agent and Session cursors, which still anchor pages officially +(ERR-15), would need tombstones; the official 404 message text naming the resource +(ERR-16) is not copied; 409 fields belong to a separate batch. + +### Acceptance boundary + +Go store tests cover each changed list, and `list_cursor_public_test.go` replays +rows C1–C5 and K1–K3 over real HTTP and PostgreSQL for tenant A and tenant B, with +seeded Subagent and Artifact history and exact response bytes, including long, +control-character and invalid UTF-8 Skill version cursors. For K2 it pages every +changed list one resource at a time in both orders and checks the page contents, +`has_more` and the first and last IDs. The path-ID and +Artifact filter replays and the API error mapping test were updated. +`official_list_query.py` checks rows C1, C2, C5, K1 and K3 through raw HTTP and the +pinned SDK for both tenants, and the Subagent acceptance script asserts the C2 and +C3 errors on child lists. This is resource and query acceptance with no model +execution. The independent replay, server gate and review are recorded separately +when complete. diff --git a/contracts/agents-api/official-semantics-alignment.md b/contracts/agents-api/official-semantics-alignment.md index 5b8454d43..3dbe589ba 100644 --- a/contracts/agents-api/official-semantics-alignment.md +++ b/contracts/agents-api/official-semantics-alignment.md @@ -175,10 +175,11 @@ Decisions: assigns because it only generates version 4 and 5 UUIDs. The request then follows exactly the missing-identifier path, including body, query and storage checks. Routes whose lookup is the next check keep their direct not-found - response. Malformed list cursors and request-body references are unchanged: - Session, Turn, Item, Subagent, Artifact, Agent, Vault and Credential cursors - still return 400 `invalid_request`, and Template cursors keep their existing - not-found response. + response. Request-body references are unchanged. Malformed list cursors were + later aligned by the [list cursor error batch](list-query-semantics.md#list-cursor-errors--september-23-2026): + Agent, Session, Turn, Template, Vault and Credential cursors take the same + missing-cursor path, and Item, Subagent, Artifact and Skill version cursors + return their list's official cursor error. - Network messages are Core wording; the official prose is not copied. - Documented message difference for M2: the official message abbreviated a 65-character key as `'KKK...KKK'`. That single sample of identical characters @@ -258,9 +259,11 @@ devices and device crossings still reject the whole capture and fail the Turn with `artifact_capture_failed`; there is no official evidence for them yet. Republication after changed bytes is inferred rather than observed, and the deleted-newest case above is unobserved. The unknown `after` cursor (HE-57) -belongs to ERROR-PROTOCOL-001. Subagent lists keep their `data`/`has_more` -envelope until there is official Subagent evidence. Artifact IDs keep the Core -UUID format. Paths removed from the workspace keep their Artifacts. +was later aligned by the +[list cursor error batch](list-query-semantics.md#list-cursor-errors--september-23-2026). +Subagent lists keep their `data`/`has_more` envelope until there is official +Subagent evidence. Artifact IDs keep the Core UUID format. Paths removed from +the workspace keep their Artifacts. Rust tests cover every link kind, including absolute links to a secret outside the workspace and a relative link to a workspace file outside `outputs/`; an diff --git a/contracts/agents-api/openapi.yaml b/contracts/agents-api/openapi.yaml index 9dfc10217..c62d852b3 100644 --- a/contracts/agents-api/openapi.yaml +++ b/contracts/agents-api/openapi.yaml @@ -2986,7 +2986,8 @@ paths: description: Lists only the authenticated tenant's saved Agents, independently of Sessions. Limit 0 is treated as 1 and larger limits as 100, as observed on the hosted service. The local default is 20; exact upstream default/cap - and empty cursor fields remain unverified. + and empty cursor fields remain unverified. An unknown, malformed or foreign + after cursor returns not found. parameters: - description: agents=v1 in: header @@ -3504,8 +3505,9 @@ paths: get: description: Lists tenant-owned safe template metadata in creation order with ID tie-breaking. Defaults to limit 20 and descending order; limit 0 is treated - as 1 and larger limits as 100. Foreign and missing cursors reject identically. - Concurrent-page and exact hosted error behavior remain unverified. + as 1 and larger limits as 100. Foreign, missing and malformed cursors return + the same not found error. Concurrent-page and exact hosted error behavior + remain unverified. parameters: - description: agents=v1 in: header @@ -3855,11 +3857,12 @@ paths: - Runtime observations /agents/sessions: get: - description: Cursor and results are scoped to the authenticated execution tenant. - Optional agent_id matches the immutable root Agent ID, including inline Agents - and historical Sessions whose saved source was updated or deleted. Omission - lists all Agents. Returns the same Environment and pending-input activity - projection as Session retrieval, including self_hosted Sessions. + description: Cursor and results are scoped to the authenticated execution tenant; + an unknown, malformed or foreign after cursor returns not found. Optional + agent_id matches the immutable root Agent ID, including inline Agents and + historical Sessions whose saved source was updated or deleted. Omission lists + all Agents. Returns the same Environment and pending-input activity projection + as Session retrieval, including self_hosted Sessions. parameters: - description: agents=v1 in: header @@ -4249,8 +4252,10 @@ paths: description: Lists published outputs independently of Environment availability. Sorting uses publication time and ID. A later Turn publishes a path again only when it is new, its bytes changed, or no Artifact remains for it. A malformed - environment_id matches nothing. The local default page size is 20; exact upstream - defaults and error parity remain unverified. + environment_id matches nothing. An after value that is not an Artifact of + this Session, including a malformed one, returns 400 invalid_request_error + with the message "after is not a valid artifact ID". The local default page + size is 20; exact upstream defaults remain unverified. parameters: - description: agents=v1 in: header @@ -4670,7 +4675,9 @@ paths: get: description: Returns supported message and tool Items in first-observation order. Native engine fields are projected explicitly; unfinished Items on terminal - Turns are incomplete. Cursors belong to the same tenant and Session. + Turns are incomplete. Cursors are Items of the same tenant and Session. Any + other after value, including a malformed one, returns 400 invalid_request_error + with the message "Invalid session item ID in `after`". parameters: - description: agents=v1 in: header @@ -4888,8 +4895,10 @@ paths: - Core /agents/sessions/{session_id}/subagents: get: - description: Includes nested and closed Subagents. Cursors belong to the same - tenant and Session. A limit outside 1–100 is rejected. + description: Includes nested and closed Subagents. Cursors are Subagents of + the same tenant and Session. Any other after value, including a malformed + one, returns 400 invalid_request_error with the message "Invalid resource + ID in `after`". A limit outside 1–100 is rejected. parameters: - description: agents=v1 in: header @@ -5009,7 +5018,9 @@ paths: /agents/sessions/{session_id}/subagents/{subagent_id}/items: get: description: Returns only this Subagent's own Items across all its Turns, not - its descendants' Items. Cursors belong to the same tenant, Session and Subagent. + its descendants' Items. Cursors are Items of the same tenant, Session and + Subagent. Any other after value, including a malformed one, returns 400 invalid_request_error + with the message "Invalid session item ID in `after`". parameters: - description: agents=v1 in: header @@ -5080,8 +5091,10 @@ paths: /agents/sessions/{session_id}/subagents/{subagent_id}/turns: get: description: Includes this Subagent's Turns after resume, with the Session's - Agent ID as agent_id. Cursors belong to the same tenant, Session and Subagent. - Missing recorded usage remains null. A limit outside 1–100 is rejected. + Agent ID as agent_id. Cursors are Turns of the same tenant, Session and Subagent. + Any other after value, including a malformed one, returns 400 invalid_request_error + with the message "Invalid resource ID in `after`". Missing recorded usage + remains null. A limit outside 1–100 is rejected. parameters: - description: agents=v1 in: header @@ -5211,8 +5224,10 @@ paths: - Subagents /agents/sessions/{session_id}/subagents/{subagent_id}/turns/{turn_id}/items: get: - description: Returns Items owned by this exact Subagent Turn. Cursors belong - to the same tenant, Session, Subagent and Turn. + description: Returns Items owned by this exact Subagent Turn. Cursors are Items + of the same tenant, Session, Subagent and Turn. Any other after value, including + a malformed one, returns 400 invalid_request_error with the message "Invalid + session item ID in `after`". parameters: - description: agents=v1 in: header @@ -5289,8 +5304,9 @@ paths: get: description: Returns the Session's root Turns in creation order; Subagent Turns are listed through the Subagent Turn routes. The cursor belongs to the same - Session and tenant. Usage contains the latest recorded complete token breakdown; - missing measurements remain null. + Session and tenant; any other after value, including a malformed one or a + Subagent Turn ID, returns not found. Usage contains the latest recorded complete + token breakdown; missing measurements remain null. parameters: - description: agents=v1 in: header @@ -5833,8 +5849,10 @@ paths: /skills/{skill_id}/versions: get: description: Orders by version number; after identifies a version resource, - not a version number. No contents are decrypted. Limit 0 returns an empty - page whose has_more reports whether any version follows the cursor. + not a version number. An after value that does not begin with skillver, or + a version of another Skill, returns 400 invalid_value with param after; a + missing version returns not found. No contents are decrypted. Limit 0 returns + an empty page whose has_more reports whether any version follows the cursor. parameters: - description: Skill ID in: path @@ -5981,12 +5999,13 @@ paths: - Skills /vaults: get: - description: Lists project-owned Vaults independently of execution. Includes - active and archived records by default. Status accepts a scalar, the SDK's - status[] array or both, filtering by their union; a repeated scalar is rejected. - Limits default to 20 and clamp to 1–100. Equal creation times use ID ordering; - exact hosted errors and concurrent-page behavior remain unverified. Archive/delete - lifecycle is not implemented. + description: Lists project-owned Vaults independently of execution. An unknown, + malformed or foreign after cursor returns not found. Includes active and archived + records by default. Status accepts a scalar, the SDK's status[] array or both, + filtering by their union; a repeated scalar is rejected. Limits default to + 20 and clamp to 1–100. Equal creation times use ID ordering; exact hosted + errors and concurrent-page behavior remain unverified. Archive/delete lifecycle + is not implemented. parameters: - description: agents=v1 in: header @@ -6205,12 +6224,13 @@ paths: /vaults/{vault_id}/credentials: get: description: Lists only metadata from the authenticated project's requested - Vault, without decryption or execution. Includes active and archived Credentials - by default, independently of Vault status. Status accepts a scalar, the SDK - status[] array or both, filtering by their union; a repeated scalar is rejected. - Limits default to 20 and clamp to 1–100. Equal creation times use ID ordering. - Hosted errors, concurrent-page behavior and archive/delete lifecycle remain - unverified or unimplemented. + Vault, without decryption or execution. An unknown, malformed or foreign after + cursor, including another Vault's Credential, returns not found. Includes + active and archived Credentials by default, independently of Vault status. + Status accepts a scalar, the SDK status[] array or both, filtering by their + union; a repeated scalar is rejected. Limits default to 20 and clamp to 1–100. + Equal creation times use ID ordering. Hosted errors, concurrent-page behavior + and archive/delete lifecycle remain unverified or unimplemented. parameters: - description: agents=v1 in: header diff --git a/contracts/agents-api/operation-evidence.md b/contracts/agents-api/operation-evidence.md index de1e3bc1a..906743974 100644 --- a/contracts/agents-api/operation-evidence.md +++ b/contracts/agents-api/operation-evidence.md @@ -1,6 +1,6 @@ # Pinned operation evidence inventory — 2026-09-23 -Baseline inventory of main `b5715912f09333e2b4449ec6f0eecaabce44c9b7`. The Session admission batch below updates creation and metadata validation, the list query tolerance batch (L) updates list and resource query handling, the validation error batch (X) updates field error codes/params, malformed path IDs and U+0000 handling, the Environment Files wire batch (G) updates Files.create/list status, envelope, query, path and empty-page behavior, and the creation stream settlement batch (J) updates creation SSE lifetime/snapshot, terminal Turn usage and Turn start order, and the Session deletion batch (Z) updates the deletion lifecycle, and the Agent configuration validation batch (M) updates saved and inline Agent configuration errors, and the whitespace input batch (P) admits whitespace-only message text; historical evidence retains its original revision and scope. This inventory guides repeated qualification and does not assert complete compatibility. +Baseline inventory of main `b5715912f09333e2b4449ec6f0eecaabce44c9b7`. The Session admission batch below updates creation and metadata validation, the list query tolerance batch (L) updates list and resource query handling, the validation error batch (X) updates field error codes/params, malformed path IDs and U+0000 handling, the Environment Files wire batch (G) updates Files.create/list status, envelope, query, path and empty-page behavior, and the creation stream settlement batch (J) updates creation SSE lifetime/snapshot, terminal Turn usage and Turn start order, and the Session deletion batch (Z) updates the deletion lifecycle, and the Agent configuration validation batch (M) updates saved and inline Agent configuration errors, the whitespace input batch (P) admits whitespace-only message text, and the list cursor error batch (CE) updates unresolved `after` cursor errors on every list; historical evidence retains its original revision and scope. This inventory guides repeated qualification and does not assert complete compatibility. Baseline: `contracts/agents-api/upstream.json`, SDK **3.13.0**, upstream commit **d7c41efee1b0802b79f3f88a678ef2052b06e9ce**, `OpenAI-Beta: agents=v1`. AGENTS.md and relevant CONTRIBUTING.md compatibility, ownership and evidence rules govern this inventory. @@ -44,6 +44,7 @@ Repository paths below are relative to the inspected worktree; private evidence | U | [Subagent visibility](subagents.md#subagent-visibility--september-23-2026); private `~/.parsar/remediation/20260923/campaign-scan-3/subagents-tools/findings.json` SAT-01, 02, 07, 08, 09 (SAT-03 for the kept limit rejections) with raw records under `official/` (labels `R00`–`R33`, `C01`–`C06`, S1/S2 stream frames): two owned official Sessions with two child Turns, all deleted; the first official Subagent observations. Rows A1–A5 of that section; SAT-03/05/13/14 match, SAT-04/06/10 and SAT-12 stay deferred or unknown. Go store/API and real-PostgreSQL HTTP tests, TypeScript client and Core Web tests; live acceptance is recorded with the batch. | | P | [Whitespace-only message text](official-semantics-alignment.md#whitespace-only-message-text--september-23); private `~/.parsar/remediation/20260923/campaign-scan-1/sessions/findings.json` SES-01..08 with raw records under `official/` (`s1-create-string-spaces`, `s4-create-string-newline-tab`, `s2-create-message-part-newline-tab`, `e1-events-two-whitespace-messages`, `q2-items-l100`, `p1a`, `e2`–`e4`). Rows W1–W6 of that section. Proto, dispatch, Codex, API handler, TypeScript client and real-PostgreSQL HTTP/Worker tests without a model, including Claude SDK and MiniMax Code admission rejection; live acceptance completed Codex whitespace-only Turns, and the MiniMax native refusal is recorded in the linked section. | | Y | [Artifact capture and listing](official-semantics-alignment.md#artifact-capture-and-listing--september-23); private `~/.parsar/remediation/20260923/campaign-scan-2/hosted-env/findings.json` HE-50..62 with raw records under `official/` (labels `al01`–`al09`, `ar01`–`ar04`, `ac01`–`ac05`, `ad01`/`ad02`): three owned Sessions and two tiny Turns, all deleted; first official Artifact observations. Rows A1–A4 of that section: symlink skip, republication, list envelope and malformed filter. Rust link tests, real-PostgreSQL store/HTTP and pinned-SDK tests without a model; live acceptance is recorded with the batch. | +| CE | [List cursor errors](list-query-semantics.md#list-cursor-errors--september-23-2026); private `~/.parsar/remediation/20260923/campaign-scan-4/errors/findings.json` ERR-01..06 (ERR-07..13 and ERR-17 match or record upstream failures) with raw records labelled `cur-*` in `official/results.json`, plus SAT-04 (campaign scan 3) and HE-57 (campaign scan 2): owned Agent, Session, Turn, Item, Subagent, Artifact, Template, Vault, Credential, File, Skill and Skill version cursors without a model. Rows C1–C5 and K1–K3 of that section. Go store, real-PostgreSQL HTTP tenant A/B and pinned-SDK tests; independent acceptance is recorded with the batch. | | Z | [Session deletion lifecycle](official-semantics-alignment.md#session-deletion-lifecycle--september-23); private `~/.parsar/remediation/20260923/campaign-scan-1/sessions/findings.json` SES-29/30 with raw records under `official/` (`q5-delete-repeat`, `q5b-delete-while-in-progress`, `q5-delete-never-existed`, `q5-delete-while-running`, `q5-get-after-delete`). Rows D1–D5 of that section. Handler, real-PostgreSQL HTTP/store/lock-race, Worker, pinned-SDK, TypeScript client and Web tests without a model; live acceptance is recorded with the batch. | ## Per-operation evidence matrix @@ -55,44 +56,44 @@ Paths in the appendix include `/v1`. SDK names here omit `client.`. `P` means pa | 1 | beta.agents.create | P: saved configuration, 201; metadata/name errors use official code and param; configuration protocol errors use official code, JSON-path param and message; repeated tools and non-object schema roots reject | R `agent-create-supported`; initial unsupported model case 400; X VA-07/08/09; M TV-01/02 `AC01`/`AC02` | C DB create; T saved-Agent execution references; X DB field errors and U+0000 no-write; M DB configuration errors, no-write and tenant replay | Model-derived reasoning defaults and unsupported configurations; saved `web_search` live/omitted still rejects locally (TV-05); multi-error order, unsampled kind phrases and complete default/null/errors unknown | | 2 | beta.agents.retrieve | P: tenant-owned saved read | R `agent-read`, `agent-read-deleted` | C DB own/foreign/deleted read | Full field defaults and inline-vs-saved lifetime | | 3 | beta.agents.update | P: atomic replacements; empty body touches timestamp; metadata/name errors use official code and param; configuration errors as for create, before the Agent lookup | R `agent-patch-metadata`, `agent-null-fields`, `agent-noop`, `agent-nested-reasoning`, rejection labels; X VA-07/08/09/10; M TV-01..03 and TV-07 update labels (`F01`–`X01`, `W04`–`M02`) | C DB no-op/unchanged snapshot; resource implementation-validation.md actual PostgreSQL SDK update tests; M DB owned/foreign/missing/malformed-ID replay, K1 values and isolation | Model-dependent default recomputation; uncommon nested/null/error variants; TV-05 saved `web_search` modes | -| 4 | beta.agents.list | P: scoped cursor list; unknown keys ignored, limit 0/above 100 clamp | R `agent-list-empty-scoped`, `agent-list-limit101`; L VA-01/02/03/04/18 | L DB `official_list_query.py` tenant A/B | Core page capacity 100; no inferred official cap. Overflowing limits unsampled | +| 4 | beta.agents.list | P: scoped cursor list; unknown keys ignored, limit 0/above 100 clamp; any unresolved cursor, malformed included, is the missing 404 | R `agent-list-empty-scoped`, `agent-list-limit101`; L VA-01/02/03/04/18; CE ERR-13 `cur-agents-random`, `cur-agents-othertype-session` | L DB `official_list_query.py` tenant A/B; CE DB cursor matrix tenant A/B | Core page capacity 100; no inferred official cap. Overflowing limits unsampled | | 5 | beta.agents.delete | P: resource deletion | R `cleanup-agent`, subsequent 404 | C DB delete/post-delete | Referenced/in-flight/repeated-delete exact parity | | 6 | beta.agents.sessions.create | P: JSON/live SSE 201, saved/inline frozen config, initial messages, native profiles; inline agent configuration errors use official fields with `agent.` params before the input requirement, and saved records with repeated tools or non-object schema roots reject admission; fresh creation SSE sends the JSON 201 projection, then ends right after the first idle recorded when a Turn ends or an input reservation stops being pending, or any failed, never sending later events; nothing admitted ends after `created`; a silent settlement ends after events up to the cursor read with a settled projection in one snapshot. A same-key stream retry returns 201, sends no events and ends at once; whitespace-only text is admitted and stored verbatim, while empty text/content/input keep the local 400 | S `create-1/2.json`, `omitted-input.json`, `null-input.json`, `empty-array-input.json`, `retry-status-original/repeat.json`; H stream; J creation streams closed after idle, open through requires_action; M TV-01..04 `SC01`–`SC11`; P SES-01..03 whitespace-only string and message input 201, verbatim Item | N Live none admission and retry; C Live three hosted profiles; D/T/K/I recorded additional workflows; J DB creation-stream lifetime/snapshot/retry; M DB inline and saved-override configuration errors without writes; P DB verbatim whitespace Items and unchanged empty-input 400 without writes | Session admission batch removes idle `none` creation; local idempotent create still differs from two official IDs. Stream retry, self-hosted, hosted and no-input stream lifetimes are local; work drained before a silent-settlement read can still be sent. Many input/tool/environment combinations restricted; harness admission limits keep the local code (TV-06). Empty-input 400s keep the local code and message; `["", text]` parts are accepted but unobserved officially (SES-08); Claude SDK and MiniMax Code reject whitespace-only messages at admission as a declared native limitation (W6) | | 7 | beta.agents.sessions.retrieve | P: persisted state, required actions, usage; malformed ID equals missing | S `retrieve-1.json`, `session-after-1.json`; H recovered state; X SES-28 | C Live history; T pending actions; H Core acceptance recorded | Complete statuses/actions/lifecycle timing; Claude/MiniMax public usage remains null | | 8 | beta.agents.sessions.update | P: metadata-only replacement/clear; metadata errors use official code and `metadata`/`metadata.` param | S `metadata-replace/null/empty/omit/invalid-value.json`; `update-agent.json` uses newer unpinned field | N Live completed Session metadata rejection/clear/isolation; recorded active controlled metadata coverage | Session admission batch changes empty update to observed official 400; Session agent update belongs to baseline upgrade, not fixed-pin operation gap | -| 9 | beta.agents.sessions.list | P: Agent filter, full envelope, cursor paging; unknown keys ignored, limit 0/above 100 clamp | S `list-filter.json`, `list-empty-after.json`, `list-owned-cross-filter-cursor.json`, limit/order/unknown-query samples; L SES-10/11/15/16/17 | C Live order/cursors/empty/tenant checks; L DB tenant A/B | Core page capacity 100; official cap unknown. Eventual visibility sample is not a required delay | +| 9 | beta.agents.sessions.list | P: Agent filter, full envelope, cursor paging; unknown keys ignored, limit 0/above 100 clamp; any unresolved cursor, malformed included, is the missing 404 | S `list-filter.json`, `list-empty-after.json`, `list-owned-cross-filter-cursor.json`, limit/order/unknown-query samples; L SES-10/11/15/16/17; CE ERR-01 `cur-sessions-malformed`, ERR-13/14 | C Live order/cursors/empty/tenant checks; L DB tenant A/B; CE DB cursor matrix tenant A/B | Core page capacity 100; official cap unknown. Eventual visibility sample is not a required delay | | 10 | beta.agents.sessions.delete | P: deletion only of a durably idle or failed Session without required actions or pending input; a busy root Turn or pending reservation gives 409 `conflict_error` with no change (subagent child Turns and pending Environment file writes are not checked); owner repeat returns the same 200; owned managed cleanup, user compute retained | S cleanup files 200/deleted; retry-session active cleanup initially 409; Z SES-29 `q5-delete-repeat` 200, SES-30 `q5b-delete-while-in-progress` 409, `q5-delete-never-existed` 404, `q5-delete-while-running` 200 right after an `events.create` 202 | D recorded real cleanup; C cleanup separately recorded; Z DB matrix D1–D4 with no-write digest, admission lock race and pinned-SDK script | Core returns 409 right after an `events.create` 202 because it admits Turns synchronously (official 200); input awaiting its Environment cannot be cancelled publicly and is unobserved officially; physical purge/retention may end repeat idempotency; caller compute ownership preserved | | 11 | beta.agents.sessions.events.create | P: 202/empty body, empty-array authenticated no-op, text/cancel/function admission; whitespace-only text is admitted and stored verbatim, empty text/content/input keep the local 400 | S `second-turn-create.json`, `events-empty/null.json`; H second-input; P SES-04 two whitespace-only messages 202, SES-06/07 empty text/content/input 400 | C Live real continuation/no-op; T qualified message/result/cancel workflows; P DB verbatim whitespace Items and no-write empty-input rejection | Mixed prepared-environment batches, native receipt vs durable acceptance, cancel-before-result-publication timing; unqualified content/tools; empty-input error code/message differ; `["", text]` unobserved officially (SES-08); Claude SDK and MiniMax Code reject whitespace-only messages at admission as a declared native limitation (W6) | | 12 | beta.agents.sessions.events.stream | P: live-only SSE, typed persisted projections; terminal Turn events carry top-level Turn usage (null when unknown); new Turns start `turn.created`, user `item.added`, `session.in_progress`, `turn.in_progress` | H create/reconnect frames; no historical frames in sampled idle interval; J GET streams never ended, terminal `usage` present | C Live; H recorded three-harness disconnect/recovery; T pending actions; J DB order/usage | Full SSE/Item variants/order (EVT-05..10 deferred); child Turns and Items are not streamed on the Session (U, SAT-09) and are read through the Subagent routes; no replay guarantee or observer-disconnect proof for every state | | 13 | beta.agents.sessions.turns.retrieve | P: persisted root Turn identity; malformed and child Turn IDs equal missing | H/S contain Turn list payloads; no isolated positive retrieve raw request identified in this set; X SES-28 official `turn_` 404; U SAT-07 `C04` child Turn ID 404 | Recorded H/B scoped Turn recovery (B under the earlier mixed root/child contract); C history uses list; U DB child-ID 404 equal to missing, tenant B | Distinguish list-shape evidence from retrieve wire qualification; full lifecycle/usage; official 404 message text differs | -| 14 | beta.agents.sessions.turns.list | P: full envelope, ordered root Turns only; a child Turn cursor equals a missing one; limit outside 1–100 rejects with the Beta code | S `turns-final/empty-page/limit-high/order-empty.json`; H both directions; L SES-14/15/16/17; X SES-28; U SAT-07 `R06` root-only list beside a child Turn | C Live paging; H/B real child/root identity recorded under the earlier mixed contract; L DB tenant A/B; U DB root-only list and cursor | All interleavings, same-timestamp paging, interim/failed usage | -| 15 | beta.agents.sessions.items.list | P: scoped root Items, full envelope; limit 0/above 100 clamp within the pinned 1–100 page | S `items-final/empty-page/limit-high.json`; H both directions; L SES-12/13/15/16/17 | C Live; H/T recorded content/coordination/result variants; L DB tenant A/B | Full Item union. Newer turn_id filter excluded from pin | +| 14 | beta.agents.sessions.turns.list | P: full envelope, ordered root Turns only; a child Turn, malformed or other unresolved cursor equals a missing one; limit outside 1–100 rejects with the Beta code | S `turns-final/empty-page/limit-high/order-empty.json`; H both directions; L SES-14/15/16/17; X SES-28; U SAT-07 `R06` root-only list beside a child Turn; CE ERR-01 `cur-turns-malformed`, ERR-13 | C Live paging; H/B real child/root identity recorded under the earlier mixed contract; L DB tenant A/B; U DB root-only list and cursor; CE DB cursor matrix tenant A/B | All interleavings, same-timestamp paging, interim/failed usage | +| 15 | beta.agents.sessions.items.list | P: scoped root Items, full envelope; limit 0/above 100 clamp within the pinned 1–100 page; a cursor that is not an Item of the Session is 400 ``Invalid session item ID in `after` `` | S `items-final/empty-page/limit-high.json`; H both directions; L SES-12/13/15/16/17; CE ERR-02 `cur-items-*` | C Live; H/T recorded content/coordination/result variants; L DB tenant A/B; CE DB cursor matrix tenant A/B | Full Item union. Newer turn_id filter excluded from pin | | 16 | beta.agents.sessions.artifacts.retrieve | P: immutable captured metadata; unchanged outputs keep their Artifact ID across Turns | Y HE-58/59 `ar01-retrieve`, missing and `ar03-cross-session` 404 (fields match; official `artifact_` IDs vs Core UUIDs) | D recorded live Docker/user-managed workspace output; Y DB unchanged-path ID and metadata stability | Error messages differ; hard-link/special-file capture edges unobserved | -| 17 | beta.agents.sessions.artifacts.list | P: scoped stored list, common list envelope; later Turns publish only new, changed or no-remaining-Artifact paths; symlinks skipped; malformed `environment_id` gives an empty page | Y HE-50..56 `al01`–`al09`: Turn 1 capture with a skipped link, Turn 2 republication, envelope, order/cursor/limits, other and malformed filters | D recorded live output enumeration; Y Rust link tests, DB republication, HTTP envelope/filter/tenant and pinned-SDK checks | Unknown `after` (HE-57, ERROR-PROTOCOL-001); changed-bytes republication inferred; deleted-newest edge and paging during capture/delete unobserved | +| 17 | beta.agents.sessions.artifacts.list | P: scoped stored list, common list envelope; later Turns publish only new, changed or no-remaining-Artifact paths; symlinks skipped; malformed `environment_id` gives an empty page; a cursor that is not an Artifact of the Session is 400 `after is not a valid artifact ID` | Y HE-50..56 `al01`–`al09`: Turn 1 capture with a skipped link, Turn 2 republication, envelope, order/cursor/limits, other and malformed filters; CE HE-57, ERR-05 `cur-artifacts-*` | D recorded live output enumeration; Y Rust link tests, DB republication, HTTP envelope/filter/tenant and pinned-SDK checks; CE DB cursor matrix tenant A/B | Changed-bytes republication inferred; deleted-newest edge and paging during capture/delete unobserved; another Session's Artifact as a cursor is inferred | | 18 | beta.agents.sessions.artifacts.delete | P: stored deletion; the next Turn republishes a deleted path | Y HE-61 `ad01-delete`, `ad02-delete-repeat` 404, reads after delete 404; HE-52 deleted path republished by Turn 2 | D recorded workflow summary; Y DB deleted-then-unchanged and deleted-during-capture republication | In-flight deletion and physical retention parity | | 19 | beta.agents.sessions.artifacts.content | P: immutable download after Runtime loss | Y HE-60/62 `ac01-content`, `ac02-content-range` (Range ignored, full 200), `ac05` 404 after Session deletion | D recorded live retained download; Y DB earlier versions keep their bytes | Core's extra Content-Disposition; cancellation-edge capture, partial transfer and content after Environment expiry unobserved officially | | 20 | beta.agents.sessions.subagents.retrieve | P: owned durable child identity/lifecycle | U `R01`, `R22`–`R26` (SAT-05/13 match; instructions hidden, SAT-11 ARCH) | B recorded Live six-read matrix; U DB tenant B 404 | Full lifecycle/multi-agent parity; native close differences; 404 message text (SAT-06) | -| 21 | beta.agents.sessions.subagents.list | P: direct/nested/closed child records; `object`/`first_id`/`last_id` envelope with null IDs on an empty page; limit outside 1–100 rejects | U SAT-01 `R00`, `R11`; SAT-03 `R12`/`R13` rejections | B recorded Live scopes/pages/continuation; U DB envelope, empty page, rejection, tenant B | Publication timing/parent propagation and unsupported native nesting; unknown `after` (SAT-04) | -| 22 | beta.agents.sessions.subagents.items.list | P: child-owned history only; limit 0/above 100 clamp | U SAT-02 `R14`/`R15`; SAT-12 `R02` (contents unknown) | B recorded Live child separation/recovery; U DB clamp, tenant B | Full child Item union; child input Item retained (SAT-12); continuous child progress is not streamed on the Session | +| 21 | beta.agents.sessions.subagents.list | P: direct/nested/closed child records; `object`/`first_id`/`last_id` envelope with null IDs on an empty page; limit outside 1–100 rejects; a cursor that is not a Subagent of the Session is 400 ``Invalid resource ID in `after` `` | U SAT-01 `R00`, `R11`; SAT-03 `R12`/`R13` rejections; CE SAT-04 `R32`/`R33`, ERR-04 `cur-subagents-*` | B recorded Live scopes/pages/continuation; U DB envelope, empty page, rejection, tenant B; CE DB cursor matrix tenant A/B | Publication timing/parent propagation and unsupported native nesting | +| 22 | beta.agents.sessions.subagents.items.list | P: child-owned history only; limit 0/above 100 clamp; a cursor outside the child's Items is 400 ``Invalid session item ID in `after` `` | U SAT-02 `R14`/`R15`; SAT-12 `R02` (contents unknown); CE ERR-03 `cur-subitems-*` | B recorded Live child separation/recovery; U DB clamp, tenant B; CE DB cursor matrix tenant A/B | Full child Item union; child input Item retained (SAT-12); continuous child progress is not streamed on the Session | | 23 | beta.agents.sessions.subagents.turns.retrieve | P: scoped child Turn; `agent_id` is the Session Agent ID, `subagent_id` the child | U SAT-08 `R04`; `R27`/`R28` 404 | B recorded Live six-read matrix; U DB direct and nested child identity, tenant B | Nested-child `agent_id` unobserved; full status/usage/timestamp official semantics | -| 24 | beta.agents.sessions.subagents.turns.list | P: persisted child history with the Session Agent ID; limit outside 1–100 rejects | U SAT-08 `R03`; SAT-03 `R16`/`R17` rejections | B recorded Live paging/cold continuation; U DB identity and rejection | Full concurrent/cancel ordering and accounting; unknown `after` (SAT-04) | -| 25 | beta.agents.sessions.subagents.turns.items.list | P: exact child-and-Turn Items; limit 0/above 100 clamp | U SAT-02 `C05`; `R05` | B recorded Live six-read matrix; U DB clamp, tenant B | Complete union and live ordering, overlapping mutation/cursors | +| 24 | beta.agents.sessions.subagents.turns.list | P: persisted child history with the Session Agent ID; limit outside 1–100 rejects; a cursor that is not a Turn of the child is 400 ``Invalid resource ID in `after` `` | U SAT-08 `R03`; SAT-03 `R16`/`R17` rejections; CE SAT-04 `C06`, ERR-04 `cur-subturns-*` | B recorded Live paging/cold continuation; U DB identity and rejection; CE DB cursor matrix tenant A/B | Full concurrent/cancel ordering and accounting | +| 25 | beta.agents.sessions.subagents.turns.items.list | P: exact child-and-Turn Items; limit 0/above 100 clamp; a cursor outside the child Turn's Items is 400 ``Invalid session item ID in `after` `` | U SAT-02 `C05`; `R05`; CE ERR-03 `cur-subturnitems-*` | B recorded Live six-read matrix; U DB clamp, tenant B; CE DB cursor matrix tenant A/B | Complete union and live ordering, overlapping mutation/cursors | | 26 | beta.agents.environments.retrieve | P: durable status, safe configured installation metadata | None located | D/I/K recorded native readiness and metadata | All lifecycle timing and installation inventory; configured metadata is not arbitrary workspace discovery | | 27 | beta.agents.environments.files.create | P: inline/source-file copy to qualified workspace; 201; observed path, unknown-field and provisioning errors | G HE-10 success/nested/empty, HE-16 path forms and unknown field, HE-18 pending | F/D/K recorded real copy, hashes/consumption/retention; G handler tests | 50 MiB local bound and no parent creation (official 5 MiB, creates parents); overwrite/conflict messages, other body errors and unknown-write semantics | | 28 | beta.agents.environments.files.list | P: direct regular-file directory, opaque cursor; `page` envelope; unknown keys ignored (malformed query encoding still rejected locally), repeated keys rejected; missing, file and symlink paths list empty without following on local workspace readers (the Claude SDK adapter reader keeps 404/503); cleaned-path, token and provisioning errors | G HE-32 envelope, HE-34/35 query keys, HE-36/37 empty pages, HE-38/39 path and token errors, HE-18 pending | F/D/K recorded live workspace listing; G handler, Worker and native helper tests | 1,024-entry prefilter bound; no recursion (HE-30); exact defaults, unsampled path forms and mutation invalidation unknown | | 29 | beta.agents.environments.templates.create | P: reusable network/files/env/setup/packages/Skills/Plugins/capability config, 201; network rejections use `invalid_request_error` | R `template-create`; V nullable Skill selector projection; X SFT-20 | C DB; I/K recorded real frozen-reference initialization | Restricted forms and unqualified combinations; complete hosted initialization semantics | | 30 | beta.agents.environments.templates.retrieve | P: safe resource read | R `template-read`, deleted owned read; V nullable Skill selector projection | C DB; I/K recorded reference workflow | Full field/default/redaction parity; no live-secret projection inference | | 31 | beta.agents.environments.templates.update | P: field replacement/null clearing, empty timestamp touch; network rejections use `invalid_request_error` | R `template-patch`, `template-null`, `template-noop`; V nullable Skill selector projection; X SFT-20 | C DB no-op; I/K recorded frozen Session behavior | Template update and referencing Session selection are distinct; composition and null inheritance are qualified separately in environment-templates.md/template-null-selection.md; uncommon fields/errors remain unverified | -| 32 | beta.agents.environments.templates.list | P: scoped cursor list; unknown keys ignored, limit 0/above 100 clamp | R `template-list-empty-scoped`; L SFT-11/23/24 | Recorded resource DB checks; L DB tenant A/B | Multipage mutation and default parity | +| 32 | beta.agents.environments.templates.list | P: scoped cursor list; unknown keys ignored, limit 0/above 100 clamp; any unresolved cursor is the missing 404 | R `template-list-empty-scoped`; L SFT-11/23/24; CE ERR-07 `cur-templates-*` (match) | Recorded resource DB checks; L DB tenant A/B; CE DB cursor matrix tenant A/B | Multipage mutation and default parity | | 33 | beta.agents.environments.templates.delete | P: delete resource, preserve committed Session snapshot | R `cleanup` at Template path, post-delete read | C DB; K recorded live deletion then continuation | Concurrent references/delete and exact errors | | 34 | beta.agents.vaults.create | P: tenant resource, 201; non-string metadata reports `metadata.`; no pair/length limits | R `vault-create`, `vault-empty-token-fixture`; X VA-08/10/18 | C DB; O recorded MCP attachment workflow; X DB U+0000 no-write | Archive lifecycle, full defaults and selection parity | | 35 | beta.agents.vaults.retrieve | P: safe metadata/status | R `vault-read`, post-cleanup read | C DB deleted/error checks; O recorded lifecycle | Full archived-state and visibility semantics | -| 36 | beta.agents.vaults.list | P: stored status filter (scalar and `status[]` union), separate clamping pagination | R `vault-list-empty-scoped`; L VA-02/03/04/05/06/18 | Recorded resource DB coverage; L DB tenant A/B | Real archive transitions; official negative-limit 400 is a recorded pin conflict | +| 36 | beta.agents.vaults.list | P: stored status filter (scalar and `status[]` union), separate clamping pagination; any unresolved cursor, malformed included, is the missing 404 | R `vault-list-empty-scoped`; L VA-02/03/04/05/06/18; CE ERR-01, ERR-13 `cur-vaults-*` | Recorded resource DB coverage; L DB tenant A/B; CE DB cursor matrix tenant A/B | Real archive transitions; official negative-limit 400 is a recorded pin conflict | | 37 | beta.agents.vaults.delete | P: atomic credential cascade, frozen attachment boundaries | R `cleanup` at Vault path | C DB deletion; O recorded grant lifecycle | Archive vs delete, already-delivered tokens and active effects | | 38 | beta.agents.vaults.credentials.create | P: encrypted static/OAuth, nonempty tokens, 201 | R static/OAuth create + empty token/access-token rejection labels | C DB exact-row preservation/restart; O recorded genuine grants/real MCP | Full provider grants/default/error/selection behavior; qualified none profiles only | | 39 | beta.agents.vaults.credentials.retrieve | P: safe metadata, secret-free read | R `credential-read`, post-delete read | C DB own/foreign/restart; O recorded lifecycle | Full response metadata and archived-state parity | | 40 | beta.agents.vaults.credentials.update | P: static replacement/OAuth grant patch, reject empty effective update | R `credential-rotate`, `credential-empty-token`, `credential-oauth-empty-patch` | C DB rejected-write preservation; O recorded real Codex replacement/refresh | Official empty-access-token update not separately probed; concurrent refresh/replacement/provider errors and in-flight withdrawal | -| 41 | beta.agents.vaults.credentials.list | P: safe scoped metadata/status list (scalar and `status[]` union) | R `credential-list`; L VA-03/04/05/06/18 | C DB mixed list/rejected-write/restart; O recorded resource checks; L DB tenant A/B | Archive behavior; official negative-limit 400 is a recorded pin conflict | +| 41 | beta.agents.vaults.credentials.list | P: safe scoped metadata/status list (scalar and `status[]` union); any unresolved cursor, malformed or the Vault's own ID included, is the missing 404 | R `credential-list`; L VA-03/04/05/06/18; CE ERR-01 `cur-creds-malformed`, ERR-12/13 | C DB mixed list/rejected-write/restart; O recorded resource checks; L DB tenant A/B; CE DB cursor matrix tenant A/B | Archive behavior; official negative-limit 400 is a recorded pin conflict | | 42 | beta.agents.vaults.credentials.delete | P: encrypted resource deletion and dispatch denial | R `cleanup` at Credential paths | C DB; O recorded live Codex refusal after deletion | Cannot recall already-delivered token; exact hosted withdrawal/error timing | | 43 | files.create | P: immutable multipart purpose=user_data | W three owned200 uploads (two user_data, assistants control) | F recorded DB upload; D/K recorded native source consumption | Other Core upload purposes/expires_after unsupported; purpose acceptance does not imply platform processing | | 44 | files.retrieve | P: project-owned metadata | E/V missing-ID404 param=id; W positive metadata/status/nulls | F recorded DB workflow; V actual HTTP/PG foreign==missing | Other purposes/errors/defaults beyond the sampled profile | @@ -107,13 +108,13 @@ Paths in the appendix include `/v1`. SDK names here omit `client.`. `P` means pa | 53 | skills.content.retrieve | P: unversioned content selects default | W distinct default1/latest2 bytes and default2 transition | K recorded DB plus joint resource acceptance | Headers/errors and source deletion/read races | | 54 | skills.versions.create | P: immutable increasing version; optional default change | V second version default=false preserves default1/latest2 | K recorded DB resources | Broader numbering/default/top-level metadata/error/null semantics; upload limits | | 55 | skills.versions.retrieve | P: owned immutable version metadata; malformed version path equals missing | V immediate version1 read404, bounded delayed read200 | K recorded DB resources and Live concrete Session freeze | Visibility timing is observational; full selector/metadata/error parity unqualified | -| 56 | skills.versions.list | P: scoped version cursor list; limit 0 empty page with has_more | V delayed owned list contains created versions; L SFT-08/09 | K recorded DB resource checks; L DB zero page | Exact ordering/cursors/default and concurrent version mutation | +| 56 | skills.versions.list | P: scoped version cursor list; limit 0 empty page with has_more; a cursor not beginning with `skillver` or naming another Skill's version is 400 `invalid_value` on `after`, a missing version 404 | V delayed owned list contains created versions; L SFT-08/09; CE ERR-06 `cur-skillvers-*` | K recorded DB resource checks; L DB zero page; CE DB cursor matrix tenant A/B and pinned SDK | Exact ordering/cursors/default and concurrent version mutation | | 57 | skills.versions.delete | P: nondefault deletion; default rejects invalid_value/version while other versions remain; deleting the only version deletes the Skill in the same locked transaction | W two-version default400; latest200 with parent pointer fallback; [sole-version deletion](file-resource-semantics.md#sole-version-deletion--september-23-2026) SFT-01 sole200 then Skill 404, SFT-04 default400 with visible v2 | Skill store (atomic removal, frozen Session, upload lock order), HTTP tenant A/B and joint resource acceptance | SFT-02 number reuse is an intentional difference (numbers stay immutable); SFT-03 whole-Skill removal and stale official version reads are not emulated | | 58 | skills.versions.content.retrieve | P: decrypt/read immutable concrete bundle | W v1/v2 ZIP members and markers | K recorded DB/live consumption; joint resource acceptance | Content headers/errors and source deletion/read races | ## Remaining gaps without task ordering -1. **Public generic semantics:** sampled create/event/envelope/error/no-op corrections are merged. L aligns unknown/repeated list keys, sampled limit bounds and single-resource unknown keys. X gives malformed path IDs on every Beta, Files and Skills route the exact missing-resource response, reports metadata/name field errors with official code and param, maps Template network rejections to `invalid_request_error`, and rejects U+0000 in stored strings as a documented local limit (the official service stores it). G aligns Environment Files list query tolerance and the sampled Files.create/list errors. Other resource-by-resource omissions/null/default/error params, overflowing limits, list caps, concurrent mutation and deletion require separate evidence. +1. **Public generic semantics:** sampled create/event/envelope/error/no-op corrections are merged. L aligns unknown/repeated list keys, sampled limit bounds and single-resource unknown keys. X gives malformed path IDs on every Beta, Files and Skills route the exact missing-resource response, reports metadata/name field errors with official code and param, maps Template network rejections to `invalid_request_error`, and rejects U+0000 in stored strings as a documented local limit (the official service stores it). G aligns Environment Files list query tolerance and the sampled Files.create/list errors. CE gives every Beta and Skill version list the observed unresolved-cursor error; Files, Skills and Environment Files cursors are unchanged. Other resource-by-resource omissions/null/default/error params, overflowing limits, list caps, concurrent mutation and deletion require separate evidence. 2. **Session differences:** The Session admission batch removes idle `none` creation and empty metadata update. Local durable creation idempotency remains an explicit difference. P admits whitespace-only input verbatim, as observed officially; Claude SDK and MiniMax Code declare whitespace-only messages unsupported at admission. Session agent updates, newer Environment shapes and root Item turn_id are baseline-upgrade questions. 3. **Template/Skill composition:** shared env/files/setup/packages selection is covered by the composition batch; template-reference null network/capability lists are covered by the null-selection batch. Official derived capability-directory projection remains different. Skill content/default metadata and sole-version deletion are covered by file-resource-semantics.md; number reuse is an intentional difference; visibility and broader error behavior remain unverified. 4. **Execution coverage:** use T's qualified matrix, not a blanket missing-image/structured-output claim. MiniMax functions/service MCP, optional tool combinations, unsupported images/placements and broader native lifecycle are explicit restrictions. PTC omission retains approved native behavior; Claude/MiniMax public Usage remains null; child settlement cadence/native close limits remain visible. No second executor/model loop or guessed counters are justified. diff --git a/contracts/agents-api/runtime-observability-api.md b/contracts/agents-api/runtime-observability-api.md index 668d5c28d..e30d4a37a 100644 --- a/contracts/agents-api/runtime-observability-api.md +++ b/contracts/agents-api/runtime-observability-api.md @@ -179,14 +179,14 @@ authorization failure are not downgraded to unavailable rows. Use the existing Agents API error envelope. -| HTTP | Code | When | +| HTTP | Type / code | When | | --- | --- | --- | -| 400 | `unsupported_parameter` | Unknown or duplicate query fields. | -| 400 | `invalid_request` | Empty or invalid limits, order, or malformed cursor. | -| 401 | `authentication_error` | Missing or invalid API authentication. | -| 404 | `not_found` | Missing or foreign Session/cursor, indistinguishably. | -| 500 | `internal_error` | Integrity, ownership, or invalid provider evidence. | -| 503 | `execution_unavailable` | Required Runtime observation service is not configured. | +| 400 | `invalid_request_error` / `invalid_request_error` | List: a repeated supported query key, or an empty or invalid limit or order, with the shared Beta list messages. Unknown list query keys are ignored. | +| 400 | `invalid_request_error` / `unsupported_parameter` | Single-Session retrieval with any query parameter. | +| 401 | `authentication_error` / `invalid_api_key` | Missing or invalid API authentication. | +| 404 | `not_found_error` / `not_found_error` | Missing, malformed or foreign Session/cursor, indistinguishably, as for the [Session list cursor](list-query-semantics.md#list-cursor-errors--september-23-2026). | +| 500 | `server_error` / `internal_error` | Integrity, ownership, or invalid provider evidence. | +| 503 | `server_error` / `execution_unavailable` | Required Runtime observation service is not configured, or list collection exceeded its request budget. | Errors never include provider raw responses or credentials. diff --git a/contracts/agents-api/subagents.md b/contracts/agents-api/subagents.md index 6c3a0dc1a..4479793d4 100644 --- a/contracts/agents-api/subagents.md +++ b/contracts/agents-api/subagents.md @@ -208,7 +208,8 @@ Sessions and two child Turns, all deleted. Unchanged: Subagent retrieve fields and statuses, child history contents (SAT-12 remains unknown; Core keeps the child input Item), the hidden task text, the -single `subagent.created` emission, cursor error semantics (SAT-04, HE-57), native +single `subagent.created` emission, cursor error semantics (SAT-04, HE-57, since +aligned by the [list cursor error batch](list-query-semantics.md#list-cursor-errors--september-23-2026)), native history ownership, cancellation, cold continuation and tenant isolation. ### Acceptance boundary diff --git a/scripts/agents-api-subagents-acceptance.py b/scripts/agents-api-subagents-acceptance.py index 624b47fbc..4243e28d9 100644 --- a/scripts/agents-api-subagents-acceptance.py +++ b/scripts/agents-api-subagents-acceptance.py @@ -175,6 +175,13 @@ def raw(suffix, params=None, expected=200, other=False): require(isinstance(body.get("error"), dict), "error_envelope_missing") return body + def cursor_error(suffix, after): + """An unresolved cursor is the family's official 400 (ERROR-PROTOCOL-001).""" + message = "Invalid session item ID in `after`" if suffix.endswith("/items") else "Invalid resource ID in `after`" + error = raw(suffix, {"after": after}, expected=400)["error"] + require(error == {"message": message, "type": "invalid_request_error", "code": "invalid_request_error", + "param": None}, "cursor_error_mismatch") + def sdk_list(resource, *pos, **keywords): values, seen = [], set() for item in resource.list(*pos, limit=100, order="asc", **keywords): @@ -269,7 +276,7 @@ def page_check(suffix, expected): require(defaults["data"] == list(reversed(expected))[:20], "pagination_defaults_mismatch") # Unknown list keys are ignored (list query tolerance, row A1). require(raw(suffix, {"unknown": "value"})["data"] == defaults["data"], "unknown_list_key_not_ignored") - raw(suffix, {"after": str(uuid.uuid4())}, expected=404) + cursor_error(suffix, str(uuid.uuid4())) raw(suffix, {"order": "invalid"}, expected=400) raw(suffix, other=True, expected=404) @@ -377,15 +384,15 @@ def inspect(stage=None): require(root_turn, "fixture_not_observed_root_turn") raw(suffix + "/turns/" + root_turn, expected=404) raw(suffix + "/turns/" + root_turn + "/items", expected=404) - raw(suffix + "/turns", {"after": root_turn}, expected=404) + cursor_error(suffix + "/turns", root_turn) if root_items: - raw(suffix + "/items", {"after": root_items[0]["id"]}, expected=404) + cursor_error(suffix + "/items", root_items[0]["id"]) summaries.append({"id": child, "parent_agent_id": identifier(sub["parent_agent_id"]), "status": sub["status"], "opened_at": sub["opened_at"], "closed_at": sub["closed_at"], "turn_ids": sorted(own_turn_ids), "item_ids": sorted(own_item_ids)}) first, second = summaries[:2] raw("/subagents/" + first["id"] + "/turns/" + second["turn_ids"][0], expected=404) - raw("/subagents/" + first["id"] + "/items", {"after": second["item_ids"][0]}, expected=404) + cursor_error("/subagents/" + first["id"] + "/items", second["item_ids"][0]) # Session Turn reads carry root work only (SAT-07). visible("session_turns_root_only", lambda: require( all(turn.get("subagent_id") is None and turn["agent_id"] == root for turn in turns) and diff --git a/services/agents-api/internal/api/agents_list.go b/services/agents-api/internal/api/agents_list.go index 5af979863..8793d8666 100644 --- a/services/agents-api/internal/api/agents_list.go +++ b/services/agents-api/internal/api/agents_list.go @@ -7,7 +7,7 @@ import ( ) // @Summary List reusable Agents -// @Description Lists only the authenticated tenant's saved Agents, independently of Sessions. Limit 0 is treated as 1 and larger limits as 100, as observed on the hosted service. The local default is 20; exact upstream default/cap and empty cursor fields remain unverified. +// @Description Lists only the authenticated tenant's saved Agents, independently of Sessions. Limit 0 is treated as 1 and larger limits as 100, as observed on the hosted service. The local default is 20; exact upstream default/cap and empty cursor fields remain unverified. An unknown, malformed or foreign after cursor returns not found. // @Tags Agents // @Produce json // @Security BearerAuth diff --git a/services/agents-api/internal/api/credentials_list.go b/services/agents-api/internal/api/credentials_list.go index f54ad5c70..5619cc432 100644 --- a/services/agents-api/internal/api/credentials_list.go +++ b/services/agents-api/internal/api/credentials_list.go @@ -7,7 +7,7 @@ import ( ) // @Summary List safe Vault Credential metadata -// @Description Lists only metadata from the authenticated project's requested Vault, without decryption or execution. Includes active and archived Credentials by default, independently of Vault status. Status accepts a scalar, the SDK status[] array or both, filtering by their union; a repeated scalar is rejected. Limits default to 20 and clamp to 1–100. Equal creation times use ID ordering. Hosted errors, concurrent-page behavior and archive/delete lifecycle remain unverified or unimplemented. +// @Description Lists only metadata from the authenticated project's requested Vault, without decryption or execution. An unknown, malformed or foreign after cursor, including another Vault's Credential, returns not found. Includes active and archived Credentials by default, independently of Vault status. Status accepts a scalar, the SDK status[] array or both, filtering by their union; a repeated scalar is rejected. Limits default to 20 and clamp to 1–100. Equal creation times use ID ordering. Hosted errors, concurrent-page behavior and archive/delete lifecycle remain unverified or unimplemented. // @Tags Credentials // @Produce json // @Security BearerAuth diff --git a/services/agents-api/internal/api/environment_files_create.go b/services/agents-api/internal/api/environment_files_create.go index 42f202cae..8b57122b9 100644 --- a/services/agents-api/internal/api/environment_files_create.go +++ b/services/agents-api/internal/api/environment_files_create.go @@ -10,11 +10,11 @@ import ( "slices" "strings" "time" - "unicode" "unicode/utf8" v1 "github.com/MiniMax-AI-Dev/parsar/contracts/agents-api/v1" "github.com/MiniMax-AI-Dev/parsar/internal/agentdaemon/proto" + "github.com/MiniMax-AI-Dev/parsar/services/agents-api/internal/echotext" "github.com/MiniMax-AI-Dev/parsar/services/agents-api/internal/execution" "github.com/MiniMax-AI-Dev/parsar/services/agents-api/internal/store" "github.com/go-chi/chi/v5" @@ -173,21 +173,9 @@ func environmentFileCreatePathError(value string) error { // whose name is not echoed. var errUnknownEnvironmentFileField = &fieldError{message: "Unknown parameter."} -// echoableField bounds the caller-supplied name that an unknown-field error -// repeats in both message and param; JSON escaping can grow each byte sixfold. -// encoding/json has already replaced invalid bytes and lone surrogates with -// U+FFFD, so a name containing it is not repeated either. -func echoableField(field string) bool { - if len(field) > 256 || !utf8.ValidString(field) || strings.ContainsRune(field, utf8.RuneError) { - return false - } - for _, r := range field { - if !unicode.IsPrint(r) { - return false - } - } - return true -} +// echoableField bounds the caller-supplied name that an error repeats in its +// message or param; see echotext.Allowed. +func echoableField(field string) bool { return echotext.Allowed(field) } // unknownBodyField returns the first top-level member outside allowed, in // document order. Malformed and non-object bodies are left to the caller's diff --git a/services/agents-api/internal/api/environment_templates.go b/services/agents-api/internal/api/environment_templates.go index 0e66cc828..ea2ee3f7e 100644 --- a/services/agents-api/internal/api/environment_templates.go +++ b/services/agents-api/internal/api/environment_templates.go @@ -173,7 +173,7 @@ func (h *Handler) deleteEnvironmentTemplate(w http.ResponseWriter, r *http.Reque } // @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 0 is treated as 1 and larger limits as 100. Foreign and missing cursors reject identically. Concurrent-page and exact hosted error behavior remain unverified. +// @Description Lists tenant-owned safe template metadata in creation order with ID tie-breaking. Defaults to limit 20 and descending order; limit 0 is treated as 1 and larger limits as 100. Foreign, missing and malformed cursors return the same not found error. Concurrent-page and exact hosted error behavior remain unverified. // @Tags Environment Templates // @Produce json // @Security BearerAuth diff --git a/services/agents-api/internal/api/errors.go b/services/agents-api/internal/api/errors.go index 15578602c..d30cdc523 100644 --- a/services/agents-api/internal/api/errors.go +++ b/services/agents-api/internal/api/errors.go @@ -65,6 +65,7 @@ func writeFieldError(w http.ResponseWriter, err error) bool { } func writeStoreError(w http.ResponseWriter, r *http.Request, err error, notFoundParam ...string) { + var cursor *store.InvalidCursorError switch { case errors.Is(err, store.ErrExecutorCredentialExists): writeError(w, http.StatusConflict, "executor_credential_exists", "This executor key ID already exists. Explicitly rotate it to replace the secret.") @@ -95,6 +96,14 @@ func writeStoreError(w http.ResponseWriter, r *http.Request, err error, notFound writeError(w, http.StatusBadRequest, "unsupported_or_invalid_configuration", "This Session's harness does not accept a message whose text is only whitespace. Include non-whitespace text or an image, or use a harness that supports whitespace-only text.") case errors.Is(err, execution.ErrExecutionUnavailable): writeError(w, http.StatusServiceUnavailable, "execution_unavailable", "Execution is not available on this service.") + case errors.As(err, &cursor): + // Observed official fields for an unresolved list cursor: Skill versions + // use invalid_value on after, Beta lists invalid_request_error with a null param. + if listFamilyOf(r) == skillsList { + writeError(w, http.StatusBadRequest, "invalid_value", cursor.Message, "after") + } else { + writeError(w, http.StatusBadRequest, "invalid_request_error", cursor.Message) + } case errors.Is(err, store.ErrNotFound): code := "not_found_error" // Files and Skills retain their non-beta error envelope. diff --git a/services/agents-api/internal/api/errors_test.go b/services/agents-api/internal/api/errors_test.go index 6023325d3..7de682ed4 100644 --- a/services/agents-api/internal/api/errors_test.go +++ b/services/agents-api/internal/api/errors_test.go @@ -44,6 +44,25 @@ func TestResourceNotFoundErrorSurfaces(t *testing.T) { } } +// An unresolved list cursor keeps its store message; Skill versions use the +// observed invalid_value code on after, Beta lists invalid_request_error with a +// null param. +func TestInvalidCursorErrorFields(t *testing.T) { + for path, want := range map[string]string{ + "/v1/agents/sessions/session/items": `{"error":{"message":"Invalid session item ID in ` + "`after`" + `","type":"invalid_request_error","code":"invalid_request_error","param":null}}`, + "/v1/agents/sessions/session/subagents": `{"error":{"message":"Invalid session item ID in ` + "`after`" + `","type":"invalid_request_error","code":"invalid_request_error","param":null}}`, + "/v1/skills/skill_missing/versions": `{"error":{"message":"Invalid session item ID in ` + "`after`" + `","type":"invalid_request_error","code":"invalid_value","param":"after"}}`, + "/v1/agents/sessions/session/artifacts?x=1": `{"error":{"message":"Invalid session item ID in ` + "`after`" + `","type":"invalid_request_error","code":"invalid_request_error","param":null}}`, + } { + response := httptest.NewRecorder() + request := httptest.NewRequest(http.MethodGet, path, nil) + writeStoreError(response, request, fmt.Errorf("list: %w", &store.InvalidCursorError{Message: "Invalid session item ID in `after`"})) + if response.Code != http.StatusBadRequest || response.Body.String() != want+"\n" { + t.Errorf("%s: %d %s", path, response.Code, response.Body) + } + } +} + func TestMissingBetaErrorAfterAuthentication(t *testing.T) { for _, authenticated := range []bool{false, true} { handler, _, _ := testHandler(t) diff --git a/services/agents-api/internal/api/handler.go b/services/agents-api/internal/api/handler.go index c6fc36618..070ae329d 100644 --- a/services/agents-api/internal/api/handler.go +++ b/services/agents-api/internal/api/handler.go @@ -325,7 +325,7 @@ func (h *Handler) respondSessionStatus(w http.ResponseWriter, r *http.Request, s } // @Summary List execution Sessions -// @Description Cursor and results are scoped to the authenticated execution tenant. Optional agent_id matches the immutable root Agent ID, including inline Agents and historical Sessions whose saved source was updated or deleted. Omission lists all Agents. Returns the same Environment and pending-input activity projection as Session retrieval, including self_hosted Sessions. +// @Description Cursor and results are scoped to the authenticated execution tenant; an unknown, malformed or foreign after cursor returns not found. Optional agent_id matches the immutable root Agent ID, including inline Agents and historical Sessions whose saved source was updated or deleted. Omission lists all Agents. Returns the same Environment and pending-input activity projection as Session retrieval, including self_hosted Sessions. // @Tags Sessions // @Produce json // @Security BearerAuth diff --git a/services/agents-api/internal/api/items.go b/services/agents-api/internal/api/items.go index 4c599ebd2..119ad16a0 100644 --- a/services/agents-api/internal/api/items.go +++ b/services/agents-api/internal/api/items.go @@ -6,7 +6,7 @@ import ( ) // @Summary List persisted execution Items -// @Description Returns supported message and tool Items in first-observation order. Native engine fields are projected explicitly; unfinished Items on terminal Turns are incomplete. Cursors belong to the same tenant and Session. +// @Description Returns supported message and tool Items in first-observation order. Native engine fields are projected explicitly; unfinished Items on terminal Turns are incomplete. Cursors are Items of the same tenant and Session. Any other after value, including a malformed one, returns 400 invalid_request_error with the message "Invalid session item ID in `after`". // @Tags Items // @Produce json // @Security BearerAuth diff --git a/services/agents-api/internal/api/list_query_errors.go b/services/agents-api/internal/api/list_query_errors.go index 84067567b..dad6574fa 100644 --- a/services/agents-api/internal/api/list_query_errors.go +++ b/services/agents-api/internal/api/list_query_errors.go @@ -34,7 +34,12 @@ func writeListOrderError(w http.ResponseWriter, r *http.Request, order string) { case filesList: writeError(w, http.StatusBadRequest, "", "order must be asc or desc.") case skillsList: - writeError(w, http.StatusBadRequest, "invalid_value", fmt.Sprintf("Invalid value: '%s'. Supported values are: 'asc' and 'desc'.", order), "order") + // The observed message echoes the value; a long or unprintable one is left out. + message := "Invalid value. Supported values are: 'asc' and 'desc'." + if echoableField(order) { + message = fmt.Sprintf("Invalid value: '%s'. Supported values are: 'asc' and 'desc'.", order) + } + writeError(w, http.StatusBadRequest, "invalid_value", message, "order") default: writeError(w, http.StatusBadRequest, "invalid_request_error", fmt.Sprintf("Failed to deserialize query string: order: unknown variant `%s`, expected `asc` or `desc`", order)) } diff --git a/services/agents-api/internal/api/pagination_test.go b/services/agents-api/internal/api/pagination_test.go index 5e51b0c7b..e20f890f0 100644 --- a/services/agents-api/internal/api/pagination_test.go +++ b/services/agents-api/internal/api/pagination_test.go @@ -7,6 +7,7 @@ import ( "net/http/httptest" "net/url" "reflect" + "strings" "testing" ) @@ -192,6 +193,27 @@ func TestListOrderErrorEnvelopes(t *testing.T) { } } +// The Skills order error repeats the value only when it is short and printable, +// so a long or control-character value cannot inflate the response. +func TestSkillsOrderErrorBoundsEcho(t *testing.T) { + const bounded = "Invalid value. Supported values are: 'asc' and 'desc'." + for _, order := range []string{strings.Repeat("x", 257), strings.Repeat("\x01", 100), "bad\nvalue", "\xff"} { + for _, path := range []string{"/v1/skills", "/v1/skills/skill-example/versions"} { + w := httptest.NewRecorder() + r := httptest.NewRequest(http.MethodGet, path+"?order="+url.QueryEscape(order), nil) + if _, ok := readPage(w, r); ok { + t.Fatal("invalid order accepted") + } + assertListQueryError(t, w, "invalid_value", "order", bounded) + } + } + w := httptest.NewRecorder() + if _, ok := readPage(w, httptest.NewRequest(http.MethodGet, "/v1/skills?order="+strings.Repeat("y", 256), nil)); ok { + t.Fatal("invalid order accepted") + } + assertListQueryError(t, w, "invalid_value", "order", "Invalid value: '"+strings.Repeat("y", 256)+"'. Supported values are: 'asc' and 'desc'.") +} + func TestListQueryErrorPrecedence(t *testing.T) { skillsOrder := "Invalid value: ''. Supported values are: 'asc' and 'desc'." for _, test := range []struct { diff --git a/services/agents-api/internal/api/session_artifacts.go b/services/agents-api/internal/api/session_artifacts.go index 2632b3881..f0710cef5 100644 --- a/services/agents-api/internal/api/session_artifacts.go +++ b/services/agents-api/internal/api/session_artifacts.go @@ -31,7 +31,7 @@ func (h *Handler) artifactsReady(w http.ResponseWriter) bool { } // @Summary List immutable Session artifacts -// @Description Lists published outputs independently of Environment availability. Sorting uses publication time and ID. A later Turn publishes a path again only when it is new, its bytes changed, or no Artifact remains for it. A malformed environment_id matches nothing. The local default page size is 20; exact upstream defaults and error parity remain unverified. +// @Description Lists published outputs independently of Environment availability. Sorting uses publication time and ID. A later Turn publishes a path again only when it is new, its bytes changed, or no Artifact remains for it. A malformed environment_id matches nothing. An after value that is not an Artifact of this Session, including a malformed one, returns 400 invalid_request_error with the message "after is not a valid artifact ID". The local default page size is 20; exact upstream defaults remain unverified. // @Tags Artifacts // @Produce json // @Security BearerAuth diff --git a/services/agents-api/internal/api/skills_list.go b/services/agents-api/internal/api/skills_list.go index 6fd18dde4..85ba28d11 100644 --- a/services/agents-api/internal/api/skills_list.go +++ b/services/agents-api/internal/api/skills_list.go @@ -42,7 +42,7 @@ func (h *Handler) listSkills(w http.ResponseWriter, r *http.Request) { } // @Summary List Skill versions -// @Description Orders by version number; after identifies a version resource, not a version number. No contents are decrypted. Limit 0 returns an empty page whose has_more reports whether any version follows the cursor. +// @Description Orders by version number; after identifies a version resource, not a version number. An after value that does not begin with skillver, or a version of another Skill, returns 400 invalid_value with param after; a missing version returns not found. No contents are decrypted. Limit 0 returns an empty page whose has_more reports whether any version follows the cursor. // @Tags Skills // @Produce json // @Security BearerAuth diff --git a/services/agents-api/internal/api/subagent_turns.go b/services/agents-api/internal/api/subagent_turns.go index f05958f34..ffea97556 100644 --- a/services/agents-api/internal/api/subagent_turns.go +++ b/services/agents-api/internal/api/subagent_turns.go @@ -31,7 +31,7 @@ func (h *Handler) getSubagentTurn(w http.ResponseWriter, r *http.Request) { } // @Summary List a Subagent's Turns -// @Description Includes this Subagent's Turns after resume, with the Session's Agent ID as agent_id. Cursors belong to the same tenant, Session and Subagent. Missing recorded usage remains null. A limit outside 1–100 is rejected. +// @Description Includes this Subagent's Turns after resume, with the Session's Agent ID as agent_id. Cursors are Turns of the same tenant, Session and Subagent. Any other after value, including a malformed one, returns 400 invalid_request_error with the message "Invalid resource ID in `after`". Missing recorded usage remains null. A limit outside 1–100 is rejected. // @Tags Subagents // @Produce json // @Security BearerAuth @@ -58,7 +58,7 @@ func (h *Handler) listSubagentTurns(w http.ResponseWriter, r *http.Request) { } // @Summary List a Subagent Turn's Items -// @Description Returns Items owned by this exact Subagent Turn. Cursors belong to the same tenant, Session, Subagent and Turn. +// @Description Returns Items owned by this exact Subagent Turn. Cursors are Items of the same tenant, Session, Subagent and Turn. Any other after value, including a malformed one, returns 400 invalid_request_error with the message "Invalid session item ID in `after`". // @Tags Subagents // @Produce json // @Security BearerAuth diff --git a/services/agents-api/internal/api/subagents.go b/services/agents-api/internal/api/subagents.go index d5c1ba65b..abf06e5c4 100644 --- a/services/agents-api/internal/api/subagents.go +++ b/services/agents-api/internal/api/subagents.go @@ -63,7 +63,7 @@ func (h *Handler) getSubagent(w http.ResponseWriter, r *http.Request) { } // @Summary List Session Subagents -// @Description Includes nested and closed Subagents. Cursors belong to the same tenant and Session. A limit outside 1–100 is rejected. +// @Description Includes nested and closed Subagents. Cursors are Subagents of the same tenant and Session. Any other after value, including a malformed one, returns 400 invalid_request_error with the message "Invalid resource ID in `after`". A limit outside 1–100 is rejected. // @Tags Subagents // @Produce json // @Security BearerAuth @@ -89,7 +89,7 @@ func (h *Handler) listSubagents(w http.ResponseWriter, r *http.Request) { } // @Summary List a Subagent's Items -// @Description Returns only this Subagent's own Items across all its Turns, not its descendants' Items. Cursors belong to the same tenant, Session and Subagent. +// @Description Returns only this Subagent's own Items across all its Turns, not its descendants' Items. Cursors are Items of the same tenant, Session and Subagent. Any other after value, including a malformed one, returns 400 invalid_request_error with the message "Invalid session item ID in `after`". // @Tags Subagents // @Produce json // @Security BearerAuth diff --git a/services/agents-api/internal/api/turns.go b/services/agents-api/internal/api/turns.go index f56d8686d..4f51d676b 100644 --- a/services/agents-api/internal/api/turns.go +++ b/services/agents-api/internal/api/turns.go @@ -43,7 +43,7 @@ func (h *Handler) getTurn(w http.ResponseWriter, r *http.Request) { } // @Summary List execution Turns -// @Description Returns the Session's root Turns in creation order; Subagent Turns are listed through the Subagent Turn routes. The cursor belongs to the same Session and tenant. Usage contains the latest recorded complete token breakdown; missing measurements remain null. +// @Description Returns the Session's root Turns in creation order; Subagent Turns are listed through the Subagent Turn routes. The cursor belongs to the same Session and tenant; any other after value, including a malformed one or a Subagent Turn ID, returns not found. Usage contains the latest recorded complete token breakdown; missing measurements remain null. // @Tags Turns // @Produce json // @Security BearerAuth diff --git a/services/agents-api/internal/api/vaults_list.go b/services/agents-api/internal/api/vaults_list.go index 7714836c1..56c8ae6ad 100644 --- a/services/agents-api/internal/api/vaults_list.go +++ b/services/agents-api/internal/api/vaults_list.go @@ -7,7 +7,7 @@ import ( ) // @Summary List Vaults -// @Description Lists project-owned Vaults independently of execution. Includes active and archived records by default. Status accepts a scalar, the SDK's status[] array or both, filtering by their union; a repeated scalar is rejected. Limits default to 20 and clamp to 1–100. Equal creation times use ID ordering; exact hosted errors and concurrent-page behavior remain unverified. Archive/delete lifecycle is not implemented. +// @Description Lists project-owned Vaults independently of execution. An unknown, malformed or foreign after cursor returns not found. Includes active and archived records by default. Status accepts a scalar, the SDK's status[] array or both, filtering by their union; a repeated scalar is rejected. Limits default to 20 and clamp to 1–100. Equal creation times use ID ordering; exact hosted errors and concurrent-page behavior remain unverified. Archive/delete lifecycle is not implemented. // @Tags Vaults // @Produce json // @Security BearerAuth diff --git a/services/agents-api/internal/db/queries/skills.sql b/services/agents-api/internal/db/queries/skills.sql index 8f0121bff..0b39b36c5 100644 --- a/services/agents-api/internal/db/queries/skills.sql +++ b/services/agents-api/internal/db/queries/skills.sql @@ -46,8 +46,9 @@ SELECT id, tenant_id, skill_id, version, name, description, created_at FROM skill_versions WHERE tenant_id = $1 AND skill_id = $2 AND version = $3; -- name: GetSkillVersionByID :one +-- Tenant-wide, so a list cursor can tell another Skill's version from a missing one. SELECT id, tenant_id, skill_id, version, name, description, created_at -FROM skill_versions WHERE tenant_id = $1 AND skill_id = $2 AND id = $3; +FROM skill_versions WHERE tenant_id = $1 AND id = $2; -- name: ReadSkillVersion :one SELECT * FROM skill_versions WHERE tenant_id = $1 AND skill_id = $2 AND version = $3; diff --git a/services/agents-api/internal/db/sqlc/skills.sql.go b/services/agents-api/internal/db/sqlc/skills.sql.go index ebc1126fc..61bb93477 100644 --- a/services/agents-api/internal/db/sqlc/skills.sql.go +++ b/services/agents-api/internal/db/sqlc/skills.sql.go @@ -236,12 +236,11 @@ func (q *Queries) GetSkillVersion(ctx context.Context, arg GetSkillVersionParams const getSkillVersionByID = `-- name: GetSkillVersionByID :one SELECT id, tenant_id, skill_id, version, name, description, created_at -FROM skill_versions WHERE tenant_id = $1 AND skill_id = $2 AND id = $3 +FROM skill_versions WHERE tenant_id = $1 AND id = $2 ` type GetSkillVersionByIDParams struct { TenantID pgtype.UUID `json:"tenant_id"` - SkillID pgtype.UUID `json:"skill_id"` ID pgtype.UUID `json:"id"` } @@ -255,8 +254,9 @@ type GetSkillVersionByIDRow struct { CreatedAt pgtype.Timestamptz `json:"created_at"` } +// Tenant-wide, so a list cursor can tell another Skill's version from a missing one. func (q *Queries) GetSkillVersionByID(ctx context.Context, arg GetSkillVersionByIDParams) (GetSkillVersionByIDRow, error) { - row := q.db.QueryRow(ctx, getSkillVersionByID, arg.TenantID, arg.SkillID, arg.ID) + row := q.db.QueryRow(ctx, getSkillVersionByID, arg.TenantID, arg.ID) var i GetSkillVersionByIDRow err := row.Scan( &i.ID, diff --git a/services/agents-api/internal/echotext/echotext.go b/services/agents-api/internal/echotext/echotext.go new file mode 100644 index 000000000..321cd37fd --- /dev/null +++ b/services/agents-api/internal/echotext/echotext.go @@ -0,0 +1,25 @@ +// Package echotext decides whether an error message may repeat a +// caller-supplied value. +package echotext + +import ( + "strings" + "unicode" + "unicode/utf8" +) + +// Allowed bounds a caller-supplied value that an error message repeats: at most +// 256 bytes of valid, printable UTF-8. JSON escaping can grow each byte sixfold. +// encoding/json replaces invalid bytes and lone surrogates with U+FFFD, so a +// value containing it is not repeated either. +func Allowed(value string) bool { + if len(value) > 256 || !utf8.ValidString(value) || strings.ContainsRune(value, utf8.RuneError) { + return false + } + for _, r := range value { + if !unicode.IsPrint(r) { + return false + } + } + return true +} diff --git a/services/agents-api/internal/echotext/echotext_test.go b/services/agents-api/internal/echotext/echotext_test.go new file mode 100644 index 000000000..f9f2eec0d --- /dev/null +++ b/services/agents-api/internal/echotext/echotext_test.go @@ -0,0 +1,23 @@ +package echotext + +import ( + "strings" + "testing" +) + +func TestAllowed(t *testing.T) { + for value, want := range map[string]bool{ + "not-a-valid-id": true, + "skill_é": true, + strings.Repeat("x", 256): true, + strings.Repeat("x", 257): false, + "bad\x01value": false, + "line\nbreak": false, + "\xff": false, + "replacement�": false, + } { + if got := Allowed(value); got != want { + t.Errorf("Allowed(%q) = %t; want %t", value, got, want) + } + } +} diff --git a/services/agents-api/internal/store/agents_list.go b/services/agents-api/internal/store/agents_list.go index 6f665b26a..f3a546945 100644 --- a/services/agents-api/internal/store/agents_list.go +++ b/services/agents-api/internal/store/agents_list.go @@ -24,7 +24,7 @@ func (s *Store) ListAgents(ctx context.Context, tenantID, cursor string, limit i } params := sqlc.ListAgentsParams{TenantID: tenant, PageLimit: int32(limit + 1), AfterID: pgtype.UUID{Valid: true}, Ascending: ascending} if cursor != "" { - after, err := s.GetAgent(ctx, tenantID, cursor) + after, err := s.GetAgent(ctx, tenantID, lookupCursor(cursor)) if err != nil { return AgentPage{}, err } diff --git a/services/agents-api/internal/store/agents_list_test.go b/services/agents-api/internal/store/agents_list_test.go index f7a62c66f..f6c6b058b 100644 --- a/services/agents-api/internal/store/agents_list_test.go +++ b/services/agents-api/internal/store/agents_list_test.go @@ -74,14 +74,12 @@ func TestAgentListPaginationIsolationAndReconnect(t *testing.T) { if got := read(s, false); !slices.Equal(got, reverse) { t.Fatalf("descending equal timestamps: %v", got) } - for _, after := range []string{foreign.ID, uuid.NewString()} { + // A malformed cursor follows the missing-cursor path (ERR-01). + for _, after := range []string{foreign.ID, uuid.NewString(), "not-an-id"} { if _, err := s.ListAgents(ctx, tenant, after, 2, true); !errors.Is(err, ErrNotFound) { - t.Fatalf("unowned/unknown cursor accepted: %v", err) + t.Fatalf("unowned/unknown/malformed cursor accepted: %v", err) } } - if _, err := s.ListAgents(ctx, tenant, "not-an-id", 2, true); !errors.Is(err, ErrInvalidInput) { - t.Fatalf("invalid cursor accepted: %v", err) - } tail, err := s.ListAgents(ctx, tenant, ids[len(ids)-1], 2, true) if err != nil || tail.Agents == nil || len(tail.Agents) != 0 || tail.NextCursor != "" { t.Fatalf("end page: %+v, %v", tail, err) diff --git a/services/agents-api/internal/store/environment_templates.go b/services/agents-api/internal/store/environment_templates.go index 6d006b07a..6f5afc9d4 100644 --- a/services/agents-api/internal/store/environment_templates.go +++ b/services/agents-api/internal/store/environment_templates.go @@ -190,7 +190,7 @@ func (s *Store) ListEnvironmentTemplates(ctx context.Context, tenantID, cursor s } 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) + after, err := s.GetEnvironmentTemplate(ctx, tenantID, lookupCursor(cursor)) if err != nil { return EnvironmentTemplatePage{}, err } diff --git a/services/agents-api/internal/store/item_reads.go b/services/agents-api/internal/store/item_reads.go index 86c6e9c35..52e972fc7 100644 --- a/services/agents-api/internal/store/item_reads.go +++ b/services/agents-api/internal/store/item_reads.go @@ -24,19 +24,17 @@ func (s *Store) ListItems(ctx context.Context, tenantID, sessionID, cursor strin err := s.withPublicSession(ctx, tenantID, sessionID, func(ctx context.Context, q *sqlc.Queries, session pgtype.UUID) error { p := sqlc.ListSessionItemsParams{SessionID: session, PageLimit: int32(limit + 1), Ascending: ascending, AfterID: pgtype.UUID{Valid: true}} if cursor != "" { - id, err := parseID(cursor) - if err != nil { - return err - } - row, err := q.GetSessionItem(ctx, sqlc.GetSessionItemParams{SessionID: session, ID: id}) + // Any cursor that is not an Item of this Session, including a + // malformed one, is an invalid cursor rather than a missing resource. + row, err := q.GetSessionItem(ctx, sqlc.GetSessionItemParams{SessionID: session, ID: parsePathID(cursor)}) if errors.Is(err, pgx.ErrNoRows) { - return ErrNotFound + return errItemCursor } if err != nil { return err } p.AfterCreated = row.CreatedAt - p.AfterID = id + p.AfterID = row.ID p.AfterPosition = row.Position } rows, err := q.ListSessionItems(ctx, p) diff --git a/services/agents-api/internal/store/item_reads_test.go b/services/agents-api/internal/store/item_reads_test.go index 1406396f5..c55a80c8a 100644 --- a/services/agents-api/internal/store/item_reads_test.go +++ b/services/agents-api/internal/store/item_reads_test.go @@ -102,9 +102,15 @@ func TestItemsRecoverSnapshotsPartialResultsPaginationAndIsolation(t *testing.T) } } other, _ := s.CreateSession(ctx, tenant, store.CreateSessionInput{Creator: store.FixtureCreator(), Engine: "codex", IdempotencyKey: "other"}) - for _, scope := range []struct{ tenant, session, cursor string }{{uuid.NewString(), session.ID, ""}, {tenant, other.ID, page.Items[0].ID}} { - if _, err = s.ListItems(ctx, scope.tenant, scope.session, scope.cursor, 20, true); !errors.Is(err, store.ErrNotFound) { - t.Fatal(err) + // A foreign parent is not found before the cursor is read. + if _, err = s.ListItems(ctx, uuid.NewString(), session.ID, page.Items[0].ID, 20, true); !errors.Is(err, store.ErrNotFound) { + t.Fatal(err) + } + // Another Session's Item is an invalid cursor here, like a missing or malformed one. + for _, cursor := range []string{page.Items[0].ID, uuid.NewString(), "not-a-uuid"} { + var invalid *store.InvalidCursorError + if _, err = s.ListItems(ctx, tenant, other.ID, cursor, 20, true); !errors.As(err, &invalid) || invalid.Message != "Invalid session item ID in `after`" { + t.Fatal(cursor, err) } } } diff --git a/services/agents-api/internal/store/list_cursor_public_test.go b/services/agents-api/internal/store/list_cursor_public_test.go new file mode 100644 index 000000000..385056f07 --- /dev/null +++ b/services/agents-api/internal/store/list_cursor_public_test.go @@ -0,0 +1,425 @@ +package store_test + +import ( + "bytes" + "encoding/json" + "mime/multipart" + "net/http" + "net/http/httptest" + "net/url" + "slices" + "strings" + "testing" + + v1 "github.com/MiniMax-AI-Dev/parsar/contracts/agents-api/v1" + "github.com/MiniMax-AI-Dev/parsar/internal/agentdaemon/device" + "github.com/MiniMax-AI-Dev/parsar/internal/agentdaemon/proto" + "github.com/MiniMax-AI-Dev/parsar/services/agents-api/internal/api" + "github.com/MiniMax-AI-Dev/parsar/services/agents-api/internal/credentialcrypto" + "github.com/MiniMax-AI-Dev/parsar/services/agents-api/internal/store" + "github.com/google/uuid" +) + +// cursorFixture is one tenant's resources for the list cursor matrix. Each list +// has a second parent in the same tenant, so other-parent cursors are real IDs. +type cursorFixture struct { + agent, template, vault, otherVault, credential, otherCredential string + session, turn, item, otherSession, otherItem string + artifactSession, artifactTurn, artifact, otherArtifact string + subSession, rootTurn, rootItem, otherSubagent, otherChildTurn string + child, childTurn, childItem, laterChildTurn, laterChildItem string + sibling, siblingTurn, siblingItem string + skill, version, laterVersion, deletedVersion, otherSkillVersion string + file string +} + +func seedCursorFixture(t *testing.T, s *store.Store, writer *store.Store, client pathIDClient, token, tenant, label string) cursorFixture { + t.Helper() + ctx := t.Context() + var f cursorFixture + // Each list has at least two resources, so valid cursors page between them. + f.agent = client.created(token, "/v1/agents", `{"model":"cursor-model"}`) + client.created(token, "/v1/agents", `{"model":"cursor-model"}`) + f.template = client.created(token, "/v1/agents/environments/templates", `{"name":"cursor-template"}`) + client.created(token, "/v1/agents/environments/templates", `{"name":"cursor-template-2"}`) + f.vault = client.created(token, "/v1/vaults", `{"name":"cursor-vault"}`) + f.otherVault = client.created(token, "/v1/vaults", `{"name":"cursor-other-vault"}`) + credential := `{"name":"cursor","auth":{"type":"static_bearer","mcp_server_url":"https://mcp.example/mcp","token":"cursor-token"}}` + f.credential = client.created(token, "/v1/vaults/"+f.vault+"/credentials", credential) + client.created(token, "/v1/vaults/"+f.vault+"/credentials", credential) + f.otherCredential = client.created(token, "/v1/vaults/"+f.otherVault+"/credentials", credential) + + // Queued Turns and user Items exist without a daemon. + first := func(path string) string { + t.Helper() + status, raw := client.do(token, http.MethodGet, path+"?order=asc", "", nil) + var page struct{ Data []struct{ ID string } } + if status != http.StatusOK || json.Unmarshal([]byte(raw), &page) != nil || len(page.Data) == 0 { + t.Fatalf("fixture %s: %d %s", path, status, raw) + } + return page.Data[0].ID + } + // Both Sessions use the saved Agent, so the Session list can page them by agent_id. + newSession := `{"agent_id":"` + f.agent + `","environment":{"type":"none"},"input":"Keep this Session."}` + f.session = client.created(token, "/v1/agents/sessions", newSession) + f.turn = first("/v1/agents/sessions/" + f.session + "/turns") + f.item = first("/v1/agents/sessions/" + f.session + "/items") + if _, err := s.TransitionTurn(ctx, tenant, f.session, f.turn, store.TurnTransition{ExpectedStatus: store.TurnQueued, Status: store.TurnCancelled}); err != nil { + t.Fatal(err) + } + if _, err := s.SubmitMessage(ctx, tenant, f.session, label+"-second", json.RawMessage(`{"input":[{"role":"user","content":[{"type":"input_text","text":"second"}]}]}`)); err != nil { + t.Fatal(err) + } + f.otherSession = client.created(token, "/v1/agents/sessions", newSession) + f.otherItem = first("/v1/agents/sessions/" + f.otherSession + "/items") + + artifactSession, environment := hostedArtifactSession(t, s, tenant, label+"-artifacts") + f.artifactSession = artifactSession + f.artifactTurn = completeArtifactTurn(t, s, tenant, artifactSession, environment, label+"-artifact-turn", map[string]string{"a.txt": "alpha", "c.txt": "charlie"}) + f.artifact = first("/v1/agents/sessions/" + artifactSession + "/artifacts") + otherArtifactSession, otherEnvironment := hostedArtifactSession(t, s, tenant, label+"-other-artifacts") + completeArtifactTurn(t, s, tenant, otherArtifactSession, otherEnvironment, label+"-other-artifact-turn", map[string]string{"b.txt": "bravo"}) + f.otherArtifact = first("/v1/agents/sessions/" + otherArtifactSession + "/artifacts") + + // Subagent history is seeded through the execution lease, as a daemon would. + seedSubagents := func(key string) (session, rootTurn string) { + t.Helper() + created, err := s.CreateSession(ctx, tenant, store.CreateSessionInput{Creator: store.FixtureCreator(), Engine: "codex", IdempotencyKey: key, + Configuration: json.RawMessage(`{"agent":{"id":"agent_root","model":"cursor-model","multi_agent":{"enabled":true,"max_concurrent_subagents":4}}}`)}) + if err != nil { + t.Fatal(err) + } + receipt, err := s.SubmitMessage(ctx, tenant, created.ID, key+"-input", json.RawMessage(`{"input":[{"role":"user","content":[{"type":"input_text","text":"delegate"}]}]}`)) + if err != nil { + t.Fatal(err) + } + host, err := s.CreateDevice(ctx, tenant, "cursor "+key, device.HashCredential(uuid.NewString())) + if err != nil { + t.Fatal(err) + } + if err = writer.BindSessionDevice(ctx, tenant, created.ID, host.ID); err != nil { + t.Fatal(err) + } + if _, err = writer.TransitionTurn(ctx, tenant, created.ID, receipt.TurnID, store.TurnTransition{ExpectedStatus: store.TurnQueued, Status: store.TurnInProgress}); err != nil { + t.Fatal(err) + } + opened := int64(1700000001000) + identity := func(child string) store.ExecutionEvent { + return subagentFixture(proto.TypeSubagentIdentity, proto.SubagentIdentityPayload{NativeID: child, ParentNativeID: "root", NativeCreatedAt: 1700000001, ParentTurnID: "native-root", SourceItemID: "spawn-" + child}) + } + // Distinct creation times keep child-turn before later-child-turn. + turn := func(child, id string, created int64) store.ExecutionEvent { + return subagentFixture(proto.TypeSubagentTurn, proto.SubagentTurnPayload{NativeID: child, TurnID: id, Status: store.TurnInProgress, CreatedAtMS: created, StartedAtMS: &created}) + } + message := func(child, turn, id string, position int32) store.ExecutionEvent { + text := "answer " + id + payload, _ := json.Marshal(proto.OutputMessagePayload{ID: id, Status: "completed", Text: &text}) + return subagentFixture(proto.TypeSubagentItem, proto.SubagentItemPayload{NativeID: child, TurnID: turn, ItemID: id, Position: position, Kind: proto.TypeOutputMessage, Payload: payload}) + } + facts := []store.ExecutionEvent{identity("child"), identity("sibling"), + turn("child", "child-turn", opened), message("child", "child-turn", "child-item", 0), message("child", "child-turn", "child-item-2", 1), + turn("child", "later-child-turn", opened+1000), message("child", "later-child-turn", "later-child-item", 0), + turn("sibling", "sibling-turn", opened), message("sibling", "sibling-turn", "sibling-item", 0)} + if err = writer.AppendTurnEvents(ctx, tenant, created.ID, receipt.TurnID, 1, facts); err != nil { + t.Fatal(err) + } + return created.ID, receipt.TurnID + } + subagent := func(session, native string) string { + t.Helper() + identity, err := s.GetSubagentIdentity(ctx, tenant, session, native) + if err != nil { + t.Fatal(err) + } + return identity.ID + } + f.subSession, f.rootTurn = seedSubagents(label + "-subagents") + f.rootItem = first("/v1/agents/sessions/" + f.subSession + "/items") + f.child, f.sibling = subagent(f.subSession, "child"), subagent(f.subSession, "sibling") + childTurns, err := s.ListSubagentTurns(ctx, tenant, f.subSession, f.child, "", 10, true) + if err != nil || len(childTurns.Data) != 2 { + t.Fatal("fixture child Turns", childTurns, err) + } + f.childTurn, f.laterChildTurn = childTurns.Data[0].ID, childTurns.Data[1].ID + f.childItem = first("/v1/agents/sessions/" + f.subSession + "/subagents/" + f.child + "/turns/" + f.childTurn + "/items") + f.laterChildItem = first("/v1/agents/sessions/" + f.subSession + "/subagents/" + f.child + "/turns/" + f.laterChildTurn + "/items") + f.siblingTurn = first("/v1/agents/sessions/" + f.subSession + "/subagents/" + f.sibling + "/turns") + f.siblingItem = first("/v1/agents/sessions/" + f.subSession + "/subagents/" + f.sibling + "/items") + otherSubSession, _ := seedSubagents(label + "-other-subagents") + f.otherSubagent = subagent(otherSubSession, "child") + f.otherChildTurn = first("/v1/agents/sessions/" + otherSubSession + "/subagents/" + f.otherSubagent + "/turns") + + skill, err := s.CreateSkill(ctx, tenant, store.SkillArchive(t, label+"-cursor-skill")) + if err != nil { + t.Fatal(err) + } + f.skill = skill.ID + versions, err := s.ListSkillVersions(ctx, tenant, skill.ID, "", 10, true) + if err != nil || len(versions.Versions) != 1 { + t.Fatal("fixture Skill version", versions, err) + } + f.version = versions.Versions[0].ID + later, err := s.CreateSkillVersion(ctx, tenant, skill.ID, store.SkillArchive(t, label+"-cursor-skill-v2"), false) + if err != nil { + t.Fatal(err) + } + f.laterVersion = later.ID + deleted, err := s.CreateSkillVersion(ctx, tenant, skill.ID, store.SkillArchive(t, label+"-cursor-skill-v3"), false) + if err != nil { + t.Fatal(err) + } + if _, err = s.DeleteSkillVersion(ctx, tenant, skill.ID, "3"); err != nil { + t.Fatal(err) + } + f.deletedVersion = deleted.ID + otherSkill, err := s.CreateSkill(ctx, tenant, store.SkillArchive(t, label+"-cursor-other-skill")) + if err != nil { + t.Fatal(err) + } + otherVersions, err := s.ListSkillVersions(ctx, tenant, otherSkill.ID, "", 10, true) + if err != nil || len(otherVersions.Versions) != 1 { + t.Fatal("fixture other Skill version", otherVersions, err) + } + f.otherSkillVersion = otherVersions.Versions[0].ID + + var upload bytes.Buffer + form := multipart.NewWriter(&upload) + if err := form.WriteField("purpose", "user_data"); err != nil { + t.Fatal(err) + } + part, err := form.CreateFormFile("file", "cursor.txt") + if err != nil { + t.Fatal(err) + } + if _, err := part.Write([]byte("cursor file")); err != nil { + t.Fatal(err) + } + if err := form.Close(); err != nil { + t.Fatal(err) + } + status, raw := client.do(token, http.MethodPost, "/v1/files", form.FormDataContentType(), upload.Bytes()) + var file struct{ ID string } + if status != http.StatusOK || json.Unmarshal([]byte(raw), &file) != nil || file.ID == "" { + t.Fatalf("fixture File: %d %s", status, raw) + } + f.file = file.ID + return f +} + +// wireError is the exact serialized error body, including its trailing newline. +func wireError(kind string, code, param *string, message string) string { + raw, _ := json.Marshal(v1.ErrorResponse{Error: v1.APIError{Message: message, Type: kind, Code: code, Param: param}}) + return string(raw) + "\n" +} + +// Unresolved list cursors answer with each family's observed official error +// (ERROR-PROTOCOL-001 rows C1–C5), while Files, Skills, valid cursors and parent +// lookups keep their behavior (K1–K3). A foreign cursor is always byte-identical +// to a missing one, and a foreign or missing parent is 404 before any cursor. +func TestListCursorErrorsPostgres(t *testing.T) { + _, pool := store.NewTestStore(t) + cipher, err := credentialcrypto.New(bytes.Repeat([]byte{67}, 32)) + if err != nil { + t.Fatal(err) + } + s := store.NewWithCredentialCipher(pool, cipher) + owner, foreign := uuid.NewString(), uuid.NewString() + ownerTenant, foreignTenant := uuid.NewString(), uuid.NewString() + auth, err := api.NewAuthenticator([]api.APIKey{ + {OrganizationID: "test-org", ProjectID: uuid.NewString(), SubjectKind: "service_account", SubjectID: "cursor-owner", TokenSHA256: device.HashCredential(owner), TenantID: ownerTenant}, + {OrganizationID: "test-org", ProjectID: uuid.NewString(), SubjectKind: "service_account", SubjectID: "cursor-foreign", TokenSHA256: device.HashCredential(foreign), TenantID: foreignTenant}, + }) + if err != nil { + t.Fatal(err) + } + h, err := api.NewHandler(s, auth, "codex", api.WithExecution(s), api.WithSubagents(s), api.WithSkills(s), api.WithSourceFiles(s), api.WithSessionArtifacts(s)) + if err != nil { + t.Fatal(err) + } + server := httptest.NewServer(h) + defer server.Close() + client := pathIDClient{t: t, server: server} + lease, err := s.AcquireExecutionLease(t.Context()) + if err != nil { + t.Fatal(err) + } + defer func() { _ = lease.Close(t.Context()) }() + a := seedCursorFixture(t, s, lease.Store(), client, owner, ownerTenant, "a") + b := seedCursorFixture(t, s, lease.Store(), client, foreign, foreignTenant, "b") + + text := func(value string) *string { return &value } + var ( + lookupMissing = wireError("not_found_error", text("not_found_error"), nil, "Resource not found.") + skillsMissing = wireError("invalid_request_error", nil, nil, "Resource not found.") + itemCursor = wireError("invalid_request_error", text("invalid_request_error"), nil, "Invalid session item ID in `after`") + otherCursor = wireError("invalid_request_error", text("invalid_request_error"), nil, "Invalid resource ID in `after`") + artifact = wireError("invalid_request_error", text("invalid_request_error"), nil, "after is not a valid artifact ID") + otherSkill = wireError("invalid_request_error", text("invalid_value"), text("after"), "Skill version cursor does not match this skill.") + ) + prefix := func(value string) string { + return wireError("invalid_request_error", text("invalid_value"), text("after"), "Invalid 'after': '"+value+"'. Expected an ID that begins with 'skillver'.") + } + list := func(token, path, after string) (int, string) { + t.Helper() + query := "" + if after != "" { + query = "?" + url.Values{"after": {after}}.Encode() + } + return client.do(token, http.MethodGet, path+query, "", nil) + } + expect := func(token, path string, status int, want string, cursors ...string) { + t.Helper() + for _, cursor := range cursors { + if got, body := list(token, path, cursor); got != status || body != want { + t.Errorf("GET %s after=%q = %d %s; want %d %s", path, cursor, got, body, status, want) + } + } + } + malformed := []string{"not-a-valid-id", "sess_0e6cb352a4b62bb3006ab3f4b12e408196925fb4f852b298b8", "00000000-0000-0000-0000-000000000000", "ffffffff-ffff-ffff-ffff-ffffffffffff", "%", "é"} + random := uuid.NewString() + sessions := "/v1/agents/sessions/" + subagents := sessions + a.subSession + "/subagents/" + + // C1: lookup-family lists answer every unresolved cursor, including malformed, + // other-type, other-parent and foreign ones, exactly like a missing one. + for path, cursors := range map[string][]string{ + "/v1/agents": {a.session, a.template, b.agent}, + "/v1/agents/sessions": {a.agent, a.turn, b.session}, + sessions + a.session + "/turns": {a.item, a.otherItem, a.rootTurn, a.childTurn, b.turn}, + "/v1/agents/environments/templates": {a.vault, a.agent, b.template}, + "/v1/vaults": {a.credential, a.template, b.vault}, + "/v1/vaults/" + a.vault + "/credentials": {a.otherCredential, a.vault, b.credential}, + } { + expect(owner, path, http.StatusNotFound, lookupMissing, random) + expect(owner, path, http.StatusNotFound, lookupMissing, malformed...) + expect(owner, path, http.StatusNotFound, lookupMissing, cursors...) + } + // Tenant B sees tenant A's cursors on its own top-level lists as missing too. + for _, path := range []string{"/v1/agents", "/v1/agents/sessions", "/v1/agents/environments/templates", "/v1/vaults"} { + expect(foreign, path, http.StatusNotFound, lookupMissing, a.agent, a.session, a.template, a.vault, "not-a-valid-id") + } + + // C2: Item lists reject any cursor that is not an Item of their exact scope. + expect(owner, sessions+a.session+"/items", http.StatusBadRequest, itemCursor, + append([]string{random, a.turn, a.otherItem, a.rootItem, a.childItem, b.item, "msg_dd0287b00b5e5e37f0d53b128b3183c1f5daf20b115b65940a"}, malformed...)...) + expect(owner, sessions+a.subSession+"/items", http.StatusBadRequest, itemCursor, a.childItem, a.siblingItem, a.item, b.rootItem) + expect(owner, subagents+a.child+"/items", http.StatusBadRequest, itemCursor, + append([]string{random, a.rootItem, a.siblingItem, a.childTurn, a.item, b.childItem}, malformed...)...) + expect(owner, subagents+a.child+"/turns/"+a.childTurn+"/items", http.StatusBadRequest, itemCursor, + append([]string{random, a.rootItem, a.laterChildItem, a.siblingItem, a.childTurn, b.childItem}, malformed...)...) + + // C3: Subagent and Subagent Turn lists use the resource message. + expect(owner, sessions+a.subSession+"/subagents", http.StatusBadRequest, otherCursor, + append([]string{random, a.rootTurn, a.childTurn, a.otherSubagent, a.session, b.child}, malformed...)...) + expect(owner, subagents+a.child+"/turns", http.StatusBadRequest, otherCursor, + append([]string{random, a.rootTurn, a.siblingTurn, a.otherChildTurn, a.childItem, a.child, b.childTurn}, malformed...)...) + + // C4: Artifact lists use the artifact message. + expect(owner, sessions+a.artifactSession+"/artifacts", http.StatusBadRequest, artifact, + append([]string{random, a.artifactTurn, a.otherArtifact, a.artifactSession, b.artifact, "artifact_e23ad32448f4ce24f27cd05ec953f6920cd9a58115308addee"}, malformed...)...) + + // C5: Skill versions tell a non-version value, another Skill's version and a + // missing version apart; foreign and deleted versions are missing. + versions := "/v1/skills/" + a.skill + "/versions" + for _, value := range []string{"not-a-valid-id", a.skill, random, "3", "SKILLVER_" + random} { + expect(owner, versions, http.StatusBadRequest, prefix(value), value) + } + // Long, unprintable and invalid UTF-8 values are not repeated in the message. + unechoed := wireError("invalid_request_error", text("invalid_value"), text("after"), "Invalid 'after'. Expected an ID that begins with 'skillver'.") + expect(owner, versions, http.StatusBadRequest, unechoed, strings.Repeat("x", 257), strings.Repeat("a", 200<<10), "bad\x01value", strings.Repeat("\x01", 1000), "line\nbreak", "\xff") + expect(owner, versions, http.StatusBadRequest, prefix(strings.Repeat("y", 256)), strings.Repeat("y", 256)) + expect(owner, versions, http.StatusBadRequest, otherSkill, a.otherSkillVersion) + expect(owner, versions, http.StatusNotFound, skillsMissing, "skillver_"+random, a.deletedVersion, b.version, "skillver_not-a-uuid", "skillver", "skillver_"+uuid.Nil.String()) + + // K1: Files and Skills keep their existing cursor errors. + filesMissing := wireError("invalid_request_error", nil, text("after"), "Resource not found.") + expect(owner, "/v1/files", http.StatusNotFound, filesMissing, "file-"+random, "not-a-valid-id", b.file, a.skill) + expect(owner, "/v1/skills", http.StatusNotFound, skillsMissing, "skill_"+random, b.skill, a.version) + + // K2: a valid cursor still returns exactly the next resource in either order, + // with has_more and the first and last IDs of that page. + type envelope struct { + FirstID *string `json:"first_id"` + LastID *string `json:"last_id"` + HasMore *bool `json:"has_more"` + Data []struct{ ID string } `json:"data"` + } + read := func(path string, query url.Values) (envelope, []string) { + t.Helper() + // The Session list is filtered to the two HTTP-created Sessions; the + // store-seeded fixture Sessions lack a public Agent projection. + if path == "/v1/agents/sessions" { + query.Set("agent_id", a.agent) + } + status, raw := client.do(owner, http.MethodGet, path+"?"+query.Encode(), "", nil) + var page envelope + if status != http.StatusOK || json.Unmarshal([]byte(raw), &page) != nil || page.HasMore == nil { + t.Fatalf("GET %s?%s: %d %s", path, query.Encode(), status, raw) + } + ids := make([]string, 0, len(page.Data)) + for _, value := range page.Data { + ids = append(ids, value.ID) + } + return page, ids + } + pages := 0 + for _, path := range []string{ + "/v1/agents", "/v1/agents/sessions", sessions + a.session + "/turns", sessions + a.session + "/items", + "/v1/agents/environments/templates", "/v1/vaults", "/v1/vaults/" + a.vault + "/credentials", + sessions + a.subSession + "/subagents", subagents + a.child + "/items", subagents + a.child + "/turns", + subagents + a.child + "/turns/" + a.childTurn + "/items", sessions + a.artifactSession + "/artifacts", versions, + } { + for _, order := range []string{"asc", "desc"} { + _, all := read(path, url.Values{"order": {order}, "limit": {"100"}}) + if len(all) < 2 { + t.Fatalf("K2 fixture %s has %d resources", path, len(all)) + } + for index, cursor := range all { + page, got := read(path, url.Values{"order": {order}, "limit": {"1"}, "after": {cursor}}) + want := all[index+1 : min(index+2, len(all))] + bounds := page.FirstID == nil && page.LastID == nil + if len(want) == 1 { + bounds = page.FirstID != nil && page.LastID != nil && *page.FirstID == want[0] && *page.LastID == want[0] + } + if !slices.Equal(got, want) || *page.HasMore != (index+2 < len(all)) || !bounds { + t.Errorf("%s order=%s after %d/%d: got %v has_more=%t; want %v", path, order, index, len(all), got, *page.HasMore, want) + } + pages++ + } + } + } + if pages < 52 { + t.Fatalf("K2 checked only %d pages", pages) + } + + // K3: a missing or foreign parent is 404 before any cursor is evaluated. + missingParent := func(token, path string) string { + t.Helper() + status, body := list(token, path, "") + if status != http.StatusNotFound { + t.Fatalf("missing parent %s: %d %s", path, status, body) + } + return body + } + for _, parent := range []struct{ foreign, missing string }{ + {sessions + a.session + "/turns", sessions + random + "/turns"}, + {sessions + a.session + "/items", sessions + random + "/items"}, + {sessions + a.subSession + "/subagents", sessions + random + "/subagents"}, + {subagents + a.child + "/items", sessions + random + "/subagents/" + a.child + "/items"}, + {subagents + a.child + "/turns", sessions + random + "/subagents/" + a.child + "/turns"}, + {subagents + a.child + "/turns/" + a.childTurn + "/items", sessions + random + "/subagents/" + a.child + "/turns/" + a.childTurn + "/items"}, + {sessions + a.artifactSession + "/artifacts", sessions + random + "/artifacts"}, + {"/v1/vaults/" + a.vault + "/credentials", "/v1/vaults/" + random + "/credentials"}, + {versions, "/v1/skills/skill_" + random + "/versions"}, + } { + want := missingParent(foreign, parent.missing) + expect(foreign, parent.foreign, http.StatusNotFound, want, "", "not-a-valid-id", random, a.item, a.childItem, a.version, b.item, b.version) + expect(owner, parent.missing, http.StatusNotFound, want, "not-a-valid-id", a.item, a.version) + } + // A missing Subagent or child Turn inside an owned Session is also 404 first. + for _, path := range []string{subagents + random + "/items", subagents + random + "/turns", subagents + a.child + "/turns/" + random + "/items", subagents + a.sibling + "/turns/" + a.childTurn + "/items"} { + want := missingParent(owner, path) + expect(owner, path, http.StatusNotFound, want, "not-a-valid-id", random, a.childItem, a.rootItem) + } + expect(owner, "/v1/skills/not-a-skill/versions", http.StatusNotFound, skillsMissing, "not-a-valid-id", a.version, a.otherSkillVersion) +} diff --git a/services/agents-api/internal/store/list_cursors.go b/services/agents-api/internal/store/list_cursors.go new file mode 100644 index 000000000..e20f93853 --- /dev/null +++ b/services/agents-api/internal/store/list_cursors.go @@ -0,0 +1,60 @@ +package store + +import ( + "errors" + "fmt" + + "github.com/MiniMax-AI-Dev/parsar/services/agents-api/internal/echotext" +) + +// InvalidCursorError reports a list `after` cursor that does not name a +// resource of that list once its parents have resolved. Message is the list +// family's observed official message; the API layer selects the family's code +// and param. Missing, malformed, other-type, other-parent and foreign cursors +// all produce the same error, so a cursor never reveals another tenant's +// resources. +type InvalidCursorError struct{ Message string } + +func (e *InvalidCursorError) Error() string { return e.Message } + +// Official messages for lists that reject an unresolved cursor with 400. +var ( + // Session Items, Subagent Items and Subagent Turn Items. + errItemCursor = &InvalidCursorError{Message: "Invalid session item ID in `after`"} + // Subagents and Subagent Turns. + errResourceCursor = &InvalidCursorError{Message: "Invalid resource ID in `after`"} + // Session Artifacts. + errArtifactCursor = &InvalidCursorError{Message: "after is not a valid artifact ID"} + // A Skill version of another Skill in the same tenant. + errSkillVersionCursorParent = &InvalidCursorError{Message: "Skill version cursor does not match this skill."} +) + +// skillVersionCursorPrefix reports a Skill version cursor that is not a +// version resource ID at all. The official message echoes the value; a long or +// unprintable value is left out so the error stays bounded. +func skillVersionCursorPrefix(value string) error { + if !echotext.Allowed(value) { + return &InvalidCursorError{Message: "Invalid 'after'. Expected an ID that begins with 'skillver'."} + } + return &InvalidCursorError{Message: fmt.Sprintf("Invalid 'after': '%s'. Expected an ID that begins with 'skillver'.", value)} +} + +// unresolvedCursor replaces a missing cursor resource with the list's cursor +// error and keeps every other failure. +func unresolvedCursor(err, cursor error) error { + if errors.Is(err, ErrNotFound) { + return cursor + } + return err +} + +// lookupCursor resolves a cursor of a list whose unresolved cursor is a 404 +// (Agents, Sessions, Turns, Templates, Vaults and Credentials) like a path +// identifier: a value that cannot name a resource becomes UnknownResourceID, +// so the list follows exactly the missing-cursor path. +func lookupCursor(value string) string { + if _, err := parseID(value); err != nil { + return UnknownResourceID + } + return value +} diff --git a/services/agents-api/internal/store/path_id_semantics_public_test.go b/services/agents-api/internal/store/path_id_semantics_public_test.go index 599b5ece4..c56d7854b 100644 --- a/services/agents-api/internal/store/path_id_semantics_public_test.go +++ b/services/agents-api/internal/store/path_id_semantics_public_test.go @@ -384,11 +384,14 @@ func TestMalformedPathIDsMatchMissingPostgres(t *testing.T) { } } - // Malformed list cursors remain invalid requests rather than missing resources. - for _, list := range []string{"/turns", "/items", "/subagents", "/artifacts"} { + // A malformed list cursor answers like any other unresolved cursor of that + // list: 404 on lookup-family lists and the family's 400 elsewhere (see + // list_cursor_public_test.go). A malformed parent still answers first. + for list, want := range map[string]int{"/turns": 404, "/items": 400, "/subagents": 400, "/artifacts": 400} { status, body := client.do(owner, http.MethodGet, "/v1/agents/sessions/"+session+list+"?after=not-a-uuid", "", nil) - if status != http.StatusBadRequest || !strings.Contains(body, `"code":"invalid_request"`) { - t.Errorf("malformed %s cursor = %d %s", list, status, body) + missingCursorStatus, missingCursorBody := client.do(owner, http.MethodGet, "/v1/agents/sessions/"+session+list+"?after="+missing, "", nil) + if status != want || status != missingCursorStatus || body != missingCursorBody { + t.Errorf("malformed %s cursor = %d %s; missing cursor %d %s", list, status, body, missingCursorStatus, missingCursorBody) } missingStatus, missingBody := client.do(owner, http.MethodGet, "/v1/agents/sessions/"+missing+list+"?after=not-a-uuid", "", nil) status, body = client.do(owner, http.MethodGet, "/v1/agents/sessions/not-a-uuid"+list+"?after=not-a-uuid", "", nil) @@ -396,11 +399,11 @@ func TestMalformedPathIDsMatchMissingPostgres(t *testing.T) { t.Errorf("malformed Session with malformed %s cursor = %d %s; missing %d %s", list, status, body, missingStatus, missingBody) } } - // Top-level cursors keep their existing family behavior; Templates already - // resolved malformed cursors as missing before this change. - for list, want := range map[string]int{"/v1/agents/sessions": 400, "/v1/agents": 400, "/v1/vaults": 400, "/v1/vaults/" + vault + "/credentials": 400, "/v1/agents/environments/templates": 404} { - if status, body := client.do(owner, http.MethodGet, list+"?after=not-a-uuid", "", nil); status != want { - t.Errorf("malformed %s cursor = %d %s; want %d", list, status, body, want) + for _, list := range []string{"/v1/agents/sessions", "/v1/agents", "/v1/vaults", "/v1/vaults/" + vault + "/credentials", "/v1/agents/environments/templates"} { + status, body := client.do(owner, http.MethodGet, list+"?after=not-a-uuid", "", nil) + missingStatus, missingBody := client.do(owner, http.MethodGet, list+"?after="+missing, "", nil) + if status != http.StatusNotFound || status != missingStatus || body != missingBody { + t.Errorf("malformed %s cursor = %d %s; missing %d %s", list, status, body, missingStatus, missingBody) } } if after := databaseDigest(t, pool); !mapsEqual(before, after) { diff --git a/services/agents-api/internal/store/session_artifacts.go b/services/agents-api/internal/store/session_artifacts.go index 426b91aa4..8e9884fc8 100644 --- a/services/agents-api/internal/store/session_artifacts.go +++ b/services/agents-api/internal/store/session_artifacts.go @@ -54,11 +54,18 @@ func (s *Store) ListSessionArtifacts(ctx context.Context, tenantID, sessionID, e params.EnvironmentID = parsePathID(environmentID) } if cursor != "" { - // A malformed cursor remains an invalid request, unlike a path identifier. - if _, err := parseID(cursor); err != nil { - return ArtifactPage{}, err - } + // Any cursor that is not an Artifact of this Session, including a + // malformed one, is an invalid cursor rather than a missing resource. after, err := s.GetSessionArtifact(ctx, tenantID, sessionID, cursor) + if errors.Is(err, ErrNotFound) { + // The Session lookup above is a separate statement: a Session deleted + // since then stays not found. Deleted Sessions never reappear, so an + // existing one here also existed when the cursor was read. + if _, err := s.GetSession(ctx, tenantID, sessionID); err != nil { + return ArtifactPage{}, err + } + return ArtifactPage{}, errArtifactCursor + } if err != nil { return ArtifactPage{}, err } diff --git a/services/agents-api/internal/store/session_artifacts_public_test.go b/services/agents-api/internal/store/session_artifacts_public_test.go index db23b5d0a..5ffc9b0b0 100644 --- a/services/agents-api/internal/store/session_artifacts_public_test.go +++ b/services/agents-api/internal/store/session_artifacts_public_test.go @@ -172,14 +172,16 @@ func TestSessionArtifactListEnvelopeAndEnvironmentFilterPostgres(t *testing.T) { } } } - // Cursor validation and page bounds keep their own errors under any filter. - for _, query := range []url.Values{{"environment_id": {"not-a-uuid"}, "after": {"not-a-uuid"}}, {"environment_id": {"not-a-uuid"}, "limit": {"0"}}} { - if status, raw, _ := list(owner, session, query); status != http.StatusBadRequest { + // Cursor validation and page bounds keep their own errors under any filter; + // malformed and unknown cursors share the Artifact cursor error. + const invalidCursor = `{"error":{"message":"after is not a valid artifact ID","type":"invalid_request_error","code":"invalid_request_error","param":null}}` + "\n" + for _, query := range []url.Values{{"environment_id": {"not-a-uuid"}, "after": {"not-a-uuid"}}, {"environment_id": {"not-a-uuid"}, "after": {uuid.NewString()}}} { + if status, raw, _ := list(owner, session, query); status != http.StatusBadRequest || raw != invalidCursor { t.Errorf("%v: %d %s", query, status, raw) } } - if status, raw, _ := list(owner, session, url.Values{"environment_id": {"not-a-uuid"}, "after": {uuid.NewString()}}); status != http.StatusNotFound { - t.Errorf("unknown cursor with malformed filter: %d %s", status, raw) + if status, raw, _ := list(owner, session, url.Values{"environment_id": {"not-a-uuid"}, "limit": {"0"}}); status != http.StatusBadRequest || raw == invalidCursor { + t.Errorf("limit 0 with malformed filter: %d %s", status, raw) } // Tenant and Session scoping precede the filter: foreign and missing Sessions stay 404. diff --git a/services/agents-api/internal/store/sessions.go b/services/agents-api/internal/store/sessions.go index 696457611..064990247 100644 --- a/services/agents-api/internal/store/sessions.go +++ b/services/agents-api/internal/store/sessions.go @@ -225,11 +225,7 @@ func (s *Store) ListSessions(ctx context.Context, tenantID, cursor string, limit params.AgentID = pgtype.Text{String: *agentID, Valid: true} } if cursor != "" { - // A malformed cursor remains an invalid request, unlike a path identifier. - if _, err := parseID(cursor); err != nil { - return SessionPage{}, err - } - after, err := s.GetSession(ctx, tenantID, cursor) + after, err := s.GetSession(ctx, tenantID, lookupCursor(cursor)) if err != nil { return SessionPage{}, err } @@ -271,7 +267,7 @@ var UnknownResourceID = uuid.Max.String() // parsePathID parses a caller-supplied resource path identifier. A value that // cannot name a resource resolves to UnknownResourceID, so the request follows // exactly the path of a well-formed missing identifier, including validation -// order. List cursors and request-body references keep parseID. +// order. Request-body references keep parseID; see lookupCursor for cursors. func parsePathID(value string) pgtype.UUID { id, err := parseID(value) if err != nil { diff --git a/services/agents-api/internal/store/skills_list.go b/services/agents-api/internal/store/skills_list.go index 12673925d..782da1803 100644 --- a/services/agents-api/internal/store/skills_list.go +++ b/services/agents-api/internal/store/skills_list.go @@ -3,6 +3,7 @@ package store import ( "context" "errors" + "strings" "github.com/MiniMax-AI-Dev/parsar/services/agents-api/internal/db/sqlc" "github.com/jackc/pgx/v5" @@ -65,17 +66,30 @@ func (s *Store) ListSkillVersions(ctx context.Context, tenantID, skillID, after } params := sqlc.ListSkillVersionsParams{TenantID: tenant, SkillID: id, PageLimit: int32(limit + 1), Ascending: ascending} if after != "" { + // A value that is not a version resource ID is invalid; a well-formed + // version missing from this tenant, including another tenant's, is not + // found; another Skill's version in this tenant does not match. + if !strings.HasPrefix(after, "skillver") { + return SkillVersionPage{}, skillVersionCursorPrefix(after) + } cursorID, err := skillResourceID(after, "skillver_") if err != nil { return SkillVersionPage{}, err } - cursor, err := s.queries.GetSkillVersionByID(ctx, sqlc.GetSkillVersionByIDParams{TenantID: tenant, SkillID: id, ID: cursorID}) + cursor, err := s.queries.GetSkillVersionByID(ctx, sqlc.GetSkillVersionByIDParams{TenantID: tenant, ID: cursorID}) if errors.Is(err, pgx.ErrNoRows) { return SkillVersionPage{}, ErrNotFound } if err != nil { return SkillVersionPage{}, err } + if cursor.SkillID != id { + // As for Artifacts, a Skill deleted since its lookup stays not found. + if _, err := s.GetSkill(ctx, tenantID, skillID); err != nil { + return SkillVersionPage{}, err + } + return SkillVersionPage{}, errSkillVersionCursorParent + } params.AfterVersion = pgtype.Int8{Int64: cursor.Version, Valid: true} } rows, err := s.queries.ListSkillVersions(ctx, params) diff --git a/services/agents-api/internal/store/skills_test.go b/services/agents-api/internal/store/skills_test.go index 01b17663e..81122740d 100644 --- a/services/agents-api/internal/store/skills_test.go +++ b/services/agents-api/internal/store/skills_test.go @@ -132,7 +132,8 @@ func TestSkillsOwnershipEncryptionAndVersions(t *testing.T) { if err != nil || next.HasMore || len(next.Versions) != 6 || next.Versions[0].Version != 4 { t.Fatal("version resource cursor", next, err) } - if _, err = s.ListSkillVersions(t.Context(), tenant, created.ID, "3", 20, true); !errors.Is(err, ErrNotFound) { + var cursorErr *InvalidCursorError + if _, err = s.ListSkillVersions(t.Context(), tenant, created.ID, "3", 20, true); !errors.As(err, &cursorErr) || cursorErr.Message != "Invalid 'after': '3'. Expected an ID that begins with 'skillver'." { t.Fatal("numeric version is not a cursor", err) } if _, err = s.UpdateSkillDefault(t.Context(), tenant, created.ID, "999"); !errors.Is(err, ErrNotFound) { diff --git a/services/agents-api/internal/store/subagent_item_reads.go b/services/agents-api/internal/store/subagent_item_reads.go index a113139b8..40bb2ec6f 100644 --- a/services/agents-api/internal/store/subagent_item_reads.go +++ b/services/agents-api/internal/store/subagent_item_reads.go @@ -38,21 +38,16 @@ func (s *Store) listChildItems(ctx context.Context, tenant, session, child, turn p.TurnID = row.ID } if after != "" { - id, err := parseID(after) - if err != nil { - return err - } - row, err := q.GetChildItem(ctx, sqlc.GetChildItemParams{SessionID: sid, SubagentID: childID, ID: id}) - if errors.Is(err, pgx.ErrNoRows) { - return ErrNotFound + // Any cursor outside this child (and Turn) scope, including a + // malformed one, uses the Session Item cursor error. + row, err := q.GetChildItem(ctx, sqlc.GetChildItemParams{SessionID: sid, SubagentID: childID, ID: parsePathID(after)}) + if errors.Is(err, pgx.ErrNoRows) || (err == nil && p.TurnID.Valid && p.TurnID != row.TurnID) { + return errItemCursor } if err != nil { return err } - if p.TurnID.Valid && p.TurnID != row.TurnID { - return ErrNotFound - } - p.AfterID = id + p.AfterID = row.ID p.AfterCreated = row.TurnCreatedAt p.AfterTurn = row.TurnID p.AfterPosition = row.Position diff --git a/services/agents-api/internal/store/subagent_reads.go b/services/agents-api/internal/store/subagent_reads.go index 432625884..5cf2fc353 100644 --- a/services/agents-api/internal/store/subagent_reads.go +++ b/services/agents-api/internal/store/subagent_reads.go @@ -52,13 +52,11 @@ func (s *Store) ListSubagents(ctx context.Context, tenant, session, after string err := s.withPublicSession(ctx, tenant, session, func(ctx context.Context, q *sqlc.Queries, sid pgtype.UUID) error { p := sqlc.ListPublicSubagentsParams{SessionID: sid, Ascending: asc, PageLimit: int32(limit + 1), AfterID: pgtype.UUID{Valid: true}} if after != "" { - // A malformed cursor remains an invalid request, unlike a path identifier. - if _, err := parseID(after); err != nil { - return err - } + // Any cursor that is not a Subagent of this Session, including a + // malformed one, is an invalid cursor rather than a missing resource. cursor, err := publicSubagent(ctx, q, sid, after) if err != nil { - return err + return unresolvedCursor(err, errResourceCursor) } p.AfterOpened = pgtype.Int8{Int64: cursor.OpenedAt, Valid: true} p.AfterID, _ = parseID(after) @@ -153,12 +151,10 @@ func (s *Store) ListSubagentTurns(ctx context.Context, tenant, session, child, a childID, _ := parseID(child) p := sqlc.ListChildTurnsParams{SessionID: sid, SubagentID: childID, Ascending: asc, PageLimit: int32(limit + 1), AfterID: pgtype.UUID{Valid: true}} if after != "" { - if _, err := parseID(after); err != nil { - return err - } + // Root Turns and other children's Turns are outside this list. row, err := childTurn(ctx, q, sid, child, after) if err != nil { - return err + return unresolvedCursor(err, errResourceCursor) } p.AfterCreated = row.CreatedAt p.AfterID = row.ID diff --git a/services/agents-api/internal/store/subagent_resources_test.go b/services/agents-api/internal/store/subagent_resources_test.go index b6b4f79c8..a635ff6ee 100644 --- a/services/agents-api/internal/store/subagent_resources_test.go +++ b/services/agents-api/internal/store/subagent_resources_test.go @@ -130,8 +130,8 @@ func TestSubagentResourcesNativeOwnershipLifecycleAndRecovery(t *testing.T) { if _, err = s.GetSubagentTurn(ctx, tenant, session.ID, nested.ID, tid); !errors.Is(err, ErrNotFound) { t.Fatal("nested ownership", err) } - if _, err = s.ListSubagentItems(ctx, tenant, session.ID, nested.ID, own.Data[0].ID, 20, true); !errors.Is(err, ErrNotFound) { - t.Fatal("foreign cursor", err) + if _, err = s.ListSubagentItems(ctx, tenant, session.ID, nested.ID, own.Data[0].ID, 20, true); !errors.Is(err, errItemCursor) { + t.Fatal("another child's Item cursor", err) } if _, err = s.GetSubagent(ctx, uuid.NewString(), session.ID, child.ID); !errors.Is(err, ErrNotFound) { t.Fatal("foreign tenant", err) diff --git a/services/agents-api/internal/store/turn_reads.go b/services/agents-api/internal/store/turn_reads.go index 7998e6723..f2d3288d2 100644 --- a/services/agents-api/internal/store/turn_reads.go +++ b/services/agents-api/internal/store/turn_reads.go @@ -27,12 +27,8 @@ func (s *Store) ListTurns(ctx context.Context, tenantID, sessionID, cursor strin session, _ := parseID(sessionID) params := sqlc.ListRootTurnsParams{TenantID: tenant, SessionID: session, PageLimit: int32(limit + 1), AfterID: pgtype.UUID{Valid: true}, Ascending: ascending} if cursor != "" { - // A malformed cursor remains an invalid request, unlike a path identifier. - if _, err := parseID(cursor); err != nil { - return TurnPage{}, err - } // A child Turn is not a Session Turn, so its ID is a missing cursor here. - after, err := s.GetTurn(ctx, tenantID, sessionID, cursor) + after, err := s.GetTurn(ctx, tenantID, sessionID, lookupCursor(cursor)) if err != nil { return TurnPage{}, err } diff --git a/services/agents-api/internal/store/vault_credentials_list.go b/services/agents-api/internal/store/vault_credentials_list.go index d63439fff..0f2897485 100644 --- a/services/agents-api/internal/store/vault_credentials_list.go +++ b/services/agents-api/internal/store/vault_credentials_list.go @@ -35,7 +35,7 @@ func (s *Store) ListCredentials(ctx context.Context, tenantID, vaultID, cursor s parent, _ := parseID(vault.ID) params := sqlc.ListCredentialsParams{TenantID: tenant, VaultID: parent, PageLimit: int32(limit + 1), AfterID: pgtype.UUID{Valid: true}, Ascending: ascending, Statuses: statuses} if cursor != "" { - after, err := s.GetCredential(ctx, tenantID, vaultID, cursor) + after, err := s.GetCredential(ctx, tenantID, vaultID, lookupCursor(cursor)) if err != nil { return CredentialPage{}, err } diff --git a/services/agents-api/internal/store/vault_credentials_list_test.go b/services/agents-api/internal/store/vault_credentials_list_test.go index 62f64d889..ee7c5ae1b 100644 --- a/services/agents-api/internal/store/vault_credentials_list_test.go +++ b/services/agents-api/internal/store/vault_credentials_list_test.go @@ -116,7 +116,7 @@ func TestCredentialListFilteringOwnershipAndKeylessReconnect(t *testing.T) { } } for _, tc := range []struct{ owner, vault, cursor string }{ - {tenant, vaults[0].ID, otherVault.ID}, {tenant, vaults[0].ID, otherProject.ID}, {tenant, vaults[0].ID, uuid.NewString()}, + {tenant, vaults[0].ID, otherVault.ID}, {tenant, vaults[0].ID, otherProject.ID}, {tenant, vaults[0].ID, uuid.NewString()}, {tenant, vaults[0].ID, "invalid"}, {tenant, vaults[2].ID, ""}, {foreign, vaults[0].ID, ""}, {tenant, uuid.NewString(), ""}, } { if _, err := reader.ListCredentials(ctx, tc.owner, tc.vault, tc.cursor, 20, false, nil); !errors.Is(err, ErrNotFound) { @@ -133,7 +133,7 @@ func TestCredentialListFilteringOwnershipAndKeylessReconnect(t *testing.T) { owner, vault, cursor string limit int statuses []string - }{{"invalid", vaults[0].ID, "", 20, nil}, {tenant, "invalid", "", 20, nil}, {tenant, vaults[0].ID, "invalid", 20, nil}, {tenant, vaults[0].ID, "", 0, nil}, {tenant, vaults[0].ID, "", 101, nil}, {tenant, vaults[0].ID, "", 20, []string{"deleted"}}} { + }{{"invalid", vaults[0].ID, "", 20, nil}, {tenant, "invalid", "", 20, nil}, {tenant, vaults[0].ID, "", 0, nil}, {tenant, vaults[0].ID, "", 101, nil}, {tenant, vaults[0].ID, "", 20, []string{"deleted"}}} { if _, err := reader.ListCredentials(ctx, tc.owner, tc.vault, tc.cursor, tc.limit, false, tc.statuses); !errors.Is(err, ErrInvalidInput) { t.Fatal("invalid internal query accepted", err) } diff --git a/services/agents-api/internal/store/vaults_list.go b/services/agents-api/internal/store/vaults_list.go index cb2c30033..22cc1a014 100644 --- a/services/agents-api/internal/store/vaults_list.go +++ b/services/agents-api/internal/store/vaults_list.go @@ -32,7 +32,7 @@ func (s *Store) ListVaults(ctx context.Context, tenantID, cursor string, limit i } params := sqlc.ListVaultsParams{TenantID: tenant, PageLimit: int32(limit + 1), AfterID: pgtype.UUID{Valid: true}, Ascending: ascending, Statuses: statuses} if cursor != "" { - after, err := s.GetVault(ctx, tenantID, cursor) + after, err := s.GetVault(ctx, tenantID, lookupCursor(cursor)) if err != nil { return VaultPage{}, err } diff --git a/services/agents-api/internal/store/vaults_list_test.go b/services/agents-api/internal/store/vaults_list_test.go index 7ca6bd067..cd574a4b0 100644 --- a/services/agents-api/internal/store/vaults_list_test.go +++ b/services/agents-api/internal/store/vaults_list_test.go @@ -92,16 +92,16 @@ func TestVaultListFilteringPaginationAndReconnect(t *testing.T) { } } } - for _, cursor := range []string{foreign.ID, uuid.NewString()} { + for _, cursor := range []string{foreign.ID, uuid.NewString(), "invalid"} { if _, err := s.ListVaults(ctx, tenant, cursor, 20, true, []string{"archived"}); !errors.Is(err, ErrNotFound) { - t.Fatalf("foreign/unknown cursor: %v", err) + t.Fatalf("foreign/unknown/malformed cursor: %v", err) } } for _, tc := range []struct { tenant, cursor string limit int statuses []string - }{{"invalid", "", 20, nil}, {tenant, "invalid", 20, nil}, {tenant, "", 0, nil}, {tenant, "", 101, nil}, {tenant, "", 20, []string{"deleted"}}} { + }{{"invalid", "", 20, nil}, {tenant, "", 0, nil}, {tenant, "", 101, nil}, {tenant, "", 20, []string{"deleted"}}} { if _, err := s.ListVaults(ctx, tc.tenant, tc.cursor, tc.limit, true, tc.statuses); !errors.Is(err, ErrInvalidInput) { t.Fatalf("invalid store query: %v", err) } diff --git a/services/agents-api/tests/official_agent_list.py b/services/agents-api/tests/official_agent_list.py index dcda38b9d..3ea64fb3d 100644 --- a/services/agents-api/tests/official_agent_list.py +++ b/services/agents-api/tests/official_agent_list.py @@ -42,11 +42,15 @@ def verify_agent_list(client, other, invalid, saved, expect_error): assert len(default["data"]) == min(20, len(saved)) and default["has_more"] == (len(saved) > 20) assert [agent["id"] for agent in default["data"]] == [agent.id for agent in desc[:20]] for params in ({"limit": "-1"}, {"limit": "1.5"}, {"limit": "null"}, - {"limit": ""}, {"limit": str(2**63)}, {"order": "newest"}, {"after": "invalid-id"}, + {"limit": ""}, {"limit": str(2**63)}, {"order": "newest"}, [("limit", "1"), ("limit", "2")]): response = raw.get(url, headers=headers, params=params) assert response.status_code == 400, (params, response.status_code) assert response.json()["error"]["type"] == "invalid_request_error" + # A malformed cursor is a missing one. + missing = raw.get(url, headers=headers, params={"after": str(uuid.uuid4())}) + malformed = raw.get(url, headers=headers, params={"after": "invalid-id"}) + assert missing.status_code == malformed.status_code == 404 and missing.json() == malformed.json() zero = raw.get(url, headers=headers, params={"limit": "0", "tenant_id": "other"}).json() assert [agent["id"] for agent in zero["data"]] == [desc[0].id] and zero["has_more"] is True assert raw.get(url).status_code == 401 diff --git a/services/agents-api/tests/official_client.py b/services/agents-api/tests/official_client.py index 934bef867..f95b39834 100644 --- a/services/agents-api/tests/official_client.py +++ b/services/agents-api/tests/official_client.py @@ -241,14 +241,14 @@ def expect_error(error, operation): expect_error(NotFoundError, lambda: turns.retrieve(turn_ids[0], session_id=first.id)) expect_error(NotFoundError, lambda: turns.list(first.id, after=turn_ids[0])) expect_error(BadRequestError, lambda: turns.list(turn_session.id, limit=101)) - # Unparsable identifiers share the missing-resource response; - # malformed list cursors remain invalid requests. + # Unparsable identifiers share the missing-resource response, + # and so does a malformed Turn list cursor. for malformed in ("sess_" + uuid.uuid4().hex, "invalid"): expect_error(NotFoundError, lambda: sessions.retrieve(malformed)) expect_error(NotFoundError, lambda: turns.list(malformed)) expect_error(NotFoundError, lambda: sessions.items.list(malformed)) expect_error(NotFoundError, lambda: turns.retrieve("turn_" + uuid.uuid4().hex, session_id=turn_session.id)) - expect_error(BadRequestError, lambda: turns.list(turn_session.id, after="invalid")) + expect_error(NotFoundError, lambda: turns.list(turn_session.id, after="invalid")) saved_items = verify_items(a, b, invalid, turn_session.id, first.id, turn_ids, expect_error) request_sessions.append(verify_active_session_metadata(a, turn_session.id)) referenced, reference_retry = verify_agent_references(a, b, expect_error) diff --git a/services/agents-api/tests/official_credential_list.py b/services/agents-api/tests/official_credential_list.py index 5dd6f2d4d..461925ece 100644 --- a/services/agents-api/tests/official_credential_list.py +++ b/services/agents-api/tests/official_credential_list.py @@ -135,11 +135,12 @@ def safe_error(response, status): safe_error(raw.get(base + owner + "/credentials", headers=headers), 404) expect_error(NotFoundError, lambda: credentials.list(owner)) for owner, cursor in ((vault.id, sibling.id), (vault.id, foreign.id), - (empty_vault.id, expected[0].id), (vault.id, str(uuid.uuid4()))): + (empty_vault.id, expected[0].id), (vault.id, str(uuid.uuid4())), + (vault.id, "invalid-credential")): safe_error(raw.get(base + owner + "/credentials", headers=headers, params={"after": cursor}), 404) expect_error(NotFoundError, lambda: credentials.list(owner, after=cursor)) invalid_queries = [ - {"after": "invalid-credential"}, {"status": "unknown"}, {"limit": "null"}, + {"status": "unknown"}, {"limit": "null"}, {"order": "invalid"}, [("status", "active"), ("status", "archived")], [("status", "active"), ("status[]", "unknown")], ] diff --git a/services/agents-api/tests/official_items.py b/services/agents-api/tests/official_items.py index 8d6963f7b..26333f5ef 100644 --- a/services/agents-api/tests/official_items.py +++ b/services/agents-api/tests/official_items.py @@ -13,7 +13,8 @@ def verify_items(a, b, invalid, session, peer_session, turns, expect_error): peer_items = list(items.list(peer_session)) assert len(peer_items) == 1 and peer_items[0].role == "user" expect_error(NotFoundError, lambda: b.beta.agents.sessions.items.list(session)) - expect_error(NotFoundError, lambda: items.list(peer_session, after=recovered[0].id)) + # Another Session's Item is an invalid cursor, not a missing resource. + expect_error(BadRequestError, lambda: items.list(peer_session, after=recovered[0].id)) expect_error(AuthenticationError, lambda: invalid.beta.agents.sessions.items.list(session)) assert items.list(session, limit=101).data == items.list(session, limit=100).data assert items.list(session, limit=0, order="asc").data == [recovered[0]] diff --git a/services/agents-api/tests/official_list_query.py b/services/agents-api/tests/official_list_query.py index 291be4d49..21d79b18f 100644 --- a/services/agents-api/tests/official_list_query.py +++ b/services/agents-api/tests/official_list_query.py @@ -2,13 +2,14 @@ Owned fixtures use real Worker admission with dispatch paused. No native executor or model runs, and initial Turn/Item history remains visible throughout the test. -The tolerance checks follow contracts/agents-api/list-query-semantics.md. +The tolerance and cursor checks follow contracts/agents-api/list-query-semantics.md. """ import importlib.metadata import json import secrets import sys +import uuid from contextlib import ExitStack from pathlib import Path @@ -32,6 +33,128 @@ } +# Unresolved `after` cursors (cursor error batch rows C1, C2, C5 and K1). +LOOKUP_MISSING = {"message": "Resource not found.", "type": "not_found_error", "code": "not_found_error", "param": None} +SKILLS_MISSING = {"message": "Resource not found.", "type": "invalid_request_error", "code": None, "param": None} +FILES_MISSING = {"message": "Resource not found.", "type": "invalid_request_error", "code": None, "param": "after"} +ITEM_CURSOR = {"message": "Invalid session item ID in `after`", "type": "invalid_request_error", + "code": "invalid_request_error", "param": None} +OTHER_SKILL_VERSION = {"message": "Skill version cursor does not match this skill.", "type": "invalid_request_error", + "code": "invalid_value", "param": "after"} + + +def version_prefix_error(value): + return {"message": f"Invalid 'after': '{value}'. Expected an ID that begins with 'skillver'.", + "type": "invalid_request_error", "code": "invalid_value", "param": "after"} + + +def verify_cursors(raw, base, token, foreign, client, owned, session_id, vault_id, cleanup): + """Checks unresolved cursors through raw HTTP and the SDK for tenants A and B. + + Tenant B owns one resource per family, so its IDs are real foreign cursors, + and tenant A's IDs are foreign cursors for B. Subagent and Artifact lists need + seeded execution history and are covered by the Go PostgreSQL tests. + """ + checks = 0 + other = cleanup.enter_context(OpenAI(api_key=foreign, base_url=base + "/v1", max_retries=0, + http_client=DefaultHttpxClient(trust_env=False))) + b_agent = other.beta.agents.create(model="query-fixture-model", name="Foreign Cursor Agent") + cleanup.callback(other.beta.agents.delete, b_agent.id) + b_template = other.beta.agents.environments.templates.create(name="Foreign Cursor Template") + cleanup.callback(other.beta.agents.environments.templates.delete, b_template.id) + b_vault = other.beta.agents.vaults.create(name="Foreign Cursor Vault") + cleanup.callback(other.beta.agents.vaults.delete, b_vault.id) + b_credential = other.beta.agents.vaults.credentials.create( + b_vault.id, name="Foreign Cursor Credential", + auth={"type": "static_bearer", "mcp_server_url": "https://query.example.invalid/mcp", "token": "foreign-" + secrets.token_hex(8)}) + b_session = other.beta.agents.sessions.create(agent_id=b_agent.id, environment={"type": "none"}, input="Foreign cursor history.") + cleanup.callback(other.beta.agents.sessions.delete, b_session.id) + other.beta.agents.sessions.events.create(b_session.id, events=[{"type": "agent.session.input.cancel"}]) + b_turn = other.beta.agents.sessions.turns.list(b_session.id).data[0].id + b_item = other.beta.agents.sessions.items.list(b_session.id).data[0].id + b_file = other.files.create(file=("foreign-cursor.txt", b"Foreign cursor fixture"), purpose="user_data") + cleanup.callback(other.files.delete, b_file.id) + manifest = b"---\nname: foreign-cursor\ndescription: Foreign cursor fixture.\n---\nCursor.\n" + b_skill = other.skills.create(files=[("foreign-cursor/SKILL.md", manifest, "text/markdown")]) + cleanup.callback(other.skills.delete, b_skill.id) + b_version = other.skills.versions.list(b_skill.id).data[0].id + + agents, sessions, vaults = client.beta.agents, client.beta.agents.sessions, client.beta.agents.vaults + skill = owned["skills"][0] + first_version = client.skills.versions.list(skill, order="asc").data[0].id + manifest = b"---\nname: query-0\ndescription: Second owned version.\n---\nCursor.\n" + later_version = client.skills.versions.create(skill, files=[("query-0/SKILL.md", manifest, "text/markdown")]).id + other_version = client.skills.versions.list(owned["skills"][1]).data[0].id + other_turn = sessions.turns.list(owned["sessions"][1]).data[0].id + other_item = sessions.items.list(owned["sessions"][1]).data[0].id + + def expect(key, path, status, error, cursors, listing=None, beta=True): + nonlocal checks + headers = {"Authorization": "Bearer " + key} + if beta: + headers["OpenAI-Beta"] = "agents=v1" + for cursor in cursors: + response = raw.get(base + "/v1" + path, headers=headers, params={"after": cursor}) + assert response.status_code == status and response.json() == {"error": error}, (path, cursor, response.status_code, response.text) + if listing is not None: + try: + listing(after=cursor) + except APIStatusError as sdk_error: + assert sdk_error.status_code == status and sdk_error.body == error, (path, cursor, sdk_error.body) + else: + raise AssertionError(f"SDK accepted {path} cursor {cursor}") + checks += 1 + + random = str(uuid.uuid4()) + malformed = ["not-a-valid-id", str(uuid.UUID(int=0))] + # C1: lookup-family lists answer malformed, other-type, other-parent and foreign + # cursors exactly like a missing one. + lookups = [ + ("/agents", agents.list, ["agent_" + secrets.token_hex(25), owned["sessions"][0], b_agent.id]), + ("/agents/environments/templates", agents.environments.templates.list, ["envtmpl_" + secrets.token_hex(25), owned["vaults"][0], b_template.id]), + ("/agents/sessions", sessions.list, ["sess_" + secrets.token_hex(25), owned["agents"][0], b_session.id]), + (f"/agents/sessions/{session_id}/turns", lambda **q: sessions.turns.list(session_id, **q), ["turn_" + secrets.token_hex(25), owned["items"][0], other_turn, b_turn]), + ("/vaults", vaults.list, ["vault_" + secrets.token_hex(25), owned["credentials"][0], b_vault.id]), + (f"/vaults/{vault_id}/credentials", lambda **q: vaults.credentials.list(vault_id, **q), ["credential_" + secrets.token_hex(25), vault_id, b_credential.id]), + ] + for path, listing, cursors in lookups: + expect(token, path, 404, LOOKUP_MISSING, [random, *malformed, *cursors], listing) + for path, _, _ in lookups[:3] + lookups[4:5]: + expect(foreign, path, 404, LOOKUP_MISSING, [owned["agents"][0], owned["sessions"][0], owned["templates"][0], owned["vaults"][0]]) + expect(foreign, f"/agents/sessions/{b_session.id}/turns", 404, LOOKUP_MISSING, owned["turns"]) + expect(foreign, f"/vaults/{b_vault.id}/credentials", 404, LOOKUP_MISSING, owned["credentials"]) + # C2: any cursor that is not an Item of this Session. + items = lambda **q: sessions.items.list(session_id, **q) + expect(token, f"/agents/sessions/{session_id}/items", 400, ITEM_CURSOR, + [random, *malformed, "msg_" + secrets.token_hex(25), owned["turns"][0], other_item, b_item], items) + expect(foreign, f"/agents/sessions/{b_session.id}/items", 400, ITEM_CURSOR, owned["items"]) + # C5: Skill versions tell non-version values, other Skills' versions and missing versions apart. + versions = lambda **q: client.skills.versions.list(skill, **q) + for value in ("not-a-valid-id", skill, random): + expect(token, f"/skills/{skill}/versions", 400, version_prefix_error(value), [value], versions, beta=False) + # A long or unprintable value is not repeated in the message. + unechoed = {**version_prefix_error(""), "message": "Invalid 'after'. Expected an ID that begins with 'skillver'."} + expect(token, f"/skills/{skill}/versions", 400, unechoed, ["x" * 300, "bad\x01value"], versions, beta=False) + expect(token, f"/skills/{skill}/versions", 400, OTHER_SKILL_VERSION, [other_version], versions, beta=False) + expect(token, f"/skills/{skill}/versions", 404, SKILLS_MISSING, ["skillver_" + random, "skillver_not-a-uuid", b_version], versions, beta=False) + expect(foreign, f"/skills/{b_skill.id}/versions", 404, SKILLS_MISSING, [first_version, later_version], beta=False) + assert [value.id for value in versions(order="asc", after=first_version)] == [later_version] + # K1: Files and Skills keep their missing-cursor errors. + expect(token, "/files", 404, FILES_MISSING, ["file-" + secrets.token_hex(12), "not-a-valid-id", b_file.id], client.files.list, beta=False) + expect(token, "/skills", 404, SKILLS_MISSING, ["skill_" + random, "not-a-valid-id", b_skill.id], client.skills.list, beta=False) + # K3: a foreign or missing parent is 404 before its cursor is read. + for foreign_path, missing_path, beta in ( + (f"/agents/sessions/{session_id}/turns", f"/agents/sessions/{random}/turns", True), + (f"/agents/sessions/{session_id}/items", f"/agents/sessions/{random}/items", True), + (f"/vaults/{vault_id}/credentials", f"/vaults/{random}/credentials", True), + (f"/skills/{skill}/versions", f"/skills/skill_{random}/versions", False)): + headers = {"Authorization": "Bearer " + foreign, **({"OpenAI-Beta": "agents=v1"} if beta else {})} + missing = raw.get(base + "/v1" + missing_path, headers=headers) + assert missing.status_code == 404, missing_path + expect(foreign, foreign_path, 404, missing.json()["error"], ["not-a-valid-id", owned["items"][0], b_item, first_version], beta=beta) + return checks + + def verify_tolerance(raw, url, headers, foreign_headers, name, listing, owned, policy, nested, private): """Checks unknown/repeated keys and limit policy for one family as tenant A and B.""" checks = 0 @@ -340,8 +463,9 @@ def main(): assert {name: [value.to_dict() for value in listing(order="asc", limit=1)] for name, _, listing, _, _ in families} == state_before assert list(sessions.turns.list(session_id, order="asc")) == turns assert list(sessions.items.list(session_id, order="asc")) == items + cursors = verify_cursors(raw, base, token, foreign, client, owned, session_id, vault_id, cleanup) resources = verify_resource_queries(raw, base, token, foreign, client, owned, session_id, vault_id) - print(json.dumps({"single_resource_checks": resources, "result": "passed", "families": len(families), "sdk_and_raw_rejections": rejected, "tolerance_checks": tolerated, + print(json.dumps({"single_resource_checks": resources, "cursor_checks": cursors, "result": "passed", "families": len(families), "sdk_and_raw_rejections": rejected, "tolerance_checks": tolerated, "sdk_empty_order_omitted": len(families), "retained_turns": len(turns), "retained_items": len(items), "postgres": True, "worker_admission": True, "native_model_execution": False})) diff --git a/services/agents-api/tests/official_vault_list.py b/services/agents-api/tests/official_vault_list.py index 2fc38d7a2..17ab9e6f6 100644 --- a/services/agents-api/tests/official_vault_list.py +++ b/services/agents-api/tests/official_vault_list.py @@ -104,7 +104,10 @@ def sdk_page(values, has_more, query, **request): response = raw.get(endpoint, headers=headers, params={"after": foreign.id}) assert response.status_code == 404 and response.json()["error"]["code"] == "not_found_error" assert foreign.id not in response.text - for params in ({"after": "invalid-vault"}, {"status": "unknown"}, {"limit": "null"}): + # A malformed cursor is a missing one. + invalid_cursor = raw.get(endpoint, headers=headers, params={"after": "invalid-vault"}) + assert invalid_cursor.status_code == 404 and invalid_cursor.json() == response.json() + for params in ({"status": "unknown"}, {"limit": "null"}): response = raw.get(endpoint, headers=headers, params=params) assert response.status_code == 400 assert response.json()["error"]["type"] == "invalid_request_error"