From 108581418d63ce86cb73a46665d98395ae289c51 Mon Sep 17 00:00:00 2001 From: SaladDay <1203511142@qq.com> Date: Thu, 5 Nov 2026 08:12:30 +0000 Subject: [PATCH 1/4] Place Codex native state under agent.StateDir --- .../agent/codex/environment_transport_test.go | 4 +- .../internal/agent/codex/mcp_config_test.go | 4 +- .../agent/codex/mcp_http_preflight_test.go | 2 +- .../internal/agent/codex/mcp_http_test.go | 4 +- apps/daemon/internal/agent/codex/options.go | 42 ++++--------------- .../agent/codex/permission_profile_test.go | 2 +- .../agent/codex/preparation_helpers_test.go | 2 +- .../agent/codex/provider_config_test.go | 23 ++++++++-- .../internal/agent/codex/recovery_test.go | 2 +- .../internal/agent/codex/session_plan.go | 2 +- .../cli/connect_environment_binding.go | 2 +- .../internal/cli/connect_environment_test.go | 2 +- 12 files changed, 40 insertions(+), 51 deletions(-) diff --git a/apps/daemon/internal/agent/codex/environment_transport_test.go b/apps/daemon/internal/agent/codex/environment_transport_test.go index 36ed0e9e3..4dc578c14 100644 --- a/apps/daemon/internal/agent/codex/environment_transport_test.go +++ b/apps/daemon/internal/agent/codex/environment_transport_test.go @@ -19,7 +19,7 @@ func TestReadOnlyPreparationRejectedBeforeNativeSetup(t *testing.T) { if len(preparationFrames(t, root)) != 0 { t.Fatal("read-only request started native child") } - if _, err := os.Stat(filepath.Join(root, "daemon", "agent-sessions")); !os.IsNotExist(err) { + if _, err := os.Stat(filepath.Join(root, "runtime", "codex")); !os.IsNotExist(err) { t.Fatal("read-only request created native state", err) } } @@ -41,7 +41,7 @@ func TestNativeTransportEnvironmentRejectedBeforeState(t *testing.T) { if len(preparationFrames(t, root)) != 0 { t.Fatal("transport override started native process") } - if _, err := os.Stat(filepath.Join(root, "daemon", "agent-sessions")); !os.IsNotExist(err) { + if _, err := os.Stat(filepath.Join(root, "runtime", "codex")); !os.IsNotExist(err) { t.Fatal("transport override created state", err) } }) diff --git a/apps/daemon/internal/agent/codex/mcp_config_test.go b/apps/daemon/internal/agent/codex/mcp_config_test.go index d05689bb0..a545c3d2f 100644 --- a/apps/daemon/internal/agent/codex/mcp_config_test.go +++ b/apps/daemon/internal/agent/codex/mcp_config_test.go @@ -73,11 +73,11 @@ func TestWriteCodexMCPConfig_EmitsStreamableHTTPURL(t *testing.T) { // TestWriteCodexMCPConfig_FreshHomeDropsStaleEntries documents the // "fresh entries only" guarantee: callers allocate a brand-new -// CODEX_HOME per prompt (BuildSessionPlan does this via allocSessionDir +// CODEX_HOME per prompt (BuildSessionPlan does this via sessionDir // + plan.Cleanup), so the previous run's mcp_servers can't leak. // // writeCodexMCPConfig itself is APPEND semantics now — the truncation -// guarantee lives in allocSessionDir's RemoveAll, not in the writer. +// guarantee lives in sessionDir's RemoveAll, not in the writer. // Test that workflow explicitly so a future refactor that breaks the // fresh-home contract fails here. func TestWriteCodexMCPConfig_FreshHomeDropsStaleEntries(t *testing.T) { diff --git a/apps/daemon/internal/agent/codex/mcp_http_preflight_test.go b/apps/daemon/internal/agent/codex/mcp_http_preflight_test.go index e88bf7fb9..285a2c080 100644 --- a/apps/daemon/internal/agent/codex/mcp_http_preflight_test.go +++ b/apps/daemon/internal/agent/codex/mcp_http_preflight_test.go @@ -174,7 +174,7 @@ func TestPublicMCPHTTPPreparationChecksBeforeNewAndResumedThread(t *testing.T) { t.Fatal(err) } assertPreparationOnly(t, root) - home, err := allocSessionDir(testStateRoot(t), req.AgentStateKey) + home, err := sessionDir(testStateRoot(t), req.AgentStateKey) if err != nil { t.Fatal(err) } diff --git a/apps/daemon/internal/agent/codex/mcp_http_test.go b/apps/daemon/internal/agent/codex/mcp_http_test.go index 81d00996f..585953866 100644 --- a/apps/daemon/internal/agent/codex/mcp_http_test.go +++ b/apps/daemon/internal/agent/codex/mcp_http_test.go @@ -31,7 +31,7 @@ func TestPublicMCPHTTPPlanOwnsConfigurationAndPreservesHistory(t *testing.T) { !slices.Contains(plan.ExtraConfig, [2]string{"shell_environment_policy.exclude", `["OAC_RUNTIME_MCP_*"]`}) { t.Fatal("native profile kept only credential isolation") } - home, err := allocSessionDir(testStateRoot(t), req.AgentStateKey) + home, err := sessionDir(testStateRoot(t), req.AgentStateKey) if err != nil { t.Fatal(err) } @@ -91,7 +91,7 @@ func TestPublicMCPHTTPRejectsInvalidProfileAndStoredCredentials(t *testing.T) { } } t.Setenv("OAC_RUNTIME_HOME", t.TempDir()) - home, err := allocSessionDir(testStateRoot(t), "credentials") + home, err := sessionDir(testStateRoot(t), "credentials") if err != nil { t.Fatal(err) } diff --git a/apps/daemon/internal/agent/codex/options.go b/apps/daemon/internal/agent/codex/options.go index 2b8d7ad4b..4167d8a43 100644 --- a/apps/daemon/internal/agent/codex/options.go +++ b/apps/daemon/internal/agent/codex/options.go @@ -8,6 +8,7 @@ import ( "path/filepath" "strings" + "github.com/MiniMax-AI/OpenAgentCore/apps/daemon/internal/agent" "github.com/MiniMax-AI/OpenAgentCore/internal/agentdaemon/proto" harnessconfiguration "github.com/MiniMax-AI/OpenAgentCore/internal/harnessconfig/codex" ) @@ -100,7 +101,7 @@ func BuildSessionPlan(stateRoot, agentStateKey string, opts map[string]any, turn plan.Model = turn.Model plan.SystemPrompt = stringOpt(opts, "system_prompt") - codexHome, err := allocSessionDir(stateRoot, agentStateKey) + codexHome, err := sessionDir(stateRoot, agentStateKey) if err != nil { return plan, err } @@ -211,25 +212,12 @@ func applyExecutionControls(plan *SessionPlan, turn proto.TurnSettings, controls // helpers // --------------------------------------------------------------------------- -func allocSessionDir(root, agentStateKey string) (string, error) { - if strings.TrimSpace(agentStateKey) == "" { - return "", fmt.Errorf("codex: agentStateKey required for a Session directory") - } - if !filepath.IsAbs(root) || filepath.Clean(root) != root { - return "", fmt.Errorf("codex: state root must be a clean absolute directory") - } - parts := strings.Split(agentStateKey, "/") - safeParts := make([]string, 0, len(parts)) - for _, part := range parts { - if safe := safePathPartCodex(part); safe != "" && safe != "." && safe != ".." { - safeParts = append(safeParts, safe) - } - } - if len(safeParts) == 0 { - return "", fmt.Errorf("codex: invalid agentStateKey %q", agentStateKey) +// sessionDir creates the Session's native state directory under root. +func sessionDir(root, agentStateKey string) (string, error) { + dir, err := agent.StateDir(root, "codex", agentStateKey) + if err != nil { + return "", err } - dirParts := append([]string{root, "daemon", "agent-sessions"}, safeParts...) - dir := filepath.Join(dirParts...) if err := os.MkdirAll(dir, 0o700); err != nil { return "", fmt.Errorf("codex: create Session directory %s: %w", dir, err) } @@ -283,22 +271,6 @@ func stringOpt(opts map[string]any, key string) string { return strings.TrimSpace(s) } -func safePathPartCodex(runID string) string { - var b strings.Builder - for _, r := range runID { - if (r >= 'a' && r <= 'z') || (r >= 'A' && r <= 'Z') || (r >= '0' && r <= '9') || r == '-' || r == '_' || r == '.' { - b.WriteRune(r) - } else { - b.WriteByte('_') - } - } - out := b.String() - if out == "" { - return "run" - } - return out -} - // strconv quotes a value as a TOML string. Done by reusing the JSON // encoder for escape rules — TOML strings accept the same standard // escape set so this is wire-safe. diff --git a/apps/daemon/internal/agent/codex/permission_profile_test.go b/apps/daemon/internal/agent/codex/permission_profile_test.go index cf9794976..5d12325dd 100644 --- a/apps/daemon/internal/agent/codex/permission_profile_test.go +++ b/apps/daemon/internal/agent/codex/permission_profile_test.go @@ -49,7 +49,7 @@ func TestSelfHostedToolEnvironmentCannotRedirectNativeHistory(t *testing.T) { values[key] = value } if values["CODEX_HOME"] == "" || values["CODEX_HOME"] == "wrong" || values["HOME"] == "wrong" || values["USERPROFILE"] == "wrong" || values["USER_VALUE"] != "ready" || - values["CODEX_SQLITE_HOME"] != filepath.Join(runtimeRoot, "daemon", "agent-sessions", "session") { + values["CODEX_SQLITE_HOME"] != filepath.Join(runtimeRoot, "runtime", "codex", "state", "session") { t.Fatal("initialization changed native state identity") } } diff --git a/apps/daemon/internal/agent/codex/preparation_helpers_test.go b/apps/daemon/internal/agent/codex/preparation_helpers_test.go index 308e39177..17046126c 100644 --- a/apps/daemon/internal/agent/codex/preparation_helpers_test.go +++ b/apps/daemon/internal/agent/codex/preparation_helpers_test.go @@ -99,7 +99,7 @@ func assertPreparationOnly(t *testing.T, root string) { func preparedCatalogs(t *testing.T, root string) []string { t.Helper() - files, err := filepath.Glob(filepath.Join(root, "daemon", "agent-sessions", "*", "model-catalog-*.json")) + files, err := filepath.Glob(filepath.Join(root, "runtime", "codex", "state", "*", "model-catalog-*.json")) if err != nil { t.Fatal(err) } diff --git a/apps/daemon/internal/agent/codex/provider_config_test.go b/apps/daemon/internal/agent/codex/provider_config_test.go index c33b4bb70..0211780fa 100644 --- a/apps/daemon/internal/agent/codex/provider_config_test.go +++ b/apps/daemon/internal/agent/codex/provider_config_test.go @@ -1,6 +1,7 @@ package codex import ( + "github.com/MiniMax-AI/OpenAgentCore/apps/daemon/internal/agent" "github.com/MiniMax-AI/OpenAgentCore/internal/agentdaemon/proto" "os" "path/filepath" @@ -260,10 +261,26 @@ func TestProviderCredentialOnlyInNativeLoopEnvironment(t *testing.T) { } } +func TestSessionsKeepNativeStateInOwnDirectories(t *testing.T) { + state := testStateRoot(t) + homes := map[string]bool{} + for _, key := range []string{"session-a", "session-b"} { + plan, err := BuildSessionPlan(state, key, nil, proto.TurnSettings{}, nil) + if err != nil { + t.Fatal(err) + } + want, _ := agent.StateDir(state, "codex", key) + if got := nativeHomeFromPlan(plan); got != want || homes[got] { + t.Fatalf("CODEX_HOME for %s = %s, want its own %s", key, got, want) + } + homes[want] = true + } +} + func TestExplicitStateRootSurvivesRuntimeReplacement(t *testing.T) { state := t.TempDir() t.Setenv("OAC_RUNTIME_HOME", t.TempDir()) - first, err := allocSessionDir(state, "stable-session") + first, err := sessionDir(state, "stable-session") if err != nil { t.Fatal(err) } @@ -272,7 +289,7 @@ func TestExplicitStateRootSurvivesRuntimeReplacement(t *testing.T) { t.Fatal(err) } t.Setenv("OAC_RUNTIME_HOME", t.TempDir()) - second, err := allocSessionDir(state, "stable-session") + second, err := sessionDir(state, "stable-session") if err != nil || second != first { t.Fatal("instance identity changed native history", err) } @@ -283,7 +300,7 @@ func TestExplicitStateRootSurvivesRuntimeReplacement(t *testing.T) { if err = os.WriteFile(unavailable, nil, 0600); err != nil { t.Fatal(err) } - if _, err = allocSessionDir(unavailable, "stable-session"); err == nil { + if _, err = sessionDir(unavailable, "stable-session"); err == nil { t.Fatal("unavailable explicit root fell back") } } diff --git a/apps/daemon/internal/agent/codex/recovery_test.go b/apps/daemon/internal/agent/codex/recovery_test.go index 1743afa2c..22655073c 100644 --- a/apps/daemon/internal/agent/codex/recovery_test.go +++ b/apps/daemon/internal/agent/codex/recovery_test.go @@ -147,7 +147,7 @@ func TestPreparedRecoveryCannotStartWithoutExistingHistory(t *testing.T) { t.Run(environment, func(t *testing.T) { req, cfg, root := preparationFixture(t) req.RequireExistingNativeSession = true - cwd, err := allocSessionDir(testStateRoot(t), req.AgentStateKey) + cwd, err := sessionDir(testStateRoot(t), req.AgentStateKey) if err != nil { t.Fatal(err) } diff --git a/apps/daemon/internal/agent/codex/session_plan.go b/apps/daemon/internal/agent/codex/session_plan.go index 5a67e794f..5dbe7a021 100644 --- a/apps/daemon/internal/agent/codex/session_plan.go +++ b/apps/daemon/internal/agent/codex/session_plan.go @@ -27,7 +27,7 @@ func prepareSessionPlan(ctx context.Context, req proto.PromptRequestPayload, cfg // Codex's SQLite databases stay on the compute's own disk, where they open faster than // on shared state storage. A replacement compute rebuilds them from the history in // CODEX_HOME; goals, whose state lives only there, are disabled. - sqliteHome, err := allocSessionDir(cfg.runtimeRoot, req.AgentStateKey) + sqliteHome, err := sessionDir(cfg.runtimeRoot, req.AgentStateKey) if err != nil { plan.Cleanup() return SessionPlan{}, nil, err diff --git a/apps/daemon/internal/cli/connect_environment_binding.go b/apps/daemon/internal/cli/connect_environment_binding.go index a9371407f..cb0a04281 100644 --- a/apps/daemon/internal/cli/connect_environment_binding.go +++ b/apps/daemon/internal/cli/connect_environment_binding.go @@ -94,7 +94,7 @@ func saveEnvironmentBinding(root string, want environmentBinding) error { } } else if errors.Is(err, os.ErrNotExist) { // Unlabelled native state cannot safely be adopted by a new enrollment. - for _, native := range []string{"runtime", "sessions", "daemon/agent-sessions"} { + for _, native := range []string{"runtime", "sessions"} { if _, e := os.Lstat(filepath.Join(root, native)); !errors.Is(e, os.ErrNotExist) { return errors.New("connect: existing Runtime history has no Environment binding") } diff --git a/apps/daemon/internal/cli/connect_environment_test.go b/apps/daemon/internal/cli/connect_environment_test.go index fe704a709..c1adc1132 100644 --- a/apps/daemon/internal/cli/connect_environment_test.go +++ b/apps/daemon/internal/cli/connect_environment_test.go @@ -160,7 +160,7 @@ func TestEnvironmentBindingPreservesIdentityAndHistory(t *testing.T) { if err := saveEnvironmentBinding(root, want); err != nil { t.Fatal(err) } - history := filepath.Join(root, "daemon", "agent-sessions", "retained") + history := filepath.Join(root, "runtime", "codex", "state", "retained") if err := os.MkdirAll(filepath.Dir(history), 0700); err != nil { t.Fatal(err) } From 929fe9fe4cc290c70f547f6479594076f20ac4f9 Mon Sep 17 00:00:00 2001 From: SaladDay <1203511142@qq.com> Date: Thu, 5 Nov 2026 08:12:30 +0000 Subject: [PATCH 2/4] Derive Claude Code config, home and scratch from the Session state key --- .../agent/claudesdk/cancellation_test.go | 8 +- .../agent/claudesdk/commands_session_test.go | 4 +- .../internal/agent/claudesdk/declaration.go | 6 +- .../agent/claudesdk/declaration_test.go | 2 +- .../claudesdk/error_classification_test.go | 4 +- .../claudesdk/execution_controls_test.go | 34 ++++---- .../internal/agent/claudesdk/executor.go | 4 - .../internal/agent/claudesdk/executor_test.go | 2 +- .../agent/claudesdk/functions_test.go | 4 +- .../agent/claudesdk/harness_config_test.go | 4 +- apps/daemon/internal/agent/claudesdk/local.go | 22 ++---- .../internal/agent/claudesdk/local_test.go | 25 +----- .../agent/claudesdk/mcp_bearer_test.go | 8 +- .../agent/claudesdk/mcp_environment_test.go | 4 +- .../internal/agent/claudesdk/mcp_test.go | 4 +- .../internal/agent/claudesdk/messages_test.go | 4 +- .../internal/agent/claudesdk/options.go | 25 +++--- .../internal/agent/claudesdk/options_test.go | 4 +- .../agent/claudesdk/preparation_test.go | 14 ++-- .../internal/agent/claudesdk/readiness.go | 10 ++- .../agent/claudesdk/readiness_test.go | 12 +-- .../agent/claudesdk/restrictions_test.go | 4 +- .../internal/agent/claudesdk/session_test.go | 12 +-- .../internal/agent/claudesdk/steering_test.go | 4 +- .../agent/claudesdk/tool_environment_test.go | 2 +- .../internal/agent/claudesdk/usage_test.go | 4 +- .../internal/agent/claudesdk/workspace.go | 65 +++++++++------ .../agent/claudesdk/workspace_launch_test.go | 4 +- .../claudesdk/workspace_structured_test.go | 6 +- .../agent/claudesdk/workspace_test.go | 79 +++++++++++++------ 30 files changed, 201 insertions(+), 183 deletions(-) diff --git a/apps/daemon/internal/agent/claudesdk/cancellation_test.go b/apps/daemon/internal/agent/claudesdk/cancellation_test.go index 1b2a675ec..8f3392b86 100644 --- a/apps/daemon/internal/agent/claudesdk/cancellation_test.go +++ b/apps/daemon/internal/agent/claudesdk/cancellation_test.go @@ -47,7 +47,7 @@ func TestCancellationWaitsForDrainAndPublishesOutcome(t *testing.T) { if err := running.(*session).SteerWithReceipt(ctx, proto.PromptSteerPayload{InputID: "later", Input: proto.TextInput("later")}, func() {}); !errors.Is(err, agent.ErrSteeringInactive) { t.Fatal("cancelled execution accepted steering", err) } - if err := os.WriteFile(filepath.Join(config.StateDir, "release"), nil, 0o600); err != nil { + if err := os.WriteFile(filepath.Join(testState(config), "release"), nil, 0o600); err != nil { t.Fatal(err) } if err := running.Cancel(ctx); err != nil { @@ -133,7 +133,7 @@ func TestCancellationDrainsIntoReadyConsumer(t *testing.T) { if event := <-out; event.Type != proto.TypeDelta { t.Fatal("missing native readiness barrier") } - if err := os.WriteFile(filepath.Join(config.StateDir, "release"), nil, 0o600); err != nil { + if err := os.WriteFile(filepath.Join(testState(config), "release"), nil, 0o600); err != nil { t.Fatal(err) } if err := running.Cancel(ctx); err != nil { @@ -159,11 +159,11 @@ func TestCancellationDrainsIntoReadyConsumer(t *testing.T) { } func cancellationConfig(root, mode string) Config { - return Config{Node: os.Args[0], Entrypoint: filepath.Join(root, "worker"), StateDir: filepath.Join(root, "state"), Env: []string{"GO_CLAUDE_SDK_HELPER=1", "SDK_HELPER_MODE=cancellation-" + mode, "GORACE=atexit_sleep_ms=0"}} + return Config{Node: os.Args[0], Entrypoint: filepath.Join(root, "worker"), StateRoot: filepath.Join(root, "state"), Env: []string{"GO_CLAUDE_SDK_HELPER=1", "SDK_HELPER_MODE=cancellation-" + mode, "GORACE=atexit_sleep_ms=0"}} } func cancellationRequest() proto.PromptRequestPayload { - return proto.PromptRequestPayload{RunID: "run", Input: proto.TextInput("hello"), AgentSessionID: "native-session", Turn: proto.TurnSettings{Model: "fake-model"}, AgentOptions: map[string]any{"system_prompt": "instructions"}} + return proto.PromptRequestPayload{AgentStateKey: testStateKey, RunID: "run", Input: proto.TextInput("hello"), AgentSessionID: "native-session", Turn: proto.TurnSettings{Model: "fake-model"}, AgentOptions: map[string]any{"system_prompt": "instructions"}} } func runCancellationHelper(request startRequest, mode string, scanner *bufio.Scanner, emit func(bridgeEvent)) { diff --git a/apps/daemon/internal/agent/claudesdk/commands_session_test.go b/apps/daemon/internal/agent/claudesdk/commands_session_test.go index 58ba1e7f4..efe9fb2ba 100644 --- a/apps/daemon/internal/agent/claudesdk/commands_session_test.go +++ b/apps/daemon/internal/agent/claudesdk/commands_session_test.go @@ -21,7 +21,7 @@ func TestWorkspaceCommandsRequirePackagedFeature(t *testing.T) { if _, err := NewExecutorFactory(config)(t.Context(), preparationRequest()); err == nil || !strings.Contains(err.Error(), "workspace preparation is unavailable") { t.Fatal("old bridge accepted command observations", err) } - if _, err := os.Stat(filepath.Join(config.StateDir, "launched")); !os.IsNotExist(err) { + if _, err := os.Stat(filepath.Join(testState(config), "launched")); !os.IsNotExist(err) { t.Fatal("old bridge started execution before rejection") } } @@ -33,7 +33,7 @@ func TestWorkspaceCommandFramesKeepStartIdentityAndObservedOutput(t *testing.T) t.Fatal(err) } defer resource.Close(context.Background()) - if _, err := os.Stat(filepath.Join(config.StateDir, "start.json")); !os.IsNotExist(err) { + if _, err := os.Stat(filepath.Join(testState(config), "start.json")); !os.IsNotExist(err) { t.Fatal("preparation submitted a command") } out := make(chan proto.Envelope, 16) diff --git a/apps/daemon/internal/agent/claudesdk/declaration.go b/apps/daemon/internal/agent/claudesdk/declaration.go index c6d497577..27f9d7909 100644 --- a/apps/daemon/internal/agent/claudesdk/declaration.go +++ b/apps/daemon/internal/agent/claudesdk/declaration.go @@ -59,13 +59,13 @@ func discoverWithCheck(parent context.Context, options agent.DiscoveryOptions, d if !filepath.IsAbs(options.StateRoot) || filepath.Clean(options.StateRoot) != options.StateRoot { return fail(fmt.Errorf("Claude SDK requires an explicit state root")) } - config = Config{Node: node, Entrypoint: entrypoint, StateDir: filepath.Join(options.StateRoot, "daemon", options.Profile, "runtime", "claude-sdk")} + config = Config{Node: node, Entrypoint: entrypoint, StateRoot: options.StateRoot, RuntimeRoot: options.RuntimeRoot} if binding := localworkspace.Current(); binding != nil { config.Node, err = filepath.EvalSymlinks(node) if err != nil { return fail(err) } - config, err = ConfigureLocal(config, options.StateRoot, options.RuntimeRoot, binding.Workspace()) + config, err = ConfigureLocal(config, binding.Workspace()) if err != nil { return fail(err) } @@ -74,7 +74,7 @@ func discoverWithCheck(parent context.Context, options agent.DiscoveryOptions, d if err != nil { return fail(err) } - local := config.Workspace != nil + local := config.Workspace != "" if local && !info.SupportsLocalRuntime() { return fail(fmt.Errorf("Claude SDK bundle does not support the local Runtime contract")) } diff --git a/apps/daemon/internal/agent/claudesdk/declaration_test.go b/apps/daemon/internal/agent/claudesdk/declaration_test.go index 1e6510eaa..c82020773 100644 --- a/apps/daemon/internal/agent/claudesdk/declaration_test.go +++ b/apps/daemon/internal/agent/claudesdk/declaration_test.go @@ -103,7 +103,7 @@ func TestRuntimeDiscoveryConfigurationAndRegistration(t *testing.T) { calls := 0 runtime := discoverWithCheck(t.Context(), options, Declaration.Info(), func(_ context.Context, c Config) (RuntimeInfo, error) { calls++ - if c.Node != node || c.Entrypoint != entrypoint || c.StateDir != filepath.Join(options.StateRoot, "daemon", "test", "runtime", "claude-sdk") || c.Env != nil { + if c.Node != node || c.Entrypoint != entrypoint || c.StateRoot != options.StateRoot || c.RuntimeRoot != options.RuntimeRoot || c.Env != nil { t.Fatalf("configuration: %+v", c) } if !ready { diff --git a/apps/daemon/internal/agent/claudesdk/error_classification_test.go b/apps/daemon/internal/agent/claudesdk/error_classification_test.go index 2b4d2fdb8..f34bfc1d7 100644 --- a/apps/daemon/internal/agent/claudesdk/error_classification_test.go +++ b/apps/daemon/internal/agent/claudesdk/error_classification_test.go @@ -20,8 +20,8 @@ func TestClassifiedBridgeFailurePreservesTerminalEvidence(t *testing.T) { t.Run(mode, func(t *testing.T) { root := t.TempDir() t.Setenv("OAC_RUNTIME_HOME", root) - config := Config{Node: os.Args[0], Entrypoint: filepath.Join(root, "worker"), StateDir: filepath.Join(root, "state"), Env: []string{"GO_CLAUDE_SDK_HELPER=1", "SDK_HELPER_MODE=classified-" + mode, "GORACE=atexit_sleep_ms=0"}} - req := proto.PromptRequestPayload{RunID: "run", Input: proto.TextInput("hello"), AgentSessionID: "native-session", Turn: proto.TurnSettings{Model: "fake-model"}, AgentOptions: map[string]any{"system_prompt": "instructions"}} + config := Config{Node: os.Args[0], Entrypoint: filepath.Join(root, "worker"), StateRoot: filepath.Join(root, "state"), Env: []string{"GO_CLAUDE_SDK_HELPER=1", "SDK_HELPER_MODE=classified-" + mode, "GORACE=atexit_sleep_ms=0"}} + req := proto.PromptRequestPayload{AgentStateKey: testStateKey, RunID: "run", Input: proto.TextInput("hello"), AgentSessionID: "native-session", Turn: proto.TurnSettings{Model: "fake-model"}, AgentOptions: map[string]any{"system_prompt": "instructions"}} ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second) defer cancel() out := make(chan proto.Envelope, 16) diff --git a/apps/daemon/internal/agent/claudesdk/execution_controls_test.go b/apps/daemon/internal/agent/claudesdk/execution_controls_test.go index 33ba1c420..09bd55db3 100644 --- a/apps/daemon/internal/agent/claudesdk/execution_controls_test.go +++ b/apps/daemon/internal/agent/claudesdk/execution_controls_test.go @@ -16,8 +16,8 @@ import ( func TestExecutionControlsPreserveNativeDefaultsAndInstructions(t *testing.T) { root := t.TempDir() t.Setenv("OAC_RUNTIME_HOME", root) - config := Config{Entrypoint: filepath.Join(root, "worker"), StateDir: filepath.Join(root, "state")} - request := proto.PromptRequestPayload{RunID: "run", Input: proto.TextInput("Original input."), AgentSessionID: "native-session", Turn: proto.TurnSettings{Model: "native-model"}, AgentOptions: map[string]any{"system_prompt": "Keep these exact instructions.\nDo not replace them."}} + config := Config{Entrypoint: filepath.Join(root, "worker"), StateRoot: filepath.Join(root, "state")} + request := proto.PromptRequestPayload{AgentStateKey: testStateKey, RunID: "run", Input: proto.TextInput("Original input."), AgentSessionID: "native-session", Turn: proto.TurnSettings{Model: "native-model"}, AgentOptions: map[string]any{"system_prompt": "Keep these exact instructions.\nDo not replace them."}} ordinary, _, err := prepareConfiguration(config, request) if err != nil { t.Fatal(err) @@ -48,7 +48,7 @@ func TestLiveWebSearchReachesTheBridgeOnEveryPlacement(t *testing.T) { live := &proto.ExecutionControls{WebSearch: &proto.WebSearchControl{Mode: "live", ContextSize: "medium"}, TextVerbosity: "medium"} root := t.TempDir() t.Setenv("OAC_RUNTIME_HOME", root) - none, _, err := prepareConfiguration(Config{Entrypoint: filepath.Join(root, "worker"), StateDir: filepath.Join(root, "state")}, proto.PromptRequestPayload{ + none, _, err := prepareConfiguration(Config{Entrypoint: filepath.Join(root, "worker"), StateRoot: filepath.Join(root, "state")}, proto.PromptRequestPayload{AgentStateKey: testStateKey, RunID: "run", Input: proto.TextInput("Search."), ExecutionControls: live, Turn: proto.TurnSettings{Model: "native-model"}, AgentOptions: map[string]any{}}) if err != nil || none.WebSearch != "live" { t.Fatal("live search on environment none did not reach the bridge", err) @@ -76,13 +76,13 @@ func TestExecutionControlsRejectUnsupportedProfilesBeforeLaunch(t *testing.T) { t.Run(name, func(t *testing.T) { root := t.TempDir() t.Setenv("OAC_RUNTIME_HOME", root) - config := Config{Node: "must-not-run", Entrypoint: filepath.Join(root, "worker"), StateDir: filepath.Join(root, "state")} - request.RunID, request.Input = "run", proto.TextInput("Original input.") + config := Config{Node: "must-not-run", Entrypoint: filepath.Join(root, "worker"), StateRoot: filepath.Join(root, "state")} + request.RunID, request.Input, request.AgentStateKey = "run", proto.TextInput("Original input."), testStateKey _, err := startSingleTurn(t.Context(), config, request, make(chan proto.Envelope, 1)) if err == nil || !strings.Contains(err.Error(), "not supported by this Harness") { t.Fatal("unsupported controls did not fail at admission", err) } - if _, err := os.Stat(config.StateDir); !os.IsNotExist(err) { + if _, err := os.Stat(testState(config)); !os.IsNotExist(err) { t.Fatal("unsupported controls reached native setup", err) } }) @@ -92,9 +92,9 @@ func TestExecutionControlsRejectUnsupportedProfilesBeforeLaunch(t *testing.T) { func TestReasoningEffortReachesNativeOptions(t *testing.T) { root := t.TempDir() t.Setenv("OAC_RUNTIME_HOME", root) - config := Config{Entrypoint: filepath.Join(root, "worker"), StateDir: filepath.Join(root, "state")} + config := Config{Entrypoint: filepath.Join(root, "worker"), StateRoot: filepath.Join(root, "state")} for _, effort := range []string{"low", "medium", "high", "xhigh", "max"} { - request := proto.PromptRequestPayload{RunID: "run", Input: proto.TextInput("hello"), ExecutionControls: &proto.ExecutionControls{TextVerbosity: "medium"}, Turn: proto.TurnSettings{Model: "native-model", ReasoningEffort: effort}, AgentOptions: map[string]any{"harness_config": map[string]any{"thinking": map[string]any{"type": "adaptive"}}}} + request := proto.PromptRequestPayload{AgentStateKey: testStateKey, RunID: "run", Input: proto.TextInput("hello"), ExecutionControls: &proto.ExecutionControls{TextVerbosity: "medium"}, Turn: proto.TurnSettings{Model: "native-model", ReasoningEffort: effort}, AgentOptions: map[string]any{"harness_config": map[string]any{"thinking": map[string]any{"type": "adaptive"}}}} start, _, err := prepareConfiguration(config, request) if err != nil { t.Fatal(effort, err) @@ -108,14 +108,14 @@ func TestReasoningEffortReachesNativeOptions(t *testing.T) { func TestMCPWithoutEnvironmentNoneRejectedBeforeSetup(t *testing.T) { root := t.TempDir() t.Setenv("OAC_RUNTIME_HOME", root) - config := Config{Node: "must-not-run", Entrypoint: filepath.Join(root, "worker"), StateDir: filepath.Join(root, "state")} + config := Config{Node: "must-not-run", Entrypoint: filepath.Join(root, "worker"), StateRoot: filepath.Join(root, "state")} servers := []proto.MCPHTTPServer{{ConnectionOrigin: "service", ServerLabel: "remote", ServerURL: "https://example.test/mcp"}} - request := proto.PromptRequestPayload{RunID: "run", Input: proto.TextInput("Input"), MCPHTTPServers: &servers} + request := proto.PromptRequestPayload{AgentStateKey: testStateKey, RunID: "run", Input: proto.TextInput("Input"), MCPHTTPServers: &servers} _, err := startSingleTurn(t.Context(), config, request, make(chan proto.Envelope, 1)) if err == nil || !strings.Contains(err.Error(), "service-origin MCP requires a service execution host") { t.Fatal("MCP reached an unsupported environment", err) } - if _, err := os.Stat(config.StateDir); !os.IsNotExist(err) { + if _, err := os.Stat(testState(config)); !os.IsNotExist(err) { t.Fatal("MCP reached native setup", err) } } @@ -123,9 +123,9 @@ func TestMCPWithoutEnvironmentNoneRejectedBeforeSetup(t *testing.T) { func TestStructuredOutputConfigurationReachesNativeUnchanged(t *testing.T) { root := t.TempDir() t.Setenv("OAC_RUNTIME_HOME", root) - config := Config{Entrypoint: filepath.Join(root, "worker"), StateDir: filepath.Join(root, "state")} + config := Config{Entrypoint: filepath.Join(root, "worker"), StateRoot: filepath.Join(root, "state")} schema := json.RawMessage(`{"type":"object","properties":{"n":{"const":9007199254740992}}}`) - request := proto.PromptRequestPayload{RunID: "run", Input: proto.TextInput("Original input."), DisableSubagents: true, Turn: proto.TurnSettings{Model: "model"}, AgentOptions: map[string]any{"system_prompt": "Original instructions."}, ExecutionControls: &proto.ExecutionControls{TextVerbosity: "medium", OutputFormat: &proto.OutputFormat{Type: "json_schema", Schema: schema}}} + request := proto.PromptRequestPayload{AgentStateKey: testStateKey, RunID: "run", Input: proto.TextInput("Original input."), DisableSubagents: true, Turn: proto.TurnSettings{Model: "model"}, AgentOptions: map[string]any{"system_prompt": "Original instructions."}, ExecutionControls: &proto.ExecutionControls{TextVerbosity: "medium", OutputFormat: &proto.OutputFormat{Type: "json_schema", Schema: schema}}} start, _, err := prepareConfiguration(config, request) if err != nil { t.Fatal(err) @@ -148,8 +148,8 @@ func TestStructuredOutputConfigurationReachesNativeUnchanged(t *testing.T) { func TestToolDiscoveryPreservesFrozenFunctionsBesideOtherProfiles(t *testing.T) { root := t.TempDir() t.Setenv("OAC_RUNTIME_HOME", root) - config := Config{Entrypoint: filepath.Join(root, "worker"), StateDir: filepath.Join(root, "state")} - request := proto.PromptRequestPayload{RunID: "run", Input: proto.TextInput("Original input."), DisableSubagents: true, ToolSearch: true, Turn: proto.TurnSettings{Model: "model"}, AgentOptions: map[string]any{}, FunctionTools: []proto.FunctionTool{ + config := Config{Entrypoint: filepath.Join(root, "worker"), StateRoot: filepath.Join(root, "state")} + request := proto.PromptRequestPayload{AgentStateKey: testStateKey, RunID: "run", Input: proto.TextInput("Original input."), DisableSubagents: true, ToolSearch: true, Turn: proto.TurnSettings{Model: "model"}, AgentOptions: map[string]any{}, FunctionTools: []proto.FunctionTool{ {Name: "lookup", Description: "Lookup", Parameters: json.RawMessage(`{"type":"object","properties":{"ticket":{"const":"original"}}}`), DeferLoading: true}, {Name: "clock", Description: "Clock", Parameters: json.RawMessage(`{"type":"object"}`)}, }} @@ -171,8 +171,8 @@ func TestToolDiscoveryPreservesFrozenFunctionsBesideOtherProfiles(t *testing.T) func TestReasoningSummaryReachesTheBridgeOnlyWhenRequested(t *testing.T) { root := t.TempDir() t.Setenv("OAC_RUNTIME_HOME", root) - config := Config{Entrypoint: filepath.Join(root, "worker"), StateDir: filepath.Join(root, "state")} - request := proto.PromptRequestPayload{RunID: "run", Input: proto.TextInput("Input."), Turn: proto.TurnSettings{Model: "native-model"}, AgentOptions: map[string]any{}} + config := Config{Entrypoint: filepath.Join(root, "worker"), StateRoot: filepath.Join(root, "state")} + request := proto.PromptRequestPayload{AgentStateKey: testStateKey, RunID: "run", Input: proto.TextInput("Input."), Turn: proto.TurnSettings{Model: "native-model"}, AgentOptions: map[string]any{}} for _, summary := range []string{"", "auto"} { request.ExecutionControls = &proto.ExecutionControls{TextVerbosity: "medium", ReasoningSummary: summary} start, _, err := prepareConfiguration(config, request) diff --git a/apps/daemon/internal/agent/claudesdk/executor.go b/apps/daemon/internal/agent/claudesdk/executor.go index 428816001..9d9526a04 100644 --- a/apps/daemon/internal/agent/claudesdk/executor.go +++ b/apps/daemon/internal/agent/claudesdk/executor.go @@ -28,10 +28,6 @@ type executor struct { func NewExecutorFactory(config Config) agent.ExecutorFactory { config.Env = slices.Clone(config.Env) - if config.Workspace != nil { - workspace := *config.Workspace - config.Workspace = &workspace - } checked := &runtimeCheckCache{} return func(ctx context.Context, req proto.PromptRequestPayload) (agent.Executor, error) { if ctx == nil { diff --git a/apps/daemon/internal/agent/claudesdk/executor_test.go b/apps/daemon/internal/agent/claudesdk/executor_test.go index 121f5a83d..128591741 100644 --- a/apps/daemon/internal/agent/claudesdk/executor_test.go +++ b/apps/daemon/internal/agent/claudesdk/executor_test.go @@ -26,7 +26,7 @@ func persistentConfig(t *testing.T, mode string) (Config, proto.PromptRequestPay if err := os.WriteFile(entry, []byte("version-one"), 0600); err != nil { t.Fatal(err) } - return Config{Node: os.Args[0], Entrypoint: entry, StateDir: filepath.Join(root, "state"), Env: []string{"GO_CLAUDE_EXECUTOR_HELPER=1", "SDK_EXECUTOR_DIR=" + root, "SDK_EXECUTOR_MODE=" + mode, "GORACE=atexit_sleep_ms=0"}}, proto.PromptRequestPayload{DisableExecutionEnvironment: true, Turn: proto.TurnSettings{Model: "fixture"}, AgentOptions: map[string]any{}} + return Config{Node: os.Args[0], Entrypoint: entry, StateRoot: filepath.Join(root, "state"), Env: []string{"GO_CLAUDE_EXECUTOR_HELPER=1", "SDK_EXECUTOR_DIR=" + root, "SDK_EXECUTOR_MODE=" + mode, "GORACE=atexit_sleep_ms=0"}}, proto.PromptRequestPayload{AgentStateKey: testStateKey, DisableExecutionEnvironment: true, Turn: proto.TurnSettings{Model: "fixture"}, AgentOptions: map[string]any{}} } func runPersistentExecutorHelper() { diff --git a/apps/daemon/internal/agent/claudesdk/functions_test.go b/apps/daemon/internal/agent/claudesdk/functions_test.go index 3d4d5e5d5..15f203ea5 100644 --- a/apps/daemon/internal/agent/claudesdk/functions_test.go +++ b/apps/daemon/internal/agent/claudesdk/functions_test.go @@ -21,8 +21,8 @@ func TestFunctionFactoryNativeReceipts(t *testing.T) { t.Run(mode, func(t *testing.T) { root := t.TempDir() t.Setenv("OAC_RUNTIME_HOME", root) - config := Config{Node: os.Args[0], Entrypoint: filepath.Join(root, "worker"), StateDir: filepath.Join(root, "state"), Env: []string{"GO_CLAUDE_SDK_HELPER=1", "SDK_HELPER_MODE=" + mode, "GORACE=atexit_sleep_ms=0"}} - request := proto.PromptRequestPayload{RunID: "run", Input: proto.TextInput("hello"), AgentSessionID: "native-session", Turn: proto.TurnSettings{Model: "fake-model"}, AgentOptions: map[string]any{"system_prompt": "instructions"}, FunctionTools: []proto.FunctionTool{{Name: "lookup", Description: "Lookup.", Parameters: json.RawMessage(`{"type":"object","properties":{"ids":{"type":"array","items":{"type":"string"}}}}`)}}} + config := Config{Node: os.Args[0], Entrypoint: filepath.Join(root, "worker"), StateRoot: filepath.Join(root, "state"), Env: []string{"GO_CLAUDE_SDK_HELPER=1", "SDK_HELPER_MODE=" + mode, "GORACE=atexit_sleep_ms=0"}} + request := proto.PromptRequestPayload{AgentStateKey: testStateKey, RunID: "run", Input: proto.TextInput("hello"), AgentSessionID: "native-session", Turn: proto.TurnSettings{Model: "fake-model"}, AgentOptions: map[string]any{"system_prompt": "instructions"}, FunctionTools: []proto.FunctionTool{{Name: "lookup", Description: "Lookup.", Parameters: json.RawMessage(`{"type":"object","properties":{"ids":{"type":"array","items":{"type":"string"}}}}`)}}} ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second) defer cancel() out := make(chan proto.Envelope, 16) diff --git a/apps/daemon/internal/agent/claudesdk/harness_config_test.go b/apps/daemon/internal/agent/claudesdk/harness_config_test.go index 835a6b891..f152b47ba 100644 --- a/apps/daemon/internal/agent/claudesdk/harness_config_test.go +++ b/apps/daemon/internal/agent/claudesdk/harness_config_test.go @@ -11,9 +11,9 @@ import ( func TestHarnessConfigReachesBridgeAndOwnsSnapshot(t *testing.T) { root := t.TempDir() t.Setenv("OAC_RUNTIME_HOME", root) - config := Config{Entrypoint: filepath.Join(root, "main.js"), StateDir: filepath.Join(root, "state")} + config := Config{Entrypoint: filepath.Join(root, "main.js"), StateRoot: filepath.Join(root, "state")} native := map[string]any{"thinking": map[string]any{"type": "enabled", "budgetTokens": 1024}} - req := proto.PromptRequestPayload{Turn: proto.TurnSettings{Model: "fixture", ReasoningEffort: "high"}, AgentOptions: map[string]any{"harness_config": native}, ExecutionControls: &proto.ExecutionControls{TextVerbosity: "medium"}} + req := proto.PromptRequestPayload{AgentStateKey: testStateKey, Turn: proto.TurnSettings{Model: "fixture", ReasoningEffort: "high"}, AgentOptions: map[string]any{"harness_config": native}, ExecutionControls: &proto.ExecutionControls{TextVerbosity: "medium"}} start, _, err := prepareConfiguration(config, req) if err != nil { t.Fatal(err) diff --git a/apps/daemon/internal/agent/claudesdk/local.go b/apps/daemon/internal/agent/claudesdk/local.go index d6a465cda..76febac63 100644 --- a/apps/daemon/internal/agent/claudesdk/local.go +++ b/apps/daemon/internal/agent/claudesdk/local.go @@ -3,27 +3,16 @@ package claudesdk import ( "fmt" "os" - "path/filepath" "strings" ) // ConfigureLocal selects the qualified, dedicated Runtime layout. The shared // localworkspace binding still authorizes every request against its Session. -func ConfigureLocal(config Config, stateRoot, root, workspace string) (Config, error) { - if !filepath.IsAbs(stateRoot) || filepath.Clean(stateRoot) != stateRoot { - return Config{}, fmt.Errorf("claudesdk: state root must be a clean absolute directory") - } - config.StateDir = filepath.Join(stateRoot, "runtime", "claude-sdk", "history") - config.Workspace = &WorkspaceConfig{ - Directory: workspace, - HomeDir: filepath.Join(root, "runtime", "claude-sdk", "home"), - ScratchDir: filepath.Join(root, "runtime", "claude-sdk", "scratch"), - } - for _, dir := range []string{config.StateDir, config.Workspace.HomeDir, config.Workspace.ScratchDir} { - if err := os.MkdirAll(dir, 0700); err != nil { - return Config{}, err - } +func ConfigureLocal(config Config, workspace string) (Config, error) { + if !canonicalWorkspaceDir(workspace) { + return Config{}, fmt.Errorf("claudesdk: invalid trusted workspace configuration") } + config.Workspace = workspace config.Env = nil for _, entry := range os.Environ() { name, _, _ := strings.Cut(entry, "=") @@ -31,6 +20,5 @@ func ConfigureLocal(config Config, stateRoot, root, workspace string) (Config, e config.Env = append(config.Env, entry) } } - _, _, err := workspaceEnvironment(config) - return config, err + return config, nil } diff --git a/apps/daemon/internal/agent/claudesdk/local_test.go b/apps/daemon/internal/agent/claudesdk/local_test.go index 09ff0d219..ef94c0097 100644 --- a/apps/daemon/internal/agent/claudesdk/local_test.go +++ b/apps/daemon/internal/agent/claudesdk/local_test.go @@ -2,7 +2,6 @@ package claudesdk import ( "encoding/json" - "path/filepath" "slices" "strings" "testing" @@ -13,7 +12,7 @@ import ( func TestLocalWorkspaceBindingAndRequiredHistory(t *testing.T) { config := workspaceFixture(t) req := workspaceRequest() - req.LocalEnvironment = &proto.LocalEnvironment{ID: "environment", WorkspaceRoot: config.Workspace.Directory} + req.LocalEnvironment = &proto.LocalEnvironment{ID: "environment", WorkspaceRoot: config.Workspace} req.RequireExistingNativeSession = true start, _, err := prepareConfiguration(config, req) if err != nil || !start.RequireHistory { @@ -47,25 +46,3 @@ func TestWorkspaceProviderCredentialsReplaceAmbientSelection(t *testing.T) { } } } - -func TestLocalHistorySurvivesChangingInstanceRoot(t *testing.T) { - config := workspaceFixture(t) - retained, instance := t.TempDir(), t.TempDir() - first, err := ConfigureLocal(config, retained, instance, config.Workspace.Directory) - if err != nil { - t.Fatal(err) - } - second, err := ConfigureLocal(config, retained, t.TempDir(), config.Workspace.Directory) - if err != nil { - t.Fatal(err) - } - if first.StateDir != second.StateDir || first.StateDir != filepath.Join(retained, "runtime", "claude-sdk", "history") { - t.Fatal("history moved with compute") - } - if first.Workspace.HomeDir == second.Workspace.HomeDir || first.Workspace.ScratchDir == second.Workspace.ScratchDir { - t.Fatal("instance HOME or scratch retained across compute") - } - if _, err := ConfigureLocal(config, "relative", instance, config.Workspace.Directory); err == nil { - t.Fatal("invalid state root accepted") - } -} diff --git a/apps/daemon/internal/agent/claudesdk/mcp_bearer_test.go b/apps/daemon/internal/agent/claudesdk/mcp_bearer_test.go index 34a7d749a..f428d6585 100644 --- a/apps/daemon/internal/agent/claudesdk/mcp_bearer_test.go +++ b/apps/daemon/internal/agent/claudesdk/mcp_bearer_test.go @@ -14,7 +14,7 @@ import ( func TestMCPBearerUsesFreshOwnedEnvironmentReferences(t *testing.T) { root := t.TempDir() t.Setenv("OAC_RUNTIME_HOME", root) - config := Config{Entrypoint: filepath.Join(root, "main.js"), StateDir: filepath.Join(root, "state")} + config := Config{Entrypoint: filepath.Join(root, "main.js"), StateRoot: filepath.Join(root, "state")} tokens := []string{"first.synthetic+/==", "second-synthetic_token~"} tools := []string{"echo.v1"} servers := []proto.MCPHTTPServer{ @@ -22,7 +22,7 @@ func TestMCPBearerUsesFreshOwnedEnvironmentReferences(t *testing.T) { {ConnectionOrigin: "service", ServerLabel: "second", ServerURL: "https://second.example/mcp", BearerToken: &tokens[1]}, {ConnectionOrigin: "service", ServerLabel: "anonymous", ServerURL: "http://anonymous.example/mcp"}, } - req := proto.PromptRequestPayload{RunID: "run", Input: proto.TextInput("hello"), DisableExecutionEnvironment: true, MCPHTTPServers: &servers, Turn: proto.TurnSettings{Model: "fixture"}, AgentOptions: map[string]any{}} + req := proto.PromptRequestPayload{AgentStateKey: testStateKey, RunID: "run", Input: proto.TextInput("hello"), DisableExecutionEnvironment: true, MCPHTTPServers: &servers, Turn: proto.TurnSettings{Model: "fixture"}, AgentOptions: map[string]any{}} seen := map[string]bool{} for range 2 { start, env, err := prepareConfiguration(config, req) @@ -64,9 +64,9 @@ func TestMCPBearerRejectsInvalidCredentialBeforeStateCreation(t *testing.T) { for _, token := range []string{"", "=", " space", "space ", "has space", "line\r\ninjection", "nul\x00byte", "opaque中文", "middle=padding", "punctuation:invalid"} { root := t.TempDir() t.Setenv("OAC_RUNTIME_HOME", root) - config := Config{Entrypoint: filepath.Join(root, "main.js"), StateDir: filepath.Join(root, "state")} + config := Config{Entrypoint: filepath.Join(root, "main.js"), StateRoot: filepath.Join(root, "state")} servers := []proto.MCPHTTPServer{{ConnectionOrigin: "service", ServerLabel: "fixture", ServerURL: "https://example.invalid/mcp", BearerToken: &token}} - req := proto.PromptRequestPayload{RunID: "run", Input: proto.TextInput("hello"), DisableExecutionEnvironment: true, MCPHTTPServers: &servers, Turn: proto.TurnSettings{Model: "fixture"}, AgentOptions: map[string]any{}} + req := proto.PromptRequestPayload{AgentStateKey: testStateKey, RunID: "run", Input: proto.TextInput("hello"), DisableExecutionEnvironment: true, MCPHTTPServers: &servers, Turn: proto.TurnSettings{Model: "fixture"}, AgentOptions: map[string]any{}} if _, _, err := prepareConfiguration(config, req); err == nil || err.Error() != "claudesdk: unsupported HTTPS MCP bearer credential" { t.Fatal("invalid bearer accepted or unsafe error returned") } diff --git a/apps/daemon/internal/agent/claudesdk/mcp_environment_test.go b/apps/daemon/internal/agent/claudesdk/mcp_environment_test.go index d614578ea..cb67554ed 100644 --- a/apps/daemon/internal/agent/claudesdk/mcp_environment_test.go +++ b/apps/daemon/internal/agent/claudesdk/mcp_environment_test.go @@ -15,7 +15,7 @@ func TestEnvironmentMCPUsesInstalledLauncherAndSelectedCredential(t *testing.T) req := workspaceRequest() token := "selected-user-token" t.Setenv("MCP_TOKEN", "unselected-native-token") - req.LocalEnvironment = &proto.LocalEnvironment{CapabilityRoot: "/private/runtime/capabilities", WorkspaceRoot: config.Workspace.Directory, MCP: []proto.EnvironmentMCP{ + req.LocalEnvironment = &proto.LocalEnvironment{CapabilityRoot: "/private/runtime/capabilities", WorkspaceRoot: config.Workspace, MCP: []proto.EnvironmentMCP{ {InstallationRoot: "/private/runtime/capabilities", PackageRoot: "plugins/local", Server: proto.PluginMCPServer{Name: "local", Type: "stdio", Command: "untrusted-package-command", Args: []string{"package-argument"}, EnvVars: []string{"MCP_TOKEN"}}}, {InstallationRoot: "/private/runtime/capabilities", PackageRoot: "plugins/remote", Server: proto.PluginMCPServer{Name: "remote", Type: "http", URL: "https://example.invalid/mcp", BearerTokenEnvVar: "MCP_TOKEN"}, BearerToken: &token}, }} @@ -94,7 +94,7 @@ func TestEnvironmentMCPObservationsUseInstalledDeclarations(t *testing.T) { func TestPublicMCPStdioStartsInTheWorkspaceDirectory(t *testing.T) { servers := []proto.MCPHTTPServer{{ConnectionOrigin: "environment", ServerLabel: "local", Stdio: &proto.MCPStdio{Command: "run", Cwd: "/workspace", EnvVars: []string{"TOKEN"}}}} - req := proto.PromptRequestPayload{LocalEnvironment: &proto.LocalEnvironment{WorkspaceDirectory: "/workspace", WorkspaceRoot: "/environment/workspace"}, MCPHTTPServers: &servers} + req := proto.PromptRequestPayload{AgentStateKey: testStateKey, LocalEnvironment: &proto.LocalEnvironment{WorkspaceDirectory: "/workspace", WorkspaceRoot: "/environment/workspace"}, MCPHTTPServers: &servers} projected, _, err := prepareRuntimeMCP(req) raw, _ := json.Marshal(projected) if err != nil || string(raw) != `[{"server_label":"local","allowed_tools":null,"command":"run","args":[],"declared":true}]` { diff --git a/apps/daemon/internal/agent/claudesdk/mcp_test.go b/apps/daemon/internal/agent/claudesdk/mcp_test.go index 49c1c9eba..9a5d90be6 100644 --- a/apps/daemon/internal/agent/claudesdk/mcp_test.go +++ b/apps/daemon/internal/agent/claudesdk/mcp_test.go @@ -14,9 +14,9 @@ func TestHTTPMCPDeclaration(t *testing.T) { t.Run(mode, func(t *testing.T) { root := t.TempDir() t.Setenv("OAC_RUNTIME_HOME", root) - config := Config{Entrypoint: filepath.Join(root, "main.js"), StateDir: filepath.Join(root, "state")} + config := Config{Entrypoint: filepath.Join(root, "main.js"), StateRoot: filepath.Join(root, "state")} servers := []proto.MCPHTTPServer{{ConnectionOrigin: "service", ServerLabel: "fixture", ServerURL: "https://example.invalid/mcp"}} - req := proto.PromptRequestPayload{RunID: "run", Input: proto.TextInput("hello"), DisableExecutionEnvironment: true, MCPHTTPServers: &servers, Turn: proto.TurnSettings{Model: "fixture"}, AgentOptions: map[string]any{}} + req := proto.PromptRequestPayload{AgentStateKey: testStateKey, RunID: "run", Input: proto.TextInput("hello"), DisableExecutionEnvironment: true, MCPHTTPServers: &servers, Turn: proto.TurnSettings{Model: "fixture"}, AgentOptions: map[string]any{}} tools := []string{"echo"} switch mode { case "selected": diff --git a/apps/daemon/internal/agent/claudesdk/messages_test.go b/apps/daemon/internal/agent/claudesdk/messages_test.go index 3c41be51d..e185a96dc 100644 --- a/apps/daemon/internal/agent/claudesdk/messages_test.go +++ b/apps/daemon/internal/agent/claudesdk/messages_test.go @@ -19,8 +19,8 @@ func TestMessageObservations(t *testing.T) { t.Run(mode, func(t *testing.T) { root := t.TempDir() t.Setenv("OAC_RUNTIME_HOME", root) - config := Config{Node: os.Args[0], Entrypoint: filepath.Join(root, "worker"), StateDir: filepath.Join(root, "state"), Env: []string{"GO_CLAUDE_SDK_HELPER=1", "SDK_HELPER_MODE=" + mode, "GORACE=atexit_sleep_ms=0"}} - request := proto.PromptRequestPayload{RunID: "run", Input: proto.TextInput("hello"), AgentSessionID: "native-session", Turn: proto.TurnSettings{Model: "fake-model"}, AgentOptions: map[string]any{"system_prompt": "instructions"}} + config := Config{Node: os.Args[0], Entrypoint: filepath.Join(root, "worker"), StateRoot: filepath.Join(root, "state"), Env: []string{"GO_CLAUDE_SDK_HELPER=1", "SDK_HELPER_MODE=" + mode, "GORACE=atexit_sleep_ms=0"}} + request := proto.PromptRequestPayload{AgentStateKey: testStateKey, RunID: "run", Input: proto.TextInput("hello"), AgentSessionID: "native-session", Turn: proto.TurnSettings{Model: "fake-model"}, AgentOptions: map[string]any{"system_prompt": "instructions"}} ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second) defer cancel() out := make(chan proto.Envelope, 16) diff --git a/apps/daemon/internal/agent/claudesdk/options.go b/apps/daemon/internal/agent/claudesdk/options.go index b9c4a981e..091ab368c 100644 --- a/apps/daemon/internal/agent/claudesdk/options.go +++ b/apps/daemon/internal/agent/claudesdk/options.go @@ -6,6 +6,7 @@ import ( "path/filepath" "strings" + "github.com/MiniMax-AI/OpenAgentCore/apps/daemon/internal/agent" "github.com/MiniMax-AI/OpenAgentCore/internal/agentdaemon/proto" harnessconfiguration "github.com/MiniMax-AI/OpenAgentCore/internal/harnessconfig/claudesdk" ) @@ -13,9 +14,12 @@ import ( type Config struct { Node string Entrypoint string - StateDir string - Env []string - Workspace *WorkspaceConfig + // StateRoot and RuntimeRoot hold each Session's native state, derived from its state key. + StateRoot string + RuntimeRoot string + Env []string + // Workspace is the trusted workspace directory; empty means environment:none. + Workspace string } type subagentOptions struct { @@ -123,29 +127,30 @@ func prepareConfiguration(config Config, req proto.PromptRequestPayload) (startR return fail("SDK entrypoint must be absolute") } config.Env = withProvider(config.Env, provider) - if config.Workspace != nil { + if config.Workspace != "" { profile, env, err := prepareWorkspace(config, req) if err != nil { return startRequest{}, nil, err } start.Workspace = profile - start.Cwd = config.Workspace.Directory + start.Cwd = config.Workspace return start, env, nil } if req.LocalEnvironment != nil || req.RequireExistingNativeSession { return fail("local execution and history recovery require a dedicated workspace") } - if !filepath.IsAbs(config.StateDir) || filepath.Clean(config.StateDir) != config.StateDir || filepath.Dir(config.StateDir) == config.StateDir { - return fail("SDK state must be an explicit private absolute directory") + state, err := agent.StateDir(config.StateRoot, harnessconfiguration.Declaration.Kind, req.AgentStateKey) + if err != nil { + return fail(err.Error()) } - start.Cwd = filepath.Join(config.StateDir, "work") - for _, dir := range []string{config.StateDir, filepath.Join(config.StateDir, "tmp"), start.Cwd} { + start.Cwd = filepath.Join(state, "work") + for _, dir := range []string{state, filepath.Join(state, "tmp"), start.Cwd} { if err := os.MkdirAll(dir, 0o700); err != nil { return startRequest{}, nil, err } } env := withProvider(append(append([]string{}, os.Environ()...), config.Env...), provider) - env = append(env, "CLAUDE_CONFIG_DIR="+config.StateDir, "TMPDIR="+filepath.Join(config.StateDir, "tmp")) + env = append(env, "CLAUDE_CONFIG_DIR="+state, "TMPDIR="+filepath.Join(state, "tmp")) projectedMCP, mcpEnv, err := prepareRuntimeMCP(req) if err != nil { return startRequest{}, nil, err diff --git a/apps/daemon/internal/agent/claudesdk/options_test.go b/apps/daemon/internal/agent/claudesdk/options_test.go index 9e79b3318..74fc3f86b 100644 --- a/apps/daemon/internal/agent/claudesdk/options_test.go +++ b/apps/daemon/internal/agent/claudesdk/options_test.go @@ -23,8 +23,8 @@ func TestNullableSystemPrompt(t *testing.T) { t.Run(tc.name, func(t *testing.T) { root := t.TempDir() t.Setenv("OAC_RUNTIME_HOME", root) - config := Config{Entrypoint: filepath.Join(root, "main.js"), StateDir: filepath.Join(root, "state")} - start, _, err := prepareConfiguration(config, proto.PromptRequestPayload{RunID: "run", Input: proto.TextInput("hello"), Turn: proto.TurnSettings{Model: "test-model"}, AgentOptions: tc.options}) + config := Config{Entrypoint: filepath.Join(root, "main.js"), StateRoot: filepath.Join(root, "state")} + start, _, err := prepareConfiguration(config, proto.PromptRequestPayload{AgentStateKey: testStateKey, RunID: "run", Input: proto.TextInput("hello"), Turn: proto.TurnSettings{Model: "test-model"}, AgentOptions: tc.options}) if (err != nil) != tc.reject || (!tc.reject && start.SystemPrompt != tc.want) { t.Fatalf("system prompt %q, error %v", start.SystemPrompt, err) } diff --git a/apps/daemon/internal/agent/claudesdk/preparation_test.go b/apps/daemon/internal/agent/claudesdk/preparation_test.go index 24c369d74..6b681a568 100644 --- a/apps/daemon/internal/agent/claudesdk/preparation_test.go +++ b/apps/daemon/internal/agent/claudesdk/preparation_test.go @@ -31,7 +31,7 @@ func TestPreparationWaitsForReceiptAndRetainsConfiguration(t *testing.T) { } result <- e }() - raw := waitPreparationFile(t, filepath.Join(config.StateDir, "prepare.json")) + raw := waitPreparationFile(t, filepath.Join(testState(config), "prepare.json")) select { case <-result: t.Fatal("preparation returned before its native receipt") @@ -39,7 +39,7 @@ func TestPreparationWaitsForReceiptAndRetainsConfiguration(t *testing.T) { t.Fatal(err) default: } - if err := os.WriteFile(filepath.Join(config.StateDir, "ready"), nil, 0o600); err != nil { + if err := os.WriteFile(filepath.Join(testState(config), "ready"), nil, 0o600); err != nil { t.Fatal(err) } var resource agent.Executor @@ -53,7 +53,7 @@ func TestPreparationWaitsForReceiptAndRetainsConfiguration(t *testing.T) { e := resource.(*executor) defer e.Close(context.Background()) pid := e.base.process.Cmd.Process.Pid - if _, err := os.Stat(filepath.Join(config.StateDir, "start.json")); !os.IsNotExist(err) { + if _, err := os.Stat(filepath.Join(testState(config), "start.json")); !os.IsNotExist(err) { t.Fatal("preparation submitted input") } var frozen startRequest @@ -64,7 +64,7 @@ func TestPreparationWaitsForReceiptAndRetainsConfiguration(t *testing.T) { t.Fatal("configuration-only request was not retained") } config.Env[0] = "ANTHROPIC_AUTH_TOKEN=changed" - config.Workspace.Directory = "/changed" + config.Workspace = "/changed" req.Turn.Model = "changed" req.AgentSessionID = "changed" out := make(chan proto.Envelope, 16) @@ -75,7 +75,7 @@ func TestPreparationWaitsForReceiptAndRetainsConfiguration(t *testing.T) { t.Fatal(err) } var sent struct{ Settings json.RawMessage } - if json.Unmarshal(waitPreparationFile(t, filepath.Join(config.StateDir, "start.json")), &sent) != nil || string(sent.Settings) != `{"model":"turn-model","effort":null,"review":"auto_review"}` { + if json.Unmarshal(waitPreparationFile(t, filepath.Join(testState(config), "start.json")), &sent) != nil || string(sent.Settings) != `{"model":"turn-model","effort":null,"review":"auto_review"}` { t.Fatalf("turn_start settings = %s", sent.Settings) } if turn.(*session).owner != e || turn.(*session).process.Cmd.Process.Pid != pid { @@ -95,7 +95,7 @@ func TestPreparationWaitsForReceiptAndRetainsConfiguration(t *testing.T) { if done.Content != "completed" || done.Metadata[proto.DoneMetaAgentSessionID] != "native-session" || done.Usage.Raw["claude_sdk_result"] == nil { t.Fatal("prepared execution lost ordinary output or frozen resume", done) } - if _, err := os.Stat(filepath.Join(config.StateDir, "released")); err != nil { + if _, err := os.Stat(filepath.Join(testState(config), "released")); err != nil { t.Fatal("completion preceded process release", err) } } @@ -126,7 +126,7 @@ func TestPreparationRejectsInputAndUnavailableProfilesBeforeLaunch(t *testing.T) if _, err := NewExecutorFactory(config)(t.Context(), req); err == nil { t.Fatal("invalid preparation was accepted") } - if _, err := os.Stat(filepath.Join(config.StateDir, "launched")); !os.IsNotExist(err) { + if _, err := os.Stat(filepath.Join(testState(config), "launched")); !os.IsNotExist(err) { t.Fatal("rejection launched native preparation") } }) diff --git a/apps/daemon/internal/agent/claudesdk/readiness.go b/apps/daemon/internal/agent/claudesdk/readiness.go index a9f7f8fbf..b1b328aa7 100644 --- a/apps/daemon/internal/agent/claudesdk/readiness.go +++ b/apps/daemon/internal/agent/claudesdk/readiness.go @@ -103,12 +103,18 @@ func CheckRuntime(ctx context.Context, config Config) (RuntimeInfo, error) { binary = "node" } env := append(append([]string{}, os.Environ()...), config.Env...) - if config.Workspace != nil { + if config.Workspace != "" { var err error - _, env, err = workspaceEnvironment(config) + if env, _, err = trustedEnvironment(config); err != nil { + return RuntimeInfo{}, err + } + // The check belongs to no Session, so it writes only to a home it discards. + home, err := os.MkdirTemp("", "claude-runtime-check-") if err != nil { return RuntimeInfo{}, err } + defer os.RemoveAll(home) + env = append(env, "HOME="+home, "TMPDIR="+home, "CLAUDE_CONFIG_DIR="+home) } process, err := clirunner.Start(clirunner.StartOptions{ Parent: ctx, Binary: binary, diff --git a/apps/daemon/internal/agent/claudesdk/readiness_test.go b/apps/daemon/internal/agent/claudesdk/readiness_test.go index a6e093e0e..93c106b0a 100644 --- a/apps/daemon/internal/agent/claudesdk/readiness_test.go +++ b/apps/daemon/internal/agent/claudesdk/readiness_test.go @@ -20,10 +20,10 @@ const readyReport = `{"type":"runtime_ready","protocol":3,"node":"22.22.2","sdk" func TestRequiredMCPNeedsQualifiedRuntime(t *testing.T) { root := t.TempDir() t.Setenv("OAC_RUNTIME_HOME", root) - config := Config{Node: os.Args[0], Entrypoint: filepath.Join(root, "main.js"), StateDir: filepath.Join(root, "state"), Env: []string{ + config := Config{Node: os.Args[0], Entrypoint: filepath.Join(root, "main.js"), StateRoot: filepath.Join(root, "state"), Env: []string{ "GO_CLAUDE_READINESS_HELPER=1", "READINESS_MODE=ready-http-mcp", "GORACE=atexit_sleep_ms=0", }} - req := proto.PromptRequestPayload{RunID: "run", Input: proto.TextInput("hello"), DisableExecutionEnvironment: true, + req := proto.PromptRequestPayload{AgentStateKey: testStateKey, RunID: "run", Input: proto.TextInput("hello"), DisableExecutionEnvironment: true, Turn: proto.TurnSettings{Model: "fixture"}, AgentOptions: map[string]any{}, MCPHTTPServers: &[]proto.MCPHTTPServer{{ConnectionOrigin: "service", ServerLabel: "fixture", ServerURL: "https://example.invalid/mcp", Required: true}}} if _, err := startSingleTurn(t.Context(), config, req, make(chan proto.Envelope, 1)); err == nil || err.Error() != "claudesdk: packaged runtime does not support required HTTP MCP" { t.Fatalf("unqualified runtime executed required MCP: %v", err) @@ -33,7 +33,7 @@ func TestRequiredMCPNeedsQualifiedRuntime(t *testing.T) { func TestHTTPMCPRejectsOldPackagedRuntime(t *testing.T) { root := t.TempDir() t.Setenv("OAC_RUNTIME_HOME", root) - config := Config{Node: os.Args[0], Entrypoint: filepath.Join(root, "main.js"), StateDir: filepath.Join(root, "state"), Env: []string{ + config := Config{Node: os.Args[0], Entrypoint: filepath.Join(root, "main.js"), StateRoot: filepath.Join(root, "state"), Env: []string{ "GO_CLAUDE_READINESS_HELPER=1", "READINESS_MODE=ready", "GORACE=atexit_sleep_ms=0", }} info, err := CheckRuntime(t.Context(), config) @@ -44,7 +44,7 @@ func TestHTTPMCPRejectsOldPackagedRuntime(t *testing.T) { if !info.SupportsHTTPMCP() { t.Fatal("runtime feature not recognized") } - req := proto.PromptRequestPayload{RunID: "run", Input: proto.TextInput("hello"), DisableExecutionEnvironment: true, + req := proto.PromptRequestPayload{AgentStateKey: testStateKey, RunID: "run", Input: proto.TextInput("hello"), DisableExecutionEnvironment: true, Turn: proto.TurnSettings{Model: "fixture"}, AgentOptions: map[string]any{}, MCPHTTPServers: &[]proto.MCPHTTPServer{{ConnectionOrigin: "service", ServerLabel: "fixture", ServerURL: "https://example.invalid/mcp"}}} if _, err := startSingleTurn(t.Context(), config, req, make(chan proto.Envelope, 1)); err == nil || !strings.Contains(err.Error(), "packaged runtime does not support HTTP MCP") { t.Fatalf("old runtime was not rejected before execution: %v", err) @@ -77,11 +77,11 @@ func TestRuntimeReadiness(t *testing.T) { func TestMCPBearerRejectsAnonymousOnlyRuntimeWithoutProbeSecrets(t *testing.T) { root := t.TempDir() t.Setenv("OAC_RUNTIME_HOME", root) - config := Config{Node: os.Args[0], Entrypoint: filepath.Join(root, "main.js"), StateDir: filepath.Join(root, "state"), Env: []string{ + config := Config{Node: os.Args[0], Entrypoint: filepath.Join(root, "main.js"), StateRoot: filepath.Join(root, "state"), Env: []string{ "GO_CLAUDE_READINESS_HELPER=1", "READINESS_MODE=ready-http-mcp", "GORACE=atexit_sleep_ms=0", }} token := "private-fixture-token" - req := proto.PromptRequestPayload{RunID: "run", Input: proto.TextInput("hello"), DisableExecutionEnvironment: true, + req := proto.PromptRequestPayload{AgentStateKey: testStateKey, RunID: "run", Input: proto.TextInput("hello"), DisableExecutionEnvironment: true, Turn: proto.TurnSettings{Model: "fixture"}, AgentOptions: map[string]any{}, MCPHTTPServers: &[]proto.MCPHTTPServer{{ConnectionOrigin: "service", ServerLabel: "fixture", ServerURL: "https://example.invalid/mcp", BearerToken: &token}}} if _, err := startSingleTurn(t.Context(), config, req, make(chan proto.Envelope, 1)); err == nil || err.Error() != "claudesdk: packaged runtime does not support authenticated HTTP MCP" { t.Fatalf("old runtime executed authenticated request or readiness received its secret: %v", err) diff --git a/apps/daemon/internal/agent/claudesdk/restrictions_test.go b/apps/daemon/internal/agent/claudesdk/restrictions_test.go index d84fa2379..aad7b7e25 100644 --- a/apps/daemon/internal/agent/claudesdk/restrictions_test.go +++ b/apps/daemon/internal/agent/claudesdk/restrictions_test.go @@ -24,8 +24,8 @@ func TestTextFactoryAcceptsRestrictiveCapabilities(t *testing.T) { t.Run(test.name, func(t *testing.T) { root := t.TempDir() t.Setenv("OAC_RUNTIME_HOME", root) - config := Config{Node: os.Args[0], Entrypoint: filepath.Join(root, "worker"), StateDir: filepath.Join(root, "state"), Env: []string{"GO_CLAUDE_SDK_HELPER=1", "SDK_HELPER_MODE=success", "GORACE=atexit_sleep_ms=0"}} - request := proto.PromptRequestPayload{RunID: "restricted-run", Input: proto.TextInput("hello"), AgentSessionID: "native-session", DisableExecutionEnvironment: test.environment, DisableSubagents: test.subagents, ExecutionControls: test.controls, Turn: proto.TurnSettings{Model: "fake-model"}, AgentOptions: map[string]any{"system_prompt": "instructions"}} + config := Config{Node: os.Args[0], Entrypoint: filepath.Join(root, "worker"), StateRoot: filepath.Join(root, "state"), Env: []string{"GO_CLAUDE_SDK_HELPER=1", "SDK_HELPER_MODE=success", "GORACE=atexit_sleep_ms=0"}} + request := proto.PromptRequestPayload{AgentStateKey: testStateKey, RunID: "restricted-run", Input: proto.TextInput("hello"), AgentSessionID: "native-session", DisableExecutionEnvironment: test.environment, DisableSubagents: test.subagents, ExecutionControls: test.controls, Turn: proto.TurnSettings{Model: "fake-model"}, AgentOptions: map[string]any{"system_prompt": "instructions"}} ctx, cancel := context.WithTimeout(t.Context(), 5*time.Second) defer cancel() out := make(chan proto.Envelope, 16) diff --git a/apps/daemon/internal/agent/claudesdk/session_test.go b/apps/daemon/internal/agent/claudesdk/session_test.go index abf339ddd..d402ebbc0 100644 --- a/apps/daemon/internal/agent/claudesdk/session_test.go +++ b/apps/daemon/internal/agent/claudesdk/session_test.go @@ -21,8 +21,8 @@ func TestTextFactoryCompletionAndFailures(t *testing.T) { t.Run(mode, func(t *testing.T) { root := t.TempDir() t.Setenv("OAC_RUNTIME_HOME", root) - config := Config{Node: os.Args[0], Entrypoint: filepath.Join(root, "worker"), StateDir: filepath.Join(root, "state"), Env: []string{"GO_CLAUDE_SDK_HELPER=1", "SDK_HELPER_MODE=" + mode, "GORACE=atexit_sleep_ms=0"}} - request := proto.PromptRequestPayload{RunID: "run", Input: proto.TextInput("hello"), AgentSessionID: "native-session", Turn: proto.TurnSettings{Model: "fake-model"}, AgentOptions: map[string]any{"system_prompt": "instructions"}} + config := Config{Node: os.Args[0], Entrypoint: filepath.Join(root, "worker"), StateRoot: filepath.Join(root, "state"), Env: []string{"GO_CLAUDE_SDK_HELPER=1", "SDK_HELPER_MODE=" + mode, "GORACE=atexit_sleep_ms=0"}} + request := proto.PromptRequestPayload{AgentStateKey: testStateKey, RunID: "run", Input: proto.TextInput("hello"), AgentSessionID: "native-session", Turn: proto.TurnSettings{Model: "fake-model"}, AgentOptions: map[string]any{"system_prompt": "instructions"}} ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second) defer cancel() out := make(chan proto.Envelope, 16) @@ -55,7 +55,7 @@ func TestTextFactoryCompletionAndFailures(t *testing.T) { if payload.Content != "final" || payload.Metadata[proto.DoneMetaAgentSessionID] != "native-session" || deltas != "partial" { t.Fatalf("bad completion: %+v, deltas %q", payload, deltas) } - if _, err := os.Stat(filepath.Join(config.StateDir, "released")); err != nil { + if _, err := os.Stat(filepath.Join(testState(config), "released")); err != nil { t.Fatal("Done preceded process release") } } else if payload.Metadata[proto.DoneMetaAgentSessionID] != nil { @@ -75,15 +75,15 @@ func TestTextFactoryRejectsUnsupportedInput(t *testing.T) { t.Run(kind, func(t *testing.T) { root := t.TempDir() t.Setenv("OAC_RUNTIME_HOME", root) - config := Config{Node: "must-not-run", Entrypoint: filepath.Join(root, "worker"), StateDir: filepath.Join(root, "state")} - request := proto.PromptRequestPayload{RunID: "run", Input: proto.TextInput("hello"), Turn: proto.TurnSettings{Model: "fake"}, AgentOptions: map[string]any{}} + config := Config{Node: "must-not-run", Entrypoint: filepath.Join(root, "worker"), StateRoot: filepath.Join(root, "state")} + request := proto.PromptRequestPayload{AgentStateKey: testStateKey, RunID: "run", Input: proto.TextInput("hello"), Turn: proto.TurnSettings{Model: "fake"}, AgentOptions: map[string]any{}} switch kind { case "execution-controls": request.ExecutionControls = &proto.ExecutionControls{TextVerbosity: "low"} case "tool": request.FunctionTools = []proto.FunctionTool{{}} case "outside": - config.StateDir = "relative-state" + config.StateRoot = "relative-state" } _, err := startSingleTurn(context.Background(), config, request, make(chan proto.Envelope, 1)) if err == nil || !strings.HasPrefix(err.Error(), "claudesdk:") { diff --git a/apps/daemon/internal/agent/claudesdk/steering_test.go b/apps/daemon/internal/agent/claudesdk/steering_test.go index 7459f56f5..216daebd2 100644 --- a/apps/daemon/internal/agent/claudesdk/steering_test.go +++ b/apps/daemon/internal/agent/claudesdk/steering_test.go @@ -23,11 +23,11 @@ func TestSteeringReceiptsAndLifecycle(t *testing.T) { t.Run(mode, func(t *testing.T) { root := t.TempDir() t.Setenv("OAC_RUNTIME_HOME", root) - config := Config{Node: os.Args[0], Entrypoint: filepath.Join(root, "worker"), StateDir: filepath.Join(root, "state"), Env: []string{"GO_CLAUDE_SDK_HELPER=1", "SDK_HELPER_MODE=steering-" + mode, "GORACE=atexit_sleep_ms=0"}} + config := Config{Node: os.Args[0], Entrypoint: filepath.Join(root, "worker"), StateRoot: filepath.Join(root, "state"), Env: []string{"GO_CLAUDE_SDK_HELPER=1", "SDK_HELPER_MODE=steering-" + mode, "GORACE=atexit_sleep_ms=0"}} if mode == "phased" { config.Env[1] = "SDK_HELPER_MODE=steering-timeout" } - request := proto.PromptRequestPayload{RunID: "run", Input: proto.TextInput("hello"), AgentSessionID: "native", Turn: proto.TurnSettings{Model: "fake-model"}, AgentOptions: map[string]any{"system_prompt": "instructions"}} + request := proto.PromptRequestPayload{AgentStateKey: testStateKey, RunID: "run", Input: proto.TextInput("hello"), AgentSessionID: "native", Turn: proto.TurnSettings{Model: "fake-model"}, AgentOptions: map[string]any{"system_prompt": "instructions"}} ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second) defer cancel() out := make(chan proto.Envelope, 16) diff --git a/apps/daemon/internal/agent/claudesdk/tool_environment_test.go b/apps/daemon/internal/agent/claudesdk/tool_environment_test.go index 873bc281f..5724c1259 100644 --- a/apps/daemon/internal/agent/claudesdk/tool_environment_test.go +++ b/apps/daemon/internal/agent/claudesdk/tool_environment_test.go @@ -16,7 +16,7 @@ func TestSelfHostedToolEnvironment(t *testing.T) { } t.Setenv("OAC_RUNTIME_TOOL_ENV_FILE", file) req := workspaceRequest() - req.LocalEnvironment = &proto.LocalEnvironment{WorkspaceRoot: config.Workspace.Directory} + req.LocalEnvironment = &proto.LocalEnvironment{WorkspaceRoot: config.Workspace} profile, _, err := prepareWorkspace(config, req) if err != nil { t.Fatal(err) diff --git a/apps/daemon/internal/agent/claudesdk/usage_test.go b/apps/daemon/internal/agent/claudesdk/usage_test.go index e2ea723a3..09b0d7f6c 100644 --- a/apps/daemon/internal/agent/claudesdk/usage_test.go +++ b/apps/daemon/internal/agent/claudesdk/usage_test.go @@ -22,8 +22,8 @@ func TestUsageTransportPreservesSnapshotOnFailureAndDone(t *testing.T) { t.Run(mode, func(t *testing.T) { root := t.TempDir() t.Setenv("OAC_RUNTIME_HOME", root) - config := Config{Node: os.Args[0], Entrypoint: filepath.Join(root, "worker"), StateDir: filepath.Join(root, "state"), Env: []string{"GO_CLAUDE_SDK_HELPER=1", "SDK_HELPER_MODE=usage-" + mode, "GORACE=atexit_sleep_ms=0"}} - request := proto.PromptRequestPayload{RunID: "usage-run", Input: proto.TextInput("hello"), AgentSessionID: "native-session", Turn: proto.TurnSettings{Model: "fake-model"}, AgentOptions: map[string]any{"system_prompt": "instructions"}} + config := Config{Node: os.Args[0], Entrypoint: filepath.Join(root, "worker"), StateRoot: filepath.Join(root, "state"), Env: []string{"GO_CLAUDE_SDK_HELPER=1", "SDK_HELPER_MODE=usage-" + mode, "GORACE=atexit_sleep_ms=0"}} + request := proto.PromptRequestPayload{AgentStateKey: testStateKey, RunID: "usage-run", Input: proto.TextInput("hello"), AgentSessionID: "native-session", Turn: proto.TurnSettings{Model: "fake-model"}, AgentOptions: map[string]any{"system_prompt": "instructions"}} ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second) defer cancel() out := make(chan proto.Envelope, 16) diff --git a/apps/daemon/internal/agent/claudesdk/workspace.go b/apps/daemon/internal/agent/claudesdk/workspace.go index e60dd9d66..27de2070e 100644 --- a/apps/daemon/internal/agent/claudesdk/workspace.go +++ b/apps/daemon/internal/agent/claudesdk/workspace.go @@ -7,20 +7,12 @@ import ( "slices" "strings" + "github.com/MiniMax-AI/OpenAgentCore/apps/daemon/internal/agent" "github.com/MiniMax-AI/OpenAgentCore/apps/daemon/internal/localworkspace" "github.com/MiniMax-AI/OpenAgentCore/internal/agentdaemon/proto" + harnessconfiguration "github.com/MiniMax-AI/OpenAgentCore/internal/harnessconfig/claudesdk" ) -// WorkspaceConfig binds one trusted private placement. It does not create an -// isolation boundary or authorize a public Environment. The operator must place -// the entire factory inside the qualified outer mount/process boundary first. -// State directories must already exist. -type WorkspaceConfig struct { - Directory string - HomeDir string - ScratchDir string -} - type workspaceProfile struct { ToolEnv map[string]string `json:"tool_env,omitempty"` CapabilityRoot string `json:"capability_root,omitempty"` @@ -36,13 +28,29 @@ func prepareWorkspace(config Config, req proto.PromptRequestPayload) (*workspace if req.DisableExecutionEnvironment { return nil, nil, fmt.Errorf("claudesdk: workspace profile does not support the requested execution combination") } - if req.LocalEnvironment != nil && req.LocalEnvironment.WorkspaceRoot != config.Workspace.Directory { + if req.LocalEnvironment != nil && req.LocalEnvironment.WorkspaceRoot != config.Workspace { return nil, nil, fmt.Errorf("claudesdk: workspace root conflicts with the trusted workspace binding") } - profile, env, err := workspaceEnvironment(config) + // Retained history lives with the Environment filesystem; HOME and scratch stay on the Runtime's own disk. + kind := harnessconfiguration.Declaration.Kind + state, err := agent.StateDir(config.StateRoot, kind, req.AgentStateKey) + if err != nil { + return nil, nil, err + } + runtime, err := agent.StateDir(config.RuntimeRoot, kind, req.AgentStateKey) if err != nil { return nil, nil, err } + dirs := workspaceDirs{State: state, Home: filepath.Join(runtime, "home"), Scratch: filepath.Join(runtime, "scratch")} + profile, env, err := workspaceEnvironment(config, dirs) + if err != nil { + return nil, nil, err + } + for _, dir := range []string{dirs.State, dirs.Home, dirs.Scratch} { + if err = os.MkdirAll(dir, 0o700); err != nil { + return nil, nil, err + } + } if req.LocalEnvironment != nil { profile.ToolEnv, err = localworkspace.ReadOptionalToolEnvironment() if err != nil { @@ -62,14 +70,22 @@ func prepareWorkspace(config Config, req proto.PromptRequestPayload) (*workspace return profile, append(env, credentials...), nil } +type workspaceDirs struct{ State, Home, Scratch string } + // Harness state paths and selected model credentials overlay the user environment. -func workspaceEnvironment(config Config) (*workspaceProfile, []string, error) { - fail := func() (*workspaceProfile, []string, error) { - return nil, nil, fmt.Errorf("claudesdk: invalid trusted workspace configuration") +func workspaceEnvironment(config Config, dirs workspaceDirs) (*workspaceProfile, []string, error) { + env, names, err := trustedEnvironment(config) + if err != nil { + return nil, nil, err } - w := config.Workspace - if w == nil { - return fail() + env = append(env, "HOME="+dirs.Home, "TMPDIR="+dirs.Scratch, "CLAUDE_CONFIG_DIR="+dirs.State) + return &workspaceProfile{Home: dirs.Home, State: dirs.State, Scratch: dirs.Scratch, EnvNames: names}, env, nil +} + +// trustedEnvironment validates the trusted binding and returns the user environment without Harness state paths or credentials. +func trustedEnvironment(config Config) ([]string, []string, error) { + fail := func() ([]string, []string, error) { + return nil, nil, fmt.Errorf("claudesdk: invalid trusted workspace configuration") } for _, path := range []string{config.Node, config.Entrypoint, filepath.Join(filepath.Dir(config.Entrypoint), "runtime_check.js")} { info, err := os.Stat(path) @@ -77,12 +93,9 @@ func workspaceEnvironment(config Config) (*workspaceProfile, []string, error) { return fail() } } - for _, path := range []string{w.Directory, config.StateDir, w.HomeDir, w.ScratchDir} { - if !canonicalWorkspaceDir(path) { - return fail() - } + if !canonicalWorkspaceDir(config.Workspace) { + return fail() } - profile := &workspaceProfile{Home: w.HomeDir, State: config.StateDir, Scratch: w.ScratchDir, EnvNames: []string{}} env := []string{} for _, entry := range os.Environ() { name, _, _ := strings.Cut(entry, "=") @@ -90,7 +103,7 @@ func workspaceEnvironment(config Config) (*workspaceProfile, []string, error) { env = append(env, entry) } } - env = append(env, "HOME="+w.HomeDir, "TMPDIR="+w.ScratchDir, "CLAUDE_CONFIG_DIR="+config.StateDir) + names := []string{} seen := map[string]bool{} for _, entry := range config.Env { name, _, ok := strings.Cut(entry, "=") @@ -98,10 +111,10 @@ func workspaceEnvironment(config Config) (*workspaceProfile, []string, error) { return fail() } seen[name] = true - profile.EnvNames = append(profile.EnvNames, name) + names = append(names, name) env = append(env, entry) } - return profile, env, nil + return env, names, nil } func workspaceEnvName(name string) bool { diff --git a/apps/daemon/internal/agent/claudesdk/workspace_launch_test.go b/apps/daemon/internal/agent/claudesdk/workspace_launch_test.go index 39abe7d2c..612d3cf64 100644 --- a/apps/daemon/internal/agent/claudesdk/workspace_launch_test.go +++ b/apps/daemon/internal/agent/claudesdk/workspace_launch_test.go @@ -60,14 +60,14 @@ esac } // Feature checking must reject an older bridge without starting execution. script = strings.ReplaceAll(script, `"features":["workspace_tools","workspace_prepare","workspace_command_observations"]`, `"features":[]`) - script = strings.ReplaceAll(script, "IFS= read -r request", "touch '"+filepath.Join(config.StateDir, "unexpected-start")+"'") + script = strings.ReplaceAll(script, "IFS= read -r request", "touch '"+filepath.Join(testState(config), "unexpected-start")+"'") if err := os.WriteFile(config.Node, []byte(script), 0o700); err != nil { t.Fatal(err) } if _, err := startSingleTurn(t.Context(), config, workspaceRequest(), out); err == nil { t.Fatal("old packaged bridge accepted workspace execution") } - if _, err := os.Stat(filepath.Join(config.StateDir, "unexpected-start")); !os.IsNotExist(err) { + if _, err := os.Stat(filepath.Join(testState(config), "unexpected-start")); !os.IsNotExist(err) { t.Fatal("old packaged bridge started execution") } } diff --git a/apps/daemon/internal/agent/claudesdk/workspace_structured_test.go b/apps/daemon/internal/agent/claudesdk/workspace_structured_test.go index aa905e201..8a2815e34 100644 --- a/apps/daemon/internal/agent/claudesdk/workspace_structured_test.go +++ b/apps/daemon/internal/agent/claudesdk/workspace_structured_test.go @@ -25,7 +25,7 @@ func TestWorkspaceStructuredPreparationQualificationAndFrozenSchema(t *testing.T if err == nil || !strings.Contains(err.Error(), "workspace structured output") { t.Fatal("unqualified bundle admitted", err) } - if _, err := os.Stat(filepath.Join(config.StateDir, "launched")); !os.IsNotExist(err) { + if _, err := os.Stat(filepath.Join(testState(config), "launched")); !os.IsNotExist(err) { t.Fatal("unqualified request reached native launch", err) } return @@ -36,7 +36,7 @@ func TestWorkspaceStructuredPreparationQualificationAndFrozenSchema(t *testing.T defer e.Close(context.Background()) req.ExecutionControls.OutputFormat.Schema[0] = ' ' var frozen startRequest - if err := json.Unmarshal(waitPreparationFile(t, filepath.Join(config.StateDir, "prepare.json")), &frozen); err != nil { + if err := json.Unmarshal(waitPreparationFile(t, filepath.Join(testState(config), "prepare.json")), &frozen); err != nil { t.Fatal(err) } if frozen.OutputFormat == nil || string(frozen.OutputFormat.Schema) != schema { @@ -52,7 +52,7 @@ func TestWorkspaceStructuredPreparationQualificationAndFrozenSchema(t *testing.T } } var started map[string]json.RawMessage - if err := json.Unmarshal(waitPreparationFile(t, filepath.Join(config.StateDir, "start.json")), &started); err != nil || len(started) != 4 || started["output_format"] != nil { + if err := json.Unmarshal(waitPreparationFile(t, filepath.Join(testState(config), "start.json")), &started); err != nil || len(started) != 4 || started["output_format"] != nil { t.Fatal("Start replaced the prepared configuration", err) } }) diff --git a/apps/daemon/internal/agent/claudesdk/workspace_test.go b/apps/daemon/internal/agent/claudesdk/workspace_test.go index 69f7d3d7e..88876f241 100644 --- a/apps/daemon/internal/agent/claudesdk/workspace_test.go +++ b/apps/daemon/internal/agent/claudesdk/workspace_test.go @@ -4,10 +4,13 @@ import ( "encoding/json" "os" "path/filepath" + "slices" "strings" "testing" + "github.com/MiniMax-AI/OpenAgentCore/apps/daemon/internal/agent" "github.com/MiniMax-AI/OpenAgentCore/internal/agentdaemon/proto" + harnessconfiguration "github.com/MiniMax-AI/OpenAgentCore/internal/harnessconfig/claudesdk" ) func workspaceFixture(t *testing.T) Config { @@ -17,15 +20,14 @@ func workspaceFixture(t *testing.T) Config { t.Fatal(err) } t.Setenv("OAC_RUNTIME_HOME", root) - for _, name := range []string{"workspace", "home", "state", "scratch", "secrets", "bin", "runtime/dist", "runtime/node_modules"} { + for _, name := range []string{"workspace", "state", "runtime-home", "secrets", "bin", "runtime/dist", "runtime/node_modules"} { if err := os.MkdirAll(filepath.Join(root, name), 0o700); err != nil { t.Fatal(err) } } - config := Config{Node: filepath.Join(root, "bin", "node"), Entrypoint: filepath.Join(root, "runtime", "dist", "main.js"), StateDir: filepath.Join(root, "state"), - Env: []string{"ANTHROPIC_AUTH_TOKEN=selected-provider-fixture", "ANTHROPIC_BASE_URL=https://example.invalid"}, - Workspace: &WorkspaceConfig{Directory: filepath.Join(root, "workspace"), HomeDir: filepath.Join(root, "home"), - ScratchDir: filepath.Join(root, "scratch")}} + config := Config{Node: filepath.Join(root, "bin", "node"), Entrypoint: filepath.Join(root, "runtime", "dist", "main.js"), StateRoot: filepath.Join(root, "state"), + Env: []string{"ANTHROPIC_AUTH_TOKEN=selected-provider-fixture", "ANTHROPIC_BASE_URL=https://example.invalid"}, + RuntimeRoot: filepath.Join(root, "runtime-home"), Workspace: filepath.Join(root, "workspace")} for _, name := range []string{config.Node, config.Entrypoint, filepath.Join(filepath.Dir(config.Entrypoint), "runtime_check.js")} { if err := os.WriteFile(name, nil, 0o700); err != nil { t.Fatal(err) @@ -35,7 +37,7 @@ func workspaceFixture(t *testing.T) Config { } func workspaceRequest() proto.PromptRequestPayload { - return proto.PromptRequestPayload{RunID: "run", Input: proto.TextInput("hello"), DisableSubagents: true, + return proto.PromptRequestPayload{AgentStateKey: testStateKey, RunID: "run", Input: proto.TextInput("hello"), DisableSubagents: true, Turn: proto.TurnSettings{Model: "fixture"}, AgentOptions: map[string]any{}} } @@ -47,7 +49,7 @@ func TestWorkspaceTrustedBindingAndEnvironment(t *testing.T) { if err != nil { t.Fatal(err) } - if start.Cwd != config.Workspace.Directory || start.Workspace == nil || start.MCPHTTPServers != nil { + if start.Cwd != config.Workspace || start.Workspace == nil || start.MCPHTTPServers != nil { t.Fatal("trusted binding was not retained") } raw, _ := json.Marshal(start) @@ -65,16 +67,39 @@ func TestWorkspaceTrustedBindingAndEnvironment(t *testing.T) { if _, ok := values["ANTHROPIC_API_KEY"]; ok { t.Fatal("inherited unselected provider credential") } - if values["HOME"] != config.Workspace.HomeDir || values["CLAUDE_CONFIG_DIR"] != config.StateDir || - values["TMPDIR"] != config.Workspace.ScratchDir || values["ANTHROPIC_AUTH_TOKEN"] != "selected-provider-fixture" { + if values["CLAUDE_CONFIG_DIR"] != testState(config) || values["ANTHROPIC_AUTH_TOKEN"] != "selected-provider-fixture" { t.Fatal("explicit runtime environment was not preserved") } - entries, err := os.ReadDir(config.StateDir) + entries, err := os.ReadDir(testState(config)) if err != nil || len(entries) != 0 { t.Fatal("preparation created history or nested scratch") } } +func TestSessionsKeepNativeStateInOwnDirectories(t *testing.T) { + config := workspaceFixture(t) + req := workspaceRequest() + seen := map[string]string{} + for _, key := range []string{"session-a", "session-b"} { + req.AgentStateKey = key + _, env, err := prepareConfiguration(config, req) + if err != nil { + t.Fatal(err) + } + state, _ := agent.StateDir(config.StateRoot, harnessconfiguration.Declaration.Kind, key) + runtime, _ := agent.StateDir(config.RuntimeRoot, harnessconfiguration.Declaration.Kind, key) + for name, want := range map[string]string{"CLAUDE_CONFIG_DIR": state, "HOME": filepath.Join(runtime, "home"), "TMPDIR": filepath.Join(runtime, "scratch")} { + if !slices.Contains(env, name+"="+want) { + t.Fatalf("%s for %s is not %s", name, key, want) + } + if prior, ok := seen[want]; ok { + t.Fatalf("%s and %s share %s", prior, key, want) + } + seen[want] = key + } + } +} + func TestWorkspaceRejectsConflictsBeforeSideEffects(t *testing.T) { for _, name := range []string{"none", "workspace-root", "mcp", "relative", "missing", "ambient-setting", "duplicate-env", "bad-env"} { t.Run(name, func(t *testing.T) { @@ -84,24 +109,22 @@ func TestWorkspaceRejectsConflictsBeforeSideEffects(t *testing.T) { case "none": req.DisableExecutionEnvironment = true case "workspace-root": - req.LocalEnvironment = &proto.LocalEnvironment{ID: "environment", WorkspaceRoot: config.Workspace.ScratchDir} + req.LocalEnvironment = &proto.LocalEnvironment{ID: "environment", WorkspaceRoot: filepath.Join(config.RuntimeRoot, "scratch")} case "mcp": req.MCPHTTPServers = &[]proto.MCPHTTPServer{{ConnectionOrigin: "service", ServerLabel: "remote", ServerURL: "https://example.test/mcp"}} case "relative": - config.Workspace.Directory = "relative" + config.Workspace = "relative" case "missing": - config.Workspace.Directory = filepath.Join(config.Workspace.Directory, "missing") - case "overlap": - config.Workspace.ScratchDir = config.StateDir + config.Workspace = filepath.Join(config.Workspace, "missing") case "symlink": - alias := filepath.Join(filepath.Dir(config.StateDir), "alias") - if err := os.Symlink(config.Workspace.Directory, alias); err != nil { + alias := filepath.Join(filepath.Dir(testState(config)), "alias") + if err := os.Symlink(config.Workspace, alias); err != nil { t.Fatal(err) } - config.Workspace.Directory = alias + config.Workspace = alias case "rule-pattern": - config.Workspace.Directory += "*" - if err := os.Mkdir(config.Workspace.Directory, 0o700); err != nil { + config.Workspace += "*" + if err := os.Mkdir(config.Workspace, 0o700); err != nil { t.Fatal(err) } case "ambient-setting": @@ -115,8 +138,7 @@ func TestWorkspaceRejectsConflictsBeforeSideEffects(t *testing.T) { if _, _, err := prepareConfiguration(config, req); err == nil { t.Fatal("invalid binding or request accepted") } - entries, err := os.ReadDir(config.StateDir) - if err != nil || len(entries) != 0 { + if _, err := os.Stat(testState(config)); !os.IsNotExist(err) { t.Fatal("rejection created execution state") } }) @@ -139,7 +161,7 @@ func TestWorkspaceRetainsDeclaredFunctions(t *testing.T) { func TestPublicMCPUsesWorkspaceProjectionWithoutCredentialCopy(t *testing.T) { config := workspaceFixture(t) req := workspaceRequest() - req.LocalEnvironment = &proto.LocalEnvironment{WorkspaceRoot: config.Workspace.Directory} + req.LocalEnvironment = &proto.LocalEnvironment{WorkspaceRoot: config.Workspace} token := "vault-selected-canary" tools := []string{"prove"} req.MCPHTTPServers = &[]proto.MCPHTTPServer{{ConnectionOrigin: "environment", ServerLabel: "remote", ServerURL: "https://example.test/mcp", AllowedTools: &tools, Required: true, BearerToken: &token}} @@ -171,3 +193,14 @@ func TestPublicMCPUsesWorkspaceProjectionWithoutCredentialCopy(t *testing.T) { t.Fatal(err) } } + +const testStateKey = "session" + +// testState is the native state directory the fixtures' Session writes to. +func testState(config Config) string { + dir, err := agent.StateDir(config.StateRoot, harnessconfiguration.Declaration.Kind, testStateKey) + if err != nil { + panic(err) + } + return dir +} From f80b31134ee604c40d9f16bee7a4f94c055f5975 Mon Sep 17 00:00:00 2001 From: SaladDay <1203511142@qq.com> Date: Thu, 5 Nov 2026 08:12:30 +0000 Subject: [PATCH 3/4] Document per-Session native state directories --- contracts/agents-api/harness-onboarding.md | 2 +- contracts/agents-api/zh/harness-onboarding.md | 4 ++-- services/core/deploy/claude/README.md | 2 +- services/core/deploy/codex/README.md | 2 +- 4 files changed, 5 insertions(+), 5 deletions(-) diff --git a/contracts/agents-api/harness-onboarding.md b/contracts/agents-api/harness-onboarding.md index 6f135c251..2213a9ad3 100644 --- a/contracts/agents-api/harness-onboarding.md +++ b/contracts/agents-api/harness-onboarding.md @@ -159,7 +159,7 @@ A Harness that supports the Subagent reads implements the [neutral observation c Registration is static and requires a build. Export one `agent.Declaration` from `apps/daemon/internal/agent//declaration.go`, and run `make generate-harness-catalog`, which lists it in the generated `harnessDeclarations`. The binding is the shared `harnessconfig.Declaration` plus a `Discover` function. Discovery receives the profile, diagnostic writers and the Runtime-resolved `DiscoveryOptions.StateRoot` and instance-private `DiscoveryOptions.RuntimeRoot`, owns native configuration and availability checks, and returns the installed `agent.Runtime` with its descriptor and Executor factory. Return nil when the adapter is not configured; return an unavailable descriptor without a factory when configured prerequisites fail. Discovery only narrows the declared capabilities with `agent.Narrow`, never widens them, and an unavailable descriptor comes from `agent.Unavailable`. Keep version gates and factory-selection conditions inside the adapter. -The Runtime resolves the [state directory](../../docs/configuration.md#runtime-resource-directories) once and passes it explicitly to every adapter. Adapters that support retained native history place it beneath that root with their own namespaced layout; they do not choose a fallback root from ambient environment or put history in the workspace. An adapter without this capability keeps instance-private native state and must not declare history portable merely because a state root is supplied. Private launch homes, scratch data and instance-private native state use the supplied `DiscoveryOptions.RuntimeRoot`. Device credentials and connection identity stay outside retained state. A state path alone does not qualify native continuation after compute replacement: the adapter must preserve native ownership, reject missing or foreign history before model input, and complete native shutdown before another writer attaches. +The Runtime resolves the [state directory](../../docs/configuration.md#runtime-resource-directories) once and passes it explicitly to every adapter. Adapters that support retained native history place it beneath that root with their own namespaced layout; they do not choose a fallback root from ambient environment or put history in the workspace. An adapter without this capability keeps instance-private native state and must not declare history portable merely because a state root is supplied. Private launch homes, scratch data and instance-private native state use the supplied `DiscoveryOptions.RuntimeRoot`. One Runtime serves several Sessions in sequence, so every per-Session native state directory (the native history or configuration directory, the launch home and scratch data) lies beneath `agent.StateDir(StateRoot, kind, key)` or `agent.StateDir(RuntimeRoot, kind, key)`, where `key` is the request's `AgentStateKey`. Nothing per-Session lives elsewhere. Device credentials and connection identity stay outside retained state. A state path alone does not qualify native continuation after compute replacement: the adapter must preserve native ownership, reject missing or foreign history before model input, and complete native shutdown before another writer attaches. [`cli/agent_registration.go`](https://github.com/MiniMax-AI/OpenAgentCore/blob/main/apps/daemon/internal/cli/agent_registration.go) iterates the discovered runtimes and calls `Registry.Register` from `agent/harness.go`. It verifies that discovery retained the declared kind and registers the Runtime in this order: diff --git a/contracts/agents-api/zh/harness-onboarding.md b/contracts/agents-api/zh/harness-onboarding.md index 3b081909d..2c5004b3b 100644 --- a/contracts/agents-api/zh/harness-onboarding.md +++ b/contracts/agents-api/zh/harness-onboarding.md @@ -1,7 +1,7 @@ --- title: "Harness 协议" source: contracts/agents-api/harness-onboarding.md -source_hash: 27ab04dbb3aaf2792d54710372b370588d35db931af8f2e6b88a34d78030b7f9 +source_hash: 623be858995bb486e05f51505fbb1d9bd87a0c5bcbef0a9ad54b90bf109216c9 --- **Harness** 是一种运行模型和工具循环的原生代理引擎(Codex、Claude Code、MiniMax Code);**Harness 适配器**将 Runtime 的 Executor 和 Turn 契约转换到该引擎的 SDK 或协议。Harness 协议是 Runtime 与适配器之间的契约:适配器接口及其生命周期义务、声明、注册和验收。本文面向添加或维护 Harness 的开发者。 @@ -161,7 +161,7 @@ MCP、公共函数、延迟函数发现、结构化输出、图像输入、详 注册是静态的,并且需要构建。从 `apps/daemon/internal/agent//declaration.go` 导出一个 `agent.Declaration`,然后运行 `make generate-harness-catalog`,它会把该声明列入生成的 `harnessDeclarations`。绑定由共享的 `harnessconfig.Declaration` 加上 `Discover` 函数组成。发现过程接收 profile、诊断写入器以及 Runtime 解析后的 `DiscoveryOptions.StateRoot` 和实例私有的 `DiscoveryOptions.RuntimeRoot`,负责原生配置和可用性检查,并返回已安装的 `agent.Runtime` 及其描述符和 Executor 工厂。未配置适配器时返回 nil;已配置的前置条件失败时,返回不带工厂的不可用描述符。发现过程只用 `agent.Narrow` 收窄声明的能力,绝不扩大;不可用的描述符来自 `agent.Unavailable`。将版本门控和工厂选择条件保留在适配器内部。 -Runtime 统一解析[状态目录](../../../docs/zh/configuration.md#runtime-resource-directories),并显式传给每个适配器。支持保留原生历史的适配器在该根目录下按各自的命名空间布局存放历史,不从环境变量选择回退根目录,也不把历史放进工作区。不支持该能力的适配器将原生状态保持为实例私有,不能仅因收到状态根目录就宣称历史可迁移。私有启动主目录、临时数据及实例私有原生状态使用传入的 `DiscoveryOptions.RuntimeRoot`。设备凭据和连接身份必须留在保留状态之外。仅有状态路径并不意味着计算资源替换后的原生续接已通过资格验证:适配器必须保持原生所有权,在提交模型输入之前拒绝缺失或外来历史,并在另一个写入者挂载前完成原生关闭。 +Runtime 统一解析[状态目录](../../../docs/zh/configuration.md#runtime-resource-directories),并显式传给每个适配器。支持保留原生历史的适配器在该根目录下按各自的命名空间布局存放历史,不从环境变量选择回退根目录,也不把历史放进工作区。不支持该能力的适配器将原生状态保持为实例私有,不能仅因收到状态根目录就宣称历史可迁移。私有启动主目录、临时数据及实例私有原生状态使用传入的 `DiscoveryOptions.RuntimeRoot`。一个 Runtime 依次服务多个 Session,因此每个 Session 的原生状态目录(原生历史或配置目录、启动主目录和临时数据)都位于 `agent.StateDir(StateRoot, kind, key)` 或 `agent.StateDir(RuntimeRoot, kind, key)` 之下,其中 `key` 为请求的 `AgentStateKey`;任何 Session 私有状态都不放在别处。设备凭据和连接身份必须留在保留状态之外。仅有状态路径并不意味着计算资源替换后的原生续接已通过资格验证:适配器必须保持原生所有权,在提交模型输入之前拒绝缺失或外来历史,并在另一个写入者挂载前完成原生关闭。 [`cli/agent_registration.go`](https://github.com/MiniMax-AI/OpenAgentCore/blob/main/apps/daemon/internal/cli/agent_registration.go) 遍历已发现的 Runtime,并调用 `agent/harness.go` 中的 `Registry.Register`。它验证发现过程是否保留了声明的 kind,并按以下顺序注册该 Runtime: diff --git a/services/core/deploy/claude/README.md b/services/core/deploy/claude/README.md index b00a32753..b1c83e033 100644 --- a/services/core/deploy/claude/README.md +++ b/services/core/deploy/claude/README.md @@ -16,7 +16,7 @@ Claude accepts only the `anthropic` model protocol ([`harnessconfig/claudesdk`]( ## Native state and recovery -With a workspace binding, the SDK stores retained history at `/runtime/claude-sdk/history`, where the Runtime supplies `` from the [state directory setting](../../../../docs/configuration.md#runtime-resource-directories). Without a workspace binding, native state uses `/daemon//runtime/claude-sdk`. Private launch home and scratch directories for workspace execution stay at `$OAC_RUNTIME_HOME/runtime/claude-sdk/home` and `$OAC_RUNTIME_HOME/runtime/claude-sdk/scratch` (mode 0700). The daemon's authentication stays under `$OAC_RUNTIME_HOME/daemon/`. These locations keep native history, temporary process state and device credentials separate; they do not restrict the tools. +Each Session keeps its native state under its own state key, as the [Harness protocol](../../../../contracts/agents-api/harness-onboarding.md) requires. The SDK's `CLAUDE_CONFIG_DIR`, which holds retained history, is `/runtime/claude_sdk/state//`, where the Runtime supplies `` from the [state directory setting](../../../../docs/configuration.md#runtime-resource-directories). With a workspace binding, the private launch home and scratch directory are `home` and `scratch` under `$OAC_RUNTIME_HOME/runtime/claude_sdk/state//` (mode 0700). The daemon's authentication stays under `$OAC_RUNTIME_HOME/daemon/`. These locations keep native history, temporary process state and device credentials separate; they do not restrict the tools. Recovery uses the SDK's history APIs ([`recovery.ts`](../../../../packages/claude-sdk-adapter/src/recovery.ts)). An explicitly supplied native Session ID must exist. When Core requires existing history but has no recorded ID, the adapter accepts only a single native Session whose recorded cwd equals the bound workspace and which has at least one message. Missing, foreign, ambiguous or empty history is rejected before any model input. diff --git a/services/core/deploy/codex/README.md b/services/core/deploy/codex/README.md index 04e1c01bd..6637f2094 100644 --- a/services/core/deploy/codex/README.md +++ b/services/core/deploy/codex/README.md @@ -12,7 +12,7 @@ The adapter accepts only Codex `0.153.4`: installation and recovery checks requi Codex accepts only the `responses` model protocol ([`harnessconfig/codex`](../../../../internal/harnessconfig/codex/declaration.go)). A Chat Completions or Anthropic provider is rejected; nothing converts between protocols. [Model execution](../../../../contracts/agents-api/model-execution.md#deployment-defaults) owns provider selection, including the per-Harness deployment default. -Each Session has its own `CODEX_HOME` at `/daemon/agent-sessions//`, where the Runtime supplies `` from the [state directory setting](../../../../docs/configuration.md#runtime-resource-directories). Native history stays there; Runtime device credentials stay in the Runtime home. Codex's SQLite databases, `CODEX_SQLITE_HOME`, take the same path under the Runtime home on the compute's own disk, because on NFS workspace storage they made preparing each new Session about 1.1 s slower. Compute replacement loses nothing the Session needs: Codex rebuilds its thread index and history views from the rollouts in `CODEX_HOME` and reads a thread's dynamic tools from its rollout, goals are disabled as described below, and the adapter uses none of the other databases. The state layout alone does not qualify cross-node native continuation. The adapter regenerates that directory's `config.toml` on every prompt: the Session's frozen provider bundle becomes the `[model_providers.oac]` block, with `wire_api` `responses`, and the thread is pinned to that provider, so Codex never falls back to its built-in `openai` provider. Provider bearer credentials use the native `env_key` reference and enter the model-loop child process environment; they are not written into retained `config.toml`. The adapter excludes that variable from native shell environments with `shell_environment_policy.exclude` and disables `shell_snapshot`, whose capture in the pinned native version can otherwise persist the model-loop environment. This retains native thread history without retaining native shell snapshots; it introduces no user setting. +Each Session has its own `CODEX_HOME` at `/runtime/codex/state//` ([Harness protocol](../../../../contracts/agents-api/harness-onboarding.md) owns the rule), where the Runtime supplies `` from the [state directory setting](../../../../docs/configuration.md#runtime-resource-directories). Native history stays there; Runtime device credentials stay in the Runtime home. Codex's SQLite databases, `CODEX_SQLITE_HOME`, take the same path under `$OAC_RUNTIME_HOME` on the compute's own disk, because on NFS workspace storage they made preparing each new Session about 1.1 s slower. Compute replacement loses nothing the Session needs: Codex rebuilds its thread index and history views from the rollouts in `CODEX_HOME` and reads a thread's dynamic tools from its rollout, goals are disabled as described below, and the adapter uses none of the other databases. The state layout alone does not qualify cross-node native continuation. The adapter regenerates that directory's `config.toml` on every prompt: the Session's frozen provider bundle becomes the `[model_providers.oac]` block, with `wire_api` `responses`, and the thread is pinned to that provider, so Codex never falls back to its built-in `openai` provider. Provider bearer credentials use the native `env_key` reference and enter the model-loop child process environment; they are not written into retained `config.toml`. The adapter excludes that variable from native shell environments with `shell_environment_policy.exclude` and disables `shell_snapshot`, whose capture in the pinned native version can otherwise persist the model-loop environment. This retains native thread history without retaining native shell snapshots; it introduces no user setting. By default Codex retries a failed model connection forever, waiting up to 60 seconds between attempts, so a Turn whose model host is unreachable stays in progress with no events. The adapter disables that `unbounded_connection_retries` feature, so Codex falls back to its bounded defaults: up to 4 retries per request and 5 per response stream. A model host that does not resolve or refuses connections then fails the Turn with `connection_failed` within about a minute, depending on how long each lookup or connection attempt takes. Retries start only after a request fails, so slow responses are unaffected, and a stream still ends after Codex's 300 seconds without data. Codex sets no connect timeout: against a host that silently drops connections, each of the 30 attempts waits for the operating system's connect timeout. From e95b18af40ffba88ee2b10cdf9b31d57a4bf72fd Mon Sep 17 00:00:00 2001 From: SaladDay <1203511142@qq.com> Date: Thu, 5 Nov 2026 08:39:10 +0000 Subject: [PATCH 4/4] Keep every Claude Code native path under the key, and drop the unused discovery profile --- .../internal/agent/claudesdk/cancellation.go | 1 + .../internal/agent/claudesdk/declaration.go | 4 --- .../agent/claudesdk/declaration_test.go | 8 +++--- .../internal/agent/claudesdk/executor_turn.go | 5 ++-- .../agent/claudesdk/harness_config_test.go | 5 ++-- .../internal/agent/claudesdk/installation.go | 5 ++-- apps/daemon/internal/agent/claudesdk/local.go | 4 +-- .../internal/agent/claudesdk/options.go | 15 +++++----- .../agent/claudesdk/tool_environment_test.go | 4 +-- .../internal/agent/claudesdk/workspace.go | 28 ++++--------------- .../agent/claudesdk/workspace_test.go | 22 ++++++--------- apps/daemon/internal/agent/harness.go | 1 - apps/daemon/internal/cli/agent_discovery.go | 2 +- .../cli/connect_environment_binding.go | 2 +- packages/claude-sdk-adapter/README.md | 2 +- services/core/deploy/claude/README.md | 2 +- services/core/deploy/codex/README.md | 2 +- 17 files changed, 46 insertions(+), 66 deletions(-) diff --git a/apps/daemon/internal/agent/claudesdk/cancellation.go b/apps/daemon/internal/agent/claudesdk/cancellation.go index e3070a246..2162ba7ea 100644 --- a/apps/daemon/internal/agent/claudesdk/cancellation.go +++ b/apps/daemon/internal/agent/claudesdk/cancellation.go @@ -3,6 +3,7 @@ package claudesdk import ( "context" "errors" + "github.com/MiniMax-AI/OpenAgentCore/apps/daemon/internal/agent" "github.com/MiniMax-AI/OpenAgentCore/internal/agentdaemon/proto" diff --git a/apps/daemon/internal/agent/claudesdk/declaration.go b/apps/daemon/internal/agent/claudesdk/declaration.go index 27f9d7909..ec60ad383 100644 --- a/apps/daemon/internal/agent/claudesdk/declaration.go +++ b/apps/daemon/internal/agent/claudesdk/declaration.go @@ -9,7 +9,6 @@ import ( "github.com/MiniMax-AI/OpenAgentCore/apps/daemon/internal/agent" "github.com/MiniMax-AI/OpenAgentCore/apps/daemon/internal/localworkspace" - "github.com/MiniMax-AI/OpenAgentCore/apps/daemon/internal/paths" "github.com/MiniMax-AI/OpenAgentCore/internal/agentdaemon/proto" declaration "github.com/MiniMax-AI/OpenAgentCore/internal/harnessconfig/claudesdk" ) @@ -38,9 +37,6 @@ func discoverWithCheck(parent context.Context, options agent.DiscoveryOptions, d if !filepath.IsAbs(entrypoint) { return fail(fmt.Errorf("%s must be absolute", claudeSDKEntrypointEnv)) } - if err := paths.ValidateProfile(options.Profile); err != nil { - return fail(err) - } if !filepath.IsAbs(options.RuntimeRoot) || filepath.Clean(options.RuntimeRoot) != options.RuntimeRoot { return fail(fmt.Errorf("Claude SDK requires an explicit Runtime root")) } diff --git a/apps/daemon/internal/agent/claudesdk/declaration_test.go b/apps/daemon/internal/agent/claudesdk/declaration_test.go index c82020773..716bc6efb 100644 --- a/apps/daemon/internal/agent/claudesdk/declaration_test.go +++ b/apps/daemon/internal/agent/claudesdk/declaration_test.go @@ -26,7 +26,7 @@ func TestClaudeSDKInvalidPathsFailBeforeProbe(t *testing.T) { } else { root = "relative-home" } - out := discoverWithCheck(t.Context(), agent.DiscoveryOptions{RuntimeRoot: root, StateRoot: t.TempDir(), Profile: "default", Stdout: &strings.Builder{}, Stderr: &strings.Builder{}}, Declaration.Info(), func(context.Context, Config) (RuntimeInfo, error) { + out := discoverWithCheck(t.Context(), agent.DiscoveryOptions{RuntimeRoot: root, StateRoot: t.TempDir(), Stdout: &strings.Builder{}, Stderr: &strings.Builder{}}, Declaration.Info(), func(context.Context, Config) (RuntimeInfo, error) { t.Fatal("invalid paths reached runtime probe") return RuntimeInfo{}, nil }) @@ -47,7 +47,7 @@ func TestClaudeSDKFeatureDiscovery(t *testing.T) { } t.Setenv(claudeSDKNodeEnv, node) for _, features := range [][]string{nil, {"mcp_http_tools"}, {"mcp_http_bearer_auth"}, {"mcp_http_tools", "mcp_http_bearer_auth"}, {"mcp_http_required"}, {"mcp_http_tools", "mcp_http_required"}, {"subagent_resources"}, {"structured_output"}} { - out := discoverWithCheck(t.Context(), agent.DiscoveryOptions{RuntimeRoot: root, StateRoot: t.TempDir(), Profile: "default", Stdout: &strings.Builder{}, Stderr: &strings.Builder{}}, Declaration.Info(), func(context.Context, Config) (RuntimeInfo, error) { + out := discoverWithCheck(t.Context(), agent.DiscoveryOptions{RuntimeRoot: root, StateRoot: t.TempDir(), Stdout: &strings.Builder{}, Stderr: &strings.Builder{}}, Declaration.Info(), func(context.Context, Config) (RuntimeInfo, error) { info := RuntimeInfo{SDK: declaration.Declaration.Native.Version, Native: "native", Features: features} return info, nil }) @@ -75,7 +75,7 @@ func TestDiscoveryOnlyNarrowsDeclaration(t *testing.T) { } t.Setenv(claudeSDKNodeEnv, node) features := []string{"message_images", "function_result_images", "tool_search", "structured_output", "subagent_resources", "mcp_http_tools", "mcp_http_bearer_auth", "mcp_http_required"} - out := discoverWithCheck(t.Context(), agent.DiscoveryOptions{RuntimeRoot: root, StateRoot: t.TempDir(), Profile: "default", Stdout: io.Discard, Stderr: io.Discard}, Declaration.Info(), func(context.Context, Config) (RuntimeInfo, error) { + out := discoverWithCheck(t.Context(), agent.DiscoveryOptions{RuntimeRoot: root, StateRoot: t.TempDir(), Stdout: io.Discard, Stderr: io.Discard}, Declaration.Info(), func(context.Context, Config) (RuntimeInfo, error) { return RuntimeInfo{SDK: declaration.Declaration.Native.Version, Native: "native", Features: features}, nil }) if out == nil || !out.Info.Available { @@ -93,7 +93,7 @@ func TestRuntimeDiscoveryConfigurationAndRegistration(t *testing.T) { } t.Setenv(claudeSDKNodeEnv, node) entrypoint := filepath.Join(root, "bundle", "main.js") - options := agent.DiscoveryOptions{RuntimeRoot: root, StateRoot: t.TempDir(), Profile: "test", Stdout: io.Discard, Stderr: io.Discard} + options := agent.DiscoveryOptions{RuntimeRoot: root, StateRoot: t.TempDir(), Stdout: io.Discard, Stderr: io.Discard} for _, configured := range []bool{false, true} { for _, ready := range []bool{false, true} { t.Setenv(claudeSDKEntrypointEnv, "") diff --git a/apps/daemon/internal/agent/claudesdk/executor_turn.go b/apps/daemon/internal/agent/claudesdk/executor_turn.go index 418de6b14..c0505daa1 100644 --- a/apps/daemon/internal/agent/claudesdk/executor_turn.go +++ b/apps/daemon/internal/agent/claudesdk/executor_turn.go @@ -3,10 +3,11 @@ package claudesdk import ( "encoding/json" "fmt" - "github.com/MiniMax-AI/OpenAgentCore/apps/daemon/internal/agent" - "github.com/MiniMax-AI/OpenAgentCore/internal/agentdaemon/proto" "strings" "time" + + "github.com/MiniMax-AI/OpenAgentCore/apps/daemon/internal/agent" + "github.com/MiniMax-AI/OpenAgentCore/internal/agentdaemon/proto" ) func (s *session) runTurn(start startRequest, out chan<- proto.Envelope) { diff --git a/apps/daemon/internal/agent/claudesdk/harness_config_test.go b/apps/daemon/internal/agent/claudesdk/harness_config_test.go index f152b47ba..217ad31f8 100644 --- a/apps/daemon/internal/agent/claudesdk/harness_config_test.go +++ b/apps/daemon/internal/agent/claudesdk/harness_config_test.go @@ -2,10 +2,11 @@ package claudesdk import ( "encoding/json" - "github.com/MiniMax-AI/OpenAgentCore/internal/agentdaemon/proto" - "github.com/MiniMax-AI/OpenAgentCore/internal/harnessconfig" "path/filepath" "testing" + + "github.com/MiniMax-AI/OpenAgentCore/internal/agentdaemon/proto" + "github.com/MiniMax-AI/OpenAgentCore/internal/harnessconfig" ) func TestHarnessConfigReachesBridgeAndOwnsSnapshot(t *testing.T) { diff --git a/apps/daemon/internal/agent/claudesdk/installation.go b/apps/daemon/internal/agent/claudesdk/installation.go index c4b5fa196..83ebf0c21 100644 --- a/apps/daemon/internal/agent/claudesdk/installation.go +++ b/apps/daemon/internal/agent/claudesdk/installation.go @@ -3,10 +3,11 @@ package claudesdk import ( "context" "fmt" - "github.com/MiniMax-AI/OpenAgentCore/apps/daemon/internal/agent" - declaration "github.com/MiniMax-AI/OpenAgentCore/internal/harnessconfig/claudesdk" "path/filepath" "runtime" + + "github.com/MiniMax-AI/OpenAgentCore/apps/daemon/internal/agent" + declaration "github.com/MiniMax-AI/OpenAgentCore/internal/harnessconfig/claudesdk" ) func Installation() agent.Installation { diff --git a/apps/daemon/internal/agent/claudesdk/local.go b/apps/daemon/internal/agent/claudesdk/local.go index 76febac63..9e32aa6e3 100644 --- a/apps/daemon/internal/agent/claudesdk/local.go +++ b/apps/daemon/internal/agent/claudesdk/local.go @@ -6,8 +6,8 @@ import ( "strings" ) -// ConfigureLocal selects the qualified, dedicated Runtime layout. The shared -// localworkspace binding still authorizes every request against its Session. +// ConfigureLocal binds the dedicated workspace and keeps only the workspace +// environment names of the process environment. func ConfigureLocal(config Config, workspace string) (Config, error) { if !canonicalWorkspaceDir(workspace) { return Config{}, fmt.Errorf("claudesdk: invalid trusted workspace configuration") diff --git a/apps/daemon/internal/agent/claudesdk/options.go b/apps/daemon/internal/agent/claudesdk/options.go index 091ab368c..85750665c 100644 --- a/apps/daemon/internal/agent/claudesdk/options.go +++ b/apps/daemon/internal/agent/claudesdk/options.go @@ -127,8 +127,12 @@ func prepareConfiguration(config Config, req proto.PromptRequestPayload) (startR return fail("SDK entrypoint must be absolute") } config.Env = withProvider(config.Env, provider) + state, err := agent.StateDir(config.StateRoot, harnessconfiguration.Declaration.Kind, req.AgentStateKey) + if err != nil { + return fail(err.Error()) + } if config.Workspace != "" { - profile, env, err := prepareWorkspace(config, req) + profile, env, err := prepareWorkspace(config, req, state) if err != nil { return startRequest{}, nil, err } @@ -139,18 +143,15 @@ func prepareConfiguration(config Config, req proto.PromptRequestPayload) (startR if req.LocalEnvironment != nil || req.RequireExistingNativeSession { return fail("local execution and history recovery require a dedicated workspace") } - state, err := agent.StateDir(config.StateRoot, harnessconfiguration.Declaration.Kind, req.AgentStateKey) - if err != nil { - return fail(err.Error()) - } start.Cwd = filepath.Join(state, "work") - for _, dir := range []string{state, filepath.Join(state, "tmp"), start.Cwd} { + configDir, home, tmp := filepath.Join(state, "config"), filepath.Join(state, "home"), filepath.Join(state, "tmp") + for _, dir := range []string{start.Cwd, configDir, home, tmp} { if err := os.MkdirAll(dir, 0o700); err != nil { return startRequest{}, nil, err } } env := withProvider(append(append([]string{}, os.Environ()...), config.Env...), provider) - env = append(env, "CLAUDE_CONFIG_DIR="+state, "TMPDIR="+filepath.Join(state, "tmp")) + env = append(env, "CLAUDE_CONFIG_DIR="+configDir, "HOME="+home, "TMPDIR="+tmp) projectedMCP, mcpEnv, err := prepareRuntimeMCP(req) if err != nil { return startRequest{}, nil, err diff --git a/apps/daemon/internal/agent/claudesdk/tool_environment_test.go b/apps/daemon/internal/agent/claudesdk/tool_environment_test.go index 5724c1259..26a1cb0f6 100644 --- a/apps/daemon/internal/agent/claudesdk/tool_environment_test.go +++ b/apps/daemon/internal/agent/claudesdk/tool_environment_test.go @@ -17,7 +17,7 @@ func TestSelfHostedToolEnvironment(t *testing.T) { t.Setenv("OAC_RUNTIME_TOOL_ENV_FILE", file) req := workspaceRequest() req.LocalEnvironment = &proto.LocalEnvironment{WorkspaceRoot: config.Workspace} - profile, _, err := prepareWorkspace(config, req) + profile, _, err := prepareWorkspace(config, req, testState(config)) if err != nil { t.Fatal(err) } @@ -27,7 +27,7 @@ func TestSelfHostedToolEnvironment(t *testing.T) { if err := os.WriteFile(file, []byte(`[]`), 0600); err != nil { t.Fatal(err) } - if _, _, err := prepareWorkspace(config, req); err == nil { + if _, _, err := prepareWorkspace(config, req, testState(config)); err == nil { t.Fatal("invalid explicit tool configuration was ignored") } } diff --git a/apps/daemon/internal/agent/claudesdk/workspace.go b/apps/daemon/internal/agent/claudesdk/workspace.go index 27de2070e..b4cf995d3 100644 --- a/apps/daemon/internal/agent/claudesdk/workspace.go +++ b/apps/daemon/internal/agent/claudesdk/workspace.go @@ -24,7 +24,7 @@ type workspaceProfile struct { EnvNames []string `json:"env_names"` } -func prepareWorkspace(config Config, req proto.PromptRequestPayload) (*workspaceProfile, []string, error) { +func prepareWorkspace(config Config, req proto.PromptRequestPayload, state string) (*workspaceProfile, []string, error) { if req.DisableExecutionEnvironment { return nil, nil, fmt.Errorf("claudesdk: workspace profile does not support the requested execution combination") } @@ -32,21 +32,17 @@ func prepareWorkspace(config Config, req proto.PromptRequestPayload) (*workspace return nil, nil, fmt.Errorf("claudesdk: workspace root conflicts with the trusted workspace binding") } // Retained history lives with the Environment filesystem; HOME and scratch stay on the Runtime's own disk. - kind := harnessconfiguration.Declaration.Kind - state, err := agent.StateDir(config.StateRoot, kind, req.AgentStateKey) + runtime, err := agent.StateDir(config.RuntimeRoot, harnessconfiguration.Declaration.Kind, req.AgentStateKey) if err != nil { return nil, nil, err } - runtime, err := agent.StateDir(config.RuntimeRoot, kind, req.AgentStateKey) - if err != nil { - return nil, nil, err - } - dirs := workspaceDirs{State: state, Home: filepath.Join(runtime, "home"), Scratch: filepath.Join(runtime, "scratch")} - profile, env, err := workspaceEnvironment(config, dirs) + env, names, err := trustedEnvironment(config) if err != nil { return nil, nil, err } - for _, dir := range []string{dirs.State, dirs.Home, dirs.Scratch} { + profile := &workspaceProfile{Home: filepath.Join(runtime, "home"), State: filepath.Join(state, "config"), Scratch: filepath.Join(runtime, "scratch"), EnvNames: names} + env = append(env, "HOME="+profile.Home, "TMPDIR="+profile.Scratch, "CLAUDE_CONFIG_DIR="+profile.State) + for _, dir := range []string{profile.State, profile.Home, profile.Scratch} { if err = os.MkdirAll(dir, 0o700); err != nil { return nil, nil, err } @@ -70,18 +66,6 @@ func prepareWorkspace(config Config, req proto.PromptRequestPayload) (*workspace return profile, append(env, credentials...), nil } -type workspaceDirs struct{ State, Home, Scratch string } - -// Harness state paths and selected model credentials overlay the user environment. -func workspaceEnvironment(config Config, dirs workspaceDirs) (*workspaceProfile, []string, error) { - env, names, err := trustedEnvironment(config) - if err != nil { - return nil, nil, err - } - env = append(env, "HOME="+dirs.Home, "TMPDIR="+dirs.Scratch, "CLAUDE_CONFIG_DIR="+dirs.State) - return &workspaceProfile{Home: dirs.Home, State: dirs.State, Scratch: dirs.Scratch, EnvNames: names}, env, nil -} - // trustedEnvironment validates the trusted binding and returns the user environment without Harness state paths or credentials. func trustedEnvironment(config Config) ([]string, []string, error) { fail := func() ([]string, []string, error) { diff --git a/apps/daemon/internal/agent/claudesdk/workspace_test.go b/apps/daemon/internal/agent/claudesdk/workspace_test.go index 88876f241..ccf4f3b9e 100644 --- a/apps/daemon/internal/agent/claudesdk/workspace_test.go +++ b/apps/daemon/internal/agent/claudesdk/workspace_test.go @@ -88,7 +88,7 @@ func TestSessionsKeepNativeStateInOwnDirectories(t *testing.T) { } state, _ := agent.StateDir(config.StateRoot, harnessconfiguration.Declaration.Kind, key) runtime, _ := agent.StateDir(config.RuntimeRoot, harnessconfiguration.Declaration.Kind, key) - for name, want := range map[string]string{"CLAUDE_CONFIG_DIR": state, "HOME": filepath.Join(runtime, "home"), "TMPDIR": filepath.Join(runtime, "scratch")} { + for name, want := range map[string]string{"CLAUDE_CONFIG_DIR": filepath.Join(state, "config"), "HOME": filepath.Join(runtime, "home"), "TMPDIR": filepath.Join(runtime, "scratch")} { if !slices.Contains(env, name+"="+want) { t.Fatalf("%s for %s is not %s", name, key, want) } @@ -98,6 +98,13 @@ func TestSessionsKeepNativeStateInOwnDirectories(t *testing.T) { seen[want] = key } } + // Without a workspace, every native path, HOME included, is under the key too. + config.Workspace = "" + _, env, err := prepareConfiguration(config, req) + state, _ := agent.StateDir(config.StateRoot, harnessconfiguration.Declaration.Kind, req.AgentStateKey) + if err != nil || !slices.Contains(env, "HOME="+filepath.Join(state, "home")) || !slices.Contains(env, "CLAUDE_CONFIG_DIR="+filepath.Join(state, "config")) { + t.Fatal("environment none keeps native state outside the key", err) + } } func TestWorkspaceRejectsConflictsBeforeSideEffects(t *testing.T) { @@ -116,17 +123,6 @@ func TestWorkspaceRejectsConflictsBeforeSideEffects(t *testing.T) { config.Workspace = "relative" case "missing": config.Workspace = filepath.Join(config.Workspace, "missing") - case "symlink": - alias := filepath.Join(filepath.Dir(testState(config)), "alias") - if err := os.Symlink(config.Workspace, alias); err != nil { - t.Fatal(err) - } - config.Workspace = alias - case "rule-pattern": - config.Workspace += "*" - if err := os.Mkdir(config.Workspace, 0o700); err != nil { - t.Fatal(err) - } case "ambient-setting": config.Env = append(config.Env, "NODE_OPTIONS=--require=untrusted") case "duplicate-env": @@ -202,5 +198,5 @@ func testState(config Config) string { if err != nil { panic(err) } - return dir + return filepath.Join(dir, "config") } diff --git a/apps/daemon/internal/agent/harness.go b/apps/daemon/internal/agent/harness.go index 27a0c0d15..4e1d98d45 100644 --- a/apps/daemon/internal/agent/harness.go +++ b/apps/daemon/internal/agent/harness.go @@ -78,7 +78,6 @@ func Narrow(capability *proto.CapabilitySupport, provided bool) { // DiscoveryOptions provides process context without naming an implementation. type DiscoveryOptions struct { - Profile string // StateRoot holds private history retained with the Environment filesystem. StateRoot string // RuntimeRoot holds instance-private configuration and process files. diff --git a/apps/daemon/internal/cli/agent_discovery.go b/apps/daemon/internal/cli/agent_discovery.go index 83c5d2bae..6d28d58aa 100644 --- a/apps/daemon/internal/cli/agent_discovery.go +++ b/apps/daemon/internal/cli/agent_discovery.go @@ -42,7 +42,7 @@ func discoverAgentCLIs(parent context.Context, rc *runContext, profile string, d } running.Go(func() { p := &probes[i] - p.runtime = declaration.Discover(parent, agent.DiscoveryOptions{Profile: profile, StateRoot: stateRoot, RuntimeRoot: runtimeRoot, Stdout: &p.stdout, Stderr: &p.stderr}, declaration.Info()) + p.runtime = declaration.Discover(parent, agent.DiscoveryOptions{StateRoot: stateRoot, RuntimeRoot: runtimeRoot, Stdout: &p.stdout, Stderr: &p.stderr}, declaration.Info()) }) } running.Wait() diff --git a/apps/daemon/internal/cli/connect_environment_binding.go b/apps/daemon/internal/cli/connect_environment_binding.go index cb0a04281..f30472381 100644 --- a/apps/daemon/internal/cli/connect_environment_binding.go +++ b/apps/daemon/internal/cli/connect_environment_binding.go @@ -105,7 +105,7 @@ func saveEnvironmentBinding(root string, want environmentBinding) error { } for _, entry := range profiles { if entry.IsDir() { - for _, name := range []string{"auth.json", "sessions.json", "runtime"} { + for _, name := range []string{"auth.json", "sessions.json"} { if _, e := os.Lstat(filepath.Join(dir, entry.Name(), name)); !errors.Is(e, os.ErrNotExist) { return errors.New("connect: existing profile has no Environment binding") } diff --git a/packages/claude-sdk-adapter/README.md b/packages/claude-sdk-adapter/README.md index 86a58ad6d..cbed18d56 100644 --- a/packages/claude-sdk-adapter/README.md +++ b/packages/claude-sdk-adapter/README.md @@ -26,7 +26,7 @@ It also emits the neutral `output_message` start/completion snapshots and tags d ### Workspace execution -`claudesdk.Config.Workspace` is an operator binding for the selected workspace and native state. It runs the native `claude_code` tool preset and system prompt, with the request's instructions appended, plus admitted host functions in the SDK loop. Ask-user, scheduling, workflow and background tools are removed because no one answers or waits, `ListAgents` is removed because it reaches other Sessions' native processes on the host, and `WebSearch` and `WebFetch` are removed when the web search control is `disabled`. The native setting sources and MCP discovery apply unchanged, so a workspace's project settings and hooks run with the Session's provider key ([model execution](../../contracts/agents-api/model-execution.md#session-override)), and native telemetry stays on; the adapter keeps private `CLAUDE_CONFIG_DIR`, `HOME` and `TMPDIR`. On every placement `DISABLE_AUTOUPDATER` and `CLAUDE_CODE_DISABLE_BACKGROUND_TASKS` are flag settings, which outrank user and project settings, and the bridge runs with Node's `--use-env-proxy`, which native tools do not inherit, so that its own fetch honours the operator's proxy. Native tools run with the launching user's permissions on all platforms; the adapter adds no inner sandbox, protected-root deny policy or managed shell wrapper. Isolation belongs to the outer Environment ([Runtime and outer isolation](../../docs/concepts.md#runtime-and-outer-isolation)), which must exclude other tenants' and broader application credentials; an ordinary native install provides no such boundary, and directory selection is not tenant authorization. The adapter's tool callback approves every tool for unattended execution. It does not use the CLI permission-bypass flag, which Claude rejects for root accounts. Environment Skills install as symlinks under `/skills`, as a local `~/.claude/skills` does, so native lists their bare names and runs inline shell under that callback. +`claudesdk.Config.Workspace` is the operator-bound workspace directory. It runs the native `claude_code` tool preset and system prompt, with the request's instructions appended, plus admitted host functions in the SDK loop. Ask-user, scheduling, workflow and background tools are removed because no one answers or waits, `ListAgents` is removed because it reaches other Sessions' native processes on the host, and `WebSearch` and `WebFetch` are removed when the web search control is `disabled`. The native setting sources and MCP discovery apply unchanged, so a workspace's project settings and hooks run with the Session's provider key ([model execution](../../contracts/agents-api/model-execution.md#session-override)), and native telemetry stays on; the adapter keeps private `CLAUDE_CONFIG_DIR`, `HOME` and `TMPDIR`. On every placement `DISABLE_AUTOUPDATER` and `CLAUDE_CODE_DISABLE_BACKGROUND_TASKS` are flag settings, which outrank user and project settings, and the bridge runs with Node's `--use-env-proxy`, which native tools do not inherit, so that its own fetch honours the operator's proxy. Native tools run with the launching user's permissions on all platforms; the adapter adds no inner sandbox, protected-root deny policy or managed shell wrapper. Isolation belongs to the outer Environment ([Runtime and outer isolation](../../docs/concepts.md#runtime-and-outer-isolation)), which must exclude other tenants' and broader application credentials; an ordinary native install provides no such boundary, and directory selection is not tenant authorization. The adapter's tool callback approves every tool for unattended execution. It does not use the CLI permission-bypass flag, which Claude rejects for root accounts. Environment Skills install as symlinks under `/skills`, as a local `~/.claude/skills` does, so native lists their bare names and runs inline shell under that callback. `Config.Env` selects readiness and native process variables. Explicit tool env is applied to tool execution, but same-user tools can still read credentials or history from local files. Workspace hooks keep their event, identity and lifecycle responsibilities; they are not security enforcement. Process groups and Windows Jobs provide cancellation and descendant cleanup, not isolation. Supported MCP and subagent combinations require their own qualification. The `none` profile keeps its tool inventory. The bridge emits neutral `search_observation` events for root `WebSearch` (action `search`) and `WebFetch` (action `open_page`) calls. Packaged `workspace_tools` establishes bridge support, not outer host isolation or public API admission. The dedicated Runtime composes public preparation, placement quotas, command Items and Files ownership. Real-provider acceptance verifies effects, cancellation and same-history continuation for the actual platform and outer deployment. diff --git a/services/core/deploy/claude/README.md b/services/core/deploy/claude/README.md index b1c83e033..a22093f66 100644 --- a/services/core/deploy/claude/README.md +++ b/services/core/deploy/claude/README.md @@ -16,7 +16,7 @@ Claude accepts only the `anthropic` model protocol ([`harnessconfig/claudesdk`]( ## Native state and recovery -Each Session keeps its native state under its own state key, as the [Harness protocol](../../../../contracts/agents-api/harness-onboarding.md) requires. The SDK's `CLAUDE_CONFIG_DIR`, which holds retained history, is `/runtime/claude_sdk/state//`, where the Runtime supplies `` from the [state directory setting](../../../../docs/configuration.md#runtime-resource-directories). With a workspace binding, the private launch home and scratch directory are `home` and `scratch` under `$OAC_RUNTIME_HOME/runtime/claude_sdk/state//` (mode 0700). The daemon's authentication stays under `$OAC_RUNTIME_HOME/daemon/`. These locations keep native history, temporary process state and device credentials separate; they do not restrict the tools. +Each Session keeps its native state under its own state key, as the [Harness protocol](../../../../contracts/agents-api/harness-onboarding.md#register-the-adapter) requires. The SDK's `CLAUDE_CONFIG_DIR`, which holds retained history, is `config` under `/runtime/claude_sdk/state//`, where the Runtime supplies `` from the [state directory setting](../../../../docs/configuration.md#runtime-resource-directories). With a workspace binding, the private launch home and scratch directory are `home` and `scratch` under `$OAC_RUNTIME_HOME/runtime/claude_sdk/state//` (mode 0700). The daemon's authentication stays under `$OAC_RUNTIME_HOME/daemon/`. These locations keep native history, temporary process state and device credentials separate; they do not restrict the tools. Recovery uses the SDK's history APIs ([`recovery.ts`](../../../../packages/claude-sdk-adapter/src/recovery.ts)). An explicitly supplied native Session ID must exist. When Core requires existing history but has no recorded ID, the adapter accepts only a single native Session whose recorded cwd equals the bound workspace and which has at least one message. Missing, foreign, ambiguous or empty history is rejected before any model input. diff --git a/services/core/deploy/codex/README.md b/services/core/deploy/codex/README.md index 6637f2094..c50daeeeb 100644 --- a/services/core/deploy/codex/README.md +++ b/services/core/deploy/codex/README.md @@ -12,7 +12,7 @@ The adapter accepts only Codex `0.153.4`: installation and recovery checks requi Codex accepts only the `responses` model protocol ([`harnessconfig/codex`](../../../../internal/harnessconfig/codex/declaration.go)). A Chat Completions or Anthropic provider is rejected; nothing converts between protocols. [Model execution](../../../../contracts/agents-api/model-execution.md#deployment-defaults) owns provider selection, including the per-Harness deployment default. -Each Session has its own `CODEX_HOME` at `/runtime/codex/state//` ([Harness protocol](../../../../contracts/agents-api/harness-onboarding.md) owns the rule), where the Runtime supplies `` from the [state directory setting](../../../../docs/configuration.md#runtime-resource-directories). Native history stays there; Runtime device credentials stay in the Runtime home. Codex's SQLite databases, `CODEX_SQLITE_HOME`, take the same path under `$OAC_RUNTIME_HOME` on the compute's own disk, because on NFS workspace storage they made preparing each new Session about 1.1 s slower. Compute replacement loses nothing the Session needs: Codex rebuilds its thread index and history views from the rollouts in `CODEX_HOME` and reads a thread's dynamic tools from its rollout, goals are disabled as described below, and the adapter uses none of the other databases. The state layout alone does not qualify cross-node native continuation. The adapter regenerates that directory's `config.toml` on every prompt: the Session's frozen provider bundle becomes the `[model_providers.oac]` block, with `wire_api` `responses`, and the thread is pinned to that provider, so Codex never falls back to its built-in `openai` provider. Provider bearer credentials use the native `env_key` reference and enter the model-loop child process environment; they are not written into retained `config.toml`. The adapter excludes that variable from native shell environments with `shell_environment_policy.exclude` and disables `shell_snapshot`, whose capture in the pinned native version can otherwise persist the model-loop environment. This retains native thread history without retaining native shell snapshots; it introduces no user setting. +Each Session has its own `CODEX_HOME` at `/runtime/codex/state//` ([Harness protocol](../../../../contracts/agents-api/harness-onboarding.md#register-the-adapter) owns the rule), where the Runtime supplies `` from the [state directory setting](../../../../docs/configuration.md#runtime-resource-directories). Native history stays there; Runtime device credentials stay in the Runtime home. Codex's SQLite databases, `CODEX_SQLITE_HOME`, take the same path under `$OAC_RUNTIME_HOME` on the compute's own disk, because on NFS workspace storage they made preparing each new Session about 1.1 s slower. Compute replacement loses nothing the Session needs: Codex rebuilds its thread index and history views from the rollouts in `CODEX_HOME` and reads a thread's dynamic tools from its rollout, goals are disabled as described below, and the adapter uses none of the other databases. The state layout alone does not qualify cross-node native continuation. The adapter regenerates that directory's `config.toml` on every prompt: the Session's frozen provider bundle becomes the `[model_providers.oac]` block, with `wire_api` `responses`, and the thread is pinned to that provider, so Codex never falls back to its built-in `openai` provider. Provider bearer credentials use the native `env_key` reference and enter the model-loop child process environment; they are not written into retained `config.toml`. The adapter excludes that variable from native shell environments with `shell_environment_policy.exclude` and disables `shell_snapshot`, whose capture in the pinned native version can otherwise persist the model-loop environment. This retains native thread history without retaining native shell snapshots; it introduces no user setting. By default Codex retries a failed model connection forever, waiting up to 60 seconds between attempts, so a Turn whose model host is unreachable stays in progress with no events. The adapter disables that `unbounded_connection_retries` feature, so Codex falls back to its bounded defaults: up to 4 retries per request and 5 per response stream. A model host that does not resolve or refuses connections then fails the Turn with `connection_failed` within about a minute, depending on how long each lookup or connection attempt takes. Retries start only after a request fails, so slow responses are unaffected, and a stream still ends after Codex's 300 seconds without data. Codex sets no connect timeout: against a host that silently drops connections, each of the 30 attempts waits for the operating system's connect timeout.