@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
package/start.ps1 ADDED
@@ -0,0 +1,83 @@
1
+ #!/usr/bin/env pwsh
2
+ # Start remux.
3
+ #
4
+ # Usage:
5
+ # .\start.ps1 Dev mode (hot reload, no tunnel)
6
+ # .\start.ps1 --prod Production (build + run with tunnel)
7
+ # .\start.ps1 --prod --password mypass Production with custom password
8
+ # .\start.ps1 --dev --tunnel Dev mode with tunnel enabled
9
+
10
+ $ErrorActionPreference = "Stop"
11
+ Set-Location $PSScriptRoot
12
+
13
+ # Parse our flags, pass the rest through to the CLI.
14
+ $isProd = $false
15
+ $passthrough = @()
16
+
17
+ for ($i = 0; $i -lt $args.Count; $i++) {
18
+ if ($args[$i] -eq "--prod") {
19
+ $isProd = $true
20
+ } elseif ($args[$i] -eq "--dev") {
21
+ # explicit dev mode (default anyway)
22
+ } else {
23
+ $passthrough += $args[$i]
24
+ }
25
+ }
26
+
27
+ if ($isProd) {
28
+ # Production mode: always rebuild to ensure dist/ is current.
29
+ Write-Host "Building remux..." -ForegroundColor Yellow
30
+ npm run build
31
+ # Use a stable token so the URL doesn't change on restart.
32
+ if (-not $env:REMUX_TOKEN) {
33
+ $tokenFile = Join-Path (Join-Path $env:USERPROFILE ".remux") "token"
34
+ if (Test-Path $tokenFile) {
35
+ $env:REMUX_TOKEN = (Get-Content $tokenFile -Raw).Trim()
36
+ } else {
37
+ $env:REMUX_TOKEN = -join ((48..57) + (65..90) + (97..122) | Get-Random -Count 24 | ForEach-Object { [char]$_ })
38
+ New-Item -ItemType Directory -Path (Split-Path $tokenFile) -Force | Out-Null
39
+ Set-Content $tokenFile $env:REMUX_TOKEN
40
+ }
41
+ Write-Host " Stable token saved to $tokenFile" -ForegroundColor DarkGray
42
+ }
43
+ Write-Host "Starting remux (production)..." -ForegroundColor Cyan
44
+ node dist/backend/cli-zellij.js @passthrough
45
+ return
46
+ }
47
+
48
+ # Dev mode: tsx watch (hot reload) + Vite frontend.
49
+ # Default: no tunnel, no password. Override with explicit flags.
50
+ $devArgs = @("--no-require-password")
51
+
52
+ # Only add --no-tunnel if user didn't explicitly pass --tunnel.
53
+ $hasTunnel = $passthrough -contains "--tunnel"
54
+ if (-not $hasTunnel) {
55
+ $devArgs += "--no-tunnel"
56
+ }
57
+
58
+ $devArgs += $passthrough
59
+
60
+ Write-Host ""
61
+ Write-Host "Starting remux dev mode..." -ForegroundColor Cyan
62
+ Write-Host " Backend: tsx watch (auto-restart on changes)" -ForegroundColor DarkGray
63
+ Write-Host " Frontend: Vite HMR on http://localhost:5173" -ForegroundColor DarkGray
64
+ Write-Host ""
65
+
66
+ # Start Vite in background.
67
+ $viteJob = Start-Process -FilePath "node" -ArgumentList "node_modules/vite/bin/vite.js","--config","vite.config.ts" `
68
+ -NoNewWindow -PassThru -RedirectStandardOutput "$env:TEMP\remux-vite.log" -RedirectStandardError "$env:TEMP\remux-vite-err.log"
69
+
70
+ Write-Host "[vite] started (pid=$($viteJob.Id))" -ForegroundColor Green
71
+ Start-Sleep 2
72
+
73
+ try {
74
+ Write-Host "[back] starting..." -ForegroundColor Blue
75
+ $env:VITE_DEV_MODE = "1"
76
+ npx tsx watch src/backend/cli-zellij.ts @devArgs
77
+ }
78
+ finally {
79
+ if (-not $viteJob.HasExited) {
80
+ Stop-Process -Id $viteJob.Id -Force -ErrorAction SilentlyContinue
81
+ Write-Host "[vite] stopped" -ForegroundColor Green
82
+ }
83
+ }
@@ -0,0 +1,171 @@
1
+ import { describe, it, expect } from "vitest";
2
+
3
+ // Test the adapter framework (E10)
4
+ // Since adapters are ES modules in the bundle, test the logic patterns
5
+
6
+ describe("AdapterRegistry pattern", () => {
7
+ it("should register and query adapters", () => {
8
+ // Simulate registry behavior
9
+ const adapters = new Map();
10
+
11
+ const genericShell = {
12
+ id: "generic-shell",
13
+ name: "Shell",
14
+ mode: "passive",
15
+ capabilities: ["cwd", "last-command"],
16
+ getCurrentState: () => ({
17
+ adapterId: "generic-shell",
18
+ name: "Shell",
19
+ mode: "passive",
20
+ capabilities: ["cwd", "last-command"],
21
+ currentState: "idle",
22
+ }),
23
+ };
24
+
25
+ adapters.set(genericShell.id, genericShell);
26
+ expect(adapters.get("generic-shell")).toBeDefined();
27
+ expect(adapters.get("generic-shell").name).toBe("Shell");
28
+ });
29
+
30
+ it("should emit events to listeners", () => {
31
+ const listeners = [];
32
+ const events = [];
33
+
34
+ listeners.push((event) => events.push(event));
35
+
36
+ // Simulate emit
37
+ const event = {
38
+ type: "state_change",
39
+ seq: 1,
40
+ timestamp: new Date().toISOString(),
41
+ data: { state: "running" },
42
+ adapterId: "claude-code",
43
+ };
44
+
45
+ for (const listener of listeners) {
46
+ listener(event);
47
+ }
48
+
49
+ expect(events.length).toBe(1);
50
+ expect(events[0].adapterId).toBe("claude-code");
51
+ expect(events[0].data.state).toBe("running");
52
+ });
53
+
54
+ it("should handle adapter errors without crashing", () => {
55
+ const errorAdapter = {
56
+ id: "broken",
57
+ mode: "passive",
58
+ onTerminalData: () => {
59
+ throw new Error("adapter crash");
60
+ },
61
+ };
62
+
63
+ // Should not throw
64
+ expect(() => {
65
+ try {
66
+ errorAdapter.onTerminalData("test", "data");
67
+ } catch {
68
+ // Registry catches this
69
+ }
70
+ }).not.toThrow();
71
+ });
72
+ });
73
+
74
+ describe("OSC notification parsing", () => {
75
+ it("should parse OSC 9 notifications", () => {
76
+ const data = 'before\x1b]9;Build complete\x07after';
77
+ const osc9Re = /\x1b\]9;([^\x07\x1b]+)[\x07]/;
78
+ const match = data.match(osc9Re);
79
+ expect(match).not.toBeNull();
80
+ expect(match[1]).toBe("Build complete");
81
+ });
82
+
83
+ it("should parse OSC 777 notifications", () => {
84
+ const data = '\x1b]777;notify;Build;All tests passed\x07';
85
+ const osc777Re = /\x1b\]777;notify;([^;]*);([^\x07\x1b]*)[\x07]/;
86
+ const match = data.match(osc777Re);
87
+ expect(match).not.toBeNull();
88
+ expect(match[1]).toBe("Build");
89
+ expect(match[2]).toBe("All tests passed");
90
+ });
91
+
92
+ it("should handle data without OSC sequences", () => {
93
+ const data = "normal terminal output";
94
+ const osc9Re = /\x1b\]9;([^\x07\x1b]+)[\x07]/;
95
+ expect(data.match(osc9Re)).toBeNull();
96
+ });
97
+ });
98
+
99
+ describe("Generic shell adapter patterns", () => {
100
+ it("should detect OSC 7 working directory", () => {
101
+ const data = "\x1b]7;file://hostname/Users/test\x07";
102
+ const osc7Re = /\x1b\]7;file:\/\/[^/]*([^\x07\x1b]+)/;
103
+ const match = data.match(osc7Re);
104
+ expect(match).not.toBeNull();
105
+ expect(decodeURIComponent(match[1])).toBe("/Users/test");
106
+ });
107
+
108
+ it("should detect OSC 133 command boundaries", () => {
109
+ const promptStart = "\x1b]133;A\x07";
110
+ const commandStart = "\x1b]133;B\x07";
111
+ const outputStart = "\x1b]133;C\x07";
112
+ const commandEnd = "\x1b]133;D;0\x07";
113
+
114
+ expect(commandStart.match(/\x1b\]133;B\x07/)).not.toBeNull();
115
+ expect(commandEnd.match(/\x1b\]133;D;?(\d*)\x07/)).not.toBeNull();
116
+ expect(commandEnd.match(/\x1b\]133;D;?(\d*)\x07/)[1]).toBe("0");
117
+ });
118
+ });
119
+
120
+ describe("Git service patterns", () => {
121
+ it("should parse worktree porcelain output", () => {
122
+ const output = `worktree /Users/test/remux
123
+ HEAD abc1234
124
+ branch refs/heads/main
125
+
126
+ worktree /Users/test/remux/.worktrees/feature
127
+ HEAD def5678
128
+ branch refs/heads/feat/new
129
+ `;
130
+
131
+ const worktrees = [];
132
+ let current = {};
133
+
134
+ for (const line of output.split("\n")) {
135
+ if (line.startsWith("worktree ")) {
136
+ if (current.path) worktrees.push({ ...current });
137
+ current = { path: line.replace("worktree ", "") };
138
+ } else if (line.startsWith("HEAD ")) {
139
+ current.head = line.replace("HEAD ", "").substring(0, 7);
140
+ } else if (line.startsWith("branch ")) {
141
+ current.branch = line.replace("branch refs/heads/", "");
142
+ }
143
+ }
144
+ if (current.path) worktrees.push({ ...current });
145
+
146
+ expect(worktrees.length).toBe(2);
147
+ expect(worktrees[0].branch).toBe("main");
148
+ expect(worktrees[1].branch).toBe("feat/new");
149
+ expect(worktrees[1].head).toBe("def5678");
150
+ });
151
+ });
152
+
153
+ describe("Team mode patterns", () => {
154
+ it("should validate RBAC permissions", () => {
155
+ const ROLE_PERMISSIONS = {
156
+ owner: ["read", "write", "admin", "approve"],
157
+ admin: ["read", "write", "admin", "approve"],
158
+ member: ["read", "write", "approve"],
159
+ viewer: ["read"],
160
+ };
161
+
162
+ const hasPermission = (role, perm) =>
163
+ ROLE_PERMISSIONS[role]?.includes(perm) ?? false;
164
+
165
+ expect(hasPermission("owner", "admin")).toBe(true);
166
+ expect(hasPermission("viewer", "write")).toBe(false);
167
+ expect(hasPermission("member", "read")).toBe(true);
168
+ expect(hasPermission("member", "admin")).toBe(false);
169
+ expect(hasPermission("unknown", "read")).toBe(false);
170
+ });
171
+ });
@@ -0,0 +1,243 @@
1
+ /**
2
+ * Auth tests for Remux server.
3
+ * Tests: auto-generated token, password authentication, token validation.
4
+ */
5
+
6
+ import { describe, it, expect, beforeAll, afterAll } from "vitest";
7
+ import { spawn } from "child_process";
8
+ import http from "http";
9
+ import WebSocket from "ws";
10
+ import path from "path";
11
+ import { fileURLToPath } from "url";
12
+
13
+ const __dirname = path.dirname(fileURLToPath(import.meta.url));
14
+ const SERVER_JS = path.join(__dirname, "..", "server.js");
15
+
16
+ function httpGet(port, urlPath) {
17
+ return new Promise((resolve, reject) => {
18
+ http
19
+ .get(`http://localhost:${port}${urlPath}`, (res) => {
20
+ let body = "";
21
+ res.on("data", (d) => (body += d));
22
+ res.on("end", () =>
23
+ resolve({ status: res.statusCode, body, headers: res.headers }),
24
+ );
25
+ })
26
+ .on("error", reject);
27
+ });
28
+ }
29
+
30
+ function httpPost(port, urlPath, data) {
31
+ return new Promise((resolve, reject) => {
32
+ const body = new URLSearchParams(data).toString();
33
+ const req = http.request(
34
+ {
35
+ hostname: "localhost",
36
+ port,
37
+ path: urlPath,
38
+ method: "POST",
39
+ headers: {
40
+ "Content-Type": "application/x-www-form-urlencoded",
41
+ "Content-Length": Buffer.byteLength(body),
42
+ },
43
+ },
44
+ (res) => {
45
+ let resBody = "";
46
+ res.on("data", (d) => (resBody += d));
47
+ res.on("end", () =>
48
+ resolve({ status: res.statusCode, body: resBody, headers: res.headers }),
49
+ );
50
+ },
51
+ );
52
+ req.on("error", reject);
53
+ req.write(body);
54
+ req.end();
55
+ });
56
+ }
57
+
58
+ function connectWs(port) {
59
+ return new Promise((resolve, reject) => {
60
+ const ws = new WebSocket(`ws://localhost:${port}/ws`);
61
+ ws.on("open", () => resolve(ws));
62
+ ws.on("error", reject);
63
+ });
64
+ }
65
+
66
+ /** Unwrap envelope if present. */
67
+ function unwrap(parsed) {
68
+ if (parsed && parsed.v === 1 && typeof parsed.type === "string") {
69
+ return { type: parsed.type, ...(parsed.payload || {}) };
70
+ }
71
+ return parsed;
72
+ }
73
+
74
+ function waitForMsg(ws, type, timeout = 3000) {
75
+ return new Promise((resolve, reject) => {
76
+ const timer = setTimeout(() => {
77
+ ws.removeListener("message", handler);
78
+ reject(new Error(`timeout waiting for ${type}`));
79
+ }, timeout);
80
+ const handler = (raw) => {
81
+ try {
82
+ const msg = unwrap(JSON.parse(raw.toString()));
83
+ if (msg.type === type) {
84
+ clearTimeout(timer);
85
+ ws.removeListener("message", handler);
86
+ resolve(msg);
87
+ }
88
+ } catch {}
89
+ };
90
+ ws.on("message", handler);
91
+ });
92
+ }
93
+
94
+ /** Start a server instance and wait for "Remux running" output.
95
+ * Returns { proc, port, stdout } where stdout contains all console output.
96
+ * Explicitly removes REMUX_TOKEN and REMUX_PASSWORD from parent env to avoid leaking. */
97
+ function startServer(env, port) {
98
+ return new Promise((resolve, reject) => {
99
+ let stdout = "";
100
+ const cleanEnv = { ...process.env };
101
+ delete cleanEnv.REMUX_TOKEN;
102
+ delete cleanEnv.REMUX_PASSWORD;
103
+ const proc = spawn("node", [SERVER_JS], {
104
+ env: { ...cleanEnv, ...env, PORT: String(port) },
105
+ stdio: "pipe",
106
+ });
107
+ const timeout = setTimeout(
108
+ () => reject(new Error("server start timeout")),
109
+ 10000,
110
+ );
111
+ proc.stdout.on("data", (d) => {
112
+ stdout += d.toString();
113
+ if (stdout.includes("Remux running")) {
114
+ clearTimeout(timeout);
115
+ // Extra delay for WASM init
116
+ setTimeout(() => resolve({ proc, port, stdout }), 2000);
117
+ }
118
+ });
119
+ proc.stderr.on("data", (d) => {
120
+ stdout += d.toString();
121
+ });
122
+ proc.on("error", reject);
123
+ });
124
+ }
125
+
126
+ // ── Auto-generated token ─────────────────────────────────────────
127
+
128
+ describe("auto-generated token (no REMUX_TOKEN, no REMUX_PASSWORD)", () => {
129
+ let server;
130
+
131
+ beforeAll(async () => {
132
+ server = await startServer(
133
+ { REMUX_INSTANCE_ID: "auth-test-auto-" + Date.now() },
134
+ 19877,
135
+ );
136
+ }, 15000);
137
+
138
+ afterAll(() => server?.proc?.kill("SIGTERM"));
139
+
140
+ it("prints full URL with auto-generated token on startup", () => {
141
+ expect(server.stdout).toMatch(/http:\/\/localhost:\d+\?token=[a-f0-9]{32}/);
142
+ });
143
+
144
+ it("rejects access without token", async () => {
145
+ const res = await httpGet(server.port, "/");
146
+ expect(res.status).toBe(403);
147
+ });
148
+
149
+ it("accepts access with auto-generated token from startup output", async () => {
150
+ const match = server.stdout.match(/token=([a-f0-9]{32})/);
151
+ expect(match).not.toBeNull();
152
+ const token = match[1];
153
+
154
+ const res = await httpGet(server.port, `/?token=${token}`);
155
+ expect(res.status).toBe(200);
156
+ expect(res.body).toContain("<title>Remux</title>");
157
+ });
158
+
159
+ it("authenticates WebSocket with auto-generated token", async () => {
160
+ const match = server.stdout.match(/token=([a-f0-9]{32})/);
161
+ const token = match[1];
162
+
163
+ const ws = await connectWs(server.port);
164
+ ws.send(JSON.stringify({ type: "auth", token }));
165
+ const msg = await waitForMsg(ws, "auth_ok");
166
+ expect(msg.type).toBe("auth_ok");
167
+ ws.close();
168
+ });
169
+ });
170
+
171
+ // ── Password authentication ──────────────────────────────────────
172
+
173
+ describe("password authentication (REMUX_PASSWORD set)", () => {
174
+ let server;
175
+ const TEST_PASSWORD = "test-secret-pw-" + Date.now();
176
+
177
+ beforeAll(async () => {
178
+ server = await startServer(
179
+ {
180
+ REMUX_PASSWORD: TEST_PASSWORD,
181
+ REMUX_INSTANCE_ID: "auth-test-pw-" + Date.now(),
182
+ },
183
+ 19878,
184
+ );
185
+ }, 15000);
186
+
187
+ afterAll(() => server?.proc?.kill("SIGTERM"));
188
+
189
+ it("shows password page when accessing root without token", async () => {
190
+ const res = await httpGet(server.port, "/");
191
+ expect(res.status).toBe(200);
192
+ expect(res.body).toContain("Remux — Login");
193
+ expect(res.body).toContain('type="password"');
194
+ expect(res.body).toContain('action="/auth"');
195
+ });
196
+
197
+ it("rejects wrong password and redirects with error", async () => {
198
+ const res = await httpPost(server.port, "/auth", {
199
+ password: "wrong-password",
200
+ });
201
+ expect(res.status).toBe(302);
202
+ expect(res.headers.location).toContain("error=1");
203
+ });
204
+
205
+ it("correct password generates working token and redirects", async () => {
206
+ const res = await httpPost(server.port, "/auth", {
207
+ password: TEST_PASSWORD,
208
+ });
209
+ expect(res.status).toBe(302);
210
+ expect(res.headers.location).toMatch(/\?token=[a-f0-9]{32}/);
211
+
212
+ // Follow the redirect — the token should work
213
+ const tokenMatch = res.headers.location.match(/token=([a-f0-9]{32})/);
214
+ const token = tokenMatch[1];
215
+
216
+ const pageRes = await httpGet(server.port, `/?token=${token}`);
217
+ expect(pageRes.status).toBe(200);
218
+ expect(pageRes.body).toContain("<title>Remux</title>");
219
+ });
220
+
221
+ it("password-generated token works for WebSocket auth", async () => {
222
+ // First get a token via password
223
+ const res = await httpPost(server.port, "/auth", {
224
+ password: TEST_PASSWORD,
225
+ });
226
+ const tokenMatch = res.headers.location.match(/token=([a-f0-9]{32})/);
227
+ const token = tokenMatch[1];
228
+
229
+ const ws = await connectWs(server.port);
230
+ ws.send(JSON.stringify({ type: "auth", token }));
231
+ const msg = await waitForMsg(ws, "auth_ok");
232
+ expect(msg.type).toBe("auth_ok");
233
+ ws.close();
234
+ });
235
+
236
+ it("rejects WebSocket auth with invalid token", async () => {
237
+ const ws = await connectWs(server.port);
238
+ ws.send(JSON.stringify({ type: "auth", token: "invalid-token" }));
239
+ const msg = await waitForMsg(ws, "auth_error");
240
+ expect(msg.reason).toBe("invalid token");
241
+ ws.close();
242
+ });
243
+ });