From bb1b1e3df948f82c1daaeae392d08ed8b0834d7b Mon Sep 17 00:00:00 2001 From: saladday <1203511142@qq.com> Date: Mon, 21 Sep 2026 19:34:30 +0800 Subject: [PATCH 1/2] Add hosted skill-only Plugins and capability directories --- CONTRIBUTING.md | 34 +++- .../internal/agent/claudesdk/workspace.go | 27 ++- .../environment_remote_validation_test.go | 2 +- .../internal/agent/codex/hosted_skills.go | 9 +- .../internal/agent/codex/preparation.go | 6 +- .../internal/agent/codex/session_plan.go | 36 ++-- .../internal/agent/mcode/workspace.go | 27 +-- apps/parsar-daemon/internal/cli/root.go | 1 + apps/parsar-daemon/internal/cli/root_test.go | 15 +- .../internal/cli/runtime_capabilities.go | 56 ++++++ .../internal/localworkspace/binding.go | 10 + .../localworkspace/capabilities_test.go | 28 +++ .../internal/localworkspace/skills.go | 66 ++----- contracts/agents-api/README.md | 2 +- contracts/agents-api/environment-templates.md | 64 ++++++- contracts/agents-api/openapi.yaml | 30 +-- contracts/agents-api/v1/sessions.go | 1 + internal/agentbundle/archive.go | 90 +++++++++ internal/agentcapabilities/install.go | 165 ++++++++++++++++ internal/agentcapabilities/install_test.go | 149 ++++++++++++++ internal/agentcapabilities/manifest.go | 85 ++++++++ internal/agentcapabilities/operation.go | 13 ++ internal/agentcapabilities/tree.go | 137 +++++++++++++ internal/agentdaemon/proto/environment.go | 8 +- internal/agentplugin/bundle.go | 181 ++++++++++++++++++ internal/agentplugin/bundle_test.go | 91 +++++++++ internal/agentskill/bundle.go | 94 +++------ packages/claude-sdk-adapter/src/workspace.ts | 4 +- .../src/workspace_skills.ts | 89 ++++++--- .../tests/workspace_skills.test.mjs | 17 ++ scripts/build-agents-api.sh | 2 +- .../internal/api/capability_archive.go | 26 +++ .../internal/api/environment_plugins.go | 67 +++++++ .../internal/api/environment_plugins_test.go | 124 ++++++++++++ .../internal/api/environment_setup.go | 16 ++ .../internal/api/environment_skills.go | 14 +- .../internal/api/environment_templates.go | 10 +- .../api/environment_templates_test.go | 14 +- .../agents-api/internal/api/environments.go | 9 +- .../internal/api/hosted_environment.go | 20 +- .../internal/api/hosted_environment_test.go | 2 +- .../internal/api/session_template.go | 18 +- .../credentialcrypto/environment_setup.go | 2 +- .../internal/db/queries/environment_setup.sql | 8 +- .../db/queries/environment_templates.sql | 13 +- .../internal/db/sqlc/environment_setup.sql.go | 24 ++- .../db/sqlc/environment_templates.sql.go | 52 ++++- .../agents-api/internal/db/sqlc/models.go | 3 + .../execution/environment_placement.go | 10 +- .../execution/environment_placement_test.go | 2 +- .../internal/execution/runtime_setup.go | 43 +++-- .../internal/store/environment_plugins.go | 57 ++++++ .../store/environment_plugins_test.go | 118 ++++++++++++ .../internal/store/environment_setup.go | 16 +- .../internal/store/environment_templates.go | 59 +++--- .../internal/store/initial_files.go | 14 +- .../internal/store/session_initial_input.go | 10 +- .../migrations/000049_environment_plugins.sql | 11 ++ .../tests/official_environment_plugins.py | 108 +++++++++++ .../tests/official_environment_templates.py | 2 +- 60 files changed, 2078 insertions(+), 333 deletions(-) create mode 100644 apps/parsar-daemon/internal/cli/runtime_capabilities.go create mode 100644 apps/parsar-daemon/internal/localworkspace/capabilities_test.go create mode 100644 internal/agentbundle/archive.go create mode 100644 internal/agentcapabilities/install.go create mode 100644 internal/agentcapabilities/install_test.go create mode 100644 internal/agentcapabilities/manifest.go create mode 100644 internal/agentcapabilities/operation.go create mode 100644 internal/agentcapabilities/tree.go create mode 100644 internal/agentplugin/bundle.go create mode 100644 internal/agentplugin/bundle_test.go create mode 100644 packages/claude-sdk-adapter/tests/workspace_skills.test.mjs create mode 100644 services/agents-api/internal/api/capability_archive.go create mode 100644 services/agents-api/internal/api/environment_plugins.go create mode 100644 services/agents-api/internal/api/environment_plugins_test.go create mode 100644 services/agents-api/internal/store/environment_plugins.go create mode 100644 services/agents-api/internal/store/environment_plugins_test.go create mode 100644 services/agents-api/migrations/000049_environment_plugins.sql create mode 100644 services/agents-api/tests/official_environment_plugins.py diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 1627380a1..8e5674371 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -250,7 +250,7 @@ Completed environments never reinstall initial files on reconnect or native reco Provider RunCommand carries bounded stdin, not confidential argv. Only fixed trusted initializers may run with Runtime authority. User setup and package install hooks run in the common packaged sandbox, without daemon credentials or native history. -Files and resolved Skills precede system, npm/Python packages and ordered setup commands. Initialization has +Files, resolved Skills and inline Plugins precede system, npm/Python packages and ordered setup commands. Initialization has provisioning network access; requested network restrictions apply to native tools after setup. Confidential env and setup snapshots are encrypted independently of ordinary metadata. Adapters apply tool env only after isolation, never to the @@ -297,15 +297,29 @@ Inline and referenced Skill ZIPs use the same confidential initialization snapsh 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. Generic Plugins and capability-directory -imports remain separate work; an adapter-owned Claude plugin envelope does not -implement public Plugins. - -Name, enabled/disabled/exact-domain restricted network, initial files, inline/referenced Skills and env/setup/system/npm/Python are +not modify this tree. Skill-only public Plugin ZIPs preserve their complete package +layout and reuse the shared archive and portable Skill parsers. Core keeps safe +Plugin metadata separate from encrypted archives. Templates inherit or replace +Plugin and capability-directory lists through the same hosted resolver. + +After ordered setup, the existing initializer snapshots declared workspace-contained +capability directories into protected storage and writes one installed manifest. +This is an initialization artifact, not a second lifecycle owner or database ledger. +Directory bytes are observed after setup; they are not frozen at Session creation. +The common daemon resolves the manifest only for executable preparation and passes +validated Runtime-owned Skill/package roots to adapters. Files reads do not require +that artifact. Reconnection and recovery read installed bytes, never mutable source +directories. Missing or inconsistent installations fail preparation without replay. + +Adapters register only selected Skill roots without changing the execution loop. +Codex uses explicit extra roots; MiniMax projects its native catalog; Claude creates +one controlled envelope per package with real directories and immutable hardlinks +under content. Its explicit paths remain inside that envelope; original native +control files are not activated. Keep native MCP discovery disabled. Unsupported +native activation fails explicitly. Public Plugin MCP remains separate qualification, +not silent partial activation or a generic plugin framework. + +Name, enabled/disabled/exact-domain restricted network, initial files, inline/referenced Skills, skill-only Plugins, workspace capability directories and env/setup/system/npm/Python are implemented independently of remaining installation fields. Reject unsupported inputs rather than persisting them for silent omission; expand inline and template initialization together in separately qualified diff --git a/apps/parsar-daemon/internal/agent/claudesdk/workspace.go b/apps/parsar-daemon/internal/agent/claudesdk/workspace.go index 1a0a45d16..beb433f0b 100644 --- a/apps/parsar-daemon/internal/agent/claudesdk/workspace.go +++ b/apps/parsar-daemon/internal/agent/claudesdk/workspace.go @@ -8,9 +8,9 @@ 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/agentcapabilities" "github.com/MiniMax-AI-Dev/parsar/internal/agentdaemon/proto" "github.com/MiniMax-AI-Dev/parsar/internal/agentnetwork" - "github.com/MiniMax-AI-Dev/parsar/internal/agentskill" ) // WorkspaceConfig binds one trusted private placement. It does not create an @@ -30,17 +30,17 @@ type WorkspaceConfig struct { } type workspaceProfile struct { - Skills []agentskill.Metadata `json:"skills,omitempty"` - ToolEnvironment bool `json:"tool_environment,omitempty"` - SystemPackages bool `json:"system_packages,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"` - AllowedDomains []string `json:"allowed_domains,omitempty"` + Skills []agentcapabilities.InstalledSkill `json:"skills,omitempty"` + ToolEnvironment bool `json:"tool_environment,omitempty"` + SystemPackages bool `json:"system_packages,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"` + AllowedDomains []string `json:"allowed_domains,omitempty"` } func prepareWorkspace(config Config, req proto.PromptRequestPayload) (*workspaceProfile, []string, error) { @@ -65,9 +65,6 @@ func prepareWorkspace(config Config, req proto.PromptRequestPayload) (*workspace profile.SystemPackages = req.LocalEnvironment.SystemPackages } 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/environment_remote_validation_test.go b/apps/parsar-daemon/internal/agent/codex/environment_remote_validation_test.go index ecd52345e..0c85f11a0 100644 --- a/apps/parsar-daemon/internal/agent/codex/environment_remote_validation_test.go +++ b/apps/parsar-daemon/internal/agent/codex/environment_remote_validation_test.go @@ -84,7 +84,7 @@ func TestRemoteEnvironmentKeepsPathsAndCredentialsSeparate(t *testing.T) { t.Fatal(err) } defer plan.Cleanup() - if plan.Cwd != r.WorkDir || skillRoot != "" || len(plan.Environments) != 1 || plan.Environments[0].Cwd != r.RemoteEnvironment.WorkspaceDirectory || plan.Environments[0].EnvironmentID != "remote" { + if plan.Cwd != r.WorkDir || len(skillRoot) != 0 || len(plan.Environments) != 1 || plan.Environments[0].Cwd != r.RemoteEnvironment.WorkspaceDirectory || plan.Environments[0].EnvironmentID != "remote" { t.Fatal("remote execution changed harness cwd or installed local skills") } if _, err := os.Stat(r.RemoteEnvironment.WorkspaceDirectory); !os.IsNotExist(err) { diff --git a/apps/parsar-daemon/internal/agent/codex/hosted_skills.go b/apps/parsar-daemon/internal/agent/codex/hosted_skills.go index 969b17a69..9ad5ee8e0 100644 --- a/apps/parsar-daemon/internal/agent/codex/hosted_skills.go +++ b/apps/parsar-daemon/internal/agent/codex/hosted_skills.go @@ -7,15 +7,12 @@ import ( "path/filepath" "github.com/MiniMax-AI-Dev/parsar/apps/parsar-daemon/internal/localworkspace" - "github.com/MiniMax-AI-Dev/parsar/internal/agentskill" + "github.com/MiniMax-AI-Dev/parsar/internal/agentcapabilities" ) -func verifyHostedSkills(skills []agentskill.Metadata) error { - if err := localworkspace.VerifySkills(skills); err != nil { - return err - } +func verifyHostedSkills(skills []agentcapabilities.InstalledSkill) error { for _, skill := range skills { - if err := verifyHostedSkillLayout(filepath.Join(localworkspace.SkillDirectory, skill.Name)); err != nil { + if err := verifyHostedSkillLayout(localworkspace.SkillPath(skill)); err != nil { return err } } diff --git a/apps/parsar-daemon/internal/agent/codex/preparation.go b/apps/parsar-daemon/internal/agent/codex/preparation.go index 25bacc779..081719c30 100644 --- a/apps/parsar-daemon/internal/agent/codex/preparation.go +++ b/apps/parsar-daemon/internal/agent/codex/preparation.go @@ -66,7 +66,7 @@ func newPreparation(parent context.Context, req proto.PromptRequestPayload, cfg if !req.WorkspaceReadOnly { req.AgentOptions = executionOptions(req) } - plan, skillRoot, err := prepareSessionPlan(parent, req, cfg) + plan, skillRoots, err := prepareSessionPlan(parent, req, cfg) if err != nil { return nil, err } @@ -169,8 +169,8 @@ func newPreparation(parent context.Context, req proto.PromptRequestPayload, cfg return nil, err } } - if skillRoot != "" { - if err := setSkillExtraRoots(cancelCtx, rpc, []string{skillRoot}); err != nil { + if len(skillRoots) > 0 { + if err := setSkillExtraRoots(cancelCtx, rpc, skillRoots); err != nil { cancelFn() _ = rpc.Close() plan.Cleanup() diff --git a/apps/parsar-daemon/internal/agent/codex/session_plan.go b/apps/parsar-daemon/internal/agent/codex/session_plan.go index cf0895820..122d3eb8a 100644 --- a/apps/parsar-daemon/internal/agent/codex/session_plan.go +++ b/apps/parsar-daemon/internal/agent/codex/session_plan.go @@ -8,22 +8,22 @@ import ( "github.com/MiniMax-AI-Dev/parsar/internal/agentdaemon/proto" ) -func prepareSessionPlan(ctx context.Context, req proto.PromptRequestPayload, cfg sessionConfig) (SessionPlan, string, error) { +func prepareSessionPlan(ctx context.Context, req proto.PromptRequestPayload, cfg sessionConfig) (SessionPlan, []string, error) { profile, err := managedPermissionProfile(req, cfg) if err != nil { - return SessionPlan{}, "", err + return SessionPlan{}, nil, err } if req.WorkspaceReadOnly { plan, err := workspaceReadPlan(req) - return plan, "", err + return plan, nil, err } mcpServers, err := publicMCPHTTPServers(req) if err != nil { - return SessionPlan{}, "", err + return SessionPlan{}, nil, err } plan, err := BuildSessionPlan(req.RunID, req.AgentStateKey, req.WorkDir, req.AgentOptions) if err != nil { - return SessionPlan{}, "", fmt.Errorf("codex: build session plan: %w", err) + return SessionPlan{}, nil, fmt.Errorf("codex: build session plan: %w", err) } if profile != "" { plan.Sandbox = "" @@ -37,13 +37,13 @@ func prepareSessionPlan(ctx context.Context, req proto.PromptRequestPayload, cfg if req.LocalEnvironment != nil && req.LocalEnvironment.ToolEnvironment { if err := localworkspace.VerifyToolEnvironment(req.LocalEnvironment.SystemPackages); err != nil { plan.Cleanup() - return SessionPlan{}, "", err + return SessionPlan{}, nil, err } plan.Env = append(plan.Env, "PARSAR_RUNTIME_TOOL_ENV=1") if req.LocalEnvironment.SystemPackages { if err := prepareSystemToolAnchor(); err != nil { plan.Cleanup() - return SessionPlan{}, "", err + return SessionPlan{}, nil, err } plan.Env = append(plan.Env, "PARSAR_RUNTIME_SYSTEM_PACKAGES=1") } @@ -57,32 +57,38 @@ func prepareSessionPlan(ctx context.Context, req proto.PromptRequestPayload, cfg if mcpServers != nil { if err := configureMCPHTTP(&plan, mcpServers); err != nil { plan.Cleanup() - return SessionPlan{}, "", err + return SessionPlan{}, nil, err } } if stringOpt(req.AgentOptions, "model_verbosity") != "" { if err := prepareModelVerbosity(ctx, cfg.codexBinary, &plan); err != nil { plan.Cleanup() - return SessionPlan{}, "", err + return SessionPlan{}, nil, err } } if req.DisableExecutionEnvironment { plan.Env = append(plan.Env, "CODEX_EXEC_SERVER_URL=none") } - skillRoot := "" + var skillRoots []string if req.LocalEnvironment != nil && len(req.LocalEnvironment.Skills) > 0 { err = verifyHostedSkills(req.LocalEnvironment.Skills) if err == nil { - skillRoot = localworkspace.SkillDirectory + for _, skill := range req.LocalEnvironment.Skills { + skillRoots = append(skillRoots, localworkspace.SkillPath(skill)) + } } } else if !req.DisableExecutionEnvironment && req.RemoteEnvironment == nil { - skillRoot, err = prepareManagedSkills(ctx, cfg.logger, req) + var root string + root, err = prepareManagedSkills(ctx, cfg.logger, req) + if root != "" { + skillRoots = []string{root} + } } if err != nil { plan.Cleanup() - return SessionPlan{}, "", err + return SessionPlan{}, nil, err } if req.RemoteEnvironment != nil { @@ -92,8 +98,8 @@ func prepareSessionPlan(ctx context.Context, req proto.PromptRequestPayload, cfg if cfg.runtimeNetwork.Access == "restricted" { if err := prepareManagedNetwork(&plan, cfg.runtimeNetwork); err != nil { plan.Cleanup() - return SessionPlan{}, "", err + return SessionPlan{}, nil, err } } - return plan, skillRoot, nil + return plan, skillRoots, nil } diff --git a/apps/parsar-daemon/internal/agent/mcode/workspace.go b/apps/parsar-daemon/internal/agent/mcode/workspace.go index 3dedae717..75ab13151 100644 --- a/apps/parsar-daemon/internal/agent/mcode/workspace.go +++ b/apps/parsar-daemon/internal/agent/mcode/workspace.go @@ -62,21 +62,26 @@ func prepareWorkspaceOptions(ctx context.Context, c WorkspaceConfig, req proto.P 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 { + root := filepath.Join(opts.DataDir, "skills") + if err := os.MkdirAll(root, 0700); err != nil { return opts, err } + for _, skill := range req.LocalEnvironment.Skills { + link, target := filepath.Join(root, skill.Metadata.Name), localworkspace.SkillPath(skill) + actual, err := os.Readlink(link) + if err == nil { + if actual != target { + return opts, fmt.Errorf("mcode: unexpected native Skill root") + } + } else if !os.IsNotExist(err) { + return opts, err + } else if err := os.Symlink(target, link); err != nil { + return opts, err + } + } } + raw, err := os.ReadFile(filepath.Join(opts.DataDir, "config.yaml")) if err != nil { return opts, err diff --git a/apps/parsar-daemon/internal/cli/root.go b/apps/parsar-daemon/internal/cli/root.go index b6df78316..128b6720d 100644 --- a/apps/parsar-daemon/internal/cli/root.go +++ b/apps/parsar-daemon/internal/cli/root.go @@ -33,6 +33,7 @@ func defaultRunContext() *runContext { // commands lists subcommands in --help render order: the user's // likely flow connect → status → stop / logs → logout. var commands = []command{ + {name: "runtime-capabilities", summary: "Install frozen capabilities in the packaged Runtime", run: runRuntimeCapabilities}, {name: "placement", summary: "Enroll or retire an explicitly managed local execution placement", run: runPlacement}, {name: "connect", summary: "Pair, open the reverse WebSocket, and start serving prompts", run: runConnect}, {name: "status", summary: "Print the paired profile and daemon state", run: runStatus}, diff --git a/apps/parsar-daemon/internal/cli/root_test.go b/apps/parsar-daemon/internal/cli/root_test.go index 42ba1caea..250fb6680 100644 --- a/apps/parsar-daemon/internal/cli/root_test.go +++ b/apps/parsar-daemon/internal/cli/root_test.go @@ -60,13 +60,14 @@ func TestSubcommandsAreRegistered(t *testing.T) { // Guards against dropping a subcommand off the commands slice — // the public CLI surface is the shipped contract. want := map[string]bool{ - "placement": false, - "connect": false, - "status": false, - "stop": false, - "logs": false, - "logout": false, - "version": false, + "runtime-capabilities": false, + "placement": false, + "connect": false, + "status": false, + "stop": false, + "logs": false, + "logout": false, + "version": false, } for _, c := range commands { if _, ok := want[c.name]; !ok { diff --git a/apps/parsar-daemon/internal/cli/runtime_capabilities.go b/apps/parsar-daemon/internal/cli/runtime_capabilities.go new file mode 100644 index 000000000..ac17058ba --- /dev/null +++ b/apps/parsar-daemon/internal/cli/runtime_capabilities.go @@ -0,0 +1,56 @@ +package cli + +import ( + "encoding/json" + "fmt" + "io" + "os" + + "github.com/MiniMax-AI-Dev/parsar/internal/agentcapabilities" +) + +// This packaged helper is invoked by the existing Core initializer. It has no +// daemon connection, credentials, scheduling or retry behavior. +func runRuntimeCapabilities(ctx *runContext, args []string) error { + if len(args) != 0 { + return agentcapabilities.ErrInvalid + } + var request agentcapabilities.Operation + decoder := json.NewDecoder(io.LimitReader(os.Stdin, 32<<20)) + decoder.DisallowUnknownFields() + if decoder.Decode(&request) != nil || request.Version != 1 { + return agentcapabilities.ErrInvalid + } + var extra any + if decoder.Decode(&extra) != io.EOF { + return agentcapabilities.ErrInvalid + } + root, err := os.OpenRoot("/environment") + if err != nil { + return agentcapabilities.ErrInvalid + } + defer root.Close() + installed, err := root.OpenRoot("initialization/capabilities") + if err != nil { + return agentcapabilities.ErrInvalid + } + defer installed.Close() + switch request.Action { + case "plugin": + err = agentcapabilities.InstallPlugin(installed, request.Slot, request.Archive, request.Plugin) + case "finalize": + workspace, openErr := root.OpenRoot("workspace") + if openErr != nil { + return agentcapabilities.ErrInvalid + } + defer workspace.Close() + err = agentcapabilities.Finalize(workspace, installed, request.Sources) + default: + err = agentcapabilities.ErrInvalid + } + if err != nil { + return agentcapabilities.ErrInvalid + } + _, err = fmt.Fprintln(ctx.stdout, `{"version":1,"outcome":"completed"}`) + return err +} diff --git a/apps/parsar-daemon/internal/localworkspace/binding.go b/apps/parsar-daemon/internal/localworkspace/binding.go index a554a21e2..0fdd46bae 100644 --- a/apps/parsar-daemon/internal/localworkspace/binding.go +++ b/apps/parsar-daemon/internal/localworkspace/binding.go @@ -108,6 +108,16 @@ func (b *Binding) Configure(r proto.PromptRequestPayload) (proto.PromptRequestPa return r, err } } + local := *r.LocalEnvironment + local.Skills = nil + if local.Capabilities { + var err error + local.Skills, err = LoadSkills() + if err != nil { + return r, err + } + } + r.LocalEnvironment = &local r.WorkDir = b.workspace } return r, nil diff --git a/apps/parsar-daemon/internal/localworkspace/capabilities_test.go b/apps/parsar-daemon/internal/localworkspace/capabilities_test.go new file mode 100644 index 000000000..6457339e7 --- /dev/null +++ b/apps/parsar-daemon/internal/localworkspace/capabilities_test.go @@ -0,0 +1,28 @@ +package localworkspace + +import ( + "bytes" + "encoding/json" + "testing" + + "github.com/MiniMax-AI-Dev/parsar/internal/agentcapabilities" +) + +func TestCapabilityPathsStayRuntimeOwnedAndDoNotGateReads(t *testing.T) { + binding, request := testBinding(t) + request.LocalEnvironment.Skills = []agentcapabilities.InstalledSkill{{RelativeRoot: "caller/private", PackageRoot: "caller"}} + raw, err := json.Marshal(request.LocalEnvironment) + if err != nil || bytes.Contains(raw, []byte("caller")) || bytes.Contains(raw, []byte("skills")) { + t.Fatal("Runtime paths crossed the public daemon descriptor", err) + } + configured, err := binding.Configure(request) + if err != nil || len(configured.LocalEnvironment.Skills) != 0 { + t.Fatal("execution accepted caller-supplied Skill paths", err) + } + request.LocalEnvironment.Capabilities = true + request.LocalEnvironment.Skills = nil + request.WorkspaceReadOnly = true + if _, err := binding.Configure(request); err != nil { + t.Fatal("read-only binding required a capability installation", err) + } +} diff --git a/apps/parsar-daemon/internal/localworkspace/skills.go b/apps/parsar-daemon/internal/localworkspace/skills.go index d9aa9a7ec..bc3c89eb7 100644 --- a/apps/parsar-daemon/internal/localworkspace/skills.go +++ b/apps/parsar-daemon/internal/localworkspace/skills.go @@ -1,60 +1,32 @@ package localworkspace import ( - "errors" - "io/fs" "os" "path/filepath" - "github.com/MiniMax-AI-Dev/parsar/internal/agentskill" + "github.com/MiniMax-AI-Dev/parsar/internal/agentcapabilities" ) -const CapabilityDirectory = "/environment/initialization/capabilities" -const SkillDirectory = CapabilityDirectory + "/skills" +const CapabilityDirectory = agentcapabilities.Directory -// VerifySkills consumes the common initialized layout, independently of native loading. -func VerifySkills(skills []agentskill.Metadata) error { - if len(skills) == 0 { - return nil +// LoadSkills consumes only the packaged installation after binding authorization. +func LoadSkills() ([]agentcapabilities.InstalledSkill, error) { + actual, err := filepath.EvalSymlinks(CapabilityDirectory) + if err != nil || actual != CapabilityDirectory { + return nil, agentcapabilities.ErrInvalid } - for _, directory := range []string{CapabilityDirectory, SkillDirectory} { - actual, err := filepath.EvalSymlinks(directory) - if err != nil || actual != directory { - return errors.New("initialized Skill directory unavailable") - } + root, err := os.OpenRoot(CapabilityDirectory) + if err != nil { + return nil, agentcapabilities.ErrInvalid } - 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 - } + defer root.Close() + manifest, err := agentcapabilities.Load(root) + if err != nil { + return nil, err } - return nil + return append([]agentcapabilities.InstalledSkill{}, manifest.Skills...), nil +} + +func SkillPath(skill agentcapabilities.InstalledSkill) string { + return filepath.Join(CapabilityDirectory, skill.RelativeRoot) } diff --git a/contracts/agents-api/README.md b/contracts/agents-api/README.md index 659448aa7..5d2f910a2 100644 --- a/contracts/agents-api/README.md +++ b/contracts/agents-api/README.md @@ -158,7 +158,7 @@ user-managed enrollment remain outside this qualification. | Area | Missing or unverified scope | | --- | --- | | Subagents / multi_agent | Six public child read operations, enabled execution, child lifecycle/interactions and full recovery; deferred outside the MVP | -| Environment Templates | Plugins, capability directories, unsupported restricted hostname forms, installation overrides/null network and exact hosted errors; CRUD/list, files, env/setup/system/npm/Python, inline/referenced Skills and Session references are supported | +| Environment Templates | Skill-only Plugins and workspace capability directories are in current-batch acceptance; Plugin MCP, unsupported restricted hostname forms, unqualified installation overrides/null network and exact hosted errors remain gaps. CRUD/list, files, env/setup/system/npm/Python, inline/referenced Skills and Session references have accepted coverage | | 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 6c09a7884..22631027f 100644 --- a/contracts/agents-api/environment-templates.md +++ b/contracts/agents-api/environment-templates.md @@ -160,7 +160,7 @@ content after template update/deletion. 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. +never reinstalls it. The public descriptor requests capability installation; the common daemon resolves protected metadata and paths for native preparation. 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 @@ -169,10 +169,70 @@ isolated workspace tool worker. No Provider or model/tool loop is added. Codex nested `SKILL.md` discovery, `agents/openai.yaml` native dependency configuration and Claude inline/fenced shell preprocessing are not qualified in this batch and explicitly fail adapter preparation. Other files are not interpreted as a public plugin installation. -Generic Plugins and capability-directory imports remain separate gaps. Native built-in Skill visibility +The skill-only Plugin and capability-directory batch below extends installation; Plugin MCP remains unqualified. 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. +## Skill-only Plugins and capability directories + +A hosted +`plugins` entry uses the pinned inline ZIP source with type/name/description and +`.codex-plugin/plugin.json` inside its single archive root. The manifest declares +Skill directories with `skills`; complete package-relative resources are retained. +The same parser, template resolution and encrypted Session snapshot serve inline +and template requests. Only type/name/description appears in public Plugin metadata. +Template Plugin lists inherit on omission and replace when supplied. Template +updates accept null/empty clearing; explicit null Session overrides remain an +unconfirmed semantic and reject. + +`capability_directories` currently accepts clean absolute paths within `/workspace`. +Initial files and setup can populate them. The shared initializer snapshots these +directories after setup, then publishes one protected installed manifest. Recovery +uses those installed bytes even if the source directory changes or is removed. +This timing and workspace restriction are local implementation choices, not claims +about unspecified upstream behavior. A supplied Session directory list replaces +the template list; omitted lists inherit. Missing, overlapping duplicate Skill +names, unsupported manifests and nonregular files reject initialization without +publishing completion. Directory-discovered Skills do not become fabricated +inline entries in public `skills` or `plugins` metadata. + +The common daemon resolves the installed manifest before executable preparation; +read-only Files operations retain their existing minimal requirements. Native +adapters consume Runtime-owned Skill and package roots. Codex uses explicit roots, +MiniMax uses its native registry and Claude uses controlled envelopes with explicit +roots into a hardlinked content tree. Native component configuration is never +passed wholesale to a harness. Provider APIs and the native execution loops are +unchanged. No new installation/recovery lifecycle or framework is introduced. + +This batch supports skill-only packages. Populated MCP configuration and other +unqualified activation reject explicitly. An empty MCP map is inert; accepting it +does not qualify MCP. Archives retain the shared 5 MiB compressed/20 MiB expanded/ +1,000-entry limits. Plugin lists are limited to 50 entries and 10 MiB compressed; +combined installed capabilities are limited to 50 Skills and 50 MiB. Limits are +implementation bounds. Native shell preprocessing, dependency activation and +nested discovery retain the existing adapter restrictions. + +Real standalone Docker acceptance on 2026-09-21 passed with the fixed official +SDK and raw HTTP against current Core/daemon/adapters on qualified native images: +Codex/Kimi (258.08s), Claude/Kimi (187.81s) and MiniMax Code/MiniMax (250.23s). +Each exercised two Plugin Skills with package-relative resources and one directory +Skill generated during setup, public Files/Artifacts and tenant rejection, retained +native history after Core/Runtime restart and source/template deletion, no repeated +initialization, cancellation with stopped effects and stable duplicate cancellation. +Operators verified nonempty private canaries and actual daemon credentials before +native scripts proved the contents unreadable; canary hashes survived cold recovery. +All owned execution resources were cleaned. The complete Core-only `make check` +passed, including dedicated real PostgreSQL checks and native packaging. + +Evidence is under `~/.parsar/remediation/20260921/template-plugins/`: +`acceptance-summary.json`, `native-final-{codex,claude,mcode}.json`, +`runtime-builds.json` and `full-check-attempt2-result.json`. The shared reproducible +fixture is `services/agents-api/tests/official_environment_plugins.py`; operator +runners reuse existing standalone acceptance and private model configuration. +Review and merge remain required before delivery. Plugin MCP, portable root +`plugin.json` applicability and the unconfirmed semantics above remain gaps; +these results do not establish complete Environment Templates or protocol compatibility. + ## Packaged Runtime initialization contract Template handlers and stores resolve public configuration without choosing a diff --git a/contracts/agents-api/openapi.yaml b/contracts/agents-api/openapi.yaml index bb86db839..47863f305 100644 --- a/contracts/agents-api/openapi.yaml +++ b/contracts/agents-api/openapi.yaml @@ -282,6 +282,11 @@ definitions: allOf: - $ref: '#/definitions/v1.EnvironmentPackages' x-nullable: true + plugins: + items: + type: object + type: array + x-nullable: true setup_commands: items: type: object @@ -1998,9 +2003,9 @@ paths: get: description: Returns durable connection status and safe installed metadata for supported self_hosted and basic openai_hosted profiles. Initial files expose - frozen safe metadata without content; empty plugins/skills describe the absence - of API-managed installations, not the contents or discovered capabilities - of the caller's machine. Unsupported installation configurations remain implementation + frozen safe metadata without content; Plugin/Skill entries expose only safe + configured installation metadata. Capability-directory discoveries are not + added to those arrays. Unsupported installation configurations remain implementation gaps. This read does not prepare execution, start compute or require an enabled execution worker. Session deletion removes the associated Environment from public reads; project-shared read authorization is unchanged. Connection status @@ -2256,11 +2261,12 @@ paths: description: Saves tenant-owned hosted configuration. Supports nullable name, enabled/disabled or exact-domain restricted network, initial inline/file_id files, confidential env, ordered setup_commands, system/npm/Python packages - and inline/referenced Skill ZIPs. Omitted/null network defaults to enabled. - Restricted network requires 1–100 exact ASCII hostnames; other host forms - and populated unsupported installations are rejected before persistence without - echoing input. No compute is allocated. Exact hosted error/retry semantics - remain unverified. + inline/referenced Skill ZIPs, skill-only Plugin ZIPs and workspace-contained + capability directories. Omitted/null network defaults to enabled. Restricted + network requires 1–100 exact ASCII hostnames; other host forms and populated + unsupported installations are rejected before persistence without echoing + input. No compute is allocated. Exact hosted error/retry semantics remain + unverified. parameters: - description: agents=v1 in: header @@ -2394,9 +2400,11 @@ paths: 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. + archives are encrypted separately and omitted from responses. Plugins and + capability directories replace as lists; null/empty clears. Plugin archives + are encrypted and omitted from responses. Capability directories are snapshotted + after setup; Plugin MCP activation remains 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 5cb59ee0d..77fee1cfe 100644 --- a/contracts/agents-api/v1/sessions.go +++ b/contracts/agents-api/v1/sessions.go @@ -37,6 +37,7 @@ type InlineAgent struct { // Environment contains supported request variants; self-hosted creation requires a workspace directory. type Environment struct { + Plugins []json.RawMessage `json:"plugins,omitempty" swaggertype:"array,object" extensions:"x-nullable"` 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"` diff --git a/internal/agentbundle/archive.go b/internal/agentbundle/archive.go new file mode 100644 index 000000000..295a4d636 --- /dev/null +++ b/internal/agentbundle/archive.go @@ -0,0 +1,90 @@ +// Package agentbundle validates bounded regular-file capability archives. +package agentbundle + +import ( + "archive/zip" + "bytes" + "errors" + "io" + "os" + "path" + "strings" + "unicode/utf8" +) + +const ( + MaxArchiveBytes = 5 << 20 + MaxExpandedBytes = 20 << 20 + MaxFiles = 1000 +) + +var ErrInvalid = errors.New("invalid or unsupported capability archive") + +type File struct { + Path string `json:"path"` + Data []byte `json:"data"` + Executable bool `json:"executable,omitempty"` +} + +// Read strips a single archive root after validating every member and its bounds. +func Read(archive []byte) ([]File, error) { + if 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 + 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) + + files = append(files, File{Path: parts[1], Data: body, Executable: entry.Mode().Perm()&0111 != 0}) + } + + 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 +} diff --git a/internal/agentcapabilities/install.go b/internal/agentcapabilities/install.go new file mode 100644 index 000000000..e6d26750b --- /dev/null +++ b/internal/agentcapabilities/install.go @@ -0,0 +1,165 @@ +package agentcapabilities + +import ( + "encoding/json" + "os" + "path" + "strconv" + "strings" + + "github.com/MiniMax-AI-Dev/parsar/internal/agentbundle" + "github.com/MiniMax-AI-Dev/parsar/internal/agentplugin" + "github.com/MiniMax-AI-Dev/parsar/internal/agentskill" +) + +func InstallPlugin(root *os.Root, slot int, archive []byte, metadata agentplugin.Metadata) error { + if slot < 0 || slot >= 50 { + return ErrInvalid + } + bundle, err := agentplugin.Read(archive, metadata) + if err != nil { + return ErrInvalid + } + return writeTree(root, "plugins/"+strconv.Itoa(slot), bundle.Files) +} + +// Finalize runs once after setup under the existing allocation initializer. +// It emits an installation artifact, not another lifecycle or public catalog. +func Finalize(workspace, installed *os.Root, input Input) error { + if len(input.Skills) > MaxSkills || len(input.Plugins) > 50 || ValidateDirectories(input.Directories) != nil { + return ErrInvalid + } + if _, err := installed.Lstat(ManifestName); !os.IsNotExist(err) { + return ErrInvalid + } + manifest := Manifest{Version: 1, Skills: []InstalledSkill{}} + total := 0 + count := func(files []agentbundle.File) error { + for _, file := range files { + total += len(file.Data) + } + if total > MaxSnapshotBytes { + return ErrInvalid + } + return nil + } + for _, metadata := range input.Skills { + if !validRelative(metadata.Name) || strings.Contains(metadata.Name, "/") { + return ErrInvalid + } + root := "skills/" + metadata.Name + files, err := ReadTree(installed, root, true) + if err != nil || count(files) != nil { + return ErrInvalid + } + body, err := installed.ReadFile(root + "/SKILL.md") + if err != nil || agentskill.ValidateManifest(body, metadata) != nil || manifest.add(metadata, root, root) != nil { + return ErrInvalid + } + } + for slot, expected := range input.Plugins { + root := "plugins/" + strconv.Itoa(slot) + files, err := ReadTree(installed, root, true) + if err != nil || count(files) != nil { + return ErrInvalid + } + bundle, err := agentplugin.Inspect(files) + if err != nil || bundle.Metadata != expected || addPlugin(&manifest, root, bundle) != nil { + return ErrInvalid + } + } + for slot, source := range input.Directories { + name := strings.TrimPrefix(source, "/workspace") + name = strings.TrimPrefix(name, "/") + if name == "" { + name = "." + } + files, err := ReadTree(workspace, name, false) + if err != nil || count(files) != nil { + return ErrInvalid + } + root := "directories/" + strconv.Itoa(slot) + if discover(&manifest, root, files) != nil || writeTree(installed, root, files) != nil { + return ErrInvalid + } + } + body, err := json.Marshal(manifest) + if err != nil || writeFile(installed, ManifestName+".tmp", body, 0400) != nil || installed.Rename(ManifestName+".tmp", ManifestName) != nil { + return ErrInvalid + } + return syncDirectory(installed, ".") +} + +func addPlugin(manifest *Manifest, root string, bundle agentplugin.Bundle) error { + for _, skill := range bundle.Skills { + if err := manifest.add(skill.Metadata, path.Join(root, skill.RelativeRoot), root); err != nil { + return err + } + } + return nil +} + +func discover(manifest *Manifest, root string, files []agentbundle.File) error { + for _, file := range files { + if file.Path == ".codex-plugin/plugin.json" { + bundle, err := agentplugin.Inspect(files) + if err != nil { + return ErrInvalid + } + return addPlugin(manifest, root, bundle) + } + } + before := len(manifest.Skills) + for _, file := range files { + if path.Base(file.Path) != "SKILL.md" { + continue + } + metadata, err := agentskill.InspectManifest(file.Data) + if err != nil || manifest.add(metadata, path.Join(root, path.Dir(file.Path)), root) != nil { + return ErrInvalid + } + } + if before == len(manifest.Skills) { + return ErrInvalid + } + return nil +} + +// Load checks the protected installation; it never reads the original workspace +// directories, even after a native/Core restart. +func Load(root *os.Root) (Manifest, error) { + info, err := root.Lstat(ManifestName) + if err != nil || !info.Mode().IsRegular() || info.Mode().Perm()&0222 != 0 || info.Size() > 256<<10 { + return Manifest{}, ErrInvalid + } + body, err := root.ReadFile(ManifestName) + if err != nil { + return Manifest{}, ErrInvalid + } + manifest, err := decodeManifest(body) + if err != nil { + return Manifest{}, err + } + packages := map[string]bool{} + total := 0 + for _, skill := range manifest.Skills { + if !packages[skill.PackageRoot] { + files, err := ReadTree(root, skill.PackageRoot, true) + if err != nil { + return Manifest{}, err + } + for _, file := range files { + total += len(file.Data) + } + if total > MaxSnapshotBytes { + return Manifest{}, ErrInvalid + } + packages[skill.PackageRoot] = true + } + body, err := root.ReadFile(skill.RelativeRoot + "/SKILL.md") + if err != nil || agentskill.ValidateManifest(body, skill.Metadata) != nil { + return Manifest{}, ErrInvalid + } + } + return manifest, nil +} diff --git a/internal/agentcapabilities/install_test.go b/internal/agentcapabilities/install_test.go new file mode 100644 index 000000000..5272c7551 --- /dev/null +++ b/internal/agentcapabilities/install_test.go @@ -0,0 +1,149 @@ +package agentcapabilities + +import ( + "archive/zip" + "bytes" + "os" + "path/filepath" + "reflect" + "testing" + + "github.com/MiniMax-AI-Dev/parsar/internal/agentbundle" + "github.com/MiniMax-AI-Dev/parsar/internal/agentplugin" + "github.com/MiniMax-AI-Dev/parsar/internal/agentskill" +) + +func TestInstalledCapabilitiesPreserveSourcesWithoutRescanning(t *testing.T) { + workspace := openTestRoot(t) + installed := openTestRoot(t) + inline := agentskill.Metadata{Type: "inline", Name: "inline", Description: "Check proof."} + if err := writeTree(installed, "skills/inline", []agentbundle.File{{Path: "SKILL.md", Data: skillManifest("inline")}}); err != nil { + t.Fatal(err) + } + plugin := agentplugin.Metadata{Type: "inline", Name: "package", Description: "Package proof."} + if err := InstallPlugin(installed, 0, testPlugin(t, "bundled"), plugin); err != nil { + t.Fatal(err) + } + writeWorkspace(t, workspace, "generated/nested/SKILL.md", skillManifest("generated")) + writeWorkspace(t, workspace, "generated/shared/proof.txt", []byte("original-resource")) + input := Input{Skills: []agentskill.Metadata{inline}, Plugins: []agentplugin.Metadata{plugin}, Directories: []string{"/workspace/generated"}} + if err := Finalize(workspace, installed, input); err != nil { + t.Fatal(err) + } + before, err := Load(installed) + if err != nil || len(before.Skills) != 3 { + t.Fatalf("installed capabilities: %+v %v", before, err) + } + if before.Skills[1].RelativeRoot != "plugins/0/skills/bundled" || before.Skills[2].PackageRoot != "directories/0" { + t.Fatalf("package layout lost: %+v", before) + } + if err := workspace.RemoveAll("generated"); err != nil { + t.Fatal(err) + } + after, err := Load(installed) + if err != nil || !reflect.DeepEqual(before, after) { + t.Fatalf("load rescanned changed sources: %+v %v", after, err) + } + for name, expected := range map[string]string{"plugins/0/shared/proof.txt": "plugin-resource", "directories/0/shared/proof.txt": "original-resource"} { + body, err := installed.ReadFile(name) + info, statErr := installed.Stat(name) + if err != nil || statErr != nil || string(body) != expected || info.Mode().Perm()&0222 != 0 { + t.Fatalf("snapshot changed or writable: %s", name) + } + } + if Finalize(workspace, installed, input) == nil { + t.Fatal("completed initialization was replayed") + } +} + +func TestDirectoryDiscoveryDoesNotActivateChildPluginMCP(t *testing.T) { + workspace, installed := openTestRoot(t), openTestRoot(t) + writeWorkspace(t, workspace, "parent/child/.codex-plugin/plugin.json", []byte(`{"name":"remote","description":"Remote","skills":"./skills","mcpServers":"./.mcp.json"}`)) + writeWorkspace(t, workspace, "parent/child/.mcp.json", []byte(`{"mcpServers":{"remote":{"type":"http","url":"https://example.com"}}}`)) + writeWorkspace(t, workspace, "parent/child/skills/proof/SKILL.md", skillManifest("proof")) + if err := Finalize(workspace, installed, Input{Directories: []string{"/workspace/parent"}}); err != nil { + t.Fatal(err) + } + manifest, err := Load(installed) + if err != nil || len(manifest.Skills) != 1 || manifest.Skills[0].RelativeRoot != "directories/0/child/skills/proof" { + t.Fatalf("parent Skill discovery failed: %+v %v", manifest, err) + } + if err := Finalize(workspace, openTestRoot(t), Input{Directories: []string{"/workspace/parent/child"}}); err == nil { + t.Fatal("explicit Plugin root silently dropped declared MCP") + } +} + +func TestInvalidDirectorySnapshotsNeverPublish(t *testing.T) { + for _, scenario := range []string{"missing", "outside", "symlink", "duplicate", "file"} { + t.Run(scenario, func(t *testing.T) { + workspace, installed := openTestRoot(t), openTestRoot(t) + writeWorkspace(t, workspace, "first/SKILL.md", skillManifest("proof")) + input := Input{Directories: []string{"/workspace/first"}} + switch scenario { + case "missing": + input.Directories = []string{"/workspace/missing"} + case "outside": + input.Directories = []string{"/workspace/../initialization"} + case "symlink": + if err := workspace.Symlink(t.TempDir(), "first/private"); err != nil { + t.Fatal(err) + } + case "duplicate": + writeWorkspace(t, workspace, "second/SKILL.md", skillManifest("proof")) + input.Directories = append(input.Directories, "/workspace/second") + case "file": + input.Directories = []string{"/workspace/first/SKILL.md"} + } + if Finalize(workspace, installed, input) == nil { + t.Fatal("invalid capability source accepted") + } + if _, err := installed.Lstat(ManifestName); !os.IsNotExist(err) { + t.Fatal("failed snapshot published a completion artifact") + } + }) + } +} + +func openTestRoot(t *testing.T) *os.Root { + t.Helper() + root, err := os.OpenRoot(t.TempDir()) + if err != nil { + t.Fatal(err) + } + t.Cleanup(func() { root.Close() }) + return root +} + +func writeWorkspace(t *testing.T, root *os.Root, name string, body []byte) { + t.Helper() + if root.MkdirAll(filepath.Dir(name), 0700) != nil || root.WriteFile(name, body, 0600) != nil { + t.Fatal("workspace fixture failed") + } +} + +func skillManifest(name string) []byte { + return []byte("---\nname: " + name + "\ndescription: Check proof.\n---\nRead supporting files.\n") +} + +func testPlugin(t *testing.T, name string) []byte { + t.Helper() + var buffer bytes.Buffer + writer := zip.NewWriter(&buffer) + for path, body := range map[string][]byte{ + ".codex-plugin/plugin.json": []byte(`{"name":"package","description":"Package proof.","skills":"./skills"}`), + "skills/" + name + "/SKILL.md": skillManifest(name), + "shared/proof.txt": []byte("plugin-resource"), + } { + file, err := writer.Create("package/" + path) + if err != nil { + t.Fatal(err) + } + if _, err = file.Write(body); err != nil { + t.Fatal(err) + } + } + if err := writer.Close(); err != nil { + t.Fatal(err) + } + return buffer.Bytes() +} diff --git a/internal/agentcapabilities/manifest.go b/internal/agentcapabilities/manifest.go new file mode 100644 index 000000000..321025435 --- /dev/null +++ b/internal/agentcapabilities/manifest.go @@ -0,0 +1,85 @@ +// Package agentcapabilities owns the packaged Runtime's inert installation data. +package agentcapabilities + +import ( + "encoding/json" + "errors" + "io/fs" + "path" + "strings" + + "github.com/MiniMax-AI-Dev/parsar/internal/agentplugin" + "github.com/MiniMax-AI-Dev/parsar/internal/agentskill" +) + +const Directory = "/environment/initialization/capabilities" +const ManifestName = "installed.json" +const MaxSkills = 50 +const MaxSnapshotBytes = 50 << 20 + +var ErrInvalid = errors.New("capability installation unavailable or unsupported") + +// InstalledSkill contains Runtime-owned paths, never public resource metadata. +type InstalledSkill struct { + Metadata agentskill.Metadata `json:"metadata"` + RelativeRoot string `json:"relative_root"` + PackageRoot string `json:"package_root"` +} + +type Manifest struct { + Version int `json:"version"` + Skills []InstalledSkill `json:"skills"` +} + +// Input describes frozen sources; directory contents are observed after setup. +type Input struct { + Skills []agentskill.Metadata `json:"skills,omitempty"` + Plugins []agentplugin.Metadata `json:"plugins,omitempty"` + Directories []string `json:"directories,omitempty"` +} + +func ValidateDirectories(directories []string) error { + if len(directories) > 50 { + return ErrInvalid + } + seen := map[string]bool{} + for _, directory := range directories { + if (directory != "/workspace" && !strings.HasPrefix(directory, "/workspace/")) || + path.Clean(directory) != directory || strings.ContainsAny(directory, "\\\x00\r\n") || seen[directory] { + return ErrInvalid + } + seen[directory] = true + } + return nil +} + +func validRelative(value string) bool { + return value != "." && fs.ValidPath(value) && !strings.ContainsAny(value, "\\\x00\r\n") +} + +func (m *Manifest) add(metadata agentskill.Metadata, root, pkg string) error { + if !validRelative(root) || !validRelative(pkg) || (root != pkg && !strings.HasPrefix(root, pkg+"/")) || len(m.Skills) >= MaxSkills { + return ErrInvalid + } + for _, old := range m.Skills { + if old.Metadata.Name == metadata.Name { + return ErrInvalid + } + } + m.Skills = append(m.Skills, InstalledSkill{Metadata: metadata, RelativeRoot: root, PackageRoot: pkg}) + return nil +} + +func decodeManifest(body []byte) (Manifest, error) { + var result Manifest + if len(body) > 256<<10 || json.Unmarshal(body, &result) != nil || result.Version != 1 || len(result.Skills) > MaxSkills { + return Manifest{}, ErrInvalid + } + checked := Manifest{Version: 1} + for _, skill := range result.Skills { + if checked.add(skill.Metadata, skill.RelativeRoot, skill.PackageRoot) != nil { + return Manifest{}, ErrInvalid + } + } + return result, nil +} diff --git a/internal/agentcapabilities/operation.go b/internal/agentcapabilities/operation.go new file mode 100644 index 000000000..739ad1087 --- /dev/null +++ b/internal/agentcapabilities/operation.go @@ -0,0 +1,13 @@ +package agentcapabilities + +import "github.com/MiniMax-AI-Dev/parsar/internal/agentplugin" + +// Operation is confidential stdin for the packaged helper, not a public API. +type Operation struct { + Version int `json:"version"` + Action string `json:"action"` + Slot int `json:"slot,omitempty"` + Archive []byte `json:"archive,omitempty"` + Plugin agentplugin.Metadata `json:"plugin,omitempty"` + Sources Input `json:"sources,omitempty"` +} diff --git a/internal/agentcapabilities/tree.go b/internal/agentcapabilities/tree.go new file mode 100644 index 000000000..a1709f4a8 --- /dev/null +++ b/internal/agentcapabilities/tree.go @@ -0,0 +1,137 @@ +package agentcapabilities + +import ( + "io" + "io/fs" + "os" + "path" + "strings" + + "github.com/MiniMax-AI-Dev/parsar/internal/agentbundle" +) + +// ReadTree stays inside an already-owned root and rejects aliases/special files. +// Installation sources may be writable; retained snapshots must be read-only. +func ReadTree(root *os.Root, name string, immutable bool) ([]agentbundle.File, error) { + if name != "." && !validRelative(name) { + return nil, ErrInvalid + } + info, err := root.Lstat(name) + if err != nil || !info.IsDir() { + return nil, ErrInvalid + } + var files []agentbundle.File + total := 0 + entries := 0 + err = fs.WalkDir(root.FS(), name, func(current string, entry fs.DirEntry, walkErr error) error { + entries++ + if walkErr != nil || entries > agentbundle.MaxFiles { + return ErrInvalid + } + info, err := root.Lstat(current) + if err != nil { + return ErrInvalid + } + if info.IsDir() { + return nil + } + if !info.Mode().IsRegular() || (immutable && info.Mode().Perm()&0222 != 0) || len(files) >= agentbundle.MaxFiles || info.Size() > int64(agentbundle.MaxExpandedBytes-total) { + return ErrInvalid + } + file, err := root.Open(current) + if err != nil { + return ErrInvalid + } + actual, statErr := file.Stat() + if statErr != nil || !actual.Mode().IsRegular() || !os.SameFile(info, actual) { + file.Close() + return ErrInvalid + } + body, readErr := io.ReadAll(io.LimitReader(file, int64(agentbundle.MaxExpandedBytes-total)+1)) + closeErr := file.Close() + if readErr != nil || closeErr != nil || len(body) > agentbundle.MaxExpandedBytes-total || int64(len(body)) != actual.Size() { + return ErrInvalid + } + total += len(body) + relative := current + if name != "." { + relative = strings.TrimPrefix(current, name+"/") + } + if !validRelative(relative) { + return ErrInvalid + } + files = append(files, agentbundle.File{Path: relative, Data: body, Executable: actual.Mode().Perm()&0111 != 0}) + return nil + }) + if err != nil { + return nil, ErrInvalid + } + return files, nil +} + +// writeTree only creates a fresh owned destination. Uncertain writes are never +// retried here; the existing Core initialization owner decides cleanup. +func writeTree(root *os.Root, name string, files []agentbundle.File) error { + if !validRelative(name) || root.MkdirAll(path.Dir(name), 0700) != nil || root.Mkdir(name, 0700) != nil { + return ErrInvalid + } + directory, err := root.OpenRoot(name) + if err != nil { + return ErrInvalid + } + defer directory.Close() + for _, file := range files { + if !validRelative(file.Path) || directory.MkdirAll(path.Dir(file.Path), 0700) != nil { + return ErrInvalid + } + mode := fs.FileMode(0400) + if file.Executable { + mode = 0500 + } + if err := writeFile(directory, file.Path, file.Data, mode); err != nil { + return err + } + } + err = fs.WalkDir(directory.FS(), ".", func(name string, entry fs.DirEntry, err error) error { + if err != nil { + return ErrInvalid + } + if entry.IsDir() { + return syncDirectory(directory, name) + } + return nil + }) + if err != nil { + return err + } + return syncDirectory(root, path.Dir(name)) +} + +func writeFile(root *os.Root, name string, body []byte, mode fs.FileMode) error { + file, err := root.OpenFile(name, os.O_WRONLY|os.O_CREATE|os.O_EXCL, mode) + if err != nil { + return ErrInvalid + } + _, err = file.Write(body) + if err == nil { + err = file.Sync() + } + closeErr := file.Close() + if err != nil || closeErr != nil { + return ErrInvalid + } + return nil +} + +func syncDirectory(root *os.Root, name string) error { + file, err := root.Open(name) + if err != nil { + return ErrInvalid + } + err = file.Sync() + closeErr := file.Close() + if err != nil || closeErr != nil { + return ErrInvalid + } + return nil +} diff --git a/internal/agentdaemon/proto/environment.go b/internal/agentdaemon/proto/environment.go index 8bbf3028a..a064784f4 100644 --- a/internal/agentdaemon/proto/environment.go +++ b/internal/agentdaemon/proto/environment.go @@ -1,12 +1,14 @@ package proto -import "github.com/MiniMax-AI-Dev/parsar/internal/agentskill" +import "github.com/MiniMax-AI-Dev/parsar/internal/agentcapabilities" // 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"` + // Capabilities requests the completed, protected Runtime installation. + Capabilities bool `json:"capabilities,omitempty"` + // Skills is resolved by the bound daemon; wire input cannot supply paths. + Skills []agentcapabilities.InstalledSkill `json:"-"` // ToolEnvironment consumes Core-completed confidential initialization. ToolEnvironment bool `json:"tool_environment,omitempty"` // SystemPackages requires the installed Runtime tool root during execution. diff --git a/internal/agentplugin/bundle.go b/internal/agentplugin/bundle.go new file mode 100644 index 000000000..4c459762d --- /dev/null +++ b/internal/agentplugin/bundle.go @@ -0,0 +1,181 @@ +// Package agentplugin validates the supported inert Agents API Plugin format. +package agentplugin + +import ( + "bytes" + "encoding/json" + "errors" + "io" + "path" + "sort" + "strings" + + "github.com/MiniMax-AI-Dev/parsar/internal/agentbundle" + "github.com/MiniMax-AI-Dev/parsar/internal/agentskill" +) + +var ErrInvalid = errors.New("invalid or unsupported Plugin bundle") + +type Metadata struct { + Type string `json:"type"` + Name string `json:"name"` + Description string `json:"description"` +} + +// Skill identifies a validated root inside its preserved Plugin package. +type Skill struct { + Metadata agentskill.Metadata `json:"metadata"` + RelativeRoot string `json:"relative_root"` +} + +type Bundle struct { + Metadata Metadata + Files []agentbundle.File + Skills []Skill +} + +// Read validates the archive and its declared identity without native loading. +func Read(archive []byte, expected Metadata) (Bundle, error) { + files, err := agentbundle.Read(archive) + if err != nil { + return Bundle{}, ErrInvalid + } + bundle, err := Inspect(files) + if err != nil || bundle.Metadata != expected { + return Bundle{}, ErrInvalid + } + return bundle, nil +} + +// Inspect consumes files already bounded and rooted by the archive reader or +// Runtime snapshotter. It never executes or imports the native Plugin manifest. +func Inspect(files []agentbundle.File) (Bundle, error) { + members := make(map[string][]byte, len(files)) + for _, file := range files { + if _, exists := members[file.Path]; exists { + return Bundle{}, ErrInvalid + } + members[file.Path] = file.Data + } + var manifest struct { + Name string `json:"name"` + Description string `json:"description"` + Version string `json:"version"` + Skills json.RawMessage `json:"skills"` + MCP json.RawMessage `json:"mcpServers"` + Author json.RawMessage `json:"author"` + Homepage string `json:"homepage"` + Repository string `json:"repository"` + License string `json:"license"` + Keywords []string `json:"keywords"` + Interface json.RawMessage `json:"interface"` + } + if decodeObject(members[".codex-plugin/plugin.json"], &manifest) != nil || manifest.Name == "" || manifest.Description == "" { + return Bundle{}, ErrInvalid + } + roots, err := skillRoots(manifest.Skills) + if err != nil || rejectMCP(manifest.MCP, members) != nil { + return Bundle{}, ErrInvalid + } + result := Bundle{Metadata: Metadata{Type: "inline", Name: manifest.Name, Description: manifest.Description}, Files: files} + seen := map[string]bool{} + for _, file := range files { + if path.Base(file.Path) != "SKILL.md" { + continue + } + root := path.Dir(file.Path) + selected := false + for _, declared := range roots { + selected = selected || strings.HasPrefix(file.Path, declared+"/") + } + if !selected { + continue + } + metadata, err := agentskill.InspectManifest(file.Data) + if err != nil || seen[metadata.Name] { + return Bundle{}, ErrInvalid + } + seen[metadata.Name] = true + result.Skills = append(result.Skills, Skill{Metadata: metadata, RelativeRoot: root}) + } + if len(result.Skills) == 0 || len(result.Skills) > 50 { + return Bundle{}, ErrInvalid + } + sort.Slice(result.Skills, func(i, j int) bool { return result.Skills[i].RelativeRoot < result.Skills[j].RelativeRoot }) + return result, nil +} + +func skillRoots(raw json.RawMessage) ([]string, error) { + var roots []string + var single string + if json.Unmarshal(raw, &single) == nil { + roots = []string{single} + } else if json.Unmarshal(raw, &roots) != nil { + return nil, ErrInvalid + } + if len(roots) == 0 || len(roots) > 50 { + return nil, ErrInvalid + } + for i, root := range roots { + root, err := relativeDeclaration(root) + if err != nil { + return nil, err + } + roots[i] = root + } + return roots, nil +} + +func relativeDeclaration(value string) (string, error) { + if !strings.HasPrefix(value, "./") || strings.ContainsAny(value, "\\\x00\r\n") { + return "", ErrInvalid + } + value = strings.TrimSuffix(strings.TrimPrefix(value, "./"), "/") + if value == "" || value == "." || value == ".." || strings.HasPrefix(value, "../") || path.Clean(value) != value || path.IsAbs(value) { + return "", ErrInvalid + } + return value, nil +} + +func rejectMCP(raw json.RawMessage, files map[string][]byte) error { + config := ".mcp.json" + declared := len(raw) != 0 + if declared { + if json.Unmarshal(raw, &config) != nil { + return ErrInvalid + } + var err error + config, err = relativeDeclaration(config) + if err != nil { + return err + } + } + body, exists := files[config] + if !exists && !declared { + return nil + } + var input struct { + Servers map[string]json.RawMessage `json:"mcpServers"` + } + if decodeObject(body, &input) != nil || input.Servers == nil || len(input.Servers) != 0 { + return ErrInvalid + } + return nil +} + +func decodeObject(body []byte, output any) error { + body = bytes.TrimSpace(body) + if len(body) == 0 || len(body) > 256<<10 || body[0] != '{' { + return ErrInvalid + } + decoder := json.NewDecoder(bytes.NewReader(body)) + decoder.DisallowUnknownFields() + if decoder.Decode(output) != nil { + return ErrInvalid + } + var extra any + if decoder.Decode(&extra) != io.EOF { + return ErrInvalid + } + return nil +} diff --git a/internal/agentplugin/bundle_test.go b/internal/agentplugin/bundle_test.go new file mode 100644 index 000000000..7db4c8124 --- /dev/null +++ b/internal/agentplugin/bundle_test.go @@ -0,0 +1,91 @@ +package agentplugin + +import ( + "archive/zip" + "bytes" + "io/fs" + "testing" +) + +func TestPluginPreservesMultipleSkillRootsAndSharedResources(t *testing.T) { + files := pluginFixture() + files[".mcp.json"] = []byte(`{"mcpServers":{}}`) + bundle, err := Read(pluginArchive(t, files, 0600), Metadata{Type: "inline", Name: "proof", Description: "Use shared resources."}) + if err != nil || len(bundle.Skills) != 2 { + t.Fatalf("plugin rejected: %v", err) + } + if bundle.Skills[0].RelativeRoot != "skills/first" || bundle.Skills[1].RelativeRoot != "skills/second" { + t.Fatalf("Skill roots changed: %+v", bundle.Skills) + } + found := false + for _, file := range bundle.Files { + if file.Path == "references/proof.txt" && string(file.Data) == "package-resource" { + found = true + } + } + if !found { + t.Fatal("package-relative resource was dropped") + } + if _, err := Read(pluginArchive(t, files, 0600), Metadata{Type: "inline", Name: "other", Description: "Use shared resources."}); err == nil { + t.Fatal("mismatched public identity accepted") + } +} + +func TestPluginRejectsUnqualifiedActivationAndUnsafeLayout(t *testing.T) { + for _, tc := range []struct { + name string + path string + body string + }{ + {"declared MCP", ".codex-plugin/plugin.json", `{"name":"proof","description":"Use shared resources.","skills":"./skills","mcpServers":"./missing.json"}`}, + {"default MCP", ".mcp.json", `{"mcpServers":{"remote":{"type":"http","url":"https://example.com/mcp"}}}`}, + {"hooks", ".codex-plugin/plugin.json", `{"name":"proof","description":"Use shared resources.","skills":"./skills","hooks":"./hooks.json"}`}, + {"escaping declaration", ".codex-plugin/plugin.json", `{"name":"proof","description":"Use shared resources.","skills":"./../private"}`}, + {"absolute declaration", ".codex-plugin/plugin.json", `{"name":"proof","description":"Use shared resources.","skills":"/private"}`}, + {"escaping member", "../../private", "outside"}, + {"duplicate name", "skills/second/SKILL.md", "---\nname: first\ndescription: Read shared resources.\n---\nText"}, + {"native Skill authority", "skills/first/SKILL.md", "---\nname: first\ndescription: Read shared resources.\nallowed-tools: Bash\n---\nText"}, + {"missing selected root", ".codex-plugin/plugin.json", `{"name":"proof","description":"Use shared resources.","skills":"./missing"}`}, + } { + t.Run(tc.name, func(t *testing.T) { + files := pluginFixture() + files[tc.path] = []byte(tc.body) + if _, err := Read(pluginArchive(t, files, 0600), Metadata{Type: "inline", Name: "proof", Description: "Use shared resources."}); err == nil { + t.Fatal("invalid Plugin accepted") + } + }) + } + if _, err := Read(pluginArchive(t, pluginFixture(), fs.ModeSymlink|0600), Metadata{Type: "inline", Name: "proof", Description: "Use shared resources."}); err == nil { + t.Fatal("symlink members accepted") + } +} + +func pluginFixture() map[string][]byte { + return map[string][]byte{ + ".codex-plugin/plugin.json": []byte(`{"name":"proof","description":"Use shared resources.","version":"1.0.0","skills":"./skills/"}`), + "skills/first/SKILL.md": []byte("---\nname: first\ndescription: Read shared resources.\n---\nRead ../../references/proof.txt.\n"), + "skills/second/SKILL.md": []byte("---\nname: second\ndescription: Check shared resources.\n---\nRead ../../references/proof.txt.\n"), + "references/proof.txt": []byte("package-resource"), + } +} + +func pluginArchive(t *testing.T, files map[string][]byte, mode fs.FileMode) []byte { + t.Helper() + var out bytes.Buffer + writer := zip.NewWriter(&out) + for name, body := range files { + header := &zip.FileHeader{Name: "package/" + name, Method: zip.Deflate} + header.SetMode(mode) + file, err := writer.CreateHeader(header) + if err != nil { + t.Fatal(err) + } + if _, err := file.Write(body); err != nil { + t.Fatal(err) + } + } + if err := writer.Close(); err != nil { + t.Fatal(err) + } + return out.Bytes() +} diff --git a/internal/agentskill/bundle.go b/internal/agentskill/bundle.go index 6929e3104..758595ecd 100644 --- a/internal/agentskill/bundle.go +++ b/internal/agentskill/bundle.go @@ -2,23 +2,21 @@ package agentskill import ( - "archive/zip" - "bytes" "errors" "io" - "os" - "path" "regexp" "strings" "unicode/utf8" "gopkg.in/yaml.v3" + + "github.com/MiniMax-AI-Dev/parsar/internal/agentbundle" ) const ( - MaxArchiveBytes = 5 << 20 - MaxExpandedBytes = 20 << 20 - MaxFiles = 1000 + MaxArchiveBytes = agentbundle.MaxArchiveBytes + MaxExpandedBytes = agentbundle.MaxExpandedBytes + MaxFiles = agentbundle.MaxFiles ) var ErrInvalid = errors.New("invalid or unsupported Skill bundle") @@ -30,96 +28,52 @@ type Metadata struct { 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 { +func Read(archive []byte, expected Metadata) ([]agentbundle.File, error) { + if expected.Type != "inline" || !namePattern.MatchString(expected.Name) || len(expected.Name) > 64 || expected.Description == "" || !utf8.ValidString(expected.Description) { return nil, ErrInvalid } - reader, err := zip.NewReader(bytes.NewReader(archive), int64(len(archive))) - if err != nil || len(reader.File) == 0 || len(reader.File) > MaxFiles { + files, err := agentbundle.Read(archive) + if err != nil { 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 { + for i := range files { + if strings.HasPrefix(files[i].Path, "SKILL.md/") { 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 strings.EqualFold(parts[1], "SKILL.md") { - if manifest { - return nil, ErrInvalid - } - parts[1] = "SKILL.md" - if ValidateManifest(body, expected) != nil { + if strings.EqualFold(files[i].Path, "SKILL.md") { + if manifest || ValidateManifest(files[i].Data, expected) != nil { return nil, ErrInvalid } manifest = true + files[i].Path = "SKILL.md" } - 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 { - actual, err := manifestMetadata(body) + actual, err := InspectManifest(body) if err != nil || actual != expected { return ErrInvalid } return nil } +// InspectManifest shares the portable parser with Runtime directory discovery. +func InspectManifest(body []byte) (Metadata, error) { + metadata, err := manifestMetadata(body) + if err != nil || !namePattern.MatchString(metadata.Name) || len(metadata.Name) > 64 || metadata.Description == "" { + return Metadata{}, ErrInvalid + } + return metadata, nil +} + func manifestMetadata(body []byte) (Metadata, error) { if len(body) > 256<<10 || !utf8.Valid(body) { return Metadata{}, ErrInvalid diff --git a/packages/claude-sdk-adapter/src/workspace.ts b/packages/claude-sdk-adapter/src/workspace.ts index 46647d589..4acac0d3e 100644 --- a/packages/claude-sdk-adapter/src/workspace.ts +++ b/packages/claude-sdk-adapter/src/workspace.ts @@ -95,13 +95,13 @@ export class WorkspaceProfile { env.CLAUDE_CODE_SHELL_PREFIX = "/usr/local/bin/agents-api-tool-root"; env.PARSAR_RUNTIME_TOOL_SCRATCH = config.scratch; } - const skills = workspaceSkills(config.state, config.skills ?? []); + const skills = workspaceSkills(config.skills ?? []); this.skillNames = skills?.names ?? []; const skillTools = skills ? ["Skill"] : []; const protectedRoots = [config.home, config.state, ...config.protected_dirs]; this.options = { env, tools: [...nativeTools, ...skillTools], - ...(skills ? { plugins: [{ type: "local" as const, path: skills.path, skipMcpDiscovery: true }] } : {}), allowedTools: [...functions], mcpServers: {}, strictMcpConfig: true, + ...(skills ? { plugins: skills.paths.map(path => ({ type: "local" as const, path, skipMcpDiscovery: true })) } : {}), allowedTools: [...functions], mcpServers: {}, strictMcpConfig: true, settingSources: [], permissionMode: "default", persistSession: true, settings: { ...(skills ? { disableSkillShellExecution: true } : {}), diff --git a/packages/claude-sdk-adapter/src/workspace_skills.ts b/packages/claude-sdk-adapter/src/workspace_skills.ts index 4ba85305c..3d03c5a48 100644 --- a/packages/claude-sdk-adapter/src/workspace_skills.ts +++ b/packages/claude-sdk-adapter/src/workspace_skills.ts @@ -1,9 +1,15 @@ -import { lstatSync, mkdirSync, readFileSync, readlinkSync, symlinkSync, writeFileSync } from "node:fs"; -import { join } from "node:path"; +import { linkSync, lstatSync, mkdirSync, readFileSync, readdirSync, writeFileSync } from "node:fs"; +import { join, posix } from "node:path"; -export type WorkspaceSkill = { type: "inline"; name: string; description: string }; -export const skillRoot = "/environment/initialization/capabilities/skills"; -const pluginName = "environment-skills"; +export type WorkspaceSkill = { + metadata: { type: "inline"; name: string; description: string }; + relative_root: string; + package_root: string; +}; +export const capabilityRoot = "/environment/initialization/capabilities"; +const relativePath = (value: unknown): value is string => typeof value === "string" && value !== "." && + value.length > 0 && !value.startsWith("/") && !/[\\\x00-\x1f\x7f]/.test(value) && + posix.normalize(value) === value && !value.split("/").includes(".."); export function parseSkills(value: unknown): WorkspaceSkill[] { if (value === undefined) return []; @@ -11,34 +17,69 @@ export function parseSkills(value: unknown): WorkspaceSkill[] { 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); + Object.keys(skill).some(key => !["metadata", "relative_root", "package_root"].includes(key)) || + !relativePath(skill.relative_root) || !relativePath(skill.package_root) || + (skill.relative_root !== skill.package_root && !skill.relative_root.startsWith(skill.package_root + "/"))) throw new Error("invalid_request"); + const metadata = skill.metadata; + if (!metadata || typeof metadata !== "object" || Array.isArray(metadata) || + Object.keys(metadata).some(key => !["type", "name", "description"].includes(key)) || + metadata.type !== "inline" || typeof metadata.name !== "string" || !/^[a-z0-9]+(?:[-_][a-z0-9]+)*$/.test(metadata.name) || + metadata.name.length > 64 || typeof metadata.description !== "string" || !metadata.description || seen.has(metadata.name)) throw new Error("invalid_request"); + seen.add(metadata.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 { +// The common Runtime has already validated these immutable packages. Native +// explicit component paths must remain within the plugin root after realpath, +// so preserve the tree with hardlinks rather than outward directory symlinks. +function projectPackage(source: string, destination: string): void { + mkdirSync(destination, { recursive: true, mode: 0o700 }); + if (!lstatSync(destination).isDirectory()) throw new Error("invalid native Skill root"); + for (const entry of readdirSync(source, { withFileTypes: true })) { + const from = join(source, entry.name), to = join(destination, entry.name); + if (entry.isDirectory()) { + projectPackage(from, to); + } else if (entry.isFile()) { + try { linkSync(from, to); } + catch (error) { + if ((error as NodeJS.ErrnoException).code !== "EEXIST") throw error; + const sourceInfo = lstatSync(from), installed = lstatSync(to); + if (!installed.isFile() || installed.dev !== sourceInfo.dev || installed.ino !== sourceInfo.ino) throw new Error("invalid native Skill root"); + } + } else { throw new Error("invalid native Skill root"); } + } +} + +// Only the generated envelope is activated. Original plugin control files stay +// beneath content; exact Skill roots, not public plugin manifests, drive loading. +export function workspaceSkills(skills: readonly WorkspaceSkill[]): { paths: string[]; names: string[] } | undefined { if (!skills.length) return undefined; + const packages = new Map(); for (const skill of skills) { - const root = join(skillRoot, skill.name); - const body = readFileSync(join(root, "SKILL.md"), "utf8"); + const body = readFileSync(join(capabilityRoot, skill.relative_root, "SKILL.md"), "utf8"); if (/(?<=^|\s)!`[^`]+`/m.test(body) || /```!\s*\n?[\s\S]*?\n?```/.test(body)) { throw new Error("unsupported native Skill activation"); } + const selected = packages.get(skill.package_root) ?? []; + selected.push(skill); + packages.set(skill.package_root, selected); } - 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); + const result = { paths: [] as string[], names: [] as string[] }; + for (const [root, selected] of packages) { + const name = `environment-skills-${result.paths.length}`; + const path = join(capabilityRoot, "native", "claude", name); + projectPackage(join(capabilityRoot, root), join(path, "content")); + mkdirSync(join(path, ".claude-plugin"), { recursive: true, mode: 0o700 }); + const manifest = JSON.stringify({ name, description: "Environment Skills", skills: selected.map(skill => + "./" + posix.join("content", posix.relative(root, skill.relative_root))) }); + const target = join(path, ".claude-plugin", "plugin.json"); + try { writeFileSync(target, manifest, { flag: "wx", mode: 0o400 }); } + catch (error) { + if ((error as NodeJS.ErrnoException).code !== "EEXIST" || !lstatSync(target).isFile() || readFileSync(target, "utf8") !== manifest) throw error; + } + result.paths.push(path); + result.names.push(...selected.map(skill => `${name}:${skill.metadata.name}`)); } - return { path, names: skills.map(skill => `${pluginName}:${skill.name}`) }; + return result; } diff --git a/packages/claude-sdk-adapter/tests/workspace_skills.test.mjs b/packages/claude-sdk-adapter/tests/workspace_skills.test.mjs new file mode 100644 index 000000000..1b8296aa9 --- /dev/null +++ b/packages/claude-sdk-adapter/tests/workspace_skills.test.mjs @@ -0,0 +1,17 @@ +import assert from 'node:assert/strict'; +import test from 'node:test'; +import { parseSkills } from '../dist/workspace_skills.js'; + +test('Runtime Skill paths remain inside the declared package; public metadata is not a path descriptor', () => { + const skill = { metadata: { type: 'inline', name: 'proof', description: 'A proof.' }, relative_root: 'plugins/0/custom/proof', package_root: 'plugins/0' }; + assert.deepEqual(parseSkills([skill]), [skill]); + for (const invalid of [ + { ...skill, relative_root: '/private' }, + { ...skill, relative_root: 'plugins/0/../../private' }, + { ...skill, relative_root: 'plugins/1/proof' }, + { ...skill, package_root: '.' }, + { ...skill, metadata: { ...skill.metadata, name: '../escape' } }, + skill.metadata, + ]) assert.throws(() => parseSkills([invalid]), /invalid_request/); + assert.throws(() => parseSkills([skill, skill]), /invalid_request/); +}); diff --git a/scripts/build-agents-api.sh b/scripts/build-agents-api.sh index ff3471e85..b401c1497 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/agentnetwork internal/agentskill internal/obs/log services/agents-api \ + internal/agentnetwork internal/agentbundle internal/agentcapabilities internal/agentplugin internal/agentskill internal/obs/log services/agents-api \ | tar -C "$build_context" -xf - ( diff --git a/services/agents-api/internal/api/capability_archive.go b/services/agents-api/internal/api/capability_archive.go new file mode 100644 index 000000000..235059c53 --- /dev/null +++ b/services/agents-api/internal/api/capability_archive.go @@ -0,0 +1,26 @@ +package api + +import ( + "encoding/base64" + "encoding/json" + + "github.com/MiniMax-AI-Dev/parsar/internal/agentbundle" + "github.com/MiniMax-AI-Dev/parsar/services/agents-api/internal/store" +) + +// Skills and Plugins share the pinned inline capability source shape. +func decodeCapabilityArchive(raw json.RawMessage) ([]byte, error) { + var source struct { + Type string `json:"type"` + MediaType string `json:"media_type"` + Data string `json:"data"` + } + if decodeInputObject(raw, &source, "type", "media_type", "data") != nil || source.Type != "base64" || source.MediaType != "application/zip" || len(source.Data) > base64.StdEncoding.EncodedLen(agentbundle.MaxArchiveBytes) { + return nil, store.ErrInvalidInput + } + body, err := base64.StdEncoding.Strict().DecodeString(source.Data) + if err != nil { + return nil, store.ErrInvalidInput + } + return body, nil +} diff --git a/services/agents-api/internal/api/environment_plugins.go b/services/agents-api/internal/api/environment_plugins.go new file mode 100644 index 000000000..12308a6de --- /dev/null +++ b/services/agents-api/internal/api/environment_plugins.go @@ -0,0 +1,67 @@ +package api + +import ( + "encoding/json" + + "github.com/MiniMax-AI-Dev/parsar/internal/agentplugin" + "github.com/MiniMax-AI-Dev/parsar/services/agents-api/internal/store" +) + +func decodeEnvironmentPlugins(raw json.RawMessage) ([]store.EnvironmentPlugin, 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.EnvironmentPlugin, 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 + } + body, err := decodeCapabilityArchive(input.Source) + if err != nil { + return nil, err + } + result = append(result, store.EnvironmentPlugin{Metadata: agentplugin.Metadata{Type: input.Type, Name: input.Name, Description: input.Description}, Archive: body}) + } + return result, store.ValidateEnvironmentPlugins(result) +} + +func pluginResponse(plugins []agentplugin.Metadata) []json.RawMessage { + result := make([]json.RawMessage, 0, len(plugins)) + for _, plugin := range plugins { + raw, _ := json.Marshal(plugin) + result = append(result, raw) + } + return result +} + +func storedPlugins(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 agentplugin.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_plugins_test.go b/services/agents-api/internal/api/environment_plugins_test.go new file mode 100644 index 000000000..440268a54 --- /dev/null +++ b/services/agents-api/internal/api/environment_plugins_test.go @@ -0,0 +1,124 @@ +package api + +import ( + "archive/zip" + "bytes" + "encoding/base64" + "encoding/json" + "reflect" + "strings" + "testing" + + "github.com/MiniMax-AI-Dev/parsar/services/agents-api/internal/store" +) + +func pluginInput(t *testing.T) json.RawMessage { + t.Helper() + var archive bytes.Buffer + writer := zip.NewWriter(&archive) + for path, body := range map[string]string{ + ".codex-plugin/plugin.json": `{"name":"proof-plugin","description":"Two Skills.","skills":["./skills"]}`, + "skills/alpha/SKILL.md": "---\nname: alpha\ndescription: Alpha proof.\n---\nprivate-plugin-canary", + "skills/beta/SKILL.md": "---\nname: beta\ndescription: Beta proof.\n---\nUse ../../shared/data.txt", + "shared/data.txt": "private-plugin-resource", + } { + f, err := writer.Create("proof/" + path) + if err != nil { + t.Fatal(err) + } + if _, err = f.Write([]byte(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-plugin", "description": "Two Skills.", "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 TestPluginsSharedParsingConfidentialMetadataAndOverrides(t *testing.T) { + plugin := pluginInput(t) + raw := []byte(`{"plugins":[` + string(plugin) + `],"capability_directories":["/workspace/generated"]}`) + template, err := decodeTemplateInput(raw) + if err != nil || !template.SetPlugins || !template.SetDirectories || len(template.Initialization.Plugins) != 1 { + t.Fatal("template", err) + } + var decoded decodedSessionRequest + if err = json.Unmarshal([]byte(`{"agent":{"model":"test"},"environment":{"type":"openai_hosted",`+string(raw[1:])+`}`), &decoded); err != nil { + t.Fatal(err) + } + input, err := decoded.validated() + if err != nil { + t.Fatal(err) + } + if !reflect.DeepEqual(input.initialization.Plugins, template.Initialization.Plugins) { + t.Fatal("different installation inputs") + } + cfg, err := resolve(input, "tenant", "key", nil) + if err != nil || bytes.Contains(cfg, []byte(`"source"`)) || bytes.Contains(cfg, []byte("private-plugin")) { + t.Fatal("unsafe snapshot", err) + } + var snapshot struct { + Environment json.RawMessage `json:"environment"` + } + if err = json.Unmarshal(cfg, &snapshot); err != nil { + t.Fatal(err) + } + stored, err := storedEnvironment(snapshot.Environment) + if err != nil || len(stored.Plugins) != 1 || len(stored.CapabilityDirectories) != 1 { + t.Fatal("metadata", err) + } + env := store.Environment{ID: "environment", Status: "connected", Configuration: snapshot.Environment} + if response, err := environmentResponse(env); err != nil || len(response.Plugins) != 1 { + t.Fatal("environment response", err) + } + if response, err := hostedSessionEnvironment(env); err != nil || len(*response.Plugins) != 1 || len(*response.CapabilityDirectories) != 1 { + t.Fatal("session response", err) + } + lookup := &templateLookupStore{network: "enabled", plugins: template.Initialization.Plugins, directories: template.Initialization.CapabilityDirectories} + h := Handler{store: lookup} + for _, override := range []string{"", `,"plugins":[],"capability_directories":[]`} { + if err = json.Unmarshal([]byte(`{"agent":{"model":"test"},"environment":{"type":"openai_hosted","environment_template_id":"template"`+override+`}}`), &decoded); err != nil { + t.Fatal(err) + } + in, err := decoded.validated() + if err != nil { + t.Fatal(err) + } + intent, err := sessionCreationRequest(in, nil) + if err != nil || !bytes.Contains(intent, []byte("template")) { + t.Fatal("intent", err) + } + if err = h.resolveTemplateEnvironment(t.Context(), "tenant", &in); err != nil { + t.Fatal(err) + } + want := 1 + if override != "" { + want = 0 + } + if len(in.initialization.Plugins) != want || len(in.initialization.CapabilityDirectories) != want || len(in.Environment.Plugins) != want { + t.Fatal("inheritance/replacement") + } + } + for _, field := range []string{`"plugins":null`, `"capability_directories":null`} { + if _, _, _, err := decodeTemplateEnvironment([]byte(`{"type":"openai_hosted","environment_template_id":"template",` + field + `}`)); err == nil { + t.Fatal("unqualified null override") + } + } + for _, directory := range []string{`null`, `"/private"`, `"/workspace/../private"`, `"relative"`} { + if _, err := decodeTemplateInput([]byte(`{"capability_directories":[` + directory + `]}`)); err == nil { + t.Fatal("unsafe directory") + } + } + bad := strings.Replace(string(plugin), `"name":"proof-plugin"`, `"name":"mismatch"`, 1) + if _, err := decodeEnvironmentPlugins([]byte("[" + bad + "]")); err == nil { + t.Fatal("mismatched identity") + } + if _, err := decodeEnvironmentPlugins([]byte("[" + string(plugin) + "," + string(plugin) + "]")); err == nil { + t.Fatal("duplicate identity") + } +} diff --git a/services/agents-api/internal/api/environment_setup.go b/services/agents-api/internal/api/environment_setup.go index b6ac949f6..efdc66ad8 100644 --- a/services/agents-api/internal/api/environment_setup.go +++ b/services/agents-api/internal/api/environment_setup.go @@ -78,6 +78,22 @@ func decodeEnvironmentSetup(fields map[string]json.RawMessage) (store.Environmen if err != nil { return result, err } + result.Plugins, err = decodeEnvironmentPlugins(fields["plugins"]) + if err != nil { + return result, err + } + if raw, supplied := fields["capability_directories"]; supplied { + var entries []*string + if json.Unmarshal(raw, &entries) != nil { + return result, store.ErrInvalidInput + } + for _, entry := range entries { + if entry == nil { + return result, store.ErrInvalidInput + } + result.CapabilityDirectories = append(result.CapabilityDirectories, *entry) + } + } return result, result.Validate() } diff --git a/services/agents-api/internal/api/environment_skills.go b/services/agents-api/internal/api/environment_skills.go index 71383d25a..328a9b992 100644 --- a/services/agents-api/internal/api/environment_skills.go +++ b/services/agents-api/internal/api/environment_skills.go @@ -2,10 +2,8 @@ package api import ( "bytes" - "encoding/base64" "encoding/json" - "github.com/MiniMax-AI-Dev/parsar/internal/agentskill" "github.com/MiniMax-AI-Dev/parsar/services/agents-api/internal/store" ) @@ -53,17 +51,9 @@ func decodeEnvironmentSkills(raw json.RawMessage) ([]store.EnvironmentSkill, err 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) + body, err := decodeCapabilityArchive(input.Source) if err != nil { - return nil, store.ErrInvalidInput + return nil, err } result = append(result, store.EnvironmentSkill{Metadata: store.EnvironmentSkillMetadata{Type: input.Type, Name: input.Name, Description: input.Description}, Archive: body}) } diff --git a/services/agents-api/internal/api/environment_templates.go b/services/agents-api/internal/api/environment_templates.go index b79209435..8c16603a9 100644 --- a/services/agents-api/internal/api/environment_templates.go +++ b/services/agents-api/internal/api/environment_templates.go @@ -39,6 +39,8 @@ func decodeTemplateInput(raw []byte) (store.EnvironmentTemplateInput, error) { _, in.SetSetup = fields["setup_commands"] _, in.SetPackages = fields["packages"] _, in.SetSkills = fields["skills"] + _, in.SetPlugins = fields["plugins"] + _, in.SetDirectories = fields["capability_directories"] var setupErr error in.Initialization, setupErr = decodeEnvironmentSetup(fields) if setupErr != nil { @@ -64,7 +66,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: append([]string{}, t.AllowedDomains...)}, Packages: packageMetadata(&t.Packages), Files: templateFileResponse(t.Files), Plugins: []json.RawMessage{}, Skills: skillResponse(t.Skills)} + return v1.EnvironmentTemplate{ID: t.ID, Object: "agent.environment.template", Name: t.Name, CreatedAt: t.CreatedAt.Unix(), UpdatedAt: t.UpdatedAt.Unix(), CapabilityDirectories: append([]string{}, t.CapabilityDirectories...), Network: v1.EnvironmentNetwork{Access: t.NetworkAccess, AllowedDomains: append([]string{}, t.AllowedDomains...)}, Packages: packageMetadata(&t.Packages), Files: templateFileResponse(t.Files), Plugins: pluginResponse(t.Plugins), Skills: skillResponse(t.Skills)} } func templateNoQuery(w http.ResponseWriter, r *http.Request) bool { @@ -85,14 +87,14 @@ func readTemplateInput(w http.ResponseWriter, r *http.Request) (store.Environmen } in, err := decodeTemplateInput(raw) if err != nil { - writeError(w, http.StatusBadRequest, "unsupported_or_invalid_configuration", "Template fields are invalid or require unsupported initialization. Name, enabled/disabled or exact-domain restricted network, initial files, env, system/npm/Python packages, setup commands and inline/referenced Skill ZIPs are supported.") + writeError(w, http.StatusBadRequest, "unsupported_or_invalid_configuration", "Template fields are invalid or require unsupported initialization. Name, enabled/disabled or exact-domain restricted network, initial files, env, system/npm/Python packages, setup commands inline/referenced Skill ZIPs, skill-only Plugin ZIPs and workspace capability directories are supported.") return in, false } return in, true } // @Summary Create an Environment Template -// @Description Saves tenant-owned hosted configuration. Supports nullable name, enabled/disabled or exact-domain restricted network, initial inline/file_id files, confidential env, ordered setup_commands, system/npm/Python packages and inline/referenced Skill ZIPs. Omitted/null network defaults to enabled. Restricted network requires 1–100 exact ASCII hostnames; other host forms and populated unsupported installations are rejected before persistence without echoing input. No compute is allocated. Exact hosted error/retry semantics remain unverified. +// @Description Saves tenant-owned hosted configuration. Supports nullable name, enabled/disabled or exact-domain restricted network, initial inline/file_id files, confidential env, ordered setup_commands, system/npm/Python packages inline/referenced Skill ZIPs, skill-only Plugin ZIPs and workspace-contained capability directories. Omitted/null network defaults to enabled. Restricted network requires 1–100 exact ASCII hostnames; other host forms and populated unsupported installations are rejected before persistence without echoing input. No compute is allocated. Exact hosted error/retry semantics remain unverified. // @Tags Environment Templates // @Accept json // @Produce json @@ -138,7 +140,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. 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. +// @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. Plugins and capability directories replace as lists; null/empty clears. Plugin archives are encrypted and omitted from responses. Capability directories are snapshotted after setup; Plugin MCP activation remains unsupported. Exact hosted no-op timestamp behavior remains unverified. // @Tags Environment Templates // @Accept json // @Produce json diff --git a/services/agents-api/internal/api/environment_templates_test.go b/services/agents-api/internal/api/environment_templates_test.go index aef5e4be0..e2b35f6cc 100644 --- a/services/agents-api/internal/api/environment_templates_test.go +++ b/services/agents-api/internal/api/environment_templates_test.go @@ -17,7 +17,7 @@ func TestTemplateConfigurationRejectsUnqualifiedInputs(t *testing.T) { t.Fatalf("supported input: %s: %v", raw, err) } } - for _, raw := range []string{`null`, `[]`, `{"name":""}`, `{"name":42}`, `{"type":"openai_hosted"}`, `{"env":{"PATH":"confidential-canary"}}`, `{"setup_commands":[{"command":"confidential-canary","cwd":"relative"}]}`, `{"packages":{"system":["-o"]}}`, `{"packages":{"system":[""]}}`, `{"packages":{"system":[null]}}`, `{"plugins":[{}]}`, `{"skills":[{}]}`, `{"capability_directories":["/workspace"]}`} { + for _, raw := range []string{`null`, `[]`, `{"name":""}`, `{"name":42}`, `{"type":"openai_hosted"}`, `{"env":{"PATH":"confidential-canary"}}`, `{"setup_commands":[{"command":"confidential-canary","cwd":"relative"}]}`, `{"packages":{"system":["-o"]}}`, `{"packages":{"system":[""]}}`, `{"packages":{"system":[null]}}`, `{"plugins":[{}]}`, `{"skills":[{}]}`, `{"capability_directories":["/private"]}`} { if _, err := decodeTemplateInput([]byte(raw)); err == nil { t.Fatalf("unsupported input accepted: %s", raw) } @@ -35,15 +35,17 @@ func TestTemplateConfigurationRejectsUnqualifiedInputs(t *testing.T) { type templateLookupStore struct { ResourceStore - network string - domains []string - tenant string - skills []store.EnvironmentSkill + network string + domains []string + tenant string + skills []store.EnvironmentSkill + plugins []store.EnvironmentPlugin + directories []string } func (s *templateLookupStore) ResolveEnvironmentTemplate(_ context.Context, tenant, id string) (store.EnvironmentTemplate, []store.InitialFile, error) { s.tenant = tenant - return store.EnvironmentTemplate{ID: id, NetworkAccess: s.network, AllowedDomains: s.domains, Initialization: store.EnvironmentSetup{Skills: s.skills}}, nil, nil + return store.EnvironmentTemplate{ID: id, NetworkAccess: s.network, AllowedDomains: s.domains, Initialization: store.EnvironmentSetup{Skills: s.skills, Plugins: s.plugins, CapabilityDirectories: s.directories}}, nil, nil } func TestTemplateResolutionAndCreationIntent(t *testing.T) { diff --git a/services/agents-api/internal/api/environments.go b/services/agents-api/internal/api/environments.go index eefec6487..86f386f1f 100644 --- a/services/agents-api/internal/api/environments.go +++ b/services/agents-api/internal/api/environments.go @@ -11,7 +11,7 @@ import ( ) // @Summary Retrieve an execution Environment -// @Description Returns durable connection status and safe installed metadata for supported self_hosted and basic openai_hosted profiles. Initial files expose frozen safe metadata without content; empty plugins/skills describe the absence of API-managed installations, not the contents or discovered capabilities of the caller's machine. Unsupported installation configurations remain implementation gaps. This read does not prepare execution, start compute or require an enabled execution worker. Session deletion removes the associated Environment from public reads; project-shared read authorization is unchanged. Connection status does not prove native readiness or process quiescence. +// @Description Returns durable connection status and safe installed metadata for supported self_hosted and basic openai_hosted profiles. Initial files expose frozen safe metadata without content; Plugin/Skill entries expose only safe configured installation metadata. Capability-directory discoveries are not added to those arrays. Unsupported installation configurations remain implementation gaps. This read does not prepare execution, start compute or require an enabled execution worker. Session deletion removes the associated Environment from public reads; project-shared read authorization is unchanged. Connection status does not prove native readiness or process quiescence. // @Tags Environments // @Produce json // @Security BearerAuth @@ -40,7 +40,7 @@ func (h *Handler) getEnvironment(w http.ResponseWriter, r *http.Request) { func environmentResponse(environment store.Environment) (v1.EnvironmentInfo, error) { configuration, err := storedEnvironment(environment.Configuration) - if err != nil || (configuration.Type != "self_hosted" && configuration.Type != "openai_hosted") || len(configuration.CapabilityDirectories) != 0 || environment.ID == "" { + if err != nil || (configuration.Type != "self_hosted" && configuration.Type != "openai_hosted") || (configuration.Type == "self_hosted" && len(configuration.CapabilityDirectories) != 0) || environment.ID == "" { return v1.EnvironmentInfo{}, errors.New("unsupported stored environment metadata configuration") } switch environment.Status { @@ -55,8 +55,11 @@ func environmentResponse(environment store.Environment) (v1.EnvironmentInfo, err if configuration.Skills == nil { configuration.Skills = []json.RawMessage{} } + if configuration.Plugins == nil { + configuration.Plugins = []json.RawMessage{} + } return v1.EnvironmentInfo{ ID: environment.ID, Object: "agent.environment", Type: configuration.Type, Status: environment.Status, - Files: files, Plugins: []json.RawMessage{}, Skills: configuration.Skills, + Files: files, Plugins: configuration.Plugins, 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 3cb3d4de3..ec6e6430e 100644 --- a/services/agents-api/internal/api/hosted_environment.go +++ b/services/agents-api/internal/api/hosted_environment.go @@ -41,11 +41,10 @@ func decodeHostedEnvironment(raw json.RawMessage) (*v1.Environment, error) { env.Files = initialFileResponse(files) 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 - } + case "plugins": + env.Plugins = pluginResponse(setup.PluginMetadata()) + case "capability_directories": + env.CapabilityDirectories = append([]string{}, setup.CapabilityDirectories...) case "env", "setup_commands": // Confidential values remain in the separate initialization snapshot. case "packages": @@ -64,15 +63,14 @@ func hostedSessionEnvironment(environment store.Environment) (v1.SessionEnvironm if err != nil || cfg.Type != "openai_hosted" { return v1.SessionEnvironment{}, store.ErrInvalidInput } - empty := []json.RawMessage{} files := cfg.Files if files == nil { files = []json.RawMessage{} } - directories := []string{} + directories := append([]string{}, cfg.CapabilityDirectories...) return v1.SessionEnvironment{ID: environment.ID, Type: cfg.Type, CapabilityDirectories: &directories, Network: &v1.EnvironmentNetwork{Access: cfg.Network.Access, AllowedDomains: append([]string{}, cfg.Network.AllowedDomains...)}, - Packages: func() *v1.EnvironmentPackages { value := packageMetadata(cfg.Packages); return &value }(), Files: &files, Plugins: &empty, Skills: &cfg.Skills}, nil + Packages: func() *v1.EnvironmentPackages { value := packageMetadata(cfg.Packages); return &value }(), Files: &files, Plugins: &cfg.Plugins, Skills: &cfg.Skills}, nil } // WithHostedEnvironments enables admission only for an operator-composed, @@ -125,6 +123,11 @@ func storedEnvironment(raw json.RawMessage) (*v1.Environment, error) { if err != nil { return nil, err } + plugins, err := storedPlugins(fields["plugins"]) + if err != nil { + return nil, err + } + delete(fields, "plugins") delete(fields, "skills") delete(fields, "files") base, err := json.Marshal(fields) @@ -137,5 +140,6 @@ func storedEnvironment(raw json.RawMessage) (*v1.Environment, error) { } cfg.Files = files cfg.Skills = skills + cfg.Plugins = plugins return cfg, nil } diff --git a/services/agents-api/internal/api/hosted_environment_test.go b/services/agents-api/internal/api/hosted_environment_test.go index a48e2e34e..18d905fcc 100644 --- a/services/agents-api/internal/api/hosted_environment_test.go +++ b/services/agents-api/internal/api/hosted_environment_test.go @@ -34,7 +34,7 @@ func TestHostedEnvironmentDefaultsAndExplicitGaps(t *testing.T) { `"network":{"access":"disabled","allowed_domains":["example.com"]}`, `"network":{"access":"enabled","unknown":true}`, `"env":{"SECRET":null}`, `"files":[{}]`, `"packages":{"system":[null]}`, `"packages":{"unknown":[]}`, `"plugins":[{}]`, `"skills":[{}]`, `"setup_commands":["echo test"]`, - `"capability_directories":["/workspace"]`, `"template_id":"template"`, `"workspace_directory":"/workspace"`, + `"capability_directories":["/private"]`, `"template_id":"template"`, `"workspace_directory":"/workspace"`, `"files":{}`, `"env":[]`, `"packages":[]`, `"network":[]`, `"unknown":null`, } { if _, err := decodeSessionEnvironment(json.RawMessage(`{"type":"openai_hosted",` + field + `}`)); err == nil { diff --git a/services/agents-api/internal/api/session_template.go b/services/agents-api/internal/api/session_template.go index 4707cedb9..bf819b6c9 100644 --- a/services/agents-api/internal/api/session_template.go +++ b/services/agents-api/internal/api/session_template.go @@ -30,8 +30,10 @@ func decodeTemplateEnvironment(raw json.RawMessage) (*v1.Environment, string, js if value, exists := fields["network"]; exists && bytes.Equal(bytes.TrimSpace(value), []byte("null")) { return nil, "", nil, store.ErrInvalidInput } - if value, exists := fields["skills"]; exists && bytes.Equal(bytes.TrimSpace(value), []byte("null")) { - return nil, "", nil, store.ErrInvalidInput + for _, name := range []string{"skills", "plugins", "capability_directories"} { + if value, exists := fields[name]; exists && bytes.Equal(bytes.TrimSpace(value), []byte("null")) { + return nil, "", nil, store.ErrInvalidInput + } } for _, name := range []string{"files", "env", "setup_commands", "packages"} { if _, supplied := fields[name]; supplied { @@ -70,8 +72,20 @@ func (h *Handler) resolveTemplateEnvironment(ctx context.Context, tenant string, if _, supplied := fields["skills"]; !supplied { skills = template.Initialization.Skills } + plugins := input.initialization.Plugins + if _, supplied := fields["plugins"]; !supplied { + plugins = template.Initialization.Plugins + } + directories := input.initialization.CapabilityDirectories + if _, supplied := fields["capability_directories"]; !supplied { + directories = template.Initialization.CapabilityDirectories + } input.initialization = template.Initialization input.initialization.Skills = skills + input.initialization.Plugins = plugins + input.initialization.CapabilityDirectories = directories + input.Environment.Plugins = pluginResponse(input.initialization.PluginMetadata()) + input.Environment.CapabilityDirectories = append([]string{}, directories...) input.Environment.Skills = skillResponse(input.initialization.SkillMetadata()) packages := template.Initialization.PackageMetadata() input.Environment.Packages = &packages diff --git a/services/agents-api/internal/credentialcrypto/environment_setup.go b/services/agents-api/internal/credentialcrypto/environment_setup.go index c7cbd9b8c..8955d3394 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" && binding.Field != "skills") { + if (binding.Resource != "environment_template" && binding.Resource != "session") || (binding.Field != "env" && binding.Field != "setup_commands" && binding.Field != "initialization" && binding.Field != "skills" && binding.Field != "plugins") { return nil, errInvalidBinding } for _, value := range []string{binding.TenantID, binding.OwnerID} { diff --git a/services/agents-api/internal/db/queries/environment_setup.sql b/services/agents-api/internal/db/queries/environment_setup.sql index b02447789..9c53690e2 100644 --- a/services/agents-api/internal/db/queries/environment_setup.sql +++ b/services/agents-api/internal/db/queries/environment_setup.sql @@ -6,5 +6,11 @@ SELECT f.contents FROM sessions s LEFT JOIN environment_setups f ON s.id = f.ses WHERE s.tenant_id = $1 AND s.id = $2 AND s.deleted_at IS NULL; -- name: SetSessionSetupMetadata :one -UPDATE sessions SET configuration = jsonb_set(jsonb_set(jsonb_set(configuration, '{environment,packages}', sqlc.arg(packages)::jsonb), '{environment,skills}', sqlc.arg(skills)::jsonb), '{environment,initialization}', 'true'::jsonb) +UPDATE sessions SET configuration = jsonb_set(configuration, '{environment}', + (configuration->'environment') || jsonb_build_object( + 'packages', sqlc.arg(packages)::jsonb, + 'skills', sqlc.arg(skills)::jsonb, + 'plugins', sqlc.arg(plugins)::jsonb, + 'capability_directories', sqlc.arg(capability_directories)::jsonb, + 'initialization', true)) WHERE id = $1 RETURNING *; diff --git a/services/agents-api/internal/db/queries/environment_templates.sql b/services/agents-api/internal/db/queries/environment_templates.sql index 1eb007e33..8fde13271 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, skills, skill_contents, network_allowed_domains) -VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12) RETURNING id, tenant_id, name, network_access, network_allowed_domains, created_at, updated_at, files, packages, skills; +INSERT INTO environment_templates (id, tenant_id, name, network_access, files, file_contents, packages, env_contents, setup_contents, skills, skill_contents, network_allowed_domains, plugins, plugin_contents, capability_directories) +VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13, $14, $15) RETURNING id, tenant_id, name, network_access, network_allowed_domains, created_at, updated_at, files, packages, skills, plugins, capability_directories; -- name: GetEnvironmentTemplate :one -SELECT id, tenant_id, name, network_access, network_allowed_domains, created_at, updated_at, files, packages, skills FROM environment_templates WHERE tenant_id = $1 AND id = $2; +SELECT id, tenant_id, name, network_access, network_allowed_domains, created_at, updated_at, files, packages, skills, plugins, capability_directories FROM environment_templates WHERE tenant_id = $1 AND id = $2; -- name: UpdateEnvironmentTemplate :one UPDATE environment_templates SET @@ -17,15 +17,18 @@ UPDATE environment_templates SET 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, + plugins = CASE WHEN sqlc.arg(set_plugins)::boolean THEN sqlc.arg(plugins)::jsonb ELSE plugins END, + plugin_contents = CASE WHEN sqlc.arg(set_plugins)::boolean THEN sqlc.narg(plugin_contents)::bytea ELSE plugin_contents END, + capability_directories = CASE WHEN sqlc.arg(set_directories)::boolean THEN sqlc.arg(capability_directories)::text[] ELSE capability_directories END, updated_at = clock_timestamp() WHERE tenant_id = sqlc.arg(tenant_id) AND id = sqlc.arg(id) -RETURNING id, tenant_id, name, network_access, network_allowed_domains, created_at, updated_at, files, packages, skills; +RETURNING id, tenant_id, name, network_access, network_allowed_domains, created_at, updated_at, files, packages, skills, plugins, capability_directories; -- 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, network_allowed_domains, created_at, updated_at, files, packages, skills FROM environment_templates +SELECT id, tenant_id, name, network_access, network_allowed_domains, created_at, updated_at, files, packages, skills, plugins, capability_directories 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_setup.sql.go b/services/agents-api/internal/db/sqlc/environment_setup.sql.go index d7c2945b2..c72589537 100644 --- a/services/agents-api/internal/db/sqlc/environment_setup.sql.go +++ b/services/agents-api/internal/db/sqlc/environment_setup.sql.go @@ -43,18 +43,32 @@ func (q *Queries) GetEnvironmentSetup(ctx context.Context, arg GetEnvironmentSet } const setSessionSetupMetadata = `-- name: SetSessionSetupMetadata :one -UPDATE sessions SET configuration = jsonb_set(jsonb_set(jsonb_set(configuration, '{environment,packages}', $2::jsonb), '{environment,skills}', $3::jsonb), '{environment,initialization}', 'true'::jsonb) +UPDATE sessions SET configuration = jsonb_set(configuration, '{environment}', + (configuration->'environment') || jsonb_build_object( + 'packages', $2::jsonb, + 'skills', $3::jsonb, + 'plugins', $4::jsonb, + 'capability_directories', $5::jsonb, + 'initialization', true)) WHERE id = $1 RETURNING id, tenant_id, engine, metadata, idempotency_key, request_hash, created_at, configuration, event_sequence, creation_request_hash, deleted_at, creator_kind, creator_id ` type SetSessionSetupMetadataParams struct { - ID pgtype.UUID `json:"id"` - Packages []byte `json:"packages"` - Skills []byte `json:"skills"` + ID pgtype.UUID `json:"id"` + Packages []byte `json:"packages"` + Skills []byte `json:"skills"` + Plugins []byte `json:"plugins"` + CapabilityDirectories []byte `json:"capability_directories"` } func (q *Queries) SetSessionSetupMetadata(ctx context.Context, arg SetSessionSetupMetadataParams) (Session, error) { - row := q.db.QueryRow(ctx, setSessionSetupMetadata, arg.ID, arg.Packages, arg.Skills) + row := q.db.QueryRow(ctx, setSessionSetupMetadata, + arg.ID, + arg.Packages, + arg.Skills, + arg.Plugins, + arg.CapabilityDirectories, + ) var i Session err := row.Scan( &i.ID, 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 b468190ce..12aa12e40 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, skills, skill_contents, network_allowed_domains) -VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12) RETURNING id, tenant_id, name, network_access, network_allowed_domains, created_at, updated_at, files, packages, skills +INSERT INTO environment_templates (id, tenant_id, name, network_access, files, file_contents, packages, env_contents, setup_contents, skills, skill_contents, network_allowed_domains, plugins, plugin_contents, capability_directories) +VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13, $14, $15) RETURNING id, tenant_id, name, network_access, network_allowed_domains, created_at, updated_at, files, packages, skills, plugins, capability_directories ` type CreateEnvironmentTemplateParams struct { @@ -29,6 +29,9 @@ type CreateEnvironmentTemplateParams struct { Skills []byte `json:"skills"` SkillContents []byte `json:"skill_contents"` NetworkAllowedDomains []string `json:"network_allowed_domains"` + Plugins []byte `json:"plugins"` + PluginContents []byte `json:"plugin_contents"` + CapabilityDirectories []string `json:"capability_directories"` } type CreateEnvironmentTemplateRow struct { @@ -42,6 +45,8 @@ type CreateEnvironmentTemplateRow struct { Files []byte `json:"files"` Packages []byte `json:"packages"` Skills []byte `json:"skills"` + Plugins []byte `json:"plugins"` + CapabilityDirectories []string `json:"capability_directories"` } func (q *Queries) CreateEnvironmentTemplate(ctx context.Context, arg CreateEnvironmentTemplateParams) (CreateEnvironmentTemplateRow, error) { @@ -58,6 +63,9 @@ func (q *Queries) CreateEnvironmentTemplate(ctx context.Context, arg CreateEnvir arg.Skills, arg.SkillContents, arg.NetworkAllowedDomains, + arg.Plugins, + arg.PluginContents, + arg.CapabilityDirectories, ) var i CreateEnvironmentTemplateRow err := row.Scan( @@ -71,6 +79,8 @@ func (q *Queries) CreateEnvironmentTemplate(ctx context.Context, arg CreateEnvir &i.Files, &i.Packages, &i.Skills, + &i.Plugins, + &i.CapabilityDirectories, ) return i, err } @@ -92,7 +102,7 @@ func (q *Queries) DeleteEnvironmentTemplate(ctx context.Context, arg DeleteEnvir } const getEnvironmentTemplate = `-- name: GetEnvironmentTemplate :one -SELECT id, tenant_id, name, network_access, network_allowed_domains, created_at, updated_at, files, packages, skills FROM environment_templates WHERE tenant_id = $1 AND id = $2 +SELECT id, tenant_id, name, network_access, network_allowed_domains, created_at, updated_at, files, packages, skills, plugins, capability_directories FROM environment_templates WHERE tenant_id = $1 AND id = $2 ` type GetEnvironmentTemplateParams struct { @@ -111,6 +121,8 @@ type GetEnvironmentTemplateRow struct { Files []byte `json:"files"` Packages []byte `json:"packages"` Skills []byte `json:"skills"` + Plugins []byte `json:"plugins"` + CapabilityDirectories []string `json:"capability_directories"` } func (q *Queries) GetEnvironmentTemplate(ctx context.Context, arg GetEnvironmentTemplateParams) (GetEnvironmentTemplateRow, error) { @@ -127,12 +139,14 @@ func (q *Queries) GetEnvironmentTemplate(ctx context.Context, arg GetEnvironment &i.Files, &i.Packages, &i.Skills, + &i.Plugins, + &i.CapabilityDirectories, ) return i, err } const listEnvironmentTemplates = `-- name: ListEnvironmentTemplates :many -SELECT id, tenant_id, name, network_access, network_allowed_domains, created_at, updated_at, files, packages, skills FROM environment_templates +SELECT id, tenant_id, name, network_access, network_allowed_domains, created_at, updated_at, files, packages, skills, plugins, capability_directories FROM environment_templates WHERE tenant_id = $1 AND ($2::timestamptz IS NULL OR (NOT $3::boolean AND (created_at, id) < ($2::timestamptz, $4::uuid)) @@ -164,6 +178,8 @@ type ListEnvironmentTemplatesRow struct { Files []byte `json:"files"` Packages []byte `json:"packages"` Skills []byte `json:"skills"` + Plugins []byte `json:"plugins"` + CapabilityDirectories []string `json:"capability_directories"` } func (q *Queries) ListEnvironmentTemplates(ctx context.Context, arg ListEnvironmentTemplatesParams) ([]ListEnvironmentTemplatesRow, error) { @@ -192,6 +208,8 @@ func (q *Queries) ListEnvironmentTemplates(ctx context.Context, arg ListEnvironm &i.Files, &i.Packages, &i.Skills, + &i.Plugins, + &i.CapabilityDirectories, ); err != nil { return nil, err } @@ -204,7 +222,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, skills, skill_contents, network_allowed_domains 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, network_allowed_domains, plugins, plugin_contents, capability_directories FROM environment_templates WHERE tenant_id = $1 AND id = $2 ` type ResolveEnvironmentTemplateParams struct { @@ -230,6 +248,9 @@ func (q *Queries) ResolveEnvironmentTemplate(ctx context.Context, arg ResolveEnv &i.Skills, &i.SkillContents, &i.NetworkAllowedDomains, + &i.Plugins, + &i.PluginContents, + &i.CapabilityDirectories, ) return i, err } @@ -246,9 +267,12 @@ UPDATE environment_templates SET setup_contents = CASE WHEN $13::boolean THEN $14::bytea ELSE setup_contents END, skills = CASE WHEN $15::boolean THEN $16::jsonb ELSE skills END, skill_contents = CASE WHEN $15::boolean THEN $17::bytea ELSE skill_contents END, + plugins = CASE WHEN $18::boolean THEN $19::jsonb ELSE plugins END, + plugin_contents = CASE WHEN $18::boolean THEN $20::bytea ELSE plugin_contents END, + capability_directories = CASE WHEN $21::boolean THEN $22::text[] ELSE capability_directories END, updated_at = clock_timestamp() -WHERE tenant_id = $18 AND id = $19 -RETURNING id, tenant_id, name, network_access, network_allowed_domains, created_at, updated_at, files, packages, skills +WHERE tenant_id = $23 AND id = $24 +RETURNING id, tenant_id, name, network_access, network_allowed_domains, created_at, updated_at, files, packages, skills, plugins, capability_directories ` type UpdateEnvironmentTemplateParams struct { @@ -269,6 +293,11 @@ type UpdateEnvironmentTemplateParams struct { SetSkills bool `json:"set_skills"` Skills []byte `json:"skills"` SkillContents []byte `json:"skill_contents"` + SetPlugins bool `json:"set_plugins"` + Plugins []byte `json:"plugins"` + PluginContents []byte `json:"plugin_contents"` + SetDirectories bool `json:"set_directories"` + CapabilityDirectories []string `json:"capability_directories"` TenantID pgtype.UUID `json:"tenant_id"` ID pgtype.UUID `json:"id"` } @@ -284,6 +313,8 @@ type UpdateEnvironmentTemplateRow struct { Files []byte `json:"files"` Packages []byte `json:"packages"` Skills []byte `json:"skills"` + Plugins []byte `json:"plugins"` + CapabilityDirectories []string `json:"capability_directories"` } func (q *Queries) UpdateEnvironmentTemplate(ctx context.Context, arg UpdateEnvironmentTemplateParams) (UpdateEnvironmentTemplateRow, error) { @@ -305,6 +336,11 @@ func (q *Queries) UpdateEnvironmentTemplate(ctx context.Context, arg UpdateEnvir arg.SetSkills, arg.Skills, arg.SkillContents, + arg.SetPlugins, + arg.Plugins, + arg.PluginContents, + arg.SetDirectories, + arg.CapabilityDirectories, arg.TenantID, arg.ID, ) @@ -320,6 +356,8 @@ func (q *Queries) UpdateEnvironmentTemplate(ctx context.Context, arg UpdateEnvir &i.Files, &i.Packages, &i.Skills, + &i.Plugins, + &i.CapabilityDirectories, ) return i, err } diff --git a/services/agents-api/internal/db/sqlc/models.go b/services/agents-api/internal/db/sqlc/models.go index f07c8e5f9..6480e227d 100644 --- a/services/agents-api/internal/db/sqlc/models.go +++ b/services/agents-api/internal/db/sqlc/models.go @@ -95,6 +95,9 @@ type EnvironmentTemplate struct { Skills []byte `json:"skills"` SkillContents []byte `json:"skill_contents"` NetworkAllowedDomains []string `json:"network_allowed_domains"` + Plugins []byte `json:"plugins"` + PluginContents []byte `json:"plugin_contents"` + CapabilityDirectories []string `json:"capability_directories"` } type ExecutionProjectScope struct { diff --git a/services/agents-api/internal/execution/environment_placement.go b/services/agents-api/internal/execution/environment_placement.go index 8ffefa252..fdc7f6525 100644 --- a/services/agents-api/internal/execution/environment_placement.go +++ b/services/agents-api/internal/execution/environment_placement.go @@ -7,12 +7,15 @@ import ( "errors" v1 "github.com/MiniMax-AI-Dev/parsar/contracts/agents-api/v1" + "github.com/MiniMax-AI-Dev/parsar/internal/agentcapabilities" "github.com/MiniMax-AI-Dev/parsar/internal/agentdaemon/proto" "github.com/MiniMax-AI-Dev/parsar/internal/agentnetwork" + "github.com/MiniMax-AI-Dev/parsar/internal/agentplugin" "github.com/MiniMax-AI-Dev/parsar/services/agents-api/internal/store" ) type environmentPlacement struct { + Plugins []agentplugin.Metadata `json:"plugins,omitempty"` Skills []store.EnvironmentSkillMetadata `json:"skills,omitempty"` Type string `json:"type"` ToolEnvironment bool `json:"initialization,omitempty"` @@ -43,6 +46,7 @@ func parseEnvironmentPlacement(configuration json.RawMessage) (environmentPlacem case "openai_hosted": // Stored policy is shared by preparation and provider bootstrap. var local struct { + Plugins []agentplugin.Metadata `json:"plugins,omitempty"` Skills []store.EnvironmentSkillMetadata `json:"skills,omitempty"` Files []store.InitialFileMetadata `json:"files"` Packages *v1.EnvironmentPackages `json:"packages,omitempty"` @@ -56,7 +60,7 @@ func parseEnvironmentPlacement(configuration json.RawMessage) (environmentPlacem } decoder := json.NewDecoder(bytes.NewReader(configuration)) decoder.DisallowUnknownFields() - if decoder.Decode(&local) == nil && len(local.CapabilityDirectories) == 0 { + if decoder.Decode(&local) == nil && agentcapabilities.ValidateDirectories(local.CapabilityDirectories) == nil { placement.SystemPackages = local.Packages != nil && len(local.Packages.System) > 0 placement.NetworkAccess = "enabled" if local.Network != nil { @@ -91,12 +95,12 @@ func (d *Dispatcher) configurePreparedEnvironment(ctx context.Context, session s if placement.SystemPackages && !placement.ToolEnvironment { return nil, store.ErrInvalidInput } - req.LocalEnvironment = &proto.LocalEnvironment{ID: environment.ID, ToolEnvironment: placement.ToolEnvironment, SystemPackages: placement.SystemPackages} + req.LocalEnvironment = &proto.LocalEnvironment{ID: environment.ID, Capabilities: len(placement.Plugins)+len(placement.CapabilityDirectories) > 0, ToolEnvironment: placement.ToolEnvironment, SystemPackages: placement.SystemPackages} for _, metadata := range placement.Skills { if store.ValidateInstalledSkillMetadata(metadata) != nil { return nil, store.ErrInvalidInput } - req.LocalEnvironment.Skills = append(req.LocalEnvironment.Skills, (store.EnvironmentSkill{Metadata: metadata}).InstallationMetadata()) + req.LocalEnvironment.Capabilities = true } req.LocalEnvironment.NetworkAccess = placement.NetworkAccess req.LocalEnvironment.AllowedDomains = append([]string(nil), placement.AllowedDomains...) diff --git a/services/agents-api/internal/execution/environment_placement_test.go b/services/agents-api/internal/execution/environment_placement_test.go index 2cb535ca8..d0f0ab80d 100644 --- a/services/agents-api/internal/execution/environment_placement_test.go +++ b/services/agents-api/internal/execution/environment_placement_test.go @@ -17,7 +17,7 @@ func TestSkillReferenceIdentityStopsAtCoreBoundary(t *testing.T) { Configuration: []byte(`{"type":"openai_hosted","initialization":true,"skills":[{"type":"skill_reference","skill_id":"skill-private","version":"1","name":"proof","description":"A proof."}]}`)} var request proto.PromptRequestPayload _, err := (&Dispatcher{}).configurePreparedEnvironment(t.Context(), session, environment, store.ExecutionDevice{EnvironmentID: environment.ID}, &request) - if err != nil || request.LocalEnvironment == nil || len(request.LocalEnvironment.Skills) != 1 || request.LocalEnvironment.Skills[0].Name != "proof" || request.LocalEnvironment.Skills[0].Type != "inline" { + if err != nil || request.LocalEnvironment == nil || !request.LocalEnvironment.Capabilities || len(request.LocalEnvironment.Skills) != 0 { t.Fatal("resolved Skill did not use the common installation descriptor", err) } raw, err := json.Marshal(request.LocalEnvironment) diff --git a/services/agents-api/internal/execution/runtime_setup.go b/services/agents-api/internal/execution/runtime_setup.go index 622e86e97..8b839fbb8 100644 --- a/services/agents-api/internal/execution/runtime_setup.go +++ b/services/agents-api/internal/execution/runtime_setup.go @@ -5,6 +5,8 @@ import ( "encoding/json" "errors" + "github.com/MiniMax-AI-Dev/parsar/internal/agentbundle" + "github.com/MiniMax-AI-Dev/parsar/internal/agentcapabilities" "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" @@ -13,16 +15,17 @@ import ( // runtimeSetupOperation is the packaged initializer's confidential stdin contract. // Public templates and native harness configuration never cross this boundary. type runtimeSetupOperation struct { - Skill *store.EnvironmentSkill `json:"-"` - Name string `json:"name,omitempty"` - Files []agentskill.File `json:"files,omitempty"` - Version int `json:"version"` - Action string `json:"action"` - Network string `json:"network,omitempty"` - Env map[string]string `json:"env"` - Packages []string `json:"packages,omitempty"` - Command string `json:"command,omitempty"` - CWD string `json:"cwd,omitempty"` + Capabilities *agentcapabilities.Operation `json:"-"` + Skill *store.EnvironmentSkill `json:"-"` + Name string `json:"name,omitempty"` + Files []agentbundle.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 { @@ -37,6 +40,9 @@ func setupOperations(setup store.EnvironmentSetup) []runtimeSetupOperation { for i := range setup.Skills { result = append(result, runtimeSetupOperation{Version: 1, Action: "skill", Skill: &setup.Skills[i]}) } + for i, plugin := range setup.Plugins { + result = append(result, runtimeSetupOperation{Capabilities: &agentcapabilities.Operation{Version: 1, Action: "plugin", Slot: i, Archive: plugin.Archive, Plugin: plugin.Metadata}}) + } // The public network policy applies after setup completes. Provisioning uses // the isolated initializer's network; adapters enforce the runtime policy. const network = "enabled" @@ -56,6 +62,13 @@ func setupOperations(setup store.EnvironmentSetup) []runtimeSetupOperation { } result = append(result, runtimeSetupOperation{Version: 1, Action: "setup", Network: network, Command: command.Command, CWD: cwd}) } + if len(setup.Skills)+len(setup.Plugins)+len(setup.CapabilityDirectories) > 0 { + sources := agentcapabilities.Input{Plugins: setup.PluginMetadata(), Directories: setup.CapabilityDirectories} + for _, skill := range setup.Skills { + sources.Skills = append(sources.Skills, skill.InstallationMetadata()) + } + result = append(result, runtimeSetupOperation{Capabilities: &agentcapabilities.Operation{Version: 1, Action: "finalize", Sources: sources}}) + } return result } @@ -70,11 +83,17 @@ func runRuntimeSetup(ctx context.Context, provider sandbox.Provider, reference s } operation.Name, operation.Files = operation.Skill.Metadata.Name, files } - input, err := json.Marshal(operation) + var payload any = operation + args := []string{"/usr/bin/python3", "-I", "-S", "/usr/local/bin/agents-api-runtime-initialize"} + if operation.Capabilities != nil { + payload = operation.Capabilities + args = []string{"/usr/local/bin/parsar-daemon", "runtime-capabilities"} + } + input, err := json.Marshal(payload) if err != nil { return err } - result, err := provider.RunCommand(ctx, reference, sandbox.Command{Directory: "/", Args: []string{"/usr/bin/python3", "-I", "-S", "/usr/local/bin/agents-api-runtime-initialize"}, Stdin: input}) + result, err := provider.RunCommand(ctx, reference, sandbox.Command{Directory: "/", Args: args, Stdin: input}) if err != nil { return err } diff --git a/services/agents-api/internal/store/environment_plugins.go b/services/agents-api/internal/store/environment_plugins.go new file mode 100644 index 000000000..fdc8635e5 --- /dev/null +++ b/services/agents-api/internal/store/environment_plugins.go @@ -0,0 +1,57 @@ +package store + +import ( + "encoding/json" + "github.com/MiniMax-AI-Dev/parsar/internal/agentplugin" +) + +const MaxPluginsArchiveBytes = 10 << 20 + +// EnvironmentPlugin separates safe identity from confidential immutable input. +type EnvironmentPlugin struct { + Metadata agentplugin.Metadata `json:"metadata"` + Archive []byte `json:"archive"` +} + +func ValidateEnvironmentPlugins(plugins []EnvironmentPlugin) error { + if len(plugins) > 50 { + return ErrInvalidInput + } + seen := map[string]bool{} + compressed, expanded := 0, 0 + for _, plugin := range plugins { + compressed += len(plugin.Archive) + if compressed > MaxPluginsArchiveBytes || seen[plugin.Metadata.Name] { + return ErrInvalidInput + } + seen[plugin.Metadata.Name] = true + bundle, err := agentplugin.Read(plugin.Archive, plugin.Metadata) + if err != nil { + return ErrInvalidInput + } + for _, file := range bundle.Files { + expanded += len(file.Data) + } + if expanded > 50<<20 { + return ErrInvalidInput + } + } + return nil +} + +func (s EnvironmentSetup) PluginMetadata() []agentplugin.Metadata { + result := make([]agentplugin.Metadata, 0, len(s.Plugins)) + for _, plugin := range s.Plugins { + result = append(result, plugin.Metadata) + } + return result +} + +func (s *Store) sealTemplatePlugins(tenant, id string, setup EnvironmentSetup) ([]byte, []byte, error) { + metadata, err := json.Marshal(setup.PluginMetadata()) + if err != nil { + return nil, nil, err + } + contents, err := s.sealEnvironmentSetup(tenant, "environment_template", id, "plugins", setup.Plugins, len(setup.Plugins) == 0) + return metadata, contents, err +} diff --git a/services/agents-api/internal/store/environment_plugins_test.go b/services/agents-api/internal/store/environment_plugins_test.go new file mode 100644 index 000000000..f6d109a14 --- /dev/null +++ b/services/agents-api/internal/store/environment_plugins_test.go @@ -0,0 +1,118 @@ +package store + +import ( + "archive/zip" + "bytes" + "encoding/json" + "errors" + "reflect" + "testing" + + "github.com/MiniMax-AI-Dev/parsar/internal/agentplugin" + "github.com/MiniMax-AI-Dev/parsar/services/agents-api/internal/credentialcrypto" + "github.com/google/uuid" +) + +func TestPluginsEncryptedTemplateAndFrozenSession(t *testing.T) { + _, pool := testStore(t) + cipher, err := credentialcrypto.New(bytes.Repeat([]byte{23}, 32)) + if err != nil { + t.Fatal(err) + } + s := NewWithCredentialCipher(pool, cipher) + var archive bytes.Buffer + writer := zip.NewWriter(&archive) + for path, body := range map[string]string{ + ".codex-plugin/plugin.json": `{"name":"plugin-proof","description":"A proof.","skills":"./skills"}`, + "skills/proof/SKILL.md": "---\nname: proof\ndescription: A proof.\n---\nplugin-private-canary", + "shared/data.txt": "plugin-private-resource", + } { + f, err := writer.CreateHeader(&zip.FileHeader{Name: "proof/" + path, Method: zip.Store}) + if err != nil { + t.Fatal(err) + } + if _, err = f.Write([]byte(body)); err != nil { + t.Fatal(err) + } + } + if err = writer.Close(); err != nil { + t.Fatal(err) + } + setup := EnvironmentSetup{Plugins: []EnvironmentPlugin{{Metadata: agentplugin.Metadata{Type: "inline", Name: "plugin-proof", Description: "A proof."}, Archive: archive.Bytes()}}, CapabilityDirectories: []string{"/workspace/generated"}} + tenant, foreign := uuid.NewString(), uuid.NewString() + template, err := s.CreateEnvironmentTemplate(t.Context(), tenant, EnvironmentTemplateInput{SetPlugins: true, SetDirectories: true, Initialization: setup}) + if err != nil { + t.Fatal(err) + } + public, err := New(pool).GetEnvironmentTemplate(t.Context(), tenant, template.ID) + if err != nil || len(public.Plugins) != 1 || !public.Initialization.Empty() || !reflect.DeepEqual(public.CapabilityDirectories, setup.CapabilityDirectories) { + t.Fatal("safe metadata without decryption", err) + } + page, err := New(pool).ListEnvironmentTemplates(t.Context(), tenant, "", 20, false) + if err != nil || len(page.Templates) != 1 || len(page.Templates[0].Plugins) != 1 { + t.Fatal("list", err) + } + var metadata, encrypted []byte + if err = pool.QueryRow(t.Context(), "SELECT plugins,plugin_contents FROM environment_templates WHERE id=$1", template.ID).Scan(&metadata, &encrypted); err != nil || len(encrypted) == 0 || bytes.Contains(metadata, []byte("private")) || bytes.Contains(encrypted, []byte("private")) { + t.Fatal("plaintext storage", err) + } + resolved, _, err := s.ResolveEnvironmentTemplate(t.Context(), tenant, template.ID) + if err != nil || !reflect.DeepEqual(resolved.Initialization.Plugins, setup.Plugins) { + t.Fatal("resolution", err) + } + for _, read := range []func() error{ + func() error { _, e := s.GetEnvironmentTemplate(t.Context(), foreign, template.ID); return e }, + func() error { _, _, e := s.ResolveEnvironmentTemplate(t.Context(), foreign, template.ID); return e }, + func() error { + _, e := s.UpdateEnvironmentTemplate(t.Context(), foreign, template.ID, EnvironmentTemplateInput{SetPlugins: true}) + return e + }, + } { + if err := read(); !errors.Is(err, ErrNotFound) { + t.Fatal("tenant isolation", err) + } + } + request := CreateSessionInput{Creator: FixtureCreator(), Engine: "codex", IdempotencyKey: uuid.NewString(), Configuration: json.RawMessage(`{"environment":{"type":"openai_hosted"}}`), Initialization: resolved.Initialization} + session, err := s.CreateSession(t.Context(), tenant, request) + if err != nil { + t.Fatal(err) + } + var cfg struct { + Environment struct { + Plugins []agentplugin.Metadata `json:"plugins"` + Directories []string `json:"capability_directories"` + } `json:"environment"` + } + if err = json.Unmarshal(session.Configuration, &cfg); err != nil || len(cfg.Environment.Plugins) != 1 || !reflect.DeepEqual(cfg.Environment.Directories, setup.CapabilityDirectories) { + t.Fatal("frozen public metadata", 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.Plugins, setup.Plugins) || !reflect.DeepEqual(preserved.Initialization.CapabilityDirectories, setup.CapabilityDirectories) { + t.Fatal("unrelated update changed installation", err) + } + if _, err = s.UpdateEnvironmentTemplate(t.Context(), tenant, template.ID, EnvironmentTemplateInput{SetPlugins: true, SetDirectories: true}); err != nil { + t.Fatal(err) + } + cleared, _, err := s.ResolveEnvironmentTemplate(t.Context(), tenant, template.ID) + if err != nil || len(cleared.Initialization.Plugins) != 0 || len(cleared.CapabilityDirectories) != 0 { + t.Fatal("clear", 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.Plugins, setup.Plugins) || !reflect.DeepEqual(frozen.CapabilityDirectories, setup.CapabilityDirectories) { + t.Fatal("frozen snapshot changed", err) + } + retry, err := s.CreateSession(t.Context(), tenant, request) + if err != nil || retry.ID != session.ID { + t.Fatal("retry", err) + } + if _, err = s.ReadEnvironmentSetup(t.Context(), foreign, session.ID); !errors.Is(err, ErrNotFound) { + t.Fatal("foreign snapshot", err) + } +} diff --git a/services/agents-api/internal/store/environment_setup.go b/services/agents-api/internal/store/environment_setup.go index b2cdf0a07..63a1cc49b 100644 --- a/services/agents-api/internal/store/environment_setup.go +++ b/services/agents-api/internal/store/environment_setup.go @@ -9,6 +9,7 @@ import ( "strings" v1 "github.com/MiniMax-AI-Dev/parsar/contracts/agents-api/v1" + "github.com/MiniMax-AI-Dev/parsar/internal/agentcapabilities" "github.com/MiniMax-AI-Dev/parsar/services/agents-api/internal/credentialcrypto" "github.com/MiniMax-AI-Dev/parsar/services/agents-api/internal/db/sqlc" "github.com/google/uuid" @@ -19,10 +20,12 @@ import ( // EnvironmentSetup is confidential input, never ordinary resource metadata. // Core freezes it once; the common Runtime initializer executes it in order. type EnvironmentSetup struct { - Env map[string]string `json:"env,omitempty"` - Commands []SetupCommand `json:"setup_commands,omitempty"` - Packages v1.EnvironmentPackages `json:"packages"` - Skills []EnvironmentSkill `json:"skills,omitempty"` + Env map[string]string `json:"env,omitempty"` + Commands []SetupCommand `json:"setup_commands,omitempty"` + Packages v1.EnvironmentPackages `json:"packages"` + Skills []EnvironmentSkill `json:"skills,omitempty"` + Plugins []EnvironmentPlugin `json:"plugins,omitempty"` + CapabilityDirectories []string `json:"capability_directories,omitempty"` } type SetupCommand struct { @@ -33,7 +36,7 @@ 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)+len(s.Skills) == 0 + return len(s.Env)+len(s.Commands)+len(s.Packages.NPM)+len(s.Packages.Python)+len(s.Packages.System)+len(s.Skills)+len(s.Plugins)+len(s.CapabilityDirectories) == 0 } func (s EnvironmentSetup) Validate() error { @@ -41,11 +44,12 @@ func (s EnvironmentSetup) Validate() error { } func (s EnvironmentSetup) validate(installed bool) error { - if validateEnvironmentSkills(s.Skills, installed) != nil { + if validateEnvironmentSkills(s.Skills, installed) != nil || ValidateEnvironmentPlugins(s.Plugins) != nil || agentcapabilities.ValidateDirectories(s.CapabilityDirectories) != nil { return ErrInvalidInput } ordinary := s ordinary.Skills = nil + ordinary.Plugins = nil raw, err := json.Marshal(ordinary) if err != nil || len(raw) > 512*1024 { return ErrInvalidInput diff --git a/services/agents-api/internal/store/environment_templates.go b/services/agents-api/internal/store/environment_templates.go index 794ce1d05..69b3106c4 100644 --- a/services/agents-api/internal/store/environment_templates.go +++ b/services/agents-api/internal/store/environment_templates.go @@ -5,6 +5,7 @@ import ( "encoding/json" "errors" "github.com/MiniMax-AI-Dev/parsar/internal/agentnetwork" + "github.com/MiniMax-AI-Dev/parsar/internal/agentplugin" "time" "unicode/utf8" @@ -19,28 +20,30 @@ import ( // EnvironmentTemplate is configuration ownership, independent of provider images. type EnvironmentTemplate struct { - Skills []EnvironmentSkillMetadata - Packages v1.EnvironmentPackages - Initialization EnvironmentSetup - Files []InitialFileMetadata - ID string - Name *string - NetworkAccess string - AllowedDomains []string - CreatedAt time.Time - UpdatedAt time.Time + Plugins []agentplugin.Metadata + CapabilityDirectories []string + Skills []EnvironmentSkillMetadata + Packages v1.EnvironmentPackages + Initialization EnvironmentSetup + Files []InitialFileMetadata + ID string + Name *string + NetworkAccess string + AllowedDomains []string + CreatedAt time.Time + UpdatedAt time.Time } type EnvironmentTemplateInput struct { - Initialization EnvironmentSetup - SetEnv, SetSetup, SetPackages, SetSkills bool - Files []InitialFile - SetFiles bool - Name *string - SetName bool - NetworkAccess string - AllowedDomains []string - SetNetwork bool + Initialization EnvironmentSetup + SetEnv, SetSetup, SetPackages, SetSkills, SetPlugins, SetDirectories bool + Files []InitialFile + SetFiles bool + Name *string + SetName bool + NetworkAccess string + AllowedDomains []string + SetNetwork bool } func (in EnvironmentTemplateInput) valid() bool { @@ -57,11 +60,11 @@ func templateFromRow(row templateMetadataRow, err error) (EnvironmentTemplate, e if err != nil { return EnvironmentTemplate{}, err } - result := EnvironmentTemplate{ID: uuid.UUID(row.ID.Bytes).String(), NetworkAccess: row.NetworkAccess, AllowedDomains: append([]string{}, row.NetworkAllowedDomains...), CreatedAt: row.CreatedAt.Time, UpdatedAt: row.UpdatedAt.Time} + result := EnvironmentTemplate{CapabilityDirectories: append([]string{}, row.CapabilityDirectories...), ID: uuid.UUID(row.ID.Bytes).String(), NetworkAccess: row.NetworkAccess, AllowedDomains: append([]string{}, row.NetworkAllowedDomains...), CreatedAt: row.CreatedAt.Time, UpdatedAt: row.UpdatedAt.Time} if row.Name.Valid { result.Name = &row.Name.String } - if json.Unmarshal(row.Files, &result.Files) != nil || json.Unmarshal(row.Packages, &result.Packages) != nil || json.Unmarshal(row.Skills, &result.Skills) != nil { + if json.Unmarshal(row.Files, &result.Files) != nil || json.Unmarshal(row.Packages, &result.Packages) != nil || json.Unmarshal(row.Skills, &result.Skills) != nil || json.Unmarshal(row.Plugins, &result.Plugins) != nil { return EnvironmentTemplate{}, ErrInvalidInput } return result, nil @@ -97,7 +100,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, NetworkAllowedDomains: append([]string{}, in.AllowedDomains...), Files: metadata, FileContents: encrypted, Packages: packages, EnvContents: envContents, SetupContents: setupContents, Skills: skills, SkillContents: skillContents}) + plugins, pluginContents, err := s.sealTemplatePlugins(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, NetworkAllowedDomains: append([]string{}, in.AllowedDomains...), Files: metadata, FileContents: encrypted, Packages: packages, EnvContents: envContents, SetupContents: setupContents, Skills: skills, SkillContents: skillContents, Plugins: plugins, PluginContents: pluginContents, CapabilityDirectories: append([]string{}, in.Initialization.CapabilityDirectories...)}) return templateFromRow(templateMetadataRow(row), err) } @@ -127,7 +134,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 && !in.SetSkills { + if !in.SetName && !in.SetNetwork && !in.SetFiles && !in.SetEnv && !in.SetSetup && !in.SetPackages && !in.SetSkills && !in.SetPlugins && !in.SetDirectories { return s.GetEnvironmentTemplate(ctx, tenantID, templateID) } var name pgtype.Text @@ -146,7 +153,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, NetworkAllowedDomains: append([]string{}, in.AllowedDomains...), 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}) + plugins, pluginContents, err := s.sealTemplatePlugins(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, NetworkAllowedDomains: append([]string{}, in.AllowedDomains...), 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, SetPlugins: in.SetPlugins, SetDirectories: in.SetDirectories, Skills: skills, SkillContents: skillContents, Plugins: plugins, PluginContents: pluginContents, CapabilityDirectories: append([]string{}, in.Initialization.CapabilityDirectories...)}) 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 f80238c3d..736c207bc 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, NetworkAllowedDomains: row.NetworkAllowedDomains, CreatedAt: row.CreatedAt, UpdatedAt: row.UpdatedAt, Files: row.Files, Packages: row.Packages, Skills: row.Skills}, err) + value, err := templateFromRow(templateMetadataRow{ID: row.ID, TenantID: row.TenantID, Name: row.Name, NetworkAccess: row.NetworkAccess, NetworkAllowedDomains: row.NetworkAllowedDomains, CreatedAt: row.CreatedAt, UpdatedAt: row.UpdatedAt, Files: row.Files, Packages: row.Packages, Skills: row.Skills, Plugins: row.Plugins, CapabilityDirectories: row.CapabilityDirectories}, err) if err != nil { return value, nil, err } @@ -127,6 +127,18 @@ func (s *Store) ResolveEnvironmentTemplate(ctx context.Context, tenant, id strin return value, nil, ErrInvalidInput } } + value.Initialization.CapabilityDirectories = append([]string(nil), value.CapabilityDirectories...) + if err = s.openEnvironmentSetup(canonicalTenant, "environment_template", value.ID, "plugins", row.PluginContents, &value.Initialization.Plugins); err != nil { + return value, nil, err + } + if len(value.Plugins) != len(value.Initialization.Plugins) { + return value, nil, ErrInvalidInput + } + for i, metadata := range value.Plugins { + if metadata != value.Initialization.Plugins[i].Metadata { + return value, nil, ErrInvalidInput + } + } if err = value.Initialization.Validate(); err != nil { return value, nil, err } diff --git a/services/agents-api/internal/store/session_initial_input.go b/services/agents-api/internal/store/session_initial_input.go index c57379942..b124d6145 100644 --- a/services/agents-api/internal/store/session_initial_input.go +++ b/services/agents-api/internal/store/session_initial_input.go @@ -63,7 +63,15 @@ func (s *Store) createSessionResources(ctx context.Context, tenant string, param if err != nil { return err } - row, err = q.SetSessionSetupMetadata(ctx, sqlc.SetSessionSetupMetadataParams{ID: row.ID, Packages: packages, Skills: skills}) + plugins, err := json.Marshal(setup.PluginMetadata()) + if err != nil { + return err + } + directories, err := json.Marshal(append([]string{}, setup.CapabilityDirectories...)) + if err != nil { + return err + } + row, err = q.SetSessionSetupMetadata(ctx, sqlc.SetSessionSetupMetadataParams{ID: row.ID, Packages: packages, Skills: skills, Plugins: plugins, CapabilityDirectories: directories}) if err != nil { return err } diff --git a/services/agents-api/migrations/000049_environment_plugins.sql b/services/agents-api/migrations/000049_environment_plugins.sql new file mode 100644 index 000000000..36fb04d99 --- /dev/null +++ b/services/agents-api/migrations/000049_environment_plugins.sql @@ -0,0 +1,11 @@ +-- +goose Up +ALTER TABLE environment_templates + ADD COLUMN plugins jsonb NOT NULL DEFAULT '[]'::jsonb, + ADD COLUMN plugin_contents bytea, + ADD COLUMN capability_directories text[] NOT NULL DEFAULT '{}'; + +-- +goose Down +ALTER TABLE environment_templates + DROP COLUMN capability_directories, + DROP COLUMN plugin_contents, + DROP COLUMN plugins; diff --git a/services/agents-api/tests/official_environment_plugins.py b/services/agents-api/tests/official_environment_plugins.py new file mode 100644 index 000000000..9b8f169db --- /dev/null +++ b/services/agents-api/tests/official_environment_plugins.py @@ -0,0 +1,108 @@ +"""Fixed-client helpers for real hosted Plugin and generated-directory acceptance. + +Use with the existing standalone Docker/native runner and Files/Artifacts checks. +This module provides fixtures and assertions, never a model or Runtime substitute. +""" +import base64 +import io +import json +import secrets +import zipfile + + +def plugin_fixture(): + marker = 'plugin-proof-' + secrets.token_hex(20) + files = {'.codex-plugin/plugin.json': json.dumps({ + 'name': 'workspace-proof', 'description': 'Two portable verification Skills.', + 'skills': ['./layout', './more']}), 'shared/proof.txt': marker} + for name, root, relative in [('proof-alpha', 'layout/custom/alpha', '../../../shared/proof.txt'), + ('proof-beta', 'more/beta', '../../shared/proof.txt')]: + files[root + '/SKILL.md'] = ('---\nname: ' + name + '\ndescription: Execute the packaged ' + name + ' verification script.\n---\n' + 'Use your native tools to run `python3 scripts/check.py` from this installed Skill directory. ' + 'Use the provided script; do not recreate it. Report PLUGIN_VERIFIED after success.\n') + files[root + '/scripts/check.py'] = '''import json, os +from pathlib import Path +root = Path(__file__).parent.parent +for name in ['ANTHROPIC_API_KEY', 'ANTHROPIC_AUTH_TOKEN', 'OPENAI_API_KEY', 'MINIMAX_API_KEY']: + assert name not in os.environ, 'native credential reached Plugin script' +private_paths = json.loads(Path('/workspace/plugin-isolation-paths.json').read_text()) +assert set(private_paths) == {'staging', 'native_history', 'daemon_auth'}, 'private probe paths missing' +for private in private_paths.values(): + try: + with open(private, 'rb') as stream: + exposed = stream.read(1) + except OSError: + continue + # A readable empty mask does not expose the underlying nonempty private file. + assert not exposed, 'private Runtime content readable' +try: + (root / 'SKILL.md').write_text('tamper') +except OSError: + pass +else: + raise AssertionError('installed Skill writable') +value = (root / RELATIVE).read_text() +Path('/workspace/outputs').mkdir(exist_ok=True) +Path(OUTPUT).write_text(value) +print('PLUGIN_VERIFIED') +'''.replace('RELATIVE', repr(relative)).replace('OUTPUT', repr('/workspace/outputs/' + name + '.txt')) + archive = io.BytesIO() + with zipfile.ZipFile(archive, 'w', zipfile.ZIP_DEFLATED) as output: + for path, content in files.items(): + output.writestr('proof/' + path, content) + plugin = {'type': 'inline', 'name': 'workspace-proof', 'description': 'Two portable verification Skills.', + 'source': {'type': 'base64', 'media_type': 'application/zip', + 'data': base64.b64encode(archive.getvalue()).decode()}} + generated = { + '/workspace/generated/source.md': '---\nname: generated-proof\ndescription: Verify the setup-generated Skill snapshot.\n---\n' + 'Run `python3 check.py` from this installed Skill directory with your native tools. Do not recreate it.\n', + '/workspace/generated/check.py': "from pathlib import Path\nimport shutil\n" + "root=Path(__file__).parent\nassert '/initialization/capabilities/' in str(root)\n" + "Path('/workspace/outputs').mkdir(exist_ok=True)\n" + "Path('/workspace/outputs/generated-proof.txt').write_text((root/'proof.txt').read_text())\n" + "shutil.rmtree('/workspace/generated', ignore_errors=True)\nprint('GENERATED_SKILL_VERIFIED')\n", + '/workspace/generated/proof.txt': marker, + } + initial = [{'type': 'inline', 'path': path, 'data': base64.b64encode(content.encode()).decode()} + for path, content in generated.items()] + commands = [{'command': 'cp /workspace/generated/source.md /workspace/generated/SKILL.md; printf initialized > /workspace/setup-once'}] + outputs = {'/workspace/outputs/' + name + '.txt': marker.encode() + for name in ['proof-alpha', 'proof-beta', 'generated-proof']} + return plugin, initial, commands, outputs + + +def verify_plugin_metadata(client, session, plugin): + expected = [{key: plugin[key] for key in ['type', 'name', 'description']}] + resource = client.beta.agents.environments.retrieve(session.environment.id).to_dict() + value = session.to_dict()['environment'] + assert resource['plugins'] == value['plugins'] == expected + assert value['capability_directories'] == ['/workspace/generated'] + assert resource['skills'] == value['skills'] == [] # Configured resource metadata, not native inventory. + assert plugin['source']['data'] not in json.dumps([resource, value]) + + +def verify_plugin_resources(client, foreign, http): + """Run before native creation; resource reads need no allocated Environment.""" + api = client.beta.agents.environments.templates + plugin, initial, commands, _ = plugin_fixture() + source = str(client.base_url).rstrip('/') + '/agents/environments/templates' + headers = {'Authorization': 'Bearer ' + client.api_key, 'OpenAI-Beta': 'agents=v1'} + other = {**headers, 'Authorization': 'Bearer ' + foreign.api_key} + template = api.create(plugins=[plugin], capability_directories=['/workspace/generated'], + files=initial, setup_commands=commands) + try: + metadata = [{key: plugin[key] for key in ['type', 'name', 'description']}] + for body in [template.to_dict(), api.retrieve(template.id).to_dict(), + http.get(source + '/' + template.id, headers=headers).json()]: + assert body['plugins'] == metadata + assert body['capability_directories'] == ['/workspace/generated'] + assert plugin['source']['data'] not in json.dumps(body) + for method in ['GET', 'POST', 'DELETE']: + assert http.request(method, source + '/' + template.id, headers=other, + **({'json': {'plugins': []}} if method == 'POST' else {})).status_code == 404 + assert api.update(template.id, name='preserve').to_dict()['plugins'] == metadata + assert api.update(template.id, plugins=[], capability_directories=[]).to_dict()['plugins'] == [] + assert api.update(template.id, plugins=[plugin]).to_dict()['plugins'] == metadata + assert api.update(template.id, plugins=None, capability_directories=None).to_dict()['plugins'] == [] + finally: + api.delete(template.id) diff --git a/services/agents-api/tests/official_environment_templates.py b/services/agents-api/tests/official_environment_templates.py index 754726ce5..21b44338f 100644 --- a/services/agents-api/tests/official_environment_templates.py +++ b/services/agents-api/tests/official_environment_templates.py @@ -66,7 +66,7 @@ def verify_environment_templates(client, foreign, http): {'files': [{'type': 'inline', 'path': '/workspace/a', 'data': canary}]}, {'packages': {'system': ['-' + canary]}}, {'skills': [{'type': 'inline', 'data': canary}]}, {'plugins': [{'type': 'inline', 'data': canary}]}, - {'capability_directories': ['/workspace']}, + {'capability_directories': ['/private']}, {'network': {'access': 'restricted', 'allowed_domains': ['*.example.com']}}, {'name': ''}, {'unknown': canary}]: for path in ['', '/' + owned[0]]: From 8ff0b845a3428f887d04c2e830da8799a963194d Mon Sep 17 00:00:00 2001 From: saladday <1203511142@qq.com> Date: Mon, 21 Sep 2026 19:38:00 +0800 Subject: [PATCH 2/2] Record completed Plugin acceptance and independent review --- contracts/agents-api/README.md | 2 +- contracts/agents-api/environment-templates.md | 3 ++- 2 files changed, 3 insertions(+), 2 deletions(-) diff --git a/contracts/agents-api/README.md b/contracts/agents-api/README.md index 5d2f910a2..7390f0e10 100644 --- a/contracts/agents-api/README.md +++ b/contracts/agents-api/README.md @@ -158,7 +158,7 @@ user-managed enrollment remain outside this qualification. | Area | Missing or unverified scope | | --- | --- | | Subagents / multi_agent | Six public child read operations, enabled execution, child lifecycle/interactions and full recovery; deferred outside the MVP | -| Environment Templates | Skill-only Plugins and workspace capability directories are in current-batch acceptance; Plugin MCP, unsupported restricted hostname forms, unqualified installation overrides/null network and exact hosted errors remain gaps. CRUD/list, files, env/setup/system/npm/Python, inline/referenced Skills and Session references have accepted coverage | +| Environment Templates | Plugin MCP, unsupported restricted hostname forms, unqualified installation overrides/null network and exact hosted errors remain gaps. CRUD/list, files, env/setup/system/npm/Python, inline/referenced Skills, skill-only Plugins, workspace capability directories and Session references have accepted coverage | | 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 22631027f..a21112c71 100644 --- a/contracts/agents-api/environment-templates.md +++ b/contracts/agents-api/environment-templates.md @@ -229,7 +229,8 @@ Evidence is under `~/.parsar/remediation/20260921/template-plugins/`: `runtime-builds.json` and `full-check-attempt2-result.json`. The shared reproducible fixture is `services/agents-api/tests/official_environment_plugins.py`; operator runners reuse existing standalone acceptance and private model configuration. -Review and merge remain required before delivery. Plugin MCP, portable root +A user-authorized reused-context GPT-6 Astra high independent review of all 60 +changed files found no material actionable findings. Plugin MCP, portable root `plugin.json` applicability and the unconfirmed semantics above remain gaps; these results do not establish complete Environment Templates or protocol compatibility.