diff --git a/cli/cmd/serve.go b/cli/cmd/serve.go index e40d57b1e0..e8f48eb5b4 100644 --- a/cli/cmd/serve.go +++ b/cli/cmd/serve.go @@ -100,6 +100,9 @@ environment variable (the --host-key flag takes precedence).`, onyx-cli serve --host 0.0.0.0 --port 2222 onyx-cli serve --idle-timeout 30m --max-session-timeout 2h`, RunE: func(cmd *cobra.Command, args []string) error { + // Sessions here belong to remote clients; keep them off the host filesystem. + tui.RemoteMode = true + serverCfg := config.Load() if serverCfg.ServerURL == "" { return exitcodes.New(exitcodes.NotConfigured, "server URL is not configured\n Run: onyx-cli chat to complete first-time setup") diff --git a/cli/internal/tui/commands.go b/cli/internal/tui/commands.go index ed026654dc..ce64c1242c 100644 --- a/cli/internal/tui/commands.go +++ b/cli/internal/tui/commands.go @@ -267,6 +267,10 @@ func cmdSelectModel(m Model, idxStr string) (Model, tea.Cmd) { } func cmdAttach(m Model, pathStr string) (Model, tea.Cmd) { + if RemoteMode { + m.viewport.addWarning("/attach is disabled over SSH: paths resolve on the server host, not yours.") + return m, nil + } if pathStr == "" { m.viewport.addWarning("Usage: /attach ") return m, nil diff --git a/cli/internal/tui/commands_test.go b/cli/internal/tui/commands_test.go index 7c880765a8..4893b17619 100644 --- a/cli/internal/tui/commands_test.go +++ b/cli/internal/tui/commands_test.go @@ -1,6 +1,8 @@ package tui import ( + "os" + "path/filepath" "strings" "testing" @@ -279,6 +281,43 @@ func TestSelectAgentFromPickerUsesIDWhenNameMatches(t *testing.T) { } } +func TestAttachRefusedInRemoteMode(t *testing.T) { + RemoteMode = true + t.Cleanup(func() { RemoteMode = false }) + + m := NewModel(config.DefaultConfig(), nil) + m, cmd := cmdAttach(m, "/etc/passwd") + + if cmd != nil { + t.Fatal("expected no upload command in remote mode") + } + if len(m.viewport.entries) == 0 { + t.Fatal("expected a warning entry") + } + got := m.viewport.entries[len(m.viewport.entries)-1].content + if !strings.Contains(got, "disabled over SSH") { + t.Errorf("warning = %q, want a refusal mentioning SSH", got) + } +} + +func TestDetectFileDropIgnoredInRemoteMode(t *testing.T) { + path := filepath.Join(t.TempDir(), "secret.txt") + if err := os.WriteFile(path, []byte("secret"), 0o600); err != nil { + t.Fatalf("write temp file: %v", err) + } + + if got := detectFileDrop(path); got != path { + t.Fatalf("local detectFileDrop = %q, want %q", got, path) + } + + RemoteMode = true + t.Cleanup(func() { RemoteMode = false }) + + if got := detectFileDrop(path); got != "" { + t.Errorf("remote detectFileDrop = %q, want no match", got) + } +} + func TestSelectModelInvalidIndex(t *testing.T) { m := NewModel(config.DefaultConfig(), nil) m, _ = cmdSelectModel(m, "5") diff --git a/cli/internal/tui/input.go b/cli/internal/tui/input.go index 6496542c8a..5dbd7863ad 100644 --- a/cli/internal/tui/input.go +++ b/cli/internal/tui/input.go @@ -204,8 +204,12 @@ type fileDropMsg struct { path string } -// detectFileDrop checks if the text looks like a file path. +// detectFileDrop checks if the text looks like a file path. It never matches +// in remote mode, where the path would resolve on the server host. func detectFileDrop(text string) string { + if RemoteMode { + return "" + } cleaned := strings.Trim(text, "'\"") if cleaned == "" { return "" diff --git a/cli/internal/tui/sshauth.go b/cli/internal/tui/sshauth.go index 402ed2b8c6..0a51a84c7c 100644 --- a/cli/internal/tui/sshauth.go +++ b/cli/internal/tui/sshauth.go @@ -14,6 +14,11 @@ const ( MaxAPIKeyRetries = 5 ) +// RemoteMode marks this process as serving the TUI to remote clients. +// Commands that touch the host filesystem are refused while it is set, +// because those paths resolve on the operator's machine, not the client's. +var RemoteMode bool + // --- auth prompt (bubbletea model) --- type authState int