From 4c559e9eb9dd570c08fd93948df1f4764ef47152 Mon Sep 17 00:00:00 2001 From: Dylan Mikus Date: Sun, 9 Aug 2026 22:14:25 -0400 Subject: [PATCH 1/3] Add sandbox codev2 command --- docs/cli-reference.md | 26 +++++- go/cmd/amika/sandbox/command.go | 3 + go/cmd/amika/sandbox/doc.go | 1 + go/cmd/amika/sandbox/sandbox_ssh.go | 69 +++++++------- go/cmd/amika/sandbox/sandbox_ssh_test.go | 111 +++++++++-------------- go/cmd/amika/sandbox/sandbox_ssh_v2.go | 82 +++++++++++++++++ 6 files changed, 186 insertions(+), 106 deletions(-) diff --git a/docs/cli-reference.md b/docs/cli-reference.md index a3cc062b..bdb74dd9 100644 --- a/docs/cli-reference.md +++ b/docs/cli-reference.md @@ -25,7 +25,7 @@ amika sandbox list --remote -o json | jq '.[].name' amika snapshot list -o json-pretty ``` -Most list commands emit a JSON array (empty as `[]`, never `null`); `snapshot list` is the exception and emits a `{ "items": [...] }` envelope to match the API's `ListSandboxSnapshotsResponse`. Mutating commands emit a JSON result object or a per-item result array. Because JSON output cannot be interrupted by an interactive prompt, in JSON mode the CLI never prompts: destructive commands require their confirmation flag (`--force` for deletes, `--yes` for `sandbox create` mounts, `--no-interactive` for `snapshot create`), and commands that would open a shell or editor (`sandbox connect`, `sandbox code`, interactive `sandbox ssh`, `sandbox create --connect`, `auth login` without `--api-key-file`) refuse `-o json`. Human-readable progress and any subprocess output go to stderr so stdout carries only the JSON value. +Most list commands emit a JSON array (empty as `[]`, never `null`); `snapshot list` is the exception and emits a `{ "items": [...] }` envelope to match the API's `ListSandboxSnapshotsResponse`. Mutating commands emit a JSON result object or a per-item result array. Because JSON output cannot be interrupted by an interactive prompt, in JSON mode the CLI never prompts: destructive commands require their confirmation flag (`--force` for deletes, `--yes` for `sandbox create` mounts, `--no-interactive` for `snapshot create`), and commands that would open a shell or editor (`sandbox connect`, `sandbox code`, `sandbox codev2`, interactive `sandbox ssh`, `sandbox create --connect`, `auth login` without `--api-key-file`) refuse `-o json`. Human-readable progress and any subprocess output go to stderr so stdout carries only the JSON value. ```bash # Create a sandbox and capture its name for a script @@ -35,7 +35,7 @@ name=$(amika sandbox create --remote --no-git -o json | jq -r .name) amika sandbox delete a b c --remote --force -o json | jq '.[] | select(.status=="error")' ``` -Commands honoring `--output`: the read commands `sandbox list`, `snapshot list`, `volume list`, `service list`, `auth status`, and `secret list`, plus `sandbox create`, `sandbox start`, `sandbox stop`, `sandbox delete`, `sandbox agent-send`, `volume delete`, `snapshot create`, `snapshot delete`, `secret push`/`delete`, `auth login --api-key-file`, `auth logout`, and `materialize`. Commands that open a shell or editor (`sandbox connect`, `sandbox code`) or display a masked credential table and prompt for confirmation (`secret extract`, `secret push`) reject `-o json`/`json-pretty` since they produce no JSON result. `sandbox ssh` and `scp` do not accept `--output` at all (see above). +Commands honoring `--output`: the read commands `sandbox list`, `snapshot list`, `volume list`, `service list`, `auth status`, and `secret list`, plus `sandbox create`, `sandbox start`, `sandbox stop`, `sandbox delete`, `sandbox agent-send`, `volume delete`, `snapshot create`, `snapshot delete`, `secret push`/`delete`, `auth login --api-key-file`, `auth logout`, and `materialize`. Commands that open a shell or editor (`sandbox connect`, `sandbox code`, `sandbox codev2`) or display a masked credential table and prompt for confirmation (`secret extract`, `secret push`) reject `-o json`/`json-pretty` since they produce no JSON result. `sandbox ssh` and `scp` do not accept `--output` at all (see above). ## `amika sandbox` @@ -43,7 +43,7 @@ Manage Docker-backed persistent sandboxes with bind mounts and named volumes. ### Global sandbox flags -These persistent flags apply to all `sandbox` subcommands (`create`, `list`, `connect`, `stop`, `start`, `delete`, `ssh`, `code`, `agent-send`): +These persistent flags apply to all `sandbox` subcommands (`create`, `list`, `connect`, `stop`, `start`, `delete`, `ssh`, `code`, `codev2`, `agent-send`): | Flag | Default | Description | | ---------- | ------- | -------------------------------- | @@ -285,6 +285,26 @@ amika sandbox code my-sandbox --editor=codex | `--editor ` | `cursor` | Editor or agent to open: `cursor`, `claude`, or `codex` | | `--path ` | — | Override the remote path to open (absolute, or relative to the sandbox workspace root) | +### `amika sandbox codev2` + +Open a sandbox in the same supported editors as `sandbox code`, but use the +beta direct WebSocket SSH transport instead of provider-native SSH. Use it when +the provider route is unavailable or when you want the direct transport used by +`sandbox sshv2` and `amika scpv2`. + +`codev2` creates a managed SSH alias with an Amika `ProxyCommand`, then starts +or configures Cursor, Claude Desktop, or Codex exactly as `sandbox code` does. +It requires an SSH identity created or imported with `amika secret ssh-keygen`. + +```bash +amika sandbox codev2 my-sandbox +amika sandbox codev2 my-sandbox --editor=cursor +amika sandbox codev2 my-sandbox --editor=claude +amika sandbox codev2 my-sandbox --editor=codex +``` + +`--editor` and `--path` have the same values and defaults as `sandbox code`. + ### `amika sandbox agent-send` 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. diff --git a/go/cmd/amika/sandbox/command.go b/go/cmd/amika/sandbox/command.go index b92d8bf6..1b6cd772 100644 --- a/go/cmd/amika/sandbox/command.go +++ b/go/cmd/amika/sandbox/command.go @@ -26,6 +26,7 @@ func New() *cobra.Command { sandboxCmd.AddCommand(sandboxSSHCmd) sandboxCmd.AddCommand(sandboxSSHV2Cmd) sandboxCmd.AddCommand(sandboxCodeCmd) + sandboxCmd.AddCommand(sandboxCodeV2Cmd) sandboxCmd.AddCommand(sandboxAgentSendCmd) sandboxCmd.PersistentFlags().Bool("local", false, "Only operate on local sandboxes") @@ -69,6 +70,8 @@ func New() *cobra.Command { sandboxSSHV2Cmd.Flags().BoolP("t", "t", false, "Force pseudo-terminal allocation (like ssh -t)") sandboxCodeCmd.Flags().String("editor", "cursor", "Editor or agent to open: \"cursor\", \"claude\", or \"codex\"") sandboxCodeCmd.Flags().String("path", "", "Override the remote path to open (absolute, or relative to the sandbox workspace root)") + sandboxCodeV2Cmd.Flags().String("editor", "cursor", "Editor or agent to open: \"cursor\", \"claude\", or \"codex\"") + sandboxCodeV2Cmd.Flags().String("path", "", "Override the remote path to open (absolute, or relative to the sandbox workspace root)") 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\")") diff --git a/go/cmd/amika/sandbox/doc.go b/go/cmd/amika/sandbox/doc.go index 6bf182cc..86f1e3c3 100644 --- a/go/cmd/amika/sandbox/doc.go +++ b/go/cmd/amika/sandbox/doc.go @@ -12,6 +12,7 @@ // - delete // - ssh // - code +// - codev2 // - agent-send // // It also owns sandbox-specific flag parsing, local and remote execution diff --git a/go/cmd/amika/sandbox/sandbox_ssh.go b/go/cmd/amika/sandbox/sandbox_ssh.go index 4589e644..4bc07e68 100644 --- a/go/cmd/amika/sandbox/sandbox_ssh.go +++ b/go/cmd/amika/sandbox/sandbox_ssh.go @@ -184,15 +184,11 @@ Examples: pathOverride, _ := cmd.Flags().GetString("path") paths := basedir.New("") - switch editor { - case "cursor": - return openSandboxInCursor(cmd, client, paths, name, pathOverride) - case "claude": - return openSandboxInClaude(cmd, client, paths, name, pathOverride) - case "codex": - return openSandboxInCodex(cmd, client, paths, name, pathOverride) + sshTarget, err := resolveSandboxSSHAlias(client, paths, name) + if err != nil { + return err } - return nil + return openSandboxInEditor(cmd, editor, paths, sshTarget, pathOverride) }, } @@ -248,24 +244,35 @@ func resolveSandboxSSHAlias(client sshInfoClient, paths basedir.Paths, name stri return sandboxSSHAlias{alias: alias, sandboxName: sandboxName, repoName: info.RepoName}, nil } -// openSandboxInCursor launches Cursor connected to the sandbox over SSH. -func openSandboxInCursor(cmd *cobra.Command, client sshInfoClient, paths basedir.Paths, name, pathOverride string) error { +// openSandboxInEditor starts the selected editor with a prepared SSH target. +func openSandboxInEditor(cmd *cobra.Command, editor string, paths basedir.Paths, target sandboxSSHAlias, pathOverride string) error { + switch editor { + case "cursor": + return openSandboxInCursorTarget(cmd, target, pathOverride) + case "claude": + return openSandboxInClaudeTarget(cmd, paths, target, pathOverride) + case "codex": + return openSandboxInCodexTarget(cmd, paths, target, pathOverride) + default: + return fmt.Errorf("unsupported editor %q", editor) + } +} + +// openSandboxInCursor launches Cursor connected to a prepared SSH target. +func openSandboxInCursorTarget(cmd *cobra.Command, target sandboxSSHAlias, pathOverride string) error { 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 := prepareCursorSSHTarget(client, paths, name, pathOverride) - if err != nil { - return err - } + remotePath := resolveRemoteWorkspacePath(target.repoName, pathOverride) - cursorCmd := exec.Command("cursor", "--remote", "ssh-remote+"+target.alias, target.remotePath) + cursorCmd := exec.Command("cursor", "--remote", "ssh-remote+"+target.alias, 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, target.alias) - fmt.Fprintf(cmd.OutOrStdout(), "Running: cursor --remote ssh-remote+%s %s\n", target.alias, target.remotePath) + fmt.Fprintf(cmd.OutOrStdout(), "Opening sandbox %q in Cursor via SSH (%s)...\n", target.sandboxName, target.alias) + fmt.Fprintf(cmd.OutOrStdout(), "Running: cursor --remote ssh-remote+%s %s\n", target.alias, 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 { return fmt.Errorf("cursor failed: %w\n\nMake sure the \"Remote - SSH\" extension is installed in Cursor", err) @@ -282,7 +289,11 @@ func openSandboxInClaude(cmd *cobra.Command, client sshInfoClient, paths basedir if err != nil { return err } + return openSandboxInClaudeTarget(cmd, paths, target, pathOverride) +} +// openSandboxInClaudeTarget registers a prepared SSH target in Claude Desktop. +func openSandboxInClaudeTarget(cmd *cobra.Command, paths basedir.Paths, target sandboxSSHAlias, pathOverride string) error { host := appcfg.ClaudeSSHHost{ ID: target.alias, Name: "Amika: " + target.sandboxName, @@ -294,7 +305,7 @@ func openSandboxInClaude(cmd *cobra.Command, client sshInfoClient, paths basedir } out := cmd.OutOrStdout() - fmt.Fprintf(out, "Registered SSH environment %q for sandbox %q in Claude Desktop.\n", host.Name, name) + fmt.Fprintf(out, "Registered SSH environment %q for sandbox %q in Claude Desktop.\n", host.Name, target.sandboxName) if err := openApp("claude://code/new"); err != nil { fmt.Fprintf(out, "Could not launch Claude Desktop automatically (%v); open it yourself.\n", err) } else { @@ -313,13 +324,17 @@ func openSandboxInCodex(cmd *cobra.Command, client sshInfoClient, paths basedir. if err != nil { return err } + return openSandboxInCodexTarget(cmd, paths, target, pathOverride) +} +// openSandboxInCodexTarget enables Codex remote connections for a prepared SSH target. +func openSandboxInCodexTarget(cmd *cobra.Command, paths basedir.Paths, target sandboxSSHAlias, pathOverride string) error { if _, err := appcfg.EnableCodexRemoteConnections(paths); err != nil { return fmt.Errorf("enable Codex remote connections: %w", err) } out := cmd.OutOrStdout() - fmt.Fprintf(out, "Enabled Codex remote connections; SSH host %q for sandbox %q is available from ~/.ssh/config.\n", target.alias, name) + fmt.Fprintf(out, "Enabled Codex remote connections; SSH host %q for sandbox %q is available from ~/.ssh/config.\n", target.alias, target.sandboxName) if err := openApp("codex://"); err != nil { fmt.Fprintf(out, "Could not launch Codex automatically (%v); open it yourself.\n", err) } else { @@ -330,22 +345,6 @@ func openSandboxInCodex(cmd *cobra.Command, client sshInfoClient, paths basedir. return nil } -type cursorSSHTarget struct { - alias string - remotePath string -} - -func prepareCursorSSHTarget(client sshInfoClient, paths basedir.Paths, name string, pathOverride string) (cursorSSHTarget, error) { - target, err := resolveSandboxSSHAlias(client, paths, name) - if err != nil { - return cursorSSHTarget{}, err - } - return cursorSSHTarget{ - alias: target.alias, - remotePath: resolveRemoteWorkspacePath(target.repoName, pathOverride), - }, nil -} - // resolveRemoteWorkspacePath computes the remote path to open in the editor. // An absolute pathOverride is used verbatim; a relative one is joined onto // /home/amika so that e.g. "workspace/biz" → "/home/amika/workspace/biz". diff --git a/go/cmd/amika/sandbox/sandbox_ssh_test.go b/go/cmd/amika/sandbox/sandbox_ssh_test.go index 61b07f7f..6713614e 100644 --- a/go/cmd/amika/sandbox/sandbox_ssh_test.go +++ b/go/cmd/amika/sandbox/sandbox_ssh_test.go @@ -10,6 +10,7 @@ import ( "github.com/BurntSushi/toml" "github.com/gofixpoint/amika/go/internal/apiclient" "github.com/gofixpoint/amika/go/internal/basedir" + "github.com/gofixpoint/amika/go/internal/ssh" "github.com/spf13/cobra" ) @@ -91,6 +92,21 @@ type stubSSHClient struct { sandbox *apiclient.RemoteSandbox } +// stubV2SSHClient implements the APIs codev2 uses before it hands the prepared +// alias to an editor. Its session method is not reached by this test because +// prepareSessionTarget is replaced with a recorder. +type stubV2SSHClient struct { + sandbox *apiclient.RemoteSandbox +} + +func (s *stubV2SSHClient) GetSandbox(_ string) (*apiclient.RemoteSandbox, error) { + return s.sandbox, nil +} + +func (s *stubV2SSHClient) CreateSSHSession(_ string) (*apiclient.SSHSession, error) { + return nil, nil +} + func (s *stubSSHClient) GetSSH(_ string) (*apiclient.SSHInfo, error) { return s.info, nil } @@ -242,75 +258,34 @@ func TestOpenSandboxInCodex(t *testing.T) { } } -func TestPrepareCursorSSHTarget(t *testing.T) { - tests := []struct { - name string - info *apiclient.SSHInfo - pathOverride string - wantAlias string - wantPath string - }{ - { - name: "default path with repo", - info: &apiclient.SSHInfo{ - SSHDestination: "-p 2222 tok@ssh.app.daytona.io", - SandboxID: "sb_abc", - SandboxName: "my-sandbox", - RepoName: "biz", - }, - wantAlias: "amika-sb_abc", - wantPath: "/home/amika/workspace/biz", - }, - { - name: "default path without repo", - info: &apiclient.SSHInfo{ - SSHDestination: "-p 2222 tok@ssh.app.daytona.io", - SandboxID: "sb_abc", - SandboxName: "my-sandbox", - }, - wantAlias: "amika-sb_abc", - wantPath: "/home/amika/workspace", - }, - { - name: "relative path override", - info: &apiclient.SSHInfo{ - SSHDestination: "-p 2222 tok@ssh.app.daytona.io", - SandboxID: "sb_abc", - SandboxName: "my-sandbox", - RepoName: "biz", - }, - pathOverride: "workspace/biz", - wantAlias: "amika-sb_abc", - wantPath: "/home/amika/workspace/biz", - }, - { - name: "absolute path override", - info: &apiclient.SSHInfo{ - SSHDestination: "-p 2222 tok@ssh.app.daytona.io", - SandboxID: "sb_abc", - SandboxName: "my-sandbox", - RepoName: "biz", - }, - pathOverride: "/custom/path", - wantAlias: "amika-sb_abc", - wantPath: "/custom/path", - }, +func TestResolveSandboxV2SSHAliasUsesSharedSessionPreparation(t *testing.T) { + paths, _ := testSSHPaths(t) + repoName := "biz" + client := &stubV2SSHClient{sandbox: &apiclient.RemoteSandbox{ + ID: "sb_abc", + Name: "my-sandbox", + RepoName: &repoName, + }} + + previous := prepareSessionTarget + var gotName, gotID string + prepareSessionTarget = func(gotPaths basedir.Paths, _ ssh.SessionCreator, name, id string) (string, error) { + if gotPaths != paths { + t.Fatalf("paths = %#v, want %#v", gotPaths, paths) + } + gotName, gotID = name, id + return "my-sandbox.sb_abc.app-amika-dev.amika", nil } + t.Cleanup(func() { prepareSessionTarget = previous }) - for _, tt := range tests { - t.Run(tt.name, func(t *testing.T) { - client := &stubSSHClient{info: tt.info} - paths, _ := testSSHPaths(t) - got, err := prepareCursorSSHTarget(client, paths, "my-sandbox", tt.pathOverride) - if err != nil { - t.Fatalf("prepareCursorSSHTarget: %v", err) - } - if got.alias != tt.wantAlias { - t.Errorf("alias = %q, want %q", got.alias, tt.wantAlias) - } - if got.remotePath != tt.wantPath { - t.Errorf("remotePath = %q, want %q", got.remotePath, tt.wantPath) - } - }) + target, err := resolveSandboxV2SSHAlias(client, paths, "lookup-name") + if err != nil { + t.Fatalf("resolveSandboxV2SSHAlias: %v", err) + } + if gotName != "my-sandbox" || gotID != "sb_abc" { + t.Fatalf("PrepareSessionTarget(%q, %q), want sandbox identity", gotName, gotID) + } + if target.alias != "my-sandbox.sb_abc.app-amika-dev.amika" || target.sandboxName != "my-sandbox" || target.repoName != "biz" { + t.Fatalf("target = %#v", target) } } diff --git a/go/cmd/amika/sandbox/sandbox_ssh_v2.go b/go/cmd/amika/sandbox/sandbox_ssh_v2.go index 4fbef6d2..d2f3d1b1 100644 --- a/go/cmd/amika/sandbox/sandbox_ssh_v2.go +++ b/go/cmd/amika/sandbox/sandbox_ssh_v2.go @@ -3,6 +3,7 @@ package sandboxcmd import ( "fmt" + "github.com/gofixpoint/amika/go/internal/apiclient" "github.com/gofixpoint/amika/go/internal/basedir" "github.com/gofixpoint/amika/go/internal/output" "github.com/gofixpoint/amika/go/internal/runmode" @@ -49,3 +50,84 @@ var sandboxSSHV2Cmd = &cobra.Command{ return ssh.ExecSessionSSH(alias, forcePTY, args[1:]) }, } + +var sandboxCodeV2Cmd = &cobra.Command{ + Use: "codev2 ", + Short: "Open a remote sandbox in an editor over the beta direct WebSocket transport", + Long: `Open a remote sandbox in an editor or coding agent over the beta direct +WebSocket SSH transport. It supports the same editors and flags as "sandbox code", +but bypasses provider-native SSH access. + +The command creates a managed SSH alias backed by Amika's WebSocket proxy, then +hands that alias to the selected editor. It requires an SSH identity from +"amika secret ssh-keygen". + +Examples: + amika sandbox codev2 my-sandbox + amika sandbox codev2 my-sandbox --editor=cursor + amika sandbox codev2 my-sandbox --editor=claude + amika sandbox codev2 my-sandbox --editor=codex`, + Args: cobra.ExactArgs(1), + RunE: func(cmd *cobra.Command, args []string) error { + if err := output.RejectJSON(cmd); err != nil { + return err + } + editor, _ := cmd.Flags().GetString("editor") + if err := validateEditor(editor); err != nil { + return err + } + if runmode.Resolve(cmd) == runmode.Local { + return fmt.Errorf("direct WebSocket SSH requires a remote sandbox") + } + if err := runmode.RequireAuth(runmode.Remote, runmode.DefaultAuthChecker); err != nil { + return err + } + + target, err := getRemoteTarget(cmd) + if err != nil { + return err + } + client, err := getRemoteClient(target) + if err != nil { + return err + } + paths := basedir.New("") + sshTarget, err := resolveSandboxV2SSHAlias(client, paths, args[0]) + if err != nil { + return err + } + pathOverride, _ := cmd.Flags().GetString("path") + return openSandboxInEditor(cmd, editor, paths, sshTarget, pathOverride) + }, +} + +// sshV2Client is the subset of apiclient.Client codev2 needs to prepare a +// direct-session SSH alias for an editor. +type sshV2Client interface { + ssh.SessionCreator + GetSandbox(name string) (*apiclient.RemoteSandbox, error) +} + +// prepareSessionTarget is a seam around the shared v2 setup used by codev2, +// sshv2, and scpv2. Keeping it replaceable lets the command package verify its +// alias handoff without re-testing the session package's key and pinning logic. +var prepareSessionTarget = ssh.PrepareSessionTarget + +// resolveSandboxV2SSHAlias prepares the same direct-session alias used by +// sshv2 and scpv2. Editors subsequently use system OpenSSH, which invokes the +// managed alias's ProxyCommand once it dials the host. +func resolveSandboxV2SSHAlias(client sshV2Client, paths basedir.Paths, name string) (sandboxSSHAlias, error) { + sandbox, err := client.GetSandbox(name) + if err != nil { + return sandboxSSHAlias{}, err + } + alias, err := prepareSessionTarget(paths, client, sandbox.Name, sandbox.ID) + if err != nil { + return sandboxSSHAlias{}, err + } + repoName := "" + if sandbox.RepoName != nil { + repoName = *sandbox.RepoName + } + return sandboxSSHAlias{alias: alias, sandboxName: sandbox.Name, repoName: repoName}, nil +} From c2822b62709cd6f0460ab8a5d8d3cb91101b924f Mon Sep 17 00:00:00 2001 From: Dylan Mikus Date: Sun, 9 Aug 2026 22:34:14 -0400 Subject: [PATCH 2/3] Expose codev2 aliases to Codex --- docs/cli-reference.md | 15 +++-- go/cmd/amika/sandbox/sandbox_ssh_test.go | 17 ++++- go/cmd/amika/sandbox/sandbox_ssh_v2.go | 8 +++ go/internal/ssh/config.go | 80 +++++++++++++++++++++++- go/internal/ssh/config_test.go | 45 +++++++++++++ 5 files changed, 158 insertions(+), 7 deletions(-) diff --git a/docs/cli-reference.md b/docs/cli-reference.md index bdb74dd9..35680c5d 100644 --- a/docs/cli-reference.md +++ b/docs/cli-reference.md @@ -290,11 +290,16 @@ amika sandbox code my-sandbox --editor=codex Open a sandbox in the same supported editors as `sandbox code`, but use the beta direct WebSocket SSH transport instead of provider-native SSH. Use it when the provider route is unavailable or when you want the direct transport used by -`sandbox sshv2` and `amika scpv2`. - -`codev2` creates a managed SSH alias with an Amika `ProxyCommand`, then starts -or configures Cursor, Claude Desktop, or Codex exactly as `sandbox code` does. -It requires an SSH identity created or imported with `amika secret ssh-keygen`. +`sandbox sshv2` and `amika scpv2`. It works with remote sandboxes only and +requires Amika authentication. + +`codev2` writes a concrete managed SSH alias so Codex can discover it, while +its connection settings use an Amika `ProxyCommand`. It then starts or +configures Cursor, Claude Desktop, or Codex exactly as `sandbox code` does. +Before first use, run `amika secret ssh-keygen` to generate and upload an SSH +key, or pass `--import ` to use an existing key. As with +`sandbox code`, Claude and Codex require +`AMIKA_OPEN_CLAUDE_CODEX_SUPPORT=true`; Cursor is available by default. ```bash amika sandbox codev2 my-sandbox diff --git a/go/cmd/amika/sandbox/sandbox_ssh_test.go b/go/cmd/amika/sandbox/sandbox_ssh_test.go index 6713614e..3cbcb26d 100644 --- a/go/cmd/amika/sandbox/sandbox_ssh_test.go +++ b/go/cmd/amika/sandbox/sandbox_ssh_test.go @@ -268,7 +268,9 @@ func TestResolveSandboxV2SSHAliasUsesSharedSessionPreparation(t *testing.T) { }} previous := prepareSessionTarget + previousUpsert := upsertSessionHost var gotName, gotID string + var gotAlias string prepareSessionTarget = func(gotPaths basedir.Paths, _ ssh.SessionCreator, name, id string) (string, error) { if gotPaths != paths { t.Fatalf("paths = %#v, want %#v", gotPaths, paths) @@ -276,7 +278,17 @@ func TestResolveSandboxV2SSHAliasUsesSharedSessionPreparation(t *testing.T) { gotName, gotID = name, id return "my-sandbox.sb_abc.app-amika-dev.amika", nil } - t.Cleanup(func() { prepareSessionTarget = previous }) + upsertSessionHost = func(gotPaths basedir.Paths, alias string) error { + if gotPaths != paths { + t.Fatalf("paths = %#v, want %#v", gotPaths, paths) + } + gotAlias = alias + return nil + } + t.Cleanup(func() { + prepareSessionTarget = previous + upsertSessionHost = previousUpsert + }) target, err := resolveSandboxV2SSHAlias(client, paths, "lookup-name") if err != nil { @@ -285,6 +297,9 @@ func TestResolveSandboxV2SSHAliasUsesSharedSessionPreparation(t *testing.T) { if gotName != "my-sandbox" || gotID != "sb_abc" { t.Fatalf("PrepareSessionTarget(%q, %q), want sandbox identity", gotName, gotID) } + if gotAlias != "my-sandbox.sb_abc.app-amika-dev.amika" { + t.Fatalf("UpsertSessionHost(%q), want prepared alias", gotAlias) + } if target.alias != "my-sandbox.sb_abc.app-amika-dev.amika" || target.sandboxName != "my-sandbox" || target.repoName != "biz" { t.Fatalf("target = %#v", target) } diff --git a/go/cmd/amika/sandbox/sandbox_ssh_v2.go b/go/cmd/amika/sandbox/sandbox_ssh_v2.go index d2f3d1b1..53a0ae9a 100644 --- a/go/cmd/amika/sandbox/sandbox_ssh_v2.go +++ b/go/cmd/amika/sandbox/sandbox_ssh_v2.go @@ -113,6 +113,11 @@ type sshV2Client interface { // alias handoff without re-testing the session package's key and pinning logic. var prepareSessionTarget = ssh.PrepareSessionTarget +// upsertSessionHost makes a concrete v2 alias discoverable to editors that +// enumerate SSH Host entries, notably Codex. Cursor and Claude receive their +// aliases directly, so this extra persistent entry is specific to codev2. +var upsertSessionHost = ssh.UpsertSessionHost + // resolveSandboxV2SSHAlias prepares the same direct-session alias used by // sshv2 and scpv2. Editors subsequently use system OpenSSH, which invokes the // managed alias's ProxyCommand once it dials the host. @@ -125,6 +130,9 @@ func resolveSandboxV2SSHAlias(client sshV2Client, paths basedir.Paths, name stri if err != nil { return sandboxSSHAlias{}, err } + if err := upsertSessionHost(paths, alias); err != nil { + return sandboxSSHAlias{}, fmt.Errorf("write direct SSH host alias: %w", err) + } repoName := "" if sandbox.RepoName != nil { repoName = *sandbox.RepoName diff --git a/go/internal/ssh/config.go b/go/internal/ssh/config.go index e7b5a53e..1a4ba3d8 100644 --- a/go/internal/ssh/config.go +++ b/go/internal/ssh/config.go @@ -20,7 +20,7 @@ const aliasPrefix = "amika-" // managedHeader marks the generated config as owned by amika. The file is fully // regenerated from the JSON state on every change, so manual edits are lost. const managedHeader = `# This file is managed by amika. Do not edit by hand. -# It is regenerated from the SSH hosts state on every ` + "`amika sandbox code`" + ` run. +# It is regenerated whenever Amika prepares an SSH target. ` // HostEntry is one managed SSH host: a stable alias for a sandbox plus the @@ -46,6 +46,17 @@ type HostsState struct { // that proxies its aliases, one entry per control plane this machine has // opened a v2 session against. SessionProxyCommands map[string]string `json:"session_proxy_commands,omitempty"` + // SessionHosts lists concrete v2 aliases for editors that discover SSH + // connections by enumerating Host entries, rather than accepting an alias + // supplied directly. Their connection settings come from the wildcard + // session blocks below, not these intentionally empty entries. + SessionHosts []SessionHostEntry `json:"session_hosts,omitempty"` +} + +// SessionHostEntry is a concrete direct-WebSocket SSH alias advertised to +// editors. The alias is self-describing and validated with ParseSessionAlias. +type SessionHostEntry struct { + Alias string `json:"alias"` } // Alias returns the stable SSH host alias for a sandbox id. Cursor keys its @@ -207,6 +218,20 @@ func (s *HostsState) Upsert(entry HostEntry) { }) } +// UpsertSessionHost adds a concrete v2 host alias if it is not already +// present, keeping entries sorted so the rendered config is deterministic. +func (s *HostsState) UpsertSessionHost(alias string) { + for _, host := range s.SessionHosts { + if host.Alias == alias { + return + } + } + s.SessionHosts = append(s.SessionHosts, SessionHostEntry{Alias: alias}) + sort.Slice(s.SessionHosts, func(i, j int) bool { + return s.SessionHosts[i].Alias < s.SessionHosts[j].Alias + }) +} + // Render produces the contents of ~/.ssh/amika.conf from the state. Each block // is a stable `Host amika-` alias preceded by the sandbox name as a comment // so the file stays human-readable even though it is keyed by id. @@ -228,6 +253,15 @@ func Render(state HostsState) string { } b.WriteString(" StrictHostKeyChecking accept-new\n") } + if hosts := renderSessionHosts(state); len(hosts) > 0 { + b.WriteString("\n# Direct WebSocket SSH aliases for editor discovery.\n") + for i, host := range hosts { + if i > 0 { + b.WriteString("\n") + } + b.WriteString(host) + } + } if blocks := renderSessionBlocks(state); len(blocks) > 0 { b.WriteString("\n# No-relay WebSocket SSH aliases, one block per control plane.\n") for i, block := range blocks { @@ -240,6 +274,21 @@ func Render(state HostsState) string { return b.String() } +// renderSessionHosts emits intentionally empty concrete Host stanzas before +// the wildcard session blocks. This makes aliases discoverable to apps such as +// Codex while letting the later wildcard stanza provide every SSH option. +func renderSessionHosts(state HostsState) []string { + hosts := make([]string, 0, len(state.SessionHosts)) + for _, entry := range state.SessionHosts { + parsed, err := ParseSessionAlias(entry.Alias) + if err != nil { + continue + } + hosts = append(hosts, fmt.Sprintf("# %s\nHost %s\n", parsed.Name, entry.Alias)) + } + return hosts +} + // renderSessionBlocks renders one wildcard block per configured environment, in // sorted order so the generated file does not churn between runs. Blocks that // fail validation are skipped rather than fatal, matching how Render treats the @@ -268,7 +317,15 @@ func renderSessionBlocks(state HostsState) []string { // so a bad value fails at the call that introduced it rather than silently // vanishing from the generated config. func validateSessionState(state HostsState) error { + for _, entry := range state.SessionHosts { + if _, err := ParseSessionAlias(entry.Alias); err != nil { + return err + } + } if state.SessionConfig == nil { + if len(state.SessionHosts) > 0 { + return ErrInvalidSessionAlias + } return nil } for environment, proxyCommand := range state.SessionProxyCommands { @@ -435,6 +492,27 @@ func UpsertHost(paths basedir.Paths, entry HostEntry) (string, error) { return Alias(entry.SandboxID), nil } +// UpsertSessionHost records a concrete direct-WebSocket SSH alias for editor +// discovery while its wildcard session block continues to provide the actual +// connection settings. +func UpsertSessionHost(paths basedir.Paths, alias string) error { + state, err := LoadState(paths) + if err != nil { + return err + } + state.UpsertSessionHost(alias) + if err := SaveState(paths, state); err != nil { + return err + } + if err := WriteAmikaConfig(paths, state); err != nil { + return err + } + if err := EnsureInclude(paths); err != nil { + return err + } + return nil +} + // writeFileAtomic writes data to path via a temp file + rename so a concurrent // run never observes a half-written file. The parent directory is created with // owner-only permissions. diff --git a/go/internal/ssh/config_test.go b/go/internal/ssh/config_test.go index 0df9748a..28b65d20 100644 --- a/go/internal/ssh/config_test.go +++ b/go/internal/ssh/config_test.go @@ -156,6 +156,51 @@ func TestUpsertReplacesAndSorts(t *testing.T) { } } +func TestUpsertSessionHostRendersConcreteAliasBeforeWildcardSettings(t *testing.T) { + paths := testPaths(t) + alias := "my-sandbox.sb_1.app-amika-dev.amika" + state := HostsState{ + SessionConfig: &SessionConfig{ + IdentityFile: "/home/user/.ssh/amika_id_ed25519", + KnownHostsFile: "/home/user/.ssh/amika_known_hosts", + }, + SessionProxyCommands: map[string]string{ + "app-amika-dev": "/usr/local/bin/amika plumbing ssh-stdio-proxy %h", + }, + } + if err := SaveState(paths, state); err != nil { + t.Fatalf("SaveState: %v", err) + } + if err := UpsertSessionHost(paths, alias); err != nil { + t.Fatalf("UpsertSessionHost: %v", err) + } + + confPath, _ := paths.SSHAmikaConfigFile() + data, err := os.ReadFile(confPath) + if err != nil { + t.Fatalf("read amika config: %v", err) + } + content := string(data) + concreteHost := "Host " + alias + wildcardHost := "Host *.app-amika-dev.amika" + concreteIndex := strings.Index(content, concreteHost) + wildcardIndex := strings.Index(content, wildcardHost) + if concreteIndex < 0 || wildcardIndex < 0 || concreteIndex > wildcardIndex { + t.Fatalf("concrete alias must precede its wildcard settings:\n%s", content) + } + if strings.Contains(content[concreteIndex:wildcardIndex], "HostName") { + t.Fatalf("concrete alias must not override wildcard connection settings:\n%s", content) + } + + loaded, err := LoadState(paths) + if err != nil { + t.Fatalf("LoadState: %v", err) + } + if len(loaded.SessionHosts) != 1 || loaded.SessionHosts[0].Alias != alias { + t.Fatalf("session hosts = %#v", loaded.SessionHosts) + } +} + func TestEnsureIncludeCreatesAndIsIdempotent(t *testing.T) { paths := testPaths(t) configPath, _ := paths.SSHConfigFile() From 28d923c8ecb1272141e2864873150f38cdbfcb63 Mon Sep 17 00:00:00 2001 From: Dylan Mikus Date: Sun, 9 Aug 2026 22:50:17 -0400 Subject: [PATCH 3/3] Enable Claude and Codex editors by default --- docs/cli-reference.md | 34 ++++++++++++---------- go/cmd/amika/sandbox/sandbox_ssh.go | 22 ++------------ go/cmd/amika/sandbox/sandbox_ssh_test.go | 37 +++++------------------- 3 files changed, 28 insertions(+), 65 deletions(-) diff --git a/docs/cli-reference.md b/docs/cli-reference.md index 35680c5d..28b437a0 100644 --- a/docs/cli-reference.md +++ b/docs/cli-reference.md @@ -269,9 +269,7 @@ through the same Amika-managed SSH host alias (`amika-`, written to `~/.codex/config.toml`), then opens Codex; enable the host under Settings > Connections. -The `claude` and `codex` editors are gated behind a feature flag: set -`AMIKA_OPEN_CLAUDE_CODEX_SUPPORT=true` to enable them (`cursor` is always -available). +This command requires a signed-in Amika account and a remote sandbox. ```bash amika sandbox code my-sandbox @@ -288,18 +286,24 @@ amika sandbox code my-sandbox --editor=codex ### `amika sandbox codev2` Open a sandbox in the same supported editors as `sandbox code`, but use the -beta direct WebSocket SSH transport instead of provider-native SSH. Use it when -the provider route is unavailable or when you want the direct transport used by -`sandbox sshv2` and `amika scpv2`. It works with remote sandboxes only and -requires Amika authentication. - -`codev2` writes a concrete managed SSH alias so Codex can discover it, while -its connection settings use an Amika `ProxyCommand`. It then starts or -configures Cursor, Claude Desktop, or Codex exactly as `sandbox code` does. -Before first use, run `amika secret ssh-keygen` to generate and upload an SSH -key, or pass `--import ` to use an existing key. As with -`sandbox code`, Claude and Codex require -`AMIKA_OPEN_CLAUDE_CODEX_SUPPORT=true`; Cursor is available by default. +beta direct WebSocket SSH transport instead of the provider's SSH route. Use +it when normal `sandbox code` cannot reach the provider SSH route. It works +with remote sandboxes only and requires a signed-in Amika account. + +`codev2` adds a named SSH connection to Amika's managed config so Codex can +find it, then starts or configures Cursor, Claude Desktop, or Codex as +`sandbox code` does. The connection routes through Amika's direct transport; +you do not need to configure that transport yourself. + +Before first use, create and upload an SSH key: + +```bash +amika secret ssh-keygen +``` + +To use an existing key, pass `--import ` instead. Cursor is +the default editor; Claude Desktop and Codex are also available without an +environment-variable feature gate. ```bash amika sandbox codev2 my-sandbox diff --git a/go/cmd/amika/sandbox/sandbox_ssh.go b/go/cmd/amika/sandbox/sandbox_ssh.go index 4bc07e68..572867ea 100644 --- a/go/cmd/amika/sandbox/sandbox_ssh.go +++ b/go/cmd/amika/sandbox/sandbox_ssh.go @@ -8,7 +8,6 @@ import ( "os/exec" "path" "runtime" - "strings" "github.com/gofixpoint/amika/go/internal/apiclient" "github.com/gofixpoint/amika/go/internal/appcfg" @@ -19,25 +18,10 @@ import ( "github.com/spf13/cobra" ) -// claudeCodexSupportEnv gates the claude/codex editors. They stay off until it -// is set to "true", mirroring the webapp's NEXT_PUBLIC_OPEN_CLAUDE_CODEX_SUPPORT -// flag so the two roll out together. -const claudeCodexSupportEnv = "AMIKA_OPEN_CLAUDE_CODEX_SUPPORT" - -func claudeCodexEditorsEnabled() bool { - return strings.EqualFold(strings.TrimSpace(os.Getenv(claudeCodexSupportEnv)), "true") -} - -// validateEditor checks that the requested editor is known and enabled. cursor -// is always available; claude and codex require claudeCodexSupportEnv. +// validateEditor checks that the requested editor is supported. func validateEditor(editor string) error { switch editor { - case "cursor": - return nil - case "claude", "codex": - if !claudeCodexEditorsEnabled() { - return fmt.Errorf("editor %q is not enabled; set %s=true to enable it", editor, claudeCodexSupportEnv) - } + case "cursor", "claude", "codex": return nil default: return fmt.Errorf("unsupported editor %q; supported editors are %q", editor, supportedEditors) @@ -143,8 +127,6 @@ Supported --editor values: For claude and codex, the command writes the local app config so the sandbox appears as a remote environment; select it in the app to start the session. -These two editors are gated: set AMIKA_OPEN_CLAUDE_CODEX_SUPPORT=true to enable -them. Examples: amika sandbox code my-sandbox diff --git a/go/cmd/amika/sandbox/sandbox_ssh_test.go b/go/cmd/amika/sandbox/sandbox_ssh_test.go index 3cbcb26d..9ab8a2ec 100644 --- a/go/cmd/amika/sandbox/sandbox_ssh_test.go +++ b/go/cmd/amika/sandbox/sandbox_ssh_test.go @@ -123,40 +123,17 @@ func testSSHPaths(t *testing.T) (basedir.Paths, string) { } func TestValidateEditor(t *testing.T) { - t.Run("cursor is always allowed", func(t *testing.T) { - t.Setenv(claudeCodexSupportEnv, "") - if err := validateEditor("cursor"); err != nil { - t.Fatalf("cursor should be allowed: %v", err) - } - }) - - t.Run("unknown editor is rejected", func(t *testing.T) { - t.Setenv(claudeCodexSupportEnv, "true") - if err := validateEditor("vim"); err == nil { - t.Fatalf("expected unknown editor to be rejected") - } - }) - - for _, editor := range []string{"claude", "codex"} { - t.Run(editor+" gated off by default", func(t *testing.T) { - t.Setenv(claudeCodexSupportEnv, "") - if err := validateEditor(editor); err == nil { - t.Fatalf("expected %q to be gated when the flag is unset", editor) - } - }) - t.Run(editor+" enabled when flag is true", func(t *testing.T) { - t.Setenv(claudeCodexSupportEnv, "true") + for _, editor := range supportedEditors { + t.Run(editor+" is allowed", func(t *testing.T) { if err := validateEditor(editor); err != nil { - t.Fatalf("expected %q to be allowed when flag is set: %v", editor, err) - } - }) - t.Run(editor+" gated when flag is a non-true value", func(t *testing.T) { - t.Setenv(claudeCodexSupportEnv, "1") - if err := validateEditor(editor); err == nil { - t.Fatalf("expected %q to be gated when flag is %q", editor, "1") + t.Fatalf("%q should be allowed: %v", editor, err) } }) } + + if err := validateEditor("vim"); err == nil { + t.Fatal("expected unknown editor to be rejected") + } } func daytonaInfo() *apiclient.SSHInfo {