authorgravatar for julius0216@outlook.comJulius Marminge <julius0216@outlook.com> 2026-05-03 22:19:15-07:00
committergravatar for noreply@github.comGitHub <noreply@github.com> 2026-05-03 22:19:15-07:00
log3772fa127ccfe1de9326067ee6ed91e079456e97
tree17086f4a588a830ba0dc2cad67439c265c3e7a10
parent92e340d80cff793bdb06ed9d6761dfb1bde35c52
signature Signed by PGP key B5690EEEBB952194

feat: Hosted Frontend, Tailscale Integration & SSH Lancher (#2361)

Co-authored-by: codex <codex@users.noreply.github.com>

119 files changed, 12687 insertions(+), 1040 deletions(-)

.docs/remote-architecture.md+80
...@@ -93,6 +93,8 @@ Examples:...@@ -93,6 +93,8 @@ Examples:
9393
94A known environment may or may not know the target `environmentId` before first successful connect.94A known environment may or may not know the target `environmentId` before first successful connect.
9595
96In the hosted web app, known environments are browser-local. A hosted pairing URL can create the saved entry, but it does not give the hosted app a server-side control plane or a copy of the session state.
97
96### AccessEndpoint98### AccessEndpoint
9799
98An `AccessEndpoint` is one concrete way to reach a known environment.100An `AccessEndpoint` is one concrete way to reach a known environment.
...@@ -108,6 +110,67 @@ A single environment may have many endpoints:...@@ -108,6 +110,67 @@ A single environment may have many endpoints:
108110
109The environment stays the same. Only the access path changes.111The environment stays the same. Only the access path changes.
110112
113### AdvertisedEndpoint
114
115An `AdvertisedEndpoint` is a server or desktop-authored candidate endpoint for an environment. It is how the backend tells the client which URLs may be useful for pairing and reconnecting.
116
117`AdvertisedEndpoint` is deliberately narrower than the full access model:
118
119- it describes a concrete HTTP and WebSocket base URL pair
120- it can mark the endpoint as default, available, or unavailable
121- it includes reachability hints such as loopback, LAN, private, public, or tunnel
122- it includes compatibility hints such as whether the endpoint can be used from the hosted HTTPS app
123
124Clients should treat advertised endpoints as hints, not as proof that a route works from the current device. The final connection attempt still decides whether the endpoint is reachable.
125
126The UI presents one default advertised endpoint in the network-access summary and keeps the rest behind an expandable advanced list. The default controls pairing QR codes and primary copy actions. Users can override it, but that override is a UI preference, not backend configuration.
127
128Persist the override by stable endpoint kind rather than raw URL whenever possible. For example, a LAN endpoint should be stored as the desktop LAN endpoint preference, not as `192.168.x.y`, because the address can change when the user switches networks. Provider endpoints should use provider-specific stable keys such as Tailscale IP or Tailscale MagicDNS HTTPS. Custom endpoints may fall back to their concrete identity.
129
130When no user default is saved, endpoint selection should prefer:
131
1321. endpoints compatible with the hosted HTTPS app
1332. explicitly default endpoints
1343. non-loopback endpoints
1354. loopback endpoints only for same-machine clients
136
137This keeps endpoint discovery centralized without making any one provider, such as Tailscale or a future tunnel service, part of the core environment model.
138
139### Endpoint providers
140
141Endpoint providers are add-ons that contribute advertised endpoints for the current environment.
142
143The provider boundary is intentionally outside the core environment model:
144
145- core owns `ExecutionEnvironment`, saved environments, pairing, and connection lifecycle
146- providers discover or synthesize endpoints
147- providers return normalized `AdvertisedEndpoint` records
148- the UI and pairing logic select from those records without knowing provider-specific commands
149
150The first provider is Tailscale. It can discover Tailnet IP and MagicDNS addresses from the local machine and publish them as additional endpoint candidates. Future providers, such as a hosted tunnel service, should plug into the same shape rather than adding a separate remote environment path.
151
152Provider-specific confidence should remain a hint. A Tailscale endpoint still needs a successful browser or desktop connection before the client treats it as connected.
153
154### Hosted pairing request
155
156A hosted pairing request is a bootstrap URL for the static web app, not a transport.
157
158Example:
159
160```text
161https://app.t3.codes/pair?host=https://backend.example.com:3773#token=PAIRCODE
162```
163
164The hosted app reads the `host` parameter and pairing token, exchanges the token directly with that backend, then saves the resulting environment record in browser local storage.
165
166Important constraints:
167
168- the hosted app does not proxy HTTP or WebSocket traffic
169- the backend must still be reachable directly from the browser
170- HTTPS pages can only connect to HTTPS/WSS backends
171- HTTP LAN endpoints should keep using direct desktop or CLI pairing URLs
172- the token belongs in the URL hash so it is not sent to the hosted app origin
173
111### RepositoryIdentity174### RepositoryIdentity
112175
113`RepositoryIdentity` remains a best-effort logical repo grouping mechanism across environments.176`RepositoryIdentity` remains a best-effort logical repo grouping mechanism across environments.
...@@ -151,6 +214,8 @@ Benefits:...@@ -151,6 +214,8 @@ Benefits:
151- no client-specific process management required214- no client-specific process management required
152- best fit for hosted or self-managed remote T3 deployments215- best fit for hosted or self-managed remote T3 deployments
153216
217Browser security rules are part of this access method. A hosted HTTPS web client can connect to `wss://` backends, but it cannot connect to plain `ws://` or `http://` LAN backends because that would be mixed content.
218
154### 2. Tunneled WebSocket access219### 2. Tunneled WebSocket access
155220
156Examples:221Examples:
...@@ -170,6 +235,8 @@ This is especially useful when:...@@ -170,6 +235,8 @@ This is especially useful when:
170- mobile must reach a desktop-hosted environment235- mobile must reach a desktop-hosted environment
171- a machine should be reachable without exposing raw LAN or public ports236- a machine should be reachable without exposing raw LAN or public ports
172237
238Tailscale-backed access sits here architecturally even though the current implementation is endpoint discovery rather than a T3-managed tunnel. It contributes private-network endpoints and lets the existing HTTP/WebSocket client path do the actual connection.
239
173### 3. Desktop-managed SSH access240### 3. Desktop-managed SSH access
174241
175SSH is an access and launch helper, not a separate environment type.242SSH is an access and launch helper, not a separate environment type.
...@@ -185,6 +252,8 @@ After that, the renderer should still connect using an ordinary WebSocket URL ag...@@ -185,6 +252,8 @@ After that, the renderer should still connect using an ordinary WebSocket URL ag
185252
186This keeps the renderer transport model consistent with every other access method.253This keeps the renderer transport model consistent with every other access method.
187254
255The desktop main process owns the SSH bridge because it can spawn local SSH processes, manage askpass prompts, write temporary launch scripts, and clean up forwards. The renderer receives a saved environment record and connects through the forwarded URL; it should not need SSH-specific RPC paths for normal environment traffic.
256
188## Launch methods257## Launch methods
189258
190Launch methods answer a different question:259Launch methods answer a different question:
...@@ -227,6 +296,15 @@ The recommended T3 flow is:...@@ -227,6 +296,15 @@ The recommended T3 flow is:
2274. Desktop establishes local port forwarding.2964. Desktop establishes local port forwarding.
2285. Renderer connects to the forwarded WebSocket endpoint as a normal environment.2975. Renderer connects to the forwarded WebSocket endpoint as a normal environment.
229298
299The saved environment should remember that it was created by desktop SSH launch only for reconnect and lifecycle UX. That metadata should not change the server protocol or the environment identity model.
300
301Failure handling should be explicit:
302
303- SSH authentication failure should surface before any environment is saved
304- remote launch failure should include remote logs or the launcher command output when available
305- forwarded-port failure should leave the saved environment disconnected rather than falling back to an unrelated endpoint
306- reconnect should attempt to restore the SSH bridge before reconnecting the normal WebSocket client
307
230### 3. Client-managed local publish308### 3. Client-managed local publish
231309
232This is the inverse of remote launch: a local T3 server is already running, and the client publishes it through a tunnel.310This is the inverse of remote launch: a local T3 server is already running, and the client publishes it through a tunnel.
...@@ -267,6 +345,8 @@ T3 already supports a WebSocket auth token on the server. That should become a f...@@ -267,6 +345,8 @@ T3 already supports a WebSocket auth token on the server. That should become a f
267345
268For publicly reachable environments, authenticated access should be treated as required.346For publicly reachable environments, authenticated access should be treated as required.
269347
348Hosted pairing should be treated as a client-side convenience only. The hosted app must not receive pairing tokens through query parameters, must not store pairing state server-side, and must not imply that an HTTP backend is safe or reachable from an HTTPS browser context.
349
270## Relationship to Zed350## Relationship to Zed
271351
272Zed is a useful reference implementation for managed remote launch and reconnect behavior.352Zed is a useful reference implementation for managed remote launch and reconnect behavior.
.gitignore+1
...@@ -22,3 +22,4 @@ apps/web/src/components/__screenshots__...@@ -22,3 +22,4 @@ apps/web/src/components/__screenshots__
22__screenshots__/22__screenshots__/
23.tanstack23.tanstack
24squashfs-root/24squashfs-root/
25.vercel
.plans/19-remote-endpoints-hosted-static.md created+350
...@@ -0,0 +1,350 @@
1# Remote Endpoints and Hosted Static App Plan
2
3## Purpose
4
5Make remote access feel first-class while keeping the free DIY path open.
6
7The immediate product goal is:
8
9- users can expose a backend through LAN, their own Tailscale, MagicDNS, a manual HTTPS endpoint, or later T3 Tunnel
10- users can generate a hosted pairing link for `app.t3.codes`
11- the hosted app can pair, persist, reconnect, and operate against saved environments without requiring a backend at the hosted app origin
12- all transports reuse the same backend auth, WebSocket runtime, saved environment registry, and pairing UX
13
14This plan intentionally leaves the paid T3 cloud tunnel fabric out of scope. It defines the OSS foundation that T3 Tunnel should later plug into.
15
16## Current State
17
18Already present or in progress:
19
20- Server auth distinguishes bootstrap credentials from session credentials.
21- One-time pairing credentials can be exchanged for browser sessions or bearer sessions.
22- Saved remote environments store `httpBaseUrl`, `wsBaseUrl`, and a bearer token.
23- Remote environment WebSocket connections use a short-lived WebSocket token.
24- Pairing URLs can carry tokens in the URL fragment.
25- Hosted `/pair?host=...#token=...` can add a saved environment.
26- Hosted static startup can avoid assuming the page origin is the backend.
27
28Main gaps:
29
30- Reachability is represented ad hoc as `endpointUrl`, manual host input, or saved environment URLs.
31- Desktop exposure, hosted pairing, manual remote environments, and future tunnels do not share one endpoint model.
32- Tailscale/MagicDNS endpoints are not detected or surfaced.
33- Hosted-static empty/offline states are still thin.
34- Browser compatibility is not explicitly modeled, especially HTTPS hosted app to HTTP backend mixed-content failure.
35
36## Core Decision: Add `AdvertisedEndpoint`
37
38Add a new first-class contract instead of extending the environment descriptor.
39
40### Why not extend `ExecutionEnvironmentDescriptor`
41
42`ExecutionEnvironmentDescriptor` answers: "What environment is this?"
43
44Examples:
45
46- environment id
47- label
48- platform
49- server version
50- capabilities
51
52`AdvertisedEndpoint` answers: "How can a client reach this environment right now?"
53
54Examples:
55
56- loopback URL
57- LAN URL
58- Tailscale IP URL
59- MagicDNS/Serve URL
60- manual URL
61- future T3 Tunnel URL
62- browser compatibility and exposure level
63
64Those are different lifecycles. One environment can have many endpoints, endpoints can appear/disappear as network interfaces change, and the same descriptor is returned regardless of which endpoint the client used. Extending the descriptor would blur environment identity with transport reachability and make saved environments harder to reason about.
65
66### Target Contract
67
68Add a schema in `packages/contracts`, likely `remoteAccess.ts`:
69
70```ts
71type AdvertisedEndpointProvider =
72 | "loopback"
73 | "lan"
74 | "tailscale-ip"
75 | "tailscale-magicdns"
76 | "manual"
77 | "t3-tunnel";
78
79type AdvertisedEndpointVisibility = "local" | "private-network" | "tailnet" | "public";
80
81type AdvertisedEndpointCompatibility = {
82 hostedHttpsApp: "compatible" | "mixed-content-blocked" | "untrusted-certificate" | "unknown";
83 desktopApp: "compatible" | "unknown";
84};
85
86type AdvertisedEndpoint = {
87 id: string;
88 provider: AdvertisedEndpointProvider;
89 label: string;
90 httpBaseUrl: string;
91 wsBaseUrl: string;
92 visibility: AdvertisedEndpointVisibility;
93 compatibility: AdvertisedEndpointCompatibility;
94 source: "server" | "desktop" | "user";
95 status: "available" | "unavailable" | "unknown";
96 isDefault?: boolean;
97};
98```
99
100Keep the contract schema-only. All classification logic belongs in `packages/shared`, `apps/server`, `apps/desktop`, or `apps/web`.
101
102## HTTP/WS and HTTPS/WSS Readiness
103
104The codebase is partially ready, but the UX and compatibility model are not explicit enough.
105
106What is ready:
107
108- Remote target parsing already derives `ws://` from `http://` and `wss://` from `https://`.
109- Saved environments store both HTTP and WebSocket base URLs.
110- Remote auth uses bearer tokens instead of cookies, so cross-origin hosted clients are viable.
111- WebSocket connections can use a dynamically issued `wsToken`.
112- Server CORS support exists for browser remote auth endpoints.
113
114What is not solved by code alone:
115
116- `https://app.t3.codes` cannot reliably call `http://...` or `ws://...` endpoints because browsers block mixed content.
117- `wss://100.x.y.z:3773` needs a certificate the browser trusts. A raw Tailscale IP does not solve certificate trust.
118- LAN `http://192.168.x.y:3773` is usable from another desktop/native context but not from the hosted HTTPS app.
119- The UI needs to explain why an endpoint is copyable for desktop pairing but not hosted-app compatible.
120
121Policy:
122
123- Support both HTTP/WS and HTTPS/WSS at the runtime layer.
124- Mark endpoint compatibility at the product layer.
125- Generate `app.t3.codes` links only from endpoints that are likely hosted-browser compatible, or show a warning with an explicit fallback.
126
127## Architecture
128
129### Endpoint Sources
130
131Endpoint records can come from several providers:
132
1331. **Server runtime**
134 - headless bind host and port
135 - server-known explicit advertised host config
136
1372. **Desktop shell**
138 - loopback backend URL
139 - LAN exposure state
140 - network interface discovery
141 - Tailscale CLI/status discovery
142
1433. **User configuration**
144 - manually added hostnames
145 - preferred endpoint labels
146 - hidden/disabled endpoints
147
1484. **Future cloud provider**
149 - T3 Tunnel endpoint
150 - billing/account status
151 - tunnel lifecycle state
152
153### Endpoint Registry
154
155Create a central runtime registry:
156
157- `packages/contracts/src/remoteAccess.ts`
158- `packages/shared/src/remoteAccess.ts` for URL normalization and compatibility classification
159- `apps/server/src/remoteAccess/*` for server/headless endpoints
160- `apps/desktop/src/remoteAccess/*` for desktop-discovered endpoints
161- `apps/web/src/environments/endpoints/*` for client-side display and pairing selection
162
163The web app should consume endpoint records and not care whether they came from LAN, Tailscale, or a future tunnel.
164
165### Pairing Link Generation
166
167Move hosted pairing link generation to endpoint-driven input:
168
169```ts
170buildHostedPairingUrl({
171 endpoint: AdvertisedEndpoint,
172 token,
173});
174```
175
176Generated URL:
177
178```text
179https://app.t3.codes/pair?host=<encoded endpoint httpBaseUrl>#token=<one-time token>
180```
181
182Use fragment tokens by default. Continue accepting `?token=` for compatibility.
183
184## Phase 1: Endpoint Abstraction
185
186### Goals
187
188- Centralize URL normalization, protocol derivation, and compatibility checks.
189- Replace ad hoc desktop `endpointUrl` pairing logic with endpoint selection.
190- Preserve all current remote behavior.
191
192### Tasks
193
1941. Add `AdvertisedEndpoint` schemas to `packages/contracts`.
1952. Add shared helpers:
196 - normalize HTTP base URL
197 - derive WebSocket base URL
198 - classify loopback/private/LAN/Tailscale/public host
199 - classify hosted HTTPS compatibility
2003. Add server endpoint discovery:
201 - loopback endpoint
202 - configured non-loopback endpoint
203 - explicit advertised host override
2044. Add desktop endpoint discovery:
205 - local loopback
206 - LAN exposure endpoint
207 - endpoint status labels
2085. Add WebSocket/API method or existing config field for endpoint snapshots.
2096. Refactor settings connections UI:
210 - render endpoint rows
211 - endpoint picker for pairing link copy
212 - show compatibility warnings
2137. Refactor hosted link builder to accept endpoint records.
2148. Add tests for URL normalization and compatibility classification.
215
216### Acceptance Criteria
217
218- Existing LAN/network access UI still works.
219- Pairing links are generated from endpoint records.
220- Loopback endpoints never produce hosted pairing links silently.
221- HTTP private-network endpoints are marked incompatible with `app.t3.codes`.
222- No remote environment runtime changes are required for existing saved environments.
223
224## Phase 2: BYO Tailscale/MagicDNS
225
226### Goals
227
228- Detect free DIY Tailscale reachability.
229- Surface Tailscale endpoints as normal advertised endpoints.
230- Keep users in control of their own tailnet.
231
232### Tasks
233
2341. Detect Tailscale IPs from network interfaces:
235 - IPv4 `100.64.0.0/10`
236 - mark as `provider: "tailscale-ip"`
2372. Add optional desktop-side `tailscale status --json` discovery:
238 - MagicDNS hostname
239 - Tailscale Serve/Funnel HTTPS endpoint if discoverable
240 - graceful failure if CLI is missing
2413. Add manual Tailscale endpoint override:
242 - hostname
243 - label
244 - preferred/default flag
2454. Show Tailscale endpoint rows in settings:
246 - raw IP HTTP endpoint: desktop-compatible, hosted-app likely blocked
247 - HTTPS MagicDNS/Serve endpoint: hosted-compatible if URL is HTTPS
2485. Generate pairing links using selected Tailscale endpoint.
2496. Document DIY setup:
250 - local desktop-to-desktop over Tailscale
251 - hosted app requirements
252 - why HTTPS matters
253
254### Acceptance Criteria
255
256- A machine on Tailscale shows a Tailscale endpoint without paid features.
257- Users can copy a Tailscale-hosted pairing link when the endpoint is HTTPS-compatible.
258- Users can still copy token-only/manual values when endpoint compatibility is unknown.
259- Tailscale is optional and never required for regular LAN/loopback use.
260
261## Phase 3: Hosted Static App Completion
262
263### Goals
264
265- `app.t3.codes` works as a real client shell.
266- It can pair, persist, reconnect, and clearly explain offline/incompatible states.
267
268### Tasks
269
2701. Finish hosted-static root behavior:
271 - no primary backend required
272 - saved environment hydration before initial routing decisions
273 - first saved environment selected as active
2742. Add hosted empty state:
275 - no saved environments
276 - paste pairing URL
277 - add host + token
2783. Add offline saved environment UI:
279 - last connected
280 - reconnect
281 - remove
282 - copy/add alternate endpoint
2834. Audit primary-backend assumptions:
284 - command palette
285 - settings pages
286 - server config atom defaults
287 - keybindings
288 - provider/model lists
289 - update/desktop-only affordances
2905. Add route tests for:
291 - hosted `/pair?host=...#token=...`
292 - hosted root with no saved environments
293 - hosted root with saved environment
294 - primary backend unavailable but saved environment present
2956. Add deployment hardening:
296 - SPA fallback
297 - strict CSP
298 - no third-party scripts
299 - no query token logging
300 - disable or hide source maps in production if needed
3017. Add browser error messages:
302 - mixed content
303 - unreachable backend
304 - CORS failure
305 - certificate failure
306
307### Acceptance Criteria
308
309- `app.t3.codes` can pair a reachable HTTPS backend and reconnect after reload.
310- A saved environment can be used without any backend at `app.t3.codes`.
311- Offline machines show a useful state instead of a generic boot error.
312- HTTP endpoints are still supported in desktop/native/local contexts.
313- Hosted HTTPS app only promises compatibility for HTTPS/WSS endpoints.
314
315## Phase 4: Future T3 Tunnel Provider
316
317Not part of the current implementation, but the endpoint abstraction should make it straightforward.
318
319Future tunnel provider responsibilities:
320
321- create endpoint with `provider: "t3-tunnel"`
322- surface tunnel status
323- provide stable HTTPS URL
324- use existing backend pairing/session auth
325- never bypass server auth
326
327The tunnel fabric can later be Pipenet-derived, Tailscale-derived, or another reverse tunnel implementation. The rest of T3 Code should only see an `AdvertisedEndpoint`.
328
329## Security Checklist
330
331- Pairing tokens are short-lived and one-time.
332- Generated hosted pairing links put tokens in the fragment.
333- The backend remains the authorization boundary.
334- Endpoint discovery never disables backend auth.
335- Hosted app does not silently downgrade to HTTP.
336- Tunnel/public endpoints require explicit user action.
337- Client sessions remain revocable.
338- Endpoint URLs and request logs must avoid recording pairing tokens.
339- Future cloud tunnel must authenticate tunnel creation and tunnel data connections separately from backend pairing.
340
341## Verification
342
343Each implementation PR should run:
344
345- `bun fmt`
346- `bun lint`
347- `bun typecheck`
348- focused tests for changed backend/web behavior
349- backend tests for any server-side endpoint discovery or auth changes using `bun run test`, never `bun test`
350
REMOTE.md+76-1
...@@ -22,9 +22,41 @@ If you are already running the desktop app and want to make it reachable from ot...@@ -22,9 +22,41 @@ If you are already running the desktop app and want to make it reachable from ot
2222
231. Open **Settings** → **Connections**.231. Open **Settings** → **Connections**.
242. Under **Manage Local Backend**, toggle **Network access** on. This will restart the app and run the backend on all network interfaces.242. Under **Manage Local Backend**, toggle **Network access** on. This will restart the app and run the backend on all network interfaces.
253. The settings panel will show the address the server is reachable at (e.g. `http://192.168.x.y:3773`).253. The settings panel will show the default reachable endpoint, with a `+N` control when more endpoints are available. Expand it to inspect alternatives such as loopback, LAN, private-network, or HTTPS endpoints.
264. Use **Create Link** to generate a pairing link you can share with another device.264. Use **Create Link** to generate a pairing link you can share with another device.
2727
28The default endpoint controls the QR code and primary copy action for pairing links. You can change it from the expanded endpoint list. The preference is stored by endpoint type, so choosing the local LAN endpoint survives normal IP address changes when you move between networks.
29
30When no user default is saved, the app uses the built-in LAN endpoint for pairing links when
31available. You can set another endpoint as the default from the expanded endpoint list.
32
33- HTTPS/WSS-compatible endpoints work from `https://app.t3.codes`, but are not made the default
34 automatically.
35- Non-loopback HTTP endpoints are useful for direct LAN pairing.
36- Loopback-only endpoints are not useful for another device unless that device is the same machine.
37
38If the copied link points directly at `http://192.168.x.y:3773`, open it from a client that can reach that LAN address. If it points at `https://app.t3.codes/pair?...`, the hosted web app will save the environment and connect directly to the backend URL in the link.
39
40### Tailscale Endpoints
41
42When the desktop app can detect Tailscale, it adds Tailnet endpoints to the reachable endpoint list.
43
44Depending on your Tailscale setup, this may include:
45
46- the machine's `100.x.y.z` Tailnet IP
47- a MagicDNS name
48- an HTTPS MagicDNS endpoint when Tailscale Serve is configured for this backend
49
50The Tailscale HTTPS endpoint uses the clean MagicDNS URL, such as
51`https://machine.tailnet.ts.net/`, and is disabled until the app verifies that the URL reaches this
52backend. Use **Setup** on the Tailscale HTTPS row to opt in. The desktop app restarts the backend
53with the same server-side behavior as `t3 serve --tailscale-serve`, then the server asks Tailscale
54Serve to proxy HTTPS traffic to the local backend.
55
56The Tailscale support is an endpoint provider add-on. The core remote model still works without Tailscale: LAN HTTP endpoints, custom HTTPS endpoints, future tunnels, and SSH-launched environments all use the same saved environment and pairing flow.
57
58For `https://app.t3.codes`, prefer an HTTPS Tailnet or other HTTPS endpoint. A plain `http://100.x.y.z:3773` endpoint can still work from a desktop client or another browser page served over HTTP, but it will not work from the hosted HTTPS app because of browser mixed-content rules.
59
28### Option 2: Headless Server (CLI)60### Option 2: Headless Server (CLI)
2961
30Use this when you want to run the server without a GUI, for example on a remote machine over SSH.62Use this when you want to run the server without a GUI, for example on a remote machine over SSH.
...@@ -47,14 +79,42 @@ From there, connect from another device in either of these ways:...@@ -47,14 +79,42 @@ From there, connect from another device in either of these ways:
47- scan the QR code on your phone79- scan the QR code on your phone
48- in the desktop app, enter the full pairing URL80- in the desktop app, enter the full pairing URL
49- in the desktop app, enter the host and token separately81- in the desktop app, enter the host and token separately
82- in the hosted web app, open a hosted pairing URL when the backend is reachable over HTTPS
5083
51Use `t3 serve --help` for the full flag reference. It supports the same general startup options as the normal server command, including an optional `cwd` argument.84Use `t3 serve --help` for the full flag reference. It supports the same general startup options as the normal server command, including an optional `cwd` argument.
5285
86For hosted web pairing over Tailscale HTTPS, opt in to Tailscale Serve:
87
88```bash
89npx t3 serve --tailscale-serve
90```
91
92By default this configures Tailscale Serve on HTTPS port 443 and advertises
93`https://machine.tailnet.ts.net/`. Advanced users can choose a different HTTPS port:
94
95```bash
96npx t3 serve --tailscale-serve --tailscale-serve-port 8443
97```
98
53> Note99> Note
54> The GUIs do not currently support adding projects on remote environments.100> The GUIs do not currently support adding projects on remote environments.
55> For now, use `t3 project ...` on the server machine instead.101> For now, use `t3 project ...` on the server machine instead.
56> Full GUI support for remote project management is coming soon.102> Full GUI support for remote project management is coming soon.
57103
104### Option 3: Desktop-Managed SSH Launch
105
106Use this when you want the desktop app to start or reuse T3 Code on another machine over SSH.
107
1081. Open **Settings** → **Connections**.
1092. Under **Remote Environments**, choose **Add environment**.
1103. Select the SSH launch flow.
1114. Enter the SSH target, such as `user@example.com`.
1125. Confirm the launch. The desktop app probes the host, starts or reuses a remote T3 server, opens a local port forward, and saves the environment.
113
114After setup, the renderer connects to a local forwarded HTTP/WebSocket endpoint. The remote host still owns the actual T3 server, projects, files, git state, terminals, and provider sessions.
115
116SSH launch is a desktop feature because it needs local process and SSH access. Once the environment is paired and saved, it uses the same environment list and connection model as direct LAN, Tailscale, HTTPS, or future tunnel-backed environments.
117
58## How Pairing Works118## How Pairing Works
59119
60The remote device does not need a long-lived secret up front.120The remote device does not need a long-lived secret up front.
...@@ -67,6 +127,20 @@ Instead:...@@ -67,6 +127,20 @@ Instead:
67127
68After pairing, future access is session-based. You do not need to keep reusing the original token unless you are pairing a new device.128After pairing, future access is session-based. You do not need to keep reusing the original token unless you are pairing a new device.
69129
130## Hosted Web App Pairing
131
132The hosted web app at `https://app.t3.codes` can save a remote backend in browser local storage from a URL like:
133
134```text
135https://app.t3.codes/pair?host=https://backend.example.com:3773#token=PAIRCODE
136```
137
138Use hosted pairing when the backend is reachable from the browser over HTTPS/WSS. This includes a backend behind a trusted HTTPS tunnel or another HTTPS endpoint you operate.
139
140Do not use hosted pairing for plain HTTP LAN URLs such as `http://192.168.x.y:3773`. Browsers block an HTTPS page from connecting to an insecure HTTP or WS backend. For those endpoints, use the direct pairing URL shown by the desktop app or CLI from a client that can open that HTTP URL directly.
141
142Hosted pairing does not proxy traffic through T3 Code. The browser still connects directly to the backend URL in the pairing link.
143
70## Managing Access Later144## Managing Access Later
71145
72Use `t3 auth` to manage access after the initial pairing flow.146Use `t3 auth` to manage access after the initial pairing flow.
...@@ -84,4 +158,5 @@ Use `t3 auth --help` and the nested subcommand help pages for the full reference...@@ -84,4 +158,5 @@ Use `t3 auth --help` and the nested subcommand help pages for the full reference
84- Treat pairing URLs and pairing tokens like passwords.158- Treat pairing URLs and pairing tokens like passwords.
85- Prefer binding `--host` to a trusted private address, such as a Tailnet IP, instead of exposing the server broadly.159- Prefer binding `--host` to a trusted private address, such as a Tailnet IP, instead of exposing the server broadly.
86- Anyone with a valid pairing credential can create a session until that credential expires or is revoked.160- Anyone with a valid pairing credential can create a session until that credential expires or is revoked.
161- Hosted pairing links keep the credential in the URL hash so it is not sent to the hosted app server, but it can still be exposed through browser history, screenshots, logs, or copy/paste.
87- Use `t3 auth` to revoke credentials or sessions you no longer trust.162- Use `t3 auth` to revoke credentials or sessions you no longer trust.
apps/desktop/package.json+4
...@@ -15,13 +15,17 @@...@@ -15,13 +15,17 @@
15 "smoke-test": "node scripts/smoke-test.mjs"15 "smoke-test": "node scripts/smoke-test.mjs"
16 },16 },
17 "dependencies": {17 "dependencies": {
18 "@effect/platform-node": "catalog:",
18 "effect": "catalog:",19 "effect": "catalog:",
19 "electron": "40.9.3",20 "electron": "40.9.3",
20 "electron-updater": "^6.6.2"21 "electron-updater": "^6.6.2"
21 },22 },
22 "devDependencies": {23 "devDependencies": {
24 "@t3tools/client-runtime": "workspace:*",
23 "@t3tools/contracts": "workspace:*",25 "@t3tools/contracts": "workspace:*",
24 "@t3tools/shared": "workspace:*",26 "@t3tools/shared": "workspace:*",
27 "@t3tools/ssh": "workspace:*",
28 "@t3tools/tailscale": "workspace:*",
25 "@types/node": "catalog:",29 "@types/node": "catalog:",
26 "effect-acp": "workspace:*",30 "effect-acp": "workspace:*",
27 "tsdown": "catalog:",31 "tsdown": "catalog:",
apps/desktop/src/clientPersistence.test.ts+6
...@@ -72,6 +72,12 @@ const savedRegistryRecord: PersistedSavedEnvironmentRecord = {...@@ -72,6 +72,12 @@ const savedRegistryRecord: PersistedSavedEnvironmentRecord = {
72 wsBaseUrl: "wss://remote.example.com/",72 wsBaseUrl: "wss://remote.example.com/",
73 createdAt: "2026-04-09T00:00:00.000Z",73 createdAt: "2026-04-09T00:00:00.000Z",
74 lastConnectedAt: "2026-04-09T01:00:00.000Z",74 lastConnectedAt: "2026-04-09T01:00:00.000Z",
75 desktopSsh: {
76 alias: "devbox",
77 hostname: "devbox.example.com",
78 username: "julius",
79 port: 22,
80 },
75};81};
7682
77describe("clientPersistence", () => {83describe("clientPersistence", () => {
apps/desktop/src/clientPersistence.ts+12-3
...@@ -57,6 +57,12 @@ function isPersistedSavedEnvironmentStorageRecord(...@@ -57,6 +57,12 @@ function isPersistedSavedEnvironmentStorageRecord(
57 typeof value.wsBaseUrl === "string" &&57 typeof value.wsBaseUrl === "string" &&
58 typeof value.createdAt === "string" &&58 typeof value.createdAt === "string" &&
59 (value.lastConnectedAt === null || typeof value.lastConnectedAt === "string") &&59 (value.lastConnectedAt === null || typeof value.lastConnectedAt === "string") &&
60 (value.desktopSsh === undefined ||
61 (Predicate.isObject(value.desktopSsh) &&
62 typeof value.desktopSsh.alias === "string" &&
63 typeof value.desktopSsh.hostname === "string" &&
64 (value.desktopSsh.username === null || typeof value.desktopSsh.username === "string") &&
65 (value.desktopSsh.port === null || typeof value.desktopSsh.port === "number"))) &&
60 (value.encryptedBearerToken === undefined || typeof value.encryptedBearerToken === "string")66 (value.encryptedBearerToken === undefined || typeof value.encryptedBearerToken === "string")
61 );67 );
62}68}
...@@ -77,7 +83,7 @@ function readSavedEnvironmentRegistryDocument(filePath: string): SavedEnvironmen...@@ -77,7 +83,7 @@ function readSavedEnvironmentRegistryDocument(filePath: string): SavedEnvironmen
77function toPersistedSavedEnvironmentRecord(83function toPersistedSavedEnvironmentRecord(
78 record: PersistedSavedEnvironmentStorageRecord,84 record: PersistedSavedEnvironmentStorageRecord,
79): PersistedSavedEnvironmentRecord {85): PersistedSavedEnvironmentRecord {
80 return {86 const nextRecord = {
81 environmentId: record.environmentId,87 environmentId: record.environmentId,
82 label: record.label,88 label: record.label,
83 httpBaseUrl: record.httpBaseUrl,89 httpBaseUrl: record.httpBaseUrl,
...@@ -85,6 +91,7 @@ function toPersistedSavedEnvironmentRecord(...@@ -85,6 +91,7 @@ function toPersistedSavedEnvironmentRecord(
85 createdAt: record.createdAt,91 createdAt: record.createdAt,
86 lastConnectedAt: record.lastConnectedAt,92 lastConnectedAt: record.lastConnectedAt,
87 };93 };
94 return record.desktopSsh ? { ...nextRecord, desktopSsh: record.desktopSsh } : nextRecord;
88}95}
8996
90export function readClientSettings(settingsPath: string): ClientSettings | null {97export function readClientSettings(settingsPath: string): ClientSettings | null {
...@@ -134,6 +141,7 @@ export function writeSavedEnvironmentRegistry(...@@ -134,6 +141,7 @@ export function writeSavedEnvironmentRegistry(
134 wsBaseUrl: record.wsBaseUrl,141 wsBaseUrl: record.wsBaseUrl,
135 createdAt: record.createdAt,142 createdAt: record.createdAt,
136 lastConnectedAt: record.lastConnectedAt,143 lastConnectedAt: record.lastConnectedAt,
144 ...(record.desktopSsh ? { desktopSsh: record.desktopSsh } : {}),
137 encryptedBearerToken,145 encryptedBearerToken,
138 }146 }
139 : record;147 : record;
...@@ -189,7 +197,7 @@ export function writeSavedEnvironmentSecret(input: {...@@ -189,7 +197,7 @@ export function writeSavedEnvironmentSecret(input: {
189 const encryptedBearerToken = input.secretStorage197 const encryptedBearerToken = input.secretStorage
190 .encryptString(input.secret)198 .encryptString(input.secret)
191 .toString("base64");199 .toString("base64");
192 return {200 const nextRecord = {
193 environmentId: record.environmentId,201 environmentId: record.environmentId,
194 label: record.label,202 label: record.label,
195 httpBaseUrl: record.httpBaseUrl,203 httpBaseUrl: record.httpBaseUrl,
...@@ -197,7 +205,8 @@ export function writeSavedEnvironmentSecret(input: {...@@ -197,7 +205,8 @@ export function writeSavedEnvironmentSecret(input: {
197 createdAt: record.createdAt,205 createdAt: record.createdAt,
198 lastConnectedAt: record.lastConnectedAt,206 lastConnectedAt: record.lastConnectedAt,
199 encryptedBearerToken,207 encryptedBearerToken,
200 } satisfies PersistedSavedEnvironmentStorageRecord;208 };
209 return record.desktopSsh ? { ...nextRecord, desktopSsh: record.desktopSsh } : nextRecord;
201 }),210 }),
202 } satisfies SavedEnvironmentRegistryDocument);211 } satisfies SavedEnvironmentRegistryDocument);
203 return found;212 return found;
apps/desktop/src/desktopSettings.test.ts+83
...@@ -9,6 +9,7 @@ import {...@@ -9,6 +9,7 @@ import {
9 readDesktopSettings,9 readDesktopSettings,
10 resolveDefaultDesktopSettings,10 resolveDefaultDesktopSettings,
11 setDesktopServerExposurePreference,11 setDesktopServerExposurePreference,
12 setDesktopTailscaleServePreference,
12 setDesktopUpdateChannelPreference,13 setDesktopUpdateChannelPreference,
13 writeDesktopSettings,14 writeDesktopSettings,
14} from "./desktopSettings.ts";15} from "./desktopSettings.ts";
...@@ -35,6 +36,8 @@ describe("desktopSettings", () => {...@@ -35,6 +36,8 @@ describe("desktopSettings", () => {
35 it("defaults packaged nightly builds to the nightly update channel", () => {36 it("defaults packaged nightly builds to the nightly update channel", () => {
36 expect(resolveDefaultDesktopSettings("0.0.17-nightly.20260415.1")).toEqual({37 expect(resolveDefaultDesktopSettings("0.0.17-nightly.20260415.1")).toEqual({
37 serverExposureMode: "local-only",38 serverExposureMode: "local-only",
39 tailscaleServeEnabled: false,
40 tailscaleServePort: 443,
38 updateChannel: "nightly",41 updateChannel: "nightly",
39 updateChannelConfiguredByUser: false,42 updateChannelConfiguredByUser: false,
40 });43 });
...@@ -45,12 +48,16 @@ describe("desktopSettings", () => {...@@ -45,12 +48,16 @@ describe("desktopSettings", () => {
4548
46 writeDesktopSettings(settingsPath, {49 writeDesktopSettings(settingsPath, {
47 serverExposureMode: "network-accessible",50 serverExposureMode: "network-accessible",
51 tailscaleServeEnabled: true,
52 tailscaleServePort: 8443,
48 updateChannel: "latest",53 updateChannel: "latest",
49 updateChannelConfiguredByUser: true,54 updateChannelConfiguredByUser: true,
50 });55 });
5156
52 expect(readDesktopSettings(settingsPath, "0.0.17")).toEqual({57 expect(readDesktopSettings(settingsPath, "0.0.17")).toEqual({
53 serverExposureMode: "network-accessible",58 serverExposureMode: "network-accessible",
59 tailscaleServeEnabled: true,
60 tailscaleServePort: 8443,
54 updateChannel: "latest",61 updateChannel: "latest",
55 updateChannelConfiguredByUser: true,62 updateChannelConfiguredByUser: true,
56 });63 });
...@@ -61,6 +68,8 @@ describe("desktopSettings", () => {...@@ -61,6 +68,8 @@ describe("desktopSettings", () => {
61 setDesktopServerExposurePreference(68 setDesktopServerExposurePreference(
62 {69 {
63 serverExposureMode: "local-only",70 serverExposureMode: "local-only",
71 tailscaleServeEnabled: false,
72 tailscaleServePort: 443,
64 updateChannel: "latest",73 updateChannel: "latest",
65 updateChannelConfiguredByUser: false,74 updateChannelConfiguredByUser: false,
66 },75 },
...@@ -68,6 +77,50 @@ describe("desktopSettings", () => {...@@ -68,6 +77,50 @@ describe("desktopSettings", () => {
68 ),77 ),
69 ).toEqual({78 ).toEqual({
70 serverExposureMode: "network-accessible",79 serverExposureMode: "network-accessible",
80 tailscaleServeEnabled: false,
81 tailscaleServePort: 443,
82 updateChannel: "latest",
83 updateChannelConfiguredByUser: false,
84 });
85 });
86
87 it("persists the requested Tailscale Serve preference", () => {
88 expect(
89 setDesktopTailscaleServePreference(
90 {
91 serverExposureMode: "local-only",
92 tailscaleServeEnabled: false,
93 tailscaleServePort: 443,
94 updateChannel: "latest",
95 updateChannelConfiguredByUser: false,
96 },
97 { enabled: true, port: 8443 },
98 ),
99 ).toEqual({
100 serverExposureMode: "local-only",
101 tailscaleServeEnabled: true,
102 tailscaleServePort: 8443,
103 updateChannel: "latest",
104 updateChannelConfiguredByUser: false,
105 });
106 });
107
108 it("preserves the configured Tailscale Serve port when no new port is requested", () => {
109 expect(
110 setDesktopTailscaleServePreference(
111 {
112 serverExposureMode: "local-only",
113 tailscaleServeEnabled: false,
114 tailscaleServePort: 8443,
115 updateChannel: "latest",
116 updateChannelConfiguredByUser: false,
117 },
118 { enabled: true },
119 ),
120 ).toEqual({
121 serverExposureMode: "local-only",
122 tailscaleServeEnabled: true,
123 tailscaleServePort: 8443,
71 updateChannel: "latest",124 updateChannel: "latest",
72 updateChannelConfiguredByUser: false,125 updateChannelConfiguredByUser: false,
73 });126 });
...@@ -78,6 +131,8 @@ describe("desktopSettings", () => {...@@ -78,6 +131,8 @@ describe("desktopSettings", () => {
78 setDesktopUpdateChannelPreference(131 setDesktopUpdateChannelPreference(
79 {132 {
80 serverExposureMode: "local-only",133 serverExposureMode: "local-only",
134 tailscaleServeEnabled: false,
135 tailscaleServePort: 443,
81 updateChannel: "latest",136 updateChannel: "latest",
82 updateChannelConfiguredByUser: false,137 updateChannelConfiguredByUser: false,
83 },138 },
...@@ -85,6 +140,8 @@ describe("desktopSettings", () => {...@@ -85,6 +140,8 @@ describe("desktopSettings", () => {
85 ),140 ),
86 ).toEqual({141 ).toEqual({
87 serverExposureMode: "local-only",142 serverExposureMode: "local-only",
143 tailscaleServeEnabled: false,
144 tailscaleServePort: 443,
88 updateChannel: "nightly",145 updateChannel: "nightly",
89 updateChannelConfiguredByUser: true,146 updateChannelConfiguredByUser: true,
90 });147 });
...@@ -103,6 +160,8 @@ describe("desktopSettings", () => {...@@ -103,6 +160,8 @@ describe("desktopSettings", () => {
103160
104 expect(readDesktopSettings(settingsPath, "0.0.17-nightly.20260415.1")).toEqual({161 expect(readDesktopSettings(settingsPath, "0.0.17-nightly.20260415.1")).toEqual({
105 serverExposureMode: "local-only",162 serverExposureMode: "local-only",
163 tailscaleServeEnabled: false,
164 tailscaleServePort: 443,
106 updateChannel: "nightly",165 updateChannel: "nightly",
107 updateChannelConfiguredByUser: false,166 updateChannelConfiguredByUser: false,
108 });167 });
...@@ -121,6 +180,8 @@ describe("desktopSettings", () => {...@@ -121,6 +180,8 @@ describe("desktopSettings", () => {
121180
122 expect(readDesktopSettings(settingsPath, "0.0.17-nightly.20260415.1")).toEqual({181 expect(readDesktopSettings(settingsPath, "0.0.17-nightly.20260415.1")).toEqual({
123 serverExposureMode: "local-only",182 serverExposureMode: "local-only",
183 tailscaleServeEnabled: false,
184 tailscaleServePort: 443,
124 updateChannel: "nightly",185 updateChannel: "nightly",
125 updateChannelConfiguredByUser: false,186 updateChannelConfiguredByUser: false,
126 });187 });
...@@ -140,8 +201,30 @@ describe("desktopSettings", () => {...@@ -140,8 +201,30 @@ describe("desktopSettings", () => {
140201
141 expect(readDesktopSettings(settingsPath, "0.0.17-nightly.20260415.1")).toEqual({202 expect(readDesktopSettings(settingsPath, "0.0.17-nightly.20260415.1")).toEqual({
142 serverExposureMode: "local-only",203 serverExposureMode: "local-only",
204 tailscaleServeEnabled: false,
205 tailscaleServePort: 443,
143 updateChannel: "latest",206 updateChannel: "latest",
144 updateChannelConfiguredByUser: true,207 updateChannelConfiguredByUser: true,
145 });208 });
146 });209 });
210
211 it("falls back to the default Tailscale Serve port when the persisted port is invalid", () => {
212 const settingsPath = makeSettingsPath();
213 fs.writeFileSync(
214 settingsPath,
215 JSON.stringify({
216 tailscaleServeEnabled: true,
217 tailscaleServePort: 0,
218 }),
219 "utf8",
220 );
221
222 expect(readDesktopSettings(settingsPath, "0.0.17")).toEqual({
223 serverExposureMode: "local-only",
224 tailscaleServeEnabled: true,
225 tailscaleServePort: 443,
226 updateChannel: "latest",
227 updateChannelConfiguredByUser: false,
228 });
229 });
147});230});
apps/desktop/src/desktopSettings.ts+33
...@@ -6,12 +6,18 @@ import { resolveDefaultDesktopUpdateChannel } from "./updateChannels.ts";...@@ -6,12 +6,18 @@ import { resolveDefaultDesktopUpdateChannel } from "./updateChannels.ts";
66
7export interface DesktopSettings {7export interface DesktopSettings {
8 readonly serverExposureMode: DesktopServerExposureMode;8 readonly serverExposureMode: DesktopServerExposureMode;
9 readonly tailscaleServeEnabled: boolean;
10 readonly tailscaleServePort: number;
9 readonly updateChannel: DesktopUpdateChannel;11 readonly updateChannel: DesktopUpdateChannel;
10 readonly updateChannelConfiguredByUser: boolean;12 readonly updateChannelConfiguredByUser: boolean;
11}13}
1214
15export const DEFAULT_TAILSCALE_SERVE_PORT = 443;
16
13export const DEFAULT_DESKTOP_SETTINGS: DesktopSettings = {17export const DEFAULT_DESKTOP_SETTINGS: DesktopSettings = {
14 serverExposureMode: "local-only",18 serverExposureMode: "local-only",
19 tailscaleServeEnabled: false,
20 tailscaleServePort: DEFAULT_TAILSCALE_SERVE_PORT,
15 updateChannel: "latest",21 updateChannel: "latest",
16 updateChannelConfiguredByUser: false,22 updateChannelConfiguredByUser: false,
17};23};
...@@ -35,6 +41,29 @@ export function setDesktopServerExposurePreference(...@@ -35,6 +41,29 @@ export function setDesktopServerExposurePreference(
35 };41 };
36}42}
3743
44export function setDesktopTailscaleServePreference(
45 settings: DesktopSettings,
46 input: { readonly enabled: boolean; readonly port?: number },
47): DesktopSettings {
48 const port =
49 input.port === undefined
50 ? settings.tailscaleServePort
51 : normalizeTailscaleServePort(input.port);
52 return settings.tailscaleServeEnabled === input.enabled && settings.tailscaleServePort === port
53 ? settings
54 : {
55 ...settings,
56 tailscaleServeEnabled: input.enabled,
57 tailscaleServePort: port,
58 };
59}
60
61export function normalizeTailscaleServePort(value: unknown): number {
62 return typeof value === "number" && Number.isInteger(value) && value >= 1 && value <= 65_535
63 ? value
64 : DEFAULT_TAILSCALE_SERVE_PORT;
65}
66
38export function setDesktopUpdateChannelPreference(67export function setDesktopUpdateChannelPreference(
39 settings: DesktopSettings,68 settings: DesktopSettings,
40 requestedChannel: DesktopUpdateChannel,69 requestedChannel: DesktopUpdateChannel,
...@@ -57,6 +86,8 @@ export function readDesktopSettings(settingsPath: string, appVersion: string): D...@@ -57,6 +86,8 @@ export function readDesktopSettings(settingsPath: string, appVersion: string): D
57 const raw = FS.readFileSync(settingsPath, "utf8");86 const raw = FS.readFileSync(settingsPath, "utf8");
58 const parsed = JSON.parse(raw) as {87 const parsed = JSON.parse(raw) as {
59 readonly serverExposureMode?: unknown;88 readonly serverExposureMode?: unknown;
89 readonly tailscaleServeEnabled?: unknown;
90 readonly tailscaleServePort?: unknown;
60 readonly updateChannel?: unknown;91 readonly updateChannel?: unknown;
61 readonly updateChannelConfiguredByUser?: unknown;92 readonly updateChannelConfiguredByUser?: unknown;
62 };93 };
...@@ -72,6 +103,8 @@ export function readDesktopSettings(settingsPath: string, appVersion: string): D...@@ -72,6 +103,8 @@ export function readDesktopSettings(settingsPath: string, appVersion: string): D
72 return {103 return {
73 serverExposureMode:104 serverExposureMode:
74 parsed.serverExposureMode === "network-accessible" ? "network-accessible" : "local-only",105 parsed.serverExposureMode === "network-accessible" ? "network-accessible" : "local-only",
106 tailscaleServeEnabled: parsed.tailscaleServeEnabled === true,
107 tailscaleServePort: normalizeTailscaleServePort(parsed.tailscaleServePort),
75 updateChannel:108 updateChannel:
76 updateChannelConfiguredByUser && parsedUpdateChannel !== null109 updateChannelConfiguredByUser && parsedUpdateChannel !== null
77 ? parsedUpdateChannel110 ? parsedUpdateChannel
apps/desktop/src/main.ts+128-5
...@@ -36,11 +36,14 @@ import { autoUpdater } from "electron-updater";...@@ -36,11 +36,14 @@ import { autoUpdater } from "electron-updater";
36import type { ContextMenuItem } from "@t3tools/contracts";36import type { ContextMenuItem } from "@t3tools/contracts";
37import { RotatingFileSink } from "@t3tools/shared/logging";37import { RotatingFileSink } from "@t3tools/shared/logging";
38import { parsePersistedServerObservabilitySettings } from "@t3tools/shared/serverSettings";38import { parsePersistedServerObservabilitySettings } from "@t3tools/shared/serverSettings";
39import type { RemoteT3RunnerOptions } from "@t3tools/ssh/tunnel";
39import { DEFAULT_DESKTOP_BACKEND_PORT, resolveDesktopBackendPort } from "./backendPort.ts";40import { DEFAULT_DESKTOP_BACKEND_PORT, resolveDesktopBackendPort } from "./backendPort.ts";
40import {41import {
42 type DesktopSettings,
41 DEFAULT_DESKTOP_SETTINGS,43 DEFAULT_DESKTOP_SETTINGS,
42 readDesktopSettings,44 readDesktopSettings,
43 setDesktopServerExposurePreference,45 setDesktopServerExposurePreference,
46 setDesktopTailscaleServePreference,
44 setDesktopUpdateChannelPreference,47 setDesktopUpdateChannelPreference,
45 writeDesktopSettings,48 writeDesktopSettings,
46} from "./desktopSettings.ts";49} from "./desktopSettings.ts";
...@@ -55,7 +58,11 @@ import {...@@ -55,7 +58,11 @@ import {
55} from "./clientPersistence.ts";58} from "./clientPersistence.ts";
56import { isBackendReadinessAborted, waitForHttpReady } from "./backendReadiness.ts";59import { isBackendReadinessAborted, waitForHttpReady } from "./backendReadiness.ts";
57import { showDesktopConfirmDialog } from "./confirmDialog.ts";60import { showDesktopConfirmDialog } from "./confirmDialog.ts";
58import { resolveDesktopServerExposure } from "./serverExposure.ts";61import {
62 resolveDesktopCoreAdvertisedEndpoints,
63 resolveDesktopServerExposure,
64} from "./serverExposure.ts";
65import { DesktopSshEnvironmentBridge, resolveRemoteT3CliPackageSpec } from "./sshEnvironment.ts";
59import { syncShellEnvironment } from "./syncShellEnvironment.ts";66import { syncShellEnvironment } from "./syncShellEnvironment.ts";
60import { waitForBackendStartupReady } from "./backendStartupReadiness.ts";67import { waitForBackendStartupReady } from "./backendStartupReadiness.ts";
61import { getAutoUpdateDisabledReason, shouldBroadcastDownloadProgress } from "./updateState.ts";68import { getAutoUpdateDisabledReason, shouldBroadcastDownloadProgress } from "./updateState.ts";
...@@ -76,6 +83,7 @@ import {...@@ -76,6 +83,7 @@ import {
76import { isArm64HostRunningIntelBuild, resolveDesktopRuntimeInfo } from "./runtimeArch.ts";83import { isArm64HostRunningIntelBuild, resolveDesktopRuntimeInfo } from "./runtimeArch.ts";
77import { resolveDesktopAppBranding } from "./appBranding.ts";84import { resolveDesktopAppBranding } from "./appBranding.ts";
78import { bindFirstRevealTrigger, type RevealSubscription } from "./windowReveal.ts";85import { bindFirstRevealTrigger, type RevealSubscription } from "./windowReveal.ts";
86import { resolveTailscaleAdvertisedEndpoints } from "./tailscaleEndpointProvider.ts";
7987
80syncShellEnvironment();88syncShellEnvironment();
8189
...@@ -102,6 +110,8 @@ const SET_SAVED_ENVIRONMENT_SECRET_CHANNEL = "desktop:set-saved-environment-secr...@@ -102,6 +110,8 @@ const SET_SAVED_ENVIRONMENT_SECRET_CHANNEL = "desktop:set-saved-environment-secr
102const REMOVE_SAVED_ENVIRONMENT_SECRET_CHANNEL = "desktop:remove-saved-environment-secret";110const REMOVE_SAVED_ENVIRONMENT_SECRET_CHANNEL = "desktop:remove-saved-environment-secret";
103const GET_SERVER_EXPOSURE_STATE_CHANNEL = "desktop:get-server-exposure-state";111const GET_SERVER_EXPOSURE_STATE_CHANNEL = "desktop:get-server-exposure-state";
104const SET_SERVER_EXPOSURE_MODE_CHANNEL = "desktop:set-server-exposure-mode";112const SET_SERVER_EXPOSURE_MODE_CHANNEL = "desktop:set-server-exposure-mode";
113const SET_TAILSCALE_SERVE_ENABLED_CHANNEL = "desktop:set-tailscale-serve-enabled";
114const GET_ADVERTISED_ENDPOINTS_CHANNEL = "desktop:get-advertised-endpoints";
105const BASE_DIR = process.env.T3CODE_HOME?.trim() || Path.join(OS.homedir(), ".t3");115const BASE_DIR = process.env.T3CODE_HOME?.trim() || Path.join(OS.homedir(), ".t3");
106const STATE_DIR = Path.join(BASE_DIR, "userdata");116const STATE_DIR = Path.join(BASE_DIR, "userdata");
107const DESKTOP_SETTINGS_PATH = Path.join(STATE_DIR, "desktop-settings.json");117const DESKTOP_SETTINGS_PATH = Path.join(STATE_DIR, "desktop-settings.json");
...@@ -110,6 +120,11 @@ const SAVED_ENVIRONMENT_REGISTRY_PATH = Path.join(STATE_DIR, "saved-environments...@@ -110,6 +120,11 @@ const SAVED_ENVIRONMENT_REGISTRY_PATH = Path.join(STATE_DIR, "saved-environments
110const DESKTOP_SCHEME = "t3";120const DESKTOP_SCHEME = "t3";
111const ROOT_DIR = Path.resolve(__dirname, "../../..");121const ROOT_DIR = Path.resolve(__dirname, "../../..");
112const isDevelopment = Boolean(process.env.VITE_DEV_SERVER_URL);122const isDevelopment = Boolean(process.env.VITE_DEV_SERVER_URL);
123// Dev-only SSH launcher override. Set this to an absolute path on the SSH host
124// for a built server entry, for example:
125// "/Users/julius/Development/Work/codething-mvp/apps/server/dist/bin.mjs"
126const DEV_REMOTE_T3_SERVER_ENTRY_PATH =
127 process.env.T3CODE_DEV_REMOTE_T3_SERVER_ENTRY_PATH?.trim() ?? "";
113const desktopAppBranding: DesktopAppBranding = resolveDesktopAppBranding({128const desktopAppBranding: DesktopAppBranding = resolveDesktopAppBranding({
114 isDevelopment,129 isDevelopment,
115 appVersion: app.getVersion(),130 appVersion: app.getVersion(),
...@@ -297,6 +312,9 @@ function backendChildEnv(): NodeJS.ProcessEnv {...@@ -297,6 +312,9 @@ function backendChildEnv(): NodeJS.ProcessEnv {
297 delete env.T3CODE_DESKTOP_WS_URL;312 delete env.T3CODE_DESKTOP_WS_URL;
298 delete env.T3CODE_DESKTOP_LAN_ACCESS;313 delete env.T3CODE_DESKTOP_LAN_ACCESS;
299 delete env.T3CODE_DESKTOP_LAN_HOST;314 delete env.T3CODE_DESKTOP_LAN_HOST;
315 delete env.T3CODE_DESKTOP_HTTPS_ENDPOINTS;
316 delete env.T3CODE_TAILSCALE_SERVE;
317 delete env.T3CODE_TAILSCALE_SERVE_PORT;
300 return env;318 return env;
301}319}
302320
...@@ -305,9 +323,32 @@ function getDesktopServerExposureState(): DesktopServerExposureState {...@@ -305,9 +323,32 @@ function getDesktopServerExposureState(): DesktopServerExposureState {
305 mode: desktopServerExposureMode,323 mode: desktopServerExposureMode,
306 endpointUrl: backendEndpointUrl,324 endpointUrl: backendEndpointUrl,
307 advertisedHost: backendAdvertisedHost,325 advertisedHost: backendAdvertisedHost,
326 tailscaleServeEnabled: desktopSettings.tailscaleServeEnabled,
327 tailscaleServePort: desktopSettings.tailscaleServePort,
308 };328 };
309}329}
310330
331async function getDesktopAdvertisedEndpoints() {
332 const exposure = resolveDesktopServerExposure({
333 mode: desktopServerExposureMode,
334 port: backendPort,
335 networkInterfaces: OS.networkInterfaces(),
336 ...(backendAdvertisedHost ? { advertisedHostOverride: backendAdvertisedHost } : {}),
337 });
338 const coreEndpoints = resolveDesktopCoreAdvertisedEndpoints({
339 port: backendPort,
340 exposure,
341 customHttpsEndpointUrls: resolveCustomHttpsEndpointUrls(),
342 });
343 const tailscaleEndpoints = await resolveTailscaleAdvertisedEndpoints({
344 port: backendPort,
345 serveEnabled: desktopSettings.tailscaleServeEnabled,
346 servePort: desktopSettings.tailscaleServePort,
347 networkInterfaces: OS.networkInterfaces(),
348 });
349 return [...coreEndpoints, ...tailscaleEndpoints];
350}
351
311function getDesktopSecretStorage() {352function getDesktopSecretStorage() {
312 return {353 return {
313 isEncryptionAvailable: () => safeStorage.isEncryptionAvailable(),354 isEncryptionAvailable: () => safeStorage.isEncryptionAvailable(),
...@@ -321,9 +362,19 @@ function resolveAdvertisedHostOverride(): string | undefined {...@@ -321,9 +362,19 @@ function resolveAdvertisedHostOverride(): string | undefined {
321 return override && override.length > 0 ? override : undefined;362 return override && override.length > 0 ? override : undefined;
322}363}
323364
365function resolveCustomHttpsEndpointUrls(): readonly string[] {
366 return (process.env.T3CODE_DESKTOP_HTTPS_ENDPOINTS ?? "")
367 .split(",")
368 .map((entry) => entry.trim())
369 .filter((entry) => entry.length > 0);
370}
371
324async function applyDesktopServerExposureMode(372async function applyDesktopServerExposureMode(
325 mode: DesktopServerExposureMode,373 mode: DesktopServerExposureMode,
326 options?: { readonly persist?: boolean; readonly rejectIfUnavailable?: boolean },374 options?: {
375 readonly persist?: boolean;
376 readonly rejectIfUnavailable?: boolean;
377 },
327): Promise<DesktopServerExposureState> {378): Promise<DesktopServerExposureState> {
328 const advertisedHostOverride = resolveAdvertisedHostOverride();379 const advertisedHostOverride = resolveAdvertisedHostOverride();
329 const requestedMode = mode;380 const requestedMode = mode;
...@@ -361,6 +412,17 @@ async function applyDesktopServerExposureMode(...@@ -361,6 +412,17 @@ async function applyDesktopServerExposureMode(
361 return getDesktopServerExposureState();412 return getDesktopServerExposureState();
362}413}
363414
415async function applyDesktopTailscaleServeEnabled(
416 nextSettings: DesktopSettings,
417): Promise<DesktopServerExposureState> {
418 desktopSettings = nextSettings;
419 writeDesktopSettings(DESKTOP_SETTINGS_PATH, desktopSettings);
420 relaunchDesktopApp(
421 desktopSettings.tailscaleServeEnabled ? "tailscale-serve-enabled" : "tailscale-serve-disabled",
422 );
423 return getDesktopServerExposureState();
424}
425
364function relaunchDesktopApp(reason: string): void {426function relaunchDesktopApp(reason: string): void {
365 writeDesktopLogHeader(`desktop relaunch requested reason=${reason}`);427 writeDesktopLogHeader(`desktop relaunch requested reason=${reason}`);
366 setImmediate(() => {428 setImmediate(() => {
...@@ -373,6 +435,7 @@ function relaunchDesktopApp(reason: string): void {...@@ -373,6 +435,7 @@ function relaunchDesktopApp(reason: string): void {
373 `desktop relaunch backend shutdown warning message=${formatErrorMessage(error)}`,435 `desktop relaunch backend shutdown warning message=${formatErrorMessage(error)}`,
374 );436 );
375 })437 })
438 .then(() => desktopSshEnvironmentBridge.dispose().catch(() => undefined))
376 .finally(() => {439 .finally(() => {
377 restoreStdIoCapture?.();440 restoreStdIoCapture?.();
378 if (isDevelopment) {441 if (isDevelopment) {
...@@ -629,6 +692,22 @@ let updateInstallInFlight = false;...@@ -629,6 +692,22 @@ let updateInstallInFlight = false;
629let updaterConfigured = false;692let updaterConfigured = false;
630let updateState: DesktopUpdateState = initialUpdateState();693let updateState: DesktopUpdateState = initialUpdateState();
631694
695const desktopSshEnvironmentBridge = new DesktopSshEnvironmentBridge({
696 getMainWindow: () => mainWindow,
697 resolveCliRunner: (): RemoteT3RunnerOptions => {
698 if (isDevelopment && DEV_REMOTE_T3_SERVER_ENTRY_PATH.length > 0) {
699 return { nodeScriptPath: DEV_REMOTE_T3_SERVER_ENTRY_PATH };
700 }
701 return {
702 packageSpec: resolveRemoteT3CliPackageSpec({
703 appVersion: app.getVersion(),
704 updateChannel: desktopSettings.updateChannel,
705 isDevelopment,
706 }),
707 };
708 },
709});
710
632function resolveUpdaterErrorContext(): DesktopUpdateErrorContext {711function resolveUpdaterErrorContext(): DesktopUpdateErrorContext {
633 if (updateInstallInFlight) return "install";712 if (updateInstallInFlight) return "install";
634 if (updateDownloadInFlight) return "download";713 if (updateDownloadInFlight) return "download";
...@@ -1180,7 +1259,10 @@ async function checkForUpdates(reason: string): Promise<boolean> {...@@ -1180,7 +1259,10 @@ async function checkForUpdates(reason: string): Promise<boolean> {
1180 }1259 }
1181}1260}
11821261
1183async function downloadAvailableUpdate(): Promise<{ accepted: boolean; completed: boolean }> {1262async function downloadAvailableUpdate(): Promise<{
1263 accepted: boolean;
1264 completed: boolean;
1265}> {
1184 if (!updaterConfigured || updateDownloadInFlight || updateState.status !== "available") {1266 if (!updaterConfigured || updateDownloadInFlight || updateState.status !== "available") {
1185 return { accepted: false, completed: false };1267 return { accepted: false, completed: false };
1186 }1268 }
...@@ -1202,7 +1284,10 @@ async function downloadAvailableUpdate(): Promise<{ accepted: boolean; completed...@@ -1202,7 +1284,10 @@ async function downloadAvailableUpdate(): Promise<{ accepted: boolean; completed
1202 }1284 }
1203}1285}
12041286
1205async function installDownloadedUpdate(): Promise<{ accepted: boolean; completed: boolean }> {1287async function installDownloadedUpdate(): Promise<{
1288 accepted: boolean;
1289 completed: boolean;
1290}> {
1206 if (isQuitting || !updaterConfigured || updateState.status !== "downloaded") {1291 if (isQuitting || !updaterConfigured || updateState.status !== "downloaded") {
1207 return { accepted: false, completed: false };1292 return { accepted: false, completed: false };
1208 }1293 }
...@@ -1402,6 +1487,8 @@ function startBackend(): void {...@@ -1402,6 +1487,8 @@ function startBackend(): void {
1402 t3Home: BASE_DIR,1487 t3Home: BASE_DIR,
1403 host: backendBindHost,1488 host: backendBindHost,
1404 desktopBootstrapToken: backendBootstrapToken,1489 desktopBootstrapToken: backendBootstrapToken,
1490 tailscaleServeEnabled: desktopSettings.tailscaleServeEnabled,
1491 tailscaleServePort: desktopSettings.tailscaleServePort,
1405 ...(backendObservabilitySettings.otlpTracesUrl1492 ...(backendObservabilitySettings.otlpTracesUrl
1406 ? { otlpTracesUrl: backendObservabilitySettings.otlpTracesUrl }1493 ? { otlpTracesUrl: backendObservabilitySettings.otlpTracesUrl }
1407 : {}),1494 : {}),
...@@ -1647,6 +1734,8 @@ function registerIpcHandlers(): void {...@@ -1647,6 +1734,8 @@ function registerIpcHandlers(): void {
1647 },1734 },
1648 );1735 );
16491736
1737 desktopSshEnvironmentBridge.registerIpcHandlers(ipcMain);
1738
1650 ipcMain.removeHandler(GET_SERVER_EXPOSURE_STATE_CHANNEL);1739 ipcMain.removeHandler(GET_SERVER_EXPOSURE_STATE_CHANNEL);
1651 ipcMain.handle(GET_SERVER_EXPOSURE_STATE_CHANNEL, async () => getDesktopServerExposureState());1740 ipcMain.handle(GET_SERVER_EXPOSURE_STATE_CHANNEL, async () => getDesktopServerExposureState());
16521741
...@@ -1669,6 +1758,31 @@ function registerIpcHandlers(): void {...@@ -1669,6 +1758,31 @@ function registerIpcHandlers(): void {
1669 return nextState;1758 return nextState;
1670 });1759 });
16711760
1761 ipcMain.removeHandler(SET_TAILSCALE_SERVE_ENABLED_CHANNEL);
1762 ipcMain.handle(SET_TAILSCALE_SERVE_ENABLED_CHANNEL, async (_event, rawInput: unknown) => {
1763 if (typeof rawInput !== "object" || rawInput === null) {
1764 throw new Error("Invalid Tailscale Serve input.");
1765 }
1766 const input = rawInput as {
1767 readonly enabled?: unknown;
1768 readonly port?: unknown;
1769 };
1770 if (typeof input.enabled !== "boolean") {
1771 throw new Error("Invalid Tailscale Serve input.");
1772 }
1773 const nextSettings = setDesktopTailscaleServePreference(desktopSettings, {
1774 enabled: input.enabled,
1775 ...(typeof input.port === "number" ? { port: input.port } : {}),
1776 });
1777 if (nextSettings === desktopSettings) {
1778 return getDesktopServerExposureState();
1779 }
1780 return applyDesktopTailscaleServeEnabled(nextSettings);
1781 });
1782
1783 ipcMain.removeHandler(GET_ADVERTISED_ENDPOINTS_CHANNEL);
1784 ipcMain.handle(GET_ADVERTISED_ENDPOINTS_CHANNEL, async () => getDesktopAdvertisedEndpoints());
1785
1672 ipcMain.removeHandler(PICK_FOLDER_CHANNEL);1786 ipcMain.removeHandler(PICK_FOLDER_CHANNEL);
1673 ipcMain.handle(PICK_FOLDER_CHANNEL, async (_event, rawOptions: unknown) => {1787 ipcMain.handle(PICK_FOLDER_CHANNEL, async (_event, rawOptions: unknown) => {
1674 const owner = BrowserWindow.getFocusedWindow() ?? mainWindow;1788 const owner = BrowserWindow.getFocusedWindow() ?? mainWindow;
...@@ -1959,7 +2073,10 @@ function createWindow(): BrowserWindow {...@@ -1959,7 +2073,10 @@ function createWindow(): BrowserWindow {
1959 const externalUrl = getSafeExternalUrl(params.linkURL);2073 const externalUrl = getSafeExternalUrl(params.linkURL);
1960 if (externalUrl) {2074 if (externalUrl) {
1961 menuTemplate.push(2075 menuTemplate.push(
1962 { label: "Copy Link", click: () => clipboard.writeText(params.linkURL) },2076 {
2077 label: "Copy Link",
2078 click: () => clipboard.writeText(params.linkURL),
2079 },
1963 { type: "separator" },2080 { type: "separator" },
1964 );2081 );
1965 }2082 }
...@@ -2019,6 +2136,9 @@ function createWindow(): BrowserWindow {...@@ -2019,6 +2136,9 @@ function createWindow(): BrowserWindow {
2019 }2136 }
20202137
2021 window.on("closed", () => {2138 window.on("closed", () => {
2139 desktopSshEnvironmentBridge.cancelPendingPasswordPrompts(
2140 "SSH authentication was cancelled because the app window closed.",
2141 );
2022 if (mainWindow === window) {2142 if (mainWindow === window) {
2023 mainWindow = null;2143 mainWindow = null;
2024 }2144 }
...@@ -2110,6 +2230,7 @@ app.on("before-quit", () => {...@@ -2110,6 +2230,7 @@ app.on("before-quit", () => {
2110 clearUpdatePollTimer();2230 clearUpdatePollTimer();
2111 cancelBackendReadinessWait();2231 cancelBackendReadinessWait();
2112 stopBackend();2232 stopBackend();
2233 void desktopSshEnvironmentBridge.dispose().catch(() => undefined);
2113 restoreStdIoCapture?.();2234 restoreStdIoCapture?.();
2114});2235});
21152236
...@@ -2159,6 +2280,7 @@ if (process.platform !== "win32") {...@@ -2159,6 +2280,7 @@ if (process.platform !== "win32") {
2159 clearUpdatePollTimer();2280 clearUpdatePollTimer();
2160 cancelBackendReadinessWait();2281 cancelBackendReadinessWait();
2161 stopBackend();2282 stopBackend();
2283 void desktopSshEnvironmentBridge.dispose().catch(() => undefined);
2162 restoreStdIoCapture?.();2284 restoreStdIoCapture?.();
2163 app.quit();2285 app.quit();
2164 });2286 });
...@@ -2169,6 +2291,7 @@ if (process.platform !== "win32") {...@@ -2169,6 +2291,7 @@ if (process.platform !== "win32") {
2169 writeDesktopLogHeader("SIGTERM received");2291 writeDesktopLogHeader("SIGTERM received");
2170 clearUpdatePollTimer();2292 clearUpdatePollTimer();
2171 stopBackend();2293 stopBackend();
2294 void desktopSshEnvironmentBridge.dispose().catch(() => undefined);
2172 restoreStdIoCapture?.();2295 restoreStdIoCapture?.();
2173 app.quit();2296 app.quit();
2174 });2297 });
apps/desktop/src/preload.ts+59
...@@ -22,8 +22,36 @@ const SET_SAVED_ENVIRONMENT_REGISTRY_CHANNEL = "desktop:set-saved-environment-re...@@ -22,8 +22,36 @@ const SET_SAVED_ENVIRONMENT_REGISTRY_CHANNEL = "desktop:set-saved-environment-re
22const GET_SAVED_ENVIRONMENT_SECRET_CHANNEL = "desktop:get-saved-environment-secret";22const GET_SAVED_ENVIRONMENT_SECRET_CHANNEL = "desktop:get-saved-environment-secret";
23const SET_SAVED_ENVIRONMENT_SECRET_CHANNEL = "desktop:set-saved-environment-secret";23const SET_SAVED_ENVIRONMENT_SECRET_CHANNEL = "desktop:set-saved-environment-secret";
24const REMOVE_SAVED_ENVIRONMENT_SECRET_CHANNEL = "desktop:remove-saved-environment-secret";24const REMOVE_SAVED_ENVIRONMENT_SECRET_CHANNEL = "desktop:remove-saved-environment-secret";
25const DISCOVER_SSH_HOSTS_CHANNEL = "desktop:discover-ssh-hosts";
26const ENSURE_SSH_ENVIRONMENT_CHANNEL = "desktop:ensure-ssh-environment";
27const DISCONNECT_SSH_ENVIRONMENT_CHANNEL = "desktop:disconnect-ssh-environment";
28const FETCH_SSH_ENVIRONMENT_DESCRIPTOR_CHANNEL = "desktop:fetch-ssh-environment-descriptor";
29const BOOTSTRAP_SSH_BEARER_SESSION_CHANNEL = "desktop:bootstrap-ssh-bearer-session";
30const FETCH_SSH_SESSION_STATE_CHANNEL = "desktop:fetch-ssh-session-state";
31const ISSUE_SSH_WEBSOCKET_TOKEN_CHANNEL = "desktop:issue-ssh-websocket-token";
32const SSH_PASSWORD_PROMPT_CHANNEL = "desktop:ssh-password-prompt";
33const RESOLVE_SSH_PASSWORD_PROMPT_CHANNEL = "desktop:resolve-ssh-password-prompt";
25const GET_SERVER_EXPOSURE_STATE_CHANNEL = "desktop:get-server-exposure-state";34const GET_SERVER_EXPOSURE_STATE_CHANNEL = "desktop:get-server-exposure-state";
26const SET_SERVER_EXPOSURE_MODE_CHANNEL = "desktop:set-server-exposure-mode";35const SET_SERVER_EXPOSURE_MODE_CHANNEL = "desktop:set-server-exposure-mode";
36const SET_TAILSCALE_SERVE_ENABLED_CHANNEL = "desktop:set-tailscale-serve-enabled";
37const GET_ADVERTISED_ENDPOINTS_CHANNEL = "desktop:get-advertised-endpoints";
38const SSH_PASSWORD_PROMPT_CANCELLED_RESULT = "ssh-password-prompt-cancelled";
39
40function unwrapEnsureSshEnvironmentResult(result: unknown) {
41 if (
42 typeof result === "object" &&
43 result !== null &&
44 "type" in result &&
45 result.type === SSH_PASSWORD_PROMPT_CANCELLED_RESULT
46 ) {
47 const message =
48 "message" in result && typeof result.message === "string"
49 ? result.message
50 : "SSH authentication cancelled.";
51 throw new Error(message);
52 }
53 return result as Awaited<ReturnType<DesktopBridge["ensureSshEnvironment"]>>;
54}
2755
28contextBridge.exposeInMainWorld("desktopBridge", {56contextBridge.exposeInMainWorld("desktopBridge", {
29 getAppBranding: () => {57 getAppBranding: () => {
...@@ -51,8 +79,39 @@ contextBridge.exposeInMainWorld("desktopBridge", {...@@ -51,8 +79,39 @@ contextBridge.exposeInMainWorld("desktopBridge", {
51 ipcRenderer.invoke(SET_SAVED_ENVIRONMENT_SECRET_CHANNEL, environmentId, secret),79 ipcRenderer.invoke(SET_SAVED_ENVIRONMENT_SECRET_CHANNEL, environmentId, secret),
52 removeSavedEnvironmentSecret: (environmentId) =>80 removeSavedEnvironmentSecret: (environmentId) =>
53 ipcRenderer.invoke(REMOVE_SAVED_ENVIRONMENT_SECRET_CHANNEL, environmentId),81 ipcRenderer.invoke(REMOVE_SAVED_ENVIRONMENT_SECRET_CHANNEL, environmentId),
82 discoverSshHosts: () => ipcRenderer.invoke(DISCOVER_SSH_HOSTS_CHANNEL),
83 ensureSshEnvironment: async (target, options) =>
84 unwrapEnsureSshEnvironmentResult(
85 await ipcRenderer.invoke(ENSURE_SSH_ENVIRONMENT_CHANNEL, target, options),
86 ),
87 disconnectSshEnvironment: (target) =>
88 ipcRenderer.invoke(DISCONNECT_SSH_ENVIRONMENT_CHANNEL, target),
89 fetchSshEnvironmentDescriptor: (httpBaseUrl) =>
90 ipcRenderer.invoke(FETCH_SSH_ENVIRONMENT_DESCRIPTOR_CHANNEL, httpBaseUrl),
91 bootstrapSshBearerSession: (httpBaseUrl, credential) =>
92 ipcRenderer.invoke(BOOTSTRAP_SSH_BEARER_SESSION_CHANNEL, httpBaseUrl, credential),
93 fetchSshSessionState: (httpBaseUrl, bearerToken) =>
94 ipcRenderer.invoke(FETCH_SSH_SESSION_STATE_CHANNEL, httpBaseUrl, bearerToken),
95 issueSshWebSocketToken: (httpBaseUrl, bearerToken) =>
96 ipcRenderer.invoke(ISSUE_SSH_WEBSOCKET_TOKEN_CHANNEL, httpBaseUrl, bearerToken),
97 onSshPasswordPrompt: (listener) => {
98 const wrappedListener = (_event: Electron.IpcRendererEvent, request: unknown) => {
99 if (typeof request !== "object" || request === null) return;
100 listener(request as Parameters<typeof listener>[0]);
101 };
102
103 ipcRenderer.on(SSH_PASSWORD_PROMPT_CHANNEL, wrappedListener);
104 return () => {
105 ipcRenderer.removeListener(SSH_PASSWORD_PROMPT_CHANNEL, wrappedListener);
106 };
107 },
108 resolveSshPasswordPrompt: (requestId, password) =>
109 ipcRenderer.invoke(RESOLVE_SSH_PASSWORD_PROMPT_CHANNEL, requestId, password),
54 getServerExposureState: () => ipcRenderer.invoke(GET_SERVER_EXPOSURE_STATE_CHANNEL),110 getServerExposureState: () => ipcRenderer.invoke(GET_SERVER_EXPOSURE_STATE_CHANNEL),
55 setServerExposureMode: (mode) => ipcRenderer.invoke(SET_SERVER_EXPOSURE_MODE_CHANNEL, mode),111 setServerExposureMode: (mode) => ipcRenderer.invoke(SET_SERVER_EXPOSURE_MODE_CHANNEL, mode),
112 setTailscaleServeEnabled: (input) =>
113 ipcRenderer.invoke(SET_TAILSCALE_SERVE_ENABLED_CHANNEL, input),
114 getAdvertisedEndpoints: () => ipcRenderer.invoke(GET_ADVERTISED_ENDPOINTS_CHANNEL),
56 pickFolder: (options) => ipcRenderer.invoke(PICK_FOLDER_CHANNEL, options),115 pickFolder: (options) => ipcRenderer.invoke(PICK_FOLDER_CHANNEL, options),
57 confirm: (message) => ipcRenderer.invoke(CONFIRM_CHANNEL, message),116 confirm: (message) => ipcRenderer.invoke(CONFIRM_CHANNEL, message),
58 setTheme: (theme) => ipcRenderer.invoke(SET_THEME_CHANNEL, theme),117 setTheme: (theme) => ipcRenderer.invoke(SET_THEME_CHANNEL, theme),
apps/desktop/src/serverExposure.test.ts+120-1
...@@ -1,6 +1,10 @@...@@ -1,6 +1,10 @@
1import { describe, expect, it } from "vitest";1import { describe, expect, it } from "vitest";
22
3import { resolveDesktopServerExposure, resolveLanAdvertisedHost } from "./serverExposure.ts";3import {
4 resolveDesktopCoreAdvertisedEndpoints,
5 resolveDesktopServerExposure,
6 resolveLanAdvertisedHost,
7} from "./serverExposure.ts";
48
5describe("resolveLanAdvertisedHost", () => {9describe("resolveLanAdvertisedHost", () => {
6 it("prefers an explicit host override", () => {10 it("prefers an explicit host override", () => {
...@@ -74,6 +78,121 @@ describe("resolveLanAdvertisedHost", () => {...@@ -74,6 +78,121 @@ describe("resolveLanAdvertisedHost", () => {
74 });78 });
75});79});
7680
81describe("resolveDesktopCoreAdvertisedEndpoints", () => {
82 it("advertises loopback and LAN endpoints without provider-specific assumptions", () => {
83 const exposure = resolveDesktopServerExposure({
84 mode: "network-accessible",
85 port: 3773,
86 networkInterfaces: {
87 en0: [
88 {
89 address: "192.168.1.44",
90 family: "IPv4",
91 internal: false,
92 netmask: "255.255.255.0",
93 cidr: "192.168.1.44/24",
94 mac: "00:00:00:00:00:00",
95 },
96 ],
97 },
98 });
99
100 expect(
101 resolveDesktopCoreAdvertisedEndpoints({
102 port: 3773,
103 exposure,
104 customHttpsEndpointUrls: [
105 "https://desktop.example.ts.net",
106 "http://desktop.example.test:3773",
107 "not-a-url",
108 ],
109 }),
110 ).toEqual([
111 {
112 id: "desktop-loopback:3773",
113 label: "This machine",
114 provider: {
115 id: "desktop-core",
116 label: "Desktop",
117 kind: "core",
118 isAddon: false,
119 },
120 httpBaseUrl: "http://127.0.0.1:3773/",
121 wsBaseUrl: "ws://127.0.0.1:3773/",
122 reachability: "loopback",
123 compatibility: {
124 hostedHttpsApp: "mixed-content-blocked",
125 desktopApp: "compatible",
126 },
127 source: "desktop-core",
128 status: "available",
129 description: "Loopback endpoint for this desktop app.",
130 },
131 {
132 id: "desktop-lan:http://192.168.1.44:3773",
133 label: "Local network",
134 provider: {
135 id: "desktop-core",
136 label: "Desktop",
137 kind: "core",
138 isAddon: false,
139 },
140 httpBaseUrl: "http://192.168.1.44:3773/",
141 wsBaseUrl: "ws://192.168.1.44:3773/",
142 reachability: "lan",
143 compatibility: {
144 hostedHttpsApp: "mixed-content-blocked",
145 desktopApp: "compatible",
146 },
147 source: "desktop-core",
148 status: "available",
149 isDefault: true,
150 description: "Reachable from devices on the same network.",
151 },
152 {
153 id: "manual:https://desktop.example.ts.net",
154 label: "Custom HTTPS",
155 provider: {
156 id: "manual",
157 label: "Manual",
158 kind: "manual",
159 isAddon: false,
160 },
161 httpBaseUrl: "https://desktop.example.ts.net/",
162 wsBaseUrl: "wss://desktop.example.ts.net/",
163 reachability: "public",
164 compatibility: {
165 hostedHttpsApp: "compatible",
166 desktopApp: "compatible",
167 },
168 source: "user",
169 status: "unknown",
170 description: "User-configured HTTPS endpoint for this desktop backend.",
171 },
172 {
173 id: "manual:http://desktop.example.test:3773",
174 label: "Custom endpoint",
175 provider: {
176 id: "manual",
177 label: "Manual",
178 kind: "manual",
179 isAddon: false,
180 },
181 httpBaseUrl: "http://desktop.example.test:3773/",
182 wsBaseUrl: "ws://desktop.example.test:3773/",
183 reachability: "public",
184 compatibility: {
185 hostedHttpsApp: "mixed-content-blocked",
186 desktopApp: "compatible",
187 },
188 source: "user",
189 status: "unknown",
190 description: "User-configured endpoint for this desktop backend.",
191 },
192 ]);
193 });
194});
195
77describe("resolveDesktopServerExposure", () => {196describe("resolveDesktopServerExposure", () => {
78 it("keeps the desktop server loopback-only when local-only mode is selected", () => {197 it("keeps the desktop server loopback-only when local-only mode is selected", () => {
79 expect(198 expect(
apps/desktop/src/serverExposure.ts+109-1
...@@ -1,5 +1,13 @@...@@ -1,5 +1,13 @@
1import type { NetworkInterfaceInfo } from "node:os";1import type { NetworkInterfaceInfo } from "node:os";
2import type { DesktopServerExposureMode } from "@t3tools/contracts";2import {
3 createAdvertisedEndpoint,
4 type CreateAdvertisedEndpointInput,
5} from "@t3tools/client-runtime";
6import type {
7 AdvertisedEndpoint,
8 AdvertisedEndpointProvider,
9 DesktopServerExposureMode,
10} from "@t3tools/contracts";
311
4const DESKTOP_LOOPBACK_HOST = "127.0.0.1";12const DESKTOP_LOOPBACK_HOST = "127.0.0.1";
5const DESKTOP_LAN_BIND_HOST = "0.0.0.0";13const DESKTOP_LAN_BIND_HOST = "0.0.0.0";
...@@ -13,6 +21,26 @@ export interface DesktopServerExposure {...@@ -13,6 +21,26 @@ export interface DesktopServerExposure {
13 readonly advertisedHost: string | null;21 readonly advertisedHost: string | null;
14}22}
1523
24export interface DesktopAdvertisedEndpointInput {
25 readonly port: number;
26 readonly exposure: DesktopServerExposure;
27 readonly customHttpsEndpointUrls?: readonly string[];
28}
29
30const DESKTOP_CORE_ENDPOINT_PROVIDER: AdvertisedEndpointProvider = {
31 id: "desktop-core",
32 label: "Desktop",
33 kind: "core",
34 isAddon: false,
35};
36
37const DESKTOP_MANUAL_ENDPOINT_PROVIDER: AdvertisedEndpointProvider = {
38 id: "manual",
39 label: "Manual",
40 kind: "manual",
41 isAddon: false,
42};
43
16const normalizeOptionalHost = (value: string | undefined): string | undefined => {44const normalizeOptionalHost = (value: string | undefined): string | undefined => {
17 const normalized = value?.trim();45 const normalized = value?.trim();
18 return normalized && normalized.length > 0 ? normalized : undefined;46 return normalized && normalized.length > 0 ? normalized : undefined;
...@@ -21,6 +49,14 @@ const normalizeOptionalHost = (value: string | undefined): string | undefined =>...@@ -21,6 +49,14 @@ const normalizeOptionalHost = (value: string | undefined): string | undefined =>
21const isUsableLanIpv4Address = (address: string): boolean =>49const isUsableLanIpv4Address = (address: string): boolean =>
22 !address.startsWith("127.") && !address.startsWith("169.254.");50 !address.startsWith("127.") && !address.startsWith("169.254.");
2351
52function isHttpsEndpointUrl(value: string): boolean {
53 try {
54 return new URL(value).protocol === "https:";
55 } catch {
56 return false;
57 }
58}
59
24export function resolveLanAdvertisedHost(60export function resolveLanAdvertisedHost(
25 networkInterfaces: NodeJS.Dict<NetworkInterfaceInfo[]>,61 networkInterfaces: NodeJS.Dict<NetworkInterfaceInfo[]>,
26 explicitHost: string | undefined,62 explicitHost: string | undefined,
...@@ -78,3 +114,75 @@ export function resolveDesktopServerExposure(input: {...@@ -78,3 +114,75 @@ export function resolveDesktopServerExposure(input: {
78 advertisedHost,114 advertisedHost,
79 };115 };
80}116}
117
118function createDesktopEndpoint(
119 input: Omit<CreateAdvertisedEndpointInput, "provider" | "source">,
120): AdvertisedEndpoint {
121 return createAdvertisedEndpoint({
122 ...input,
123 provider: DESKTOP_CORE_ENDPOINT_PROVIDER,
124 source: "desktop-core",
125 });
126}
127
128function createManualEndpoint(
129 input: Omit<CreateAdvertisedEndpointInput, "provider" | "source">,
130): AdvertisedEndpoint {
131 return createAdvertisedEndpoint({
132 ...input,
133 provider: DESKTOP_MANUAL_ENDPOINT_PROVIDER,
134 source: "user",
135 });
136}
137
138export function resolveDesktopCoreAdvertisedEndpoints(
139 input: DesktopAdvertisedEndpointInput,
140): readonly AdvertisedEndpoint[] {
141 const endpoints: AdvertisedEndpoint[] = [
142 createDesktopEndpoint({
143 id: `desktop-loopback:${input.port}`,
144 label: "This machine",
145 httpBaseUrl: input.exposure.localHttpUrl,
146 reachability: "loopback",
147 status: "available",
148 description: "Loopback endpoint for this desktop app.",
149 }),
150 ];
151
152 if (input.exposure.endpointUrl) {
153 endpoints.push(
154 createDesktopEndpoint({
155 id: `desktop-lan:${input.exposure.endpointUrl}`,
156 label: "Local network",
157 httpBaseUrl: input.exposure.endpointUrl,
158 reachability: "lan",
159 status: "available",
160 isDefault: true,
161 description: "Reachable from devices on the same network.",
162 }),
163 );
164 }
165
166 for (const customEndpointUrl of input.customHttpsEndpointUrls ?? []) {
167 try {
168 const isHttpsEndpoint = isHttpsEndpointUrl(customEndpointUrl);
169 endpoints.push(
170 createManualEndpoint({
171 id: `manual:${customEndpointUrl}`,
172 label: isHttpsEndpoint ? "Custom HTTPS" : "Custom endpoint",
173 httpBaseUrl: customEndpointUrl,
174 reachability: "public",
175 ...(isHttpsEndpoint ? ({ hostedHttpsCompatibility: "compatible" } as const) : {}),
176 status: "unknown",
177 description: isHttpsEndpoint
178 ? "User-configured HTTPS endpoint for this desktop backend."
179 : "User-configured endpoint for this desktop backend.",
180 }),
181 );
182 } catch {
183 // Ignore malformed user-configured endpoints without dropping valid endpoints.
184 }
185 }
186
187 return endpoints;
188}
apps/desktop/src/sshEnvironment.test.ts created+98
...@@ -0,0 +1,98 @@
1import * as FS from "node:fs";
2import * as OS from "node:os";
3import * as Path from "node:path";
4
5import { afterEach, describe, expect, it } from "vitest";
6
7import { SshPasswordPromptError } from "@t3tools/ssh/errors";
8
9import { discoverDesktopSshHosts, isSshPasswordPromptCancellation } from "./sshEnvironment.ts";
10
11const tempDirectories: string[] = [];
12
13afterEach(() => {
14 for (const directory of tempDirectories.splice(0)) {
15 FS.rmSync(directory, { recursive: true, force: true });
16 }
17});
18
19function makeTempHomeDir(): string {
20 const directory = FS.mkdtempSync(Path.join(OS.tmpdir(), "t3-ssh-env-test-"));
21 tempDirectories.push(directory);
22 return directory;
23}
24
25describe("sshEnvironment", () => {
26 it("treats password prompt timeouts as cancellable authentication prompts", () => {
27 expect(
28 isSshPasswordPromptCancellation(
29 new SshPasswordPromptError({
30 message: "SSH authentication timed out for devbox.",
31 }),
32 ),
33 ).toBe(true);
34 });
35
36 it("wires desktop host discovery through the ssh package runtime", async () => {
37 const homeDir = makeTempHomeDir();
38 const sshDir = Path.join(homeDir, ".ssh");
39 FS.mkdirSync(Path.join(sshDir, "config.d"), { recursive: true });
40 FS.writeFileSync(
41 Path.join(sshDir, "config"),
42 ["Host devbox", " HostName devbox.example.com", "Include config.d/*.conf", ""].join("\n"),
43 "utf8",
44 );
45 FS.writeFileSync(
46 Path.join(sshDir, "config.d", "team.conf"),
47 [
48 "Host staging",
49 " HostName staging.example.com",
50 "Host *",
51 " ServerAliveInterval 30",
52 "",
53 ].join("\n"),
54 "utf8",
55 );
56 FS.writeFileSync(
57 Path.join(sshDir, "known_hosts"),
58 [
59 "known.example.com ssh-ed25519 AAAA",
60 "|1|hashed|entry ssh-ed25519 AAAA",
61 "[bastion.example.com]:2222 ssh-ed25519 AAAA",
62 "",
63 ].join("\n"),
64 "utf8",
65 );
66
67 await expect(discoverDesktopSshHosts({ homeDir })).resolves.toEqual([
68 {
69 alias: "bastion.example.com",
70 hostname: "bastion.example.com",
71 username: null,
72 port: null,
73 source: "known-hosts",
74 },
75 {
76 alias: "devbox",
77 hostname: "devbox",
78 username: null,
79 port: null,
80 source: "ssh-config",
81 },
82 {
83 alias: "known.example.com",
84 hostname: "known.example.com",
85 username: null,
86 port: null,
87 source: "known-hosts",
88 },
89 {
90 alias: "staging",
91 hostname: "staging",
92 username: null,
93 port: null,
94 source: "ssh-config",
95 },
96 ]);
97 });
98});
apps/desktop/src/sshEnvironment.ts created+420
...@@ -0,0 +1,420 @@
1import * as Crypto from "node:crypto";
2
3import * as NodeHttpClient from "@effect/platform-node/NodeHttpClient";
4import * as NodeServices from "@effect/platform-node/NodeServices";
5import { NetService } from "@t3tools/shared/Net";
6import type {
7 AuthBearerBootstrapResult,
8 AuthSessionState,
9 AuthWebSocketTokenResult,
10 DesktopDiscoveredSshHost,
11 DesktopSshEnvironmentTarget,
12 DesktopSshPasswordPromptRequest,
13 ExecutionEnvironmentDescriptor,
14} from "@t3tools/contracts";
15import {
16 SshPasswordPrompt,
17 type SshPasswordPromptShape,
18 type SshPasswordRequest,
19} from "@t3tools/ssh/auth";
20import { discoverSshHosts } from "@t3tools/ssh/config";
21import { SshPasswordPromptError } from "@t3tools/ssh/errors";
22import {
23 fetchLoopbackSshJson,
24 SshEnvironmentManager,
25 type RemoteT3RunnerOptions,
26} from "@t3tools/ssh/tunnel";
27import { Effect, Exit, Layer, ManagedRuntime, Scope } from "effect";
28
29export { resolveRemoteT3CliPackageSpec } from "@t3tools/ssh/command";
30
31const DISCOVER_SSH_HOSTS_CHANNEL = "desktop:discover-ssh-hosts";
32const ENSURE_SSH_ENVIRONMENT_CHANNEL = "desktop:ensure-ssh-environment";
33const DISCONNECT_SSH_ENVIRONMENT_CHANNEL = "desktop:disconnect-ssh-environment";
34const FETCH_SSH_ENVIRONMENT_DESCRIPTOR_CHANNEL = "desktop:fetch-ssh-environment-descriptor";
35const BOOTSTRAP_SSH_BEARER_SESSION_CHANNEL = "desktop:bootstrap-ssh-bearer-session";
36const FETCH_SSH_SESSION_STATE_CHANNEL = "desktop:fetch-ssh-session-state";
37const ISSUE_SSH_WEBSOCKET_TOKEN_CHANNEL = "desktop:issue-ssh-websocket-token";
38const SSH_PASSWORD_PROMPT_CHANNEL = "desktop:ssh-password-prompt";
39const RESOLVE_SSH_PASSWORD_PROMPT_CHANNEL = "desktop:resolve-ssh-password-prompt";
40const DEFAULT_SSH_PASSWORD_PROMPT_TIMEOUT_MS = 3 * 60 * 1000;
41const SSH_PASSWORD_PROMPT_CANCELLED_RESULT = "ssh-password-prompt-cancelled";
42
43interface DesktopSshEnvironmentManagerOptions {
44 readonly passwordProvider?: (request: SshPasswordRequest) => Promise<string | null>;
45 readonly resolveCliPackageSpec?: () => string;
46 readonly resolveCliRunner?: () => RemoteT3RunnerOptions;
47}
48
49const sshRuntime = ManagedRuntime.make(
50 Layer.mergeAll(NodeServices.layer, NodeHttpClient.layerUndici, NetService.layer),
51);
52
53function createDesktopSshRuntime(
54 passwordPrompt: SshPasswordPromptShape,
55 scope: Scope.Scope,
56 options: DesktopSshEnvironmentManagerOptions,
57) {
58 return ManagedRuntime.make(
59 Layer.mergeAll(
60 NodeServices.layer,
61 NodeHttpClient.layerUndici,
62 NetService.layer,
63 Layer.succeed(Scope.Scope, scope),
64 Layer.succeed(SshPasswordPrompt, SshPasswordPrompt.of(passwordPrompt)),
65 SshEnvironmentManager.layer({
66 ...(options.resolveCliPackageSpec === undefined
67 ? {}
68 : { resolveCliPackageSpec: options.resolveCliPackageSpec }),
69 ...(options.resolveCliRunner === undefined
70 ? {}
71 : { resolveCliRunner: options.resolveCliRunner }),
72 }),
73 ),
74 );
75}
76
77export async function discoverDesktopSshHosts(input?: {
78 readonly homeDir?: string;
79}): Promise<readonly DesktopDiscoveredSshHost[]> {
80 return await sshRuntime.runPromise(discoverSshHosts(input ?? {}));
81}
82
83export class DesktopSshEnvironmentManager {
84 private readonly runtime: ReturnType<typeof createDesktopSshRuntime>;
85 private readonly scope: Scope.Scope;
86
87 constructor(options: DesktopSshEnvironmentManagerOptions = {}) {
88 const passwordPrompt: SshPasswordPromptShape = {
89 isAvailable: options.passwordProvider !== undefined,
90 request: (request) => {
91 const passwordProvider = options.passwordProvider;
92 if (!passwordProvider) {
93 return Effect.succeed(null);
94 }
95
96 return Effect.tryPromise({
97 try: () => passwordProvider(request),
98 catch: (cause) =>
99 new SshPasswordPromptError({
100 message: cause instanceof Error ? cause.message : "SSH password prompt failed.",
101 cause,
102 }),
103 });
104 },
105 };
106 this.scope = Effect.runSync(Scope.make());
107 this.runtime = createDesktopSshRuntime(passwordPrompt, this.scope, options);
108 }
109
110 async discoverHosts(): Promise<readonly DesktopDiscoveredSshHost[]> {
111 return await discoverDesktopSshHosts();
112 }
113
114 async ensureEnvironment(
115 target: DesktopSshEnvironmentTarget,
116 options?: { readonly issuePairingToken?: boolean },
117 ) {
118 return await this.runtime.runPromise(
119 Effect.service(SshEnvironmentManager).pipe(
120 Effect.flatMap((manager) => manager.ensureEnvironment(target, options)),
121 ),
122 );
123 }
124
125 async disconnectEnvironment(target: DesktopSshEnvironmentTarget): Promise<void> {
126 await this.runtime.runPromise(
127 Effect.service(SshEnvironmentManager).pipe(
128 Effect.flatMap((manager) => manager.disconnectEnvironment(target)),
129 ),
130 );
131 }
132
133 async dispose(): Promise<void> {
134 await this.runtime.runPromise(Scope.close(this.scope, Exit.void));
135 await this.runtime.dispose();
136 }
137}
138
139function getSafeDesktopSshTarget(rawTarget: unknown): DesktopSshEnvironmentTarget | null {
140 if (typeof rawTarget !== "object" || rawTarget === null) {
141 return null;
142 }
143
144 const target = rawTarget as Partial<DesktopSshEnvironmentTarget>;
145 if (typeof target.alias !== "string" || typeof target.hostname !== "string") {
146 return null;
147 }
148 if (
149 target.username !== null &&
150 target.username !== undefined &&
151 typeof target.username !== "string"
152 ) {
153 return null;
154 }
155 if (target.port !== null && target.port !== undefined && !Number.isInteger(target.port)) {
156 return null;
157 }
158
159 const alias = target.alias.trim();
160 const hostname = target.hostname.trim();
161 if (alias.length === 0 || hostname.length === 0) {
162 return null;
163 }
164
165 return {
166 alias,
167 hostname,
168 username: target.username?.trim() || null,
169 port: target.port ?? null,
170 };
171}
172
173/** Minimal subset of Electron's BrowserWindow used by the SSH bridge. */
174export interface DesktopSshBridgeWindow {
175 isDestroyed(): boolean;
176 isMinimized(): boolean;
177 restore(): void;
178 focus(): void;
179 readonly webContents: {
180 send(channel: string, ...args: readonly unknown[]): void;
181 };
182}
183
184/** Minimal subset of Electron's ipcMain used by the SSH bridge. */
185export interface DesktopSshBridgeIpcMain {
186 removeHandler(channel: string): void;
187 handle(
188 channel: string,
189 listener: (event: unknown, ...args: readonly unknown[]) => unknown | Promise<unknown>,
190 ): void;
191}
192
193export interface DesktopSshEnvironmentBridgeOptions {
194 readonly getMainWindow: () => DesktopSshBridgeWindow | null;
195 readonly resolveCliPackageSpec?: () => string;
196 readonly resolveCliRunner?: () => RemoteT3RunnerOptions;
197 readonly passwordPromptTimeoutMs?: number;
198}
199
200interface PendingSshPasswordPrompt {
201 readonly resolve: (password: string | null) => void;
202 readonly reject: (error: Error) => void;
203 readonly timeout: ReturnType<typeof setTimeout>;
204}
205
206export function isSshPasswordPromptCancellation(error: unknown): error is SshPasswordPromptError {
207 const message = error instanceof SshPasswordPromptError ? error.message.toLowerCase() : "";
208 return (
209 error instanceof SshPasswordPromptError &&
210 (message.includes("cancelled") || message.includes("timed out"))
211 );
212}
213
214/**
215 * Wires the SSH environment manager to Electron IPC, owning the renderer-facing
216 * password prompt state so `main.ts` only needs to register, cancel, and dispose.
217 */
218export class DesktopSshEnvironmentBridge {
219 private readonly options: DesktopSshEnvironmentBridgeOptions;
220 private readonly manager: DesktopSshEnvironmentManager;
221 private readonly pendingPrompts = new Map<string, PendingSshPasswordPrompt>();
222 private readonly passwordPromptTimeoutMs: number;
223
224 constructor(options: DesktopSshEnvironmentBridgeOptions) {
225 this.options = options;
226 this.passwordPromptTimeoutMs =
227 options.passwordPromptTimeoutMs ?? DEFAULT_SSH_PASSWORD_PROMPT_TIMEOUT_MS;
228 this.manager = new DesktopSshEnvironmentManager({
229 passwordProvider: (request) => this.requestPasswordFromRenderer(request),
230 ...(options.resolveCliPackageSpec === undefined
231 ? {}
232 : { resolveCliPackageSpec: options.resolveCliPackageSpec }),
233 ...(options.resolveCliRunner === undefined
234 ? {}
235 : { resolveCliRunner: options.resolveCliRunner }),
236 });
237 }
238
239 registerIpcHandlers(ipcMain: DesktopSshBridgeIpcMain): void {
240 ipcMain.removeHandler(DISCOVER_SSH_HOSTS_CHANNEL);
241 ipcMain.handle(DISCOVER_SSH_HOSTS_CHANNEL, async () => this.manager.discoverHosts());
242
243 ipcMain.removeHandler(ENSURE_SSH_ENVIRONMENT_CHANNEL);
244 ipcMain.handle(ENSURE_SSH_ENVIRONMENT_CHANNEL, async (_event, rawTarget, rawOptions) => {
245 const target = getSafeDesktopSshTarget(rawTarget);
246 if (!target) {
247 throw new Error("Invalid desktop SSH target.");
248 }
249
250 const issuePairingToken =
251 typeof rawOptions === "object" &&
252 rawOptions !== null &&
253 "issuePairingToken" in rawOptions &&
254 (rawOptions as { issuePairingToken?: unknown }).issuePairingToken === true;
255
256 try {
257 return await this.manager.ensureEnvironment(target, {
258 issuePairingToken,
259 });
260 } catch (error) {
261 if (isSshPasswordPromptCancellation(error)) {
262 return {
263 type: SSH_PASSWORD_PROMPT_CANCELLED_RESULT,
264 message: error.message,
265 };
266 }
267 throw error;
268 }
269 });
270
271 ipcMain.removeHandler(DISCONNECT_SSH_ENVIRONMENT_CHANNEL);
272 ipcMain.handle(DISCONNECT_SSH_ENVIRONMENT_CHANNEL, async (_event, rawTarget) => {
273 const target = getSafeDesktopSshTarget(rawTarget);
274 if (!target) {
275 throw new Error("Invalid desktop SSH target.");
276 }
277
278 await this.manager.disconnectEnvironment(target);
279 });
280
281 ipcMain.removeHandler(FETCH_SSH_ENVIRONMENT_DESCRIPTOR_CHANNEL);
282 ipcMain.handle(FETCH_SSH_ENVIRONMENT_DESCRIPTOR_CHANNEL, async (_event, rawHttpBaseUrl) =>
283 sshRuntime.runPromise(
284 fetchLoopbackSshJson<ExecutionEnvironmentDescriptor>({
285 httpBaseUrl: rawHttpBaseUrl,
286 pathname: "/.well-known/t3/environment",
287 }),
288 ),
289 );
290
291 ipcMain.removeHandler(BOOTSTRAP_SSH_BEARER_SESSION_CHANNEL);
292 ipcMain.handle(
293 BOOTSTRAP_SSH_BEARER_SESSION_CHANNEL,
294 async (_event, rawHttpBaseUrl, rawCredential) =>
295 sshRuntime.runPromise(
296 fetchLoopbackSshJson<AuthBearerBootstrapResult>({
297 httpBaseUrl: rawHttpBaseUrl,
298 pathname: "/api/auth/bootstrap/bearer",
299 method: "POST",
300 body: { credential: rawCredential },
301 }),
302 ),
303 );
304
305 ipcMain.removeHandler(FETCH_SSH_SESSION_STATE_CHANNEL);
306 ipcMain.handle(
307 FETCH_SSH_SESSION_STATE_CHANNEL,
308 async (_event, rawHttpBaseUrl, rawBearerToken) =>
309 sshRuntime.runPromise(
310 fetchLoopbackSshJson<AuthSessionState>({
311 httpBaseUrl: rawHttpBaseUrl,
312 pathname: "/api/auth/session",
313 bearerToken: rawBearerToken,
314 }),
315 ),
316 );
317
318 ipcMain.removeHandler(ISSUE_SSH_WEBSOCKET_TOKEN_CHANNEL);
319 ipcMain.handle(
320 ISSUE_SSH_WEBSOCKET_TOKEN_CHANNEL,
321 async (_event, rawHttpBaseUrl, rawBearerToken) =>
322 sshRuntime.runPromise(
323 fetchLoopbackSshJson<AuthWebSocketTokenResult>({
324 httpBaseUrl: rawHttpBaseUrl,
325 pathname: "/api/auth/ws-token",
326 method: "POST",
327 bearerToken: rawBearerToken,
328 }),
329 ),
330 );
331
332 ipcMain.removeHandler(RESOLVE_SSH_PASSWORD_PROMPT_CHANNEL);
333 ipcMain.handle(
334 RESOLVE_SSH_PASSWORD_PROMPT_CHANNEL,
335 async (_event, rawRequestId, rawPassword) => {
336 if (typeof rawRequestId !== "string" || rawRequestId.trim().length === 0) {
337 throw new Error("Invalid SSH password prompt id.");
338 }
339 if (rawPassword !== null && typeof rawPassword !== "string") {
340 throw new Error("Invalid SSH password prompt response.");
341 }
342
343 const pending = this.pendingPrompts.get(rawRequestId);
344 if (!pending) {
345 throw new Error("SSH password prompt expired. Try connecting again.");
346 }
347
348 clearTimeout(pending.timeout);
349 this.pendingPrompts.delete(rawRequestId);
350 pending.resolve(rawPassword);
351 },
352 );
353 }
354
355 cancelPendingPasswordPrompts(reason: string): void {
356 for (const [requestId, pending] of this.pendingPrompts) {
357 clearTimeout(pending.timeout);
358 this.pendingPrompts.delete(requestId);
359 pending.reject(new Error(reason));
360 }
361 }
362
363 async dispose(): Promise<void> {
364 this.cancelPendingPasswordPrompts("SSH environment bridge disposed.");
365 await this.manager.dispose();
366 }
367
368 private async requestPasswordFromRenderer(input: SshPasswordRequest): Promise<string | null> {
369 const window = this.options.getMainWindow();
370 if (!window || window.isDestroyed()) {
371 throw new Error("T3 Code window is not available for SSH authentication.");
372 }
373
374 const request: DesktopSshPasswordPromptRequest = {
375 requestId: Crypto.randomUUID(),
376 destination: input.destination,
377 username: input.username,
378 prompt: input.prompt,
379 expiresAt: new Date(Date.now() + this.passwordPromptTimeoutMs).toISOString(),
380 };
381
382 return await new Promise<string | null>((resolve, reject) => {
383 const rejectPrompt = (error: Error) => {
384 clearTimeout(timeout);
385 this.pendingPrompts.delete(request.requestId);
386 reject(error);
387 };
388 const timeout = setTimeout(() => {
389 this.pendingPrompts.delete(request.requestId);
390 reject(new Error(`SSH authentication timed out for ${input.destination}.`));
391 }, this.passwordPromptTimeoutMs);
392 timeout.unref();
393
394 this.pendingPrompts.set(request.requestId, { resolve, reject, timeout });
395
396 try {
397 if (window.isDestroyed()) {
398 throw new Error("T3 Code window is not available for SSH authentication.");
399 }
400 window.webContents.send(SSH_PASSWORD_PROMPT_CHANNEL, request);
401 if (window.isDestroyed()) {
402 throw new Error("T3 Code window is not available for SSH authentication.");
403 }
404 if (window.isMinimized()) {
405 window.restore();
406 }
407 if (window.isDestroyed()) {
408 throw new Error("T3 Code window is not available for SSH authentication.");
409 }
410 window.focus();
411 } catch (error) {
412 rejectPrompt(
413 error instanceof Error
414 ? error
415 : new Error("T3 Code window is not available for SSH authentication."),
416 );
417 }
418 });
419 }
420}
apps/desktop/src/tailscaleEndpointProvider.test.ts created+122
...@@ -0,0 +1,122 @@
1import { describe, expect, it } from "vitest";
2import { Effect } from "effect";
3
4import {
5 isTailscaleIpv4Address,
6 parseTailscaleMagicDnsName,
7 resolveTailscaleAdvertisedEndpoints,
8} from "./tailscaleEndpointProvider.ts";
9
10describe("tailscale endpoint provider", () => {
11 it("detects Tailnet IPv4 addresses", () => {
12 expect(isTailscaleIpv4Address("100.64.0.1")).toBe(true);
13 expect(isTailscaleIpv4Address("100.127.255.254")).toBe(true);
14 expect(isTailscaleIpv4Address("100.128.0.1")).toBe(false);
15 expect(isTailscaleIpv4Address("192.168.1.44")).toBe(false);
16 });
17
18 it("parses MagicDNS names from tailscale status", async () => {
19 expect(
20 Effect.runSync(
21 parseTailscaleMagicDnsName(JSON.stringify({ Self: { DNSName: "desktop.tail.ts.net." } })),
22 ),
23 ).toBe("desktop.tail.ts.net");
24 expect(Effect.runSync(parseTailscaleMagicDnsName("{}"))).toBeNull();
25 await expect(Effect.runPromise(parseTailscaleMagicDnsName("not-json"))).rejects.toBeDefined();
26 });
27
28 it("resolves Tailscale endpoints as add-on advertised endpoints", async () => {
29 await expect(
30 resolveTailscaleAdvertisedEndpoints({
31 port: 3773,
32 networkInterfaces: {
33 tailscale0: [
34 {
35 address: "100.100.100.100",
36 family: "IPv4",
37 internal: false,
38 netmask: "255.192.0.0",
39 cidr: "100.100.100.100/10",
40 mac: "00:00:00:00:00:00",
41 },
42 ],
43 },
44 statusJson: JSON.stringify({ Self: { DNSName: "desktop.tail.ts.net." } }),
45 }),
46 ).resolves.toEqual([
47 {
48 id: "tailscale-ip:http://100.100.100.100:3773",
49 label: "Tailscale IP",
50 provider: {
51 id: "tailscale",
52 label: "Tailscale",
53 kind: "private-network",
54 isAddon: true,
55 },
56 httpBaseUrl: "http://100.100.100.100:3773/",
57 wsBaseUrl: "ws://100.100.100.100:3773/",
58 reachability: "private-network",
59 compatibility: {
60 hostedHttpsApp: "mixed-content-blocked",
61 desktopApp: "compatible",
62 },
63 source: "desktop-addon",
64 status: "available",
65 description: "Reachable from devices on the same Tailnet.",
66 },
67 {
68 id: "tailscale-magicdns:https://desktop.tail.ts.net/",
69 label: "Tailscale HTTPS",
70 provider: {
71 id: "tailscale",
72 label: "Tailscale",
73 kind: "private-network",
74 isAddon: true,
75 },
76 httpBaseUrl: "https://desktop.tail.ts.net/",
77 wsBaseUrl: "wss://desktop.tail.ts.net/",
78 reachability: "private-network",
79 compatibility: {
80 hostedHttpsApp: "requires-configuration",
81 desktopApp: "compatible",
82 },
83 source: "desktop-addon",
84 status: "unavailable",
85 description: "MagicDNS hostname. Configure Tailscale Serve for HTTPS access.",
86 },
87 ]);
88 });
89
90 it("marks the Tailscale HTTPS endpoint available after Serve is enabled and reachable", async () => {
91 await expect(
92 resolveTailscaleAdvertisedEndpoints({
93 port: 3773,
94 networkInterfaces: {},
95 statusJson: JSON.stringify({ Self: { DNSName: "desktop.tail.ts.net." } }),
96 serveEnabled: true,
97 probe: async () => true,
98 }),
99 ).resolves.toEqual([
100 {
101 id: "tailscale-magicdns:https://desktop.tail.ts.net/",
102 label: "Tailscale HTTPS",
103 provider: {
104 id: "tailscale",
105 label: "Tailscale",
106 kind: "private-network",
107 isAddon: true,
108 },
109 httpBaseUrl: "https://desktop.tail.ts.net/",
110 wsBaseUrl: "wss://desktop.tail.ts.net/",
111 reachability: "private-network",
112 compatibility: {
113 hostedHttpsApp: "compatible",
114 desktopApp: "compatible",
115 },
116 source: "desktop-addon",
117 status: "available",
118 description: "HTTPS endpoint served by Tailscale Serve.",
119 },
120 ]);
121 });
122});
apps/desktop/src/tailscaleEndpointProvider.ts created+142
...@@ -0,0 +1,142 @@
1import type { NetworkInterfaceInfo } from "node:os";
2
3import * as NodeHttpClient from "@effect/platform-node/NodeHttpClient";
4import * as NodeServices from "@effect/platform-node/NodeServices";
5import {
6 createAdvertisedEndpoint,
7 type CreateAdvertisedEndpointInput,
8} from "@t3tools/client-runtime";
9import type { AdvertisedEndpoint, AdvertisedEndpointProvider } from "@t3tools/contracts";
10import {
11 buildTailscaleHttpsBaseUrl,
12 isTailscaleIpv4Address,
13 parseTailscaleMagicDnsName,
14 probeTailscaleHttpsEndpoint,
15 readTailscaleStatus,
16} from "@t3tools/tailscale";
17import { Effect, Layer } from "effect";
18
19export { isTailscaleIpv4Address, parseTailscaleMagicDnsName } from "@t3tools/tailscale";
20
21const TailscaleDesktopLayer = Layer.mergeAll(NodeServices.layer, NodeHttpClient.layerUndici);
22
23const TAILSCALE_ENDPOINT_PROVIDER: AdvertisedEndpointProvider = {
24 id: "tailscale",
25 label: "Tailscale",
26 kind: "private-network",
27 isAddon: true,
28};
29
30function createTailscaleEndpoint(
31 input: Omit<CreateAdvertisedEndpointInput, "provider" | "source">,
32): AdvertisedEndpoint {
33 return createAdvertisedEndpoint({
34 ...input,
35 provider: TAILSCALE_ENDPOINT_PROVIDER,
36 source: "desktop-addon",
37 });
38}
39
40export function resolveTailscaleIpAdvertisedEndpoints(input: {
41 readonly port: number;
42 readonly networkInterfaces: NodeJS.Dict<NetworkInterfaceInfo[]>;
43}): readonly AdvertisedEndpoint[] {
44 const seen = new Set<string>();
45 const endpoints: AdvertisedEndpoint[] = [];
46
47 for (const interfaceAddresses of Object.values(input.networkInterfaces)) {
48 if (!interfaceAddresses) continue;
49
50 for (const address of interfaceAddresses) {
51 if (address.internal) continue;
52 if (address.family !== "IPv4") continue;
53 if (!isTailscaleIpv4Address(address.address)) continue;
54 if (seen.has(address.address)) continue;
55 seen.add(address.address);
56
57 endpoints.push(
58 createTailscaleEndpoint({
59 id: `tailscale-ip:http://${address.address}:${input.port}`,
60 label: "Tailscale IP",
61 httpBaseUrl: `http://${address.address}:${input.port}`,
62 reachability: "private-network",
63 status: "available",
64 description: "Reachable from devices on the same Tailnet.",
65 }),
66 );
67 }
68 }
69
70 return endpoints;
71}
72
73export async function resolveTailscaleMagicDnsAdvertisedEndpoint(input: {
74 readonly dnsName: string | null;
75 readonly serveEnabled: boolean;
76 readonly servePort?: number;
77 readonly probe?: (baseUrl: string) => Promise<boolean>;
78}): Promise<AdvertisedEndpoint | null> {
79 if (!input.dnsName) {
80 return null;
81 }
82
83 const httpBaseUrl = buildTailscaleHttpsBaseUrl({
84 magicDnsName: input.dnsName,
85 ...(input.servePort === undefined ? {} : { servePort: input.servePort }),
86 });
87 const isReachable = input.serveEnabled
88 ? await (input.probe?.(httpBaseUrl) ??
89 Effect.runPromise(
90 probeTailscaleHttpsEndpoint({ baseUrl: httpBaseUrl }).pipe(
91 Effect.provide(TailscaleDesktopLayer),
92 ),
93 ))
94 : false;
95
96 return createTailscaleEndpoint({
97 id: `tailscale-magicdns:${httpBaseUrl}`,
98 label: "Tailscale HTTPS",
99 httpBaseUrl,
100 reachability: "private-network",
101 hostedHttpsCompatibility: isReachable ? "compatible" : "requires-configuration",
102 status: isReachable ? "available" : "unavailable",
103 description: isReachable
104 ? "HTTPS endpoint served by Tailscale Serve."
105 : "MagicDNS hostname. Configure Tailscale Serve for HTTPS access.",
106 });
107}
108
109export async function resolveTailscaleAdvertisedEndpoints(input: {
110 readonly port: number;
111 readonly serveEnabled?: boolean;
112 readonly servePort?: number;
113 readonly networkInterfaces: NodeJS.Dict<NetworkInterfaceInfo[]>;
114 readonly statusJson?: string | null;
115 readonly probe?: (baseUrl: string) => Promise<boolean>;
116}): Promise<readonly AdvertisedEndpoint[]> {
117 const ipEndpoints = resolveTailscaleIpAdvertisedEndpoints(input);
118 const dnsName =
119 input.statusJson === undefined
120 ? await Effect.runPromise(
121 readTailscaleStatus.pipe(
122 Effect.map((status) => status.magicDnsName),
123 Effect.catch(() => Effect.succeed(null)),
124 Effect.provide(TailscaleDesktopLayer),
125 ),
126 )
127 : input.statusJson
128 ? await Effect.runPromise(
129 parseTailscaleMagicDnsName(input.statusJson).pipe(
130 Effect.catch(() => Effect.succeed(null)),
131 ),
132 )
133 : null;
134 const magicDnsEndpoint = await resolveTailscaleMagicDnsAdvertisedEndpoint({
135 dnsName,
136 serveEnabled: input.serveEnabled === true,
137 ...(input.servePort === undefined ? {} : { servePort: input.servePort }),
138 ...(input.probe === undefined ? {} : { probe: input.probe }),
139 });
140
141 return magicDnsEndpoint ? [...ipEndpoints, magicDnsEndpoint] : ipEndpoints;
142}
apps/server/package.json+1
...@@ -40,6 +40,7 @@...@@ -40,6 +40,7 @@
40 "@effect/vitest": "catalog:",40 "@effect/vitest": "catalog:",
41 "@t3tools/contracts": "workspace:*",41 "@t3tools/contracts": "workspace:*",
42 "@t3tools/shared": "workspace:*",42 "@t3tools/shared": "workspace:*",
43 "@t3tools/tailscale": "workspace:*",
43 "@t3tools/web": "workspace:*",44 "@t3tools/web": "workspace:*",
44 "@types/bun": "catalog:",45 "@types/bun": "catalog:",
45 "@types/node": "catalog:",46 "@types/node": "catalog:",
apps/server/src/cli-config.test.ts+36
...@@ -46,6 +46,8 @@ it.layer(NodeServices.layer)("cli config resolution", (it) => {...@@ -46,6 +46,8 @@ it.layer(NodeServices.layer)("cli config resolution", (it) => {
46 bootstrapFd: Option.none(),46 bootstrapFd: Option.none(),
47 autoBootstrapProjectFromCwd: Option.none(),47 autoBootstrapProjectFromCwd: Option.none(),
48 logWebSocketEvents: Option.none(),48 logWebSocketEvents: Option.none(),
49 tailscaleServeEnabled: Option.none(),
50 tailscaleServePort: Option.none(),
49 },51 },
50 Option.none(),52 Option.none(),
51 ).pipe(53 ).pipe(
...@@ -87,6 +89,8 @@ it.layer(NodeServices.layer)("cli config resolution", (it) => {...@@ -87,6 +89,8 @@ it.layer(NodeServices.layer)("cli config resolution", (it) => {
87 desktopBootstrapToken: undefined,89 desktopBootstrapToken: undefined,
88 autoBootstrapProjectFromCwd: false,90 autoBootstrapProjectFromCwd: false,
89 logWebSocketEvents: true,91 logWebSocketEvents: true,
92 tailscaleServeEnabled: false,
93 tailscaleServePort: 443,
90 });94 });
91 }),95 }),
92 );96 );
...@@ -108,6 +112,8 @@ it.layer(NodeServices.layer)("cli config resolution", (it) => {...@@ -108,6 +112,8 @@ it.layer(NodeServices.layer)("cli config resolution", (it) => {
108 bootstrapFd: Option.none(),112 bootstrapFd: Option.none(),
109 autoBootstrapProjectFromCwd: Option.some(true),113 autoBootstrapProjectFromCwd: Option.some(true),
110 logWebSocketEvents: Option.some(true),114 logWebSocketEvents: Option.some(true),
115 tailscaleServeEnabled: Option.some(true),
116 tailscaleServePort: Option.some(8443),
111 },117 },
112 Option.some("Debug"),118 Option.some("Debug"),
113 ).pipe(119 ).pipe(
...@@ -149,6 +155,8 @@ it.layer(NodeServices.layer)("cli config resolution", (it) => {...@@ -149,6 +155,8 @@ it.layer(NodeServices.layer)("cli config resolution", (it) => {
149 desktopBootstrapToken: undefined,155 desktopBootstrapToken: undefined,
150 autoBootstrapProjectFromCwd: true,156 autoBootstrapProjectFromCwd: true,
151 logWebSocketEvents: true,157 logWebSocketEvents: true,
158 tailscaleServeEnabled: true,
159 tailscaleServePort: 8443,
152 });160 });
153 }),161 }),
154 );162 );
...@@ -161,6 +169,8 @@ it.layer(NodeServices.layer)("cli config resolution", (it) => {...@@ -161,6 +169,8 @@ it.layer(NodeServices.layer)("cli config resolution", (it) => {
161 noBrowser: true,169 noBrowser: true,
162 autoBootstrapProjectFromCwd: true,170 autoBootstrapProjectFromCwd: true,
163 logWebSocketEvents: true,171 logWebSocketEvents: true,
172 tailscaleServeEnabled: false,
173 tailscaleServePort: 443,
164 });174 });
165 const derivedPaths = yield* deriveServerPaths(baseDir, new URL("http://127.0.0.1:4173"));175 const derivedPaths = yield* deriveServerPaths(baseDir, new URL("http://127.0.0.1:4173"));
166176
...@@ -176,6 +186,8 @@ it.layer(NodeServices.layer)("cli config resolution", (it) => {...@@ -176,6 +186,8 @@ it.layer(NodeServices.layer)("cli config resolution", (it) => {
176 bootstrapFd: Option.none(),186 bootstrapFd: Option.none(),
177 autoBootstrapProjectFromCwd: Option.some(false),187 autoBootstrapProjectFromCwd: Option.some(false),
178 logWebSocketEvents: Option.some(false),188 logWebSocketEvents: Option.some(false),
189 tailscaleServeEnabled: Option.none(),
190 tailscaleServePort: Option.none(),
179 },191 },
180 Option.none(),192 Option.none(),
181 ).pipe(193 ).pipe(
...@@ -212,6 +224,8 @@ it.layer(NodeServices.layer)("cli config resolution", (it) => {...@@ -212,6 +224,8 @@ it.layer(NodeServices.layer)("cli config resolution", (it) => {
212 desktopBootstrapToken: undefined,224 desktopBootstrapToken: undefined,
213 autoBootstrapProjectFromCwd: false,225 autoBootstrapProjectFromCwd: false,
214 logWebSocketEvents: false,226 logWebSocketEvents: false,
227 tailscaleServeEnabled: false,
228 tailscaleServePort: 443,
215 });229 });
216 }),230 }),
217 );231 );
...@@ -229,6 +243,8 @@ it.layer(NodeServices.layer)("cli config resolution", (it) => {...@@ -229,6 +243,8 @@ it.layer(NodeServices.layer)("cli config resolution", (it) => {
229 noBrowser: true,243 noBrowser: true,
230 autoBootstrapProjectFromCwd: false,244 autoBootstrapProjectFromCwd: false,
231 logWebSocketEvents: true,245 logWebSocketEvents: true,
246 tailscaleServeEnabled: false,
247 tailscaleServePort: 443,
232 otlpTracesUrl: "http://localhost:4318/v1/traces",248 otlpTracesUrl: "http://localhost:4318/v1/traces",
233 otlpMetricsUrl: "http://localhost:4318/v1/metrics",249 otlpMetricsUrl: "http://localhost:4318/v1/metrics",
234 });250 });
...@@ -246,6 +262,8 @@ it.layer(NodeServices.layer)("cli config resolution", (it) => {...@@ -246,6 +262,8 @@ it.layer(NodeServices.layer)("cli config resolution", (it) => {
246 bootstrapFd: Option.none(),262 bootstrapFd: Option.none(),
247 autoBootstrapProjectFromCwd: Option.none(),263 autoBootstrapProjectFromCwd: Option.none(),
248 logWebSocketEvents: Option.none(),264 logWebSocketEvents: Option.none(),
265 tailscaleServeEnabled: Option.none(),
266 tailscaleServePort: Option.none(),
249 },267 },
250 Option.none(),268 Option.none(),
251 ).pipe(269 ).pipe(
...@@ -281,6 +299,8 @@ it.layer(NodeServices.layer)("cli config resolution", (it) => {...@@ -281,6 +299,8 @@ it.layer(NodeServices.layer)("cli config resolution", (it) => {
281 desktopBootstrapToken: undefined,299 desktopBootstrapToken: undefined,
282 autoBootstrapProjectFromCwd: false,300 autoBootstrapProjectFromCwd: false,
283 logWebSocketEvents: true,301 logWebSocketEvents: true,
302 tailscaleServeEnabled: false,
303 tailscaleServePort: 443,
284 });304 });
285 assert.equal(join(baseDir, "dev"), resolved.stateDir);305 assert.equal(join(baseDir, "dev"), resolved.stateDir);
286 }),306 }),
...@@ -305,6 +325,8 @@ it.layer(NodeServices.layer)("cli config resolution", (it) => {...@@ -305,6 +325,8 @@ it.layer(NodeServices.layer)("cli config resolution", (it) => {
305 bootstrapFd: Option.none(),325 bootstrapFd: Option.none(),
306 autoBootstrapProjectFromCwd: Option.none(),326 autoBootstrapProjectFromCwd: Option.none(),
307 logWebSocketEvents: Option.none(),327 logWebSocketEvents: Option.none(),
328 tailscaleServeEnabled: Option.none(),
329 tailscaleServePort: Option.none(),
308 },330 },
309 Option.none(),331 Option.none(),
310 ).pipe(332 ).pipe(
...@@ -346,6 +368,8 @@ it.layer(NodeServices.layer)("cli config resolution", (it) => {...@@ -346,6 +368,8 @@ it.layer(NodeServices.layer)("cli config resolution", (it) => {
346 noBrowser: false,368 noBrowser: false,
347 autoBootstrapProjectFromCwd: false,369 autoBootstrapProjectFromCwd: false,
348 logWebSocketEvents: false,370 logWebSocketEvents: false,
371 tailscaleServeEnabled: false,
372 tailscaleServePort: 443,
349 });373 });
350 const derivedPaths = yield* deriveServerPaths(baseDir, new URL("http://127.0.0.1:4173"));374 const derivedPaths = yield* deriveServerPaths(baseDir, new URL("http://127.0.0.1:4173"));
351375
...@@ -361,6 +385,8 @@ it.layer(NodeServices.layer)("cli config resolution", (it) => {...@@ -361,6 +385,8 @@ it.layer(NodeServices.layer)("cli config resolution", (it) => {
361 bootstrapFd: Option.none(),385 bootstrapFd: Option.none(),
362 autoBootstrapProjectFromCwd: Option.none(),386 autoBootstrapProjectFromCwd: Option.none(),
363 logWebSocketEvents: Option.none(),387 logWebSocketEvents: Option.none(),
388 tailscaleServeEnabled: Option.none(),
389 tailscaleServePort: Option.none(),
364 },390 },
365 Option.some("Debug"),391 Option.some("Debug"),
366 ).pipe(392 ).pipe(
...@@ -399,6 +425,8 @@ it.layer(NodeServices.layer)("cli config resolution", (it) => {...@@ -399,6 +425,8 @@ it.layer(NodeServices.layer)("cli config resolution", (it) => {
399 desktopBootstrapToken: undefined,425 desktopBootstrapToken: undefined,
400 autoBootstrapProjectFromCwd: true,426 autoBootstrapProjectFromCwd: true,
401 logWebSocketEvents: true,427 logWebSocketEvents: true,
428 tailscaleServeEnabled: false,
429 tailscaleServePort: 443,
402 });430 });
403 }),431 }),
404 );432 );
...@@ -432,6 +460,8 @@ it.layer(NodeServices.layer)("cli config resolution", (it) => {...@@ -432,6 +460,8 @@ it.layer(NodeServices.layer)("cli config resolution", (it) => {
432 bootstrapFd: Option.none(),460 bootstrapFd: Option.none(),
433 autoBootstrapProjectFromCwd: Option.none(),461 autoBootstrapProjectFromCwd: Option.none(),
434 logWebSocketEvents: Option.none(),462 logWebSocketEvents: Option.none(),
463 tailscaleServeEnabled: Option.none(),
464 tailscaleServePort: Option.none(),
435 },465 },
436 Option.none(),466 Option.none(),
437 ).pipe(467 ).pipe(
...@@ -463,6 +493,8 @@ it.layer(NodeServices.layer)("cli config resolution", (it) => {...@@ -463,6 +493,8 @@ it.layer(NodeServices.layer)("cli config resolution", (it) => {
463 desktopBootstrapToken: undefined,493 desktopBootstrapToken: undefined,
464 autoBootstrapProjectFromCwd: false,494 autoBootstrapProjectFromCwd: false,
465 logWebSocketEvents: false,495 logWebSocketEvents: false,
496 tailscaleServeEnabled: false,
497 tailscaleServePort: 443,
466 });498 });
467 }),499 }),
468 );500 );
...@@ -485,6 +517,8 @@ it.layer(NodeServices.layer)("cli config resolution", (it) => {...@@ -485,6 +517,8 @@ it.layer(NodeServices.layer)("cli config resolution", (it) => {
485 bootstrapFd: Option.none(),517 bootstrapFd: Option.none(),
486 autoBootstrapProjectFromCwd: Option.none(),518 autoBootstrapProjectFromCwd: Option.none(),
487 logWebSocketEvents: Option.none(),519 logWebSocketEvents: Option.none(),
520 tailscaleServeEnabled: Option.none(),
521 tailscaleServePort: Option.none(),
488 },522 },
489 Option.none(),523 Option.none(),
490 {524 {
...@@ -522,6 +556,8 @@ it.layer(NodeServices.layer)("cli config resolution", (it) => {...@@ -522,6 +556,8 @@ it.layer(NodeServices.layer)("cli config resolution", (it) => {
522 desktopBootstrapToken: undefined,556 desktopBootstrapToken: undefined,
523 autoBootstrapProjectFromCwd: false,557 autoBootstrapProjectFromCwd: false,
524 logWebSocketEvents: false,558 logWebSocketEvents: false,
559 tailscaleServeEnabled: false,
560 tailscaleServePort: 443,
525 });561 });
526 }),562 }),
527 );563 );
apps/server/src/cli.test.ts+2
...@@ -76,6 +76,8 @@ const makeCliTestServerConfig = (baseDir: string) =>...@@ -76,6 +76,8 @@ const makeCliTestServerConfig = (baseDir: string) =>
76 desktopBootstrapToken: undefined,76 desktopBootstrapToken: undefined,
77 autoBootstrapProjectFromCwd: false,77 autoBootstrapProjectFromCwd: false,
78 logWebSocketEvents: false,78 logWebSocketEvents: false,
79 tailscaleServeEnabled: false,
80 tailscaleServePort: 443,
79 } satisfies ServerConfigShape;81 } satisfies ServerConfigShape;
80 });82 });
8183
apps/server/src/cli.ts+47
...@@ -78,6 +78,8 @@ const BootstrapEnvelopeSchema = Schema.Struct({...@@ -78,6 +78,8 @@ const BootstrapEnvelopeSchema = Schema.Struct({
78 desktopBootstrapToken: Schema.optional(Schema.String),78 desktopBootstrapToken: Schema.optional(Schema.String),
79 autoBootstrapProjectFromCwd: Schema.optional(Schema.Boolean),79 autoBootstrapProjectFromCwd: Schema.optional(Schema.Boolean),
80 logWebSocketEvents: Schema.optional(Schema.Boolean),80 logWebSocketEvents: Schema.optional(Schema.Boolean),
81 tailscaleServeEnabled: Schema.optional(Schema.Boolean),
82 tailscaleServePort: Schema.optional(PortSchema),
81 otlpTracesUrl: Schema.optional(Schema.String),83 otlpTracesUrl: Schema.optional(Schema.String),
82 otlpMetricsUrl: Schema.optional(Schema.String),84 otlpMetricsUrl: Schema.optional(Schema.String),
83});85});
...@@ -126,6 +128,17 @@ const logWebSocketEventsFlag = Flag.boolean("log-websocket-events").pipe(...@@ -126,6 +128,17 @@ const logWebSocketEventsFlag = Flag.boolean("log-websocket-events").pipe(
126 Flag.withAlias("log-ws-events"),128 Flag.withAlias("log-ws-events"),
127 Flag.optional,129 Flag.optional,
128);130);
131const tailscaleServeFlag = Flag.boolean("tailscale-serve").pipe(
132 Flag.withDescription(
133 "Configure Tailscale Serve to expose this backend over HTTPS on the Tailnet.",
134 ),
135 Flag.optional,
136);
137const tailscaleServePortFlag = Flag.integer("tailscale-serve-port").pipe(
138 Flag.withSchema(PortSchema),
139 Flag.withDescription("HTTPS port for Tailscale Serve when --tailscale-serve is enabled."),
140 Flag.optional,
141);
129142
130const EnvServerConfig = Config.all({143const EnvServerConfig = Config.all({
131 logLevel: Config.logLevel("T3CODE_LOG_LEVEL").pipe(Config.withDefault("Info")),144 logLevel: Config.logLevel("T3CODE_LOG_LEVEL").pipe(Config.withDefault("Info")),
...@@ -174,6 +187,14 @@ const EnvServerConfig = Config.all({...@@ -174,6 +187,14 @@ const EnvServerConfig = Config.all({
174 Config.option,187 Config.option,
175 Config.map(Option.getOrUndefined),188 Config.map(Option.getOrUndefined),
176 ),189 ),
190 tailscaleServeEnabled: Config.boolean("T3CODE_TAILSCALE_SERVE").pipe(
191 Config.option,
192 Config.map(Option.getOrUndefined),
193 ),
194 tailscaleServePort: Config.port("T3CODE_TAILSCALE_SERVE_PORT").pipe(
195 Config.option,
196 Config.map(Option.getOrUndefined),
197 ),
177});198});
178199
179interface CliServerFlags {200interface CliServerFlags {
...@@ -187,6 +208,8 @@ interface CliServerFlags {...@@ -187,6 +208,8 @@ interface CliServerFlags {
187 readonly bootstrapFd: Option.Option<number>;208 readonly bootstrapFd: Option.Option<number>;
188 readonly autoBootstrapProjectFromCwd: Option.Option<boolean>;209 readonly autoBootstrapProjectFromCwd: Option.Option<boolean>;
189 readonly logWebSocketEvents: Option.Option<boolean>;210 readonly logWebSocketEvents: Option.Option<boolean>;
211 readonly tailscaleServeEnabled: Option.Option<boolean>;
212 readonly tailscaleServePort: Option.Option<number>;
190}213}
191214
192interface CliAuthLocationFlags {215interface CliAuthLocationFlags {
...@@ -233,6 +256,8 @@ export const resolveServerConfig = (...@@ -233,6 +256,8 @@ export const resolveServerConfig = (
233 bootstrapFd: flags.bootstrapFd ?? Option.none(),256 bootstrapFd: flags.bootstrapFd ?? Option.none(),
234 autoBootstrapProjectFromCwd: flags.autoBootstrapProjectFromCwd ?? Option.none(),257 autoBootstrapProjectFromCwd: flags.autoBootstrapProjectFromCwd ?? Option.none(),
235 logWebSocketEvents: flags.logWebSocketEvents ?? Option.none(),258 logWebSocketEvents: flags.logWebSocketEvents ?? Option.none(),
259 tailscaleServeEnabled: flags.tailscaleServeEnabled ?? Option.none(),
260 tailscaleServePort: flags.tailscaleServePort ?? Option.none(),
236 } satisfies CliServerFlags;261 } satisfies CliServerFlags;
237 const bootstrapFd = Option.getOrUndefined(normalizedFlags.bootstrapFd) ?? env.bootstrapFd;262 const bootstrapFd = Option.getOrUndefined(normalizedFlags.bootstrapFd) ?? env.bootstrapFd;
238 const bootstrapEnvelope =263 const bootstrapEnvelope =
...@@ -323,6 +348,22 @@ export const resolveServerConfig = (...@@ -323,6 +348,22 @@ export const resolveServerConfig = (
323 ),348 ),
324 () => Boolean(devUrl),349 () => Boolean(devUrl),
325 );350 );
351 const tailscaleServeEnabled = Option.getOrElse(
352 resolveOptionPrecedence(
353 normalizedFlags.tailscaleServeEnabled,
354 Option.fromUndefinedOr(env.tailscaleServeEnabled),
355 Option.fromUndefinedOr(bootstrap?.tailscaleServeEnabled),
356 ),
357 () => false,
358 );
359 const tailscaleServePort = Option.getOrElse(
360 resolveOptionPrecedence(
361 normalizedFlags.tailscaleServePort,
362 Option.fromUndefinedOr(env.tailscaleServePort),
363 Option.fromUndefinedOr(bootstrap?.tailscaleServePort),
364 ),
365 () => 443,
366 );
326 const staticDir = devUrl ? undefined : yield* resolveStaticDir();367 const staticDir = devUrl ? undefined : yield* resolveStaticDir();
327 const host = Option.getOrElse(368 const host = Option.getOrElse(
328 resolveOptionPrecedence(369 resolveOptionPrecedence(
...@@ -365,6 +406,8 @@ export const resolveServerConfig = (...@@ -365,6 +406,8 @@ export const resolveServerConfig = (
365 desktopBootstrapToken,406 desktopBootstrapToken,
366 autoBootstrapProjectFromCwd,407 autoBootstrapProjectFromCwd,
367 logWebSocketEvents,408 logWebSocketEvents,
409 tailscaleServeEnabled,
410 tailscaleServePort,
368 };411 };
369412
370 return config;413 return config;
...@@ -386,6 +429,8 @@ const resolveCliAuthConfig = (...@@ -386,6 +429,8 @@ const resolveCliAuthConfig = (
386 bootstrapFd: Option.none(),429 bootstrapFd: Option.none(),
387 autoBootstrapProjectFromCwd: Option.none(),430 autoBootstrapProjectFromCwd: Option.none(),
388 logWebSocketEvents: Option.none(),431 logWebSocketEvents: Option.none(),
432 tailscaleServeEnabled: Option.none(),
433 tailscaleServePort: Option.none(),
389 },434 },
390 cliLogLevel,435 cliLogLevel,
391 );436 );
...@@ -766,6 +811,8 @@ const sharedServerCommandFlags = {...@@ -766,6 +811,8 @@ const sharedServerCommandFlags = {
766 bootstrapFd: bootstrapFdFlag,811 bootstrapFd: bootstrapFdFlag,
767 autoBootstrapProjectFromCwd: autoBootstrapProjectFromCwdFlag,812 autoBootstrapProjectFromCwd: autoBootstrapProjectFromCwdFlag,
768 logWebSocketEvents: logWebSocketEventsFlag,813 logWebSocketEvents: logWebSocketEventsFlag,
814 tailscaleServeEnabled: tailscaleServeFlag,
815 tailscaleServePort: tailscaleServePortFlag,
769} as const;816} as const;
770817
771const authLocationFlags = sharedServerLocationFlags;818const authLocationFlags = sharedServerLocationFlags;
apps/server/src/config.ts+4
...@@ -65,6 +65,8 @@ export interface ServerConfigShape extends ServerDerivedPaths {...@@ -65,6 +65,8 @@ export interface ServerConfigShape extends ServerDerivedPaths {
65 readonly desktopBootstrapToken: string | undefined;65 readonly desktopBootstrapToken: string | undefined;
66 readonly autoBootstrapProjectFromCwd: boolean;66 readonly autoBootstrapProjectFromCwd: boolean;
67 readonly logWebSocketEvents: boolean;67 readonly logWebSocketEvents: boolean;
68 readonly tailscaleServeEnabled: boolean;
69 readonly tailscaleServePort: number;
68}70}
6971
70export const deriveServerPaths = Effect.fn(function* (72export const deriveServerPaths = Effect.fn(function* (
...@@ -158,6 +160,8 @@ export class ServerConfig extends Context.Service<ServerConfig, ServerConfigShap...@@ -158,6 +160,8 @@ export class ServerConfig extends Context.Service<ServerConfig, ServerConfigShap
158 mode: "web",160 mode: "web",
159 autoBootstrapProjectFromCwd: false,161 autoBootstrapProjectFromCwd: false,
160 logWebSocketEvents: false,162 logWebSocketEvents: false,
163 tailscaleServeEnabled: false,
164 tailscaleServePort: 443,
161 port: 0,165 port: 0,
162 host: undefined,166 host: undefined,
163 desktopBootstrapToken: undefined,167 desktopBootstrapToken: undefined,
apps/server/src/environment/Layers/ServerEnvironment.test.ts+2
...@@ -30,6 +30,8 @@ const makeServerConfig = Effect.fn(function* (baseDir: string) {...@@ -30,6 +30,8 @@ const makeServerConfig = Effect.fn(function* (baseDir: string) {
30 mode: "web",30 mode: "web",
31 autoBootstrapProjectFromCwd: false,31 autoBootstrapProjectFromCwd: false,
32 logWebSocketEvents: false,32 logWebSocketEvents: false,
33 tailscaleServeEnabled: false,
34 tailscaleServePort: 443,
33 port: 0,35 port: 0,
34 host: undefined,36 host: undefined,
35 desktopBootstrapToken: undefined,37 desktopBootstrapToken: undefined,
apps/server/src/keybindings.ts+14-278
...@@ -13,14 +13,10 @@ import {...@@ -13,14 +13,10 @@ import {
13 KeybindingShortcut,13 KeybindingShortcut,
14 KeybindingWhenNode,14 KeybindingWhenNode,
15 MAX_KEYBINDINGS_COUNT,15 MAX_KEYBINDINGS_COUNT,
16 MODEL_PICKER_JUMP_KEYBINDING_COMMANDS,
17 MAX_WHEN_EXPRESSION_DEPTH,
18 ResolvedKeybindingRule,16 ResolvedKeybindingRule,
19 ResolvedKeybindingsConfig,17 ResolvedKeybindingsConfig,
20 THREAD_JUMP_KEYBINDING_COMMANDS,
21 type ServerConfigIssue,18 type ServerConfigIssue,
22} from "@t3tools/contracts";19} from "@t3tools/contracts";
23import type { Mutable } from "effect/Types";
24import {20import {
25 Array,21 Array,
26 Cache,22 Cache,
...@@ -48,278 +44,20 @@ import * as Semaphore from "effect/Semaphore";...@@ -48,278 +44,20 @@ import * as Semaphore from "effect/Semaphore";
48import { ServerConfig } from "./config.ts";44import { ServerConfig } from "./config.ts";
49import { writeFileStringAtomically } from "./atomicWrite.ts";45import { writeFileStringAtomically } from "./atomicWrite.ts";
50import { fromLenientJson } from "@t3tools/shared/schemaJson";46import { fromLenientJson } from "@t3tools/shared/schemaJson";
5147import {
52type WhenToken =48 DEFAULT_KEYBINDINGS,
53 | { type: "identifier"; value: string }49 DEFAULT_RESOLVED_KEYBINDINGS,
54 | { type: "not" }50 compileResolvedKeybindingRule,
55 | { type: "and" }51 compileResolvedKeybindingsConfig,
56 | { type: "or" }52 parseKeybindingShortcut,
57 | { type: "lparen" }53} from "@t3tools/shared/keybindings";
58 | { type: "rparen" };54
5955export {
60export const DEFAULT_KEYBINDINGS: ReadonlyArray<KeybindingRule> = [56 DEFAULT_KEYBINDINGS,
61 { key: "mod+j", command: "terminal.toggle" },57 compileResolvedKeybindingRule,
62 { key: "mod+d", command: "terminal.split", when: "terminalFocus" },58 compileResolvedKeybindingsConfig,
63 { key: "mod+n", command: "terminal.new", when: "terminalFocus" },59 parseKeybindingShortcut,
64 { key: "mod+w", command: "terminal.close", when: "terminalFocus" },60};
65 { key: "mod+d", command: "diff.toggle", when: "!terminalFocus" },
66 { key: "mod+k", command: "commandPalette.toggle", when: "!terminalFocus" },
67 { key: "mod+n", command: "chat.new", when: "!terminalFocus" },
68 { key: "mod+shift+o", command: "chat.new", when: "!terminalFocus" },
69 { key: "mod+shift+n", command: "chat.newLocal", when: "!terminalFocus" },
70 { key: "mod+shift+m", command: "modelPicker.toggle", when: "!terminalFocus" },
71 { key: "mod+o", command: "editor.openFavorite" },
72 { key: "mod+shift+[", command: "thread.previous" },
73 { key: "mod+shift+]", command: "thread.next" },
74 ...THREAD_JUMP_KEYBINDING_COMMANDS.map((command, index) => ({
75 key: `mod+${index + 1}`,
76 command,
77 })),
78 ...MODEL_PICKER_JUMP_KEYBINDING_COMMANDS.map((command, index) => ({
79 key: `mod+${index + 1}`,
80 command,
81 when: "modelPickerOpen",
82 })),
83];
84
85function normalizeKeyToken(token: string): string {
86 if (token === "space") return " ";
87 if (token === "esc") return "escape";
88 return token;
89}
90
91/** @internal - Exported for testing */
92export function parseKeybindingShortcut(value: string): KeybindingShortcut | null {
93 const rawTokens = value
94 .toLowerCase()
95 .split("+")
96 .map((token) => token.trim());
97 const tokens = [...rawTokens];
98 let trailingEmptyCount = 0;
99 while (tokens[tokens.length - 1] === "") {
100 trailingEmptyCount += 1;
101 tokens.pop();
102 }
103 if (trailingEmptyCount > 0) {
104 tokens.push("+");
105 }
106 if (tokens.some((token) => token.length === 0)) {
107 return null;
108 }
109 if (tokens.length === 0) return null;
110
111 let key: string | null = null;
112 let metaKey = false;
113 let ctrlKey = false;
114 let shiftKey = false;
115 let altKey = false;
116 let modKey = false;
117
118 for (const token of tokens) {
119 switch (token) {
120 case "cmd":
121 case "meta":
122 metaKey = true;
123 break;
124 case "ctrl":
125 case "control":
126 ctrlKey = true;
127 break;
128 case "shift":
129 shiftKey = true;
130 break;
131 case "alt":
132 case "option":
133 altKey = true;
134 break;
135 case "mod":
136 modKey = true;
137 break;
138 default: {
139 if (key !== null) return null;
140 key = normalizeKeyToken(token);
141 }
142 }
143 }
144
145 if (key === null) return null;
146 return {
147 key,
148 metaKey,
149 ctrlKey,
150 shiftKey,
151 altKey,
152 modKey,
153 };
154}
155
156function tokenizeWhenExpression(expression: string): WhenToken[] | null {
157 const tokens: WhenToken[] = [];
158 let index = 0;
159
160 while (index < expression.length) {
161 const current = expression[index];
162 if (!current) break;
163
164 if (/\s/.test(current)) {
165 index += 1;
166 continue;
167 }
168 if (expression.startsWith("&&", index)) {
169 tokens.push({ type: "and" });
170 index += 2;
171 continue;
172 }
173 if (expression.startsWith("||", index)) {
174 tokens.push({ type: "or" });
175 index += 2;
176 continue;
177 }
178 if (current === "!") {
179 tokens.push({ type: "not" });
180 index += 1;
181 continue;
182 }
183 if (current === "(") {
184 tokens.push({ type: "lparen" });
185 index += 1;
186 continue;
187 }
188 if (current === ")") {
189 tokens.push({ type: "rparen" });
190 index += 1;
191 continue;
192 }
193
194 const identifier = /^[A-Za-z_][A-Za-z0-9_.-]*/.exec(expression.slice(index));
195 if (!identifier) {
196 return null;
197 }
198 tokens.push({ type: "identifier", value: identifier[0] });
199 index += identifier[0].length;
200 }
201
202 return tokens;
203}
204
205function parseKeybindingWhenExpression(expression: string): KeybindingWhenNode | null {
206 const tokens = tokenizeWhenExpression(expression);
207 if (!tokens || tokens.length === 0) return null;
208 let index = 0;
209
210 const parsePrimary = (depth: number): KeybindingWhenNode | null => {
211 if (depth > MAX_WHEN_EXPRESSION_DEPTH) {
212 return null;
213 }
214 const token = tokens[index];
215 if (!token) return null;
216
217 if (token.type === "identifier") {
218 index += 1;
219 return { type: "identifier", name: token.value };
220 }
221
222 if (token.type === "lparen") {
223 index += 1;
224 const expressionNode = parseOr(depth + 1);
225 const closeToken = tokens[index];
226 if (!expressionNode || !closeToken || closeToken.type !== "rparen") {
227 return null;
228 }
229 index += 1;
230 return expressionNode;
231 }
232
233 return null;
234 };
235
236 const parseUnary = (depth: number): KeybindingWhenNode | null => {
237 let notCount = 0;
238 while (tokens[index]?.type === "not") {
239 index += 1;
240 notCount += 1;
241 if (notCount > MAX_WHEN_EXPRESSION_DEPTH) {
242 return null;
243 }
244 }
245
246 let node = parsePrimary(depth);
247 if (!node) return null;
248
249 while (notCount > 0) {
250 node = { type: "not", node };
251 notCount -= 1;
252 }
253
254 return node;
255 };
256
257 const parseAnd = (depth: number): KeybindingWhenNode | null => {
258 let left = parseUnary(depth);
259 if (!left) return null;
260
261 while (tokens[index]?.type === "and") {
262 index += 1;
263 const right = parseUnary(depth);
264 if (!right) return null;
265 left = { type: "and", left, right };
266 }
267
268 return left;
269 };
270
271 const parseOr = (depth: number): KeybindingWhenNode | null => {
272 let left = parseAnd(depth);
273 if (!left) return null;
274
275 while (tokens[index]?.type === "or") {
276 index += 1;
277 const right = parseAnd(depth);
278 if (!right) return null;
279 left = { type: "or", left, right };
280 }
281
282 return left;
283 };
284
285 const ast = parseOr(0);
286 if (!ast || index !== tokens.length) return null;
287 return ast;
288}
289
290/** @internal - Exported for testing */
291export function compileResolvedKeybindingRule(rule: KeybindingRule): ResolvedKeybindingRule | null {
292 const shortcut = parseKeybindingShortcut(rule.key);
293 if (!shortcut) return null;
294
295 if (rule.when !== undefined) {
296 const whenAst = parseKeybindingWhenExpression(rule.when);
297 if (!whenAst) return null;
298 return {
299 command: rule.command,
300 shortcut,
301 whenAst,
302 };
303 }
304
305 return {
306 command: rule.command,
307 shortcut,
308 };
309}
310
311export function compileResolvedKeybindingsConfig(
312 config: KeybindingsConfig,
313): ResolvedKeybindingsConfig {
314 const compiled: Mutable<ResolvedKeybindingsConfig> = [];
315 for (const rule of config) {
316 const result = Schema.decodeExit(ResolvedKeybindingFromConfig)(rule);
317 if (result._tag === "Success") {
318 compiled.push(result.value);
319 }
320 }
321 return compiled;
322}
32361
324export const ResolvedKeybindingFromConfig = KeybindingRule.pipe(62export const ResolvedKeybindingFromConfig = KeybindingRule.pipe(
325 Schema.decodeTo(63 Schema.decodeTo(
...@@ -412,8 +150,6 @@ function encodeWhenAst(node: KeybindingWhenNode): string {...@@ -412,8 +150,6 @@ function encodeWhenAst(node: KeybindingWhenNode): string {
412 }150 }
413}151}
414152
415const DEFAULT_RESOLVED_KEYBINDINGS = compileResolvedKeybindingsConfig(DEFAULT_KEYBINDINGS);
416
417const RawKeybindingsEntries = fromLenientJson(Schema.Array(Schema.Unknown));153const RawKeybindingsEntries = fromLenientJson(Schema.Array(Schema.Unknown));
418const KeybindingsConfigJson = Schema.fromJsonString(KeybindingsConfig);154const KeybindingsConfigJson = Schema.fromJsonString(KeybindingsConfig);
419const PrettyJsonString = SchemaGetter.parseJson<string>().compose(155const PrettyJsonString = SchemaGetter.parseJson<string>().compose(
apps/server/src/server.test.ts+35-6
...@@ -75,6 +75,7 @@ import {...@@ -75,6 +75,7 @@ import {
75 type ProjectionSnapshotQueryShape,75 type ProjectionSnapshotQueryShape,
76} from "./orchestration/Services/ProjectionSnapshotQuery.ts";76} from "./orchestration/Services/ProjectionSnapshotQuery.ts";
77import { SqlitePersistenceMemory } from "./persistence/Layers/Sqlite.ts";77import { SqlitePersistenceMemory } from "./persistence/Layers/Sqlite.ts";
78import { PersistenceSqlError } from "./persistence/Errors.ts";
78import {79import {
79 ProviderRegistry,80 ProviderRegistry,
80 type ProviderRegistryShape,81 type ProviderRegistryShape,
...@@ -217,10 +218,8 @@ const browserOtlpTracingLayer = Layer.mergeAll(...@@ -217,10 +218,8 @@ const browserOtlpTracingLayer = Layer.mergeAll(
217 Layer.succeed(HttpClient.TracerDisabledWhen, () => true),218 Layer.succeed(HttpClient.TracerDisabledWhen, () => true),
218);219);
219220
220const authTestLayer = ServerAuthLive.pipe(221const makeAuthTestLayer = () =>
221 Layer.provide(SqlitePersistenceMemory),222 ServerAuthLive.pipe(Layer.provide(SqlitePersistenceMemory), Layer.provide(ServerSecretStoreLive));
222 Layer.provide(ServerSecretStoreLive),
223);
224223
225const makeBrowserOtlpPayload = (spanName: string) =>224const makeBrowserOtlpPayload = (spanName: string) =>
226 Effect.gen(function* () {225 Effect.gen(function* () {
...@@ -378,6 +377,8 @@ const buildAppUnderTest = (options?: {...@@ -378,6 +377,8 @@ const buildAppUnderTest = (options?: {
378 desktopBootstrapToken: defaultDesktopBootstrapToken,377 desktopBootstrapToken: defaultDesktopBootstrapToken,
379 autoBootstrapProjectFromCwd: false,378 autoBootstrapProjectFromCwd: false,
380 logWebSocketEvents: false,379 logWebSocketEvents: false,
380 tailscaleServeEnabled: false,
381 tailscaleServePort: 443,
381 ...options?.config,382 ...options?.config,
382 };383 };
383 const layerConfig = Layer.succeed(ServerConfig, config);384 const layerConfig = Layer.succeed(ServerConfig, config);
...@@ -653,7 +654,7 @@ const buildAppUnderTest = (options?: {...@@ -653,7 +654,7 @@ const buildAppUnderTest = (options?: {
653 ...options?.layers?.repositoryIdentityResolver,654 ...options?.layers?.repositoryIdentityResolver,
654 }),655 }),
655 ),656 ),
656 Layer.provideMerge(authTestLayer),657 Layer.provideMerge(makeAuthTestLayer()),
657 Layer.provide(workspaceAndProjectServicesLayer),658 Layer.provide(workspaceAndProjectServicesLayer),
658 Layer.provideMerge(FetchHttpClient.layer),659 Layer.provideMerge(FetchHttpClient.layer),
659 Layer.provide(layerConfig),660 Layer.provide(layerConfig),
...@@ -2021,7 +2022,7 @@ it.layer(NodeServices.layer)("server router seam", (it) => {...@@ -2021,7 +2022,7 @@ it.layer(NodeServices.layer)("server router seam", (it) => {
2021 assert.deepEqual(second, {2022 assert.deepEqual(second, {
2022 version: 1,2023 version: 1,
2023 type: "keybindingsUpdated",2024 type: "keybindingsUpdated",
2024 payload: { issues: [] },2025 payload: { keybindings: [], issues: [] },
2025 });2026 });
2026 }).pipe(Effect.provide(NodeHttpServer.layerTest)),2027 }).pipe(Effect.provide(NodeHttpServer.layerTest)),
2027 );2028 );
...@@ -3110,6 +3111,34 @@ it.layer(NodeServices.layer)("server router seam", (it) => {...@@ -3110,6 +3111,34 @@ it.layer(NodeServices.layer)("server router seam", (it) => {
3110 }).pipe(Effect.provide(NodeHttpServer.layerTest)),3111 }).pipe(Effect.provide(NodeHttpServer.layerTest)),
3111 );3112 );
31123113
3114 it.effect("routes websocket rpc orchestration shell snapshot errors", () =>
3115 Effect.gen(function* () {
3116 const projectionError = new PersistenceSqlError({
3117 operation: "ProjectionSnapshotQuery.getShellSnapshot:test",
3118 detail: "failed to read projection shell snapshot",
3119 });
3120 yield* buildAppUnderTest({
3121 layers: {
3122 projectionSnapshotQuery: {
3123 getShellSnapshot: () => Effect.fail(projectionError),
3124 },
3125 },
3126 });
3127
3128 const wsUrl = yield* getWsServerUrl("/ws");
3129 const result = yield* Effect.scoped(
3130 withWsRpcClient(wsUrl, (client) =>
3131 client[ORCHESTRATION_WS_METHODS.subscribeShell]({}).pipe(Stream.runCollect),
3132 ).pipe(Effect.result),
3133 );
3134
3135 assertTrue(result._tag === "Failure");
3136 assertTrue(result.failure._tag === "OrchestrationGetSnapshotError");
3137 assertTrue(result.failure.cause instanceof Error);
3138 assert.include(result.failure.cause.message, projectionError.message);
3139 }).pipe(Effect.provide(NodeHttpServer.layerTest)),
3140 );
3141
3113 it.effect("enriches replayed project events with repository identity metadata", () =>3142 it.effect("enriches replayed project events with repository identity metadata", () =>
3114 Effect.gen(function* () {3143 Effect.gen(function* () {
3115 const repositoryIdentity = {3144 const repositoryIdentity = {
apps/server/src/server.ts+53
...@@ -83,6 +83,7 @@ import {...@@ -83,6 +83,7 @@ import {
83 orchestrationSnapshotRouteLayer,83 orchestrationSnapshotRouteLayer,
84} from "./orchestration/http.ts";84} from "./orchestration/http.ts";
85import { NetService } from "@t3tools/shared/Net";85import { NetService } from "@t3tools/shared/Net";
86import { disableTailscaleServe, ensureTailscaleServe } from "@t3tools/tailscale";
8687
87const PtyAdapterLive = Layer.unwrap(88const PtyAdapterLive = Layer.unwrap(
88 Effect.gen(function* () {89 Effect.gen(function* () {
...@@ -334,6 +335,57 @@ export const makeServerLayer = Layer.unwrap(...@@ -334,6 +335,57 @@ export const makeServerLayer = Layer.unwrap(
334 () => clearPersistedServerRuntimeState(config.serverRuntimeStatePath),335 () => clearPersistedServerRuntimeState(config.serverRuntimeStatePath),
335 ),336 ),
336 );337 );
338 const tailscaleServeLayer = config.tailscaleServeEnabled
339 ? Layer.effectDiscard(
340 Effect.acquireRelease(
341 Effect.gen(function* () {
342 const server = yield* HttpServer.HttpServer;
343 const address = server.address;
344 if (typeof address === "string" || !("port" in address)) {
345 return null;
346 }
347
348 const localPort = address.port;
349 return yield* ensureTailscaleServe({
350 localPort,
351 servePort: config.tailscaleServePort,
352 localHost: "127.0.0.1",
353 }).pipe(
354 Effect.as({ localPort, servePort: config.tailscaleServePort }),
355 Effect.tap(() =>
356 Effect.logInfo("Tailscale Serve configured", {
357 localPort,
358 servePort: config.tailscaleServePort,
359 }),
360 ),
361 Effect.catch((cause) =>
362 Effect.logWarning("Failed to configure Tailscale Serve", {
363 cause,
364 localPort,
365 servePort: config.tailscaleServePort,
366 }).pipe(Effect.as(null)),
367 ),
368 );
369 }),
370 (configured) =>
371 configured
372 ? disableTailscaleServe({ servePort: configured.servePort }).pipe(
373 Effect.tap(() =>
374 Effect.logInfo("Tailscale Serve disabled", {
375 servePort: configured.servePort,
376 }),
377 ),
378 Effect.catch((cause) =>
379 Effect.logWarning("Failed to disable Tailscale Serve", {
380 cause,
381 servePort: configured.servePort,
382 }),
383 ),
384 )
385 : Effect.void,
386 ),
387 )
388 : Layer.empty;
337389
338 const serverApplicationLayer = Layer.mergeAll(390 const serverApplicationLayer = Layer.mergeAll(
339 HttpRouter.serve(makeRoutesLayer, {391 HttpRouter.serve(makeRoutesLayer, {
...@@ -341,6 +393,7 @@ export const makeServerLayer = Layer.unwrap(...@@ -341,6 +393,7 @@ export const makeServerLayer = Layer.unwrap(
341 }),393 }),
342 httpListeningLayer,394 httpListeningLayer,
343 runtimeStateLayer,395 runtimeStateLayer,
396 tailscaleServeLayer,
344 );397 );
345398
346 return serverApplicationLayer.pipe(399 return serverApplicationLayer.pipe(
apps/server/src/ws.ts+4
...@@ -672,6 +672,9 @@ const makeWsRpcLayer = (currentSessionId: AuthSessionId) =>...@@ -672,6 +672,9 @@ const makeWsRpcLayer = (currentSessionId: AuthSessionId) =>
672 ORCHESTRATION_WS_METHODS.subscribeShell,672 ORCHESTRATION_WS_METHODS.subscribeShell,
673 Effect.gen(function* () {673 Effect.gen(function* () {
674 const snapshot = yield* projectionSnapshotQuery.getShellSnapshot().pipe(674 const snapshot = yield* projectionSnapshotQuery.getShellSnapshot().pipe(
675 Effect.tapError((cause) =>
676 Effect.logError("orchestration shell snapshot load failed", { cause }),
677 ),
675 Effect.mapError(678 Effect.mapError(
676 (cause) =>679 (cause) =>
677 new OrchestrationGetSnapshotError({680 new OrchestrationGetSnapshotError({
...@@ -1017,6 +1020,7 @@ const makeWsRpcLayer = (currentSessionId: AuthSessionId) =>...@@ -1017,6 +1020,7 @@ const makeWsRpcLayer = (currentSessionId: AuthSessionId) =>
1017 version: 1 as const,1020 version: 1 as const,
1018 type: "keybindingsUpdated" as const,1021 type: "keybindingsUpdated" as const,
1019 payload: {1022 payload: {
1023 keybindings: event.keybindings,
1020 issues: event.issues,1024 issues: event.issues,
1021 },1025 },
1022 })),1026 })),
apps/web/src/branding.ts+1-1
...@@ -15,4 +15,4 @@ export const APP_STAGE_LABEL =...@@ -15,4 +15,4 @@ export const APP_STAGE_LABEL =
15 injectedDesktopAppBranding?.stageLabel ?? (import.meta.env.DEV ? "Dev" : "Alpha");15 injectedDesktopAppBranding?.stageLabel ?? (import.meta.env.DEV ? "Dev" : "Alpha");
16export const APP_DISPLAY_NAME =16export const APP_DISPLAY_NAME =
17 injectedDesktopAppBranding?.displayName ?? `${APP_BASE_NAME} (${APP_STAGE_LABEL})`;17 injectedDesktopAppBranding?.displayName ?? `${APP_BASE_NAME} (${APP_STAGE_LABEL})`;
18export const APP_VERSION = import.meta.env.APP_VERSION || "0.0.0";18export const APP_VERSION = "0.0.20"; // import.meta.env.APP_VERSION || "0.0.0";
apps/web/src/clientPersistenceStorage.test.ts+6
...@@ -10,6 +10,12 @@ const savedRegistryRecord: PersistedSavedEnvironmentRecord = {...@@ -10,6 +10,12 @@ const savedRegistryRecord: PersistedSavedEnvironmentRecord = {
10 wsBaseUrl: "wss://remote.example.com/",10 wsBaseUrl: "wss://remote.example.com/",
11 createdAt: "2026-04-09T00:00:00.000Z",11 createdAt: "2026-04-09T00:00:00.000Z",
12 lastConnectedAt: null,12 lastConnectedAt: null,
13 desktopSsh: {
14 alias: "devbox",
15 hostname: "devbox.example.com",
16 username: "julius",
17 port: 22,
18 },
13};19};
1420
15function createLocalStorageStub(): Storage {21function createLocalStorageStub(): Storage {
apps/web/src/clientPersistenceStorage.ts+14-3
...@@ -19,6 +19,14 @@ const BrowserSavedEnvironmentRecordSchema = Schema.Struct({...@@ -19,6 +19,14 @@ const BrowserSavedEnvironmentRecordSchema = Schema.Struct({
19 wsBaseUrl: Schema.String,19 wsBaseUrl: Schema.String,
20 createdAt: Schema.String,20 createdAt: Schema.String,
21 lastConnectedAt: Schema.NullOr(Schema.String),21 lastConnectedAt: Schema.NullOr(Schema.String),
22 desktopSsh: Schema.optionalKey(
23 Schema.Struct({
24 alias: Schema.String,
25 hostname: Schema.String,
26 username: Schema.NullOr(Schema.String),
27 port: Schema.NullOr(Schema.Number),
28 }),
29 ),
22 bearerToken: Schema.optionalKey(Schema.String),30 bearerToken: Schema.optionalKey(Schema.String),
23});31});
24type BrowserSavedEnvironmentRecord = typeof BrowserSavedEnvironmentRecordSchema.Type;32type BrowserSavedEnvironmentRecord = typeof BrowserSavedEnvironmentRecordSchema.Type;
...@@ -37,7 +45,7 @@ function hasWindow(): boolean {...@@ -37,7 +45,7 @@ function hasWindow(): boolean {
37function toPersistedSavedEnvironmentRecord(45function toPersistedSavedEnvironmentRecord(
38 record: PersistedSavedEnvironmentRecord,46 record: PersistedSavedEnvironmentRecord,
39): PersistedSavedEnvironmentRecord {47): PersistedSavedEnvironmentRecord {
40 return {48 const nextRecord = {
41 environmentId: record.environmentId,49 environmentId: record.environmentId,
42 label: record.label,50 label: record.label,
43 httpBaseUrl: record.httpBaseUrl,51 httpBaseUrl: record.httpBaseUrl,
...@@ -45,6 +53,7 @@ function toPersistedSavedEnvironmentRecord(...@@ -45,6 +53,7 @@ function toPersistedSavedEnvironmentRecord(
45 createdAt: record.createdAt,53 createdAt: record.createdAt,
46 lastConnectedAt: record.lastConnectedAt,54 lastConnectedAt: record.lastConnectedAt,
47 };55 };
56 return record.desktopSsh ? { ...nextRecord, desktopSsh: record.desktopSsh } : nextRecord;
48}57}
4958
50export function readBrowserClientSettings(): ClientSettings | null {59export function readBrowserClientSettings(): ClientSettings | null {
...@@ -135,6 +144,7 @@ export function writeBrowserSavedEnvironmentRegistry(...@@ -135,6 +144,7 @@ export function writeBrowserSavedEnvironmentRegistry(
135 wsBaseUrl: record.wsBaseUrl,144 wsBaseUrl: record.wsBaseUrl,
136 createdAt: record.createdAt,145 createdAt: record.createdAt,
137 lastConnectedAt: record.lastConnectedAt,146 lastConnectedAt: record.lastConnectedAt,
147 ...(record.desktopSsh ? { desktopSsh: record.desktopSsh } : {}),
138 bearerToken,148 bearerToken,
139 }149 }
140 : toPersistedSavedEnvironmentRecord(record);150 : toPersistedSavedEnvironmentRecord(record);
...@@ -166,7 +176,7 @@ export function writeBrowserSavedEnvironmentSecret(...@@ -166,7 +176,7 @@ export function writeBrowserSavedEnvironmentSecret(
166 return record;176 return record;
167 }177 }
168 found = true;178 found = true;
169 return {179 const nextRecord = {
170 environmentId: record.environmentId,180 environmentId: record.environmentId,
171 label: record.label,181 label: record.label,
172 httpBaseUrl: record.httpBaseUrl,182 httpBaseUrl: record.httpBaseUrl,
...@@ -174,7 +184,8 @@ export function writeBrowserSavedEnvironmentSecret(...@@ -174,7 +184,8 @@ export function writeBrowserSavedEnvironmentSecret(
174 createdAt: record.createdAt,184 createdAt: record.createdAt,
175 lastConnectedAt: record.lastConnectedAt,185 lastConnectedAt: record.lastConnectedAt,
176 bearerToken: secret,186 bearerToken: secret,
177 } satisfies BrowserSavedEnvironmentRecord;187 };
188 return record.desktopSsh ? { ...nextRecord, desktopSsh: record.desktopSsh } : nextRecord;
178 }),189 }),
179 });190 });
180 return found;191 return found;
apps/web/src/components/BranchToolbar.tsx+29-13
...@@ -80,8 +80,6 @@ const MobileRunContextSelector = memo(function MobileRunContextSelector({...@@ -80,8 +80,6 @@ const MobileRunContextSelector = memo(function MobileRunContextSelector({
80 () => availableEnvironments?.find((env) => env.environmentId === environmentId) ?? null,80 () => availableEnvironments?.find((env) => env.environmentId === environmentId) ?? null,
81 [availableEnvironments, environmentId],81 [availableEnvironments, environmentId],
82 );82 );
83 const environmentLabel = activeEnvironment?.label ?? "Run on";
84 const EnvironmentIcon = activeEnvironment?.isPrimary ? MonitorIcon : CloudIcon;
85 const WorkspaceIcon =83 const WorkspaceIcon =
86 effectiveEnvMode === "worktree"84 effectiveEnvMode === "worktree"
87 ? FolderGit2Icon85 ? FolderGit2Icon
...@@ -93,6 +91,34 @@ const MobileRunContextSelector = memo(function MobileRunContextSelector({...@@ -93,6 +91,34 @@ const MobileRunContextSelector = memo(function MobileRunContextSelector({
93 : effectiveEnvMode === "worktree"91 : effectiveEnvMode === "worktree"
94 ? resolveEnvModeLabel("worktree")92 ? resolveEnvModeLabel("worktree")
95 : resolveCurrentWorkspaceLabel(activeWorktreePath);93 : resolveCurrentWorkspaceLabel(activeWorktreePath);
94 const isLocked = envLocked || envModeLocked;
95 const EnvironmentIcon = activeEnvironment?.isPrimary ? MonitorIcon : CloudIcon;
96 const icon = showEnvironmentPicker ? (
97 // Button's base styles apply `-mx-0.5` to descendant SVGs, which eats 4px
98 // out of whatever gap we set. mx-0! cancels that so gap-0.5 reads as 2px.
99 <span className="inline-flex shrink-0 items-center gap-0.5">
100 <EnvironmentIcon className="size-3 shrink-0 mx-0!" />
101 <WorkspaceIcon className="size-3 shrink-0 mx-0!" />
102 </span>
103 ) : (
104 <WorkspaceIcon className="size-3 shrink-0" />
105 );
106 const triggerContent = (
107 <>
108 {icon}
109 <span className="min-w-0 truncate">
110 {showEnvironmentPicker ? (activeEnvironment?.label ?? "Run on") : workspaceLabel}
111 </span>
112 </>
113 );
114
115 if (isLocked) {
116 return (
117 <span className="inline-flex min-w-0 max-w-[48%] flex-1 items-center justify-start gap-1 rounded-md border border-transparent px-[calc(--spacing(2)-1px)] text-sm font-medium text-muted-foreground/70 md:hidden">
118 {triggerContent}
119 </span>
120 );
121 }
96122
97 return (123 return (
98 <Menu>124 <Menu>
...@@ -100,17 +126,7 @@ const MobileRunContextSelector = memo(function MobileRunContextSelector({...@@ -100,17 +126,7 @@ const MobileRunContextSelector = memo(function MobileRunContextSelector({
100 render={<Button variant="ghost" size="xs" />}126 render={<Button variant="ghost" size="xs" />}
101 className="min-w-0 max-w-[48%] flex-1 justify-start text-muted-foreground/70 hover:text-foreground/80 md:hidden"127 className="min-w-0 max-w-[48%] flex-1 justify-start text-muted-foreground/70 hover:text-foreground/80 md:hidden"
102 >128 >
103 {showEnvironmentPicker ? (129 {triggerContent}
104 <>
105 <EnvironmentIcon className="size-3 shrink-0" />
106 <span className="min-w-0 truncate">{environmentLabel}</span>
107 </>
108 ) : (
109 <>
110 <WorkspaceIcon className="size-3 shrink-0" />
111 <span className="min-w-0 truncate">{workspaceLabel}</span>
112 </>
113 )}
114 <ChevronDownIcon className="size-3 shrink-0 opacity-50" />130 <ChevronDownIcon className="size-3 shrink-0 opacity-50" />
115 </MenuTrigger>131 </MenuTrigger>
116 <MenuPopup align="start" side="top" className="w-64">132 <MenuPopup align="start" side="top" className="w-64">
apps/web/src/components/ChatView.browser.tsx+68
...@@ -1663,6 +1663,74 @@ describe("ChatView timeline estimator parity (full app)", () => {...@@ -1663,6 +1663,74 @@ describe("ChatView timeline estimator parity (full app)", () => {
1663 customWsRpcResolver = null;1663 customWsRpcResolver = null;
1664 document.body.innerHTML = "";1664 document.body.innerHTML = "";
1665 });1665 });
1666
1667 it("renders locked single-environment mobile run context as a static workspace label", async () => {
1668 const mounted = await mountChatView({
1669 viewport: COMPACT_FOOTER_VIEWPORT,
1670 snapshot: createSnapshotForTargetUser({
1671 targetMessageId: "msg-user-mobile-locked-workspace" as MessageId,
1672 targetText: "locked mobile workspace",
1673 }),
1674 });
1675
1676 try {
1677 await waitForElement(
1678 () =>
1679 Array.from(document.querySelectorAll<HTMLElement>("span")).find(
1680 (element) => element.textContent?.trim() === "Local checkout",
1681 ) ?? null,
1682 "Unable to find static mobile workspace label.",
1683 );
1684
1685 expect(findButtonByText("Local checkout")).toBeNull();
1686 } finally {
1687 await mounted.cleanup();
1688 }
1689 });
1690
1691 it("keeps dismiss-only composer banners aligned on mobile", async () => {
1692 const mounted = await mountChatView({
1693 viewport: COMPACT_FOOTER_VIEWPORT,
1694 snapshot: createSnapshotForTargetUser({
1695 targetMessageId: "msg-user-mobile-version-banner" as MessageId,
1696 targetText: "mobile version banner",
1697 }),
1698 configureFixture: (nextFixture) => {
1699 nextFixture.serverConfig = {
1700 ...nextFixture.serverConfig,
1701 environment: {
1702 ...nextFixture.serverConfig.environment,
1703 serverVersion: "9.9.9",
1704 },
1705 };
1706 },
1707 });
1708
1709 try {
1710 const banner = await waitForElement(
1711 () =>
1712 Array.from(document.querySelectorAll<HTMLElement>('[data-slot="alert"]')).find(
1713 (element) => element.textContent?.includes("Client and server versions differ"),
1714 ) ?? null,
1715 "Unable to find version mismatch banner.",
1716 );
1717 const title = banner.querySelector<HTMLElement>('[data-slot="alert-title"]');
1718 const description = banner.querySelector<HTMLElement>('[data-slot="alert-description"]');
1719 const dismissButton = banner.querySelector<HTMLButtonElement>(
1720 'button[aria-label="Dismiss version mismatch warning"]',
1721 );
1722
1723 expect(title).toBeTruthy();
1724 expect(description).toBeTruthy();
1725 expect(dismissButton).toBeTruthy();
1726 expect(dismissButton!.getBoundingClientRect().top).toBeLessThan(
1727 description!.getBoundingClientRect().top,
1728 );
1729 } finally {
1730 await mounted.cleanup();
1731 }
1732 });
1733
1666 it("re-expands the bootstrap project using its logical key", async () => {1734 it("re-expands the bootstrap project using its logical key", async () => {
1667 useUiStateStore.setState({1735 useUiStateStore.setState({
1668 projectExpandedById: {1736 projectExpandedById: {
apps/web/src/components/ChatView.tsx+299-76
...@@ -104,7 +104,7 @@ import { BranchToolbar } from "./BranchToolbar";...@@ -104,7 +104,7 @@ import { BranchToolbar } from "./BranchToolbar";
104import { resolveShortcutCommand, shortcutLabelForCommand } from "../keybindings";104import { resolveShortcutCommand, shortcutLabelForCommand } from "../keybindings";
105import PlanSidebar from "./PlanSidebar";105import PlanSidebar from "./PlanSidebar";
106import ThreadTerminalDrawer from "./ThreadTerminalDrawer";106import ThreadTerminalDrawer from "./ThreadTerminalDrawer";
107import { ChevronDownIcon } from "lucide-react";107import { ChevronDownIcon, TriangleAlertIcon, WifiOffIcon } from "lucide-react";
108import { cn, randomUUID } from "~/lib/utils";108import { cn, randomUUID } from "~/lib/utils";
109import { stackedThreadToast, toastManager } from "./ui/toast";109import { stackedThreadToast, toastManager } from "./ui/toast";
110import { decodeProjectScriptKeybindingRule } from "~/lib/projectScriptKeybindings";110import { decodeProjectScriptKeybindingRule } from "~/lib/projectScriptKeybindings";
...@@ -121,6 +121,7 @@ import { resolveAppModelSelectionForInstance } from "../modelSelection";...@@ -121,6 +121,7 @@ import { resolveAppModelSelectionForInstance } from "../modelSelection";
121import { isTerminalFocused } from "../lib/terminalFocus";121import { isTerminalFocused } from "../lib/terminalFocus";
122import { deriveLogicalProjectKeyFromSettings } from "../logicalProject";122import { deriveLogicalProjectKeyFromSettings } from "../logicalProject";
123import {123import {
124 reconnectSavedEnvironment,
124 useSavedEnvironmentRegistryStore,125 useSavedEnvironmentRegistryStore,
125 useSavedEnvironmentRuntimeStore,126 useSavedEnvironmentRuntimeStore,
126} from "../environments/runtime";127} from "../environments/runtime";
...@@ -148,6 +149,7 @@ import { NoActiveThreadState } from "./NoActiveThreadState";...@@ -148,6 +149,7 @@ import { NoActiveThreadState } from "./NoActiveThreadState";
148import { resolveEffectiveEnvMode, resolveEnvironmentOptionLabel } from "./BranchToolbar.logic";149import { resolveEffectiveEnvMode, resolveEnvironmentOptionLabel } from "./BranchToolbar.logic";
149import { ProviderStatusBanner } from "./chat/ProviderStatusBanner";150import { ProviderStatusBanner } from "./chat/ProviderStatusBanner";
150import { ThreadErrorBanner } from "./chat/ThreadErrorBanner";151import { ThreadErrorBanner } from "./chat/ThreadErrorBanner";
152import { ComposerBannerStack, type ComposerBannerStackItem } from "./chat/ComposerBannerStack";
151import {153import {
152 MAX_HIDDEN_MOUNTED_TERMINAL_THREADS,154 MAX_HIDDEN_MOUNTED_TERMINAL_THREADS,
153 buildExpiredTerminalContextToastCopy,155 buildExpiredTerminalContextToastCopy,
...@@ -180,6 +182,13 @@ import {...@@ -180,6 +182,13 @@ import {
180import { sanitizeThreadErrorMessage } from "~/rpc/transportError";182import { sanitizeThreadErrorMessage } from "~/rpc/transportError";
181import { retainThreadDetailSubscription } from "../environments/runtime/service";183import { retainThreadDetailSubscription } from "../environments/runtime/service";
182import { RightPanelSheet } from "./RightPanelSheet";184import { RightPanelSheet } from "./RightPanelSheet";
185import { Button } from "./ui/button";
186import {
187 buildVersionMismatchDismissalKey,
188 dismissVersionMismatch,
189 isVersionMismatchDismissed,
190 resolveServerConfigVersionMismatch,
191} from "../versionSkew";
183192
184const IMAGE_ONLY_BOOTSTRAP_PROMPT =193const IMAGE_ONLY_BOOTSTRAP_PROMPT =
185 "[User attached one or more images without additional text. Respond using the conversation context and the attached image(s).]";194 "[User attached one or more images without additional text. Respond using the conversation context and the attached image(s).]";
...@@ -187,6 +196,11 @@ const EMPTY_ACTIVITIES: OrchestrationThreadActivity[] = [];...@@ -187,6 +196,11 @@ const EMPTY_ACTIVITIES: OrchestrationThreadActivity[] = [];
187const EMPTY_PROPOSED_PLANS: Thread["proposedPlans"] = [];196const EMPTY_PROPOSED_PLANS: Thread["proposedPlans"] = [];
188const EMPTY_PROVIDERS: ServerProvider[] = [];197const EMPTY_PROVIDERS: ServerProvider[] = [];
189const EMPTY_PENDING_USER_INPUT_ANSWERS: Record<string, PendingUserInputDraftAnswer> = {};198const EMPTY_PENDING_USER_INPUT_ANSWERS: Record<string, PendingUserInputDraftAnswer> = {};
199type EnvironmentUnavailableState = {
200 readonly environmentId: EnvironmentId;
201 readonly label: string;
202 readonly connectionState: "connecting" | "disconnected" | "error";
203};
190204
191type ThreadPlanCatalogEntry = Pick<Thread, "id" | "proposedPlans">;205type ThreadPlanCatalogEntry = Pick<Thread, "id" | "proposedPlans">;
192206
...@@ -855,6 +869,78 @@ export default function ChatView(props: ChatViewProps) {...@@ -855,6 +869,78 @@ export default function ChatView(props: ChatViewProps) {
855 const primaryEnvironmentId = usePrimaryEnvironmentId();869 const primaryEnvironmentId = usePrimaryEnvironmentId();
856 const savedEnvironmentRegistry = useSavedEnvironmentRegistryStore((s) => s.byId);870 const savedEnvironmentRegistry = useSavedEnvironmentRegistryStore((s) => s.byId);
857 const savedEnvironmentRuntimeById = useSavedEnvironmentRuntimeStore((s) => s.byId);871 const savedEnvironmentRuntimeById = useSavedEnvironmentRuntimeStore((s) => s.byId);
872 const activeSavedEnvironmentRecord =
873 activeThread && activeThread.environmentId !== primaryEnvironmentId
874 ? (savedEnvironmentRegistry[activeThread.environmentId] ?? null)
875 : null;
876 const activeSavedEnvironmentRuntime = activeSavedEnvironmentRecord
877 ? (savedEnvironmentRuntimeById[activeSavedEnvironmentRecord.environmentId] ?? null)
878 : null;
879 const activeSavedEnvironmentConnectionState = activeSavedEnvironmentRecord
880 ? (activeSavedEnvironmentRuntime?.connectionState ?? "disconnected")
881 : "connected";
882 const activeEnvironmentUnavailable =
883 activeSavedEnvironmentRecord !== null && activeSavedEnvironmentConnectionState !== "connected";
884 const activeSavedEnvironmentId = activeSavedEnvironmentRecord?.environmentId ?? null;
885 const activeEnvironmentUnavailableLabel = activeSavedEnvironmentRecord
886 ? resolveEnvironmentOptionLabel({
887 isPrimary: false,
888 environmentId: activeSavedEnvironmentRecord.environmentId,
889 runtimeLabel: activeSavedEnvironmentRuntime?.descriptor?.label ?? null,
890 savedLabel: activeSavedEnvironmentRecord.label,
891 })
892 : null;
893 const activeEnvironmentUnavailableState = useMemo<EnvironmentUnavailableState | null>(() => {
894 if (
895 !activeEnvironmentUnavailable ||
896 !activeEnvironmentUnavailableLabel ||
897 !activeSavedEnvironmentId
898 ) {
899 return null;
900 }
901
902 return {
903 environmentId: activeSavedEnvironmentId,
904 label: activeEnvironmentUnavailableLabel,
905 connectionState:
906 activeSavedEnvironmentConnectionState === "connecting" ||
907 activeSavedEnvironmentConnectionState === "error"
908 ? activeSavedEnvironmentConnectionState
909 : "disconnected",
910 };
911 }, [
912 activeEnvironmentUnavailable,
913 activeEnvironmentUnavailableLabel,
914 activeSavedEnvironmentConnectionState,
915 activeSavedEnvironmentId,
916 ]);
917 const [reconnectingEnvironmentId, setReconnectingEnvironmentId] = useState<EnvironmentId | null>(
918 null,
919 );
920 const handleReconnectActiveEnvironment = useCallback(
921 async (environmentId: EnvironmentId, label: string) => {
922 setReconnectingEnvironmentId(environmentId);
923 try {
924 await reconnectSavedEnvironment(environmentId);
925 toastManager.add({
926 type: "success",
927 title: "Environment reconnected",
928 description: `${label} is ready.`,
929 });
930 } catch (error) {
931 toastManager.add(
932 stackedThreadToast({
933 type: "error",
934 title: "Could not reconnect environment",
935 description: error instanceof Error ? error.message : "Failed to reconnect.",
936 }),
937 );
938 } finally {
939 setReconnectingEnvironmentId(null);
940 }
941 },
942 [],
943 );
858 const projectGroupingSettings = useSettings((settings) => ({944 const projectGroupingSettings = useSettings((settings) => ({
859 sidebarProjectGroupingMode: settings.sidebarProjectGroupingMode,945 sidebarProjectGroupingMode: settings.sidebarProjectGroupingMode,
860 sidebarProjectGroupingOverrides: settings.sidebarProjectGroupingOverrides,946 sidebarProjectGroupingOverrides: settings.sidebarProjectGroupingOverrides,
...@@ -1055,6 +1141,120 @@ export default function ChatView(props: ChatViewProps) {...@@ -1055,6 +1141,120 @@ export default function ChatView(props: ChatViewProps) {
1055 primaryEnvironmentId && activeThread?.environmentId === primaryEnvironmentId1141 primaryEnvironmentId && activeThread?.environmentId === primaryEnvironmentId
1056 ? primaryServerConfig1142 ? primaryServerConfig
1057 : (activeEnvRuntimeState?.serverConfig ?? primaryServerConfig);1143 : (activeEnvRuntimeState?.serverConfig ?? primaryServerConfig);
1144 const versionMismatch = resolveServerConfigVersionMismatch(serverConfig);
1145 const versionMismatchDismissKey =
1146 versionMismatch && activeThread
1147 ? buildVersionMismatchDismissalKey(activeThread.environmentId, versionMismatch)
1148 : null;
1149 const [dismissedVersionMismatchKey, setDismissedVersionMismatchKey] = useState<string | null>(
1150 null,
1151 );
1152 const versionMismatchDismissed =
1153 versionMismatchDismissKey === dismissedVersionMismatchKey ||
1154 isVersionMismatchDismissed(versionMismatchDismissKey);
1155 const showVersionMismatchBanner =
1156 versionMismatch !== null && versionMismatchDismissKey !== null && !versionMismatchDismissed;
1157 const hasMultipleRegisteredEnvironments = Object.keys(savedEnvironmentRegistry).length > 0;
1158 const versionMismatchServerLabel = useMemo(() => {
1159 if (!hasMultipleRegisteredEnvironments || !activeThread) {
1160 return "server";
1161 }
1162
1163 const isPrimary = activeThread.environmentId === primaryEnvironmentId;
1164 const savedRecord = savedEnvironmentRegistry[activeThread.environmentId];
1165 const runtimeState = savedEnvironmentRuntimeById[activeThread.environmentId];
1166 return `${resolveEnvironmentOptionLabel({
1167 isPrimary,
1168 environmentId: activeThread.environmentId,
1169 runtimeLabel: runtimeState?.descriptor?.label ?? serverConfig?.environment.label ?? null,
1170 savedLabel: savedRecord?.label ?? null,
1171 })} server`;
1172 }, [
1173 activeThread,
1174 hasMultipleRegisteredEnvironments,
1175 primaryEnvironmentId,
1176 savedEnvironmentRegistry,
1177 savedEnvironmentRuntimeById,
1178 serverConfig?.environment.label,
1179 ]);
1180 const composerBannerItems = useMemo<ComposerBannerStackItem[]>(() => {
1181 const items: ComposerBannerStackItem[] = [];
1182 if (activeEnvironmentUnavailableState) {
1183 items.push({
1184 id: `environment-unavailable:${activeEnvironmentUnavailableState.environmentId}`,
1185 variant:
1186 activeEnvironmentUnavailableState.connectionState === "error" ? "error" : "warning",
1187 icon: <WifiOffIcon />,
1188 title: (
1189 <>
1190 {activeEnvironmentUnavailableState.label} is{" "}
1191 {activeEnvironmentUnavailableState.connectionState === "connecting"
1192 ? "connecting"
1193 : "disconnected"}
1194 </>
1195 ),
1196 description: "Reconnect this environment before sending messages or running actions.",
1197 actions: (
1198 <>
1199 <Button
1200 size="xs"
1201 disabled={
1202 activeEnvironmentUnavailableState.connectionState === "connecting" ||
1203 reconnectingEnvironmentId === activeEnvironmentUnavailableState.environmentId
1204 }
1205 onClick={() =>
1206 void handleReconnectActiveEnvironment(
1207 activeEnvironmentUnavailableState.environmentId,
1208 activeEnvironmentUnavailableState.label,
1209 )
1210 }
1211 >
1212 {activeEnvironmentUnavailableState.connectionState === "connecting" ||
1213 reconnectingEnvironmentId === activeEnvironmentUnavailableState.environmentId
1214 ? "Reconnecting..."
1215 : "Reconnect"}
1216 </Button>
1217 <Button
1218 size="xs"
1219 variant="outline"
1220 onClick={() => void navigate({ to: "/settings/connections" })}
1221 >
1222 Connections
1223 </Button>
1224 </>
1225 ),
1226 });
1227 }
1228 if (showVersionMismatchBanner && versionMismatch && versionMismatchDismissKey) {
1229 items.push({
1230 id: `version-mismatch:${versionMismatchDismissKey}`,
1231 variant: "warning",
1232 icon: <TriangleAlertIcon />,
1233 title: "Client and server versions differ",
1234 description: (
1235 <>
1236 Client {versionMismatch.clientVersion} is connected to {versionMismatchServerLabel}{" "}
1237 {versionMismatch.serverVersion}. Sync them if RPC calls or reconnects fail.
1238 </>
1239 ),
1240 dismissLabel: "Dismiss version mismatch warning",
1241 onDismiss: () => {
1242 dismissVersionMismatch(versionMismatchDismissKey);
1243 setDismissedVersionMismatchKey(versionMismatchDismissKey);
1244 },
1245 });
1246 }
1247 return items;
1248 }, [
1249 activeEnvironmentUnavailableState,
1250 handleReconnectActiveEnvironment,
1251 navigate,
1252 reconnectingEnvironmentId,
1253 showVersionMismatchBanner,
1254 versionMismatch,
1255 versionMismatchDismissKey,
1256 versionMismatchServerLabel,
1257 ]);
1058 const providerStatuses = serverConfig?.providers ?? EMPTY_PROVIDERS;1258 const providerStatuses = serverConfig?.providers ?? EMPTY_PROVIDERS;
1059 const unlockedSelectedProvider = resolveSelectableProvider(1259 const unlockedSelectedProvider = resolveSelectableProvider(
1060 providerStatuses,1260 providerStatuses,
...@@ -2354,6 +2554,13 @@ export default function ChatView(props: ChatViewProps) {...@@ -2354,6 +2554,13 @@ export default function ChatView(props: ChatViewProps) {
2354 const localApi = readLocalApi();2554 const localApi = readLocalApi();
2355 if (!api || !localApi || !activeThread || isRevertingCheckpoint) return;2555 if (!api || !localApi || !activeThread || isRevertingCheckpoint) return;
23562556
2557 if (activeEnvironmentUnavailable && activeEnvironmentUnavailableLabel) {
2558 setThreadError(
2559 activeThread.id,
2560 `Reconnect ${activeEnvironmentUnavailableLabel} before reverting checkpoints.`,
2561 );
2562 return;
2563 }
2357 if (phase === "running" || isSendBusy || isConnecting) {2564 if (phase === "running" || isSendBusy || isConnecting) {
2358 setThreadError(activeThread.id, "Interrupt the current turn before reverting checkpoints.");2565 setThreadError(activeThread.id, "Interrupt the current turn before reverting checkpoints.");
2359 return;2566 return;
...@@ -2389,6 +2596,8 @@ export default function ChatView(props: ChatViewProps) {...@@ -2389,6 +2596,8 @@ export default function ChatView(props: ChatViewProps) {
2389 },2596 },
2390 [2597 [
2391 activeThread,2598 activeThread,
2599 activeEnvironmentUnavailable,
2600 activeEnvironmentUnavailableLabel,
2392 environmentId,2601 environmentId,
2393 isConnecting,2602 isConnecting,
2394 isRevertingCheckpoint,2603 isRevertingCheckpoint,
...@@ -2401,7 +2610,15 @@ export default function ChatView(props: ChatViewProps) {...@@ -2401,7 +2610,15 @@ export default function ChatView(props: ChatViewProps) {
2401 const onSend = async (e?: { preventDefault: () => void }) => {2610 const onSend = async (e?: { preventDefault: () => void }) => {
2402 e?.preventDefault();2611 e?.preventDefault();
2403 const api = readEnvironmentApi(environmentId);2612 const api = readEnvironmentApi(environmentId);
2404 if (!api || !activeThread || isSendBusy || isConnecting || sendInFlightRef.current) return;2613 if (
2614 !api ||
2615 !activeThread ||
2616 isSendBusy ||
2617 isConnecting ||
2618 activeEnvironmentUnavailable ||
2619 sendInFlightRef.current
2620 )
2621 return;
2405 if (activePendingProgress) {2622 if (activePendingProgress) {
2406 onAdvanceActivePendingUserInput();2623 onAdvanceActivePendingUserInput();
2407 return;2624 return;
...@@ -3022,6 +3239,7 @@ export default function ChatView(props: ChatViewProps) {...@@ -3022,6 +3239,7 @@ export default function ChatView(props: ChatViewProps) {
3022 !isServerThread ||3239 !isServerThread ||
3023 isSendBusy ||3240 isSendBusy ||
3024 isConnecting ||3241 isConnecting ||
3242 activeEnvironmentUnavailable ||
3025 sendInFlightRef.current3243 sendInFlightRef.current
3026 ) {3244 ) {
3027 return;3245 return;
...@@ -3136,6 +3354,7 @@ export default function ChatView(props: ChatViewProps) {...@@ -3136,6 +3354,7 @@ export default function ChatView(props: ChatViewProps) {
3136 activeThreadBranch,3354 activeThreadBranch,
3137 activeThread,3355 activeThread,
3138 beginLocalDispatch,3356 beginLocalDispatch,
3357 activeEnvironmentUnavailable,
3139 isConnecting,3358 isConnecting,
3140 isSendBusy,3359 isSendBusy,
3141 isServerThread,3360 isServerThread,
...@@ -3382,74 +3601,82 @@ export default function ChatView(props: ChatViewProps) {...@@ -3382,74 +3601,82 @@ export default function ChatView(props: ChatViewProps) {
3382 : "pb-[calc(env(safe-area-inset-bottom)+0.75rem)] sm:pb-[calc(env(safe-area-inset-bottom)+1rem)]",3601 : "pb-[calc(env(safe-area-inset-bottom)+0.75rem)] sm:pb-[calc(env(safe-area-inset-bottom)+1rem)]",
3383 )}3602 )}
3384 >3603 >
3385 <ChatComposer3604 <div className="relative isolate">
3386 ref={composerRef}3605 <ComposerBannerStack className="relative z-0" items={composerBannerItems} />
3387 composerDraftTarget={composerDraftTarget}3606 <div className="relative z-10">
3388 environmentId={environmentId}3607 <ChatComposer
3389 routeKind={routeKind}3608 ref={composerRef}
3390 routeThreadRef={routeThreadRef}3609 composerDraftTarget={composerDraftTarget}
3391 draftId={draftId}3610 environmentId={environmentId}
3392 activeThreadId={activeThreadId}3611 routeKind={routeKind}
3393 activeThreadEnvironmentId={activeThread?.environmentId}3612 routeThreadRef={routeThreadRef}
3394 activeThread={activeThread}3613 draftId={draftId}
3395 isServerThread={isServerThread}3614 activeThreadId={activeThreadId}
3396 isLocalDraftThread={isLocalDraftThread}3615 activeThreadEnvironmentId={activeThread?.environmentId}
3397 phase={phase}3616 activeThread={activeThread}
3398 isConnecting={isConnecting}3617 isServerThread={isServerThread}
3399 isSendBusy={isSendBusy}3618 isLocalDraftThread={isLocalDraftThread}
3400 isPreparingWorktree={isPreparingWorktree}3619 phase={phase}
3401 activePendingApproval={activePendingApproval}3620 isConnecting={isConnecting}
3402 pendingApprovals={pendingApprovals}3621 isSendBusy={isSendBusy}
3403 pendingUserInputs={pendingUserInputs}3622 isPreparingWorktree={isPreparingWorktree}
3404 activePendingProgress={activePendingProgress}3623 environmentUnavailable={activeEnvironmentUnavailableState}
3405 activePendingResolvedAnswers={activePendingResolvedAnswers}3624 activePendingApproval={activePendingApproval}
3406 activePendingIsResponding={activePendingIsResponding}3625 pendingApprovals={pendingApprovals}
3407 activePendingDraftAnswers={activePendingDraftAnswers}3626 pendingUserInputs={pendingUserInputs}
3408 activePendingQuestionIndex={activePendingQuestionIndex}3627 activePendingProgress={activePendingProgress}
3409 respondingRequestIds={respondingRequestIds}3628 activePendingResolvedAnswers={activePendingResolvedAnswers}
3410 showPlanFollowUpPrompt={showPlanFollowUpPrompt}3629 activePendingIsResponding={activePendingIsResponding}
3411 activeProposedPlan={activeProposedPlan}3630 activePendingDraftAnswers={activePendingDraftAnswers}
3412 activePlan={activePlan as { turnId?: TurnId } | null}3631 activePendingQuestionIndex={activePendingQuestionIndex}
3413 sidebarProposedPlan={sidebarProposedPlan as { turnId?: TurnId } | null}3632 respondingRequestIds={respondingRequestIds}
3414 planSidebarLabel={planSidebarLabel}3633 showPlanFollowUpPrompt={showPlanFollowUpPrompt}
3415 planSidebarOpen={planSidebarOpen}3634 activeProposedPlan={activeProposedPlan}
3416 runtimeMode={runtimeMode}3635 activePlan={activePlan as { turnId?: TurnId } | null}
3417 interactionMode={interactionMode}3636 sidebarProposedPlan={sidebarProposedPlan as { turnId?: TurnId } | null}
3418 lockedProvider={lockedProvider}3637 planSidebarLabel={planSidebarLabel}
3419 providerStatuses={providerStatuses as ServerProvider[]}3638 planSidebarOpen={planSidebarOpen}
3420 activeProjectDefaultModelSelection={activeProject?.defaultModelSelection}3639 runtimeMode={runtimeMode}
3421 activeThreadModelSelection={activeThread?.modelSelection}3640 interactionMode={interactionMode}
3422 activeThreadActivities={activeThread?.activities}3641 lockedProvider={lockedProvider}
3423 resolvedTheme={resolvedTheme}3642 providerStatuses={providerStatuses as ServerProvider[]}
3424 settings={settings}3643 activeProjectDefaultModelSelection={activeProject?.defaultModelSelection}
3425 keybindings={keybindings}3644 activeThreadModelSelection={activeThread?.modelSelection}
3426 terminalOpen={Boolean(terminalState.terminalOpen)}3645 activeThreadActivities={activeThread?.activities}
3427 gitCwd={gitCwd}3646 resolvedTheme={resolvedTheme}
3428 promptRef={promptRef}3647 settings={settings}
3429 composerImagesRef={composerImagesRef}3648 keybindings={keybindings}
3430 composerTerminalContextsRef={composerTerminalContextsRef}3649 terminalOpen={Boolean(terminalState.terminalOpen)}
3431 shouldAutoScrollRef={isAtEndRef}3650 gitCwd={gitCwd}
3432 scheduleStickToBottom={scrollToEnd}3651 promptRef={promptRef}
3433 onSend={onSend}3652 composerImagesRef={composerImagesRef}
3434 onInterrupt={onInterrupt}3653 composerTerminalContextsRef={composerTerminalContextsRef}
3435 onImplementPlanInNewThread={onImplementPlanInNewThread}3654 shouldAutoScrollRef={isAtEndRef}
3436 onRespondToApproval={onRespondToApproval}3655 scheduleStickToBottom={scrollToEnd}
3437 onSelectActivePendingUserInputOption={onSelectActivePendingUserInputOption}3656 onSend={onSend}
3438 onAdvanceActivePendingUserInput={onAdvanceActivePendingUserInput}3657 onInterrupt={onInterrupt}
3439 onPreviousActivePendingUserInputQuestion={onPreviousActivePendingUserInputQuestion}3658 onImplementPlanInNewThread={onImplementPlanInNewThread}
3440 onChangeActivePendingUserInputCustomAnswer={3659 onRespondToApproval={onRespondToApproval}
3441 onChangeActivePendingUserInputCustomAnswer3660 onSelectActivePendingUserInputOption={onSelectActivePendingUserInputOption}
3442 }3661 onAdvanceActivePendingUserInput={onAdvanceActivePendingUserInput}
3443 onProviderModelSelect={onProviderModelSelect}3662 onPreviousActivePendingUserInputQuestion={
3444 toggleInteractionMode={toggleInteractionMode}3663 onPreviousActivePendingUserInputQuestion
3445 handleRuntimeModeChange={handleRuntimeModeChange}3664 }
3446 handleInteractionModeChange={handleInteractionModeChange}3665 onChangeActivePendingUserInputCustomAnswer={
3447 togglePlanSidebar={togglePlanSidebar}3666 onChangeActivePendingUserInputCustomAnswer
3448 focusComposer={focusComposer}3667 }
3449 scheduleComposerFocus={scheduleComposerFocus}3668 onProviderModelSelect={onProviderModelSelect}
3450 setThreadError={setThreadError}3669 toggleInteractionMode={toggleInteractionMode}
3451 onExpandImage={onExpandTimelineImage}3670 handleRuntimeModeChange={handleRuntimeModeChange}
3452 />3671 handleInteractionModeChange={handleInteractionModeChange}
3672 togglePlanSidebar={togglePlanSidebar}
3673 focusComposer={focusComposer}
3674 scheduleComposerFocus={scheduleComposerFocus}
3675 setThreadError={setThreadError}
3676 onExpandImage={onExpandTimelineImage}
3677 />
3678 </div>
3679 </div>
3453 {isGitRepo && (3680 {isGitRepo && (
3454 <BranchToolbar3681 <BranchToolbar
3455 environmentId={activeThread.environmentId}3682 environmentId={activeThread.environmentId}
...@@ -3468,12 +3695,8 @@ export default function ChatView(props: ChatViewProps) {...@@ -3468,12 +3695,8 @@ export default function ChatView(props: ChatViewProps) {
3468 {...(canCheckoutPullRequestIntoThread3695 {...(canCheckoutPullRequestIntoThread
3469 ? { onCheckoutPullRequestRequest: openPullRequestDialog }3696 ? { onCheckoutPullRequestRequest: openPullRequestDialog }
3470 : {})}3697 : {})}
3471 {...(hasMultipleEnvironments3698 {...(hasMultipleEnvironments ? { onEnvironmentChange } : {})}
3472 ? {3699 availableEnvironments={logicalProjectEnvironments}
3473 availableEnvironments: logicalProjectEnvironments,
3474 onEnvironmentChange,
3475 }
3476 : {})}
3477 />3700 />
3478 )}3701 )}
3479 </div>3702 </div>
apps/web/src/components/KeybindingsToast.browser.tsx+1-1
...@@ -289,7 +289,7 @@ function sendServerConfigUpdatedPush(issues: ServerConfig["issues"]) {...@@ -289,7 +289,7 @@ function sendServerConfigUpdatedPush(issues: ServerConfig["issues"]) {
289 rpcHarness.emitStreamValue(WS_METHODS.subscribeServerConfig, {289 rpcHarness.emitStreamValue(WS_METHODS.subscribeServerConfig, {
290 version: 1,290 version: 1,
291 type: "keybindingsUpdated",291 type: "keybindingsUpdated",
292 payload: { issues },292 payload: { keybindings: fixture.serverConfig.keybindings, issues },
293 });293 });
294}294}
295295
apps/web/src/components/ProjectFavicon.tsx+20-6
...@@ -10,15 +10,29 @@ export function ProjectFavicon(input: {...@@ -10,15 +10,29 @@ export function ProjectFavicon(input: {
10 cwd: string;10 cwd: string;
11 className?: string;11 className?: string;
12}) {12}) {
13 const src = resolveEnvironmentHttpUrl({13 const src = (() => {
14 environmentId: input.environmentId,14 try {
15 pathname: "/api/project-favicon",15 return resolveEnvironmentHttpUrl({
16 searchParams: { cwd: input.cwd },16 environmentId: input.environmentId,
17 });17 pathname: "/api/project-favicon",
18 searchParams: { cwd: input.cwd },
19 });
20 } catch {
21 return null;
22 }
23 })();
18 const [status, setStatus] = useState<"loading" | "loaded" | "error">(() =>24 const [status, setStatus] = useState<"loading" | "loaded" | "error">(() =>
19 loadedProjectFaviconSrcs.has(src) ? "loaded" : "loading",25 src && loadedProjectFaviconSrcs.has(src) ? "loaded" : "loading",
20 );26 );
2127
28 if (!src) {
29 return (
30 <FolderIcon
31 className={`size-3.5 shrink-0 text-muted-foreground/50 ${input.className ?? ""}`}
32 />
33 );
34 }
35
22 return (36 return (
23 <>37 <>
24 {status !== "loaded" ? (38 {status !== "loaded" ? (
apps/web/src/components/WebSocketConnectionSurface.logic.test.ts+1
...@@ -8,6 +8,7 @@ function makeStatus(overrides: Partial<WsConnectionStatus> = {}): WsConnectionSt...@@ -8,6 +8,7 @@ function makeStatus(overrides: Partial<WsConnectionStatus> = {}): WsConnectionSt
8 attemptCount: 0,8 attemptCount: 0,
9 closeCode: null,9 closeCode: null,
10 closeReason: null,10 closeReason: null,
11 connectionLabel: null,
11 connectedAt: null,12 connectedAt: null,
12 disconnectedAt: null,13 disconnectedAt: null,
13 hasConnected: false,14 hasConnected: false,
apps/web/src/components/WebSocketConnectionSurface.tsx+12-4
...@@ -53,8 +53,16 @@ function describeExhaustedToast(): string {...@@ -53,8 +53,16 @@ function describeExhaustedToast(): string {
53 return "Retries exhausted trying to reconnect";53 return "Retries exhausted trying to reconnect";
54}54}
5555
56function buildReconnectTitle(_status: WsConnectionStatus): string {56function getConnectionDisplayName(status: WsConnectionStatus): string {
57 return "Disconnected from T3 Server";57 return status.connectionLabel?.trim() || "T3 Server";
58}
59
60function buildReconnectTitle(status: WsConnectionStatus): string {
61 return `Disconnected from ${getConnectionDisplayName(status)}`;
62}
63
64function buildRecoveredTitle(status: WsConnectionStatus): string {
65 return `Reconnected to ${getConnectionDisplayName(status)}`;
58}66}
5967
60function describeRecoveredToast(68function describeRecoveredToast(
...@@ -297,7 +305,7 @@ export function WebSocketConnectionCoordinator() {...@@ -297,7 +305,7 @@ export function WebSocketConnectionCoordinator() {
297 },305 },
298 description: describeExhaustedToast(),306 description: describeExhaustedToast(),
299 timeout: 0,307 timeout: 0,
300 title: "Disconnected from T3 Server",308 title: buildReconnectTitle(status),
301 type: "error",309 type: "error",
302 })310 })
303 : stackedThreadToast({311 : stackedThreadToast({
...@@ -334,7 +342,7 @@ export function WebSocketConnectionCoordinator() {...@@ -334,7 +342,7 @@ export function WebSocketConnectionCoordinator() {
334 ) {342 ) {
335 const successToast = {343 const successToast = {
336 description: describeRecoveredToast(previousDisconnectedAt, status.connectedAt),344 description: describeRecoveredToast(previousDisconnectedAt, status.connectedAt),
337 title: "Reconnected to T3 Server",345 title: buildRecoveredTitle(status),
338 type: "success" as const,346 type: "success" as const,
339 timeout: 0,347 timeout: 0,
340 data: {348 data: {
apps/web/src/components/auth/PairingRouteSurface.tsx+123
...@@ -2,11 +2,13 @@ import type { AuthSessionState } from "@t3tools/contracts";...@@ -2,11 +2,13 @@ import type { AuthSessionState } from "@t3tools/contracts";
2import React, { startTransition, useEffect, useRef, useState, useCallback } from "react";2import React, { startTransition, useEffect, useRef, useState, useCallback } from "react";
33
4import { APP_DISPLAY_NAME } from "../../branding";4import { APP_DISPLAY_NAME } from "../../branding";
5import { addSavedEnvironment } from "../../environments/runtime";
5import {6import {
6 peekPairingTokenFromUrl,7 peekPairingTokenFromUrl,
7 stripPairingTokenFromUrl,8 stripPairingTokenFromUrl,
8 submitServerAuthCredential,9 submitServerAuthCredential,
9} from "../../environments/primary";10} from "../../environments/primary";
11import { readHostedPairingRequest } from "../../hostedPairing";
10import { Button } from "../ui/button";12import { Button } from "../ui/button";
11import { Input } from "../ui/input";13import { Input } from "../ui/input";
1214
...@@ -159,6 +161,127 @@ export function PairingRouteSurface({...@@ -159,6 +161,127 @@ export function PairingRouteSurface({
159 );161 );
160}162}
161163
164export function HostedPairingRouteSurface() {
165 const hostedPairingRequestRef = useRef(readHostedPairingRequest());
166 const [status, setStatus] = useState<"pairing" | "paired" | "error">(() =>
167 hostedPairingRequestRef.current ? "pairing" : "error",
168 );
169 const [message, setMessage] = useState(() =>
170 hostedPairingRequestRef.current
171 ? "Connecting to this backend."
172 : "This pairing link is missing its backend host or token.",
173 );
174 const [canRetry, setCanRetry] = useState(false);
175 const submitAttemptedRef = useRef(false);
176 const tokenSubmittedRef = useRef(false);
177
178 const submitHostedPairingRequest = useCallback(async () => {
179 const request = hostedPairingRequestRef.current;
180
181 if (!request) {
182 setStatus("error");
183 setMessage("This pairing link is missing its backend host or token.");
184 setCanRetry(false);
185 return;
186 }
187
188 if (tokenSubmittedRef.current) {
189 setStatus("error");
190 setMessage("This one-time pairing token was already submitted. Request a new pairing link.");
191 setCanRetry(false);
192 return;
193 }
194
195 setStatus("pairing");
196 setMessage("Connecting to this backend.");
197 setCanRetry(false);
198 tokenSubmittedRef.current = true;
199
200 try {
201 const record = await addSavedEnvironment({
202 label: request.label,
203 host: request.host,
204 pairingCode: request.token,
205 });
206 setStatus("paired");
207 setMessage(`${record.label} is saved in this browser.`);
208 } catch (error) {
209 tokenSubmittedRef.current = false;
210 setStatus("error");
211 setCanRetry(true);
212 setMessage(
213 `${errorMessageFromUnknown(error)} If the backend accepted this one-time token, request a new pairing link before retrying.`,
214 );
215 }
216 }, []);
217
218 useEffect(() => {
219 if (submitAttemptedRef.current) {
220 return;
221 }
222 submitAttemptedRef.current = true;
223
224 stripPairingTokenFromUrl();
225 void submitHostedPairingRequest();
226 }, [submitHostedPairingRequest]);
227
228 const request = hostedPairingRequestRef.current;
229
230 return (
231 <div className="relative flex min-h-screen items-center justify-center overflow-hidden bg-background px-4 py-10 text-foreground sm:px-6">
232 <div className="pointer-events-none absolute inset-0 opacity-80">
233 <div className="absolute inset-x-0 top-0 h-44 bg-[radial-gradient(44rem_16rem_at_top,color-mix(in_srgb,var(--color-emerald-500)_14%,transparent),transparent)]" />
234 <div className="absolute inset-y-0 left-0 w-72 bg-[radial-gradient(28rem_18rem_at_left,color-mix(in_srgb,var(--color-sky-500)_10%,transparent),transparent)]" />
235 <div className="absolute inset-0 bg-[linear-gradient(145deg,color-mix(in_srgb,var(--background)_90%,var(--color-black))_0%,var(--background)_55%)]" />
236 </div>
237
238 <section className="relative w-full max-w-xl rounded-2xl border border-border/80 bg-card/90 p-6 shadow-2xl shadow-black/20 backdrop-blur-md sm:p-8">
239 <p className="text-[11px] font-semibold tracking-[0.18em] text-muted-foreground uppercase">
240 {APP_DISPLAY_NAME}
241 </p>
242 <h1 className="mt-3 text-2xl font-semibold tracking-tight sm:text-3xl">
243 {status === "paired"
244 ? "Backend paired"
245 : status === "error"
246 ? "Pairing failed"
247 : "Pairing backend"}
248 </h1>
249 <p className="mt-2 text-sm leading-relaxed text-muted-foreground">{message}</p>
250
251 {request ? (
252 <div className="mt-5 rounded-lg border border-border/70 bg-background/55 px-3 py-3 text-xs leading-relaxed text-muted-foreground">
253 Host: <span className="font-mono text-foreground/80">{request.host}</span>
254 </div>
255 ) : null}
256
257 {status === "error" ? (
258 <div className="mt-5 rounded-lg border border-destructive/30 bg-destructive/6 px-3 py-2 text-sm text-destructive">
259 Verify the backend is reachable from this browser, supports CORS for hosted clients, and
260 is served over HTTPS when opening this page from HTTPS.
261 </div>
262 ) : null}
263
264 <div className="mt-6 flex flex-wrap gap-2">
265 {status === "pairing" ? (
266 <Button disabled size="sm">
267 Pairing...
268 </Button>
269 ) : canRetry ? (
270 <Button size="sm" onClick={() => void submitHostedPairingRequest()}>
271 Try again
272 </Button>
273 ) : null}
274 {status === "paired" ? (
275 <Button size="sm" variant="outline" onClick={() => (window.location.href = "/")}>
276 Open app
277 </Button>
278 ) : null}
279 </div>
280 </section>
281 </div>
282 );
283}
284
162function errorMessageFromUnknown(error: unknown): string {285function errorMessageFromUnknown(error: unknown): string {
163 if (error instanceof Error && error.message.trim().length > 0) {286 if (error instanceof Error && error.message.trim().length > 0) {
164 return error.message;287 return error.message;
apps/web/src/components/chat/ChatComposer.tsx+25-4
...@@ -301,6 +301,7 @@ const ComposerFooterPrimaryActions = memo(function ComposerFooterPrimaryActions(...@@ -301,6 +301,7 @@ const ComposerFooterPrimaryActions = memo(function ComposerFooterPrimaryActions(
301 promptHasText: boolean;301 promptHasText: boolean;
302 isSendBusy: boolean;302 isSendBusy: boolean;
303 isConnecting: boolean;303 isConnecting: boolean;
304 isEnvironmentUnavailable: boolean;
304 hasSendableContent: boolean;305 hasSendableContent: boolean;
305 preserveComposerFocusOnPointerDown?: boolean;306 preserveComposerFocusOnPointerDown?: boolean;
306 onPreviousPendingQuestion: () => void;307 onPreviousPendingQuestion: () => void;
...@@ -321,6 +322,7 @@ const ComposerFooterPrimaryActions = memo(function ComposerFooterPrimaryActions(...@@ -321,6 +322,7 @@ const ComposerFooterPrimaryActions = memo(function ComposerFooterPrimaryActions(
321 promptHasText={props.promptHasText}322 promptHasText={props.promptHasText}
322 isSendBusy={props.isSendBusy}323 isSendBusy={props.isSendBusy}
323 isConnecting={props.isConnecting}324 isConnecting={props.isConnecting}
325 isEnvironmentUnavailable={props.isEnvironmentUnavailable}
324 isPreparingWorktree={props.isPreparingWorktree}326 isPreparingWorktree={props.isPreparingWorktree}
325 hasSendableContent={props.hasSendableContent}327 hasSendableContent={props.hasSendableContent}
326 preserveComposerFocusOnPointerDown={props.preserveComposerFocusOnPointerDown ?? false}328 preserveComposerFocusOnPointerDown={props.preserveComposerFocusOnPointerDown ?? false}
...@@ -393,6 +395,10 @@ export interface ChatComposerProps {...@@ -393,6 +395,10 @@ export interface ChatComposerProps {
393 isConnecting: boolean;395 isConnecting: boolean;
394 isSendBusy: boolean;396 isSendBusy: boolean;
395 isPreparingWorktree: boolean;397 isPreparingWorktree: boolean;
398 environmentUnavailable: {
399 readonly label: string;
400 readonly connectionState: "connecting" | "disconnected" | "error";
401 } | null;
396402
397 // Pending approvals / inputs403 // Pending approvals / inputs
398 activePendingApproval: PendingApproval | null;404 activePendingApproval: PendingApproval | null;
...@@ -500,6 +506,7 @@ export const ChatComposer = memo(...@@ -500,6 +506,7 @@ export const ChatComposer = memo(
500 isConnecting,506 isConnecting,
501 isSendBusy,507 isSendBusy,
502 isPreparingWorktree,508 isPreparingWorktree,
509 environmentUnavailable,
503 activePendingApproval,510 activePendingApproval,
504 pendingApprovals,511 pendingApprovals,
505 pendingUserInputs,512 pendingUserInputs,
...@@ -1959,6 +1966,7 @@ export const ChatComposer = memo(...@@ -1959,6 +1966,7 @@ export const ChatComposer = memo(
1959 className={cn(1966 className={cn(
1960 "rounded-[20px] border bg-card transition-colors duration-200 has-focus-visible:border-ring/45",1967 "rounded-[20px] border bg-card transition-colors duration-200 has-focus-visible:border-ring/45",
1961 isDragOverComposer ? "border-primary/70 bg-accent/30" : "border-border",1968 isDragOverComposer ? "border-primary/70 bg-accent/30" : "border-border",
1969 environmentUnavailable ? "opacity-75" : null,
1962 composerProviderState.composerSurfaceClassName,1970 composerProviderState.composerSurfaceClassName,
1963 )}1971 )}
1964 onFocusCapture={(event) => {1972 onFocusCapture={(event) => {
...@@ -2070,6 +2078,7 @@ export const ChatComposer = memo(...@@ -2070,6 +2078,7 @@ export const ChatComposer = memo(
2070 promptHasText={false}2078 promptHasText={false}
2071 isSendBusy={isSendBusy}2079 isSendBusy={isSendBusy}
2072 isConnecting={isConnecting}2080 isConnecting={isConnecting}
2081 isEnvironmentUnavailable={environmentUnavailable !== null}
2073 isPreparingWorktree={false}2082 isPreparingWorktree={false}
2074 hasSendableContent={false}2083 hasSendableContent={false}
2075 preserveComposerFocusOnPointerDown2084 preserveComposerFocusOnPointerDown
...@@ -2250,11 +2259,21 @@ export const ChatComposer = memo(...@@ -2250,11 +2259,21 @@ export const ChatComposer = memo(
2250 ? "Type your own answer, or leave this blank to use the selected option"2259 ? "Type your own answer, or leave this blank to use the selected option"
2251 : showPlanFollowUpPrompt && activeProposedPlan2260 : showPlanFollowUpPrompt && activeProposedPlan
2252 ? "Add feedback to refine the plan, or leave this blank to implement it"2261 ? "Add feedback to refine the plan, or leave this blank to implement it"
2253 : phase === "disconnected"2262 : environmentUnavailable
2254 ? "Ask for follow-up changes or attach images"2263 ? `${environmentUnavailable.label} is ${
2255 : "Ask anything, @tag files/folders, or use / to show available commands"2264 environmentUnavailable.connectionState === "connecting"
2265 ? "connecting"
2266 : "disconnected"
2267 }`
2268 : phase === "disconnected"
2269 ? "Ask for follow-up changes or attach images"
2270 : "Ask anything, @tag files/folders, or use / to show available commands"
2271 }
2272 disabled={
2273 isConnecting ||
2274 isComposerApprovalState ||
2275 (environmentUnavailable !== null && activePendingProgress === null)
2256 }2276 }
2257 disabled={isConnecting || isComposerApprovalState}
2258 />2277 />
2259 {showMobilePendingAnswerActions ? (2278 {showMobilePendingAnswerActions ? (
2260 <div2279 <div
...@@ -2269,6 +2288,7 @@ export const ChatComposer = memo(...@@ -2269,6 +2288,7 @@ export const ChatComposer = memo(
2269 promptHasText={false}2288 promptHasText={false}
2270 isSendBusy={isSendBusy}2289 isSendBusy={isSendBusy}
2271 isConnecting={isConnecting}2290 isConnecting={isConnecting}
2291 isEnvironmentUnavailable={environmentUnavailable !== null}
2272 isPreparingWorktree={false}2292 isPreparingWorktree={false}
2273 hasSendableContent={false}2293 hasSendableContent={false}
2274 preserveComposerFocusOnPointerDown2294 preserveComposerFocusOnPointerDown
...@@ -2384,6 +2404,7 @@ export const ChatComposer = memo(...@@ -2384,6 +2404,7 @@ export const ChatComposer = memo(
2384 promptHasText={prompt.trim().length > 0}2404 promptHasText={prompt.trim().length > 0}
2385 isSendBusy={isSendBusy}2405 isSendBusy={isSendBusy}
2386 isConnecting={isConnecting}2406 isConnecting={isConnecting}
2407 isEnvironmentUnavailable={environmentUnavailable !== null}
2387 isPreparingWorktree={isPreparingWorktree}2408 isPreparingWorktree={isPreparingWorktree}
2388 hasSendableContent={composerSendState.hasSendableContent}2409 hasSendableContent={composerSendState.hasSendableContent}
2389 preserveComposerFocusOnPointerDown={isMobileViewport}2410 preserveComposerFocusOnPointerDown={isMobileViewport}
apps/web/src/components/chat/ChatHeader.test.ts created+48
...@@ -0,0 +1,48 @@
1import { EnvironmentId } from "@t3tools/contracts";
2import { describe, expect, it } from "vitest";
3
4import { shouldShowOpenInPicker } from "./ChatHeader";
5
6describe("shouldShowOpenInPicker", () => {
7 const primaryEnvironmentId = EnvironmentId.make("environment-primary");
8
9 it("shows the picker for projects in the primary environment", () => {
10 expect(
11 shouldShowOpenInPicker({
12 activeProjectName: "codething-mvp",
13 activeThreadEnvironmentId: primaryEnvironmentId,
14 primaryEnvironmentId,
15 }),
16 ).toBe(true);
17 });
18
19 it("hides the picker when hosted static mode has no primary environment", () => {
20 expect(
21 shouldShowOpenInPicker({
22 activeProjectName: "codething-mvp",
23 activeThreadEnvironmentId: EnvironmentId.make("environment-remote"),
24 primaryEnvironmentId: null,
25 }),
26 ).toBe(false);
27 });
28
29 it("hides the picker for remote environments", () => {
30 expect(
31 shouldShowOpenInPicker({
32 activeProjectName: "codething-mvp",
33 activeThreadEnvironmentId: EnvironmentId.make("environment-remote"),
34 primaryEnvironmentId,
35 }),
36 ).toBe(false);
37 });
38
39 it("hides the picker when there is no active project", () => {
40 expect(
41 shouldShowOpenInPicker({
42 activeProjectName: undefined,
43 activeThreadEnvironmentId: primaryEnvironmentId,
44 primaryEnvironmentId,
45 }),
46 ).toBe(false);
47 });
48});
apps/web/src/components/chat/ChatHeader.tsx+18-3
...@@ -44,6 +44,18 @@ interface ChatHeaderProps {...@@ -44,6 +44,18 @@ interface ChatHeaderProps {
44 onToggleDiff: () => void;44 onToggleDiff: () => void;
45}45}
4646
47export function shouldShowOpenInPicker(input: {
48 readonly activeProjectName: string | undefined;
49 readonly activeThreadEnvironmentId: EnvironmentId;
50 readonly primaryEnvironmentId: EnvironmentId | null;
51}): boolean {
52 return (
53 Boolean(input.activeProjectName) &&
54 input.primaryEnvironmentId !== null &&
55 input.activeThreadEnvironmentId === input.primaryEnvironmentId
56 );
57}
58
47export const ChatHeader = memo(function ChatHeader({59export const ChatHeader = memo(function ChatHeader({
48 activeThreadEnvironmentId,60 activeThreadEnvironmentId,
49 activeThreadId,61 activeThreadId,
...@@ -70,8 +82,11 @@ export const ChatHeader = memo(function ChatHeader({...@@ -70,8 +82,11 @@ export const ChatHeader = memo(function ChatHeader({
70 onToggleDiff,82 onToggleDiff,
71}: ChatHeaderProps) {83}: ChatHeaderProps) {
72 const primaryEnvironmentId = usePrimaryEnvironmentId();84 const primaryEnvironmentId = usePrimaryEnvironmentId();
73 const isRemoteEnvironment =85 const showOpenInPicker = shouldShowOpenInPicker({
74 primaryEnvironmentId !== null && activeThreadEnvironmentId !== primaryEnvironmentId;86 activeProjectName,
87 activeThreadEnvironmentId,
88 primaryEnvironmentId,
89 });
7590
76 return (91 return (
77 <div className="@container/header-actions flex min-w-0 flex-1 items-center gap-2">92 <div className="@container/header-actions flex min-w-0 flex-1 items-center gap-2">
...@@ -106,7 +121,7 @@ export const ChatHeader = memo(function ChatHeader({...@@ -106,7 +121,7 @@ export const ChatHeader = memo(function ChatHeader({
106 onDeleteScript={onDeleteProjectScript}121 onDeleteScript={onDeleteProjectScript}
107 />122 />
108 )}123 )}
109 {activeProjectName && !isRemoteEnvironment && (124 {showOpenInPicker && (
110 <OpenInPicker125 <OpenInPicker
111 keybindings={keybindings}126 keybindings={keybindings}
112 availableEditors={availableEditors}127 availableEditors={availableEditors}
apps/web/src/components/chat/ComposerBannerStack.tsx created+195
...@@ -0,0 +1,195 @@
1import { useEffect, useRef, useState, type CSSProperties, type ReactNode } from "react";
2import { XIcon } from "lucide-react";
3
4import { cn } from "~/lib/utils";
5import { Alert, AlertAction, AlertDescription, AlertTitle } from "../ui/alert";
6import { Button } from "../ui/button";
7
8const DISMISS_TRANSITION_MS = 220;
9const frontExitStyle = {
10 opacity: 0,
11 transform: "translate3d(0, 4rem, 0)",
12} satisfies CSSProperties;
13const stackedExitStyle = {
14 opacity: 0,
15 transform: "translate3d(0, 7rem, 0)",
16} satisfies CSSProperties;
17const restingStyle = {
18 opacity: 1,
19 transform: "translate3d(0, 0, 0)",
20} satisfies CSSProperties;
21const exitTransitionStyle = {
22 transition: `transform ${DISMISS_TRANSITION_MS}ms ease-in, opacity ${DISMISS_TRANSITION_MS}ms ease-in`,
23 willChange: "transform, opacity",
24} satisfies CSSProperties;
25
26export interface ComposerBannerStackItem {
27 readonly id: string;
28 readonly variant: "error" | "info" | "success" | "warning";
29 readonly icon: ReactNode;
30 readonly title: ReactNode;
31 readonly description?: ReactNode;
32 readonly actions?: ReactNode;
33 readonly dismissLabel?: string;
34 readonly onDismiss?: () => void;
35}
36
37interface ComposerBannerStackProps {
38 readonly className?: string;
39 readonly items: ReadonlyArray<ComposerBannerStackItem>;
40}
41
42export function ComposerBannerStack({ className, items }: ComposerBannerStackProps) {
43 const [exitingItemId, setExitingItemId] = useState<string | null>(null);
44 const dismissTimeoutRef = useRef<ReturnType<typeof setTimeout> | null>(null);
45
46 useEffect(() => {
47 if (exitingItemId && !items.some((item) => item.id === exitingItemId)) {
48 setExitingItemId(null);
49 }
50 }, [exitingItemId, items]);
51
52 useEffect(() => {
53 return () => {
54 if (dismissTimeoutRef.current) {
55 clearTimeout(dismissTimeoutRef.current);
56 }
57 };
58 }, []);
59
60 if (items.length === 0) {
61 return null;
62 }
63
64 const frontItem = items[0];
65 if (!frontItem) {
66 return null;
67 }
68 const stackedItems = items.slice(1);
69 const hasStack = stackedItems.length > 0;
70 const showCollapsedStackCap = hasStack && exitingItemId !== frontItem.id;
71
72 const requestDismiss = (item: ComposerBannerStackItem) => {
73 if (!item.onDismiss || exitingItemId) {
74 return;
75 }
76 setExitingItemId(item.id);
77 if (dismissTimeoutRef.current) {
78 clearTimeout(dismissTimeoutRef.current);
79 }
80 dismissTimeoutRef.current = setTimeout(() => {
81 dismissTimeoutRef.current = null;
82 item.onDismiss?.();
83 }, DISMISS_TRANSITION_MS);
84 };
85
86 return (
87 <div className={cn("group/banner-stack mx-auto mb-2 max-w-208", className)}>
88 <div
89 className={cn(
90 "relative",
91 hasStack ? "group-hover/banner-stack:z-50 group-focus-within/banner-stack:z-50" : null,
92 )}
93 >
94 {showCollapsedStackCap ? (
95 <div
96 className={cn(
97 "pointer-events-none absolute inset-x-0 -top-3 z-0 mx-auto h-3 rounded-t-xl",
98 "border border-b-0 border-warning/24 bg-background/96 shadow-[0_6px_18px_rgba(0,0,0,0.06)]",
99 "transition-opacity duration-150 ease-out",
100 "group-hover/banner-stack:opacity-0 group-focus-within/banner-stack:opacity-0",
101 )}
102 style={{ width: "96%" }}
103 aria-hidden="true"
104 />
105 ) : null}
106 <div
107 className={cn(
108 "relative z-10",
109 exitingItemId === frontItem.id ? "pointer-events-none" : null,
110 )}
111 style={{
112 ...exitTransitionStyle,
113 ...(exitingItemId === frontItem.id ? frontExitStyle : restingStyle),
114 }}
115 >
116 <ComposerBannerStackAlert
117 item={frontItem}
118 exiting={exitingItemId === frontItem.id}
119 onDismissRequest={() => requestDismiss(frontItem)}
120 />
121 </div>
122 {hasStack ? (
123 <div
124 className={cn(
125 "pointer-events-none absolute inset-x-0 bottom-[calc(100%+0.5rem)] z-20 space-y-2 opacity-0",
126 "transition-[opacity,transform] duration-150 ease-out",
127 "translate-y-1 transform-gpu will-change-[opacity,transform]",
128 "group-hover/banner-stack:pointer-events-auto group-hover/banner-stack:translate-y-0 group-hover/banner-stack:opacity-100",
129 "group-focus-within/banner-stack:pointer-events-auto group-focus-within/banner-stack:translate-y-0 group-focus-within/banner-stack:opacity-100",
130 )}
131 >
132 {stackedItems.map((item) => (
133 <div
134 key={item.id}
135 className={cn(exitingItemId === item.id ? "pointer-events-none" : null)}
136 style={{
137 ...exitTransitionStyle,
138 ...(exitingItemId === item.id ? stackedExitStyle : restingStyle),
139 }}
140 >
141 <ComposerBannerStackAlert
142 item={item}
143 exiting={exitingItemId === item.id}
144 onDismissRequest={() => requestDismiss(item)}
145 />
146 </div>
147 ))}
148 </div>
149 ) : null}
150 </div>
151 </div>
152 );
153}
154
155function ComposerBannerStackAlert({
156 item,
157 exiting,
158 onDismissRequest,
159}: {
160 readonly item: ComposerBannerStackItem;
161 readonly exiting: boolean;
162 readonly onDismissRequest: () => void;
163}) {
164 const dismissOnly = item.onDismiss && !item.actions;
165
166 return (
167 <Alert variant={item.variant}>
168 {item.icon}
169 <AlertTitle>{item.title}</AlertTitle>
170 {item.description ? <AlertDescription>{item.description}</AlertDescription> : null}
171 {item.actions || item.onDismiss ? (
172 <AlertAction
173 className={
174 dismissOnly
175 ? "max-sm:col-start-3 max-sm:row-start-1 max-sm:mt-0 max-sm:self-start"
176 : undefined
177 }
178 >
179 {item.actions}
180 {item.onDismiss ? (
181 <Button
182 size="icon-xs"
183 variant="ghost"
184 aria-label={item.dismissLabel ?? "Dismiss warning"}
185 disabled={exiting}
186 onClick={onDismissRequest}
187 >
188 <XIcon className="size-3.5" />
189 </Button>
190 ) : null}
191 </AlertAction>
192 ) : null}
193 </Alert>
194 );
195}
apps/web/src/components/chat/ComposerPrimaryActions.tsx+17-12
...@@ -20,6 +20,7 @@ interface ComposerPrimaryActionsProps {...@@ -20,6 +20,7 @@ interface ComposerPrimaryActionsProps {
20 promptHasText: boolean;20 promptHasText: boolean;
21 isSendBusy: boolean;21 isSendBusy: boolean;
22 isConnecting: boolean;22 isConnecting: boolean;
23 isEnvironmentUnavailable: boolean;
23 isPreparingWorktree: boolean;24 isPreparingWorktree: boolean;
24 hasSendableContent: boolean;25 hasSendableContent: boolean;
25 preserveComposerFocusOnPointerDown?: boolean;26 preserveComposerFocusOnPointerDown?: boolean;
...@@ -58,6 +59,7 @@ export const ComposerPrimaryActions = memo(function ComposerPrimaryActions({...@@ -58,6 +59,7 @@ export const ComposerPrimaryActions = memo(function ComposerPrimaryActions({
58 promptHasText,59 promptHasText,
59 isSendBusy,60 isSendBusy,
60 isConnecting,61 isConnecting,
62 isEnvironmentUnavailable,
61 isPreparingWorktree,63 isPreparingWorktree,
62 hasSendableContent,64 hasSendableContent,
63 preserveComposerFocusOnPointerDown = false,65 preserveComposerFocusOnPointerDown = false,
...@@ -104,6 +106,7 @@ export const ComposerPrimaryActions = memo(function ComposerPrimaryActions({...@@ -104,6 +106,7 @@ export const ComposerPrimaryActions = memo(function ComposerPrimaryActions({
104 className={cn("rounded-full", compact ? "px-3" : "px-4")}106 className={cn("rounded-full", compact ? "px-3" : "px-4")}
105 {...pointerFocusProps}107 {...pointerFocusProps}
106 disabled={108 disabled={
109 isEnvironmentUnavailable ||
107 pendingAction.isResponding ||110 pendingAction.isResponding ||
108 (pendingAction.isLastQuestion ? !pendingAction.isComplete : !pendingAction.canAdvance)111 (pendingAction.isLastQuestion ? !pendingAction.isComplete : !pendingAction.canAdvance)
109 }112 }
...@@ -143,7 +146,7 @@ export const ComposerPrimaryActions = memo(function ComposerPrimaryActions({...@@ -143,7 +146,7 @@ export const ComposerPrimaryActions = memo(function ComposerPrimaryActions({
143 size="sm"146 size="sm"
144 className={cn("rounded-full", compact ? "h-9 px-3 sm:h-8" : "h-9 px-4 sm:h-8")}147 className={cn("rounded-full", compact ? "h-9 px-3 sm:h-8" : "h-9 px-4 sm:h-8")}
145 {...pointerFocusProps}148 {...pointerFocusProps}
146 disabled={isSendBusy || isConnecting}149 disabled={isSendBusy || isConnecting || isEnvironmentUnavailable}
147 >150 >
148 {isConnecting || isSendBusy ? "Sending..." : "Refine"}151 {isConnecting || isSendBusy ? "Sending..." : "Refine"}
149 </Button>152 </Button>
...@@ -157,7 +160,7 @@ export const ComposerPrimaryActions = memo(function ComposerPrimaryActions({...@@ -157,7 +160,7 @@ export const ComposerPrimaryActions = memo(function ComposerPrimaryActions({
157 size="sm"160 size="sm"
158 className="h-9 rounded-l-full rounded-r-none px-4 sm:h-8"161 className="h-9 rounded-l-full rounded-r-none px-4 sm:h-8"
159 {...pointerFocusProps}162 {...pointerFocusProps}
160 disabled={isSendBusy || isConnecting}163 disabled={isSendBusy || isConnecting || isEnvironmentUnavailable}
161 >164 >
162 {isConnecting || isSendBusy ? "Sending..." : "Implement"}165 {isConnecting || isSendBusy ? "Sending..." : "Implement"}
163 </Button>166 </Button>
...@@ -170,7 +173,7 @@ export const ComposerPrimaryActions = memo(function ComposerPrimaryActions({...@@ -170,7 +173,7 @@ export const ComposerPrimaryActions = memo(function ComposerPrimaryActions({
170 className="h-9 rounded-l-none rounded-r-full border-l-white/12 px-2 sm:h-8"173 className="h-9 rounded-l-none rounded-r-full border-l-white/12 px-2 sm:h-8"
171 aria-label="Implementation actions"174 aria-label="Implementation actions"
172 {...pointerFocusProps}175 {...pointerFocusProps}
173 disabled={isSendBusy || isConnecting}176 disabled={isSendBusy || isConnecting || isEnvironmentUnavailable}
174 />177 />
175 }178 }
176 >179 >
...@@ -178,7 +181,7 @@ export const ComposerPrimaryActions = memo(function ComposerPrimaryActions({...@@ -178,7 +181,7 @@ export const ComposerPrimaryActions = memo(function ComposerPrimaryActions({
178 </MenuTrigger>181 </MenuTrigger>
179 <MenuPopup align="end" side="top">182 <MenuPopup align="end" side="top">
180 <MenuItem183 <MenuItem
181 disabled={isSendBusy || isConnecting}184 disabled={isSendBusy || isConnecting || isEnvironmentUnavailable}
182 onClick={() => void onImplementPlanInNewThread()}185 onClick={() => void onImplementPlanInNewThread()}
183 >186 >
184 Implement in a new thread187 Implement in a new thread
...@@ -194,15 +197,17 @@ export const ComposerPrimaryActions = memo(function ComposerPrimaryActions({...@@ -194,15 +197,17 @@ export const ComposerPrimaryActions = memo(function ComposerPrimaryActions({
194 type="submit"197 type="submit"
195 className="flex h-9 w-9 enabled:cursor-pointer items-center justify-center rounded-full bg-primary/90 text-primary-foreground transition-all duration-150 hover:bg-primary hover:scale-105 disabled:pointer-events-none disabled:opacity-30 disabled:hover:scale-100 sm:h-8 sm:w-8"198 className="flex h-9 w-9 enabled:cursor-pointer items-center justify-center rounded-full bg-primary/90 text-primary-foreground transition-all duration-150 hover:bg-primary hover:scale-105 disabled:pointer-events-none disabled:opacity-30 disabled:hover:scale-100 sm:h-8 sm:w-8"
196 {...pointerFocusProps}199 {...pointerFocusProps}
197 disabled={isSendBusy || isConnecting || !hasSendableContent}200 disabled={isSendBusy || isConnecting || isEnvironmentUnavailable || !hasSendableContent}
198 aria-label={201 aria-label={
199 isConnecting202 isEnvironmentUnavailable
200 ? "Connecting"203 ? "Environment disconnected"
201 : isPreparingWorktree204 : isConnecting
202 ? "Preparing worktree"205 ? "Connecting"
203 : isSendBusy206 : isPreparingWorktree
204 ? "Sending"207 ? "Preparing worktree"
205 : "Send message"208 : isSendBusy
209 ? "Sending"
210 : "Send message"
206 }211 }
207 >212 >
208 {isConnecting || isSendBusy ? (213 {isConnecting || isSendBusy ? (
apps/web/src/components/desktop/SshPasswordPromptDialog.tsx created+221
...@@ -0,0 +1,221 @@
1import type { DesktopSshPasswordPromptRequest } from "@t3tools/contracts";
2import { useEffect, useId, useRef, useState } from "react";
3
4import { Button } from "../ui/button";
5import {
6 Dialog,
7 DialogDescription,
8 DialogFooter,
9 DialogHeader,
10 DialogPanel,
11 DialogPopup,
12 DialogTitle,
13} from "../ui/dialog";
14import { Input } from "../ui/input";
15
16function describeSshTarget(request: DesktopSshPasswordPromptRequest): string {
17 return request.username ? `${request.username}@${request.destination}` : request.destination;
18}
19
20function formatRemainingSeconds(seconds: number): string {
21 return `${Math.floor(seconds / 60)}:${String(seconds % 60).padStart(2, "0")}`;
22}
23
24function getPromptErrorMessage(error: unknown): string {
25 const message = error instanceof Error ? error.message : "SSH password prompt failed.";
26 return message.includes("expired") || message.includes("no longer pending")
27 ? "This SSH password prompt expired. Try connecting again."
28 : message;
29}
30
31export function SshPasswordPromptDialog() {
32 const [queue, setQueue] = useState<readonly DesktopSshPasswordPromptRequest[]>([]);
33 const [password, setPassword] = useState("");
34 const [isResponding, setIsResponding] = useState(false);
35 const [now, setNow] = useState(() => Date.now());
36 const [responseError, setResponseError] = useState<string | null>(null);
37 const currentRequest = queue[0] ?? null;
38 const inputRef = useRef<HTMLInputElement | null>(null);
39 const isRespondingRef = useRef(false);
40 const formId = useId();
41
42 useEffect(() => {
43 const bridge = window.desktopBridge;
44 if (!bridge?.onSshPasswordPrompt) {
45 return;
46 }
47
48 return bridge.onSshPasswordPrompt((request) => {
49 setQueue((currentQueue) => [...currentQueue, request]);
50 });
51 }, []);
52
53 useEffect(() => {
54 setPassword("");
55 setResponseError(null);
56 if (!currentRequest) {
57 return;
58 }
59
60 setNow(Date.now());
61 const frame = window.requestAnimationFrame(() => {
62 inputRef.current?.focus();
63 inputRef.current?.select();
64 });
65 return () => {
66 window.cancelAnimationFrame(frame);
67 };
68 }, [currentRequest]);
69
70 useEffect(() => {
71 if (!currentRequest) {
72 return;
73 }
74
75 const interval = window.setInterval(() => {
76 setNow(Date.now());
77 }, 1_000);
78 return () => {
79 window.clearInterval(interval);
80 };
81 }, [currentRequest]);
82
83 const expiresAtMs = currentRequest ? Date.parse(currentRequest.expiresAt) : Number.NaN;
84 const remainingMs = Number.isFinite(expiresAtMs) ? Math.max(0, expiresAtMs - now) : null;
85 const isExpired = remainingMs !== null && remainingMs <= 0;
86 const remainingSeconds = remainingMs === null ? null : Math.ceil(remainingMs / 1_000);
87 const remainingLabel =
88 remainingSeconds === null ? null : formatRemainingSeconds(remainingSeconds);
89
90 useEffect(() => {
91 if (isExpired) {
92 setResponseError("This SSH password prompt expired. Try connecting again.");
93 }
94 }, [isExpired]);
95
96 const removeCurrentPrompt = (requestId: string) => {
97 setQueue((currentQueue) =>
98 currentQueue[0]?.requestId === requestId ? currentQueue.slice(1) : currentQueue,
99 );
100 setPassword("");
101 setResponseError(null);
102 };
103
104 const respond = async (nextPassword: string | null) => {
105 if (!currentRequest || isRespondingRef.current) {
106 return;
107 }
108
109 const requestId = currentRequest.requestId;
110 if (nextPassword !== null && isExpired) {
111 setResponseError("This SSH password prompt expired. Try connecting again.");
112 return;
113 }
114
115 isRespondingRef.current = true;
116 setIsResponding(true);
117 setResponseError(null);
118 try {
119 await window.desktopBridge?.resolveSshPasswordPrompt(requestId, nextPassword);
120 removeCurrentPrompt(requestId);
121 } catch (error) {
122 if (nextPassword === null) {
123 removeCurrentPrompt(requestId);
124 } else {
125 setResponseError(getPromptErrorMessage(error));
126 }
127 } finally {
128 isRespondingRef.current = false;
129 setIsResponding(false);
130 }
131 };
132
133 const dismissExpiredPrompt = () => {
134 if (currentRequest) {
135 removeCurrentPrompt(currentRequest.requestId);
136 }
137 };
138
139 const cancelPrompt = () => {
140 if (isExpired) {
141 dismissExpiredPrompt();
142 return;
143 }
144 void respond(null);
145 };
146
147 const target = currentRequest ? describeSshTarget(currentRequest) : null;
148
149 return (
150 <Dialog
151 open={currentRequest !== null}
152 onOpenChange={(open) => {
153 if (!open) {
154 cancelPrompt();
155 }
156 }}
157 >
158 <DialogPopup className="max-w-md" showCloseButton={false}>
159 <DialogHeader>
160 <DialogTitle>SSH Password Required</DialogTitle>
161 <DialogDescription>
162 T3 needs your SSH password to connect to{" "}
163 {target ? <code>{target}</code> : "the remote host"}. The password is passed to the
164 local SSH process for this connection attempt and is not saved by T3 Code.
165 </DialogDescription>
166 </DialogHeader>
167 <DialogPanel className="space-y-3" scrollFade={false}>
168 <form
169 className="space-y-3"
170 id={formId}
171 onSubmit={(event) => {
172 event.preventDefault();
173 void respond(password);
174 }}
175 >
176 <div className="space-y-2">
177 <div className="flex items-center justify-between gap-3">
178 <p className="text-sm font-medium text-foreground">{currentRequest?.prompt}</p>
179 {remainingLabel ? (
180 <span
181 className={
182 isExpired
183 ? "shrink-0 text-xs font-medium text-destructive"
184 : "shrink-0 text-xs text-muted-foreground"
185 }
186 >
187 {isExpired ? "Expired" : remainingLabel}
188 </span>
189 ) : null}
190 </div>
191 <Input
192 ref={inputRef}
193 autoComplete="current-password"
194 disabled={isResponding || isExpired}
195 name="ssh-password"
196 type="password"
197 value={password}
198 onChange={(event) => setPassword(event.target.value)}
199 />
200 </div>
201 {responseError ? (
202 <p className="text-sm text-destructive">{responseError}</p>
203 ) : (
204 <p className="text-sm text-muted-foreground">
205 Use SSH keys to avoid repeated password prompts on new SSH sessions.
206 </p>
207 )}
208 </form>
209 </DialogPanel>
210 <DialogFooter>
211 <Button disabled={isResponding} type="button" variant="outline" onClick={cancelPrompt}>
212 {isExpired ? "Dismiss" : "Cancel"}
213 </Button>
214 <Button disabled={isResponding || isExpired} form={formId} type="submit">
215 Continue
216 </Button>
217 </DialogFooter>
218 </DialogPopup>
219 </Dialog>
220 );
221}
apps/web/src/components/settings/ConnectionsSettings.tsx+1636-293
...@@ -1,8 +1,28 @@...@@ -1,8 +1,28 @@
1import { PlusIcon, QrCodeIcon } from "lucide-react";1import {
2import { memo, useCallback, useEffect, useMemo, useState } from "react";2 ChevronDownIcon,
3 ChevronsLeftRightEllipsisIcon,
4 PlusIcon,
5 QrCodeIcon,
6 RefreshCwIcon,
7 TerminalIcon,
8 TriangleAlertIcon,
9} from "lucide-react";
10import {
11 type ReactNode,
12 memo,
13 useCallback,
14 useEffect,
15 useLayoutEffect,
16 useMemo,
17 useRef,
18 useState,
19} from "react";
3import {20import {
4 type AuthClientSession,21 type AuthClientSession,
5 type AuthPairingLink,22 type AuthPairingLink,
23 type AdvertisedEndpoint,
24 type DesktopDiscoveredSshHost,
25 type DesktopSshEnvironmentTarget,
6 type DesktopServerExposureState,26 type DesktopServerExposureState,
7 type EnvironmentId,27 type EnvironmentId,
8} from "@t3tools/contracts";28} from "@t3tools/contracts";
...@@ -11,6 +31,7 @@ import { DateTime } from "effect";...@@ -11,6 +31,7 @@ import { DateTime } from "effect";
11import { useCopyToClipboard } from "../../hooks/useCopyToClipboard";31import { useCopyToClipboard } from "../../hooks/useCopyToClipboard";
12import { cn } from "../../lib/utils";32import { cn } from "../../lib/utils";
13import { formatElapsedDurationLabel, formatExpiresInLabel } from "../../timestampFormat";33import { formatElapsedDurationLabel, formatExpiresInLabel } from "../../timestampFormat";
34import { resolveDesktopPairingUrl, resolveHostedPairingUrl } from "./pairingUrls";
14import {35import {
15 SettingsPageContainer,36 SettingsPageContainer,
16 SettingsRow,37 SettingsRow,
...@@ -20,6 +41,7 @@ import {...@@ -20,6 +41,7 @@ import {
20import { Input } from "../ui/input";41import { Input } from "../ui/input";
21import {42import {
22 Dialog,43 Dialog,
44 DialogClose,
23 DialogFooter,45 DialogFooter,
24 DialogDescription,46 DialogDescription,
25 DialogHeader,47 DialogHeader,
...@@ -28,6 +50,7 @@ import {...@@ -28,6 +50,7 @@ import {
28 DialogTitle,50 DialogTitle,
29 DialogTrigger,51 DialogTrigger,
30} from "../ui/dialog";52} from "../ui/dialog";
53import { ScrollArea } from "../ui/scroll-area";
31import {54import {
32 AlertDialog,55 AlertDialog,
33 AlertDialogClose,56 AlertDialogClose,
...@@ -44,8 +67,19 @@ import { Switch } from "../ui/switch";...@@ -44,8 +67,19 @@ import { Switch } from "../ui/switch";
44import { stackedThreadToast, toastManager } from "../ui/toast";67import { stackedThreadToast, toastManager } from "../ui/toast";
45import { Tooltip, TooltipPopup, TooltipTrigger } from "../ui/tooltip";68import { Tooltip, TooltipPopup, TooltipTrigger } from "../ui/tooltip";
46import { Button } from "../ui/button";69import { Button } from "../ui/button";
70import { Group, GroupSeparator } from "../ui/group";
71import {
72 Menu,
73 MenuGroup,
74 MenuGroupLabel,
75 MenuItem,
76 MenuPopup,
77 MenuSeparator,
78 MenuTrigger,
79} from "../ui/menu";
47import { Textarea } from "../ui/textarea";80import { Textarea } from "../ui/textarea";
48import { setPairingTokenOnUrl } from "../../pairingUrl";81import { getPairingTokenFromUrl, setPairingTokenOnUrl } from "../../pairingUrl";
82import { readHostedPairingRequest } from "../../hostedPairing";
49import {83import {
50 createServerPairingCredential,84 createServerPairingCredential,
51 fetchSessionState,85 fetchSessionState,
...@@ -63,10 +97,17 @@ import {...@@ -63,10 +97,17 @@ import {
63 useSavedEnvironmentRegistryStore,97 useSavedEnvironmentRegistryStore,
64 useSavedEnvironmentRuntimeStore,98 useSavedEnvironmentRuntimeStore,
65 addSavedEnvironment,99 addSavedEnvironment,
100 connectDesktopSshEnvironment,
101 disconnectSavedEnvironment,
66 getPrimaryEnvironmentConnection,102 getPrimaryEnvironmentConnection,
67 reconnectSavedEnvironment,103 reconnectSavedEnvironment,
68 removeSavedEnvironment,104 removeSavedEnvironment,
69} from "~/environments/runtime";105} from "~/environments/runtime";
106import { useUiStateStore } from "~/uiStateStore";
107import { resolveServerConfigVersionMismatch } from "~/versionSkew";
108import { useServerConfig } from "~/rpc/serverState";
109
110const DEFAULT_TAILSCALE_SERVE_PORT = 443;
70111
71const accessTimestampFormatter = new Intl.DateTimeFormat(undefined, {112const accessTimestampFormatter = new Intl.DateTimeFormat(undefined, {
72 dateStyle: "medium",113 dateStyle: "medium",
...@@ -135,6 +176,62 @@ function ConnectionStatusDot({...@@ -135,6 +176,62 @@ function ConnectionStatusDot({
135 );176 );
136}177}
137178
179function AnimatedHeight({ children }: { readonly children: ReactNode }) {
180 const contentRef = useRef<HTMLDivElement>(null);
181 const [height, setHeight] = useState<number | null>(null);
182
183 useLayoutEffect(() => {
184 const element = contentRef.current;
185 if (!element) return;
186 let firstFrameId: number | null = null;
187 let secondFrameId: number | null = null;
188
189 const updateHeight = () => {
190 const nextHeight = Math.ceil(element.scrollHeight || element.getBoundingClientRect().height);
191 setHeight((currentHeight) => (currentHeight === nextHeight ? currentHeight : nextHeight));
192 };
193 const cancelPendingFrames = () => {
194 if (firstFrameId !== null) {
195 window.cancelAnimationFrame(firstFrameId);
196 firstFrameId = null;
197 }
198 if (secondFrameId !== null) {
199 window.cancelAnimationFrame(secondFrameId);
200 secondFrameId = null;
201 }
202 };
203 const updateHeightAfterPaint = () => {
204 cancelPendingFrames();
205 updateHeight();
206 firstFrameId = window.requestAnimationFrame(() => {
207 firstFrameId = null;
208 updateHeight();
209 secondFrameId = window.requestAnimationFrame(() => {
210 secondFrameId = null;
211 updateHeight();
212 });
213 });
214 };
215
216 updateHeightAfterPaint();
217 const resizeObserver = new ResizeObserver(updateHeightAfterPaint);
218 resizeObserver.observe(element);
219 return () => {
220 resizeObserver.disconnect();
221 cancelPendingFrames();
222 };
223 }, []);
224
225 return (
226 <div
227 className="overflow-hidden transition-[height] duration-200 ease-out motion-reduce:transition-none"
228 style={height === null ? undefined : { height }}
229 >
230 <div ref={contentRef}>{children}</div>
231 </div>
232 );
233}
234
138function getSavedBackendStatusTooltip(235function getSavedBackendStatusTooltip(
139 runtime: SavedEnvironmentRuntimeState | null,236 runtime: SavedEnvironmentRuntimeState | null,
140 record: SavedEnvironmentRecord,237 record: SavedEnvironmentRecord,
...@@ -160,12 +257,153 @@ function getSavedBackendStatusTooltip(...@@ -160,12 +257,153 @@ function getSavedBackendStatusTooltip(
160 : "Not connected yet.";257 : "Not connected yet.";
161}258}
162259
260function formatDesktopSshTarget(target: NonNullable<SavedEnvironmentRecord["desktopSsh"]>): string {
261 const authority = target.username ? `${target.username}@${target.hostname}` : target.hostname;
262 return target.port ? `${authority}:${target.port}` : authority;
263}
264
265function parseManualDesktopSshTarget(input: {
266 readonly host: string;
267 readonly username: string;
268 readonly port: string;
269}): DesktopSshEnvironmentTarget {
270 const rawHost = input.host.trim();
271 if (rawHost.length === 0) {
272 throw new Error("SSH host or alias is required.");
273 }
274
275 let hostname = rawHost;
276 let username = input.username.trim() || null;
277 let port: number | null = null;
278
279 const atIndex = hostname.lastIndexOf("@");
280 if (atIndex > 0) {
281 const inlineUsername = hostname.slice(0, atIndex).trim();
282 hostname = hostname.slice(atIndex + 1).trim();
283 if (!username && inlineUsername.length > 0) {
284 username = inlineUsername;
285 }
286 }
287
288 const bracketedHostMatch = /^\[([^\]]+)\](?::(\d+))?$/u.exec(hostname);
289 if (bracketedHostMatch) {
290 hostname = bracketedHostMatch[1]!.trim();
291 if (bracketedHostMatch[2]) {
292 port = Number.parseInt(bracketedHostMatch[2], 10);
293 }
294 } else {
295 const colonSegments = hostname.split(":");
296 if (colonSegments.length === 2 && /^\d+$/u.test(colonSegments[1] ?? "")) {
297 hostname = colonSegments[0]!.trim();
298 port = Number.parseInt(colonSegments[1]!, 10);
299 }
300 }
301
302 const rawPort = input.port.trim();
303 if (rawPort.length > 0) {
304 port = Number.parseInt(rawPort, 10);
305 }
306
307 if (hostname.length === 0) {
308 throw new Error("SSH host or alias is required.");
309 }
310
311 if (port !== null && (!Number.isInteger(port) || port <= 0 || port > 65_535)) {
312 throw new Error("SSH port must be between 1 and 65535.");
313 }
314
315 return {
316 alias: hostname,
317 hostname,
318 username,
319 port,
320 };
321}
322
323function parsePairingUrlFields(
324 input: string,
325): { readonly host: string; readonly pairingCode: string } | null {
326 const trimmed = input.trim();
327 if (!trimmed) return null;
328
329 try {
330 const urlLikeInput =
331 /^[a-zA-Z][a-zA-Z\d+.-]*:\/\//u.test(trimmed) || trimmed.startsWith("//")
332 ? trimmed
333 : `https://${trimmed}`;
334 const url = new URL(urlLikeInput, window.location.origin);
335 const hostedPairingRequest = readHostedPairingRequest(url);
336 if (hostedPairingRequest) {
337 return {
338 host: hostedPairingRequest.host,
339 pairingCode: hostedPairingRequest.token,
340 };
341 }
342
343 const pairingCode = getPairingTokenFromUrl(url);
344 if (!pairingCode) return null;
345 return {
346 host: url.origin,
347 pairingCode,
348 };
349 } catch {
350 return null;
351 }
352}
353
354function parseRemotePairingFields(input: { readonly host: string; readonly pairingCode: string }): {
355 readonly host: string;
356 readonly pairingCode: string;
357} {
358 const parsedPairingUrl = parsePairingUrlFields(input.host);
359 if (parsedPairingUrl) return parsedPairingUrl;
360
361 const host = input.host.trim();
362 const pairingCode = input.pairingCode.trim();
363 if (!host) {
364 throw new Error("Enter a backend host.");
365 }
366 if (!pairingCode) {
367 throw new Error("Enter a pairing code.");
368 }
369 return { host, pairingCode };
370}
371
372function formatDesktopSshConnectionError(error: unknown): string {
373 const fallback = "Failed to connect SSH host.";
374 const rawMessage = error instanceof Error ? error.message : fallback;
375 const withoutIpcPrefix = rawMessage.replace(
376 /^Error invoking remote method 'desktop:ensure-ssh-environment':\s*/u,
377 "",
378 );
379 const withoutTaggedErrorPrefix = withoutIpcPrefix.replace(/^Ssh[A-Za-z]+Error:\s*/u, "");
380 return withoutTaggedErrorPrefix.trim() || fallback;
381}
382
163/** Direct row in the card – same pattern as the Provider / ACP-agent list rows. */383/** Direct row in the card – same pattern as the Provider / ACP-agent list rows. */
164const ITEM_ROW_CLASSNAME = "border-t border-border/60 px-4 py-4 first:border-t-0 sm:px-5";384const ITEM_ROW_CLASSNAME = "border-t border-border/60 px-4 py-4 first:border-t-0 sm:px-5";
385const ENDPOINT_ROW_CLASSNAME = "border-t border-border/60 px-4 py-2.5 first:border-t-0 sm:px-5";
165386
166const ITEM_ROW_INNER_CLASSNAME =387const ITEM_ROW_INNER_CLASSNAME =
167 "flex flex-col gap-3 sm:flex-row sm:items-center sm:justify-between";388 "flex flex-col gap-3 sm:flex-row sm:items-center sm:justify-between";
168389
390type AccessSectionPresentation = "current" | "endpoint-rail";
391
392function accessRowClassName(_presentation: AccessSectionPresentation) {
393 return ITEM_ROW_CLASSNAME;
394}
395
396function endpointRowClassName(presentation: AccessSectionPresentation, isAvailable: boolean) {
397 if (presentation === "endpoint-rail") {
398 return cn(
399 "relative border-t border-border/60 px-4 py-3 first:border-t-0 sm:px-5",
400 !isAvailable && "bg-muted/20",
401 );
402 }
403
404 return cn(ENDPOINT_ROW_CLASSNAME, !isAvailable && "bg-muted/24");
405}
406
169function sortDesktopPairingLinks(links: ReadonlyArray<ServerPairingLinkRecord>) {407function sortDesktopPairingLinks(links: ReadonlyArray<ServerPairingLinkRecord>) {
170 return [...links].toSorted(408 return [...links].toSorted(
171 (left, right) => new Date(right.createdAt).getTime() - new Date(left.createdAt).getTime(),409 (left, right) => new Date(right.createdAt).getTime() - new Date(left.createdAt).getTime(),
...@@ -243,10 +481,66 @@ function removeDesktopClientSession(...@@ -243,10 +481,66 @@ function removeDesktopClientSession(
243 return current.filter((clientSession) => clientSession.sessionId !== sessionId);481 return current.filter((clientSession) => clientSession.sessionId !== sessionId);
244}482}
245483
246function resolveDesktopPairingUrl(endpointUrl: string, credential: string): string {484function selectPairingEndpoint(
247 const url = new URL(endpointUrl);485 endpoints: ReadonlyArray<AdvertisedEndpoint>,
248 url.pathname = "/pair";486 defaultEndpointKey?: string | null,
249 return setPairingTokenOnUrl(url, credential).toString();487): AdvertisedEndpoint | null {
488 const availableEndpoints = endpoints.filter((endpoint) => endpoint.status !== "unavailable");
489 if (defaultEndpointKey) {
490 const selectedEndpoint = availableEndpoints.find(
491 (endpoint) => endpointDefaultPreferenceKey(endpoint) === defaultEndpointKey,
492 );
493 if (selectedEndpoint) {
494 return selectedEndpoint;
495 }
496 }
497 return (
498 availableEndpoints.find((endpoint) => endpoint.isDefault) ??
499 availableEndpoints.find((endpoint) => endpoint.reachability !== "loopback") ??
500 availableEndpoints.find((endpoint) => endpoint.compatibility.hostedHttpsApp === "compatible") ??
501 null
502 );
503}
504
505function isTailscaleHttpsEndpoint(endpoint: AdvertisedEndpoint): boolean {
506 return endpoint.id.startsWith("tailscale-magicdns:");
507}
508
509function endpointDefaultPreferenceKey(endpoint: AdvertisedEndpoint): string {
510 if (endpoint.id.startsWith("desktop-loopback:")) {
511 return "desktop-core:loopback:http";
512 }
513 if (endpoint.id.startsWith("desktop-lan:")) {
514 return "desktop-core:lan:http";
515 }
516 if (endpoint.id.startsWith("tailscale-ip:")) {
517 return "tailscale:ip:http";
518 }
519 if (isTailscaleHttpsEndpoint(endpoint)) {
520 return "tailscale:magicdns:https";
521 }
522
523 let scheme = "unknown";
524 try {
525 scheme = new URL(endpoint.httpBaseUrl).protocol.replace(/:$/u, "");
526 } catch {
527 // Keep the stored preference stable even if a custom endpoint is malformed.
528 }
529
530 return `${endpoint.provider.id}:${endpoint.reachability}:${scheme}:${endpoint.label}`;
531}
532
533function resolveAdvertisedEndpointPairingUrl(
534 endpoint: AdvertisedEndpoint,
535 credential: string,
536): string {
537 if (endpoint.compatibility.hostedHttpsApp === "compatible") {
538 return (
539 resolveHostedPairingUrl(endpoint.httpBaseUrl, credential) ??
540 resolveDesktopPairingUrl(endpoint.httpBaseUrl, credential)
541 );
542 }
543 return resolveDesktopPairingUrl(endpoint.httpBaseUrl, credential);
250}544}
251545
252function resolveCurrentOriginPairingUrl(credential: string): string {546function resolveCurrentOriginPairingUrl(credential: string): string {
...@@ -254,9 +548,21 @@ function resolveCurrentOriginPairingUrl(credential: string): string {...@@ -254,9 +548,21 @@ function resolveCurrentOriginPairingUrl(credential: string): string {
254 return setPairingTokenOnUrl(url, credential).toString();548 return setPairingTokenOnUrl(url, credential).toString();
255}549}
256550
551function isHostedAppPairingUrl(value: string): boolean {
552 try {
553 const url = new URL(value);
554 return url.pathname === "/pair" && url.searchParams.has("host");
555 } catch {
556 return false;
557 }
558}
559
257type PairingLinkListRowProps = {560type PairingLinkListRowProps = {
258 pairingLink: ServerPairingLinkRecord;561 pairingLink: ServerPairingLinkRecord;
259 endpointUrl: string | null | undefined;562 endpointUrl: string | null | undefined;
563 endpoints: ReadonlyArray<AdvertisedEndpoint>;
564 defaultEndpointKey: string | null;
565 presentation?: AccessSectionPresentation;
260 revokingPairingLinkId: string | null;566 revokingPairingLinkId: string | null;
261 onRevoke: (id: string) => void;567 onRevoke: (id: string) => void;
262};568};
...@@ -264,6 +570,9 @@ type PairingLinkListRowProps = {...@@ -264,6 +570,9 @@ type PairingLinkListRowProps = {
264const PairingLinkListRow = memo(function PairingLinkListRow({570const PairingLinkListRow = memo(function PairingLinkListRow({
265 pairingLink,571 pairingLink,
266 endpointUrl,572 endpointUrl,
573 endpoints,
574 defaultEndpointKey,
575 presentation = "current",
267 revokingPairingLinkId,576 revokingPairingLinkId,
268 onRevoke,577 onRevoke,
269}: PairingLinkListRowProps) {578}: PairingLinkListRowProps) {
...@@ -278,55 +587,197 @@ const PairingLinkListRow = memo(function PairingLinkListRow({...@@ -278,55 +587,197 @@ const PairingLinkListRow = memo(function PairingLinkListRow({
278 () => resolveCurrentOriginPairingUrl(pairingLink.credential),587 () => resolveCurrentOriginPairingUrl(pairingLink.credential),
279 [pairingLink.credential],588 [pairingLink.credential],
280 );589 );
590 const hostedPairingUrl = useMemo(
591 () =>
592 endpointUrl != null && endpointUrl !== ""
593 ? resolveHostedPairingUrl(endpointUrl, pairingLink.credential)
594 : null,
595 [endpointUrl, pairingLink.credential],
596 );
597 const endpointPairingUrl = useMemo(() => {
598 const endpoint = selectPairingEndpoint(endpoints, defaultEndpointKey);
599 return endpoint ? resolveAdvertisedEndpointPairingUrl(endpoint, pairingLink.credential) : null;
600 }, [defaultEndpointKey, endpoints, pairingLink.credential]);
601 const endpointCopyOptions = useMemo(
602 () =>
603 endpoints
604 .filter((endpoint) => endpoint.status !== "unavailable")
605 .map((endpoint) => {
606 const url = resolveAdvertisedEndpointPairingUrl(endpoint, pairingLink.credential);
607 return {
608 key: endpointDefaultPreferenceKey(endpoint),
609 label: endpoint.label,
610 url,
611 detail: isHostedAppPairingUrl(url) ? "Hosted app link" : "Backend pairing URL",
612 };
613 }),
614 [endpoints, pairingLink.credential],
615 );
281 const shareablePairingUrl =616 const shareablePairingUrl =
282 endpointUrl != null && endpointUrl !== ""617 endpointPairingUrl ??
283 ? resolveDesktopPairingUrl(endpointUrl, pairingLink.credential)618 (endpointUrl != null && endpointUrl !== ""
619 ? (hostedPairingUrl ?? resolveDesktopPairingUrl(endpointUrl, pairingLink.credential))
284 : isLoopbackHostname(window.location.hostname)620 : isLoopbackHostname(window.location.hostname)
285 ? null621 ? null
286 : currentOriginPairingUrl;622 : currentOriginPairingUrl);
287 const copyValue = shareablePairingUrl ?? pairingLink.credential;623 const revealValue = shareablePairingUrl ?? pairingLink.credential;
624 const isShareableHostedAppPairingUrl =
625 shareablePairingUrl !== null && isHostedAppPairingUrl(shareablePairingUrl);
288 const canCopyToClipboard =626 const canCopyToClipboard =
289 typeof window !== "undefined" &&627 typeof window !== "undefined" &&
290 window.isSecureContext &&628 window.isSecureContext &&
291 navigator.clipboard?.writeText != null;629 navigator.clipboard?.writeText != null;
292630
293 const { copyToClipboard, isCopied } = useCopyToClipboard({631 const { copyToClipboard } = useCopyToClipboard<"code" | "hosted-link" | "link">({
294 onCopy: () => {632 onCopy: (kind) => {
295 toastManager.add({633 toastManager.add({
296 type: "success",634 type: "success",
297 title: shareablePairingUrl ? "Pairing URL copied" : "Pairing token copied",635 title:
298 description: shareablePairingUrl636 kind === "hosted-link"
299 ? "Open it in the client you want to pair to this environment."637 ? "Hosted app link copied"
300 : "Paste it into another client with this backend's reachable host.",638 : kind === "link"
639 ? "Pairing URL copied"
640 : "Pairing code copied",
641 description:
642 kind === "hosted-link"
643 ? "Open it in the browser on the device you want to connect."
644 : kind === "link"
645 ? "Open it in the client you want to pair to this environment."
646 : "Paste it into another client to finish pairing.",
301 });647 });
302 },648 },
303 onError: (error) => {649 onError: (error, kind) => {
304 setIsRevealDialogOpen(true);650 setIsRevealDialogOpen(true);
305 toastManager.add(651 toastManager.add(
306 stackedThreadToast({652 stackedThreadToast({
307 type: "error",653 type: "error",
308 title: canCopyToClipboard ? "Could not copy pairing URL" : "Clipboard copy unavailable",654 title: canCopyToClipboard
655 ? kind === "hosted-link"
656 ? "Could not copy hosted app link"
657 : kind === "link"
658 ? "Could not copy pairing URL"
659 : "Could not copy pairing code"
660 : "Clipboard copy unavailable",
309 description: canCopyToClipboard ? error.message : "Showing the full value instead.",661 description: canCopyToClipboard ? error.message : "Showing the full value instead.",
310 }),662 }),
311 );663 );
312 },664 },
313 });665 });
314666
315 const handleCopy = useCallback(() => {667 const copyPairingValue = useCallback(
316 copyToClipboard(copyValue, undefined);668 (value: string, kind: "code" | "hosted-link" | "link") => {
317 }, [copyToClipboard, copyValue]);669 copyToClipboard(value, kind);
670 },
671 [copyToClipboard],
672 );
673
674 const copyKindForUrl = useCallback(
675 (url: string): "hosted-link" | "link" => (isHostedAppPairingUrl(url) ? "hosted-link" : "link"),
676 [],
677 );
678
679 const handleCopyCode = useCallback(() => {
680 copyPairingValue(pairingLink.credential, "code");
681 }, [copyPairingValue, pairingLink.credential]);
682
683 const handleCopyDefaultLink = useCallback(() => {
684 if (!shareablePairingUrl) return;
685 copyPairingValue(shareablePairingUrl, copyKindForUrl(shareablePairingUrl));
686 }, [copyKindForUrl, copyPairingValue, shareablePairingUrl]);
318687
319 const expiresAbsolute = formatAccessTimestamp(pairingLink.expiresAt);688 const expiresAbsolute = formatAccessTimestamp(pairingLink.expiresAt);
320689
321 const roleLabel = pairingLink.role === "owner" ? "Owner" : "Client";690 const roleLabel = pairingLink.role === "owner" ? "Owner" : "Client";
322 const primaryLabel = pairingLink.label ?? `${roleLabel} link`;691 const primaryLabel = pairingLink.label ?? `${roleLabel} link`;
692 const defaultEndpointCopyOption =
693 endpointCopyOptions.find((option) => option.key === defaultEndpointKey) ??
694 endpointCopyOptions[0] ??
695 null;
696 const defaultEndpointCopyLabel = defaultEndpointCopyOption?.label ?? "URL";
697 const backendEndpointCopyOptions = endpointCopyOptions.filter(
698 (option) => !isHostedAppPairingUrl(option.url),
699 );
700 const hostedEndpointCopyOptions = endpointCopyOptions.filter((option) =>
701 isHostedAppPairingUrl(option.url),
702 );
703 const renderEndpointMenuItems = (
704 options: typeof endpointCopyOptions = endpointCopyOptions,
705 renderDetail = true,
706 ) =>
707 options.map((option) => (
708 <MenuItem
709 key={option.key}
710 onClick={() => copyPairingValue(option.url, copyKindForUrl(option.url))}
711 >
712 <span className="min-w-0 flex-1">
713 <span className="block truncate">{option.label}</span>
714 {renderDetail ? (
715 <span className="block truncate text-[11px] text-muted-foreground">
716 {option.detail}
717 </span>
718 ) : null}
719 </span>
720 </MenuItem>
721 ));
722 const renderPairingCodeMenuItem = (renderDetail = true) => (
723 <MenuItem onClick={handleCopyCode}>
724 <span className="min-w-0 flex-1">
725 <span className="block truncate">Copy code</span>
726 {renderDetail ? (
727 <span className="block truncate text-[11px] text-muted-foreground">Token only</span>
728 ) : null}
729 </span>
730 </MenuItem>
731 );
732 const renderCompactEndpointGroup = (
733 label: string,
734 options: typeof endpointCopyOptions,
735 includeSeparator: boolean,
736 ) =>
737 options.length > 0 ? (
738 <>
739 {includeSeparator ? <MenuSeparator /> : null}
740 <MenuGroup>
741 <MenuGroupLabel>{label}</MenuGroupLabel>
742 {renderEndpointMenuItems(options, false)}
743 </MenuGroup>
744 </>
745 ) : null;
746 const renderGroupedCopyMenuItems = (options?: { codeFirst?: boolean }) => (
747 <>
748 {options?.codeFirst ? (
749 <>
750 <MenuGroup>
751 <MenuGroupLabel>Pairing code</MenuGroupLabel>
752 {renderPairingCodeMenuItem(false)}
753 </MenuGroup>
754 {endpointCopyOptions.length > 0 ? <MenuSeparator /> : null}
755 </>
756 ) : null}
757 {renderCompactEndpointGroup("Pairing URLs", backendEndpointCopyOptions, false)}
758 {renderCompactEndpointGroup(
759 "Hosted app link",
760 hostedEndpointCopyOptions,
761 backendEndpointCopyOptions.length > 0,
762 )}
763 {!options?.codeFirst ? (
764 <>
765 {endpointCopyOptions.length > 0 ? <MenuSeparator /> : null}
766 <MenuGroup>
767 <MenuGroupLabel>Pairing code</MenuGroupLabel>
768 {renderPairingCodeMenuItem(false)}
769 </MenuGroup>
770 </>
771 ) : null}
772 </>
773 );
323774
324 if (expiresAtMs <= nowMs) {775 if (expiresAtMs <= nowMs) {
325 return null;776 return null;
326 }777 }
327778
328 return (779 return (
329 <div className={ITEM_ROW_CLASSNAME}>780 <div className={accessRowClassName(presentation)}>
330 <div className={ITEM_ROW_INNER_CLASSNAME}>781 <div className={ITEM_ROW_INNER_CLASSNAME}>
331 <div className="min-w-0 flex-1 space-y-1">782 <div className="min-w-0 flex-1 space-y-1">
332 <div className="flex min-h-5 items-center gap-1.5">783 <div className="flex min-h-5 items-center gap-1.5">
...@@ -377,27 +828,70 @@ const PairingLinkListRow = memo(function PairingLinkListRow({...@@ -377,27 +828,70 @@ const PairingLinkListRow = memo(function PairingLinkListRow({
377 <div className="flex w-full shrink-0 items-center gap-2 sm:w-auto sm:justify-end">828 <div className="flex w-full shrink-0 items-center gap-2 sm:w-auto sm:justify-end">
378 <Dialog open={isRevealDialogOpen} onOpenChange={setIsRevealDialogOpen}>829 <Dialog open={isRevealDialogOpen} onOpenChange={setIsRevealDialogOpen}>
379 {canCopyToClipboard ? (830 {canCopyToClipboard ? (
380 <Button size="xs" variant="outline" onClick={handleCopy}>831 <>
381 {isCopied ? "Copied" : shareablePairingUrl ? "Copy" : "Copy token"}832 {shareablePairingUrl ? (
382 </Button>833 <Group aria-label="Copy selected endpoint">
834 <Button
835 size="xs"
836 variant="outline"
837 className="max-w-56"
838 title={`Copy pairing URL for: ${defaultEndpointCopyLabel}`}
839 onClick={handleCopyDefaultLink}
840 >
841 <span className="truncate">
842 Copy pairing URL for: {defaultEndpointCopyLabel}
843 </span>
844 </Button>
845 <GroupSeparator />
846 <Menu>
847 <MenuTrigger
848 render={
849 <Button
850 size="icon-xs"
851 variant="outline"
852 aria-label="Choose endpoint to copy"
853 />
854 }
855 >
856 <ChevronDownIcon className="size-3.5" />
857 </MenuTrigger>
858 <MenuPopup align="end" className="min-w-60">
859 {renderGroupedCopyMenuItems()}
860 </MenuPopup>
861 </Menu>
862 </Group>
863 ) : (
864 <Button size="xs" variant="outline" onClick={handleCopyCode}>
865 Copy code
866 </Button>
867 )}
868 </>
383 ) : (869 ) : (
384 <DialogTrigger render={<Button size="xs" variant="outline" />}>870 <DialogTrigger render={<Button size="xs" variant="outline" />}>
385 {shareablePairingUrl ? "Show link" : "Show token"}871 {shareablePairingUrl ? "Show link" : "Show code"}
386 </DialogTrigger>872 </DialogTrigger>
387 )}873 )}
388 <DialogPopup className="max-w-md">874 <DialogPopup className="max-w-md">
389 <DialogHeader>875 <DialogHeader>
390 <DialogTitle>{shareablePairingUrl ? "Pairing link" : "Pairing token"}</DialogTitle>876 <DialogTitle>
877 {shareablePairingUrl
878 ? isShareableHostedAppPairingUrl
879 ? "Hosted app pairing link"
880 : "Pairing link"
881 : "Pairing code"}
882 </DialogTitle>
391 <DialogDescription>883 <DialogDescription>
392 {shareablePairingUrl884 {shareablePairingUrl
393 ? "Clipboard copy is unavailable here. Open or manually copy this full pairing URL on the device you want to connect."885 ? isShareableHostedAppPairingUrl
394 : "Clipboard copy is unavailable here. Manually copy this token and pair from another client using this backend's reachable host."}886 ? "Clipboard copy is unavailable here. Open or manually copy this hosted app link on the device you want to connect."
887 : "Clipboard copy is unavailable here. Open or manually copy this full pairing URL on the device you want to connect."
888 : "Clipboard copy is unavailable here. Manually copy this code into another client."}
395 </DialogDescription>889 </DialogDescription>
396 </DialogHeader>890 </DialogHeader>
397 <DialogPanel className="space-y-4">891 <DialogPanel className="space-y-4">
398 <Textarea892 <Textarea
399 readOnly893 readOnly
400 value={copyValue}894 value={revealValue}
401 rows={shareablePairingUrl ? 4 : 3}895 rows={shareablePairingUrl ? 4 : 3}
402 className="text-xs leading-relaxed"896 className="text-xs leading-relaxed"
403 onFocus={(event) => event.currentTarget.select()}897 onFocus={(event) => event.currentTarget.select()}
...@@ -420,8 +914,8 @@ const PairingLinkListRow = memo(function PairingLinkListRow({...@@ -420,8 +914,8 @@ const PairingLinkListRow = memo(function PairingLinkListRow({
420 Done914 Done
421 </Button>915 </Button>
422 {canCopyToClipboard ? (916 {canCopyToClipboard ? (
423 <Button variant="outline" size="xs" onClick={handleCopy}>917 <Button variant="outline" size="xs" onClick={handleCopyCode}>
424 {isCopied ? "Copied" : "Copy again"}918 Copy code
425 </Button>919 </Button>
426 ) : null}920 ) : null}
427 </DialogFooter>921 </DialogFooter>
...@@ -443,12 +937,14 @@ const PairingLinkListRow = memo(function PairingLinkListRow({...@@ -443,12 +937,14 @@ const PairingLinkListRow = memo(function PairingLinkListRow({
443937
444type ConnectedClientListRowProps = {938type ConnectedClientListRowProps = {
445 clientSession: ServerClientSessionRecord;939 clientSession: ServerClientSessionRecord;
940 presentation?: AccessSectionPresentation;
446 revokingClientSessionId: string | null;941 revokingClientSessionId: string | null;
447 onRevokeSession: (sessionId: ServerClientSessionRecord["sessionId"]) => void;942 onRevokeSession: (sessionId: ServerClientSessionRecord["sessionId"]) => void;
448};943};
449944
450const ConnectedClientListRow = memo(function ConnectedClientListRow({945const ConnectedClientListRow = memo(function ConnectedClientListRow({
451 clientSession,946 clientSession,
947 presentation = "current",
452 revokingClientSessionId,948 revokingClientSessionId,
453 onRevokeSession,949 onRevokeSession,
454}: ConnectedClientListRowProps) {950}: ConnectedClientListRowProps) {
...@@ -477,7 +973,7 @@ const ConnectedClientListRow = memo(function ConnectedClientListRow({...@@ -477,7 +973,7 @@ const ConnectedClientListRow = memo(function ConnectedClientListRow({
477 clientSession.subject);973 clientSession.subject);
478974
479 return (975 return (
480 <div className={ITEM_ROW_CLASSNAME}>976 <div className={accessRowClassName(presentation)}>
481 <div className={ITEM_ROW_INNER_CLASSNAME}>977 <div className={ITEM_ROW_INNER_CLASSNAME}>
482 <div className="min-w-0 flex-1 space-y-1">978 <div className="min-w-0 flex-1 space-y-1">
483 <div className="flex min-h-5 items-center gap-1.5">979 <div className="flex min-h-5 items-center gap-1.5">
...@@ -620,6 +1116,9 @@ const AuthorizedClientsHeaderAction = memo(function AuthorizedClientsHeaderActio...@@ -620,6 +1116,9 @@ const AuthorizedClientsHeaderAction = memo(function AuthorizedClientsHeaderActio
6201116
621type PairingClientsListProps = {1117type PairingClientsListProps = {
622 endpointUrl: string | null | undefined;1118 endpointUrl: string | null | undefined;
1119 endpoints: ReadonlyArray<AdvertisedEndpoint>;
1120 defaultEndpointKey: string | null;
1121 presentation?: AccessSectionPresentation;
623 isLoading: boolean;1122 isLoading: boolean;
624 pairingLinks: ReadonlyArray<ServerPairingLinkRecord>;1123 pairingLinks: ReadonlyArray<ServerPairingLinkRecord>;
625 clientSessions: ReadonlyArray<ServerClientSessionRecord>;1124 clientSessions: ReadonlyArray<ServerClientSessionRecord>;
...@@ -631,6 +1130,9 @@ type PairingClientsListProps = {...@@ -631,6 +1130,9 @@ type PairingClientsListProps = {
6311130
632const PairingClientsList = memo(function PairingClientsList({1131const PairingClientsList = memo(function PairingClientsList({
633 endpointUrl,1132 endpointUrl,
1133 endpoints,
1134 defaultEndpointKey,
1135 presentation = "current",
634 isLoading,1136 isLoading,
635 pairingLinks,1137 pairingLinks,
636 clientSessions,1138 clientSessions,
...@@ -646,6 +1148,9 @@ const PairingClientsList = memo(function PairingClientsList({...@@ -646,6 +1148,9 @@ const PairingClientsList = memo(function PairingClientsList({
646 key={pairingLink.id}1148 key={pairingLink.id}
647 pairingLink={pairingLink}1149 pairingLink={pairingLink}
648 endpointUrl={endpointUrl}1150 endpointUrl={endpointUrl}
1151 endpoints={endpoints}
1152 defaultEndpointKey={defaultEndpointKey}
1153 presentation={presentation}
649 revokingPairingLinkId={revokingPairingLinkId}1154 revokingPairingLinkId={revokingPairingLinkId}
650 onRevoke={onRevokePairingLink}1155 onRevoke={onRevokePairingLink}
651 />1156 />
...@@ -655,13 +1160,14 @@ const PairingClientsList = memo(function PairingClientsList({...@@ -655,13 +1160,14 @@ const PairingClientsList = memo(function PairingClientsList({
655 <ConnectedClientListRow1160 <ConnectedClientListRow
656 key={clientSession.sessionId}1161 key={clientSession.sessionId}
657 clientSession={clientSession}1162 clientSession={clientSession}
1163 presentation={presentation}
658 revokingClientSessionId={revokingClientSessionId}1164 revokingClientSessionId={revokingClientSessionId}
659 onRevokeSession={onRevokeClientSession}1165 onRevokeSession={onRevokeClientSession}
660 />1166 />
661 ))}1167 ))}
6621168
663 {pairingLinks.length === 0 && clientSessions.length === 0 && !isLoading ? (1169 {pairingLinks.length === 0 && clientSessions.length === 0 && !isLoading ? (
664 <div className={ITEM_ROW_CLASSNAME}>1170 <div className={accessRowClassName(presentation)}>
665 <p className="text-xs text-muted-foreground/60">No pairing links or client sessions.</p>1171 <p className="text-xs text-muted-foreground/60">No pairing links or client sessions.</p>
666 </div>1172 </div>
667 ) : null}1173 ) : null}
...@@ -669,19 +1175,156 @@ const PairingClientsList = memo(function PairingClientsList({...@@ -669,19 +1175,156 @@ const PairingClientsList = memo(function PairingClientsList({
669 );1175 );
670});1176});
6711177
1178type AdvertisedEndpointListRowProps = {
1179 endpoint: AdvertisedEndpoint;
1180 isDefault: boolean;
1181 presentation?: AccessSectionPresentation;
1182 onSetDefault: (endpoint: AdvertisedEndpoint) => void;
1183 onSetupTailscaleServe: (endpoint: AdvertisedEndpoint) => void;
1184 onDisableTailscaleServe: (endpoint: AdvertisedEndpoint) => void;
1185 isUpdatingTailscaleServe: boolean;
1186};
1187
1188const AdvertisedEndpointListRow = memo(function AdvertisedEndpointListRow({
1189 endpoint,
1190 isDefault,
1191 presentation = "current",
1192 onSetDefault,
1193 onSetupTailscaleServe,
1194 onDisableTailscaleServe,
1195 isUpdatingTailscaleServe,
1196}: AdvertisedEndpointListRowProps) {
1197 const isAvailable = endpoint.status === "available";
1198 const needsTailscaleSetup = isTailscaleHttpsEndpoint(endpoint) && endpoint.status !== "available";
1199 const canDisableTailscaleServe =
1200 isTailscaleHttpsEndpoint(endpoint) && endpoint.status === "available";
1201 const shouldShowEndpointUrl = !needsTailscaleSetup;
1202 const isEndpointRail = presentation === "endpoint-rail";
1203 return (
1204 <div className={endpointRowClassName(presentation, isAvailable)}>
1205 {isEndpointRail && isDefault ? (
1206 <span className="absolute inset-y-2 left-0 w-1 rounded-r-full bg-primary" aria-hidden />
1207 ) : null}
1208 <div className="flex min-h-6 min-w-0 flex-col gap-2 sm:-my-0.5 sm:flex-row sm:items-center">
1209 <div className="flex min-w-0 items-baseline gap-3">
1210 <h3 className="shrink-0 text-sm leading-5 font-medium text-foreground">
1211 {endpoint.label}
1212 </h3>
1213 {shouldShowEndpointUrl ? (
1214 <p
1215 className="min-w-0 truncate text-xs leading-5 text-muted-foreground"
1216 title={endpoint.httpBaseUrl}
1217 >
1218 {endpoint.httpBaseUrl}
1219 </p>
1220 ) : null}
1221 {!isAvailable ? (
1222 <span className="shrink-0 rounded-md border border-border/70 px-1 py-0.5 text-[10px] text-muted-foreground">
1223 Setup required
1224 </span>
1225 ) : null}
1226 </div>
1227 <div className="ml-auto flex min-h-6 shrink-0 items-center justify-end gap-2">
1228 {isDefault ? (
1229 <span className="rounded-md border border-primary/30 bg-primary/10 px-1 py-0.5 text-[10px] text-primary">
1230 Default
1231 </span>
1232 ) : null}
1233 {needsTailscaleSetup ? (
1234 <Button
1235 size="xs"
1236 variant="outline"
1237 onClick={() => onSetupTailscaleServe(endpoint)}
1238 disabled={isUpdatingTailscaleServe}
1239 >
1240 {isUpdatingTailscaleServe ? "Restarting…" : "Setup"}
1241 </Button>
1242 ) : null}
1243 {canDisableTailscaleServe ? (
1244 <Button
1245 size="xs"
1246 variant="destructive-outline"
1247 onClick={() => onDisableTailscaleServe(endpoint)}
1248 disabled={isUpdatingTailscaleServe}
1249 >
1250 {isUpdatingTailscaleServe ? "Restarting…" : "Disable"}
1251 </Button>
1252 ) : null}
1253 {!needsTailscaleSetup && !isDefault ? (
1254 <Button size="xs" variant="outline" onClick={() => onSetDefault(endpoint)}>
1255 Set as default
1256 </Button>
1257 ) : null}
1258 </div>
1259 </div>
1260 </div>
1261 );
1262});
1263
1264function NetworkAccessDescription({
1265 endpoint,
1266 hiddenEndpointCount,
1267 expanded,
1268 onToggleExpanded,
1269 fallback,
1270}: {
1271 endpoint: AdvertisedEndpoint | null;
1272 hiddenEndpointCount: number;
1273 expanded: boolean;
1274 onToggleExpanded: () => void;
1275 fallback: ReactNode;
1276}) {
1277 if (!endpoint) {
1278 return fallback;
1279 }
1280
1281 const summary = (
1282 <>
1283 <span className="min-w-0 truncate">{endpoint.httpBaseUrl}</span>
1284 {hiddenEndpointCount > 0 ? (
1285 <span className="shrink-0 text-xs font-medium">
1286 {expanded ? "Hide" : `+${hiddenEndpointCount}`}
1287 </span>
1288 ) : null}
1289 </>
1290 );
1291
1292 return (
1293 <span className="inline-flex min-w-0 max-w-full items-baseline gap-1">
1294 <span className="shrink-0">Reachable at</span>
1295 {hiddenEndpointCount > 0 ? (
1296 <button
1297 type="button"
1298 className="inline-flex min-w-0 max-w-full items-baseline gap-2 border-b border-dotted border-muted-foreground/60 text-left text-muted-foreground underline-offset-4 hover:border-foreground hover:text-foreground"
1299 onClick={onToggleExpanded}
1300 aria-expanded={expanded}
1301 >
1302 {summary}
1303 </button>
1304 ) : (
1305 <span className="inline-flex min-w-0 max-w-full items-baseline gap-2">{summary}</span>
1306 )}
1307 </span>
1308 );
1309}
1310
672type SavedBackendListRowProps = {1311type SavedBackendListRowProps = {
673 environmentId: EnvironmentId;1312 environmentId: EnvironmentId;
674 reconnectingEnvironmentId: EnvironmentId | null;1313 reconnectingEnvironmentId: EnvironmentId | null;
1314 disconnectingEnvironmentId: EnvironmentId | null;
675 removingEnvironmentId: EnvironmentId | null;1315 removingEnvironmentId: EnvironmentId | null;
676 onReconnect: (environmentId: EnvironmentId) => void;1316 onConnect: (environmentId: EnvironmentId) => void;
1317 onDisconnect: (environmentId: EnvironmentId) => void;
677 onRemove: (environmentId: EnvironmentId) => void;1318 onRemove: (environmentId: EnvironmentId) => void;
678};1319};
6791320
680function SavedBackendListRow({1321function SavedBackendListRow({
681 environmentId,1322 environmentId,
682 reconnectingEnvironmentId,1323 reconnectingEnvironmentId,
1324 disconnectingEnvironmentId,
683 removingEnvironmentId,1325 removingEnvironmentId,
684 onReconnect,1326 onConnect,
1327 onDisconnect,
685 onRemove,1328 onRemove,
686}: SavedBackendListRowProps) {1329}: SavedBackendListRowProps) {
687 const nowMs = useRelativeTimeTick(1_000);1330 const nowMs = useRelativeTimeTick(1_000);
...@@ -693,6 +1336,10 @@ function SavedBackendListRow({...@@ -693,6 +1336,10 @@ function SavedBackendListRow({
693 }1336 }
6941337
695 const connectionState = runtime?.connectionState ?? "disconnected";1338 const connectionState = runtime?.connectionState ?? "disconnected";
1339 const isConnected = connectionState === "connected";
1340 const isConnecting =
1341 connectionState === "connecting" || reconnectingEnvironmentId === environmentId;
1342 const isDisconnecting = disconnectingEnvironmentId === environmentId;
696 const stateDotClassName =1343 const stateDotClassName =
697 connectionState === "connected"1344 connectionState === "connected"
698 ? "bg-success"1345 ? "bg-success"
...@@ -703,8 +1350,11 @@ function SavedBackendListRow({...@@ -703,8 +1350,11 @@ function SavedBackendListRow({
703 : "bg-muted-foreground/40";1350 : "bg-muted-foreground/40";
704 const roleLabel = runtime?.role ? (runtime.role === "owner" ? "Owner" : "Client") : null;1351 const roleLabel = runtime?.role ? (runtime.role === "owner" ? "Owner" : "Client") : null;
705 const descriptorLabel = runtime?.descriptor?.label ?? null;1352 const descriptorLabel = runtime?.descriptor?.label ?? null;
1353 const displayLabel = descriptorLabel ?? record.label;
706 const statusTooltip = getSavedBackendStatusTooltip(runtime, record, nowMs);1354 const statusTooltip = getSavedBackendStatusTooltip(runtime, record, nowMs);
1355 const versionMismatch = resolveServerConfigVersionMismatch(runtime?.serverConfig);
707 const metadataBits = [1356 const metadataBits = [
1357 record.desktopSsh ? `SSH ${formatDesktopSshTarget(record.desktopSsh)}` : null,
708 roleLabel,1358 roleLabel,
709 record.lastConnectedAt1359 record.lastConnectedAt
710 ? `Last connected ${formatAccessTimestamp(record.lastConnectedAt)}`1360 ? `Last connected ${formatAccessTimestamp(record.lastConnectedAt)}`
...@@ -723,23 +1373,35 @@ function SavedBackendListRow({...@@ -723,23 +1373,35 @@ function SavedBackendListRow({
723 connectionState === "connecting" ? "bg-warning/60 duration-2000" : null1373 connectionState === "connecting" ? "bg-warning/60 duration-2000" : null
724 }1374 }
725 />1375 />
726 <h3 className="text-sm font-medium text-foreground">{record.label}</h3>1376 <h3 className="text-sm font-medium text-foreground">{displayLabel}</h3>
727 </div>1377 </div>
728 {metadataBits.length > 0 ? (1378 {metadataBits.length > 0 ? (
729 <p className="text-xs text-muted-foreground">{metadataBits.join(" · ")}</p>1379 <p className="text-xs text-muted-foreground">{metadataBits.join(" · ")}</p>
730 ) : null}1380 ) : null}
731 {descriptorLabel && descriptorLabel !== record.label ? (1381 {versionMismatch ? (
732 <p className="text-xs text-muted-foreground">Server label: {descriptorLabel}</p>1382 <p className="flex items-center gap-1 text-warning text-xs">
1383 <TriangleAlertIcon className="size-3.5 shrink-0" />
1384 Version drift: client {versionMismatch.clientVersion}, server{" "}
1385 {versionMismatch.serverVersion}.
1386 </p>
733 ) : null}1387 ) : null}
734 </div>1388 </div>
735 <div className="flex w-full shrink-0 items-center gap-2 sm:w-auto sm:justify-end">1389 <div className="flex w-full shrink-0 items-center gap-2 sm:w-auto sm:justify-end">
736 <Button1390 <Button
737 size="xs"1391 size="xs"
738 variant="outline"1392 variant="outline"
739 disabled={reconnectingEnvironmentId === environmentId}1393 disabled={isConnected ? isDisconnecting : isConnecting}
740 onClick={() => void onReconnect(environmentId)}1394 onClick={() =>
1395 void (isConnected ? onDisconnect(environmentId) : onConnect(environmentId))
1396 }
741 >1397 >
742 {reconnectingEnvironmentId === environmentId ? "Reconnecting…" : "Reconnect"}1398 {isConnected
1399 ? isDisconnecting
1400 ? "Disconnecting…"
1401 : "Disconnect"
1402 : isConnecting
1403 ? "Connecting…"
1404 : "Connect"}
743 </Button>1405 </Button>
744 <Button1406 <Button
745 size="xs"1407 size="xs"
...@@ -755,6 +1417,46 @@ function SavedBackendListRow({...@@ -755,6 +1417,46 @@ function SavedBackendListRow({
755 );1417 );
756}1418}
7571419
1420interface DesktopSshHostRowProps {
1421 target: DesktopDiscoveredSshHost;
1422 connectingHostAlias: string | null;
1423 onConnect: (target: DesktopDiscoveredSshHost) => void;
1424}
1425
1426const DesktopSshHostRow = memo(function DesktopSshHostRow({
1427 target,
1428 connectingHostAlias,
1429 onConnect,
1430}: DesktopSshHostRowProps) {
1431 const address = formatDesktopSshTarget(target);
1432 const showAddress = address !== target.alias;
1433 const buttonLabel = connectingHostAlias === target.alias ? "Adding…" : "Add environment";
1434
1435 return (
1436 <div className="border-t border-border/60 px-4 py-3 first:border-t-0 sm:px-5">
1437 <div className={ITEM_ROW_INNER_CLASSNAME}>
1438 <div className="min-w-0 flex-1">
1439 <h3 className="truncate text-sm font-medium text-foreground">{target.alias}</h3>
1440 {showAddress ? <p className="truncate text-xs text-muted-foreground">{address}</p> : null}
1441 </div>
1442 <div className="flex w-full shrink-0 items-center gap-2 sm:w-auto sm:justify-end">
1443 <Button
1444 size="xs"
1445 variant="outline"
1446 disabled={connectingHostAlias === target.alias}
1447 onClick={() => onConnect(target)}
1448 >
1449 {connectingHostAlias === target.alias ? (
1450 <RefreshCwIcon className="size-3 animate-spin" />
1451 ) : null}
1452 {buttonLabel}
1453 </Button>
1454 </div>
1455 </div>
1456 </div>
1457 );
1458});
1459
758export function ConnectionsSettings() {1460export function ConnectionsSettings() {
759 const desktopBridge = window.desktopBridge;1461 const desktopBridge = window.desktopBridge;
760 const [currentSessionRole, setCurrentSessionRole] = useState<"owner" | "client" | null>(1462 const [currentSessionRole, setCurrentSessionRole] = useState<"owner" | "client" | null>(
...@@ -771,9 +1473,42 @@ export function ConnectionsSettings() {...@@ -771,9 +1473,42 @@ export function ConnectionsSettings() {
771 .map((record) => record.environmentId),1473 .map((record) => record.environmentId),
772 [savedEnvironmentsById],1474 [savedEnvironmentsById],
773 );1475 );
1476 const savedDesktopSshEnvironmentsByAlias = useMemo(
1477 () =>
1478 Object.values(savedEnvironmentsById).reduce<Record<string, SavedEnvironmentRecord>>(
1479 (accumulator, record) => {
1480 if (record.desktopSsh?.alias) {
1481 accumulator[record.desktopSsh.alias] = record;
1482 }
1483 return accumulator;
1484 },
1485 {},
1486 ),
1487 [savedEnvironmentsById],
1488 );
1489 const savedDesktopSshEnvironmentKeys = useMemo(() => {
1490 const keys = new Set<string>();
1491 for (const record of Object.values(savedEnvironmentsById)) {
1492 const target = record.desktopSsh;
1493 if (!target) continue;
1494 keys.add(target.alias);
1495 keys.add(formatDesktopSshTarget(target));
1496 }
1497 return keys;
1498 }, [savedEnvironmentsById]);
1499 const [discoveredSshHosts, setDiscoveredSshHosts] = useState<
1500 ReadonlyArray<DesktopDiscoveredSshHost>
1501 >([]);
1502 const [hasLoadedDiscoveredSshHosts, setHasLoadedDiscoveredSshHosts] = useState(false);
1503 const [isLoadingDiscoveredSshHosts, setIsLoadingDiscoveredSshHosts] = useState(false);
1504 const [discoveredSshHostsError, setDiscoveredSshHostsError] = useState<string | null>(null);
1505 const [connectingSshHostAlias, setConnectingSshHostAlias] = useState<string | null>(null);
7741506
775 const [desktopServerExposureState, setDesktopServerExposureState] =1507 const [desktopServerExposureState, setDesktopServerExposureState] =
776 useState<DesktopServerExposureState | null>(null);1508 useState<DesktopServerExposureState | null>(null);
1509 const [desktopAdvertisedEndpoints, setDesktopAdvertisedEndpoints] = useState<
1510 ReadonlyArray<AdvertisedEndpoint>
1511 >([]);
777 const [desktopServerExposureError, setDesktopServerExposureError] = useState<string | null>(null);1512 const [desktopServerExposureError, setDesktopServerExposureError] = useState<string | null>(null);
778 const [desktopPairingLinks, setDesktopPairingLinks] = useState<1513 const [desktopPairingLinks, setDesktopPairingLinks] = useState<
779 ReadonlyArray<ServerPairingLinkRecord>1514 ReadonlyArray<ServerPairingLinkRecord>
...@@ -793,27 +1528,78 @@ export function ConnectionsSettings() {...@@ -793,27 +1528,78 @@ export function ConnectionsSettings() {
793 >(null);1528 >(null);
794 const [isRevokingOtherDesktopClients, setIsRevokingOtherDesktopClients] = useState(false);1529 const [isRevokingOtherDesktopClients, setIsRevokingOtherDesktopClients] = useState(false);
795 const [addBackendDialogOpen, setAddBackendDialogOpen] = useState(false);1530 const [addBackendDialogOpen, setAddBackendDialogOpen] = useState(false);
796 const [savedBackendMode, setSavedBackendMode] = useState<"pairing-url" | "host-code">(1531 const [savedBackendMode, setSavedBackendMode] = useState<"remote" | "ssh">("remote");
797 "pairing-url",
798 );
799 const [savedBackendLabel, setSavedBackendLabel] = useState("");
800 const [savedBackendPairingUrl, setSavedBackendPairingUrl] = useState("");
801 const [savedBackendHost, setSavedBackendHost] = useState("");1532 const [savedBackendHost, setSavedBackendHost] = useState("");
802 const [savedBackendPairingCode, setSavedBackendPairingCode] = useState("");1533 const [savedBackendPairingCode, setSavedBackendPairingCode] = useState("");
1534 const [savedBackendSshHost, setSavedBackendSshHost] = useState("");
1535 const [savedBackendSshUsername, setSavedBackendSshUsername] = useState("");
1536 const [savedBackendSshPort, setSavedBackendSshPort] = useState("");
803 const [savedBackendError, setSavedBackendError] = useState<string | null>(null);1537 const [savedBackendError, setSavedBackendError] = useState<string | null>(null);
804 const [isAddingSavedBackend, setIsAddingSavedBackend] = useState(false);1538 const [isAddingSavedBackend, setIsAddingSavedBackend] = useState(false);
1539 const unsavedDiscoveredSshHosts = useMemo(
1540 () =>
1541 discoveredSshHosts.filter((target) => {
1542 const address = formatDesktopSshTarget(target);
1543 return (
1544 !savedDesktopSshEnvironmentKeys.has(target.alias) &&
1545 !savedDesktopSshEnvironmentKeys.has(address)
1546 );
1547 }),
1548 [discoveredSshHosts, savedDesktopSshEnvironmentKeys],
1549 );
805 const [reconnectingSavedEnvironmentId, setReconnectingSavedEnvironmentId] =1550 const [reconnectingSavedEnvironmentId, setReconnectingSavedEnvironmentId] =
806 useState<EnvironmentId | null>(null);1551 useState<EnvironmentId | null>(null);
1552 const [disconnectingSavedEnvironmentId, setDisconnectingSavedEnvironmentId] =
1553 useState<EnvironmentId | null>(null);
807 const [removingSavedEnvironmentId, setRemovingSavedEnvironmentId] =1554 const [removingSavedEnvironmentId, setRemovingSavedEnvironmentId] =
808 useState<EnvironmentId | null>(null);1555 useState<EnvironmentId | null>(null);
809 const [isUpdatingDesktopServerExposure, setIsUpdatingDesktopServerExposure] = useState(false);1556 const [isUpdatingDesktopServerExposure, setIsUpdatingDesktopServerExposure] = useState(false);
1557 const [isDesktopServerExposureDialogOpen, setIsDesktopServerExposureDialogOpen] = useState(false);
1558 const [isUpdatingTailscaleServe, setIsUpdatingTailscaleServe] = useState(false);
1559 const [pendingTailscaleServeEndpoint, setPendingTailscaleServeEndpoint] =
1560 useState<AdvertisedEndpoint | null>(null);
1561 const [disableTailscaleServeDialogOpen, setDisableTailscaleServeDialogOpen] = useState(false);
1562 const [tailscaleServePortInput, setTailscaleServePortInput] = useState(
1563 String(DEFAULT_TAILSCALE_SERVE_PORT),
1564 );
810 const [pendingDesktopServerExposureMode, setPendingDesktopServerExposureMode] = useState<1565 const [pendingDesktopServerExposureMode, setPendingDesktopServerExposureMode] = useState<
811 DesktopServerExposureState["mode"] | null1566 DesktopServerExposureState["mode"] | null
812 >(null);1567 >(null);
1568 const primaryServerConfig = useServerConfig();
1569 const primaryVersionMismatch = resolveServerConfigVersionMismatch(primaryServerConfig);
1570 const [isAdvertisedEndpointListExpanded, setIsAdvertisedEndpointListExpanded] = useState(false);
1571 const defaultAdvertisedEndpointKey = useUiStateStore(
1572 (state) => state.defaultAdvertisedEndpointKey,
1573 );
1574 const setDefaultAdvertisedEndpointKey = useUiStateStore(
1575 (state) => state.setDefaultAdvertisedEndpointKey,
1576 );
813 const canManageLocalBackend = currentSessionRole === "owner";1577 const canManageLocalBackend = currentSessionRole === "owner";
814 const isLocalBackendNetworkAccessible = desktopBridge1578 const isLocalBackendNetworkAccessible = desktopBridge
815 ? desktopServerExposureState?.mode === "network-accessible"1579 ? desktopServerExposureState?.mode === "network-accessible"
816 : currentAuthPolicy === "remote-reachable";1580 : currentAuthPolicy === "remote-reachable";
1581 const trimmedTailscaleServePortInput = tailscaleServePortInput.trim();
1582 const parsedTailscaleServePort = Number(trimmedTailscaleServePortInput);
1583 const isTailscaleServePortValid =
1584 /^\d+$/u.test(trimmedTailscaleServePortInput) &&
1585 Number.isInteger(parsedTailscaleServePort) &&
1586 parsedTailscaleServePort >= 1 &&
1587 parsedTailscaleServePort <= 65_535;
1588
1589 const pendingTailscaleServeBaseUrl = useMemo(() => {
1590 if (!pendingTailscaleServeEndpoint) return null;
1591 if (!isTailscaleServePortValid) return pendingTailscaleServeEndpoint.httpBaseUrl;
1592 if (parsedTailscaleServePort === DEFAULT_TAILSCALE_SERVE_PORT) {
1593 return pendingTailscaleServeEndpoint.httpBaseUrl;
1594 }
1595 try {
1596 const url = new URL(pendingTailscaleServeEndpoint.httpBaseUrl);
1597 url.port = String(parsedTailscaleServePort);
1598 return url.toString().replace(/\/$/u, "");
1599 } catch {
1600 return pendingTailscaleServeEndpoint.httpBaseUrl;
1601 }
1602 }, [isTailscaleServePortValid, parsedTailscaleServePort, pendingTailscaleServeEndpoint]);
8171603
818 const handleDesktopServerExposureChange = useCallback(1604 const handleDesktopServerExposureChange = useCallback(
819 async (checked: boolean) => {1605 async (checked: boolean) => {
...@@ -825,12 +1611,12 @@ export function ConnectionsSettings() {...@@ -825,12 +1611,12 @@ export function ConnectionsSettings() {
825 checked ? "network-accessible" : "local-only",1611 checked ? "network-accessible" : "local-only",
826 );1612 );
827 setDesktopServerExposureState(nextState);1613 setDesktopServerExposureState(nextState);
828 setPendingDesktopServerExposureMode(null);1614 setIsDesktopServerExposureDialogOpen(false);
829 setIsUpdatingDesktopServerExposure(false);1615 setIsUpdatingDesktopServerExposure(false);
830 } catch (error) {1616 } catch (error) {
831 const message =1617 const message =
832 error instanceof Error ? error.message : "Failed to update network exposure.";1618 error instanceof Error ? error.message : "Failed to update network exposure.";
833 setPendingDesktopServerExposureMode(null);1619 setIsDesktopServerExposureDialogOpen(false);
834 setDesktopServerExposureError(message);1620 setDesktopServerExposureError(message);
835 toastManager.add(1621 toastManager.add(
836 stackedThreadToast({1622 stackedThreadToast({
...@@ -851,6 +1637,74 @@ export function ConnectionsSettings() {...@@ -851,6 +1637,74 @@ export function ConnectionsSettings() {
851 void handleDesktopServerExposureChange(checked);1637 void handleDesktopServerExposureChange(checked);
852 }, [handleDesktopServerExposureChange, pendingDesktopServerExposureMode]);1638 }, [handleDesktopServerExposureChange, pendingDesktopServerExposureMode]);
8531639
1640 const handleConfirmTailscaleServeSetup = useCallback(async () => {
1641 if (!desktopBridge) return;
1642 if (!isTailscaleServePortValid) return;
1643 setIsUpdatingTailscaleServe(true);
1644 setDesktopServerExposureError(null);
1645 try {
1646 const nextState = await desktopBridge.setTailscaleServeEnabled({
1647 enabled: true,
1648 port: parsedTailscaleServePort,
1649 });
1650 setDesktopServerExposureState(nextState);
1651 setPendingTailscaleServeEndpoint(null);
1652 } catch (error) {
1653 const message =
1654 error instanceof Error ? error.message : "Failed to configure Tailscale HTTPS.";
1655 setDesktopServerExposureError(message);
1656 toastManager.add(
1657 stackedThreadToast({
1658 type: "error",
1659 title: "Could not set up Tailscale HTTPS",
1660 description: message,
1661 }),
1662 );
1663 } finally {
1664 setIsUpdatingTailscaleServe(false);
1665 }
1666 }, [desktopBridge, isTailscaleServePortValid, parsedTailscaleServePort]);
1667
1668 const handleStartTailscaleServeSetup = useCallback(
1669 (endpoint: AdvertisedEndpoint) => {
1670 setTailscaleServePortInput(
1671 String(desktopServerExposureState?.tailscaleServePort ?? DEFAULT_TAILSCALE_SERVE_PORT),
1672 );
1673 setPendingTailscaleServeEndpoint(endpoint);
1674 },
1675 [desktopServerExposureState?.tailscaleServePort],
1676 );
1677
1678 const handleConfirmTailscaleServeDisable = useCallback(async () => {
1679 if (!desktopBridge) return;
1680 setIsUpdatingTailscaleServe(true);
1681 setDesktopServerExposureError(null);
1682 try {
1683 const nextState = await desktopBridge.setTailscaleServeEnabled({
1684 enabled: false,
1685 port: desktopServerExposureState?.tailscaleServePort ?? DEFAULT_TAILSCALE_SERVE_PORT,
1686 });
1687 setDesktopServerExposureState(nextState);
1688 setDisableTailscaleServeDialogOpen(false);
1689 } catch (error) {
1690 const message = error instanceof Error ? error.message : "Failed to disable Tailscale HTTPS.";
1691 setDesktopServerExposureError(message);
1692 toastManager.add(
1693 stackedThreadToast({
1694 type: "error",
1695 title: "Could not disable Tailscale HTTPS",
1696 description: message,
1697 }),
1698 );
1699 } finally {
1700 setIsUpdatingTailscaleServe(false);
1701 }
1702 }, [desktopBridge, desktopServerExposureState?.tailscaleServePort]);
1703
1704 const handleStartTailscaleServeDisable = useCallback((_endpoint: AdvertisedEndpoint) => {
1705 setDisableTailscaleServeDialogOpen(true);
1706 }, []);
1707
854 const handleRevokeDesktopPairingLink = useCallback(async (id: string) => {1708 const handleRevokeDesktopPairingLink = useCallback(async (id: string) => {
855 setRevokingDesktopPairingLinkId(id);1709 setRevokingDesktopPairingLinkId(id);
856 setDesktopAccessManagementError(null);1710 setDesktopAccessManagementError(null);
...@@ -920,22 +1774,53 @@ export function ConnectionsSettings() {...@@ -920,22 +1774,53 @@ export function ConnectionsSettings() {
920 }, []);1774 }, []);
9211775
922 const handleAddSavedBackend = useCallback(async () => {1776 const handleAddSavedBackend = useCallback(async () => {
1777 if (savedBackendMode === "ssh") {
1778 setIsAddingSavedBackend(true);
1779 setSavedBackendError(null);
1780 try {
1781 const target = parseManualDesktopSshTarget({
1782 host: savedBackendSshHost,
1783 username: savedBackendSshUsername,
1784 port: savedBackendSshPort,
1785 });
1786 const record = await connectDesktopSshEnvironment(target, { label: "" });
1787 setSavedBackendHost("");
1788 setSavedBackendPairingCode("");
1789 setSavedBackendSshHost("");
1790 setSavedBackendSshUsername("");
1791 setSavedBackendSshPort("");
1792
1793 setAddBackendDialogOpen(false);
1794 toastManager.add({
1795 type: "success",
1796 title: "Environment connected",
1797 description: `${record.label} is ready over an SSH-managed tunnel.`,
1798 });
1799 } catch (error) {
1800 const message = formatDesktopSshConnectionError(error);
1801 setSavedBackendError(message);
1802 } finally {
1803 setIsAddingSavedBackend(false);
1804 }
1805 return;
1806 }
1807
923 setIsAddingSavedBackend(true);1808 setIsAddingSavedBackend(true);
924 setSavedBackendError(null);1809 setSavedBackendError(null);
925 try {1810 try {
1811 const remotePairingInput = parseRemotePairingFields({
1812 host: savedBackendHost,
1813 pairingCode: savedBackendPairingCode,
1814 });
926 const record = await addSavedEnvironment({1815 const record = await addSavedEnvironment({
927 label: savedBackendLabel,1816 label: "",
928 ...(savedBackendMode === "pairing-url"1817 ...remotePairingInput,
929 ? { pairingUrl: savedBackendPairingUrl }
930 : {
931 host: savedBackendHost,
932 pairingCode: savedBackendPairingCode,
933 }),
934 });1818 });
935 setSavedBackendLabel("");
936 setSavedBackendPairingUrl("");
937 setSavedBackendHost("");1819 setSavedBackendHost("");
938 setSavedBackendPairingCode("");1820 setSavedBackendPairingCode("");
1821 setSavedBackendSshHost("");
1822 setSavedBackendSshUsername("");
1823 setSavedBackendSshPort("");
939 setAddBackendDialogOpen(false);1824 setAddBackendDialogOpen(false);
940 toastManager.add({1825 toastManager.add({
941 type: "success",1826 type: "success",
...@@ -957,24 +1842,25 @@ export function ConnectionsSettings() {...@@ -957,24 +1842,25 @@ export function ConnectionsSettings() {
957 }1842 }
958 }, [1843 }, [
959 savedBackendHost,1844 savedBackendHost,
960 savedBackendLabel,
961 savedBackendMode,1845 savedBackendMode,
962 savedBackendPairingCode,1846 savedBackendPairingCode,
963 savedBackendPairingUrl,1847 savedBackendSshHost,
1848 savedBackendSshPort,
1849 savedBackendSshUsername,
964 ]);1850 ]);
9651851
966 const handleReconnectSavedBackend = useCallback(async (environmentId: EnvironmentId) => {1852 const handleConnectSavedBackend = useCallback(async (environmentId: EnvironmentId) => {
967 setReconnectingSavedEnvironmentId(environmentId);1853 setReconnectingSavedEnvironmentId(environmentId);
968 setSavedBackendError(null);1854 setSavedBackendError(null);
969 try {1855 try {
970 await reconnectSavedEnvironment(environmentId);1856 await reconnectSavedEnvironment(environmentId);
971 } catch (error) {1857 } catch (error) {
972 const message = error instanceof Error ? error.message : "Failed to reconnect backend.";1858 const message = error instanceof Error ? error.message : "Failed to connect backend.";
973 setSavedBackendError(message);1859 setSavedBackendError(message);
974 toastManager.add(1860 toastManager.add(
975 stackedThreadToast({1861 stackedThreadToast({
976 type: "error",1862 type: "error",
977 title: "Could not reconnect backend",1863 title: "Could not connect backend",
978 description: message,1864 description: message,
979 }),1865 }),
980 );1866 );
...@@ -983,6 +1869,26 @@ export function ConnectionsSettings() {...@@ -983,6 +1869,26 @@ export function ConnectionsSettings() {
983 }1869 }
984 }, []);1870 }, []);
9851871
1872 const handleDisconnectSavedBackend = useCallback(async (environmentId: EnvironmentId) => {
1873 setDisconnectingSavedEnvironmentId(environmentId);
1874 setSavedBackendError(null);
1875 try {
1876 await disconnectSavedEnvironment(environmentId);
1877 } catch (error) {
1878 const message = error instanceof Error ? error.message : "Failed to disconnect backend.";
1879 setSavedBackendError(message);
1880 toastManager.add(
1881 stackedThreadToast({
1882 type: "error",
1883 title: "Could not disconnect backend",
1884 description: message,
1885 }),
1886 );
1887 } finally {
1888 setDisconnectingSavedEnvironmentId(null);
1889 }
1890 }, []);
1891
986 const handleRemoveSavedBackend = useCallback(async (environmentId: EnvironmentId) => {1892 const handleRemoveSavedBackend = useCallback(async (environmentId: EnvironmentId) => {
987 setRemovingSavedEnvironmentId(environmentId);1893 setRemovingSavedEnvironmentId(environmentId);
988 setSavedBackendError(null);1894 setSavedBackendError(null);
...@@ -1003,6 +1909,84 @@ export function ConnectionsSettings() {...@@ -1003,6 +1909,84 @@ export function ConnectionsSettings() {
1003 }1909 }
1004 }, []);1910 }, []);
10051911
1912 const loadDiscoveredSshHosts = useCallback(async () => {
1913 if (!desktopBridge) {
1914 setDiscoveredSshHosts([]);
1915 setHasLoadedDiscoveredSshHosts(false);
1916 setDiscoveredSshHostsError(null);
1917 return;
1918 }
1919
1920 setIsLoadingDiscoveredSshHosts(true);
1921 setDiscoveredSshHostsError(null);
1922 try {
1923 const hosts = await desktopBridge.discoverSshHosts();
1924 setDiscoveredSshHosts(hosts);
1925 setHasLoadedDiscoveredSshHosts(true);
1926 } catch (error) {
1927 const message = error instanceof Error ? error.message : "Failed to discover SSH hosts.";
1928 setDiscoveredSshHostsError(message);
1929 setHasLoadedDiscoveredSshHosts(true);
1930 } finally {
1931 setIsLoadingDiscoveredSshHosts(false);
1932 }
1933 }, [desktopBridge]);
1934
1935 const handleConnectSshHost = useCallback(
1936 async (target: DesktopSshEnvironmentTarget, label?: string) => {
1937 setConnectingSshHostAlias(target.alias);
1938 if (savedBackendMode === "ssh") {
1939 setSavedBackendError(null);
1940 } else {
1941 setDiscoveredSshHostsError(null);
1942 }
1943 try {
1944 const record = await connectDesktopSshEnvironment(
1945 target,
1946 label === undefined ? undefined : { label },
1947 );
1948 setSavedBackendSshHost("");
1949 setSavedBackendSshUsername("");
1950 setSavedBackendSshPort("");
1951 setAddBackendDialogOpen(false);
1952 toastManager.add({
1953 type: "success",
1954 title: savedDesktopSshEnvironmentsByAlias[target.alias]
1955 ? "Environment reconnected"
1956 : "Environment connected",
1957 description: `${record.label} is ready over an SSH-managed tunnel.`,
1958 });
1959 } catch (error) {
1960 const message = formatDesktopSshConnectionError(error);
1961 if (savedBackendMode === "ssh") {
1962 setSavedBackendError(message);
1963 } else {
1964 setDiscoveredSshHostsError(message);
1965 }
1966 } finally {
1967 setConnectingSshHostAlias(null);
1968 }
1969 },
1970 [savedBackendMode, savedDesktopSshEnvironmentsByAlias],
1971 );
1972
1973 useEffect(() => {
1974 if (!desktopBridge || !addBackendDialogOpen || savedBackendMode !== "ssh") {
1975 return;
1976 }
1977 if (hasLoadedDiscoveredSshHosts || isLoadingDiscoveredSshHosts) {
1978 return;
1979 }
1980 void loadDiscoveredSshHosts();
1981 }, [
1982 addBackendDialogOpen,
1983 desktopBridge,
1984 hasLoadedDiscoveredSshHosts,
1985 isLoadingDiscoveredSshHosts,
1986 loadDiscoveredSshHosts,
1987 savedBackendMode,
1988 ]);
1989
1006 useEffect(() => {1990 useEffect(() => {
1007 if (desktopBridge) {1991 if (desktopBridge) {
1008 setCurrentSessionRole("owner");1992 setCurrentSessionRole("owner");
...@@ -1105,8 +2089,21 @@ export function ConnectionsSettings() {...@@ -1105,8 +2089,21 @@ export function ConnectionsSettings() {
1105 error instanceof Error ? error.message : "Failed to load network exposure state.";2089 error instanceof Error ? error.message : "Failed to load network exposure state.";
1106 setDesktopServerExposureError(message);2090 setDesktopServerExposureError(message);
1107 });2091 });
2092 void desktopBridge
2093 .getAdvertisedEndpoints()
2094 .then((endpoints) => {
2095 if (cancelled) return;
2096 setDesktopAdvertisedEndpoints(endpoints);
2097 })
2098 .catch((error: unknown) => {
2099 if (cancelled) return;
2100 const message =
2101 error instanceof Error ? error.message : "Failed to load reachable endpoints.";
2102 setDesktopServerExposureError(message);
2103 });
1108 } else {2104 } else {
1109 setDesktopServerExposureState(null);2105 setDesktopServerExposureState(null);
2106 setDesktopAdvertisedEndpoints([]);
1110 setDesktopServerExposureError(null);2107 setDesktopServerExposureError(null);
1111 }2108 }
11122109
...@@ -1123,131 +2120,420 @@ export function ConnectionsSettings() {...@@ -1123,131 +2120,420 @@ export function ConnectionsSettings() {
1123 setDesktopClientSessions([]);2120 setDesktopClientSessions([]);
1124 setDesktopAccessManagementError(null);2121 setDesktopAccessManagementError(null);
1125 setDesktopServerExposureState(null);2122 setDesktopServerExposureState(null);
2123 setDesktopAdvertisedEndpoints([]);
1126 setDesktopServerExposureError(null);2124 setDesktopServerExposureError(null);
1127 }, [canManageLocalBackend]);2125 }, [canManageLocalBackend]);
1128 const visibleDesktopPairingLinks = useMemo(2126 const visibleDesktopPairingLinks = useMemo(
1129 () => desktopPairingLinks.filter((pairingLink) => pairingLink.role === "client"),2127 () => desktopPairingLinks.filter((pairingLink) => pairingLink.role === "client"),
1130 [desktopPairingLinks],2128 [desktopPairingLinks],
1131 );2129 );
2130 const tailscaleHttpsEndpoint = useMemo(
2131 () => desktopAdvertisedEndpoints.find(isTailscaleHttpsEndpoint) ?? null,
2132 [desktopAdvertisedEndpoints],
2133 );
2134 const visibleDesktopNetworkAdvertisedEndpoints = useMemo(
2135 () =>
2136 isLocalBackendNetworkAccessible
2137 ? desktopAdvertisedEndpoints.filter((endpoint) => !isTailscaleHttpsEndpoint(endpoint))
2138 : [],
2139 [desktopAdvertisedEndpoints, isLocalBackendNetworkAccessible],
2140 );
2141 const visibleDesktopAdvertisedEndpoints = useMemo(
2142 () =>
2143 tailscaleHttpsEndpoint
2144 ? [...visibleDesktopNetworkAdvertisedEndpoints, tailscaleHttpsEndpoint]
2145 : visibleDesktopNetworkAdvertisedEndpoints,
2146 [tailscaleHttpsEndpoint, visibleDesktopNetworkAdvertisedEndpoints],
2147 );
2148 const isLocalBackendRemotelyReachable =
2149 isLocalBackendNetworkAccessible || tailscaleHttpsEndpoint?.status === "available";
2150 const defaultDesktopNetworkAdvertisedEndpoint = useMemo(
2151 () =>
2152 selectPairingEndpoint(visibleDesktopNetworkAdvertisedEndpoints, defaultAdvertisedEndpointKey),
2153 [defaultAdvertisedEndpointKey, visibleDesktopNetworkAdvertisedEndpoints],
2154 );
2155 const defaultDesktopAdvertisedEndpoint = useMemo(
2156 () =>
2157 defaultDesktopNetworkAdvertisedEndpoint ??
2158 selectPairingEndpoint(
2159 tailscaleHttpsEndpoint ? [tailscaleHttpsEndpoint] : [],
2160 defaultAdvertisedEndpointKey,
2161 ),
2162 [defaultAdvertisedEndpointKey, defaultDesktopNetworkAdvertisedEndpoint, tailscaleHttpsEndpoint],
2163 );
2164 const defaultDesktopAdvertisedEndpointKey = defaultDesktopAdvertisedEndpoint
2165 ? endpointDefaultPreferenceKey(defaultDesktopAdvertisedEndpoint)
2166 : null;
2167 const handleSetDefaultAdvertisedEndpoint = useCallback(
2168 (endpoint: AdvertisedEndpoint) => {
2169 setDefaultAdvertisedEndpointKey(endpointDefaultPreferenceKey(endpoint));
2170 },
2171 [setDefaultAdvertisedEndpointKey],
2172 );
2173 const handleSavedBackendHostChange = useCallback((value: string) => {
2174 const parsedPairingUrl = parsePairingUrlFields(value);
2175 if (parsedPairingUrl) {
2176 setSavedBackendHost(parsedPairingUrl.host);
2177 setSavedBackendPairingCode(parsedPairingUrl.pairingCode);
2178 return;
2179 }
2180 setSavedBackendHost(value);
2181 }, []);
2182
2183 const renderConnectionModeCard = (input: {
2184 readonly mode: "remote" | "ssh";
2185 readonly title: string;
2186 readonly description: string;
2187 readonly icon?: ReactNode;
2188 }) => {
2189 const selected = savedBackendMode === input.mode;
2190 return (
2191 <button
2192 type="button"
2193 aria-pressed={selected}
2194 className={cn(
2195 "group flex min-h-24 items-start gap-3 rounded-lg border p-4 text-left",
2196 selected ? "border-primary/50 bg-primary/5" : "border-border/60 hover:bg-muted/40",
2197 )}
2198 disabled={isAddingSavedBackend}
2199 onClick={() => {
2200 setSavedBackendMode(input.mode);
2201 }}
2202 >
2203 {input.icon ? (
2204 <span
2205 className={cn(
2206 "mt-0.5 flex size-8 shrink-0 items-center justify-center rounded-md border",
2207 selected
2208 ? "border-primary/30 bg-primary/10 text-primary"
2209 : "border-border/70 bg-background text-muted-foreground group-hover:text-foreground",
2210 )}
2211 >
2212 {input.icon}
2213 </span>
2214 ) : null}
2215 <span className="min-w-0">
2216 <span className="block text-sm font-medium text-foreground">{input.title}</span>
2217 <span className="mt-1 block text-xs leading-relaxed text-muted-foreground">
2218 {input.description}
2219 </span>
2220 </span>
2221 </button>
2222 );
2223 };
2224
2225 const renderRemoteFields = () => (
2226 <div className="space-y-3">
2227 <div className="grid gap-3 sm:grid-cols-[minmax(0,1fr)_10rem]">
2228 <label className="block">
2229 <span className="mb-1.5 block text-xs font-medium text-foreground">Host</span>
2230 <Input
2231 value={savedBackendHost}
2232 onChange={(event) => handleSavedBackendHostChange(event.target.value)}
2233 placeholder="backend.example.com"
2234 disabled={isAddingSavedBackend}
2235 spellCheck={false}
2236 />
2237 </label>
2238 <label className="block">
2239 <span className="mb-1.5 block text-xs font-medium text-foreground">Pairing code</span>
2240 <Input
2241 value={savedBackendPairingCode}
2242 onChange={(event) => setSavedBackendPairingCode(event.target.value)}
2243 placeholder="PAIRCODE"
2244 disabled={isAddingSavedBackend}
2245 spellCheck={false}
2246 />
2247 </label>
2248 </div>
2249 <div>
2250 <span className="mt-1 block text-[11px] text-muted-foreground">
2251 Paste a full pairing URL here to fill both fields automatically.
2252 </span>
2253 </div>
2254 </div>
2255 );
2256 const renderRemoteModeBody = () => (
2257 <div className="space-y-4">
2258 {renderRemoteFields()}
2259 {savedBackendError ? <p className="text-xs text-destructive">{savedBackendError}</p> : null}
2260 <Button
2261 variant="outline"
2262 className="w-full"
2263 disabled={isAddingSavedBackend}
2264 onClick={() => void handleAddSavedBackend()}
2265 >
2266 <PlusIcon className="size-3.5" />
2267 {isAddingSavedBackend ? "Adding…" : "Add environment"}
2268 </Button>
2269 </div>
2270 );
2271 const renderSshFields = () => (
2272 <div className="space-y-4">
2273 <div className="space-y-3">
2274 <label className="block">
2275 <span className="mb-1.5 block text-xs font-medium text-foreground">
2276 SSH host or alias
2277 </span>
2278 <Input
2279 value={savedBackendSshHost}
2280 onChange={(event) => setSavedBackendSshHost(event.target.value)}
2281 placeholder="Search hosts or type devbox"
2282 disabled={isAddingSavedBackend}
2283 spellCheck={false}
2284 />
2285 </label>
2286 <div className="grid gap-3 sm:grid-cols-[minmax(0,1fr)_7rem]">
2287 <label className="block">
2288 <span className="mb-1.5 block text-xs font-medium text-foreground">Username</span>
2289 <Input
2290 value={savedBackendSshUsername}
2291 onChange={(event) => setSavedBackendSshUsername(event.target.value)}
2292 placeholder="julius"
2293 disabled={isAddingSavedBackend}
2294 spellCheck={false}
2295 />
2296 </label>
2297 <label className="block">
2298 <span className="mb-1.5 block text-xs font-medium text-foreground">Port</span>
2299 <Input
2300 value={savedBackendSshPort}
2301 onChange={(event) => setSavedBackendSshPort(event.target.value)}
2302 placeholder="22"
2303 inputMode="numeric"
2304 disabled={isAddingSavedBackend}
2305 spellCheck={false}
2306 />
2307 </label>
2308 </div>
2309 {savedBackendError || discoveredSshHostsError ? (
2310 <div className="rounded-md border border-destructive/30 bg-destructive/5 px-3 py-2 text-xs text-destructive">
2311 {savedBackendError ?? discoveredSshHostsError}
2312 </div>
2313 ) : null}
2314 <Button
2315 variant="outline"
2316 className="w-full"
2317 disabled={isAddingSavedBackend}
2318 onClick={() => void handleAddSavedBackend()}
2319 >
2320 <PlusIcon className="size-3.5" />
2321 {isAddingSavedBackend ? "Adding…" : "Add environment"}
2322 </Button>
2323 </div>
2324 <div className="overflow-hidden rounded-lg border border-border/60">
2325 <div className="flex items-center justify-between gap-3 border-b border-border/60 bg-muted/30 px-3 py-2">
2326 <div className="min-w-0">
2327 <p className="text-xs font-medium text-foreground">Suggested hosts</p>
2328 <p className="text-[11px] text-muted-foreground">From SSH config and known hosts</p>
2329 </div>
2330 <Button
2331 size="xs"
2332 variant="ghost"
2333 disabled={isLoadingDiscoveredSshHosts}
2334 onClick={() => void loadDiscoveredSshHosts()}
2335 >
2336 {isLoadingDiscoveredSshHosts ? (
2337 <RefreshCwIcon className="size-3 animate-spin" />
2338 ) : (
2339 <RefreshCwIcon className="size-3" />
2340 )}
2341 Refresh
2342 </Button>
2343 </div>
2344 <ScrollArea scrollFade className="max-h-56">
2345 <div>
2346 {unsavedDiscoveredSshHosts.map((target) => (
2347 <DesktopSshHostRow
2348 key={`${target.alias}:${target.hostname}:${target.port ?? ""}`}
2349 target={target}
2350 connectingHostAlias={connectingSshHostAlias}
2351 onConnect={(nextTarget) => void handleConnectSshHost(nextTarget)}
2352 />
2353 ))}
2354 {hasLoadedDiscoveredSshHosts &&
2355 !isLoadingDiscoveredSshHosts &&
2356 unsavedDiscoveredSshHosts.length === 0 ? (
2357 <div className={ITEM_ROW_CLASSNAME}>
2358 <p className="text-xs text-muted-foreground">No new SSH hosts were discovered.</p>
2359 </div>
2360 ) : null}
2361 </div>
2362 </ScrollArea>
2363 </div>
2364 </div>
2365 );
2366 const renderNetworkAccessToggle = () => (
2367 <Switch
2368 checked={desktopServerExposureState?.mode === "network-accessible"}
2369 disabled={!desktopServerExposureState || isUpdatingDesktopServerExposure}
2370 onCheckedChange={(checked) => {
2371 setPendingDesktopServerExposureMode(checked ? "network-accessible" : "local-only");
2372 setIsDesktopServerExposureDialogOpen(true);
2373 }}
2374 aria-label="Enable network access"
2375 />
2376 );
2377 const renderEndpointRows = (presentation: AccessSectionPresentation) =>
2378 isAdvertisedEndpointListExpanded
2379 ? visibleDesktopNetworkAdvertisedEndpoints.map((endpoint) => {
2380 const endpointKey = endpointDefaultPreferenceKey(endpoint);
2381 return (
2382 <AdvertisedEndpointListRow
2383 key={endpoint.id}
2384 endpoint={endpoint}
2385 isDefault={endpointKey === defaultDesktopAdvertisedEndpointKey}
2386 presentation={presentation}
2387 onSetDefault={handleSetDefaultAdvertisedEndpoint}
2388 onSetupTailscaleServe={handleStartTailscaleServeSetup}
2389 onDisableTailscaleServe={handleStartTailscaleServeDisable}
2390 isUpdatingTailscaleServe={isUpdatingTailscaleServe}
2391 />
2392 );
2393 })
2394 : null;
2395 const renderTailscaleRow = () => (
2396 <SettingsRow
2397 title="Tailscale HTTPS"
2398 description={
2399 tailscaleHttpsEndpoint
2400 ? tailscaleHttpsEndpoint.status === "available"
2401 ? tailscaleHttpsEndpoint.httpBaseUrl
2402 : "Use Tailscale Serve to expose this backend through a MagicDNS HTTPS URL."
2403 : "Start Tailscale to set up HTTPS access through MagicDNS."
2404 }
2405 control={
2406 tailscaleHttpsEndpoint ? (
2407 <Switch
2408 checked={tailscaleHttpsEndpoint.status === "available"}
2409 disabled={isUpdatingTailscaleServe}
2410 onCheckedChange={(checked) => {
2411 if (checked) {
2412 handleStartTailscaleServeSetup(tailscaleHttpsEndpoint);
2413 return;
2414 }
2415 handleStartTailscaleServeDisable(tailscaleHttpsEndpoint);
2416 }}
2417 aria-label="Enable Tailscale HTTPS"
2418 />
2419 ) : null
2420 }
2421 />
2422 );
2423 const renderAuthorizedClients = (presentation: AccessSectionPresentation) => (
2424 <>
2425 {desktopAccessManagementError ? (
2426 <div className={accessRowClassName(presentation)}>
2427 <p className="text-xs text-destructive">{desktopAccessManagementError}</p>
2428 </div>
2429 ) : null}
2430 <PairingClientsList
2431 endpointUrl={desktopServerExposureState?.endpointUrl}
2432 endpoints={visibleDesktopAdvertisedEndpoints}
2433 defaultEndpointKey={defaultDesktopAdvertisedEndpointKey}
2434 presentation={presentation}
2435 isLoading={isLoadingDesktopAccessManagement}
2436 pairingLinks={visibleDesktopPairingLinks}
2437 clientSessions={desktopClientSessions}
2438 revokingPairingLinkId={revokingDesktopPairingLinkId}
2439 revokingClientSessionId={revokingDesktopClientSessionId}
2440 onRevokePairingLink={handleRevokeDesktopPairingLink}
2441 onRevokeClientSession={handleRevokeDesktopClientSession}
2442 />
2443 </>
2444 );
2445 const renderNetworkAccessRow = () => (
2446 <SettingsRow
2447 title="Network access"
2448 description={
2449 isLocalBackendNetworkAccessible ? (
2450 <NetworkAccessDescription
2451 endpoint={defaultDesktopNetworkAdvertisedEndpoint}
2452 hiddenEndpointCount={Math.max(visibleDesktopNetworkAdvertisedEndpoints.length - 1, 0)}
2453 expanded={isAdvertisedEndpointListExpanded}
2454 onToggleExpanded={() => setIsAdvertisedEndpointListExpanded((expanded) => !expanded)}
2455 fallback={
2456 desktopServerExposureState?.endpointUrl
2457 ? `Reachable at ${desktopServerExposureState.endpointUrl}`
2458 : desktopServerExposureState?.advertisedHost
2459 ? `Exposed on all interfaces. Pairing links use ${desktopServerExposureState.advertisedHost}.`
2460 : "Exposed on all interfaces."
2461 }
2462 />
2463 ) : desktopServerExposureState ? (
2464 "Limited to this machine."
2465 ) : (
2466 "Loading…"
2467 )
2468 }
2469 status={
2470 desktopServerExposureError ? (
2471 <span className="block text-destructive">{desktopServerExposureError}</span>
2472 ) : null
2473 }
2474 control={renderNetworkAccessToggle()}
2475 />
2476 );
2477 const renderDisabledNetworkAccessRow = () => (
2478 <SettingsRow
2479 title="Network access"
2480 description={
2481 currentAuthPolicy === "remote-reachable"
2482 ? "This backend is already configured for remote access. Network exposure changes must be made where the server is launched."
2483 : "This backend is only reachable on this machine. Restart it with a non-loopback host to enable remote pairing."
2484 }
2485 control={
2486 <Tooltip>
2487 <TooltipTrigger
2488 render={
2489 <span className="inline-flex">
2490 <Switch
2491 checked={isLocalBackendNetworkAccessible}
2492 disabled
2493 aria-label="Enable network access"
2494 />
2495 </span>
2496 }
2497 />
2498 <TooltipPopup side="top">
2499 Network exposure changes restart the backend and must be controlled where the server
2500 process is launched.
2501 </TooltipPopup>
2502 </Tooltip>
2503 }
2504 />
2505 );
2506
1132 return (2507 return (
1133 <SettingsPageContainer>2508 <SettingsPageContainer>
1134 {canManageLocalBackend ? (2509 {canManageLocalBackend ? (
1135 <>2510 <>
1136 <SettingsSection title="Manage local backend">2511 <SettingsSection title="Manage local backend">
1137 {desktopBridge ? (2512 {primaryVersionMismatch ? (
1138 <SettingsRow2513 <SettingsRow
1139 title="Network access"2514 title="Version drift"
1140 description={2515 description={
1141 desktopServerExposureState?.endpointUrl2516 <span className="flex items-center gap-1 text-warning">
1142 ? `Reachable at ${desktopServerExposureState.endpointUrl}`2517 <TriangleAlertIcon className="size-3.5 shrink-0" />
1143 : desktopServerExposureState?.mode === "network-accessible"2518 Client {primaryVersionMismatch.clientVersion}, server{" "}
1144 ? desktopServerExposureState.advertisedHost2519 {primaryVersionMismatch.serverVersion}. Sync them if RPC calls or reconnects
1145 ? `Exposed on all interfaces. Pairing links use ${desktopServerExposureState.advertisedHost}.`2520 fail.
1146 : "Exposed on all interfaces."2521 </span>
1147 : desktopServerExposureState
1148 ? "Limited to this machine."
1149 : "Loading…"
1150 }
1151 status={
1152 desktopServerExposureError ? (
1153 <span className="block text-destructive">{desktopServerExposureError}</span>
1154 ) : null
1155 }
1156 control={
1157 <AlertDialog
1158 open={pendingDesktopServerExposureMode !== null}
1159 onOpenChange={(open) => {
1160 if (isUpdatingDesktopServerExposure) return;
1161 if (!open) setPendingDesktopServerExposureMode(null);
1162 }}
1163 >
1164 <Switch
1165 checked={desktopServerExposureState?.mode === "network-accessible"}
1166 disabled={!desktopServerExposureState || isUpdatingDesktopServerExposure}
1167 onCheckedChange={(checked) => {
1168 setPendingDesktopServerExposureMode(
1169 checked ? "network-accessible" : "local-only",
1170 );
1171 }}
1172 aria-label="Enable network access"
1173 />
1174 <AlertDialogPopup>
1175 <AlertDialogHeader>
1176 <AlertDialogTitle>
1177 {pendingDesktopServerExposureMode === "network-accessible"
1178 ? "Enable network access?"
1179 : "Disable network access?"}
1180 </AlertDialogTitle>
1181 <AlertDialogDescription>
1182 {pendingDesktopServerExposureMode === "network-accessible"
1183 ? "T3 Code will restart to expose this environment over the network."
1184 : "T3 Code will restart and limit this environment back to this machine."}
1185 </AlertDialogDescription>
1186 </AlertDialogHeader>
1187 <AlertDialogFooter>
1188 <AlertDialogClose
1189 disabled={isUpdatingDesktopServerExposure}
1190 render={
1191 <Button variant="outline" disabled={isUpdatingDesktopServerExposure} />
1192 }
1193 >
1194 Cancel
1195 </AlertDialogClose>
1196 <Button
1197 onClick={handleConfirmDesktopServerExposureChange}
1198 disabled={
1199 pendingDesktopServerExposureMode === null ||
1200 isUpdatingDesktopServerExposure
1201 }
1202 >
1203 {isUpdatingDesktopServerExposure ? (
1204 <>
1205 <Spinner className="size-3.5" />
1206 Restarting…
1207 </>
1208 ) : pendingDesktopServerExposureMode === "network-accessible" ? (
1209 "Restart and enable"
1210 ) : (
1211 "Restart and disable"
1212 )}
1213 </Button>
1214 </AlertDialogFooter>
1215 </AlertDialogPopup>
1216 </AlertDialog>
1217 }2522 }
1218 />2523 />
2524 ) : null}
2525 {desktopBridge ? (
2526 <>
2527 {renderNetworkAccessRow()}
2528 {renderEndpointRows("endpoint-rail")}
2529 {renderTailscaleRow()}
2530 </>
1219 ) : (2531 ) : (
1220 <SettingsRow2532 renderDisabledNetworkAccessRow()
1221 title="Network access"
1222 description={
1223 currentAuthPolicy === "remote-reachable"
1224 ? "This backend is already configured for remote access. Network exposure changes must be made where the server is launched."
1225 : "This backend is only reachable on this machine. Restart it with a non-loopback host to enable remote pairing."
1226 }
1227 control={
1228 <Tooltip>
1229 <TooltipTrigger
1230 render={
1231 <span className="inline-flex">
1232 <Switch
1233 checked={isLocalBackendNetworkAccessible}
1234 disabled
1235 aria-label="Enable network access"
1236 />
1237 </span>
1238 }
1239 />
1240 <TooltipPopup side="top">
1241 Network exposure changes restart the backend and must be controlled where the
1242 server process is launched.
1243 </TooltipPopup>
1244 </Tooltip>
1245 }
1246 />
1247 )}2533 )}
1248 </SettingsSection>2534 </SettingsSection>
12492535
1250 {isLocalBackendNetworkAccessible ? (2536 {isLocalBackendRemotelyReachable ? (
1251 <SettingsSection2537 <SettingsSection
1252 title="Authorized clients"2538 title="Authorized clients"
1253 headerAction={2539 headerAction={
...@@ -1258,23 +2544,166 @@ export function ConnectionsSettings() {...@@ -1258,23 +2544,166 @@ export function ConnectionsSettings() {
1258 />2544 />
1259 }2545 }
1260 >2546 >
1261 {desktopAccessManagementError ? (2547 {renderAuthorizedClients("current")}
1262 <div className={ITEM_ROW_CLASSNAME}>
1263 <p className="text-xs text-destructive">{desktopAccessManagementError}</p>
1264 </div>
1265 ) : null}
1266 <PairingClientsList
1267 endpointUrl={desktopServerExposureState?.endpointUrl}
1268 isLoading={isLoadingDesktopAccessManagement}
1269 pairingLinks={visibleDesktopPairingLinks}
1270 clientSessions={desktopClientSessions}
1271 revokingPairingLinkId={revokingDesktopPairingLinkId}
1272 revokingClientSessionId={revokingDesktopClientSessionId}
1273 onRevokePairingLink={handleRevokeDesktopPairingLink}
1274 onRevokeClientSession={handleRevokeDesktopClientSession}
1275 />
1276 </SettingsSection>2548 </SettingsSection>
1277 ) : null}2549 ) : null}
2550 <AlertDialog
2551 open={isDesktopServerExposureDialogOpen}
2552 onOpenChange={(open) => {
2553 if (isUpdatingDesktopServerExposure) return;
2554 setIsDesktopServerExposureDialogOpen(open);
2555 }}
2556 onOpenChangeComplete={(open) => {
2557 if (!open) setPendingDesktopServerExposureMode(null);
2558 }}
2559 >
2560 <AlertDialogPopup>
2561 <AlertDialogHeader>
2562 <AlertDialogTitle>
2563 {pendingDesktopServerExposureMode === "network-accessible"
2564 ? "Enable network access?"
2565 : "Disable network access?"}
2566 </AlertDialogTitle>
2567 <AlertDialogDescription>
2568 {pendingDesktopServerExposureMode === "network-accessible"
2569 ? "T3 Code will restart to expose this environment over the network."
2570 : "T3 Code will restart and limit this environment back to this machine."}
2571 </AlertDialogDescription>
2572 </AlertDialogHeader>
2573 <AlertDialogFooter>
2574 <AlertDialogClose
2575 disabled={isUpdatingDesktopServerExposure}
2576 render={<Button variant="outline" disabled={isUpdatingDesktopServerExposure} />}
2577 >
2578 Cancel
2579 </AlertDialogClose>
2580 <Button
2581 variant={
2582 pendingDesktopServerExposureMode === "local-only" ? "destructive" : "default"
2583 }
2584 onClick={handleConfirmDesktopServerExposureChange}
2585 disabled={
2586 pendingDesktopServerExposureMode === null || isUpdatingDesktopServerExposure
2587 }
2588 >
2589 {isUpdatingDesktopServerExposure ? (
2590 <>
2591 <Spinner className="size-3.5" />
2592 Restarting…
2593 </>
2594 ) : pendingDesktopServerExposureMode === "network-accessible" ? (
2595 "Restart and enable"
2596 ) : (
2597 "Restart and disable"
2598 )}
2599 </Button>
2600 </AlertDialogFooter>
2601 </AlertDialogPopup>
2602 </AlertDialog>
2603 <AlertDialog
2604 open={disableTailscaleServeDialogOpen}
2605 onOpenChange={(open) => {
2606 if (isUpdatingTailscaleServe) return;
2607 setDisableTailscaleServeDialogOpen(open);
2608 }}
2609 >
2610 <AlertDialogPopup>
2611 <AlertDialogHeader>
2612 <AlertDialogTitle>Disable Tailscale HTTPS?</AlertDialogTitle>
2613 <AlertDialogDescription>
2614 T3 Code will restart the local backend without Tailscale Serve.
2615 </AlertDialogDescription>
2616 </AlertDialogHeader>
2617 <AlertDialogFooter>
2618 <AlertDialogClose
2619 disabled={isUpdatingTailscaleServe}
2620 render={<Button variant="outline" disabled={isUpdatingTailscaleServe} />}
2621 >
2622 Cancel
2623 </AlertDialogClose>
2624 <Button
2625 variant="destructive"
2626 onClick={() => void handleConfirmTailscaleServeDisable()}
2627 disabled={isUpdatingTailscaleServe}
2628 >
2629 {isUpdatingTailscaleServe ? (
2630 <>
2631 <Spinner className="size-3.5" />
2632 Restarting…
2633 </>
2634 ) : (
2635 "Restart and disable"
2636 )}
2637 </Button>
2638 </AlertDialogFooter>
2639 </AlertDialogPopup>
2640 </AlertDialog>
2641 <Dialog
2642 open={pendingTailscaleServeEndpoint !== null}
2643 onOpenChange={(open) => {
2644 if (isUpdatingTailscaleServe) return;
2645 if (!open) setPendingTailscaleServeEndpoint(null);
2646 }}
2647 >
2648 <DialogPopup className="max-w-md">
2649 <DialogHeader>
2650 <DialogTitle>Set up Tailscale HTTPS?</DialogTitle>
2651 <DialogDescription>
2652 T3 Code will restart the local backend with Tailscale Serve enabled and ask
2653 Tailscale to proxy HTTPS traffic to this backend.
2654 </DialogDescription>
2655 </DialogHeader>
2656 <DialogPanel className="space-y-4">
2657 <label className="block">
2658 <span className="text-sm font-medium text-foreground">HTTPS port</span>
2659 <Input
2660 className="mt-2"
2661 type="number"
2662 inputMode="numeric"
2663 min={1}
2664 max={65_535}
2665 step={1}
2666 value={tailscaleServePortInput}
2667 onChange={(event) => setTailscaleServePortInput(event.target.value)}
2668 disabled={isUpdatingTailscaleServe}
2669 />
2670 </label>
2671 {!isTailscaleServePortValid ? (
2672 <p className="mt-2 text-xs text-destructive">Enter a port from 1 to 65535.</p>
2673 ) : null}
2674 <div className="rounded-md border border-border/70 bg-muted/20 px-3 py-2">
2675 <p className="text-xs font-medium text-muted-foreground">HTTPS endpoint</p>
2676 <p
2677 className="mt-1 truncate text-sm text-foreground"
2678 title={pendingTailscaleServeBaseUrl ?? undefined}
2679 >
2680 {pendingTailscaleServeBaseUrl ?? "Pending MagicDNS endpoint"}
2681 </p>
2682 </div>
2683 </DialogPanel>
2684 <DialogFooter>
2685 <DialogClose
2686 disabled={isUpdatingTailscaleServe}
2687 render={<Button variant="outline" disabled={isUpdatingTailscaleServe} />}
2688 >
2689 Cancel
2690 </DialogClose>
2691 <Button
2692 onClick={() => void handleConfirmTailscaleServeSetup()}
2693 disabled={isUpdatingTailscaleServe || !isTailscaleServePortValid}
2694 >
2695 {isUpdatingTailscaleServe ? (
2696 <>
2697 <Spinner className="size-3.5" />
2698 Restarting…
2699 </>
2700 ) : (
2701 "Enable"
2702 )}
2703 </Button>
2704 </DialogFooter>
2705 </DialogPopup>
2706 </Dialog>
1278 </>2707 </>
1279 ) : (2708 ) : (
1280 <SettingsSection title="Local backend access">2709 <SettingsSection title="Local backend access">
...@@ -1305,120 +2734,32 @@ export function ConnectionsSettings() {...@@ -1305,120 +2734,32 @@ export function ConnectionsSettings() {
1305 </Button>2734 </Button>
1306 }2735 }
1307 />2736 />
1308 <DialogPopup>2737 <DialogPopup className="max-h-[80dvh] sm:max-w-3xl">
1309 <DialogHeader>2738 <DialogHeader>
1310 <DialogTitle>Add Environment</DialogTitle>2739 <DialogTitle>Add Environment</DialogTitle>
1311 <DialogDescription>Pair another environment to this client.</DialogDescription>2740 <DialogDescription>Pair another environment to this client.</DialogDescription>
1312 <div className="flex gap-1 rounded-lg border border-border/60 bg-muted/50 p-1">
1313 <button
1314 type="button"
1315 className={cn(
1316 "flex-1 rounded-md px-3 py-1.5 text-xs font-medium transition-colors",
1317 savedBackendMode === "pairing-url"
1318 ? "bg-background text-foreground shadow-xs"
1319 : "text-muted-foreground hover:text-foreground",
1320 )}
1321 disabled={isAddingSavedBackend}
1322 onClick={() => setSavedBackendMode("pairing-url")}
1323 >
1324 Pairing URL
1325 </button>
1326 <button
1327 type="button"
1328 className={cn(
1329 "flex-1 rounded-md px-3 py-1.5 text-xs font-medium transition-colors",
1330 savedBackendMode === "host-code"
1331 ? "bg-background text-foreground shadow-xs"
1332 : "text-muted-foreground hover:text-foreground",
1333 )}
1334 disabled={isAddingSavedBackend}
1335 onClick={() => setSavedBackendMode("host-code")}
1336 >
1337 Host + code
1338 </button>
1339 </div>
1340 </DialogHeader>2741 </DialogHeader>
1341 <DialogPanel>2742 <DialogPanel>
1342 <div className="space-y-4">2743 <div className="space-y-4">
1343 {savedBackendMode === "pairing-url" ? (2744 <div className="grid gap-3 sm:grid-cols-2">
1344 <p className="text-xs text-muted-foreground">2745 {renderConnectionModeCard({
1345 Enter the full pairing URL from the environment you want to connect to.2746 mode: "remote",
1346 </p>2747 title: "Remote link",
1347 ) : (2748 description: "Enter a backend host and pairing code.",
1348 <p className="text-xs text-muted-foreground">2749 icon: <ChevronsLeftRightEllipsisIcon aria-hidden className="size-4" />,
1349 Enter the backend host and pairing code separately.2750 })}
1350 </p>2751 {desktopBridge
1351 )}2752 ? renderConnectionModeCard({
1352 <div className="space-y-3">2753 mode: "ssh",
1353 <label className="block">2754 title: "SSH",
1354 <span className="mb-1.5 block text-xs font-medium text-foreground">2755 description: "Use local SSH config, agent, and tunnels for the backend.",
1355 Label2756 icon: <TerminalIcon aria-hidden className="size-4" />,
1356 </span>2757 })
1357 <Input2758 : null}
1358 value={savedBackendLabel}
1359 onChange={(event) => setSavedBackendLabel(event.target.value)}
1360 placeholder="My backend (optional)"
1361 disabled={isAddingSavedBackend}
1362 spellCheck={false}
1363 />
1364 </label>
1365 {savedBackendMode === "pairing-url" ? (
1366 <label className="block">
1367 <span className="mb-1.5 block text-xs font-medium text-foreground">
1368 Pairing URL
1369 </span>
1370 <Input
1371 value={savedBackendPairingUrl}
1372 onChange={(event) => setSavedBackendPairingUrl(event.target.value)}
1373 placeholder="https://backend.example.com/pair#token=..."
1374 disabled={isAddingSavedBackend}
1375 spellCheck={false}
1376 />
1377 <span className="mt-1 block text-[11px] text-muted-foreground">
1378 The full URL including the pairing token.
1379 </span>
1380 </label>
1381 ) : (
1382 <>
1383 <label className="block">
1384 <span className="mb-1.5 block text-xs font-medium text-foreground">
1385 Host
1386 </span>
1387 <Input
1388 value={savedBackendHost}
1389 onChange={(event) => setSavedBackendHost(event.target.value)}
1390 placeholder="https://backend.example.com"
1391 disabled={isAddingSavedBackend}
1392 spellCheck={false}
1393 />
1394 </label>
1395 <label className="block">
1396 <span className="mb-1.5 block text-xs font-medium text-foreground">
1397 Pairing code
1398 </span>
1399 <Input
1400 value={savedBackendPairingCode}
1401 onChange={(event) => setSavedBackendPairingCode(event.target.value)}
1402 placeholder="Pairing code"
1403 disabled={isAddingSavedBackend}
1404 spellCheck={false}
1405 />
1406 </label>
1407 </>
1408 )}
1409 </div>2759 </div>
1410 {savedBackendError ? (2760 <AnimatedHeight>
1411 <p className="text-xs text-destructive">{savedBackendError}</p>2761 {savedBackendMode === "ssh" ? renderSshFields() : renderRemoteModeBody()}
1412 ) : null}2762 </AnimatedHeight>
1413 <Button
1414 variant="outline"
1415 className="w-full"
1416 disabled={isAddingSavedBackend}
1417 onClick={() => void handleAddSavedBackend()}
1418 >
1419 <PlusIcon className="size-3.5" />
1420 {isAddingSavedBackend ? "Adding…" : "Add Backend"}
1421 </Button>
1422 </div>2763 </div>
1423 </DialogPanel>2764 </DialogPanel>
1424 </DialogPopup>2765 </DialogPopup>
...@@ -1430,8 +2771,10 @@ export function ConnectionsSettings() {...@@ -1430,8 +2771,10 @@ export function ConnectionsSettings() {
1430 key={environmentId}2771 key={environmentId}
1431 environmentId={environmentId}2772 environmentId={environmentId}
1432 reconnectingEnvironmentId={reconnectingSavedEnvironmentId}2773 reconnectingEnvironmentId={reconnectingSavedEnvironmentId}
2774 disconnectingEnvironmentId={disconnectingSavedEnvironmentId}
1433 removingEnvironmentId={removingSavedEnvironmentId}2775 removingEnvironmentId={removingSavedEnvironmentId}
1434 onReconnect={handleReconnectSavedBackend}2776 onConnect={handleConnectSavedBackend}
2777 onDisconnect={handleDisconnectSavedBackend}
1435 onRemove={handleRemoveSavedBackend}2778 onRemove={handleRemoveSavedBackend}
1436 />2779 />
1437 ))}2780 ))}
apps/web/src/components/settings/SettingsPanels.browser.tsx+309-2
...@@ -21,6 +21,7 @@ import { render } from "vitest-browser-react";...@@ -21,6 +21,7 @@ import { render } from "vitest-browser-react";
21import { __resetLocalApiForTests } from "../../localApi";21import { __resetLocalApiForTests } from "../../localApi";
22import { AppAtomRegistryProvider, resetAppAtomRegistryForTests } from "../../rpc/atomRegistry";22import { AppAtomRegistryProvider, resetAppAtomRegistryForTests } from "../../rpc/atomRegistry";
23import { resetServerStateForTests, setServerConfigSnapshot } from "../../rpc/serverState";23import { resetServerStateForTests, setServerConfigSnapshot } from "../../rpc/serverState";
24import { useUiStateStore } from "../../uiStateStore";
24import { ConnectionsSettings } from "./ConnectionsSettings";25import { ConnectionsSettings } from "./ConnectionsSettings";
25import { GeneralSettingsPanel } from "./SettingsPanels";26import { GeneralSettingsPanel } from "./SettingsPanels";
26import { SourceControlSettingsPanel } from "./SourceControlSettings";27import { SourceControlSettingsPanel } from "./SourceControlSettings";
...@@ -115,6 +116,8 @@ const authAccessHarness = vi.hoisted(() => {...@@ -115,6 +116,8 @@ const authAccessHarness = vi.hoisted(() => {
115 };116 };
116});117});
117118
119const mockConnectDesktopSshEnvironment = vi.hoisted(() => vi.fn());
120
118vi.mock("../../environments/runtime", () => {121vi.mock("../../environments/runtime", () => {
119 const primaryConnection = {122 const primaryConnection = {
120 kind: "primary" as const,123 kind: "primary" as const,
...@@ -152,6 +155,7 @@ vi.mock("../../environments/runtime", () => {...@@ -152,6 +155,7 @@ vi.mock("../../environments/runtime", () => {
152 new URL(path, "http://localhost:3000").toString(),155 new URL(path, "http://localhost:3000").toString(),
153 waitForSavedEnvironmentRegistryHydration: async () => undefined,156 waitForSavedEnvironmentRegistryHydration: async () => undefined,
154 addSavedEnvironment: vi.fn(),157 addSavedEnvironment: vi.fn(),
158 connectDesktopSshEnvironment: mockConnectDesktopSshEnvironment,
155 disconnectSavedEnvironment: vi.fn(),159 disconnectSavedEnvironment: vi.fn(),
156 ensureEnvironmentConnectionBootstrapped: async () => undefined,160 ensureEnvironmentConnectionBootstrapped: async () => undefined,
157 getPrimaryEnvironmentConnection: () => primaryConnection,161 getPrimaryEnvironmentConnection: () => primaryConnection,
...@@ -259,7 +263,9 @@ function makeClientSession(input: {...@@ -259,7 +263,9 @@ function makeClientSession(input: {
259}263}
260264
261const createDesktopBridgeStub = (overrides?: {265const createDesktopBridgeStub = (overrides?: {
266 readonly discoverSshHosts?: DesktopBridge["discoverSshHosts"];
262 readonly serverExposureState?: Awaited<ReturnType<DesktopBridge["getServerExposureState"]>>;267 readonly serverExposureState?: Awaited<ReturnType<DesktopBridge["getServerExposureState"]>>;
268 readonly advertisedEndpoints?: Awaited<ReturnType<DesktopBridge["getAdvertisedEndpoints"]>>;
263 readonly setServerExposureMode?: DesktopBridge["setServerExposureMode"];269 readonly setServerExposureMode?: DesktopBridge["setServerExposureMode"];
264 readonly setUpdateChannel?: DesktopBridge["setUpdateChannel"];270 readonly setUpdateChannel?: DesktopBridge["setUpdateChannel"];
265}): DesktopBridge => {271}): DesktopBridge => {
...@@ -295,11 +301,58 @@ const createDesktopBridgeStub = (overrides?: {...@@ -295,11 +301,58 @@ const createDesktopBridgeStub = (overrides?: {
295 getSavedEnvironmentSecret: vi.fn().mockResolvedValue(null),301 getSavedEnvironmentSecret: vi.fn().mockResolvedValue(null),
296 setSavedEnvironmentSecret: vi.fn().mockResolvedValue(true),302 setSavedEnvironmentSecret: vi.fn().mockResolvedValue(true),
297 removeSavedEnvironmentSecret: vi.fn().mockResolvedValue(undefined),303 removeSavedEnvironmentSecret: vi.fn().mockResolvedValue(undefined),
304 discoverSshHosts: overrides?.discoverSshHosts ?? vi.fn().mockResolvedValue([]),
305 ensureSshEnvironment: vi.fn().mockImplementation(async (target) => ({
306 target,
307 httpBaseUrl: "http://127.0.0.1:3774/",
308 wsBaseUrl: "ws://127.0.0.1:3774/",
309 pairingToken: "ssh-pairing-token",
310 })),
311 disconnectSshEnvironment: vi.fn().mockResolvedValue(undefined),
312 fetchSshEnvironmentDescriptor: vi.fn().mockResolvedValue({
313 environmentId: "environment-ssh",
314 label: "SSH environment",
315 platform: {
316 os: "linux",
317 arch: "x64",
318 },
319 serverVersion: "0.0.0-test",
320 capabilities: {
321 repositoryIdentity: true,
322 },
323 }),
324 bootstrapSshBearerSession: vi.fn().mockResolvedValue({
325 authenticated: true,
326 role: "owner",
327 sessionMethod: "bearer-session-token",
328 expiresAt: "2026-05-01T12:00:00.000Z",
329 sessionToken: "ssh-bearer-token",
330 }),
331 fetchSshSessionState: vi.fn().mockResolvedValue({
332 authenticated: true,
333 auth: {
334 policy: "remote-reachable",
335 bootstrapMethods: ["one-time-token"],
336 sessionMethods: ["browser-session-cookie", "bearer-session-token"],
337 sessionCookieName: "t3_session",
338 },
339 role: "owner",
340 sessionMethod: "bearer-session-token",
341 expiresAt: "2026-05-01T12:00:00.000Z",
342 }),
343 issueSshWebSocketToken: vi.fn().mockResolvedValue({
344 token: "ssh-ws-token",
345 expiresAt: "2026-05-01T12:05:00.000Z",
346 }),
347 onSshPasswordPrompt: vi.fn(() => () => {}),
348 resolveSshPasswordPrompt: vi.fn().mockResolvedValue(undefined),
298 getServerExposureState: vi.fn().mockResolvedValue(349 getServerExposureState: vi.fn().mockResolvedValue(
299 overrides?.serverExposureState ?? {350 overrides?.serverExposureState ?? {
300 mode: "local-only",351 mode: "local-only",
301 endpointUrl: null,352 endpointUrl: null,
302 advertisedHost: null,353 advertisedHost: null,
354 tailscaleServeEnabled: false,
355 tailscaleServePort: 443,
303 },356 },
304 ),357 ),
305 setServerExposureMode:358 setServerExposureMode:
...@@ -308,7 +361,17 @@ const createDesktopBridgeStub = (overrides?: {...@@ -308,7 +361,17 @@ const createDesktopBridgeStub = (overrides?: {
308 mode,361 mode,
309 endpointUrl: mode === "network-accessible" ? "http://192.168.1.44:3773" : null,362 endpointUrl: mode === "network-accessible" ? "http://192.168.1.44:3773" : null,
310 advertisedHost: mode === "network-accessible" ? "192.168.1.44" : null,363 advertisedHost: mode === "network-accessible" ? "192.168.1.44" : null,
364 tailscaleServeEnabled: false,
365 tailscaleServePort: 443,
311 })),366 })),
367 setTailscaleServeEnabled: vi.fn().mockImplementation(async (input) => ({
368 mode: overrides?.serverExposureState?.mode ?? "network-accessible",
369 endpointUrl: overrides?.serverExposureState?.endpointUrl ?? "http://192.168.1.44:3773",
370 advertisedHost: overrides?.serverExposureState?.advertisedHost ?? "192.168.1.44",
371 tailscaleServeEnabled: input.enabled,
372 tailscaleServePort: input.port ?? 443,
373 })),
374 getAdvertisedEndpoints: vi.fn().mockResolvedValue(overrides?.advertisedEndpoints ?? []),
312 pickFolder: vi.fn().mockResolvedValue(null),375 pickFolder: vi.fn().mockResolvedValue(null),
313 confirm: vi.fn().mockResolvedValue(false),376 confirm: vi.fn().mockResolvedValue(false),
314 setTheme: vi.fn().mockResolvedValue(undefined),377 setTheme: vi.fn().mockResolvedValue(undefined),
...@@ -345,7 +408,9 @@ describe("GeneralSettingsPanel observability", () => {...@@ -345,7 +408,9 @@ describe("GeneralSettingsPanel observability", () => {
345 resetServerStateForTests();408 resetServerStateForTests();
346 await __resetLocalApiForTests();409 await __resetLocalApiForTests();
347 localStorage.clear();410 localStorage.clear();
411 useUiStateStore.setState({ defaultAdvertisedEndpointKey: null });
348 authAccessHarness.reset();412 authAccessHarness.reset();
413 mockConnectDesktopSshEnvironment.mockReset();
349 });414 });
350415
351 afterEach(async () => {416 afterEach(async () => {
...@@ -431,6 +496,176 @@ describe("GeneralSettingsPanel observability", () => {...@@ -431,6 +496,176 @@ describe("GeneralSettingsPanel observability", () => {
431 .toBeInTheDocument();496 .toBeInTheDocument();
432 });497 });
433498
499 it("hides advertised endpoint rows when desktop network access is disabled", async () => {
500 window.desktopBridge = createDesktopBridgeStub({
501 serverExposureState: {
502 mode: "local-only",
503 endpointUrl: null,
504 advertisedHost: null,
505 tailscaleServeEnabled: false,
506 tailscaleServePort: 443,
507 },
508 advertisedEndpoints: [
509 {
510 id: "loopback",
511 label: "This machine",
512 provider: {
513 id: "desktop-core",
514 label: "Desktop",
515 kind: "manual",
516 isAddon: false,
517 },
518 httpBaseUrl: "http://127.0.0.1:3773/",
519 wsBaseUrl: "ws://127.0.0.1:3773/",
520 reachability: "loopback",
521 compatibility: {
522 hostedHttpsApp: "mixed-content-blocked",
523 desktopApp: "compatible",
524 },
525 source: "desktop-core",
526 status: "available",
527 isDefault: true,
528 },
529 {
530 id: "tailscale-ip",
531 label: "Tailscale IP",
532 provider: {
533 id: "tailscale",
534 label: "Tailscale",
535 kind: "private-network",
536 isAddon: true,
537 },
538 httpBaseUrl: "http://100.105.39.17:3773/",
539 wsBaseUrl: "ws://100.105.39.17:3773/",
540 reachability: "private-network",
541 compatibility: {
542 hostedHttpsApp: "mixed-content-blocked",
543 desktopApp: "compatible",
544 },
545 source: "desktop-addon",
546 status: "available",
547 },
548 ],
549 });
550 authAccessHarness.setSnapshot({
551 pairingLinks: [],
552 clientSessions: [],
553 });
554 setServerConfigSnapshot(createBaseServerConfig());
555
556 mounted = await render(
557 <AppAtomRegistryProvider>
558 <ConnectionsSettings />
559 </AppAtomRegistryProvider>,
560 );
561
562 await expect.element(page.getByText("Limited to this machine.")).toBeInTheDocument();
563 await expect
564 .element(page.getByRole("heading", { name: "This machine", exact: true }))
565 .not.toBeInTheDocument();
566 await expect
567 .element(page.getByRole("heading", { name: "Tailscale IP", exact: true }))
568 .not.toBeInTheDocument();
569 });
570
571 it("collapses advertised endpoints behind the network access summary", async () => {
572 window.desktopBridge = createDesktopBridgeStub({
573 serverExposureState: {
574 mode: "network-accessible",
575 endpointUrl: "http://192.168.86.39:3773",
576 advertisedHost: "192.168.86.39",
577 tailscaleServeEnabled: false,
578 tailscaleServePort: 443,
579 },
580 advertisedEndpoints: [
581 {
582 id: "desktop-loopback:3773",
583 label: "This machine",
584 provider: {
585 id: "desktop-core",
586 label: "Desktop",
587 kind: "manual",
588 isAddon: false,
589 },
590 httpBaseUrl: "http://127.0.0.1:3773/",
591 wsBaseUrl: "ws://127.0.0.1:3773/",
592 reachability: "loopback",
593 compatibility: {
594 hostedHttpsApp: "mixed-content-blocked",
595 desktopApp: "compatible",
596 },
597 source: "desktop-core",
598 status: "available",
599 },
600 {
601 id: "desktop-lan:http://192.168.86.39:3773",
602 label: "Local network",
603 provider: {
604 id: "desktop-core",
605 label: "Desktop",
606 kind: "manual",
607 isAddon: false,
608 },
609 httpBaseUrl: "http://192.168.86.39:3773/",
610 wsBaseUrl: "ws://192.168.86.39:3773/",
611 reachability: "lan",
612 compatibility: {
613 hostedHttpsApp: "mixed-content-blocked",
614 desktopApp: "compatible",
615 },
616 source: "desktop-core",
617 status: "available",
618 isDefault: true,
619 },
620 {
621 id: "tailscale-ip:http://100.105.39.17:3773",
622 label: "Tailscale IP",
623 provider: {
624 id: "tailscale",
625 label: "Tailscale",
626 kind: "private-network",
627 isAddon: true,
628 },
629 httpBaseUrl: "http://100.105.39.17:3773/",
630 wsBaseUrl: "ws://100.105.39.17:3773/",
631 reachability: "private-network",
632 compatibility: {
633 hostedHttpsApp: "mixed-content-blocked",
634 desktopApp: "compatible",
635 },
636 source: "desktop-addon",
637 status: "available",
638 },
639 ],
640 });
641 authAccessHarness.setSnapshot({
642 pairingLinks: [],
643 clientSessions: [],
644 });
645 setServerConfigSnapshot(createBaseServerConfig());
646
647 mounted = await render(
648 <AppAtomRegistryProvider>
649 <ConnectionsSettings />
650 </AppAtomRegistryProvider>,
651 );
652
653 await expect.element(page.getByText("http://192.168.86.39:3773/")).toBeInTheDocument();
654 await expect.element(page.getByRole("button", { name: "+2" })).toBeInTheDocument();
655 await expect
656 .element(page.getByRole("heading", { name: "Local network", exact: true }))
657 .not.toBeInTheDocument();
658
659 await page.getByRole("button", { name: "+2" }).click();
660
661 await expect
662 .element(page.getByRole("heading", { name: "Local network", exact: true }))
663 .toBeInTheDocument();
664 await expect.element(page.getByText("Default", { exact: true })).toBeInTheDocument();
665 await page.getByRole("button", { name: "Set as default" }).first().click();
666 await expect.element(page.getByText("http://127.0.0.1:3773/").first()).toBeInTheDocument();
667 });
668
434 it("shows diagnostics inside About with a single logs-folder action", async () => {669 it("shows diagnostics inside About with a single logs-folder action", async () => {
435 setServerConfigSnapshot(createBaseServerConfig());670 setServerConfigSnapshot(createBaseServerConfig());
436671
...@@ -461,6 +696,8 @@ describe("GeneralSettingsPanel observability", () => {...@@ -461,6 +696,8 @@ describe("GeneralSettingsPanel observability", () => {
461 mode: "network-accessible",696 mode: "network-accessible",
462 endpointUrl: "http://192.168.1.44:3773",697 endpointUrl: "http://192.168.1.44:3773",
463 advertisedHost: "192.168.1.44",698 advertisedHost: "192.168.1.44",
699 tailscaleServeEnabled: false,
700 tailscaleServePort: 443,
464 },701 },
465 });702 });
466 let pairingLinks: Array<AuthAccessSnapshot["pairingLinks"][number]> = [];703 let pairingLinks: Array<AuthAccessSnapshot["pairingLinks"][number]> = [];
...@@ -566,7 +803,7 @@ describe("GeneralSettingsPanel observability", () => {...@@ -566,7 +803,7 @@ describe("GeneralSettingsPanel observability", () => {
566 .element(page.getByText("Client · Mobile · iOS · Safari · 192.168.1.88"))803 .element(page.getByText("Client · Mobile · iOS · Safari · 192.168.1.88"))
567 .toBeInTheDocument();804 .toBeInTheDocument();
568 await expect805 await expect
569 .element(page.getByRole("button", { name: /^(Copy|Show link)$/ }))806 .element(page.getByRole("button", { name: /^Copy pairing URL for:/ }))
570 .toBeInTheDocument();807 .toBeInTheDocument();
571 await expect.element(page.getByText("Revoke others")).toBeInTheDocument();808 await expect.element(page.getByText("Revoke others")).toBeInTheDocument();
572 });809 });
...@@ -577,6 +814,8 @@ describe("GeneralSettingsPanel observability", () => {...@@ -577,6 +814,8 @@ describe("GeneralSettingsPanel observability", () => {
577 mode: "network-accessible",814 mode: "network-accessible",
578 endpointUrl: "http://192.168.1.44:3773",815 endpointUrl: "http://192.168.1.44:3773",
579 advertisedHost: "192.168.1.44",816 advertisedHost: "192.168.1.44",
817 tailscaleServeEnabled: false,
818 tailscaleServePort: 443,
580 },819 },
581 });820 });
582 let clientSessions: Array<AuthAccessSnapshot["clientSessions"][number]> = [821 let clientSessions: Array<AuthAccessSnapshot["clientSessions"][number]> = [
...@@ -677,9 +916,77 @@ describe("GeneralSettingsPanel observability", () => {...@@ -677,9 +916,77 @@ describe("GeneralSettingsPanel observability", () => {
677 await vi.waitFor(() => {916 await vi.waitFor(() => {
678 expect(desktopBridge.setServerExposureMode).toHaveBeenCalledWith("network-accessible");917 expect(desktopBridge.setServerExposureMode).toHaveBeenCalledWith("network-accessible");
679 });918 });
919 await expect.element(page.getByText("http://192.168.1.44:3773")).toBeInTheDocument();
920 });
921
922 it("adds desktop ssh environments from the add-environment dialog", async () => {
923 const discoverSshHosts = vi.fn().mockResolvedValue([
924 {
925 alias: "devbox",
926 hostname: "devbox.example.com",
927 username: "julius",
928 port: 22,
929 source: "ssh-config" as const,
930 },
931 ]);
932 window.desktopBridge = createDesktopBridgeStub({
933 discoverSshHosts,
934 });
935 mockConnectDesktopSshEnvironment.mockResolvedValue({
936 environmentId: EnvironmentId.make("environment-devbox"),
937 label: "Build box",
938 wsBaseUrl: "ws://127.0.0.1:3774/",
939 httpBaseUrl: "http://127.0.0.1:3774/",
940 createdAt: "2036-04-07T00:00:00.000Z",
941 lastConnectedAt: "2036-04-07T00:00:00.000Z",
942 desktopSsh: {
943 alias: "devbox.example.com",
944 hostname: "devbox.example.com",
945 username: "julius",
946 port: 2222,
947 },
948 });
949
950 setServerConfigSnapshot(createBaseServerConfig());
951
952 mounted = await render(
953 <AppAtomRegistryProvider>
954 <ConnectionsSettings />
955 </AppAtomRegistryProvider>,
956 );
957
958 await page.getByRole("button", { name: "Add environment", exact: true }).click();
959 const addEnvironmentDialog = page.getByRole("dialog", { name: "Add Environment" });
960 await expect
961 .element(addEnvironmentDialog.getByRole("heading", { name: "Add Environment", exact: true }))
962 .toBeInTheDocument();
963 await addEnvironmentDialog.getByRole("button", { name: /^SSH\b/ }).click();
964 await vi.waitFor(() => {
965 expect(discoverSshHosts).toHaveBeenCalledTimes(1);
966 });
680 await expect967 await expect
681 .element(page.getByText("Reachable at http://192.168.1.44:3773"))968 .element(page.getByRole("heading", { name: "devbox", exact: true }))
682 .toBeInTheDocument();969 .toBeInTheDocument();
970
971 await addEnvironmentDialog.getByLabelText("SSH host or alias").fill("devbox.example.com");
972 await addEnvironmentDialog.getByLabelText("Username").fill("julius");
973 await addEnvironmentDialog.getByLabelText("Port").fill("2222");
974 await addEnvironmentDialog
975 .getByRole("button", { name: "Add environment", exact: true })
976 .first()
977 .click();
978
979 await vi.waitFor(() => {
980 expect(mockConnectDesktopSshEnvironment).toHaveBeenCalledWith(
981 {
982 alias: "devbox.example.com",
983 hostname: "devbox.example.com",
984 username: "julius",
985 port: 2222,
986 },
987 { label: "" },
988 );
989 });
683 });990 });
684991
685 it("opens the logs folder in the preferred editor", async () => {992 it("opens the logs folder in the preferred editor", async () => {
apps/web/src/components/settings/pairingUrls.test.ts created+24
...@@ -0,0 +1,24 @@
1import { afterEach, describe, expect, it, vi } from "vitest";
2
3import { resolveDesktopPairingUrl, resolveHostedPairingUrl } from "./pairingUrls";
4
5describe("settings pairing URL helpers", () => {
6 afterEach(() => {
7 vi.unstubAllEnvs();
8 });
9
10 it("uses direct backend pairing URLs for HTTP endpoints", () => {
11 expect(resolveHostedPairingUrl("http://192.168.1.44:3773", "PAIRCODE")).toBeNull();
12 expect(resolveDesktopPairingUrl("http://192.168.1.44:3773", "PAIRCODE")).toBe(
13 "http://192.168.1.44:3773/pair#token=PAIRCODE",
14 );
15 });
16
17 it("uses hosted pairing URLs for HTTPS endpoints", () => {
18 vi.stubEnv("VITE_HOSTED_APP_URL", "https://preview.t3.codes");
19
20 expect(resolveHostedPairingUrl("https://host.tailnet.example.ts.net:3773", "PAIRCODE")).toBe(
21 "https://preview.t3.codes/pair?host=https%3A%2F%2Fhost.tailnet.example.ts.net%3A3773#token=PAIRCODE",
22 );
23 });
24});
apps/web/src/components/settings/pairingUrls.ts created+20
...@@ -0,0 +1,20 @@
1import { buildHostedPairingUrl } from "../../hostedPairing";
2import { setPairingTokenOnUrl } from "../../pairingUrl";
3
4export function resolveDesktopPairingUrl(endpointUrl: string, credential: string): string {
5 const url = new URL(endpointUrl);
6 url.pathname = "/pair";
7 return setPairingTokenOnUrl(url, credential).toString();
8}
9
10export function resolveHostedPairingUrl(endpointUrl: string, credential: string): string | null {
11 const url = new URL(endpointUrl);
12 if (url.protocol !== "https:") {
13 return null;
14 }
15
16 return buildHostedPairingUrl({
17 host: endpointUrl,
18 token: credential,
19 });
20}
apps/web/src/components/settings/settingsLayout.tsx+1-1
...@@ -52,7 +52,7 @@ export function SettingsRow({...@@ -52,7 +52,7 @@ export function SettingsRow({
52 children,52 children,
53}: {53}: {
54 title: ReactNode;54 title: ReactNode;
55 description: string;55 description: ReactNode;
56 status?: ReactNode;56 status?: ReactNode;
57 resetAction?: ReactNode;57 resetAction?: ReactNode;
58 control?: ReactNode;58 control?: ReactNode;
apps/web/src/components/ui/alert-dialog.tsx+2-2
...@@ -18,7 +18,7 @@ function AlertDialogBackdrop({ className, ...props }: AlertDialogPrimitive.Backd...@@ -18,7 +18,7 @@ function AlertDialogBackdrop({ className, ...props }: AlertDialogPrimitive.Backd
18 return (18 return (
19 <AlertDialogPrimitive.Backdrop19 <AlertDialogPrimitive.Backdrop
20 className={cn(20 className={cn(
21 "fixed inset-0 z-50 bg-black/32 backdrop-blur-sm transition-all duration-200 ease-out data-ending-style:opacity-0 data-starting-style:opacity-0",21 "fixed inset-0 z-50 transition-all duration-200 data-ending-style:opacity-0 data-starting-style:opacity-0",
22 className,22 className,
23 )}23 )}
24 data-slot="alert-dialog-backdrop"24 data-slot="alert-dialog-backdrop"
...@@ -31,7 +31,7 @@ function AlertDialogViewport({ className, ...props }: AlertDialogPrimitive.Viewp...@@ -31,7 +31,7 @@ function AlertDialogViewport({ className, ...props }: AlertDialogPrimitive.Viewp
31 return (31 return (
32 <AlertDialogPrimitive.Viewport32 <AlertDialogPrimitive.Viewport
33 className={cn(33 className={cn(
34 "fixed inset-0 z-50 grid grid-rows-[1fr_auto_3fr] justify-items-center p-4",34 "fixed inset-0 z-50 grid grid-rows-[1fr_auto_3fr] justify-items-center bg-white/48 p-4 backdrop-blur-[2px] dark:bg-black/28",
35 className,35 className,
36 )}36 )}
37 data-slot="alert-dialog-viewport"37 data-slot="alert-dialog-viewport"
apps/web/src/components/ui/dialog.tsx+2-2
...@@ -24,7 +24,7 @@ function DialogBackdrop({ className, ...props }: DialogPrimitive.Backdrop.Props)...@@ -24,7 +24,7 @@ function DialogBackdrop({ className, ...props }: DialogPrimitive.Backdrop.Props)
24 return (24 return (
25 <DialogPrimitive.Backdrop25 <DialogPrimitive.Backdrop
26 className={cn(26 className={cn(
27 "fixed inset-0 z-50 bg-black/32 backdrop-blur-sm transition-all duration-200 data-ending-style:opacity-0 data-starting-style:opacity-0",27 "fixed inset-0 z-50 transition-all duration-200 data-ending-style:opacity-0 data-starting-style:opacity-0",
28 className,28 className,
29 )}29 )}
30 data-slot="dialog-backdrop"30 data-slot="dialog-backdrop"
...@@ -37,7 +37,7 @@ function DialogViewport({ className, ...props }: DialogPrimitive.Viewport.Props)...@@ -37,7 +37,7 @@ function DialogViewport({ className, ...props }: DialogPrimitive.Viewport.Props)
37 return (37 return (
38 <DialogPrimitive.Viewport38 <DialogPrimitive.Viewport
39 className={cn(39 className={cn(
40 "fixed inset-0 z-50 grid grid-rows-[1fr_auto_3fr] justify-items-center p-4",40 "fixed inset-0 z-50 grid grid-rows-[1fr_auto_3fr] justify-items-center bg-white/48 p-4 backdrop-blur-[2px] dark:bg-black/28",
41 className,41 className,
42 )}42 )}
43 data-slot="dialog-viewport"43 data-slot="dialog-viewport"
apps/web/src/components/ui/scroll-area.tsx+1-1
...@@ -23,7 +23,7 @@ function ScrollArea({...@@ -23,7 +23,7 @@ function ScrollArea({
23 >23 >
24 <ScrollAreaPrimitive.Viewport24 <ScrollAreaPrimitive.Viewport
25 className={cn(25 className={cn(
26 "h-full overflow-y-auto overscroll-contain rounded-[inherit] outline-none transition-shadows focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-1 focus-visible:ring-offset-background data-has-overflow-x:overscroll-x-contain",26 "h-full max-h-[inherit] overflow-y-auto overscroll-contain rounded-[inherit] outline-none transition-shadows focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-1 focus-visible:ring-offset-background data-has-overflow-x:overscroll-x-contain",
27 scrollFade &&27 scrollFade &&
28 "mask-t-from-[calc(100%-min(var(--fade-size),var(--scroll-area-overflow-y-start)))] mask-b-from-[calc(100%-min(var(--fade-size),var(--scroll-area-overflow-y-end)))] mask-l-from-[calc(100%-min(var(--fade-size),var(--scroll-area-overflow-x-start)))] mask-r-from-[calc(100%-min(var(--fade-size),var(--scroll-area-overflow-x-end)))] [--fade-size:1.5rem]",28 "mask-t-from-[calc(100%-min(var(--fade-size),var(--scroll-area-overflow-y-start)))] mask-b-from-[calc(100%-min(var(--fade-size),var(--scroll-area-overflow-y-end)))] mask-l-from-[calc(100%-min(var(--fade-size),var(--scroll-area-overflow-x-start)))] mask-r-from-[calc(100%-min(var(--fade-size),var(--scroll-area-overflow-x-end)))] [--fade-size:1.5rem]",
29 scrollbarGutter && "[scrollbar-gutter:stable]",29 scrollbarGutter && "[scrollbar-gutter:stable]",
apps/web/src/environments/remote/api.test.ts+13
...@@ -48,6 +48,19 @@ describe("remote environment api", () => {...@@ -48,6 +48,19 @@ describe("remote environment api", () => {
48 });48 });
49 });49 });
5050
51 it("derives backend urls and token from a hosted app pairing link", () => {
52 expect(
53 resolveRemotePairingTarget({
54 pairingUrl:
55 "https://app.t3.codes/pair?host=https%3A%2F%2Fdesktop.tailnet.ts.net%3A44342%2F#token=pairing-token",
56 }),
57 ).toEqual({
58 credential: "pairing-token",
59 httpBaseUrl: "https://desktop.tailnet.ts.net:44342/",
60 wsBaseUrl: "wss://desktop.tailnet.ts.net:44342/",
61 });
62 });
63
51 it("derives backend urls from a host and pairing code", () => {64 it("derives backend urls from a host and pairing code", () => {
52 expect(65 expect(
53 resolveRemotePairingTarget({66 resolveRemotePairingTarget({
apps/web/src/environments/remote/api.ts+6
...@@ -15,6 +15,12 @@ class RemoteEnvironmentAuthHttpError extends Error {...@@ -15,6 +15,12 @@ class RemoteEnvironmentAuthHttpError extends Error {
15 }15 }
16}16}
1717
18export function isRemoteEnvironmentAuthHttpError(
19 error: unknown,
20): error is RemoteEnvironmentAuthHttpError {
21 return error instanceof RemoteEnvironmentAuthHttpError;
22}
23
18function remoteEndpointUrl(httpBaseUrl: string, pathname: string): string {24function remoteEndpointUrl(httpBaseUrl: string, pathname: string): string {
19 const url = new URL(httpBaseUrl);25 const url = new URL(httpBaseUrl);
20 url.pathname = pathname;26 url.pathname = pathname;
apps/web/src/environments/remote/target.ts+11
...@@ -1,4 +1,5 @@...@@ -1,4 +1,5 @@
1import { getPairingTokenFromUrl } from "../../pairingUrl";1import { getPairingTokenFromUrl } from "../../pairingUrl";
2import { readHostedPairingRequest } from "../../hostedPairing";
23
3export interface ResolvedRemotePairingTarget {4export interface ResolvedRemotePairingTarget {
4 readonly credential: string;5 readonly credential: string;
...@@ -57,6 +58,16 @@ export function resolveRemotePairingTarget(input: {...@@ -57,6 +58,16 @@ export function resolveRemotePairingTarget(input: {
57 const pairingUrl = input.pairingUrl?.trim() ?? "";58 const pairingUrl = input.pairingUrl?.trim() ?? "";
58 if (pairingUrl.length > 0) {59 if (pairingUrl.length > 0) {
59 const url = new URL(pairingUrl, window.location.origin);60 const url = new URL(pairingUrl, window.location.origin);
61 const hostedPairingRequest = readHostedPairingRequest(url);
62 if (hostedPairingRequest) {
63 const hostedBackendUrl = normalizeRemoteBaseUrl(hostedPairingRequest.host);
64 return {
65 credential: hostedPairingRequest.token,
66 httpBaseUrl: toHttpBaseUrl(hostedBackendUrl),
67 wsBaseUrl: toWsBaseUrl(hostedBackendUrl),
68 };
69 }
70
60 const credential = getPairingTokenFromUrl(url) ?? "";71 const credential = getPairingTokenFromUrl(url) ?? "";
61 if (!credential) {72 if (!credential) {
62 throw new Error("Pairing URL is missing its token.");73 throw new Error("Pairing URL is missing its token.");
apps/web/src/environments/runtime/catalog.ts+21-1
...@@ -18,6 +18,7 @@ export interface SavedEnvironmentRecord {...@@ -18,6 +18,7 @@ export interface SavedEnvironmentRecord {
18 readonly httpBaseUrl: string;18 readonly httpBaseUrl: string;
19 readonly createdAt: string;19 readonly createdAt: string;
20 readonly lastConnectedAt: string | null;20 readonly lastConnectedAt: string | null;
21 readonly desktopSsh?: PersistedSavedEnvironmentRecord["desktopSsh"];
21}22}
2223
23interface SavedEnvironmentRegistryState {24interface SavedEnvironmentRegistryState {
...@@ -28,13 +29,14 @@ interface SavedEnvironmentRegistryStore extends SavedEnvironmentRegistryState {...@@ -28,13 +29,14 @@ interface SavedEnvironmentRegistryStore extends SavedEnvironmentRegistryState {
28 readonly upsert: (record: SavedEnvironmentRecord) => void;29 readonly upsert: (record: SavedEnvironmentRecord) => void;
29 readonly remove: (environmentId: EnvironmentId) => void;30 readonly remove: (environmentId: EnvironmentId) => void;
30 readonly markConnected: (environmentId: EnvironmentId, connectedAt: string) => void;31 readonly markConnected: (environmentId: EnvironmentId, connectedAt: string) => void;
32 readonly rename: (environmentId: EnvironmentId, label: string) => void;
31 readonly reset: () => void;33 readonly reset: () => void;
32}34}
3335
34let savedEnvironmentRegistryHydrated = false;36let savedEnvironmentRegistryHydrated = false;
35let savedEnvironmentRegistryHydrationPromise: Promise<void> | null = null;37let savedEnvironmentRegistryHydrationPromise: Promise<void> | null = null;
3638
37function toPersistedSavedEnvironmentRecord(39export function toPersistedSavedEnvironmentRecord(
38 record: SavedEnvironmentRecord,40 record: SavedEnvironmentRecord,
39): PersistedSavedEnvironmentRecord {41): PersistedSavedEnvironmentRecord {
40 return {42 return {
...@@ -44,6 +46,7 @@ function toPersistedSavedEnvironmentRecord(...@@ -44,6 +46,7 @@ function toPersistedSavedEnvironmentRecord(
44 wsBaseUrl: record.wsBaseUrl,46 wsBaseUrl: record.wsBaseUrl,
45 createdAt: record.createdAt,47 createdAt: record.createdAt,
46 lastConnectedAt: record.lastConnectedAt,48 lastConnectedAt: record.lastConnectedAt,
49 ...(record.desktopSsh ? { desktopSsh: record.desktopSsh } : {}),
47 };50 };
48}51}
4952
...@@ -148,6 +151,23 @@ export const useSavedEnvironmentRegistryStore = create<SavedEnvironmentRegistryS...@@ -148,6 +151,23 @@ export const useSavedEnvironmentRegistryStore = create<SavedEnvironmentRegistryS
148 persistSavedEnvironmentRegistryState(byId);151 persistSavedEnvironmentRegistryState(byId);
149 return { byId };152 return { byId };
150 }),153 }),
154 rename: (environmentId, label) =>
155 set((state) => {
156 const existing = state.byId[environmentId];
157 const nextLabel = label.trim();
158 if (!existing || nextLabel.length === 0 || existing.label === nextLabel) {
159 return state;
160 }
161 const byId = {
162 ...state.byId,
163 [environmentId]: {
164 ...existing,
165 label: nextLabel,
166 },
167 };
168 persistSavedEnvironmentRegistryState(byId);
169 return { byId };
170 }),
151 reset: () => {171 reset: () => {
152 persistSavedEnvironmentRegistryState({});172 persistSavedEnvironmentRegistryState({});
153 set({173 set({
apps/web/src/environments/runtime/index.ts+1
...@@ -16,6 +16,7 @@ export {...@@ -16,6 +16,7 @@ export {
1616
17export {17export {
18 addSavedEnvironment,18 addSavedEnvironment,
19 connectDesktopSshEnvironment,
19 disconnectSavedEnvironment,20 disconnectSavedEnvironment,
20 ensureEnvironmentConnectionBootstrapped,21 ensureEnvironmentConnectionBootstrapped,
21 getPrimaryEnvironmentConnection,22 getPrimaryEnvironmentConnection,
apps/web/src/environments/runtime/service.addSavedEnvironment.test.ts+819-18
...@@ -1,14 +1,60 @@...@@ -1,14 +1,60 @@
1import { EnvironmentId } from "@t3tools/contracts";1import { EnvironmentId } from "@t3tools/contracts";
2import { beforeEach, describe, expect, it, vi } from "vitest";2import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
3
4let mockSavedRecords: Array<Record<string, unknown>> = [];
35
4const mockResolveRemotePairingTarget = vi.fn();6const mockResolveRemotePairingTarget = vi.fn();
5const mockFetchRemoteEnvironmentDescriptor = vi.fn();7const mockFetchRemoteEnvironmentDescriptor = vi.fn();
6const mockBootstrapRemoteBearerSession = vi.fn();8const mockBootstrapRemoteBearerSession = vi.fn();
9const mockFetchRemoteSessionState = vi.fn();
10const mockIsRemoteEnvironmentAuthHttpError = vi.fn((_: unknown) => false);
11const mockResolveRemoteWebSocketConnectionUrl = vi.fn();
12const mockBootstrapSshBearerSession = vi.fn();
13const mockFetchSshSessionState = vi.fn();
7const mockPersistSavedEnvironmentRecord = vi.fn();14const mockPersistSavedEnvironmentRecord = vi.fn();
8const mockWriteSavedEnvironmentBearerToken = vi.fn();15const mockWriteSavedEnvironmentBearerToken = vi.fn();
9const mockSetSavedEnvironmentRegistry = vi.fn();16const mockSetSavedEnvironmentRegistry = vi.fn();
10const mockUpsert = vi.fn();17const mockGetSavedEnvironmentRecord = vi.fn((environmentId: EnvironmentId) => {
11const mockListSavedEnvironmentRecords = vi.fn();18 return mockSavedRecords.find((record) => record.environmentId === environmentId) ?? null;
19});
20const mockReadSavedEnvironmentBearerToken = vi.fn();
21const mockRemoveSavedEnvironmentBearerToken = vi.fn();
22const mockPatchRuntime = vi.fn();
23const mockClearRuntime = vi.fn();
24const mockRegistrySetState = vi.fn((next: { byId: Record<string, Record<string, unknown>> }) => {
25 mockSavedRecords = Object.values(next.byId);
26});
27const mockRemove = vi.fn((environmentId: EnvironmentId) => {
28 mockSavedRecords = mockSavedRecords.filter((record) => record.environmentId !== environmentId);
29});
30const mockMarkConnected = vi.fn((environmentId: EnvironmentId, connectedAt: string) => {
31 mockSavedRecords = mockSavedRecords.map((record) =>
32 record.environmentId === environmentId ? { ...record, lastConnectedAt: connectedAt } : record,
33 );
34});
35const mockRename = vi.fn((environmentId: EnvironmentId, label: string) => {
36 mockSavedRecords = mockSavedRecords.map((record) =>
37 record.environmentId === environmentId ? { ...record, label } : record,
38 );
39});
40const mockUpsert = vi.fn((record: Record<string, unknown>) => {
41 mockSavedRecords = [
42 ...mockSavedRecords.filter((entry) => entry.environmentId !== record.environmentId),
43 record,
44 ];
45});
46const mockListSavedEnvironmentRecords = vi.fn(() => mockSavedRecords);
47const mockEnsureSshEnvironment = vi.fn();
48const mockDisconnectSshEnvironment = vi.fn();
49const mockFetchSshEnvironmentDescriptor = vi.fn();
50const mockToPersistedSavedEnvironmentRecord = vi.fn((record) => record);
51const mockCreateEnvironmentConnection = vi.fn();
52const mockClientGetConfig = vi.fn(async () => ({
53 environment: {
54 environmentId: EnvironmentId.make("environment-1"),
55 label: "Remote environment",
56 },
57}));
1258
13vi.mock("../remote/target", () => ({59vi.mock("../remote/target", () => ({
14 resolveRemotePairingTarget: mockResolveRemotePairingTarget,60 resolveRemotePairingTarget: mockResolveRemotePairingTarget,
...@@ -17,8 +63,9 @@ vi.mock("../remote/target", () => ({...@@ -17,8 +63,9 @@ vi.mock("../remote/target", () => ({
17vi.mock("../remote/api", () => ({63vi.mock("../remote/api", () => ({
18 bootstrapRemoteBearerSession: mockBootstrapRemoteBearerSession,64 bootstrapRemoteBearerSession: mockBootstrapRemoteBearerSession,
19 fetchRemoteEnvironmentDescriptor: mockFetchRemoteEnvironmentDescriptor,65 fetchRemoteEnvironmentDescriptor: mockFetchRemoteEnvironmentDescriptor,
20 fetchRemoteSessionState: vi.fn(),66 fetchRemoteSessionState: mockFetchRemoteSessionState,
21 resolveRemoteWebSocketConnectionUrl: vi.fn(),67 isRemoteEnvironmentAuthHttpError: mockIsRemoteEnvironmentAuthHttpError,
68 resolveRemoteWebSocketConnectionUrl: mockResolveRemoteWebSocketConnectionUrl,
22}));69}));
2370
24vi.mock("~/localApi", () => ({71vi.mock("~/localApi", () => ({
...@@ -30,24 +77,28 @@ vi.mock("~/localApi", () => ({...@@ -30,24 +77,28 @@ vi.mock("~/localApi", () => ({
30}));77}));
3178
32vi.mock("./catalog", () => ({79vi.mock("./catalog", () => ({
33 getSavedEnvironmentRecord: vi.fn(),80 getSavedEnvironmentRecord: mockGetSavedEnvironmentRecord,
34 hasSavedEnvironmentRegistryHydrated: vi.fn(),81 hasSavedEnvironmentRegistryHydrated: vi.fn(),
35 listSavedEnvironmentRecords: mockListSavedEnvironmentRecords,82 listSavedEnvironmentRecords: mockListSavedEnvironmentRecords,
36 persistSavedEnvironmentRecord: mockPersistSavedEnvironmentRecord,83 persistSavedEnvironmentRecord: mockPersistSavedEnvironmentRecord,
37 readSavedEnvironmentBearerToken: vi.fn(),84 readSavedEnvironmentBearerToken: mockReadSavedEnvironmentBearerToken,
38 removeSavedEnvironmentBearerToken: vi.fn(),85 removeSavedEnvironmentBearerToken: mockRemoveSavedEnvironmentBearerToken,
86 toPersistedSavedEnvironmentRecord: mockToPersistedSavedEnvironmentRecord,
39 useSavedEnvironmentRegistryStore: {87 useSavedEnvironmentRegistryStore: {
40 getState: () => ({88 getState: () => ({
41 upsert: mockUpsert,89 upsert: mockUpsert,
42 remove: vi.fn(),90 remove: mockRemove,
43 markConnected: vi.fn(),91 markConnected: mockMarkConnected,
92 rename: mockRename,
44 }),93 }),
94 setState: mockRegistrySetState,
95 subscribe: vi.fn(() => () => {}),
45 },96 },
46 useSavedEnvironmentRuntimeStore: {97 useSavedEnvironmentRuntimeStore: {
47 getState: () => ({98 getState: () => ({
48 ensure: vi.fn(),99 ensure: vi.fn(),
49 patch: vi.fn(),100 patch: mockPatchRuntime,
50 clear: vi.fn(),101 clear: mockClearRuntime,
51 }),102 }),
52 },103 },
53 waitForSavedEnvironmentRegistryHydration: vi.fn(),104 waitForSavedEnvironmentRegistryHydration: vi.fn(),
...@@ -55,18 +106,58 @@ vi.mock("./catalog", () => ({...@@ -55,18 +106,58 @@ vi.mock("./catalog", () => ({
55}));106}));
56107
57vi.mock("./connection", () => ({108vi.mock("./connection", () => ({
58 createEnvironmentConnection: vi.fn(),109 createEnvironmentConnection: mockCreateEnvironmentConnection,
110}));
111
112vi.mock("../../rpc/wsRpcClient", () => ({
113 createWsRpcClient: vi.fn(() => ({
114 server: {
115 getConfig: mockClientGetConfig,
116 },
117 orchestration: {
118 subscribeThread: vi.fn(() => () => {}),
119 },
120 })),
121}));
122
123vi.mock("../../rpc/wsTransport", () => ({
124 WsTransport: vi.fn(),
59}));125}));
60126
61describe("addSavedEnvironment", () => {127describe("addSavedEnvironment", () => {
128 afterEach(() => {
129 vi.useRealTimers();
130 });
131
62 beforeEach(() => {132 beforeEach(() => {
63 vi.resetModules();133 vi.resetModules();
64 vi.clearAllMocks();134 vi.clearAllMocks();
65 mockResolveRemotePairingTarget.mockReturnValue({135 mockSavedRecords = [];
66 httpBaseUrl: "https://remote.example.com/",136 vi.stubGlobal("window", {
67 wsBaseUrl: "wss://remote.example.com/",137 desktopBridge: {
68 credential: "pairing-code",138 ensureSshEnvironment: mockEnsureSshEnvironment,
139 disconnectSshEnvironment: mockDisconnectSshEnvironment,
140 fetchSshEnvironmentDescriptor: mockFetchSshEnvironmentDescriptor,
141 bootstrapSshBearerSession: mockBootstrapSshBearerSession,
142 fetchSshSessionState: mockFetchSshSessionState,
143 issueSshWebSocketToken: vi.fn(),
144 },
69 });145 });
146 mockResolveRemotePairingTarget.mockImplementation(
147 (input: { host?: string; pairingCode?: string }) => ({
148 httpBaseUrl: input.host
149 ? input.host.endsWith("/")
150 ? input.host
151 : `${input.host}/`
152 : "https://remote.example.com/",
153 wsBaseUrl: input.host
154 ? input.host.replace(/^http/u, "ws").endsWith("/")
155 ? input.host.replace(/^http/u, "ws")
156 : `${input.host.replace(/^http/u, "ws")}/`
157 : "wss://remote.example.com/",
158 credential: input.pairingCode ?? "pairing-code",
159 }),
160 );
70 mockFetchRemoteEnvironmentDescriptor.mockResolvedValue({161 mockFetchRemoteEnvironmentDescriptor.mockResolvedValue({
71 environmentId: EnvironmentId.make("environment-1"),162 environmentId: EnvironmentId.make("environment-1"),
72 label: "Remote environment",163 label: "Remote environment",
...@@ -75,10 +166,60 @@ describe("addSavedEnvironment", () => {...@@ -75,10 +166,60 @@ describe("addSavedEnvironment", () => {
75 sessionToken: "bearer-token",166 sessionToken: "bearer-token",
76 role: "owner",167 role: "owner",
77 });168 });
169 mockFetchRemoteSessionState.mockResolvedValue({
170 authenticated: true,
171 role: "owner",
172 });
173 mockIsRemoteEnvironmentAuthHttpError.mockReturnValue(false);
174 mockResolveRemoteWebSocketConnectionUrl.mockResolvedValue(
175 "wss://remote.example.com/?wsToken=remote-token",
176 );
177 mockFetchSshEnvironmentDescriptor.mockResolvedValue({
178 environmentId: EnvironmentId.make("environment-1"),
179 label: "Remote environment",
180 });
181 mockBootstrapSshBearerSession.mockResolvedValue({
182 sessionToken: "ssh-bearer-token",
183 role: "owner",
184 });
78 mockPersistSavedEnvironmentRecord.mockResolvedValue(undefined);185 mockPersistSavedEnvironmentRecord.mockResolvedValue(undefined);
79 mockWriteSavedEnvironmentBearerToken.mockResolvedValue(false);186 mockWriteSavedEnvironmentBearerToken.mockResolvedValue(false);
80 mockSetSavedEnvironmentRegistry.mockResolvedValue(undefined);187 mockSetSavedEnvironmentRegistry.mockResolvedValue(undefined);
81 mockListSavedEnvironmentRecords.mockReturnValue([]);188 mockReadSavedEnvironmentBearerToken.mockResolvedValue(null);
189 mockRemoveSavedEnvironmentBearerToken.mockResolvedValue(undefined);
190 mockFetchSshSessionState.mockResolvedValue({
191 authenticated: true,
192 role: "owner",
193 });
194 mockCreateEnvironmentConnection.mockImplementation(
195 (input: { knownEnvironment: { environmentId: EnvironmentId }; client: unknown }) => ({
196 kind: "saved",
197 environmentId: input.knownEnvironment.environmentId,
198 knownEnvironment: input.knownEnvironment,
199 client: input.client,
200 ensureBootstrapped: async () => undefined,
201 reconnect: async () => undefined,
202 dispose: async () => undefined,
203 }),
204 );
205 mockClientGetConfig.mockResolvedValue({
206 environment: {
207 environmentId: EnvironmentId.make("environment-1"),
208 label: "Remote environment",
209 },
210 });
211 mockEnsureSshEnvironment.mockResolvedValue({
212 target: {
213 alias: "devbox",
214 hostname: "devbox.example.com",
215 username: "julius",
216 port: 22,
217 },
218 httpBaseUrl: "http://127.0.0.1:3774/",
219 wsBaseUrl: "ws://127.0.0.1:3774/",
220 pairingToken: "ssh-pairing-code",
221 });
222 mockDisconnectSshEnvironment.mockResolvedValue(undefined);
82 });223 });
83224
84 it("rolls back persisted metadata when bearer token persistence fails", async () => {225 it("rolls back persisted metadata when bearer token persistence fails", async () => {
...@@ -102,4 +243,664 @@ describe("addSavedEnvironment", () => {...@@ -102,4 +243,664 @@ describe("addSavedEnvironment", () => {
102243
103 await resetEnvironmentServiceForTests();244 await resetEnvironmentServiceForTests();
104 });245 });
246
247 it("restores unrelated saved environments when credential persistence rollback runs", async () => {
248 mockSavedRecords = [
249 {
250 environmentId: EnvironmentId.make("environment-existing"),
251 label: "Existing environment",
252 httpBaseUrl: "https://existing.example.com/",
253 wsBaseUrl: "wss://existing.example.com/",
254 createdAt: "2026-04-14T00:00:00.000Z",
255 lastConnectedAt: null,
256 },
257 ];
258
259 const { addSavedEnvironment, resetEnvironmentServiceForTests } = await import("./service");
260
261 await expect(
262 addSavedEnvironment({
263 label: "Remote environment",
264 host: "remote.example.com",
265 pairingCode: "123456",
266 }),
267 ).rejects.toThrow("Unable to persist saved environment credentials.");
268
269 expect(mockSetSavedEnvironmentRegistry).toHaveBeenCalledWith([
270 expect.objectContaining({
271 environmentId: EnvironmentId.make("environment-existing"),
272 }),
273 ]);
274
275 await resetEnvironmentServiceForTests();
276 });
277
278 it("persists the server label after saved environment metadata refresh", async () => {
279 mockWriteSavedEnvironmentBearerToken.mockResolvedValue(true);
280 mockClientGetConfig.mockResolvedValue({
281 environment: {
282 environmentId: EnvironmentId.make("environment-1"),
283 label: "Julius's Mac mini",
284 },
285 });
286
287 const { addSavedEnvironment, resetEnvironmentServiceForTests } = await import("./service");
288
289 await expect(
290 addSavedEnvironment({
291 label: "100.65.180.100",
292 host: "remote.example.com",
293 pairingCode: "123456",
294 }),
295 ).resolves.toMatchObject({
296 environmentId: EnvironmentId.make("environment-1"),
297 });
298
299 expect(mockRename).toHaveBeenCalledWith(
300 EnvironmentId.make("environment-1"),
301 "Julius's Mac mini",
302 );
303 expect(mockSavedRecords).toEqual([
304 expect.objectContaining({
305 environmentId: EnvironmentId.make("environment-1"),
306 label: "Julius's Mac mini",
307 }),
308 ]);
309
310 await resetEnvironmentServiceForTests();
311 });
312
313 it("removes an older ssh record when the same target returns a new environment id", async () => {
314 mockWriteSavedEnvironmentBearerToken.mockResolvedValue(true);
315 mockFetchSshEnvironmentDescriptor.mockResolvedValue({
316 environmentId: EnvironmentId.make("environment-2"),
317 label: "Remote environment",
318 });
319 mockSavedRecords = [
320 {
321 environmentId: EnvironmentId.make("environment-1"),
322 label: "Old ssh environment",
323 httpBaseUrl: "http://127.0.0.1:3774/",
324 wsBaseUrl: "ws://127.0.0.1:3774/",
325 createdAt: "2026-04-14T00:00:00.000Z",
326 lastConnectedAt: null,
327 desktopSsh: {
328 alias: "devbox",
329 hostname: "devbox.example.com",
330 username: "julius",
331 port: 22,
332 },
333 },
334 ];
335
336 const { addSavedEnvironment, resetEnvironmentServiceForTests } = await import("./service");
337
338 await expect(
339 addSavedEnvironment({
340 label: "Remote environment",
341 host: "http://127.0.0.1:3774/",
342 pairingCode: "ssh-pairing-code",
343 desktopSsh: {
344 alias: "devbox",
345 hostname: "devbox.example.com",
346 username: "julius",
347 port: 22,
348 },
349 }),
350 ).resolves.toMatchObject({
351 environmentId: EnvironmentId.make("environment-2"),
352 });
353
354 expect(mockUpsert).toHaveBeenCalledWith(
355 expect.objectContaining({
356 environmentId: EnvironmentId.make("environment-2"),
357 }),
358 );
359 expect(mockRemove).toHaveBeenCalledWith(EnvironmentId.make("environment-1"));
360 expect(mockRemoveSavedEnvironmentBearerToken).toHaveBeenCalledWith(
361 EnvironmentId.make("environment-1"),
362 );
363
364 await resetEnvironmentServiceForTests();
365 });
366
367 it("retries desktop ssh session refresh when the forwarded endpoint returns ssh_http 401", async () => {
368 mockWriteSavedEnvironmentBearerToken.mockResolvedValue(true);
369 mockBootstrapSshBearerSession
370 .mockResolvedValueOnce({
371 sessionToken: "ssh-bearer-token",
372 role: "owner",
373 })
374 .mockResolvedValueOnce({
375 sessionToken: "ssh-bearer-token-2",
376 role: "owner",
377 });
378 mockFetchSshSessionState
379 .mockRejectedValueOnce(new Error("[ssh_http:401] Unauthorized"))
380 .mockResolvedValueOnce({
381 authenticated: true,
382 role: "owner",
383 });
384
385 const { connectDesktopSshEnvironment, resetEnvironmentServiceForTests } =
386 await import("./service");
387
388 await expect(
389 connectDesktopSshEnvironment({
390 alias: "devbox",
391 hostname: "devbox",
392 username: null,
393 port: null,
394 }),
395 ).resolves.toMatchObject({
396 environmentId: EnvironmentId.make("environment-1"),
397 });
398
399 expect(mockEnsureSshEnvironment).toHaveBeenCalled();
400 expect(mockBootstrapSshBearerSession).toHaveBeenCalledTimes(2);
401 expect(mockFetchSshSessionState).toHaveBeenCalledTimes(2);
402
403 await resetEnvironmentServiceForTests();
404 });
405
406 it("does not attempt desktop ssh bearer recovery for non-ssh saved environments", async () => {
407 mockWriteSavedEnvironmentBearerToken.mockResolvedValue(true);
408 const authError = {
409 status: 401,
410 message: "Unauthorized",
411 };
412 mockFetchRemoteSessionState.mockRejectedValueOnce(authError);
413 mockIsRemoteEnvironmentAuthHttpError.mockImplementation(
414 (error: unknown) => error === authError,
415 );
416
417 const { addSavedEnvironment, resetEnvironmentServiceForTests } = await import("./service");
418
419 await expect(
420 addSavedEnvironment({
421 label: "Remote environment",
422 host: "remote.example.com",
423 pairingCode: "123456",
424 }),
425 ).rejects.toThrow("Saved environment credential expired. Pair it again.");
426
427 expect(mockEnsureSshEnvironment).not.toHaveBeenCalled();
428 expect(mockBootstrapSshBearerSession).not.toHaveBeenCalled();
429 expect(mockRemoveSavedEnvironmentBearerToken).toHaveBeenCalledWith(
430 EnvironmentId.make("environment-1"),
431 );
432
433 await resetEnvironmentServiceForTests();
434 });
435
436 it("only registers the retried ssh connection after bearer re-issuance succeeds", async () => {
437 mockWriteSavedEnvironmentBearerToken.mockResolvedValue(true);
438 mockBootstrapSshBearerSession
439 .mockResolvedValueOnce({
440 sessionToken: "ssh-bearer-token",
441 role: "owner",
442 })
443 .mockResolvedValueOnce({
444 sessionToken: "ssh-bearer-token-2",
445 role: "owner",
446 });
447 mockFetchSshSessionState
448 .mockRejectedValueOnce(new Error("[ssh_http:401] Unauthorized"))
449 .mockResolvedValueOnce({
450 authenticated: true,
451 role: "owner",
452 });
453
454 const createdConnections: Array<{
455 readonly environmentId: EnvironmentId;
456 readonly dispose: ReturnType<typeof vi.fn>;
457 }> = [];
458 mockCreateEnvironmentConnection.mockImplementation(
459 (input: { knownEnvironment: { environmentId: EnvironmentId }; client: unknown }) => {
460 const connection = {
461 kind: "saved" as const,
462 environmentId: input.knownEnvironment.environmentId,
463 knownEnvironment: input.knownEnvironment,
464 client: input.client,
465 ensureBootstrapped: async () => undefined,
466 reconnect: async () => undefined,
467 dispose: vi.fn(async () => undefined),
468 };
469 createdConnections.push(connection);
470 return connection;
471 },
472 );
473
474 const {
475 connectDesktopSshEnvironment,
476 listEnvironmentConnections,
477 resetEnvironmentServiceForTests,
478 } = await import("./service");
479
480 await connectDesktopSshEnvironment({
481 alias: "devbox",
482 hostname: "devbox",
483 username: null,
484 port: null,
485 });
486
487 expect(createdConnections).toHaveLength(2);
488 expect(createdConnections[0]?.dispose).toHaveBeenCalledTimes(1);
489 expect(listEnvironmentConnections()).toHaveLength(1);
490 expect(listEnvironmentConnections()[0]).toBe(createdConnections[1]);
491
492 await resetEnvironmentServiceForTests();
493 });
494
495 it("marks desktop ssh reconnect failures as runtime errors when bearer recovery fails", async () => {
496 mockWriteSavedEnvironmentBearerToken.mockResolvedValue(true);
497
498 const connection = {
499 kind: "saved" as const,
500 environmentId: EnvironmentId.make("environment-1"),
501 knownEnvironment: {
502 environmentId: EnvironmentId.make("environment-1"),
503 },
504 client: {},
505 ensureBootstrapped: async () => undefined,
506 reconnect: vi.fn(async () => {
507 throw new Error("socket closed");
508 }),
509 dispose: async () => undefined,
510 };
511 mockCreateEnvironmentConnection.mockReturnValue(connection);
512
513 const { addSavedEnvironment, reconnectSavedEnvironment, resetEnvironmentServiceForTests } =
514 await import("./service");
515
516 await addSavedEnvironment({
517 label: "Remote environment",
518 host: "http://127.0.0.1:3774/",
519 pairingCode: "ssh-pairing-code",
520 desktopSsh: {
521 alias: "devbox",
522 hostname: "devbox.example.com",
523 username: "julius",
524 port: 22,
525 },
526 });
527
528 mockSavedRecords = [
529 {
530 environmentId: EnvironmentId.make("environment-1"),
531 label: "Remote environment",
532 httpBaseUrl: "http://127.0.0.1:3774/",
533 wsBaseUrl: "ws://127.0.0.1:3774/",
534 createdAt: "2026-04-14T00:00:00.000Z",
535 lastConnectedAt: null,
536 desktopSsh: {
537 alias: "devbox",
538 hostname: "devbox.example.com",
539 username: "julius",
540 port: 22,
541 },
542 },
543 ];
544 mockWriteSavedEnvironmentBearerToken.mockResolvedValue(false);
545
546 await expect(reconnectSavedEnvironment(EnvironmentId.make("environment-1"))).rejects.toThrow(
547 "Unable to persist saved environment credentials.",
548 );
549
550 expect(mockPatchRuntime).toHaveBeenCalledWith(
551 EnvironmentId.make("environment-1"),
552 expect.objectContaining({
553 connectionState: "error",
554 lastError: "Unable to persist saved environment credentials.",
555 }),
556 );
557
558 await resetEnvironmentServiceForTests();
559 });
560
561 it("bootstraps a desktop ssh environment through the desktop bridge", async () => {
562 mockWriteSavedEnvironmentBearerToken.mockResolvedValue(true);
563
564 const { connectDesktopSshEnvironment, resetEnvironmentServiceForTests } =
565 await import("./service");
566
567 await expect(
568 connectDesktopSshEnvironment({
569 alias: "devbox",
570 hostname: "devbox",
571 username: null,
572 port: null,
573 }),
574 ).resolves.toMatchObject({
575 environmentId: EnvironmentId.make("environment-1"),
576 });
577
578 expect(mockEnsureSshEnvironment).toHaveBeenCalledWith(
579 {
580 alias: "devbox",
581 hostname: "devbox",
582 username: null,
583 port: null,
584 },
585 { issuePairingToken: true },
586 );
587 expect(mockResolveRemotePairingTarget).toHaveBeenCalledWith({
588 host: "http://127.0.0.1:3774/",
589 pairingCode: "ssh-pairing-code",
590 });
591 expect(mockFetchSshEnvironmentDescriptor).toHaveBeenCalledWith("http://127.0.0.1:3774/");
592 expect(mockBootstrapSshBearerSession).toHaveBeenCalledWith(
593 "http://127.0.0.1:3774/",
594 "ssh-pairing-code",
595 );
596 expect(mockFetchRemoteEnvironmentDescriptor).not.toHaveBeenCalled();
597 expect(mockBootstrapRemoteBearerSession).not.toHaveBeenCalled();
598 expect(mockUpsert.mock.invocationCallOrder[0]).toBeLessThan(
599 mockCreateEnvironmentConnection.mock.invocationCallOrder[0] ?? Number.POSITIVE_INFINITY,
600 );
601
602 await resetEnvironmentServiceForTests();
603 });
604
605 it("disconnects the desktop ssh process before removing a saved ssh environment", async () => {
606 mockSavedRecords = [
607 {
608 environmentId: EnvironmentId.make("environment-1"),
609 label: "Remote environment",
610 httpBaseUrl: "http://127.0.0.1:3774/",
611 wsBaseUrl: "ws://127.0.0.1:3774/",
612 createdAt: "2026-04-14T00:00:00.000Z",
613 lastConnectedAt: null,
614 desktopSsh: {
615 alias: "devbox",
616 hostname: "devbox.example.com",
617 username: "julius",
618 port: 22,
619 },
620 },
621 ];
622
623 const { removeSavedEnvironment, resetEnvironmentServiceForTests } = await import("./service");
624
625 await removeSavedEnvironment(EnvironmentId.make("environment-1"));
626
627 expect(mockDisconnectSshEnvironment).toHaveBeenCalledWith({
628 alias: "devbox",
629 hostname: "devbox.example.com",
630 username: "julius",
631 port: 22,
632 });
633 expect(mockRemove).toHaveBeenCalledWith(EnvironmentId.make("environment-1"));
634 expect(mockRemoveSavedEnvironmentBearerToken).toHaveBeenCalledWith(
635 EnvironmentId.make("environment-1"),
636 );
637 expect(mockDisconnectSshEnvironment.mock.invocationCallOrder[0]).toBeLessThan(
638 mockRemove.mock.invocationCallOrder[0] ?? Number.POSITIVE_INFINITY,
639 );
640
641 await resetEnvironmentServiceForTests();
642 });
643
644 it("disconnects a saved ssh environment without removing its saved record", async () => {
645 mockSavedRecords = [
646 {
647 environmentId: EnvironmentId.make("environment-1"),
648 label: "Remote environment",
649 httpBaseUrl: "http://127.0.0.1:3774/",
650 wsBaseUrl: "ws://127.0.0.1:3774/",
651 createdAt: "2026-04-14T00:00:00.000Z",
652 lastConnectedAt: null,
653 desktopSsh: {
654 alias: "devbox",
655 hostname: "devbox.example.com",
656 username: "julius",
657 port: 22,
658 },
659 },
660 ];
661
662 const { disconnectSavedEnvironment, resetEnvironmentServiceForTests } =
663 await import("./service");
664
665 await disconnectSavedEnvironment(EnvironmentId.make("environment-1"));
666
667 expect(mockDisconnectSshEnvironment).toHaveBeenCalledWith({
668 alias: "devbox",
669 hostname: "devbox.example.com",
670 username: "julius",
671 port: 22,
672 });
673 expect(mockRemove).not.toHaveBeenCalled();
674 expect(mockRemoveSavedEnvironmentBearerToken).toHaveBeenCalledWith(
675 EnvironmentId.make("environment-1"),
676 );
677
678 await resetEnvironmentServiceForTests();
679 });
680
681 it("keeps remote environment credentials when disconnecting a non-ssh saved environment", async () => {
682 mockSavedRecords = [
683 {
684 environmentId: EnvironmentId.make("environment-1"),
685 label: "Remote environment",
686 httpBaseUrl: "https://remote.example.com/",
687 wsBaseUrl: "wss://remote.example.com/",
688 createdAt: "2026-04-14T00:00:00.000Z",
689 lastConnectedAt: null,
690 },
691 ];
692
693 const { disconnectSavedEnvironment, resetEnvironmentServiceForTests } =
694 await import("./service");
695
696 await disconnectSavedEnvironment(EnvironmentId.make("environment-1"));
697
698 expect(mockDisconnectSshEnvironment).not.toHaveBeenCalled();
699 expect(mockRemove).not.toHaveBeenCalled();
700 expect(mockRemoveSavedEnvironmentBearerToken).not.toHaveBeenCalled();
701
702 await resetEnvironmentServiceForTests();
703 });
704
705 it("cancels a pending saved environment connection when disconnected", async () => {
706 mockSavedRecords = [
707 {
708 environmentId: EnvironmentId.make("environment-1"),
709 label: "Remote environment",
710 httpBaseUrl: "https://remote.example.com/",
711 wsBaseUrl: "wss://remote.example.com/",
712 createdAt: "2026-04-14T00:00:00.000Z",
713 lastConnectedAt: null,
714 },
715 ];
716 mockReadSavedEnvironmentBearerToken.mockResolvedValue("bearer-token");
717 const dispose = vi.fn(async () => undefined);
718 mockCreateEnvironmentConnection.mockImplementation(
719 (input: { knownEnvironment: { environmentId: EnvironmentId }; client: unknown }) => ({
720 kind: "saved" as const,
721 environmentId: input.knownEnvironment.environmentId,
722 knownEnvironment: input.knownEnvironment,
723 client: input.client,
724 ensureBootstrapped: async () => undefined,
725 reconnect: async () => undefined,
726 dispose,
727 }),
728 );
729 let resolveSessionState!: (value: {
730 readonly authenticated: true;
731 readonly role: "owner";
732 }) => void;
733 mockFetchRemoteSessionState.mockReturnValue(
734 new Promise((resolve) => {
735 resolveSessionState = resolve;
736 }),
737 );
738
739 const {
740 disconnectSavedEnvironment,
741 listEnvironmentConnections,
742 reconnectSavedEnvironment,
743 resetEnvironmentServiceForTests,
744 } = await import("./service");
745
746 const reconnectPromise = reconnectSavedEnvironment(EnvironmentId.make("environment-1"));
747 await vi.waitFor(() => {
748 expect(mockFetchRemoteSessionState).toHaveBeenCalledOnce();
749 });
750
751 await disconnectSavedEnvironment(EnvironmentId.make("environment-1"));
752 resolveSessionState({
753 authenticated: true,
754 role: "owner",
755 });
756 await expect(reconnectPromise).resolves.toBeUndefined();
757
758 expect(listEnvironmentConnections()).toHaveLength(0);
759 expect(dispose).toHaveBeenCalledOnce();
760 expect(mockPatchRuntime).not.toHaveBeenCalledWith(
761 EnvironmentId.make("environment-1"),
762 expect.objectContaining({
763 connectionState: "error",
764 }),
765 );
766
767 await resetEnvironmentServiceForTests();
768 });
769
770 it("reissues ssh pairing credentials when connecting after a manual ssh disconnect", async () => {
771 mockSavedRecords = [
772 {
773 environmentId: EnvironmentId.make("environment-1"),
774 label: "Remote environment",
775 httpBaseUrl: "http://127.0.0.1:3774/",
776 wsBaseUrl: "ws://127.0.0.1:3774/",
777 createdAt: "2026-04-14T00:00:00.000Z",
778 lastConnectedAt: null,
779 desktopSsh: {
780 alias: "devbox",
781 hostname: "devbox.example.com",
782 username: "julius",
783 port: 22,
784 },
785 },
786 ];
787 mockReadSavedEnvironmentBearerToken.mockResolvedValue(null);
788 mockWriteSavedEnvironmentBearerToken.mockResolvedValue(true);
789
790 const { reconnectSavedEnvironment, resetEnvironmentServiceForTests } =
791 await import("./service");
792
793 await reconnectSavedEnvironment(EnvironmentId.make("environment-1"));
794
795 expect(mockEnsureSshEnvironment).toHaveBeenCalledWith(
796 {
797 alias: "devbox",
798 hostname: "devbox.example.com",
799 username: "julius",
800 port: 22,
801 },
802 { issuePairingToken: true },
803 );
804 expect(mockBootstrapSshBearerSession).toHaveBeenCalledWith(
805 "http://127.0.0.1:3774/",
806 "ssh-pairing-code",
807 );
808 expect(mockWriteSavedEnvironmentBearerToken).toHaveBeenCalledWith(
809 EnvironmentId.make("environment-1"),
810 "ssh-bearer-token",
811 );
812
813 await resetEnvironmentServiceForTests();
814 });
815
816 it("rolls back ssh registry metadata when pairing token issuance fails", async () => {
817 const originalRecord = {
818 environmentId: EnvironmentId.make("environment-1"),
819 label: "Remote environment",
820 httpBaseUrl: "http://127.0.0.1:3773/",
821 wsBaseUrl: "ws://127.0.0.1:3773/",
822 createdAt: "2026-04-14T00:00:00.000Z",
823 lastConnectedAt: null,
824 desktopSsh: {
825 alias: "devbox",
826 hostname: "devbox.example.com",
827 username: "julius",
828 port: 22,
829 },
830 };
831 mockSavedRecords = [originalRecord];
832 mockReadSavedEnvironmentBearerToken.mockResolvedValue(null);
833 mockEnsureSshEnvironment.mockResolvedValue({
834 target: {
835 alias: "devbox",
836 hostname: "devbox.example.com",
837 username: "julius",
838 port: 22,
839 },
840 httpBaseUrl: "http://127.0.0.1:3774/",
841 wsBaseUrl: "ws://127.0.0.1:3774/",
842 pairingToken: null,
843 });
844
845 const { reconnectSavedEnvironment, resetEnvironmentServiceForTests } =
846 await import("./service");
847
848 await expect(reconnectSavedEnvironment(EnvironmentId.make("environment-1"))).rejects.toThrow(
849 "Desktop SSH launch did not return a pairing token.",
850 );
851
852 expect(mockPersistSavedEnvironmentRecord).toHaveBeenCalledWith(
853 expect.objectContaining({
854 httpBaseUrl: "http://127.0.0.1:3774/",
855 }),
856 );
857 expect(mockSetSavedEnvironmentRegistry).toHaveBeenCalledWith([originalRecord]);
858 expect(mockSavedRecords).toEqual([originalRecord]);
859 expect(mockBootstrapSshBearerSession).not.toHaveBeenCalled();
860
861 await resetEnvironmentServiceForTests();
862 });
863
864 it("surfaces desktop ssh bootstrap failures during saved ssh reconnect", async () => {
865 mockSavedRecords = [
866 {
867 environmentId: EnvironmentId.make("environment-1"),
868 label: "Remote environment",
869 httpBaseUrl: "http://127.0.0.1:3774/",
870 wsBaseUrl: "ws://127.0.0.1:3774/",
871 createdAt: "2026-04-14T00:00:00.000Z",
872 lastConnectedAt: null,
873 desktopSsh: {
874 alias: "devbox",
875 hostname: "devbox.example.com",
876 username: "julius",
877 port: 22,
878 },
879 },
880 ];
881 mockReadSavedEnvironmentBearerToken.mockResolvedValue(null);
882 mockEnsureSshEnvironment.mockRejectedValue(new Error("SSH command timed out after 60000ms."));
883
884 const { reconnectSavedEnvironment, resetEnvironmentServiceForTests } =
885 await import("./service");
886
887 await expect(reconnectSavedEnvironment(EnvironmentId.make("environment-1"))).rejects.toThrow(
888 "SSH command timed out after 60000ms.",
889 );
890 expect(mockPatchRuntime).toHaveBeenCalledWith(
891 EnvironmentId.make("environment-1"),
892 expect.objectContaining({
893 connectionState: "connecting",
894 }),
895 );
896 expect(mockPatchRuntime).toHaveBeenCalledWith(
897 EnvironmentId.make("environment-1"),
898 expect.objectContaining({
899 connectionState: "error",
900 lastError: "SSH command timed out after 60000ms.",
901 }),
902 );
903
904 await resetEnvironmentServiceForTests();
905 });
105});906});
apps/web/src/environments/runtime/service.threadSubscriptions.test.ts+183-22
...@@ -15,31 +15,36 @@ const mockCreateEnvironmentConnection = vi.fn();...@@ -15,31 +15,36 @@ const mockCreateEnvironmentConnection = vi.fn();
15const mockCreateWsRpcClient = vi.fn();15const mockCreateWsRpcClient = vi.fn();
16const mockWaitForSavedEnvironmentRegistryHydration = vi.fn();16const mockWaitForSavedEnvironmentRegistryHydration = vi.fn();
17const mockListSavedEnvironmentRecords = vi.fn();17const mockListSavedEnvironmentRecords = vi.fn();
18const mockGetSavedEnvironmentRecord = vi.fn();
19const mockReadSavedEnvironmentBearerToken = vi.fn();
18const mockSavedEnvironmentRegistrySubscribe = vi.fn();20const mockSavedEnvironmentRegistrySubscribe = vi.fn();
21const mockGetPrimaryKnownEnvironment = vi.hoisted(() => vi.fn());
22const mockFetchRemoteSessionState = vi.fn();
23const mockConnectionReconnects: Array<ReturnType<typeof vi.fn>> = [];
24let savedEnvironmentRegistryListener: (() => void) | null = null;
1925
20function MockWsTransport() {26function MockWsTransport() {
21 return undefined;27 return undefined;
22}28}
2329
24vi.mock("../primary", () => ({30vi.mock("../primary", () => ({
25 getPrimaryKnownEnvironment: vi.fn(() => ({31 getPrimaryKnownEnvironment: mockGetPrimaryKnownEnvironment,
26 id: "env-1",32}));
27 label: "Primary environment",33
28 source: "window-origin",34vi.mock("../remote/api", () => ({
29 target: {35 bootstrapRemoteBearerSession: vi.fn(),
30 httpBaseUrl: "http://127.0.0.1:3000/",36 fetchRemoteEnvironmentDescriptor: vi.fn(),
31 wsBaseUrl: "ws://127.0.0.1:3000/",37 fetchRemoteSessionState: mockFetchRemoteSessionState,
32 },38 isRemoteEnvironmentAuthHttpError: vi.fn(() => false),
33 environmentId: EnvironmentId.make("env-1"),39 resolveRemoteWebSocketConnectionUrl: vi.fn(async () => "ws://remote.example.test/ws"),
34 })),
35}));40}));
3641
37vi.mock("./catalog", () => ({42vi.mock("./catalog", () => ({
38 getSavedEnvironmentRecord: vi.fn(),43 getSavedEnvironmentRecord: mockGetSavedEnvironmentRecord,
39 hasSavedEnvironmentRegistryHydrated: vi.fn(() => true),44 hasSavedEnvironmentRegistryHydrated: vi.fn(() => true),
40 listSavedEnvironmentRecords: mockListSavedEnvironmentRecords,45 listSavedEnvironmentRecords: mockListSavedEnvironmentRecords,
41 persistSavedEnvironmentRecord: vi.fn(),46 persistSavedEnvironmentRecord: vi.fn(),
42 readSavedEnvironmentBearerToken: vi.fn(),47 readSavedEnvironmentBearerToken: mockReadSavedEnvironmentBearerToken,
43 removeSavedEnvironmentBearerToken: vi.fn(),48 removeSavedEnvironmentBearerToken: vi.fn(),
44 useSavedEnvironmentRegistryStore: {49 useSavedEnvironmentRegistryStore: {
45 subscribe: mockSavedEnvironmentRegistrySubscribe,50 subscribe: mockSavedEnvironmentRegistrySubscribe,
...@@ -47,6 +52,7 @@ vi.mock("./catalog", () => ({...@@ -47,6 +52,7 @@ vi.mock("./catalog", () => ({
47 upsert: vi.fn(),52 upsert: vi.fn(),
48 remove: vi.fn(),53 remove: vi.fn(),
49 markConnected: vi.fn(),54 markConnected: vi.fn(),
55 rename: vi.fn(),
50 }),56 }),
51 },57 },
52 useSavedEnvironmentRuntimeStore: {58 useSavedEnvironmentRuntimeStore: {
...@@ -145,31 +151,72 @@ describe("retainThreadDetailSubscription", () => {...@@ -145,31 +151,72 @@ describe("retainThreadDetailSubscription", () => {
145 vi.useFakeTimers();151 vi.useFakeTimers();
146 vi.resetModules();152 vi.resetModules();
147 vi.clearAllMocks();153 vi.clearAllMocks();
154 mockGetPrimaryKnownEnvironment.mockReturnValue({
155 id: "env-1",
156 label: "Primary environment",
157 source: "window-origin",
158 target: {
159 httpBaseUrl: "http://127.0.0.1:3000/",
160 wsBaseUrl: "ws://127.0.0.1:3000/",
161 },
162 environmentId: EnvironmentId.make("env-1"),
163 });
148164
149 mockThreadUnsubscribe.mockImplementation(() => undefined);165 mockThreadUnsubscribe.mockImplementation(() => undefined);
150 mockSubscribeThread.mockImplementation(() => mockThreadUnsubscribe);166 mockSubscribeThread.mockImplementation(() => mockThreadUnsubscribe);
151 mockCreateWsRpcClient.mockReturnValue({167 mockCreateWsRpcClient.mockReturnValue({
168 server: {
169 getConfig: vi.fn(async () => ({
170 environment: {
171 environmentId: EnvironmentId.make("env-remote"),
172 label: "Remote env",
173 platform: { os: "darwin", arch: "arm64" },
174 serverVersion: "0.0.0-test",
175 capabilities: { repositoryIdentity: true },
176 },
177 })),
178 },
152 orchestration: {179 orchestration: {
153 subscribeThread: mockSubscribeThread,180 subscribeThread: mockSubscribeThread,
154 },181 },
155 });182 });
156 mockCreateEnvironmentConnection.mockImplementation((input) => ({183 mockCreateEnvironmentConnection.mockImplementation((input) => {
157 kind: input.kind,184 const reconnect = vi.fn(async () => undefined);
158 environmentId: input.knownEnvironment.environmentId,185 mockConnectionReconnects.push(reconnect);
159 knownEnvironment: input.knownEnvironment,186 return {
160 client: input.client,187 kind: input.kind,
161 ensureBootstrapped: vi.fn(async () => undefined),188 environmentId: input.knownEnvironment.environmentId,
162 reconnect: vi.fn(async () => undefined),189 knownEnvironment: input.knownEnvironment,
163 dispose: vi.fn(async () => undefined),190 client: input.client,
164 }));191 ensureBootstrapped: vi.fn(async () => undefined),
165 mockSavedEnvironmentRegistrySubscribe.mockReturnValue(() => undefined);192 reconnect,
193 dispose: vi.fn(async () => undefined),
194 };
195 });
196 savedEnvironmentRegistryListener = null;
197 mockSavedEnvironmentRegistrySubscribe.mockImplementation((listener: () => void) => {
198 savedEnvironmentRegistryListener = listener;
199 return () => {
200 if (savedEnvironmentRegistryListener === listener) {
201 savedEnvironmentRegistryListener = null;
202 }
203 };
204 });
166 mockWaitForSavedEnvironmentRegistryHydration.mockResolvedValue(undefined);205 mockWaitForSavedEnvironmentRegistryHydration.mockResolvedValue(undefined);
167 mockListSavedEnvironmentRecords.mockReturnValue([]);206 mockListSavedEnvironmentRecords.mockReturnValue([]);
207 mockGetSavedEnvironmentRecord.mockReturnValue(null);
208 mockReadSavedEnvironmentBearerToken.mockResolvedValue(null);
209 mockFetchRemoteSessionState.mockResolvedValue({
210 authenticated: true,
211 role: "client",
212 });
213 mockConnectionReconnects.length = 0;
168 });214 });
169215
170 afterEach(async () => {216 afterEach(async () => {
171 const { resetEnvironmentServiceForTests } = await import("./service");217 const { resetEnvironmentServiceForTests } = await import("./service");
172 await resetEnvironmentServiceForTests();218 await resetEnvironmentServiceForTests();
219 vi.unstubAllGlobals();
173 vi.useRealTimers();220 vi.useRealTimers();
174 });221 });
175222
...@@ -204,6 +251,31 @@ describe("retainThreadDetailSubscription", () => {...@@ -204,6 +251,31 @@ describe("retainThreadDetailSubscription", () => {
204 await resetEnvironmentServiceForTests();251 await resetEnvironmentServiceForTests();
205 });252 });
206253
254 it("does not start the primary connection until the known environment has an id", async () => {
255 mockGetPrimaryKnownEnvironment.mockReturnValue({
256 id: "env-1",
257 label: "Primary environment",
258 source: "window-origin",
259 target: {
260 httpBaseUrl: "http://127.0.0.1:3000/",
261 wsBaseUrl: "ws://127.0.0.1:3000/",
262 },
263 });
264 const {
265 listEnvironmentConnections,
266 resetEnvironmentServiceForTests,
267 startEnvironmentConnectionService,
268 } = await import("./service");
269
270 const stop = startEnvironmentConnectionService(new QueryClient());
271
272 expect(mockCreateEnvironmentConnection).not.toHaveBeenCalled();
273 expect(listEnvironmentConnections()).toEqual([]);
274
275 stop();
276 await resetEnvironmentServiceForTests();
277 });
278
207 it("keeps non-idle thread detail subscriptions attached until the thread becomes idle", async () => {279 it("keeps non-idle thread detail subscriptions attached until the thread becomes idle", async () => {
208 const {280 const {
209 retainThreadDetailSubscription,281 retainThreadDetailSubscription,
...@@ -253,6 +325,95 @@ describe("retainThreadDetailSubscription", () => {...@@ -253,6 +325,95 @@ describe("retainThreadDetailSubscription", () => {
253 await resetEnvironmentServiceForTests();325 await resetEnvironmentServiceForTests();
254 });326 });
255327
328 it("reattaches retained thread detail subscriptions after a saved environment reconnect replaces the client", async () => {
329 const environmentId = EnvironmentId.make("env-remote");
330 const threadId = ThreadId.make("thread-reconnect");
331 const record = {
332 environmentId,
333 label: "Remote env",
334 httpBaseUrl: "http://remote.example.test",
335 wsBaseUrl: "ws://remote.example.test",
336 createdAt: "2026-05-01T00:00:00.000Z",
337 lastConnectedAt: "2026-05-01T00:00:00.000Z",
338 };
339 mockListSavedEnvironmentRecords.mockReturnValue([record]);
340 mockGetSavedEnvironmentRecord.mockReturnValue(record);
341 mockReadSavedEnvironmentBearerToken.mockResolvedValue("bearer-token");
342
343 const {
344 disconnectSavedEnvironment,
345 listEnvironmentConnections,
346 reconnectSavedEnvironment,
347 retainThreadDetailSubscription,
348 startEnvironmentConnectionService,
349 resetEnvironmentServiceForTests,
350 } = await import("./service");
351
352 const stop = startEnvironmentConnectionService(new QueryClient());
353 savedEnvironmentRegistryListener?.();
354 await vi.waitFor(() => {
355 expect(mockCreateEnvironmentConnection).toHaveBeenCalledTimes(2);
356 expect(
357 listEnvironmentConnections().some(
358 (connection) => connection.environmentId === environmentId,
359 ),
360 ).toBe(true);
361 });
362
363 const release = retainThreadDetailSubscription(environmentId, threadId);
364 expect(mockSubscribeThread).toHaveBeenCalledTimes(1);
365
366 await disconnectSavedEnvironment(environmentId);
367 expect(mockThreadUnsubscribe).toHaveBeenCalledTimes(1);
368 expect(
369 listEnvironmentConnections().some((connection) => connection.environmentId === environmentId),
370 ).toBe(false);
371
372 await reconnectSavedEnvironment(environmentId);
373 await vi.waitFor(() => {
374 expect(mockCreateEnvironmentConnection).toHaveBeenCalledTimes(3);
375 expect(mockSubscribeThread).toHaveBeenCalledTimes(2);
376 });
377
378 release();
379 stop();
380 await resetEnvironmentServiceForTests();
381 });
382
383 it("reconnects environment streams when the browser resumes from the background", async () => {
384 let visibilityState: DocumentVisibilityState = "visible";
385 const documentTarget = new EventTarget();
386 const windowTarget = new EventTarget();
387 vi.stubGlobal("document", {
388 addEventListener: documentTarget.addEventListener.bind(documentTarget),
389 removeEventListener: documentTarget.removeEventListener.bind(documentTarget),
390 get visibilityState() {
391 return visibilityState;
392 },
393 });
394 vi.stubGlobal("window", {
395 addEventListener: windowTarget.addEventListener.bind(windowTarget),
396 removeEventListener: windowTarget.removeEventListener.bind(windowTarget),
397 });
398
399 const { resetEnvironmentServiceForTests, startEnvironmentConnectionService } =
400 await import("./service");
401
402 const stop = startEnvironmentConnectionService(new QueryClient());
403 expect(mockConnectionReconnects).toHaveLength(1);
404
405 visibilityState = "hidden";
406 documentTarget.dispatchEvent(new Event("visibilitychange"));
407 expect(mockConnectionReconnects[0]).not.toHaveBeenCalled();
408
409 visibilityState = "visible";
410 documentTarget.dispatchEvent(new Event("visibilitychange"));
411 expect(mockConnectionReconnects[0]).toHaveBeenCalledTimes(1);
412
413 stop();
414 await resetEnvironmentServiceForTests();
415 });
416
256 it("allows a larger idle cache before capacity eviction starts", async () => {417 it("allows a larger idle cache before capacity eviction starts", async () => {
257 const {418 const {
258 retainThreadDetailSubscription,419 retainThreadDetailSubscription,
apps/web/src/environments/runtime/service.ts+647-111
...@@ -1,5 +1,7 @@...@@ -1,5 +1,7 @@
1import {1import {
2 type AuthSessionRole,2 type AuthSessionRole,
3 type DesktopSshEnvironmentBootstrap,
4 type DesktopSshEnvironmentTarget,
3 type EnvironmentId,5 type EnvironmentId,
4 type OrchestrationEvent,6 type OrchestrationEvent,
5 type OrchestrationShellSnapshot,7 type OrchestrationShellSnapshot,
...@@ -33,6 +35,7 @@ import {...@@ -33,6 +35,7 @@ import {
33 bootstrapRemoteBearerSession,35 bootstrapRemoteBearerSession,
34 fetchRemoteEnvironmentDescriptor,36 fetchRemoteEnvironmentDescriptor,
35 fetchRemoteSessionState,37 fetchRemoteSessionState,
38 isRemoteEnvironmentAuthHttpError,
36 resolveRemoteWebSocketConnectionUrl,39 resolveRemoteWebSocketConnectionUrl,
37} from "../remote/api";40} from "../remote/api";
38import { resolveRemotePairingTarget } from "../remote/target";41import { resolveRemotePairingTarget } from "../remote/target";
...@@ -44,6 +47,7 @@ import {...@@ -44,6 +47,7 @@ import {
44 readSavedEnvironmentBearerToken,47 readSavedEnvironmentBearerToken,
45 removeSavedEnvironmentBearerToken,48 removeSavedEnvironmentBearerToken,
46 type SavedEnvironmentRecord,49 type SavedEnvironmentRecord,
50 toPersistedSavedEnvironmentRecord,
47 useSavedEnvironmentRegistryStore,51 useSavedEnvironmentRegistryStore,
48 useSavedEnvironmentRuntimeStore,52 useSavedEnvironmentRuntimeStore,
49 waitForSavedEnvironmentRegistryHydration,53 waitForSavedEnvironmentRegistryHydration,
...@@ -59,8 +63,11 @@ import {...@@ -59,8 +63,11 @@ import {
59} from "~/store";63} from "~/store";
60import { useTerminalStateStore } from "~/terminalStateStore";64import { useTerminalStateStore } from "~/terminalStateStore";
61import { useUiStateStore } from "~/uiStateStore";65import { useUiStateStore } from "~/uiStateStore";
66import type { WsProtocolCloseContext } from "../../rpc/protocol";
67import { getServerConfig } from "../../rpc/serverState";
62import { WsTransport } from "../../rpc/wsTransport";68import { WsTransport } from "../../rpc/wsTransport";
63import { createWsRpcClient, type WsRpcClient } from "../../rpc/wsRpcClient";69import { createWsRpcClient, type WsRpcClient } from "../../rpc/wsRpcClient";
70import { appendVersionMismatchHint, resolveServerConfigVersionMismatch } from "../../versionSkew";
64import {71import {
65 deriveLogicalProjectKeyFromSettings,72 deriveLogicalProjectKeyFromSettings,
66 derivePhysicalProjectKey,73 derivePhysicalProjectKey,
...@@ -85,6 +92,28 @@ type ThreadDetailSubscriptionEntry = {...@@ -85,6 +92,28 @@ type ThreadDetailSubscriptionEntry = {
85};92};
8693
87const environmentConnections = new Map<EnvironmentId, EnvironmentConnection>();94const environmentConnections = new Map<EnvironmentId, EnvironmentConnection>();
95class SavedEnvironmentConnectionCancelledError extends Error {
96 constructor(environmentId: EnvironmentId) {
97 super(`Saved environment ${environmentId} connection was cancelled.`);
98 this.name = "SavedEnvironmentConnectionCancelledError";
99 }
100}
101
102function isSavedEnvironmentConnectionCancelledError(
103 error: unknown,
104): error is SavedEnvironmentConnectionCancelledError {
105 return error instanceof SavedEnvironmentConnectionCancelledError;
106}
107
108interface PendingSavedEnvironmentConnection {
109 cancelled: boolean;
110 readonly promise: Promise<EnvironmentConnection>;
111}
112
113const pendingSavedEnvironmentConnections = new Map<
114 EnvironmentId,
115 PendingSavedEnvironmentConnection
116>();
88const environmentConnectionListeners = new Set<() => void>();117const environmentConnectionListeners = new Set<() => void>();
89const threadDetailSubscriptions = new Map<string, ThreadDetailSubscriptionEntry>();118const threadDetailSubscriptions = new Map<string, ThreadDetailSubscriptionEntry>();
90const lastAppliedProjectionVersionByEnvironment = new Map<119const lastAppliedProjectionVersionByEnvironment = new Map<
...@@ -97,6 +126,8 @@ const lastAppliedProjectionVersionByEnvironment = new Map<...@@ -97,6 +126,8 @@ const lastAppliedProjectionVersionByEnvironment = new Map<
97126
98let activeService: EnvironmentServiceState | null = null;127let activeService: EnvironmentServiceState | null = null;
99let needsProviderInvalidation = false;128let needsProviderInvalidation = false;
129let lastBrowserHiddenAt: number | null = null;
130let lastBrowserResumeReconnectAt = Number.NEGATIVE_INFINITY;
100131
101// Thread detail subscription cache policy:132// Thread detail subscription cache policy:
102// - Active consumers keep a subscription retained via refCount.133// - Active consumers keep a subscription retained via refCount.
...@@ -107,7 +138,9 @@ let needsProviderInvalidation = false;...@@ -107,7 +138,9 @@ let needsProviderInvalidation = false;
107// - Capacity eviction only targets idle cached subscriptions.138// - Capacity eviction only targets idle cached subscriptions.
108const THREAD_DETAIL_SUBSCRIPTION_IDLE_EVICTION_MS = 15 * 60 * 1000;139const THREAD_DETAIL_SUBSCRIPTION_IDLE_EVICTION_MS = 15 * 60 * 1000;
109const MAX_CACHED_THREAD_DETAIL_SUBSCRIPTIONS = 32;140const MAX_CACHED_THREAD_DETAIL_SUBSCRIPTIONS = 32;
141const BROWSER_RESUME_RECONNECT_COOLDOWN_MS = 2_000;
110const NOOP = () => undefined;142const NOOP = () => undefined;
143const SSH_HTTP_STATUS_RE = /^\[ssh_http:(\d+)\]\s/u;
111144
112function compareAppliedProjectionVersion(145function compareAppliedProjectionVersion(
113 left: { readonly sequence: number; readonly updatedAt: string | null },146 left: { readonly sequence: number; readonly updatedAt: string | null },
...@@ -327,6 +360,25 @@ function disposeThreadDetailSubscriptionsForEnvironment(environmentId: Environme...@@ -327,6 +360,25 @@ function disposeThreadDetailSubscriptionsForEnvironment(environmentId: Environme
327 }360 }
328}361}
329362
363function detachThreadDetailSubscriptionsForEnvironment(environmentId: EnvironmentId): void {
364 for (const entry of threadDetailSubscriptions.values()) {
365 if (entry.environmentId !== environmentId) {
366 continue;
367 }
368 entry.unsubscribe();
369 entry.unsubscribe = NOOP;
370 watchThreadDetailSubscriptionConnection(entry);
371 }
372}
373
374function attachThreadDetailSubscriptionsForEnvironment(environmentId: EnvironmentId): void {
375 for (const entry of threadDetailSubscriptions.values()) {
376 if (entry.environmentId === environmentId) {
377 attachThreadDetailSubscription(entry);
378 }
379 }
380}
381
330function reconcileThreadDetailSubscriptionsForEnvironment(382function reconcileThreadDetailSubscriptionsForEnvironment(
331 environmentId: EnvironmentId,383 environmentId: EnvironmentId,
332 threadIds: ReadonlyArray<ThreadId>,384 threadIds: ReadonlyArray<ThreadId>,
...@@ -491,6 +543,227 @@ function isoNow(): string {...@@ -491,6 +543,227 @@ function isoNow(): string {
491 return new Date().toISOString();543 return new Date().toISOString();
492}544}
493545
546function readSshHttpErrorStatus(error: unknown): number | null {
547 if (!(error instanceof Error)) {
548 return null;
549 }
550
551 const match = SSH_HTTP_STATUS_RE.exec(error.message);
552 if (!match) {
553 return null;
554 }
555
556 const parsed = Number.parseInt(match[1] ?? "", 10);
557 return Number.isInteger(parsed) ? parsed : null;
558}
559
560function isSshHttpAuthError(error: unknown, status: number): boolean {
561 return readSshHttpErrorStatus(error) === status;
562}
563
564function isDesktopSshTargetEqual(
565 left: DesktopSshEnvironmentTarget | undefined,
566 right: DesktopSshEnvironmentTarget | undefined,
567): boolean {
568 if (!left || !right) {
569 return false;
570 }
571
572 return (
573 left.alias === right.alias &&
574 left.hostname === right.hostname &&
575 left.username === right.username &&
576 left.port === right.port
577 );
578}
579
580function findSavedEnvironmentRecordByDesktopSshTarget(
581 target: DesktopSshEnvironmentTarget | undefined,
582): SavedEnvironmentRecord | null {
583 if (!target) {
584 return null;
585 }
586
587 return (
588 listSavedEnvironmentRecords().find((record) =>
589 isDesktopSshTargetEqual(record.desktopSsh, target),
590 ) ?? null
591 );
592}
593
594function buildSavedEnvironmentRegistryById(
595 records: ReadonlyArray<SavedEnvironmentRecord>,
596): Record<EnvironmentId, SavedEnvironmentRecord> {
597 return Object.fromEntries(records.map((record) => [record.environmentId, record])) as Record<
598 EnvironmentId,
599 SavedEnvironmentRecord
600 >;
601}
602
603type SavedEnvironmentRegistrySnapshot = ReadonlyMap<EnvironmentId, SavedEnvironmentRecord | null>;
604
605function snapshotSavedEnvironmentRegistry(
606 environmentIds: ReadonlyArray<EnvironmentId>,
607): SavedEnvironmentRegistrySnapshot {
608 return new Map(
609 environmentIds.map((environmentId) => [
610 environmentId,
611 getSavedEnvironmentRecord(environmentId) ?? null,
612 ]),
613 );
614}
615
616async function persistSavedEnvironmentRegistryRollback(
617 snapshot: SavedEnvironmentRegistrySnapshot,
618): Promise<void> {
619 const byId = buildSavedEnvironmentRegistryById(listSavedEnvironmentRecords());
620 for (const [environmentId, record] of snapshot) {
621 if (record) {
622 byId[environmentId] = record;
623 continue;
624 }
625 delete byId[environmentId];
626 }
627 const records = Object.values(byId);
628 await ensureLocalApi().persistence.setSavedEnvironmentRegistry(
629 records.map((entry) => toPersistedSavedEnvironmentRecord(entry)),
630 );
631 useSavedEnvironmentRegistryStore.setState({
632 byId,
633 });
634}
635
636async function resolveDesktopSshEnvironmentBootstrap(
637 target: DesktopSshEnvironmentTarget,
638 options?: { readonly issuePairingToken?: boolean },
639): Promise<DesktopSshEnvironmentBootstrap> {
640 const desktopBridge = window.desktopBridge;
641 if (!desktopBridge) {
642 throw new Error("SSH launch is only available in the desktop app.");
643 }
644
645 return await desktopBridge.ensureSshEnvironment(target, options);
646}
647
648function getDesktopSshBridge() {
649 const desktopBridge = window.desktopBridge;
650 if (!desktopBridge) {
651 throw new Error("SSH launch is only available in the desktop app.");
652 }
653 return desktopBridge;
654}
655
656async function fetchDesktopSshEnvironmentDescriptor(httpBaseUrl: string) {
657 return await getDesktopSshBridge().fetchSshEnvironmentDescriptor(httpBaseUrl);
658}
659
660async function bootstrapDesktopSshBearerSession(httpBaseUrl: string, credential: string) {
661 return await getDesktopSshBridge().bootstrapSshBearerSession(httpBaseUrl, credential);
662}
663
664async function fetchDesktopSshSessionState(httpBaseUrl: string, bearerToken: string) {
665 return await getDesktopSshBridge().fetchSshSessionState(httpBaseUrl, bearerToken);
666}
667
668async function resolveDesktopSshWebSocketConnectionUrl(
669 wsBaseUrl: string,
670 httpBaseUrl: string,
671 bearerToken: string,
672) {
673 const issued = await getDesktopSshBridge().issueSshWebSocketToken(httpBaseUrl, bearerToken);
674 const url = new URL(wsBaseUrl, window.location.origin);
675 url.searchParams.set("wsToken", issued.token);
676 return url.toString();
677}
678
679async function prepareSavedEnvironmentRecordForConnection(
680 record: SavedEnvironmentRecord,
681 options?: { readonly issuePairingToken?: boolean },
682): Promise<{
683 readonly record: SavedEnvironmentRecord;
684 readonly pairingToken: string | null;
685 readonly remotePort: number | null;
686 readonly remoteServerKind: "external" | "managed" | null;
687}> {
688 if (!record.desktopSsh) {
689 return {
690 record,
691 pairingToken: null,
692 remotePort: null,
693 remoteServerKind: null,
694 };
695 }
696
697 const bootstrap = await resolveDesktopSshEnvironmentBootstrap(record.desktopSsh, options);
698 const nextRecord: SavedEnvironmentRecord = {
699 ...record,
700 httpBaseUrl: bootstrap.httpBaseUrl,
701 wsBaseUrl: bootstrap.wsBaseUrl,
702 desktopSsh: bootstrap.target,
703 };
704
705 if (
706 nextRecord.httpBaseUrl !== record.httpBaseUrl ||
707 nextRecord.wsBaseUrl !== record.wsBaseUrl ||
708 !isDesktopSshTargetEqual(nextRecord.desktopSsh, record.desktopSsh)
709 ) {
710 await persistSavedEnvironmentRecord(nextRecord);
711 useSavedEnvironmentRegistryStore.getState().upsert(nextRecord);
712 }
713
714 return {
715 record: nextRecord,
716 pairingToken: bootstrap.pairingToken,
717 remotePort: bootstrap.remotePort ?? null,
718 remoteServerKind: bootstrap.remoteServerKind ?? null,
719 };
720}
721
722async function issueDesktopSshBearerSession(record: SavedEnvironmentRecord): Promise<{
723 readonly record: SavedEnvironmentRecord;
724 readonly bearerToken: string;
725 readonly role: AuthSessionRole | null;
726}> {
727 const registrySnapshot = snapshotSavedEnvironmentRegistry([record.environmentId]);
728 const prepared = await prepareSavedEnvironmentRecordForConnection(record, {
729 issuePairingToken: true,
730 });
731 if (!prepared.pairingToken) {
732 await persistSavedEnvironmentRegistryRollback(registrySnapshot);
733 throw new Error("Desktop SSH launch did not return a pairing token.");
734 }
735
736 const bearerSession = await bootstrapDesktopSshBearerSession(
737 prepared.record.httpBaseUrl,
738 prepared.pairingToken,
739 ).catch(async (error) => {
740 await persistSavedEnvironmentRegistryRollback(registrySnapshot);
741 const detail = [
742 `local ${prepared.record.httpBaseUrl}`,
743 `remote port ${prepared.remotePort ?? "unknown"}`,
744 prepared.remoteServerKind ? `remote server ${prepared.remoteServerKind}` : null,
745 ]
746 .filter(Boolean)
747 .join(", ");
748 const message = error instanceof Error ? error.message : String(error);
749 throw new Error(`${message} (${detail})`);
750 });
751 const didPersistBearerToken = await writeSavedEnvironmentBearerToken(
752 prepared.record.environmentId,
753 bearerSession.sessionToken,
754 );
755 if (!didPersistBearerToken) {
756 await persistSavedEnvironmentRegistryRollback(registrySnapshot);
757 throw new Error("Unable to persist saved environment credentials.");
758 }
759
760 return {
761 record: prepared.record,
762 bearerToken: bearerSession.sessionToken,
763 role: bearerSession.role ?? null,
764 };
765}
766
494function setRuntimeConnecting(environmentId: EnvironmentId) {767function setRuntimeConnecting(environmentId: EnvironmentId) {
495 useSavedEnvironmentRuntimeStore.getState().patch(environmentId, {768 useSavedEnvironmentRuntimeStore.getState().patch(environmentId, {
496 connectionState: "connecting",769 connectionState: "connecting",
...@@ -797,40 +1070,80 @@ function createPrimaryEnvironmentClient(...@@ -797,40 +1070,80 @@ function createPrimaryEnvironmentClient(
797 `Unable to resolve websocket URL for ${knownEnvironment?.label ?? "primary environment"}.`,1070 `Unable to resolve websocket URL for ${knownEnvironment?.label ?? "primary environment"}.`,
798 );1071 );
799 }1072 }
1073 const connectionLabel = knownEnvironment?.label ?? null;
8001074
801 return createWsRpcClient(new WsTransport(wsBaseUrl));1075 return createWsRpcClient(
1076 new WsTransport(wsBaseUrl, {
1077 getConnectionLabel: () => connectionLabel,
1078 getVersionMismatchHint: () =>
1079 resolveServerConfigVersionMismatch(getServerConfig())?.hint ?? null,
1080 }),
1081 );
802}1082}
8031083
804function createSavedEnvironmentClient(1084function createSavedEnvironmentClient(
805 record: SavedEnvironmentRecord,1085 environmentId: EnvironmentId,
806 bearerToken: string,1086 bearerToken: string,
807): WsRpcClient {1087): WsRpcClient {
808 useSavedEnvironmentRuntimeStore.getState().ensure(record.environmentId);1088 useSavedEnvironmentRuntimeStore.getState().ensure(environmentId);
8091089
810 return createWsRpcClient(1090 return createWsRpcClient(
811 new WsTransport(1091 new WsTransport(
812 () =>1092 async () => {
813 resolveRemoteWebSocketConnectionUrl({1093 const record = getSavedEnvironmentRecord(environmentId);
814 wsBaseUrl: record.wsBaseUrl,1094 if (!record) {
815 httpBaseUrl: record.httpBaseUrl,1095 throw new Error(`Saved environment ${environmentId} not found.`);
816 bearerToken,1096 }
817 }),1097 return record.desktopSsh
1098 ? await resolveDesktopSshWebSocketConnectionUrl(
1099 record.wsBaseUrl,
1100 record.httpBaseUrl,
1101 bearerToken,
1102 )
1103 : await resolveRemoteWebSocketConnectionUrl({
1104 wsBaseUrl: record.wsBaseUrl,
1105 httpBaseUrl: record.httpBaseUrl,
1106 bearerToken,
1107 });
1108 },
818 {1109 {
1110 getConnectionLabel: () => getSavedEnvironmentRecord(environmentId)?.label ?? null,
1111 getVersionMismatchHint: () =>
1112 resolveServerConfigVersionMismatch(
1113 useSavedEnvironmentRuntimeStore.getState().byId[environmentId]?.serverConfig,
1114 )?.hint ?? null,
819 onAttempt: () => {1115 onAttempt: () => {
820 setRuntimeConnecting(record.environmentId);1116 setRuntimeConnecting(environmentId);
821 },1117 },
822 onOpen: () => {1118 onOpen: () => {
823 setRuntimeConnected(record.environmentId);1119 setRuntimeConnected(environmentId);
824 },1120 },
825 onError: (message: string) => {1121 onError: (message: string) => {
826 useSavedEnvironmentRuntimeStore.getState().patch(record.environmentId, {1122 const mismatch = resolveServerConfigVersionMismatch(
1123 useSavedEnvironmentRuntimeStore.getState().byId[environmentId]?.serverConfig,
1124 );
1125 useSavedEnvironmentRuntimeStore.getState().patch(environmentId, {
827 connectionState: "error",1126 connectionState: "error",
828 lastError: message,1127 lastError: appendVersionMismatchHint(message, mismatch),
829 lastErrorAt: isoNow(),1128 lastErrorAt: isoNow(),
830 });1129 });
831 },1130 },
832 onClose: (details: { readonly code: number; readonly reason: string }) => {1131 onClose: (
833 setRuntimeDisconnected(record.environmentId, details.reason);1132 details: { readonly code: number; readonly reason: string },
1133 context: WsProtocolCloseContext,
1134 ) => {
1135 if (context.intentional) {
1136 return;
1137 }
1138 setRuntimeDisconnected(
1139 environmentId,
1140 appendVersionMismatchHint(
1141 details.reason,
1142 resolveServerConfigVersionMismatch(
1143 useSavedEnvironmentRuntimeStore.getState().byId[environmentId]?.serverConfig,
1144 ),
1145 ),
1146 );
834 },1147 },
835 },1148 },
836 ),1149 ),
...@@ -838,18 +1151,25 @@ function createSavedEnvironmentClient(...@@ -838,18 +1151,25 @@ function createSavedEnvironmentClient(
838}1151}
8391152
840async function refreshSavedEnvironmentMetadata(1153async function refreshSavedEnvironmentMetadata(
841 record: SavedEnvironmentRecord,1154 environmentId: EnvironmentId,
842 bearerToken: string,1155 bearerToken: string,
843 client: WsRpcClient,1156 client: WsRpcClient,
844 roleHint?: AuthSessionRole | null,1157 roleHint?: AuthSessionRole | null,
845 configHint?: ServerConfig | null,1158 configHint?: ServerConfig | null,
846): Promise<void> {1159): Promise<void> {
1160 const record = getSavedEnvironmentRecord(environmentId);
1161 if (!record) {
1162 throw new Error(`Saved environment ${environmentId} not found.`);
1163 }
1164
847 const [serverConfig, sessionState] = await Promise.all([1165 const [serverConfig, sessionState] = await Promise.all([
848 configHint ? Promise.resolve(configHint) : client.server.getConfig(),1166 configHint ? Promise.resolve(configHint) : client.server.getConfig(),
849 fetchRemoteSessionState({1167 record.desktopSsh
850 httpBaseUrl: record.httpBaseUrl,1168 ? fetchDesktopSshSessionState(record.httpBaseUrl, bearerToken)
851 bearerToken,1169 : fetchRemoteSessionState({
852 }),1170 httpBaseUrl: record.httpBaseUrl,
1171 bearerToken,
1172 }),
853 ]);1173 ]);
8541174
855 useSavedEnvironmentRuntimeStore.getState().patch(record.environmentId, {1175 useSavedEnvironmentRuntimeStore.getState().patch(record.environmentId, {
...@@ -858,6 +1178,9 @@ async function refreshSavedEnvironmentMetadata(...@@ -858,6 +1178,9 @@ async function refreshSavedEnvironmentMetadata(
858 serverConfig,1178 serverConfig,
859 role: sessionState.authenticated ? (sessionState.role ?? roleHint ?? null) : null,1179 role: sessionState.authenticated ? (sessionState.role ?? roleHint ?? null) : null,
860 });1180 });
1181 useSavedEnvironmentRegistryStore
1182 .getState()
1183 .rename(record.environmentId, serverConfig.environment.label);
861}1184}
8621185
863function registerConnection(connection: EnvironmentConnection): EnvironmentConnection {1186function registerConnection(connection: EnvironmentConnection): EnvironmentConnection {
...@@ -866,6 +1189,7 @@ function registerConnection(connection: EnvironmentConnection): EnvironmentConne...@@ -866,6 +1189,7 @@ function registerConnection(connection: EnvironmentConnection): EnvironmentConne
866 throw new Error(`Environment ${connection.environmentId} already has an active connection.`);1189 throw new Error(`Environment ${connection.environmentId} already has an active connection.`);
867 }1190 }
868 environmentConnections.set(connection.environmentId, connection);1191 environmentConnections.set(connection.environmentId, connection);
1192 attachThreadDetailSubscriptionsForEnvironment(connection.environmentId);
869 emitEnvironmentConnectionRegistryChange();1193 emitEnvironmentConnectionRegistryChange();
870 return connection;1194 return connection;
871}1195}
...@@ -876,10 +1200,10 @@ async function removeConnection(environmentId: EnvironmentId): Promise<boolean>...@@ -876,10 +1200,10 @@ async function removeConnection(environmentId: EnvironmentId): Promise<boolean>
876 return false;1200 return false;
877 }1201 }
8781202
879 disposeThreadDetailSubscriptionsForEnvironment(environmentId);
880 lastAppliedProjectionVersionByEnvironment.delete(environmentId);1203 lastAppliedProjectionVersionByEnvironment.delete(environmentId);
881 environmentConnections.delete(environmentId);1204 environmentConnections.delete(environmentId);
882 emitEnvironmentConnectionRegistryChange();1205 emitEnvironmentConnectionRegistryChange();
1206 detachThreadDetailSubscriptionsForEnvironment(environmentId);
883 await connection.dispose();1207 await connection.dispose();
884 return true;1208 return true;
885}1209}
...@@ -905,6 +1229,10 @@ function createPrimaryEnvironmentConnection(): EnvironmentConnection {...@@ -905,6 +1229,10 @@ function createPrimaryEnvironmentConnection(): EnvironmentConnection {
905 );1229 );
906}1230}
9071231
1232function maybeCreatePrimaryEnvironmentConnection(): EnvironmentConnection | null {
1233 return getPrimaryKnownEnvironment()?.environmentId ? createPrimaryEnvironmentConnection() : null;
1234}
1235
908async function ensureSavedEnvironmentConnection(1236async function ensureSavedEnvironmentConnection(
909 record: SavedEnvironmentRecord,1237 record: SavedEnvironmentRecord,
910 options?: {1238 options?: {
...@@ -919,69 +1247,144 @@ async function ensureSavedEnvironmentConnection(...@@ -919,69 +1247,144 @@ async function ensureSavedEnvironmentConnection(
919 return existing;1247 return existing;
920 }1248 }
9211249
922 const bearerToken =1250 const pending = pendingSavedEnvironmentConnections.get(record.environmentId);
923 options?.bearerToken ?? (await readSavedEnvironmentBearerToken(record.environmentId));1251 if (pending) {
924 if (!bearerToken) {1252 return pending.promise;
925 useSavedEnvironmentRuntimeStore.getState().patch(record.environmentId, {
926 authState: "requires-auth",
927 role: null,
928 connectionState: "disconnected",
929 lastError: "Saved environment is missing its saved credential. Pair it again.",
930 lastErrorAt: isoNow(),
931 });
932 throw new Error("Saved environment is missing its saved credential.");
933 }1253 }
9341254
935 const client = options?.client ?? createSavedEnvironmentClient(record, bearerToken);1255 const pendingEntry: PendingSavedEnvironmentConnection = {
936 const knownEnvironment = createKnownEnvironment({1256 cancelled: false,
937 id: record.environmentId,1257 promise: Promise.resolve().then(async () => {
938 label: record.label,1258 let activeRecord = record;
939 source: "manual",1259 let roleHint = options?.role ?? null;
940 target: {1260 let bearerToken =
941 httpBaseUrl: record.httpBaseUrl,1261 options?.bearerToken ?? (await readSavedEnvironmentBearerToken(record.environmentId));
942 wsBaseUrl: record.wsBaseUrl,1262 if (!bearerToken) {
943 },1263 if (record.desktopSsh) {
944 });1264 const issued = await issueDesktopSshBearerSession(record);
945 const connection = createEnvironmentConnection({1265 activeRecord = issued.record;
946 kind: "saved",1266 bearerToken = issued.bearerToken;
947 knownEnvironment: {1267 roleHint = issued.role;
948 ...knownEnvironment,1268 } else {
949 environmentId: record.environmentId,1269 useSavedEnvironmentRuntimeStore.getState().patch(record.environmentId, {
950 },1270 authState: "requires-auth",
951 client,1271 role: null,
952 refreshMetadata: async () => {1272 connectionState: "disconnected",
953 await refreshSavedEnvironmentMetadata(record, bearerToken, client);1273 lastError: "Saved environment is missing its saved credential. Pair it again.",
954 },1274 lastErrorAt: isoNow(),
955 onConfigSnapshot: (config) => {1275 });
956 useSavedEnvironmentRuntimeStore.getState().patch(record.environmentId, {1276 throw new Error("Saved environment is missing its saved credential.");
957 descriptor: config.environment,1277 }
958 serverConfig: config,1278 } else {
1279 const prepared = await prepareSavedEnvironmentRecordForConnection(record);
1280 activeRecord = prepared.record;
1281 }
1282
1283 const activeBearerToken = bearerToken;
1284 const client =
1285 options?.client ??
1286 createSavedEnvironmentClient(activeRecord.environmentId, activeBearerToken);
1287 const knownEnvironment = createKnownEnvironment({
1288 id: activeRecord.environmentId,
1289 label: activeRecord.label,
1290 source: "manual",
1291 target: {
1292 httpBaseUrl: activeRecord.httpBaseUrl,
1293 wsBaseUrl: activeRecord.wsBaseUrl,
1294 },
959 });1295 });
960 },1296 const connection = createEnvironmentConnection({
961 onWelcome: (payload) => {1297 kind: "saved",
962 useSavedEnvironmentRuntimeStore.getState().patch(record.environmentId, {1298 knownEnvironment: {
963 descriptor: payload.environment,1299 ...knownEnvironment,
1300 environmentId: activeRecord.environmentId,
1301 },
1302 client,
1303 refreshMetadata: async () => {
1304 await refreshSavedEnvironmentMetadata(
1305 activeRecord.environmentId,
1306 activeBearerToken,
1307 client,
1308 );
1309 },
1310 onConfigSnapshot: (config) => {
1311 useSavedEnvironmentRuntimeStore.getState().patch(activeRecord.environmentId, {
1312 descriptor: config.environment,
1313 serverConfig: config,
1314 });
1315 },
1316 onWelcome: (payload) => {
1317 useSavedEnvironmentRuntimeStore.getState().patch(activeRecord.environmentId, {
1318 descriptor: payload.environment,
1319 });
1320 },
1321 ...createEnvironmentConnectionHandlers(),
964 });1322 });
965 },
966 ...createEnvironmentConnectionHandlers(),
967 });
9681323
969 registerConnection(connection);1324 try {
1325 try {
1326 await refreshSavedEnvironmentMetadata(
1327 activeRecord.environmentId,
1328 activeBearerToken,
1329 client,
1330 roleHint,
1331 options?.serverConfig ?? null,
1332 );
1333 } catch (error) {
1334 const isAuthError = activeRecord.desktopSsh
1335 ? isSshHttpAuthError(error, 401)
1336 : isRemoteEnvironmentAuthHttpError(error) && error.status === 401;
1337 if (!isAuthError) {
1338 throw error;
1339 }
1340 if (!activeRecord.desktopSsh) {
1341 await removeSavedEnvironmentBearerToken(activeRecord.environmentId);
1342 throw new Error("Saved environment credential expired. Pair it again.", {
1343 cause: error,
1344 });
1345 }
1346
1347 const issued = await issueDesktopSshBearerSession(activeRecord);
1348 activeRecord = issued.record;
1349 bearerToken = issued.bearerToken;
1350 roleHint = issued.role;
1351 await connection.dispose().catch(() => undefined);
1352 pendingSavedEnvironmentConnections.delete(activeRecord.environmentId);
1353 return await ensureSavedEnvironmentConnection(activeRecord, {
1354 bearerToken,
1355 role: roleHint,
1356 serverConfig: options?.serverConfig ?? null,
1357 });
1358 }
1359 if (
1360 pendingEntry.cancelled ||
1361 pendingSavedEnvironmentConnections.get(activeRecord.environmentId) !== pendingEntry
1362 ) {
1363 await connection.dispose().catch(() => undefined);
1364 throw new SavedEnvironmentConnectionCancelledError(activeRecord.environmentId);
1365 }
1366 registerConnection(connection);
1367 return connection;
1368 } catch (error) {
1369 if (error instanceof SavedEnvironmentConnectionCancelledError) {
1370 throw error;
1371 }
1372 setRuntimeError(activeRecord.environmentId, error);
1373 const removed = await removeConnection(activeRecord.environmentId).catch(() => false);
1374 if (!removed) {
1375 await connection.dispose().catch(() => undefined);
1376 }
1377 throw error;
1378 }
1379 }),
1380 };
9701381
971 try {1382 pendingSavedEnvironmentConnections.set(record.environmentId, pendingEntry);
972 await refreshSavedEnvironmentMetadata(1383 return await pendingEntry.promise.finally(() => {
973 record,1384 if (pendingSavedEnvironmentConnections.get(record.environmentId) === pendingEntry) {
974 bearerToken,1385 pendingSavedEnvironmentConnections.delete(record.environmentId);
975 client,1386 }
976 options?.role ?? null,1387 });
977 options?.serverConfig ?? null,
978 );
979 return connection;
980 } catch (error) {
981 setRuntimeError(record.environmentId, error);
982 await removeConnection(record.environmentId).catch(() => false);
983 throw error;
984 }
985}1388}
9861389
987async function syncSavedEnvironmentConnections(1390async function syncSavedEnvironmentConnections(
...@@ -1006,6 +1409,55 @@ function stopActiveService() {...@@ -1006,6 +1409,55 @@ function stopActiveService() {
1006 activeService = null;1409 activeService = null;
1007}1410}
10081411
1412function reconnectEnvironmentConnectionsAfterBrowserResume(reason: string): void {
1413 const now = Date.now();
1414 if (now - lastBrowserResumeReconnectAt < BROWSER_RESUME_RECONNECT_COOLDOWN_MS) {
1415 return;
1416 }
1417 lastBrowserResumeReconnectAt = now;
1418
1419 for (const connection of environmentConnections.values()) {
1420 void connection.reconnect().catch((error) => {
1421 console.warn("Environment reconnect after browser resume failed", {
1422 environmentId: connection.environmentId,
1423 reason,
1424 error: error instanceof Error ? error.message : String(error),
1425 });
1426 });
1427 }
1428}
1429
1430function subscribeBrowserResumeReconnects(): () => void {
1431 if (typeof document === "undefined" || typeof window === "undefined") {
1432 return NOOP;
1433 }
1434
1435 const handleVisibilityChange = () => {
1436 if (document.visibilityState === "hidden") {
1437 lastBrowserHiddenAt = Date.now();
1438 return;
1439 }
1440 if (document.visibilityState === "visible" && lastBrowserHiddenAt !== null) {
1441 lastBrowserHiddenAt = null;
1442 reconnectEnvironmentConnectionsAfterBrowserResume("visibilitychange");
1443 }
1444 };
1445
1446 const handlePageShow = (event: PageTransitionEvent) => {
1447 if (event.persisted || lastBrowserHiddenAt !== null) {
1448 lastBrowserHiddenAt = null;
1449 reconnectEnvironmentConnectionsAfterBrowserResume("pageshow");
1450 }
1451 };
1452
1453 document.addEventListener("visibilitychange", handleVisibilityChange);
1454 window.addEventListener("pageshow", handlePageShow);
1455 return () => {
1456 document.removeEventListener("visibilitychange", handleVisibilityChange);
1457 window.removeEventListener("pageshow", handlePageShow);
1458 };
1459}
1460
1009export function subscribeEnvironmentConnections(listener: () => void): () => void {1461export function subscribeEnvironmentConnections(listener: () => void): () => void {
1010 environmentConnectionListeners.add(listener);1462 environmentConnectionListeners.add(listener);
1011 return () => {1463 return () => {
...@@ -1036,13 +1488,23 @@ export function getPrimaryEnvironmentConnection(): EnvironmentConnection {...@@ -1036,13 +1488,23 @@ export function getPrimaryEnvironmentConnection(): EnvironmentConnection {
1036}1488}
10371489
1038export async function disconnectSavedEnvironment(environmentId: EnvironmentId): Promise<void> {1490export async function disconnectSavedEnvironment(environmentId: EnvironmentId): Promise<void> {
1491 const record = getSavedEnvironmentRecord(environmentId);
1492 const pendingConnection = pendingSavedEnvironmentConnections.get(environmentId);
1493 if (pendingConnection) {
1494 pendingConnection.cancelled = true;
1495 pendingSavedEnvironmentConnections.delete(environmentId);
1496 }
1039 const connection = environmentConnections.get(environmentId);1497 const connection = environmentConnections.get(environmentId);
1040 if (connection?.kind !== "saved") {1498
1041 return;1499 if (connection?.kind === "saved") {
1500 await removeConnection(environmentId).catch(() => false);
1042 }1501 }
1502 setRuntimeDisconnected(environmentId);
10431503
1044 useSavedEnvironmentRuntimeStore.getState().clear(environmentId);1504 if (record?.desktopSsh && typeof window !== "undefined") {
1045 await removeConnection(environmentId).catch(() => false);1505 await window.desktopBridge?.disconnectSshEnvironment(record.desktopSsh);
1506 await removeSavedEnvironmentBearerToken(environmentId);
1507 }
1046}1508}
10471509
1048export async function reconnectSavedEnvironment(environmentId: EnvironmentId): Promise<void> {1510export async function reconnectSavedEnvironment(environmentId: EnvironmentId): Promise<void> {
...@@ -1053,23 +1515,57 @@ export async function reconnectSavedEnvironment(environmentId: EnvironmentId): P...@@ -1053,23 +1515,57 @@ export async function reconnectSavedEnvironment(environmentId: EnvironmentId): P
10531515
1054 const connection = environmentConnections.get(environmentId);1516 const connection = environmentConnections.get(environmentId);
1055 if (!connection) {1517 if (!connection) {
1056 await ensureSavedEnvironmentConnection(record);1518 setRuntimeConnecting(environmentId);
1057 return;1519 try {
1520 await ensureSavedEnvironmentConnection(record);
1521 return;
1522 } catch (error) {
1523 if (isSavedEnvironmentConnectionCancelledError(error)) {
1524 return;
1525 }
1526 setRuntimeError(environmentId, error);
1527 throw error;
1528 }
1058 }1529 }
10591530
1060 setRuntimeConnecting(environmentId);1531 setRuntimeConnecting(environmentId);
1061 try {1532 try {
1533 if (record.desktopSsh) {
1534 await prepareSavedEnvironmentRecordForConnection(record);
1535 }
1062 await connection.reconnect();1536 await connection.reconnect();
1063 } catch (error) {1537 } catch (error) {
1538 if (record.desktopSsh) {
1539 try {
1540 const issued = await issueDesktopSshBearerSession(
1541 getSavedEnvironmentRecord(environmentId) ?? record,
1542 );
1543 await removeConnection(environmentId).catch(() => false);
1544 await ensureSavedEnvironmentConnection(issued.record, {
1545 bearerToken: issued.bearerToken,
1546 role: issued.role,
1547 });
1548 return;
1549 } catch (recoveryError) {
1550 if (isSavedEnvironmentConnectionCancelledError(recoveryError)) {
1551 return;
1552 }
1553 setRuntimeError(environmentId, recoveryError);
1554 throw recoveryError;
1555 }
1556 }
1064 setRuntimeError(environmentId, error);1557 setRuntimeError(environmentId, error);
1065 throw error;1558 throw error;
1066 }1559 }
1067}1560}
10681561
1069export async function removeSavedEnvironment(environmentId: EnvironmentId): Promise<void> {1562export async function removeSavedEnvironment(environmentId: EnvironmentId): Promise<void> {
1563 await disconnectSavedEnvironment(environmentId);
1564 disposeThreadDetailSubscriptionsForEnvironment(environmentId);
1070 useSavedEnvironmentRegistryStore.getState().remove(environmentId);1565 useSavedEnvironmentRegistryStore.getState().remove(environmentId);
1566 useSavedEnvironmentRuntimeStore.getState().clear(environmentId);
1567 useStore.getState().removeEnvironmentState(environmentId);
1071 await removeSavedEnvironmentBearerToken(environmentId);1568 await removeSavedEnvironmentBearerToken(environmentId);
1072 await disconnectSavedEnvironment(environmentId);
1073}1569}
10741570
1075export async function addSavedEnvironment(input: {1571export async function addSavedEnvironment(input: {
...@@ -1077,33 +1573,43 @@ export async function addSavedEnvironment(input: {...@@ -1077,33 +1573,43 @@ export async function addSavedEnvironment(input: {
1077 readonly pairingUrl?: string;1573 readonly pairingUrl?: string;
1078 readonly host?: string;1574 readonly host?: string;
1079 readonly pairingCode?: string;1575 readonly pairingCode?: string;
1576 readonly desktopSsh?: DesktopSshEnvironmentTarget;
1080}): Promise<SavedEnvironmentRecord> {1577}): Promise<SavedEnvironmentRecord> {
1081 const resolvedTarget = resolveRemotePairingTarget({1578 const resolvedTarget = resolveRemotePairingTarget({
1082 ...(input.pairingUrl !== undefined ? { pairingUrl: input.pairingUrl } : {}),1579 ...(input.pairingUrl !== undefined ? { pairingUrl: input.pairingUrl } : {}),
1083 ...(input.host !== undefined ? { host: input.host } : {}),1580 ...(input.host !== undefined ? { host: input.host } : {}),
1084 ...(input.pairingCode !== undefined ? { pairingCode: input.pairingCode } : {}),1581 ...(input.pairingCode !== undefined ? { pairingCode: input.pairingCode } : {}),
1085 });1582 });
1086 const descriptor = await fetchRemoteEnvironmentDescriptor({1583 const descriptor = input.desktopSsh
1087 httpBaseUrl: resolvedTarget.httpBaseUrl,1584 ? await fetchDesktopSshEnvironmentDescriptor(resolvedTarget.httpBaseUrl)
1088 });1585 : await fetchRemoteEnvironmentDescriptor({
1586 httpBaseUrl: resolvedTarget.httpBaseUrl,
1587 });
1089 const environmentId = descriptor.environmentId;1588 const environmentId = descriptor.environmentId;
10901589 const registrySnapshot = snapshotSavedEnvironmentRegistry([environmentId]);
1091 if (environmentConnections.has(environmentId)) {1590 const existingRecord =
1092 throw new Error("This environment is already connected.");1591 getSavedEnvironmentRecord(environmentId) ??
1093 }1592 findSavedEnvironmentRecordByDesktopSshTarget(input.desktopSsh);
10941593 const staleDesktopSshRecord =
1095 const bearerSession = await bootstrapRemoteBearerSession({1594 existingRecord && existingRecord.environmentId !== environmentId ? existingRecord : null;
1096 httpBaseUrl: resolvedTarget.httpBaseUrl,1595
1097 credential: resolvedTarget.credential,1596 const bearerSession = input.desktopSsh
1098 });1597 ? await bootstrapDesktopSshBearerSession(resolvedTarget.httpBaseUrl, resolvedTarget.credential)
1598 : await bootstrapRemoteBearerSession({
1599 httpBaseUrl: resolvedTarget.httpBaseUrl,
1600 credential: resolvedTarget.credential,
1601 });
10991602
1100 const record: SavedEnvironmentRecord = {1603 const record: SavedEnvironmentRecord = {
1101 environmentId,1604 environmentId,
1102 label: input.label.trim() || descriptor.label,1605 label: input.label.trim() || existingRecord?.label || descriptor.label,
1103 wsBaseUrl: resolvedTarget.wsBaseUrl,1606 wsBaseUrl: resolvedTarget.wsBaseUrl,
1104 httpBaseUrl: resolvedTarget.httpBaseUrl,1607 httpBaseUrl: resolvedTarget.httpBaseUrl,
1105 createdAt: isoNow(),1608 createdAt: existingRecord?.createdAt ?? isoNow(),
1106 lastConnectedAt: isoNow(),1609 lastConnectedAt: isoNow(),
1610 ...((input.desktopSsh ?? existingRecord?.desktopSsh)
1611 ? { desktopSsh: input.desktopSsh ?? existingRecord?.desktopSsh }
1612 : {}),
1107 };1613 };
11081614
1109 await persistSavedEnvironmentRecord(record);1615 await persistSavedEnvironmentRecord(record);
...@@ -1112,26 +1618,50 @@ export async function addSavedEnvironment(input: {...@@ -1112,26 +1618,50 @@ export async function addSavedEnvironment(input: {
1112 bearerSession.sessionToken,1618 bearerSession.sessionToken,
1113 );1619 );
1114 if (!didPersistBearerToken) {1620 if (!didPersistBearerToken) {
1115 await ensureLocalApi().persistence.setSavedEnvironmentRegistry(1621 await persistSavedEnvironmentRegistryRollback(registrySnapshot);
1116 listSavedEnvironmentRecords().map((entry) => ({
1117 environmentId: entry.environmentId,
1118 label: entry.label,
1119 httpBaseUrl: entry.httpBaseUrl,
1120 wsBaseUrl: entry.wsBaseUrl,
1121 createdAt: entry.createdAt,
1122 lastConnectedAt: entry.lastConnectedAt,
1123 })),
1124 );
1125 throw new Error("Unable to persist saved environment credentials.");1622 throw new Error("Unable to persist saved environment credentials.");
1126 }1623 }
1624 useSavedEnvironmentRegistryStore.getState().upsert(record);
1625 if (staleDesktopSshRecord) {
1626 await removeSavedEnvironment(staleDesktopSshRecord.environmentId);
1627 }
1628 await removeConnection(environmentId).catch(() => false);
1127 await ensureSavedEnvironmentConnection(record, {1629 await ensureSavedEnvironmentConnection(record, {
1128 bearerToken: bearerSession.sessionToken,1630 bearerToken: bearerSession.sessionToken,
1129 role: bearerSession.role,1631 role: bearerSession.role,
1130 });1632 });
1131 useSavedEnvironmentRegistryStore.getState().upsert(record);
1132 return record;1633 return record;
1133}1634}
11341635
1636export async function connectDesktopSshEnvironment(
1637 target: DesktopSshEnvironmentTarget,
1638 options?: { label?: string },
1639): Promise<SavedEnvironmentRecord> {
1640 const bootstrap = await resolveDesktopSshEnvironmentBootstrap(target, {
1641 issuePairingToken: true,
1642 });
1643 if (!bootstrap.pairingToken) {
1644 throw new Error("Desktop SSH launch did not return a pairing token.");
1645 }
1646
1647 return await addSavedEnvironment({
1648 label: options?.label?.trim() || bootstrap.target.alias,
1649 host: bootstrap.httpBaseUrl,
1650 pairingCode: bootstrap.pairingToken,
1651 desktopSsh: bootstrap.target,
1652 }).catch((error) => {
1653 const detail = [
1654 `local ${bootstrap.httpBaseUrl}`,
1655 `remote port ${bootstrap.remotePort ?? "unknown"}`,
1656 bootstrap.remoteServerKind ? `remote server ${bootstrap.remoteServerKind}` : null,
1657 ]
1658 .filter(Boolean)
1659 .join(", ");
1660 const message = error instanceof Error ? error.message : String(error);
1661 throw new Error(`${message} (${detail})`);
1662 });
1663}
1664
1135export async function ensureEnvironmentConnectionBootstrapped(1665export async function ensureEnvironmentConnectionBootstrapped(
1136 environmentId: EnvironmentId,1666 environmentId: EnvironmentId,
1137): Promise<void> {1667): Promise<void> {
...@@ -1170,7 +1700,7 @@ export function startEnvironmentConnectionService(queryClient: QueryClient): ()...@@ -1170,7 +1700,7 @@ export function startEnvironmentConnectionService(queryClient: QueryClient): ()
1170 },1700 },
1171 );1701 );
11721702
1173 createPrimaryEnvironmentConnection();1703 maybeCreatePrimaryEnvironmentConnection();
11741704
1175 const unsubscribeSavedEnvironments = useSavedEnvironmentRegistryStore.subscribe(() => {1705 const unsubscribeSavedEnvironments = useSavedEnvironmentRegistryStore.subscribe(() => {
1176 if (!hasSavedEnvironmentRegistryHydrated()) {1706 if (!hasSavedEnvironmentRegistryHydrated()) {
...@@ -1183,12 +1713,15 @@ export function startEnvironmentConnectionService(queryClient: QueryClient): ()...@@ -1183,12 +1713,15 @@ export function startEnvironmentConnectionService(queryClient: QueryClient): ()
1183 .then(() => syncSavedEnvironmentConnections(listSavedEnvironmentRecords()))1713 .then(() => syncSavedEnvironmentConnections(listSavedEnvironmentRecords()))
1184 .catch(() => undefined);1714 .catch(() => undefined);
11851715
1716 const unsubscribeBrowserResumeReconnects = subscribeBrowserResumeReconnects();
1717
1186 activeService = {1718 activeService = {
1187 queryClient,1719 queryClient,
1188 queryInvalidationThrottler,1720 queryInvalidationThrottler,
1189 refCount: 1,1721 refCount: 1,
1190 stop: () => {1722 stop: () => {
1191 unsubscribeSavedEnvironments();1723 unsubscribeSavedEnvironments();
1724 unsubscribeBrowserResumeReconnects();
1192 queryInvalidationThrottler.cancel();1725 queryInvalidationThrottler.cancel();
1193 },1726 },
1194 };1727 };
...@@ -1206,7 +1739,10 @@ export function startEnvironmentConnectionService(queryClient: QueryClient): ()...@@ -1206,7 +1739,10 @@ export function startEnvironmentConnectionService(queryClient: QueryClient): ()
12061739
1207export async function resetEnvironmentServiceForTests(): Promise<void> {1740export async function resetEnvironmentServiceForTests(): Promise<void> {
1208 stopActiveService();1741 stopActiveService();
1742 lastBrowserHiddenAt = null;
1743 lastBrowserResumeReconnectAt = Number.NEGATIVE_INFINITY;
1209 lastAppliedProjectionVersionByEnvironment.clear();1744 lastAppliedProjectionVersionByEnvironment.clear();
1745 pendingSavedEnvironmentConnections.clear();
1210 for (const key of Array.from(threadDetailSubscriptions.keys())) {1746 for (const key of Array.from(threadDetailSubscriptions.keys())) {
1211 disposeThreadDetailSubscriptionByKey(key);1747 disposeThreadDetailSubscriptionByKey(key);
1212 }1748 }
apps/web/src/hostedPairing.test.ts created+66
...@@ -0,0 +1,66 @@
1import { afterEach, describe, expect, it, vi } from "vitest";
2
3import {
4 buildHostedPairingUrl,
5 hasHostedPairingRequest,
6 isHostedStaticApp,
7 readHostedPairingRequest,
8} from "./hostedPairing";
9
10describe("hostedPairing", () => {
11 afterEach(() => {
12 vi.unstubAllEnvs();
13 });
14
15 it("reads hosted pairing host and query token parameters", () => {
16 const url = new URL("https://app.t3.codes/pair?host=100.64.1.2:3773&token=ABCD1234");
17
18 expect(readHostedPairingRequest(url)).toEqual({
19 host: "100.64.1.2:3773",
20 token: "ABCD1234",
21 label: "",
22 });
23 expect(hasHostedPairingRequest(url)).toBe(true);
24 });
25
26 it("prefers hash tokens so generated hosted links do not put credentials in search params", () => {
27 vi.stubEnv("VITE_HOSTED_APP_URL", "https://preview.t3.codes");
28
29 const url = new URL(
30 buildHostedPairingUrl({
31 host: "https://backend.example.com:3773",
32 token: "pairing-token",
33 label: "Workstation",
34 }),
35 );
36
37 expect(url.origin).toBe("https://preview.t3.codes");
38 expect(url.pathname).toBe("/pair");
39 expect(url.searchParams.get("host")).toBe("https://backend.example.com:3773");
40 expect(url.searchParams.get("label")).toBe("Workstation");
41 expect(url.searchParams.has("token")).toBe(false);
42 expect(url.hash).toBe("#token=pairing-token");
43 });
44
45 it("ignores incomplete hosted pairing requests", () => {
46 expect(
47 hasHostedPairingRequest(new URL("https://app.t3.codes/pair?host=backend.example.com")),
48 ).toBe(false);
49 expect(hasHostedPairingRequest(new URL("https://app.t3.codes/pair?token=ABCD1234"))).toBe(
50 false,
51 );
52 });
53
54 it("detects the hosted static app only when no backend URL is configured", () => {
55 vi.stubEnv("VITE_HOSTED_APP_URL", "https://preview.t3.codes");
56 vi.stubEnv("VITE_HTTP_URL", "");
57 vi.stubEnv("VITE_WS_URL", "");
58
59 expect(isHostedStaticApp(new URL("https://preview.t3.codes/"))).toBe(true);
60 expect(isHostedStaticApp(new URL("https://preview.t3.codes/pair"))).toBe(true);
61 expect(isHostedStaticApp(new URL("https://backend.example.com/"))).toBe(false);
62
63 vi.stubEnv("VITE_HTTP_URL", "https://backend.example.com");
64 expect(isHostedStaticApp(new URL("https://preview.t3.codes/"))).toBe(false);
65 });
66});
apps/web/src/hostedPairing.ts created+70
...@@ -0,0 +1,70 @@
1import { getPairingTokenFromUrl, setPairingTokenOnUrl } from "./pairingUrl";
2
3const DEFAULT_HOSTED_APP_URL = "https://app.t3.codes";
4
5export interface HostedPairingRequest {
6 readonly host: string;
7 readonly token: string;
8 readonly label: string;
9}
10
11function configuredHostedAppUrl(): string {
12 return import.meta.env.VITE_HOSTED_APP_URL?.trim() || DEFAULT_HOSTED_APP_URL;
13}
14
15function configuredBackendUrl(): string {
16 return import.meta.env.VITE_HTTP_URL?.trim() || import.meta.env.VITE_WS_URL?.trim() || "";
17}
18
19function originFromUrl(value: string): string | null {
20 try {
21 return new URL(value).origin;
22 } catch {
23 return null;
24 }
25}
26
27export function isHostedStaticApp(url: URL = new URL(window.location.href)): boolean {
28 if (configuredBackendUrl()) {
29 return false;
30 }
31
32 const hostedOrigin = originFromUrl(configuredHostedAppUrl());
33 return hostedOrigin !== null && url.origin === hostedOrigin;
34}
35
36export function readHostedPairingRequest(url: URL = new URL(window.location.href)) {
37 const host = url.searchParams.get("host")?.trim() ?? "";
38 const token = getPairingTokenFromUrl(url)?.trim() ?? "";
39 const label = url.searchParams.get("label")?.trim() ?? "";
40
41 if (!host || !token) {
42 return null;
43 }
44
45 return {
46 host,
47 token,
48 label,
49 } satisfies HostedPairingRequest;
50}
51
52export function hasHostedPairingRequest(url: URL = new URL(window.location.href)): boolean {
53 return readHostedPairingRequest(url) !== null;
54}
55
56export function buildHostedPairingUrl(input: {
57 readonly host: string;
58 readonly token: string;
59 readonly label?: string | null;
60}): string {
61 const url = new URL("/pair", configuredHostedAppUrl());
62 url.searchParams.set("host", input.host);
63
64 const label = input.label?.trim();
65 if (label) {
66 url.searchParams.set("label", label);
67 }
68
69 return setPairingTokenOnUrl(url, input.token).toString();
70}
apps/web/src/localApi.test.ts+31
...@@ -178,16 +178,47 @@ function makeDesktopBridge(overrides: Partial<DesktopBridge> = {}): DesktopBridg...@@ -178,16 +178,47 @@ function makeDesktopBridge(overrides: Partial<DesktopBridge> = {}): DesktopBridg
178 getSavedEnvironmentSecret: async () => null,178 getSavedEnvironmentSecret: async () => null,
179 setSavedEnvironmentSecret: async () => true,179 setSavedEnvironmentSecret: async () => true,
180 removeSavedEnvironmentSecret: async () => undefined,180 removeSavedEnvironmentSecret: async () => undefined,
181 discoverSshHosts: async () => [],
182 ensureSshEnvironment: async () => {
183 throw new Error("ensureSshEnvironment not implemented in test");
184 },
185 disconnectSshEnvironment: async () => undefined,
186 fetchSshEnvironmentDescriptor: async () => {
187 throw new Error("fetchSshEnvironmentDescriptor not implemented in test");
188 },
189 bootstrapSshBearerSession: async () => {
190 throw new Error("bootstrapSshBearerSession not implemented in test");
191 },
192 fetchSshSessionState: async () => {
193 throw new Error("fetchSshSessionState not implemented in test");
194 },
195 issueSshWebSocketToken: async () => {
196 throw new Error("issueSshWebSocketToken not implemented in test");
197 },
198 onSshPasswordPrompt: () => () => undefined,
199 resolveSshPasswordPrompt: async () => undefined,
181 getServerExposureState: async () => ({200 getServerExposureState: async () => ({
182 mode: "local-only",201 mode: "local-only",
183 endpointUrl: null,202 endpointUrl: null,
184 advertisedHost: null,203 advertisedHost: null,
204 tailscaleServeEnabled: false,
205 tailscaleServePort: 443,
185 }),206 }),
186 setServerExposureMode: async () => ({207 setServerExposureMode: async () => ({
187 mode: "local-only",208 mode: "local-only",
188 endpointUrl: null,209 endpointUrl: null,
189 advertisedHost: null,210 advertisedHost: null,
211 tailscaleServeEnabled: false,
212 tailscaleServePort: 443,
213 }),
214 setTailscaleServeEnabled: async (input) => ({
215 mode: "local-only",
216 endpointUrl: null,
217 advertisedHost: null,
218 tailscaleServeEnabled: input.enabled,
219 tailscaleServePort: input.port ?? 443,
190 }),220 }),
221 getAdvertisedEndpoints: async () => [],
191 pickFolder: async () => null,222 pickFolder: async () => null,
192 confirm: async () => true,223 confirm: async () => true,
193 setTheme: async () => undefined,224 setTheme: async () => undefined,
apps/web/src/localApi.ts+38-9
...@@ -13,6 +13,7 @@ import {...@@ -13,6 +13,7 @@ import {
13 getPrimaryEnvironmentConnection,13 getPrimaryEnvironmentConnection,
14 resetEnvironmentServiceForTests,14 resetEnvironmentServiceForTests,
15} from "./environments/runtime";15} from "./environments/runtime";
16import { getPrimaryKnownEnvironment } from "./environments/primary";
16import { type WsRpcClient } from "./rpc/wsRpcClient";17import { type WsRpcClient } from "./rpc/wsRpcClient";
17import { showContextMenuFallback } from "./contextMenuFallback";18import { showContextMenuFallback } from "./contextMenuFallback";
18import {19import {
...@@ -27,7 +28,11 @@ import {...@@ -27,7 +28,11 @@ import {
2728
28let cachedApi: LocalApi | undefined;29let cachedApi: LocalApi | undefined;
2930
30export function createLocalApi(rpcClient: WsRpcClient): LocalApi {31function unavailableLocalBackendError(): Error {
32 return new Error("Local backend API is unavailable before a backend is paired.");
33}
34
35function createBrowserLocalApi(rpcClient?: WsRpcClient): LocalApi {
31 return {36 return {
32 dialogs: {37 dialogs: {
33 pickFolder: async (options) => {38 pickFolder: async (options) => {
...@@ -42,7 +47,10 @@ export function createLocalApi(rpcClient: WsRpcClient): LocalApi {...@@ -42,7 +47,10 @@ export function createLocalApi(rpcClient: WsRpcClient): LocalApi {
42 },47 },
43 },48 },
44 shell: {49 shell: {
45 openInEditor: (cwd, editor) => rpcClient.shell.openInEditor({ cwd, editor }),50 openInEditor: (cwd, editor) =>
51 rpcClient
52 ? rpcClient.shell.openInEditor({ cwd, editor })
53 : Promise.reject(unavailableLocalBackendError()),
46 openExternal: async (url) => {54 openExternal: async (url) => {
47 if (window.desktopBridge) {55 if (window.desktopBridge) {
48 const opened = await window.desktopBridge.openExternal(url);56 const opened = await window.desktopBridge.openExternal(url);
...@@ -111,16 +119,34 @@ export function createLocalApi(rpcClient: WsRpcClient): LocalApi {...@@ -111,16 +119,34 @@ export function createLocalApi(rpcClient: WsRpcClient): LocalApi {
111 },119 },
112 },120 },
113 server: {121 server: {
114 getConfig: rpcClient.server.getConfig,122 getConfig: () =>
115 refreshProviders: rpcClient.server.refreshProviders,123 rpcClient ? rpcClient.server.getConfig() : Promise.reject(unavailableLocalBackendError()),
116 upsertKeybinding: rpcClient.server.upsertKeybinding,124 refreshProviders: () =>
117 getSettings: rpcClient.server.getSettings,125 rpcClient
118 updateSettings: rpcClient.server.updateSettings,126 ? rpcClient.server.refreshProviders()
119 discoverSourceControl: rpcClient.server.discoverSourceControl,127 : Promise.reject(unavailableLocalBackendError()),
128 upsertKeybinding: (input) =>
129 rpcClient
130 ? rpcClient.server.upsertKeybinding(input)
131 : Promise.reject(unavailableLocalBackendError()),
132 getSettings: () =>
133 rpcClient ? rpcClient.server.getSettings() : Promise.reject(unavailableLocalBackendError()),
134 updateSettings: (patch) =>
135 rpcClient
136 ? rpcClient.server.updateSettings(patch)
137 : Promise.reject(unavailableLocalBackendError()),
138 discoverSourceControl: () =>
139 rpcClient
140 ? rpcClient.server.discoverSourceControl()
141 : Promise.reject(unavailableLocalBackendError()),
120 },142 },
121 };143 };
122}144}
123145
146export function createLocalApi(rpcClient: WsRpcClient): LocalApi {
147 return createBrowserLocalApi(rpcClient);
148}
149
124export function readLocalApi(): LocalApi | undefined {150export function readLocalApi(): LocalApi | undefined {
125 if (typeof window === "undefined") return undefined;151 if (typeof window === "undefined") return undefined;
126 if (cachedApi) return cachedApi;152 if (cachedApi) return cachedApi;
...@@ -130,7 +156,10 @@ export function readLocalApi(): LocalApi | undefined {...@@ -130,7 +156,10 @@ export function readLocalApi(): LocalApi | undefined {
130 return cachedApi;156 return cachedApi;
131 }157 }
132158
133 cachedApi = createLocalApi(getPrimaryEnvironmentConnection().client);159 const primaryEnvironment = getPrimaryKnownEnvironment();
160 cachedApi = primaryEnvironment
161 ? createLocalApi(getPrimaryEnvironmentConnection().client)
162 : createBrowserLocalApi();
134 return cachedApi;163 return cachedApi;
135}164}
136165
apps/web/src/routes/__root.tsx+80-15
...@@ -13,6 +13,7 @@ import { QueryClient, useQueryClient } from "@tanstack/react-query";...@@ -13,6 +13,7 @@ import { QueryClient, useQueryClient } from "@tanstack/react-query";
13import { APP_DISPLAY_NAME } from "../branding";13import { APP_DISPLAY_NAME } from "../branding";
14import { AppSidebarLayout } from "../components/AppSidebarLayout";14import { AppSidebarLayout } from "../components/AppSidebarLayout";
15import { CommandPalette } from "../components/CommandPalette";15import { CommandPalette } from "../components/CommandPalette";
16import { SshPasswordPromptDialog } from "../components/desktop/SshPasswordPromptDialog";
16import {17import {
17 SlowRpcAckToastCoordinator,18 SlowRpcAckToastCoordinator,
18 WebSocketConnectionCoordinator,19 WebSocketConnectionCoordinator,
...@@ -46,19 +47,41 @@ import { syncBrowserChromeTheme } from "../hooks/useTheme";...@@ -46,19 +47,41 @@ import { syncBrowserChromeTheme } from "../hooks/useTheme";
46import {47import {
47 ensureEnvironmentConnectionBootstrapped,48 ensureEnvironmentConnectionBootstrapped,
48 getPrimaryEnvironmentConnection,49 getPrimaryEnvironmentConnection,
50 listSavedEnvironmentRecords,
51 waitForSavedEnvironmentRegistryHydration,
49 startEnvironmentConnectionService,52 startEnvironmentConnectionService,
53 useSavedEnvironmentRegistryStore,
50} from "../environments/runtime";54} from "../environments/runtime";
51import { configureClientTracing } from "../observability/clientTracing";55import { configureClientTracing } from "../observability/clientTracing";
52import {56import {
53 ensurePrimaryEnvironmentReady,57 ensurePrimaryEnvironmentReady,
58 getPrimaryKnownEnvironment,
54 resolveInitialServerAuthGateState,59 resolveInitialServerAuthGateState,
55 updatePrimaryEnvironmentDescriptor,60 updatePrimaryEnvironmentDescriptor,
56} from "../environments/primary";61} from "../environments/primary";
62import { hasHostedPairingRequest, isHostedStaticApp } from "../hostedPairing";
5763
58export const Route = createRootRouteWithContext<{64export const Route = createRootRouteWithContext<{
59 queryClient: QueryClient;65 queryClient: QueryClient;
60}>()({66}>()({
61 beforeLoad: async () => {67 beforeLoad: async ({ location }) => {
68 if (location.pathname === "/pair" && hasHostedPairingRequest(new URL(window.location.href))) {
69 return {
70 authGateState: {
71 status: "hosted-pairing",
72 } as const,
73 };
74 }
75
76 if (isHostedStaticApp(new URL(window.location.href))) {
77 await waitForSavedEnvironmentRegistryHydration();
78 return {
79 authGateState: {
80 status: "hosted-static",
81 } as const,
82 };
83 }
84
62 const [, authGateState] = await Promise.all([85 const [, authGateState] = await Promise.all([
63 ensurePrimaryEnvironmentReady(),86 ensurePrimaryEnvironmentReady(),
64 resolveInitialServerAuthGateState(),87 resolveInitialServerAuthGateState(),
...@@ -77,6 +100,7 @@ export const Route = createRootRouteWithContext<{...@@ -77,6 +100,7 @@ export const Route = createRootRouteWithContext<{
77function RootRouteView() {100function RootRouteView() {
78 const pathname = useLocation({ select: (location) => location.pathname });101 const pathname = useLocation({ select: (location) => location.pathname });
79 const { authGateState } = Route.useRouteContext();102 const { authGateState } = Route.useRouteContext();
103 const primaryEnvironmentAuthenticated = authGateState.status === "authenticated";
80104
81 useEffect(() => {105 useEffect(() => {
82 const frame = window.requestAnimationFrame(() => {106 const frame = window.requestAnimationFrame(() => {
...@@ -91,30 +115,65 @@ function RootRouteView() {...@@ -91,30 +115,65 @@ function RootRouteView() {
91 return <Outlet />;115 return <Outlet />;
92 }116 }
93117
94 if (authGateState.status !== "authenticated") {118 if (authGateState.status !== "authenticated" && authGateState.status !== "hosted-static") {
95 return <Outlet />;119 return <Outlet />;
96 }120 }
121
122 const appShell = (
123 <CommandPalette>
124 <AppSidebarLayout>
125 <Outlet />
126 </AppSidebarLayout>
127 </CommandPalette>
128 );
129
97 return (130 return (
98 <ToastProvider>131 <ToastProvider>
99 <AnchoredToastProvider>132 <AnchoredToastProvider>
100 <AuthenticatedTracingBootstrap />133 {primaryEnvironmentAuthenticated ? <AuthenticatedTracingBootstrap /> : null}
101 <ServerStateBootstrap />134 {primaryEnvironmentAuthenticated ? <ServerStateBootstrap /> : null}
102 <EnvironmentConnectionManagerBootstrap />135 <EnvironmentConnectionManagerBootstrap />
103 <EventRouter />136 <SshPasswordPromptDialog />
104 <WebSocketConnectionCoordinator />137 <HostedStaticEnvironmentBootstrap />
105 <SlowRpcAckToastCoordinator />138 {primaryEnvironmentAuthenticated ? <EventRouter /> : null}
106 <WebSocketConnectionSurface>139 {primaryEnvironmentAuthenticated ? <WebSocketConnectionCoordinator /> : null}
107 <CommandPalette>140 {primaryEnvironmentAuthenticated ? <SlowRpcAckToastCoordinator /> : null}
108 <AppSidebarLayout>141 {primaryEnvironmentAuthenticated ? (
109 <Outlet />142 <WebSocketConnectionSurface>{appShell}</WebSocketConnectionSurface>
110 </AppSidebarLayout>143 ) : (
111 </CommandPalette>144 appShell
112 </WebSocketConnectionSurface>145 )}
113 </AnchoredToastProvider>146 </AnchoredToastProvider>
114 </ToastProvider>147 </ToastProvider>
115 );148 );
116}149}
117150
151function HostedStaticEnvironmentBootstrap() {
152 const savedEnvironmentCount = useSavedEnvironmentRegistryStore(
153 (state) => Object.keys(state.byId).length,
154 );
155
156 useEffect(() => {
157 if (getPrimaryKnownEnvironment()) {
158 return;
159 }
160
161 const currentActiveEnvironmentId = useStore.getState().activeEnvironmentId;
162 if (currentActiveEnvironmentId) {
163 return;
164 }
165
166 const firstSavedEnvironment = listSavedEnvironmentRecords()[0];
167 if (!firstSavedEnvironment) {
168 return;
169 }
170
171 useStore.getState().setActiveEnvironmentId(firstSavedEnvironment.environmentId);
172 }, [savedEnvironmentCount]);
173
174 return null;
175}
176
118function RootRouteErrorView({ error, reset }: ErrorComponentProps) {177function RootRouteErrorView({ error, reset }: ErrorComponentProps) {
119 const message = errorMessage(error);178 const message = errorMessage(error);
120 const details = errorDetails(error);179 const details = errorDetails(error);
...@@ -187,7 +246,13 @@ function errorDetails(error: unknown): string {...@@ -187,7 +246,13 @@ function errorDetails(error: unknown): string {
187}246}
188247
189function ServerStateBootstrap() {248function ServerStateBootstrap() {
190 useEffect(() => startServerStateSync(getPrimaryEnvironmentConnection().client.server), []);249 useEffect(() => {
250 if (!getPrimaryKnownEnvironment()) {
251 return;
252 }
253
254 return startServerStateSync(getPrimaryEnvironmentConnection().client.server);
255 }, []);
191256
192 return null;257 return null;
193}258}
apps/web/src/routes/_chat.index.tsx+55
...@@ -1,11 +1,66 @@...@@ -1,11 +1,66 @@
1import { createFileRoute } from "@tanstack/react-router";1import { createFileRoute } from "@tanstack/react-router";
2import { LinkIcon, PlusIcon } from "lucide-react";
23
3import { NoActiveThreadState } from "../components/NoActiveThreadState";4import { NoActiveThreadState } from "../components/NoActiveThreadState";
5import { Button } from "../components/ui/button";
6import { Empty, EmptyDescription, EmptyHeader, EmptyTitle } from "../components/ui/empty";
7import { SidebarInset, SidebarTrigger } from "../components/ui/sidebar";
8import { useSavedEnvironmentRegistryStore } from "../environments/runtime";
9import { APP_DISPLAY_NAME } from "~/branding";
410
5function ChatIndexRouteView() {11function ChatIndexRouteView() {
12 const { authGateState } = Route.useRouteContext();
13 const savedEnvironmentCount = useSavedEnvironmentRegistryStore(
14 (state) => Object.keys(state.byId).length,
15 );
16
17 if (authGateState.status === "hosted-static" && savedEnvironmentCount === 0) {
18 return <HostedStaticOnboardingState />;
19 }
20
6 return <NoActiveThreadState />;21 return <NoActiveThreadState />;
7}22}
823
9export const Route = createFileRoute("/_chat/")({24export const Route = createFileRoute("/_chat/")({
10 component: ChatIndexRouteView,25 component: ChatIndexRouteView,
11});26});
27
28function HostedStaticOnboardingState() {
29 return (
30 <SidebarInset className="h-dvh min-h-0 overflow-hidden overscroll-y-none bg-background text-foreground">
31 <div className="flex min-h-0 min-w-0 flex-1 flex-col overflow-x-hidden bg-background">
32 <header className="border-b border-border px-3 py-2 sm:px-5 sm:py-3">
33 <div className="flex items-center gap-2">
34 <SidebarTrigger className="size-7 shrink-0 md:hidden" />
35 <span className="text-sm font-medium text-foreground md:text-muted-foreground/60">
36 {APP_DISPLAY_NAME}
37 </span>
38 </div>
39 </header>
40
41 <Empty className="flex-1">
42 <div className="w-full max-w-xl rounded-3xl border border-border/55 bg-card/20 px-8 py-12 shadow-sm/5">
43 <EmptyHeader className="max-w-none">
44 <div className="mx-auto mb-5 flex size-11 items-center justify-center rounded-xl border border-border/70 bg-background/70 text-muted-foreground">
45 <LinkIcon className="size-5" />
46 </div>
47 <EmptyTitle className="text-foreground text-xl">
48 Connect an environment to get started
49 </EmptyTitle>
50 <EmptyDescription className="mt-2 text-sm leading-relaxed text-muted-foreground/78">
51 Open a pairing link from your T3 Code desktop app or add a reachable backend
52 manually. Your saved environments stay in this browser.
53 </EmptyDescription>
54 <div className="mt-6 flex justify-center">
55 <Button render={<a href="/settings/connections" />} size="sm">
56 <PlusIcon className="size-4" />
57 Add environment
58 </Button>
59 </div>
60 </EmptyHeader>
61 </div>
62 </Empty>
63 </div>
64 </SidebarInset>
65 );
66}
apps/web/src/routes/_chat.tsx+4-1
...@@ -108,7 +108,10 @@ function ChatRouteLayout() {...@@ -108,7 +108,10 @@ function ChatRouteLayout() {
108108
109export const Route = createFileRoute("/_chat")({109export const Route = createFileRoute("/_chat")({
110 beforeLoad: async ({ context }) => {110 beforeLoad: async ({ context }) => {
111 if (context.authGateState.status !== "authenticated") {111 if (
112 context.authGateState.status !== "authenticated" &&
113 context.authGateState.status !== "hosted-static"
114 ) {
112 throw redirect({ to: "/pair", replace: true });115 throw redirect({ to: "/pair", replace: true });
113 }116 }
114 },117 },
apps/web/src/routes/pair.tsx+16-2
...@@ -1,11 +1,21 @@...@@ -1,11 +1,21 @@
1import { createFileRoute, redirect, useNavigate } from "@tanstack/react-router";1import { createFileRoute, redirect, useNavigate } from "@tanstack/react-router";
22
3import { PairingPendingSurface, PairingRouteSurface } from "../components/auth/PairingRouteSurface";3import {
4 HostedPairingRouteSurface,
5 PairingPendingSurface,
6 PairingRouteSurface,
7} from "../components/auth/PairingRouteSurface";
48
5export const Route = createFileRoute("/pair")({9export const Route = createFileRoute("/pair")({
6 beforeLoad: async ({ context }) => {10 beforeLoad: async ({ context }) => {
7 const { authGateState } = context;11 const { authGateState } = context;
8 if (authGateState.status === "authenticated") {12 if (authGateState.status === "hosted-pairing") {
13 return {
14 authGateState,
15 };
16 }
17
18 if (authGateState.status === "authenticated" || authGateState.status === "hosted-static") {
9 throw redirect({ to: "/", replace: true });19 throw redirect({ to: "/", replace: true });
10 }20 }
11 return {21 return {
...@@ -24,6 +34,10 @@ function PairRouteView() {...@@ -24,6 +34,10 @@ function PairRouteView() {
24 return null;34 return null;
25 }35 }
2636
37 if (authGateState.status === "hosted-pairing") {
38 return <HostedPairingRouteSurface />;
39 }
40
27 return (41 return (
28 <PairingRouteSurface42 <PairingRouteSurface
29 auth={authGateState.auth}43 auth={authGateState.auth}
apps/web/src/routes/settings.tsx+4-1
...@@ -104,7 +104,10 @@ function SettingsRouteLayout() {...@@ -104,7 +104,10 @@ function SettingsRouteLayout() {
104104
105export const Route = createFileRoute("/settings")({105export const Route = createFileRoute("/settings")({
106 beforeLoad: async ({ context, location }) => {106 beforeLoad: async ({ context, location }) => {
107 if (context.authGateState.status !== "authenticated") {107 if (
108 context.authGateState.status !== "authenticated" &&
109 context.authGateState.status !== "hosted-static"
110 ) {
108 throw redirect({ to: "/pair", replace: true });111 throw redirect({ to: "/pair", replace: true });
109 }112 }
110113
apps/web/src/rpc/protocol.ts+159-27
...@@ -14,15 +14,45 @@ import {...@@ -14,15 +14,45 @@ import {
14 recordWsConnectionClosed,14 recordWsConnectionClosed,
15 recordWsConnectionErrored,15 recordWsConnectionErrored,
16 recordWsConnectionOpened,16 recordWsConnectionOpened,
17 type WsConnectionMetadata,
17 WS_RECONNECT_MAX_RETRIES,18 WS_RECONNECT_MAX_RETRIES,
18} from "./wsConnectionState";19} from "./wsConnectionState";
1920
21export interface WsProtocolCloseContext {
22 readonly intentional: boolean;
23}
24
20export interface WsProtocolLifecycleHandlers {25export interface WsProtocolLifecycleHandlers {
26 readonly getConnectionLabel?: () => string | null;
27 readonly getVersionMismatchHint?: () => string | null;
28 readonly isCloseIntentional?: () => boolean;
21 readonly isActive?: () => boolean;29 readonly isActive?: () => boolean;
22 readonly onAttempt?: (socketUrl: string) => void;30 readonly onAttempt?: (socketUrl: string) => void;
23 readonly onOpen?: () => void;31 readonly onOpen?: () => void;
32 readonly onHeartbeatPing?: () => void;
33 readonly onHeartbeatPong?: () => void;
34 readonly onHeartbeatTimeout?: () => void;
35 readonly onRequestStart?: (info: {
36 readonly id: string;
37 readonly tag: string;
38 readonly stream: boolean;
39 }) => void;
40 readonly onRequestChunk?: (info: {
41 readonly id: string;
42 readonly tag: string;
43 readonly chunkCount: number;
44 }) => void;
45 readonly onRequestExit?: (info: {
46 readonly id: string;
47 readonly tag: string;
48 readonly stream: boolean;
49 }) => void;
50 readonly onRequestInterrupt?: (info: { readonly id: string; readonly tag?: string }) => void;
24 readonly onError?: (message: string) => void;51 readonly onError?: (message: string) => void;
25 readonly onClose?: (details: { readonly code: number; readonly reason: string }) => void;52 readonly onClose?: (
53 details: { readonly code: number; readonly reason: string },
54 context: WsProtocolCloseContext,
55 ) => void;
26}56}
2757
28export const makeWsRpcProtocolClient = RpcClient.make(WsRpcGroup);58export const makeWsRpcProtocolClient = RpcClient.make(WsRpcGroup);
...@@ -48,27 +78,47 @@ function resolveWsRpcSocketUrl(rawUrl: string): string {...@@ -48,27 +78,47 @@ function resolveWsRpcSocketUrl(rawUrl: string): string {
48 return resolved.toString();78 return resolved.toString();
49}79}
5080
51function defaultLifecycleHandlers(): Required<WsProtocolLifecycleHandlers> {81function resolveConnectionMetadata(handlers?: WsProtocolLifecycleHandlers): WsConnectionMetadata {
82 return {
83 connectionLabel: handlers?.getConnectionLabel?.() ?? null,
84 versionMismatchHint: handlers?.getVersionMismatchHint?.() ?? null,
85 };
86}
87
88type ComposedWsProtocolLifecycleHandlers = Required<
89 Pick<WsProtocolLifecycleHandlers, "isActive" | "onAttempt" | "onOpen" | "onError" | "onClose">
90>;
91
92function defaultLifecycleHandlers(
93 handlers?: WsProtocolLifecycleHandlers,
94): ComposedWsProtocolLifecycleHandlers {
52 return {95 return {
53 isActive: () => true,96 isActive: () => true,
54 onAttempt: recordWsConnectionAttempt,97 onAttempt: (socketUrl) => {
55 onOpen: recordWsConnectionOpened,98 recordWsConnectionAttempt(socketUrl, resolveConnectionMetadata(handlers));
99 },
100 onOpen: () => {
101 recordWsConnectionOpened(resolveConnectionMetadata(handlers));
102 },
56 onError: (message) => {103 onError: (message) => {
57 clearAllTrackedRpcRequests();104 clearAllTrackedRpcRequests();
58 recordWsConnectionErrored(message);105 recordWsConnectionErrored(message, resolveConnectionMetadata(handlers));
59 },106 },
60 onClose: (details) => {107 onClose: (details, context) => {
61 clearAllTrackedRpcRequests();108 clearAllTrackedRpcRequests();
62 recordWsConnectionClosed(details);109 if (context.intentional) {
110 return;
111 }
112 recordWsConnectionClosed(details, resolveConnectionMetadata(handlers));
63 },113 },
64 };114 };
65}115}
66116
67function composeLifecycleHandlers(117function composeLifecycleHandlers(
68 handlers?: WsProtocolLifecycleHandlers,118 handlers?: WsProtocolLifecycleHandlers,
69): Required<WsProtocolLifecycleHandlers> {119): ComposedWsProtocolLifecycleHandlers {
70 const defaults = defaultLifecycleHandlers();120 const defaults = defaultLifecycleHandlers(handlers);
71 const isActive = handlers?.isActive ?? (() => true);121 const isActive = handlers?.isActive ?? defaults.isActive;
72122
73 return {123 return {
74 isActive,124 isActive,
...@@ -93,12 +143,12 @@ function composeLifecycleHandlers(...@@ -93,12 +143,12 @@ function composeLifecycleHandlers(
93 defaults.onError(message);143 defaults.onError(message);
94 handlers?.onError?.(message);144 handlers?.onError?.(message);
95 },145 },
96 onClose: (details) => {146 onClose: (details, context) => {
97 if (!isActive()) {147 if (!isActive()) {
98 return;148 return;
99 }149 }
100 defaults.onClose(details);150 defaults.onClose(details, context);
101 handlers?.onClose?.(details);151 handlers?.onClose?.(details, context);
102 },152 },
103 };153 };
104}154}
...@@ -144,10 +194,15 @@ export function createWsRpcProtocolLayer(...@@ -144,10 +194,15 @@ export function createWsRpcProtocolLayer(
144 socket.addEventListener(194 socket.addEventListener(
145 "close",195 "close",
146 (event) => {196 (event) => {
147 lifecycle.onClose({197 lifecycle.onClose(
148 code: event.code,198 {
149 reason: event.reason,199 code: event.code,
150 });200 reason: event.reason,
201 },
202 {
203 intentional: handlers?.isCloseIntentional?.() ?? false,
204 },
205 );
151 },206 },
152 { once: true },207 { once: true },
153 );208 );
...@@ -172,22 +227,99 @@ export function createWsRpcProtocolLayer(...@@ -172,22 +227,99 @@ export function createWsRpcProtocolLayer(
172 ...protocol,227 ...protocol,
173 run: (clientId, writeResponse) =>228 run: (clientId, writeResponse) =>
174 protocol.run(clientId, (response) => {229 protocol.run(clientId, (response) => {
175 if (response._tag === "Chunk" || response._tag === "Exit") {230 if (response._tag === "ClientProtocolError" || response._tag === "Defect") {
176 acknowledgeRpcRequest(response.requestId);
177 } else if (response._tag === "ClientProtocolError" || response._tag === "Defect") {
178 clearAllTrackedRpcRequests();231 clearAllTrackedRpcRequests();
179 }232 }
180 return writeResponse(response);233 return writeResponse(response);
181 }),234 }),
182 send: (clientId, request, transferables) => {
183 if (request._tag === "Request") {
184 trackRpcRequestSent(request.id, request.tag);
185 }
186 return protocol.send(clientId, request, transferables);
187 },
188 }),235 }),
189 ),236 ),
190 );237 );
238 const requestHooksLayer = Layer.succeed(
239 RpcClient.RequestHooks,
240 RpcClient.RequestHooks.of({
241 onRequestStart: (info) =>
242 Effect.sync(() => {
243 if (!lifecycle.isActive()) {
244 return;
245 }
246 handlers?.onRequestStart?.({
247 id: String(info.id),
248 tag: info.tag,
249 stream: info.stream,
250 });
251 trackRpcRequestSent(String(info.id), info.tag);
252 }),
253 onRequestChunk: (info) =>
254 Effect.sync(() => {
255 if (!lifecycle.isActive()) {
256 return;
257 }
258 handlers?.onRequestChunk?.({
259 id: String(info.id),
260 tag: info.tag,
261 chunkCount: info.chunkCount,
262 });
263 acknowledgeRpcRequest(String(info.id));
264 }),
265 onRequestExit: (info) =>
266 Effect.sync(() => {
267 if (!lifecycle.isActive()) {
268 return;
269 }
270 handlers?.onRequestExit?.({
271 id: String(info.id),
272 tag: info.tag,
273 stream: info.stream,
274 });
275 acknowledgeRpcRequest(String(info.id));
276 }),
277 onRequestInterrupt: (info) =>
278 Effect.sync(() => {
279 if (!lifecycle.isActive()) {
280 return;
281 }
282 handlers?.onRequestInterrupt?.({
283 id: String(info.id),
284 ...(info.tag === undefined ? {} : { tag: info.tag }),
285 });
286 acknowledgeRpcRequest(String(info.id));
287 }),
288 }),
289 );
290 const connectionHooksLayer = Layer.succeed(
291 RpcClient.ConnectionHooks,
292 RpcClient.ConnectionHooks.of({
293 onConnect: Effect.void,
294 onDisconnect: Effect.void,
295 onPing: Effect.sync(() => {
296 if (lifecycle.isActive()) {
297 handlers?.onHeartbeatPing?.();
298 }
299 }),
300 onPong: Effect.sync(() => {
301 if (lifecycle.isActive()) {
302 handlers?.onHeartbeatPong?.();
303 }
304 }),
305 onPingTimeout: Effect.sync(() => {
306 if (lifecycle.isActive()) {
307 clearAllTrackedRpcRequests();
308 recordWsConnectionErrored(
309 "WebSocket heartbeat timed out.",
310 resolveConnectionMetadata(handlers),
311 );
312 handlers?.onHeartbeatTimeout?.();
313 }
314 }),
315 }),
316 );
191317
192 return protocolLayer.pipe(Layer.provide(Layer.mergeAll(socketLayer, RpcSerialization.layerJson)));318 return Layer.mergeAll(
319 protocolLayer.pipe(
320 Layer.provide(Layer.mergeAll(socketLayer, RpcSerialization.layerJson, connectionHooksLayer)),
321 ),
322 requestHooksLayer,
323 connectionHooksLayer,
324 );
193}325}
apps/web/src/rpc/serverState.test.ts+23
...@@ -10,10 +10,12 @@ import {...@@ -10,10 +10,12 @@ import {
10 type ServerLifecycleStreamEvent,10 type ServerLifecycleStreamEvent,
11 type ServerProvider,11 type ServerProvider,
12} from "@t3tools/contracts";12} from "@t3tools/contracts";
13import { DEFAULT_RESOLVED_KEYBINDINGS } from "@t3tools/shared/keybindings";
13import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";14import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
1415
15import {16import {
16 getServerConfig,17 getServerConfig,
18 getServerKeybindings,
17 onProvidersUpdated,19 onProvidersUpdated,
18 onServerConfigUpdated,20 onServerConfigUpdated,
19 onWelcome,21 onWelcome,
...@@ -141,6 +143,11 @@ afterEach(() => {...@@ -141,6 +143,11 @@ afterEach(() => {
141});143});
142144
143describe("serverState", () => {145describe("serverState", () => {
146 it("uses default keybindings before a server config snapshot is available", () => {
147 expect(getServerConfig()).toBeNull();
148 expect(getServerKeybindings()).toEqual(DEFAULT_RESOLVED_KEYBINDINGS);
149 });
150
144 it("bootstraps the server config snapshot and replays it to late subscribers", async () => {151 it("bootstraps the server config snapshot and replays it to late subscribers", async () => {
145 serverApi.getConfig.mockResolvedValueOnce(baseServerConfig);152 serverApi.getConfig.mockResolvedValueOnce(baseServerConfig);
146153
...@@ -271,10 +278,25 @@ describe("serverState", () => {...@@ -271,10 +278,25 @@ describe("serverState", () => {
271 },278 },
272 ];279 ];
273280
281 const nextKeybindings = [
282 {
283 command: "commandPalette.toggle",
284 shortcut: {
285 key: "p",
286 metaKey: false,
287 ctrlKey: false,
288 shiftKey: false,
289 altKey: false,
290 modKey: true,
291 },
292 },
293 ] as const;
294
274 emitServerConfigEvent({295 emitServerConfigEvent({
275 version: 1,296 version: 1,
276 type: "keybindingsUpdated",297 type: "keybindingsUpdated",
277 payload: {298 payload: {
299 keybindings: nextKeybindings,
278 issues: [{ kind: "keybindings.malformed-config", message: "bad json" }],300 issues: [{ kind: "keybindings.malformed-config", message: "bad json" }],
279 },301 },
280 });302 });
...@@ -299,6 +321,7 @@ describe("serverState", () => {...@@ -299,6 +321,7 @@ describe("serverState", () => {
299 await waitFor(() => {321 await waitFor(() => {
300 expect(getServerConfig()).toEqual({322 expect(getServerConfig()).toEqual({
301 ...baseServerConfig,323 ...baseServerConfig,
324 keybindings: nextKeybindings,
302 issues: [{ kind: "keybindings.malformed-config", message: "bad json" }],325 issues: [{ kind: "keybindings.malformed-config", message: "bad json" }],
303 providers: nextProviders,326 providers: nextProviders,
304 settings: {327 settings: {
apps/web/src/rpc/serverState.ts+8-2
...@@ -10,6 +10,7 @@ import {...@@ -10,6 +10,7 @@ import {
10 type ServerProviderUpdatedPayload,10 type ServerProviderUpdatedPayload,
11 type ServerSettings,11 type ServerSettings,
12} from "@t3tools/contracts";12} from "@t3tools/contracts";
13import { DEFAULT_RESOLVED_KEYBINDINGS } from "@t3tools/shared/keybindings";
13import { Atom } from "effect/unstable/reactivity";14import { Atom } from "effect/unstable/reactivity";
14import { useCallback, useRef } from "react";15import { useCallback, useRef } from "react";
1516
...@@ -42,12 +43,12 @@ function toServerConfigUpdatedPayload(config: ServerConfig): ServerConfigUpdated...@@ -42,12 +43,12 @@ function toServerConfigUpdatedPayload(config: ServerConfig): ServerConfigUpdated
42}43}
4344
44const EMPTY_AVAILABLE_EDITORS: ReadonlyArray<EditorId> = [];45const EMPTY_AVAILABLE_EDITORS: ReadonlyArray<EditorId> = [];
45const EMPTY_KEYBINDINGS: ServerConfig["keybindings"] = [];
46const EMPTY_SERVER_PROVIDERS: ReadonlyArray<ServerProvider> = [];46const EMPTY_SERVER_PROVIDERS: ReadonlyArray<ServerProvider> = [];
4747
48const selectAvailableEditors = (config: ServerConfig | null): ReadonlyArray<EditorId> =>48const selectAvailableEditors = (config: ServerConfig | null): ReadonlyArray<EditorId> =>
49 config?.availableEditors ?? EMPTY_AVAILABLE_EDITORS;49 config?.availableEditors ?? EMPTY_AVAILABLE_EDITORS;
50const selectKeybindings = (config: ServerConfig | null) => config?.keybindings ?? EMPTY_KEYBINDINGS;50const selectKeybindings = (config: ServerConfig | null) =>
51 config?.keybindings ?? DEFAULT_RESOLVED_KEYBINDINGS;
51const selectKeybindingsConfigPath = (config: ServerConfig | null) =>52const selectKeybindingsConfigPath = (config: ServerConfig | null) =>
52 config?.keybindingsConfigPath ?? null;53 config?.keybindingsConfigPath ?? null;
53const selectObservability = (config: ServerConfig | null) => config?.observability ?? null;54const selectObservability = (config: ServerConfig | null) => config?.observability ?? null;
...@@ -74,6 +75,10 @@ export function getServerConfig(): ServerConfig | null {...@@ -74,6 +75,10 @@ export function getServerConfig(): ServerConfig | null {
74 return appAtomRegistry.get(serverConfigAtom);75 return appAtomRegistry.get(serverConfigAtom);
75}76}
7677
78export function getServerKeybindings(): ServerConfig["keybindings"] {
79 return selectKeybindings(getServerConfig());
80}
81
77export function getServerConfigUpdatedNotification(): ServerConfigUpdatedNotification | null {82export function getServerConfigUpdatedNotification(): ServerConfigUpdatedNotification | null {
78 return appAtomRegistry.get(serverConfigUpdatedAtom);83 return appAtomRegistry.get(serverConfigUpdatedAtom);
79}84}
...@@ -97,6 +102,7 @@ export function applyServerConfigEvent(event: ServerConfigStreamEvent): void {...@@ -97,6 +102,7 @@ export function applyServerConfigEvent(event: ServerConfigStreamEvent): void {
97 }102 }
98 const nextConfig = {103 const nextConfig = {
99 ...latestServerConfig,104 ...latestServerConfig,
105 keybindings: event.payload.keybindings,
100 issues: event.payload.issues,106 issues: event.payload.issues,
101 } satisfies ServerConfig;107 } satisfies ServerConfig;
102 resolveServerConfig(nextConfig);108 resolveServerConfig(nextConfig);
apps/web/src/rpc/wsConnectionState.test.ts+33-1
...@@ -45,7 +45,9 @@ describe("wsConnectionState", () => {...@@ -45,7 +45,9 @@ describe("wsConnectionState", () => {
45 });45 });
4646
47 it("schedules the next retry after a failed websocket attempt", () => {47 it("schedules the next retry after a failed websocket attempt", () => {
48 recordWsConnectionAttempt("ws://localhost:3020/ws");48 recordWsConnectionAttempt("ws://localhost:3020/ws", {
49 connectionLabel: "Remote Mac",
50 });
49 recordWsConnectionErrored("Unable to connect to the T3 server WebSocket.");51 recordWsConnectionErrored("Unable to connect to the T3 server WebSocket.");
5052
51 const firstRetryDelayMs = getWsReconnectDelayMsForRetry(0);53 const firstRetryDelayMs = getWsReconnectDelayMsForRetry(0);
...@@ -54,12 +56,42 @@ describe("wsConnectionState", () => {...@@ -54,12 +56,42 @@ describe("wsConnectionState", () => {
54 }56 }
5557
56 expect(getWsConnectionStatus()).toMatchObject({58 expect(getWsConnectionStatus()).toMatchObject({
59 connectionLabel: "Remote Mac",
57 nextRetryAt: new Date(Date.now() + firstRetryDelayMs).toISOString(),60 nextRetryAt: new Date(Date.now() + firstRetryDelayMs).toISOString(),
58 reconnectAttemptCount: 1,61 reconnectAttemptCount: 1,
59 reconnectPhase: "waiting",62 reconnectPhase: "waiting",
60 });63 });
61 });64 });
6265
66 it("adds a version mismatch hint to websocket errors when metadata includes one", () => {
67 recordWsConnectionAttempt("ws://localhost:3020/ws", {
68 connectionLabel: "Remote Mac",
69 });
70 recordWsConnectionErrored("Unable to connect to the T3 server WebSocket.", {
71 versionMismatchHint: "Version mismatch. Try syncing the client and server.",
72 });
73
74 expect(getWsConnectionStatus()).toMatchObject({
75 lastError:
76 "Unable to connect to the T3 server WebSocket. Hint: Version mismatch. Try syncing the client and server.",
77 });
78 });
79
80 it("adds a version mismatch hint to websocket close reasons when metadata includes one", () => {
81 recordWsConnectionAttempt("ws://localhost:3020/ws");
82 recordWsConnectionOpened();
83 recordWsConnectionClosed(
84 { code: 1006, reason: "socket closed" },
85 {
86 versionMismatchHint: "Version mismatch. Try syncing the client and server.",
87 },
88 );
89
90 expect(getWsConnectionStatus()).toMatchObject({
91 closeReason: "socket closed Hint: Version mismatch. Try syncing the client and server.",
92 });
93 });
94
63 it("marks the reconnect cycle as exhausted after the final attempt fails", () => {95 it("marks the reconnect cycle as exhausted after the final attempt fails", () => {
64 for (let attempt = 0; attempt < WS_RECONNECT_MAX_ATTEMPTS; attempt += 1) {96 for (let attempt = 0; attempt < WS_RECONNECT_MAX_ATTEMPTS; attempt += 1) {
65 recordWsConnectionAttempt("ws://localhost:3020/ws");97 recordWsConnectionAttempt("ws://localhost:3020/ws");
apps/web/src/rpc/wsConnectionState.ts+57-12
...@@ -16,6 +16,7 @@ export interface WsConnectionStatus {...@@ -16,6 +16,7 @@ export interface WsConnectionStatus {
16 readonly attemptCount: number;16 readonly attemptCount: number;
17 readonly closeCode: number | null;17 readonly closeCode: number | null;
18 readonly closeReason: string | null;18 readonly closeReason: string | null;
19 readonly connectionLabel: string | null;
19 readonly connectedAt: string | null;20 readonly connectedAt: string | null;
20 readonly disconnectedAt: string | null;21 readonly disconnectedAt: string | null;
21 readonly hasConnected: boolean;22 readonly hasConnected: boolean;
...@@ -34,6 +35,7 @@ const INITIAL_WS_CONNECTION_STATUS = Object.freeze<WsConnectionStatus>({...@@ -34,6 +35,7 @@ const INITIAL_WS_CONNECTION_STATUS = Object.freeze<WsConnectionStatus>({
34 attemptCount: 0,35 attemptCount: 0,
35 closeCode: null,36 closeCode: null,
36 closeReason: null,37 closeReason: null,
38 connectionLabel: null,
37 connectedAt: null,39 connectedAt: null,
38 disconnectedAt: null,40 disconnectedAt: null,
39 hasConnected: false,41 hasConnected: false,
...@@ -65,6 +67,16 @@ function updateWsConnectionStatus(...@@ -65,6 +67,16 @@ function updateWsConnectionStatus(
65 return nextStatus;67 return nextStatus;
66}68}
6769
70export interface WsConnectionMetadata {
71 readonly connectionLabel?: string | null;
72 readonly versionMismatchHint?: string | null;
73}
74
75function normalizeConnectionLabel(label: string | null | undefined): string | null {
76 const normalized = label?.trim();
77 return normalized ? normalized : null;
78}
79
68export function getWsConnectionStatus(): WsConnectionStatus {80export function getWsConnectionStatus(): WsConnectionStatus {
69 return appAtomRegistry.get(wsConnectionStatusAtom);81 return appAtomRegistry.get(wsConnectionStatusAtom);
70}82}
...@@ -85,10 +97,15 @@ export function getWsConnectionUiState(status: WsConnectionStatus): WsConnection...@@ -85,10 +97,15 @@ export function getWsConnectionUiState(status: WsConnectionStatus): WsConnection
85 return "reconnecting";97 return "reconnecting";
86}98}
8799
88export function recordWsConnectionAttempt(socketUrl: string): WsConnectionStatus {100export function recordWsConnectionAttempt(
101 socketUrl: string,
102 metadata?: WsConnectionMetadata,
103): WsConnectionStatus {
104 const connectionLabel = normalizeConnectionLabel(metadata?.connectionLabel);
89 return updateWsConnectionStatus((current) => ({105 return updateWsConnectionStatus((current) => ({
90 ...current,106 ...current,
91 attemptCount: current.attemptCount + 1,107 attemptCount: current.attemptCount + 1,
108 connectionLabel: connectionLabel ?? current.connectionLabel,
92 nextRetryAt: null,109 nextRetryAt: null,
93 phase: "connecting",110 phase: "connecting",
94 reconnectAttemptCount: current.phase === "connected" ? 1 : current.reconnectAttemptCount + 1,111 reconnectAttemptCount: current.phase === "connected" ? 1 : current.reconnectAttemptCount + 1,
...@@ -97,11 +114,13 @@ export function recordWsConnectionAttempt(socketUrl: string): WsConnectionStatus...@@ -97,11 +114,13 @@ export function recordWsConnectionAttempt(socketUrl: string): WsConnectionStatus
97 }));114 }));
98}115}
99116
100export function recordWsConnectionOpened(): WsConnectionStatus {117export function recordWsConnectionOpened(metadata?: WsConnectionMetadata): WsConnectionStatus {
118 const connectionLabel = normalizeConnectionLabel(metadata?.connectionLabel);
101 return updateWsConnectionStatus((current) => ({119 return updateWsConnectionStatus((current) => ({
102 ...current,120 ...current,
103 closeCode: null,121 closeCode: null,
104 closeReason: null,122 closeReason: null,
123 connectionLabel: connectionLabel ?? current.connectionLabel,
105 connectedAt: isoNow(),124 connectedAt: isoNow(),
106 disconnectedAt: null,125 disconnectedAt: null,
107 hasConnected: true,126 hasConnected: true,
...@@ -112,24 +131,48 @@ export function recordWsConnectionOpened(): WsConnectionStatus {...@@ -112,24 +131,48 @@ export function recordWsConnectionOpened(): WsConnectionStatus {
112 }));131 }));
113}132}
114133
115export function recordWsConnectionErrored(message?: string | null): WsConnectionStatus {134function appendHint(message: string | null | undefined, hint: string | null | undefined) {
135 const normalizedMessage = message?.trim();
136 const normalizedHint = hint?.trim();
137 if (!normalizedMessage) {
138 return normalizedHint ? `Hint: ${normalizedHint}` : null;
139 }
140 return normalizedHint ? `${normalizedMessage} Hint: ${normalizedHint}` : normalizedMessage;
141}
142
143export function recordWsConnectionErrored(
144 message?: string | null,
145 metadata?: WsConnectionMetadata,
146): WsConnectionStatus {
116 return updateWsConnectionStatus((current) =>147 return updateWsConnectionStatus((current) =>
117 applyDisconnectState(current, {148 applyDisconnectState(current, {
118 lastError: message?.trim() ? message : current.lastError,149 lastError:
150 appendHint(message, metadata?.versionMismatchHint) ??
151 appendHint(current.lastError, metadata?.versionMismatchHint),
119 lastErrorAt: isoNow(),152 lastErrorAt: isoNow(),
120 }),153 }),
121 );154 );
122}155}
123156
124export function recordWsConnectionClosed(details?: {157export function recordWsConnectionClosed(
125 readonly code?: number;158 details?: {
126 readonly reason?: string;159 readonly code?: number;
127}): WsConnectionStatus {160 readonly reason?: string;
161 },
162 metadata?: WsConnectionMetadata,
163): WsConnectionStatus {
164 const connectionLabel = normalizeConnectionLabel(metadata?.connectionLabel);
128 return updateWsConnectionStatus((current) =>165 return updateWsConnectionStatus((current) =>
129 applyDisconnectState(current, {166 applyDisconnectState(
130 closeCode: details?.code ?? current.closeCode,167 current,
131 closeReason: details?.reason?.trim() ? details.reason : current.closeReason,168 {
132 }),169 closeCode: details?.code ?? current.closeCode,
170 closeReason:
171 appendHint(details?.reason, metadata?.versionMismatchHint) ??
172 appendHint(current.closeReason, metadata?.versionMismatchHint),
173 },
174 connectionLabel === null ? undefined : { connectionLabel },
175 ),
133 );176 );
134}177}
135178
...@@ -173,6 +216,7 @@ function applyDisconnectState(...@@ -173,6 +216,7 @@ function applyDisconnectState(
173 updates: Partial<216 updates: Partial<
174 Pick<WsConnectionStatus, "closeCode" | "closeReason" | "lastError" | "lastErrorAt">217 Pick<WsConnectionStatus, "closeCode" | "closeReason" | "lastError" | "lastErrorAt">
175 >,218 >,
219 metadata?: WsConnectionMetadata,
176): WsConnectionStatus {220): WsConnectionStatus {
177 const disconnectedAt = current.disconnectedAt ?? isoNow();221 const disconnectedAt = current.disconnectedAt ?? isoNow();
178 const nextRetryDelayMs =222 const nextRetryDelayMs =
...@@ -183,6 +227,7 @@ function applyDisconnectState(...@@ -183,6 +227,7 @@ function applyDisconnectState(
183 return {227 return {
184 ...current,228 ...current,
185 ...updates,229 ...updates,
230 connectionLabel: normalizeConnectionLabel(metadata?.connectionLabel) ?? current.connectionLabel,
186 disconnectedAt,231 disconnectedAt,
187 nextRetryAt:232 nextRetryAt:
188 nextRetryDelayMs === null233 nextRetryDelayMs === null
apps/web/src/rpc/wsRpcClient.ts+19-23
...@@ -155,11 +155,10 @@ export function createWsRpcClient(transport: WsTransport): WsRpcClient {...@@ -155,11 +155,10 @@ export function createWsRpcClient(transport: WsTransport): WsRpcClient {
155 restart: (input) => transport.request((client) => client[WS_METHODS.terminalRestart](input)),155 restart: (input) => transport.request((client) => client[WS_METHODS.terminalRestart](input)),
156 close: (input) => transport.request((client) => client[WS_METHODS.terminalClose](input)),156 close: (input) => transport.request((client) => client[WS_METHODS.terminalClose](input)),
157 onEvent: (listener, options) =>157 onEvent: (listener, options) =>
158 transport.subscribe(158 transport.subscribe((client) => client[WS_METHODS.subscribeTerminalEvents]({}), listener, {
159 (client) => client[WS_METHODS.subscribeTerminalEvents]({}),159 ...options,
160 listener,160 tag: WS_METHODS.subscribeTerminalEvents,
161 options,161 }),
162 ),
163 },162 },
164 projects: {163 projects: {
165 searchEntries: (input) =>164 searchEntries: (input) =>
...@@ -194,7 +193,7 @@ export function createWsRpcClient(transport: WsTransport): WsRpcClient {...@@ -194,7 +193,7 @@ export function createWsRpcClient(transport: WsTransport): WsRpcClient {
194 current = applyGitStatusStreamEvent(current, event);193 current = applyGitStatusStreamEvent(current, event);
195 listener(current);194 listener(current);
196 },195 },
197 options,196 { ...options, tag: WS_METHODS.subscribeVcsStatus },
198 );197 );
199 },198 },
200 listRefs: (input) => transport.request((client) => client[WS_METHODS.vcsListRefs](input)),199 listRefs: (input) => transport.request((client) => client[WS_METHODS.vcsListRefs](input)),
...@@ -243,23 +242,20 @@ export function createWsRpcClient(transport: WsTransport): WsRpcClient {...@@ -243,23 +242,20 @@ export function createWsRpcClient(transport: WsTransport): WsRpcClient {
243 discoverSourceControl: () =>242 discoverSourceControl: () =>
244 transport.request((client) => client[WS_METHODS.serverDiscoverSourceControl]({})),243 transport.request((client) => client[WS_METHODS.serverDiscoverSourceControl]({})),
245 subscribeConfig: (listener, options) =>244 subscribeConfig: (listener, options) =>
246 transport.subscribe(245 transport.subscribe((client) => client[WS_METHODS.subscribeServerConfig]({}), listener, {
247 (client) => client[WS_METHODS.subscribeServerConfig]({}),246 ...options,
248 listener,247 tag: WS_METHODS.subscribeServerConfig,
249 options,248 }),
250 ),
251 subscribeLifecycle: (listener, options) =>249 subscribeLifecycle: (listener, options) =>
252 transport.subscribe(250 transport.subscribe((client) => client[WS_METHODS.subscribeServerLifecycle]({}), listener, {
253 (client) => client[WS_METHODS.subscribeServerLifecycle]({}),251 ...options,
254 listener,252 tag: WS_METHODS.subscribeServerLifecycle,
255 options,253 }),
256 ),
257 subscribeAuthAccess: (listener, options) =>254 subscribeAuthAccess: (listener, options) =>
258 transport.subscribe(255 transport.subscribe((client) => client[WS_METHODS.subscribeAuthAccess]({}), listener, {
259 (client) => client[WS_METHODS.subscribeAuthAccess]({}),256 ...options,
260 listener,257 tag: WS_METHODS.subscribeAuthAccess,
261 options,258 }),
262 ),
263 },259 },
264 orchestration: {260 orchestration: {
265 dispatchCommand: (input) =>261 dispatchCommand: (input) =>
...@@ -272,13 +268,13 @@ export function createWsRpcClient(transport: WsTransport): WsRpcClient {...@@ -272,13 +268,13 @@ export function createWsRpcClient(transport: WsTransport): WsRpcClient {
272 transport.subscribe(268 transport.subscribe(
273 (client) => client[ORCHESTRATION_WS_METHODS.subscribeShell]({}),269 (client) => client[ORCHESTRATION_WS_METHODS.subscribeShell]({}),
274 listener,270 listener,
275 options,271 { ...options, tag: ORCHESTRATION_WS_METHODS.subscribeShell },
276 ),272 ),
277 subscribeThread: (input, listener, options) =>273 subscribeThread: (input, listener, options) =>
278 transport.subscribe(274 transport.subscribe(
279 (client) => client[ORCHESTRATION_WS_METHODS.subscribeThread](input),275 (client) => client[ORCHESTRATION_WS_METHODS.subscribeThread](input),
280 listener,276 listener,
281 options,277 { ...options, tag: ORCHESTRATION_WS_METHODS.subscribeThread },
282 ),278 ),
283 },279 },
284 };280 };
apps/web/src/rpc/wsTransport.test.ts+75-4
...@@ -284,10 +284,15 @@ describe("WsTransport", () => {...@@ -284,10 +284,15 @@ describe("WsTransport", () => {
284 socket.close(1012, "service restart");284 socket.close(1012, "service restart");
285285
286 await waitFor(() => {286 await waitFor(() => {
287 expect(onClose).toHaveBeenCalledWith({287 expect(onClose).toHaveBeenCalledWith(
288 code: 1012,288 {
289 reason: "service restart",289 code: 1012,
290 });290 reason: "service restart",
291 },
292 {
293 intentional: false,
294 },
295 );
291 expect(getWsConnectionStatus()).toMatchObject({296 expect(getWsConnectionStatus()).toMatchObject({
292 attemptCount: 2,297 attemptCount: 2,
293 closeReason: "service restart",298 closeReason: "service restart",
...@@ -298,6 +303,38 @@ describe("WsTransport", () => {...@@ -298,6 +303,38 @@ describe("WsTransport", () => {
298 await transport.dispose();303 await transport.dispose();
299 });304 });
300305
306 it("does not report an intentional dispose as a reconnectable disconnect", async () => {
307 const onClose = vi.fn();
308 const transport = createTransport("ws://localhost:3020", {
309 getConnectionLabel: () => "Remote Mac mini",
310 onClose,
311 });
312
313 await waitFor(() => {
314 expect(sockets).toHaveLength(1);
315 });
316
317 const socket = getSocket();
318 socket.open();
319
320 await waitFor(() => {
321 expect(getWsConnectionStatus()).toMatchObject({
322 connectionLabel: "Remote Mac mini",
323 hasConnected: true,
324 phase: "connected",
325 });
326 });
327
328 await transport.dispose();
329
330 expect(onClose).not.toHaveBeenCalled();
331 expect(getWsConnectionStatus()).toMatchObject({
332 connectionLabel: "Remote Mac mini",
333 phase: "connected",
334 reconnectPhase: "idle",
335 });
336 });
337
301 it("reconnects the websocket session without disposing the transport", async () => {338 it("reconnects the websocket session without disposing the transport", async () => {
302 const transport = createTransport("ws://localhost:3020");339 const transport = createTransport("ws://localhost:3020");
303340
...@@ -418,6 +455,40 @@ describe("WsTransport", () => {...@@ -418,6 +455,40 @@ describe("WsTransport", () => {
418 await transport.dispose();455 await transport.dispose();
419 });456 });
420457
458 it("ignores stale socket close events after the transport is disposed", async () => {
459 const onClose = vi.fn();
460 const transport = createTransport("ws://localhost:3020", { onClose });
461
462 await waitFor(() => {
463 expect(sockets).toHaveLength(1);
464 });
465
466 const firstSocket = getSocket();
467 firstSocket.open();
468
469 await waitFor(() => {
470 expect(getWsConnectionStatus()).toMatchObject({
471 hasConnected: true,
472 phase: "connected",
473 });
474 });
475
476 await transport.reconnect();
477
478 await waitFor(() => {
479 expect(sockets).toHaveLength(2);
480 });
481
482 const secondSocket = getSocket();
483 secondSocket.open();
484 await transport.dispose();
485
486 onClose.mockClear();
487 firstSocket.close(1006, "stale close after dispose");
488
489 expect(onClose).not.toHaveBeenCalled();
490 });
491
421 it("marks unary requests as slow until the first server ack arrives", async () => {492 it("marks unary requests as slow until the first server ack arrives", async () => {
422 const slowAckThresholdMs = 25;493 const slowAckThresholdMs = 25;
423 setSlowRpcAckThresholdMsForTests(slowAckThresholdMs);494 setSlowRpcAckThresholdMsForTests(slowAckThresholdMs);
apps/web/src/rpc/wsTransport.ts+73-8
...@@ -25,6 +25,7 @@ import { isTransportConnectionErrorMessage } from "./transportError";...@@ -25,6 +25,7 @@ import { isTransportConnectionErrorMessage } from "./transportError";
25interface SubscribeOptions {25interface SubscribeOptions {
26 readonly retryDelay?: Duration.Input;26 readonly retryDelay?: Duration.Input;
27 readonly onResubscribe?: () => void;27 readonly onResubscribe?: () => void;
28 readonly tag?: string;
28}29}
2930
30interface RequestOptions {31interface RequestOptions {
...@@ -40,6 +41,12 @@ interface TransportSession {...@@ -40,6 +41,12 @@ interface TransportSession {
40 readonly runtime: ManagedRuntime.ManagedRuntime<RpcClient.Protocol, never>;41 readonly runtime: ManagedRuntime.ManagedRuntime<RpcClient.Protocol, never>;
41}42}
4243
44interface StreamRequestStartInfo {
45 readonly id: string;
46 readonly tag: string;
47 readonly stream: boolean;
48}
49
43function formatErrorMessage(error: unknown): string {50function formatErrorMessage(error: unknown): string {
44 if (error instanceof Error && error.message.trim().length > 0) {51 if (error instanceof Error && error.message.trim().length > 0) {
45 return error.message;52 return error.message;
...@@ -52,10 +59,13 @@ export class WsTransport {...@@ -52,10 +59,13 @@ export class WsTransport {
52 private readonly lifecycleHandlers: WsProtocolLifecycleHandlers | undefined;59 private readonly lifecycleHandlers: WsProtocolLifecycleHandlers | undefined;
53 private disposed = false;60 private disposed = false;
54 private hasReportedTransportDisconnect = false;61 private hasReportedTransportDisconnect = false;
62 private intentionalCloseDepth = 0;
55 private reconnectChain: Promise<void> = Promise.resolve();63 private reconnectChain: Promise<void> = Promise.resolve();
56 private nextSessionId = 0;64 private nextSessionId = 0;
57 private activeSessionId = 0;65 private activeSessionId = 0;
58 private session: TransportSession;66 private session: TransportSession;
67 private lastHeartbeatPongAt = 0;
68 private readonly streamRequestStartListeners = new Set<(info: StreamRequestStartInfo) => void>();
5969
60 constructor(70 constructor(
61 url: WsRpcProtocolSocketUrlProvider,71 url: WsRpcProtocolSocketUrlProvider,
...@@ -126,18 +136,24 @@ export class WsTransport {...@@ -126,18 +136,24 @@ export class WsTransport {
126136
127 const session = this.session;137 const session = this.session;
128 try {138 try {
129 if (hasReceivedValue) {
130 try {
131 options?.onResubscribe?.();
132 } catch {
133 // Swallow reconnect hook errors so the stream can recover.
134 }
135 }
136
137 const runningStream = this.runStreamOnSession(139 const runningStream = this.runStreamOnSession(
138 session,140 session,
139 connect,141 connect,
140 listener,142 listener,
143 {
144 ...(options?.tag === undefined ? {} : { tag: options.tag }),
145 ...(hasReceivedValue
146 ? {
147 onStarted: () => {
148 try {
149 options?.onResubscribe?.();
150 } catch {
151 // Swallow reconnect hook errors so the stream can recover.
152 }
153 },
154 }
155 : {}),
156 },
141 () => active,157 () => active,
142 () => {158 () => {
143 this.hasReportedTransportDisconnect = false;159 this.hasReportedTransportDisconnect = false;
...@@ -193,6 +209,7 @@ export class WsTransport {...@@ -193,6 +209,7 @@ export class WsTransport {
193 }209 }
194210
195 clearAllTrackedRpcRequests();211 clearAllTrackedRpcRequests();
212 this.lastHeartbeatPongAt = 0;
196 const previousSession = this.session;213 const previousSession = this.session;
197 this.session = this.createSession();214 this.session = this.createSession();
198 await this.closeSession(previousSession);215 await this.closeSession(previousSession);
...@@ -202,6 +219,10 @@ export class WsTransport {...@@ -202,6 +219,10 @@ export class WsTransport {
202 await reconnectOperation;219 await reconnectOperation;
203 }220 }
204221
222 isHeartbeatFresh(maxAgeMs = 15_000): boolean {
223 return this.lastHeartbeatPongAt > 0 && Date.now() - this.lastHeartbeatPongAt <= maxAgeMs;
224 }
225
205 async dispose() {226 async dispose() {
206 if (this.disposed) {227 if (this.disposed) {
207 return;228 return;
...@@ -211,7 +232,9 @@ export class WsTransport {...@@ -211,7 +232,9 @@ export class WsTransport {
211 }232 }
212233
213 private closeSession(session: TransportSession) {234 private closeSession(session: TransportSession) {
235 this.intentionalCloseDepth += 1;
214 return session.runtime.runPromise(Scope.close(session.clientScope, Exit.void)).finally(() => {236 return session.runtime.runPromise(Scope.close(session.clientScope, Exit.void)).finally(() => {
237 this.intentionalCloseDepth -= 1;
215 session.runtime.dispose();238 session.runtime.dispose();
216 });239 });
217 }240 }
...@@ -225,6 +248,23 @@ export class WsTransport {...@@ -225,6 +248,23 @@ export class WsTransport {
225 createWsRpcProtocolLayer(this.url, {248 createWsRpcProtocolLayer(this.url, {
226 ...this.lifecycleHandlers,249 ...this.lifecycleHandlers,
227 isActive: () => !this.disposed && this.activeSessionId === sessionId,250 isActive: () => !this.disposed && this.activeSessionId === sessionId,
251 isCloseIntentional: () =>
252 this.disposed ||
253 this.intentionalCloseDepth > 0 ||
254 this.lifecycleHandlers?.isCloseIntentional?.() === true,
255 onHeartbeatPong: () => {
256 this.lastHeartbeatPongAt = Date.now();
257 this.lifecycleHandlers?.onHeartbeatPong?.();
258 },
259 onRequestStart: (info) => {
260 this.lifecycleHandlers?.onRequestStart?.(info);
261 if (!info.stream) {
262 return;
263 }
264 for (const listener of this.streamRequestStartListeners) {
265 listener(info);
266 }
267 },
228 }),268 }),
229 ClientTracingLive,269 ClientTracingLive,
230 ),270 ),
...@@ -241,6 +281,10 @@ export class WsTransport {...@@ -241,6 +281,10 @@ export class WsTransport {
241 session: TransportSession,281 session: TransportSession,
242 connect: (client: WsRpcProtocolClient) => Stream.Stream<TValue, Error, never>,282 connect: (client: WsRpcProtocolClient) => Stream.Stream<TValue, Error, never>,
243 listener: (value: TValue) => void,283 listener: (value: TValue) => void,
284 requestStart: {
285 readonly tag?: string;
286 readonly onStarted?: () => void;
287 },
244 isActive: () => boolean,288 isActive: () => boolean,
245 markValueReceived: () => void,289 markValueReceived: () => void,
246 ): {290 ): {
...@@ -253,6 +297,23 @@ export class WsTransport {...@@ -253,6 +297,23 @@ export class WsTransport {
253 resolveCompleted = resolve;297 resolveCompleted = resolve;
254 rejectCompleted = reject;298 rejectCompleted = reject;
255 });299 });
300 let requestStartListener: ((info: StreamRequestStartInfo) => void) | null = null;
301 if (requestStart.onStarted) {
302 requestStartListener = (info) => {
303 if (!isActive() || !info.stream) {
304 return;
305 }
306 if (requestStart.tag !== undefined && info.tag !== requestStart.tag) {
307 return;
308 }
309 requestStart.onStarted?.();
310 if (requestStartListener) {
311 this.streamRequestStartListeners.delete(requestStartListener);
312 requestStartListener = null;
313 }
314 };
315 this.streamRequestStartListeners.add(requestStartListener);
316 }
256 const cancel = session.runtime.runCallback(317 const cancel = session.runtime.runCallback(
257 Effect.promise(() => session.clientPromise).pipe(318 Effect.promise(() => session.clientPromise).pipe(
258 Effect.flatMap((client) =>319 Effect.flatMap((client) =>
...@@ -274,6 +335,10 @@ export class WsTransport {...@@ -274,6 +335,10 @@ export class WsTransport {
274 ),335 ),
275 {336 {
276 onExit: (exit) => {337 onExit: (exit) => {
338 if (requestStartListener) {
339 this.streamRequestStartListeners.delete(requestStartListener);
340 requestStartListener = null;
341 }
277 if (Exit.isSuccess(exit)) {342 if (Exit.isSuccess(exit)) {
278 resolveCompleted();343 resolveCompleted();
279 return;344 return;
apps/web/src/store.test.ts+34
...@@ -16,6 +16,7 @@ import { describe, expect, it } from "vitest";...@@ -16,6 +16,7 @@ import { describe, expect, it } from "vitest";
16import {16import {
17 applyOrchestrationEvent,17 applyOrchestrationEvent,
18 applyOrchestrationEvents,18 applyOrchestrationEvents,
19 removeEnvironmentState,
19 selectEnvironmentState,20 selectEnvironmentState,
20 selectProjectsAcrossEnvironments,21 selectProjectsAcrossEnvironments,
21 selectThreadByRef,22 selectThreadByRef,
...@@ -246,6 +247,39 @@ function makeEvent<T extends OrchestrationEvent["type"]>(...@@ -246,6 +247,39 @@ function makeEvent<T extends OrchestrationEvent["type"]>(
246 } as Extract<OrchestrationEvent, { type: T }>;247 } as Extract<OrchestrationEvent, { type: T }>;
247}248}
248249
250describe("environment state removal", () => {
251 it("drops local state for removed environments", () => {
252 const removedThread = makeThread({
253 environmentId: remoteEnvironmentId,
254 id: ThreadId.make("thread-removed"),
255 });
256 const keptThread = makeThread({ id: ThreadId.make("thread-kept") });
257 const removedState = makeState(removedThread).environmentStateById[remoteEnvironmentId]!;
258 const keptState = makeState(keptThread).environmentStateById[localEnvironmentId]!;
259 const state: AppState = {
260 activeEnvironmentId: remoteEnvironmentId,
261 environmentStateById: {
262 [remoteEnvironmentId]: removedState,
263 [localEnvironmentId]: keptState,
264 },
265 };
266
267 const next = removeEnvironmentState(state, remoteEnvironmentId);
268
269 expect(next.activeEnvironmentId).toBeNull();
270 expect(next.environmentStateById[remoteEnvironmentId]).toBeUndefined();
271 expect(next.environmentStateById[localEnvironmentId]).toBe(keptState);
272 });
273
274 it("preserves active environment when removing a different environment", () => {
275 const state = makeState(makeThread());
276
277 const next = removeEnvironmentState(state, remoteEnvironmentId);
278
279 expect(next).toBe(state);
280 });
281});
282
249describe("thread selection memoization", () => {283describe("thread selection memoization", () => {
250 it("returns stable thread references for repeated reads of the same state", () => {284 it("returns stable thread references for repeated reads of the same state", () => {
251 const thread = makeThread({285 const thread = makeThread({
apps/web/src/store.ts+17
...@@ -1905,6 +1905,20 @@ export function setActiveEnvironmentId(state: AppState, environmentId: Environme...@@ -1905,6 +1905,20 @@ export function setActiveEnvironmentId(state: AppState, environmentId: Environme
1905 };1905 };
1906}1906}
19071907
1908export function removeEnvironmentState(state: AppState, environmentId: EnvironmentId): AppState {
1909 if (!state.environmentStateById[environmentId] && state.activeEnvironmentId !== environmentId) {
1910 return state;
1911 }
1912
1913 const { [environmentId]: _removed, ...environmentStateById } = state.environmentStateById;
1914 return {
1915 ...state,
1916 activeEnvironmentId:
1917 state.activeEnvironmentId === environmentId ? null : state.activeEnvironmentId,
1918 environmentStateById,
1919 };
1920}
1921
1908export function setThreadBranch(1922export function setThreadBranch(
1909 state: AppState,1923 state: AppState,
1910 threadRef: ScopedThreadRef,1924 threadRef: ScopedThreadRef,
...@@ -1930,6 +1944,7 @@ export function setThreadBranch(...@@ -1930,6 +1944,7 @@ export function setThreadBranch(
19301944
1931interface AppStore extends AppState {1945interface AppStore extends AppState {
1932 setActiveEnvironmentId: (environmentId: EnvironmentId) => void;1946 setActiveEnvironmentId: (environmentId: EnvironmentId) => void;
1947 removeEnvironmentState: (environmentId: EnvironmentId) => void;
1933 syncServerShellSnapshot: (1948 syncServerShellSnapshot: (
1934 snapshot: OrchestrationShellSnapshot,1949 snapshot: OrchestrationShellSnapshot,
1935 environmentId: EnvironmentId,1950 environmentId: EnvironmentId,
...@@ -1953,6 +1968,8 @@ export const useStore = create<AppStore>((set) => ({...@@ -1953,6 +1968,8 @@ export const useStore = create<AppStore>((set) => ({
1953 ...initialState,1968 ...initialState,
1954 setActiveEnvironmentId: (environmentId) =>1969 setActiveEnvironmentId: (environmentId) =>
1955 set((state) => setActiveEnvironmentId(state, environmentId)),1970 set((state) => setActiveEnvironmentId(state, environmentId)),
1971 removeEnvironmentState: (environmentId) =>
1972 set((state) => removeEnvironmentState(state, environmentId)),
1956 syncServerShellSnapshot: (snapshot, environmentId) =>1973 syncServerShellSnapshot: (snapshot, environmentId) =>
1957 set((state) => syncServerShellSnapshot(state, snapshot, environmentId)),1974 set((state) => syncServerShellSnapshot(state, snapshot, environmentId)),
1958 syncServerThreadDetail: (thread, environmentId) =>1975 syncServerThreadDetail: (thread, environmentId) =>
apps/web/src/uiStateStore.test.ts+25
...@@ -10,6 +10,7 @@ import {...@@ -10,6 +10,7 @@ import {
10 type PersistedUiState,10 type PersistedUiState,
11 persistState,11 persistState,
12 reorderProjects,12 reorderProjects,
13 setDefaultAdvertisedEndpointKey,
13 setProjectExpanded,14 setProjectExpanded,
14 setThreadChangedFilesExpanded,15 setThreadChangedFilesExpanded,
15 syncProjects,16 syncProjects,
...@@ -23,6 +24,7 @@ function makeUiState(overrides: Partial<UiState> = {}): UiState {...@@ -23,6 +24,7 @@ function makeUiState(overrides: Partial<UiState> = {}): UiState {
23 projectOrder: [],24 projectOrder: [],
24 threadLastVisitedAtById: {},25 threadLastVisitedAtById: {},
25 threadChangedFilesExpandedById: {},26 threadChangedFilesExpandedById: {},
27 defaultAdvertisedEndpointKey: null,
26 ...overrides,28 ...overrides,
27 };29 };
28}30}
...@@ -102,6 +104,18 @@ describe("uiStateStore pure functions", () => {...@@ -102,6 +104,18 @@ describe("uiStateStore pure functions", () => {
102 expect(next).toBe(initialState);104 expect(next).toBe(initialState);
103 });105 });
104106
107 it("setDefaultAdvertisedEndpointKey stores endpoint preference by stable key", () => {
108 const initialState = makeUiState();
109
110 const next = setDefaultAdvertisedEndpointKey(initialState, "desktop-core:lan:http");
111
112 expect(next.defaultAdvertisedEndpointKey).toBe("desktop-core:lan:http");
113 expect(setDefaultAdvertisedEndpointKey(next, "desktop-core:lan:http")).toBe(next);
114 expect(setDefaultAdvertisedEndpointKey(next, "")).toMatchObject({
115 defaultAdvertisedEndpointKey: null,
116 });
117 });
118
105 it("reorderProjects moves all member keys of a multi-member group together", () => {119 it("reorderProjects moves all member keys of a multi-member group together", () => {
106 const keyALocal = "env-local:proj-a";120 const keyALocal = "env-local:proj-a";
107 const keyARemote = "env-remote:proj-a";121 const keyARemote = "env-remote:proj-a";
...@@ -554,6 +568,17 @@ describe("uiStateStore persistence round-trip", () => {...@@ -554,6 +568,17 @@ describe("uiStateStore persistence round-trip", () => {
554 expect(rehydrated.projectOrder).toEqual([projectC.key, projectA.key, projectB.key]);568 expect(rehydrated.projectOrder).toEqual([projectC.key, projectA.key, projectB.key]);
555 });569 });
556570
571 it("persists the default advertised endpoint preference", () => {
572 const state = setDefaultAdvertisedEndpointKey(makeUiState(), "desktop-core:lan:http");
573
574 persistState(state);
575
576 const persisted = JSON.parse(
577 localStorageStub.getItem(PERSISTED_STATE_KEY) ?? "{}",
578 ) as PersistedUiState;
579 expect(persisted.defaultAdvertisedEndpointKey).toBe("desktop-core:lan:http");
580 });
581
557 it("preserves expand state across restart when project's logical key changes", () => {582 it("preserves expand state across restart when project's logical key changes", () => {
558 // After restart, in-memory previousExpandedById is empty, so the583 // After restart, in-memory previousExpandedById is empty, so the
559 // previousLogicalKey-to-state bridge in syncProjects cannot help. The584 // previousLogicalKey-to-state bridge in syncProjects cannot help. The
apps/web/src/uiStateStore.ts+27-1
...@@ -19,6 +19,7 @@ export interface PersistedUiState {...@@ -19,6 +19,7 @@ export interface PersistedUiState {
19 collapsedProjectCwds?: string[];19 collapsedProjectCwds?: string[];
20 expandedProjectCwds?: string[];20 expandedProjectCwds?: string[];
21 projectOrderCwds?: string[];21 projectOrderCwds?: string[];
22 defaultAdvertisedEndpointKey?: string | null;
22 threadChangedFilesExpandedById?: Record<string, Record<string, boolean>>;23 threadChangedFilesExpandedById?: Record<string, Record<string, boolean>>;
23}24}
2425
...@@ -32,7 +33,11 @@ export interface UiThreadState {...@@ -32,7 +33,11 @@ export interface UiThreadState {
32 threadChangedFilesExpandedById: Record<string, Record<string, boolean>>;33 threadChangedFilesExpandedById: Record<string, Record<string, boolean>>;
33}34}
3435
35export interface UiState extends UiProjectState, UiThreadState {}36export interface UiEndpointState {
37 defaultAdvertisedEndpointKey: string | null;
38}
39
40export interface UiState extends UiProjectState, UiThreadState, UiEndpointState {}
3641
37export interface SyncProjectInput {42export interface SyncProjectInput {
38 /** Physical project key (env + cwd). Used for manual sort order. */43 /** Physical project key (env + cwd). Used for manual sort order. */
...@@ -52,6 +57,7 @@ const initialState: UiState = {...@@ -52,6 +57,7 @@ const initialState: UiState = {
52 projectOrder: [],57 projectOrder: [],
53 threadLastVisitedAtById: {},58 threadLastVisitedAtById: {},
54 threadChangedFilesExpandedById: {},59 threadChangedFilesExpandedById: {},
60 defaultAdvertisedEndpointKey: null,
55};61};
5662
57const persistedCollapsedProjectCwds = new Set<string>();63const persistedCollapsedProjectCwds = new Set<string>();
...@@ -88,6 +94,11 @@ function readPersistedState(): UiState {...@@ -88,6 +94,11 @@ function readPersistedState(): UiState {
88 hydratePersistedProjectState(parsed);94 hydratePersistedProjectState(parsed);
89 return {95 return {
90 ...initialState,96 ...initialState,
97 defaultAdvertisedEndpointKey:
98 typeof parsed.defaultAdvertisedEndpointKey === "string" &&
99 parsed.defaultAdvertisedEndpointKey.length > 0
100 ? parsed.defaultAdvertisedEndpointKey
101 : null,
91 threadChangedFilesExpandedById: sanitizePersistedThreadChangedFilesExpanded(102 threadChangedFilesExpandedById: sanitizePersistedThreadChangedFilesExpanded(
92 parsed.threadChangedFilesExpandedById,103 parsed.threadChangedFilesExpandedById,
93 ),104 ),
...@@ -179,6 +190,7 @@ export function persistState(state: UiState): void {...@@ -179,6 +190,7 @@ export function persistState(state: UiState): void {
179 collapsedProjectCwds,190 collapsedProjectCwds,
180 expandedProjectCwds,191 expandedProjectCwds,
181 projectOrderCwds,192 projectOrderCwds,
193 defaultAdvertisedEndpointKey: state.defaultAdvertisedEndpointKey,
182 threadChangedFilesExpandedById,194 threadChangedFilesExpandedById,
183 } satisfies PersistedUiState),195 } satisfies PersistedUiState),
184 );196 );
...@@ -532,6 +544,17 @@ export function setThreadChangedFilesExpanded(...@@ -532,6 +544,17 @@ export function setThreadChangedFilesExpanded(
532 };544 };
533}545}
534546
547export function setDefaultAdvertisedEndpointKey(state: UiState, key: string | null): UiState {
548 const nextKey = key && key.length > 0 ? key : null;
549 if (state.defaultAdvertisedEndpointKey === nextKey) {
550 return state;
551 }
552 return {
553 ...state,
554 defaultAdvertisedEndpointKey: nextKey,
555 };
556}
557
535export function toggleProject(state: UiState, projectId: string): UiState {558export function toggleProject(state: UiState, projectId: string): UiState {
536 const expanded = state.projectExpandedById[projectId] ?? true;559 const expanded = state.projectExpandedById[projectId] ?? true;
537 return {560 return {
...@@ -606,6 +629,7 @@ interface UiStateStore extends UiState {...@@ -606,6 +629,7 @@ interface UiStateStore extends UiState {
606 markThreadUnread: (threadId: string, latestTurnCompletedAt: string | null | undefined) => void;629 markThreadUnread: (threadId: string, latestTurnCompletedAt: string | null | undefined) => void;
607 clearThreadUi: (threadId: string) => void;630 clearThreadUi: (threadId: string) => void;
608 setThreadChangedFilesExpanded: (threadId: string, turnId: string, expanded: boolean) => void;631 setThreadChangedFilesExpanded: (threadId: string, turnId: string, expanded: boolean) => void;
632 setDefaultAdvertisedEndpointKey: (key: string | null) => void;
609 toggleProject: (projectId: string) => void;633 toggleProject: (projectId: string) => void;
610 setProjectExpanded: (projectId: string, expanded: boolean) => void;634 setProjectExpanded: (projectId: string, expanded: boolean) => void;
611 reorderProjects: (635 reorderProjects: (
...@@ -625,6 +649,8 @@ export const useUiStateStore = create<UiStateStore>((set) => ({...@@ -625,6 +649,8 @@ export const useUiStateStore = create<UiStateStore>((set) => ({
625 clearThreadUi: (threadId) => set((state) => clearThreadUi(state, threadId)),649 clearThreadUi: (threadId) => set((state) => clearThreadUi(state, threadId)),
626 setThreadChangedFilesExpanded: (threadId, turnId, expanded) =>650 setThreadChangedFilesExpanded: (threadId, turnId, expanded) =>
627 set((state) => setThreadChangedFilesExpanded(state, threadId, turnId, expanded)),651 set((state) => setThreadChangedFilesExpanded(state, threadId, turnId, expanded)),
652 setDefaultAdvertisedEndpointKey: (key) =>
653 set((state) => setDefaultAdvertisedEndpointKey(state, key)),
628 toggleProject: (projectId) => set((state) => toggleProject(state, projectId)),654 toggleProject: (projectId) => set((state) => toggleProject(state, projectId)),
629 setProjectExpanded: (projectId, expanded) =>655 setProjectExpanded: (projectId, expanded) =>
630 set((state) => setProjectExpanded(state, projectId, expanded)),656 set((state) => setProjectExpanded(state, projectId, expanded)),
apps/web/src/versionSkew.test.ts created+78
...@@ -0,0 +1,78 @@
1import { EnvironmentId } from "@t3tools/contracts";
2import { describe, expect, it } from "vitest";
3
4import { APP_VERSION } from "./branding";
5import {
6 appendVersionMismatchHint,
7 buildVersionMismatchDismissalKey,
8 dismissVersionMismatch,
9 isVersionMismatchDismissed,
10 resolveServerConfigVersionMismatch,
11 resolveVersionMismatch,
12} from "./versionSkew";
13
14describe("versionSkew", () => {
15 it("does not warn when versions match", () => {
16 expect(resolveVersionMismatch(APP_VERSION)).toBeNull();
17 });
18
19 it("returns a mismatch when the server version differs from the client", () => {
20 expect(resolveVersionMismatch("9.9.9")).toEqual({
21 clientVersion: APP_VERSION,
22 serverVersion: "9.9.9",
23 hint: "Version mismatch. Try syncing the client and server to the same T3 Code version.",
24 });
25 });
26
27 it("reads the server version from config descriptors", () => {
28 expect(
29 resolveServerConfigVersionMismatch({
30 environment: {
31 environmentId: EnvironmentId.make("environment-1"),
32 label: "Remote",
33 platform: {
34 os: "darwin",
35 arch: "arm64",
36 },
37 serverVersion: "9.9.9",
38 capabilities: {
39 repositoryIdentity: true,
40 },
41 },
42 }),
43 ).toMatchObject({
44 serverVersion: "9.9.9",
45 });
46 });
47
48 it("keys dismissals by environment, client version, and server version", () => {
49 const environmentId = EnvironmentId.make("environment-dismissal");
50 const key = buildVersionMismatchDismissalKey(environmentId, {
51 clientVersion: APP_VERSION,
52 serverVersion: "9.9.9",
53 });
54
55 expect(key).toBe(`${environmentId}:${APP_VERSION}:9.9.9`);
56 expect(isVersionMismatchDismissed(key)).toBe(false);
57
58 dismissVersionMismatch(key);
59
60 expect(isVersionMismatchDismissed(key)).toBe(true);
61 expect(
62 isVersionMismatchDismissed(
63 buildVersionMismatchDismissalKey(environmentId, {
64 clientVersion: APP_VERSION,
65 serverVersion: "9.9.10",
66 }),
67 ),
68 ).toBe(false);
69 });
70
71 it("appends a hint to connection errors when versions differ", () => {
72 const mismatch = resolveVersionMismatch("9.9.9");
73
74 expect(appendVersionMismatchHint("Socket closed.", mismatch)).toBe(
75 "Socket closed. Hint: Version mismatch. Try syncing the client and server to the same T3 Code version.",
76 );
77 });
78});
apps/web/src/versionSkew.ts created+116
...@@ -0,0 +1,116 @@
1import type { EnvironmentId, ServerConfig } from "@t3tools/contracts";
2import * as Schema from "effect/Schema";
3
4import { APP_VERSION } from "./branding";
5import { getLocalStorageItem, setLocalStorageItem } from "./hooks/useLocalStorage";
6
7export interface VersionMismatch {
8 readonly clientVersion: string;
9 readonly serverVersion: string;
10 readonly hint: string;
11}
12
13export const VERSION_MISMATCH_DISMISSALS_STORAGE_KEY = "t3code:version-mismatch-dismissals:v1";
14
15const VersionMismatchDismissalsSchema = Schema.Struct({
16 keys: Schema.Array(Schema.String),
17});
18
19type VersionMismatchDismissals = typeof VersionMismatchDismissalsSchema.Type;
20
21function normalizeVersion(version: string | null | undefined): string | null {
22 const trimmed = version?.trim();
23 return trimmed && trimmed.length > 0 ? trimmed : null;
24}
25
26export function resolveVersionMismatch(
27 serverVersion: string | null | undefined,
28): VersionMismatch | null {
29 const normalizedClientVersion = normalizeVersion(APP_VERSION);
30 const normalizedServerVersion = normalizeVersion(serverVersion);
31 if (
32 !normalizedClientVersion ||
33 !normalizedServerVersion ||
34 normalizedClientVersion === normalizedServerVersion
35 ) {
36 return null;
37 }
38
39 return {
40 clientVersion: normalizedClientVersion,
41 serverVersion: normalizedServerVersion,
42 hint: "Version mismatch. Try syncing the client and server to the same T3 Code version.",
43 };
44}
45
46export function resolveServerConfigVersionMismatch(
47 serverConfig: Pick<ServerConfig, "environment"> | null | undefined,
48): VersionMismatch | null {
49 return resolveVersionMismatch(serverConfig?.environment.serverVersion);
50}
51
52export function buildVersionMismatchDismissalKey(
53 environmentId: EnvironmentId,
54 mismatch: Pick<VersionMismatch, "clientVersion" | "serverVersion">,
55): string {
56 return `${environmentId}:${mismatch.clientVersion}:${mismatch.serverVersion}`;
57}
58
59function readVersionMismatchDismissals(): VersionMismatchDismissals {
60 try {
61 return (
62 getLocalStorageItem(
63 VERSION_MISMATCH_DISMISSALS_STORAGE_KEY,
64 VersionMismatchDismissalsSchema,
65 ) ?? { keys: [] }
66 );
67 } catch {
68 return { keys: [] };
69 }
70}
71
72function writeVersionMismatchDismissals(document: VersionMismatchDismissals): void {
73 try {
74 setLocalStorageItem(
75 VERSION_MISMATCH_DISMISSALS_STORAGE_KEY,
76 document,
77 VersionMismatchDismissalsSchema,
78 );
79 } catch {
80 // Dismissal state is best-effort UI state; a storage failure should not block the banner.
81 }
82}
83
84export function isVersionMismatchDismissed(dismissalKey: string | null | undefined): boolean {
85 if (!dismissalKey) {
86 return false;
87 }
88 return readVersionMismatchDismissals().keys.includes(dismissalKey);
89}
90
91export function dismissVersionMismatch(dismissalKey: string | null | undefined): void {
92 if (!dismissalKey) {
93 return;
94 }
95 const document = readVersionMismatchDismissals();
96 if (document.keys.includes(dismissalKey)) {
97 return;
98 }
99 writeVersionMismatchDismissals({
100 keys: [...document.keys, dismissalKey],
101 });
102}
103
104export function appendVersionMismatchHint(
105 message: string | null | undefined,
106 mismatch: VersionMismatch | null | undefined,
107): string | null {
108 const normalizedMessage = normalizeVersion(message);
109 if (!normalizedMessage) {
110 return mismatch?.hint ?? null;
111 }
112 if (!mismatch) {
113 return normalizedMessage;
114 }
115 return `${normalizedMessage} Hint: ${mismatch.hint}`;
116}
apps/web/src/vite-env.d.ts+3
...@@ -3,6 +3,9 @@...@@ -3,6 +3,9 @@
3import type { DesktopBridge, LocalApi } from "@t3tools/contracts";3import type { DesktopBridge, LocalApi } from "@t3tools/contracts";
44
5interface ImportMetaEnv {5interface ImportMetaEnv {
6 readonly VITE_HTTP_URL: string;
7 readonly VITE_WS_URL: string;
8 readonly VITE_HOSTED_APP_URL: string;
6 readonly APP_VERSION: string;9 readonly APP_VERSION: string;
7}10}
811
apps/web/vercel.json created+10
...@@ -0,0 +1,10 @@
1{
2 "buildCommand": "turbo build --filter @t3tools/web && bun ../../scripts/apply-web-brand-assets.ts production",
3 "installCommand": "bun add -g turbo && bun install --filter '@t3tools/contracts' --filter '@t3tools/client-runtime' --filter '@t3tools/scripts' --filter '@t3tools/web'",
4 "rewrites": [
5 {
6 "source": "/(.*)",
7 "destination": "/index.html"
8 }
9 ]
10}
apps/web/vite.config.ts+10
...@@ -9,6 +9,15 @@ const port = Number(process.env.PORT ?? 5733);...@@ -9,6 +9,15 @@ const port = Number(process.env.PORT ?? 5733);
9const host = process.env.HOST?.trim() || "localhost";9const host = process.env.HOST?.trim() || "localhost";
10const configuredHttpUrl = process.env.VITE_HTTP_URL?.trim();10const configuredHttpUrl = process.env.VITE_HTTP_URL?.trim();
11const configuredWsUrl = process.env.VITE_WS_URL?.trim();11const configuredWsUrl = process.env.VITE_WS_URL?.trim();
12const configuredHostedAppUrl = (() => {
13 if (process.env.VERCEL_ENV === "production" && process.env.VERCEL_PROJECT_PRODUCTION_URL) {
14 return `https://${process.env.VERCEL_PROJECT_PRODUCTION_URL}`;
15 }
16 if (process.env.VERCEL_URL) {
17 return `https://${process.env.VERCEL_URL}`;
18 }
19 return process.env.VITE_HOSTED_APP_URL?.trim();
20})();
12const sourcemapEnv = process.env.T3CODE_WEB_SOURCEMAP?.trim().toLowerCase();21const sourcemapEnv = process.env.T3CODE_WEB_SOURCEMAP?.trim().toLowerCase();
1322
14const buildSourcemap =23const buildSourcemap =
...@@ -68,6 +77,7 @@ export default defineConfig({...@@ -68,6 +77,7 @@ export default defineConfig({
68 "import.meta.env.VITE_HTTP_URL": JSON.stringify(configuredHttpUrl ?? ""),77 "import.meta.env.VITE_HTTP_URL": JSON.stringify(configuredHttpUrl ?? ""),
69 // In dev mode, tell the web app where the WebSocket server lives78 // In dev mode, tell the web app where the WebSocket server lives
70 "import.meta.env.VITE_WS_URL": JSON.stringify(configuredWsUrl ?? ""),79 "import.meta.env.VITE_WS_URL": JSON.stringify(configuredWsUrl ?? ""),
80 "import.meta.env.VITE_HOSTED_APP_URL": JSON.stringify(configuredHostedAppUrl ?? ""),
71 "import.meta.env.APP_VERSION": JSON.stringify(pkg.version),81 "import.meta.env.APP_VERSION": JSON.stringify(pkg.version),
72 },82 },
73 resolve: {83 resolve: {
bun.lock+68-20
...@@ -16,13 +16,17 @@...@@ -16,13 +16,17 @@
16 "name": "@t3tools/desktop",16 "name": "@t3tools/desktop",
17 "version": "0.0.21",17 "version": "0.0.21",
18 "dependencies": {18 "dependencies": {
19 "@effect/platform-node": "catalog:",
19 "effect": "catalog:",20 "effect": "catalog:",
20 "electron": "40.9.3",21 "electron": "40.9.3",
21 "electron-updater": "^6.6.2",22 "electron-updater": "^6.6.2",
22 },23 },
23 "devDependencies": {24 "devDependencies": {
25 "@t3tools/client-runtime": "workspace:*",
24 "@t3tools/contracts": "workspace:*",26 "@t3tools/contracts": "workspace:*",
25 "@t3tools/shared": "workspace:*",27 "@t3tools/shared": "workspace:*",
28 "@t3tools/ssh": "workspace:*",
29 "@t3tools/tailscale": "workspace:*",
26 "@types/node": "catalog:",30 "@types/node": "catalog:",
27 "effect-acp": "workspace:*",31 "effect-acp": "workspace:*",
28 "tsdown": "catalog:",32 "tsdown": "catalog:",
...@@ -63,6 +67,7 @@...@@ -63,6 +67,7 @@
63 "@effect/vitest": "catalog:",67 "@effect/vitest": "catalog:",
64 "@t3tools/contracts": "workspace:*",68 "@t3tools/contracts": "workspace:*",
65 "@t3tools/shared": "workspace:*",69 "@t3tools/shared": "workspace:*",
70 "@t3tools/tailscale": "workspace:*",
66 "@t3tools/web": "workspace:*",71 "@t3tools/web": "workspace:*",
67 "@types/bun": "catalog:",72 "@types/bun": "catalog:",
68 "@types/node": "catalog:",73 "@types/node": "catalog:",
...@@ -198,6 +203,38 @@...@@ -198,6 +203,38 @@
198 "vitest": "catalog:",203 "vitest": "catalog:",
199 },204 },
200 },205 },
206 "packages/ssh": {
207 "name": "@t3tools/ssh",
208 "version": "0.0.0-alpha.1",
209 "dependencies": {
210 "@t3tools/contracts": "workspace:*",
211 "@t3tools/shared": "workspace:*",
212 "effect": "catalog:",
213 },
214 "devDependencies": {
215 "@effect/language-service": "catalog:",
216 "@effect/platform-node": "catalog:",
217 "@effect/vitest": "catalog:",
218 "@types/node": "catalog:",
219 "typescript": "catalog:",
220 "vitest": "catalog:",
221 },
222 },
223 "packages/tailscale": {
224 "name": "@t3tools/tailscale",
225 "version": "0.0.0-alpha.1",
226 "dependencies": {
227 "@effect/platform-node": "catalog:",
228 "effect": "catalog:",
229 },
230 "devDependencies": {
231 "@effect/language-service": "catalog:",
232 "@effect/vitest": "catalog:",
233 "@types/node": "catalog:",
234 "typescript": "catalog:",
235 "vitest": "catalog:",
236 },
237 },
201 "scripts": {238 "scripts": {
202 "name": "@t3tools/scripts",239 "name": "@t3tools/scripts",
203 "dependencies": {240 "dependencies": {
...@@ -220,6 +257,9 @@...@@ -220,6 +257,9 @@
220 "electron",257 "electron",
221 "node-pty",258 "node-pty",
222 ],259 ],
260 "patchedDependencies": {
261 "effect@4.0.0-beta.59": "patches/effect@4.0.0-beta.59.patch",
262 },
223 "overrides": {263 "overrides": {
224 "@effect/atom-react": "catalog:",264 "@effect/atom-react": "catalog:",
225 "@effect/platform-bun": "catalog:",265 "@effect/platform-bun": "catalog:",
...@@ -231,17 +271,17 @@...@@ -231,17 +271,17 @@
231 "vite": "^8.0.0",271 "vite": "^8.0.0",
232 },272 },
233 "catalog": {273 "catalog": {
234 "@effect/atom-react": "4.0.0-beta.45",274 "@effect/atom-react": "4.0.0-beta.59",
235 "@effect/language-service": "0.84.2",275 "@effect/language-service": "0.84.2",
236 "@effect/openapi-generator": "4.0.0-beta.45",276 "@effect/openapi-generator": "4.0.0-beta.59",
237 "@effect/platform-bun": "4.0.0-beta.45",277 "@effect/platform-bun": "4.0.0-beta.59",
238 "@effect/platform-node": "4.0.0-beta.45",278 "@effect/platform-node": "4.0.0-beta.59",
239 "@effect/platform-node-shared": "4.0.0-beta.45",279 "@effect/platform-node-shared": "4.0.0-beta.59",
240 "@effect/sql-sqlite-bun": "4.0.0-beta.45",280 "@effect/sql-sqlite-bun": "4.0.0-beta.59",
241 "@effect/vitest": "4.0.0-beta.45",281 "@effect/vitest": "4.0.0-beta.59",
242 "@types/bun": "^1.3.11",282 "@types/bun": "^1.3.11",
243 "@types/node": "^24.10.13",283 "@types/node": "^24.10.13",
244 "effect": "4.0.0-beta.45",284 "effect": "4.0.0-beta.59",
245 "tsdown": "^0.20.3",285 "tsdown": "^0.20.3",
246 "typescript": "^5.7.3",286 "typescript": "^5.7.3",
247 "vitest": "^4.0.0",287 "vitest": "^4.0.0",
...@@ -329,21 +369,21 @@...@@ -329,21 +369,21 @@
329369
330 "@dnd-kit/utilities": ["@dnd-kit/utilities@3.2.2", "", { "dependencies": { "tslib": "^2.0.0" }, "peerDependencies": { "react": ">=16.8.0" } }, "sha512-+MKAJEOfaBe5SmV6t34p80MMKhjvUz0vRrvVJbPT0WElzaOJ/1xs+D+KDv+tD/NE5ujfrChEcshd4fLn0wpiqg=="],370 "@dnd-kit/utilities": ["@dnd-kit/utilities@3.2.2", "", { "dependencies": { "tslib": "^2.0.0" }, "peerDependencies": { "react": ">=16.8.0" } }, "sha512-+MKAJEOfaBe5SmV6t34p80MMKhjvUz0vRrvVJbPT0WElzaOJ/1xs+D+KDv+tD/NE5ujfrChEcshd4fLn0wpiqg=="],
331371
332 "@effect/atom-react": ["@effect/atom-react@4.0.0-beta.45", "", { "peerDependencies": { "effect": "^4.0.0-beta.45", "react": "^19.2.4", "scheduler": "*" } }, "sha512-jCoiae9UNeis9Yoal1p5V/P4eJK9nX89wORom7X5LpR2Ok920SZJc8dIeYkRXRAp9BCYJ/yWcO8gGLf7do71vg=="],372 "@effect/atom-react": ["@effect/atom-react@4.0.0-beta.59", "", { "peerDependencies": { "effect": "^4.0.0-beta.59", "react": "^19.2.4", "scheduler": "*" } }, "sha512-VkznQz5c+Z/BLxX+hQNPzPOyUnLQjnbppFSNP7tbPru7HKR4ihzCDC6Xjbx87156MOrZ+JOa6shTMbmvGT5W0w=="],
333373
334 "@effect/language-service": ["@effect/language-service@0.84.2", "", { "bin": { "effect-language-service": "cli.js" } }, "sha512-l04qNxpiA8rY5yXWckRPJ7Mk5MNerXuNymSFf+IdflfI5i8jgL1bpBNLuP6ijg7wgjdHc/KmTnCj2kT0SCntuA=="],374 "@effect/language-service": ["@effect/language-service@0.84.2", "", { "bin": { "effect-language-service": "cli.js" } }, "sha512-l04qNxpiA8rY5yXWckRPJ7Mk5MNerXuNymSFf+IdflfI5i8jgL1bpBNLuP6ijg7wgjdHc/KmTnCj2kT0SCntuA=="],
335375
336 "@effect/openapi-generator": ["@effect/openapi-generator@4.0.0-beta.45", "", { "peerDependencies": { "@effect/platform-node": "^4.0.0-beta.45", "effect": "^4.0.0-beta.45" }, "bin": { "openapigen": "dist/bin.js" } }, "sha512-zT1UPzapV6mZebMuYhoDPQj/FKNn4R+oDgz1hEcQwazal4+lVDyEYGhRl0PgZroy6CP78fAOWNgW7TriGqSlOQ=="],376 "@effect/openapi-generator": ["@effect/openapi-generator@4.0.0-beta.59", "", { "peerDependencies": { "@effect/platform-node": "^4.0.0-beta.59", "effect": "^4.0.0-beta.59" }, "bin": { "openapigen": "dist/bin.js" } }, "sha512-y1KF6mEb3pA5AdQsNGeWxV42Xi1uBt9Wzo5xMsp06j/WkOM2vWFmvSXw6xvwINAUi1HVjdOLo3xnTXtSv9YdRg=="],
337377
338 "@effect/platform-bun": ["@effect/platform-bun@4.0.0-beta.45", "", { "dependencies": { "@effect/platform-node-shared": "^4.0.0-beta.45" }, "peerDependencies": { "effect": "^4.0.0-beta.45" } }, "sha512-dZsbkJ+o4JfKE1OIAlMq8YWhmEfXxtTYennYADK/M/XlOtkFLsc2jkZppDvMznVni6JpFxWc7wd5rUf8jBVP5g=="],378 "@effect/platform-bun": ["@effect/platform-bun@4.0.0-beta.59", "", { "dependencies": { "@effect/platform-node-shared": "^4.0.0-beta.59" }, "peerDependencies": { "effect": "^4.0.0-beta.59" } }, "sha512-1jXCIx34X80ojiK9ACO9Q7RST9CXudRmlAbsP4kPqjUo6aqOuGSWXq9ueBO4LbaIZiioRxtTciom35U9ldfTkQ=="],
339379
340 "@effect/platform-node": ["@effect/platform-node@4.0.0-beta.45", "", { "dependencies": { "@effect/platform-node-shared": "^4.0.0-beta.45", "mime": "^4.1.0", "undici": "^7.24.0" }, "peerDependencies": { "effect": "^4.0.0-beta.45", "ioredis": "^5.7.0" } }, "sha512-P07NMG4eoy62iLX9szak0hkr7hrwgq5+XMkm7URVug/3zqfZVxEG2kxn9JzVK1lF5WbaVrEKwjt3IkEJxzSPtg=="],380 "@effect/platform-node": ["@effect/platform-node@4.0.0-beta.59", "", { "dependencies": { "@effect/platform-node-shared": "^4.0.0-beta.59", "mime": "^4.1.0", "undici": "^8.0.2" }, "peerDependencies": { "effect": "^4.0.0-beta.59", "ioredis": "^5.7.0" } }, "sha512-jHRW0l953FjYNhQHexr48jFiBu5iGEZH5nmKD6Ha+lPtm1MrKG2V4njfWA3Fv0nUmd3VN87eBJ557wU0twN1Hg=="],
341381
342 "@effect/platform-node-shared": ["@effect/platform-node-shared@4.0.0-beta.45", "", { "dependencies": { "@types/ws": "^8.18.1", "ws": "^8.19.0" }, "peerDependencies": { "effect": "^4.0.0-beta.45" } }, "sha512-0T4RG18o+aCVUSlmPxA7uAjHJkyE3MS/uRrR5kCNSZQNG3X71wdIbu82wE/MnV6+6YSSPDx/6TVdZWYkJF0JWw=="],382 "@effect/platform-node-shared": ["@effect/platform-node-shared@4.0.0-beta.59", "", { "dependencies": { "@types/ws": "^8.18.1", "ws": "^8.20.0" }, "peerDependencies": { "effect": "^4.0.0-beta.59" } }, "sha512-fGwFJuG0Te9U/ZeqeDZ2HcSKZBhX5wLjX2/Rxb5+yaOkvvFAN9MvIh05R0QQK5DCcERvnbhHSl1CjSIAN4aEwQ=="],
343383
344 "@effect/sql-sqlite-bun": ["@effect/sql-sqlite-bun@4.0.0-beta.45", "", { "peerDependencies": { "effect": "^4.0.0-beta.45" } }, "sha512-sJBBztwI4YxjdjhdyjWelh3KKoZucmmZS2ONeNDIzYgqFlsrwEEUrhVkWf5ch9Io+kUUzeb6q7/SedFoLUWO7g=="],384 "@effect/sql-sqlite-bun": ["@effect/sql-sqlite-bun@4.0.0-beta.59", "", { "peerDependencies": { "effect": "^4.0.0-beta.59" } }, "sha512-qZWt2HeLgSY8ygEfwbldK9lJ5xg/WP4lgCp1VLlyE8BImSB5g1h+UbhitXr+pOIf+ry4Xmc4ycp7YwTBOFH5/A=="],
345385
346 "@effect/vitest": ["@effect/vitest@4.0.0-beta.45", "", { "peerDependencies": { "effect": "^4.0.0-beta.45", "vitest": "^3.0.0 || ^4.0.0" } }, "sha512-38JA5Z6c2FoEhpVKbl7rjTrMU8Al5T30Kwtb6N3B9kOkyr2SsmvNsP9T65bh03IBh3ak9U//acrkmjS3hLQwKA=="],386 "@effect/vitest": ["@effect/vitest@4.0.0-beta.59", "", { "peerDependencies": { "effect": "^4.0.0-beta.59", "vitest": "^3.0.0 || ^4.0.0" } }, "sha512-jhpJbpDs1ED58SmFzcfmqLXxle84fiexaMEhV8SBl8WC2GM3FT5DW0WJYFjbZIH5/735brp3iGdjY5/uAxS7Bg=="],
347387
348 "@electron/get": ["@electron/get@2.0.3", "", { "dependencies": { "debug": "^4.1.1", "env-paths": "^2.2.0", "fs-extra": "^8.1.0", "got": "^11.8.5", "progress": "^2.0.3", "semver": "^6.2.0", "sumchecker": "^3.0.1" }, "optionalDependencies": { "global-agent": "^3.0.0" } }, "sha512-Qkzpg2s9GnVV2I2BjRksUi43U5e6+zaQMcjoJy0C+C5oxaKl+fmckGDQFtRpZpZV0NQekuZZ+tGz7EA9TVnQtQ=="],388 "@electron/get": ["@electron/get@2.0.3", "", { "dependencies": { "debug": "^4.1.1", "env-paths": "^2.2.0", "fs-extra": "^8.1.0", "got": "^11.8.5", "progress": "^2.0.3", "semver": "^6.2.0", "sumchecker": "^3.0.1" }, "optionalDependencies": { "global-agent": "^3.0.0" } }, "sha512-Qkzpg2s9GnVV2I2BjRksUi43U5e6+zaQMcjoJy0C+C5oxaKl+fmckGDQFtRpZpZV0NQekuZZ+tGz7EA9TVnQtQ=="],
349389
...@@ -741,6 +781,10 @@...@@ -741,6 +781,10 @@
741781
742 "@t3tools/shared": ["@t3tools/shared@workspace:packages/shared"],782 "@t3tools/shared": ["@t3tools/shared@workspace:packages/shared"],
743783
784 "@t3tools/ssh": ["@t3tools/ssh@workspace:packages/ssh"],
785
786 "@t3tools/tailscale": ["@t3tools/tailscale@workspace:packages/tailscale"],
787
744 "@t3tools/web": ["@t3tools/web@workspace:apps/web"],788 "@t3tools/web": ["@t3tools/web@workspace:apps/web"],
745789
746 "@tailwindcss/node": ["@tailwindcss/node@4.2.1", "", { "dependencies": { "@jridgewell/remapping": "^2.3.5", "enhanced-resolve": "^5.19.0", "jiti": "^2.6.1", "lightningcss": "1.31.1", "magic-string": "^0.30.21", "source-map-js": "^1.2.1", "tailwindcss": "4.2.1" } }, "sha512-jlx6sLk4EOwO6hHe1oCGm1Q4AN/s0rSrTTPBGPM0/RQ6Uylwq17FuU8IeJJKEjtc6K6O07zsvP+gDO6MMWo7pg=="],790 "@tailwindcss/node": ["@tailwindcss/node@4.2.1", "", { "dependencies": { "@jridgewell/remapping": "^2.3.5", "enhanced-resolve": "^5.19.0", "jiti": "^2.6.1", "lightningcss": "1.31.1", "magic-string": "^0.30.21", "source-map-js": "^1.2.1", "tailwindcss": "4.2.1" } }, "sha512-jlx6sLk4EOwO6hHe1oCGm1Q4AN/s0rSrTTPBGPM0/RQ6Uylwq17FuU8IeJJKEjtc6K6O07zsvP+gDO6MMWo7pg=="],
...@@ -1097,7 +1141,7 @@...@@ -1097,7 +1141,7 @@
10971141
1098 "ee-first": ["ee-first@1.1.1", "", {}, "sha512-WMwm9LhRUo+WUaRN+vRuETqG89IgZphVSNkdFgeb6sS/E4OrDIN7t48CAewSHXc6C8lefD8KKfr5vY61brQlow=="],1142 "ee-first": ["ee-first@1.1.1", "", {}, "sha512-WMwm9LhRUo+WUaRN+vRuETqG89IgZphVSNkdFgeb6sS/E4OrDIN7t48CAewSHXc6C8lefD8KKfr5vY61brQlow=="],
10991143
1100 "effect": ["effect@4.0.0-beta.45", "", { "dependencies": { "@standard-schema/spec": "^1.1.0", "fast-check": "^4.5.3", "find-my-way-ts": "^0.1.6", "ini": "^6.0.0", "kubernetes-types": "^1.30.0", "msgpackr": "^1.11.8", "multipasta": "^0.2.7", "toml": "^3.0.0", "uuid": "^13.0.0", "yaml": "^2.8.2" } }, "sha512-vvNrUWqnzBIW1hRMa+zw0CLRW6HLgdu7hQ6K7PT/rS+UY/73Ma11O+Oi9oc9zwL8KcN37M47UDseAdlF0bGNWw=="],1144 "effect": ["effect@4.0.0-beta.59", "", { "dependencies": { "@standard-schema/spec": "^1.1.0", "fast-check": "^4.6.0", "find-my-way-ts": "^0.1.6", "ini": "^6.0.0", "kubernetes-types": "^1.30.0", "msgpackr": "^1.11.9", "multipasta": "^0.2.7", "toml": "^4.1.1", "uuid": "^13.0.0", "yaml": "^2.8.3" } }, "sha512-xyUDLeHSe8d6lWGOvR6Fgn2HL6gYeTZ/S4Jzk9uc4ZUxMPPsNZlNXrvk0C7/utQFzeX7uAWcVnG2BjbA0SRoAA=="],
11011145
1102 "effect-acp": ["effect-acp@workspace:packages/effect-acp"],1146 "effect-acp": ["effect-acp@workspace:packages/effect-acp"],
11031147
...@@ -1855,7 +1899,7 @@...@@ -1855,7 +1899,7 @@
18551899
1856 "toidentifier": ["toidentifier@1.0.1", "", {}, "sha512-o5sSPKEkg/DIQNmH43V0/uerLrpzVedkUh8tGNvaeXpfpuwjKenlSox/2O/BTlZUtEe+JG7s5YhEz608PlAHRA=="],1900 "toidentifier": ["toidentifier@1.0.1", "", {}, "sha512-o5sSPKEkg/DIQNmH43V0/uerLrpzVedkUh8tGNvaeXpfpuwjKenlSox/2O/BTlZUtEe+JG7s5YhEz608PlAHRA=="],
18571901
1858 "toml": ["toml@3.0.0", "", {}, "sha512-y/mWCZinnvxjTKYhJ+pYxwD0mRLVvOtdS2Awbgxln6iEnt4rk0yBxeSBHkGJcPucRiG0e55mwWp+g/05rsrd6w=="],1902 "toml": ["toml@4.1.1", "", {}, "sha512-EBJnVBr3dTXdA89WVFoAIPUqkBjxPMwRqsfuo1r240tKFHXv3zgca4+NJib/h6TyvGF7vOawz0jGuryJCdNHrw=="],
18591903
1860 "totalist": ["totalist@3.0.1", "", {}, "sha512-sf4i37nQ2LBx4m3wB74y+ubopq6W/dIzXg0FDGjsYnZHVa1Da8FH853wlL2gtUhg+xJXjfk3kUZS3BRoQeoQBQ=="],1904 "totalist": ["totalist@3.0.1", "", {}, "sha512-sf4i37nQ2LBx4m3wB74y+ubopq6W/dIzXg0FDGjsYnZHVa1Da8FH853wlL2gtUhg+xJXjfk3kUZS3BRoQeoQBQ=="],
18611905
...@@ -1909,7 +1953,7 @@...@@ -1909,7 +1953,7 @@
19091953
1910 "uncrypto": ["uncrypto@0.1.3", "", {}, "sha512-Ql87qFHB3s/De2ClA9e0gsnS6zXG27SkTiSJwjCc9MebbfapQfuPzumMIUMi38ezPZVNFcHI9sUIepeQfw8J8Q=="],1954 "uncrypto": ["uncrypto@0.1.3", "", {}, "sha512-Ql87qFHB3s/De2ClA9e0gsnS6zXG27SkTiSJwjCc9MebbfapQfuPzumMIUMi38ezPZVNFcHI9sUIepeQfw8J8Q=="],
19111955
1912 "undici": ["undici@7.24.4", "", {}, "sha512-BM/JzwwaRXxrLdElV2Uo6cTLEjhSb3WXboncJamZ15NgUURmvlXvxa6xkwIOILIjPNo9i8ku136ZvWV0Uly8+w=="],1956 "undici": ["undici@8.2.0", "", {}, "sha512-Z+4Hx9GE26Lh9Upwfnc8C7SsrpBPGaM/Gm6kMFtiG7c+5IvQKlXi/t+9x9DrrCh29cww5TSP9YdVaBcnLDs5fQ=="],
19131957
1914 "undici-types": ["undici-types@7.16.0", "", {}, "sha512-Zz+aZWSj8LE6zoxD+xrjh4VfkIG8Ya6LvYkZqtUQGJPZjYl53ypCaUwWqo7eI0x66KBGeRo+mlBEkMSeSZ38Nw=="],1958 "undici-types": ["undici-types@7.16.0", "", {}, "sha512-Zz+aZWSj8LE6zoxD+xrjh4VfkIG8Ya6LvYkZqtUQGJPZjYl53ypCaUwWqo7eI0x66KBGeRo+mlBEkMSeSZ38Nw=="],
19151959
...@@ -2017,7 +2061,7 @@...@@ -2017,7 +2061,7 @@
20172061
2018 "wrappy": ["wrappy@1.0.2", "", {}, "sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ=="],2062 "wrappy": ["wrappy@1.0.2", "", {}, "sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ=="],
20192063
2020 "ws": ["ws@8.19.0", "", { "peerDependencies": { "bufferutil": "^4.0.1", "utf-8-validate": ">=5.0.2" }, "optionalPeers": ["bufferutil", "utf-8-validate"] }, "sha512-blAT2mjOEIi0ZzruJfIhb3nps74PRWTCz1IjglWEEpQl5XS/UNama6u2/rjFkDDouqr4L67ry+1aGIALViWjDg=="],2064 "ws": ["ws@8.20.0", "", { "peerDependencies": { "bufferutil": "^4.0.1", "utf-8-validate": ">=5.0.2" }, "optionalPeers": ["bufferutil", "utf-8-validate"] }, "sha512-sAt8BhgNbzCtgGbt2OxmpuryO63ZoDk/sqaB/znQm94T4fCEsy/yV+7CdC1kJhOU9lboAEU7R3kquuycDoibVA=="],
20212065
2022 "wsl-utils": ["wsl-utils@0.1.0", "", { "dependencies": { "is-wsl": "^3.1.0" } }, "sha512-h3Fbisa2nKGPxCpm89Hk33lBLsnaGBvctQopaBSOW/uIs6FTe1ATyAnKFJrzVs9vpGdsTe73WF3V4lIsk4Gacw=="],2066 "wsl-utils": ["wsl-utils@0.1.0", "", { "dependencies": { "is-wsl": "^3.1.0" } }, "sha512-h3Fbisa2nKGPxCpm89Hk33lBLsnaGBvctQopaBSOW/uIs6FTe1ATyAnKFJrzVs9vpGdsTe73WF3V4lIsk4Gacw=="],
20232067
...@@ -2027,7 +2071,7 @@...@@ -2027,7 +2071,7 @@
20272071
2028 "yallist": ["yallist@3.1.1", "", {}, "sha512-a4UGQaWPH59mOXUYnAG2ewncQS4i4F43Tv3JoAM+s2VDAmS9NsK8GpDMLrCHPksFT7h3K6TOoUNn2pb7RoXx4g=="],2072 "yallist": ["yallist@3.1.1", "", {}, "sha512-a4UGQaWPH59mOXUYnAG2ewncQS4i4F43Tv3JoAM+s2VDAmS9NsK8GpDMLrCHPksFT7h3K6TOoUNn2pb7RoXx4g=="],
20292073
2030 "yaml": ["yaml@2.8.2", "", { "bin": { "yaml": "bin.mjs" } }, "sha512-mplynKqc1C2hTVYxd0PU2xQAc22TI1vShAYGksCCfxbn/dFwnHTNi1bvYsBTkhdUNtGIf5xNOg938rrSSYvS9A=="],2074 "yaml": ["yaml@2.8.3", "", { "bin": { "yaml": "bin.mjs" } }, "sha512-AvbaCLOO2Otw/lW5bmh9d/WEdcDFdQp2Z2ZUH3pX9U2ihyUY0nvLv7J6TrWowklRGPYbB/IuIMfYgxaCPg5Bpg=="],
20312075
2032 "yaml-language-server": ["yaml-language-server@1.20.0", "", { "dependencies": { "@vscode/l10n": "^0.0.18", "ajv": "^8.17.1", "ajv-draft-04": "^1.0.0", "prettier": "^3.5.0", "request-light": "^0.5.7", "vscode-json-languageservice": "4.1.8", "vscode-languageserver": "^9.0.0", "vscode-languageserver-textdocument": "^1.0.1", "vscode-languageserver-types": "^3.16.0", "vscode-uri": "^3.0.2", "yaml": "2.7.1" }, "bin": { "yaml-language-server": "bin/yaml-language-server" } }, "sha512-qhjK/bzSRZ6HtTvgeFvjNPJGWdZ0+x5NREV/9XZWFjIGezew2b4r5JPy66IfOhd5OA7KeFwk1JfmEbnTvev0cA=="],2076 "yaml-language-server": ["yaml-language-server@1.20.0", "", { "dependencies": { "@vscode/l10n": "^0.0.18", "ajv": "^8.17.1", "ajv-draft-04": "^1.0.0", "prettier": "^3.5.0", "request-light": "^0.5.7", "vscode-json-languageservice": "4.1.8", "vscode-languageserver": "^9.0.0", "vscode-languageserver-textdocument": "^1.0.1", "vscode-languageserver-types": "^3.16.0", "vscode-uri": "^3.0.2", "yaml": "2.7.1" }, "bin": { "yaml-language-server": "bin/yaml-language-server" } }, "sha512-qhjK/bzSRZ6HtTvgeFvjNPJGWdZ0+x5NREV/9XZWFjIGezew2b4r5JPy66IfOhd5OA7KeFwk1JfmEbnTvev0cA=="],
20332077
...@@ -2053,6 +2097,8 @@...@@ -2053,6 +2097,8 @@
20532097
2054 "@astrojs/language-server/@astrojs/compiler": ["@astrojs/compiler@2.13.1", "", {}, "sha512-f3FN83d2G/v32ipNClRKgYv30onQlMZX1vCeZMjPsMMPl1mDpmbl0+N5BYo4S/ofzqJyS5hvwacEo0CCVDn/Qg=="],2098 "@astrojs/language-server/@astrojs/compiler": ["@astrojs/compiler@2.13.1", "", {}, "sha512-f3FN83d2G/v32ipNClRKgYv30onQlMZX1vCeZMjPsMMPl1mDpmbl0+N5BYo4S/ofzqJyS5hvwacEo0CCVDn/Qg=="],
20552099
2100 "@astrojs/yaml2ts/yaml": ["yaml@2.8.2", "", { "bin": { "yaml": "bin.mjs" } }, "sha512-mplynKqc1C2hTVYxd0PU2xQAc22TI1vShAYGksCCfxbn/dFwnHTNi1bvYsBTkhdUNtGIf5xNOg938rrSSYvS9A=="],
2101
2056 "@babel/code-frame/@babel/helper-validator-identifier": ["@babel/helper-validator-identifier@7.28.5", "", {}, "sha512-qSs4ifwzKJSV39ucNjsvc6WVHs6b7S03sOh2OcHF9UHfVPqWWALUsNUVzhSBiItjRZoLHx7nIarVjqKVusUZ1Q=="],2102 "@babel/code-frame/@babel/helper-validator-identifier": ["@babel/helper-validator-identifier@7.28.5", "", {}, "sha512-qSs4ifwzKJSV39ucNjsvc6WVHs6b7S03sOh2OcHF9UHfVPqWWALUsNUVzhSBiItjRZoLHx7nIarVjqKVusUZ1Q=="],
20572103
2058 "@babel/core/@babel/generator": ["@babel/generator@7.29.1", "", { "dependencies": { "@babel/parser": "^7.29.0", "@babel/types": "^7.29.0", "@jridgewell/gen-mapping": "^0.3.12", "@jridgewell/trace-mapping": "^0.3.28", "jsesc": "^3.0.2" } }, "sha512-qsaF+9Qcm2Qv8SRIMMscAvG4O3lJ0F1GuMo5HR/Bp02LopNgnZBC/EkbevHFeGs4ls/oPz9v+Bsmzbkbe+0dUw=="],2104 "@babel/core/@babel/generator": ["@babel/generator@7.29.1", "", { "dependencies": { "@babel/parser": "^7.29.0", "@babel/types": "^7.29.0", "@jridgewell/gen-mapping": "^0.3.12", "@jridgewell/trace-mapping": "^0.3.28", "jsesc": "^3.0.2" } }, "sha512-qsaF+9Qcm2Qv8SRIMMscAvG4O3lJ0F1GuMo5HR/Bp02LopNgnZBC/EkbevHFeGs4ls/oPz9v+Bsmzbkbe+0dUw=="],
...@@ -2117,6 +2163,8 @@...@@ -2117,6 +2163,8 @@
21172163
2118 "@tanstack/router-utils/@babel/generator": ["@babel/generator@7.29.1", "", { "dependencies": { "@babel/parser": "^7.29.0", "@babel/types": "^7.29.0", "@jridgewell/gen-mapping": "^0.3.12", "@jridgewell/trace-mapping": "^0.3.28", "jsesc": "^3.0.2" } }, "sha512-qsaF+9Qcm2Qv8SRIMMscAvG4O3lJ0F1GuMo5HR/Bp02LopNgnZBC/EkbevHFeGs4ls/oPz9v+Bsmzbkbe+0dUw=="],2164 "@tanstack/router-utils/@babel/generator": ["@babel/generator@7.29.1", "", { "dependencies": { "@babel/parser": "^7.29.0", "@babel/types": "^7.29.0", "@jridgewell/gen-mapping": "^0.3.12", "@jridgewell/trace-mapping": "^0.3.28", "jsesc": "^3.0.2" } }, "sha512-qsaF+9Qcm2Qv8SRIMMscAvG4O3lJ0F1GuMo5HR/Bp02LopNgnZBC/EkbevHFeGs4ls/oPz9v+Bsmzbkbe+0dUw=="],
21192165
2166 "@vitest/browser/ws": ["ws@8.19.0", "", { "peerDependencies": { "bufferutil": "^4.0.1", "utf-8-validate": ">=5.0.2" }, "optionalPeers": ["bufferutil", "utf-8-validate"] }, "sha512-blAT2mjOEIi0ZzruJfIhb3nps74PRWTCz1IjglWEEpQl5XS/UNama6u2/rjFkDDouqr4L67ry+1aGIALViWjDg=="],
2167
2120 "anymatch/picomatch": ["picomatch@2.3.1", "", {}, "sha512-JU3teHTNjmE2VCGFzuY8EXzCDVwEqB2a8fsIvwaStHhAWJEeVd1o1QD80CU6+ZdEXXSLbSsuLwJjkCBWqRQUVA=="],2168 "anymatch/picomatch": ["picomatch@2.3.1", "", {}, "sha512-JU3teHTNjmE2VCGFzuY8EXzCDVwEqB2a8fsIvwaStHhAWJEeVd1o1QD80CU6+ZdEXXSLbSsuLwJjkCBWqRQUVA=="],
21212169
2122 "ast-kit/@babel/parser": ["@babel/parser@8.0.0-rc.2", "", { "dependencies": { "@babel/types": "^8.0.0-rc.2" }, "bin": "./bin/babel-parser.js" }, "sha512-29AhEtcq4x8Dp3T72qvUMZHx0OMXCj4Jy/TEReQa+KWLln524Cj1fWb3QFi0l/xSpptQBR6y9RNEXuxpFvwiUQ=="],2170 "ast-kit/@babel/parser": ["@babel/parser@8.0.0-rc.2", "", { "dependencies": { "@babel/types": "^8.0.0-rc.2" }, "bin": "./bin/babel-parser.js" }, "sha512-29AhEtcq4x8Dp3T72qvUMZHx0OMXCj4Jy/TEReQa+KWLln524Cj1fWb3QFi0l/xSpptQBR6y9RNEXuxpFvwiUQ=="],
package.json+11-8
...@@ -8,14 +8,14 @@...@@ -8,14 +8,14 @@
8 "scripts"8 "scripts"
9 ],9 ],
10 "catalog": {10 "catalog": {
11 "effect": "4.0.0-beta.45",11 "effect": "4.0.0-beta.59",
12 "@effect/atom-react": "4.0.0-beta.45",12 "@effect/atom-react": "4.0.0-beta.59",
13 "@effect/openapi-generator": "4.0.0-beta.45",13 "@effect/openapi-generator": "4.0.0-beta.59",
14 "@effect/platform-bun": "4.0.0-beta.45",14 "@effect/platform-bun": "4.0.0-beta.59",
15 "@effect/platform-node": "4.0.0-beta.45",15 "@effect/platform-node": "4.0.0-beta.59",
16 "@effect/platform-node-shared": "4.0.0-beta.45",16 "@effect/platform-node-shared": "4.0.0-beta.59",
17 "@effect/sql-sqlite-bun": "4.0.0-beta.45",17 "@effect/sql-sqlite-bun": "4.0.0-beta.59",
18 "@effect/vitest": "4.0.0-beta.45",18 "@effect/vitest": "4.0.0-beta.59",
19 "@effect/language-service": "0.84.2",19 "@effect/language-service": "0.84.2",
20 "@types/bun": "^1.3.11",20 "@types/bun": "^1.3.11",
21 "@types/node": "^24.10.13",21 "@types/node": "^24.10.13",
...@@ -84,6 +84,9 @@...@@ -84,6 +84,9 @@
84 "apps/web/public"84 "apps/web/public"
85 ]85 ]
86 },86 },
87 "patchedDependencies": {
88 "effect@4.0.0-beta.59": "patches/effect@4.0.0-beta.59.patch"
89 },
87 "trustedDependencies": [90 "trustedDependencies": [
88 "node-pty",91 "node-pty",
89 "electron"92 "electron"
packages/client-runtime/src/advertisedEndpoint.test.ts created+61
...@@ -0,0 +1,61 @@
1import { describe, expect, it } from "vitest";
2
3import {
4 classifyHostedHttpsCompatibility,
5 createAdvertisedEndpoint,
6 deriveWsBaseUrl,
7 normalizeHttpBaseUrl,
8} from "./advertisedEndpoint.ts";
9
10const coreProvider = {
11 id: "desktop-core",
12 label: "Desktop",
13 kind: "core",
14 isAddon: false,
15} as const;
16
17describe("advertised endpoint helpers", () => {
18 it("normalizes HTTP and WebSocket base URLs", () => {
19 expect(normalizeHttpBaseUrl("https://example.com/path?x=1#hash")).toBe("https://example.com/");
20 expect(normalizeHttpBaseUrl("wss://example.com/socket")).toBe("https://example.com/");
21 expect(deriveWsBaseUrl("https://example.com/api")).toBe("wss://example.com/");
22 expect(deriveWsBaseUrl("http://127.0.0.1:3773")).toBe("ws://127.0.0.1:3773/");
23 });
24
25 it("marks HTTP endpoints as blocked from hosted HTTPS apps", () => {
26 expect(classifyHostedHttpsCompatibility("http://192.168.1.44:3773")).toBe(
27 "mixed-content-blocked",
28 );
29 expect(classifyHostedHttpsCompatibility("https://desktop.example.com", "compatible")).toBe(
30 "compatible",
31 );
32 });
33
34 it("creates provider-neutral endpoint records", () => {
35 expect(
36 createAdvertisedEndpoint({
37 id: "lan:http://192.168.1.44:3773",
38 label: "LAN",
39 provider: coreProvider,
40 httpBaseUrl: "http://192.168.1.44:3773",
41 reachability: "lan",
42 source: "desktop-core",
43 isDefault: true,
44 }),
45 ).toEqual({
46 id: "lan:http://192.168.1.44:3773",
47 label: "LAN",
48 provider: coreProvider,
49 httpBaseUrl: "http://192.168.1.44:3773/",
50 wsBaseUrl: "ws://192.168.1.44:3773/",
51 reachability: "lan",
52 compatibility: {
53 hostedHttpsApp: "mixed-content-blocked",
54 desktopApp: "compatible",
55 },
56 source: "desktop-core",
57 status: "available",
58 isDefault: true,
59 });
60 });
61});
packages/client-runtime/src/advertisedEndpoint.ts created+78
...@@ -0,0 +1,78 @@
1import type {
2 AdvertisedEndpoint,
3 AdvertisedEndpointHostedHttpsCompatibility,
4 AdvertisedEndpointProvider,
5 AdvertisedEndpointReachability,
6 AdvertisedEndpointSource,
7 AdvertisedEndpointStatus,
8} from "@t3tools/contracts";
9
10export interface CreateAdvertisedEndpointInput {
11 readonly id: string;
12 readonly label: string;
13 readonly provider: AdvertisedEndpointProvider;
14 readonly httpBaseUrl: string;
15 readonly reachability: AdvertisedEndpointReachability;
16 readonly hostedHttpsCompatibility?: AdvertisedEndpointHostedHttpsCompatibility;
17 readonly desktopCompatibility?: "compatible" | "unknown";
18 readonly source: AdvertisedEndpointSource;
19 readonly status?: AdvertisedEndpointStatus;
20 readonly isDefault?: boolean;
21 readonly description?: string;
22}
23
24export function normalizeHttpBaseUrl(rawValue: string): string {
25 const url = new URL(rawValue);
26 if (url.protocol === "ws:") {
27 url.protocol = "http:";
28 } else if (url.protocol === "wss:") {
29 url.protocol = "https:";
30 }
31
32 if (url.protocol !== "http:" && url.protocol !== "https:") {
33 throw new Error(`Endpoint must use HTTP or HTTPS. Received ${url.protocol}`);
34 }
35
36 url.pathname = "/";
37 url.search = "";
38 url.hash = "";
39 return url.toString();
40}
41
42export function deriveWsBaseUrl(httpBaseUrl: string): string {
43 const url = new URL(normalizeHttpBaseUrl(httpBaseUrl));
44 url.protocol = url.protocol === "https:" ? "wss:" : "ws:";
45 return url.toString();
46}
47
48export function classifyHostedHttpsCompatibility(
49 httpBaseUrl: string,
50 fallback: AdvertisedEndpointHostedHttpsCompatibility = "unknown",
51): AdvertisedEndpointHostedHttpsCompatibility {
52 const url = new URL(normalizeHttpBaseUrl(httpBaseUrl));
53 if (url.protocol === "http:") {
54 return "mixed-content-blocked";
55 }
56 return fallback === "mixed-content-blocked" ? "unknown" : fallback;
57}
58
59export function createAdvertisedEndpoint(input: CreateAdvertisedEndpointInput): AdvertisedEndpoint {
60 const httpBaseUrl = normalizeHttpBaseUrl(input.httpBaseUrl);
61 return {
62 id: input.id,
63 label: input.label,
64 provider: input.provider,
65 httpBaseUrl,
66 wsBaseUrl: deriveWsBaseUrl(httpBaseUrl),
67 reachability: input.reachability,
68 compatibility: {
69 hostedHttpsApp:
70 input.hostedHttpsCompatibility ?? classifyHostedHttpsCompatibility(httpBaseUrl),
71 desktopApp: input.desktopCompatibility ?? "compatible",
72 },
73 source: input.source,
74 status: input.status ?? "available",
75 ...(input.isDefault === undefined ? {} : { isDefault: input.isDefault }),
76 ...(input.description === undefined ? {} : { description: input.description }),
77 };
78}
packages/client-runtime/src/index.ts+1
...@@ -1,3 +1,4 @@...@@ -1,3 +1,4 @@
1export * from "./advertisedEndpoint.ts";
1export * from "./knownEnvironment.ts";2export * from "./knownEnvironment.ts";
2export * from "./scoped.ts";3export * from "./scoped.ts";
3export * from "./sourceControlDiscoveryState.ts";4export * from "./sourceControlDiscoveryState.ts";
packages/contracts/src/index.ts+1
...@@ -1,6 +1,7 @@...@@ -1,6 +1,7 @@
1export * from "./baseSchemas.ts";1export * from "./baseSchemas.ts";
2export * from "./auth.ts";2export * from "./auth.ts";
3export * from "./environment.ts";3export * from "./environment.ts";
4export * from "./remoteAccess.ts";
4export * from "./ipc.ts";5export * from "./ipc.ts";
5export * from "./terminal.ts";6export * from "./terminal.ts";
6export * from "./provider.ts";7export * from "./provider.ts";
packages/contracts/src/ipc.ts+64-1
...@@ -53,8 +53,15 @@ import type {...@@ -53,8 +53,15 @@ import type {
53 OrchestrationThreadStreamItem,53 OrchestrationThreadStreamItem,
54} from "./orchestration.ts";54} from "./orchestration.ts";
55import type { EnvironmentId } from "./baseSchemas.ts";55import type { EnvironmentId } from "./baseSchemas.ts";
56import type {
57 AuthBearerBootstrapResult,
58 AuthSessionState,
59 AuthWebSocketTokenResult,
60} from "./auth.ts";
61import type { AdvertisedEndpoint } from "./remoteAccess.ts";
56import { EditorId } from "./editor.ts";62import { EditorId } from "./editor.ts";
57import { ServerSettings, type ClientSettings, type ServerSettingsPatch } from "./settings.ts";63import type { ExecutionEnvironmentDescriptor } from "./environment.ts";
64import type { ClientSettings, ServerSettings, ServerSettingsPatch } from "./settings.ts";
58import type {65import type {
59 SourceControlCloneRepositoryInput,66 SourceControlCloneRepositoryInput,
60 SourceControlCloneRepositoryResult,67 SourceControlCloneRepositoryResult,
...@@ -135,6 +142,36 @@ export interface DesktopEnvironmentBootstrap {...@@ -135,6 +142,36 @@ export interface DesktopEnvironmentBootstrap {
135 bootstrapToken?: string;142 bootstrapToken?: string;
136}143}
137144
145export interface DesktopSshEnvironmentTarget {
146 alias: string;
147 hostname: string;
148 username: string | null;
149 port: number | null;
150}
151
152export type DesktopSshHostSource = "ssh-config" | "known-hosts";
153
154export interface DesktopDiscoveredSshHost extends DesktopSshEnvironmentTarget {
155 source: DesktopSshHostSource;
156}
157
158export interface DesktopSshEnvironmentBootstrap {
159 target: DesktopSshEnvironmentTarget;
160 httpBaseUrl: string;
161 wsBaseUrl: string;
162 pairingToken: string | null;
163 remotePort?: number;
164 remoteServerKind?: "external" | "managed";
165}
166
167export interface DesktopSshPasswordPromptRequest {
168 requestId: string;
169 destination: string;
170 username: string | null;
171 prompt: string;
172 expiresAt: string;
173}
174
138export interface PersistedSavedEnvironmentRecord {175export interface PersistedSavedEnvironmentRecord {
139 environmentId: EnvironmentId;176 environmentId: EnvironmentId;
140 label: string;177 label: string;
...@@ -142,6 +179,7 @@ export interface PersistedSavedEnvironmentRecord {...@@ -142,6 +179,7 @@ export interface PersistedSavedEnvironmentRecord {
142 httpBaseUrl: string;179 httpBaseUrl: string;
143 createdAt: string;180 createdAt: string;
144 lastConnectedAt: string | null;181 lastConnectedAt: string | null;
182 desktopSsh?: DesktopSshEnvironmentTarget;
145}183}
146184
147export type DesktopServerExposureMode = "local-only" | "network-accessible";185export type DesktopServerExposureMode = "local-only" | "network-accessible";
...@@ -150,6 +188,8 @@ export interface DesktopServerExposureState {...@@ -150,6 +188,8 @@ export interface DesktopServerExposureState {
150 mode: DesktopServerExposureMode;188 mode: DesktopServerExposureMode;
151 endpointUrl: string | null;189 endpointUrl: string | null;
152 advertisedHost: string | null;190 advertisedHost: string | null;
191 tailscaleServeEnabled: boolean;
192 tailscaleServePort: number;
153}193}
154194
155export interface PickFolderOptions {195export interface PickFolderOptions {
...@@ -168,8 +208,31 @@ export interface DesktopBridge {...@@ -168,8 +208,31 @@ export interface DesktopBridge {
168 getSavedEnvironmentSecret: (environmentId: EnvironmentId) => Promise<string | null>;208 getSavedEnvironmentSecret: (environmentId: EnvironmentId) => Promise<string | null>;
169 setSavedEnvironmentSecret: (environmentId: EnvironmentId, secret: string) => Promise<boolean>;209 setSavedEnvironmentSecret: (environmentId: EnvironmentId, secret: string) => Promise<boolean>;
170 removeSavedEnvironmentSecret: (environmentId: EnvironmentId) => Promise<void>;210 removeSavedEnvironmentSecret: (environmentId: EnvironmentId) => Promise<void>;
211 discoverSshHosts: () => Promise<readonly DesktopDiscoveredSshHost[]>;
212 ensureSshEnvironment: (
213 target: DesktopSshEnvironmentTarget,
214 options?: { issuePairingToken?: boolean },
215 ) => Promise<DesktopSshEnvironmentBootstrap>;
216 disconnectSshEnvironment: (target: DesktopSshEnvironmentTarget) => Promise<void>;
217 fetchSshEnvironmentDescriptor: (httpBaseUrl: string) => Promise<ExecutionEnvironmentDescriptor>;
218 bootstrapSshBearerSession: (
219 httpBaseUrl: string,
220 credential: string,
221 ) => Promise<AuthBearerBootstrapResult>;
222 fetchSshSessionState: (httpBaseUrl: string, bearerToken: string) => Promise<AuthSessionState>;
223 issueSshWebSocketToken: (
224 httpBaseUrl: string,
225 bearerToken: string,
226 ) => Promise<AuthWebSocketTokenResult>;
227 onSshPasswordPrompt: (listener: (request: DesktopSshPasswordPromptRequest) => void) => () => void;
228 resolveSshPasswordPrompt: (requestId: string, password: string | null) => Promise<void>;
171 getServerExposureState: () => Promise<DesktopServerExposureState>;229 getServerExposureState: () => Promise<DesktopServerExposureState>;
172 setServerExposureMode: (mode: DesktopServerExposureMode) => Promise<DesktopServerExposureState>;230 setServerExposureMode: (mode: DesktopServerExposureMode) => Promise<DesktopServerExposureState>;
231 setTailscaleServeEnabled: (input: {
232 readonly enabled: boolean;
233 readonly port?: number;
234 }) => Promise<DesktopServerExposureState>;
235 getAdvertisedEndpoints: () => Promise<readonly AdvertisedEndpoint[]>;
173 pickFolder: (options?: PickFolderOptions) => Promise<string | null>;236 pickFolder: (options?: PickFolderOptions) => Promise<string | null>;
174 confirm: (message: string) => Promise<boolean>;237 confirm: (message: string) => Promise<boolean>;
175 setTheme: (theme: DesktopTheme) => Promise<void>;238 setTheme: (theme: DesktopTheme) => Promise<void>;
packages/contracts/src/remoteAccess.ts created+68
...@@ -0,0 +1,68 @@
1import { Schema } from "effect";
2
3import { TrimmedNonEmptyString } from "./baseSchemas.ts";
4
5export const AdvertisedEndpointProviderKind = Schema.Literals([
6 "core",
7 "private-network",
8 "tunnel",
9 "manual",
10]);
11export type AdvertisedEndpointProviderKind = typeof AdvertisedEndpointProviderKind.Type;
12
13export const AdvertisedEndpointReachability = Schema.Literals([
14 "loopback",
15 "lan",
16 "private-network",
17 "public",
18]);
19export type AdvertisedEndpointReachability = typeof AdvertisedEndpointReachability.Type;
20
21export const AdvertisedEndpointHostedHttpsCompatibility = Schema.Literals([
22 "compatible",
23 "mixed-content-blocked",
24 "requires-configuration",
25 "unknown",
26]);
27export type AdvertisedEndpointHostedHttpsCompatibility =
28 typeof AdvertisedEndpointHostedHttpsCompatibility.Type;
29
30export const AdvertisedEndpointStatus = Schema.Literals(["available", "unavailable", "unknown"]);
31export type AdvertisedEndpointStatus = typeof AdvertisedEndpointStatus.Type;
32
33export const AdvertisedEndpointSource = Schema.Literals([
34 "desktop-core",
35 "desktop-addon",
36 "server",
37 "user",
38]);
39export type AdvertisedEndpointSource = typeof AdvertisedEndpointSource.Type;
40
41export const AdvertisedEndpointProvider = Schema.Struct({
42 id: TrimmedNonEmptyString,
43 label: TrimmedNonEmptyString,
44 kind: AdvertisedEndpointProviderKind,
45 isAddon: Schema.Boolean,
46});
47export type AdvertisedEndpointProvider = typeof AdvertisedEndpointProvider.Type;
48
49export const AdvertisedEndpointCompatibility = Schema.Struct({
50 hostedHttpsApp: AdvertisedEndpointHostedHttpsCompatibility,
51 desktopApp: Schema.Literals(["compatible", "unknown"]),
52});
53export type AdvertisedEndpointCompatibility = typeof AdvertisedEndpointCompatibility.Type;
54
55export const AdvertisedEndpoint = Schema.Struct({
56 id: TrimmedNonEmptyString,
57 label: TrimmedNonEmptyString,
58 provider: AdvertisedEndpointProvider,
59 httpBaseUrl: TrimmedNonEmptyString,
60 wsBaseUrl: TrimmedNonEmptyString,
61 reachability: AdvertisedEndpointReachability,
62 compatibility: AdvertisedEndpointCompatibility,
63 source: AdvertisedEndpointSource,
64 status: AdvertisedEndpointStatus,
65 isDefault: Schema.optional(Schema.Boolean),
66 description: Schema.optional(TrimmedNonEmptyString),
67});
68export type AdvertisedEndpoint = typeof AdvertisedEndpoint.Type;
packages/contracts/src/server.ts+1
...@@ -198,6 +198,7 @@ export const ServerConfigUpdatedPayload = Schema.Struct({...@@ -198,6 +198,7 @@ export const ServerConfigUpdatedPayload = Schema.Struct({
198export type ServerConfigUpdatedPayload = typeof ServerConfigUpdatedPayload.Type;198export type ServerConfigUpdatedPayload = typeof ServerConfigUpdatedPayload.Type;
199199
200export const ServerConfigKeybindingsUpdatedPayload = Schema.Struct({200export const ServerConfigKeybindingsUpdatedPayload = Schema.Struct({
201 keybindings: ResolvedKeybindingsConfig,
201 issues: ServerConfigIssues,202 issues: ServerConfigIssues,
202});203});
203export type ServerConfigKeybindingsUpdatedPayload =204export type ServerConfigKeybindingsUpdatedPayload =
packages/shared/package.json+4
...@@ -75,6 +75,10 @@...@@ -75,6 +75,10 @@
75 "./path": {75 "./path": {
76 "types": "./src/path.ts",76 "types": "./src/path.ts",
77 "import": "./src/path.ts"77 "import": "./src/path.ts"
78 },
79 "./keybindings": {
80 "types": "./src/keybindings.ts",
81 "import": "./src/keybindings.ts"
78 }82 }
79 },83 },
80 "scripts": {84 "scripts": {
packages/shared/src/keybindings.ts created+283
...@@ -0,0 +1,283 @@
1import {
2 type KeybindingRule,
3 type KeybindingShortcut,
4 type KeybindingWhenNode,
5 MAX_KEYBINDINGS_COUNT,
6 MAX_WHEN_EXPRESSION_DEPTH,
7 MODEL_PICKER_JUMP_KEYBINDING_COMMANDS,
8 type ResolvedKeybindingRule,
9 type ResolvedKeybindingsConfig,
10 THREAD_JUMP_KEYBINDING_COMMANDS,
11} from "@t3tools/contracts";
12
13type WhenToken =
14 | { type: "identifier"; value: string }
15 | { type: "not" }
16 | { type: "and" }
17 | { type: "or" }
18 | { type: "lparen" }
19 | { type: "rparen" };
20
21export const DEFAULT_KEYBINDINGS: ReadonlyArray<KeybindingRule> = [
22 { key: "mod+j", command: "terminal.toggle" },
23 { key: "mod+d", command: "terminal.split", when: "terminalFocus" },
24 { key: "mod+n", command: "terminal.new", when: "terminalFocus" },
25 { key: "mod+w", command: "terminal.close", when: "terminalFocus" },
26 { key: "mod+d", command: "diff.toggle", when: "!terminalFocus" },
27 { key: "mod+k", command: "commandPalette.toggle", when: "!terminalFocus" },
28 { key: "mod+n", command: "chat.new", when: "!terminalFocus" },
29 { key: "mod+shift+o", command: "chat.new", when: "!terminalFocus" },
30 { key: "mod+shift+n", command: "chat.newLocal", when: "!terminalFocus" },
31 { key: "mod+shift+m", command: "modelPicker.toggle", when: "!terminalFocus" },
32 { key: "mod+o", command: "editor.openFavorite" },
33 { key: "mod+shift+[", command: "thread.previous" },
34 { key: "mod+shift+]", command: "thread.next" },
35 ...THREAD_JUMP_KEYBINDING_COMMANDS.map((command, index) => ({
36 key: `mod+${index + 1}`,
37 command,
38 })),
39 ...MODEL_PICKER_JUMP_KEYBINDING_COMMANDS.map((command, index) => ({
40 key: `mod+${index + 1}`,
41 command,
42 when: "modelPickerOpen",
43 })),
44];
45
46function normalizeKeyToken(token: string): string {
47 if (token === "space") return " ";
48 if (token === "esc") return "escape";
49 return token;
50}
51
52export function parseKeybindingShortcut(value: string): KeybindingShortcut | null {
53 const rawTokens = value
54 .toLowerCase()
55 .split("+")
56 .map((token) => token.trim());
57 const tokens = [...rawTokens];
58 let trailingEmptyCount = 0;
59 while (tokens[tokens.length - 1] === "") {
60 trailingEmptyCount += 1;
61 tokens.pop();
62 }
63 if (trailingEmptyCount > 0) {
64 tokens.push("+");
65 }
66 if (tokens.some((token) => token.length === 0)) {
67 return null;
68 }
69 if (tokens.length === 0) return null;
70
71 let key: string | null = null;
72 let metaKey = false;
73 let ctrlKey = false;
74 let shiftKey = false;
75 let altKey = false;
76 let modKey = false;
77
78 for (const token of tokens) {
79 switch (token) {
80 case "cmd":
81 case "meta":
82 metaKey = true;
83 break;
84 case "ctrl":
85 case "control":
86 ctrlKey = true;
87 break;
88 case "shift":
89 shiftKey = true;
90 break;
91 case "alt":
92 case "option":
93 altKey = true;
94 break;
95 case "mod":
96 modKey = true;
97 break;
98 default: {
99 if (key !== null) return null;
100 key = normalizeKeyToken(token);
101 }
102 }
103 }
104
105 if (key === null) return null;
106 return {
107 key,
108 metaKey,
109 ctrlKey,
110 shiftKey,
111 altKey,
112 modKey,
113 };
114}
115
116function tokenizeWhenExpression(expression: string): WhenToken[] | null {
117 const tokens: WhenToken[] = [];
118 let index = 0;
119
120 while (index < expression.length) {
121 const current = expression[index];
122 if (!current) break;
123
124 if (/\s/.test(current)) {
125 index += 1;
126 continue;
127 }
128 if (expression.startsWith("&&", index)) {
129 tokens.push({ type: "and" });
130 index += 2;
131 continue;
132 }
133 if (expression.startsWith("||", index)) {
134 tokens.push({ type: "or" });
135 index += 2;
136 continue;
137 }
138 if (current === "!") {
139 tokens.push({ type: "not" });
140 index += 1;
141 continue;
142 }
143 if (current === "(") {
144 tokens.push({ type: "lparen" });
145 index += 1;
146 continue;
147 }
148 if (current === ")") {
149 tokens.push({ type: "rparen" });
150 index += 1;
151 continue;
152 }
153
154 const identifier = /^[A-Za-z_][A-Za-z0-9_.-]*/.exec(expression.slice(index));
155 if (!identifier) {
156 return null;
157 }
158 tokens.push({ type: "identifier", value: identifier[0] });
159 index += identifier[0].length;
160 }
161
162 return tokens;
163}
164
165function parseKeybindingWhenExpression(expression: string): KeybindingWhenNode | null {
166 const tokens = tokenizeWhenExpression(expression);
167 if (!tokens || tokens.length === 0) return null;
168 let index = 0;
169
170 const parsePrimary = (depth: number): KeybindingWhenNode | null => {
171 if (depth > MAX_WHEN_EXPRESSION_DEPTH) {
172 return null;
173 }
174 const token = tokens[index];
175 if (!token) return null;
176
177 if (token.type === "identifier") {
178 index += 1;
179 return { type: "identifier", name: token.value };
180 }
181
182 if (token.type === "lparen") {
183 index += 1;
184 const expressionNode = parseOr(depth + 1);
185 const closeToken = tokens[index];
186 if (!expressionNode || !closeToken || closeToken.type !== "rparen") {
187 return null;
188 }
189 index += 1;
190 return expressionNode;
191 }
192
193 return null;
194 };
195
196 const parseUnary = (depth: number): KeybindingWhenNode | null => {
197 let notCount = 0;
198 while (tokens[index]?.type === "not") {
199 index += 1;
200 notCount += 1;
201 if (notCount > MAX_WHEN_EXPRESSION_DEPTH) {
202 return null;
203 }
204 }
205
206 let node = parsePrimary(depth);
207 if (!node) return null;
208
209 while (notCount > 0) {
210 node = { type: "not", node };
211 notCount -= 1;
212 }
213
214 return node;
215 };
216
217 const parseAnd = (depth: number): KeybindingWhenNode | null => {
218 let left = parseUnary(depth);
219 if (!left) return null;
220
221 while (tokens[index]?.type === "and") {
222 index += 1;
223 const right = parseUnary(depth);
224 if (!right) return null;
225 left = { type: "and", left, right };
226 }
227
228 return left;
229 };
230
231 const parseOr = (depth: number): KeybindingWhenNode | null => {
232 let left = parseAnd(depth);
233 if (!left) return null;
234
235 while (tokens[index]?.type === "or") {
236 index += 1;
237 const right = parseAnd(depth);
238 if (!right) return null;
239 left = { type: "or", left, right };
240 }
241
242 return left;
243 };
244
245 const ast = parseOr(0);
246 if (!ast || index !== tokens.length) return null;
247 return ast;
248}
249
250export function compileResolvedKeybindingRule(rule: KeybindingRule): ResolvedKeybindingRule | null {
251 const shortcut = parseKeybindingShortcut(rule.key);
252 if (!shortcut) return null;
253
254 if (rule.when !== undefined) {
255 const whenAst = parseKeybindingWhenExpression(rule.when);
256 if (!whenAst) return null;
257 return {
258 command: rule.command,
259 shortcut,
260 whenAst,
261 };
262 }
263
264 return {
265 command: rule.command,
266 shortcut,
267 };
268}
269
270export function compileResolvedKeybindingsConfig(
271 config: ReadonlyArray<KeybindingRule>,
272): ResolvedKeybindingsConfig {
273 const compiled: ResolvedKeybindingRule[] = [];
274 for (const rule of config) {
275 const result = compileResolvedKeybindingRule(rule);
276 if (result) {
277 compiled.push(result);
278 }
279 }
280 return compiled.slice(-MAX_KEYBINDINGS_COUNT);
281}
282
283export const DEFAULT_RESOLVED_KEYBINDINGS = compileResolvedKeybindingsConfig(DEFAULT_KEYBINDINGS);
packages/ssh/package.json created+46
...@@ -0,0 +1,46 @@
1{
2 "name": "@t3tools/ssh",
3 "version": "0.0.0-alpha.1",
4 "private": true,
5 "type": "module",
6 "exports": {
7 "./auth": {
8 "types": "./src/auth.ts",
9 "import": "./src/auth.ts"
10 },
11 "./command": {
12 "types": "./src/command.ts",
13 "import": "./src/command.ts"
14 },
15 "./config": {
16 "types": "./src/config.ts",
17 "import": "./src/config.ts"
18 },
19 "./errors": {
20 "types": "./src/errors.ts",
21 "import": "./src/errors.ts"
22 },
23 "./tunnel": {
24 "types": "./src/tunnel.ts",
25 "import": "./src/tunnel.ts"
26 }
27 },
28 "scripts": {
29 "prepare": "effect-language-service patch",
30 "typecheck": "tsc --noEmit",
31 "test": "vitest run"
32 },
33 "dependencies": {
34 "@t3tools/contracts": "workspace:*",
35 "@t3tools/shared": "workspace:*",
36 "effect": "catalog:"
37 },
38 "devDependencies": {
39 "@effect/language-service": "catalog:",
40 "@effect/platform-node": "catalog:",
41 "@effect/vitest": "catalog:",
42 "@types/node": "catalog:",
43 "typescript": "catalog:",
44 "vitest": "catalog:"
45 }
46}
packages/ssh/src/auth.test.ts created+65
...@@ -0,0 +1,65 @@
1import * as NodeServices from "@effect/platform-node/NodeServices";
2import { assert, describe, it } from "@effect/vitest";
3import { Effect, FileSystem, Path } from "effect";
4
5import {
6 buildSshAskpassHelperDescriptor,
7 buildSshChildEnvironment,
8 isSshAuthFailure,
9} from "./auth.ts";
10
11describe("ssh auth", () => {
12 it.effect("detects ssh auth failures from common permission denied messages", () =>
13 Effect.sync(() => {
14 assert.equal(
15 isSshAuthFailure(
16 new Error(
17 "julius@100.65.180.100: Permission denied (publickey,password,keyboard-interactive).",
18 ),
19 ),
20 true,
21 );
22 assert.equal(isSshAuthFailure(new Error("Permission denied (publickey).")), true);
23 assert.equal(isSshAuthFailure(new Error("Connection timed out")), false);
24 assert.equal(isSshAuthFailure(new Error("mkdir: Permission denied")), false);
25 }),
26 );
27
28 it.effect("creates askpass env for cached password prompts", () =>
29 Effect.gen(function* () {
30 const fs = yield* FileSystem.FileSystem;
31 const path = yield* Path.Path;
32 const directory = yield* fs.makeTempDirectoryScoped({ prefix: "t3-ssh-askpass-test-" });
33 const env = yield* buildSshChildEnvironment({
34 authSecret: "super-secret",
35 interactiveAuth: true,
36 askpassDirectory: directory,
37 platform: "linux",
38 baseEnv: {},
39 });
40
41 const askpassPath = path.join(directory, "ssh-askpass.sh");
42 assert.equal(env.SSH_ASKPASS, askpassPath);
43 assert.equal(env.SSH_ASKPASS_REQUIRE, "force");
44 assert.equal(env.T3_SSH_AUTH_SECRET, "super-secret");
45 assert.equal(env.DISPLAY, "t3code");
46 assert.equal(yield* fs.exists(askpassPath), true);
47 assert.include(yield* fs.readFileString(askpassPath), 'printf "%s\\n" "$T3_SSH_AUTH_SECRET"');
48 }).pipe(Effect.provide(NodeServices.layer), Effect.scoped),
49 );
50
51 it.effect("builds a windows askpass launcher pair", () =>
52 Effect.gen(function* () {
53 const descriptor = yield* buildSshAskpassHelperDescriptor({
54 directory: "C:\\temp\\t3code-ssh-askpass",
55 platform: "win32",
56 }).pipe(Effect.provide(NodeServices.layer));
57
58 assert.equal(descriptor.launcherPath, "C:\\temp\\t3code-ssh-askpass\\ssh-askpass.cmd");
59 assert.deepEqual(
60 descriptor.files.map((file) => file.path.split("\\").at(-1)),
61 ["ssh-askpass.cmd", "ssh-askpass.ps1"],
62 );
63 }),
64 );
65});
packages/ssh/src/auth.ts created+205
...@@ -0,0 +1,205 @@
1import { Context, Effect, FileSystem, Layer, Path, PlatformError } from "effect";
2
3import { SshPasswordPromptError } from "./errors.ts";
4
5export interface SshPasswordRequest {
6 readonly destination: string;
7 readonly username: string | null;
8 readonly prompt: string;
9 readonly attempt: number;
10}
11
12export interface SshAskpassFile {
13 readonly path: string;
14 readonly contents: string;
15 readonly mode?: number;
16}
17
18export interface SshAskpassHelperDescriptor {
19 readonly launcherPath: string;
20 readonly files: ReadonlyArray<SshAskpassFile>;
21}
22
23export interface SshAuthOptions {
24 readonly authSecret?: string | null;
25 readonly batchMode?: "yes" | "no";
26 readonly interactiveAuth?: boolean;
27}
28
29export interface SshPasswordPromptShape {
30 readonly isAvailable: boolean;
31 readonly request: (
32 request: SshPasswordRequest,
33 ) => Effect.Effect<string | null, SshPasswordPromptError>;
34}
35
36export class SshPasswordPrompt extends Context.Service<SshPasswordPrompt, SshPasswordPromptShape>()(
37 "@t3tools/ssh/SshPasswordPrompt",
38) {
39 static readonly disabledLayer = Layer.succeed(
40 SshPasswordPrompt,
41 SshPasswordPrompt.of({
42 isAvailable: false,
43 request: () => Effect.succeed(null),
44 }),
45 );
46}
47
48export interface SshChildEnvironmentOptions {
49 readonly interactiveAuth?: boolean;
50 readonly baseEnv?: NodeJS.ProcessEnv;
51 readonly askpassDirectory?: string;
52 readonly authSecret?: string | null;
53 readonly platform?: NodeJS.Platform;
54}
55
56const SSH_ASKPASS_DIR_NAME = "t3code-ssh-askpass";
57
58function joinSshAskpassPath(
59 directory: string,
60 fileName: string,
61 platform: NodeJS.Platform,
62): string {
63 const trimmed = directory.replace(/[\\/]+$/u, "");
64 return platform === "win32" ? `${trimmed}\\${fileName}` : `${trimmed}/${fileName}`;
65}
66
67export const ASKPASS_POSIX_SCRIPT = `#!/bin/sh
68# Invoked by ssh via SSH_ASKPASS when T3 Code re-runs ssh with a cached password
69# from the renderer's in-app prompt. We never expose a native dialog here - if
70# T3_SSH_AUTH_SECRET is missing, that's a caller bug and we fail loudly.
71if [ "\${T3_SSH_AUTH_SECRET+x}" = "x" ]; then
72 printf "%s\\n" "$T3_SSH_AUTH_SECRET"
73 exit 0
74fi
75printf 'T3 Code ssh-askpass invoked without T3_SSH_AUTH_SECRET.\\n' >&2
76exit 1
77`;
78
79export const ASKPASS_WINDOWS_LAUNCHER_SCRIPT = `@echo off\r
80powershell -NoProfile -ExecutionPolicy Bypass -File "%~dp0ssh-askpass.ps1" %*\r
81`;
82
83export const ASKPASS_WINDOWS_SCRIPT = `# Invoked by ssh via SSH_ASKPASS (through ssh-askpass.cmd) when T3 Code re-runs\r
84# ssh with a cached password from the renderer's in-app prompt. We never expose\r
85# a native dialog here - if T3_SSH_AUTH_SECRET is missing, that's a caller bug\r
86# and we fail loudly.\r
87if ($null -ne $env:T3_SSH_AUTH_SECRET) {\r
88 [Console]::Out.WriteLine($env:T3_SSH_AUTH_SECRET)\r
89 exit 0\r
90}\r
91[Console]::Error.WriteLine("T3 Code ssh-askpass invoked without T3_SSH_AUTH_SECRET.")\r
92exit 1\r
93`;
94
95export const getDefaultSshAskpassDirectory = Effect.fn("ssh/auth.getDefaultSshAskpassDirectory")(
96 function* () {
97 const fs = yield* FileSystem.FileSystem;
98 const path = yield* Path.Path;
99 const parentDirectory = yield* fs.makeTempDirectory({ prefix: "t3code-ssh-runtime-" });
100 return path.join(parentDirectory, SSH_ASKPASS_DIR_NAME);
101 },
102);
103
104export const buildSshAskpassHelperDescriptor = Effect.fn(
105 "ssh/auth.buildSshAskpassHelperDescriptor",
106)(function* (input: {
107 readonly directory: string;
108 readonly platform?: NodeJS.Platform;
109}): Effect.fn.Return<SshAskpassHelperDescriptor, never, Path.Path> {
110 const platform = input.platform ?? process.platform;
111 const path = yield* Path.Path;
112 const directory = input.directory;
113
114 if (platform === "win32") {
115 const powershellPath = joinSshAskpassPath(directory, "ssh-askpass.ps1", platform);
116 return {
117 launcherPath: joinSshAskpassPath(directory, "ssh-askpass.cmd", platform),
118 files: [
119 {
120 path: joinSshAskpassPath(directory, "ssh-askpass.cmd", platform),
121 contents: ASKPASS_WINDOWS_LAUNCHER_SCRIPT,
122 },
123 {
124 path: powershellPath,
125 contents: ASKPASS_WINDOWS_SCRIPT,
126 },
127 ],
128 };
129 }
130
131 return {
132 launcherPath: path.join(directory, "ssh-askpass.sh"),
133 files: [
134 {
135 path: path.join(directory, "ssh-askpass.sh"),
136 contents: ASKPASS_POSIX_SCRIPT,
137 mode: 0o700,
138 },
139 ],
140 };
141});
142
143export const ensureSshAskpassHelpers = Effect.fn("ssh/auth.ensureSshAskpassHelpers")(
144 function* (input: {
145 readonly directory: string;
146 readonly platform?: NodeJS.Platform;
147 }): Effect.fn.Return<string, PlatformError.PlatformError, FileSystem.FileSystem | Path.Path> {
148 const fs = yield* FileSystem.FileSystem;
149 const path = yield* Path.Path;
150 const descriptor = yield* buildSshAskpassHelperDescriptor(input);
151 const platform = input.platform ?? process.platform;
152
153 yield* fs.makeDirectory(path.dirname(descriptor.launcherPath), { recursive: true });
154
155 for (const file of descriptor.files) {
156 const existing = yield* fs.exists(file.path);
157 const current = existing ? yield* fs.readFileString(file.path) : null;
158 if (current !== file.contents) {
159 yield* fs.writeFileString(file.path, file.contents);
160 }
161 if (file.mode !== undefined && platform !== "win32") {
162 yield* fs.chmod(file.path, file.mode);
163 }
164 }
165
166 return descriptor.launcherPath;
167 },
168);
169
170export const buildSshChildEnvironment = Effect.fn("ssh/auth.buildSshChildEnvironment")(function* (
171 input: SshChildEnvironmentOptions = {},
172): Effect.fn.Return<
173 NodeJS.ProcessEnv,
174 PlatformError.PlatformError,
175 FileSystem.FileSystem | Path.Path
176> {
177 const baseEnv = { ...(input.baseEnv ?? process.env) };
178 if (!input.interactiveAuth) {
179 return baseEnv;
180 }
181
182 const platform = input.platform ?? process.platform;
183 const directory = input.askpassDirectory ?? (yield* getDefaultSshAskpassDirectory());
184 const sshAskpass = yield* ensureSshAskpassHelpers({ directory, platform });
185
186 return {
187 ...baseEnv,
188 SSH_ASKPASS: sshAskpass,
189 SSH_ASKPASS_REQUIRE: "force",
190 ...(input.authSecret === undefined ? {} : { T3_SSH_AUTH_SECRET: input.authSecret ?? "" }),
191 ...(platform === "win32" || baseEnv.DISPLAY ? {} : { DISPLAY: "t3code" }),
192 };
193});
194
195export function isSshAuthFailure(error: unknown): boolean {
196 const message = error instanceof Error ? error.message : String(error);
197 const normalized = message.toLowerCase();
198 return (
199 /permission denied \((?:publickey|password|keyboard-interactive|hostbased|gssapi-with-mic)[^)]*\)/u.test(
200 normalized,
201 ) ||
202 /authentication failed/u.test(normalized) ||
203 /too many authentication failures/u.test(normalized)
204 );
205}
packages/ssh/src/command.test.ts created+151
...@@ -0,0 +1,151 @@
1import { assert, describe, it } from "@effect/vitest";
2import * as NodeServices from "@effect/platform-node/NodeServices";
3import { Duration, Effect, Fiber, Layer, Result, Sink, Stream } from "effect";
4import { TestClock } from "effect/testing";
5import { ChildProcessSpawner } from "effect/unstable/process";
6
7import {
8 baseSshArgs,
9 getLastNonEmptyOutputLine,
10 parseSshResolveOutput,
11 resolveRemoteT3CliPackageSpec,
12 runSshCommand,
13} from "./command.ts";
14
15const makeNeverFinishingProcess = () => {
16 let finish: ((exitCode: ChildProcessSpawner.ExitCode) => void) | null = null;
17 return ChildProcessSpawner.makeHandle({
18 pid: ChildProcessSpawner.ProcessId(123),
19 stdout: Stream.empty,
20 stderr: Stream.empty,
21 all: Stream.empty,
22 exitCode: Effect.callback<ChildProcessSpawner.ExitCode>((resume) => {
23 finish = (exitCode) => resume(Effect.succeed(exitCode));
24 return Effect.sync(() => {
25 finish = null;
26 });
27 }),
28 isRunning: Effect.succeed(true),
29 kill: () =>
30 Effect.sync(() => {
31 finish?.(ChildProcessSpawner.ExitCode(143));
32 }),
33 stdin: Sink.drain,
34 getInputFd: () => Sink.drain,
35 getOutputFd: () => Stream.empty,
36 unref: Effect.succeed(Effect.void),
37 });
38};
39
40describe("ssh command", () => {
41 it.effect("parses resolved ssh config output into a target", () =>
42 Effect.sync(() => {
43 assert.deepEqual(
44 parseSshResolveOutput(
45 "devbox",
46 ["hostname devbox.example.com", "user julius", "port 2222", ""].join("\n"),
47 ),
48 {
49 alias: "devbox",
50 hostname: "devbox.example.com",
51 username: "julius",
52 port: 2222,
53 },
54 );
55 }),
56 );
57
58 it.effect("builds interactive ssh args without forcing batch mode", () =>
59 Effect.sync(() => {
60 assert.deepEqual(
61 baseSshArgs(
62 {
63 alias: "devbox",
64 hostname: "devbox.example.com",
65 username: "julius",
66 port: 2222,
67 },
68 { batchMode: "no" },
69 ),
70 ["-o", "BatchMode=no", "-o", "ConnectTimeout=10", "-p", "2222"],
71 );
72 }),
73 );
74
75 it.effect("resolves the remote t3 package spec from the desktop release channel", () =>
76 Effect.sync(() => {
77 assert.equal(
78 resolveRemoteT3CliPackageSpec({
79 appVersion: "0.0.17",
80 updateChannel: "latest",
81 }),
82 "t3@0.0.17",
83 );
84 assert.equal(
85 resolveRemoteT3CliPackageSpec({
86 appVersion: "0.0.17-nightly.20260415.44",
87 updateChannel: "nightly",
88 }),
89 "t3@0.0.17-nightly.20260415.44",
90 );
91 assert.equal(
92 resolveRemoteT3CliPackageSpec({
93 appVersion: "0.0.0-dev",
94 updateChannel: "nightly",
95 isDevelopment: true,
96 }),
97 "t3@nightly",
98 );
99 assert.equal(
100 resolveRemoteT3CliPackageSpec({
101 appVersion: "0.0.0-dev",
102 updateChannel: "latest",
103 isDevelopment: true,
104 }),
105 "t3@nightly",
106 );
107 }),
108 );
109
110 it.effect("reads the last non-empty ssh output line", () =>
111 Effect.sync(() => {
112 assert.equal(
113 getLastNonEmptyOutputLine(
114 ["Welcome to the host", "", '{"credential":"pairing-token"}', ""].join("\n"),
115 ),
116 '{"credential":"pairing-token"}',
117 );
118 }),
119 );
120
121 it.effect("fails commands that never finish", () => {
122 const spawner = ChildProcessSpawner.make(() => Effect.succeed(makeNeverFinishingProcess()));
123 const spawnerLayer = Layer.succeed(ChildProcessSpawner.ChildProcessSpawner, spawner);
124 const processLayer = Layer.mergeAll(NodeServices.layer, spawnerLayer, TestClock.layer());
125
126 return Effect.gen(function* () {
127 const fiber = yield* Effect.forkChild(
128 Effect.result(
129 runSshCommand(
130 {
131 alias: "devbox",
132 hostname: "devbox.example.com",
133 username: "julius",
134 port: 2222,
135 },
136 { timeoutMs: 1 },
137 ),
138 ),
139 );
140 yield* Effect.yieldNow;
141 yield* TestClock.adjust(Duration.millis(1));
142
143 const result = yield* Fiber.join(fiber);
144
145 assert.isTrue(Result.isFailure(result));
146 if (Result.isFailure(result)) {
147 assert.include(result.failure.message, "SSH command timed out after 1ms.");
148 }
149 }).pipe(Effect.provide(processLayer));
150 });
151});
packages/ssh/src/command.ts created+337
...@@ -0,0 +1,337 @@
1import * as Crypto from "node:crypto";
2
3import type { DesktopSshEnvironmentTarget, DesktopUpdateChannel } from "@t3tools/contracts";
4import { Duration, Effect, FileSystem, Option, Path, Scope, Stream } from "effect";
5import { ChildProcess, ChildProcessSpawner } from "effect/unstable/process";
6
7import { buildSshChildEnvironment, type SshAuthOptions } from "./auth.ts";
8import { SshCommandError, SshInvalidTargetError } from "./errors.ts";
9
10const PUBLISHABLE_T3_VERSION_PATTERN = /^\d+\.\d+\.\d+(?:-[0-9A-Za-z.-]+)?$/u;
11const DEFAULT_SSH_COMMAND_TIMEOUT_MS = 60_000;
12
13const encoder = new TextEncoder();
14
15export interface SshCommandResult {
16 readonly stdout: string;
17 readonly stderr: string;
18}
19
20export interface RunSshCommandOptions extends SshAuthOptions {
21 readonly preHostArgs?: ReadonlyArray<string>;
22 readonly remoteCommandArgs?: ReadonlyArray<string>;
23 readonly stdin?: string;
24 readonly timeoutMs?: number;
25}
26
27export function parseSshResolveOutput(alias: string, stdout: string): DesktopSshEnvironmentTarget {
28 const values = new Map<string, string>();
29 for (const line of stdout.split(/\r?\n/u)) {
30 const trimmed = line.trim();
31 if (trimmed.length === 0) {
32 continue;
33 }
34 const [key, ...rest] = trimmed.split(/\s+/u);
35 if (!key || rest.length === 0 || values.has(key)) {
36 continue;
37 }
38 values.set(key, rest.join(" ").trim());
39 }
40
41 const hostname = values.get("hostname")?.trim() || alias;
42 const username = values.get("user")?.trim() || null;
43 const rawPort = values.get("port")?.trim() ?? "";
44 const parsedPort = Number.parseInt(rawPort, 10);
45
46 return {
47 alias,
48 hostname,
49 username,
50 port: Number.isInteger(parsedPort) ? parsedPort : null,
51 };
52}
53
54export function targetConnectionKey(target: DesktopSshEnvironmentTarget): string {
55 return `${target.alias}\u0000${target.hostname}\u0000${target.username ?? ""}\u0000${target.port ?? ""}`;
56}
57
58export function remoteStateKey(target: DesktopSshEnvironmentTarget): string {
59 return Crypto.createHash("sha256").update(targetConnectionKey(target)).digest("hex").slice(0, 16);
60}
61
62export function buildSshHostSpec(target: DesktopSshEnvironmentTarget): string {
63 const destination = target.alias.trim() || target.hostname.trim();
64 if (destination.length === 0) {
65 throw new Error("SSH target is missing its alias/hostname.");
66 }
67 return target.username ? `${target.username}@${destination}` : destination;
68}
69
70export const buildSshHostSpecEffect = (
71 target: DesktopSshEnvironmentTarget,
72): Effect.Effect<string, SshInvalidTargetError> =>
73 Effect.try({
74 try: () => buildSshHostSpec(target),
75 catch: (cause) =>
76 new SshInvalidTargetError({
77 message: cause instanceof Error ? cause.message : "SSH target is invalid.",
78 }),
79 });
80
81export function baseSshArgs(
82 target: DesktopSshEnvironmentTarget,
83 input?: { readonly batchMode?: "yes" | "no" },
84): string[] {
85 return [
86 "-o",
87 `BatchMode=${input?.batchMode ?? "no"}`,
88 "-o",
89 "ConnectTimeout=10",
90 ...(target.port !== null ? ["-p", String(target.port)] : []),
91 ];
92}
93
94export function getLastNonEmptyOutputLine(stdout: string): string | null {
95 return (
96 stdout
97 .trim()
98 .split(/\r?\n/u)
99 .map((entry) => entry.trim())
100 .findLast((entry) => entry.length > 0) ?? null
101 );
102}
103
104export const collectProcessOutput = <E>(
105 stream: Stream.Stream<Uint8Array, E>,
106): Effect.Effect<string, E> =>
107 stream.pipe(
108 Stream.decodeText(),
109 Stream.runFold(
110 () => "",
111 (acc, chunk) => acc + chunk,
112 ),
113 );
114
115function normalizeSshErrorMessage(stderr: string, fallbackMessage: string): string {
116 const cleaned = stderr.trim();
117 return cleaned.length > 0 ? cleaned : fallbackMessage;
118}
119
120function sshTargetLogFields(target: DesktopSshEnvironmentTarget) {
121 return {
122 alias: target.alias,
123 hostname: target.hostname,
124 username: target.username,
125 port: target.port,
126 };
127}
128
129function stdinStream(input: string | undefined) {
130 return input === undefined ? Stream.empty : Stream.make(encoder.encode(input));
131}
132
133const runSshCommandInScope = Effect.fn("ssh/command.runSshCommand.inScope")(function* (
134 target: DesktopSshEnvironmentTarget,
135 input: RunSshCommandOptions,
136 commandScope: Scope.Scope,
137): Effect.fn.Return<
138 SshCommandResult,
139 SshCommandError | SshInvalidTargetError,
140 ChildProcessSpawner.ChildProcessSpawner | FileSystem.FileSystem | Path.Path
141> {
142 const hostSpec = yield* buildSshHostSpecEffect(target);
143 const environment = yield* buildSshChildEnvironment({
144 ...(input.interactiveAuth === undefined ? {} : { interactiveAuth: input.interactiveAuth }),
145 ...(input.authSecret === undefined ? {} : { authSecret: input.authSecret }),
146 }).pipe(
147 Effect.mapError(
148 (cause) =>
149 new SshCommandError({
150 command: ["ssh"],
151 exitCode: null,
152 stderr: "",
153 message: "Failed to prepare SSH authentication helpers.",
154 cause,
155 }),
156 ),
157 );
158 const args = [
159 ...baseSshArgs(target, {
160 batchMode: input.batchMode ?? (input.interactiveAuth ? "no" : "yes"),
161 }),
162 ...(input.preHostArgs ?? []),
163 hostSpec,
164 ...(input.remoteCommandArgs ?? []),
165 ];
166 const spawner = yield* ChildProcessSpawner.ChildProcessSpawner;
167 yield* Effect.logDebug("ssh.command.start", {
168 ...sshTargetLogFields(target),
169 command: ["ssh", ...args],
170 hasStdin: input.stdin !== undefined,
171 timeoutMs: input.timeoutMs ?? DEFAULT_SSH_COMMAND_TIMEOUT_MS,
172 });
173 const child = yield* spawner
174 .spawn(
175 ChildProcess.make("ssh", args, {
176 env: environment,
177 shell: process.platform === "win32",
178 stdin: {
179 stream: stdinStream(input.stdin),
180 endOnDone: true,
181 },
182 }),
183 )
184 .pipe(
185 Effect.provideService(Scope.Scope, commandScope),
186 Effect.mapError(
187 (cause) =>
188 new SshCommandError({
189 command: ["ssh", ...args],
190 exitCode: null,
191 stderr: "",
192 message:
193 cause instanceof Error
194 ? cause.message
195 : `Failed to spawn SSH command for ${hostSpec}.`,
196 cause,
197 }),
198 ),
199 );
200
201 const [stdout, stderr, exitCode] = yield* Effect.all(
202 [
203 collectProcessOutput(child.stdout),
204 collectProcessOutput(child.stderr),
205 child.exitCode.pipe(Effect.map(Number)),
206 ],
207 { concurrency: "unbounded" },
208 ).pipe(
209 Effect.mapError(
210 (cause) =>
211 new SshCommandError({
212 command: ["ssh", ...args],
213 exitCode: null,
214 stderr: "",
215 message:
216 cause instanceof Error ? cause.message : `Failed to run SSH command for ${hostSpec}.`,
217 cause,
218 }),
219 ),
220 );
221
222 if (exitCode !== 0) {
223 yield* Effect.logWarning("ssh.command.failed", {
224 ...sshTargetLogFields(target),
225 command: ["ssh", ...args],
226 exitCode,
227 stderr,
228 });
229 return yield* new SshCommandError({
230 command: ["ssh", ...args],
231 exitCode,
232 stderr,
233 message: normalizeSshErrorMessage(
234 stderr,
235 `SSH command failed for ${hostSpec} (exit ${exitCode}).`,
236 ),
237 });
238 }
239
240 yield* Effect.logDebug("ssh.command.succeeded", {
241 ...sshTargetLogFields(target),
242 command: ["ssh", ...args],
243 });
244 return { stdout, stderr };
245});
246
247export const runSshCommand = Effect.fn("ssh/command.runSshCommand")(function* (
248 target: DesktopSshEnvironmentTarget,
249 input: RunSshCommandOptions = {},
250): Effect.fn.Return<
251 SshCommandResult,
252 SshCommandError | SshInvalidTargetError,
253 ChildProcessSpawner.ChildProcessSpawner | FileSystem.FileSystem | Path.Path
254> {
255 return yield* Effect.scopedWith((commandScope) =>
256 runSshCommandInScope(target, input, commandScope),
257 ).pipe(
258 Effect.timeoutOption(Duration.millis(input.timeoutMs ?? DEFAULT_SSH_COMMAND_TIMEOUT_MS)),
259 Effect.flatMap((result) =>
260 Option.match(result, {
261 onSome: Effect.succeed,
262 onNone: () =>
263 Effect.gen(function* () {
264 yield* Effect.logWarning("ssh.command.timedOut", {
265 ...sshTargetLogFields(target),
266 timeoutMs: input.timeoutMs ?? DEFAULT_SSH_COMMAND_TIMEOUT_MS,
267 remoteCommandArgs: input.remoteCommandArgs ?? [],
268 preHostArgs: input.preHostArgs ?? [],
269 hasStdin: input.stdin !== undefined,
270 });
271 return yield* new SshCommandError({
272 command: ["ssh"],
273 exitCode: null,
274 stderr: "",
275 message: `SSH command timed out after ${input.timeoutMs ?? DEFAULT_SSH_COMMAND_TIMEOUT_MS}ms.`,
276 });
277 }),
278 }),
279 ),
280 );
281});
282
283export const resolveSshTarget = Effect.fn("ssh/command.resolveSshTarget")(function* (
284 alias: string,
285): Effect.fn.Return<
286 DesktopSshEnvironmentTarget,
287 SshCommandError | SshInvalidTargetError,
288 ChildProcessSpawner.ChildProcessSpawner | FileSystem.FileSystem | Path.Path
289> {
290 const trimmedAlias = alias.trim();
291 if (trimmedAlias.length === 0) {
292 return yield* new SshInvalidTargetError({ message: "SSH host alias is required." });
293 }
294
295 yield* Effect.logDebug("ssh.target.resolve.start", { alias: trimmedAlias });
296 return yield* runSshCommand(
297 {
298 alias: trimmedAlias,
299 hostname: trimmedAlias,
300 username: null,
301 port: null,
302 },
303 { preHostArgs: ["-G"] },
304 ).pipe(
305 Effect.map((result) => parseSshResolveOutput(trimmedAlias, result.stdout)),
306 Effect.tap((target) =>
307 Effect.logDebug("ssh.target.resolve.succeeded", sshTargetLogFields(target)),
308 ),
309 Effect.catch((cause) =>
310 Effect.logDebug("ssh.target.resolve.fallback", { alias: trimmedAlias, cause }).pipe(
311 Effect.as({
312 alias: trimmedAlias,
313 hostname: trimmedAlias,
314 username: null,
315 port: null,
316 }),
317 ),
318 ),
319 );
320});
321
322export function resolveRemoteT3CliPackageSpec(input: {
323 readonly appVersion: string;
324 readonly updateChannel: DesktopUpdateChannel;
325 readonly isDevelopment?: boolean;
326}): string {
327 const appVersion = input.appVersion.trim();
328 if (!input.isDevelopment && PUBLISHABLE_T3_VERSION_PATTERN.test(appVersion)) {
329 return `t3@${appVersion}`;
330 }
331
332 if (input.isDevelopment) {
333 return "t3@nightly";
334 }
335
336 return input.updateChannel === "nightly" ? "t3@nightly" : "t3@latest";
337}
packages/ssh/src/config.test.ts created+136
...@@ -0,0 +1,136 @@
1import * as NodeServices from "@effect/platform-node/NodeServices";
2import { assert, describe, it } from "@effect/vitest";
3import { Effect, FileSystem, Path } from "effect";
4
5import {
6 discoverSshHosts,
7 parseKnownHostsHostnames,
8 resolveSshConfigIncludePattern,
9} from "./config.ts";
10
11function makeTempHomeDir() {
12 return Effect.gen(function* () {
13 const fs = yield* FileSystem.FileSystem;
14 return yield* fs.makeTempDirectoryScoped({ prefix: "t3-ssh-test-" });
15 });
16}
17
18describe("ssh config", () => {
19 it.effect("discovers ssh config hosts across included files", () =>
20 Effect.gen(function* () {
21 const fs = yield* FileSystem.FileSystem;
22 const path = yield* Path.Path;
23 const homeDir = yield* makeTempHomeDir();
24 const sshDir = path.join(homeDir, ".ssh");
25 yield* fs.makeDirectory(path.join(sshDir, "config.d"), { recursive: true });
26 yield* fs.writeFileString(
27 path.join(sshDir, "config"),
28 [
29 "Host devbox",
30 " HostName devbox.example.com",
31 "Host=equalsbox",
32 "Include=config.d/*.conf",
33 "",
34 ].join("\n"),
35 );
36 yield* fs.writeFileString(
37 path.join(sshDir, "config.d", "team.conf"),
38 [
39 "Host staging",
40 " HostName staging.example.com",
41 "Host *",
42 " ServerAliveInterval 30",
43 "",
44 ].join("\n"),
45 );
46 yield* fs.writeFileString(
47 path.join(sshDir, "known_hosts"),
48 [
49 "known.example.com ssh-ed25519 AAAA",
50 "|1|hashed|entry ssh-ed25519 AAAA",
51 "[bastion.example.com]:2222 ssh-ed25519 AAAA",
52 "",
53 ].join("\n"),
54 );
55
56 const hosts = yield* discoverSshHosts({ homeDir });
57 assert.deepEqual(hosts, [
58 {
59 alias: "bastion.example.com",
60 hostname: "bastion.example.com",
61 username: null,
62 port: null,
63 source: "known-hosts",
64 },
65 {
66 alias: "devbox",
67 hostname: "devbox",
68 username: null,
69 port: null,
70 source: "ssh-config",
71 },
72 {
73 alias: "equalsbox",
74 hostname: "equalsbox",
75 username: null,
76 port: null,
77 source: "ssh-config",
78 },
79 {
80 alias: "known.example.com",
81 hostname: "known.example.com",
82 username: null,
83 port: null,
84 source: "known-hosts",
85 },
86 {
87 alias: "staging",
88 hostname: "staging",
89 username: null,
90 port: null,
91 source: "ssh-config",
92 },
93 ]);
94 }).pipe(Effect.provide(NodeServices.layer), Effect.scoped),
95 );
96
97 it.effect("parses known_hosts entries without returning hashed hosts", () =>
98 Effect.sync(() => {
99 assert.deepEqual(
100 parseKnownHostsHostnames(
101 [
102 "github.com ssh-ed25519 AAAA",
103 "gitlab.com,gitlab-alias ssh-ed25519 BBBB",
104 "|1|hashed|entry ssh-ed25519 CCCC",
105 "@cert-authority *.example.com ssh-ed25519 DDDD",
106 "[ssh.example.com]:2200 ssh-ed25519 EEEE",
107 "port.example.com:22 ssh-ed25519 HHHH",
108 "::1 ssh-ed25519 FFFF",
109 "2001:db8::1 ssh-ed25519 GGGG",
110 "",
111 ].join("\n"),
112 ),
113 [
114 "::1",
115 "2001:db8::1",
116 "github.com",
117 "gitlab-alias",
118 "gitlab.com",
119 "port.example.com",
120 "ssh.example.com",
121 ],
122 );
123 }),
124 );
125
126 it.effect("expands tilde-prefixed ssh config include patterns", () =>
127 Effect.gen(function* () {
128 const pattern = yield* resolveSshConfigIncludePattern(
129 "~/.ssh/config.d/*.conf",
130 "/tmp/project",
131 "/tmp/home",
132 );
133 assert.equal(pattern, "/tmp/home/.ssh/config.d/*.conf");
134 }).pipe(Effect.provide(NodeServices.layer)),
135 );
136});
packages/ssh/src/config.ts created+251
...@@ -0,0 +1,251 @@
1import type { DesktopDiscoveredSshHost } from "@t3tools/contracts";
2
3import { Effect, FileSystem, Path, PlatformError } from "effect";
4
5import { SshHostDiscoveryError } from "./errors.ts";
6
7const NO_HOSTS: ReadonlyArray<string> = [] as const;
8
9function stripInlineComment(line: string): string {
10 const hashIndex = line.indexOf("#");
11 return (hashIndex >= 0 ? line.slice(0, hashIndex) : line).trim();
12}
13
14function splitDirectiveArgs(value: string): ReadonlyArray<string> {
15 return value
16 .replace(/=(?!=)/gu, " ")
17 .trim()
18 .split(/\s+/u)
19 .map((entry) => entry.trim())
20 .filter((entry) => entry.length > 0);
21}
22
23function expandHomePath(input: string, homeDir: string): string {
24 return input.replace(/^~(?=$|\/|\\)/u, homeDir);
25}
26
27export const resolveSshConfigIncludePattern = Effect.fnUntraced(function* (
28 includePattern: string,
29 _directory: string,
30 homeDir: string,
31) {
32 const path = yield* Path.Path;
33 const expandedPattern = expandHomePath(includePattern, homeDir);
34 return path.isAbsolute(expandedPattern)
35 ? expandedPattern
36 : path.resolve(path.join(homeDir, ".ssh"), expandedPattern);
37});
38
39function hasSshPattern(value: string): boolean {
40 return value.includes("*") || value.includes("?") || value.startsWith("!");
41}
42
43function escapeRegex(value: string): string {
44 return value.replace(/[.*+?^${}()|[\]\\]/gu, "\\$&");
45}
46
47function globToRegExp(pattern: string): RegExp {
48 return new RegExp(
49 `^${escapeRegex(pattern).replace(/\\\*/gu, ".*").replace(/\\\?/gu, ".")}$`,
50 "u",
51 );
52}
53
54const expandGlob = Effect.fnUntraced(function* (pattern: string) {
55 const fs = yield* FileSystem.FileSystem;
56 const path = yield* Path.Path;
57 if (!pattern.includes("*") && !pattern.includes("?")) {
58 return (yield* fs.exists(pattern)) ? [pattern] : NO_HOSTS;
59 }
60
61 const directory = path.dirname(pattern);
62 const basePattern = path.basename(pattern);
63 if (!(yield* fs.exists(directory))) {
64 return NO_HOSTS;
65 }
66
67 const matcher = globToRegExp(basePattern);
68 const entries = yield* fs.readDirectory(directory);
69 const matchedPaths: string[] = [];
70 for (const entry of entries) {
71 if (!matcher.test(entry)) {
72 continue;
73 }
74 const entryPath = path.join(directory, entry);
75 if (yield* fs.exists(entryPath)) {
76 matchedPaths.push(entryPath);
77 }
78 }
79 return matchedPaths.toSorted((left, right) => left.localeCompare(right));
80});
81
82export const collectSshConfigAliasesFromFile = Effect.fnUntraced(function* (
83 filePath: string,
84 visited = new Set<string>(),
85 homeDir: string,
86): Effect.fn.Return<
87 ReadonlyArray<string>,
88 PlatformError.PlatformError,
89 FileSystem.FileSystem | Path.Path
90> {
91 const fs = yield* FileSystem.FileSystem;
92 const path = yield* Path.Path;
93 const resolvedPath = path.resolve(filePath);
94 if (visited.has(resolvedPath) || !(yield* fs.exists(resolvedPath))) {
95 return NO_HOSTS;
96 }
97 visited.add(resolvedPath);
98
99 const aliases = new Set<string>();
100 const directory = path.dirname(resolvedPath);
101 const raw = yield* fs.readFileString(resolvedPath);
102
103 for (const line of raw.split(/\r?\n/u)) {
104 const stripped = stripInlineComment(line);
105 if (stripped.length === 0) {
106 continue;
107 }
108
109 const [directive = "", ...rawArgs] = splitDirectiveArgs(stripped);
110 const normalizedDirective = directive.toLowerCase();
111 if (normalizedDirective === "include") {
112 for (const includePattern of rawArgs) {
113 const resolvedPattern = yield* resolveSshConfigIncludePattern(
114 includePattern,
115 directory,
116 homeDir,
117 );
118 const includedPaths = yield* expandGlob(resolvedPattern);
119 for (const includedPath of includedPaths) {
120 const includedAliases = yield* collectSshConfigAliasesFromFile(
121 includedPath,
122 visited,
123 homeDir,
124 );
125 for (const alias of includedAliases) {
126 aliases.add(alias);
127 }
128 }
129 }
130 continue;
131 }
132
133 if (normalizedDirective !== "host") {
134 continue;
135 }
136
137 for (const alias of rawArgs) {
138 if (alias.length === 0 || hasSshPattern(alias)) {
139 continue;
140 }
141 aliases.add(alias);
142 }
143 }
144
145 return [...aliases].toSorted((left, right) => left.localeCompare(right));
146});
147
148function normalizeKnownHostsHostname(rawHost: string): string {
149 const bracketMatch = /^\[([^\]]+)\]:(\d+)$/u.exec(rawHost);
150 if (bracketMatch?.[1]) {
151 return bracketMatch[1];
152 }
153
154 if (!rawHost.includes(":")) {
155 return rawHost;
156 }
157
158 const firstColonIndex = rawHost.indexOf(":");
159 const lastColonIndex = rawHost.lastIndexOf(":");
160 return firstColonIndex === lastColonIndex ? rawHost.slice(0, lastColonIndex) : rawHost;
161}
162
163export function parseKnownHostsHostnames(raw: string): ReadonlyArray<string> {
164 const hostnames = new Set<string>();
165
166 for (const line of raw.split(/\r?\n/u)) {
167 const trimmed = line.trim();
168 if (trimmed.length === 0 || trimmed.startsWith("#")) {
169 continue;
170 }
171
172 const withoutMarker = trimmed.startsWith("@")
173 ? trimmed.split(/\s+/u).slice(1).join(" ")
174 : trimmed;
175 const [hostField = ""] = withoutMarker.split(/\s+/u);
176 if (hostField.length === 0 || hostField.startsWith("|")) {
177 continue;
178 }
179
180 for (const rawHost of hostField.split(",")) {
181 const host = normalizeKnownHostsHostname(rawHost).trim();
182 if (host.length === 0 || hasSshPattern(host)) {
183 continue;
184 }
185 hostnames.add(host);
186 }
187 }
188
189 return [...hostnames].toSorted((left, right) => left.localeCompare(right));
190}
191
192const readKnownHostsHostnames = Effect.fnUntraced(function* (filePath: string) {
193 const fs = yield* FileSystem.FileSystem;
194 if (!(yield* fs.exists(filePath))) {
195 return NO_HOSTS;
196 }
197 return parseKnownHostsHostnames(yield* fs.readFileString(filePath));
198});
199
200export const discoverSshHosts = Effect.fnUntraced(
201 function* (input: { readonly homeDir?: string }) {
202 const path = yield* Path.Path;
203 const homeDir = input?.homeDir ?? process.env.HOME ?? process.env.USERPROFILE ?? "";
204 if (homeDir.trim().length === 0) {
205 return [];
206 }
207
208 const sshDirectory = path.join(homeDir, ".ssh");
209 const configAliases = yield* collectSshConfigAliasesFromFile(
210 path.join(sshDirectory, "config"),
211 new Set<string>(),
212 homeDir,
213 );
214 const knownHosts = yield* readKnownHostsHostnames(path.join(sshDirectory, "known_hosts"));
215 const discovered = new Map<string, DesktopDiscoveredSshHost>();
216
217 for (const alias of configAliases) {
218 discovered.set(alias, {
219 alias,
220 hostname: alias,
221 username: null,
222 port: null,
223 source: "ssh-config",
224 });
225 }
226
227 for (const hostname of knownHosts) {
228 if (discovered.has(hostname)) {
229 continue;
230 }
231 discovered.set(hostname, {
232 alias: hostname,
233 hostname,
234 username: null,
235 port: null,
236 source: "known-hosts",
237 });
238 }
239
240 return [...discovered.values()].toSorted((left, right) =>
241 left.alias.localeCompare(right.alias),
242 );
243 },
244 Effect.mapError(
245 (cause) =>
246 new SshHostDiscoveryError({
247 message: "Failed to discover SSH hosts.",
248 cause,
249 }),
250 ),
251);
packages/ssh/src/errors.ts created+46
...@@ -0,0 +1,46 @@
1import { Data } from "effect";
2
3export class SshHostDiscoveryError extends Data.TaggedError("SshHostDiscoveryError")<{
4 readonly message: string;
5 readonly cause: unknown;
6}> {}
7
8export class SshInvalidTargetError extends Data.TaggedError("SshInvalidTargetError")<{
9 readonly message: string;
10}> {}
11
12export class SshCommandError extends Data.TaggedError("SshCommandError")<{
13 readonly message: string;
14 readonly command: readonly string[];
15 readonly exitCode: number | null;
16 readonly stderr: string;
17 readonly cause?: unknown;
18}> {}
19
20export class SshLaunchError extends Data.TaggedError("SshLaunchError")<{
21 readonly message: string;
22 readonly stdout: string;
23 readonly cause?: unknown;
24}> {}
25
26export class SshPairingError extends Data.TaggedError("SshPairingError")<{
27 readonly message: string;
28 readonly stdout: string;
29 readonly cause?: unknown;
30}> {}
31
32export class SshHttpBridgeError extends Data.TaggedError("SshHttpBridgeError")<{
33 readonly message: string;
34 readonly status?: number;
35 readonly cause?: unknown;
36}> {}
37
38export class SshReadinessError extends Data.TaggedError("SshReadinessError")<{
39 readonly message: string;
40 readonly cause?: unknown;
41}> {}
42
43export class SshPasswordPromptError extends Data.TaggedError("SshPasswordPromptError")<{
44 readonly message: string;
45 readonly cause?: unknown;
46}> {}
packages/ssh/src/tunnel.test.ts created+294
...@@ -0,0 +1,294 @@
1import { assert, describe, it } from "@effect/vitest";
2import * as NodeServices from "@effect/platform-node/NodeServices";
3import { NetService } from "@t3tools/shared/Net";
4import { Duration, Effect, Fiber, Layer, Result, Sink, Stream } from "effect";
5import { TestClock } from "effect/testing";
6import { HttpClient, HttpClientResponse } from "effect/unstable/http";
7import { ChildProcess, ChildProcessSpawner } from "effect/unstable/process";
8
9import { SshPasswordPrompt } from "./auth.ts";
10import {
11 buildRemoteLaunchScript,
12 buildRemotePairingScript,
13 buildRemoteStopScript,
14 buildRemoteT3RunnerScript,
15 describeReadinessCause,
16 issueRemotePairingToken,
17 REMOTE_PICK_PORT_SCRIPT,
18 SshEnvironmentManager,
19 waitForHttpReady,
20} from "./tunnel.ts";
21
22const makeSuccessfulProcess = (stdout: string) => {
23 const stdoutStream = Stream.make(new TextEncoder().encode(stdout));
24 return ChildProcessSpawner.makeHandle({
25 pid: ChildProcessSpawner.ProcessId(123),
26 stdout: stdoutStream,
27 stderr: Stream.empty,
28 all: stdoutStream,
29 exitCode: Effect.succeed(ChildProcessSpawner.ExitCode(0)),
30 isRunning: Effect.succeed(false),
31 kill: () => Effect.void,
32 stdin: Sink.drain,
33 getInputFd: () => Sink.drain,
34 getOutputFd: () => Stream.empty,
35 unref: Effect.succeed(Effect.void),
36 });
37};
38
39const makeRunningProcess = (onKill: () => void) => {
40 let finish: ((exitCode: ChildProcessSpawner.ExitCode) => void) | null = null;
41 return ChildProcessSpawner.makeHandle({
42 pid: ChildProcessSpawner.ProcessId(123),
43 stdout: Stream.empty,
44 stderr: Stream.empty,
45 all: Stream.empty,
46 exitCode: Effect.callback<ChildProcessSpawner.ExitCode>((resume) => {
47 finish = (exitCode) => resume(Effect.succeed(exitCode));
48 return Effect.sync(() => {
49 finish = null;
50 });
51 }),
52 isRunning: Effect.succeed(true),
53 kill: () =>
54 Effect.sync(() => {
55 onKill();
56 finish?.(ChildProcessSpawner.ExitCode(143));
57 }),
58 stdin: Sink.drain,
59 getInputFd: () => Sink.drain,
60 getOutputFd: () => Stream.empty,
61 unref: Effect.succeed(Effect.void),
62 });
63};
64
65const testHttpClient = HttpClient.make((request) =>
66 Effect.succeed(HttpClientResponse.fromWeb(request, new Response("", { status: 200 }))),
67);
68
69const hangingHttpClient = HttpClient.make(() => Effect.never);
70
71const testNetService = NetService.of({
72 canListenOnHost: () => Effect.succeed(true),
73 isPortAvailableOnLoopback: () => Effect.succeed(true),
74 reserveLoopbackPort: () => Effect.succeed(41_773),
75 findAvailablePort: (preferred) => Effect.succeed(preferred),
76});
77
78function commandArgs(command: ChildProcess.Command): ReadonlyArray<string> {
79 return command._tag === "StandardCommand" ? command.args : [];
80}
81
82describe("ssh tunnel scripts", () => {
83 it("builds the remote t3 runner with npx and npm fallbacks", () => {
84 const script = buildRemoteT3RunnerScript();
85
86 assert.include(script, "T3_NODE_SCRIPT_PATH=''");
87 assert.include(script, 'exec t3 "$@"');
88 assert.include(script, "exec npx --yes 't3@latest' \"$@\"");
89 assert.include(script, "exec npm exec --yes 't3@latest' -- \"$@\"");
90 assert.include(script, "could not install 't3@latest'");
91 });
92
93 it("shell-quotes package specs in the remote t3 runner", () => {
94 const script = buildRemoteT3RunnerScript({
95 packageSpec: "t3@nightly; touch /tmp/t3-owned",
96 });
97
98 assert.include(script, "exec npx --yes 't3@nightly; touch /tmp/t3-owned' \"$@\"");
99 assert.include(script, "exec npm exec --yes 't3@nightly; touch /tmp/t3-owned' -- \"$@\"");
100 assert.notInclude(script, "exec npx --yes t3@nightly; touch /tmp/t3-owned");
101 });
102
103 it("builds the remote t3 runner with a node script override", () => {
104 const script = buildRemoteT3RunnerScript({
105 nodeScriptPath: "/Users/julius/Development/Work/codething-mvp/apps/server/dist/bin.mjs",
106 });
107
108 assert.include(
109 script,
110 "T3_NODE_SCRIPT_PATH='/Users/julius/Development/Work/codething-mvp/apps/server/dist/bin.mjs'",
111 );
112 assert.include(script, 'exec node "$T3_NODE_SCRIPT_PATH" "$@"');
113 });
114
115 it("uses the remote t3 runner for launch and pairing scripts", () => {
116 const target = {
117 alias: "devbox",
118 hostname: "devbox.example.com",
119 username: "julius",
120 port: 2222,
121 } as const;
122
123 assert.include(
124 buildRemoteLaunchScript(),
125 '[ -n "$REMOTE_PID" ] && [ -n "$REMOTE_PORT" ] && kill -0 "$REMOTE_PID" 2>/dev/null',
126 );
127 assert.include(buildRemoteLaunchScript(), "RUNNER_CHANGED=1");
128 assert.include(buildRemoteLaunchScript(), 'kill "$REMOTE_PID" 2>/dev/null || true');
129 assert.include(buildRemoteLaunchScript(), "wait_ready");
130 assert.include(buildRemoteLaunchScript(), '"$RUNNER_FILE" serve --host 127.0.0.1');
131 assert.include(buildRemoteLaunchScript(), '--base-dir "$DEFAULT_SERVER_HOME"');
132 assert.notInclude(buildRemoteLaunchScript(), "server-home");
133 assert.include(buildRemoteLaunchScript(), "Remote T3 server did not become ready");
134 assert.include(buildRemoteLaunchScript({ packageSpec: "t3@nightly" }), "t3@nightly");
135 assert.include(
136 buildRemotePairingScript(target),
137 '"$RUNNER_FILE" auth pairing create --base-dir "$PAIRING_BASE_DIR" --json',
138 );
139 assert.include(buildRemotePairingScript(target), 'PAIRING_BASE_DIR="$DEFAULT_SERVER_HOME"');
140 assert.notInclude(buildRemotePairingScript(target), "server-home");
141 assert.include(buildRemotePairingScript(target, { packageSpec: "t3@nightly" }), "t3@nightly");
142 assert.include(
143 buildRemoteStopScript(target),
144 'if [ "$REMOTE_MANAGED" != "external" ] && [ -n "$REMOTE_PID" ]',
145 );
146 assert.include(buildRemoteStopScript(target), 'kill "$REMOTE_PID" 2>/dev/null || true');
147 assert.include(buildRemoteStopScript(target), 'rm -f "$PID_FILE" "$PORT_FILE" "$MANAGED_FILE"');
148 assert.include(
149 buildRemoteLaunchScript(),
150 'DEFAULT_RUNTIME_FILE="$DEFAULT_SERVER_HOME/userdata/server-runtime.json"',
151 );
152 assert.include(buildRemoteLaunchScript(), "resolve_default_runtime_port()");
153 assert.include(buildRemoteLaunchScript(), "printf 'external\\n' >\"$MANAGED_FILE\"");
154 assert.isBelow(
155 buildRemoteLaunchScript().indexOf('if [ "$REMOTE_MANAGED" = "managed" ]'),
156 buildRemoteLaunchScript().indexOf("printf 'external\\n' >\"$MANAGED_FILE\""),
157 );
158 assert.isBelow(
159 buildRemoteLaunchScript().indexOf('DEFAULT_REMOTE_PORT="$(resolve_default_runtime_port'),
160 buildRemoteLaunchScript().indexOf('elif [ -n "$REMOTE_PID" ]'),
161 );
162 });
163
164 it("allows the remote port picker to run without a state file path", () => {
165 assert.include(REMOTE_PICK_PORT_SCRIPT, 'const filePath = process.argv[2] ?? "";');
166 });
167
168 it.effect("bounds each HTTP readiness probe so retries cannot hang on one request", () =>
169 Effect.gen(function* () {
170 const fiber = yield* Effect.forkChild(
171 Effect.result(
172 waitForHttpReady({
173 baseUrl: "http://127.0.0.1:41773/",
174 timeoutMs: 1_000,
175 intervalMs: 100,
176 probeTimeoutMs: 250,
177 }),
178 ),
179 );
180 yield* Effect.yieldNow;
181 yield* TestClock.adjust(Duration.millis(1_000));
182
183 const result = yield* Fiber.join(fiber);
184
185 assert.isTrue(Result.isFailure(result));
186 if (Result.isFailure(result)) {
187 assert.include(result.failure.message, "Timed out waiting 1000ms");
188 }
189 }).pipe(
190 Effect.provide(
191 Layer.merge(TestClock.layer(), Layer.succeed(HttpClient.HttpClient, hangingHttpClient)),
192 ),
193 ),
194 );
195
196 it("preserves primitive readiness reason values in diagnostic output", () => {
197 assert.deepEqual(
198 describeReadinessCause({
199 _tag: "HttpClientError",
200 message: "Backend readiness probe failed.",
201 reason: "authentication failed",
202 cause: "upstream closed",
203 }),
204 {
205 _tag: "HttpClientError",
206 message: "Backend readiness probe failed.",
207 reason: "authentication failed",
208 cause: "upstream closed",
209 },
210 );
211 });
212
213 it.effect("accepts pretty-printed pairing JSON from the remote CLI", () => {
214 const target = {
215 alias: "devbox",
216 hostname: "devbox.example.com",
217 username: "julius",
218 port: 2222,
219 } as const;
220 const spawner = ChildProcessSpawner.make(() =>
221 Effect.succeed(
222 makeSuccessfulProcess(`{
223 "id": "88941235-6ed5-4184-a2ff-5339e2075958",
224 "credential": "LCL4R2TPHDKQ",
225 "role": "client",
226 "expiresAt": "2026-04-29T01:01:20.994Z"
227}
228
229`),
230 ),
231 );
232 const spawnerLayer = Layer.succeed(ChildProcessSpawner.ChildProcessSpawner, spawner);
233 const processLayer = Layer.merge(NodeServices.layer, spawnerLayer);
234 return Effect.gen(function* () {
235 const result = yield* issueRemotePairingToken(target);
236 assert.equal(result.credential, "LCL4R2TPHDKQ");
237 }).pipe(Effect.provide(processLayer));
238 });
239
240 it.effect("closes the tunnel scope and starts fresh after disconnect", () => {
241 const spawnedCommands: Array<ReadonlyArray<string>> = [];
242 let tunnelKillCount = 0;
243 let stopCommandCount = 0;
244 const spawner = ChildProcessSpawner.make((command) =>
245 Effect.sync(() => {
246 const args = commandArgs(command);
247 spawnedCommands.push(args);
248 if (args.includes("-N")) {
249 return makeRunningProcess(() => {
250 tunnelKillCount += 1;
251 });
252 }
253 if (args.includes("sh") && args.includes("--")) {
254 return makeSuccessfulProcess('{"remotePort":3773}\n');
255 }
256 if (args.includes("sh")) {
257 stopCommandCount += 1;
258 return makeSuccessfulProcess('{"stopped":true}\n');
259 }
260 return makeSuccessfulProcess("\n");
261 }),
262 );
263 const layer = Layer.mergeAll(
264 NodeServices.layer,
265 Layer.succeed(ChildProcessSpawner.ChildProcessSpawner, spawner),
266 Layer.succeed(HttpClient.HttpClient, testHttpClient),
267 Layer.succeed(NetService, testNetService),
268 SshPasswordPrompt.disabledLayer,
269 SshEnvironmentManager.layer(),
270 );
271 const target = {
272 alias: "devbox",
273 hostname: "devbox.example.com",
274 username: "julius",
275 port: 2222,
276 } as const;
277
278 return Effect.gen(function* () {
279 const manager = yield* SshEnvironmentManager;
280
281 const first = yield* manager.ensureEnvironment(target);
282 assert.equal(first.httpBaseUrl, "http://127.0.0.1:41773/");
283
284 yield* manager.disconnectEnvironment(target);
285 assert.equal(tunnelKillCount, 1);
286 assert.equal(stopCommandCount, 1);
287
288 yield* manager.ensureEnvironment(target);
289
290 assert.equal(spawnedCommands.filter((args) => args.includes("-N")).length, 2);
291 assert.equal(tunnelKillCount, 1);
292 }).pipe(Effect.provide(layer), Effect.scoped);
293 });
294});
packages/ssh/src/tunnel.ts created+1584
...@@ -0,0 +1,1584 @@
1import type {
2 DesktopSshEnvironmentBootstrap,
3 DesktopSshEnvironmentTarget,
4} from "@t3tools/contracts";
5import { type NetError, NetService } from "@t3tools/shared/Net";
6import { fromLenientJson } from "@t3tools/shared/schemaJson";
7import {
8 Deferred,
9 Context,
10 Duration,
11 Effect,
12 Exit,
13 FileSystem,
14 Layer,
15 Option,
16 Path,
17 Ref,
18 Schema,
19 Scope,
20 Schedule,
21 Stream,
22} from "effect";
23import { HttpClient, HttpClientRequest } from "effect/unstable/http";
24import { ChildProcess, ChildProcessSpawner } from "effect/unstable/process";
25
26import {
27 buildSshChildEnvironment,
28 type SshAuthOptions,
29 SshPasswordPrompt,
30 isSshAuthFailure,
31} from "./auth.ts";
32import {
33 baseSshArgs,
34 buildSshHostSpecEffect,
35 collectProcessOutput,
36 getLastNonEmptyOutputLine,
37 remoteStateKey,
38 resolveSshTarget,
39 runSshCommand,
40 targetConnectionKey,
41} from "./command.ts";
42import {
43 SshCommandError,
44 SshHttpBridgeError,
45 SshInvalidTargetError,
46 SshLaunchError,
47 SshPairingError,
48 SshPasswordPromptError,
49 SshReadinessError,
50} from "./errors.ts";
51
52export const DEFAULT_REMOTE_PORT = 3773;
53const REMOTE_PORT_SCAN_WINDOW = 200;
54const SSH_READY_TIMEOUT_MS = 20_000;
55const SSH_READY_PROBE_TIMEOUT_MS = 1_000;
56const TUNNEL_SHUTDOWN_TIMEOUT_MS = 2_000;
57const REMOTE_READY_TIMEOUT_MS = 15_000;
58const REMOTE_REUSE_READY_TIMEOUT_MS = 2_000;
59
60export interface RemoteT3RunnerOptions {
61 readonly packageSpec?: string;
62 readonly nodeScriptPath?: string | null;
63}
64
65export interface SshEnvironmentManagerOptions {
66 readonly resolveCliPackageSpec?: () => string;
67 readonly resolveCliRunner?: () => RemoteT3RunnerOptions;
68}
69
70interface SshTunnelEntry {
71 readonly key: string;
72 readonly target: DesktopSshEnvironmentTarget;
73 readonly remotePort: number;
74 readonly remoteServerKind: "external" | "managed" | null;
75 readonly localPort: number;
76 readonly httpBaseUrl: string;
77 readonly wsBaseUrl: string;
78 readonly process: ChildProcessSpawner.ChildProcessHandle;
79 readonly scope: Scope.Scope;
80}
81
82type SshEnvironmentEffectContext =
83 | ChildProcessSpawner.ChildProcessSpawner
84 | FileSystem.FileSystem
85 | Path.Path
86 | HttpClient.HttpClient
87 | NetService
88 | SshPasswordPrompt;
89
90type SshEnvironmentEffectError =
91 | SshCommandError
92 | SshInvalidTargetError
93 | SshLaunchError
94 | SshPairingError
95 | SshReadinessError
96 | SshPasswordPromptError
97 | NetError;
98
99function makeSshTunnelCancelledError(target: DesktopSshEnvironmentTarget): SshCommandError {
100 return new SshCommandError({
101 command: ["ssh"],
102 exitCode: null,
103 stderr: "",
104 message: `SSH environment connection was cancelled for ${target.alias || target.hostname}.`,
105 });
106}
107
108function sshTargetLogFields(target: DesktopSshEnvironmentTarget) {
109 return {
110 alias: target.alias,
111 hostname: target.hostname,
112 username: target.username,
113 port: target.port,
114 };
115}
116
117function sshRunnerLogFields(runner: RemoteT3RunnerOptions | undefined) {
118 if (runner?.nodeScriptPath?.trim()) {
119 return { runner: "node-script", nodeScriptPath: runner.nodeScriptPath.trim() };
120 }
121 if (runner?.packageSpec?.trim()) {
122 return { runner: "package", packageSpec: runner.packageSpec.trim() };
123 }
124 return { runner: "default" };
125}
126
127interface SshAuthOperationInput<T> {
128 readonly key: string;
129 readonly target: DesktopSshEnvironmentTarget;
130 readonly operation: (
131 authOptions: SshAuthOptions,
132 ) => Effect.Effect<T, SshEnvironmentEffectError, SshEnvironmentEffectContext>;
133}
134
135interface SshAuthAttemptInput<T> extends SshAuthOperationInput<T> {
136 readonly promptCount: number;
137 readonly authSecret: string | null;
138}
139
140export interface SshEnvironmentManagerShape {
141 readonly ensureEnvironment: (
142 target: DesktopSshEnvironmentTarget,
143 options?: { readonly issuePairingToken?: boolean },
144 ) => Effect.Effect<
145 DesktopSshEnvironmentBootstrap,
146 SshEnvironmentEffectError,
147 SshEnvironmentEffectContext
148 >;
149 readonly disconnectEnvironment: (
150 target: DesktopSshEnvironmentTarget,
151 ) => Effect.Effect<void, SshEnvironmentEffectError, SshEnvironmentEffectContext>;
152}
153
154const RemoteLaunchResult = Schema.Struct({
155 remotePort: Schema.Number,
156 serverKind: Schema.optional(Schema.Literals(["external", "managed"])),
157});
158
159const RemotePairingResult = Schema.Struct({
160 credential: Schema.String,
161});
162
163const RemoteHttpError = Schema.Struct({
164 error: Schema.optional(Schema.String),
165});
166
167const decodeRemoteLaunchResult = Schema.decodeEffect(fromLenientJson(RemoteLaunchResult));
168const decodeRemotePairingResult = Schema.decodeEffect(fromLenientJson(RemotePairingResult));
169const decodeRemoteHttpError = Schema.decodeEffect(Schema.fromJsonString(RemoteHttpError));
170
171export function normalizeSshErrorMessage(stderr: string, fallbackMessage: string): string {
172 const cleaned = stderr.trim();
173 return cleaned.length > 0 ? cleaned : fallbackMessage;
174}
175
176function stripTrailingNewlines(value: string): string {
177 return value.replace(/\n+$/u, "");
178}
179
180function shellSingleQuote(value: string): string {
181 return `'${value.replaceAll("'", "'\\''")}'`;
182}
183
184function applyScriptPlaceholders(
185 template: string,
186 replacements: Readonly<Record<string, string>>,
187): string {
188 let result = template;
189 for (const [token, value] of Object.entries(replacements)) {
190 result = result.replaceAll(`@@${token}@@`, value);
191 }
192 return result;
193}
194
195export function describeReadinessCause(cause: unknown): unknown {
196 if (cause instanceof SshReadinessError) {
197 return {
198 _tag: cause._tag,
199 message: cause.message,
200 ...(cause.cause === undefined ? {} : { cause: describeReadinessCause(cause.cause) }),
201 };
202 }
203 if (cause instanceof Error) {
204 return {
205 name: cause.name,
206 message: cause.message,
207 ...(cause.cause === undefined ? {} : { cause: describeReadinessCause(cause.cause) }),
208 };
209 }
210 if (typeof cause !== "object" || cause === null) {
211 return cause;
212 }
213
214 const record = cause as Readonly<Record<string, unknown>>;
215 return {
216 ...(typeof record._tag === "string" ? { _tag: record._tag } : {}),
217 ...(typeof record.message === "string" ? { message: record.message } : {}),
218 ...(record.reason === undefined ? {} : { reason: describeReadinessCause(record.reason) }),
219 ...(record.cause === undefined ? {} : { cause: describeReadinessCause(record.cause) }),
220 };
221}
222
223export const REMOTE_PICK_PORT_SCRIPT = `const fs = require("node:fs");
224const net = require("node:net");
225const filePath = process.argv[2] ?? "";
226const defaultPort = Number.parseInt(process.argv[3] ?? "", 10);
227const scanWindow = Number.parseInt(process.argv[4] ?? "", 10);
228const raw = fs.existsSync(filePath) ? fs.readFileSync(filePath, "utf8").trim() : "";
229const preferred = Number.parseInt(raw, 10);
230const start = Number.isInteger(preferred) ? preferred : defaultPort;
231const end = start + scanWindow;
232
233function tryPort(port) {
234 return new Promise((resolve) => {
235 const server = net.createServer();
236 server.unref();
237 server.once("error", () => resolve(false));
238 server.listen(port, "127.0.0.1", () => {
239 server.close((error) => resolve(error ? false : port));
240 });
241 });
242}
243
244(async () => {
245 for (let port = start; port < end; port += 1) {
246 const available = await tryPort(port);
247 if (available) {
248 process.stdout.write(String(port));
249 return;
250 }
251 }
252 process.exit(1);
253})().catch(() => process.exit(1));
254`;
255
256export const REMOTE_WAIT_READY_SCRIPT = `const http = require("node:http");
257const port = Number.parseInt(process.argv[2] ?? "", 10);
258const timeoutMs = Number.parseInt(process.argv[3] ?? "", 10);
259const probeTimeoutMs = Number.parseInt(process.argv[4] ?? "", 10);
260if (!Number.isInteger(port) || !Number.isInteger(timeoutMs) || !Number.isInteger(probeTimeoutMs)) {
261 process.exit(1);
262}
263const deadline = Date.now() + timeoutMs;
264
265function sleep(ms) {
266 return new Promise((resolve) => setTimeout(resolve, ms));
267}
268
269function probe() {
270 return new Promise((resolve) => {
271 const request = http.get(
272 {
273 hostname: "127.0.0.1",
274 port,
275 path: "/",
276 timeout: probeTimeoutMs,
277 },
278 (response) => {
279 response.resume();
280 response.once("end", () => {
281 resolve(response.statusCode >= 200 && response.statusCode < 300);
282 });
283 },
284 );
285 request.once("timeout", () => {
286 request.destroy();
287 resolve(false);
288 });
289 request.once("error", () => resolve(false));
290 });
291}
292
293(async () => {
294 while (Date.now() < deadline) {
295 if (await probe()) {
296 process.exit(0);
297 }
298 await sleep(100);
299 }
300 process.exit(1);
301})().catch(() => process.exit(1));
302`;
303
304export const REMOTE_RUNNER_SCRIPT = `#!/bin/sh
305set -eu
306T3_NODE_SCRIPT_PATH=@@T3_NODE_SCRIPT_PATH@@
307if [ -n "$T3_NODE_SCRIPT_PATH" ]; then
308 exec node "$T3_NODE_SCRIPT_PATH" "$@"
309fi
310if command -v t3 >/dev/null 2>&1; then
311 exec t3 "$@"
312fi
313if command -v npx >/dev/null 2>&1; then
314 exec npx --yes @@T3_PACKAGE_SPEC@@ "$@"
315fi
316if command -v npm >/dev/null 2>&1; then
317 exec npm exec --yes @@T3_PACKAGE_SPEC@@ -- "$@"
318fi
319printf 'Remote host is missing the t3 CLI and could not install @@T3_PACKAGE_SPEC@@ because npx and npm are unavailable on PATH.\\n' >&2
320exit 1
321`;
322
323export const REMOTE_LAUNCH_SCRIPT = `set -eu
324STATE_KEY="$1"
325STATE_DIR="$HOME/.t3/ssh-launch/$STATE_KEY"
326DEFAULT_SERVER_HOME="$HOME/.t3"
327DEFAULT_RUNTIME_FILE="$DEFAULT_SERVER_HOME/userdata/server-runtime.json"
328PORT_FILE="$STATE_DIR/port"
329PID_FILE="$STATE_DIR/pid"
330MANAGED_FILE="$STATE_DIR/managed"
331LOG_FILE="$STATE_DIR/server.log"
332RUNNER_FILE="$STATE_DIR/run-t3.sh"
333RUNNER_NEXT="$STATE_DIR/run-t3.next.$$"
334mkdir -p "$STATE_DIR"
335cleanup_runner_next() {
336 rm -f "$RUNNER_NEXT"
337}
338trap cleanup_runner_next EXIT
339cat >"$RUNNER_NEXT" <<'SH'
340@@T3_RUNNER_SCRIPT@@
341SH
342RUNNER_CHANGED=0
343if [ ! -f "$RUNNER_FILE" ] || ! cmp -s "$RUNNER_NEXT" "$RUNNER_FILE"; then
344 RUNNER_CHANGED=1
345fi
346mv "$RUNNER_NEXT" "$RUNNER_FILE"
347chmod 700 "$RUNNER_FILE"
348pick_port() {
349 node - "$PORT_FILE" "@@T3_DEFAULT_REMOTE_PORT@@" "@@T3_REMOTE_PORT_SCAN_WINDOW@@" <<'NODE'
350@@T3_PICK_PORT_SCRIPT@@
351NODE
352}
353wait_ready() {
354 node - "$REMOTE_PORT" "$1" "@@T3_READY_PROBE_TIMEOUT_MS@@" <<'NODE'
355@@T3_WAIT_READY_SCRIPT@@
356NODE
357}
358wait_for_pid_exit() {
359 PID_TO_WAIT="$1"
360 WAIT_COUNT=0
361 while kill -0 "$PID_TO_WAIT" 2>/dev/null && [ "$WAIT_COUNT" -lt 20 ]; do
362 WAIT_COUNT=$((WAIT_COUNT + 1))
363 sleep 0.1
364 done
365}
366resolve_default_runtime_port() {
367 node - "$DEFAULT_RUNTIME_FILE" <<'NODE'
368const fs = require("node:fs");
369const runtimePath = process.argv[2] ?? "";
370try {
371 const runtime = JSON.parse(fs.readFileSync(runtimePath, "utf8"));
372 const pid = Number(runtime.pid);
373 const port = Number(runtime.port);
374 if (!Number.isInteger(pid) || !Number.isInteger(port)) {
375 process.exit(1);
376 }
377 const origin = new URL(String(runtime.origin ?? ""));
378 if (origin.protocol !== "http:" || !["127.0.0.1", "localhost"].includes(origin.hostname)) {
379 process.exit(1);
380 }
381 process.kill(pid, 0);
382 process.stdout.write(String(port));
383} catch {
384 process.exit(1);
385}
386NODE
387}
388REMOTE_PID="$(cat "$PID_FILE" 2>/dev/null || true)"
389REMOTE_PORT="$(cat "$PORT_FILE" 2>/dev/null || true)"
390REMOTE_MANAGED="$(cat "$MANAGED_FILE" 2>/dev/null || true)"
391DEFAULT_REMOTE_PORT="$(resolve_default_runtime_port 2>/dev/null || true)"
392if [ -n "$DEFAULT_REMOTE_PORT" ]; then
393 REMOTE_PORT="$DEFAULT_REMOTE_PORT"
394 if wait_ready "@@T3_REUSE_READY_TIMEOUT_MS@@"; then
395 if [ "$REMOTE_MANAGED" = "managed" ] && [ -n "$REMOTE_PID" ] && kill -0 "$REMOTE_PID" 2>/dev/null; then
396 kill "$REMOTE_PID" 2>/dev/null || true
397 wait_for_pid_exit "$REMOTE_PID"
398 fi
399 printf '%s\\n' "$REMOTE_PORT" >"$PORT_FILE"
400 printf 'external\\n' >"$MANAGED_FILE"
401 REMOTE_PID=""
402 REMOTE_MANAGED="external"
403 else
404 REMOTE_PID="$(cat "$PID_FILE" 2>/dev/null || true)"
405 REMOTE_PORT="$(cat "$PORT_FILE" 2>/dev/null || true)"
406 REMOTE_MANAGED="$(cat "$MANAGED_FILE" 2>/dev/null || true)"
407 fi
408fi
409if [ "$REMOTE_MANAGED" = "external" ]; then
410 if [ -z "$REMOTE_PORT" ] || ! wait_ready "@@T3_REUSE_READY_TIMEOUT_MS@@"; then
411 REMOTE_PID=""
412 REMOTE_PORT=""
413 REMOTE_MANAGED=""
414 fi
415elif [ -n "$REMOTE_PID" ] && [ -n "$REMOTE_PORT" ] && kill -0 "$REMOTE_PID" 2>/dev/null; then
416 if [ "$RUNNER_CHANGED" -eq 1 ]; then
417 kill "$REMOTE_PID" 2>/dev/null || true
418 wait_for_pid_exit "$REMOTE_PID"
419 REMOTE_PID=""
420 REMOTE_PORT=""
421 REMOTE_MANAGED=""
422 elif ! wait_ready "@@T3_REUSE_READY_TIMEOUT_MS@@"; then
423 kill "$REMOTE_PID" 2>/dev/null || true
424 wait_for_pid_exit "$REMOTE_PID"
425 REMOTE_PID=""
426 REMOTE_PORT=""
427 REMOTE_MANAGED=""
428 fi
429else
430 REMOTE_PID=""
431 REMOTE_PORT=""
432 REMOTE_MANAGED=""
433fi
434if [ -z "$REMOTE_PID" ] || [ -z "$REMOTE_PORT" ]; then
435 REMOTE_PORT="$(pick_port)" || true
436 if [ -z "$REMOTE_PORT" ]; then
437 printf 'Failed to find an available port on the remote host. Ensure node is available on PATH.\\n' >&2
438 exit 1
439 fi
440 nohup env T3CODE_NO_BROWSER=1 "$RUNNER_FILE" serve --host 127.0.0.1 --port "$REMOTE_PORT" --base-dir "$DEFAULT_SERVER_HOME" >>"$LOG_FILE" 2>&1 < /dev/null &
441 REMOTE_PID="$!"
442 printf '%s\\n' "$REMOTE_PID" >"$PID_FILE"
443 printf '%s\\n' "$REMOTE_PORT" >"$PORT_FILE"
444 printf 'managed\\n' >"$MANAGED_FILE"
445 if ! wait_ready "@@T3_READY_TIMEOUT_MS@@"; then
446 printf 'Remote T3 server did not become ready on 127.0.0.1:%s.\\n' "$REMOTE_PORT" >&2
447 tail -n 80 "$LOG_FILE" >&2 2>/dev/null || true
448 kill "$REMOTE_PID" 2>/dev/null || true
449 wait_for_pid_exit "$REMOTE_PID"
450 rm -f "$PID_FILE" "$PORT_FILE" "$MANAGED_FILE"
451 exit 1
452 fi
453fi
454printf '{"remotePort":%s,"serverKind":"%s"}\\n' "$REMOTE_PORT" "\${REMOTE_MANAGED:-managed}"
455`;
456
457export const REMOTE_PAIRING_SCRIPT = `set -eu
458STATE_DIR="$HOME/.t3/ssh-launch/@@T3_STATE_KEY@@"
459DEFAULT_SERVER_HOME="$HOME/.t3"
460RUNNER_FILE="$STATE_DIR/run-t3.sh"
461mkdir -p "$STATE_DIR"
462cat >"$RUNNER_FILE" <<'SH'
463@@T3_RUNNER_SCRIPT@@
464SH
465chmod 700 "$RUNNER_FILE"
466PAIRING_BASE_DIR="$DEFAULT_SERVER_HOME"
467"$RUNNER_FILE" auth pairing create --base-dir "$PAIRING_BASE_DIR" --json
468`;
469
470export const REMOTE_STOP_SCRIPT = `set -eu
471STATE_DIR="$HOME/.t3/ssh-launch/@@T3_STATE_KEY@@"
472PID_FILE="$STATE_DIR/pid"
473PORT_FILE="$STATE_DIR/port"
474MANAGED_FILE="$STATE_DIR/managed"
475REMOTE_MANAGED="$(cat "$MANAGED_FILE" 2>/dev/null || true)"
476REMOTE_PID="$(cat "$PID_FILE" 2>/dev/null || true)"
477if [ "$REMOTE_MANAGED" != "external" ] && [ -n "$REMOTE_PID" ] && kill -0 "$REMOTE_PID" 2>/dev/null; then
478 kill "$REMOTE_PID" 2>/dev/null || true
479 WAIT_COUNT=0
480 while kill -0 "$REMOTE_PID" 2>/dev/null && [ "$WAIT_COUNT" -lt 20 ]; do
481 WAIT_COUNT=$((WAIT_COUNT + 1))
482 sleep 0.1
483 done
484fi
485rm -f "$PID_FILE" "$PORT_FILE" "$MANAGED_FILE"
486printf '{"stopped":true}\\n'
487`;
488
489const REMOTE_LOG_TAIL_SCRIPT = `set -eu
490STATE_DIR="$HOME/.t3/ssh-launch/@@T3_STATE_KEY@@"
491LOG_FILE="$STATE_DIR/server.log"
492if [ -f "$LOG_FILE" ]; then
493 tail -n 80 "$LOG_FILE" 2>/dev/null || true
494fi
495`;
496
497export function buildRemoteT3RunnerScript(input?: RemoteT3RunnerOptions): string {
498 const packageSpec = shellSingleQuote(input?.packageSpec?.trim() || "t3@latest");
499 const nodeScriptPath = input?.nodeScriptPath?.trim() || "";
500 return stripTrailingNewlines(
501 applyScriptPlaceholders(REMOTE_RUNNER_SCRIPT, {
502 T3_PACKAGE_SPEC: packageSpec,
503 T3_NODE_SCRIPT_PATH: shellSingleQuote(nodeScriptPath),
504 }),
505 );
506}
507
508export function buildRemoteLaunchScript(input?: RemoteT3RunnerOptions): string {
509 return applyScriptPlaceholders(REMOTE_LAUNCH_SCRIPT, {
510 T3_RUNNER_SCRIPT: stripTrailingNewlines(buildRemoteT3RunnerScript(input)),
511 T3_PICK_PORT_SCRIPT: stripTrailingNewlines(REMOTE_PICK_PORT_SCRIPT),
512 T3_WAIT_READY_SCRIPT: stripTrailingNewlines(REMOTE_WAIT_READY_SCRIPT),
513 T3_DEFAULT_REMOTE_PORT: String(DEFAULT_REMOTE_PORT),
514 T3_REMOTE_PORT_SCAN_WINDOW: String(REMOTE_PORT_SCAN_WINDOW),
515 T3_READY_TIMEOUT_MS: String(REMOTE_READY_TIMEOUT_MS),
516 T3_REUSE_READY_TIMEOUT_MS: String(REMOTE_REUSE_READY_TIMEOUT_MS),
517 T3_READY_PROBE_TIMEOUT_MS: String(SSH_READY_PROBE_TIMEOUT_MS),
518 });
519}
520
521export function buildRemotePairingScript(
522 target: DesktopSshEnvironmentTarget,
523 input?: RemoteT3RunnerOptions,
524): string {
525 return applyScriptPlaceholders(REMOTE_PAIRING_SCRIPT, {
526 T3_STATE_KEY: remoteStateKey(target),
527 T3_RUNNER_SCRIPT: stripTrailingNewlines(buildRemoteT3RunnerScript(input)),
528 });
529}
530
531export function buildRemoteStopScript(target: DesktopSshEnvironmentTarget): string {
532 return applyScriptPlaceholders(REMOTE_STOP_SCRIPT, {
533 T3_STATE_KEY: remoteStateKey(target),
534 });
535}
536
537function buildRemoteLogTailScript(target: DesktopSshEnvironmentTarget): string {
538 return applyScriptPlaceholders(REMOTE_LOG_TAIL_SCRIPT, {
539 T3_STATE_KEY: remoteStateKey(target),
540 });
541}
542
543export const launchOrReuseRemoteServer = Effect.fn("ssh/tunnel.launchOrReuseRemoteServer")(
544 function* (
545 target: DesktopSshEnvironmentTarget,
546 input?: SshAuthOptions,
547 runner?: RemoteT3RunnerOptions,
548 ): Effect.fn.Return<
549 { readonly remotePort: number; readonly remoteServerKind: "external" | "managed" | null },
550 SshCommandError | SshInvalidTargetError | SshLaunchError,
551 ChildProcessSpawner.ChildProcessSpawner | FileSystem.FileSystem | Path.Path
552 > {
553 yield* Effect.logInfo("ssh.remoteServer.launch.start", {
554 ...sshTargetLogFields(target),
555 ...sshRunnerLogFields(runner),
556 stateKey: remoteStateKey(target),
557 });
558 const result = yield* runSshCommand(target, {
559 remoteCommandArgs: ["sh", "-s", "--", remoteStateKey(target)],
560 stdin: buildRemoteLaunchScript(runner),
561 ...(input?.authSecret === undefined ? {} : { authSecret: input.authSecret }),
562 ...(input?.batchMode === undefined ? {} : { batchMode: input.batchMode }),
563 ...(input?.interactiveAuth === undefined ? {} : { interactiveAuth: input.interactiveAuth }),
564 });
565 if (!getLastNonEmptyOutputLine(result.stdout)) {
566 return yield* new SshLaunchError({
567 message: "SSH launch did not return a remote port.",
568 stdout: result.stdout,
569 });
570 }
571 const parsed = yield* decodeRemoteLaunchResult(result.stdout).pipe(
572 Effect.mapError(
573 (cause) =>
574 new SshLaunchError({
575 message: "SSH launch returned unparseable output.",
576 stdout: result.stdout,
577 cause,
578 }),
579 ),
580 );
581 if (!Number.isInteger(parsed.remotePort)) {
582 return yield* new SshLaunchError({
583 message: `SSH launch returned an invalid remote port: ${String(parsed.remotePort)}.`,
584 stdout: result.stdout,
585 });
586 }
587 yield* Effect.logInfo("ssh.remoteServer.launch.ready", {
588 ...sshTargetLogFields(target),
589 remotePort: parsed.remotePort,
590 remoteServerKind: parsed.serverKind ?? null,
591 stateKey: remoteStateKey(target),
592 });
593 return {
594 remotePort: parsed.remotePort,
595 remoteServerKind: parsed.serverKind ?? null,
596 };
597 },
598);
599
600export const issueRemotePairingToken = Effect.fn("ssh/tunnel.issueRemotePairingToken")(function* (
601 target: DesktopSshEnvironmentTarget,
602 input?: SshAuthOptions,
603 runner?: RemoteT3RunnerOptions,
604): Effect.fn.Return<
605 {
606 readonly credential: string;
607 },
608 SshCommandError | SshInvalidTargetError | SshPairingError,
609 ChildProcessSpawner.ChildProcessSpawner | FileSystem.FileSystem | Path.Path
610> {
611 yield* Effect.logDebug("ssh.remoteServer.pairingToken.start", {
612 ...sshTargetLogFields(target),
613 stateKey: remoteStateKey(target),
614 });
615 const result = yield* runSshCommand(target, {
616 remoteCommandArgs: ["sh", "-s"],
617 stdin: buildRemotePairingScript(target, runner),
618 ...(input?.authSecret === undefined ? {} : { authSecret: input.authSecret }),
619 ...(input?.batchMode === undefined ? {} : { batchMode: input.batchMode }),
620 ...(input?.interactiveAuth === undefined ? {} : { interactiveAuth: input.interactiveAuth }),
621 });
622 if (!getLastNonEmptyOutputLine(result.stdout)) {
623 return yield* new SshPairingError({
624 message: "SSH pairing did not return a credential.",
625 stdout: result.stdout,
626 });
627 }
628 const parsed = yield* decodeRemotePairingResult(result.stdout).pipe(
629 Effect.mapError(
630 (cause) =>
631 new SshPairingError({
632 message: "SSH pairing returned unparseable output.",
633 stdout: result.stdout,
634 cause,
635 }),
636 ),
637 );
638 if (parsed.credential.trim().length === 0) {
639 return yield* new SshPairingError({
640 message: "SSH pairing command returned an invalid credential.",
641 stdout: result.stdout,
642 });
643 }
644 yield* Effect.logDebug("ssh.remoteServer.pairingToken.created", {
645 ...sshTargetLogFields(target),
646 stateKey: remoteStateKey(target),
647 });
648 return {
649 credential: parsed.credential,
650 };
651});
652
653export const stopRemoteServer = Effect.fn("ssh/tunnel.stopRemoteServer")(function* (
654 target: DesktopSshEnvironmentTarget,
655 input?: SshAuthOptions,
656): Effect.fn.Return<
657 void,
658 SshCommandError | SshInvalidTargetError,
659 ChildProcessSpawner.ChildProcessSpawner | FileSystem.FileSystem | Path.Path
660> {
661 yield* Effect.logInfo("ssh.remoteServer.stop.start", {
662 ...sshTargetLogFields(target),
663 stateKey: remoteStateKey(target),
664 });
665 yield* runSshCommand(target, {
666 remoteCommandArgs: ["sh", "-s"],
667 stdin: buildRemoteStopScript(target),
668 ...(input?.authSecret === undefined ? {} : { authSecret: input.authSecret }),
669 ...(input?.batchMode === undefined ? {} : { batchMode: input.batchMode }),
670 ...(input?.interactiveAuth === undefined ? {} : { interactiveAuth: input.interactiveAuth }),
671 });
672 yield* Effect.logInfo("ssh.remoteServer.stop.succeeded", {
673 ...sshTargetLogFields(target),
674 stateKey: remoteStateKey(target),
675 });
676});
677
678const readRemoteServerLogTail = Effect.fn("ssh/tunnel.readRemoteServerLogTail")(function* (
679 target: DesktopSshEnvironmentTarget,
680 input?: SshAuthOptions,
681): Effect.fn.Return<
682 string,
683 SshCommandError | SshInvalidTargetError,
684 ChildProcessSpawner.ChildProcessSpawner | FileSystem.FileSystem | Path.Path
685> {
686 const result = yield* runSshCommand(target, {
687 remoteCommandArgs: ["sh", "-s"],
688 stdin: buildRemoteLogTailScript(target),
689 timeoutMs: 10_000,
690 ...(input?.authSecret === undefined ? {} : { authSecret: input.authSecret }),
691 ...(input?.batchMode === undefined ? {} : { batchMode: input.batchMode }),
692 ...(input?.interactiveAuth === undefined ? {} : { interactiveAuth: input.interactiveAuth }),
693 });
694 return result.stdout.trim();
695});
696
697export const waitForHttpReady = Effect.fn("ssh/tunnel.waitForHttpReady")(function* (input: {
698 readonly baseUrl: string;
699 readonly timeoutMs?: number;
700 readonly intervalMs?: number;
701 readonly probeTimeoutMs?: number;
702 readonly path?: string;
703}): Effect.fn.Return<void, SshReadinessError, HttpClient.HttpClient> {
704 const timeoutMs = input.timeoutMs ?? 30_000;
705 const intervalMs = input.intervalMs ?? 100;
706 const probeTimeoutMs = input.probeTimeoutMs ?? SSH_READY_PROBE_TIMEOUT_MS;
707 const retryPolicy = Schedule.spaced(Duration.millis(intervalMs)).pipe(
708 Schedule.take(Math.max(0, Math.ceil(timeoutMs / intervalMs))),
709 );
710 const requestUrl = new URL(input.path ?? "/", input.baseUrl).toString();
711 const client = yield* HttpClient.HttpClient;
712 const lastProbeFailure = yield* Ref.make<unknown>(null);
713 let attempt = 0;
714
715 yield* Effect.logDebug("ssh.tunnel.httpReady.start", {
716 baseUrl: input.baseUrl,
717 requestUrl,
718 timeoutMs,
719 intervalMs,
720 probeTimeoutMs,
721 });
722
723 const readinessClient = client.pipe(
724 HttpClient.filterStatusOk,
725 HttpClient.transform((effect) =>
726 Effect.gen(function* () {
727 attempt += 1;
728 const responseOption = yield* effect.pipe(
729 Effect.timeoutOption(Duration.millis(probeTimeoutMs)),
730 Effect.mapError(
731 (cause) =>
732 new SshReadinessError({
733 message: `Backend readiness probe failed at ${requestUrl}.`,
734 cause,
735 }),
736 ),
737 );
738 return yield* Option.match(responseOption, {
739 onSome: Effect.succeed,
740 onNone: () =>
741 Effect.fail(
742 new SshReadinessError({
743 message: `Backend readiness probe exceeded ${probeTimeoutMs}ms at ${requestUrl}.`,
744 cause: {
745 kind: "probe-timeout",
746 attempt,
747 probeTimeoutMs,
748 },
749 }),
750 ),
751 });
752 }).pipe(
753 Effect.mapError((cause) =>
754 cause instanceof SshReadinessError
755 ? cause
756 : new SshReadinessError({
757 message: `Backend readiness probe failed at ${requestUrl}.`,
758 cause,
759 }),
760 ),
761 Effect.tapError((cause) =>
762 Ref.set(lastProbeFailure, {
763 attempt,
764 cause: describeReadinessCause(cause),
765 }),
766 ),
767 ),
768 ),
769 HttpClient.tap((response) => response.text.pipe(Effect.ignore)),
770 HttpClient.retry(retryPolicy),
771 );
772
773 const result = yield* readinessClient.execute(HttpClientRequest.get(requestUrl)).pipe(
774 Effect.mapError((cause) =>
775 cause instanceof SshReadinessError
776 ? cause
777 : new SshReadinessError({
778 message: `Backend readiness probe failed at ${requestUrl}.`,
779 cause,
780 }),
781 ),
782 Effect.timeoutOption(Duration.millis(timeoutMs)),
783 );
784
785 return yield* Option.match(result, {
786 onSome: () =>
787 Effect.logDebug("ssh.tunnel.httpReady.succeeded", {
788 baseUrl: input.baseUrl,
789 requestUrl,
790 attempts: attempt,
791 }),
792 onNone: () =>
793 Effect.gen(function* () {
794 const lastFailure = yield* Ref.get(lastProbeFailure);
795 yield* Effect.logWarning("ssh.tunnel.httpReady.timedOut", {
796 baseUrl: input.baseUrl,
797 requestUrl,
798 timeoutMs,
799 intervalMs,
800 probeTimeoutMs,
801 attempts: attempt,
802 lastFailure,
803 });
804 return yield* new SshReadinessError({
805 message: `Timed out waiting ${timeoutMs}ms for backend readiness at ${input.baseUrl}.`,
806 cause: lastFailure,
807 });
808 }),
809 });
810});
811
812function isLoopbackHostname(hostname: string): boolean {
813 const normalized = hostname
814 .trim()
815 .toLowerCase()
816 .replace(/^\[(.*)\]$/, "$1");
817 return normalized === "127.0.0.1" || normalized === "::1" || normalized === "localhost";
818}
819
820function resolveLoopbackSshHttpUrl(
821 rawHttpBaseUrl: unknown,
822 pathname: string,
823): Effect.Effect<URL, SshHttpBridgeError> {
824 return Effect.try({
825 try: () => {
826 if (typeof rawHttpBaseUrl !== "string" || rawHttpBaseUrl.trim().length === 0) {
827 throw new Error("Invalid SSH forwarded http base URL.");
828 }
829 const baseUrl = new URL(rawHttpBaseUrl);
830 if (!isLoopbackHostname(baseUrl.hostname)) {
831 throw new Error("SSH desktop bridge only supports loopback forwarded URLs.");
832 }
833 const url = new URL(baseUrl.toString());
834 url.pathname = pathname;
835 url.search = "";
836 url.hash = "";
837 return url;
838 },
839 catch: (cause) =>
840 new SshHttpBridgeError({
841 message: cause instanceof Error ? cause.message : "Invalid SSH forwarded http base URL.",
842 cause,
843 }),
844 });
845}
846
847export const fetchLoopbackSshJson = Effect.fn("ssh/tunnel.fetchLoopbackSshJson")(function* <
848 T,
849>(input: {
850 readonly httpBaseUrl: unknown;
851 readonly pathname: string;
852 readonly method?: "GET" | "POST";
853 readonly bearerToken?: unknown;
854 readonly body?: unknown;
855}): Effect.fn.Return<T, SshHttpBridgeError, HttpClient.HttpClient> {
856 const requestUrl = yield* resolveLoopbackSshHttpUrl(input.httpBaseUrl, input.pathname);
857 const bearerToken =
858 typeof input.bearerToken === "string" && input.bearerToken.trim().length > 0
859 ? input.bearerToken
860 : null;
861
862 const request = (
863 input.method === "POST"
864 ? HttpClientRequest.post(requestUrl.toString())
865 : HttpClientRequest.get(requestUrl.toString())
866 ).pipe(
867 input.body === undefined ? (req) => req : HttpClientRequest.bodyJsonUnsafe(input.body),
868 bearerToken
869 ? HttpClientRequest.setHeader("authorization", `Bearer ${bearerToken}`)
870 : (req) => req,
871 );
872 const client = yield* HttpClient.HttpClient;
873 const response = yield* client.execute(request).pipe(
874 Effect.mapError(
875 (cause) =>
876 new SshHttpBridgeError({
877 message: `Failed to reach SSH forwarded endpoint ${requestUrl.toString()}.`,
878 cause,
879 }),
880 ),
881 );
882 if (response.status < 200 || response.status >= 300) {
883 const text = yield* response.text.pipe(Effect.catch(() => Effect.succeed("")));
884 const parsedError = yield* decodeRemoteHttpError(text).pipe(
885 Effect.catch(() => Effect.succeed(null)),
886 );
887 const message =
888 parsedError?.error && parsedError.error.trim().length > 0
889 ? parsedError.error
890 : text || `SSH forwarded request failed (${response.status}).`;
891 return yield* new SshHttpBridgeError({
892 status: response.status,
893 message: `[ssh_http:${response.status}] ${message} (${input.method ?? "GET"} ${requestUrl.toString()})`,
894 });
895 }
896 return (yield* response.json.pipe(
897 Effect.mapError(
898 (cause) =>
899 new SshHttpBridgeError({
900 message: `SSH forwarded endpoint ${requestUrl.toString()} returned invalid JSON.`,
901 cause,
902 }),
903 ),
904 )) as T;
905});
906
907const reserveLocalTunnelPort = Effect.fn("ssh/tunnel.reserveLocalTunnelPort")(function* () {
908 const net = yield* NetService;
909 return yield* net.reserveLoopbackPort();
910});
911
912const startSshTunnel = Effect.fn("ssh/tunnel.startSshTunnel")(function* (input: {
913 readonly key: string;
914 readonly resolvedTarget: DesktopSshEnvironmentTarget;
915 readonly remotePort: number;
916 readonly localPort: number;
917 readonly httpBaseUrl: string;
918 readonly wsBaseUrl: string;
919 readonly authOptions: SshAuthOptions;
920 readonly remoteServerKind: "external" | "managed" | null;
921}): Effect.fn.Return<
922 SshTunnelEntry,
923 SshCommandError | SshInvalidTargetError | SshReadinessError,
924 | ChildProcessSpawner.ChildProcessSpawner
925 | FileSystem.FileSystem
926 | Path.Path
927 | HttpClient.HttpClient
928 | NetService
929 | Scope.Scope
930> {
931 const hostSpec = yield* buildSshHostSpecEffect(input.resolvedTarget);
932 const childEnvironment = yield* buildSshChildEnvironment({
933 ...(input.authOptions.authSecret === undefined
934 ? {}
935 : { authSecret: input.authOptions.authSecret }),
936 ...(input.authOptions.interactiveAuth === undefined
937 ? {}
938 : { interactiveAuth: input.authOptions.interactiveAuth }),
939 }).pipe(
940 Effect.mapError(
941 (cause) =>
942 new SshCommandError({
943 command: ["ssh"],
944 exitCode: null,
945 stderr: "",
946 message: "Failed to prepare SSH authentication helpers.",
947 cause,
948 }),
949 ),
950 );
951 const args = [
952 ...baseSshArgs(input.resolvedTarget, {
953 batchMode: input.authOptions.batchMode ?? "no",
954 }),
955 "-o",
956 "ExitOnForwardFailure=yes",
957 "-o",
958 "ServerAliveInterval=15",
959 "-o",
960 "ServerAliveCountMax=3",
961 "-n",
962 "-N",
963 "-L",
964 `${input.localPort}:127.0.0.1:${input.remotePort}`,
965 hostSpec,
966 ];
967 const tunnelCommand = ["ssh", ...args];
968 const spawner = yield* ChildProcessSpawner.ChildProcessSpawner;
969 const scope = yield* Scope.Scope;
970 yield* Effect.logDebug("ssh.tunnel.spawn.start", {
971 ...sshTargetLogFields(input.resolvedTarget),
972 command: tunnelCommand,
973 localPort: input.localPort,
974 remotePort: input.remotePort,
975 remoteServerKind: input.remoteServerKind,
976 httpBaseUrl: input.httpBaseUrl,
977 });
978 const child = yield* spawner
979 .spawn(
980 ChildProcess.make("ssh", args, {
981 env: childEnvironment,
982 shell: process.platform === "win32",
983 stdin: {
984 stream: Stream.empty,
985 endOnDone: true,
986 },
987 }),
988 )
989 .pipe(
990 Effect.mapError(
991 (cause) =>
992 new SshCommandError({
993 command: tunnelCommand,
994 exitCode: null,
995 stderr: "",
996 message:
997 cause instanceof Error
998 ? cause.message
999 : `Failed to spawn SSH tunnel for ${input.resolvedTarget.alias}.`,
1000 cause,
1001 }),
1002 ),
1003 );
1004 yield* Effect.logDebug("ssh.tunnel.spawn.succeeded", {
1005 ...sshTargetLogFields(input.resolvedTarget),
1006 command: tunnelCommand,
1007 pid: child.pid,
1008 localPort: input.localPort,
1009 remotePort: input.remotePort,
1010 httpBaseUrl: input.httpBaseUrl,
1011 });
1012 const tunnelEntry: SshTunnelEntry = {
1013 key: input.key,
1014 target: input.resolvedTarget,
1015 remotePort: input.remotePort,
1016 remoteServerKind: input.remoteServerKind,
1017 localPort: input.localPort,
1018 httpBaseUrl: input.httpBaseUrl,
1019 wsBaseUrl: input.wsBaseUrl,
1020 process: child,
1021 scope,
1022 };
1023 const exitFailure = Effect.all(
1024 [collectProcessOutput(child.stderr), child.exitCode.pipe(Effect.map(Number))],
1025 { concurrency: "unbounded" },
1026 ).pipe(
1027 Effect.mapError(
1028 (cause) =>
1029 new SshCommandError({
1030 command: tunnelCommand,
1031 exitCode: null,
1032 stderr: "",
1033 message:
1034 cause instanceof Error
1035 ? cause.message
1036 : `Failed to monitor SSH tunnel for ${input.resolvedTarget.alias}.`,
1037 cause,
1038 }),
1039 ),
1040 Effect.flatMap(([stderr, exitCode]) => {
1041 const error = new SshCommandError({
1042 command: tunnelCommand,
1043 exitCode,
1044 stderr,
1045 message: normalizeSshErrorMessage(
1046 stderr,
1047 `SSH tunnel exited unexpectedly for ${input.resolvedTarget.alias} (exit ${exitCode}).`,
1048 ),
1049 });
1050 return Effect.logWarning("ssh.tunnel.process.exited", {
1051 ...sshTargetLogFields(input.resolvedTarget),
1052 command: tunnelCommand,
1053 pid: child.pid,
1054 localPort: input.localPort,
1055 remotePort: input.remotePort,
1056 httpBaseUrl: input.httpBaseUrl,
1057 exitCode,
1058 stderr,
1059 }).pipe(Effect.andThen(Effect.fail(error)));
1060 }),
1061 );
1062 yield* Effect.raceFirst(
1063 waitForHttpReady({
1064 baseUrl: input.httpBaseUrl,
1065 timeoutMs: SSH_READY_TIMEOUT_MS,
1066 }),
1067 exitFailure,
1068 ).pipe(
1069 Effect.tap(() =>
1070 Effect.logInfo("ssh.tunnel.ready", {
1071 ...sshTargetLogFields(input.resolvedTarget),
1072 command: tunnelCommand,
1073 pid: child.pid,
1074 localPort: input.localPort,
1075 remotePort: input.remotePort,
1076 httpBaseUrl: input.httpBaseUrl,
1077 }),
1078 ),
1079 Effect.tapError((cause) =>
1080 Effect.gen(function* () {
1081 const net = yield* NetService;
1082 const processRunningExit = yield* Effect.exit(child.isRunning);
1083 const localPortAvailableExit = yield* Effect.exit(
1084 net.canListenOnHost(input.localPort, "127.0.0.1"),
1085 );
1086 const remoteLogTailExit = yield* Effect.exit(
1087 readRemoteServerLogTail(input.resolvedTarget, input.authOptions),
1088 );
1089 const processRunning = Exit.isSuccess(processRunningExit) ? processRunningExit.value : null;
1090 const localPortAvailable = Exit.isSuccess(localPortAvailableExit)
1091 ? localPortAvailableExit.value
1092 : null;
1093 const remoteLogTail = Exit.isSuccess(remoteLogTailExit)
1094 ? remoteLogTailExit.value || null
1095 : null;
1096 yield* Effect.logWarning("ssh.tunnel.ready.failed", {
1097 ...sshTargetLogFields(input.resolvedTarget),
1098 command: tunnelCommand,
1099 pid: child.pid,
1100 processRunning,
1101 ...(Exit.isSuccess(processRunningExit)
1102 ? {}
1103 : { processRunningError: processRunningExit.cause }),
1104 localPort: input.localPort,
1105 localPortListening: localPortAvailable === null ? null : !localPortAvailable,
1106 remotePort: input.remotePort,
1107 httpBaseUrl: input.httpBaseUrl,
1108 ...(Exit.isSuccess(localPortAvailableExit)
1109 ? {}
1110 : { localPortProbeError: localPortAvailableExit.cause }),
1111 ...(remoteLogTail === null ? {} : { remoteLogTail }),
1112 ...(Exit.isSuccess(remoteLogTailExit)
1113 ? {}
1114 : { remoteLogTailError: remoteLogTailExit.cause }),
1115 cause,
1116 });
1117 }),
1118 ),
1119 Effect.onExit((exit) =>
1120 Exit.isSuccess(exit)
1121 ? Effect.void
1122 : child
1123 .kill({
1124 killSignal: "SIGTERM",
1125 forceKillAfter: TUNNEL_SHUTDOWN_TIMEOUT_MS,
1126 })
1127 .pipe(Effect.ignore),
1128 ),
1129 );
1130 return tunnelEntry;
1131});
1132
1133const makeSshEnvironmentManager = Effect.fn("ssh/tunnel.SshEnvironmentManager.make")(function* (
1134 options: SshEnvironmentManagerOptions = {},
1135): Effect.fn.Return<SshEnvironmentManagerShape, never, Scope.Scope> {
1136 const managerScope = yield* Scope.Scope;
1137 const tunnels = new Map<string, SshTunnelEntry>();
1138 const pendingTunnelEntries = new Map<
1139 string,
1140 Deferred.Deferred<SshTunnelEntry, SshEnvironmentEffectError>
1141 >();
1142 const authSecrets = new Map<string, string>();
1143
1144 const closeTunnelEntry = Effect.fn("ssh/tunnel.closeTunnelEntry")(function* (
1145 entry: SshTunnelEntry,
1146 ) {
1147 yield* Effect.logDebug("ssh.tunnel.close.start", {
1148 ...sshTargetLogFields(entry.target),
1149 key: entry.key,
1150 localPort: entry.localPort,
1151 remotePort: entry.remotePort,
1152 });
1153 yield* Scope.close(entry.scope, Exit.void).pipe(Effect.ignore);
1154 yield* Effect.logInfo("ssh.tunnel.close.succeeded", {
1155 ...sshTargetLogFields(entry.target),
1156 key: entry.key,
1157 localPort: entry.localPort,
1158 remotePort: entry.remotePort,
1159 });
1160 });
1161
1162 const cancelPendingTunnelEntry = Effect.fn("ssh/tunnel.cancelPendingTunnelEntry")(function* (
1163 key: string,
1164 target: DesktopSshEnvironmentTarget,
1165 ) {
1166 const pending = pendingTunnelEntries.get(key);
1167 if (!pending) {
1168 return;
1169 }
1170 pendingTunnelEntries.delete(key);
1171 yield* Deferred.fail(pending, makeSshTunnelCancelledError(target)).pipe(Effect.ignore);
1172 });
1173
1174 yield* Scope.addFinalizer(
1175 managerScope,
1176 Effect.sync(() => [...tunnels.values()]).pipe(
1177 Effect.flatMap((entries) =>
1178 Effect.forEach(entries, closeTunnelEntry, { concurrency: "unbounded" }),
1179 ),
1180 Effect.ignore,
1181 ),
1182 );
1183
1184 const promptForPassword = Effect.fn("ssh/tunnel.promptForPassword")(function* (
1185 target: DesktopSshEnvironmentTarget,
1186 attempt: number,
1187 ): Effect.fn.Return<string, SshInvalidTargetError | SshPasswordPromptError, SshPasswordPrompt> {
1188 const promptService = yield* SshPasswordPrompt;
1189 const hostSpec = yield* buildSshHostSpecEffect(target);
1190 if (!promptService.isAvailable) {
1191 yield* Effect.logWarning("ssh.auth.passwordPrompt.unavailable", {
1192 ...sshTargetLogFields(target),
1193 attempt,
1194 });
1195 return yield* new SshPasswordPromptError({
1196 message: `SSH authentication failed for ${hostSpec}.`,
1197 });
1198 }
1199
1200 yield* Effect.logInfo("ssh.auth.passwordPrompt.request", {
1201 ...sshTargetLogFields(target),
1202 attempt,
1203 });
1204 const password = yield* promptService.request({
1205 attempt,
1206 destination: target.alias.trim() || target.hostname.trim(),
1207 username: target.username,
1208 prompt: `Enter the SSH password for ${hostSpec}.`,
1209 });
1210 if (password === null) {
1211 yield* Effect.logWarning("ssh.auth.passwordPrompt.cancelled", {
1212 ...sshTargetLogFields(target),
1213 attempt,
1214 });
1215 return yield* new SshPasswordPromptError({
1216 message: `SSH authentication cancelled for ${hostSpec}.`,
1217 });
1218 }
1219 yield* Effect.logInfo("ssh.auth.passwordPrompt.received", {
1220 ...sshTargetLogFields(target),
1221 attempt,
1222 });
1223 return password;
1224 });
1225
1226 const handleSshAuthFailure = Effect.fn("ssh/tunnel.runWithSshAuthAttempt.handleFailure")(
1227 function* <T>(
1228 input: SshAuthAttemptInput<T> & {
1229 readonly error: SshEnvironmentEffectError;
1230 },
1231 ): Effect.fn.Return<T, SshEnvironmentEffectError, SshEnvironmentEffectContext> {
1232 if (!isSshAuthFailure(input.error)) {
1233 return yield* input.error;
1234 }
1235
1236 yield* Effect.logWarning("ssh.auth.failed", {
1237 ...sshTargetLogFields(input.target),
1238 key: input.key,
1239 promptCount: input.promptCount,
1240 cause: input.error,
1241 });
1242 const promptService = yield* SshPasswordPrompt;
1243 if (!promptService.isAvailable) {
1244 return yield* input.error;
1245 }
1246 if (input.authSecret !== null) {
1247 authSecrets.delete(input.key);
1248 }
1249 if (input.promptCount >= 2) {
1250 return yield* input.error;
1251 }
1252
1253 const nextPromptCount = input.promptCount + 1;
1254 const nextAuthSecret = yield* promptForPassword(input.target, nextPromptCount);
1255 authSecrets.set(input.key, nextAuthSecret);
1256 return yield* runWithSshAuthAttempt({
1257 ...input,
1258 promptCount: nextPromptCount,
1259 authSecret: nextAuthSecret,
1260 });
1261 },
1262 );
1263
1264 const runWithSshAuthAttempt = Effect.fn("ssh/tunnel.runWithSshAuthAttempt")(function* <T>(
1265 input: SshAuthAttemptInput<T>,
1266 ): Effect.fn.Return<T, SshEnvironmentEffectError, SshEnvironmentEffectContext> {
1267 const promptService = yield* SshPasswordPrompt;
1268 const authOptions =
1269 input.authSecret === null
1270 ? {
1271 batchMode: promptService.isAvailable ? ("yes" as const) : ("no" as const),
1272 interactiveAuth: !promptService.isAvailable,
1273 }
1274 : {
1275 authSecret: input.authSecret,
1276 batchMode: "no" as const,
1277 interactiveAuth: true,
1278 };
1279
1280 return yield* input
1281 .operation(authOptions)
1282 .pipe(Effect.catch((error) => handleSshAuthFailure({ ...input, error })));
1283 });
1284
1285 const runWithSshAuth = Effect.fn("ssh/tunnel.runWithSshAuth")(function* <T>(
1286 input: SshAuthOperationInput<T>,
1287 ): Effect.fn.Return<T, SshEnvironmentEffectError, SshEnvironmentEffectContext> {
1288 return yield* runWithSshAuthAttempt({
1289 ...input,
1290 promptCount: 0,
1291 authSecret: authSecrets.get(input.key) ?? null,
1292 });
1293 });
1294
1295 const createTunnelEntry = Effect.fn("ssh/tunnel.ensureTunnelEntry.create")(function* (input: {
1296 readonly key: string;
1297 readonly resolvedTarget: DesktopSshEnvironmentTarget;
1298 readonly runner?: RemoteT3RunnerOptions;
1299 }): Effect.fn.Return<SshTunnelEntry, SshEnvironmentEffectError, SshEnvironmentEffectContext> {
1300 yield* Effect.logDebug("ssh.environment.tunnel.create.start", {
1301 ...sshTargetLogFields(input.resolvedTarget),
1302 ...sshRunnerLogFields(input.runner),
1303 key: input.key,
1304 });
1305 const remoteLaunch = yield* runWithSshAuth({
1306 key: input.key,
1307 target: input.resolvedTarget,
1308 operation: (authOptions) =>
1309 launchOrReuseRemoteServer(input.resolvedTarget, authOptions, input.runner),
1310 });
1311 const remotePort = remoteLaunch.remotePort;
1312 yield* Effect.logDebug("ssh.environment.remotePort.ready", {
1313 ...sshTargetLogFields(input.resolvedTarget),
1314 key: input.key,
1315 remotePort,
1316 remoteServerKind: remoteLaunch.remoteServerKind,
1317 });
1318 const localPort = yield* reserveLocalTunnelPort();
1319 const httpBaseUrl = `http://127.0.0.1:${localPort}/`;
1320 const wsBaseUrl = `ws://127.0.0.1:${localPort}/`;
1321 yield* Effect.logDebug("ssh.environment.localPort.reserved", {
1322 ...sshTargetLogFields(input.resolvedTarget),
1323 key: input.key,
1324 localPort,
1325 remotePort,
1326 });
1327 const entryScope = yield* Scope.make("sequential");
1328 const tunnelEntry = yield* runWithSshAuth({
1329 key: input.key,
1330 target: input.resolvedTarget,
1331 operation: (authOptions) =>
1332 startSshTunnel({
1333 key: input.key,
1334 resolvedTarget: input.resolvedTarget,
1335 remotePort,
1336 localPort,
1337 httpBaseUrl,
1338 wsBaseUrl,
1339 authOptions,
1340 remoteServerKind: remoteLaunch.remoteServerKind,
1341 }).pipe(Effect.provideService(Scope.Scope, entryScope)),
1342 }).pipe(
1343 Effect.onExit((exit) =>
1344 Exit.isSuccess(exit) ? Effect.void : Scope.close(entryScope, Exit.void).pipe(Effect.ignore),
1345 ),
1346 );
1347 tunnels.set(input.key, tunnelEntry);
1348 const spawnerService = yield* ChildProcessSpawner.ChildProcessSpawner;
1349 const fileSystemService = yield* FileSystem.FileSystem;
1350 const pathService = yield* Path.Path;
1351 yield* Scope.addFinalizer(
1352 entryScope,
1353 Effect.gen(function* () {
1354 if (tunnels.get(tunnelEntry.key) !== tunnelEntry) {
1355 return;
1356 }
1357 yield* Effect.logDebug("ssh.environment.tunnel.finalizer.start", {
1358 ...sshTargetLogFields(tunnelEntry.target),
1359 key: tunnelEntry.key,
1360 localPort: tunnelEntry.localPort,
1361 remotePort: tunnelEntry.remotePort,
1362 });
1363 tunnels.delete(tunnelEntry.key);
1364 const authSecret = authSecrets.get(tunnelEntry.key) ?? null;
1365 yield* Effect.all(
1366 [
1367 tunnelEntry.process.kill({
1368 killSignal: "SIGTERM",
1369 forceKillAfter: TUNNEL_SHUTDOWN_TIMEOUT_MS,
1370 }),
1371 stopRemoteServer(
1372 tunnelEntry.target,
1373 authSecret === null
1374 ? {
1375 batchMode: "yes",
1376 interactiveAuth: false,
1377 }
1378 : {
1379 authSecret,
1380 batchMode: "no",
1381 interactiveAuth: true,
1382 },
1383 ).pipe(
1384 Effect.provideService(ChildProcessSpawner.ChildProcessSpawner, spawnerService),
1385 Effect.provideService(FileSystem.FileSystem, fileSystemService),
1386 Effect.provideService(Path.Path, pathService),
1387 ),
1388 ],
1389 { concurrency: "unbounded" },
1390 ).pipe(Effect.ignore);
1391 yield* Effect.logDebug("ssh.environment.tunnel.finalizer.succeeded", {
1392 ...sshTargetLogFields(tunnelEntry.target),
1393 key: tunnelEntry.key,
1394 localPort: tunnelEntry.localPort,
1395 remotePort: tunnelEntry.remotePort,
1396 });
1397 }).pipe(Effect.ignore),
1398 );
1399 yield* Effect.logDebug("ssh.environment.tunnel.create.succeeded", {
1400 ...sshTargetLogFields(input.resolvedTarget),
1401 key: input.key,
1402 localPort,
1403 remotePort,
1404 });
1405 return tunnelEntry;
1406 });
1407
1408 const ensureTunnelEntry = Effect.fn("ssh/tunnel.ensureTunnelEntry")(function* (
1409 key: string,
1410 resolvedTarget: DesktopSshEnvironmentTarget,
1411 runner?: RemoteT3RunnerOptions,
1412 ): Effect.fn.Return<SshTunnelEntry, SshEnvironmentEffectError, SshEnvironmentEffectContext> {
1413 let entry = tunnels.get(key) ?? null;
1414
1415 if (entry !== null) {
1416 yield* Effect.logDebug("ssh.environment.tunnel.existing.check", {
1417 ...sshTargetLogFields(resolvedTarget),
1418 key,
1419 localPort: entry.localPort,
1420 remotePort: entry.remotePort,
1421 });
1422 const readinessExit = yield* Effect.exit(
1423 waitForHttpReady({ baseUrl: entry.httpBaseUrl, timeoutMs: 2_000 }),
1424 );
1425 if (Exit.isSuccess(readinessExit)) {
1426 yield* Effect.logDebug("ssh.environment.tunnel.reused", {
1427 ...sshTargetLogFields(resolvedTarget),
1428 key,
1429 localPort: entry.localPort,
1430 remotePort: entry.remotePort,
1431 });
1432 return entry;
1433 }
1434 yield* Effect.logWarning("ssh.environment.tunnel.existing.stale", {
1435 ...sshTargetLogFields(resolvedTarget),
1436 key,
1437 localPort: entry.localPort,
1438 remotePort: entry.remotePort,
1439 cause: readinessExit.cause,
1440 });
1441 yield* closeTunnelEntry(entry);
1442 yield* cancelPendingTunnelEntry(key, resolvedTarget);
1443 entry = null;
1444 }
1445
1446 const pending = pendingTunnelEntries.get(key);
1447 if (pending) {
1448 yield* Effect.logDebug("ssh.environment.tunnel.pending.await", {
1449 ...sshTargetLogFields(resolvedTarget),
1450 key,
1451 });
1452 return yield* Deferred.await(pending);
1453 }
1454
1455 const deferred = yield* Deferred.make<SshTunnelEntry, SshEnvironmentEffectError>();
1456 pendingTunnelEntries.set(key, deferred);
1457
1458 return yield* createTunnelEntry({
1459 key,
1460 resolvedTarget,
1461 ...(runner === undefined ? {} : { runner }),
1462 }).pipe(
1463 Effect.tapError((cause) =>
1464 Effect.logWarning("ssh.environment.tunnel.create.failed", {
1465 ...sshTargetLogFields(resolvedTarget),
1466 key,
1467 cause,
1468 }),
1469 ),
1470 Effect.onExit((exit) =>
1471 Effect.sync(() => {
1472 if (pendingTunnelEntries.get(key) === deferred) {
1473 pendingTunnelEntries.delete(key);
1474 }
1475 }).pipe(Effect.andThen(Deferred.done(deferred, exit))),
1476 ),
1477 );
1478 });
1479
1480 const ensureEnvironment = Effect.fn("ssh/tunnel.ensureEnvironment")(function* (
1481 target: DesktopSshEnvironmentTarget,
1482 requestOptions?: { readonly issuePairingToken?: boolean },
1483 ): Effect.fn.Return<
1484 DesktopSshEnvironmentBootstrap,
1485 SshEnvironmentEffectError,
1486 SshEnvironmentEffectContext
1487 > {
1488 yield* Effect.logInfo("ssh.environment.ensure.start", {
1489 ...sshTargetLogFields(target),
1490 issuePairingToken: requestOptions?.issuePairingToken === true,
1491 });
1492 const baseResolved = yield* resolveSshTarget(target.alias || target.hostname);
1493 const resolvedTarget: DesktopSshEnvironmentTarget = {
1494 ...baseResolved,
1495 ...(target.username !== null ? { username: target.username } : {}),
1496 ...(target.port !== null ? { port: target.port } : {}),
1497 };
1498 const key = targetConnectionKey(resolvedTarget);
1499 yield* Effect.logDebug("ssh.environment.target.resolved", {
1500 ...sshTargetLogFields(resolvedTarget),
1501 key,
1502 });
1503 const packageSpec = options.resolveCliPackageSpec?.();
1504 const runner =
1505 options.resolveCliRunner?.() ?? (packageSpec === undefined ? undefined : { packageSpec });
1506 yield* Effect.logDebug("ssh.environment.runner.resolved", {
1507 ...sshTargetLogFields(resolvedTarget),
1508 ...sshRunnerLogFields(runner),
1509 key,
1510 });
1511 const entry = yield* ensureTunnelEntry(key, resolvedTarget, runner);
1512
1513 const pairingResult = requestOptions?.issuePairingToken
1514 ? yield* runWithSshAuth({
1515 key,
1516 target: entry.target,
1517 operation: (authOptions) => issueRemotePairingToken(entry.target, authOptions, runner),
1518 })
1519 : null;
1520 const pairingToken = pairingResult?.credential ?? null;
1521
1522 yield* Effect.logInfo("ssh.environment.ensure.succeeded", {
1523 ...sshTargetLogFields(entry.target),
1524 key,
1525 localPort: entry.localPort,
1526 remotePort: entry.remotePort,
1527 remoteServerKind: entry.remoteServerKind,
1528 issuedPairingToken: pairingToken !== null,
1529 });
1530 return {
1531 target: entry.target,
1532 httpBaseUrl: entry.httpBaseUrl,
1533 wsBaseUrl: entry.wsBaseUrl,
1534 pairingToken,
1535 remotePort: entry.remotePort,
1536 ...(entry.remoteServerKind ? { remoteServerKind: entry.remoteServerKind } : {}),
1537 };
1538 });
1539
1540 const disconnectEnvironment = Effect.fn("ssh/tunnel.disconnectEnvironment")(function* (
1541 target: DesktopSshEnvironmentTarget,
1542 ): Effect.fn.Return<void, SshEnvironmentEffectError, SshEnvironmentEffectContext> {
1543 yield* Effect.logInfo("ssh.environment.disconnect.start", sshTargetLogFields(target));
1544 const baseResolved = yield* resolveSshTarget(target.alias || target.hostname);
1545 const resolvedTarget: DesktopSshEnvironmentTarget = {
1546 ...baseResolved,
1547 ...(target.username !== null ? { username: target.username } : {}),
1548 ...(target.port !== null ? { port: target.port } : {}),
1549 };
1550 const key = targetConnectionKey(resolvedTarget);
1551 const entry = tunnels.get(key) ?? null;
1552 yield* Effect.logDebug("ssh.environment.disconnect.targetResolved", {
1553 ...sshTargetLogFields(resolvedTarget),
1554 key,
1555 hasTunnel: entry !== null,
1556 hasPendingTunnel: pendingTunnelEntries.has(key),
1557 });
1558 if (entry !== null) {
1559 yield* closeTunnelEntry(entry);
1560 }
1561 yield* cancelPendingTunnelEntry(key, resolvedTarget);
1562 if (entry === null) {
1563 yield* runWithSshAuth({
1564 key,
1565 target: resolvedTarget,
1566 operation: (authOptions) => stopRemoteServer(resolvedTarget, authOptions),
1567 });
1568 }
1569 yield* Effect.logInfo("ssh.environment.disconnect.succeeded", {
1570 ...sshTargetLogFields(resolvedTarget),
1571 key,
1572 });
1573 });
1574
1575 return SshEnvironmentManager.of({ ensureEnvironment, disconnectEnvironment });
1576});
1577
1578export class SshEnvironmentManager extends Context.Service<
1579 SshEnvironmentManager,
1580 SshEnvironmentManagerShape
1581>()("@t3tools/ssh/SshEnvironmentManager") {
1582 static readonly layer = (options: SshEnvironmentManagerOptions = {}) =>
1583 Layer.effect(SshEnvironmentManager, makeSshEnvironmentManager(options));
1584}
packages/ssh/tsconfig.json created+17
...@@ -0,0 +1,17 @@
1{
2 "extends": "../../tsconfig.base.json",
3 "compilerOptions": {
4 "plugins": [
5 {
6 "name": "@effect/language-service",
7 "diagnosticSeverity": {
8 "importFromBarrel": "error",
9 "anyUnknownInErrorContext": "warning",
10 "instanceOfSchema": "warning",
11 "deterministicKeys": "warning"
12 }
13 }
14 ]
15 },
16 "include": ["src"]
17}
packages/tailscale/package.json created+28
...@@ -0,0 +1,28 @@
1{
2 "name": "@t3tools/tailscale",
3 "version": "0.0.0-alpha.1",
4 "private": true,
5 "type": "module",
6 "exports": {
7 ".": {
8 "types": "./src/index.ts",
9 "import": "./src/index.ts"
10 }
11 },
12 "scripts": {
13 "prepare": "effect-language-service patch",
14 "typecheck": "tsc --noEmit",
15 "test": "vitest run"
16 },
17 "dependencies": {
18 "@effect/platform-node": "catalog:",
19 "effect": "catalog:"
20 },
21 "devDependencies": {
22 "@effect/language-service": "catalog:",
23 "@effect/vitest": "catalog:",
24 "@types/node": "catalog:",
25 "typescript": "catalog:",
26 "vitest": "catalog:"
27 }
28}
packages/tailscale/src/index.ts created+1
...@@ -0,0 +1 @@
1export * from "./tailscale.ts";
packages/tailscale/src/tailscale.test.ts created+141
...@@ -0,0 +1,141 @@
1import { assert, describe, it } from "@effect/vitest";
2import { Effect, Layer, Sink, Stream } from "effect";
3import { ChildProcessSpawner } from "effect/unstable/process";
4
5import {
6 buildTailscaleHttpsBaseUrl,
7 disableTailscaleServe,
8 ensureTailscaleServe,
9 isTailscaleIpv4Address,
10 parseTailscaleMagicDnsName,
11 parseTailscaleStatus,
12 readTailscaleStatus,
13} from "./tailscale.ts";
14
15const encoder = new TextEncoder();
16const tailscaleStatusJson = `{"Self":{"DNSName":"desktop.tail.ts.net.","TailscaleIPs":["100.100.100.100","fd7a:115c:a1e0::1","192.168.1.20"]}}`;
17const tailscaleStatusWithSingleIpJson = `{"Self":{"DNSName":"desktop.tail.ts.net.","TailscaleIPs":["100.90.1.2"]}}`;
18
19function mockHandle(result: { stdout?: string; stderr?: string; code?: number }) {
20 return ChildProcessSpawner.makeHandle({
21 pid: ChildProcessSpawner.ProcessId(1),
22 exitCode: Effect.succeed(ChildProcessSpawner.ExitCode(result.code ?? 0)),
23 isRunning: Effect.succeed(false),
24 kill: () => Effect.void,
25 unref: Effect.succeed(Effect.void),
26 stdin: Sink.drain,
27 stdout: Stream.make(encoder.encode(result.stdout ?? "")),
28 stderr: Stream.make(encoder.encode(result.stderr ?? "")),
29 all: Stream.empty,
30 getInputFd: () => Sink.drain,
31 getOutputFd: () => Stream.empty,
32 });
33}
34
35function mockSpawnerLayer(
36 handler: (
37 command: string,
38 args: ReadonlyArray<string>,
39 ) => { stdout?: string; stderr?: string; code?: number },
40) {
41 return Layer.succeed(
42 ChildProcessSpawner.ChildProcessSpawner,
43 ChildProcessSpawner.make((command) => {
44 const childProcess = command as unknown as {
45 readonly command: string;
46 readonly args: ReadonlyArray<string>;
47 };
48 return Effect.succeed(mockHandle(handler(childProcess.command, childProcess.args)));
49 }),
50 );
51}
52
53describe("tailscale", () => {
54 it.effect("detects Tailnet IPv4 addresses", () =>
55 Effect.sync(() => {
56 assert.equal(isTailscaleIpv4Address("100.64.0.1"), true);
57 assert.equal(isTailscaleIpv4Address("100.127.255.254"), true);
58 assert.equal(isTailscaleIpv4Address("100.128.0.1"), false);
59 assert.equal(isTailscaleIpv4Address("192.168.1.44"), false);
60 }),
61 );
62
63 it.effect("parses MagicDNS names from tailscale status", () =>
64 Effect.gen(function* () {
65 const dnsName = yield* parseTailscaleMagicDnsName(tailscaleStatusJson);
66 assert.equal(dnsName, "desktop.tail.ts.net");
67 assert.equal(yield* parseTailscaleMagicDnsName("{}"), null);
68 }),
69 );
70
71 it.effect("parses status facts", () =>
72 Effect.gen(function* () {
73 const status = yield* parseTailscaleStatus(tailscaleStatusJson);
74 assert.deepEqual(status, {
75 magicDnsName: "desktop.tail.ts.net",
76 tailnetIpv4Addresses: ["100.100.100.100"],
77 });
78 }),
79 );
80
81 it.effect("builds clean HTTPS base URLs", () =>
82 Effect.sync(() => {
83 assert.equal(
84 buildTailscaleHttpsBaseUrl({ magicDnsName: "desktop.tail.ts.net" }),
85 "https://desktop.tail.ts.net/",
86 );
87 assert.equal(
88 buildTailscaleHttpsBaseUrl({ magicDnsName: "desktop.tail.ts.net", servePort: 8443 }),
89 "https://desktop.tail.ts.net:8443/",
90 );
91 }),
92 );
93
94 it.effect("reads tailscale status through the process spawner service", () => {
95 const layer = mockSpawnerLayer((command, args) => {
96 assert.equal(command, "tailscale");
97 assert.deepEqual(args, ["status", "--json"]);
98 return {
99 stdout: tailscaleStatusWithSingleIpJson,
100 };
101 });
102
103 return Effect.gen(function* () {
104 const status = yield* readTailscaleStatus.pipe(Effect.provide(layer));
105 assert.deepEqual(status, {
106 magicDnsName: "desktop.tail.ts.net",
107 tailnetIpv4Addresses: ["100.90.1.2"],
108 });
109 });
110 });
111
112 it.effect("configures tailscale serve through the process spawner service", () => {
113 const layer = mockSpawnerLayer((command, args) => {
114 assert.equal(command, "tailscale");
115 assert.deepEqual(args, ["serve", "--bg", "--https=8443", "http://127.0.0.1:13773"]);
116 return {};
117 });
118
119 return ensureTailscaleServe({ localPort: 13773, servePort: 8443 }).pipe(Effect.provide(layer));
120 });
121
122 it.effect("disables tailscale serve through the process spawner service", () => {
123 const commands: {
124 readonly command: string;
125 readonly args: ReadonlyArray<string>;
126 }[] = [];
127 const layer = mockSpawnerLayer((command, args) => {
128 commands.push({ command, args });
129 assert.equal(command, "tailscale");
130 assert.deepEqual(args, ["serve", "--https=8443", "off"]);
131 return {};
132 });
133
134 return Effect.gen(function* () {
135 yield* disableTailscaleServe({ servePort: 8443 }).pipe(Effect.provide(layer));
136 assert.deepEqual(commands, [
137 { command: "tailscale", args: ["serve", "--https=8443", "off"] },
138 ]);
139 });
140 });
141});
packages/tailscale/src/tailscale.ts created+320
...@@ -0,0 +1,320 @@
1import { Data, Effect, Option, Schema, Stream } from "effect";
2import { HttpClient, HttpClientRequest } from "effect/unstable/http";
3import { ChildProcess, ChildProcessSpawner } from "effect/unstable/process";
4
5export const DEFAULT_TAILSCALE_SERVE_PORT = 443;
6export const TAILSCALE_STATUS_TIMEOUT_MS = 1_500;
7export const TAILSCALE_SERVE_TIMEOUT_MS = 10_000;
8export const TAILSCALE_PROBE_TIMEOUT_MS = 2_500;
9
10export class TailscaleCommandError extends Data.TaggedError("TailscaleCommandError")<{
11 readonly command: readonly string[];
12 readonly message: string;
13 readonly exitCode: number | null;
14 readonly stderr: string;
15}> {}
16
17export class TailscaleStatusParseError extends Data.TaggedError("TailscaleStatusParseError")<{
18 readonly cause: unknown;
19}> {}
20
21export class TailscaleUnavailableError extends Data.TaggedError("TailscaleUnavailableError")<{
22 readonly reason: string;
23}> {}
24
25const TailscaleStatusSelf = Schema.Struct({
26 DNSName: Schema.optional(Schema.Unknown),
27 TailscaleIPs: Schema.optional(Schema.Unknown),
28});
29
30const TailscaleStatusJson = Schema.Struct({
31 Self: Schema.optional(TailscaleStatusSelf),
32});
33
34export type TailscaleStatusSelf = typeof TailscaleStatusSelf.Type;
35export type TailscaleStatusJson = typeof TailscaleStatusJson.Type;
36
37export interface TailscaleStatus {
38 readonly magicDnsName: string | null;
39 readonly tailnetIpv4Addresses: readonly string[];
40}
41
42const collectStdout = <E>(stream: Stream.Stream<Uint8Array, E>): Effect.Effect<string, E> =>
43 stream.pipe(
44 Stream.decodeText(),
45 Stream.runFold(
46 () => "",
47 (acc, chunk) => acc + chunk,
48 ),
49 );
50
51const collectStderr = collectStdout;
52
53const tailscaleCommandError = (
54 args: readonly string[],
55 message: string,
56 exitCode: number | null,
57 stderr = "",
58): TailscaleCommandError =>
59 new TailscaleCommandError({
60 command: ["tailscale", ...args],
61 message,
62 exitCode,
63 stderr,
64 });
65
66const decodeTailscaleStatusJson = Schema.decodeEffect(Schema.fromJsonString(TailscaleStatusJson));
67
68function normalizeMagicDnsName(status: TailscaleStatusJson): string | null {
69 const dnsName = status.Self?.DNSName;
70 if (typeof dnsName !== "string") {
71 return null;
72 }
73
74 const normalized = dnsName.trim().replace(/\.$/u, "");
75 return normalized.length > 0 ? normalized : null;
76}
77
78export const parseTailscaleMagicDnsName = (
79 rawStatusJson: string,
80): Effect.Effect<string | null, TailscaleStatusParseError> =>
81 decodeTailscaleStatusJson(rawStatusJson).pipe(
82 Effect.mapError((cause) => new TailscaleStatusParseError({ cause })),
83 Effect.map(normalizeMagicDnsName),
84 );
85
86export function isTailscaleIpv4Address(address: string): boolean {
87 const parts = address.split(".");
88 if (parts.length !== 4) {
89 return false;
90 }
91 const [first, second, third, fourth] = parts.map((part) => Number.parseInt(part, 10));
92 if (
93 first === undefined ||
94 second === undefined ||
95 third === undefined ||
96 fourth === undefined ||
97 [first, second, third, fourth].some((part) => !Number.isInteger(part) || part < 0 || part > 255)
98 ) {
99 return false;
100 }
101 return first === 100 && second >= 64 && second <= 127;
102}
103
104export const parseTailscaleStatus = (
105 rawStatusJson: string,
106): Effect.Effect<TailscaleStatus, TailscaleStatusParseError> =>
107 decodeTailscaleStatusJson(rawStatusJson).pipe(
108 Effect.mapError((cause) => new TailscaleStatusParseError({ cause })),
109 Effect.map((parsed) => {
110 const rawIps = parsed.Self?.TailscaleIPs;
111 const tailnetIpv4Addresses = Array.isArray(rawIps)
112 ? rawIps
113 .filter((address): address is string => typeof address === "string")
114 .filter(isTailscaleIpv4Address)
115 : [];
116
117 return {
118 magicDnsName: normalizeMagicDnsName(parsed),
119 tailnetIpv4Addresses,
120 };
121 }),
122 );
123
124export const readTailscaleStatus: Effect.Effect<
125 TailscaleStatus,
126 TailscaleCommandError | TailscaleStatusParseError,
127 ChildProcessSpawner.ChildProcessSpawner
128> = Effect.gen(function* () {
129 const args = ["status", "--json"];
130 const spawner = yield* ChildProcessSpawner.ChildProcessSpawner;
131 const child = yield* spawner
132 .spawn(
133 ChildProcess.make("tailscale", args, {
134 shell: process.platform === "win32",
135 }),
136 )
137 .pipe(
138 Effect.mapError((cause) =>
139 tailscaleCommandError(
140 args,
141 cause instanceof Error ? cause.message : "Failed to spawn tailscale status.",
142 null,
143 ),
144 ),
145 );
146 const [stdout, stderr, exitCode] = yield* Effect.all(
147 [
148 collectStdout(child.stdout),
149 collectStderr(child.stderr),
150 child.exitCode.pipe(Effect.map(Number)),
151 ],
152 { concurrency: "unbounded" },
153 ).pipe(
154 Effect.mapError((cause) =>
155 tailscaleCommandError(
156 args,
157 cause instanceof Error ? cause.message : "Failed to run tailscale status.",
158 null,
159 ),
160 ),
161 );
162 if (exitCode !== 0) {
163 return yield* tailscaleCommandError(
164 args,
165 `Tailscale status exited with code ${exitCode}.`,
166 exitCode,
167 stderr,
168 );
169 }
170 return yield* parseTailscaleStatus(stdout);
171}).pipe(
172 Effect.scoped,
173 Effect.timeoutOption(TAILSCALE_STATUS_TIMEOUT_MS),
174 Effect.flatMap((result) =>
175 Option.match(result, {
176 onNone: () =>
177 Effect.fail(
178 tailscaleCommandError(["status", "--json"], "Tailscale status timed out.", null),
179 ),
180 onSome: Effect.succeed,
181 }),
182 ),
183);
184
185export function buildTailscaleHttpsBaseUrl(input: {
186 readonly magicDnsName: string;
187 readonly servePort?: number;
188}): string {
189 const url = new URL(`https://${input.magicDnsName}`);
190 const servePort = input.servePort ?? DEFAULT_TAILSCALE_SERVE_PORT;
191 if (servePort !== DEFAULT_TAILSCALE_SERVE_PORT) {
192 url.port = String(servePort);
193 }
194 url.pathname = "/";
195 return url.toString();
196}
197
198const runTailscaleCommand = (
199 args: readonly string[],
200 input: {
201 readonly spawnMessage: string;
202 readonly runMessage: string;
203 readonly exitMessage: (exitCode: number) => string;
204 readonly timeoutMessage: string;
205 readonly timeoutMs: number;
206 },
207): Effect.Effect<void, TailscaleCommandError, ChildProcessSpawner.ChildProcessSpawner> =>
208 Effect.gen(function* () {
209 const spawner = yield* ChildProcessSpawner.ChildProcessSpawner;
210 const child = yield* spawner
211 .spawn(
212 ChildProcess.make("tailscale", args, {
213 shell: process.platform === "win32",
214 }),
215 )
216 .pipe(
217 Effect.mapError((cause) =>
218 tailscaleCommandError(
219 args,
220 cause instanceof Error ? cause.message : input.spawnMessage,
221 null,
222 ),
223 ),
224 );
225 const [stderr, exitCode] = yield* Effect.all(
226 [collectStderr(child.stderr), child.exitCode.pipe(Effect.map(Number))],
227 { concurrency: "unbounded" },
228 ).pipe(
229 Effect.mapError((cause) =>
230 tailscaleCommandError(
231 args,
232 cause instanceof Error ? cause.message : input.runMessage,
233 null,
234 ),
235 ),
236 );
237 if (exitCode !== 0) {
238 return yield* tailscaleCommandError(args, input.exitMessage(exitCode), exitCode, stderr);
239 }
240 }).pipe(
241 Effect.scoped,
242 Effect.timeoutOption(input.timeoutMs),
243 Effect.flatMap((result) =>
244 Option.match(result, {
245 onNone: () => Effect.fail(tailscaleCommandError(args, input.timeoutMessage, null)),
246 onSome: Effect.succeed,
247 }),
248 ),
249 );
250
251export const ensureTailscaleServe = (input: {
252 readonly localPort: number;
253 readonly servePort?: number;
254 readonly localHost?: string;
255}): Effect.Effect<void, TailscaleCommandError, ChildProcessSpawner.ChildProcessSpawner> => {
256 const servePort = input.servePort ?? DEFAULT_TAILSCALE_SERVE_PORT;
257 const localHost = input.localHost ?? "127.0.0.1";
258 const args = ["serve", "--bg", `--https=${servePort}`, `http://${localHost}:${input.localPort}`];
259 return runTailscaleCommand(args, {
260 spawnMessage: "Failed to spawn tailscale serve.",
261 runMessage: "Failed to run tailscale serve.",
262 exitMessage: (exitCode) => `Tailscale serve exited with code ${exitCode}.`,
263 timeoutMessage: "Tailscale serve timed out.",
264 timeoutMs: TAILSCALE_SERVE_TIMEOUT_MS,
265 });
266};
267
268export const disableTailscaleServe = (
269 input: {
270 readonly servePort?: number;
271 } = {},
272): Effect.Effect<void, TailscaleCommandError, ChildProcessSpawner.ChildProcessSpawner> =>
273 Effect.gen(function* () {
274 const servePort = input.servePort ?? DEFAULT_TAILSCALE_SERVE_PORT;
275 return yield* runTailscaleCommand(["serve", `--https=${servePort}`, "off"], {
276 spawnMessage: "Failed to spawn tailscale serve off.",
277 runMessage: "Failed to run tailscale serve off.",
278 exitMessage: (exitCode) => `Tailscale serve off exited with code ${exitCode}.`,
279 timeoutMessage: "Tailscale serve off timed out.",
280 timeoutMs: TAILSCALE_SERVE_TIMEOUT_MS,
281 });
282 });
283
284export const probeTailscaleHttpsEndpoint = (input: {
285 readonly baseUrl: string;
286 readonly timeoutMs?: number;
287}): Effect.Effect<boolean, never, HttpClient.HttpClient> =>
288 Effect.gen(function* () {
289 const client = yield* HttpClient.HttpClient;
290 const response = yield* Effect.gen(function* () {
291 const url = new URL("/.well-known/t3/environment", input.baseUrl);
292 const request = HttpClientRequest.get(url.toString());
293 return yield* client.execute(request);
294 }).pipe(Effect.timeoutOption(input.timeoutMs ?? TAILSCALE_PROBE_TIMEOUT_MS));
295
296 return Option.match(response, {
297 onNone: () => false,
298 onSome: (httpResponse) => httpResponse.status >= 200 && httpResponse.status < 300,
299 });
300 }).pipe(Effect.catch(() => Effect.succeed(false)));
301
302export const resolveTailscaleHttpsBaseUrl = (
303 input: {
304 readonly servePort?: number;
305 } = {},
306): Effect.Effect<
307 string | null,
308 TailscaleCommandError | TailscaleStatusParseError,
309 ChildProcessSpawner.ChildProcessSpawner
310> =>
311 readTailscaleStatus.pipe(
312 Effect.map((status) =>
313 status.magicDnsName
314 ? buildTailscaleHttpsBaseUrl({
315 magicDnsName: status.magicDnsName,
316 ...(input.servePort === undefined ? {} : { servePort: input.servePort }),
317 })
318 : null,
319 ),
320 );
packages/tailscale/tsconfig.json created+17
...@@ -0,0 +1,17 @@
1{
2 "extends": "../../tsconfig.base.json",
3 "compilerOptions": {
4 "plugins": [
5 {
6 "name": "@effect/language-service",
7 "diagnosticSeverity": {
8 "importFromBarrel": "error",
9 "anyUnknownInErrorContext": "warning",
10 "instanceOfSchema": "warning",
11 "deterministicKeys": "warning"
12 }
13 }
14 ]
15 },
16 "include": ["src"]
17}
patches/effect@4.0.0-beta.59.patch created+277
...@@ -0,0 +1,277 @@
1diff --git a/dist/unstable/rpc/RpcClient.d.ts b/dist/unstable/rpc/RpcClient.d.ts
2index 7c5970042df67a25b4145df017960b3e6febed9c..7af3374632d53ad59f24e80835aa6741853b1f2b 100644
3--- a/dist/unstable/rpc/RpcClient.d.ts
4+++ b/dist/unstable/rpc/RpcClient.d.ts
5@@ -2,6 +2,7 @@ import * as Cause from "../../Cause.ts";
6 import * as Context from "../../Context.ts";
7 import type * as Duration from "../../Duration.ts";
8 import * as Effect from "../../Effect.ts";
9+import * as Exit from "../../Exit.ts";
10 import * as Layer from "../../Layer.ts";
11 import * as Queue from "../../Queue.ts";
12 import * as Schedule from "../../Schedule.ts";
13@@ -192,9 +193,40 @@ export declare const layerProtocolWorker: (options: {
14 readonly targetUtilization?: number | undefined;
15 readonly timeToLive: Duration.Input;
16 }) => Layer.Layer<Protocol, WorkerError, Worker.WorkerPlatform | Worker.Spawner>;
17+declare const RequestHooks_base: Context.ServiceClass<RequestHooks, "effect/rpc/RpcClient/RequestHooks", {
18+ readonly onRequestStart?: ((info: {
19+ readonly id: RequestId;
20+ readonly tag: string;
21+ readonly stream: boolean;
22+ }) => Effect.Effect<void>) | undefined;
23+ readonly onRequestChunk?: ((info: {
24+ readonly id: RequestId;
25+ readonly tag: string;
26+ readonly chunkCount: number;
27+ }) => Effect.Effect<void>) | undefined;
28+ readonly onRequestExit?: ((info: {
29+ readonly id: RequestId;
30+ readonly tag: string;
31+ readonly stream: boolean;
32+ readonly exit: Exit.Exit<unknown, unknown>;
33+ }) => Effect.Effect<void>) | undefined;
34+ readonly onRequestInterrupt?: ((info: {
35+ readonly id: RequestId;
36+ readonly tag?: string | undefined;
37+ }) => Effect.Effect<void>) | undefined;
38+}>;
39+/**
40+ * @since 4.0.0
41+ * @category RequestHooks
42+ */
43+export declare class RequestHooks extends RequestHooks_base {
44+}
45 declare const ConnectionHooks_base: Context.ServiceClass<ConnectionHooks, "effect/rpc/RpcClient/ConnectionHooks", {
46 readonly onConnect: Effect.Effect<void>;
47 readonly onDisconnect: Effect.Effect<void>;
48+ readonly onPing?: Effect.Effect<void> | undefined;
49+ readonly onPong?: Effect.Effect<void> | undefined;
50+ readonly onPingTimeout?: Effect.Effect<void> | undefined;
51 }>;
52 /**
53 * @since 4.0.0
54diff --git a/dist/unstable/rpc/RpcClient.js b/dist/unstable/rpc/RpcClient.js
55index 951dfc2d667a9136cfa714dcd3195e6575917fde..599c0345873bedd72bc93afe80eab0c676319421 100644
56--- a/dist/unstable/rpc/RpcClient.js
57+++ b/dist/unstable/rpc/RpcClient.js
58@@ -42,6 +42,7 @@ export const makeNoSerialization = /*#__PURE__*/Effect.fnUntraced(function* (gro
59 const services = yield* Effect.context();
60 const scope = Context.get(services, Scope.Scope);
61 const entries = new Map();
62+ const hooks = yield* Effect.serviceOption(RequestHooks);
63 let isShutdown = false;
64 yield* Scope.addFinalizer(scope, Effect.withFiber(parent => {
65 isShutdown = true;
66@@ -79,6 +80,11 @@ export const makeNoSerialization = /*#__PURE__*/Effect.fnUntraced(function* (gro
67 return Effect.interrupt;
68 }
69 const id = generateRequestId();
70+ const onStart = Option.isSome(hooks) && hooks.value.onRequestStart ? hooks.value.onRequestStart({
71+ id,
72+ tag: rpc._tag,
73+ stream: false
74+ }) : Effect.void;
75 const send = middleware(message => options.onFromClient({
76 message,
77 context,
78@@ -96,7 +102,7 @@ export const makeNoSerialization = /*#__PURE__*/Effect.fnUntraced(function* (gro
79 headers: Headers.merge(parentFiber.getRef(CurrentHeaders), headers)
80 });
81 if (discard) {
82- return send;
83+ return onStart.pipe(Effect.andThen(send));
84 }
85 let fiber;
86 return Effect.onInterrupt(Effect.callback(resume => {
87@@ -114,7 +120,7 @@ export const makeNoSerialization = /*#__PURE__*/Effect.fnUntraced(function* (gro
88 }
89 };
90 entries.set(id, entry);
91- fiber = send.pipe(span ? Effect.withParentSpan(span, {
92+ fiber = onStart.pipe(Effect.andThen(send), span ? Effect.withParentSpan(span, {
93 captureStackTrace: false
94 }) : identity, Effect.runForkWith(parentFiber.context));
95 fiber.addObserver(exit => {
96@@ -123,8 +129,9 @@ export const makeNoSerialization = /*#__PURE__*/Effect.fnUntraced(function* (gro
97 }
98 });
99 }), interruptors => {
100+ const entry = entries.get(id);
101 entries.delete(id);
102- return Effect.andThen(Fiber.interrupt(fiber), sendInterrupt(id, Array.from(interruptors), context));
103+ return Effect.andThen(Fiber.interrupt(fiber), sendInterrupt(id, Array.from(interruptors), context, entry?.rpc._tag));
104 });
105 });
106 const onStreamRequest = Effect.fnUntraced(function* (rpc, middleware, payload, headers, streamBufferSize, context) {
107@@ -136,11 +143,17 @@ export const makeNoSerialization = /*#__PURE__*/Effect.fnUntraced(function* (gro
108 });
109 const fiber = Fiber.getCurrent();
110 const id = generateRequestId();
111+ const onStart = Option.isSome(hooks) && hooks.value.onRequestStart ? hooks.value.onRequestStart({
112+ id,
113+ tag: rpc._tag,
114+ stream: true
115+ }) : Effect.void;
116 const scope = Context.getUnsafe(fiber.context, Scope.Scope);
117 yield* Scope.addFinalizerExit(scope, exit => {
118- if (!entries.has(id)) return Effect.void;
119+ const entry = entries.get(id);
120+ if (!entry) return Effect.void;
121 entries.delete(id);
122- return sendInterrupt(id, Exit.isFailure(exit) ? Array.from(Cause.interruptors(exit.cause)) : [], context);
123+ return sendInterrupt(id, Exit.isFailure(exit) ? Array.from(Cause.interruptors(exit.cause)) : [], context, entry.rpc._tag);
124 });
125 const queue = yield* Queue.bounded(streamBufferSize);
126 entries.set(id, {
127@@ -148,9 +161,10 @@ export const makeNoSerialization = /*#__PURE__*/Effect.fnUntraced(function* (gro
128 rpc,
129 queue,
130 scope,
131- context
132+ context,
133+ chunkCount: 0
134 });
135- yield* middleware(message => options.onFromClient({
136+ yield* onStart.pipe(Effect.andThen(middleware(message => options.onFromClient({
137 message,
138 context,
139 discard: false
140@@ -165,7 +179,7 @@ export const makeNoSerialization = /*#__PURE__*/Effect.fnUntraced(function* (gro
141 sampled: span.sampled
142 } : {}),
143 headers: Headers.merge(fiber.getRef(CurrentHeaders), headers)
144- }).pipe(span ? Effect.withParentSpan(span, {
145+ }))).pipe(span ? Effect.withParentSpan(span, {
146 captureStackTrace: false
147 }) : identity, Effect.catchCause(error => Queue.failCause(queue, error)), Effect.interruptible, Effect.forkIn(scope, {
148 startImmediately: true
149@@ -195,7 +209,10 @@ export const makeNoSerialization = /*#__PURE__*/Effect.fnUntraced(function* (gro
150 });
151 };
152 };
153- const sendInterrupt = (requestId, interruptors, context) => Effect.callback(resume => {
154+ const sendInterrupt = (requestId, interruptors, context, tag) => (Option.isSome(hooks) && hooks.value.onRequestInterrupt ? hooks.value.onRequestInterrupt({
155+ id: requestId,
156+ tag
157+ }) : Effect.void).pipe(Effect.andThen(Effect.callback(resume => {
158 const parentFiber = Fiber.getCurrent();
159 const fiber = options.onFromClient({
160 message: {
161@@ -209,7 +226,7 @@ export const makeNoSerialization = /*#__PURE__*/Effect.fnUntraced(function* (gro
162 fiber.addObserver(() => {
163 resume(Effect.void);
164 });
165- });
166+ })));
167 const write = message => {
168 switch (message._tag) {
169 case "Chunk":
170@@ -217,7 +234,13 @@ export const makeNoSerialization = /*#__PURE__*/Effect.fnUntraced(function* (gro
171 const requestId = message.requestId;
172 const entry = entries.get(requestId);
173 if (!entry || entry._tag !== "Queue") return Effect.void;
174- return Queue.offerAll(entry.queue, message.values).pipe(supportsAck ? Effect.flatMap(() => options.onFromClient({
175+ const chunkCount = entry.chunkCount += message.values.length;
176+ const onChunk = Option.isSome(hooks) && hooks.value.onRequestChunk ? hooks.value.onRequestChunk({
177+ id: requestId,
178+ tag: entry.rpc._tag,
179+ chunkCount
180+ }) : Effect.void;
181+ return Queue.offerAll(entry.queue, message.values).pipe(Effect.andThen(onChunk), supportsAck ? Effect.flatMap(() => options.onFromClient({
182 message: {
183 _tag: "Ack",
184 requestId: message.requestId
185@@ -232,11 +255,18 @@ export const makeNoSerialization = /*#__PURE__*/Effect.fnUntraced(function* (gro
186 const entry = entries.get(requestId);
187 if (!entry) return Effect.void;
188 entries.delete(requestId);
189+ const onExit = Option.isSome(hooks) && hooks.value.onRequestExit ? hooks.value.onRequestExit({
190+ id: requestId,
191+ tag: entry.rpc._tag,
192+ stream: entry._tag === "Queue",
193+ exit: message.exit
194+ }) : Effect.void;
195 if (entry._tag === "Effect") {
196- entry.resume(message.exit);
197- return Effect.void;
198+ return onExit.pipe(Effect.andThen(Effect.sync(() => {
199+ entry.resume(message.exit);
200+ })));
201 }
202- return message.exit._tag === "Success" ? Queue.end(entry.queue) : Queue.failCause(entry.queue, message.exit.cause);
203+ return onExit.pipe(Effect.andThen(message.exit._tag === "Success" ? Queue.end(entry.queue) : Queue.failCause(entry.queue, message.exit.cause)));
204 }
205 case "Defect":
206 {
207@@ -530,7 +560,7 @@ export const makeProtocolSocket = options => Protocol.make(Effect.fnUntraced(fun
208 const requestClientMap = new Map();
209 const write = yield* socket.writer;
210 let parser = serialization.makeUnsafe();
211- const pinger = yield* makePinger(write(parser.encode(constPing)));
212+ const pinger = yield* makePinger(write(parser.encode(constPing)), Option.isSome(hooks) ? hooks.value : undefined);
213 let currentError;
214 const onOpen = Effect.suspend(() => {
215 currentError = undefined;
216@@ -550,8 +580,7 @@ export const makeProtocolSocket = options => Protocol.make(Effect.fnUntraced(fun
217 body: () => {
218 const response = responses[i++];
219 if (response._tag === "Pong") {
220- pinger.onPong();
221- return Effect.void;
222+ return pinger.onPong;
223 }
224 if ("requestId" in response) {
225 const clientId = requestClientMap.get(response.requestId);
226@@ -579,12 +608,12 @@ export const makeProtocolSocket = options => Protocol.make(Effect.fnUntraced(fun
227 }
228 }, {
229 onOpen
230- }).pipe(Effect.raceFirst(Effect.flatMap(pinger.timeout, () => Effect.fail(new Socket.SocketError({
231+ }).pipe(Effect.raceFirst(Effect.flatMap(pinger.timeout, () => (Option.isSome(hooks) && hooks.value.onPingTimeout ? hooks.value.onPingTimeout : Effect.void).pipe(Effect.andThen(Effect.fail(new Socket.SocketError({
232 reason: new Socket.SocketOpenError({
233 kind: "Timeout",
234 cause: new Error("ping timeout")
235 })
236- })))));
237+ })))))));
238 }).pipe(Effect.flatMap(() => Effect.fail(new Socket.SocketError({
239 reason: new Socket.SocketCloseError({
240 code: 1000
241@@ -626,20 +655,20 @@ export const makeProtocolSocket = options => Protocol.make(Effect.fnUntraced(fun
242 };
243 }));
244 const defaultRetryPolicy = /*#__PURE__*/Schedule.exponential(500, 1.5).pipe(/*#__PURE__*/Schedule.either(/*#__PURE__*/Schedule.spaced(5000)));
245-const makePinger = /*#__PURE__*/Effect.fnUntraced(function* (writePing) {
246+const makePinger = /*#__PURE__*/Effect.fnUntraced(function* (writePing, hooks) {
247 let recievedPong = true;
248 const latch = Latch.makeUnsafe();
249 const reset = () => {
250 recievedPong = true;
251 latch.closeUnsafe();
252 };
253- const onPong = () => {
254+ const onPong = Effect.sync(() => {
255 recievedPong = true;
256- };
257+ }).pipe(Effect.andThen(hooks?.onPong ?? Effect.void));
258 yield* Effect.suspend(() => {
259 if (!recievedPong) return latch.open;
260 recievedPong = false;
261- return writePing;
262+ return (hooks?.onPing ?? Effect.void).pipe(Effect.andThen(writePing));
263 }).pipe(Effect.delay("5 seconds"), Effect.ignore, Effect.forever, Effect.interruptible, Effect.forkScoped);
264 return {
265 timeout: latch.await,
266@@ -773,6 +802,11 @@ export const makeProtocolWorker = options => Protocol.make(Effect.fnUntraced(fun
267 * @category protocol
268 */
269 export const layerProtocolWorker = /*#__PURE__*/flow(makeProtocolWorker, /*#__PURE__*/Layer.effect(Protocol));
270+/**
271+ * @since 4.0.0
272+ * @category RequestHooks
273+ */
274+export class RequestHooks extends /*#__PURE__*/Context.Service()("effect/rpc/RpcClient/RequestHooks") {}
275 /**
276 * @since 4.0.0
277 * @category ConnectionHooks
scripts/apply-web-brand-assets.ts created+56
...@@ -0,0 +1,56 @@
1#!/usr/bin/env node
2
3import * as NodeRuntime from "@effect/platform-node/NodeRuntime";
4import * as NodeServices from "@effect/platform-node/NodeServices";
5import { Effect, FileSystem, Option, Path } from "effect";
6import { Argument, Command } from "effect/unstable/cli";
7import { resolveWebIconOverrides, type WebAssetBrand } from "./lib/brand-assets.ts";
8
9const WEB_ASSET_BRANDS = [
10 "development",
11 "production",
12] as const satisfies ReadonlyArray<WebAssetBrand>;
13
14export const applyWebBrandAssets = Effect.fn("applyWebBrandAssets")(function* (
15 brand: WebAssetBrand,
16 targetDirectory: string,
17) {
18 const fs = yield* FileSystem.FileSystem;
19 const path = yield* Path.Path;
20 const repoRoot = yield* path.fromFileUrl(new URL("..", import.meta.url));
21
22 yield* Effect.forEach(
23 resolveWebIconOverrides(brand, targetDirectory),
24 (override) =>
25 fs.copyFile(
26 path.join(repoRoot, override.sourceRelativePath),
27 path.join(repoRoot, override.targetRelativePath),
28 ),
29 { concurrency: "unbounded" },
30 );
31});
32
33export const applyWebBrandAssetsCommand = Command.make(
34 "apply-web-brand-assets",
35 {
36 brand: Argument.choice("brand", WEB_ASSET_BRANDS).pipe(
37 Argument.withDescription("Asset brand to copy into the hosted web output directory."),
38 ),
39 targetDirectory: Argument.string("target-directory").pipe(
40 Argument.withDescription("Output directory that contains the hosted web build assets."),
41 Argument.optional,
42 ),
43 },
44 ({ brand, targetDirectory }) =>
45 applyWebBrandAssets(
46 brand,
47 Option.getOrElse(targetDirectory, () => "apps/web/dist"),
48 ),
49).pipe(Command.withDescription("Copy web brand assets into a built hosted web app."));
50
51if (import.meta.main) {
52 Command.run(applyWebBrandAssetsCommand, { version: "0.0.0" }).pipe(
53 Effect.provide(NodeServices.layer),
54 NodeRuntime.runMain,
55 );
56}
scripts/lib/brand-assets.test.ts created+45
...@@ -0,0 +1,45 @@
1import { describe, expect, it } from "vitest";
2
3import {
4 BRAND_ASSET_PATHS,
5 DEVELOPMENT_ICON_OVERRIDES,
6 PUBLISH_ICON_OVERRIDES,
7 resolveWebIconOverrides,
8} from "./brand-assets.ts";
9
10describe("brand-assets", () => {
11 it("maps server publish web assets to production icons", () => {
12 expect(PUBLISH_ICON_OVERRIDES).toEqual([
13 {
14 sourceRelativePath: BRAND_ASSET_PATHS.productionWebFaviconIco,
15 targetRelativePath: "dist/client/favicon.ico",
16 },
17 {
18 sourceRelativePath: BRAND_ASSET_PATHS.productionWebFavicon16Png,
19 targetRelativePath: "dist/client/favicon-16x16.png",
20 },
21 {
22 sourceRelativePath: BRAND_ASSET_PATHS.productionWebFavicon32Png,
23 targetRelativePath: "dist/client/favicon-32x32.png",
24 },
25 {
26 sourceRelativePath: BRAND_ASSET_PATHS.productionWebAppleTouchIconPng,
27 targetRelativePath: "dist/client/apple-touch-icon.png",
28 },
29 ]);
30 });
31
32 it("maps server build web assets to development icons", () => {
33 expect(DEVELOPMENT_ICON_OVERRIDES[0]).toEqual({
34 sourceRelativePath: BRAND_ASSET_PATHS.developmentWebFaviconIco,
35 targetRelativePath: "dist/client/favicon.ico",
36 });
37 });
38
39 it("can target hosted web dist directly", () => {
40 expect(resolveWebIconOverrides("production", "apps/web/dist")).toContainEqual({
41 sourceRelativePath: BRAND_ASSET_PATHS.productionWebAppleTouchIconPng,
42 targetRelativePath: "apps/web/dist/apple-touch-icon.png",
43 });
44 });
45});
scripts/lib/brand-assets.ts+49-34
...@@ -19,45 +19,60 @@ export const BRAND_ASSET_PATHS = {...@@ -19,45 +19,60 @@ export const BRAND_ASSET_PATHS = {
19 developmentWebAppleTouchIconPng: "assets/dev/blueprint-web-apple-touch-180.png",19 developmentWebAppleTouchIconPng: "assets/dev/blueprint-web-apple-touch-180.png",
20} as const;20} as const;
2121
22export type WebAssetBrand = "development" | "production";
23
22export interface IconOverride {24export interface IconOverride {
23 readonly sourceRelativePath: string;25 readonly sourceRelativePath: string;
24 readonly targetRelativePath: string;26 readonly targetRelativePath: string;
25}27}
2628
27export const DEVELOPMENT_ICON_OVERRIDES: ReadonlyArray<IconOverride> = [29const WEB_ICON_TARGET_FILENAMES = {
28 {30 faviconIco: "favicon.ico",
29 sourceRelativePath: BRAND_ASSET_PATHS.developmentWebFaviconIco,31 favicon16Png: "favicon-16x16.png",
30 targetRelativePath: "dist/client/favicon.ico",32 favicon32Png: "favicon-32x32.png",
31 },33 appleTouchIconPng: "apple-touch-icon.png",
32 {34} as const;
33 sourceRelativePath: BRAND_ASSET_PATHS.developmentWebFavicon16Png,
34 targetRelativePath: "dist/client/favicon-16x16.png",
35 },
36 {
37 sourceRelativePath: BRAND_ASSET_PATHS.developmentWebFavicon32Png,
38 targetRelativePath: "dist/client/favicon-32x32.png",
39 },
40 {
41 sourceRelativePath: BRAND_ASSET_PATHS.developmentWebAppleTouchIconPng,
42 targetRelativePath: "dist/client/apple-touch-icon.png",
43 },
44];
4535
46export const PUBLISH_ICON_OVERRIDES: ReadonlyArray<IconOverride> = [36const WEB_ICON_SOURCE_PATHS_BY_BRAND = {
47 {37 development: {
48 sourceRelativePath: BRAND_ASSET_PATHS.productionWebFaviconIco,38 faviconIco: BRAND_ASSET_PATHS.developmentWebFaviconIco,
49 targetRelativePath: "dist/client/favicon.ico",39 favicon16Png: BRAND_ASSET_PATHS.developmentWebFavicon16Png,
40 favicon32Png: BRAND_ASSET_PATHS.developmentWebFavicon32Png,
41 appleTouchIconPng: BRAND_ASSET_PATHS.developmentWebAppleTouchIconPng,
50 },42 },
51 {43 production: {
52 sourceRelativePath: BRAND_ASSET_PATHS.productionWebFavicon16Png,44 faviconIco: BRAND_ASSET_PATHS.productionWebFaviconIco,
53 targetRelativePath: "dist/client/favicon-16x16.png",45 favicon16Png: BRAND_ASSET_PATHS.productionWebFavicon16Png,
46 favicon32Png: BRAND_ASSET_PATHS.productionWebFavicon32Png,
47 appleTouchIconPng: BRAND_ASSET_PATHS.productionWebAppleTouchIconPng,
54 },48 },
55 {49} as const satisfies Record<WebAssetBrand, Record<keyof typeof WEB_ICON_TARGET_FILENAMES, string>>;
56 sourceRelativePath: BRAND_ASSET_PATHS.productionWebFavicon32Png,50
57 targetRelativePath: "dist/client/favicon-32x32.png",51export function resolveWebIconOverrides(
58 },52 brand: WebAssetBrand,
59 {53 targetDirectory: string,
60 sourceRelativePath: BRAND_ASSET_PATHS.productionWebAppleTouchIconPng,54): ReadonlyArray<IconOverride> {
61 targetRelativePath: "dist/client/apple-touch-icon.png",55 const sourcePaths = WEB_ICON_SOURCE_PATHS_BY_BRAND[brand];
62 },56 return [
63];57 {
58 sourceRelativePath: sourcePaths.faviconIco,
59 targetRelativePath: `${targetDirectory}/${WEB_ICON_TARGET_FILENAMES.faviconIco}`,
60 },
61 {
62 sourceRelativePath: sourcePaths.favicon16Png,
63 targetRelativePath: `${targetDirectory}/${WEB_ICON_TARGET_FILENAMES.favicon16Png}`,
64 },
65 {
66 sourceRelativePath: sourcePaths.favicon32Png,
67 targetRelativePath: `${targetDirectory}/${WEB_ICON_TARGET_FILENAMES.favicon32Png}`,
68 },
69 {
70 sourceRelativePath: sourcePaths.appleTouchIconPng,
71 targetRelativePath: `${targetDirectory}/${WEB_ICON_TARGET_FILENAMES.appleTouchIconPng}`,
72 },
73 ];
74}
75
76export const DEVELOPMENT_ICON_OVERRIDES = resolveWebIconOverrides("development", "dist/client");
77
78export const PUBLISH_ICON_OVERRIDES = resolveWebIconOverrides("production", "dist/client");
scripts/release-smoke.ts+13
...@@ -24,6 +24,8 @@ const workspaceFiles = [...@@ -24,6 +24,8 @@ const workspaceFiles = [
24 "packages/client-runtime/package.json",24 "packages/client-runtime/package.json",
25 "packages/contracts/package.json",25 "packages/contracts/package.json",
26 "packages/shared/package.json",26 "packages/shared/package.json",
27 "packages/ssh/package.json",
28 "packages/tailscale/package.json",
27 "packages/effect-acp/package.json",29 "packages/effect-acp/package.json",
28 "packages/effect-codex-app-server/package.json",30 "packages/effect-codex-app-server/package.json",
29 "scripts/package.json",31 "scripts/package.json",
...@@ -36,6 +38,17 @@ function copyWorkspaceManifestFixture(targetRoot: string): void {...@@ -36,6 +38,17 @@ function copyWorkspaceManifestFixture(targetRoot: string): void {
36 mkdirSync(dirname(destinationPath), { recursive: true });38 mkdirSync(dirname(destinationPath), { recursive: true });
37 cpSync(sourcePath, destinationPath);39 cpSync(sourcePath, destinationPath);
38 }40 }
41
42 const packageJson = JSON.parse(readFileSync(resolve(repoRoot, "package.json"), "utf8")) as {
43 readonly patchedDependencies?: Record<string, string>;
44 };
45
46 for (const relativePath of Object.values(packageJson.patchedDependencies ?? {})) {
47 const sourcePath = resolve(repoRoot, relativePath);
48 const destinationPath = resolve(targetRoot, relativePath);
49 mkdirSync(dirname(destinationPath), { recursive: true });
50 cpSync(sourcePath, destinationPath);
51 }
39}52}
4053
41function writeMacManifestFixtures(targetRoot: string): { arm64Path: string; x64Path: string } {54function writeMacManifestFixtures(targetRoot: string): { arm64Path: string; x64Path: string } {
turbo.json+2
...@@ -11,6 +11,8 @@...@@ -11,6 +11,8 @@
11 "T3CODE_NO_BROWSER",11 "T3CODE_NO_BROWSER",
12 "T3CODE_HOME",12 "T3CODE_HOME",
13 "T3CODE_AUTH_TOKEN",13 "T3CODE_AUTH_TOKEN",
14 "T3CODE_TAILSCALE_SERVE",
15 "T3CODE_TAILSCALE_SERVE_PORT",
14 "T3CODE_DESKTOP_WS_URL",16 "T3CODE_DESKTOP_WS_URL",
15 "T3CODE_TRACE_MIN_LEVEL",17 "T3CODE_TRACE_MIN_LEVEL",
16 "T3CODE_TRACE_TIMING_ENABLED",18 "T3CODE_TRACE_TIMING_ENABLED",