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