From 995e40c663a06dc0b0a222b2eca352e8ac790ec7 Mon Sep 17 00:00:00 2001 From: saladday <1203511142@qq.com> Date: Sun, 20 Sep 2026 16:30:40 +0800 Subject: [PATCH] feat(agents-api): share secure environment setup across runtimes --- CONTRIBUTING.md | 27 +++- .../internal/agent/claudesdk/workspace.go | 28 +++- .../internal/agent/codex/preparation.go | 6 + .../internal/agent/codex/session.go | 2 + .../internal/agent/codex/session_plan.go | 10 ++ .../internal/agent/codex/tool_environment.go | 68 ++++++++ .../agent/codex/tool_environment_test.go | 75 +++++++++ .../internal/agent/mcode/workspace.go | 11 ++ .../internal/localworkspace/binding.go | 5 + .../internal/localworkspace/initialization.go | 35 ++++ contracts/agents-api/README.md | 6 +- contracts/agents-api/environment-templates.md | 117 ++++++++++++-- contracts/agents-api/openapi.yaml | 44 +++-- contracts/agents-api/v1/sessions.go | 3 + internal/agentdaemon/proto/environment.go | 2 + packages/claude-sdk-adapter/src/workspace.ts | 14 +- .../tests/workspace.test.mjs | 16 ++ packages/mcode-harness/launch.mjs | 4 +- packages/mcode-harness/worker.ts | 13 ++ scripts/build-agents-runtime.sh | 2 + scripts/build-claude-runtime.sh | 1 + scripts/build-mcode-runtime.sh | 1 + services/agents-api/README.md | 10 +- services/agents-api/deploy/claude/Dockerfile | 3 +- services/agents-api/deploy/codex/Dockerfile | 4 +- services/agents-api/deploy/codex/README.md | 15 +- .../agents-api/deploy/codex/requirements.toml | 17 ++ services/agents-api/deploy/codex/tool-env.py | 24 +++ .../agents-api/deploy/e2b/build-template.py | 4 +- services/agents-api/deploy/e2b/init.py | 3 +- services/agents-api/deploy/mcode/Dockerfile | 3 +- .../agents-api/deploy/runtime/initialize.py | 151 +++++++++++++++++ .../deploy/runtime/initialize_test.py | 76 +++++++++ .../internal/api/environment_setup.go | 85 ++++++++++ .../internal/api/environment_setup_test.go | 77 +++++++++ .../internal/api/environment_templates.go | 14 +- .../api/environment_templates_test.go | 4 +- services/agents-api/internal/api/handler.go | 4 +- .../internal/api/hosted_environment.go | 37 +++-- .../internal/api/hosted_environment_test.go | 2 +- .../internal/api/session_creation_identity.go | 2 +- .../internal/api/session_request.go | 5 + .../internal/api/session_template.go | 9 +- .../credentialcrypto/environment_setup.go | 46 ++++++ .../environment_setup_test.go | 31 ++++ .../internal/db/queries/environment_setup.sql | 10 ++ .../db/queries/environment_templates.sql | 13 +- .../db/queries/initial_environment_files.sql | 3 +- .../db/queries/runtime_allocations.sql | 2 +- .../internal/db/sqlc/environment_setup.sql.go | 74 +++++++++ .../db/sqlc/environment_templates.sql.go | 46 +++++- .../db/sqlc/initial_environment_files.sql.go | 3 +- .../agents-api/internal/db/sqlc/models.go | 8 + .../db/sqlc/runtime_allocations.sql.go | 2 +- .../execution/environment_placement.go | 6 +- .../execution/runtime_initialization.go | 32 +++- .../internal/execution/runtime_setup.go | 72 +++++++++ .../internal/sandbox/docker/bootstrap.go | 2 +- .../internal/store/environment_setup.go | 152 ++++++++++++++++++ .../internal/store/environment_setup_test.go | 88 ++++++++++ .../internal/store/environment_templates.go | 48 ++++-- .../internal/store/initial_files.go | 13 +- .../store/runtime_initialization_test.go | 28 +++- .../internal/store/session_initial_input.go | 15 +- .../agents-api/internal/store/sessions.go | 20 ++- .../migrations/000044_environment_setup.sql | 13 ++ services/agents-api/tests/official_e2b_v1.py | 23 +++ .../tests/official_environment_setup.py | 136 ++++++++++++++++ .../tests/official_environment_templates.py | 4 +- 69 files changed, 1793 insertions(+), 136 deletions(-) create mode 100644 apps/parsar-daemon/internal/agent/codex/tool_environment.go create mode 100644 apps/parsar-daemon/internal/agent/codex/tool_environment_test.go create mode 100644 apps/parsar-daemon/internal/localworkspace/initialization.go create mode 100644 services/agents-api/deploy/codex/tool-env.py create mode 100644 services/agents-api/deploy/runtime/initialize.py create mode 100644 services/agents-api/deploy/runtime/initialize_test.py create mode 100644 services/agents-api/internal/api/environment_setup.go create mode 100644 services/agents-api/internal/api/environment_setup_test.go create mode 100644 services/agents-api/internal/credentialcrypto/environment_setup.go create mode 100644 services/agents-api/internal/credentialcrypto/environment_setup_test.go create mode 100644 services/agents-api/internal/db/queries/environment_setup.sql create mode 100644 services/agents-api/internal/db/sqlc/environment_setup.sql.go create mode 100644 services/agents-api/internal/execution/runtime_setup.go create mode 100644 services/agents-api/internal/store/environment_setup.go create mode 100644 services/agents-api/internal/store/environment_setup_test.go create mode 100644 services/agents-api/migrations/000044_environment_setup.sql create mode 100644 services/agents-api/tests/official_environment_setup.py diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 2cd8065b..e9ea3757 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -266,19 +266,38 @@ Session creation freezes tenant-authorized source bytes in the same commit, inde of later source/template deletion. Public resource reads must not require decryption or load encrypted file bodies. Record original creation intent before resolution. +Template parsing, persistence and resolution must not select a harness or Provider, +or depend on native tool names and private harness paths. The shared initializer +uses the packaged Runtime contract for trusted commands, workspace/staging paths, +confidential input and completion receipts. Each Provider supplies that same +Runtime and carries initialization commands through RunCommand; each adapter owns +native tool configuration. A new harness or Provider must not require template +business-logic changes. Reuse qualified shared helpers even when their executable +names have historical engine prefixes; renaming is not a boundary fix. Select real +regressions by the changed shared, Provider and adapter boundaries, rather than +repeating every deployment combination for each configuration field. + The allocation lifecycle owns pending/running/complete initialization. Authentication may connect the daemon during initialization; execution bindings, native preparation, live Files and connected publication wait for completion. Keep Provider bootstrap -settlement distinct. Advance at most one bounded file per full maintenance scan, +settlement distinct. Advance at most one bounded initialization operation per full +maintenance scan, using process-local progress and the existing lifecycle gate. A recovered or uncertain running installation fails and uses existing cleanup, without replaying writes. Completed environments never reinstall initial files on reconnect or native recovery. Provider RunCommand carries bounded stdin, not confidential argv. Only fixed trusted -initializers may run with Runtime authority; user setup scripts remain unsupported. +initializers may run with Runtime authority. User setup and package install hooks +run in the common packaged sandbox, without daemon credentials or native history. +Files and npm/Python packages precede ordered setup commands. Initialization has +provisioning network access; requested network restrictions apply to native tools +after setup. Confidential env and setup snapshots are encrypted independently of +ordinary metadata. Adapters apply tool env only after isolation, never to the +credential-bearing daemon/native harness launcher. Reuse the packaged atomic file writer and anchored parent creation across all profiles. -Name, enabled/disabled network and initial files are qualified independently of other -installation fields. Reject unsupported inputs rather than persisting them for silent +Name, enabled/disabled network, initial files and env/setup/npm/Python are +implemented independently of remaining installation fields. Reject unsupported +inputs rather than persisting them for silent omission; expand inline and template initialization together in separately qualified batches. Resource reads need only tenant authorization, not a live Runtime. See the [Template coverage and unresolved semantics](contracts/agents-api/environment-templates.md). diff --git a/apps/parsar-daemon/internal/agent/claudesdk/workspace.go b/apps/parsar-daemon/internal/agent/claudesdk/workspace.go index 1519beae..7e0f208d 100644 --- a/apps/parsar-daemon/internal/agent/claudesdk/workspace.go +++ b/apps/parsar-daemon/internal/agent/claudesdk/workspace.go @@ -6,6 +6,7 @@ import ( "path/filepath" "strings" + "github.com/MiniMax-AI-Dev/parsar/apps/parsar-daemon/internal/localworkspace" "github.com/MiniMax-AI-Dev/parsar/apps/parsar-daemon/internal/paths" "github.com/MiniMax-AI-Dev/parsar/internal/agentdaemon/proto" ) @@ -26,13 +27,14 @@ type WorkspaceConfig struct { } type workspaceProfile struct { - 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"` + ToolEnvironment bool `json:"tool_environment,omitempty"` + Home string `json:"home"` + State string `json:"state"` + Scratch string `json:"scratch"` + ProtectedDirs []string `json:"protected_dirs"` + DependencyPath string `json:"dependency_path"` + EnvNames []string `json:"env_names"` + NetworkAccess string `json:"network_access,omitempty"` } func prepareWorkspace(config Config, req proto.PromptRequestPayload) (*workspaceProfile, []string, error) { @@ -45,7 +47,17 @@ func prepareWorkspace(config Config, req proto.PromptRequestPayload) (*workspace if req.LocalEnvironment != nil && (config.Workspace.NetworkAccess == "" || req.LocalEnvironment.NetworkAccess != config.Workspace.NetworkAccess) { return nil, nil, fmt.Errorf("claudesdk: local Runtime network policy mismatch") } - return workspaceEnvironment(config) + profile, env, err := workspaceEnvironment(config) + if err != nil { + return nil, nil, err + } + if req.LocalEnvironment != nil && req.LocalEnvironment.ToolEnvironment { + if err := localworkspace.VerifyToolEnvironment(); err != nil { + return nil, nil, err + } + profile.ToolEnvironment = true + } + return profile, env, nil } func workspaceCwd(w *WorkspaceConfig) string { diff --git a/apps/parsar-daemon/internal/agent/codex/preparation.go b/apps/parsar-daemon/internal/agent/codex/preparation.go index 0e996372..6470334b 100644 --- a/apps/parsar-daemon/internal/agent/codex/preparation.go +++ b/apps/parsar-daemon/internal/agent/codex/preparation.go @@ -100,6 +100,7 @@ func newPreparation(parent context.Context, req proto.PromptRequestPayload, cfg defer harness.releaseWith(rpc) s := &Session{ + toolEnvironment: req.LocalEnvironment != nil && req.LocalEnvironment.ToolEnvironment, functions: functions, observeMessages: req.ObserveMessages, observeTools: req.ObserveTools, @@ -149,6 +150,11 @@ func newPreparation(parent context.Context, req proto.PromptRequestPayload, cfg return p.preparationFailed(err) } } + if s.toolEnvironment { + if err := verifyToolEnvironmentHook(cancelCtx, rpc, plan.Cwd); err != nil { + return p.preparationFailed(err) + } + } if plan.mcpHTTPServers != nil { if err := verifyMCPHTTPConfig(cancelCtx, rpc, plan); err != nil { cancelFn() diff --git a/apps/parsar-daemon/internal/agent/codex/session.go b/apps/parsar-daemon/internal/agent/codex/session.go index e586871a..59b7e1ba 100644 --- a/apps/parsar-daemon/internal/agent/codex/session.go +++ b/apps/parsar-daemon/internal/agent/codex/session.go @@ -61,6 +61,7 @@ func Factory(ctx context.Context, req proto.PromptRequestPayload, out chan<- pro // 5. turn/completed emits TypeDone + closes out. Cancel can short-cut // this by killing the child early. type Session struct { + toolEnvironment bool subagents *subagentObservations observeSubagentIdentities bool functions *functionCalls @@ -145,6 +146,7 @@ func (s *Session) registerHandlers() { rpc.OnNotification("item/reasoning/summaryTextDelta", s.onReasoningDelta) rpc.OnNotification("thread/tokenUsage/updated", s.onUsageUpdated) rpc.OnNotification("error", s.onErrorNotif) + rpc.OnNotification("hook/completed", s.onToolEnvironmentHook) rpc.OnServerRequest("item/commandExecution/requestApproval", s.handleCodexCommandApproval) rpc.OnServerRequest("item/fileChange/requestApproval", s.handleCodexFileApproval) diff --git a/apps/parsar-daemon/internal/agent/codex/session_plan.go b/apps/parsar-daemon/internal/agent/codex/session_plan.go index 0adb652f..c82eccc0 100644 --- a/apps/parsar-daemon/internal/agent/codex/session_plan.go +++ b/apps/parsar-daemon/internal/agent/codex/session_plan.go @@ -4,6 +4,7 @@ import ( "context" "fmt" + "github.com/MiniMax-AI-Dev/parsar/apps/parsar-daemon/internal/localworkspace" "github.com/MiniMax-AI-Dev/parsar/internal/agentdaemon/proto" ) @@ -33,6 +34,15 @@ func prepareSessionPlan(ctx context.Context, req proto.PromptRequestPayload, cfg plan.ExtraConfig = append(plan.ExtraConfig, [2]string{"features.shell_snapshot", "false"}) } + if req.LocalEnvironment != nil && req.LocalEnvironment.ToolEnvironment { + if err := localworkspace.VerifyToolEnvironment(); err != nil { + plan.Cleanup() + return SessionPlan{}, "", err + } + plan.Env = append(plan.Env, "PARSAR_RUNTIME_TOOL_ENV=1") + plan.ExtraConfig = append(plan.ExtraConfig, [2]string{"features.hooks", "true"}) + } + if req.DisableSubagents { disableSubagents(&plan) } diff --git a/apps/parsar-daemon/internal/agent/codex/tool_environment.go b/apps/parsar-daemon/internal/agent/codex/tool_environment.go new file mode 100644 index 00000000..d8ce2c32 --- /dev/null +++ b/apps/parsar-daemon/internal/agent/codex/tool_environment.go @@ -0,0 +1,68 @@ +package codex + +import ( + "context" + "encoding/json" + "errors" + "time" +) + +const toolEnvironmentHookSource = "/etc/codex/runtime-hooks" +const toolEnvironmentHookCommand = "/usr/bin/python3 -I -S /etc/codex/tool-env.py" + +func verifyToolEnvironmentHook(ctx context.Context, rpc *JSONRPCClient, cwd string) error { + operation, cancel := context.WithTimeout(ctx, 10*time.Second) + defer cancel() + raw, err := rpc.Request(operation, "hooks/list", map[string]any{"cwds": []string{cwd}}) + if err != nil { + return errors.New("codex: initialized tool hook unavailable") + } + var response struct { + Data []struct { + CWD string `json:"cwd"` + Hooks []struct { + EventName string `json:"eventName"` + Command string `json:"command"` + Matcher string `json:"matcher"` + Enabled bool `json:"enabled"` + IsManaged bool `json:"isManaged"` + Async bool `json:"async"` + SourcePath string `json:"sourcePath"` + TrustStatus string `json:"trustStatus"` + } `json:"hooks"` + Errors []json.RawMessage `json:"errors"` + } `json:"data"` + } + if json.Unmarshal(raw, &response) != nil || len(response.Data) != 1 || response.Data[0].CWD != cwd || len(response.Data[0].Errors) != 0 { + return errors.New("codex: initialized tool hook configuration unavailable") + } + for _, hook := range response.Data[0].Hooks { + if hook.EventName == "preToolUse" && hook.Command == toolEnvironmentHookCommand && hook.Matcher == "^Bash$" && hook.Enabled && hook.IsManaged && !hook.Async && hook.SourcePath == toolEnvironmentHookSource && hook.TrustStatus == "managed" { + return nil + } + } + return errors.New("codex: required initialized tool hook missing") +} + +func (s *Session) onToolEnvironmentHook(raw json.RawMessage) { + if !s.toolEnvironment { + return + } + var notification struct { + ThreadID string `json:"threadId"` + TurnID string `json:"turnId"` + Run struct { + SourcePath string `json:"sourcePath"` + Status string `json:"status"` + } `json:"run"` + } + if json.Unmarshal(raw, ¬ification) != nil || notification.Run.SourcePath != toolEnvironmentHookSource || !s.isRootTurn(notification.ThreadID, notification.TurnID) { + return + } + if notification.Run.Status == "failed" { + // Native hook process failures can run the original command before this + // notification. Stop subsequent work and report failure, never success. + s.emitTerminal("codex: initialized tool configuration failed; prior command effects may exist", true) + s.cancelFn() + } +} diff --git a/apps/parsar-daemon/internal/agent/codex/tool_environment_test.go b/apps/parsar-daemon/internal/agent/codex/tool_environment_test.go new file mode 100644 index 00000000..b1dbd16b --- /dev/null +++ b/apps/parsar-daemon/internal/agent/codex/tool_environment_test.go @@ -0,0 +1,75 @@ +package codex + +import ( + "context" + "encoding/json" + "testing" + "time" + + "github.com/MiniMax-AI-Dev/parsar/internal/agentdaemon/proto" +) + +func TestInitializedToolHookReadiness(t *testing.T) { + for _, mode := range []string{"ready", "disabled", "unmanaged", "async", "wrong command", "errors"} { + t.Run(mode, func(t *testing.T) { + client, server, cleanup := NewTestClient() + defer cleanup() + ctx, cancel := context.WithTimeout(t.Context(), time.Second) + defer cancel() + done := make(chan error, 1) + go func() { done <- verifyToolEnvironmentHook(ctx, client.JSONRPCClient, "/workspace") }() + var request struct { + ID, Method string + Params struct { + CWDs []string `json:"cwds"` + } + } + if err := json.NewDecoder(server.FromClient).Decode(&request); err != nil { + t.Fatal(err) + } + if request.Method != "hooks/list" || len(request.Params.CWDs) != 1 || request.Params.CWDs[0] != "/workspace" { + t.Fatal("wrong hook lookup") + } + hook := map[string]any{"eventName": "preToolUse", "command": toolEnvironmentHookCommand, "matcher": "^Bash$", "enabled": true, "isManaged": true, "async": false, "sourcePath": toolEnvironmentHookSource, "trustStatus": "managed"} + entry := map[string]any{"cwd": "/workspace", "hooks": []any{hook}, "errors": []any{}} + switch mode { + case "disabled": + hook["enabled"] = false + case "unmanaged": + hook["isManaged"] = false + case "async": + hook["async"] = true + case "wrong command": + hook["command"] = "/workspace/untrusted" + case "errors": + entry["errors"] = []any{"cannot load hook"} + } + if err := json.NewEncoder(server.ToClient).Encode(map[string]any{"id": request.ID, "result": map[string]any{"data": []any{entry}}}); err != nil { + t.Fatal(err) + } + if err := <-done; (err == nil) != (mode == "ready") { + t.Fatal("hook admission", mode, err) + } + }) + } +} + +func TestInitializedToolHookFailureStopsRunWithoutSuccess(t *testing.T) { + ctx, cancel := context.WithCancel(t.Context()) + defer cancel() + out := make(chan proto.Envelope, 4) + s := &Session{toolEnvironment: true, runID: "run", cancelCtx: ctx, cancelFn: cancel, out: out, cfg: defaultSessionConfig(), bufs: NewItemBuffers()} + s.setThreadID("thread") + s.beginRootTurn("thread", "turn") + s.onToolEnvironmentHook(json.RawMessage(`{"threadId":"foreign","turnId":"turn","run":{"sourcePath":"/etc/codex/runtime-hooks","status":"failed"}}`)) + if s.terminal.Load() { + t.Fatal("foreign hook ended run") + } + s.onToolEnvironmentHook(json.RawMessage(`{"threadId":"thread","turnId":"turn","run":{"sourcePath":"/etc/codex/runtime-hooks","status":"failed"}}`)) + if !s.terminal.Load() || ctx.Err() == nil { + t.Fatal("failed hook did not stop execution") + } + if len(out) != 2 || (<-out).Type != proto.TypeError || (<-out).Type != proto.TypeDone { + t.Fatal("failed hook reported wrong terminal events") + } +} diff --git a/apps/parsar-daemon/internal/agent/mcode/workspace.go b/apps/parsar-daemon/internal/agent/mcode/workspace.go index 5d75fb95..3718f72a 100644 --- a/apps/parsar-daemon/internal/agent/mcode/workspace.go +++ b/apps/parsar-daemon/internal/agent/mcode/workspace.go @@ -9,6 +9,7 @@ import ( "slices" "strings" + "github.com/MiniMax-AI-Dev/parsar/apps/parsar-daemon/internal/localworkspace" "github.com/MiniMax-AI-Dev/parsar/internal/agentdaemon/proto" ) @@ -77,6 +78,16 @@ func prepareWorkspaceOptions(ctx context.Context, c WorkspaceConfig, req proto.P return opts, err } profile := map[string]any{"workspace": "/workspace", "scratch": c.Scratch, "network": c.Network, "protectedDirs": slices.Clone(c.ProtectedDirs)} + if req.LocalEnvironment.ToolEnvironment { + if err := localworkspace.VerifyToolEnvironment(); err != nil { + return opts, err + } + profile["toolEnvironment"] = true + // Initialization exposes only user env/packages; private staging and + // daemon/native history remain explicitly denied. + protected := slices.Clone(c.ProtectedDirs) + profile["protectedDirs"] = slices.DeleteFunc(protected, func(path string) bool { return path == "/environment" }) + } raw, err = json.Marshal(profile) if err != nil { return opts, err diff --git a/apps/parsar-daemon/internal/localworkspace/binding.go b/apps/parsar-daemon/internal/localworkspace/binding.go index 938c0c34..213aa956 100644 --- a/apps/parsar-daemon/internal/localworkspace/binding.go +++ b/apps/parsar-daemon/internal/localworkspace/binding.go @@ -93,6 +93,11 @@ func (b *Binding) Configure(r proto.PromptRequestPayload) (proto.PromptRequestPa return r, errors.New("request does not match the local Runtime network policy") } if !r.WorkspaceReadOnly { + if r.LocalEnvironment.ToolEnvironment { + if err := VerifyToolEnvironment(); err != nil { + return r, err + } + } r.WorkDir = b.workspace } return r, nil diff --git a/apps/parsar-daemon/internal/localworkspace/initialization.go b/apps/parsar-daemon/internal/localworkspace/initialization.go new file mode 100644 index 00000000..6c3365c8 --- /dev/null +++ b/apps/parsar-daemon/internal/localworkspace/initialization.go @@ -0,0 +1,35 @@ +package localworkspace + +import ( + "errors" + "os" + "path/filepath" +) + +// These paths belong to the packaged Runtime, not a harness or public template. +const ( + InitializationDirectory = "/environment/initialization" + ToolEnvironmentShell = InitializationDirectory + "/tool-env.sh" + ToolEnvironmentJSON = InitializationDirectory + "/tool-env.json" + PackageDirectory = "/environment/packages" +) + +// VerifyToolEnvironment is required only for execution consuming initialized +// tool configuration. It never makes Files reads depend on execution setup. +func VerifyToolEnvironment() error { + for _, path := range []string{InitializationDirectory, PackageDirectory, ToolEnvironmentShell, ToolEnvironmentJSON} { + actual, err := filepath.EvalSymlinks(path) + info, statErr := os.Lstat(path) + if err != nil || statErr != nil || actual != path { + return errors.New("initialized tool configuration unavailable") + } + if path == InitializationDirectory || path == PackageDirectory { + if !info.IsDir() { + return errors.New("initialized tool directory unavailable") + } + } else if !info.Mode().IsRegular() || info.Mode().Perm()&0222 != 0 || info.Size() > 1024*1024 { + return errors.New("initialized tool configuration is not immutable") + } + } + return nil +} diff --git a/contracts/agents-api/README.md b/contracts/agents-api/README.md index f90a8001..1c1a5e1a 100644 --- a/contracts/agents-api/README.md +++ b/contracts/agents-api/README.md @@ -355,7 +355,7 @@ including further deployment qualification; this inventory describes merged beha describe native discovery or workspace files created by commands. Unknown installation configurations are rejected, not reported as empty. Reads use the owning live Session's project partition and do not require execution setup. - Other populated installation metadata/configuration, full hosted lifecycle and + System packages and remaining unsupported installation configuration, full hosted lifecycle and exact hosted error semantics remain gaps. [Environment Templates](environment-templates.md) provide tenant-owned CRUD/list @@ -399,7 +399,9 @@ operator setup: [Codex](../../services/agents-api/deploy/codex/README.md), [MiniMax Code](../../services/agents-api/deploy/mcode/README.md). The [E2B guide](../../services/agents-api/deploy/e2b/README.md) packages those qualified images as pinned templates. -Other populated startup installations, restricted domains and hosted public +The env/setup/npm/Python initialization batch extends these profiles; see its +[current evidence boundary](environment-templates.md#current-setup-batch). System +packages, remaining unsupported startup installations, restricted domains and hosted public HTTP MCP remain outside these accepted profiles. MiniMax's private MCP tool bridge is internal transport, not public MCP support. diff --git a/contracts/agents-api/environment-templates.md b/contracts/agents-api/environment-templates.md index 7e32f753..a7a69069 100644 --- a/contracts/agents-api/environment-templates.md +++ b/contracts/agents-api/environment-templates.md @@ -1,4 +1,4 @@ -# Environment Templates and initial files +# Environment Templates and initialization Core owns reusable configuration through the five pinned [Template operations](https://github.com/openai/openai-python/blob/d7c41efee1b0802b79f3f88a678ef2052b06e9ce/src/openai/resources/beta/agents/environments/templates.py). @@ -18,7 +18,7 @@ and five-operation SandboxProvider path as inline configuration. or network replaces, with null clearing name or resetting network. - Empty/null installation fields retain empty defaults. Responses contain safe metadata and never `env`, `setup_commands` or inline file data. Initial files are - supported as described below; other populated installations reject explicitly. + supported as described below, together with env, ordered setup and npm/Python packages; remaining populated installations reject explicitly. - Listing uses `after`, `limit` (1–100, default 20), and `order` (default `desc`). Creation timestamp plus ID supplies stable local ordering. Missing/foreign IDs and cursors return the same not-found result. No compute is allocated by CRUD. @@ -35,7 +35,9 @@ from openai import OpenAI client = OpenAI(base_url="https://your-core.example/v1", api_key="your-project-key") template = client.beta.agents.environments.templates.create( - name="Restricted outbound access", network={"access": "disabled"} + name="Python workspace", network={"access": "disabled"}, + env={"APP_MODE": "analysis"}, packages={"python": ["packaging==26.0"]}, + setup_commands=[{"command": "mkdir -p /workspace/outputs"}] ) session = client.beta.agents.sessions.create( agent={"model": "your-configured-model"}, @@ -83,18 +85,81 @@ native-history recovery preserve user modifications instead of reinstalling file Docker/E2B and all three harnesses use this same lifecycle. The Provider API remains five operations; public Templates are never E2B image templates. +## Packaged Runtime initialization contract + +Template handlers and stores resolve public configuration without choosing a +harness, native path or compute backend. The common initialization lifecycle uses +the following existing Linux Runtime packaging requirements through Provider +`RunCommand`; these are private deployment requirements, not public Template fields. + +- `/workspace` is the public workspace. `/environment/workspace` names the same + storage for trusted initialization; `/environment/staging` is private staging. +- `/usr/bin/python3 -I -S` runs the trusted, fd-anchored initial-file installer. + It invokes the existing `/usr/local/bin/agents-api-codex-write` atomic writer. + That executable is a shared filesystem helper packaged for every harness; its + historical name does not select Codex or invoke native Codex tools. +- Confidential content travels on bounded stdin. Successful initialization needs + the writer's versioned completion receipt and confirmed process exit. Unknown + effects use the existing allocation cleanup path rather than replay. +- Provider implementations preserve argv, stdin, exit status and allocation + ownership. They do not interpret public templates. Runtime adapters own native + configuration; initialization must not consume a harness's private history, + model credentials or native tool protocol. + +New hosted harnesses reuse these helpers and paths; new Providers deploy the same +Runtime contract. Neither addition should change template validation, storage or +resolution. Extend this contract only for an accepted initialization requirement. +The trusted `/usr/local/bin/agents-api-runtime-initialize` receives a bounded +version-1 JSON operation on stdin. It configures read-only tool env under +`/environment/initialization`, installs packages under `/environment/packages`, +and runs ordered commands through distro bubblewrap. The fixed mount/process map +excludes daemon credentials, native history and staging. User values are applied +inside isolation, never to the launcher. Receipt and process exit must both confirm +completion; child output is discarded because it can contain secrets. + +Runtime receives a `tool_environment` execution flag, without template identity or +provider information. Adapters validate the common files and apply them in their +native tool sandbox: Claude uses its native Bash hook, Codex its managed Bash hook, +and MiniMax its isolated native-tool worker. Native transports remain unchanged. +Files reads do not require initialized tool configuration. Docker setup requires +the existing nested-sandbox deployment profile for every harness; E2B supplies +the same Runtime layout and kernel isolation. + +Codex 0.153.4 can execute an original command when a native hook process fails. +The adapter verifies the required trusted managed hook before preparation and +stops the Turn on an observed failed hook. Earlier command effects may already +exist; this is not an atomic hook-failure prevention guarantee. + ## Explicit gaps and evidence boundaries -Nonempty `env`, `setup_commands`, `packages`, `capability_directories`, -`skills` and `plugins`, plus restricted-domain network policy, remain unsupported +System packages, nonempty `capability_directories`, `skills` and `plugins`, +plus restricted-domain network policy, remain unsupported for both templates and inline initialization. The separate live Files API remains available after initialization. Unsupported requests reject without echoing payloads. The [hosted guide](https://developers.openai.com/api/docs/guides/agents-api/environments/openai-hosted) clarifies that configured env values are readable by Agent code, files/packages precede setup commands, nonzero setup prevents start, and runtime-reserved env names -must reject. Implementing those populated fields requires a separate batch with -common initialization, safe snapshots and failure/readiness acceptance. +must reject. The shared initialization batch implements those fields with encrypted snapshots +and the existing readiness gate. Public reads show packages but omit env/commands. +Template updates replace each supplied field; omission preserves it and null clears +it. Referenced Sessions inherit the snapshot; explicit env/packages/setup overrides +with a template ID reject while override semantics remain unconfirmed. + +Files are installed first, followed by npm/Python packages and ordered commands; +the default cwd is `/workspace`. One command or package operation has the existing +two-minute local budget, within the thirty-minute initialization budget. No command +is retried after unknown effects. Completed setup never runs on reconnect. +Package dependencies are available to native tools across working directories. +System-level packages remain a separate privilege-boundary gap. + +The [update Reference](https://developers.openai.com/api/reference/python/resources/beta/subresources/agents/subresources/environments/subresources/templates/methods/update) +defines runtime network as post-setup and packages as preceding that policy. +Initialization therefore uses its isolated provisioning network; native tools +apply the requested enabled/disabled policy afterward. Allowing setup internet is +an implementation inference from that phase boundary, not an explicit upstream +guarantee. Env values are intentionally readable by Agent code; they must not +appear automatically in public metadata or initialization diagnostics. The [current Template reference](https://developers.openai.com/api/reference/python/resources/beta/subresources/agents/subresources/environments/subresources/templates) mentions different GA/beta defaults; this service retains `agents=v1` and the @@ -112,8 +177,8 @@ private `verify_environment_templates: true`; it creates its actual native/model Sessions from public templates, verifies frozen snapshots and creation retries after update/delete, then reuses the existing execution, Files/Artifacts, isolation, cancellation and crash/history-recovery assertions. Its disabled-network Session -inherits that policy from another template. Runtime and provider packaging are -unchanged. Database integration tests cover persistence and concurrent field updates; +inherits that policy from another template. Runtime and provider packaging were +unchanged in the original metadata-only batch. Database integration tests cover persistence and concurrent field updates; API tests cover parsing and caller-intent distinctions. `official_environment_initial_files.py` and the `verify_initial_files: true` option @@ -156,3 +221,37 @@ recorded with unknown cause, as does a Docker reconnect failure under a differen Core/Runtime restart order. They are not claimed as fixed. Sanitized run results, checks, build hashes and failed attempts are retained under the private `environment-template-files` acceptance directory and the linked task record. + +### Current setup batch + +`official_environment_setup.py` adds fixed-client/raw-response assertions for +confidential snapshots, safe package metadata, real registry dependencies, ordered +setup, native visibility across cwd and the post-setup network boundary. Runtime +mechanism tests cover private files/processes, immutable configuration, child +cleanup and failure receipts. The batch passed real-model template and inline acceptance on newly built Docker +Runtimes for Codex, Claude Code and MiniMax Code, plus Codex on a newly built E2B +template. Each verified actual npm/PyPI installs, ordered setup, native env and +dependency visibility across working directories, Files/Artifacts, cancellation, +post-setup disabled networking and recovery without repeating initialization. E2B +also verified daemon/history/process/envd isolation and separate Core/Runtime +crashes with exact native history and no automatic input replay. + +The shared initializer additionally passed actual Docker isolation probes for all +three profiles and E2B registry installation. Docker nonzero setup and missing cwd +failed before native Turns and reclaimed the Environment. PostgreSQL/race checks +cover encrypted owner/field-bound snapshots, readiness and uncertain-install cleanup. +A rebuilt standalone Core passed real fixed-SDK/raw-HTTP retries with changed, added +and removed inline env/setup under a saved Agent; unchanged retries still recover +after Agent deletion. Only this creation-identity regression required the final +Core rebuild; the completed model matrix preceded that isolated hash correction. + +One MiniMax inline post-restart model request reported an upstream timeout after +100 seconds. The affected inline rerun passed in 214.75 seconds, with no production +transport changes; this does not establish or fix the timeout cause. All completed +runs confirmed owned resource cleanup. The three-harness-by-two-Provider matrix +was not repeated: shared E2B initialization and the changed native adapter paths +were covered separately. System packages and unconfirmed reference overrides +remain gaps, and native Codex hook failure retains the limitation stated above. +Private sanitized run/check/build evidence is retained under +`~/.parsar/remediation/20260920/environment-template-setup/` and the linked board. +These results do not establish complete Template or Agents API compatibility. diff --git a/contracts/agents-api/openapi.yaml b/contracts/agents-api/openapi.yaml index 25e9222e..75bbdf62 100644 --- a/contracts/agents-api/openapi.yaml +++ b/contracts/agents-api/openapi.yaml @@ -241,6 +241,11 @@ definitions: type: string type: array x-nullable: true + env: + additionalProperties: + type: string + type: object + x-nullable: true environment_template_id: type: string files: @@ -252,6 +257,15 @@ definitions: allOf: - $ref: '#/definitions/v1.EnvironmentNetworkInput' x-nullable: true + packages: + allOf: + - $ref: '#/definitions/v1.EnvironmentPackages' + x-nullable: true + setup_commands: + items: + type: object + type: array + x-nullable: true type: enum: - none @@ -2041,11 +2055,11 @@ paths: consumes: - application/json description: Saves tenant-owned basic hosted configuration. Supports nullable - name, enabled/disabled network, initial inline/file_id files and empty remaining - installation defaults. Omitted/null network defaults to enabled. Other populated - installations and restricted network are rejected before persistence without - echoing input. No compute is allocated. Exact hosted error/retry semantics - remain unverified. + name, enabled/disabled network, initial inline/file_id files, confidential + env, ordered setup_commands and npm/Python packages. Omitted/null network + defaults to enabled. System packages, other populated installations and restricted + network are rejected before persistence without echoing input. No compute + is allocated. Exact hosted error/retry semantics remain unverified. parameters: - description: agents=v1 in: header @@ -2346,15 +2360,17 @@ paths: supports non-deferred function tools with text results alongside native workspace tools; HTTP MCP remains unsupported. Idle Sessions provision automatically; initial provisioning has no caller connection action. Network defaults to - enabled; disabled is also supported, while restricted domains and other populated - startup installations are rejected. Initial inline and tenant-owned file_id - files freeze encrypted bytes before provisioning, then install through the - common Core lifecycle before native execution or live Files access. Referenced - files overrides are rejected pending semantic verification. Tenant-owned environment_template_id - references inherit omitted network and allow only narrowing overrides. Referenced - network:null is explicitly unsupported pending semantic verification. Core - freezes effective configuration; template updates/deletion do not alter Session - snapshots or same-intent creation retries. + enabled; disabled is also supported, while restricted domains, system packages + and remaining unsupported startup installations are rejected. Confidential + env, npm/Python packages and ordered setup commands use the shared initialization + lifecycle; requested network applies after setup. Initial inline and tenant-owned + file_id files freeze encrypted bytes before provisioning, then install through + the common Core lifecycle before native execution or live Files access. Referenced + files/env/packages/setup overrides are rejected pending semantic verification. + Tenant-owned environment_template_id references inherit omitted network and + allow only narrowing overrides. Referenced network:null is explicitly unsupported + pending semantic verification. Core freezes effective configuration; template + updates/deletion do not alter Session snapshots or same-intent creation retries. parameters: - description: agents=v1 in: header diff --git a/contracts/agents-api/v1/sessions.go b/contracts/agents-api/v1/sessions.go index 0aa1947b..936bc3f3 100644 --- a/contracts/agents-api/v1/sessions.go +++ b/contracts/agents-api/v1/sessions.go @@ -35,6 +35,9 @@ type InlineAgent struct { // Environment contains supported request variants; self-hosted creation requires a workspace directory. type Environment struct { + Env map[string]string `json:"env,omitempty" extensions:"x-nullable"` + SetupCommands []json.RawMessage `json:"setup_commands,omitempty" extensions:"x-nullable" swaggertype:"array,object"` + Packages *EnvironmentPackages `json:"packages,omitempty" extensions:"x-nullable"` Files []json.RawMessage `json:"files,omitempty" swaggertype:"array,object" extensions:"x-nullable"` EnvironmentTemplateID string `json:"environment_template_id,omitempty"` Type string `json:"type" enums:"none,self_hosted,openai_hosted" binding:"required"` diff --git a/internal/agentdaemon/proto/environment.go b/internal/agentdaemon/proto/environment.go index 6e43ca06..5159bafb 100644 --- a/internal/agentdaemon/proto/environment.go +++ b/internal/agentdaemon/proto/environment.go @@ -3,6 +3,8 @@ package proto // LocalEnvironment references a deployment-bound workspace; it never supplies a path. type LocalEnvironment struct { ID string `json:"id"` + // ToolEnvironment consumes Core-completed confidential initialization. + ToolEnvironment bool `json:"tool_environment,omitempty"` // NetworkAccess must match the immutable Runtime policy for execution. NetworkAccess string `json:"network_access,omitempty"` } diff --git a/packages/claude-sdk-adapter/src/workspace.ts b/packages/claude-sdk-adapter/src/workspace.ts index e866be98..8c297607 100644 --- a/packages/claude-sdk-adapter/src/workspace.ts +++ b/packages/claude-sdk-adapter/src/workspace.ts @@ -9,6 +9,7 @@ export type Workspace = { protected_dirs: string[]; dependency_path: string; env_names: string[]; + tool_environment?: boolean; network_access?: "enabled" | "disabled"; }; @@ -39,7 +40,8 @@ export function parseWorkspace(value: unknown, cwd: string): Workspace | undefin if (value === undefined) return undefined; if (!value || typeof value !== "object" || Array.isArray(value)) throw new Error("invalid_request"); const config = value as Record; - if (Object.keys(config).some(key => !["home", "state", "scratch", "protected_dirs", "dependency_path", "env_names", "network_access"].includes(key)) || + if (Object.keys(config).some(key => !["home", "state", "scratch", "protected_dirs", "dependency_path", "env_names", "network_access", "tool_environment"].includes(key)) || + (config.tool_environment !== undefined && typeof config.tool_environment !== "boolean") || (config.network_access !== undefined && config.network_access !== "enabled" && config.network_access !== "disabled") || !Array.isArray(config.protected_dirs) || !Array.isArray(config.env_names) || typeof config.dependency_path !== "string" || !config.dependency_path || @@ -61,7 +63,7 @@ export function parseWorkspace(value: unknown, cwd: string): Workspace | undefin export class WorkspaceProfile { readonly options: Options; - constructor(private readonly cwd: string, config: Workspace, private readonly functions: readonly string[] = []) { + constructor(private readonly cwd: string, private readonly config: Workspace, private readonly functions: readonly string[] = []) { config = parseWorkspace(config, cwd)!; // SDK history lookup reads the bridge environment, independently of query.env. if (process.env.HOME !== config.home || process.env.CLAUDE_CONFIG_DIR !== config.state || @@ -91,7 +93,7 @@ export class WorkspaceProfile { sandbox: { enabled: true, failIfUnavailable: true, autoAllowBashIfSandboxed: false, allowUnsandboxedCommands: false, excludedCommands: [], enableWeakerNestedSandbox: false, enableWeakerNetworkIsolation: false, - filesystem: { disabled: false, allowWrite: [cwd, config.scratch], denyRead: protectedRoots, + filesystem: { disabled: false, allowWrite: [cwd, config.scratch, ...(config.tool_environment ? ["/environment/packages"] : [])], denyRead: protectedRoots, denyWrite: protectedRoots, allowRead: [] }, credentials: { envVars: [...new Set([...credentialNames, ...config.env_names])].map(name => ({ name, mode: "deny" })), @@ -122,6 +124,12 @@ export class WorkspaceProfile { readonly beforeTool: HookCallback = async (input, id, { signal }) => { if (!signal.aborted && input.hook_event_name === "PreToolUse" && input.agent_id === undefined && (id === undefined || id === input.tool_use_id) && this.permits(input.tool_name, input.tool_input)) { + if (input.tool_name === "Bash" && this.config.tool_environment) { + const toolInput = input.tool_input as Record; + const quote = (text: string) => "'" + text.replaceAll("'", "'\\''") + "'"; + return { hookSpecificOutput: { hookEventName: "PreToolUse", updatedInput: { ...toolInput, + command: ". /environment/initialization/tool-env.sh && eval -- " + quote(toolInput.command as string) } } }; + } return input.tool_name !== "Read" && input.tool_name !== "Edit" ? {} : { hookSpecificOutput: { hookEventName: "PreToolUse", updatedInput: this.absoluteInput(input.tool_name, input.tool_input as Record) } }; } diff --git a/packages/claude-sdk-adapter/tests/workspace.test.mjs b/packages/claude-sdk-adapter/tests/workspace.test.mjs index 99331d8e..b71814d9 100644 --- a/packages/claude-sdk-adapter/tests/workspace.test.mjs +++ b/packages/claude-sdk-adapter/tests/workspace.test.mjs @@ -202,3 +202,19 @@ test("workspace functions retain native sandbox and exact tool authority", async controller.abort(); assert.equal((await profile.canUseTool("mcp__functions__lookup", input, options)).behavior, "deny"); }); + +test("initialized user env is applied inside native Bash, never SDK spawn env", async t => { + const { dirs, config } = fixture(t); + const profile = new WorkspaceProfile(dirs.workspace, { ...config, tool_environment: true }); + assert.equal(profile.options.env.PYTHONPATH, undefined); + assert.equal(profile.options.env.PATH, dirs.deps); + assert.ok(profile.options.sandbox.filesystem.allowWrite.includes("/environment/packages")); + const input = { hook_event_name: "PreToolUse", tool_name: "Bash", tool_use_id: "tool", + tool_input: { command: "printf '%s' 'quoted value'", timeout: 1000 } }; + const result = await profile.beforeTool(input, "tool", { signal: new AbortController().signal }); + assert.equal(result.hookSpecificOutput.updatedInput.timeout, 1000); + assert.equal(result.hookSpecificOutput.updatedInput.command, + ". /environment/initialization/tool-env.sh && eval -- 'printf '\\''%s'\\'' '\\''quoted value'\\'''" ); + const denied = await profile.beforeTool({ ...input, tool_input: { command: "id", dangerouslyDisableSandbox: true } }, "tool", { signal: new AbortController().signal }); + assert.equal(denied.hookSpecificOutput.permissionDecision, "deny"); +}); diff --git a/packages/mcode-harness/launch.mjs b/packages/mcode-harness/launch.mjs index cd39c492..f3447531 100644 --- a/packages/mcode-harness/launch.mjs +++ b/packages/mcode-harness/launch.mjs @@ -17,11 +17,11 @@ try { mkdirSync(profile.scratch,{recursive:true}); await SandboxManager.initialize({ network:{allowedDomains:[],deniedDomains:profile.network==='disabled'?['*']:[],allowAll:profile.network==='enabled'}, - filesystem:{denyRead:profile.protectedDirs,allowWrite:[profile.workspace,profile.scratch],denyWrite:[]}, + filesystem:{denyRead:profile.protectedDirs,allowWrite:[profile.workspace,profile.scratch,...(profile.toolEnvironment ? ['/environment/packages'] : [])],denyWrite:[]}, seccomp:{applyPath:join(here,'dist/vendor/seccomp/x64/apply-seccomp')}, },undefined,false); const quote=s=>"'"+s.replaceAll("'","'\\''")+"'"; - const command=[process.execPath,join(here,'dist/worker.mjs'),profile.workspace].map(quote).join(' '); + const command=[process.execPath,join(here,'dist/worker.mjs'),profile.workspace,...(profile.toolEnvironment ? ['--tool-environment'] : [])].map(quote).join(' '); const wrapped=await SandboxManager.wrapWithSandbox(command,'/bin/bash',undefined,undefined,{baseEnv,sandboxTempDir:profile.scratch}); if (cancelled) throw new Error('Cancelled before workspace tool start'); child=spawn('/bin/bash',['-c','exec '+wrapped],{cwd:profile.workspace,env:baseEnv,stdio:['pipe','pipe','pipe']}); diff --git a/packages/mcode-harness/worker.ts b/packages/mcode-harness/worker.ts index 612e6418..97d39a90 100644 --- a/packages/mcode-harness/worker.ts +++ b/packages/mcode-harness/worker.ts @@ -20,6 +20,19 @@ const tool = tools.find(value => value.def.name === request.tool); if (!tool || !request.input || typeof request.input !== 'object' || Array.isArray(request.input) || !isRuntimeToolInputValid(tools, request.tool, request.input)) throw new Error('invalid tool request'); +if (process.argv[3] === '--tool-environment') { + const env = JSON.parse(readFileSync('/environment/initialization/tool-env.json', 'utf8')); + for (const [name, value] of Object.entries(env)) { + if (typeof value !== 'string') throw new Error('invalid initialized tool environment'); + process.env[name] = value; + } + // The native Bash boundary strips native identity variables even in mode:off. + // Reapply user values in the already isolated shell without changing tools. + if (request.tool === 'bash') { + const quote = (text: string) => "'" + text.replaceAll("'", "'\\''") + "'"; + request.input.command = '. /environment/initialization/tool-env.sh && eval -- ' + quote(request.input.command); + } +} const context = { sessionId: 'worker', turnId: 'call', allowBashAutoPromotion: false, canConsumeBackgroundBashOutput: false }; try { diff --git a/scripts/build-agents-runtime.sh b/scripts/build-agents-runtime.sh index c7223d13..c66fcb06 100755 --- a/scripts/build-agents-runtime.sh +++ b/scripts/build-agents-runtime.sh @@ -34,8 +34,10 @@ trap 'rm -rf "$context"' EXIT cp "$helpers_dir/agents-api-codex-directory" "$helpers_dir/agents-api-codex-write" "$helpers_dir/agents-api-workspace-export" "$context/" cp "$native_dir/bin/codex" "$context/codex" cp -R "$native_dir/codex-resources" "$context/codex-resources" +cp "$repo_root/services/agents-api/deploy/codex/tool-env.py" "$context/tool-env.py" cp "$repo_root/services/agents-api/deploy/codex/requirements.toml" "$context/requirements.toml" cp "$repo_root/services/agents-api/deploy/codex/Dockerfile" "$context/Dockerfile" +cp "$repo_root/services/agents-api/deploy/runtime/initialize.py" "$context/runtime-initialize.py" # Preserve the previous bundle if compilation or validation failed. mkdir -p "$output_dir" cp -R "$context/." "$output_dir/" diff --git a/scripts/build-claude-runtime.sh b/scripts/build-claude-runtime.sh index c04fd36e..00aa95d5 100755 --- a/scripts/build-claude-runtime.sh +++ b/scripts/build-claude-runtime.sh @@ -27,6 +27,7 @@ for helper in agents-api-codex-directory agents-api-codex-write agents-api-works cp "$helpers_dir/$helper" "$context/" done cp "$repo_root/services/agents-api/deploy/claude/Dockerfile" "$context/Dockerfile" +cp "$repo_root/services/agents-api/deploy/runtime/initialize.py" "$context/runtime-initialize.py" mkdir -p "$output_dir" cp -R "$context/." "$output_dir/" printf 'Claude Runtime image context: %s\n' "$output_dir" diff --git a/scripts/build-mcode-runtime.sh b/scripts/build-mcode-runtime.sh index efa111a8..61b905a2 100644 --- a/scripts/build-mcode-runtime.sh +++ b/scripts/build-mcode-runtime.sh @@ -28,6 +28,7 @@ for helper in agents-api-codex-directory agents-api-codex-write agents-api-works cp "$helpers/$helper" "$context/" done cp "$repo_root/services/agents-api/deploy/mcode/Dockerfile" "$context/Dockerfile" +cp "$repo_root/services/agents-api/deploy/runtime/initialize.py" "$context/runtime-initialize.py" mkdir -p "$output" cp -R "$context/." "$output/" printf 'MiniMax Code Runtime image context: %s\n' "$output" diff --git a/services/agents-api/README.md b/services/agents-api/README.md index 1f91e724..6eebe57e 100644 --- a/services/agents-api/README.md +++ b/services/agents-api/README.md @@ -240,8 +240,8 @@ only completion snapshots. Pinned native 0.153.4 may also omit early process output from both notifications and its final aggregate; this remains an upstream execution gap. Recover missed output with Items queries, not SSE replay. -Non-text message input, Subagents and installations beyond hosted initial files -remain unsupported. Saving optional Agent configuration does not make +Non-text message input, Subagents and installations beyond hosted initial files, +env, ordered setup and npm/Python packages remain unsupported. Saving optional Agent configuration does not make it executable. Unsupported requests fail explicitly. `/healthz` reports liveness only. ## Managed hosted execution @@ -253,8 +253,10 @@ then follow the [Docker setup](deploy/codex/README.md#standalone-operator-config or [E2B template/provider setup](deploy/e2b/README.md). Core remains independently deployed with its own database. Public idle and initial text Sessions share the existing preparation, execution, Files and recovery paths. -Networking defaults to enabled; disabled is also supported. Restricted domains, -other populated startup installations remain gaps. Initial inline/file_id files and [public Environment Templates](../../contracts/agents-api/environment-templates.md) +Networking defaults to enabled; disabled is also supported after setup completes. +Restricted domains, system packages and remaining unsupported startup installations +remain gaps. Initial inline/file_id files, confidential env, npm/Python packages, +ordered setup and [public Environment Templates](../../contracts/agents-api/environment-templates.md) resolve to the same immutable hosted configuration, independently of provider templates. Additional harnesses require separate integration and qualification. Connected describes the authenticated Runtime connection, not native readiness. diff --git a/services/agents-api/deploy/claude/Dockerfile b/services/agents-api/deploy/claude/Dockerfile index 544b838f..c6771102 100644 --- a/services/agents-api/deploy/claude/Dockerfile +++ b/services/agents-api/deploy/claude/Dockerfile @@ -2,11 +2,12 @@ FROM node:22.23.1-bookworm-slim@sha256:8607a9064d4a571140998ae9e52a3b3fcf9cff361d04642d5971e6cd76d39e27 USER root RUN apt-get update && apt-get install -y --no-install-recommends \ - ca-certificates bash git python3 ripgrep bubblewrap socat \ + ca-certificates bash git python3 python3-pip ripgrep bubblewrap socat \ && rm -rf /var/lib/apt/lists/* \ && mkdir -p /environment/workspace /workspace /home/runtime COPY --chmod=0555 parsar-daemon agents-api-codex-directory agents-api-codex-write agents-api-workspace-export /usr/local/bin/ COPY claude-sdk /opt/claude-sdk +COPY --chmod=0444 runtime-initialize.py /usr/local/bin/agents-api-runtime-initialize ENV HOME=/home/runtime PARSAR_HOME=/home/runtime/.parsar \ PARSAR_CLAUDE_SDK_NODE=/usr/local/bin/node \ PARSAR_CLAUDE_SDK_ENTRYPOINT=/opt/claude-sdk/dist/main.js \ diff --git a/services/agents-api/deploy/codex/Dockerfile b/services/agents-api/deploy/codex/Dockerfile index 3c0d2242..ec33f5c9 100644 --- a/services/agents-api/deploy/codex/Dockerfile +++ b/services/agents-api/deploy/codex/Dockerfile @@ -2,7 +2,7 @@ # Native package and resources must both be Codex 0.153.4 (linux/amd64). FROM debian:bookworm-slim@sha256:a0982977ea1cf754c15281f48a7d5957cd14039a2a4f2aca9f23d74d226003d0 RUN apt-get update && apt-get install -y --no-install-recommends \ - ca-certificates bash git python3 nodejs npm ripgrep \ + ca-certificates bash git python3 python3-pip nodejs npm ripgrep bubblewrap \ && rm -rf /var/lib/apt/lists/* \ && mkdir -p /environment/workspace /workspace /home/runtime \ && groupadd --gid 1000 runtime \ @@ -10,7 +10,9 @@ RUN apt-get update && apt-get install -y --no-install-recommends \ COPY --chmod=0555 parsar-daemon agents-api-codex-directory agents-api-codex-write agents-api-workspace-export codex /usr/local/bin/ COPY codex-resources /usr/local/codex-resources COPY requirements.toml /etc/codex/requirements.toml +COPY --chmod=0444 tool-env.py /etc/codex/tool-env.py RUN chmod 0555 /etc/codex && chmod 0444 /etc/codex/requirements.toml +COPY --chmod=0444 runtime-initialize.py /usr/local/bin/agents-api-runtime-initialize ENV HOME=/home/runtime PARSAR_HOME=/home/runtime/.parsar \ PARSAR_CODEX_BIN=/usr/local/bin/codex PARSAR_CODEX_PERMISSION_PROFILE=managed-workspace \ PARSAR_RUNTIME_WORKSPACE=/environment/workspace \ diff --git a/services/agents-api/deploy/codex/README.md b/services/agents-api/deploy/codex/README.md index 935e3d03..9007e32f 100644 --- a/services/agents-api/deploy/codex/README.md +++ b/services/agents-api/deploy/codex/README.md @@ -12,7 +12,7 @@ User-managed installation and enrollment are outside this qualification batch. Use Codex 0.153.4 and its matching `codex-resources` directory. Install the immutable requirements file at `/etc/codex/requirements.toml`, mount only the authorized -Environment parent at `/environment`, containing only `workspace` and `staging` +Environment parent at `/environment`, containing `workspace`, private `staging`, tool `initialization` and `packages` directories on one mount for trusted writes. Retain daemon/native state beneath `/home`. Set `PARSAR_CODEX_PERMISSION_PROFILE=managed-workspace` on the daemon. This operator setting enables adapter selection of the immutable Runtime network profile at @@ -31,7 +31,8 @@ profile and denies reads of daemon authentication, generated provider configurat and native history under the declared daemon state layout. Minimal native reads exclude other home contents; native helper aliases under the Session tmp directory remain readable so the pinned harness can start its sandbox and apply_patch. -Only `/environment/workspace` is writable by native tools. Staging and its ancestors +The workspace and initialized package prefix are writable by native tools; +initialized tool configuration is read-only. Staging and its ancestors are unavailable for native tool writes; staging is also explicitly denied for reads. The image includes enabled and disabled native network profiles with the same filesystem restrictions. Bootstrap freezes `PARSAR_RUNTIME_NETWORK_ACCESS`; @@ -182,3 +183,13 @@ connection status; execution separately prepares the native harness. Session deletion revokes authority before owned container/volume cleanup. Supported network policies are enabled and disabled. Templates, populated startup installations, restricted domains and hosted MCP combinations remain explicit gaps. + +### Environment initialization + +Templates and inline env/setup/npm/Python configuration share the packaged Runtime +initializer. Enable the existing `nested_sandbox: true` Docker provider setting +when admitting these configurations: user commands and package hooks require +bubblewrap user/PID/mount isolation. Runtime execution still uses native Codex +isolation. The image includes the trusted initializer and managed native Bash +hook; do not inject user env into the daemon or app-server launcher. +See [initialization contract and limits](../../../../contracts/agents-api/environment-templates.md). diff --git a/services/agents-api/deploy/codex/requirements.toml b/services/agents-api/deploy/codex/requirements.toml index 39ccdc63..e073a5f3 100644 --- a/services/agents-api/deploy/codex/requirements.toml +++ b/services/agents-api/deploy/codex/requirements.toml @@ -1,3 +1,4 @@ +allow_managed_hooks_only = true default_permissions = "managed-workspace" allowed_approval_policies = ["never"] [allowed_permission_profiles] @@ -7,6 +8,8 @@ managed-workspace-enabled = true ":minimal" = "read" "/environment/workspace" = "write" "/workspace" = "write" +"/environment/initialization" = "read" +"/environment/packages" = "write" [permissions.managed-workspace.network] enabled = false [permissions.managed-workspace-enabled] @@ -21,3 +24,17 @@ deny_read = [ "/home/*/.parsar/parsar-daemon/agent-sessions/*/sessions", "/home/*/.parsar/parsar-daemon/agent-sessions/*/archived_sessions", ] + +[features] +hooks = true + +[hooks] +managed_dir = "/etc/codex/runtime-hooks" + +[[hooks.PreToolUse]] +matcher = "^Bash$" + +[[hooks.PreToolUse.hooks]] +type = "command" +command = "/usr/bin/python3 -I -S /etc/codex/tool-env.py" +timeout = 5 diff --git a/services/agents-api/deploy/codex/tool-env.py b/services/agents-api/deploy/codex/tool-env.py new file mode 100644 index 00000000..666a9dfd --- /dev/null +++ b/services/agents-api/deploy/codex/tool-env.py @@ -0,0 +1,24 @@ +"""Managed native hook: transform Bash input, never execute untrusted code.""" +import json +import os +from pathlib import Path +import shlex +import sys + +try: + if os.environ.get('PARSAR_RUNTIME_TOOL_ENV') != '1': + print('{}') + sys.exit(0) + value = json.load(sys.stdin) + command = value['tool_input']['command'] + if value.get('hook_event_name') != 'PreToolUse' or value.get('tool_name') != 'Bash' or not isinstance(command, str): + raise ValueError('invalid hook input') + if not Path('/environment/initialization/tool-env.sh').is_file(): + raise ValueError('missing tool environment') + print(json.dumps({'hookSpecificOutput': {'hookEventName': 'PreToolUse', + 'permissionDecision': 'allow', 'updatedInput': { + 'command': '. /environment/initialization/tool-env.sh && eval -- ' + shlex.quote(command)}}})) +except Exception: + # Native exit 2 denies the tool. Never return a partial rewrite or input. + print('Initialized tool configuration unavailable', file=sys.stderr) + sys.exit(2) diff --git a/services/agents-api/deploy/e2b/build-template.py b/services/agents-api/deploy/e2b/build-template.py index 448a3d76..3b930d92 100644 --- a/services/agents-api/deploy/e2b/build-template.py +++ b/services/agents-api/deploy/e2b/build-template.py @@ -58,14 +58,14 @@ (context / 'init.py').write_bytes(Path(__file__).with_name('init.py').read_bytes()) template = (Template(file_context_path=context).from_image(BASE) .run_cmd('apt-get update && apt-get install -y --no-install-recommends ' - 'ca-certificates bash git python3 ripgrep bubblewrap socat util-linux ' + 'ca-certificates bash git python3 python3-pip ripgrep bubblewrap socat util-linux ' '&& rm -rf /var/lib/apt/lists/*', user='root') .copy('runtime.tar.gz', '/root/runtime.tar.gz', user='root') .copy('runtime-env.json', '/etc/parsar-runtime-env.json', user='root') .copy('init.py', '/opt/parsar-e2b/init.py', user='root') .run_cmd('tar --no-same-owner -xzf /root/runtime.tar.gz -C / && rm /root/runtime.tar.gz ' '&& usermod -l runtime -d /home/runtime node ' - '&& mkdir -p /home/runtime/.parsar /environment/workspace /environment/staging /workspace ' + '&& mkdir -p /home/runtime/.parsar /environment/workspace /environment/staging /environment/initialization /environment/packages /workspace ' '&& chown -R 1000:1000 /home/runtime /environment ' '&& chmod 0700 /home/runtime/.parsar /environment/staging ' '&& chmod 0444 /etc/parsar-runtime-env.json ' diff --git a/services/agents-api/deploy/e2b/init.py b/services/agents-api/deploy/e2b/init.py index 3b719470..e40e4ae6 100644 --- a/services/agents-api/deploy/e2b/init.py +++ b/services/agents-api/deploy/e2b/init.py @@ -38,7 +38,8 @@ profile = Path('/home/runtime/.parsar/parsar-daemon/default') profile.mkdir(mode=0o700, parents=True, exist_ok=True) for directory in [Path('/home/runtime'), Path('/home/runtime/.parsar'), profile.parent, profile, - Path('/environment/workspace'), Path('/environment/staging')]: + Path('/environment/workspace'), Path('/environment/staging'), + Path('/environment/initialization'), Path('/environment/packages')]: os.chown(directory, 1000, 1000) directory.chmod(0o700) auth = profile / 'auth.json' diff --git a/services/agents-api/deploy/mcode/Dockerfile b/services/agents-api/deploy/mcode/Dockerfile index 306b4dd4..07c0e4dc 100644 --- a/services/agents-api/deploy/mcode/Dockerfile +++ b/services/agents-api/deploy/mcode/Dockerfile @@ -2,12 +2,13 @@ FROM node:22.23.1-bookworm-slim@sha256:8607a9064d4a571140998ae9e52a3b3fcf9cff361d04642d5971e6cd76d39e27 USER root RUN apt-get update && apt-get install -y --no-install-recommends \ - ca-certificates bash git python3 ripgrep bubblewrap socat \ + ca-certificates bash git python3 python3-pip ripgrep bubblewrap socat \ && rm -rf /var/lib/apt/lists/* \ && mkdir -p /environment/workspace /workspace /home/runtime COPY --chmod=0555 parsar-daemon agents-api-codex-directory agents-api-codex-write agents-api-workspace-export /usr/local/bin/ COPY mcode /opt/mcode COPY mcode-harness /opt/mcode-harness +COPY --chmod=0444 runtime-initialize.py /usr/local/bin/agents-api-runtime-initialize ENV HOME=/home/runtime PARSAR_HOME=/home/runtime/.parsar \ PARSAR_MCODE_NODE=/usr/local/bin/node \ PARSAR_MCODE_BIN=/opt/mcode/cli.js \ diff --git a/services/agents-api/deploy/runtime/initialize.py b/services/agents-api/deploy/runtime/initialize.py new file mode 100644 index 00000000..377ac48b --- /dev/null +++ b/services/agents-api/deploy/runtime/initialize.py @@ -0,0 +1,151 @@ +"""Trusted hosted initialization. Invoke with /usr/bin/python3 -I -S. + +Core owns sequencing and the completion ledger. This helper executes one bounded +operation; it never retries, schedules, selects a harness or interprets templates. +""" +import json +import os +from pathlib import Path +import re +import shlex +import subprocess +import sys + +ROOT = Path('/environment') +CONFIG = ROOT / 'initialization' +PACKAGES = ROOT / 'packages' +ENV_FILE = CONFIG / 'tool-env.sh' +MAX_INPUT = 1024 * 1024 +BASE_ENV = {'PATH': '/usr/local/bin:/usr/bin:/bin', 'HOME': '/tmp', 'LANG': 'C.UTF-8'} +DIRECTORY = os.O_RDONLY | os.O_DIRECTORY | os.O_NOFOLLOW | os.O_CLOEXEC + + +def open_directory(parent, name): + """Do not follow user-created directory aliases outside the Runtime roots.""" + return os.open(name, DIRECTORY, dir_fd=parent) + + +def roots(): + root = os.open('/', DIRECTORY) + try: + environment = open_directory(root, 'environment') + finally: + os.close(root) + try: + for name in ('workspace', 'packages', 'initialization'): + child = open_directory(environment, name) + os.close(child) + finally: + os.close(environment) + + +def configure(env): + if not isinstance(env, dict) or any( + not isinstance(name, str) or not re.fullmatch('[A-Za-z_][A-Za-z0-9_]*', name) + or not isinstance(value, str) or '\x00' in value + for name, value in env.items() + ): + raise ValueError('invalid environment') + # These Runtime-owned defaults are applied inside the sandbox, never to its + # launcher. Public reserved-name validation belongs to Core. + values = { + 'PATH': '/environment/packages/npm/bin:/environment/packages/python/bin:' + BASE_ENV['PATH'], + **env, + 'PYTHONPATH': '/environment/packages/python' + (':' + env['PYTHONPATH'] if env.get('PYTHONPATH') else ''), + } + materialized = json.dumps(values, ensure_ascii=True) + script = ''.join('export ' + name + '=' + shlex.quote(value) + '\n' for name, value in sorted(values.items())) + directory = os.open(CONFIG, DIRECTORY) + try: + fd = os.open('tool-env.sh', os.O_WRONLY | os.O_CREAT | os.O_EXCL | os.O_NOFOLLOW, 0o400, dir_fd=directory) + with os.fdopen(fd, 'w') as stream: + stream.write(script) + stream.flush() + os.fsync(stream.fileno()) + fd = os.open('tool-env.json', os.O_WRONLY | os.O_CREAT | os.O_EXCL | os.O_NOFOLLOW, 0o400, dir_fd=directory) + with os.fdopen(fd, 'w') as stream: + stream.write(materialized) + stream.flush() + os.fsync(stream.fileno()) + os.fsync(directory) + finally: + os.close(directory) + + +def sandbox(network, cwd): + if network not in ('enabled', 'disabled') or not isinstance(cwd, str) or not cwd.startswith('/') or '\x00' in cwd: + raise ValueError('invalid execution configuration') + # One packaging contract for every Provider/harness. No native state, daemon + # credential, staging payload or parent process is visible in this mount map. + args = ['/usr/bin/bwrap', '--unshare-user', '--unshare-pid', '--unshare-ipc', '--unshare-uts', + '--die-with-parent', '--new-session', '--cap-drop', 'ALL', '--clearenv', + '--ro-bind', '/usr', '/usr', '--symlink', 'usr/bin', '/bin', + '--symlink', 'usr/sbin', '/sbin', '--symlink', 'usr/lib', '/lib', + '--symlink', 'usr/lib64', '/lib64', '--dir', '/etc', + '--ro-bind', '/etc/resolv.conf', '/etc/resolv.conf', + '--ro-bind', '/etc/hosts', '/etc/hosts', '--ro-bind', '/etc/ssl', '/etc/ssl', + '--proc', '/proc', '--dev', '/dev', '--tmpfs', '/tmp', '--dir', '/home', + '--dir', '/environment', '--bind', str(ROOT / 'workspace'), '/workspace', + '--bind', str(PACKAGES), str(PACKAGES), '--ro-bind', str(CONFIG), str(CONFIG)] + if network == 'disabled': + args += ['--unshare-net'] + for key, value in BASE_ENV.items(): + args += ['--setenv', key, value] + return args + ['--chdir', cwd, '--'] + + +def run(request): + action = request['action'] + if action == 'configure': + configure(request['env']) + return + args = sandbox(request['network'], request.get('cwd', '/workspace')) + if action == 'setup': + command = request['command'] + if not isinstance(command, str) or not command or '\x00' in command: + raise ValueError('invalid setup command') + # Source confidential values only inside isolation. eval preserves the + # shell's cwd, unlike replacing the native shell with a child wrapper. + args += ['/bin/bash', '--noprofile', '--norc', '-c', + '. /environment/initialization/tool-env.sh && eval -- "$1"', '--', command] + elif action in ('npm', 'python'): + packages = request['packages'] + if not isinstance(packages, list) or not packages or any( + not isinstance(package, str) or not package or '\x00' in package or package.startswith('-') + for package in packages + ): + raise ValueError('invalid packages') + command = ['npm', 'install', '--global', '--prefix', str(PACKAGES / 'npm'), '--', *packages] + if action == 'python': + command = ['/usr/bin/python3', '-m', 'pip', 'install', '--disable-pip-version-check', + '--no-input', '--target', str(PACKAGES / 'python'), '--', *packages] + args += ['/bin/bash', '--noprofile', '--norc', '-c', + '. /environment/initialization/tool-env.sh && exec "$@"', '--', *command] + else: + raise ValueError('invalid action') + # Setup/package output can contain arbitrary confidential values. The public + # completion receipt deliberately contains no child stdout/stderr or command. + subprocess.run(args, env=BASE_ENV, stdin=subprocess.DEVNULL, + stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL, check=True) + + +def main(): + try: + raw = sys.stdin.buffer.read(MAX_INPUT + 1) + if len(raw) > MAX_INPUT: + raise ValueError('input too large') + request = json.loads(raw) + if not isinstance(request, dict) or request.get('version') != 1: + raise ValueError('invalid version') + roots() + run(request) + except Exception: + # Never serialize an exception that could contain input or process args. + print('{"version":1,"outcome":"failed"}') + return 1 + print('{"version":1,"outcome":"completed"}') + return 0 + + +if __name__ == '__main__': + sys.exit(main()) diff --git a/services/agents-api/deploy/runtime/initialize_test.py b/services/agents-api/deploy/runtime/initialize_test.py new file mode 100644 index 00000000..915b9922 --- /dev/null +++ b/services/agents-api/deploy/runtime/initialize_test.py @@ -0,0 +1,76 @@ +"""Real Linux initialization checks; run inside a disposable packaged Runtime. + +The fixture must expose writable workspace/packages/initialization roots and +support the same nested isolation as its deployed Provider. No model is mocked; +these checks exercise initialization only, not public native-model acceptance. +""" +import json +import os +from pathlib import Path +import subprocess +import sys +import time + +HELPER = '/usr/local/bin/agents-api-runtime-initialize' +CANARY = 'private-initialization-canary-47a8' + + +def invoke(action, *, succeeds=True, **fields): + payload = json.dumps({'version': 1, 'action': action, 'network': 'enabled', **fields}) + result = subprocess.run(['/usr/bin/python3', '-I', '-S', HELPER], input=payload, + text=True, capture_output=True, timeout=120) + expected = 'completed' if succeeds else 'failed' + assert result.returncode == (0 if succeeds else 1), (action, result.returncode) + assert result.stderr == '', (action, 'unexpected stderr') + assert json.loads(result.stdout) == {'version': 1, 'outcome': expected}, action + assert CANARY not in result.stdout + result.stderr, 'confidential output exposed' + + +def main(): + for name in ('workspace', 'packages', 'initialization', 'private', 'staging'): + Path('/environment', name).mkdir(exist_ok=True) + Path('/environment/private/credential').write_text(CANARY) + Path('/environment/staging/request').write_text(CANARY) + os.environ['DAEMON_PRIVATE_CANARY'] = CANARY + invoke('configure', env={'INITIALIZATION_VALUE': CANARY, 'WITH_QUOTES': "'\n$(false)"}) + # Re-entry must not replace confidential configuration after any effects. + invoke('configure', succeeds=False, env={'INITIALIZATION_VALUE': 'changed'}) + invoke('setup', command='printf "%s" "$INITIALIZATION_VALUE" > first; printf secret; printf secret >&2') + assert Path('/environment/workspace/first').read_text() == CANARY + check = '''import os, pathlib, socket +for path in ('/environment/private/credential', '/environment/staging/request', '/home/runtime/.parsar'): + assert not pathlib.Path(path).exists(), path +assert 'DAEMON_PRIVATE_CANARY' not in os.environ +assert os.environ['INITIALIZATION_VALUE'] == 'private-initialization-canary-47a8' +assert os.environ['WITH_QUOTES'] == "'\\n$(false)" +for p in pathlib.Path('/proc').glob('[0-9]*/environ'): + assert b'DAEMON_PRIVATE_CANARY=' not in p.read_bytes() +for path in ('/usr/bin/untrusted', '/environment/initialization/tool-env.sh'): + try: pathlib.Path(path).write_text('bad') + except OSError: pass + else: raise AssertionError(path) +pathlib.Path('/environment/packages/visible').write_text('ok') +assert len(socket.if_nameindex()) == 1 +''' + Path('/environment/workspace/check.py').write_text(check) + invoke('setup', network='disabled', command='/usr/bin/python3 /workspace/check.py') + # Shell cwd is explicit and ordered effects survive between invocations. + Path('/environment/workspace/sub').mkdir() + invoke('setup', cwd='/workspace/sub', command='test -f ../first && pwd > second') + assert Path('/environment/workspace/sub/second').read_text() == '/workspace/sub\n' + invoke('setup', succeeds=False, command='echo secret; echo secret >&2; exit 7') + invoke('setup', succeeds=False, cwd='/missing', command='touch /workspace/should-not-exist') + assert not Path('/environment/workspace/should-not-exist').exists() + invoke('setup', command='setsid /bin/bash -c "sleep 2; touch /workspace/descendant" >/dev/null 2>&1 &') + time.sleep(3) + assert not Path('/environment/workspace/descendant').exists(), 'detached setup descendant survived' + if '--packages' in sys.argv: + # Actual public registries, not synthetic package fixtures. + invoke('npm', packages=['is-number@7.0.0']) + invoke('python', packages=['packaging==26.0']) + invoke('setup', cwd='/workspace/sub', command="node -e \"if (!require('/environment/packages/npm/lib/node_modules/is-number')(42)) process.exit(1)\" && python3 -c 'import packaging; assert packaging.__version__ == \"26.0\"'") + print(json.dumps({'initialization': 'passed', 'real_packages': '--packages' in sys.argv})) + + +if __name__ == '__main__': + main() diff --git a/services/agents-api/internal/api/environment_setup.go b/services/agents-api/internal/api/environment_setup.go new file mode 100644 index 00000000..d1d6bb1b --- /dev/null +++ b/services/agents-api/internal/api/environment_setup.go @@ -0,0 +1,85 @@ +package api + +import ( + "bytes" + "encoding/json" + + v1 "github.com/MiniMax-AI-Dev/parsar/contracts/agents-api/v1" + "github.com/MiniMax-AI-Dev/parsar/services/agents-api/internal/store" +) + +func decodeEnvironmentSetup(fields map[string]json.RawMessage) (store.EnvironmentSetup, error) { + var result store.EnvironmentSetup + if value, ok := fields["env"]; ok { + var entries map[string]*string + if json.Unmarshal(value, &entries) != nil { + return result, store.ErrInvalidInput + } + result.Env = make(map[string]string, len(entries)) + for name, entry := range entries { + if entry == nil { + return result, store.ErrInvalidInput + } + result.Env[name] = *entry + } + } + if value, ok := fields["setup_commands"]; ok { + var commands []json.RawMessage + if json.Unmarshal(value, &commands) != nil { + return result, store.ErrInvalidInput + } + for _, command := range commands { + var input struct { + Command *string `json:"command"` + CWD *string `json:"cwd"` + } + if decodeInputObject(command, &input, "command", "cwd") != nil || input.Command == nil { + return result, store.ErrInvalidInput + } + step := store.SetupCommand{Command: *input.Command} + if input.CWD != nil { + if *input.CWD == "" { + return result, store.ErrInvalidInput + } + step.CWD = *input.CWD + } + result.Commands = append(result.Commands, step) + } + } + if value, ok := fields["packages"]; ok && !bytes.Equal(bytes.TrimSpace(value), []byte("null")) { + var input struct { + NPM []*string `json:"npm"` + Python []*string `json:"python"` + System []*string `json:"system"` + } + if decodeInputObject(value, &input, "npm", "python", "system") != nil { + return result, store.ErrInvalidInput + } + for name, entries := range map[string][]*string{"npm": input.NPM, "python": input.Python, "system": input.System} { + var target *[]string + switch name { + case "npm": + target = &result.Packages.NPM + case "python": + target = &result.Packages.Python + case "system": + target = &result.Packages.System + } + for _, entry := range entries { + if entry == nil { + return result, store.ErrInvalidInput + } + *target = append(*target, *entry) + } + } + } + return result, result.Validate() +} + +func packageMetadata(packages *v1.EnvironmentPackages) v1.EnvironmentPackages { + value := store.EnvironmentSetup{} + if packages != nil { + value.Packages = *packages + } + return value.PackageMetadata() +} diff --git a/services/agents-api/internal/api/environment_setup_test.go b/services/agents-api/internal/api/environment_setup_test.go new file mode 100644 index 00000000..04fb900a --- /dev/null +++ b/services/agents-api/internal/api/environment_setup_test.go @@ -0,0 +1,77 @@ +package api + +import ( + "bytes" + "encoding/json" + "testing" +) + +func TestEnvironmentSetupSharedParsingAndConfidentialSnapshot(t *testing.T) { + raw := []byte(`{"env":{"TOKEN":"private-env-canary","QUOTED":"'\n$(false)"},"packages":{"npm":["is-number@7.0.0"],"python":["packaging==26.0"]},"setup_commands":[{"command":"printf private-command-canary > result","cwd":null},{"command":"pwd","cwd":"/workspace/sub"}]}`) + input, err := decodeTemplateInput(raw) + if err != nil || !input.SetEnv || !input.SetSetup || !input.SetPackages || len(input.Initialization.Commands) != 2 { + t.Fatal("template setup input", err) + } + var request decodedSessionRequest + if err := json.Unmarshal(append(append([]byte(`{"agent":{"model":"test"},"environment":{"type":"openai_hosted",`), raw[1:len(raw)-1]...), []byte(`}}`)...), &request); err != nil { + t.Fatal(err) + } + decoded, err := request.validated() + if err != nil { + t.Fatal(err) + } + configuration, err := resolve(decoded, "tenant", "key", nil) + if err != nil || bytes.Contains(configuration, []byte("canary")) || !bytes.Contains(configuration, []byte("is-number@7.0.0")) { + t.Fatal("confidential input in ordinary configuration", err) + } + if decoded.initialization.Env["TOKEN"] != input.Initialization.Env["TOKEN"] || len(decoded.initialization.Commands) != 2 { + t.Fatal("inline and template parsing diverged") + } + for _, invalid := range []string{`{"env":{"OPENAI_API_KEY":"x"}}`, `{"env":{"CODEX_HOME":"x"}}`, `{"env":{"PARSAR_HOME":"x"}}`, `{"env":{"BAD-NAME":"x"}}`, `{"env":{"VALUE":null}}`, `{"setup_commands":[null]}`, `{"setup_commands":[{}]}`, `{"setup_commands":[{"command":null}]}`, `{"setup_commands":[{"command":"pwd","cwd":""}]}`, `{"packages":{"python":[null]}}`, `{"packages":{"npm":["--ignore-scripts"]}}`} { + if _, err := decodeTemplateInput([]byte(invalid)); err == nil { + t.Fatal("invalid setup accepted", invalid) + } + } + cleared, err := decodeTemplateInput([]byte(`{"env":null,"setup_commands":null,"packages":null}`)) + if err != nil || !cleared.Initialization.Empty() || !cleared.SetEnv || !cleared.SetSetup || !cleared.SetPackages { + t.Fatal("nullable replacements", err) + } +} + +func TestSavedAgentCreationIntentRetainsInlineSetup(t *testing.T) { + intent := func(environment string) json.RawMessage { + t.Helper() + var request decodedSessionRequest + if err := json.Unmarshal([]byte(`{"agent_id":"saved","environment":`+environment+`}`), &request); err != nil { + t.Fatal(err) + } + input, err := request.validated() + if err != nil { + t.Fatal(err) + } + value, err := sessionCreationRequest(input, nil) + if err != nil { + t.Fatal(err) + } + return value + } + variants := []string{ + `{"type":"openai_hosted"}`, + `{"type":"openai_hosted","env":{"TOKEN":"first"}}`, + `{"type":"openai_hosted","env":{"TOKEN":"changed"}}`, + `{"type":"openai_hosted","setup_commands":[{"command":"first"}]}`, + `{"type":"openai_hosted","setup_commands":[{"command":"changed"}]}`, + `{"type":"openai_hosted","env":{"TOKEN":"first"},"setup_commands":[{"command":"first"}]}`, + } + for i, first := range variants { + identity := intent(first) + if !bytes.Equal(identity, intent(first)) { + t.Fatal("unchanged intent differs") + } + for j, second := range variants { + if i != j && bytes.Equal(identity, intent(second)) { + t.Fatalf("changed, added or removed setup lost from intent: %d, %d", i, j) + } + } + } +} diff --git a/services/agents-api/internal/api/environment_templates.go b/services/agents-api/internal/api/environment_templates.go index 38415f54..61650695 100644 --- a/services/agents-api/internal/api/environment_templates.go +++ b/services/agents-api/internal/api/environment_templates.go @@ -35,6 +35,14 @@ func decodeTemplateInput(raw []byte) (store.EnvironmentTemplateInput, error) { delete(fields, "name") _, in.SetNetwork = fields["network"] _, in.SetFiles = fields["files"] + _, in.SetEnv = fields["env"] + _, in.SetSetup = fields["setup_commands"] + _, in.SetPackages = fields["packages"] + var setupErr error + in.Initialization, setupErr = decodeEnvironmentSetup(fields) + if setupErr != nil { + return in, setupErr + } var fileErr error in.Files, fileErr = decodeInitialFiles(fields["files"]) if fileErr != nil { @@ -54,7 +62,7 @@ func decodeTemplateInput(raw []byte) (store.EnvironmentTemplateInput, error) { } func templateResponse(t store.EnvironmentTemplate) v1.EnvironmentTemplate { - return v1.EnvironmentTemplate{ID: t.ID, Object: "agent.environment.template", Name: t.Name, CreatedAt: t.CreatedAt.Unix(), UpdatedAt: t.UpdatedAt.Unix(), CapabilityDirectories: []string{}, Network: v1.EnvironmentNetwork{Access: t.NetworkAccess, AllowedDomains: []string{}}, Packages: v1.EnvironmentPackages{NPM: []string{}, Python: []string{}, System: []string{}}, Files: templateFileResponse(t.Files), Plugins: []json.RawMessage{}, Skills: []json.RawMessage{}} + return v1.EnvironmentTemplate{ID: t.ID, Object: "agent.environment.template", Name: t.Name, CreatedAt: t.CreatedAt.Unix(), UpdatedAt: t.UpdatedAt.Unix(), CapabilityDirectories: []string{}, Network: v1.EnvironmentNetwork{Access: t.NetworkAccess, AllowedDomains: []string{}}, Packages: packageMetadata(&t.Packages), Files: templateFileResponse(t.Files), Plugins: []json.RawMessage{}, Skills: []json.RawMessage{}} } func templateNoQuery(w http.ResponseWriter, r *http.Request) bool { @@ -75,14 +83,14 @@ func readTemplateInput(w http.ResponseWriter, r *http.Request) (store.Environmen } in, err := decodeTemplateInput(raw) if err != nil { - writeError(w, http.StatusBadRequest, "unsupported_or_invalid_configuration", "Template fields are invalid or require unsupported initialization. Name, enabled/disabled network and initial files are supported.") + writeError(w, http.StatusBadRequest, "unsupported_or_invalid_configuration", "Template fields are invalid or require unsupported initialization. Name, enabled/disabled network, initial files, env, npm/Python packages and setup commands are supported.") return in, false } return in, true } // @Summary Create an Environment Template -// @Description Saves tenant-owned basic hosted configuration. Supports nullable name, enabled/disabled network, initial inline/file_id files and empty remaining installation defaults. Omitted/null network defaults to enabled. Other populated installations and restricted network are rejected before persistence without echoing input. No compute is allocated. Exact hosted error/retry semantics remain unverified. +// @Description Saves tenant-owned basic hosted configuration. Supports nullable name, enabled/disabled network, initial inline/file_id files, confidential env, ordered setup_commands and npm/Python packages. Omitted/null network defaults to enabled. System packages, other populated installations and restricted network are rejected before persistence without echoing input. No compute is allocated. Exact hosted error/retry semantics remain unverified. // @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 2b7d5de6..9162a090 100644 --- a/services/agents-api/internal/api/environment_templates_test.go +++ b/services/agents-api/internal/api/environment_templates_test.go @@ -17,13 +17,13 @@ func TestTemplateConfigurationRejectsUnqualifiedInputs(t *testing.T) { t.Fatalf("supported input: %s: %v", raw, err) } } - for _, raw := range []string{`null`, `[]`, `{"name":""}`, `{"name":42}`, `{"type":"openai_hosted"}`, `{"network":{"access":"restricted","allowed_domains":["example.com"]}}`, `{"env":{"TOKEN":"confidential-canary"}}`, `{"setup_commands":[{"command":"confidential-canary"}]}`, `{"packages":{"python":["requests"]}}`, `{"plugins":[{}]}`, `{"skills":[{}]}`, `{"capability_directories":["/workspace"]}`} { + for _, raw := range []string{`null`, `[]`, `{"name":""}`, `{"name":42}`, `{"type":"openai_hosted"}`, `{"network":{"access":"restricted","allowed_domains":["example.com"]}}`, `{"env":{"PATH":"confidential-canary"}}`, `{"setup_commands":[{"command":"confidential-canary","cwd":"relative"}]}`, `{"packages":{"system":["curl"]}}`, `{"plugins":[{}]}`, `{"skills":[{}]}`, `{"capability_directories":["/workspace"]}`} { if _, err := decodeTemplateInput([]byte(raw)); err == nil { t.Fatalf("unsupported input accepted: %s", raw) } } h, _, _ := testHandler(t) - req := httptest.NewRequest(http.MethodPost, "/v1/agents/environments/templates", strings.NewReader(`{"env":{"TOKEN":"confidential-canary"}}`)) + req := httptest.NewRequest(http.MethodPost, "/v1/agents/environments/templates", strings.NewReader(`{"env":{"PATH":"confidential-canary"}}`)) req.Header.Set("Authorization", "Bearer test-api-key") req.Header.Set("OpenAI-Beta", "agents=v1") response := httptest.NewRecorder() diff --git a/services/agents-api/internal/api/handler.go b/services/agents-api/internal/api/handler.go index 7c82c0c8..8a483bff 100644 --- a/services/agents-api/internal/api/handler.go +++ b/services/agents-api/internal/api/handler.go @@ -118,7 +118,7 @@ func NewHandler(s ResourceStore, auth *Authenticator, engine string, options ... // createSession atomically reserves or admits initial text with the Session. // @Summary Create an execution Session -// @Description Supports inline configuration or a tenant-owned saved agent_id with per-Session field replacements. Execution supports model/instructions, text verbosity, non-deferred function tools, disabled multi_agent, implicit reasoning, service tier auto and environment type none, subject to the configured engine. Codex additionally supports HTTP MCP with explicit service origin, native allowed_tools and boolean required defaulting to false. Session vault_ids attach only project-owned Vaults; credential_id selects an attached static bearer credential for the exact HTTPS URL, while null/omission selects a unique match or remains anonymous. Ambiguous selection rejects creation. Frozen private selections never populate an omitted public credential_id; missing decryption configuration fails dispatch without anonymous fallback. Required initialization uses native startup before the first native Turn, including cold resume, and requires a separately advertised capability; exact hosted creation timing and error parity remain unverified. Other MCP origins and OAuth remain unsupported. The self_hosted profile requires Codex, an absolute workspace_directory and empty capability_directories, with optional non-deferred function tools and HTTP MCP using explicit service origin, optionally authenticated by the attached Vault rules. Remote MCP and remote Bearer authentication each require separately advertised combination support; old peers cannot receive unsupported work. Omitted/null capability_directories use the empty-list default; self_hosted requires configured execution plus executor registry. Claude SDK currently requires medium verbosity and object-root function schemas. It supports anonymous or attached static-bearer service-origin HTTP MCP on none with boolean required and separately advertised MCP/bearer/required runtime support. Required servers must be connected before the first native input is released; pending or failed startup rejects execution. The shared Vault selection and immutable binding rules apply; unsupported native labels/tool names reject before persistence. An attached Vault with no matching credential may remain anonymous; missing keys or failed credential lookup/decryption never fall back to anonymous execution. Omitted stream defaults to false; stream and agent_id cannot be null. Metadata may be null, but its values must be strings. Initial input accepts a string or user-message array containing text. None initial input atomically starts a Turn; self_hosted initial input is reserved while returning its Environment connection target, with execution deferred to native readiness and Session failure on initial timeout. Omitted or null input creates an idle Session. With stream=true, returns live Session events starting at creation; disconnect does not cancel execution. New Sessions retain their authenticated creator; all creation retries require the same typed subject, including across key rotation. Saved-Agent retries and inline requests using Vault attachments or credential references retain caller intent independently of later resource changes; unrelated inline retries preserve resolved/default equivalences. Unknown historical creators reject retries; known creators without recorded intent retain resolved-snapshot retry rules. These conflict policies are local and not verified hosted parity. Creation retries observe future events without replay; retry with stream=false to retrieve the Session. Non-text initial input remains unsupported. Basic Codex and Claude SDK openai_hosted creation requires an explicitly configured managed provider. The Claude workspace profile supports non-deferred function tools with text results alongside native workspace tools; HTTP MCP remains unsupported. Idle Sessions provision automatically; initial provisioning has no caller connection action. Network defaults to enabled; disabled is also supported, while restricted domains and other populated startup installations are rejected. Initial inline and tenant-owned file_id files freeze encrypted bytes before provisioning, then install through the common Core lifecycle before native execution or live Files access. Referenced files overrides are rejected pending semantic verification. Tenant-owned environment_template_id references inherit omitted network and allow only narrowing overrides. Referenced network:null is explicitly unsupported pending semantic verification. Core freezes effective configuration; template updates/deletion do not alter Session snapshots or same-intent creation retries. +// @Description Supports inline configuration or a tenant-owned saved agent_id with per-Session field replacements. Execution supports model/instructions, text verbosity, non-deferred function tools, disabled multi_agent, implicit reasoning, service tier auto and environment type none, subject to the configured engine. Codex additionally supports HTTP MCP with explicit service origin, native allowed_tools and boolean required defaulting to false. Session vault_ids attach only project-owned Vaults; credential_id selects an attached static bearer credential for the exact HTTPS URL, while null/omission selects a unique match or remains anonymous. Ambiguous selection rejects creation. Frozen private selections never populate an omitted public credential_id; missing decryption configuration fails dispatch without anonymous fallback. Required initialization uses native startup before the first native Turn, including cold resume, and requires a separately advertised capability; exact hosted creation timing and error parity remain unverified. Other MCP origins and OAuth remain unsupported. The self_hosted profile requires Codex, an absolute workspace_directory and empty capability_directories, with optional non-deferred function tools and HTTP MCP using explicit service origin, optionally authenticated by the attached Vault rules. Remote MCP and remote Bearer authentication each require separately advertised combination support; old peers cannot receive unsupported work. Omitted/null capability_directories use the empty-list default; self_hosted requires configured execution plus executor registry. Claude SDK currently requires medium verbosity and object-root function schemas. It supports anonymous or attached static-bearer service-origin HTTP MCP on none with boolean required and separately advertised MCP/bearer/required runtime support. Required servers must be connected before the first native input is released; pending or failed startup rejects execution. The shared Vault selection and immutable binding rules apply; unsupported native labels/tool names reject before persistence. An attached Vault with no matching credential may remain anonymous; missing keys or failed credential lookup/decryption never fall back to anonymous execution. Omitted stream defaults to false; stream and agent_id cannot be null. Metadata may be null, but its values must be strings. Initial input accepts a string or user-message array containing text. None initial input atomically starts a Turn; self_hosted initial input is reserved while returning its Environment connection target, with execution deferred to native readiness and Session failure on initial timeout. Omitted or null input creates an idle Session. With stream=true, returns live Session events starting at creation; disconnect does not cancel execution. New Sessions retain their authenticated creator; all creation retries require the same typed subject, including across key rotation. Saved-Agent retries and inline requests using Vault attachments or credential references retain caller intent independently of later resource changes; unrelated inline retries preserve resolved/default equivalences. Unknown historical creators reject retries; known creators without recorded intent retain resolved-snapshot retry rules. These conflict policies are local and not verified hosted parity. Creation retries observe future events without replay; retry with stream=false to retrieve the Session. Non-text initial input remains unsupported. Basic Codex and Claude SDK openai_hosted creation requires an explicitly configured managed provider. The Claude workspace profile supports non-deferred function tools with text results alongside native workspace tools; HTTP MCP remains unsupported. Idle Sessions provision automatically; initial provisioning has no caller connection action. Network defaults to enabled; disabled is also supported, while restricted domains, system packages and remaining unsupported startup installations are rejected. Confidential env, npm/Python packages and ordered setup commands use the shared initialization lifecycle; requested network applies after setup. Initial inline and tenant-owned file_id files freeze encrypted bytes before provisioning, then install through the common Core lifecycle before native execution or live Files access. Referenced files/env/packages/setup overrides are rejected pending semantic verification. Tenant-owned environment_template_id references inherit omitted network and allow only narrowing overrides. Referenced network:null is explicitly unsupported pending semantic verification. Core freezes effective configuration; template updates/deletion do not alter Session snapshots or same-intent creation retries. // @Tags Sessions // @Accept json // @Produce json,text/event-stream @@ -223,7 +223,7 @@ func (h *Handler) createSession(w http.ResponseWriter, r *http.Request) { return } createInput := store.CreateSessionInput{ - Creator: sessionCreator(r), InitialFiles: input.initialFiles, + Creator: sessionCreator(r), InitialFiles: input.initialFiles, Initialization: input.initialization, Engine: h.engine, IdempotencyKey: key, Metadata: input.Metadata, Configuration: configuration, InitialInputs: initialInputs, CreationRequest: creationRequest, } if input.Stream { diff --git a/services/agents-api/internal/api/hosted_environment.go b/services/agents-api/internal/api/hosted_environment.go index e129b02c..fe1bb045 100644 --- a/services/agents-api/internal/api/hosted_environment.go +++ b/services/agents-api/internal/api/hosted_environment.go @@ -14,6 +14,10 @@ func decodeHostedEnvironment(raw json.RawMessage) (*v1.Environment, error) { if json.Unmarshal(raw, &fields) != nil { return nil, store.ErrInvalidInput } + setup, err := decodeEnvironmentSetup(fields) + if err != nil { + return nil, err + } env := &v1.Environment{Type: "openai_hosted", Network: &v1.EnvironmentNetworkInput{Access: "enabled"}} for name, value := range fields { switch name { @@ -33,24 +37,16 @@ func decodeHostedEnvironment(raw json.RawMessage) (*v1.Environment, error) { return nil, err } env.Files = initialFileResponse(files) - case "capability_directories", "plugins", "skills", "setup_commands": + case "capability_directories", "plugins", "skills": var list []json.RawMessage if json.Unmarshal(value, &list) != nil || len(list) != 0 { return nil, store.ErrInvalidInput } - case "env": - var entries map[string]string - if json.Unmarshal(value, &entries) != nil || len(entries) != 0 { - return nil, store.ErrInvalidInput - } + case "env", "setup_commands": + // Confidential values remain in the separate initialization snapshot. case "packages": - if bytes.Equal(bytes.TrimSpace(value), []byte("null")) { - continue - } - var packages v1.EnvironmentPackages - if decodeInputObject(value, &packages, "npm", "python", "system") != nil || len(packages.NPM)+len(packages.Python)+len(packages.System) != 0 { - return nil, store.ErrInvalidInput - } + packages := setup.PackageMetadata() + env.Packages = &packages default: return nil, store.ErrInvalidInput } @@ -72,7 +68,7 @@ func hostedSessionEnvironment(environment store.Environment) (v1.SessionEnvironm directories := []string{} return v1.SessionEnvironment{ID: environment.ID, Type: cfg.Type, CapabilityDirectories: &directories, Network: &v1.EnvironmentNetwork{Access: cfg.Network.Access, AllowedDomains: []string{}}, - Packages: &v1.EnvironmentPackages{NPM: []string{}, Python: []string{}, System: []string{}}, Files: &files, Plugins: &empty, Skills: &empty}, nil + Packages: func() *v1.EnvironmentPackages { value := packageMetadata(cfg.Packages); return &value }(), Files: &files, Plugins: &empty, Skills: &empty}, nil } // WithHostedEnvironments enables admission only for an operator-composed, @@ -93,6 +89,12 @@ func storedEnvironment(raw json.RawMessage) (*v1.Environment, error) { if kind != "openai_hosted" { return decodeSessionEnvironment(raw) } + // Confidential fields must never appear in the persisted public snapshot. + for _, field := range []string{"env", "setup_commands"} { + if _, exists := fields[field]; exists { + return nil, store.ErrInvalidInput + } + } var files []json.RawMessage if value, exists := fields["files"]; exists { if json.Unmarshal(value, &files) != nil || len(files) > 50 { @@ -108,6 +110,13 @@ func storedEnvironment(raw json.RawMessage) (*v1.Environment, error) { } } } + if value, ok := fields["initialization"]; ok { + var initialized bool + if json.Unmarshal(value, &initialized) != nil || !initialized { + return nil, store.ErrInvalidInput + } + delete(fields, "initialization") + } delete(fields, "files") base, err := json.Marshal(fields) if err != nil { diff --git a/services/agents-api/internal/api/hosted_environment_test.go b/services/agents-api/internal/api/hosted_environment_test.go index 71bdfbe9..d5721d98 100644 --- a/services/agents-api/internal/api/hosted_environment_test.go +++ b/services/agents-api/internal/api/hosted_environment_test.go @@ -31,7 +31,7 @@ func TestHostedEnvironmentDefaultsAndExplicitGaps(t *testing.T) { for _, field := range []string{ `"network":{}`, `"network":{"access":null}`, `"network":{"access":"restricted"}`, `"network":{"access":"disabled","allowed_domains":["example.com"]}`, `"network":{"access":"enabled","unknown":true}`, - `"env":{"SECRET":"value"}`, `"files":[{}]`, `"packages":{"npm":["package"]}`, + `"env":{"SECRET":null}`, `"files":[{}]`, `"packages":{"system":["package"]}`, `"packages":{"unknown":[]}`, `"plugins":[{}]`, `"skills":[{}]`, `"setup_commands":["echo test"]`, `"capability_directories":["/workspace"]`, `"template_id":"template"`, `"workspace_directory":"/workspace"`, `"files":{}`, `"env":[]`, `"packages":[]`, `"network":[]`, `"unknown":null`, diff --git a/services/agents-api/internal/api/session_creation_identity.go b/services/agents-api/internal/api/session_creation_identity.go index 387a059d..ad17056f 100644 --- a/services/agents-api/internal/api/session_creation_identity.go +++ b/services/agents-api/internal/api/session_creation_identity.go @@ -17,7 +17,7 @@ func sessionCreationRequest(input sessionRequest, initial []store.Input) (json.R agentID = *input.AgentID } var environment any = input.Environment - if input.templateID != "" || len(input.initialFiles) > 0 { + if input.templateID != "" || len(input.initialFiles) > 0 || !input.initialization.Empty() { environment = input.originalEnvironment if len(input.originalEnvironment) == 0 { environment = input.templateEnvironment diff --git a/services/agents-api/internal/api/session_request.go b/services/agents-api/internal/api/session_request.go index fefde169..0b847f24 100644 --- a/services/agents-api/internal/api/session_request.go +++ b/services/agents-api/internal/api/session_request.go @@ -23,6 +23,7 @@ type decodedSessionRequest struct { type sessionRequest struct { initialFiles []store.InitialFile + initialization store.EnvironmentSetup originalEnvironment json.RawMessage v1.CreateSessionRequest Input json.RawMessage @@ -50,6 +51,10 @@ func (request decodedSessionRequest) validated() (sessionRequest, error) { return input, store.ErrInvalidInput } var err error + input.initialization, err = decodeEnvironmentSetup(environmentFields) + if err != nil { + return input, err + } input.initialFiles, err = decodeInitialFiles(environmentFields["files"]) if err != nil { return input, err diff --git a/services/agents-api/internal/api/session_template.go b/services/agents-api/internal/api/session_template.go index f9b8488a..2cc397cc 100644 --- a/services/agents-api/internal/api/session_template.go +++ b/services/agents-api/internal/api/session_template.go @@ -29,8 +29,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 _, supplied := fields["files"]; supplied { - return nil, "", nil, store.ErrInvalidInput + for _, name := range []string{"files", "env", "setup_commands", "packages"} { + if _, supplied := fields[name]; supplied { + return nil, "", nil, store.ErrInvalidInput + } } delete(fields, "environment_template_id") inline, err := json.Marshal(fields) @@ -58,6 +60,9 @@ func (h *Handler) resolveTemplateEnvironment(ctx context.Context, tenant string, } else if template.NetworkAccess == "disabled" && input.Environment.Network.Access != "disabled" { return store.ErrInvalidInput } + input.initialization = template.Initialization + packages := template.Initialization.PackageMetadata() + input.Environment.Packages = &packages input.initialFiles = files input.Environment.Files = initialFileResponse(files) // Runtime sees only the effective ordinary hosted configuration. diff --git a/services/agents-api/internal/credentialcrypto/environment_setup.go b/services/agents-api/internal/credentialcrypto/environment_setup.go new file mode 100644 index 00000000..55f367f3 --- /dev/null +++ b/services/agents-api/internal/credentialcrypto/environment_setup.go @@ -0,0 +1,46 @@ +package credentialcrypto + +import ( + "encoding/json" + "unicode/utf8" +) + +// EnvironmentSetupBinding separates confidential fields and immutable snapshots. +type EnvironmentSetupBinding struct { + TenantID string `json:"tenant_id"` + Resource string `json:"resource"` + OwnerID string `json:"owner_id"` + Field string `json:"field"` +} + +func (c *Cipher) SealEnvironmentSetup(plaintext []byte, binding EnvironmentSetupBinding) ([]byte, error) { + aad, err := environmentSetupData(binding) + if err != nil { + return nil, err + } + return c.seal(plaintext, aad) +} + +func (c *Cipher) OpenEnvironmentSetup(ciphertext []byte, binding EnvironmentSetupBinding) ([]byte, error) { + aad, err := environmentSetupData(binding) + if err != nil { + return nil, err + } + return c.open(ciphertext, aad) +} + +func environmentSetupData(binding EnvironmentSetupBinding) ([]byte, error) { + if (binding.Resource != "environment_template" && binding.Resource != "session") || (binding.Field != "env" && binding.Field != "setup_commands" && binding.Field != "initialization") { + return nil, errInvalidBinding + } + for _, value := range []string{binding.TenantID, binding.OwnerID} { + if value == "" || !utf8.ValidString(value) { + return nil, errInvalidBinding + } + } + return json.Marshal(struct { + Domain string `json:"domain"` + Version byte `json:"version"` + Binding EnvironmentSetupBinding `json:"binding"` + }{"parsar.agents-api.environment-setup", formatVersion, binding}) +} diff --git a/services/agents-api/internal/credentialcrypto/environment_setup_test.go b/services/agents-api/internal/credentialcrypto/environment_setup_test.go new file mode 100644 index 00000000..d2e410ec --- /dev/null +++ b/services/agents-api/internal/credentialcrypto/environment_setup_test.go @@ -0,0 +1,31 @@ +package credentialcrypto + +import ( + "bytes" + "testing" +) + +func TestEnvironmentSetupResourceAndFieldBinding(t *testing.T) { + c := testCipher(t, bytes.Repeat([]byte{3}, 32)) + binding := EnvironmentSetupBinding{TenantID: "tenant", Resource: "environment_template", OwnerID: "owner", Field: "env"} + plaintext := []byte("private-configuration") + encrypted, err := c.SealEnvironmentSetup(plaintext, binding) + if err != nil { + t.Fatal(err) + } + got, err := c.OpenEnvironmentSetup(encrypted, binding) + if err != nil || !bytes.Equal(got, plaintext) { + t.Fatal("round trip", err) + } + for _, change := range []func(*EnvironmentSetupBinding){func(b *EnvironmentSetupBinding) { b.TenantID = "other" }, func(b *EnvironmentSetupBinding) { b.Resource = "session" }, func(b *EnvironmentSetupBinding) { b.OwnerID = "other" }, func(b *EnvironmentSetupBinding) { b.Field = "setup_commands" }} { + other := binding + change(&other) + if _, err := c.OpenEnvironmentSetup(encrypted, other); err == nil { + t.Fatal("cross-boundary decryption") + } + } + encrypted[len(encrypted)-1] ^= 1 + if _, err := c.OpenEnvironmentSetup(encrypted, binding); err == nil { + t.Fatal("modified ciphertext accepted") + } +} diff --git a/services/agents-api/internal/db/queries/environment_setup.sql b/services/agents-api/internal/db/queries/environment_setup.sql new file mode 100644 index 00000000..76b6d059 --- /dev/null +++ b/services/agents-api/internal/db/queries/environment_setup.sql @@ -0,0 +1,10 @@ +-- name: CreateEnvironmentSetup :exec +INSERT INTO environment_setups (session_id, contents) VALUES ($1, $2); + +-- name: GetEnvironmentSetup :one +SELECT f.contents FROM sessions s LEFT JOIN environment_setups f ON s.id = f.session_id +WHERE s.tenant_id = $1 AND s.id = $2 AND s.deleted_at IS NULL; + +-- name: SetSessionSetupMetadata :one +UPDATE sessions SET configuration = jsonb_set(jsonb_set(configuration, '{environment,packages}', $2::jsonb), '{environment,initialization}', 'true'::jsonb) +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 eaaa6877..3bb4fbbc 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) -VALUES ($1, $2, $3, $4, $5, $6) RETURNING id, tenant_id, name, network_access, created_at, updated_at, files; +INSERT INTO environment_templates (id, tenant_id, name, network_access, files, file_contents, packages, env_contents, setup_contents) +VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9) RETURNING id, tenant_id, name, network_access, created_at, updated_at, files, packages; -- name: GetEnvironmentTemplate :one -SELECT id, tenant_id, name, network_access, created_at, updated_at, files FROM environment_templates WHERE tenant_id = $1 AND id = $2; +SELECT id, tenant_id, name, network_access, created_at, updated_at, files, packages FROM environment_templates WHERE tenant_id = $1 AND id = $2; -- name: UpdateEnvironmentTemplate :one UPDATE environment_templates SET @@ -11,15 +11,18 @@ UPDATE environment_templates SET network_access = CASE WHEN sqlc.arg(set_network)::boolean THEN sqlc.arg(network_access)::text ELSE network_access END, files = CASE WHEN sqlc.arg(set_files)::boolean THEN sqlc.arg(files)::jsonb ELSE files END, file_contents = CASE WHEN sqlc.arg(set_files)::boolean THEN sqlc.narg(file_contents)::bytea ELSE file_contents END, + packages = CASE WHEN sqlc.arg(set_packages)::boolean THEN sqlc.arg(packages)::jsonb ELSE packages END, + env_contents = CASE WHEN sqlc.arg(set_env)::boolean THEN sqlc.narg(env_contents)::bytea ELSE env_contents END, + setup_contents = CASE WHEN sqlc.arg(set_setup)::boolean THEN sqlc.narg(setup_contents)::bytea ELSE setup_contents END, updated_at = clock_timestamp() WHERE tenant_id = sqlc.arg(tenant_id) AND id = sqlc.arg(id) -RETURNING id, tenant_id, name, network_access, created_at, updated_at, files; +RETURNING id, tenant_id, name, network_access, created_at, updated_at, files, packages; -- name: DeleteEnvironmentTemplate :one DELETE FROM environment_templates WHERE tenant_id = $1 AND id = $2 RETURNING id; -- name: ListEnvironmentTemplates :many -SELECT id, tenant_id, name, network_access, created_at, updated_at, files FROM environment_templates +SELECT id, tenant_id, name, network_access, created_at, updated_at, files, packages 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/queries/initial_environment_files.sql b/services/agents-api/internal/db/queries/initial_environment_files.sql index ec7b89fe..0fb3015f 100644 --- a/services/agents-api/internal/db/queries/initial_environment_files.sql +++ b/services/agents-api/internal/db/queries/initial_environment_files.sql @@ -14,7 +14,8 @@ WHERE s.tenant_id = $1 AND f.session_id = $2 AND f.position = $3 AND s.deleted_a SELECT NOT EXISTS ( SELECT 1 FROM runtime_allocations a JOIN environments e ON e.id = a.environment_id WHERE e.session_id = s.id AND a.initialization <> 'complete' -) AND (NOT EXISTS (SELECT 1 FROM initial_environment_files f WHERE f.session_id = s.id) +) AND ((NOT EXISTS (SELECT 1 FROM initial_environment_files f WHERE f.session_id = s.id) + AND NOT EXISTS (SELECT 1 FROM environment_setups f WHERE f.session_id = s.id)) OR EXISTS (SELECT 1 FROM runtime_allocations a JOIN environments e ON e.id = a.environment_id WHERE e.session_id = s.id AND a.initialization = 'complete')) AS ready FROM sessions s WHERE s.tenant_id = $1 AND s.id = $2; diff --git a/services/agents-api/internal/db/queries/runtime_allocations.sql b/services/agents-api/internal/db/queries/runtime_allocations.sql index f6ce4414..9f4bc346 100644 --- a/services/agents-api/internal/db/queries/runtime_allocations.sql +++ b/services/agents-api/internal/db/queries/runtime_allocations.sql @@ -1,6 +1,6 @@ -- name: CreateRuntimeAllocation :one INSERT INTO runtime_allocations (id, environment_id, device_id, provider_key, initialization) -VALUES ($1, $2, $3, $4, CASE WHEN EXISTS (SELECT 1 FROM initial_environment_files f JOIN environments e ON e.session_id = f.session_id WHERE e.id = $2) THEN 'pending' ELSE 'complete' END) RETURNING *; +VALUES ($1, $2, $3, $4, CASE WHEN EXISTS (SELECT 1 FROM initial_environment_files f JOIN environments e ON e.session_id = f.session_id WHERE e.id = $2) OR EXISTS (SELECT 1 FROM environment_setups f JOIN environments e ON e.session_id = f.session_id WHERE e.id = $2) THEN 'pending' ELSE 'complete' END) RETURNING *; -- name: GetRuntimeAllocation :one SELECT sqlc.embed(a), e.session_id, s.tenant_id, s.deleted_at, (a.kept_at <= clock_timestamp() - interval '1 hour') AS expired diff --git a/services/agents-api/internal/db/sqlc/environment_setup.sql.go b/services/agents-api/internal/db/sqlc/environment_setup.sql.go new file mode 100644 index 00000000..a0b20ae0 --- /dev/null +++ b/services/agents-api/internal/db/sqlc/environment_setup.sql.go @@ -0,0 +1,74 @@ +// Code generated by sqlc. DO NOT EDIT. +// versions: +// sqlc v1.29.0 +// source: environment_setup.sql + +package sqlc + +import ( + "context" + + "github.com/jackc/pgx/v5/pgtype" +) + +const createEnvironmentSetup = `-- name: CreateEnvironmentSetup :exec +INSERT INTO environment_setups (session_id, contents) VALUES ($1, $2) +` + +type CreateEnvironmentSetupParams struct { + SessionID pgtype.UUID `json:"session_id"` + Contents []byte `json:"contents"` +} + +func (q *Queries) CreateEnvironmentSetup(ctx context.Context, arg CreateEnvironmentSetupParams) error { + _, err := q.db.Exec(ctx, createEnvironmentSetup, arg.SessionID, arg.Contents) + return err +} + +const getEnvironmentSetup = `-- name: GetEnvironmentSetup :one +SELECT f.contents FROM sessions s LEFT JOIN environment_setups f ON s.id = f.session_id +WHERE s.tenant_id = $1 AND s.id = $2 AND s.deleted_at IS NULL +` + +type GetEnvironmentSetupParams struct { + TenantID pgtype.UUID `json:"tenant_id"` + ID pgtype.UUID `json:"id"` +} + +func (q *Queries) GetEnvironmentSetup(ctx context.Context, arg GetEnvironmentSetupParams) ([]byte, error) { + row := q.db.QueryRow(ctx, getEnvironmentSetup, arg.TenantID, arg.ID) + var contents []byte + err := row.Scan(&contents) + return contents, err +} + +const setSessionSetupMetadata = `-- name: SetSessionSetupMetadata :one +UPDATE sessions SET configuration = jsonb_set(jsonb_set(configuration, '{environment,packages}', $2::jsonb), '{environment,initialization}', 'true'::jsonb) +WHERE id = $1 RETURNING id, tenant_id, engine, metadata, idempotency_key, request_hash, created_at, configuration, event_sequence, creation_request_hash, deleted_at, creator_kind, creator_id +` + +type SetSessionSetupMetadataParams struct { + ID pgtype.UUID `json:"id"` + Column2 []byte `json:"column_2"` +} + +func (q *Queries) SetSessionSetupMetadata(ctx context.Context, arg SetSessionSetupMetadataParams) (Session, error) { + row := q.db.QueryRow(ctx, setSessionSetupMetadata, arg.ID, arg.Column2) + var i Session + err := row.Scan( + &i.ID, + &i.TenantID, + &i.Engine, + &i.Metadata, + &i.IdempotencyKey, + &i.RequestHash, + &i.CreatedAt, + &i.Configuration, + &i.EventSequence, + &i.CreationRequestHash, + &i.DeletedAt, + &i.CreatorKind, + &i.CreatorID, + ) + return i, err +} 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 7401a499..c2e4a070 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) -VALUES ($1, $2, $3, $4, $5, $6) RETURNING id, tenant_id, name, network_access, created_at, updated_at, files +INSERT INTO environment_templates (id, tenant_id, name, network_access, files, file_contents, packages, env_contents, setup_contents) +VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9) RETURNING id, tenant_id, name, network_access, created_at, updated_at, files, packages ` type CreateEnvironmentTemplateParams struct { @@ -23,6 +23,9 @@ type CreateEnvironmentTemplateParams struct { NetworkAccess string `json:"network_access"` Files []byte `json:"files"` FileContents []byte `json:"file_contents"` + Packages []byte `json:"packages"` + EnvContents []byte `json:"env_contents"` + SetupContents []byte `json:"setup_contents"` } type CreateEnvironmentTemplateRow struct { @@ -33,6 +36,7 @@ type CreateEnvironmentTemplateRow struct { CreatedAt pgtype.Timestamptz `json:"created_at"` UpdatedAt pgtype.Timestamptz `json:"updated_at"` Files []byte `json:"files"` + Packages []byte `json:"packages"` } func (q *Queries) CreateEnvironmentTemplate(ctx context.Context, arg CreateEnvironmentTemplateParams) (CreateEnvironmentTemplateRow, error) { @@ -43,6 +47,9 @@ func (q *Queries) CreateEnvironmentTemplate(ctx context.Context, arg CreateEnvir arg.NetworkAccess, arg.Files, arg.FileContents, + arg.Packages, + arg.EnvContents, + arg.SetupContents, ) var i CreateEnvironmentTemplateRow err := row.Scan( @@ -53,6 +60,7 @@ func (q *Queries) CreateEnvironmentTemplate(ctx context.Context, arg CreateEnvir &i.CreatedAt, &i.UpdatedAt, &i.Files, + &i.Packages, ) return i, err } @@ -74,7 +82,7 @@ func (q *Queries) DeleteEnvironmentTemplate(ctx context.Context, arg DeleteEnvir } const getEnvironmentTemplate = `-- name: GetEnvironmentTemplate :one -SELECT id, tenant_id, name, network_access, created_at, updated_at, files FROM environment_templates WHERE tenant_id = $1 AND id = $2 +SELECT id, tenant_id, name, network_access, created_at, updated_at, files, packages FROM environment_templates WHERE tenant_id = $1 AND id = $2 ` type GetEnvironmentTemplateParams struct { @@ -90,6 +98,7 @@ type GetEnvironmentTemplateRow struct { CreatedAt pgtype.Timestamptz `json:"created_at"` UpdatedAt pgtype.Timestamptz `json:"updated_at"` Files []byte `json:"files"` + Packages []byte `json:"packages"` } func (q *Queries) GetEnvironmentTemplate(ctx context.Context, arg GetEnvironmentTemplateParams) (GetEnvironmentTemplateRow, error) { @@ -103,12 +112,13 @@ func (q *Queries) GetEnvironmentTemplate(ctx context.Context, arg GetEnvironment &i.CreatedAt, &i.UpdatedAt, &i.Files, + &i.Packages, ) return i, err } const listEnvironmentTemplates = `-- name: ListEnvironmentTemplates :many -SELECT id, tenant_id, name, network_access, created_at, updated_at, files FROM environment_templates +SELECT id, tenant_id, name, network_access, created_at, updated_at, files, packages FROM environment_templates WHERE tenant_id = $1 AND ($2::timestamptz IS NULL OR (NOT $3::boolean AND (created_at, id) < ($2::timestamptz, $4::uuid)) @@ -137,6 +147,7 @@ type ListEnvironmentTemplatesRow struct { CreatedAt pgtype.Timestamptz `json:"created_at"` UpdatedAt pgtype.Timestamptz `json:"updated_at"` Files []byte `json:"files"` + Packages []byte `json:"packages"` } func (q *Queries) ListEnvironmentTemplates(ctx context.Context, arg ListEnvironmentTemplatesParams) ([]ListEnvironmentTemplatesRow, error) { @@ -162,6 +173,7 @@ func (q *Queries) ListEnvironmentTemplates(ctx context.Context, arg ListEnvironm &i.CreatedAt, &i.UpdatedAt, &i.Files, + &i.Packages, ); err != nil { return nil, err } @@ -174,7 +186,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 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 FROM environment_templates WHERE tenant_id = $1 AND id = $2 ` type ResolveEnvironmentTemplateParams struct { @@ -194,6 +206,9 @@ func (q *Queries) ResolveEnvironmentTemplate(ctx context.Context, arg ResolveEnv &i.UpdatedAt, &i.Files, &i.FileContents, + &i.Packages, + &i.EnvContents, + &i.SetupContents, ) return i, err } @@ -204,9 +219,12 @@ UPDATE environment_templates SET network_access = CASE WHEN $3::boolean THEN $4::text ELSE network_access END, files = CASE WHEN $5::boolean THEN $6::jsonb ELSE files END, file_contents = CASE WHEN $5::boolean THEN $7::bytea ELSE file_contents END, + packages = CASE WHEN $8::boolean THEN $9::jsonb ELSE packages END, + env_contents = CASE WHEN $10::boolean THEN $11::bytea ELSE env_contents END, + setup_contents = CASE WHEN $12::boolean THEN $13::bytea ELSE setup_contents END, updated_at = clock_timestamp() -WHERE tenant_id = $8 AND id = $9 -RETURNING id, tenant_id, name, network_access, created_at, updated_at, files +WHERE tenant_id = $14 AND id = $15 +RETURNING id, tenant_id, name, network_access, created_at, updated_at, files, packages ` type UpdateEnvironmentTemplateParams struct { @@ -217,6 +235,12 @@ type UpdateEnvironmentTemplateParams struct { SetFiles bool `json:"set_files"` Files []byte `json:"files"` FileContents []byte `json:"file_contents"` + SetPackages bool `json:"set_packages"` + Packages []byte `json:"packages"` + SetEnv bool `json:"set_env"` + EnvContents []byte `json:"env_contents"` + SetSetup bool `json:"set_setup"` + SetupContents []byte `json:"setup_contents"` TenantID pgtype.UUID `json:"tenant_id"` ID pgtype.UUID `json:"id"` } @@ -229,6 +253,7 @@ type UpdateEnvironmentTemplateRow struct { CreatedAt pgtype.Timestamptz `json:"created_at"` UpdatedAt pgtype.Timestamptz `json:"updated_at"` Files []byte `json:"files"` + Packages []byte `json:"packages"` } func (q *Queries) UpdateEnvironmentTemplate(ctx context.Context, arg UpdateEnvironmentTemplateParams) (UpdateEnvironmentTemplateRow, error) { @@ -240,6 +265,12 @@ func (q *Queries) UpdateEnvironmentTemplate(ctx context.Context, arg UpdateEnvir arg.SetFiles, arg.Files, arg.FileContents, + arg.SetPackages, + arg.Packages, + arg.SetEnv, + arg.EnvContents, + arg.SetSetup, + arg.SetupContents, arg.TenantID, arg.ID, ) @@ -252,6 +283,7 @@ func (q *Queries) UpdateEnvironmentTemplate(ctx context.Context, arg UpdateEnvir &i.CreatedAt, &i.UpdatedAt, &i.Files, + &i.Packages, ) return i, err } diff --git a/services/agents-api/internal/db/sqlc/initial_environment_files.sql.go b/services/agents-api/internal/db/sqlc/initial_environment_files.sql.go index 7485be9d..3f9fa3ef 100644 --- a/services/agents-api/internal/db/sqlc/initial_environment_files.sql.go +++ b/services/agents-api/internal/db/sqlc/initial_environment_files.sql.go @@ -116,7 +116,8 @@ const getSessionInitializationReady = `-- name: GetSessionInitializationReady :o SELECT NOT EXISTS ( SELECT 1 FROM runtime_allocations a JOIN environments e ON e.id = a.environment_id WHERE e.session_id = s.id AND a.initialization <> 'complete' -) AND (NOT EXISTS (SELECT 1 FROM initial_environment_files f WHERE f.session_id = s.id) +) AND ((NOT EXISTS (SELECT 1 FROM initial_environment_files f WHERE f.session_id = s.id) + AND NOT EXISTS (SELECT 1 FROM environment_setups f WHERE f.session_id = s.id)) OR EXISTS (SELECT 1 FROM runtime_allocations a JOIN environments e ON e.id = a.environment_id WHERE e.session_id = s.id AND a.initialization = 'complete')) AS ready FROM sessions s WHERE s.tenant_id = $1 AND s.id = $2 diff --git a/services/agents-api/internal/db/sqlc/models.go b/services/agents-api/internal/db/sqlc/models.go index 44ab6858..0554edc6 100644 --- a/services/agents-api/internal/db/sqlc/models.go +++ b/services/agents-api/internal/db/sqlc/models.go @@ -75,6 +75,11 @@ type EnvironmentInputReservation struct { IsInitial bool `json:"is_initial"` } +type EnvironmentSetup struct { + SessionID pgtype.UUID `json:"session_id"` + Contents []byte `json:"contents"` +} + type EnvironmentTemplate struct { ID pgtype.UUID `json:"id"` TenantID pgtype.UUID `json:"tenant_id"` @@ -84,6 +89,9 @@ type EnvironmentTemplate struct { UpdatedAt pgtype.Timestamptz `json:"updated_at"` Files []byte `json:"files"` FileContents []byte `json:"file_contents"` + Packages []byte `json:"packages"` + EnvContents []byte `json:"env_contents"` + SetupContents []byte `json:"setup_contents"` } type ExecutionProjectScope struct { diff --git a/services/agents-api/internal/db/sqlc/runtime_allocations.sql.go b/services/agents-api/internal/db/sqlc/runtime_allocations.sql.go index 4f796173..aa3bcae0 100644 --- a/services/agents-api/internal/db/sqlc/runtime_allocations.sql.go +++ b/services/agents-api/internal/db/sqlc/runtime_allocations.sql.go @@ -13,7 +13,7 @@ import ( const createRuntimeAllocation = `-- name: CreateRuntimeAllocation :one INSERT INTO runtime_allocations (id, environment_id, device_id, provider_key, initialization) -VALUES ($1, $2, $3, $4, CASE WHEN EXISTS (SELECT 1 FROM initial_environment_files f JOIN environments e ON e.session_id = f.session_id WHERE e.id = $2) THEN 'pending' ELSE 'complete' END) RETURNING id, environment_id, device_id, provider_key, state, create_settled, created_at, kept_at, released_at, initialization +VALUES ($1, $2, $3, $4, CASE WHEN EXISTS (SELECT 1 FROM initial_environment_files f JOIN environments e ON e.session_id = f.session_id WHERE e.id = $2) OR EXISTS (SELECT 1 FROM environment_setups f JOIN environments e ON e.session_id = f.session_id WHERE e.id = $2) THEN 'pending' ELSE 'complete' END) RETURNING id, environment_id, device_id, provider_key, state, create_settled, created_at, kept_at, released_at, initialization ` type CreateRuntimeAllocationParams struct { diff --git a/services/agents-api/internal/execution/environment_placement.go b/services/agents-api/internal/execution/environment_placement.go index 2208bc64..a38891ae 100644 --- a/services/agents-api/internal/execution/environment_placement.go +++ b/services/agents-api/internal/execution/environment_placement.go @@ -6,12 +6,14 @@ import ( "encoding/json" "errors" + v1 "github.com/MiniMax-AI-Dev/parsar/contracts/agents-api/v1" "github.com/MiniMax-AI-Dev/parsar/internal/agentdaemon/proto" "github.com/MiniMax-AI-Dev/parsar/services/agents-api/internal/store" ) type environmentPlacement struct { Type string `json:"type"` + ToolEnvironment bool `json:"initialization,omitempty"` NetworkAccess string `json:"-"` WorkspaceDirectory string `json:"workspace_directory"` CapabilityDirectories []string `json:"capability_directories"` @@ -38,6 +40,8 @@ func parseEnvironmentPlacement(configuration json.RawMessage) (environmentPlacem // Qualified local execution currently supports enabled/disabled network only. var local struct { Files []store.InitialFileMetadata `json:"files"` + Packages *v1.EnvironmentPackages `json:"packages,omitempty"` + Initialization bool `json:"initialization,omitempty"` Type string `json:"type"` CapabilityDirectories []string `json:"capability_directories"` Network *struct { @@ -77,7 +81,7 @@ func (d *Dispatcher) configurePreparedEnvironment(ctx context.Context, session s return nil, store.ErrInvalidInput } if placement.Type == "openai_hosted" { - req.LocalEnvironment = &proto.LocalEnvironment{ID: environment.ID} + req.LocalEnvironment = &proto.LocalEnvironment{ID: environment.ID, ToolEnvironment: placement.ToolEnvironment} // Keep the previously qualified explicit-disabled internal peer path intact. // New bound-policy peers validate the exact policy during preparation. boundPolicy := placement.NetworkAccess != "disabled" diff --git a/services/agents-api/internal/execution/runtime_initialization.go b/services/agents-api/internal/execution/runtime_initialization.go index e6c8b526..36f8ee8f 100644 --- a/services/agents-api/internal/execution/runtime_initialization.go +++ b/services/agents-api/internal/execution/runtime_initialization.go @@ -17,6 +17,8 @@ import ( type runtimeInitialization struct { owner store.RuntimeAllocation next, count int + files int + operations []runtimeSetupOperation deadline time.Time } @@ -39,9 +41,19 @@ func (r *runtimeLifecycle) observeInitialization(ctx context.Context, owner stor return err } var cfg struct { - Files []store.InitialFileMetadata `json:"files"` + Initialization bool `json:"initialization"` + Files []store.InitialFileMetadata `json:"files"` } - if json.Unmarshal(environment.Configuration, &cfg) != nil || len(cfg.Files) == 0 || len(cfg.Files) > 50 { + if json.Unmarshal(environment.Configuration, &cfg) != nil || len(cfg.Files) > 50 { + _, err = r.store.RequestRuntimeCleanup(ctx, owner) + return err + } + setup, err := r.store.ReadEnvironmentSetup(ctx, owner.TenantID, owner.SessionID) + if err != nil { + return err + } + operations := setupOperations(setup) + if len(cfg.Files)+len(operations) == 0 || cfg.Initialization != !setup.Empty() { _, err = r.store.RequestRuntimeCleanup(ctx, owner) return err } @@ -49,11 +61,11 @@ func (r *runtimeLifecycle) observeInitialization(ctx context.Context, owner stor if err != nil { return err } - r.initializing = &runtimeInitialization{owner: claimed, count: len(cfg.Files), deadline: time.Now().Add(30 * time.Minute)} + r.initializing = &runtimeInitialization{owner: claimed, count: len(cfg.Files) + len(operations), files: len(cfg.Files), operations: operations, deadline: time.Now().Add(30 * time.Minute)} return nil } -// One bounded file follows a full maintenance scan; other allocations get serviced between files. +// One bounded operation follows a full maintenance scan; other allocations get serviced between operations. func (r *runtimeLifecycle) advanceInitialization(ctx context.Context) error { active := r.initializing if active == nil { @@ -85,9 +97,15 @@ func (r *runtimeLifecycle) advanceInitialization(ctx context.Context) error { r.initializing = nil return err } - file, body, err := r.store.ReadInitialEnvironmentFile(operation, owner.TenantID, owner.SessionID, active.next) - if err == nil { - err = installInitialFile(operation, r.config.Providers[owner.ProviderKey], runtimeReference(owner), file, body) + if active.next < active.files { + var file store.InitialFileMetadata + var body []byte + file, body, err = r.store.ReadInitialEnvironmentFile(operation, owner.TenantID, owner.SessionID, active.next) + if err == nil { + err = installInitialFile(operation, r.config.Providers[owner.ProviderKey], runtimeReference(owner), file, body) + } + } else { + err = runRuntimeSetup(operation, r.config.Providers[owner.ProviderKey], runtimeReference(owner), active.operations[active.next-active.files]) } if err != nil { // Clearing the in-memory owner makes the next observation request cleanup, diff --git a/services/agents-api/internal/execution/runtime_setup.go b/services/agents-api/internal/execution/runtime_setup.go new file mode 100644 index 00000000..0515ac4a --- /dev/null +++ b/services/agents-api/internal/execution/runtime_setup.go @@ -0,0 +1,72 @@ +package execution + +import ( + "context" + "encoding/json" + "errors" + + "github.com/MiniMax-AI-Dev/parsar/services/agents-api/internal/sandbox" + "github.com/MiniMax-AI-Dev/parsar/services/agents-api/internal/store" +) + +// runtimeSetupOperation is the packaged initializer's confidential stdin contract. +// Public templates and native harness configuration never cross this boundary. +type runtimeSetupOperation struct { + Version int `json:"version"` + Action string `json:"action"` + Network string `json:"network,omitempty"` + Env map[string]string `json:"env"` + Packages []string `json:"packages,omitempty"` + Command string `json:"command,omitempty"` + CWD string `json:"cwd,omitempty"` +} + +func setupOperations(setup store.EnvironmentSetup) []runtimeSetupOperation { + if setup.Empty() { + return nil + } + env := setup.Env + if env == nil { + env = map[string]string{} + } + result := []runtimeSetupOperation{{Version: 1, Action: "configure", Env: env}} + // The public network policy applies after setup completes. Provisioning uses + // the isolated initializer's network; adapters enforce the runtime policy. + const network = "enabled" + if len(setup.Packages.NPM) > 0 { + result = append(result, runtimeSetupOperation{Version: 1, Action: "npm", Network: network, Packages: setup.Packages.NPM}) + } + if len(setup.Packages.Python) > 0 { + result = append(result, runtimeSetupOperation{Version: 1, Action: "python", Network: network, Packages: setup.Packages.Python}) + } + for _, command := range setup.Commands { + cwd := command.CWD + if cwd == "" { + cwd = "/workspace" + } + result = append(result, runtimeSetupOperation{Version: 1, Action: "setup", Network: network, Command: command.Command, CWD: cwd}) + } + return result +} + +func runRuntimeSetup(ctx context.Context, provider sandbox.Provider, reference sandbox.Reference, operation runtimeSetupOperation) error { + if provider == nil { + return sandbox.ErrInvalid + } + input, err := json.Marshal(operation) + 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}) + if err != nil { + return err + } + var receipt struct { + Version int `json:"version"` + Outcome string `json:"outcome"` + } + if result.ExitCode != 0 || result.Stderr != "" || json.Unmarshal([]byte(result.Stdout), &receipt) != nil || receipt.Version != 1 || receipt.Outcome != "completed" { + return errors.New("environment initialization operation unconfirmed") + } + return nil +} diff --git a/services/agents-api/internal/sandbox/docker/bootstrap.go b/services/agents-api/internal/sandbox/docker/bootstrap.go index e6eb824b..54dba1ae 100644 --- a/services/agents-api/internal/sandbox/docker/bootstrap.go +++ b/services/agents-api/internal/sandbox/docker/bootstrap.go @@ -36,7 +36,7 @@ func (p *Provider) bootstrap(ctx context.Context, id string, b sandbox.Bootstrap }); e != nil { return e } - return p.copy(ctx, id, "/environment", []entry{{name: "workspace", directory: true}, {name: "staging", directory: true}}) + return p.copy(ctx, id, "/environment", []entry{{name: "workspace", directory: true}, {name: "staging", directory: true}, {name: "initialization", directory: true}, {name: "packages", directory: true}}) } func (p *Provider) copy(ctx context.Context, id, path string, entries []entry) error { var content bytes.Buffer diff --git a/services/agents-api/internal/store/environment_setup.go b/services/agents-api/internal/store/environment_setup.go new file mode 100644 index 00000000..4351e9a4 --- /dev/null +++ b/services/agents-api/internal/store/environment_setup.go @@ -0,0 +1,152 @@ +package store + +import ( + "context" + "encoding/json" + "errors" + "path" + "regexp" + "strings" + + v1 "github.com/MiniMax-AI-Dev/parsar/contracts/agents-api/v1" + "github.com/MiniMax-AI-Dev/parsar/services/agents-api/internal/credentialcrypto" + "github.com/MiniMax-AI-Dev/parsar/services/agents-api/internal/db/sqlc" + "github.com/google/uuid" + "github.com/jackc/pgx/v5" + "github.com/jackc/pgx/v5/pgtype" +) + +// 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"` +} + +type SetupCommand struct { + Command string `json:"command"` + CWD string `json:"cwd,omitempty"` +} + +var environmentName = regexp.MustCompile(`^[A-Za-z_][A-Za-z0-9_]*$`) + +func (s EnvironmentSetup) Empty() bool { + return len(s.Env)+len(s.Commands)+len(s.Packages.NPM)+len(s.Packages.Python)+len(s.Packages.System) == 0 +} + +func (s EnvironmentSetup) Validate() error { + raw, err := json.Marshal(s) + if err != nil || len(raw) > 512*1024 || len(s.Packages.System) > 0 { + return ErrInvalidInput + } + for name, value := range s.Env { + // The first three reservations are explicitly part of the public guide; + // PARSAR_* identifies the actual deployment authority and binding. + if !environmentName.MatchString(name) || name == "PATH" || name == "OPENAI_API_KEY" || strings.HasPrefix(name, "CODEX_") || strings.HasPrefix(name, "PARSAR_") || strings.ContainsRune(value, 0) { + return ErrInvalidInput + } + } + for _, command := range s.Commands { + if command.Command == "" || strings.ContainsRune(command.Command, 0) || (command.CWD != "" && (!path.IsAbs(command.CWD) || strings.ContainsRune(command.CWD, 0))) { + return ErrInvalidInput + } + } + for _, packages := range [][]string{s.Packages.NPM, s.Packages.Python} { + for _, item := range packages { + if item == "" || strings.HasPrefix(item, "-") || strings.ContainsRune(item, 0) { + return ErrInvalidInput + } + } + } + return nil +} + +func (s *Store) sealEnvironmentSetup(tenant, resource, id, field string, input any, empty bool) ([]byte, error) { + if empty { + return nil, nil + } + plaintext, err := json.Marshal(input) + if err != nil { + return nil, err + } + return s.credentialCipher.SealEnvironmentSetup(plaintext, credentialcrypto.EnvironmentSetupBinding{TenantID: tenant, Resource: resource, OwnerID: id, Field: field}) +} + +func (s *Store) openEnvironmentSetup(tenant, resource, id, field string, ciphertext []byte, output any) error { + if len(ciphertext) == 0 { + return nil + } + plaintext, err := s.credentialCipher.OpenEnvironmentSetup(ciphertext, credentialcrypto.EnvironmentSetupBinding{TenantID: tenant, Resource: resource, OwnerID: id, Field: field}) + if err != nil { + return err + } + if json.Unmarshal(plaintext, output) != nil { + return ErrInvalidInput + } + return nil +} + +func (s *Store) saveEnvironmentSetup(ctx context.Context, q *sqlc.Queries, tenant string, session pgtype.UUID, setup EnvironmentSetup) error { + if err := setup.Validate(); err != nil { + return err + } + if setup.Empty() { + return nil + } + owner, err := parseID(tenant) + if err != nil { + return err + } + encrypted, err := s.sealEnvironmentSetup(uuid.UUID(owner.Bytes).String(), "session", uuid.UUID(session.Bytes).String(), "initialization", setup, false) + if err != nil { + return err + } + return q.CreateEnvironmentSetup(ctx, sqlc.CreateEnvironmentSetupParams{SessionID: session, Contents: encrypted}) +} + +func (s *Store) ReadEnvironmentSetup(ctx context.Context, tenant, session string) (EnvironmentSetup, error) { + var result EnvironmentSetup + lookup, err := deviceLookup(tenant, session) + if err != nil { + return result, ErrNotFound + } + encrypted, err := s.queries.GetEnvironmentSetup(ctx, sqlc.GetEnvironmentSetupParams{TenantID: lookup.TenantID, ID: lookup.ID}) + if errors.Is(err, pgx.ErrNoRows) { + return result, ErrNotFound + } + if err != nil { + return result, err + } + if err = s.openEnvironmentSetup(uuid.UUID(lookup.TenantID.Bytes).String(), "session", uuid.UUID(lookup.ID.Bytes).String(), "initialization", encrypted, &result); err != nil { + return result, err + } + return result, result.Validate() +} + +func (s EnvironmentSetup) PackageMetadata() v1.EnvironmentPackages { + result := s.Packages + if result.NPM == nil { + result.NPM = []string{} + } + if result.Python == nil { + result.Python = []string{} + } + if result.System == nil { + result.System = []string{} + } + return result +} + +func (s *Store) sealTemplateSetup(tenant, id string, setup EnvironmentSetup) ([]byte, []byte, []byte, error) { + packages, err := json.Marshal(setup.PackageMetadata()) + if err != nil { + return nil, nil, nil, err + } + env, err := s.sealEnvironmentSetup(tenant, "environment_template", id, "env", setup.Env, len(setup.Env) == 0) + if err != nil { + return nil, nil, nil, err + } + commands, err := s.sealEnvironmentSetup(tenant, "environment_template", id, "setup_commands", setup.Commands, len(setup.Commands) == 0) + return packages, env, commands, err +} diff --git a/services/agents-api/internal/store/environment_setup_test.go b/services/agents-api/internal/store/environment_setup_test.go new file mode 100644 index 00000000..385ef2ce --- /dev/null +++ b/services/agents-api/internal/store/environment_setup_test.go @@ -0,0 +1,88 @@ +package store + +import ( + "bytes" + "encoding/json" + "errors" + "reflect" + "testing" + + v1 "github.com/MiniMax-AI-Dev/parsar/contracts/agents-api/v1" + "github.com/MiniMax-AI-Dev/parsar/services/agents-api/internal/credentialcrypto" + "github.com/google/uuid" +) + +func TestEnvironmentSetupEncryptedSnapshotAndIsolation(t *testing.T) { + _, pool := testStore(t) + cipher, err := credentialcrypto.New(bytes.Repeat([]byte{5}, 32)) + if err != nil { + t.Fatal(err) + } + s := NewWithCredentialCipher(pool, cipher) + tenant, foreign := uuid.NewString(), uuid.NewString() + setup := EnvironmentSetup{Env: map[string]string{"SECRET": "template-env-canary"}, Commands: []SetupCommand{{Command: "printf template-command-canary > result"}}, Packages: v1.EnvironmentPackages{NPM: []string{"is-number@7.0.0"}}} + template, err := s.CreateEnvironmentTemplate(t.Context(), tenant, EnvironmentTemplateInput{Initialization: setup, SetEnv: true, SetSetup: true, SetPackages: true}) + if err != nil { + t.Fatal(err) + } + public, err := New(pool).GetEnvironmentTemplate(t.Context(), tenant, template.ID) + if err != nil || len(public.Packages.NPM) != 1 || !public.Initialization.Empty() { + t.Fatal("public metadata requires plaintext or key", err) + } + resolved, _, err := s.ResolveEnvironmentTemplate(t.Context(), tenant, template.ID) + if err != nil || !reflect.DeepEqual(resolved.Initialization.Env, setup.Env) || !reflect.DeepEqual(resolved.Initialization.Commands, setup.Commands) { + t.Fatal("confidential configuration resolution", err) + } + if _, _, err := s.ResolveEnvironmentTemplate(t.Context(), foreign, template.ID); !errors.Is(err, ErrNotFound) { + t.Fatal("foreign resolution", err) + } + var envCipher, commandCipher []byte + if err := pool.QueryRow(t.Context(), "SELECT env_contents, setup_contents FROM environment_templates WHERE id=$1", template.ID).Scan(&envCipher, &commandCipher); err != nil || bytes.Contains(envCipher, []byte("template-env-canary")) || bytes.Contains(commandCipher, []byte("template-command-canary")) { + t.Fatal("plaintext template storage", err) + } + // Unrelated updates preserve both confidential fields; replacement is scoped. + name := "renamed" + if _, err := s.UpdateEnvironmentTemplate(t.Context(), tenant, template.ID, EnvironmentTemplateInput{SetName: true, Name: &name}); err != nil { + t.Fatal(err) + } + retained, _, err := s.ResolveEnvironmentTemplate(t.Context(), tenant, template.ID) + if err != nil || !reflect.DeepEqual(retained.Initialization, resolved.Initialization) { + t.Fatal("name update changed initialization", err) + } + input := 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, input) + if err != nil { + t.Fatal(err) + } + if bytes.Contains(session.Configuration, []byte("canary")) { + t.Fatal("plaintext Session metadata") + } + if _, err := s.UpdateEnvironmentTemplate(t.Context(), tenant, template.ID, EnvironmentTemplateInput{SetEnv: true}); err != nil { + t.Fatal(err) + } + cleared, _, err := s.ResolveEnvironmentTemplate(t.Context(), tenant, template.ID) + if err != nil || len(cleared.Initialization.Env) != 0 || !reflect.DeepEqual(cleared.Initialization.Commands, setup.Commands) { + t.Fatal("field replacement lost unrelated values", 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, resolved.Initialization) { + t.Fatal("Session did not freeze setup", err) + } + if _, err := s.ReadEnvironmentSetup(t.Context(), foreign, session.ID); !errors.Is(err, ErrNotFound) { + t.Fatal("foreign Session initialization", err) + } + retry, err := s.CreateSession(t.Context(), tenant, input) + if err != nil || retry.ID != session.ID { + t.Fatal("creation replay", err) + } + input.Initialization.Env = map[string]string{"SECRET": "changed"} + if _, err := s.CreateSession(t.Context(), tenant, input); !errors.Is(err, ErrIdempotencyConflict) { + t.Fatal("changed setup retried", err) + } + if _, err := s.GetSessionExecutionBinding(t.Context(), tenant, session.ID); !errors.Is(err, ErrNotFound) { + t.Fatal("uninitialized execution admitted", err) + } +} diff --git a/services/agents-api/internal/store/environment_templates.go b/services/agents-api/internal/store/environment_templates.go index c499eaf0..fcb22e7f 100644 --- a/services/agents-api/internal/store/environment_templates.go +++ b/services/agents-api/internal/store/environment_templates.go @@ -7,6 +7,8 @@ import ( "time" "unicode/utf8" + v1 "github.com/MiniMax-AI-Dev/parsar/contracts/agents-api/v1" + "github.com/MiniMax-AI-Dev/parsar/services/agents-api/internal/db/sqlc" "github.com/google/uuid" "github.com/jackc/pgx/v5" @@ -16,25 +18,29 @@ import ( // EnvironmentTemplate is configuration ownership, independent of provider images. type EnvironmentTemplate struct { - Files []InitialFileMetadata - ID string - Name *string - NetworkAccess string - CreatedAt time.Time - UpdatedAt time.Time + Packages v1.EnvironmentPackages + Initialization EnvironmentSetup + Files []InitialFileMetadata + ID string + Name *string + NetworkAccess string + CreatedAt time.Time + UpdatedAt time.Time } type EnvironmentTemplateInput struct { - Files []InitialFile - SetFiles bool - Name *string - SetName bool - NetworkAccess string - SetNetwork bool + Initialization EnvironmentSetup + SetEnv, SetSetup, SetPackages bool + Files []InitialFile + SetFiles bool + Name *string + SetName bool + NetworkAccess string + SetNetwork bool } func (in EnvironmentTemplateInput) valid() bool { - return (in.Name == nil || (utf8.ValidString(*in.Name) && utf8.RuneCountInString(*in.Name) >= 1 && utf8.RuneCountInString(*in.Name) <= 256)) && + return in.Initialization.Validate() == nil && (in.Name == nil || (utf8.ValidString(*in.Name) && utf8.RuneCountInString(*in.Name) >= 1 && utf8.RuneCountInString(*in.Name) <= 256)) && (!in.SetNetwork || in.NetworkAccess == "enabled" || in.NetworkAccess == "disabled") } @@ -51,7 +57,7 @@ func templateFromRow(row templateMetadataRow, err error) (EnvironmentTemplate, e if row.Name.Valid { result.Name = &row.Name.String } - if json.Unmarshal(row.Files, &result.Files) != nil { + if json.Unmarshal(row.Files, &result.Files) != nil || json.Unmarshal(row.Packages, &result.Packages) != nil { return EnvironmentTemplate{}, ErrInvalidInput } return result, nil @@ -78,7 +84,11 @@ func (s *Store) CreateEnvironmentTemplate(ctx context.Context, tenantID string, if err != nil { return EnvironmentTemplate{}, err } - row, err := s.queries.CreateEnvironmentTemplate(ctx, sqlc.CreateEnvironmentTemplateParams{ID: pgtype.UUID{Bytes: id, Valid: true}, TenantID: tenant, Name: name, NetworkAccess: in.NetworkAccess, Files: metadata, FileContents: encrypted}) + packages, envContents, setupContents, err := s.sealTemplateSetup(uuid.UUID(tenant.Bytes).String(), id.String(), in.Initialization) + if err != nil { + return EnvironmentTemplate{}, err + } + row, err := s.queries.CreateEnvironmentTemplate(ctx, sqlc.CreateEnvironmentTemplateParams{ID: pgtype.UUID{Bytes: id, Valid: true}, TenantID: tenant, Name: name, NetworkAccess: in.NetworkAccess, Files: metadata, FileContents: encrypted, Packages: packages, EnvContents: envContents, SetupContents: setupContents}) return templateFromRow(templateMetadataRow(row), err) } @@ -108,7 +118,7 @@ func (s *Store) UpdateEnvironmentTemplate(ctx context.Context, tenantID, templat if err != nil { return EnvironmentTemplate{}, ErrNotFound } - if !in.SetName && !in.SetNetwork && !in.SetFiles { + if !in.SetName && !in.SetNetwork && !in.SetFiles && !in.SetEnv && !in.SetSetup && !in.SetPackages { return s.GetEnvironmentTemplate(ctx, tenantID, templateID) } var name pgtype.Text @@ -119,7 +129,11 @@ func (s *Store) UpdateEnvironmentTemplate(ctx context.Context, tenantID, templat if err != nil { return EnvironmentTemplate{}, err } - row, err := s.queries.UpdateEnvironmentTemplate(ctx, sqlc.UpdateEnvironmentTemplateParams{TenantID: tenant, ID: id, Name: name, SetName: in.SetName, NetworkAccess: in.NetworkAccess, SetNetwork: in.SetNetwork, SetFiles: in.SetFiles, Files: metadata, FileContents: encrypted}) + packages, envContents, setupContents, err := s.sealTemplateSetup(uuid.UUID(tenant.Bytes).String(), uuid.UUID(id.Bytes).String(), in.Initialization) + if err != nil { + return EnvironmentTemplate{}, err + } + row, err := s.queries.UpdateEnvironmentTemplate(ctx, sqlc.UpdateEnvironmentTemplateParams{TenantID: tenant, ID: id, Name: name, SetName: in.SetName, NetworkAccess: in.NetworkAccess, SetNetwork: in.SetNetwork, SetFiles: in.SetFiles, Files: metadata, FileContents: encrypted, Packages: packages, EnvContents: envContents, SetupContents: setupContents, SetPackages: in.SetPackages, SetEnv: in.SetEnv, SetSetup: in.SetSetup}) 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 fcdb21a1..961c8513 100644 --- a/services/agents-api/internal/store/initial_files.go +++ b/services/agents-api/internal/store/initial_files.go @@ -104,10 +104,21 @@ func (s *Store) ResolveEnvironmentTemplate(ctx context.Context, tenant, id strin return EnvironmentTemplate{}, nil, ErrNotFound } row, err := s.queries.ResolveEnvironmentTemplate(ctx, sqlc.ResolveEnvironmentTemplateParams{TenantID: lookup.TenantID, ID: lookup.ID}) - value, err := templateFromRow(templateMetadataRow{ID: row.ID, TenantID: row.TenantID, Name: row.Name, NetworkAccess: row.NetworkAccess, CreatedAt: row.CreatedAt, UpdatedAt: row.UpdatedAt, Files: row.Files}, err) + value, err := templateFromRow(templateMetadataRow{ID: row.ID, TenantID: row.TenantID, Name: row.Name, NetworkAccess: row.NetworkAccess, CreatedAt: row.CreatedAt, UpdatedAt: row.UpdatedAt, Files: row.Files, Packages: row.Packages}, err) if err != nil { return value, nil, err } + value.Initialization.Packages = value.Packages + canonicalTenant := uuid.UUID(lookup.TenantID.Bytes).String() + if err = s.openEnvironmentSetup(canonicalTenant, "environment_template", value.ID, "env", row.EnvContents, &value.Initialization.Env); err != nil { + return value, nil, err + } + if err = s.openEnvironmentSetup(canonicalTenant, "environment_template", value.ID, "setup_commands", row.SetupContents, &value.Initialization.Commands); err != nil { + return value, nil, err + } + if err = value.Initialization.Validate(); err != nil { + return value, nil, err + } if len(row.FileContents) == 0 { if len(value.Files) > 0 { return value, nil, ErrInvalidInput diff --git a/services/agents-api/internal/store/runtime_initialization_test.go b/services/agents-api/internal/store/runtime_initialization_test.go index f262bc07..16b0c3a4 100644 --- a/services/agents-api/internal/store/runtime_initialization_test.go +++ b/services/agents-api/internal/store/runtime_initialization_test.go @@ -8,8 +8,10 @@ import ( "errors" "fmt" "strconv" + "strings" "testing" + v1 "github.com/MiniMax-AI-Dev/parsar/contracts/agents-api/v1" "github.com/MiniMax-AI-Dev/parsar/services/agents-api/internal/credentialcrypto" "github.com/MiniMax-AI-Dev/parsar/services/agents-api/internal/sandbox" "github.com/MiniMax-AI-Dev/parsar/services/agents-api/internal/store" @@ -31,6 +33,16 @@ func (p *initializingProvider) RunCommand(_ context.Context, _ sandbox.Reference if p.fail { return sandbox.CommandResult{}, sandbox.ErrCommandUnconfirmed } + if c.Args[len(c.Args)-1] == "/usr/local/bin/agents-api-runtime-initialize" { + var operation struct { + Version int `json:"version"` + Action string `json:"action"` + } + if json.Unmarshal(c.Stdin, &operation) != nil || operation.Version != 1 || operation.Action == "" { + return sandbox.CommandResult{}, sandbox.ErrInvalid + } + return sandbox.CommandResult{Stdout: `{"version":1,"outcome":"completed"}`}, nil + } size, err := strconv.Atoi(c.Args[len(c.Args)-1]) if err != nil || len(c.Stdin) != size+32 { return sandbox.CommandResult{}, sandbox.ErrInvalid @@ -43,8 +55,11 @@ func (p *initializingProvider) RunCommand(_ context.Context, _ sandbox.Reference } func TestManagedInitialFilesGateFairnessCompletionAndRestart(t *testing.T) { - for _, mode := range []string{"complete", "restart", "uncertain"} { + for _, mode := range []string{"complete", "restart", "uncertain", "setup-complete", "setup-restart", "setup-uncertain"} { t.Run(mode, func(t *testing.T) { + setupOnly := strings.HasPrefix(mode, "setup-") + mode = strings.TrimPrefix(mode, "setup-") + expectedSteps := 2 _, pool := store.NewTestStore(t) cipher, err := credentialcrypto.New(bytes.Repeat([]byte{9}, 32)) if err != nil { @@ -53,6 +68,11 @@ func TestManagedInitialFilesGateFairnessCompletionAndRestart(t *testing.T) { s := store.NewWithCredentialCipher(pool, cipher) tenant := uuid.NewString() input := store.CreateSessionInput{Creator: store.FixtureCreator(), Engine: "codex", IdempotencyKey: uuid.NewString(), Configuration: json.RawMessage(`{"environment":{"type":"openai_hosted"}}`), InitialFiles: []store.InitialFile{{Type: "inline", Path: "/workspace/a", Data: []byte("first")}, {Type: "inline", Path: "/workspace/b", Data: []byte("second")}}} + if setupOnly { + input.InitialFiles = nil + input.Initialization = store.EnvironmentSetup{Env: map[string]string{"VALUE": "private"}, Packages: v1.EnvironmentPackages{NPM: []string{"is-number@7.0.0"}}, Commands: []store.SetupCommand{{Command: "touch first"}, {Command: "test -f first"}}} + expectedSteps = 4 + } session, err := s.CreateSession(t.Context(), tenant, input) if err != nil { t.Fatal(err) @@ -99,13 +119,13 @@ func TestManagedInitialFilesGateFairnessCompletionAndRestart(t *testing.T) { w, _ = managedWorker(t, s, key, p) } if mode == "complete" { - for n := 0; p.writes < 2 && n < 100; n++ { + for n := 0; p.writes < expectedSteps && n < 100; n++ { if err := w.ReconcileManagedRuntimes(t.Context()); err != nil { t.Fatal(err) } } got, err := s.GetRuntimeAllocation(t.Context(), tenant, env.ID) - if err != nil || got.Initialization != "complete" || p.writes != 2 || p.gets <= afterFirst { + if err != nil || got.Initialization != "complete" || p.writes != expectedSteps || p.gets <= afterFirst { t.Fatal("completion or maintenance", got, err, p.writes) } if _, err := s.GetSessionExecutionBinding(t.Context(), tenant, session.ID); err != nil { @@ -118,7 +138,7 @@ func TestManagedInitialFilesGateFairnessCompletionAndRestart(t *testing.T) { t.Fatal(err) } } - if p.writes != 2 { + if p.writes != expectedSteps { t.Fatal("completed initialization replayed") } } else { diff --git a/services/agents-api/internal/store/session_initial_input.go b/services/agents-api/internal/store/session_initial_input.go index e883e6d9..863a3a01 100644 --- a/services/agents-api/internal/store/session_initial_input.go +++ b/services/agents-api/internal/store/session_initial_input.go @@ -22,7 +22,7 @@ func validateInitialInputs(inputs []Input) ([]Input, json.RawMessage, error) { // The Session upsert locks retries. Only the new row reserves or admits work, so a // retry after completion or later Turns cannot submit the original input again. -func (s *Store) createSessionResources(ctx context.Context, tenant string, params sqlc.CreateSessionParams, inputs []Input, encodedInput json.RawMessage, files []InitialFile) (sqlc.Session, *Environment, error) { +func (s *Store) createSessionResources(ctx context.Context, tenant string, params sqlc.CreateSessionParams, inputs []Input, encodedInput json.RawMessage, files []InitialFile, setup EnvironmentSetup) (sqlc.Session, *Environment, error) { var row sqlc.Session var environment *Environment err := pgx.BeginFunc(ctx, s.pool, func(tx pgx.Tx) error { @@ -43,6 +43,19 @@ func (s *Store) createSessionResources(ctx context.Context, tenant string, param return err } } + if err := s.saveEnvironmentSetup(ctx, q, tenant, row.ID, setup); err != nil { + return err + } + if !setup.Empty() { + packages, err := json.Marshal(setup.PackageMetadata()) + if err != nil { + return err + } + row, err = q.SetSessionSetupMetadata(ctx, sqlc.SetSessionSetupMetadataParams{ID: row.ID, Column2: packages}) + if err != nil { + return err + } + } if err := createSessionEnvironment(ctx, q, row); err != nil { return err } diff --git a/services/agents-api/internal/store/sessions.go b/services/agents-api/internal/store/sessions.go index 21bac09c..66a6b589 100644 --- a/services/agents-api/internal/store/sessions.go +++ b/services/agents-api/internal/store/sessions.go @@ -49,6 +49,7 @@ type Session struct { } type CreateSessionInput struct { + Initialization EnvironmentSetup InitialFiles []InitialFile Creator identity.Subject CreationRequest json.RawMessage @@ -121,14 +122,19 @@ func (s *Store) createSession(ctx context.Context, tenantID string, input Create if string(configuration) == "{}" { hashConfiguration = nil } + var initialization *EnvironmentSetup + if !input.Initialization.Empty() { + initialization = &input.Initialization + } // JSON map keys are sorted by encoding/json, so key order does not affect retries. canonical, err := json.Marshal(struct { - Engine string - Metadata map[string]string - Configuration json.RawMessage `json:",omitempty"` - InitialInputs json.RawMessage `json:",omitempty"` - InitialFiles []InitialFile `json:",omitempty"` - }{input.Engine, input.Metadata, hashConfiguration, encodedInput, input.InitialFiles}) + Engine string + Metadata map[string]string + Configuration json.RawMessage `json:",omitempty"` + InitialInputs json.RawMessage `json:",omitempty"` + InitialFiles []InitialFile `json:",omitempty"` + Initialization *EnvironmentSetup `json:",omitempty"` + }{input.Engine, input.Metadata, hashConfiguration, encodedInput, input.InitialFiles, initialization}) if err != nil { return SessionCreation{}, fmt.Errorf("%w: input: %v", ErrInvalidInput, err) } @@ -143,7 +149,7 @@ func (s *Store) createSession(ctx context.Context, tenantID string, input Create Configuration: configuration, CreationRequestHash: creationHash, CreatorKind: pgtype.Text{String: input.Creator.Kind, Valid: true}, CreatorID: pgtype.Text{String: input.Creator.ID, Valid: true}, } - row, environment, err := s.createSessionResources(ctx, tenantID, params, batch, encodedInput, input.InitialFiles) + row, environment, err := s.createSessionResources(ctx, tenantID, params, batch, encodedInput, input.InitialFiles, input.Initialization) if errors.Is(err, pgx.ErrNoRows) { return SessionCreation{}, ErrIdempotencyConflict } diff --git a/services/agents-api/migrations/000044_environment_setup.sql b/services/agents-api/migrations/000044_environment_setup.sql new file mode 100644 index 00000000..859a7471 --- /dev/null +++ b/services/agents-api/migrations/000044_environment_setup.sql @@ -0,0 +1,13 @@ +-- +goose Up +ALTER TABLE environment_templates + ADD COLUMN packages jsonb NOT NULL DEFAULT '{"npm":[],"python":[],"system":[]}'::jsonb CHECK (jsonb_typeof(packages) = 'object'), + ADD COLUMN env_contents bytea, + ADD COLUMN setup_contents bytea; +CREATE TABLE environment_setups ( + session_id uuid PRIMARY KEY REFERENCES sessions(id) ON DELETE CASCADE, + contents bytea NOT NULL +); + +-- +goose Down +DROP TABLE environment_setups; +ALTER TABLE environment_templates DROP COLUMN packages, DROP COLUMN env_contents, DROP COLUMN setup_contents; diff --git a/services/agents-api/tests/official_e2b_v1.py b/services/agents-api/tests/official_e2b_v1.py index b8e2d8a1..16b4f436 100644 --- a/services/agents-api/tests/official_e2b_v1.py +++ b/services/agents-api/tests/official_e2b_v1.py @@ -28,6 +28,8 @@ config = json.loads(Path(sys.argv[1]).read_text()) if config.get('verify_initial_files') and not config.get('verify_environment_templates'): raise ValueError('Initial-file acceptance requires verify_environment_templates') +if config.get('verify_environment_setup') and not config.get('verify_initial_files'): + raise ValueError('Setup acceptance requires verify_initial_files') if config.get('verify_initialization_restart') and not config.get('verify_initial_files'): raise ValueError('Initialization restart acceptance requires verify_initial_files') root = Path(config['proof_root']) @@ -241,8 +243,14 @@ def check(name): from official_environment_initial_files import initial_files, verify_initial_snapshot, assert_initial_bytes_script environment, initial_source, initial_expected = initial_files(client, foreign, http, agent, enabled_template) sources.append(initial_source) + if config.get('verify_environment_setup'): + from official_environment_setup import setup_configuration, attach_setup, verify_setup_metadata, native_setup_script + setup, setup_marker = setup_configuration() + environment = attach_setup(client, foreign, http, environment, setup, enabled_template, network='enabled') session = sessions.create(agent=agent, environment=environment, extra_headers={'Idempotency-Key': 'idle'}) created.append(session.id) + if config.get('verify_environment_setup'): + verify_setup_metadata(client, session, setup) eid = session.environment.id if initial_expected: verify_initial_snapshot(client, http, session, initial_expected, initial_source) @@ -286,6 +294,8 @@ def check(name): assert http.get(base + '/v1' + resource, headers={**headers, 'Authorization': 'Bearer ' + tokens[1]}).status_code == 404 marker, memory = secrets.token_hex(24), secrets.token_hex(24) expected_files = {p: len(body) for p, body in initial_expected.items()} + if config.get('verify_environment_setup'): + expected_files.update({'/workspace/setup-once': 11, '/workspace/setup-version': 6}) for name, data in [('input.txt', marker.encode()), ('binary.bin', bytes(range(256))), ('empty', b'')]: expected_files['/workspace/' + name] = upload(eid, '/workspace/' + name, data) source = client.files.create(file=('source.bin', b'source-bytes\x00\xff'), purpose='user_data') @@ -298,6 +308,8 @@ def check(name): script = 'from pathlib import Path\np=Path("/workspace/outputs");p.mkdir(exist_ok=True)\n(p/"a.bin").write_bytes(Path("/workspace/binary.bin").read_bytes());(p/"empty").write_bytes(b"")\nprint(Path("/workspace/input.txt").read_text())\n' if initial_expected: script = 'from pathlib import Path\n' + assert_initial_bytes_script(initial_expected) + script + if config.get('verify_environment_setup'): + script = native_setup_script(setup_marker) + script upload(eid, '/workspace/publish.py', script) first = prompt(session.id, 'Run exactly `python3 /workspace/publish.py`. Remember this conversation-only marker: ' + memory, 1) identity = native_id(session.id) @@ -309,6 +321,9 @@ def check(name): if initial_expected: check('template_initial_files_exact_native_bytes_and_frozen_source_deletion') upload(eid, '/workspace/initial-inline.bin', b'retained-user-change') + if config.get('verify_environment_setup'): + check('template_real_registry_packages_ordered_setup_and_native_visibility') + upload(eid, '/workspace/setup-once', b'preserved-setup-change') # The native isolation script is the same actual-tool probe used to qualify all profiles. history = config['native_history_root'] + '/e2b-isolation-canary' vm.files.write(history, 'synthetic-private-history', user='runtime') @@ -378,6 +393,11 @@ def stable(): sources.append(inline_source) assert read(vm, '/workspace/initial-inline.bin') == b'retained-user-change' check('recovery_preserves_user_changes_without_reinstalling_initial_files') + if config.get('verify_environment_setup'): + assert read(vm, '/workspace/setup-once') == b'preserved-setup-change' + inline_setup, inline_setup_marker = setup_configuration() + disabled_environment = attach_setup(client, foreign, http, disabled_environment, inline_setup) + check('recovery_does_not_repeat_completed_setup') disabled = sessions.create(agent=agent, environment=disabled_environment) if inline_expected: verify_initial_snapshot(client, http, disabled, inline_expected, inline_source) @@ -394,6 +414,9 @@ def stable(): network_script = 'import urllib.request,urllib.error,json\ntry:\n urllib.request.urlopen("https://api.moonshot.cn/v1/models",timeout=8)\nexcept urllib.error.HTTPError as e:\n assert e.code==403,e.code\nexcept (urllib.error.URLError,PermissionError,TimeoutError):pass\nelse:raise AssertionError("native network was allowed")\nopen("/workspace/network-result.json","w").write(json.dumps({"blocked":True}))\n' if inline_expected: network_script = 'from pathlib import Path\n' + assert_initial_bytes_script(inline_expected) + network_script + if config.get('verify_environment_setup'): + verify_setup_metadata(client, disabled, inline_setup) + network_script = native_setup_script(inline_setup_marker) + network_script upload(disabled.environment.id, '/workspace/network.py', network_script) prompt(disabled.id, 'Run exactly `python3 /workspace/network.py`. Do not modify it.', 1) assert json.loads(read(restricted, '/workspace/network-result.json')) == {'blocked': True} diff --git a/services/agents-api/tests/official_environment_setup.py b/services/agents-api/tests/official_environment_setup.py new file mode 100644 index 00000000..4a6e1b1e --- /dev/null +++ b/services/agents-api/tests/official_environment_setup.py @@ -0,0 +1,136 @@ +"""Shared real-deployment assertions for confidential environment initialization.""" +import json +import secrets + +from openai import NotFoundError + + +def setup_configuration(proxy=None): + marker = 'setup-private-' + secrets.token_hex(16) + "' $()" + env = {'SETUP_VALUE': marker} + if proxy: + env.update(HTTPS_PROXY=proxy, HTTP_PROXY=proxy) + first = """test -f /workspace/initial-inline.bin && mkdir -p /workspace/setup-sub && python3 - <<'SCRIPT' +import os +from pathlib import Path +import packaging +assert packaging.__version__ == '26.0' +assert os.environ['SETUP_VALUE'] +Path('/workspace/setup-sub/order').write_text('first') +SCRIPT +semver 1.2.3 > /workspace/setup-version +""" + second = "test \"$(cat order)\" = first && printf second > order && printf initialized > /workspace/setup-once" + return { + 'env': env, 'packages': {'npm': ['semver@7.7.2'], 'python': ['packaging==26.0']}, + 'setup_commands': [{'command': first}, {'command': second, 'cwd': '/workspace/setup-sub'}], + }, marker + + +def attach_setup(client, foreign, http, environment, configuration, template_id=None, network="disabled"): + endpoint = str(client.base_url).rstrip('/') + if template_id: + raw = client.beta.agents.environments.templates.with_raw_response.update( + template_id, network={'access': network}, **configuration) + resource = raw.http_response.json() + assert resource['packages'] == {**configuration['packages'], 'system': []} + headers = {'Authorization': 'Bearer ' + foreign.api_key, 'OpenAI-Beta': 'agents=v1'} + rejected = http.get(endpoint + '/agents/environments/templates/' + template_id, headers=headers) + assert rejected.status_code == 404 + metadata = [resource, client.beta.agents.environments.templates.list().to_dict()] + assert configuration['env']['SETUP_VALUE'] not in json.dumps(metadata) + assert all('env' not in value and 'setup_commands' not in value for value in [resource]) + return environment + return {**environment, 'network': {'access': network}, **configuration} + + +def verify_setup_metadata(client, session, configuration): + resource = client.beta.agents.environments.retrieve(session.environment.id).to_dict() + assert session.environment.to_dict()['packages'] == {**configuration['packages'], 'system': []} + for value in [session.to_dict(), resource]: + assert configuration['env']['SETUP_VALUE'] not in json.dumps(value) + environment = value.get('environment', value) + assert 'env' not in environment and 'setup_commands' not in environment + + +def native_setup_script(marker, network_target=None): + script = f'''from pathlib import Path +import os, subprocess, socket +import packaging +assert packaging.__version__ == '26.0' +assert os.environ['SETUP_VALUE'] == {marker!r} +assert Path('/workspace/setup-sub/order').read_text() == 'second' +assert Path('/workspace/setup-once').read_text() == 'initialized' +for cwd in ['/workspace', '/workspace/setup-sub']: + assert subprocess.check_output(['semver', '1.2.3'], cwd=cwd).strip() == b'1.2.3' + subprocess.run(['python3', '-c', 'import packaging; assert packaging.__version__ == "26.0"'], cwd=cwd, check=True) +''' + if network_target: + script += f'''try: + connection = socket.create_connection({network_target!r}, timeout=2) +except OSError: + pass +else: + connection.close() + raise AssertionError('runtime network policy was not applied after setup') +''' + return script + + +def verify_setup_failure(client, http, agent, until): + """Actual Provider initialization must fail before any native Turn starts.""" + sessions = client.beta.agents.sessions + for command in [{'command': 'echo confidential-setup-failure >&2; exit 7'}, + {'command': 'touch /workspace/unexpected', 'cwd': '/missing-setup-cwd'}]: + session = sessions.create(agent=agent, environment={ + 'type': 'openai_hosted', 'setup_commands': [command, + {'command': 'touch /workspace/unexpected-later-step'}]}) + try: + until(lambda: client.beta.agents.environments.retrieve(session.environment.id).status == 'failed', 180) + assert sessions.turns.list(session.id).data == [] + resource = sessions.retrieve(session.id).to_dict() + assert 'confidential-setup-failure' not in json.dumps(resource) + assert 'setup_commands' not in resource['environment'] + finally: + sessions.delete(session.id) + + +def verify_saved_agent_setup_identity(client, http): + sessions = client.beta.agents.sessions + agent = client.beta.agents.create(model='kimi-k3') + created = [] + secret = 'intent-canary-' + secrets.token_hex(12) + variants = [ + {'type': 'openai_hosted'}, + {'type': 'openai_hosted', 'env': {'VALUE': secret}}, + {'type': 'openai_hosted', 'env': {'VALUE': secret + '-changed'}}, + {'type': 'openai_hosted', 'setup_commands': [{'command': 'true'}]}, + {'type': 'openai_hosted', 'setup_commands': [{'command': 'false'}]}, + {'type': 'openai_hosted', 'env': {'VALUE': secret}, 'setup_commands': [{'command': 'true'}]}, + ] + try: + for index in [0, 1, 3]: + key = 'setup-intent-' + secrets.token_hex(12) + original = sessions.create(agent_id=agent.id, environment=variants[index], + extra_headers={'Idempotency-Key': key}) + created.append((original, key, index)) + client.beta.agents.delete(agent.id) + endpoint = str(client.base_url).rstrip('/') + '/agents/sessions' + for original, key, index in created: + assert sessions.create(agent_id=agent.id, environment=variants[index], + extra_headers={'Idempotency-Key': key}).id == original.id + for changed, environment in enumerate(variants): + if changed == index: + continue + response = http.post(endpoint, json={'agent_id': agent.id, 'environment': environment}, + headers={'Authorization': 'Bearer ' + client.api_key, + 'OpenAI-Beta': 'agents=v1', 'Idempotency-Key': key}) + assert response.status_code == 409, (index, changed, response.status_code) + assert secret not in response.text + finally: + for session, _, _ in created: + sessions.delete(session.id) + try: + client.beta.agents.delete(agent.id) + except NotFoundError: + pass diff --git a/services/agents-api/tests/official_environment_templates.py b/services/agents-api/tests/official_environment_templates.py index 09fd3bae..b6a98ccd 100644 --- a/services/agents-api/tests/official_environment_templates.py +++ b/services/agents-api/tests/official_environment_templates.py @@ -61,9 +61,9 @@ def verify_environment_templates(client, foreign, http): for query in ['limit=0', 'limit=101', 'limit=bad', 'order=wrong', 'limit=1&limit=2', 'unknown=1']: assert http.get(base + '?' + query, headers=headers).status_code == 400 canary = 'template-private-' + uuid.uuid4().hex - for body in [{'env': {'TOKEN': canary}}, {'setup_commands': [{'command': canary}]}, + for body in [{'env': {'PATH': canary}}, {'setup_commands': [{'command': canary, 'cwd': 'relative'}]}, {'files': [{'type': 'inline', 'path': '/workspace/a', 'data': canary}]}, - {'packages': {'python': [canary]}}, {'skills': [{'type': 'inline', 'data': canary}]}, + {'packages': {'system': [canary]}}, {'skills': [{'type': 'inline', 'data': canary}]}, {'plugins': [{'type': 'inline', 'data': canary}]}, {'capability_directories': ['/workspace']}, {'network': {'access': 'restricted', 'allowed_domains': ['example.com']}},