@wangyaoshen/remux 0.3.8-dev.a8ceb0c

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (183) hide show
  1. package/.github/ISSUE_TEMPLATE/bug_report.md +47 -0
  2. package/.github/ISSUE_TEMPLATE/feature_request.md +38 -0
  3. package/.github/PULL_REQUEST_TEMPLATE.md +28 -0
  4. package/.github/dependabot.yml +33 -0
  5. package/.github/workflows/ci.yml +65 -0
  6. package/.github/workflows/deploy.yml +65 -0
  7. package/.github/workflows/publish.yml +312 -0
  8. package/.github/workflows/release-please.yml +21 -0
  9. package/.gitmodules +3 -0
  10. package/.nvmrc +1 -0
  11. package/.release-please-manifest.json +3 -0
  12. package/CLAUDE.md +104 -0
  13. package/Dockerfile +23 -0
  14. package/LICENSE +21 -0
  15. package/README.md +120 -0
  16. package/apps/ios/Config/signing.xcconfig +4 -0
  17. package/apps/ios/Package.swift +26 -0
  18. package/apps/ios/Remux.xcodeproj/project.pbxproj +477 -0
  19. package/apps/ios/Remux.xcodeproj/project.xcworkspace/contents.xcworkspacedata +7 -0
  20. package/apps/ios/Sources/Remux/Assets.xcassets/AppIcon.appiconset/Contents.json +23 -0
  21. package/apps/ios/Sources/Remux/Assets.xcassets/AppIcon.appiconset/icon_1024x1024.png +0 -0
  22. package/apps/ios/Sources/Remux/Assets.xcassets/AppIcon.appiconset/icon_120x120.png +0 -0
  23. package/apps/ios/Sources/Remux/Assets.xcassets/AppIcon.appiconset/icon_152x152.png +0 -0
  24. package/apps/ios/Sources/Remux/Assets.xcassets/AppIcon.appiconset/icon_167x167.png +0 -0
  25. package/apps/ios/Sources/Remux/Assets.xcassets/AppIcon.appiconset/icon_180x180.png +0 -0
  26. package/apps/ios/Sources/Remux/Assets.xcassets/AppIcon.appiconset/icon_20x20.png +0 -0
  27. package/apps/ios/Sources/Remux/Assets.xcassets/AppIcon.appiconset/icon_29x29.png +0 -0
  28. package/apps/ios/Sources/Remux/Assets.xcassets/AppIcon.appiconset/icon_40x40.png +0 -0
  29. package/apps/ios/Sources/Remux/Assets.xcassets/AppIcon.appiconset/icon_58x58.png +0 -0
  30. package/apps/ios/Sources/Remux/Assets.xcassets/AppIcon.appiconset/icon_60x60.png +0 -0
  31. package/apps/ios/Sources/Remux/Assets.xcassets/AppIcon.appiconset/icon_76x76.png +0 -0
  32. package/apps/ios/Sources/Remux/Assets.xcassets/AppIcon.appiconset/icon_80x80.png +0 -0
  33. package/apps/ios/Sources/Remux/Assets.xcassets/AppIcon.appiconset/icon_87x87.png +0 -0
  34. package/apps/ios/Sources/Remux/Assets.xcassets/Contents.json +6 -0
  35. package/apps/ios/Sources/Remux/Extensions/FaceIDManager.swift +29 -0
  36. package/apps/ios/Sources/Remux/Extensions/InspectCache.swift +66 -0
  37. package/apps/ios/Sources/Remux/MainTabView.swift +32 -0
  38. package/apps/ios/Sources/Remux/Remux.entitlements +8 -0
  39. package/apps/ios/Sources/Remux/RemuxiOSApp.swift +14 -0
  40. package/apps/ios/Sources/Remux/RootView.swift +130 -0
  41. package/apps/ios/Sources/Remux/Views/Control/ControlView.swift +102 -0
  42. package/apps/ios/Sources/Remux/Views/Inspect/InspectView.swift +98 -0
  43. package/apps/ios/Sources/Remux/Views/Live/LiveTerminalView.swift +132 -0
  44. package/apps/ios/Sources/Remux/Views/Now/NowView.swift +173 -0
  45. package/apps/ios/Sources/Remux/Views/Onboarding/ManualConnectView.swift +55 -0
  46. package/apps/ios/Sources/Remux/Views/Onboarding/OnboardingView.swift +70 -0
  47. package/apps/ios/Sources/Remux/Views/Onboarding/QRScannerView.swift +92 -0
  48. package/apps/ios/Sources/Remux/Views/Settings/MeView.swift +136 -0
  49. package/apps/macos/Package.swift +37 -0
  50. package/apps/macos/Resources/shell-integration/bash/bash-preexec.sh +382 -0
  51. package/apps/macos/Resources/shell-integration/bash/ghostty.bash +315 -0
  52. package/apps/macos/Resources/shell-integration/elvish/lib/ghostty-integration.elv +191 -0
  53. package/apps/macos/Resources/shell-integration/fish/vendor_conf.d/ghostty-shell-integration.fish +246 -0
  54. package/apps/macos/Resources/shell-integration/nushell/vendor/autoload/ghostty.nu +110 -0
  55. package/apps/macos/Resources/shell-integration/zsh/.zshenv +61 -0
  56. package/apps/macos/Resources/shell-integration/zsh/ghostty-integration +458 -0
  57. package/apps/macos/Resources/terminfo/67/ghostty +0 -0
  58. package/apps/macos/Resources/terminfo/78/xterm-ghostty +0 -0
  59. package/apps/macos/Sources/Remux/AppDelegate.swift +257 -0
  60. package/apps/macos/Sources/Remux/CrashReporter.swift +210 -0
  61. package/apps/macos/Sources/Remux/FinderIntegration.swift +117 -0
  62. package/apps/macos/Sources/Remux/GhosttyConfig.swift +311 -0
  63. package/apps/macos/Sources/Remux/KeyboardShortcuts/ShortcutAction.swift +115 -0
  64. package/apps/macos/Sources/Remux/KeyboardShortcuts/ShortcutSettingsView.swift +271 -0
  65. package/apps/macos/Sources/Remux/KeyboardShortcuts/StoredShortcut.swift +149 -0
  66. package/apps/macos/Sources/Remux/MainContentView.swift +308 -0
  67. package/apps/macos/Sources/Remux/MenuBarManager.swift +275 -0
  68. package/apps/macos/Sources/Remux/NotificationManager.swift +145 -0
  69. package/apps/macos/Sources/Remux/PortScanner.swift +152 -0
  70. package/apps/macos/Sources/Remux/RemuxApp.swift +13 -0
  71. package/apps/macos/Sources/Remux/SSHDetector.swift +151 -0
  72. package/apps/macos/Sources/Remux/SessionPersistence.swift +226 -0
  73. package/apps/macos/Sources/Remux/SocketController.swift +258 -0
  74. package/apps/macos/Sources/Remux/UpdateChecker.swift +152 -0
  75. package/apps/macos/Sources/Remux/Views/CommandPalette.swift +198 -0
  76. package/apps/macos/Sources/Remux/Views/ConnectionView.swift +84 -0
  77. package/apps/macos/Sources/Remux/Views/InspectView.swift +127 -0
  78. package/apps/macos/Sources/Remux/Views/SettingsView.swift +77 -0
  79. package/apps/macos/Sources/Remux/Views/Sidebar/SidebarView.swift +410 -0
  80. package/apps/macos/Sources/Remux/Views/SplitTree/BrowserPanel.swift +193 -0
  81. package/apps/macos/Sources/Remux/Views/SplitTree/MarkdownPanel.swift +277 -0
  82. package/apps/macos/Sources/Remux/Views/SplitTree/PanelProtocol.swift +14 -0
  83. package/apps/macos/Sources/Remux/Views/SplitTree/SplitNode.swift +149 -0
  84. package/apps/macos/Sources/Remux/Views/SplitTree/SplitView.swift +234 -0
  85. package/apps/macos/Sources/Remux/Views/SplitTree/TerminalPanel.swift +26 -0
  86. package/apps/macos/Sources/Remux/Views/TabBarView.swift +94 -0
  87. package/apps/macos/Sources/Remux/Views/Terminal/ClipboardHelper.swift +101 -0
  88. package/apps/macos/Sources/Remux/Views/Terminal/CopyModeOverlay.swift +325 -0
  89. package/apps/macos/Sources/Remux/Views/Terminal/GhosttyNativeTerminalView.swift +39 -0
  90. package/apps/macos/Sources/Remux/Views/Terminal/GhosttyNativeView.swift +559 -0
  91. package/apps/macos/Sources/Remux/Views/Terminal/SurfaceSearchOverlay.swift +109 -0
  92. package/apps/macos/Sources/Remux/Views/Terminal/TerminalContainerView.swift +95 -0
  93. package/apps/macos/Sources/Remux/Views/Terminal/TerminalRelay.swift +117 -0
  94. package/build.mjs +33 -0
  95. package/native/android/DecodeGoldenPayloads.kt +487 -0
  96. package/native/android/ProtocolModels.kt +188 -0
  97. package/native/ios/DecodeGoldenPayloads.swift +711 -0
  98. package/native/ios/ProtocolModels.swift +200 -0
  99. package/package.json +45 -0
  100. package/packages/RemuxKit/Package.swift +27 -0
  101. package/packages/RemuxKit/Sources/RemuxKit/Device/DeviceManager.swift +27 -0
  102. package/packages/RemuxKit/Sources/RemuxKit/Models/ProtocolModels.swift +206 -0
  103. package/packages/RemuxKit/Sources/RemuxKit/Networking/MessageRouter.swift +108 -0
  104. package/packages/RemuxKit/Sources/RemuxKit/Networking/RemuxConnection.swift +395 -0
  105. package/packages/RemuxKit/Sources/RemuxKit/State/RemuxState.swift +188 -0
  106. package/packages/RemuxKit/Sources/RemuxKit/Storage/KeychainStore.swift +142 -0
  107. package/packages/RemuxKit/Sources/RemuxKit/Terminal/GhosttyBridge.swift +145 -0
  108. package/packages/RemuxKit/Sources/RemuxKit/Terminal/GhosttyTerminalView.swift +35 -0
  109. package/packages/RemuxKit/Sources/RemuxKit/Terminal/Resources/ghostty-terminal.html +91 -0
  110. package/packages/RemuxKit/Tests/RemuxKitTests/ConnectionIntegrationTest.swift +74 -0
  111. package/packages/RemuxKit/Tests/RemuxKitTests/KeychainStoreTests.swift +81 -0
  112. package/packages/RemuxKit/Tests/RemuxKitTests/ProtocolModelsTests.swift +179 -0
  113. package/packages/RemuxKit/Tests/RemuxKitTests/RemuxStateTests.swift +62 -0
  114. package/playwright.config.ts +17 -0
  115. package/pnpm-lock.yaml +1588 -0
  116. package/pty-daemon.js +303 -0
  117. package/release-please-config.json +14 -0
  118. package/scripts/auto-deploy.sh +46 -0
  119. package/scripts/build-dmg.sh +121 -0
  120. package/scripts/build-ghostty-kit.sh +43 -0
  121. package/scripts/check-active-terminology.mjs +132 -0
  122. package/scripts/setup-ci-secrets.sh +80 -0
  123. package/scripts/sync-ghostty-web.sh +28 -0
  124. package/scripts/upload-testflight.sh +100 -0
  125. package/server.js +7074 -0
  126. package/src/adapters/agent-events.ts +246 -0
  127. package/src/adapters/claude-code.ts +158 -0
  128. package/src/adapters/codex.ts +210 -0
  129. package/src/adapters/generic-shell.ts +58 -0
  130. package/src/adapters/index.ts +15 -0
  131. package/src/adapters/registry.ts +99 -0
  132. package/src/adapters/types.ts +41 -0
  133. package/src/auth.ts +174 -0
  134. package/src/e2ee.ts +236 -0
  135. package/src/git-service.ts +168 -0
  136. package/src/message-buffer.ts +137 -0
  137. package/src/pty-daemon.ts +357 -0
  138. package/src/push.ts +127 -0
  139. package/src/renderers.ts +455 -0
  140. package/src/server.ts +2407 -0
  141. package/src/service.ts +226 -0
  142. package/src/session.ts +978 -0
  143. package/src/store.ts +1422 -0
  144. package/src/team.ts +123 -0
  145. package/src/tunnel.ts +126 -0
  146. package/src/types.d.ts +50 -0
  147. package/src/vt-tracker.ts +188 -0
  148. package/src/workspace-head.ts +144 -0
  149. package/src/workspace.ts +153 -0
  150. package/src/ws-handler.ts +1526 -0
  151. package/start.ps1 +83 -0
  152. package/tests/adapters.test.js +171 -0
  153. package/tests/auth.test.js +243 -0
  154. package/tests/codex-adapter.test.js +535 -0
  155. package/tests/durable-stream.test.js +153 -0
  156. package/tests/e2e/app.spec.js +530 -0
  157. package/tests/e2ee.test.js +325 -0
  158. package/tests/message-buffer.test.js +245 -0
  159. package/tests/message-routing.test.js +305 -0
  160. package/tests/pty-daemon.test.js +346 -0
  161. package/tests/push.test.js +281 -0
  162. package/tests/renderers.test.js +391 -0
  163. package/tests/search-shell.test.js +499 -0
  164. package/tests/server.test.js +882 -0
  165. package/tests/service.test.js +267 -0
  166. package/tests/store.test.js +369 -0
  167. package/tests/tunnel.test.js +67 -0
  168. package/tests/workspace-head.test.js +116 -0
  169. package/tests/workspace.test.js +417 -0
  170. package/tsconfig.backend.json +11 -0
  171. package/tsconfig.json +15 -0
  172. package/tui/client/client_test.go +125 -0
  173. package/tui/client/connection.go +342 -0
  174. package/tui/client/host_manager.go +141 -0
  175. package/tui/config/cache.go +81 -0
  176. package/tui/config/config.go +53 -0
  177. package/tui/config/config_test.go +89 -0
  178. package/tui/go.mod +32 -0
  179. package/tui/go.sum +50 -0
  180. package/tui/main.go +261 -0
  181. package/tui/tests/integration_test.go +283 -0
  182. package/tui/ui/model.go +310 -0
  183. package/vitest.config.js +10 -0
@@ -0,0 +1,305 @@
1
+ /**
2
+ * Tests that verify WebSocket message routing never leaks control-plane
3
+ * JSON into terminal PTY output, and that workspace CRUD operations
4
+ * produce visible results through the correct message flow.
5
+ *
6
+ * Red-line tests:
7
+ * 1. Server: JSON control messages must never reach pty.write()
8
+ * 2. Client: enveloped (v:1) messages must never reach term.write()
9
+ * 3. Client: known control-type JSON from scrollback must be stripped
10
+ * 4. Store: createNote/listNotes round-trip returns the created note
11
+ * 5. Store: createTopic/listTopics round-trip returns the created topic
12
+ * 6. Inspect: ANSI cleanup removes ^L (form-feed) character
13
+ */
14
+
15
+ import { describe, it, expect, beforeEach, afterEach, vi } from "vitest";
16
+ import Database from "better-sqlite3";
17
+ import {
18
+ _resetDbForTest,
19
+ closeDb,
20
+ createNote,
21
+ listNotes,
22
+ createTopic,
23
+ listTopics,
24
+ listDevices,
25
+ createDevice,
26
+ } from "../src/store.ts";
27
+
28
+ /** Create an in-memory SQLite DB with the full schema. */
29
+ function createTestDb() {
30
+ const db = new Database(":memory:");
31
+ db.pragma("journal_mode = WAL");
32
+ db.pragma("foreign_keys = ON");
33
+ db.exec(`
34
+ CREATE TABLE IF NOT EXISTS sessions (name TEXT PRIMARY KEY, created_at INTEGER NOT NULL);
35
+ CREATE TABLE IF NOT EXISTS tabs (
36
+ id INTEGER PRIMARY KEY, session_name TEXT NOT NULL, title TEXT NOT NULL DEFAULT 'Tab',
37
+ scrollback BLOB, ended INTEGER NOT NULL DEFAULT 0,
38
+ FOREIGN KEY (session_name) REFERENCES sessions(name) ON DELETE CASCADE
39
+ );
40
+ CREATE TABLE IF NOT EXISTS devices (
41
+ id TEXT PRIMARY KEY, name TEXT NOT NULL, fingerprint TEXT NOT NULL,
42
+ trust TEXT NOT NULL DEFAULT 'untrusted', created_at INTEGER NOT NULL, last_seen INTEGER NOT NULL
43
+ );
44
+ CREATE TABLE IF NOT EXISTS pair_codes (
45
+ code TEXT PRIMARY KEY, created_by TEXT NOT NULL, expires_at INTEGER NOT NULL,
46
+ FOREIGN KEY (created_by) REFERENCES devices(id) ON DELETE CASCADE
47
+ );
48
+ CREATE TABLE IF NOT EXISTS settings (key TEXT PRIMARY KEY, value TEXT);
49
+ CREATE TABLE IF NOT EXISTS push_subscriptions (
50
+ device_id TEXT PRIMARY KEY, endpoint TEXT NOT NULL, p256dh TEXT NOT NULL,
51
+ auth TEXT NOT NULL, created_at INTEGER NOT NULL
52
+ );
53
+ CREATE TABLE IF NOT EXISTS topics (
54
+ id TEXT PRIMARY KEY, session_name TEXT NOT NULL, title TEXT NOT NULL,
55
+ created_at INTEGER NOT NULL, updated_at INTEGER NOT NULL
56
+ );
57
+ CREATE TABLE IF NOT EXISTS runs (
58
+ id TEXT PRIMARY KEY, topic_id TEXT REFERENCES topics(id), session_name TEXT NOT NULL,
59
+ tab_id INTEGER, command TEXT, exit_code INTEGER, started_at INTEGER NOT NULL,
60
+ ended_at INTEGER, status TEXT DEFAULT 'running'
61
+ );
62
+ CREATE TABLE IF NOT EXISTS artifacts (
63
+ id TEXT PRIMARY KEY, run_id TEXT REFERENCES runs(id), topic_id TEXT REFERENCES topics(id),
64
+ session_name TEXT, type TEXT NOT NULL, title TEXT, content TEXT, created_at INTEGER NOT NULL
65
+ );
66
+ CREATE TABLE IF NOT EXISTS approvals (
67
+ id TEXT PRIMARY KEY, run_id TEXT REFERENCES runs(id), topic_id TEXT REFERENCES topics(id),
68
+ title TEXT NOT NULL, description TEXT, status TEXT DEFAULT 'pending',
69
+ created_at INTEGER NOT NULL, resolved_at INTEGER
70
+ );
71
+ CREATE VIRTUAL TABLE IF NOT EXISTS fts_index USING fts5(
72
+ entity_type, entity_id, title, content, tokenize='porter unicode61'
73
+ );
74
+ CREATE TABLE IF NOT EXISTS memory_notes (
75
+ id TEXT PRIMARY KEY, content TEXT NOT NULL, pinned INTEGER DEFAULT 0,
76
+ created_at INTEGER NOT NULL, updated_at INTEGER NOT NULL, session_name TEXT
77
+ );
78
+ CREATE TABLE IF NOT EXISTS commands (
79
+ id TEXT PRIMARY KEY, session_name TEXT NOT NULL, tab_id INTEGER NOT NULL,
80
+ command TEXT, exit_code INTEGER, cwd TEXT, started_at INTEGER NOT NULL, ended_at INTEGER
81
+ );
82
+ `);
83
+ return db;
84
+ }
85
+
86
+ // ── RED LINE 1: Notes CRUD round-trip ──────────────────────────────
87
+
88
+ describe("workspace notes: create and list round-trip", () => {
89
+ let db;
90
+ beforeEach(() => { db = createTestDb(); _resetDbForTest(db); });
91
+ afterEach(() => { closeDb(); });
92
+
93
+ it("createNote returns a note and listNotes returns it", () => {
94
+ const note = createNote("test note content");
95
+ expect(note).toBeDefined();
96
+ expect(note.id).toBeTruthy();
97
+ expect(note.content).toBe("test note content");
98
+ expect(note.pinned).toBe(false);
99
+
100
+ const notes = listNotes();
101
+ expect(notes.length).toBe(1);
102
+ expect(notes[0].id).toBe(note.id);
103
+ expect(notes[0].content).toBe("test note content");
104
+ });
105
+
106
+ it("multiple notes are listed and all present", () => {
107
+ createNote("first");
108
+ createNote("second");
109
+ createNote("third");
110
+
111
+ const notes = listNotes();
112
+ expect(notes.length).toBe(3);
113
+ const contents = notes.map(n => n.content);
114
+ expect(contents).toContain("first");
115
+ expect(contents).toContain("second");
116
+ expect(contents).toContain("third");
117
+ });
118
+ });
119
+
120
+ // ── RED LINE 2: Topics CRUD round-trip ─────────────────────────────
121
+
122
+ describe("workspace topics: create and list round-trip", () => {
123
+ let db;
124
+ beforeEach(() => { db = createTestDb(); _resetDbForTest(db); });
125
+ afterEach(() => { closeDb(); });
126
+
127
+ it("createTopic returns a topic and listTopics returns it", () => {
128
+ const topic = createTopic("test-session", "Test Topic");
129
+ expect(topic).toBeDefined();
130
+ expect(topic.id).toBeTruthy();
131
+ expect(topic.title).toBe("Test Topic");
132
+
133
+ const topics = listTopics("test-session");
134
+ expect(topics.length).toBe(1);
135
+ expect(topics[0].id).toBe(topic.id);
136
+ expect(topics[0].title).toBe("Test Topic");
137
+ });
138
+ });
139
+
140
+ // ── RED LINE 3: Devices CRUD round-trip ────────────────────────────
141
+
142
+ describe("devices: create and list round-trip", () => {
143
+ let db;
144
+ beforeEach(() => { db = createTestDb(); _resetDbForTest(db); });
145
+ afterEach(() => { closeDb(); });
146
+
147
+ it("createDevice and listDevices round-trip", () => {
148
+ // createDevice(fingerprint, trust, name)
149
+ const device = createDevice("fp-abc123", "trusted", "Test Device");
150
+ expect(device).toBeDefined();
151
+ expect(device.name).toBe("Test Device");
152
+
153
+ const devices = listDevices();
154
+ expect(devices.length).toBe(1);
155
+ expect(devices[0].name).toBe("Test Device");
156
+ expect(devices[0].trust).toBe("trusted");
157
+ });
158
+ });
159
+
160
+ // ── RED LINE 4: ANSI cleanup strips form-feed ──────────────────────
161
+
162
+ describe("inspect ANSI cleanup", () => {
163
+ it("form-feed character (0x0c) is stripped from inspect output", () => {
164
+ // Simulate the ANSI stripping regex from ws-handler inspect fallback
165
+ const rawText = "\x0cwangyaoshen@mac ~ % echo hello\nhello\nwangyaoshen@mac ~ % ";
166
+ const cleaned = rawText
167
+ .replace(/\x1b\[[0-9;?]*[A-Za-z]/g, "")
168
+ .replace(/\x1b\][^\x07\x1b]*(?:\x07|\x1b\\)/g, "")
169
+ .replace(/\x1b[PX^_][^\x1b]*\x1b\\/g, "")
170
+ .replace(/\x1b[()][A-Z0-9]/g, "")
171
+ .replace(/\x1b[A-Z=><78]/gi, "")
172
+ .replace(/[\x00-\x08\x0b\x0c\x0e-\x1f]/g, "")
173
+ .replace(/\x7f/g, "");
174
+
175
+ expect(cleaned).not.toContain("\x0c");
176
+ expect(cleaned).toContain("echo hello");
177
+ expect(cleaned).toContain("hello");
178
+ });
179
+ });
180
+
181
+ // ── RED LINE 5: Client message routing logic ───────────────────────
182
+
183
+ describe("client-side message routing", () => {
184
+ // Simulate the client's ws.onmessage routing logic
185
+ function routeMessage(data) {
186
+ const result = { writtenToTerm: false, handled: false, warned: false };
187
+
188
+ if (typeof data === "string" && data[0] === "{") {
189
+ try {
190
+ const parsed = JSON.parse(data);
191
+ const msg = parsed.v === 1 ? { type: parsed.type, ...parsed.payload } : parsed;
192
+
193
+ if (msg.type === "ping") { result.handled = true; return result; }
194
+ if (msg.type === "state") { result.handled = true; return result; }
195
+ if (msg.type === "attached") { result.handled = true; return result; }
196
+ if (msg.type === "note_list") { result.handled = true; return result; }
197
+ if (msg.type === "topic_list") { result.handled = true; return result; }
198
+ if (msg.type === "device_list") { result.handled = true; return result; }
199
+ // ... other known types would go here ...
200
+
201
+ // Unrecognized enveloped message — block from terminal
202
+ if (parsed.v === 1) {
203
+ result.warned = true;
204
+ return result;
205
+ }
206
+ // Non-enveloped JSON — falls through to term.write (could be PTY output)
207
+ } catch {}
208
+ }
209
+
210
+ result.writtenToTerm = true;
211
+ return result;
212
+ }
213
+
214
+ it("enveloped ping is handled, not written to terminal", () => {
215
+ const r = routeMessage('{"v":1,"type":"ping","payload":{}}');
216
+ expect(r.handled).toBe(true);
217
+ expect(r.writtenToTerm).toBe(false);
218
+ });
219
+
220
+ it("enveloped note_list is handled, not written to terminal", () => {
221
+ const r = routeMessage('{"v":1,"type":"note_list","payload":{"notes":[]}}');
222
+ expect(r.handled).toBe(true);
223
+ expect(r.writtenToTerm).toBe(false);
224
+ });
225
+
226
+ it("unrecognized enveloped message is warned, not written to terminal", () => {
227
+ const r = routeMessage('{"v":1,"type":"unknown_future_type","payload":{}}');
228
+ expect(r.warned).toBe(true);
229
+ expect(r.writtenToTerm).toBe(false);
230
+ });
231
+
232
+ it("non-enveloped JSON (PTY echo) is written to terminal", () => {
233
+ // User ran: echo '{"type":"foo"}'
234
+ const r = routeMessage('{"type":"foo"}');
235
+ expect(r.writtenToTerm).toBe(true);
236
+ });
237
+
238
+ it("plain terminal text is written to terminal", () => {
239
+ const r = routeMessage("wangyaoshen@mac ~ % ");
240
+ expect(r.writtenToTerm).toBe(true);
241
+ });
242
+
243
+ it("non-enveloped control-type JSON from PTY scrollback is written to terminal (regression risk)", () => {
244
+ // This is the known issue: if PTY scrollback contains {"type":"list_devices"},
245
+ // it will be written to terminal because it's not enveloped (v:1).
246
+ // The CLIENT cannot distinguish this from legitimate PTY output like
247
+ // echo '{"type":"list_devices"}'. The fix must be SERVER-side (prevent
248
+ // control messages from ever reaching PTY).
249
+ const r = routeMessage('{"type":"list_devices"}');
250
+ expect(r.writtenToTerm).toBe(true); // Expected: falls through (non-enveloped)
251
+ });
252
+ });
253
+
254
+ // ── RED LINE 6: Server-side JSON routing never reaches PTY ─────────
255
+
256
+ describe("server-side JSON routing", () => {
257
+ // Simulate the server's ws.on("message") routing logic
258
+ function routeServerMessage(msg) {
259
+ const result = { handledAsControl: false, writtenToPty: false };
260
+
261
+ if (msg.startsWith("{")) {
262
+ try {
263
+ JSON.parse(msg); // Parse to validate
264
+ // In real code: handlers for each type + return at line 991
265
+ result.handledAsControl = true;
266
+ return result; // The return at line 991 prevents PTY write
267
+ } catch {
268
+ // not valid JSON — fall through to PTY
269
+ }
270
+ }
271
+
272
+ // Raw terminal input → PTY
273
+ result.writtenToPty = true;
274
+ return result;
275
+ }
276
+
277
+ it("list_topics JSON is handled as control, not written to PTY", () => {
278
+ const r = routeServerMessage('{"type":"list_topics","sessionName":"main"}');
279
+ expect(r.handledAsControl).toBe(true);
280
+ expect(r.writtenToPty).toBe(false);
281
+ });
282
+
283
+ it("list_devices JSON is handled as control, not written to PTY", () => {
284
+ const r = routeServerMessage('{"type":"list_devices"}');
285
+ expect(r.handledAsControl).toBe(true);
286
+ expect(r.writtenToPty).toBe(false);
287
+ });
288
+
289
+ it("create_note JSON is handled as control, not written to PTY", () => {
290
+ const r = routeServerMessage('{"type":"create_note","content":"test"}');
291
+ expect(r.handledAsControl).toBe(true);
292
+ expect(r.writtenToPty).toBe(false);
293
+ });
294
+
295
+ it("plain text is written to PTY", () => {
296
+ const r = routeServerMessage("hello world");
297
+ expect(r.writtenToPty).toBe(true);
298
+ expect(r.handledAsControl).toBe(false);
299
+ });
300
+
301
+ it("invalid JSON starting with { falls through to PTY", () => {
302
+ const r = routeServerMessage("{not valid json");
303
+ expect(r.writtenToPty).toBe(true);
304
+ });
305
+ });
@@ -0,0 +1,346 @@
1
+ /**
2
+ * Tests for PTY daemon TLV protocol, frame codec, and daemon lifecycle.
3
+ */
4
+ import { describe, it, expect, beforeAll, afterAll, afterEach } from "vitest";
5
+ import { spawn } from "child_process";
6
+ import net from "net";
7
+ import fs from "fs";
8
+ import path from "path";
9
+ import { fileURLToPath } from "url";
10
+
11
+ const __dirname = path.dirname(fileURLToPath(import.meta.url));
12
+ const DAEMON_SCRIPT = path.join(__dirname, "..", "pty-daemon.js");
13
+
14
+ // TLV tags (mirrored from pty-daemon.ts)
15
+ const TAG_PTY_OUTPUT = 0x01;
16
+ const TAG_CLIENT_INPUT = 0x02;
17
+ const TAG_RESIZE = 0x03;
18
+ const TAG_STATUS_REQ = 0x04;
19
+ const TAG_STATUS_RES = 0x05;
20
+ const TAG_SNAPSHOT_REQ = 0x06;
21
+ const TAG_SNAPSHOT_RES = 0x07;
22
+ const TAG_SHUTDOWN = 0xff;
23
+
24
+ function encodeFrame(tag, payload) {
25
+ const data =
26
+ typeof payload === "string" ? Buffer.from(payload, "utf8") : payload;
27
+ const frame = Buffer.alloc(5 + data.length);
28
+ frame[0] = tag;
29
+ frame.writeUInt32BE(data.length, 1);
30
+ data.copy(frame, 5);
31
+ return frame;
32
+ }
33
+
34
+ function parseFrames(buf) {
35
+ const frames = [];
36
+ let offset = 0;
37
+ while (offset + 5 <= buf.length) {
38
+ const tag = buf[offset];
39
+ const length = buf.readUInt32BE(offset + 1);
40
+ if (offset + 5 + length > buf.length) break;
41
+ const payload = buf.subarray(offset + 5, offset + 5 + length);
42
+ frames.push({ tag, payload });
43
+ offset += 5 + length;
44
+ }
45
+ return frames;
46
+ }
47
+
48
+ function waitForSocket(socketPath, timeout = 5000) {
49
+ return new Promise((resolve, reject) => {
50
+ const start = Date.now();
51
+ const check = () => {
52
+ if (fs.existsSync(socketPath)) {
53
+ resolve();
54
+ return;
55
+ }
56
+ if (Date.now() - start > timeout) {
57
+ reject(new Error(`Socket ${socketPath} not created within ${timeout}ms`));
58
+ return;
59
+ }
60
+ setTimeout(check, 100);
61
+ };
62
+ check();
63
+ });
64
+ }
65
+
66
+ function connectToDaemon(socketPath) {
67
+ return new Promise((resolve, reject) => {
68
+ const client = net.createConnection(socketPath, () => resolve(client));
69
+ client.on("error", reject);
70
+ });
71
+ }
72
+
73
+ function collectFrames(client, timeout = 2000) {
74
+ return new Promise((resolve) => {
75
+ const chunks = [];
76
+ const handler = (data) => chunks.push(data);
77
+ client.on("data", handler);
78
+ setTimeout(() => {
79
+ client.removeListener("data", handler);
80
+ const all = Buffer.concat(chunks);
81
+ resolve(parseFrames(all));
82
+ }, timeout);
83
+ });
84
+ }
85
+
86
+ function sendAndReceive(client, frame, timeout = 2000) {
87
+ return new Promise((resolve) => {
88
+ const chunks = [];
89
+ const handler = (data) => chunks.push(data);
90
+ client.on("data", handler);
91
+ client.write(frame);
92
+ setTimeout(() => {
93
+ client.removeListener("data", handler);
94
+ const all = Buffer.concat(chunks);
95
+ resolve(parseFrames(all));
96
+ }, timeout);
97
+ });
98
+ }
99
+
100
+ describe("TLV Frame Codec", () => {
101
+ it("encodes and decodes a simple frame", () => {
102
+ const frame = encodeFrame(TAG_PTY_OUTPUT, "hello");
103
+ expect(frame.length).toBe(5 + 5); // 5 header + 5 payload
104
+ expect(frame[0]).toBe(TAG_PTY_OUTPUT);
105
+ expect(frame.readUInt32BE(1)).toBe(5);
106
+ expect(frame.subarray(5).toString()).toBe("hello");
107
+
108
+ const parsed = parseFrames(frame);
109
+ expect(parsed).toHaveLength(1);
110
+ expect(parsed[0].tag).toBe(TAG_PTY_OUTPUT);
111
+ expect(parsed[0].payload.toString()).toBe("hello");
112
+ });
113
+
114
+ it("encodes empty payload", () => {
115
+ const frame = encodeFrame(TAG_STATUS_REQ, Buffer.alloc(0));
116
+ expect(frame.length).toBe(5);
117
+ expect(frame.readUInt32BE(1)).toBe(0);
118
+ });
119
+
120
+ it("parses multiple frames in a single buffer", () => {
121
+ const f1 = encodeFrame(TAG_PTY_OUTPUT, "first");
122
+ const f2 = encodeFrame(TAG_CLIENT_INPUT, "second");
123
+ const combined = Buffer.concat([f1, f2]);
124
+ const parsed = parseFrames(combined);
125
+ expect(parsed).toHaveLength(2);
126
+ expect(parsed[0].tag).toBe(TAG_PTY_OUTPUT);
127
+ expect(parsed[1].tag).toBe(TAG_CLIENT_INPUT);
128
+ expect(parsed[1].payload.toString()).toBe("second");
129
+ });
130
+
131
+ it("handles incomplete frame gracefully", () => {
132
+ const frame = encodeFrame(TAG_PTY_OUTPUT, "hello");
133
+ // Truncate the frame
134
+ const partial = frame.subarray(0, 7);
135
+ const parsed = parseFrames(partial);
136
+ expect(parsed).toHaveLength(0);
137
+ });
138
+ });
139
+
140
+ describe("PTY Daemon Lifecycle", () => {
141
+ let daemon;
142
+ let socketPath;
143
+
144
+ beforeAll(() => {
145
+ socketPath = `/tmp/remux-test-daemon-${process.pid}-${Date.now()}.sock`;
146
+ });
147
+
148
+ afterEach(() => {
149
+ if (daemon && !daemon.killed) {
150
+ daemon.kill("SIGTERM");
151
+ daemon = null;
152
+ }
153
+ try {
154
+ fs.unlinkSync(socketPath);
155
+ } catch {}
156
+ });
157
+
158
+ it("starts daemon and accepts connection", async () => {
159
+ daemon = spawn(process.execPath, [
160
+ DAEMON_SCRIPT,
161
+ "--socket", socketPath,
162
+ "--shell", "/bin/sh",
163
+ "--cols", "80",
164
+ "--rows", "24",
165
+ "--cwd", "/tmp",
166
+ "--tab-id", "test-1",
167
+ ], { stdio: "pipe" });
168
+
169
+ await waitForSocket(socketPath);
170
+ const client = await connectToDaemon(socketPath);
171
+
172
+ // Send a command to provoke output
173
+ client.write(encodeFrame(TAG_CLIENT_INPUT, "echo DAEMON_ALIVE\n"));
174
+ const frames = await collectFrames(client, 2000);
175
+
176
+ // Should have received PTY output
177
+ const outputText = frames
178
+ .filter((f) => f.tag === TAG_PTY_OUTPUT)
179
+ .map((f) => f.payload.toString())
180
+ .join("");
181
+ expect(outputText).toContain("DAEMON_ALIVE");
182
+
183
+ client.end();
184
+ }, 10000);
185
+
186
+ it("responds to status request", async () => {
187
+ daemon = spawn(process.execPath, [
188
+ DAEMON_SCRIPT,
189
+ "--socket", socketPath,
190
+ "--shell", "/bin/sh",
191
+ "--cols", "100",
192
+ "--rows", "30",
193
+ "--cwd", "/tmp",
194
+ "--tab-id", "test-2",
195
+ ], { stdio: "pipe" });
196
+
197
+ await waitForSocket(socketPath);
198
+ const client = await connectToDaemon(socketPath);
199
+ // Wait for initial output
200
+ await new Promise((r) => setTimeout(r, 500));
201
+
202
+ const frames = await sendAndReceive(
203
+ client,
204
+ encodeFrame(TAG_STATUS_REQ, Buffer.alloc(0)),
205
+ 1500,
206
+ );
207
+
208
+ const statusFrame = frames.find((f) => f.tag === TAG_STATUS_RES);
209
+ expect(statusFrame).toBeDefined();
210
+
211
+ const status = JSON.parse(statusFrame.payload.toString());
212
+ expect(status.alive).toBe(true);
213
+ expect(status.tabId).toBe("test-2");
214
+ expect(typeof status.pid).toBe("number");
215
+ expect(status.pid).toBeGreaterThan(0);
216
+
217
+ client.end();
218
+ }, 10000);
219
+
220
+ it("forwards input to PTY and receives output", async () => {
221
+ daemon = spawn(process.execPath, [
222
+ DAEMON_SCRIPT,
223
+ "--socket", socketPath,
224
+ "--shell", "/bin/sh",
225
+ "--cols", "80",
226
+ "--rows", "24",
227
+ "--cwd", "/tmp",
228
+ "--tab-id", "test-3",
229
+ ], { stdio: "pipe" });
230
+
231
+ await waitForSocket(socketPath);
232
+ const client = await connectToDaemon(socketPath);
233
+ await new Promise((r) => setTimeout(r, 500));
234
+
235
+ // Send a command
236
+ client.write(encodeFrame(TAG_CLIENT_INPUT, "echo REMUX_TEST_OK\n"));
237
+ const frames = await collectFrames(client, 2000);
238
+
239
+ // Should have received output containing our echo
240
+ const outputText = frames
241
+ .filter((f) => f.tag === TAG_PTY_OUTPUT)
242
+ .map((f) => f.payload.toString())
243
+ .join("");
244
+
245
+ expect(outputText).toContain("REMUX_TEST_OK");
246
+
247
+ client.end();
248
+ }, 10000);
249
+
250
+ it("returns scrollback on snapshot request", async () => {
251
+ daemon = spawn(process.execPath, [
252
+ DAEMON_SCRIPT,
253
+ "--socket", socketPath,
254
+ "--shell", "/bin/sh",
255
+ "--cols", "80",
256
+ "--rows", "24",
257
+ "--cwd", "/tmp",
258
+ "--tab-id", "test-4",
259
+ ], { stdio: "pipe" });
260
+
261
+ await waitForSocket(socketPath);
262
+ const client = await connectToDaemon(socketPath);
263
+ await new Promise((r) => setTimeout(r, 500));
264
+
265
+ // Write something to terminal
266
+ client.write(encodeFrame(TAG_CLIENT_INPUT, "echo SNAPSHOT_TEST\n"));
267
+ await new Promise((r) => setTimeout(r, 1000));
268
+
269
+ // Request snapshot
270
+ const frames = await sendAndReceive(
271
+ client,
272
+ encodeFrame(TAG_SNAPSHOT_REQ, Buffer.alloc(0)),
273
+ 1500,
274
+ );
275
+
276
+ const snapshotFrame = frames.find((f) => f.tag === TAG_SNAPSHOT_RES);
277
+ expect(snapshotFrame).toBeDefined();
278
+ expect(snapshotFrame.payload.toString()).toContain("SNAPSHOT_TEST");
279
+
280
+ client.end();
281
+ }, 10000);
282
+
283
+ it("shuts down on TAG_SHUTDOWN", async () => {
284
+ daemon = spawn(process.execPath, [
285
+ DAEMON_SCRIPT,
286
+ "--socket", socketPath,
287
+ "--shell", "/bin/sh",
288
+ "--cols", "80",
289
+ "--rows", "24",
290
+ "--cwd", "/tmp",
291
+ "--tab-id", "test-5",
292
+ ], { stdio: "pipe" });
293
+
294
+ await waitForSocket(socketPath);
295
+ const client = await connectToDaemon(socketPath);
296
+ await new Promise((r) => setTimeout(r, 500));
297
+
298
+ // Send shutdown
299
+ client.write(encodeFrame(TAG_SHUTDOWN, Buffer.alloc(0)));
300
+
301
+ // Wait for daemon to exit
302
+ await new Promise((resolve) => {
303
+ daemon.on("exit", resolve);
304
+ setTimeout(resolve, 3000); // timeout safety
305
+ });
306
+
307
+ // Socket file should be cleaned up
308
+ await new Promise((r) => setTimeout(r, 500));
309
+ expect(fs.existsSync(socketPath)).toBe(false);
310
+ }, 10000);
311
+
312
+ it("survives client disconnect (daemon stays alive)", async () => {
313
+ daemon = spawn(process.execPath, [
314
+ DAEMON_SCRIPT,
315
+ "--socket", socketPath,
316
+ "--shell", "/bin/sh",
317
+ "--cols", "80",
318
+ "--rows", "24",
319
+ "--cwd", "/tmp",
320
+ "--tab-id", "test-6",
321
+ ], { stdio: "pipe" });
322
+
323
+ await waitForSocket(socketPath);
324
+
325
+ // Connect and disconnect
326
+ const client1 = await connectToDaemon(socketPath);
327
+ await new Promise((r) => setTimeout(r, 500));
328
+ client1.end();
329
+ await new Promise((r) => setTimeout(r, 500));
330
+
331
+ // Daemon should still be alive — reconnect
332
+ const client2 = await connectToDaemon(socketPath);
333
+ const frames = await sendAndReceive(
334
+ client2,
335
+ encodeFrame(TAG_STATUS_REQ, Buffer.alloc(0)),
336
+ 1500,
337
+ );
338
+
339
+ const statusFrame = frames.find((f) => f.tag === TAG_STATUS_RES);
340
+ expect(statusFrame).toBeDefined();
341
+ const status = JSON.parse(statusFrame.payload.toString());
342
+ expect(status.alive).toBe(true);
343
+
344
+ client2.end();
345
+ }, 10000);
346
+ });