Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
27 changes: 23 additions & 4 deletions CONTRIBUTING.md
Original file line number Diff line number Diff line change
Expand Up @@ -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).
Expand Down
28 changes: 20 additions & 8 deletions apps/parsar-daemon/internal/agent/claudesdk/workspace.go
Original file line number Diff line number Diff line change
Expand Up @@ -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"
)
Expand All @@ -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) {
Expand All @@ -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 {
Expand Down
6 changes: 6 additions & 0 deletions apps/parsar-daemon/internal/agent/codex/preparation.go
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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()
Expand Down
2 changes: 2 additions & 0 deletions apps/parsar-daemon/internal/agent/codex/session.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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)
Expand Down
10 changes: 10 additions & 0 deletions apps/parsar-daemon/internal/agent/codex/session_plan.go
Original file line number Diff line number Diff line change
Expand Up @@ -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"
)

Expand Down Expand Up @@ -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)
}
Expand Down
68 changes: 68 additions & 0 deletions apps/parsar-daemon/internal/agent/codex/tool_environment.go
Original file line number Diff line number Diff line change
@@ -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, &notification) != 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()
}
}
75 changes: 75 additions & 0 deletions apps/parsar-daemon/internal/agent/codex/tool_environment_test.go
Original file line number Diff line number Diff line change
@@ -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")
}
}
11 changes: 11 additions & 0 deletions apps/parsar-daemon/internal/agent/mcode/workspace.go
Original file line number Diff line number Diff line change
Expand Up @@ -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"
)

Expand Down Expand Up @@ -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
Expand Down
5 changes: 5 additions & 0 deletions apps/parsar-daemon/internal/localworkspace/binding.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
35 changes: 35 additions & 0 deletions apps/parsar-daemon/internal/localworkspace/initialization.go
Original file line number Diff line number Diff line change
@@ -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
}
6 changes: 4 additions & 2 deletions contracts/agents-api/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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.

Expand Down
Loading
Loading