Skip to content

Commit efecaef

Browse files
morganlintonGrok Build
andcommitted
feat: add local clipboard watching with automatic sync + TUI visibility
Add opt-in clipboard watching (`[watch] enabled = true`) so the daemon automatically detects meaningful local clipboard changes (especially screenshots) and makes them immediately available to remote hosts. - Intelligent hybrid polling + debounce/filtering (prioritizes images, ignores rapid tiny text changes) - Updates internal state + watchHub notifications for proactive delivery - `pastelocal-remote --watch` now benefits from real-time change notifications - TUI dashboard shows watch status (enabled + last change time) - Added `--watch` mode documentation and clear logging - Added focused tests + fixed all review feedback (TUI render, reload enable, data race via atomic.Bool, graceful shutdown, etc.) This makes the remote clipboard experience feel much more native for agentic coding workflows over SSH. The feature is fully opt-in and respects the existing security model. Co-authored-by: Grok Build <grok@x.ai>
1 parent 7f8d6a1 commit efecaef

13 files changed

Lines changed: 399 additions & 98 deletions

File tree

cmd/pastelocal-remote/main.go

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -258,7 +258,9 @@ func runSend(client *http.Client, baseURL, token, filePath, format string) int {
258258
return 0
259259
}
260260

261-
// runWatch connects to the WebSocket endpoint and prints notifications.
261+
// runWatch connects to the WebSocket /clipboard/watch endpoint (or falls back to polling)
262+
// and prints notifications. With daemon clipboard watching enabled, changes are
263+
// pushed proactively when the local OS clipboard changes (screenshots etc).
262264
func runWatch(baseURL, token string) int {
263265
// For WebSocket, we need to use a WebSocket client.
264266
// Convert http:// to ws://.

internal/config/config.go

Lines changed: 33 additions & 22 deletions
Original file line numberDiff line numberDiff line change
@@ -41,6 +41,7 @@ type Config struct {
4141
Redaction RedactionConfig `toml:"redaction"`
4242
Processors ProcessorConfig `toml:"processors"`
4343
Relay RelayConfig `toml:"relay"`
44+
Watch WatchConfig `toml:"watch"`
4445
}
4546

4647
type MacOSConfig struct {
@@ -52,13 +53,13 @@ type LinuxConfig struct {
5253
}
5354

5455
type Host struct {
55-
AddedAt time.Time `toml:"added_at"`
56-
RemotePort int `toml:"remote_port"`
57-
RemoteUser string `toml:"remote_user"`
58-
RemotePath string `toml:"remote_path"`
59-
Termius bool `toml:"termius"`
60-
Permissions []string `toml:"permissions"` // "read", "write"
61-
TokenHash string `toml:"token_hash"` // SHA-256 of per-host token
56+
AddedAt time.Time `toml:"added_at"`
57+
RemotePort int `toml:"remote_port"`
58+
RemoteUser string `toml:"remote_user"`
59+
RemotePath string `toml:"remote_path"`
60+
Termius bool `toml:"termius"`
61+
Permissions []string `toml:"permissions"` // "read", "write"
62+
TokenHash string `toml:"token_hash"` // SHA-256 of per-host token
6263
}
6364

6465
// HistoryConfig controls the clipboard history ring buffer.
@@ -77,16 +78,16 @@ type RedactionConfig struct {
7778
// RedactionRule defines a single content redaction rule.
7879
type RedactionRule struct {
7980
Name string `toml:"name"`
80-
Pattern string `toml:"pattern"` // regex pattern
81-
Action string `toml:"action"` // "redact" or "block"
81+
Pattern string `toml:"pattern"` // regex pattern
82+
Action string `toml:"action"` // "redact" or "block"
8283
Description string `toml:"description"`
8384
}
8485

8586
// ProcessorConfig controls the clipboard processor pipeline.
8687
type ProcessorConfig struct {
87-
Enabled bool `toml:"enabled"`
88-
Timeout int `toml:"timeout_seconds"`
89-
Chain []ProcessorEntry `toml:"chain"`
88+
Enabled bool `toml:"enabled"`
89+
Timeout int `toml:"timeout_seconds"`
90+
Chain []ProcessorEntry `toml:"chain"`
9091
}
9192

9293
// ProcessorEntry represents a single processor in the pipeline.
@@ -97,13 +98,22 @@ type ProcessorEntry struct {
9798

9899
// RelayConfig controls the E2E encrypted relay for multi-device sync.
99100
type RelayConfig struct {
100-
Enabled bool `toml:"enabled"`
101-
RelayURL string `toml:"relay_url"`
102-
DeviceID string `toml:"device_id"`
101+
Enabled bool `toml:"enabled"`
102+
RelayURL string `toml:"relay_url"`
103+
DeviceID string `toml:"device_id"`
103104
DeviceKeyPath string `toml:"device_key_path"`
104105
AuthTokenPath string `toml:"auth_token_path"`
105-
AutoUpload bool `toml:"auto_upload"`
106-
UploadTTL int `toml:"upload_ttl"` // seconds
106+
AutoUpload bool `toml:"auto_upload"`
107+
UploadTTL int `toml:"upload_ttl"` // seconds
108+
}
109+
110+
// WatchConfig controls the optional local clipboard watcher in the daemon.
111+
// When enabled (opt-in), the daemon automatically detects meaningful
112+
// OS clipboard changes (screenshots, substantial text) using polling with
113+
// debouncing and filtering. Detected changes update internal state and
114+
// notify active /clipboard/watch subscribers.
115+
type WatchConfig struct {
116+
Enabled bool `toml:"enabled"`
107117
}
108118

109119
// mu protects file operations during Save to prevent concurrent writes.
@@ -135,14 +145,15 @@ func Default() *Config {
135145
Timeout: 5,
136146
},
137147
Relay: RelayConfig{
138-
Enabled: false,
139-
RelayURL: "http://localhost:7332",
140-
DeviceID: "",
148+
Enabled: false,
149+
RelayURL: "http://localhost:7332",
150+
DeviceID: "",
141151
DeviceKeyPath: "~/.config/pastelocal/device-key",
142152
AuthTokenPath: "~/.config/pastelocal/relay-token",
143-
AutoUpload: false,
144-
UploadTTL: 300,
153+
AutoUpload: false,
154+
UploadTTL: 300,
145155
},
156+
Watch: WatchConfig{Enabled: false},
146157
}
147158
}
148159

internal/config/config_test.go

Lines changed: 34 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -185,7 +185,7 @@ func TestSaveLoadRoundTrip(t *testing.T) {
185185
original.Hosts = map[string]Host{
186186
"server1": {
187187
AddedAt: time.Date(2024, 6, 15, 12, 0, 0, 0, time.UTC),
188-
RemotePort: 22,
188+
RemotePort: 22,
189189
RemoteUser: "admin",
190190
RemotePath: "/opt/app",
191191
Termius: false,
@@ -480,21 +480,21 @@ func TestMultipleHostsAddRemove(t *testing.T) {
480480
hosts := map[string]Host{
481481
"server1": {
482482
AddedAt: time.Date(2024, 1, 1, 0, 0, 0, 0, time.UTC),
483-
RemotePort: 22,
483+
RemotePort: 22,
484484
RemoteUser: "admin",
485485
RemotePath: "/opt/app",
486486
Termius: true,
487487
},
488488
"server2": {
489489
AddedAt: time.Date(2024, 2, 1, 0, 0, 0, 0, time.UTC),
490-
RemotePort: 2222,
490+
RemotePort: 2222,
491491
RemoteUser: "deploy",
492492
RemotePath: "/home/deploy",
493493
Termius: false,
494494
},
495495
"server3": {
496496
AddedAt: time.Date(2024, 3, 1, 0, 0, 0, 0, time.UTC),
497-
RemotePort: 22,
497+
RemotePort: 22,
498498
RemoteUser: "root",
499499
RemotePath: "/root",
500500
Termius: false,
@@ -583,8 +583,8 @@ func TestMergeDefaultsAllZero(t *testing.T) {
583583

584584
func TestMergeDefaultsPartialOverride(t *testing.T) {
585585
cfg := &Config{
586-
Port: 8080,
587-
Hosts: map[string]Host{"existing": {}},
586+
Port: 8080,
587+
Hosts: map[string]Host{"existing": {}},
588588
}
589589

590590
mergeDefaults(cfg)
@@ -831,14 +831,14 @@ func TestSaveAndReloadWithHosts(t *testing.T) {
831831
cfg.Port = 8080
832832
cfg.AddHost("host1", Host{
833833
AddedAt: time.Date(2024, 1, 15, 10, 30, 0, 0, time.UTC),
834-
RemotePort: 22,
834+
RemotePort: 22,
835835
RemoteUser: "admin",
836836
RemotePath: "/opt",
837837
Termius: true,
838838
})
839839
cfg.AddHost("host2", Host{
840840
AddedAt: time.Date(2024, 6, 1, 0, 0, 0, 0, time.UTC),
841-
RemotePort: 2222,
841+
RemotePort: 2222,
842842
RemoteUser: "deploy",
843843
RemotePath: "/home/deploy",
844844
Termius: false,
@@ -868,3 +868,29 @@ func TestSaveAndReloadWithHosts(t *testing.T) {
868868
t.Error("host1.Termius = false, want true")
869869
}
870870
}
871+
872+
func TestWatchConfig(t *testing.T) {
873+
// Default should be disabled (opt-in)
874+
cfg := Default()
875+
if cfg.Watch.Enabled {
876+
t.Error("Watch.Enabled should default to false")
877+
}
878+
879+
// Load from TOML with [watch] section
880+
dir := t.TempDir()
881+
path := filepath.Join(dir, "cfg.toml")
882+
tomlContent := `port = 7331
883+
[watch]
884+
enabled = true
885+
`
886+
if err := os.WriteFile(path, []byte(tomlContent), 0o644); err != nil {
887+
t.Fatal(err)
888+
}
889+
loaded, err := Load(path)
890+
if err != nil {
891+
t.Fatalf("load: %v", err)
892+
}
893+
if !loaded.Watch.Enabled {
894+
t.Error("Watch.Enabled should be true when set in config")
895+
}
896+
}

internal/proto/types.go

Lines changed: 15 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -6,19 +6,19 @@ const ProtocolVersion = 2
66
// ClipboardResponse is the JSON response for GET /clipboard on success.
77
type ClipboardResponse struct {
88
OK bool `json:"ok"`
9-
Image string `json:"image,omitempty"` // base64-encoded image (png/jpeg/etc)
10-
Text string `json:"text,omitempty"` // plain text content (when format is text)
11-
Format string `json:"format"` // "png", "text", "html"
9+
Image string `json:"image,omitempty"` // base64-encoded image (png/jpeg/etc)
10+
Text string `json:"text,omitempty"` // plain text content (when format is text)
11+
Format string `json:"format"` // "png", "text", "html"
1212
ByteCount int64 `json:"byte_count"`
13-
CapturedAt string `json:"captured_at"` // RFC3339
14-
ID string `json:"id,omitempty"` // unique ID for history entries
13+
CapturedAt string `json:"captured_at"` // RFC3339
14+
ID string `json:"id,omitempty"` // unique ID for history entries
1515
}
1616

1717
// ClipboardWriteRequest is the JSON request for POST /clipboard.
1818
type ClipboardWriteRequest struct {
19-
Format string `json:"format"` // "png" or "text"
20-
Image string `json:"image,omitempty"` // base64-encoded image (when format is png)
21-
Text string `json:"text,omitempty"` // plain text (when format is text)
19+
Format string `json:"format"` // "png" or "text"
20+
Image string `json:"image,omitempty"` // base64-encoded image (when format is png)
21+
Text string `json:"text,omitempty"` // plain text (when format is text)
2222
}
2323

2424
// ClipboardWriteResponse is the JSON response for POST /clipboard on success.
@@ -41,6 +41,9 @@ type VersionResponse struct {
4141
OK bool `json:"ok"`
4242
ProtocolVersion int `json:"protocol_version"`
4343
BinaryVersion string `json:"binary_version"`
44+
// Watch-related status (populated by daemon when clipboard watching is enabled).
45+
WatchEnabled bool `json:"watch_enabled"`
46+
LastClipboardChange string `json:"last_clipboard_change,omitempty"`
4447
}
4548

4649
// HealthResponse is the JSON response for GET /health.
@@ -59,13 +62,13 @@ type HistoryEntry struct {
5962

6063
// HistoryResponse is the JSON response for GET /clipboard/history.
6164
type HistoryResponse struct {
62-
OK bool `json:"ok"`
63-
Items []HistoryEntry `json:"items"`
65+
OK bool `json:"ok"`
66+
Items []HistoryEntry `json:"items"`
6467
}
6568

6669
// WatchNotification is the JSON payload pushed over the WebSocket.
6770
type WatchNotification struct {
68-
Event string `json:"event"` // "clipboard_changed"
71+
Event string `json:"event"` // "clipboard_changed"
6972
CapturedAt string `json:"captured_at"`
7073
Format string `json:"format"`
7174
ID string `json:"id,omitempty"`
@@ -91,7 +94,7 @@ type SnippetListResponse struct {
9194
// SnippetSaveRequest is the JSON request for POST /snippets.
9295
type SnippetSaveRequest struct {
9396
Name string `json:"name"`
94-
Format string `json:"format"` // "text" or "png"
97+
Format string `json:"format"` // "text" or "png"
9598
Text string `json:"text,omitempty"`
9699
Image string `json:"image,omitempty"` // base64
97100
Description string `json:"description,omitempty"`

internal/server/handlers.go

Lines changed: 9 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -172,7 +172,7 @@ func (s *Server) handleClipboardGet(w http.ResponseWriter, r *http.Request) {
172172
Event: "clipboard_read",
173173
ImageHash: fmt.Sprintf("%x", hash),
174174
ByteCount: int64(len(content.Data)),
175-
SourceIP: remoteIP,
175+
SourceIP: remoteIP,
176176
Auth: token,
177177
}
178178
if err := WriteAudit(s.cfg.AuditLog, entry); err != nil {
@@ -334,7 +334,7 @@ func (s *Server) handleClipboardPost(w http.ResponseWriter, r *http.Request) {
334334
Event: "clipboard_write",
335335
ImageHash: fmt.Sprintf("%x", hash),
336336
ByteCount: int64(len(content.Data)),
337-
SourceIP: remoteIP,
337+
SourceIP: remoteIP,
338338
Auth: token,
339339
}
340340
if err := WriteAudit(s.cfg.AuditLog, entry); err != nil {
@@ -440,6 +440,13 @@ func (s *Server) handleVersion(w http.ResponseWriter, r *http.Request) {
440440
ProtocolVersion: proto.ProtocolVersion,
441441
BinaryVersion: BinaryVersion,
442442
}
443+
// Populate optional watch status (available even without auth, for local TUI/dashboard).
444+
if enabled, t := s.WatchStatus(); enabled || !t.IsZero() {
445+
resp.WatchEnabled = enabled
446+
if enabled && !t.IsZero() {
447+
resp.LastClipboardChange = t.Format(time.RFC3339)
448+
}
449+
}
443450
w.Header().Set("Content-Type", "application/json")
444451
json.NewEncoder(w).Encode(resp)
445452
}

internal/server/history.go

Lines changed: 4 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -28,11 +28,11 @@ type historyEntry struct {
2828
// clipboard reads. Data is encrypted at rest using AES-GCM with a
2929
// key derived from the auth token.
3030
type HistoryBuffer struct {
31-
mu sync.Mutex
31+
mu sync.Mutex
3232
entries []*historyEntry
33-
size int
34-
ttl time.Duration
35-
key []byte // AES key derived from token
33+
size int
34+
ttl time.Duration
35+
key []byte // AES key derived from token
3636
}
3737

3838
// NewHistoryBuffer creates a new HistoryBuffer with the given capacity and TTL.

internal/server/processor.go

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -7,8 +7,8 @@ import (
77
"os/exec"
88
"time"
99

10-
"github.com/pastelocal/pastelocal/internal/config"
1110
"github.com/pastelocal/pastelocal/internal/clipboard"
11+
"github.com/pastelocal/pastelocal/internal/config"
1212
cliperr "github.com/pastelocal/pastelocal/internal/errors"
1313
)
1414

internal/server/redaction.go

Lines changed: 4 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -14,10 +14,10 @@ type RedactionEngine struct {
1414
}
1515

1616
type compiledRule struct {
17-
name string
18-
action string // "redact" or "block"
19-
re *regexp.Regexp
20-
desc string
17+
name string
18+
action string // "redact" or "block"
19+
re *regexp.Regexp
20+
desc string
2121
}
2222

2323
// NewRedactionEngine creates a new engine from the config.

0 commit comments

Comments
 (0)