diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 90aa133bc..1627380a1 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -45,6 +45,8 @@ and Clippy, and Linux OpenSSL development libraries. `make sqlc-generate` owns o migrations. The public protocol schema is `contracts/agents-api/openapi.yaml`; there is no product swaggo contract in this repository. Preserve its pinned types, coverage ledgers and official SDK/raw HTTP tests when changing API behavior. +Run `make openapi` after handler annotation changes. It reuses the original +Core-only swaggo v1.16.4 generator and writes this schema, without product routes. Core changes must retain the independent build and official-client workflow. Changes to native Harness sources require `make check-agents-harness-native`, @@ -248,7 +250,7 @@ Completed environments never reinstall initial files on reconnect or native reco Provider RunCommand carries bounded stdin, not confidential argv. Only fixed trusted initializers may run with Runtime authority. User setup and package install hooks run in the common packaged sandbox, without daemon credentials or native history. -Files and inline Skills precede system, npm/Python packages and ordered setup commands. Initialization has +Files and resolved Skills precede system, npm/Python packages and ordered setup commands. Initialization has provisioning network access; requested network restrictions apply to native tools after setup. Confidential env and setup snapshots are encrypted independently of ordinary metadata. Adapters apply tool env only after isolation, never to the @@ -271,7 +273,27 @@ Core preserves the system-package requirement in the common execution binding; a missing installation receipt fails preparation instead of falling back to base tools. This requirement does not add execution prerequisites to Files reads. -Inline Skill ZIPs use the same confidential initialization snapshot and installer. +Skills and their immutable versions are Core-owned tenant resources, independent of +Sessions and native Skill installations. Serialize version allocation and pointer +mutations under the owning Skill row; preserve unique version identities across +concurrent uploads and deletion. Metadata reads never load or decrypt bundle bytes. +Encrypt bundle contents with a tenant, Skill and version binding using the existing +service cipher. Deleting a Skill reclaims its versions without affecting already +frozen Session initialization. Public reference metadata, unresolved template intent +and the resolved Runtime bundle are distinct; do not report a reference as inline +merely because it reuses the same installer. No compatibility reader, source +cache, extra lifecycle owner or per-harness resource implementation is required. +Resolve references inside the Session creation transaction, after the creation +upsert establishes ownership. Lock referenced resources in a stable order; freeze +the selected version, descriptive metadata and bytes together. Creation retries +recover the recorded intent before reading mutable templates or Skill sources. +Templates preserve omitted/default, latest and explicit version selectors. Session +responses contain concrete versions; only validated installation metadata crosses +the Runtime boundary. A supplied Session Skill list replaces the template list; +omission inherits. Explicit null reference selectors and null list overrides remain +unqualified and reject rather than silently changing selection. + +Inline and referenced Skill ZIPs use the same confidential initialization snapshot and installer. Core validates portable manifests and bounded regular-file archives, returns only safe Skill metadata, and freezes content before native preparation. The Runtime owns `/environment/initialization/capabilities/skills/`; setup and native tools may read but @@ -279,11 +301,11 @@ not modify this tree. The common execution descriptor carries Skill metadata, never native plugin configuration or template identities. Adapters register native Skill roots without changing the execution loop or enabling unrestricted tools. Native activation extensions remain adapter-owned and must fail explicitly when -unqualified. Skills API references, generic Plugins and capability-directory +unqualified. Generic Plugins and capability-directory imports remain separate work; an adapter-owned Claude plugin envelope does not implement public Plugins. -Name, enabled/disabled/exact-domain restricted network, initial files, inline Skills and env/setup/system/npm/Python are +Name, enabled/disabled/exact-domain restricted network, initial files, inline/referenced Skills and env/setup/system/npm/Python are implemented independently of remaining installation fields. Reject unsupported inputs rather than persisting them for silent omission; expand inline and template initialization together in separately qualified diff --git a/Makefile b/Makefile index 258cf8f5a..3254dd3ad 100644 --- a/Makefile +++ b/Makefile @@ -1,6 +1,7 @@ SHELL := /bin/bash SQLC_VERSION ?= v1.29.0 SQLC ?= go run github.com/sqlc-dev/sqlc/cmd/sqlc@$(SQLC_VERSION) +SWAG_VERSION ?= v1.16.4 .PHONY: help check check-database check-go check-sqlc sqlc-generate node-deps check-claude-sdk check-mcode-harness build-daemon build-agents-api build-agents-api-release check-agents-api docker-build-agents-api check-agents-api-container build-agents-executor check-agents-executor build-agents-harness check-agents-harness check-agents-harness-native build-agents-runtime build-claude-runtime build-claude-sdk-runtime build-mcode-harness build-mcode-runtime @@ -16,6 +17,16 @@ check-database: sqlc-generate: cd services/agents-api && $(SQLC) generate +.PHONY: openapi +openapi: + @set -e; root="$${PARSAR_HOME:-$$HOME/.parsar}/build"; mkdir -p "$$root"; \ + output=$$(mktemp -d "$$root/core-openapi.XXXXXX"); trap 'rm -rf "$$output"' EXIT; \ + go run github.com/swaggo/swag/cmd/swag@$(SWAG_VERSION) init \ + -g cmd/server/main.go --dir ./services/agents-api,./contracts/agents-api/v1 \ + --exclude ./services/agents-api/internal/executor --output "$$output" \ + --outputTypes yaml --parseInternal; \ + mv "$$output/swagger.yaml" contracts/agents-api/openapi.yaml + check-sqlc: python3 scripts/check-sqlc.py diff --git a/contracts/agents-api/README.md b/contracts/agents-api/README.md index 57be10a8d..659448aa7 100644 --- a/contracts/agents-api/README.md +++ b/contracts/agents-api/README.md @@ -64,7 +64,8 @@ This inventory is based on the pinned Python source, not our generated OpenAPI. It contains 42 distinct HTTP operations in 15 resource classes, excluding async duplicates, overloads and client-side helpers. There are 36 handler entries; the six Subagent read operations remain missing. The separate -general `/v1/files` source-file API is outside this 42-operation count. +general `/v1/files` source-file API and `/v1/skills` resource/version operations +are outside this 42-operation count. An implemented route is not complete semantic compatibility. **Accepted** below means a recorded workflow passed under a specific profile; **partial** means some @@ -72,12 +73,14 @@ variants work; **missing** means no implementation; **unverified** means behavio has not been shown to match upstream. Do not convert the route count into a compatibility percentage or treat a Docker result as E2B qualification. -Paths below are SDK resource paths beneath `client.beta.agents`. Method names use -the Python SDK. Vault HTTP paths start at `/vaults`, not `/agents/vaults`. +Paths below are SDK resource paths beneath `client.beta.agents`, except Skills +and Versions under `client.skills`. Method names use the Python SDK. Vault HTTP +paths start at `/vaults`, not `/agents/vaults`. | Resource | Upstream operations | Current coverage | | --- | --- | --- | | Root reusable Agents | create, retrieve, update, list, delete | Partial create/retrieve/update/list/delete and Session references; configuration/error gaps remain | +| Skills and Versions | create, retrieve, update default, list, delete, content | [Tenant-owned encrypted bundles and hosted references](environment-templates.md); qualified upload limits and unresolved hosted semantics are recorded explicitly | | sessions | create, retrieve, update, list, delete | Create (ordinary/live), retrieve, list with root-Agent filter, metadata-only update, public deletion with owned Docker/E2B cleanup; general physical cleanup and exact hosted semantics remain open | | sessions.events | create, stream | Text/cancel/function-result admission and live events; function-action state snapshots supported | | sessions.turns | retrieve, list | Implemented reads; lifecycle conformance still partial | @@ -89,7 +92,7 @@ the Python SDK. Vault HTTP paths start at `/vaults`, not `/agents/vaults`. | sessions.subagents.turns.items | list | Missing | | environments | retrieve | Supported Codex self-hosted and three-harness Docker/E2B hosted profiles: durable status and safe initial-file metadata; other installation inventory and full lifecycle parity remain gaps | | environments.files | create, list | [Bounded live listing and inline/source-file creation](environment-files.md) on qualified Docker/E2B workspaces; Codex self-hosted listing is a separate supported path. Full listing, overwrite and error semantics remain partial | -| environments.templates | create, retrieve, update, list, delete | [Reusable network, files, env/setup/packages, inline Skills and Session snapshots](environment-templates.md); other initialization and full semantics remain gaps | +| environments.templates | create, retrieve, update, list, delete | [Reusable network, files, env/setup/packages, inline/referenced Skills and Session snapshots](environment-templates.md); other initialization and full semantics remain gaps | | vaults | create, retrieve, list, delete | Create/retrieve/list/delete with independent tenant persistence, stored status filtering, atomic Credential cascade and frozen Session attachments; archive semantics and full hosted lifecycle parity remain missing | | vaults.credentials | create, retrieve, update, list, delete | Static-bearer create/retrieve/list/token replacement/deletion with scoped encrypted storage; Session attachment and exact-URL HTTPS MCP binding; OAuth, archive semantics and full hosted lifecycle parity remain missing | @@ -155,7 +158,7 @@ user-managed enrollment remain outside this qualification. | Area | Missing or unverified scope | | --- | --- | | Subagents / multi_agent | Six public child read operations, enabled execution, child lifecycle/interactions and full recovery; deferred outside the MVP | -| Environment Templates | Skills references, Plugins, capability directories, unsupported restricted hostname forms, installation overrides/null network and exact hosted errors; CRUD/list, files, env/setup/system/npm/Python, inline Skills and Session references are supported | +| Environment Templates | Plugins, capability directories, unsupported restricted hostname forms, installation overrides/null network and exact hosted errors; CRUD/list, files, env/setup/system/npm/Python, inline/referenced Skills and Session references are supported | | Input and configuration | Non-text initial input, broader content/configuration unions, structured output and reasoning/verbosity combinations | | Tools and interactions | Deferred functions, other tool types, effective tool-set enforcement and result/cancel publication ordering; MiniMax public functions/MCP remain unsupported | | Vault and Credentials | OAuth/refresh, archive semantics, revocation/concurrent mutation and exact hosted selection/error behavior; static bearer CRUD/token replacement is already present | diff --git a/contracts/agents-api/environment-templates.md b/contracts/agents-api/environment-templates.md index 60b42d9c7..6c09a7884 100644 --- a/contracts/agents-api/environment-templates.md +++ b/contracts/agents-api/environment-templates.md @@ -18,7 +18,7 @@ and five-operation SandboxProvider path as inline configuration. or network replaces, with null clearing name or resetting network. - Empty/null installation fields retain empty defaults. Responses contain safe metadata and never `env`, `setup_commands` or inline file data. Initial files are - supported as described below, together with inline Skills, env, ordered setup and system/npm/Python packages; remaining populated installations reject explicitly. + supported as described below, together with inline/referenced Skills, env, ordered setup and system/npm/Python packages; remaining populated installations reject explicitly. - Listing uses `after`, `limit` (1–100, default 20), and `order` (default `desc`). Creation timestamp plus ID supplies stable local ordering. Missing/foreign IDs and cursors return the same not-found result. No compute is allocated by CRUD. @@ -86,9 +86,48 @@ native-history recovery preserve user modifications instead of reinstalling file Docker/E2B and all three harnesses use this same lifecycle. The Provider API remains five operations; public Templates are never E2B image templates. -## Inline Skills +## Skills and versioned references -Both templates and standalone hosted configuration accept inline Skill ZIPs: +Both templates and standalone hosted configuration accept project-owned Skill +references and inline Skill ZIPs. Upload a directory through the pinned SDK, then +reference its default version from a template: + +```python +skill = client.skills.create(files=[ + ("report/SKILL.md", b"---\nname: report\ndescription: Create the report.\n---\nFollow the report procedure.", "text/markdown"), +]) +template = client.beta.agents.environments.templates.create( + skills=[{"type": "skill_reference", "skill_id": skill.id}] +) +session = client.beta.agents.sessions.create( + agent={"model": "your-configured-model"}, + environment={"type": "openai_hosted", "environment_template_id": template.id}, + input="Use the report Skill.", +) +``` + +Core exposes the pinned `/v1/skills` resource, version and content operations using +ordinary project bearer authentication. No Agents beta header is required on those +resource routes. Metadata reads do not decrypt or fetch bundles. Content is encrypted +with its tenant, Skill and immutable version identity. ZIP uploads use `files` and +directory uploads use repeated `files[]`; the fixed SDK directory form above works. +SDK 3.13.0 drops a single FileTypes tuple during multipart extraction before sending +it. Use raw HTTP for a single ZIP with this fixed client; Core does not synthesize +missing bytes or alter the pinned SDK. + +Templates preserve reference selectors: omission selects default at Session +creation, `"latest"` selects latest, and a positive version string selects that +version. A Session freezes tenant-authorized bytes and concrete version metadata +in its creation transaction. Later source deletion, default changes or template +updates cannot change that Session or its committed creation retry. A supplied +Session Skill list replaces the template list; omission inherits. Explicit null +reference versions and null list overrides are not qualified and reject. +References return type/skill_id/version/name/description in Session metadata, +while template responses retain unresolved selectors. Confidential bundle content +never appears in these metadata responses. The common Runtime installation path +receives frozen files and descriptive metadata, without source or template IDs. + +Inline Skill ZIPs use the same initializer: ```python import base64 @@ -113,11 +152,10 @@ and invalid manifests reject. Content is inert during installation; executable files retain their executable bit. These operational limits are not claims about upstream limits. -Responses contain only type/name/description. Archive content stays in encrypted, +Inline metadata contains only type/name/description. Archive content stays in encrypted, resource-bound template and Session snapshots. Updates replace supplied `skills`; omission preserves and null/[] clears. Existing Sessions retain their frozen -content after template update/deletion. A template reference with an explicit -Skills override rejects pending confirmation of upstream merge semantics. +content after template update/deletion. The shared initializer installs Skills under `/environment/initialization/capabilities/skills/` before setup and native execution. @@ -131,8 +169,7 @@ isolated workspace tool worker. No Provider or model/tool loop is added. Codex nested `SKILL.md` discovery, `agents/openai.yaml` native dependency configuration and Claude inline/fenced shell preprocessing are not qualified in this batch and explicitly fail adapter preparation. Other files are not interpreted as a public plugin installation. -Public `skill_reference`, `/v1/skills` version resolution, generic Plugins and -capability-directory imports remain separate gaps. Native built-in Skill visibility +Generic Plugins and capability-directory imports remain separate gaps. Native built-in Skill visibility is not evidence of exact public tool-set parity. Qualification probes alone do not establish complete public support; record real service acceptance separately. @@ -233,9 +270,9 @@ policy on recovery; resource tests alone do not establish execution compatibilit ## Explicit gaps and evidence boundaries -Nonempty `capability_directories` and `plugins`, and Skills API references, -remain unsupported -for both templates and inline initialization. The separate live Files API remains +Nonempty `capability_directories` and `plugins` remain unsupported +for both templates and inline initialization. Skill references use the shared +initialization flow described above. The separate live Files API remains available after initialization. Unsupported requests reject without echoing payloads. The [hosted guide](https://developers.openai.com/api/docs/guides/agents-api/environments/openai-hosted) @@ -247,7 +284,7 @@ Template updates replace each supplied field; omission preserves it and null cle it. Referenced Sessions inherit the snapshot; explicit env/packages/setup overrides with a template ID reject while override semantics remain unconfirmed. -Files and inline Skills are installed first, followed by system, npm/Python packages and ordered commands; +Files and resolved Skills are installed first, followed by system, npm/Python packages and ordered commands; the default cwd is `/workspace`. One command or package operation has the existing two-minute local budget, within the thirty-minute initialization budget. No command is retried after unknown effects. Completed setup never runs on reconnect. @@ -506,3 +543,52 @@ Early Docker result manifests contain inherited installer archive fields; those fields do not qualify a new installer archive. Current binary and image hashes identify the tested deployment. These checks do not establish complete upstream Template or Agents API compatibility. + +## Reference batch validation and limits + +Resource operations passed fixed SDK/raw HTTP and real PostgreSQL checks. The +shared reference path passed real Docker execution on all three qualified profiles: + +| Harness | Real model | Complete driver result | +| --- | --- | --- | +| Codex | Kimi K3 | Passed, 308.80 seconds | +| Claude Code | Kimi K3 | Passed, 211.68 seconds | +| MiniMax Code | MiniMax M2.7 | Passed, 162.71 seconds | + +Each run covers SDK upload, unresolved template intent, concrete Session metadata, +native Skill supporting files, public Files/Artifacts and tenant checks, source +and template deletion, committed retry, and cold Core/Runtime continuation without +reinstalling or replaying the original Turn. All report zero cleanup errors. The +current Core uses the previously qualified Runtime images and native adapters; +this batch does not change their execution architecture or qualify E2B references. + +MiniMax's initial attempts failed because the test host's SOCKS route exceeded the +native TLS connection deadline. A temporary operator SSH byte relay restored normal +TLS latency, retaining native certificate validation and the same model/credentials. +One subsequent response recalled the exact random history value but omitted its +fixed prefix. Clarifying the test prompt to request the complete literal token, +without supplying its random value again, passed the unchanged exact assertion and +remaining checks. Failed evidence is retained; no Core output repair or native +connection-timeout change was made. + +`make sqlc-generate`, `make openapi` and the standalone `make check` passed; the +full gate took 484.22 seconds. The optional MiniMax packaged-native scratch/large-output +probe was skipped because its profile/artifact variables were unset. Unchanged +native-profile qualification and the real reference workflows are separate evidence. +A user-authorized reused-context GPT-6 Astra high reviewer inspected the entire +52-file diff and original acceptance records, with no material actionable findings. +This was not a fresh-context blind review. These checks do not establish complete +upstream Skill, Template or Agents API compatibility. + +Sanitized results and operator drivers are retained on `zju_a100_2` under +`~/.parsar/remediation/20260921/template-skill-references/`, with a local evidence +index under `~/.parsar/remediation/20260921/template-capabilities-design/`. + +The current upload profile accepts at most 500 regular files, 5 MiB compressed +and 20 MiB expanded per bundle. These are qualified implementation limits, not +published protocol maxima. Exact hosted error parity, null version selection, +unversioned content selection, top-level metadata across version changes and +last/default/latest deletion semantics remain recorded gaps. Current resource +behavior selects default for unversioned content, preserves initial top-level +metadata, rejects default-version deletion and never reuses version numbers. +These choices are not verified upstream guarantees. diff --git a/contracts/agents-api/environments.md b/contracts/agents-api/environments.md index e67afa53a..acac9e589 100644 --- a/contracts/agents-api/environments.md +++ b/contracts/agents-api/environments.md @@ -61,8 +61,8 @@ an existing allocation's Create. Omitted/null network defaults to enabled. Enabled, disabled and exact-host restricted policies use the same qualified image with adapter-selected immutable native policy. Templates and inline configuration share initial files, env, packages, ordered setup -and inline Skills through the hosted initializer. Unsupported hostname forms, -Plugins, Skill references and capability-directory imports reject explicitly; see +and inline or tenant-owned referenced Skills through the hosted initializer. +Unsupported hostname forms, Plugins and capability-directory imports reject explicitly; see the [Template coverage and limits](environment-templates.md). Empty/null installation defaults produce safe empty metadata, not a live workspace inventory. Hosted MCP combinations remain unimplemented. diff --git a/contracts/agents-api/openapi.yaml b/contracts/agents-api/openapi.yaml index f6cd5c02f..bb86db839 100644 --- a/contracts/agents-api/openapi.yaml +++ b/contracts/agents-api/openapi.yaml @@ -1207,6 +1207,146 @@ definitions: - data - has_more type: object + v1.Skill: + properties: + created_at: + type: integer + default_version: + type: string + description: + type: string + id: + type: string + latest_version: + type: string + name: + type: string + object: + enum: + - skill + type: string + required: + - created_at + - default_version + - description + - id + - latest_version + - name + - object + type: object + v1.SkillDeleted: + properties: + deleted: + type: boolean + id: + type: string + object: + enum: + - skill.deleted + type: string + required: + - deleted + - id + - object + type: object + v1.SkillList: + properties: + data: + items: + $ref: '#/definitions/v1.Skill' + type: array + first_id: + type: string + x-nullable: true + has_more: + type: boolean + last_id: + type: string + x-nullable: true + object: + enum: + - list + type: string + required: + - data + - has_more + - object + type: object + v1.SkillUpdateRequest: + properties: + default_version: + type: string + required: + - default_version + type: object + v1.SkillVersion: + properties: + created_at: + type: integer + description: + type: string + id: + type: string + name: + type: string + object: + enum: + - skill.version + type: string + skill_id: + type: string + version: + type: string + required: + - created_at + - description + - id + - name + - object + - skill_id + - version + type: object + v1.SkillVersionDeleted: + properties: + deleted: + type: boolean + id: + type: string + object: + enum: + - skill.version.deleted + type: string + version: + type: string + required: + - deleted + - id + - object + - version + type: object + v1.SkillVersionList: + properties: + data: + items: + $ref: '#/definitions/v1.SkillVersion' + type: array + first_id: + type: string + x-nullable: true + has_more: + type: boolean + last_id: + type: string + x-nullable: true + object: + enum: + - list + type: string + required: + - data + - has_more + - object + type: object v1.SourceFile: properties: bytes: @@ -2113,13 +2253,14 @@ paths: post: consumes: - application/json - description: Saves tenant-owned hosted configuration. Supports nullable - name, enabled/disabled or exact-domain restricted network, initial inline/file_id files, confidential - env, ordered setup_commands, system/npm/Python packages and inline Skill ZIPs. - Omitted/null network defaults to enabled. Restricted network requires 1–100 - exact ASCII hostnames; other host forms and populated unsupported installations - are rejected before persistence without echoing input. - No compute is allocated. Exact hosted error/retry semantics remain unverified. + description: Saves tenant-owned hosted configuration. Supports nullable name, + enabled/disabled or exact-domain restricted network, initial inline/file_id + files, confidential env, ordered setup_commands, system/npm/Python packages + and inline/referenced Skill ZIPs. Omitted/null network defaults to enabled. + Restricted network requires 1–100 exact ASCII hostnames; other host forms + and populated unsupported installations are rejected before persistence without + echoing input. No compute is allocated. Exact hosted error/retry semantics + remain unverified. parameters: - description: agents=v1 in: header @@ -2424,16 +2565,21 @@ paths: initial provisioning has no caller connection action. Network defaults to enabled; disabled and restricted exact ASCII hostnames are supported. Restricted policy requires 1–100 allowed domains. Unsupported hostname forms and startup - installations are rejected. Confidential env, system/npm/Python - packages and ordered setup commands use the shared initialization lifecycle; - requested network applies after setup. Initial inline and tenant-owned file_id - files freeze encrypted bytes before provisioning, then install through the - common Core lifecycle before native execution or live Files access. Referenced - files/env/packages/setup overrides are rejected pending semantic verification. - Tenant-owned environment_template_id references inherit omitted network and - allow only narrowing overrides. Referenced network:null is explicitly unsupported - pending semantic verification. Core freezes effective configuration; template - updates/deletion do not alter Session snapshots or same-intent creation retries. + installations are rejected. Confidential env, system/npm/Python packages and + ordered setup commands use the shared initialization lifecycle; requested + network applies after setup. Initial inline and tenant-owned file_id files + freeze encrypted bytes before provisioning, then install through the common + Core lifecycle before native execution or live Files access. Referenced files/env/packages/setup + overrides are rejected pending semantic verification. Tenant-owned environment_template_id + references inherit omitted network and allow only narrowing overrides. Referenced + network:null is explicitly unsupported pending semantic verification. Core + freezes effective configuration; template updates/deletion do not alter Session + snapshots or same-intent creation retries. Inline or tenant-owned skill_reference + Skills share initialization. Templates preserve default/latest/explicit selectors; + Session creation freezes concrete metadata and encrypted content atomically. + Skill-list omission inherits and a supplied list replaces; null overrides + and null version selectors remain unqualified and reject. Source deletion/default + updates cannot change committed Session Skill contents. parameters: - description: agents=v1 in: header @@ -3421,6 +3567,300 @@ paths: summary: Download source file bytes tags: - Files + /skills: + get: + description: Lists tenant-owned metadata in timestamp order. Default page size + 20, maximum 100; exact hosted defaults and limit-zero semantics remain unverified. + parameters: + - description: Skill resource cursor + in: query + name: after + type: string + - description: Page size + in: query + name: limit + type: integer + - description: Creation order + enum: + - asc + - desc + in: query + name: order + type: string + produces: + - application/json + responses: + "200": + description: OK + schema: + $ref: '#/definitions/v1.SkillList' + security: + - BearerAuth: [] + summary: List Skills + tags: + - Skills + post: + consumes: + - multipart/form-data + description: Accepts one ZIP in files or a directory in files[]. Applies the + qualified portable Skill bundle profile. No Beta header is required; full + hosted upload limits and activation extensions are not qualified. + parameters: + - description: Skill ZIP or directory files + in: formData + name: files + required: true + type: file + produces: + - application/json + responses: + "200": + description: OK + schema: + $ref: '#/definitions/v1.Skill' + security: + - BearerAuth: [] + summary: Upload a Skill + tags: + - Skills + /skills/{skill_id}: + delete: + description: Deletes tenant-owned source bundles. Existing Session installation + snapshots remain independent. + parameters: + - description: Skill ID + in: path + name: skill_id + required: true + type: string + produces: + - application/json + responses: + "200": + description: OK + schema: + $ref: '#/definitions/v1.SkillDeleted' + security: + - BearerAuth: [] + summary: Delete a Skill and its versions + tags: + - Skills + get: + description: Returns tenant-owned metadata without decrypting contents or starting + Runtime. No Beta header is required. + parameters: + - description: Skill ID + in: path + name: skill_id + required: true + type: string + produces: + - application/json + responses: + "200": + description: OK + schema: + $ref: '#/definitions/v1.Skill' + security: + - BearerAuth: [] + summary: Retrieve Skill metadata + tags: + - Skills + post: + consumes: + - application/json + description: Changes only the tenant-owned default pointer; immutable versions + and existing Session snapshots remain unchanged. + parameters: + - description: Skill ID + in: path + name: skill_id + required: true + type: string + - description: Default version + in: body + name: body + required: true + schema: + $ref: '#/definitions/v1.SkillUpdateRequest' + produces: + - application/json + responses: + "200": + description: OK + schema: + $ref: '#/definitions/v1.Skill' + security: + - BearerAuth: [] + summary: Update the default Skill version + tags: + - Skills + /skills/{skill_id}/content: + get: + description: Downloads an authorized ZIP using the default pointer when no concrete + version is supplied. Exact upstream unversioned selection, content headers + and range semantics remain unverified. + parameters: + - description: Skill ID + in: path + name: skill_id + required: true + type: string + produces: + - application/octet-stream + responses: + "200": + description: OK + schema: + type: file + security: + - BearerAuth: [] + summary: Download Skill content + tags: + - Skills + /skills/{skill_id}/versions: + get: + description: Orders by version number; after identifies a version resource, + not a version number. No contents are decrypted. + parameters: + - description: Skill ID + in: path + name: skill_id + required: true + type: string + - description: Version resource cursor + in: query + name: after + type: string + - description: Page size + in: query + name: limit + type: integer + - description: Version order + enum: + - asc + - desc + in: query + name: order + type: string + produces: + - application/json + responses: + "200": + description: OK + schema: + $ref: '#/definitions/v1.SkillVersionList' + security: + - BearerAuth: [] + summary: List Skill versions + tags: + - Skills + post: + consumes: + - multipart/form-data + parameters: + - description: Skill ID + in: path + name: skill_id + required: true + type: string + - description: Skill ZIP or directory files + in: formData + name: files + required: true + type: file + - description: Set as default + in: formData + name: default + type: boolean + produces: + - application/json + responses: + "200": + description: OK + schema: + $ref: '#/definitions/v1.SkillVersion' + security: + - BearerAuth: [] + summary: Upload an immutable Skill version + tags: + - Skills + /skills/{skill_id}/versions/{version}: + delete: + description: Rejects deletion of the current default version. Exact hosted last-version/default + deletion precedence is not verified. + parameters: + - description: Skill ID + in: path + name: skill_id + required: true + type: string + - description: Concrete version number + in: path + name: version + required: true + type: string + produces: + - application/json + responses: + "200": + description: OK + schema: + $ref: '#/definitions/v1.SkillVersionDeleted' + security: + - BearerAuth: [] + summary: Delete a Skill version + tags: + - Skills + get: + parameters: + - description: Skill ID + in: path + name: skill_id + required: true + type: string + - description: Concrete version number + in: path + name: version + required: true + type: string + produces: + - application/json + responses: + "200": + description: OK + schema: + $ref: '#/definitions/v1.SkillVersion' + security: + - BearerAuth: [] + summary: Retrieve Skill version metadata + tags: + - Skills + /skills/{skill_id}/versions/{version}/content: + get: + parameters: + - description: Skill ID + in: path + name: skill_id + required: true + type: string + - description: Concrete version number + in: path + name: version + required: true + type: string + produces: + - application/octet-stream + responses: + "200": + description: OK + schema: + type: file + security: + - BearerAuth: [] + summary: Download immutable Skill version content + tags: + - Skills /vaults: get: description: Lists project-owned Vaults independently of execution. Includes diff --git a/contracts/agents-api/v1/skills.go b/contracts/agents-api/v1/skills.go new file mode 100644 index 000000000..4820024e7 --- /dev/null +++ b/contracts/agents-api/v1/skills.go @@ -0,0 +1,55 @@ +package v1 + +// Skill describes a tenant-owned, versioned bundle without exposing its contents. +type Skill struct { + ID string `json:"id" binding:"required"` + Object string `json:"object" binding:"required" enums:"skill"` + CreatedAt int64 `json:"created_at" binding:"required"` + Name string `json:"name" binding:"required"` + Description string `json:"description" binding:"required"` + DefaultVersion string `json:"default_version" binding:"required"` + LatestVersion string `json:"latest_version" binding:"required"` +} + +type SkillVersion struct { + ID string `json:"id" binding:"required"` + Object string `json:"object" binding:"required" enums:"skill.version"` + CreatedAt int64 `json:"created_at" binding:"required"` + SkillID string `json:"skill_id" binding:"required"` + Version string `json:"version" binding:"required"` + Name string `json:"name" binding:"required"` + Description string `json:"description" binding:"required"` +} + +type SkillList struct { + Object string `json:"object" binding:"required" enums:"list"` + Data []Skill `json:"data" binding:"required"` + FirstID *string `json:"first_id" extensions:"x-nullable"` + LastID *string `json:"last_id" extensions:"x-nullable"` + HasMore bool `json:"has_more" binding:"required"` +} + +type SkillVersionList struct { + Object string `json:"object" binding:"required" enums:"list"` + Data []SkillVersion `json:"data" binding:"required"` + FirstID *string `json:"first_id" extensions:"x-nullable"` + LastID *string `json:"last_id" extensions:"x-nullable"` + HasMore bool `json:"has_more" binding:"required"` +} + +type SkillDeleted struct { + ID string `json:"id" binding:"required"` + Object string `json:"object" binding:"required" enums:"skill.deleted"` + Deleted bool `json:"deleted" binding:"required"` +} + +type SkillVersionDeleted struct { + ID string `json:"id" binding:"required"` + Object string `json:"object" binding:"required" enums:"skill.version.deleted"` + Version string `json:"version" binding:"required"` + Deleted bool `json:"deleted" binding:"required"` +} + +type SkillUpdateRequest struct { + DefaultVersion string `json:"default_version" binding:"required"` +} diff --git a/internal/agentskill/bundle.go b/internal/agentskill/bundle.go index 094292f59..6929e3104 100644 --- a/internal/agentskill/bundle.go +++ b/internal/agentskill/bundle.go @@ -82,7 +82,11 @@ func Read(archive []byte, expected Metadata) ([]File, error) { return nil, ErrInvalid } total += len(body) - if parts[1] == "SKILL.md" { + if strings.EqualFold(parts[1], "SKILL.md") { + if manifest { + return nil, ErrInvalid + } + parts[1] = "SKILL.md" if ValidateManifest(body, expected) != nil { return nil, ErrInvalid } @@ -109,58 +113,66 @@ func Read(archive []byte, expected Metadata) ([]File, error) { // ValidateManifest accepts portable descriptive metadata, not native activation controls. func ValidateManifest(body []byte, expected Metadata) error { - if len(body) > 256<<10 || !utf8.Valid(body) { + actual, err := manifestMetadata(body) + if err != nil || actual != expected { return ErrInvalid } + return nil +} + +func manifestMetadata(body []byte) (Metadata, error) { + if len(body) > 256<<10 || !utf8.Valid(body) { + return Metadata{}, ErrInvalid + } text := strings.ReplaceAll(string(body), "\r\n", "\n") if !strings.HasPrefix(text, "---\n") { - return ErrInvalid + return Metadata{}, ErrInvalid } end := strings.Index(text[4:], "\n---") if end < 0 { - return ErrInvalid + return Metadata{}, ErrInvalid } end += 4 tail := text[end+4:] if tail != "" && !strings.HasPrefix(tail, "\n") { - return ErrInvalid + return Metadata{}, ErrInvalid } decoder := yaml.NewDecoder(strings.NewReader(text[4:end])) var document yaml.Node if decoder.Decode(&document) != nil || len(document.Content) != 1 { - return ErrInvalid + return Metadata{}, ErrInvalid } var extra yaml.Node if decoder.Decode(&extra) != io.EOF { - return ErrInvalid + return Metadata{}, ErrInvalid } node := document.Content[0] if node.Kind != yaml.MappingNode { - return ErrInvalid + return Metadata{}, ErrInvalid } fields := map[string]*yaml.Node{} for i := 0; i < len(node.Content); i += 2 { key, value := node.Content[i], node.Content[i+1] if key.Kind != yaml.ScalarNode || key.Tag != "!!str" || fields[key.Value] != nil { - return ErrInvalid + return Metadata{}, ErrInvalid } fields[key.Value] = value switch key.Value { case "name", "description", "license", "compatibility": if value.Kind != yaml.ScalarNode || value.Tag != "!!str" { - return ErrInvalid + return Metadata{}, ErrInvalid } case "metadata": var metadata map[string]string if value.Kind != yaml.MappingNode || value.Decode(&metadata) != nil { - return ErrInvalid + return Metadata{}, ErrInvalid } default: - return ErrInvalid + return Metadata{}, ErrInvalid } } - if fields["name"] == nil || fields["description"] == nil || fields["name"].Value != expected.Name || fields["description"].Value != expected.Description { - return ErrInvalid + if fields["name"] == nil || fields["description"] == nil { + return Metadata{}, ErrInvalid } - return nil + return Metadata{Type: "inline", Name: fields["name"].Value, Description: fields["description"].Value}, nil } diff --git a/internal/agentskill/upload.go b/internal/agentskill/upload.go new file mode 100644 index 000000000..e3efd1a00 --- /dev/null +++ b/internal/agentskill/upload.go @@ -0,0 +1,59 @@ +package agentskill + +import ( + "archive/zip" + "bytes" + "io" + "strings" +) + +// Inspect obtains portable metadata from an uploaded bundle, then applies the +// same complete archive validation used for environment installation. +func Inspect(archive []byte) (Metadata, error) { + if len(archive) > MaxArchiveBytes { + return Metadata{}, ErrInvalid + } + reader, err := zip.NewReader(bytes.NewReader(archive), int64(len(archive))) + if err != nil || len(reader.File) > MaxFiles { + return Metadata{}, ErrInvalid + } + var metadata Metadata + found := false + files := 0 + for _, entry := range reader.File { + if !entry.FileInfo().IsDir() { + files++ + if files > 500 { + return Metadata{}, ErrInvalid + } + } + parts := strings.Split(entry.Name, "/") + if len(parts) != 2 || !strings.EqualFold(parts[1], "SKILL.md") { + continue + } + if found || entry.UncompressedSize64 > 256<<10 { + return Metadata{}, ErrInvalid + } + stream, err := entry.Open() + if err != nil { + return Metadata{}, ErrInvalid + } + body, readErr := io.ReadAll(io.LimitReader(stream, (256<<10)+1)) + closeErr := stream.Close() + if readErr != nil || closeErr != nil { + return Metadata{}, ErrInvalid + } + metadata, err = manifestMetadata(body) + if err != nil { + return Metadata{}, err + } + found = true + } + if !found { + return Metadata{}, ErrInvalid + } + if _, err = Read(archive, metadata); err != nil { + return Metadata{}, err + } + return metadata, nil +} diff --git a/internal/agentskill/upload_test.go b/internal/agentskill/upload_test.go new file mode 100644 index 000000000..f09b52f40 --- /dev/null +++ b/internal/agentskill/upload_test.go @@ -0,0 +1,47 @@ +package agentskill + +import ( + "archive/zip" + "bytes" + "testing" +) + +func TestInspectUsesInstallationValidation(t *testing.T) { + archive := func(names ...string) []byte { + t.Helper() + var buffer bytes.Buffer + w := zip.NewWriter(&buffer) + for _, name := range names { + f, err := w.Create(name) + if err != nil { + t.Fatal(err) + } + if _, err = f.Write([]byte("---\nname: proof\ndescription: Run a proof.\n---\nInstructions")); err != nil { + t.Fatal(err) + } + } + if err := w.Close(); err != nil { + t.Fatal(err) + } + return buffer.Bytes() + } + data := archive("proof/skill.md") + metadata, err := Inspect(data) + if err != nil || metadata.Name != "proof" { + t.Fatal("manifest discovery", err) + } + files, err := Read(data, metadata) + if err != nil || len(files) != 1 || files[0].Path != "SKILL.md" { + t.Fatal("canonical native manifest", files, err) + } + for _, names := range [][]string{ + {"proof/SKILL.md", "proof/skill.md"}, + {"proof/SKILL.md", "proof/../../secret"}, + {"proof/SKILL.md", "other/file"}, + {"proof/missing.md"}, + } { + if _, err := Inspect(archive(names...)); err == nil { + t.Fatal("invalid upload accepted", names) + } + } +} diff --git a/services/agents-api/cmd/server/main.go b/services/agents-api/cmd/server/main.go index 6963b5524..c8c9d4699 100644 --- a/services/agents-api/cmd/server/main.go +++ b/services/agents-api/cmd/server/main.go @@ -90,7 +90,7 @@ func run() error { } var workerDone chan error var worker *execution.Worker - options := []api.Option{api.WithSourceFiles(executionStore), api.WithSessionArtifacts(executionStore)} + options := []api.Option{api.WithSkills(executionStore), api.WithSourceFiles(executionStore), api.WithSessionArtifacts(executionStore)} var daemonHandler http.Handler var registry *gateway.Registry var checkOwnership func(context.Context) error diff --git a/services/agents-api/internal/api/environment_setup.go b/services/agents-api/internal/api/environment_setup.go index b7c9dbf16..b6ac949f6 100644 --- a/services/agents-api/internal/api/environment_setup.go +++ b/services/agents-api/internal/api/environment_setup.go @@ -74,7 +74,7 @@ func decodeEnvironmentSetup(fields map[string]json.RawMessage) (store.Environmen } } var err error - result.Skills, err = decodeInlineSkills(fields["skills"]) + result.Skills, err = decodeEnvironmentSkills(fields["skills"]) if err != nil { return result, err } diff --git a/services/agents-api/internal/api/environment_skills.go b/services/agents-api/internal/api/environment_skills.go index 11b560227..71383d25a 100644 --- a/services/agents-api/internal/api/environment_skills.go +++ b/services/agents-api/internal/api/environment_skills.go @@ -1,6 +1,7 @@ package api import ( + "bytes" "encoding/base64" "encoding/json" @@ -8,7 +9,7 @@ import ( "github.com/MiniMax-AI-Dev/parsar/services/agents-api/internal/store" ) -func decodeInlineSkills(raw json.RawMessage) ([]store.InlineSkill, error) { +func decodeEnvironmentSkills(raw json.RawMessage) ([]store.EnvironmentSkill, error) { if len(raw) == 0 { return nil, nil } @@ -16,8 +17,33 @@ func decodeInlineSkills(raw json.RawMessage) ([]store.InlineSkill, error) { if json.Unmarshal(raw, &entries) != nil || len(entries) > 50 { return nil, store.ErrInvalidInput } - result := make([]store.InlineSkill, 0, len(entries)) + result := make([]store.EnvironmentSkill, 0, len(entries)) for _, entry := range entries { + var discriminator struct { + Type string `json:"type"` + } + if json.Unmarshal(entry, &discriminator) != nil { + return nil, store.ErrInvalidInput + } + if discriminator.Type == "skill_reference" { + var reference struct { + Type string `json:"type"` + SkillID string `json:"skill_id"` + Version json.RawMessage `json:"version"` + } + if decodeInputObject(entry, &reference, "type", "skill_id", "version") != nil { + return nil, store.ErrInvalidInput + } + metadata := store.EnvironmentSkillMetadata{Type: reference.Type, SkillID: reference.SkillID} + if len(reference.Version) > 0 { + // Null selection semantics are unconfirmed; do not silently select default. + if bytes.Equal(bytes.TrimSpace(reference.Version), []byte("null")) || json.Unmarshal(reference.Version, &metadata.Version) != nil || metadata.Version == "" { + return nil, store.ErrInvalidInput + } + } + result = append(result, store.EnvironmentSkill{Metadata: metadata}) + continue + } var input struct { Type string `json:"type"` Name string `json:"name"` @@ -39,12 +65,12 @@ func decodeInlineSkills(raw json.RawMessage) ([]store.InlineSkill, error) { if err != nil { return nil, store.ErrInvalidInput } - result = append(result, store.InlineSkill{Metadata: agentskill.Metadata{Type: input.Type, Name: input.Name, Description: input.Description}, Archive: body}) + result = append(result, store.EnvironmentSkill{Metadata: store.EnvironmentSkillMetadata{Type: input.Type, Name: input.Name, Description: input.Description}, Archive: body}) } - return result, store.ValidateInlineSkills(result) + return result, store.ValidateEnvironmentSkills(result) } -func skillResponse(skills []agentskill.Metadata) []json.RawMessage { +func skillResponse(skills []store.EnvironmentSkillMetadata) []json.RawMessage { result := make([]json.RawMessage, 0, len(skills)) for _, skill := range skills { raw, _ := json.Marshal(skill) @@ -63,8 +89,8 @@ func storedSkills(raw json.RawMessage) ([]json.RawMessage, error) { } seen := map[string]bool{} for _, entry := range entries { - var metadata agentskill.Metadata - if decodeInputObject(entry, &metadata, "type", "name", "description") != nil || metadata.Type != "inline" || metadata.Name == "" || metadata.Description == "" || seen[metadata.Name] { + var metadata store.EnvironmentSkillMetadata + if decodeInputObject(entry, &metadata, "type", "name", "description", "skill_id", "version") != nil || store.ValidateInstalledSkillMetadata(metadata) != nil || seen[metadata.Name] { return nil, store.ErrInvalidInput } seen[metadata.Name] = true diff --git a/services/agents-api/internal/api/environment_skills_test.go b/services/agents-api/internal/api/environment_skills_test.go index c1eb6e78a..8498e35b7 100644 --- a/services/agents-api/internal/api/environment_skills_test.go +++ b/services/agents-api/internal/api/environment_skills_test.go @@ -6,6 +6,8 @@ import ( "encoding/base64" "encoding/json" "testing" + + "github.com/MiniMax-AI-Dev/parsar/services/agents-api/internal/store" ) func skillInput(t *testing.T, body string) json.RawMessage { @@ -29,6 +31,62 @@ func skillInput(t *testing.T, body string) json.RawMessage { return raw } +func TestSkillReferenceParsingInheritanceAndReplacement(t *testing.T) { + lookup := &templateLookupStore{network: "enabled", skills: []store.EnvironmentSkill{{Metadata: store.EnvironmentSkillMetadata{Type: "skill_reference", SkillID: "skill-template", Version: "latest"}}}} + h := Handler{store: lookup} + for _, fields := range []string{"", `,"skills":[]`, `,"skills":[{"type":"skill_reference","skill_id":"skill-override","version":"2"}]`} { + var decoded decodedSessionRequest + if err := json.Unmarshal([]byte(`{"agent":{"model":"test"},"environment":{"type":"openai_hosted","environment_template_id":"template"`+fields+`}}`), &decoded); err != nil { + t.Fatal(err) + } + input, err := decoded.validated() + if err != nil { + t.Fatal(err) + } + intent, err := sessionCreationRequest(input, nil) + if err != nil || len(intent) == 0 { + t.Fatal("missing unresolved retry intent", err) + } + if err = h.resolveTemplateEnvironment(t.Context(), "tenant", &input); err != nil { + t.Fatal(err) + } + switch fields { + case "": + if len(input.initialization.Skills) != 1 || input.initialization.Skills[0].Metadata != lookup.skills[0].Metadata { + t.Fatal("reference inheritance") + } + case `,"skills":[]`: + if len(input.initialization.Skills) != 0 { + t.Fatal("explicit empty list did not replace") + } + default: + if len(input.initialization.Skills) != 1 || input.initialization.Skills[0].Metadata.SkillID != "skill-override" || input.initialization.Skills[0].Metadata.Version != "2" { + t.Fatal("reference replacement") + } + } + } + for _, version := range []string{"", `,"version":"latest"`, `,"version":"1"`} { + raw := []byte(`{"type":"openai_hosted","skills":[{"type":"skill_reference","skill_id":"skill-owned"` + version + `}]}`) + var decoded decodedSessionRequest + if err := json.Unmarshal(append(append([]byte(`{"agent":{"model":"test"},"environment":`), raw...), '}'), &decoded); err != nil { + t.Fatal(err) + } + input, err := decoded.validated() + if err != nil { + t.Fatal(err) + } + intent, err := sessionCreationRequest(input, nil) + if err != nil || !bytes.Contains(intent, []byte("skill-owned")) { + t.Fatal("inline reference lost retry intent", err) + } + } + for _, version := range []string{`null`, `1`, `""`, `"0"`} { + if _, err := decodeEnvironmentSkills([]byte(`[{"type":"skill_reference","skill_id":"skill-owned","version":` + version + `}]`)); err == nil { + t.Fatal("invalid or unconfirmed selector accepted") + } + } +} + func TestInlineSkillsSharedParsingSnapshotAndIntent(t *testing.T) { skill := skillInput(t, "private-skill-canary") raw := append(append([]byte(`{"skills":[`), skill...), []byte(`]}`)...) @@ -75,7 +133,7 @@ func TestInlineSkillsSharedParsingSnapshotAndIntent(t *testing.T) { t.Fatal("clear", err) } } - for _, invalid := range []string{`{"skills":[null]}`, `{"skills":[{"type":"skill_reference","skill_id":"foreign"}]}`, `{"skills":[{"type":"inline","name":"proof","description":"A proof.","source":{"type":"base64","media_type":"application/zip","data":"invalid"}}]}`} { + for _, invalid := range []string{`{"skills":[null]}`, `{"skills":[{"type":"skill_reference","skill_id":""}]}`, `{"skills":[{"type":"inline","name":"proof","description":"A proof.","source":{"type":"base64","media_type":"application/zip","data":"invalid"}}]}`} { if _, err := decodeTemplateInput([]byte(invalid)); err == nil { t.Fatal("invalid or unsupported skill accepted") } diff --git a/services/agents-api/internal/api/environment_templates.go b/services/agents-api/internal/api/environment_templates.go index 84ab08248..b79209435 100644 --- a/services/agents-api/internal/api/environment_templates.go +++ b/services/agents-api/internal/api/environment_templates.go @@ -85,14 +85,14 @@ func readTemplateInput(w http.ResponseWriter, r *http.Request) (store.Environmen } in, err := decodeTemplateInput(raw) if err != nil { - writeError(w, http.StatusBadRequest, "unsupported_or_invalid_configuration", "Template fields are invalid or require unsupported initialization. Name, enabled/disabled or exact-domain restricted network, initial files, env, system/npm/Python packages, setup commands and inline Skill ZIPs are supported.") + writeError(w, http.StatusBadRequest, "unsupported_or_invalid_configuration", "Template fields are invalid or require unsupported initialization. Name, enabled/disabled or exact-domain restricted network, initial files, env, system/npm/Python packages, setup commands and inline/referenced Skill ZIPs are supported.") return in, false } return in, true } // @Summary Create an Environment Template -// @Description Saves tenant-owned hosted configuration. Supports nullable name, enabled/disabled or exact-domain restricted network, initial inline/file_id files, confidential env, ordered setup_commands, system/npm/Python packages and inline Skill ZIPs. Omitted/null network defaults to enabled. Restricted network requires 1–100 exact ASCII hostnames; other host forms and populated unsupported installations are rejected before persistence without echoing input. No compute is allocated. Exact hosted error/retry semantics remain unverified. +// @Description Saves tenant-owned hosted configuration. Supports nullable name, enabled/disabled or exact-domain restricted network, initial inline/file_id files, confidential env, ordered setup_commands, system/npm/Python packages and inline/referenced Skill ZIPs. Omitted/null network defaults to enabled. Restricted network requires 1–100 exact ASCII hostnames; other host forms and populated unsupported installations are rejected before persistence without echoing input. No compute is allocated. Exact hosted error/retry semantics remain unverified. // @Tags Environment Templates // @Accept json // @Produce json diff --git a/services/agents-api/internal/api/environment_templates_test.go b/services/agents-api/internal/api/environment_templates_test.go index 646e20bd2..aef5e4be0 100644 --- a/services/agents-api/internal/api/environment_templates_test.go +++ b/services/agents-api/internal/api/environment_templates_test.go @@ -38,11 +38,12 @@ type templateLookupStore struct { network string domains []string tenant string + skills []store.EnvironmentSkill } func (s *templateLookupStore) ResolveEnvironmentTemplate(_ context.Context, tenant, id string) (store.EnvironmentTemplate, []store.InitialFile, error) { s.tenant = tenant - return store.EnvironmentTemplate{ID: id, NetworkAccess: s.network, AllowedDomains: s.domains}, nil, nil + return store.EnvironmentTemplate{ID: id, NetworkAccess: s.network, AllowedDomains: s.domains, Initialization: store.EnvironmentSetup{Skills: s.skills}}, nil, nil } func TestTemplateResolutionAndCreationIntent(t *testing.T) { diff --git a/services/agents-api/internal/api/errors.go b/services/agents-api/internal/api/errors.go index 5ca79d1ab..5ae5f28b0 100644 --- a/services/agents-api/internal/api/errors.go +++ b/services/agents-api/internal/api/errors.go @@ -30,6 +30,8 @@ func writeError(w http.ResponseWriter, status int, code, message string) { func writeStoreError(w http.ResponseWriter, r *http.Request, err error) { switch { + case errors.Is(err, store.ErrDefaultSkillVersion): + writeError(w, http.StatusBadRequest, "invalid_request", "Change the default version before deleting this Skill version.") case errors.Is(err, store.ErrSourceFileTooLarge): writeError(w, http.StatusRequestEntityTooLarge, "request_too_large", "File exceeds this operation's content limit.") case errors.Is(err, store.ErrCredentialStorageUnavailable): diff --git a/services/agents-api/internal/api/handler.go b/services/agents-api/internal/api/handler.go index 58b2025d9..3ef02bd08 100644 --- a/services/agents-api/internal/api/handler.go +++ b/services/agents-api/internal/api/handler.go @@ -45,6 +45,7 @@ type Handler struct { hostedEnvironments bool directoryReader EnvironmentDirectoryReader fileWriter EnvironmentFileWriter + skills SkillStore sourceFiles SourceFileStore artifacts SessionArtifactStore } @@ -64,6 +65,7 @@ func NewHandler(s ResourceStore, auth *Authenticator, engine string, options ... }) router.Group(func(r chi.Router) { r.Use(h.authenticateProject) + h.registerSkillRoutes(r) r.Post("/v1/files", h.createSourceFile) r.Get("/v1/files", h.listSourceFiles) r.Get("/v1/files/{file_id}", h.getSourceFile) @@ -120,7 +122,7 @@ func NewHandler(s ResourceStore, auth *Authenticator, engine string, options ... // createSession atomically reserves or admits initial text with the Session. // @Summary Create an execution Session -// @Description Supports inline configuration or a tenant-owned saved agent_id with per-Session field replacements. Execution supports model/instructions, text verbosity, non-deferred function tools, disabled multi_agent, implicit reasoning, service tier auto and environment type none, subject to the configured engine. Codex additionally supports HTTP MCP with explicit service origin, native allowed_tools and boolean required defaulting to false. Session vault_ids attach only project-owned Vaults; credential_id selects an attached static bearer credential for the exact HTTPS URL, while null/omission selects a unique match or remains anonymous. Ambiguous selection rejects creation. Frozen private selections never populate an omitted public credential_id; missing decryption configuration fails dispatch without anonymous fallback. Required initialization uses native startup before the first native Turn, including cold resume, and requires a separately advertised capability; exact hosted creation timing and error parity remain unverified. Other MCP origins and OAuth remain unsupported. The self_hosted profile requires Codex, an absolute workspace_directory and empty capability_directories, with optional non-deferred function tools and HTTP MCP using explicit service origin, optionally authenticated by the attached Vault rules. Remote MCP and remote Bearer authentication each require separately advertised combination support; old peers cannot receive unsupported work. Omitted/null capability_directories use the empty-list default; self_hosted requires configured execution plus executor registry. Claude SDK currently requires medium verbosity and object-root function schemas. It supports anonymous or attached static-bearer service-origin HTTP MCP on none with boolean required and separately advertised MCP/bearer/required runtime support. Required servers must be connected before the first native input is released; pending or failed startup rejects execution. The shared Vault selection and immutable binding rules apply; unsupported native labels/tool names reject before persistence. An attached Vault with no matching credential may remain anonymous; missing keys or failed credential lookup/decryption never fall back to anonymous execution. Omitted stream defaults to false; stream and agent_id cannot be null. Metadata may be null, but its values must be strings. Initial input accepts a string or user-message array containing text. None initial input atomically starts a Turn; self_hosted initial input is reserved while returning its Environment connection target, with execution deferred to native readiness and Session failure on initial timeout. Omitted or null input creates an idle Session. With stream=true, returns live Session events starting at creation; disconnect does not cancel execution. New Sessions retain their authenticated creator; all creation retries require the same typed subject, including across key rotation. Saved-Agent retries and inline requests using Vault attachments or credential references retain caller intent independently of later resource changes; unrelated inline retries preserve resolved/default equivalences. Unknown historical creators reject retries; known creators without recorded intent retain resolved-snapshot retry rules. These conflict policies are local and not verified hosted parity. Creation retries observe future events without replay; retry with stream=false to retrieve the Session. Non-text initial input remains unsupported. Basic Codex and Claude SDK openai_hosted creation requires an explicitly configured managed provider. The Claude workspace profile supports non-deferred function tools with text results alongside native workspace tools; HTTP MCP remains unsupported. Idle Sessions provision automatically; initial provisioning has no caller connection action. Network defaults to enabled; disabled and restricted exact ASCII hostnames are supported. Restricted policy requires 1–100 allowed domains. Unsupported hostname forms and startup installations are rejected. Confidential env, system/npm/Python packages and ordered setup commands use the shared initialization lifecycle; requested network applies after setup. Initial inline and tenant-owned file_id files freeze encrypted bytes before provisioning, then install through the common Core lifecycle before native execution or live Files access. Referenced files/env/packages/setup overrides are rejected pending semantic verification. Tenant-owned environment_template_id references inherit omitted network and allow only narrowing overrides. Referenced network:null is explicitly unsupported pending semantic verification. Core freezes effective configuration; template updates/deletion do not alter Session snapshots or same-intent creation retries. +// @Description Supports inline configuration or a tenant-owned saved agent_id with per-Session field replacements. Execution supports model/instructions, text verbosity, non-deferred function tools, disabled multi_agent, implicit reasoning, service tier auto and environment type none, subject to the configured engine. Codex additionally supports HTTP MCP with explicit service origin, native allowed_tools and boolean required defaulting to false. Session vault_ids attach only project-owned Vaults; credential_id selects an attached static bearer credential for the exact HTTPS URL, while null/omission selects a unique match or remains anonymous. Ambiguous selection rejects creation. Frozen private selections never populate an omitted public credential_id; missing decryption configuration fails dispatch without anonymous fallback. Required initialization uses native startup before the first native Turn, including cold resume, and requires a separately advertised capability; exact hosted creation timing and error parity remain unverified. Other MCP origins and OAuth remain unsupported. The self_hosted profile requires Codex, an absolute workspace_directory and empty capability_directories, with optional non-deferred function tools and HTTP MCP using explicit service origin, optionally authenticated by the attached Vault rules. Remote MCP and remote Bearer authentication each require separately advertised combination support; old peers cannot receive unsupported work. Omitted/null capability_directories use the empty-list default; self_hosted requires configured execution plus executor registry. Claude SDK currently requires medium verbosity and object-root function schemas. It supports anonymous or attached static-bearer service-origin HTTP MCP on none with boolean required and separately advertised MCP/bearer/required runtime support. Required servers must be connected before the first native input is released; pending or failed startup rejects execution. The shared Vault selection and immutable binding rules apply; unsupported native labels/tool names reject before persistence. An attached Vault with no matching credential may remain anonymous; missing keys or failed credential lookup/decryption never fall back to anonymous execution. Omitted stream defaults to false; stream and agent_id cannot be null. Metadata may be null, but its values must be strings. Initial input accepts a string or user-message array containing text. None initial input atomically starts a Turn; self_hosted initial input is reserved while returning its Environment connection target, with execution deferred to native readiness and Session failure on initial timeout. Omitted or null input creates an idle Session. With stream=true, returns live Session events starting at creation; disconnect does not cancel execution. New Sessions retain their authenticated creator; all creation retries require the same typed subject, including across key rotation. Saved-Agent retries and inline requests using Vault attachments or credential references retain caller intent independently of later resource changes; unrelated inline retries preserve resolved/default equivalences. Unknown historical creators reject retries; known creators without recorded intent retain resolved-snapshot retry rules. These conflict policies are local and not verified hosted parity. Creation retries observe future events without replay; retry with stream=false to retrieve the Session. Non-text initial input remains unsupported. Basic Codex and Claude SDK openai_hosted creation requires an explicitly configured managed provider. The Claude workspace profile supports non-deferred function tools with text results alongside native workspace tools; HTTP MCP remains unsupported. Idle Sessions provision automatically; initial provisioning has no caller connection action. Network defaults to enabled; disabled and restricted exact ASCII hostnames are supported. Restricted policy requires 1–100 allowed domains. Unsupported hostname forms and startup installations are rejected. Confidential env, system/npm/Python packages and ordered setup commands use the shared initialization lifecycle; requested network applies after setup. Initial inline and tenant-owned file_id files freeze encrypted bytes before provisioning, then install through the common Core lifecycle before native execution or live Files access. Referenced files/env/packages/setup overrides are rejected pending semantic verification. Tenant-owned environment_template_id references inherit omitted network and allow only narrowing overrides. Referenced network:null is explicitly unsupported pending semantic verification. Core freezes effective configuration; template updates/deletion do not alter Session snapshots or same-intent creation retries. Inline or tenant-owned skill_reference Skills share initialization. Templates preserve default/latest/explicit selectors; Session creation freezes concrete metadata and encrypted content atomically. Skill-list omission inherits and a supplied list replaces; null overrides and null version selectors remain unqualified and reject. Source deletion/default updates cannot change committed Session Skill contents. // @Tags Sessions // @Accept json // @Produce json,text/event-stream diff --git a/services/agents-api/internal/api/session_creation_identity.go b/services/agents-api/internal/api/session_creation_identity.go index c6d3322fd..5e2561952 100644 --- a/services/agents-api/internal/api/session_creation_identity.go +++ b/services/agents-api/internal/api/session_creation_identity.go @@ -11,7 +11,7 @@ import ( ) func sessionCreationRequest(input sessionRequest, initial []store.Input) (json.RawMessage, error) { - if input.XAgentsCore == nil && input.AgentID == nil && input.templateID == "" && len(input.initialFiles) == 0 && !inlineCredentialIntent(input) && input.agentFields["x_agents_core"] == nil { + if input.XAgentsCore == nil && input.AgentID == nil && input.templateID == "" && len(input.initialFiles) == 0 && input.initialization.Empty() && !inlineCredentialIntent(input) && input.agentFields["x_agents_core"] == nil { return nil, nil } agentID := "" diff --git a/services/agents-api/internal/api/session_template.go b/services/agents-api/internal/api/session_template.go index d86f361b5..4707cedb9 100644 --- a/services/agents-api/internal/api/session_template.go +++ b/services/agents-api/internal/api/session_template.go @@ -30,7 +30,10 @@ func decodeTemplateEnvironment(raw json.RawMessage) (*v1.Environment, string, js if value, exists := fields["network"]; exists && bytes.Equal(bytes.TrimSpace(value), []byte("null")) { return nil, "", nil, store.ErrInvalidInput } - for _, name := range []string{"files", "env", "setup_commands", "packages", "skills"} { + if value, exists := fields["skills"]; exists && bytes.Equal(bytes.TrimSpace(value), []byte("null")) { + return nil, "", nil, store.ErrInvalidInput + } + for _, name := range []string{"files", "env", "setup_commands", "packages"} { if _, supplied := fields[name]; supplied { return nil, "", nil, store.ErrInvalidInput } @@ -63,8 +66,13 @@ func (h *Handler) resolveTemplateEnvironment(ctx context.Context, tenant string, if !effective.Narrows(agentnetwork.Policy{Access: template.NetworkAccess, AllowedDomains: template.AllowedDomains}) { return store.ErrInvalidInput } + skills := input.initialization.Skills + if _, supplied := fields["skills"]; !supplied { + skills = template.Initialization.Skills + } input.initialization = template.Initialization - input.Environment.Skills = skillResponse(template.Skills) + input.initialization.Skills = skills + input.Environment.Skills = skillResponse(input.initialization.SkillMetadata()) packages := template.Initialization.PackageMetadata() input.Environment.Packages = &packages input.initialFiles = files diff --git a/services/agents-api/internal/api/skills.go b/services/agents-api/internal/api/skills.go new file mode 100644 index 000000000..b98a5673d --- /dev/null +++ b/services/agents-api/internal/api/skills.go @@ -0,0 +1,172 @@ +package api + +import ( + "context" + "net/http" + "strconv" + + v1 "github.com/MiniMax-AI-Dev/parsar/contracts/agents-api/v1" + "github.com/MiniMax-AI-Dev/parsar/services/agents-api/internal/store" + "github.com/go-chi/chi/v5" +) + +type SkillStore interface { + CreateSkill(context.Context, string, []byte) (store.Skill, error) + GetSkill(context.Context, string, string) (store.Skill, error) + UpdateSkillDefault(context.Context, string, string, string) (store.Skill, error) + DeleteSkill(context.Context, string, string) error + ListSkills(context.Context, string, string, int, bool) (store.SkillPage, error) + CreateSkillVersion(context.Context, string, string, []byte, bool) (store.SkillVersion, error) + GetSkillVersion(context.Context, string, string, string) (store.SkillVersion, error) + ReadSkillVersion(context.Context, string, string, string) (store.SkillVersion, []byte, error) + ReadDefaultSkillVersion(context.Context, string, string) (store.SkillVersion, []byte, error) + DeleteSkillVersion(context.Context, string, string, string) (store.SkillVersion, error) + ListSkillVersions(context.Context, string, string, string, int, bool) (store.SkillVersionPage, error) +} + +func WithSkills(s SkillStore) Option { return func(h *Handler) { h.skills = s } } + +func (h *Handler) registerSkillRoutes(r chi.Router) { + r.Post("/v1/skills", h.createSkill) + r.Get("/v1/skills", h.listSkills) + r.Get("/v1/skills/{skill_id}", h.getSkill) + r.Post("/v1/skills/{skill_id}", h.updateSkill) + r.Delete("/v1/skills/{skill_id}", h.deleteSkill) + r.Get("/v1/skills/{skill_id}/content", h.skillContent) + r.Post("/v1/skills/{skill_id}/versions", h.createSkillVersion) + r.Get("/v1/skills/{skill_id}/versions", h.listSkillVersions) + r.Get("/v1/skills/{skill_id}/versions/{version}", h.getSkillVersion) + r.Delete("/v1/skills/{skill_id}/versions/{version}", h.deleteSkillVersion) + r.Get("/v1/skills/{skill_id}/versions/{version}/content", h.skillVersionContent) +} + +func (h *Handler) skillsReady(w http.ResponseWriter, r *http.Request, list bool) bool { + if h.skills == nil { + writeError(w, http.StatusServiceUnavailable, "skill_storage_unavailable", "Skill storage is unavailable.") + return false + } + if !list && r.URL.RawQuery != "" { + writeStoreError(w, r, store.ErrInvalidInput) + return false + } + return true +} + +// @Summary Retrieve Skill metadata +// @Description Returns tenant-owned metadata without decrypting contents or starting Runtime. No Beta header is required. +// @Tags Skills +// @Produce json +// @Security BearerAuth +// @Param skill_id path string true "Skill ID" +// @Success 200 {object} v1.Skill +// @Router /skills/{skill_id} [get] +func (h *Handler) getSkill(w http.ResponseWriter, r *http.Request) { + if !h.skillsReady(w, r, false) { + return + } + value, err := h.skills.GetSkill(r.Context(), tenantID(r), chi.URLParam(r, "skill_id")) + if err != nil { + writeStoreError(w, r, err) + return + } + writeJSON(w, http.StatusOK, skillResponseResource(value)) +} + +// @Summary Update the default Skill version +// @Description Changes only the tenant-owned default pointer; immutable versions and existing Session snapshots remain unchanged. +// @Tags Skills +// @Accept json +// @Produce json +// @Security BearerAuth +// @Param skill_id path string true "Skill ID" +// @Param body body v1.SkillUpdateRequest true "Default version" +// @Success 200 {object} v1.Skill +// @Router /skills/{skill_id} [post] +func (h *Handler) updateSkill(w http.ResponseWriter, r *http.Request) { + if !h.skillsReady(w, r, false) { + return + } + body, ok := readJSONBodyLimit(w, r, 64<<10, "Request exceeds 64 KiB.") + if !ok { + return + } + var input v1.SkillUpdateRequest + if decodeInputObject(body, &input, "default_version") != nil || input.DefaultVersion == "" { + writeStoreError(w, r, store.ErrInvalidInput) + return + } + value, err := h.skills.UpdateSkillDefault(r.Context(), tenantID(r), chi.URLParam(r, "skill_id"), input.DefaultVersion) + if err != nil { + writeStoreError(w, r, err) + return + } + writeJSON(w, http.StatusOK, skillResponseResource(value)) +} + +// @Summary Delete a Skill and its versions +// @Description Deletes tenant-owned source bundles. Existing Session installation snapshots remain independent. +// @Tags Skills +// @Produce json +// @Security BearerAuth +// @Param skill_id path string true "Skill ID" +// @Success 200 {object} v1.SkillDeleted +// @Router /skills/{skill_id} [delete] +func (h *Handler) deleteSkill(w http.ResponseWriter, r *http.Request) { + if !h.skillsReady(w, r, false) { + return + } + id := chi.URLParam(r, "skill_id") + if err := h.skills.DeleteSkill(r.Context(), tenantID(r), id); err != nil { + writeStoreError(w, r, err) + return + } + writeJSON(w, http.StatusOK, v1.SkillDeleted{ID: id, Object: "skill.deleted", Deleted: true}) +} + +// @Summary Retrieve Skill version metadata +// @Tags Skills +// @Produce json +// @Security BearerAuth +// @Param skill_id path string true "Skill ID" +// @Param version path string true "Concrete version number" +// @Success 200 {object} v1.SkillVersion +// @Router /skills/{skill_id}/versions/{version} [get] +func (h *Handler) getSkillVersion(w http.ResponseWriter, r *http.Request) { + if !h.skillsReady(w, r, false) { + return + } + value, err := h.skills.GetSkillVersion(r.Context(), tenantID(r), chi.URLParam(r, "skill_id"), chi.URLParam(r, "version")) + if err != nil { + writeStoreError(w, r, err) + return + } + writeJSON(w, http.StatusOK, skillVersionResponse(value)) +} + +// @Summary Delete a Skill version +// @Description Rejects deletion of the current default version. Exact hosted last-version/default deletion precedence is not verified. +// @Tags Skills +// @Produce json +// @Security BearerAuth +// @Param skill_id path string true "Skill ID" +// @Param version path string true "Concrete version number" +// @Success 200 {object} v1.SkillVersionDeleted +// @Router /skills/{skill_id}/versions/{version} [delete] +func (h *Handler) deleteSkillVersion(w http.ResponseWriter, r *http.Request) { + if !h.skillsReady(w, r, false) { + return + } + value, err := h.skills.DeleteSkillVersion(r.Context(), tenantID(r), chi.URLParam(r, "skill_id"), chi.URLParam(r, "version")) + if err != nil { + writeStoreError(w, r, err) + return + } + writeJSON(w, http.StatusOK, v1.SkillVersionDeleted{ID: value.ID, Object: "skill.version.deleted", Version: strconv.FormatInt(value.Version, 10), Deleted: true}) +} + +func skillResponseResource(s store.Skill) v1.Skill { + return v1.Skill{ID: s.ID, Object: "skill", CreatedAt: s.CreatedAt.Unix(), Name: s.Name, Description: s.Description, DefaultVersion: strconv.FormatInt(s.DefaultVersion, 10), LatestVersion: strconv.FormatInt(s.LatestVersion, 10)} +} +func skillVersionResponse(s store.SkillVersion) v1.SkillVersion { + return v1.SkillVersion{ID: s.ID, Object: "skill.version", SkillID: s.SkillID, CreatedAt: s.CreatedAt.Unix(), Name: s.Name, Description: s.Description, Version: strconv.FormatInt(s.Version, 10)} +} diff --git a/services/agents-api/internal/api/skills_list.go b/services/agents-api/internal/api/skills_list.go new file mode 100644 index 000000000..abcc4d5f6 --- /dev/null +++ b/services/agents-api/internal/api/skills_list.go @@ -0,0 +1,77 @@ +package api + +import ( + "net/http" + + v1 "github.com/MiniMax-AI-Dev/parsar/contracts/agents-api/v1" + "github.com/go-chi/chi/v5" +) + +// @Summary List Skills +// @Description Lists tenant-owned metadata in timestamp order. Default page size 20, maximum 100; exact hosted defaults and limit-zero semantics remain unverified. +// @Tags Skills +// @Produce json +// @Security BearerAuth +// @Param after query string false "Skill resource cursor" +// @Param limit query integer false "Page size" +// @Param order query string false "Creation order" Enums(asc,desc) +// @Success 200 {object} v1.SkillList +// @Router /skills [get] +func (h *Handler) listSkills(w http.ResponseWriter, r *http.Request) { + if !h.skillsReady(w, r, true) { + return + } + options, ok := readPage(w, r) + if !ok { + return + } + page, err := h.skills.ListSkills(r.Context(), tenantID(r), options.after, options.limit, options.ascending) + if err != nil { + writeStoreError(w, r, err) + return + } + result := v1.SkillList{Object: "list", Data: make([]v1.Skill, 0, len(page.Skills)), HasMore: page.HasMore} + for _, value := range page.Skills { + result.Data = append(result.Data, skillResponseResource(value)) + } + if len(result.Data) > 0 { + result.FirstID = &result.Data[0].ID + result.LastID = &result.Data[len(result.Data)-1].ID + } + writeJSON(w, http.StatusOK, result) +} + +// @Summary List Skill versions +// @Description Orders by version number; after identifies a version resource, not a version number. No contents are decrypted. +// @Tags Skills +// @Produce json +// @Security BearerAuth +// @Param skill_id path string true "Skill ID" +// @Param after query string false "Version resource cursor" +// @Param limit query integer false "Page size" +// @Param order query string false "Version order" Enums(asc,desc) +// @Success 200 {object} v1.SkillVersionList +// @Router /skills/{skill_id}/versions [get] +func (h *Handler) listSkillVersions(w http.ResponseWriter, r *http.Request) { + if !h.skillsReady(w, r, true) { + return + } + options, ok := readPage(w, r) + if !ok { + return + } + page, err := h.skills.ListSkillVersions(r.Context(), tenantID(r), chi.URLParam(r, "skill_id"), options.after, options.limit, options.ascending) + if err != nil { + writeStoreError(w, r, err) + return + } + result := v1.SkillVersionList{Object: "list", Data: make([]v1.SkillVersion, 0, len(page.Versions)), HasMore: page.HasMore} + for _, value := range page.Versions { + result.Data = append(result.Data, skillVersionResponse(value)) + } + if len(result.Data) > 0 { + result.FirstID = &result.Data[0].ID + result.LastID = &result.Data[len(result.Data)-1].ID + } + writeJSON(w, http.StatusOK, result) +} diff --git a/services/agents-api/internal/api/skills_transfer.go b/services/agents-api/internal/api/skills_transfer.go new file mode 100644 index 000000000..0000cf040 --- /dev/null +++ b/services/agents-api/internal/api/skills_transfer.go @@ -0,0 +1,119 @@ +package api + +import ( + "bytes" + "context" + "errors" + "io" + "net/http" + "time" + + "github.com/MiniMax-AI-Dev/parsar/internal/agentskill" + "github.com/MiniMax-AI-Dev/parsar/services/agents-api/internal/store" + "github.com/go-chi/chi/v5" +) + +// @Summary Upload a Skill +// @Description Accepts one ZIP in files or a directory in files[]. Applies the qualified portable Skill bundle profile. No Beta header is required; full hosted upload limits and activation extensions are not qualified. +// @Tags Skills +// @Accept multipart/form-data +// @Produce json +// @Security BearerAuth +// @Param files formData file true "Skill ZIP or directory files" +// @Success 200 {object} v1.Skill +// @Router /skills [post] +func (h *Handler) createSkill(w http.ResponseWriter, r *http.Request) { h.uploadSkill(w, r, false) } + +// @Summary Upload an immutable Skill version +// @Tags Skills +// @Accept multipart/form-data +// @Produce json +// @Security BearerAuth +// @Param skill_id path string true "Skill ID" +// @Param files formData file true "Skill ZIP or directory files" +// @Param default formData boolean false "Set as default" +// @Success 200 {object} v1.SkillVersion +// @Router /skills/{skill_id}/versions [post] +func (h *Handler) createSkillVersion(w http.ResponseWriter, r *http.Request) { + h.uploadSkill(w, r, true) +} + +func (h *Handler) uploadSkill(w http.ResponseWriter, r *http.Request, version bool) { + if !h.skillsReady(w, r, false) { + return + } + deadline := time.Now().Add(sourceTransferTimeout) + controller := http.NewResponseController(w) + if controller.SetReadDeadline(deadline) != nil || controller.SetWriteDeadline(deadline) != nil { + writeError(w, http.StatusServiceUnavailable, "file_transfer_unavailable", "Bounded file transfer is unavailable.") + return + } + ctx, cancel := context.WithDeadline(r.Context(), deadline) + defer cancel() + r.Body = http.MaxBytesReader(w, r.Body, agentskill.MaxExpandedBytes+(1<<20)) + archive, makeDefault, err := readSkillUpload(r, version) + if err != nil { + var limit *http.MaxBytesError + if errors.As(err, &limit) { + writeStoreError(w, r, store.ErrSourceFileTooLarge) + } else { + writeStoreError(w, r, store.ErrInvalidInput) + } + return + } + if version { + result, err := h.skills.CreateSkillVersion(ctx, tenantID(r), chi.URLParam(r, "skill_id"), archive, makeDefault) + if err != nil { + writeStoreError(w, r, err) + return + } + writeJSON(w, http.StatusOK, skillVersionResponse(result)) + } else { + result, err := h.skills.CreateSkill(ctx, tenantID(r), archive) + if err != nil { + writeStoreError(w, r, err) + return + } + writeJSON(w, http.StatusOK, skillResponseResource(result)) + } +} + +// @Summary Download Skill content +// @Description Downloads an authorized ZIP using the default pointer when no concrete version is supplied. Exact upstream unversioned selection, content headers and range semantics remain unverified. +// @Tags Skills +// @Produce octet-stream +// @Security BearerAuth +// @Param skill_id path string true "Skill ID" +// @Success 200 {file} binary +// @Router /skills/{skill_id}/content [get] +func (h *Handler) skillContent(w http.ResponseWriter, r *http.Request) { + if !h.skillsReady(w, r, false) { + return + } + serveStoredContent(w, r, func(ctx context.Context, consume func(string, int64, io.Reader) error) error { + var value store.SkillVersion + var body []byte + var err error + if version := chi.URLParam(r, "version"); version != "" { + value, body, err = h.skills.ReadSkillVersion(ctx, tenantID(r), chi.URLParam(r, "skill_id"), version) + } else { + value, body, err = h.skills.ReadDefaultSkillVersion(ctx, tenantID(r), chi.URLParam(r, "skill_id")) + } + if err != nil { + return err + } + return consume(value.Name+".zip", int64(len(body)), bytes.NewReader(body)) + }) +} + +// @Summary Download immutable Skill version content +// @Tags Skills +// @Produce octet-stream +// @Security BearerAuth +// @Param skill_id path string true "Skill ID" +// @Param version path string true "Concrete version number" +// @Success 200 {file} binary +// @Router /skills/{skill_id}/versions/{version}/content [get] +func (h *Handler) skillVersionContent(w http.ResponseWriter, r *http.Request) { + h.skillContent(w, r) +} diff --git a/services/agents-api/internal/api/skills_upload.go b/services/agents-api/internal/api/skills_upload.go new file mode 100644 index 000000000..7e5d7eaef --- /dev/null +++ b/services/agents-api/internal/api/skills_upload.go @@ -0,0 +1,122 @@ +package api + +import ( + "archive/zip" + "bytes" + "errors" + "io" + "mime" + "net/http" + "path" + "strings" + "unicode/utf8" + + "github.com/MiniMax-AI-Dev/parsar/internal/agentskill" + "github.com/MiniMax-AI-Dev/parsar/services/agents-api/internal/store" +) + +// readSkillUpload preserves directory paths from Content-Disposition. The +// multipart Part.FileName helper would discard their parent components. +func readSkillUpload(r *http.Request, versionUpload bool) ([]byte, bool, error) { + if r.Header.Get("Content-Encoding") != "" { + return nil, false, store.ErrInvalidInput + } + reader, err := r.MultipartReader() + if err != nil { + return nil, false, store.ErrInvalidInput + } + var archive bytes.Buffer + writer := zip.NewWriter(&archive) + files, expanded := 0, 0 + var uploaded []byte + var makeDefault, seenDefault, zipUpload bool + paths := map[string]bool{} + for { + part, err := reader.NextRawPart() + if errors.Is(err, io.EOF) { + break + } + if err != nil { + return nil, false, err + } + kind, attrs, err := mime.ParseMediaType(part.Header.Get("Content-Disposition")) + field := attrs["name"] + if err != nil || kind != "form-data" || part.Header.Get("Content-Transfer-Encoding") != "" { + return nil, false, store.ErrInvalidInput + } + if field == "default" { + if !versionUpload || seenDefault { + return nil, false, store.ErrInvalidInput + } + if _, ok := attrs["filename"]; ok { + return nil, false, store.ErrInvalidInput + } + value, err := io.ReadAll(io.LimitReader(part, 6)) + if err != nil || (string(value) != "true" && string(value) != "false") { + return nil, false, store.ErrInvalidInput + } + seenDefault = true + makeDefault = string(value) == "true" + } else { + name := attrs["filename"] + if (field != "files" && field != "files[]") || name == "" || files >= 500 || zipUpload { + return nil, false, store.ErrInvalidInput + } + if field == "files" { + if files != 0 { + return nil, false, store.ErrInvalidInput + } + uploaded, err = io.ReadAll(io.LimitReader(part, agentskill.MaxArchiveBytes+1)) + if err != nil { + return nil, false, err + } + if len(uploaded) > agentskill.MaxArchiveBytes { + return nil, false, store.ErrInvalidInput + } + zipUpload = true + } else { + if !utf8.ValidString(name) || len(name) > 4096 || path.IsAbs(name) || path.Clean(name) != name || strings.ContainsAny(name, "\\\x00\r\n") || !strings.Contains(name, "/") || paths[name] { + return nil, false, store.ErrInvalidInput + } + paths[name] = true + body, err := io.ReadAll(io.LimitReader(part, int64(agentskill.MaxExpandedBytes-expanded)+1)) + if err != nil { + return nil, false, err + } + expanded += len(body) + if expanded > agentskill.MaxExpandedBytes { + return nil, false, store.ErrInvalidInput + } + header := &zip.FileHeader{Name: name, Method: zip.Deflate} + header.SetMode(0644) + file, err := writer.CreateHeader(header) + if err != nil { + return nil, false, err + } + if _, err = file.Write(body); err != nil { + return nil, false, err + } + } + files++ + } + if err := part.Close(); err != nil { + return nil, false, err + } + } + if _, err := io.Copy(io.Discard, r.Body); err != nil { + return nil, false, err + } + if err := writer.Close(); err != nil { + return nil, false, err + } + if files == 0 { + return nil, false, store.ErrInvalidInput + } + if !zipUpload { + uploaded = archive.Bytes() + } + if _, err := agentskill.Inspect(uploaded); err != nil { + return nil, false, store.ErrInvalidInput + } + return uploaded, makeDefault, nil +} diff --git a/services/agents-api/internal/api/skills_upload_test.go b/services/agents-api/internal/api/skills_upload_test.go new file mode 100644 index 000000000..de3e0762f --- /dev/null +++ b/services/agents-api/internal/api/skills_upload_test.go @@ -0,0 +1,93 @@ +package api + +import ( + "archive/zip" + "bytes" + "mime/multipart" + "net/http/httptest" + "net/textproto" + "testing" + + "github.com/MiniMax-AI-Dev/parsar/internal/agentskill" +) + +func TestSkillMultipartPreservesPathsAndValidatesEntireUpload(t *testing.T) { + manifest := []byte("---\nname: proof\ndescription: Verify an uploaded bundle.\n---\nRead scripts/proof.py.") + type part struct { + field, name string + body []byte + } + request := func(parts []part) *bytes.Buffer { + var buffer bytes.Buffer + w := multipart.NewWriter(&buffer) + if err := w.SetBoundary("skill-proof-boundary"); err != nil { + t.Fatal(err) + } + for _, entry := range parts { + h := textproto.MIMEHeader{} + disposition := `form-data; name="` + entry.field + `"` + if entry.name != "" { + disposition += `; filename="` + entry.name + `"` + } + h.Set("Content-Disposition", disposition) + p, err := w.CreatePart(h) + if err != nil { + t.Fatal(err) + } + if _, err = p.Write(entry.body); err != nil { + t.Fatal(err) + } + } + if err := w.Close(); err != nil { + t.Fatal(err) + } + return &buffer + } + parse := func(parts []part, version bool) ([]byte, bool, error) { + r := httptest.NewRequest("POST", "/v1/skills", request(parts)) + r.Header.Set("Content-Type", "multipart/form-data; boundary=skill-proof-boundary") + return readSkillUpload(r, version) + } + directory := []part{{"files[]", "proof/skill.md", manifest}, {"files[]", "proof/scripts/proof.py", []byte("print('proof')")}, {"default", "", []byte("true")}} + body, defaultVersion, err := parse(directory, true) + if err != nil || !defaultVersion { + t.Fatal("directory upload", err) + } + meta, err := agentskill.Inspect(body) + if err != nil { + t.Fatal(err) + } + files, err := agentskill.Read(body, meta) + if err != nil || len(files) != 2 || files[1].Path != "scripts/proof.py" { + t.Fatal("directory paths lost", files, err) + } + var zipped bytes.Buffer + zw := zip.NewWriter(&zipped) + f, err := zw.Create("proof/SKILL.md") + if err != nil { + t.Fatal(err) + } + if _, err = f.Write(manifest); err != nil { + t.Fatal(err) + } + if err = zw.Close(); err != nil { + t.Fatal(err) + } + if _, _, err = parse([]part{{"files", "proof.zip", zipped.Bytes()}}, false); err != nil { + t.Fatal("zip upload", err) + } + for _, parts := range [][]part{ + {{"files[]", "proof/SKILL.md", manifest}, {"files[]", "proof/../../private", []byte("escape")}}, + {{"files[]", "proof/SKILL.md", manifest}, {"files[]", "proof/SKILL.md", manifest}}, + {{"files", "proof.zip", zipped.Bytes()}, {"files[]", "proof/extra", []byte("mixed")}}, + {{"files[]", "proof/SKILL.md", manifest}, {"default", "", []byte("true")}, {"default", "", []byte("false")}}, + {{"files[]", "proof/SKILL.md", manifest}, {"unknown", "", []byte("ignored")}}, + } { + if _, _, err = parse(parts, true); err == nil { + t.Fatal("invalid tail committed", parts[1].field) + } + } + if _, _, err = parse(directory, false); err == nil { + t.Fatal("default accepted on Skill create") + } +} diff --git a/services/agents-api/internal/credentialcrypto/skill.go b/services/agents-api/internal/credentialcrypto/skill.go new file mode 100644 index 000000000..2522e1ae4 --- /dev/null +++ b/services/agents-api/internal/credentialcrypto/skill.go @@ -0,0 +1,43 @@ +package credentialcrypto + +import ( + "encoding/json" + "unicode/utf8" +) + +// SkillBinding prevents encrypted bundles from being moved across owners or versions. +type SkillBinding struct { + TenantID string `json:"tenant_id"` + SkillID string `json:"skill_id"` + VersionID string `json:"version_id"` + Version string `json:"version"` +} + +func (c *Cipher) SealSkill(body []byte, binding SkillBinding) ([]byte, error) { + aad, err := skillData(binding) + if err != nil { + return nil, err + } + return c.seal(body, aad) +} + +func (c *Cipher) OpenSkill(body []byte, binding SkillBinding) ([]byte, error) { + aad, err := skillData(binding) + if err != nil { + return nil, err + } + return c.open(body, aad) +} + +func skillData(binding SkillBinding) ([]byte, error) { + for _, value := range []string{binding.TenantID, binding.SkillID, binding.VersionID, binding.Version} { + if value == "" || !utf8.ValidString(value) { + return nil, errInvalidBinding + } + } + return json.Marshal(struct { + Domain string `json:"domain"` + Version byte `json:"version"` + Binding SkillBinding `json:"binding"` + }{"parsar.agents-api.skill", formatVersion, binding}) +} diff --git a/services/agents-api/internal/credentialcrypto/skill_test.go b/services/agents-api/internal/credentialcrypto/skill_test.go new file mode 100644 index 000000000..d622232ed --- /dev/null +++ b/services/agents-api/internal/credentialcrypto/skill_test.go @@ -0,0 +1,34 @@ +package credentialcrypto + +import ( + "bytes" + "testing" +) + +func TestSkillContentBoundToTenantResourceAndVersion(t *testing.T) { + c := testCipher(t, bytes.Repeat([]byte{29}, 32)) + binding := SkillBinding{TenantID: "tenant", SkillID: "skill", VersionID: "version-id", Version: "1"} + ciphertext, err := c.SealSkill([]byte("private-bundle"), binding) + if err != nil { + t.Fatal(err) + } + if body, err := c.OpenSkill(ciphertext, binding); err != nil || string(body) != "private-bundle" { + t.Fatal("round trip", err) + } + for _, change := range []func(*SkillBinding){ + func(b *SkillBinding) { b.TenantID = "other" }, + func(b *SkillBinding) { b.SkillID = "other" }, + func(b *SkillBinding) { b.VersionID = "other" }, + func(b *SkillBinding) { b.Version = "2" }, + } { + other := binding + change(&other) + if _, err := c.OpenSkill(ciphertext, other); err == nil { + t.Fatal("cross-boundary bundle accepted") + } + } + ciphertext[len(ciphertext)-1] ^= 1 + if _, err := c.OpenSkill(ciphertext, binding); err == nil { + t.Fatal("tampered content accepted") + } +} diff --git a/services/agents-api/internal/db/queries/environment_setup.sql b/services/agents-api/internal/db/queries/environment_setup.sql index 76b6d0596..b02447789 100644 --- a/services/agents-api/internal/db/queries/environment_setup.sql +++ b/services/agents-api/internal/db/queries/environment_setup.sql @@ -6,5 +6,5 @@ SELECT f.contents FROM sessions s LEFT JOIN environment_setups f ON s.id = f.ses WHERE s.tenant_id = $1 AND s.id = $2 AND s.deleted_at IS NULL; -- name: SetSessionSetupMetadata :one -UPDATE sessions SET configuration = jsonb_set(jsonb_set(configuration, '{environment,packages}', $2::jsonb), '{environment,initialization}', 'true'::jsonb) +UPDATE sessions SET configuration = jsonb_set(jsonb_set(jsonb_set(configuration, '{environment,packages}', sqlc.arg(packages)::jsonb), '{environment,skills}', sqlc.arg(skills)::jsonb), '{environment,initialization}', 'true'::jsonb) WHERE id = $1 RETURNING *; diff --git a/services/agents-api/internal/db/queries/skills.sql b/services/agents-api/internal/db/queries/skills.sql new file mode 100644 index 000000000..b5dcd37c1 --- /dev/null +++ b/services/agents-api/internal/db/queries/skills.sql @@ -0,0 +1,73 @@ +-- name: CreateSkill :one +INSERT INTO skills (id, tenant_id, name, description, default_version, latest_version, next_version) +VALUES ($1, $2, $3, $4, 1, 1, 2) +RETURNING *; + +-- name: GetSkill :one +SELECT * FROM skills WHERE tenant_id = $1 AND id = $2; + +-- name: LockSkill :one +SELECT * FROM skills WHERE tenant_id = $1 AND id = $2 FOR UPDATE; + +-- name: ListSkills :many +SELECT * FROM skills +WHERE tenant_id = sqlc.arg(tenant_id) + AND (sqlc.narg(after_created)::timestamptz IS NULL + OR (sqlc.arg(ascending)::boolean AND (created_at, id) > (sqlc.narg(after_created)::timestamptz, sqlc.arg(after_id)::uuid)) + OR (NOT sqlc.arg(ascending)::boolean AND (created_at, id) < (sqlc.narg(after_created)::timestamptz, sqlc.arg(after_id)::uuid))) +ORDER BY + CASE WHEN sqlc.arg(ascending)::boolean THEN created_at END ASC, + CASE WHEN sqlc.arg(ascending)::boolean THEN id END ASC, + CASE WHEN NOT sqlc.arg(ascending)::boolean THEN created_at END DESC, + CASE WHEN NOT sqlc.arg(ascending)::boolean THEN id END DESC +LIMIT sqlc.arg(page_limit); + +-- name: SetDefaultSkillVersion :one +UPDATE skills SET default_version = $3 WHERE tenant_id = $1 AND id = $2 RETURNING *; + +-- name: AdvanceSkillVersion :exec +UPDATE skills SET latest_version = next_version, next_version = next_version + 1, + default_version = CASE WHEN sqlc.arg(make_default)::boolean THEN next_version ELSE default_version END +WHERE tenant_id = sqlc.arg(tenant_id) AND id = sqlc.arg(id); + +-- name: DeleteSkill :one +DELETE FROM skills WHERE tenant_id = $1 AND id = $2 RETURNING id; + +-- name: CreateSkillVersion :one +INSERT INTO skill_versions (id, tenant_id, skill_id, version, name, description, contents) +VALUES ($1, $2, $3, $4, $5, $6, $7) +RETURNING id, tenant_id, skill_id, version, name, description, created_at; + +-- name: GetSkillVersion :one +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 +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; + +-- name: ReadSkillVersion :one +SELECT * FROM skill_versions WHERE tenant_id = $1 AND skill_id = $2 AND version = $3; + +-- name: ListSkillVersions :many +SELECT id, tenant_id, skill_id, version, name, description, created_at +FROM skill_versions +WHERE tenant_id = sqlc.arg(tenant_id) AND skill_id = sqlc.arg(skill_id) + AND (sqlc.narg(after_version)::bigint IS NULL + OR (sqlc.arg(ascending)::boolean AND version > sqlc.narg(after_version)::bigint) + OR (NOT sqlc.arg(ascending)::boolean AND version < sqlc.narg(after_version)::bigint)) +ORDER BY CASE WHEN sqlc.arg(ascending)::boolean THEN version END ASC, + CASE WHEN NOT sqlc.arg(ascending)::boolean THEN version END DESC +LIMIT sqlc.arg(page_limit); + +-- name: DeleteSkillVersion :one +DELETE FROM skill_versions WHERE tenant_id = $1 AND skill_id = $2 AND version = $3 +RETURNING id, tenant_id, skill_id, version, name, description, created_at; + +-- name: RefreshLatestSkillVersion :exec +UPDATE skills SET latest_version = (SELECT max(v.version) FROM skill_versions v WHERE v.skill_id = skills.id) +WHERE skills.tenant_id = $1 AND skills.id = $2; + +-- name: ReadDefaultSkillVersion :one +SELECT v.* FROM skill_versions v JOIN skills s ON s.id = v.skill_id AND s.default_version = v.version +WHERE s.tenant_id = $1 AND s.id = $2; diff --git a/services/agents-api/internal/db/sqlc/environment_setup.sql.go b/services/agents-api/internal/db/sqlc/environment_setup.sql.go index a0b20ae00..d7c2945b2 100644 --- a/services/agents-api/internal/db/sqlc/environment_setup.sql.go +++ b/services/agents-api/internal/db/sqlc/environment_setup.sql.go @@ -43,17 +43,18 @@ func (q *Queries) GetEnvironmentSetup(ctx context.Context, arg GetEnvironmentSet } const setSessionSetupMetadata = `-- name: SetSessionSetupMetadata :one -UPDATE sessions SET configuration = jsonb_set(jsonb_set(configuration, '{environment,packages}', $2::jsonb), '{environment,initialization}', 'true'::jsonb) +UPDATE sessions SET configuration = jsonb_set(jsonb_set(jsonb_set(configuration, '{environment,packages}', $2::jsonb), '{environment,skills}', $3::jsonb), '{environment,initialization}', 'true'::jsonb) WHERE id = $1 RETURNING id, tenant_id, engine, metadata, idempotency_key, request_hash, created_at, configuration, event_sequence, creation_request_hash, deleted_at, creator_kind, creator_id ` type SetSessionSetupMetadataParams struct { - ID pgtype.UUID `json:"id"` - Column2 []byte `json:"column_2"` + ID pgtype.UUID `json:"id"` + Packages []byte `json:"packages"` + Skills []byte `json:"skills"` } func (q *Queries) SetSessionSetupMetadata(ctx context.Context, arg SetSessionSetupMetadataParams) (Session, error) { - row := q.db.QueryRow(ctx, setSessionSetupMetadata, arg.ID, arg.Column2) + row := q.db.QueryRow(ctx, setSessionSetupMetadata, arg.ID, arg.Packages, arg.Skills) var i Session err := row.Scan( &i.ID, diff --git a/services/agents-api/internal/db/sqlc/models.go b/services/agents-api/internal/db/sqlc/models.go index 68fac8faa..f07c8e5f9 100644 --- a/services/agents-api/internal/db/sqlc/models.go +++ b/services/agents-api/internal/db/sqlc/models.go @@ -193,6 +193,28 @@ type SessionModelExecution struct { EncryptedConfig []byte `json:"encrypted_config"` } +type Skill struct { + ID pgtype.UUID `json:"id"` + TenantID pgtype.UUID `json:"tenant_id"` + Name string `json:"name"` + Description string `json:"description"` + CreatedAt pgtype.Timestamptz `json:"created_at"` + DefaultVersion int64 `json:"default_version"` + LatestVersion int64 `json:"latest_version"` + NextVersion int64 `json:"next_version"` +} + +type SkillVersion struct { + ID pgtype.UUID `json:"id"` + TenantID pgtype.UUID `json:"tenant_id"` + SkillID pgtype.UUID `json:"skill_id"` + Version int64 `json:"version"` + Name string `json:"name"` + Description string `json:"description"` + CreatedAt pgtype.Timestamptz `json:"created_at"` + Contents []byte `json:"contents"` +} + type SourceFile struct { ID pgtype.UUID `json:"id"` TenantID pgtype.UUID `json:"tenant_id"` diff --git a/services/agents-api/internal/db/sqlc/skills.sql.go b/services/agents-api/internal/db/sqlc/skills.sql.go new file mode 100644 index 000000000..9625f13ff --- /dev/null +++ b/services/agents-api/internal/db/sqlc/skills.sql.go @@ -0,0 +1,500 @@ +// Code generated by sqlc. DO NOT EDIT. +// versions: +// sqlc v1.29.0 +// source: skills.sql + +package sqlc + +import ( + "context" + + "github.com/jackc/pgx/v5/pgtype" +) + +const advanceSkillVersion = `-- name: AdvanceSkillVersion :exec +UPDATE skills SET latest_version = next_version, next_version = next_version + 1, + default_version = CASE WHEN $1::boolean THEN next_version ELSE default_version END +WHERE tenant_id = $2 AND id = $3 +` + +type AdvanceSkillVersionParams struct { + MakeDefault bool `json:"make_default"` + TenantID pgtype.UUID `json:"tenant_id"` + ID pgtype.UUID `json:"id"` +} + +func (q *Queries) AdvanceSkillVersion(ctx context.Context, arg AdvanceSkillVersionParams) error { + _, err := q.db.Exec(ctx, advanceSkillVersion, arg.MakeDefault, arg.TenantID, arg.ID) + return err +} + +const createSkill = `-- name: CreateSkill :one +INSERT INTO skills (id, tenant_id, name, description, default_version, latest_version, next_version) +VALUES ($1, $2, $3, $4, 1, 1, 2) +RETURNING id, tenant_id, name, description, created_at, default_version, latest_version, next_version +` + +type CreateSkillParams struct { + ID pgtype.UUID `json:"id"` + TenantID pgtype.UUID `json:"tenant_id"` + Name string `json:"name"` + Description string `json:"description"` +} + +func (q *Queries) CreateSkill(ctx context.Context, arg CreateSkillParams) (Skill, error) { + row := q.db.QueryRow(ctx, createSkill, + arg.ID, + arg.TenantID, + arg.Name, + arg.Description, + ) + var i Skill + err := row.Scan( + &i.ID, + &i.TenantID, + &i.Name, + &i.Description, + &i.CreatedAt, + &i.DefaultVersion, + &i.LatestVersion, + &i.NextVersion, + ) + return i, err +} + +const createSkillVersion = `-- name: CreateSkillVersion :one +INSERT INTO skill_versions (id, tenant_id, skill_id, version, name, description, contents) +VALUES ($1, $2, $3, $4, $5, $6, $7) +RETURNING id, tenant_id, skill_id, version, name, description, created_at +` + +type CreateSkillVersionParams struct { + ID pgtype.UUID `json:"id"` + TenantID pgtype.UUID `json:"tenant_id"` + SkillID pgtype.UUID `json:"skill_id"` + Version int64 `json:"version"` + Name string `json:"name"` + Description string `json:"description"` + Contents []byte `json:"contents"` +} + +type CreateSkillVersionRow struct { + ID pgtype.UUID `json:"id"` + TenantID pgtype.UUID `json:"tenant_id"` + SkillID pgtype.UUID `json:"skill_id"` + Version int64 `json:"version"` + Name string `json:"name"` + Description string `json:"description"` + CreatedAt pgtype.Timestamptz `json:"created_at"` +} + +func (q *Queries) CreateSkillVersion(ctx context.Context, arg CreateSkillVersionParams) (CreateSkillVersionRow, error) { + row := q.db.QueryRow(ctx, createSkillVersion, + arg.ID, + arg.TenantID, + arg.SkillID, + arg.Version, + arg.Name, + arg.Description, + arg.Contents, + ) + var i CreateSkillVersionRow + err := row.Scan( + &i.ID, + &i.TenantID, + &i.SkillID, + &i.Version, + &i.Name, + &i.Description, + &i.CreatedAt, + ) + return i, err +} + +const deleteSkill = `-- name: DeleteSkill :one +DELETE FROM skills WHERE tenant_id = $1 AND id = $2 RETURNING id +` + +type DeleteSkillParams struct { + TenantID pgtype.UUID `json:"tenant_id"` + ID pgtype.UUID `json:"id"` +} + +func (q *Queries) DeleteSkill(ctx context.Context, arg DeleteSkillParams) (pgtype.UUID, error) { + row := q.db.QueryRow(ctx, deleteSkill, arg.TenantID, arg.ID) + var id pgtype.UUID + err := row.Scan(&id) + return id, err +} + +const deleteSkillVersion = `-- name: DeleteSkillVersion :one +DELETE FROM skill_versions WHERE tenant_id = $1 AND skill_id = $2 AND version = $3 +RETURNING id, tenant_id, skill_id, version, name, description, created_at +` + +type DeleteSkillVersionParams struct { + TenantID pgtype.UUID `json:"tenant_id"` + SkillID pgtype.UUID `json:"skill_id"` + Version int64 `json:"version"` +} + +type DeleteSkillVersionRow struct { + ID pgtype.UUID `json:"id"` + TenantID pgtype.UUID `json:"tenant_id"` + SkillID pgtype.UUID `json:"skill_id"` + Version int64 `json:"version"` + Name string `json:"name"` + Description string `json:"description"` + CreatedAt pgtype.Timestamptz `json:"created_at"` +} + +func (q *Queries) DeleteSkillVersion(ctx context.Context, arg DeleteSkillVersionParams) (DeleteSkillVersionRow, error) { + row := q.db.QueryRow(ctx, deleteSkillVersion, arg.TenantID, arg.SkillID, arg.Version) + var i DeleteSkillVersionRow + err := row.Scan( + &i.ID, + &i.TenantID, + &i.SkillID, + &i.Version, + &i.Name, + &i.Description, + &i.CreatedAt, + ) + return i, err +} + +const getSkill = `-- name: GetSkill :one +SELECT id, tenant_id, name, description, created_at, default_version, latest_version, next_version FROM skills WHERE tenant_id = $1 AND id = $2 +` + +type GetSkillParams struct { + TenantID pgtype.UUID `json:"tenant_id"` + ID pgtype.UUID `json:"id"` +} + +func (q *Queries) GetSkill(ctx context.Context, arg GetSkillParams) (Skill, error) { + row := q.db.QueryRow(ctx, getSkill, arg.TenantID, arg.ID) + var i Skill + err := row.Scan( + &i.ID, + &i.TenantID, + &i.Name, + &i.Description, + &i.CreatedAt, + &i.DefaultVersion, + &i.LatestVersion, + &i.NextVersion, + ) + return i, err +} + +const getSkillVersion = `-- name: GetSkillVersion :one +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 +` + +type GetSkillVersionParams struct { + TenantID pgtype.UUID `json:"tenant_id"` + SkillID pgtype.UUID `json:"skill_id"` + Version int64 `json:"version"` +} + +type GetSkillVersionRow struct { + ID pgtype.UUID `json:"id"` + TenantID pgtype.UUID `json:"tenant_id"` + SkillID pgtype.UUID `json:"skill_id"` + Version int64 `json:"version"` + Name string `json:"name"` + Description string `json:"description"` + CreatedAt pgtype.Timestamptz `json:"created_at"` +} + +func (q *Queries) GetSkillVersion(ctx context.Context, arg GetSkillVersionParams) (GetSkillVersionRow, error) { + row := q.db.QueryRow(ctx, getSkillVersion, arg.TenantID, arg.SkillID, arg.Version) + var i GetSkillVersionRow + err := row.Scan( + &i.ID, + &i.TenantID, + &i.SkillID, + &i.Version, + &i.Name, + &i.Description, + &i.CreatedAt, + ) + return i, err +} + +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 +` + +type GetSkillVersionByIDParams struct { + TenantID pgtype.UUID `json:"tenant_id"` + SkillID pgtype.UUID `json:"skill_id"` + ID pgtype.UUID `json:"id"` +} + +type GetSkillVersionByIDRow struct { + ID pgtype.UUID `json:"id"` + TenantID pgtype.UUID `json:"tenant_id"` + SkillID pgtype.UUID `json:"skill_id"` + Version int64 `json:"version"` + Name string `json:"name"` + Description string `json:"description"` + CreatedAt pgtype.Timestamptz `json:"created_at"` +} + +func (q *Queries) GetSkillVersionByID(ctx context.Context, arg GetSkillVersionByIDParams) (GetSkillVersionByIDRow, error) { + row := q.db.QueryRow(ctx, getSkillVersionByID, arg.TenantID, arg.SkillID, arg.ID) + var i GetSkillVersionByIDRow + err := row.Scan( + &i.ID, + &i.TenantID, + &i.SkillID, + &i.Version, + &i.Name, + &i.Description, + &i.CreatedAt, + ) + return i, err +} + +const listSkillVersions = `-- name: ListSkillVersions :many +SELECT id, tenant_id, skill_id, version, name, description, created_at +FROM skill_versions +WHERE tenant_id = $1 AND skill_id = $2 + AND ($3::bigint IS NULL + OR ($4::boolean AND version > $3::bigint) + OR (NOT $4::boolean AND version < $3::bigint)) +ORDER BY CASE WHEN $4::boolean THEN version END ASC, + CASE WHEN NOT $4::boolean THEN version END DESC +LIMIT $5 +` + +type ListSkillVersionsParams struct { + TenantID pgtype.UUID `json:"tenant_id"` + SkillID pgtype.UUID `json:"skill_id"` + AfterVersion pgtype.Int8 `json:"after_version"` + Ascending bool `json:"ascending"` + PageLimit int32 `json:"page_limit"` +} + +type ListSkillVersionsRow struct { + ID pgtype.UUID `json:"id"` + TenantID pgtype.UUID `json:"tenant_id"` + SkillID pgtype.UUID `json:"skill_id"` + Version int64 `json:"version"` + Name string `json:"name"` + Description string `json:"description"` + CreatedAt pgtype.Timestamptz `json:"created_at"` +} + +func (q *Queries) ListSkillVersions(ctx context.Context, arg ListSkillVersionsParams) ([]ListSkillVersionsRow, error) { + rows, err := q.db.Query(ctx, listSkillVersions, + arg.TenantID, + arg.SkillID, + arg.AfterVersion, + arg.Ascending, + arg.PageLimit, + ) + if err != nil { + return nil, err + } + defer rows.Close() + items := []ListSkillVersionsRow{} + for rows.Next() { + var i ListSkillVersionsRow + if err := rows.Scan( + &i.ID, + &i.TenantID, + &i.SkillID, + &i.Version, + &i.Name, + &i.Description, + &i.CreatedAt, + ); err != nil { + return nil, err + } + items = append(items, i) + } + if err := rows.Err(); err != nil { + return nil, err + } + return items, nil +} + +const listSkills = `-- name: ListSkills :many +SELECT id, tenant_id, name, description, created_at, default_version, latest_version, next_version FROM skills +WHERE tenant_id = $1 + AND ($2::timestamptz IS NULL + OR ($3::boolean AND (created_at, id) > ($2::timestamptz, $4::uuid)) + OR (NOT $3::boolean AND (created_at, id) < ($2::timestamptz, $4::uuid))) +ORDER BY + CASE WHEN $3::boolean THEN created_at END ASC, + CASE WHEN $3::boolean THEN id END ASC, + CASE WHEN NOT $3::boolean THEN created_at END DESC, + CASE WHEN NOT $3::boolean THEN id END DESC +LIMIT $5 +` + +type ListSkillsParams struct { + TenantID pgtype.UUID `json:"tenant_id"` + AfterCreated pgtype.Timestamptz `json:"after_created"` + Ascending bool `json:"ascending"` + AfterID pgtype.UUID `json:"after_id"` + PageLimit int32 `json:"page_limit"` +} + +func (q *Queries) ListSkills(ctx context.Context, arg ListSkillsParams) ([]Skill, error) { + rows, err := q.db.Query(ctx, listSkills, + arg.TenantID, + arg.AfterCreated, + arg.Ascending, + arg.AfterID, + arg.PageLimit, + ) + if err != nil { + return nil, err + } + defer rows.Close() + items := []Skill{} + for rows.Next() { + var i Skill + if err := rows.Scan( + &i.ID, + &i.TenantID, + &i.Name, + &i.Description, + &i.CreatedAt, + &i.DefaultVersion, + &i.LatestVersion, + &i.NextVersion, + ); err != nil { + return nil, err + } + items = append(items, i) + } + if err := rows.Err(); err != nil { + return nil, err + } + return items, nil +} + +const lockSkill = `-- name: LockSkill :one +SELECT id, tenant_id, name, description, created_at, default_version, latest_version, next_version FROM skills WHERE tenant_id = $1 AND id = $2 FOR UPDATE +` + +type LockSkillParams struct { + TenantID pgtype.UUID `json:"tenant_id"` + ID pgtype.UUID `json:"id"` +} + +func (q *Queries) LockSkill(ctx context.Context, arg LockSkillParams) (Skill, error) { + row := q.db.QueryRow(ctx, lockSkill, arg.TenantID, arg.ID) + var i Skill + err := row.Scan( + &i.ID, + &i.TenantID, + &i.Name, + &i.Description, + &i.CreatedAt, + &i.DefaultVersion, + &i.LatestVersion, + &i.NextVersion, + ) + return i, err +} + +const readDefaultSkillVersion = `-- name: ReadDefaultSkillVersion :one +SELECT v.id, v.tenant_id, v.skill_id, v.version, v.name, v.description, v.created_at, v.contents FROM skill_versions v JOIN skills s ON s.id = v.skill_id AND s.default_version = v.version +WHERE s.tenant_id = $1 AND s.id = $2 +` + +type ReadDefaultSkillVersionParams struct { + TenantID pgtype.UUID `json:"tenant_id"` + ID pgtype.UUID `json:"id"` +} + +func (q *Queries) ReadDefaultSkillVersion(ctx context.Context, arg ReadDefaultSkillVersionParams) (SkillVersion, error) { + row := q.db.QueryRow(ctx, readDefaultSkillVersion, arg.TenantID, arg.ID) + var i SkillVersion + err := row.Scan( + &i.ID, + &i.TenantID, + &i.SkillID, + &i.Version, + &i.Name, + &i.Description, + &i.CreatedAt, + &i.Contents, + ) + return i, err +} + +const readSkillVersion = `-- name: ReadSkillVersion :one +SELECT id, tenant_id, skill_id, version, name, description, created_at, contents FROM skill_versions WHERE tenant_id = $1 AND skill_id = $2 AND version = $3 +` + +type ReadSkillVersionParams struct { + TenantID pgtype.UUID `json:"tenant_id"` + SkillID pgtype.UUID `json:"skill_id"` + Version int64 `json:"version"` +} + +func (q *Queries) ReadSkillVersion(ctx context.Context, arg ReadSkillVersionParams) (SkillVersion, error) { + row := q.db.QueryRow(ctx, readSkillVersion, arg.TenantID, arg.SkillID, arg.Version) + var i SkillVersion + err := row.Scan( + &i.ID, + &i.TenantID, + &i.SkillID, + &i.Version, + &i.Name, + &i.Description, + &i.CreatedAt, + &i.Contents, + ) + return i, err +} + +const refreshLatestSkillVersion = `-- name: RefreshLatestSkillVersion :exec +UPDATE skills SET latest_version = (SELECT max(v.version) FROM skill_versions v WHERE v.skill_id = skills.id) +WHERE skills.tenant_id = $1 AND skills.id = $2 +` + +type RefreshLatestSkillVersionParams struct { + TenantID pgtype.UUID `json:"tenant_id"` + ID pgtype.UUID `json:"id"` +} + +func (q *Queries) RefreshLatestSkillVersion(ctx context.Context, arg RefreshLatestSkillVersionParams) error { + _, err := q.db.Exec(ctx, refreshLatestSkillVersion, arg.TenantID, arg.ID) + return err +} + +const setDefaultSkillVersion = `-- name: SetDefaultSkillVersion :one +UPDATE skills SET default_version = $3 WHERE tenant_id = $1 AND id = $2 RETURNING id, tenant_id, name, description, created_at, default_version, latest_version, next_version +` + +type SetDefaultSkillVersionParams struct { + TenantID pgtype.UUID `json:"tenant_id"` + ID pgtype.UUID `json:"id"` + DefaultVersion int64 `json:"default_version"` +} + +func (q *Queries) SetDefaultSkillVersion(ctx context.Context, arg SetDefaultSkillVersionParams) (Skill, error) { + row := q.db.QueryRow(ctx, setDefaultSkillVersion, arg.TenantID, arg.ID, arg.DefaultVersion) + var i Skill + err := row.Scan( + &i.ID, + &i.TenantID, + &i.Name, + &i.Description, + &i.CreatedAt, + &i.DefaultVersion, + &i.LatestVersion, + &i.NextVersion, + ) + return i, err +} diff --git a/services/agents-api/internal/execution/environment_placement.go b/services/agents-api/internal/execution/environment_placement.go index 48b9e211c..8ffefa252 100644 --- a/services/agents-api/internal/execution/environment_placement.go +++ b/services/agents-api/internal/execution/environment_placement.go @@ -9,19 +9,18 @@ import ( 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/internal/agentnetwork" - "github.com/MiniMax-AI-Dev/parsar/internal/agentskill" "github.com/MiniMax-AI-Dev/parsar/services/agents-api/internal/store" ) type environmentPlacement struct { - Skills []agentskill.Metadata `json:"skills,omitempty"` - Type string `json:"type"` - ToolEnvironment bool `json:"initialization,omitempty"` - SystemPackages bool `json:"-"` - NetworkAccess string `json:"-"` - AllowedDomains []string `json:"-"` - WorkspaceDirectory string `json:"workspace_directory"` - CapabilityDirectories []string `json:"capability_directories"` + Skills []store.EnvironmentSkillMetadata `json:"skills,omitempty"` + Type string `json:"type"` + ToolEnvironment bool `json:"initialization,omitempty"` + SystemPackages bool `json:"-"` + NetworkAccess string `json:"-"` + AllowedDomains []string `json:"-"` + WorkspaceDirectory string `json:"workspace_directory"` + CapabilityDirectories []string `json:"capability_directories"` } // LocalWorkspaceConfiguration recognizes the qualified stored V1 profile. It @@ -44,12 +43,12 @@ func parseEnvironmentPlacement(configuration json.RawMessage) (environmentPlacem case "openai_hosted": // Stored policy is shared by preparation and provider bootstrap. var local struct { - Skills []agentskill.Metadata `json:"skills,omitempty"` - Files []store.InitialFileMetadata `json:"files"` - Packages *v1.EnvironmentPackages `json:"packages,omitempty"` - Initialization bool `json:"initialization,omitempty"` - Type string `json:"type"` - CapabilityDirectories []string `json:"capability_directories"` + Skills []store.EnvironmentSkillMetadata `json:"skills,omitempty"` + Files []store.InitialFileMetadata `json:"files"` + Packages *v1.EnvironmentPackages `json:"packages,omitempty"` + Initialization bool `json:"initialization,omitempty"` + Type string `json:"type"` + CapabilityDirectories []string `json:"capability_directories"` Network *struct { Access string `json:"access"` AllowedDomains []string `json:"allowed_domains"` @@ -92,7 +91,13 @@ func (d *Dispatcher) configurePreparedEnvironment(ctx context.Context, session s if placement.SystemPackages && !placement.ToolEnvironment { return nil, store.ErrInvalidInput } - req.LocalEnvironment = &proto.LocalEnvironment{ID: environment.ID, ToolEnvironment: placement.ToolEnvironment, SystemPackages: placement.SystemPackages, Skills: placement.Skills} + req.LocalEnvironment = &proto.LocalEnvironment{ID: environment.ID, ToolEnvironment: placement.ToolEnvironment, SystemPackages: placement.SystemPackages} + for _, metadata := range placement.Skills { + if store.ValidateInstalledSkillMetadata(metadata) != nil { + return nil, store.ErrInvalidInput + } + req.LocalEnvironment.Skills = append(req.LocalEnvironment.Skills, (store.EnvironmentSkill{Metadata: metadata}).InstallationMetadata()) + } req.LocalEnvironment.NetworkAccess = placement.NetworkAccess req.LocalEnvironment.AllowedDomains = append([]string(nil), placement.AllowedDomains...) return nil, nil diff --git a/services/agents-api/internal/execution/environment_placement_test.go b/services/agents-api/internal/execution/environment_placement_test.go index e35a331f4..2cb535ca8 100644 --- a/services/agents-api/internal/execution/environment_placement_test.go +++ b/services/agents-api/internal/execution/environment_placement_test.go @@ -1,7 +1,9 @@ package execution import ( + "bytes" "context" + "encoding/json" "slices" "testing" @@ -9,6 +11,28 @@ import ( "github.com/MiniMax-AI-Dev/parsar/services/agents-api/internal/store" ) +func TestSkillReferenceIdentityStopsAtCoreBoundary(t *testing.T) { + session := store.Session{ID: "session", TenantID: "tenant"} + environment := store.Environment{ID: "environment", SessionID: session.ID, TenantID: session.TenantID, + Configuration: []byte(`{"type":"openai_hosted","initialization":true,"skills":[{"type":"skill_reference","skill_id":"skill-private","version":"1","name":"proof","description":"A proof."}]}`)} + var request proto.PromptRequestPayload + _, err := (&Dispatcher{}).configurePreparedEnvironment(t.Context(), session, environment, store.ExecutionDevice{EnvironmentID: environment.ID}, &request) + if err != nil || request.LocalEnvironment == nil || len(request.LocalEnvironment.Skills) != 1 || request.LocalEnvironment.Skills[0].Name != "proof" || request.LocalEnvironment.Skills[0].Type != "inline" { + t.Fatal("resolved Skill did not use the common installation descriptor", err) + } + raw, err := json.Marshal(request.LocalEnvironment) + if err != nil || bytes.Contains(raw, []byte("skill-private")) || bytes.Contains(raw, []byte("skill_reference")) { + t.Fatal("public source identity reached Runtime", err) + } + environment.Configuration = []byte(`{"type":"openai_hosted","skills":[{"type":"skill_reference","skill_id":"skill-private","version":"latest"}]}`) + if !LocalWorkspaceConfiguration(environment.Configuration) { + t.Fatal("admission demanded installed metadata before the creation transaction") + } + if _, err := (&Dispatcher{}).configurePreparedEnvironment(t.Context(), session, environment, store.ExecutionDevice{EnvironmentID: environment.ID}, &proto.PromptRequestPayload{}); err == nil { + t.Fatal("execution received an unresolved Skill selector") + } +} + func TestLocalEnvironmentRequiresQualifiedProfileAndExactAuthority(t *testing.T) { for _, configuration := range []string{ `{"type":"openai_hosted","network":{}}`, diff --git a/services/agents-api/internal/execution/runtime_setup.go b/services/agents-api/internal/execution/runtime_setup.go index ed61ec3ae..622e86e97 100644 --- a/services/agents-api/internal/execution/runtime_setup.go +++ b/services/agents-api/internal/execution/runtime_setup.go @@ -13,16 +13,16 @@ import ( // runtimeSetupOperation is the packaged initializer's confidential stdin contract. // Public templates and native harness configuration never cross this boundary. type runtimeSetupOperation struct { - Skill *store.InlineSkill `json:"-"` - Name string `json:"name,omitempty"` - Files []agentskill.File `json:"files,omitempty"` - Version int `json:"version"` - Action string `json:"action"` - Network string `json:"network,omitempty"` - Env map[string]string `json:"env"` - Packages []string `json:"packages,omitempty"` - Command string `json:"command,omitempty"` - CWD string `json:"cwd,omitempty"` + Skill *store.EnvironmentSkill `json:"-"` + Name string `json:"name,omitempty"` + Files []agentskill.File `json:"files,omitempty"` + Version int `json:"version"` + Action string `json:"action"` + Network string `json:"network,omitempty"` + Env map[string]string `json:"env"` + Packages []string `json:"packages,omitempty"` + Command string `json:"command,omitempty"` + CWD string `json:"cwd,omitempty"` } func setupOperations(setup store.EnvironmentSetup) []runtimeSetupOperation { @@ -64,7 +64,7 @@ func runRuntimeSetup(ctx context.Context, provider sandbox.Provider, reference s return sandbox.ErrInvalid } if operation.Skill != nil { - files, err := agentskill.Read(operation.Skill.Archive, operation.Skill.Metadata) + files, err := agentskill.Read(operation.Skill.Archive, operation.Skill.InstallationMetadata()) if err != nil { return err } diff --git a/services/agents-api/internal/store/environment_setup.go b/services/agents-api/internal/store/environment_setup.go index 0e39e8932..b2cdf0a07 100644 --- a/services/agents-api/internal/store/environment_setup.go +++ b/services/agents-api/internal/store/environment_setup.go @@ -22,7 +22,7 @@ type EnvironmentSetup struct { Env map[string]string `json:"env,omitempty"` Commands []SetupCommand `json:"setup_commands,omitempty"` Packages v1.EnvironmentPackages `json:"packages"` - Skills []InlineSkill `json:"skills,omitempty"` + Skills []EnvironmentSkill `json:"skills,omitempty"` } type SetupCommand struct { @@ -37,7 +37,11 @@ func (s EnvironmentSetup) Empty() bool { } func (s EnvironmentSetup) Validate() error { - if ValidateInlineSkills(s.Skills) != nil { + return s.validate(false) +} + +func (s EnvironmentSetup) validate(installed bool) error { + if validateEnvironmentSkills(s.Skills, installed) != nil { return ErrInvalidInput } ordinary := s @@ -94,7 +98,7 @@ func (s *Store) openEnvironmentSetup(tenant, resource, id, field string, ciphert } func (s *Store) saveEnvironmentSetup(ctx context.Context, q *sqlc.Queries, tenant string, session pgtype.UUID, setup EnvironmentSetup) error { - if err := setup.Validate(); err != nil { + if err := setup.validate(true); err != nil { return err } if setup.Empty() { @@ -127,7 +131,7 @@ func (s *Store) ReadEnvironmentSetup(ctx context.Context, tenant, session string if err = s.openEnvironmentSetup(uuid.UUID(lookup.TenantID.Bytes).String(), "session", uuid.UUID(lookup.ID.Bytes).String(), "initialization", encrypted, &result); err != nil { return result, err } - return result, result.Validate() + return result, result.validate(true) } func (s EnvironmentSetup) PackageMetadata() v1.EnvironmentPackages { diff --git a/services/agents-api/internal/store/environment_skill_references.go b/services/agents-api/internal/store/environment_skill_references.go new file mode 100644 index 000000000..89ba8679a --- /dev/null +++ b/services/agents-api/internal/store/environment_skill_references.go @@ -0,0 +1,79 @@ +package store + +import ( + "context" + "errors" + "sort" + "strconv" + + "github.com/MiniMax-AI-Dev/parsar/services/agents-api/internal/db/sqlc" + "github.com/jackc/pgx/v5" +) + +// freezeEnvironmentSkills runs only for a newly inserted Session, in its transaction. +// Resource locks serialize selection with pointer changes, version deletion and +// resource deletion. The returned copy no longer depends on any source resource. +func (s *Store) freezeEnvironmentSkills(ctx context.Context, q *sqlc.Queries, tenantID string, setup EnvironmentSetup) (EnvironmentSetup, error) { + if err := setup.Validate(); err != nil { + return EnvironmentSetup{}, err + } + owners := make(map[string]sqlc.Skill) + for _, skill := range setup.Skills { + if skill.Metadata.Type == "skill_reference" { + owners[skill.Metadata.SkillID] = sqlc.Skill{} + } + } + // Opposite caller list orders must not produce opposite database lock orders. + ids := make([]string, 0, len(owners)) + for id := range owners { + ids = append(ids, id) + } + sort.Strings(ids) + for _, id := range ids { + tenant, skill, err := skillIDs(tenantID, id) + if err != nil { + return EnvironmentSetup{}, err + } + owner, err := q.LockSkill(ctx, sqlc.LockSkillParams{TenantID: tenant, ID: skill}) + if errors.Is(err, pgx.ErrNoRows) { + err = ErrNotFound + } + if err != nil { + return EnvironmentSetup{}, err + } + owners[id] = owner + } + result := setup + result.Skills = append([]EnvironmentSkill(nil), setup.Skills...) + for i, skill := range result.Skills { + if skill.Metadata.Type != "skill_reference" { + continue + } + owner := owners[skill.Metadata.SkillID] + number := owner.DefaultVersion + switch skill.Metadata.Version { + case "": + case "latest": + number = owner.LatestVersion + default: + var err error + number, err = skillVersionNumber(skill.Metadata.Version) + if err != nil { + return EnvironmentSetup{}, err + } + } + row, err := q.ReadSkillVersion(ctx, sqlc.ReadSkillVersionParams{TenantID: owner.TenantID, SkillID: owner.ID, Version: number}) + if errors.Is(err, pgx.ErrNoRows) { + err = ErrNotFound + } + if err != nil { + return EnvironmentSetup{}, err + } + version, archive, err := s.openSkillVersion(row) + if err != nil { + return EnvironmentSetup{}, err + } + result.Skills[i] = EnvironmentSkill{Metadata: EnvironmentSkillMetadata{Type: "skill_reference", SkillID: version.SkillID, Version: strconv.FormatInt(version.Version, 10), Name: version.Name, Description: version.Description}, Archive: archive} + } + return result, result.validate(true) +} diff --git a/services/agents-api/internal/store/environment_skill_references_test.go b/services/agents-api/internal/store/environment_skill_references_test.go new file mode 100644 index 000000000..9f7b0c480 --- /dev/null +++ b/services/agents-api/internal/store/environment_skill_references_test.go @@ -0,0 +1,145 @@ +package store + +import ( + "bytes" + "encoding/json" + "errors" + "sync" + "testing" + + "github.com/MiniMax-AI-Dev/parsar/services/agents-api/internal/credentialcrypto" + "github.com/google/uuid" +) + +func TestSkillReferencesFreezeWithinSessionCreation(t *testing.T) { + _, pool := testStore(t) + cipher, err := credentialcrypto.New(bytes.Repeat([]byte{61}, 32)) + if err != nil { + t.Fatal(err) + } + s := NewWithCredentialCipher(pool, cipher) + tenant := uuid.NewString() + first, second := skillArchive(t, "frozen-first"), skillArchive(t, "frozen-second") + skill, err := s.CreateSkill(t.Context(), tenant, first) + if err != nil { + t.Fatal(err) + } + if _, err = s.CreateSkillVersion(t.Context(), tenant, skill.ID, second, false); err != nil { + t.Fatal(err) + } + intent := EnvironmentSetup{Skills: []EnvironmentSkill{{Metadata: EnvironmentSkillMetadata{Type: "skill_reference", SkillID: skill.ID}}}} + template, err := s.CreateEnvironmentTemplate(t.Context(), tenant, EnvironmentTemplateInput{SetSkills: true, Initialization: intent}) + if err != nil { + t.Fatal(err) + } + resolved, _, err := s.ResolveEnvironmentTemplate(t.Context(), tenant, template.ID) + if err != nil || resolved.Skills[0].Version != "" || len(resolved.Initialization.Skills[0].Archive) != 0 { + t.Fatal("template resolved a mutable selector", err) + } + input := CreateSessionInput{Creator: FixtureCreator(), Engine: "codex", IdempotencyKey: uuid.NewString(), Configuration: json.RawMessage(`{"environment":{"type":"openai_hosted"}}`), Initialization: resolved.Initialization} + // Concurrent callers share one Session and one frozen installation. + var group sync.WaitGroup + ids := make(chan string, 6) + failures := make(chan error, 6) + for range 6 { + group.Add(1) + go func() { + defer group.Done() + session, err := s.CreateSession(t.Context(), tenant, input) + if err != nil { + failures <- err + } else { + ids <- session.ID + } + }() + } + group.Wait() + close(ids) + close(failures) + for err := range failures { + t.Fatal(err) + } + sessionID := "" + for id := range ids { + if sessionID != "" && sessionID != id { + t.Fatal("concurrent retry created a second Session") + } + sessionID = id + } + assertFrozen := func(id, version string, archive []byte) { + t.Helper() + setup, err := s.ReadEnvironmentSetup(t.Context(), tenant, id) + if err != nil || len(setup.Skills) != 1 || setup.Skills[0].Metadata.Type != "skill_reference" || setup.Skills[0].Metadata.SkillID != skill.ID || setup.Skills[0].Metadata.Version != version || !bytes.Equal(setup.Skills[0].Archive, archive) { + t.Fatal("incorrect frozen installation", err) + } + session, err := s.GetSession(t.Context(), tenant, id) + if err != nil { + t.Fatal(err) + } + var cfg struct { + Environment struct { + Skills []EnvironmentSkillMetadata `json:"skills"` + } `json:"environment"` + } + if json.Unmarshal(session.Configuration, &cfg) != nil || len(cfg.Environment.Skills) != 1 || cfg.Environment.Skills[0] != setup.Skills[0].Metadata || bytes.Contains(session.Configuration, archive) { + t.Fatal("public snapshot differs from frozen contents") + } + } + assertFrozen(sessionID, "1", first) + if input.Initialization.Skills[0].Metadata.Version != "" || len(input.Initialization.Skills[0].Archive) != 0 { + t.Fatal("creation mutated caller intent") + } + if _, err = s.UpdateSkillDefault(t.Context(), tenant, skill.ID, "2"); err != nil { + t.Fatal(err) + } + for _, selector := range []string{"", "latest", "1"} { + next := input + next.IdempotencyKey = uuid.NewString() + next.Initialization.Skills = []EnvironmentSkill{{Metadata: EnvironmentSkillMetadata{Type: "skill_reference", SkillID: skill.ID, Version: selector}}} + created, err := s.CreateSession(t.Context(), tenant, next) + if err != nil { + t.Fatal(err) + } + if selector == "1" { + assertFrozen(created.ID, "1", first) + } else { + assertFrozen(created.ID, "2", second) + } + } + if _, err = s.DeleteEnvironmentTemplate(t.Context(), tenant, template.ID); err != nil { + t.Fatal(err) + } + if err = s.DeleteSkill(t.Context(), tenant, skill.ID); err != nil { + t.Fatal(err) + } + retry, err := s.CreateSession(t.Context(), tenant, input) + if err != nil || retry.ID != sessionID { + t.Fatal("committed retry read deleted sources", err) + } + assertFrozen(sessionID, "1", first) + if _, err = s.ReadEnvironmentSetup(t.Context(), uuid.NewString(), sessionID); !errors.Is(err, ErrNotFound) { + t.Fatal("foreign tenant read frozen Skill", err) + } +} + +func TestSkillReferenceAuthorizationRollsBackSession(t *testing.T) { + _, pool := testStore(t) + cipher, err := credentialcrypto.New(bytes.Repeat([]byte{62}, 32)) + if err != nil { + t.Fatal(err) + } + s := NewWithCredentialCipher(pool, cipher) + tenant, foreign := uuid.NewString(), uuid.NewString() + skill, err := s.CreateSkill(t.Context(), tenant, skillArchive(t, "private-owner")) + if err != nil { + t.Fatal(err) + } + input := CreateSessionInput{Creator: FixtureCreator(), Engine: "codex", IdempotencyKey: uuid.NewString(), Configuration: json.RawMessage(`{"environment":{"type":"openai_hosted"}}`), Initialization: EnvironmentSetup{Skills: []EnvironmentSkill{{Metadata: EnvironmentSkillMetadata{Type: "skill_reference", SkillID: skill.ID}}}}} + if _, err := s.CreateSession(t.Context(), foreign, input); !errors.Is(err, ErrNotFound) { + t.Fatal("foreign reference accepted", err) + } + var count int + if err := pool.QueryRow(t.Context(), "SELECT count(*) FROM sessions WHERE tenant_id=$1", foreign).Scan(&count); err != nil || count != 0 { + t.Fatal("failed reference left a Session", count, err) + } +} diff --git a/services/agents-api/internal/store/environment_skills.go b/services/agents-api/internal/store/environment_skills.go index dd23ddf29..0bf74ffc1 100644 --- a/services/agents-api/internal/store/environment_skills.go +++ b/services/agents-api/internal/store/environment_skills.go @@ -6,15 +6,57 @@ import ( "github.com/MiniMax-AI-Dev/parsar/internal/agentskill" ) -// InlineSkill is confidential immutable initialization input. Only Metadata is public. -type InlineSkill struct { - Metadata agentskill.Metadata `json:"metadata"` - Archive []byte `json:"archive"` +// EnvironmentSkillMetadata is either template intent or concrete installed metadata. +// References acquire their name, description and concrete version at Session commit. +type EnvironmentSkillMetadata struct { + Type string `json:"type"` + Name string `json:"name,omitempty"` + Description string `json:"description,omitempty"` + SkillID string `json:"skill_id,omitempty"` + Version string `json:"version,omitempty"` +} + +// EnvironmentSkill keeps confidential content separate from public metadata. +// A template reference has no archive; a committed Session always has frozen bytes. +type EnvironmentSkill struct { + Metadata EnvironmentSkillMetadata `json:"metadata"` + Archive []byte `json:"archive,omitempty"` +} + +// InstallationMetadata removes public reference identity at the installer boundary. +func (s EnvironmentSkill) InstallationMetadata() agentskill.Metadata { + return agentskill.Metadata{Type: "inline", Name: s.Metadata.Name, Description: s.Metadata.Description} } const MaxSkillsArchiveBytes = 10 << 20 -func ValidateInlineSkills(skills []InlineSkill) error { +func ValidateEnvironmentSkills(skills []EnvironmentSkill) error { + return validateEnvironmentSkills(skills, false) +} + +func ValidateInstalledSkillMetadata(metadata EnvironmentSkillMetadata) error { + if metadata.Name == "" || metadata.Description == "" { + return ErrInvalidInput + } + switch metadata.Type { + case "inline": + if metadata.SkillID != "" || metadata.Version != "" { + return ErrInvalidInput + } + case "skill_reference": + if metadata.SkillID == "" { + return ErrInvalidInput + } + if _, err := skillVersionNumber(metadata.Version); err != nil { + return err + } + default: + return ErrInvalidInput + } + return nil +} + +func validateEnvironmentSkills(skills []EnvironmentSkill, installed bool) error { if len(skills) > 50 { return ErrInvalidInput } @@ -22,12 +64,27 @@ func ValidateInlineSkills(skills []InlineSkill) error { total := 0 expanded := 0 for _, skill := range skills { + if !installed && skill.Metadata.Type == "skill_reference" { + m := skill.Metadata + if m.SkillID == "" || len(m.SkillID) > 256 || m.Name != "" || m.Description != "" || len(skill.Archive) != 0 { + return ErrInvalidInput + } + if m.Version != "" && m.Version != "latest" { + if _, err := skillVersionNumber(m.Version); err != nil { + return err + } + } + continue + } + if err := ValidateInstalledSkillMetadata(skill.Metadata); err != nil { + return err + } total += len(skill.Archive) if total > MaxSkillsArchiveBytes || seen[skill.Metadata.Name] { return ErrInvalidInput } seen[skill.Metadata.Name] = true - files, err := agentskill.Read(skill.Archive, skill.Metadata) + files, err := agentskill.Read(skill.Archive, skill.InstallationMetadata()) if err != nil { return ErrInvalidInput } @@ -41,8 +98,8 @@ func ValidateInlineSkills(skills []InlineSkill) error { return nil } -func (s EnvironmentSetup) SkillMetadata() []agentskill.Metadata { - result := make([]agentskill.Metadata, 0, len(s.Skills)) +func (s EnvironmentSetup) SkillMetadata() []EnvironmentSkillMetadata { + result := make([]EnvironmentSkillMetadata, 0, len(s.Skills)) for _, skill := range s.Skills { result = append(result, skill.Metadata) } diff --git a/services/agents-api/internal/store/environment_skills_test.go b/services/agents-api/internal/store/environment_skills_test.go index 842f23cf4..badd2c45e 100644 --- a/services/agents-api/internal/store/environment_skills_test.go +++ b/services/agents-api/internal/store/environment_skills_test.go @@ -8,7 +8,6 @@ import ( "reflect" "testing" - "github.com/MiniMax-AI-Dev/parsar/internal/agentskill" "github.com/MiniMax-AI-Dev/parsar/services/agents-api/internal/credentialcrypto" "github.com/google/uuid" ) @@ -33,7 +32,7 @@ func TestSkillsEncryptedTemplateAndFrozenSession(t *testing.T) { if err = writer.Close(); err != nil { t.Fatal(err) } - setup := EnvironmentSetup{Skills: []InlineSkill{{Metadata: agentskill.Metadata{Type: "inline", Name: "proof", Description: "A proof."}, Archive: archive.Bytes()}}} + setup := EnvironmentSetup{Skills: []EnvironmentSkill{{Metadata: EnvironmentSkillMetadata{Type: "inline", Name: "proof", Description: "A proof."}, Archive: archive.Bytes()}}} tenant, foreign := uuid.NewString(), uuid.NewString() template, err := s.CreateEnvironmentTemplate(t.Context(), tenant, EnvironmentTemplateInput{SetSkills: true, Initialization: setup}) if err != nil { diff --git a/services/agents-api/internal/store/environment_templates.go b/services/agents-api/internal/store/environment_templates.go index 31a7c9de9..794ce1d05 100644 --- a/services/agents-api/internal/store/environment_templates.go +++ b/services/agents-api/internal/store/environment_templates.go @@ -5,7 +5,6 @@ import ( "encoding/json" "errors" "github.com/MiniMax-AI-Dev/parsar/internal/agentnetwork" - "github.com/MiniMax-AI-Dev/parsar/internal/agentskill" "time" "unicode/utf8" @@ -20,7 +19,7 @@ import ( // EnvironmentTemplate is configuration ownership, independent of provider images. type EnvironmentTemplate struct { - Skills []agentskill.Metadata + Skills []EnvironmentSkillMetadata Packages v1.EnvironmentPackages Initialization EnvironmentSetup Files []InitialFileMetadata diff --git a/services/agents-api/internal/store/session_configuration_test.go b/services/agents-api/internal/store/session_configuration_test.go index 819b489ae..5cecd0cc8 100644 --- a/services/agents-api/internal/store/session_configuration_test.go +++ b/services/agents-api/internal/store/session_configuration_test.go @@ -2,8 +2,6 @@ package store import ( "context" - "crypto/sha256" - "encoding/hex" "errors" "strings" "testing" @@ -81,35 +79,23 @@ func TestConfigurationIsPartOfSessionIdentity(t *testing.T) { } } -func TestOriginalSessionHashRespectsRecordedCreator(t *testing.T) { - s, pool := testStore(t) - ctx := context.Background() - legacyHash := sha256.Sum256([]byte(`{"Engine":"codex","Metadata":{}}`)) - for _, known := range []bool{false, true} { - tenant, id := uuid.NewString(), uuid.NewString() - var kind, creatorID any - if known { - kind, creatorID = FixtureCreator().Kind, FixtureCreator().ID - } - // Seed each ownership state explicitly; neither the migration nor a retry assigns it. - _, err := pool.Exec(ctx, `INSERT INTO sessions (id, tenant_id, engine, metadata, idempotency_key, request_hash, creator_kind, creator_id) - VALUES ($1, $2, 'codex', '{}', 'legacy', $3, $4, $5)`, id, tenant, hex.EncodeToString(legacyHash[:]), kind, creatorID) - if err != nil { - t.Fatal(err) - } - for _, configuration := range [][]byte{nil, []byte(`{}`), []byte(` { } `)} { - got, err := s.CreateSession(ctx, tenant, CreateSessionInput{Creator: FixtureCreator(), Engine: "codex", IdempotencyKey: "legacy", Configuration: configuration}) - if !known { - if !errors.Is(err, ErrIdempotencyConflict) { - t.Fatal("unknown historical creator was claimed", err) - } - } else if err != nil || got.ID != id || string(got.Configuration) != "{}" || got.Creator == nil || *got.Creator != FixtureCreator() { - t.Fatalf("original hash retry = %+v, %v", got, err) - } - } - read, err := s.GetSession(ctx, tenant, id) - if err != nil || (read.Creator != nil) != known { - t.Fatal("retry changed recorded creator", read, err) +func TestEmptyConfigurationCanonicalizationPreservesCreator(t *testing.T) { + s, _ := testStore(t) + tenant := uuid.NewString() + input := CreateSessionInput{Creator: FixtureCreator(), Engine: "codex", IdempotencyKey: "empty-configuration"} + first, err := s.CreateSession(t.Context(), tenant, input) + if err != nil { + t.Fatal(err) + } + for _, configuration := range [][]byte{nil, []byte(`{}`), []byte(` { } `)} { + input.Configuration = configuration + got, err := s.CreateSession(t.Context(), tenant, input) + if err != nil || got.ID != first.ID || string(got.Configuration) != "{}" || got.Creator == nil || *got.Creator != FixtureCreator() { + t.Fatal("canonical retry changed identity or creator", err) } } + input.Creator.ID += "-other" + if _, err := s.CreateSession(t.Context(), tenant, input); !errors.Is(err, ErrIdempotencyConflict) { + t.Fatal("another creator claimed the same request", err) + } } diff --git a/services/agents-api/internal/store/session_initial_input.go b/services/agents-api/internal/store/session_initial_input.go index bc682932b..c57379942 100644 --- a/services/agents-api/internal/store/session_initial_input.go +++ b/services/agents-api/internal/store/session_initial_input.go @@ -34,6 +34,10 @@ func (s *Store) createSessionResources(ctx context.Context, tenant string, param return err } if row.ID == params.ID { + setup, err = s.freezeEnvironmentSkills(ctx, q, tenant, setup) + if err != nil { + return err + } if err := s.saveSessionModelExecution(ctx, q, tenant, row.ID, provider); err != nil { return err } @@ -55,7 +59,11 @@ func (s *Store) createSessionResources(ctx context.Context, tenant string, param if err != nil { return err } - row, err = q.SetSessionSetupMetadata(ctx, sqlc.SetSessionSetupMetadataParams{ID: row.ID, Column2: packages}) + skills, err := json.Marshal(setup.SkillMetadata()) + if err != nil { + return err + } + row, err = q.SetSessionSetupMetadata(ctx, sqlc.SetSessionSetupMetadataParams{ID: row.ID, Packages: packages, Skills: skills}) if err != nil { return err } diff --git a/services/agents-api/internal/store/sessions.go b/services/agents-api/internal/store/sessions.go index d66a9b870..0cf31049e 100644 --- a/services/agents-api/internal/store/sessions.go +++ b/services/agents-api/internal/store/sessions.go @@ -136,11 +136,6 @@ func (s *Store) createSession(ctx context.Context, tenantID string, input Create return SessionCreation{}, err } } - hashConfiguration := configuration - // Empty configuration retains the idempotency hashes from the first schema. - if string(configuration) == "{}" { - hashConfiguration = nil - } var initialization *EnvironmentSetup if !input.Initialization.Empty() { initialization = &input.Initialization @@ -154,7 +149,7 @@ func (s *Store) createSession(ctx context.Context, tenantID string, input Create InitialInputs json.RawMessage `json:",omitempty"` InitialFiles []InitialFile `json:",omitempty"` Initialization *EnvironmentSetup `json:",omitempty"` - }{input.ModelProvider, input.Engine, input.Metadata, hashConfiguration, encodedInput, input.InitialFiles, initialization}) + }{input.ModelProvider, input.Engine, input.Metadata, configuration, encodedInput, input.InitialFiles, initialization}) if err != nil { return SessionCreation{}, fmt.Errorf("%w: input: %v", ErrInvalidInput, err) } diff --git a/services/agents-api/internal/store/skill_versions.go b/services/agents-api/internal/store/skill_versions.go new file mode 100644 index 000000000..a8ee780ca --- /dev/null +++ b/services/agents-api/internal/store/skill_versions.go @@ -0,0 +1,144 @@ +package store + +import ( + "context" + "errors" + "math" + + "github.com/MiniMax-AI-Dev/parsar/internal/agentskill" + "github.com/MiniMax-AI-Dev/parsar/services/agents-api/internal/db/sqlc" + "github.com/jackc/pgx/v5" +) + +var ErrDefaultSkillVersion = errors.New("cannot delete the default skill version") + +func (s *Store) CreateSkillVersion(ctx context.Context, tenantID, skillID string, archive []byte, makeDefault bool) (SkillVersion, error) { + tenant, id, err := skillIDs(tenantID, skillID) + if err != nil { + return SkillVersion{}, err + } + metadata, err := agentskill.Inspect(archive) + if err != nil { + return SkillVersion{}, ErrInvalidInput + } + var result SkillVersion + err = pgx.BeginFunc(ctx, s.pool, func(tx pgx.Tx) error { + q := s.queries.WithTx(tx) + owner, err := q.LockSkill(ctx, sqlc.LockSkillParams{TenantID: tenant, ID: id}) + if err != nil { + return err + } + if owner.NextVersion == math.MaxInt64 { + return ErrInvalidInput + } + result, err = s.saveSkillVersion(ctx, q, tenant, id, owner.NextVersion, metadata, archive) + if err != nil { + return err + } + return q.AdvanceSkillVersion(ctx, sqlc.AdvanceSkillVersionParams{TenantID: tenant, ID: id, MakeDefault: makeDefault}) + }) + if errors.Is(err, pgx.ErrNoRows) { + err = ErrNotFound + } + return result, err +} + +func (s *Store) GetSkillVersion(ctx context.Context, tenantID, skillID, version string) (SkillVersion, error) { + tenant, id, err := skillIDs(tenantID, skillID) + if err != nil { + return SkillVersion{}, err + } + number, err := skillVersionNumber(version) + if err != nil { + return SkillVersion{}, err + } + row, err := s.queries.GetSkillVersion(ctx, sqlc.GetSkillVersionParams{TenantID: tenant, SkillID: id, Version: number}) + if errors.Is(err, pgx.ErrNoRows) { + err = ErrNotFound + } + return skillVersionFromRow(row), err +} + +// ReadSkillVersion reads metadata and encrypted bytes from one authorized row. +func (s *Store) ReadSkillVersion(ctx context.Context, tenantID, skillID, version string) (SkillVersion, []byte, error) { + tenant, id, err := skillIDs(tenantID, skillID) + if err != nil { + return SkillVersion{}, nil, err + } + number, err := skillVersionNumber(version) + if err != nil { + return SkillVersion{}, nil, err + } + row, err := s.queries.ReadSkillVersion(ctx, sqlc.ReadSkillVersionParams{TenantID: tenant, SkillID: id, Version: number}) + if errors.Is(err, pgx.ErrNoRows) { + err = ErrNotFound + } + if err != nil { + return SkillVersion{}, nil, err + } + return s.openSkillVersion(row) +} + +func (s *Store) openSkillVersion(row sqlc.SkillVersion) (SkillVersion, []byte, error) { + body, err := s.credentialCipher.OpenSkill(row.Contents, skillBinding(row.TenantID, row.SkillID, row.ID, row.Version)) + if err != nil { + return SkillVersion{}, nil, err + } + metadata := agentskill.Metadata{Type: "inline", Name: row.Name, Description: row.Description} + if _, err := agentskill.Read(body, metadata); err != nil { + return SkillVersion{}, nil, ErrInvalidInput + } + result := skillVersionFromRow(sqlc.GetSkillVersionRow{ID: row.ID, TenantID: row.TenantID, SkillID: row.SkillID, Version: row.Version, Name: row.Name, Description: row.Description, CreatedAt: row.CreatedAt}) + return result, body, nil +} + +func (s *Store) DeleteSkillVersion(ctx context.Context, tenantID, skillID, version string) (SkillVersion, error) { + tenant, id, err := skillIDs(tenantID, skillID) + if err != nil { + return SkillVersion{}, err + } + number, err := skillVersionNumber(version) + if err != nil { + return SkillVersion{}, err + } + var result SkillVersion + err = pgx.BeginFunc(ctx, s.pool, func(tx pgx.Tx) error { + q := s.queries.WithTx(tx) + owner, err := q.LockSkill(ctx, sqlc.LockSkillParams{TenantID: tenant, ID: id}) + if err != nil { + return err + } + if owner.DefaultVersion == number { + return ErrDefaultSkillVersion + } + row, err := q.DeleteSkillVersion(ctx, sqlc.DeleteSkillVersionParams{TenantID: tenant, SkillID: id, Version: number}) + if err != nil { + return err + } + result = skillVersionFromRow(sqlc.GetSkillVersionRow(row)) + if owner.LatestVersion == number { + return q.RefreshLatestSkillVersion(ctx, sqlc.RefreshLatestSkillVersionParams{TenantID: tenant, ID: id}) + } + return nil + }) + if errors.Is(err, pgx.ErrNoRows) { + err = ErrNotFound + } + return result, err +} + +// ReadDefaultSkillVersion selects the pointer and immutable content in one read. +func (s *Store) ReadDefaultSkillVersion(ctx context.Context, tenantID, skillID string) (SkillVersion, []byte, error) { + tenant, id, err := skillIDs(tenantID, skillID) + if err != nil { + return SkillVersion{}, nil, err + } + row, err := s.queries.ReadDefaultSkillVersion(ctx, sqlc.ReadDefaultSkillVersionParams{TenantID: tenant, ID: id}) + if errors.Is(err, pgx.ErrNoRows) { + err = ErrNotFound + } + if err != nil { + return SkillVersion{}, nil, err + } + return s.openSkillVersion(row) +} diff --git a/services/agents-api/internal/store/skills.go b/services/agents-api/internal/store/skills.go new file mode 100644 index 000000000..db6dd8806 --- /dev/null +++ b/services/agents-api/internal/store/skills.go @@ -0,0 +1,159 @@ +package store + +import ( + "context" + "errors" + "strconv" + "strings" + "time" + + "github.com/MiniMax-AI-Dev/parsar/internal/agentskill" + "github.com/MiniMax-AI-Dev/parsar/services/agents-api/internal/credentialcrypto" + "github.com/MiniMax-AI-Dev/parsar/services/agents-api/internal/db/sqlc" + "github.com/google/uuid" + "github.com/jackc/pgx/v5" + "github.com/jackc/pgx/v5/pgtype" +) + +type Skill struct { + ID string + Name string + Description string + CreatedAt time.Time + DefaultVersion int64 + LatestVersion int64 +} + +type SkillVersion struct { + ID string + SkillID string + Version int64 + Name string + Description string + CreatedAt time.Time +} + +func (s *Store) CreateSkill(ctx context.Context, tenantID string, archive []byte) (Skill, error) { + tenant, err := parseID(tenantID) + if err != nil { + return Skill{}, err + } + metadata, err := agentskill.Inspect(archive) + if err != nil { + return Skill{}, ErrInvalidInput + } + var result Skill + err = pgx.BeginFunc(ctx, s.pool, func(tx pgx.Tx) error { + q := s.queries.WithTx(tx) + id := pgtype.UUID{Bytes: uuid.New(), Valid: true} + row, err := q.CreateSkill(ctx, sqlc.CreateSkillParams{ID: id, TenantID: tenant, Name: metadata.Name, Description: metadata.Description}) + if err != nil { + return err + } + if _, err = s.saveSkillVersion(ctx, q, tenant, id, 1, metadata, archive); err != nil { + return err + } + result = skillFromRow(row) + return nil + }) + return result, err +} + +func (s *Store) GetSkill(ctx context.Context, tenantID, skillID string) (Skill, error) { + tenant, id, err := skillIDs(tenantID, skillID) + if err != nil { + return Skill{}, err + } + row, err := s.queries.GetSkill(ctx, sqlc.GetSkillParams{TenantID: tenant, ID: id}) + if errors.Is(err, pgx.ErrNoRows) { + return Skill{}, ErrNotFound + } + return skillFromRow(row), err +} + +func (s *Store) UpdateSkillDefault(ctx context.Context, tenantID, skillID, version string) (Skill, error) { + tenant, id, err := skillIDs(tenantID, skillID) + if err != nil { + return Skill{}, err + } + number, err := skillVersionNumber(version) + if err != nil { + return Skill{}, err + } + var result Skill + err = pgx.BeginFunc(ctx, s.pool, func(tx pgx.Tx) error { + q := s.queries.WithTx(tx) + if _, err := q.LockSkill(ctx, sqlc.LockSkillParams{TenantID: tenant, ID: id}); err != nil { + return err + } + if _, err := q.GetSkillVersion(ctx, sqlc.GetSkillVersionParams{TenantID: tenant, SkillID: id, Version: number}); err != nil { + return err + } + row, err := q.SetDefaultSkillVersion(ctx, sqlc.SetDefaultSkillVersionParams{TenantID: tenant, ID: id, DefaultVersion: number}) + result = skillFromRow(row) + return err + }) + if errors.Is(err, pgx.ErrNoRows) { + err = ErrNotFound + } + return result, err +} + +func (s *Store) DeleteSkill(ctx context.Context, tenantID, skillID string) error { + tenant, id, err := skillIDs(tenantID, skillID) + if err != nil { + return err + } + _, err = s.queries.DeleteSkill(ctx, sqlc.DeleteSkillParams{TenantID: tenant, ID: id}) + if errors.Is(err, pgx.ErrNoRows) { + return ErrNotFound + } + return err +} + +func (s *Store) saveSkillVersion(ctx context.Context, q *sqlc.Queries, tenant, skill pgtype.UUID, version int64, metadata agentskill.Metadata, archive []byte) (SkillVersion, error) { + id := pgtype.UUID{Bytes: uuid.New(), Valid: true} + body, err := s.credentialCipher.SealSkill(archive, skillBinding(tenant, skill, id, version)) + if err != nil { + return SkillVersion{}, err + } + row, err := q.CreateSkillVersion(ctx, sqlc.CreateSkillVersionParams{ID: id, TenantID: tenant, SkillID: skill, Version: version, Name: metadata.Name, Description: metadata.Description, Contents: body}) + return skillVersionFromRow(sqlc.GetSkillVersionRow(row)), err +} + +func skillBinding(tenant, skill, versionID pgtype.UUID, version int64) credentialcrypto.SkillBinding { + return credentialcrypto.SkillBinding{TenantID: uuid.UUID(tenant.Bytes).String(), SkillID: uuid.UUID(skill.Bytes).String(), VersionID: uuid.UUID(versionID.Bytes).String(), Version: strconv.FormatInt(version, 10)} +} + +func skillIDs(tenantID, skillID string) (pgtype.UUID, pgtype.UUID, error) { + tenant, err := parseID(tenantID) + if err != nil { + return tenant, pgtype.UUID{}, err + } + id, err := skillResourceID(skillID, "skill_") + return tenant, id, err +} + +func skillResourceID(value, prefix string) (pgtype.UUID, error) { + id, err := uuid.Parse(strings.TrimPrefix(value, prefix)) + if err != nil || id == uuid.Nil || value != prefix+id.String() { + return pgtype.UUID{}, ErrNotFound + } + return pgtype.UUID{Bytes: id, Valid: true}, nil +} + +func skillVersionNumber(value string) (int64, error) { + number, err := strconv.ParseInt(value, 10, 64) + if err != nil || number < 1 || strconv.FormatInt(number, 10) != value { + return 0, ErrInvalidInput + } + return number, nil +} + +func skillFromRow(row sqlc.Skill) Skill { + return Skill{ID: "skill_" + uuid.UUID(row.ID.Bytes).String(), Name: row.Name, Description: row.Description, CreatedAt: row.CreatedAt.Time, DefaultVersion: row.DefaultVersion, LatestVersion: row.LatestVersion} +} + +func skillVersionFromRow(row sqlc.GetSkillVersionRow) SkillVersion { + return SkillVersion{ID: "skillver_" + uuid.UUID(row.ID.Bytes).String(), SkillID: "skill_" + uuid.UUID(row.SkillID.Bytes).String(), Version: row.Version, Name: row.Name, Description: row.Description, CreatedAt: row.CreatedAt.Time} +} diff --git a/services/agents-api/internal/store/skills_list.go b/services/agents-api/internal/store/skills_list.go new file mode 100644 index 000000000..5363ad0ad --- /dev/null +++ b/services/agents-api/internal/store/skills_list.go @@ -0,0 +1,91 @@ +package store + +import ( + "context" + "errors" + + "github.com/MiniMax-AI-Dev/parsar/services/agents-api/internal/db/sqlc" + "github.com/jackc/pgx/v5" + "github.com/jackc/pgx/v5/pgtype" +) + +type SkillPage struct { + Skills []Skill + HasMore bool +} + +type SkillVersionPage struct { + Versions []SkillVersion + HasMore bool +} + +func (s *Store) ListSkills(ctx context.Context, tenantID, after string, limit int, ascending bool) (SkillPage, error) { + tenant, err := parseID(tenantID) + if err != nil { + return SkillPage{}, err + } + if limit < 1 || limit > 100 { + return SkillPage{}, ErrInvalidInput + } + params := sqlc.ListSkillsParams{TenantID: tenant, PageLimit: int32(limit + 1), Ascending: ascending, AfterID: pgtype.UUID{Valid: true}} + if after != "" { + cursor, err := s.GetSkill(ctx, tenantID, after) + if err != nil { + return SkillPage{}, err + } + params.AfterCreated = pgtype.Timestamptz{Time: cursor.CreatedAt, Valid: true} + params.AfterID, _ = skillResourceID(cursor.ID, "skill_") + } + rows, err := s.queries.ListSkills(ctx, params) + if err != nil { + return SkillPage{}, err + } + page := SkillPage{Skills: make([]Skill, 0, min(limit, len(rows))), HasMore: len(rows) > limit} + if page.HasMore { + rows = rows[:limit] + } + for _, row := range rows { + page.Skills = append(page.Skills, skillFromRow(row)) + } + return page, nil +} + +func (s *Store) ListSkillVersions(ctx context.Context, tenantID, skillID, after string, limit int, ascending bool) (SkillVersionPage, error) { + tenant, id, err := skillIDs(tenantID, skillID) + if err != nil { + return SkillVersionPage{}, err + } + if limit < 1 || limit > 100 { + return SkillVersionPage{}, ErrInvalidInput + } + if _, err := s.GetSkill(ctx, tenantID, skillID); err != nil { + return SkillVersionPage{}, err + } + params := sqlc.ListSkillVersionsParams{TenantID: tenant, SkillID: id, PageLimit: int32(limit + 1), Ascending: ascending} + if 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}) + if errors.Is(err, pgx.ErrNoRows) { + return SkillVersionPage{}, ErrNotFound + } + if err != nil { + return SkillVersionPage{}, err + } + params.AfterVersion = pgtype.Int8{Int64: cursor.Version, Valid: true} + } + rows, err := s.queries.ListSkillVersions(ctx, params) + if err != nil { + return SkillVersionPage{}, err + } + page := SkillVersionPage{Versions: make([]SkillVersion, 0, min(limit, len(rows))), HasMore: len(rows) > limit} + if page.HasMore { + rows = rows[:limit] + } + for _, row := range rows { + page.Versions = append(page.Versions, skillVersionFromRow(sqlc.GetSkillVersionRow(row))) + } + return page, nil +} diff --git a/services/agents-api/internal/store/skills_public_test.go b/services/agents-api/internal/store/skills_public_test.go new file mode 100644 index 000000000..920a5ae6d --- /dev/null +++ b/services/agents-api/internal/store/skills_public_test.go @@ -0,0 +1,65 @@ +package store_test + +import ( + "bytes" + "context" + "encoding/json" + "net/http/httptest" + "os" + "os/exec" + "testing" + "time" + + "github.com/MiniMax-AI-Dev/parsar/internal/agentdaemon/device" + "github.com/MiniMax-AI-Dev/parsar/services/agents-api/internal/api" + "github.com/MiniMax-AI-Dev/parsar/services/agents-api/internal/credentialcrypto" + "github.com/MiniMax-AI-Dev/parsar/services/agents-api/internal/store" + "github.com/google/uuid" +) + +func TestSkillsOfficialClientPostgres(t *testing.T) { + python := os.Getenv("PARSAR_OFFICIAL_SDK_PYTHON") + if python == "" { + t.Skip("pinned official Python SDK required") + } + _, pool := store.NewTestStore(t) + cipher, err := credentialcrypto.New(bytes.Repeat([]byte{51}, 32)) + if err != nil { + t.Fatal(err) + } + s := store.NewWithCredentialCipher(pool, cipher) + token, foreign := uuid.NewString(), uuid.NewString() + auth, err := api.NewAuthenticator([]api.APIKey{ + {OrganizationID: "test-org", ProjectID: uuid.NewString(), SubjectKind: "service_account", SubjectID: "test-runner", TokenSHA256: device.HashCredential(token), TenantID: uuid.NewString()}, + {OrganizationID: "test-org", ProjectID: uuid.NewString(), SubjectKind: "service_account", SubjectID: "test-runner", TokenSHA256: device.HashCredential(foreign), TenantID: uuid.NewString()}, + }) + if err != nil { + t.Fatal(err) + } + h, err := api.NewHandler(s, auth, "codex", api.WithSkills(s)) + if err != nil { + t.Fatal(err) + } + server := httptest.NewServer(h) + defer server.Close() + recoveredStore := store.NewWithCredentialCipher(pool, cipher) + h, err = api.NewHandler(recoveredStore, auth, "codex", api.WithSkills(recoveredStore)) + if err != nil { + t.Fatal(err) + } + recovered := httptest.NewServer(h) + defer recovered.Close() + settings, err := json.Marshal(map[string]string{"base": server.URL, "recovered": recovered.URL, "token": token, "foreign": foreign}) + if err != nil { + t.Fatal(err) + } + ctx, cancel := context.WithTimeout(t.Context(), time.Minute) + defer cancel() + command := exec.CommandContext(ctx, python, "../../tests/official_skills.py") + command.Stdin = bytes.NewReader(settings) + output, err := command.CombinedOutput() + if err != nil { + t.Fatalf("Skills official client: %v %s", err, output) + } + t.Log(string(output)) +} diff --git a/services/agents-api/internal/store/skills_test.go b/services/agents-api/internal/store/skills_test.go new file mode 100644 index 000000000..01b17663e --- /dev/null +++ b/services/agents-api/internal/store/skills_test.go @@ -0,0 +1,165 @@ +package store + +import ( + "archive/zip" + "bytes" + "errors" + "fmt" + "sort" + "strconv" + "sync" + "testing" + + "github.com/MiniMax-AI-Dev/parsar/services/agents-api/internal/credentialcrypto" + "github.com/google/uuid" +) + +func skillArchive(t *testing.T, marker string) []byte { + t.Helper() + var buffer bytes.Buffer + writer := zip.NewWriter(&buffer) + file, err := writer.CreateHeader(&zip.FileHeader{Name: "proof/SKILL.md", Method: zip.Store}) + if err != nil { + t.Fatal(err) + } + if _, err = fmt.Fprintf(file, "---\nname: proof\ndescription: Verify a versioned Skill.\n---\n%s", marker); err != nil { + t.Fatal(err) + } + if err = writer.Close(); err != nil { + t.Fatal(err) + } + return buffer.Bytes() +} + +func TestSkillsOwnershipEncryptionAndVersions(t *testing.T) { + _, pool := testStore(t) + cipher, err := credentialcrypto.New(bytes.Repeat([]byte{41}, 32)) + if err != nil { + t.Fatal(err) + } + s := NewWithCredentialCipher(pool, cipher) + tenant, foreign := uuid.NewString(), uuid.NewString() + archive := skillArchive(t, "confidential-skill-canary") + created, err := s.CreateSkill(t.Context(), tenant, archive) + if err != nil { + t.Fatal(err) + } + if created.DefaultVersion != 1 || created.LatestVersion != 1 { + t.Fatal("initial pointers", created) + } + t.Cleanup(func() { _ = s.DeleteSkill(t.Context(), tenant, created.ID) }) + metadata, err := New(pool).GetSkill(t.Context(), tenant, created.ID) + if err != nil || metadata.Name != "proof" { + t.Fatal("metadata requires no content key", err) + } + var contents []byte + if err = pool.QueryRow(t.Context(), "SELECT contents FROM skill_versions WHERE tenant_id=$1", tenant).Scan(&contents); err != nil { + t.Fatal(err) + } + if bytes.Contains(contents, []byte("confidential-skill-canary")) { + t.Fatal("plaintext bundle persisted") + } + version, body, err := s.ReadSkillVersion(t.Context(), tenant, created.ID, "1") + if err != nil || !bytes.Equal(body, archive) || version.Version != 1 { + t.Fatal("content round trip", err) + } + if _, err = s.GetSkill(t.Context(), foreign, created.ID); !errors.Is(err, ErrNotFound) { + t.Fatal("foreign metadata", err) + } + if _, _, err = s.ReadSkillVersion(t.Context(), foreign, created.ID, "1"); !errors.Is(err, ErrNotFound) { + t.Fatal("foreign content", err) + } + if _, err = s.CreateSkillVersion(t.Context(), foreign, created.ID, archive, true); !errors.Is(err, ErrNotFound) { + t.Fatal("foreign version", err) + } + if _, err = s.UpdateSkillDefault(t.Context(), foreign, created.ID, "1"); !errors.Is(err, ErrNotFound) { + t.Fatal("foreign pointer", err) + } + if err = s.DeleteSkill(t.Context(), foreign, created.ID); !errors.Is(err, ErrNotFound) { + t.Fatal("foreign delete", err) + } + if _, err = s.ListSkills(t.Context(), foreign, created.ID, 20, false); !errors.Is(err, ErrNotFound) { + t.Fatal("foreign cursor", err) + } + if _, err = s.ListSkillVersions(t.Context(), foreign, created.ID, "", 20, false); !errors.Is(err, ErrNotFound) { + t.Fatal("foreign versions", err) + } + + const count = 8 + results := make(chan SkillVersion, count) + failures := make(chan error, count) + var group sync.WaitGroup + for range count { + group.Add(1) + go func() { + defer group.Done() + version, err := s.CreateSkillVersion(t.Context(), tenant, created.ID, archive, false) + if err != nil { + failures <- err + } else { + results <- version + } + }() + } + group.Wait() + close(results) + close(failures) + for err := range failures { + t.Fatal(err) + } + numbers := []int{} + for version := range results { + numbers = append(numbers, int(version.Version)) + } + sort.Ints(numbers) + for i, number := range numbers { + if number != i+2 { + t.Fatal("concurrent version allocation", numbers) + } + } + if len(numbers) != count { + t.Fatal("missing versions", numbers) + } + current, err := s.GetSkill(t.Context(), tenant, created.ID) + if err != nil || current.DefaultVersion != 1 || current.LatestVersion != count+1 { + t.Fatal("concurrent pointers", current, err) + } + first, err := s.ListSkillVersions(t.Context(), tenant, created.ID, "", 3, true) + if err != nil || !first.HasMore || len(first.Versions) != 3 || first.Versions[0].Version != 1 { + t.Fatal("first page", first, err) + } + next, err := s.ListSkillVersions(t.Context(), tenant, created.ID, first.Versions[2].ID, 20, true) + 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) { + t.Fatal("numeric version is not a cursor", err) + } + if _, err = s.UpdateSkillDefault(t.Context(), tenant, created.ID, "999"); !errors.Is(err, ErrNotFound) { + t.Fatal("missing default", err) + } + updated, err := s.UpdateSkillDefault(t.Context(), tenant, created.ID, "3") + if err != nil || updated.DefaultVersion != 3 { + t.Fatal("default update", err) + } + if _, err = s.DeleteSkillVersion(t.Context(), tenant, created.ID, "3"); !errors.Is(err, ErrDefaultSkillVersion) { + t.Fatal("default deletion", err) + } + if _, err = s.DeleteSkillVersion(t.Context(), tenant, created.ID, strconv.Itoa(count+1)); err != nil { + t.Fatal(err) + } + added, err := s.CreateSkillVersion(t.Context(), tenant, created.ID, archive, true) + if err != nil || added.Version != count+2 { + t.Fatal("deleted version number reused", added, err) + } + if err = s.DeleteSkill(t.Context(), tenant, created.ID); err != nil { + t.Fatal(err) + } + if _, _, err = s.ReadSkillVersion(t.Context(), tenant, created.ID, "1"); !errors.Is(err, ErrNotFound) { + t.Fatal("cascaded content", err) + } + var remaining int + if err = pool.QueryRow(t.Context(), "SELECT count(*) FROM skill_versions WHERE tenant_id=$1", tenant).Scan(&remaining); err != nil || remaining != 0 { + t.Fatal("orphan content", remaining, err) + } +} diff --git a/services/agents-api/migrations/000048_skills.sql b/services/agents-api/migrations/000048_skills.sql new file mode 100644 index 000000000..e9adef44d --- /dev/null +++ b/services/agents-api/migrations/000048_skills.sql @@ -0,0 +1,37 @@ +-- +goose Up +CREATE TABLE skills ( + id uuid PRIMARY KEY, + tenant_id uuid NOT NULL, + name text NOT NULL, + description text NOT NULL, + created_at timestamptz NOT NULL DEFAULT clock_timestamp(), + default_version bigint NOT NULL CHECK (default_version > 0), + latest_version bigint NOT NULL CHECK (latest_version > 0), + next_version bigint NOT NULL CHECK (next_version > latest_version), + UNIQUE (tenant_id, id) +); +CREATE INDEX skills_tenant_created ON skills (tenant_id, created_at, id); + +CREATE TABLE skill_versions ( + id uuid PRIMARY KEY, + tenant_id uuid NOT NULL, + skill_id uuid NOT NULL, + version bigint NOT NULL CHECK (version > 0), + name text NOT NULL, + description text NOT NULL, + created_at timestamptz NOT NULL DEFAULT clock_timestamp(), + contents bytea NOT NULL, + UNIQUE (skill_id, version), + FOREIGN KEY (tenant_id, skill_id) REFERENCES skills (tenant_id, id) ON DELETE CASCADE +); +ALTER TABLE skills ADD CONSTRAINT skills_default_version + FOREIGN KEY (id, default_version) REFERENCES skill_versions (skill_id, version) + DEFERRABLE INITIALLY DEFERRED; +ALTER TABLE skills ADD CONSTRAINT skills_latest_version + FOREIGN KEY (id, latest_version) REFERENCES skill_versions (skill_id, version) + DEFERRABLE INITIALLY DEFERRED; + +-- +goose Down +ALTER TABLE skills DROP CONSTRAINT skills_default_version, DROP CONSTRAINT skills_latest_version; +DROP TABLE skill_versions; +DROP TABLE skills; diff --git a/services/agents-api/tests/official_environment_skill_references.py b/services/agents-api/tests/official_environment_skill_references.py new file mode 100644 index 000000000..b07afebb2 --- /dev/null +++ b/services/agents-api/tests/official_environment_skill_references.py @@ -0,0 +1,33 @@ +"""Real-model Skill-reference proof using the shared inline installation fixture.""" +import base64 +import io +import json +import zipfile + +from official_environment_skills import inline_skill +from official_session_artifacts import verify_session_artifacts + + +def upload_reference_skill(client): + inline, expected = inline_skill() + with zipfile.ZipFile(io.BytesIO(base64.b64decode(inline["source"]["data"]))) as archive: + files = [(name, archive.read(name), "application/octet-stream") for name in archive.namelist()] + skill = client.skills.create(files=files) + assert skill.default_version == "1" + return skill, expected + + +def verify_reference_metadata(client, session, skill, expected): + metadata = [{"type": "skill_reference", "skill_id": skill.id, "version": "1", + "name": skill.name, "description": skill.description}] + environment = client.beta.agents.environments.retrieve(session.environment.id) + assert session.environment.to_dict()["skills"] == metadata + assert environment.to_dict()["skills"] == metadata + assert expected.decode() not in json.dumps([session.to_dict(), environment.to_dict()]) + + +def verify_reference_output(client, foreign, http, session, artifacts): + files = client.beta.agents.environments.files.list(session.environment.id, path="/workspace/outputs").data + assert [(file.path, file.size_bytes) for file in files] == [ + ("/workspace/outputs/skill-proof.txt", len(next(iter(artifacts.values()))["/workspace/outputs/skill-proof.txt"]))] + verify_session_artifacts(client, foreign, http, session.id, session.environment.id, artifacts) diff --git a/services/agents-api/tests/official_skills.py b/services/agents-api/tests/official_skills.py new file mode 100644 index 000000000..157e09fcf --- /dev/null +++ b/services/agents-api/tests/official_skills.py @@ -0,0 +1,104 @@ +"""Pinned official SDK and raw HTTP acceptance for Core-owned Skill resources. + +This exercises a real API and PostgreSQL; native model/installation acceptance is +separate and must not be inferred from these resource checks. +""" +import io +import json +import secrets +import sys +import zipfile + +import httpx +import openai +from openai import DefaultHttpxClient, OpenAI + + +def bundle(marker): + content = ("---\nname: proof\ndescription: Verify a versioned Skill.\n---\n" + marker).encode() + output = io.BytesIO() + with zipfile.ZipFile(output, "w", zipfile.ZIP_DEFLATED) as archive: + archive.writestr("proof/SKILL.md", content) + archive.writestr("proof/scripts/proof.py", b"print('proof')") + return output.getvalue(), content + + +def main(): + assert openai.__version__ == "3.13.0", openai.__version__ + settings = json.load(sys.stdin) + client = OpenAI(base_url=settings["base"] + "/v1", api_key=settings["token"], max_retries=0, http_client=DefaultHttpxClient(trust_env=False), _strict_response_validation=True) + recovered = OpenAI(base_url=settings["recovered"] + "/v1", api_key=settings["token"], max_retries=0, http_client=DefaultHttpxClient(trust_env=False), _strict_response_validation=True) + headers = {"Authorization": "Bearer " + settings["token"]} + foreign = {"Authorization": "Bearer " + settings["foreign"]} + base = settings["base"] + "/v1" + marker = "confidential-skill-" + secrets.token_hex(20) + data, manifest = bundle(marker) + owned = [] + with httpx.Client(timeout=20, trust_env=False) as http: + try: + raw = client.skills.with_raw_response.create(files=[("proof/SKILL.md", manifest, "text/markdown"), ("proof/scripts/proof.py", b"print('proof')", "text/plain")]) + skill = raw.parse() + owned.append(skill.id) + assert "OpenAI-Beta" not in raw.http_response.request.headers + assert set(raw.http_response.json()) == {"id", "object", "name", "description", "created_at", "default_version", "latest_version"} + assert skill.object == "skill" and skill.default_version == skill.latest_version == "1" + assert marker not in raw.http_response.text + data = client.skills.content.retrieve(skill.id).read() + with zipfile.ZipFile(io.BytesIO(data)) as archive: + assert archive.read("proof/SKILL.md") == manifest + first = client.skills.versions.retrieve(version="1", skill_id=skill.id) + assert first.version == "1" and first.object == "skill.version" + assert client.skills.versions.content.retrieve(version="1", skill_id=skill.id).read() == data + _, second_manifest = bundle("second-" + marker) + second = client.skills.versions.create(skill_id=skill.id, files=[("proof/SKILL.md", second_manifest, "text/markdown")], default=False) + second_data = client.skills.versions.content.retrieve(version=second.version, skill_id=skill.id).read() + assert second.version == "2" + assert client.skills.retrieve(skill.id).default_version == "1" + assert client.skills.retrieve(skill.id).latest_version == "2" + assert client.skills.update(skill.id, default_version="2").default_version == "2" + assert recovered.skills.content.retrieve(skill.id).read() == second_data + # Raw HTTP covers the single-ZIP form; the pinned SDK loses a single + # FileTypes value during array extraction before sending its request. + zip_data, _ = bundle("raw-" + marker) + uploaded = http.post(base + "/skills", headers=headers, files={"files": ("proof.zip", zip_data, "application/zip")}) + assert uploaded.status_code == 200 + directory = client.skills.retrieve(uploaded.json()["id"]) + owned.append(directory.id) + downloaded = client.skills.versions.content.retrieve(version="1", skill_id=directory.id).read() + with zipfile.ZipFile(io.BytesIO(downloaded)) as archive: + assert downloaded == zip_data + assert archive.read("proof/scripts/proof.py") == b"print('proof')" + page = client.skills.list(limit=1, order="asc") + assert page.data[0].id == skill.id and page.has_more + assert client.skills.list(limit=1, order="asc", after=skill.id).data[0].id == directory.id + versions = client.skills.versions.list(skill.id, limit=1, order="asc") + assert versions.data[0].id == first.id and versions.has_more + assert client.skills.versions.list(skill.id, limit=1, order="asc", after=first.id).data[0].id == second.id + endpoints = ["", "/content", "/versions", "/versions/1", "/versions/1/content"] + for suffix in endpoints: + assert http.get(base + "/skills/" + skill.id + suffix, headers=foreign).status_code == 404 + assert http.get(base + "/skills", headers=foreign, params={"after": skill.id}).status_code == 404 + assert http.post(base + "/skills/" + skill.id, headers=foreign, json={"default_version": "1"}).status_code == 404 + assert http.delete(base + "/skills/" + skill.id, headers=foreign).status_code == 404 + assert http.delete(base + "/skills/" + skill.id + "/versions/1", headers=foreign).status_code == 404 + assert http.post(base + "/skills/" + skill.id + "/versions", headers=foreign, files={"files": ("proof.zip", data)}).status_code == 404 + assert http.get(base + "/skills").status_code == 401 + assert http.post(base + "/skills/" + skill.id, headers=headers, json={"default_version": 1}).status_code == 400 + # Verify a malformed trailing field cannot commit a partial upload. + before = [item.id for item in client.skills.list()] + malformed = http.post(base + "/skills", headers=headers, files=[("files", ("proof.zip", data)), ("unknown", (None, "reject"))]) + assert malformed.status_code == 400 + assert [item.id for item in client.skills.list()] == before + deleted = client.skills.versions.delete(version="1", skill_id=skill.id) + assert deleted.id == first.id and deleted.version == "1" and deleted.object == "skill.version.deleted" and deleted.deleted + assert client.skills.delete(directory.id).deleted + owned.remove(directory.id) + assert http.get(base + "/skills/" + directory.id + "/versions/1/content", headers=headers).status_code == 404 + print(json.dumps({"sdk": openai.__version__, "resource_operations": 11, "uploads": ["zip", "directory"], "postgres": True, "native_model": False, "result": "passed"})) + finally: + for skill_id in owned: + client.skills.delete(skill_id) + + +if __name__ == "__main__": + main()