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:
9393
9494A 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
9698### AccessEndpoint
9799
98100An `AccessEndpoint` is one concrete way to reach a known environment.
......@@ -108,6 +110,67 @@ A single environment may have many endpoints:
108110
109111The 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
111174### RepositoryIdentity
112175
113176`RepositoryIdentity` remains a best-effort logical repo grouping mechanism across environments.
......@@ -151,6 +214,8 @@ Benefits:
151214- no client-specific process management required
152215- 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
154219### 2. Tunneled WebSocket access
155220
156221Examples:
......@@ -170,6 +235,8 @@ This is especially useful when:
170235- mobile must reach a desktop-hosted environment
171236- 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
173240### 3. Desktop-managed SSH access
174241
175242SSH 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
185252
186253This 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
188257## Launch methods
189258
190259Launch methods answer a different question:
......@@ -227,6 +296,15 @@ The recommended T3 flow is:
2272964. Desktop establishes local port forwarding.
2282975. 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
230308### 3. Client-managed local publish
231309
232310This 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
267345
268346For 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
270350## Relationship to Zed
271351
272352Zed is a useful reference implementation for managed remote launch and reconnect behavior.
.gitignore+1
......@@ -22,3 +22,4 @@ apps/web/src/components/__screenshots__
2222__screenshots__/
2323.tanstack
2424squashfs-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
2222
23231. Open **Settings** → **Connections**.
24242. 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.
26264. 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
2860### Option 2: Headless Server (CLI)
2961
3062Use 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:
4779- scan the QR code on your phone
4880- in the desktop app, enter the full pairing URL
4981- 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
5184Use `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
5399> Note
54100> The GUIs do not currently support adding projects on remote environments.
55101> For now, use `t3 project ...` on the server machine instead.
56102> 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
58118## How Pairing Works
59119
60120The remote device does not need a long-lived secret up front.
......@@ -67,6 +127,20 @@ Instead:
67127
68128After 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
70144## Managing Access Later
71145
72146Use `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
84158- Treat pairing URLs and pairing tokens like passwords.
85159- Prefer binding `--host` to a trusted private address, such as a Tailnet IP, instead of exposing the server broadly.
86160- 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.
87162- Use `t3 auth` to revoke credentials or sessions you no longer trust.
apps/desktop/package.json+4
......@@ -15,13 +15,17 @@
1515 "smoke-test": "node scripts/smoke-test.mjs"
1616 },
1717 "dependencies": {
18 "@effect/platform-node": "catalog:",
1819 "effect": "catalog:",
1920 "electron": "40.9.3",
2021 "electron-updater": "^6.6.2"
2122 },
2223 "devDependencies": {
24 "@t3tools/client-runtime": "workspace:*",
2325 "@t3tools/contracts": "workspace:*",
2426 "@t3tools/shared": "workspace:*",
27 "@t3tools/ssh": "workspace:*",
28 "@t3tools/tailscale": "workspace:*",
2529 "@types/node": "catalog:",
2630 "effect-acp": "workspace:*",
2731 "tsdown": "catalog:",
apps/desktop/src/clientPersistence.test.ts+6
......@@ -72,6 +72,12 @@ const savedRegistryRecord: PersistedSavedEnvironmentRecord = {
7272 wsBaseUrl: "wss://remote.example.com/",
7373 createdAt: "2026-04-09T00:00:00.000Z",
7474 lastConnectedAt: "2026-04-09T01:00:00.000Z",
75 desktopSsh: {
76 alias: "devbox",
77 hostname: "devbox.example.com",
78 username: "julius",
79 port: 22,
80 },
7581};
7682
7783describe("clientPersistence", () => {
apps/desktop/src/clientPersistence.ts+12-3
......@@ -57,6 +57,12 @@ function isPersistedSavedEnvironmentStorageRecord(
5757 typeof value.wsBaseUrl === "string" &&
5858 typeof value.createdAt === "string" &&
5959 (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"))) &&
6066 (value.encryptedBearerToken === undefined || typeof value.encryptedBearerToken === "string")
6167 );
6268}
......@@ -77,7 +83,7 @@ function readSavedEnvironmentRegistryDocument(filePath: string): SavedEnvironmen
7783function toPersistedSavedEnvironmentRecord(
7884 record: PersistedSavedEnvironmentStorageRecord,
7985): PersistedSavedEnvironmentRecord {
80 return {
86 const nextRecord = {
8187 environmentId: record.environmentId,
8288 label: record.label,
8389 httpBaseUrl: record.httpBaseUrl,
......@@ -85,6 +91,7 @@ function toPersistedSavedEnvironmentRecord(
8591 createdAt: record.createdAt,
8692 lastConnectedAt: record.lastConnectedAt,
8793 };
94 return record.desktopSsh ? { ...nextRecord, desktopSsh: record.desktopSsh } : nextRecord;
8895}
8996
9097export function readClientSettings(settingsPath: string): ClientSettings | null {
......@@ -134,6 +141,7 @@ export function writeSavedEnvironmentRegistry(
134141 wsBaseUrl: record.wsBaseUrl,
135142 createdAt: record.createdAt,
136143 lastConnectedAt: record.lastConnectedAt,
144 ...(record.desktopSsh ? { desktopSsh: record.desktopSsh } : {}),
137145 encryptedBearerToken,
138146 }
139147 : record;
......@@ -189,7 +197,7 @@ export function writeSavedEnvironmentSecret(input: {
189197 const encryptedBearerToken = input.secretStorage
190198 .encryptString(input.secret)
191199 .toString("base64");
192 return {
200 const nextRecord = {
193201 environmentId: record.environmentId,
194202 label: record.label,
195203 httpBaseUrl: record.httpBaseUrl,
......@@ -197,7 +205,8 @@ export function writeSavedEnvironmentSecret(input: {
197205 createdAt: record.createdAt,
198206 lastConnectedAt: record.lastConnectedAt,
199207 encryptedBearerToken,
200 } satisfies PersistedSavedEnvironmentStorageRecord;
208 };
209 return record.desktopSsh ? { ...nextRecord, desktopSsh: record.desktopSsh } : nextRecord;
201210 }),
202211 } satisfies SavedEnvironmentRegistryDocument);
203212 return found;
apps/desktop/src/desktopSettings.test.ts+83
......@@ -9,6 +9,7 @@ import {
99 readDesktopSettings,
1010 resolveDefaultDesktopSettings,
1111 setDesktopServerExposurePreference,
12 setDesktopTailscaleServePreference,
1213 setDesktopUpdateChannelPreference,
1314 writeDesktopSettings,
1415} from "./desktopSettings.ts";
......@@ -35,6 +36,8 @@ describe("desktopSettings", () => {
3536 it("defaults packaged nightly builds to the nightly update channel", () => {
3637 expect(resolveDefaultDesktopSettings("0.0.17-nightly.20260415.1")).toEqual({
3738 serverExposureMode: "local-only",
39 tailscaleServeEnabled: false,
40 tailscaleServePort: 443,
3841 updateChannel: "nightly",
3942 updateChannelConfiguredByUser: false,
4043 });
......@@ -45,12 +48,16 @@ describe("desktopSettings", () => {
4548
4649 writeDesktopSettings(settingsPath, {
4750 serverExposureMode: "network-accessible",
51 tailscaleServeEnabled: true,
52 tailscaleServePort: 8443,
4853 updateChannel: "latest",
4954 updateChannelConfiguredByUser: true,
5055 });
5156
5257 expect(readDesktopSettings(settingsPath, "0.0.17")).toEqual({
5358 serverExposureMode: "network-accessible",
59 tailscaleServeEnabled: true,
60 tailscaleServePort: 8443,
5461 updateChannel: "latest",
5562 updateChannelConfiguredByUser: true,
5663 });
......@@ -61,6 +68,8 @@ describe("desktopSettings", () => {
6168 setDesktopServerExposurePreference(
6269 {
6370 serverExposureMode: "local-only",
71 tailscaleServeEnabled: false,
72 tailscaleServePort: 443,
6473 updateChannel: "latest",
6574 updateChannelConfiguredByUser: false,
6675 },
......@@ -68,6 +77,50 @@ describe("desktopSettings", () => {
6877 ),
6978 ).toEqual({
7079 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,
71124 updateChannel: "latest",
72125 updateChannelConfiguredByUser: false,
73126 });
......@@ -78,6 +131,8 @@ describe("desktopSettings", () => {
78131 setDesktopUpdateChannelPreference(
79132 {
80133 serverExposureMode: "local-only",
134 tailscaleServeEnabled: false,
135 tailscaleServePort: 443,
81136 updateChannel: "latest",
82137 updateChannelConfiguredByUser: false,
83138 },
......@@ -85,6 +140,8 @@ describe("desktopSettings", () => {
85140 ),
86141 ).toEqual({
87142 serverExposureMode: "local-only",
143 tailscaleServeEnabled: false,
144 tailscaleServePort: 443,
88145 updateChannel: "nightly",
89146 updateChannelConfiguredByUser: true,
90147 });
......@@ -103,6 +160,8 @@ describe("desktopSettings", () => {
103160
104161 expect(readDesktopSettings(settingsPath, "0.0.17-nightly.20260415.1")).toEqual({
105162 serverExposureMode: "local-only",
163 tailscaleServeEnabled: false,
164 tailscaleServePort: 443,
106165 updateChannel: "nightly",
107166 updateChannelConfiguredByUser: false,
108167 });
......@@ -121,6 +180,8 @@ describe("desktopSettings", () => {
121180
122181 expect(readDesktopSettings(settingsPath, "0.0.17-nightly.20260415.1")).toEqual({
123182 serverExposureMode: "local-only",
183 tailscaleServeEnabled: false,
184 tailscaleServePort: 443,
124185 updateChannel: "nightly",
125186 updateChannelConfiguredByUser: false,
126187 });
......@@ -140,8 +201,30 @@ describe("desktopSettings", () => {
140201
141202 expect(readDesktopSettings(settingsPath, "0.0.17-nightly.20260415.1")).toEqual({
142203 serverExposureMode: "local-only",
204 tailscaleServeEnabled: false,
205 tailscaleServePort: 443,
143206 updateChannel: "latest",
144207 updateChannelConfiguredByUser: true,
145208 });
146209 });
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 });
147230});
apps/desktop/src/desktopSettings.ts+33
......@@ -6,12 +6,18 @@ import { resolveDefaultDesktopUpdateChannel } from "./updateChannels.ts";
66
77export interface DesktopSettings {
88 readonly serverExposureMode: DesktopServerExposureMode;
9 readonly tailscaleServeEnabled: boolean;
10 readonly tailscaleServePort: number;
911 readonly updateChannel: DesktopUpdateChannel;
1012 readonly updateChannelConfiguredByUser: boolean;
1113}
1214
15export const DEFAULT_TAILSCALE_SERVE_PORT = 443;
16
1317export const DEFAULT_DESKTOP_SETTINGS: DesktopSettings = {
1418 serverExposureMode: "local-only",
19 tailscaleServeEnabled: false,
20 tailscaleServePort: DEFAULT_TAILSCALE_SERVE_PORT,
1521 updateChannel: "latest",
1622 updateChannelConfiguredByUser: false,
1723};
......@@ -35,6 +41,29 @@ export function setDesktopServerExposurePreference(
3541 };
3642}
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
3867export function setDesktopUpdateChannelPreference(
3968 settings: DesktopSettings,
4069 requestedChannel: DesktopUpdateChannel,
......@@ -57,6 +86,8 @@ export function readDesktopSettings(settingsPath: string, appVersion: string): D
5786 const raw = FS.readFileSync(settingsPath, "utf8");
5887 const parsed = JSON.parse(raw) as {
5988 readonly serverExposureMode?: unknown;
89 readonly tailscaleServeEnabled?: unknown;
90 readonly tailscaleServePort?: unknown;
6091 readonly updateChannel?: unknown;
6192 readonly updateChannelConfiguredByUser?: unknown;
6293 };
......@@ -72,6 +103,8 @@ export function readDesktopSettings(settingsPath: string, appVersion: string): D
72103 return {
73104 serverExposureMode:
74105 parsed.serverExposureMode === "network-accessible" ? "network-accessible" : "local-only",
106 tailscaleServeEnabled: parsed.tailscaleServeEnabled === true,
107 tailscaleServePort: normalizeTailscaleServePort(parsed.tailscaleServePort),
75108 updateChannel:
76109 updateChannelConfiguredByUser && parsedUpdateChannel !== null
77110 ? parsedUpdateChannel
apps/desktop/src/main.ts+128-5
......@@ -36,11 +36,14 @@ import { autoUpdater } from "electron-updater";
3636import type { ContextMenuItem } from "@t3tools/contracts";
3737import { RotatingFileSink } from "@t3tools/shared/logging";
3838import { parsePersistedServerObservabilitySettings } from "@t3tools/shared/serverSettings";
39import type { RemoteT3RunnerOptions } from "@t3tools/ssh/tunnel";
3940import { DEFAULT_DESKTOP_BACKEND_PORT, resolveDesktopBackendPort } from "./backendPort.ts";
4041import {
42 type DesktopSettings,
4143 DEFAULT_DESKTOP_SETTINGS,
4244 readDesktopSettings,
4345 setDesktopServerExposurePreference,
46 setDesktopTailscaleServePreference,
4447 setDesktopUpdateChannelPreference,
4548 writeDesktopSettings,
4649} from "./desktopSettings.ts";
......@@ -55,7 +58,11 @@ import {
5558} from "./clientPersistence.ts";
5659import { isBackendReadinessAborted, waitForHttpReady } from "./backendReadiness.ts";
5760import { showDesktopConfirmDialog } from "./confirmDialog.ts";
58import { resolveDesktopServerExposure } from "./serverExposure.ts";
61import {
62 resolveDesktopCoreAdvertisedEndpoints,
63 resolveDesktopServerExposure,
64} from "./serverExposure.ts";
65import { DesktopSshEnvironmentBridge, resolveRemoteT3CliPackageSpec } from "./sshEnvironment.ts";
5966import { syncShellEnvironment } from "./syncShellEnvironment.ts";
6067import { waitForBackendStartupReady } from "./backendStartupReadiness.ts";
6168import { getAutoUpdateDisabledReason, shouldBroadcastDownloadProgress } from "./updateState.ts";
......@@ -76,6 +83,7 @@ import {
7683import { isArm64HostRunningIntelBuild, resolveDesktopRuntimeInfo } from "./runtimeArch.ts";
7784import { resolveDesktopAppBranding } from "./appBranding.ts";
7885import { bindFirstRevealTrigger, type RevealSubscription } from "./windowReveal.ts";
86import { resolveTailscaleAdvertisedEndpoints } from "./tailscaleEndpointProvider.ts";
7987
8088syncShellEnvironment();
8189
......@@ -102,6 +110,8 @@ const SET_SAVED_ENVIRONMENT_SECRET_CHANNEL = "desktop:set-saved-environment-secr
102110const REMOVE_SAVED_ENVIRONMENT_SECRET_CHANNEL = "desktop:remove-saved-environment-secret";
103111const GET_SERVER_EXPOSURE_STATE_CHANNEL = "desktop:get-server-exposure-state";
104112const 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";
105115const BASE_DIR = process.env.T3CODE_HOME?.trim() || Path.join(OS.homedir(), ".t3");
106116const STATE_DIR = Path.join(BASE_DIR, "userdata");
107117const 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
110120const DESKTOP_SCHEME = "t3";
111121const ROOT_DIR = Path.resolve(__dirname, "../../..");
112122const 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() ?? "";
113128const desktopAppBranding: DesktopAppBranding = resolveDesktopAppBranding({
114129 isDevelopment,
115130 appVersion: app.getVersion(),
......@@ -297,6 +312,9 @@ function backendChildEnv(): NodeJS.ProcessEnv {
297312 delete env.T3CODE_DESKTOP_WS_URL;
298313 delete env.T3CODE_DESKTOP_LAN_ACCESS;
299314 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;
300318 return env;
301319}
302320
......@@ -305,9 +323,32 @@ function getDesktopServerExposureState(): DesktopServerExposureState {
305323 mode: desktopServerExposureMode,
306324 endpointUrl: backendEndpointUrl,
307325 advertisedHost: backendAdvertisedHost,
326 tailscaleServeEnabled: desktopSettings.tailscaleServeEnabled,
327 tailscaleServePort: desktopSettings.tailscaleServePort,
308328 };
309329}
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
311352function getDesktopSecretStorage() {
312353 return {
313354 isEncryptionAvailable: () => safeStorage.isEncryptionAvailable(),
......@@ -321,9 +362,19 @@ function resolveAdvertisedHostOverride(): string | undefined {
321362 return override && override.length > 0 ? override : undefined;
322363}
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
324372async function applyDesktopServerExposureMode(
325373 mode: DesktopServerExposureMode,
326 options?: { readonly persist?: boolean; readonly rejectIfUnavailable?: boolean },
374 options?: {
375 readonly persist?: boolean;
376 readonly rejectIfUnavailable?: boolean;
377 },
327378): Promise<DesktopServerExposureState> {
328379 const advertisedHostOverride = resolveAdvertisedHostOverride();
329380 const requestedMode = mode;
......@@ -361,6 +412,17 @@ async function applyDesktopServerExposureMode(
361412 return getDesktopServerExposureState();
362413}
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
364426function relaunchDesktopApp(reason: string): void {
365427 writeDesktopLogHeader(`desktop relaunch requested reason=${reason}`);
366428 setImmediate(() => {
......@@ -373,6 +435,7 @@ function relaunchDesktopApp(reason: string): void {
373435 `desktop relaunch backend shutdown warning message=${formatErrorMessage(error)}`,
374436 );
375437 })
438 .then(() => desktopSshEnvironmentBridge.dispose().catch(() => undefined))
376439 .finally(() => {
377440 restoreStdIoCapture?.();
378441 if (isDevelopment) {
......@@ -629,6 +692,22 @@ let updateInstallInFlight = false;
629692let updaterConfigured = false;
630693let 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
632711function resolveUpdaterErrorContext(): DesktopUpdateErrorContext {
633712 if (updateInstallInFlight) return "install";
634713 if (updateDownloadInFlight) return "download";
......@@ -1180,7 +1259,10 @@ async function checkForUpdates(reason: string): Promise<boolean> {
11801259 }
11811260}
11821261
1183async function downloadAvailableUpdate(): Promise<{ accepted: boolean; completed: boolean }> {
1262async function downloadAvailableUpdate(): Promise<{
1263 accepted: boolean;
1264 completed: boolean;
1265}> {
11841266 if (!updaterConfigured || updateDownloadInFlight || updateState.status !== "available") {
11851267 return { accepted: false, completed: false };
11861268 }
......@@ -1202,7 +1284,10 @@ async function downloadAvailableUpdate(): Promise<{ accepted: boolean; completed
12021284 }
12031285}
12041286
1205async function installDownloadedUpdate(): Promise<{ accepted: boolean; completed: boolean }> {
1287async function installDownloadedUpdate(): Promise<{
1288 accepted: boolean;
1289 completed: boolean;
1290}> {
12061291 if (isQuitting || !updaterConfigured || updateState.status !== "downloaded") {
12071292 return { accepted: false, completed: false };
12081293 }
......@@ -1402,6 +1487,8 @@ function startBackend(): void {
14021487 t3Home: BASE_DIR,
14031488 host: backendBindHost,
14041489 desktopBootstrapToken: backendBootstrapToken,
1490 tailscaleServeEnabled: desktopSettings.tailscaleServeEnabled,
1491 tailscaleServePort: desktopSettings.tailscaleServePort,
14051492 ...(backendObservabilitySettings.otlpTracesUrl
14061493 ? { otlpTracesUrl: backendObservabilitySettings.otlpTracesUrl }
14071494 : {}),
......@@ -1647,6 +1734,8 @@ function registerIpcHandlers(): void {
16471734 },
16481735 );
16491736
1737 desktopSshEnvironmentBridge.registerIpcHandlers(ipcMain);
1738
16501739 ipcMain.removeHandler(GET_SERVER_EXPOSURE_STATE_CHANNEL);
16511740 ipcMain.handle(GET_SERVER_EXPOSURE_STATE_CHANNEL, async () => getDesktopServerExposureState());
16521741
......@@ -1669,6 +1758,31 @@ function registerIpcHandlers(): void {
16691758 return nextState;
16701759 });
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
16721786 ipcMain.removeHandler(PICK_FOLDER_CHANNEL);
16731787 ipcMain.handle(PICK_FOLDER_CHANNEL, async (_event, rawOptions: unknown) => {
16741788 const owner = BrowserWindow.getFocusedWindow() ?? mainWindow;
......@@ -1959,7 +2073,10 @@ function createWindow(): BrowserWindow {
19592073 const externalUrl = getSafeExternalUrl(params.linkURL);
19602074 if (externalUrl) {
19612075 menuTemplate.push(
1962 { label: "Copy Link", click: () => clipboard.writeText(params.linkURL) },
2076 {
2077 label: "Copy Link",
2078 click: () => clipboard.writeText(params.linkURL),
2079 },
19632080 { type: "separator" },
19642081 );
19652082 }
......@@ -2019,6 +2136,9 @@ function createWindow(): BrowserWindow {
20192136 }
20202137
20212138 window.on("closed", () => {
2139 desktopSshEnvironmentBridge.cancelPendingPasswordPrompts(
2140 "SSH authentication was cancelled because the app window closed.",
2141 );
20222142 if (mainWindow === window) {
20232143 mainWindow = null;
20242144 }
......@@ -2110,6 +2230,7 @@ app.on("before-quit", () => {
21102230 clearUpdatePollTimer();
21112231 cancelBackendReadinessWait();
21122232 stopBackend();
2233 void desktopSshEnvironmentBridge.dispose().catch(() => undefined);
21132234 restoreStdIoCapture?.();
21142235});
21152236
......@@ -2159,6 +2280,7 @@ if (process.platform !== "win32") {
21592280 clearUpdatePollTimer();
21602281 cancelBackendReadinessWait();
21612282 stopBackend();
2283 void desktopSshEnvironmentBridge.dispose().catch(() => undefined);
21622284 restoreStdIoCapture?.();
21632285 app.quit();
21642286 });
......@@ -2169,6 +2291,7 @@ if (process.platform !== "win32") {
21692291 writeDesktopLogHeader("SIGTERM received");
21702292 clearUpdatePollTimer();
21712293 stopBackend();
2294 void desktopSshEnvironmentBridge.dispose().catch(() => undefined);
21722295 restoreStdIoCapture?.();
21732296 app.quit();
21742297 });
apps/desktop/src/preload.ts+59
......@@ -22,8 +22,36 @@ const SET_SAVED_ENVIRONMENT_REGISTRY_CHANNEL = "desktop:set-saved-environment-re
2222const GET_SAVED_ENVIRONMENT_SECRET_CHANNEL = "desktop:get-saved-environment-secret";
2323const SET_SAVED_ENVIRONMENT_SECRET_CHANNEL = "desktop:set-saved-environment-secret";
2424const 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";
2534const GET_SERVER_EXPOSURE_STATE_CHANNEL = "desktop:get-server-exposure-state";
2635const 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
2856contextBridge.exposeInMainWorld("desktopBridge", {
2957 getAppBranding: () => {
......@@ -51,8 +79,39 @@ contextBridge.exposeInMainWorld("desktopBridge", {
5179 ipcRenderer.invoke(SET_SAVED_ENVIRONMENT_SECRET_CHANNEL, environmentId, secret),
5280 removeSavedEnvironmentSecret: (environmentId) =>
5381 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),
54110 getServerExposureState: () => ipcRenderer.invoke(GET_SERVER_EXPOSURE_STATE_CHANNEL),
55111 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),
56115 pickFolder: (options) => ipcRenderer.invoke(PICK_FOLDER_CHANNEL, options),
57116 confirm: (message) => ipcRenderer.invoke(CONFIRM_CHANNEL, message),
58117 setTheme: (theme) => ipcRenderer.invoke(SET_THEME_CHANNEL, theme),
apps/desktop/src/serverExposure.test.ts+120-1
......@@ -1,6 +1,10 @@
11import { describe, expect, it } from "vitest";
22
3import { resolveDesktopServerExposure, resolveLanAdvertisedHost } from "./serverExposure.ts";
3import {
4 resolveDesktopCoreAdvertisedEndpoints,
5 resolveDesktopServerExposure,
6 resolveLanAdvertisedHost,
7} from "./serverExposure.ts";
48
59describe("resolveLanAdvertisedHost", () => {
610 it("prefers an explicit host override", () => {
......@@ -74,6 +78,121 @@ describe("resolveLanAdvertisedHost", () => {
7478 });
7579});
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
77196describe("resolveDesktopServerExposure", () => {
78197 it("keeps the desktop server loopback-only when local-only mode is selected", () => {
79198 expect(
apps/desktop/src/serverExposure.ts+109-1
......@@ -1,5 +1,13 @@
11import 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
412const DESKTOP_LOOPBACK_HOST = "127.0.0.1";
513const DESKTOP_LAN_BIND_HOST = "0.0.0.0";
......@@ -13,6 +21,26 @@ export interface DesktopServerExposure {
1321 readonly advertisedHost: string | null;
1422}
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
1644const normalizeOptionalHost = (value: string | undefined): string | undefined => {
1745 const normalized = value?.trim();
1846 return normalized && normalized.length > 0 ? normalized : undefined;
......@@ -21,6 +49,14 @@ const normalizeOptionalHost = (value: string | undefined): string | undefined =>
2149const isUsableLanIpv4Address = (address: string): boolean =>
2250 !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
2460export function resolveLanAdvertisedHost(
2561 networkInterfaces: NodeJS.Dict<NetworkInterfaceInfo[]>,
2662 explicitHost: string | undefined,
......@@ -78,3 +114,75 @@ export function resolveDesktopServerExposure(input: {
78114 advertisedHost,
79115 };
80116}
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 @@
4040 "@effect/vitest": "catalog:",
4141 "@t3tools/contracts": "workspace:*",
4242 "@t3tools/shared": "workspace:*",
43 "@t3tools/tailscale": "workspace:*",
4344 "@t3tools/web": "workspace:*",
4445 "@types/bun": "catalog:",
4546 "@types/node": "catalog:",
apps/server/src/cli-config.test.ts+36
......@@ -46,6 +46,8 @@ it.layer(NodeServices.layer)("cli config resolution", (it) => {
4646 bootstrapFd: Option.none(),
4747 autoBootstrapProjectFromCwd: Option.none(),
4848 logWebSocketEvents: Option.none(),
49 tailscaleServeEnabled: Option.none(),
50 tailscaleServePort: Option.none(),
4951 },
5052 Option.none(),
5153 ).pipe(
......@@ -87,6 +89,8 @@ it.layer(NodeServices.layer)("cli config resolution", (it) => {
8789 desktopBootstrapToken: undefined,
8890 autoBootstrapProjectFromCwd: false,
8991 logWebSocketEvents: true,
92 tailscaleServeEnabled: false,
93 tailscaleServePort: 443,
9094 });
9195 }),
9296 );
......@@ -108,6 +112,8 @@ it.layer(NodeServices.layer)("cli config resolution", (it) => {
108112 bootstrapFd: Option.none(),
109113 autoBootstrapProjectFromCwd: Option.some(true),
110114 logWebSocketEvents: Option.some(true),
115 tailscaleServeEnabled: Option.some(true),
116 tailscaleServePort: Option.some(8443),
111117 },
112118 Option.some("Debug"),
113119 ).pipe(
......@@ -149,6 +155,8 @@ it.layer(NodeServices.layer)("cli config resolution", (it) => {
149155 desktopBootstrapToken: undefined,
150156 autoBootstrapProjectFromCwd: true,
151157 logWebSocketEvents: true,
158 tailscaleServeEnabled: true,
159 tailscaleServePort: 8443,
152160 });
153161 }),
154162 );
......@@ -161,6 +169,8 @@ it.layer(NodeServices.layer)("cli config resolution", (it) => {
161169 noBrowser: true,
162170 autoBootstrapProjectFromCwd: true,
163171 logWebSocketEvents: true,
172 tailscaleServeEnabled: false,
173 tailscaleServePort: 443,
164174 });
165175 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) => {
176186 bootstrapFd: Option.none(),
177187 autoBootstrapProjectFromCwd: Option.some(false),
178188 logWebSocketEvents: Option.some(false),
189 tailscaleServeEnabled: Option.none(),
190 tailscaleServePort: Option.none(),
179191 },
180192 Option.none(),
181193 ).pipe(
......@@ -212,6 +224,8 @@ it.layer(NodeServices.layer)("cli config resolution", (it) => {
212224 desktopBootstrapToken: undefined,
213225 autoBootstrapProjectFromCwd: false,
214226 logWebSocketEvents: false,
227 tailscaleServeEnabled: false,
228 tailscaleServePort: 443,
215229 });
216230 }),
217231 );
......@@ -229,6 +243,8 @@ it.layer(NodeServices.layer)("cli config resolution", (it) => {
229243 noBrowser: true,
230244 autoBootstrapProjectFromCwd: false,
231245 logWebSocketEvents: true,
246 tailscaleServeEnabled: false,
247 tailscaleServePort: 443,
232248 otlpTracesUrl: "http://localhost:4318/v1/traces",
233249 otlpMetricsUrl: "http://localhost:4318/v1/metrics",
234250 });
......@@ -246,6 +262,8 @@ it.layer(NodeServices.layer)("cli config resolution", (it) => {
246262 bootstrapFd: Option.none(),
247263 autoBootstrapProjectFromCwd: Option.none(),
248264 logWebSocketEvents: Option.none(),
265 tailscaleServeEnabled: Option.none(),
266 tailscaleServePort: Option.none(),
249267 },
250268 Option.none(),
251269 ).pipe(
......@@ -281,6 +299,8 @@ it.layer(NodeServices.layer)("cli config resolution", (it) => {
281299 desktopBootstrapToken: undefined,
282300 autoBootstrapProjectFromCwd: false,
283301 logWebSocketEvents: true,
302 tailscaleServeEnabled: false,
303 tailscaleServePort: 443,
284304 });
285305 assert.equal(join(baseDir, "dev"), resolved.stateDir);
286306 }),
......@@ -305,6 +325,8 @@ it.layer(NodeServices.layer)("cli config resolution", (it) => {
305325 bootstrapFd: Option.none(),
306326 autoBootstrapProjectFromCwd: Option.none(),
307327 logWebSocketEvents: Option.none(),
328 tailscaleServeEnabled: Option.none(),
329 tailscaleServePort: Option.none(),
308330 },
309331 Option.none(),
310332 ).pipe(
......@@ -346,6 +368,8 @@ it.layer(NodeServices.layer)("cli config resolution", (it) => {
346368 noBrowser: false,
347369 autoBootstrapProjectFromCwd: false,
348370 logWebSocketEvents: false,
371 tailscaleServeEnabled: false,
372 tailscaleServePort: 443,
349373 });
350374 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) => {
361385 bootstrapFd: Option.none(),
362386 autoBootstrapProjectFromCwd: Option.none(),
363387 logWebSocketEvents: Option.none(),
388 tailscaleServeEnabled: Option.none(),
389 tailscaleServePort: Option.none(),
364390 },
365391 Option.some("Debug"),
366392 ).pipe(
......@@ -399,6 +425,8 @@ it.layer(NodeServices.layer)("cli config resolution", (it) => {
399425 desktopBootstrapToken: undefined,
400426 autoBootstrapProjectFromCwd: true,
401427 logWebSocketEvents: true,
428 tailscaleServeEnabled: false,
429 tailscaleServePort: 443,
402430 });
403431 }),
404432 );
......@@ -432,6 +460,8 @@ it.layer(NodeServices.layer)("cli config resolution", (it) => {
432460 bootstrapFd: Option.none(),
433461 autoBootstrapProjectFromCwd: Option.none(),
434462 logWebSocketEvents: Option.none(),
463 tailscaleServeEnabled: Option.none(),
464 tailscaleServePort: Option.none(),
435465 },
436466 Option.none(),
437467 ).pipe(
......@@ -463,6 +493,8 @@ it.layer(NodeServices.layer)("cli config resolution", (it) => {
463493 desktopBootstrapToken: undefined,
464494 autoBootstrapProjectFromCwd: false,
465495 logWebSocketEvents: false,
496 tailscaleServeEnabled: false,
497 tailscaleServePort: 443,
466498 });
467499 }),
468500 );
......@@ -485,6 +517,8 @@ it.layer(NodeServices.layer)("cli config resolution", (it) => {
485517 bootstrapFd: Option.none(),
486518 autoBootstrapProjectFromCwd: Option.none(),
487519 logWebSocketEvents: Option.none(),
520 tailscaleServeEnabled: Option.none(),
521 tailscaleServePort: Option.none(),
488522 },
489523 Option.none(),
490524 {
......@@ -522,6 +556,8 @@ it.layer(NodeServices.layer)("cli config resolution", (it) => {
522556 desktopBootstrapToken: undefined,
523557 autoBootstrapProjectFromCwd: false,
524558 logWebSocketEvents: false,
559 tailscaleServeEnabled: false,
560 tailscaleServePort: 443,
525561 });
526562 }),
527563 );
apps/server/src/cli.test.ts+2
......@@ -76,6 +76,8 @@ const makeCliTestServerConfig = (baseDir: string) =>
7676 desktopBootstrapToken: undefined,
7777 autoBootstrapProjectFromCwd: false,
7878 logWebSocketEvents: false,
79 tailscaleServeEnabled: false,
80 tailscaleServePort: 443,
7981 } satisfies ServerConfigShape;
8082 });
8183
apps/server/src/cli.ts+47
......@@ -78,6 +78,8 @@ const BootstrapEnvelopeSchema = Schema.Struct({
7878 desktopBootstrapToken: Schema.optional(Schema.String),
7979 autoBootstrapProjectFromCwd: Schema.optional(Schema.Boolean),
8080 logWebSocketEvents: Schema.optional(Schema.Boolean),
81 tailscaleServeEnabled: Schema.optional(Schema.Boolean),
82 tailscaleServePort: Schema.optional(PortSchema),
8183 otlpTracesUrl: Schema.optional(Schema.String),
8284 otlpMetricsUrl: Schema.optional(Schema.String),
8385});
......@@ -126,6 +128,17 @@ const logWebSocketEventsFlag = Flag.boolean("log-websocket-events").pipe(
126128 Flag.withAlias("log-ws-events"),
127129 Flag.optional,
128130);
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
130143const EnvServerConfig = Config.all({
131144 logLevel: Config.logLevel("T3CODE_LOG_LEVEL").pipe(Config.withDefault("Info")),
......@@ -174,6 +187,14 @@ const EnvServerConfig = Config.all({
174187 Config.option,
175188 Config.map(Option.getOrUndefined),
176189 ),
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 ),
177198});
178199
179200interface CliServerFlags {
......@@ -187,6 +208,8 @@ interface CliServerFlags {
187208 readonly bootstrapFd: Option.Option<number>;
188209 readonly autoBootstrapProjectFromCwd: Option.Option<boolean>;
189210 readonly logWebSocketEvents: Option.Option<boolean>;
211 readonly tailscaleServeEnabled: Option.Option<boolean>;
212 readonly tailscaleServePort: Option.Option<number>;
190213}
191214
192215interface CliAuthLocationFlags {
......@@ -233,6 +256,8 @@ export const resolveServerConfig = (
233256 bootstrapFd: flags.bootstrapFd ?? Option.none(),
234257 autoBootstrapProjectFromCwd: flags.autoBootstrapProjectFromCwd ?? Option.none(),
235258 logWebSocketEvents: flags.logWebSocketEvents ?? Option.none(),
259 tailscaleServeEnabled: flags.tailscaleServeEnabled ?? Option.none(),
260 tailscaleServePort: flags.tailscaleServePort ?? Option.none(),
236261 } satisfies CliServerFlags;
237262 const bootstrapFd = Option.getOrUndefined(normalizedFlags.bootstrapFd) ?? env.bootstrapFd;
238263 const bootstrapEnvelope =
......@@ -323,6 +348,22 @@ export const resolveServerConfig = (
323348 ),
324349 () => Boolean(devUrl),
325350 );
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 );
326367 const staticDir = devUrl ? undefined : yield* resolveStaticDir();
327368 const host = Option.getOrElse(
328369 resolveOptionPrecedence(
......@@ -365,6 +406,8 @@ export const resolveServerConfig = (
365406 desktopBootstrapToken,
366407 autoBootstrapProjectFromCwd,
367408 logWebSocketEvents,
409 tailscaleServeEnabled,
410 tailscaleServePort,
368411 };
369412
370413 return config;
......@@ -386,6 +429,8 @@ const resolveCliAuthConfig = (
386429 bootstrapFd: Option.none(),
387430 autoBootstrapProjectFromCwd: Option.none(),
388431 logWebSocketEvents: Option.none(),
432 tailscaleServeEnabled: Option.none(),
433 tailscaleServePort: Option.none(),
389434 },
390435 cliLogLevel,
391436 );
......@@ -766,6 +811,8 @@ const sharedServerCommandFlags = {
766811 bootstrapFd: bootstrapFdFlag,
767812 autoBootstrapProjectFromCwd: autoBootstrapProjectFromCwdFlag,
768813 logWebSocketEvents: logWebSocketEventsFlag,
814 tailscaleServeEnabled: tailscaleServeFlag,
815 tailscaleServePort: tailscaleServePortFlag,
769816} as const;
770817
771818const authLocationFlags = sharedServerLocationFlags;
apps/server/src/config.ts+4
......@@ -65,6 +65,8 @@ export interface ServerConfigShape extends ServerDerivedPaths {
6565 readonly desktopBootstrapToken: string | undefined;
6666 readonly autoBootstrapProjectFromCwd: boolean;
6767 readonly logWebSocketEvents: boolean;
68 readonly tailscaleServeEnabled: boolean;
69 readonly tailscaleServePort: number;
6870}
6971
7072export const deriveServerPaths = Effect.fn(function* (
......@@ -158,6 +160,8 @@ export class ServerConfig extends Context.Service<ServerConfig, ServerConfigShap
158160 mode: "web",
159161 autoBootstrapProjectFromCwd: false,
160162 logWebSocketEvents: false,
163 tailscaleServeEnabled: false,
164 tailscaleServePort: 443,
161165 port: 0,
162166 host: undefined,
163167 desktopBootstrapToken: undefined,
apps/server/src/environment/Layers/ServerEnvironment.test.ts+2
......@@ -30,6 +30,8 @@ const makeServerConfig = Effect.fn(function* (baseDir: string) {
3030 mode: "web",
3131 autoBootstrapProjectFromCwd: false,
3232 logWebSocketEvents: false,
33 tailscaleServeEnabled: false,
34 tailscaleServePort: 443,
3335 port: 0,
3436 host: undefined,
3537 desktopBootstrapToken: undefined,
apps/server/src/keybindings.ts+14-278
......@@ -13,14 +13,10 @@ import {
1313 KeybindingShortcut,
1414 KeybindingWhenNode,
1515 MAX_KEYBINDINGS_COUNT,
16 MODEL_PICKER_JUMP_KEYBINDING_COMMANDS,
17 MAX_WHEN_EXPRESSION_DEPTH,
1816 ResolvedKeybindingRule,
1917 ResolvedKeybindingsConfig,
20 THREAD_JUMP_KEYBINDING_COMMANDS,
2118 type ServerConfigIssue,
2219} from "@t3tools/contracts";
23import type { Mutable } from "effect/Types";
2420import {
2521 Array,
2622 Cache,
......@@ -48,278 +44,20 @@ import * as Semaphore from "effect/Semaphore";
4844import { ServerConfig } from "./config.ts";
4945import { writeFileStringAtomically } from "./atomicWrite.ts";
5046import { fromLenientJson } from "@t3tools/shared/schemaJson";
51
52type WhenToken =
53 | { type: "identifier"; value: string }
54 | { type: "not" }
55 | { type: "and" }
56 | { type: "or" }
57 | { type: "lparen" }
58 | { type: "rparen" };
59
60export const DEFAULT_KEYBINDINGS: ReadonlyArray<KeybindingRule> = [
61 { key: "mod+j", command: "terminal.toggle" },
62 { key: "mod+d", command: "terminal.split", when: "terminalFocus" },
63 { key: "mod+n", command: "terminal.new", when: "terminalFocus" },
64 { key: "mod+w", command: "terminal.close", when: "terminalFocus" },
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}
47import {
48 DEFAULT_KEYBINDINGS,
49 DEFAULT_RESOLVED_KEYBINDINGS,
50 compileResolvedKeybindingRule,
51 compileResolvedKeybindingsConfig,
52 parseKeybindingShortcut,
53} from "@t3tools/shared/keybindings";
54
55export {
56 DEFAULT_KEYBINDINGS,
57 compileResolvedKeybindingRule,
58 compileResolvedKeybindingsConfig,
59 parseKeybindingShortcut,
60};
32361
32462export const ResolvedKeybindingFromConfig = KeybindingRule.pipe(
32563 Schema.decodeTo(
......@@ -412,8 +150,6 @@ function encodeWhenAst(node: KeybindingWhenNode): string {
412150 }
413151}
414152
415const DEFAULT_RESOLVED_KEYBINDINGS = compileResolvedKeybindingsConfig(DEFAULT_KEYBINDINGS);
416
417153const RawKeybindingsEntries = fromLenientJson(Schema.Array(Schema.Unknown));
418154const KeybindingsConfigJson = Schema.fromJsonString(KeybindingsConfig);
419155const PrettyJsonString = SchemaGetter.parseJson<string>().compose(
apps/server/src/server.test.ts+35-6
......@@ -75,6 +75,7 @@ import {
7575 type ProjectionSnapshotQueryShape,
7676} from "./orchestration/Services/ProjectionSnapshotQuery.ts";
7777import { SqlitePersistenceMemory } from "./persistence/Layers/Sqlite.ts";
78import { PersistenceSqlError } from "./persistence/Errors.ts";
7879import {
7980 ProviderRegistry,
8081 type ProviderRegistryShape,
......@@ -217,10 +218,8 @@ const browserOtlpTracingLayer = Layer.mergeAll(
217218 Layer.succeed(HttpClient.TracerDisabledWhen, () => true),
218219);
219220
220const authTestLayer = ServerAuthLive.pipe(
221 Layer.provide(SqlitePersistenceMemory),
222 Layer.provide(ServerSecretStoreLive),
223);
221const makeAuthTestLayer = () =>
222 ServerAuthLive.pipe(Layer.provide(SqlitePersistenceMemory), Layer.provide(ServerSecretStoreLive));
224223
225224const makeBrowserOtlpPayload = (spanName: string) =>
226225 Effect.gen(function* () {
......@@ -378,6 +377,8 @@ const buildAppUnderTest = (options?: {
378377 desktopBootstrapToken: defaultDesktopBootstrapToken,
379378 autoBootstrapProjectFromCwd: false,
380379 logWebSocketEvents: false,
380 tailscaleServeEnabled: false,
381 tailscaleServePort: 443,
381382 ...options?.config,
382383 };
383384 const layerConfig = Layer.succeed(ServerConfig, config);
......@@ -653,7 +654,7 @@ const buildAppUnderTest = (options?: {
653654 ...options?.layers?.repositoryIdentityResolver,
654655 }),
655656 ),
656 Layer.provideMerge(authTestLayer),
657 Layer.provideMerge(makeAuthTestLayer()),
657658 Layer.provide(workspaceAndProjectServicesLayer),
658659 Layer.provideMerge(FetchHttpClient.layer),
659660 Layer.provide(layerConfig),
......@@ -2021,7 +2022,7 @@ it.layer(NodeServices.layer)("server router seam", (it) => {
20212022 assert.deepEqual(second, {
20222023 version: 1,
20232024 type: "keybindingsUpdated",
2024 payload: { issues: [] },
2025 payload: { keybindings: [], issues: [] },
20252026 });
20262027 }).pipe(Effect.provide(NodeHttpServer.layerTest)),
20272028 );
......@@ -3110,6 +3111,34 @@ it.layer(NodeServices.layer)("server router seam", (it) => {
31103111 }).pipe(Effect.provide(NodeHttpServer.layerTest)),
31113112 );
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
31133142 it.effect("enriches replayed project events with repository identity metadata", () =>
31143143 Effect.gen(function* () {
31153144 const repositoryIdentity = {
apps/server/src/server.ts+53
......@@ -83,6 +83,7 @@ import {
8383 orchestrationSnapshotRouteLayer,
8484} from "./orchestration/http.ts";
8585import { NetService } from "@t3tools/shared/Net";
86import { disableTailscaleServe, ensureTailscaleServe } from "@t3tools/tailscale";
8687
8788const PtyAdapterLive = Layer.unwrap(
8889 Effect.gen(function* () {
......@@ -334,6 +335,57 @@ export const makeServerLayer = Layer.unwrap(
334335 () => clearPersistedServerRuntimeState(config.serverRuntimeStatePath),
335336 ),
336337 );
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
338390 const serverApplicationLayer = Layer.mergeAll(
339391 HttpRouter.serve(makeRoutesLayer, {
......@@ -341,6 +393,7 @@ export const makeServerLayer = Layer.unwrap(
341393 }),
342394 httpListeningLayer,
343395 runtimeStateLayer,
396 tailscaleServeLayer,
344397 );
345398
346399 return serverApplicationLayer.pipe(
apps/server/src/ws.ts+4
......@@ -672,6 +672,9 @@ const makeWsRpcLayer = (currentSessionId: AuthSessionId) =>
672672 ORCHESTRATION_WS_METHODS.subscribeShell,
673673 Effect.gen(function* () {
674674 const snapshot = yield* projectionSnapshotQuery.getShellSnapshot().pipe(
675 Effect.tapError((cause) =>
676 Effect.logError("orchestration shell snapshot load failed", { cause }),
677 ),
675678 Effect.mapError(
676679 (cause) =>
677680 new OrchestrationGetSnapshotError({
......@@ -1017,6 +1020,7 @@ const makeWsRpcLayer = (currentSessionId: AuthSessionId) =>
10171020 version: 1 as const,
10181021 type: "keybindingsUpdated" as const,
10191022 payload: {
1023 keybindings: event.keybindings,
10201024 issues: event.issues,
10211025 },
10221026 })),
apps/web/src/branding.ts+1-1
......@@ -15,4 +15,4 @@ export const APP_STAGE_LABEL =
1515 injectedDesktopAppBranding?.stageLabel ?? (import.meta.env.DEV ? "Dev" : "Alpha");
1616export const APP_DISPLAY_NAME =
1717 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 = {
1010 wsBaseUrl: "wss://remote.example.com/",
1111 createdAt: "2026-04-09T00:00:00.000Z",
1212 lastConnectedAt: null,
13 desktopSsh: {
14 alias: "devbox",
15 hostname: "devbox.example.com",
16 username: "julius",
17 port: 22,
18 },
1319};
1420
1521function createLocalStorageStub(): Storage {
apps/web/src/clientPersistenceStorage.ts+14-3
......@@ -19,6 +19,14 @@ const BrowserSavedEnvironmentRecordSchema = Schema.Struct({
1919 wsBaseUrl: Schema.String,
2020 createdAt: Schema.String,
2121 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 ),
2230 bearerToken: Schema.optionalKey(Schema.String),
2331});
2432type BrowserSavedEnvironmentRecord = typeof BrowserSavedEnvironmentRecordSchema.Type;
......@@ -37,7 +45,7 @@ function hasWindow(): boolean {
3745function toPersistedSavedEnvironmentRecord(
3846 record: PersistedSavedEnvironmentRecord,
3947): PersistedSavedEnvironmentRecord {
40 return {
48 const nextRecord = {
4149 environmentId: record.environmentId,
4250 label: record.label,
4351 httpBaseUrl: record.httpBaseUrl,
......@@ -45,6 +53,7 @@ function toPersistedSavedEnvironmentRecord(
4553 createdAt: record.createdAt,
4654 lastConnectedAt: record.lastConnectedAt,
4755 };
56 return record.desktopSsh ? { ...nextRecord, desktopSsh: record.desktopSsh } : nextRecord;
4857}
4958
5059export function readBrowserClientSettings(): ClientSettings | null {
......@@ -135,6 +144,7 @@ export function writeBrowserSavedEnvironmentRegistry(
135144 wsBaseUrl: record.wsBaseUrl,
136145 createdAt: record.createdAt,
137146 lastConnectedAt: record.lastConnectedAt,
147 ...(record.desktopSsh ? { desktopSsh: record.desktopSsh } : {}),
138148 bearerToken,
139149 }
140150 : toPersistedSavedEnvironmentRecord(record);
......@@ -166,7 +176,7 @@ export function writeBrowserSavedEnvironmentSecret(
166176 return record;
167177 }
168178 found = true;
169 return {
179 const nextRecord = {
170180 environmentId: record.environmentId,
171181 label: record.label,
172182 httpBaseUrl: record.httpBaseUrl,
......@@ -174,7 +184,8 @@ export function writeBrowserSavedEnvironmentSecret(
174184 createdAt: record.createdAt,
175185 lastConnectedAt: record.lastConnectedAt,
176186 bearerToken: secret,
177 } satisfies BrowserSavedEnvironmentRecord;
187 };
188 return record.desktopSsh ? { ...nextRecord, desktopSsh: record.desktopSsh } : nextRecord;
178189 }),
179190 });
180191 return found;
apps/web/src/components/BranchToolbar.tsx+29-13
......@@ -80,8 +80,6 @@ const MobileRunContextSelector = memo(function MobileRunContextSelector({
8080 () => availableEnvironments?.find((env) => env.environmentId === environmentId) ?? null,
8181 [availableEnvironments, environmentId],
8282 );
83 const environmentLabel = activeEnvironment?.label ?? "Run on";
84 const EnvironmentIcon = activeEnvironment?.isPrimary ? MonitorIcon : CloudIcon;
8583 const WorkspaceIcon =
8684 effectiveEnvMode === "worktree"
8785 ? FolderGit2Icon
......@@ -93,6 +91,34 @@ const MobileRunContextSelector = memo(function MobileRunContextSelector({
9391 : effectiveEnvMode === "worktree"
9492 ? resolveEnvModeLabel("worktree")
9593 : 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
97123 return (
98124 <Menu>
......@@ -100,17 +126,7 @@ const MobileRunContextSelector = memo(function MobileRunContextSelector({
100126 render={<Button variant="ghost" size="xs" />}
101127 className="min-w-0 max-w-[48%] flex-1 justify-start text-muted-foreground/70 hover:text-foreground/80 md:hidden"
102128 >
103 {showEnvironmentPicker ? (
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 )}
129 {triggerContent}
114130 <ChevronDownIcon className="size-3 shrink-0 opacity-50" />
115131 </MenuTrigger>
116132 <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)", () => {
16631663 customWsRpcResolver = null;
16641664 document.body.innerHTML = "";
16651665 });
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
16661734 it("re-expands the bootstrap project using its logical key", async () => {
16671735 useUiStateStore.setState({
16681736 projectExpandedById: {
apps/web/src/components/ChatView.tsx+299-76
......@@ -104,7 +104,7 @@ import { BranchToolbar } from "./BranchToolbar";
104104import { resolveShortcutCommand, shortcutLabelForCommand } from "../keybindings";
105105import PlanSidebar from "./PlanSidebar";
106106import ThreadTerminalDrawer from "./ThreadTerminalDrawer";
107import { ChevronDownIcon } from "lucide-react";
107import { ChevronDownIcon, TriangleAlertIcon, WifiOffIcon } from "lucide-react";
108108import { cn, randomUUID } from "~/lib/utils";
109109import { stackedThreadToast, toastManager } from "./ui/toast";
110110import { decodeProjectScriptKeybindingRule } from "~/lib/projectScriptKeybindings";
......@@ -121,6 +121,7 @@ import { resolveAppModelSelectionForInstance } from "../modelSelection";
121121import { isTerminalFocused } from "../lib/terminalFocus";
122122import { deriveLogicalProjectKeyFromSettings } from "../logicalProject";
123123import {
124 reconnectSavedEnvironment,
124125 useSavedEnvironmentRegistryStore,
125126 useSavedEnvironmentRuntimeStore,
126127} from "../environments/runtime";
......@@ -148,6 +149,7 @@ import { NoActiveThreadState } from "./NoActiveThreadState";
148149import { resolveEffectiveEnvMode, resolveEnvironmentOptionLabel } from "./BranchToolbar.logic";
149150import { ProviderStatusBanner } from "./chat/ProviderStatusBanner";
150151import { ThreadErrorBanner } from "./chat/ThreadErrorBanner";
152import { ComposerBannerStack, type ComposerBannerStackItem } from "./chat/ComposerBannerStack";
151153import {
152154 MAX_HIDDEN_MOUNTED_TERMINAL_THREADS,
153155 buildExpiredTerminalContextToastCopy,
......@@ -180,6 +182,13 @@ import {
180182import { sanitizeThreadErrorMessage } from "~/rpc/transportError";
181183import { retainThreadDetailSubscription } from "../environments/runtime/service";
182184import { RightPanelSheet } from "./RightPanelSheet";
185import { Button } from "./ui/button";
186import {
187 buildVersionMismatchDismissalKey,
188 dismissVersionMismatch,
189 isVersionMismatchDismissed,
190 resolveServerConfigVersionMismatch,
191} from "../versionSkew";
183192
184193const IMAGE_ONLY_BOOTSTRAP_PROMPT =
185194 "[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[] = [];
187196const EMPTY_PROPOSED_PLANS: Thread["proposedPlans"] = [];
188197const EMPTY_PROVIDERS: ServerProvider[] = [];
189198const 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
191205type ThreadPlanCatalogEntry = Pick<Thread, "id" | "proposedPlans">;
192206
......@@ -855,6 +869,78 @@ export default function ChatView(props: ChatViewProps) {
855869 const primaryEnvironmentId = usePrimaryEnvironmentId();
856870 const savedEnvironmentRegistry = useSavedEnvironmentRegistryStore((s) => s.byId);
857871 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 );
858944 const projectGroupingSettings = useSettings((settings) => ({
859945 sidebarProjectGroupingMode: settings.sidebarProjectGroupingMode,
860946 sidebarProjectGroupingOverrides: settings.sidebarProjectGroupingOverrides,
......@@ -1055,6 +1141,120 @@ export default function ChatView(props: ChatViewProps) {
10551141 primaryEnvironmentId && activeThread?.environmentId === primaryEnvironmentId
10561142 ? primaryServerConfig
10571143 : (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 ]);
10581258 const providerStatuses = serverConfig?.providers ?? EMPTY_PROVIDERS;
10591259 const unlockedSelectedProvider = resolveSelectableProvider(
10601260 providerStatuses,
......@@ -2354,6 +2554,13 @@ export default function ChatView(props: ChatViewProps) {
23542554 const localApi = readLocalApi();
23552555 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 }
23572564 if (phase === "running" || isSendBusy || isConnecting) {
23582565 setThreadError(activeThread.id, "Interrupt the current turn before reverting checkpoints.");
23592566 return;
......@@ -2389,6 +2596,8 @@ export default function ChatView(props: ChatViewProps) {
23892596 },
23902597 [
23912598 activeThread,
2599 activeEnvironmentUnavailable,
2600 activeEnvironmentUnavailableLabel,
23922601 environmentId,
23932602 isConnecting,
23942603 isRevertingCheckpoint,
......@@ -2401,7 +2610,15 @@ export default function ChatView(props: ChatViewProps) {
24012610 const onSend = async (e?: { preventDefault: () => void }) => {
24022611 e?.preventDefault();
24032612 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;
24052622 if (activePendingProgress) {
24062623 onAdvanceActivePendingUserInput();
24072624 return;
......@@ -3022,6 +3239,7 @@ export default function ChatView(props: ChatViewProps) {
30223239 !isServerThread ||
30233240 isSendBusy ||
30243241 isConnecting ||
3242 activeEnvironmentUnavailable ||
30253243 sendInFlightRef.current
30263244 ) {
30273245 return;
......@@ -3136,6 +3354,7 @@ export default function ChatView(props: ChatViewProps) {
31363354 activeThreadBranch,
31373355 activeThread,
31383356 beginLocalDispatch,
3357 activeEnvironmentUnavailable,
31393358 isConnecting,
31403359 isSendBusy,
31413360 isServerThread,
......@@ -3382,74 +3601,82 @@ export default function ChatView(props: ChatViewProps) {
33823601 : "pb-[calc(env(safe-area-inset-bottom)+0.75rem)] sm:pb-[calc(env(safe-area-inset-bottom)+1rem)]",
33833602 )}
33843603 >
3385 <ChatComposer
3386 ref={composerRef}
3387 composerDraftTarget={composerDraftTarget}
3388 environmentId={environmentId}
3389 routeKind={routeKind}
3390 routeThreadRef={routeThreadRef}
3391 draftId={draftId}
3392 activeThreadId={activeThreadId}
3393 activeThreadEnvironmentId={activeThread?.environmentId}
3394 activeThread={activeThread}
3395 isServerThread={isServerThread}
3396 isLocalDraftThread={isLocalDraftThread}
3397 phase={phase}
3398 isConnecting={isConnecting}
3399 isSendBusy={isSendBusy}
3400 isPreparingWorktree={isPreparingWorktree}
3401 activePendingApproval={activePendingApproval}
3402 pendingApprovals={pendingApprovals}
3403 pendingUserInputs={pendingUserInputs}
3404 activePendingProgress={activePendingProgress}
3405 activePendingResolvedAnswers={activePendingResolvedAnswers}
3406 activePendingIsResponding={activePendingIsResponding}
3407 activePendingDraftAnswers={activePendingDraftAnswers}
3408 activePendingQuestionIndex={activePendingQuestionIndex}
3409 respondingRequestIds={respondingRequestIds}
3410 showPlanFollowUpPrompt={showPlanFollowUpPrompt}
3411 activeProposedPlan={activeProposedPlan}
3412 activePlan={activePlan as { turnId?: TurnId } | null}
3413 sidebarProposedPlan={sidebarProposedPlan as { turnId?: TurnId } | null}
3414 planSidebarLabel={planSidebarLabel}
3415 planSidebarOpen={planSidebarOpen}
3416 runtimeMode={runtimeMode}
3417 interactionMode={interactionMode}
3418 lockedProvider={lockedProvider}
3419 providerStatuses={providerStatuses as ServerProvider[]}
3420 activeProjectDefaultModelSelection={activeProject?.defaultModelSelection}
3421 activeThreadModelSelection={activeThread?.modelSelection}
3422 activeThreadActivities={activeThread?.activities}
3423 resolvedTheme={resolvedTheme}
3424 settings={settings}
3425 keybindings={keybindings}
3426 terminalOpen={Boolean(terminalState.terminalOpen)}
3427 gitCwd={gitCwd}
3428 promptRef={promptRef}
3429 composerImagesRef={composerImagesRef}
3430 composerTerminalContextsRef={composerTerminalContextsRef}
3431 shouldAutoScrollRef={isAtEndRef}
3432 scheduleStickToBottom={scrollToEnd}
3433 onSend={onSend}
3434 onInterrupt={onInterrupt}
3435 onImplementPlanInNewThread={onImplementPlanInNewThread}
3436 onRespondToApproval={onRespondToApproval}
3437 onSelectActivePendingUserInputOption={onSelectActivePendingUserInputOption}
3438 onAdvanceActivePendingUserInput={onAdvanceActivePendingUserInput}
3439 onPreviousActivePendingUserInputQuestion={onPreviousActivePendingUserInputQuestion}
3440 onChangeActivePendingUserInputCustomAnswer={
3441 onChangeActivePendingUserInputCustomAnswer
3442 }
3443 onProviderModelSelect={onProviderModelSelect}
3444 toggleInteractionMode={toggleInteractionMode}
3445 handleRuntimeModeChange={handleRuntimeModeChange}
3446 handleInteractionModeChange={handleInteractionModeChange}
3447 togglePlanSidebar={togglePlanSidebar}
3448 focusComposer={focusComposer}
3449 scheduleComposerFocus={scheduleComposerFocus}
3450 setThreadError={setThreadError}
3451 onExpandImage={onExpandTimelineImage}
3452 />
3604 <div className="relative isolate">
3605 <ComposerBannerStack className="relative z-0" items={composerBannerItems} />
3606 <div className="relative z-10">
3607 <ChatComposer
3608 ref={composerRef}
3609 composerDraftTarget={composerDraftTarget}
3610 environmentId={environmentId}
3611 routeKind={routeKind}
3612 routeThreadRef={routeThreadRef}
3613 draftId={draftId}
3614 activeThreadId={activeThreadId}
3615 activeThreadEnvironmentId={activeThread?.environmentId}
3616 activeThread={activeThread}
3617 isServerThread={isServerThread}
3618 isLocalDraftThread={isLocalDraftThread}
3619 phase={phase}
3620 isConnecting={isConnecting}
3621 isSendBusy={isSendBusy}
3622 isPreparingWorktree={isPreparingWorktree}
3623 environmentUnavailable={activeEnvironmentUnavailableState}
3624 activePendingApproval={activePendingApproval}
3625 pendingApprovals={pendingApprovals}
3626 pendingUserInputs={pendingUserInputs}
3627 activePendingProgress={activePendingProgress}
3628 activePendingResolvedAnswers={activePendingResolvedAnswers}
3629 activePendingIsResponding={activePendingIsResponding}
3630 activePendingDraftAnswers={activePendingDraftAnswers}
3631 activePendingQuestionIndex={activePendingQuestionIndex}
3632 respondingRequestIds={respondingRequestIds}
3633 showPlanFollowUpPrompt={showPlanFollowUpPrompt}
3634 activeProposedPlan={activeProposedPlan}
3635 activePlan={activePlan as { turnId?: TurnId } | null}
3636 sidebarProposedPlan={sidebarProposedPlan as { turnId?: TurnId } | null}
3637 planSidebarLabel={planSidebarLabel}
3638 planSidebarOpen={planSidebarOpen}
3639 runtimeMode={runtimeMode}
3640 interactionMode={interactionMode}
3641 lockedProvider={lockedProvider}
3642 providerStatuses={providerStatuses as ServerProvider[]}
3643 activeProjectDefaultModelSelection={activeProject?.defaultModelSelection}
3644 activeThreadModelSelection={activeThread?.modelSelection}
3645 activeThreadActivities={activeThread?.activities}
3646 resolvedTheme={resolvedTheme}
3647 settings={settings}
3648 keybindings={keybindings}
3649 terminalOpen={Boolean(terminalState.terminalOpen)}
3650 gitCwd={gitCwd}
3651 promptRef={promptRef}
3652 composerImagesRef={composerImagesRef}
3653 composerTerminalContextsRef={composerTerminalContextsRef}
3654 shouldAutoScrollRef={isAtEndRef}
3655 scheduleStickToBottom={scrollToEnd}
3656 onSend={onSend}
3657 onInterrupt={onInterrupt}
3658 onImplementPlanInNewThread={onImplementPlanInNewThread}
3659 onRespondToApproval={onRespondToApproval}
3660 onSelectActivePendingUserInputOption={onSelectActivePendingUserInputOption}
3661 onAdvanceActivePendingUserInput={onAdvanceActivePendingUserInput}
3662 onPreviousActivePendingUserInputQuestion={
3663 onPreviousActivePendingUserInputQuestion
3664 }
3665 onChangeActivePendingUserInputCustomAnswer={
3666 onChangeActivePendingUserInputCustomAnswer
3667 }
3668 onProviderModelSelect={onProviderModelSelect}
3669 toggleInteractionMode={toggleInteractionMode}
3670 handleRuntimeModeChange={handleRuntimeModeChange}
3671 handleInteractionModeChange={handleInteractionModeChange}
3672 togglePlanSidebar={togglePlanSidebar}
3673 focusComposer={focusComposer}
3674 scheduleComposerFocus={scheduleComposerFocus}
3675 setThreadError={setThreadError}
3676 onExpandImage={onExpandTimelineImage}
3677 />
3678 </div>
3679 </div>
34533680 {isGitRepo && (
34543681 <BranchToolbar
34553682 environmentId={activeThread.environmentId}
......@@ -3468,12 +3695,8 @@ export default function ChatView(props: ChatViewProps) {
34683695 {...(canCheckoutPullRequestIntoThread
34693696 ? { onCheckoutPullRequestRequest: openPullRequestDialog }
34703697 : {})}
3471 {...(hasMultipleEnvironments
3472 ? {
3473 availableEnvironments: logicalProjectEnvironments,
3474 onEnvironmentChange,
3475 }
3476 : {})}
3698 {...(hasMultipleEnvironments ? { onEnvironmentChange } : {})}
3699 availableEnvironments={logicalProjectEnvironments}
34773700 />
34783701 )}
34793702 </div>
apps/web/src/components/KeybindingsToast.browser.tsx+1-1
......@@ -289,7 +289,7 @@ function sendServerConfigUpdatedPush(issues: ServerConfig["issues"]) {
289289 rpcHarness.emitStreamValue(WS_METHODS.subscribeServerConfig, {
290290 version: 1,
291291 type: "keybindingsUpdated",
292 payload: { issues },
292 payload: { keybindings: fixture.serverConfig.keybindings, issues },
293293 });
294294}
295295
apps/web/src/components/ProjectFavicon.tsx+20-6
......@@ -10,15 +10,29 @@ export function ProjectFavicon(input: {
1010 cwd: string;
1111 className?: string;
1212}) {
13 const src = resolveEnvironmentHttpUrl({
14 environmentId: input.environmentId,
15 pathname: "/api/project-favicon",
16 searchParams: { cwd: input.cwd },
17 });
13 const src = (() => {
14 try {
15 return resolveEnvironmentHttpUrl({
16 environmentId: input.environmentId,
17 pathname: "/api/project-favicon",
18 searchParams: { cwd: input.cwd },
19 });
20 } catch {
21 return null;
22 }
23 })();
1824 const [status, setStatus] = useState<"loading" | "loaded" | "error">(() =>
19 loadedProjectFaviconSrcs.has(src) ? "loaded" : "loading",
25 src && loadedProjectFaviconSrcs.has(src) ? "loaded" : "loading",
2026 );
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
2236 return (
2337 <>
2438 {status !== "loaded" ? (
apps/web/src/components/WebSocketConnectionSurface.logic.test.ts+1
......@@ -8,6 +8,7 @@ function makeStatus(overrides: Partial<WsConnectionStatus> = {}): WsConnectionSt
88 attemptCount: 0,
99 closeCode: null,
1010 closeReason: null,
11 connectionLabel: null,
1112 connectedAt: null,
1213 disconnectedAt: null,
1314 hasConnected: false,
apps/web/src/components/WebSocketConnectionSurface.tsx+12-4
......@@ -53,8 +53,16 @@ function describeExhaustedToast(): string {
5353 return "Retries exhausted trying to reconnect";
5454}
5555
56function buildReconnectTitle(_status: WsConnectionStatus): string {
57 return "Disconnected from T3 Server";
56function getConnectionDisplayName(status: WsConnectionStatus): string {
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)}`;
5866}
5967
6068function describeRecoveredToast(
......@@ -297,7 +305,7 @@ export function WebSocketConnectionCoordinator() {
297305 },
298306 description: describeExhaustedToast(),
299307 timeout: 0,
300 title: "Disconnected from T3 Server",
308 title: buildReconnectTitle(status),
301309 type: "error",
302310 })
303311 : stackedThreadToast({
......@@ -334,7 +342,7 @@ export function WebSocketConnectionCoordinator() {
334342 ) {
335343 const successToast = {
336344 description: describeRecoveredToast(previousDisconnectedAt, status.connectedAt),
337 title: "Reconnected to T3 Server",
345 title: buildRecoveredTitle(status),
338346 type: "success" as const,
339347 timeout: 0,
340348 data: {
apps/web/src/components/auth/PairingRouteSurface.tsx+123
......@@ -2,11 +2,13 @@ import type { AuthSessionState } from "@t3tools/contracts";
22import React, { startTransition, useEffect, useRef, useState, useCallback } from "react";
33
44import { APP_DISPLAY_NAME } from "../../branding";
5import { addSavedEnvironment } from "../../environments/runtime";
56import {
67 peekPairingTokenFromUrl,
78 stripPairingTokenFromUrl,
89 submitServerAuthCredential,
910} from "../../environments/primary";
11import { readHostedPairingRequest } from "../../hostedPairing";
1012import { Button } from "../ui/button";
1113import { Input } from "../ui/input";
1214
......@@ -159,6 +161,127 @@ export function PairingRouteSurface({
159161 );
160162}
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
162285function errorMessageFromUnknown(error: unknown): string {
163286 if (error instanceof Error && error.message.trim().length > 0) {
164287 return error.message;
apps/web/src/components/chat/ChatComposer.tsx+25-4
......@@ -301,6 +301,7 @@ const ComposerFooterPrimaryActions = memo(function ComposerFooterPrimaryActions(
301301 promptHasText: boolean;
302302 isSendBusy: boolean;
303303 isConnecting: boolean;
304 isEnvironmentUnavailable: boolean;
304305 hasSendableContent: boolean;
305306 preserveComposerFocusOnPointerDown?: boolean;
306307 onPreviousPendingQuestion: () => void;
......@@ -321,6 +322,7 @@ const ComposerFooterPrimaryActions = memo(function ComposerFooterPrimaryActions(
321322 promptHasText={props.promptHasText}
322323 isSendBusy={props.isSendBusy}
323324 isConnecting={props.isConnecting}
325 isEnvironmentUnavailable={props.isEnvironmentUnavailable}
324326 isPreparingWorktree={props.isPreparingWorktree}
325327 hasSendableContent={props.hasSendableContent}
326328 preserveComposerFocusOnPointerDown={props.preserveComposerFocusOnPointerDown ?? false}
......@@ -393,6 +395,10 @@ export interface ChatComposerProps {
393395 isConnecting: boolean;
394396 isSendBusy: boolean;
395397 isPreparingWorktree: boolean;
398 environmentUnavailable: {
399 readonly label: string;
400 readonly connectionState: "connecting" | "disconnected" | "error";
401 } | null;
396402
397403 // Pending approvals / inputs
398404 activePendingApproval: PendingApproval | null;
......@@ -500,6 +506,7 @@ export const ChatComposer = memo(
500506 isConnecting,
501507 isSendBusy,
502508 isPreparingWorktree,
509 environmentUnavailable,
503510 activePendingApproval,
504511 pendingApprovals,
505512 pendingUserInputs,
......@@ -1959,6 +1966,7 @@ export const ChatComposer = memo(
19591966 className={cn(
19601967 "rounded-[20px] border bg-card transition-colors duration-200 has-focus-visible:border-ring/45",
19611968 isDragOverComposer ? "border-primary/70 bg-accent/30" : "border-border",
1969 environmentUnavailable ? "opacity-75" : null,
19621970 composerProviderState.composerSurfaceClassName,
19631971 )}
19641972 onFocusCapture={(event) => {
......@@ -2070,6 +2078,7 @@ export const ChatComposer = memo(
20702078 promptHasText={false}
20712079 isSendBusy={isSendBusy}
20722080 isConnecting={isConnecting}
2081 isEnvironmentUnavailable={environmentUnavailable !== null}
20732082 isPreparingWorktree={false}
20742083 hasSendableContent={false}
20752084 preserveComposerFocusOnPointerDown
......@@ -2250,11 +2259,21 @@ export const ChatComposer = memo(
22502259 ? "Type your own answer, or leave this blank to use the selected option"
22512260 : showPlanFollowUpPrompt && activeProposedPlan
22522261 ? "Add feedback to refine the plan, or leave this blank to implement it"
2253 : phase === "disconnected"
2254 ? "Ask for follow-up changes or attach images"
2255 : "Ask anything, @tag files/folders, or use / to show available commands"
2262 : environmentUnavailable
2263 ? `${environmentUnavailable.label} is ${
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)
22562276 }
2257 disabled={isConnecting || isComposerApprovalState}
22582277 />
22592278 {showMobilePendingAnswerActions ? (
22602279 <div
......@@ -2269,6 +2288,7 @@ export const ChatComposer = memo(
22692288 promptHasText={false}
22702289 isSendBusy={isSendBusy}
22712290 isConnecting={isConnecting}
2291 isEnvironmentUnavailable={environmentUnavailable !== null}
22722292 isPreparingWorktree={false}
22732293 hasSendableContent={false}
22742294 preserveComposerFocusOnPointerDown
......@@ -2384,6 +2404,7 @@ export const ChatComposer = memo(
23842404 promptHasText={prompt.trim().length > 0}
23852405 isSendBusy={isSendBusy}
23862406 isConnecting={isConnecting}
2407 isEnvironmentUnavailable={environmentUnavailable !== null}
23872408 isPreparingWorktree={isPreparingWorktree}
23882409 hasSendableContent={composerSendState.hasSendableContent}
23892410 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 {
4444 onToggleDiff: () => void;
4545}
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
4759export const ChatHeader = memo(function ChatHeader({
4860 activeThreadEnvironmentId,
4961 activeThreadId,
......@@ -70,8 +82,11 @@ export const ChatHeader = memo(function ChatHeader({
7082 onToggleDiff,
7183}: ChatHeaderProps) {
7284 const primaryEnvironmentId = usePrimaryEnvironmentId();
73 const isRemoteEnvironment =
74 primaryEnvironmentId !== null && activeThreadEnvironmentId !== primaryEnvironmentId;
85 const showOpenInPicker = shouldShowOpenInPicker({
86 activeProjectName,
87 activeThreadEnvironmentId,
88 primaryEnvironmentId,
89 });
7590
7691 return (
7792 <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({
106121 onDeleteScript={onDeleteProjectScript}
107122 />
108123 )}
109 {activeProjectName && !isRemoteEnvironment && (
124 {showOpenInPicker && (
110125 <OpenInPicker
111126 keybindings={keybindings}
112127 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 {
2020 promptHasText: boolean;
2121 isSendBusy: boolean;
2222 isConnecting: boolean;
23 isEnvironmentUnavailable: boolean;
2324 isPreparingWorktree: boolean;
2425 hasSendableContent: boolean;
2526 preserveComposerFocusOnPointerDown?: boolean;
......@@ -58,6 +59,7 @@ export const ComposerPrimaryActions = memo(function ComposerPrimaryActions({
5859 promptHasText,
5960 isSendBusy,
6061 isConnecting,
62 isEnvironmentUnavailable,
6163 isPreparingWorktree,
6264 hasSendableContent,
6365 preserveComposerFocusOnPointerDown = false,
......@@ -104,6 +106,7 @@ export const ComposerPrimaryActions = memo(function ComposerPrimaryActions({
104106 className={cn("rounded-full", compact ? "px-3" : "px-4")}
105107 {...pointerFocusProps}
106108 disabled={
109 isEnvironmentUnavailable ||
107110 pendingAction.isResponding ||
108111 (pendingAction.isLastQuestion ? !pendingAction.isComplete : !pendingAction.canAdvance)
109112 }
......@@ -143,7 +146,7 @@ export const ComposerPrimaryActions = memo(function ComposerPrimaryActions({
143146 size="sm"
144147 className={cn("rounded-full", compact ? "h-9 px-3 sm:h-8" : "h-9 px-4 sm:h-8")}
145148 {...pointerFocusProps}
146 disabled={isSendBusy || isConnecting}
149 disabled={isSendBusy || isConnecting || isEnvironmentUnavailable}
147150 >
148151 {isConnecting || isSendBusy ? "Sending..." : "Refine"}
149152 </Button>
......@@ -157,7 +160,7 @@ export const ComposerPrimaryActions = memo(function ComposerPrimaryActions({
157160 size="sm"
158161 className="h-9 rounded-l-full rounded-r-none px-4 sm:h-8"
159162 {...pointerFocusProps}
160 disabled={isSendBusy || isConnecting}
163 disabled={isSendBusy || isConnecting || isEnvironmentUnavailable}
161164 >
162165 {isConnecting || isSendBusy ? "Sending..." : "Implement"}
163166 </Button>
......@@ -170,7 +173,7 @@ export const ComposerPrimaryActions = memo(function ComposerPrimaryActions({
170173 className="h-9 rounded-l-none rounded-r-full border-l-white/12 px-2 sm:h-8"
171174 aria-label="Implementation actions"
172175 {...pointerFocusProps}
173 disabled={isSendBusy || isConnecting}
176 disabled={isSendBusy || isConnecting || isEnvironmentUnavailable}
174177 />
175178 }
176179 >
......@@ -178,7 +181,7 @@ export const ComposerPrimaryActions = memo(function ComposerPrimaryActions({
178181 </MenuTrigger>
179182 <MenuPopup align="end" side="top">
180183 <MenuItem
181 disabled={isSendBusy || isConnecting}
184 disabled={isSendBusy || isConnecting || isEnvironmentUnavailable}
182185 onClick={() => void onImplementPlanInNewThread()}
183186 >
184187 Implement in a new thread
......@@ -194,15 +197,17 @@ export const ComposerPrimaryActions = memo(function ComposerPrimaryActions({
194197 type="submit"
195198 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"
196199 {...pointerFocusProps}
197 disabled={isSendBusy || isConnecting || !hasSendableContent}
200 disabled={isSendBusy || isConnecting || isEnvironmentUnavailable || !hasSendableContent}
198201 aria-label={
199 isConnecting
200 ? "Connecting"
201 : isPreparingWorktree
202 ? "Preparing worktree"
203 : isSendBusy
204 ? "Sending"
205 : "Send message"
202 isEnvironmentUnavailable
203 ? "Environment disconnected"
204 : isConnecting
205 ? "Connecting"
206 : isPreparingWorktree
207 ? "Preparing worktree"
208 : isSendBusy
209 ? "Sending"
210 : "Send message"
206211 }
207212 >
208213 {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 @@
1import { PlusIcon, QrCodeIcon } from "lucide-react";
2import { memo, useCallback, useEffect, useMemo, useState } from "react";
1import {
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";
320import {
421 type AuthClientSession,
522 type AuthPairingLink,
23 type AdvertisedEndpoint,
24 type DesktopDiscoveredSshHost,
25 type DesktopSshEnvironmentTarget,
626 type DesktopServerExposureState,
727 type EnvironmentId,
828} from "@t3tools/contracts";
......@@ -11,6 +31,7 @@ import { DateTime } from "effect";
1131import { useCopyToClipboard } from "../../hooks/useCopyToClipboard";
1232import { cn } from "../../lib/utils";
1333import { formatElapsedDurationLabel, formatExpiresInLabel } from "../../timestampFormat";
34import { resolveDesktopPairingUrl, resolveHostedPairingUrl } from "./pairingUrls";
1435import {
1536 SettingsPageContainer,
1637 SettingsRow,
......@@ -20,6 +41,7 @@ import {
2041import { Input } from "../ui/input";
2142import {
2243 Dialog,
44 DialogClose,
2345 DialogFooter,
2446 DialogDescription,
2547 DialogHeader,
......@@ -28,6 +50,7 @@ import {
2850 DialogTitle,
2951 DialogTrigger,
3052} from "../ui/dialog";
53import { ScrollArea } from "../ui/scroll-area";
3154import {
3255 AlertDialog,
3356 AlertDialogClose,
......@@ -44,8 +67,19 @@ import { Switch } from "../ui/switch";
4467import { stackedThreadToast, toastManager } from "../ui/toast";
4568import { Tooltip, TooltipPopup, TooltipTrigger } from "../ui/tooltip";
4669import { 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";
4780import { Textarea } from "../ui/textarea";
48import { setPairingTokenOnUrl } from "../../pairingUrl";
81import { getPairingTokenFromUrl, setPairingTokenOnUrl } from "../../pairingUrl";
82import { readHostedPairingRequest } from "../../hostedPairing";
4983import {
5084 createServerPairingCredential,
5185 fetchSessionState,
......@@ -63,10 +97,17 @@ import {
6397 useSavedEnvironmentRegistryStore,
6498 useSavedEnvironmentRuntimeStore,
6599 addSavedEnvironment,
100 connectDesktopSshEnvironment,
101 disconnectSavedEnvironment,
66102 getPrimaryEnvironmentConnection,
67103 reconnectSavedEnvironment,
68104 removeSavedEnvironment,
69105} from "~/environments/runtime";
106import { useUiStateStore } from "~/uiStateStore";
107import { resolveServerConfigVersionMismatch } from "~/versionSkew";
108import { useServerConfig } from "~/rpc/serverState";
109
110const DEFAULT_TAILSCALE_SERVE_PORT = 443;
70111
71112const accessTimestampFormatter = new Intl.DateTimeFormat(undefined, {
72113 dateStyle: "medium",
......@@ -135,6 +176,62 @@ function ConnectionStatusDot({
135176 );
136177}
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
138235function getSavedBackendStatusTooltip(
139236 runtime: SavedEnvironmentRuntimeState | null,
140237 record: SavedEnvironmentRecord,
......@@ -160,12 +257,153 @@ function getSavedBackendStatusTooltip(
160257 : "Not connected yet.";
161258}
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
163383/** Direct row in the card – same pattern as the Provider / ACP-agent list rows. */
164384const 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
166387const ITEM_ROW_INNER_CLASSNAME =
167388 "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
169407function sortDesktopPairingLinks(links: ReadonlyArray<ServerPairingLinkRecord>) {
170408 return [...links].toSorted(
171409 (left, right) => new Date(right.createdAt).getTime() - new Date(left.createdAt).getTime(),
......@@ -243,10 +481,66 @@ function removeDesktopClientSession(
243481 return current.filter((clientSession) => clientSession.sessionId !== sessionId);
244482}
245483
246function resolveDesktopPairingUrl(endpointUrl: string, credential: string): string {
247 const url = new URL(endpointUrl);
248 url.pathname = "/pair";
249 return setPairingTokenOnUrl(url, credential).toString();
484function selectPairingEndpoint(
485 endpoints: ReadonlyArray<AdvertisedEndpoint>,
486 defaultEndpointKey?: string | null,
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);
250544}
251545
252546function resolveCurrentOriginPairingUrl(credential: string): string {
......@@ -254,9 +548,21 @@ function resolveCurrentOriginPairingUrl(credential: string): string {
254548 return setPairingTokenOnUrl(url, credential).toString();
255549}
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
257560type PairingLinkListRowProps = {
258561 pairingLink: ServerPairingLinkRecord;
259562 endpointUrl: string | null | undefined;
563 endpoints: ReadonlyArray<AdvertisedEndpoint>;
564 defaultEndpointKey: string | null;
565 presentation?: AccessSectionPresentation;
260566 revokingPairingLinkId: string | null;
261567 onRevoke: (id: string) => void;
262568};
......@@ -264,6 +570,9 @@ type PairingLinkListRowProps = {
264570const PairingLinkListRow = memo(function PairingLinkListRow({
265571 pairingLink,
266572 endpointUrl,
573 endpoints,
574 defaultEndpointKey,
575 presentation = "current",
267576 revokingPairingLinkId,
268577 onRevoke,
269578}: PairingLinkListRowProps) {
......@@ -278,55 +587,197 @@ const PairingLinkListRow = memo(function PairingLinkListRow({
278587 () => resolveCurrentOriginPairingUrl(pairingLink.credential),
279588 [pairingLink.credential],
280589 );
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 );
281616 const shareablePairingUrl =
282 endpointUrl != null && endpointUrl !== ""
283 ? resolveDesktopPairingUrl(endpointUrl, pairingLink.credential)
617 endpointPairingUrl ??
618 (endpointUrl != null && endpointUrl !== ""
619 ? (hostedPairingUrl ?? resolveDesktopPairingUrl(endpointUrl, pairingLink.credential))
284620 : isLoopbackHostname(window.location.hostname)
285621 ? null
286 : currentOriginPairingUrl;
287 const copyValue = shareablePairingUrl ?? pairingLink.credential;
622 : currentOriginPairingUrl);
623 const revealValue = shareablePairingUrl ?? pairingLink.credential;
624 const isShareableHostedAppPairingUrl =
625 shareablePairingUrl !== null && isHostedAppPairingUrl(shareablePairingUrl);
288626 const canCopyToClipboard =
289627 typeof window !== "undefined" &&
290628 window.isSecureContext &&
291629 navigator.clipboard?.writeText != null;
292630
293 const { copyToClipboard, isCopied } = useCopyToClipboard({
294 onCopy: () => {
631 const { copyToClipboard } = useCopyToClipboard<"code" | "hosted-link" | "link">({
632 onCopy: (kind) => {
295633 toastManager.add({
296634 type: "success",
297 title: shareablePairingUrl ? "Pairing URL copied" : "Pairing token copied",
298 description: shareablePairingUrl
299 ? "Open it in the client you want to pair to this environment."
300 : "Paste it into another client with this backend's reachable host.",
635 title:
636 kind === "hosted-link"
637 ? "Hosted app link copied"
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.",
301647 });
302648 },
303 onError: (error) => {
649 onError: (error, kind) => {
304650 setIsRevealDialogOpen(true);
305651 toastManager.add(
306652 stackedThreadToast({
307653 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",
309661 description: canCopyToClipboard ? error.message : "Showing the full value instead.",
310662 }),
311663 );
312664 },
313665 });
314666
315 const handleCopy = useCallback(() => {
316 copyToClipboard(copyValue, undefined);
317 }, [copyToClipboard, copyValue]);
667 const copyPairingValue = useCallback(
668 (value: string, kind: "code" | "hosted-link" | "link") => {
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
319688 const expiresAbsolute = formatAccessTimestamp(pairingLink.expiresAt);
320689
321690 const roleLabel = pairingLink.role === "owner" ? "Owner" : "Client";
322691 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
324775 if (expiresAtMs <= nowMs) {
325776 return null;
326777 }
327778
328779 return (
329 <div className={ITEM_ROW_CLASSNAME}>
780 <div className={accessRowClassName(presentation)}>
330781 <div className={ITEM_ROW_INNER_CLASSNAME}>
331782 <div className="min-w-0 flex-1 space-y-1">
332783 <div className="flex min-h-5 items-center gap-1.5">
......@@ -377,27 +828,70 @@ const PairingLinkListRow = memo(function PairingLinkListRow({
377828 <div className="flex w-full shrink-0 items-center gap-2 sm:w-auto sm:justify-end">
378829 <Dialog open={isRevealDialogOpen} onOpenChange={setIsRevealDialogOpen}>
379830 {canCopyToClipboard ? (
380 <Button size="xs" variant="outline" onClick={handleCopy}>
381 {isCopied ? "Copied" : shareablePairingUrl ? "Copy" : "Copy token"}
382 </Button>
831 <>
832 {shareablePairingUrl ? (
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 </>
383869 ) : (
384870 <DialogTrigger render={<Button size="xs" variant="outline" />}>
385 {shareablePairingUrl ? "Show link" : "Show token"}
871 {shareablePairingUrl ? "Show link" : "Show code"}
386872 </DialogTrigger>
387873 )}
388874 <DialogPopup className="max-w-md">
389875 <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>
391883 <DialogDescription>
392884 {shareablePairingUrl
393 ? "Clipboard copy is unavailable here. Open or manually copy this full pairing URL on the device you want to connect."
394 : "Clipboard copy is unavailable here. Manually copy this token and pair from another client using this backend's reachable host."}
885 ? isShareableHostedAppPairingUrl
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."}
395889 </DialogDescription>
396890 </DialogHeader>
397891 <DialogPanel className="space-y-4">
398892 <Textarea
399893 readOnly
400 value={copyValue}
894 value={revealValue}
401895 rows={shareablePairingUrl ? 4 : 3}
402896 className="text-xs leading-relaxed"
403897 onFocus={(event) => event.currentTarget.select()}
......@@ -420,8 +914,8 @@ const PairingLinkListRow = memo(function PairingLinkListRow({
420914 Done
421915 </Button>
422916 {canCopyToClipboard ? (
423 <Button variant="outline" size="xs" onClick={handleCopy}>
424 {isCopied ? "Copied" : "Copy again"}
917 <Button variant="outline" size="xs" onClick={handleCopyCode}>
918 Copy code
425919 </Button>
426920 ) : null}
427921 </DialogFooter>
......@@ -443,12 +937,14 @@ const PairingLinkListRow = memo(function PairingLinkListRow({
443937
444938type ConnectedClientListRowProps = {
445939 clientSession: ServerClientSessionRecord;
940 presentation?: AccessSectionPresentation;
446941 revokingClientSessionId: string | null;
447942 onRevokeSession: (sessionId: ServerClientSessionRecord["sessionId"]) => void;
448943};
449944
450945const ConnectedClientListRow = memo(function ConnectedClientListRow({
451946 clientSession,
947 presentation = "current",
452948 revokingClientSessionId,
453949 onRevokeSession,
454950}: ConnectedClientListRowProps) {
......@@ -477,7 +973,7 @@ const ConnectedClientListRow = memo(function ConnectedClientListRow({
477973 clientSession.subject);
478974
479975 return (
480 <div className={ITEM_ROW_CLASSNAME}>
976 <div className={accessRowClassName(presentation)}>
481977 <div className={ITEM_ROW_INNER_CLASSNAME}>
482978 <div className="min-w-0 flex-1 space-y-1">
483979 <div className="flex min-h-5 items-center gap-1.5">
......@@ -620,6 +1116,9 @@ const AuthorizedClientsHeaderAction = memo(function AuthorizedClientsHeaderActio
6201116
6211117type PairingClientsListProps = {
6221118 endpointUrl: string | null | undefined;
1119 endpoints: ReadonlyArray<AdvertisedEndpoint>;
1120 defaultEndpointKey: string | null;
1121 presentation?: AccessSectionPresentation;
6231122 isLoading: boolean;
6241123 pairingLinks: ReadonlyArray<ServerPairingLinkRecord>;
6251124 clientSessions: ReadonlyArray<ServerClientSessionRecord>;
......@@ -631,6 +1130,9 @@ type PairingClientsListProps = {
6311130
6321131const PairingClientsList = memo(function PairingClientsList({
6331132 endpointUrl,
1133 endpoints,
1134 defaultEndpointKey,
1135 presentation = "current",
6341136 isLoading,
6351137 pairingLinks,
6361138 clientSessions,
......@@ -646,6 +1148,9 @@ const PairingClientsList = memo(function PairingClientsList({
6461148 key={pairingLink.id}
6471149 pairingLink={pairingLink}
6481150 endpointUrl={endpointUrl}
1151 endpoints={endpoints}
1152 defaultEndpointKey={defaultEndpointKey}
1153 presentation={presentation}
6491154 revokingPairingLinkId={revokingPairingLinkId}
6501155 onRevoke={onRevokePairingLink}
6511156 />
......@@ -655,13 +1160,14 @@ const PairingClientsList = memo(function PairingClientsList({
6551160 <ConnectedClientListRow
6561161 key={clientSession.sessionId}
6571162 clientSession={clientSession}
1163 presentation={presentation}
6581164 revokingClientSessionId={revokingClientSessionId}
6591165 onRevokeSession={onRevokeClientSession}
6601166 />
6611167 ))}
6621168
6631169 {pairingLinks.length === 0 && clientSessions.length === 0 && !isLoading ? (
664 <div className={ITEM_ROW_CLASSNAME}>
1170 <div className={accessRowClassName(presentation)}>
6651171 <p className="text-xs text-muted-foreground/60">No pairing links or client sessions.</p>
6661172 </div>
6671173 ) : null}
......@@ -669,19 +1175,156 @@ const PairingClientsList = memo(function PairingClientsList({
6691175 );
6701176});
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
6721311type SavedBackendListRowProps = {
6731312 environmentId: EnvironmentId;
6741313 reconnectingEnvironmentId: EnvironmentId | null;
1314 disconnectingEnvironmentId: EnvironmentId | null;
6751315 removingEnvironmentId: EnvironmentId | null;
676 onReconnect: (environmentId: EnvironmentId) => void;
1316 onConnect: (environmentId: EnvironmentId) => void;
1317 onDisconnect: (environmentId: EnvironmentId) => void;
6771318 onRemove: (environmentId: EnvironmentId) => void;
6781319};
6791320
6801321function SavedBackendListRow({
6811322 environmentId,
6821323 reconnectingEnvironmentId,
1324 disconnectingEnvironmentId,
6831325 removingEnvironmentId,
684 onReconnect,
1326 onConnect,
1327 onDisconnect,
6851328 onRemove,
6861329}: SavedBackendListRowProps) {
6871330 const nowMs = useRelativeTimeTick(1_000);
......@@ -693,6 +1336,10 @@ function SavedBackendListRow({
6931336 }
6941337
6951338 const connectionState = runtime?.connectionState ?? "disconnected";
1339 const isConnected = connectionState === "connected";
1340 const isConnecting =
1341 connectionState === "connecting" || reconnectingEnvironmentId === environmentId;
1342 const isDisconnecting = disconnectingEnvironmentId === environmentId;
6961343 const stateDotClassName =
6971344 connectionState === "connected"
6981345 ? "bg-success"
......@@ -703,8 +1350,11 @@ function SavedBackendListRow({
7031350 : "bg-muted-foreground/40";
7041351 const roleLabel = runtime?.role ? (runtime.role === "owner" ? "Owner" : "Client") : null;
7051352 const descriptorLabel = runtime?.descriptor?.label ?? null;
1353 const displayLabel = descriptorLabel ?? record.label;
7061354 const statusTooltip = getSavedBackendStatusTooltip(runtime, record, nowMs);
1355 const versionMismatch = resolveServerConfigVersionMismatch(runtime?.serverConfig);
7071356 const metadataBits = [
1357 record.desktopSsh ? `SSH ${formatDesktopSshTarget(record.desktopSsh)}` : null,
7081358 roleLabel,
7091359 record.lastConnectedAt
7101360 ? `Last connected ${formatAccessTimestamp(record.lastConnectedAt)}`
......@@ -723,23 +1373,35 @@ function SavedBackendListRow({
7231373 connectionState === "connecting" ? "bg-warning/60 duration-2000" : null
7241374 }
7251375 />
726 <h3 className="text-sm font-medium text-foreground">{record.label}</h3>
1376 <h3 className="text-sm font-medium text-foreground">{displayLabel}</h3>
7271377 </div>
7281378 {metadataBits.length > 0 ? (
7291379 <p className="text-xs text-muted-foreground">{metadataBits.join(" · ")}</p>
7301380 ) : null}
731 {descriptorLabel && descriptorLabel !== record.label ? (
732 <p className="text-xs text-muted-foreground">Server label: {descriptorLabel}</p>
1381 {versionMismatch ? (
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>
7331387 ) : null}
7341388 </div>
7351389 <div className="flex w-full shrink-0 items-center gap-2 sm:w-auto sm:justify-end">
7361390 <Button
7371391 size="xs"
7381392 variant="outline"
739 disabled={reconnectingEnvironmentId === environmentId}
740 onClick={() => void onReconnect(environmentId)}
1393 disabled={isConnected ? isDisconnecting : isConnecting}
1394 onClick={() =>
1395 void (isConnected ? onDisconnect(environmentId) : onConnect(environmentId))
1396 }
7411397 >
742 {reconnectingEnvironmentId === environmentId ? "Reconnecting…" : "Reconnect"}
1398 {isConnected
1399 ? isDisconnecting
1400 ? "Disconnecting…"
1401 : "Disconnect"
1402 : isConnecting
1403 ? "Connecting…"
1404 : "Connect"}
7431405 </Button>
7441406 <Button
7451407 size="xs"
......@@ -755,6 +1417,46 @@ function SavedBackendListRow({
7551417 );
7561418}
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
7581460export function ConnectionsSettings() {
7591461 const desktopBridge = window.desktopBridge;
7601462 const [currentSessionRole, setCurrentSessionRole] = useState<"owner" | "client" | null>(
......@@ -771,9 +1473,42 @@ export function ConnectionsSettings() {
7711473 .map((record) => record.environmentId),
7721474 [savedEnvironmentsById],
7731475 );
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
7751507 const [desktopServerExposureState, setDesktopServerExposureState] =
7761508 useState<DesktopServerExposureState | null>(null);
1509 const [desktopAdvertisedEndpoints, setDesktopAdvertisedEndpoints] = useState<
1510 ReadonlyArray<AdvertisedEndpoint>
1511 >([]);
7771512 const [desktopServerExposureError, setDesktopServerExposureError] = useState<string | null>(null);
7781513 const [desktopPairingLinks, setDesktopPairingLinks] = useState<
7791514 ReadonlyArray<ServerPairingLinkRecord>
......@@ -793,27 +1528,78 @@ export function ConnectionsSettings() {
7931528 >(null);
7941529 const [isRevokingOtherDesktopClients, setIsRevokingOtherDesktopClients] = useState(false);
7951530 const [addBackendDialogOpen, setAddBackendDialogOpen] = useState(false);
796 const [savedBackendMode, setSavedBackendMode] = useState<"pairing-url" | "host-code">(
797 "pairing-url",
798 );
799 const [savedBackendLabel, setSavedBackendLabel] = useState("");
800 const [savedBackendPairingUrl, setSavedBackendPairingUrl] = useState("");
1531 const [savedBackendMode, setSavedBackendMode] = useState<"remote" | "ssh">("remote");
8011532 const [savedBackendHost, setSavedBackendHost] = useState("");
8021533 const [savedBackendPairingCode, setSavedBackendPairingCode] = useState("");
1534 const [savedBackendSshHost, setSavedBackendSshHost] = useState("");
1535 const [savedBackendSshUsername, setSavedBackendSshUsername] = useState("");
1536 const [savedBackendSshPort, setSavedBackendSshPort] = useState("");
8031537 const [savedBackendError, setSavedBackendError] = useState<string | null>(null);
8041538 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 );
8051550 const [reconnectingSavedEnvironmentId, setReconnectingSavedEnvironmentId] =
8061551 useState<EnvironmentId | null>(null);
1552 const [disconnectingSavedEnvironmentId, setDisconnectingSavedEnvironmentId] =
1553 useState<EnvironmentId | null>(null);
8071554 const [removingSavedEnvironmentId, setRemovingSavedEnvironmentId] =
8081555 useState<EnvironmentId | null>(null);
8091556 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 );
8101565 const [pendingDesktopServerExposureMode, setPendingDesktopServerExposureMode] = useState<
8111566 DesktopServerExposureState["mode"] | null
8121567 >(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 );
8131577 const canManageLocalBackend = currentSessionRole === "owner";
8141578 const isLocalBackendNetworkAccessible = desktopBridge
8151579 ? desktopServerExposureState?.mode === "network-accessible"
8161580 : 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
8181604 const handleDesktopServerExposureChange = useCallback(
8191605 async (checked: boolean) => {
......@@ -825,12 +1611,12 @@ export function ConnectionsSettings() {
8251611 checked ? "network-accessible" : "local-only",
8261612 );
8271613 setDesktopServerExposureState(nextState);
828 setPendingDesktopServerExposureMode(null);
1614 setIsDesktopServerExposureDialogOpen(false);
8291615 setIsUpdatingDesktopServerExposure(false);
8301616 } catch (error) {
8311617 const message =
8321618 error instanceof Error ? error.message : "Failed to update network exposure.";
833 setPendingDesktopServerExposureMode(null);
1619 setIsDesktopServerExposureDialogOpen(false);
8341620 setDesktopServerExposureError(message);
8351621 toastManager.add(
8361622 stackedThreadToast({
......@@ -851,6 +1637,74 @@ export function ConnectionsSettings() {
8511637 void handleDesktopServerExposureChange(checked);
8521638 }, [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
8541708 const handleRevokeDesktopPairingLink = useCallback(async (id: string) => {
8551709 setRevokingDesktopPairingLinkId(id);
8561710 setDesktopAccessManagementError(null);
......@@ -920,22 +1774,53 @@ export function ConnectionsSettings() {
9201774 }, []);
9211775
9221776 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
9231808 setIsAddingSavedBackend(true);
9241809 setSavedBackendError(null);
9251810 try {
1811 const remotePairingInput = parseRemotePairingFields({
1812 host: savedBackendHost,
1813 pairingCode: savedBackendPairingCode,
1814 });
9261815 const record = await addSavedEnvironment({
927 label: savedBackendLabel,
928 ...(savedBackendMode === "pairing-url"
929 ? { pairingUrl: savedBackendPairingUrl }
930 : {
931 host: savedBackendHost,
932 pairingCode: savedBackendPairingCode,
933 }),
1816 label: "",
1817 ...remotePairingInput,
9341818 });
935 setSavedBackendLabel("");
936 setSavedBackendPairingUrl("");
9371819 setSavedBackendHost("");
9381820 setSavedBackendPairingCode("");
1821 setSavedBackendSshHost("");
1822 setSavedBackendSshUsername("");
1823 setSavedBackendSshPort("");
9391824 setAddBackendDialogOpen(false);
9401825 toastManager.add({
9411826 type: "success",
......@@ -957,24 +1842,25 @@ export function ConnectionsSettings() {
9571842 }
9581843 }, [
9591844 savedBackendHost,
960 savedBackendLabel,
9611845 savedBackendMode,
9621846 savedBackendPairingCode,
963 savedBackendPairingUrl,
1847 savedBackendSshHost,
1848 savedBackendSshPort,
1849 savedBackendSshUsername,
9641850 ]);
9651851
966 const handleReconnectSavedBackend = useCallback(async (environmentId: EnvironmentId) => {
1852 const handleConnectSavedBackend = useCallback(async (environmentId: EnvironmentId) => {
9671853 setReconnectingSavedEnvironmentId(environmentId);
9681854 setSavedBackendError(null);
9691855 try {
9701856 await reconnectSavedEnvironment(environmentId);
9711857 } 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.";
9731859 setSavedBackendError(message);
9741860 toastManager.add(
9751861 stackedThreadToast({
9761862 type: "error",
977 title: "Could not reconnect backend",
1863 title: "Could not connect backend",
9781864 description: message,
9791865 }),
9801866 );
......@@ -983,6 +1869,26 @@ export function ConnectionsSettings() {
9831869 }
9841870 }, []);
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
9861892 const handleRemoveSavedBackend = useCallback(async (environmentId: EnvironmentId) => {
9871893 setRemovingSavedEnvironmentId(environmentId);
9881894 setSavedBackendError(null);
......@@ -1003,6 +1909,84 @@ export function ConnectionsSettings() {
10031909 }
10041910 }, []);
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
10061990 useEffect(() => {
10071991 if (desktopBridge) {
10081992 setCurrentSessionRole("owner");
......@@ -1105,8 +2089,21 @@ export function ConnectionsSettings() {
11052089 error instanceof Error ? error.message : "Failed to load network exposure state.";
11062090 setDesktopServerExposureError(message);
11072091 });
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 });
11082104 } else {
11092105 setDesktopServerExposureState(null);
2106 setDesktopAdvertisedEndpoints([]);
11102107 setDesktopServerExposureError(null);
11112108 }
11122109
......@@ -1123,131 +2120,420 @@ export function ConnectionsSettings() {
11232120 setDesktopClientSessions([]);
11242121 setDesktopAccessManagementError(null);
11252122 setDesktopServerExposureState(null);
2123 setDesktopAdvertisedEndpoints([]);
11262124 setDesktopServerExposureError(null);
11272125 }, [canManageLocalBackend]);
11282126 const visibleDesktopPairingLinks = useMemo(
11292127 () => desktopPairingLinks.filter((pairingLink) => pairingLink.role === "client"),
11302128 [desktopPairingLinks],
11312129 );
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
11322507 return (
11332508 <SettingsPageContainer>
11342509 {canManageLocalBackend ? (
11352510 <>
11362511 <SettingsSection title="Manage local backend">
1137 {desktopBridge ? (
2512 {primaryVersionMismatch ? (
11382513 <SettingsRow
1139 title="Network access"
2514 title="Version drift"
11402515 description={
1141 desktopServerExposureState?.endpointUrl
1142 ? `Reachable at ${desktopServerExposureState.endpointUrl}`
1143 : desktopServerExposureState?.mode === "network-accessible"
1144 ? desktopServerExposureState.advertisedHost
1145 ? `Exposed on all interfaces. Pairing links use ${desktopServerExposureState.advertisedHost}.`
1146 : "Exposed on all interfaces."
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>
2516 <span className="flex items-center gap-1 text-warning">
2517 <TriangleAlertIcon className="size-3.5 shrink-0" />
2518 Client {primaryVersionMismatch.clientVersion}, server{" "}
2519 {primaryVersionMismatch.serverVersion}. Sync them if RPC calls or reconnects
2520 fail.
2521 </span>
12172522 }
12182523 />
2524 ) : null}
2525 {desktopBridge ? (
2526 <>
2527 {renderNetworkAccessRow()}
2528 {renderEndpointRows("endpoint-rail")}
2529 {renderTailscaleRow()}
2530 </>
12192531 ) : (
1220 <SettingsRow
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 />
2532 renderDisabledNetworkAccessRow()
12472533 )}
12482534 </SettingsSection>
12492535
1250 {isLocalBackendNetworkAccessible ? (
2536 {isLocalBackendRemotelyReachable ? (
12512537 <SettingsSection
12522538 title="Authorized clients"
12532539 headerAction={
......@@ -1258,23 +2544,166 @@ export function ConnectionsSettings() {
12582544 />
12592545 }
12602546 >
1261 {desktopAccessManagementError ? (
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 />
2547 {renderAuthorizedClients("current")}
12762548 </SettingsSection>
12772549 ) : 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>
12782707 </>
12792708 ) : (
12802709 <SettingsSection title="Local backend access">
......@@ -1305,120 +2734,32 @@ export function ConnectionsSettings() {
13052734 </Button>
13062735 }
13072736 />
1308 <DialogPopup>
2737 <DialogPopup className="max-h-[80dvh] sm:max-w-3xl">
13092738 <DialogHeader>
13102739 <DialogTitle>Add Environment</DialogTitle>
13112740 <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>
13402741 </DialogHeader>
13412742 <DialogPanel>
13422743 <div className="space-y-4">
1343 {savedBackendMode === "pairing-url" ? (
1344 <p className="text-xs text-muted-foreground">
1345 Enter the full pairing URL from the environment you want to connect to.
1346 </p>
1347 ) : (
1348 <p className="text-xs text-muted-foreground">
1349 Enter the backend host and pairing code separately.
1350 </p>
1351 )}
1352 <div className="space-y-3">
1353 <label className="block">
1354 <span className="mb-1.5 block text-xs font-medium text-foreground">
1355 Label
1356 </span>
1357 <Input
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 )}
2744 <div className="grid gap-3 sm:grid-cols-2">
2745 {renderConnectionModeCard({
2746 mode: "remote",
2747 title: "Remote link",
2748 description: "Enter a backend host and pairing code.",
2749 icon: <ChevronsLeftRightEllipsisIcon aria-hidden className="size-4" />,
2750 })}
2751 {desktopBridge
2752 ? renderConnectionModeCard({
2753 mode: "ssh",
2754 title: "SSH",
2755 description: "Use local SSH config, agent, and tunnels for the backend.",
2756 icon: <TerminalIcon aria-hidden className="size-4" />,
2757 })
2758 : null}
14092759 </div>
1410 {savedBackendError ? (
1411 <p className="text-xs text-destructive">{savedBackendError}</p>
1412 ) : null}
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>
2760 <AnimatedHeight>
2761 {savedBackendMode === "ssh" ? renderSshFields() : renderRemoteModeBody()}
2762 </AnimatedHeight>
14222763 </div>
14232764 </DialogPanel>
14242765 </DialogPopup>
......@@ -1430,8 +2771,10 @@ export function ConnectionsSettings() {
14302771 key={environmentId}
14312772 environmentId={environmentId}
14322773 reconnectingEnvironmentId={reconnectingSavedEnvironmentId}
2774 disconnectingEnvironmentId={disconnectingSavedEnvironmentId}
14332775 removingEnvironmentId={removingSavedEnvironmentId}
1434 onReconnect={handleReconnectSavedBackend}
2776 onConnect={handleConnectSavedBackend}
2777 onDisconnect={handleDisconnectSavedBackend}
14352778 onRemove={handleRemoveSavedBackend}
14362779 />
14372780 ))}
apps/web/src/components/settings/SettingsPanels.browser.tsx+309-2
......@@ -21,6 +21,7 @@ import { render } from "vitest-browser-react";
2121import { __resetLocalApiForTests } from "../../localApi";
2222import { AppAtomRegistryProvider, resetAppAtomRegistryForTests } from "../../rpc/atomRegistry";
2323import { resetServerStateForTests, setServerConfigSnapshot } from "../../rpc/serverState";
24import { useUiStateStore } from "../../uiStateStore";
2425import { ConnectionsSettings } from "./ConnectionsSettings";
2526import { GeneralSettingsPanel } from "./SettingsPanels";
2627import { SourceControlSettingsPanel } from "./SourceControlSettings";
......@@ -115,6 +116,8 @@ const authAccessHarness = vi.hoisted(() => {
115116 };
116117});
117118
119const mockConnectDesktopSshEnvironment = vi.hoisted(() => vi.fn());
120
118121vi.mock("../../environments/runtime", () => {
119122 const primaryConnection = {
120123 kind: "primary" as const,
......@@ -152,6 +155,7 @@ vi.mock("../../environments/runtime", () => {
152155 new URL(path, "http://localhost:3000").toString(),
153156 waitForSavedEnvironmentRegistryHydration: async () => undefined,
154157 addSavedEnvironment: vi.fn(),
158 connectDesktopSshEnvironment: mockConnectDesktopSshEnvironment,
155159 disconnectSavedEnvironment: vi.fn(),
156160 ensureEnvironmentConnectionBootstrapped: async () => undefined,
157161 getPrimaryEnvironmentConnection: () => primaryConnection,
......@@ -259,7 +263,9 @@ function makeClientSession(input: {
259263}
260264
261265const createDesktopBridgeStub = (overrides?: {
266 readonly discoverSshHosts?: DesktopBridge["discoverSshHosts"];
262267 readonly serverExposureState?: Awaited<ReturnType<DesktopBridge["getServerExposureState"]>>;
268 readonly advertisedEndpoints?: Awaited<ReturnType<DesktopBridge["getAdvertisedEndpoints"]>>;
263269 readonly setServerExposureMode?: DesktopBridge["setServerExposureMode"];
264270 readonly setUpdateChannel?: DesktopBridge["setUpdateChannel"];
265271}): DesktopBridge => {
......@@ -295,11 +301,58 @@ const createDesktopBridgeStub = (overrides?: {
295301 getSavedEnvironmentSecret: vi.fn().mockResolvedValue(null),
296302 setSavedEnvironmentSecret: vi.fn().mockResolvedValue(true),
297303 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),
298349 getServerExposureState: vi.fn().mockResolvedValue(
299350 overrides?.serverExposureState ?? {
300351 mode: "local-only",
301352 endpointUrl: null,
302353 advertisedHost: null,
354 tailscaleServeEnabled: false,
355 tailscaleServePort: 443,
303356 },
304357 ),
305358 setServerExposureMode:
......@@ -308,7 +361,17 @@ const createDesktopBridgeStub = (overrides?: {
308361 mode,
309362 endpointUrl: mode === "network-accessible" ? "http://192.168.1.44:3773" : null,
310363 advertisedHost: mode === "network-accessible" ? "192.168.1.44" : null,
364 tailscaleServeEnabled: false,
365 tailscaleServePort: 443,
311366 })),
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 ?? []),
312375 pickFolder: vi.fn().mockResolvedValue(null),
313376 confirm: vi.fn().mockResolvedValue(false),
314377 setTheme: vi.fn().mockResolvedValue(undefined),
......@@ -345,7 +408,9 @@ describe("GeneralSettingsPanel observability", () => {
345408 resetServerStateForTests();
346409 await __resetLocalApiForTests();
347410 localStorage.clear();
411 useUiStateStore.setState({ defaultAdvertisedEndpointKey: null });
348412 authAccessHarness.reset();
413 mockConnectDesktopSshEnvironment.mockReset();
349414 });
350415
351416 afterEach(async () => {
......@@ -431,6 +496,176 @@ describe("GeneralSettingsPanel observability", () => {
431496 .toBeInTheDocument();
432497 });
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
434669 it("shows diagnostics inside About with a single logs-folder action", async () => {
435670 setServerConfigSnapshot(createBaseServerConfig());
436671
......@@ -461,6 +696,8 @@ describe("GeneralSettingsPanel observability", () => {
461696 mode: "network-accessible",
462697 endpointUrl: "http://192.168.1.44:3773",
463698 advertisedHost: "192.168.1.44",
699 tailscaleServeEnabled: false,
700 tailscaleServePort: 443,
464701 },
465702 });
466703 let pairingLinks: Array<AuthAccessSnapshot["pairingLinks"][number]> = [];
......@@ -566,7 +803,7 @@ describe("GeneralSettingsPanel observability", () => {
566803 .element(page.getByText("Client · Mobile · iOS · Safari · 192.168.1.88"))
567804 .toBeInTheDocument();
568805 await expect
569 .element(page.getByRole("button", { name: /^(Copy|Show link)$/ }))
806 .element(page.getByRole("button", { name: /^Copy pairing URL for:/ }))
570807 .toBeInTheDocument();
571808 await expect.element(page.getByText("Revoke others")).toBeInTheDocument();
572809 });
......@@ -577,6 +814,8 @@ describe("GeneralSettingsPanel observability", () => {
577814 mode: "network-accessible",
578815 endpointUrl: "http://192.168.1.44:3773",
579816 advertisedHost: "192.168.1.44",
817 tailscaleServeEnabled: false,
818 tailscaleServePort: 443,
580819 },
581820 });
582821 let clientSessions: Array<AuthAccessSnapshot["clientSessions"][number]> = [
......@@ -677,9 +916,77 @@ describe("GeneralSettingsPanel observability", () => {
677916 await vi.waitFor(() => {
678917 expect(desktopBridge.setServerExposureMode).toHaveBeenCalledWith("network-accessible");
679918 });
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 });
680967 await expect
681 .element(page.getByText("Reachable at http://192.168.1.44:3773"))
968 .element(page.getByRole("heading", { name: "devbox", exact: true }))
682969 .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 });
683990 });
684991
685992 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({
5252 children,
5353}: {
5454 title: ReactNode;
55 description: string;
55 description: ReactNode;
5656 status?: ReactNode;
5757 resetAction?: ReactNode;
5858 control?: ReactNode;
apps/web/src/components/ui/alert-dialog.tsx+2-2
......@@ -18,7 +18,7 @@ function AlertDialogBackdrop({ className, ...props }: AlertDialogPrimitive.Backd
1818 return (
1919 <AlertDialogPrimitive.Backdrop
2020 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",
2222 className,
2323 )}
2424 data-slot="alert-dialog-backdrop"
......@@ -31,7 +31,7 @@ function AlertDialogViewport({ className, ...props }: AlertDialogPrimitive.Viewp
3131 return (
3232 <AlertDialogPrimitive.Viewport
3333 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",
3535 className,
3636 )}
3737 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)
2424 return (
2525 <DialogPrimitive.Backdrop
2626 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",
2828 className,
2929 )}
3030 data-slot="dialog-backdrop"
......@@ -37,7 +37,7 @@ function DialogViewport({ className, ...props }: DialogPrimitive.Viewport.Props)
3737 return (
3838 <DialogPrimitive.Viewport
3939 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",
4141 className,
4242 )}
4343 data-slot="dialog-viewport"
apps/web/src/components/ui/scroll-area.tsx+1-1
......@@ -23,7 +23,7 @@ function ScrollArea({
2323 >
2424 <ScrollAreaPrimitive.Viewport
2525 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",
2727 scrollFade &&
2828 "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]",
2929 scrollbarGutter && "[scrollbar-gutter:stable]",
apps/web/src/environments/remote/api.test.ts+13
......@@ -48,6 +48,19 @@ describe("remote environment api", () => {
4848 });
4949 });
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
5164 it("derives backend urls from a host and pairing code", () => {
5265 expect(
5366 resolveRemotePairingTarget({
apps/web/src/environments/remote/api.ts+6
......@@ -15,6 +15,12 @@ class RemoteEnvironmentAuthHttpError extends Error {
1515 }
1616}
1717
18export function isRemoteEnvironmentAuthHttpError(
19 error: unknown,
20): error is RemoteEnvironmentAuthHttpError {
21 return error instanceof RemoteEnvironmentAuthHttpError;
22}
23
1824function remoteEndpointUrl(httpBaseUrl: string, pathname: string): string {
1925 const url = new URL(httpBaseUrl);
2026 url.pathname = pathname;
apps/web/src/environments/remote/target.ts+11
......@@ -1,4 +1,5 @@
11import { getPairingTokenFromUrl } from "../../pairingUrl";
2import { readHostedPairingRequest } from "../../hostedPairing";
23
34export interface ResolvedRemotePairingTarget {
45 readonly credential: string;
......@@ -57,6 +58,16 @@ export function resolveRemotePairingTarget(input: {
5758 const pairingUrl = input.pairingUrl?.trim() ?? "";
5859 if (pairingUrl.length > 0) {
5960 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
6071 const credential = getPairingTokenFromUrl(url) ?? "";
6172 if (!credential) {
6273 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 {
1818 readonly httpBaseUrl: string;
1919 readonly createdAt: string;
2020 readonly lastConnectedAt: string | null;
21 readonly desktopSsh?: PersistedSavedEnvironmentRecord["desktopSsh"];
2122}
2223
2324interface SavedEnvironmentRegistryState {
......@@ -28,13 +29,14 @@ interface SavedEnvironmentRegistryStore extends SavedEnvironmentRegistryState {
2829 readonly upsert: (record: SavedEnvironmentRecord) => void;
2930 readonly remove: (environmentId: EnvironmentId) => void;
3031 readonly markConnected: (environmentId: EnvironmentId, connectedAt: string) => void;
32 readonly rename: (environmentId: EnvironmentId, label: string) => void;
3133 readonly reset: () => void;
3234}
3335
3436let savedEnvironmentRegistryHydrated = false;
3537let savedEnvironmentRegistryHydrationPromise: Promise<void> | null = null;
3638
37function toPersistedSavedEnvironmentRecord(
39export function toPersistedSavedEnvironmentRecord(
3840 record: SavedEnvironmentRecord,
3941): PersistedSavedEnvironmentRecord {
4042 return {
......@@ -44,6 +46,7 @@ function toPersistedSavedEnvironmentRecord(
4446 wsBaseUrl: record.wsBaseUrl,
4547 createdAt: record.createdAt,
4648 lastConnectedAt: record.lastConnectedAt,
49 ...(record.desktopSsh ? { desktopSsh: record.desktopSsh } : {}),
4750 };
4851}
4952
......@@ -148,6 +151,23 @@ export const useSavedEnvironmentRegistryStore = create<SavedEnvironmentRegistryS
148151 persistSavedEnvironmentRegistryState(byId);
149152 return { byId };
150153 }),
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 }),
151171 reset: () => {
152172 persistSavedEnvironmentRegistryState({});
153173 set({
apps/web/src/environments/runtime/index.ts+1
......@@ -16,6 +16,7 @@ export {
1616
1717export {
1818 addSavedEnvironment,
19 connectDesktopSshEnvironment,
1920 disconnectSavedEnvironment,
2021 ensureEnvironmentConnectionBootstrapped,
2122 getPrimaryEnvironmentConnection,
apps/web/src/environments/runtime/service.addSavedEnvironment.test.ts+819-18
......@@ -1,14 +1,60 @@
11import { 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
46const mockResolveRemotePairingTarget = vi.fn();
57const mockFetchRemoteEnvironmentDescriptor = vi.fn();
68const 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();
714const mockPersistSavedEnvironmentRecord = vi.fn();
815const mockWriteSavedEnvironmentBearerToken = vi.fn();
916const mockSetSavedEnvironmentRegistry = vi.fn();
10const mockUpsert = vi.fn();
11const mockListSavedEnvironmentRecords = vi.fn();
17const mockGetSavedEnvironmentRecord = vi.fn((environmentId: EnvironmentId) => {
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
1359vi.mock("../remote/target", () => ({
1460 resolveRemotePairingTarget: mockResolveRemotePairingTarget,
......@@ -17,8 +63,9 @@ vi.mock("../remote/target", () => ({
1763vi.mock("../remote/api", () => ({
1864 bootstrapRemoteBearerSession: mockBootstrapRemoteBearerSession,
1965 fetchRemoteEnvironmentDescriptor: mockFetchRemoteEnvironmentDescriptor,
20 fetchRemoteSessionState: vi.fn(),
21 resolveRemoteWebSocketConnectionUrl: vi.fn(),
66 fetchRemoteSessionState: mockFetchRemoteSessionState,
67 isRemoteEnvironmentAuthHttpError: mockIsRemoteEnvironmentAuthHttpError,
68 resolveRemoteWebSocketConnectionUrl: mockResolveRemoteWebSocketConnectionUrl,
2269}));
2370
2471vi.mock("~/localApi", () => ({
......@@ -30,24 +77,28 @@ vi.mock("~/localApi", () => ({
3077}));
3178
3279vi.mock("./catalog", () => ({
33 getSavedEnvironmentRecord: vi.fn(),
80 getSavedEnvironmentRecord: mockGetSavedEnvironmentRecord,
3481 hasSavedEnvironmentRegistryHydrated: vi.fn(),
3582 listSavedEnvironmentRecords: mockListSavedEnvironmentRecords,
3683 persistSavedEnvironmentRecord: mockPersistSavedEnvironmentRecord,
37 readSavedEnvironmentBearerToken: vi.fn(),
38 removeSavedEnvironmentBearerToken: vi.fn(),
84 readSavedEnvironmentBearerToken: mockReadSavedEnvironmentBearerToken,
85 removeSavedEnvironmentBearerToken: mockRemoveSavedEnvironmentBearerToken,
86 toPersistedSavedEnvironmentRecord: mockToPersistedSavedEnvironmentRecord,
3987 useSavedEnvironmentRegistryStore: {
4088 getState: () => ({
4189 upsert: mockUpsert,
42 remove: vi.fn(),
43 markConnected: vi.fn(),
90 remove: mockRemove,
91 markConnected: mockMarkConnected,
92 rename: mockRename,
4493 }),
94 setState: mockRegistrySetState,
95 subscribe: vi.fn(() => () => {}),
4596 },
4697 useSavedEnvironmentRuntimeStore: {
4798 getState: () => ({
4899 ensure: vi.fn(),
49 patch: vi.fn(),
50 clear: vi.fn(),
100 patch: mockPatchRuntime,
101 clear: mockClearRuntime,
51102 }),
52103 },
53104 waitForSavedEnvironmentRegistryHydration: vi.fn(),
......@@ -55,18 +106,58 @@ vi.mock("./catalog", () => ({
55106}));
56107
57108vi.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(),
59125}));
60126
61127describe("addSavedEnvironment", () => {
128 afterEach(() => {
129 vi.useRealTimers();
130 });
131
62132 beforeEach(() => {
63133 vi.resetModules();
64134 vi.clearAllMocks();
65 mockResolveRemotePairingTarget.mockReturnValue({
66 httpBaseUrl: "https://remote.example.com/",
67 wsBaseUrl: "wss://remote.example.com/",
68 credential: "pairing-code",
135 mockSavedRecords = [];
136 vi.stubGlobal("window", {
137 desktopBridge: {
138 ensureSshEnvironment: mockEnsureSshEnvironment,
139 disconnectSshEnvironment: mockDisconnectSshEnvironment,
140 fetchSshEnvironmentDescriptor: mockFetchSshEnvironmentDescriptor,
141 bootstrapSshBearerSession: mockBootstrapSshBearerSession,
142 fetchSshSessionState: mockFetchSshSessionState,
143 issueSshWebSocketToken: vi.fn(),
144 },
69145 });
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 );
70161 mockFetchRemoteEnvironmentDescriptor.mockResolvedValue({
71162 environmentId: EnvironmentId.make("environment-1"),
72163 label: "Remote environment",
......@@ -75,10 +166,60 @@ describe("addSavedEnvironment", () => {
75166 sessionToken: "bearer-token",
76167 role: "owner",
77168 });
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 });
78185 mockPersistSavedEnvironmentRecord.mockResolvedValue(undefined);
79186 mockWriteSavedEnvironmentBearerToken.mockResolvedValue(false);
80187 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);
82223 });
83224
84225 it("rolls back persisted metadata when bearer token persistence fails", async () => {
......@@ -102,4 +243,664 @@ describe("addSavedEnvironment", () => {
102243
103244 await resetEnvironmentServiceForTests();
104245 });
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 });
105906});
apps/web/src/environments/runtime/service.threadSubscriptions.test.ts+183-22
......@@ -15,31 +15,36 @@ const mockCreateEnvironmentConnection = vi.fn();
1515const mockCreateWsRpcClient = vi.fn();
1616const mockWaitForSavedEnvironmentRegistryHydration = vi.fn();
1717const mockListSavedEnvironmentRecords = vi.fn();
18const mockGetSavedEnvironmentRecord = vi.fn();
19const mockReadSavedEnvironmentBearerToken = vi.fn();
1820const 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
2026function MockWsTransport() {
2127 return undefined;
2228}
2329
2430vi.mock("../primary", () => ({
25 getPrimaryKnownEnvironment: vi.fn(() => ({
26 id: "env-1",
27 label: "Primary environment",
28 source: "window-origin",
29 target: {
30 httpBaseUrl: "http://127.0.0.1:3000/",
31 wsBaseUrl: "ws://127.0.0.1:3000/",
32 },
33 environmentId: EnvironmentId.make("env-1"),
34 })),
31 getPrimaryKnownEnvironment: mockGetPrimaryKnownEnvironment,
32}));
33
34vi.mock("../remote/api", () => ({
35 bootstrapRemoteBearerSession: vi.fn(),
36 fetchRemoteEnvironmentDescriptor: vi.fn(),
37 fetchRemoteSessionState: mockFetchRemoteSessionState,
38 isRemoteEnvironmentAuthHttpError: vi.fn(() => false),
39 resolveRemoteWebSocketConnectionUrl: vi.fn(async () => "ws://remote.example.test/ws"),
3540}));
3641
3742vi.mock("./catalog", () => ({
38 getSavedEnvironmentRecord: vi.fn(),
43 getSavedEnvironmentRecord: mockGetSavedEnvironmentRecord,
3944 hasSavedEnvironmentRegistryHydrated: vi.fn(() => true),
4045 listSavedEnvironmentRecords: mockListSavedEnvironmentRecords,
4146 persistSavedEnvironmentRecord: vi.fn(),
42 readSavedEnvironmentBearerToken: vi.fn(),
47 readSavedEnvironmentBearerToken: mockReadSavedEnvironmentBearerToken,
4348 removeSavedEnvironmentBearerToken: vi.fn(),
4449 useSavedEnvironmentRegistryStore: {
4550 subscribe: mockSavedEnvironmentRegistrySubscribe,
......@@ -47,6 +52,7 @@ vi.mock("./catalog", () => ({
4752 upsert: vi.fn(),
4853 remove: vi.fn(),
4954 markConnected: vi.fn(),
55 rename: vi.fn(),
5056 }),
5157 },
5258 useSavedEnvironmentRuntimeStore: {
......@@ -145,31 +151,72 @@ describe("retainThreadDetailSubscription", () => {
145151 vi.useFakeTimers();
146152 vi.resetModules();
147153 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
149165 mockThreadUnsubscribe.mockImplementation(() => undefined);
150166 mockSubscribeThread.mockImplementation(() => mockThreadUnsubscribe);
151167 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 },
152179 orchestration: {
153180 subscribeThread: mockSubscribeThread,
154181 },
155182 });
156 mockCreateEnvironmentConnection.mockImplementation((input) => ({
157 kind: input.kind,
158 environmentId: input.knownEnvironment.environmentId,
159 knownEnvironment: input.knownEnvironment,
160 client: input.client,
161 ensureBootstrapped: vi.fn(async () => undefined),
162 reconnect: vi.fn(async () => undefined),
163 dispose: vi.fn(async () => undefined),
164 }));
165 mockSavedEnvironmentRegistrySubscribe.mockReturnValue(() => undefined);
183 mockCreateEnvironmentConnection.mockImplementation((input) => {
184 const reconnect = vi.fn(async () => undefined);
185 mockConnectionReconnects.push(reconnect);
186 return {
187 kind: input.kind,
188 environmentId: input.knownEnvironment.environmentId,
189 knownEnvironment: input.knownEnvironment,
190 client: input.client,
191 ensureBootstrapped: vi.fn(async () => 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 });
166205 mockWaitForSavedEnvironmentRegistryHydration.mockResolvedValue(undefined);
167206 mockListSavedEnvironmentRecords.mockReturnValue([]);
207 mockGetSavedEnvironmentRecord.mockReturnValue(null);
208 mockReadSavedEnvironmentBearerToken.mockResolvedValue(null);
209 mockFetchRemoteSessionState.mockResolvedValue({
210 authenticated: true,
211 role: "client",
212 });
213 mockConnectionReconnects.length = 0;
168214 });
169215
170216 afterEach(async () => {
171217 const { resetEnvironmentServiceForTests } = await import("./service");
172218 await resetEnvironmentServiceForTests();
219 vi.unstubAllGlobals();
173220 vi.useRealTimers();
174221 });
175222
......@@ -204,6 +251,31 @@ describe("retainThreadDetailSubscription", () => {
204251 await resetEnvironmentServiceForTests();
205252 });
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
207279 it("keeps non-idle thread detail subscriptions attached until the thread becomes idle", async () => {
208280 const {
209281 retainThreadDetailSubscription,
......@@ -253,6 +325,95 @@ describe("retainThreadDetailSubscription", () => {
253325 await resetEnvironmentServiceForTests();
254326 });
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
256417 it("allows a larger idle cache before capacity eviction starts", async () => {
257418 const {
258419 retainThreadDetailSubscription,
apps/web/src/environments/runtime/service.ts+647-111
......@@ -1,5 +1,7 @@
11import {
22 type AuthSessionRole,
3 type DesktopSshEnvironmentBootstrap,
4 type DesktopSshEnvironmentTarget,
35 type EnvironmentId,
46 type OrchestrationEvent,
57 type OrchestrationShellSnapshot,
......@@ -33,6 +35,7 @@ import {
3335 bootstrapRemoteBearerSession,
3436 fetchRemoteEnvironmentDescriptor,
3537 fetchRemoteSessionState,
38 isRemoteEnvironmentAuthHttpError,
3639 resolveRemoteWebSocketConnectionUrl,
3740} from "../remote/api";
3841import { resolveRemotePairingTarget } from "../remote/target";
......@@ -44,6 +47,7 @@ import {
4447 readSavedEnvironmentBearerToken,
4548 removeSavedEnvironmentBearerToken,
4649 type SavedEnvironmentRecord,
50 toPersistedSavedEnvironmentRecord,
4751 useSavedEnvironmentRegistryStore,
4852 useSavedEnvironmentRuntimeStore,
4953 waitForSavedEnvironmentRegistryHydration,
......@@ -59,8 +63,11 @@ import {
5963} from "~/store";
6064import { useTerminalStateStore } from "~/terminalStateStore";
6165import { useUiStateStore } from "~/uiStateStore";
66import type { WsProtocolCloseContext } from "../../rpc/protocol";
67import { getServerConfig } from "../../rpc/serverState";
6268import { WsTransport } from "../../rpc/wsTransport";
6369import { createWsRpcClient, type WsRpcClient } from "../../rpc/wsRpcClient";
70import { appendVersionMismatchHint, resolveServerConfigVersionMismatch } from "../../versionSkew";
6471import {
6572 deriveLogicalProjectKeyFromSettings,
6673 derivePhysicalProjectKey,
......@@ -85,6 +92,28 @@ type ThreadDetailSubscriptionEntry = {
8592};
8693
8794const 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>();
88117const environmentConnectionListeners = new Set<() => void>();
89118const threadDetailSubscriptions = new Map<string, ThreadDetailSubscriptionEntry>();
90119const lastAppliedProjectionVersionByEnvironment = new Map<
......@@ -97,6 +126,8 @@ const lastAppliedProjectionVersionByEnvironment = new Map<
97126
98127let activeService: EnvironmentServiceState | null = null;
99128let needsProviderInvalidation = false;
129let lastBrowserHiddenAt: number | null = null;
130let lastBrowserResumeReconnectAt = Number.NEGATIVE_INFINITY;
100131
101132// Thread detail subscription cache policy:
102133// - Active consumers keep a subscription retained via refCount.
......@@ -107,7 +138,9 @@ let needsProviderInvalidation = false;
107138// - Capacity eviction only targets idle cached subscriptions.
108139const THREAD_DETAIL_SUBSCRIPTION_IDLE_EVICTION_MS = 15 * 60 * 1000;
109140const MAX_CACHED_THREAD_DETAIL_SUBSCRIPTIONS = 32;
141const BROWSER_RESUME_RECONNECT_COOLDOWN_MS = 2_000;
110142const NOOP = () => undefined;
143const SSH_HTTP_STATUS_RE = /^\[ssh_http:(\d+)\]\s/u;
111144
112145function compareAppliedProjectionVersion(
113146 left: { readonly sequence: number; readonly updatedAt: string | null },
......@@ -327,6 +360,25 @@ function disposeThreadDetailSubscriptionsForEnvironment(environmentId: Environme
327360 }
328361}
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
330382function reconcileThreadDetailSubscriptionsForEnvironment(
331383 environmentId: EnvironmentId,
332384 threadIds: ReadonlyArray<ThreadId>,
......@@ -491,6 +543,227 @@ function isoNow(): string {
491543 return new Date().toISOString();
492544}
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
494767function setRuntimeConnecting(environmentId: EnvironmentId) {
495768 useSavedEnvironmentRuntimeStore.getState().patch(environmentId, {
496769 connectionState: "connecting",
......@@ -797,40 +1070,80 @@ function createPrimaryEnvironmentClient(
7971070 `Unable to resolve websocket URL for ${knownEnvironment?.label ?? "primary environment"}.`,
7981071 );
7991072 }
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 );
8021082}
8031083
8041084function createSavedEnvironmentClient(
805 record: SavedEnvironmentRecord,
1085 environmentId: EnvironmentId,
8061086 bearerToken: string,
8071087): WsRpcClient {
808 useSavedEnvironmentRuntimeStore.getState().ensure(record.environmentId);
1088 useSavedEnvironmentRuntimeStore.getState().ensure(environmentId);
8091089
8101090 return createWsRpcClient(
8111091 new WsTransport(
812 () =>
813 resolveRemoteWebSocketConnectionUrl({
814 wsBaseUrl: record.wsBaseUrl,
815 httpBaseUrl: record.httpBaseUrl,
816 bearerToken,
817 }),
1092 async () => {
1093 const record = getSavedEnvironmentRecord(environmentId);
1094 if (!record) {
1095 throw new Error(`Saved environment ${environmentId} not found.`);
1096 }
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 },
8181109 {
1110 getConnectionLabel: () => getSavedEnvironmentRecord(environmentId)?.label ?? null,
1111 getVersionMismatchHint: () =>
1112 resolveServerConfigVersionMismatch(
1113 useSavedEnvironmentRuntimeStore.getState().byId[environmentId]?.serverConfig,
1114 )?.hint ?? null,
8191115 onAttempt: () => {
820 setRuntimeConnecting(record.environmentId);
1116 setRuntimeConnecting(environmentId);
8211117 },
8221118 onOpen: () => {
823 setRuntimeConnected(record.environmentId);
1119 setRuntimeConnected(environmentId);
8241120 },
8251121 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, {
8271126 connectionState: "error",
828 lastError: message,
1127 lastError: appendVersionMismatchHint(message, mismatch),
8291128 lastErrorAt: isoNow(),
8301129 });
8311130 },
832 onClose: (details: { readonly code: number; readonly reason: string }) => {
833 setRuntimeDisconnected(record.environmentId, details.reason);
1131 onClose: (
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 );
8341147 },
8351148 },
8361149 ),
......@@ -838,18 +1151,25 @@ function createSavedEnvironmentClient(
8381151}
8391152
8401153async function refreshSavedEnvironmentMetadata(
841 record: SavedEnvironmentRecord,
1154 environmentId: EnvironmentId,
8421155 bearerToken: string,
8431156 client: WsRpcClient,
8441157 roleHint?: AuthSessionRole | null,
8451158 configHint?: ServerConfig | null,
8461159): Promise<void> {
1160 const record = getSavedEnvironmentRecord(environmentId);
1161 if (!record) {
1162 throw new Error(`Saved environment ${environmentId} not found.`);
1163 }
1164
8471165 const [serverConfig, sessionState] = await Promise.all([
8481166 configHint ? Promise.resolve(configHint) : client.server.getConfig(),
849 fetchRemoteSessionState({
850 httpBaseUrl: record.httpBaseUrl,
851 bearerToken,
852 }),
1167 record.desktopSsh
1168 ? fetchDesktopSshSessionState(record.httpBaseUrl, bearerToken)
1169 : fetchRemoteSessionState({
1170 httpBaseUrl: record.httpBaseUrl,
1171 bearerToken,
1172 }),
8531173 ]);
8541174
8551175 useSavedEnvironmentRuntimeStore.getState().patch(record.environmentId, {
......@@ -858,6 +1178,9 @@ async function refreshSavedEnvironmentMetadata(
8581178 serverConfig,
8591179 role: sessionState.authenticated ? (sessionState.role ?? roleHint ?? null) : null,
8601180 });
1181 useSavedEnvironmentRegistryStore
1182 .getState()
1183 .rename(record.environmentId, serverConfig.environment.label);
8611184}
8621185
8631186function registerConnection(connection: EnvironmentConnection): EnvironmentConnection {
......@@ -866,6 +1189,7 @@ function registerConnection(connection: EnvironmentConnection): EnvironmentConne
8661189 throw new Error(`Environment ${connection.environmentId} already has an active connection.`);
8671190 }
8681191 environmentConnections.set(connection.environmentId, connection);
1192 attachThreadDetailSubscriptionsForEnvironment(connection.environmentId);
8691193 emitEnvironmentConnectionRegistryChange();
8701194 return connection;
8711195}
......@@ -876,10 +1200,10 @@ async function removeConnection(environmentId: EnvironmentId): Promise<boolean>
8761200 return false;
8771201 }
8781202
879 disposeThreadDetailSubscriptionsForEnvironment(environmentId);
8801203 lastAppliedProjectionVersionByEnvironment.delete(environmentId);
8811204 environmentConnections.delete(environmentId);
8821205 emitEnvironmentConnectionRegistryChange();
1206 detachThreadDetailSubscriptionsForEnvironment(environmentId);
8831207 await connection.dispose();
8841208 return true;
8851209}
......@@ -905,6 +1229,10 @@ function createPrimaryEnvironmentConnection(): EnvironmentConnection {
9051229 );
9061230}
9071231
1232function maybeCreatePrimaryEnvironmentConnection(): EnvironmentConnection | null {
1233 return getPrimaryKnownEnvironment()?.environmentId ? createPrimaryEnvironmentConnection() : null;
1234}
1235
9081236async function ensureSavedEnvironmentConnection(
9091237 record: SavedEnvironmentRecord,
9101238 options?: {
......@@ -919,69 +1247,144 @@ async function ensureSavedEnvironmentConnection(
9191247 return existing;
9201248 }
9211249
922 const bearerToken =
923 options?.bearerToken ?? (await readSavedEnvironmentBearerToken(record.environmentId));
924 if (!bearerToken) {
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.");
1250 const pending = pendingSavedEnvironmentConnections.get(record.environmentId);
1251 if (pending) {
1252 return pending.promise;
9331253 }
9341254
935 const client = options?.client ?? createSavedEnvironmentClient(record, bearerToken);
936 const knownEnvironment = createKnownEnvironment({
937 id: record.environmentId,
938 label: record.label,
939 source: "manual",
940 target: {
941 httpBaseUrl: record.httpBaseUrl,
942 wsBaseUrl: record.wsBaseUrl,
943 },
944 });
945 const connection = createEnvironmentConnection({
946 kind: "saved",
947 knownEnvironment: {
948 ...knownEnvironment,
949 environmentId: record.environmentId,
950 },
951 client,
952 refreshMetadata: async () => {
953 await refreshSavedEnvironmentMetadata(record, bearerToken, client);
954 },
955 onConfigSnapshot: (config) => {
956 useSavedEnvironmentRuntimeStore.getState().patch(record.environmentId, {
957 descriptor: config.environment,
958 serverConfig: config,
1255 const pendingEntry: PendingSavedEnvironmentConnection = {
1256 cancelled: false,
1257 promise: Promise.resolve().then(async () => {
1258 let activeRecord = record;
1259 let roleHint = options?.role ?? null;
1260 let bearerToken =
1261 options?.bearerToken ?? (await readSavedEnvironmentBearerToken(record.environmentId));
1262 if (!bearerToken) {
1263 if (record.desktopSsh) {
1264 const issued = await issueDesktopSshBearerSession(record);
1265 activeRecord = issued.record;
1266 bearerToken = issued.bearerToken;
1267 roleHint = issued.role;
1268 } else {
1269 useSavedEnvironmentRuntimeStore.getState().patch(record.environmentId, {
1270 authState: "requires-auth",
1271 role: null,
1272 connectionState: "disconnected",
1273 lastError: "Saved environment is missing its saved credential. Pair it again.",
1274 lastErrorAt: isoNow(),
1275 });
1276 throw new Error("Saved environment is missing its saved credential.");
1277 }
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 },
9591295 });
960 },
961 onWelcome: (payload) => {
962 useSavedEnvironmentRuntimeStore.getState().patch(record.environmentId, {
963 descriptor: payload.environment,
1296 const connection = createEnvironmentConnection({
1297 kind: "saved",
1298 knownEnvironment: {
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(),
9641322 });
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 {
972 await refreshSavedEnvironmentMetadata(
973 record,
974 bearerToken,
975 client,
976 options?.role ?? null,
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 }
1382 pendingSavedEnvironmentConnections.set(record.environmentId, pendingEntry);
1383 return await pendingEntry.promise.finally(() => {
1384 if (pendingSavedEnvironmentConnections.get(record.environmentId) === pendingEntry) {
1385 pendingSavedEnvironmentConnections.delete(record.environmentId);
1386 }
1387 });
9851388}
9861389
9871390async function syncSavedEnvironmentConnections(
......@@ -1006,6 +1409,55 @@ function stopActiveService() {
10061409 activeService = null;
10071410}
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
10091461export function subscribeEnvironmentConnections(listener: () => void): () => void {
10101462 environmentConnectionListeners.add(listener);
10111463 return () => {
......@@ -1036,13 +1488,23 @@ export function getPrimaryEnvironmentConnection(): EnvironmentConnection {
10361488}
10371489
10381490export 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 }
10391497 const connection = environmentConnections.get(environmentId);
1040 if (connection?.kind !== "saved") {
1041 return;
1498
1499 if (connection?.kind === "saved") {
1500 await removeConnection(environmentId).catch(() => false);
10421501 }
1502 setRuntimeDisconnected(environmentId);
10431503
1044 useSavedEnvironmentRuntimeStore.getState().clear(environmentId);
1045 await removeConnection(environmentId).catch(() => false);
1504 if (record?.desktopSsh && typeof window !== "undefined") {
1505 await window.desktopBridge?.disconnectSshEnvironment(record.desktopSsh);
1506 await removeSavedEnvironmentBearerToken(environmentId);
1507 }
10461508}
10471509
10481510export async function reconnectSavedEnvironment(environmentId: EnvironmentId): Promise<void> {
......@@ -1053,23 +1515,57 @@ export async function reconnectSavedEnvironment(environmentId: EnvironmentId): P
10531515
10541516 const connection = environmentConnections.get(environmentId);
10551517 if (!connection) {
1056 await ensureSavedEnvironmentConnection(record);
1057 return;
1518 setRuntimeConnecting(environmentId);
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 }
10581529 }
10591530
10601531 setRuntimeConnecting(environmentId);
10611532 try {
1533 if (record.desktopSsh) {
1534 await prepareSavedEnvironmentRecordForConnection(record);
1535 }
10621536 await connection.reconnect();
10631537 } 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 }
10641557 setRuntimeError(environmentId, error);
10651558 throw error;
10661559 }
10671560}
10681561
10691562export async function removeSavedEnvironment(environmentId: EnvironmentId): Promise<void> {
1563 await disconnectSavedEnvironment(environmentId);
1564 disposeThreadDetailSubscriptionsForEnvironment(environmentId);
10701565 useSavedEnvironmentRegistryStore.getState().remove(environmentId);
1566 useSavedEnvironmentRuntimeStore.getState().clear(environmentId);
1567 useStore.getState().removeEnvironmentState(environmentId);
10711568 await removeSavedEnvironmentBearerToken(environmentId);
1072 await disconnectSavedEnvironment(environmentId);
10731569}
10741570
10751571export async function addSavedEnvironment(input: {
......@@ -1077,33 +1573,43 @@ export async function addSavedEnvironment(input: {
10771573 readonly pairingUrl?: string;
10781574 readonly host?: string;
10791575 readonly pairingCode?: string;
1576 readonly desktopSsh?: DesktopSshEnvironmentTarget;
10801577}): Promise<SavedEnvironmentRecord> {
10811578 const resolvedTarget = resolveRemotePairingTarget({
10821579 ...(input.pairingUrl !== undefined ? { pairingUrl: input.pairingUrl } : {}),
10831580 ...(input.host !== undefined ? { host: input.host } : {}),
10841581 ...(input.pairingCode !== undefined ? { pairingCode: input.pairingCode } : {}),
10851582 });
1086 const descriptor = await fetchRemoteEnvironmentDescriptor({
1087 httpBaseUrl: resolvedTarget.httpBaseUrl,
1088 });
1583 const descriptor = input.desktopSsh
1584 ? await fetchDesktopSshEnvironmentDescriptor(resolvedTarget.httpBaseUrl)
1585 : await fetchRemoteEnvironmentDescriptor({
1586 httpBaseUrl: resolvedTarget.httpBaseUrl,
1587 });
10891588 const environmentId = descriptor.environmentId;
1090
1091 if (environmentConnections.has(environmentId)) {
1092 throw new Error("This environment is already connected.");
1093 }
1094
1095 const bearerSession = await bootstrapRemoteBearerSession({
1096 httpBaseUrl: resolvedTarget.httpBaseUrl,
1097 credential: resolvedTarget.credential,
1098 });
1589 const registrySnapshot = snapshotSavedEnvironmentRegistry([environmentId]);
1590 const existingRecord =
1591 getSavedEnvironmentRecord(environmentId) ??
1592 findSavedEnvironmentRecordByDesktopSshTarget(input.desktopSsh);
1593 const staleDesktopSshRecord =
1594 existingRecord && existingRecord.environmentId !== environmentId ? existingRecord : null;
1595
1596 const bearerSession = input.desktopSsh
1597 ? await bootstrapDesktopSshBearerSession(resolvedTarget.httpBaseUrl, resolvedTarget.credential)
1598 : await bootstrapRemoteBearerSession({
1599 httpBaseUrl: resolvedTarget.httpBaseUrl,
1600 credential: resolvedTarget.credential,
1601 });
10991602
11001603 const record: SavedEnvironmentRecord = {
11011604 environmentId,
1102 label: input.label.trim() || descriptor.label,
1605 label: input.label.trim() || existingRecord?.label || descriptor.label,
11031606 wsBaseUrl: resolvedTarget.wsBaseUrl,
11041607 httpBaseUrl: resolvedTarget.httpBaseUrl,
1105 createdAt: isoNow(),
1608 createdAt: existingRecord?.createdAt ?? isoNow(),
11061609 lastConnectedAt: isoNow(),
1610 ...((input.desktopSsh ?? existingRecord?.desktopSsh)
1611 ? { desktopSsh: input.desktopSsh ?? existingRecord?.desktopSsh }
1612 : {}),
11071613 };
11081614
11091615 await persistSavedEnvironmentRecord(record);
......@@ -1112,26 +1618,50 @@ export async function addSavedEnvironment(input: {
11121618 bearerSession.sessionToken,
11131619 );
11141620 if (!didPersistBearerToken) {
1115 await ensureLocalApi().persistence.setSavedEnvironmentRegistry(
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 );
1621 await persistSavedEnvironmentRegistryRollback(registrySnapshot);
11251622 throw new Error("Unable to persist saved environment credentials.");
11261623 }
1624 useSavedEnvironmentRegistryStore.getState().upsert(record);
1625 if (staleDesktopSshRecord) {
1626 await removeSavedEnvironment(staleDesktopSshRecord.environmentId);
1627 }
1628 await removeConnection(environmentId).catch(() => false);
11271629 await ensureSavedEnvironmentConnection(record, {
11281630 bearerToken: bearerSession.sessionToken,
11291631 role: bearerSession.role,
11301632 });
1131 useSavedEnvironmentRegistryStore.getState().upsert(record);
11321633 return record;
11331634}
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
11351665export async function ensureEnvironmentConnectionBootstrapped(
11361666 environmentId: EnvironmentId,
11371667): Promise<void> {
......@@ -1170,7 +1700,7 @@ export function startEnvironmentConnectionService(queryClient: QueryClient): ()
11701700 },
11711701 );
11721702
1173 createPrimaryEnvironmentConnection();
1703 maybeCreatePrimaryEnvironmentConnection();
11741704
11751705 const unsubscribeSavedEnvironments = useSavedEnvironmentRegistryStore.subscribe(() => {
11761706 if (!hasSavedEnvironmentRegistryHydrated()) {
......@@ -1183,12 +1713,15 @@ export function startEnvironmentConnectionService(queryClient: QueryClient): ()
11831713 .then(() => syncSavedEnvironmentConnections(listSavedEnvironmentRecords()))
11841714 .catch(() => undefined);
11851715
1716 const unsubscribeBrowserResumeReconnects = subscribeBrowserResumeReconnects();
1717
11861718 activeService = {
11871719 queryClient,
11881720 queryInvalidationThrottler,
11891721 refCount: 1,
11901722 stop: () => {
11911723 unsubscribeSavedEnvironments();
1724 unsubscribeBrowserResumeReconnects();
11921725 queryInvalidationThrottler.cancel();
11931726 },
11941727 };
......@@ -1206,7 +1739,10 @@ export function startEnvironmentConnectionService(queryClient: QueryClient): ()
12061739
12071740export async function resetEnvironmentServiceForTests(): Promise<void> {
12081741 stopActiveService();
1742 lastBrowserHiddenAt = null;
1743 lastBrowserResumeReconnectAt = Number.NEGATIVE_INFINITY;
12091744 lastAppliedProjectionVersionByEnvironment.clear();
1745 pendingSavedEnvironmentConnections.clear();
12101746 for (const key of Array.from(threadDetailSubscriptions.keys())) {
12111747 disposeThreadDetailSubscriptionByKey(key);
12121748 }
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
178178 getSavedEnvironmentSecret: async () => null,
179179 setSavedEnvironmentSecret: async () => true,
180180 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,
181200 getServerExposureState: async () => ({
182201 mode: "local-only",
183202 endpointUrl: null,
184203 advertisedHost: null,
204 tailscaleServeEnabled: false,
205 tailscaleServePort: 443,
185206 }),
186207 setServerExposureMode: async () => ({
187208 mode: "local-only",
188209 endpointUrl: null,
189210 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,
190220 }),
221 getAdvertisedEndpoints: async () => [],
191222 pickFolder: async () => null,
192223 confirm: async () => true,
193224 setTheme: async () => undefined,
apps/web/src/localApi.ts+38-9
......@@ -13,6 +13,7 @@ import {
1313 getPrimaryEnvironmentConnection,
1414 resetEnvironmentServiceForTests,
1515} from "./environments/runtime";
16import { getPrimaryKnownEnvironment } from "./environments/primary";
1617import { type WsRpcClient } from "./rpc/wsRpcClient";
1718import { showContextMenuFallback } from "./contextMenuFallback";
1819import {
......@@ -27,7 +28,11 @@ import {
2728
2829let 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 {
3136 return {
3237 dialogs: {
3338 pickFolder: async (options) => {
......@@ -42,7 +47,10 @@ export function createLocalApi(rpcClient: WsRpcClient): LocalApi {
4247 },
4348 },
4449 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()),
4654 openExternal: async (url) => {
4755 if (window.desktopBridge) {
4856 const opened = await window.desktopBridge.openExternal(url);
......@@ -111,16 +119,34 @@ export function createLocalApi(rpcClient: WsRpcClient): LocalApi {
111119 },
112120 },
113121 server: {
114 getConfig: rpcClient.server.getConfig,
115 refreshProviders: rpcClient.server.refreshProviders,
116 upsertKeybinding: rpcClient.server.upsertKeybinding,
117 getSettings: rpcClient.server.getSettings,
118 updateSettings: rpcClient.server.updateSettings,
119 discoverSourceControl: rpcClient.server.discoverSourceControl,
122 getConfig: () =>
123 rpcClient ? rpcClient.server.getConfig() : Promise.reject(unavailableLocalBackendError()),
124 refreshProviders: () =>
125 rpcClient
126 ? rpcClient.server.refreshProviders()
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()),
120142 },
121143 };
122144}
123145
146export function createLocalApi(rpcClient: WsRpcClient): LocalApi {
147 return createBrowserLocalApi(rpcClient);
148}
149
124150export function readLocalApi(): LocalApi | undefined {
125151 if (typeof window === "undefined") return undefined;
126152 if (cachedApi) return cachedApi;
......@@ -130,7 +156,10 @@ export function readLocalApi(): LocalApi | undefined {
130156 return cachedApi;
131157 }
132158
133 cachedApi = createLocalApi(getPrimaryEnvironmentConnection().client);
159 const primaryEnvironment = getPrimaryKnownEnvironment();
160 cachedApi = primaryEnvironment
161 ? createLocalApi(getPrimaryEnvironmentConnection().client)
162 : createBrowserLocalApi();
134163 return cachedApi;
135164}
136165
apps/web/src/routes/__root.tsx+80-15
......@@ -13,6 +13,7 @@ import { QueryClient, useQueryClient } from "@tanstack/react-query";
1313import { APP_DISPLAY_NAME } from "../branding";
1414import { AppSidebarLayout } from "../components/AppSidebarLayout";
1515import { CommandPalette } from "../components/CommandPalette";
16import { SshPasswordPromptDialog } from "../components/desktop/SshPasswordPromptDialog";
1617import {
1718 SlowRpcAckToastCoordinator,
1819 WebSocketConnectionCoordinator,
......@@ -46,19 +47,41 @@ import { syncBrowserChromeTheme } from "../hooks/useTheme";
4647import {
4748 ensureEnvironmentConnectionBootstrapped,
4849 getPrimaryEnvironmentConnection,
50 listSavedEnvironmentRecords,
51 waitForSavedEnvironmentRegistryHydration,
4952 startEnvironmentConnectionService,
53 useSavedEnvironmentRegistryStore,
5054} from "../environments/runtime";
5155import { configureClientTracing } from "../observability/clientTracing";
5256import {
5357 ensurePrimaryEnvironmentReady,
58 getPrimaryKnownEnvironment,
5459 resolveInitialServerAuthGateState,
5560 updatePrimaryEnvironmentDescriptor,
5661} from "../environments/primary";
62import { hasHostedPairingRequest, isHostedStaticApp } from "../hostedPairing";
5763
5864export const Route = createRootRouteWithContext<{
5965 queryClient: QueryClient;
6066}>()({
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
6285 const [, authGateState] = await Promise.all([
6386 ensurePrimaryEnvironmentReady(),
6487 resolveInitialServerAuthGateState(),
......@@ -77,6 +100,7 @@ export const Route = createRootRouteWithContext<{
77100function RootRouteView() {
78101 const pathname = useLocation({ select: (location) => location.pathname });
79102 const { authGateState } = Route.useRouteContext();
103 const primaryEnvironmentAuthenticated = authGateState.status === "authenticated";
80104
81105 useEffect(() => {
82106 const frame = window.requestAnimationFrame(() => {
......@@ -91,30 +115,65 @@ function RootRouteView() {
91115 return <Outlet />;
92116 }
93117
94 if (authGateState.status !== "authenticated") {
118 if (authGateState.status !== "authenticated" && authGateState.status !== "hosted-static") {
95119 return <Outlet />;
96120 }
121
122 const appShell = (
123 <CommandPalette>
124 <AppSidebarLayout>
125 <Outlet />
126 </AppSidebarLayout>
127 </CommandPalette>
128 );
129
97130 return (
98131 <ToastProvider>
99132 <AnchoredToastProvider>
100 <AuthenticatedTracingBootstrap />
101 <ServerStateBootstrap />
133 {primaryEnvironmentAuthenticated ? <AuthenticatedTracingBootstrap /> : null}
134 {primaryEnvironmentAuthenticated ? <ServerStateBootstrap /> : null}
102135 <EnvironmentConnectionManagerBootstrap />
103 <EventRouter />
104 <WebSocketConnectionCoordinator />
105 <SlowRpcAckToastCoordinator />
106 <WebSocketConnectionSurface>
107 <CommandPalette>
108 <AppSidebarLayout>
109 <Outlet />
110 </AppSidebarLayout>
111 </CommandPalette>
112 </WebSocketConnectionSurface>
136 <SshPasswordPromptDialog />
137 <HostedStaticEnvironmentBootstrap />
138 {primaryEnvironmentAuthenticated ? <EventRouter /> : null}
139 {primaryEnvironmentAuthenticated ? <WebSocketConnectionCoordinator /> : null}
140 {primaryEnvironmentAuthenticated ? <SlowRpcAckToastCoordinator /> : null}
141 {primaryEnvironmentAuthenticated ? (
142 <WebSocketConnectionSurface>{appShell}</WebSocketConnectionSurface>
143 ) : (
144 appShell
145 )}
113146 </AnchoredToastProvider>
114147 </ToastProvider>
115148 );
116149}
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
118177function RootRouteErrorView({ error, reset }: ErrorComponentProps) {
119178 const message = errorMessage(error);
120179 const details = errorDetails(error);
......@@ -187,7 +246,13 @@ function errorDetails(error: unknown): string {
187246}
188247
189248function ServerStateBootstrap() {
190 useEffect(() => startServerStateSync(getPrimaryEnvironmentConnection().client.server), []);
249 useEffect(() => {
250 if (!getPrimaryKnownEnvironment()) {
251 return;
252 }
253
254 return startServerStateSync(getPrimaryEnvironmentConnection().client.server);
255 }, []);
191256
192257 return null;
193258}
apps/web/src/routes/_chat.index.tsx+55
......@@ -1,11 +1,66 @@
11import { createFileRoute } from "@tanstack/react-router";
2import { LinkIcon, PlusIcon } from "lucide-react";
23
34import { 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
511function 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
621 return <NoActiveThreadState />;
722}
823
924export const Route = createFileRoute("/_chat/")({
1025 component: ChatIndexRouteView,
1126});
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() {
108108
109109export const Route = createFileRoute("/_chat")({
110110 beforeLoad: async ({ context }) => {
111 if (context.authGateState.status !== "authenticated") {
111 if (
112 context.authGateState.status !== "authenticated" &&
113 context.authGateState.status !== "hosted-static"
114 ) {
112115 throw redirect({ to: "/pair", replace: true });
113116 }
114117 },
apps/web/src/routes/pair.tsx+16-2
......@@ -1,11 +1,21 @@
11import { 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
59export const Route = createFileRoute("/pair")({
610 beforeLoad: async ({ context }) => {
711 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") {
919 throw redirect({ to: "/", replace: true });
1020 }
1121 return {
......@@ -24,6 +34,10 @@ function PairRouteView() {
2434 return null;
2535 }
2636
37 if (authGateState.status === "hosted-pairing") {
38 return <HostedPairingRouteSurface />;
39 }
40
2741 return (
2842 <PairingRouteSurface
2943 auth={authGateState.auth}
apps/web/src/routes/settings.tsx+4-1
......@@ -104,7 +104,10 @@ function SettingsRouteLayout() {
104104
105105export const Route = createFileRoute("/settings")({
106106 beforeLoad: async ({ context, location }) => {
107 if (context.authGateState.status !== "authenticated") {
107 if (
108 context.authGateState.status !== "authenticated" &&
109 context.authGateState.status !== "hosted-static"
110 ) {
108111 throw redirect({ to: "/pair", replace: true });
109112 }
110113
apps/web/src/rpc/protocol.ts+159-27
......@@ -14,15 +14,45 @@ import {
1414 recordWsConnectionClosed,
1515 recordWsConnectionErrored,
1616 recordWsConnectionOpened,
17 type WsConnectionMetadata,
1718 WS_RECONNECT_MAX_RETRIES,
1819} from "./wsConnectionState";
1920
21export interface WsProtocolCloseContext {
22 readonly intentional: boolean;
23}
24
2025export interface WsProtocolLifecycleHandlers {
26 readonly getConnectionLabel?: () => string | null;
27 readonly getVersionMismatchHint?: () => string | null;
28 readonly isCloseIntentional?: () => boolean;
2129 readonly isActive?: () => boolean;
2230 readonly onAttempt?: (socketUrl: string) => void;
2331 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;
2451 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;
2656}
2757
2858export const makeWsRpcProtocolClient = RpcClient.make(WsRpcGroup);
......@@ -48,27 +78,47 @@ function resolveWsRpcSocketUrl(rawUrl: string): string {
4878 return resolved.toString();
4979}
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 {
5295 return {
5396 isActive: () => true,
54 onAttempt: recordWsConnectionAttempt,
55 onOpen: recordWsConnectionOpened,
97 onAttempt: (socketUrl) => {
98 recordWsConnectionAttempt(socketUrl, resolveConnectionMetadata(handlers));
99 },
100 onOpen: () => {
101 recordWsConnectionOpened(resolveConnectionMetadata(handlers));
102 },
56103 onError: (message) => {
57104 clearAllTrackedRpcRequests();
58 recordWsConnectionErrored(message);
105 recordWsConnectionErrored(message, resolveConnectionMetadata(handlers));
59106 },
60 onClose: (details) => {
107 onClose: (details, context) => {
61108 clearAllTrackedRpcRequests();
62 recordWsConnectionClosed(details);
109 if (context.intentional) {
110 return;
111 }
112 recordWsConnectionClosed(details, resolveConnectionMetadata(handlers));
63113 },
64114 };
65115}
66116
67117function composeLifecycleHandlers(
68118 handlers?: WsProtocolLifecycleHandlers,
69): Required<WsProtocolLifecycleHandlers> {
70 const defaults = defaultLifecycleHandlers();
71 const isActive = handlers?.isActive ?? (() => true);
119): ComposedWsProtocolLifecycleHandlers {
120 const defaults = defaultLifecycleHandlers(handlers);
121 const isActive = handlers?.isActive ?? defaults.isActive;
72122
73123 return {
74124 isActive,
......@@ -93,12 +143,12 @@ function composeLifecycleHandlers(
93143 defaults.onError(message);
94144 handlers?.onError?.(message);
95145 },
96 onClose: (details) => {
146 onClose: (details, context) => {
97147 if (!isActive()) {
98148 return;
99149 }
100 defaults.onClose(details);
101 handlers?.onClose?.(details);
150 defaults.onClose(details, context);
151 handlers?.onClose?.(details, context);
102152 },
103153 };
104154}
......@@ -144,10 +194,15 @@ export function createWsRpcProtocolLayer(
144194 socket.addEventListener(
145195 "close",
146196 (event) => {
147 lifecycle.onClose({
148 code: event.code,
149 reason: event.reason,
150 });
197 lifecycle.onClose(
198 {
199 code: event.code,
200 reason: event.reason,
201 },
202 {
203 intentional: handlers?.isCloseIntentional?.() ?? false,
204 },
205 );
151206 },
152207 { once: true },
153208 );
......@@ -172,22 +227,99 @@ export function createWsRpcProtocolLayer(
172227 ...protocol,
173228 run: (clientId, writeResponse) =>
174229 protocol.run(clientId, (response) => {
175 if (response._tag === "Chunk" || response._tag === "Exit") {
176 acknowledgeRpcRequest(response.requestId);
177 } else if (response._tag === "ClientProtocolError" || response._tag === "Defect") {
230 if (response._tag === "ClientProtocolError" || response._tag === "Defect") {
178231 clearAllTrackedRpcRequests();
179232 }
180233 return writeResponse(response);
181234 }),
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 },
188235 }),
189236 ),
190237 );
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 );
193325}
apps/web/src/rpc/serverState.test.ts+23
......@@ -10,10 +10,12 @@ import {
1010 type ServerLifecycleStreamEvent,
1111 type ServerProvider,
1212} from "@t3tools/contracts";
13import { DEFAULT_RESOLVED_KEYBINDINGS } from "@t3tools/shared/keybindings";
1314import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
1415
1516import {
1617 getServerConfig,
18 getServerKeybindings,
1719 onProvidersUpdated,
1820 onServerConfigUpdated,
1921 onWelcome,
......@@ -141,6 +143,11 @@ afterEach(() => {
141143});
142144
143145describe("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
144151 it("bootstraps the server config snapshot and replays it to late subscribers", async () => {
145152 serverApi.getConfig.mockResolvedValueOnce(baseServerConfig);
146153
......@@ -271,10 +278,25 @@ describe("serverState", () => {
271278 },
272279 ];
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
274295 emitServerConfigEvent({
275296 version: 1,
276297 type: "keybindingsUpdated",
277298 payload: {
299 keybindings: nextKeybindings,
278300 issues: [{ kind: "keybindings.malformed-config", message: "bad json" }],
279301 },
280302 });
......@@ -299,6 +321,7 @@ describe("serverState", () => {
299321 await waitFor(() => {
300322 expect(getServerConfig()).toEqual({
301323 ...baseServerConfig,
324 keybindings: nextKeybindings,
302325 issues: [{ kind: "keybindings.malformed-config", message: "bad json" }],
303326 providers: nextProviders,
304327 settings: {
apps/web/src/rpc/serverState.ts+8-2
......@@ -10,6 +10,7 @@ import {
1010 type ServerProviderUpdatedPayload,
1111 type ServerSettings,
1212} from "@t3tools/contracts";
13import { DEFAULT_RESOLVED_KEYBINDINGS } from "@t3tools/shared/keybindings";
1314import { Atom } from "effect/unstable/reactivity";
1415import { useCallback, useRef } from "react";
1516
......@@ -42,12 +43,12 @@ function toServerConfigUpdatedPayload(config: ServerConfig): ServerConfigUpdated
4243}
4344
4445const EMPTY_AVAILABLE_EDITORS: ReadonlyArray<EditorId> = [];
45const EMPTY_KEYBINDINGS: ServerConfig["keybindings"] = [];
4646const EMPTY_SERVER_PROVIDERS: ReadonlyArray<ServerProvider> = [];
4747
4848const selectAvailableEditors = (config: ServerConfig | null): ReadonlyArray<EditorId> =>
4949 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;
5152const selectKeybindingsConfigPath = (config: ServerConfig | null) =>
5253 config?.keybindingsConfigPath ?? null;
5354const selectObservability = (config: ServerConfig | null) => config?.observability ?? null;
......@@ -74,6 +75,10 @@ export function getServerConfig(): ServerConfig | null {
7475 return appAtomRegistry.get(serverConfigAtom);
7576}
7677
78export function getServerKeybindings(): ServerConfig["keybindings"] {
79 return selectKeybindings(getServerConfig());
80}
81
7782export function getServerConfigUpdatedNotification(): ServerConfigUpdatedNotification | null {
7883 return appAtomRegistry.get(serverConfigUpdatedAtom);
7984}
......@@ -97,6 +102,7 @@ export function applyServerConfigEvent(event: ServerConfigStreamEvent): void {
97102 }
98103 const nextConfig = {
99104 ...latestServerConfig,
105 keybindings: event.payload.keybindings,
100106 issues: event.payload.issues,
101107 } satisfies ServerConfig;
102108 resolveServerConfig(nextConfig);
apps/web/src/rpc/wsConnectionState.test.ts+33-1
......@@ -45,7 +45,9 @@ describe("wsConnectionState", () => {
4545 });
4646
4747 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 });
4951 recordWsConnectionErrored("Unable to connect to the T3 server WebSocket.");
5052
5153 const firstRetryDelayMs = getWsReconnectDelayMsForRetry(0);
......@@ -54,12 +56,42 @@ describe("wsConnectionState", () => {
5456 }
5557
5658 expect(getWsConnectionStatus()).toMatchObject({
59 connectionLabel: "Remote Mac",
5760 nextRetryAt: new Date(Date.now() + firstRetryDelayMs).toISOString(),
5861 reconnectAttemptCount: 1,
5962 reconnectPhase: "waiting",
6063 });
6164 });
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
6395 it("marks the reconnect cycle as exhausted after the final attempt fails", () => {
6496 for (let attempt = 0; attempt < WS_RECONNECT_MAX_ATTEMPTS; attempt += 1) {
6597 recordWsConnectionAttempt("ws://localhost:3020/ws");
apps/web/src/rpc/wsConnectionState.ts+57-12
......@@ -16,6 +16,7 @@ export interface WsConnectionStatus {
1616 readonly attemptCount: number;
1717 readonly closeCode: number | null;
1818 readonly closeReason: string | null;
19 readonly connectionLabel: string | null;
1920 readonly connectedAt: string | null;
2021 readonly disconnectedAt: string | null;
2122 readonly hasConnected: boolean;
......@@ -34,6 +35,7 @@ const INITIAL_WS_CONNECTION_STATUS = Object.freeze<WsConnectionStatus>({
3435 attemptCount: 0,
3536 closeCode: null,
3637 closeReason: null,
38 connectionLabel: null,
3739 connectedAt: null,
3840 disconnectedAt: null,
3941 hasConnected: false,
......@@ -65,6 +67,16 @@ function updateWsConnectionStatus(
6567 return nextStatus;
6668}
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
6880export function getWsConnectionStatus(): WsConnectionStatus {
6981 return appAtomRegistry.get(wsConnectionStatusAtom);
7082}
......@@ -85,10 +97,15 @@ export function getWsConnectionUiState(status: WsConnectionStatus): WsConnection
8597 return "reconnecting";
8698}
8799
88export function recordWsConnectionAttempt(socketUrl: string): WsConnectionStatus {
100export function recordWsConnectionAttempt(
101 socketUrl: string,
102 metadata?: WsConnectionMetadata,
103): WsConnectionStatus {
104 const connectionLabel = normalizeConnectionLabel(metadata?.connectionLabel);
89105 return updateWsConnectionStatus((current) => ({
90106 ...current,
91107 attemptCount: current.attemptCount + 1,
108 connectionLabel: connectionLabel ?? current.connectionLabel,
92109 nextRetryAt: null,
93110 phase: "connecting",
94111 reconnectAttemptCount: current.phase === "connected" ? 1 : current.reconnectAttemptCount + 1,
......@@ -97,11 +114,13 @@ export function recordWsConnectionAttempt(socketUrl: string): WsConnectionStatus
97114 }));
98115}
99116
100export function recordWsConnectionOpened(): WsConnectionStatus {
117export function recordWsConnectionOpened(metadata?: WsConnectionMetadata): WsConnectionStatus {
118 const connectionLabel = normalizeConnectionLabel(metadata?.connectionLabel);
101119 return updateWsConnectionStatus((current) => ({
102120 ...current,
103121 closeCode: null,
104122 closeReason: null,
123 connectionLabel: connectionLabel ?? current.connectionLabel,
105124 connectedAt: isoNow(),
106125 disconnectedAt: null,
107126 hasConnected: true,
......@@ -112,24 +131,48 @@ export function recordWsConnectionOpened(): WsConnectionStatus {
112131 }));
113132}
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 {
116147 return updateWsConnectionStatus((current) =>
117148 applyDisconnectState(current, {
118 lastError: message?.trim() ? message : current.lastError,
149 lastError:
150 appendHint(message, metadata?.versionMismatchHint) ??
151 appendHint(current.lastError, metadata?.versionMismatchHint),
119152 lastErrorAt: isoNow(),
120153 }),
121154 );
122155}
123156
124export function recordWsConnectionClosed(details?: {
125 readonly code?: number;
126 readonly reason?: string;
127}): WsConnectionStatus {
157export function recordWsConnectionClosed(
158 details?: {
159 readonly code?: number;
160 readonly reason?: string;
161 },
162 metadata?: WsConnectionMetadata,
163): WsConnectionStatus {
164 const connectionLabel = normalizeConnectionLabel(metadata?.connectionLabel);
128165 return updateWsConnectionStatus((current) =>
129 applyDisconnectState(current, {
130 closeCode: details?.code ?? current.closeCode,
131 closeReason: details?.reason?.trim() ? details.reason : current.closeReason,
132 }),
166 applyDisconnectState(
167 current,
168 {
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 ),
133176 );
134177}
135178
......@@ -173,6 +216,7 @@ function applyDisconnectState(
173216 updates: Partial<
174217 Pick<WsConnectionStatus, "closeCode" | "closeReason" | "lastError" | "lastErrorAt">
175218 >,
219 metadata?: WsConnectionMetadata,
176220): WsConnectionStatus {
177221 const disconnectedAt = current.disconnectedAt ?? isoNow();
178222 const nextRetryDelayMs =
......@@ -183,6 +227,7 @@ function applyDisconnectState(
183227 return {
184228 ...current,
185229 ...updates,
230 connectionLabel: normalizeConnectionLabel(metadata?.connectionLabel) ?? current.connectionLabel,
186231 disconnectedAt,
187232 nextRetryAt:
188233 nextRetryDelayMs === null
apps/web/src/rpc/wsRpcClient.ts+19-23
......@@ -155,11 +155,10 @@ export function createWsRpcClient(transport: WsTransport): WsRpcClient {
155155 restart: (input) => transport.request((client) => client[WS_METHODS.terminalRestart](input)),
156156 close: (input) => transport.request((client) => client[WS_METHODS.terminalClose](input)),
157157 onEvent: (listener, options) =>
158 transport.subscribe(
159 (client) => client[WS_METHODS.subscribeTerminalEvents]({}),
160 listener,
161 options,
162 ),
158 transport.subscribe((client) => client[WS_METHODS.subscribeTerminalEvents]({}), listener, {
159 ...options,
160 tag: WS_METHODS.subscribeTerminalEvents,
161 }),
163162 },
164163 projects: {
165164 searchEntries: (input) =>
......@@ -194,7 +193,7 @@ export function createWsRpcClient(transport: WsTransport): WsRpcClient {
194193 current = applyGitStatusStreamEvent(current, event);
195194 listener(current);
196195 },
197 options,
196 { ...options, tag: WS_METHODS.subscribeVcsStatus },
198197 );
199198 },
200199 listRefs: (input) => transport.request((client) => client[WS_METHODS.vcsListRefs](input)),
......@@ -243,23 +242,20 @@ export function createWsRpcClient(transport: WsTransport): WsRpcClient {
243242 discoverSourceControl: () =>
244243 transport.request((client) => client[WS_METHODS.serverDiscoverSourceControl]({})),
245244 subscribeConfig: (listener, options) =>
246 transport.subscribe(
247 (client) => client[WS_METHODS.subscribeServerConfig]({}),
248 listener,
249 options,
250 ),
245 transport.subscribe((client) => client[WS_METHODS.subscribeServerConfig]({}), listener, {
246 ...options,
247 tag: WS_METHODS.subscribeServerConfig,
248 }),
251249 subscribeLifecycle: (listener, options) =>
252 transport.subscribe(
253 (client) => client[WS_METHODS.subscribeServerLifecycle]({}),
254 listener,
255 options,
256 ),
250 transport.subscribe((client) => client[WS_METHODS.subscribeServerLifecycle]({}), listener, {
251 ...options,
252 tag: WS_METHODS.subscribeServerLifecycle,
253 }),
257254 subscribeAuthAccess: (listener, options) =>
258 transport.subscribe(
259 (client) => client[WS_METHODS.subscribeAuthAccess]({}),
260 listener,
261 options,
262 ),
255 transport.subscribe((client) => client[WS_METHODS.subscribeAuthAccess]({}), listener, {
256 ...options,
257 tag: WS_METHODS.subscribeAuthAccess,
258 }),
263259 },
264260 orchestration: {
265261 dispatchCommand: (input) =>
......@@ -272,13 +268,13 @@ export function createWsRpcClient(transport: WsTransport): WsRpcClient {
272268 transport.subscribe(
273269 (client) => client[ORCHESTRATION_WS_METHODS.subscribeShell]({}),
274270 listener,
275 options,
271 { ...options, tag: ORCHESTRATION_WS_METHODS.subscribeShell },
276272 ),
277273 subscribeThread: (input, listener, options) =>
278274 transport.subscribe(
279275 (client) => client[ORCHESTRATION_WS_METHODS.subscribeThread](input),
280276 listener,
281 options,
277 { ...options, tag: ORCHESTRATION_WS_METHODS.subscribeThread },
282278 ),
283279 },
284280 };
apps/web/src/rpc/wsTransport.test.ts+75-4
......@@ -284,10 +284,15 @@ describe("WsTransport", () => {
284284 socket.close(1012, "service restart");
285285
286286 await waitFor(() => {
287 expect(onClose).toHaveBeenCalledWith({
288 code: 1012,
289 reason: "service restart",
290 });
287 expect(onClose).toHaveBeenCalledWith(
288 {
289 code: 1012,
290 reason: "service restart",
291 },
292 {
293 intentional: false,
294 },
295 );
291296 expect(getWsConnectionStatus()).toMatchObject({
292297 attemptCount: 2,
293298 closeReason: "service restart",
......@@ -298,6 +303,38 @@ describe("WsTransport", () => {
298303 await transport.dispose();
299304 });
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
301338 it("reconnects the websocket session without disposing the transport", async () => {
302339 const transport = createTransport("ws://localhost:3020");
303340
......@@ -418,6 +455,40 @@ describe("WsTransport", () => {
418455 await transport.dispose();
419456 });
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
421492 it("marks unary requests as slow until the first server ack arrives", async () => {
422493 const slowAckThresholdMs = 25;
423494 setSlowRpcAckThresholdMsForTests(slowAckThresholdMs);
apps/web/src/rpc/wsTransport.ts+73-8
......@@ -25,6 +25,7 @@ import { isTransportConnectionErrorMessage } from "./transportError";
2525interface SubscribeOptions {
2626 readonly retryDelay?: Duration.Input;
2727 readonly onResubscribe?: () => void;
28 readonly tag?: string;
2829}
2930
3031interface RequestOptions {
......@@ -40,6 +41,12 @@ interface TransportSession {
4041 readonly runtime: ManagedRuntime.ManagedRuntime<RpcClient.Protocol, never>;
4142}
4243
44interface StreamRequestStartInfo {
45 readonly id: string;
46 readonly tag: string;
47 readonly stream: boolean;
48}
49
4350function formatErrorMessage(error: unknown): string {
4451 if (error instanceof Error && error.message.trim().length > 0) {
4552 return error.message;
......@@ -52,10 +59,13 @@ export class WsTransport {
5259 private readonly lifecycleHandlers: WsProtocolLifecycleHandlers | undefined;
5360 private disposed = false;
5461 private hasReportedTransportDisconnect = false;
62 private intentionalCloseDepth = 0;
5563 private reconnectChain: Promise<void> = Promise.resolve();
5664 private nextSessionId = 0;
5765 private activeSessionId = 0;
5866 private session: TransportSession;
67 private lastHeartbeatPongAt = 0;
68 private readonly streamRequestStartListeners = new Set<(info: StreamRequestStartInfo) => void>();
5969
6070 constructor(
6171 url: WsRpcProtocolSocketUrlProvider,
......@@ -126,18 +136,24 @@ export class WsTransport {
126136
127137 const session = this.session;
128138 try {
129 if (hasReceivedValue) {
130 try {
131 options?.onResubscribe?.();
132 } catch {
133 // Swallow reconnect hook errors so the stream can recover.
134 }
135 }
136
137139 const runningStream = this.runStreamOnSession(
138140 session,
139141 connect,
140142 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 },
141157 () => active,
142158 () => {
143159 this.hasReportedTransportDisconnect = false;
......@@ -193,6 +209,7 @@ export class WsTransport {
193209 }
194210
195211 clearAllTrackedRpcRequests();
212 this.lastHeartbeatPongAt = 0;
196213 const previousSession = this.session;
197214 this.session = this.createSession();
198215 await this.closeSession(previousSession);
......@@ -202,6 +219,10 @@ export class WsTransport {
202219 await reconnectOperation;
203220 }
204221
222 isHeartbeatFresh(maxAgeMs = 15_000): boolean {
223 return this.lastHeartbeatPongAt > 0 && Date.now() - this.lastHeartbeatPongAt <= maxAgeMs;
224 }
225
205226 async dispose() {
206227 if (this.disposed) {
207228 return;
......@@ -211,7 +232,9 @@ export class WsTransport {
211232 }
212233
213234 private closeSession(session: TransportSession) {
235 this.intentionalCloseDepth += 1;
214236 return session.runtime.runPromise(Scope.close(session.clientScope, Exit.void)).finally(() => {
237 this.intentionalCloseDepth -= 1;
215238 session.runtime.dispose();
216239 });
217240 }
......@@ -225,6 +248,23 @@ export class WsTransport {
225248 createWsRpcProtocolLayer(this.url, {
226249 ...this.lifecycleHandlers,
227250 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 },
228268 }),
229269 ClientTracingLive,
230270 ),
......@@ -241,6 +281,10 @@ export class WsTransport {
241281 session: TransportSession,
242282 connect: (client: WsRpcProtocolClient) => Stream.Stream<TValue, Error, never>,
243283 listener: (value: TValue) => void,
284 requestStart: {
285 readonly tag?: string;
286 readonly onStarted?: () => void;
287 },
244288 isActive: () => boolean,
245289 markValueReceived: () => void,
246290 ): {
......@@ -253,6 +297,23 @@ export class WsTransport {
253297 resolveCompleted = resolve;
254298 rejectCompleted = reject;
255299 });
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 }
256317 const cancel = session.runtime.runCallback(
257318 Effect.promise(() => session.clientPromise).pipe(
258319 Effect.flatMap((client) =>
......@@ -274,6 +335,10 @@ export class WsTransport {
274335 ),
275336 {
276337 onExit: (exit) => {
338 if (requestStartListener) {
339 this.streamRequestStartListeners.delete(requestStartListener);
340 requestStartListener = null;
341 }
277342 if (Exit.isSuccess(exit)) {
278343 resolveCompleted();
279344 return;
apps/web/src/store.test.ts+34
......@@ -16,6 +16,7 @@ import { describe, expect, it } from "vitest";
1616import {
1717 applyOrchestrationEvent,
1818 applyOrchestrationEvents,
19 removeEnvironmentState,
1920 selectEnvironmentState,
2021 selectProjectsAcrossEnvironments,
2122 selectThreadByRef,
......@@ -246,6 +247,39 @@ function makeEvent<T extends OrchestrationEvent["type"]>(
246247 } as Extract<OrchestrationEvent, { type: T }>;
247248}
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
249283describe("thread selection memoization", () => {
250284 it("returns stable thread references for repeated reads of the same state", () => {
251285 const thread = makeThread({
apps/web/src/store.ts+17
......@@ -1905,6 +1905,20 @@ export function setActiveEnvironmentId(state: AppState, environmentId: Environme
19051905 };
19061906}
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
19081922export function setThreadBranch(
19091923 state: AppState,
19101924 threadRef: ScopedThreadRef,
......@@ -1930,6 +1944,7 @@ export function setThreadBranch(
19301944
19311945interface AppStore extends AppState {
19321946 setActiveEnvironmentId: (environmentId: EnvironmentId) => void;
1947 removeEnvironmentState: (environmentId: EnvironmentId) => void;
19331948 syncServerShellSnapshot: (
19341949 snapshot: OrchestrationShellSnapshot,
19351950 environmentId: EnvironmentId,
......@@ -1953,6 +1968,8 @@ export const useStore = create<AppStore>((set) => ({
19531968 ...initialState,
19541969 setActiveEnvironmentId: (environmentId) =>
19551970 set((state) => setActiveEnvironmentId(state, environmentId)),
1971 removeEnvironmentState: (environmentId) =>
1972 set((state) => removeEnvironmentState(state, environmentId)),
19561973 syncServerShellSnapshot: (snapshot, environmentId) =>
19571974 set((state) => syncServerShellSnapshot(state, snapshot, environmentId)),
19581975 syncServerThreadDetail: (thread, environmentId) =>
apps/web/src/uiStateStore.test.ts+25
......@@ -10,6 +10,7 @@ import {
1010 type PersistedUiState,
1111 persistState,
1212 reorderProjects,
13 setDefaultAdvertisedEndpointKey,
1314 setProjectExpanded,
1415 setThreadChangedFilesExpanded,
1516 syncProjects,
......@@ -23,6 +24,7 @@ function makeUiState(overrides: Partial<UiState> = {}): UiState {
2324 projectOrder: [],
2425 threadLastVisitedAtById: {},
2526 threadChangedFilesExpandedById: {},
27 defaultAdvertisedEndpointKey: null,
2628 ...overrides,
2729 };
2830}
......@@ -102,6 +104,18 @@ describe("uiStateStore pure functions", () => {
102104 expect(next).toBe(initialState);
103105 });
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
105119 it("reorderProjects moves all member keys of a multi-member group together", () => {
106120 const keyALocal = "env-local:proj-a";
107121 const keyARemote = "env-remote:proj-a";
......@@ -554,6 +568,17 @@ describe("uiStateStore persistence round-trip", () => {
554568 expect(rehydrated.projectOrder).toEqual([projectC.key, projectA.key, projectB.key]);
555569 });
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
557582 it("preserves expand state across restart when project's logical key changes", () => {
558583 // After restart, in-memory previousExpandedById is empty, so the
559584 // previousLogicalKey-to-state bridge in syncProjects cannot help. The
apps/web/src/uiStateStore.ts+27-1
......@@ -19,6 +19,7 @@ export interface PersistedUiState {
1919 collapsedProjectCwds?: string[];
2020 expandedProjectCwds?: string[];
2121 projectOrderCwds?: string[];
22 defaultAdvertisedEndpointKey?: string | null;
2223 threadChangedFilesExpandedById?: Record<string, Record<string, boolean>>;
2324}
2425
......@@ -32,7 +33,11 @@ export interface UiThreadState {
3233 threadChangedFilesExpandedById: Record<string, Record<string, boolean>>;
3334}
3435
35export interface UiState extends UiProjectState, UiThreadState {}
36export interface UiEndpointState {
37 defaultAdvertisedEndpointKey: string | null;
38}
39
40export interface UiState extends UiProjectState, UiThreadState, UiEndpointState {}
3641
3742export interface SyncProjectInput {
3843 /** Physical project key (env + cwd). Used for manual sort order. */
......@@ -52,6 +57,7 @@ const initialState: UiState = {
5257 projectOrder: [],
5358 threadLastVisitedAtById: {},
5459 threadChangedFilesExpandedById: {},
60 defaultAdvertisedEndpointKey: null,
5561};
5662
5763const persistedCollapsedProjectCwds = new Set<string>();
......@@ -88,6 +94,11 @@ function readPersistedState(): UiState {
8894 hydratePersistedProjectState(parsed);
8995 return {
9096 ...initialState,
97 defaultAdvertisedEndpointKey:
98 typeof parsed.defaultAdvertisedEndpointKey === "string" &&
99 parsed.defaultAdvertisedEndpointKey.length > 0
100 ? parsed.defaultAdvertisedEndpointKey
101 : null,
91102 threadChangedFilesExpandedById: sanitizePersistedThreadChangedFilesExpanded(
92103 parsed.threadChangedFilesExpandedById,
93104 ),
......@@ -179,6 +190,7 @@ export function persistState(state: UiState): void {
179190 collapsedProjectCwds,
180191 expandedProjectCwds,
181192 projectOrderCwds,
193 defaultAdvertisedEndpointKey: state.defaultAdvertisedEndpointKey,
182194 threadChangedFilesExpandedById,
183195 } satisfies PersistedUiState),
184196 );
......@@ -532,6 +544,17 @@ export function setThreadChangedFilesExpanded(
532544 };
533545}
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
535558export function toggleProject(state: UiState, projectId: string): UiState {
536559 const expanded = state.projectExpandedById[projectId] ?? true;
537560 return {
......@@ -606,6 +629,7 @@ interface UiStateStore extends UiState {
606629 markThreadUnread: (threadId: string, latestTurnCompletedAt: string | null | undefined) => void;
607630 clearThreadUi: (threadId: string) => void;
608631 setThreadChangedFilesExpanded: (threadId: string, turnId: string, expanded: boolean) => void;
632 setDefaultAdvertisedEndpointKey: (key: string | null) => void;
609633 toggleProject: (projectId: string) => void;
610634 setProjectExpanded: (projectId: string, expanded: boolean) => void;
611635 reorderProjects: (
......@@ -625,6 +649,8 @@ export const useUiStateStore = create<UiStateStore>((set) => ({
625649 clearThreadUi: (threadId) => set((state) => clearThreadUi(state, threadId)),
626650 setThreadChangedFilesExpanded: (threadId, turnId, expanded) =>
627651 set((state) => setThreadChangedFilesExpanded(state, threadId, turnId, expanded)),
652 setDefaultAdvertisedEndpointKey: (key) =>
653 set((state) => setDefaultAdvertisedEndpointKey(state, key)),
628654 toggleProject: (projectId) => set((state) => toggleProject(state, projectId)),
629655 setProjectExpanded: (projectId, expanded) =>
630656 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 @@
33import type { DesktopBridge, LocalApi } from "@t3tools/contracts";
44
55interface ImportMetaEnv {
6 readonly VITE_HTTP_URL: string;
7 readonly VITE_WS_URL: string;
8 readonly VITE_HOSTED_APP_URL: string;
69 readonly APP_VERSION: string;
710}
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);
99const host = process.env.HOST?.trim() || "localhost";
1010const configuredHttpUrl = process.env.VITE_HTTP_URL?.trim();
1111const 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})();
1221const sourcemapEnv = process.env.T3CODE_WEB_SOURCEMAP?.trim().toLowerCase();
1322
1423const buildSourcemap =
......@@ -68,6 +77,7 @@ export default defineConfig({
6877 "import.meta.env.VITE_HTTP_URL": JSON.stringify(configuredHttpUrl ?? ""),
6978 // In dev mode, tell the web app where the WebSocket server lives
7079 "import.meta.env.VITE_WS_URL": JSON.stringify(configuredWsUrl ?? ""),
80 "import.meta.env.VITE_HOSTED_APP_URL": JSON.stringify(configuredHostedAppUrl ?? ""),
7181 "import.meta.env.APP_VERSION": JSON.stringify(pkg.version),
7282 },
7383 resolve: {
bun.lock+68-20
......@@ -16,13 +16,17 @@
1616 "name": "@t3tools/desktop",
1717 "version": "0.0.21",
1818 "dependencies": {
19 "@effect/platform-node": "catalog:",
1920 "effect": "catalog:",
2021 "electron": "40.9.3",
2122 "electron-updater": "^6.6.2",
2223 },
2324 "devDependencies": {
25 "@t3tools/client-runtime": "workspace:*",
2426 "@t3tools/contracts": "workspace:*",
2527 "@t3tools/shared": "workspace:*",
28 "@t3tools/ssh": "workspace:*",
29 "@t3tools/tailscale": "workspace:*",
2630 "@types/node": "catalog:",
2731 "effect-acp": "workspace:*",
2832 "tsdown": "catalog:",
......@@ -63,6 +67,7 @@
6367 "@effect/vitest": "catalog:",
6468 "@t3tools/contracts": "workspace:*",
6569 "@t3tools/shared": "workspace:*",
70 "@t3tools/tailscale": "workspace:*",
6671 "@t3tools/web": "workspace:*",
6772 "@types/bun": "catalog:",
6873 "@types/node": "catalog:",
......@@ -198,6 +203,38 @@
198203 "vitest": "catalog:",
199204 },
200205 },
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 },
201238 "scripts": {
202239 "name": "@t3tools/scripts",
203240 "dependencies": {
......@@ -220,6 +257,9 @@
220257 "electron",
221258 "node-pty",
222259 ],
260 "patchedDependencies": {
261 "effect@4.0.0-beta.59": "patches/effect@4.0.0-beta.59.patch",
262 },
223263 "overrides": {
224264 "@effect/atom-react": "catalog:",
225265 "@effect/platform-bun": "catalog:",
......@@ -231,17 +271,17 @@
231271 "vite": "^8.0.0",
232272 },
233273 "catalog": {
234 "@effect/atom-react": "4.0.0-beta.45",
274 "@effect/atom-react": "4.0.0-beta.59",
235275 "@effect/language-service": "0.84.2",
236 "@effect/openapi-generator": "4.0.0-beta.45",
237 "@effect/platform-bun": "4.0.0-beta.45",
238 "@effect/platform-node": "4.0.0-beta.45",
239 "@effect/platform-node-shared": "4.0.0-beta.45",
240 "@effect/sql-sqlite-bun": "4.0.0-beta.45",
241 "@effect/vitest": "4.0.0-beta.45",
276 "@effect/openapi-generator": "4.0.0-beta.59",
277 "@effect/platform-bun": "4.0.0-beta.59",
278 "@effect/platform-node": "4.0.0-beta.59",
279 "@effect/platform-node-shared": "4.0.0-beta.59",
280 "@effect/sql-sqlite-bun": "4.0.0-beta.59",
281 "@effect/vitest": "4.0.0-beta.59",
242282 "@types/bun": "^1.3.11",
243283 "@types/node": "^24.10.13",
244 "effect": "4.0.0-beta.45",
284 "effect": "4.0.0-beta.59",
245285 "tsdown": "^0.20.3",
246286 "typescript": "^5.7.3",
247287 "vitest": "^4.0.0",
......@@ -329,21 +369,21 @@
329369
330370 "@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
334374 "@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
348388 "@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 @@
741781
742782 "@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
744788 "@t3tools/web": ["@t3tools/web@workspace:apps/web"],
745789
746790 "@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 @@
10971141
10981142 "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
11021146 "effect-acp": ["effect-acp@workspace:packages/effect-acp"],
11031147
......@@ -1855,7 +1899,7 @@
18551899
18561900 "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
18601904 "totalist": ["totalist@3.0.1", "", {}, "sha512-sf4i37nQ2LBx4m3wB74y+ubopq6W/dIzXg0FDGjsYnZHVa1Da8FH853wlL2gtUhg+xJXjfk3kUZS3BRoQeoQBQ=="],
18611905
......@@ -1909,7 +1953,7 @@
19091953
19101954 "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
19141958 "undici-types": ["undici-types@7.16.0", "", {}, "sha512-Zz+aZWSj8LE6zoxD+xrjh4VfkIG8Ya6LvYkZqtUQGJPZjYl53ypCaUwWqo7eI0x66KBGeRo+mlBEkMSeSZ38Nw=="],
19151959
......@@ -2017,7 +2061,7 @@
20172061
20182062 "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
20222066 "wsl-utils": ["wsl-utils@0.1.0", "", { "dependencies": { "is-wsl": "^3.1.0" } }, "sha512-h3Fbisa2nKGPxCpm89Hk33lBLsnaGBvctQopaBSOW/uIs6FTe1ATyAnKFJrzVs9vpGdsTe73WF3V4lIsk4Gacw=="],
20232067
......@@ -2027,7 +2071,7 @@
20272071
20282072 "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
20322076 "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 @@
20532097
20542098 "@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
20562102 "@babel/code-frame/@babel/helper-validator-identifier": ["@babel/helper-validator-identifier@7.28.5", "", {}, "sha512-qSs4ifwzKJSV39ucNjsvc6WVHs6b7S03sOh2OcHF9UHfVPqWWALUsNUVzhSBiItjRZoLHx7nIarVjqKVusUZ1Q=="],
20572103
20582104 "@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 @@
21172163
21182164 "@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
21202168 "anymatch/picomatch": ["picomatch@2.3.1", "", {}, "sha512-JU3teHTNjmE2VCGFzuY8EXzCDVwEqB2a8fsIvwaStHhAWJEeVd1o1QD80CU6+ZdEXXSLbSsuLwJjkCBWqRQUVA=="],
21212169
21222170 "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 @@
88 "scripts"
99 ],
1010 "catalog": {
11 "effect": "4.0.0-beta.45",
12 "@effect/atom-react": "4.0.0-beta.45",
13 "@effect/openapi-generator": "4.0.0-beta.45",
14 "@effect/platform-bun": "4.0.0-beta.45",
15 "@effect/platform-node": "4.0.0-beta.45",
16 "@effect/platform-node-shared": "4.0.0-beta.45",
17 "@effect/sql-sqlite-bun": "4.0.0-beta.45",
18 "@effect/vitest": "4.0.0-beta.45",
11 "effect": "4.0.0-beta.59",
12 "@effect/atom-react": "4.0.0-beta.59",
13 "@effect/openapi-generator": "4.0.0-beta.59",
14 "@effect/platform-bun": "4.0.0-beta.59",
15 "@effect/platform-node": "4.0.0-beta.59",
16 "@effect/platform-node-shared": "4.0.0-beta.59",
17 "@effect/sql-sqlite-bun": "4.0.0-beta.59",
18 "@effect/vitest": "4.0.0-beta.59",
1919 "@effect/language-service": "0.84.2",
2020 "@types/bun": "^1.3.11",
2121 "@types/node": "^24.10.13",
......@@ -84,6 +84,9 @@
8484 "apps/web/public"
8585 ]
8686 },
87 "patchedDependencies": {
88 "effect@4.0.0-beta.59": "patches/effect@4.0.0-beta.59.patch"
89 },
8790 "trustedDependencies": [
8891 "node-pty",
8992 "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 @@
1export * from "./advertisedEndpoint.ts";
12export * from "./knownEnvironment.ts";
23export * from "./scoped.ts";
34export * from "./sourceControlDiscoveryState.ts";
packages/contracts/src/index.ts+1
......@@ -1,6 +1,7 @@
11export * from "./baseSchemas.ts";
22export * from "./auth.ts";
33export * from "./environment.ts";
4export * from "./remoteAccess.ts";
45export * from "./ipc.ts";
56export * from "./terminal.ts";
67export * from "./provider.ts";
packages/contracts/src/ipc.ts+64-1
......@@ -53,8 +53,15 @@ import type {
5353 OrchestrationThreadStreamItem,
5454} from "./orchestration.ts";
5555import type { EnvironmentId } from "./baseSchemas.ts";
56import type {
57 AuthBearerBootstrapResult,
58 AuthSessionState,
59 AuthWebSocketTokenResult,
60} from "./auth.ts";
61import type { AdvertisedEndpoint } from "./remoteAccess.ts";
5662import { 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";
5865import type {
5966 SourceControlCloneRepositoryInput,
6067 SourceControlCloneRepositoryResult,
......@@ -135,6 +142,36 @@ export interface DesktopEnvironmentBootstrap {
135142 bootstrapToken?: string;
136143}
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
138175export interface PersistedSavedEnvironmentRecord {
139176 environmentId: EnvironmentId;
140177 label: string;
......@@ -142,6 +179,7 @@ export interface PersistedSavedEnvironmentRecord {
142179 httpBaseUrl: string;
143180 createdAt: string;
144181 lastConnectedAt: string | null;
182 desktopSsh?: DesktopSshEnvironmentTarget;
145183}
146184
147185export type DesktopServerExposureMode = "local-only" | "network-accessible";
......@@ -150,6 +188,8 @@ export interface DesktopServerExposureState {
150188 mode: DesktopServerExposureMode;
151189 endpointUrl: string | null;
152190 advertisedHost: string | null;
191 tailscaleServeEnabled: boolean;
192 tailscaleServePort: number;
153193}
154194
155195export interface PickFolderOptions {
......@@ -168,8 +208,31 @@ export interface DesktopBridge {
168208 getSavedEnvironmentSecret: (environmentId: EnvironmentId) => Promise<string | null>;
169209 setSavedEnvironmentSecret: (environmentId: EnvironmentId, secret: string) => Promise<boolean>;
170210 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>;
171229 getServerExposureState: () => Promise<DesktopServerExposureState>;
172230 setServerExposureMode: (mode: DesktopServerExposureMode) => Promise<DesktopServerExposureState>;
231 setTailscaleServeEnabled: (input: {
232 readonly enabled: boolean;
233 readonly port?: number;
234 }) => Promise<DesktopServerExposureState>;
235 getAdvertisedEndpoints: () => Promise<readonly AdvertisedEndpoint[]>;
173236 pickFolder: (options?: PickFolderOptions) => Promise<string | null>;
174237 confirm: (message: string) => Promise<boolean>;
175238 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({
198198export type ServerConfigUpdatedPayload = typeof ServerConfigUpdatedPayload.Type;
199199
200200export const ServerConfigKeybindingsUpdatedPayload = Schema.Struct({
201 keybindings: ResolvedKeybindingsConfig,
201202 issues: ServerConfigIssues,
202203});
203204export type ServerConfigKeybindingsUpdatedPayload =
packages/shared/package.json+4
......@@ -75,6 +75,10 @@
7575 "./path": {
7676 "types": "./src/path.ts",
7777 "import": "./src/path.ts"
78 },
79 "./keybindings": {
80 "types": "./src/keybindings.ts",
81 "import": "./src/keybindings.ts"
7882 }
7983 },
8084 "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 = {
1919 developmentWebAppleTouchIconPng: "assets/dev/blueprint-web-apple-touch-180.png",
2020} as const;
2121
22export type WebAssetBrand = "development" | "production";
23
2224export interface IconOverride {
2325 readonly sourceRelativePath: string;
2426 readonly targetRelativePath: string;
2527}
2628
27export const DEVELOPMENT_ICON_OVERRIDES: ReadonlyArray<IconOverride> = [
28 {
29 sourceRelativePath: BRAND_ASSET_PATHS.developmentWebFaviconIco,
30 targetRelativePath: "dist/client/favicon.ico",
31 },
32 {
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];
29const WEB_ICON_TARGET_FILENAMES = {
30 faviconIco: "favicon.ico",
31 favicon16Png: "favicon-16x16.png",
32 favicon32Png: "favicon-32x32.png",
33 appleTouchIconPng: "apple-touch-icon.png",
34} as const;
4535
46export const PUBLISH_ICON_OVERRIDES: ReadonlyArray<IconOverride> = [
47 {
48 sourceRelativePath: BRAND_ASSET_PATHS.productionWebFaviconIco,
49 targetRelativePath: "dist/client/favicon.ico",
36const WEB_ICON_SOURCE_PATHS_BY_BRAND = {
37 development: {
38 faviconIco: BRAND_ASSET_PATHS.developmentWebFaviconIco,
39 favicon16Png: BRAND_ASSET_PATHS.developmentWebFavicon16Png,
40 favicon32Png: BRAND_ASSET_PATHS.developmentWebFavicon32Png,
41 appleTouchIconPng: BRAND_ASSET_PATHS.developmentWebAppleTouchIconPng,
5042 },
51 {
52 sourceRelativePath: BRAND_ASSET_PATHS.productionWebFavicon16Png,
53 targetRelativePath: "dist/client/favicon-16x16.png",
43 production: {
44 faviconIco: BRAND_ASSET_PATHS.productionWebFaviconIco,
45 favicon16Png: BRAND_ASSET_PATHS.productionWebFavicon16Png,
46 favicon32Png: BRAND_ASSET_PATHS.productionWebFavicon32Png,
47 appleTouchIconPng: BRAND_ASSET_PATHS.productionWebAppleTouchIconPng,
5448 },
55 {
56 sourceRelativePath: BRAND_ASSET_PATHS.productionWebFavicon32Png,
57 targetRelativePath: "dist/client/favicon-32x32.png",
58 },
59 {
60 sourceRelativePath: BRAND_ASSET_PATHS.productionWebAppleTouchIconPng,
61 targetRelativePath: "dist/client/apple-touch-icon.png",
62 },
63];
49} as const satisfies Record<WebAssetBrand, Record<keyof typeof WEB_ICON_TARGET_FILENAMES, string>>;
50
51export function resolveWebIconOverrides(
52 brand: WebAssetBrand,
53 targetDirectory: string,
54): ReadonlyArray<IconOverride> {
55 const sourcePaths = WEB_ICON_SOURCE_PATHS_BY_BRAND[brand];
56 return [
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 = [
2424 "packages/client-runtime/package.json",
2525 "packages/contracts/package.json",
2626 "packages/shared/package.json",
27 "packages/ssh/package.json",
28 "packages/tailscale/package.json",
2729 "packages/effect-acp/package.json",
2830 "packages/effect-codex-app-server/package.json",
2931 "scripts/package.json",
......@@ -36,6 +38,17 @@ function copyWorkspaceManifestFixture(targetRoot: string): void {
3638 mkdirSync(dirname(destinationPath), { recursive: true });
3739 cpSync(sourcePath, destinationPath);
3840 }
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 }
3952}
4053
4154function writeMacManifestFixtures(targetRoot: string): { arm64Path: string; x64Path: string } {
turbo.json+2
......@@ -11,6 +11,8 @@
1111 "T3CODE_NO_BROWSER",
1212 "T3CODE_HOME",
1313 "T3CODE_AUTH_TOKEN",
14 "T3CODE_TAILSCALE_SERVE",
15 "T3CODE_TAILSCALE_SERVE_PORT",
1416 "T3CODE_DESKTOP_WS_URL",
1517 "T3CODE_TRACE_MIN_LEVEL",
1618 "T3CODE_TRACE_TIMING_ENABLED",