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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 6 additions & 0 deletions apps/daemon/internal/agent/claudesdk/declaration.go
Original file line number Diff line number Diff line change
Expand Up @@ -148,6 +148,12 @@ func discoverWithCheck(parent context.Context, options agent.DiscoveryOptions, d
out.Preparation = NewPreparationFactory(config)
out.WorkspaceReadPreparation = true
}
// The view runs the same install; its probe stays on this host.
if view, err := newView(Config{Node: node, Entrypoint: entrypoint}, info); err != nil {
fmt.Fprintf(options.Stderr, "oac-daemon: Claude SDK agent-host view unavailable: %v\n", err)
} else {
out.View = view
}

fmt.Fprintf(options.Stdout, "Claude SDK preflight ok (SDK %s, %s)\n", info.SDK, info.Native)
return out
Expand Down
77 changes: 45 additions & 32 deletions apps/daemon/internal/agent/claudesdk/executor.go
Original file line number Diff line number Diff line change
Expand Up @@ -36,46 +36,59 @@ func NewExecutorFactory(config Config) agent.ExecutorFactory {
if ctx == nil {
ctx = context.Background()
}
if req.RunID != "" || len(req.Input) != 0 || req.ConversationID != "" {
return nil, errors.New("claudesdk: Executor preparation cannot submit input")
}
start, env, err := prepareConfiguration(config, req)
if err != nil {
return nil, err
}
info, err := checked.check(ctx, config)
if err != nil {
return nil, err
}
if err = validateExecutorFeatures(info, start); err != nil {
if err := preparationOnly(req); err != nil {
return nil, err
}
start.Type = "executor_prepare"
base, err := launch(ctx, config, start, env)
start, env, err := prepareConfiguration(config, req)
if err != nil {
return nil, err
}
base.reads.supported = slices.Contains(info.Features, "workspace_read")
base.directories.supported = slices.Contains(info.Features, "workspace_directory")
e := &executor{base: base, start: start, ready: make(chan error, 1), done: make(chan struct{}), nativeID: start.Resume}
go e.read()
if err = e.write(start); err == nil {
select {
case err = <-e.ready:
case <-ctx.Done():
err = ctx.Err()
}
return startExecutor(ctx, checked, config, start, func() (*session, error) { return launch(ctx, config, start, env) })
}
}

func preparationOnly(req proto.PromptRequestPayload) error {
if req.RunID != "" || len(req.Input) != 0 || req.ConversationID != "" {
return errors.New("claudesdk: Executor preparation cannot submit input")
}
return nil
}

// startExecutor checks the installed bridge against probe, starts it through
// run and waits until it is ready for Turns.
func startExecutor(ctx context.Context, checked *runtimeCheckCache, probe Config, start startRequest, run func() (*session, error)) (agent.Executor, error) {
info, err := checked.check(ctx, probe)
if err != nil {
return nil, err
}
if err = validateExecutorFeatures(info, start); err != nil {
return nil, err
}
start.Type = "executor_prepare"
base, err := run()
if err != nil {
return nil, err
}
base.reads.supported = slices.Contains(info.Features, "workspace_read")
base.directories.supported = slices.Contains(info.Features, "workspace_directory")
e := &executor{base: base, start: start, ready: make(chan error, 1), done: make(chan struct{}), nativeID: start.Resume}
go e.read()
if err = e.write(start); err == nil {
select {
case err = <-e.ready:
case <-ctx.Done():
err = ctx.Err()
}
if err != nil {
closeCtx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
defer cancel()
if closeErr := e.Close(closeCtx); closeErr != nil {
return e, errors.Join(err, closeErr)
}
return nil, err
}
if err != nil {
closeCtx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
defer cancel()
if closeErr := e.Close(closeCtx); closeErr != nil {
return e, errors.Join(err, closeErr)
}
return e, nil
return nil, err
}
return e, nil
}

func validateExecutorFeatures(info RuntimeInfo, start startRequest) error {
Expand Down
2 changes: 1 addition & 1 deletion apps/daemon/internal/agent/claudesdk/local_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -72,7 +72,7 @@ func TestWorkspaceProviderCredentialsReplaceAmbientSelection(t *testing.T) {
if strings.Contains(string(raw), "selected-secret") || !slices.Equal(original, config.Env) {
t.Fatal("provider leaked or mutated shared configuration")
}
if !slices.Contains(env, "ANTHROPIC_AUTH_TOKEN=selected-secret") || slices.Contains(env, "ANTHROPIC_AUTH_TOKEN=selected-provider-fixture") {
if !slices.Contains(env, "ANTHROPIC_API_KEY=selected-secret") || slices.ContainsFunc(env, func(entry string) bool { return strings.HasPrefix(entry, "ANTHROPIC_AUTH_TOKEN=") }) {
t.Fatal("provider selection was not exclusive")
}
for _, value := range []any{nil, "secret", map[string]any{"protocol": "anthropic", "base_url": "http://provider.example", "api_key": "secret"}, map[string]any{"protocol": "anthropic", "base_url": "https://user:pass@provider.example", "api_key": "secret"}} {
Expand Down
108 changes: 60 additions & 48 deletions apps/daemon/internal/agent/claudesdk/options.go
Original file line number Diff line number Diff line change
Expand Up @@ -55,6 +55,62 @@ func prepare(config Config, req proto.PromptRequestPayload) (startRequest, []str
}

func prepareConfiguration(config Config, req proto.PromptRequestPayload) (startRequest, []string, error) {
start, provider, err := prepareOptions(req, req.MCPHTTPServers != nil || (req.LocalEnvironment != nil && len(req.LocalEnvironment.MCP) != 0))
if err != nil {
return startRequest{}, nil, err
}
if !filepath.IsAbs(config.Entrypoint) {
return startRequest{}, nil, fmt.Errorf("claudesdk: SDK entrypoint must be absolute")
}
config.Env = withProvider(config.Env, provider)
if config.Workspace != nil {
profile, env, err := prepareWorkspace(config, req)
if err != nil {
return startRequest{}, nil, err
}
start.Workspace = profile
start.Cwd = workspaceCwd(config.Workspace)
return start, env, nil
}
if req.LocalEnvironment != nil || req.RequireExistingNativeSession {
return startRequest{}, nil, fmt.Errorf("claudesdk: local execution and history recovery require a dedicated workspace")
}
root, err := paths.Root()
if err != nil {
return startRequest{}, nil, err
}
relative, err := filepath.Rel(root, config.StateDir)
if err != nil || !filepath.IsAbs(root) || !filepath.IsAbs(config.StateDir) || relative == "." || relative == ".." || strings.HasPrefix(relative, ".."+string(filepath.Separator)) {
return startRequest{}, nil, fmt.Errorf("claudesdk: SDK state must be in a managed runtime subdirectory")
}
start.Cwd = filepath.Join(config.StateDir, "work")
for _, dir := range []string{config.StateDir, filepath.Join(config.StateDir, "tmp"), start.Cwd} {
if err := os.MkdirAll(dir, 0o700); err != nil {
return startRequest{}, nil, err
}
}
env := withProvider(append(append([]string{}, os.Environ()...), config.Env...), provider)
env = append(env, "CLAUDE_CONFIG_DIR="+config.StateDir, "TMPDIR="+filepath.Join(config.StateDir, "tmp"), "DISABLE_TELEMETRY=1", "CLAUDE_CODE_DISABLE_NONESSENTIAL_TRAFFIC=1")
projectedMCP, mcpEnv, err := prepareRuntimeMCP(req)
if err != nil {
return startRequest{}, nil, err
}
if req.MCPHTTPServers != nil {
servers := make([]mcpHTTPServer, 0, len(projectedMCP))
for _, server := range projectedMCP {
servers = append(servers, server.mcpHTTPServer)
}
start.MCPHTTPServers = &servers
}
env = append(env, mcpEnv...)
return start, env, nil
}

// prepareOptions validates the request's execution options and renders the
// selected model provider. mcp reports whether the Executor serves MCP, which
// an agent-host view takes from its Session rather than the request.
func prepareOptions(req proto.PromptRequestPayload, mcp bool) (startRequest, []string, error) {
skills := req.LocalEnvironment != nil && len(req.LocalEnvironment.Skills) != 0
start := startRequest{Type: "start", Resume: req.AgentSessionID, RequireHistory: req.RequireExistingNativeSession, ObserveMessages: req.ObserveMessages, Functions: req.FunctionTools, observeFunctions: req.ObserveToolObservations}
fail := func(reason string) (startRequest, []string, error) {
return startRequest{}, nil, fmt.Errorf("claudesdk: %s", reason)
Expand All @@ -71,7 +127,7 @@ func prepareConfiguration(config Config, req proto.PromptRequestPayload) (startR
return startRequest{}, nil, err
}
if req.ToolSearch {
if (req.LocalEnvironment != nil && (len(req.LocalEnvironment.Skills) != 0 || len(req.LocalEnvironment.MCP) != 0)) || req.MCPHTTPServers != nil || !req.DisableSubagents || (req.ExecutionControls != nil && req.ExecutionControls.OutputFormat != nil) {
if skills || mcp || !req.DisableSubagents || (req.ExecutionControls != nil && req.ExecutionControls.OutputFormat != nil) {
return fail("tool discovery requires the single-agent text/function profile")
}
start.ToolSearch = true
Expand All @@ -86,7 +142,7 @@ func prepareConfiguration(config Config, req proto.PromptRequestPayload) (startR
}
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)) {
if format.Type != "json_schema" || !req.ObserveMessages || !req.DisableSubagents || mcp || skills {
return fail("structured output requires the qualified message-observing single-agent function profile")
}
if err := proto.ValidateBinary64Schema(format.Schema); err != nil {
Expand All @@ -95,7 +151,7 @@ func prepareConfiguration(config Config, req proto.PromptRequestPayload) (startR
start.OutputFormat = format
}
if req.ObserveSubagentIdentities {
if req.DisableSubagents || len(req.FunctionTools) != 0 || req.MCPHTTPServers != nil || (req.LocalEnvironment != nil && len(req.LocalEnvironment.MCP) != 0) {
if req.DisableSubagents || len(req.FunctionTools) != 0 || mcp {
return fail("subagent execution does not support this tool combination")
}
limit := 6
Expand Down Expand Up @@ -142,49 +198,5 @@ func prepareConfiguration(config Config, req proto.PromptRequestPayload) (startR
if strings.TrimSpace(start.Model) == "" {
return fail("model is required")
}
if !filepath.IsAbs(config.Entrypoint) {
return fail("SDK entrypoint must be absolute")
}
config.Env = withProvider(config.Env, provider)
if config.Workspace != nil {
profile, env, err := prepareWorkspace(config, req)
if err != nil {
return startRequest{}, nil, err
}
start.Workspace = profile
start.Cwd = workspaceCwd(config.Workspace)
return start, env, nil
}
if req.LocalEnvironment != nil || req.RequireExistingNativeSession {
return fail("local execution and history recovery require a dedicated workspace")
}
root, err := paths.Root()
if err != nil {
return startRequest{}, nil, err
}
relative, err := filepath.Rel(root, config.StateDir)
if err != nil || !filepath.IsAbs(root) || !filepath.IsAbs(config.StateDir) || relative == "." || relative == ".." || strings.HasPrefix(relative, ".."+string(filepath.Separator)) {
return fail("SDK state must be in a managed runtime subdirectory")
}
start.Cwd = filepath.Join(config.StateDir, "work")
for _, dir := range []string{config.StateDir, filepath.Join(config.StateDir, "tmp"), start.Cwd} {
if err := os.MkdirAll(dir, 0o700); err != nil {
return startRequest{}, nil, err
}
}
env := withProvider(append(append([]string{}, os.Environ()...), config.Env...), provider)
env = append(env, "CLAUDE_CONFIG_DIR="+config.StateDir, "TMPDIR="+filepath.Join(config.StateDir, "tmp"), "DISABLE_TELEMETRY=1", "CLAUDE_CODE_DISABLE_NONESSENTIAL_TRAFFIC=1")
projectedMCP, mcpEnv, err := prepareRuntimeMCP(req)
if err != nil {
return startRequest{}, nil, err
}
if req.MCPHTTPServers != nil {
servers := make([]mcpHTTPServer, 0, len(projectedMCP))
for _, server := range projectedMCP {
servers = append(servers, server.mcpHTTPServer)
}
start.MCPHTTPServers = &servers
}
env = append(env, mcpEnv...)
return start, env, nil
return start, provider, nil
}
6 changes: 4 additions & 2 deletions apps/daemon/internal/agent/claudesdk/provider.go
Original file line number Diff line number Diff line change
Expand Up @@ -6,13 +6,15 @@ import (
harnessconfiguration "github.com/MiniMax-AI/OpenAgentCore/internal/harnessconfig/claudesdk"
)

// Validate the frozen native provider before producing launch variables.
// Validate the frozen native provider before producing launch variables. The
// key renders as ANTHROPIC_API_KEY, which Claude Code sends as the X-Api-Key
// header that the anthropic protocol declares.
func providerEnvironment(value any) ([]string, error) {
provider, err := harnessconfiguration.Configuration().ParseProvider(value)
if err != nil {
return nil, err
}
return []string{"ANTHROPIC_BASE_URL=" + provider.BaseURL, "ANTHROPIC_AUTH_TOKEN=" + provider.APIKey}, nil
return []string{"ANTHROPIC_BASE_URL=" + provider.BaseURL, "ANTHROPIC_API_KEY=" + provider.APIKey}, nil
}

func withProvider(env, provider []string) []string {
Expand Down
16 changes: 9 additions & 7 deletions apps/daemon/internal/agent/claudesdk/readiness.go
Original file line number Diff line number Diff line change
Expand Up @@ -16,13 +16,15 @@ import (
// RuntimeInfo describes a successful local probe, not provider authentication or
// execution capability. Versions are checked against the installed pinned manifest.
type RuntimeInfo struct {
Type string `json:"type"`
Protocol int `json:"protocol"`
Node string `json:"node"`
SDK string `json:"sdk"`
MCP string `json:"mcp"`
Native string `json:"native"`
Features []string `json:"features"`
Type string `json:"type"`
Protocol int `json:"protocol"`
Node string `json:"node"`
SDK string `json:"sdk"`
MCP string `json:"mcp"`
Native string `json:"native"`
// NativePath is the SDK's native Claude Code binary, relative to the bundle root.
NativePath string `json:"native_path"`
Features []string `json:"features"`
}

func (info RuntimeInfo) SupportsFunctionResultImages() bool {
Expand Down
2 changes: 2 additions & 0 deletions apps/daemon/internal/agent/claudesdk/readiness_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -145,6 +145,8 @@ func runReadinessHelper() {
time.Sleep(time.Minute)
case "wait":
time.Sleep(time.Minute)
case "view":
_, _ = fmt.Fprintln(os.Stdout, strings.TrimSuffix(readyReport, "}")+`,"native_path":"native/claude","features":["workspace_tools","workspace_prepare","workspace_command_observations","local_runtime_v2","mcp_http_tools","workspace_mcp_http"]}`)
default:
os.Exit(3)
}
Expand Down
8 changes: 7 additions & 1 deletion apps/daemon/internal/agent/claudesdk/session.go
Original file line number Diff line number Diff line change
Expand Up @@ -86,7 +86,13 @@ 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, OwnProcessGroup: true})
return startSession(clirunner.Start, clirunner.StartOptions{Parent: ctx, Binary: binary, Args: []string{config.Entrypoint}, Dir: start.Cwd, Env: env, NeedStdin: true, OwnProcessGroup: true})
}

// startSession runs the bridge through start: clirunner.Start, or an agent-host
// view's Launch.
func startSession(start func(clirunner.StartOptions) (*clirunner.Process, error), options clirunner.StartOptions) (*session, error) {
process, err := start(options)
if err != nil {
return nil, err
}
Expand Down
Loading
Loading