返回 DeepSeek-Reasonix
remote_app.go
根目录 / desktop / remote_app.go
1 package main
2
3 import (
4 "context"
5 "encoding/json"
6 "errors"
7 "fmt"
8 "io"
9 "net"
10 "net/http"
11 "os"
12 "os/exec"
13 "path"
14 "path/filepath"
15 "runtime"
16 "strings"
17 "sync"
18 "sync/atomic"
19 "time"
20
21 "reasonix/internal/agent"
22 "reasonix/internal/config"
23 "reasonix/internal/netclient"
24 "reasonix/internal/remote"
25 "reasonix/internal/remote/bootstrap"
26 "reasonix/internal/remote/forward"
27 )
28
29 // ── View structs mirrored in frontend/src/lib/types.ts ──
30
31 type RemoteHostView struct {
32 ID string `json:"id"`
33 Label string `json:"label"`
34 Host string `json:"host"`
35 Port int `json:"port"`
36 User string `json:"user"`
37 IdentityFile string `json:"identityFile"`
38 ProxyJump string `json:"proxyJump"`
39 DefaultWorkspace string `json:"defaultWorkspace"`
40 ServeInstall string `json:"serveInstall"`
41 CredentialMode string `json:"credentialMode"`
42 UseSSHConfig bool `json:"useSSHConfig"`
43 PasswordSet bool `json:"passwordSet,omitempty"`
44 KeyPassphraseSet bool `json:"keyPassphraseSet,omitempty"`
45 }
46
47 type RemoteHostInput struct {
48 Label string `json:"label"`
49 Host string `json:"host"`
50 Port int `json:"port"`
51 User string `json:"user"`
52 IdentityFile string `json:"identityFile"`
53 ProxyJump string `json:"proxyJump"`
54 DefaultWorkspace string `json:"defaultWorkspace"`
55 ServeInstall string `json:"serveInstall"`
56 CredentialMode string `json:"credentialMode"`
57 UseSSHConfig bool `json:"useSSHConfig"`
58 Password string `json:"password,omitempty"`
59 KeyPassphrase string `json:"keyPassphrase,omitempty"`
60 ClearPassword bool `json:"clearPassword,omitempty"`
61 ClearPassphrase bool `json:"clearPassphrase,omitempty"`
62 PreserveExistingSettings bool `json:"preserveExistingSettings,omitempty"`
63 }
64
65 type RemoteFingerprintView struct {
66 HostID string `json:"hostId"`
67 Address string `json:"address"`
68 KeyType string `json:"keyType"`
69 SHA256 string `json:"sha256"`
70 }
71
72 type RemoteConnectionStatusView struct {
73 HostID string `json:"hostId"`
74 State string `json:"state"`
75 Error string `json:"error,omitempty"`
76 ErrorDetails *RemoteConnectionErrorDetailsView `json:"errorDetails,omitempty"`
77 Fingerprint *RemoteFingerprintView `json:"fingerprint,omitempty"`
78 SecretPrompt *RemoteSecretPromptView `json:"secretPrompt,omitempty"`
79 Attempt int `json:"attempt,omitempty"`
80 }
81
82 // RemoteSecretPromptView contains prompt metadata only. Secret text travels
83 // one way through ConfirmRemoteSecret and is never emitted in status events.
84 type RemoteSecretPromptView struct {
85 PromptID string `json:"promptId"`
86 HostID string `json:"hostId"`
87 Host string `json:"host"`
88 Kind string `json:"kind"` // password | passphrase
89 Identity string `json:"identity,omitempty"`
90 }
91
92 type RemoteKnownHostLocationView struct {
93 Path string `json:"path"`
94 Line int `json:"line"`
95 }
96
97 type RemoteConnectionErrorDetailsView struct {
98 Code string `json:"code"`
99 PresentedSHA256 string `json:"presentedSha256,omitempty"`
100 KnownHostRecords []RemoteKnownHostLocationView `json:"knownHostRecords,omitempty"`
101 }
102
103 type RemoteDirEntry struct {
104 Name string `json:"name"`
105 Path string `json:"path"`
106 IsDir bool `json:"isDir"`
107 Size int64 `json:"size"`
108 MtimeUnix int64 `json:"mtimeUnix"`
109 Symlink bool `json:"symlink"`
110 }
111
112 type RemoteFilePreview struct {
113 Path string `json:"path"`
114 Body string `json:"body"`
115 Size int64 `json:"size"`
116 MtimeUnix int64 `json:"mtimeUnix"`
117 Truncated bool `json:"truncated"`
118 Binary bool `json:"binary"`
119 Err string `json:"err,omitempty"`
120 }
121
122 type RemoteWriteResult struct {
123 OK bool `json:"ok"`
124 Conflict bool `json:"conflict"`
125 NewMtimeUnix int64 `json:"newMtimeUnix"`
126 }
127
128 type RemoteForwardInput struct {
129 LocalPort int `json:"localPort"`
130 RemoteHost string `json:"remoteHost"`
131 RemotePort int `json:"remotePort"`
132 Label string `json:"label"`
133 }
134
135 type RemoteForwardView struct {
136 ID string `json:"id"`
137 HostID string `json:"hostId"`
138 LocalPort int `json:"localPort"`
139 RemoteHost string `json:"remoteHost"`
140 RemotePort int `json:"remotePort"`
141 Label string `json:"label"`
142 State string `json:"state"`
143 Error string `json:"error,omitempty"`
144 }
145
146 type RemoteServerView struct {
147 HostID string `json:"hostId"`
148 Workspace string `json:"workspace"`
149 State string `json:"state"`
150 Message string `json:"message,omitempty"`
151 LocalURL string `json:"localUrl,omitempty"`
152 InstanceID string `json:"instanceId,omitempty"`
153 Error string `json:"error,omitempty"`
154 }
155
156 // ── Kernel seam ──
157
158 // remoteKernel is the desktop's view of the remote subsystem. The concrete
159 // *desktopRemoteManager satisfies it; remote_app_test.go injects a fake.
160 type remoteKernel interface {
161 Hosts() ([]RemoteHostView, error)
162 AddHost(RemoteHostInput) (RemoteHostView, error)
163 UpdateHost(id string, in RemoteHostInput) (RemoteHostView, error)
164 RemoveHost(id string) error
165 ScanSSHConfig() ([]RemoteHostInput, error)
166
167 Connect(hostID string) error
168 Disconnect(hostID string) error
169 Statuses() []RemoteConnectionStatusView
170 ResolveHostKey(hostID string, accept bool) error
171 ResolveSecret(hostID, promptID, secret string, accept bool) error
172
173 ListDir(ctx context.Context, hostID, path string) ([]RemoteDirEntry, error)
174 ReadFile(ctx context.Context, hostID, path string) (RemoteFilePreview, error)
175 DownloadFile(ctx context.Context, hostID, path string, dst io.Writer) (int64, error)
176 WriteFile(ctx context.Context, hostID, path, body string, expectMtime int64) (RemoteWriteResult, error)
177 Mkdir(ctx context.Context, hostID, path string) error
178 Rename(ctx context.Context, hostID, oldPath, newPath string) error
179 Delete(ctx context.Context, hostID, path string, recursive bool) error
180
181 Forwards(hostID string) []RemoteForwardView
182 AddForward(hostID string, in RemoteForwardInput) (RemoteForwardView, error)
183 RemoveForward(hostID, forwardID string) error
184
185 EnsureServer(ctx context.Context, hostID, workspace string) (RemoteServerView, string, error)
186 SwitchCredentialProxyModel(ctx context.Context, hostID, workspace, currentRef, nextRef, expectedPath string) error
187 StopServer(hostID, workspace string) error
188 ServerStatus(hostID, workspace string) RemoteServerView
189 // ServeSnapshot is the read-only lookup for already-running serves; it
190 // must never contact the remote or wake anything up.
191 ServeSnapshot(hostID, workspace string) (RemoteServerView, string, bool)
192 ServerLogs(ctx context.Context, hostID, workspace string, tailLines int) (string, error)
193 CheckPlatform(ctx context.Context, hostID string) error
194 Close() error
195 }
196
197 // remoteEventSink receives kernel status transitions for bridging to the
198 // frontend. All methods may be called from kernel goroutines.
199 type remoteEventSink interface {
200 onStatus(RemoteConnectionStatusView)
201 onForwards(hostID string, forwards []RemoteForwardView)
202 onServer(RemoteServerView)
203 }
204
205 // ── App wiring ──
206
207 func (a *App) remoteRT() (remoteKernel, error) {
208 a.remoteMu.Lock()
209 defer a.remoteMu.Unlock()
210 if a.remoteRuntime != nil {
211 return a.remoteRuntime, nil
212 }
213 mgr := newDesktopRemoteManager(a)
214 a.remoteRuntime = mgr
215 return mgr, nil
216 }
217
218 func (a *App) stopRemoteRuntime() {
219 a.remoteMu.Lock()
220 rt := a.remoteRuntime
221 a.remoteRuntime = nil
222 a.remoteMu.Unlock()
223 if rt != nil {
224 _ = rt.Close()
225 }
226 a.closeRemoteBrokers()
227 }
228
229 // remoteEventSink implementation on *App.
230 func (a *App) onStatus(s RemoteConnectionStatusView) {
231 a.emitRemoteEvent("remote:status", s)
232 a.remoteTabsHostStatus(s.HostID, s.State, s.Error)
233 // Close web windows after terminal SSH failures; transient reconnects keep
234 // them open while the status event explains the failure.
235 if s.State == "stopped" && s.Error != "" {
236 // Status callbacks may run while desktopRemoteManager.mu is held, so never
237 // wait on the host lifecycle mutex here. Capturing the generation before
238 // queueing makes a later explicit reconnect/open supersede this close.
239 op := a.beginRemoteWindowHostOperation(s.HostID)
240 a.goSafe("remoteWindowTerminalClose", func() {
241 _ = op.run(func(func() bool) error {
242 a.closeRemoteWindowForHost(s.HostID)
243 return nil
244 })
245 })
246 return
247 }
248 // After a reconnect the loopback tunnel rebinds to a new port. Re-point an
249 // open web window at the fresh Serve URL so it stays usable.
250 if s.State == "connected" && a.hasRemoteWindow(s.HostID) {
251 a.refreshRemoteWindowAfterReconnect(s.HostID)
252 }
253 }
254
255 // refreshRemoteWindowAfterReconnect re-establishes the Serve forward after an
256 // SSH reconnect and navigates the host's web window to the new loopback URL.
257 // The remote Serve process is reused, so this is a cheap state probe when the
258 // tunnel already rebinding — the window is kept regardless of failure, and the
259 // user can reopen it if the Serve itself went away.
260 func (a *App) refreshRemoteWindowAfterReconnect(hostID string) {
261 op := a.beginRemoteWindowHostOperation(hostID)
262 a.goSafe("remoteWindowReconnect", func() {
263 _ = op.run(func(current func() bool) error {
264 rt, err := a.remoteRT()
265 if err != nil {
266 return nil
267 }
268 ws := a.remoteWindowWorkspace(hostID)
269 if strings.TrimSpace(ws) == "" {
270 // Window opened before workspace tracking: fall back to the
271 // host's last workspace.
272 ws = a.RemoteLastWorkspace(hostID)
273 }
274 if strings.TrimSpace(ws) == "" {
275 return nil
276 }
277 status := rt.ServerStatus(hostID, ws)
278 if status.State != "ready" {
279 return nil
280 }
281 view, token, err := rt.EnsureServer(a.bootContext(), hostID, ws)
282 if err != nil || view.State != "ready" || view.LocalURL == "" || !current() {
283 return nil
284 }
285 if !a.hasRemoteWindow(hostID) {
286 return nil
287 }
288 _ = a.openRemoteWindowForHost(hostID, ws, serveURLWithToken(view.LocalURL, token))
289 return nil
290 })
291 })
292 }
293
294 func (a *App) onServer(s RemoteServerView) { a.emitRemoteEvent("remote:server", s) }
295 func (a *App) onForwards(hostID string, f []RemoteForwardView) {
296 a.emitRemoteEvent("remote:forwards", map[string]any{"hostId": hostID, "forwards": f})
297 }
298
299 // ── Bound methods ──
300
301 func (a *App) RemoteHosts() ([]RemoteHostView, error) {
302 rt, err := a.remoteRT()
303 if err != nil {
304 return nil, err
305 }
306 return rt.Hosts()
307 }
308
309 func (a *App) AddRemoteHost(in RemoteHostInput) (RemoteHostView, error) {
310 rt, err := a.remoteRT()
311 if err != nil {
312 return RemoteHostView{}, err
313 }
314 return rt.AddHost(in)
315 }
316
317 func (a *App) UpdateRemoteHost(id string, in RemoteHostInput) (RemoteHostView, error) {
318 rt, err := a.remoteRT()
319 if err != nil {
320 return RemoteHostView{}, err
321 }
322 return rt.UpdateHost(id, in)
323 }
324
325 func (a *App) RemoveRemoteHost(id string) error {
326 op := a.beginRemoteWindowHostOperation(id)
327 return op.run(func(func() bool) error {
328 rt, err := a.remoteRT()
329 if err != nil {
330 return err
331 }
332 if err := rt.RemoveHost(id); err != nil {
333 return err
334 }
335 if err := a.removeRemoteTabsForHost(id); err != nil {
336 return fmt.Errorf("replace tabs for removed remote host: %w", err)
337 }
338 a.closeRemoteWindowForHost(id)
339 return nil
340 })
341 }
342
343 func (a *App) ScanSSHConfig() ([]RemoteHostInput, error) {
344 rt, err := a.remoteRT()
345 if err != nil {
346 return nil, err
347 }
348 return rt.ScanSSHConfig()
349 }
350
351 func (a *App) ConnectRemoteHost(id string) error {
352 rt, err := a.remoteRT()
353 if err != nil {
354 return err
355 }
356 if err := rt.Connect(id); err != nil {
357 view := RemoteConnectionStatusView{HostID: id, State: "stopped"}
358 applyRemoteConnectionError(&view, err)
359 a.onStatus(view)
360 return err
361 }
362 return nil
363 }
364
365 func applyRemoteConnectionError(view *RemoteConnectionStatusView, err error) {
366 if err == nil {
367 return
368 }
369 view.Error = err.Error()
370 if view.State == "degraded" {
371 return
372 }
373 details := &RemoteConnectionErrorDetailsView{Code: "connection_failed"}
374 switch {
375 case errors.Is(err, remote.ErrHostKeyMismatch):
376 details.Code = "host_key_mismatch"
377 var mismatch *remote.HostKeyMismatchError
378 if errors.As(err, &mismatch) {
379 details.PresentedSHA256 = mismatch.PresentedFingerprint
380 details.KnownHostRecords = make([]RemoteKnownHostLocationView, 0, len(mismatch.Locations))
381 for _, location := range mismatch.Locations {
382 details.KnownHostRecords = append(details.KnownHostRecords, RemoteKnownHostLocationView{
383 Path: location.Filename,
384 Line: location.Line,
385 })
386 }
387 }
388 case errors.Is(err, remote.ErrAuthFailed):
389 details.Code = "auth_failed"
390 case errors.Is(err, remote.ErrHostKeyRejected):
391 details.Code = "host_key_rejected"
392 }
393 view.ErrorDetails = details
394 }
395
396 func (a *App) DisconnectRemoteHost(id string) error {
397 op := a.beginRemoteWindowHostOperation(id)
398 return op.run(func(func() bool) error {
399 rt, err := a.remoteRT()
400 if err != nil {
401 return err
402 }
403 if err := rt.Disconnect(id); err != nil {
404 return err
405 }
406 // An explicit disconnect kills the loopback tunnel; close the host's web
407 // window so the user is not left staring at a dead Serve page. The remote
408 // Serve itself stays resident.
409 a.closeRemoteWindowForHost(id)
410 return nil
411 })
412 }
413
414 func (a *App) RemoteConnectionStatuses() []RemoteConnectionStatusView {
415 rt, err := a.remoteRT()
416 if err != nil {
417 return nil
418 }
419 return rt.Statuses()
420 }
421
422 func (a *App) ConfirmRemoteHostKey(hostID string, accept bool) error {
423 rt, err := a.remoteRT()
424 if err != nil {
425 return err
426 }
427 return rt.ResolveHostKey(hostID, accept)
428 }
429
430 // ConfirmRemoteSecret resolves a one-shot interactive SSH credential prompt.
431 // The secret is retained only in the connection's in-memory reconnect cache;
432 // callers must use the host settings form when they explicitly want storage.
433 func (a *App) ConfirmRemoteSecret(hostID, promptID, secret string, accept bool) error {
434 rt, err := a.remoteRT()
435 if err != nil {
436 return err
437 }
438 return rt.ResolveSecret(hostID, promptID, secret, accept)
439 }
440
441 func (a *App) ListRemoteDir(hostID, path string) ([]RemoteDirEntry, error) {
442 rt, err := a.remoteRT()
443 if err != nil {
444 return nil, err
445 }
446 return rt.ListDir(a.bootContext(), hostID, path)
447 }
448
449 func (a *App) ReadRemoteFile(hostID, path string) (RemoteFilePreview, error) {
450 rt, err := a.remoteRT()
451 if err != nil {
452 return RemoteFilePreview{}, err
453 }
454 return rt.ReadFile(a.bootContext(), hostID, path)
455 }
456
457 // SaveRemoteFileAs streams a remote file directly to a user-selected local
458 // destination. It never interprets the remote path as a local system path.
459 func (a *App) SaveRemoteFileAs(hostID, remotePath string) (string, error) {
460 return a.saveRemoteFileAs(hostID, remotePath)
461 }
462
463 // SaveRemotePresentedFileAs requires the remote path to come from the trusted
464 // metadata of the matching built-in present call before it opens a local save
465 // dialog. The SSH host remains the source of bytes.
466 func (a *App) SaveRemotePresentedFileAs(tabID, hostID, toolCallID, remotePath string) (string, error) {
467 fence, err := a.requireRemotePresentedFiles(tabID, hostID)
468 if err != nil {
469 return "", err
470 }
471 snapshot, err := a.RemoteTabSnapshot(tabID)
472 if err != nil {
473 return "", err
474 }
475 if !a.remotePresentedFilesFenceCurrent(fence) {
476 return "", fmt.Errorf("remote file selection changed while validating the presented file")
477 }
478 if !remotePresentedFileDeclared(snapshot.History, toolCallID, remotePath) {
479 return "", os.ErrPermission
480 }
481 return a.saveRemoteFileAs(hostID, remotePath)
482 }
483
484 // ResolveRemotePresentedPathForTab returns the absolute coordinate on the
485 // remote source host. The trusted history and route generation are checked;
486 // the resulting remote path is never passed to a local system-open API.
487 func (a *App) ResolveRemotePresentedPathForTab(tabID, hostID, toolCallID, remotePath string) (string, error) {
488 fence, err := a.requireRemotePresentedFiles(tabID, hostID)
489 if err != nil {
490 return "", err
491 }
492 snapshot, err := a.RemoteTabSnapshot(tabID)
493 if err != nil {
494 return "", err
495 }
496 if !a.remotePresentedFilesFenceCurrent(fence) {
497 return "", fmt.Errorf("remote file selection changed while validating the presented file")
498 }
499 if !remotePresentedFileDeclared(snapshot.History, toolCallID, remotePath) {
500 return "", os.ErrPermission
501 }
502 return remotePresentedAbsolutePath(fence.tab.ref.Workspace, remotePath), nil
503 }
504
505 // ResolveRemoteWorkspacePathForTab binds a derived workspace artifact to the
506 // authenticated remote tab that produced it. Unlike a presented file this has
507 // no present-call grant, so only paths contained by the tab workspace resolve.
508 func (a *App) ResolveRemoteWorkspacePathForTab(tabID, hostID, toolCallID, remotePath string) (string, error) {
509 fence, err := a.requireRemoteWorkspaceArtifact(tabID, hostID)
510 if err != nil {
511 return "", err
512 }
513 snapshot, err := a.RemoteTabSnapshot(tabID)
514 if err != nil {
515 return "", err
516 }
517 if !a.remotePresentedFilesFenceCurrent(fence) || !remoteWorkspaceArtifactDeclared(snapshot.History, toolCallID, remotePath) {
518 return "", os.ErrPermission
519 }
520 return confinedRemoteWorkspacePath(fence.tab.ref.Workspace, remotePath)
521 }
522
523 func confinedRemoteWorkspacePath(workspace, requested string) (string, error) {
524 workspace = strings.TrimSpace(workspace)
525 requested = strings.TrimSpace(requested)
526 if workspace == "" || requested == "" {
527 return "", os.ErrPermission
528 }
529 windows := isWindowsAbsolutePath(workspace) || strings.Contains(workspace, `\`)
530 normalize := func(value string) string {
531 clean := path.Clean(strings.ReplaceAll(value, `\`, "/"))
532 if windows {
533 clean = strings.ToLower(clean)
534 }
535 return strings.TrimRight(clean, "/")
536 }
537 root := normalize(workspace)
538 target := requested
539 if !path.IsAbs(requested) && !filepath.IsAbs(requested) && !isWindowsAbsolutePath(requested) {
540 target = strings.TrimRight(workspace, `/\`) + "/" + strings.TrimLeft(requested, `/\`)
541 }
542 canonical := normalize(target)
543 if canonical != root && !strings.HasPrefix(canonical, root+"/") {
544 return "", os.ErrPermission
545 }
546 if windows {
547 return strings.ReplaceAll(path.Clean(strings.ReplaceAll(target, `\`, "/")), "/", `\`), nil
548 }
549 return path.Clean(target), nil
550 }
551
552 func remotePresentedAbsolutePath(workspace, presented string) string {
553 presented = strings.TrimSpace(presented)
554 workspace = strings.TrimSpace(workspace)
555 if path.IsAbs(presented) || filepath.IsAbs(presented) || isWindowsAbsolutePath(presented) {
556 return presented
557 }
558 if isWindowsAbsolutePath(workspace) || strings.Contains(workspace, `\`) {
559 return strings.TrimRight(workspace, `/\`) + `\` + strings.TrimLeft(strings.ReplaceAll(presented, "/", `\`), `/\`)
560 }
561 return path.Join(workspace, presented)
562 }
563
564 func isWindowsAbsolutePath(value string) bool {
565 drive := len(value) >= 3 && ((value[0] >= 'A' && value[0] <= 'Z') || (value[0] >= 'a' && value[0] <= 'z')) && value[1] == ':' && (value[2] == '\\' || value[2] == '/')
566 return drive || strings.HasPrefix(value, `\\`)
567 }
568
569 type remotePresentedFilesFence struct {
570 tab *remoteTab
571 tabID string
572 hostID string
573 client *http.Client
574 base string
575 generation uint64
576 selectionRevision uint64
577 sessionPath string
578 }
579
580 // requireRemotePresentedFiles binds an action to the same authenticated remote
581 // tab and host that produced the deliverable. A path from the renderer is only
582 // a locator; the trusted present record is re-read from remote history below.
583 func (a *App) requireRemotePresentedFiles(tabID, hostID string) (remotePresentedFilesFence, error) {
584 fence, err := a.requireRemoteWorkspaceArtifact(tabID, hostID)
585 if err != nil {
586 return remotePresentedFilesFence{}, err
587 }
588 if !fence.tab.capabilities["present-files-v1"] {
589 return remotePresentedFilesFence{}, fmt.Errorf("this remote Reasonix Serve does not support present-files-v1")
590 }
591 return fence, nil
592 }
593
594 func (a *App) requireRemoteWorkspaceArtifact(tabID, hostID string) (remotePresentedFilesFence, error) {
595 a.remoteTabMu.Lock()
596 defer a.remoteTabMu.Unlock()
597 tab := a.remoteTabs[tabID]
598 valid := tab != nil && tab.ref.HostID == hostID && tab.client != nil && tab.state == "ready" &&
599 strings.TrimSpace(tab.routing.currentPath) != "" && tab.routing.rehydratingPath == ""
600 if !valid {
601 return remotePresentedFilesFence{}, os.ErrPermission
602 }
603 return remotePresentedFilesFence{
604 tab: tab,
605 tabID: tabID,
606 hostID: hostID,
607 client: tab.client,
608 base: tab.base,
609 generation: tab.gen,
610 selectionRevision: tab.selectionRevision,
611 sessionPath: tab.routing.currentPath,
612 }, nil
613 }
614
615 func remoteWorkspaceArtifactDeclared(history json.RawMessage, toolCallID, requested string) bool {
616 if strings.TrimSpace(toolCallID) == "" || strings.TrimSpace(requested) == "" {
617 return false
618 }
619 var messages []struct {
620 Role string `json:"role"`
621 ToolCallID string `json:"toolCallId"`
622 ToolName string `json:"toolName"`
623 ToolError string `json:"toolResultError"`
624 ToolCalls []struct {
625 ID string `json:"id"`
626 Name string `json:"name"`
627 Arguments string `json:"arguments"`
628 } `json:"toolCalls"`
629 }
630 if json.Unmarshal(history, &messages) != nil {
631 return false
632 }
633 declared := false
634 toolName := ""
635 for _, message := range messages {
636 for _, call := range message.ToolCalls {
637 if call.ID != toolCallID {
638 continue
639 }
640 field := "path"
641 switch call.Name {
642 case "write_file", "edit_file", "multi_edit", "notebook_edit", "delete_range", "delete_symbol":
643 case "move_file":
644 field = "destination_path"
645 default:
646 continue
647 }
648 var args map[string]any
649 if json.Unmarshal([]byte(call.Arguments), &args) == nil && args[field] == requested {
650 declared, toolName = true, call.Name
651 }
652 }
653 }
654 if !declared {
655 return false
656 }
657 for _, message := range messages {
658 if message.Role == "tool" && message.ToolCallID == toolCallID && message.ToolName == toolName && message.ToolError == "" {
659 return true
660 }
661 }
662 return false
663 }
664
665 func (a *App) remotePresentedFilesFenceCurrent(fence remotePresentedFilesFence) bool {
666 a.remoteTabMu.Lock()
667 defer a.remoteTabMu.Unlock()
668 current := a.remoteTabs[fence.tabID]
669 return current != nil && current == fence.tab && current.ref.HostID == fence.hostID &&
670 current.client == fence.client && current.base == fence.base && current.gen == fence.generation &&
671 current.selectionRevision == fence.selectionRevision && current.state == "ready" &&
672 current.routing.rehydratingPath == "" &&
673 agent.CanonicalSessionPath(current.routing.currentPath) == agent.CanonicalSessionPath(fence.sessionPath)
674 }
675
676 func remotePresentedFileDeclared(history json.RawMessage, toolCallID, requested string) bool {
677 if strings.TrimSpace(toolCallID) == "" || strings.TrimSpace(requested) == "" {
678 return false
679 }
680 var messages []struct {
681 Role string `json:"role"`
682 ToolCallID string `json:"toolCallId"`
683 ToolName string `json:"toolName"`
684 PresentedFiles []struct {
685 Path string `json:"path"`
686 } `json:"presentedFiles"`
687 }
688 if json.Unmarshal(history, &messages) != nil {
689 return false
690 }
691 for _, message := range messages {
692 if message.Role != "tool" || message.ToolName != "present" || message.ToolCallID != toolCallID {
693 continue
694 }
695 for _, file := range message.PresentedFiles {
696 if file.Path == requested {
697 return true
698 }
699 }
700 }
701 return false
702 }
703
704 func (a *App) saveRemoteFileAs(hostID, remotePath string) (string, error) {
705 if a.ctx == nil {
706 return "", nil
707 }
708 target, err := a.nativeHost().SaveFileDialog(a.ctx, nativeDialogOptions{
709 Title: "Save remote file as", DefaultFilename: path.Base(remotePath), CanCreateDirectories: true,
710 })
711 if err != nil || target == "" {
712 return "", err
713 }
714 rt, err := a.remoteRT()
715 if err != nil {
716 return "", err
717 }
718 tmp, err := os.CreateTemp(filepath.Dir(target), ".reasonix-remote-download-*")
719 if err != nil {
720 return "", err
721 }
722 tmpPath := tmp.Name()
723 defer os.Remove(tmpPath)
724 if _, err = rt.DownloadFile(a.bootContext(), hostID, remotePath, tmp); err != nil {
725 _ = tmp.Close()
726 return "", err
727 }
728 if err = tmp.Sync(); err == nil {
729 err = tmp.Close()
730 } else {
731 _ = tmp.Close()
732 }
733 if err != nil {
734 return "", err
735 }
736 if err = os.Rename(tmpPath, target); err != nil {
737 return "", err
738 }
739 return target, nil
740 }
741
742 func (a *App) WriteRemoteFile(hostID, path, body string, expectMtimeUnix int64) (RemoteWriteResult, error) {
743 rt, err := a.remoteRT()
744 if err != nil {
745 return RemoteWriteResult{}, err
746 }
747 return rt.WriteFile(a.bootContext(), hostID, path, body, expectMtimeUnix)
748 }
749
750 func (a *App) MkdirRemote(hostID, path string) error {
751 rt, err := a.remoteRT()
752 if err != nil {
753 return err
754 }
755 return rt.Mkdir(a.bootContext(), hostID, path)
756 }
757
758 func (a *App) RenameRemotePath(hostID, oldPath, newPath string) error {
759 rt, err := a.remoteRT()
760 if err != nil {
761 return err
762 }
763 return rt.Rename(a.bootContext(), hostID, oldPath, newPath)
764 }
765
766 func (a *App) DeleteRemotePath(hostID, path string, recursive bool) error {
767 rt, err := a.remoteRT()
768 if err != nil {
769 return err
770 }
771 return rt.Delete(a.bootContext(), hostID, path, recursive)
772 }
773
774 func (a *App) RemoteForwards(hostID string) ([]RemoteForwardView, error) {
775 rt, err := a.remoteRT()
776 if err != nil {
777 return nil, err
778 }
779 return rt.Forwards(hostID), nil
780 }
781
782 func (a *App) AddRemoteForward(hostID string, in RemoteForwardInput) (RemoteForwardView, error) {
783 rt, err := a.remoteRT()
784 if err != nil {
785 return RemoteForwardView{}, err
786 }
787 return rt.AddForward(hostID, in)
788 }
789
790 func (a *App) RemoveRemoteForward(hostID, forwardID string) error {
791 rt, err := a.remoteRT()
792 if err != nil {
793 return err
794 }
795 return rt.RemoveForward(hostID, forwardID)
796 }
797
798 // OpenRemoteWorkspace is the idempotent "open remote web" entry: it starts or
799 // reuses the target workspace's remote Serve, atomically replaces the loopback
800 // tunnel, then opens (or re-points) the host's web window.
801 //
802 // Two-phase switch contract:
803 // - If Serve/tunnel establishment fails, nothing is touched: the previous
804 // window and tunnel stay exactly as they were, and no workspace is saved.
805 // - Once the new Serve and tunnel are committed, the switch is final. A
806 // window-open failure (spawn error) surfaces to the caller while the Serve
807 // stays ready for the new workspace, and the recorded last workspace
808 // matches the running Serve so the next open reuses it. The previous
809 // window, if any, is left in place; it is re-pointed by the next
810 // successful open (or closed by an explicit disconnect/stop).
811 func (a *App) OpenRemoteWorkspace(hostID, workspace string) error {
812 op := a.beginRemoteWindowHostOperation(hostID)
813 return op.run(func(current func() bool) error {
814 rt, err := a.remoteRT()
815 if err != nil {
816 return err
817 }
818 view, token, err := rt.EnsureServer(a.bootContext(), hostID, workspace)
819 if err != nil {
820 return err
821 }
822 // A disconnect, stop, removal, or terminal SSH failure that began while
823 // EnsureServer was in flight owns the final state and must prevent a late
824 // child window from being spawned against its dead tunnel.
825 if !current() {
826 return nil
827 }
828 if view.LocalURL == "" {
829 return fmt.Errorf("remote serve did not report a local URL")
830 }
831 url := serveURLWithToken(view.LocalURL, token)
832 if err := a.saveLastRemoteWorkspace(hostID, workspace); err != nil {
833 return err
834 }
835 return a.openRemoteWindowForHost(hostID, workspace, url)
836 })
837 }
838
839 // serveURLWithToken appends the one-shot Serve token to the first-visit URL.
840 // The remote serve converts it to an HttpOnly cookie on the first request and
841 // redirects to a token-free URL.
842 func serveURLWithToken(localURL, token string) string {
843 if token != "" && !strings.Contains(localURL, "token=") {
844 return fmt.Sprintf("%s?token=%s", strings.TrimRight(localURL, "/"), token)
845 }
846 return localURL
847 }
848
849 func (a *App) RemoteServerStatus(hostID, workspace string) (RemoteServerView, error) {
850 rt, err := a.remoteRT()
851 if err != nil {
852 return RemoteServerView{}, err
853 }
854 return rt.ServerStatus(hostID, workspace), nil
855 }
856
857 func (a *App) RemoteServerLogs(hostID, workspace string, tailLines int) (string, error) {
858 rt, err := a.remoteRT()
859 if err != nil {
860 return "", err
861 }
862 return rt.ServerLogs(a.bootContext(), hostID, workspace, tailLines)
863 }
864
865 // ── desktopRemoteManager: concrete remoteKernel ──
866
867 type managedHost struct {
868 client desktopSSHClient
869 ctx context.Context
870 cancel context.CancelFunc
871 status RemoteConnectionStatusView
872 serves map[string]*serveEntry // per-workspace serve registry, keyed by the workspace string
873 fpAnswer chan bool // TOFU resolution channel; non-nil while pending
874 secretAnswer chan remoteSecretAnswer // one-shot credential channel; non-nil while pending
875 secretPromptID string // opaque ID prevents a stale dialog resolving a later prompt
876 verifiedPeer *RemoteFingerprintView // authenticated target key; retained after pending UI clears
877 serveMu sync.Mutex // serializes EnsureServer/StopServer for this host
878 // credPort is the last healed reverse-tunnel port; 0 means unknown.
879 // It is atomic because ensure and status dispatch use different locks.
880 credPort atomic.Int64
881 // credFallbackAt throttles legacy-serve replacement per workspace.
882 credFallbackAt map[string]int64
883 credWatch credentialWatchdog
884 }
885
886 // serveEntry is one workspace's serve registration: the published view (with
887 // its loopback tunnel URL), the auth token for that serve, and the remote
888 // bind address the forward targets (kept so a ready entry can be revalidated
889 // without a fresh ensure round trip).
890 type serveEntry struct {
891 view RemoteServerView
892 token string
893 addr string
894 }
895
896 type remoteSecretAnswer struct {
897 secret string
898 accept bool
899 }
900
901 type desktopSSHClient interface {
902 bootstrap.Conn
903 Start(context.Context) error
904 Close() error
905 Subscribe(func(remote.StatusEvent)) func()
906 Forwards() *forward.Set
907 }
908
909 type desktopRemoteManager struct {
910 sink remoteEventSink
911
912 mu sync.Mutex
913 hosts map[string]*managedHost
914
915 newClient func(remote.Options) (desktopSSHClient, error)
916 ensureServe func(context.Context, bootstrap.Conn, bootstrap.Options) (bootstrap.Result, error)
917 stopServe func(context.Context, bootstrap.Conn, string) error
918 serveLogs func(context.Context, bootstrap.Conn, string, int, *strings.Builder) error
919 localBinary func() string
920 fetchRemoteBinary func(context.Context, string, string, string) ([]byte, error)
921 promptGate chan struct{}
922 promptSeq uint64
923 }
924
925 func newDesktopRemoteManager(sink remoteEventSink) *desktopRemoteManager {
926 return &desktopRemoteManager{
927 sink: sink,
928 hosts: map[string]*managedHost{},
929 newClient: func(opts remote.Options) (desktopSSHClient, error) {
930 return remote.New(opts)
931 },
932 ensureServe: bootstrap.EnsureServe,
933 stopServe: bootstrap.Stop,
934 serveLogs: func(ctx context.Context, conn bootstrap.Conn, workspace string, n int, out *strings.Builder) error {
935 return bootstrap.Logs(ctx, conn, workspace, n, out)
936 },
937 localBinary: desktopCLIBinaryPath,
938 fetchRemoteBinary: downloadRemoteCLIBinary,
939 promptGate: make(chan struct{}, 1),
940 }
941 }
942
943 func (m *desktopRemoteManager) Hosts() ([]RemoteHostView, error) {
944 cfg, err := config.Load()
945 if err != nil {
946 return nil, err
947 }
948 out := make([]RemoteHostView, 0, len(cfg.Remote.Hosts))
949 for _, h := range cfg.Remote.Hosts {
950 out = append(out, hostEntryToView(h))
951 }
952 return out, nil
953 }
954
955 func (m *desktopRemoteManager) AddHost(in RemoteHostInput) (RemoteHostView, error) {
956 var entry config.RemoteHostEntry
957 if err := config.EditUserConfigWithCredentials(func(c *config.Config) ([]config.CredentialChange, error) {
958 entry = inputToHostEntry(in)
959 if existing, ok := c.RemoteHost(entry.Name); ok {
960 preserveRemoteHostHiddenFields(&entry, existing)
961 if in.PreserveExistingSettings {
962 preserveRemoteHostImportSettings(&entry, existing)
963 }
964 }
965 changes, removals := applyRemoteCredentialInput(&entry, in)
966 if err := c.UpsertRemoteHost(entry); err != nil {
967 return nil, err
968 }
969 return append(changes, config.UnusedGeneratedRemoteCredentialChanges(c, removals)...), nil
970 }); err != nil {
971 return RemoteHostView{}, err
972 }
973 return hostEntryToView(entry), nil
974 }
975
976 func (m *desktopRemoteManager) UpdateHost(id string, in RemoteHostInput) (RemoteHostView, error) {
977 var merged config.RemoteHostEntry
978 if err := config.EditUserConfigWithCredentials(func(c *config.Config) ([]config.CredentialChange, error) {
979 entry := inputToHostEntry(in)
980 entry.Name = id
981 if existing, ok := c.RemoteHost(id); ok {
982 preserveRemoteHostHiddenFields(&entry, existing)
983 }
984 changes, removals := applyRemoteCredentialInput(&entry, in)
985 merged = entry
986 if err := c.UpsertRemoteHost(entry); err != nil {
987 return nil, err
988 }
989 return append(changes, config.UnusedGeneratedRemoteCredentialChanges(c, removals)...), nil
990 }); err != nil {
991 return RemoteHostView{}, err
992 }
993 if !merged.CredentialProxyEnabled() {
994 m.mu.Lock()
995 mh := m.hosts[id]
996 m.mu.Unlock()
997 if mh != nil {
998 mh.credWatch.stop()
999 }
1000 }
1001 return hostEntryToView(merged), nil
1002 }
1003
1004 func (m *desktopRemoteManager) RemoveHost(id string) error {
1005 _ = m.Disconnect(id)
1006 removed := false
1007 if err := config.EditUserConfigWithCredentials(func(c *config.Config) ([]config.CredentialChange, error) {
1008 var removals []string
1009 if existing, ok := c.RemoteHost(id); ok {
1010 for _, key := range []string{existing.PasswordEnv, existing.PassphraseEnv} {
1011 if config.IsGeneratedRemoteCredential(id, key) {
1012 removals = append(removals, key)
1013 }
1014 }
1015 }
1016 removed = c.RemoveRemoteHost(id)
1017 return config.UnusedGeneratedRemoteCredentialChanges(c, removals), nil
1018 }); err != nil {
1019 return err
1020 }
1021 if !removed {
1022 return fmt.Errorf("no remote host named %q", id)
1023 }
1024 return nil
1025 }
1026
1027 func (m *desktopRemoteManager) ScanSSHConfig() ([]RemoteHostInput, error) {
1028 src, err := remote.LoadUserSSHConfig()
1029 if err != nil {
1030 return nil, err
1031 }
1032 // Non-nil so Wails encodes an empty result as [] (not null), which the React
1033 // import page iterates safely.
1034 out := []RemoteHostInput{}
1035 for _, cand := range src.Aliases() {
1036 out = append(out, RemoteHostInput{
1037 Label: cand.Alias,
1038 Host: cand.Alias,
1039 Port: 0,
1040 UseSSHConfig: true,
1041 PreserveExistingSettings: true,
1042 })
1043 }
1044 return out, nil
1045 }
1046
1047 func (m *desktopRemoteManager) Connect(hostID string) error {
1048 cfg, err := config.Load()
1049 if err != nil {
1050 return err
1051 }
1052 sshCfg, err := remote.LoadUserSSHConfig()
1053 if err != nil {
1054 return fmt.Errorf("load SSH config: %w", err)
1055 }
1056 host, err := remote.ResolveHost(cfg, hostID, sshCfg)
1057 if err != nil {
1058 return err
1059 }
1060
1061 resolvedJumps, err := remote.ResolveJumpHosts(cfg, host.ProxyJump, sshCfg)
1062 if err != nil {
1063 return err
1064 }
1065
1066 // Honor the user's proxy settings for the SSH dial, same as the CLI.
1067 dialer, derr := netclient.NewStreamDialer(cfg.NetworkProxySpec())
1068 if derr != nil {
1069 return fmt.Errorf("remote: network proxy is misconfigured: %w", derr)
1070 }
1071
1072 hostCtx, cancel := context.WithCancel(context.Background())
1073 mh := &managedHost{
1074 ctx: hostCtx, cancel: cancel,
1075 status: RemoteConnectionStatusView{HostID: hostID, State: "connecting"},
1076 serves: map[string]*serveEntry{}, credFallbackAt: map[string]int64{},
1077 }
1078 secretPrompt := m.secretPrompt(hostID, mh)
1079 auth := desktopAuthForHost(host, secretPrompt)
1080 jumpHosts := make([]remote.JumpHostOptions, 0, len(resolvedJumps))
1081 for _, jump := range resolvedJumps {
1082 jumpHosts = append(jumpHosts, remote.JumpHostOptions{Host: jump, Auth: desktopAuthForHost(jump, secretPrompt)})
1083 }
1084 policy := &remote.HostKeyPolicy{
1085 Prompt: m.hostKeyPrompt(hostID, mh),
1086 Verified: func(q remote.HostKeyQuestion) {
1087 if q.Host != host.Label() {
1088 return // a ProxyJump identity is not the target identity
1089 }
1090 m.mu.Lock()
1091 if m.hosts[hostID] == mh {
1092 mh.verifiedPeer = &RemoteFingerprintView{
1093 HostID: hostID, Address: q.Address, KeyType: q.KeyType, SHA256: q.Fingerprint,
1094 }
1095 }
1096 m.mu.Unlock()
1097 },
1098 }
1099 client, err := m.newClient(remote.Options{
1100 Host: host, Auth: auth, JumpHosts: jumpHosts, HostKeys: policy, Dialer: dialer,
1101 })
1102 if err != nil {
1103 cancel()
1104 return err
1105 }
1106 mh.client = client
1107
1108 // Insert a fully-populated generation atomically. A stopped generation is
1109 // replaceable; active/connecting generations make Connect idempotent.
1110 var replaced *managedHost
1111 m.mu.Lock()
1112 if existing := m.hosts[hostID]; existing != nil && existing.status.State != "stopped" {
1113 m.mu.Unlock()
1114 cancel()
1115 _ = client.Close()
1116 return nil // already connecting/connected
1117 }
1118 replaced = m.hosts[hostID]
1119 m.hosts[hostID] = mh
1120 m.mu.Unlock()
1121 closeManagedHost(replaced)
1122
1123 client.Subscribe(func(ev remote.StatusEvent) { m.onClientStatus(hostID, mh, ev) })
1124
1125 go func() {
1126 if err := client.Start(hostCtx); err != nil {
1127 // Keep the stopped generation and its user-visible error. The next
1128 // Connect atomically replaces it with a fresh client.
1129 cancel()
1130 _ = client.Close()
1131 return
1132 }
1133 m.applyConfiguredForwards(hostID, mh, cfg)
1134 }()
1135 return nil
1136 }
1137
1138 func desktopAuthForHost(host remote.ResolvedHost, prompt remote.SecretPrompt) remote.AuthOptions {
1139 auth := remote.AuthOptions{SecretPrompt: prompt}
1140 if host.PassphraseEnv != "" {
1141 env := host.PassphraseEnv
1142 auth.Passphrase = func() (string, error) { return config.ResolveCredential(env).Value, nil }
1143 }
1144 if host.PasswordEnv != "" {
1145 env := host.PasswordEnv
1146 auth.Password = func() (string, error) { return config.ResolveCredential(env).Value, nil }
1147 }
1148 return auth
1149 }
1150
1151 func (m *desktopRemoteManager) applyConfiguredForwards(hostID string, mh *managedHost, cfg *config.Config) {
1152 entry, ok := cfg.RemoteHost(hostID)
1153 if !ok || !m.isCurrent(hostID, mh) {
1154 return
1155 }
1156 for _, f := range entry.Forwards {
1157 dir := forward.Local
1158 if strings.EqualFold(f.Type, "remote") {
1159 dir = forward.Remote
1160 }
1161 _, _ = mh.client.Forwards().Add(forward.Spec{Direction: dir, BindAddr: desktopNormalizeBind(f.Bind), TargetAddr: f.Target})
1162 }
1163 m.emitForwardsFor(hostID, mh)
1164 }
1165
1166 func (m *desktopRemoteManager) Disconnect(hostID string) error {
1167 m.mu.Lock()
1168 mh := m.hosts[hostID]
1169 delete(m.hosts, hostID)
1170 var answer chan bool
1171 var secretAnswer chan remoteSecretAnswer
1172 if mh != nil {
1173 answer = mh.fpAnswer
1174 mh.fpAnswer = nil
1175 secretAnswer = mh.secretAnswer
1176 mh.secretAnswer = nil
1177 mh.secretPromptID = ""
1178 }
1179 if mh != nil && m.sink != nil {
1180 m.sink.onStatus(RemoteConnectionStatusView{HostID: hostID, State: "stopped"})
1181 }
1182 m.mu.Unlock()
1183 if mh == nil {
1184 return nil
1185 }
1186 if answer != nil {
1187 select {
1188 case answer <- false:
1189 default:
1190 }
1191 }
1192 if secretAnswer != nil {
1193 select {
1194 case secretAnswer <- remoteSecretAnswer{}:
1195 default:
1196 }
1197 }
1198 closeManagedHost(mh)
1199 return nil
1200 }
1201
1202 func closeManagedHost(mh *managedHost) {
1203 if mh == nil {
1204 return
1205 }
1206 mh.credWatch.stop()
1207 if mh.cancel != nil {
1208 mh.cancel()
1209 }
1210 if mh.client != nil {
1211 _ = mh.client.Close()
1212 }
1213 }
1214
1215 func (m *desktopRemoteManager) Statuses() []RemoteConnectionStatusView {
1216 m.mu.Lock()
1217 defer m.mu.Unlock()
1218 out := make([]RemoteConnectionStatusView, 0, len(m.hosts))
1219 for _, mh := range m.hosts {
1220 out = append(out, mh.status)
1221 }
1222 return out
1223 }
1224
1225 func (m *desktopRemoteManager) ResolveHostKey(hostID string, accept bool) error {
1226 m.mu.Lock()
1227 mh := m.hosts[hostID]
1228 var ch chan bool
1229 if mh != nil {
1230 ch = mh.fpAnswer
1231 }
1232 m.mu.Unlock()
1233 if ch == nil {
1234 return fmt.Errorf("no pending host key confirmation for %q", hostID)
1235 }
1236 select {
1237 case ch <- accept:
1238 return nil
1239 default:
1240 return fmt.Errorf("host key confirmation already resolved for %q", hostID)
1241 }
1242 }
1243
1244 func (m *desktopRemoteManager) ResolveSecret(hostID, promptID, secret string, accept bool) error {
1245 m.mu.Lock()
1246 mh := m.hosts[hostID]
1247 var ch chan remoteSecretAnswer
1248 if mh != nil && mh.secretPromptID == promptID {
1249 ch = mh.secretAnswer
1250 }
1251 m.mu.Unlock()
1252 if ch == nil {
1253 return fmt.Errorf("no pending SSH credential prompt for %q", hostID)
1254 }
1255 select {
1256 case ch <- remoteSecretAnswer{secret: secret, accept: accept}:
1257 return nil
1258 default:
1259 return fmt.Errorf("SSH credential prompt already resolved for %q", hostID)
1260 }
1261 }
1262
1263 // hostKeyPrompt returns a HostKeyPrompt that surfaces the fingerprint as a
1264 // pending_hostkey status and blocks on the answer channel until the UI calls
1265 // ConfirmRemoteHostKey.
1266 func (m *desktopRemoteManager) hostKeyPrompt(hostID string, generation *managedHost) remote.HostKeyPrompt {
1267 return func(ctx context.Context, q remote.HostKeyQuestion) (bool, error) {
1268 // The frontend presents one global TOFU dialog. Serialize prompts so two
1269 // simultaneous first-seen hosts cannot overwrite one another in the UI.
1270 select {
1271 case m.promptGate <- struct{}{}:
1272 defer func() { <-m.promptGate }()
1273 case <-ctx.Done():
1274 return false, ctx.Err()
1275 }
1276 answer := make(chan bool, 1)
1277 m.mu.Lock()
1278 mh := m.hosts[hostID]
1279 if mh != generation {
1280 m.mu.Unlock()
1281 return false, fmt.Errorf("host %q connection was replaced", hostID)
1282 }
1283 mh.fpAnswer = answer
1284 fp := &RemoteFingerprintView{HostID: hostID, Address: q.Address, KeyType: q.KeyType, SHA256: q.Fingerprint}
1285 mh.status = RemoteConnectionStatusView{HostID: hostID, State: "pending_hostkey", Fingerprint: fp}
1286 status := mh.status
1287 if m.sink != nil {
1288 m.sink.onStatus(status)
1289 }
1290 m.mu.Unlock()
1291 defer func() {
1292 m.mu.Lock()
1293 if m.hosts[hostID] == generation && generation.fpAnswer == answer {
1294 generation.fpAnswer = nil
1295 }
1296 m.mu.Unlock()
1297 }()
1298
1299 select {
1300 case ok := <-answer:
1301 return ok, nil
1302 case <-ctx.Done():
1303 return false, ctx.Err()
1304 case <-time.After(2 * time.Minute):
1305 return false, fmt.Errorf("host key confirmation timed out")
1306 }
1307 }
1308 }
1309
1310 // secretPrompt surfaces a password/passphrase request as a global desktop
1311 // dialog. Prompt metadata may be emitted, but the entered secret only crosses
1312 // the one-shot answer channel and AuthOptions' in-memory reconnect cache.
1313 func (m *desktopRemoteManager) secretPrompt(hostID string, generation *managedHost) remote.SecretPrompt {
1314 return func(ctx context.Context, kind remote.SecretKind, host, identityFile string) (string, error) {
1315 select {
1316 case m.promptGate <- struct{}{}:
1317 defer func() { <-m.promptGate }()
1318 case <-ctx.Done():
1319 return "", ctx.Err()
1320 }
1321
1322 answer := make(chan remoteSecretAnswer, 1)
1323 m.mu.Lock()
1324 mh := m.hosts[hostID]
1325 if mh != generation {
1326 m.mu.Unlock()
1327 return "", fmt.Errorf("host %q connection was replaced", hostID)
1328 }
1329 m.promptSeq++
1330 promptID := fmt.Sprintf("ssh-secret-%d", m.promptSeq)
1331 mh.secretAnswer = answer
1332 mh.secretPromptID = promptID
1333 identity := ""
1334 if strings.TrimSpace(identityFile) != "" {
1335 identity = filepath.Base(identityFile)
1336 }
1337 prompt := &RemoteSecretPromptView{PromptID: promptID, HostID: hostID, Host: host, Kind: kind.String(), Identity: identity}
1338 mh.status = RemoteConnectionStatusView{HostID: hostID, State: "pending_secret", SecretPrompt: prompt}
1339 status := mh.status
1340 if m.sink != nil {
1341 m.sink.onStatus(status)
1342 }
1343 m.mu.Unlock()
1344 defer func() {
1345 m.mu.Lock()
1346 if m.hosts[hostID] == generation && generation.secretAnswer == answer {
1347 generation.secretAnswer = nil
1348 generation.secretPromptID = ""
1349 }
1350 m.mu.Unlock()
1351 }()
1352
1353 select {
1354 case response := <-answer:
1355 if !response.accept {
1356 return "", fmt.Errorf("remote: %s prompt canceled", kind)
1357 }
1358 return response.secret, nil
1359 case <-ctx.Done():
1360 return "", ctx.Err()
1361 case <-time.After(2 * time.Minute):
1362 return "", fmt.Errorf("remote: %s prompt timed out", kind)
1363 }
1364 }
1365 }
1366
1367 func (m *desktopRemoteManager) onClientStatus(hostID string, generation *managedHost, ev remote.StatusEvent) {
1368 if ev.Status == remote.StatusIdle {
1369 return
1370 }
1371 view := RemoteConnectionStatusView{
1372 HostID: hostID,
1373 State: statusString(ev.Status),
1374 Attempt: ev.Attempt,
1375 }
1376 if ev.Err != nil {
1377 applyRemoteConnectionError(&view, ev.Err)
1378 }
1379 m.mu.Lock()
1380 mh := m.hosts[hostID]
1381 if mh != generation {
1382 m.mu.Unlock()
1383 return
1384 }
1385 // Preserve a pending modal that a separate prompt goroutine set.
1386 if (mh.status.State == "pending_hostkey" || mh.status.State == "pending_secret") && view.State == "connecting" {
1387 m.mu.Unlock()
1388 return
1389 }
1390 mh.status = view
1391 if view.State == "connected" {
1392 // Reconnects rebind the reverse forward, so force the next ensure to
1393 // heal the new credential channel while m.mu is already held.
1394 mh.credPort.Store(0)
1395 }
1396 if m.sink != nil {
1397 m.sink.onStatus(view)
1398 }
1399 m.mu.Unlock()
1400 }
1401
1402 func (m *desktopRemoteManager) isCurrent(hostID string, generation *managedHost) bool {
1403 m.mu.Lock()
1404 defer m.mu.Unlock()
1405 return m.hosts[hostID] == generation
1406 }
1407
1408 func (m *desktopRemoteManager) client(hostID string) desktopSSHClient {
1409 m.mu.Lock()
1410 defer m.mu.Unlock()
1411 if mh := m.hosts[hostID]; mh != nil {
1412 return mh.client
1413 }
1414 return nil
1415 }
1416
1417 func (m *desktopRemoteManager) fs(ctx context.Context, hostID string) (desktopSSHClient, error) {
1418 c := m.client(hostID)
1419 if c == nil {
1420 return nil, fmt.Errorf("host %q is not connected", hostID)
1421 }
1422 return c, nil
1423 }
1424
1425 func (m *desktopRemoteManager) ListDir(ctx context.Context, hostID, path string) ([]RemoteDirEntry, error) {
1426 c, err := m.fs(ctx, hostID)
1427 if err != nil {
1428 return nil, err
1429 }
1430 fsys, err := c.SFTP()
1431 if err != nil {
1432 return nil, err
1433 }
1434 entries, err := fsys.List(ctx, path)
1435 if err != nil {
1436 return nil, err
1437 }
1438 out := make([]RemoteDirEntry, 0, len(entries))
1439 for _, e := range entries {
1440 out = append(out, RemoteDirEntry{
1441 Name: e.Name, Path: e.Path, IsDir: e.IsDir,
1442 Size: e.Size, MtimeUnix: e.ModTime, Symlink: e.Symlink,
1443 })
1444 }
1445 return out, nil
1446 }
1447
1448 func (m *desktopRemoteManager) ReadFile(ctx context.Context, hostID, path string) (RemoteFilePreview, error) {
1449 c, err := m.fs(ctx, hostID)
1450 if err != nil {
1451 return RemoteFilePreview{}, err
1452 }
1453 fsys, err := c.SFTP()
1454 if err != nil {
1455 return RemoteFilePreview{}, err
1456 }
1457 st, err := fsys.Stat(ctx, path)
1458 if err != nil {
1459 return RemoteFilePreview{Path: path, Err: err.Error()}, nil
1460 }
1461 data, truncated, kind, err := fsys.ReadFile(ctx, path, 0)
1462 if err != nil {
1463 return RemoteFilePreview{Path: path, Err: err.Error()}, nil
1464 }
1465 binary := kind != 0 // sftpfs.KindText == 0
1466 prev := RemoteFilePreview{
1467 Path: path, Size: st.Size, MtimeUnix: st.ModTime,
1468 Truncated: truncated, Binary: binary,
1469 }
1470 if !binary {
1471 prev.Body = string(data)
1472 }
1473 return prev, nil
1474 }
1475
1476 func (m *desktopRemoteManager) DownloadFile(ctx context.Context, hostID, remotePath string, dst io.Writer) (int64, error) {
1477 c, err := m.fs(ctx, hostID)
1478 if err != nil {
1479 return 0, err
1480 }
1481 fsys, err := c.SFTP()
1482 if err != nil {
1483 return 0, err
1484 }
1485 return fsys.Download(ctx, remotePath, dst)
1486 }
1487
1488 func (m *desktopRemoteManager) WriteFile(ctx context.Context, hostID, path, body string, expectMtime int64) (RemoteWriteResult, error) {
1489 c, err := m.fs(ctx, hostID)
1490 if err != nil {
1491 return RemoteWriteResult{}, err
1492 }
1493 fsys, err := c.SFTP()
1494 if err != nil {
1495 return RemoteWriteResult{}, err
1496 }
1497 // Optimistic-concurrency check: if the caller passed an expected mtime and
1498 // the remote file moved, report a conflict instead of overwriting.
1499 if expectMtime > 0 {
1500 if st, serr := fsys.Stat(ctx, path); serr == nil && st.ModTime != expectMtime {
1501 return RemoteWriteResult{Conflict: true}, nil
1502 }
1503 }
1504 if err := fsys.WriteFileAtomic(ctx, path, []byte(body), 0o644); err != nil {
1505 return RemoteWriteResult{}, err
1506 }
1507 st, _ := fsys.Stat(ctx, path)
1508 return RemoteWriteResult{OK: true, NewMtimeUnix: st.ModTime}, nil
1509 }
1510
1511 func (m *desktopRemoteManager) Mkdir(ctx context.Context, hostID, path string) error {
1512 c, err := m.fs(ctx, hostID)
1513 if err != nil {
1514 return err
1515 }
1516 fsys, err := c.SFTP()
1517 if err != nil {
1518 return err
1519 }
1520 return fsys.MkdirAll(ctx, path)
1521 }
1522
1523 func (m *desktopRemoteManager) Rename(ctx context.Context, hostID, oldPath, newPath string) error {
1524 c, err := m.fs(ctx, hostID)
1525 if err != nil {
1526 return err
1527 }
1528 fsys, err := c.SFTP()
1529 if err != nil {
1530 return err
1531 }
1532 return fsys.Rename(ctx, oldPath, newPath)
1533 }
1534
1535 func (m *desktopRemoteManager) Delete(ctx context.Context, hostID, path string, recursive bool) error {
1536 c, err := m.fs(ctx, hostID)
1537 if err != nil {
1538 return err
1539 }
1540 fsys, err := c.SFTP()
1541 if err != nil {
1542 return err
1543 }
1544 return fsys.Remove(ctx, path, recursive)
1545 }
1546
1547 func (m *desktopRemoteManager) Forwards(hostID string) []RemoteForwardView {
1548 c := m.client(hostID)
1549 if c == nil {
1550 return nil
1551 }
1552 return forwardEntriesToViews(hostID, c.Forwards().List())
1553 }
1554
1555 func (m *desktopRemoteManager) AddForward(hostID string, in RemoteForwardInput) (RemoteForwardView, error) {
1556 c := m.client(hostID)
1557 if c == nil {
1558 return RemoteForwardView{}, fmt.Errorf("host %q is not connected", hostID)
1559 }
1560 if in.LocalPort <= 0 || in.LocalPort > 65535 || in.RemotePort <= 0 || in.RemotePort > 65535 || strings.TrimSpace(in.RemoteHost) == "" {
1561 return RemoteForwardView{}, fmt.Errorf("forward requires a remote host and ports between 1 and 65535")
1562 }
1563 spec := forward.Spec{
1564 Name: in.Label,
1565 Direction: forward.Local,
1566 BindAddr: net.JoinHostPort("127.0.0.1", fmt.Sprint(in.LocalPort)),
1567 TargetAddr: net.JoinHostPort(strings.TrimSpace(in.RemoteHost), fmt.Sprint(in.RemotePort)),
1568 }
1569 if _, err := c.Forwards().Add(spec); err != nil {
1570 return RemoteForwardView{}, err
1571 }
1572 m.emitForwards(hostID)
1573 view := RemoteForwardView{
1574 ID: spec.DefaultName(), HostID: hostID, LocalPort: in.LocalPort,
1575 RemoteHost: in.RemoteHost, RemotePort: in.RemotePort, Label: in.Label, State: "active",
1576 }
1577 return view, nil
1578 }
1579
1580 func (m *desktopRemoteManager) RemoveForward(hostID, forwardID string) error {
1581 c := m.client(hostID)
1582 if c == nil {
1583 return fmt.Errorf("host %q is not connected", hostID)
1584 }
1585 if err := c.Forwards().Remove(forwardID); err != nil {
1586 return err
1587 }
1588 m.emitForwards(hostID)
1589 return nil
1590 }
1591
1592 func (m *desktopRemoteManager) emitForwards(hostID string) {
1593 mh := m.managed(hostID)
1594 if mh != nil {
1595 m.emitForwardsFor(hostID, mh)
1596 }
1597 }
1598
1599 func (m *desktopRemoteManager) emitForwardsFor(hostID string, generation *managedHost) {
1600 entries := generation.client.Forwards().List()
1601 m.mu.Lock()
1602 defer m.mu.Unlock()
1603 if m.hosts[hostID] != generation {
1604 return
1605 }
1606 if m.sink != nil {
1607 m.sink.onForwards(hostID, forwardEntriesToViews(hostID, entries))
1608 }
1609 }
1610
1611 func (m *desktopRemoteManager) Close() error {
1612 m.mu.Lock()
1613 hosts := m.hosts
1614 m.hosts = map[string]*managedHost{}
1615 answers := make([]chan bool, 0, len(hosts))
1616 secretAnswers := make([]chan remoteSecretAnswer, 0, len(hosts))
1617 for _, mh := range hosts {
1618 if mh.fpAnswer != nil {
1619 answers = append(answers, mh.fpAnswer)
1620 mh.fpAnswer = nil
1621 }
1622 if mh.secretAnswer != nil {
1623 secretAnswers = append(secretAnswers, mh.secretAnswer)
1624 mh.secretAnswer = nil
1625 }
1626 }
1627 m.mu.Unlock()
1628 for _, answer := range answers {
1629 select {
1630 case answer <- false:
1631 default:
1632 }
1633 }
1634 for _, answer := range secretAnswers {
1635 select {
1636 case answer <- remoteSecretAnswer{}:
1637 default:
1638 }
1639 }
1640 for _, mh := range hosts {
1641 closeManagedHost(mh)
1642 }
1643 return nil
1644 }
1645
1646 func managedOperationContext(parent context.Context, mh *managedHost) (context.Context, context.CancelFunc) {
1647 if parent == nil {
1648 parent = context.Background()
1649 }
1650 ctx, cancel := context.WithCancel(parent)
1651 stop := func() bool { return false }
1652 if mh != nil && mh.ctx != nil {
1653 stop = context.AfterFunc(mh.ctx, cancel)
1654 }
1655 return ctx, func() {
1656 stop()
1657 cancel()
1658 }
1659 }
1660
1661 // publishFailedServeStart keeps host server ownership on a previous ready
1662 // Serve when a new Serve or its tunnel failed to establish. The previous
1663 // Serve is still running with its tunnel (forward Replace is atomic), so
1664 // Stop/Logs and reconnect refresh must keep operating on the workspace that
1665 // actually runs; the failure is delivered through the EnsureServer return
1666 // value and the caller's actionErr. When there is no previous ready Serve
1667 // (first start), the error view is published so the UI can show it.
1668 func (m *desktopRemoteManager) publishFailedServeStart(hostID string, generation *managedHost, previous RemoteServerView, previousToken, previousAddr string, failed RemoteServerView) {
1669 if previous.State == "ready" {
1670 m.publishServerIfCurrent(hostID, generation, previous, previousToken, previousAddr)
1671 return
1672 }
1673 m.publishServerIfCurrent(hostID, generation, failed, "", "")
1674 }
1675
1676 func (m *desktopRemoteManager) publishServerIfCurrent(hostID string, generation *managedHost, view RemoteServerView, token, addr string) bool {
1677 m.mu.Lock()
1678 defer m.mu.Unlock()
1679 if m.hosts[hostID] != generation {
1680 return false
1681 }
1682 if generation.serves == nil {
1683 generation.serves = map[string]*serveEntry{}
1684 }
1685 generation.serves[view.Workspace] = &serveEntry{view: view, token: token, addr: addr}
1686 if m.sink != nil {
1687 m.sink.onServer(view)
1688 }
1689 return true
1690 }
1691
1692 func hasUsableServeForward(entries []forward.Entry, name, targetAddr, localURL string) bool {
1693 for _, entry := range entries {
1694 if entry.Spec.Name == name && entry.Up && entry.Spec.TargetAddr == targetAddr && entry.BoundAddr != "" {
1695 return localURL == fmt.Sprintf("http://%s/", entry.BoundAddr)
1696 }
1697 }
1698 return false
1699 }
1700
1701 func desktopCLIBinaryPath() string {
1702 packagedName, commandName := desktopCLIBinaryNames(runtime.GOOS)
1703 candidates := []string{}
1704 if exe, err := os.Executable(); err == nil {
1705 dir := filepath.Dir(exe)
1706 candidates = append(candidates, filepath.Join(dir, packagedName))
1707 }
1708 if found, err := exec.LookPath(commandName); err == nil {
1709 candidates = append(candidates, found)
1710 }
1711 for _, candidate := range candidates {
1712 st, err := os.Stat(candidate)
1713 if err != nil || !st.Mode().IsRegular() {
1714 continue
1715 }
1716 if runtime.GOOS != "windows" && st.Mode().Perm()&0o111 == 0 {
1717 continue
1718 }
1719 return candidate
1720 }
1721 return ""
1722 }
1723
1724 func desktopCLIBinaryNames(goos string) (packaged, command string) {
1725 if goos == "windows" {
1726 return "reasonix-cli.exe", "reasonix.exe"
1727 }
1728 return "reasonix", "reasonix"
1729 }
1730
1731 func desktopNormalizeBind(bind string) string {
1732 bind = strings.TrimSpace(bind)
1733 if !strings.Contains(bind, ":") {
1734 return net.JoinHostPort("127.0.0.1", bind)
1735 }
1736 return bind
1737 }
1738
1739 // ── helpers ──
1740
1741 func preserveRemoteHostHiddenFields(entry *config.RemoteHostEntry, existing config.RemoteHostEntry) {
1742 entry.PassphraseEnv = existing.PassphraseEnv
1743 entry.PasswordEnv = existing.PasswordEnv
1744 entry.Forwards = append([]config.RemoteForwardEntry(nil), existing.Forwards...)
1745 }
1746
1747 // Importing an already-managed SSH alias refreshes only its OpenSSH lookup
1748 // fields. Reasonix-specific workspace and bootstrap policy remain user-owned.
1749 func preserveRemoteHostImportSettings(entry *config.RemoteHostEntry, existing config.RemoteHostEntry) {
1750 entry.Workspace = existing.Workspace
1751 entry.ServeInstall = existing.ServeInstall
1752 entry.CredentialMode = existing.CredentialMode
1753 }
1754
1755 // applyRemoteCredentialInput maps plaintext received from the one-shot Wails
1756 // call into Reasonix-owned credential slots. Blank fields preserve the current
1757 // reference; explicit clear flags remove only slots that this desktop created.
1758 func applyRemoteCredentialInput(entry *config.RemoteHostEntry, in RemoteHostInput) (changes []config.CredentialChange, removalCandidates []string) {
1759 if in.ClearPassword {
1760 if config.IsGeneratedRemoteCredential(entry.Name, entry.PasswordEnv) {
1761 removalCandidates = append(removalCandidates, entry.PasswordEnv)
1762 }
1763 entry.PasswordEnv = ""
1764 }
1765 if in.Password != "" {
1766 entry.PasswordEnv = config.RemotePasswordCredentialEnvName(entry.Name)
1767 changes = append(changes, config.CredentialChange{Key: entry.PasswordEnv, Value: in.Password})
1768 }
1769
1770 if in.ClearPassphrase {
1771 if config.IsGeneratedRemoteCredential(entry.Name, entry.PassphraseEnv) {
1772 removalCandidates = append(removalCandidates, entry.PassphraseEnv)
1773 }
1774 entry.PassphraseEnv = ""
1775 }
1776 if in.KeyPassphrase != "" {
1777 entry.PassphraseEnv = config.RemotePassphraseCredentialEnvName(entry.Name)
1778 changes = append(changes, config.CredentialChange{Key: entry.PassphraseEnv, Value: in.KeyPassphrase})
1779 }
1780 return changes, removalCandidates
1781 }
1782
1783 func hostEntryToView(h config.RemoteHostEntry) RemoteHostView {
1784 return RemoteHostView{
1785 ID: h.Name, Label: h.Name, Host: h.Host, Port: h.Port, User: h.User,
1786 IdentityFile: h.IdentityFile, ProxyJump: h.ProxyJump,
1787 DefaultWorkspace: h.Workspace, ServeInstall: h.ServeInstallMode(), CredentialMode: credentialModeView(h), UseSSHConfig: h.UseSSHConfig,
1788 PasswordSet: config.ResolveCredential(h.PasswordEnv).Set,
1789 KeyPassphraseSet: config.ResolveCredential(h.PassphraseEnv).Set,
1790 }
1791 }
1792
1793 func inputToHostEntry(in RemoteHostInput) config.RemoteHostEntry {
1794 name := strings.TrimSpace(in.Label)
1795 return config.RemoteHostEntry{
1796 Name: name, Host: in.Host, Port: in.Port, User: in.User,
1797 IdentityFile: in.IdentityFile, ProxyJump: in.ProxyJump,
1798 Workspace: in.DefaultWorkspace, ServeInstall: in.ServeInstall, CredentialMode: normalizeCredentialMode(in.CredentialMode), UseSSHConfig: in.UseSSHConfig,
1799 }
1800 }
1801
1802 func forwardEntriesToViews(hostID string, entries []forward.Entry) []RemoteForwardView {
1803 out := make([]RemoteForwardView, 0, len(entries))
1804 for _, e := range entries {
1805 state := "active"
1806 if !e.Up {
1807 state = "error"
1808 }
1809 v := RemoteForwardView{
1810 ID: e.Spec.Name, HostID: hostID, Label: e.Spec.Name, State: state,
1811 }
1812 if e.LastErr != nil {
1813 v.Error = e.LastErr.Error()
1814 }
1815 out = append(out, v)
1816 }
1817 return out
1818 }
1819
1820 func statusString(s remote.Status) string {
1821 switch s {
1822 case remote.StatusConnecting:
1823 return "connecting"
1824 case remote.StatusConnected:
1825 return "connected"
1826 case remote.StatusReconnecting:
1827 return "reconnecting"
1828 case remote.StatusDegraded:
1829 return "degraded"
1830 case remote.StatusStopped:
1831 return "stopped"
1832 default:
1833 return "stopped"
1834 }
1835 }
1836
1836 lines GO