Skip to content

Commit 95a6543

Browse files
rgarciacursoragent
andauthored
feat(ssh): add -o json support for --setup-only (#106)
## Summary - Adds `-o json` flag to `kernel browsers ssh --setup-only` for machine-readable output - When set, suppresses all pterm progress messages and emits a JSON object to stdout with `vm_domain`, `session_id`, `ssh_key_file`, `proxy_command`, and `ssh_command` - Skips ephemeral key cleanup when JSON output is used so the key file remains available for the caller ## Motivation Scripting around `--setup-only` currently requires fragile `grep`/`awk` parsing of human-readable output: ```bash OUTPUT=$(kernel browsers ssh "$SESSION_ID" -i /tmp/kernel-scp-key --setup-only 2>&1) VM_DOMAIN=$(echo "$OUTPUT" | grep "VM domain:" | awk '{print $NF}') ``` With `-o json`, this becomes: ```bash SETUP=$(kernel browsers ssh "$SESSION_ID" -i /tmp/kernel-scp-key --setup-only -o json) VM_DOMAIN=$(echo "$SETUP" | jq -r '.vm_domain') ``` ## Example output ```json { "vm_domain": "actual-vm-domain.onkernel.app", "session_id": "abc123", "ssh_key_file": "/tmp/kernel-scp-key", "proxy_command": "websocat --binary wss://actual-vm-domain.onkernel.app:2222", "ssh_command": "ssh -o 'ProxyCommand=websocat --binary wss://...:2222' -o StrictHostKeyChecking=no -o UserKnownHostsFile=/dev/null -i /tmp/kernel-scp-key root@localhost" } ``` ## Test plan - [ ] Run `kernel browsers ssh <id> -i <key> --setup-only -o json` and verify clean JSON on stdout - [ ] Run `kernel browsers ssh <id> --setup-only -o json` (ephemeral key) and verify key file is not cleaned up - [ ] Run `kernel browsers ssh <id> --setup-only` without `-o` and verify existing behavior is unchanged - [ ] Run `kernel browsers ssh <id> -o json` without `--setup-only` and verify it errors - [ ] Run `kernel browsers ssh <id> -o yaml` and verify it errors <!-- CURSOR_SUMMARY --> --- > [!NOTE] > **Low Risk** > CLI-only behavior changes gated behind `--setup-only -o json`, with limited impact on the default interactive SSH path; main risk is leaving temporary key files behind when JSON output is used. > > **Overview** > Adds `-o/--output json` support to `kernel browsers ssh` when used with `--setup-only`, emitting a single JSON object (vm domain, session id, key path, and ready-to-run proxy/ssh commands) for scripting. > > When JSON output is enabled, progress/log messaging is suppressed, `setupVMSSH` gains a quiet mode for key-injection/setup messaging, and ephemeral key cleanup is skipped so callers can reuse the generated key file; invalid output formats and `-o json` without `--setup-only` now error. > > <sup>Written by [Cursor Bugbot](https://cursor.com/dashboard?tab=bugbot) for commit 405d824. This will update automatically on new commits. Configure [here](https://cursor.com/dashboard?tab=bugbot).</sup> <!-- /CURSOR_SUMMARY --> --------- Co-authored-by: Cursor <cursoragent@cursor.com>
1 parent 4d9565b commit 95a6543

2 files changed

Lines changed: 62 additions & 11 deletions

File tree

cmd/ssh.go

Lines changed: 61 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@ package cmd
33
import (
44
"context"
55
"encoding/base64"
6+
"encoding/json"
67
"fmt"
78
"os"
89
"os/exec"
@@ -16,6 +17,15 @@ import (
1617
"github.com/spf13/cobra"
1718
)
1819

20+
// sshSetupResult is the JSON output structure for --setup-only -o json.
21+
type sshSetupResult struct {
22+
VMDomain string `json:"vm_domain"`
23+
SessionID string `json:"session_id"`
24+
SSHKeyFile string `json:"ssh_key_file"`
25+
ProxyCommand string `json:"proxy_command"`
26+
SSHCommand string `json:"ssh_command"`
27+
}
28+
1929
var sshCmd = &cobra.Command{
2030
Use: "ssh <id>",
2131
Short: "Open an interactive SSH session to a browser VM",
@@ -49,6 +59,7 @@ func init() {
4959
sshCmd.Flags().StringP("local-forward", "L", "", "Local port forwarding (localport:host:remoteport)")
5060
sshCmd.Flags().StringP("remote-forward", "R", "", "Remote port forwarding (remoteport:host:localport)")
5161
sshCmd.Flags().Bool("setup-only", false, "Setup SSH on VM without connecting")
62+
sshCmd.Flags().StringP("output", "o", "", "Output format: json for machine-readable output (only with --setup-only)")
5263
}
5364

5465
func runSSH(cmd *cobra.Command, args []string) error {
@@ -60,26 +71,39 @@ func runSSH(cmd *cobra.Command, args []string) error {
6071
localForward, _ := cmd.Flags().GetString("local-forward")
6172
remoteForward, _ := cmd.Flags().GetString("remote-forward")
6273
setupOnly, _ := cmd.Flags().GetBool("setup-only")
74+
output, _ := cmd.Flags().GetString("output")
75+
76+
if output != "" && output != "json" {
77+
return fmt.Errorf("unsupported --output value: use 'json'")
78+
}
79+
if output == "json" && !setupOnly {
80+
return fmt.Errorf("--output json is only supported with --setup-only")
81+
}
6382

6483
cfg := ssh.Config{
6584
BrowserID: browserID,
6685
IdentityFile: identityFile,
6786
LocalForward: localForward,
6887
RemoteForward: remoteForward,
6988
SetupOnly: setupOnly,
89+
Output: output,
7090
}
7191

7292
return connectSSH(ctx, client, cfg)
7393
}
7494

7595
func connectSSH(ctx context.Context, client kernel.Client, cfg ssh.Config) error {
96+
jsonOutput := cfg.Output == "json"
97+
7698
// Check websocat is installed locally
7799
if err := ssh.CheckWebsocatInstalled(); err != nil {
78100
return err
79101
}
80102

81103
// Get browser info
82-
pterm.Info.Printf("Getting browser %s info...\n", cfg.BrowserID)
104+
if !jsonOutput {
105+
pterm.Info.Printf("Getting browser %s info...\n", cfg.BrowserID)
106+
}
83107
browser, err := client.Browsers.Get(ctx, cfg.BrowserID, kernel.BrowserGetParams{})
84108
if err != nil {
85109
return fmt.Errorf("failed to get browser: %w", err)
@@ -95,7 +119,9 @@ func connectSSH(ctx context.Context, client kernel.Client, cfg ssh.Config) error
95119
if err != nil {
96120
return fmt.Errorf("failed to extract VM domain: %w", err)
97121
}
98-
pterm.Info.Printf("VM domain: %s\n", vmDomain)
122+
if !jsonOutput {
123+
pterm.Info.Printf("VM domain: %s\n", vmDomain)
124+
}
99125

100126
// Generate or load SSH keypair
101127
var privateKeyPEM, publicKey string
@@ -104,7 +130,9 @@ func connectSSH(ctx context.Context, client kernel.Client, cfg ssh.Config) error
104130

105131
if cfg.IdentityFile != "" {
106132
// Use provided key
107-
pterm.Info.Printf("Using SSH key: %s\n", cfg.IdentityFile)
133+
if !jsonOutput {
134+
pterm.Info.Printf("Using SSH key: %s\n", cfg.IdentityFile)
135+
}
108136
keyFile = cfg.IdentityFile
109137

110138
// Read public key to inject into VM
@@ -117,7 +145,9 @@ func connectSSH(ctx context.Context, client kernel.Client, cfg ssh.Config) error
117145
publicKey = strings.TrimSpace(string(pubKeyData))
118146
} else {
119147
// Generate ephemeral keypair
120-
pterm.Info.Println("Generating ephemeral SSH keypair...")
148+
if !jsonOutput {
149+
pterm.Info.Println("Generating ephemeral SSH keypair...")
150+
}
121151
keyPair, err := ssh.GenerateKeyPair()
122152
if err != nil {
123153
return fmt.Errorf("failed to generate SSH keypair: %w", err)
@@ -134,22 +164,40 @@ func connectSSH(ctx context.Context, client kernel.Client, cfg ssh.Config) error
134164
pterm.Debug.Printf("Temp key file: %s\n", keyFile)
135165
}
136166

137-
// Cleanup temp key on exit
138-
if cleanupKey {
167+
// Cleanup temp key on exit (skip if JSON setup-only, since the caller needs the key)
168+
if cleanupKey && !(cfg.SetupOnly && jsonOutput) {
139169
defer func() {
140170
pterm.Debug.Printf("Cleaning up temp key: %s\n", keyFile)
141171
os.Remove(keyFile)
142172
}()
143173
}
144174

145175
// Setup SSH services on VM
146-
pterm.Info.Println("Setting up SSH services on VM...")
147-
if err := setupVMSSH(ctx, client, browser.SessionID, publicKey); err != nil {
176+
if !jsonOutput {
177+
pterm.Info.Println("Setting up SSH services on VM...")
178+
}
179+
if err := setupVMSSH(ctx, client, browser.SessionID, publicKey, jsonOutput); err != nil {
148180
return fmt.Errorf("failed to setup SSH on VM: %w", err)
149181
}
150-
pterm.Success.Println("SSH services running on VM")
182+
if !jsonOutput {
183+
pterm.Success.Println("SSH services running on VM")
184+
}
151185

152186
if cfg.SetupOnly {
187+
if jsonOutput {
188+
proxyCmd := fmt.Sprintf("websocat --binary wss://%s:2222", vmDomain)
189+
sshCommand := fmt.Sprintf("ssh -o 'ProxyCommand=%s' -o StrictHostKeyChecking=no -o UserKnownHostsFile=/dev/null -o LogLevel=ERROR -i %s root@localhost", proxyCmd, keyFile)
190+
result := sshSetupResult{
191+
VMDomain: vmDomain,
192+
SessionID: browser.SessionID,
193+
SSHKeyFile: keyFile,
194+
ProxyCommand: proxyCmd,
195+
SSHCommand: sshCommand,
196+
}
197+
enc := json.NewEncoder(os.Stdout)
198+
enc.SetIndent("", " ")
199+
return enc.Encode(result)
200+
}
153201
pterm.Info.Println("\n--setup-only specified, not connecting.")
154202
pterm.Info.Printf("To connect manually:\n")
155203
pterm.Info.Printf(" ssh -o 'ProxyCommand=websocat --binary wss://%s:2222' -i %s root@localhost\n", vmDomain, keyFile)
@@ -192,7 +240,7 @@ func connectSSH(ctx context.Context, client kernel.Client, cfg ssh.Config) error
192240
}
193241

194242
// setupVMSSH installs and configures sshd + websocat on the VM using process.exec
195-
func setupVMSSH(ctx context.Context, client kernel.Client, sessionID, publicKey string) error {
243+
func setupVMSSH(ctx context.Context, client kernel.Client, sessionID, publicKey string, quiet bool) error {
196244
// First check if services are already running
197245
checkScript := ssh.CheckServicesScript()
198246
checkResp, err := client.Browsers.Process.Exec(ctx, sessionID, kernel.BrowserProcessExecParams{
@@ -205,7 +253,9 @@ func setupVMSSH(ctx context.Context, client kernel.Client, sessionID, publicKey
205253
} else if checkResp != nil && checkResp.StdoutB64 != "" {
206254
stdout, _ := base64.StdEncoding.DecodeString(checkResp.StdoutB64)
207255
if strings.TrimSpace(string(stdout)) == "RUNNING" {
208-
pterm.Info.Println("SSH services already running, injecting key...")
256+
if !quiet {
257+
pterm.Info.Println("SSH services already running, injecting key...")
258+
}
209259
// Just inject the key
210260
return injectSSHKey(ctx, client, sessionID, publicKey)
211261
}

pkg/ssh/ssh.go

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -23,6 +23,7 @@ type Config struct {
2323
LocalForward string // -L flag value
2424
RemoteForward string // -R flag value
2525
SetupOnly bool
26+
Output string // "json" for machine-readable output
2627
}
2728

2829
// KeyPair holds an SSH keypair

0 commit comments

Comments
 (0)