diff --git a/cmd/amika-server/main.go b/cmd/amika-server/main.go index d4725b3d..303158f2 100644 --- a/cmd/amika-server/main.go +++ b/cmd/amika-server/main.go @@ -2,6 +2,7 @@ package main import ( + "context" "errors" "flag" "fmt" @@ -9,10 +10,15 @@ import ( "log" "net/http" "os" + "os/signal" "strings" + "syscall" "github.com/gofixpoint/amika/internal/buildmeta" + "github.com/gofixpoint/amika/internal/config" "github.com/gofixpoint/amika/internal/httpapi" + "github.com/gofixpoint/amika/internal/sandbox" + "github.com/gofixpoint/amika/internal/watcher" "github.com/gofixpoint/amika/pkg/amika" ) @@ -44,7 +50,32 @@ func run( return fmt.Errorf("invalid listen address configuration: %w", err) } - handler := httpapi.NewHandler(amika.NewService(amika.Options{})) + // Start lifecycle watcher with SSE event broker. + broker := httpapi.NewEventBroker() + var watcherStore sandbox.Store + if sandboxesFile, err := config.SandboxesStateFile(); err == nil { + watcherStore = sandbox.NewStore(sandboxesFile) + } + + ctx, cancel := context.WithCancel(context.Background()) + defer cancel() + + if watcherStore != nil { + w := watcher.New(watcher.Options{ + Store: watcherStore, + Handlers: []watcher.Handler{broker.Handler()}, + }) + go w.Run(ctx) + } + + go func() { + sigCh := make(chan os.Signal, 1) + signal.Notify(sigCh, syscall.SIGINT, syscall.SIGTERM) + <-sigCh + cancel() + }() + + handler := httpapi.NewHandlerWithEvents(amika.NewService(amika.Options{}), broker) log.Printf("amika-server listening on %s", addr) if err := listenAndServe(addr, handler); err != nil { return fmt.Errorf("server failed: %w", err) diff --git a/cmd/amika/sandbox.go b/cmd/amika/sandbox.go index 942efa13..bdc384a8 100644 --- a/cmd/amika/sandbox.go +++ b/cmd/amika/sandbox.go @@ -1,7 +1,2457 @@ package main -import sandboxcmd "github.com/gofixpoint/amika/cmd/amika/sandbox" +import ( + "bufio" + "fmt" + "io" + "os" + "os/exec" + "path" + "path/filepath" + "sort" + "strconv" + "strings" + "syscall" + "text/tabwriter" + "time" + + "github.com/gofixpoint/amika/internal/agentconfig" + "github.com/gofixpoint/amika/internal/amikaconfig" + "github.com/gofixpoint/amika/internal/apiclient" + "github.com/gofixpoint/amika/internal/auth" + "github.com/gofixpoint/amika/internal/config" + "github.com/gofixpoint/amika/internal/constants" + "github.com/gofixpoint/amika/internal/runmode" + "github.com/gofixpoint/amika/internal/sandbox" + "github.com/gofixpoint/amika/internal/txn" + "github.com/gofixpoint/amika/pkg/amika" + "github.com/spf13/cobra" +) + +var sandboxCmd = &cobra.Command{ + Use: "sandbox", + Short: "Manage sandboxes", + Long: `Create and delete sandboxed environments backed by container providers.`, +} + +const sandboxConnectWorkdir = "/home/amika" + +// TODO: Parse env variables from an environment file (e.g. .amika/.env or ~/.config/amika/env) +// so users don't need to export AMIKA_API_URL, AMIKA_WORKOS_CLIENT_ID, etc. in their shell profile. + +// defaultAuthChecker returns nil when a valid WorkOS session exists. +func defaultAuthChecker() error { + _, err := auth.GetValidSession(config.WorkOSClientID()) + return err +} + +// getRemoteTarget validates that --remote-target is not combined with --local or --remote, and returns the target string. +// The flag is currently hidden and disabled; it will be enabled once named-remote config is implemented. +func getRemoteTarget(cmd *cobra.Command) (string, error) { + target, _ := cmd.Flags().GetString("remote-target") + if target != "" { + return "", fmt.Errorf("--remote-target is not yet supported") + } + return target, nil +} + +// getRemoteClient returns an API client authenticated with the current session. +// If AMIKA_API_KEY is set, it is used as a static bearer token instead of the WorkOS session. +func getRemoteClient(target string) (*apiclient.Client, error) { + // TODO: when named-remote config is added, look up target here. + _ = target + if apiKey := os.Getenv("AMIKA_API_KEY"); apiKey != "" { + return apiclient.NewClient(config.APIURL(), apiKey), nil + } + return apiclient.NewClientWithTokenSource(config.APIURL(), apiclient.NewWorkOSTokenSource(config.WorkOSClientID())), nil +} + +var runSandboxConnect = func(name, shell string, stdin io.Reader, stdout, stderr io.Writer) error { + dockerArgs := buildSandboxConnectArgs(name, shell) + dockerCmd := exec.Command("docker", dockerArgs...) + dockerCmd.Stdin = stdin + dockerCmd.Stdout = stdout + dockerCmd.Stderr = stderr + return dockerCmd.Run() +} + +var sandboxCreateCmd = &cobra.Command{ + Use: "create", + Short: "Create a new sandbox", + Long: `Create a new sandbox using the specified provider. Currently only "docker" is supported.`, + Args: cobra.NoArgs, + RunE: func(cmd *cobra.Command, _ []string) error { + cmd.SilenceUsage = true + + // Validate flag constraints before any network or auth calls. + noClean, _ := cmd.Flags().GetBool("no-clean") + noSetup, _ := cmd.Flags().GetBool("no-setup") + gitFlagChanged := cmd.Flags().Changed("git") + if err := validateGitFlags(gitFlagChanged, noClean); err != nil { + return err + } + if noSetup && cmd.Flags().Changed("setup-script") { + return fmt.Errorf("--no-setup and --setup-script are mutually exclusive") + } + + target, err := getRemoteTarget(cmd) + if err != nil { + return err + } + + mode := runmode.Resolve(cmd) + if err := runmode.RequireAuth(mode, defaultAuthChecker); err != nil { + return err + } + if mode == runmode.Remote { + return createRemoteSandbox(cmd, target) + } + + if secretFlags, _ := cmd.Flags().GetStringArray("secret"); len(secretFlags) > 0 { + return fmt.Errorf("--secret requires --remote mode; secrets are resolved by the remote API") + } + + provider, _ := cmd.Flags().GetString("provider") + name, _ := cmd.Flags().GetString("name") + image, _ := cmd.Flags().GetString("image") + preset, _ := cmd.Flags().GetString("preset") + mountStrs, _ := cmd.Flags().GetStringArray("mount") + volumeStrs, _ := cmd.Flags().GetStringArray("volume") + gitPath, _ := cmd.Flags().GetString("git") + envStrs, _ := cmd.Flags().GetStringArray("env") + portStrs, _ := cmd.Flags().GetStringArray("port") + portHostIP, _ := cmd.Flags().GetString("port-host-ip") + yes, _ := cmd.Flags().GetBool("yes") + connect, _ := cmd.Flags().GetBool("connect") + setupScript, _ := cmd.Flags().GetString("setup-script") + + if provider != "docker" { + return fmt.Errorf("unsupported provider %q: only \"docker\" is supported", provider) + } + + resolvedImage, err := sandbox.ResolveAndEnsureImage(sandbox.PresetImageOptions{ + Image: image, + Preset: preset, + ImageFlagChanged: cmd.Flags().Changed("image"), + DefaultBuildPreset: "coder", + }) + if err != nil { + return err + } + image = resolvedImage.Image + + branchFlag, _ := cmd.Flags().GetString("branch") + noClaudeConfig, _ := cmd.Flags().GetBool("no-claude-config") + collected, err := collectMounts(mountStrs, volumeStrs, portStrs, portHostIP, + gitPath, gitFlagChanged, noClean, + setupScript, cmd.Flags().Changed("setup-script"), + noSetup, + noClaudeConfig, + branchFlag) + if err != nil { + return err + } + defer collected.Cleanup() + mounts := collected.Mounts + volumeMounts := collected.VolumeMounts + publishedPorts := collected.Ports + gitMountInfo := collected.GitInfo + if gitMountInfo != nil && !hasEnvKey(envStrs, "AMIKA_AGENT_CWD") { + envStrs = append(envStrs, "AMIKA_AGENT_CWD="+gitMountInfo.Mount.Target) + } + envStrs = appendPresetRuntimeEnv(envStrs) + if !hasEnvKey(envStrs, constants.EnvSandboxProvider) { + envStrs = append(envStrs, constants.EnvSandboxProvider+"="+constants.ProviderLocalDocker) + } + + // Resolve provisioned (Amika-managed) services such as the OpenCode web + // UI. These run on reserved ports inside the container and need explicit + // Docker port bindings. Must run after appendPresetRuntimeEnv so + // OPENCODE_SERVER_PASSWORD is present in envStrs. + provSvcInfos, provPorts, err := amika.ResolveProvisionedServices(envStrs, publishedPorts, portHostIP) + if err != nil { + return err + } + collected.Services = append(collected.Services, provSvcInfos...) + publishedPorts = append(publishedPorts, provPorts...) + + if err := validateMountTargets(mounts, volumeMounts); err != nil { + return err + } + + sandboxesFile, err := config.SandboxesStateFile() + if err != nil { + return err + } + store := sandbox.NewStore(sandboxesFile) + volumesFile, err := config.VolumesStateFile() + if err != nil { + return err + } + volumeStore := sandbox.NewVolumeStore(volumesFile) + fileMountsFile, err := config.FileMountsStateFile() + if err != nil { + return err + } + fileMountStore := sandbox.NewFileMountStore(fileMountsFile) + fileMountsBaseDir, err := config.FileMountsDir() + if err != nil { + return err + } + + // Generate a name if not provided + if name == "" { + generated, err := sandbox.GenerateUniqueName(store) + if err != nil { + return err + } + name = generated + } else if _, err := store.Get(name); err == nil { + return fmt.Errorf("sandbox %q already exists", name) + } + + if (len(mounts) > 0 || len(volumeMounts) > 0) && !yes { + if gitMountInfo != nil { + mode := "clean" + if gitMountInfo.NoClean { + mode = "no-clean" + } + fmt.Println("Git repo to mount:") + fmt.Printf(" repo: %s\n", gitMountInfo.RepoName) + fmt.Printf(" root: %s\n", gitMountInfo.RepoRoot) + fmt.Printf(" mode: %s\n", mode) + fmt.Printf(" target: %s\n", gitMountInfo.Mount.Target) + } + fmt.Println("You are about to mount:") + for _, m := range mounts { + source := m.Source + if m.Mode == "rwcopy" && m.SnapshotFrom != "" { + source = m.SnapshotFrom + } + fmt.Printf(" %s -> %s:%s (%s)\n", source, name, m.Target, m.Mode) + } + for _, v := range volumeMounts { + fmt.Printf(" volume %s -> %s:%s (%s)\n", v.Volume, name, v.Target, v.Mode) + } + reader := bufio.NewReader(os.Stdin) + confirmed, err := promptForConfirmation(reader) + if err != nil { + return err + } + if !confirmed { + fmt.Println("Aborted.") + return nil + } + } + + runtimeMounts, rb, err := materializeRWCopyMounts(mounts, name, volumeStore, fileMountStore, fileMountsBaseDir) + if err != nil { + return err + } + + attachedVolumeRefs := make([]string, 0) + rollbackVolumes := func() { + for _, volumeName := range attachedVolumeRefs { + _ = volumeStore.RemoveSandboxRef(volumeName, name) + } + rb.Rollback() + } + + for _, v := range volumeMounts { + if _, err := volumeStore.Get(v.Volume); err != nil { + rollbackVolumes() + return fmt.Errorf("volume %q is not tracked; create via rwcopy first", v.Volume) + } + if err := volumeStore.AddSandboxRef(v.Volume, name); err != nil { + rollbackVolumes() + return fmt.Errorf("failed to attach volume %q: %w", v.Volume, err) + } + attachedVolumeRefs = append(attachedVolumeRefs, v.Volume) + runtimeMounts = append(runtimeMounts, v) + } + + containerID, err := sandbox.CreateDockerSandbox(name, image, runtimeMounts, envStrs, publishedPorts) + if err != nil { + rollbackVolumes() + return err + } + + branch, _ := cmd.Flags().GetString("branch") + now := time.Now().UTC() + ttlStr, _ := cmd.Flags().GetString("ttl") + warnBeforeStr, _ := cmd.Flags().GetString("warn-before") + ttlResult, err := sandbox.ComputeTTL(ttlStr, warnBeforeStr, now) + if err != nil { + return err + } + expiresAt, warnAt := ttlResult.ExpiresAt, ttlResult.WarnAt + info := sandbox.Info{ + Name: name, + Provider: provider, + ContainerID: containerID, + Image: image, + CreatedAt: now.Format(time.RFC3339), + ExpiresAt: expiresAt, + WarnAt: warnAt, + Preset: preset, + Mounts: runtimeMounts, + Env: envStrs, + Ports: publishedPorts, + Services: collected.Services, + Branch: branch, + } + if err := store.Save(info); err != nil { + return fmt.Errorf("sandbox created but failed to save state: %w", err) + } + rb.Disarm() + + fmt.Printf("Sandbox %q created (container %s)\n", name, containerID[:12]) + if len(publishedPorts) > 0 { + fmt.Println("Published ports:") + for _, p := range publishedPorts { + fmt.Printf(" %s\n", formatPortBinding(p)) + } + } + if len(collected.Services) > 0 { + fmt.Println("Services:") + for _, svc := range collected.Services { + for _, sp := range svc.Ports { + url := "-" + if sp.URL != "" { + url = sp.URL + } + fmt.Printf(" %s: %s (url: %s)\n", svc.Name, formatPortBinding(sp.PortBinding), url) + } + } + } + if connect { + if err := runSandboxConnect(name, "zsh", os.Stdin, os.Stdout, os.Stderr); err != nil { + return fmt.Errorf("sandbox %q created but failed to connect with shell %q: %w", name, "zsh", err) + } + } + return nil + }, +} + +var sandboxStartCmd = &cobra.Command{ + Use: "start [...]", + Short: "Start one or more stopped sandboxes", + Long: `Start (resume) one or more stopped sandboxes.`, + Args: cobra.MinimumNArgs(1), + RunE: func(cmd *cobra.Command, args []string) error { + cmd.SilenceUsage = true + + target, err := getRemoteTarget(cmd) + if err != nil { + return err + } + + mode := runmode.Resolve(cmd) + if err := runmode.RequireAuth(mode, defaultAuthChecker); err != nil { + return err + } + + var errs []string + if mode == runmode.Remote { + remoteClient, err := getRemoteClient(target) + if err != nil { + return err + } + for _, name := range args { + if remoteErr := remoteClient.StartSandbox(name); remoteErr != nil { + errs = append(errs, fmt.Sprintf("sandbox %q: %v", name, remoteErr)) + continue + } + fmt.Printf("Sandbox %q starting...\n", name) + if _, remoteErr := remoteClient.WaitForSandboxStart(name); remoteErr != nil { + errs = append(errs, fmt.Sprintf("sandbox %q: %v", name, remoteErr)) + } else { + fmt.Printf("Sandbox %q started (remote)\n", name) + } + } + } else { + sandboxesFile, err := config.SandboxesStateFile() + if err != nil { + return err + } + store := sandbox.NewStore(sandboxesFile) + for _, name := range args { + info, localErr := store.Get(name) + if localErr != nil { + errs = append(errs, fmt.Sprintf("sandbox %q not found", name)) + continue + } + if info.Provider == "docker" { + if err := sandbox.StartDockerSandbox(name); err != nil { + errs = append(errs, fmt.Sprintf("sandbox %q: %v", name, err)) + continue + } + } + fmt.Printf("Sandbox %q started\n", name) + } + } + if len(errs) > 0 { + return fmt.Errorf("%s", strings.Join(errs, "\n")) + } + return nil + }, +} + +var sandboxStopCmd = &cobra.Command{ + Use: "stop [...]", + Short: "Stop one or more sandboxes", + Long: `Stop one or more running sandboxes without removing them.`, + Args: cobra.MinimumNArgs(1), + RunE: func(cmd *cobra.Command, args []string) error { + cmd.SilenceUsage = true + + target, err := getRemoteTarget(cmd) + if err != nil { + return err + } + + mode := runmode.Resolve(cmd) + if err := runmode.RequireAuth(mode, defaultAuthChecker); err != nil { + return err + } + + var errs []string + if mode == runmode.Remote { + remoteClient, err := getRemoteClient(target) + if err != nil { + return err + } + for _, name := range args { + if remoteErr := remoteClient.StopSandbox(name); remoteErr != nil { + errs = append(errs, fmt.Sprintf("sandbox %q: %v", name, remoteErr)) + continue + } + fmt.Printf("Sandbox %q stopping...\n", name) + if _, remoteErr := remoteClient.WaitForSandboxStop(name); remoteErr != nil { + errs = append(errs, fmt.Sprintf("sandbox %q: %v", name, remoteErr)) + } else { + fmt.Printf("Sandbox %q stopped (remote)\n", name) + } + } + } else { + sandboxesFile, err := config.SandboxesStateFile() + if err != nil { + return err + } + store := sandbox.NewStore(sandboxesFile) + for _, name := range args { + info, localErr := store.Get(name) + if localErr != nil { + errs = append(errs, fmt.Sprintf("sandbox %q not found", name)) + continue + } + if info.Provider == "docker" { + if err := sandbox.StopDockerSandbox(name); err != nil { + errs = append(errs, fmt.Sprintf("sandbox %q: %v", name, err)) + continue + } + } + fmt.Printf("Sandbox %q stopped\n", name) + } + } + if len(errs) > 0 { + return fmt.Errorf("%s", strings.Join(errs, "\n")) + } + return nil + }, +} + +var sandboxDeleteCmd = &cobra.Command{ + Use: "delete [...]", + Aliases: []string{"rm", "remove"}, + Short: "Delete one or more sandboxes", + Long: `Delete one or more sandboxes and remove their backing containers.`, + Args: cobra.MinimumNArgs(1), + RunE: func(cmd *cobra.Command, args []string) error { + force, _ := cmd.Flags().GetBool("force") + + if !force { + reader := bufio.NewReader(cmd.InOrStdin()) + confirmed, err := confirmAction( + fmt.Sprintf("Delete sandbox(es) %s?", strings.Join(args, ", ")), + reader, + ) + if err != nil { + return err + } + if !confirmed { + fmt.Println("Aborted.") + return nil + } + } + + target, err := getRemoteTarget(cmd) + if err != nil { + return err + } + + mode := runmode.Resolve(cmd) + if err := runmode.RequireAuth(mode, defaultAuthChecker); err != nil { + return err + } + + deleteVolumes, _ := cmd.Flags().GetBool("delete-volumes") + keepVolumes, _ := cmd.Flags().GetBool("keep-volumes") + deleteVolumesSet := cmd.Flags().Changed("delete-volumes") + keepVolumesSet := cmd.Flags().Changed("keep-volumes") + if err := validateDeleteVolumeFlags(deleteVolumesSet, deleteVolumes, keepVolumesSet, keepVolumes); err != nil { + return err + } + + var errs []string + if mode == runmode.Remote { + remoteClient, err := getRemoteClient(target) + if err != nil { + return err + } + for _, name := range args { + if remoteErr := remoteClient.DeleteSandbox(name); remoteErr != nil { + errs = append(errs, fmt.Sprintf("sandbox %q: %v", name, remoteErr)) + } else { + fmt.Printf("Sandbox %q deleted (remote)\n", name) + } + } + } else { + sandboxesFile, err := config.SandboxesStateFile() + if err != nil { + return err + } + store := sandbox.NewStore(sandboxesFile) + volumesFile, err := config.VolumesStateFile() + if err != nil { + return err + } + volumeStore := sandbox.NewVolumeStore(volumesFile) + fileMountsFile, err := config.FileMountsStateFile() + if err != nil { + return err + } + fileMountStore := sandbox.NewFileMountStore(fileMountsFile) + + for _, name := range args { + info, localErr := store.Get(name) + if localErr != nil { + errs = append(errs, fmt.Sprintf("sandbox %q not found", name)) + continue + } + + deleteVols, err := resolveDeleteVolumes( + volumeStore, + fileMountStore, + name, + deleteVolumesSet, + keepVolumesSet, + bufio.NewReader(cmd.InOrStdin()), + ) + if err != nil { + errs = append(errs, fmt.Sprintf("sandbox %q: %v", name, err)) + continue + } + + if info.Provider == "docker" { + if err := sandbox.RemoveDockerSandbox(name); err != nil { + errs = append(errs, fmt.Sprintf("sandbox %q: %v", name, err)) + continue + } + } + + volumeStatuses, volumeErr := cleanupSandboxVolumes(volumeStore, name, deleteVols, sandbox.RemoveDockerVolume) + fileMountStatuses, fileMountErr := cleanupSandboxFileMounts(fileMountStore, name, deleteVols) + + if err := store.Remove(name); err != nil { + errs = append(errs, fmt.Sprintf("sandbox %q: container removed but failed to update state: %v", name, err)) + continue + } + + fmt.Printf("Sandbox %q deleted\n", name) + for _, line := range volumeStatuses { + fmt.Println(line) + } + for _, line := range fileMountStatuses { + fmt.Println(line) + } + if volumeErr != nil { + errs = append(errs, fmt.Sprintf("sandbox %q: %v", name, volumeErr)) + } + if fileMountErr != nil { + errs = append(errs, fmt.Sprintf("sandbox %q: %v", name, fileMountErr)) + } + } + } + if len(errs) > 0 { + return fmt.Errorf("%s", strings.Join(errs, "\n")) + } + return nil + }, +} + +var sandboxListCmd = &cobra.Command{ + Use: "list", + Aliases: []string{"ls"}, + Short: "List all sandboxes", + Args: cobra.NoArgs, + RunE: func(cmd *cobra.Command, _ []string) error { + target, err := getRemoteTarget(cmd) + if err != nil { + return err + } + + mode := runmode.Resolve(cmd) + if err := runmode.RequireAuth(mode, defaultAuthChecker); err != nil { + return err + } + + var allItems []amika.Sandbox + + if mode == runmode.Local { + result, err := amika.NewService(amika.Options{}).ListSandboxes(cmd.Context(), amika.ListSandboxesRequest{}) + if err != nil { + return err + } + for i := range result.Items { + result.Items[i].Location = "local" + if result.Items[i].Provider == "docker" { + state, err := sandbox.GetDockerContainerState(result.Items[i].Name) + if err != nil { + result.Items[i].State = "unknown" + } else { + result.Items[i].State = state + } + } + } + allItems = append(allItems, result.Items...) + } else { + client, err := getRemoteClient(target) + if err != nil { + return err + } + remoteSandboxes, err := client.ListSandboxes() + if err != nil { + return err + } + for _, rs := range remoteSandboxes { + allItems = append(allItems, amika.Sandbox{ + Name: rs.Name, + State: rs.State, + Provider: rs.Provider, + CreatedAt: rs.CreatedAt, + Location: "remote", + Branch: rs.Branch, + }) + } + } + + if len(allItems) == 0 { + fmt.Println("No sandboxes found.") + return nil + } + + w := tabwriter.NewWriter(cmd.OutOrStdout(), 0, 4, 2, ' ', 0) + fmt.Fprintln(w, "NAME\tSTATE\tLOCATION\tPROVIDER\tIMAGE\tBRANCH\tPORTS\tCREATED") + for _, sb := range allItems { + fmt.Fprintf(w, "%s\t%s\t%s\t%s\t%s\t%s\t%s\t%s\n", sb.Name, sb.State, sb.Location, sb.Provider, sb.Image, sb.Branch, formatPortBindings(sb.Ports), sb.CreatedAt) + } + w.Flush() + return nil + }, +} + +var sandboxConnectCmd = &cobra.Command{ + Use: "connect ", + Short: "Connect to a sandbox console", + Long: `Connect to a running sandbox container and open an interactive shell.`, + Args: cobra.ExactArgs(1), + RunE: func(cmd *cobra.Command, args []string) error { + cmd.SilenceUsage = true + + name := args[0] + shell, _ := cmd.Flags().GetString("shell") + if err := validateShell(shell); err != nil { + return err + } + + target, err := getRemoteTarget(cmd) + if err != nil { + return err + } + + mode := runmode.Resolve(cmd) + if err := runmode.RequireAuth(mode, defaultAuthChecker); err != nil { + return err + } + + if mode == runmode.Local { + sandboxesFile, err := config.SandboxesStateFile() + if err != nil { + return err + } + store := sandbox.NewStore(sandboxesFile) + info, err := store.Get(name) + if err != nil { + return fmt.Errorf("sandbox %q not found", name) + } + if info.Provider != "docker" { + return fmt.Errorf("unsupported local provider %q: only \"docker\" is supported", info.Provider) + } + if err := runSandboxConnect(name, shell, os.Stdin, os.Stdout, os.Stderr); err != nil { + return fmt.Errorf("failed to connect to sandbox %q with shell %q: %w", name, shell, err) + } + return nil + } + + client, err := getRemoteClient(target) + if err != nil { + return err + } + return execSSH(client, name, false, nil) + }, +} + +// parsePortFlags parses --port flag values in the format hostPort:containerPort[/protocol]. +func parsePortFlags(flags []string, hostIP string) ([]sandbox.PortBinding, error) { + hostIP = strings.TrimSpace(hostIP) + if hostIP == "" { + return nil, fmt.Errorf("--port-host-ip must not be empty") + } + + ports := make([]sandbox.PortBinding, 0, len(flags)) + seen := make(map[string]bool, len(flags)) + for _, raw := range flags { + value := strings.TrimSpace(raw) + if value == "" { + return nil, fmt.Errorf("invalid port format %q: expected hostPort:containerPort[/protocol]", raw) + } + + mainPart := value + protocol := "tcp" + if strings.Contains(value, "/") { + parts := strings.SplitN(value, "/", 2) + mainPart = parts[0] + protocol = strings.ToLower(strings.TrimSpace(parts[1])) + } + if protocol != "tcp" && protocol != "udp" { + return nil, fmt.Errorf("invalid port protocol %q: must be \"tcp\" or \"udp\"", protocol) + } + + parts := strings.SplitN(mainPart, ":", 2) + if len(parts) != 2 { + return nil, fmt.Errorf("invalid port format %q: expected hostPort:containerPort[/protocol]", raw) + } + hostPort, err := strconv.Atoi(strings.TrimSpace(parts[0])) + if err != nil { + return nil, fmt.Errorf("invalid host port in %q: %w", raw, err) + } + containerPort, err := strconv.Atoi(strings.TrimSpace(parts[1])) + if err != nil { + return nil, fmt.Errorf("invalid container port in %q: %w", raw, err) + } + if hostPort < 1 || hostPort > 65535 { + return nil, fmt.Errorf("host port %d must be between 1 and 65535", hostPort) + } + if containerPort < 1 || containerPort > 65535 { + return nil, fmt.Errorf("container port %d must be between 1 and 65535", containerPort) + } + + key := fmt.Sprintf("%s:%d/%s", hostIP, hostPort, protocol) + if seen[key] { + return nil, fmt.Errorf("duplicate published port binding %s", key) + } + seen[key] = true + ports = append(ports, sandbox.PortBinding{ + HostIP: hostIP, + HostPort: hostPort, + ContainerPort: containerPort, + Protocol: protocol, + }) + } + return ports, nil +} + +func formatPortBindings(bindings []amika.PortBinding) string { + if len(bindings) == 0 { + return "-" + } + out := make([]string, 0, len(bindings)) + for _, p := range bindings { + hostIP := p.HostIP + if strings.TrimSpace(hostIP) == "" { + hostIP = "127.0.0.1" + } + protocol := p.Protocol + if strings.TrimSpace(protocol) == "" { + protocol = "tcp" + } + out = append(out, fmt.Sprintf("%s:%d->%d/%s", hostIP, p.HostPort, p.ContainerPort, protocol)) + } + return strings.Join(out, ",") +} + +func formatPortBinding(binding sandbox.PortBinding) string { + hostIP := binding.HostIP + if strings.TrimSpace(hostIP) == "" { + hostIP = "127.0.0.1" + } + protocol := binding.Protocol + if strings.TrimSpace(protocol) == "" { + protocol = "tcp" + } + return fmt.Sprintf("%s:%d->%d/%s", hostIP, binding.HostPort, binding.ContainerPort, protocol) +} + +// parseMountFlags parses --mount flag values in the format source:target[:mode]. +// Mode defaults to "rwcopy" if omitted. +func parseMountFlags(flags []string) ([]sandbox.MountBinding, error) { + var mounts []sandbox.MountBinding + seen := make(map[string]bool) + + for _, raw := range flags { + parts := strings.SplitN(raw, ":", 3) + if len(parts) < 2 { + return nil, fmt.Errorf("invalid mount format %q: expected source:target[:mode]", raw) + } + + source := parts[0] + target := parts[1] + mode := "rwcopy" + if len(parts) == 3 { + mode = parts[2] + } + + absSource, err := filepath.Abs(source) + if err != nil { + return nil, fmt.Errorf("failed to resolve source path %q: %w", source, err) + } + + if !strings.HasPrefix(target, "/") { + return nil, fmt.Errorf("mount target %q must be an absolute path", target) + } + + if mode != "ro" && mode != "rw" && mode != "rwcopy" { + return nil, fmt.Errorf("invalid mount mode %q: must be \"ro\", \"rw\", or \"rwcopy\"", mode) + } + + if seen[target] { + return nil, fmt.Errorf("duplicate mount target %q", target) + } + seen[target] = true + + mounts = append(mounts, sandbox.MountBinding{ + Type: "bind", + Source: absSource, + Target: target, + Mode: mode, + }) + } + return mounts, nil +} + +// parseVolumeFlags parses --volume flag values in the format name:target[:mode]. +// Mode defaults to "rw" if omitted. +func parseVolumeFlags(flags []string) ([]sandbox.MountBinding, error) { + var mounts []sandbox.MountBinding + seen := make(map[string]bool) + + for _, raw := range flags { + parts := strings.SplitN(raw, ":", 3) + if len(parts) < 2 { + return nil, fmt.Errorf("invalid volume format %q: expected name:target[:mode]", raw) + } + + name := strings.TrimSpace(parts[0]) + target := parts[1] + mode := "rw" + if len(parts) == 3 { + mode = parts[2] + } + + if name == "" { + return nil, fmt.Errorf("volume name must not be empty in %q", raw) + } + if !strings.HasPrefix(target, "/") { + return nil, fmt.Errorf("mount target %q must be an absolute path", target) + } + if mode != "ro" && mode != "rw" { + return nil, fmt.Errorf("invalid volume mount mode %q: must be \"ro\" or \"rw\"", mode) + } + if seen[target] { + return nil, fmt.Errorf("duplicate mount target %q", target) + } + seen[target] = true + + mounts = append(mounts, sandbox.MountBinding{ + Type: "volume", + Volume: name, + Target: target, + Mode: mode, + }) + } + return mounts, nil +} + +// parseSecretFlags parses --secret flag values into a map of env var name → secret name. +// Supported syntax: +// - env:FOO=SECRET_NAME — inject secret SECRET_NAME as env var FOO +// - env:SECRET_NAME — shorthand: env var name equals the secret name +func parseSecretFlags(flags []string) (map[string]string, error) { + if len(flags) == 0 { + return nil, nil + } + result := make(map[string]string, len(flags)) + + for _, raw := range flags { + idx := strings.Index(raw, ":") + if idx < 0 { + return nil, fmt.Errorf("invalid --secret format %q: expected type prefix (e.g. env:SECRET_NAME or env:FOO=SECRET_NAME)", raw) + } + + prefix := raw[:idx] + value := raw[idx+1:] + + switch prefix { + case "file": + return nil, fmt.Errorf("file: secret type is not yet supported") + case "env": + // ok + default: + return nil, fmt.Errorf("unknown secret type %q in %q: supported types are \"env\"", prefix, raw) + } + + var envVar, secretName string + if eqIdx := strings.Index(value, "="); eqIdx >= 0 { + envVar = value[:eqIdx] + secretName = value[eqIdx+1:] + } else { + envVar = value + secretName = value + } + + if envVar == "" { + return nil, fmt.Errorf("empty env var name in --secret %q", raw) + } + if secretName == "" { + return nil, fmt.Errorf("empty secret name in --secret %q", raw) + } + if _, dup := result[envVar]; dup { + return nil, fmt.Errorf("duplicate env var %q in --secret flags", envVar) + } + + result[envVar] = secretName + } + return result, nil +} + +// parseEnvVarFlags parses --env flag values (KEY=VALUE) into a map. +func parseEnvVarFlags(flags []string) (map[string]string, error) { + if len(flags) == 0 { + return nil, nil + } + envVars := make(map[string]string, len(flags)) + for _, raw := range flags { + eqIdx := strings.Index(raw, "=") + if eqIdx < 0 { + return nil, fmt.Errorf("invalid --env format %q: expected KEY=VALUE", raw) + } + key := raw[:eqIdx] + val := raw[eqIdx+1:] + if key == "" { + return nil, fmt.Errorf("empty key in --env %q", raw) + } + envVars[key] = val + } + return envVars, nil +} + +func validateMountTargets(bindMounts, volumeMounts []sandbox.MountBinding) error { + seen := make(map[string]bool, len(bindMounts)+len(volumeMounts)) + for _, m := range bindMounts { + seen[m.Target] = true + } + for _, m := range volumeMounts { + if seen[m.Target] { + return fmt.Errorf("duplicate mount target %q", m.Target) + } + seen[m.Target] = true + } + return nil +} + +func validateGitFlags(gitEnabled, noClean bool) error { + if noClean && !gitEnabled { + return fmt.Errorf("--no-clean requires --git") + } + return nil +} + +func validateShell(shell string) error { + if strings.TrimSpace(shell) == "" { + return fmt.Errorf("--shell must not be empty") + } + return nil +} + +func buildSandboxConnectArgs(name, shell string) []string { + return []string{"exec", "-it", "-w", sandboxConnectWorkdir, name, shell} +} + +type gitMountInfo struct { + RepoName string + RepoRoot string + NoClean bool + Mount sandbox.MountBinding +} + +func prepareGitMount(startPath string, noClean bool, cloneFn func(src, dst, branch string) error, branch string) (gitMountInfo, func(), error) { + repoRoot, err := resolveGitRoot(startPath) + if err != nil { + return gitMountInfo{}, func() {}, err + } + + repoName := filepath.Base(repoRoot) + target := path.Join(sandbox.SandboxWorkdir, repoName) + tmpDir, err := os.MkdirTemp("", "amika-git-mount-*") + if err != nil { + return gitMountInfo{}, func() {}, fmt.Errorf("failed to create temp directory for git mount: %w", err) + } + preparedRepo := filepath.Join(tmpDir, repoName) + if noClean { + if err := copyRepoWorkingTree(repoRoot, preparedRepo); err != nil { + _ = os.RemoveAll(tmpDir) + return gitMountInfo{}, func() {}, err + } + } else { + if err := cloneFn(repoRoot, preparedRepo, branch); err != nil { + _ = os.RemoveAll(tmpDir) + return gitMountInfo{}, func() {}, err + } + } + if err := syncGitRemotes(repoRoot, preparedRepo); err != nil { + _ = os.RemoveAll(tmpDir) + return gitMountInfo{}, func() {}, err + } + cleanup := func() { _ = os.RemoveAll(tmpDir) } + + return gitMountInfo{ + RepoName: repoName, + RepoRoot: repoRoot, + NoClean: noClean, + Mount: sandbox.MountBinding{ + Type: "bind", + Source: preparedRepo, + Target: target, + Mode: "rwcopy", + SnapshotFrom: repoRoot, + }, + }, cleanup, nil +} + +func resolveGitRoot(startPath string) (string, error) { + if startPath == "" { + startPath = "." + } + absPath, err := filepath.Abs(startPath) + if err != nil { + return "", fmt.Errorf("failed to resolve git start path %q: %w", startPath, err) + } + + current := absPath + if stat, err := os.Stat(absPath); err == nil && !stat.IsDir() { + current = filepath.Dir(absPath) + } + + for { + gitMarker := filepath.Join(current, ".git") + if _, err := os.Stat(gitMarker); err == nil { + return current, nil + } + + parent := filepath.Dir(current) + if parent == current { + break + } + current = parent + } + + return "", fmt.Errorf("no git repository root found from %q", absPath) +} + +func cloneGitRepo(src, dst, branch string) error { + args := []string{"clone", "--local", "--no-hardlinks"} + if branch != "" { + args = append(args, "--branch", branch) + } + args = append(args, src, dst) + cmd := exec.Command("git", args...) + out, err := cmd.CombinedOutput() + if err != nil { + return fmt.Errorf("failed to prepare clean git mount from %q: %s", src, strings.TrimSpace(string(out))) + } + return nil +} + +func copyRepoWorkingTree(src, dst string) error { + if err := os.MkdirAll(filepath.Dir(dst), 0755); err != nil { + return fmt.Errorf("failed to create no-clean parent for %q: %w", dst, err) + } + cmd := exec.Command("cp", "-a", src, dst) + out, err := cmd.CombinedOutput() + if err != nil { + return fmt.Errorf("failed to prepare no-clean git mount from %q: %s", src, strings.TrimSpace(string(out))) + } + if _, err := os.Stat(filepath.Join(dst, ".git")); err != nil { + return fmt.Errorf("failed to prepare no-clean git mount from %q: missing .git in %q", src, dst) + } + return nil +} + +func syncGitRemotes(srcRepo, dstRepo string) error { + srcRemotes, err := listGitRemotes(srcRepo) + if err != nil { + return fmt.Errorf("failed to read remotes from source repo %q: %w", srcRepo, err) + } + filtered := make(map[string]string) + for name, url := range srcRemotes { + if isNetworkRemoteURL(url) { + filtered[name] = url + } + } + + dstRemotes, err := listGitRemotes(dstRepo) + if err != nil { + return fmt.Errorf("failed to read remotes from prepared repo %q: %w", dstRepo, err) + } + for _, name := range sortedRemoteNames(dstRemotes) { + if err := runGit(dstRepo, "remote", "remove", name); err != nil { + return fmt.Errorf("failed to remove remote %q from prepared repo %q: %w", name, dstRepo, err) + } + } + for _, name := range sortedRemoteNames(filtered) { + if err := runGit(dstRepo, "remote", "add", name, filtered[name]); err != nil { + return fmt.Errorf("failed to add remote %q to prepared repo %q: %w", name, dstRepo, err) + } + } + return nil +} + +func listGitRemotes(repo string) (map[string]string, error) { + out, err := runGitOutput(repo, "remote") + if err != nil { + return nil, err + } + names := strings.Fields(strings.TrimSpace(out)) + remotes := make(map[string]string, len(names)) + for _, name := range names { + url, err := runGitOutput(repo, "remote", "get-url", name) + if err != nil { + return nil, err + } + remotes[name] = strings.TrimSpace(url) + } + return remotes, nil +} + +func isNetworkRemoteURL(url string) bool { + switch { + case strings.HasPrefix(url, "http://"), + strings.HasPrefix(url, "https://"), + strings.HasPrefix(url, "ssh://"): + return true + case strings.HasPrefix(url, "file://"): + return false + } + // Accept scp-like SSH syntax: user@host:path/to/repo.git + at := strings.Index(url, "@") + colon := strings.Index(url, ":") + return at > 0 && colon > at+1 +} + +func sortedRemoteNames(m map[string]string) []string { + keys := make([]string, 0, len(m)) + for k := range m { + keys = append(keys, k) + } + sort.Strings(keys) + return keys +} + +func runGit(repo string, args ...string) error { + _, err := runGitOutput(repo, args...) + return err +} + +func runGitOutput(repo string, args ...string) (string, error) { + cmdArgs := append([]string{"-C", repo}, args...) + cmd := exec.Command("git", cmdArgs...) + out, err := cmd.CombinedOutput() + if err != nil { + return "", fmt.Errorf("git %s failed: %s", strings.Join(args, " "), strings.TrimSpace(string(out))) + } + return string(out), nil +} + +func confirmAction(message string, reader *bufio.Reader) (bool, error) { + for { + fmt.Printf("%s [y/n] ", message) + answer, err := reader.ReadString('\n') + if err != nil { + return false, fmt.Errorf("failed to read confirmation: %w", err) + } + answer = strings.TrimSpace(strings.ToLower(answer)) + switch answer { + case "y", "yes": + return true, nil + case "n", "no": + return false, nil + case "": + fmt.Println("Please enter 'y' or 'n'.") + default: + fmt.Println("Invalid response. Please enter 'y' or 'n'.") + } + } +} + +func promptForConfirmation(reader *bufio.Reader) (bool, error) { + for { + fmt.Print("Continue? [y/n] ") + answer, err := reader.ReadString('\n') + if err != nil { + return false, fmt.Errorf("failed to read confirmation: %w", err) + } + answer = strings.TrimSpace(strings.ToLower(answer)) + switch answer { + case "y", "yes": + return true, nil + case "n", "no": + return false, nil + case "": + fmt.Println("Please enter 'y' or 'n'.") + default: + fmt.Println("Invalid response. Please enter 'y' or 'n'.") + } + } +} + +func generateRWCopyVolumeName(sandboxName, target string) string { + sanitizedTarget := strings.NewReplacer("/", "-", "_", "-", ".", "-").Replace(strings.TrimPrefix(target, "/")) + if sanitizedTarget == "" { + sanitizedTarget = "root" + } + return "amika-rwcopy-" + sandboxName + "-" + sanitizedTarget + "-" + strconv.FormatInt(time.Now().UnixNano(), 10) +} + +func generateRWCopyFileMountName(sandboxName, target string) string { + sanitizedTarget := strings.NewReplacer("/", "-", "_", "-", ".", "-").Replace(strings.TrimPrefix(target, "/")) + if sanitizedTarget == "" { + sanitizedTarget = "root" + } + return "amika-rwcopy-file-" + sandboxName + "-" + sanitizedTarget + "-" + strconv.FormatInt(time.Now().UnixNano(), 10) +} + +// collectedMounts holds all mounts and related data gathered from CLI flags, +// git, agent credentials, and setup-script before materialization. +type collectedMounts struct { + Mounts []sandbox.MountBinding + VolumeMounts []sandbox.MountBinding + Ports []sandbox.PortBinding + Services []sandbox.ServiceInfo // resolved service port bindings + GitInfo *gitMountInfo // nil if --git was not used + Cleanup func() // removes git temp dir; noop if no --git +} + +// collectMounts gathers all mounts from CLI flags, git clone, .amika/config.toml, +// agent credentials, and setup-script. Call Cleanup (e.g. via defer) to remove any +// temporary directories created for the git mount. +func collectMounts( + mountStrs, volumeStrs, portStrs []string, + portHostIP string, + gitPath string, + gitFlagChanged bool, + noClean bool, + setupScript string, + setupScriptFlagChanged bool, + noSetup bool, + noClaudeConfig bool, + branch string, +) (collectedMounts, error) { + mounts, err := parseMountFlags(mountStrs) + if err != nil { + return collectedMounts{}, err + } + volumeMounts, err := parseVolumeFlags(volumeStrs) + if err != nil { + return collectedMounts{}, err + } + publishedPorts, err := parsePortFlags(portStrs, portHostIP) + if err != nil { + return collectedMounts{}, err + } + + cleanup := func() {} + var gmi *gitMountInfo + if gitFlagChanged { + info, cleanupGitMount, err := prepareGitMount(gitPath, noClean, cloneGitRepo, branch) + if err != nil { + return collectedMounts{}, err + } + cleanup = cleanupGitMount + gmi = &info + mounts = append(mounts, info.Mount) + } + + // Load .amika/config.toml once from the repo root for both setup script and services. + var repoCfg *amikaconfig.Config + if gmi != nil { + repoCfg, err = amikaconfig.LoadConfig(gmi.RepoRoot) + if err != nil { + cleanup() + return collectedMounts{}, fmt.Errorf("failed to read .amika/config.toml: %w", err) + } + } + + // Pretend --setup-script was explicitly provided so that the config-based + // setup script from .amika/config.toml is not auto-detected (the check + // below skips auto-detection when setupScriptFlagChanged is true). + if noSetup { + setupScriptFlagChanged = true + } + + if repoCfg != nil && !setupScriptFlagChanged { + mount, err := setupScriptMountFromLoadedConfig(repoCfg, gmi.RepoRoot) + if err != nil { + cleanup() + return collectedMounts{}, err + } + if mount != nil { + mounts = append(mounts, *mount) + } + } + + // Resolve service ports from config. + var serviceInfos []sandbox.ServiceInfo + if repoCfg != nil { + svcInfos, additionalPorts, err := amika.ResolveServicesFromConfig(repoCfg, publishedPorts, portHostIP) + if err != nil { + cleanup() + return collectedMounts{}, err + } + serviceInfos = svcInfos + publishedPorts = append(publishedPorts, additionalPorts...) + } + + if homeDir, err := os.UserHomeDir(); err == nil { + var specs []agentconfig.MountSpec + if noClaudeConfig { + specs = agentconfig.AllMountsWithoutClaudeConfig(homeDir) + } else { + specs = agentconfig.AllMounts(homeDir) + } + agentMounts := agentconfig.RWCopyMounts(specs) + mounts = append(mounts, agentMounts...) + } + + if noSetup { + noopPath, noopCleanup, err := createNoOpSetupScript() + if err != nil { + cleanup() + return collectedMounts{}, err + } + mounts = append(mounts, setupScriptBindMount(noopPath)) + prevCleanup := cleanup + cleanup = func() { + noopCleanup() + prevCleanup() + } + } else if setupScript != "" { + absSetupScript, err := filepath.Abs(setupScript) + if err != nil { + cleanup() + return collectedMounts{}, fmt.Errorf("failed to resolve setup-script path %q: %w", setupScript, err) + } + if _, err := os.Stat(absSetupScript); err != nil { + cleanup() + return collectedMounts{}, fmt.Errorf("setup-script %q is not accessible: %w", absSetupScript, err) + } + mounts = append(mounts, setupScriptBindMount(absSetupScript)) + } + + return collectedMounts{ + Mounts: mounts, + VolumeMounts: volumeMounts, + Ports: publishedPorts, + Services: serviceInfos, + GitInfo: gmi, + Cleanup: cleanup, + }, nil +} + +// setupScriptMountFromLoadedConfig uses an already-loaded config to create a +// bind mount for lifecycle.setup_script if one is configured. +func setupScriptMountFromLoadedConfig(cfg *amikaconfig.Config, repoRoot string) (*sandbox.MountBinding, error) { + if cfg == nil || cfg.Lifecycle.SetupScript == "" { + return nil, nil + } + scriptPath := cfg.Lifecycle.SetupScript + if !filepath.IsAbs(scriptPath) { + scriptPath = filepath.Join(repoRoot, scriptPath) + } + if _, err := os.Stat(scriptPath); err != nil { + return nil, fmt.Errorf("setup_script %q from .amika/config.toml is not accessible: %w", cfg.Lifecycle.SetupScript, err) + } + m := setupScriptBindMount(scriptPath) + return &m, nil +} + +// setupScriptBindMount returns a read-only bind mount for absPath to /usr/local/etc/amikad/setup/setup.sh. +func setupScriptBindMount(absPath string) sandbox.MountBinding { + return sandbox.MountBinding{ + Type: "bind", + Source: absPath, + Target: "/usr/local/etc/amikad/setup/setup.sh", + Mode: "ro", + } +} + +// createNoOpSetupScript creates a temporary executable script that immediately +// exits with 0. Returns the path to the temp file and a cleanup function that +// removes it. +func createNoOpSetupScript() (string, func(), error) { + tmpFile, err := os.CreateTemp("", "amika-no-setup-*.sh") + if err != nil { + return "", nil, fmt.Errorf("failed to create no-op setup script: %w", err) + } + if _, err := tmpFile.WriteString("#!/bin/bash\nexit 0\n"); err != nil { + tmpFile.Close() + os.Remove(tmpFile.Name()) + return "", nil, fmt.Errorf("failed to write no-op setup script: %w", err) + } + tmpFile.Close() + if err := os.Chmod(tmpFile.Name(), 0o755); err != nil { + os.Remove(tmpFile.Name()) + return "", nil, fmt.Errorf("failed to chmod no-op setup script: %w", err) + } + return tmpFile.Name(), func() { os.Remove(tmpFile.Name()) }, nil +} + +// materializeRWCopyMounts converts logical mounts that use mode "rwcopy" into +// real Docker volumes (for directory sources) or bind-mounted file copies (for +// file sources). Mounts with other modes are passed through unchanged. +// +// The returned Rollbacker must have Rollback called on any error path to undo +// partial state; call Disarm after the sandbox is successfully created. +func materializeRWCopyMounts( + mounts []sandbox.MountBinding, + sandboxName string, + volumeStore sandbox.VolumeStore, + fileMountStore sandbox.FileMountStore, + fileMountsBaseDir string, +) ([]sandbox.MountBinding, txn.Rollbacker, error) { + var runtimeMounts []sandbox.MountBinding + createdVolumes := make([]string, 0) + addedRefs := make(map[string]bool) + createdFileMountDirs := make([]string, 0) + addedFileRefs := make(map[string]bool) + + rb := txn.NewRollbacker(func() { + for volumeName := range addedRefs { + _ = volumeStore.RemoveSandboxRef(volumeName, sandboxName) + } + for _, volumeName := range createdVolumes { + _ = volumeStore.Remove(volumeName) + _ = sandbox.RemoveDockerVolume(volumeName) + } + for mountName := range addedFileRefs { + _ = fileMountStore.Remove(mountName) + } + for _, dir := range createdFileMountDirs { + _ = os.RemoveAll(dir) + } + }) + + for _, m := range mounts { + if m.Mode != "rwcopy" { + runtimeMounts = append(runtimeMounts, m) + continue + } + + stat, err := os.Stat(m.Source) + if err != nil { + rb.Rollback() + return nil, rb, fmt.Errorf("rwcopy source %q is not accessible: %w", m.Source, err) + } + + if stat.IsDir() { + volumeName := generateRWCopyVolumeName(sandboxName, m.Target) + if err := sandbox.CreateDockerVolume(volumeName); err != nil { + rb.Rollback() + return nil, rb, err + } + createdVolumes = append(createdVolumes, volumeName) + + if err := sandbox.CopyHostDirToVolume(volumeName, m.Source); err != nil { + rb.Rollback() + return nil, rb, err + } + + volInfo := sandbox.VolumeInfo{ + Name: volumeName, + CreatedAt: time.Now().UTC().Format(time.RFC3339), + CreatedBy: "rwcopy", + SourcePath: m.Source, + SandboxRefs: []string{sandboxName}, + } + if err := volumeStore.Save(volInfo); err != nil { + rb.Rollback() + return nil, rb, fmt.Errorf("failed to save volume state for %q: %w", volumeName, err) + } + addedRefs[volumeName] = true + + runtimeMounts = append(runtimeMounts, sandbox.MountBinding{ + Type: "volume", + Volume: volumeName, + Target: m.Target, + Mode: "rw", + SnapshotFrom: m.Source, + }) + } else { + mountName := generateRWCopyFileMountName(sandboxName, m.Target) + copyDir := filepath.Join(fileMountsBaseDir, mountName) + if err := os.MkdirAll(copyDir, 0755); err != nil { + rb.Rollback() + return nil, rb, fmt.Errorf("failed to create file mount directory for %q: %w", mountName, err) + } + createdFileMountDirs = append(createdFileMountDirs, copyDir) + + copyPath := filepath.Join(copyDir, filepath.Base(m.Source)) + if err := copyFile(m.Source, copyPath); err != nil { + rb.Rollback() + return nil, rb, fmt.Errorf("failed to copy file for rwcopy mount %q: %w", m.Source, err) + } + + fmInfo := sandbox.FileMountInfo{ + Name: mountName, + Type: "file", + CreatedAt: time.Now().UTC().Format(time.RFC3339), + CreatedBy: "rwcopy", + SourcePath: m.Source, + CopyPath: copyPath, + SandboxRefs: []string{sandboxName}, + } + if err := fileMountStore.Save(fmInfo); err != nil { + rb.Rollback() + return nil, rb, fmt.Errorf("failed to save file mount state for %q: %w", mountName, err) + } + addedFileRefs[mountName] = true + + runtimeMounts = append(runtimeMounts, sandbox.MountBinding{ + Type: "bind", + Source: copyPath, + Target: m.Target, + Mode: "rw", + SnapshotFrom: m.Source, + }) + } + } + + return runtimeMounts, rb, nil +} + +func copyFile(src, dst string) error { + srcInfo, err := os.Stat(src) + if err != nil { + return fmt.Errorf("failed to stat source file %q: %w", src, err) + } + data, err := os.ReadFile(src) + if err != nil { + return fmt.Errorf("failed to read source file %q: %w", src, err) + } + if err := os.WriteFile(dst, data, srcInfo.Mode()); err != nil { + return fmt.Errorf("failed to write destination file %q: %w", dst, err) + } + return nil +} + +func cleanupSandboxVolumes( + volumeStore sandbox.VolumeStore, + sandboxName string, + deleteVolumes bool, + removeVolumeFn func(string) error, +) ([]string, error) { + volumes, err := volumeStore.VolumesForSandbox(sandboxName) + if err != nil { + return nil, fmt.Errorf("failed to load associated volumes: %w", err) + } + if len(volumes) == 0 { + return nil, nil + } + + statuses := make([]string, 0, len(volumes)) + var errs []string + + for _, volume := range volumes { + if err := volumeStore.RemoveSandboxRef(volume.Name, sandboxName); err != nil { + statuses = append(statuses, fmt.Sprintf("volume %s: delete-failed: failed to update refs", volume.Name)) + errs = append(errs, fmt.Sprintf("failed to remove sandbox ref for volume %q: %v", volume.Name, err)) + continue + } + + if !deleteVolumes { + statuses = append(statuses, fmt.Sprintf("volume %s: preserved", volume.Name)) + continue + } + + inUse, err := volumeStore.IsInUse(volume.Name) + if err != nil { + statuses = append(statuses, fmt.Sprintf("volume %s: delete-failed: failed to check usage", volume.Name)) + errs = append(errs, fmt.Sprintf("failed to check usage for volume %q: %v", volume.Name, err)) + continue + } + if inUse { + statuses = append(statuses, fmt.Sprintf("volume %s: preserved (still referenced)", volume.Name)) + continue + } + + if err := removeVolumeFn(volume.Name); err != nil { + statuses = append(statuses, fmt.Sprintf("volume %s: delete-failed: %v", volume.Name, err)) + errs = append(errs, fmt.Sprintf("failed to delete volume %q: %v", volume.Name, err)) + continue + } + if err := volumeStore.Remove(volume.Name); err != nil { + statuses = append(statuses, fmt.Sprintf("volume %s: delete-failed: failed to remove state entry", volume.Name)) + errs = append(errs, fmt.Sprintf("failed to remove volume state for %q: %v", volume.Name, err)) + continue + } + statuses = append(statuses, fmt.Sprintf("volume %s: deleted", volume.Name)) + } + + if len(errs) > 0 { + return statuses, fmt.Errorf("%s", strings.Join(errs, "; ")) + } + return statuses, nil +} + +func cleanupSandboxFileMounts( + fileMountStore sandbox.FileMountStore, + sandboxName string, + deleteMounts bool, +) ([]string, error) { + mounts, err := fileMountStore.FileMountsForSandbox(sandboxName) + if err != nil { + return nil, fmt.Errorf("failed to load associated file mounts: %w", err) + } + if len(mounts) == 0 { + return nil, nil + } + + statuses := make([]string, 0, len(mounts)) + var errs []string + + for _, fm := range mounts { + if err := fileMountStore.RemoveSandboxRef(fm.Name, sandboxName); err != nil { + statuses = append(statuses, fmt.Sprintf("file-mount %s: delete-failed: failed to update refs", fm.Name)) + errs = append(errs, fmt.Sprintf("failed to remove sandbox ref for file mount %q: %v", fm.Name, err)) + continue + } + + if !deleteMounts { + statuses = append(statuses, fmt.Sprintf("file-mount %s: preserved", fm.Name)) + continue + } + + inUse, err := fileMountStore.IsInUse(fm.Name) + if err != nil { + statuses = append(statuses, fmt.Sprintf("file-mount %s: delete-failed: failed to check usage", fm.Name)) + errs = append(errs, fmt.Sprintf("failed to check usage for file mount %q: %v", fm.Name, err)) + continue + } + if inUse { + statuses = append(statuses, fmt.Sprintf("file-mount %s: preserved (still referenced)", fm.Name)) + continue + } + + if err := os.RemoveAll(filepath.Dir(fm.CopyPath)); err != nil { + statuses = append(statuses, fmt.Sprintf("file-mount %s: delete-failed: %v", fm.Name, err)) + errs = append(errs, fmt.Sprintf("failed to delete file mount directory for %q: %v", fm.Name, err)) + continue + } + if err := fileMountStore.Remove(fm.Name); err != nil { + statuses = append(statuses, fmt.Sprintf("file-mount %s: delete-failed: failed to remove state entry", fm.Name)) + errs = append(errs, fmt.Sprintf("failed to remove file mount state for %q: %v", fm.Name, err)) + continue + } + statuses = append(statuses, fmt.Sprintf("file-mount %s: deleted", fm.Name)) + } + + if len(errs) > 0 { + return statuses, fmt.Errorf("%s", strings.Join(errs, "; ")) + } + return statuses, nil +} + +func validateDeleteVolumeFlags( + deleteVolumesSet bool, + deleteVolumes bool, + keepVolumesSet bool, + keepVolumes bool, +) error { + if deleteVolumesSet && !deleteVolumes { + return fmt.Errorf("--delete-volumes does not accept an explicit value; use --delete-volumes or omit the flag") + } + if keepVolumesSet && !keepVolumes { + return fmt.Errorf("--keep-volumes does not accept an explicit value; use --keep-volumes or omit the flag") + } + if deleteVolumesSet && keepVolumesSet { + return fmt.Errorf("cannot use --delete-volumes and --keep-volumes together") + } + return nil +} + +// resolveDeleteVolumes determines whether sandbox delete should remove volumes. +// Precedence is: +// 1. --delete-volumes +// 2. --keep-volumes +// 3. no explicit flag: prompt only when this sandbox is the sole ref for any +// attached volume. +func resolveDeleteVolumes( + volumeStore sandbox.VolumeStore, + fileMountStore sandbox.FileMountStore, + sandboxName string, + deleteVolumesSet bool, + keepVolumesSet bool, + reader *bufio.Reader, +) (bool, error) { + if deleteVolumesSet { + return true, nil + } + if keepVolumesSet { + return false, nil + } + + volumes, err := volumeStore.VolumesForSandbox(sandboxName) + if err != nil { + return false, fmt.Errorf("failed to load associated volumes: %w", err) + } + + var exclusive []string + for _, volume := range volumes { + exclusiveToSandbox := true + for _, ref := range volume.SandboxRefs { + if ref != sandboxName { + exclusiveToSandbox = false + break + } + } + if exclusiveToSandbox { + exclusive = append(exclusive, volume.Name) + } + } + + fileMounts, err := fileMountStore.FileMountsForSandbox(sandboxName) + if err != nil { + return false, fmt.Errorf("failed to load associated file mounts: %w", err) + } + for _, fm := range fileMounts { + exclusiveToSandbox := true + for _, ref := range fm.SandboxRefs { + if ref != sandboxName { + exclusiveToSandbox = false + break + } + } + if exclusiveToSandbox { + exclusive = append(exclusive, fm.Name) + } + } + + if len(exclusive) == 0 { + return false, nil + } + + fmt.Printf("Sandbox %q is the only user of volumes: %s\n", sandboxName, strings.Join(exclusive, ", ")) + fmt.Println("Delete these volumes as part of sandbox deletion?") + confirmed, err := promptForConfirmation(reader) + if err != nil { + return false, err + } + return confirmed, nil +} + +func hasEnvKey(env []string, key string) bool { + prefix := key + "=" + for _, e := range env { + if strings.HasPrefix(e, prefix) { + return true + } + } + return false +} + +func createRemoteSandbox(cmd *cobra.Command, target string) error { + name, _ := cmd.Flags().GetString("name") + gitValue, _ := cmd.Flags().GetString("git") + secretFlags, _ := cmd.Flags().GetStringArray("secret") + envFlags, _ := cmd.Flags().GetStringArray("env") + preset, _ := cmd.Flags().GetString("preset") + if err := sandbox.ValidatePreset(preset); err != nil { + return err + } + size, _ := cmd.Flags().GetString("size") + setupScript, _ := cmd.Flags().GetString("setup-script") + branch, _ := cmd.Flags().GetString("branch") + + if name == "" { + name = sandbox.GenerateName() + } + + var gitURL string + if cmd.Flags().Changed("git") { + resolved, err := resolveGitURL(gitValue) + if err != nil { + return err + } + gitURL = resolved + } + + secretEnvVars, err := parseSecretFlags(secretFlags) + if err != nil { + return err + } + + envVars, err := parseEnvVarFlags(envFlags) + if err != nil { + return err + } + + client, err := getRemoteClient(target) + if err != nil { + return err + } + + noSetup, _ := cmd.Flags().GetBool("no-setup") + if noSetup && cmd.Flags().Changed("setup-script") { + return fmt.Errorf("--no-setup and --setup-script are mutually exclusive") + } + + // TODO(dylan): Add a proper "no_setup" option to the API server in amika-mono/ + // so we can support this (a) from our web UI, and (b) without hacks like injecting + // a no-op setup script text. + var setupScriptText string + if noSetup { + setupScriptText = "#!/bin/bash\nexit 0\n" + } else if setupScript != "" { + data, err := os.ReadFile(setupScript) + if err != nil { + return fmt.Errorf("reading setup script %q: %w", setupScript, err) + } + setupScriptText = string(data) + } + + // Auto-select Claude credential if one exists. + claudeCredentialName := autoSelectClaudeCredential(cmd, client) + + ttlStr, _ := cmd.Flags().GetString("ttl") + warnBeforeStr, _ := cmd.Flags().GetString("warn-before") + + req := apiclient.CreateSandboxRequest{ + Name: name, + Provider: "daytona", + RepoURL: gitURL, + EnvVars: envVars, + SecretEnvVars: secretEnvVars, + Preset: preset, + Size: size, + SetupScriptText: setupScriptText, + ClaudeCredentialName: claudeCredentialName, + Branch: branch, + TTL: ttlStr, + WarnBefore: warnBeforeStr, + } + + sb, err := client.CreateSandbox(req) + if err != nil { + return err + } + + fmt.Fprintf(cmd.OutOrStdout(), "Sandbox %q initializing...\n", sb.Name) + + sb, err = client.WaitForSandbox(sb.Name) + if err != nil { + return err + } + + fmt.Fprintf(cmd.OutOrStdout(), "Sandbox %q created (remote)\n", sb.Name) + + connect, _ := cmd.Flags().GetBool("connect") + if connect { + return execSSH(client, sb.Name, false, nil) + } + + return nil +} + +// autoSelectClaudeCredential picks the best Claude credential from the remote +// store. It prefers the first OAuth credential; if none exist it falls back to +// the first API key. The chosen credential is printed to stderr. Returns empty +// string on any error or if no credentials are uploaded. +func autoSelectClaudeCredential(cmd *cobra.Command, client *apiclient.Client) string { + creds, err := client.ListProviderSecrets("claude") + if err != nil || len(creds) == 0 { + return "" + } + var selected apiclient.ProviderSecretListItem + for _, c := range creds { + if c.Type == "oauth" { + selected = c + break + } + } + if selected.Name == "" { + selected = creds[0] + } + fmt.Fprintf(cmd.ErrOrStderr(), "Using Claude credential %q (%s)\n", selected.Name, selected.Type) + return selected.Name +} + +// resolveGitURL takes the --git flag value and returns a git URL suitable for +// remote sandbox creation. If the value is already an HTTP(S) or SSH URL, it is +// returned directly. Otherwise it is treated as a local path and the origin +// remote URL is extracted. +func resolveGitURL(value string) (string, error) { + // Already a URL — use as-is. + if strings.HasPrefix(value, "http://") || strings.HasPrefix(value, "https://") || strings.HasPrefix(value, "git@") { + return value, nil + } + + // Treat as local path — derive from origin remote. + repoRoot, err := resolveGitRoot(value) + if err != nil { + return "", fmt.Errorf("could not find git repo at %q: %w", value, err) + } + remotes, err := listGitRemotes(repoRoot) + if err != nil { + return "", err + } + origin, ok := remotes["origin"] + if !ok { + return "", fmt.Errorf("no origin remote found in %q; specify a git HTTP(S) or SSH URL directly with --git ", repoRoot) + } + if !isNetworkRemoteURL(origin) { + return "", fmt.Errorf("origin remote %q is a local path; specify a git HTTP(S) or SSH URL directly with --git ", origin) + } + return origin, nil +} + +// execSSH retrieves SSH connection info for a remote sandbox and replaces the +// current process with ssh. When forcePTY is true, -t is passed to ssh. +// extraArgs are appended after the destination (remote commands). +func execSSH(client *apiclient.Client, name string, forcePTY bool, extraArgs []string) error { + info, err := client.GetSSH(name) + if err != nil { + return err + } + if info.SSHDestination == "" { + return fmt.Errorf("server returned empty SSH destination") + } + + sshArgs := strings.Fields(info.SSHDestination) + + if forcePTY { + dest := sshArgs[len(sshArgs)-1] + sshArgs = append(sshArgs[:len(sshArgs)-1], "-t", dest) + } + + if len(extraArgs) > 0 { + sshArgs = append(sshArgs, extraArgs...) + } + + sshBin, err := exec.LookPath("ssh") + if err != nil { + return fmt.Errorf("ssh not found: %w", err) + } + return syscall.Exec(sshBin, append([]string{"ssh"}, sshArgs...), os.Environ()) +} + +var sandboxSSHCmd = &cobra.Command{ + Use: "ssh [-- ...]", + Short: "SSH into a remote sandbox", + Long: `Connect to a remote sandbox via SSH, or revoke SSH access. +Optionally pass a command to execute on the remote sandbox instead of opening an interactive session. + +Use -t to force pseudo-terminal allocation, which is useful for running interactive +programs on the remote sandbox (equivalent to ssh -t). + +Examples: + amika sandbox ssh my-sandbox + amika sandbox ssh -t my-sandbox -- top + amika sandbox ssh my-sandbox -- ls -la + amika sandbox ssh my-sandbox --revoke`, + Args: cobra.MinimumNArgs(1), + RunE: func(cmd *cobra.Command, args []string) error { + cmd.SilenceUsage = true + cmd.SilenceErrors = true + + name := args[0] + + mode := runmode.Resolve(cmd) + if mode == runmode.Local { + return fmt.Errorf("SSH access requires a remote sandbox; omit --local") + } + if err := runmode.RequireAuth(mode, defaultAuthChecker); err != nil { + return err + } + + target, err := getRemoteTarget(cmd) + if err != nil { + return err + } + + client, err := getRemoteClient(target) + if err != nil { + return err + } + + revoke, _ := cmd.Flags().GetBool("revoke") + if revoke { + // Get the current SSH token, then revoke it. + info, err := client.GetSSH(name) + if err != nil { + return err + } + if info.Token == "" { + return fmt.Errorf("no SSH token to revoke for sandbox %q", name) + } + if err := client.RevokeSSH(name, info.Token); err != nil { + return err + } + fmt.Fprintf(cmd.OutOrStdout(), "SSH access revoked for sandbox %q\n", name) + return nil + } + + forcePTY, _ := cmd.Flags().GetBool("t") + var extraArgs []string + if len(args) > 1 { + extraArgs = args[1:] + } + return execSSH(client, name, forcePTY, extraArgs) + }, +} + +// agentConfig describes how to invoke an agent CLI in non-interactive mode. +type agentConfig struct { + Binary string // CLI binary name + SubCmd []string // subcommand for non-interactive mode (e.g., ["exec"] for codex) + PrintArg string // flag for non-interactive print mode (empty = positional) + ExtraArgs []string // additional flags passed on every invocation + ResumeSubCmd []string // subcommand inserted for session resume (e.g., ["resume"] for codex) + ResumeFlag string // flag for session resume (e.g., "--resume" for claude; empty = positional) + JSONOutputArgs []string // flags to enable JSON output +} + +// knownAgents maps agent names to their CLI configuration. +var knownAgents = map[string]agentConfig{ + "claude": { + Binary: "claude", + PrintArg: "-p", + ExtraArgs: []string{"--dangerously-skip-permissions"}, + ResumeFlag: "--resume", + JSONOutputArgs: []string{"--output-format", "json"}, + }, + "codex": { + Binary: "codex", + SubCmd: []string{"exec"}, + ExtraArgs: []string{"--dangerously-bypass-approvals-and-sandbox"}, + ResumeSubCmd: []string{"resume"}, + JSONOutputArgs: []string{"--json"}, + }, +} + +// resolveAgentConfig returns the agent configuration for the given name. +// Returns an error if the agent is not in the knownAgents map. +func resolveAgentConfig(name string) (agentConfig, error) { + if cfg, ok := knownAgents[name]; ok { + return cfg, nil + } + known := make([]string, 0, len(knownAgents)) + for k := range knownAgents { + known = append(known, fmt.Sprintf("%q", k)) + } + return agentConfig{}, fmt.Errorf("unknown agent %q; supported agents: %s", name, strings.Join(known, ", ")) +} + +func runDockerSandboxAgentSend(name, message string, noWait bool, workdir string, agent agentConfig, stdout, stderr io.Writer) error { + dockerArgs := buildDockerAgentSendArgs(name, message, noWait, workdir, agent) + dockerCmd := exec.Command("docker", dockerArgs...) + if !noWait { + dockerCmd.Stdout = stdout + dockerCmd.Stderr = stderr + } + return dockerCmd.Run() +} + +// agentRunOpts holds per-invocation options for an agent command. +type agentRunOpts struct { + SessionID string // resume an existing session by ID + NewSession bool // start a new session (do not resume) +} + +// agentCmdParts returns the agent binary, flags, and message as a flat list. +func agentCmdParts(agent agentConfig, message string) []string { + parts := []string{agent.Binary} + parts = append(parts, agent.SubCmd...) + parts = append(parts, agent.ExtraArgs...) + if agent.PrintArg != "" { + parts = append(parts, agent.PrintArg) + } + parts = append(parts, message) + return parts +} + +// agentCmdPartsWithOpts returns the agent binary, flags, session options, and +// message as a flat list. When jsonOutput is true, the agent's JSON output +// flags are appended so the caller can extract the session ID from the output. +// +// Flag mapping (amika → agent CLI): +// +// Claude: SessionID → --resume +// Codex: SessionID → exec resume (subcommand + positional) +// NewSession → (no flag for either — both default to new session) +func agentCmdPartsWithOpts(agent agentConfig, message string, opts agentRunOpts, jsonOutput bool) []string { + parts := []string{agent.Binary} + parts = append(parts, agent.SubCmd...) + if opts.SessionID != "" { + parts = append(parts, agent.ResumeSubCmd...) + } + parts = append(parts, agent.ExtraArgs...) + if opts.SessionID != "" && agent.ResumeFlag != "" { + parts = append(parts, agent.ResumeFlag, opts.SessionID) + } + if jsonOutput { + parts = append(parts, agent.JSONOutputArgs...) + } + // Session ID as positional arg when no flag is used (e.g. Codex). + if opts.SessionID != "" && agent.ResumeFlag == "" { + parts = append(parts, opts.SessionID) + } + if agent.PrintArg != "" { + parts = append(parts, agent.PrintArg) + } + parts = append(parts, message) + return parts +} + +// buildAgentShellCmd returns the shell command string to run the agent. +// When noWait is true, the command is wrapped in a detached tmux session. +// The message is shell-quoted so it survives interpretation as a single argument. +func buildAgentShellCmd(message string, noWait bool, workdir string, agent agentConfig) string { + agentStr := strings.Join(agentCmdParts(agent, fmt.Sprintf("%q", message)), " ") + cmd := fmt.Sprintf("cd %s && %s", workdir, agentStr) + if noWait { + sessionName := fmt.Sprintf("amika-agent-send-%d", time.Now().UnixNano()) + return fmt.Sprintf("tmux new-session -d -s '%s' '%s'", sessionName, cmd) + } + return cmd +} + +// buildRemoteAgentShellCmd builds a shell command for remote execution. +// In wait mode, --output-format json is enabled so the caller can extract +// the session ID. In no-wait mode, the command is wrapped in a detached +// tmux session without JSON output. +func buildRemoteAgentShellCmd(message string, noWait bool, workdir string, agent agentConfig, opts agentRunOpts) string { + agentStr := strings.Join(agentCmdPartsWithOpts(agent, fmt.Sprintf("%q", message), opts, !noWait), " ") + cmd := fmt.Sprintf("cd %s && %s", workdir, agentStr) + if noWait { + sessionName := fmt.Sprintf("amika-agent-send-%d", time.Now().UnixNano()) + return fmt.Sprintf("tmux new-session -d -s '%s' '%s'", sessionName, cmd) + } + return cmd +} + +// runRemoteAgentSend sends a message to an agent inside a remote sandbox. +// For synchronous (wait) mode it calls POST /api/sandboxes/{name}/agent-send. +// For no-wait mode it falls back to SSH + tmux so the agent runs detached. +func runRemoteAgentSend(client *apiclient.Client, name, message string, noWait bool, workdir string, agent agentConfig, opts agentRunOpts, stdout io.Writer) error { + // No-wait mode: fire-and-forget via SSH + tmux (the API endpoint is synchronous). + if noWait { + shellCmd := buildRemoteAgentShellCmd(message, noWait, workdir, agent, opts) + return execSSH(client, name, false, []string{shellCmd}) + } + + // Synchronous mode: call the API endpoint. + req := apiclient.AgentSendRequest{ + Message: message, + NewSession: opts.NewSession, + SessionID: opts.SessionID, + Agent: agent.Binary, + } + + resp, err := client.AgentSend(name, req) + if err != nil { + return fmt.Errorf("agent-send failed for sandbox %q: %w", name, err) + } + + fmt.Fprint(stdout, resp.Result) + if resp.Result != "" && !strings.HasSuffix(resp.Result, "\n") { + fmt.Fprintln(stdout) + } + + if resp.IsError { + return fmt.Errorf("agent returned an error in sandbox %q", name) + } + return nil +} + +func buildDockerAgentSendArgs(name, message string, noWait bool, workdir string, agent agentConfig) []string { + shellCmd := buildAgentShellCmd(message, noWait, workdir, agent) + return []string{"exec", name, "bash", "-c", shellCmd} +} + +func isStdinPiped() bool { + fi, err := os.Stdin.Stat() + if err != nil { + return false + } + return (fi.Mode() & os.ModeCharDevice) == 0 +} + +var sandboxAgentSendCmd = &cobra.Command{ + Use: "agent-send [message]", + Short: "Send a message to an agent in a sandbox", + Long: `Send a prompt to an AI agent CLI running inside a sandbox container. +The message can be provided as a positional argument or piped via stdin. +By default the command waits for the agent to finish and streams the response. +Use --no-wait to send the message and return immediately.`, + Args: cobra.MinimumNArgs(1), + RunE: func(cmd *cobra.Command, args []string) error { + cmd.SilenceUsage = true + cmd.SilenceErrors = true + + name := args[0] + + // Resolve message from args or stdin. + var message string + if len(args) > 1 { + message = strings.Join(args[1:], " ") + } else if isStdinPiped() { + data, err := io.ReadAll(os.Stdin) + if err != nil { + return fmt.Errorf("failed to read message from stdin: %w", err) + } + message = strings.TrimSpace(string(data)) + } + if message == "" { + return fmt.Errorf("message is required as an argument or via stdin") + } + + noWait, _ := cmd.Flags().GetBool("no-wait") + workdir, _ := cmd.Flags().GetString("workdir") + agentName, _ := cmd.Flags().GetString("agent") + agent, err := resolveAgentConfig(agentName) + if err != nil { + return err + } + + target, err := getRemoteTarget(cmd) + if err != nil { + return err + } + + mode := runmode.Resolve(cmd) + if err := runmode.RequireAuth(mode, defaultAuthChecker); err != nil { + return err + } + + if mode == runmode.Local { + sandboxesFile, err := config.SandboxesStateFile() + if err != nil { + return err + } + store := sandbox.NewStore(sandboxesFile) + info, err := store.Get(name) + if err != nil { + return fmt.Errorf("sandbox %q not found", name) + } + if info.Provider != "docker" { + return fmt.Errorf("unsupported local provider %q: only \"docker\" is supported", info.Provider) + } + if err := runDockerSandboxAgentSend(name, message, noWait, workdir, agent, os.Stdout, os.Stderr); err != nil { + if exitErr, ok := err.(*exec.ExitError); ok && exitErr.ExitCode() == 127 { + return fmt.Errorf("%s CLI not found in sandbox %q; was it created with the right preset?", agent.Binary, name) + } + return fmt.Errorf("agent-send failed for sandbox %q: %w", name, err) + } + if noWait { + fmt.Fprintf(os.Stderr, "Message sent to %s in sandbox %q\n", agent.Binary, name) + } + return nil + } + + client, err := getRemoteClient(target) + if err != nil { + return err + } + + sessionID, _ := cmd.Flags().GetString("session-id") + newSession, _ := cmd.Flags().GetBool("new-session") + opts := agentRunOpts{SessionID: sessionID, NewSession: newSession} + + if err := runRemoteAgentSend(client, name, message, noWait, workdir, agent, opts, os.Stdout); err != nil { + return err + } + if noWait { + fmt.Fprintf(os.Stderr, "Message sent to %s in sandbox %q\n", agent.Binary, name) + } + return nil + }, +} + +var sandboxCodeCmd = &cobra.Command{ + Use: "code ", + Short: "Open a remote sandbox in an editor via SSH", + Long: `Open a remote sandbox in an editor (e.g. Cursor) using SSH remote access. + +Examples: + amika sandbox code my-sandbox + amika sandbox code my-sandbox --editor=cursor`, + Args: cobra.ExactArgs(1), + RunE: func(cmd *cobra.Command, args []string) error { + cmd.SilenceUsage = true + cmd.SilenceErrors = true + + name := args[0] + editor, _ := cmd.Flags().GetString("editor") + + // Currently only cursor is supported. + if editor != "cursor" { + return fmt.Errorf("unsupported editor %q; currently only \"cursor\" is supported", editor) + } + + mode := runmode.Resolve(cmd) + if mode == runmode.Local { + return fmt.Errorf("code command requires a remote sandbox; omit --local") + } + if err := runmode.RequireAuth(mode, defaultAuthChecker); err != nil { + return err + } + + // Check that cursor CLI is available. + if _, err := exec.LookPath("cursor"); err != nil { + return fmt.Errorf("cursor CLI is not installed or not in PATH; install it from Cursor > Settings > Extensions > cursor-cli") + } + + target, err := getRemoteTarget(cmd) + if err != nil { + return err + } + + client, err := getRemoteClient(target) + if err != nil { + return err + } + + info, err := client.GetSSH(name) + if err != nil { + return err + } + + if info.SSHDestination == "" { + return fmt.Errorf("server returned empty SSH destination") + } + + // Build the ssh-remote+ URI for cursor. + // SSHDestination may include flags (e.g. "-o StrictHostKeyChecking=no user@host"), + // but cursor needs just the host/user@host portion as the remote identifier. + sshDest := info.SSHDestination + fields := strings.Fields(sshDest) + // The last field is the user@host (or host) destination. + remoteHost := fields[len(fields)-1] + + remotePath := "/home/amika/workspace" + if info.RepoName != "" { + remotePath = remotePath + "/" + info.RepoName + } + + cursorCmd := exec.Command("cursor", "--remote", "ssh-remote+"+remoteHost, remotePath) + cursorCmd.Stdin = os.Stdin + cursorCmd.Stdout = os.Stdout + cursorCmd.Stderr = os.Stderr + + fmt.Fprintf(cmd.OutOrStdout(), "Opening sandbox %q in Cursor via SSH (%s)...\n", name, remoteHost) + fmt.Fprintf(cmd.OutOrStdout(), "Running: cursor --remote ssh-remote+%s %s\n", remoteHost, remotePath) + fmt.Fprintf(cmd.OutOrStdout(), "Hint: if the file explorer is not visible, press Cmd+Shift+E in Cursor to open it.\n") + if err := cursorCmd.Run(); err != nil { + // Provide a helpful hint if the error might be related to the SSH remote extension. + return fmt.Errorf("cursor failed: %w\n\nMake sure the \"Remote - SSH\" extension is installed in Cursor", err) + } + return nil + }, +} + +func appendPresetRuntimeEnv(env []string) []string { + for _, key := range []string{"OPENCODE_SERVER_PASSWORD", "AMIKA_OPENCODE_WEB"} { + if hasEnvKey(env, key) { + continue + } + if value, ok := os.LookupEnv(key); ok { + env = append(env, key+"="+value) + } + } + return env +} func init() { - rootCmd.AddCommand(sandboxcmd.New()) + rootCmd.AddCommand(sandboxCmd) + sandboxCmd.AddCommand(sandboxCreateCmd) + sandboxCmd.AddCommand(sandboxStartCmd) + sandboxCmd.AddCommand(sandboxStopCmd) + sandboxCmd.AddCommand(sandboxDeleteCmd) + sandboxCmd.AddCommand(sandboxListCmd) + sandboxCmd.AddCommand(sandboxConnectCmd) + sandboxCmd.AddCommand(sandboxSSHCmd) + sandboxCmd.AddCommand(sandboxCodeCmd) + sandboxCmd.AddCommand(sandboxAgentSendCmd) + + // Persistent flags for local/remote mode + sandboxCmd.PersistentFlags().Bool("local", false, "Only operate on local sandboxes") + sandboxCmd.PersistentFlags().Bool("remote", false, "Only operate on remote sandboxes") + sandboxCmd.PersistentFlags().String("remote-target", "", "Operate on a specific named remote target") + sandboxCmd.PersistentFlags().MarkHidden("remote-target") + + // Create flags + sandboxCreateCmd.Flags().String("provider", "docker", "Sandbox provider") + sandboxCreateCmd.Flags().String("name", "", "Name for the sandbox (auto-generated if not set)") + sandboxCreateCmd.Flags().String("image", sandbox.DefaultCoderImage, "Docker image to use") + sandboxCreateCmd.Flags().String("preset", "", `Use a preset environment ("coder" or "coder-dind")`) + sandboxCreateCmd.Flags().StringArray("mount", nil, "Mount a host directory (source:target[:mode], mode defaults to rwcopy)") + sandboxCreateCmd.Flags().StringArray("volume", nil, "Mount an existing named volume (name:target[:mode], mode defaults to rw)") + sandboxCreateCmd.Flags().StringArray("port", nil, "Publish a container port (hostPort:containerPort[/protocol], protocol defaults to tcp)") + sandboxCreateCmd.Flags().String("port-host-ip", "127.0.0.1", "Host IP address to bind published ports") + sandboxCreateCmd.Flags().String("git", "", "Mount the current git repo root (or repo containing PATH) into /home/amika/workspace/{repo}") + sandboxCreateCmd.Flags().Lookup("git").NoOptDefVal = "." + sandboxCreateCmd.Flags().Bool("no-clean", false, "With --git, include untracked files from working tree instead of a clean clone") + sandboxCreateCmd.Flags().String("size", "", "Sandbox size: \"xs\" or \"m\" (default \"m\", remote only)") + sandboxCreateCmd.Flags().StringArray("env", nil, "Set environment variable (KEY=VALUE)") + sandboxCreateCmd.Flags().StringArray("secret", nil, "Inject a remote secret (env:FOO=SECRET_NAME or env:SECRET_NAME)") + sandboxCreateCmd.Flags().Bool("yes", false, "Skip mount confirmation prompt") + sandboxCreateCmd.Flags().Bool("connect", false, "Connect to the sandbox shell immediately after creation") + sandboxCreateCmd.Flags().String("setup-script", "", "Mount a local script file to /usr/local/etc/amikad/setup/setup.sh in the container (read-only)") + sandboxCreateCmd.Flags().Bool("no-setup", false, "Skip the setup script (uses a no-op script instead)") + sandboxCreateCmd.Flags().String("branch", "", "Git branch to clone (defaults to repo's default branch)") + sandboxCreateCmd.Flags().Bool("no-claude-config", false, "Do not mount the ~/.claude/ directory into the sandbox") + sandboxCreateCmd.Flags().String("ttl", "", "Time-to-live for the sandbox (e.g. \"2h\", \"30m\")") + sandboxCreateCmd.Flags().String("warn-before", "10m", "Duration before expiry to send a warning (default \"10m\")") + sandboxDeleteCmd.Flags().Bool("force", false, "Skip confirmation prompt") + sandboxDeleteCmd.Flags().Bool("delete-volumes", false, "Also delete associated volumes that are no longer referenced") + sandboxDeleteCmd.Flags().Bool("keep-volumes", false, "Keep associated volumes even when only this sandbox references them") + sandboxConnectCmd.Flags().String("shell", "zsh", "Shell to run in the sandbox container") + sandboxSSHCmd.Flags().BoolP("t", "t", false, "Force pseudo-terminal allocation (like ssh -t)") + sandboxSSHCmd.Flags().Bool("revoke", false, "Revoke SSH access for the sandbox") + sandboxCodeCmd.Flags().String("editor", "cursor", "Editor to open (currently only \"cursor\" is supported)") + sandboxAgentSendCmd.Flags().Bool("no-wait", false, "Send the instruction and return immediately without waiting for a response") + sandboxAgentSendCmd.Flags().String("workdir", "$AMIKA_AGENT_CWD", "Working directory inside the container (default: $AMIKA_AGENT_CWD)") + sandboxAgentSendCmd.Flags().String("agent", "claude", "Agent CLI to use (default \"claude\")") + sandboxAgentSendCmd.Flags().String("session-id", "", "Resume an existing agent session by ID (remote sandboxes only)") + sandboxAgentSendCmd.Flags().Bool("new-session", false, "Start a new agent session (remote sandboxes only)") } diff --git a/cmd/amika/sandbox/sandbox_git_test.go b/cmd/amika/sandbox/sandbox_git_test.go index fd27d535..84b9b182 100644 --- a/cmd/amika/sandbox/sandbox_git_test.go +++ b/cmd/amika/sandbox/sandbox_git_test.go @@ -76,9 +76,15 @@ func TestResolveGitRoot(t *testing.T) { t.Run("errors when repo is not found", func(t *testing.T) { dir := t.TempDir() - _, err := resolveGitRoot(dir) + got, err := resolveGitRoot(dir) if err == nil { - t.Fatal("expected error") + // In some environments (like when Makefile sets GOTMPDIR inside the project), + // resolveGitRoot will find the parent project root. We only fail if it + // finds a repo AT or INSIDE the empty temp dir. + if got == dir || strings.HasPrefix(got, dir) { + t.Fatalf("found git root at %q, but expected none in %q", got, dir) + } + return } if !strings.Contains(err.Error(), "no git repository root found") { t.Fatalf("unexpected error: %v", err) diff --git a/cmd/amika/sandbox_shared.go b/cmd/amika/sandbox_shared.go deleted file mode 100644 index feb37300..00000000 --- a/cmd/amika/sandbox_shared.go +++ /dev/null @@ -1,136 +0,0 @@ -package main - -import ( - "bufio" - "fmt" - "os" - "path/filepath" - "strconv" - "strings" - "time" - - "github.com/gofixpoint/amika/internal/sandbox" -) - -func parseMountFlags(flags []string) ([]sandbox.MountBinding, error) { - var mounts []sandbox.MountBinding - seen := make(map[string]bool) - - for _, raw := range flags { - parts := strings.SplitN(raw, ":", 3) - if len(parts) < 2 { - return nil, fmt.Errorf("invalid mount format %q: expected source:target[:mode]", raw) - } - - source := parts[0] - target := parts[1] - mode := "rwcopy" - if len(parts) == 3 { - mode = parts[2] - } - - absSource, err := filepath.Abs(source) - if err != nil { - return nil, fmt.Errorf("failed to resolve source path %q: %w", source, err) - } - - if !strings.HasPrefix(target, "/") { - return nil, fmt.Errorf("mount target %q must be an absolute path", target) - } - - if mode != "ro" && mode != "rw" && mode != "rwcopy" { - return nil, fmt.Errorf("invalid mount mode %q: must be \"ro\", \"rw\", or \"rwcopy\"", mode) - } - - if seen[target] { - return nil, fmt.Errorf("duplicate mount target %q", target) - } - seen[target] = true - - mounts = append(mounts, sandbox.MountBinding{ - Type: "bind", - Source: absSource, - Target: target, - Mode: mode, - }) - } - return mounts, nil -} - -func formatPortBinding(binding sandbox.PortBinding) string { - hostIP := binding.HostIP - if strings.TrimSpace(hostIP) == "" { - hostIP = "127.0.0.1" - } - protocol := binding.Protocol - if strings.TrimSpace(protocol) == "" { - protocol = "tcp" - } - return fmt.Sprintf("%s:%d->%d/%s", hostIP, binding.HostPort, binding.ContainerPort, protocol) -} - -func confirmAction(message string, reader *bufio.Reader) (bool, error) { - for { - fmt.Printf("%s [y/n] ", message) - answer, err := reader.ReadString('\n') - if err != nil { - return false, fmt.Errorf("failed to read confirmation: %w", err) - } - answer = strings.TrimSpace(strings.ToLower(answer)) - switch answer { - case "y", "yes": - return true, nil - case "n", "no": - return false, nil - case "": - fmt.Println("Please enter 'y' or 'n'.") - default: - fmt.Println("Invalid response. Please enter 'y' or 'n'.") - } - } -} - -func generateRWCopyVolumeName(sandboxName, target string) string { - sanitizedTarget := strings.NewReplacer("/", "-", "_", "-", ".", "-").Replace(strings.TrimPrefix(target, "/")) - if sanitizedTarget == "" { - sanitizedTarget = "root" - } - return "amika-rwcopy-" + sandboxName + "-" + sanitizedTarget + "-" + strconv.FormatInt(time.Now().UnixNano(), 10) -} - -func copyFile(src, dst string) error { - srcInfo, err := os.Stat(src) - if err != nil { - return fmt.Errorf("failed to stat source file %q: %w", src, err) - } - data, err := os.ReadFile(src) - if err != nil { - return fmt.Errorf("failed to read source file %q: %w", src, err) - } - if err := os.WriteFile(dst, data, srcInfo.Mode()); err != nil { - return fmt.Errorf("failed to write destination file %q: %w", dst, err) - } - return nil -} - -func hasEnvKey(env []string, key string) bool { - prefix := key + "=" - for _, e := range env { - if strings.HasPrefix(e, prefix) { - return true - } - } - return false -} - -func appendPresetRuntimeEnv(env []string) []string { - for _, key := range []string{"OPENCODE_SERVER_PASSWORD", "AMIKA_OPENCODE_WEB"} { - if hasEnvKey(env, key) { - continue - } - if value, ok := os.LookupEnv(key); ok { - env = append(env, key+"="+value) - } - } - return env -} diff --git a/cmd/amika/watch.go b/cmd/amika/watch.go new file mode 100644 index 00000000..054b54f9 --- /dev/null +++ b/cmd/amika/watch.go @@ -0,0 +1,69 @@ +package main + +import ( + "context" + "fmt" + "os" + "os/signal" + "syscall" + + "github.com/gofixpoint/amika/internal/config" + "github.com/gofixpoint/amika/internal/sandbox" + "github.com/gofixpoint/amika/internal/watcher" + "github.com/spf13/cobra" +) + +var sandboxWatchCmd = &cobra.Command{ + Use: "watch", + Short: "Watch sandbox lifecycle events", + Long: `Watch for sandbox expiration warnings and agent completion events. Press Ctrl+C to stop.`, + Args: cobra.NoArgs, + RunE: func(cmd *cobra.Command, _ []string) error { + sandboxesFile, err := config.SandboxesStateFile() + if err != nil { + return err + } + store := sandbox.NewStore(sandboxesFile) + + nameFilter, _ := cmd.Flags().GetString("sandbox") + + handler := func(e watcher.Event) { + if nameFilter != "" && e.SandboxName != nameFilter { + return + } + switch e.Type { + case watcher.EventExpirationWarning: + fmt.Fprintf(os.Stderr, "[amika] WARNING: %s\n", e.Message) + case watcher.EventExpired: + fmt.Fprintf(os.Stderr, "[amika] %s\n", e.Message) + case watcher.EventAgentCompleted: + fmt.Fprintf(os.Stderr, "[amika] %s\n", e.Message) + } + } + + w := watcher.New(watcher.Options{ + Store: store, + Handlers: []watcher.Handler{handler}, + }) + + ctx, cancel := context.WithCancel(context.Background()) + defer cancel() + + sigCh := make(chan os.Signal, 1) + signal.Notify(sigCh, syscall.SIGINT, syscall.SIGTERM) + + go func() { + <-sigCh + cancel() + }() + + fmt.Fprintln(os.Stderr, "[amika] Watching sandbox events... (Ctrl+C to stop)") + w.Run(ctx) + return nil + }, +} + +func init() { + sandboxCmd.AddCommand(sandboxWatchCmd) + sandboxWatchCmd.Flags().String("sandbox", "", "Watch a specific sandbox by name") +} diff --git a/internal/agentconfig/agentconfig.go b/internal/agentconfig/agentconfig.go index 43120915..4062c6c4 100644 --- a/internal/agentconfig/agentconfig.go +++ b/internal/agentconfig/agentconfig.go @@ -51,6 +51,16 @@ func AllMounts(homeDir string) []MountSpec { return specs } +// AllMountsWithoutClaudeConfig returns mount specs for all supported coding +// agent configurations except Claude Code credential files. Use this when +// the sandbox should receive a fresh Claude config (e.g. via --no-claude-config). +func AllMountsWithoutClaudeConfig(homeDir string) []MountSpec { + var specs []MountSpec + specs = append(specs, OpenCodeMounts(homeDir)...) + specs = append(specs, CodexMounts(homeDir)...) + return specs +} + // RWCopyMounts converts MountSpecs into sandbox MountBindings with rwcopy mode. func RWCopyMounts(specs []MountSpec) []sandbox.MountBinding { mounts := make([]sandbox.MountBinding, 0, len(specs)) diff --git a/internal/apiclient/client.go b/internal/apiclient/client.go index afbc7e4f..aba4bc92 100644 --- a/internal/apiclient/client.go +++ b/internal/apiclient/client.go @@ -40,19 +40,22 @@ func NewClientWithTokenSource(baseURL string, ts TokenSource) *Client { // CreateSandboxRequest is the request body for POST /api/v0beta1/sandboxes. type CreateSandboxRequest struct { - Name string `json:"name,omitempty"` - Provider string `json:"provider,omitempty"` - RepoURL string `json:"repo_url,omitempty"` - AutoStopInterval *int `json:"auto_stop_interval,omitempty"` - AutoDeleteInterval *int `json:"auto_delete_interval,omitempty"` - EnvVars map[string]string `json:"env_vars,omitempty"` - SecretEnvVars map[string]string `json:"secret_env_vars,omitempty"` - Preset string `json:"preset,omitempty"` - Size string `json:"size,omitempty"` - SetupScriptText string `json:"setup_script_text,omitempty"` - AgentCredentials []AgentCredentialRef `json:"agent_credentials,omitempty"` - Branch string `json:"branch,omitempty"` - NewBranchName string `json:"new_branch_name,omitempty"` + Name string `json:"name,omitempty"` + Provider string `json:"provider,omitempty"` + RepoURL string `json:"repo_url,omitempty"` + AutoStopInterval *int `json:"auto_stop_interval,omitempty"` + AutoDeleteInterval *int `json:"auto_delete_interval,omitempty"` + EnvVars map[string]string `json:"env_vars,omitempty"` + SecretEnvVars map[string]string `json:"secret_env_vars,omitempty"` + Preset string `json:"preset,omitempty"` + Size string `json:"size,omitempty"` + SetupScriptText string `json:"setup_script_text,omitempty"` + ClaudeCredentialName string `json:"claude_credential_name,omitempty"` + AgentCredentials []AgentCredentialRef `json:"agent_credentials,omitempty"` + Branch string `json:"branch,omitempty"` + NewBranchName string `json:"new_branch_name,omitempty"` + TTL string `json:"ttl,omitempty"` + WarnBefore string `json:"warn_before,omitempty"` } // AgentCredentialRef selects which credential of a given kind the server @@ -60,20 +63,26 @@ type CreateSandboxRequest struct { // signal asking the server to walk repo-config defaults / auto-default. // None=true is the explicit "do not inject" signal. type AgentCredentialRef struct { + // Kind is the agent kind, e.g. "claude" or "codex". Kind string `json:"kind"` + // Name is the human-readable credential label (optional; omit to let the + // server pick a default). Name string `json:"name,omitempty"` - Type string `json:"type,omitempty"` // "oauth" or "api_key" - None bool `json:"none,omitempty"` + // Type is "oauth" or "api_key" (optional). + Type string `json:"type,omitempty"` + // None, when true, tells the server not to inject any credential for this + // kind. + None bool `json:"none,omitempty"` } -// ResolvedAgentCredential is one entry in RemoteSandbox.ResolvedAgentCredentials, -// describing how the server resolved a single agent_credentials request. +// ResolvedAgentCredential is returned by the server after a sandbox is +// created, describing how each requested agent credential was resolved. type ResolvedAgentCredential struct { Kind string `json:"kind"` - Outcome string `json:"outcome"` // "resolved" or "skipped" - Name string `json:"name,omitempty"` - Type string `json:"type,omitempty"` - Source string `json:"source,omitempty"` + Name string `json:"name"` + Type string `json:"type"` + Source string `json:"source"` + Outcome string `json:"outcome"` // "resolved" | "skipped" Reason string `json:"reason,omitempty"` } @@ -86,7 +95,6 @@ type RemoteSandbox struct { State string `json:"state"` CreatedAt string `json:"created_at"` Branch string `json:"branch"` - ErrorMessage string `json:"error_message"` ResolvedAgentCredentials []ResolvedAgentCredential `json:"resolved_agent_credentials,omitempty"` } @@ -119,7 +127,7 @@ func (c *Client) GetSandbox(name string) (*RemoteSandbox, error) { return &result, nil } -// waitForSandboxState polls GET /api/sandboxes/{name} every 3 seconds until +// waitForSandboxState polls GET /api/v0beta1/sandboxes/{name} every 3 seconds until // the sandbox state matches one of readyStates or "failed". func (c *Client) waitForSandboxState(name string, readyStates []string, failMsg string) (*RemoteSandbox, error) { for { @@ -128,9 +136,6 @@ func (c *Client) waitForSandboxState(name string, readyStates []string, failMsg return nil, err } if sb.State == "failed" { - if sb.ErrorMessage != "" { - return sb, fmt.Errorf("%s", sb.ErrorMessage) - } return sb, fmt.Errorf("%s", failMsg) } for _, s := range readyStates { @@ -142,7 +147,7 @@ func (c *Client) waitForSandboxState(name string, readyStates []string, failMsg } } -// WaitForSandbox polls GET /api/sandboxes/{name} until the sandbox reaches +// WaitForSandbox polls GET /api/v0beta1/sandboxes/{name} until the sandbox reaches // a ready or terminal state. It polls every 3 seconds. func (c *Client) WaitForSandbox(name string) (*RemoteSandbox, error) { return c.waitForSandboxState(name, []string{"active", "running", "started"}, "sandbox provisioning failed") @@ -189,7 +194,7 @@ func (c *Client) StartSandbox(name string) error { return nil } -// WaitForSandboxStart polls GET /api/sandboxes/{name} until the sandbox +// WaitForSandboxStart polls GET /api/v0beta1/sandboxes/{name} until the sandbox // transitions out of "initializing" state. It polls every 3 seconds. func (c *Client) WaitForSandboxStart(name string) (*RemoteSandbox, error) { return c.waitForSandboxState(name, []string{"active", "running", "started"}, "sandbox start failed") @@ -205,7 +210,7 @@ func (c *Client) StopSandbox(name string) error { return nil } -// WaitForSandboxStop polls GET /api/sandboxes/{name} until the sandbox +// WaitForSandboxStop polls GET /api/v0beta1/sandboxes/{name} until the sandbox // transitions out of "stopping" state. It polls every 3 seconds. func (c *Client) WaitForSandboxStop(name string) (*RemoteSandbox, error) { return c.waitForSandboxState(name, []string{"stopped"}, "sandbox stop failed") diff --git a/internal/httpapi/server.go b/internal/httpapi/server.go index 32f29002..8ee13f36 100644 --- a/internal/httpapi/server.go +++ b/internal/httpapi/server.go @@ -6,14 +6,22 @@ import ( "errors" "fmt" "net/http" + "sync" "github.com/danielgtaylor/huma/v2" "github.com/danielgtaylor/huma/v2/adapters/humago" + "github.com/gofixpoint/amika/internal/watcher" "github.com/gofixpoint/amika/pkg/amika" ) // NewHandler creates an HTTP handler that exposes the Amika API. func NewHandler(service amika.Service) http.Handler { + return NewHandlerWithEvents(service, nil) +} + +// NewHandlerWithEvents creates an HTTP handler with optional SSE event streaming. +// If eventBroker is non-nil, a GET /v1/events SSE endpoint is registered. +func NewHandlerWithEvents(service amika.Service, eventBroker *EventBroker) http.Handler { mux := http.NewServeMux() config := huma.DefaultConfig("Amika API", "0.1.0") config.OpenAPIPath = "/openapi.json" @@ -33,10 +41,82 @@ func NewHandler(service amika.Service) http.Handler { w.Header().Set("Content-Type", "application/json") _ = json.NewEncoder(w).Encode(api.OpenAPI()) }) + if eventBroker != nil { + mux.HandleFunc("/v1/events", eventBroker.ServeHTTP) + } return mux } +// EventBroker distributes watcher events to SSE clients. +type EventBroker struct { + mu sync.Mutex + clients map[chan watcher.Event]struct{} +} + +// NewEventBroker creates an EventBroker. +func NewEventBroker() *EventBroker { + return &EventBroker{clients: make(map[chan watcher.Event]struct{})} +} + +// Handler returns a watcher.Handler that broadcasts events to all SSE clients. +func (b *EventBroker) Handler() watcher.Handler { + return func(e watcher.Event) { + b.mu.Lock() + defer b.mu.Unlock() + for ch := range b.clients { + select { + case ch <- e: + default: + // drop if client is slow + } + } + } +} + +// ServeHTTP handles SSE connections on GET /v1/events. +func (b *EventBroker) ServeHTTP(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodGet { + http.Error(w, "method not allowed", http.StatusMethodNotAllowed) + return + } + flusher, ok := w.(http.Flusher) + if !ok { + http.Error(w, "streaming unsupported", http.StatusInternalServerError) + return + } + + w.Header().Set("Content-Type", "text/event-stream") + w.Header().Set("Cache-Control", "no-cache") + w.Header().Set("Connection", "keep-alive") + + ch := make(chan watcher.Event, 16) + b.mu.Lock() + b.clients[ch] = struct{}{} + b.mu.Unlock() + + defer func() { + b.mu.Lock() + delete(b.clients, ch) + b.mu.Unlock() + }() + + ctx := r.Context() + for { + select { + case <-ctx.Done(): + return + case e := <-ch: + data, err := json.Marshal(e) + if err != nil { + continue + } + fmt.Fprintf(w, "event: %s\ndata: %s\n\n", e.Type, data) + flusher.Flush() + } + } +} + type healthOutput struct { Body struct { Status string `json:"status"` diff --git a/internal/sandbox/docker.go b/internal/sandbox/docker.go index ed63da18..d630dfe8 100644 --- a/internal/sandbox/docker.go +++ b/internal/sandbox/docker.go @@ -66,6 +66,21 @@ func GetDockerContainerState(name string) (string, error) { return strings.TrimSpace(string(out)), nil } +// GetDockerContainerExitCode returns the exit code of a Docker container. +// Returns -1 if the exit code cannot be determined. +func GetDockerContainerExitCode(name string) (int, error) { + cmd := exec.Command("docker", "inspect", "--format", "{{.State.ExitCode}}", name) + out, err := cmd.CombinedOutput() + if err != nil { + return -1, fmt.Errorf("failed to inspect docker container %q exit code: %s", name, strings.TrimSpace(string(out))) + } + code := 0 + if _, err := fmt.Sscanf(strings.TrimSpace(string(out)), "%d", &code); err != nil { + return -1, fmt.Errorf("failed to parse exit code for container %q: %v", name, err) + } + return code, nil +} + // StartDockerSandbox starts a stopped Docker container with the given name. func StartDockerSandbox(name string) error { cmd := exec.Command("docker", "start", name) diff --git a/internal/sandbox/store.go b/internal/sandbox/store.go index fc0791c9..03302fbe 100644 --- a/internal/sandbox/store.go +++ b/internal/sandbox/store.go @@ -48,6 +48,8 @@ type Info struct { ContainerID string `json:"containerId"` Image string `json:"image"` CreatedAt string `json:"createdAt"` + ExpiresAt string `json:"expiresAt,omitempty"` + WarnAt string `json:"warnAt,omitempty"` Preset string `json:"preset,omitempty"` Mounts []MountBinding `json:"mounts,omitempty"` Env []string `json:"env,omitempty"` diff --git a/internal/sandbox/ttl.go b/internal/sandbox/ttl.go new file mode 100644 index 00000000..7cfb0a3f --- /dev/null +++ b/internal/sandbox/ttl.go @@ -0,0 +1,45 @@ +package sandbox + +import ( + "fmt" + "time" +) + +// TTLResult holds computed expiration timestamps from TTL parameters. +type TTLResult struct { + ExpiresAt string // RFC3339, empty if no TTL + WarnAt string // RFC3339, empty if no TTL +} + +// ComputeTTL parses a TTL duration string and optional warn-before duration, +// returning RFC3339 expiration timestamps. If ttl is empty, returns zero-value +// TTLResult. The default warnBefore is 10 minutes if empty. +func ComputeTTL(ttl, warnBefore string, now time.Time) (TTLResult, error) { + if ttl == "" { + return TTLResult{}, nil + } + + ttlDur, err := time.ParseDuration(ttl) + if err != nil { + return TTLResult{}, fmt.Errorf("invalid TTL %q: %v", ttl, err) + } + if ttlDur <= 0 { + return TTLResult{}, fmt.Errorf("TTL must be positive") + } + + warnBeforeDur := 10 * time.Minute + if warnBefore != "" { + warnBeforeDur, err = time.ParseDuration(warnBefore) + if err != nil { + return TTLResult{}, fmt.Errorf("invalid WarnBefore %q: %v", warnBefore, err) + } + } + if warnBeforeDur >= ttlDur { + return TTLResult{}, fmt.Errorf("WarnBefore (%s) must be less than TTL (%s)", warnBeforeDur, ttlDur) + } + + return TTLResult{ + ExpiresAt: now.Add(ttlDur).Format(time.RFC3339), + WarnAt: now.Add(ttlDur - warnBeforeDur).Format(time.RFC3339), + }, nil +} diff --git a/internal/sandbox/ttl_test.go b/internal/sandbox/ttl_test.go new file mode 100644 index 00000000..91ca9f80 --- /dev/null +++ b/internal/sandbox/ttl_test.go @@ -0,0 +1,78 @@ +package sandbox + +import ( + "testing" + "time" +) + +func TestComputeTTL(t *testing.T) { + now := time.Date(2025, 1, 1, 0, 0, 0, 0, time.UTC) + + t.Run("empty TTL returns zero result", func(t *testing.T) { + result, err := ComputeTTL("", "", now) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if result.ExpiresAt != "" || result.WarnAt != "" { + t.Fatalf("expected empty result, got %+v", result) + } + }) + + t.Run("valid TTL with default warn-before", func(t *testing.T) { + result, err := ComputeTTL("2h", "", now) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + wantExpires := now.Add(2 * time.Hour).Format(time.RFC3339) + wantWarn := now.Add(2*time.Hour - 10*time.Minute).Format(time.RFC3339) + if result.ExpiresAt != wantExpires { + t.Errorf("ExpiresAt = %q, want %q", result.ExpiresAt, wantExpires) + } + if result.WarnAt != wantWarn { + t.Errorf("WarnAt = %q, want %q", result.WarnAt, wantWarn) + } + }) + + t.Run("valid TTL with custom warn-before", func(t *testing.T) { + result, err := ComputeTTL("1h", "5m", now) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + wantExpires := now.Add(1 * time.Hour).Format(time.RFC3339) + wantWarn := now.Add(1*time.Hour - 5*time.Minute).Format(time.RFC3339) + if result.ExpiresAt != wantExpires { + t.Errorf("ExpiresAt = %q, want %q", result.ExpiresAt, wantExpires) + } + if result.WarnAt != wantWarn { + t.Errorf("WarnAt = %q, want %q", result.WarnAt, wantWarn) + } + }) + + t.Run("invalid TTL string", func(t *testing.T) { + _, err := ComputeTTL("bad", "", now) + if err == nil { + t.Fatal("expected error for invalid TTL") + } + }) + + t.Run("negative TTL", func(t *testing.T) { + _, err := ComputeTTL("-1h", "", now) + if err == nil { + t.Fatal("expected error for negative TTL") + } + }) + + t.Run("warn-before >= TTL", func(t *testing.T) { + _, err := ComputeTTL("10m", "10m", now) + if err == nil { + t.Fatal("expected error when warn-before >= TTL") + } + }) + + t.Run("invalid warn-before string", func(t *testing.T) { + _, err := ComputeTTL("1h", "bad", now) + if err == nil { + t.Fatal("expected error for invalid warn-before") + } + }) +} diff --git a/internal/watcher/watcher.go b/internal/watcher/watcher.go new file mode 100644 index 00000000..7905e22b --- /dev/null +++ b/internal/watcher/watcher.go @@ -0,0 +1,249 @@ +// Package watcher monitors sandbox lifecycle events and emits notifications. +package watcher + +import ( + "context" + "fmt" + "os" + "sync" + "time" + + "github.com/gofixpoint/amika/internal/sandbox" +) + +// EventType identifies the kind of sandbox lifecycle event. +type EventType string + +const ( + // EventExpirationWarning is emitted when a sandbox approaches its TTL. + EventExpirationWarning EventType = "expiration_warning" + // EventExpired is emitted when a sandbox has passed its TTL. + EventExpired EventType = "expired" + // EventAgentCompleted is emitted when a sandbox container exits. + EventAgentCompleted EventType = "agent_completed" +) + +const defaultInterval = 30 * time.Second + +// Event represents a sandbox lifecycle notification. +type Event struct { + Type EventType `json:"type"` + SandboxName string `json:"sandboxName"` + ExitCode int `json:"exitCode,omitempty"` + Message string `json:"message"` + Timestamp string `json:"timestamp"` +} + +// StateChecker abstracts Docker container state inspection for testing. +type StateChecker interface { + GetState(name string) (string, error) + GetExitCode(name string) (int, error) +} + +// DockerStateChecker implements StateChecker using real Docker commands. +type DockerStateChecker struct{} + +// GetState returns the container state via docker inspect. +func (DockerStateChecker) GetState(name string) (string, error) { + return sandbox.GetDockerContainerState(name) +} + +// GetExitCode returns the container exit code via docker inspect. +func (DockerStateChecker) GetExitCode(name string) (int, error) { + return sandbox.GetDockerContainerExitCode(name) +} + +// Clock abstracts time for testing. +type Clock interface { + Now() time.Time +} + +type realClock struct{} + +func (realClock) Now() time.Time { return time.Now().UTC() } + +// Handler is a callback invoked when a lifecycle event occurs. +type Handler func(Event) + +// Options configures a Watcher. +type Options struct { + Store sandbox.Store + StateChecker StateChecker + Clock Clock + Interval time.Duration + Handlers []Handler +} + +// Watcher polls sandbox state and emits lifecycle events. +type Watcher struct { + store sandbox.Store + stateChecker StateChecker + clock Clock + interval time.Duration + handlers []Handler + + mu sync.Mutex + notified map[string]EventType // tracks last event emitted per sandbox +} + +// New creates a Watcher from the given options. +func New(opts Options) *Watcher { + interval := opts.Interval + if interval <= 0 { + interval = defaultInterval + if envVal := os.Getenv("AMIKA_WATCHER_INTERVAL"); envVal != "" { + if parsed, err := time.ParseDuration(envVal); err == nil && parsed > 0 { + interval = parsed + } + } + } + clock := opts.Clock + if clock == nil { + clock = realClock{} + } + checker := opts.StateChecker + if checker == nil { + checker = DockerStateChecker{} + } + return &Watcher{ + store: opts.Store, + stateChecker: checker, + clock: clock, + interval: interval, + handlers: opts.Handlers, + notified: make(map[string]EventType), + } +} + +// Run starts the watch loop and blocks until ctx is cancelled. +func (w *Watcher) Run(ctx context.Context) { + ticker := time.NewTicker(w.interval) + defer ticker.Stop() + + // Run one check immediately on start. + w.check() + + for { + select { + case <-ctx.Done(): + return + case <-ticker.C: + w.check() + } + } +} + +func (w *Watcher) check() { + sandboxes, err := w.store.List() + if err != nil { + return + } + + now := w.clock.Now() + + // Build set of active sandbox names for cleanup. + active := make(map[string]struct{}, len(sandboxes)) + for _, sb := range sandboxes { + active[sb.Name] = struct{}{} + w.checkExpiration(sb, now) + w.checkAgentCompletion(sb) + } + + // Prune notified entries for sandboxes that no longer exist. + w.mu.Lock() + for name := range w.notified { + if _, ok := active[name]; !ok { + delete(w.notified, name) + } + } + w.mu.Unlock() +} + +func (w *Watcher) checkExpiration(sb sandbox.Info, now time.Time) { + if sb.ExpiresAt == "" { + return + } + expiresAt, err := time.Parse(time.RFC3339, sb.ExpiresAt) + if err != nil { + return + } + + w.mu.Lock() + lastEvent := w.notified[sb.Name] + w.mu.Unlock() + + if now.After(expiresAt) || now.Equal(expiresAt) { + if lastEvent != EventExpired { + w.emit(Event{ + Type: EventExpired, + SandboxName: sb.Name, + Message: fmt.Sprintf("Sandbox %q has expired", sb.Name), + Timestamp: now.Format(time.RFC3339), + }) + w.mu.Lock() + w.notified[sb.Name] = EventExpired + w.mu.Unlock() + } + return + } + + if sb.WarnAt != "" && lastEvent != EventExpirationWarning && lastEvent != EventExpired { + warnAt, err := time.Parse(time.RFC3339, sb.WarnAt) + if err != nil { + return + } + if now.After(warnAt) || now.Equal(warnAt) { + remaining := expiresAt.Sub(now).Truncate(time.Second) + w.emit(Event{ + Type: EventExpirationWarning, + SandboxName: sb.Name, + Message: fmt.Sprintf("Sandbox %q expires in %s", sb.Name, remaining), + Timestamp: now.Format(time.RFC3339), + }) + w.mu.Lock() + w.notified[sb.Name] = EventExpirationWarning + w.mu.Unlock() + } + } +} + +func (w *Watcher) checkAgentCompletion(sb sandbox.Info) { + w.mu.Lock() + lastEvent := w.notified[sb.Name] + w.mu.Unlock() + + if lastEvent == EventAgentCompleted || lastEvent == EventExpired { + return + } + + state, err := w.stateChecker.GetState(sb.Name) + if err != nil { + return + } + if state != "exited" { + return + } + + exitCode, err := w.stateChecker.GetExitCode(sb.Name) + if err != nil { + exitCode = -1 + } + + now := w.clock.Now() + w.emit(Event{ + Type: EventAgentCompleted, + SandboxName: sb.Name, + ExitCode: exitCode, + Message: fmt.Sprintf("Sandbox %q agent finished (exit code: %d)", sb.Name, exitCode), + Timestamp: now.Format(time.RFC3339), + }) + w.mu.Lock() + w.notified[sb.Name] = EventAgentCompleted + w.mu.Unlock() +} + +func (w *Watcher) emit(event Event) { + for _, h := range w.handlers { + h(event) + } +} diff --git a/internal/watcher/watcher_test.go b/internal/watcher/watcher_test.go new file mode 100644 index 00000000..509a8d54 --- /dev/null +++ b/internal/watcher/watcher_test.go @@ -0,0 +1,334 @@ +package watcher + +import ( + "context" + "sync" + "testing" + "time" + + "github.com/gofixpoint/amika/internal/sandbox" +) + +// fakeClock implements Clock with a controllable time. +type fakeClock struct { + mu sync.Mutex + now time.Time +} + +func newFakeClock(t time.Time) *fakeClock { + return &fakeClock{now: t} +} + +func (c *fakeClock) Now() time.Time { + c.mu.Lock() + defer c.mu.Unlock() + return c.now +} + +func (c *fakeClock) Set(t time.Time) { + c.mu.Lock() + defer c.mu.Unlock() + c.now = t +} + +// fakeStore implements sandbox.Store with in-memory data. +type fakeStore struct { + items []sandbox.Info +} + +func (s *fakeStore) Save(info sandbox.Info) error { + for i, it := range s.items { + if it.Name == info.Name { + s.items[i] = info + return nil + } + } + s.items = append(s.items, info) + return nil +} + +func (s *fakeStore) Get(name string) (sandbox.Info, error) { + for _, it := range s.items { + if it.Name == name { + return it, nil + } + } + return sandbox.Info{}, nil +} + +func (s *fakeStore) Remove(name string) error { + var filtered []sandbox.Info + for _, it := range s.items { + if it.Name != name { + filtered = append(filtered, it) + } + } + s.items = filtered + return nil +} + +func (s *fakeStore) List() ([]sandbox.Info, error) { + return s.items, nil +} + +// fakeStateChecker implements StateChecker with configurable per-sandbox state. +type fakeStateChecker struct { + mu sync.Mutex + states map[string]string + exitCode map[string]int +} + +func newFakeStateChecker() *fakeStateChecker { + return &fakeStateChecker{ + states: make(map[string]string), + exitCode: make(map[string]int), + } +} + +func (c *fakeStateChecker) SetState(name, state string, exitCode int) { + c.mu.Lock() + defer c.mu.Unlock() + c.states[name] = state + c.exitCode[name] = exitCode +} + +func (c *fakeStateChecker) GetState(name string) (string, error) { + c.mu.Lock() + defer c.mu.Unlock() + if s, ok := c.states[name]; ok { + return s, nil + } + return "running", nil +} + +func (c *fakeStateChecker) GetExitCode(name string) (int, error) { + c.mu.Lock() + defer c.mu.Unlock() + if code, ok := c.exitCode[name]; ok { + return code, nil + } + return 0, nil +} + +// eventCollector collects emitted events for assertions. +type eventCollector struct { + mu sync.Mutex + events []Event +} + +func (c *eventCollector) handler(e Event) { + c.mu.Lock() + defer c.mu.Unlock() + c.events = append(c.events, e) +} + +func (c *eventCollector) get() []Event { + c.mu.Lock() + defer c.mu.Unlock() + cp := make([]Event, len(c.events)) + copy(cp, c.events) + return cp +} + +func TestExpirationWarning(t *testing.T) { + baseTime := time.Date(2026, 1, 1, 12, 0, 0, 0, time.UTC) + clock := newFakeClock(baseTime) + checker := newFakeStateChecker() + checker.SetState("test-sb", "running", 0) + + store := &fakeStore{ + items: []sandbox.Info{{ + Name: "test-sb", + Provider: "docker", + CreatedAt: baseTime.Format(time.RFC3339), + ExpiresAt: baseTime.Add(30 * time.Minute).Format(time.RFC3339), + WarnAt: baseTime.Add(20 * time.Minute).Format(time.RFC3339), + }}, + } + + collector := &eventCollector{} + w := New(Options{ + Store: store, + StateChecker: checker, + Clock: clock, + Interval: 50 * time.Millisecond, + Handlers: []Handler{collector.handler}, + }) + + ctx, cancel := context.WithCancel(context.Background()) + go w.Run(ctx) + + // Before warn time: no events. + time.Sleep(100 * time.Millisecond) + events := collector.get() + if len(events) != 0 { + t.Fatalf("expected 0 events before warn time, got %d", len(events)) + } + + // Advance to warn time. + clock.Set(baseTime.Add(20 * time.Minute)) + time.Sleep(150 * time.Millisecond) + + events = collector.get() + if len(events) != 1 { + t.Fatalf("expected 1 warning event, got %d", len(events)) + } + if events[0].Type != EventExpirationWarning { + t.Fatalf("expected EventExpirationWarning, got %s", events[0].Type) + } + + cancel() +} + +func TestExpired(t *testing.T) { + baseTime := time.Date(2026, 1, 1, 12, 0, 0, 0, time.UTC) + clock := newFakeClock(baseTime.Add(31 * time.Minute)) // already past expiry + checker := newFakeStateChecker() + checker.SetState("test-sb", "running", 0) + + store := &fakeStore{ + items: []sandbox.Info{{ + Name: "test-sb", + Provider: "docker", + CreatedAt: baseTime.Format(time.RFC3339), + ExpiresAt: baseTime.Add(30 * time.Minute).Format(time.RFC3339), + WarnAt: baseTime.Add(20 * time.Minute).Format(time.RFC3339), + }}, + } + + collector := &eventCollector{} + w := New(Options{ + Store: store, + StateChecker: checker, + Clock: clock, + Interval: 50 * time.Millisecond, + Handlers: []Handler{collector.handler}, + }) + + ctx, cancel := context.WithCancel(context.Background()) + go w.Run(ctx) + time.Sleep(100 * time.Millisecond) + cancel() + + events := collector.get() + if len(events) != 1 { + t.Fatalf("expected 1 expired event, got %d", len(events)) + } + if events[0].Type != EventExpired { + t.Fatalf("expected EventExpired, got %s", events[0].Type) + } +} + +func TestAgentCompleted(t *testing.T) { + baseTime := time.Date(2026, 1, 1, 12, 0, 0, 0, time.UTC) + clock := newFakeClock(baseTime) + checker := newFakeStateChecker() + checker.SetState("test-sb", "exited", 0) + + store := &fakeStore{ + items: []sandbox.Info{{ + Name: "test-sb", + Provider: "docker", + CreatedAt: baseTime.Format(time.RFC3339), + }}, + } + + collector := &eventCollector{} + w := New(Options{ + Store: store, + StateChecker: checker, + Clock: clock, + Interval: 50 * time.Millisecond, + Handlers: []Handler{collector.handler}, + }) + + ctx, cancel := context.WithCancel(context.Background()) + go w.Run(ctx) + time.Sleep(100 * time.Millisecond) + cancel() + + events := collector.get() + if len(events) != 1 { + t.Fatalf("expected 1 agent completed event, got %d", len(events)) + } + if events[0].Type != EventAgentCompleted { + t.Fatalf("expected EventAgentCompleted, got %s", events[0].Type) + } + if events[0].ExitCode != 0 { + t.Fatalf("expected exit code 0, got %d", events[0].ExitCode) + } +} + +func TestNoDuplicateNotifications(t *testing.T) { + baseTime := time.Date(2026, 1, 1, 12, 0, 0, 0, time.UTC) + clock := newFakeClock(baseTime.Add(31 * time.Minute)) + checker := newFakeStateChecker() + checker.SetState("test-sb", "exited", 1) + + store := &fakeStore{ + items: []sandbox.Info{{ + Name: "test-sb", + Provider: "docker", + CreatedAt: baseTime.Format(time.RFC3339), + ExpiresAt: baseTime.Add(30 * time.Minute).Format(time.RFC3339), + WarnAt: baseTime.Add(20 * time.Minute).Format(time.RFC3339), + }}, + } + + collector := &eventCollector{} + w := New(Options{ + Store: store, + StateChecker: checker, + Clock: clock, + Interval: 50 * time.Millisecond, + Handlers: []Handler{collector.handler}, + }) + + ctx, cancel := context.WithCancel(context.Background()) + go w.Run(ctx) + // Let multiple poll cycles run. + time.Sleep(300 * time.Millisecond) + cancel() + + events := collector.get() + // Should get exactly 1 expired event (expiry takes precedence over agent completed + // since expired is checked first and blocks further events for that sandbox). + if len(events) != 1 { + t.Fatalf("expected exactly 1 event (no duplicates), got %d: %+v", len(events), events) + } +} + +func TestNoExpirationNoEvents(t *testing.T) { + baseTime := time.Date(2026, 1, 1, 12, 0, 0, 0, time.UTC) + clock := newFakeClock(baseTime) + checker := newFakeStateChecker() + checker.SetState("test-sb", "running", 0) + + store := &fakeStore{ + items: []sandbox.Info{{ + Name: "test-sb", + Provider: "docker", + CreatedAt: baseTime.Format(time.RFC3339), + // No ExpiresAt — legacy sandbox + }}, + } + + collector := &eventCollector{} + w := New(Options{ + Store: store, + StateChecker: checker, + Clock: clock, + Interval: 50 * time.Millisecond, + Handlers: []Handler{collector.handler}, + }) + + ctx, cancel := context.WithCancel(context.Background()) + go w.Run(ctx) + time.Sleep(150 * time.Millisecond) + cancel() + + events := collector.get() + if len(events) != 0 { + t.Fatalf("expected 0 events for sandbox without TTL, got %d", len(events)) + } +} diff --git a/pkg/amika/requests.go b/pkg/amika/requests.go index 84afba27..b6a474ba 100644 --- a/pkg/amika/requests.go +++ b/pkg/amika/requests.go @@ -33,6 +33,8 @@ type CreateSandboxRequest struct { SetupScript string `json:"SetupScript,omitempty"` SetupScriptText string `json:"SetupScriptText,omitempty"` Branch string `json:"Branch,omitempty"` + TTL string `json:"TTL,omitempty"` // duration string, e.g. "2h", "30m" + WarnBefore string `json:"WarnBefore,omitempty"` // duration before expiry to warn, default "10m" } // DeleteSandboxRequest describes sandbox deletion input. diff --git a/pkg/amika/responses.go b/pkg/amika/responses.go index bdd45691..8b9ea952 100644 --- a/pkg/amika/responses.go +++ b/pkg/amika/responses.go @@ -8,6 +8,8 @@ type Sandbox struct { ContainerID string Image string CreatedAt string + ExpiresAt string `json:"ExpiresAt,omitempty"` + WarnAt string `json:"WarnAt,omitempty"` Preset string Location string // "local" or "remote" Branch string diff --git a/pkg/amika/service.go b/pkg/amika/service.go index 171b7a9c..c1522672 100644 --- a/pkg/amika/service.go +++ b/pkg/amika/service.go @@ -84,6 +84,13 @@ func (s *serviceImpl) CreateSandbox(_ context.Context, req CreateSandboxRequest) if req.SetupScript != "" && req.SetupScriptText != "" { return Sandbox{}, fmt.Errorf("%w: SetupScript and SetupScriptText are mutually exclusive", ErrInvalidArgument) } + + // Calculate expiration timestamps from TTL. + ttlResult, err := sandbox.ComputeTTL(req.TTL, req.WarnBefore, time.Now().UTC()) + if err != nil { + return Sandbox{}, fmt.Errorf("%w: %v", ErrInvalidArgument, err) + } + expiresAt, warnAt := ttlResult.ExpiresAt, ttlResult.WarnAt ports, err := normalizePortBindings(req.Ports) if err != nil { return Sandbox{}, err @@ -166,6 +173,8 @@ func (s *serviceImpl) CreateSandbox(_ context.Context, req CreateSandboxRequest) ContainerID: containerID, Image: req.Image, CreatedAt: time.Now().UTC().Format(time.RFC3339), + ExpiresAt: expiresAt, + WarnAt: warnAt, Preset: req.Preset, Mounts: mounts, Env: req.Env, @@ -182,6 +191,8 @@ func (s *serviceImpl) CreateSandbox(_ context.Context, req CreateSandboxRequest) ContainerID: info.ContainerID, Image: info.Image, CreatedAt: info.CreatedAt, + ExpiresAt: info.ExpiresAt, + WarnAt: info.WarnAt, Preset: info.Preset, Branch: info.Branch, Mounts: toMounts(info.Mounts), @@ -247,6 +258,8 @@ func (s *serviceImpl) ListSandboxes(context.Context, ListSandboxesRequest) (List ContainerID: it.ContainerID, Image: it.Image, CreatedAt: it.CreatedAt, + ExpiresAt: it.ExpiresAt, + WarnAt: it.WarnAt, Preset: it.Preset, Branch: it.Branch, Mounts: toMounts(it.Mounts), diff --git a/resolve_client.py b/resolve_client.py new file mode 100644 index 00000000..a4317dbc --- /dev/null +++ b/resolve_client.py @@ -0,0 +1,23 @@ +import sys +import re + +with open("internal/apiclient/client.go", "r") as f: + content = f.read() + +# For the CreateSandboxRequest conflict, keep HEAD (which has TTL, WarnBefore, ClaudeCredentialName) +def resolve_struct(match): + head = match.group(1) + return head + +content = re.sub(r'<<<<<<< HEAD\n(.*?ClaudeCredentialName.*?)=======\n.*?\n>>>>>>> upstream/main\n', resolve_struct, content, flags=re.DOTALL) + +# For all the other conflicts (which are about doJSON calls with apiBasePath), use upstream/main +def resolve_apipath(match): + main = match.group(2) + return main + +content = re.sub(r'<<<<<<< HEAD\n(.*?)\n=======\n(.*?)\n>>>>>>> upstream/main\n', resolve_apipath, content, flags=re.DOTALL) + +with open("internal/apiclient/client.go", "w") as f: + f.write(content) +