| 1 | package main |
| 2 | |
| 3 | import ( |
| 4 | "context" |
| 5 | "crypto/rand" |
| 6 | "encoding/hex" |
| 7 | "errors" |
| 8 | "fmt" |
| 9 | "io" |
| 10 | "os" |
| 11 | "path" |
| 12 | "path/filepath" |
| 13 | "strings" |
| 14 | |
| 15 | "reasonix/internal/remote/sftpfs" |
| 16 | "reasonix/internal/store" |
| 17 | ) |
| 18 | |
| 19 | // browserRelayMaxBytes bounds one staged capture; screenshots and downloads |
| 20 | // the shell writes are far smaller, and the HTTP wire already caps replies. |
| 21 | const browserRelayMaxBytes = 32 << 20 |
| 22 | |
| 23 | // FileRelay stages a desktop-side capture file (screenshot, download) onto |
| 24 | // the remote host through the existing SFTP channel, so the remote serve's |
| 25 | // tools read a path local to them. A desktop path is never handed to the |
| 26 | // remote as-is. |
| 27 | type FileRelay interface { |
| 28 | // Stage copies localPath into the workspace's remote scratch directory |
| 29 | // and returns the remote path of the copy. |
| 30 | Stage(ctx context.Context, workspace, localPath string) (remotePath string, err error) |
| 31 | } |
| 32 | |
| 33 | type browserUploadRelay interface { |
| 34 | Fetch(ctx context.Context, workspace, remotePath, localDirectory string) (string, error) |
| 35 | } |
| 36 | |
| 37 | // Fetch stages a remote-owned file on the desktop before the local browser |
| 38 | // sees a path. Resolve both source and roots on the remote filesystem; a |
| 39 | // remote absolute filename must never be interpreted by os.Open locally. |
| 40 | func (r sftpFileRelay) Fetch(ctx context.Context, workspace, remotePath, localDirectory string) (string, error) { |
| 41 | fs, err := r.conn.SFTP() |
| 42 | if err != nil { |
| 43 | return "", err |
| 44 | } |
| 45 | home, homeErr := fs.RealPath(ctx, "~") |
| 46 | wirePath := relaySFTPPath(remotePath, home) |
| 47 | if !path.IsAbs(wirePath) && !(relayWindowsHome(home) && relayWindowsDrivePath(wirePath)) { |
| 48 | return "", fmt.Errorf("browser upload: remote path must be absolute") |
| 49 | } |
| 50 | resolved, err := fs.RealPath(ctx, wirePath) |
| 51 | if err != nil { |
| 52 | return "", err |
| 53 | } |
| 54 | roots := []string{} |
| 55 | if workspace != "" { |
| 56 | roots = append(roots, relaySFTPPath(workspace, home)) |
| 57 | } |
| 58 | if homeErr == nil { |
| 59 | roots = append(roots, path.Join(home, ".reasonix", "browser-relay", store.RemoteWorkspaceSlug(workspace))) |
| 60 | } |
| 61 | owned := false |
| 62 | for _, root := range roots { |
| 63 | canonical, err := fs.RealPath(ctx, root) |
| 64 | if err == nil && canonical != "/" && strings.HasPrefix(resolved, strings.TrimSuffix(canonical, "/")+"/") { |
| 65 | owned = true |
| 66 | break |
| 67 | } |
| 68 | } |
| 69 | if !owned { |
| 70 | return "", fmt.Errorf("browser upload: remote file is outside this task's workspace and scratch directory") |
| 71 | } |
| 72 | info, err := fs.Stat(ctx, resolved) |
| 73 | if err != nil { |
| 74 | return "", err |
| 75 | } |
| 76 | if !info.Mode.IsRegular() || info.Size > browserRelayMaxBytes { |
| 77 | return "", fmt.Errorf("browser upload: remote file must be regular and at most %d bytes", browserRelayMaxBytes) |
| 78 | } |
| 79 | dir, err := os.MkdirTemp(localDirectory, "remote-") |
| 80 | if err != nil { |
| 81 | return "", err |
| 82 | } |
| 83 | destination := filepath.Join(dir, path.Base(resolved)) |
| 84 | f, err := os.OpenFile(destination, os.O_WRONLY|os.O_CREATE|os.O_EXCL, 0o600) |
| 85 | if err != nil { |
| 86 | _ = os.RemoveAll(dir) |
| 87 | return "", err |
| 88 | } |
| 89 | _, copyErr := fs.Download(ctx, resolved, &browserUploadWriter{writer: f, remaining: browserRelayMaxBytes}) |
| 90 | closeErr := f.Close() |
| 91 | if copyErr != nil || closeErr != nil { |
| 92 | _ = os.RemoveAll(dir) |
| 93 | return "", fmt.Errorf("browser upload: remote staging failed: %w", errors.Join(copyErr, closeErr)) |
| 94 | } |
| 95 | return destination, nil |
| 96 | } |
| 97 | |
| 98 | type browserUploadWriter struct { |
| 99 | writer io.Writer |
| 100 | remaining int64 |
| 101 | } |
| 102 | |
| 103 | func (w *browserUploadWriter) Write(p []byte) (int, error) { |
| 104 | if int64(len(p)) > w.remaining { |
| 105 | return 0, fmt.Errorf("browser upload exceeds %d bytes", browserRelayMaxBytes) |
| 106 | } |
| 107 | n, err := w.writer.Write(p) |
| 108 | w.remaining -= int64(n) |
| 109 | return n, err |
| 110 | } |
| 111 | |
| 112 | // sftpConn is the slice of an SSH client the relay needs; desktopSSHClient |
| 113 | // satisfies it and tests fake it. |
| 114 | type sftpConn interface { |
| 115 | SFTP() (*sftpfs.FS, error) |
| 116 | } |
| 117 | |
| 118 | // sftpFileRelay is the FileRelay over one SSH connection generation. |
| 119 | type sftpFileRelay struct { |
| 120 | conn sftpConn |
| 121 | } |
| 122 | |
| 123 | func (r sftpFileRelay) Stage(ctx context.Context, workspace, localPath string) (string, error) { |
| 124 | info, err := os.Stat(localPath) |
| 125 | if err != nil { |
| 126 | return "", fmt.Errorf("browser relay: stat %s: %w", localPath, err) |
| 127 | } |
| 128 | if !info.Mode().IsRegular() { |
| 129 | return "", fmt.Errorf("browser relay: %s is not a regular file", localPath) |
| 130 | } |
| 131 | if info.Size() > browserRelayMaxBytes { |
| 132 | return "", fmt.Errorf("browser relay: %s exceeds %d bytes", localPath, browserRelayMaxBytes) |
| 133 | } |
| 134 | fs, err := r.conn.SFTP() |
| 135 | if err != nil { |
| 136 | return "", fmt.Errorf("browser relay: sftp: %w", err) |
| 137 | } |
| 138 | home, err := fs.RealPath(ctx, "~") |
| 139 | if err != nil { |
| 140 | return "", fmt.Errorf("browser relay: resolve remote home: %w", err) |
| 141 | } |
| 142 | dir := path.Join(home, ".reasonix", "browser-relay", store.RemoteWorkspaceSlug(workspace)) |
| 143 | if err := fs.MkdirAll(ctx, dir); err != nil { |
| 144 | return "", fmt.Errorf("browser relay: remote scratch dir: %w", err) |
| 145 | } |
| 146 | f, err := os.Open(localPath) |
| 147 | if err != nil { |
| 148 | return "", fmt.Errorf("browser relay: open %s: %w", localPath, err) |
| 149 | } |
| 150 | defer f.Close() |
| 151 | remote := path.Join(dir, relayFileName(localPath)) |
| 152 | if _, err := fs.UploadAtomic(ctx, remote, io.LimitReader(f, browserRelayMaxBytes), 0o600); err != nil { |
| 153 | return "", fmt.Errorf("browser relay: upload %s: %w", localPath, err) |
| 154 | } |
| 155 | return relayAgentPath(remote, home), nil |
| 156 | } |
| 157 | |
| 158 | // SFTP servers on Windows can canonicalize a drive as /C:/... while tools in |
| 159 | // the remote Agent need C:/... . Infer this from the remote canonical home, |
| 160 | // never from the desktop OS: a Windows desktop also connects to POSIX hosts. |
| 161 | // Keep wire paths in the server's spelling for every SFTP operation. |
| 162 | func relayAgentPath(wirePath, home string) string { |
| 163 | if relayWindowsHome(home) && strings.HasPrefix(wirePath, "/") && relayWindowsDrivePath(wirePath[1:]) { |
| 164 | return wirePath[1:] |
| 165 | } |
| 166 | return wirePath |
| 167 | } |
| 168 | |
| 169 | func relaySFTPPath(agentPath, home string) string { |
| 170 | if !relayWindowsHome(home) { |
| 171 | return agentPath |
| 172 | } |
| 173 | normalized := strings.ReplaceAll(agentPath, "\\", "/") |
| 174 | drivePath := strings.TrimPrefix(normalized, "/") |
| 175 | if !relayWindowsDrivePath(drivePath) { |
| 176 | return normalized |
| 177 | } |
| 178 | if strings.HasPrefix(home, "/") { |
| 179 | return "/" + drivePath |
| 180 | } |
| 181 | return drivePath |
| 182 | } |
| 183 | |
| 184 | func relayWindowsHome(home string) bool { |
| 185 | return relayWindowsDrivePath(strings.TrimPrefix(home, "/")) |
| 186 | } |
| 187 | |
| 188 | func relayWindowsDrivePath(value string) bool { |
| 189 | return len(value) >= 3 && ((value[0] >= 'A' && value[0] <= 'Z') || (value[0] >= 'a' && value[0] <= 'z')) && value[1] == ':' && value[2] == '/' |
| 190 | } |
| 191 | |
| 192 | // relayFileName prefixes the capture's base name with randomness so repeated |
| 193 | // captures of the same tab never overwrite a file a tool call still reads. |
| 194 | func relayFileName(localPath string) string { |
| 195 | buf := make([]byte, 8) |
| 196 | _, _ = rand.Read(buf) |
| 197 | base := filepath.Base(localPath) |
| 198 | base = strings.Map(func(r rune) rune { |
| 199 | if r == '/' || r == '\\' || r == 0 { |
| 200 | return '-' |
| 201 | } |
| 202 | return r |
| 203 | }, base) |
| 204 | if base == "" || base == "." || base == string(filepath.Separator) { |
| 205 | base = "capture" |
| 206 | } |
| 207 | return hex.EncodeToString(buf) + "-" + base |
| 208 | } |
| 209 |