From e6425d6ed9199f89d5b291559add1225060baa2d Mon Sep 17 00:00:00 2001 From: saladday <1203511142@qq.com> Date: Sun, 20 Sep 2026 17:34:59 +0800 Subject: [PATCH 1/2] feat(agents-api): initialize inline Skills through shared Runtime --- CONTRIBUTING.md | 16 +- .../internal/agent/claudesdk/workspace.go | 24 ++- .../internal/agent/codex/hosted_skills.go | 28 +++ .../internal/agent/codex/session_plan.go | 7 +- .../internal/agent/mcode/options.go | 18 +- .../internal/agent/mcode/workspace.go | 19 +- .../internal/localworkspace/skills.go | 60 +++++++ contracts/agents-api/README.md | 4 +- contracts/agents-api/environment-templates.md | 56 +++++- contracts/agents-api/openapi.yaml | 19 +- contracts/agents-api/v1/sessions.go | 1 + internal/agentdaemon/proto/environment.go | 4 + internal/agentskill/bundle.go | 166 ++++++++++++++++++ internal/agentskill/bundle_test.go | 71 ++++++++ packages/claude-sdk-adapter/src/workspace.ts | 19 +- .../src/workspace_skills.ts | 44 +++++ packages/mcode-harness/launch.mjs | 2 +- scripts/build-agents-api.sh | 2 +- .../agents-api/deploy/runtime/initialize.py | 79 ++++++++- .../deploy/runtime/initialize_test.py | 15 +- .../internal/api/environment_setup.go | 5 + .../internal/api/environment_skills.go | 76 ++++++++ .../internal/api/environment_skills_test.go | 96 ++++++++++ .../internal/api/environment_templates.go | 9 +- .../agents-api/internal/api/environments.go | 5 +- .../internal/api/hosted_environment.go | 12 +- .../internal/api/session_template.go | 3 +- .../credentialcrypto/environment_setup.go | 2 +- .../db/queries/environment_templates.sql | 12 +- .../db/sqlc/environment_templates.sql.go | 36 +++- .../agents-api/internal/db/sqlc/models.go | 2 + .../execution/environment_placement.go | 15 +- .../internal/execution/runtime_setup.go | 28 ++- .../internal/store/environment_setup.go | 10 +- .../internal/store/environment_skills.go | 59 +++++++ .../internal/store/environment_skills_test.go | 83 +++++++++ .../internal/store/environment_templates.go | 34 ++-- .../internal/store/initial_files.go | 13 +- .../migrations/000045_environment_skills.sql | 7 + .../tests/official_environment_skills.py | 66 +++++++ 40 files changed, 1139 insertions(+), 88 deletions(-) create mode 100644 apps/parsar-daemon/internal/agent/codex/hosted_skills.go create mode 100644 apps/parsar-daemon/internal/localworkspace/skills.go create mode 100644 internal/agentskill/bundle.go create mode 100644 internal/agentskill/bundle_test.go create mode 100644 packages/claude-sdk-adapter/src/workspace_skills.ts create mode 100644 services/agents-api/internal/api/environment_skills.go create mode 100644 services/agents-api/internal/api/environment_skills_test.go create mode 100644 services/agents-api/internal/store/environment_skills.go create mode 100644 services/agents-api/internal/store/environment_skills_test.go create mode 100644 services/agents-api/migrations/000045_environment_skills.sql create mode 100644 services/agents-api/tests/official_environment_skills.py diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index e9ea3757..a3284b8a 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -288,14 +288,26 @@ 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 npm/Python packages precede ordered setup commands. Initialization has +Files, inline Skills and npm/Python packages precede 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 credential-bearing daemon/native harness launcher. Reuse the packaged atomic file writer and anchored parent creation across all profiles. -Name, enabled/disabled network, initial files and env/setup/npm/Python are +Inline 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 +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 +imports remain separate work; an adapter-owned Claude plugin envelope does not +implement public Plugins. + +Name, enabled/disabled network, initial files, inline Skills and env/setup/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/apps/parsar-daemon/internal/agent/claudesdk/workspace.go b/apps/parsar-daemon/internal/agent/claudesdk/workspace.go index 7e0f208d..28972971 100644 --- a/apps/parsar-daemon/internal/agent/claudesdk/workspace.go +++ b/apps/parsar-daemon/internal/agent/claudesdk/workspace.go @@ -9,6 +9,7 @@ import ( "github.com/MiniMax-AI-Dev/parsar/apps/parsar-daemon/internal/localworkspace" "github.com/MiniMax-AI-Dev/parsar/apps/parsar-daemon/internal/paths" "github.com/MiniMax-AI-Dev/parsar/internal/agentdaemon/proto" + "github.com/MiniMax-AI-Dev/parsar/internal/agentskill" ) // WorkspaceConfig binds one trusted private placement. It does not create an @@ -27,14 +28,15 @@ type WorkspaceConfig struct { } type workspaceProfile struct { - ToolEnvironment bool `json:"tool_environment,omitempty"` - Home string `json:"home"` - State string `json:"state"` - Scratch string `json:"scratch"` - ProtectedDirs []string `json:"protected_dirs"` - DependencyPath string `json:"dependency_path"` - EnvNames []string `json:"env_names"` - NetworkAccess string `json:"network_access,omitempty"` + Skills []agentskill.Metadata `json:"skills,omitempty"` + ToolEnvironment bool `json:"tool_environment,omitempty"` + Home string `json:"home"` + State string `json:"state"` + Scratch string `json:"scratch"` + ProtectedDirs []string `json:"protected_dirs"` + DependencyPath string `json:"dependency_path"` + EnvNames []string `json:"env_names"` + NetworkAccess string `json:"network_access,omitempty"` } func prepareWorkspace(config Config, req proto.PromptRequestPayload) (*workspaceProfile, []string, error) { @@ -57,6 +59,12 @@ func prepareWorkspace(config Config, req proto.PromptRequestPayload) (*workspace } profile.ToolEnvironment = true } + if req.LocalEnvironment != nil { + if err := localworkspace.VerifySkills(req.LocalEnvironment.Skills); err != nil { + return nil, nil, err + } + profile.Skills = req.LocalEnvironment.Skills + } return profile, env, nil } diff --git a/apps/parsar-daemon/internal/agent/codex/hosted_skills.go b/apps/parsar-daemon/internal/agent/codex/hosted_skills.go new file mode 100644 index 00000000..13c36299 --- /dev/null +++ b/apps/parsar-daemon/internal/agent/codex/hosted_skills.go @@ -0,0 +1,28 @@ +package codex + +import ( + "fmt" + "os" + "path/filepath" + + "github.com/MiniMax-AI-Dev/parsar/apps/parsar-daemon/internal/localworkspace" + "github.com/MiniMax-AI-Dev/parsar/internal/agentskill" +) + +func verifyHostedSkills(skills []agentskill.Metadata) error { + if err := localworkspace.VerifySkills(skills); err != nil { + return err + } + for _, skill := range skills { + // Native dependency declarations may start MCP installation outside the + // workspace tool sandbox. They are not qualified by an inert Skill upload. + _, err := os.Lstat(filepath.Join(localworkspace.SkillDirectory, skill.Name, "agents", "openai.yaml")) + if err == nil { + return fmt.Errorf("codex: native Skill configuration is unsupported") + } + if !os.IsNotExist(err) { + return err + } + } + return nil +} diff --git a/apps/parsar-daemon/internal/agent/codex/session_plan.go b/apps/parsar-daemon/internal/agent/codex/session_plan.go index c82eccc0..123e6a4a 100644 --- a/apps/parsar-daemon/internal/agent/codex/session_plan.go +++ b/apps/parsar-daemon/internal/agent/codex/session_plan.go @@ -65,7 +65,12 @@ func prepareSessionPlan(ctx context.Context, req proto.PromptRequestPayload, cfg plan.Env = append(plan.Env, "CODEX_EXEC_SERVER_URL=none") } skillRoot := "" - if !req.DisableExecutionEnvironment && req.RemoteEnvironment == nil { + if req.LocalEnvironment != nil && len(req.LocalEnvironment.Skills) > 0 { + err = verifyHostedSkills(req.LocalEnvironment.Skills) + if err == nil { + skillRoot = localworkspace.SkillDirectory + } + } else if !req.DisableExecutionEnvironment && req.RemoteEnvironment == nil { skillRoot, err = prepareManagedSkills(ctx, cfg.logger, req) } if err != nil { diff --git a/apps/parsar-daemon/internal/agent/mcode/options.go b/apps/parsar-daemon/internal/agent/mcode/options.go index 1d420329..9ccd9efb 100644 --- a/apps/parsar-daemon/internal/agent/mcode/options.go +++ b/apps/parsar-daemon/internal/agent/mcode/options.go @@ -22,6 +22,10 @@ type launchOptions struct { } func prepareOptions(ctx context.Context, req proto.PromptRequestPayload) (launchOptions, error) { + return prepareOptionsWithSkills(ctx, req, true) +} + +func prepareOptionsWithSkills(ctx context.Context, req proto.PromptRequestPayload, managedSkills bool) (launchOptions, error) { var result launchOptions if req.StrictResume { if err := validateExecutionRequest(req); err != nil { @@ -48,12 +52,14 @@ func prepareOptions(ctx context.Context, req proto.PromptRequestPayload) (launch return result, err } } - installed, err := claudecode.InstallManagedSkills(ctx, log.With("component", "mcode"), root, req.AgentOptions["skills"]) - if err != nil { - return result, err - } - if len(installed.Warnings) > 0 { - return result, fmt.Errorf("mcode: one or more configured Skills could not be installed") + if managedSkills { + installed, err := claudecode.InstallManagedSkills(ctx, log.With("component", "mcode"), root, req.AgentOptions["skills"]) + if err != nil { + return result, err + } + if len(installed.Warnings) > 0 { + return result, fmt.Errorf("mcode: one or more configured Skills could not be installed") + } } opts := req.AgentOptions prompt := optionString(opts, "system_prompt") diff --git a/apps/parsar-daemon/internal/agent/mcode/workspace.go b/apps/parsar-daemon/internal/agent/mcode/workspace.go index 3718f72a..b9624b85 100644 --- a/apps/parsar-daemon/internal/agent/mcode/workspace.go +++ b/apps/parsar-daemon/internal/agent/mcode/workspace.go @@ -56,10 +56,25 @@ func prepareWorkspaceOptions(ctx context.Context, c WorkspaceConfig, req proto.P // cwd remains private; only the internal MCP worker receives the public workspace. private := req private.LocalEnvironment, private.WorkDir, private.DisableExecutionEnvironment = nil, "", true - opts, err := prepareOptions(ctx, private) + opts, err := prepareOptionsWithSkills(ctx, private, false) if err != nil { return opts, err } + if err := localworkspace.VerifySkills(req.LocalEnvironment.Skills); err != nil { + return opts, err + } + if len(req.LocalEnvironment.Skills) > 0 { + link := filepath.Join(opts.DataDir, "skills") + if target, err := os.Readlink(link); err == nil { + if target != localworkspace.SkillDirectory { + return opts, fmt.Errorf("mcode: unexpected native Skill root") + } + } else if !os.IsNotExist(err) { + return opts, err + } else if err := os.Symlink(localworkspace.SkillDirectory, link); err != nil { + return opts, err + } + } raw, err := os.ReadFile(filepath.Join(opts.DataDir, "config.yaml")) if err != nil { return opts, err @@ -77,7 +92,7 @@ func prepareWorkspaceOptions(ctx context.Context, c WorkspaceConfig, req proto.P if err = os.WriteFile(filepath.Join(opts.DataDir, "config.yaml"), raw, 0600); err != nil { return opts, err } - profile := map[string]any{"workspace": "/workspace", "scratch": c.Scratch, "network": c.Network, "protectedDirs": slices.Clone(c.ProtectedDirs)} + profile := map[string]any{"workspace": "/workspace", "scratch": c.Scratch, "network": c.Network, "protectedDirs": slices.Clone(c.ProtectedDirs), "skills": len(req.LocalEnvironment.Skills) > 0} if req.LocalEnvironment.ToolEnvironment { if err := localworkspace.VerifyToolEnvironment(); err != nil { return opts, err diff --git a/apps/parsar-daemon/internal/localworkspace/skills.go b/apps/parsar-daemon/internal/localworkspace/skills.go new file mode 100644 index 00000000..d9aa9a7e --- /dev/null +++ b/apps/parsar-daemon/internal/localworkspace/skills.go @@ -0,0 +1,60 @@ +package localworkspace + +import ( + "errors" + "io/fs" + "os" + "path/filepath" + + "github.com/MiniMax-AI-Dev/parsar/internal/agentskill" +) + +const CapabilityDirectory = "/environment/initialization/capabilities" +const SkillDirectory = CapabilityDirectory + "/skills" + +// VerifySkills consumes the common initialized layout, independently of native loading. +func VerifySkills(skills []agentskill.Metadata) error { + if len(skills) == 0 { + return nil + } + for _, directory := range []string{CapabilityDirectory, SkillDirectory} { + actual, err := filepath.EvalSymlinks(directory) + if err != nil || actual != directory { + return errors.New("initialized Skill directory unavailable") + } + } + seen := map[string]bool{} + for _, metadata := range skills { + if metadata.Type != "inline" || metadata.Name == "" || filepath.Base(metadata.Name) != metadata.Name || metadata.Name == "." || metadata.Name == ".." || seen[metadata.Name] { + return agentskill.ErrInvalid + } + seen[metadata.Name] = true + root := filepath.Join(SkillDirectory, metadata.Name) + count, total := 0, int64(0) + if err := filepath.WalkDir(root, func(path string, entry fs.DirEntry, err error) error { + if err != nil { + return agentskill.ErrInvalid + } + if entry.IsDir() { + return nil + } + info, err := entry.Info() + if err != nil || !info.Mode().IsRegular() || info.Mode().Perm()&0222 != 0 { + return agentskill.ErrInvalid + } + count++ + total += info.Size() + if count > agentskill.MaxFiles || total > agentskill.MaxExpandedBytes { + return agentskill.ErrInvalid + } + return nil + }); err != nil { + return err + } + body, err := os.ReadFile(filepath.Join(root, "SKILL.md")) + if err != nil || agentskill.ValidateManifest(body, metadata) != nil { + return agentskill.ErrInvalid + } + } + return nil +} diff --git a/contracts/agents-api/README.md b/contracts/agents-api/README.md index 1c1a5e1a..2ec0d222 100644 --- a/contracts/agents-api/README.md +++ b/contracts/agents-api/README.md @@ -89,7 +89,7 @@ the Python SDK. Vault HTTP paths start at `/vaults`, not `/agents/vaults`. | sessions.subagents.turns.items | list | Missing | | environments | retrieve | Supported Codex self-hosted and three-harness Docker/E2B hosted profiles: durable status and safe 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/initial-file configuration 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 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 +155,7 @@ user-managed enrollment remain outside this qualification. | Area | Missing or unverified scope | | --- | --- | | Subagents / multi_agent | Six public child read operations, enabled execution, child lifecycle/interactions and full recovery; deferred outside the MVP | -| Environment Templates | Other populated initialization, restricted network, referenced files overrides/null network and exact hosted errors; CRUD/list, initial files and Session references are supported | +| Environment Templates | Skills references, Plugins, system packages, capability directories, restricted network, installation overrides/null network and exact hosted errors; CRUD/list, files, env/setup/npm/Python, inline 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 a7a69069..7191a67f 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 env, ordered setup and npm/Python packages; remaining populated installations reject explicitly. + supported as described below, together with inline Skills, env, ordered setup and 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. @@ -85,6 +85,56 @@ 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 + +Both templates and standalone hosted configuration accept inline Skill ZIPs: + +```python +import base64 +from pathlib import Path + +skill = { + "type": "inline", "name": "report", "description": "Create the report.", + "source": {"type": "base64", "media_type": "application/zip", + "data": base64.b64encode(Path("report.zip").read_bytes()).decode()}, +} +template = client.beta.agents.environments.templates.create(skills=[skill]) +``` + +Each archive contains one top-level folder with `SKILL.md` and optional supporting +files. The manifest name/description must match the request. Portable descriptive +frontmatter supports `name`, `description`, `license`, `compatibility` and string +`metadata`; native hooks, permission controls and subagent directives reject. +Local limits are 50 Skills, 5 MiB compressed and 20 MiB expanded per archive, +10 MiB compressed and 50 MiB expanded in total, and 1,000 entries per archive. +Regular files only: path traversal, links, duplicate destinations, special files +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, +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. + +The shared initializer installs Skills under +`/environment/initialization/capabilities/skills/` before setup and native execution. +Setup and native tools can read that tree but cannot write it; completed recovery +never reinstalls it. The execution contract carries installed metadata only. +Codex registers native extra roots, Claude creates its own explicit Skill plugin +envelope, and MiniMax points its native user-global catalog at the shared root. +MiniMax retains disabled unrestricted built-in tools and uses its existing +isolated workspace tool worker. No Provider or model/tool loop is added. + +Codex `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 +is not evidence of exact public tool-set parity. Qualification probes alone do not +establish complete public support; record real service acceptance separately. + ## Packaged Runtime initialization contract Template handlers and stores resolve public configuration without choosing a @@ -132,7 +182,7 @@ exist; this is not an atomic hook-failure prevention guarantee. ## Explicit gaps and evidence boundaries -System packages, nonempty `capability_directories`, `skills` and `plugins`, +System packages, nonempty `capability_directories` and `plugins`, and Skills API references, plus restricted-domain network policy, remain unsupported for both templates and inline initialization. The separate live Files API remains available after initialization. Unsupported requests reject without echoing payloads. @@ -146,7 +196,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 are installed first, followed by npm/Python packages and ordered commands; +Files and inline Skills are installed first, followed by 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. diff --git a/contracts/agents-api/openapi.yaml b/contracts/agents-api/openapi.yaml index 75bbdf62..98d8759f 100644 --- a/contracts/agents-api/openapi.yaml +++ b/contracts/agents-api/openapi.yaml @@ -266,6 +266,11 @@ definitions: type: object type: array x-nullable: true + skills: + items: + type: object + type: array + x-nullable: true type: enum: - none @@ -2056,10 +2061,10 @@ paths: - application/json description: Saves tenant-owned basic hosted configuration. Supports nullable name, enabled/disabled network, initial inline/file_id files, confidential - env, ordered setup_commands and npm/Python packages. Omitted/null network - defaults to enabled. System packages, other populated installations and restricted - network are rejected before persistence without echoing input. No compute - is allocated. Exact hosted error/retry semantics remain unverified. + env, ordered setup_commands, npm/Python packages and inline Skill ZIPs. Omitted/null + network defaults to enabled. System packages, other populated installations + and restricted network are rejected before persistence without echoing input. + No compute is allocated. Exact hosted error/retry semantics remain unverified. parameters: - description: agents=v1 in: header @@ -2192,8 +2197,10 @@ paths: Null name clears and null network resets to the pinned enabled default. Existing Session snapshots and creation retries remain unchanged. Initial files replace as a list; null/empty clears. File data is encrypted separately and excluded - from response metadata. Other populated installations are unsupported. Exact - hosted no-op timestamp behavior remains unverified. + from response metadata. Skills replace as a list; null/empty clears. Skill + archives are encrypted separately and omitted from responses. Other populated + installations are unsupported. Exact hosted no-op timestamp behavior remains + unverified. parameters: - description: agents=v1 in: header diff --git a/contracts/agents-api/v1/sessions.go b/contracts/agents-api/v1/sessions.go index 936bc3f3..09e6afc2 100644 --- a/contracts/agents-api/v1/sessions.go +++ b/contracts/agents-api/v1/sessions.go @@ -35,6 +35,7 @@ type InlineAgent struct { // Environment contains supported request variants; self-hosted creation requires a workspace directory. type Environment struct { + Skills []json.RawMessage `json:"skills,omitempty" swaggertype:"array,object" extensions:"x-nullable"` Env map[string]string `json:"env,omitempty" extensions:"x-nullable"` SetupCommands []json.RawMessage `json:"setup_commands,omitempty" extensions:"x-nullable" swaggertype:"array,object"` Packages *EnvironmentPackages `json:"packages,omitempty" extensions:"x-nullable"` diff --git a/internal/agentdaemon/proto/environment.go b/internal/agentdaemon/proto/environment.go index 5159bafb..aef22f9d 100644 --- a/internal/agentdaemon/proto/environment.go +++ b/internal/agentdaemon/proto/environment.go @@ -1,8 +1,12 @@ package proto +import "github.com/MiniMax-AI-Dev/parsar/internal/agentskill" + // LocalEnvironment references a deployment-bound workspace; it never supplies a path. type LocalEnvironment struct { ID string `json:"id"` + // Skills describes Core-installed inert content in the packaged Runtime. + Skills []agentskill.Metadata `json:"skills,omitempty"` // ToolEnvironment consumes Core-completed confidential initialization. ToolEnvironment bool `json:"tool_environment,omitempty"` // NetworkAccess must match the immutable Runtime policy for execution. diff --git a/internal/agentskill/bundle.go b/internal/agentskill/bundle.go new file mode 100644 index 00000000..094292f5 --- /dev/null +++ b/internal/agentskill/bundle.go @@ -0,0 +1,166 @@ +// Package agentskill validates inert Skill bundles without native loading rules. +package agentskill + +import ( + "archive/zip" + "bytes" + "errors" + "io" + "os" + "path" + "regexp" + "strings" + "unicode/utf8" + + "gopkg.in/yaml.v3" +) + +const ( + MaxArchiveBytes = 5 << 20 + MaxExpandedBytes = 20 << 20 + MaxFiles = 1000 +) + +var ErrInvalid = errors.New("invalid or unsupported Skill bundle") +var namePattern = regexp.MustCompile(`^[a-z0-9]+(?:[-_][a-z0-9]+)*$`) + +type Metadata struct { + Type string `json:"type"` + Name string `json:"name"` + Description string `json:"description"` +} + +type File struct { + Path string `json:"path"` + Data []byte `json:"data"` + Executable bool `json:"executable,omitempty"` +} + +// Read validates the full archive before exposing any files for installation. +func Read(archive []byte, expected Metadata) ([]File, error) { + if expected.Type != "inline" || !namePattern.MatchString(expected.Name) || len(expected.Name) > 64 || expected.Description == "" || !utf8.ValidString(expected.Description) || len(archive) > MaxArchiveBytes { + return nil, ErrInvalid + } + reader, err := zip.NewReader(bytes.NewReader(archive), int64(len(archive))) + if err != nil || len(reader.File) == 0 || len(reader.File) > MaxFiles { + return nil, ErrInvalid + } + root := "" + seen := map[string]bool{} + files := []File{} + total := 0 + manifest := false + for _, entry := range reader.File { + name := strings.TrimSuffix(entry.Name, "/") + if !utf8.ValidString(name) || len(name) > 4096 || strings.ContainsAny(name, "\\\x00\r\n") || path.Clean(name) != name || path.IsAbs(name) { + return nil, ErrInvalid + } + parts := strings.SplitN(name, "/", 2) + if parts[0] == "." || parts[0] == ".." || parts[0] == "" { + return nil, ErrInvalid + } + if root == "" { + root = parts[0] + } + if root != parts[0] || seen[name] || entry.Flags&1 != 0 { + return nil, ErrInvalid + } + seen[name] = true + if entry.Mode().Type() == os.ModeDir { + continue + } + if !entry.Mode().IsRegular() || len(parts) != 2 || entry.UncompressedSize64 > MaxExpandedBytes || total+int(entry.UncompressedSize64) > MaxExpandedBytes { + return nil, ErrInvalid + } + stream, err := entry.Open() + if err != nil { + return nil, ErrInvalid + } + body, readErr := io.ReadAll(io.LimitReader(stream, int64(MaxExpandedBytes-total)+1)) + closeErr := stream.Close() + if readErr != nil || closeErr != nil || len(body) > MaxExpandedBytes-total { + return nil, ErrInvalid + } + total += len(body) + if parts[1] == "SKILL.md" { + if ValidateManifest(body, expected) != nil { + return nil, ErrInvalid + } + manifest = true + } + files = append(files, File{Path: parts[1], Data: body, Executable: entry.Mode().Perm()&0111 != 0}) + } + if !manifest { + return nil, ErrInvalid + } + regular := map[string]bool{} + for _, f := range files { + regular[f.Path] = true + } + for _, f := range files { + for parent := path.Dir(f.Path); parent != "."; parent = path.Dir(parent) { + if regular[parent] { + return nil, ErrInvalid + } + } + } + return files, nil +} + +// ValidateManifest accepts portable descriptive metadata, not native activation controls. +func ValidateManifest(body []byte, expected Metadata) error { + if len(body) > 256<<10 || !utf8.Valid(body) { + return ErrInvalid + } + text := strings.ReplaceAll(string(body), "\r\n", "\n") + if !strings.HasPrefix(text, "---\n") { + return ErrInvalid + } + end := strings.Index(text[4:], "\n---") + if end < 0 { + return ErrInvalid + } + end += 4 + tail := text[end+4:] + if tail != "" && !strings.HasPrefix(tail, "\n") { + return ErrInvalid + } + decoder := yaml.NewDecoder(strings.NewReader(text[4:end])) + var document yaml.Node + if decoder.Decode(&document) != nil || len(document.Content) != 1 { + return ErrInvalid + } + var extra yaml.Node + if decoder.Decode(&extra) != io.EOF { + return ErrInvalid + } + node := document.Content[0] + if node.Kind != yaml.MappingNode { + return 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 + } + fields[key.Value] = value + switch key.Value { + case "name", "description", "license", "compatibility": + if value.Kind != yaml.ScalarNode || value.Tag != "!!str" { + return ErrInvalid + } + case "metadata": + var metadata map[string]string + if value.Kind != yaml.MappingNode || value.Decode(&metadata) != nil { + return ErrInvalid + } + default: + return ErrInvalid + } + } + if fields["name"] == nil || fields["description"] == nil || fields["name"].Value != expected.Name || fields["description"].Value != expected.Description { + return ErrInvalid + } + return nil +} diff --git a/internal/agentskill/bundle_test.go b/internal/agentskill/bundle_test.go new file mode 100644 index 00000000..b725e771 --- /dev/null +++ b/internal/agentskill/bundle_test.go @@ -0,0 +1,71 @@ +package agentskill + +import ( + "archive/zip" + "bytes" + "io/fs" + "testing" +) + +func TestBundle(t *testing.T) { + metadata := Metadata{Type: "inline", Name: "proof-skill", Description: "Run the proof."} + manifest := []byte("---\nname: proof-skill\ndescription: Run the proof.\n---\nRun scripts/check.py.\n") + makeArchive := func(paths []string, bodies [][]byte, mode fs.FileMode) []byte { + var b bytes.Buffer + w := zip.NewWriter(&b) + for i, p := range paths { + h := &zip.FileHeader{Name: p, Method: zip.Deflate} + h.SetMode(mode) + f, err := w.CreateHeader(h) + if err != nil { + t.Fatal(err) + } + if _, err = f.Write(bodies[i]); err != nil { + t.Fatal(err) + } + } + if err := w.Close(); err != nil { + t.Fatal(err) + } + return b.Bytes() + } + data := makeArchive([]string{"folder/SKILL.md", "folder/scripts/check.py"}, [][]byte{manifest, {0, 1, 2}}, 0755) + files, err := Read(data, metadata) + if err != nil || len(files) != 2 || !bytes.Equal(files[1].Data, []byte{0, 1, 2}) || !files[1].Executable { + t.Fatalf("files=%+v err=%v", files, err) + } + for _, tc := range []struct { + name string + paths []string + bodies [][]byte + mode fs.FileMode + }{ + {"escape", []string{"folder/SKILL.md", "folder/../../private"}, [][]byte{manifest, {}}, 0600}, + {"duplicate", []string{"folder/SKILL.md", "folder/SKILL.md"}, [][]byte{manifest, manifest}, 0600}, + {"multiple roots", []string{"folder/SKILL.md", "other/file"}, [][]byte{manifest, {}}, 0600}, + {"symlink", []string{"folder/SKILL.md"}, [][]byte{manifest}, fs.ModeSymlink | 0600}, + {"file parent", []string{"folder/SKILL.md", "folder/a", "folder/a/b"}, [][]byte{manifest, {}, {}}, 0600}, + {"expanded limit", []string{"folder/SKILL.md", "folder/large"}, [][]byte{manifest, bytes.Repeat([]byte{0}, MaxExpandedBytes)}, 0600}, + {"missing manifest", []string{"folder/file"}, [][]byte{{}}, 0600}, + } { + t.Run(tc.name, func(t *testing.T) { + if _, err := Read(makeArchive(tc.paths, tc.bodies, tc.mode), metadata); err == nil { + t.Fatal("invalid archive accepted") + } + }) + } + if _, err := Read(make([]byte, MaxArchiveBytes+1), metadata); err == nil { + t.Fatal("archive byte limit ignored") + } + for _, front := range []string{ + "name: other\ndescription: Run the proof.", + "name: proof-skill\nname: proof-skill\ndescription: Run the proof.", + "name: proof-skill\ndescription: Run the proof.\nhooks: {}", + "name: proof-skill\ndescription: Run the proof.\ncontext: fork", + "name: proof-skill\ndescription: Run the proof.\nallowed-tools: Bash", + } { + if ValidateManifest([]byte("---\n"+front+"\n---\nText"), metadata) == nil { + t.Fatal("unsupported manifest accepted") + } + } +} diff --git a/packages/claude-sdk-adapter/src/workspace.ts b/packages/claude-sdk-adapter/src/workspace.ts index 8c297607..fe86caeb 100644 --- a/packages/claude-sdk-adapter/src/workspace.ts +++ b/packages/claude-sdk-adapter/src/workspace.ts @@ -1,3 +1,4 @@ +import { parseSkills, workspaceSkills, type WorkspaceSkill } from "./workspace_skills.js"; import type { CanUseTool, HookCallback, Options } from "@anthropic-ai/claude-agent-sdk"; import { lstatSync, realpathSync, statSync } from "node:fs"; import { dirname, isAbsolute, join, resolve } from "node:path"; @@ -9,6 +10,7 @@ export type Workspace = { protected_dirs: string[]; dependency_path: string; env_names: string[]; + skills?: WorkspaceSkill[]; tool_environment?: boolean; network_access?: "enabled" | "disabled"; }; @@ -40,7 +42,7 @@ export function parseWorkspace(value: unknown, cwd: string): Workspace | undefin if (value === undefined) return undefined; if (!value || typeof value !== "object" || Array.isArray(value)) throw new Error("invalid_request"); const config = value as Record; - if (Object.keys(config).some(key => !["home", "state", "scratch", "protected_dirs", "dependency_path", "env_names", "network_access", "tool_environment"].includes(key)) || + if (Object.keys(config).some(key => !["home", "state", "scratch", "protected_dirs", "dependency_path", "env_names", "network_access", "tool_environment", "skills"].includes(key)) || (config.tool_environment !== undefined && typeof config.tool_environment !== "boolean") || (config.network_access !== undefined && config.network_access !== "enabled" && config.network_access !== "disabled") || !Array.isArray(config.protected_dirs) || !Array.isArray(config.env_names) || @@ -57,11 +59,12 @@ export function parseWorkspace(value: unknown, cwd: string): Workspace | undefin contains(root, actual) || contains(actual, root))) throw new Error("invalid_request"); return actual; }); - return { ...config, dependency_path: dependencies.join(":") } as Workspace; + return { ...config, ...(config.skills === undefined ? {} : { skills: parseSkills(config.skills) }), dependency_path: dependencies.join(":") } as Workspace; } export class WorkspaceProfile { readonly options: Options; + private readonly skillNames: readonly string[]; constructor(private readonly cwd: string, private readonly config: Workspace, private readonly functions: readonly string[] = []) { config = parseWorkspace(config, cwd)!; @@ -78,11 +81,16 @@ export class WorkspaceProfile { if (value === undefined) throw new Error("invalid_request"); env[name] = value; } + const skills = workspaceSkills(config.state, config.skills ?? []); + this.skillNames = skills?.names ?? []; + const skillTools = skills ? ["Skill"] : []; const protectedRoots = [config.home, config.state, ...config.protected_dirs]; this.options = { - env, tools: [...nativeTools], allowedTools: [...functions], mcpServers: {}, strictMcpConfig: true, + env, tools: [...nativeTools, ...skillTools], + ...(skills ? { plugins: [{ type: "local" as const, path: skills.path, skipMcpDiscovery: true }] } : {}), allowedTools: [...functions], mcpServers: {}, strictMcpConfig: true, settingSources: [], permissionMode: "default", persistSession: true, settings: { + ...(skills ? { disableSkillShellExecution: true } : {}), permissions: { blockReadsOutsideWorkingDirectories: true, disableBypassPermissionsMode: "disable", deny: [...protectedRoots, "/proc", "/sys"].flatMap(path => [ @@ -94,7 +102,7 @@ export class WorkspaceProfile { enabled: true, failIfUnavailable: true, autoAllowBashIfSandboxed: false, allowUnsandboxedCommands: false, excludedCommands: [], enableWeakerNestedSandbox: false, enableWeakerNetworkIsolation: false, filesystem: { disabled: false, allowWrite: [cwd, config.scratch, ...(config.tool_environment ? ["/environment/packages"] : [])], denyRead: protectedRoots, - denyWrite: protectedRoots, allowRead: [] }, + denyWrite: [...protectedRoots, ...(skills ? ["/environment/initialization/capabilities"] : [])], allowRead: [] }, credentials: { envVars: [...new Set([...credentialNames, ...config.env_names])].map(name => ({ name, mode: "deny" })), files: protectedRoots.map(path => ({ path, mode: "deny" })), @@ -107,7 +115,7 @@ export class WorkspaceProfile { } verify(tools: string[], servers: { name: string; status: string }[]): void { - const expected = [...nativeTools, ...this.functions]; + const expected = [...nativeTools, ...this.functions, ...(this.skillNames.length ? ["Skill"] : [])]; if (servers.length !== (this.functions.length ? 1 : 0) || servers.some(server => server.name !== "functions" || server.status !== "connected") || tools.length !== expected.length || new Set(tools).size !== tools.length || @@ -144,6 +152,7 @@ export class WorkspaceProfile { if (!value || typeof value !== "object" || Array.isArray(value)) return false; const input = value as Record; if (this.functions.includes(name)) return true; + if (name === "Skill") return typeof input.skill === "string" && this.skillNames.includes(input.skill); if (name === "Bash") return typeof input.command === "string" && !!input.command.trim() && (input.run_in_background === undefined || input.run_in_background === false) && (input.dangerouslyDisableSandbox === undefined || input.dangerouslyDisableSandbox === false); diff --git a/packages/claude-sdk-adapter/src/workspace_skills.ts b/packages/claude-sdk-adapter/src/workspace_skills.ts new file mode 100644 index 00000000..4ba85305 --- /dev/null +++ b/packages/claude-sdk-adapter/src/workspace_skills.ts @@ -0,0 +1,44 @@ +import { lstatSync, mkdirSync, readFileSync, readlinkSync, symlinkSync, writeFileSync } from "node:fs"; +import { join } from "node:path"; + +export type WorkspaceSkill = { type: "inline"; name: string; description: string }; +export const skillRoot = "/environment/initialization/capabilities/skills"; +const pluginName = "environment-skills"; + +export function parseSkills(value: unknown): WorkspaceSkill[] { + if (value === undefined) return []; + if (!Array.isArray(value) || value.length > 50) throw new Error("invalid_request"); + const seen = new Set(); + for (const skill of value) { + if (!skill || typeof skill !== "object" || Array.isArray(skill) || + Object.keys(skill).some(key => !["type", "name", "description"].includes(key)) || + skill.type !== "inline" || typeof skill.name !== "string" || !/^[a-z0-9]+(?:[-_][a-z0-9]+)*$/.test(skill.name) || + skill.name.length > 64 || typeof skill.description !== "string" || !skill.description || seen.has(skill.name)) throw new Error("invalid_request"); + seen.add(skill.name); + } + return value as WorkspaceSkill[]; +} + +// The adapter generates the native plugin envelope; public Skill archives never +// supply a plugin manifest, settings, hooks or an MCP installation. +export function workspaceSkills(state: string, skills: readonly WorkspaceSkill[]): { path: string; names: string[] } | undefined { + if (!skills.length) return undefined; + for (const skill of skills) { + const root = join(skillRoot, skill.name); + const body = readFileSync(join(root, "SKILL.md"), "utf8"); + if (/(?<=^|\s)!`[^`]+`/m.test(body) || /```!\s*\n?[\s\S]*?\n?```/.test(body)) { + throw new Error("unsupported native Skill activation"); + } + } + const path = join(state, "environment-skills"); + mkdirSync(join(path, ".claude-plugin"), { recursive: true, mode: 0o700 }); + writeFileSync(join(path, ".claude-plugin", "plugin.json"), JSON.stringify({ name: pluginName, description: "Environment Skills" }), { mode: 0o600 }); + const link = join(path, "skills"); + try { + if (!lstatSync(link).isSymbolicLink() || readlinkSync(link) !== skillRoot) throw new Error("invalid native Skill root"); + } catch (error) { + if ((error as NodeJS.ErrnoException).code !== "ENOENT") throw error; + symlinkSync(skillRoot, link); + } + return { path, names: skills.map(skill => `${pluginName}:${skill.name}`) }; +} diff --git a/packages/mcode-harness/launch.mjs b/packages/mcode-harness/launch.mjs index f3447531..54b3fe1e 100644 --- a/packages/mcode-harness/launch.mjs +++ b/packages/mcode-harness/launch.mjs @@ -17,7 +17,7 @@ try { mkdirSync(profile.scratch,{recursive:true}); await SandboxManager.initialize({ network:{allowedDomains:[],deniedDomains:profile.network==='disabled'?['*']:[],allowAll:profile.network==='enabled'}, - filesystem:{denyRead:profile.protectedDirs,allowWrite:[profile.workspace,profile.scratch,...(profile.toolEnvironment ? ['/environment/packages'] : [])],denyWrite:[]}, + filesystem:{denyRead:profile.protectedDirs,allowWrite:[profile.workspace,profile.scratch,...(profile.toolEnvironment ? ['/environment/packages'] : [])],denyWrite:profile.skills ? ["/environment/initialization/capabilities"] : []}, seccomp:{applyPath:join(here,'dist/vendor/seccomp/x64/apply-seccomp')}, },undefined,false); const quote=s=>"'"+s.replaceAll("'","'\\''")+"'"; diff --git a/scripts/build-agents-api.sh b/scripts/build-agents-api.sh index c4c0edc9..f24443d0 100755 --- a/scripts/build-agents-api.sh +++ b/scripts/build-agents-api.sh @@ -21,7 +21,7 @@ tar -C "$repo_root" -cf - \ go.mod go.sum \ contracts/agents-api/v1 \ internal/agentdaemon/device internal/agentdaemon/gateway internal/agentdaemon/proto \ - internal/obs/log services/agents-api \ + internal/agentskill internal/obs/log services/agents-api \ | tar -C "$build_context" -xf - ( diff --git a/services/agents-api/deploy/runtime/initialize.py b/services/agents-api/deploy/runtime/initialize.py index 377ac48b..d3f60274 100644 --- a/services/agents-api/deploy/runtime/initialize.py +++ b/services/agents-api/deploy/runtime/initialize.py @@ -3,6 +3,8 @@ Core owns sequencing and the completion ledger. This helper executes one bounded operation; it never retries, schedules, selects a harness or interprets templates. """ +import base64 +import hashlib import json import os from pathlib import Path @@ -15,7 +17,9 @@ CONFIG = ROOT / 'initialization' PACKAGES = ROOT / 'packages' ENV_FILE = CONFIG / 'tool-env.sh' -MAX_INPUT = 1024 * 1024 +CAPABILITIES = CONFIG / 'capabilities' +SKILLS = CAPABILITIES / 'skills' +MAX_INPUT = 32 * 1024 * 1024 BASE_ENV = {'PATH': '/usr/local/bin:/usr/bin:/bin', 'HOME': '/tmp', 'LANG': 'C.UTF-8'} DIRECTORY = os.O_RDONLY | os.O_DIRECTORY | os.O_NOFOLLOW | os.O_CLOEXEC @@ -39,6 +43,66 @@ def roots(): os.close(environment) +def install_skill(request): + name, files = request['name'], request['files'] + if not isinstance(name, str) or not re.fullmatch('[a-z0-9]+(?:[-_][a-z0-9]+)*', name) or len(name) > 64 or not isinstance(files, list) or not 0 < len(files) <= 1000: + raise ValueError('invalid skill') + directory = os.open(SKILLS, DIRECTORY) + try: + os.mkdir(name, mode=0o700, dir_fd=directory) + skill = open_directory(directory, name) + finally: + os.close(directory) + total = 0 + try: + for file in files: + relative = file['path'] + if not isinstance(relative, str) or len(relative) > 4096 or any(c in relative for c in ('\\', '\x00', '\r', '\n')): + raise ValueError('invalid skill path') + parts = relative.split('/') + if any(not part or part in ('.', '..') for part in parts): + raise ValueError('invalid skill path') + body = base64.b64decode(file['data'], validate=True) + total += len(body) + if total > 20 * 1024 * 1024: + raise ValueError('skill too large') + parent = os.dup(skill) + try: + for part in parts[:-1]: + try: + os.mkdir(part, mode=0o700, dir_fd=parent) + except FileExistsError: + pass + child = open_directory(parent, part) + os.close(parent) + parent = child + # A fresh installation cannot replace an existing member. + try: + os.stat(parts[-1], dir_fd=parent, follow_symlinks=False) + except FileNotFoundError: + pass + else: + raise ValueError('duplicate skill member') + result = subprocess.run(['/usr/local/bin/agents-api-codex-write', str(SKILLS / name), relative, + str(len(body)), str(ROOT / 'staging')], + input=body + hashlib.sha256(body).digest(), env=BASE_ENV, + capture_output=True, check=True) + receipt = json.loads(result.stdout) + if result.stderr or receipt != {'version': 1, 'outcome': 'completed', 'size_bytes': len(body)}: + raise ValueError('skill write unconfirmed') + fd = os.open(parts[-1], os.O_RDONLY | os.O_NOFOLLOW, dir_fd=parent) + try: + os.fchmod(fd, 0o500 if file.get('executable', False) else 0o400) + os.fsync(fd) + finally: + os.close(fd) + finally: + os.close(parent) + os.fsync(skill) + finally: + os.close(skill) + + def configure(env): if not isinstance(env, dict) or any( not isinstance(name, str) or not re.fullmatch('[A-Za-z_][A-Za-z0-9_]*', name) @@ -55,6 +119,16 @@ def configure(env): } materialized = json.dumps(values, ensure_ascii=True) script = ''.join('export ' + name + '=' + shlex.quote(value) + '\n' for name, value in sorted(values.items())) + environment = os.open(CONFIG, DIRECTORY) + try: + os.mkdir('capabilities', mode=0o700, dir_fd=environment) + capabilities = open_directory(environment, 'capabilities') + try: + os.mkdir('skills', mode=0o700, dir_fd=capabilities) + finally: + os.close(capabilities) + finally: + os.close(environment) directory = os.open(CONFIG, DIRECTORY) try: fd = os.open('tool-env.sh', os.O_WRONLY | os.O_CREAT | os.O_EXCL | os.O_NOFOLLOW, 0o400, dir_fd=directory) @@ -99,6 +173,9 @@ def run(request): if action == 'configure': configure(request['env']) return + if action == 'skill': + install_skill(request) + return args = sandbox(request['network'], request.get('cwd', '/workspace')) if action == 'setup': command = request['command'] diff --git a/services/agents-api/deploy/runtime/initialize_test.py b/services/agents-api/deploy/runtime/initialize_test.py index 915b9922..d280ef32 100644 --- a/services/agents-api/deploy/runtime/initialize_test.py +++ b/services/agents-api/deploy/runtime/initialize_test.py @@ -4,6 +4,7 @@ support the same nested isolation as its deployed Provider. No model is mocked; these checks exercise initialization only, not public native-model acceptance. """ +import base64 import json import os from pathlib import Path @@ -33,6 +34,18 @@ def main(): Path('/environment/staging/request').write_text(CANARY) os.environ['DAEMON_PRIVATE_CANARY'] = CANARY invoke('configure', env={'INITIALIZATION_VALUE': CANARY, 'WITH_QUOTES': "'\n$(false)"}) + skill = [{'path': 'SKILL.md', 'data': base64.b64encode(b'---\nname: proof\ndescription: A proof.\n---\nRead check.sh.').decode()}, + {'path': 'scripts/check.sh', 'data': base64.b64encode(b'#!/bin/sh\nprintf skill-proof').decode(), 'executable': True}, + {'path': 'data.bin', 'data': base64.b64encode(bytes(range(256))).decode()}] + invoke('skill', name='proof', files=skill) + assert Path('/environment/initialization/capabilities/skills/proof/data.bin').read_bytes() == bytes(range(256)) + assert Path('/environment/initialization/capabilities/skills/proof/scripts/check.sh').stat().st_mode & 0o777 == 0o500 + invoke('skill', succeeds=False, name='proof', files=skill) + invoke('skill', succeeds=False, name='invalid', files=[{'path': '../../private/credential', 'data': 'YmFk'}]) + assert Path('/environment/private/credential').read_text() == CANARY + invoke('setup', command='/environment/initialization/capabilities/skills/proof/scripts/check.sh > /workspace/skill-result') + assert Path('/environment/workspace/skill-result').read_text() == 'skill-proof' + # Re-entry must not replace confidential configuration after any effects. invoke('configure', succeeds=False, env={'INITIALIZATION_VALUE': 'changed'}) invoke('setup', command='printf "%s" "$INITIALIZATION_VALUE" > first; printf secret; printf secret >&2') @@ -45,7 +58,7 @@ def main(): assert os.environ['WITH_QUOTES'] == "'\\n$(false)" for p in pathlib.Path('/proc').glob('[0-9]*/environ'): assert b'DAEMON_PRIVATE_CANARY=' not in p.read_bytes() -for path in ('/usr/bin/untrusted', '/environment/initialization/tool-env.sh'): +for path in ('/usr/bin/untrusted', '/environment/initialization/tool-env.sh', '/environment/initialization/capabilities/skills/proof/SKILL.md'): try: pathlib.Path(path).write_text('bad') except OSError: pass else: raise AssertionError(path) diff --git a/services/agents-api/internal/api/environment_setup.go b/services/agents-api/internal/api/environment_setup.go index d1d6bb1b..b7c9dbf1 100644 --- a/services/agents-api/internal/api/environment_setup.go +++ b/services/agents-api/internal/api/environment_setup.go @@ -73,6 +73,11 @@ func decodeEnvironmentSetup(fields map[string]json.RawMessage) (store.Environmen } } } + var err error + result.Skills, err = decodeInlineSkills(fields["skills"]) + if err != nil { + return result, err + } return result, result.Validate() } diff --git a/services/agents-api/internal/api/environment_skills.go b/services/agents-api/internal/api/environment_skills.go new file mode 100644 index 00000000..11b56022 --- /dev/null +++ b/services/agents-api/internal/api/environment_skills.go @@ -0,0 +1,76 @@ +package api + +import ( + "encoding/base64" + "encoding/json" + + "github.com/MiniMax-AI-Dev/parsar/internal/agentskill" + "github.com/MiniMax-AI-Dev/parsar/services/agents-api/internal/store" +) + +func decodeInlineSkills(raw json.RawMessage) ([]store.InlineSkill, error) { + if len(raw) == 0 { + return nil, nil + } + var entries []json.RawMessage + if json.Unmarshal(raw, &entries) != nil || len(entries) > 50 { + return nil, store.ErrInvalidInput + } + result := make([]store.InlineSkill, 0, len(entries)) + for _, entry := range entries { + var input struct { + Type string `json:"type"` + Name string `json:"name"` + Description string `json:"description"` + Source json.RawMessage `json:"source"` + } + if decodeInputObject(entry, &input, "type", "name", "description", "source") != nil || input.Type != "inline" { + return nil, store.ErrInvalidInput + } + var source struct { + Type string `json:"type"` + MediaType string `json:"media_type"` + Data string `json:"data"` + } + if decodeInputObject(input.Source, &source, "type", "media_type", "data") != nil || source.Type != "base64" || source.MediaType != "application/zip" || len(source.Data) > base64.StdEncoding.EncodedLen(agentskill.MaxArchiveBytes) { + return nil, store.ErrInvalidInput + } + body, err := base64.StdEncoding.Strict().DecodeString(source.Data) + 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}) + } + return result, store.ValidateInlineSkills(result) +} + +func skillResponse(skills []agentskill.Metadata) []json.RawMessage { + result := make([]json.RawMessage, 0, len(skills)) + for _, skill := range skills { + raw, _ := json.Marshal(skill) + result = append(result, raw) + } + return result +} + +func storedSkills(raw json.RawMessage) ([]json.RawMessage, error) { + if len(raw) == 0 { + return []json.RawMessage{}, nil + } + var entries []json.RawMessage + if json.Unmarshal(raw, &entries) != nil || len(entries) > 50 { + return nil, store.ErrInvalidInput + } + 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] { + return nil, store.ErrInvalidInput + } + seen[metadata.Name] = true + } + if entries == nil { + entries = []json.RawMessage{} + } + return entries, nil +} diff --git a/services/agents-api/internal/api/environment_skills_test.go b/services/agents-api/internal/api/environment_skills_test.go new file mode 100644 index 00000000..c1eb6e78 --- /dev/null +++ b/services/agents-api/internal/api/environment_skills_test.go @@ -0,0 +1,96 @@ +package api + +import ( + "archive/zip" + "bytes" + "encoding/base64" + "encoding/json" + "testing" +) + +func skillInput(t *testing.T, body string) json.RawMessage { + t.Helper() + var archive bytes.Buffer + writer := zip.NewWriter(&archive) + file, err := writer.Create("proof/SKILL.md") + if err != nil { + t.Fatal(err) + } + if _, err = file.Write([]byte("---\nname: proof\ndescription: A proof.\n---\n" + body)); err != nil { + t.Fatal(err) + } + if err = writer.Close(); err != nil { + t.Fatal(err) + } + raw, err := json.Marshal(map[string]any{"type": "inline", "name": "proof", "description": "A proof.", "source": map[string]string{"type": "base64", "media_type": "application/zip", "data": base64.StdEncoding.EncodeToString(archive.Bytes())}}) + if err != nil { + t.Fatal(err) + } + return raw +} + +func TestInlineSkillsSharedParsingSnapshotAndIntent(t *testing.T) { + skill := skillInput(t, "private-skill-canary") + raw := append(append([]byte(`{"skills":[`), skill...), []byte(`]}`)...) + template, err := decodeTemplateInput(raw) + if err != nil || !template.SetSkills || len(template.Initialization.Skills) != 1 { + t.Fatal("template", err) + } + environment := append([]byte(`{"type":"openai_hosted",`), raw[1:]...) + decode := func(agent string, environment []byte) sessionRequest { + var request decodedSessionRequest + if err := json.Unmarshal(append(append([]byte(`{`+agent+`,"environment":`), environment...), '}'), &request); err != nil { + t.Fatal(err) + } + input, err := request.validated() + if err != nil { + t.Fatal(err) + } + return input + } + inline := decode(`"agent":{"model":"test"}`, environment) + if !bytes.Equal(inline.initialization.Skills[0].Archive, template.Initialization.Skills[0].Archive) { + t.Fatal("inline/template differ") + } + configuration, err := resolve(inline, "tenant", "key", nil) + if err != nil || bytes.Contains(configuration, []byte(`"source"`)) || bytes.Contains(configuration, []byte(`"archive"`)) { + t.Fatal("confidential snapshot", err) + } + public, err := storedEnvironment(mustEnvironment(t, configuration)) + if err != nil || len(public.Skills) != 1 || !bytes.Contains(public.Skills[0], []byte(`"name":"proof"`)) { + t.Fatal("metadata", err) + } + intent, err := sessionCreationRequest(decode(`"agent_id":"saved"`, environment), nil) + if err != nil { + t.Fatal(err) + } + changed := append(append([]byte(`{"type":"openai_hosted","skills":[`), skillInput(t, "different")...), []byte(`]}`)...) + other, err := sessionCreationRequest(decode(`"agent_id":"saved"`, changed), nil) + if err != nil || bytes.Equal(intent, other) { + t.Fatal("archive omitted from retry identity", err) + } + for _, clearing := range []string{`{"skills":null}`, `{"skills":[]}`} { + input, err := decodeTemplateInput([]byte(clearing)) + if err != nil || !input.SetSkills || !input.Initialization.Empty() { + 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"}}]}`} { + if _, err := decodeTemplateInput([]byte(invalid)); err == nil { + t.Fatal("invalid or unsupported skill accepted") + } + } + duplicate := append(append(append(append([]byte(`{"skills":[`), skill...), ','), skill...), []byte(`]}`)...) + if _, err := decodeTemplateInput(duplicate); err == nil { + t.Fatal("duplicate skill accepted") + } +} + +func mustEnvironment(t *testing.T, configuration []byte) json.RawMessage { + t.Helper() + var fields map[string]json.RawMessage + if err := json.Unmarshal(configuration, &fields); err != nil { + t.Fatal(err) + } + return fields["environment"] +} diff --git a/services/agents-api/internal/api/environment_templates.go b/services/agents-api/internal/api/environment_templates.go index 61650695..bf3f25ab 100644 --- a/services/agents-api/internal/api/environment_templates.go +++ b/services/agents-api/internal/api/environment_templates.go @@ -38,6 +38,7 @@ func decodeTemplateInput(raw []byte) (store.EnvironmentTemplateInput, error) { _, in.SetEnv = fields["env"] _, in.SetSetup = fields["setup_commands"] _, in.SetPackages = fields["packages"] + _, in.SetSkills = fields["skills"] var setupErr error in.Initialization, setupErr = decodeEnvironmentSetup(fields) if setupErr != nil { @@ -62,7 +63,7 @@ func decodeTemplateInput(raw []byte) (store.EnvironmentTemplateInput, error) { } func templateResponse(t store.EnvironmentTemplate) v1.EnvironmentTemplate { - return v1.EnvironmentTemplate{ID: t.ID, Object: "agent.environment.template", Name: t.Name, CreatedAt: t.CreatedAt.Unix(), UpdatedAt: t.UpdatedAt.Unix(), CapabilityDirectories: []string{}, Network: v1.EnvironmentNetwork{Access: t.NetworkAccess, AllowedDomains: []string{}}, Packages: packageMetadata(&t.Packages), Files: templateFileResponse(t.Files), Plugins: []json.RawMessage{}, Skills: []json.RawMessage{}} + return v1.EnvironmentTemplate{ID: t.ID, Object: "agent.environment.template", Name: t.Name, CreatedAt: t.CreatedAt.Unix(), UpdatedAt: t.UpdatedAt.Unix(), CapabilityDirectories: []string{}, Network: v1.EnvironmentNetwork{Access: t.NetworkAccess, AllowedDomains: []string{}}, Packages: packageMetadata(&t.Packages), Files: templateFileResponse(t.Files), Plugins: []json.RawMessage{}, Skills: skillResponse(t.Skills)} } func templateNoQuery(w http.ResponseWriter, r *http.Request) bool { @@ -83,14 +84,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 network, initial files, env, npm/Python packages and setup commands are supported.") + writeError(w, http.StatusBadRequest, "unsupported_or_invalid_configuration", "Template fields are invalid or require unsupported initialization. Name, enabled/disabled network, initial files, env, npm/Python packages, setup commands and inline Skill ZIPs are supported.") return in, false } return in, true } // @Summary Create an Environment Template -// @Description Saves tenant-owned basic hosted configuration. Supports nullable name, enabled/disabled network, initial inline/file_id files, confidential env, ordered setup_commands and npm/Python packages. Omitted/null network defaults to enabled. System packages, other populated installations and restricted network are rejected before persistence without echoing input. No compute is allocated. Exact hosted error/retry semantics remain unverified. +// @Description Saves tenant-owned basic hosted configuration. Supports nullable name, enabled/disabled network, initial inline/file_id files, confidential env, ordered setup_commands, npm/Python packages and inline Skill ZIPs. Omitted/null network defaults to enabled. System packages, other populated installations and restricted network are rejected before persistence without echoing input. No compute is allocated. Exact hosted error/retry semantics remain unverified. // @Tags Environment Templates // @Accept json // @Produce json @@ -136,7 +137,7 @@ func (h *Handler) getEnvironmentTemplate(w http.ResponseWriter, r *http.Request) } // @Summary Update an Environment Template -// @Description Supplied fields replace atomically; omitted fields remain unchanged. Null name clears and null network resets to the pinned enabled default. Existing Session snapshots and creation retries remain unchanged. Initial files replace as a list; null/empty clears. File data is encrypted separately and excluded from response metadata. Other populated installations are unsupported. Exact hosted no-op timestamp behavior remains unverified. +// @Description Supplied fields replace atomically; omitted fields remain unchanged. Null name clears and null network resets to the pinned enabled default. Existing Session snapshots and creation retries remain unchanged. Initial files replace as a list; null/empty clears. File data is encrypted separately and excluded from response metadata. Skills replace as a list; null/empty clears. Skill archives are encrypted separately and omitted from responses. Other populated installations are unsupported. Exact hosted no-op timestamp behavior remains unverified. // @Tags Environment Templates // @Accept json // @Produce json diff --git a/services/agents-api/internal/api/environments.go b/services/agents-api/internal/api/environments.go index 33e75af1..eefec648 100644 --- a/services/agents-api/internal/api/environments.go +++ b/services/agents-api/internal/api/environments.go @@ -52,8 +52,11 @@ func environmentResponse(environment store.Environment) (v1.EnvironmentInfo, err if files == nil { files = []json.RawMessage{} } + if configuration.Skills == nil { + configuration.Skills = []json.RawMessage{} + } return v1.EnvironmentInfo{ ID: environment.ID, Object: "agent.environment", Type: configuration.Type, Status: environment.Status, - Files: files, Plugins: []json.RawMessage{}, Skills: []json.RawMessage{}, + Files: files, Plugins: []json.RawMessage{}, Skills: configuration.Skills, }, nil } diff --git a/services/agents-api/internal/api/hosted_environment.go b/services/agents-api/internal/api/hosted_environment.go index fe1bb045..960d5d36 100644 --- a/services/agents-api/internal/api/hosted_environment.go +++ b/services/agents-api/internal/api/hosted_environment.go @@ -37,7 +37,9 @@ func decodeHostedEnvironment(raw json.RawMessage) (*v1.Environment, error) { return nil, err } env.Files = initialFileResponse(files) - case "capability_directories", "plugins", "skills": + case "skills": + env.Skills = skillResponse(setup.SkillMetadata()) + case "capability_directories", "plugins": var list []json.RawMessage if json.Unmarshal(value, &list) != nil || len(list) != 0 { return nil, store.ErrInvalidInput @@ -68,7 +70,7 @@ func hostedSessionEnvironment(environment store.Environment) (v1.SessionEnvironm directories := []string{} return v1.SessionEnvironment{ID: environment.ID, Type: cfg.Type, CapabilityDirectories: &directories, Network: &v1.EnvironmentNetwork{Access: cfg.Network.Access, AllowedDomains: []string{}}, - Packages: func() *v1.EnvironmentPackages { value := packageMetadata(cfg.Packages); return &value }(), Files: &files, Plugins: &empty, Skills: &empty}, nil + Packages: func() *v1.EnvironmentPackages { value := packageMetadata(cfg.Packages); return &value }(), Files: &files, Plugins: &empty, Skills: &cfg.Skills}, nil } // WithHostedEnvironments enables admission only for an operator-composed, @@ -117,6 +119,11 @@ func storedEnvironment(raw json.RawMessage) (*v1.Environment, error) { } delete(fields, "initialization") } + skills, err := storedSkills(fields["skills"]) + if err != nil { + return nil, err + } + delete(fields, "skills") delete(fields, "files") base, err := json.Marshal(fields) if err != nil { @@ -127,5 +134,6 @@ func storedEnvironment(raw json.RawMessage) (*v1.Environment, error) { return nil, err } cfg.Files = files + cfg.Skills = skills return cfg, nil } diff --git a/services/agents-api/internal/api/session_template.go b/services/agents-api/internal/api/session_template.go index 2cc397cc..3c0668a0 100644 --- a/services/agents-api/internal/api/session_template.go +++ b/services/agents-api/internal/api/session_template.go @@ -29,7 +29,7 @@ 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"} { + for _, name := range []string{"files", "env", "setup_commands", "packages", "skills"} { if _, supplied := fields[name]; supplied { return nil, "", nil, store.ErrInvalidInput } @@ -61,6 +61,7 @@ func (h *Handler) resolveTemplateEnvironment(ctx context.Context, tenant string, return store.ErrInvalidInput } input.initialization = template.Initialization + input.Environment.Skills = skillResponse(template.Skills) packages := template.Initialization.PackageMetadata() input.Environment.Packages = &packages input.initialFiles = files diff --git a/services/agents-api/internal/credentialcrypto/environment_setup.go b/services/agents-api/internal/credentialcrypto/environment_setup.go index 55f367f3..c7cbd9b8 100644 --- a/services/agents-api/internal/credentialcrypto/environment_setup.go +++ b/services/agents-api/internal/credentialcrypto/environment_setup.go @@ -30,7 +30,7 @@ func (c *Cipher) OpenEnvironmentSetup(ciphertext []byte, binding EnvironmentSetu } func environmentSetupData(binding EnvironmentSetupBinding) ([]byte, error) { - if (binding.Resource != "environment_template" && binding.Resource != "session") || (binding.Field != "env" && binding.Field != "setup_commands" && binding.Field != "initialization") { + if (binding.Resource != "environment_template" && binding.Resource != "session") || (binding.Field != "env" && binding.Field != "setup_commands" && binding.Field != "initialization" && binding.Field != "skills") { return nil, errInvalidBinding } for _, value := range []string{binding.TenantID, binding.OwnerID} { diff --git a/services/agents-api/internal/db/queries/environment_templates.sql b/services/agents-api/internal/db/queries/environment_templates.sql index 3bb4fbbc..1a34aa4e 100644 --- a/services/agents-api/internal/db/queries/environment_templates.sql +++ b/services/agents-api/internal/db/queries/environment_templates.sql @@ -1,9 +1,9 @@ -- name: CreateEnvironmentTemplate :one -INSERT INTO environment_templates (id, tenant_id, name, network_access, files, file_contents, packages, env_contents, setup_contents) -VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9) RETURNING id, tenant_id, name, network_access, created_at, updated_at, files, packages; +INSERT INTO environment_templates (id, tenant_id, name, network_access, files, file_contents, packages, env_contents, setup_contents, skills, skill_contents) +VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11) RETURNING id, tenant_id, name, network_access, created_at, updated_at, files, packages, skills; -- name: GetEnvironmentTemplate :one -SELECT id, tenant_id, name, network_access, created_at, updated_at, files, packages FROM environment_templates WHERE tenant_id = $1 AND id = $2; +SELECT id, tenant_id, name, network_access, created_at, updated_at, files, packages, skills FROM environment_templates WHERE tenant_id = $1 AND id = $2; -- name: UpdateEnvironmentTemplate :one UPDATE environment_templates SET @@ -14,15 +14,17 @@ UPDATE environment_templates SET packages = CASE WHEN sqlc.arg(set_packages)::boolean THEN sqlc.arg(packages)::jsonb ELSE packages END, env_contents = CASE WHEN sqlc.arg(set_env)::boolean THEN sqlc.narg(env_contents)::bytea ELSE env_contents END, setup_contents = CASE WHEN sqlc.arg(set_setup)::boolean THEN sqlc.narg(setup_contents)::bytea ELSE setup_contents END, + skills = CASE WHEN sqlc.arg(set_skills)::boolean THEN sqlc.arg(skills)::jsonb ELSE skills END, + skill_contents = CASE WHEN sqlc.arg(set_skills)::boolean THEN sqlc.narg(skill_contents)::bytea ELSE skill_contents END, updated_at = clock_timestamp() WHERE tenant_id = sqlc.arg(tenant_id) AND id = sqlc.arg(id) -RETURNING id, tenant_id, name, network_access, created_at, updated_at, files, packages; +RETURNING id, tenant_id, name, network_access, created_at, updated_at, files, packages, skills; -- name: DeleteEnvironmentTemplate :one DELETE FROM environment_templates WHERE tenant_id = $1 AND id = $2 RETURNING id; -- name: ListEnvironmentTemplates :many -SELECT id, tenant_id, name, network_access, created_at, updated_at, files, packages FROM environment_templates +SELECT id, tenant_id, name, network_access, created_at, updated_at, files, packages, skills FROM environment_templates WHERE tenant_id = sqlc.arg(tenant_id) AND (sqlc.narg(after_created)::timestamptz IS NULL OR (NOT sqlc.arg(ascending)::boolean AND (created_at, id) < (sqlc.narg(after_created)::timestamptz, sqlc.arg(after_id)::uuid)) diff --git a/services/agents-api/internal/db/sqlc/environment_templates.sql.go b/services/agents-api/internal/db/sqlc/environment_templates.sql.go index c2e4a070..49755205 100644 --- a/services/agents-api/internal/db/sqlc/environment_templates.sql.go +++ b/services/agents-api/internal/db/sqlc/environment_templates.sql.go @@ -12,8 +12,8 @@ import ( ) const createEnvironmentTemplate = `-- name: CreateEnvironmentTemplate :one -INSERT INTO environment_templates (id, tenant_id, name, network_access, files, file_contents, packages, env_contents, setup_contents) -VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9) RETURNING id, tenant_id, name, network_access, created_at, updated_at, files, packages +INSERT INTO environment_templates (id, tenant_id, name, network_access, files, file_contents, packages, env_contents, setup_contents, skills, skill_contents) +VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11) RETURNING id, tenant_id, name, network_access, created_at, updated_at, files, packages, skills ` type CreateEnvironmentTemplateParams struct { @@ -26,6 +26,8 @@ type CreateEnvironmentTemplateParams struct { Packages []byte `json:"packages"` EnvContents []byte `json:"env_contents"` SetupContents []byte `json:"setup_contents"` + Skills []byte `json:"skills"` + SkillContents []byte `json:"skill_contents"` } type CreateEnvironmentTemplateRow struct { @@ -37,6 +39,7 @@ type CreateEnvironmentTemplateRow struct { UpdatedAt pgtype.Timestamptz `json:"updated_at"` Files []byte `json:"files"` Packages []byte `json:"packages"` + Skills []byte `json:"skills"` } func (q *Queries) CreateEnvironmentTemplate(ctx context.Context, arg CreateEnvironmentTemplateParams) (CreateEnvironmentTemplateRow, error) { @@ -50,6 +53,8 @@ func (q *Queries) CreateEnvironmentTemplate(ctx context.Context, arg CreateEnvir arg.Packages, arg.EnvContents, arg.SetupContents, + arg.Skills, + arg.SkillContents, ) var i CreateEnvironmentTemplateRow err := row.Scan( @@ -61,6 +66,7 @@ func (q *Queries) CreateEnvironmentTemplate(ctx context.Context, arg CreateEnvir &i.UpdatedAt, &i.Files, &i.Packages, + &i.Skills, ) return i, err } @@ -82,7 +88,7 @@ func (q *Queries) DeleteEnvironmentTemplate(ctx context.Context, arg DeleteEnvir } const getEnvironmentTemplate = `-- name: GetEnvironmentTemplate :one -SELECT id, tenant_id, name, network_access, created_at, updated_at, files, packages FROM environment_templates WHERE tenant_id = $1 AND id = $2 +SELECT id, tenant_id, name, network_access, created_at, updated_at, files, packages, skills FROM environment_templates WHERE tenant_id = $1 AND id = $2 ` type GetEnvironmentTemplateParams struct { @@ -99,6 +105,7 @@ type GetEnvironmentTemplateRow struct { UpdatedAt pgtype.Timestamptz `json:"updated_at"` Files []byte `json:"files"` Packages []byte `json:"packages"` + Skills []byte `json:"skills"` } func (q *Queries) GetEnvironmentTemplate(ctx context.Context, arg GetEnvironmentTemplateParams) (GetEnvironmentTemplateRow, error) { @@ -113,12 +120,13 @@ func (q *Queries) GetEnvironmentTemplate(ctx context.Context, arg GetEnvironment &i.UpdatedAt, &i.Files, &i.Packages, + &i.Skills, ) return i, err } const listEnvironmentTemplates = `-- name: ListEnvironmentTemplates :many -SELECT id, tenant_id, name, network_access, created_at, updated_at, files, packages FROM environment_templates +SELECT id, tenant_id, name, network_access, created_at, updated_at, files, packages, skills FROM environment_templates WHERE tenant_id = $1 AND ($2::timestamptz IS NULL OR (NOT $3::boolean AND (created_at, id) < ($2::timestamptz, $4::uuid)) @@ -148,6 +156,7 @@ type ListEnvironmentTemplatesRow struct { UpdatedAt pgtype.Timestamptz `json:"updated_at"` Files []byte `json:"files"` Packages []byte `json:"packages"` + Skills []byte `json:"skills"` } func (q *Queries) ListEnvironmentTemplates(ctx context.Context, arg ListEnvironmentTemplatesParams) ([]ListEnvironmentTemplatesRow, error) { @@ -174,6 +183,7 @@ func (q *Queries) ListEnvironmentTemplates(ctx context.Context, arg ListEnvironm &i.UpdatedAt, &i.Files, &i.Packages, + &i.Skills, ); err != nil { return nil, err } @@ -186,7 +196,7 @@ func (q *Queries) ListEnvironmentTemplates(ctx context.Context, arg ListEnvironm } const resolveEnvironmentTemplate = `-- name: ResolveEnvironmentTemplate :one -SELECT id, tenant_id, name, network_access, created_at, updated_at, files, file_contents, packages, env_contents, setup_contents FROM environment_templates WHERE tenant_id = $1 AND id = $2 +SELECT id, tenant_id, name, network_access, created_at, updated_at, files, file_contents, packages, env_contents, setup_contents, skills, skill_contents FROM environment_templates WHERE tenant_id = $1 AND id = $2 ` type ResolveEnvironmentTemplateParams struct { @@ -209,6 +219,8 @@ func (q *Queries) ResolveEnvironmentTemplate(ctx context.Context, arg ResolveEnv &i.Packages, &i.EnvContents, &i.SetupContents, + &i.Skills, + &i.SkillContents, ) return i, err } @@ -222,9 +234,11 @@ UPDATE environment_templates SET packages = CASE WHEN $8::boolean THEN $9::jsonb ELSE packages END, env_contents = CASE WHEN $10::boolean THEN $11::bytea ELSE env_contents END, setup_contents = CASE WHEN $12::boolean THEN $13::bytea ELSE setup_contents END, + skills = CASE WHEN $14::boolean THEN $15::jsonb ELSE skills END, + skill_contents = CASE WHEN $14::boolean THEN $16::bytea ELSE skill_contents END, updated_at = clock_timestamp() -WHERE tenant_id = $14 AND id = $15 -RETURNING id, tenant_id, name, network_access, created_at, updated_at, files, packages +WHERE tenant_id = $17 AND id = $18 +RETURNING id, tenant_id, name, network_access, created_at, updated_at, files, packages, skills ` type UpdateEnvironmentTemplateParams struct { @@ -241,6 +255,9 @@ type UpdateEnvironmentTemplateParams struct { EnvContents []byte `json:"env_contents"` SetSetup bool `json:"set_setup"` SetupContents []byte `json:"setup_contents"` + SetSkills bool `json:"set_skills"` + Skills []byte `json:"skills"` + SkillContents []byte `json:"skill_contents"` TenantID pgtype.UUID `json:"tenant_id"` ID pgtype.UUID `json:"id"` } @@ -254,6 +271,7 @@ type UpdateEnvironmentTemplateRow struct { UpdatedAt pgtype.Timestamptz `json:"updated_at"` Files []byte `json:"files"` Packages []byte `json:"packages"` + Skills []byte `json:"skills"` } func (q *Queries) UpdateEnvironmentTemplate(ctx context.Context, arg UpdateEnvironmentTemplateParams) (UpdateEnvironmentTemplateRow, error) { @@ -271,6 +289,9 @@ func (q *Queries) UpdateEnvironmentTemplate(ctx context.Context, arg UpdateEnvir arg.EnvContents, arg.SetSetup, arg.SetupContents, + arg.SetSkills, + arg.Skills, + arg.SkillContents, arg.TenantID, arg.ID, ) @@ -284,6 +305,7 @@ func (q *Queries) UpdateEnvironmentTemplate(ctx context.Context, arg UpdateEnvir &i.UpdatedAt, &i.Files, &i.Packages, + &i.Skills, ) return i, err } diff --git a/services/agents-api/internal/db/sqlc/models.go b/services/agents-api/internal/db/sqlc/models.go index 0554edc6..3e4da467 100644 --- a/services/agents-api/internal/db/sqlc/models.go +++ b/services/agents-api/internal/db/sqlc/models.go @@ -92,6 +92,8 @@ type EnvironmentTemplate struct { Packages []byte `json:"packages"` EnvContents []byte `json:"env_contents"` SetupContents []byte `json:"setup_contents"` + Skills []byte `json:"skills"` + SkillContents []byte `json:"skill_contents"` } type ExecutionProjectScope struct { diff --git a/services/agents-api/internal/execution/environment_placement.go b/services/agents-api/internal/execution/environment_placement.go index a38891ae..aac27334 100644 --- a/services/agents-api/internal/execution/environment_placement.go +++ b/services/agents-api/internal/execution/environment_placement.go @@ -8,15 +8,17 @@ 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/agentskill" "github.com/MiniMax-AI-Dev/parsar/services/agents-api/internal/store" ) type environmentPlacement struct { - Type string `json:"type"` - ToolEnvironment bool `json:"initialization,omitempty"` - NetworkAccess string `json:"-"` - WorkspaceDirectory string `json:"workspace_directory"` - CapabilityDirectories []string `json:"capability_directories"` + Skills []agentskill.Metadata `json:"skills,omitempty"` + Type string `json:"type"` + ToolEnvironment bool `json:"initialization,omitempty"` + NetworkAccess string `json:"-"` + WorkspaceDirectory string `json:"workspace_directory"` + CapabilityDirectories []string `json:"capability_directories"` } // LocalWorkspaceConfiguration recognizes the qualified stored V1 profile. It @@ -39,6 +41,7 @@ func parseEnvironmentPlacement(configuration json.RawMessage) (environmentPlacem case "openai_hosted": // Qualified local execution currently supports enabled/disabled network only. 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"` @@ -81,7 +84,7 @@ func (d *Dispatcher) configurePreparedEnvironment(ctx context.Context, session s return nil, store.ErrInvalidInput } if placement.Type == "openai_hosted" { - req.LocalEnvironment = &proto.LocalEnvironment{ID: environment.ID, ToolEnvironment: placement.ToolEnvironment} + req.LocalEnvironment = &proto.LocalEnvironment{ID: environment.ID, ToolEnvironment: placement.ToolEnvironment, Skills: placement.Skills} // Keep the previously qualified explicit-disabled internal peer path intact. // New bound-policy peers validate the exact policy during preparation. boundPolicy := placement.NetworkAccess != "disabled" diff --git a/services/agents-api/internal/execution/runtime_setup.go b/services/agents-api/internal/execution/runtime_setup.go index 0515ac4a..a58eef95 100644 --- a/services/agents-api/internal/execution/runtime_setup.go +++ b/services/agents-api/internal/execution/runtime_setup.go @@ -5,6 +5,7 @@ import ( "encoding/json" "errors" + "github.com/MiniMax-AI-Dev/parsar/internal/agentskill" "github.com/MiniMax-AI-Dev/parsar/services/agents-api/internal/sandbox" "github.com/MiniMax-AI-Dev/parsar/services/agents-api/internal/store" ) @@ -12,13 +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 { - 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.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"` } func setupOperations(setup store.EnvironmentSetup) []runtimeSetupOperation { @@ -30,6 +34,9 @@ func setupOperations(setup store.EnvironmentSetup) []runtimeSetupOperation { env = map[string]string{} } result := []runtimeSetupOperation{{Version: 1, Action: "configure", Env: env}} + for i := range setup.Skills { + result = append(result, runtimeSetupOperation{Version: 1, Action: "skill", Skill: &setup.Skills[i]}) + } // The public network policy applies after setup completes. Provisioning uses // the isolated initializer's network; adapters enforce the runtime policy. const network = "enabled" @@ -53,6 +60,13 @@ func runRuntimeSetup(ctx context.Context, provider sandbox.Provider, reference s if provider == nil { return sandbox.ErrInvalid } + if operation.Skill != nil { + files, err := agentskill.Read(operation.Skill.Archive, operation.Skill.Metadata) + if err != nil { + return err + } + operation.Name, operation.Files = operation.Skill.Metadata.Name, files + } input, err := json.Marshal(operation) 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 4351e9a4..0ee670ff 100644 --- a/services/agents-api/internal/store/environment_setup.go +++ b/services/agents-api/internal/store/environment_setup.go @@ -22,6 +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"` } type SetupCommand struct { @@ -32,11 +33,16 @@ type SetupCommand struct { var environmentName = regexp.MustCompile(`^[A-Za-z_][A-Za-z0-9_]*$`) func (s EnvironmentSetup) Empty() bool { - return len(s.Env)+len(s.Commands)+len(s.Packages.NPM)+len(s.Packages.Python)+len(s.Packages.System) == 0 + return len(s.Env)+len(s.Commands)+len(s.Packages.NPM)+len(s.Packages.Python)+len(s.Packages.System)+len(s.Skills) == 0 } func (s EnvironmentSetup) Validate() error { - raw, err := json.Marshal(s) + if ValidateInlineSkills(s.Skills) != nil { + return ErrInvalidInput + } + ordinary := s + ordinary.Skills = nil + raw, err := json.Marshal(ordinary) if err != nil || len(raw) > 512*1024 || len(s.Packages.System) > 0 { return ErrInvalidInput } diff --git a/services/agents-api/internal/store/environment_skills.go b/services/agents-api/internal/store/environment_skills.go new file mode 100644 index 00000000..dd23ddf2 --- /dev/null +++ b/services/agents-api/internal/store/environment_skills.go @@ -0,0 +1,59 @@ +package store + +import ( + "encoding/json" + + "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"` +} + +const MaxSkillsArchiveBytes = 10 << 20 + +func ValidateInlineSkills(skills []InlineSkill) error { + if len(skills) > 50 { + return ErrInvalidInput + } + seen := map[string]bool{} + total := 0 + expanded := 0 + for _, skill := range skills { + 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) + if err != nil { + return ErrInvalidInput + } + for _, file := range files { + expanded += len(file.Data) + } + if expanded > 50<<20 { + return ErrInvalidInput + } + } + return nil +} + +func (s EnvironmentSetup) SkillMetadata() []agentskill.Metadata { + result := make([]agentskill.Metadata, 0, len(s.Skills)) + for _, skill := range s.Skills { + result = append(result, skill.Metadata) + } + return result +} + +func (s *Store) sealTemplateSkills(tenant, id string, setup EnvironmentSetup) ([]byte, []byte, error) { + metadata, err := json.Marshal(setup.SkillMetadata()) + if err != nil { + return nil, nil, err + } + contents, err := s.sealEnvironmentSetup(tenant, "environment_template", id, "skills", setup.Skills, len(setup.Skills) == 0) + return metadata, contents, err +} diff --git a/services/agents-api/internal/store/environment_skills_test.go b/services/agents-api/internal/store/environment_skills_test.go new file mode 100644 index 00000000..842f23cf --- /dev/null +++ b/services/agents-api/internal/store/environment_skills_test.go @@ -0,0 +1,83 @@ +package store + +import ( + "archive/zip" + "bytes" + "encoding/json" + "errors" + "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" +) + +func TestSkillsEncryptedTemplateAndFrozenSession(t *testing.T) { + _, pool := testStore(t) + cipher, err := credentialcrypto.New(bytes.Repeat([]byte{17}, 32)) + if err != nil { + t.Fatal(err) + } + s := NewWithCredentialCipher(pool, cipher) + var archive bytes.Buffer + writer := zip.NewWriter(&archive) + header := &zip.FileHeader{Name: "proof/SKILL.md", Method: zip.Store} + file, err := writer.CreateHeader(header) + if err != nil { + t.Fatal(err) + } + if _, err = file.Write([]byte("---\nname: proof\ndescription: A proof.\n---\nprivate-skill-canary")); err != nil { + t.Fatal(err) + } + 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()}}} + tenant, foreign := uuid.NewString(), uuid.NewString() + template, err := s.CreateEnvironmentTemplate(t.Context(), tenant, EnvironmentTemplateInput{SetSkills: true, Initialization: setup}) + if err != nil { + t.Fatal(err) + } + public, err := New(pool).GetEnvironmentTemplate(t.Context(), tenant, template.ID) + if err != nil || len(public.Skills) != 1 || !public.Initialization.Empty() { + t.Fatal("public metadata", err) + } + var metadata, encrypted []byte + if err = pool.QueryRow(t.Context(), "SELECT skills,skill_contents FROM environment_templates WHERE id=$1", template.ID).Scan(&metadata, &encrypted); err != nil || bytes.Contains(metadata, []byte("canary")) || bytes.Contains(encrypted, []byte("canary")) { + t.Fatal("plaintext storage", err) + } + resolved, _, err := s.ResolveEnvironmentTemplate(t.Context(), tenant, template.ID) + if err != nil || !reflect.DeepEqual(resolved.Initialization.Skills, setup.Skills) { + t.Fatal("resolution", err) + } + if _, _, err = s.ResolveEnvironmentTemplate(t.Context(), foreign, template.ID); !errors.Is(err, ErrNotFound) { + t.Fatal("tenant isolation", err) + } + session, err := s.CreateSession(t.Context(), tenant, CreateSessionInput{Creator: FixtureCreator(), Engine: "codex", IdempotencyKey: uuid.NewString(), Configuration: json.RawMessage(`{"environment":{"type":"openai_hosted"}}`), Initialization: resolved.Initialization}) + if err != nil { + t.Fatal(err) + } + name := "renamed" + if _, err = s.UpdateEnvironmentTemplate(t.Context(), tenant, template.ID, EnvironmentTemplateInput{SetName: true, Name: &name}); err != nil { + t.Fatal(err) + } + preserved, _, err := s.ResolveEnvironmentTemplate(t.Context(), tenant, template.ID) + if err != nil || !reflect.DeepEqual(preserved.Initialization.Skills, setup.Skills) { + t.Fatal("unrelated update", err) + } + cleared, err := s.UpdateEnvironmentTemplate(t.Context(), tenant, template.ID, EnvironmentTemplateInput{SetSkills: true}) + if err != nil || len(cleared.Skills) != 0 { + t.Fatal("clearing", err) + } + if _, err = s.DeleteEnvironmentTemplate(t.Context(), tenant, template.ID); err != nil { + t.Fatal(err) + } + frozen, err := s.ReadEnvironmentSetup(t.Context(), tenant, session.ID) + if err != nil || !reflect.DeepEqual(frozen.Skills, setup.Skills) { + t.Fatal("frozen content changed", err) + } + if _, err = s.ReadEnvironmentSetup(t.Context(), foreign, session.ID); !errors.Is(err, ErrNotFound) { + t.Fatal("foreign content", err) + } +} diff --git a/services/agents-api/internal/store/environment_templates.go b/services/agents-api/internal/store/environment_templates.go index fcb22e7f..52b17470 100644 --- a/services/agents-api/internal/store/environment_templates.go +++ b/services/agents-api/internal/store/environment_templates.go @@ -4,6 +4,7 @@ import ( "context" "encoding/json" "errors" + "github.com/MiniMax-AI-Dev/parsar/internal/agentskill" "time" "unicode/utf8" @@ -18,6 +19,7 @@ import ( // EnvironmentTemplate is configuration ownership, independent of provider images. type EnvironmentTemplate struct { + Skills []agentskill.Metadata Packages v1.EnvironmentPackages Initialization EnvironmentSetup Files []InitialFileMetadata @@ -29,14 +31,14 @@ type EnvironmentTemplate struct { } type EnvironmentTemplateInput struct { - Initialization EnvironmentSetup - SetEnv, SetSetup, SetPackages bool - Files []InitialFile - SetFiles bool - Name *string - SetName bool - NetworkAccess string - SetNetwork bool + Initialization EnvironmentSetup + SetEnv, SetSetup, SetPackages, SetSkills bool + Files []InitialFile + SetFiles bool + Name *string + SetName bool + NetworkAccess string + SetNetwork bool } func (in EnvironmentTemplateInput) valid() bool { @@ -57,7 +59,7 @@ func templateFromRow(row templateMetadataRow, err error) (EnvironmentTemplate, e if row.Name.Valid { result.Name = &row.Name.String } - if json.Unmarshal(row.Files, &result.Files) != nil || json.Unmarshal(row.Packages, &result.Packages) != nil { + if json.Unmarshal(row.Files, &result.Files) != nil || json.Unmarshal(row.Packages, &result.Packages) != nil || json.Unmarshal(row.Skills, &result.Skills) != nil { return EnvironmentTemplate{}, ErrInvalidInput } return result, nil @@ -88,7 +90,11 @@ func (s *Store) CreateEnvironmentTemplate(ctx context.Context, tenantID string, if err != nil { return EnvironmentTemplate{}, err } - row, err := s.queries.CreateEnvironmentTemplate(ctx, sqlc.CreateEnvironmentTemplateParams{ID: pgtype.UUID{Bytes: id, Valid: true}, TenantID: tenant, Name: name, NetworkAccess: in.NetworkAccess, Files: metadata, FileContents: encrypted, Packages: packages, EnvContents: envContents, SetupContents: setupContents}) + skills, skillContents, err := s.sealTemplateSkills(uuid.UUID(tenant.Bytes).String(), id.String(), in.Initialization) + if err != nil { + return EnvironmentTemplate{}, err + } + row, err := s.queries.CreateEnvironmentTemplate(ctx, sqlc.CreateEnvironmentTemplateParams{ID: pgtype.UUID{Bytes: id, Valid: true}, TenantID: tenant, Name: name, NetworkAccess: in.NetworkAccess, Files: metadata, FileContents: encrypted, Packages: packages, EnvContents: envContents, SetupContents: setupContents, Skills: skills, SkillContents: skillContents}) return templateFromRow(templateMetadataRow(row), err) } @@ -118,7 +124,7 @@ func (s *Store) UpdateEnvironmentTemplate(ctx context.Context, tenantID, templat if err != nil { return EnvironmentTemplate{}, ErrNotFound } - if !in.SetName && !in.SetNetwork && !in.SetFiles && !in.SetEnv && !in.SetSetup && !in.SetPackages { + if !in.SetName && !in.SetNetwork && !in.SetFiles && !in.SetEnv && !in.SetSetup && !in.SetPackages && !in.SetSkills { return s.GetEnvironmentTemplate(ctx, tenantID, templateID) } var name pgtype.Text @@ -133,7 +139,11 @@ func (s *Store) UpdateEnvironmentTemplate(ctx context.Context, tenantID, templat if err != nil { return EnvironmentTemplate{}, err } - row, err := s.queries.UpdateEnvironmentTemplate(ctx, sqlc.UpdateEnvironmentTemplateParams{TenantID: tenant, ID: id, Name: name, SetName: in.SetName, NetworkAccess: in.NetworkAccess, SetNetwork: in.SetNetwork, SetFiles: in.SetFiles, Files: metadata, FileContents: encrypted, Packages: packages, EnvContents: envContents, SetupContents: setupContents, SetPackages: in.SetPackages, SetEnv: in.SetEnv, SetSetup: in.SetSetup}) + skills, skillContents, err := s.sealTemplateSkills(uuid.UUID(tenant.Bytes).String(), uuid.UUID(id.Bytes).String(), in.Initialization) + if err != nil { + return EnvironmentTemplate{}, err + } + row, err := s.queries.UpdateEnvironmentTemplate(ctx, sqlc.UpdateEnvironmentTemplateParams{TenantID: tenant, ID: id, Name: name, SetName: in.SetName, NetworkAccess: in.NetworkAccess, SetNetwork: in.SetNetwork, SetFiles: in.SetFiles, Files: metadata, FileContents: encrypted, Packages: packages, EnvContents: envContents, SetupContents: setupContents, SetPackages: in.SetPackages, SetEnv: in.SetEnv, SetSetup: in.SetSetup, SetSkills: in.SetSkills, Skills: skills, SkillContents: skillContents}) return templateFromRow(templateMetadataRow(row), err) } diff --git a/services/agents-api/internal/store/initial_files.go b/services/agents-api/internal/store/initial_files.go index 961c8513..61841fdc 100644 --- a/services/agents-api/internal/store/initial_files.go +++ b/services/agents-api/internal/store/initial_files.go @@ -104,7 +104,7 @@ func (s *Store) ResolveEnvironmentTemplate(ctx context.Context, tenant, id strin return EnvironmentTemplate{}, nil, ErrNotFound } row, err := s.queries.ResolveEnvironmentTemplate(ctx, sqlc.ResolveEnvironmentTemplateParams{TenantID: lookup.TenantID, ID: lookup.ID}) - value, err := templateFromRow(templateMetadataRow{ID: row.ID, TenantID: row.TenantID, Name: row.Name, NetworkAccess: row.NetworkAccess, CreatedAt: row.CreatedAt, UpdatedAt: row.UpdatedAt, Files: row.Files, Packages: row.Packages}, err) + value, err := templateFromRow(templateMetadataRow{ID: row.ID, TenantID: row.TenantID, Name: row.Name, NetworkAccess: row.NetworkAccess, CreatedAt: row.CreatedAt, UpdatedAt: row.UpdatedAt, Files: row.Files, Packages: row.Packages, Skills: row.Skills}, err) if err != nil { return value, nil, err } @@ -116,6 +116,17 @@ func (s *Store) ResolveEnvironmentTemplate(ctx context.Context, tenant, id strin if err = s.openEnvironmentSetup(canonicalTenant, "environment_template", value.ID, "setup_commands", row.SetupContents, &value.Initialization.Commands); err != nil { return value, nil, err } + if err = s.openEnvironmentSetup(canonicalTenant, "environment_template", value.ID, "skills", row.SkillContents, &value.Initialization.Skills); err != nil { + return value, nil, err + } + if len(value.Skills) != len(value.Initialization.Skills) { + return value, nil, ErrInvalidInput + } + for i, metadata := range value.Skills { + if metadata != value.Initialization.Skills[i].Metadata { + return value, nil, ErrInvalidInput + } + } if err = value.Initialization.Validate(); err != nil { return value, nil, err } diff --git a/services/agents-api/migrations/000045_environment_skills.sql b/services/agents-api/migrations/000045_environment_skills.sql new file mode 100644 index 00000000..dae32c83 --- /dev/null +++ b/services/agents-api/migrations/000045_environment_skills.sql @@ -0,0 +1,7 @@ +-- +goose Up +ALTER TABLE environment_templates + ADD COLUMN skills jsonb NOT NULL DEFAULT '[]'::jsonb, + ADD COLUMN skill_contents bytea; + +-- +goose Down +ALTER TABLE environment_templates DROP COLUMN skill_contents, DROP COLUMN skills; diff --git a/services/agents-api/tests/official_environment_skills.py b/services/agents-api/tests/official_environment_skills.py new file mode 100644 index 00000000..aef19f62 --- /dev/null +++ b/services/agents-api/tests/official_environment_skills.py @@ -0,0 +1,66 @@ +"""Real native-model acceptance helpers for encrypted inline Skill initialization.""" +import base64 +import io +import json +import secrets +import zipfile + + +def inline_skill(): + marker = 'skill-private-' + secrets.token_hex(20) + manifest = """--- +name: proof-skill +description: Verify the initialized workspace and publish the Skill proof. +--- +Run `python3 /environment/initialization/capabilities/skills/proof-skill/scripts/check.py`. +Stop on any failed assertion. Report INITIAL_FILES_VERIFIED and SKILL_VERIFIED. +""" + script = f'''import os, runpy +from pathlib import Path +for name in ['ANTHROPIC_API_KEY', 'ANTHROPIC_AUTH_TOKEN', 'OPENAI_API_KEY', 'MINIMAX_API_KEY']: + assert name not in os.environ, 'native credential reached a Skill helper' +for path in ['/environment/staging/initial-files-private-canary', '/home/runtime/.parsar/parsar-daemon/default/auth.json']: + assert not os.access(path, os.R_OK), 'private Runtime state reached a Skill helper' +manifest = Path('/environment/initialization/capabilities/skills/proof-skill/SKILL.md') +try: + manifest.write_text('tampered') +except OSError: + pass +else: + raise AssertionError('Skill content was writable') +runpy.run_path('/workspace/verify.py') +Path('/workspace/outputs/skill-proof.txt').write_text({marker!r}) +print('SKILL_VERIFIED') +''' + data = io.BytesIO() + with zipfile.ZipFile(data, 'w', zipfile.ZIP_DEFLATED) as archive: + archive.writestr('proof-skill/SKILL.md', manifest) + archive.writestr('proof-skill/scripts/check.py', script) + return { + 'type': 'inline', 'name': 'proof-skill', + 'description': 'Verify the initialized workspace and publish the Skill proof.', + 'source': {'type': 'base64', 'media_type': 'application/zip', + 'data': base64.b64encode(data.getvalue()).decode()}, + }, marker.encode() + + +def attach_skills(client, foreign, http, environment, skill, template_id=None): + endpoint = str(client.base_url).rstrip('/') + if template_id: + response = client.beta.agents.environments.templates.with_raw_response.update(template_id, skills=[skill]) + metadata = {key: skill[key] for key in ['type', 'name', 'description']} + assert response.http_response.json()['skills'] == [metadata] + assert skill['source']['data'] not in json.dumps(response.http_response.json()) + rejected = http.get(endpoint + '/agents/environments/templates/' + template_id, + headers={'Authorization': 'Bearer ' + foreign.api_key, 'OpenAI-Beta': 'agents=v1'}) + assert rejected.status_code == 404 + return environment + return {**environment, 'skills': [skill]} + + +def verify_skills_metadata(client, session, skill): + expected = [{key: skill[key] for key in ['type', 'name', 'description']}] + environment = client.beta.agents.environments.retrieve(session.environment.id).to_dict() + assert session.environment.to_dict()['skills'] == expected + assert environment['skills'] == expected + assert skill['source']['data'] not in json.dumps([session.to_dict(), environment]) From 231322873c2cb5ca1a165069623771afa1a5ed8f Mon Sep 17 00:00:00 2001 From: saladday <1203511142@qq.com> Date: Sun, 20 Sep 2026 17:42:50 +0800 Subject: [PATCH 2/2] fix(agents-api): bound native Skill discovery and record acceptance --- .../internal/agent/codex/hosted_skills.go | 30 ++++++++++---- .../agent/codex/hosted_skills_test.go | 36 +++++++++++++++++ contracts/agents-api/environment-templates.md | 40 ++++++++++++++++++- 3 files changed, 97 insertions(+), 9 deletions(-) create mode 100644 apps/parsar-daemon/internal/agent/codex/hosted_skills_test.go diff --git a/apps/parsar-daemon/internal/agent/codex/hosted_skills.go b/apps/parsar-daemon/internal/agent/codex/hosted_skills.go index 13c36299..969b17a6 100644 --- a/apps/parsar-daemon/internal/agent/codex/hosted_skills.go +++ b/apps/parsar-daemon/internal/agent/codex/hosted_skills.go @@ -2,6 +2,7 @@ package codex import ( "fmt" + "io/fs" "os" "path/filepath" @@ -14,15 +15,30 @@ func verifyHostedSkills(skills []agentskill.Metadata) error { return err } for _, skill := range skills { - // Native dependency declarations may start MCP installation outside the - // workspace tool sandbox. They are not qualified by an inert Skill upload. - _, err := os.Lstat(filepath.Join(localworkspace.SkillDirectory, skill.Name, "agents", "openai.yaml")) - if err == nil { - return fmt.Errorf("codex: native Skill configuration is unsupported") - } - if !os.IsNotExist(err) { + if err := verifyHostedSkillLayout(filepath.Join(localworkspace.SkillDirectory, skill.Name)); err != nil { return err } } return nil } + +func verifyHostedSkillLayout(root string) error { + // Native dependency declarations may start MCP installation outside the + // workspace tool sandbox. They are not qualified by an inert Skill upload. + if _, err := os.Lstat(filepath.Join(root, "agents", "openai.yaml")); err == nil { + return fmt.Errorf("codex: native Skill configuration is unsupported") + } else if !os.IsNotExist(err) { + return err + } + // Extra roots are scanned recursively. One public Skill must not silently + // introduce additional native Skills with their own activation metadata. + return filepath.WalkDir(root, func(path string, entry fs.DirEntry, err error) error { + if err != nil { + return err + } + if entry.Name() == "SKILL.md" && path != filepath.Join(root, "SKILL.md") { + return fmt.Errorf("codex: nested native Skills are unsupported") + } + return nil + }) +} diff --git a/apps/parsar-daemon/internal/agent/codex/hosted_skills_test.go b/apps/parsar-daemon/internal/agent/codex/hosted_skills_test.go new file mode 100644 index 00000000..0c745568 --- /dev/null +++ b/apps/parsar-daemon/internal/agent/codex/hosted_skills_test.go @@ -0,0 +1,36 @@ +package codex + +import ( + "os" + "path/filepath" + "testing" +) + +func TestHostedSkillDoesNotActivateAdditionalNativeResources(t *testing.T) { + for _, tc := range []struct { + name string + files []string + rejected bool + }{ + {"ordinary supporting files", []string{"SKILL.md", "scripts/check.py", "references/guide.md"}, false}, + {"native dependency configuration", []string{"SKILL.md", "agents/openai.yaml"}, true}, + {"nested discovery", []string{"SKILL.md", "references/other/SKILL.md"}, true}, + {"nested dependencies", []string{"SKILL.md", "references/other/SKILL.md", "references/other/agents/openai.yaml"}, true}, + } { + t.Run(tc.name, func(t *testing.T) { + root := t.TempDir() + for _, name := range tc.files { + path := filepath.Join(root, name) + if err := os.MkdirAll(filepath.Dir(path), 0700); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(path, []byte("fixture"), 0400); err != nil { + t.Fatal(err) + } + } + if err := verifyHostedSkillLayout(root); (err != nil) != tc.rejected { + t.Fatalf("rejected=%v err=%v", tc.rejected, err) + } + }) + } +} diff --git a/contracts/agents-api/environment-templates.md b/contracts/agents-api/environment-templates.md index 7191a67f..d4ec6387 100644 --- a/contracts/agents-api/environment-templates.md +++ b/contracts/agents-api/environment-templates.md @@ -127,8 +127,8 @@ envelope, and MiniMax points its native user-global catalog at the shared root. MiniMax retains disabled unrestricted built-in tools and uses its existing isolated workspace tool worker. No Provider or model/tool loop is added. -Codex `agents/openai.yaml` native dependency configuration and Claude inline/fenced -shell preprocessing are not qualified in this batch and explicitly fail adapter +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 @@ -305,3 +305,39 @@ remain gaps, and native Codex hook failure retains the limitation stated above. Private sanitized run/check/build evidence is retained under `~/.parsar/remediation/20260920/environment-template-setup/` and the linked board. These results do not establish complete Template or Agents API compatibility. + + +### Accepted inline-Skill profiles (2026-09-20) + +`official_environment_skills.py` supplies fixed-client/raw-HTTP checks and a +native-discovered Skill whose helper produces an unpredictable Artifact, checks +private credentials/staging, and attempts to modify its own installed manifest. +Standalone Core, dedicated PostgreSQL and freshly packaged Docker Runtimes passed +with Codex 0.153.4/Kimi, Claude SDK 0.3.269 (native 2.1.269)/Kimi, and MiniMax Code +0.4.12/MiniMax-M3. Codex covered template and inline configuration; Claude and +MiniMax covered the template path through the same initializer. All verified safe +metadata, foreign-tenant rejection, frozen snapshots after template clear/delete, +creation retry, native Skill execution, Files/Artifacts, cancellation, and owned +history/workspace recovery after Core and Runtime restart. Cleanup completed. +The Core binary SHA-256 was +`85be1bc26ca6c03617ba74bf092485656dda9311bb636d507be6576a2f087f16`. + +The shared initializer also passed on a real Docker container and E2B VM, including +binary/executable content, read-only Skill access from setup, duplicate/path +rejection and private-state isolation. This batch did not repeat the E2B model +matrix: Provider code is unchanged, while its shared initialization boundary was +exercised in a real VM. PostgreSQL/API/archive tests, Claude SDK tests/build, +`make openapi`, `make sqlc-generate` and `make check` passed. The default gate's +optional native build probe remains skipped and is not counted as live acceptance. + +Initial integration failed safely because the proposed Skill parent was root-owned; +using the existing Runtime initialization directory resolved that packaging +boundary without broadening permissions. The earlier MiniMax/Kimi native timeout +and probe-only unrestricted-tool configuration failure remain recorded. The latter +passed after restoring the unchanged production tool settings. Docker builds reused +qualified base images after registry DNS failure; current daemon, adapter and +initializer artifacts were copied using the repository packaging recipe. Raw +receipts retain their inherited historical manifest fields; accompanying source, +Core and image hashes identify the actual candidates. Evidence is retained under +`~/.parsar/remediation/20260920/environment-template-skills` and the linked board +record. This profile does not establish complete upstream Skill semantics.