diff --git a/apps/daemon/internal/agent/claudesdk/execution_controls_test.go b/apps/daemon/internal/agent/claudesdk/execution_controls_test.go index 84786b5a7..1cf817d6c 100644 --- a/apps/daemon/internal/agent/claudesdk/execution_controls_test.go +++ b/apps/daemon/internal/agent/claudesdk/execution_controls_test.go @@ -22,16 +22,26 @@ func TestExecutionControlsPreserveNativeDefaultsAndInstructions(t *testing.T) { if err != nil { t.Fatal(err) } - request.ExecutionControls = &proto.ExecutionControls{TextVerbosity: "medium"} + request.ExecutionControls = &proto.ExecutionControls{WebSearch: &proto.WebSearchControl{Mode: "disabled", ContextSize: "medium"}, TextVerbosity: "medium"} before, _ := json.Marshal(request) controlled, _, err := prepare(config, request) if err != nil { t.Fatal(err) } after, _ := json.Marshal(request) + if !controlled.DisableWebSearch || ordinary.DisableWebSearch { + t.Fatal("disabled web search did not reach the bridge") + } + controlled.DisableWebSearch = false if !reflect.DeepEqual(ordinary, controlled) || string(before) != string(after) { t.Fatal("default controls changed native input, instructions, continuation or caller options") } + // Without a search control Claude Code keeps its native web tools. + request.ExecutionControls = &proto.ExecutionControls{TextVerbosity: "medium"} + absent, _, err := prepare(config, request) + if err != nil || !reflect.DeepEqual(ordinary, absent) { + t.Fatal("absent search control changed the native configuration", err) + } } func TestExecutionControlsRejectUnsupportedProfilesBeforeLaunch(t *testing.T) { diff --git a/apps/daemon/internal/agent/claudesdk/executor_turn.go b/apps/daemon/internal/agent/claudesdk/executor_turn.go index 97c6638cf..7841a023b 100644 --- a/apps/daemon/internal/agent/claudesdk/executor_turn.go +++ b/apps/daemon/internal/agent/claudesdk/executor_turn.go @@ -57,6 +57,7 @@ func (s *session) runTurn(start startRequest, out chan<- proto.Envelope) { terminal := false mcp := mcpState{calls: map[string]proto.ToolObservation{}} commands := commandState{calls: map[string]proto.ToolObservation{}} + searches := searchState{calls: map[string]proto.ToolObservation{}} settlementReceived := false settlementConfirmed := false cancelled := false @@ -92,6 +93,11 @@ func (s *session) runTurn(start startRequest, out chan<- proto.Envelope) { failure = err s.invalidate() } + case "search_observation": + if err := searches.receive(event, start, s.inputSessionID(), emit); err != nil { + failure = err + s.invalidate() + } case "mcp_observation": if err := mcp.receive(event, start, emit); err != nil { failure = err @@ -159,7 +165,7 @@ func (s *session) runTurn(start startRequest, out chan<- proto.Envelope) { } emit(proto.TypeUsage, proto.UsagePayload{Usage: usage}) case "result": - if !s.matchesInputSession(event.SessionID) || event.SessionID == "" || start.Resume != "" && event.SessionID != start.Resume || usageSession != "" && event.SessionID != usageSession || !s.functionsComplete(false) || !s.steeringComplete() || !mcp.complete() || !commands.complete() { + if !s.matchesInputSession(event.SessionID) || event.SessionID == "" || start.Resume != "" && event.SessionID != start.Resume || usageSession != "" && event.SessionID != usageSession || !s.functionsComplete(false) || !s.steeringComplete() || !mcp.complete() || !commands.complete() || !searches.complete() { failure = fmt.Errorf("claudesdk: invalid native completion or unconfirmed input/result") s.settlementErr = failure s.invalidate() @@ -194,11 +200,12 @@ func (s *session) runTurn(start startRequest, out chan<- proto.Envelope) { } // Close result admission before deciding which unanswered calls cancellation settled. s.stopFunctions() - if !s.functionsComplete(cancelled && settlementConfirmed) || !s.steeringComplete() || !mcp.complete() || !commands.complete() { + if !s.functionsComplete(cancelled && settlementConfirmed) || !s.steeringComplete() || !mcp.complete() || !commands.complete() || !searches.complete() { settlementConfirmed, reusable, reason = false, false, "unsettled_native_operations" } mcp.close(emit) commands.close(emit) + searches.close(emit) s.stopSteering() metadata := map[string]any{proto.DoneMetaAgentSessionType: "claude_session"} if id := s.inputSessionID(); id != "" { diff --git a/apps/daemon/internal/agent/claudesdk/options.go b/apps/daemon/internal/agent/claudesdk/options.go index 9e267210f..b542f8950 100644 --- a/apps/daemon/internal/agent/claudesdk/options.go +++ b/apps/daemon/internal/agent/claudesdk/options.go @@ -25,6 +25,7 @@ type subagentOptions struct { type startRequest struct { NativeModelOptions *nativeModelOptions `json:"native_model_options,omitempty"` ToolSearch bool `json:"tool_search,omitempty"` + DisableWebSearch bool `json:"disable_web_search,omitempty"` Subagents *subagentOptions `json:"subagents,omitempty"` OutputFormat *proto.OutputFormat `json:"output_format,omitempty"` Type string `json:"type"` @@ -81,6 +82,10 @@ func prepareConfiguration(config Config, req proto.PromptRequestPayload) (startR if err := validateMCP(req); err != nil { return startRequest{}, nil, err } + // Without a search control Claude Code keeps its native web tools. + if controls := req.ExecutionControls; controls != nil && controls.WebSearch != nil { + start.DisableWebSearch = controls.WebSearch.Mode == "disabled" + } if req.ExecutionControls != nil && req.ExecutionControls.OutputFormat != nil { format := req.ExecutionControls.OutputFormat if format.Type != "json_schema" || !req.ObserveMessages || !req.DisableSubagents || req.MCPHTTPServers != nil || (req.LocalEnvironment != nil && (len(req.LocalEnvironment.MCP) != 0 || len(req.LocalEnvironment.Skills) != 0)) { @@ -163,7 +168,7 @@ func prepareConfiguration(config Config, req proto.PromptRequestPayload) (startR } } env := withProvider(append(append([]string{}, os.Environ()...), config.Env...), provider) - env = append(env, "CLAUDE_CONFIG_DIR="+config.StateDir, "TMPDIR="+filepath.Join(config.StateDir, "tmp"), "DISABLE_TELEMETRY=1", "CLAUDE_CODE_DISABLE_NONESSENTIAL_TRAFFIC=1") + env = append(env, "CLAUDE_CONFIG_DIR="+config.StateDir, "TMPDIR="+filepath.Join(config.StateDir, "tmp")) projectedMCP, mcpEnv, err := prepareRuntimeMCP(req) if err != nil { return startRequest{}, nil, err diff --git a/apps/daemon/internal/agent/claudesdk/searches.go b/apps/daemon/internal/agent/claudesdk/searches.go new file mode 100644 index 000000000..3c7930751 --- /dev/null +++ b/apps/daemon/internal/agent/claudesdk/searches.go @@ -0,0 +1,52 @@ +package claudesdk + +import ( + "fmt" + + "github.com/MiniMax-AI/OpenAgentCore/internal/agentdaemon/proto" +) + +type searchState struct { + calls map[string]proto.ToolObservation +} + +func (c *searchState) receive(event bridgeEvent, start startRequest, sessionID string, emit func(string, any)) error { + n := event.Observation + if start.Workspace == nil || sessionID == "" || event.SessionID != sessionID || event.ID == "" || n == nil || + n.Kind != "web_search" || n.Action == nil || (n.Action.Type != "search" && n.Action.Type != "open_page") || + (n.Action.Type == "search") != (n.Action.Query != nil) || (n.Action.Type == "open_page") != (n.Action.URL != nil) || + n.Name != "" || n.Command != "" || n.Cwd != nil || n.ExitCode != nil || n.DurationMS != nil || n.Server != "" || + len(n.Arguments) != 0 || len(n.Output) != 0 || len(n.Error) != 0 || n.Content != nil { + return fmt.Errorf("claudesdk: invalid web search observation") + } + previous, exists := c.calls[event.ID] + if (event.Stage == "before" && (exists || n.Status != "in_progress")) || + (event.Stage == "after" && (!exists || previous.Status != "in_progress" || + (n.Status != "completed" && n.Status != "failed" && n.Status != "incomplete"))) || + (event.Stage != "before" && event.Stage != "after") { + return fmt.Errorf("claudesdk: inconsistent web search observation") + } + c.calls[event.ID] = *n + emit(proto.TypeToolCall, proto.ToolCallPayload{ID: event.ID, Name: "web_search", Stage: event.Stage, Observation: n}) + return nil +} + +func (c *searchState) complete() bool { + for _, call := range c.calls { + if call.Status == "in_progress" { + return false + } + } + return true +} + +func (c *searchState) close(emit func(string, any)) { + for id, call := range c.calls { + if call.Status != "in_progress" { + continue + } + call.Status = "incomplete" + c.calls[id] = call + emit(proto.TypeToolCall, proto.ToolCallPayload{ID: id, Name: "web_search", Stage: "after", Observation: &call}) + } +} diff --git a/apps/daemon/internal/agent/claudesdk/searches_test.go b/apps/daemon/internal/agent/claudesdk/searches_test.go new file mode 100644 index 000000000..333712472 --- /dev/null +++ b/apps/daemon/internal/agent/claudesdk/searches_test.go @@ -0,0 +1,47 @@ +package claudesdk + +import ( + "testing" + + "github.com/MiniMax-AI/OpenAgentCore/internal/agentdaemon/proto" +) + +func searchEvent(id, stage, status string, action *proto.ToolWebSearchAction) bridgeEvent { + return bridgeEvent{Type: "search_observation", SessionID: "native-session", ID: id, Stage: stage, + Observation: &proto.ToolObservation{Kind: "web_search", Status: status, Action: action}} +} + +func TestSearchObservationLifecycle(t *testing.T) { + state := searchState{calls: map[string]proto.ToolObservation{}} + start := startRequest{Workspace: &workspaceProfile{}} + var events []proto.ToolCallPayload + emit := func(kind string, payload any) { events = append(events, payload.(proto.ToolCallPayload)) } + query, url := "agents api", "https://example.com" + search := &proto.ToolWebSearchAction{Type: "search", Query: &query} + if err := state.receive(searchEvent("one", "before", "in_progress", search), start, "native-session", emit); err != nil || state.complete() { + t.Fatal("search was not pending", err) + } + if err := state.receive(searchEvent("one", "before", "in_progress", search), start, "native-session", emit); err == nil { + t.Fatal("duplicate search accepted") + } + if err := state.receive(searchEvent("one", "after", "completed", search), start, "native-session", emit); err != nil || !state.complete() { + t.Fatal("search did not complete", err) + } + open := &proto.ToolWebSearchAction{Type: "open_page", URL: &url} + if err := state.receive(searchEvent("two", "before", "in_progress", open), start, "native-session", emit); err != nil { + t.Fatal(err) + } + state.close(emit) + if state.calls["two"].Status != "incomplete" || len(events) != 4 || events[3].Observation.Action.URL == nil { + t.Fatal("unfinished page open was not closed", events) + } + for name, event := range map[string]bridgeEvent{ + "action": searchEvent("bad", "before", "in_progress", &proto.ToolWebSearchAction{Type: "find_in_page", Pattern: &query}), + "missing": searchEvent("bad", "before", "in_progress", &proto.ToolWebSearchAction{Type: "search"}), + "session": {Type: "search_observation", SessionID: "other", ID: "bad", Stage: "before", Observation: &proto.ToolObservation{Kind: "web_search", Status: "in_progress", Action: search}}, + } { + if err := state.receive(event, start, "native-session", emit); err == nil { + t.Fatal("invalid observation accepted", name) + } + } +} diff --git a/apps/daemon/internal/agent/claudesdk/session.go b/apps/daemon/internal/agent/claudesdk/session.go index f252ce14f..5872d6f8c 100644 --- a/apps/daemon/internal/agent/claudesdk/session.go +++ b/apps/daemon/internal/agent/claudesdk/session.go @@ -65,7 +65,9 @@ func launch(ctx context.Context, config Config, start startRequest, env []string if binary == "" { binary = "node" } - process, err := clirunner.Start(clirunner.StartOptions{Parent: ctx, Binary: binary, Args: []string{config.Entrypoint}, Dir: start.Cwd, Env: env, NeedStdin: true}) + // Node's fetch honours the operator's proxy only when asked; unlike NODE_USE_ENV_PROXY, the flag stays with + // the bridge, so native tools run with the environment they would have locally. + process, err := clirunner.Start(clirunner.StartOptions{Parent: ctx, Binary: binary, Args: []string{"--use-env-proxy", config.Entrypoint}, Dir: start.Cwd, Env: env, NeedStdin: true}) if err != nil { return nil, err } diff --git a/apps/daemon/internal/agent/claudesdk/workspace.go b/apps/daemon/internal/agent/claudesdk/workspace.go index ba63e73de..f755acb6f 100644 --- a/apps/daemon/internal/agent/claudesdk/workspace.go +++ b/apps/daemon/internal/agent/claudesdk/workspace.go @@ -114,7 +114,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, "DISABLE_TELEMETRY=1", "DISABLE_ERROR_REPORTING=1", "DISABLE_AUTOUPDATER=1", "CLAUDE_CODE_DISABLE_NONESSENTIAL_TRAFFIC=1", "CLAUDE_CODE_DISABLE_BACKGROUND_TASKS=1") + env = append(env, "HOME="+w.HomeDir, "TMPDIR="+w.ScratchDir, "CLAUDE_CONFIG_DIR="+config.StateDir) seen := map[string]bool{} for _, entry := range config.Env { name, _, ok := strings.Cut(entry, "=") diff --git a/apps/daemon/internal/agent/mcode/options.go b/apps/daemon/internal/agent/mcode/options.go index 318d086db..22e49a66c 100644 --- a/apps/daemon/internal/agent/mcode/options.go +++ b/apps/daemon/internal/agent/mcode/options.go @@ -64,7 +64,6 @@ func prepareOptions(runtimeRoot string, req proto.PromptRequestPayload) (launchO "features": map[string]bool{"mavis": false, "delegation": true, "webSearch": false}, }} } - config["permissionMode"] = "auto" data, err := json.Marshal(config) if err != nil { return result, err diff --git a/apps/daemon/internal/agent/mcode/options_test.go b/apps/daemon/internal/agent/mcode/options_test.go index 5018a743f..6334e978d 100644 --- a/apps/daemon/internal/agent/mcode/options_test.go +++ b/apps/daemon/internal/agent/mcode/options_test.go @@ -50,8 +50,8 @@ func TestOptionsRefreshManagedState(t *testing.T) { if json.Unmarshal(data, &cfg) != nil { t.Fatal("invalid config") } - if cfg["permissionMode"] != "auto" { - t.Fatal("native permission mode changed") + if _, set := cfg["permissionMode"]; set { + t.Fatal("text profile restated the native permission mode") } if cfg["skills"].(map[string]any)["external"].(map[string]any)["enabled"] != false { t.Fatal("external discovery enabled") diff --git a/apps/daemon/internal/agent/mcode/workspace.go b/apps/daemon/internal/agent/mcode/workspace.go index b5bbd2899..80e4fe020 100644 --- a/apps/daemon/internal/agent/mcode/workspace.go +++ b/apps/daemon/internal/agent/mcode/workspace.go @@ -100,7 +100,6 @@ func prepareWorkspaceOptions(runtimeRoot string, c WorkspaceConfig, req proto.Pr return opts, err } config["permissionMode"] = "bypassPermissions" - config["sandbox"] = map[string]bool{"enabled": false} if len(req.LocalEnvironment.Skills) > 0 { selected := config["agents"].(map[string]any)["default"].(map[string]any) names := make([]string, 0, len(req.LocalEnvironment.Skills)) diff --git a/contracts/agents-api/environments.md b/contracts/agents-api/environments.md index b8aa83954..10a35831e 100644 --- a/contracts/agents-api/environments.md +++ b/contracts/agents-api/environments.md @@ -380,7 +380,7 @@ Skills and their immutable versions are Project resources, independent of Sessio - Bundles are encrypted with the service cipher, bound to Project, Skill and version. Metadata reads never load or decrypt bundles. Deleting a Skill reclaims its versions without affecting frozen Sessions. - References resolve inside the Session creation transaction, after the upsert establishes ownership. Referenced resources are locked in a stable order, and the version, metadata and bytes freeze together. Public reference metadata, unresolved Template intent and the resolved Runtime bundle stay distinct; a reference is never reported as inline. -Inline and referenced Skills use the same confidential snapshot and installer. The Runtime installs them under `skills/` in its capability root before setup and native execution. Adapters register only the selected Skill roots: Codex as explicit extra roots, MiniMax Code through its native catalog, and Claude as one controlled envelope per package with real directories and immutable hard links. Automatic native MCP discovery stays disabled. Codex nested `SKILL.md` discovery, `agents/openai.yaml` dependency configuration and Claude inline shell preprocessing fail adapter preparation. Native Harness configuration is never passed through wholesale. +Inline and referenced Skills use the same confidential snapshot and installer. The Runtime installs them under `skills/` in its capability root before setup and native execution. Adapters register only the selected Skill roots: Codex as explicit extra roots, MiniMax Code through its native catalog, and Claude as one controlled envelope per package with real directories and immutable hard links. Skill roots activate no MCP servers; only [Plugin MCP](#plugin-mcp) does. Codex nested `SKILL.md` discovery, `agents/openai.yaml` dependency configuration and Claude inline shell preprocessing fail adapter preparation. Native Harness configuration is never passed through wholesale. ### Plugin MCP diff --git a/contracts/agents-api/execution-tools.md b/contracts/agents-api/execution-tools.md index 683a5c41d..d71a107f7 100644 --- a/contracts/agents-api/execution-tools.md +++ b/contracts/agents-api/execution-tools.md @@ -47,7 +47,7 @@ The Claude adapter passes `outputFormat` to the pinned SDK and allows its native A `tool_search` tool has only `type`; Responses-only execution fields are rejected. Function `defer_loading` marks which definitions load lazily. Discovery requires both: `tool_search` without a deferred function, or deferred functions without `tool_search`, is rejected. The saved-Agent tool union keeps `tool_search`; the pinned Session response union omits it, so Session and SSE resources project it out while the frozen configuration keeps it. The pinned Items union has no tool-search Item, and Core invents none. -Core sends `PromptRequestPayload.ToolSearch` and each `FunctionTool.DeferLoading`, and requires `tool_search` support in both the Harness declaration and the Runtime. Native search and lazy schema loading belong to the adapter. The Claude adapter's MCP server marks eager definitions `anthropic/alwaysLoad:true` and deferred ones false, and enables native ToolSearch; the function profile allows only declared callbacks and ToolSearch besides the selected workspace tools. A workspace Runtime derives `tool_search` from the bridge's `workspace_tool_search` feature. The native Harness owns model and provider policy; known conflicting modes and beta settings reject in the adapter, and the SDK gives no reliable pre-input signal that deferral took effect after an opaque policy change. +Core sends `PromptRequestPayload.ToolSearch` and each `FunctionTool.DeferLoading`, and requires `tool_search` support in both the Harness declaration and the Runtime. Native search and lazy schema loading belong to the adapter. The Claude adapter's MCP server marks eager definitions `anthropic/alwaysLoad:true` and deferred ones false, and enables native ToolSearch; the function profile allows only declared callbacks and ToolSearch besides the workspace's native tools. A workspace Runtime derives `tool_search` from the bridge's `workspace_tool_search` feature. The native Harness owns model and provider policy; known conflicting modes and beta settings reject in the adapter, and the SDK gives no reliable pre-input signal that deferral took effect after an opaque policy change. ## Web search and programmatic tool calling @@ -60,14 +60,14 @@ Core sends `PromptRequestPayload.ToolSearch` and each `FunctionTool.DeferLoading Saved Agents keep every pinned `web_search` mode: omitted or null is saved as `live`, and `cached` and `live` as sent ([saved modes](./wire-semantics.md#saved-configuration)). Search settings are resource data: omitted or null `context_size` resolves to `medium`; omitted domains and location resolve to null; an empty domain list stays empty; a supplied location, including `{}`, has `city`, `country`, `region` and `timezone`, null where omitted. -Execution carries the resolved values to the Harness and validates them against its declaration in `internal/harnessconfig` (`Execution`): the supported web search modes and whether search settings are accepted, reasoning efforts and summaries, service tiers, programmatic-tool-calling values and text verbosities. A value outside the declaration is rejected at Session admission with a message naming the field, unless the Session replaces the saved tools. Core sends an option only when the Agent sets it or the pinned default applies, so an omitted `web_search` or `programmatic_tool_calling` keeps each Harness's native behavior. For programmatic calling this differs from the official default-on behavior. Unrelated native utility tools are not removed. +Execution carries the resolved values to the Harness and validates them against its declaration in `internal/harnessconfig` (`Execution`): the supported web search modes and whether search settings are accepted, reasoning efforts and summaries, service tiers, programmatic-tool-calling values and text verbosities. A value outside the declaration is rejected at Session admission with a message naming the field, unless the Session replaces the saved tools. Core sends an option only when the Agent sets it or the pinned default applies, so an omitted `web_search` or `programmatic_tool_calling` keeps each Harness's native behavior. For programmatic calling this differs from the official default-on behavior. Unrelated native utility tools are not removed. Claude Code on a workspace placement also keeps `WebSearch` and `WebFetch` when the control is omitted; [Turns and Items](./sessions-events.md#turns-and-items) gives their `web_search_call` actions. `ExecutionControls` carries the options ([Runtime protocol](../../docs/runtime-protocol.md#capability-declarations)). The Runtime needs only the `execution_controls` capability. | Harness | Web search | Programmatic tool calling | | --- | --- | --- | | Codex | `disabled`, `cached` and `live`, with `context_size`, `allowed_domains` and `location`, as native `web_search` and `tools.web_search` settings | `enabled: true` turns on native `code_mode`; `false` turns off `code_mode`, `code_mode_only` and `code_mode_prewarm` and checks native managed requirements before a thread starts or resumes | -| Claude Code | `disabled` only, with the restricted built-in inventory verified at native initialization | `false` only | +| Claude Code | `disabled` only. On a workspace placement the native `claude_code` tool set runs without ask-user, scheduling, workflow, background and `ListAgents` tools, and `disabled` removes `WebSearch` and `WebFetch`; `environment: none` keeps no built-in tools | `false` only | | MiniMax Code | `disabled` only, through the native `features.webSearch=false` because the restricted profile has no search tool | `false` only | ## HTTP MCP diff --git a/contracts/agents-api/harness-onboarding.md b/contracts/agents-api/harness-onboarding.md index 883f24941..1a0f3566e 100644 --- a/contracts/agents-api/harness-onboarding.md +++ b/contracts/agents-api/harness-onboarding.md @@ -217,7 +217,7 @@ The daemon's `clirunner` starts every native child in its own Unix process group Owned output pipes stay readable after the leader exits. Consumers drain stdout and stderr before calling `Wait`, which joins the cached process result and closes the readers. `Done` reports leader reaping and group cleanup signals; it is not a native execution receipt or proof of persisted history. SDK adapters settle each Turn and drain its observations before publishing completion, and Executor close also closes the query and awaits the native child. Process groups are lifecycle supervision, not isolation or containment of descendants that leave the group. -Adapters run native tools unattended with the launching user's permissions, and a Harness never asks a human. Codex runs with approval policy `never`, under which it settles MCP elicitation itself without reaching the client, and with full access; the adapter also disables its blocking `request_user_input` tool. Claude runs through the adapter's tool callback, which allows or denies without asking, in native `default` permission mode with the SDK sandbox disabled. MiniMax runs with bypassed permissions and its sandbox disabled; the adapter disables `askUser`, advertises no elicitation and answers `session/request_permission` with the ACP `cancelled` outcome, which MiniMax treats as a denial. No native permission or question request reaches Core: a human in the loop goes through a function tool, the Session reads [`requires_action`](./sessions-events.md#session-status) and the application submits the function result. Do not add permission profiles, bubblewrap wrappers or native sandbox settings; there is one execution path for every Environment origin. Resource paths are operator configuration, not a permission boundary. +Adapters run native tools unattended with the launching user's permissions, and a Harness never asks a human. Codex runs with approval policy `never`, under which it settles MCP elicitation itself without reaching the client, and with full access; the adapter also disables its blocking `request_user_input` tool. Claude runs through the adapter's tool callback, which allows or denies without asking. MiniMax runs with bypassed permissions; the adapter disables `askUser`, advertises no elicitation and answers `session/request_permission` with the ACP `cancelled` outcome, which MiniMax treats as a denial. No native permission or question request reaches Core: a human in the loop goes through a function tool, the Session reads [`requires_action`](./sessions-events.md#session-status) and the application submits the function result. Do not add permission profiles, bubblewrap wrappers or native sandbox settings; there is one execution path for every Environment origin. Resource paths are operator configuration, not a permission boundary. An unreachable model host must not hold a Turn open. The adapter keeps the Harness's own connection retries bounded through native settings, and the Harness's final connection error becomes `connection_failed` when the Harness reports it as structured data ([native failure classification](../../docs/runtime-protocol.md#native-failure-classification)). Core adds no model timeout of its own. Each adapter guide states its Harness's bound. diff --git a/contracts/agents-api/index.md b/contracts/agents-api/index.md index 21bd147b2..3d49c80f0 100644 --- a/contracts/agents-api/index.md +++ b/contracts/agents-api/index.md @@ -69,7 +69,8 @@ Each item is Core's deliberate or native behavior where the official service beh - A repeated Session creation with the same `Idempotency-Key` returns the original Session; the official service creates a new one. - Omitted programmatic tool calling keeps the Harness's native behavior; the official default is on. -- An Agent without a `web_search` tool keeps the Harness's native search, which for Codex is cached search; the pinned contract does not define search without the tool. +- An Agent without a `web_search` tool keeps the Harness's [native search](./execution-tools.md#web-search-and-programmatic-tool-calling); the pinned contract does not define search without the tool. +- Claude Code on a workspace placement appends the Agent's `instructions` to its native system prompt and runs its [native tool set](./execution-tools.md#web-search-and-programmatic-tool-calling). Its search has no cached mode and no domain, location or context-size settings. - An omitted reasoning effort stays null instead of taking the model's default. - Codex maps programmatic tool calling to its native `code_mode`, whose model-written scripts call the other tools; the feature is under development upstream. - A Session's `agent.tools` omits `tool_search` declarations. diff --git a/contracts/agents-api/model-execution.md b/contracts/agents-api/model-execution.md index eb9e1039b..ab06d65e3 100644 --- a/contracts/agents-api/model-execution.md +++ b/contracts/agents-api/model-execution.md @@ -99,7 +99,7 @@ Unsupported protocol, Harness or Environment combinations are rejected before th The resolved provider configuration is frozen and encrypted in the Session creation transaction, with its own encryption purpose and Project and Session binding. Creation retries include it in their request hash, so a changed key or endpoint under the same Idempotency-Key conflicts; a key enters any stored hash only as a fingerprint keyed by the deployment credential key. No public Session, Agent, Environment, event or ordinary configuration contains the key. The top-level extension is write-only and cannot be updated. -At dispatch, Core sends the snapshot as one confidential provider bundle over the daemon connection bound to the Session, and the adapter applies it natively and connects directly to the provider. Core never falls back to other credentials when a snapshot is missing or cannot be decrypted. For `self_hosted`, the receiving daemon is the executor enrolled for the Session's own Environment with a current executor credential of the Session creator's principal; rotation or revocation closes the socket before further dispatch. The executor host stores the bundle in its native Harness home, as hosted Runtimes do. Native tools run with the starting account's permissions and can read what that account can read, and revocation does not erase a bundle already delivered. +At dispatch, Core sends the snapshot as one confidential provider bundle over the daemon connection bound to the Session, and the adapter applies it natively and connects directly to the provider. Core never falls back to other credentials when a snapshot is missing or cannot be decrypted. For `self_hosted`, the receiving daemon is the executor enrolled for the Session's own Environment with a current executor credential of the Session creator's principal; rotation or revocation closes the socket before further dispatch. The executor host stores the bundle in its native Harness home, as hosted Runtimes do. Native tools run with the starting account's permissions and can read what that account can read. The workspace's native configuration, such as Claude Code project settings and hooks, applies as it does locally and can use the provider key, so attach only repositories trusted with that key. Revocation does not erase a bundle already delivered. ## Native model parameters diff --git a/contracts/agents-api/sessions-events.md b/contracts/agents-api/sessions-events.md index b0d3655ed..ed65db440 100644 --- a/contracts/agents-api/sessions-events.md +++ b/contracts/agents-api/sessions-events.md @@ -136,7 +136,7 @@ Turn and Item lists take `after`, `limit` and `order` ([list rules](./wire-seman | `command_execution` | Command, reported output, exit code, duration and working directory | | `mcp_call` | Server and tool identity, arguments, structured result or error | | `function_call`, `function_call_output` | A linked call and its result. The result always carries `output` and `error`, null when the submission omitted them; stored results keep the submitted field presence. Native file changes appear as an `apply_patch` function call with the changes as arguments and no invented result | -| `web_search_call` | The supported action fields (`search`, `open_page`, `find_in_page`, `other`) | +| `web_search_call` | The supported action fields (`search`, `open_page`, `find_in_page`, `other`); Claude Code reports `search` for `WebSearch` and `open_page` for `WebFetch` | | `reasoning`, `agent_message` and the Subagent coordination calls | See [Subagents](./subagents.md). `agent_message` has no status; reasoning status can be null | A failed tool does not fail its Turn. Tool output is readable by the Session's Project and can contain the tool's own diagnostic text. diff --git a/contracts/agents-api/subagents.md b/contracts/agents-api/subagents.md index a6b3c0a51..aaa4b055f 100644 --- a/contracts/agents-api/subagents.md +++ b/contracts/agents-api/subagents.md @@ -54,7 +54,7 @@ An adapter freezes root output before child settlement, keeps its native owner a **Codex.** The adapter enables the native `multi_agent` feature with a nesting depth of 64 and maps the concurrency limit to `agents.max_threads`. It disables native hooks, plugins, code mode and `multi_agent_v2`, and refuses to start if the native hook list is not empty or managed requirements force a conflicting feature. Close and reopen facts come from direct tool output correlated with the same call's persisted completion, so they need native persisted receipts. Native Turn times have second precision. Child file work can finish under the same owner after the root Turn finishes. Cancellation continues under the same owner after a caller deadline; a later call can confirm settlement without repeating the native interrupt. -**Claude Code.** The pinned SDK's native Agent and SendMessage calls run the single child type `oac_worker`, which inherits the model and has workspace Bash, Agent and SendMessage; the bridge's `subagent_resources` feature gates it. Children use native Bash with the parent's launching-user permissions. Private child records establish parentage, the first own input time and later own Turns; inherited parent context is excluded. The query owner admits children before start and keeps their history through settlement. Confirmed cancellation writes an immutable effect receipt because a native abort can leave no terminal record. There is no close operation: completed or cancelled children stay active. Messages to running children, background work, other child profiles and per-call model overrides are rejected. The [Claude SDK adapter](https://github.com/MiniMax-AI/OpenAgentCore/blob/main/packages/claude-sdk-adapter/README.md#subagents) documents the details. +**Claude Code.** The pinned SDK's native Agent and SendMessage calls run the single child type `oac_worker`, which inherits the model and, on a workspace placement, the parent's native tools, and otherwise has only Agent and SendMessage; the bridge's `subagent_resources` feature gates it. Children use native Bash with the parent's launching-user permissions. Private child records establish parentage, the first own input time and later own Turns; inherited parent context is excluded. The query owner admits children before start and keeps their history through settlement. Confirmed cancellation writes an immutable effect receipt because a native abort can leave no terminal record. There is no close operation: completed or cancelled children stay active. Messages to running children, background work, other child profiles and per-call model overrides are rejected. The [Claude SDK adapter](https://github.com/MiniMax-AI/OpenAgentCore/blob/main/packages/claude-sdk-adapter/README.md#subagents) documents the details. **MiniMax Code.** Native ACP delegation (`task`, `task_append`, `task_stop`) creates children. Session-private SQLite records supply child identity, accepted inputs, terminal times and own messages. The native task-create transaction enforces the concurrency limit before start, and native preparation must acknowledge that limit and the restricted tool profile before any model input. There is no close or reopen, and native workers do not delegate nested work. diff --git a/contracts/agents-api/zh/environments.md b/contracts/agents-api/zh/environments.md index 432dcc55e..bd04f7e46 100644 --- a/contracts/agents-api/zh/environments.md +++ b/contracts/agents-api/zh/environments.md @@ -1,7 +1,7 @@ --- title: "Environment 与模板" source: contracts/agents-api/environments.md -source_hash: d25a0d0ec0768d6f46ce4928b574bdab42db6d46782d28a742a380fb8bb365d1 +source_hash: 20bf8194519cb0c830b88e76c9fea28a64ec1615c1535c8ff8abb5668c086e00 --- Environment 是 Session 的执行资源,包括 Harness 运行所在的机器、工作区以及已完成准备的能力。Session 通过其 `environment` 配置创建 Environment;不存在独立的 create 调用。Environment 模板是 Session 创建时解析的可复用准备配置。本契约涵盖这两类资源、两种放置方式、输入接纳、能力准备、Skills、Plugins 和 MCP 连接来源。 @@ -382,7 +382,7 @@ Skills 及其不可变版本是项目资源,独立于 Session 和原生安装 - Bundle 使用服务密码进行加密,并绑定到项目、Skill 和版本。元数据读取绝不会加载或解密 Bundle。删除 Skill 会回收其版本,但不会影响已冻结的 Session。 - 引用会在 Session 创建事务中、upsert 建立所有权之后解析。被引用的资源按稳定顺序锁定,版本、元数据和字节会一并冻结。公开引用元数据、未解析的模板意图和已解析的 Runtime Bundle 始终彼此独立;引用绝不会报告为内联内容。 -内联和引用的 Skills 使用相同的机密快照与安装程序。Runtime 会在设置和原生执行之前,将它们安装到能力根目录下的 `skills/`。适配器只注册所选的 Skill 根目录:Codex 将其注册为显式额外根目录,MiniMax Code 通过其原生目录进行注册,Claude 则为每个包使用一个受控封装,其中包含实际目录和不可变硬链接。自动原生 MCP 发现保持禁用。Codex 的嵌套 `SKILL.md` 发现、`agents/openai.yaml` 依赖项配置以及 Claude 内联 shell 预处理都会导致适配器准备失败。原生 Harness 配置绝不会整体透传。 +内联和引用的 Skills 使用相同的机密快照与安装程序。Runtime 会在设置和原生执行之前,将它们安装到能力根目录下的 `skills/`。适配器只注册所选的 Skill 根目录:Codex 将其注册为显式额外根目录,MiniMax Code 通过其原生目录进行注册,Claude 则为每个包使用一个受控封装,其中包含实际目录和不可变硬链接。Skill 根目录不会启用 MCP 服务器,只有 [Plugin MCP](#plugin-mcp) 会启用。Codex 的嵌套 `SKILL.md` 发现、`agents/openai.yaml` 依赖项配置以及 Claude 内联 shell 预处理都会导致适配器准备失败。原生 Harness 配置绝不会整体透传。 ### Plugin MCP {#plugin-mcp} diff --git a/contracts/agents-api/zh/execution-tools.md b/contracts/agents-api/zh/execution-tools.md index 16805aefe..d4a280795 100644 --- a/contracts/agents-api/zh/execution-tools.md +++ b/contracts/agents-api/zh/execution-tools.md @@ -1,7 +1,7 @@ --- title: "执行工具" source: contracts/agents-api/execution-tools.md -source_hash: e9c3fd5267e24f56f1e24ef04af39abc3839ba6f31d1cc6537ba2553eb5417fe +source_hash: ae8c47681587ccd0e7084c883be63a1f7064c1e780a5516536736943d8d45e08 --- Agent 在 `tools` 中声明应用函数、控制项和 MCP 服务器,并可在 `text.format` 中声明输出 schema。本契约说明 Core 如何验证声明、哪些内容跨越 Runtime 边界,以及调用方如何恢复待执行操作。[Harness 能力](harness-capabilities.md)列出各 Harness 在不同部署位置支持的操作。原生工作区工具和 Environment Plugin MCP 属于 [Environment](environments.md#skills-plugins-and-environment-mcp)。 @@ -49,7 +49,7 @@ Claude 适配器将 `outputFormat` 传给固定版本 SDK,并允许原生 `Str `tool_search` 工具仅包含 `type`;仅适用于 Responses 的执行字段被拒绝。函数 `defer_loading` 标记延迟加载的定义。发现要求两者同时存在:没有延迟函数的 `tool_search`,或没有 `tool_search` 的延迟函数均被拒绝。已保存 Agent 的工具联合类型保留 `tool_search`;固定版本 Session 响应联合类型省略它,因此 Session 与 SSE 资源投影移除它,冻结配置仍保留它。固定版本 Item 联合类型没有 tool-search Item,Core 不自行创建。 -Core 发送 `PromptRequestPayload.ToolSearch` 和每个 `FunctionTool.DeferLoading`,并要求 Harness 声明和 Runtime 均支持 `tool_search`。原生搜索与 schema 延迟加载属于适配器。Claude 适配器的 MCP 服务器将立即加载定义标记为 `anthropic/alwaysLoad:true`,延迟定义标记为 false,并启用原生 ToolSearch;函数配置仅允许所声明回调、ToolSearch 与选定的工作区工具。工作区 Runtime 从桥接层的 `workspace_tool_search` 功能推导 `tool_search`。原生 Harness 管理模型与提供商策略;已知冲突模式和 beta 设置在适配器中拒绝,不透明策略改变后,SDK 不提供可靠的输入前信号来确认延迟加载是否生效。 +Core 发送 `PromptRequestPayload.ToolSearch` 和每个 `FunctionTool.DeferLoading`,并要求 Harness 声明和 Runtime 均支持 `tool_search`。原生搜索与 schema 延迟加载属于适配器。Claude 适配器的 MCP 服务器将立即加载定义标记为 `anthropic/alwaysLoad:true`,延迟定义标记为 false,并启用原生 ToolSearch;函数配置仅允许所声明回调、ToolSearch 与工作区的原生工具。工作区 Runtime 从桥接层的 `workspace_tool_search` 功能推导 `tool_search`。原生 Harness 管理模型与提供商策略;已知冲突模式和 beta 设置在适配器中拒绝,不透明策略改变后,SDK 不提供可靠的输入前信号来确认延迟加载是否生效。 ## Web 搜索与程序化工具调用 {#web-search-and-programmatic-tool-calling} @@ -62,14 +62,14 @@ Core 发送 `PromptRequestPayload.ToolSearch` 和每个 `FunctionTool.DeferLoadi 已保存 Agent 保留固定版本的所有 `web_search` 模式:省略或 null 保存为 `live`,`cached` 和 `live` 原样保存([保存模式](wire-semantics.md#saved-configuration))。搜索设置是资源数据:省略或 null 的 `context_size` 解析为 `medium`;省略域名和位置解析为 null;空域名列表保持为空;提供的位置(包括 `{}`)包含 `city`、`country`、`region` 和 `timezone`,省略字段为 null。 -执行把解析后的值交给 Harness,并按 `internal/harnessconfig` 中的声明(`Execution`)校验:支持的 Web 搜索模式以及是否接受搜索设置、reasoning effort 与 summary、service tier、程序化工具调用的取值和 text verbosity。声明之外的值会在 Session 准入时被拒绝,消息会指出字段,除非 Session 替换了保存的工具。Core 仅在 Agent 设置了选项或适用固定版本的默认值时才发送该选项,因此省略 `web_search` 或 `programmatic_tool_calling` 会保留各 Harness 的原生行为。对程序化调用而言,这与官方默认启用的行为不同。无关的原生实用工具不会被移除。 +执行把解析后的值交给 Harness,并按 `internal/harnessconfig` 中的声明(`Execution`)校验:支持的 Web 搜索模式以及是否接受搜索设置、reasoning effort 与 summary、service tier、程序化工具调用的取值和 text verbosity。声明之外的值会在 Session 准入时被拒绝,消息会指出字段,除非 Session 替换了保存的工具。Core 仅在 Agent 设置了选项或适用固定版本的默认值时才发送该选项,因此省略 `web_search` 或 `programmatic_tool_calling` 会保留各 Harness 的原生行为。对程序化调用而言,这与官方默认启用的行为不同。无关的原生实用工具不会被移除。省略该控制项时,工作区位置上的 Claude Code 也保留 `WebSearch` 和 `WebFetch`;其 `web_search_call` 操作见 [Turn 与 Item](./sessions-events.md#turns-and-items)。 `ExecutionControls` 携带这些选项([Runtime 协议](../../../docs/zh/runtime-protocol.md#capability-declarations))。Runtime 只需要 `execution_controls` 能力。 | Harness | Web 搜索 | 程序化工具调用 | | --- | --- | --- | | Codex | `disabled`、`cached` 和 `live`,以及 `context_size`、`allowed_domains` 和 `location`,对应原生 `web_search` 与 `tools.web_search` 设置 | `enabled: true` 开启原生 `code_mode`;`false` 关闭 `code_mode`、`code_mode_only` 和 `code_mode_prewarm`,并在启动或恢复线程前检查原生受管要求 | -| Claude Code | 仅 `disabled`,并在原生初始化时验证受限内置工具清单 | 仅 `false` | +| Claude Code | 仅 `disabled`。在工作区位置运行原生 `claude_code` 工具集,但移除向用户提问、定时、工作流、后台和 `ListAgents` 工具,`disabled` 会移除 `WebSearch` 和 `WebFetch`;`environment: none` 不保留内置工具 | 仅 `false` | | MiniMax Code | 仅 `disabled`,受限配置没有搜索工具,通过原生 `features.webSearch=false` 实现 | 仅 `false` | ## HTTP MCP {#http-mcp} diff --git a/contracts/agents-api/zh/harness-onboarding.md b/contracts/agents-api/zh/harness-onboarding.md index 9f009794b..0fe34bd27 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: 9ee093a477717517aac1f7d51eded6810afaedbefda0d2c13b900cbeba49a278 +source_hash: 9440f55f079d3a54c6fa4f2bd9e7063edc1370440057874412e86b8b2bd19fc7 --- **Harness** 是一种运行模型和工具循环的原生代理引擎(Codex、Claude Code、MiniMax Code);**Harness 适配器**将 Runtime 的 Executor 和 Turn 契约转换到该引擎的 SDK 或协议。Harness 协议是 Runtime 与适配器之间的契约:适配器接口及其生命周期义务、声明、注册和验收。本文面向添加或维护 Harness 的开发者。 @@ -219,7 +219,7 @@ daemon 的 `clirunner` 让每个原生子进程在自己的 Unix 进程组中启 所属输出管道在主进程退出后仍可读取。消费者在调用 `Wait` 之前耗尽 stdout 和 stderr;`Wait` 会汇合缓存的进程结果并关闭读取器。`Done` 报告主进程回收和进程组清理信号;它不是原生执行回执,也不是历史已持久化的证据。SDK 适配器会结算每个 Turn,并在发布完成状态前耗尽其观察结果;Executor 关闭还会关闭 Query 并等待原生子进程。进程组用于生命周期监管,而不是隔离或遏制离开进程组的后代进程。 -适配器以启动用户的权限无人值守运行原生工具,Harness 从不询问人类。Codex 使用批准策略 `never` 和完全访问权限,在该策略下 Codex 自行处理 MCP elicitation,不会发给客户端;适配器还禁用其阻塞式 `request_user_input` 工具。Claude 通过适配器的工具回调运行,回调直接允许或拒绝、不会询问,使用原生 `default` 权限模式并禁用 SDK sandbox。MiniMax 绕过权限并禁用 sandbox;适配器禁用 `askUser`、不声明 elicitation,并以 ACP `cancelled` 结果答复 `session/request_permission`,MiniMax 将其视为拒绝。原生权限或问题请求都不会到达 Core:需要人工介入时通过 function 工具完成,Session 读取 [`requires_action`](./sessions-events.md#session-status),由应用提交 function 结果。不要添加权限 profile、bubblewrap 包装器或原生 sandbox 设置;每种 Environment 来源都只有一条执行路径。资源路径属于操作员配置,而不是权限边界。 +适配器以启动用户的权限无人值守运行原生工具,Harness 从不询问人类。Codex 使用批准策略 `never` 和完全访问权限,在该策略下 Codex 自行处理 MCP elicitation,不会发给客户端;适配器还禁用其阻塞式 `request_user_input` 工具。Claude 通过适配器的工具回调运行,回调直接允许或拒绝、不会询问。MiniMax 绕过权限;适配器禁用 `askUser`、不声明 elicitation,并以 ACP `cancelled` 结果答复 `session/request_permission`,MiniMax 将其视为拒绝。原生权限或问题请求都不会到达 Core:需要人工介入时通过 function 工具完成,Session 读取 [`requires_action`](./sessions-events.md#session-status),由应用提交 function 结果。不要添加权限 profile、bubblewrap 包装器或原生 sandbox 设置;每种 Environment 来源都只有一条执行路径。资源路径属于操作员配置,而不是权限边界。 模型主机不可达时不能让 Turn 一直挂起。适配器通过原生设置让 Harness 自身的连接重试保持有界;Harness 以结构化数据报告最终连接错误时,该错误归为 `connection_failed`([原生失败分类](../../../docs/zh/runtime-protocol.md#native-failure-classification))。Core 不另设模型超时。各适配器指南写明其 Harness 的上限。 diff --git a/contracts/agents-api/zh/index.md b/contracts/agents-api/zh/index.md index 378b0771f..e4ef3753d 100644 --- a/contracts/agents-api/zh/index.md +++ b/contracts/agents-api/zh/index.md @@ -1,7 +1,7 @@ --- title: "OpenAI 兼容性" source: contracts/agents-api/index.md -source_hash: e66771e892b2d9c4a0e4d24a81f0976d3f7db5ca8b9920ea6155199face758f6 +source_hash: 070ba5163d6a897e5fb12fcede93df423bd0d5a5ed6f36c48cadb3e67636b115 --- Core 实现下方固定版本的 OpenAI Agents API。本台账列出各项资源的覆盖情况、相对于官方服务的所有差异以及已知缺口。 @@ -72,7 +72,8 @@ Core 自身字段位于 `x_agents_core` 中([Core extensions](../../../docs/zh - 使用相同 `Idempotency-Key` 重复创建 Session 会返回原 Session;官方服务会创建一个新的 Session。 - 未指定程序化工具调用时,会保留 Harness 的原生行为;官方默认值为启用。 -- Agent 未声明 `web_search` 工具时,会保留 Harness 的原生搜索,Codex 为缓存搜索;固定的官方契约未定义未声明该工具时是否搜索。 +- Agent 未声明 `web_search` 工具时,会保留 Harness 的[原生搜索](./execution-tools.md#web-search-and-programmatic-tool-calling);固定的官方契约未定义未声明该工具时是否搜索。 +- 工作区位置上的 Claude Code 把 Agent 的 `instructions` 追加到原生系统提示词之后,并运行其[原生工具集](./execution-tools.md#web-search-and-programmatic-tool-calling)。其搜索没有缓存模式,也没有域名、位置和上下文大小设置。 - 未指定推理强度时会保持为 null,而不是采用模型的默认值。 - Codex 把程序化工具调用映射到原生 `code_mode`,由模型编写的脚本调用其他工具;该功能在上游仍处于开发中。 - Session 的 `agent.tools` 会省略 `tool_search` 声明。 diff --git a/contracts/agents-api/zh/model-execution.md b/contracts/agents-api/zh/model-execution.md index e32eeb8ce..19214d49b 100644 --- a/contracts/agents-api/zh/model-execution.md +++ b/contracts/agents-api/zh/model-execution.md @@ -1,7 +1,7 @@ --- title: "模型执行" source: contracts/agents-api/model-execution.md -source_hash: 3007a19d5f030e14149218f9d1822efceb1188c3a31c6505180df511fb1ab205 +source_hash: d65e90e7ce794129a02a2091aadcee3919f8c4c22917d6558ed66933657896d4 --- 每个 Session 都运行一个 Harness,并使用一个模型提供商。Core 通过三个固定版本上游协议未定义的 Core 扩展来选择它们:`x_agents_core.harness` 选择 Harness,`x_agents_core.model_provider` 提供端点和密钥,`x_agents_core.harness_config` 携带原生模型参数。Core 没有提供商目录、模型别名解析或产品权限模型;除 Session 和已保存 Agent 配置包外,唯一存储的配置包是每个 Harness 的一个 [deployment default](#deployment-defaults)。本文档定义 Harness—模型提供商协议:[`internal/modelprovider/config.go`](https://github.com/MiniMax-AI/OpenAgentCore/blob/main/internal/modelprovider/config.go) 负责验证冻结的提供商连接,每个 Harness 则通过 [`internal/harnessconfig/harness.go`](https://github.com/MiniMax-AI/OpenAgentCore/blob/main/internal/harnessconfig/harness.go) 声明其协议和原生参数。 @@ -101,7 +101,7 @@ Core 从同一个数据库快照读取 Agent 配置和加密配置包;显式 解析后的提供商配置会在 Session 创建事务中被冻结并加密,使用自己的加密用途,并绑定到项目和 Session。创建重试会将其纳入请求哈希,因此使用相同 Idempotency-Key 时,更改密钥或端点会产生冲突;密钥进入任何存储哈希时,只会表现为由部署凭据密钥加键控的指纹。任何公开的 Session、Agent、Environment、事件或常规配置均不包含该密钥。顶层扩展仅可写入,无法更新。 -在分派时,Core 会通过绑定到 Session 的 daemon 连接,将快照作为一个机密提供商配置包发送出去;适配器会原生应用该配置并直接连接提供商。快照缺失或无法解密时,Core 绝不会回退到其他凭据。对于 `self_hosted`,接收方 daemon 是为该 Session 自身 Environment 注册的执行器,并持有 Session 创建者主体的当前执行器凭据;凭据轮换或吊销会在继续分派前关闭套接字。执行器主机将该配置包存放在其原生 Harness home 中,与托管 Runtime 的做法相同。原生工具以启动账户的权限运行,并且可以读取该账户有权读取的内容;吊销凭据不会擦除已经交付的配置包。 +在分派时,Core 会通过绑定到 Session 的 daemon 连接,将快照作为一个机密提供商配置包发送出去;适配器会原生应用该配置并直接连接提供商。快照缺失或无法解密时,Core 绝不会回退到其他凭据。对于 `self_hosted`,接收方 daemon 是为该 Session 自身 Environment 注册的执行器,并持有 Session 创建者主体的当前执行器凭据;凭据轮换或吊销会在继续分派前关闭套接字。执行器主机将该配置包存放在其原生 Harness home 中,与托管 Runtime 的做法相同。原生工具以启动账户的权限运行,并且可以读取该账户有权读取的内容。工作区的原生配置(例如 Claude Code 的项目设置和 hooks)会像在本机一样生效,并且可以使用提供商密钥,因此只能附加可以接触该密钥的可信仓库。吊销凭据不会擦除已经交付的配置包。 ## 原生模型参数 {#native-model-parameters} diff --git a/contracts/agents-api/zh/sessions-events.md b/contracts/agents-api/zh/sessions-events.md index 6bc2449c7..fb09c2eec 100644 --- a/contracts/agents-api/zh/sessions-events.md +++ b/contracts/agents-api/zh/sessions-events.md @@ -1,7 +1,7 @@ --- title: "Session、事件和历史" source: contracts/agents-api/sessions-events.md -source_hash: f2d2832178aa0f0e4293ee2ae01e12d57400c3b70abcb7f51a1ab4f810970085 +source_hash: d56933c7152a4ae3fac2f4f4deab4bcd87f81b6e7a4650542d8480d529742c91 --- 本契约涵盖 Session 内部发生的事情:发送输入、实时事件流,以及读取 Turn、Item 和使用量的持久化历史。Session 资源本身(创建配置、重试标识、更新、列出和删除)见 [API 行为](wire-semantics.md)。消息和函数结果内容见[消息内容](message-content.md)。[Agents API 指南](../../../docs/zh/api/public-agent-api.md)展示了使用 SDK 和 HTTP 的调用方式。 @@ -138,7 +138,7 @@ Turn 和 Item 列表接受 `after`、`limit` 和 `order`([列表规则](wire-s | `command_execution` | 命令、报告的输出、退出代码、持续时间和工作目录 | | `mcp_call` | 服务器和工具标识、参数、结构化结果或错误 | | `function_call`、`function_call_output` | 关联的调用及其结果。结果始终携带 `output` 和 `error`;提交时省略这些字段则值为 null。存储的结果会保留提交时字段是否存在的信息。原生文件更改显示为 `apply_patch` 函数调用,更改内容作为参数,不会生成虚构结果 | -| `web_search_call` | 支持的操作字段(`search`、`open_page`、`find_in_page`、`other`) | +| `web_search_call` | 支持的操作字段(`search`、`open_page`、`find_in_page`、`other`);Claude Code 以 `search` 报告 `WebSearch`,以 `open_page` 报告 `WebFetch` | | `reasoning`、`agent_message` 和 Subagent 协调调用 | 见 [Subagents](subagents.md)。`agent_message` 没有状态;reasoning 状态可以为 null | 工具失败不会导致其 Turn 失败。Session 的项目可以读取工具输出,其中可能包含该工具自己的诊断文本。 diff --git a/contracts/agents-api/zh/subagents.md b/contracts/agents-api/zh/subagents.md index 8d9503320..f84d00619 100644 --- a/contracts/agents-api/zh/subagents.md +++ b/contracts/agents-api/zh/subagents.md @@ -1,7 +1,7 @@ --- title: "子 Agent" source: contracts/agents-api/subagents.md -source_hash: 5502201e91555cbc23df61c9eea52990acb0d956709134876194b8b4fb5c7bc1 +source_hash: 2d566c29c98abe79571dc8dbdb43629b8cf87487a3ebc4c23b290a08ef336e0c --- 启用 `multi_agent.enabled` 后,Harness 可以启动原生子 Agent。Core 通过锁定版本的 SDK(见 [`upstream.json`](https://github.com/MiniMax-AI/OpenAgentCore/blob/main/contracts/agents-api/upstream.json))中的六项 Subagent 读取操作公开它们,并根据适配器观察结果记录它们。当 `multi_agent.enabled=false` 时,Runtime 会移除原生子 Agent 工具。[Harness capabilities](harness-capabilities.md) 列出各 Harness 在哪些组合中支持子 Agent。 @@ -56,7 +56,7 @@ Core 根据已授权的 Session 绑定关系分配合公开 ID 和所有权。 **Codex.** 适配器会启用原生 `multi_agent` 特性,将嵌套深度设为 64,并把并发限制映射到 `agents.max_threads`。它会禁用原生钩子、插件、代码模式和 `multi_agent_v2`;如果原生钩子列表非空,或托管配置要求强制启用冲突特性,则拒绝启动。关闭和重新打开的事实来自直接工具输出,并与同一次调用的持久化完成记录相关联,因此需要原生持久化回执。原生 Turn 时间具有秒级精度。根级 Turn 完成后,子级文件工作可以在同一所有者下完成。超过调用方截止时间后,取消操作仍会在同一所有者下继续;后续调用可以确认已经收敛,而无需重复执行原生中断。 -**Claude Code.** 锁定版本 SDK 的原生 Agent 和 SendMessage 调用会运行唯一的子级类型 `oac_worker`;该类型继承模型,并可使用工作区中的 Bash、Agent 和 SendMessage;bridge 的 `subagent_resources` 特性控制是否启用它。子级使用原生 Bash,权限继承自父级的启动用户。私有子级记录用于确定父子关系、首次自身输入时间及后续自身 Turns;继承的父级上下文会被排除。查询所有者会在启动前接纳子级,并将子级历史保留至工作收敛。已确认的取消操作会写入不可变的操作回执,因为原生中止可能不会留下终态记录。不存在关闭操作:已完成或已取消的子级保持 active 状态。向运行中的子级发送消息、使用后台工作、采用其他子级配置以及按次覆盖模型都会被拒绝。[Claude SDK adapter](https://github.com/MiniMax-AI/OpenAgentCore/blob/main/packages/claude-sdk-adapter/README.md#subagents) 记录了详细信息。 +**Claude Code.** 锁定版本 SDK 的原生 Agent 和 SendMessage 调用会运行唯一的子级类型 `oac_worker`;该类型继承模型;在工作区位置上继承父级的原生工具,否则只有 Agent 和 SendMessage;bridge 的 `subagent_resources` 特性控制是否启用它。子级使用原生 Bash,权限继承自父级的启动用户。私有子级记录用于确定父子关系、首次自身输入时间及后续自身 Turns;继承的父级上下文会被排除。查询所有者会在启动前接纳子级,并将子级历史保留至工作收敛。已确认的取消操作会写入不可变的操作回执,因为原生中止可能不会留下终态记录。不存在关闭操作:已完成或已取消的子级保持 active 状态。向运行中的子级发送消息、使用后台工作、采用其他子级配置以及按次覆盖模型都会被拒绝。[Claude SDK adapter](https://github.com/MiniMax-AI/OpenAgentCore/blob/main/packages/claude-sdk-adapter/README.md#subagents) 记录了详细信息。 **MiniMax Code.** 原生 ACP 委派(`task`、`task_append`、`task_stop`)会创建子级。Session 私有 SQLite 记录提供子级身份、已接受的输入、终态时间和自身消息。原生 task-create 事务会在启动前强制执行并发限制,并且原生准备过程必须在任何模型输入前确认该限制和受限工具配置。不存在关闭或重新打开操作,原生工作器也不会委派嵌套工作。 diff --git a/docs/maintainers.md b/docs/maintainers.md index cb9b45a5c..a71d5075e 100644 --- a/docs/maintainers.md +++ b/docs/maintainers.md @@ -72,7 +72,7 @@ docker build --platform linux/amd64 -t oac-runtime:codex "${OAC_DEV_HOME:-$HOME/ The script checks the package version, builds `oac-daemon` for Linux amd64 and prepares a context with only the daemon, the unmodified native executable, its resources, the shared login-profile build step and `services/core/deploy/codex/Dockerfile`. -**Claude Code Runtime image.** Node 20 or newer and pnpm are required. +**Claude Code Runtime image.** Node 22.21 or newer and pnpm are required. ```sh make build-claude-sdk-runtime diff --git a/docs/zh/maintainers.md b/docs/zh/maintainers.md index 88e113d5e..c9623927c 100644 --- a/docs/zh/maintainers.md +++ b/docs/zh/maintainers.md @@ -1,7 +1,7 @@ --- title: "构建并发布 OpenAgentCore" source: docs/maintainers.md -source_hash: ed8fc3819a4f16c96a56b4f10c47ca099ce5ca6c42e79700debeddaf913ee0cb +source_hash: 8f41d8581db31b3a8bac4c54cc2808cf30f70c609c852cd9172d915ad466abf2 --- 本指南面向负责构建和发布 OpenAgentCore 的维护者。要安装 Core 和 Web,请按 [安装 Core 和 Web](getting-started/install.md) 操作。安装程序代码遵循的规则见 [部署](https://github.com/MiniMax-AI/OpenAgentCore/blob/main/deploy/README.md) 和 [节点安装程序](https://github.com/MiniMax-AI/OpenAgentCore/blob/main/deploy/node/README.md);必需检查见 [CONTRIBUTING](https://github.com/MiniMax-AI/OpenAgentCore/blob/main/CONTRIBUTING.md#required-checks)。 @@ -74,7 +74,7 @@ docker build --platform linux/amd64 -t oac-runtime:codex "${OAC_DEV_HOME:-$HOME/ 该脚本会检查软件包版本,为 Linux amd64 构建 `oac-daemon`,并准备一个仅包含守护进程、未修改的原生可执行文件、相关资源、共用的登录 profile 构建步骤和 `services/core/deploy/codex/Dockerfile` 的上下文。 -**Claude Code Runtime 镜像。** 必须使用 Node 20 或更高版本以及 pnpm。 +**Claude Code Runtime 镜像。** 必须使用 Node 22.21 或更高版本以及 pnpm。 ```sh make build-claude-sdk-runtime diff --git a/internal/harnessconfig/claudesdk/declaration.go b/internal/harnessconfig/claudesdk/declaration.go index d52648303..b155f8192 100644 --- a/internal/harnessconfig/claudesdk/declaration.go +++ b/internal/harnessconfig/claudesdk/declaration.go @@ -53,8 +53,9 @@ var Declaration = harnessconfig.Declaration{ }, // The SDK takes effort low to max. It has no verbosity, summary, service // tier, search mode or search setting, and enabling programmatic tool calling - // is not wired. Search is off in the fixed tool inventory, so disabled is the - // only mode; medium is the official default verbosity and needs no setting. + // is not wired. Native search has no cached mode or settings, so disabled is + // the only selectable mode and an absent control keeps native search; medium + // is the official default verbosity and needs no setting. Execution: harnessconfig.ExecutionOptions{ WebSearchModes: []string{"disabled"}, ReasoningEfforts: []string{"low", "medium", "high", "xhigh", "max"}, diff --git a/packages/claude-sdk-adapter/README.md b/packages/claude-sdk-adapter/README.md index f0a8b4a41..7ec44761d 100644 --- a/packages/claude-sdk-adapter/README.md +++ b/packages/claude-sdk-adapter/README.md @@ -26,13 +26,13 @@ With `observe_messages`, it also emits the neutral `output_message` start/comple ### Workspace execution -`claudesdk.Config.Workspace` is an operator binding for the selected workspace and native state. It enables native Bash/Read/Edit and admitted host functions in the SDK loop. 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 authorizes unattended execution in native `default` permission mode. It does not use the CLI permission-bypass flag, which Claude rejects for root accounts. +`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. -`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. 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. +`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. ### Executor preparation and Turns -The private bridge accepts `executor_prepare` without model input. It freezes validated configuration and resume identity, checks required history, and retains one native process and SDK Query across Turns. Preparation requires initialization and acknowledgement of required hooks while the input iterator remains empty. An `executor_ready` receipt permits later `turn_start` messages containing only Turn identity and ordered input; configuration replacement and concurrent starts are rejected. Every Turn event carries its originating `turn_id`. Native Session identity and actual tool inventory are checked before `input_ready`. +The private bridge accepts `executor_prepare` without model input. It freezes validated configuration and resume identity, checks required history, and retains one native process and SDK Query across Turns. Preparation requires initialization and acknowledgement of required hooks while the input iterator remains empty. An `executor_ready` receipt permits later `turn_start` messages containing only Turn identity and ordered input; configuration replacement and concurrent starts are rejected. Every Turn event carries its originating `turn_id`. Native Session identity and the tool inventory the profile requires are checked before `input_ready`. Each Turn ends with a result/error and `turn_settled`, independently of process exit. The outer input iterator remains open for later Turns. `turn_cancel` invokes the native interrupt control for that exact Turn. Unconfirmed input, native child work or queue state invalidates the Executor and requires close before replacement. EOF, owner signals and invalid control input close owned resources. Preparation may write native metadata and perform startup traffic; readiness does not prove provider authentication, complete sandbox health or placement authorization. @@ -54,7 +54,7 @@ Private workspace execution requires the packaged `workspace_command_observation The private adapter accepts typed anonymous HTTP and static-bearer HTTPS MCP declarations on the trusted `environment:none` harness host. The packaged readiness report must include `mcp_http_tools`; discovery advertises that feature only when present, and execution rechecks the installed bundle before dispatching an MCP request. An unchanged SDK version alone cannot qualify an older bridge. Authenticated private requests also require the packaged `mcp_http_bearer_auth` feature at discovery and dispatch. The daemon generates a separate environment reference for each server and launch; only those references enter the bridge request and native SDK configuration. The native HTTP client expands them from its owned process environment. Literal bearers must never enter SDK MCP headers because that configuration enters argv. Readiness probes receive no per-request bearer environment. Token validation is shared with the Codex adapter; credential storage remains an opaque-string contract. Public MCP admission, Vault credential selection and their failure rules belong to [public MCP connection origin](../../contracts/agents-api/environments.md#public-mcp-connection-origin) and [credential selection](../../contracts/agents-api/vaults.md#credential-selection-in-a-session). -MCP queries use the SDK's main-thread Agent definition to restrict model-visible tools, in addition to empty built-ins, strict MCP configuration, empty setting sources and default-deny permissions. Permission allowlists alone do not restrict the native model inventory. Null selects all tools from a declared server; an empty list selects none. Host functions compose with those selections. Native server status supplies original tool identities; map their normalized native aliases while preserving the original names in observations. Native status deduplicates aliases, so it does not prove a complete original server inventory. A native PreToolUse hook waits for inventory verification before admitting root calls and denies unverified, mismatched or cancelled calls. The native Agent restriction controls model-visible tools; inventory verification is not a barrier before the model request. Anonymous HTTP declarations explicitly set an empty Authorization header to disable native OAuth and automatic credential injection. Preserve that header; do not erase native history or credentials to enforce this boundary. Servers that reject a blank Authorization header, normalized name collisions and inventory changes during a query require separate validation; this profile covers static inventories. Private SDK status/control objects can contain expanded authentication headers. Read only connection and tool identity fields; never retain, log or publish raw status/configuration or control responses. Diagnostic projections must whitelist safe fields; filtering actual model or tool output does not fix a leak. The adapter profile requires connected servers, reserves the `functions` label, accepts alphanumeric/underscore/hyphen server labels and alphanumeric/underscore/hyphen/dot selected tool names, and excludes remote environments. Required startup is separately qualified by `mcp_http_required`. All HTTP MCP queries use native SDK startup and an empty input iterator to confirm initialization hooks. Required declarations additionally check connected server status before the initial prompt is released exactly once. Pending, failed, missing or ambiguous required status rejects before input; native startup timeouts are retained without an adapter retry loop. Normal system/init still verifies Session identity and the complete inventory before input readiness/tool authority. Optional servers keep their inventory checks without a pre-input connection requirement. A Runtime must advertise the concrete required-initialization capability; there is no fallback to another execution path. +Before the native query starts, the adapter lists each server with a nonempty `allowed_tools` subset over Streamable HTTP with the native configuration's URL and bearer header, and passes the native names of the unselected tools in `disallowedTools`; a listing failure fails the query without retry. The PreToolUse hook denies any unselected call as a backstop. Null selects all tools from a declared server; an empty list selects none. Host functions compose with those selections. Native server status supplies original tool identities; map their normalized native aliases while preserving the original names in observations. Native status deduplicates aliases, so it does not prove a complete original server inventory. A native PreToolUse hook waits for inventory verification before admitting root calls and denies unverified, mismatched or cancelled calls. Inventory verification is not a barrier before the model request. Anonymous HTTP declarations explicitly set an empty Authorization header to disable native OAuth and automatic credential injection. Preserve that header; do not erase native history or credentials to enforce this boundary. Servers that reject a blank Authorization header, normalized name collisions and inventory changes during a query require separate validation; this profile covers static inventories. Private SDK status/control objects can contain expanded authentication headers. Read only connection and tool identity fields; never retain, log or publish raw status/configuration or control responses. Diagnostic projections must whitelist safe fields; filtering actual model or tool output does not fix a leak. The adapter profile requires connected servers, reserves the `functions` label, accepts alphanumeric/underscore/hyphen server labels and alphanumeric/underscore/hyphen/dot selected tool names, and excludes remote environments. Required startup is separately qualified by `mcp_http_required`. All HTTP MCP queries use native SDK startup and an empty input iterator to confirm initialization hooks. Required declarations additionally check connected server status before the initial prompt is released exactly once. Pending, failed, missing or ambiguous required status rejects before input; native startup timeouts are retained without an adapter retry loop. Normal system/init still verifies Session identity before input readiness/tool authority, with the complete inventory on `none` and the presence of every tool Core declared on a workspace. Optional servers without a subset keep their inventory checks without a pre-input connection requirement. A Runtime must advertise the concrete required-initialization capability; there is no fallback to another execution path. Root assistant tool calls and live root user results produce the neutral MCP observations. Correlate actual Session/call identities; exclude replay, synthetic and subagent work and keep host function receipts separate. Preserve the exact native `tool_use_result` when one result is unambiguous, otherwise the per-call result content. Native errors remain observed native errors. The SDK can replace annotated MCP content with rendered structuredContent and flatten MCP errors; these observations do not claim original MCP envelope fidelity or hosted output parity. Do not reconstruct lost fields or infer output from model prose. Unfinished observed calls become incomplete on shutdown, without claiming that remote tool effects were cancelled. Rich content, native truncation and asynchronous MCP task results remain unverified. @@ -70,7 +70,7 @@ SDK state lives under `paths.ProfileDir(profile)/runtime/claude-sdk`, independen ### Descriptor and execution profile -The SDK descriptor advertises the validated daemon subset, including durable Turns/input receipts, text observations, function tools, raw usage and restrictive execution controls. It does not advertise permissions, raw tool Items, general web-search control or text-verbosity levels. Router admission for `environment:none` uses the available engine capability, not an engine name. +The SDK descriptor advertises the validated daemon subset, including durable Turns/input receipts, text observations, function tools, raw usage and restrictive execution controls. It does not advertise permissions, raw tool Items, enabled web-search modes or text-verbosity levels. Router admission for `environment:none` uses the available engine capability, not an engine name. Core owns public admission for Claude: [harness selection](../../contracts/agents-api/model-execution.md#harness-selection) chooses the engine for each Session; one [engine policy](../../contracts/agents-api/harness-onboarding.md#declare-the-harness) serves API admission, device selection and the final claim; the [supported operations](../../contracts/agents-api/harness-capabilities.md) table records Claude's medium-only verbosity and object-root function schemas; [function result images](../../contracts/agents-api/message-content.md#function-results) defines which placements accept image results; and [deployment defaults](../../contracts/agents-api/model-execution.md#deployment-defaults) define the model provider Core freezes for a Session. The adapter receives that provider as the adapter-owned `model_provider`, never in public Session configuration. Without one, a `none` host uses the daemon's own provider environment. The adapter alone selects the provider environment and removes credentials from native tool environments. @@ -102,16 +102,16 @@ The first own native user record has a null `parentUuid`. Its timestamp supplies The native query owns child execution and cleanup. PreToolUse admission reserves each native Agent or idle-child SendMessage call before execution. Native `task_started` associates the call and child ID; completion releases that reservation. The frozen limit applies across the child tree, excludes the root, and defaults to six. Unknown call associations fail closed. SendMessage to a running child is rejected; only idle continuation is qualified. Native background execution, alternate agent types, worktree isolation and per-call model overrides are rejected. -Workspace children use native Bash with the same launching-user permissions as the parent, and the daemon and adapter add no filesystem, permission or network sandbox. A child's workspace tool calls are denied until native task admission has verified its identity. Workspace hooks keep their execution and event responsibilities but are not a private-file boundary: tools can access Runtime state that the host user can access. Isolation belongs to the outer Environment. Functions and MCP combined with Subagents are not qualified and are rejected explicitly ([subagents contract](../../contracts/agents-api/subagents.md)); functions and MCP without Subagents are unaffected. Claude on Windows requires Git Bash. +Workspace children use native Bash with the same launching-user permissions as the parent, and the daemon and adapter add no filesystem, permission or network sandbox. A child's Agent and SendMessage calls are denied until native task admission has verified its identity. Workspace hooks keep their execution and event responsibilities but are not a private-file boundary: tools can access Runtime state that the host user can access. Isolation belongs to the outer Environment. Functions and MCP combined with Subagents are not qualified and are rejected explicitly ([subagents contract](../../contracts/agents-api/subagents.md)); functions and MCP without Subagents are unaffected. Claude on Windows requires Git Bash. Cancellation uses an adapter-owned effect receipt only after the query owner confirms native process exit, because the fixed native history can end at a tool call without a cancellation result or timestamp. Each receipt is linked into the native history directory atomically, without overwriting an earlier receipt, and records the child, own Turn, spawn call and confirmed effect time. Native records stay unchanged. Replay uses that same timestamp; it never takes a new cancellation time from an unfinished history. Child Items and the cancelled Turn precede the root cancellation event. Missing native exit confirmation or a missing receipt does not imply a terminal child state. ## Runtime artifact -`make build-claude-sdk-runtime` exports the compiled bridge and pinned production SDK/MCP dependencies, including the native package for the build host, into a platform/architecture/libc-specific `.tar.gz` and SHA256 file under `${OAC_DEV_HOME:-$HOME/.oac}/build/claude-sdk-runtime`. `CLAUDE_SDK_BUILD_DIR` may select another absolute output directory. The production dependency closure requires Node20 or newer; Node22 is the tested version. This standalone archive requires operator-supplied Node and is independent of product sources, services and databases. It does not add Node or SDK assets to the Agents API binaries/image. +`make build-claude-sdk-runtime` exports the compiled bridge and pinned production SDK/MCP dependencies, including the native package for the build host, into a platform/architecture/libc-specific `.tar.gz` and SHA256 file under `${OAC_DEV_HOME:-$HOME/.oac}/build/claude-sdk-runtime`. `CLAUDE_SDK_BUILD_DIR` may select another absolute output directory. The bridge requires Node 22.21 or newer, or 24.5 or newer on Node 24, which accept `--use-env-proxy`; Node 22 is the tested version. This standalone archive requires operator-supplied Node and is independent of product sources, services and databases. It does not add Node or SDK assets to the Agents API binaries/image. The build validates source manifests with the repository-pinned pnpm frozen install and compiles into fresh managed staging, never exporting incremental checkout output. It then uses `pnpm deploy` from the adapter workspace with its dedicated frozen lock. The adapter has no workspace dependencies; keep that boundary explicit. Workspace injection is configured only in this adapter project for its frozen export; do not enable it in the Web/client workspace or replace export with a custom dependency copier. Export only compiled `dist` and production dependencies; retain their package metadata, lockfile and licenses. Check dependency links stay inside the export, pinned SDK/MCP/native versions, native `--version`, and bridge startup before publishing the archive. Startup with stdin EOF is an import check, not model execution acceptance. `make check-claude-sdk` includes this artifact check. Extract the archive into a fresh managed runtime directory on a matching host and use its absolute `dist/main.js` as the private factory entrypoint. Validate relocation and real provider cancellation/continuation before accepting an artifact. Linux x64/glibc with Node22 is the currently exercised platform; other hosts require their own native acceptance. Do not reuse a bundle across platforms or libc variants. The native installer bundles Node and owns user-managed activation; release publication remains separate. Operator-configured discovery and registration remain available for unmanaged bootstrap as specified above. -The exported `dist/runtime_check.js` companion is the local readiness contract. It checks Node20+, installed SDK/MCP/native versions against the package manifest, contained dependency resolution, native startup, and the exact `dist/main.js` bridge with stdin EOF. It emits one versioned JSON report without calling a model or creating Session state. The artifact check reuses this companion and separately checks all exported links, the lockfile and source pins. `claudesdk.CheckRuntime` uses the same Node, entrypoint and environment as execution, with shared process-group ownership, bounded output and a 15-second deadline plus bounded cleanup. Both native and bridge probes have five-second limits. Return unavailable on failed or malformed probes; never forward native diagnostics or treat local readiness as provider authentication, public capability acceptance or filesystem isolation. The native installer reuses this readiness check after copying its release components. +The exported `dist/runtime_check.js` companion is the local readiness contract. It checks the Node version, installed SDK/MCP/native versions against the package manifest, contained dependency resolution, native startup, and the exact `dist/main.js` bridge with stdin EOF. It emits one versioned JSON report without calling a model or creating Session state. The artifact check reuses this companion and separately checks all exported links, the lockfile and source pins. `claudesdk.CheckRuntime` uses the same Node, entrypoint and environment as execution, with shared process-group ownership, bounded output and a 15-second deadline plus bounded cleanup. Both native and bridge probes have five-second limits. Return unavailable on failed or malformed probes; never forward native diagnostics or treat local readiness as provider authentication, public capability acceptance or filesystem isolation. The native installer reuses this readiness check after copying its release components. diff --git a/packages/claude-sdk-adapter/src/adapter.ts b/packages/claude-sdk-adapter/src/adapter.ts index e0375740f..6a9115706 100644 --- a/packages/claude-sdk-adapter/src/adapter.ts +++ b/packages/claude-sdk-adapter/src/adapter.ts @@ -5,7 +5,7 @@ import { toolSearchEnvironment } from "./tool_search.js"; import { Subagents } from "./subagents.js"; import type { Fact } from "./subagent_history.js"; import { WorkspaceDirectories, type WorkspaceDirectoryEvent } from "./workspace_directories.js"; -import { getSessionInfo, query, startup, type McpServerConfig, type Options, type WarmQuery } from "@anthropic-ai/claude-agent-sdk"; +import { getSessionInfo, query, startup, type HookCallback, type McpServerConfig, type Options, type WarmQuery } from "@anthropic-ai/claude-agent-sdk"; import { WorkspaceReads, type WorkspaceReadEvent } from "./workspace_reads.js"; import { Inputs, type InputEvent } from "./inputs.js"; import { executorResultUsage, resultUsage, type NativeUsage } from "./usage.js"; @@ -16,6 +16,7 @@ import { FunctionBridge, type FunctionEvent } from "./function_bridge.js"; import { MCPProfile } from "./mcp.js"; import { MCPObserver, type MCPEvent } from "./mcp_observer.js"; import { CommandObserver, type CommandEvent } from "./command_observer.js"; +import { SearchObserver, type SearchEvent } from "./search_observer.js"; import { WorkspaceProfile } from "./workspace.js"; import { immediateInput, type Prepare, type Start, type ExecutorPrepare } from "./request.js"; import { recoverSession } from "./recovery.js"; @@ -31,6 +32,7 @@ export type Event = | FunctionEvent | MCPEvent | CommandEvent + | SearchEvent | { type: "prepared" } | { type: "usage"; session_id: string; result_id: string; usage: NativeUsage } | { type: "delta"; delta: string } @@ -49,8 +51,9 @@ export async function execute(request: Start | Prepare | ExecutorPrepare, emit: const declarations = request.workspace?.mcp ?? request.mcp_http_servers; const profile = declarations === undefined ? undefined : new MCPProfile(declarations, names); const subagents = request.subagents ? new Subagents(request.cwd, request.subagents.max_concurrent, request.resume) : undefined; - const workspace = request.workspace === undefined ? undefined : new WorkspaceProfile(request.cwd, request.workspace, names, profile, subagents, !!request.output_format, !!request.tool_search); + const workspace = request.workspace === undefined ? undefined : new WorkspaceProfile(request.cwd, request.workspace, profile, subagents, !!request.disable_web_search); let commands = workspace ? new CommandObserver() : undefined; + let searches = workspace ? new SearchObserver() : undefined; if (request.type === "prepare" && !workspace) throw new Error("invalid_request"); if (workspace && "mcp_http_servers" in request) throw new Error("invalid_request"); if (request.require_history && !request.resume) { @@ -85,34 +88,45 @@ export async function execute(request: Start | Prepare | ExecutorPrepare, emit: abort.signal.addEventListener("abort", closeInputs, { once: true }); try { if (abort.signal.aborted) throw new Error("cancelled"); + // The model sees only the selected tools of a subset server; the hook below remains the backstop. + const unselected = profile ? await profile.unselectedTools(abort.signal) : []; + const beforeTool: HookCallback = async (input, id, context) => { + if (input.hook_event_name !== "PreToolUse") return {}; + if (subagents?.isCoordination(input.tool_name)) return subagents.beforeTool(input, id, context); + const admission = profile ? await profile.beforeTool(input, id, context) : {}; + if ("hookSpecificOutput" in admission && admission.hookSpecificOutput?.hookEventName === "PreToolUse" && + admission.hookSpecificOutput.permissionDecision === "deny") return admission; + return workspace ? workspace.beforeTool(input, id, context) : admission; + }; const options: Options = { ...(request.native_model_options?.effort !== undefined ? { effort: request.native_model_options.effort } : {}), ...(request.native_model_options?.thinking !== undefined ? { thinking: request.native_model_options.thinking } : {}), cwd: request.cwd, - env: workspace?.options.env ?? { ...process.env, ...(subagents ? { CLAUDE_CODE_DISABLE_BACKGROUND_TASKS: "1" } : {}) }, + env: workspace?.options.env ?? { ...process.env }, + // Flag settings outrank user and project settings, so no settings file brings back detached work after a + // Turn or updates of the pinned binary. + settings: { env: { CLAUDE_CODE_DISABLE_BACKGROUND_TASKS: "1", DISABLE_AUTOUPDATER: "1" } }, model: request.model, ...(request.output_format ? { outputFormat: request.output_format } : {}), - systemPrompt: request.system_prompt, + // On a workspace Claude Code keeps its own prompt and the Agent's instructions are appended; that prompt + // describes tools environment none does not have. + systemPrompt: workspace ? { type: "preset", preset: "claude_code", ...(request.system_prompt ? { append: request.system_prompt } : {}) } : request.system_prompt, ...(request.resume ? { resume: request.resume } : {}), - tools: subagents ? ["Agent", "SendMessage"] : request.tool_search ? ["ToolSearch"] : [], allowedTools: profile?.allowed ?? allowed, strictMcpConfig: true, settingSources: [], - ...(profile ? { - agent: "oac_root", disallowedTools: profile.denied, - hooks: { PreToolUse: [{ hooks: [profile.beforeTool] }] }, - agents: { oac_root: { description: "Execution root.", prompt: request.system_prompt, - model: request.model, tools: [...(Array.isArray(workspace?.options.tools) ? workspace.options.tools : []), ...profile.allowed] } }, - } : {}), - persistSession: true, includePartialMessages: true, abortController: abort, - canUseTool: async () => ({ behavior: "deny", message: "Tools are unavailable in this execution profile." }), + includePartialMessages: true, abortController: abort, + // Environment none keeps no workspace tools and approves only Core's function and MCP tools. The host's + // settings, memory files and MCP servers belong to no Session. + ...(workspace ? {} : { + tools: subagents ? ["Agent", "SendMessage"] : request.tool_search ? ["ToolSearch"] : [], allowedTools: profile?.allowed ?? allowed, + settingSources: [], strictMcpConfig: true, + canUseTool: async () => ({ behavior: "deny" as const, message: "Tools are unavailable in this execution profile." }), + }), ...(workspace?.options ?? {}), + disallowedTools: [...(workspace?.options.disallowedTools ?? []), ...(profile?.denied ?? []), ...unselected], mcpServers, ...(subagents ? { ...subagents.options(!!workspace), hooks: { Stop: [{ hooks: [subagents.stopped] }], SubagentStart: [{ hooks: [subagents.childStart] }], - PostToolUseFailure: [{ hooks: [subagents.failedTool] }], - PreToolUse: [{ hooks: [async (input, id, context) => { - if (input.hook_event_name === "PreToolUse" && subagents.isCoordination(input.tool_name)) return subagents.beforeTool(input, id, context); - return workspace ? workspace.beforeTool(input, id, context) : {}; - }] }], - } } : {}), + PostToolUseFailure: [{ hooks: [subagents.failedTool] }], PreToolUse: [{ hooks: [beforeTool] }], + } } : workspace || profile ? { hooks: { PreToolUse: [{ hooks: [beforeTool] }] } } : {}), spawnClaudeCodeProcess: options => { const child = spawnNative(options); nativeAlive = true; @@ -149,6 +163,7 @@ export async function execute(request: Start | Prepare | ExecutorPrepare, emit: messages=request.observe_messages ? new MessageObserver() : undefined; structured=request.output_format ? new StructuredOutput() : undefined; commands=workspace ? new CommandObserver() : undefined; + searches=workspace ? new SearchObserver() : undefined; mcp=profile ? new MCPObserver(profile.identities) : undefined; subagents?.beginTurn(); return inputs; @@ -179,15 +194,21 @@ export async function execute(request: Start | Prepare | ExecutorPrepare, emit: await functions.consume(message, nativeID, turns?.cancelled); if (mcp) for (const event of mcp.consume(message, nativeID)) await emit(event); if (commands) for (const event of commands.consume(message, nativeID, inputs.hasInput)) await emit(event); + if (searches) for (const event of searches.consume(message, nativeID)) await emit(event); if (messages) for (const event of messages.consume(message)) await emit(event); if (message.type === "system" && message.subtype === "init") { nativeID = message.session_id; if (!nativeID || (request.resume && nativeID !== request.resume)) throw new Error("unexpected native session"); - if (workspace) workspace.verify(message.tools, profile ? await stream.mcpServerStatus() : message.mcp_servers, nativeID); - else if (profile) profile.verify(message.tools, await stream.mcpServerStatus(), nativeID); - else if (message.tools.length !== allowed.length + (subagents ? 2 : 0) + (structured ? 1 : 0) || message.tools.some(name => ![...allowed, ...(subagents ? ["Task", "SendMessage"] : []), ...(structured ? ["StructuredOutput"] : [])].includes(name)) || + if (profile) profile.verify(message.tools, await stream.mcpServerStatus(), nativeID, !workspace); + if (workspace) { + // Native tools and settings may add servers and tools, but every tool and Skill Core declared must still be present. + const declared = [...names, ...(workspace.skillNames.length ? ["Skill"] : []), ...(request.tool_search ? ["ToolSearch"] : []), + ...(structured ? ["StructuredOutput"] : []), ...(subagents ? ["Task", "SendMessage"] : [])]; + if (declared.some(name => !message.tools.includes(name)) || workspace.skillNames.some(name => !message.skills?.includes(name)) || + definitions.length && !message.mcp_servers.some(server => server.name === "functions" && server.status === "connected")) throw new Error("unexpected native configuration"); + } else if (!profile && (message.tools.length !== allowed.length + (subagents ? 2 : 0) + (structured ? 1 : 0) || message.tools.some(name => ![...allowed, ...(subagents ? ["Task", "SendMessage"] : []), ...(structured ? ["StructuredOutput"] : [])].includes(name)) || message.mcp_servers.length !== (definitions.length ? 1 : 0) || - message.mcp_servers.some(server => server.name !== "functions" || server.status !== "connected")) { + message.mcp_servers.some(server => server.name !== "functions" || server.status !== "connected"))) { throw new Error("unexpected native configuration"); } turns?.identify(nativeID); @@ -212,7 +233,7 @@ export async function execute(request: Start | Prepare | ExecutorPrepare, emit: if(turns && result && inputs.complete && (!subagents || subagents.complete)) { if (turns.cancelled) functions.cancelUnanswered(); if(!await turns.quiescent() || abort.signal.aborted) throw new Error("unconfirmed interrupt"); - functions.assertComplete(); mcp?.assertComplete(); commands?.assertComplete(); + functions.assertComplete(); mcp?.assertComplete(); commands?.assertComplete(); searches?.assertComplete(); if(subagents) for(const event of await subagents.facts()) await emit(event); await emit(turns.cancelled ? {type:"error",code:"cancelled"} : result); functions.close(); @@ -224,6 +245,7 @@ export async function execute(request: Start | Prepare | ExecutorPrepare, emit: functions.assertComplete(); mcp?.assertComplete(); commands?.assertComplete(); + searches?.assertComplete(); } catch (error) { if (error !== nativeResultFailure) classifiedFailure = undefined; failed = true; @@ -247,6 +269,7 @@ export async function execute(request: Start | Prepare | ExecutorPrepare, emit: } if (mcp) for (const event of mcp.close()) await emit(event); if (commands) for (const event of commands.close()) await emit(event); + if (searches) for (const event of searches.close(nativeID)) await emit(event); } if(turns) { if(turns.id) { diff --git a/packages/claude-sdk-adapter/src/mcp.ts b/packages/claude-sdk-adapter/src/mcp.ts index 453122181..72118da0d 100644 --- a/packages/claude-sdk-adapter/src/mcp.ts +++ b/packages/claude-sdk-adapter/src/mcp.ts @@ -1,4 +1,6 @@ import type { StdioServer } from "./mcp_environment.js"; +import { Client } from "@modelcontextprotocol/sdk/client/index.js"; +import { StreamableHTTPClientTransport } from "@modelcontextprotocol/sdk/client/streamableHttp.js"; import type { HookCallback, McpServerConfig, McpServerStatus } from "@anthropic-ai/claude-agent-sdk"; export type HTTPServer = { @@ -52,11 +54,15 @@ export class MCPProfile { readonly denied: string[] = []; readonly identities = new Map(); private sessionID = ""; - private localTools = new Set(); private admitted = false; private release!: (ready: boolean) => void; private readonly ready = new Promise(resolve => { this.release = resolve; }); + // Declared servers expose only their selected tools. Other native tools are not Core's to restrict. + private unselected(name: string): boolean { + return !this.identities.has(name) && this.declarations.some(server => name.startsWith(`mcp__${server.server_label}__`)); + } + readonly beforeTool: HookCallback = async (input, id, { signal }) => { let stopped!: () => void; const interrupted = new Promise(resolve => { @@ -66,8 +72,7 @@ export class MCPProfile { try { if (!signal.aborted && await Promise.race([this.ready, interrupted]) && !signal.aborted && this.admitted && input.hook_event_name === "PreToolUse" && input.agent_id === undefined && input.session_id === this.sessionID && - (id === undefined || id === input.tool_use_id) && - (this.identities.has(input.tool_name) || this.functions.includes(input.tool_name) || this.localTools.has(input.tool_name))) return {}; + (id === undefined || id === input.tool_use_id) && !this.unselected(input.tool_name)) return {}; return { hookSpecificOutput: { hookEventName: "PreToolUse", permissionDecision: "deny", permissionDecisionReason: "Tool is outside the verified execution profile." } }; } finally { @@ -95,24 +100,63 @@ export class MCPProfile { } } - verify(inventory: string[], statuses: McpServerStatus[], sessionID: string, localTools: readonly string[] = []): void { + // Lists each subset server with the credentials of its native configuration and returns the native names of the tools it did not select. + // A server that cannot be listed fails the query, as a native connection failure does at initialization. + async unselectedTools(signal: AbortSignal): Promise { + const hidden: string[] = []; + for (const server of this.declarations) { + if ("command" in server || !server.allowed_tools?.length) continue; + const selected = new Set(server.allowed_tools.map(name => nativeToolName(server.server_label, name))); + const reference = server.bearer_token_env_var; + const client = new Client({ name: "oac-claude-sdk-adapter", version: "0.0.0" }); + try { + await client.connect(new StreamableHTTPClientTransport(new URL(server.server_url), + { requestInit: reference ? { headers: { Authorization: `Bearer ${process.env[reference]}` } } : {} }), { signal }); + let cursor: string | undefined; + const cursors = new Set(); + do { + const page = await client.listTools({ cursor }, { signal }); + for (const tool of page.tools) { + const native = nativeToolName(server.server_label, tool.name); + if (!selected.has(native)) hidden.push(native); + } + cursor = page.nextCursor; + // A repeated cursor would page forever. + if (cursor !== undefined && cursors.has(cursor)) throw new Error("MCP tool listing repeated a cursor"); + if (cursor !== undefined) cursors.add(cursor); + } while (cursor); + } finally { + await client.close(); + } + } + return hidden; + } + + // With exact inventory the text profile accepts nothing but the declared servers and tools. + verify(inventory: string[], statuses: McpServerStatus[], sessionID: string, exact = true): void { // Native status.config can contain expanded headers. Retain only identities; // never publish or persist the private SDK control response. this.admitted = false; const expected = new Map(); const declared = new Map(this.declarations.map(server => [server.server_label, server])); const seen = new Set(); - const baseline = [...this.functions, ...localTools]; - const nativeNames = new Set(baseline); + let matched = 0; + const nativeNames = new Set(this.functions); for (const status of statuses) { if (seen.has(status.name)) throw new Error("duplicate native MCP server"); seen.add(status.name); if (status.name === "functions" && this.functions.length) { if (status.status !== "connected") throw new Error("native function server unavailable"); + matched++; continue; } const server = declared.get(status.name); - if (!server || status.status !== "connected") throw new Error("native MCP server unavailable or undeclared"); + if (!server) { + if (exact) throw new Error("native MCP server unavailable or undeclared"); + continue; + } + if (status.status !== "connected") throw new Error("native MCP server unavailable or undeclared"); + matched++; for (const tool of status.tools ?? []) { const native = nativeToolName(server.server_label, tool.name); if (nativeNames.has(native)) throw new Error("ambiguous native MCP identity"); @@ -121,14 +165,14 @@ export class MCPProfile { expected.set(native, { server: server.server_label, name: tool.name }); } } - if (seen.size !== declared.size + (this.functions.length ? 1 : 0) || - inventory.length !== expected.size + baseline.length || new Set(inventory).size !== inventory.length || - inventory.some(name => !expected.has(name) && !baseline.includes(name))) { + if (matched !== declared.size + (this.functions.length ? 1 : 0) || + !exact && [...expected.keys()].some(name => !inventory.includes(name)) || + exact && (inventory.length !== expected.size + this.functions.length || new Set(inventory).size !== inventory.length || + inventory.some(name => !expected.has(name) && !this.functions.includes(name)))) { throw new Error("unexpected native MCP inventory"); } this.identities.clear(); for (const [name, identity] of expected) this.identities.set(name, identity); - this.localTools = new Set(localTools); this.sessionID = sessionID; this.admitted = true; this.release(true); @@ -143,8 +187,6 @@ export class MCPProfile { } } - permits(name: string): boolean { return this.admitted && this.identities.has(name); } - credentialReferences(): string[] { return this.declarations.flatMap(server => "bearer_token_env_var" in server && server.bearer_token_env_var ? [server.bearer_token_env_var] : []); } diff --git a/packages/claude-sdk-adapter/src/request.ts b/packages/claude-sdk-adapter/src/request.ts index 9cdd460ed..e39e5e97b 100644 --- a/packages/claude-sdk-adapter/src/request.ts +++ b/packages/claude-sdk-adapter/src/request.ts @@ -19,6 +19,7 @@ export type Start = { observe_messages?: boolean; subagents?: { max_concurrent: number }; tool_search?: boolean; + disable_web_search?: boolean; functions?: { name: string; description: string; parameters: Tool["inputSchema"]; defer_loading?: boolean }[]; mcp_http_servers?: HTTPServer[]; workspace?: Workspace; @@ -35,7 +36,7 @@ export function parseRequest(line: string): Start | Prepare | ExecutorPrepare { const value: unknown = JSON.parse(line); if (!value || typeof value !== "object" || Array.isArray(value)) throw new Error("invalid_request"); const request = value as Record; - const allowed = new Set(["native_model_options", "type", "input", "model", "system_prompt", "cwd", "resume", "require_history", "observe_messages", "output_format", "subagents", "functions", "tool_search", "mcp_http_servers", "workspace"]); + const allowed = new Set(["native_model_options", "type", "input", "model", "system_prompt", "cwd", "resume", "require_history", "observe_messages", "output_format", "subagents", "functions", "tool_search", "disable_web_search", "mcp_http_servers", "workspace"]); if (Object.keys(request).some(key => !allowed.has(key)) || (request.type !== "start" && request.type !== "prepare" && request.type !== "executor_prepare") || (request.type === "start" ? !Array.isArray(request.input) : "input" in request) || @@ -43,6 +44,7 @@ export function parseRequest(line: string): Start | Prepare | ExecutorPrepare { typeof request.system_prompt !== "string" || typeof request.cwd !== "string" || !isAbsolute(request.cwd) || (request.observe_messages !== undefined && typeof request.observe_messages !== "boolean") || + (request.disable_web_search !== undefined && typeof request.disable_web_search !== "boolean") || (request.require_history !== undefined && typeof request.require_history !== "boolean") || (request.resume !== undefined && (typeof request.resume !== "string" || !request.resume))) throw new Error("invalid_request"); if (request.functions !== undefined && (!Array.isArray(request.functions) || request.functions.some(tool => diff --git a/packages/claude-sdk-adapter/src/runtime_check.ts b/packages/claude-sdk-adapter/src/runtime_check.ts index 730030d6d..a9f47bdac 100644 --- a/packages/claude-sdk-adapter/src/runtime_check.ts +++ b/packages/claude-sdk-adapter/src/runtime_check.ts @@ -8,7 +8,9 @@ import { fileURLToPath } from "node:url"; // This companion checks the exact bridge used by execution, without a model query. try { - assert(Number(process.versions.node.split(".")[0]) >= 20, "unsupported_node"); + // --use-env-proxy, which routes the bridge's own fetch through the operator's proxy, needs 22.21 or 24.5. + const [major, minor] = process.versions.node.split(".").map(Number); + assert(major === 22 ? minor >= 21 : major === 24 ? minor >= 5 : major > 24, "unsupported_node"); const root = await realpath(fileURLToPath(new URL("..", import.meta.url))); const inside = async (path: string) => { const resolved = await realpath(path); diff --git a/packages/claude-sdk-adapter/src/search_observer.ts b/packages/claude-sdk-adapter/src/search_observer.ts new file mode 100644 index 000000000..d4eb60e7f --- /dev/null +++ b/packages/claude-sdk-adapter/src/search_observer.ts @@ -0,0 +1,60 @@ +import type { SDKMessage } from "@anthropic-ai/claude-agent-sdk"; + +type Action = { type: "search"; query: string } | { type: "open_page"; url: string }; +type Observation = { kind: "web_search"; status: "in_progress" | "completed" | "failed" | "incomplete"; action: Action }; +export type SearchEvent = { + type: "search_observation"; session_id: string; id: string; + stage: "before" | "after"; observation: Observation; +}; + +// Projects the native WebSearch and WebFetch calls of the root thread as web search Items. +export class SearchObserver { + private readonly calls = new Map(); + + *consume(message: SDKMessage, sessionID: string): Generator { + if ((message.type !== "assistant" && message.type !== "user") || message.parent_tool_use_id !== null || + ("isSynthetic" in message && message.isSynthetic) || ("isReplay" in message && message.isReplay)) return; + const content = message.message.content; + if (!Array.isArray(content)) return; + if (message.type === "assistant") { + if (message.error) return; + for (const block of content) { + if (block.type !== "tool_use" || this.calls.has(block.id)) continue; + const input = block.input as Record | null; + let action: Action | undefined; + if (block.name === "WebSearch" && typeof input?.query === "string") action = { type: "search", query: input.query }; + else if (block.name === "WebFetch" && typeof input?.url === "string") action = { type: "open_page", url: input.url }; + if (!action) continue; + const observation: Observation = { kind: "web_search", status: "in_progress", action }; + this.calls.set(block.id, observation); + yield this.event(sessionID, block.id, "before", observation); + } + } else { + for (const block of content) { + if (block.type !== "tool_result") continue; + const call = this.calls.get(block.tool_use_id); + if (!call || call.status !== "in_progress") continue; + call.status = block.is_error ? "failed" : "completed"; + yield this.event(sessionID, block.tool_use_id, "after", call); + } + } + } + + assertComplete(): void { + if ([...this.calls.values()].some(call => call.status === "in_progress")) throw new Error("unconfirmed search result"); + } + + close(sessionID: string): SearchEvent[] { + const events: SearchEvent[] = []; + for (const [id, call] of this.calls) { + if (call.status !== "in_progress") continue; + call.status = "incomplete"; + events.push(this.event(sessionID, id, "after", call)); + } + return events; + } + + private event(sessionID: string, id: string, stage: "before" | "after", observation: Observation): SearchEvent { + return { type: "search_observation", session_id: sessionID, id, stage, observation: { ...observation } }; + } +} diff --git a/packages/claude-sdk-adapter/src/subagents.ts b/packages/claude-sdk-adapter/src/subagents.ts index add5e689d..56df630c4 100644 --- a/packages/claude-sdk-adapter/src/subagents.ts +++ b/packages/claude-sdk-adapter/src/subagents.ts @@ -63,7 +63,8 @@ export class Subagents { options(workspace: boolean): Pick { return { forwardSubagentText: true, agents: { oac_worker: { description: "A subagent for delegated work in this Session.", prompt: "Complete the delegated task using the available tools.", model: "inherit", - tools: [...(workspace ? ["Bash"] : []), "Agent", "SendMessage"], + // A workspace worker inherits the native tools of its parent. + ...(workspace ? {} : { tools: ["Agent", "SendMessage"] }), } } }; } consume(message: SDKMessage): void { diff --git a/packages/claude-sdk-adapter/src/workspace.ts b/packages/claude-sdk-adapter/src/workspace.ts index f26d85424..bc4b2c68a 100644 --- a/packages/claude-sdk-adapter/src/workspace.ts +++ b/packages/claude-sdk-adapter/src/workspace.ts @@ -1,10 +1,10 @@ import type { Subagents } from "./subagents.js"; -import { parseEnvironmentMCP, type EnvironmentMCPServer } from "./mcp_environment.js"; import type { MCPProfile } from "./mcp.js"; +import { parseEnvironmentMCP, type EnvironmentMCPServer } from "./mcp_environment.js"; import { parseSkills, workspaceSkills, type WorkspaceSkill } from "./workspace_skills.js"; -import type { CanUseTool, HookCallback, Options } from "@anthropic-ai/claude-agent-sdk"; +import type { HookCallback, Options } from "@anthropic-ai/claude-agent-sdk"; import { realpathSync, statSync } from "node:fs"; -import { isAbsolute, parse, resolve } from "node:path"; +import { isAbsolute, parse } from "node:path"; export type Workspace = { tool_env?: Record; @@ -24,8 +24,6 @@ const environmentNames = new Set([ "ANTHROPIC_DEFAULT_SONNET_MODEL", "ANTHROPIC_DEFAULT_OPUS_MODEL", "ANTHROPIC_DEFAULT_HAIKU_MODEL", "CLAUDE_CODE_DISABLE_EXPERIMENTAL_BETAS", "HTTP_PROXY", "HTTPS_PROXY", "NO_PROXY", ]); -const nativeTools = ["Bash", "Read", "Edit"]; -const denial = "Tool is outside the workspace execution profile."; const invalidPath = /[\x00-\x1f\x7f]/; @@ -64,18 +62,17 @@ export function parseWorkspace(value: unknown, cwd: string): Workspace | undefin export class WorkspaceProfile { readonly options: Options; - private readonly skillNames: readonly string[]; + readonly skillNames: readonly string[]; - constructor(private readonly cwd: string, private readonly config: Workspace, private readonly functions: readonly string[] = [], private readonly mcp?: MCPProfile, private readonly subagents?: Subagents, private readonly structuredOutput = false, private readonly toolSearch = false) { + constructor(cwd: string, config: Workspace, mcp?: MCPProfile, subagents?: Subagents, disableWebSearch = false) { config = parseWorkspace(config, cwd)!; // SDK history lookup reads the bridge environment, independently of query.env. if (process.env.HOME !== config.home || process.env.CLAUDE_CONFIG_DIR !== config.state || process.env.CLAUDE_CODE_PROJECT_DIR_NAME !== undefined) throw new Error("invalid_request"); const env: Record = { ...Object.fromEntries(Object.entries(process.env).filter((entry): entry is [string,string] => entry[1] !== undefined)), + // Private state paths. HOME: config.home, TMPDIR: config.scratch, CLAUDE_CONFIG_DIR: config.state, - CLAUDE_CODE_DISABLE_NONESSENTIAL_TRAFFIC: "1", DISABLE_TELEMETRY: "1", DISABLE_ERROR_REPORTING: "1", - DISABLE_AUTOUPDATER: "1", CLAUDE_CODE_DISABLE_BACKGROUND_TASKS: "1", }; for (const [name, value] of Object.entries(config.tool_env ?? {})) { // Initialization cannot redirect the native Session history lookup. @@ -92,76 +89,29 @@ export class WorkspaceProfile { } const skills = workspaceSkills(config.skills ?? [], config.capability_root ?? ""); this.skillNames = skills?.names ?? []; - const skillTools = skills ? ["Skill"] : []; this.options = { - env, tools: [...nativeTools, ...(toolSearch ? ["ToolSearch"] : []), ...skillTools, ...(subagents ? ["Agent", "SendMessage"] : [])], - ...(skills ? { plugins: skills.paths.map(path => ({ type: "local" as const, path, skipMcpDiscovery: true })) } : {}), allowedTools: mcp?.allowed ?? [...functions, ...(toolSearch ? ["ToolSearch"] : [])], mcpServers: {}, strictMcpConfig: true, - // The existing callback authorizes tools without CLI permission bypass, - // which the native CLI refuses for root accounts. - settingSources: [], permissionMode: "default", persistSession: true, - settings: {}, - sandbox: { enabled: false }, - canUseTool: this.canUseTool, - hooks: { PreToolUse: [{ hooks: [this.beforeTool] }] }, + env, tools: { type: "preset", preset: "claude_code" }, + // Nobody answers a question, no Turn leaves scheduled, monitoring or workflow work behind, ListAgents + // would reach other Sessions' processes on the host, and Core declares subagents and search. + disallowedTools: ["AskUserQuestion", "CronCreate", "CronDelete", "CronList", "ScheduleWakeup", "Monitor", "Workflow", "ListAgents", + ...(subagents ? [] : ["Agent", "SendMessage"]), ...(disableWebSearch ? ["WebSearch", "WebFetch"] : [])], + ...(skills ? { plugins: skills.paths.map(path => ({ type: "local" as const, path, skipMcpDiscovery: true })) } : {}), + // The native CLI refuses permission bypass for root accounts, so the callback approves every tool. + canUseTool: async (_name, input) => ({ behavior: "allow", updatedInput: input }), }; } - verify(tools: string[], servers: { name: string; status: string; tools?: { name: string }[] }[], sessionID = ""): void { - if (this.mcp) { - this.mcp.verify(tools, servers as Parameters[1], sessionID, [...nativeTools, ...(this.skillNames.length ? ["Skill"] : []), ...(this.subagents ? ["Task", "SendMessage"] : [])]); - return; - } - const expected = [...nativeTools, ...(this.toolSearch ? ["ToolSearch"] : []), ...(this.structuredOutput ? ["StructuredOutput"] : []), ...this.functions, ...(this.skillNames.length ? ["Skill"] : []), ...(this.subagents ? ["Task", "SendMessage"] : [])]; - if (servers.length !== (this.functions.length ? 1 : 0) || - servers.some(server => server.name !== "functions" || server.status !== "connected") || - tools.length !== expected.length || new Set(tools).size !== tools.length || - tools.some(name => !expected.includes(name))) throw new Error("unexpected native workspace inventory"); - } - - readonly canUseTool: CanUseTool = async (name, input, { signal, agentID }) => { - if (!signal.aborted && (agentID === undefined || this.subagents?.permitsActor(agentID)) && this.permits(name, input)) { - return { behavior: "allow", updatedInput: this.nativeInput(name, input) }; - } - return { behavior: "deny", message: denial }; - }; - - readonly beforeTool: HookCallback = async (input, id, { signal }) => { - if (this.mcp) { - const admission = await this.mcp.beforeTool(input, id, { signal }); - if ("hookSpecificOutput" in admission && admission.hookSpecificOutput?.hookEventName === "PreToolUse" && - admission.hookSpecificOutput.permissionDecision === "deny") return admission; - if (input.hook_event_name === "PreToolUse" && this.mcp.permits(input.tool_name)) return admission; - } - if (!signal.aborted && input.hook_event_name === "PreToolUse" && (input.agent_id === undefined || this.subagents?.permitsActor(input.agent_id)) && - (id === undefined || id === input.tool_use_id) && this.permits(input.tool_name, input.tool_input)) { - return !["Read", "Edit", "Skill"].includes(input.tool_name) ? {} : { hookSpecificOutput: { hookEventName: "PreToolUse", - updatedInput: this.nativeInput(input.tool_name, input.tool_input as Record) } }; - } - return { hookSpecificOutput: { hookEventName: "PreToolUse", permissionDecision: "deny", permissionDecisionReason: denial } }; + // Public Skill names resolve to their namespaced native plugin identities. + readonly beforeTool: HookCallback = async input => { + if (input.hook_event_name !== "PreToolUse" || input.tool_name !== "Skill") return {}; + const skill = this.skillName((input.tool_input as { skill?: unknown } | null)?.skill); + return skill === undefined ? {} : { hookSpecificOutput: { hookEventName: "PreToolUse", + updatedInput: { ...(input.tool_input as object), skill } } }; }; - private nativeInput(name: string, input: Record): Record { - if (name === "Skill") return { ...input, skill: this.skillName(input.skill) }; - return name !== "Read" && name !== "Edit" ? input : { ...input, file_path: resolve(this.cwd, input.file_path as string) }; - } - private skillName(value: unknown): string | undefined { if (typeof value !== "string") return undefined; // Public names are unique in the installed snapshot; native plugins add a namespace. return this.skillNames.find(name => name === value || name.endsWith(":" + value)); } - - private permits(name: string, value: unknown): boolean { - if (!value || typeof value !== "object" || Array.isArray(value)) return false; - const input = value as Record; - if (this.structuredOutput && name === "StructuredOutput") return true; - if (this.toolSearch && name === "ToolSearch") return true; - if (this.functions.includes(name) || this.mcp?.permits(name)) return true; - if (name === "Skill") return this.skillName(input.skill) !== undefined; - if (name === "Bash") return typeof input.command === "string" && !!input.command.trim() && - (input.run_in_background === undefined || input.run_in_background === false); - if ((name !== "Read" && name !== "Edit") || typeof input.file_path !== "string" || !input.file_path || - /[\x00-\x1f]/.test(input.file_path)) return false; - return true; - } } diff --git a/packages/claude-sdk-adapter/tests/execution.test.mjs b/packages/claude-sdk-adapter/tests/execution.test.mjs index d0625c842..6ed59a70f 100644 --- a/packages/claude-sdk-adapter/tests/execution.test.mjs +++ b/packages/claude-sdk-adapter/tests/execution.test.mjs @@ -40,6 +40,8 @@ globalThis.queryFixture = ({prompt,options}) => { assert.equal(options.effort, "high"); assert.deepEqual(options.thinking, {type:"enabled",budgetTokens:1024}); assert.equal(options.model, "fixture"); + assert.equal(options.systemPrompt, "Translate."); + assert.deepEqual(options.settings, {env:{CLAUDE_CODE_DISABLE_BACKGROUND_TASKS:"1",DISABLE_AUTOUPDATER:"1"}}); const child = options.spawnClaudeCodeProcess({command:process.execPath,args:["-e","process.stdin.resume();process.stdin.on('end',()=>process.exit(0));"],env:process.env,signal:abort.signal}); return { close(){child.stdin.end();}, @@ -59,7 +61,7 @@ globalThis.queryFixture = ({prompt,options}) => { }, }; }; -await execute({type:"start",input: [{ content: [{ type: "input_text", text: "opening text" }] }],model:"fixture",system_prompt:"",cwd:process.cwd(),native_model_options:{effort:"high",thinking:{type:"enabled",budgetTokens:1024}}, +await execute({type:"start",input: [{ content: [{ type: "input_text", text: "opening text" }] }],model:"fixture",system_prompt:"Translate.",cwd:process.cwd(),native_model_options:{effort:"high",thinking:{type:"enabled",budgetTokens:1024}}, ...(mode === "later-function" ? {functions:[{name:"lookup",description:"fixture",parameters:{type:"object",properties:{}}}]} : {})},emit,abort,functions,inputs); assert.equal(inputs.complete,true); assert.equal(events.filter(e=>e.type === "input_applied" && e.input_id === "extra").length,1); diff --git a/packages/claude-sdk-adapter/tests/mcp_bearer.test.mjs b/packages/claude-sdk-adapter/tests/mcp_bearer.test.mjs index 1426b1913..411c16a5e 100644 --- a/packages/claude-sdk-adapter/tests/mcp_bearer.test.mjs +++ b/packages/claude-sdk-adapter/tests/mcp_bearer.test.mjs @@ -1,5 +1,8 @@ import assert from "node:assert/strict"; +import { createServer } from "node:http"; import test from "node:test"; +import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js"; +import { StreamableHTTPServerTransport } from "@modelcontextprotocol/sdk/server/streamableHttp.js"; import { parseStart } from "../dist/adapter.js"; import { MCPProfile } from "../dist/mcp.js"; @@ -41,3 +44,29 @@ test("missing credential environment fails before the native query", () => { delete process.env[missing]; assert.throws(() => new MCPProfile([{ ...server, bearer_token_env_var: missing }], []), /missing MCP credential environment/); }); + +test("subset listing sends each server only its own bearer", async t => { + const seen = { a: new Set(), b: new Set() }; + const listeners = await Promise.all(["a", "b"].map(async label => { + const http = createServer(async (req, res) => { + seen[label].add(req.headers.authorization); + const mcp = new McpServer({ name: label, version: "0" }); + for (const name of ["echo", "extra"]) mcp.registerTool(name, {}, async () => ({ content: [] })); + const transport = new StreamableHTTPServerTransport({ sessionIdGenerator: undefined, enableJsonResponse: true }); + await mcp.connect(transport); + let body = ""; + for await (const chunk of req) body += chunk; + await transport.handleRequest(req, res, body ? JSON.parse(body) : undefined); + }); + await new Promise(resolve => http.listen(0, "127.0.0.1", resolve)); + return http; + })); + const references = ["A", "B"].map(letter => "OAC_RUNTIME_MCP_BEARER_" + letter.repeat(26)); + references.forEach((name, i) => { process.env[name] = "token-" + i; }); + t.after(() => { references.forEach(name => delete process.env[name]); listeners.forEach(http => http.close()); }); + const profile = new MCPProfile(["a", "b"].map((label, i) => ({ server_label: label, + server_url: `http://127.0.0.1:${listeners[i].address().port}/mcp`, allowed_tools: ["echo"], bearer_token_env_var: references[i] })), []); + assert.deepEqual(await profile.unselectedTools(new AbortController().signal), ["mcp__a__extra", "mcp__b__extra"]); + assert.deepEqual([...seen.a], ["Bearer token-0"]); + assert.deepEqual([...seen.b], ["Bearer token-1"]); +}); diff --git a/packages/claude-sdk-adapter/tests/mcp_required.test.mjs b/packages/claude-sdk-adapter/tests/mcp_required.test.mjs index 40dcb9b4c..20cd92b5a 100644 --- a/packages/claude-sdk-adapter/tests/mcp_required.test.mjs +++ b/packages/claude-sdk-adapter/tests/mcp_required.test.mjs @@ -2,8 +2,27 @@ import assert from "node:assert/strict"; import { spawn } from "node:child_process"; import { mkdtempSync, rmSync } from "node:fs"; import { tmpdir } from "node:os"; +import { createServer } from "node:http"; import { join } from "node:path"; import test from "node:test"; +import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js"; +import { StreamableHTTPServerTransport } from "@modelcontextprotocol/sdk/server/streamableHttp.js"; + +// Stateless MCP server with a selected and an unselected tool. +async function listen() { + const server = createServer(async (req, res) => { + if (req.method !== "POST") return void res.writeHead(405).end(); + const mcp = new McpServer({ name: "fixture", version: "0" }); + for (const name of ["echo", "extra"]) mcp.registerTool(name, {}, async () => ({ content: [] })); + const transport = new StreamableHTTPServerTransport({ sessionIdGenerator: undefined, enableJsonResponse: true }); + await mcp.connect(transport); + let body = ""; + for await (const chunk of req) body += chunk; + await transport.handleRequest(req, res, JSON.parse(body)); + }); + await new Promise(resolve => server.listen(0, "127.0.0.1", resolve)); + return { url: `http://127.0.0.1:${server.address().port}/mcp`, close: () => server.close() }; +} // Run the production entrypoint: its explicitly supplied Inputs must also be held. const fixture = ` @@ -15,10 +34,11 @@ const baseline=workspace?["Bash","Read","Edit"]:[]; const sdk='export function startup(args){return globalThis.startup(args);} export function query(args){return globalThis.direct(args);} export async function getSessionInfo(){return process.argv[1]==="missing-history"?undefined:{sessionId:"native"};}'; registerHooks({resolve(s,c,next){return s==="@anthropic-ai/claude-agent-sdk"?{url:"data:text/javascript,"+encodeURIComponent(sdk),shortCircuit:true}:next(s,c);}}); function create(options){ - assert.deepEqual(options.tools,baseline); - assert.equal(options.agent,"oac_root"); - assert.deepEqual(options.agents.oac_root.tools,[...baseline,...(mode==="workspace-empty"?[]:["mcp__fixture__echo"])]); - assert.deepEqual(options.disallowedTools,mode==="workspace-empty"?["mcp__fixture__*","mcp__optional__*"]:["mcp__optional__*"]);assert.equal(options.strictMcpConfig,true);assert.equal(options.hooks.PreToolUse.length,1); + assert.deepEqual(options.tools,workspace?{type:"preset",preset:"claude_code"}:[]); + assert.equal(options.agent,undefined);assert.equal(options.agents,undefined); + assert.deepEqual(options.disallowedTools.filter(name=>name.startsWith("mcp__")),mode==="workspace-empty"?["mcp__fixture__*","mcp__optional__*"]:["mcp__optional__*","mcp__fixture__extra"]); + assert.equal(options.disallowedTools.includes("AskUserQuestion"),workspace); + assert.equal(options.strictMcpConfig,workspace?undefined:true);assert.deepEqual(options.settingSources,workspace?undefined:[]);assert.equal(options.hooks.PreToolUse.length,1); assert.equal(options.mcpServers.fixture.alwaysLoad,true); const child=options.spawnClaudeCodeProcess({command:process.execPath,args:["-e","process.stdin.resume();process.stdin.on('end',()=>process.exit(0));"],env:options.env,signal:options.abortController.signal}); process.send({kind:"spawn"}); @@ -54,9 +74,10 @@ await import(${JSON.stringify(new URL("../dist/main.js", import.meta.url).href)} process.disconnect(); `; -for (const mode of ["connected", "pending", "failed", "missing", "duplicate", "missing-hooks", "cancelled", "resume", "wrong-history", "missing-history", "optional", "workspace-connected", "workspace-empty"]) { +for (const mode of ["connected", "pending", "failed", "missing", "duplicate", "missing-hooks", "cancelled", "resume", "wrong-history", "missing-history", "optional", "workspace-connected", "workspace-empty", "unlistable"]) { test(`required MCP entrypoint holds input through readiness: ${mode}`, async () => { const cwd = mkdtempSync(join(tmpdir(), "oac-required-")); + const remote = await listen(); const child = spawn(process.execPath, ["--input-type=module", "-e", fixture, mode], { stdio: ["pipe", "pipe", "pipe", "ipc"], env: {...process.env,HOME:cwd,CLAUDE_CONFIG_DIR:cwd} }); const observations = []; let stdout = "", stderr = ""; @@ -66,7 +87,7 @@ for (const mode of ["connected", "pending", "failed", "missing", "duplicate", "m const closed = new Promise(resolve => child.once("close", (code, signal) => resolve({ code, signal }))); const timer = setTimeout(() => child.kill("SIGKILL"), 8000); try { - const servers = [{ server_label: "fixture", server_url: "https://example.invalid/mcp", allowed_tools: mode === "workspace-empty" ? [] : ["echo"], required: mode !== "optional" }, + const servers = [{ server_label: "fixture", server_url: mode === "unlistable" ? "http://127.0.0.1:1/mcp" : remote.url, allowed_tools: mode === "workspace-empty" ? [] : ["echo"], required: mode !== "optional" }, { server_label: "optional", server_url: "https://optional.invalid/mcp", allowed_tools: [], required: false }]; child.stdin.write(JSON.stringify({ type: "start", model: "fixed", input: [{ content: [{ type: "input_text", text: "one input" }] }], system_prompt: "", cwd, ...(mode.includes("history") || mode === "resume" ? { resume: "native" } : {}), @@ -83,7 +104,7 @@ for (const mode of ["connected", "pending", "failed", "missing", "duplicate", "m if (mode === "missing-history") assert.equal(observations.length, 0); if (mode === "cancelled") assert.equal(events.at(-1).code, "cancelled"); } finally { - clearTimeout(timer);child.kill("SIGKILL");await closed;rmSync(cwd, { recursive: true, force: true }); + remote.close();clearTimeout(timer);child.kill("SIGKILL");await closed;rmSync(cwd, { recursive: true, force: true }); } }); } diff --git a/packages/claude-sdk-adapter/tests/mcp_workspace.test.mjs b/packages/claude-sdk-adapter/tests/mcp_workspace.test.mjs index 49bc7e805..b0e04b9c3 100644 --- a/packages/claude-sdk-adapter/tests/mcp_workspace.test.mjs +++ b/packages/claude-sdk-adapter/tests/mcp_workspace.test.mjs @@ -12,7 +12,7 @@ const stdio = { server_label: "installed", command: process.execPath, allowed_to args: ["runtime-mcp-exec", join(process.cwd(), "capabilities"), "plugins/installed", "installed"] }; const native = "mcp__installed__echo_v1"; const statuses = [{ name: "installed", status: "connected", tools: [{ name: "echo.v1" }] }]; -const baseline = ["Bash", "Read", "Edit"]; +const nativePreset = ["Bash", "Read", "Edit", "Write"]; function fixture(t, declarations = [stdio]) { const root = realpathSync(mkdtempSync(join(tmpdir(), "oac-mcp-workspace-"))); @@ -49,47 +49,45 @@ test("installed MCP projection uses the common Runtime launcher", t => { assert.throws(() => parseStart(JSON.stringify({ ...request, workspace: { ...request.workspace, network_access: "disabled" } })), /invalid_request/); }); -test("combined inventory admits exact MCP identities with host file authority", async t => { +test("workspace admits declared MCP identities and leaves other native tools alone", async t => { const { dirs, config } = fixture(t); const mcp = new MCPProfile([stdio], []); - const workspace = new WorkspaceProfile(dirs.work, config, [], mcp); + const workspace = new WorkspaceProfile(dirs.work, config, mcp); const signal = new AbortController().signal; const input = { hook_event_name: "PreToolUse", session_id: "session", tool_use_id: "call", tool_name: native, tool_input: {} }; let ready = false; - const waiting = workspace.beforeTool(input, "call", { signal }).then(value => { ready = true; return value; }); + const waiting = mcp.beforeTool(input, "call", { signal }).then(value => { ready = true; return value; }); await Promise.resolve(); assert.equal(ready, false); - workspace.verify([...baseline, native], statuses, "session"); + mcp.verify([...nativePreset, native], statuses, "session", false); assert.deepEqual(await waiting, {}); assert.deepEqual(mcp.identities.get(native), { server: "installed", name: "echo.v1" }); - assert.equal((await workspace.canUseTool(native, {}, { signal })).behavior, "allow"); - assert.equal((await workspace.canUseTool("mcp__installed__undeclared", {}, { signal })).behavior, "deny"); - assert.equal((await workspace.canUseTool("Read", { file_path: join(dirs.secrets, "model.key") }, { signal })).behavior, "allow"); - assert.equal((await workspace.canUseTool("Bash", { command: "pwd", dangerouslyDisableSandbox: true }, { signal })).behavior, "allow"); - assert.equal((await workspace.beforeTool({ ...input, tool_name: "Read", tool_input: { file_path: "ok.txt" } }, "call", { signal })).hookSpecificOutput.updatedInput.file_path, join(dirs.work, "ok.txt")); - for (const fields of [{ session_id: "other" }, { agent_id: "child" }, { tool_name: "mcp__ambient__echo" }]) { - assert.equal((await workspace.beforeTool({ ...input, ...fields }, "call", { signal })).hookSpecificOutput.permissionDecision, "deny"); + for (const tool_name of ["Read", "Write", "Bash"]) assert.deepEqual(await mcp.beforeTool({ ...input, tool_name }, "call", { signal }), {}); + for (const fields of [{ session_id: "other" }, { agent_id: "child" }, { tool_name: "mcp__installed__undeclared" }]) { + assert.equal((await mcp.beforeTool({ ...input, ...fields }, "call", { signal })).hookSpecificOutput.permissionDecision, "deny"); } - assert.deepEqual(workspace.options.allowedTools, ["mcp__installed__*"]); assert.equal(mcp.servers.installed.alwaysLoad, true); - assert.deepEqual(workspace.options.tools, baseline); - assert.deepEqual(workspace.options.sandbox, {enabled:false}); + assert.deepEqual(workspace.options.tools, { type: "preset", preset: "claude_code" }); mcp.close(); - assert.equal((await workspace.canUseTool(native, {}, { signal })).behavior, "deny"); + assert.equal((await mcp.beforeTool(input, "call", { signal })).hookSpecificOutput.permissionDecision, "deny"); }); -test("combined inventory rejects extra servers, tools and normalized identity collisions", t => { - const { dirs, config } = fixture(t); +test("workspace inventory tolerates native tools and servers, but not unavailable or ambiguous declared ones", t => { + const mcp = new MCPProfile([stdio], []); + mcp.verify([...nativePreset, native, "mcp__ambient__echo"], [...statuses, { name: "ambient", status: "connected", tools: [{ name: "echo" }] }], "session", false); + mcp.close(); for (const [tools, servers] of [ - [[...baseline, native, "Write"], statuses], - [[...baseline, native], [...statuses, { name: "ambient", status: "connected", tools: [] }]], - [[...baseline, native], [{ ...statuses[0], tools: [{ name: "echo.v1" }, { name: "echo_v1" }] }]], - [[...baseline, native], [{ ...statuses[0], status: "pending" }]], + [[...nativePreset, native], [{ ...statuses[0], tools: [{ name: "echo.v1" }, { name: "echo_v1" }] }]], + [[...nativePreset, native], [{ ...statuses[0], status: "pending" }]], + [nativePreset, []], ]) { - const mcp = new MCPProfile([stdio], []); - assert.throws(() => new WorkspaceProfile(dirs.work, config, [], mcp).verify(tools, servers, "session")); - mcp.close(); + const declared = new MCPProfile([stdio], []); + assert.throws(() => declared.verify(tools, servers, "session", false)); + declared.close(); } + const exact = new MCPProfile([stdio], []); + assert.throws(() => exact.verify([...nativePreset, native], statuses, "session")); + exact.close(); }); test("workspace bearer references reach native HTTP without leaking values into the request", t => { @@ -99,26 +97,26 @@ test("workspace bearer references reach native HTTP without leaking values into process.env[reference] = "selected-user-token"; process.env.OAC_RUNTIME_MCP_BEARER_UNSELECTED = "other-token"; const mcp = new MCPProfile([http], []); - const workspace = new WorkspaceProfile(dirs.work, config, [], mcp); + const workspace = new WorkspaceProfile(dirs.work, config, mcp); assert.equal(workspace.options.env[reference], "selected-user-token"); assert.equal(workspace.options.env.OAC_RUNTIME_MCP_BEARER_UNSELECTED, "other-token"); assert.deepEqual(mcp.servers.remote.headers, { Authorization: `Bearer \${${reference}}` }); assert.equal(JSON.stringify(mcp.servers).includes("selected-user-token"), false); delete process.env[reference]; - assert.throws(() => new WorkspaceProfile(dirs.work, config, [], mcp), /invalid_request/); + assert.throws(() => new WorkspaceProfile(dirs.work, config, mcp), /invalid_request/); }); test("MCP identity validation preserves host functions and ordinary child environment", async t => { const { dirs, config } = fixture(t); const functions = ["mcp__functions__lookup"]; const mcp = new MCPProfile([stdio], functions); - const workspace = new WorkspaceProfile(dirs.work, { ...config, tool_env: { USER_VALUE: "initialized" } }, functions, mcp); - workspace.verify([...baseline, native, ...functions], [...statuses, { name: "functions", status: "connected" }], "session"); + const workspace = new WorkspaceProfile(dirs.work, { ...config, tool_env: { USER_VALUE: "initialized" } }, mcp); + mcp.verify([...nativePreset, native, ...functions], [...statuses, { name: "functions", status: "connected" }], "session", false); const signal = new AbortController().signal; const input = { hook_event_name: "PreToolUse", session_id: "session", tool_use_id: "call", tool_name: "Bash", tool_input: { command: "printf ok" } }; - assert.deepEqual(await workspace.beforeTool(input, "call", { signal }), {}); + assert.deepEqual(await mcp.beforeTool(input, "call", { signal }), {}); assert.equal(workspace.options.env.USER_VALUE, "initialized"); - assert.equal((await workspace.canUseTool(functions[0], {}, { signal })).behavior, "allow"); + assert.deepEqual(await mcp.beforeTool({ ...input, tool_name: functions[0] }, "call", { signal }), {}); mcp.close(); }); @@ -130,14 +128,12 @@ test("public workspace HTTP preserves required startup and null versus empty all const declaration={...http,allowed_tools}; assert.deepEqual(parseEnvironmentMCP([declaration]),[declaration]); const mcp=new MCPProfile([declaration],[]); - const workspace=new WorkspaceProfile(dirs.work,{...config,mcp:[declaration]},[],mcp); + const workspace=new WorkspaceProfile(dirs.work,{...config,mcp:[declaration]},mcp); assert.throws(()=>mcp.verifyRequired([{name:"remote",status:"pending"}]),/required/); const statuses=[{name:"remote",status:"connected",tools:[{name:"prove"},{name:"other"}]}]; mcp.verifyRequired(statuses); const selected=allowed_tools===null?["prove","other"]:allowed_tools; - workspace.verify([...baseline,...selected.map(name=>"mcp__remote__"+name)],statuses,"session"); - assert.equal(mcp.permits("mcp__remote__prove"),allowed_tools===null||allowed_tools.includes("prove")); - assert.equal(mcp.permits("mcp__remote__other"),allowed_tools===null); + mcp.verify([...nativePreset,...selected.map(name=>"mcp__remote__"+name)],statuses,"session",false); mcp.close(); } }); diff --git a/packages/claude-sdk-adapter/tests/preparation.test.mjs b/packages/claude-sdk-adapter/tests/preparation.test.mjs index 8b7a4a590..d41da04c4 100644 --- a/packages/claude-sdk-adapter/tests/preparation.test.mjs +++ b/packages/claude-sdk-adapter/tests/preparation.test.mjs @@ -22,8 +22,8 @@ globalThis.startupFixture = async ({options, initializeTimeoutMs}) => { assert.equal(options.model,"fixed-model"); assert.equal(options.env.UNSELECTED_CANARY,"must-not-inherit"); assert.equal(options.env.HOME,process.env.HOME); - assert.deepEqual(options.tools,["Bash","Read","Edit"]); - assert.equal(options.sandbox.enabled,false); + assert.deepEqual(options.tools,{type:"preset",preset:"claude_code"}); + assert.equal(options.sandbox,undefined); assert.equal(options.hooks.PreToolUse.length,1); const child = options.spawnClaudeCodeProcess({command:process.execPath,env:options.env,signal:options.abortController.signal, args:["-e","process.stdin.resume();process.stdin.on('end',()=>process.exit(0));"]}); @@ -57,7 +57,7 @@ globalThis.startupFixture = async ({options, initializeTimeoutMs}) => { if(first.done)return; process.send({kind:"input",text:first.value.message.content[0].text}); yield {type:"system",subtype:"init",session_id:mode === "resume-mismatch"?"other":"native",mcp_servers:[], - tools:mode === "bad-inventory"?["Bash","Read","Edit","Agent"]:["Bash","Read","Edit"]}; + tools:["Bash","Read","Edit"]}; const ids=[first.value.uuid]; if(mode === "commands") { yield command; @@ -149,7 +149,7 @@ async function launch(t, mode) { return { child, request, events, observations, send, wait, finish }; } -for (const mode of ["release", "resume", "steer", "commands", "unused", "owner-cancel", "native-exit", "duplicate", "replacement", "early-steer", "bad-inventory", "resume-mismatch"]) { +for (const mode of ["release", "resume", "steer", "commands", "unused", "owner-cancel", "native-exit", "duplicate", "replacement", "early-steer", "resume-mismatch"]) { test(`prepared entrypoint lifecycle: ${mode}`, { timeout: 10000 }, async t => { const bridge = await launch(t, mode); const { child, request, events, observations, send, wait, finish } = bridge; @@ -167,7 +167,7 @@ for (const mode of ["release", "resume", "steer", "commands", "unused", "owner-c else { send({ type: "start", input: [{ content: [{ type: "input_text", text: "first" }] }] }); if (mode === "duplicate") { send({ type: "start", input: [{ content: [{ type: "input_text", text: "second" }] }] }); await finish("invalid_request"); } - else if (["bad-inventory", "resume-mismatch"].includes(mode)) { + else if (mode === "resume-mismatch") { await finish("execution_failed"); assert.equal(events.some(event => event.type === "input_ready"), false); } else { diff --git a/packages/claude-sdk-adapter/tests/search_observer.test.mjs b/packages/claude-sdk-adapter/tests/search_observer.test.mjs new file mode 100644 index 000000000..cffafc299 --- /dev/null +++ b/packages/claude-sdk-adapter/tests/search_observer.test.mjs @@ -0,0 +1,31 @@ +import assert from "node:assert/strict"; +import test from "node:test"; +import { SearchObserver } from "../dist/search_observer.js"; + +const assistant = (id, name, input) => ({ type: "assistant", session_id: "session", parent_tool_use_id: null, + message: { content: [{ type: "tool_use", id, name, input }] } }); +const user = (id, is_error = false) => ({ type: "user", session_id: "session", parent_tool_use_id: null, + message: { content: [{ type: "tool_result", tool_use_id: id, content: "native result", is_error }] } }); +const consume = (observer, message) => [...observer.consume(message, "session")]; + +test("WebSearch and WebFetch project as web search actions with a completion status", () => { + const observer = new SearchObserver(); + assert.deepEqual(consume(observer, assistant("search", "WebSearch", { query: "agents api" })), [{ type: "search_observation", session_id: "session", id: "search", stage: "before", + observation: { kind: "web_search", status: "in_progress", action: { type: "search", query: "agents api" } } }]); + consume(observer, assistant("fetch", "WebFetch", { url: "https://example.com", prompt: "summarize" })); + assert.throws(() => observer.assertComplete()); + assert.equal(consume(observer, user("search"))[0].observation.status, "completed"); + assert.deepEqual(consume(observer, user("fetch", true))[0].observation, { kind: "web_search", status: "failed", action: { type: "open_page", url: "https://example.com" } }); + observer.assertComplete(); + assert.deepEqual(observer.close("session"), []); +}); + +test("other tools, child work and replays are not observed; open calls close as incomplete", () => { + const observer = new SearchObserver(); + for (const message of [assistant("bash", "Bash", { command: "true" }), { ...assistant("child", "WebSearch", { query: "q" }), parent_tool_use_id: "parent" }, + { ...assistant("replay", "WebSearch", { query: "q" }), isReplay: true }, assistant("blank", "WebSearch", {})]) { + assert.deepEqual(consume(observer, message), []); + } + consume(observer, assistant("open", "WebSearch", { query: "q" })); + assert.deepEqual(observer.close("session").map(event => [event.id, event.stage, event.observation.status]), [["open", "after", "incomplete"]]); +}); diff --git a/packages/claude-sdk-adapter/tests/workspace.test.mjs b/packages/claude-sdk-adapter/tests/workspace.test.mjs index 750d578ba..ae3d43777 100644 --- a/packages/claude-sdk-adapter/tests/workspace.test.mjs +++ b/packages/claude-sdk-adapter/tests/workspace.test.mjs @@ -69,116 +69,48 @@ test("workspace environment copies only selected refs and fixed values", t => { process.env.CLAUDE_CODE_PROJECT_DIR_NAME = "ambient-history-override"; assert.throws(() => new WorkspaceProfile(dirs.workspace, config), /invalid_request/); }); - -test("workspace native options bypass isolation and preserve selected tool inventory", t => { +test("workspace runs the native tool preset and removes only what cannot run unattended", async t => { const { dirs, config } = fixture(t); - const profile = new WorkspaceProfile(dirs.workspace, config); - const options = profile.options; - assert.deepEqual(options.tools, ["Bash", "Read", "Edit"]); - assert.deepEqual(options.allowedTools, []); - assert.deepEqual(options.settingSources, []); - assert.deepEqual(options.mcpServers, {}); - assert.equal(options.strictMcpConfig, true); - assert.equal(options.permissionMode, "default"); - assert.equal(options.persistSession, true); - assert.equal(options.sandbox.enabled, false); - assert.deepEqual(options.sandbox, { enabled: false }); - assert.equal(options.allowDangerouslySkipPermissions, undefined); - assert.deepEqual(options.settings, {}); - profile.verify(["Read", "Edit", "Bash"], []); - for (const tools of [[], ["Bash", "Read", "Read"], ["Bash", "Read", "Write"], ["Bash", "Read", "Edit", "Agent"]]) { - assert.throws(() => profile.verify(tools, []), /unexpected native workspace inventory/); + const options = new WorkspaceProfile(dirs.workspace, config).options; + assert.deepEqual(options.tools, { type: "preset", preset: "claude_code" }); + assert.deepEqual(options.disallowedTools, ["AskUserQuestion", "CronCreate", "CronDelete", "CronList", "ScheduleWakeup", "Monitor", "Workflow", "ListAgents", "Agent", "SendMessage"]); + assert.equal(options.permissionMode, undefined); + for (const key of ["allowedTools", "settingSources", "mcpServers", "strictMcpConfig", "persistSession", "sandbox", "settings", "allowDangerouslySkipPermissions"]) { + assert.equal(options[key], undefined, key); + } + const signal = new AbortController().signal; + for (const [name, input] of [["Bash", { command: "true" }], ["Write", { file_path: "file.txt" }], ["Read", { file_path: "../protected/value" }]]) { + assert.deepEqual(await options.canUseTool(name, input, { signal }), { behavior: "allow", updatedInput: input }); } - assert.throws(() => profile.verify(options.tools, [{ name: "untrusted", status: "connected" }]), /unexpected native workspace inventory/); }); -test("structured workspace admits only its configured native terminal tool", async t => { - const { dirs, config, request } = fixture(t); - const output_format = { type: "json_schema", schema: { type: "object" } }; - const configured = { ...request, observe_messages: true, output_format }; - assert.deepEqual(parseStart(JSON.stringify(configured)), configured); - const ordinary = new WorkspaceProfile(dirs.workspace, config); - const structured = new WorkspaceProfile(dirs.workspace, config, [], undefined, undefined, true); - const { canUseTool: ordinaryPermission, hooks: ordinaryHooks, ...ordinaryOptions } = ordinary.options; - const { canUseTool: structuredPermission, hooks: structuredHooks, ...structuredOptions } = structured.options; - assert.deepEqual(structuredOptions, ordinaryOptions); - const inventory = ["Bash", "Read", "Edit", "StructuredOutput"]; - structured.verify(inventory, []); - assert.throws(() => ordinary.verify(inventory, [])); - assert.throws(() => structured.verify(["Bash", "Read", "Edit"], [])); - assert.throws(() => structured.verify([...inventory, "Write"], [])); - const context = { signal: new AbortController().signal, toolUseID: "terminal", requestId: "request" }; - assert.equal((await ordinary.canUseTool("StructuredOutput", {}, context)).behavior, "deny"); - assert.equal((await structured.canUseTool("StructuredOutput", {}, context)).behavior, "allow"); - for (const extra of [{ agentID: "child" }, { signal: AbortSignal.abort() }]) { - assert.equal((await structured.canUseTool("StructuredOutput", {}, { ...context, ...extra })).behavior, "deny"); - } - const hook = { hook_event_name: "PreToolUse", tool_name: "StructuredOutput", tool_input: {}, tool_use_id: "terminal" }; - assert.deepEqual(await structured.beforeTool(hook, "terminal", context), {}); - for (const [input, id, ctx] of [[{ ...hook, agent_id: "child" }, "terminal", context], [hook, "wrong", context], [hook, "terminal", { ...context, signal: AbortSignal.abort() }]]) { - assert.equal((await structured.beforeTool(input, id, ctx)).hookSpecificOutput.permissionDecision, "deny"); - } +test("Core declarations remove subagent and search tools only when declared", t => { + const { dirs, config } = fixture(t); + const subagents = new WorkspaceProfile(dirs.workspace, config, undefined, {}).options.disallowedTools; + assert.ok(!subagents.includes("Agent") && !subagents.includes("SendMessage") && !subagents.includes("WebSearch")); + const disabled = new WorkspaceProfile(dirs.workspace, config, undefined, undefined, true).options.disallowedTools; + assert.ok(disabled.includes("WebSearch") && disabled.includes("WebFetch")); }); -test("workspace permissions preserve host authority and synchronous command ownership", async t => { +test("workspace hook rewrites only Skill names", async t => { const { dirs, config } = fixture(t); - writeFileSync(join(dirs.workspace, "file.txt"), "fixture"); - symlinkSync(dirs.protected, join(dirs.workspace, "escape")); - symlinkSync(join(dirs.protected, "missing"), join(dirs.workspace, "dangling")); - symlinkSync(join(dirs.workspace, "file.txt"), join(dirs.workspace, "inside")); const profile = new WorkspaceProfile(dirs.workspace, config); const options = { signal: new AbortController().signal, toolUseID: "tool", requestId: "request" }; - const hook = async (name, input) => profile.beforeTool({ hook_event_name: "PreToolUse", session_id: "native", cwd: dirs.workspace, - transcript_path: join(dirs.state, "session"), tool_name: name, tool_input: input, tool_use_id: "tool" }, "tool", options); - for (const [name, input] of [["Bash", { command: "printf value" }], ["Bash", { command: "true", dangerouslyDisableSandbox: true }], ["Read", { file_path: "file.txt" }], - ["Read", { file_path: "inside" }], ["Edit", { file_path: "new/file.txt", old_string: "", new_string: "value" }]]) { - const permission = await profile.canUseTool(name, input, options); - assert.equal(permission.behavior, "allow"); - if (name === "Bash") assert.deepEqual(await hook(name, input), {}); - else { - assert.equal(permission.updatedInput.file_path, join(dirs.workspace, input.file_path)); - assert.deepEqual((await hook(name, input)).hookSpecificOutput.updatedInput, permission.updatedInput); - } + for (const [name, input] of [["Bash", { command: "printf value" }], ["Read", { file_path: "file.txt" }], ["Write", { file_path: "file.txt" }]]) { + assert.deepEqual(await profile.beforeTool({ hook_event_name: "PreToolUse", tool_name: name, tool_input: input, tool_use_id: "tool" }, "tool", options), {}); } - for (const [name, input] of [["Bash", { command: "true", run_in_background: true }], - ["Bash", { command: "true", run_in_background: "false" }], - ["Write", { file_path: "file.txt" }]]) { - assert.equal((await profile.canUseTool(name, input, options)).behavior, "deny"); - assert.equal((await hook(name, input)).hookSpecificOutput.permissionDecision, "deny"); - } - assert.equal((await profile.canUseTool("Bash", { command: "true" }, { ...options, agentID: "child" })).behavior, "deny"); - assert.equal((await profile.canUseTool("Bash", { command: "true" }, { ...options, signal: AbortSignal.abort() })).behavior, "deny"); }); test("Runtime rejects network isolation requests", t => { const {dirs,config}=fixture(t); - assert.deepEqual(new WorkspaceProfile(dirs.workspace,{...config,network_access:"enabled"}).options.sandbox,{enabled:false}); + assert.ok(new WorkspaceProfile(dirs.workspace,{...config,network_access:"enabled"}).options); for(const network_access of ["disabled","restricted"]) assert.throws(()=>parseWorkspace({...config,network_access,allowed_domains:network_access==="restricted"?["example.com"]:[]},dirs.workspace),/invalid_request/); }); -test("workspace functions retain native sandbox and exact tool authority", async t => { - const { dirs, config, request } = fixture(t); +test("workspace functions are parsed", async t => { + const { request } = fixture(t); const functions = [{ name: "lookup", description: "Lookup", parameters: { type: "object" } }]; assert.deepEqual(parseStart(JSON.stringify({ ...request, functions })).functions, functions); - const profile = new WorkspaceProfile(dirs.workspace, config, ["mcp__functions__lookup"]); - profile.verify(["Bash", "Read", "Edit", "mcp__functions__lookup"], [{ name: "functions", status: "connected" }]); - for (const servers of [[], [{ name: "functions", status: "failed" }], [{ name: "external", status: "connected" }]]) { - assert.throws(() => profile.verify(["Bash", "Read", "Edit", "mcp__functions__lookup"], servers)); - } - assert.throws(() => profile.verify(["Bash", "Read", "Edit", "mcp__functions__unknown"], [{ name: "functions", status: "connected" }])); - const controller = new AbortController(); - const input = { id: "fixture" }; - const options = { signal: controller.signal }; - assert.deepEqual(await profile.canUseTool("mcp__functions__lookup", input, options), { behavior: "allow", updatedInput: input }); - assert.equal((await profile.canUseTool("mcp__functions__unknown", input, options)).behavior, "deny"); - assert.equal((await profile.canUseTool("mcp__functions__lookup", input, { ...options, agentID: "child" })).behavior, "deny"); - const event = { hook_event_name: "PreToolUse", tool_name: "mcp__functions__lookup", tool_input: input, tool_use_id: "call" }; - assert.deepEqual(await profile.beforeTool(event, "call", options), {}); - assert.equal((await profile.beforeTool({ ...event, tool_name: "mcp__external__lookup" }, "call", options)).hookSpecificOutput.permissionDecision, "deny"); - assert.equal((await profile.canUseTool("Bash", { command: "true", dangerouslyDisableSandbox: true }, options)).behavior, "allow"); - assert.equal((await profile.canUseTool("Read", { file_path: dirs.state + "/history" }, options)).behavior, "allow"); - controller.abort(); - assert.equal((await profile.canUseTool("mcp__functions__lookup", input, options)).behavior, "deny"); }); test("initialized environment is ordinary child environment on every layout", t => { @@ -189,35 +121,24 @@ test("initialized environment is ordinary child environment on every layout", t assert.equal(profile.options.env.HOME,dirs.home); assert.equal(profile.options.env.CLAUDE_CONFIG_DIR,dirs.state); assert.equal(profile.options.env.home,undefined); - assert.deepEqual(profile.options.sandbox,{enabled:false}); } }); -test("host tools can access paths outside the workspace", async t => { - const {dirs,config}=fixture(t); - const profile=new WorkspaceProfile(dirs.workspace,config); - for(const file_path of ["../protected/value",join(dirs.home,"credentials")]) { - assert.equal((await profile.canUseTool("Read",{file_path},{signal:new AbortController().signal})).behavior,"allow"); - } +test("Core sets only the environment that cloud operation needs", t => { + const { dirs, config } = fixture(t); + const env = new WorkspaceProfile(dirs.workspace, config).options.env; + for (const name of ["DISABLE_TELEMETRY", "DISABLE_ERROR_REPORTING", "CLAUDE_CODE_DISABLE_NONESSENTIAL_TRAFFIC"]) assert.equal(env[name], undefined, name); }); - -test("workspace discovery preserves native file tools and the function boundary", async t => { - const { dirs, config, request } = fixture(t); +test("workspace discovery keeps the function boundary on the native tool set", t => { + const { request } = fixture(t); const functions = [{name:"lookup", description:"Lookup", parameters:{type:"object"}, defer_loading:true}]; const configured = {...request, tool_search:true, functions}; assert.deepEqual(parseStart(JSON.stringify(configured)), configured); - const profile = new WorkspaceProfile(dirs.workspace, config, ["mcp__functions__lookup"], undefined, undefined, false, true); - assert.deepEqual(profile.options.tools, ["Bash", "Read", "Edit", "ToolSearch"]); - assert.deepEqual(profile.options.allowedTools, ["mcp__functions__lookup", "ToolSearch"]); - const tools = [...profile.options.tools, "mcp__functions__lookup"]; - profile.verify(tools, [{name:"functions",status:"connected"}]); - assert.throws(() => profile.verify(tools.filter(name => name !== "ToolSearch"), [{name:"functions",status:"connected"}])); - const ordinary = new WorkspaceProfile(dirs.workspace, config); - const context = {signal:new AbortController().signal, toolUseID:"search", requestId:"request"}; - assert.equal((await profile.canUseTool("ToolSearch", {query:"lookup"}, context)).behavior,"allow"); - assert.equal((await ordinary.canUseTool("ToolSearch", {query:"lookup"}, context)).behavior,"deny"); - assert.equal((await profile.canUseTool("mcp__functions__undeclared", {}, context)).behavior,"deny"); - assert.equal((await profile.canUseTool("ToolSearch", {}, {...context, agentID:"child"})).behavior,"deny"); - assert.equal((await profile.canUseTool("ToolSearch", {}, {...context, signal:AbortSignal.abort()})).behavior,"deny"); +}); + +test("web search control is a boolean request field", t => { + const { request } = fixture(t); + assert.equal(parseStart(JSON.stringify({ ...request, disable_web_search: true })).disable_web_search, true); + assert.throws(() => parseStart(JSON.stringify({ ...request, disable_web_search: "disabled" })), /invalid_request/); }); diff --git a/packages/claude-sdk-adapter/tests/workspace_execution.test.mjs b/packages/claude-sdk-adapter/tests/workspace_execution.test.mjs index 50ff156d8..ca4cb7608 100644 --- a/packages/claude-sdk-adapter/tests/workspace_execution.test.mjs +++ b/packages/claude-sdk-adapter/tests/workspace_execution.test.mjs @@ -4,7 +4,7 @@ import test from "node:test"; const fixture = ` import assert from "node:assert/strict"; -import { mkdtempSync, mkdirSync, realpathSync, rmSync } from "node:fs"; +import { mkdtempSync, mkdirSync, realpathSync, rmSync, writeFileSync } from "node:fs"; import { registerHooks } from "node:module"; import { tmpdir } from "node:os"; import { join } from "node:path"; @@ -15,14 +15,19 @@ registerHooks({ resolve(specifier, context, next) { }}); const { execute } = await import(${JSON.stringify(new URL("../dist/adapter.js", import.meta.url).href)}); const root = realpathSync(mkdtempSync(join(tmpdir(), "oac-workspace-execute-"))); -const dirs = Object.fromEntries(["workspace", "home", "state", "scratch", "deps"].map(name => { +const dirs = Object.fromEntries(["workspace", "home", "state", "scratch", "deps", "capability"].map(name => { const path = join(root, name); mkdirSync(path); return [name, path]; })); const mode = process.argv[1]; +mkdirSync(join(dirs.capability, "pkg", "skill"), { recursive: true }); +writeFileSync(join(dirs.capability, "pkg", "skill", "SKILL.md"), "---\\nname: fixture\\ndescription: Fixture.\\n---\\nDo it.\\n"); const workspace = { home: dirs.home, state: dirs.state, scratch: dirs.scratch, - env_names: ["ANTHROPIC_API_KEY"] }; -const request = { type: "start", input: [{ content: [{ type: "input_text", text: "fixture" }] }], model: "fixture", system_prompt: "", cwd: dirs.workspace, - workspace, ...(mode.startsWith("resume") ? { resume: "native" } : {}) }; + env_names: ["ANTHROPIC_API_KEY"], ...(mode.startsWith("skills") ? { capability_root: dirs.capability, + skills: [{ metadata: { type: "inline", name: "fixture", description: "Fixture." }, relative_root: "pkg/skill", package_root: "pkg" }] } : {}) }; +const request = { type: "start", input: [{ content: [{ type: "input_text", text: "fixture" }] }], model: "fixture", system_prompt: mode === "instructions" ? "Be brief." : "", cwd: dirs.workspace, + workspace, ...(mode.startsWith("resume") ? { resume: "native" } : {}), + ...(mode.startsWith("functions") || mode === "search-missing" ? { functions: [{ name: "lookup", description: "Lookup", parameters: { type: "object" }, defer_loading: mode === "search-missing" }] } : {}), + ...(mode === "search-missing" ? { tool_search: true } : {}) }; process.env.HOME = dirs.home; process.env.CLAUDE_CONFIG_DIR = dirs.state; process.env.ANTHROPIC_API_KEY = "fixture-secret"; @@ -43,15 +48,20 @@ globalThis.queryFixture = ({prompt, options}) => { assert.equal(options.env.UNSELECTED_CANARY, "must-not-inherit"); assert.equal(options.env.ANTHROPIC_API_KEY, "fixture-secret"); assert.equal(options.env.HOME, dirs.home); - assert.deepEqual(options.tools, ["Bash", "Read", "Edit"]); - assert.equal(options.sandbox.enabled, false); + assert.deepEqual(options.tools, {type:"preset",preset:"claude_code"}); + assert.equal(options.settingSources, undefined); + // A project's settings cannot bring background work back. + assert.deepEqual(options.settings, {env:{CLAUDE_CODE_DISABLE_BACKGROUND_TASKS:"1",DISABLE_AUTOUPDATER:"1"}}); + assert.deepEqual(options.systemPrompt, {type:"preset",preset:"claude_code",...(mode === "instructions" ? {append:"Be brief."} : {})}); assert.equal(options.resume, request.resume); const child = options.spawnClaudeCodeProcess({ command: process.execPath, env: options.env, signal: abort.signal, args: ["-e", "const assert=require('node:assert/strict');assert.equal(process.env.UNSELECTED_CANARY,'must-not-inherit');assert.equal(process.env.ANTHROPIC_API_KEY,'fixture-secret');process.stdin.resume();process.stdin.on('end',()=>process.exit(0));"] }); return { close() { child.stdin.end(); }, async *[Symbol.asyncIterator]() { const first = (await prompt[Symbol.asyncIterator]().next()).value; - yield { type: "system", subtype: "init", session_id: "native", mcp_servers: mode === "extra-mcp" ? [{name:"other",status:"connected"}] : [], - tools: mode === "extra-tool" ? ["Bash", "Read", "Edit", "Agent"] : ["Bash", "Read", "Edit"] }; + yield { type: "system", subtype: "init", session_id: "native", mcp_servers: request.functions ? [{ name: "functions", status: "connected" }] : [], + tools: ["Bash", "Read", "Edit", ...(mode === "functions" || mode === "search-missing" ? ["mcp__functions__lookup"] : []), ...(mode.startsWith("skills") ? ["Skill"] : [])], + // A project's settings can disable Core's Skill plugin while the Skill tool stays. + skills: mode === "skills" ? ["environment-skills-0:fixture"] : [] }; if (mode.startsWith("commands")) { const call = id => ({type:"assistant",session_id:"native",parent_tool_use_id:null, message:{content:[{type:"tool_use",name:"Bash",id,input:{command:"printf 'observed'"}}]}}); @@ -78,10 +88,7 @@ try { assert.deepEqual(events, [{type:"error",code:"history_unavailable"}]); } else { assert.deepEqual(calls, mode === "resume-existing" ? ["history", "query"] : ["query"]); - if (mode.startsWith("extra")) { - assert.deepEqual(events.at(-1), {type:"error",code:"execution_failed"}); - assert.equal(events.some(e => e.type === "input_ready"), false); - } else if (mode.startsWith("commands")) { + if (mode.startsWith("commands")) { const observations=events.filter(e=>e.type==="command_observation"); assert.deepEqual(observations.slice(0,2).map(e=>[e.id,e.stage,e.observation.output]), [["observed","before",undefined],["observed","after","observed"]]); @@ -93,13 +100,14 @@ try { assert.deepEqual(observations.at(-1).observation,{kind:"command",status:"incomplete",command:"printf 'observed'"}); assert.deepEqual(events.at(-1),{type:"error",code:mode === "commands-cancel"?"cancelled":"execution_failed"}); } - } else assert.equal(events.at(-1).type, "result"); + } else if (mode === "functions-missing" || mode === "search-missing" || mode === "skills-missing") assert.deepEqual(events.at(-1), {type:"error",code:"execution_failed"}); + else assert.equal(events.at(-1).type, "result"); } } } finally { rmSync(root, {recursive:true,force:true}); } `; -for (const mode of ["fresh", "resume-existing", "resume-missing", "resume-mismatch", "extra-tool", "extra-mcp", "commands-success", "commands-cancel", "commands-missing"]) { +for (const mode of ["fresh", "instructions", "resume-existing", "resume-missing", "resume-mismatch", "commands-success", "commands-cancel", "commands-missing", "functions", "functions-missing", "search-missing", "skills", "skills-missing"]) { test(`workspace execution boundary: ${mode}`, { timeout: 15000 }, () => { const child = spawnSync(process.execPath, ["--input-type=module", "-e", fixture, mode], { encoding: "utf8", timeout: 10000 }); assert.equal(child.status, 0, child.stderr || child.error?.message); diff --git a/packages/claude-sdk-adapter/tests/workspace_skill_alias.test.mjs b/packages/claude-sdk-adapter/tests/workspace_skill_alias.test.mjs index 1a0cc640e..d4e68e7f6 100644 --- a/packages/claude-sdk-adapter/tests/workspace_skill_alias.test.mjs +++ b/packages/claude-sdk-adapter/tests/workspace_skill_alias.test.mjs @@ -24,13 +24,11 @@ test("selected public Skill names resolve to their native plugin identities", as const qualified = `environment-skills-${index}:${name}`; for (const skill of [name, qualified]) { const input = { skill, args: "fixture argument" }; - assert.deepEqual(await profile.canUseTool("Skill", input, context), - { behavior: "allow", updatedInput: { ...input, skill: qualified } }); const result = await profile.beforeTool({ hook_event_name: "PreToolUse", tool_use_id: "call", tool_name: "Skill", tool_input: input }, "call", context); assert.deepEqual(result.hookSpecificOutput.updatedInput, { ...input, skill: qualified }); } } for (const skill of ["unselected", "other:first", "environment-skills-0:second"]) { - assert.equal((await profile.canUseTool("Skill", { skill }, context)).behavior, "deny"); + assert.deepEqual(await profile.beforeTool({ hook_event_name: "PreToolUse", tool_use_id: "call", tool_name: "Skill", tool_input: { skill } }, "call", context), {}); } }); diff --git a/scripts/build-claude-sdk-runtime.sh b/scripts/build-claude-sdk-runtime.sh index 28d8edd84..e530f9615 100755 --- a/scripts/build-claude-sdk-runtime.sh +++ b/scripts/build-claude-sdk-runtime.sh @@ -11,7 +11,6 @@ for directory in "$runtime_root" "$output_dir"; do fi done -node -e 'if (Number(process.versions.node.split(".")[0]) < 20) throw new Error("Claude SDK runtime builds require Node 20 or newer")' mkdir -p "$runtime_root/cache/claude-sdk-builds" build_context="$(mktemp -d "$runtime_root/cache/claude-sdk-builds/runtime.XXXXXX")" trap 'rm -rf "$build_context"' EXIT diff --git a/services/core/deploy/mcode/README.md b/services/core/deploy/mcode/README.md index f7603824b..ab9e9690f 100644 --- a/services/core/deploy/mcode/README.md +++ b/services/core/deploy/mcode/README.md @@ -21,7 +21,7 @@ Each Session has its own native data directory, `$OAC_RUNTIME_HOME/runtime/mcode | Profile | Where it runs | Native configuration | | --- | --- | --- | | Text (`environment: none`) | A private working directory inside the Session's data directory | No native tools, Skills, web search, browser tools, `mcode-tools`, thread goals or user questions. With Subagents enabled, the default agent gets only the task tools (`task`, `task_append`, `task_query`, `task_output`, `task_stop`) and delegation | -| Workspace | The Environment's workspace directory, for the native process, the ACP Session and the workspace tools | The text configuration plus permission mode `bypassPermissions`, the native sandbox off, the selected workspace Skills and the `oac_workspace` MCP bridge for file and shell tools | +| Workspace | The Environment's workspace directory, for the native process, the ACP Session and the workspace tools | The text configuration plus permission mode `bypassPermissions`, the selected workspace Skills and the `oac_workspace` MCP bridge for file and shell tools | The text profile rejects workspace, function tools and MCP servers. Workspace execution requires Linux or macOS and an unrestricted Environment network policy ([`workspace.go`](../../../../apps/daemon/internal/agent/mcode/workspace.go)); the adapter enforces no network restriction.