| 1 | package main |
| 2 | |
| 3 | import ( |
| 4 | "context" |
| 5 | "encoding/json" |
| 6 | "fmt" |
| 7 | "io" |
| 8 | "net/http" |
| 9 | "net/http/cookiejar" |
| 10 | "os" |
| 11 | "os/user" |
| 12 | "path/filepath" |
| 13 | "strings" |
| 14 | "testing" |
| 15 | "time" |
| 16 | ) |
| 17 | |
| 18 | // TestCredentialProxyRealHostSmoke is a manual E2E against a real SSH host: |
| 19 | // set CRED_PROXY_SMOKE_HOST=<hostID> (whose config entry has |
| 20 | // credential_mode = "local-proxy") to run. It drives the full desktop path — |
| 21 | // connect, reverse tunnel, credential proxy, serve launch with the virtual |
| 22 | // token — then submits one real model turn and asserts the reply arrived |
| 23 | // through desktop-held credentials. Costs one tiny provider call. |
| 24 | func TestCredentialProxyRealHostSmoke(t *testing.T) { |
| 25 | hostID := os.Getenv("CRED_PROXY_SMOKE_HOST") |
| 26 | if hostID == "" { |
| 27 | t.Skip("set CRED_PROXY_SMOKE_HOST=<hostID with credential_mode=local-proxy> to run the real-host smoke") |
| 28 | } |
| 29 | // The desktop test binary's TestMain redirects HOME to a scratch dir; the |
| 30 | // smoke needs the real user config (hosts, provider, .env key). Point the |
| 31 | // config root back at the real ~/.reasonix for this test only. |
| 32 | if real, err := user.Current(); err == nil && real.HomeDir != "" { |
| 33 | t.Setenv("HOME", real.HomeDir) |
| 34 | t.Setenv("REASONIX_HOME", filepath.Join(real.HomeDir, ".reasonix")) |
| 35 | } else { |
| 36 | t.Fatal("cannot resolve the real home directory") |
| 37 | } |
| 38 | workspace := os.Getenv("CRED_PROXY_SMOKE_WORKSPACE") |
| 39 | if workspace == "" { |
| 40 | workspace = "/root/smoke-a" |
| 41 | } |
| 42 | |
| 43 | a := &App{ctx: context.Background()} |
| 44 | mgr := newDesktopRemoteManager(a) |
| 45 | a.remoteRuntime = mgr |
| 46 | t.Cleanup(func() { _ = mgr.Close(); a.closeCredentialProxy() }) |
| 47 | |
| 48 | ctx, cancel := context.WithTimeout(context.Background(), 5*time.Minute) |
| 49 | defer cancel() |
| 50 | |
| 51 | if err := mgr.Connect(hostID); err != nil { |
| 52 | t.Fatalf("connect: %v", err) |
| 53 | } |
| 54 | if err := waitForRemoteHostSmoke(mgr, hostID, 60*time.Second); err != nil { |
| 55 | t.Fatalf("host never connected: %v", err) |
| 56 | } |
| 57 | |
| 58 | view, token, err := mgr.EnsureServer(ctx, hostID, workspace) |
| 59 | if err != nil { |
| 60 | t.Fatalf("EnsureServer: %v", err) |
| 61 | } |
| 62 | if view.State != "ready" || view.LocalURL == "" { |
| 63 | t.Fatalf("serve view = %+v", view) |
| 64 | } |
| 65 | base := strings.TrimRight(view.LocalURL, "/") |
| 66 | t.Logf("serve ready at %s (workspace %s)", base, workspace) |
| 67 | |
| 68 | // Verify the reverse tunnel is registered and the remote provider entry |
| 69 | // points at it. |
| 70 | foundForward := false |
| 71 | for _, f := range mgr.client(hostID).Forwards().List() { |
| 72 | if f.Spec.Name == "cred-proxy:"+hostID && f.Up { |
| 73 | foundForward = true |
| 74 | } |
| 75 | } |
| 76 | if !foundForward { |
| 77 | t.Fatal("credential proxy reverse forward is not up") |
| 78 | } |
| 79 | |
| 80 | // One real model turn through the desktop-held key. |
| 81 | jar, _ := cookiejar.New(nil) |
| 82 | client := &http.Client{Jar: jar, Timeout: 120 * time.Second} |
| 83 | if err := serveHandshake(ctx, client, base, token); err != nil { |
| 84 | t.Fatalf("handshake: %v", err) |
| 85 | } |
| 86 | post := func(path string, body any) (int, []byte) { |
| 87 | data, _ := json.Marshal(body) |
| 88 | req, _ := http.NewRequest(http.MethodPost, base+path, strings.NewReader(string(data))) |
| 89 | req.Header.Set("Content-Type", "application/json") |
| 90 | resp, err := client.Do(req) |
| 91 | if err != nil { |
| 92 | t.Fatalf("%s: %v", path, err) |
| 93 | } |
| 94 | defer resp.Body.Close() |
| 95 | out, _ := io.ReadAll(resp.Body) |
| 96 | return resp.StatusCode, out |
| 97 | } |
| 98 | if code, body := post("/new", map[string]string{}); code != 204 { |
| 99 | t.Fatalf("/new: %d %s", code, body) |
| 100 | } |
| 101 | if code, body := post("/submit", map[string]string{"input": "Reply with exactly the word PROXY-OK and nothing else."}); code != 202 { |
| 102 | t.Fatalf("/submit: %d %s", code, body) |
| 103 | } |
| 104 | |
| 105 | // Poll history for the assistant reply (the turn rides the tunnel). |
| 106 | deadline := time.Now().Add(90 * time.Second) |
| 107 | for { |
| 108 | resp, err := client.Get(base + "/history") |
| 109 | if err == nil { |
| 110 | out, _ := io.ReadAll(resp.Body) |
| 111 | resp.Body.Close() |
| 112 | if strings.Contains(string(out), "PROXY-OK") { |
| 113 | t.Logf("model replied through the desktop credential proxy") |
| 114 | return |
| 115 | } |
| 116 | if resp.StatusCode != http.StatusOK { |
| 117 | t.Fatalf("/history: %d %s", resp.StatusCode, out) |
| 118 | } |
| 119 | } |
| 120 | if time.Now().After(deadline) { |
| 121 | t.Fatalf("no PROXY-OK reply within 90s (last history fetch err=%v)", err) |
| 122 | } |
| 123 | time.Sleep(2 * time.Second) |
| 124 | } |
| 125 | } |
| 126 | |
| 127 | func waitForRemoteHostSmoke(rt remoteKernel, hostID string, timeout time.Duration) error { |
| 128 | deadline := time.Now().Add(timeout) |
| 129 | for { |
| 130 | for _, status := range rt.Statuses() { |
| 131 | if status.HostID != hostID { |
| 132 | continue |
| 133 | } |
| 134 | switch status.State { |
| 135 | case "connected", "degraded": |
| 136 | return nil |
| 137 | case "stopped": |
| 138 | if status.Error != "" { |
| 139 | return fmt.Errorf("remote host %q: %s", hostID, status.Error) |
| 140 | } |
| 141 | return fmt.Errorf("remote host %q stopped", hostID) |
| 142 | } |
| 143 | } |
| 144 | if time.Now().After(deadline) { |
| 145 | return fmt.Errorf("remote host %q: connection timed out", hostID) |
| 146 | } |
| 147 | time.Sleep(250 * time.Millisecond) |
| 148 | } |
| 149 | } |
| 150 |