Add a Remote SSH module (remote serve + tunnel) / 新增远程 SSH 模块(远端 serve + 隧道) (#6673)

* feat(serve): support --port-file/--token-file/--pid-file for supervised headless serve

Extract RunGracefulListener so callers can bind first (--addr 127.0.0.1:0),
record the real port, and keep the auth token out of argv. Groundwork for the
remote-SSH module's detached remote serve bootstrap.

* feat(config): add user-global-only [remote] section for SSH hosts

[[remote.hosts]] entries carry connection/forward/workspace settings and name
credential env vars (passphrase_env/password_env) instead of secrets, mirroring
the provider api_key_env idiom. Like [secrets], loadForRoot pins the section
back to the user-global value after the project merge — with a deep copy,
because TOML decoding mutates slice backing arrays in place and a shallow pin
would alias the project-injected hosts. Remote credential env names flow into
CredentialEnvNames so they are filtered from tool subprocess environments.
Adds RemoteStateDir/RemoteKnownHostsPath and the internal/store name helpers
for the remote-side serve state layout.

* feat(remote): SSH transport kernel — dial, auth, host keys, forwards, SFTP

New internal/remote package group provides the connection layer for the
remote module:

- client.go: supervised connection with a single-goroutine state machine,
  keepalive ([email protected]), full-jitter exponential-backoff
  reconnect, shared SFTP (generation-checked), and forward re-attach.
- auth.go: OpenSSH-like method order (agent, identity file, default
  identities, password, keyboard-interactive); interactively-obtained
  secrets are cached in-process so reconnects never re-prompt.
- knownhosts.go: system known_hosts read-only + a Reasonix-managed TOFU
  file; key mismatch is a non-promptable hard error.
- dial.go: ProxyJump chains (netclient proxy applies to the first hop only).
- forward/: -L (persistent local listener across reconnects) and -R
  (re-registered on each attach) with typed ErrBindBusy/ErrDuplicateForward.
- sftpfs/: list/stat/capped-read-with-text-detection/atomic-write/mkdir/
  rename/remove, quarantining the new github.com/pkg/sftp dependency.
- netclient/dialer.go: StreamDialer honoring the user's proxy policy
  (SOCKS5 via x/net/proxy, http/https via CONNECT).
- sshconfig.go: ~/.ssh/config import via github.com/kevinburke/ssh_config.
- sshtest/: in-process SSH+SFTP server for tests.

New deps: github.com/pkg/sftp, github.com/kevinburke/ssh_config (both pure
Go). x/* versions kept at main-v2's pins.

Cache-impact: none — no prompt/tool/provider path touched.
Cache-guard: n/a (new leaf subsystem, not in the system-prompt prefix).

* feat(remote): remote serve bootstrap over SSH

internal/remote/bootstrap ensures a detached 'reasonix serve' runs for a
(host, workspace) pair: reuse a live process (the reconnect path), else detect
the remote OS/arch (uname), locate/install/upload reasonix, write a 32-byte
token 0600 via SFTP, launch serve bound to 127.0.0.1:0 with --token-file/
--port-file (token never in argv), poll the port file, and persist ServeState
JSON. Status/Stop/Logs manage the recorded process. Every remote command
operand is POSIX single-quote-escaped (golden test covers '; rm -rf ~').
V1 targets Linux/macOS remotes; cross-platform upload without a matching local
binary is a documented limitation directing to serve_install = npm.

Cache-impact: none — new leaf subsystem, no prompt/tool/provider path.
Cache-guard: n/a.

* feat(cli): reasonix remote command group + /remote slash

Adds the remote subcommand tree (add/list/remove/import/test/connect/open/
status/forward/serve/fs), mirroring the mcp command shape. Host CRUD writes to
the user-global config only (remote hosts are pinned there); connect runs a
foreground supervisor that dials, bootstraps remote serve, forwards the serve
port with the token in a loopback URL, applies configured -L/-R forwards, and
holds the tunnel until Ctrl-C while the remote serve keeps running. Terminal
prompts handle key passphrases, passwords, and TOFU host-key confirmation;
dialing honors the user's proxy settings via netclient. The /remote slash
command shows a read-only host summary. New i18n fields added to all three
catalogs.

Cache-impact: none — CLI surface + i18n only, no prompt/tool/provider path.
Cache-guard: n/a.

* feat(desktop): remote SSH Go bindings (remote_app.go)

25 App bindings behind a narrow remoteKernel interface (fake-injected in
tests): host CRUD (user-global config), Connect/Disconnect/status,
status-driven TOFU host-key confirmation (ConfirmRemoteHostKey resumes a
parked dial), SFTP list/read/write-with-mtime-conflict/mkdir/rename/delete,
port-forward CRUD, and remote serve EnsureServer/Open/Stop/Status/Logs.

desktopRemoteManager wraps internal/remote per host; status/forward/server
transitions bridge to remote:status / remote:forwards / remote:server through
the asyncRuntimeEmitter (never blocking the kernel). The manager is created
lazily on first use and closed in App.shutdown, rejecting any pending host-key
prompt. OpenRemoteWorkspace forwards the serve port and opens the tunneled URL
via Wails BrowserOpenURL; last workspace/host persist to desktop-remote.json
(all-optional fields, backward-compatible).

desktop/go.mod gains pkg/sftp + kevinburke/ssh_config via go mod tidy.

Cache-impact: none — desktop bindings only, no prompt/tool/provider path.
Cache-guard: n/a.

* feat(desktop): remote SSH frontend (host manager, explorer, ports, server)

- lib/types.ts: Remote* TS mirrors + SettingsTab "remote".
- lib/bridge.ts: 25 Remote* AppBindings + browser-dev mock + onRemoteStatus/
  onRemoteForwards/onRemoteServer subscribers with a mock fan-out for tests.
- store/remote.ts: zustand store; pending_hostkey status drives the global
  fingerprint dialog and resolves on the next non-pending status.
- RemoteHostsPage (Settings > Remote SSH tab): host list with status chip,
  connect/disconnect/open, add/edit form, ssh_config import picker.
- RemotePanel: overlay drawer with Files (lazy tree + textarea editor with
  mtime-conflict Reload/Overwrite), Ports (live forward rows + add form), and
  Server (bootstrap phases + open-in-browser + logs) tabs.
- RemoteHostKeyDialog: global TOFU modal off the store's pendingFingerprint.
- StatusBar remote chip (worst live state, click opens explorer); App.tsx
  subscribes remote:* app-wide and renders the panel + dialog.
- i18n keys added to en / zh / zh-TW (key sets stay aligned).
- Editor is a monospace textarea (no CodeMirror/Monaco) per lean-deps.
- Tests: src/__tests__/remote-store.test.ts (added to the test script).

Cache-impact: none — desktop frontend only.
Cache-guard: n/a.

* docs: document the Remote SSH module (SPEC, GUIDE, CONFIG_PATHS, CHANGELOG)

Adds the internal/remote layout + acyclic dependency note to SPEC 2, a bilingual
Remote SSH section to GUIDE.md / GUIDE.zh-CN.md (config, CLI, reconnect/persistence
semantics, host-key TOFU, remote-side state layout, desktop UI), the managed
known_hosts path to CONFIG_PATHS(.zh-CN), and a CHANGELOG entry.

Cache-impact: none — docs only.
Cache-guard: n/a.

* chore: ignore stray .reasonix state under package dirs

A bootstrap test whose RealPath resolved to CWD once wrote a .reasonix/remote/
token under the package dir and it was swept into a commit. The offending file
has been scrubbed from history; this guards against recurrence.

* fix(remote): CI green — errcheck + cross-platform bind-busy and Windows test skips

- client.go: check cl.Wait() return (errcheck lint).
- forward: classify WSAEADDRINUSE as bind-busy on Windows (build-tagged
  addrinuse_windows.go), so ErrBindBusy is reported cross-platform.
- sftpfs / bootstrap integration tests skip on Windows: their in-process SFTP
  server serves the LOCAL filesystem, so on Windows it serves drive paths and
  the POSIX-remote path translation breaks — a harness limitation, not a
  product one (V1 remotes are Linux/macOS). Linux/macOS CI covers the flows.

Cache-impact: none — test/lint-only + a Windows errno check.
Cache-guard: n/a.

* test(remote): skip EnsureServe reuse/relaunch/stop tests on Windows at setup

TestEnsureServeReusesLiveProcess and siblings pre-write state via pathsFor()
(POSIX path.Join) before newFakeConn runs, so on Windows the temp-dir remote
home yields a malformed drive path and the setup os.WriteFile fails. Move the
Windows skip to the top of each such test (shared skipOnWindows helper) so it
fires before any pathsFor/os setup, not just inside the harness ctor.

* fix(remote): address review — P1 security / data-integrity / lifecycle

Resolves the 14 findings from code review (all verified against the code):

Security
- Serve token no longer leaks to the remote log: runServe suppresses the
  token share line under --port-file (supervised), and the launch script
  creates the log 0600 (umask 077 + chmod). (F1)
- ProxyJump no longer sends the target's password to jump hosts: each hop gets
  its own credential-free AuthOptions with a per-hop cache (persisted on the
  Client so reconnects don't re-prompt). (F2)
- CLI surfaces a misconfigured proxy instead of silently dialing direct; the
  https-proxy path now TLS-wraps the connection before CONNECT; desktop passes
  the proxy Dialer it previously omitted. (F14)

Data integrity
- `remote fs get` streams the full file via sftpfs.Download instead of the
  4 MiB-capped preview reader that silently truncated. (F3)
- Workspace slug carries an FNV hash of the exact path, so /srv/a-b and
  /srv/a/b no longer share serve state files. (F10)
- Desktop host edit merges: passphrase_env/password_env/forwards (absent from
  the desktop input) are preserved instead of wiped. (F7)

Lifecycle / correctness
- Bootstrap gates on `serve --help` advertising --port-file, not a version
  number (the flags ship in this change, so no released version qualifies). (F4)
- Launch uses setsid only when present, so stock macOS (no setsid) works. (F5)
- Desktop no longer uploads the GUI binary as the remote CLI (LocalBinary=""
  makes the upload path fail clearly toward npm). (F6)
- Serve tunnel re-binds to the current serve address on every EnsureServer and
  is torn down on Stop; per-host serveMu serializes concurrent ensures. (F8)
- Reuse/Stop verify the pid is actually a reasonix serve (ps args), not just
  kill -0, so a recycled PID is never mistaken or signalled. (F9)
- Desktop connect retries after failure (failed Start drops the managed host)
  and builds a fully-populated host under the lock so a concurrent Disconnect
  can always close it. (F11)
- SSH handshake is bounded by a conn deadline (ClientConfig.Timeout does not
  cover NewClientConn), so a banner-less host can't hang the dial or Close. (F12)
- ScanSSHConfig returns a non-nil slice so an empty result encodes as [] not
  null. (F13)

Tests: per-hop auth isolation, full-file download vs preview cap, slug
collision (/srv/a-b vs /srv/a/b), serve-alive/launch/locate command shape,
desktop edit-merge and non-nil scan. go test ./... green in both modules;
gofmt/vet clean; Windows cross-build green; cache-guard pass.

Cache-impact: none — no prompt/tool/provider/memory path touched.
Cache-guard: ./scripts/cache-guard.sh — all cases pass.
System-prompt-review: not required — no system-prompt-feeding config field changed.

* Harden Remote SSH lifecycle and packaging

Problem:
Remote SSH could misapply ProxyJump credentials, race connection and service lifecycles, interrupt live forwarding during replacement, and ship Desktop builds without the CLI sidecar required by upload installation. Several UI failures were also silent or left stale state behind.

Root cause:
Jump aliases shared endpoint-keyed authentication, bootstrap ownership relied on weak state checks without cross-process serialization, and Desktop operations lacked generation-scoped cancellation. Forward replacement stopped the working listener before the replacement was ready, while packaging and updater contracts did not include the CLI binary.

Fix:
Resolve and authenticate every SSH hop independently, make dialing and Desktop operations context-aware, serialize bootstrap with an atomic remote lock, verify exact process ownership, and make forward replacement failure-atomic. Harden token, SFTP, agent, config, and ssh_config handling; package and update the CLI sidecar on macOS, Windows, and Linux; and surface actionable validation and lifecycle errors in the Desktop UI.

Verification:
- go test ./...
- cd desktop && go test ./...
- go test -race for Remote, CLI, store, and Desktop modules
- go vet ./... for root and Desktop modules
- pnpm test:all and pnpm build
- Windows amd64 cross-compilation for Remote, bootstrap, forwarding, SFTP, and CLI packages
- clean merge snapshot against latest origin/main-v2 with root, Desktop, and frontend checks passing

* Add native Remote SSH workspace windows

Problem:
Remote SSH was buried in settings, and opening a remote workspace handed the authenticated loopback URL to the system browser instead of preserving a desktop workspace experience.

Root cause:
The desktop had no window-level Remote SSH control and no isolated Wails launch mode for a second native workspace window.

Fix:
Add a VS Code-style status entry, host switcher, command actions, and Remote dock. Launch remote workspaces in a lightweight Reasonix process that skips local session ownership, single-instance focus, tray services, and Wails bindings. Pass the loopback URL through a one-shot permission-restricted ticket so the token never appears in process arguments.

Verification:
- go test ./...
- pnpm test:all
- pnpm build
- wails build -clean
- native macOS multi-window smoke test
- git diff --check

* Tidy Remote SSH module metadata

Record the SSH config and SFTP packages as direct dependencies after rebasing onto the latest main-v2 baseline. Remove stale module checksums so the root module remains tidy in CI.

* Fix Remote SSH Windows test assumptions

Load the fixture SSH config by its explicit path while setting both platform home variables, and use the Windows .exe suffix for the desktop CLI fixture. This keeps the Remote SSH coverage portable across the CI matrix.
This commit is contained in:
SivanCola authored and GitHub committed 2026-07-19 21:48:20 +08:00
1 parent 0a54d50453
commit 65fcd46525
109 files changed
+13684 -48

No files matched your search

+5
View File
@@ -48,6 +48,11 @@ node_modules/
!/.reasonix/commands/
!/.reasonix/commands/review.md
# Stray Reasonix state written under a package dir by a test whose RealPath
# resolves to CWD (e.g. remote bootstrap EnsureServe). Never commit these.
internal/**/.reasonix/
desktop/**/.reasonix/
# CodeGraph per-project index (rebuilt locally; never committed)
.codegraph/
+12
View File
@@ -8,6 +8,18 @@ branch.
### Added
- Added a **Remote SSH** module (VS Code Remote-SSH style): a user-global
`[remote]` host config, `reasonix remote` CLI (add/list/remove/import/test/
connect/status/forward/serve/fs) and `/remote` slash command, an SSH transport
with trust-on-first-use host-key verification, keepalive + exponential-backoff
reconnect, `-L`/`-R` port forwarding, and SFTP file access. `connect`
bootstraps a persistent `reasonix serve` on the remote host and tunnels its
loopback port so the full agent runs remotely. The desktop app adds a
**Settings -> Remote SSH** host manager, a remote file browser/editor, a
port-forwarding panel, and a status-bar connection chip. Linux/macOS remotes.
- Added `reasonix serve --port-file/--token-file/--pid-file` so a supervised
headless serve can bind an ephemeral port and read its auth token from a file
(keeping it out of `ps`).
- Added Claude Code-style searchable CLI pickers for models, providers, and
sessions, with arrow, Vim, and `Ctrl+P` / `Ctrl+N` navigation.
- Added `-p` / `--print`, `text`, `json`, and `stream-json` output modes for
+26
View File
@@ -109,6 +109,13 @@ type PromptHistoryResult struct {
type App struct {
ctx context.Context
// remoteWindow marks the lightweight native shell used for an SSH-backed
// workspace. It deliberately skips local tab/session runtime startup so a
// second window never competes for the primary process's session leases.
remoteWindow *remoteWindowLaunch
remoteWindowNavigated atomic.Bool
remoteWindowOpener func(remoteWindowLaunch) error // test-only injection
// mu protects the tab map, tabOrder, activeTabID, and per-tab fields that are read
// from bound methods. All bound methods that touch a controller use activeCtrl().
mu sync.RWMutex
@@ -197,6 +204,11 @@ type App struct {
runtimeEvents asyncRuntimeEmitter
// Remote SSH module: the manager is created lazily on the first remote
// binding call and closed on shutdown.
remoteMu sync.Mutex
remoteRuntime remoteKernel
// promptHistoryTape is a lazy, cursor-addressed view of prompt history. It
// stores session order and per-session parsed entries only after that session is
// reached by ↑ navigation. See ScanPromptHistory.
@@ -430,6 +442,9 @@ func (a *App) Platform() string {
// off the initialization in a background goroutine so the webview loads immediately.
func (a *App) startup(ctx context.Context) {
a.ctx = ctx
if a.remoteWindow != nil {
return
}
installSystemQuitHook()
a.startTray()
a.enableDeferredRebuildRetry()
@@ -464,6 +479,9 @@ func (a *App) startup(ctx context.Context) {
}
func (a *App) beforeClose(ctx context.Context) bool {
if a.remoteWindow != nil {
return false
}
if a.forceQuit.Swap(false) || consumeSystemQuitRequested() {
return false
}
@@ -776,12 +794,16 @@ func (a *App) snapshotAllTabs() {
// shutdown snapshots all tabs, saves the final window geometry, and closes tabs.
func (a *App) shutdown(context.Context) {
if a.remoteWindow != nil {
return
}
a.stopDeferredRebuildRetry()
a.stopMainThreadWatchdog()
if a.heartbeat != nil {
a.heartbeat.Stop()
}
a.stopBotRuntime()
a.stopRemoteRuntime()
a.stopTray()
// Save window geometry synchronously from Go so it's persisted even if the
// frontend's beforeunload promise hasn't resolved yet.
@@ -835,6 +857,10 @@ func (a *App) domReady(_ context.Context) {
// SA_ONSTACK flags required by Go; this is a no-op outside Linux.
repairWebKitSignalHandlers()
if a.remoteWindow != nil {
a.domReadyRemoteWindow()
return
}
state, ok := loadWindowState()
if ok {
// Validate saved position against current screens. Wails v2 doesn't
+4
View File
@@ -33,6 +33,10 @@ contents:
dst: /usr/bin/reasonix-guard
file_info:
mode: 0755
- src: ./build/bin/reasonix
dst: /usr/bin/reasonix
file_info:
mode: 0755
- src: ./build/linux/reasonix.desktop
dst: /usr/share/applications/reasonix.desktop
file_info:
+19 -5
View File
@@ -80,6 +80,7 @@ OutFile "..\..\bin\${INFO_PROJECTNAME}-${ARCH}-installer.exe" # Name of the inst
!define REASONIX_UPDATE_HELPER "reasonix-update-helper.exe"
!define REASONIX_GUARD "reasonix-guard.exe"
!define REASONIX_LAUNCHER "reasonix-launcher.exe"
!define REASONIX_CLI "reasonix.exe"
!define REASONIX_PORTABLE_ENTRY "Reasonix.exe"
!define REASONIX_UNLOCK_RETRIES 60
InstallDirRegKey HKCU "${UNINST_KEY}" "InstallLocation" # Reuse the previous install path on update; .onInit falls back to the default on first install.
@@ -154,11 +155,18 @@ check_guard:
FileClose $1
check_launcher:
IfFileExists "$INSTDIR\${REASONIX_LAUNCHER}" 0 check_portable_entry
ClearErrors
FileOpen $1 "$INSTDIR\${REASONIX_LAUNCHER}" a
IfErrors locked
FileClose $1
IfFileExists "$INSTDIR\${REASONIX_LAUNCHER}" 0 check_cli
ClearErrors
FileOpen $1 "$INSTDIR\${REASONIX_LAUNCHER}" a
IfErrors locked
FileClose $1
check_cli:
IfFileExists "$INSTDIR\${REASONIX_CLI}" 0 check_portable_entry
ClearErrors
FileOpen $1 "$INSTDIR\${REASONIX_CLI}" a
IfErrors locked
FileClose $1
check_portable_entry:
IfFileExists "$INSTDIR\${REASONIX_PORTABLE_ENTRY}" 0 done
@@ -226,6 +234,11 @@ reasonix_no_portable_entry:
CreateShortcut "$SMPROGRAMS\${INFO_PRODUCTNAME}.lnk" "$INSTDIR\${PRODUCT_EXECUTABLE}"
CreateShortCut "$DESKTOP\${INFO_PRODUCTNAME}.lnk" "$INSTDIR\${PRODUCT_EXECUTABLE}"
!endif
!if /FileExists "${REASONIX_CLI}"
File "/oname=${REASONIX_CLI}" "${REASONIX_CLI}"
!else
!warning "${REASONIX_CLI} was not found; remote upload installation will be unavailable."
!endif
!insertmacro wails.associateFiles
!insertmacro wails.associateCustomProtocols
@@ -243,6 +256,7 @@ Section "uninstall"
Delete "$INSTDIR\${REASONIX_UPDATE_HELPER}"
Delete "$INSTDIR\${REASONIX_GUARD}"
Delete "$INSTDIR\${REASONIX_LAUNCHER}"
Delete "$INSTDIR\${REASONIX_CLI}"
Delete "$INSTDIR\${REASONIX_PORTABLE_ENTRY}"
Delete "$SMPROGRAMS\${INFO_PRODUCTNAME}.lnk"
+2 -1
View File
@@ -11,9 +11,10 @@
"test:todo-visibility": "node scripts/test-todo-visibility.mjs",
"typecheck": "tsc --noEmit",
"test": "node scripts/test-todo-visibility.mjs && tsx src/__tests__/session-export.test.ts && tsx src/__tests__/conversation-width.test.ts && tsx src/__tests__/external-opener.test.tsx && tsx src/__tests__/anchored-popover-scroll.test.tsx && tsx src/__tests__/confirm-dialog.test.tsx && tsx src/__tests__/scroll-manager.test.tsx && tsx src/__tests__/layout-style-defaults.test.ts && tsx src/__tests__/scroll-content-observer.test.ts && tsx src/__tests__/math-golden.test.ts && tsx src/__tests__/mermaid-rendering.test.tsx && tsx src/__tests__/text-size.test.ts && tsx src/__tests__/font-availability.test.ts && tsx src/__tests__/typography-preferences.test.ts && tsx src/__tests__/theme-auto-background.test.ts && tsx src/__tests__/theme-pack.test.ts && tsx src/__tests__/typography-overflow-contract.test.ts && tsx src/__tests__/ask-card-layout.test.ts && tsx src/__tests__/approval-modal-file-reference.test.tsx && tsx src/__tests__/ref-token.test.tsx && tsx src/__tests__/decision-surface.test.tsx && tsx src/__tests__/provider-model-refresh.test.ts && tsx src/__tests__/composer-profile.test.ts && tsx src/__tests__/composer-history.test.ts && tsx src/__tests__/session-title-contract.test.ts && tsx src/__tests__/composer-keyboard.test.ts && tsx src/__tests__/composer-goal-toggle.test.tsx && tsx src/__tests__/composer-run-strip.test.tsx && tsx src/__tests__/composer-session-draft.test.tsx && tsx src/__tests__/composer-image-capability.test.tsx && tsx src/__tests__/composer-context-menu-clipboard.test.tsx && tsx src/__tests__/composer-resized-input-css.test.ts && tsx src/__tests__/use-controller-meta.test.ts && tsx src/__tests__/use-controller-stream-progress.test.ts && tsx src/__tests__/use-controller-live-context.test.tsx && tsx src/__tests__/use-controller-cancel-reconcile.test.tsx && tsx src/__tests__/pending-prompt-stale-status.test.tsx && tsx src/__tests__/new-session-load-race.test.tsx && tsx src/__tests__/tab-switch-hydration.test.tsx && tsx src/__tests__/activate-topic-stale.test.tsx && tsx src/__tests__/ready-event-meta-sync.test.tsx && tsx src/__tests__/ready-meta-reconcile.test.tsx && tsx src/__tests__/recovery-banner-privacy.test.ts && tsx src/__tests__/recovery-quiet-notifications.test.ts && tsx src/__tests__/workspace-layout.test.ts && tsx src/__tests__/workspace-changes-errors.test.tsx && tsx src/__tests__/workspace-split.test.ts && tsx src/__tests__/workspace-preview-css.test.ts && tsx src/__tests__/resize-drag.test.ts && tsx src/__tests__/wails-resize-fix.test.tsx && tsx src/__tests__/tool-approval-mode.test.ts && tsx src/__tests__/sound.test.ts && tsx src/__tests__/heartbeat-next-run.test.ts && tsx src/__tests__/keyboard-shortcuts.test.ts && tsx src/__tests__/send-failed.test.ts && tsx src/__tests__/edit-replay.test.ts && tsx src/__tests__/message-pasted-blocks.test.ts && tsx src/__tests__/history-tool-status.test.ts && tsx src/__tests__/history-recovery-copies.test.tsx && tsx src/__tests__/message-selection-copy.test.ts && tsx src/__tests__/transcript-selection-menu.test.tsx && tsx src/__tests__/turn-action-copy.test.ts && tsx src/__tests__/turn-actions-rendering.test.ts && tsx src/__tests__/code-block-copy-position.test.tsx && tsx src/__tests__/message-reasoning-panel.test.tsx && tsx src/__tests__/transcript-process-fold.test.ts && tsx src/__tests__/transcript-fold-preference.test.tsx && tsx src/__tests__/reasoning-display.test.ts && tsx src/__tests__/attachment-display.test.ts && tsx src/__tests__/crash-reporting.test.ts && tsx src/__tests__/bridge-drag-rejection.test.ts && tsx src/__tests__/startup-settings-contract.test.ts && tsx src/__tests__/bundle-contract.test.ts && tsx src/__tests__/command-palette-css.test.ts && tsx src/__tests__/command-palette-interactions.test.tsx && tsx src/__tests__/context-panel-breakdown.test.ts && tsx src/__tests__/context-window-ring.test.tsx && tsx src/__tests__/statusbar-workspace.test.tsx && tsx src/__tests__/settings-refresh-snapshot.test.tsx && tsx src/__tests__/capabilities-panel-actions.test.ts && tsx src/__tests__/diagnostics-settings.test.tsx && tsx src/__tests__/tool-data-archive.test.ts && tsx src/__tests__/tool-subject.test.ts && tsx src/__tests__/diff-rendering.test.ts && tsx src/__tests__/render-optimization.test.ts && tsx src/__tests__/transcript-grouping.test.ts && tsx src/__tests__/project-tree-runtime.test.ts && tsx src/__tests__/app-chrome-tabs.test.ts && tsx src/__tests__/open-topic-coalescing.test.tsx && tsx src/__tests__/delivery-worktree.test.ts && tsx src/__tests__/memory-compiler-display.test.ts && tsx src/__tests__/memory-citation-visibility.test.ts",
"test:remote": "tsx src/__tests__/remote-store.test.ts",
"test:typecheck": "tsc --noEmit -p tsconfig.test.json",
"test:delivery-worktree": "tsx src/__tests__/delivery-worktree.test.ts",
"test:all": "pnpm test:typecheck && pnpm test"
"test:all": "pnpm test:typecheck && pnpm test && pnpm test:remote"
},
"dependencies": {
"@gsap/react": "^2.1.2",
+149 -3
View File
@@ -14,6 +14,7 @@ import {
Download,
Minus,
Search,
Server,
Square,
SquarePen,
PanelLeft,
@@ -53,6 +54,9 @@ import { ClearContextCard } from "./components/ClearContextCard";
/** Footer decision surface kinds. Priority: tool/plan approval > ask > clear context. */
type DecisionSurfaceKind = "tool_approval" | "plan_approval" | "ask" | "clear_context";
import { StatusBar } from "./components/StatusBar";
import { RemoteHostKeyDialog } from "./components/RemoteHostKeyDialog";
import { onRemoteStatus, onRemoteForwards, onRemoteServer } from "./lib/bridge";
import { useRemoteStore, waitForRemoteConnection } from "./store/remote";
import { CommandPalette, type PaletteItem } from "./components/CommandPalette";
import { UpdateBanner } from "./components/UpdateBanner";
import { ContextPanel } from "./components/ContextPanel";
@@ -88,6 +92,7 @@ import {
type Mode,
modeHasPlan,
type ProjectNode,
type RemoteHostView,
type SessionMeta,
type SettingsView,
type TabMeta,
@@ -254,6 +259,7 @@ function NoticePreviewPanel() {
const HistoryPanel = lazy(() => import("./components/HistoryPanel").then((module) => ({ default: module.HistoryPanel })));
const SettingsPanel = lazy(() => import("./components/SettingsPanel").then((module) => ({ default: module.SettingsPanel })));
const RemotePanel = lazy(() => import("./components/RemotePanel").then((module) => ({ default: module.RemotePanel })));
const CHAT_MIN_WIDTH = 400;
const CHAT_COMFORT_MIN_WIDTH = 560;
@@ -1101,6 +1107,17 @@ export default function App() {
const [histView, setHistView] = useState<HistoryViewState | null>(null);
const paletteOpen = useOverlayStore((s) => s.paletteOpen);
const setPaletteOpen = useOverlayStore((s) => s.setPaletteOpen);
const remoteExplorerOpen = useRemoteStore((s) => s.explorerOpen);
const remoteExplorerHostId = useRemoteStore((s) => s.explorerHostId);
const remoteHosts = useRemoteStore((s) => s.hosts);
const remoteStatuses = useRemoteStore((s) => s.statuses);
const setRemoteHosts = useRemoteStore((s) => s.setHosts);
const hydrateRemoteStatuses = useRemoteStore((s) => s.hydrateStatuses);
const requestRemoteExplorer = useRemoteStore((s) => s.openExplorer);
const closeRemoteExplorerRequest = useRemoteStore((s) => s.closeExplorer);
const applyRemoteStatus = useRemoteStore((s) => s.applyStatus);
const setRemoteForwards = useRemoteStore((s) => s.setForwards);
const setRemoteServer = useRemoteStore((s) => s.setServer);
const shortcutsOpen = useOverlayStore((s) => s.shortcutsOpen);
const setShortcutsOpen = useOverlayStore((s) => s.setShortcutsOpen);
const paletteSessions = useOverlayStore((s) => s.paletteSessions);
@@ -2161,6 +2178,36 @@ export default function App() {
});
}, [refreshTabMetas]);
// Bridge remote:* events into the remote store once, app-wide, so the
// StatusBar chip, host manager, and explorer all see the same live state.
useEffect(() => {
const offStatus = onRemoteStatus((s) => applyRemoteStatus(s));
const offForwards = onRemoteForwards((e) => setRemoteForwards(e.hostId, e.forwards));
const offServer = onRemoteServer((s) => setRemoteServer(s));
return () => {
offStatus();
offForwards();
offServer();
};
}, [applyRemoteStatus, setRemoteForwards, setRemoteServer]);
useEffect(() => {
let cancelled = false;
void app.RemoteHosts()
.then((hosts) => {
if (!cancelled) setRemoteHosts(hosts);
})
.catch(() => {});
void app.RemoteConnectionStatuses()
.then((statuses) => {
if (!cancelled) hydrateRemoteStatuses(statuses);
})
.catch(() => {});
return () => {
cancelled = true;
};
}, [hydrateRemoteStatuses, setRemoteHosts]);
useEffect(() => {
let cancelled = false;
(async () => {
@@ -2482,6 +2529,61 @@ export default function App() {
[openWorkspacePanel],
);
useEffect(() => {
if (!remoteExplorerOpen) return;
openRightDockMode("remote");
closeRemoteExplorerRequest();
}, [closeRemoteExplorerRequest, openRightDockMode, remoteExplorerOpen]);
useEffect(() => {
if (remoteHosts.length > 0 || rightDockMode !== "remote") return;
setRightDockMode("files");
}, [remoteHosts.length, rightDockMode, setRightDockMode]);
const openRemoteDock = useCallback(() => {
const fallback = remoteHosts.find((host) => {
const state = useRemoteStore.getState().statuses[host.id]?.state;
return state === "connected" || state === "degraded";
}) ?? remoteHosts[0];
const hostId = remoteExplorerHostId && remoteHosts.some((host) => host.id === remoteExplorerHostId)
? remoteExplorerHostId
: fallback?.id;
if (hostId) requestRemoteExplorer(hostId);
}, [remoteExplorerHostId, remoteHosts, requestRemoteExplorer]);
const launchRemoteWorkspace = useCallback(async (host: RemoteHostView) => {
const workspace = host.defaultWorkspace.trim();
if (!workspace) {
useRemoteStore.getState().setExplorerTab("server");
requestRemoteExplorer(host.id);
return;
}
await app.OpenRemoteWorkspace(host.id, workspace);
}, [requestRemoteExplorer]);
const openRemoteWorkspaceFromStatus = useCallback((host: RemoteHostView) => {
void launchRemoteWorkspace(host).catch((err) => {
showToast(err instanceof Error ? err.message : String(err), "error", { durationMs: 6000 });
});
}, [launchRemoteWorkspace, showToast]);
const connectAndOpenRemoteWorkspace = useCallback((host: RemoteHostView) => {
void (async () => {
const status = useRemoteStore.getState().statuses[host.id]?.state;
if (status !== "connected" && status !== "degraded") {
// Clear any stale failure before the new generation starts; otherwise a
// previous stopped+error snapshot could make the waiter reject before
// the kernel's fresh connecting event reaches the frontend.
useRemoteStore.getState().applyStatus({ hostId: host.id, state: "connecting" });
await app.ConnectRemoteHost(host.id);
await waitForRemoteConnection(host.id);
}
await launchRemoteWorkspace(host);
})().catch((err) => {
showToast(err instanceof Error ? err.message : String(err), "error", { durationMs: 6000 });
});
}, [launchRemoteWorkspace, showToast]);
const handleWorkspacePreviewModeChange = useCallback(
(active: boolean) => {
if (workspacePreviewActive === active) return;
@@ -3178,8 +3280,27 @@ export default function App() {
badge: t(s.turns === 1 ? "history.turnOne" : "history.turnOther", { n: s.turns }),
run: () => void onResumeSession(s),
}));
return [...cmds, ...sessionItems];
}, [t, paletteSessions, handleNewTab, openTrash, onResumeSession]);
const remoteItems: PaletteItem[] = remoteHosts.map((host) => {
const status = remoteStatuses[host.id];
const connected = status?.state === "connected" || status?.state === "degraded";
const target = `${host.user ? `${host.user}@` : ""}${host.host}${host.port && host.port !== 22 ? `:${host.port}` : ""}`;
return {
id: `remote-${host.id}`,
group: t("palette.group.remote"),
title: connected
? t("palette.remote.open", { host: host.label })
: t("palette.remote.connect", { host: host.label }),
hint: host.defaultWorkspace || target,
icon: <Server size={15} />,
keywords: ["ssh", "remote", "远程", "连接", host.label, host.host],
run: () => {
if (connected) openRemoteWorkspaceFromStatus(host);
else connectAndOpenRemoteWorkspace(host);
},
};
});
return [...cmds, ...remoteItems, ...sessionItems];
}, [t, paletteSessions, remoteHosts, remoteStatuses, handleNewTab, openTrash, onResumeSession, openRemoteWorkspaceFromStatus, connectAndOpenRemoteWorkspace]);
// Delete / rename act on disk, then re-fetch so the panel reflects the change.
const onDeleteSession = useCallback(
async (path: string) => {
@@ -4122,6 +4243,13 @@ export default function App() {
workspacePath={state.meta?.workspacePath || state.meta?.workspaceRoot || state.meta?.cwd}
workspaceName={state.meta?.workspaceName}
gitBranch={state.meta?.gitBranch}
onConnectRemote={connectAndOpenRemoteWorkspace}
onDisconnectRemote={(hostId) => void app.DisconnectRemoteHost(hostId).catch(() => {})}
onManageRemote={() => setSettingsTarget("remote")}
onOpenRemote={requestRemoteExplorer}
onOpenRemoteWorkspace={openRemoteWorkspaceFromStatus}
remoteHosts={remoteHosts}
remoteStatuses={remoteStatuses}
/>
</footer>
)}
@@ -4186,10 +4314,26 @@ export default function App() {
<GitBranch size={13} />
<span className="workbench-dock__tab-label">{t("workspace.changedTab")}</span>
</button>
{remoteHosts.length > 0 && (
<button
type="button"
role="tab"
aria-selected={rightDockMode === "remote"}
className={`workbench-dock__tab${rightDockMode === "remote" ? " workbench-dock__tab--active" : ""}`}
onClick={openRemoteDock}
>
<Server size={13} />
<span className="workbench-dock__tab-label">{t("rightDock.remote")}</span>
</button>
)}
</div>
</div>
<div className="workbench-dock__body">
{rightDockMode === "context" && desktopLayoutStyle !== "creation" ? (
{rightDockMode === "remote" ? (
<Suspense fallback={null}>
<RemotePanel onClose={() => setWorkspacePanel(false)} />
</Suspense>
) : rightDockMode === "context" && desktopLayoutStyle !== "creation" ? (
<ContextPanel
tabId={activeTabId}
context={state.context}
@@ -4282,6 +4426,8 @@ export default function App() {
</Suspense>
)}
<RemoteHostKeyDialog />
<CommandPalette
open={paletteOpen}
onClose={() => setPaletteOpen(false)}
@@ -0,0 +1,105 @@
// Run: tsx src/__tests__/remote-store.test.ts
//
// Tests for the remote store's status/fingerprint reconciliation and the
// bridge mock's remote:* event fan-out.
import { useRemoteStore, waitForRemoteConnection } from "../store/remote";
import { onRemoteStatus, __emitMockRemote } from "../lib/bridge";
import type { RemoteConnectionStatus } from "../lib/types";
let passed = 0;
let failed = 0;
function eq(a: unknown, b: unknown, label: string) {
if (JSON.stringify(a) === JSON.stringify(b)) {
process.stdout.write(` PASS ${label}\n`);
passed += 1;
} else {
process.stdout.write(` FAIL ${label}: expected ${JSON.stringify(b)}, got ${JSON.stringify(a)}\n`);
failed += 1;
}
}
function reset() {
useRemoteStore.setState({ hosts: [], statuses: {}, pendingFingerprint: null });
}
useRemoteStore.getState().setHosts([
{ id: "box", label: "box", host: "box.test", port: 22, user: "dev", identityFile: "", proxyJump: "", defaultWorkspace: "/srv/app", serveInstall: "auto", useSSHConfig: false },
]);
eq(useRemoteStore.getState().hosts[0]?.defaultWorkspace, "/srv/app", "configured hosts hydrate persistent UI state");
// pending_hostkey status sets the pending fingerprint that drives the dialog.
reset();
useRemoteStore.getState().applyStatus({
hostId: "box",
state: "pending_hostkey",
fingerprint: { hostId: "box", address: "1.2.3.4:22", keyType: "ssh-ed25519", sha256: "AAAA" },
});
eq(useRemoteStore.getState().pendingFingerprint?.sha256, "AAAA", "pending_hostkey sets fingerprint");
// A stale dialog completion must not clear a newer fingerprint.
const oldFingerprint = useRemoteStore.getState().pendingFingerprint!;
useRemoteStore.getState().applyStatus({
hostId: "other",
state: "pending_hostkey",
fingerprint: { hostId: "other", address: "2.3.4.5:22", keyType: "ssh-ed25519", sha256: "BBBB" },
});
useRemoteStore.getState().clearPendingFingerprint(oldFingerprint);
eq(useRemoteStore.getState().pendingFingerprint?.sha256, "BBBB", "stale dialog cannot clear newer fingerprint");
// A subsequent non-pending status for the same host clears the fingerprint.
useRemoteStore.getState().applyStatus({ hostId: "other", state: "connected" });
eq(useRemoteStore.getState().pendingFingerprint, null, "resolution clears fingerprint");
eq(useRemoteStore.getState().statuses["other"]?.state, "connected", "status recorded");
// setStatuses replaces the whole map (mount hydration).
useRemoteStore.getState().setStatuses([
{ hostId: "a", state: "connected" },
{ hostId: "b", state: "reconnecting", attempt: 2 },
]);
eq(Object.keys(useRemoteStore.getState().statuses).sort(), ["a", "b"], "setStatuses hydrates");
eq(useRemoteStore.getState().statuses["b"]?.attempt, 2, "attempt preserved");
// Late hydration fills missing hosts without overwriting a newer live event.
useRemoteStore.getState().applyStatus({ hostId: "live", state: "connected" });
useRemoteStore.getState().hydrateStatuses([
{ hostId: "live", state: "connecting" },
{ hostId: "snapshot-only", state: "connected" },
]);
eq(useRemoteStore.getState().statuses["live"]?.state, "connected", "hydration preserves newer live status");
eq(useRemoteStore.getState().statuses["snapshot-only"]?.state, "connected", "hydration fills missing status");
const connectionReady = waitForRemoteConnection("waiting", 1_000);
useRemoteStore.getState().applyStatus({ hostId: "waiting", state: "connected" });
await connectionReady;
eq(useRemoteStore.getState().statuses["waiting"]?.state, "connected", "connection waiter resolves on live connected status");
useRemoteStore.getState().applyStatus({ hostId: "failed", state: "stopped", error: "handshake failed" });
let failedConnection = "";
try {
await waitForRemoteConnection("failed", 1_000);
} catch (err) {
failedConnection = err instanceof Error ? err.message : String(err);
}
eq(failedConnection, "handshake failed", "connection waiter rejects the host error without waiting for timeout");
// The bridge mock fan-out delivers remote:status to subscribers.
(function testMockFanout() {
if (typeof window === "undefined") {
(globalThis as Record<string, unknown>).window = {} as Window & typeof globalThis;
}
let received: RemoteConnectionStatus | null = null;
const off = onRemoteStatus((s) => {
received = s;
});
__emitMockRemote("status", { hostId: "z", state: "connected" });
eq(received !== null && (received as RemoteConnectionStatus).hostId, "z", "mock fan-out delivers status");
off();
received = null;
__emitMockRemote("status", { hostId: "y", state: "connected" });
eq(received, null, "unsubscribe stops delivery");
})();
process.stdout.write(`\n${passed} passed, ${failed} failed\n`);
if (failed > 0) process.exit(1);
@@ -33,6 +33,7 @@ function renderStatusBar(props: Partial<Parameters<typeof StatusBar>[0]> = {}):
console.log("\nstatus bar workspace");
{
const defaultItems = DEFAULT_STATUS_BAR_ITEMS as readonly string[];
ok(defaultItems.includes("workspace"), "workspace is a default configurable status item");
@@ -43,6 +44,30 @@ console.log("\nstatus bar workspace");
);
}
{
const remoteHosts = [
{ id: "demo", label: "demo", host: "192.0.2.10", port: 22, user: "dev", identityFile: "", proxyJump: "", defaultWorkspace: "~/app", serveInstall: "auto", useSSHConfig: false },
];
const stopped = renderStatusBar({ workspacePath: "/workspace/repo", workspaceName: "repo", remoteHosts });
ok(stopped.includes("SSH · Disconnected"), "configured SSH entry remains visible while disconnected");
ok(stopped.indexOf("SSH · Disconnected") < stopped.indexOf("workspace/repo"), "window-level SSH entry leads the status bar");
const connected = renderStatusBar({
workspacePath: "/workspace/repo",
workspaceName: "repo",
remoteHosts,
remoteStatuses: { demo: { hostId: "demo", state: "connected" } },
});
ok(connected.includes("demo · Connected"), "SSH entry includes host and connected state text");
const failed = renderStatusBar({
workspacePath: "/workspace/repo",
remoteHosts,
remoteStatuses: { demo: { hostId: "demo", state: "stopped", error: "handshake failed" } },
});
ok(failed.includes("demo · Connection failed"), "SSH entry keeps a recoverable failure summary visible");
}
{
const propsWithLegacySandbox = {
workspacePath: "/workspace/repo",
@@ -0,0 +1,73 @@
import { useEffect, useRef } from "react";
import { createPortal } from "react-dom";
import { app } from "../lib/bridge";
import { useT } from "../lib/i18n";
import { useRemoteStore } from "../store/remote";
/** RemoteHostKeyDialog is the global TOFU confirmation modal. It renders off
* the remote store's pendingFingerprint so it appears regardless of which
* surface initiated the connection, and resolves via ConfirmRemoteHostKey. */
export function RemoteHostKeyDialog() {
const t = useT();
const fp = useRemoteStore((s) => s.pendingFingerprint);
const clear = useRemoteStore((s) => s.clearPendingFingerprint);
const acceptRef = useRef<HTMLButtonElement>(null);
const resolvingRef = useRef(false);
useEffect(() => {
if (fp) acceptRef.current?.focus();
}, [fp]);
useEffect(() => {
if (!fp) return;
const onKey = (e: KeyboardEvent) => {
if (e.key === "Escape") {
e.preventDefault();
void resolve(false);
}
};
window.addEventListener("keydown", onKey);
return () => window.removeEventListener("keydown", onKey);
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [fp]);
if (!fp) return null;
const resolve = async (accept: boolean) => {
if (resolvingRef.current) return;
resolvingRef.current = true;
try {
await app.ConfirmRemoteHostKey(fp.hostId, accept);
} finally {
clear(fp);
resolvingRef.current = false;
}
};
return createPortal(
<div className="remote-hostkey-overlay" role="dialog" aria-modal="true" aria-labelledby="remote-hostkey-title">
<div className="remote-hostkey-dialog">
<h2 id="remote-hostkey-title" className="remote-hostkey-dialog__title">
{t("remote.fingerprint.title")}
</h2>
<p>{t("remote.fingerprint.body", { host: fp.hostId })}</p>
<dl className="remote-hostkey-dialog__facts">
<dt>{t("remote.fingerprint.type")}</dt>
<dd>{fp.keyType}</dd>
<dt>{t("remote.fingerprint.sha256")}</dt>
<dd className="remote-hostkey-dialog__fp">{fp.sha256}</dd>
</dl>
<div className="remote-hostkey-dialog__actions">
<button className="btn" onClick={() => void resolve(false)}>
{t("remote.fingerprint.reject")}
</button>
<button ref={acceptRef} className="btn btn--danger" onClick={() => void resolve(true)}>
{t("remote.fingerprint.accept")}
</button>
</div>
</div>
</div>,
document.body,
);
}
@@ -0,0 +1,323 @@
import { useCallback, useEffect, useState } from "react";
import { app } from "../lib/bridge";
import { useT } from "../lib/i18n";
import { useRemoteStore } from "../store/remote";
import type { RemoteConnectionStatus, RemoteHostInput, RemoteHostView, RemoteConnState } from "../lib/types";
const EMPTY_INPUT: RemoteHostInput = {
label: "",
host: "",
port: 22,
user: "",
identityFile: "",
proxyJump: "",
defaultWorkspace: "",
serveInstall: "auto",
useSSHConfig: false,
};
type Screen = { kind: "list" } | { kind: "add" } | { kind: "edit"; id: string } | { kind: "import" };
/** RemoteHostsPage is the Settings > Remote SSH manager: host list with
* connect/disconnect + add/edit/remove + ssh_config import. */
export function RemoteHostsPage() {
const t = useT();
const [hosts, setHosts] = useState<RemoteHostView[]>([]);
const [screen, setScreen] = useState<Screen>({ kind: "list" });
const statuses = useRemoteStore((s) => s.statuses);
const setStoreHosts = useRemoteStore((s) => s.setHosts);
const hydrateStatuses = useRemoteStore((s) => s.hydrateStatuses);
const openExplorer = useRemoteStore((s) => s.openExplorer);
const refresh = useCallback(async () => {
const next = await app.RemoteHosts();
setHosts(next);
setStoreHosts(next);
}, [setStoreHosts]);
useEffect(() => {
void refresh();
void app.RemoteConnectionStatuses().then(hydrateStatuses);
}, [refresh, hydrateStatuses]);
if (screen.kind === "add" || screen.kind === "edit") {
const initial =
screen.kind === "edit" ? hostToInput(hosts.find((h) => h.id === screen.id)) : EMPTY_INPUT;
return (
<RemoteHostForm
initial={initial}
editingId={screen.kind === "edit" ? screen.id : null}
onDone={async () => {
await refresh();
setScreen({ kind: "list" });
}}
onCancel={() => setScreen({ kind: "list" })}
/>
);
}
if (screen.kind === "import") {
return (
<RemoteSSHConfigImport
onDone={async () => {
await refresh();
setScreen({ kind: "list" });
}}
onCancel={() => setScreen({ kind: "list" })}
/>
);
}
return (
<div className="remote-hosts">
<div className="remote-hosts__toolbar">
<h2>{t("remote.hosts.title")}</h2>
<div className="remote-hosts__actions">
<button className="btn" onClick={() => setScreen({ kind: "import" })}>
{t("remote.hosts.import")}
</button>
<button className="btn btn--primary" onClick={() => setScreen({ kind: "add" })}>
{t("remote.hosts.add")}
</button>
</div>
</div>
{hosts.length === 0 ? (
<p className="remote-hosts__empty">{t("remote.hosts.empty")}</p>
) : (
<ul className="remote-hosts__list">
{hosts.map((h) => (
<RemoteHostRow
key={h.id}
host={h}
status={statuses[h.id]}
onConnect={() => void app.ConnectRemoteHost(h.id).catch(() => {})}
onDisconnect={() => void app.DisconnectRemoteHost(h.id).catch(() => {})}
onOpen={() => openExplorer(h.id)}
onEdit={() => setScreen({ kind: "edit", id: h.id })}
onRemove={async () => {
await app.RemoveRemoteHost(h.id);
await refresh();
}}
/>
))}
</ul>
)}
</div>
);
}
function RemoteHostRow(props: {
host: RemoteHostView;
status?: RemoteConnectionStatus;
onConnect: () => void;
onDisconnect: () => void;
onOpen: () => void;
onEdit: () => void;
onRemove: () => void;
}) {
const t = useT();
const { host } = props;
const state = props.status?.state;
const target = `${host.user ? host.user + "@" : ""}${host.host}${host.port && host.port !== 22 ? ":" + host.port : ""}`;
const connected = state === "connected" || state === "degraded";
return (
<li className="remote-host-row">
<div className="remote-host-row__main">
<span className="remote-host-row__name">{host.label}</span>
<span className="remote-host-row__target">{target}</span>
{state && <RemoteStatusChip state={state} />}
{props.status?.error && <span className="remote-panel__error">{props.status.error}</span>}
</div>
<div className="remote-host-row__actions">
{connected ? (
<>
<button className="btn" onClick={props.onOpen}>
{t("remote.explorer")}
</button>
<button className="btn" onClick={props.onDisconnect}>
{t("remote.disconnect")}
</button>
</>
) : (
<button className="btn btn--primary" onClick={props.onConnect}>
{t("remote.connect")}
</button>
)}
<button className="btn" onClick={props.onEdit}>
{t("remote.host.edit")}
</button>
<button
className="btn btn--danger"
onClick={() => {
if (confirm(t("remote.host.removeConfirm"))) props.onRemove();
}}
>
{t("remote.host.remove")}
</button>
</div>
</li>
);
}
export function RemoteStatusChip({ state }: { state: RemoteConnState }) {
const t = useT();
return (
<span className={`remote-chip remote-chip--${state}`} aria-label={t(`remote.status.${state}`)}>
{t(`remote.status.${state}`)}
</span>
);
}
function RemoteHostForm(props: {
initial: RemoteHostInput;
editingId: string | null;
onDone: () => void;
onCancel: () => void;
}) {
const t = useT();
const [form, setForm] = useState<RemoteHostInput>(props.initial);
const [busy, setBusy] = useState(false);
const [err, setErr] = useState("");
const set = <K extends keyof RemoteHostInput>(k: K, v: RemoteHostInput[K]) =>
setForm((f) => ({ ...f, [k]: v }));
const submit = async () => {
setBusy(true);
setErr("");
try {
if (props.editingId) await app.UpdateRemoteHost(props.editingId, form);
else await app.AddRemoteHost(form);
props.onDone();
} catch (e) {
setErr(String(e));
} finally {
setBusy(false);
}
};
return (
<div className="remote-host-form">
<label>
{t("remote.host.label")}
<input value={form.label} disabled={!!props.editingId} onChange={(e) => set("label", e.target.value)} />
</label>
<label>
{t("remote.host.host")}
<input value={form.host} onChange={(e) => set("host", e.target.value)} />
</label>
<label>
{t("remote.host.port")}
<input type="number" min={1} max={65535} value={form.port} onChange={(e) => set("port", Number(e.target.value) || 0)} />
</label>
<label>
<input type="checkbox" checked={form.useSSHConfig} onChange={(e) => set("useSSHConfig", e.target.checked)} />
{t("remote.host.useSSHConfig")}
</label>
<label>
{t("remote.host.user")}
<input value={form.user} onChange={(e) => set("user", e.target.value)} />
</label>
<label>
{t("remote.host.identityFile")}
<input value={form.identityFile} onChange={(e) => set("identityFile", e.target.value)} />
</label>
<label>
{t("remote.host.proxyJump")}
<input value={form.proxyJump} onChange={(e) => set("proxyJump", e.target.value)} />
</label>
<label>
{t("remote.host.defaultWorkspace")}
<input value={form.defaultWorkspace} onChange={(e) => set("defaultWorkspace", e.target.value)} />
</label>
<label>
{t("remote.host.serveInstall")}
<select value={form.serveInstall} onChange={(e) => set("serveInstall", e.target.value)}>
<option value="auto">auto</option>
<option value="npm">npm</option>
<option value="upload">upload</option>
<option value="never">never</option>
</select>
</label>
{err && <p className="remote-host-form__error" role="alert">{err}</p>}
<div className="remote-host-form__actions">
<button className="btn" onClick={props.onCancel}>{t("remote.host.cancel")}</button>
<button className="btn btn--primary" disabled={busy || !form.label.trim() || !form.host.trim() || form.port < 1 || form.port > 65535} onClick={() => void submit()}>
{t("remote.host.save")}
</button>
</div>
</div>
);
}
function RemoteSSHConfigImport(props: { onDone: () => void; onCancel: () => void }) {
const t = useT();
const [candidates, setCandidates] = useState<RemoteHostInput[]>([]);
const [selected, setSelected] = useState<Record<string, boolean>>({});
const [busy, setBusy] = useState(false);
const [err, setErr] = useState("");
useEffect(() => {
void app.ScanSSHConfig().then(setCandidates).catch((e) => setErr(String(e)));
}, []);
const importSelected = async () => {
setBusy(true);
setErr("");
try {
for (const c of candidates) {
if (selected[c.label]) await app.AddRemoteHost(c);
}
props.onDone();
} catch (e) {
setErr(String(e));
} finally {
setBusy(false);
}
};
return (
<div className="remote-import">
{err && <p className="remote-host-form__error" role="alert">{err}</p>}
{candidates.length === 0 ? (
<p className="remote-hosts__empty">{t("remote.hosts.importEmpty")}</p>
) : (
<ul className="remote-import__list">
{candidates.map((c) => (
<li key={c.label}>
<label>
<input
type="checkbox"
checked={!!selected[c.label]}
onChange={(e) => setSelected((s) => ({ ...s, [c.label]: e.target.checked }))}
/>
{c.label} — {c.user ? c.user + "@" : ""}{c.host}
</label>
</li>
))}
</ul>
)}
<div className="remote-host-form__actions">
<button className="btn" onClick={props.onCancel}>{t("remote.host.cancel")}</button>
<button className="btn btn--primary" disabled={busy || !Object.values(selected).some(Boolean)} onClick={() => void importSelected()}>
{t("remote.hosts.importSelected")}
</button>
</div>
</div>
);
}
function hostToInput(h?: RemoteHostView): RemoteHostInput {
if (!h) return EMPTY_INPUT;
return {
label: h.label,
host: h.host,
port: h.port,
user: h.user,
identityFile: h.identityFile,
proxyJump: h.proxyJump,
defaultWorkspace: h.defaultWorkspace,
serveInstall: h.serveInstall,
useSSHConfig: h.useSSHConfig,
};
}
@@ -0,0 +1,414 @@
import { useCallback, useEffect, useRef, useState, type ReactNode } from "react";
import { app } from "../lib/bridge";
import { useT } from "../lib/i18n";
import { useOverlayStore } from "../store/overlays";
import { useRemoteStore, type RemoteExplorerTab } from "../store/remote";
import type { RemoteDirEntry, RemoteForwardView } from "../lib/types";
import { CodeViewer } from "./CodeViewer";
import { RemoteStatusChip } from "./RemoteHostsPage";
const EMPTY_REMOTE_FORWARDS: RemoteForwardView[] = [];
/** RemotePanel is the right-dock remote work surface: a host header with
* Files / Ports / Server tabs. */
export function RemotePanel({ onClose }: { onClose: () => void }) {
const t = useT();
const hostId = useRemoteStore((s) => s.explorerHostId);
const host = useRemoteStore((s) => s.hosts.find((item) => item.id === hostId));
const tab = useRemoteStore((s) => s.explorerTab);
const setTab = useRemoteStore((s) => s.setExplorerTab);
const status = useRemoteStore((s) => (hostId ? s.statuses[hostId] : undefined));
const setSettingsTarget = useOverlayStore((s) => s.setSettingsTarget);
if (!hostId) return null;
const connected = status?.state === "connected" || status?.state === "degraded";
const busy = status?.state === "connecting" || status?.state === "reconnecting" || status?.state === "pending_hostkey";
const target = host ? `${host.user ? `${host.user}@` : ""}${host.host}${host.port && host.port !== 22 ? `:${host.port}` : ""}` : hostId;
return (
<section className="remote-panel" aria-label={t("remote.explorer")}>
<header className="remote-panel__header">
<span className="remote-panel__host-copy">
<span className="remote-panel__host">{host?.label || hostId}</span>
<span className="remote-panel__target">{target}</span>
</span>
<RemoteStatusChip state={status?.state ?? "stopped"} />
<div className="remote-panel__header-actions">
{connected ? (
<button className="btn btn--small" onClick={() => void app.DisconnectRemoteHost(hostId).catch(() => {})}>
{t("remote.disconnect")}
</button>
) : (
<button className="btn btn--small btn--primary" disabled={busy} onClick={() => void app.ConnectRemoteHost(hostId).catch(() => {})}>
{busy ? t(`remote.status.${status?.state ?? "connecting"}`) : t("remote.connect")}
</button>
)}
<button className="btn btn--ghost" onClick={() => setSettingsTarget("remote")}>
{t("remote.manageHosts")}
</button>
<button className="btn btn--ghost" onClick={onClose} aria-label={t("rightDock.collapse")}>
×
</button>
</div>
</header>
{status?.error && (
<div className="remote-panel__error-banner" role="alert">
<strong>{t("remote.status.failed")}</strong>
<span>{status.error}</span>
</div>
)}
{status?.state === "reconnecting" && (
<div className="remote-panel__banner" role="status">
{t("remote.banner.reconnecting", { n: status.attempt ?? 1 })}
</div>
)}
<nav className="remote-panel__tabs" role="tablist">
{(["files", "ports", "server"] as RemoteExplorerTab[]).map((id) => (
<button
key={id}
role="tab"
aria-selected={tab === id}
className={`remote-panel__tab ${tab === id ? "is-active" : ""}`}
onClick={() => setTab(id)}
>
{t(`remote.tab.${id}`)}
</button>
))}
</nav>
<div className="remote-panel__body">
{tab === "files" && <RemoteFilesTab hostId={hostId} connected={connected} />}
{tab === "ports" && <RemotePortsTab hostId={hostId} connected={connected} />}
{tab === "server" && <RemoteServerTab hostId={hostId} connected={connected} />}
</div>
</section>
);
}
// ── Files tab: lean lazy tree + preview/edit ──
function RemoteFilesTab({ hostId, connected }: { hostId: string; connected: boolean }) {
const t = useT();
const [entriesByDir, setEntriesByDir] = useState<Record<string, RemoteDirEntry[]>>({});
const [openDirs, setOpenDirs] = useState<Set<string>>(new Set());
const [selected, setSelected] = useState<string | null>(null);
const [loadErr, setLoadErr] = useState("");
const rootPath = "."; // remote home; RealPath resolves it server-side
const loadDir = useCallback(
async (path: string) => {
try {
const entries = await app.ListRemoteDir(hostId, path);
setEntriesByDir((m) => ({ ...m, [path]: entries }));
setLoadErr("");
} catch (e) {
setLoadErr(t("remote.tree.loadError", { err: String(e) }));
}
},
[hostId, t],
);
useEffect(() => {
if (connected) void loadDir(rootPath);
}, [connected, loadDir]);
const toggleDir = (path: string) => {
setOpenDirs((prev) => {
const next = new Set(prev);
if (next.has(path)) {
next.delete(path);
} else {
next.add(path);
if (!entriesByDir[path]) void loadDir(path);
}
return next;
});
};
const renderDir = (path: string, depth: number): ReactNode => {
const entries = entriesByDir[path];
if (!entries) return null;
if (entries.length === 0) return <li className="remote-tree__empty">{t("remote.tree.empty")}</li>;
return entries.map((e) => (
<li key={e.path} className="remote-tree__item" style={{ paddingLeft: depth * 12 }}>
{e.isDir ? (
<>
<button className="remote-tree__row" onClick={() => toggleDir(e.path)} role="treeitem" aria-expanded={openDirs.has(e.path)}>
{openDirs.has(e.path) ? "▾" : "▸"} {e.name}/
</button>
{openDirs.has(e.path) && <ul>{renderDir(e.path, depth + 1)}</ul>}
</>
) : (
<button
className={`remote-tree__row ${selected === e.path ? "is-selected" : ""}`}
onClick={() => setSelected(e.path)}
role="treeitem"
>
{e.name}
</button>
)}
</li>
));
};
if (!connected) return <p className="remote-panel__hint">{t("remote.status.stopped")}</p>;
return (
<div className="remote-files">
<div className="remote-files__tree" role="tree">
{loadErr && <p className="remote-panel__error" role="alert">{loadErr}</p>}
<ul>{renderDir(rootPath, 0)}</ul>
</div>
<div className="remote-files__view">
{selected ? <RemoteFileView hostId={hostId} path={selected} connected={connected} /> : null}
</div>
</div>
);
}
function RemoteFileView({ hostId, path, connected }: { hostId: string; path: string; connected: boolean }) {
const t = useT();
const [body, setBody] = useState("");
const [draft, setDraft] = useState<string | null>(null);
const [mtime, setMtime] = useState(0);
const [binary, setBinary] = useState(false);
const [truncated, setTruncated] = useState(false);
const [saving, setSaving] = useState(false);
const [conflict, setConflict] = useState(false);
const [err, setErr] = useState("");
const load = useCallback(async () => {
const p = await app.ReadRemoteFile(hostId, path);
setBody(p.body);
setDraft(null);
setMtime(p.mtimeUnix);
setBinary(p.binary);
setTruncated(p.truncated);
setErr(p.err ?? "");
}, [hostId, path]);
useEffect(() => {
void load();
}, [load]);
const editable = connected && !binary && !truncated && !err;
const dirty = draft !== null && draft !== body;
const save = async (force: boolean) => {
if (draft === null) return;
setSaving(true);
try {
const res = await app.WriteRemoteFile(hostId, path, draft, force ? 0 : mtime);
if (res.conflict) {
setConflict(true);
return;
}
setBody(draft);
setDraft(null);
setMtime(res.newMtimeUnix);
setConflict(false);
} finally {
setSaving(false);
}
};
return (
<div className="remote-file-view">
<div className="remote-file-view__toolbar">
<span className="remote-file-view__path">{path}</span>
{err && <span className="remote-panel__error">{err}</span>}
{binary && <span className="remote-panel__hint">{t("remote.editor.binaryBlocked")}</span>}
{truncated && <span className="remote-panel__hint">{t("remote.editor.truncatedBlocked")}</span>}
{editable && draft === null && (
<button className="btn" onClick={() => setDraft(body)}>{t("remote.editor.edit")}</button>
)}
{draft !== null && (
<button className="btn btn--primary" disabled={saving || !dirty || !connected} onClick={() => void save(false)}>
{saving ? t("remote.editor.saving") : t("remote.editor.save")}
</button>
)}
{draft !== null && !connected && <span className="remote-panel__hint">{t("remote.editor.readOnlyDisconnected")}</span>}
</div>
{draft === null ? (
<CodeViewer value={body} readOnly />
) : (
<textarea
className="remote-file-view__editor"
value={draft}
spellCheck={false}
onChange={(e) => setDraft(e.target.value)}
/>
)}
{conflict && (
<div className="remote-file-view__conflict" role="alertdialog">
<p><strong>{t("remote.editor.conflictTitle")}</strong></p>
<p>{t("remote.editor.conflictBody")}</p>
<button className="btn" onClick={() => void load()}>{t("remote.editor.reload")}</button>
<button className="btn btn--danger" onClick={() => void save(true)}>{t("remote.editor.overwrite")}</button>
</div>
)}
</div>
);
}
// ── Ports tab ──
function RemotePortsTab({ hostId, connected }: { hostId: string; connected: boolean }) {
const t = useT();
const forwards = useRemoteStore((s) => s.forwards[hostId] ?? EMPTY_REMOTE_FORWARDS);
const setForwards = useRemoteStore((s) => s.setForwards);
const [localPort, setLocalPort] = useState(8080);
const [remoteHost, setRemoteHost] = useState("127.0.0.1");
const [remotePort, setRemotePort] = useState(80);
const [label, setLabel] = useState("");
const [actionErr, setActionErr] = useState("");
useEffect(() => {
if (connected) void app.RemoteForwards(hostId).then((f) => setForwards(hostId, f));
}, [hostId, connected, setForwards]);
const add = async () => {
try {
await app.AddRemoteForward(hostId, { localPort, remoteHost, remotePort, label });
setLabel("");
setActionErr("");
} catch (e) {
setActionErr(String(e));
}
};
const remove = async (forwardId: string) => {
try {
await app.RemoveRemoteForward(hostId, forwardId);
setActionErr("");
} catch (e) {
setActionErr(String(e));
}
};
return (
<div className="remote-ports">
{actionErr && <p className="remote-panel__error" role="alert">{actionErr}</p>}
{forwards.length === 0 ? (
<p className="remote-panel__hint">{t("remote.ports.empty")}</p>
) : (
<ul className="remote-ports__list">
{forwards.map((f: RemoteForwardView) => (
<li key={f.id} className="remote-ports__row">
<span className={`remote-dot remote-dot--${f.state}`} aria-hidden />
<span>{f.label || f.id}</span>
{f.error && <span className="remote-panel__error">{f.error}</span>}
<button className="btn btn--ghost" onClick={() => void remove(f.id)}>
{t("remote.ports.remove")}
</button>
</li>
))}
</ul>
)}
<div className="remote-ports__form">
<input type="number" min={1} max={65535} aria-label={t("remote.ports.localPort")} value={localPort} onChange={(e) => setLocalPort(Number(e.target.value) || 0)} />
<input aria-label={t("remote.ports.remoteHost")} value={remoteHost} onChange={(e) => setRemoteHost(e.target.value)} />
<input type="number" min={1} max={65535} aria-label={t("remote.ports.remotePort")} value={remotePort} onChange={(e) => setRemotePort(Number(e.target.value) || 0)} />
<input aria-label={t("remote.ports.label")} placeholder={t("remote.ports.label")} value={label} onChange={(e) => setLabel(e.target.value)} />
<button className="btn btn--primary" disabled={!connected || !remoteHost.trim() || localPort < 1 || localPort > 65535 || remotePort < 1 || remotePort > 65535} onClick={() => void add()}>{t("remote.ports.add")}</button>
</div>
</div>
);
}
// ── Server tab ──
function RemoteServerTab({ hostId, connected }: { hostId: string; connected: boolean }) {
const t = useT();
const server = useRemoteStore((s) => s.servers[hostId]);
const setServer = useRemoteStore((s) => s.setServer);
const [workspace, setWorkspace] = useState("");
const [logs, setLogs] = useState("");
const [actionErr, setActionErr] = useState("");
const logsOpen = useRef(false);
useEffect(() => {
void app.RemoteLastWorkspace(hostId).then((w) => setWorkspace((cur) => cur || w));
void app.RemoteServerStatus(hostId).then(setServer);
}, [hostId, setServer]);
const refreshLogs = async () => {
logsOpen.current = true;
try {
setLogs(await app.RemoteServerLogs(hostId, 200));
setActionErr("");
} catch (e) {
setLogs("");
setActionErr(String(e));
}
};
const start = async () => {
try {
setActionErr("");
await app.OpenRemoteWorkspace(hostId, workspace);
} catch (e) {
setActionErr(String(e));
}
};
const stop = async () => {
try {
setActionErr("");
await app.StopRemoteServer(hostId);
} catch (e) {
setActionErr(String(e));
}
};
const state = server?.state ?? "stopped";
const busy = ["starting", "detect", "install", "waiting_lock", "launch", "health_check", "reuse"].includes(state);
const stateLabel = state === "ready"
? t("remote.server.state.ready")
: state === "error"
? t("remote.server.state.error")
: busy
? t("remote.server.state.starting")
: t("remote.server.state.stopped");
const canManageServer = connected && Boolean(server?.workspace) && state !== "stopped";
return (
<div className="remote-server">
<label className="remote-server__ws">
{t("remote.server.workspace")}
<input value={workspace} onChange={(e) => setWorkspace(e.target.value)} placeholder="~/project" />
</label>
<div className="remote-server__status">
{stateLabel}
{server?.message ? ` — ${server.message}` : ""}
{server?.error ? ` — ${server.error}` : ""}
{actionErr ? ` — ${actionErr}` : ""}
</div>
<div className="remote-server__actions">
<button className="btn btn--primary" disabled={!connected || !workspace || busy} onClick={() => void start()}>
{t("remote.server.start")}
</button>
{server?.localUrl && (
<button className="btn" onClick={() => void app.OpenRemoteWorkspace(hostId, workspace)}>
{t("remote.server.openBrowser")}
</button>
)}
<button className="btn" disabled={!canManageServer || busy} onClick={() => void stop()}>
{t("remote.server.stop")}
</button>
<button className="btn btn--ghost" disabled={!canManageServer} onClick={() => void refreshLogs()}>
{t("remote.server.logs")}
</button>
</div>
{logsOpen.current && (
<pre className="remote-server__logs">
{logs}
<button className="btn btn--ghost" onClick={() => void refreshLogs()}>{t("remote.server.refreshLogs")}</button>
</pre>
)}
</div>
);
}
@@ -63,11 +63,12 @@ import { getSuccessPreference, setSuccessPreference, getAttentionPreference, set
import { ModalCloseButton } from "./ModalCloseButton";
import { ShortcutComboDisplay } from "./ShortcutComboDisplay";
const SETTINGS_TABS: SettingsTab[] = ["general", "models", "bots", "mcp", "skills", "subagents", "plugins", "memory", "hooks", "diagnostics", "shortcuts", "permissions", "sandbox", "network", "appearance", "updates"];
const SETTINGS_TABS: SettingsTab[] = ["general", "models", "bots", "mcp", "remote", "skills", "subagents", "plugins", "memory", "hooks", "diagnostics", "shortcuts", "permissions", "sandbox", "network", "appearance", "updates"];
export type SettingsInitialFocus = { target: "bot-allowlist"; connectionId?: string };
type DesktopPlatform = "darwin" | "windows" | "linux";
const MCPServersSettingsPage = lazy(() => import("./CapabilitiesPanel").then((module) => ({ default: module.MCPServersSettingsPage })));
const RemoteHostsPage = lazy(() => import("./RemoteHostsPage").then((module) => ({ default: module.RemoteHostsPage })));
const SkillsSettingsPage = lazy(() => import("./CapabilitiesPanel").then((module) => ({ default: module.SkillsSettingsPage })));
const PluginsSettingsPage = lazy(() => import("./CapabilitiesPanel").then((module) => ({ default: module.PluginsSettingsPage })));
const MemorySettingsPage = lazy(() => import("./MemoryPanel").then((module) => ({ default: module.MemorySettingsPage })));
@@ -268,6 +269,7 @@ export function SettingsPanel({
{tab === "models" && s && <SettingsPageShell key={tab} s={s} tab={tab} busy={busy} apply={apply}><ModelsSection s={s} busy={busy} apply={apply} backgroundApply={backgroundApply} /></SettingsPageShell>}
{tab === "bots" && s && <SettingsPageShell key={tab} s={s} tab={tab} busy={busy} apply={apply}><BotsSection s={s} busy={busy} apply={apply} initialFocus={initialFocus} /></SettingsPageShell>}
{tab === "mcp" && <SettingsPageShell key={tab} s={s} tab={tab} busy={false} apply={apply}><Suspense fallback={lazySettingsPageFallback}><MCPServersSettingsPage /></Suspense></SettingsPageShell>}
{tab === "remote" && <SettingsPageShell key={tab} s={s} tab={tab} busy={false} apply={apply}><Suspense fallback={lazySettingsPageFallback}><RemoteHostsPage /></Suspense></SettingsPageShell>}
{tab === "skills" && <SettingsPageShell key={tab} s={s} tab={tab} busy={false} apply={apply}><Suspense fallback={lazySettingsPageFallback}><SkillsSettingsPage /></Suspense></SettingsPageShell>}
{tab === "subagents" && s && <SettingsPageShell key={tab} s={s} tab={tab} busy={busy} apply={apply}><Suspense fallback={lazySettingsPageFallback}><SubagentsSettingsPage s={s} onUseInChat={(command) => {
pendingSubagentCommandRef.current = command;
@@ -382,6 +384,7 @@ function settingsPageKind(tab: SettingsTab): "form" | "manager" {
switch (tab) {
case "models":
case "mcp":
case "remote":
case "skills":
case "subagents":
case "plugins":
@@ -499,6 +502,8 @@ function settingsTabLabel(id: SettingsTab, t: ReturnType<typeof useT>): string {
return t("settings.tab.bots");
case "mcp":
return t("settings.tab.mcp");
case "remote":
return t("settings.tab.remote");
case "skills":
return t("settings.tab.skills");
case "subagents":
@@ -538,6 +543,8 @@ function settingsTabMeta(id: SettingsTab, s: SettingsView, t: ReturnType<typeof
return botSettingsMeta(s.bot, t);
case "mcp":
return t("caps.connectorsTab");
case "remote":
return t("remote.tabHint");
case "skills":
return t("caps.skillsTab");
case "subagents":
+182 -3
View File
@@ -1,10 +1,11 @@
import { type ReactNode } from "react";
import { Activity, CircleDollarSign, CircleGauge, Database, Folder, GitBranch, Layers, Percent, RefreshCw, Wallet, Zap } from "lucide-react";
import { useRef, useState, type ReactNode } from "react";
import { Activity, Check, ChevronsUpDown, CircleDollarSign, CircleGauge, Database, Folder, GitBranch, Laptop, Layers, Percent, RefreshCw, Server, Settings, Unplug, Wallet, Zap } from "lucide-react";
import { AnchoredPopover } from "./AnchoredPopover";
import { Tooltip } from "./Tooltip";
import { useI18n, type Translator } from "../lib/i18n";
import { formatMoneyLocalized } from "../lib/money";
import { normalizeStatusBarItems, type StatusBarItemId } from "../lib/statusBarItems";
import { type BalanceInfo, type ContextInfo, type UsageSourceStats, type WireUsage } from "../lib/types";
import { type BalanceInfo, type ContextInfo, type RemoteConnectionStatus, type RemoteHostView, type UsageSourceStats, type WireUsage } from "../lib/types";
type StatusBarLabelStyle = "icon" | "text";
@@ -165,6 +166,13 @@ export function StatusBar({
workspacePath,
workspaceName,
gitBranch,
onConnectRemote,
onDisconnectRemote,
onManageRemote,
onOpenRemote,
onOpenRemoteWorkspace,
remoteHosts = [],
remoteStatuses = {},
}: {
context: ContextInfo;
usage?: WireUsage;
@@ -182,6 +190,13 @@ export function StatusBar({
workspacePath?: string;
workspaceName?: string;
gitBranch?: string;
onConnectRemote?: (host: RemoteHostView) => void;
onDisconnectRemote?: (hostId: string) => void;
onManageRemote?: () => void;
onOpenRemote?: (hostId: string) => void;
onOpenRemoteWorkspace?: (host: RemoteHostView) => void;
remoteHosts?: RemoteHostView[];
remoteStatuses?: Record<string, RemoteConnectionStatus>;
}) {
const { locale, t } = useI18n();
const pct = context.window ? Math.min(100, Math.round((context.used / context.window) * 100)) : null;
@@ -325,6 +340,15 @@ export function StatusBar({
return (
<div className={`statusbar statusbar--${metricLabelStyle}`}>
<div className="statusbar__group statusbar__group--items">
<RemoteStatusBarChip
hosts={remoteHosts}
statuses={remoteStatuses}
onOpen={onOpenRemote}
onOpenWorkspace={onOpenRemoteWorkspace}
onConnect={onConnectRemote}
onDisconnect={onDisconnectRemote}
onManage={onManageRemote}
/>
{renderedItems.map(({ id, node }) => (
<span className="statusbar__item" data-statusbar-item={id} key={id}>
{node}
@@ -334,3 +358,158 @@ export function StatusBar({
</div>
);
}
// This entry remains visible whenever an SSH host is configured. The popover
// owns quick connection actions; remote files and services live in the dock.
const REMOTE_STATE_SEVERITY: Record<string, number> = {
error: 5,
reconnecting: 4,
pending_hostkey: 4,
connecting: 3,
degraded: 2,
connected: 1,
stopped: 0,
};
function RemoteStatusBarChip({
hosts,
statuses,
onOpen,
onOpenWorkspace,
onConnect,
onDisconnect,
onManage,
}: {
hosts: RemoteHostView[];
statuses: Record<string, RemoteConnectionStatus>;
onOpen?: (hostId: string) => void;
onOpenWorkspace?: (host: RemoteHostView) => void;
onConnect?: (host: RemoteHostView) => void;
onDisconnect?: (hostId: string) => void;
onManage?: () => void;
}) {
const { t } = useI18n();
const [open, setOpen] = useState(false);
const triggerRef = useRef<HTMLButtonElement>(null);
if (hosts.length === 0) return null;
const entries = hosts.map((host) => statuses[host.id] ?? { hostId: host.id, state: "stopped" as const });
const worst = entries.reduce((a, b) => {
const aSeverity = a.error ? 6 : REMOTE_STATE_SEVERITY[a.state] ?? 0;
const bSeverity = b.error ? 6 : REMOTE_STATE_SEVERITY[b.state] ?? 0;
return bSeverity > aSeverity ? b : a;
});
const worstHost = hosts.find((host) => host.id === worst.hostId) ?? hosts[0];
const triggerState = worst.error ? "error" : worst.state;
const triggerStatus = worst.error ? t("remote.status.failed") : t(`remote.status.${worst.state}`);
const triggerLabel = worst.state === "stopped" && !worst.error
? t("remote.statusBar.disconnected")
: t("remote.statusBar.summary", { host: worstHost.label, status: triggerStatus });
return (
<span className="statusbar__remote-wrap">
<button
ref={triggerRef}
type="button"
className={`statusbar__remote remote-chip remote-chip--${triggerState}`}
onClick={() => setOpen((value) => !value)}
aria-label={triggerLabel}
aria-haspopup="dialog"
aria-expanded={open}
title={triggerLabel}
>
<Server size={11} aria-hidden="true" />
<span>{triggerLabel}</span>
<ChevronsUpDown size={10} aria-hidden="true" />
</button>
<AnchoredPopover
open={open}
anchorRef={triggerRef}
onClose={() => setOpen(false)}
className="remote-switcher"
align="start"
>
<section role="dialog" aria-label={t("remote.switcher.title")}>
<header className="remote-switcher__header">{t("remote.switcher.title")}</header>
<div className="remote-switcher__local" aria-current="true">
<span className="remote-switcher__icon"><Laptop size={14} aria-hidden="true" /></span>
<span className="remote-switcher__copy">
<strong>{t("remote.switcher.local")}</strong>
<small>{t("remote.switcher.currentSession")}</small>
</span>
<Check size={14} className="remote-switcher__check" aria-hidden="true" />
</div>
<div className="remote-switcher__section-label">{t("remote.switcher.hosts")}</div>
<div className="remote-switcher__hosts">
{hosts.map((host) => {
const status = statuses[host.id] ?? { hostId: host.id, state: "stopped" as const };
const connected = status.state === "connected" || status.state === "degraded";
const busy = status.state === "connecting" || status.state === "reconnecting" || status.state === "pending_hostkey";
const stateClass = status.error ? "error" : status.state;
const stateLabel = status.error ? t("remote.status.failed") : t(`remote.status.${status.state}`);
const target = `${host.user ? `${host.user}@` : ""}${host.host}${host.port && host.port !== 22 ? `:${host.port}` : ""}`;
return (
<div className={`remote-switcher__host remote-switcher__host--${stateClass}`} key={host.id}>
<button
type="button"
className="remote-switcher__host-main"
onClick={() => {
setOpen(false);
onOpen?.(host.id);
}}
>
<span className={`remote-switcher__state remote-switcher__state--${stateClass}`} aria-hidden="true" />
<span className="remote-switcher__copy">
<strong>{host.label}</strong>
<small>{stateLabel} · {host.defaultWorkspace || target}</small>
</span>
</button>
<span className="remote-switcher__actions">
<button
type="button"
className="btn btn--small btn--primary"
disabled={busy}
onClick={() => {
if (connected) {
setOpen(false);
onOpenWorkspace?.(host);
} else {
setOpen(false);
onConnect?.(host);
}
}}
>
{connected ? t("remote.openWorkspace") : busy ? stateLabel : t("remote.connectAndOpen")}
</button>
{connected && (
<button
type="button"
className="remote-switcher__disconnect"
onClick={() => onDisconnect?.(host.id)}
aria-label={t("remote.disconnectHost", { host: host.label })}
title={t("remote.disconnect")}
>
<Unplug size={13} aria-hidden="true" />
</button>
)}
</span>
</div>
);
})}
</div>
<button
type="button"
className="remote-switcher__manage"
onClick={() => {
setOpen(false);
onManage?.();
}}
>
<Settings size={13} aria-hidden="true" />
{t("remote.switcher.manage")}
</button>
</section>
</AnchoredPopover>
</span>
);
}
+169
View File
@@ -21,6 +21,16 @@ import type {
AutoResearchFindingView,
AutoResearchEvidenceView,
AutoResearchStatusView,
RemoteHostView,
RemoteHostInput,
RemoteConnectionStatus,
RemoteDirEntry,
RemoteFilePreview,
RemoteWriteResult,
RemoteForwardInput,
RemoteForwardView,
RemoteServerView,
RemoteForwardsEvent,
BalanceInfo,
BotConnectionDiagnostic,
BotInstallPollResult,
@@ -440,6 +450,31 @@ export interface AppBindings {
// New native-feel bindings (added with the desktop native-feel plan).
ConfirmAction(req: NativeConfirmRequest): Promise<boolean>;
SaveWindowState(state: DesktopWindowState): Promise<void>;
// ── Remote (SSH) ──
RemoteHosts(): Promise<RemoteHostView[]>;
AddRemoteHost(input: RemoteHostInput): Promise<RemoteHostView>;
UpdateRemoteHost(id: string, input: RemoteHostInput): Promise<RemoteHostView>;
RemoveRemoteHost(id: string): Promise<void>;
ScanSSHConfig(): Promise<RemoteHostInput[]>;
ConnectRemoteHost(id: string): Promise<void>;
DisconnectRemoteHost(id: string): Promise<void>;
RemoteConnectionStatuses(): Promise<RemoteConnectionStatus[]>;
ConfirmRemoteHostKey(hostId: string, accept: boolean): Promise<void>;
ListRemoteDir(hostId: string, path: string): Promise<RemoteDirEntry[]>;
ReadRemoteFile(hostId: string, path: string): Promise<RemoteFilePreview>;
WriteRemoteFile(hostId: string, path: string, body: string, expectMtimeUnix: number): Promise<RemoteWriteResult>;
MkdirRemote(hostId: string, path: string): Promise<void>;
RenameRemotePath(hostId: string, oldPath: string, newPath: string): Promise<void>;
DeleteRemotePath(hostId: string, path: string, recursive: boolean): Promise<void>;
RemoteForwards(hostId: string): Promise<RemoteForwardView[]>;
AddRemoteForward(hostId: string, input: RemoteForwardInput): Promise<RemoteForwardView>;
RemoveRemoteForward(hostId: string, forwardId: string): Promise<void>;
EnsureRemoteServer(hostId: string, workspace: string): Promise<void>;
OpenRemoteWorkspace(hostId: string, workspace: string): Promise<void>;
StopRemoteServer(hostId: string): Promise<void>;
RemoteServerStatus(hostId: string): Promise<RemoteServerView>;
RemoteServerLogs(hostId: string, tailLines: number): Promise<string>;
RemoteLastWorkspace(hostId: string): Promise<string>;
}
// Compile-time drift check. Exclude<A, B> extracts keys in A that are missing
@@ -676,6 +711,43 @@ export function onSessionRecoveryFailed(cb: (payload: SessionRecoveryFailedEvent
return () => {};
}
export function onRemoteStatus(cb: (s: RemoteConnectionStatus) => void): () => void {
if (realApp() && typeof window !== "undefined" && window.runtime) {
return window.runtime.EventsOn("remote:status", (payload?: unknown) => cb((payload ?? {}) as RemoteConnectionStatus));
}
return registerMockRemoteListener("status", cb as (v: unknown) => void);
}
export function onRemoteForwards(cb: (e: RemoteForwardsEvent) => void): () => void {
if (realApp() && typeof window !== "undefined" && window.runtime) {
return window.runtime.EventsOn("remote:forwards", (payload?: unknown) => cb((payload ?? {}) as RemoteForwardsEvent));
}
return registerMockRemoteListener("forwards", cb as (v: unknown) => void);
}
export function onRemoteServer(cb: (s: RemoteServerView) => void): () => void {
if (realApp() && typeof window !== "undefined" && window.runtime) {
return window.runtime.EventsOn("remote:server", (payload?: unknown) => cb((payload ?? {}) as RemoteServerView));
}
return registerMockRemoteListener("server", cb as (v: unknown) => void);
}
// Mock event fan-out so browser-dev and tsx tests can drive remote:* events
// without a Wails runtime.
type MockRemoteChannel = "status" | "forwards" | "server";
const mockRemoteListeners: Record<MockRemoteChannel, Set<(v: unknown) => void>> = {
status: new Set(),
forwards: new Set(),
server: new Set(),
};
function registerMockRemoteListener(ch: MockRemoteChannel, cb: (v: unknown) => void): () => void {
mockRemoteListeners[ch].add(cb);
return () => mockRemoteListeners[ch].delete(cb);
}
export function __emitMockRemote(ch: MockRemoteChannel, payload: unknown): void {
for (const cb of mockRemoteListeners[ch]) cb(payload);
}
// app proxies each call to the live binding (or the dev mock only when truly
// outside the shell), so a late-injected window.go is picked up transparently.
function bridgeBreadcrumb(method: string): string {
@@ -4354,5 +4426,102 @@ function makeMockApp(): AppBindings {
],
};
},
// ── Remote (SSH) mock ──
async RemoteHosts() {
return mockRemoteHosts.slice();
},
async AddRemoteHost(input) {
const view: RemoteHostView = { id: input.label, ...input };
mockRemoteHosts = [...mockRemoteHosts.filter((h) => h.id !== view.id), view];
return view;
},
async UpdateRemoteHost(id, input) {
const view: RemoteHostView = { id, ...input };
mockRemoteHosts = mockRemoteHosts.map((h) => (h.id === id ? view : h));
return view;
},
async RemoveRemoteHost(id) {
mockRemoteHosts = mockRemoteHosts.filter((h) => h.id !== id);
delete mockRemoteConn[id];
},
async ScanSSHConfig() {
return [
{ label: "gpu-box", host: "203.0.113.7", port: 22, user: "dev", identityFile: "~/.ssh/id_ed25519", proxyJump: "", defaultWorkspace: "", serveInstall: "auto", useSSHConfig: true },
];
},
async ConnectRemoteHost(id) {
mockRemoteConn[id] = "connecting";
__emitMockRemote("status", { hostId: id, state: "connecting" });
setTimeout(() => {
mockRemoteConn[id] = "connected";
__emitMockRemote("status", { hostId: id, state: "connected" });
}, 300);
},
async DisconnectRemoteHost(id) {
mockRemoteConn[id] = "stopped";
__emitMockRemote("status", { hostId: id, state: "stopped" });
},
async RemoteConnectionStatuses() {
return Object.entries(mockRemoteConn).map(([hostId, state]) => ({ hostId, state: state as RemoteConnectionStatus["state"] }));
},
async ConfirmRemoteHostKey(hostId, accept) {
mockRemoteConn[hostId] = accept ? "connected" : "stopped";
__emitMockRemote("status", { hostId, state: mockRemoteConn[hostId] });
},
async ListRemoteDir(_hostId, path) {
const base = path.replace(/\/$/, "");
return [
{ name: "src", path: `${base}/src`, isDir: true, size: 0, mtimeUnix: 1_700_000_000, symlink: false },
{ name: "README.md", path: `${base}/README.md`, isDir: false, size: 1024, mtimeUnix: 1_700_000_500, symlink: false },
];
},
async ReadRemoteFile(_hostId, path) {
return { path, body: `# Mock remote file\n${path}\n`, size: 40, mtimeUnix: 1_700_000_500, truncated: false, binary: false };
},
async WriteRemoteFile(_hostId, _path, _body, _expectMtimeUnix) {
return { ok: true, conflict: false, newMtimeUnix: 1_700_000_900 };
},
async MkdirRemote() {},
async RenameRemotePath() {},
async DeleteRemotePath() {},
async RemoteForwards(hostId) {
return mockRemoteForwards[hostId] ?? [];
},
async AddRemoteForward(hostId, input) {
const view: RemoteForwardView = { id: `L:${input.localPort}`, hostId, ...input, state: "active" };
mockRemoteForwards[hostId] = [...(mockRemoteForwards[hostId] ?? []), view];
__emitMockRemote("forwards", { hostId, forwards: mockRemoteForwards[hostId] });
return view;
},
async RemoveRemoteForward(hostId, forwardId) {
mockRemoteForwards[hostId] = (mockRemoteForwards[hostId] ?? []).filter((f) => f.id !== forwardId);
__emitMockRemote("forwards", { hostId, forwards: mockRemoteForwards[hostId] });
},
async EnsureRemoteServer(hostId, workspace) {
for (const state of ["starting", "detect", "launch", "ready"] as const) {
__emitMockRemote("server", { hostId, workspace, state, localUrl: state === "ready" ? "http://127.0.0.1:44321/" : "" });
}
},
async OpenRemoteWorkspace() {},
async StopRemoteServer(hostId) {
__emitMockRemote("server", { hostId, workspace: "", state: "stopped" });
},
async RemoteServerStatus(hostId) {
return { hostId, workspace: "~/app", state: "stopped" };
},
async RemoteServerLogs() {
return "mock serve log line 1\nmock serve log line 2\n";
},
async RemoteLastWorkspace() {
return "~/app";
},
};
}
// Mock remote state, module-scoped so it survives across mock method calls.
let mockRemoteHosts: RemoteHostView[] = [
{ id: "demo", label: "demo", host: "192.168.1.10", port: 22, user: "dev", identityFile: "", proxyJump: "", defaultWorkspace: "~/app", serveInstall: "auto", useSSHConfig: false },
];
const mockRemoteConn: Record<string, RemoteConnectionStatus["state"]> = {};
const mockRemoteForwards: Record<string, RemoteForwardView[]> = {};
+120 -1
View File
@@ -1028,7 +1028,126 @@ export interface MemoryView {
}
// SettingsTab is the top-level navigation item in the Settings Centre modal.
export type SettingsTab = "general" | "models" | "providers" | "bots" | "mcp" | "skills" | "subagents" | "plugins" | "memory" | "hooks" | "diagnostics" | "shortcuts" | "permissions" | "sandbox" | "network" | "appearance" | "updates";
export type SettingsTab = "general" | "models" | "providers" | "bots" | "mcp" | "remote" | "skills" | "subagents" | "plugins" | "memory" | "hooks" | "diagnostics" | "shortcuts" | "permissions" | "sandbox" | "network" | "appearance" | "updates";
// ── Remote SSH module (mirrors desktop/remote_app.go view structs) ──
export type RemoteConnState =
| "connecting"
| "connected"
| "reconnecting"
| "degraded"
| "pending_hostkey"
| "stopped";
export type RemoteServerState =
| "starting"
| "detect"
| "install"
| "waiting_lock"
| "launch"
| "health_check"
| "ready"
| "error"
| "stopped"
| "reuse";
export interface RemoteHostView {
id: string;
label: string;
host: string;
port: number;
user: string;
identityFile: string;
proxyJump: string;
defaultWorkspace: string;
serveInstall: string;
useSSHConfig: boolean;
}
export interface RemoteHostInput {
label: string;
host: string;
port: number;
user: string;
identityFile: string;
proxyJump: string;
defaultWorkspace: string;
serveInstall: string;
useSSHConfig: boolean;
}
export interface RemoteFingerprintView {
hostId: string;
address: string;
keyType: string;
sha256: string;
}
export interface RemoteConnectionStatus {
hostId: string;
state: RemoteConnState;
error?: string;
fingerprint?: RemoteFingerprintView;
attempt?: number;
}
export interface RemoteDirEntry {
name: string;
path: string;
isDir: boolean;
size: number;
mtimeUnix: number;
symlink: boolean;
}
export interface RemoteFilePreview {
path: string;
body: string;
size: number;
mtimeUnix: number;
truncated: boolean;
binary: boolean;
err?: string;
}
export interface RemoteWriteResult {
ok: boolean;
conflict: boolean;
newMtimeUnix: number;
}
export interface RemoteForwardInput {
localPort: number;
remoteHost: string;
remotePort: number;
label: string;
}
export interface RemoteForwardView {
id: string;
hostId: string;
localPort: number;
remoteHost: string;
remotePort: number;
label: string;
state: string;
error?: string;
}
export interface RemoteServerView {
hostId: string;
workspace: string;
state: RemoteServerState;
message?: string;
localUrl?: string;
error?: string;
}
export interface RemoteForwardsEvent {
hostId: string;
forwards: RemoteForwardView[];
}
/** Capability diagnostics report from App.CapabilityDiagnostics (capdiag.Report). */
export interface CapabilityDiagnosticsReport {
+91
View File
@@ -145,6 +145,7 @@ export const en = {
"rightDock.workbench": "Workspace panel",
"rightDock.views": "Workspace views",
"rightDock.overview": "Overview",
"rightDock.remote": "Remote",
// topic bar
"topicBar.renameSession": "Rename session",
@@ -1187,7 +1188,94 @@ export const en = {
"settings.tab.bots": "Bots",
"settings.tab.capabilities": "MCP & Skills",
"settings.tab.mcp": "MCP & Tools",
"settings.tab.remote": "Remote SSH",
"settings.tab.skills": "Skills",
"remote.tabHint": "SSH hosts & remote workspaces",
"remote.hosts.title": "Remote SSH hosts",
"remote.hosts.empty": "No remote hosts yet.",
"remote.hosts.add": "Add host",
"remote.hosts.import": "Import from ~/.ssh/config",
"remote.hosts.importEmpty": "No importable aliases found (Match blocks are not supported).",
"remote.hosts.importSelected": "Import selected",
"remote.host.label": "Name",
"remote.host.host": "Host",
"remote.host.port": "Port",
"remote.host.user": "User",
"remote.host.identityFile": "Identity file",
"remote.host.useSSHConfig": "Use ~/.ssh/config for unset connection options",
"remote.host.proxyJump": "Proxy jump",
"remote.host.defaultWorkspace": "Default workspace",
"remote.host.serveInstall": "Serve install",
"remote.host.save": "Save",
"remote.host.cancel": "Cancel",
"remote.host.edit": "Edit",
"remote.host.remove": "Remove",
"remote.host.removeConfirm": "Remove this host?",
"remote.connect": "Connect",
"remote.connectAndOpen": "Connect & open",
"remote.disconnect": "Disconnect",
"remote.disconnectHost": "Disconnect {host}",
"remote.openWorkspace": "Open workspace",
"remote.manageHosts": "Manage hosts",
"remote.explorer": "Remote explorer",
"remote.switcher.title": "Open a remote window",
"remote.switcher.local": "This window · Local",
"remote.switcher.currentSession": "The current chat and tools run on this computer",
"remote.switcher.hosts": "SSH hosts",
"remote.switcher.manage": "Manage SSH hosts…",
"remote.statusBar.disconnected": "SSH · Disconnected",
"remote.statusBar.summary": "{host} · {status}",
"remote.status.failed": "Connection failed",
"remote.status.connected": "Connected",
"remote.status.connecting": "Connecting…",
"remote.status.reconnecting": "Reconnecting…",
"remote.status.degraded": "Degraded",
"remote.status.pending_hostkey": "Confirm host key",
"remote.status.stopped": "Disconnected",
"remote.banner.reconnecting": "Connection lost — reconnecting (attempt {n})…",
"remote.fingerprint.title": "Unknown host key",
"remote.fingerprint.body": "The host {host} presented a key Reasonix has not seen before. Accept it only if you trust this connection.",
"remote.fingerprint.type": "Key type",
"remote.fingerprint.sha256": "SHA256",
"remote.fingerprint.accept": "Accept & continue",
"remote.fingerprint.reject": "Reject",
"remote.tab.files": "Files",
"remote.tab.ports": "Ports",
"remote.tab.server": "Server",
"remote.tree.empty": "Empty directory",
"remote.tree.loadError": "Failed to load: {err}",
"remote.tree.newFolder": "New folder",
"remote.tree.rename": "Rename",
"remote.tree.delete": "Delete",
"remote.editor.edit": "Edit",
"remote.editor.save": "Save",
"remote.editor.saving": "Saving…",
"remote.editor.binaryBlocked": "Binary file — not editable.",
"remote.editor.truncatedBlocked": "File too large to edit (truncated preview).",
"remote.editor.readOnlyDisconnected": "Disconnected — saving is disabled.",
"remote.editor.conflictTitle": "File changed on remote",
"remote.editor.conflictBody": "This file changed on the remote host since you opened it.",
"remote.editor.reload": "Reload",
"remote.editor.overwrite": "Overwrite",
"remote.ports.title": "Port forwards",
"remote.ports.empty": "No forwards.",
"remote.ports.add": "Add forward",
"remote.ports.localPort": "Local port",
"remote.ports.remoteHost": "Remote host",
"remote.ports.remotePort": "Remote port",
"remote.ports.label": "Label",
"remote.ports.remove": "Remove",
"remote.server.title": "Remote serve",
"remote.server.workspace": "Workspace",
"remote.server.start": "Start & open",
"remote.server.stop": "Stop",
"remote.server.openBrowser": "Open in browser",
"remote.server.logs": "Logs",
"remote.server.refreshLogs": "Refresh",
"remote.server.state.ready": "Ready",
"remote.server.state.starting": "Starting…",
"remote.server.state.stopped": "Stopped",
"remote.server.state.error": "Error",
"settings.tab.subagents": "Subagents",
"settings.tab.plugins": "Plugins",
"settings.tab.memory": "Memory",
@@ -1922,6 +2010,9 @@ export const en = {
"palette.cmd.appearance": "Appearance",
"palette.cmd.memory": "Memory",
"palette.cmd.models": "Models",
"palette.group.remote": "Remote SSH",
"palette.remote.connect": "Connect to {host}",
"palette.remote.open": "Open remote workspace on {host}",
"shortcuts.cheatsheetTitle": "Keyboard shortcuts",
"shortcuts.cheatsheetSummary": "Current bindings reflect your shortcut settings.",
"shortcuts.skipToComposer": "Skip to composer",
+91
View File
@@ -82,6 +82,7 @@ export const zhTW: Record<DictKey, string> = {
"rightDock.workbench": "右側工作台",
"rightDock.views": "右側工作台視圖",
"rightDock.overview": "概覽",
"rightDock.remote": "遠端",
// 話題欄
"topicBar.renameSession": "重新命名會話",
@@ -916,6 +917,93 @@ export const zhTW: Record<DictKey, string> = {
"settings.tab.providers": "模型服務",
"settings.tab.capabilities": "MCP 與技能",
"settings.tab.mcp": "MCP 與工具",
"settings.tab.remote": "遠端 SSH",
"remote.tabHint": "SSH 主機與遠端工作區",
"remote.hosts.title": "遠端 SSH 主機",
"remote.hosts.empty": "尚無遠端主機。",
"remote.hosts.add": "新增主機",
"remote.hosts.import": "從 ~/.ssh/config 匯入",
"remote.hosts.importEmpty": "找不到可匯入的別名(不支援 Match 區塊)。",
"remote.hosts.importSelected": "匯入所選",
"remote.host.label": "名稱",
"remote.host.host": "主機",
"remote.host.port": "連接埠",
"remote.host.user": "使用者",
"remote.host.identityFile": "金鑰檔案",
"remote.host.useSSHConfig": "使用 ~/.ssh/config 補齊未設定的連線參數",
"remote.host.proxyJump": "跳板",
"remote.host.defaultWorkspace": "預設工作區",
"remote.host.serveInstall": "serve 安裝方式",
"remote.host.save": "儲存",
"remote.host.cancel": "取消",
"remote.host.edit": "編輯",
"remote.host.remove": "刪除",
"remote.host.removeConfirm": "刪除此主機?",
"remote.connect": "連線",
"remote.connectAndOpen": "連線並開啟",
"remote.disconnect": "中斷",
"remote.disconnectHost": "中斷 {host}",
"remote.openWorkspace": "開啟工作區",
"remote.manageHosts": "管理主機",
"remote.explorer": "遠端瀏覽器",
"remote.switcher.title": "開啟遠端視窗",
"remote.switcher.local": "目前視窗 · 本機",
"remote.switcher.currentSession": "目前工作階段與工具在本機執行",
"remote.switcher.hosts": "SSH 主機",
"remote.switcher.manage": "管理 SSH 主機…",
"remote.statusBar.disconnected": "SSH · 已中斷",
"remote.statusBar.summary": "{host} · {status}",
"remote.status.failed": "連線失敗",
"remote.status.connected": "已連線",
"remote.status.connecting": "連線中…",
"remote.status.reconnecting": "重新連線中…",
"remote.status.degraded": "降級",
"remote.status.pending_hostkey": "確認主機金鑰",
"remote.status.stopped": "已中斷",
"remote.banner.reconnecting": "連線已中斷 —— 正在重新連線(第 {n} 次)…",
"remote.fingerprint.title": "未知的主機金鑰",
"remote.fingerprint.body": "主機 {host} 提供了 Reasonix 從未見過的金鑰。僅在你信任該連線時才接受。",
"remote.fingerprint.type": "金鑰類型",
"remote.fingerprint.sha256": "SHA256",
"remote.fingerprint.accept": "接受並繼續",
"remote.fingerprint.reject": "拒絕",
"remote.tab.files": "檔案",
"remote.tab.ports": "連接埠",
"remote.tab.server": "服務",
"remote.tree.empty": "空目錄",
"remote.tree.loadError": "載入失敗:{err}",
"remote.tree.newFolder": "新增資料夾",
"remote.tree.rename": "重新命名",
"remote.tree.delete": "刪除",
"remote.editor.edit": "編輯",
"remote.editor.save": "儲存",
"remote.editor.saving": "儲存中…",
"remote.editor.binaryBlocked": "二進位檔案 —— 不可編輯。",
"remote.editor.truncatedBlocked": "檔案過大,無法編輯(預覽已截斷)。",
"remote.editor.readOnlyDisconnected": "已中斷 —— 無法儲存。",
"remote.editor.conflictTitle": "遠端檔案已變更",
"remote.editor.conflictBody": "自你開啟以來,該檔案在遠端主機上已被修改。",
"remote.editor.reload": "重新載入",
"remote.editor.overwrite": "覆寫",
"remote.ports.title": "連接埠轉送",
"remote.ports.empty": "尚無轉送。",
"remote.ports.add": "新增轉送",
"remote.ports.localPort": "本機連接埠",
"remote.ports.remoteHost": "遠端主機",
"remote.ports.remotePort": "遠端連接埠",
"remote.ports.label": "標籤",
"remote.ports.remove": "刪除",
"remote.server.title": "遠端 serve",
"remote.server.workspace": "工作區",
"remote.server.start": "啟動並開啟",
"remote.server.stop": "停止",
"remote.server.openBrowser": "在瀏覽器中開啟",
"remote.server.logs": "日誌",
"remote.server.refreshLogs": "重新整理",
"remote.server.state.ready": "就緒",
"remote.server.state.starting": "啟動中…",
"remote.server.state.stopped": "已停止",
"remote.server.state.error": "錯誤",
"settings.tab.skills": "技能",
"settings.tab.subagents": "子智慧體",
"settings.tab.plugins": "插件",
@@ -2483,6 +2571,9 @@ export const zhTW: Record<DictKey, string> = {
"palette.cmd.appearance": "外觀",
"palette.cmd.memory": "記憶",
"palette.cmd.models": "模型",
"palette.group.remote": "遠端 SSH",
"palette.remote.connect": "連線到 {host}",
"palette.remote.open": "在 {host} 開啟遠端工作區",
"shortcuts.cheatsheetTitle": "鍵盤快捷鍵",
"shortcuts.cheatsheetSummary": "這裡顯示當前設定後的有效快捷鍵。",
"shortcuts.skipToComposer": "跳到輸入框",
+91
View File
@@ -146,6 +146,7 @@ export const zh: Record<DictKey, string> = {
"rightDock.workbench": "工作区面板",
"rightDock.views": "工作区视图",
"rightDock.overview": "概览",
"rightDock.remote": "远程",
// 话题栏
"topicBar.renameSession": "重命名会话",
@@ -1189,7 +1190,94 @@ export const zh: Record<DictKey, string> = {
"settings.tab.bots": "机器人",
"settings.tab.capabilities": "MCP 与技能",
"settings.tab.mcp": "MCP 与工具",
"settings.tab.remote": "远程 SSH",
"settings.tab.skills": "技能",
"remote.tabHint": "SSH 主机与远程工作区",
"remote.hosts.title": "远程 SSH 主机",
"remote.hosts.empty": "尚无远程主机。",
"remote.hosts.add": "添加主机",
"remote.hosts.import": "从 ~/.ssh/config 导入",
"remote.hosts.importEmpty": "未找到可导入的别名(不支持 Match 块)。",
"remote.hosts.importSelected": "导入所选",
"remote.host.label": "名称",
"remote.host.host": "主机",
"remote.host.port": "端口",
"remote.host.user": "用户",
"remote.host.identityFile": "密钥文件",
"remote.host.useSSHConfig": "使用 ~/.ssh/config 补全未设置的连接参数",
"remote.host.proxyJump": "跳板",
"remote.host.defaultWorkspace": "默认工作区",
"remote.host.serveInstall": "serve 安装方式",
"remote.host.save": "保存",
"remote.host.cancel": "取消",
"remote.host.edit": "编辑",
"remote.host.remove": "删除",
"remote.host.removeConfirm": "删除此主机?",
"remote.connect": "连接",
"remote.connectAndOpen": "连接并打开",
"remote.disconnect": "断开",
"remote.disconnectHost": "断开 {host}",
"remote.openWorkspace": "打开工作区",
"remote.manageHosts": "管理主机",
"remote.explorer": "远程浏览器",
"remote.switcher.title": "打开远程窗口",
"remote.switcher.local": "当前窗口 · 本地",
"remote.switcher.currentSession": "当前会话与工具在本机运行",
"remote.switcher.hosts": "SSH 主机",
"remote.switcher.manage": "管理 SSH 主机…",
"remote.statusBar.disconnected": "SSH · 已断开",
"remote.statusBar.summary": "{host} · {status}",
"remote.status.failed": "连接失败",
"remote.status.connected": "已连接",
"remote.status.connecting": "连接中…",
"remote.status.reconnecting": "重连中…",
"remote.status.degraded": "降级",
"remote.status.pending_hostkey": "确认主机密钥",
"remote.status.stopped": "已断开",
"remote.banner.reconnecting": "连接已断开 —— 正在重连(第 {n} 次)…",
"remote.fingerprint.title": "未知的主机密钥",
"remote.fingerprint.body": "主机 {host} 提供了 Reasonix 从未见过的密钥。仅在你信任该连接时才接受。",
"remote.fingerprint.type": "密钥类型",
"remote.fingerprint.sha256": "SHA256",
"remote.fingerprint.accept": "接受并继续",
"remote.fingerprint.reject": "拒绝",
"remote.tab.files": "文件",
"remote.tab.ports": "端口",
"remote.tab.server": "服务",
"remote.tree.empty": "空目录",
"remote.tree.loadError": "加载失败:{err}",
"remote.tree.newFolder": "新建文件夹",
"remote.tree.rename": "重命名",
"remote.tree.delete": "删除",
"remote.editor.edit": "编辑",
"remote.editor.save": "保存",
"remote.editor.saving": "保存中…",
"remote.editor.binaryBlocked": "二进制文件 —— 不可编辑。",
"remote.editor.truncatedBlocked": "文件过大,无法编辑(预览已截断)。",
"remote.editor.readOnlyDisconnected": "已断开 —— 无法保存。",
"remote.editor.conflictTitle": "远端文件已变更",
"remote.editor.conflictBody": "自你打开以来,该文件在远端主机上已被修改。",
"remote.editor.reload": "重新加载",
"remote.editor.overwrite": "覆盖",
"remote.ports.title": "端口转发",
"remote.ports.empty": "暂无转发。",
"remote.ports.add": "添加转发",
"remote.ports.localPort": "本地端口",
"remote.ports.remoteHost": "远程主机",
"remote.ports.remotePort": "远程端口",
"remote.ports.label": "标签",
"remote.ports.remove": "删除",
"remote.server.title": "远端 serve",
"remote.server.workspace": "工作区",
"remote.server.start": "启动并打开",
"remote.server.stop": "停止",
"remote.server.openBrowser": "在浏览器中打开",
"remote.server.logs": "日志",
"remote.server.refreshLogs": "刷新",
"remote.server.state.ready": "就绪",
"remote.server.state.starting": "启动中…",
"remote.server.state.stopped": "已停止",
"remote.server.state.error": "错误",
"settings.tab.subagents": "子智能体",
"settings.tab.plugins": "插件",
"settings.tab.memory": "记忆",
@@ -1925,6 +2013,9 @@ export const zh: Record<DictKey, string> = {
"palette.cmd.appearance": "外观",
"palette.cmd.memory": "记忆",
"palette.cmd.models": "模型",
"palette.group.remote": "远程 SSH",
"palette.remote.connect": "连接到 {host}",
"palette.remote.open": "在 {host} 打开远程工作区",
"shortcuts.cheatsheetTitle": "键盘快捷键",
"shortcuts.cheatsheetSummary": "这里显示当前设置后的有效快捷键。",
"shortcuts.skipToComposer": "跳到输入框",
+1 -1
View File
@@ -138,7 +138,7 @@ export function saveRightDockPreviewWidth(width: number): void {
// dock survives restart. maximized/preview stay session-local — they are view
// layout, not a durable preference. (Resize drag flags, button-press animation
// flags, measured footer height, and viewport width stay as useState in App.tsx.)
export type RightDockMode = "context" | "files" | "changed";
export type RightDockMode = "context" | "files" | "changed" | "remote";
function loadWorkspacePanelOpen(): boolean {
if (typeof window === "undefined") return WORKSPACE_PANEL_DEFAULT_OPEN;
+126
View File
@@ -0,0 +1,126 @@
// remote mirrors the kernel-owned Remote-SSH surfaces: configured hosts,
// per-host connection status, forward snapshots, server-bootstrap state, the
// pending host-key fingerprint, and the dock's host/tab selection. None of it
// is persisted here — the kernel hydrates hosts/statuses on mount and emits
// remote:* updates thereafter.
import { create } from "zustand";
import type {
RemoteConnectionStatus,
RemoteFingerprintView,
RemoteForwardView,
RemoteHostView,
RemoteServerView,
} from "../lib/types";
export type RemoteExplorerTab = "files" | "ports" | "server";
export type RemoteState = {
hosts: RemoteHostView[];
statuses: Record<string, RemoteConnectionStatus>;
forwards: Record<string, RemoteForwardView[]>;
servers: Record<string, RemoteServerView>;
pendingFingerprint: RemoteFingerprintView | null;
explorerOpen: boolean;
explorerHostId: string | null;
explorerTab: RemoteExplorerTab;
setHosts: (hosts: RemoteHostView[]) => void;
applyStatus: (s: RemoteConnectionStatus) => void;
setStatuses: (list: RemoteConnectionStatus[]) => void;
hydrateStatuses: (list: RemoteConnectionStatus[]) => void;
setForwards: (hostId: string, forwards: RemoteForwardView[]) => void;
setServer: (s: RemoteServerView) => void;
clearPendingFingerprint: (expected?: RemoteFingerprintView) => void;
openExplorer: (hostId: string) => void;
closeExplorer: () => void;
setExplorerTab: (tab: RemoteExplorerTab) => void;
};
export const useRemoteStore = create<RemoteState>((set) => ({
hosts: [],
statuses: {},
forwards: {},
servers: {},
pendingFingerprint: null,
explorerOpen: false,
explorerHostId: null,
explorerTab: "files",
setHosts: (hosts) => set({ hosts }),
applyStatus: (s) =>
set((state) => {
const next: Partial<RemoteState> = {
statuses: { ...state.statuses, [s.hostId]: s },
};
if (s.state === "pending_hostkey" && s.fingerprint) {
next.pendingFingerprint = s.fingerprint;
} else if (state.pendingFingerprint?.hostId === s.hostId) {
// The pending prompt for this host resolved.
next.pendingFingerprint = null;
}
return next;
}),
setStatuses: (list) =>
set(() => {
const statuses: Record<string, RemoteConnectionStatus> = {};
for (const s of list) statuses[s.hostId] = s;
return { statuses };
}),
hydrateStatuses: (list) =>
set((state) => {
const statuses = { ...state.statuses };
for (const s of list) {
if (!statuses[s.hostId]) statuses[s.hostId] = s;
}
return { statuses };
}),
setForwards: (hostId, forwards) =>
set((state) => ({ forwards: { ...state.forwards, [hostId]: forwards } })),
setServer: (s) =>
set((state) => ({ servers: { ...state.servers, [s.hostId]: s } })),
clearPendingFingerprint: (expected) =>
set((state) => {
if (expected && (
state.pendingFingerprint?.hostId !== expected.hostId ||
state.pendingFingerprint?.sha256 !== expected.sha256
)) return state;
return { pendingFingerprint: null };
}),
openExplorer: (hostId) => set({ explorerOpen: true, explorerHostId: hostId }),
closeExplorer: () => set({ explorerOpen: false }),
setExplorerTab: (tab) => set({ explorerTab: tab }),
}));
export function waitForRemoteConnection(hostId: string, timeoutMs = 60_000): Promise<void> {
const connected = (state?: RemoteConnectionStatus["state"]) => state === "connected" || state === "degraded";
const current = useRemoteStore.getState().statuses[hostId];
if (connected(current?.state)) return Promise.resolve();
if (current?.state === "stopped" && current.error) return Promise.reject(new Error(current.error));
return new Promise((resolve, reject) => {
let settled = false;
const finish = (err?: Error) => {
if (settled) return;
settled = true;
clearTimeout(timer);
unsubscribe();
if (err) reject(err);
else resolve();
};
const unsubscribe = useRemoteStore.subscribe((state) => {
const status = state.statuses[hostId];
if (connected(status?.state)) finish();
else if (status?.state === "stopped" && status.error) finish(new Error(status.error));
});
const timer = setTimeout(() => finish(new Error(`Timed out connecting to ${hostId}`)), timeoutMs);
});
}
+534
View File
@@ -34144,3 +34144,537 @@ body > .mermaid-diagram--fullscreen {
border: 1px solid #d1d5db !important;
}
}
/* ── Remote SSH module ──────────────────────────────────────────────────── */
.remote-hosts__toolbar {
display: flex;
align-items: center;
justify-content: space-between;
gap: 8px;
margin-bottom: 12px;
}
.remote-hosts__actions {
display: flex;
gap: 8px;
}
.remote-hosts__list {
list-style: none;
margin: 0;
padding: 0;
display: flex;
flex-direction: column;
gap: 6px;
}
.remote-hosts__empty,
.remote-panel__hint {
color: var(--fg-faint);
font-size: var(--text-sm);
}
.remote-host-row {
display: flex;
align-items: center;
justify-content: space-between;
gap: 8px;
padding: 8px 10px;
background: var(--bg-elev);
border: 1px solid var(--border-soft);
border-radius: var(--radius);
}
.remote-host-row__main {
display: flex;
align-items: center;
gap: 8px;
min-width: 0;
}
.remote-host-row__name {
font-weight: 600;
}
.remote-host-row__target {
color: var(--fg-faint);
font-size: var(--text-sm);
}
.remote-host-row__actions {
display: flex;
gap: 6px;
flex-shrink: 0;
}
.remote-chip {
display: inline-flex;
align-items: center;
gap: 4px;
padding: 1px 8px;
border-radius: 999px;
font-size: var(--text-xs);
border: 1px solid var(--border);
color: var(--fg-dim);
background: var(--bg-soft);
flex-shrink: 0;
white-space: nowrap;
}
.remote-chip--connected {
color: var(--ok);
border-color: color-mix(in srgb, var(--ok) 50%, var(--border));
}
.remote-chip--stopped {
color: var(--fg-faint);
}
.remote-chip--connecting {
color: var(--fg-dim);
}
.remote-chip--reconnecting,
.remote-chip--pending_hostkey,
.remote-chip--degraded {
color: var(--warn);
border-color: color-mix(in srgb, var(--warn) 50%, var(--border));
}
.remote-chip--error {
color: var(--danger);
border-color: color-mix(in srgb, var(--danger) 50%, var(--border));
}
.remote-host-form {
display: flex;
flex-direction: column;
gap: 10px;
max-width: 480px;
}
.remote-host-form label,
.remote-server__ws {
display: flex;
flex-direction: column;
gap: 4px;
font-size: var(--text-sm);
color: var(--fg-dim);
}
.remote-host-form input,
.remote-host-form select,
.remote-server__ws input,
.remote-ports__form input {
background: var(--bg-soft);
border: 1px solid var(--border);
border-radius: var(--radius);
color: var(--fg);
padding: 6px 8px;
}
.remote-host-form__actions,
.remote-server__actions,
.remote-ports__form {
display: flex;
gap: 8px;
flex-wrap: wrap;
align-items: center;
}
.remote-host-form__error,
.remote-panel__error {
color: var(--danger);
font-size: var(--text-sm);
}
.remote-panel {
display: flex;
flex-direction: column;
width: 100%;
height: 100%;
min-height: 0;
background: var(--bg);
}
.remote-panel__header {
display: flex;
align-items: center;
flex-wrap: wrap;
gap: 8px;
min-height: 54px;
padding: 9px 12px;
border-bottom: 1px solid var(--border-soft);
}
.remote-panel__host-copy {
display: flex;
min-width: 0;
flex-direction: column;
flex: 1;
gap: 1px;
}
.remote-panel__host {
font-weight: 600;
}
.remote-panel__target {
max-width: 220px;
overflow: hidden;
color: var(--fg-faint);
font-size: var(--text-xs);
text-overflow: ellipsis;
white-space: nowrap;
}
.remote-panel__header-actions {
width: 100%;
margin-left: 0;
display: flex;
justify-content: flex-end;
gap: 6px;
}
.remote-panel__error-banner {
display: flex;
flex-direction: column;
gap: 2px;
padding: 8px 12px;
border-bottom: 1px solid color-mix(in srgb, var(--danger) 30%, var(--border-soft));
background: color-mix(in srgb, var(--danger) 10%, var(--bg));
color: var(--danger);
font-size: var(--text-xs);
}
.remote-panel__banner {
padding: 6px 12px;
background: color-mix(in srgb, var(--warn) 18%, var(--bg-elev));
color: var(--warn);
font-size: var(--text-sm);
}
.remote-panel__tabs {
display: flex;
gap: 2px;
padding: 6px 8px;
border-bottom: 1px solid var(--border-soft);
}
.remote-panel__tab {
background: transparent;
border: none;
color: var(--fg-faint);
padding: 4px 10px;
border-radius: var(--radius);
cursor: pointer;
}
.remote-panel__tab.is-active {
background: var(--bg-elev-2);
color: var(--fg);
}
.remote-panel__body {
flex: 1;
overflow: auto;
padding: 10px 12px;
}
.workbench-dock__body > .remote-panel {
min-height: 0;
}
.remote-files {
display: flex;
flex-direction: column;
gap: 10px;
height: 100%;
}
.remote-files__tree ul {
list-style: none;
margin: 0;
padding: 0;
}
.remote-tree__row {
display: block;
width: 100%;
text-align: left;
background: transparent;
border: none;
color: var(--fg-dim);
padding: 2px 4px;
border-radius: 4px;
cursor: pointer;
font-size: var(--text-sm);
}
.remote-tree__row.is-selected {
background: var(--bg-elev-2);
color: var(--fg);
}
.remote-tree__empty {
color: var(--fg-faint);
font-size: var(--text-xs);
padding: 2px 4px;
}
.remote-file-view__toolbar {
display: flex;
align-items: center;
gap: 8px;
margin-bottom: 6px;
}
.remote-file-view__path {
font-family: monospace;
font-size: var(--text-xs);
color: var(--fg-faint);
overflow: hidden;
text-overflow: ellipsis;
}
.remote-file-view__editor {
width: 100%;
min-height: 320px;
font-family: monospace;
font-size: var(--font-code);
background: var(--bg-soft);
color: var(--fg);
border: 1px solid var(--border);
border-radius: var(--radius);
padding: 8px;
}
.remote-file-view__conflict {
margin-top: 8px;
padding: 10px;
border: 1px solid color-mix(in srgb, var(--warn) 50%, var(--border));
border-radius: var(--radius);
display: flex;
flex-direction: column;
gap: 6px;
}
.remote-ports__list {
list-style: none;
margin: 0 0 10px;
padding: 0;
display: flex;
flex-direction: column;
gap: 4px;
}
.remote-ports__row {
display: flex;
align-items: center;
gap: 8px;
}
.remote-dot {
width: 8px;
height: 8px;
border-radius: 50%;
background: var(--fg-faint);
}
.remote-dot--active {
background: var(--ok);
}
.remote-dot--error {
background: var(--danger);
}
.remote-server__status {
margin: 8px 0;
color: var(--fg-dim);
font-size: var(--text-sm);
}
.remote-server__logs {
margin-top: 10px;
max-height: 220px;
overflow: auto;
background: var(--bg-soft);
border: 1px solid var(--border-soft);
border-radius: var(--radius);
padding: 8px;
font-size: var(--text-xs);
}
.remote-hostkey-overlay {
position: fixed;
inset: 0;
display: flex;
align-items: center;
justify-content: center;
background: rgba(0, 0, 0, 0.5);
z-index: var(--z-modal);
}
.remote-hostkey-dialog {
width: min(460px, 92vw);
background: var(--bg-elev);
border: 1px solid var(--border);
border-radius: var(--radius);
padding: 18px;
}
.remote-hostkey-dialog__title {
margin: 0 0 8px;
font-size: var(--text-lg);
}
.remote-hostkey-dialog__facts {
display: grid;
grid-template-columns: auto 1fr;
gap: 4px 10px;
margin: 10px 0;
font-size: var(--text-sm);
}
.remote-hostkey-dialog__fp {
font-family: monospace;
word-break: break-all;
}
.remote-hostkey-dialog__actions {
display: flex;
justify-content: flex-end;
gap: 8px;
margin-top: 12px;
}
.statusbar__remote {
display: inline-flex;
align-items: center;
max-width: 190px;
gap: 4px;
padding: 1px 7px;
cursor: pointer;
border: 1px solid var(--border);
font: inherit;
white-space: nowrap;
}
.statusbar__remote > span {
min-width: 0;
overflow: hidden;
text-overflow: ellipsis;
}
.statusbar__remote-wrap {
display: inline-flex;
min-width: 0;
}
.remote-switcher {
z-index: var(--z-local-popover);
width: min(390px, calc(100vw - 16px));
overflow: hidden;
border: 1px solid var(--border);
border-radius: 12px;
background: var(--bg-elev);
box-shadow: 0 16px 44px rgba(0, 0, 0, 0.48);
color: var(--fg);
font-family: var(--font-ui);
}
.remote-switcher__header {
padding: 11px 12px 8px;
color: var(--fg);
font-size: var(--text-sm);
font-weight: 650;
}
.remote-switcher__local,
.remote-switcher__host {
display: flex;
align-items: center;
gap: 9px;
margin: 0 6px;
border: 1px solid transparent;
border-radius: 9px;
}
.remote-switcher__local {
min-height: 50px;
padding: 7px 9px;
border-color: var(--border-soft);
background: var(--bg-soft);
}
.remote-switcher__icon {
display: inline-flex;
align-items: center;
justify-content: center;
width: 24px;
height: 24px;
border-radius: 7px;
background: var(--bg-elev-2);
color: var(--fg-dim);
}
.remote-switcher__copy {
display: flex;
min-width: 0;
flex: 1;
flex-direction: column;
gap: 2px;
text-align: left;
}
.remote-switcher__copy strong,
.remote-switcher__copy small {
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.remote-switcher__copy strong {
color: var(--fg);
font-size: var(--text-sm);
font-weight: 620;
}
.remote-switcher__copy small {
color: var(--fg-faint);
font-size: var(--text-xs);
}
.remote-switcher__check {
color: var(--ok);
}
.remote-switcher__section-label {
padding: 12px 12px 5px;
color: var(--fg-faint);
font-size: var(--text-xs);
font-weight: 600;
text-transform: uppercase;
letter-spacing: 0.04em;
}
.remote-switcher__hosts {
display: flex;
max-height: min(320px, 45vh);
flex-direction: column;
gap: 3px;
overflow-y: auto;
padding-bottom: 5px;
}
.remote-switcher__host {
min-height: 54px;
padding: 4px 6px 4px 9px;
}
.remote-switcher__actions {
display: inline-flex;
flex: 0 0 auto;
align-items: center;
gap: 4px;
}
.remote-switcher__disconnect {
display: inline-flex;
width: 28px;
height: 28px;
align-items: center;
justify-content: center;
border: 1px solid var(--border);
border-radius: 7px;
background: transparent;
color: var(--fg-faint);
cursor: pointer;
}
.remote-switcher__disconnect:hover,
.remote-switcher__disconnect:focus-visible {
outline: none;
border-color: var(--border-strong);
background: var(--bg-soft);
color: var(--fg);
}
.remote-switcher__host:hover,
.remote-switcher__host:focus-within {
border-color: var(--border-soft);
background: var(--bg-elev-2);
}
.remote-switcher__host-main {
display: flex;
min-width: 0;
flex: 1;
align-items: center;
gap: 9px;
padding: 4px 0;
border: 0;
background: transparent;
color: inherit;
font: inherit;
cursor: pointer;
}
.remote-switcher__state {
width: 8px;
height: 8px;
flex: 0 0 8px;
border-radius: 50%;
background: var(--fg-faint);
}
.remote-switcher__state--connected { background: var(--ok); }
.remote-switcher__state--connecting,
.remote-switcher__state--reconnecting,
.remote-switcher__state--pending_hostkey,
.remote-switcher__state--degraded { background: var(--warn); }
.remote-switcher__state--error { background: var(--danger); }
.remote-switcher__manage {
display: flex;
width: 100%;
align-items: center;
gap: 7px;
padding: 10px 12px;
border: 0;
border-top: 1px solid var(--border-soft);
background: transparent;
color: var(--accent-strong);
font: inherit;
font-size: var(--text-sm);
text-align: left;
cursor: pointer;
}
.remote-switcher__manage:hover,
.remote-switcher__manage:focus-visible {
outline: none;
background: var(--bg-elev-2);
}
+3
View File
@@ -38,6 +38,8 @@ require (
github.com/gorilla/websocket v1.5.3 // indirect
github.com/jchv/go-winloader v0.0.0-20250406163304-c1995be93bd1 // indirect
github.com/joho/godotenv v1.5.1 // indirect
github.com/kevinburke/ssh_config v1.6.0 // indirect
github.com/kr/fs v0.1.0 // indirect
github.com/labstack/echo/v4 v4.15.2 // indirect
github.com/labstack/gommon v0.5.0 // indirect
github.com/larksuite/oapi-sdk-go/v3 v3.9.5 // indirect
@@ -52,6 +54,7 @@ require (
github.com/nfnt/resize v0.0.0-20180221191011-83c6a9932646 // indirect
github.com/pkg/browser v0.0.0-20240102092130-5ac0b6a4141c // indirect
github.com/pkg/errors v0.9.1 // indirect
github.com/pkg/sftp v1.13.10 // indirect
github.com/rivo/uniseg v0.4.7 // indirect
github.com/sabhiram/go-gitignore v0.0.0-20210923224102-525f6e181f06 // indirect
github.com/samber/lo v1.53.0 // indirect
+8
View File
@@ -41,8 +41,12 @@ github.com/jchv/go-winloader v0.0.0-20250406163304-c1995be93bd1 h1:njuLRcjAuMKr7
github.com/jchv/go-winloader v0.0.0-20250406163304-c1995be93bd1/go.mod h1:alcuEEnZsY1WQsagKhZDsoPCRoOijYqhZvPwLG0kzVs=
github.com/joho/godotenv v1.5.1 h1:7eLL/+HRGLY0ldzfGMeQkb7vMd0as4CfYvUVzLqw0N0=
github.com/joho/godotenv v1.5.1/go.mod h1:f4LDr5Voq0i2e/R5DDNOoa2zzDfwtkZa6DnEwAbqwq4=
github.com/kevinburke/ssh_config v1.6.0 h1:J1FBfmuVosPHf5GRdltRLhPJtJpTlMdKTBjRgTaQBFY=
github.com/kevinburke/ssh_config v1.6.0/go.mod h1:q2RIzfka+BXARoNexmF9gkxEX7DmvbW9P4hIVx2Kg4M=
github.com/kisielk/errcheck v1.5.0/go.mod h1:pFxgyoBC7bSaBwPgfKdkLd5X25qrDl4LWUI2bnpBCr8=
github.com/kisielk/gotool v1.0.0/go.mod h1:XhKaO+MFFWcvkIS/tQcRk01m1F5IRFswLeQ+oQHNcck=
github.com/kr/fs v0.1.0 h1:Jskdu9ieNAYnjxsi0LbQp1ulIKZV1LAFgK1tWhpZgl8=
github.com/kr/fs v0.1.0/go.mod h1:FFnZGqtBN9Gxj7eW1uZ42v5BccTP0vu6NEaFoC2HwRg=
github.com/kr/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE=
github.com/kr/pretty v0.3.1/go.mod h1:hoEshYVHaxMs3cyo3Yncou5ZscifuDolrwPKZanG3xk=
github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY=
@@ -82,6 +86,8 @@ github.com/pkg/browser v0.0.0-20240102092130-5ac0b6a4141c h1:+mdjkGKdHQG3305AYmd
github.com/pkg/browser v0.0.0-20240102092130-5ac0b6a4141c/go.mod h1:7rwL4CYBLnjLxUqIJNnCWiEdr3bn6IUYi15bNlnbCCU=
github.com/pkg/errors v0.9.1 h1:FEBLx1zS214owpjy7qsBeixbURkuhQAwrK5UwLGTwt4=
github.com/pkg/errors v0.9.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0=
github.com/pkg/sftp v1.13.10 h1:+5FbKNTe5Z9aspU88DPIKJ9z2KZoaGCu6Sr6kKR/5mU=
github.com/pkg/sftp v1.13.10/go.mod h1:bJ1a7uDhrX/4OII+agvy28lzRvQrmIQuaHrcI1HbeGA=
github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
github.com/rivo/uniseg v0.2.0/go.mod h1:J6wj4VEh+S6ZtnVlnTBMWIodfgj8LQOQFoIToxlJtxc=
@@ -185,6 +191,8 @@ golang.org/x/sys v0.46.0 h1:noSf2Fq6F8DBgS+LysIkx7rIExoNHJsxOAtPp4rthXw=
golang.org/x/sys v0.46.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw=
golang.org/x/term v0.0.0-20201117132131-f5c789dd3221/go.mod h1:Nr5EML6q2oocZ2LXRh80K7BxOlk5/8JxuGnuhpl+muw=
golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo=
golang.org/x/term v0.44.0 h1:0rLvDRCtNj0gZkyIXhCyOb2OAzEhLVqc4B+hrsBhrmc=
golang.org/x/term v0.44.0/go.mod h1:7ze4MdzUzLXpSAoFP1H0bOI9aXDqveSvatT5vKcFh2Y=
golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ=
golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ=
golang.org/x/text v0.3.6/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ=
+7
View File
@@ -14,7 +14,10 @@ func TestDesktopPackagesUseGuardAsDefaultLauncher(t *testing.T) {
}
build := string(buildData)
for _, want := range []string{
`CLINAME="reasonix"`,
`./cmd/reasonix`,
`cp "$guard_out" "$app/Contents/MacOS/$GUARDNAME"`,
`cp "$cli_out" "$app/Contents/MacOS/$CLINAME"`,
`Set :CFBundleExecutable $GUARDNAME`,
`Print :CFBundleIconFile`,
`Contents/Resources/$bundle_icon`,
@@ -25,6 +28,8 @@ func TestDesktopPackagesUseGuardAsDefaultLauncher(t *testing.T) {
`stamp_windows_executable "build/windows/installer/$UPDATE_HELPER" "Reasonix Update Helper"`,
`cp "$launcher_out" "$staging/${APPNAME}.exe"`,
`cp "$guard_out" "$staging/$GUARDNAME.exe"`,
`cp "build/windows/installer/$CLINAME.exe" "$staging/$CLINAME.exe"`,
`"$BINNAME" "$GUARDNAME" "$CLINAME"`,
} {
if !strings.Contains(build, want) {
t.Errorf("desktop-build.sh missing guard launcher contract %q", want)
@@ -77,6 +82,7 @@ func TestDesktopPackagesUseGuardAsDefaultLauncher(t *testing.T) {
}
}
for _, want := range []string{
"/usr/bin/reasonix",
"/usr/share/applications/reasonix.desktop",
"/usr/share/pixmaps/reasonix-desktop.png",
"/usr/share/icons/hicolor/scalable/apps/reasonix-desktop.svg",
@@ -92,6 +98,7 @@ func TestDesktopPackagesUseGuardAsDefaultLauncher(t *testing.T) {
}
windows := string(windowsData)
for _, want := range []string{
`File "/oname=${REASONIX_CLI}" "${REASONIX_CLI}"`,
`CreateShortcut "$SMPROGRAMS\${INFO_PRODUCTNAME}.lnk" "$INSTDIR\${REASONIX_LAUNCHER}" "launch --detach" "$INSTDIR\${PRODUCT_EXECUTABLE}" 0`,
`CreateShortCut "$DESKTOP\${INFO_PRODUCTNAME}.lnk" "$INSTDIR\${REASONIX_LAUNCHER}" "launch --detach" "$INSTDIR\${PRODUCT_EXECUTABLE}" 0`,
} {
+54 -18
View File
@@ -96,29 +96,60 @@ func main() {
if config.SafeModeRequested() {
launch.SafeMode = true
}
tracker := repair.NewStartupTracker("")
if tracker.SafeModeRecommended() {
launch.SafeMode = true
var remoteLaunch *remoteWindowLaunch
if launch.RemoteWindowTicket != "" {
var err error
remoteLaunch, err = consumeRemoteWindowLaunch(launch.RemoteWindowTicket)
if err != nil {
println("Error:", err.Error())
return
}
}
if launch.SafeMode {
_ = os.Setenv("REASONIX_SAFE_MODE", "1")
var tracker *repair.StartupTracker
trackerOwned := false
if remoteLaunch == nil {
tracker = repair.NewStartupTracker("")
if tracker.SafeModeRecommended() {
launch.SafeMode = true
}
if launch.SafeMode {
_ = os.Setenv("REASONIX_SAFE_MODE", "1")
}
// Begin runs before the Wails single-instance gate, but it refuses to
// overwrite the recorded state while its owner PID is alive, so a duplicate
// launch — which Wails terminates via os.Exit without OnShutdown — never
// counts as a crash toward the Safe Mode threshold.
startupState, _ := tracker.Begin(version, launch.SafeMode)
trackerOwned = startupState.PID == os.Getpid()
}
// Keep WebKit acceleration enabled during normal Linux launches. If the
// startup tracker selects Safe Mode after a crash loop (or the user requests
// it explicitly), NVIDIA systems use the broader renderer fallback before
// Wails creates the WebKit process. Other platforms provide a no-op.
configureWebKitRendererRecovery(launch.SafeMode)
// Begin runs before the Wails single-instance gate, but it refuses to
// overwrite the recorded state while its owner PID is alive, so a duplicate
// launch — which Wails terminates via os.Exit without OnShutdown — never
// counts as a crash toward the Safe Mode threshold.
startupState, _ := tracker.Begin(version, launch.SafeMode)
trackerOwned := startupState.PID == os.Getpid()
app := NewApp()
app.remoteWindow = remoteLaunch
if trackerOwned {
app.startupTracker = tracker
}
title := "Reasonix"
singleInstance := singleInstanceLock(app)
appMenu := app.createAppMenu()
dragAndDrop := &options.DragAndDrop{EnableFileDrop: true}
bindings := []any{app}
if remoteLaunch != nil {
if remoteLaunch.Title != "" {
title = remoteLaunch.Title
}
// Wails v2 has one native window per process. A remote shell must bypass
// the primary single-instance lock and must not expose local-only menus.
singleInstance = nil
appMenu = nil
dragAndDrop = &options.DragAndDrop{DisableWebViewDrop: true}
bindings = nil
}
// Restore saved window size, or fall back to the default.
width, height := 1240, 720
@@ -142,7 +173,7 @@ func main() {
scheduleWebKitSignalHandlerRepair()
err := wails.Run(&options.App{
Title: "Reasonix",
Title: title,
Width: width,
Height: height,
Frameless: goruntime.GOOS == "windows",
@@ -154,6 +185,7 @@ func main() {
AssetServer: &assetserver.Options{
Assets: assets,
Middleware: assetserver.ChainMiddleware(
app.remoteWindowAssetMiddleware(),
app.jsProfilingMiddleware(),
app.workspaceMediaMiddleware(),
app.themeAssetMiddleware(),
@@ -163,20 +195,20 @@ func main() {
OnDomReady: app.domReady,
OnBeforeClose: app.beforeClose,
OnShutdown: app.shutdown,
Bind: []any{app},
SingleInstanceLock: singleInstanceLock(app),
Bind: bindings,
SingleInstanceLock: singleInstance,
// Start hidden — domReady positions and shows the window after restoring
// geometry, so the user never sees the default size/position flash.
StartHidden: true,
// Native application menu (File > Settings, Edit, Window).
Menu: app.createAppMenu(),
Menu: appMenu,
// Native OS file drops: the webview withholds dropped files' paths from the
// HTML drop event, so the frontend (composer) reads them via runtime.OnFileDrop
// against the --wails-drop-target element instead.
DragAndDrop: &options.DragAndDrop{EnableFileDrop: true},
DragAndDrop: dragAndDrop,
// --- per-platform adaptation (see desktop/README.md for the rationale) ---
Mac: &mac.Options{
@@ -214,14 +246,18 @@ func main() {
}
type desktopLaunchOptions struct {
SafeMode bool
SafeMode bool
RemoteWindowTicket string
}
func parseDesktopLaunchArgs(args []string) desktopLaunchOptions {
var out desktopLaunchOptions
for _, arg := range args {
if arg == "--safe-mode" {
switch {
case arg == "--safe-mode":
out.SafeMode = true
case strings.HasPrefix(arg, remoteWindowTicketArgPrefix):
out.RemoteWindowTicket = strings.TrimPrefix(arg, remoteWindowTicketArgPrefix)
}
}
return out
+10
View File
@@ -18,6 +18,16 @@ func TestParseDesktopLaunchArgsSafeMode(t *testing.T) {
}
}
func TestParseDesktopLaunchArgsRemoteWindowTicket(t *testing.T) {
got := parseDesktopLaunchArgs([]string{"--other", remoteWindowTicketArgPrefix + ".remote-window-123"})
if got.RemoteWindowTicket != ".remote-window-123" {
t.Fatalf("RemoteWindowTicket = %q", got.RemoteWindowTicket)
}
if got.SafeMode {
t.Fatal("remote window ticket unexpectedly enabled safe mode")
}
}
// TestMain isolates user config/state/cache dirs for the whole package. Without
// this, tests that persist desktop state, sessions, cache, or CLI-style config
// can leak into the developer's real Reasonix directories.
File diff suppressed because it is too large. Load diff
+238
View File
@@ -0,0 +1,238 @@
package main
import (
"context"
"os"
"path/filepath"
"testing"
"time"
"reasonix/internal/config"
)
// fakeRemoteKernel implements remoteKernel for binding-layer tests.
type fakeRemoteKernel struct {
hosts []RemoteHostView
statuses []RemoteConnectionStatusView
writeResult RemoteWriteResult
ensureView RemoteServerView
ensureToken string
ensureErr error
resolveCalls []bool
closed bool
}
func (f *fakeRemoteKernel) Hosts() ([]RemoteHostView, error) { return f.hosts, nil }
func (f *fakeRemoteKernel) AddHost(in RemoteHostInput) (RemoteHostView, error) {
v := RemoteHostView{ID: in.Label, Label: in.Label, Host: in.Host}
f.hosts = append(f.hosts, v)
return v, nil
}
func (f *fakeRemoteKernel) UpdateHost(id string, in RemoteHostInput) (RemoteHostView, error) {
return RemoteHostView{ID: id, Host: in.Host}, nil
}
func (f *fakeRemoteKernel) RemoveHost(id string) error { return nil }
func (f *fakeRemoteKernel) ScanSSHConfig() ([]RemoteHostInput, error) { return nil, nil }
func (f *fakeRemoteKernel) Connect(hostID string) error { return nil }
func (f *fakeRemoteKernel) Disconnect(hostID string) error { return nil }
func (f *fakeRemoteKernel) Statuses() []RemoteConnectionStatusView { return f.statuses }
func (f *fakeRemoteKernel) ResolveHostKey(hostID string, accept bool) error {
f.resolveCalls = append(f.resolveCalls, accept)
return nil
}
func (f *fakeRemoteKernel) ListDir(context.Context, string, string) ([]RemoteDirEntry, error) {
return []RemoteDirEntry{{Name: "file.txt"}}, nil
}
func (f *fakeRemoteKernel) ReadFile(context.Context, string, string) (RemoteFilePreview, error) {
return RemoteFilePreview{Body: "hi"}, nil
}
func (f *fakeRemoteKernel) WriteFile(context.Context, string, string, string, int64) (RemoteWriteResult, error) {
return f.writeResult, nil
}
func (f *fakeRemoteKernel) Mkdir(context.Context, string, string) error { return nil }
func (f *fakeRemoteKernel) Rename(context.Context, string, string, string) error { return nil }
func (f *fakeRemoteKernel) Delete(context.Context, string, string, bool) error { return nil }
func (f *fakeRemoteKernel) Forwards(string) []RemoteForwardView { return nil }
func (f *fakeRemoteKernel) AddForward(string, RemoteForwardInput) (RemoteForwardView, error) {
return RemoteForwardView{}, nil
}
func (f *fakeRemoteKernel) RemoveForward(string, string) error { return nil }
func (f *fakeRemoteKernel) EnsureServer(context.Context, string, string) (RemoteServerView, string, error) {
return f.ensureView, f.ensureToken, f.ensureErr
}
func (f *fakeRemoteKernel) StopServer(string) error { return nil }
func (f *fakeRemoteKernel) ServerStatus(string) RemoteServerView { return f.ensureView }
func (f *fakeRemoteKernel) ServerLogs(context.Context, string, int) (string, error) {
return "log line", nil
}
func (f *fakeRemoteKernel) Close() error { f.closed = true; return nil }
func appWithFakeKernel(fake *fakeRemoteKernel) *App {
a := &App{ctx: context.Background()}
a.remoteRuntime = fake
return a
}
func TestRemoteBindingsDelegateToKernel(t *testing.T) {
fake := &fakeRemoteKernel{writeResult: RemoteWriteResult{OK: true, NewMtimeUnix: 42}}
a := appWithFakeKernel(fake)
if _, err := a.AddRemoteHost(RemoteHostInput{Label: "box", Host: "10.0.0.1"}); err != nil {
t.Fatal(err)
}
hosts, _ := a.RemoteHosts()
if len(hosts) != 1 || hosts[0].ID != "box" {
t.Fatalf("hosts = %+v", hosts)
}
entries, err := a.ListRemoteDir("box", "/")
if err != nil || len(entries) != 1 {
t.Fatalf("ListRemoteDir = %+v, %v", entries, err)
}
res, err := a.WriteRemoteFile("box", "/f", "data", 0)
if err != nil || !res.OK || res.NewMtimeUnix != 42 {
t.Fatalf("WriteRemoteFile = %+v, %v", res, err)
}
}
func TestConfirmRemoteHostKeyDelegates(t *testing.T) {
fake := &fakeRemoteKernel{}
a := appWithFakeKernel(fake)
if err := a.ConfirmRemoteHostKey("box", true); err != nil {
t.Fatal(err)
}
if len(fake.resolveCalls) != 1 || fake.resolveCalls[0] != true {
t.Fatalf("resolve calls = %+v", fake.resolveCalls)
}
}
// TestRemoteStatusBridgesToAsyncEmitter verifies a kernel status callback lands
// on the async emitter as a remote:status event.
func TestRemoteStatusBridgesToAsyncEmitter(t *testing.T) {
a := &App{ctx: context.Background()}
events := make(chan runtimeEventEnvelope, 4)
a.runtimeEvents.emit = func(ctx context.Context, name string, payload ...interface{}) {
events <- runtimeEventEnvelope{ctx: ctx, name: name, payload: payload}
}
a.onStatus(RemoteConnectionStatusView{HostID: "box", State: "connected"})
// The async emitter delivers on a background goroutine, so block briefly.
select {
case ev := <-events:
if ev.name != "remote:status" {
t.Fatalf("event name = %q, want remote:status", ev.name)
}
s, ok := ev.payload[0].(RemoteConnectionStatusView)
if !ok || s.HostID != "box" || s.State != "connected" {
t.Fatalf("payload = %+v", ev.payload[0])
}
case <-time.After(2 * time.Second):
t.Fatal("no remote:status event emitted")
}
}
func TestStopRemoteRuntimeClosesKernel(t *testing.T) {
fake := &fakeRemoteKernel{}
a := appWithFakeKernel(fake)
a.stopRemoteRuntime()
if !fake.closed {
t.Fatal("kernel not closed on stopRemoteRuntime")
}
if a.remoteRuntime != nil {
t.Fatal("remoteRuntime not cleared")
}
}
// TestUpdateHostPreservesHiddenFields pins the data-loss fix: editing a host in
// the desktop UI (whose input lacks passphrase_env/password_env/forwards) must
// not wipe those stored fields.
func TestUpdateHostPreservesHiddenFields(t *testing.T) {
home := t.TempDir()
t.Setenv("REASONIX_HOME", home)
t.Setenv("HOME", home)
mgr := newDesktopRemoteManager(&App{})
// Seed a host with credential refs + a forward via the kernel config API.
if err := editUserConfig(func(c *config.Config) error {
return c.UpsertRemoteHost(config.RemoteHostEntry{
Name: "box", Host: "10.0.0.9", User: "dev",
PassphraseEnv: "REMOTE_BOX_PASSPHRASE",
PasswordEnv: "REMOTE_BOX_PASSWORD",
Forwards: []config.RemoteForwardEntry{{Type: "local", Bind: "127.0.0.1:8080", Target: "127.0.0.1:80"}},
})
}); err != nil {
t.Fatal(err)
}
// Edit via the desktop input (no hidden fields), changing only the user.
if _, err := mgr.UpdateHost("box", RemoteHostInput{Label: "box", Host: "10.0.0.9", Port: 22, User: "ops", ServeInstall: "auto"}); err != nil {
t.Fatalf("UpdateHost: %v", err)
}
cfg, err := config.Load()
if err != nil {
t.Fatal(err)
}
h, ok := cfg.RemoteHost("box")
if !ok {
t.Fatal("host missing after edit")
}
if h.User != "ops" {
t.Fatalf("edit did not apply: user=%q", h.User)
}
if h.PassphraseEnv != "REMOTE_BOX_PASSPHRASE" || h.PasswordEnv != "REMOTE_BOX_PASSWORD" {
t.Fatalf("edit wiped credential env refs: %+v", h)
}
if len(h.Forwards) != 1 || h.Forwards[0].Bind != "127.0.0.1:8080" {
t.Fatalf("edit wiped persisted forwards: %+v", h.Forwards)
}
}
// TestScanSSHConfigReturnsNonNil pins the JSON-contract fix: an empty scan must
// encode as [] (not null), which the React import page iterates safely.
func TestScanSSHConfigReturnsNonNil(t *testing.T) {
home := t.TempDir()
t.Setenv("REASONIX_HOME", home)
t.Setenv("HOME", home) // no ~/.ssh/config here => empty result
mgr := newDesktopRemoteManager(&App{})
out, err := mgr.ScanSSHConfig()
if err != nil {
t.Fatalf("ScanSSHConfig: %v", err)
}
if out == nil {
t.Fatal("ScanSSHConfig returned nil slice (would encode as JSON null and crash the import page)")
}
}
func TestOpenRemoteWorkspacePersistsLastWorkspace(t *testing.T) {
home := t.TempDir()
t.Setenv("REASONIX_HOME", home)
t.Setenv("HOME", home)
fake := &fakeRemoteKernel{
ensureView: RemoteServerView{State: "ready", LocalURL: "http://127.0.0.1:5000/"},
ensureToken: "tok",
}
a := appWithFakeKernel(fake)
var opened remoteWindowLaunch
a.remoteWindowOpener = func(launch remoteWindowLaunch) error {
opened = launch
return nil
}
if err := a.OpenRemoteWorkspace("box", "/home/dev/app"); err != nil {
t.Fatal(err)
}
if opened.URL != "http://127.0.0.1:5000?token=tok" {
t.Fatalf("opened URL = %q", opened.URL)
}
if opened.Title != "Reasonix [SSH: box]" {
t.Fatalf("opened title = %q", opened.Title)
}
got := a.RemoteLastWorkspace("box")
if got != "/home/dev/app" {
t.Fatalf("last workspace = %q, want /home/dev/app", got)
}
// desktop-remote.json exists.
if _, err := os.Stat(filepath.Join(config.MemoryUserDir(), "desktop-remote.json")); err != nil {
t.Fatalf("desktop-remote.json not written: %v", err)
}
}
+329
View File
@@ -0,0 +1,329 @@
package main
import (
"context"
"errors"
"os"
"path/filepath"
"runtime"
"strings"
"sync"
"testing"
"time"
"reasonix/internal/config"
"reasonix/internal/remote"
"reasonix/internal/remote/bootstrap"
"reasonix/internal/remote/forward"
"reasonix/internal/remote/sftpfs"
)
type lifecycleSSHClient struct {
mu sync.Mutex
startErr error
closed bool
sub func(remote.StatusEvent)
forwards *forward.Set
}
type lifecycleEventSink struct {
statuses chan RemoteConnectionStatusView
}
func (s *lifecycleEventSink) onStatus(v RemoteConnectionStatusView) { s.statuses <- v }
func (*lifecycleEventSink) onForwards(string, []RemoteForwardView) {}
func (*lifecycleEventSink) onServer(RemoteServerView) {}
func newLifecycleSSHClient(startErr error) *lifecycleSSHClient {
return &lifecycleSSHClient{startErr: startErr, forwards: forward.NewSet(nil)}
}
func (c *lifecycleSSHClient) Start(context.Context) error {
c.mu.Lock()
sub, err := c.sub, c.startErr
c.mu.Unlock()
if sub != nil {
if err != nil {
sub(remote.StatusEvent{Status: remote.StatusStopped, Err: err})
} else {
sub(remote.StatusEvent{Status: remote.StatusConnected})
}
}
return err
}
func (c *lifecycleSSHClient) Close() error {
c.mu.Lock()
if c.closed {
c.mu.Unlock()
return nil
}
c.closed = true
c.mu.Unlock()
c.forwards.Close()
return nil
}
func (c *lifecycleSSHClient) Subscribe(fn func(remote.StatusEvent)) func() {
c.mu.Lock()
c.sub = fn
c.mu.Unlock()
fn(remote.StatusEvent{Status: remote.StatusIdle})
return func() {}
}
func (c *lifecycleSSHClient) Forwards() *forward.Set { return c.forwards }
func (c *lifecycleSSHClient) Exec(context.Context, string) (remote.ExecResult, error) {
return remote.ExecResult{}, nil
}
func (c *lifecycleSSHClient) SFTP() (*sftpfs.FS, error) { return nil, errors.New("unused") }
func seedLifecycleHost(t *testing.T, hostID string) {
t.Helper()
home := t.TempDir()
t.Setenv("REASONIX_HOME", home)
t.Setenv("HOME", home)
if err := editUserConfig(func(c *config.Config) error {
return c.UpsertRemoteHost(config.RemoteHostEntry{Name: hostID, Host: "127.0.0.1", Port: 22, User: "tester"})
}); err != nil {
t.Fatal(err)
}
}
func TestConnectCanReplaceStoppedGeneration(t *testing.T) {
seedLifecycleHost(t, "box")
mgr := newDesktopRemoteManager(nil)
first := newLifecycleSSHClient(errors.New("first dial failed"))
second := newLifecycleSSHClient(nil)
var calls int
mgr.newClient = func(remote.Options) (desktopSSHClient, error) {
calls++
if calls == 1 {
return first, nil
}
return second, nil
}
if err := mgr.Connect("box"); err != nil {
t.Fatal(err)
}
deadline := time.Now().Add(2 * time.Second)
for {
statuses := mgr.Statuses()
if len(statuses) == 1 && statuses[0].State == "stopped" {
break
}
if time.Now().After(deadline) {
t.Fatalf("first generation did not stop: %+v", statuses)
}
time.Sleep(time.Millisecond)
}
if err := mgr.Connect("box"); err != nil {
t.Fatal(err)
}
if calls != 2 {
t.Fatalf("newClient calls = %d, want 2", calls)
}
first.mu.Lock()
firstClosed := first.closed
first.mu.Unlock()
if !firstClosed {
t.Fatal("replaced stopped client was not closed")
}
}
func TestStaleClientStatusCannotOverwriteReplacement(t *testing.T) {
mgr := newDesktopRemoteManager(nil)
oldCtx, oldCancel := context.WithCancel(context.Background())
defer oldCancel()
newCtx, newCancel := context.WithCancel(context.Background())
defer newCancel()
old := &managedHost{ctx: oldCtx, cancel: oldCancel, client: newLifecycleSSHClient(nil)}
current := &managedHost{
ctx: newCtx, cancel: newCancel, client: newLifecycleSSHClient(nil),
status: RemoteConnectionStatusView{HostID: "box", State: "connected"},
}
mgr.hosts["box"] = current
mgr.onClientStatus("box", old, remote.StatusEvent{Status: remote.StatusStopped, Err: errors.New("late")})
if got := mgr.Statuses()[0]; got.State != "connected" || got.Error != "" {
t.Fatalf("replacement status was overwritten: %+v", got)
}
}
func TestServerLogsCancellationOnDisconnect(t *testing.T) {
sink := &lifecycleEventSink{statuses: make(chan RemoteConnectionStatusView, 1)}
mgr := newDesktopRemoteManager(sink)
hostCtx, hostCancel := context.WithCancel(context.Background())
mh := &managedHost{
ctx: hostCtx, cancel: hostCancel, client: newLifecycleSSHClient(nil),
server: RemoteServerView{HostID: "box", Workspace: "/work", State: "ready"},
}
mgr.hosts["box"] = mh
entered := make(chan struct{})
mgr.serveLogs = func(ctx context.Context, _ bootstrap.Conn, _ string, _ int, _ *strings.Builder) error {
close(entered)
<-ctx.Done()
return ctx.Err()
}
done := make(chan error, 1)
go func() {
_, err := mgr.ServerLogs(context.Background(), "box", 20)
done <- err
}()
<-entered
if err := mgr.Disconnect("box"); err != nil {
t.Fatal(err)
}
select {
case status := <-sink.statuses:
if status.HostID != "box" || status.State != "stopped" {
t.Fatalf("Disconnect status = %+v", status)
}
default:
t.Fatal("Disconnect did not publish a stopped status")
}
select {
case err := <-done:
if !errors.Is(err, context.Canceled) {
t.Fatalf("ServerLogs error = %v, want context canceled", err)
}
case <-time.After(2 * time.Second):
t.Fatal("ServerLogs was not canceled by Disconnect")
}
}
func TestEnsureServerResultCannotMutateReplacement(t *testing.T) {
seedLifecycleHost(t, "box")
mgr := newDesktopRemoteManager(nil)
hostCtx, hostCancel := context.WithCancel(context.Background())
old := &managedHost{ctx: hostCtx, cancel: hostCancel, client: newLifecycleSSHClient(nil)}
mgr.hosts["box"] = old
entered := make(chan struct{})
release := make(chan struct{})
mgr.ensureServe = func(context.Context, bootstrap.Conn, bootstrap.Options) (bootstrap.Result, error) {
close(entered)
<-release
return bootstrap.Result{State: bootstrap.ServeState{Addr: "127.0.0.1:9999"}, Token: "old-token"}, nil
}
mgr.localBinary = func() string { return "" }
done := make(chan error, 1)
go func() {
_, _, err := mgr.EnsureServer(context.Background(), "box", "/old")
done <- err
}()
<-entered
if err := mgr.Disconnect("box"); err != nil {
t.Fatal(err)
}
newCtx, newCancel := context.WithCancel(context.Background())
defer newCancel()
replacement := &managedHost{
ctx: newCtx, cancel: newCancel, client: newLifecycleSSHClient(nil),
server: RemoteServerView{HostID: "box", Workspace: "/new", State: "ready"}, token: "new-token",
}
mgr.mu.Lock()
mgr.hosts["box"] = replacement
mgr.mu.Unlock()
close(release)
if err := <-done; err == nil {
t.Fatal("stale EnsureServer unexpectedly succeeded")
}
if got := mgr.ServerStatus("box"); got.Workspace != "/new" || got.State != "ready" {
t.Fatalf("replacement server state was overwritten: %+v", got)
}
if replacement.token != "new-token" {
t.Fatalf("replacement token = %q, want new-token", replacement.token)
}
}
func TestStopServerRejectsEmptyWorkspace(t *testing.T) {
mgr := newDesktopRemoteManager(nil)
hostCtx, hostCancel := context.WithCancel(context.Background())
defer hostCancel()
mgr.hosts["box"] = &managedHost{ctx: hostCtx, cancel: hostCancel, client: newLifecycleSSHClient(nil)}
called := false
mgr.stopServe = func(context.Context, bootstrap.Conn, string) error { called = true; return nil }
if err := mgr.StopServer("box"); err == nil {
t.Fatal("StopServer accepted an empty workspace")
}
if called {
t.Fatal("StopServer called bootstrap.Stop with an empty workspace")
}
}
func TestDesktopCLIBinaryPathFallsBackToPATH(t *testing.T) {
dir := t.TempDir()
name := "reasonix"
if runtime.GOOS == "windows" {
name += ".exe"
}
cli := filepath.Join(dir, name)
if err := os.WriteFile(cli, []byte("test"), 0o755); err != nil {
t.Fatal(err)
}
t.Setenv("PATH", dir)
if got := desktopCLIBinaryPath(); got != cli {
t.Fatalf("desktopCLIBinaryPath = %q, want %q", got, cli)
}
}
func TestHasUsableServeForwardRequiresExactTargetAndURL(t *testing.T) {
entries := []forward.Entry{{
Spec: forward.Spec{Name: serveForwardName, TargetAddr: "127.0.0.1:9000"},
Up: true, BoundAddr: "127.0.0.1:45000",
}}
if !hasUsableServeForward(entries, "127.0.0.1:9000", "http://127.0.0.1:45000/") {
t.Fatal("exact existing serve forward was not reusable")
}
if hasUsableServeForward(entries, "127.0.0.1:9001", "http://127.0.0.1:45000/") {
t.Fatal("stale serve target was reused")
}
if hasUsableServeForward(entries, "127.0.0.1:9000", "http://127.0.0.1:45001/") {
t.Fatal("mismatched local URL was reused")
}
}
func TestDesktopNormalizeBind(t *testing.T) {
if got := desktopNormalizeBind("8080"); got != "127.0.0.1:8080" {
t.Fatalf("desktopNormalizeBind bare port = %q", got)
}
if got := desktopNormalizeBind("0.0.0.0:8080"); got != "0.0.0.0:8080" {
t.Fatalf("desktopNormalizeBind address = %q", got)
}
}
func TestHostKeyPromptsAreSerializedForGlobalDialog(t *testing.T) {
sink := &lifecycleEventSink{statuses: make(chan RemoteConnectionStatusView, 2)}
mgr := newDesktopRemoteManager(sink)
ctx, cancel := context.WithCancel(context.Background())
defer cancel()
for _, hostID := range []string{"a", "b"} {
mh := &managedHost{ctx: ctx, cancel: cancel, client: newLifecycleSSHClient(nil)}
mgr.hosts[hostID] = mh
prompt := mgr.hostKeyPrompt(hostID, mh)
go func() {
_, _ = prompt(ctx, remote.HostKeyQuestion{Address: hostID + ":22", KeyType: "ssh-ed25519", Fingerprint: hostID})
}()
}
first := <-sink.statuses
select {
case second := <-sink.statuses:
t.Fatalf("second prompt %q replaced unresolved prompt %q", second.HostID, first.HostID)
case <-time.After(50 * time.Millisecond):
}
if err := mgr.ResolveHostKey(first.HostID, true); err != nil {
t.Fatal(err)
}
select {
case second := <-sink.statuses:
if second.HostID == first.HostID {
t.Fatalf("serialized prompt repeated host %q", second.HostID)
}
if err := mgr.ResolveHostKey(second.HostID, false); err != nil {
t.Fatal(err)
}
case <-time.After(2 * time.Second):
t.Fatal("second prompt did not appear after resolving the first")
}
}
+79
View File
@@ -0,0 +1,79 @@
package main
import (
"encoding/json"
"os"
"path/filepath"
"sync"
"reasonix/internal/config"
"reasonix/internal/fileutil"
)
var remotePrefsMu sync.Mutex
// remotePrefs is desktop-only remote UI state, stored beside the other desktop
// JSON prefs (desktop-workspaces.json, desktop-tabs.json). All fields are
// optional so an older file decodes cleanly.
type remotePrefs struct {
LastHostID string `json:"lastHostId,omitempty"`
LastWorkspaceByHost map[string]string `json:"lastWorkspaceByHost,omitempty"`
ExplorerTab string `json:"explorerTab,omitempty"`
}
func remotePrefsPath() string {
dir := config.MemoryUserDir()
if dir == "" {
return ""
}
return filepath.Join(dir, "desktop-remote.json")
}
func loadRemotePrefs() remotePrefs {
var p remotePrefs
path := remotePrefsPath()
if path == "" {
return p
}
data, err := os.ReadFile(path)
if err != nil {
return p
}
_ = json.Unmarshal(data, &p)
if p.LastWorkspaceByHost == nil {
p.LastWorkspaceByHost = map[string]string{}
}
return p
}
func saveRemotePrefs(p remotePrefs) {
path := remotePrefsPath()
if path == "" {
return
}
data, err := json.MarshalIndent(p, "", " ")
if err != nil {
return
}
_ = fileutil.AtomicWriteFile(path, data, 0o600)
}
func (a *App) saveLastRemoteWorkspace(hostID, workspace string) {
remotePrefsMu.Lock()
defer remotePrefsMu.Unlock()
p := loadRemotePrefs()
if p.LastWorkspaceByHost == nil {
p.LastWorkspaceByHost = map[string]string{}
}
p.LastHostID = hostID
p.LastWorkspaceByHost[hostID] = workspace
saveRemotePrefs(p)
}
// RemoteLastWorkspace returns the last opened workspace for hostID (bound so
// the frontend can prefill the server card).
func (a *App) RemoteLastWorkspace(hostID string) string {
remotePrefsMu.Lock()
defer remotePrefsMu.Unlock()
return loadRemotePrefs().LastWorkspaceByHost[hostID]
}
+223
View File
@@ -0,0 +1,223 @@
package main
import (
"encoding/json"
"fmt"
"net"
"net/http"
"net/url"
"os"
"os/exec"
"path/filepath"
goruntime "runtime"
"strings"
"time"
"unicode"
"github.com/wailsapp/wails/v2/pkg/runtime"
"reasonix/internal/config"
)
const (
remoteWindowTicketArgPrefix = "--remote-window-ticket="
remoteWindowTicketPrefix = ".remote-window-"
remoteWindowTicketTTL = 2 * time.Minute
remoteWindowTicketMaxBytes = 16 * 1024
)
// remoteWindowLaunch is a one-shot handoff from the primary Reasonix process to
// a lightweight native window process. The URL contains the local tunnel token,
// so the descriptor lives in a mode-0600 file instead of the process arguments.
type remoteWindowLaunch struct {
URL string `json:"url"`
Title string `json:"title,omitempty"`
}
func remoteWindowTicketPath(ticket string) (string, error) {
if ticket == "" || filepath.Base(ticket) != ticket || !strings.HasPrefix(ticket, remoteWindowTicketPrefix) {
return "", fmt.Errorf("invalid remote window ticket")
}
dir := strings.TrimSpace(config.MemoryUserDir())
if dir == "" {
return "", fmt.Errorf("cannot resolve remote window state directory")
}
return filepath.Join(dir, ticket), nil
}
func writeRemoteWindowLaunch(launch remoteWindowLaunch) (string, error) {
if !isSafeRemoteWindowURL(launch.URL) {
return "", fmt.Errorf("remote window URL must use HTTP on loopback")
}
dir := config.MemoryUserDir()
if err := os.MkdirAll(dir, 0o700); err != nil {
return "", fmt.Errorf("create remote window state directory: %w", err)
}
f, err := os.CreateTemp(dir, remoteWindowTicketPrefix)
if err != nil {
return "", fmt.Errorf("create remote window ticket: %w", err)
}
path := f.Name()
remove := true
defer func() {
_ = f.Close()
if remove {
_ = os.Remove(path)
}
}()
if err := f.Chmod(0o600); err != nil {
return "", fmt.Errorf("secure remote window ticket: %w", err)
}
if err := json.NewEncoder(f).Encode(launch); err != nil {
return "", fmt.Errorf("write remote window ticket: %w", err)
}
if err := f.Sync(); err != nil {
return "", fmt.Errorf("sync remote window ticket: %w", err)
}
if err := f.Close(); err != nil {
return "", fmt.Errorf("close remote window ticket: %w", err)
}
remove = false
return filepath.Base(path), nil
}
func consumeRemoteWindowLaunch(ticket string) (*remoteWindowLaunch, error) {
path, err := remoteWindowTicketPath(ticket)
if err != nil {
return nil, err
}
info, err := os.Lstat(path)
if err != nil {
return nil, fmt.Errorf("inspect remote window ticket: %w", err)
}
defer os.Remove(path)
if !info.Mode().IsRegular() || info.Mode()&os.ModeSymlink != 0 {
return nil, fmt.Errorf("remote window ticket is not a regular file")
}
if info.Size() <= 0 || info.Size() > remoteWindowTicketMaxBytes {
return nil, fmt.Errorf("remote window ticket has an invalid size")
}
// Windows does not expose Unix owner/group permission bits through Stat;
// CreateTemp still creates the file for the current user, while ACLs remain
// governed by the private user state directory.
if goruntime.GOOS != "windows" && info.Mode().Perm()&0o077 != 0 {
return nil, fmt.Errorf("remote window ticket permissions are too broad")
}
data, err := os.ReadFile(path)
if err != nil {
return nil, fmt.Errorf("read remote window ticket: %w", err)
}
var launch remoteWindowLaunch
if err := json.Unmarshal(data, &launch); err != nil {
return nil, fmt.Errorf("decode remote window ticket: %w", err)
}
if !isSafeRemoteWindowURL(launch.URL) {
return nil, fmt.Errorf("remote window URL must use HTTP on loopback")
}
return &launch, nil
}
func isSafeRemoteWindowURL(raw string) bool {
u, err := url.Parse(raw)
if err != nil || u.Scheme != "http" || u.Host == "" || u.User != nil {
return false
}
host := strings.TrimSpace(u.Hostname())
if strings.EqualFold(host, "localhost") {
return true
}
ip := net.ParseIP(host)
return ip != nil && ip.IsLoopback()
}
func remoteWindowNavigationJS(raw string) (string, error) {
if !isSafeRemoteWindowURL(raw) {
return "", fmt.Errorf("remote window URL must use HTTP on loopback")
}
encoded, err := json.Marshal(raw)
if err != nil {
return "", err
}
return "window.location.replace(" + string(encoded) + ");", nil
}
func spawnRemoteWindow(launch remoteWindowLaunch) error {
ticket, err := writeRemoteWindowLaunch(launch)
if err != nil {
return err
}
path, _ := remoteWindowTicketPath(ticket)
executable, err := os.Executable()
if err != nil {
_ = os.Remove(path)
return fmt.Errorf("locate Reasonix executable: %w", err)
}
cmd := exec.Command(executable, remoteWindowTicketArgPrefix+ticket)
if err := cmd.Start(); err != nil {
_ = os.Remove(path)
return fmt.Errorf("start remote Reasonix window: %w", err)
}
// The child normally consumes the ticket immediately. This bounds any
// leftover token file if the child exits before reaching argument parsing.
time.AfterFunc(remoteWindowTicketTTL, func() { _ = os.Remove(path) })
if err := cmd.Process.Release(); err != nil {
return fmt.Errorf("release remote Reasonix window process: %w", err)
}
return nil
}
func remoteWindowTitle(hostID string) string {
hostID = strings.TrimSpace(strings.Map(func(r rune) rune {
if unicode.IsControl(r) {
return -1
}
return r
}, hostID))
runes := []rune(hostID)
if len(runes) > 80 {
hostID = string(runes[:80]) + "…"
}
if hostID == "" {
hostID = "Remote"
}
return "Reasonix [SSH: " + hostID + "]"
}
func (a *App) openRemoteWindow(rawURL, hostID string) error {
launch := remoteWindowLaunch{URL: rawURL, Title: remoteWindowTitle(hostID)}
if a.remoteWindowOpener != nil {
return a.remoteWindowOpener(launch)
}
return spawnRemoteWindow(launch)
}
func (a *App) remoteWindowAssetMiddleware() func(http.Handler) http.Handler {
return func(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if a.remoteWindow == nil || (r.URL.Path != "/" && r.URL.Path != "/index.html") {
next.ServeHTTP(w, r)
return
}
w.Header().Set("Content-Type", "text/html; charset=utf-8")
w.Header().Set("Cache-Control", "no-store")
w.Header().Set("Content-Security-Policy", "default-src 'none'; style-src 'unsafe-inline'")
_, _ = w.Write([]byte(`<!doctype html><html><head><meta charset="utf-8"><style>html{background:#1a1a2e}</style></head><body></body></html>`))
})
}
}
func (a *App) domReadyRemoteWindow() {
if a.remoteWindow == nil {
return
}
if a.remoteWindowNavigated.CompareAndSwap(false, true) {
if a.remoteWindow.Title != "" {
runtime.WindowSetTitle(a.ctx, a.remoteWindow.Title)
}
if js, err := remoteWindowNavigationJS(a.remoteWindow.URL); err == nil {
runtime.WindowExecJS(a.ctx, js)
}
}
runtime.WindowCenter(a.ctx)
runtime.WindowShow(a.ctx)
}
+160
View File
@@ -0,0 +1,160 @@
package main
import (
"context"
"net/http"
"net/http/httptest"
"os"
"path/filepath"
"runtime"
"strings"
"testing"
"reasonix/internal/config"
)
func TestRemoteWindowTicketRoundTripAndRemoval(t *testing.T) {
launch := remoteWindowLaunch{
URL: "http://127.0.0.1:54321/?token=secret-token",
Title: "Reasonix [SSH: box]",
}
ticket, err := writeRemoteWindowLaunch(launch)
if err != nil {
t.Fatal(err)
}
if strings.Contains(ticket, "secret-token") || filepath.Base(ticket) != ticket {
t.Fatalf("ticket leaked URL data or path: %q", ticket)
}
path, err := remoteWindowTicketPath(ticket)
if err != nil {
t.Fatal(err)
}
if runtime.GOOS != "windows" {
info, err := os.Stat(path)
if err != nil {
t.Fatal(err)
}
if got := info.Mode().Perm(); got != 0o600 {
t.Fatalf("ticket permissions = %o, want 600", got)
}
}
got, err := consumeRemoteWindowLaunch(ticket)
if err != nil {
t.Fatal(err)
}
if *got != launch {
t.Fatalf("launch = %+v, want %+v", *got, launch)
}
if _, err := os.Stat(path); !os.IsNotExist(err) {
t.Fatalf("ticket was not removed after consumption: %v", err)
}
}
func TestRemoteWindowTicketRejectsUnsafeInputs(t *testing.T) {
for _, raw := range []string{
"https://127.0.0.1:5000/?token=x",
"http://example.com:5000/?token=x",
"file:///tmp/index.html",
"javascript:alert(1)",
} {
if _, err := writeRemoteWindowLaunch(remoteWindowLaunch{URL: raw}); err == nil {
t.Fatalf("unsafe URL accepted: %q", raw)
}
}
for _, ticket := range []string{"", "../.remote-window-x", "/tmp/.remote-window-x", "unrelated"} {
if _, err := remoteWindowTicketPath(ticket); err == nil {
t.Fatalf("unsafe ticket accepted: %q", ticket)
}
}
}
func TestConsumeRemoteWindowTicketRejectsBroadPermissions(t *testing.T) {
if runtime.GOOS == "windows" {
t.Skip("Windows does not expose Unix permission bits through os.Stat")
}
dir := config.MemoryUserDir()
if err := os.MkdirAll(dir, 0o700); err != nil {
t.Fatal(err)
}
ticket := remoteWindowTicketPrefix + "insecure"
path := filepath.Join(dir, ticket)
if err := os.WriteFile(path, []byte(`{"url":"http://127.0.0.1:5000/"}`), 0o644); err != nil {
t.Fatal(err)
}
t.Cleanup(func() { _ = os.Remove(path) })
if _, err := consumeRemoteWindowLaunch(ticket); err == nil {
t.Fatal("ticket with broad permissions was accepted")
}
}
func TestConsumeRemoteWindowTicketRejectsOversizedDescriptor(t *testing.T) {
dir := config.MemoryUserDir()
if err := os.MkdirAll(dir, 0o700); err != nil {
t.Fatal(err)
}
ticket := remoteWindowTicketPrefix + "oversized"
path := filepath.Join(dir, ticket)
if err := os.WriteFile(path, make([]byte, remoteWindowTicketMaxBytes+1), 0o600); err != nil {
t.Fatal(err)
}
if _, err := consumeRemoteWindowLaunch(ticket); err == nil {
t.Fatal("oversized ticket was accepted")
}
if _, err := os.Stat(path); !os.IsNotExist(err) {
t.Fatalf("rejected ticket was not removed: %v", err)
}
}
func TestRemoteWindowNavigationJSEscapesURL(t *testing.T) {
js, err := remoteWindowNavigationJS("http://127.0.0.1:5000/?token=x%22);alert(1)//")
if err != nil {
t.Fatal(err)
}
if !strings.HasPrefix(js, "window.location.replace(\"") || !strings.HasSuffix(js, "\");") {
t.Fatalf("unexpected navigation JS: %q", js)
}
if strings.Contains(js, "\");alert") {
t.Fatalf("URL escaped the JS string: %q", js)
}
}
func TestRemoteWindowLifecycleSkipsPrimaryRuntime(t *testing.T) {
a := NewApp()
a.remoteWindow = &remoteWindowLaunch{URL: "http://127.0.0.1:5000/"}
a.startup(context.Background())
if a.tabsRestored != nil {
t.Fatal("remote window initialized local tab restore")
}
if a.heartbeat != nil || a.tray != nil || a.remoteRuntime != nil {
t.Fatal("remote window initialized primary-process runtime")
}
if a.beforeClose(context.Background()) {
t.Fatal("remote window close was intercepted")
}
a.shutdown(context.Background())
}
func TestRemoteWindowAssetMiddlewareDoesNotLoadPrimaryFrontend(t *testing.T) {
a := &App{remoteWindow: &remoteWindowLaunch{URL: "http://127.0.0.1:5000/"}}
nextCalled := false
h := a.remoteWindowAssetMiddleware()(http.HandlerFunc(func(http.ResponseWriter, *http.Request) {
nextCalled = true
}))
rec := httptest.NewRecorder()
h.ServeHTTP(rec, httptest.NewRequest(http.MethodGet, "/", nil))
if nextCalled {
t.Fatal("remote shell loaded the primary asset handler")
}
if strings.Contains(rec.Body.String(), "<script") {
t.Fatal("remote shell bootstrap unexpectedly contains frontend scripts")
}
if got := rec.Header().Get("Cache-Control"); got != "no-store" {
t.Fatalf("Cache-Control = %q", got)
}
}
func TestRemoteWindowTitleSanitizesHostLabel(t *testing.T) {
if got := remoteWindowTitle(" box\nprod "); got != "Reasonix [SSH: boxprod]" {
t.Fatalf("title = %q", got)
}
}
+9 -2
View File
@@ -599,10 +599,17 @@ func applyLinux(targz []byte) error {
if err != nil {
return err
}
cli, err := extractBinary(targz, "reasonix")
if err != nil {
return err
}
exe := currentExecutablePath()
if exe == "" {
return fmt.Errorf("update: current executable path is unavailable")
}
if err := writeAtomic(filepath.Join(filepath.Dir(exe), "reasonix"), cli, 0o700); err != nil {
return fmt.Errorf("update CLI sidecar: %w", err)
}
if err := writeAtomic(filepath.Join(filepath.Dir(exe), "reasonix-guard"), guard, 0o700); err != nil {
return fmt.Errorf("update Guard: %w", err)
}
@@ -657,9 +664,9 @@ func updateSiblingArtifacts() []string {
func updateSiblingNames(goos string) []string {
switch goos {
case "windows":
return []string{"reasonix-guard.exe", "reasonix-launcher.exe", "reasonix-update-helper.exe", "Reasonix.exe"}
return []string{"reasonix-guard.exe", "reasonix-launcher.exe", "reasonix-update-helper.exe", "reasonix.exe", "Reasonix.exe"}
case "linux":
return []string{"reasonix-guard"}
return []string{"reasonix-guard", "reasonix"}
default:
return nil
}
+2 -2
View File
@@ -46,12 +46,12 @@ func TestNormalizeVersion(t *testing.T) {
func TestUpdateSiblingNamesCoverEveryReplacedEntryPoint(t *testing.T) {
windows := strings.Join(updateSiblingNames("windows"), "\x00")
for _, want := range []string{"reasonix-guard.exe", "reasonix-launcher.exe", "reasonix-update-helper.exe", "Reasonix.exe"} {
for _, want := range []string{"reasonix-guard.exe", "reasonix-launcher.exe", "reasonix-update-helper.exe", "reasonix.exe", "Reasonix.exe"} {
if !strings.Contains(windows, want) {
t.Errorf("Windows release unit omits %q: %q", want, windows)
}
}
if got := updateSiblingNames("linux"); len(got) != 1 || got[0] != "reasonix-guard" {
if got := updateSiblingNames("linux"); len(got) != 2 || got[0] != "reasonix-guard" || got[1] != "reasonix" {
t.Fatalf("Linux release unit = %q", got)
}
if got := updateSiblingNames("darwin"); got != nil {
+4
View File
@@ -47,17 +47,21 @@ func TestWindowsInstallerScriptWaitsBeforeCopyingExecutable(t *testing.T) {
`!define REASONIX_UPDATE_HELPER "reasonix-update-helper.exe"`,
`!define REASONIX_GUARD "reasonix-guard.exe"`,
`!define REASONIX_LAUNCHER "reasonix-launcher.exe"`,
`!define REASONIX_CLI "reasonix.exe"`,
`!define REASONIX_PORTABLE_ENTRY "Reasonix.exe"`,
"Function reasonix.waitForExecutableUnlock",
`FileOpen $1 "$INSTDIR\${PRODUCT_EXECUTABLE}" a`,
`FileOpen $1 "$INSTDIR\${REASONIX_GUARD}" a`,
`FileOpen $1 "$INSTDIR\${REASONIX_LAUNCHER}" a`,
`FileOpen $1 "$INSTDIR\${REASONIX_CLI}" a`,
`FileOpen $1 "$INSTDIR\${REASONIX_PORTABLE_ENTRY}" a`,
"SetErrorLevel 1618",
"Call reasonix.waitForExecutableUnlock",
`File "/oname=${REASONIX_UPDATE_HELPER}" "${REASONIX_UPDATE_HELPER}"`,
`File "/oname=${REASONIX_CLI}" "${REASONIX_CLI}"`,
`File "/oname=${REASONIX_PORTABLE_ENTRY}" "${REASONIX_LAUNCHER}"`,
`Delete "$INSTDIR\${REASONIX_UPDATE_HELPER}"`,
`Delete "$INSTDIR\${REASONIX_CLI}"`,
} {
if !strings.Contains(script, want) {
t.Fatalf("project.nsi missing %q", want)
+1
View File
@@ -37,6 +37,7 @@ non-destructively when `<Reasonix home>/.env` is missing them.
| Global skills | `<Reasonix home>/skills/` |
| Global hooks | `<Reasonix home>/settings.json` |
| Hook trust store | `<Reasonix home>/trust.json` |
| Remote-SSH managed known_hosts | `<Reasonix home>/remote/known_hosts` |
| Sessions | `<state root>/sessions/` |
| Archives | `<state root>/archive/` |
| Memory | `<state root>/memory/` and `<state root>/projects/` |
+1
View File
@@ -30,6 +30,7 @@ Legacy 迁移、OS home 约定目录扫描以及其他 fallback 路径都会跳
| 全局 skills | `<Reasonix home>/skills/` |
| 全局 hooks | `<Reasonix home>/settings.json` |
| hooks 信任状态 | `<Reasonix home>/trust.json` |
| 远程 SSH 托管 known_hosts | `<Reasonix home>/remote/known_hosts` |
| 会话 | `<state root>/sessions/` |
| 归档 | `<state root>/archive/` |
| 记忆 | `<state root>/memory/` 与 `<state root>/projects/` |
+68
View File
@@ -203,6 +203,74 @@ Ask + Normal. For compatibility with clients built against the old mixed mode
list, `session/set_mode` still accepts `default` as Normal + Ask and `auto` as
Normal + Yolo, but new clients should use the independent selectors.
## Remote SSH
The remote module runs Reasonix on a remote host and reaches it over your own
SSH connection — VS Code Remote-SSH style. It bootstraps a persistent headless
`reasonix serve` on the remote host, forwards a local loopback port to it, and
opens the existing serve web client through that tunnel. The agent, its tools,
and its files all live on the remote host at full fidelity; nothing runs through
a lossy file proxy. V1 supports Linux and macOS remote hosts.
Hosts live in a user-global `[remote]` section of `config.toml`. Like
`[secrets]`, a project `reasonix.toml` cannot inject or override remote hosts —
a cloned repo can never steer where Reasonix opens SSH connections. Credentials
follow the provider idiom: the host names an env var (`passphrase_env`,
`password_env`) whose value lives in Reasonix's global `.env`; key material
itself is never stored — `identity_file` is a path.
```toml
[remote]
[[remote.hosts]]
name = "gpu-box"
host = "203.0.113.7"
user = "dev"
identity_file = "~/.ssh/id_ed25519"
workspace = "~/projects/app"
serve_install = "auto" # auto | npm | upload | never
[[remote.hosts.forwards]]
type = "local" # local (-L) | remote (-R)
bind = "127.0.0.1:5432"
target = "127.0.0.1:5432"
```
CLI:
```bash
reasonix remote add gpu-box [email protected] --workspace '~/projects/app'
reasonix remote import --all # from ~/.ssh/config (Match blocks skipped)
reasonix remote test gpu-box # dial + auth + host-key confirmation
reasonix remote connect gpu-box --open # bootstrap serve, tunnel, open the URL
reasonix remote serve status gpu-box
reasonix remote fs ls gpu-box:'~/projects/app'
```
`connect` is a foreground supervisor (like `ssh -N` plus the serve bootstrap):
it keeps the tunnel and configured forwards alive, auto-reconnects with
exponential backoff if the link drops, and re-attaches forwards on reconnect.
Ctrl-C disconnects the local side only — the remote serve keeps running, so the
next `connect` reuses it. There is no background daemon in V1.
Host keys are verified against your OpenSSH `~/.ssh/known_hosts` (read-only)
plus a Reasonix-managed `~/.reasonix/remote/known_hosts`. A first-seen key
prompts for trust-on-first-use and is recorded in the managed file; a key that
contradicts a recorded one is a hard error that names the offending line and is
never auto-accepted.
Remote-side state lives under the remote host's `~/.reasonix/remote/`:
`serve-<workspace-slug>.json` (pid, bound loopback address, workspace),
`serve-<slug>.token` (0600; the auth token, passed to serve via `--token-file`
so it never appears in `ps`), and `serve-<slug>.log`.
In the desktop app, manage hosts under **Settings -> Remote SSH**, then use the
status-bar chip or the host row's **Remote explorer** button to browse and edit
files over SFTP, manage port forwards, and start/open the remote workspace.
Opening a workspace creates a separate native Reasonix window, similar to a
VS Code Remote SSH window. The primary window owns the SSH tunnel; the remote
window is an isolated, lightweight shell and does not restore or acquire local
conversation sessions.
## Custom OpenAI-compatible providers
In the desktop app, open **Settings -> Model -> Access -> Add model service ->
+56
View File
@@ -181,6 +181,62 @@ Goal、由 `todo_write` 工具驱动的实时 Todo 面板,以及已配置 prov
协作方式使用“询问 + 常规”。为兼容旧版混合 mode 列表,`session/set_mode` 仍接受
`default`(常规 + 询问)和 `auto`(常规 + Yolo),新客户端应使用拆分后的独立选择器。
## 远程 SSH
远程模块让 Reasonix 在远端主机上运行,并通过你自己的 SSH 连接访问它 —— 即 VS Code
Remote-SSH 式的体验。它在远端主机上引导一个常驻的 headless `reasonix serve`,把本地一个
回环端口转发过去,再经隧道打开现有的 serve Web 客户端。agent、工具与文件全部原生运行在远端
主机上,保真度 100%,不经过有损的文件代理。V1 支持 Linux 与 macOS 远端主机。
主机保存在 `config.toml` 的用户级 `[remote]` 段。与 `[secrets]` 一样,项目级
`reasonix.toml` 无法注入或覆盖远程主机 —— 克隆的仓库永远无法左右 Reasonix 向何处发起 SSH
连接。凭据沿用 provider 惯例:主机只记录环境变量名(`passphrase_env`、`password_env`),其值
存放在 Reasonix 全局 `.env` 中;私钥内容本身从不存储 —— `identity_file` 只是路径。
```toml
[remote]
[[remote.hosts]]
name = "gpu-box"
host = "203.0.113.7"
user = "dev"
identity_file = "~/.ssh/id_ed25519"
workspace = "~/projects/app"
serve_install = "auto" # auto | npm | upload | never
[[remote.hosts.forwards]]
type = "local" # local (-L) | remote (-R)
bind = "127.0.0.1:5432"
target = "127.0.0.1:5432"
```
命令行:
```bash
reasonix remote add gpu-box [email protected] --workspace '~/projects/app'
reasonix remote import --all # 从 ~/.ssh/config 导入(跳过 Match 块)
reasonix remote test gpu-box # 拨号 + 认证 + 主机密钥确认
reasonix remote connect gpu-box --open # 引导 serve、建隧道、打开 URL
reasonix remote serve status gpu-box
reasonix remote fs ls gpu-box:'~/projects/app'
```
`connect` 是前台守护(相当于 `ssh -N` 加上 serve 引导):它保持隧道与已配置的转发存活,断线时
以指数退避自动重连,并在重连后重新挂载转发。Ctrl-C 只断开本地一侧 —— 远端 serve 继续运行,
下次 `connect` 会复用它。V1 无后台守护进程。
主机密钥会对照你的 OpenSSH `~/.ssh/known_hosts`(只读)以及 Reasonix 托管的
`~/.reasonix/remote/known_hosts` 校验。首次见到的密钥会提示 TOFU 确认并记入托管文件;与已记录
密钥冲突的密钥会硬失败并指明出错的行,绝不自动接受。
远端侧状态位于远端主机的 `~/.reasonix/remote/`:`serve-<工作区 slug>.json`(pid、绑定的回环
地址、工作区)、`serve-<slug>.token`(0600;认证 token,经 `--token-file` 传给 serve,因此不会
出现在 `ps` 中)、`serve-<slug>.log`。
在桌面端,于 **设置 -> 远程 SSH** 管理主机,再通过状态栏徽标或主机行的 **远程浏览器** 按钮经
SFTP 浏览与编辑文件、管理端口转发、启动/打开远程工作区。打开工作区时会创建一个类似 VS Code
Remote SSH 的独立 Reasonix 原生窗口。主窗口持有 SSH 隧道;远程窗口是隔离的轻量外壳,不会恢复
或抢占本地对话会话。
## 自定义 OpenAI-compatible provider
在桌面端打开 **设置 -> 模型 -> 接入 -> 添加模型服务 -> 自定义供应商**,用于接入代理、
+9 -1
View File
@@ -44,12 +44,20 @@ reasonix/
├── permission/ # per-call Policy: allow/ask/deny rules → Decision
├── command/ # custom slash commands loaded from .reasonix/commands/*.md
├── plugin/ # stdio JSON-RPC (MCP) client; adapts remote tools
├── remote/ # SSH transport for the Remote-SSH module
│ ├── forward/ # -L / -R port-forward lifecycle
│ ├── sftpfs/ # SFTP file layer (quarantines pkg/sftp)
│ └── bootstrap/ # detached `reasonix serve` bootstrap over SSH
└── agent/ # Session + harness loop
```
Dependency direction (acyclic): `cli → {agent, plugin, config} → {tool, provider}`.
Built-in subpackages (`provider/openai`, `tool/builtin`) import their parent to
self-register; parents never import children.
self-register; parents never import children. The Remote-SSH module layers
`cli → remote/bootstrap → remote → {remote/forward, remote/sftpfs, config,
netclient}`; `remote` and its subpackages never import `cli`, `agent`, or
`serve`, and all interactivity flows through callbacks (host-key / secret
prompts) so the desktop module consumes the same surface. See §Remote below.
## 3. Core Abstractions
+3
View File
@@ -17,8 +17,10 @@ require (
github.com/bmatcuk/doublestar/v4 v4.10.0
github.com/charmbracelet/x/ansi v0.11.7
github.com/joho/godotenv v1.5.1
github.com/kevinburke/ssh_config v1.6.0
github.com/larksuite/oapi-sdk-go/v3 v3.9.5
github.com/mattn/go-runewidth v0.0.24
github.com/pkg/sftp v1.13.10
github.com/rivo/uniseg v0.4.7
github.com/sabhiram/go-gitignore v0.0.0-20210923224102-525f6e181f06
github.com/santhosh-tekuri/jsonschema/v6 v6.0.2
@@ -56,6 +58,7 @@ require (
github.com/godbus/dbus/v5 v5.2.2 // indirect
github.com/gogo/protobuf v1.3.2 // indirect
github.com/gorilla/websocket v1.5.0 // indirect
github.com/kr/fs v0.1.0 // indirect
github.com/lucasb-eyer/go-colorful v1.4.0 // indirect
github.com/mattn/go-pointer v0.0.1 // indirect
github.com/muesli/cancelreader v0.2.2 // indirect
+6
View File
@@ -67,8 +67,12 @@ github.com/hexops/gotextdiff v1.0.3 h1:gitA9+qJrrTCsiCl7+kh75nPqQt1cx4ZkudSTLoUq
github.com/hexops/gotextdiff v1.0.3/go.mod h1:pSWU5MAI3yDq+fZBTazCSJysOMbxWL1BSow5/V2vxeg=
github.com/joho/godotenv v1.5.1 h1:7eLL/+HRGLY0ldzfGMeQkb7vMd0as4CfYvUVzLqw0N0=
github.com/joho/godotenv v1.5.1/go.mod h1:f4LDr5Voq0i2e/R5DDNOoa2zzDfwtkZa6DnEwAbqwq4=
github.com/kevinburke/ssh_config v1.6.0 h1:J1FBfmuVosPHf5GRdltRLhPJtJpTlMdKTBjRgTaQBFY=
github.com/kevinburke/ssh_config v1.6.0/go.mod h1:q2RIzfka+BXARoNexmF9gkxEX7DmvbW9P4hIVx2Kg4M=
github.com/kisielk/errcheck v1.5.0/go.mod h1:pFxgyoBC7bSaBwPgfKdkLd5X25qrDl4LWUI2bnpBCr8=
github.com/kisielk/gotool v1.0.0/go.mod h1:XhKaO+MFFWcvkIS/tQcRk01m1F5IRFswLeQ+oQHNcck=
github.com/kr/fs v0.1.0 h1:Jskdu9ieNAYnjxsi0LbQp1ulIKZV1LAFgK1tWhpZgl8=
github.com/kr/fs v0.1.0/go.mod h1:FFnZGqtBN9Gxj7eW1uZ42v5BccTP0vu6NEaFoC2HwRg=
github.com/kr/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE=
github.com/kr/pretty v0.3.1/go.mod h1:hoEshYVHaxMs3cyo3Yncou5ZscifuDolrwPKZanG3xk=
github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY=
@@ -83,6 +87,8 @@ github.com/mattn/go-runewidth v0.0.24 h1:cpokDiIn0MGnhdHwuWnJBITySJ20QyNGnY2kR/a
github.com/mattn/go-runewidth v0.0.24/go.mod h1:XBkDxAl56ILZc9knddidhrOlY5R/pDhgLpndooCuJAs=
github.com/muesli/cancelreader v0.2.2 h1:3I4Kt4BQjOR54NavqnDogx/MIoWBFa0StPA8ELUXHmA=
github.com/muesli/cancelreader v0.2.2/go.mod h1:3XuTXfFS2VjM+HTLZY9Ak0l6eUKfijIfMUZ4EgX0QYo=
github.com/pkg/sftp v1.13.10 h1:+5FbKNTe5Z9aspU88DPIKJ9z2KZoaGCu6Sr6kKR/5mU=
github.com/pkg/sftp v1.13.10/go.mod h1:bJ1a7uDhrX/4OII+agvy28lzRvQrmIQuaHrcI1HbeGA=
github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
github.com/rivo/uniseg v0.4.7 h1:WUdvkW8uEhrYfLC4ZzdpI2ztxP1I582+49Oc5Mq64VQ=
+31
View File
@@ -3898,6 +3898,9 @@ func (m *chatTUI) runSlashCommand(input string) tea.Cmd {
case "/mcp":
m.echoLocalCommand(input)
m.runMCPSubcommand(input)
case "/remote":
m.echoLocalCommand(input)
m.showRemoteHosts()
case "/plugin", "/plugins":
m.echoLocalCommand(input)
m.runPluginSubcommand(input)
@@ -4401,6 +4404,34 @@ func (m *chatTUI) notice(note string) {
m.commitLine(dim(" · " + note))
}
// showRemoteHosts renders a read-only summary of configured remote hosts. The
// remote session lives in a `reasonix serve` on the remote host, so connecting
// happens from a terminal (`reasonix remote connect`), not inside this chat.
func (m *chatTUI) showRemoteHosts() {
cfg, err := config.Load()
if err != nil {
m.notice(err.Error())
return
}
if len(cfg.Remote.Hosts) == 0 {
m.notice(i18n.M.RemoteNoHostsHint)
return
}
var b strings.Builder
for _, h := range cfg.Remote.Hosts {
target := h.Host
if h.User != "" {
target = h.User + "@" + target
}
if h.Port != 0 && h.Port != 22 {
target = fmt.Sprintf("%s:%d", target, h.Port)
}
fmt.Fprintf(&b, " · %s %s\n", h.Name, target)
}
fmt.Fprintf(&b, " run `reasonix remote connect <name>` in a terminal to open the remote workspace")
m.commitLine(dim(b.String()))
}
// resolveRefs resolves a line's @references off the event loop via the
// controller, delivering a refsResolvedMsg with the tagged context block.
func (m *chatTUI) resolveRefs(sent, display, restore string) tea.Cmd {
+68 -5
View File
@@ -13,6 +13,7 @@ import (
"fmt"
"io"
"log/slog"
"net"
"net/url"
"os"
"os/signal"
@@ -119,6 +120,9 @@ func Run(args []string, version string) int {
case "mcp":
configureCLIThemeFromConfigNoProbe()
return mcpCommand(rest)
case "remote":
configureCLIThemeFromConfigNoProbe()
return remoteCommand(rest, version)
case "plugin":
configureCLIThemeFromConfigNoProbe()
return pluginCommand(rest)
@@ -169,7 +173,7 @@ func isDefaultInteractiveFlag(arg string) bool {
func shouldMigrateLegacyConfigForCLI(cmd string) bool {
switch cmd {
case "", "run", "chat", "code", "serve", "setup", "config", "init", "acp", "mcp", "plugin", "subagent", "doctor", "bot", "upgrade", "update":
case "", "run", "chat", "code", "serve", "setup", "config", "init", "acp", "mcp", "remote", "plugin", "subagent", "doctor", "bot", "upgrade", "update":
return true
default:
return false
@@ -667,6 +671,9 @@ func runServe(args []string) int {
password := fs.String("password", "", "password for auth=password (use --hash-password to store a hash instead)")
hashPassword := fs.Bool("hash-password", false, "print a bcrypt hash of --password and exit")
behindProxy := fs.Bool("behind-proxy", false, "trust X-Forwarded-For / X-Forwarded-Proto headers from a reverse proxy")
portFile := fs.String("port-file", "", "write the actual bound listen address (host:port) to this file after binding")
tokenFile := fs.String("token-file", "", "read the auth=token pre-shared token from this file (overrides --token; keeps the secret out of argv)")
pidFile := fs.String("pid-file", "", "write the server process id to this file")
if err := fs.Parse(args); err != nil {
return 2
}
@@ -703,6 +710,14 @@ func runServe(args []string) int {
if *token != "" {
serveCfg.Token = *token
}
if *tokenFile != "" {
tok, err := readServeTokenFile(*tokenFile)
if err != nil {
fmt.Fprintln(os.Stderr, i18n.M.ErrorPrefix, err)
return 1
}
serveCfg.Token = tok
}
if *behindProxy {
serveCfg.BehindProxy = true
}
@@ -781,14 +796,55 @@ func runServe(args []string) int {
srv := serve.New(ctrl, bc, serveCfg)
srv.SetSessionLeases(leases)
fmt.Printf("reasonix serve — %s on http://%s\n", ctrl.Label(), *addr)
// With --port-file the supervisor needs the real bound port (--addr may be
// 127.0.0.1:0), so listen first, record the address, then serve on the
// existing listener.
var ln net.Listener
displayAddr := *addr
if *portFile != "" {
var lerr error
ln, lerr = net.Listen("tcp", *addr)
if lerr != nil {
fmt.Fprintln(os.Stderr, i18n.M.ErrorPrefix, lerr)
return 1
}
displayAddr = ln.Addr().String()
if err := writeServeAddrFile(*portFile, displayAddr); err != nil {
_ = ln.Close()
fmt.Fprintln(os.Stderr, i18n.M.ErrorPrefix, err)
return 1
}
defer os.Remove(*portFile)
}
if *pidFile != "" {
if err := writeServePidFile(*pidFile); err != nil {
if ln != nil {
_ = ln.Close()
}
fmt.Fprintln(os.Stderr, i18n.M.ErrorPrefix, err)
return 1
}
defer os.Remove(*pidFile)
}
fmt.Printf("reasonix serve — %s on http://%s\n", ctrl.Label(), displayAddr)
if srv.AuthMode() == "token" {
fmt.Printf(" auth: token\n")
fmt.Printf(" share: http://%s/?token=%s\n", *addr, srv.AuthToken())
// Under --port-file the process is supervised (e.g. remote bootstrap):
// stdout is redirected to a log, so printing the token here would leak it
// into a file readable by other same-machine users. The supervisor
// already holds the token (it wrote --token-file), so suppress the share
// URL and print only the token-file reference.
if *portFile != "" && *tokenFile != "" {
fmt.Printf(" share: http://%s/ (token in %s)\n", displayAddr, *tokenFile)
} else {
fmt.Printf(" share: http://%s/?token=%s\n", displayAddr, srv.AuthToken())
}
} else if srv.AuthMode() == "password" {
fmt.Printf(" auth: password (login at http://%s/login)\n", *addr)
fmt.Printf(" auth: password (login at http://%s/login)\n", displayAddr)
}
if warning := serve.PlainHTTPAuthWarning(serveCfg, *addr); warning != "" {
if warning := serve.PlainHTTPAuthWarning(serveCfg, displayAddr); warning != "" {
fmt.Fprintf(os.Stderr, " %s\n", warning)
}
// Diagnostic: check whether balance endpoint is reachable
@@ -803,6 +859,13 @@ func runServe(args []string) int {
// Use graceful shutdown so SIGINT/SIGTERM drain active connections.
ctx, stop := signal.NotifyContext(context.Background(), os.Interrupt, syscall.SIGTERM)
defer stop()
if ln != nil {
if err := srv.RunGracefulListener(ctx, ln); err != nil {
fmt.Fprintln(os.Stderr, i18n.M.ErrorPrefix, err)
return 1
}
return 0
}
if err := srv.RunGraceful(ctx, *addr); err != nil {
fmt.Fprintln(os.Stderr, i18n.M.ErrorPrefix, err)
return 1
+283
View File
@@ -0,0 +1,283 @@
package cli
import (
"context"
"fmt"
"os"
"strings"
"text/tabwriter"
"time"
"reasonix/internal/config"
"reasonix/internal/i18n"
"reasonix/internal/remote"
)
// remoteCommand dispatches `reasonix remote <sub>`, mirroring mcpCommand.
func remoteCommand(args []string, version string) int {
if len(args) == 0 {
remoteUsage()
return 2
}
switch args[0] {
case "add":
return remoteAddCLI(args[1:])
case "list", "ls":
return remoteListCLI()
case "remove", "rm":
return remoteRemoveCLI(args[1:])
case "import":
return remoteImportCLI(args[1:])
case "test":
return remoteTestCLI(args[1:])
case "connect", "open":
return remoteConnectCLI(args, version)
case "status":
return remoteStatusCLI(args[1:])
case "forward":
return remoteForwardCLI(args[1:])
case "serve":
return remoteServeCLI(args[1:])
case "fs":
return remoteFSCLI(args[1:])
case "help", "-h", "--help":
remoteUsage()
return 0
default:
fmt.Fprintf(os.Stderr, "unknown remote subcommand %q\n\n", args[0])
remoteUsage()
return 2
}
}
// editUserConfig runs mutate against the user-global config file under the edit
// lock and saves it there. Remote hosts are user-global (pinned in
// LoadForRoot), so they must never be written to a project reasonix.toml.
func editUserConfig(mutate func(*config.Config) error) error {
unlock := config.LockUserConfigEdits()
defer unlock()
path := config.UserConfigPath()
if strings.TrimSpace(path) == "" {
return fmt.Errorf("cannot resolve user config path")
}
cfg := config.LoadForEdit(path)
if cfg == nil {
cfg = config.Default()
}
if err := mutate(cfg); err != nil {
return err
}
return cfg.SaveTo(path)
}
func remoteAddCLI(args []string) int {
// Positionals come first (name, target); Go's flag package stops at the
// first non-flag argument, so the flags are parsed from what follows.
if len(args) < 2 {
fmt.Fprintln(os.Stderr, "usage: reasonix remote add <name> [user@]host[:port] [flags]")
return 2
}
name, target := args[0], args[1]
fs := newFlagSet("remote add")
identity := fs.String("identity", "", "path to a private key file")
jump := fs.String("jump", "", "ProxyJump chain (OpenSSH syntax)")
workspace := fs.String("workspace", "", "default remote workspace directory")
useSSHConfig := fs.Bool("use-ssh-config", false, "layer ~/.ssh/config values under unset fields")
serveInstall := fs.String("serve-install", "auto", "serve install strategy: auto|npm|upload|never")
passphraseEnv := fs.String("passphrase-env", "", "env var name holding the key passphrase")
passwordEnv := fs.String("password-env", "", "env var name holding the login password")
if err := fs.Parse(args[2:]); err != nil {
return 2
}
user, host, port, err := remote.ParseTarget(target)
if err != nil {
fmt.Fprintln(os.Stderr, i18n.M.ErrorPrefix, err)
return 2
}
entry := config.RemoteHostEntry{
Name: name,
Host: host,
Port: port,
User: user,
IdentityFile: *identity,
ProxyJump: *jump,
Workspace: *workspace,
ServeInstall: *serveInstall,
UseSSHConfig: *useSSHConfig,
PassphraseEnv: *passphraseEnv,
PasswordEnv: *passwordEnv,
}
if err := editUserConfig(func(c *config.Config) error { return c.UpsertRemoteHost(entry) }); err != nil {
fmt.Fprintln(os.Stderr, i18n.M.ErrorPrefix, err)
return 1
}
fmt.Printf("added remote host %q (%s)\n", name, target)
return 0
}
func remoteListCLI() int {
cfg, err := config.Load()
if err != nil {
fmt.Fprintln(os.Stderr, i18n.M.ErrorPrefix, err)
return 1
}
if len(cfg.Remote.Hosts) == 0 {
fmt.Println(i18n.M.RemoteNoHostsHint)
return 0
}
w := tabwriter.NewWriter(os.Stdout, 0, 2, 2, ' ', 0)
fmt.Fprintln(w, "NAME\tTARGET\tWORKSPACE\tFORWARDS\tSSH-CONFIG")
for _, h := range cfg.Remote.Hosts {
target := h.Host
if h.User != "" {
target = h.User + "@" + target
}
if h.Port != 0 && h.Port != 22 {
target = fmt.Sprintf("%s:%d", target, h.Port)
}
ws := h.Workspace
if ws == "" {
ws = "-"
}
fmt.Fprintf(w, "%s\t%s\t%s\t%d\t%v\n", h.Name, target, ws, len(h.Forwards), h.UseSSHConfig)
}
_ = w.Flush()
return 0
}
func remoteRemoveCLI(args []string) int {
if len(args) != 1 {
fmt.Fprintln(os.Stderr, "usage: reasonix remote remove <name>")
return 2
}
name := args[0]
removed := false
if err := editUserConfig(func(c *config.Config) error {
removed = c.RemoveRemoteHost(name)
return nil
}); err != nil {
fmt.Fprintln(os.Stderr, i18n.M.ErrorPrefix, err)
return 1
}
if !removed {
fmt.Fprintf(os.Stderr, "no remote host named %q\n", name)
return 1
}
fmt.Printf("removed remote host %q\n", name)
return 0
}
func remoteImportCLI(args []string) int {
fs := newFlagSet("remote import")
all := fs.Bool("all", false, "import every concrete ~/.ssh/config alias")
if err := fs.Parse(args); err != nil {
return 2
}
src, err := remote.LoadUserSSHConfig()
if err != nil {
fmt.Fprintln(os.Stderr, i18n.M.ErrorPrefix, err)
return 1
}
candidates := src.Aliases()
if len(candidates) == 0 {
fmt.Println("no importable hosts found in ~/.ssh/config (Match blocks are not evaluated)")
return 0
}
wanted := map[string]bool{}
for _, a := range fs.Args() {
wanted[a] = true
}
imported := 0
err = editUserConfig(func(c *config.Config) error {
for _, cand := range candidates {
if !*all && len(wanted) > 0 && !wanted[cand.Alias] {
continue
}
if !*all && len(wanted) == 0 {
continue // neither --all nor explicit aliases: nothing to do
}
host := cand.HostName
if host == "" {
host = cand.Alias
}
entry := config.RemoteHostEntry{
Name: cand.Alias,
Host: host,
Port: cand.Port,
User: cand.User,
IdentityFile: cand.IdentityFile,
ProxyJump: cand.ProxyJump,
UseSSHConfig: true,
}
if err := c.UpsertRemoteHost(entry); err != nil {
return err
}
imported++
}
return nil
})
if err != nil {
fmt.Fprintln(os.Stderr, i18n.M.ErrorPrefix, err)
return 1
}
if imported == 0 {
fmt.Println("nothing imported; pass alias names or --all")
remotePrintImportCandidates(candidates)
return 0
}
fmt.Printf("imported %d host(s) from ~/.ssh/config\n", imported)
return 0
}
func remotePrintImportCandidates(cands []remote.ImportedHost) {
fmt.Println("available aliases:")
for _, c := range cands {
fmt.Printf(" %s\n", c.Alias)
}
}
func remoteTestCLI(args []string) int {
if len(args) != 1 {
fmt.Fprintln(os.Stderr, "usage: reasonix remote test <name|user@host>")
return 2
}
client, cleanup, err := buildRemoteClient(args[0])
if err != nil {
fmt.Fprintln(os.Stderr, i18n.M.ErrorPrefix, err)
return 1
}
defer cleanup()
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
defer cancel()
if err := client.Start(ctx); err != nil {
fmt.Fprintln(os.Stderr, i18n.M.ErrorPrefix, err)
return 1
}
defer client.Close()
res, err := client.Exec(ctx, "uname -sm && whoami")
if err != nil {
fmt.Fprintln(os.Stderr, i18n.M.ErrorPrefix, err)
return 1
}
fmt.Printf("connection OK\n%s", res.Stdout)
return 0
}
func remoteUsage() {
fmt.Println(`Manage remote SSH hosts and their persistent serve (user-global config).
Usage:
reasonix remote add <name> [user@]host[:port] [--identity F] [--jump SPEC]
[--workspace PATH] [--use-ssh-config] [--serve-install auto|npm|upload|never]
[--passphrase-env NAME] [--password-env NAME]
reasonix remote list
reasonix remote remove <name>
reasonix remote import [alias...|--all] # from ~/.ssh/config
reasonix remote test <name|user@host> # dial + auth + host-key check
reasonix remote connect <name> [--workspace PATH] [--local-port N] [--no-serve] [--open] [--forward-only]
reasonix remote open <name> # connect --open
reasonix remote status [<name>]
reasonix remote forward add <host> (-L|-R) <spec> | forward rm <host> <name> | forward ls <host>
reasonix remote serve start|stop|status|logs <name> [--workspace PATH] [-n N]
reasonix remote fs ls <name>:<path> | fs get <name>:<remote> [local] | fs put <local> <name>:<remote>`)
}
+93
View File
@@ -0,0 +1,93 @@
package cli
import (
"os"
"path/filepath"
"strings"
"testing"
"reasonix/internal/config"
)
func TestRemoteCommandUsageExit(t *testing.T) {
if got := remoteCommand(nil, "test"); got != 2 {
t.Errorf("no-arg remote exit = %d, want 2", got)
}
if got := remoteCommand([]string{"bogus"}, "test"); got != 2 {
t.Errorf("unknown subcommand exit = %d, want 2", got)
}
if got := remoteCommand([]string{"help"}, "test"); got != 0 {
t.Errorf("help exit = %d, want 0", got)
}
}
func TestRemoteAddListRemoveRoundTrip(t *testing.T) {
home := t.TempDir()
t.Setenv("REASONIX_HOME", home)
t.Setenv("HOME", home)
if got := remoteAddCLI([]string{"box", "[email protected]:2222", "--workspace", "~/app"}); got != 0 {
t.Fatalf("add exit = %d", got)
}
// Verify it landed in the user config, global-only.
cfg, err := config.Load()
if err != nil {
t.Fatal(err)
}
h, ok := cfg.RemoteHost("box")
if !ok {
t.Fatal("host not persisted")
}
if h.User != "dev" || h.Host != "10.0.0.9" || h.Port != 2222 || h.Workspace != "~/app" {
t.Fatalf("host fields wrong: %+v", h)
}
// Confirm it went to config.toml.
raw, _ := os.ReadFile(filepath.Join(home, "config.toml"))
if !strings.Contains(string(raw), "[[remote.hosts]]") || !strings.Contains(string(raw), `name = "box"`) {
t.Fatalf("config.toml missing remote host:\n%s", raw)
}
if got := remoteRemoveCLI([]string{"box"}); got != 0 {
t.Fatalf("remove exit = %d", got)
}
if got := remoteRemoveCLI([]string{"box"}); got != 1 {
t.Errorf("second remove exit = %d, want 1", got)
}
}
func TestRemoteForwardAddPersists(t *testing.T) {
home := t.TempDir()
t.Setenv("REASONIX_HOME", home)
t.Setenv("HOME", home)
if got := remoteAddCLI([]string{"box", "[email protected]"}); got != 0 {
t.Fatalf("add exit = %d", got)
}
if got := remoteForwardAdd([]string{"box", "-L", "8080:127.0.0.1:80"}); got != 0 {
t.Fatalf("forward add exit = %d", got)
}
cfg, _ := config.Load()
h, _ := cfg.RemoteHost("box")
if len(h.Forwards) != 1 || h.Forwards[0].Type != "local" || h.Forwards[0].Bind != "127.0.0.1:8080" {
t.Fatalf("forward not persisted: %+v", h.Forwards)
}
}
func TestSplitHostPath(t *testing.T) {
cases := []struct {
in string
host, path string
ok bool
}{
{"box:/home/dev/file", "box", "/home/dev/file", true},
{"box:file", "box", "file", true},
{"nocolon", "", "", false},
{":path", "", "", false},
{"box:", "", "", false},
}
for _, c := range cases {
h, p, ok := splitHostPath(c.in)
if ok != c.ok || h != c.host || p != c.path {
t.Errorf("splitHostPath(%q) = (%q,%q,%v), want (%q,%q,%v)", c.in, h, p, ok, c.host, c.path, c.ok)
}
}
}
+655
View File
@@ -0,0 +1,655 @@
package cli
import (
"context"
"flag"
"fmt"
"os"
"os/exec"
"os/signal"
"path"
"runtime"
"strings"
"syscall"
"time"
"golang.org/x/term"
"reasonix/internal/config"
"reasonix/internal/i18n"
"reasonix/internal/netclient"
"reasonix/internal/remote"
"reasonix/internal/remote/bootstrap"
"reasonix/internal/remote/forward"
)
func newFlagSet(name string) *flag.FlagSet {
fs := flag.NewFlagSet(name, flag.ContinueOnError)
return fs
}
// buildRemoteClient resolves nameOrTarget against config + ~/.ssh/config and
// returns a not-yet-started remote.Client with terminal-based secret and
// host-key prompts. cleanup releases transient resources.
func buildRemoteClient(nameOrTarget string) (*remote.Client, func(), error) {
cfg, err := config.Load()
if err != nil {
return nil, nil, err
}
sshCfg, _ := remote.LoadUserSSHConfig()
host, err := remote.ResolveHost(cfg, nameOrTarget, sshCfg)
if err != nil {
return nil, nil, err
}
auth := remoteAuthForHost(host, terminalSecretPrompt)
resolvedJumps, err := remote.ResolveJumpHosts(cfg, host.ProxyJump, sshCfg)
if err != nil {
return nil, nil, err
}
jumpHosts := make([]remote.JumpHostOptions, 0, len(resolvedJumps))
for _, jump := range resolvedJumps {
jumpHosts = append(jumpHosts, remote.JumpHostOptions{
Host: jump,
Auth: remoteAuthForHost(jump, terminalSecretPrompt),
})
}
policy := &remote.HostKeyPolicy{Prompt: terminalHostKeyPrompt}
// A misconfigured proxy is surfaced, not silently bypassed: a proxy is often
// a policy requirement, and quietly dialing direct could exfiltrate the
// connection around it.
dialer, derr := netclient.NewStreamDialer(cfg.NetworkProxySpec())
if derr != nil {
return nil, nil, fmt.Errorf("remote: network proxy is misconfigured: %w", derr)
}
client, err := remote.New(remote.Options{
Host: host,
Auth: auth,
JumpHosts: jumpHosts,
HostKeys: policy,
Dialer: dialer,
})
if err != nil {
return nil, nil, err
}
return client, func() {}, nil
}
func remoteAuthForHost(host remote.ResolvedHost, prompt func(context.Context, remote.SecretKind, string) (string, error)) remote.AuthOptions {
auth := remote.AuthOptions{SecretPrompt: prompt}
if host.PassphraseEnv != "" {
env := host.PassphraseEnv
auth.Passphrase = func() (string, error) { return config.ResolveCredential(env).Value, nil }
}
if host.PasswordEnv != "" {
env := host.PasswordEnv
auth.Password = func() (string, error) { return config.ResolveCredential(env).Value, nil }
}
return auth
}
func terminalSecretPrompt(_ context.Context, kind remote.SecretKind, host string) (string, error) {
var label string
switch kind {
case remote.SecretPassword:
label = fmt.Sprintf(i18n.M.RemotePasswordPromptFmt, host)
default:
label = fmt.Sprintf(i18n.M.RemotePassphrasePromptFmt, host)
}
fmt.Fprint(os.Stderr, label+" ")
if !term.IsTerminal(int(os.Stdin.Fd())) {
return "", fmt.Errorf("cannot prompt for %s: not a terminal", kind)
}
b, err := term.ReadPassword(int(os.Stdin.Fd()))
fmt.Fprintln(os.Stderr)
if err != nil {
return "", err
}
return string(b), nil
}
func terminalHostKeyPrompt(_ context.Context, q remote.HostKeyQuestion) (bool, error) {
fmt.Fprintf(os.Stderr, i18n.M.RemoteHostKeyPromptFmt+"\n", q.Host, q.KeyType, q.Fingerprint)
fmt.Fprint(os.Stderr, "Accept and continue? [y/N] ")
var answer string
_, _ = fmt.Fscanln(os.Stdin, &answer)
answer = strings.ToLower(strings.TrimSpace(answer))
return answer == "y" || answer == "yes", nil
}
// remoteConnectCLI runs a foreground supervisor: connect, bootstrap serve,
// forward the serve port and configured forwards, and hold the tunnel until
// Ctrl-C. The remote serve keeps running after disconnect.
func remoteConnectCLI(args []string, version string) int {
// args[0] is "connect" or "open".
openAlias := args[0] == "open"
fs := newFlagSet("remote connect")
workspace := fs.String("workspace", "", "remote workspace directory")
localPort := fs.Int("local-port", 0, "local port to bind for the serve tunnel (0 = auto)")
noServe := fs.Bool("no-serve", false, "only establish forwards; do not bootstrap serve")
open := fs.Bool("open", openAlias, "print/open the serve URL")
forwardOnly := fs.Bool("forward-only", false, "apply configured forwards only; no serve")
if err := fs.Parse(args[1:]); err != nil {
return 2
}
rest := fs.Args()
if len(rest) != 1 {
fmt.Fprintln(os.Stderr, "usage: reasonix remote connect <name> [flags]")
return 2
}
name := rest[0]
cfg, err := config.Load()
if err != nil {
fmt.Fprintln(os.Stderr, i18n.M.ErrorPrefix, err)
return 1
}
entry, _ := cfg.RemoteHost(name)
ws := *workspace
if ws == "" {
ws = entry.Workspace
}
client, cleanup, err := buildRemoteClient(name)
if err != nil {
fmt.Fprintln(os.Stderr, i18n.M.ErrorPrefix, err)
return 1
}
defer cleanup()
// Live status line.
client.Subscribe(func(ev remote.StatusEvent) {
switch ev.Status {
case remote.StatusConnecting:
fmt.Fprintf(os.Stderr, i18n.M.RemoteConnectingFmt+"\n", name)
case remote.StatusConnected:
fmt.Fprintf(os.Stderr, i18n.M.RemoteConnectedFmt+"\n", name)
case remote.StatusReconnecting:
fmt.Fprintf(os.Stderr, i18n.M.RemoteReconnectingFmt+"\n", name, ev.Attempt)
case remote.StatusDegraded:
fmt.Fprintf(os.Stderr, i18n.M.RemoteDegradedFmt+"\n", name)
case remote.StatusStopped:
if ev.Err != nil {
fmt.Fprintf(os.Stderr, "%s %v\n", i18n.M.ErrorPrefix, ev.Err)
}
}
})
ctx, stop := signal.NotifyContext(context.Background(), os.Interrupt, syscall.SIGTERM)
defer stop()
if err := client.Start(ctx); err != nil {
fmt.Fprintln(os.Stderr, i18n.M.ErrorPrefix, err)
return 1
}
defer client.Close()
// Apply configured forwards.
if err := applyConfiguredForwards(client, entry); err != nil {
fmt.Fprintf(os.Stderr, "%s %v\n", i18n.M.ErrorPrefix, err)
}
if !*noServe && !*forwardOnly {
res, err := bootstrap.EnsureServe(ctx, client, bootstrap.Options{
Workspace: ws,
Install: entry.ServeInstallMode(),
LocalBinary: currentExecutable(),
LocalGOOS: runtime.GOOS,
LocalGOARCH: runtime.GOARCH,
MinVersion: bootstrap.MinServeVersion,
Progress: func(step, detail string) {
fmt.Fprintf(os.Stderr, i18n.M.RemoteBootstrapStepFmt+"\n", step, detail)
},
})
if err != nil {
fmt.Fprintln(os.Stderr, i18n.M.ErrorPrefix, err)
return 1
}
localURL, ferr := forwardServe(client, res.State.Addr, *localPort, res.Token)
if ferr != nil {
fmt.Fprintln(os.Stderr, i18n.M.ErrorPrefix, ferr)
return 1
}
fmt.Printf(i18n.M.RemoteServeReadyFmt+"\n", localURL)
if *open {
_ = openInBrowser(localURL)
}
}
fmt.Fprintln(os.Stderr, "Press Ctrl-C to disconnect (remote serve keeps running).")
<-ctx.Done()
fmt.Fprintln(os.Stderr, i18n.M.RemoteDisconnected)
return 0
}
func applyConfiguredForwards(client *remote.Client, entry config.RemoteHostEntry) error {
set := client.Forwards()
var firstErr error
for _, f := range entry.Forwards {
dir := forward.Local
if strings.EqualFold(f.Type, "remote") {
dir = forward.Remote
}
spec := forward.Spec{Direction: dir, BindAddr: normalizeBind(f.Bind), TargetAddr: f.Target}
if _, err := set.Add(spec); err != nil && firstErr == nil {
firstErr = err
}
}
return firstErr
}
// forwardServe adds the reserved "serve" local forward to the remote serve
// address and returns the local URL (with token).
func forwardServe(client *remote.Client, remoteAddr string, localPort int, token string) (string, error) {
bind := "127.0.0.1:0"
if localPort > 0 {
bind = fmt.Sprintf("127.0.0.1:%d", localPort)
}
bound, err := client.Forwards().Add(forward.Spec{
Name: "serve",
Direction: forward.Local,
BindAddr: bind,
TargetAddr: remoteAddr,
})
if err != nil {
return "", err
}
return fmt.Sprintf("http://%s/?token=%s", bound, token), nil
}
func normalizeBind(bind string) string {
if !strings.Contains(bind, ":") {
return "127.0.0.1:" + bind
}
return bind
}
func currentExecutable() string {
if p, err := os.Executable(); err == nil {
return p
}
return ""
}
// remoteServeCLI: serve start|stop|status|logs <name>.
func remoteServeCLI(args []string) int {
if len(args) < 2 {
fmt.Fprintln(os.Stderr, "usage: reasonix remote serve start|stop|status|logs <name> [--workspace PATH] [-n N]")
return 2
}
action := args[0]
fs := newFlagSet("remote serve")
workspace := fs.String("workspace", "", "remote workspace directory")
n := fs.Int("n", 200, "log lines to show (logs)")
if err := fs.Parse(args[2:]); err != nil {
return 2
}
name := args[1]
cfg, err := config.Load()
if err != nil {
fmt.Fprintln(os.Stderr, i18n.M.ErrorPrefix, err)
return 1
}
entry, _ := cfg.RemoteHost(name)
ws := *workspace
if ws == "" {
ws = entry.Workspace
}
client, cleanup, err := buildRemoteClient(name)
if err != nil {
fmt.Fprintln(os.Stderr, i18n.M.ErrorPrefix, err)
return 1
}
defer cleanup()
ctx, cancel := context.WithTimeout(context.Background(), 60*time.Second)
defer cancel()
if err := client.Start(ctx); err != nil {
fmt.Fprintln(os.Stderr, i18n.M.ErrorPrefix, err)
return 1
}
defer client.Close()
switch action {
case "start":
res, err := bootstrap.EnsureServe(ctx, client, bootstrap.Options{
Workspace: ws, Install: entry.ServeInstallMode(),
LocalBinary: currentExecutable(), LocalGOOS: runtime.GOOS, LocalGOARCH: runtime.GOARCH,
MinVersion: bootstrap.MinServeVersion,
})
if err != nil {
fmt.Fprintln(os.Stderr, i18n.M.ErrorPrefix, err)
return 1
}
fmt.Printf("serve running on remote %s (pid %d)\n", res.State.Addr, res.State.PID)
return 0
case "stop":
if err := bootstrap.Stop(ctx, client, ws); err != nil {
fmt.Fprintln(os.Stderr, i18n.M.ErrorPrefix, err)
return 1
}
fmt.Println("serve stopped")
return 0
case "status":
st, alive, err := bootstrap.Status(ctx, client, ws)
if err != nil {
fmt.Fprintln(os.Stderr, i18n.M.ErrorPrefix, err)
return 1
}
if st.PID == 0 {
fmt.Println("no serve recorded for this workspace")
return 0
}
fmt.Printf("pid=%d addr=%s alive=%v workspace=%s\n", st.PID, st.Addr, alive, st.Workspace)
return 0
case "logs":
if err := bootstrap.Logs(ctx, client, ws, *n, os.Stdout); err != nil {
fmt.Fprintln(os.Stderr, i18n.M.ErrorPrefix, err)
return 1
}
return 0
default:
fmt.Fprintf(os.Stderr, "unknown serve action %q\n", action)
return 2
}
}
// remoteStatusCLI prints configured host summaries; with a name it also does a
// brief liveness probe.
func remoteStatusCLI(args []string) int {
cfg, err := config.Load()
if err != nil {
fmt.Fprintln(os.Stderr, i18n.M.ErrorPrefix, err)
return 1
}
if len(args) == 0 {
return remoteListCLI()
}
name := args[0]
entry, ok := cfg.RemoteHost(name)
if !ok {
fmt.Fprintf(os.Stderr, "no remote host named %q\n", name)
return 1
}
fmt.Printf("host %s: %s@%s workspace=%s\n", entry.Name, entry.User, entry.Host, entry.Workspace)
return 0
}
// remoteForwardCLI manages persisted forward rules (applied on next connect).
func remoteForwardCLI(args []string) int {
if len(args) < 2 {
fmt.Fprintln(os.Stderr, "usage: reasonix remote forward add <host> (-L|-R) <spec> | rm <host> <name> | ls <host>")
return 2
}
switch args[0] {
case "ls":
return remoteForwardLs(args[1])
case "add":
return remoteForwardAdd(args[1:])
case "rm":
return remoteForwardRm(args[1:])
default:
fmt.Fprintf(os.Stderr, "unknown forward action %q\n", args[0])
return 2
}
}
func remoteForwardLs(host string) int {
cfg, err := config.Load()
if err != nil {
fmt.Fprintln(os.Stderr, i18n.M.ErrorPrefix, err)
return 1
}
entry, ok := cfg.RemoteHost(host)
if !ok {
fmt.Fprintf(os.Stderr, "no remote host named %q\n", host)
return 1
}
if len(entry.Forwards) == 0 {
fmt.Println("no forwards configured")
return 0
}
for _, f := range entry.Forwards {
fmt.Printf("%s\t%s -> %s\n", f.Type, f.Bind, f.Target)
}
return 0
}
func remoteForwardAdd(args []string) int {
if len(args) != 3 {
fmt.Fprintln(os.Stderr, "usage: reasonix remote forward add <host> (-L|-R) <spec>")
return 2
}
host := args[0]
dir, err := forward.ParseDirection(args[1])
if err != nil {
fmt.Fprintln(os.Stderr, i18n.M.ErrorPrefix, err)
return 2
}
spec, err := forward.ParseShorthand(dir, args[2])
if err != nil {
fmt.Fprintln(os.Stderr, i18n.M.ErrorPrefix, err)
return 2
}
if spec.NonLoopbackBind() {
fmt.Fprintln(os.Stderr, "warning: bind address is not loopback; the forward will be reachable off-machine")
}
ftype := "local"
if dir == forward.Remote {
ftype = "remote"
}
found := false
err = editUserConfig(func(c *config.Config) error {
entry, ok := c.RemoteHost(host)
if !ok {
return fmt.Errorf("no remote host named %q", host)
}
entry.Forwards = append(entry.Forwards, config.RemoteForwardEntry{Type: ftype, Bind: spec.BindAddr, Target: spec.TargetAddr})
found = true
return c.UpsertRemoteHost(entry)
})
if err != nil {
fmt.Fprintln(os.Stderr, i18n.M.ErrorPrefix, err)
return 1
}
_ = found
fmt.Printf("added %s forward %s -> %s to %q (takes effect on next connect)\n", ftype, spec.BindAddr, spec.TargetAddr, host)
return 0
}
func remoteForwardRm(args []string) int {
if len(args) != 2 {
fmt.Fprintln(os.Stderr, "usage: reasonix remote forward rm <host> <bind>")
return 2
}
host, bind := args[0], args[1]
removed := false
err := editUserConfig(func(c *config.Config) error {
entry, ok := c.RemoteHost(host)
if !ok {
return fmt.Errorf("no remote host named %q", host)
}
kept := entry.Forwards[:0]
for _, f := range entry.Forwards {
if f.Bind == bind || normalizeBind(f.Bind) == normalizeBind(bind) {
removed = true
continue
}
kept = append(kept, f)
}
entry.Forwards = kept
return c.UpsertRemoteHost(entry)
})
if err != nil {
fmt.Fprintln(os.Stderr, i18n.M.ErrorPrefix, err)
return 1
}
if !removed {
fmt.Fprintf(os.Stderr, "no forward bound to %q on %q\n", bind, host)
return 1
}
fmt.Printf("removed forward %s from %q\n", bind, host)
return 0
}
// remoteFSCLI: fs ls|get|put with <name>:<path> operands.
func remoteFSCLI(args []string) int {
if len(args) < 2 {
fmt.Fprintln(os.Stderr, "usage: reasonix remote fs ls <name>:<path> | get <name>:<remote> [local] | put <local> <name>:<remote>")
return 2
}
switch args[0] {
case "ls":
return remoteFSLs(args[1])
case "get":
return remoteFSGet(args[1:])
case "put":
return remoteFSPut(args[1:])
default:
fmt.Fprintf(os.Stderr, "unknown fs action %q\n", args[0])
return 2
}
}
func splitHostPath(s string) (host, p string, ok bool) {
i := strings.Index(s, ":")
if i <= 0 || i == len(s)-1 {
return "", "", false
}
return s[:i], s[i+1:], true
}
func withRemoteFS(name string, fn func(ctx context.Context, client *remote.Client) int) int {
client, cleanup, err := buildRemoteClient(name)
if err != nil {
fmt.Fprintln(os.Stderr, i18n.M.ErrorPrefix, err)
return 1
}
defer cleanup()
ctx, cancel := context.WithTimeout(context.Background(), 60*time.Second)
defer cancel()
if err := client.Start(ctx); err != nil {
fmt.Fprintln(os.Stderr, i18n.M.ErrorPrefix, err)
return 1
}
defer client.Close()
return fn(ctx, client)
}
func remoteFSLs(operand string) int {
host, p, ok := splitHostPath(operand)
if !ok {
fmt.Fprintln(os.Stderr, "usage: reasonix remote fs ls <name>:<path>")
return 2
}
return withRemoteFS(host, func(ctx context.Context, client *remote.Client) int {
fsys, err := client.SFTP()
if err != nil {
fmt.Fprintln(os.Stderr, i18n.M.ErrorPrefix, err)
return 1
}
entries, err := fsys.List(ctx, p)
if err != nil {
fmt.Fprintln(os.Stderr, i18n.M.ErrorPrefix, err)
return 1
}
for _, e := range entries {
suffix := ""
if e.IsDir {
suffix = "/"
}
fmt.Printf("%s%s\n", e.Name, suffix)
}
return 0
})
}
func remoteFSGet(args []string) int {
if len(args) < 1 || len(args) > 2 {
fmt.Fprintln(os.Stderr, "usage: reasonix remote fs get <name>:<remote> [local]")
return 2
}
host, remotePath, ok := splitHostPath(args[0])
if !ok {
fmt.Fprintln(os.Stderr, "usage: reasonix remote fs get <name>:<remote> [local]")
return 2
}
localPath := path.Base(remotePath)
if len(args) >= 2 {
localPath = args[1]
}
return withRemoteFS(host, func(ctx context.Context, client *remote.Client) int {
fsys, err := client.SFTP()
if err != nil {
fmt.Fprintln(os.Stderr, i18n.M.ErrorPrefix, err)
return 1
}
// Stream the full file to disk — never the capped preview reader, which
// would silently truncate large downloads.
out, err := os.Create(localPath)
if err != nil {
fmt.Fprintln(os.Stderr, i18n.M.ErrorPrefix, err)
return 1
}
n, err := fsys.Download(ctx, remotePath, out)
if cerr := out.Close(); cerr != nil && err == nil {
err = cerr
}
if err != nil {
_ = os.Remove(localPath)
fmt.Fprintln(os.Stderr, i18n.M.ErrorPrefix, err)
return 1
}
fmt.Printf("wrote %s (%d bytes)\n", localPath, n)
return 0
})
}
func remoteFSPut(args []string) int {
if len(args) != 2 {
fmt.Fprintln(os.Stderr, "usage: reasonix remote fs put <local> <name>:<remote>")
return 2
}
localPath := args[0]
host, remotePath, ok := splitHostPath(args[1])
if !ok {
fmt.Fprintln(os.Stderr, "usage: reasonix remote fs put <local> <name>:<remote>")
return 2
}
in, err := os.Open(localPath)
if err != nil {
fmt.Fprintln(os.Stderr, i18n.M.ErrorPrefix, err)
return 1
}
defer in.Close()
return withRemoteFS(host, func(ctx context.Context, client *remote.Client) int {
fsys, err := client.SFTP()
if err != nil {
fmt.Fprintln(os.Stderr, i18n.M.ErrorPrefix, err)
return 1
}
n, err := fsys.UploadAtomic(ctx, remotePath, in, 0o644)
if err != nil {
fmt.Fprintln(os.Stderr, i18n.M.ErrorPrefix, err)
return 1
}
fmt.Printf("uploaded %s -> %s (%d bytes)\n", localPath, remotePath, n)
return 0
})
}
func openInBrowser(url string) error {
var cmd string
var args []string
switch runtime.GOOS {
case "darwin":
cmd, args = "open", []string{url}
case "windows":
cmd, args = "rundll32", []string{"url.dll,FileProtocolHandler", url}
default:
cmd, args = "xdg-open", []string{url}
}
c := exec.Command(cmd, args...)
return c.Start()
}
+61
View File
@@ -0,0 +1,61 @@
package cli
import (
"fmt"
"io"
"os"
"runtime"
"strconv"
"strings"
"reasonix/internal/fileutil"
)
// readServeTokenFile loads the auth=token pre-shared token from a file so the
// secret never appears in argv (visible via ps). The file must hold a single
// non-empty line and, on POSIX systems, must not be group/world accessible.
func readServeTokenFile(path string) (string, error) {
f, err := os.Open(path)
if err != nil {
return "", err
}
defer f.Close()
fi, err := f.Stat()
if err != nil {
return "", err
}
if !fi.Mode().IsRegular() {
return "", fmt.Errorf("token file %s must be a regular file", path)
}
if runtime.GOOS != "windows" && fi.Mode().Perm()&0o077 != 0 {
return "", fmt.Errorf("token file %s must not be group/world accessible (chmod 600)", path)
}
b, err := io.ReadAll(io.LimitReader(f, (64<<10)+1))
if err != nil {
return "", err
}
if len(b) > 64<<10 {
return "", fmt.Errorf("token file %s is too large", path)
}
tok := strings.TrimSpace(string(b))
if tok == "" {
return "", fmt.Errorf("token file %s is empty", path)
}
if strings.ContainsAny(tok, "\r\n") {
return "", fmt.Errorf("token file %s must hold a single line", path)
}
return tok, nil
}
// writeServeAddrFile records the actual bound listen address (host:port) so a
// supervisor that started serve with --addr 127.0.0.1:0 can discover the real
// port. Written atomically with owner-only permissions.
func writeServeAddrFile(path, addr string) error {
return fileutil.AtomicWriteFile(path, []byte(addr+"\n"), 0o600)
}
// writeServePidFile records the server's pid for supervisors that cannot
// capture the shell's $! (or want a belt-and-braces check).
func writeServePidFile(path string) error {
return fileutil.AtomicWriteFile(path, []byte(strconv.Itoa(os.Getpid())+"\n"), 0o600)
}
+90
View File
@@ -0,0 +1,90 @@
package cli
import (
"os"
"path/filepath"
"runtime"
"strings"
"testing"
)
func TestReadServeTokenFileTrimsSingleLine(t *testing.T) {
p := filepath.Join(t.TempDir(), "token")
if err := os.WriteFile(p, []byte(" s3cret-token \n"), 0o600); err != nil {
t.Fatal(err)
}
tok, err := readServeTokenFile(p)
if err != nil {
t.Fatalf("readServeTokenFile: %v", err)
}
if tok != "s3cret-token" {
t.Fatalf("token = %q, want s3cret-token", tok)
}
}
func TestReadServeTokenFileRejectsEmptyAndMultiline(t *testing.T) {
dir := t.TempDir()
empty := filepath.Join(dir, "empty")
if err := os.WriteFile(empty, []byte(" \n"), 0o600); err != nil {
t.Fatal(err)
}
if _, err := readServeTokenFile(empty); err == nil {
t.Fatal("empty token file accepted")
}
multi := filepath.Join(dir, "multi")
if err := os.WriteFile(multi, []byte("a\nb\n"), 0o600); err != nil {
t.Fatal(err)
}
if _, err := readServeTokenFile(multi); err == nil {
t.Fatal("multi-line token file accepted")
}
}
func TestReadServeTokenFileRejectsLoosePermissions(t *testing.T) {
if runtime.GOOS == "windows" {
t.Skip("POSIX permission check")
}
p := filepath.Join(t.TempDir(), "token")
if err := os.WriteFile(p, []byte("tok\n"), 0o644); err != nil {
t.Fatal(err)
}
if _, err := readServeTokenFile(p); err == nil || !strings.Contains(err.Error(), "chmod 600") {
t.Fatalf("loose-permission token file accepted (err=%v)", err)
}
}
func TestWriteServeAddrAndPidFiles(t *testing.T) {
dir := t.TempDir()
addrPath := filepath.Join(dir, "port")
if err := writeServeAddrFile(addrPath, "127.0.0.1:43210"); err != nil {
t.Fatalf("writeServeAddrFile: %v", err)
}
b, err := os.ReadFile(addrPath)
if err != nil {
t.Fatal(err)
}
if got := strings.TrimSpace(string(b)); got != "127.0.0.1:43210" {
t.Fatalf("addr file = %q", got)
}
if runtime.GOOS != "windows" {
fi, err := os.Stat(addrPath)
if err != nil {
t.Fatal(err)
}
if fi.Mode().Perm()&0o077 != 0 {
t.Fatalf("addr file perm = %v, want owner-only", fi.Mode().Perm())
}
}
pidPath := filepath.Join(dir, "pid")
if err := writeServePidFile(pidPath); err != nil {
t.Fatalf("writeServePidFile: %v", err)
}
b, err = os.ReadFile(pidPath)
if err != nil {
t.Fatal(err)
}
if strings.TrimSpace(string(b)) == "" {
t.Fatal("pid file empty")
}
}
+1
View File
@@ -32,6 +32,7 @@ func builtinSlashSpecs() []builtinSlashSpec {
{name: "/switch", insert: "/switch ", hint: i18n.M.CmdSwitchBranch, showInHelp: true},
{name: "/todo", insert: "/todo", hint: i18n.M.CmdTodo, showInHelp: true},
{name: "/mcp", insert: "/mcp", hint: i18n.M.CmdMcp, showInHelp: true},
{name: "/remote", insert: "/remote", hint: i18n.M.CmdRemote, showInHelp: true},
{name: "/plugins", aliases: []string{"/plugin"}, insert: "/plugins", hint: i18n.M.CmdPlugins, showInHelp: true},
{name: "/model", insert: "/model", hint: i18n.M.CmdModel, descend: true, showInHelp: true},
{name: "/status", insert: "/status", hint: i18n.M.CmdStatus, showInHelp: true},
+1
View File
@@ -60,6 +60,7 @@ type Config struct {
Bot BotConfig `toml:"bot"`
Serve ServeConfig `toml:"serve"`
Secrets SecretsConfig `toml:"secrets"`
Remote RemoteConfig `toml:"remote"`
providerSources map[string]providerSourceScope
shadowedProjectProviders []ProviderEntry
+4
View File
@@ -174,6 +174,10 @@ func credentialEnvNamesFromConfig(cfg *Config) []string {
add(conn.Credential.AppSecretEnv)
add(conn.Credential.TokenEnv)
}
for _, h := range cfg.Remote.Hosts {
add(h.PassphraseEnv)
add(h.PasswordEnv)
}
sort.Strings(out)
return out
}
+5
View File
@@ -73,6 +73,7 @@ func loadForRoot(root string, migrateOnDisk bool) (*Config, error) {
}
userDefaultModel := cfg.DefaultModel
globalSecrets := cfg.Secrets
globalRemote := cfg.Remote.Clone()
tomlSources = append(tomlSources, projectTOML)
if err := mergeTOML(cfg, projectTOML); err != nil {
@@ -82,6 +83,10 @@ func loadForRoot(root string, migrateOnDisk bool) (*Config, error) {
// reasonix.toml must not be able to flip on the workflow-breaking env/path
// protections.
cfg.Secrets = globalSecrets
// Remote SSH hosts are equally user-global: a cloned repo's reasonix.toml
// must not be able to inject hosts, jump chains, or port forwards that
// steer where Reasonix opens connections.
cfg.Remote = globalRemote
// TOML decoding replaces [[plugins]] wholesale, so cfg.Plugins now holds
// only the last file's. Re-merge by name across all sources (later wins) so a
// project reasonix.toml doesn't drop the global config's MCP servers.
+22
View File
@@ -312,6 +312,28 @@ func appendUniquePath(paths []string, path string) []string {
// unavailable.
func ReasonixHomeDir() string { return reasonixHomeDir() }
// RemoteStateDir is local state for the remote-SSH module (the managed
// known_hosts file, cached host metadata): <Reasonix home>/remote. Routed
// through the home resolver so REASONIX_HOME isolation holds.
func RemoteStateDir() string {
home := reasonixHomeDir()
if strings.TrimSpace(home) == "" {
return ""
}
return filepath.Join(home, "remote")
}
// RemoteKnownHostsPath is the Reasonix-managed known_hosts file (OpenSSH
// format) that records TOFU-accepted host keys. The user's own
// ~/.ssh/known_hosts is only ever read, never written.
func RemoteKnownHostsPath() string {
dir := RemoteStateDir()
if dir == "" {
return ""
}
return filepath.Join(dir, "known_hosts")
}
// WorkspaceLeaseDir stores cross-process Delivery writer locks outside user
// workspaces. It intentionally follows the cache root rather than project or
// session state: taking a lease must never dirty the repository it protects.
+183
View File
@@ -0,0 +1,183 @@
package config
import (
"fmt"
"net"
"strconv"
"strings"
)
// RemoteConfig is the [remote] section: SSH hosts the remote module may
// connect to, and their default forwards/workspaces. Like [secrets] it is a
// user-global security control — LoadForRoot pins it back to the user config
// after the project merge so a cloned repo's reasonix.toml can never inject
// hosts, jump chains, or forwards.
type RemoteConfig struct {
// ImportSSHConfig surfaces ~/.ssh/config aliases in `reasonix remote import`.
ImportSSHConfig bool `toml:"import_ssh_config"`
Hosts []RemoteHostEntry `toml:"hosts"`
}
// RemoteHostEntry describes one SSH target. Secrets follow the provider
// idiom: the entry names credential env vars (passphrase_env/password_env);
// values live in Reasonix's global .env, never in TOML. identity_file is a
// path — private key material itself is never stored by Reasonix.
type RemoteHostEntry struct {
Name string `toml:"name"`
Host string `toml:"host"`
Port int `toml:"port"` // 0 => 22 (or ssh_config value)
User string `toml:"user"`
IdentityFile string `toml:"identity_file"`
PassphraseEnv string `toml:"passphrase_env"`
PasswordEnv string `toml:"password_env"`
ProxyJump string `toml:"proxy_jump"` // OpenSSH ProxyJump syntax, comma-separated chain
Workspace string `toml:"workspace"` // default remote workspace dir
ServeInstall string `toml:"serve_install"` // auto|npm|upload|never
UseSSHConfig bool `toml:"use_ssh_config"` // layer ~/.ssh/config values under unset fields
Forwards []RemoteForwardEntry `toml:"forwards"`
}
// RemoteForwardEntry is a persisted port-forward rule applied on connect.
type RemoteForwardEntry struct {
Type string `toml:"type"` // "local" (-L) | "remote" (-R)
Bind string `toml:"bind"` // "127.0.0.1:8080" or bare port => 127.0.0.1:<port>
Target string `toml:"target"` // host:port on the other side
}
// RemoteServeInstallModes are the accepted serve_install values.
var RemoteServeInstallModes = []string{"auto", "npm", "upload", "never"}
// Clone returns a deep copy. The global-only pin in loadForRoot must capture
// the pre-project-merge value, but TOML decoding mutates existing slice
// backing arrays in place — a shallow struct copy would alias Hosts (and each
// host's Forwards) and let a project reasonix.toml overwrite the "restored"
// global entries.
func (r RemoteConfig) Clone() RemoteConfig {
out := r
if r.Hosts != nil {
out.Hosts = make([]RemoteHostEntry, len(r.Hosts))
for i, h := range r.Hosts {
h.Forwards = append([]RemoteForwardEntry(nil), h.Forwards...)
out.Hosts[i] = h
}
}
return out
}
// ServeInstallMode returns the normalized install strategy, defaulting to auto.
func (e RemoteHostEntry) ServeInstallMode() string {
m := strings.ToLower(strings.TrimSpace(e.ServeInstall))
if m == "" {
return "auto"
}
return m
}
// PortOrDefault returns the configured port, defaulting to 22.
func (e RemoteHostEntry) PortOrDefault() int {
if e.Port > 0 {
return e.Port
}
return 22
}
func validateRemoteHost(e RemoteHostEntry) error {
if strings.TrimSpace(e.Name) == "" {
return fmt.Errorf("remote host: name is required")
}
if strings.ContainsAny(e.Name, " \t/:@") {
return fmt.Errorf("remote host %q: name must not contain spaces, '/', ':' or '@'", e.Name)
}
if strings.TrimSpace(e.Host) == "" {
return fmt.Errorf("remote host %q: host is required", e.Name)
}
if e.Port < 0 || e.Port > 65535 {
return fmt.Errorf("remote host %q: port %d out of range", e.Name, e.Port)
}
switch e.ServeInstallMode() {
case "auto", "npm", "upload", "never":
default:
return fmt.Errorf("remote host %q: serve_install must be one of auto|npm|upload|never", e.Name)
}
seenBinds := map[string]bool{}
for _, f := range e.Forwards {
kind := strings.ToLower(strings.TrimSpace(f.Type))
switch kind {
case "local", "remote":
default:
return fmt.Errorf("remote host %q: forward type must be \"local\" or \"remote\"", e.Name)
}
if strings.TrimSpace(f.Bind) == "" || strings.TrimSpace(f.Target) == "" {
return fmt.Errorf("remote host %q: forward needs both bind and target", e.Name)
}
bind, err := validateRemoteForwardAddress(f.Bind, true)
if err != nil {
return fmt.Errorf("remote host %q: invalid forward bind %q: %w", e.Name, f.Bind, err)
}
if _, err := validateRemoteForwardAddress(f.Target, false); err != nil {
return fmt.Errorf("remote host %q: invalid forward target %q: %w", e.Name, f.Target, err)
}
key := kind + "\x00" + bind
if seenBinds[key] {
return fmt.Errorf("remote host %q: duplicate %s forward bind %q", e.Name, kind, f.Bind)
}
seenBinds[key] = true
}
return nil
}
func validateRemoteForwardAddress(addr string, bind bool) (string, error) {
addr = strings.TrimSpace(addr)
if bind && !strings.Contains(addr, ":") {
addr = net.JoinHostPort("127.0.0.1", addr)
}
host, portText, err := net.SplitHostPort(addr)
if err != nil {
return "", err
}
if !bind && strings.TrimSpace(host) == "" {
return "", fmt.Errorf("host is required")
}
port, err := strconv.Atoi(portText)
if err != nil || port < 0 || port > 65535 || (!bind && port == 0) {
return "", fmt.Errorf("port out of range")
}
return net.JoinHostPort(host, strconv.Itoa(port)), nil
}
// RemoteHost looks up a configured host by name.
func (c *Config) RemoteHost(name string) (RemoteHostEntry, bool) {
for _, h := range c.Remote.Hosts {
if h.Name == name {
return h, true
}
}
return RemoteHostEntry{}, false
}
// UpsertRemoteHost adds e, or replaces the host with the same name
// (preserving position). Mirrors UpsertPlugin.
func (c *Config) UpsertRemoteHost(e RemoteHostEntry) error {
if err := validateRemoteHost(e); err != nil {
return err
}
for i := range c.Remote.Hosts {
if c.Remote.Hosts[i].Name == e.Name {
c.Remote.Hosts[i] = e
return nil
}
}
c.Remote.Hosts = append(c.Remote.Hosts, e)
return nil
}
// RemoveRemoteHost deletes the named host, reporting whether it was present.
func (c *Config) RemoveRemoteHost(name string) bool {
for i := range c.Remote.Hosts {
if c.Remote.Hosts[i].Name == name {
c.Remote.Hosts = append(c.Remote.Hosts[:i], c.Remote.Hosts[i+1:]...)
return true
}
}
return false
}
+228
View File
@@ -0,0 +1,228 @@
package config
import (
"os"
"path/filepath"
"strings"
"testing"
)
// TestProjectConfigCannotOverrideRemote pins [remote] as a user-global
// security control: a cloned repository's reasonix.toml must not be able to
// inject SSH hosts, jump chains, or port forwards.
func TestProjectConfigCannotOverrideRemote(t *testing.T) {
isolateUserConfigHome(t)
t.Setenv("REASONIX_HOME", "")
globalDir := filepath.Dir(UserConfigPath())
if err := os.MkdirAll(globalDir, 0o755); err != nil {
t.Fatal(err)
}
globalTOML := "[remote]\n[[remote.hosts]]\nname = \"trusted\"\nhost = \"trusted.example\"\n"
if err := os.WriteFile(filepath.Join(globalDir, "config.toml"), []byte(globalTOML), 0o644); err != nil {
t.Fatal(err)
}
project := t.TempDir()
projectTOML := "[remote]\n[[remote.hosts]]\nname = \"evil\"\nhost = \"attacker.example\"\nproxy_jump = \"attacker-jump\"\n"
if err := os.WriteFile(filepath.Join(project, "reasonix.toml"), []byte(projectTOML), 0o644); err != nil {
t.Fatal(err)
}
cfg, err := LoadForRoot(project)
if err != nil {
t.Fatalf("LoadForRoot() error = %v", err)
}
if len(cfg.Remote.Hosts) != 1 || cfg.Remote.Hosts[0].Name != "trusted" {
t.Fatalf("remote hosts = %+v, want only the user-global \"trusted\" host", cfg.Remote.Hosts)
}
if _, ok := cfg.RemoteHost("evil"); ok {
t.Error("project reasonix.toml injected a remote host; [remote] must stay user-global")
}
}
func TestRemoteConfigDecodeAndDefaults(t *testing.T) {
isolateUserConfigHome(t)
home := t.TempDir()
t.Setenv("REASONIX_HOME", home)
toml := `
[remote]
import_ssh_config = true
[[remote.hosts]]
name = "gpu-box"
host = "203.0.113.7"
port = 2222
user = "dev"
identity_file = "~/.ssh/id_ed25519"
passphrase_env = "REASONIX_REMOTE_GPUBOX_PASSPHRASE"
proxy_jump = "bastion.corp"
workspace = "~/projects/app"
serve_install = "npm"
use_ssh_config = true
[[remote.hosts.forwards]]
type = "local"
bind = "127.0.0.1:5432"
target = "127.0.0.1:5432"
[[remote.hosts]]
name = "minimal"
host = "10.0.0.1"
`
if err := os.WriteFile(filepath.Join(home, "config.toml"), []byte(toml), 0o644); err != nil {
t.Fatal(err)
}
cfg, err := Load()
if err != nil {
t.Fatalf("Load: %v", err)
}
if !cfg.Remote.ImportSSHConfig {
t.Error("import_ssh_config not decoded")
}
h, ok := cfg.RemoteHost("gpu-box")
if !ok {
t.Fatal("gpu-box host missing")
}
if h.Port != 2222 || h.User != "dev" || h.ProxyJump != "bastion.corp" || !h.UseSSHConfig {
t.Fatalf("gpu-box decoded wrong: %+v", h)
}
if h.ServeInstallMode() != "npm" {
t.Fatalf("ServeInstallMode = %q", h.ServeInstallMode())
}
if len(h.Forwards) != 1 || h.Forwards[0].Type != "local" || h.Forwards[0].Bind != "127.0.0.1:5432" {
t.Fatalf("forwards decoded wrong: %+v", h.Forwards)
}
m, ok := cfg.RemoteHost("minimal")
if !ok {
t.Fatal("minimal host missing")
}
if m.PortOrDefault() != 22 {
t.Fatalf("PortOrDefault = %d, want 22", m.PortOrDefault())
}
if m.ServeInstallMode() != "auto" {
t.Fatalf("default ServeInstallMode = %q, want auto", m.ServeInstallMode())
}
}
// TestUpsertRemoteHostRoundTripsThroughSave pins that hosts written via the
// CRUD helpers survive a full user-scope re-render (SaveTo renders the whole
// file from the struct — a missing [remote] renderer would silently drop
// every saved host on the next unrelated settings save).
func TestUpsertRemoteHostRoundTripsThroughSave(t *testing.T) {
isolateUserConfigHome(t)
home := t.TempDir()
t.Setenv("REASONIX_HOME", home)
path := filepath.Join(home, "config.toml")
if err := os.WriteFile(path, []byte("default_model = \"deepseek\"\n"), 0o644); err != nil {
t.Fatal(err)
}
cfg := LoadForEdit(path)
if cfg == nil {
t.Fatal("LoadForEdit returned nil")
}
host := RemoteHostEntry{
Name: "box",
Host: "198.51.100.4",
Port: 22,
User: "dev",
PassphraseEnv: "REASONIX_REMOTE_BOX_PASSPHRASE",
Forwards: []RemoteForwardEntry{{Type: "local", Bind: "127.0.0.1:8080", Target: "127.0.0.1:80"}},
}
if err := cfg.UpsertRemoteHost(host); err != nil {
t.Fatalf("UpsertRemoteHost: %v", err)
}
if err := cfg.SaveTo(path); err != nil {
t.Fatalf("SaveTo: %v", err)
}
raw, err := os.ReadFile(path)
if err != nil {
t.Fatal(err)
}
for _, want := range []string{"[[remote.hosts]]", `name = "box"`, `host = "198.51.100.4"`, "[[remote.hosts.forwards]]", `bind = "127.0.0.1:8080"`} {
if !strings.Contains(string(raw), want) {
t.Fatalf("saved config missing %q:\n%s", want, raw)
}
}
reloaded := LoadForEdit(path)
got, ok := reloaded.RemoteHost("box")
if !ok {
t.Fatal("host lost after save/reload")
}
if got.PassphraseEnv != host.PassphraseEnv || len(got.Forwards) != 1 {
t.Fatalf("host mutated across round-trip: %+v", got)
}
// Replace + remove.
host.User = "ops"
if err := reloaded.UpsertRemoteHost(host); err != nil {
t.Fatal(err)
}
if h, _ := reloaded.RemoteHost("box"); h.User != "ops" || len(reloaded.Remote.Hosts) != 1 {
t.Fatalf("upsert did not replace in place: %+v", reloaded.Remote.Hosts)
}
if !reloaded.RemoveRemoteHost("box") {
t.Fatal("RemoveRemoteHost reported missing")
}
if reloaded.RemoveRemoteHost("box") {
t.Fatal("second remove reported present")
}
}
func TestUpsertRemoteHostValidates(t *testing.T) {
cfg := Default()
bad := []RemoteHostEntry{
{Name: "", Host: "h"},
{Name: "a b", Host: "h"},
{Name: "user@host", Host: "h"},
{Name: "ok", Host: ""},
{Name: "ok", Host: "h", Port: 70000},
{Name: "ok", Host: "h", ServeInstall: "curlpipe"},
{Name: "ok", Host: "h", Forwards: []RemoteForwardEntry{{Type: "dynamic", Bind: "1", Target: "2"}}},
{Name: "ok", Host: "h", Forwards: []RemoteForwardEntry{{Type: "local", Bind: "", Target: "2"}}},
{Name: "ok", Host: "h", Forwards: []RemoteForwardEntry{{Type: "local", Bind: "abc", Target: "svc:80"}}},
{Name: "ok", Host: "h", Forwards: []RemoteForwardEntry{{Type: "local", Bind: "8080", Target: "svc:0"}}},
{Name: "ok", Host: "h", Forwards: []RemoteForwardEntry{{Type: "local", Bind: "8080", Target: "svc:80"}, {Type: "local", Bind: "127.0.0.1:8080", Target: "other:80"}}},
}
for i, e := range bad {
if err := cfg.UpsertRemoteHost(e); err == nil {
t.Errorf("case %d (%+v): invalid host accepted", i, e)
}
}
if len(cfg.Remote.Hosts) != 0 {
t.Fatalf("invalid hosts persisted: %+v", cfg.Remote.Hosts)
}
}
// TestRemoteCredentialEnvNamesCollected pins that remote passphrase/password
// env names flow into CredentialEnvNames -> secrets.RegisterCredentialEnvKeys
// so they are filtered from tool subprocess environments.
func TestRemoteCredentialEnvNamesCollected(t *testing.T) {
cfg := Default()
cfg.Remote.Hosts = []RemoteHostEntry{
{Name: "a", Host: "h1", PassphraseEnv: "REMOTE_A_PASSPHRASE"},
{Name: "b", Host: "h2", PasswordEnv: "REMOTE_B_PASSWORD"},
{Name: "c", Host: "h3", PassphraseEnv: "REMOTE_A_PASSPHRASE"}, // dup collapses
}
names := credentialEnvNamesFromConfig(cfg)
got := map[string]bool{}
for _, n := range names {
got[n] = true
}
if !got["REMOTE_A_PASSPHRASE"] || !got["REMOTE_B_PASSWORD"] {
t.Fatalf("remote credential envs missing from %v", names)
}
}
func TestRemotePathHelpers(t *testing.T) {
home := t.TempDir()
t.Setenv("REASONIX_HOME", home)
if got := RemoteStateDir(); got != filepath.Join(home, "remote") {
t.Fatalf("RemoteStateDir = %q", got)
}
if got := RemoteKnownHostsPath(); got != filepath.Join(home, "remote", "known_hosts") {
t.Fatalf("RemoteKnownHostsPath = %q", got)
}
}
+49
View File
@@ -663,6 +663,55 @@ func RenderTOMLForScope(c *Config, scope RenderScope) string {
b.WriteString("\n")
}
// [remote] is user/global only like [secrets]: LoadForRoot discards project
// values so a cloned repo can never inject SSH hosts. Rendered here so
// saved hosts survive full-file config rewrites.
if scope != RenderScopeProject && (c.Remote.ImportSSHConfig || len(c.Remote.Hosts) > 0) {
b.WriteString("[remote] # SSH remote hosts; user/global only, ./reasonix.toml cannot override\n")
if c.Remote.ImportSSHConfig {
b.WriteString("import_ssh_config = true # surface ~/.ssh/config aliases in `reasonix remote import`\n")
}
for _, h := range c.Remote.Hosts {
b.WriteString("\n[[remote.hosts]]\n")
fmt.Fprintf(&b, "name = %q\n", h.Name)
fmt.Fprintf(&b, "host = %q\n", h.Host)
if h.Port > 0 {
fmt.Fprintf(&b, "port = %d\n", h.Port)
}
if h.User != "" {
fmt.Fprintf(&b, "user = %q\n", h.User)
}
if h.IdentityFile != "" {
fmt.Fprintf(&b, "identity_file = %q # key file path; Reasonix never stores key material\n", h.IdentityFile)
}
if h.PassphraseEnv != "" {
fmt.Fprintf(&b, "passphrase_env = %q # env var name; value lives in Reasonix's global .env\n", h.PassphraseEnv)
}
if h.PasswordEnv != "" {
fmt.Fprintf(&b, "password_env = %q # env var name; value lives in Reasonix's global .env\n", h.PasswordEnv)
}
if h.ProxyJump != "" {
fmt.Fprintf(&b, "proxy_jump = %q # OpenSSH ProxyJump chain\n", h.ProxyJump)
}
if h.Workspace != "" {
fmt.Fprintf(&b, "workspace = %q # default remote workspace dir\n", h.Workspace)
}
if h.ServeInstall != "" {
fmt.Fprintf(&b, "serve_install = %q # auto|npm|upload|never\n", h.ServeInstall)
}
if h.UseSSHConfig {
b.WriteString("use_ssh_config = true # layer ~/.ssh/config values under unset fields\n")
}
for _, f := range h.Forwards {
b.WriteString("\n[[remote.hosts.forwards]]\n")
fmt.Fprintf(&b, "type = %q # local (-L) | remote (-R)\n", f.Type)
fmt.Fprintf(&b, "bind = %q\n", f.Bind)
fmt.Fprintf(&b, "target = %q\n", f.Target)
}
}
b.WriteString("\n")
}
b.WriteString("# External MCP servers. type: \"stdio\" (default, a subprocess) | \"http\" | \"sse\".\n")
b.WriteString("# ${VAR} / ${VAR:-default} are expanded from the environment in command/args/env/url/headers.\n")
if len(c.Plugins) == 0 {
+14
View File
@@ -230,6 +230,7 @@ type Messages struct {
CmdRemember string // /remember
CmdForget string // /forget
CmdMcp string // /mcp
CmdRemote string // /remote
CmdHooks string // /hooks
CmdPlugins string // /plugins
CmdPasteImage string // /paste-image
@@ -476,6 +477,19 @@ type Messages struct {
AnthropicFetchModelsFailedFmt string // "Failed to fetch models for %s: %v"
AnthropicSelectModelsLabel string // "Select models to enable for %s"
// remote SSH module
RemoteConnectingFmt string // "connecting to %s…"
RemoteConnectedFmt string // "connected to %s"
RemoteReconnectingFmt string // "reconnecting to %s (attempt %d)…"
RemoteDegradedFmt string // "connected to %s but some forwards are down"
RemoteDisconnected string // "disconnected"
RemoteServeReadyFmt string // "remote serve ready: %s"
RemoteHostKeyPromptFmt string // "host %s key (%s): %s"
RemotePassphrasePromptFmt string // "passphrase for %s:"
RemotePasswordPromptFmt string // "password for %s:"
RemoteBootstrapStepFmt string // "remote serve: %s %s"
RemoteNoHostsHint string // "no remote hosts configured; add one with `reasonix remote add`"
// top-level / runAgent
UnknownCommandFmt string // "unknown command %q"
UsageRunHint string // "usage: reasonix run [--model NAME] <task>"
+13
View File
@@ -241,6 +241,7 @@ var English = Messages{
CmdRemember: "save a memory note",
CmdForget: "archive a saved memory",
CmdMcp: "MCP servers",
CmdRemote: "remote SSH hosts",
CmdHooks: "manage hooks",
CmdPlugins: "manage plugin packages",
CmdPasteImage: "paste clipboard image",
@@ -438,6 +439,18 @@ var English = Messages{
AnthropicFetchModelsFailedFmt: "Failed to fetch models for %s: %v",
AnthropicSelectModelsLabel: "Select models to enable for %s",
RemoteConnectingFmt: "connecting to %s…",
RemoteConnectedFmt: "connected to %s",
RemoteReconnectingFmt: "reconnecting to %s (attempt %d)…",
RemoteDegradedFmt: "connected to %s, but some forwards are down",
RemoteDisconnected: "disconnected (remote serve keeps running)",
RemoteServeReadyFmt: "remote serve ready: %s",
RemoteHostKeyPromptFmt: "unknown host key for %s\n type: %s\n fingerprint: %s",
RemotePassphrasePromptFmt: "passphrase for %s:",
RemotePasswordPromptFmt: "password for %s:",
RemoteBootstrapStepFmt: "remote serve: %s %s",
RemoteNoHostsHint: "no remote hosts configured; add one with `reasonix remote add <name> [user@]host`",
UnknownCommandFmt: "unknown command %q",
UsageRunHint: "usage: reasonix -p [--model NAME] <task>",
ErrorPrefix: "error:",
+13
View File
@@ -242,6 +242,7 @@ var Chinese = Messages{
CmdRemember: "保存一条记忆",
CmdForget: "归档一条已存记忆",
CmdMcp: "MCP 服务器",
CmdRemote: "远程 SSH 主机",
CmdHooks: "管理 hooks",
CmdPlugins: "管理插件包",
CmdPasteImage: "粘贴剪贴板图片",
@@ -439,6 +440,18 @@ var Chinese = Messages{
AnthropicFetchModelsFailedFmt: "获取 %s 模型失败: %v",
AnthropicSelectModelsLabel: "选择要启用的 %s 模型",
RemoteConnectingFmt: "正在连接 %s…",
RemoteConnectedFmt: "已连接到 %s",
RemoteReconnectingFmt: "正在重连 %s(第 %d 次)…",
RemoteDegradedFmt: "已连接到 %s,但部分端口转发未建立",
RemoteDisconnected: "已断开(远端 serve 仍在运行)",
RemoteServeReadyFmt: "远端 serve 就绪:%s",
RemoteHostKeyPromptFmt: "未知的主机密钥 %s\n 类型: %s\n 指纹: %s",
RemotePassphrasePromptFmt: "%s 的密钥口令:",
RemotePasswordPromptFmt: "%s 的登录密码:",
RemoteBootstrapStepFmt: "远端 serve:%s %s",
RemoteNoHostsHint: "尚未配置远程主机;用 `reasonix remote add <名称> [user@]host` 添加",
UnknownCommandFmt: "未知命令 %q",
UsageRunHint: "用法:reasonix -p [--model NAME] <task>",
ErrorPrefix: "错误:",
+13
View File
@@ -229,6 +229,7 @@ var ChineseTraditional = Messages{
CmdRemember: "儲存一條記憶",
CmdForget: "刪除一條已存記憶",
CmdMcp: "MCP 伺服器",
CmdRemote: "遠端 SSH 主機",
CmdHooks: "管理 hooks",
CmdPlugins: "管理插件包",
CmdPasteImage: "貼上剪貼簿圖片",
@@ -419,6 +420,18 @@ var ChineseTraditional = Messages{
AnthropicFetchModelsFailedFmt: "取得 %s 模型失敗: %v",
AnthropicSelectModelsLabel: "選擇要啟用的 %s 模型",
RemoteConnectingFmt: "正在連線 %s…",
RemoteConnectedFmt: "已連線到 %s",
RemoteReconnectingFmt: "正在重新連線 %s(第 %d 次)…",
RemoteDegradedFmt: "已連線到 %s,但部分連接埠轉送未建立",
RemoteDisconnected: "已中斷(遠端 serve 仍在執行)",
RemoteServeReadyFmt: "遠端 serve 就緒:%s",
RemoteHostKeyPromptFmt: "未知的主機金鑰 %s\n 類型: %s\n 指紋: %s",
RemotePassphrasePromptFmt: "%s 的金鑰通關密語:",
RemotePasswordPromptFmt: "%s 的登入密碼:",
RemoteBootstrapStepFmt: "遠端 serve:%s %s",
RemoteNoHostsHint: "尚未設定遠端主機;用 `reasonix remote add <名稱> [user@]host` 新增",
UnknownCommandFmt: "未知命令 %q",
UsageRunHint: "用法:reasonix -p [--model NAME] <task>",
ErrorPrefix: "錯誤:",
+157
View File
@@ -0,0 +1,157 @@
package netclient
import (
"bufio"
"context"
"crypto/tls"
"fmt"
"net"
"net/http"
"net/url"
"strings"
"time"
"golang.org/x/net/proxy"
)
// StreamDialer opens a raw TCP stream under the same proxy policy netclient
// applies to HTTP. It is the dial seam for non-HTTP protocols (SSH) so a
// user's configured proxy is honored consistently.
type StreamDialer interface {
DialContext(ctx context.Context, network, addr string) (net.Conn, error)
}
// DialerFunc adapts a function to StreamDialer.
type DialerFunc func(ctx context.Context, network, addr string) (net.Conn, error)
// DialContext implements StreamDialer.
func (f DialerFunc) DialContext(ctx context.Context, network, addr string) (net.Conn, error) {
return f(ctx, network, addr)
}
// NewStreamDialer builds a StreamDialer for spec. Off/env/auto with no
// applicable proxy dial directly; socks5/socks5h dial through the SOCKS proxy;
// http/https dial through an HTTP CONNECT tunnel. DirectHosts and NoProxy are
// honored via the shared proxy resolution.
func NewStreamDialer(spec ProxySpec) (StreamDialer, error) {
pf, err := proxyFunc(spec)
if err != nil {
return nil, err
}
base := &net.Dialer{Timeout: 30 * time.Second, KeepAlive: 30 * time.Second}
if pf == nil {
return DialerFunc(base.DialContext), nil
}
return DialerFunc(func(ctx context.Context, network, addr string) (net.Conn, error) {
// proxyFunc keys off a request URL; synthesize one for the target so
// scheme-agnostic TCP dials reuse the exact NoProxy/DirectHosts logic.
host, _, splitErr := net.SplitHostPort(addr)
if splitErr != nil {
host = addr
}
probe := &http.Request{URL: &url.URL{Scheme: "https", Host: host}}
pu, perr := pf(probe)
if perr != nil {
return nil, perr
}
if pu == nil {
return base.DialContext(ctx, network, addr)
}
switch strings.ToLower(pu.Scheme) {
case "socks5", "socks5h":
return dialSOCKS5(ctx, pu, base, network, addr)
case "http", "https":
return dialHTTPConnect(ctx, pu, base, addr)
default:
return nil, fmt.Errorf("netclient: unsupported proxy scheme %q for stream dial", pu.Scheme)
}
}), nil
}
func dialSOCKS5(ctx context.Context, pu *url.URL, fwd *net.Dialer, network, addr string) (net.Conn, error) {
var auth *proxy.Auth
if pu.User != nil {
pw, _ := pu.User.Password()
auth = &proxy.Auth{User: pu.User.Username(), Password: pw}
}
d, err := proxy.SOCKS5("tcp", pu.Host, auth, fwd)
if err != nil {
return nil, err
}
if cd, ok := d.(proxy.ContextDialer); ok {
return cd.DialContext(ctx, network, addr)
}
return d.Dial(network, addr)
}
// dialHTTPConnect opens a CONNECT tunnel through an http/https proxy. For an
// https proxy the connection to the proxy itself must be TLS: the CONNECT
// request (including Proxy-Authorization credentials) is sent inside that TLS
// session, not in cleartext. The SSH client then speaks its own protocol over
// the established stream, so only the tunnel handshake lives here.
func dialHTTPConnect(ctx context.Context, pu *url.URL, base *net.Dialer, target string) (net.Conn, error) {
conn, err := base.DialContext(ctx, "tcp", pu.Host)
if err != nil {
return nil, err
}
if strings.EqualFold(pu.Scheme, "https") {
host := pu.Hostname()
tconn := tls.Client(conn, &tls.Config{ServerName: host})
hsCtx := ctx
if _, ok := ctx.Deadline(); !ok {
var cancel context.CancelFunc
hsCtx, cancel = context.WithTimeout(ctx, 30*time.Second)
defer cancel()
}
if herr := tconn.HandshakeContext(hsCtx); herr != nil {
_ = conn.Close()
return nil, fmt.Errorf("netclient: TLS handshake to https proxy %s: %w", pu.Host, herr)
}
conn = tconn
}
if deadline, ok := ctx.Deadline(); ok {
_ = conn.SetDeadline(deadline)
}
req := &http.Request{
Method: http.MethodConnect,
URL: &url.URL{Opaque: target},
Host: target,
Header: make(http.Header),
}
if pu.User != nil {
pw, _ := pu.User.Password()
req.SetBasicAuth(pu.User.Username(), pw)
req.Header.Set("Proxy-Authorization", req.Header.Get("Authorization"))
req.Header.Del("Authorization")
}
if err := req.Write(conn); err != nil {
conn.Close()
return nil, err
}
br := bufio.NewReader(conn)
resp, err := http.ReadResponse(br, req)
if err != nil {
conn.Close()
return nil, err
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK {
conn.Close()
return nil, fmt.Errorf("netclient: proxy CONNECT to %s failed: %s", target, resp.Status)
}
// Clear the handshake deadline; the caller manages timeouts thereafter.
_ = conn.SetDeadline(time.Time{})
if br.Buffered() > 0 {
// A well-behaved proxy sends nothing before the tunnel opens; if it
// did, wrap so buffered bytes aren't lost.
return &bufferedConn{Conn: conn, r: br}, nil
}
return conn, nil
}
type bufferedConn struct {
net.Conn
r *bufio.Reader
}
func (b *bufferedConn) Read(p []byte) (int, error) { return b.r.Read(p) }
+10
View File
@@ -0,0 +1,10 @@
//go:build !windows
package remote
import "net"
// dialAgent connects to the ssh-agent socket named by SSH_AUTH_SOCK.
func dialAgent(sock string) (net.Conn, error) {
return net.Dial("unix", sock)
}
+17
View File
@@ -0,0 +1,17 @@
//go:build windows
package remote
import (
"fmt"
"net"
)
// dialAgent connects to the ssh-agent on Windows. The OpenSSH agent listens on
// the named pipe \\.\pipe\openssh-ssh-agent; SSH_AUTH_SOCK may name a pipe
// path. Named-pipe dialing needs a Windows-specific transport that is a V2
// follow-up, so V1 reports agent auth as unavailable on Windows and falls back
// to key/password methods.
func dialAgent(sock string) (net.Conn, error) {
return nil, fmt.Errorf("remote: ssh-agent support on Windows is not yet implemented (SSH_AUTH_SOCK=%s)", sock)
}
+243
View File
@@ -0,0 +1,243 @@
package remote
import (
"context"
"fmt"
"os"
"path/filepath"
"sync"
"golang.org/x/crypto/ssh"
"golang.org/x/crypto/ssh/agent"
)
// SecretKind identifies which interactive secret is being requested.
type SecretKind int
const (
SecretPassphrase SecretKind = iota // private-key passphrase
SecretPassword // password auth
)
func (k SecretKind) String() string {
if k == SecretPassword {
return "password"
}
return "passphrase"
}
// AuthOptions supplies credential resolution for a dial. Passphrase and
// Password return already-resolved credential-store values (nil when none is
// configured). SecretPrompt is the interactive fallback — a terminal prompt in
// the CLI, a dialog in the desktop — and is only ever called on the first
// connect; reconnects reuse in-memory-cached secrets and never prompt.
type AuthOptions struct {
Passphrase func() (string, error)
Password func() (string, error)
SecretPrompt func(ctx context.Context, kind SecretKind, host string) (string, error)
DisableAgent bool
// cache holds secrets obtained during the first connect so the supervisor
// can reconnect silently. Populated by the auth methods.
cache *secretCache
}
type secretCache struct {
passphrase string
password string
havePass bool
havePw bool
}
// buildAuthMethods assembles ssh.AuthMethod values in OpenSSH-like order:
// agent, explicit identity file, default identities, password,
// keyboard-interactive. host is the display label used in prompts.
func buildAuthMethods(ctx context.Context, h ResolvedHost, opts *AuthOptions) ([]ssh.AuthMethod, func(), error) {
if opts.cache == nil {
opts.cache = &secretCache{}
}
var methods []ssh.AuthMethod
cleanup := func() {}
if !opts.DisableAgent {
if am, closeAgent := agentAuth(); am != nil {
methods = append(methods, am)
cleanup = closeAgent
}
}
if h.IdentityFile != "" {
am, err := keyAuth(ctx, h, opts, h.IdentityFile)
if err != nil {
cleanup()
return nil, func() {}, err
}
if am != nil {
methods = append(methods, am)
}
} else {
for _, path := range defaultIdentityFiles() {
am, err := keyAuth(ctx, h, opts, path)
if err != nil {
// A default key that exists but fails to parse shouldn't abort
// the whole chain — skip it and try the next.
continue
}
if am != nil {
methods = append(methods, am)
}
}
}
methods = append(methods, passwordAuth(ctx, h, opts))
methods = append(methods, keyboardInteractiveAuth(ctx, h, opts))
return methods, cleanup, nil
}
func agentAuth() (ssh.AuthMethod, func()) {
sock := os.Getenv("SSH_AUTH_SOCK")
if sock == "" {
return nil, func() {}
}
var mu sync.Mutex
var conns []interface{ Close() error }
method := ssh.PublicKeysCallback(func() ([]ssh.Signer, error) {
conn, err := dialAgent(sock)
if err != nil {
return nil, err
}
mu.Lock()
conns = append(conns, conn)
mu.Unlock()
return agent.NewClient(conn).Signers()
})
return method, func() {
mu.Lock()
owned := conns
conns = nil
mu.Unlock()
for _, conn := range owned {
_ = conn.Close()
}
}
}
// keyAuth loads a private key, resolving a passphrase from the credential
// store then the interactive prompt when the key is encrypted. Returns nil
// (no method, no error) when the key file simply does not exist.
func keyAuth(ctx context.Context, h ResolvedHost, opts *AuthOptions, path string) (ssh.AuthMethod, error) {
path = expandHome(path)
pem, err := os.ReadFile(path)
if err != nil {
if os.IsNotExist(err) {
return nil, nil
}
return nil, err
}
signer, err := ssh.ParsePrivateKey(pem)
if err == nil {
return ssh.PublicKeys(signer), nil
}
var missing *ssh.PassphraseMissingError
if !isPassphraseMissing(err, &missing) {
return nil, fmt.Errorf("parse key %s: %w", path, err)
}
// Encrypted key: return a lazy method so the passphrase is only resolved
// if the server actually offers publickey with this key.
return ssh.PublicKeysCallback(func() ([]ssh.Signer, error) {
pass, perr := resolvePassphrase(ctx, h, opts)
if perr != nil {
return nil, perr
}
s, serr := ssh.ParsePrivateKeyWithPassphrase(pem, []byte(pass))
if serr != nil {
return nil, fmt.Errorf("decrypt key %s: %w", path, serr)
}
return []ssh.Signer{s}, nil
}), nil
}
func resolvePassphrase(ctx context.Context, h ResolvedHost, opts *AuthOptions) (string, error) {
if opts.cache.havePass {
return opts.cache.passphrase, nil
}
if opts.Passphrase != nil {
if v, err := opts.Passphrase(); err == nil && v != "" {
opts.cache.passphrase, opts.cache.havePass = v, true
return v, nil
}
}
if opts.SecretPrompt == nil {
return "", fmt.Errorf("remote: key passphrase required but no prompt available")
}
v, err := opts.SecretPrompt(ctx, SecretPassphrase, h.Label())
if err != nil {
return "", err
}
opts.cache.passphrase, opts.cache.havePass = v, true
return v, nil
}
func passwordAuth(ctx context.Context, h ResolvedHost, opts *AuthOptions) ssh.AuthMethod {
return ssh.RetryableAuthMethod(ssh.PasswordCallback(func() (string, error) {
return resolvePassword(ctx, h, opts)
}), 3)
}
func keyboardInteractiveAuth(ctx context.Context, h ResolvedHost, opts *AuthOptions) ssh.AuthMethod {
return ssh.KeyboardInteractive(func(name, instruction string, questions []string, echos []bool) ([]string, error) {
// Never copy a password into echoed, OTP, or multi-question prompts.
// The current callback models only a password secret, so support the
// common single hidden-password challenge and fail closed otherwise.
if len(questions) != 1 || len(echos) != 1 || echos[0] {
return nil, fmt.Errorf("remote: unsupported keyboard-interactive challenge from %s", h.Label())
}
pw, err := resolvePassword(ctx, h, opts)
if err != nil {
return nil, err
}
return []string{pw}, nil
})
}
func resolvePassword(ctx context.Context, h ResolvedHost, opts *AuthOptions) (string, error) {
if opts.cache.havePw {
return opts.cache.password, nil
}
if opts.Password != nil {
if v, err := opts.Password(); err == nil && v != "" {
opts.cache.password, opts.cache.havePw = v, true
return v, nil
}
}
if opts.SecretPrompt == nil {
return "", fmt.Errorf("remote: password required but no prompt available")
}
v, err := opts.SecretPrompt(ctx, SecretPassword, h.Label())
if err != nil {
return "", err
}
opts.cache.password, opts.cache.havePw = v, true
return v, nil
}
func defaultIdentityFiles() []string {
home, err := os.UserHomeDir()
if err != nil {
return nil
}
names := []string{"id_ed25519", "id_ecdsa", "id_rsa"}
out := make([]string, 0, len(names))
for _, n := range names {
out = append(out, filepath.Join(home, ".ssh", n))
}
return out
}
func isPassphraseMissing(err error, target **ssh.PassphraseMissingError) bool {
if pe, ok := err.(*ssh.PassphraseMissingError); ok {
*target = pe
return true
}
return false
}
+61
View File
@@ -0,0 +1,61 @@
package remote
import (
"testing"
"time"
)
func TestBackoffDelayCeilingGrowsAndCaps(t *testing.T) {
p := BackoffPolicy{Initial: time.Second, Factor: 2, Max: 60 * time.Second}
want := []time.Duration{
1 * time.Second,
2 * time.Second,
4 * time.Second,
8 * time.Second,
16 * time.Second,
32 * time.Second,
60 * time.Second, // 64 capped
60 * time.Second,
}
for i, w := range want {
if got := p.delay(i); got != w {
t.Errorf("delay(%d) = %v, want %v", i, got, w)
}
}
}
func TestBackoffDefaults(t *testing.T) {
var p BackoffPolicy
if p.delay(0) != time.Second {
t.Errorf("default initial = %v", p.delay(0))
}
if p.delay(100) != 60*time.Second {
t.Errorf("default cap = %v", p.delay(100))
}
}
func TestClassifyDialError(t *testing.T) {
authy := classifyDialError(errString("ssh: handshake failed: ssh: unable to authenticate, attempted methods [none publickey], no supported methods remain"))
if !isAuthErr(authy) {
t.Errorf("auth failure not classified as ErrAuthFailed: %v", authy)
}
transient := classifyDialError(errString("dial tcp 10.0.0.1:22: connect: connection refused"))
if isAuthErr(transient) {
t.Errorf("transient error misclassified as auth: %v", transient)
}
if classifyDialError(nil) != nil {
t.Error("nil error should classify to nil")
}
}
type errString string
func (e errString) Error() string { return string(e) }
func isAuthErr(err error) bool {
type iser interface{ Is(error) bool }
if x, ok := err.(iser); ok {
return x.Is(ErrAuthFailed)
}
return false
}
+404
View File
@@ -0,0 +1,404 @@
// Package bootstrap starts and manages a detached `reasonix serve` process on
// a remote host over an established SSH connection. It detects the remote
// OS/arch, locates or installs reasonix, launches serve bound to a random
// loopback port with a file-based token (never in argv), and records the
// result under the remote ~/.reasonix/remote so a later reconnect can reuse
// it. V1 targets Linux and macOS remotes.
package bootstrap
import (
"context"
"crypto/rand"
"encoding/hex"
"errors"
"fmt"
"io"
"net"
"strconv"
"strings"
"time"
"reasonix/internal/remote"
"reasonix/internal/remote/sftpfs"
)
// Conn is the subset of *remote.Client bootstrap needs. *remote.Client
// satisfies it directly; tests inject a fake. bootstrap depends on remote
// (never the reverse), so using remote.ExecResult here introduces no cycle.
type Conn interface {
Exec(ctx context.Context, cmd string) (remote.ExecResult, error)
SFTP() (*sftpfs.FS, error)
}
// Install strategies.
const (
InstallAuto = "auto"
InstallNPM = "npm"
InstallUpload = "upload"
InstallNever = "never"
)
// MinServeVersion is retained for display/informational use only. Usability is
// decided by probing `serve --help` for the --port-file flag (see locate), not
// by a version number: --port-file/--token-file ship in this change, so no
// released version satisfies a numeric gate, and the release number this change
// lands in is unknown at authoring time.
const MinServeVersion = "flag:port-file"
// Options configures EnsureServe.
type Options struct {
Workspace string // remote workspace path (may start with ~)
Install string // auto|npm|upload|never
LocalBinary string // path to the running reasonix binary, for same-platform upload
LocalGOOS string // GOOS of LocalBinary
LocalGOARCH string // GOARCH of LocalBinary
MinVersion string // minimum acceptable remote version
Progress func(step, detail string) // optional progress callback
Clock func() time.Time // nil => time.Now
}
func (o Options) progress(step, detail string) {
if o.Progress != nil {
o.Progress(step, detail)
}
}
func (o Options) clock() func() time.Time {
if o.Clock != nil {
return o.Clock
}
return time.Now
}
// Result is the outcome of EnsureServe.
type Result struct {
State ServeState
Token string // the pre-shared auth token (read from or written to TokenFile)
Reused bool // true when an already-running serve was reused
}
// EnsureServe returns a running serve for (host, workspace), starting one if
// needed. It is also the reconnect path: an existing live process is reused.
func EnsureServe(ctx context.Context, conn Conn, opts Options) (Result, error) {
fs, err := conn.SFTP()
if err != nil {
return Result{}, err
}
home, err := fs.RealPath(ctx, "~")
if err != nil {
return Result{}, fmt.Errorf("bootstrap: resolve remote home: %w", err)
}
workspace, err := resolveWorkspace(ctx, fs, opts.Workspace, home)
if err != nil {
return Result{}, err
}
paths := pathsFor(home, workspace)
// 1. Reuse a live process if the recorded pid is still running.
if st, tok, ok := tryReuse(ctx, conn, fs, paths, workspace); ok {
opts.progress("reuse", st.Addr)
return Result{State: st, Token: tok, Reused: true}, nil
}
// 2. Detect remote platform.
opts.progress("detect", "")
unameRes, err := conn.Exec(ctx, "uname -sm")
if err != nil {
return Result{}, fmt.Errorf("bootstrap: uname: %w", err)
}
goos, goarch, err := ParseUname(string(unameRes.Stdout))
if err != nil {
return Result{}, err
}
// 3. Locate or install a usable reasonix.
bin, version, err := ensureBinary(ctx, conn, fs, opts, home, goos, goarch, paths)
if err != nil {
return Result{}, err
}
// 4. Serialize only the short launch/publish section across every client.
// Another caller may have completed while this one was locating/installing,
// so re-check state after acquiring the remote lock.
opts.progress("waiting_lock", "")
lock, err := acquireServeLock(ctx, fs, paths, opts.clock())
if err != nil {
return Result{}, err
}
defer lock.release()
if st, tok, ok := tryReuse(ctx, conn, fs, paths, workspace); ok {
opts.progress("reuse", st.Addr)
return Result{State: st, Token: tok, Reused: true}, nil
}
// 5. Generate token, write it 0600, and launch detached serve.
token, err := generateToken()
if err != nil {
return Result{}, err
}
if err := fs.MkdirAll(ctx, paths.Dir); err != nil {
return Result{}, err
}
if err := fs.WriteFileAtomic(ctx, paths.TokenFile, []byte(token+"\n"), 0o600); err != nil {
return Result{}, fmt.Errorf("bootstrap: write token: %w", err)
}
opts.progress("launch", "")
launchRes, err := conn.Exec(ctx, LaunchCommand(bin, workspace, paths))
if err != nil {
cleanupFailedLaunch(conn, fs, paths, 0)
return Result{}, fmt.Errorf("bootstrap: launch: %w", err)
}
pid, _ := strconv.Atoi(strings.TrimSpace(string(launchRes.Stdout)))
// 6. Poll the newly-created port file for the real bound address. The launch
// command removes stale port/pid files before forking.
opts.progress("health_check", "")
addr, err := pollPortFile(ctx, fs, paths.PortFile, opts.clock())
if err != nil {
cleanupFailedLaunch(conn, fs, paths, pid)
return Result{}, err
}
if filePID, perr := readPIDFile(ctx, fs, paths.PidFile); perr == nil {
pid = filePID // --pid-file is authoritative when available.
}
if pid <= 0 || !pidIsServe(ctx, conn, pid, paths) {
cleanupFailedLaunch(conn, fs, paths, pid)
return Result{}, errors.New("bootstrap: launched process did not become the expected reasonix serve")
}
st := ServeState{
PID: pid,
Addr: addr,
Workspace: workspace,
Version: version,
TokenFile: paths.TokenFile,
LogFile: paths.LogFile,
StartedAt: nowUnix(opts.clock()),
}
data, err := MarshalState(st)
if err != nil {
cleanupFailedLaunch(conn, fs, paths, pid)
return Result{}, err
}
if err := fs.WriteFileAtomic(ctx, paths.StateJSON, data, 0o600); err != nil {
cleanupFailedLaunch(conn, fs, paths, pid)
return Result{}, fmt.Errorf("bootstrap: write state: %w", err)
}
opts.progress("ready", addr)
return Result{State: st, Token: token}, nil
}
// Status reads the recorded state and reports whether the process is alive.
func Status(ctx context.Context, conn Conn, workspace string) (ServeState, bool, error) {
fs, err := conn.SFTP()
if err != nil {
return ServeState{}, false, err
}
home, err := fs.RealPath(ctx, "~")
if err != nil {
return ServeState{}, false, err
}
ws, err := resolveWorkspace(ctx, fs, workspace, home)
if err != nil {
return ServeState{}, false, err
}
paths := pathsFor(home, ws)
st, err := readState(ctx, fs, paths.StateJSON)
if err != nil {
return ServeState{}, false, nil // no state => not running
}
alive := st.Workspace == ws && validServeAddr(st.Addr) && pidIsServe(ctx, conn, st.PID, paths)
return st, alive, nil
}
// Stop terminates the recorded process and removes its state files.
func Stop(ctx context.Context, conn Conn, workspace string) error {
fs, err := conn.SFTP()
if err != nil {
return err
}
home, err := fs.RealPath(ctx, "~")
if err != nil {
return err
}
ws, err := resolveWorkspace(ctx, fs, workspace, home)
if err != nil {
return err
}
paths := pathsFor(home, ws)
st, err := readState(ctx, fs, paths.StateJSON)
if err != nil {
return nil // nothing recorded
}
// Only signal the pid if it is still OUR serve: a recycled PID now owned by
// an unrelated process must never be TERM/KILLed.
if st.PID > 0 {
if _, err := conn.Exec(ctx, StopCommand(st.PID, paths)); err != nil {
return fmt.Errorf("bootstrap: stop pid %d: %w", st.PID, err)
}
}
_ = fs.Remove(ctx, paths.StateJSON, false)
_ = fs.Remove(ctx, paths.TokenFile, false)
_ = fs.Remove(ctx, paths.PortFile, false)
_ = fs.Remove(ctx, paths.PidFile, false)
return nil
}
// Logs writes up to n tail lines of the serve log to w.
func Logs(ctx context.Context, conn Conn, workspace string, n int, w io.Writer) error {
fs, err := conn.SFTP()
if err != nil {
return err
}
home, err := fs.RealPath(ctx, "~")
if err != nil {
return err
}
ws, err := resolveWorkspace(ctx, fs, workspace, home)
if err != nil {
return err
}
paths := pathsFor(home, ws)
res, err := conn.Exec(ctx, LogsCommand(paths.LogFile, n))
if err != nil {
return err
}
_, err = w.Write(res.Stdout)
return err
}
func tryReuse(ctx context.Context, conn Conn, fs *sftpfs.FS, paths StatePaths, workspace ...string) (ServeState, string, bool) {
st, err := readState(ctx, fs, paths.StateJSON)
if err != nil || st.PID <= 0 || st.Addr == "" {
return ServeState{}, "", false
}
if len(workspace) > 0 && st.Workspace != workspace[0] {
return ServeState{}, "", false
}
if !validServeAddr(st.Addr) || !pidIsServe(ctx, conn, st.PID, paths) {
return ServeState{}, "", false
}
// The state record is informational; the workspace-derived path is the
// authority, so a tampered record cannot make us read an arbitrary file.
tok, err := readToken(ctx, fs, paths.TokenFile)
if err != nil {
return ServeState{}, "", false
}
return st, tok, true
}
// pidIsServe reports whether pid is running AND is a reasonix serve process,
// so PID reuse cannot make an unrelated process look like a live serve.
func pidIsServe(ctx context.Context, conn Conn, pid int, paths StatePaths) bool {
if pid <= 0 {
return false
}
res, err := conn.Exec(ctx, ServeAliveCommand(pid, paths))
if err != nil {
return false
}
return strings.TrimSpace(string(res.Stdout)) == "1"
}
func readState(ctx context.Context, fs *sftpfs.FS, path string) (ServeState, error) {
data, _, _, err := fs.ReadFile(ctx, path, 1<<20)
if err != nil {
return ServeState{}, err
}
return UnmarshalState(data)
}
func readToken(ctx context.Context, fs *sftpfs.FS, path string) (string, error) {
data, _, _, err := fs.ReadFile(ctx, path, 64<<10)
if err != nil {
return "", err
}
tok := strings.TrimSpace(string(data))
if tok == "" {
return "", errors.New("bootstrap: empty token file")
}
return tok, nil
}
func pollPortFile(ctx context.Context, fs *sftpfs.FS, portFile string, clock func() time.Time) (string, error) {
deadline := clock().Add(20 * time.Second)
for {
data, _, _, err := fs.ReadFile(ctx, portFile, 128)
if err == nil {
addr := strings.TrimSpace(string(data))
if validServeAddr(addr) {
return addr, nil
}
}
if clock().After(deadline) {
return "", errors.New("bootstrap: timed out waiting for serve to report its port")
}
select {
case <-ctx.Done():
return "", ctx.Err()
case <-time.After(250 * time.Millisecond):
}
}
}
func validServeAddr(addr string) bool {
host, portText, err := net.SplitHostPort(strings.TrimSpace(addr))
if err != nil || host != "127.0.0.1" {
return false
}
port, err := strconv.Atoi(portText)
return err == nil && port > 0 && port <= 65535
}
func readPIDFile(ctx context.Context, fs *sftpfs.FS, pidFile string) (int, error) {
data, _, _, err := fs.ReadFile(ctx, pidFile, 64)
if err != nil {
return 0, err
}
pid, err := strconv.Atoi(strings.TrimSpace(string(data)))
if err != nil || pid <= 0 {
return 0, errors.New("bootstrap: invalid serve pid file")
}
return pid, nil
}
func cleanupFailedLaunch(conn Conn, fs *sftpfs.FS, paths StatePaths, pid int) {
ctx, cancel := context.WithTimeout(context.Background(), 7*time.Second)
defer cancel()
if pid <= 0 {
pid, _ = readPIDFile(ctx, fs, paths.PidFile)
}
if pid > 0 {
_, _ = conn.Exec(ctx, StopCommand(pid, paths))
}
_ = fs.Remove(ctx, paths.StateJSON, false)
_ = fs.Remove(ctx, paths.TokenFile, false)
_ = fs.Remove(ctx, paths.PortFile, false)
_ = fs.Remove(ctx, paths.PidFile, false)
}
func resolveWorkspace(ctx context.Context, fs *sftpfs.FS, workspace, home string) (string, error) {
workspace = strings.TrimSpace(workspace)
if workspace == "" {
return home, nil
}
if workspace == "~" {
return home, nil
}
if strings.HasPrefix(workspace, "~/") {
return strings.TrimRight(home, "/") + "/" + strings.TrimPrefix(workspace, "~/"), nil
}
if strings.HasPrefix(workspace, "/") {
return workspace, nil
}
// Relative to home.
return strings.TrimRight(home, "/") + "/" + workspace, nil
}
func generateToken() (string, error) {
var b [32]byte
if _, err := rand.Read(b[:]); err != nil {
return "", err
}
return hex.EncodeToString(b[:]), nil
}
@@ -0,0 +1,166 @@
package bootstrap
import (
"strings"
"testing"
)
func TestParseUname(t *testing.T) {
cases := []struct {
in string
goos, goarch string
wantErr bool
}{
{"Linux x86_64", "linux", "amd64", false},
{"Linux aarch64", "linux", "arm64", false},
{"Darwin arm64", "darwin", "arm64", false},
{"Darwin x86_64", "darwin", "amd64", false},
{"Linux armv7l", "linux", "arm", false},
{" Linux x86_64 \n", "linux", "amd64", false},
{"MINGW64_NT-10.0 x86_64", "", "", true}, // Windows shell
{"Linux mips", "", "", true},
{"garbage", "", "", true},
}
for _, c := range cases {
goos, goarch, err := ParseUname(c.in)
if c.wantErr {
if err == nil {
t.Errorf("ParseUname(%q): expected error", c.in)
}
continue
}
if err != nil || goos != c.goos || goarch != c.goarch {
t.Errorf("ParseUname(%q) = (%q,%q,%v), want (%q,%q)", c.in, goos, goarch, err, c.goos, c.goarch)
}
}
}
func TestParseVersion(t *testing.T) {
cases := map[string]string{
"reasonix v1.9.0": "1.9.0",
"1.9.0": "1.9.0",
"reasonix version 2.0.1": "2.0.1",
"v1.10.0-rc.1": "1.10.0-rc.1",
}
for in, want := range cases {
got, err := ParseVersion(in)
if err != nil || got != want {
t.Errorf("ParseVersion(%q) = (%q,%v), want %q", in, got, err, want)
}
}
if _, err := ParseVersion("no version here"); err == nil {
t.Error("expected error for versionless output")
}
}
func TestCompareVersions(t *testing.T) {
cases := []struct {
a, b string
want int
}{
{"1.9.0", "1.9.0", 0},
{"1.9.0", "1.10.0", -1},
{"1.10.0", "1.9.0", 1},
{"2.0.0", "1.99.99", 1},
{"1.9", "1.9.0", 0},
{"1.9.1-rc.1", "1.9.1", 0}, // pre-release ignored for ordering
}
for _, c := range cases {
if got := CompareVersions(c.a, c.b); got != c.want {
t.Errorf("CompareVersions(%q,%q) = %d, want %d", c.a, c.b, got, c.want)
}
}
}
// TestLaunchCommandQuotesHostilePaths is the security golden: a workspace or
// log path containing shell metacharacters must be fully single-quoted so it
// cannot break out of the launch command.
func TestLaunchCommandQuotesHostilePaths(t *testing.T) {
paths := StatePaths{
Dir: "/home/dev/.reasonix/remote",
TokenFile: "/home/dev/.reasonix/remote/serve-x.token",
PortFile: "/home/dev/.reasonix/remote/serve-x.port",
PidFile: "/home/dev/.reasonix/remote/serve-x.pid",
LogFile: "/home/dev/.reasonix/remote/serve-x.log",
}
hostile := "/tmp/'; rm -rf ~; echo '"
cmd := LaunchCommand("/usr/bin/reasonix", hostile, paths)
// The hostile workspace must appear only inside a quoted operand, escaped.
if strings.Contains(cmd, "; rm -rf ~; echo") && !strings.Contains(cmd, `'\''; rm -rf ~; echo '\''`) {
t.Fatalf("hostile workspace not properly escaped:\n%s", cmd)
}
// No unescaped `rm -rf` sequence that would execute.
if strings.Contains(cmd, "cd /tmp/'; rm -rf") {
t.Fatalf("workspace broke out of quoting:\n%s", cmd)
}
// Sanity: the essential flags are present.
for _, want := range []string{"--addr 127.0.0.1:0", "--auth token", "--token-file", "--port-file", "$SX nohup", "echo $!"} {
if !strings.Contains(cmd, want) {
t.Errorf("launch command missing %q:\n%s", want, cmd)
}
}
}
func TestShellQuote(t *testing.T) {
cases := map[string]string{
"simple": "'simple'",
"has space": "'has space'",
"a'b": `'a'\''b'`,
"'; rm -rf ~": `''\''; rm -rf ~'`,
}
for in, want := range cases {
if got := shellQuote(in); got != want {
t.Errorf("shellQuote(%q) = %q, want %q", in, got, want)
}
}
}
func TestStopAndServeAliveCommands(t *testing.T) {
paths := StatePaths{TokenFile: "/state/ws.token", PortFile: "/state/ws.port"}
stop := StopCommand(4321, paths)
for _, want := range []string{"kill -TERM 4321", "kill -0 4321", "kill -KILL 4321"} {
if !strings.Contains(stop, want) {
t.Errorf("StopCommand missing %q: %s", want, stop)
}
}
alive := ServeAliveCommand(99, paths)
// Must check liveness AND that the process is a reasonix serve (guards PID
// reuse), not just kill -0.
for _, want := range []string{"kill -0 99", "ps -p 99", "*reasonix*serve*", paths.TokenFile, paths.PortFile} {
if !strings.Contains(alive, want) {
t.Errorf("ServeAliveCommand missing %q: %s", want, alive)
}
}
if strings.Count(stop, "ours") < 3 {
t.Fatalf("StopCommand must revalidate ownership during TERM/KILL wait: %s", stop)
}
}
func TestLaunchCommandDetachAndLogHardening(t *testing.T) {
cmd := LaunchCommand("/usr/bin/reasonix", "/ws", StatePaths{
Dir: "/d", TokenFile: "/d/t", PortFile: "/d/p", PidFile: "/d/i", LogFile: "/d/l",
})
// setsid must be optional (macOS lacks it) and the log created 0600 so the
// serve token line (already suppressed under --port-file) can't leak.
for _, want := range []string{"command -v setsid", "$SX nohup", "chmod 600", "umask 077", "--port-file"} {
if !strings.Contains(cmd, want) {
t.Errorf("LaunchCommand missing %q:\n%s", want, cmd)
}
}
if !strings.Contains(cmd, "rm -f '/d/p' '/d/i'") {
t.Fatalf("LaunchCommand does not clear stale port/pid files before launch:\n%s", cmd)
}
if strings.Contains(cmd, "setsid nohup") {
t.Errorf("setsid must be conditional, not hard-wired:\n%s", cmd)
}
}
func TestLocateCommandProbesPortFileFlag(t *testing.T) {
cmd := LocateCommand("/home/x/.reasonix/remote/bin/reasonix")
for _, want := range []string{"serve --help", "port-file", "portfile:yes", "portfile:no"} {
if !strings.Contains(cmd, want) {
t.Errorf("LocateCommand missing %q:\n%s", want, cmd)
}
}
}
+298
View File
@@ -0,0 +1,298 @@
package bootstrap
import (
"context"
"os"
"path/filepath"
"runtime"
"strings"
"sync"
"testing"
"time"
"golang.org/x/crypto/ssh"
"reasonix/internal/remote"
"reasonix/internal/remote/sftpfs"
"reasonix/internal/remote/sshtest"
)
// fakeConn scripts exec responses and shares a real sftpfs.FS backed by an
// sshtest SFTP server rooted at a temp dir. The temp dir stands in for the
// remote home, so ~ resolves to it.
type fakeConn struct {
fs *sftpfs.FS
mu sync.Mutex
execs []string
handler func(cmd string) (remote.ExecResult, error)
}
func (f *fakeConn) Exec(_ context.Context, cmd string) (remote.ExecResult, error) {
f.mu.Lock()
f.execs = append(f.execs, cmd)
f.mu.Unlock()
return f.handler(cmd)
}
func (f *fakeConn) SFTP() (*sftpfs.FS, error) { return f.fs, nil }
func (f *fakeConn) ranContaining(sub string) bool {
f.mu.Lock()
defer f.mu.Unlock()
for _, c := range f.execs {
if strings.Contains(c, sub) {
return true
}
}
return false
}
// skipOnWindows guards the EnsureServe integration tests. They model a POSIX
// remote — pathsFor uses path.Join and the slug maps a POSIX home, while the
// SFTP harness serves the local FS. On Windows the temp-dir "remote home" is a
// drive path, so both the test's own pathsFor pre-writes and the harness break.
// This is a harness limitation, not a product one (V1 remotes are Linux/macOS);
// Linux/macOS CI covers these flows. Call it first thing in each such test,
// before any pathsFor/os setup.
func skipOnWindows(t *testing.T) {
t.Helper()
if runtime.GOOS == "windows" {
t.Skip("EnsureServe harness models a POSIX remote; exercised on Linux/macOS")
}
}
func newFakeConn(t *testing.T, root string, handler func(cmd string) (remote.ExecResult, error)) *fakeConn {
t.Helper()
skipOnWindows(t)
srv := sshtest.Start(t, sshtest.Options{SFTPRoot: root})
cfg := &ssh.ClientConfig{User: "t", HostKeyCallback: ssh.InsecureIgnoreHostKey(), Timeout: 5 * time.Second}
cl, err := ssh.Dial("tcp", srv.Addr, cfg)
if err != nil {
t.Fatal(err)
}
t.Cleanup(func() { cl.Close() })
fs, err := sftpfs.New(cl)
if err != nil {
t.Fatal(err)
}
t.Cleanup(func() { fs.Close() })
return &fakeConn{fs: fs, handler: handler}
}
func ok(stdout string) (remote.ExecResult, error) {
return remote.ExecResult{Stdout: []byte(stdout)}, nil
}
// TestEnsureServeLaunchesWhenAbsent drives a full cold start: no prior state,
// reasonix already on PATH, serve writes its port file.
func TestEnsureServeLaunchesWhenAbsent(t *testing.T) {
skipOnWindows(t)
root := t.TempDir()
var portFile string
conn := newFakeConn(t, root, func(cmd string) (remote.ExecResult, error) {
switch {
case strings.Contains(cmd, "uname"):
return ok("Linux x86_64\n")
case strings.Contains(cmd, "command -v reasonix"):
// LocateCommand: report a path and a fresh version.
return ok("/usr/bin/reasonix\nreasonix v9.9.0\nportfile:yes\n")
case strings.Contains(cmd, "nohup"):
// Simulate serve writing the port file, then echo the pid.
if portFile != "" {
_ = os.WriteFile(portFile, []byte("127.0.0.1:44321\n"), 0o600)
}
return ok("54321\n")
case strings.Contains(cmd, "ps -p 54321"):
return ok("1\n")
default:
return ok("")
}
})
// Discover the port-file path the bootstrap will use so the fake serve can
// write it.
paths := pathsFor(root, root)
portFile = paths.PortFile
res, err := EnsureServe(context.Background(), conn, Options{
Workspace: "~",
MinVersion: "1.0.0",
Clock: time.Now,
})
if err != nil {
t.Fatalf("EnsureServe: %v", err)
}
if res.Reused {
t.Fatal("cold start should not report reuse")
}
if res.State.Addr != "127.0.0.1:44321" || res.State.PID != 54321 {
t.Fatalf("state wrong: %+v", res.State)
}
if res.Token == "" {
t.Fatal("no token generated")
}
// Token file written 0600.
fi, err := os.Stat(paths.TokenFile)
if err != nil {
t.Fatalf("token file missing: %v", err)
}
if fi.Mode().Perm() != 0o600 {
t.Fatalf("token perm = %v, want 0600", fi.Mode().Perm())
}
// State file persisted and reloadable.
data, err := os.ReadFile(paths.StateJSON)
if err != nil {
t.Fatal(err)
}
st, err := UnmarshalState(data)
if err != nil || st.Addr != "127.0.0.1:44321" {
t.Fatalf("persisted state wrong: %+v (%v)", st, err)
}
}
// TestEnsureServeReusesLiveProcess: a recorded, alive pid short-circuits to
// reuse without detecting/launching.
func TestEnsureServeReusesLiveProcess(t *testing.T) {
skipOnWindows(t)
root := t.TempDir()
paths := pathsFor(root, root)
// Pre-write state + token as if a serve is already running.
if err := os.MkdirAll(paths.Dir, 0o755); err != nil {
t.Fatal(err)
}
st := ServeState{PID: 777, Addr: "127.0.0.1:5000", Workspace: root, TokenFile: paths.TokenFile}
data, _ := MarshalState(st)
if err := os.WriteFile(paths.StateJSON, data, 0o600); err != nil {
t.Fatal(err)
}
if err := os.WriteFile(paths.TokenFile, []byte("existing-token\n"), 0o600); err != nil {
t.Fatal(err)
}
conn := newFakeConn(t, root, func(cmd string) (remote.ExecResult, error) {
if strings.Contains(cmd, "kill -0 777") {
return ok("1\n") // alive
}
if strings.Contains(cmd, "uname") || strings.Contains(cmd, "nohup") {
t.Errorf("reuse path should not detect/launch; ran: %s", cmd)
}
return ok("")
})
res, err := EnsureServe(context.Background(), conn, Options{Workspace: "~"})
if err != nil {
t.Fatalf("EnsureServe: %v", err)
}
if !res.Reused {
t.Fatal("expected reuse of live process")
}
if res.Token != "existing-token" {
t.Fatalf("token = %q, want existing-token", res.Token)
}
if conn.ranContaining("nohup") {
t.Fatal("reuse path launched a new serve")
}
}
// TestEnsureServeRelaunchesDeadProcess: a recorded but dead pid triggers a
// fresh launch.
func TestEnsureServeRelaunchesDeadProcess(t *testing.T) {
skipOnWindows(t)
root := t.TempDir()
paths := pathsFor(root, root)
if err := os.MkdirAll(paths.Dir, 0o755); err != nil {
t.Fatal(err)
}
st := ServeState{PID: 888, Addr: "127.0.0.1:5000", Workspace: root, TokenFile: paths.TokenFile}
data, _ := MarshalState(st)
_ = os.WriteFile(paths.StateJSON, data, 0o600)
_ = os.WriteFile(paths.TokenFile, []byte("stale\n"), 0o600)
conn := newFakeConn(t, root, func(cmd string) (remote.ExecResult, error) {
switch {
case strings.Contains(cmd, "kill -0 888"):
return ok("0\n") // dead
case strings.Contains(cmd, "uname"):
return ok("Linux aarch64\n")
case strings.Contains(cmd, "command -v reasonix"):
return ok("/usr/bin/reasonix\nreasonix v9.9.0\nportfile:yes\n")
case strings.Contains(cmd, "nohup"):
_ = os.WriteFile(paths.PortFile, []byte("127.0.0.1:6001\n"), 0o600)
return ok("999\n")
case strings.Contains(cmd, "ps -p 999"):
return ok("1\n")
default:
return ok("")
}
})
res, err := EnsureServe(context.Background(), conn, Options{Workspace: "~", MinVersion: "1.0.0"})
if err != nil {
t.Fatalf("EnsureServe: %v", err)
}
if res.Reused {
t.Fatal("dead process should be relaunched, not reused")
}
if res.State.PID != 999 || res.State.Addr != "127.0.0.1:6001" {
t.Fatalf("relaunched state wrong: %+v", res.State)
}
}
// TestEnsureServeInstallNeverErrorsWhenAbsent.
func TestEnsureServeInstallNeverErrorsWhenAbsent(t *testing.T) {
skipOnWindows(t)
root := t.TempDir()
conn := newFakeConn(t, root, func(cmd string) (remote.ExecResult, error) {
switch {
case strings.Contains(cmd, "uname"):
return ok("Linux x86_64\n")
case strings.Contains(cmd, "command -v reasonix"):
return ok("\n") // not found anywhere
default:
return ok("")
}
})
_, err := EnsureServe(context.Background(), conn, Options{Workspace: "~", Install: InstallNever})
if err == nil || !strings.Contains(err.Error(), "serve_install = never") {
t.Fatalf("expected install-never error, got %v", err)
}
}
func TestStopRemovesStateFiles(t *testing.T) {
skipOnWindows(t)
root := t.TempDir()
paths := pathsFor(root, root)
_ = os.MkdirAll(paths.Dir, 0o755)
st := ServeState{PID: 555, Addr: "127.0.0.1:5000", Workspace: root, TokenFile: paths.TokenFile}
data, _ := MarshalState(st)
_ = os.WriteFile(paths.StateJSON, data, 0o600)
_ = os.WriteFile(paths.TokenFile, []byte("tok\n"), 0o600)
stopped := false
conn := newFakeConn(t, root, func(cmd string) (remote.ExecResult, error) {
// Order matters: StopCommand also contains "kill -0 555" in its wait
// loop, so match the TERM (the stop signal) before the serve-alive probe.
if strings.Contains(cmd, "kill -TERM 555") {
stopped = true
return ok("")
}
// Stop verifies the pid is our serve (ServeAliveCommand) before signalling.
if strings.Contains(cmd, "ps -p 555") {
return ok("1\n")
}
return ok("")
})
if err := Stop(context.Background(), conn, "~"); err != nil {
t.Fatalf("Stop: %v", err)
}
if !stopped {
t.Error("Stop did not TERM the pid")
}
if _, err := os.Stat(paths.StateJSON); !os.IsNotExist(err) {
t.Error("state file not removed")
}
if _, err := os.Stat(paths.TokenFile); !os.IsNotExist(err) {
t.Error("token file not removed")
}
}
var _ = filepath.Join
+117
View File
@@ -0,0 +1,117 @@
package bootstrap
import (
"context"
"os"
"strings"
"sync/atomic"
"testing"
"time"
"reasonix/internal/remote"
)
func TestEnsureServeRejectsStalePortFile(t *testing.T) {
skipOnWindows(t)
root := t.TempDir()
paths := pathsFor(root, root)
if err := os.MkdirAll(paths.Dir, 0o700); err != nil {
t.Fatal(err)
}
if err := os.WriteFile(paths.PortFile, []byte("127.0.0.1:49999\n"), 0o600); err != nil {
t.Fatal(err)
}
conn := newFakeConn(t, root, func(cmd string) (remote.ExecResult, error) {
switch {
case strings.Contains(cmd, "uname"):
return ok("Linux x86_64\n")
case strings.Contains(cmd, "command -v reasonix"):
return ok("/usr/bin/reasonix\nreasonix v9.9.0\nportfile:yes\n")
case strings.Contains(cmd, "nohup"):
if strings.Contains(cmd, "rm -f "+shellQuote(paths.PortFile)) {
_ = os.Remove(paths.PortFile) // model the generated launch command
}
return ok("12345\n") // the new serve never publishes a port
default:
return ok("")
}
})
ctx, cancel := context.WithTimeout(context.Background(), 300*time.Millisecond)
defer cancel()
if res, err := EnsureServe(ctx, conn, Options{Workspace: "~"}); err == nil {
t.Fatalf("accepted a stale port as a successful launch: %+v", res.State)
}
}
func TestEnsureServeSerializesConcurrentClients(t *testing.T) {
skipOnWindows(t)
root := t.TempDir()
paths := pathsFor(root, root)
var launches atomic.Int32
conn := newFakeConn(t, root, func(cmd string) (remote.ExecResult, error) {
switch {
case strings.Contains(cmd, "uname"):
return ok("Linux x86_64\n")
case strings.Contains(cmd, "command -v reasonix"):
return ok("/usr/bin/reasonix\nreasonix v9.9.0\nportfile:yes\n")
case strings.Contains(cmd, "nohup"):
launches.Add(1)
_ = os.WriteFile(paths.PortFile, []byte("127.0.0.1:45123\n"), 0o600)
return ok("321\n")
case strings.Contains(cmd, "ps -p 321"):
return ok("1\n")
default:
return ok("")
}
})
type outcome struct {
res Result
err error
}
start := make(chan struct{})
out := make(chan outcome, 2)
for range 2 {
go func() {
<-start
res, err := EnsureServe(context.Background(), conn, Options{Workspace: "~"})
out <- outcome{res: res, err: err}
}()
}
close(start)
var reused int
for range 2 {
got := <-out
if got.err != nil {
t.Fatal(got.err)
}
if got.res.Reused {
reused++
}
}
if got := launches.Load(); got != 1 || reused != 1 {
t.Fatalf("launches=%d reused=%d, want 1/1", got, reused)
}
}
func TestAutoInstallPreservesNPMFailureWhenNoUploadBinaryExists(t *testing.T) {
skipOnWindows(t)
root := t.TempDir()
conn := newFakeConn(t, root, func(cmd string) (remote.ExecResult, error) {
switch {
case strings.Contains(cmd, "command -v reasonix"):
return ok("\n")
case strings.Contains(cmd, "npm i -g reasonix"):
return remote.ExecResult{Stdout: []byte("permission denied"), ExitCode: 1}, nil
default:
return ok("")
}
})
_, _, err := ensureBinary(context.Background(), conn, conn.fs, Options{Install: InstallAuto}, root, "linux", "amd64", pathsFor(root, root))
if err == nil {
t.Fatal("auto install unexpectedly succeeded")
}
message := err.Error()
if !strings.Contains(message, "npm install failed: permission denied") || !strings.Contains(message, "no local Reasonix CLI") {
t.Fatalf("auto install hid the actionable failures: %v", err)
}
}
+136
View File
@@ -0,0 +1,136 @@
package bootstrap
import (
"context"
"fmt"
"os"
"strings"
"reasonix/internal/remote/sftpfs"
)
// ensureBinary resolves a usable reasonix binary on the remote host per the
// install strategy, returning its path and version. A located binary older
// than MinVersion counts as missing (it lacks --port-file/--token-file).
func ensureBinary(ctx context.Context, conn Conn, fs *sftpfs.FS, opts Options, home, goos, goarch string, paths StatePaths) (bin, version string, err error) {
uploaded := uploadedBinPath(home)
bin, version = locate(ctx, conn, uploaded, opts.MinVersion)
if bin != "" {
return bin, version, nil
}
strategy := opts.Install
if strategy == "" {
strategy = InstallAuto
}
opts.progress("install", strategy)
switch strategy {
case InstallNever:
return "", "", fmt.Errorf("bootstrap: reasonix not found on remote and serve_install = never")
case InstallNPM:
return installViaNPM(ctx, conn, opts.MinVersion)
case InstallUpload:
return installViaUpload(ctx, conn, fs, opts, home, goos, goarch, uploaded)
default: // auto: try npm, then upload
if b, v, nerr := installViaNPM(ctx, conn, opts.MinVersion); nerr == nil {
return b, v, nil
} else if opts.LocalBinary == "" {
return "", "", fmt.Errorf("%w; bootstrap: no local Reasonix CLI is available for upload", nerr)
}
return installViaUpload(ctx, conn, fs, opts, home, goos, goarch, uploaded)
}
}
// locate finds an existing reasonix and returns it only if its serve command
// supports --port-file (the bootstrap contract). A binary that lacks the flag —
// including every currently-released version — is reported as missing so the
// install/upload path replaces it. minVersion is accepted for signature
// stability but the flag probe is authoritative.
func locate(ctx context.Context, conn Conn, uploaded, minVersion string) (bin, version string) {
_ = minVersion
res, err := conn.Exec(ctx, LocateCommand(uploaded))
if err != nil {
return "", ""
}
lines := strings.Split(strings.TrimRight(string(res.Stdout), "\n"), "\n")
path := strings.TrimSpace(lines[0])
if path == "" {
return "", ""
}
supportsPortFile := false
for _, ln := range lines[1:] {
ln = strings.TrimSpace(ln)
if ln == "portfile:yes" {
supportsPortFile = true
} else if ln == "portfile:no" {
supportsPortFile = false
} else if v, verr := ParseVersion(ln); verr == nil {
version = v
}
}
if !supportsPortFile {
// Missing the --port-file flag: treat as unusable so it is upgraded.
return "", ""
}
return path, version
}
func installViaNPM(ctx context.Context, conn Conn, minVersion string) (bin, version string, err error) {
res, err := conn.Exec(ctx, "npm i -g reasonix 2>&1")
if err != nil {
return "", "", fmt.Errorf("bootstrap: npm install: %w", err)
}
if res.ExitCode != 0 {
return "", "", fmt.Errorf("bootstrap: npm install failed: %s", tail(res.Stdout, 400))
}
// npm may install outside the login PATH; probe npm prefix explicitly.
loc, ver := locate(ctx, conn, "", minVersion)
if loc == "" {
return "", "", fmt.Errorf("bootstrap: reasonix not found after npm install (check remote PATH / npm prefix)")
}
return loc, ver, nil
}
// installViaUpload uploads the local reasonix binary when the remote platform
// matches the local one. Cross-platform release download is a documented V1
// limitation: use serve_install = npm for a differing remote platform.
func installViaUpload(ctx context.Context, conn Conn, fs *sftpfs.FS, opts Options, home, goos, goarch, uploaded string) (bin, version string, err error) {
if opts.LocalBinary == "" {
return "", "", fmt.Errorf("bootstrap: upload strategy needs the local reasonix binary path")
}
if opts.LocalGOOS != goos || opts.LocalGOARCH != goarch {
return "", "", fmt.Errorf("bootstrap: cannot upload: local binary is %s/%s but remote is %s/%s; use serve_install = npm",
opts.LocalGOOS, opts.LocalGOARCH, goos, goarch)
}
data, rerr := os.ReadFile(opts.LocalBinary)
if rerr != nil {
return "", "", fmt.Errorf("bootstrap: read local binary: %w", rerr)
}
if err := fs.MkdirAll(ctx, dirOf(uploaded)); err != nil {
return "", "", err
}
if err := fs.WriteFileAtomic(ctx, uploaded, data, 0o755); err != nil {
return "", "", fmt.Errorf("bootstrap: upload binary: %w", err)
}
loc, ver := locate(ctx, conn, uploaded, opts.MinVersion)
if loc == "" {
return "", "", fmt.Errorf("bootstrap: uploaded binary not runnable on remote")
}
return loc, ver, nil
}
func dirOf(p string) string {
if i := strings.LastIndex(p, "/"); i >= 0 {
return p[:i]
}
return "."
}
func tail(b []byte, n int) string {
s := strings.TrimSpace(string(b))
if len(s) > n {
return "..." + s[len(s)-n:]
}
return s
}
+116
View File
@@ -0,0 +1,116 @@
package bootstrap
import (
"fmt"
"strings"
)
// StatePaths are the absolute remote-side paths for one workspace's serve
// state. All are under ~/.reasonix/remote.
type StatePaths struct {
Dir string // ~/.reasonix/remote
StateJSON string
TokenFile string
LogFile string
PortFile string
PidFile string
LockDir string
LockOwner string
}
// shellQuote wraps s in single quotes safe for POSIX sh, escaping embedded
// single quotes as '\”. This is the only quoting used for remote command
// operands; every interpolated path/workspace passes through it.
func shellQuote(s string) string {
return "'" + strings.ReplaceAll(s, "'", `'\''`) + "'"
}
// LaunchCommand builds the `sh -c` script that starts a detached serve in
// workspace, writing the port/pid files and appending output to the log. The
// binary path and every operand are single-quote-escaped so hostile paths
// (spaces, quotes, `; rm -rf ~`) cannot break out.
//
// Detachment: `setsid` fully divorces the process from any session, but it is
// absent on stock macOS, so it is used only when present (`$SX`); `nohup` +
// backgrounding + `</dev/null` is sufficient over a non-interactive SSH exec.
// The log is created 0600 (umask 077 + explicit chmod) so a same-machine user
// cannot read serve output; serve is launched with `--port-file`, which
// suppresses its token share line, so the token never reaches the log.
// It echoes the shell's $! so the caller can record the pid immediately.
func LaunchCommand(bin, workspace string, p StatePaths) string {
return fmt.Sprintf(
"mkdir -p %s && cd %s && rm -f %s %s && umask 077 && : >>%s && chmod 600 %s && "+
"SX=; command -v setsid >/dev/null 2>&1 && SX=setsid; "+
"$SX nohup %s serve --addr 127.0.0.1:0 --auth token --token-file %s --port-file %s --pid-file %s </dev/null >>%s 2>&1 & echo $!",
shellQuote(p.Dir),
shellQuote(workspace),
shellQuote(p.PortFile),
shellQuote(p.PidFile),
shellQuote(p.LogFile),
shellQuote(p.LogFile),
shellQuote(bin),
shellQuote(p.TokenFile),
shellQuote(p.PortFile),
shellQuote(p.PidFile),
shellQuote(p.LogFile),
)
}
// StopCommand builds a script that TERMs the pid, waits up to ~5s, then KILLs
// if still alive. pid is validated numeric by the caller, and the caller has
// already confirmed (ServeAliveCommand) that the pid is our serve, so PID reuse
// cannot cause an unrelated process to be signalled.
func StopCommand(pid int, p StatePaths) string {
return fmt.Sprintf(
"T=%s; P=%s; ours() { A=$(ps -p %d -o args= 2>/dev/null || ps -p %d -o command= 2>/dev/null); "+
"case \"$A\" in *reasonix*serve*\"$T\"*\"$P\"*) return 0;; *) return 1;; esac; }; "+
"ours || exit 0; kill -TERM %d 2>/dev/null; "+
"for i in 1 2 3 4 5; do kill -0 %d 2>/dev/null || exit 0; ours || exit 0; sleep 1; done; "+
"ours && kill -KILL %d 2>/dev/null; exit 0",
shellQuote(p.TokenFile), shellQuote(p.PortFile), pid, pid, pid, pid, pid,
)
}
// ServeAliveCommand prints "1" only when pid is running AND its command line
// looks like a reasonix serve process. Checking the args (not just `kill -0`)
// prevents a recycled PID — now owned by an unrelated process — from being
// mistaken for the serve and later signalled by StopCommand.
func ServeAliveCommand(pid int, p StatePaths) string {
return fmt.Sprintf(
"T=%s; P=%s; kill -0 %d 2>/dev/null || { echo 0; exit 0; }; "+
"A=$(ps -p %d -o args= 2>/dev/null || ps -p %d -o command= 2>/dev/null); "+
"case \"$A\" in *reasonix*serve*\"$T\"*\"$P\"*) echo 1;; *) echo 0;; esac",
shellQuote(p.TokenFile), shellQuote(p.PortFile), pid, pid, pid,
)
}
// LogsCommand tails n lines of the log file (n<=0 => 200).
func LogsCommand(logFile string, n int) string {
if n <= 0 {
n = 200
}
return fmt.Sprintf("tail -n %d %s 2>/dev/null || true", n, shellQuote(logFile))
}
// servePortFileMarker is what LocateCommand greps for in `serve --help` to
// decide the located binary supports --port-file/--token-file. It must match
// the flag name registered in runServe.
const servePortFileMarker = "port-file"
// LocateCommand probes for a usable reasonix binary. It prints three lines:
// the resolved path (or empty), the `--version` output, and "portfile:yes" when
// `serve --help` advertises the --port-file flag. The bootstrap gates on the
// flag, not the version number, because --port-file/--token-file ship in this
// change: a version gate cannot know its own future release number, and any
// already-released binary would pass a numeric gate yet still lack the flags.
func LocateCommand(uploadedBin string) string {
return fmt.Sprintf(
"BIN=\"$(command -v reasonix 2>/dev/null)\"; "+
"if [ -z \"$BIN\" ] && [ -x %s ]; then BIN=%s; fi; "+
"if [ -z \"$BIN\" ]; then P=\"$(npm prefix -g 2>/dev/null)\"; if [ -n \"$P\" ] && [ -x \"$P/bin/reasonix\" ]; then BIN=\"$P/bin/reasonix\"; fi; fi; "+
"echo \"$BIN\"; "+
"if [ -n \"$BIN\" ]; then \"$BIN\" --version 2>/dev/null; "+
"if \"$BIN\" serve --help 2>&1 | grep -q -- %s; then echo portfile:yes; else echo portfile:no; fi; fi",
shellQuote(uploadedBin), shellQuote(uploadedBin), shellQuote(servePortFileMarker),
)
}
+94
View File
@@ -0,0 +1,94 @@
package bootstrap
import (
"context"
"fmt"
"strconv"
"strings"
"time"
"reasonix/internal/remote/sftpfs"
)
const (
serveLockPoll = 100 * time.Millisecond
serveLockStaleAfter = 60 * time.Second
)
type serveLock struct {
fs *sftpfs.FS
paths StatePaths
owner string
}
// acquireServeLock serializes the short launch/publish critical section across
// CLI processes, desktop windows, and reconnect generations. The expensive
// locate/install phase stays outside the lock. A crashed owner's directory is
// reclaimed only after a minute; the guarded health check itself is bounded to
// 20 seconds, so a live owner cannot legitimately age past that threshold.
func acquireServeLock(ctx context.Context, fs *sftpfs.FS, paths StatePaths, clock func() time.Time) (*serveLock, error) {
if err := fs.MkdirAll(ctx, paths.Dir); err != nil {
return nil, err
}
token, err := generateToken()
if err != nil {
return nil, err
}
owner := strconv.FormatInt(clock().Unix(), 10) + ":" + token
for {
mkdirErr := fs.MkdirExclusive(ctx, paths.LockDir)
if mkdirErr == nil {
if err := fs.WriteFileAtomic(ctx, paths.LockOwner, []byte(owner+"\n"), 0o600); err != nil {
_ = fs.Remove(context.Background(), paths.LockDir, true)
return nil, fmt.Errorf("bootstrap: write serve lock owner: %w", err)
}
return &serveLock{fs: fs, paths: paths, owner: owner}, nil
}
lockInfo, statErr := fs.Stat(ctx, paths.LockDir)
if statErr != nil || !lockInfo.IsDir {
return nil, fmt.Errorf("bootstrap: create serve lock: %w", mkdirErr)
}
data, _, _, readErr := fs.ReadFile(ctx, paths.LockOwner, 512)
if readErr == nil {
observed := strings.TrimSpace(string(data))
parts := strings.SplitN(observed, ":", 2)
created, parseErr := strconv.ParseInt(parts[0], 10, 64)
if parseErr == nil && len(parts) == 2 && clock().Sub(time.Unix(created, 0)) > serveLockStaleAfter {
// Compare the owner again immediately before removal. A new owner never
// inherits the old random token, so we cannot delete a replacement lock.
current, _, _, currentErr := fs.ReadFile(ctx, paths.LockOwner, 512)
if currentErr == nil && strings.TrimSpace(string(current)) == observed {
_ = fs.Remove(ctx, paths.LockDir, true)
continue
}
}
} else if clock().Sub(time.Unix(lockInfo.ModTime, 0)) > serveLockStaleAfter {
// The creator may have crashed between mkdir and writing owner. The
// critical section cannot legitimately leave an owner-less directory
// this old, so reclaim it.
if _, _, _, currentErr := fs.ReadFile(ctx, paths.LockOwner, 512); currentErr != nil {
_ = fs.Remove(ctx, paths.LockDir, true)
continue
}
}
select {
case <-ctx.Done():
return nil, fmt.Errorf("bootstrap: wait for serve lock: %w", ctx.Err())
case <-time.After(serveLockPoll):
}
}
}
func (l *serveLock) release() {
if l == nil {
return
}
ctx, cancel := context.WithTimeout(context.Background(), 2*time.Second)
defer cancel()
data, _, _, err := l.fs.ReadFile(ctx, l.paths.LockOwner, 512)
if err == nil && strings.TrimSpace(string(data)) == l.owner {
_ = l.fs.Remove(ctx, l.paths.LockDir, true)
}
}
+109
View File
@@ -0,0 +1,109 @@
package bootstrap
import (
"fmt"
"strings"
)
// ParseUname maps `uname -sm` output to Go GOOS/GOARCH. V1 supports Linux and
// macOS remotes; anything else (including Windows shells) is an error.
func ParseUname(out string) (goos, goarch string, err error) {
fields := strings.Fields(strings.TrimSpace(out))
if len(fields) < 2 {
return "", "", fmt.Errorf("bootstrap: cannot parse `uname -sm` output %q", out)
}
sys, machine := fields[0], fields[1]
switch strings.ToLower(sys) {
case "linux":
goos = "linux"
case "darwin":
goos = "darwin"
default:
return "", "", fmt.Errorf("bootstrap: unsupported remote OS %q (V1 supports Linux and macOS)", sys)
}
switch strings.ToLower(machine) {
case "x86_64", "amd64":
goarch = "amd64"
case "aarch64", "arm64":
goarch = "arm64"
case "armv7l", "armv6l", "arm":
goarch = "arm"
default:
return "", "", fmt.Errorf("bootstrap: unsupported remote architecture %q", machine)
}
return goos, goarch, nil
}
// ParseVersion extracts a semver-ish string from `reasonix --version` output
// like "reasonix v1.9.0" or "1.9.0".
func ParseVersion(out string) (string, error) {
for _, field := range strings.Fields(strings.TrimSpace(out)) {
v := strings.TrimPrefix(field, "v")
if looksLikeSemver(v) {
return v, nil
}
}
return "", fmt.Errorf("bootstrap: no version found in %q", out)
}
func looksLikeSemver(v string) bool {
parts := strings.SplitN(v, "-", 2)[0]
seg := strings.Split(parts, ".")
if len(seg) < 2 {
return false
}
for _, s := range seg {
if s == "" {
return false
}
for _, r := range s {
if r < '0' || r > '9' {
return false
}
}
}
return true
}
// CompareVersions returns -1, 0, or 1 comparing dotted numeric versions.
// Pre-release suffixes (after '-') are ignored for ordering. Non-numeric or
// missing segments compare as 0.
func CompareVersions(a, b string) int {
as := versionSegments(a)
bs := versionSegments(b)
for i := 0; i < len(as) || i < len(bs); i++ {
var av, bv int
if i < len(as) {
av = as[i]
}
if i < len(bs) {
bv = bs[i]
}
if av < bv {
return -1
}
if av > bv {
return 1
}
}
return 0
}
func versionSegments(v string) []int {
v = strings.TrimPrefix(strings.TrimSpace(v), "v")
v = strings.SplitN(v, "-", 2)[0]
parts := strings.Split(v, ".")
out := make([]int, 0, len(parts))
for _, p := range parts {
n := 0
for _, r := range p {
if r < '0' || r > '9' {
n = 0
break
}
n = n*10 + int(r-'0')
}
out = append(out, n)
}
return out
}
+70
View File
@@ -0,0 +1,70 @@
package bootstrap
import (
"encoding/json"
"path"
"time"
"reasonix/internal/store"
)
// ServeState is the JSON record a bootstrapped serve leaves on the remote host
// so a later (re)connect can find and reuse it. Fields use omitempty so an
// older record missing a field still decodes.
type ServeState struct {
PID int `json:"pid"`
Addr string `json:"addr"` // 127.0.0.1:<port> on the remote host
Workspace string `json:"workspace"`
Version string `json:"version,omitempty"`
TokenFile string `json:"token_file"`
LogFile string `json:"log_file,omitempty"`
StartedAt int64 `json:"started_at,omitempty"` // unix seconds
}
// MarshalState renders a ServeState as indented JSON.
func MarshalState(s ServeState) ([]byte, error) {
return json.MarshalIndent(s, "", " ")
}
// UnmarshalState parses a ServeState record.
func UnmarshalState(data []byte) (ServeState, error) {
var s ServeState
if err := json.Unmarshal(data, &s); err != nil {
return ServeState{}, err
}
return s, nil
}
// remoteDir is the ~/.reasonix/remote directory given the resolved remote home.
func remoteDir(home string) string {
return path.Join(home, ".reasonix", store.RemoteDirName)
}
// pathsFor derives every per-workspace state path from the resolved remote
// home and workspace directory.
func pathsFor(home, workspace string) StatePaths {
dir := remoteDir(home)
slug := store.RemoteWorkspaceSlug(workspace)
return StatePaths{
Dir: dir,
StateJSON: path.Join(dir, store.RemoteServeStateName(slug)),
TokenFile: path.Join(dir, store.RemoteServeTokenName(slug)),
LogFile: path.Join(dir, store.RemoteServeLogName(slug)),
PortFile: path.Join(dir, store.RemoteServePortName(slug)),
PidFile: path.Join(dir, store.RemoteServePidName(slug)),
LockDir: path.Join(dir, store.RemoteServeLockName(slug)),
LockOwner: path.Join(dir, store.RemoteServeLockName(slug), "owner"),
}
}
// uploadedBinPath is the fallback location for an uploaded reasonix binary.
func uploadedBinPath(home string) string {
return path.Join(remoteDir(home), store.RemoteBinDirName, "reasonix")
}
func nowUnix(clock func() time.Time) int64 {
if clock == nil {
return 0
}
return clock().Unix()
}
+55
View File
@@ -0,0 +1,55 @@
package bootstrap
import (
"strings"
"testing"
)
func TestServeStateRoundTrip(t *testing.T) {
st := ServeState{
PID: 1234,
Addr: "127.0.0.1:38121",
Workspace: "/home/dev/app",
Version: "1.9.0",
TokenFile: "/home/dev/.reasonix/remote/serve-x.token",
LogFile: "/home/dev/.reasonix/remote/serve-x.log",
StartedAt: 1_700_000_000,
}
data, err := MarshalState(st)
if err != nil {
t.Fatal(err)
}
got, err := UnmarshalState(data)
if err != nil {
t.Fatal(err)
}
if got != st {
t.Fatalf("round trip mismatch:\n got %+v\nwant %+v", got, st)
}
}
// TestServeStateBackwardCompat: an older record missing the newer optional
// fields must decode without error, leaving zero values.
func TestServeStateBackwardCompat(t *testing.T) {
old := `{"pid": 42, "addr": "127.0.0.1:9000", "workspace": "/app", "token_file": "/t"}`
st, err := UnmarshalState([]byte(old))
if err != nil {
t.Fatalf("old record failed to decode: %v", err)
}
if st.PID != 42 || st.Addr != "127.0.0.1:9000" || st.Version != "" || st.StartedAt != 0 {
t.Fatalf("backward-compat decode wrong: %+v", st)
}
}
func TestPathsFor(t *testing.T) {
paths := pathsFor("/home/dev", "/home/dev/projects/app")
if paths.Dir != "/home/dev/.reasonix/remote" {
t.Fatalf("Dir = %q", paths.Dir)
}
if !strings.HasPrefix(paths.StateJSON, paths.Dir+"/serve-") || !strings.HasSuffix(paths.StateJSON, ".json") {
t.Fatalf("StateJSON = %q", paths.StateJSON)
}
if !strings.HasSuffix(paths.TokenFile, ".token") || !strings.HasSuffix(paths.PortFile, ".port") {
t.Fatalf("token/port names wrong: %+v", paths)
}
}
+476
View File
@@ -0,0 +1,476 @@
package remote
import (
"bytes"
"context"
"errors"
"fmt"
"math/rand"
"sync"
"time"
"golang.org/x/crypto/ssh"
"reasonix/internal/netclient"
"reasonix/internal/remote/forward"
"reasonix/internal/remote/sftpfs"
)
// Options configures a Client. Host, Auth, and HostKeys are required; the rest
// default sensibly.
type Options struct {
Host ResolvedHost
Auth AuthOptions
JumpHosts []JumpHostOptions // resolved ProxyJump hosts in chain order
HostKeys *HostKeyPolicy
Dialer netclient.StreamDialer // first-hop transport; nil => direct
DialTimeout time.Duration // default 15s
Keepalive KeepalivePolicy
Backoff BackoffPolicy
Clock Clock // nil => real clock
Rand *rand.Rand
}
// JumpHostOptions binds one resolved ProxyJump host to credentials owned by
// that hop. Target credentials are never inherited implicitly.
type JumpHostOptions struct {
Host ResolvedHost
Auth AuthOptions
}
// Client is a supervised SSH connection: it dials, verifies the host key,
// attaches forwards, keeps the link alive, and reconnects with backoff. It
// shares one SFTP session and one forward.Set across its lifetime.
type Client struct {
opts Options
clock Clock
rng *rand.Rand
hub *statusHub
forwards *forward.Set
mu sync.Mutex
ssh *ssh.Client
hops []*ssh.Client
sftp *sftpfs.FS
generation uint64 // bumps on every (re)connect; SFTP handles carry it
status Status
closed bool
hopHosts map[string]ResolvedHost
hopAuths map[string]*AuthOptions // fallback auth cache, keyed by user+addr
hopRawAuths map[string]*AuthOptions // configured auth by alias; aliases may share an endpoint
cancel context.CancelFunc
done chan struct{}
}
// hopAuthFor returns a persistent AuthOptions for a jump host. It deliberately
// omits the target's Password/Passphrase closures and gives each jump host its
// own secret cache, so the target's password_env is never sent to a jump host
// and one hop's typed secret is never reused for another. The instance persists
// for the Client's lifetime so reconnects do not re-prompt for jump secrets.
func (c *Client) hopAuthFor(hop ResolvedHost) *AuthOptions {
c.mu.Lock()
defer c.mu.Unlock()
if c.hopAuths == nil {
c.hopAuths = map[string]*AuthOptions{}
}
key := hopAuthKey(hop)
if a, ok := c.hopAuths[key]; ok {
return a
}
a := &AuthOptions{
SecretPrompt: c.opts.Auth.SecretPrompt,
DisableAgent: c.opts.Auth.DisableAgent,
}
c.hopAuths[key] = a
return a
}
func hopAuthKey(hop ResolvedHost) string { return hop.User + "\x00" + hop.Addr() }
// resolveHop returns the pre-resolved config/ssh_config host when the assembly
// layer supplied one, with a conservative ad-hoc fallback for low-level users.
func (c *Client) resolveHop(raw string) (ResolvedHost, *AuthOptions, error) {
c.mu.Lock()
hop, ok := c.hopHosts[raw]
auth := c.hopRawAuths[raw]
c.mu.Unlock()
if ok {
return hop, auth, nil
}
userName, hostName, port, err := ParseTarget(raw)
if err != nil {
return ResolvedHost{}, nil, err
}
hop = ResolvedHost{Name: raw, HostName: hostName, Port: port, User: userName}
applyHostDefaults(&hop)
return hop, c.hopAuthFor(hop), nil
}
// New creates a Client. It does not dial; call Start.
func New(opts Options) (*Client, error) {
if opts.Host.HostName == "" {
return nil, errors.New("remote: Options.Host has no hostname")
}
if opts.HostKeys == nil {
opts.HostKeys = &HostKeyPolicy{}
}
clock := opts.Clock
if clock == nil {
clock = realClock{}
}
rng := opts.Rand
if rng == nil {
rng = rand.New(rand.NewSource(time.Now().UnixNano()))
}
c := &Client{
opts: opts,
clock: clock,
rng: rng,
hub: newStatusHub(),
status: StatusIdle,
done: make(chan struct{}),
hopHosts: map[string]ResolvedHost{},
hopAuths: map[string]*AuthOptions{},
hopRawAuths: map[string]*AuthOptions{},
}
if len(opts.JumpHosts) > 0 && len(opts.JumpHosts) != len(opts.Host.ProxyJump) {
return nil, fmt.Errorf("remote: %d resolved jump hosts for %d ProxyJump entries", len(opts.JumpHosts), len(opts.Host.ProxyJump))
}
for i, jump := range opts.JumpHosts {
if jump.Host.HostName == "" {
return nil, fmt.Errorf("remote: ProxyJump %d has no hostname", i+1)
}
raw := opts.Host.ProxyJump[i]
auth := jump.Auth
c.hopHosts[raw] = jump.Host
c.hopRawAuths[raw] = &auth
}
c.forwards = forward.NewSet(nil)
return c, nil
}
// Subscribe registers a status callback; it receives the current event
// immediately and every subsequent transition. Callbacks must not block.
func (c *Client) Subscribe(fn func(StatusEvent)) (cancel func()) {
return c.hub.subscribe(fn)
}
// Status returns the last published status event.
func (c *Client) Status() StatusEvent { return c.hub.current() }
// Forwards returns the port-forward registry bound to this client's lifecycle.
func (c *Client) Forwards() *forward.Set { return c.forwards }
// SSH returns the current ssh client, or ErrNotConnected while down.
func (c *Client) SSH() (*ssh.Client, error) {
c.mu.Lock()
defer c.mu.Unlock()
if c.ssh == nil {
return nil, ErrNotConnected
}
return c.ssh, nil
}
// SFTP returns the shared SFTP handle, lazily created for the current
// connection. Handles obtained before a reconnect return ErrDisconnected on
// use; re-fetch via SFTP.
func (c *Client) SFTP() (*sftpfs.FS, error) {
c.mu.Lock()
defer c.mu.Unlock()
if c.ssh == nil {
return nil, ErrNotConnected
}
if c.sftp != nil {
return c.sftp, nil
}
fsys, err := sftpfs.New(c.ssh)
if err != nil {
return nil, err
}
c.sftp = fsys
return c.sftp, nil
}
// ExecResult is the outcome of a one-shot remote command.
type ExecResult struct {
Stdout []byte
Stderr []byte
ExitCode int
}
// Exec runs cmd via `sh -c` on a fresh session and collects its output.
func (c *Client) Exec(ctx context.Context, cmd string) (ExecResult, error) {
cl, err := c.SSH()
if err != nil {
return ExecResult{}, err
}
type res struct {
out ExecResult
err error
}
ch := make(chan res, 1)
go func() {
sess, serr := cl.NewSession()
if serr != nil {
ch <- res{err: serr}
return
}
defer sess.Close()
var stdout, stderr bytes.Buffer
sess.Stdout = &stdout
sess.Stderr = &stderr
runErr := sess.Run(cmd)
out := ExecResult{Stdout: stdout.Bytes(), Stderr: stderr.Bytes()}
if runErr != nil {
var ee *ssh.ExitError
if errors.As(runErr, &ee) {
out.ExitCode = ee.ExitStatus()
ch <- res{out: out}
return
}
ch <- res{out: out, err: runErr}
return
}
ch <- res{out: out}
}()
select {
case <-ctx.Done():
return ExecResult{}, ctx.Err()
case r := <-ch:
return r.out, r.err
}
}
// Start dials and blocks until the first Connected (returns nil) or an
// unrecoverable error / ctx cancellation (returns the error). The supervisor
// keeps running after a successful Start; call Close to stop it.
func (c *Client) Start(ctx context.Context) error {
c.mu.Lock()
if c.closed {
c.mu.Unlock()
return errors.New("remote: client closed")
}
superCtx, cancel := context.WithCancel(context.Background())
c.cancel = cancel
c.mu.Unlock()
firstResult := make(chan error, 1)
go c.supervise(superCtx, firstResult)
select {
case <-ctx.Done():
cancel()
return ctx.Err()
case err := <-firstResult:
return err
}
}
// Close stops the supervisor and releases the connection, forwards, and SFTP.
func (c *Client) Close() error {
c.mu.Lock()
if c.closed {
c.mu.Unlock()
return nil
}
c.closed = true
cancel := c.cancel
c.mu.Unlock()
if cancel != nil {
cancel()
<-c.done
} else {
c.teardownConn()
c.publish(StatusStopped, 0, nil)
}
c.forwards.Close()
return nil
}
// supervise is the single goroutine that owns the connection lifecycle.
func (c *Client) supervise(ctx context.Context, firstResult chan<- error) {
defer close(c.done)
firstDone := false
sendFirst := func(err error) {
if !firstDone {
firstDone = true
firstResult <- err
}
}
attempt := 0
for {
if attempt == 0 {
c.publish(StatusConnecting, 0, nil)
} else {
c.publish(StatusReconnecting, attempt, nil)
}
cl, hops, err := dialSSH(ctx, dialConfig{
host: c.opts.Host,
auth: &c.opts.Auth,
resolveHop: c.resolveHop,
hostKeys: c.opts.HostKeys,
dialer: c.opts.Dialer,
dialTimeout: c.opts.DialTimeout,
})
if err != nil {
if ctx.Err() != nil {
c.publish(StatusStopped, attempt, ctx.Err())
sendFirst(ctx.Err())
return
}
if errors.Is(err, ErrAuthFailed) || errors.Is(err, ErrHostKeyMismatch) || errors.Is(err, ErrHostKeyRejected) {
// Unrecoverable: stop rather than loop.
c.publish(StatusStopped, attempt, err)
sendFirst(err)
return
}
if !firstDone {
// The very first connect failed on a transient error; report it
// so callers get immediate feedback instead of a silent retry.
c.publish(StatusStopped, attempt, err)
sendFirst(err)
return
}
attempt++
if !c.sleepBackoff(ctx, attempt) {
c.publish(StatusStopped, attempt, ctx.Err())
return
}
continue
}
// Connected. Install the connection and attach forwards.
c.installConn(cl, hops)
if attachErr := c.forwards.Attach(cl); attachErr != nil {
c.publish(StatusDegraded, attempt, attachErr)
} else {
c.publish(StatusConnected, attempt, nil)
}
sendFirst(nil)
// Block until the connection dies, ctx ends, or Close.
reason := c.watch(ctx, cl)
c.forwards.Detach()
c.teardownConn()
if ctx.Err() != nil || reason == watchClosed {
c.publish(StatusStopped, attempt, ctx.Err())
return
}
// Connection dropped: reconnect with backoff.
attempt++
if !c.sleepBackoff(ctx, attempt) {
c.publish(StatusStopped, attempt, ctx.Err())
return
}
}
}
type watchReason int
const (
watchConnLost watchReason = iota
watchClosed
)
// watch runs the keepalive loop and returns when the connection dies or ctx
// ends.
func (c *Client) watch(ctx context.Context, cl *ssh.Client) watchReason {
closed := make(chan struct{})
go func() {
_ = cl.Wait() // always non-nil at disconnect; the signal is the return itself
close(closed)
}()
interval := c.opts.Keepalive.interval()
misses := 0
for {
var tick <-chan time.Time
if interval > 0 {
tick = c.clock.After(interval)
}
select {
case <-ctx.Done():
return watchClosed
case <-closed:
return watchConnLost
case <-tick:
if c.keepaliveOK(cl) {
misses = 0
continue
}
misses++
if misses >= c.opts.Keepalive.maxMisses() {
return watchConnLost
}
}
}
}
func (c *Client) keepaliveOK(cl *ssh.Client) bool {
type res struct{ err error }
ch := make(chan res, 1)
go func() {
_, _, err := cl.SendRequest("[email protected]", true, nil)
ch <- res{err}
}()
select {
case <-c.clock.After(c.opts.Keepalive.timeout()):
return false
case r := <-ch:
return r.err == nil
}
}
// sleepBackoff waits a full-jitter backoff for attempt, returning false if ctx
// ended during the wait.
func (c *Client) sleepBackoff(ctx context.Context, attempt int) bool {
ceil := c.opts.Backoff.delay(attempt - 1)
d := time.Duration(c.rng.Int63n(int64(ceil) + 1))
select {
case <-ctx.Done():
return false
case <-c.clock.After(d):
return true
}
}
func (c *Client) installConn(cl *ssh.Client, hops []*ssh.Client) {
c.mu.Lock()
c.ssh = cl
c.hops = hops
c.sftp = nil
c.generation++
c.mu.Unlock()
}
func (c *Client) teardownConn() {
c.mu.Lock()
sftpH, cl, hops := c.sftp, c.ssh, c.hops
c.sftp, c.ssh, c.hops = nil, nil, nil
c.mu.Unlock()
if sftpH != nil {
_ = sftpH.Close()
}
if cl != nil {
_ = cl.Close()
}
closeAll(hops)
}
func (c *Client) publish(s Status, attempt int, err error) {
c.mu.Lock()
c.status = s
c.mu.Unlock()
c.hub.publish(StatusEvent{
Host: c.opts.Host.Name,
Status: s,
Attempt: attempt,
Err: err,
At: c.clock.Now(),
})
}
+179
View File
@@ -0,0 +1,179 @@
package remote
import (
"context"
"os"
"path/filepath"
"strings"
"testing"
"time"
"reasonix/internal/remote/sshtest"
)
// managedOnlyPolicy points the host-key policy at an isolated managed file and
// no system files, with an accept-all prompt, so tests never touch ~/.ssh.
func managedOnlyPolicy(t *testing.T, accept bool) *HostKeyPolicy {
t.Helper()
return &HostKeyPolicy{
SystemKnownHosts: []string{filepath.Join(t.TempDir(), "none")},
ManagedPath: filepath.Join(t.TempDir(), "known_hosts"),
Prompt: func(context.Context, HostKeyQuestion) (bool, error) {
return accept, nil
},
}
}
func newTestClient(t *testing.T, srv *sshtest.Server, opts Options) *Client {
t.Helper()
host, err := ResolveHost(nil, "test@"+srv.Addr, nil)
if err != nil {
t.Fatal(err)
}
opts.Host = host
if opts.HostKeys == nil {
opts.HostKeys = managedOnlyPolicy(t, true)
}
c, err := New(opts)
if err != nil {
t.Fatal(err)
}
return c
}
func TestClientConnectPasswordAuth(t *testing.T) {
srv := sshtest.Start(t, sshtest.Options{Password: "hunter2"})
c := newTestClient(t, srv, Options{
Auth: AuthOptions{
DisableAgent: true,
Password: func() (string, error) { return "hunter2", nil },
},
})
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
defer cancel()
if err := c.Start(ctx); err != nil {
t.Fatalf("Start: %v", err)
}
defer c.Close()
if c.Status().Status != StatusConnected {
t.Fatalf("status = %v, want connected", c.Status().Status)
}
res, err := c.Exec(ctx, "echo hello")
if err != nil {
t.Fatalf("Exec: %v", err)
}
if strings.TrimSpace(string(res.Stdout)) != "echo hello" {
t.Fatalf("exec stdout = %q", res.Stdout)
}
}
func TestClientConnectPublicKeyAuth(t *testing.T) {
pemBytes, pub, err := sshtest.GenerateKeyPEM()
if err != nil {
t.Fatal(err)
}
srv := sshtest.Start(t, sshtest.Options{AuthorizedKey: pub})
keyPath := filepath.Join(t.TempDir(), "id_ed25519")
if err := writeFile0600(keyPath, pemBytes); err != nil {
t.Fatal(err)
}
c := newTestClient(t, srv, Options{})
c.opts.Host.IdentityFile = keyPath
c.opts.Auth.DisableAgent = true
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
defer cancel()
if err := c.Start(ctx); err != nil {
t.Fatalf("Start: %v", err)
}
defer c.Close()
if c.Status().Status != StatusConnected {
t.Fatalf("status = %v", c.Status().Status)
}
}
func TestClientAuthFailureStops(t *testing.T) {
srv := sshtest.Start(t, sshtest.Options{Password: "correct"})
c := newTestClient(t, srv, Options{
Auth: AuthOptions{
DisableAgent: true,
Password: func() (string, error) { return "wrong", nil },
},
})
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
defer cancel()
err := c.Start(ctx)
if err == nil {
t.Fatal("expected auth failure")
}
if c.Status().Status != StatusStopped {
t.Fatalf("status = %v, want stopped", c.Status().Status)
}
}
func TestClientHostKeyRejectedStops(t *testing.T) {
srv := sshtest.Start(t, sshtest.Options{Password: "x"})
c := newTestClient(t, srv, Options{
HostKeys: managedOnlyPolicy(t, false), // reject TOFU
Auth: AuthOptions{
DisableAgent: true,
Password: func() (string, error) { return "x", nil },
},
})
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
defer cancel()
err := c.Start(ctx)
if err == nil {
t.Fatal("expected host key rejection")
}
}
func TestClientHostKeyTOFUPersistsAndReconnectsSilently(t *testing.T) {
srv := sshtest.Start(t, sshtest.Options{Password: "x"})
managed := filepath.Join(t.TempDir(), "known_hosts")
prompted := 0
policy := &HostKeyPolicy{
SystemKnownHosts: []string{filepath.Join(t.TempDir(), "none")},
ManagedPath: managed,
Prompt: func(context.Context, HostKeyQuestion) (bool, error) {
prompted++
return true, nil
},
}
host, _ := ResolveHost(nil, "test@"+srv.Addr, nil)
mkClient := func() *Client {
c, err := New(Options{
Host: host,
HostKeys: policy,
Auth: AuthOptions{DisableAgent: true, Password: func() (string, error) { return "x", nil }},
})
if err != nil {
t.Fatal(err)
}
return c
}
ctx := context.Background()
c1 := mkClient()
if err := c1.Start(ctx); err != nil {
t.Fatalf("first connect: %v", err)
}
c1.Close()
if prompted != 1 {
t.Fatalf("expected exactly 1 prompt on first connect, got %d", prompted)
}
// Second connect should find the key in the managed file: no prompt.
c2 := mkClient()
if err := c2.Start(ctx); err != nil {
t.Fatalf("second connect: %v", err)
}
c2.Close()
if prompted != 1 {
t.Fatalf("second connect re-prompted (count=%d); TOFU key was not persisted", prompted)
}
}
func writeFile0600(path string, data []byte) error {
return os.WriteFile(path, data, 0o600)
}
+174
View File
@@ -0,0 +1,174 @@
package remote
import (
"context"
"fmt"
"net"
"time"
"golang.org/x/crypto/ssh"
"reasonix/internal/netclient"
)
// dialConfig carries everything a single dial (or one hop of a jump chain)
// needs. It is assembled by Client.Start from Options.
type dialConfig struct {
host ResolvedHost
auth *AuthOptions // target auth (holds the target's credentials + cache)
resolveHop func(string) (ResolvedHost, *AuthOptions, error)
hostKeys *HostKeyPolicy
dialer netclient.StreamDialer // first-hop transport; nil => direct
dialTimeout time.Duration
}
// hopAuthFor returns the auth to use for a jump host. It never carries the
// target's Password/Passphrase closures: a jump host must not be authenticated
// with the target's stored credentials.
func (cfg dialConfig) hopAuthFor(hop ResolvedHost) *AuthOptions {
return &AuthOptions{SecretPrompt: cfg.auth.SecretPrompt, DisableAgent: cfg.auth.DisableAgent}
}
func (cfg dialConfig) resolvedHop(raw string) (ResolvedHost, *AuthOptions, error) {
if cfg.resolveHop != nil {
return cfg.resolveHop(raw)
}
userName, hostName, port, err := ParseTarget(raw)
if err != nil {
return ResolvedHost{}, nil, err
}
hop := ResolvedHost{Name: raw, HostName: hostName, Port: port, User: userName}
applyHostDefaults(&hop)
return hop, cfg.hopAuthFor(hop), nil
}
// dialSSH establishes an *ssh.Client to cfg.host, walking any ProxyJump chain
// left-to-right. The netclient proxy (cfg.dialer) applies only to the first
// hop, matching OpenSSH semantics; subsequent hops are dialed through the
// preceding hop's SSH connection. Each hop's host key is verified.
//
// It returns the target client and the ordered list of intermediary clients
// (jump hosts) so the caller can close them when the target connection ends.
func dialSSH(ctx context.Context, cfg dialConfig) (*ssh.Client, []*ssh.Client, error) {
timeout := cfg.dialTimeout
if timeout <= 0 {
timeout = 15 * time.Second
}
base := cfg.dialer
if base == nil {
base = netclient.DialerFunc((&net.Dialer{Timeout: timeout}).DialContext)
}
var hops []*ssh.Client
// dialThrough dials addr using either the base transport (first hop) or the
// previous SSH hop's context-aware Dial.
dialThrough := func(prev *ssh.Client, addr string) (net.Conn, error) {
dctx, cancel := context.WithTimeout(ctx, timeout)
defer cancel()
if prev == nil {
return base.DialContext(dctx, "tcp", addr)
}
return prev.DialContext(dctx, "tcp", addr)
}
var prev *ssh.Client
// Resolve and connect each jump host in order.
for i, jump := range cfg.host.ProxyJump {
hop, hopAuth, err := cfg.resolvedHop(jump)
if err != nil {
closeAll(hops)
return nil, nil, fmt.Errorf("proxy jump %q: %w", jump, err)
}
conn, derr := dialThrough(prev, hop.Addr())
if derr != nil {
closeAll(hops)
return nil, nil, fmt.Errorf("proxy jump %d (%s): %w", i+1, hop.Label(), derr)
}
// Each jump host authenticates with its own credential-free auth, so the
// target's password_env is never sent upstream to a jump host.
client, cerr := newSSHClient(ctx, conn, hop, hopAuth, cfg.hostKeys, timeout)
if cerr != nil {
closeAll(hops)
return nil, nil, fmt.Errorf("proxy jump %d (%s): %w", i+1, hop.Label(), cerr)
}
hops = append(hops, client)
prev = client
}
conn, err := dialThrough(prev, cfg.host.Addr())
if err != nil {
closeAll(hops)
return nil, nil, fmt.Errorf("dial %s: %w", cfg.host.Label(), err)
}
target, err := newSSHClient(ctx, conn, cfg.host, cfg.auth, cfg.hostKeys, timeout)
if err != nil {
closeAll(hops)
return nil, nil, err
}
return target, hops, nil
}
// newSSHClient performs the SSH handshake over an established conn. It bounds
// the handshake with a deadline (ssh.ClientConfig.Timeout only covers the TCP
// dial, not the version/key exchange, so a host that accepts TCP but never
// sends a banner would otherwise hang NewClientConn — and Close — forever).
func newSSHClient(ctx context.Context, conn net.Conn, host ResolvedHost, auth *AuthOptions, hostKeys *HostKeyPolicy, timeout time.Duration) (*ssh.Client, error) {
methods, cleanupAuth, err := buildAuthMethods(ctx, host, auth)
if err != nil {
conn.Close()
return nil, err
}
defer cleanupAuth()
hkCallback, err := hostKeys.Callback(ctx, host.Label())
if err != nil {
conn.Close()
return nil, err
}
clientCfg := &ssh.ClientConfig{
User: host.User,
Auth: methods,
HostKeyCallback: hkCallback,
Timeout: timeout,
}
// Bound the handshake even for ProxyJump channel connections, whose
// SetDeadline method returns "deadline not supported". A watcher closes the
// connection on timeout/cancellation; the acknowledgement prevents a late
// watcher from closing a successfully established client.
hsCtx, cancel := context.WithTimeout(ctx, handshakeTimeout(timeout))
stopWatch := make(chan struct{})
watchDone := make(chan struct{})
go func() {
defer close(watchDone)
select {
case <-hsCtx.Done():
_ = conn.Close()
case <-stopWatch:
}
}()
if deadline, ok := hsCtx.Deadline(); ok {
_ = conn.SetDeadline(deadline)
}
c, chans, reqs, err := ssh.NewClientConn(conn, host.Addr(), clientCfg)
close(stopWatch)
<-watchDone
cancel()
if err != nil {
conn.Close()
return nil, classifyDialError(err)
}
_ = conn.SetDeadline(time.Time{})
return ssh.NewClient(c, chans, reqs), nil
}
func handshakeTimeout(dialTimeout time.Duration) time.Duration {
if dialTimeout <= 0 {
return 15 * time.Second
}
return dialTimeout
}
func closeAll(clients []*ssh.Client) {
for i := len(clients) - 1; i >= 0; i-- {
_ = clients[i].Close()
}
}
+99
View File
@@ -0,0 +1,99 @@
package remote
import (
"context"
"errors"
"net"
"testing"
"time"
)
func TestClientUsesResolvedPerHopCredentials(t *testing.T) {
targetAuth := AuthOptions{Password: func() (string, error) { return "target", nil }}
hopAuth := AuthOptions{Password: func() (string, error) { return "jump", nil }}
c, err := New(Options{
Host: ResolvedHost{HostName: "target", Port: 22, User: "target-user", ProxyJump: []string{"bastion"}},
Auth: targetAuth,
JumpHosts: []JumpHostOptions{{Host: ResolvedHost{HostName: "10.0.0.8", Port: 2202, User: "jump-user"}, Auth: hopAuth}},
})
if err != nil {
t.Fatal(err)
}
hop, auth, err := c.resolveHop("bastion")
if err != nil {
t.Fatal(err)
}
if hop.Addr() != "10.0.0.8:2202" || hop.User != "jump-user" {
t.Fatalf("resolved hop = %+v", hop)
}
if auth.Password == nil {
t.Fatal("configured jump password was dropped")
}
jumpSecret, err := auth.Password()
if err != nil || jumpSecret != "jump" {
t.Fatalf("jump password = %q, %v", jumpSecret, err)
}
targetSecret, _ := targetAuth.Password()
if jumpSecret == targetSecret {
t.Fatal("jump auth reused the target credential")
}
}
func TestClientKeepsAliasCredentialsDistinctForSharedEndpoint(t *testing.T) {
password := func(value string) func() (string, error) {
return func() (string, error) { return value, nil }
}
endpoint := ResolvedHost{HostName: "10.0.0.8", Port: 22, User: "jump-user"}
c, err := New(Options{
Host: ResolvedHost{HostName: "target", Port: 22, User: "target-user", ProxyJump: []string{"primary", "backup"}},
JumpHosts: []JumpHostOptions{
{Host: endpoint, Auth: AuthOptions{Password: password("primary-secret")}},
{Host: endpoint, Auth: AuthOptions{Password: password("backup-secret")}},
},
})
if err != nil {
t.Fatal(err)
}
_, primary, err := c.resolveHop("primary")
if err != nil {
t.Fatal(err)
}
_, backup, err := c.resolveHop("backup")
if err != nil {
t.Fatal(err)
}
primarySecret, _ := primary.Password()
backupSecret, _ := backup.Password()
if primarySecret != "primary-secret" || backupSecret != "backup-secret" {
t.Fatalf("shared endpoint credentials collided: primary=%q backup=%q", primarySecret, backupSecret)
}
}
type noDeadlineConn struct{ net.Conn }
func (noDeadlineConn) SetDeadline(time.Time) error { return errors.New("unsupported") }
func (noDeadlineConn) SetReadDeadline(time.Time) error { return errors.New("unsupported") }
func (noDeadlineConn) SetWriteDeadline(time.Time) error { return errors.New("unsupported") }
func TestSSHHandshakeHonorsContextWhenDeadlinesUnsupported(t *testing.T) {
client, server := net.Pipe()
defer client.Close()
defer server.Close()
ctx, cancel := context.WithTimeout(context.Background(), 40*time.Millisecond)
defer cancel()
done := make(chan error, 1)
go func() {
_, err := newSSHClient(ctx, noDeadlineConn{client},
ResolvedHost{HostName: "target", Port: 22, User: "u"},
&AuthOptions{DisableAgent: true}, &HostKeyPolicy{}, time.Second)
done <- err
}()
select {
case err := <-done:
if err == nil {
t.Fatal("banner-less handshake unexpectedly succeeded")
}
case <-time.After(500 * time.Millisecond):
t.Fatal("handshake outlived its context on a ProxyJump-style connection")
}
}
+59
View File
@@ -0,0 +1,59 @@
package remote
import (
"context"
"testing"
)
// TestHopAuthDropsTargetCredentials pins the ProxyJump security fix: a jump
// host must never be handed the target's stored password/passphrase closures.
func TestHopAuthDropsTargetCredentials(t *testing.T) {
targetAuth := &AuthOptions{
Password: func() (string, error) { return "target-secret", nil },
Passphrase: func() (string, error) { return "target-key-pass", nil },
SecretPrompt: func(_ context.Context, _ SecretKind, _ string) (string, error) { return "", nil },
DisableAgent: true,
}
cfg := dialConfig{auth: targetAuth}
hop := ResolvedHost{Name: "bastion", HostName: "10.0.0.1", Port: 22, User: "jump"}
hopAuth := cfg.hopAuthFor(hop)
if hopAuth.Password != nil {
t.Error("jump host auth carries the target's Password closure")
}
if hopAuth.Passphrase != nil {
t.Error("jump host auth carries the target's Passphrase closure")
}
if hopAuth.SecretPrompt == nil {
t.Error("jump host auth should still allow interactive prompting")
}
if !hopAuth.DisableAgent {
t.Error("jump host auth should inherit DisableAgent")
}
}
// TestClientHopAuthIsPersistentAndPerHost pins that the Client's per-hop auth is
// credential-free, cached per hop (so reconnects don't re-prompt), and distinct
// between hops (so one hop's secret is never reused for another).
func TestClientHopAuthIsPersistentAndPerHost(t *testing.T) {
c, err := New(Options{
Host: ResolvedHost{HostName: "target", Port: 22, User: "u"},
Auth: AuthOptions{Password: func() (string, error) { return "target-secret", nil }},
})
if err != nil {
t.Fatal(err)
}
h1 := ResolvedHost{HostName: "hop1", Port: 22, User: "u"}
h2 := ResolvedHost{HostName: "hop2", Port: 22, User: "u"}
a1 := c.hopAuthFor(h1)
if a1.Password != nil || a1.Passphrase != nil {
t.Fatal("hop auth carries target credentials")
}
if c.hopAuthFor(h1) != a1 {
t.Error("hop auth not cached: reconnect would re-prompt for jump secrets")
}
if c.hopAuthFor(h2) == a1 {
t.Error("distinct hops share one auth: a hop's secret could be reused for another")
}
}
+14
View File
@@ -0,0 +1,14 @@
//go:build !windows
package forward
import (
"errors"
"syscall"
)
// isAddrInUse reports whether err is an "address already in use" bind error.
// Unix reports EADDRINUSE.
func isAddrInUse(err error) bool {
return errors.Is(err, syscall.EADDRINUSE)
}
@@ -0,0 +1,17 @@
//go:build windows
package forward
import (
"errors"
"syscall"
"golang.org/x/sys/windows"
)
// isAddrInUse reports whether err is an "address already in use" bind error.
// Windows reports WSAEADDRINUSE from the Winsock layer; the portable
// EADDRINUSE is checked too for defensiveness.
func isAddrInUse(err error) bool {
return errors.Is(err, windows.WSAEADDRINUSE) || errors.Is(err, syscall.EADDRINUSE)
}
+203
View File
@@ -0,0 +1,203 @@
// Package forward manages SSH port-forward rules bound to a live connection.
// Local (-L) forwards keep their local listener open across reconnects so a
// forwarded serve URL survives an outage; remote (-R) forwards are
// re-registered on every re-attach because they die with the SSH connection.
package forward
import (
"errors"
"fmt"
"net"
"strconv"
"strings"
)
// Direction is the forward direction.
type Direction int
const (
// Local is an -L forward: listen locally, dial from the remote side.
Local Direction = iota
// Remote is an -R forward: listen on the remote side, dial locally.
Remote
)
func (d Direction) String() string {
if d == Remote {
return "remote"
}
return "local"
}
// Spec describes one forward rule.
type Spec struct {
Name string // stable id; derived from bind/target when empty
Direction Direction
BindAddr string // "127.0.0.1:8080"; ":0" allowed for Local (ephemeral)
TargetAddr string // "127.0.0.1:80"
}
// Validate rejects malformed bind/target addresses before a listener is
// registered. Target port zero is never dialable; bind port zero remains
// valid for an ephemeral local/remote listener.
func (s Spec) Validate() error {
if s.Direction != Local && s.Direction != Remote {
return fmt.Errorf("forward: invalid direction %d", s.Direction)
}
if err := validateAddress(s.BindAddr, true); err != nil {
return fmt.Errorf("forward: invalid bind address %q: %w", s.BindAddr, err)
}
if err := validateAddress(s.TargetAddr, false); err != nil {
return fmt.Errorf("forward: invalid target address %q: %w", s.TargetAddr, err)
}
return nil
}
func validateAddress(addr string, allowZero bool) error {
host, portText, err := net.SplitHostPort(strings.TrimSpace(addr))
if err != nil {
return err
}
if host == "" && !allowZero {
return errors.New("host is required")
}
port, err := strconv.Atoi(portText)
if err != nil || port < 0 || port > 65535 || (!allowZero && port == 0) {
return errors.New("port must be between 1 and 65535")
}
return nil
}
// Typed errors.
var (
ErrBindBusy = errors.New("forward: bind address in use")
ErrDuplicateForward = errors.New("forward: duplicate name")
ErrNotAttached = errors.New("forward: not attached to a connection")
)
// DefaultName derives a stable name for a spec that did not set one.
func (s Spec) DefaultName() string {
if s.Name != "" {
return s.Name
}
return fmt.Sprintf("%s:%s->%s", dirShort(s.Direction), s.BindAddr, s.TargetAddr)
}
func dirShort(d Direction) string {
if d == Remote {
return "R"
}
return "L"
}
// ParseDirection maps "local"/"-L"/"L" and "remote"/"-R"/"R".
func ParseDirection(s string) (Direction, error) {
switch strings.ToLower(strings.TrimSpace(s)) {
case "local", "-l", "l":
return Local, nil
case "remote", "-r", "r":
return Remote, nil
default:
return Local, fmt.Errorf("forward: direction must be local (-L) or remote (-R), got %q", s)
}
}
// ParseShorthand parses OpenSSH-style forward shorthands:
//
// "8080:host:80" -> bind 127.0.0.1:8080, target host:80
// "127.0.0.1:8080:host:80"-> bind 127.0.0.1:8080, target host:80
// "8080" -> bind 127.0.0.1:8080, target 127.0.0.1:8080
//
// Bare ports and unqualified binds default to loopback (127.0.0.1).
func ParseShorthand(dir Direction, s string) (Spec, error) {
s = strings.TrimSpace(s)
if s == "" {
return Spec{}, fmt.Errorf("forward: empty spec")
}
parts := strings.Split(s, ":")
switch len(parts) {
case 1:
p, err := parseTargetPort(parts[0])
if err != nil {
return Spec{}, err
}
addr := net.JoinHostPort("127.0.0.1", strconv.Itoa(p))
return Spec{Direction: dir, BindAddr: addr, TargetAddr: addr}, nil
case 3:
// bindPort:targetHost:targetPort
bp, err := parsePort(parts[0])
if err != nil {
return Spec{}, err
}
tp, err := parseTargetPort(parts[2])
if err != nil {
return Spec{}, err
}
if parts[1] == "" {
return Spec{}, fmt.Errorf("forward: empty target host in %q", s)
}
return Spec{
Direction: dir,
BindAddr: net.JoinHostPort("127.0.0.1", strconv.Itoa(bp)),
TargetAddr: net.JoinHostPort(parts[1], strconv.Itoa(tp)),
}, nil
case 4:
// bindHost:bindPort:targetHost:targetPort
bp, err := parsePort(parts[1])
if err != nil {
return Spec{}, err
}
tp, err := parseTargetPort(parts[3])
if err != nil {
return Spec{}, err
}
if parts[2] == "" {
return Spec{}, fmt.Errorf("forward: empty target host in %q", s)
}
bindHost := parts[0]
if bindHost == "" {
bindHost = "127.0.0.1"
}
return Spec{
Direction: dir,
BindAddr: net.JoinHostPort(bindHost, strconv.Itoa(bp)),
TargetAddr: net.JoinHostPort(parts[2], strconv.Itoa(tp)),
}, nil
default:
return Spec{}, fmt.Errorf("forward: cannot parse spec %q", s)
}
}
// NonLoopbackBind reports whether the spec binds a non-loopback address, which
// callers surface as a warning (the forward becomes reachable off-machine).
func (s Spec) NonLoopbackBind() bool {
host, _, err := net.SplitHostPort(s.BindAddr)
if err != nil {
return false
}
ip := net.ParseIP(host)
switch {
case host == "" || host == "*":
return true
case ip == nil:
return host != "localhost"
default:
return !ip.IsLoopback()
}
}
func parsePort(s string) (int, error) {
p, err := strconv.Atoi(strings.TrimSpace(s))
if err != nil || p < 0 || p > 65535 {
return 0, fmt.Errorf("forward: invalid port %q", s)
}
return p, nil
}
func parseTargetPort(s string) (int, error) {
p, err := parsePort(s)
if err != nil || p == 0 {
return 0, fmt.Errorf("forward: invalid target port %q", s)
}
return p, nil
}
+94
View File
@@ -0,0 +1,94 @@
package forward
import "testing"
func TestParseShorthand(t *testing.T) {
cases := []struct {
in string
bind, targ string
wantErr bool
}{
{"8080", "127.0.0.1:8080", "127.0.0.1:8080", false},
{"8080:example.com:80", "127.0.0.1:8080", "example.com:80", false},
{"127.0.0.1:8080:db:5432", "127.0.0.1:8080", "db:5432", false},
{"0.0.0.0:9000:svc:9000", "0.0.0.0:9000", "svc:9000", false},
{":8080:svc:80", "127.0.0.1:8080", "svc:80", false},
{"8080:svc:", "", "", true},
{"8080::80", "", "", true},
{"0", "", "", true},
{"8080:svc:0", "", "", true},
{"", "", "", true},
{"a:b:c:d:e", "", "", true},
}
for _, c := range cases {
s, err := ParseShorthand(Local, c.in)
if c.wantErr {
if err == nil {
t.Errorf("ParseShorthand(%q): expected error", c.in)
}
continue
}
if err != nil {
t.Errorf("ParseShorthand(%q): %v", c.in, err)
continue
}
if s.BindAddr != c.bind || s.TargetAddr != c.targ {
t.Errorf("ParseShorthand(%q) = bind %q target %q, want %q / %q", c.in, s.BindAddr, s.TargetAddr, c.bind, c.targ)
}
}
}
func TestSpecValidate(t *testing.T) {
valid := Spec{Direction: Local, BindAddr: "127.0.0.1:0", TargetAddr: "svc:80"}
if err := valid.Validate(); err != nil {
t.Fatalf("valid spec rejected: %v", err)
}
for _, spec := range []Spec{
{Direction: Local, BindAddr: "127.0.0.1", TargetAddr: "svc:80"},
{Direction: Local, BindAddr: "127.0.0.1:8000", TargetAddr: "svc"},
{Direction: Local, BindAddr: "127.0.0.1:8000", TargetAddr: "svc:0"},
{Direction: Direction(99), BindAddr: "127.0.0.1:8000", TargetAddr: "svc:80"},
} {
if err := spec.Validate(); err == nil {
t.Errorf("invalid spec accepted: %+v", spec)
}
}
}
func TestNonLoopbackBind(t *testing.T) {
loop, _ := ParseShorthand(Local, "8080")
if loop.NonLoopbackBind() {
t.Error("127.0.0.1 flagged as non-loopback")
}
open, _ := ParseShorthand(Local, "0.0.0.0:8080:svc:80")
if !open.NonLoopbackBind() {
t.Error("0.0.0.0 not flagged as non-loopback")
}
}
func TestParseDirection(t *testing.T) {
for _, s := range []string{"local", "-L", "l"} {
if d, err := ParseDirection(s); err != nil || d != Local {
t.Errorf("ParseDirection(%q) = %v, %v", s, d, err)
}
}
for _, s := range []string{"remote", "-R", "R"} {
if d, err := ParseDirection(s); err != nil || d != Remote {
t.Errorf("ParseDirection(%q) = %v, %v", s, d, err)
}
}
if _, err := ParseDirection("dynamic"); err == nil {
t.Error("dynamic direction accepted")
}
}
func TestDefaultName(t *testing.T) {
s := Spec{Direction: Local, BindAddr: "127.0.0.1:8080", TargetAddr: "svc:80"}
if got := s.DefaultName(); got != "L:127.0.0.1:8080->svc:80" {
t.Errorf("DefaultName = %q", got)
}
named := Spec{Name: "web", Direction: Remote}
if got := named.DefaultName(); got != "web" {
t.Errorf("DefaultName with name = %q", got)
}
}
+337
View File
@@ -0,0 +1,337 @@
package forward
import (
"errors"
"io"
"net"
"sync"
"golang.org/x/crypto/ssh"
)
// Event reports a forward's transition, delivered to the Set's onEvent hook.
type Event struct {
Spec Spec
Up bool
BoundAddr string // actual bound address (resolves ":0")
Err error
}
// Entry is a snapshot of one registered forward.
type Entry struct {
Spec Spec
Up bool
BoundAddr string
LastErr error
}
// Set is the port-forward registry for one Client. It is safe for concurrent
// use. Local listeners persist across Detach/Attach so a forwarded port stays
// reserved through reconnects; remote listeners are torn down on Detach and
// recreated on Attach.
type Set struct {
onEvent func(Event)
mu sync.Mutex
ssh *ssh.Client
runs map[string]*runner
}
// NewSet creates an empty Set. onEvent may be nil.
func NewSet(onEvent func(Event)) *Set {
return &Set{onEvent: onEvent, runs: map[string]*runner{}}
}
type runner struct {
spec Spec
local net.Listener // Local: persistent local listener
remote net.Listener // Remote: per-connection remote listener
up bool
lastErr error
stop chan struct{}
acceptWG sync.WaitGroup
}
// Add registers spec and starts it if the Set is attached. Returns the bound
// address (useful for ":0" local forwards).
func (s *Set) Add(spec Spec) (string, error) {
if err := spec.Validate(); err != nil {
return "", err
}
name := spec.DefaultName()
spec.Name = name
s.mu.Lock()
defer s.mu.Unlock()
if _, ok := s.runs[name]; ok {
return "", ErrDuplicateForward
}
r := &runner{spec: spec, stop: make(chan struct{})}
s.runs[name] = r
if s.ssh == nil {
return "", nil // starts on Attach
}
bound, err := s.startLocked(r, s.ssh)
if err != nil {
delete(s.runs, name)
return "", err
}
return bound, nil
}
// Replace atomically swaps the named registration after the replacement has
// started successfully. If startup fails, the existing forward remains live.
// This is primarily used when a remote serve moves to a new workspace/port.
func (s *Set) Replace(spec Spec) (string, error) {
if err := spec.Validate(); err != nil {
return "", err
}
name := spec.DefaultName()
spec.Name = name
s.mu.Lock()
old := s.runs[name]
if s.ssh == nil {
s.mu.Unlock()
return "", ErrNotAttached
}
replacement := &runner{spec: spec, stop: make(chan struct{})}
bound := ""
bound, err := s.startLocked(replacement, s.ssh)
if err != nil {
s.mu.Unlock()
return "", err
}
s.runs[name] = replacement
if old != nil {
// The replacement's Up event is authoritative; suppress a later Down event
// from retiring the old runner with the same name.
old.up = false
}
s.mu.Unlock()
if old != nil {
s.stopRunner(old, true)
}
return bound, nil
}
// Remove stops and deregisters the named forward.
func (s *Set) Remove(name string) error {
s.mu.Lock()
r, ok := s.runs[name]
if ok {
delete(s.runs, name)
}
s.mu.Unlock()
if !ok {
return errors.New("forward: no such forward: " + name)
}
s.stopRunner(r, true)
return nil
}
// List snapshots all registered forwards.
func (s *Set) List() []Entry {
s.mu.Lock()
defer s.mu.Unlock()
out := make([]Entry, 0, len(s.runs))
for _, r := range s.runs {
bound := ""
if r.local != nil {
bound = r.local.Addr().String()
} else if r.remote != nil {
bound = r.remote.Addr().String()
}
out = append(out, Entry{Spec: r.spec, Up: r.up, BoundAddr: bound, LastErr: r.lastErr})
}
return out
}
// Attach binds the Set to a (re)connected ssh client and (re)starts every
// forward. Per-forward failures are joined and returned; successfully started
// forwards stay up.
func (s *Set) Attach(cl *ssh.Client) error {
s.mu.Lock()
defer s.mu.Unlock()
s.ssh = cl
var errs []error
for _, r := range s.runs {
if _, err := s.startLocked(r, cl); err != nil {
errs = append(errs, err)
}
}
return errors.Join(errs...)
}
// Detach drops the current connection. Local listeners stay open (and refuse
// data until re-attached); remote listeners are closed.
func (s *Set) Detach() {
s.mu.Lock()
defer s.mu.Unlock()
s.ssh = nil
for _, r := range s.runs {
if r.remote != nil {
_ = r.remote.Close()
r.remote = nil
}
if r.up {
r.up = false
s.emit(Event{Spec: r.spec, Up: false})
}
}
}
// Close stops all forwards and releases every listener.
func (s *Set) Close() {
s.mu.Lock()
runs := s.runs
s.runs = map[string]*runner{}
s.ssh = nil
s.mu.Unlock()
for _, r := range runs {
s.stopRunner(r, true)
}
}
// startLocked starts (or restarts) r on cl. Caller holds s.mu.
func (s *Set) startLocked(r *runner, cl *ssh.Client) (string, error) {
if r.spec.Direction == Local {
return s.startLocalLocked(r, cl)
}
return s.startRemoteLocked(r, cl)
}
func (s *Set) startLocalLocked(r *runner, cl *ssh.Client) (string, error) {
if r.local == nil {
ln, err := net.Listen("tcp", r.spec.BindAddr)
if err != nil {
r.lastErr = wrapBind(err)
s.emit(Event{Spec: r.spec, Up: false, Err: r.lastErr})
return "", r.lastErr
}
r.local = ln
r.acceptWG.Add(1)
go s.acceptLocal(r)
}
r.up = true
r.lastErr = nil
bound := r.local.Addr().String()
s.emit(Event{Spec: r.spec, Up: true, BoundAddr: bound})
return bound, nil
}
// acceptLocal accepts on the persistent local listener. Each accepted conn is
// forwarded through whatever ssh client is current at dial time; when detached
// (ssh == nil) the conn is refused.
func (s *Set) acceptLocal(r *runner) {
defer r.acceptWG.Done()
for {
conn, err := r.local.Accept()
if err != nil {
select {
case <-r.stop:
return
default:
return // listener closed
}
}
go s.handleLocalConn(r, conn)
}
}
func (s *Set) handleLocalConn(r *runner, local net.Conn) {
s.mu.Lock()
cl := s.ssh
s.mu.Unlock()
if cl == nil {
_ = local.Close()
return
}
remote, err := cl.Dial("tcp", r.spec.TargetAddr)
if err != nil {
_ = local.Close()
return
}
pipe(local, remote)
}
func (s *Set) startRemoteLocked(r *runner, cl *ssh.Client) (string, error) {
ln, err := cl.Listen("tcp", r.spec.BindAddr)
if err != nil {
r.lastErr = wrapBind(err)
s.emit(Event{Spec: r.spec, Up: false, Err: r.lastErr})
return "", r.lastErr
}
r.remote = ln
r.up = true
r.lastErr = nil
go s.acceptRemote(r, ln)
bound := ln.Addr().String()
s.emit(Event{Spec: r.spec, Up: true, BoundAddr: bound})
return bound, nil
}
func (s *Set) acceptRemote(r *runner, ln net.Listener) {
for {
remote, err := ln.Accept()
if err != nil {
return
}
go func() {
local, derr := net.Dial("tcp", r.spec.TargetAddr)
if derr != nil {
_ = remote.Close()
return
}
pipe(remote, local)
}()
}
}
func (s *Set) stopRunner(r *runner, closeLocal bool) {
close(r.stop)
if r.remote != nil {
_ = r.remote.Close()
r.remote = nil
}
if closeLocal && r.local != nil {
_ = r.local.Close()
}
r.acceptWG.Wait()
if closeLocal {
r.local = nil
}
if r.up {
r.up = false
s.emit(Event{Spec: r.spec, Up: false})
}
}
func (s *Set) emit(e Event) {
if s.onEvent != nil {
s.onEvent(e)
}
}
// pipe copies bidirectionally between a and b, closing both when either side
// ends. Half-close is best-effort via CloseWrite when supported.
func pipe(a, b net.Conn) {
done := make(chan struct{}, 2)
cp := func(dst, src net.Conn) {
_, _ = io.Copy(dst, src)
if cw, ok := dst.(interface{ CloseWrite() error }); ok {
_ = cw.CloseWrite()
}
done <- struct{}{}
}
go cp(a, b)
go cp(b, a)
<-done
_ = a.Close()
_ = b.Close()
}
func wrapBind(err error) error {
if isAddrInUse(err) {
return errors.Join(ErrBindBusy, err)
}
return err
}
+315
View File
@@ -0,0 +1,315 @@
package forward
import (
"fmt"
"io"
"net"
"testing"
"time"
"golang.org/x/crypto/ssh"
"reasonix/internal/remote/sshtest"
)
// dialSSHClient connects to the sshtest server as a real ssh client.
func dialSSHClient(t *testing.T, srv *sshtest.Server) *ssh.Client {
t.Helper()
cfg := &ssh.ClientConfig{
User: "test",
HostKeyCallback: ssh.InsecureIgnoreHostKey(),
Timeout: 5 * time.Second,
}
cl, err := ssh.Dial("tcp", srv.Addr, cfg)
if err != nil {
t.Fatalf("ssh dial: %v", err)
}
t.Cleanup(func() { cl.Close() })
return cl
}
// echoServer starts a local TCP echo server for -L target testing.
func echoServer(t *testing.T) string {
t.Helper()
ln, err := net.Listen("tcp", "127.0.0.1:0")
if err != nil {
t.Fatal(err)
}
t.Cleanup(func() { ln.Close() })
go func() {
for {
c, err := ln.Accept()
if err != nil {
return
}
go func() { _, _ = io.Copy(c, c); c.Close() }()
}
}()
return ln.Addr().String()
}
func TestLocalForwardEndToEnd(t *testing.T) {
srv := sshtest.Start(t, sshtest.Options{})
cl := dialSSHClient(t, srv)
target := echoServer(t)
set := NewSet(nil)
defer set.Close()
if err := set.Attach(cl); err != nil {
t.Fatalf("attach: %v", err)
}
bound, err := set.Add(Spec{Direction: Local, BindAddr: "127.0.0.1:0", TargetAddr: target})
if err != nil {
t.Fatalf("add local forward: %v", err)
}
if bound == "" {
t.Fatal("no bound address")
}
conn, err := net.Dial("tcp", bound)
if err != nil {
t.Fatalf("dial forward: %v", err)
}
defer conn.Close()
if _, err := conn.Write([]byte("ping")); err != nil {
t.Fatal(err)
}
buf := make([]byte, 4)
_ = conn.SetReadDeadline(time.Now().Add(5 * time.Second))
if _, err := io.ReadFull(conn, buf); err != nil {
t.Fatalf("read echo: %v", err)
}
if string(buf) != "ping" {
t.Fatalf("echo = %q, want ping", buf)
}
}
func TestLocalListenerPersistsAcrossReattach(t *testing.T) {
srv := sshtest.Start(t, sshtest.Options{})
target := echoServer(t)
set := NewSet(nil)
defer set.Close()
cl1 := dialSSHClient(t, srv)
if err := set.Attach(cl1); err != nil {
t.Fatal(err)
}
bound, err := set.Add(Spec{Direction: Local, BindAddr: "127.0.0.1:0", TargetAddr: target})
if err != nil {
t.Fatal(err)
}
// Simulate a connection drop then reconnect on a new client.
set.Detach()
cl2 := dialSSHClient(t, srv)
if err := set.Attach(cl2); err != nil {
t.Fatal(err)
}
// The bound address must be unchanged (listener stayed open).
entries := set.List()
if len(entries) != 1 || entries[0].BoundAddr != bound {
t.Fatalf("bound address changed across reattach: %+v (was %s)", entries, bound)
}
// And traffic works again through the new connection.
conn, err := net.Dial("tcp", bound)
if err != nil {
t.Fatalf("dial after reattach: %v", err)
}
defer conn.Close()
_, _ = conn.Write([]byte("pong"))
buf := make([]byte, 4)
_ = conn.SetReadDeadline(time.Now().Add(5 * time.Second))
if _, err := io.ReadFull(conn, buf); err != nil {
t.Fatalf("read echo after reattach: %v", err)
}
if string(buf) != "pong" {
t.Fatalf("echo = %q", buf)
}
}
func TestDuplicateForwardRejected(t *testing.T) {
set := NewSet(nil)
defer set.Close()
spec := Spec{Name: "web", Direction: Local, BindAddr: "127.0.0.1:0", TargetAddr: "svc:80"}
if _, err := set.Add(spec); err != nil {
t.Fatal(err)
}
if _, err := set.Add(spec); err != ErrDuplicateForward {
t.Fatalf("second add err = %v, want ErrDuplicateForward", err)
}
}
func TestReplaceSwapsLiveForwardAfterReplacementStarts(t *testing.T) {
srv := sshtest.Start(t, sshtest.Options{})
cl := dialSSHClient(t, srv)
firstTarget := echoServer(t)
secondTarget := echoServer(t)
set := NewSet(nil)
defer set.Close()
if err := set.Attach(cl); err != nil {
t.Fatal(err)
}
firstBound, err := set.Add(Spec{Name: "serve", Direction: Local, BindAddr: "127.0.0.1:0", TargetAddr: firstTarget})
if err != nil {
t.Fatal(err)
}
secondBound, err := set.Replace(Spec{Name: "serve", Direction: Local, BindAddr: "127.0.0.1:0", TargetAddr: secondTarget})
if err != nil {
t.Fatal(err)
}
if firstBound == secondBound {
t.Fatalf("replacement reused old listener %q", firstBound)
}
entries := set.List()
if len(entries) != 1 || entries[0].Spec.TargetAddr != secondTarget || !entries[0].Up {
t.Fatalf("replacement registry = %+v", entries)
}
if conn, err := net.DialTimeout("tcp", firstBound, 100*time.Millisecond); err == nil {
_ = conn.Close()
t.Fatalf("old listener %q is still accepting", firstBound)
}
conn, err := net.Dial("tcp", secondBound)
if err != nil {
t.Fatalf("dial replacement: %v", err)
}
_ = conn.Close()
}
func TestReplaceFailurePreservesExistingForward(t *testing.T) {
srv := sshtest.Start(t, sshtest.Options{})
cl := dialSSHClient(t, srv)
target := echoServer(t)
occupied, err := net.Listen("tcp", "127.0.0.1:0")
if err != nil {
t.Fatal(err)
}
defer occupied.Close()
set := NewSet(nil)
defer set.Close()
if err := set.Attach(cl); err != nil {
t.Fatal(err)
}
bound, err := set.Add(Spec{Name: "serve", Direction: Local, BindAddr: "127.0.0.1:0", TargetAddr: target})
if err != nil {
t.Fatal(err)
}
if _, err := set.Replace(Spec{Name: "serve", Direction: Local, BindAddr: occupied.Addr().String(), TargetAddr: "other:80"}); err == nil {
t.Fatal("Replace unexpectedly bound an occupied address")
}
entries := set.List()
if len(entries) != 1 || entries[0].BoundAddr != bound || entries[0].Spec.TargetAddr != target || !entries[0].Up {
t.Fatalf("failed replacement disturbed existing forward: %+v", entries)
}
}
func TestReplaceWhileDetachedPreservesExistingForward(t *testing.T) {
set := NewSet(nil)
defer set.Close()
old := Spec{Name: "serve", Direction: Local, BindAddr: "127.0.0.1:0", TargetAddr: "old:80"}
if _, err := set.Add(old); err != nil {
t.Fatal(err)
}
if _, err := set.Replace(Spec{Name: "serve", Direction: Local, BindAddr: "127.0.0.1:0", TargetAddr: "new:80"}); err != ErrNotAttached {
t.Fatalf("Replace error = %v, want ErrNotAttached", err)
}
entries := set.List()
if len(entries) != 1 || entries[0].Spec.TargetAddr != old.TargetAddr {
t.Fatalf("detached replacement disturbed old forward: %+v", entries)
}
}
func TestBindBusyReported(t *testing.T) {
srv := sshtest.Start(t, sshtest.Options{})
cl := dialSSHClient(t, srv)
// Occupy a port.
occupied, err := net.Listen("tcp", "127.0.0.1:0")
if err != nil {
t.Fatal(err)
}
defer occupied.Close()
busyAddr := occupied.Addr().String()
set := NewSet(nil)
defer set.Close()
if err := set.Attach(cl); err != nil {
t.Fatal(err)
}
_, err = set.Add(Spec{Direction: Local, BindAddr: busyAddr, TargetAddr: "svc:80"})
if err == nil {
t.Fatal("expected bind-busy error")
}
if err != ErrBindBusy && !containsErr(err, ErrBindBusy) {
t.Fatalf("err = %v, want ErrBindBusy", err)
}
}
func TestRemoteForwardEndToEnd(t *testing.T) {
srv := sshtest.Start(t, sshtest.Options{})
cl := dialSSHClient(t, srv)
target := echoServer(t)
events := make(chan Event, 8)
set := NewSet(func(e Event) { events <- e })
defer set.Close()
if err := set.Attach(cl); err != nil {
t.Fatal(err)
}
// -R: sshtest listens on its side and forwards back to our local target.
if _, err := set.Add(Spec{Direction: Remote, BindAddr: "127.0.0.1:0", TargetAddr: target}); err != nil {
t.Fatalf("add remote forward: %v", err)
}
// Find the remote bound address from the registry.
var bound string
deadline := time.After(5 * time.Second)
for bound == "" {
select {
case <-deadline:
t.Fatal("remote forward never came up")
default:
}
for _, e := range set.List() {
if e.Up {
bound = e.BoundAddr
}
}
if bound == "" {
time.Sleep(20 * time.Millisecond)
}
}
conn, err := net.Dial("tcp", bound)
if err != nil {
t.Fatalf("dial remote-forward bind: %v", err)
}
defer conn.Close()
_, _ = conn.Write([]byte("rrrr"))
buf := make([]byte, 4)
_ = conn.SetReadDeadline(time.Now().Add(5 * time.Second))
if _, err := io.ReadFull(conn, buf); err != nil {
t.Fatalf("read echo via -R: %v", err)
}
if string(buf) != "rrrr" {
t.Fatalf("echo = %q", buf)
}
}
func containsErr(err, target error) bool {
type wrapper interface{ Unwrap() []error }
if w, ok := err.(wrapper); ok {
for _, e := range w.Unwrap() {
if e == target || containsErr(e, target) {
return true
}
}
}
type single interface{ Unwrap() error }
if s, ok := err.(single); ok {
return containsErr(s.Unwrap(), target)
}
return err == target
}
var _ = fmt.Sprintf
+222
View File
@@ -0,0 +1,222 @@
package remote
import (
"fmt"
"net"
"os"
"os/user"
"strconv"
"strings"
"reasonix/internal/config"
)
// ResolvedHost is a fully resolved dial target: explicit [remote] TOML fields
// layered over ~/.ssh/config values (when use_ssh_config) over defaults.
type ResolvedHost struct {
Name string // config entry name, or the raw target for ad-hoc dials
HostName string // network address to dial
Port int
User string
IdentityFile string // explicit key path; empty => agent/default identities
PassphraseEnv string // credential env var name for the key passphrase
PasswordEnv string // credential env var name for password auth
ProxyJump []string // resolved jump chain, in dial order
Workspace string // default remote workspace directory
ServeInstall string // auto|npm|upload|never
Forwards []config.RemoteForwardEntry
}
// Addr is the host:port dial string.
func (h ResolvedHost) Addr() string {
return net.JoinHostPort(h.HostName, strconv.Itoa(h.Port))
}
// Label is the display form user@host:port.
func (h ResolvedHost) Label() string {
label := h.HostName
if h.User != "" {
label = h.User + "@" + label
}
if h.Port != 0 && h.Port != 22 {
label += ":" + strconv.Itoa(h.Port)
}
return label
}
// ParseTarget splits an ad-hoc "[user@]host[:port]" target. IPv6 literals use
// the bracketed form "[::1]:22".
func ParseTarget(s string) (userName, host string, port int, err error) {
s = strings.TrimSpace(s)
if s == "" {
return "", "", 0, fmt.Errorf("empty ssh target")
}
if at := strings.LastIndex(s, "@"); at >= 0 {
userName, s = s[:at], s[at+1:]
if userName == "" || s == "" {
return "", "", 0, fmt.Errorf("invalid ssh target %q", s)
}
}
host = s
port = 0
if strings.HasPrefix(s, "[") {
// Bracketed IPv6, optionally with :port.
end := strings.Index(s, "]")
if end < 0 {
return "", "", 0, fmt.Errorf("invalid ssh target %q: unclosed '['", s)
}
host = s[1:end]
rest := s[end+1:]
if rest != "" {
if !strings.HasPrefix(rest, ":") {
return "", "", 0, fmt.Errorf("invalid ssh target %q", s)
}
port, err = parsePort(rest[1:])
if err != nil {
return "", "", 0, err
}
}
} else if i := strings.LastIndex(s, ":"); i >= 0 {
if strings.Count(s, ":") > 1 {
// Bare IPv6 literal without a port.
host = s
} else {
host = s[:i]
port, err = parsePort(s[i+1:])
if err != nil {
return "", "", 0, err
}
}
}
if host == "" {
return "", "", 0, fmt.Errorf("invalid ssh target %q: empty host", s)
}
return userName, host, port, nil
}
func parsePort(s string) (int, error) {
p, err := strconv.Atoi(s)
if err != nil || p <= 0 || p > 65535 {
return 0, fmt.Errorf("invalid ssh port %q", s)
}
return p, nil
}
// ResolveHost builds the dial target for a configured host name or an ad-hoc
// "[user@]host[:port]" target. Field precedence: explicit TOML value →
// ~/.ssh/config value (only when the entry sets use_ssh_config, or for ad-hoc
// targets when sshCfg is non-nil) → default (port 22, current OS user).
func ResolveHost(cfg *config.Config, nameOrTarget string, sshCfg *SSHConfigSource) (ResolvedHost, error) {
if cfg != nil {
if e, ok := cfg.RemoteHost(nameOrTarget); ok {
return resolveEntry(e, sshCfg)
}
}
userName, host, port, err := ParseTarget(nameOrTarget)
if err != nil {
return ResolvedHost{}, err
}
r := ResolvedHost{Name: nameOrTarget, HostName: host, Port: port, User: userName}
applySSHConfig(&r, host, sshCfg)
applyHostDefaults(&r)
return r, nil
}
// ResolveJumpHosts resolves every ProxyJump token through the same Reasonix
// host table and ~/.ssh/config layers as the final target. A jump entry's own
// ProxyJump is deliberately cleared: the caller-provided chain is already the
// complete left-to-right route, and recursively expanding nested chains would
// make ordering and credential ownership ambiguous.
func ResolveJumpHosts(cfg *config.Config, chain []string, sshCfg *SSHConfigSource) ([]ResolvedHost, error) {
out := make([]ResolvedHost, 0, len(chain))
for i, raw := range chain {
hop, err := ResolveHost(cfg, raw, sshCfg)
if err != nil {
return nil, fmt.Errorf("proxy jump %d (%q): %w", i+1, raw, err)
}
hop.ProxyJump = nil
out = append(out, hop)
}
return out, nil
}
func resolveEntry(e config.RemoteHostEntry, sshCfg *SSHConfigSource) (ResolvedHost, error) {
r := ResolvedHost{
Name: e.Name,
HostName: strings.TrimSpace(e.Host),
Port: e.Port,
User: strings.TrimSpace(e.User),
IdentityFile: strings.TrimSpace(e.IdentityFile),
PassphraseEnv: strings.TrimSpace(e.PassphraseEnv),
PasswordEnv: strings.TrimSpace(e.PasswordEnv),
Workspace: strings.TrimSpace(e.Workspace),
ServeInstall: e.ServeInstallMode(),
Forwards: e.Forwards,
}
if j := strings.TrimSpace(e.ProxyJump); j != "" {
r.ProxyJump = splitJumpChain(j)
}
if e.UseSSHConfig {
// The TOML host field doubles as the ssh_config alias lookup key; the
// resolved HostName may differ (ssh_config HostName wins for unset).
applySSHConfig(&r, r.HostName, sshCfg)
}
applyHostDefaults(&r)
if r.HostName == "" {
return ResolvedHost{}, fmt.Errorf("remote host %q: empty hostname after resolution", e.Name)
}
return r, nil
}
// applySSHConfig fills unset fields from ~/.ssh/config for alias.
func applySSHConfig(r *ResolvedHost, alias string, sshCfg *SSHConfigSource) {
if sshCfg == nil || alias == "" {
return
}
if hn := sshCfg.HostName(alias); hn != "" {
// An explicit TOML host that matched an alias keeps the alias only as
// the lookup key; the network target comes from ssh_config.
r.HostName = hn
}
if r.Port == 0 {
r.Port = sshCfg.Port(alias)
}
if r.User == "" {
r.User = sshCfg.User(alias)
}
if r.IdentityFile == "" {
r.IdentityFile = sshCfg.IdentityFile(alias)
}
if len(r.ProxyJump) == 0 {
if j := sshCfg.ProxyJump(alias); j != "" {
r.ProxyJump = splitJumpChain(j)
}
}
}
func applyHostDefaults(r *ResolvedHost) {
if r.Port == 0 {
r.Port = 22
}
if r.User == "" {
if u, err := user.Current(); err == nil && u.Username != "" {
r.User = u.Username
} else if env := os.Getenv("USER"); env != "" {
r.User = env
}
}
if r.ServeInstall == "" {
r.ServeInstall = "auto"
}
}
func splitJumpChain(s string) []string {
parts := strings.Split(s, ",")
out := make([]string, 0, len(parts))
for _, p := range parts {
if p = strings.TrimSpace(p); p != "" && !strings.EqualFold(p, "none") {
out = append(out, p)
}
}
return out
}
+84
View File
@@ -0,0 +1,84 @@
package remote
import (
"testing"
"reasonix/internal/config"
)
func TestParseTarget(t *testing.T) {
cases := []struct {
in string
user, host string
port int
wantErr bool
}{
{"host", "", "host", 0, false},
{"user@host", "user", "host", 0, false},
{"user@host:2222", "user", "host", 2222, false},
{"host:22", "", "host", 22, false},
{"[::1]:22", "", "::1", 22, false},
{"dev@[2001:db8::1]:2200", "dev", "2001:db8::1", 2200, false},
{"2001:db8::1", "", "2001:db8::1", 0, false},
{"", "", "", 0, true},
{"user@", "", "", 0, true},
{"host:99999", "", "", 0, true},
{"host:abc", "", "", 0, true},
}
for _, c := range cases {
u, h, p, err := ParseTarget(c.in)
if c.wantErr {
if err == nil {
t.Errorf("ParseTarget(%q): expected error", c.in)
}
continue
}
if err != nil {
t.Errorf("ParseTarget(%q): %v", c.in, err)
continue
}
if u != c.user || h != c.host || p != c.port {
t.Errorf("ParseTarget(%q) = (%q,%q,%d), want (%q,%q,%d)", c.in, u, h, p, c.user, c.host, c.port)
}
}
}
func TestResolveHostFromConfig(t *testing.T) {
cfg := config.Default()
if err := cfg.UpsertRemoteHost(config.RemoteHostEntry{
Name: "box",
Host: "10.0.0.5",
Port: 2200,
User: "dev",
ProxyJump: "bastion, second",
Workspace: "~/app",
}); err != nil {
t.Fatal(err)
}
h, err := ResolveHost(cfg, "box", nil)
if err != nil {
t.Fatal(err)
}
if h.HostName != "10.0.0.5" || h.Port != 2200 || h.User != "dev" {
t.Fatalf("resolved wrong: %+v", h)
}
if len(h.ProxyJump) != 2 || h.ProxyJump[0] != "bastion" || h.ProxyJump[1] != "second" {
t.Fatalf("proxy jump chain wrong: %v", h.ProxyJump)
}
if h.Addr() != "10.0.0.5:2200" {
t.Fatalf("Addr = %q", h.Addr())
}
}
func TestResolveHostAdHocDefaultsPort(t *testing.T) {
h, err := ResolveHost(config.Default(), "[email protected]", nil)
if err != nil {
t.Fatal(err)
}
if h.Port != 22 {
t.Fatalf("default port = %d, want 22", h.Port)
}
if h.User != "user" {
t.Fatalf("user = %q", h.User)
}
}
+42
View File
@@ -0,0 +1,42 @@
package remote
import (
"os"
"path/filepath"
"testing"
"reasonix/internal/config"
)
func TestResolveJumpHostsUsesConfiguredAliasesAndSSHConfig(t *testing.T) {
home := t.TempDir()
t.Setenv("HOME", home)
t.Setenv("USERPROFILE", home)
sshDir := filepath.Join(home, ".ssh")
if err := os.MkdirAll(sshDir, 0o700); err != nil {
t.Fatal(err)
}
text := "Host bastion\n HostName 10.0.0.8\n User jump-user\n Port 2202\n IdentityFile ~/.ssh/jump_key\n"
if err := os.WriteFile(filepath.Join(sshDir, "config"), []byte(text), 0o600); err != nil {
t.Fatal(err)
}
sshCfg, err := LoadSSHConfig(filepath.Join(sshDir, "config"))
if err != nil {
t.Fatal(err)
}
cfg := config.Default()
cfg.Remote.Hosts = []config.RemoteHostEntry{{
Name: "second", Host: "10.0.0.9", User: "ops",
PasswordEnv: "SECOND_PASSWORD", ProxyJump: "ignored-nested-hop",
}}
hops, err := ResolveJumpHosts(cfg, []string{"bastion", "second"}, sshCfg)
if err != nil {
t.Fatal(err)
}
if got := hops[0]; got.HostName != "10.0.0.8" || got.User != "jump-user" || got.Port != 2202 || got.IdentityFile != filepath.Join(home, ".ssh", "jump_key") {
t.Fatalf("ssh_config jump was not fully resolved: %+v", got)
}
if got := hops[1]; got.PasswordEnv != "SECOND_PASSWORD" || len(got.ProxyJump) != 0 {
t.Fatalf("Reasonix jump credentials/nested-chain handling wrong: %+v", got)
}
}
+200
View File
@@ -0,0 +1,200 @@
package remote
import (
"context"
"fmt"
"net"
"os"
"path/filepath"
"strings"
"sync"
"golang.org/x/crypto/ssh"
"golang.org/x/crypto/ssh/knownhosts"
)
// HostKeyQuestion describes a first-seen (TOFU) host key awaiting the user's
// decision.
type HostKeyQuestion struct {
Host string // display label (user@host:port or alias)
Address string // the network address that presented the key
KeyType string // e.g. "ssh-ed25519"
Fingerprint string // ssh.FingerprintSHA256(key)
}
// HostKeyPrompt is called for an unknown host key. Returning (true, nil)
// accepts and persists it (trust on first use); (false, nil) rejects; a
// non-nil error aborts the dial. A nil prompt means strict mode: unknown hosts
// are rejected.
type HostKeyPrompt func(ctx context.Context, q HostKeyQuestion) (accept bool, err error)
// HostKeyPolicy verifies presented host keys against the user's OpenSSH
// known_hosts files (read-only) and a Reasonix-managed file (read-write, TOFU).
type HostKeyPolicy struct {
// SystemKnownHosts are OpenSSH known_hosts files consulted read-only.
// Empty => [~/.ssh/known_hosts, ~/.ssh/known_hosts2] when they exist.
SystemKnownHosts []string
// ManagedPath is the Reasonix-managed known_hosts file that accepted TOFU
// keys are appended to. Empty => config.RemoteKnownHostsPath().
ManagedPath string
// Prompt decides unknown (first-seen) keys. Nil => strict reject.
Prompt HostKeyPrompt
mu sync.Mutex // serializes appends to ManagedPath
}
// Callback builds an ssh.HostKeyCallback enforcing this policy for host (the
// display label used in prompts). ctx bounds any interactive prompt.
func (p *HostKeyPolicy) Callback(ctx context.Context, host string) (ssh.HostKeyCallback, error) {
files := p.systemFiles()
managed := p.managedPath()
if managed != "" {
if err := os.MkdirAll(filepath.Dir(managed), 0o700); err != nil {
return nil, err
}
// knownhosts.New requires each file to exist; create an empty managed
// file on first use.
if _, err := os.Stat(managed); os.IsNotExist(err) {
if err := os.WriteFile(managed, nil, 0o600); err != nil {
return nil, err
}
}
files = append(files, managed)
}
var base ssh.HostKeyCallback
if len(files) > 0 {
var err error
base, err = knownhosts.New(files...)
if err != nil {
return nil, fmt.Errorf("load known_hosts: %w", err)
}
}
return func(hostname string, remote net.Addr, key ssh.PublicKey) error {
if base != nil {
err := base(hostname, remote, key)
if err == nil {
return nil
}
var keyErr *knownhosts.KeyError
if !asKeyError(err, &keyErr) {
return err
}
if len(keyErr.Want) > 0 {
// A different key is on record for this host: hard fail, never
// promptable. Name the file:line so the user can inspect it.
return fmt.Errorf("%w for %s: presented %s; known_hosts records a different key%s",
ErrHostKeyMismatch, host, ssh.FingerprintSHA256(key), knownHostsLocations(keyErr))
}
// len(Want)==0 => host unknown. Fall through to TOFU.
}
return p.tofu(ctx, host, hostname, remote, key, managed)
}, nil
}
func (p *HostKeyPolicy) tofu(ctx context.Context, host, hostname string, remote net.Addr, key ssh.PublicKey, managed string) error {
if p.Prompt == nil {
return fmt.Errorf("%w for %s: unknown host key %s (no confirmation available)",
ErrHostKeyRejected, host, ssh.FingerprintSHA256(key))
}
accept, err := p.Prompt(ctx, HostKeyQuestion{
Host: host,
Address: remote.String(),
KeyType: key.Type(),
Fingerprint: ssh.FingerprintSHA256(key),
})
if err != nil {
return err
}
if !accept {
return fmt.Errorf("%w for %s", ErrHostKeyRejected, host)
}
if managed == "" {
return nil // accepted for this session only
}
return p.appendManaged(managed, hostname, remote, key)
}
func (p *HostKeyPolicy) appendManaged(managed, hostname string, remote net.Addr, key ssh.PublicKey) error {
p.mu.Lock()
defer p.mu.Unlock()
addrs := []string{knownhosts.Normalize(hostname)}
if remote != nil {
if norm := knownhosts.Normalize(remote.String()); norm != addrs[0] {
addrs = append(addrs, norm)
}
}
line := knownhosts.Line(addrs, key)
f, err := os.OpenFile(managed, os.O_APPEND|os.O_WRONLY|os.O_CREATE, 0o600)
if err != nil {
return err
}
defer f.Close()
if _, err := f.WriteString(strings.TrimRight(line, "\n") + "\n"); err != nil {
return err
}
return nil
}
func (p *HostKeyPolicy) systemFiles() []string {
if len(p.SystemKnownHosts) > 0 {
out := make([]string, 0, len(p.SystemKnownHosts))
for _, f := range p.SystemKnownHosts {
if f = expandHome(f); fileExists(f) {
out = append(out, f)
}
}
return out
}
home, err := os.UserHomeDir()
if err != nil {
return nil
}
var out []string
for _, name := range []string{"known_hosts", "known_hosts2"} {
p := filepath.Join(home, ".ssh", name)
if fileExists(p) {
out = append(out, p)
}
}
return out
}
func (p *HostKeyPolicy) managedPath() string {
if p.ManagedPath != "" {
return p.ManagedPath
}
return defaultManagedKnownHosts()
}
func knownHostsLocations(e *knownhosts.KeyError) string {
var b strings.Builder
for _, k := range e.Want {
if k.Filename != "" {
fmt.Fprintf(&b, " (%s:%d)", k.Filename, k.Line)
}
}
return b.String()
}
func asKeyError(err error, target **knownhosts.KeyError) bool {
for err != nil {
if ke, ok := err.(*knownhosts.KeyError); ok {
*target = ke
return true
}
type unwrapper interface{ Unwrap() error }
u, ok := err.(unwrapper)
if !ok {
return false
}
err = u.Unwrap()
}
return false
}
func fileExists(path string) bool {
fi, err := os.Stat(path)
return err == nil && !fi.IsDir()
}
+10
View File
@@ -0,0 +1,10 @@
package remote
import "reasonix/internal/config"
// defaultManagedKnownHosts is the Reasonix-managed known_hosts path. It is a
// thin indirection over config so tests can leave HostKeyPolicy.ManagedPath
// empty and still get an isolated file under REASONIX_HOME.
func defaultManagedKnownHosts() string {
return config.RemoteKnownHostsPath()
}
+210
View File
@@ -0,0 +1,210 @@
package remote
import (
"context"
"math/rand"
"sync"
"testing"
"time"
"reasonix/internal/remote/sshtest"
)
func deterministicRand() *rand.Rand { return rand.New(rand.NewSource(1)) }
// fakeClock is a controllable Clock. After() channels fire when advance() moves
// past their deadline.
type fakeClock struct {
mu sync.Mutex
now time.Time
waiters []fakeWaiter
}
type fakeWaiter struct {
at time.Time
ch chan time.Time
}
func newFakeClock() *fakeClock {
return &fakeClock{now: time.Unix(1_700_000_000, 0)}
}
func (c *fakeClock) Now() time.Time {
c.mu.Lock()
defer c.mu.Unlock()
return c.now
}
func (c *fakeClock) After(d time.Duration) <-chan time.Time {
c.mu.Lock()
defer c.mu.Unlock()
ch := make(chan time.Time, 1)
if d <= 0 {
ch <- c.now
return ch
}
c.waiters = append(c.waiters, fakeWaiter{at: c.now.Add(d), ch: ch})
return ch
}
// advance moves time forward, firing any waiters whose deadline is reached.
func (c *fakeClock) advance(d time.Duration) {
c.mu.Lock()
c.now = c.now.Add(d)
now := c.now
var remaining []fakeWaiter
var fire []chan time.Time
for _, w := range c.waiters {
if !w.at.After(now) {
fire = append(fire, w.ch)
} else {
remaining = append(remaining, w)
}
}
c.waiters = remaining
c.mu.Unlock()
for _, ch := range fire {
ch <- now
}
}
func (c *fakeClock) pendingWaiters() int {
c.mu.Lock()
defer c.mu.Unlock()
return len(c.waiters)
}
// TestReconnectAfterConnectionDrop verifies the supervisor detects a dropped
// connection and reconnects, emitting Connecting -> Connected -> Reconnecting
// -> Connected.
func TestReconnectAfterConnectionDrop(t *testing.T) {
srv := sshtest.Start(t, sshtest.Options{Password: "x"})
var mu sync.Mutex
var states []Status
host, _ := ResolveHost(nil, "test@"+srv.Addr, nil)
c, err := New(Options{
Host: host,
HostKeys: managedOnlyPolicy(t, true),
Auth: AuthOptions{DisableAgent: true, Password: func() (string, error) { return "x", nil }},
// Real clock here: we rely on the actual keepalive to notice the drop
// quickly, so keep intervals short.
Keepalive: KeepalivePolicy{Interval: 50 * time.Millisecond, MaxMisses: 1, Timeout: 200 * time.Millisecond},
Backoff: BackoffPolicy{Initial: 10 * time.Millisecond, Max: 50 * time.Millisecond},
})
if err != nil {
t.Fatal(err)
}
reconnected := make(chan struct{}, 1)
connectedCount := 0
c.Subscribe(func(ev StatusEvent) {
mu.Lock()
states = append(states, ev.Status)
if ev.Status == StatusConnected {
connectedCount++
if connectedCount == 2 {
select {
case reconnected <- struct{}{}:
default:
}
}
}
mu.Unlock()
})
ctx, cancel := context.WithTimeout(context.Background(), 15*time.Second)
defer cancel()
if err := c.Start(ctx); err != nil {
t.Fatalf("Start: %v", err)
}
defer c.Close()
// Drop every server-side connection to force a reconnect.
srv.DropConnections()
select {
case <-reconnected:
case <-time.After(10 * time.Second):
t.Fatalf("never reconnected; states=%v", snapshot(&mu, &states))
}
got := snapshot(&mu, &states)
if !containsStatus(got, StatusReconnecting) {
t.Fatalf("no Reconnecting status observed: %v", got)
}
}
// TestBackoffUsesClock drives the backoff purely through the fake clock: a
// failed first reconnect must wait on clock.After before retrying.
func TestBackoffSleepHonorsContextCancel(t *testing.T) {
clock := newFakeClock()
c := &Client{
opts: Options{Backoff: BackoffPolicy{Initial: time.Second, Max: 10 * time.Second}},
clock: clock,
rng: deterministicRand(),
}
ctx, cancel := context.WithCancel(context.Background())
done := make(chan bool, 1)
go func() { done <- c.sleepBackoff(ctx, 1) }()
// Wait until the sleeper registers its waiter, then cancel.
waitForWaiters(t, clock, 1)
cancel()
select {
case ok := <-done:
if ok {
t.Fatal("sleepBackoff returned true after ctx cancel")
}
case <-time.After(2 * time.Second):
t.Fatal("sleepBackoff did not return after ctx cancel")
}
}
func TestBackoffSleepFiresOnClock(t *testing.T) {
clock := newFakeClock()
c := &Client{
opts: Options{Backoff: BackoffPolicy{Initial: time.Second, Max: 10 * time.Second}},
clock: clock,
rng: deterministicRand(),
}
done := make(chan bool, 1)
go func() { done <- c.sleepBackoff(context.Background(), 1) }()
waitForWaiters(t, clock, 1)
clock.advance(2 * time.Second) // past any ceiling in [0, 1s]
select {
case ok := <-done:
if !ok {
t.Fatal("sleepBackoff returned false without cancel")
}
case <-time.After(2 * time.Second):
t.Fatal("sleepBackoff never fired on clock advance")
}
}
func snapshot(mu *sync.Mutex, s *[]Status) []Status {
mu.Lock()
defer mu.Unlock()
out := make([]Status, len(*s))
copy(out, *s)
return out
}
func containsStatus(states []Status, want Status) bool {
for _, s := range states {
if s == want {
return true
}
}
return false
}
func waitForWaiters(t *testing.T, c *fakeClock, n int) {
t.Helper()
for i := 0; i < 200; i++ {
if c.pendingWaiters() >= n {
return
}
time.Sleep(5 * time.Millisecond)
}
t.Fatalf("clock never registered %d waiter(s)", n)
}
+199
View File
@@ -0,0 +1,199 @@
// Package remote is the SSH transport for Reasonix's remote module: host
// resolution ([remote] config + ~/.ssh/config), authentication, host-key
// verification (system known_hosts read-only + a Reasonix-managed TOFU file),
// a supervised connection with keepalive and exponential-backoff reconnect,
// shared SFTP access, and port-forward lifecycle. The agent itself never runs
// through this package — remote workspaces are driven by a `reasonix serve`
// process bootstrapped on the remote host (internal/remote/bootstrap) and
// reached through a forwarded loopback port.
//
// The package is frontend-agnostic: all interactivity flows through callbacks
// (HostKeyPrompt, SecretPrompt) and status subscriptions, so the CLI, chat
// TUI, and the Wails desktop consume the same surface.
package remote
import (
"errors"
"strings"
"time"
)
// Status is the supervised connection state.
type Status int
const (
// StatusIdle: created, Start not yet called.
StatusIdle Status = iota
// StatusConnecting: first dial in progress.
StatusConnecting
// StatusConnected: SSH established, forwards attached.
StatusConnected
// StatusReconnecting: connection lost, supervisor is backing off/redialing.
StatusReconnecting
// StatusDegraded: connected, but at least one forward failed to attach.
StatusDegraded
// StatusStopped: Close was called, the context ended, or auth became
// unrecoverable. Terminal.
StatusStopped
)
func (s Status) String() string {
switch s {
case StatusIdle:
return "idle"
case StatusConnecting:
return "connecting"
case StatusConnected:
return "connected"
case StatusReconnecting:
return "reconnecting"
case StatusDegraded:
return "degraded"
case StatusStopped:
return "stopped"
default:
return "unknown"
}
}
// StatusEvent is one supervisor state transition, delivered to subscribers
// and returned by Client.Status.
type StatusEvent struct {
Host string // configured host name (or user@host target)
Status Status
Attempt int // reconnect attempt counter; 0 on the first connect
Err error // last error for Reconnecting/Degraded/Stopped; nil otherwise
At time.Time
}
// Typed errors surfaced by dial/auth/host-key verification and the client.
var (
// ErrNotConnected: the client is not currently connected (SSH/SFTP access
// while down, or Exec during a reconnect window).
ErrNotConnected = errors.New("remote: not connected")
// ErrAuthFailed: every configured auth method was rejected; reconnects
// stop rather than re-prompting in the background.
ErrAuthFailed = errors.New("remote: authentication failed")
// ErrHostKeyMismatch: the presented host key contradicts a recorded one.
// Never promptable — the user must inspect the named known_hosts line.
ErrHostKeyMismatch = errors.New("remote: host key mismatch")
// ErrHostKeyRejected: the user declined a first-seen (TOFU) fingerprint.
ErrHostKeyRejected = errors.New("remote: host key rejected")
// ErrDisconnected: a shared resource (SFTP handle) belongs to a previous
// connection generation; re-fetch it from the client.
ErrDisconnected = errors.New("remote: connection was re-established, re-fetch the handle")
)
// classifyDialError maps an ssh handshake error to a typed error where the
// distinction matters to the reconnect supervisor: authentication failures are
// unrecoverable (stop rather than loop re-prompting), everything else is a
// transient network error worth retrying.
func classifyDialError(err error) error {
if err == nil {
return nil
}
msg := strings.ToLower(err.Error())
if strings.Contains(msg, "unable to authenticate") ||
strings.Contains(msg, "no supported methods remain") ||
strings.Contains(msg, "permission denied") {
return errAuth{err}
}
return err
}
// errAuth wraps an unrecoverable authentication failure so the supervisor can
// detect it via errors.Is(err, ErrAuthFailed) while preserving the detail.
type errAuth struct{ err error }
func (e errAuth) Error() string { return e.err.Error() }
func (e errAuth) Unwrap() error { return e.err }
func (e errAuth) Is(target error) bool {
return target == ErrAuthFailed
}
// Clock is the test seam for keepalive and reconnect timing.
type Clock interface {
Now() time.Time
After(d time.Duration) <-chan time.Time
}
type realClock struct{}
func (realClock) Now() time.Time { return time.Now() }
func (realClock) After(d time.Duration) <-chan time.Time { return time.After(d) }
// KeepalivePolicy controls liveness probing of an established connection.
type KeepalivePolicy struct {
Interval time.Duration // 0 => 30s; <0 disables keepalive
MaxMisses int // consecutive failures before declaring the link dead; 0 => 3
Timeout time.Duration // per-probe reply timeout; 0 => 10s
}
func (p KeepalivePolicy) interval() time.Duration {
if p.Interval < 0 {
return 0
}
if p.Interval == 0 {
return 30 * time.Second
}
return p.Interval
}
func (p KeepalivePolicy) maxMisses() int {
if p.MaxMisses <= 0 {
return 3
}
return p.MaxMisses
}
func (p KeepalivePolicy) timeout() time.Duration {
if p.Timeout <= 0 {
return 10 * time.Second
}
return p.Timeout
}
// BackoffPolicy controls reconnect pacing: full-jitter exponential backoff.
type BackoffPolicy struct {
Initial time.Duration // 0 => 1s
Factor float64 // 0 => 2
Max time.Duration // 0 => 60s
}
func (p BackoffPolicy) initial() time.Duration {
if p.Initial <= 0 {
return time.Second
}
return p.Initial
}
func (p BackoffPolicy) factor() float64 {
if p.Factor <= 1 {
return 2
}
return p.Factor
}
func (p BackoffPolicy) max() time.Duration {
if p.Max <= 0 {
return 60 * time.Second
}
return p.Max
}
// delay computes the ceiling for attempt n (0-based); the supervisor draws a
// full-jitter value in [0, delay] from its rng.
func (p BackoffPolicy) delay(attempt int) time.Duration {
d := float64(p.initial())
f := p.factor()
for i := 0; i < attempt; i++ {
d *= f
if d >= float64(p.max()) {
return p.max()
}
}
if d >= float64(p.max()) {
return p.max()
}
return time.Duration(d)
}
+45
View File
@@ -0,0 +1,45 @@
package sftpfs
import "unicode/utf8"
// Kind classifies file content for preview purposes.
type Kind int
const (
// KindText is UTF-8 (or ASCII) text safe to render and edit.
KindText Kind = iota
// KindBinary contains NUL bytes or invalid UTF-8; not editable as text.
KindBinary
)
const (
// DefaultReadCap bounds a text preview to keep memory and transfer sane.
DefaultReadCap = 4 << 20 // 4 MiB
// sniffLen is how many leading bytes DetectKind inspects.
sniffLen = 8 << 10 // 8 KiB
)
// DetectKind classifies a leading sample of file content. A NUL byte marks
// binary immediately; otherwise the sample must be valid UTF-8 (allowing a
// trailing rune truncated by the sample boundary).
func DetectKind(sample []byte) Kind {
if len(sample) > sniffLen {
sample = sample[:sniffLen]
}
for _, b := range sample {
if b == 0 {
return KindBinary
}
}
if utf8.Valid(sample) {
return KindText
}
// The sample may have split a multi-byte rune at the tail; retry without
// the trailing partial rune before declaring binary.
for i := 0; i < utf8.UTFMax-1 && i < len(sample); i++ {
if utf8.Valid(sample[:len(sample)-1-i]) {
return KindText
}
}
return KindBinary
}
+35
View File
@@ -0,0 +1,35 @@
package sftpfs
import (
"strings"
"testing"
)
func TestDetectKind(t *testing.T) {
cases := []struct {
name string
in []byte
want Kind
}{
{"ascii", []byte("hello world\n"), KindText},
{"utf8", []byte("héllo 世界"), KindText},
{"empty", []byte{}, KindText},
{"nul", []byte("abc\x00def"), KindBinary},
{"invalid-utf8", []byte{0xff, 0xfe, 0xfd, 0xfc}, KindBinary},
{"long-text", []byte(strings.Repeat("a", 20000)), KindText},
}
for _, c := range cases {
if got := DetectKind(c.in); got != c.want {
t.Errorf("DetectKind(%s) = %v, want %v", c.name, got, c.want)
}
}
}
func TestDetectKindTruncatedRune(t *testing.T) {
// A multi-byte rune split exactly at the sniff boundary must not be
// misread as binary.
body := append([]byte(strings.Repeat("a", sniffLen-1)), []byte("世")[0])
if got := DetectKind(body); got != KindText {
t.Errorf("truncated trailing rune classified as %v, want text", got)
}
}
+309
View File
@@ -0,0 +1,309 @@
// Package sftpfs is the SFTP file layer for the remote module: directory
// listing, stat, capped reads with text/binary detection, atomic writes, and
// the usual mkdir/rename/remove. It quarantines the github.com/pkg/sftp
// dependency — no other Reasonix package imports it directly. One *FS is shared
// per SSH connection; the underlying pkg/sftp client is safe for concurrent
// use.
package sftpfs
import (
"bytes"
"context"
"crypto/rand"
"encoding/hex"
"io"
"io/fs"
"os"
"path"
"strings"
"github.com/pkg/sftp"
"golang.org/x/crypto/ssh"
)
// FS wraps an SFTP client bound to one SSH connection.
type FS struct {
client *sftp.Client
}
// Entry is one directory entry.
type Entry struct {
Name string
Path string
Size int64
Mode fs.FileMode
ModTime int64 // unix seconds
IsDir bool
Symlink bool
}
// New opens an SFTP session over an established SSH client.
func New(cl *ssh.Client) (*FS, error) {
c, err := sftp.NewClient(cl)
if err != nil {
return nil, err
}
return &FS{client: c}, nil
}
// Close tears down the SFTP session (not the SSH connection).
func (f *FS) Close() error {
if f == nil || f.client == nil {
return nil
}
return f.client.Close()
}
// run executes op in a goroutine and honors ctx cancellation. pkg/sftp has no
// context-aware API; on cancellation we abandon (not abort) the in-flight op —
// it completes in the background and its result is discarded.
func run[T any](ctx context.Context, op func() (T, error)) (T, error) {
type result struct {
val T
err error
}
ch := make(chan result, 1)
go func() {
v, err := op()
ch <- result{v, err}
}()
select {
case <-ctx.Done():
var zero T
return zero, ctx.Err()
case r := <-ch:
return r.val, r.err
}
}
// List returns the entries of dir.
func (f *FS) List(ctx context.Context, dir string) ([]Entry, error) {
return run(ctx, func() ([]Entry, error) {
infos, err := f.client.ReadDir(dir)
if err != nil {
return nil, err
}
out := make([]Entry, 0, len(infos))
for _, fi := range infos {
full := path.Join(dir, fi.Name())
e := Entry{
Name: fi.Name(),
Path: full,
Size: fi.Size(),
Mode: fi.Mode(),
ModTime: fi.ModTime().Unix(),
IsDir: fi.IsDir(),
Symlink: fi.Mode()&fs.ModeSymlink != 0,
}
// Resolve symlink dir-ness so the tree can show expanders.
if e.Symlink {
if st, serr := f.client.Stat(full); serr == nil {
e.IsDir = st.IsDir()
e.Size = st.Size()
}
}
out = append(out, e)
}
return out, nil
})
}
// Stat returns metadata for a single path (following symlinks).
func (f *FS) Stat(ctx context.Context, p string) (Entry, error) {
return run(ctx, func() (Entry, error) {
fi, err := f.client.Stat(p)
if err != nil {
return Entry{}, err
}
return Entry{
Name: path.Base(p),
Path: p,
Size: fi.Size(),
Mode: fi.Mode(),
ModTime: fi.ModTime().Unix(),
IsDir: fi.IsDir(),
}, nil
})
}
// ReadFile reads up to maxSize bytes (0 => DefaultReadCap). It reports
// truncated=true when the file exceeds the cap, and returns the detected Kind.
func (f *FS) ReadFile(ctx context.Context, p string, maxSize int64) (data []byte, truncated bool, kind Kind, err error) {
if maxSize <= 0 {
maxSize = DefaultReadCap
}
type res struct {
data []byte
truncated bool
kind Kind
}
r, err := run(ctx, func() (res, error) {
fh, oerr := f.client.Open(p)
if oerr != nil {
return res{}, oerr
}
defer fh.Close()
// Read one extra byte to detect truncation.
buf, rerr := io.ReadAll(io.LimitReader(fh, maxSize+1))
if rerr != nil {
return res{}, rerr
}
out := res{}
if int64(len(buf)) > maxSize {
out.truncated = true
buf = buf[:maxSize]
}
out.data = buf
out.kind = DetectKind(buf)
return out, nil
})
if err != nil {
return nil, false, KindBinary, err
}
return r.data, r.truncated, r.kind, nil
}
// Download streams the entire remote file p to w with no size cap. Use this for
// `fs get`-style whole-file transfers; ReadFile is the capped preview path and
// must not be used to download files (it silently truncates at DefaultReadCap).
// Returns the number of bytes copied.
func (f *FS) Download(ctx context.Context, p string, w io.Writer) (int64, error) {
return run(ctx, func() (int64, error) {
fh, oerr := f.client.Open(p)
if oerr != nil {
return 0, oerr
}
defer fh.Close()
return io.Copy(w, fh)
})
}
// WriteFileAtomic writes data to p via a temp file in the same directory
// followed by a rename, so a concurrent reader never sees a partial file.
func (f *FS) WriteFileAtomic(ctx context.Context, p string, data []byte, perm fs.FileMode) error {
_, err := f.writeFileAtomic(ctx, p, bytes.NewReader(data), perm)
return err
}
// UploadAtomic streams r into a same-directory temporary file and publishes it
// with the same atomic-write contract as WriteFileAtomic.
func (f *FS) UploadAtomic(ctx context.Context, p string, r io.Reader, perm fs.FileMode) (int64, error) {
return f.writeFileAtomic(ctx, p, r, perm)
}
func (f *FS) writeFileAtomic(ctx context.Context, p string, r io.Reader, perm fs.FileMode) (int64, error) {
return run(ctx, func() (int64, error) {
dir := path.Dir(p)
tmp := path.Join(dir, "."+path.Base(p)+".reasonix-tmp-"+randSuffix())
fh, oerr := f.client.OpenFile(tmp, os.O_WRONLY|os.O_CREATE|os.O_TRUNC)
if oerr != nil {
return 0, oerr
}
n, werr := io.Copy(fh, r)
if werr != nil {
_ = fh.Close()
_ = f.client.Remove(tmp)
return n, werr
}
if cerr := fh.Close(); cerr != nil {
_ = f.client.Remove(tmp)
return n, cerr
}
if perm != 0 {
if cerr := f.client.Chmod(tmp, perm); cerr != nil {
_ = f.client.Remove(tmp)
return n, cerr
}
}
if rerr := f.rename(tmp, p); rerr != nil {
_ = f.client.Remove(tmp)
return n, rerr
}
return n, nil
})
}
// rename prefers the POSIX atomic rename extension, falling back to
// remove-then-rename when the destination exists on a server without it.
func (f *FS) rename(oldPath, newPath string) error {
if err := f.client.PosixRename(oldPath, newPath); err == nil {
return nil
}
if err := f.client.Rename(oldPath, newPath); err == nil {
return nil
}
// Destination may already exist on a plain-SFTP server: remove and retry.
if _, serr := f.client.Stat(newPath); serr == nil {
if rerr := f.client.Remove(newPath); rerr != nil {
return rerr
}
}
return f.client.Rename(oldPath, newPath)
}
// MkdirAll creates p and any missing parents.
func (f *FS) MkdirAll(ctx context.Context, p string) error {
_, err := run(ctx, func() (struct{}, error) {
return struct{}{}, f.client.MkdirAll(p)
})
return err
}
// MkdirExclusive creates exactly p and fails when it already exists. It is the
// atomic primitive used by cross-client remote bootstrap locks.
func (f *FS) MkdirExclusive(ctx context.Context, p string) error {
_, err := run(ctx, func() (struct{}, error) {
return struct{}{}, f.client.Mkdir(p)
})
return err
}
// Rename moves oldPath to newPath.
func (f *FS) Rename(ctx context.Context, oldPath, newPath string) error {
_, err := run(ctx, func() (struct{}, error) {
return struct{}{}, f.rename(oldPath, newPath)
})
return err
}
// Remove deletes a file or (recursively) a directory.
func (f *FS) Remove(ctx context.Context, p string, recursive bool) error {
_, err := run(ctx, func() (struct{}, error) {
fi, serr := f.client.Stat(p)
if serr != nil {
return struct{}{}, serr
}
if fi.IsDir() {
if recursive {
return struct{}{}, f.client.RemoveAll(p)
}
return struct{}{}, f.client.RemoveDirectory(p)
}
return struct{}{}, f.client.Remove(p)
})
return err
}
// RealPath resolves ~, relative, and symlinked paths to an absolute path on
// the remote host.
func (f *FS) RealPath(ctx context.Context, p string) (string, error) {
return run(ctx, func() (string, error) {
if p == "~" || strings.HasPrefix(p, "~/") {
home, herr := f.client.Getwd() // sftp opens at the login home
if herr == nil {
p = path.Join(home, strings.TrimPrefix(strings.TrimPrefix(p, "~"), "/"))
}
}
rp, err := f.client.RealPath(p)
if err != nil {
return "", err
}
return rp, nil
})
}
func randSuffix() string {
var b [8]byte
_, _ = rand.Read(b[:])
return hex.EncodeToString(b[:])
}
+205
View File
@@ -0,0 +1,205 @@
package sftpfs
import (
"bytes"
"context"
"os"
"path/filepath"
"runtime"
"strings"
"testing"
"time"
"golang.org/x/crypto/ssh"
"reasonix/internal/remote/sshtest"
)
func dialFS(t *testing.T, root string) *FS {
t.Helper()
// The remote module targets POSIX (Linux/macOS) remotes: SFTP paths are
// always forward-slash and rooted on the remote host. This harness runs the
// SFTP server against the LOCAL filesystem, so on Windows it serves Windows
// drive paths and the POSIX/Windows path translation breaks — a property of
// the harness, not the product. Linux/macOS CI covers the round-trips.
if runtime.GOOS == "windows" {
t.Skip("SFTP-server harness serves the local FS; POSIX-remote paths are only exercised on Linux/macOS")
}
srv := sshtest.Start(t, sshtest.Options{SFTPRoot: root})
cfg := &ssh.ClientConfig{
User: "test",
HostKeyCallback: ssh.InsecureIgnoreHostKey(),
Timeout: 5 * time.Second,
}
cl, err := ssh.Dial("tcp", srv.Addr, cfg)
if err != nil {
t.Fatalf("ssh dial: %v", err)
}
t.Cleanup(func() { cl.Close() })
fsys, err := New(cl)
if err != nil {
t.Fatalf("sftp new: %v", err)
}
t.Cleanup(func() { fsys.Close() })
return fsys
}
func TestSFTPListStatRead(t *testing.T) {
root := t.TempDir()
if err := os.WriteFile(filepath.Join(root, "hello.txt"), []byte("hi there"), 0o644); err != nil {
t.Fatal(err)
}
if err := os.Mkdir(filepath.Join(root, "sub"), 0o755); err != nil {
t.Fatal(err)
}
fsys := dialFS(t, root)
ctx := context.Background()
entries, err := fsys.List(ctx, root)
if err != nil {
t.Fatalf("List: %v", err)
}
names := map[string]bool{}
for _, e := range entries {
names[e.Name] = e.IsDir
}
if _, ok := names["hello.txt"]; !ok {
t.Fatalf("hello.txt missing from listing: %+v", entries)
}
if !names["sub"] {
t.Fatal("sub not reported as dir")
}
st, err := fsys.Stat(ctx, filepath.Join(root, "hello.txt"))
if err != nil {
t.Fatalf("Stat: %v", err)
}
if st.Size != 8 {
t.Fatalf("size = %d, want 8", st.Size)
}
data, truncated, kind, err := fsys.ReadFile(ctx, filepath.Join(root, "hello.txt"), 0)
if err != nil {
t.Fatalf("ReadFile: %v", err)
}
if string(data) != "hi there" || truncated || kind != KindText {
t.Fatalf("read = %q truncated=%v kind=%v", data, truncated, kind)
}
}
// TestSFTPDownloadStreamsFullFile pins the fs-get fix: Download must return the
// whole file, not the 4 MiB preview cap ReadFile enforces.
func TestSFTPDownloadStreamsFullFile(t *testing.T) {
root := t.TempDir()
big := strings.Repeat("x", (DefaultReadCap)+5000) // > preview cap
if err := os.WriteFile(filepath.Join(root, "big.bin"), []byte(big), 0o644); err != nil {
t.Fatal(err)
}
fsys := dialFS(t, root)
// ReadFile truncates at the cap...
_, truncated, _, err := fsys.ReadFile(context.Background(), filepath.Join(root, "big.bin"), 0)
if err != nil || !truncated {
t.Fatalf("expected ReadFile to report truncation (err=%v truncated=%v)", err, truncated)
}
// ...but Download returns every byte.
var buf bytes.Buffer
n, err := fsys.Download(context.Background(), filepath.Join(root, "big.bin"), &buf)
if err != nil {
t.Fatalf("Download: %v", err)
}
if n != int64(len(big)) || buf.Len() != len(big) {
t.Fatalf("Download got %d bytes, want %d (must not truncate)", n, len(big))
}
}
func TestSFTPReadCapTruncates(t *testing.T) {
root := t.TempDir()
big := strings.Repeat("a", 100)
if err := os.WriteFile(filepath.Join(root, "big.txt"), []byte(big), 0o644); err != nil {
t.Fatal(err)
}
fsys := dialFS(t, root)
data, truncated, _, err := fsys.ReadFile(context.Background(), filepath.Join(root, "big.txt"), 10)
if err != nil {
t.Fatalf("ReadFile: %v", err)
}
if !truncated || len(data) != 10 {
t.Fatalf("cap not honored: len=%d truncated=%v", len(data), truncated)
}
}
func TestSFTPWriteAtomicAndMkdirRenameRemove(t *testing.T) {
root := t.TempDir()
fsys := dialFS(t, root)
ctx := context.Background()
target := filepath.Join(root, "out.txt")
if err := fsys.WriteFileAtomic(ctx, target, []byte("content"), 0o644); err != nil {
t.Fatalf("WriteFileAtomic: %v", err)
}
// No temp file left behind.
entries, _ := os.ReadDir(root)
for _, e := range entries {
if strings.Contains(e.Name(), "reasonix-tmp") {
t.Fatalf("temp file left behind: %s", e.Name())
}
}
got, err := os.ReadFile(target)
if err != nil || string(got) != "content" {
t.Fatalf("written content = %q err=%v", got, err)
}
uploaded := filepath.Join(root, "uploaded.txt")
n, err := fsys.UploadAtomic(ctx, uploaded, strings.NewReader("streamed"), 0o600)
if err != nil || n != 8 {
t.Fatalf("UploadAtomic = %d, %v", n, err)
}
if got, err := os.ReadFile(uploaded); err != nil || string(got) != "streamed" {
t.Fatalf("uploaded content = %q err=%v", got, err)
}
info, err := os.Stat(uploaded)
if err != nil {
t.Fatal(err)
}
if info.Mode().Perm() != 0o600 {
t.Fatalf("uploaded mode = %v", info.Mode().Perm())
}
// Overwrite existing (exercises rename-over-existing path).
if err := fsys.WriteFileAtomic(ctx, target, []byte("v2"), 0o644); err != nil {
t.Fatalf("overwrite: %v", err)
}
got, _ = os.ReadFile(target)
if string(got) != "v2" {
t.Fatalf("overwrite content = %q", got)
}
dir := filepath.Join(root, "a", "b")
if err := fsys.MkdirAll(ctx, dir); err != nil {
t.Fatalf("MkdirAll: %v", err)
}
if fi, err := os.Stat(dir); err != nil || !fi.IsDir() {
t.Fatalf("mkdir -p failed: %v", err)
}
renamed := filepath.Join(root, "renamed.txt")
if err := fsys.Rename(ctx, target, renamed); err != nil {
t.Fatalf("Rename: %v", err)
}
if _, err := os.Stat(renamed); err != nil {
t.Fatalf("rename target missing: %v", err)
}
if err := fsys.Remove(ctx, renamed, false); err != nil {
t.Fatalf("Remove file: %v", err)
}
if _, err := os.Stat(renamed); !os.IsNotExist(err) {
t.Fatal("file not removed")
}
if err := fsys.Remove(ctx, filepath.Join(root, "a"), true); err != nil {
t.Fatalf("Remove dir recursive: %v", err)
}
if _, err := os.Stat(filepath.Join(root, "a")); !os.IsNotExist(err) {
t.Fatal("dir not removed")
}
}
+232
View File
@@ -0,0 +1,232 @@
package remote
import (
"bufio"
"os"
"path/filepath"
"strconv"
"strings"
ssh_config "github.com/kevinburke/ssh_config"
)
// SSHConfigSource answers per-alias lookups against a parsed OpenSSH client
// config (~/.ssh/config plus Includes). Match blocks are not evaluated by the
// underlying parser; ImportedHost.HasMatchRules is unset because such stanzas
// are simply invisible here — `remote import` notes that limitation.
type SSHConfigSource struct {
cfg *ssh_config.Config
path string
aliases []string
}
// LoadUserSSHConfig parses ~/.ssh/config. A missing file yields an empty
// source (all lookups return zero values), not an error.
func LoadUserSSHConfig() (*SSHConfigSource, error) {
home, err := os.UserHomeDir()
if err != nil {
return &SSHConfigSource{}, nil
}
return LoadSSHConfig(filepath.Join(home, ".ssh", "config"))
}
// LoadSSHConfig parses one OpenSSH client config file.
func LoadSSHConfig(path string) (*SSHConfigSource, error) {
f, err := os.Open(path)
if err != nil {
if os.IsNotExist(err) {
return &SSHConfigSource{path: path}, nil
}
return nil, err
}
defer f.Close()
cfg, err := ssh_config.Decode(f)
if err != nil {
return nil, err
}
aliases, _ := discoverSSHAliases(path, 0, map[string]bool{})
return &SSHConfigSource{cfg: cfg, path: path, aliases: aliases}, nil
}
// Path is the file this source was parsed from (may not exist).
func (s *SSHConfigSource) Path() string { return s.path }
func (s *SSHConfigSource) get(alias, key string) string {
if s == nil || s.cfg == nil {
return ""
}
v, err := s.cfg.Get(alias, key)
if err != nil {
return ""
}
return strings.TrimSpace(v)
}
// HostName returns the ssh_config HostName for alias, or "" when it would
// just echo the default/alias back.
func (s *SSHConfigSource) HostName(alias string) string {
v := s.get(alias, "HostName")
if v == "" || v == alias {
return ""
}
return v
}
func (s *SSHConfigSource) User(alias string) string { return s.get(alias, "User") }
func (s *SSHConfigSource) Port(alias string) int {
v := s.get(alias, "Port")
if v == "" {
return 0
}
p, err := strconv.Atoi(v)
if err != nil || p <= 0 || p > 65535 || p == 22 {
return 0
}
return p
}
// IdentityFile returns the first non-default identity file, ~-expanded.
func (s *SSHConfigSource) IdentityFile(alias string) string {
if s == nil || s.cfg == nil {
return ""
}
vals, err := s.cfg.GetAll(alias, "IdentityFile")
if err != nil || len(vals) == 0 {
return ""
}
v := strings.TrimSpace(vals[0])
// The parser reports its built-in default (~/.ssh/identity) when the
// config has no entry; treat it as unset so the auth chain probes the
// modern default identities instead.
if v == "" || v == ssh_config.Default("IdentityFile") {
return ""
}
return expandHome(v)
}
func (s *SSHConfigSource) ProxyJump(alias string) string { return s.get(alias, "ProxyJump") }
// ImportedHost is one concrete Host alias surfaced by `remote import`.
type ImportedHost struct {
Alias string
HostName string
User string
Port int
IdentityFile string
ProxyJump string
}
// Aliases lists concrete (non-wildcard, non-negated) Host aliases in file
// order, deduplicated, each resolved through the full config.
func (s *SSHConfigSource) Aliases() []ImportedHost {
if s == nil || s.cfg == nil {
return nil
}
seen := map[string]bool{}
// File order is meaningful to users, so it is preserved as-is.
out := make([]ImportedHost, 0, len(s.aliases))
for _, alias := range s.aliases {
if alias == "" || strings.ContainsAny(alias, "*?!") || seen[alias] {
continue
}
seen[alias] = true
out = append(out, ImportedHost{
Alias: alias,
HostName: s.HostName(alias),
User: s.User(alias),
Port: s.Port(alias),
IdentityFile: s.IdentityFile(alias),
ProxyJump: s.ProxyJump(alias),
})
}
return out
}
// discoverSSHAliases walks Host and Include directives in file order. The
// upstream parser resolves values through Include nodes but does not expose
// included Host declarations, so import discovery needs this small read-only
// pass to avoid hiding the common ~/.ssh/config.d/* layout.
func discoverSSHAliases(filename string, depth int, seen map[string]bool) ([]string, error) {
if depth > 5 {
return nil, nil
}
abs, err := filepath.Abs(filename)
if err == nil {
filename = abs
}
if seen[filename] {
return nil, nil
}
seen[filename] = true
f, err := os.Open(filename)
if err != nil {
return nil, err
}
defer f.Close()
var out []string
scanner := bufio.NewScanner(f)
for scanner.Scan() {
line := strings.TrimSpace(scanner.Text())
if line == "" || strings.HasPrefix(line, "#") {
continue
}
line = strings.TrimSpace(stripSSHComment(line))
if eq := strings.IndexByte(line, '='); eq >= 0 {
if space := strings.IndexAny(line, " \t"); space < 0 || eq < space {
line = line[:eq] + " " + line[eq+1:]
}
}
fields := strings.Fields(line)
if len(fields) < 2 {
continue
}
switch strings.ToLower(fields[0]) {
case "host":
for _, alias := range fields[1:] {
out = append(out, strings.Trim(alias, `"'`))
}
case "include":
for _, directive := range fields[1:] {
directive = expandHome(strings.Trim(directive, `"'`))
if !filepath.IsAbs(directive) {
if home, homeErr := os.UserHomeDir(); homeErr == nil {
directive = filepath.Join(home, ".ssh", directive)
}
}
matches, _ := filepath.Glob(directive)
for _, match := range matches {
aliases, includeErr := discoverSSHAliases(match, depth+1, seen)
if includeErr == nil {
out = append(out, aliases...)
}
}
}
}
}
return out, scanner.Err()
}
func stripSSHComment(line string) string {
var quote rune
for i, r := range line {
switch {
case quote != 0 && r == quote:
quote = 0
case quote == 0 && (r == '\'' || r == '"'):
quote = r
case quote == 0 && r == '#':
return line[:i]
}
}
return line
}
func expandHome(p string) string {
if p == "~" || strings.HasPrefix(p, "~/") {
if home, err := os.UserHomeDir(); err == nil {
return filepath.Join(home, strings.TrimPrefix(strings.TrimPrefix(p, "~"), "/"))
}
}
return p
}
Loaded 100 of 109 files, more files were not shown because too many files have changed in this diff. Show more