From ca3abdb0118da5142e190ae17ea3aef48943e503 Mon Sep 17 00:00:00 2001 From: bog Date: Tue, 7 Jul 2026 17:07:26 +0800 Subject: [PATCH] Add Clanker Cloud sandbox CLI support --- cmd/cloud_sandboxes.go | 240 ++++++++++++++++++++++++ cmd/mcp.go | 2 + cmd/mcp_cloud_sandboxes.go | 253 ++++++++++++++++++++++++++ internal/clankercloud/sandbox.go | 253 ++++++++++++++++++++++++++ internal/clankercloud/sandbox_test.go | 118 ++++++++++++ 5 files changed, 866 insertions(+) create mode 100644 cmd/cloud_sandboxes.go create mode 100644 cmd/mcp_cloud_sandboxes.go create mode 100644 internal/clankercloud/sandbox.go create mode 100644 internal/clankercloud/sandbox_test.go diff --git a/cmd/cloud_sandboxes.go b/cmd/cloud_sandboxes.go new file mode 100644 index 0000000..8e8d873 --- /dev/null +++ b/cmd/cloud_sandboxes.go @@ -0,0 +1,240 @@ +package cmd + +import ( + "encoding/json" + "fmt" + "os" + "strings" + + "github.com/bgdnvk/clanker/internal/clankercloud" + "github.com/spf13/cobra" +) + +func newCloudSandboxesCmd() *cobra.Command { + var apiBaseURL string + var apiKey string + var sandboxToken string + + client := func() *clankercloud.SandboxClient { + return clankercloud.NewSandboxClient(clankercloud.SandboxClientOptions{ + BaseURL: apiBaseURL, + AccountKey: apiKey, + SandboxToken: sandboxToken, + }) + } + + cmd := &cobra.Command{ + Use: "sandboxes", + Aliases: []string{"sandbox"}, + Short: "Create and operate hosted Clanker Cloud sandboxes", + Long: strings.TrimSpace(`Create anonymous or account-owned Clanker Cloud sandboxes, then run commands or send task messages. + +The public sandbox API defaults to https://clankercloud.ai/api. Set CLANKER_CLOUD_API_KEY for account-owned sandboxes and CLANKER_SANDBOX_TOKEN for per-sandbox commands.`), + } + cmd.PersistentFlags().StringVar(&apiBaseURL, "api-base-url", "", "Clanker Cloud sandbox API base URL (default: CLANKER_CLOUD_SANDBOX_API_BASE_URL or https://clankercloud.ai/api)") + cmd.PersistentFlags().StringVar(&apiKey, "api-key", "", "Clanker Cloud account API key (default: CLANKER_CLOUD_API_KEY)") + cmd.PersistentFlags().StringVar(&sandboxToken, "sandbox-token", "", "sandbox token returned by create (default: CLANKER_SANDBOX_TOKEN)") + + cmd.AddCommand(newCloudSandboxesCreateCmd(client)) + cmd.AddCommand(newCloudSandboxesListCmd(client)) + cmd.AddCommand(newCloudSandboxesInspectCmd(client)) + cmd.AddCommand(newCloudSandboxesDeleteCmd(client)) + cmd.AddCommand(newCloudSandboxesCommandCmd(client)) + cmd.AddCommand(newCloudSandboxesMessageCmd(client)) + cmd.AddCommand(newCloudSandboxesRunCmd(&apiBaseURL, &apiKey, &sandboxToken)) + + return cmd +} + +func newCloudSandboxesCreateCmd(client func() *clankercloud.SandboxClient) *cobra.Command { + var name string + var agent string + var region string + cmd := &cobra.Command{ + Use: "create", + Short: "Create a hosted sandbox", + RunE: func(cmd *cobra.Command, args []string) error { + result, err := client().Create(cmd.Context(), clankercloud.SandboxCreateRequest{Name: name, Agent: agent, Region: region}) + return printSandboxResult(result, err) + }, + } + cmd.Flags().StringVar(&name, "name", "agent-sandbox", "sandbox name") + cmd.Flags().StringVar(&agent, "agent", "clanker-cli", "sandbox agent image: clanker-cli, codex, claude-code, openclaw, hermes, empty, or clanker-vision") + cmd.Flags().StringVar(®ion, "region", "earth", "sandbox region") + return cmd +} + +func newCloudSandboxesListCmd(client func() *clankercloud.SandboxClient) *cobra.Command { + return &cobra.Command{ + Use: "list", + Short: "List account-owned sandboxes", + RunE: func(cmd *cobra.Command, args []string) error { + result, err := client().List(cmd.Context()) + return printSandboxResult(result, err) + }, + } +} + +func newCloudSandboxesInspectCmd(client func() *clankercloud.SandboxClient) *cobra.Command { + return &cobra.Command{ + Use: "inspect SANDBOX_ID", + Short: "Inspect one sandbox", + Args: cobra.ExactArgs(1), + RunE: func(cmd *cobra.Command, args []string) error { + result, err := client().Inspect(cmd.Context(), args[0]) + return printSandboxResult(result, err) + }, + } +} + +func newCloudSandboxesDeleteCmd(client func() *clankercloud.SandboxClient) *cobra.Command { + return &cobra.Command{ + Use: "delete SANDBOX_ID", + Short: "Delete one sandbox", + Args: cobra.ExactArgs(1), + RunE: func(cmd *cobra.Command, args []string) error { + result, err := client().Delete(cmd.Context(), args[0]) + return printSandboxResult(result, err) + }, + } +} + +func newCloudSandboxesCommandCmd(client func() *clankercloud.SandboxClient) *cobra.Command { + var timeoutSeconds int + cmd := &cobra.Command{ + Use: "command SANDBOX_ID COMMAND...", + Short: "Run a shell command in a sandbox", + Args: cobra.MinimumNArgs(2), + RunE: func(cmd *cobra.Command, args []string) error { + payload := clankercloud.SandboxCommandRequest{ + Command: strings.Join(args[1:], " "), + TimeoutSeconds: timeoutSeconds, + } + result, err := client().Command(cmd.Context(), args[0], payload) + return printSandboxResult(result, err) + }, + } + cmd.Flags().IntVar(&timeoutSeconds, "timeout-seconds", 0, "optional command timeout in seconds") + return cmd +} + +func newCloudSandboxesMessageCmd(client func() *clankercloud.SandboxClient) *cobra.Command { + var role string + cmd := &cobra.Command{ + Use: "message SANDBOX_ID MESSAGE...", + Short: "Send a task message to a sandbox agent", + Args: cobra.MinimumNArgs(2), + RunE: func(cmd *cobra.Command, args []string) error { + payload := clankercloud.SandboxMessageRequest{ + Role: role, + Content: strings.Join(args[1:], " "), + } + result, err := client().Message(cmd.Context(), args[0], payload) + return printSandboxResult(result, err) + }, + } + cmd.Flags().StringVar(&role, "role", "user", "message role") + return cmd +} + +func newCloudSandboxesRunCmd(apiBaseURL *string, apiKey *string, sandboxToken *string) *cobra.Command { + var name string + var agentName string + var region string + cmd := &cobra.Command{ + Use: "run TASK...", + Short: "Create a sandbox and send it a task message", + Args: cobra.MinimumNArgs(1), + RunE: func(cmd *cobra.Command, args []string) error { + task := strings.TrimSpace(strings.Join(args, " ")) + client := clankercloud.NewSandboxClient(clankercloud.SandboxClientOptions{ + BaseURL: derefString(apiBaseURL), + AccountKey: derefString(apiKey), + SandboxToken: derefString(sandboxToken), + }) + created, err := client.Create(cmd.Context(), clankercloud.SandboxCreateRequest{Name: name, Agent: agentName, Region: region}) + if err != nil { + return err + } + if !clankercloud.SandboxResultOK(created) { + _ = printJSON(created) + return clankercloud.SandboxResultStatusError(created) + } + + sandboxID := clankercloud.ExtractSandboxID(created.Body) + token := clankercloud.ExtractSandboxToken(created.Body) + if sandboxID == "" { + _ = printJSON(created) + return fmt.Errorf("create response did not include a sandbox id") + } + if token == "" { + token = client.SandboxToken() + } + if token == "" { + token = client.AccountKey() + } + runClient := clankercloud.NewSandboxClient(clankercloud.SandboxClientOptions{ + BaseURL: derefString(apiBaseURL), + AccountKey: derefString(apiKey), + SandboxToken: token, + }) + message, err := runClient.Message(cmd.Context(), sandboxID, clankercloud.SandboxMessageRequest{ + Role: "user", + Content: task, + Metadata: map[string]any{ + "source": "clanker-cli", + "mode": "sandboxes.run", + }, + }) + if err != nil { + return err + } + out := map[string]any{ + "created": created, + "message": message, + } + if !clankercloud.SandboxResultOK(message) { + _ = printJSON(out) + return clankercloud.SandboxResultStatusError(message) + } + return printJSON(out) + }, + } + cmd.Flags().StringVar(&name, "name", "agent-runner", "sandbox name") + cmd.Flags().StringVar(&agentName, "agent", "clanker-cli", "sandbox agent image") + cmd.Flags().StringVar(®ion, "region", "earth", "sandbox region") + return cmd +} + +func printSandboxResult(result any, err error) error { + if err != nil { + return err + } + if err := printJSON(result); err != nil { + return err + } + if apiResult, ok := result.(*clankercloud.SandboxAPIResult); ok { + return clankercloud.SandboxResultStatusError(apiResult) + } + return nil +} + +func printJSON(value any) error { + encoded, err := json.MarshalIndent(value, "", " ") + if err != nil { + return fmt.Errorf("encode output: %w", err) + } + fmt.Fprintln(os.Stdout, string(encoded)) + return nil +} + +func derefString(value *string) string { + if value == nil { + return "" + } + return *value +} + +func init() { + cloudCmd.AddCommand(newCloudSandboxesCmd()) +} diff --git a/cmd/mcp.go b/cmd/mcp.go index 7c5ee76..499efc6 100644 --- a/cmd/mcp.go +++ b/cmd/mcp.go @@ -325,6 +325,8 @@ func newClankerMCPServer() *mcptransport.MCPServer { }), ) + registerCloudSandboxMCPTools(server) + server.AddTool( mcp.NewTool( "clanker_vercel_ask", diff --git a/cmd/mcp_cloud_sandboxes.go b/cmd/mcp_cloud_sandboxes.go new file mode 100644 index 0000000..54fba25 --- /dev/null +++ b/cmd/mcp_cloud_sandboxes.go @@ -0,0 +1,253 @@ +package cmd + +import ( + "context" + "fmt" + "strings" + + "github.com/bgdnvk/clanker/internal/clankercloud" + "github.com/mark3labs/mcp-go/mcp" + mcptransport "github.com/mark3labs/mcp-go/server" +) + +type cloudSandboxCreateArgs struct { + Name string `json:"name,omitempty" jsonschema:"description=Sandbox name; defaults to agent-sandbox."` + Agent string `json:"agent,omitempty" jsonschema:"description=Agent image: clanker-cli, codex, claude-code, openclaw, hermes, empty, or clanker-vision."` + Region string `json:"region,omitempty" jsonschema:"description=Sandbox region; defaults to earth."` + Metadata map[string]any `json:"metadata,omitempty" jsonschema:"description=Optional non-secret orchestration metadata."` + APIBaseURL string `json:"apiBaseUrl,omitempty" jsonschema:"description=Sandbox API base URL; defaults to CLANKER_CLOUD_SANDBOX_API_BASE_URL or https://clankercloud.ai/api."` + APIKey string `json:"apiKey,omitempty" jsonschema:"description=Clanker Cloud account API key; defaults to CLANKER_CLOUD_API_KEY."` +} + +type cloudSandboxListArgs struct { + APIBaseURL string `json:"apiBaseUrl,omitempty" jsonschema:"description=Sandbox API base URL."` + APIKey string `json:"apiKey,omitempty" jsonschema:"description=Clanker Cloud account API key; defaults to CLANKER_CLOUD_API_KEY."` +} + +type cloudSandboxIDArgs struct { + SandboxID string `json:"sandboxId" jsonschema:"description=Sandbox id,required"` + APIBaseURL string `json:"apiBaseUrl,omitempty" jsonschema:"description=Sandbox API base URL."` + APIKey string `json:"apiKey,omitempty" jsonschema:"description=Clanker Cloud account API key; defaults to CLANKER_CLOUD_API_KEY."` + SandboxToken string `json:"sandboxToken,omitempty" jsonschema:"description=Sandbox token returned by create; defaults to CLANKER_SANDBOX_TOKEN."` +} + +type cloudSandboxCommandArgs struct { + SandboxID string `json:"sandboxId" jsonschema:"description=Sandbox id,required"` + Command string `json:"command" jsonschema:"description=Shell command to run in the sandbox,required"` + TimeoutSeconds int `json:"timeoutSeconds,omitempty" jsonschema:"description=Optional command timeout in seconds."` + Env map[string]string `json:"env,omitempty" jsonschema:"description=Optional non-secret environment variables for the command."` + APIBaseURL string `json:"apiBaseUrl,omitempty" jsonschema:"description=Sandbox API base URL."` + APIKey string `json:"apiKey,omitempty" jsonschema:"description=Clanker Cloud account API key; defaults to CLANKER_CLOUD_API_KEY."` + SandboxToken string `json:"sandboxToken,omitempty" jsonschema:"description=Sandbox token returned by create; defaults to CLANKER_SANDBOX_TOKEN."` +} + +type cloudSandboxMessageArgs struct { + SandboxID string `json:"sandboxId" jsonschema:"description=Sandbox id,required"` + Role string `json:"role,omitempty" jsonschema:"description=Message role; defaults to user."` + Content string `json:"content" jsonschema:"description=Message content to send to the sandbox agent,required"` + Metadata map[string]any `json:"metadata,omitempty" jsonschema:"description=Optional non-secret orchestration metadata."` + APIBaseURL string `json:"apiBaseUrl,omitempty" jsonschema:"description=Sandbox API base URL."` + APIKey string `json:"apiKey,omitempty" jsonschema:"description=Clanker Cloud account API key; defaults to CLANKER_CLOUD_API_KEY."` + SandboxToken string `json:"sandboxToken,omitempty" jsonschema:"description=Sandbox token returned by create; defaults to CLANKER_SANDBOX_TOKEN."` +} + +type cloudSandboxTaskArgs struct { + Task string `json:"task" jsonschema:"description=Task to send to the new sandbox agent,required"` + Name string `json:"name,omitempty" jsonschema:"description=Sandbox name; defaults to agent-runner."` + Agent string `json:"agent,omitempty" jsonschema:"description=Agent image; defaults to clanker-cli."` + Region string `json:"region,omitempty" jsonschema:"description=Sandbox region; defaults to earth."` + Metadata map[string]any `json:"metadata,omitempty" jsonschema:"description=Optional non-secret orchestration metadata."` + APIBaseURL string `json:"apiBaseUrl,omitempty" jsonschema:"description=Sandbox API base URL."` + APIKey string `json:"apiKey,omitempty" jsonschema:"description=Clanker Cloud account API key; defaults to CLANKER_CLOUD_API_KEY."` +} + +func registerCloudSandboxMCPTools(server *mcptransport.MCPServer) { + server.AddTool( + mcp.NewTool( + "clanker_cloud_create_sandbox", + mcp.WithDescription("Create an anonymous or account-owned hosted Clanker Cloud sandbox. The response includes sandboxToken; keep it out of user-visible transcripts unless the user explicitly asks."), + mcp.WithInputSchema[cloudSandboxCreateArgs](), + mcp.WithDestructiveHintAnnotation(false), + mcp.WithIdempotentHintAnnotation(false), + ), + mcp.NewTypedToolHandler(func(ctx context.Context, _ mcp.CallToolRequest, args cloudSandboxCreateArgs) (*mcp.CallToolResult, error) { + client := newMCPSandboxClient(args.APIBaseURL, args.APIKey, "") + result, err := client.Create(ctx, clankercloud.SandboxCreateRequest{ + Name: args.Name, + Agent: args.Agent, + Region: args.Region, + Metadata: args.Metadata, + }) + return mcpSandboxResult(result, err) + }), + ) + + server.AddTool( + mcp.NewTool( + "clanker_cloud_list_sandboxes", + mcp.WithDescription("List account-owned Clanker Cloud sandboxes using a Clanker Cloud account API key."), + mcp.WithInputSchema[cloudSandboxListArgs](), + mcp.WithReadOnlyHintAnnotation(true), + ), + mcp.NewTypedToolHandler(func(ctx context.Context, _ mcp.CallToolRequest, args cloudSandboxListArgs) (*mcp.CallToolResult, error) { + result, err := newMCPSandboxClient(args.APIBaseURL, args.APIKey, "").List(ctx) + return mcpSandboxResult(result, err) + }), + ) + + server.AddTool( + mcp.NewTool( + "clanker_cloud_inspect_sandbox", + mcp.WithDescription("Inspect one hosted Clanker Cloud sandbox by id."), + mcp.WithInputSchema[cloudSandboxIDArgs](), + mcp.WithReadOnlyHintAnnotation(true), + ), + mcp.NewTypedToolHandler(func(ctx context.Context, _ mcp.CallToolRequest, args cloudSandboxIDArgs) (*mcp.CallToolResult, error) { + if strings.TrimSpace(args.SandboxID) == "" { + return mcp.NewToolResultError("sandboxId is required"), nil + } + result, err := newMCPSandboxClient(args.APIBaseURL, args.APIKey, args.SandboxToken).Inspect(ctx, args.SandboxID) + return mcpSandboxResult(result, err) + }), + ) + + server.AddTool( + mcp.NewTool( + "clanker_cloud_delete_sandbox", + mcp.WithDescription("Delete one hosted Clanker Cloud sandbox by id. Use only after user approval if the sandbox still contains needed work."), + mcp.WithInputSchema[cloudSandboxIDArgs](), + mcp.WithDestructiveHintAnnotation(true), + mcp.WithIdempotentHintAnnotation(false), + ), + mcp.NewTypedToolHandler(func(ctx context.Context, _ mcp.CallToolRequest, args cloudSandboxIDArgs) (*mcp.CallToolResult, error) { + if strings.TrimSpace(args.SandboxID) == "" { + return mcp.NewToolResultError("sandboxId is required"), nil + } + result, err := newMCPSandboxClient(args.APIBaseURL, args.APIKey, args.SandboxToken).Delete(ctx, args.SandboxID) + return mcpSandboxResult(result, err) + }), + ) + + server.AddTool( + mcp.NewTool( + "clanker_cloud_run_sandbox_command", + mcp.WithDescription("Run a shell command in a hosted Clanker Cloud sandbox. Prefer read-only commands unless the user has approved side effects."), + mcp.WithInputSchema[cloudSandboxCommandArgs](), + mcp.WithDestructiveHintAnnotation(false), + ), + mcp.NewTypedToolHandler(func(ctx context.Context, _ mcp.CallToolRequest, args cloudSandboxCommandArgs) (*mcp.CallToolResult, error) { + if strings.TrimSpace(args.SandboxID) == "" { + return mcp.NewToolResultError("sandboxId is required"), nil + } + if strings.TrimSpace(args.Command) == "" { + return mcp.NewToolResultError("command is required"), nil + } + result, err := newMCPSandboxClient(args.APIBaseURL, args.APIKey, args.SandboxToken).Command(ctx, args.SandboxID, clankercloud.SandboxCommandRequest{ + Command: args.Command, + TimeoutSeconds: args.TimeoutSeconds, + Env: args.Env, + }) + return mcpSandboxResult(result, err) + }), + ) + + server.AddTool( + mcp.NewTool( + "clanker_cloud_send_sandbox_message", + mcp.WithDescription("Send a message to a hosted Clanker Cloud sandbox agent."), + mcp.WithInputSchema[cloudSandboxMessageArgs](), + mcp.WithDestructiveHintAnnotation(false), + ), + mcp.NewTypedToolHandler(func(ctx context.Context, _ mcp.CallToolRequest, args cloudSandboxMessageArgs) (*mcp.CallToolResult, error) { + if strings.TrimSpace(args.SandboxID) == "" { + return mcp.NewToolResultError("sandboxId is required"), nil + } + if strings.TrimSpace(args.Content) == "" { + return mcp.NewToolResultError("content is required"), nil + } + result, err := newMCPSandboxClient(args.APIBaseURL, args.APIKey, args.SandboxToken).Message(ctx, args.SandboxID, clankercloud.SandboxMessageRequest{ + Role: args.Role, + Content: args.Content, + Metadata: args.Metadata, + }) + return mcpSandboxResult(result, err) + }), + ) + + server.AddTool( + mcp.NewTool( + "clanker_cloud_run_sandbox_task", + mcp.WithDescription("Create a hosted Clanker Cloud sandbox and send the initial task message to its agent."), + mcp.WithInputSchema[cloudSandboxTaskArgs](), + mcp.WithDestructiveHintAnnotation(false), + mcp.WithIdempotentHintAnnotation(false), + ), + mcp.NewTypedToolHandler(func(ctx context.Context, _ mcp.CallToolRequest, args cloudSandboxTaskArgs) (*mcp.CallToolResult, error) { + if strings.TrimSpace(args.Task) == "" { + return mcp.NewToolResultError("task is required"), nil + } + client := newMCPSandboxClient(args.APIBaseURL, args.APIKey, "") + metadata := map[string]any{ + "source": "clanker-mcp", + "mode": "sandbox-task", + } + for key, value := range args.Metadata { + metadata[key] = value + } + created, err := client.Create(ctx, clankercloud.SandboxCreateRequest{ + Name: firstNonEmpty(args.Name, "agent-runner"), + Agent: args.Agent, + Region: args.Region, + Metadata: metadata, + }) + if err != nil { + return mcp.NewToolResultError(err.Error()), nil + } + if !clankercloud.SandboxResultOK(created) { + return mcp.NewToolResultJSON(map[string]any{"created": created}) + } + sandboxID := clankercloud.ExtractSandboxID(created.Body) + if sandboxID == "" { + return mcp.NewToolResultError("create response did not include a sandbox id"), nil + } + token := firstNonEmpty(clankercloud.ExtractSandboxToken(created.Body), client.SandboxToken(), client.AccountKey()) + message, err := newMCPSandboxClient(args.APIBaseURL, args.APIKey, token).Message(ctx, sandboxID, clankercloud.SandboxMessageRequest{ + Role: "user", + Content: args.Task, + Metadata: metadata, + }) + if err != nil { + return mcp.NewToolResultError(err.Error()), nil + } + return mcp.NewToolResultJSON(map[string]any{ + "created": created, + "message": message, + }) + }), + ) +} + +func newMCPSandboxClient(apiBaseURL string, apiKey string, sandboxToken string) *clankercloud.SandboxClient { + return clankercloud.NewSandboxClient(clankercloud.SandboxClientOptions{ + BaseURL: apiBaseURL, + AccountKey: apiKey, + SandboxToken: sandboxToken, + }) +} + +func mcpSandboxResult(result *clankercloud.SandboxAPIResult, err error) (*mcp.CallToolResult, error) { + if err != nil { + return mcp.NewToolResultError(err.Error()), nil + } + if result == nil { + return mcp.NewToolResultError("sandbox request failed"), nil + } + if !clankercloud.SandboxResultOK(result) { + return mcp.NewToolResultJSON(map[string]any{ + "ok": false, + "error": fmt.Sprintf("sandbox request returned status %d", result.Status), + "result": result, + }) + } + return mcp.NewToolResultJSON(result) +} diff --git a/internal/clankercloud/sandbox.go b/internal/clankercloud/sandbox.go new file mode 100644 index 0000000..d67d629 --- /dev/null +++ b/internal/clankercloud/sandbox.go @@ -0,0 +1,253 @@ +package clankercloud + +import ( + "bytes" + "context" + "encoding/json" + "fmt" + "io" + "net/http" + "net/url" + "os" + "strings" +) + +const DefaultSandboxAPIBaseURL = "https://clankercloud.ai/api" + +type SandboxClient struct { + httpClient *http.Client + baseURL string + accountKey string + sandboxToken string +} + +type SandboxClientOptions struct { + BaseURL string + AccountKey string + SandboxToken string + HTTPClient *http.Client +} + +type SandboxAPIResult struct { + BaseURL string `json:"baseUrl"` + Method string `json:"method"` + Path string `json:"path"` + Status int `json:"status"` + ContentType string `json:"contentType,omitempty"` + Headers map[string]string `json:"headers,omitempty"` + Body any `json:"body,omitempty"` +} + +type SandboxCreateRequest struct { + Name string `json:"name,omitempty"` + Agent string `json:"agent,omitempty"` + Region string `json:"region,omitempty"` + Metadata map[string]any `json:"metadata,omitempty"` +} + +type SandboxCommandRequest struct { + Command string `json:"command"` + TimeoutSeconds int `json:"timeoutSeconds,omitempty"` + Env map[string]string `json:"env,omitempty"` +} + +type SandboxMessageRequest struct { + Role string `json:"role,omitempty"` + Content string `json:"content"` + Metadata map[string]any `json:"metadata,omitempty"` +} + +func NewSandboxClient(opts SandboxClientOptions) *SandboxClient { + httpClient := opts.HTTPClient + if httpClient == nil { + httpClient = &http.Client{Timeout: defaultHTTPTimeout} + } + return &SandboxClient{ + httpClient: httpClient, + baseURL: firstNonEmpty(opts.BaseURL, os.Getenv("CLANKER_CLOUD_SANDBOX_API_BASE_URL"), os.Getenv("CLANKER_SANDBOX_API_BASE_URL"), DefaultSandboxAPIBaseURL), + accountKey: firstNonEmpty(opts.AccountKey, os.Getenv("CLANKER_CLOUD_API_KEY")), + sandboxToken: firstNonEmpty(opts.SandboxToken, os.Getenv("CLANKER_SANDBOX_TOKEN")), + } +} + +func NormalizeSandboxAPIBaseURL(raw string) (string, error) { + trimmed := strings.TrimRight(strings.TrimSpace(raw), "/") + if trimmed == "" { + return "", fmt.Errorf("clanker cloud sandbox API base URL is empty") + } + parsed, err := url.Parse(trimmed) + if err != nil { + return "", fmt.Errorf("parse clanker cloud sandbox API base URL: %w", err) + } + if parsed.Scheme != "http" && parsed.Scheme != "https" { + return "", fmt.Errorf("clanker cloud sandbox API base URL must use http or https") + } + if parsed.User != nil { + return "", fmt.Errorf("clanker cloud sandbox API base URL must not include user info") + } + if parsed.RawQuery != "" || parsed.Fragment != "" { + return "", fmt.Errorf("clanker cloud sandbox API base URL must not include query or fragment") + } + path := strings.TrimRight(parsed.EscapedPath(), "/") + if path == "" { + parsed.Path = "/api" + } else if path != "/api" && !strings.HasSuffix(path, "/api") { + return "", fmt.Errorf("clanker cloud sandbox API base URL must end with /api") + } + return strings.TrimRight(parsed.String(), "/"), nil +} + +func (c *SandboxClient) BaseURL() (string, error) { + return NormalizeSandboxAPIBaseURL(c.baseURL) +} + +func (c *SandboxClient) AccountKey() string { + return strings.TrimSpace(c.accountKey) +} + +func (c *SandboxClient) SandboxToken() string { + return strings.TrimSpace(c.sandboxToken) +} + +func (c *SandboxClient) Create(ctx context.Context, payload SandboxCreateRequest) (*SandboxAPIResult, error) { + if strings.TrimSpace(payload.Name) == "" { + payload.Name = "agent-sandbox" + } + if strings.TrimSpace(payload.Agent) == "" { + payload.Agent = "clanker-cli" + } + if strings.TrimSpace(payload.Region) == "" { + payload.Region = "earth" + } + return c.callJSON(ctx, http.MethodPost, "/sandboxes", c.AccountKey(), payload) +} + +func (c *SandboxClient) List(ctx context.Context) (*SandboxAPIResult, error) { + return c.callJSON(ctx, http.MethodGet, "/sandboxes", c.AccountKey(), nil) +} + +func (c *SandboxClient) Inspect(ctx context.Context, sandboxID string) (*SandboxAPIResult, error) { + return c.callJSON(ctx, http.MethodGet, "/sandboxes/"+url.PathEscape(strings.TrimSpace(sandboxID)), c.preferredSandboxToken(), nil) +} + +func (c *SandboxClient) Delete(ctx context.Context, sandboxID string) (*SandboxAPIResult, error) { + return c.callJSON(ctx, http.MethodDelete, "/sandboxes/"+url.PathEscape(strings.TrimSpace(sandboxID)), c.preferredSandboxToken(), nil) +} + +func (c *SandboxClient) Command(ctx context.Context, sandboxID string, payload SandboxCommandRequest) (*SandboxAPIResult, error) { + return c.callJSON(ctx, http.MethodPost, "/sandboxes/"+url.PathEscape(strings.TrimSpace(sandboxID))+"/commands", c.preferredSandboxToken(), payload) +} + +func (c *SandboxClient) Message(ctx context.Context, sandboxID string, payload SandboxMessageRequest) (*SandboxAPIResult, error) { + if strings.TrimSpace(payload.Role) == "" { + payload.Role = "user" + } + return c.callJSON(ctx, http.MethodPost, "/sandboxes/"+url.PathEscape(strings.TrimSpace(sandboxID))+"/messages", c.preferredSandboxToken(), payload) +} + +func (c *SandboxClient) preferredSandboxToken() string { + return firstNonEmpty(c.sandboxToken, c.accountKey) +} + +func (c *SandboxClient) callJSON(ctx context.Context, method string, path string, token string, body any) (*SandboxAPIResult, error) { + baseURL, err := c.BaseURL() + if err != nil { + return nil, err + } + trimmedPath := strings.TrimSpace(path) + if !strings.HasPrefix(trimmedPath, "/") { + trimmedPath = "/" + trimmedPath + } + + var bodyReader io.Reader + if body != nil { + encoded, err := json.Marshal(body) + if err != nil { + return nil, fmt.Errorf("encode sandbox request: %w", err) + } + bodyReader = bytes.NewReader(encoded) + } + + req, err := http.NewRequestWithContext(ctx, method, strings.TrimRight(baseURL, "/")+trimmedPath, bodyReader) + if err != nil { + return nil, fmt.Errorf("create sandbox request: %w", err) + } + if body != nil { + req.Header.Set("Content-Type", "application/json") + } + if trimmedToken := strings.TrimSpace(token); trimmedToken != "" { + req.Header.Set("X-API-Key", trimmedToken) + } + + resp, err := c.httpClient.Do(req) + if err != nil { + return nil, fmt.Errorf("perform sandbox request: %w", err) + } + defer resp.Body.Close() + + result := &SandboxAPIResult{ + BaseURL: baseURL, + Method: method, + Path: trimmedPath, + Status: resp.StatusCode, + ContentType: resp.Header.Get("Content-Type"), + Headers: map[string]string{}, + } + for key, values := range resp.Header { + if len(values) > 0 { + result.Headers[key] = strings.Join(values, ", ") + } + } + rawBody, err := io.ReadAll(resp.Body) + if err != nil { + return nil, fmt.Errorf("read sandbox response: %w", err) + } + result.Body = decodeBody(rawBody) + return result, nil +} + +func SandboxResultOK(result *SandboxAPIResult) bool { + return result != nil && result.Status >= 200 && result.Status < 300 +} + +func SandboxResultStatusError(result *SandboxAPIResult) error { + if SandboxResultOK(result) { + return nil + } + if result == nil { + return fmt.Errorf("sandbox request failed") + } + return fmt.Errorf("sandbox request returned status %d", result.Status) +} + +func ExtractSandboxID(body any) string { + if id := extractStringAt(body, "box", "id"); id != "" { + return id + } + if id := extractStringAt(body, "sandbox", "id"); id != "" { + return id + } + return extractStringAt(body, "id") +} + +func ExtractSandboxToken(body any) string { + if token := extractStringAt(body, "sandboxToken"); token != "" { + return token + } + return extractStringAt(body, "token") +} + +func extractStringAt(value any, path ...string) string { + current := value + for _, part := range path { + object, ok := current.(map[string]any) + if !ok { + return "" + } + current = object[part] + } + if text, ok := current.(string); ok { + return strings.TrimSpace(text) + } + return "" +} diff --git a/internal/clankercloud/sandbox_test.go b/internal/clankercloud/sandbox_test.go new file mode 100644 index 0000000..14b9d5e --- /dev/null +++ b/internal/clankercloud/sandbox_test.go @@ -0,0 +1,118 @@ +package clankercloud + +import ( + "context" + "encoding/json" + "net/http" + "net/http/httptest" + "testing" +) + +func TestNormalizeSandboxAPIBaseURL(t *testing.T) { + tests := []struct { + name string + raw string + want string + wantErr bool + }{ + {name: "default host with api path", raw: "https://clankercloud.ai/api/", want: "https://clankercloud.ai/api"}, + {name: "host without path appends api", raw: "https://clankercloud.ai", want: "https://clankercloud.ai/api"}, + {name: "nested api path", raw: "https://gateway.example.com/v1/api", want: "https://gateway.example.com/v1/api"}, + {name: "query rejected", raw: "https://clankercloud.ai/api?x=1", wantErr: true}, + {name: "userinfo rejected", raw: "https://token@clankercloud.ai/api", wantErr: true}, + {name: "wrong path rejected", raw: "https://clankercloud.ai/v1", wantErr: true}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got, err := NormalizeSandboxAPIBaseURL(tt.raw) + if tt.wantErr { + if err == nil { + t.Fatalf("NormalizeSandboxAPIBaseURL(%q) succeeded, want error", tt.raw) + } + return + } + if err != nil { + t.Fatalf("NormalizeSandboxAPIBaseURL(%q): %v", tt.raw, err) + } + if got != tt.want { + t.Fatalf("NormalizeSandboxAPIBaseURL(%q) = %q, want %q", tt.raw, got, tt.want) + } + }) + } +} + +func TestSandboxClientCreateAndCommand(t *testing.T) { + var createToken string + var commandToken string + var createPayload SandboxCreateRequest + var commandPayload SandboxCommandRequest + + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "application/json") + switch r.URL.Path { + case "/api/sandboxes": + if r.Method != http.MethodPost { + t.Fatalf("create method = %s, want POST", r.Method) + } + createToken = r.Header.Get("X-API-Key") + if err := json.NewDecoder(r.Body).Decode(&createPayload); err != nil { + t.Fatalf("decode create payload: %v", err) + } + _, _ = w.Write([]byte(`{"box":{"id":"box_123","expiresAt":"2026-07-07T10:00:00Z"},"sandboxToken":"sandbox-token"}`)) + case "/api/sandboxes/box_123/commands": + if r.Method != http.MethodPost { + t.Fatalf("command method = %s, want POST", r.Method) + } + commandToken = r.Header.Get("X-API-Key") + if err := json.NewDecoder(r.Body).Decode(&commandPayload); err != nil { + t.Fatalf("decode command payload: %v", err) + } + _, _ = w.Write([]byte(`{"ok":true,"output":"done"}`)) + default: + t.Fatalf("unexpected path %s", r.URL.Path) + } + })) + defer server.Close() + + client := NewSandboxClient(SandboxClientOptions{ + BaseURL: server.URL + "/api", + AccountKey: "account-token", + SandboxToken: "sandbox-token", + HTTPClient: server.Client(), + }) + + created, err := client.Create(context.Background(), SandboxCreateRequest{Name: "repo-check", Agent: "codex", Region: "earth"}) + if err != nil { + t.Fatalf("Create: %v", err) + } + if !SandboxResultOK(created) { + t.Fatalf("Create status = %d, want 2xx", created.Status) + } + if createToken != "account-token" { + t.Fatalf("create X-API-Key = %q, want account-token", createToken) + } + if createPayload.Name != "repo-check" || createPayload.Agent != "codex" || createPayload.Region != "earth" { + t.Fatalf("create payload = %+v", createPayload) + } + if got := ExtractSandboxID(created.Body); got != "box_123" { + t.Fatalf("ExtractSandboxID = %q, want box_123", got) + } + if got := ExtractSandboxToken(created.Body); got != "sandbox-token" { + t.Fatalf("ExtractSandboxToken = %q, want sandbox-token", got) + } + + result, err := client.Command(context.Background(), "box_123", SandboxCommandRequest{Command: "go test ./...", TimeoutSeconds: 600}) + if err != nil { + t.Fatalf("Command: %v", err) + } + if !SandboxResultOK(result) { + t.Fatalf("Command status = %d, want 2xx", result.Status) + } + if commandToken != "sandbox-token" { + t.Fatalf("command X-API-Key = %q, want sandbox-token", commandToken) + } + if commandPayload.Command != "go test ./..." || commandPayload.TimeoutSeconds != 600 { + t.Fatalf("command payload = %+v", commandPayload) + } +}