Skip to content

Commit a6ea28d

Browse files
author
Cursor Agent
committed
Merge main into cli-coverage-update
2 parents 899caee + 4d9565b commit a6ea28d

16 files changed

Lines changed: 665 additions & 43 deletions

File tree

.cursor/commands/qa.md

Lines changed: 8 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -270,25 +270,25 @@ Once all deployments are complete, present the human with these invoke commands
270270
kernel invoke ts-basic get-page-title --payload '{"url": "https://www.google.com"}'
271271
kernel invoke ts-captcha-solver test-captcha-solver
272272
kernel invoke ts-stagehand teamsize-task --payload '{"company": "Kernel"}'
273-
kernel invoke ts-anthropic-cua cua-task --payload '{"query": "Go to http://magnitasks.com, Click the Tasks option in the left-side bar, and move the 5 items in the To Do and In Progress items to the Done section of the Kanban board. You are done successfully when the items are moved.", "record_replay": true}'
273+
kernel invoke ts-anthropic-cua cua-task --payload '{"query": "Go to https://www.magnitasks.com, Click the Tasks option in the left-side bar, and move the 5 items in the To Do and In Progress items to the Done section of the Kanban board. You are done successfully when the items are moved.", "record_replay": true}'
274274
kernel invoke ts-magnitude mag-url-extract --payload '{"url": "https://en.wikipedia.org/wiki/Special:Random"}'
275275
kernel invoke ts-openai-cua cua-task --payload '{"task": "Go to https://news.ycombinator.com and get the top 5 articles"}'
276-
kernel invoke ts-gemini-cua cua-task --payload '{"query": "Go to http://magnitasks.com, Click the Tasks option in the left-side bar, and move the 5 items in the To Do and In Progress items to the Done section of the Kanban board. You are done successfully when the items are moved.", "record_replay": true}'
276+
kernel invoke ts-gemini-cua cua-task --payload '{"query": "Go to https://www.magnitasks.com, Click the Tasks option in the left-side bar, and move the 5 items in the To Do and In Progress items to the Done section of the Kanban board. You are done successfully when the items are moved.", "record_replay": true}'
277277
kernel invoke ts-claude-agent-sdk agent-task --payload '{"task": "Go to https://news.ycombinator.com and get the top 3 stories"}'
278-
kernel invoke ts-yutori-cua cua-task --payload '{"query": "Go to http://magnitasks.com, Click the Tasks option in the left-side bar, and drag the 5 items in the To Do and In Progress columns to the Done section of the Kanban board. You are done successfully when the items are dragged to Done. Do not click into the items.", "record_replay": true, "mode": "computer_use"}'
279-
kernel invoke ts-yutori-cua cua-task --payload '{"query": "Go to http://magnitasks.com, Click the Tasks option in the left-side bar, and drag the 5 items in the To Do and In Progress columns to the Done section of the Kanban board. You are done successfully when the items are dragged to Done. Do not click into the items.", "record_replay": true, "mode": "playwright"}'
278+
kernel invoke ts-yutori-cua cua-task --payload '{"query": "Go to https://www.magnitasks.com, Click the Tasks option in the left-side bar, and drag the 5 items in the To Do and In Progress columns to the Done section of the Kanban board. You are done successfully when the items are dragged to Done. Do not click into the items.", "record_replay": true, "mode": "computer_use"}'
279+
kernel invoke ts-yutori-cua cua-task --payload '{"query": "Go to https://www.magnitasks.com, Click the Tasks option in the left-side bar, and drag the 5 items in the To Do and In Progress columns to the Done section of the Kanban board. You are done successfully when the items are dragged to Done. Do not click into the items.", "record_replay": true, "mode": "playwright"}'
280280

281281
# Python apps
282282
kernel invoke python-basic get-page-title --payload '{"url": "https://www.google.com"}'
283283
kernel invoke python-captcha-solver test-captcha-solver
284284
kernel invoke python-bu bu-task --payload '{"task": "Compare the price of gpt-4o and DeepSeek-V3"}'
285-
kernel invoke python-anthropic-cua cua-task --payload '{"query": "Go to http://magnitasks.com, Click the Tasks option in the left-side bar, and move the 5 items in the To Do and In Progress items to the Done section of the Kanban board. You are done successfully when the items are moved.", "record_replay": true}'
285+
kernel invoke python-anthropic-cua cua-task --payload '{"query": "Go to https://www.magnitasks.com, Click the Tasks option in the left-side bar, and move the 5 items in the To Do and In Progress items to the Done section of the Kanban board. You are done successfully when the items are moved.", "record_replay": true}'
286286
kernel invoke python-openai-cua cua-task --payload '{"task": "Go to https://news.ycombinator.com and get the top 5 articles"}'
287287
kernel invoke python-openagi-cua openagi-default-task -p '{"instruction": "Navigate to https://agiopen.org and click the What is Computer Use? button"}'
288288
kernel invoke py-claude-agent-sdk agent-task --payload '{"task": "Go to https://news.ycombinator.com and get the top 3 stories"}'
289-
kernel invoke python-gemini-cua cua-task --payload '{"query": "Go to http://magnitasks.com, Click the Tasks option in the left-side bar, and move the 5 items in the To Do and In Progress items to the Done section of the Kanban board. You are done successfully when the items are moved.", "record_replay": true}'
290-
kernel invoke python-yutori-cua cua-task --payload '{"query": "Go to http://magnitasks.com, Click the Tasks option in the left-side bar, and drag the 5 items in the To Do and In Progress columns to the Done section of the Kanban board. You are done successfully when the items are dragged to Done. Do not click into the items.", "record_replay": true, "mode": "computer_use"}'
291-
kernel invoke python-yutori-cua cua-task --payload '{"query": "Go to http://magnitasks.com, Click the Tasks option in the left-side bar, and drag the 5 items in the To Do and In Progress columns to the Done section of the Kanban board. You are done successfully when the items are dragged to Done. Do not click into the items.", "record_replay": true, "mode": "playwright"}'
289+
kernel invoke python-gemini-cua cua-task --payload '{"query": "Go to https://www.magnitasks.com, Click the Tasks option in the left-side bar, and move the 5 items in the To Do and In Progress items to the Done section of the Kanban board. You are done successfully when the items are moved.", "record_replay": true}'
290+
kernel invoke python-yutori-cua cua-task --payload '{"query": "Go to https://www.magnitasks.com, Click the Tasks option in the left-side bar, and drag the 5 items in the To Do and In Progress columns to the Done section of the Kanban board. You are done successfully when the items are dragged to Done. Do not click into the items.", "record_replay": true, "mode": "computer_use"}'
291+
kernel invoke python-yutori-cua cua-task --payload '{"query": "Go to https://www.magnitasks.com, Click the Tasks option in the left-side bar, and drag the 5 items in the To Do and In Progress columns to the Done section of the Kanban board. You are done successfully when the items are dragged to Done. Do not click into the items.", "record_replay": true, "mode": "playwright"}'
292292
```
293293

294294
## Step 7: Automated Runtime Testing (Optional)

cmd/browsers.go

Lines changed: 6 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -127,6 +127,7 @@ func getAvailableViewports() []string {
127127
"1280x800@60",
128128
"1024x768@60",
129129
"1200x800@60",
130+
"1280x800@60",
130131
}
131132
}
132133

@@ -2070,7 +2071,7 @@ func init() {
20702071
browsersUpdateCmd.Flags().String("profile-id", "", "Profile ID to load into the browser session (mutually exclusive with --profile-name)")
20712072
browsersUpdateCmd.Flags().String("profile-name", "", "Profile name to load into the browser session (mutually exclusive with --profile-id)")
20722073
browsersUpdateCmd.Flags().Bool("save-changes", false, "If set, save changes back to the profile when the session ends")
2073-
browsersUpdateCmd.Flags().String("viewport", "", "Browser viewport size (e.g., 1920x1080@25). Supported: 2560x1440@10, 1920x1080@25, 1920x1200@25, 1440x900@25, 1024x768@60, 1200x800@60")
2074+
browsersUpdateCmd.Flags().String("viewport", "", "Browser viewport size (e.g., 1920x1080@25). Supported: 2560x1440@10, 1920x1080@25, 1920x1200@25, 1440x900@25, 1024x768@60, 1200x800@60, 1280x800@60")
20742075

20752076
browsersCmd.AddCommand(browsersListCmd)
20762077
browsersCmd.AddCommand(browsersCreateCmd)
@@ -2079,6 +2080,9 @@ func init() {
20792080
browsersCmd.AddCommand(browsersGetCmd)
20802081
browsersCmd.AddCommand(browsersUpdateCmd)
20812082

2083+
// ssh
2084+
browsersCmd.AddCommand(sshCmd)
2085+
20822086
// logs
20832087
logsRoot := &cobra.Command{Use: "logs", Short: "Browser logs operations"}
20842088
logsStream := &cobra.Command{Use: "stream <id>", Short: "Stream browser logs", Args: cobra.ExactArgs(1), RunE: runBrowsersLogsStream}
@@ -2305,7 +2309,7 @@ func init() {
23052309
browsersCreateCmd.Flags().Bool("save-changes", false, "If set, save changes back to the profile when the session ends")
23062310
browsersCreateCmd.Flags().String("proxy-id", "", "Proxy ID to use for the browser session")
23072311
browsersCreateCmd.Flags().StringSlice("extension", []string{}, "Extension IDs or names to load (repeatable; may be passed multiple times or comma-separated)")
2308-
browsersCreateCmd.Flags().String("viewport", "", "Browser viewport size (e.g., 1920x1080@25). Supported: 2560x1440@10, 1920x1080@25, 1920x1200@25, 1440x900@25, 1024x768@60, 1200x800@60")
2312+
browsersCreateCmd.Flags().String("viewport", "", "Browser viewport size (e.g., 1920x1080@25). Supported: 2560x1440@10, 1920x1080@25, 1920x1200@25, 1440x900@25, 1024x768@60, 1200x800@60, 1280x800@60")
23092313
browsersCreateCmd.Flags().Bool("viewport-interactive", false, "Interactively select viewport size from list")
23102314
browsersCreateCmd.Flags().String("pool-id", "", "Browser pool ID to acquire from (mutually exclusive with --pool-name)")
23112315
browsersCreateCmd.Flags().String("pool-name", "", "Browser pool name to acquire from (mutually exclusive with --pool-id)")

cmd/browsers_test.go

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -280,7 +280,6 @@ func TestBrowsersDelete_Failure(t *testing.T) {
280280
assert.True(t, strings.Contains(errMsg, "right failed") || strings.Contains(errMsg, "left failed"), "expected error message to contain either 'right failed' or 'left failed', got: %s", errMsg)
281281
}
282282

283-
284283
func TestBrowsersView_ByID_PrintsURL(t *testing.T) {
285284
// Capture both pterm output and raw stdout
286285
setupStdoutCapture(t)
@@ -1154,6 +1153,7 @@ func TestGetAvailableViewports_ReturnsExpectedOptions(t *testing.T) {
11541153
assert.Contains(t, viewports, "1440x900@25")
11551154
assert.Contains(t, viewports, "1280x800@60")
11561155
assert.Contains(t, viewports, "1200x800@60")
1156+
assert.Contains(t, viewports, "1280x800@60")
11571157
assert.Contains(t, viewports, "1024x768@60")
11581158
}
11591159

cmd/ssh.go

Lines changed: 269 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,269 @@
1+
package cmd
2+
3+
import (
4+
"context"
5+
"encoding/base64"
6+
"fmt"
7+
"os"
8+
"os/exec"
9+
"os/signal"
10+
"strings"
11+
"syscall"
12+
13+
"github.com/kernel/cli/pkg/ssh"
14+
"github.com/kernel/kernel-go-sdk"
15+
"github.com/pterm/pterm"
16+
"github.com/spf13/cobra"
17+
)
18+
19+
var sshCmd = &cobra.Command{
20+
Use: "ssh <id>",
21+
Short: "Open an interactive SSH session to a browser VM",
22+
Long: `Establish an SSH connection to a running browser VM.
23+
24+
By default, generates an ephemeral SSH keypair and opens an interactive shell.
25+
Use -i to specify an existing SSH private key instead.
26+
27+
Port forwarding uses standard SSH syntax:
28+
-L localport:host:remoteport Forward local port to remote
29+
-R remoteport:host:localport Forward remote port to local
30+
31+
Examples:
32+
# Interactive shell
33+
kernel browsers ssh abc123def456
34+
35+
# Expose local dev server (port 3000) on VM port 8080
36+
kernel browsers ssh abc123def456 -R 8080:localhost:3000
37+
38+
# Access VM's port 5432 locally
39+
kernel browsers ssh abc123def456 -L 5432:localhost:5432
40+
41+
# Use existing SSH key
42+
kernel browsers ssh abc123def456 -i ~/.ssh/id_ed25519`,
43+
Args: cobra.ExactArgs(1),
44+
RunE: runSSH,
45+
}
46+
47+
func init() {
48+
sshCmd.Flags().StringP("identity", "i", "", "Path to SSH private key (generates ephemeral if not provided)")
49+
sshCmd.Flags().StringP("local-forward", "L", "", "Local port forwarding (localport:host:remoteport)")
50+
sshCmd.Flags().StringP("remote-forward", "R", "", "Remote port forwarding (remoteport:host:localport)")
51+
sshCmd.Flags().Bool("setup-only", false, "Setup SSH on VM without connecting")
52+
}
53+
54+
func runSSH(cmd *cobra.Command, args []string) error {
55+
ctx := cmd.Context()
56+
client := getKernelClient(cmd)
57+
browserID := args[0]
58+
59+
identityFile, _ := cmd.Flags().GetString("identity")
60+
localForward, _ := cmd.Flags().GetString("local-forward")
61+
remoteForward, _ := cmd.Flags().GetString("remote-forward")
62+
setupOnly, _ := cmd.Flags().GetBool("setup-only")
63+
64+
cfg := ssh.Config{
65+
BrowserID: browserID,
66+
IdentityFile: identityFile,
67+
LocalForward: localForward,
68+
RemoteForward: remoteForward,
69+
SetupOnly: setupOnly,
70+
}
71+
72+
return connectSSH(ctx, client, cfg)
73+
}
74+
75+
func connectSSH(ctx context.Context, client kernel.Client, cfg ssh.Config) error {
76+
// Check websocat is installed locally
77+
if err := ssh.CheckWebsocatInstalled(); err != nil {
78+
return err
79+
}
80+
81+
// Get browser info
82+
pterm.Info.Printf("Getting browser %s info...\n", cfg.BrowserID)
83+
browser, err := client.Browsers.Get(ctx, cfg.BrowserID, kernel.BrowserGetParams{})
84+
if err != nil {
85+
return fmt.Errorf("failed to get browser: %w", err)
86+
}
87+
88+
// Extract VM domain from CDP URL (which contains the JWT with the actual FQDN)
89+
var vmDomain string
90+
if browser.CdpWsURL != "" {
91+
vmDomain, err = ssh.ExtractVMDomain(browser.CdpWsURL)
92+
} else {
93+
return fmt.Errorf("browser has no CDP URL - cannot determine VM domain")
94+
}
95+
if err != nil {
96+
return fmt.Errorf("failed to extract VM domain: %w", err)
97+
}
98+
pterm.Info.Printf("VM domain: %s\n", vmDomain)
99+
100+
// Generate or load SSH keypair
101+
var privateKeyPEM, publicKey string
102+
var keyFile string
103+
var cleanupKey bool
104+
105+
if cfg.IdentityFile != "" {
106+
// Use provided key
107+
pterm.Info.Printf("Using SSH key: %s\n", cfg.IdentityFile)
108+
keyFile = cfg.IdentityFile
109+
110+
// Read public key to inject into VM
111+
// Try to read the .pub file
112+
pubKeyPath := cfg.IdentityFile + ".pub"
113+
pubKeyData, err := os.ReadFile(pubKeyPath)
114+
if err != nil {
115+
return fmt.Errorf("failed to read public key %s: %w (ensure .pub file exists alongside private key)", pubKeyPath, err)
116+
}
117+
publicKey = strings.TrimSpace(string(pubKeyData))
118+
} else {
119+
// Generate ephemeral keypair
120+
pterm.Info.Println("Generating ephemeral SSH keypair...")
121+
keyPair, err := ssh.GenerateKeyPair()
122+
if err != nil {
123+
return fmt.Errorf("failed to generate SSH keypair: %w", err)
124+
}
125+
privateKeyPEM = keyPair.PrivateKeyPEM
126+
publicKey = keyPair.PublicKeyOpenSSH
127+
128+
// Write to temp file
129+
keyFile, err = ssh.WriteTempKey(privateKeyPEM, browser.SessionID)
130+
if err != nil {
131+
return fmt.Errorf("failed to write temp key: %w", err)
132+
}
133+
cleanupKey = true
134+
pterm.Debug.Printf("Temp key file: %s\n", keyFile)
135+
}
136+
137+
// Cleanup temp key on exit
138+
if cleanupKey {
139+
defer func() {
140+
pterm.Debug.Printf("Cleaning up temp key: %s\n", keyFile)
141+
os.Remove(keyFile)
142+
}()
143+
}
144+
145+
// 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 {
148+
return fmt.Errorf("failed to setup SSH on VM: %w", err)
149+
}
150+
pterm.Success.Println("SSH services running on VM")
151+
152+
if cfg.SetupOnly {
153+
pterm.Info.Println("\n--setup-only specified, not connecting.")
154+
pterm.Info.Printf("To connect manually:\n")
155+
pterm.Info.Printf(" ssh -o 'ProxyCommand=websocat --binary wss://%s:2222' -i %s root@localhost\n", vmDomain, keyFile)
156+
return nil
157+
}
158+
159+
// Build and run SSH command
160+
pterm.Info.Println("Connecting via SSH...")
161+
sshCmd := ssh.BuildSSHCommand(vmDomain, keyFile, cfg)
162+
163+
// Connect stdin/stdout/stderr
164+
sshCmd.Stdin = os.Stdin
165+
sshCmd.Stdout = os.Stdout
166+
sshCmd.Stderr = os.Stderr
167+
168+
// Handle signals to pass to SSH process
169+
sigCh := make(chan os.Signal, 1)
170+
signal.Notify(sigCh, syscall.SIGINT, syscall.SIGTERM)
171+
go func() {
172+
for sig := range sigCh {
173+
if sshCmd.Process != nil {
174+
sshCmd.Process.Signal(sig)
175+
}
176+
}
177+
}()
178+
defer signal.Stop(sigCh)
179+
180+
// Run SSH (blocks until session ends)
181+
if err := sshCmd.Run(); err != nil {
182+
// Exit code 255 is common for SSH errors, provide more context
183+
if exitErr, ok := err.(*exec.ExitError); ok {
184+
if exitErr.ExitCode() == 255 {
185+
return fmt.Errorf("SSH connection failed (exit 255). Check that:\n 1. websocat is installed and working\n 2. The browser VM is still running\n 3. Port 2222 is accessible on the VM")
186+
}
187+
}
188+
return fmt.Errorf("SSH session ended with error: %w", err)
189+
}
190+
191+
return nil
192+
}
193+
194+
// 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 {
196+
// First check if services are already running
197+
checkScript := ssh.CheckServicesScript()
198+
checkResp, err := client.Browsers.Process.Exec(ctx, sessionID, kernel.BrowserProcessExecParams{
199+
Command: "/bin/bash",
200+
Args: []string{"-c", checkScript},
201+
AsRoot: kernel.Opt(true),
202+
})
203+
if err != nil {
204+
pterm.Debug.Printf("Check services failed (will run setup): %v\n", err)
205+
} else if checkResp != nil && checkResp.StdoutB64 != "" {
206+
stdout, _ := base64.StdEncoding.DecodeString(checkResp.StdoutB64)
207+
if strings.TrimSpace(string(stdout)) == "RUNNING" {
208+
pterm.Info.Println("SSH services already running, injecting key...")
209+
// Just inject the key
210+
return injectSSHKey(ctx, client, sessionID, publicKey)
211+
}
212+
}
213+
214+
// Run full setup script
215+
setupScript := ssh.SetupScript(publicKey)
216+
resp, err := client.Browsers.Process.Exec(ctx, sessionID, kernel.BrowserProcessExecParams{
217+
Command: "/bin/bash",
218+
Args: []string{"-c", setupScript},
219+
AsRoot: kernel.Opt(true),
220+
TimeoutSec: kernel.Opt(int64(120)), // Allow 2 minutes for package install
221+
})
222+
if err != nil {
223+
return fmt.Errorf("exec failed: %w", err)
224+
}
225+
226+
if resp.ExitCode != 0 {
227+
// Decode and show stderr for debugging
228+
var stderr string
229+
if resp.StderrB64 != "" {
230+
stderrBytes, _ := base64.StdEncoding.DecodeString(resp.StderrB64)
231+
stderr = string(stderrBytes)
232+
}
233+
var stdout string
234+
if resp.StdoutB64 != "" {
235+
stdoutBytes, _ := base64.StdEncoding.DecodeString(resp.StdoutB64)
236+
stdout = string(stdoutBytes)
237+
}
238+
return fmt.Errorf("setup script failed (exit %d):\nstdout: %s\nstderr: %s", resp.ExitCode, stdout, stderr)
239+
}
240+
241+
// Log setup output for debugging
242+
if resp.StdoutB64 != "" {
243+
stdout, _ := base64.StdEncoding.DecodeString(resp.StdoutB64)
244+
pterm.Debug.Printf("Setup output:\n%s\n", string(stdout))
245+
}
246+
247+
return nil
248+
}
249+
250+
// injectSSHKey adds a public key to authorized_keys (when services already running)
251+
func injectSSHKey(ctx context.Context, client kernel.Client, sessionID, publicKey string) error {
252+
escapedKey := strings.ReplaceAll(publicKey, "'", "'\"'\"'")
253+
script := fmt.Sprintf(`mkdir -p /root/.ssh && chmod 700 /root/.ssh && echo '%s' >> /root/.ssh/authorized_keys && chmod 600 /root/.ssh/authorized_keys`, escapedKey)
254+
255+
resp, err := client.Browsers.Process.Exec(ctx, sessionID, kernel.BrowserProcessExecParams{
256+
Command: "/bin/bash",
257+
Args: []string{"-c", script},
258+
AsRoot: kernel.Opt(true),
259+
})
260+
if err != nil {
261+
return fmt.Errorf("exec failed: %w", err)
262+
}
263+
264+
if resp.ExitCode != 0 {
265+
return fmt.Errorf("key injection failed (exit %d)", resp.ExitCode)
266+
}
267+
268+
return nil
269+
}

go.mod

Lines changed: 5 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -18,6 +18,7 @@ require (
1818
github.com/spf13/pflag v1.0.6
1919
github.com/stretchr/testify v1.11.0
2020
github.com/zalando/go-keyring v0.2.6
21+
golang.org/x/crypto v0.47.0
2122
golang.org/x/oauth2 v0.30.0
2223
)
2324

@@ -54,9 +55,9 @@ require (
5455
github.com/tidwall/pretty v1.2.1 // indirect
5556
github.com/tidwall/sjson v1.2.5 // indirect
5657
github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e // indirect
57-
golang.org/x/sync v0.13.0 // indirect
58-
golang.org/x/sys v0.33.0 // indirect
59-
golang.org/x/term v0.26.0 // indirect
60-
golang.org/x/text v0.24.0 // indirect
58+
golang.org/x/sync v0.19.0 // indirect
59+
golang.org/x/sys v0.40.0 // indirect
60+
golang.org/x/term v0.39.0 // indirect
61+
golang.org/x/text v0.33.0 // indirect
6162
gopkg.in/yaml.v3 v3.0.1 // indirect
6263
)

0 commit comments

Comments
 (0)