Skip to content

Commit d03168b

Browse files
committed
fix(serve): canonicalise prefixed app entry
Reject malformed base paths such as //prefix before URL generation so the client cannot drift onto a scheme-relative host. Serve prefixed apps at /prefix/, redirect /prefix to that canonical route, and set the manifest scope explicitly so the PWA entry, scope, and proxy behaviour stay aligned.
1 parent 816dcb6 commit d03168b

9 files changed

Lines changed: 93 additions & 20 deletions

File tree

.agents/skills/remobi-setup/references/tailscale-serve.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -26,7 +26,7 @@ By default `remobi serve` binds to `127.0.0.1`, so it is not exposed on your LAN
2626
tailscale serve --bg 7681
2727
```
2828

29-
Your terminal is now available at `https://<your-machine>.<tailnet>.ts.net`.
29+
Your terminal is now available at `https://<your-machine>.<tailnet>.ts.net`. If you publish remobi behind a path prefix instead of the root, start remobi with `--base-path /that-prefix` so the WebSocket and PWA URLs stay aligned with the external URL.
3030

3131
On mobile, tap **Add to Home Screen** for a standalone app experience with the remobi icon.
3232

docs/architecture/networking-and-websockets.md

Lines changed: 7 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
# Networking and WebSocket flow
22

3-
This page explains how a browser reaches remobi, how the `/ws` transport works, and how the shared terminal session stays in sync across clients.
3+
This page explains how a browser reaches remobi, how the WebSocket transport works, and how the shared terminal session stays in sync across clients.
44

55
For the high-level runtime layout, see [How remobi works](how-remobi-works.md).
66

@@ -18,6 +18,8 @@ The browser talks to two server entry points:
1818
- `GET /` for the HTML document with inline JS, CSS, config, and CSP nonce
1919
- `GET /ws` for the terminal WebSocket
2020

21+
When `remobi serve --base-path /prefix` is used, remobi also serves the same HTML, WebSocket, manifest, and icon routes under `/prefix/...`. Root routes stay available for direct local access.
22+
2123
## Browser-to-session sequence
2224

2325
```mermaid
@@ -27,9 +29,9 @@ sequenceDiagram
2729
participant Session as SharedTerminalSession
2830
participant PTY as node-pty command
2931
30-
Browser->>Server: GET /
32+
Browser->>Server: GET / or /prefix
3133
Server-->>Browser: HTML + inline config + client bundle
32-
Browser->>Server: GET /ws (upgrade)
34+
Browser->>Server: GET /ws or /prefix/ws (upgrade)
3335
Server->>Server: Validate Origin against Host
3436
Server->>Session: addClient(client)
3537
Note over Session,Browser: live output may race with snapshot
@@ -90,14 +92,14 @@ That is why the browser client has both snapshot handling and pending-output buf
9092

9193
The current server behaviour matters for docs because remobi is usually deployed behind another network layer:
9294

93-
- `/ws` upgrades are gated by an Origin check against the request Host header
95+
- `/ws` upgrades, including prefixed variants such as `/prefix/ws`, are gated by an Origin check against the request Host header
9496
- when no Origin is sent, loopback hosts are the only implicit allow case
9597
- CSP `connect-src` is scoped to the request authority, including explicit `ws://` and `wss://` entries for Safari compatibility
9698
- security headers are applied to both HTML and WebSocket-adjacent responses
9799

98100
## Client-side connection behaviour
99101

100-
The browser opens exactly one terminal socket to `${location.host}/ws`.
102+
The browser opens exactly one terminal socket to `${location.host}${basePath}/ws`, where `basePath` is `/` by default and can be overridden with `--base-path`.
101103

102104
- before the socket opens, outbound messages are queued locally
103105
- on open, the client sends a resize based on the fitted terminal size, then flushes queued messages

src/base-path.ts

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,10 @@ export function normalizeBasePath(value: string): string | null {
1616
return '/'
1717
}
1818

19+
if (trimmed.includes('//')) {
20+
return null
21+
}
22+
1923
return trimmed
2024
}
2125

@@ -34,3 +38,7 @@ export function joinBasePath(basePath: string, path: string): string {
3438
export function documentRoute(basePath: string): string {
3539
return basePath === '/' ? '/' : `${basePath}/`
3640
}
41+
42+
export function bareDocumentRoute(basePath: string): string | null {
43+
return basePath === '/' ? null : basePath
44+
}

src/pwa/manifest.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@ interface WebAppManifest {
55
readonly name: string
66
readonly short_name: string
77
readonly start_url: string
8+
readonly scope: string
89
readonly display: string
910
readonly background_color: string
1011
readonly theme_color: string
@@ -22,6 +23,7 @@ export function generateManifest(name: string, pwa: PwaConfig, basePath = '/'):
2223
name,
2324
short_name: pwa.shortName ?? name,
2425
start_url: documentRoute(basePath),
26+
scope: documentRoute(basePath),
2527
display: 'standalone',
2628
background_color: pwa.themeColor,
2729
theme_color: pwa.themeColor,

src/serve.ts

Lines changed: 9 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -7,7 +7,7 @@ import { Hono } from 'hono'
77
import type { WSContext } from 'hono/ws'
88
import type WebSocket from 'ws'
99
import { bundleClientAssets, renderClientHtml } from '../build'
10-
import { documentRoute, joinBasePath } from './base-path'
10+
import { bareDocumentRoute, documentRoute, joinBasePath } from './base-path'
1111
import { manifestToJson } from './pwa/manifest'
1212
import type { SessionClient, SharedTerminalSession } from './session'
1313
import {
@@ -414,13 +414,17 @@ export async function serve(
414414

415415
const canonicalDocumentRoute = documentRoute(basePath)
416416
if (canonicalDocumentRoute !== '/') {
417-
app.get(basePath, (c) =>
418-
withSecurityHeaders(c.html(html), securityHeadersForRequest(c.req.header('host'))),
419-
)
420-
421417
app.get(canonicalDocumentRoute, (c) =>
422418
withSecurityHeaders(c.html(html), securityHeadersForRequest(c.req.header('host'))),
423419
)
420+
421+
const bareRoute = bareDocumentRoute(basePath)
422+
if (bareRoute) {
423+
app.get(bareRoute, (c) => {
424+
const securityHeaders = securityHeadersForRequest(c.req.header('host'))
425+
return withSecurityHeaders(c.redirect(canonicalDocumentRoute, 308), securityHeaders)
426+
})
427+
}
424428
}
425429

426430
if (manifestJson !== null) {

tests/base-path.test.ts

Lines changed: 9 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
11
import { describe, expect, test } from 'vitest'
2-
import { documentRoute, joinBasePath, normalizeBasePath } from '../src/base-path'
2+
import { bareDocumentRoute, documentRoute, joinBasePath, normalizeBasePath } from '../src/base-path'
33

44
describe('normalizeBasePath', () => {
55
test('accepts root and trims trailing slash for nested paths', () => {
@@ -8,8 +8,10 @@ describe('normalizeBasePath', () => {
88
expect(normalizeBasePath('/proxy/nested/')).toBe('/proxy/nested')
99
})
1010

11-
test('rejects relative paths and URL suffixes', () => {
11+
test('rejects relative paths, repeated slashes, and URL suffixes', () => {
1212
expect(normalizeBasePath('proxy')).toBeNull()
13+
expect(normalizeBasePath('//proxy')).toBeNull()
14+
expect(normalizeBasePath('/proxy//nested')).toBeNull()
1315
expect(normalizeBasePath('/proxy?x=1')).toBeNull()
1416
expect(normalizeBasePath('/proxy#frag')).toBeNull()
1517
})
@@ -32,4 +34,9 @@ describe('document routes', () => {
3234
expect(documentRoute('/')).toBe('/')
3335
expect(documentRoute('/proxy')).toBe('/proxy/')
3436
})
37+
38+
test('exposes the bare route only for nested base paths', () => {
39+
expect(bareDocumentRoute('/')).toBeNull()
40+
expect(bareDocumentRoute('/proxy')).toBe('/proxy')
41+
})
3542
})

tests/cli-args.test.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -336,7 +336,7 @@ describe('parseCliArgs', () => {
336336
})
337337

338338
test('rejects invalid --base-path values', () => {
339-
for (const value of ['', 'proxy', '/proxy?x=1', '/proxy#frag']) {
339+
for (const value of ['', 'proxy', '//proxy', '/proxy//nested', '/proxy?x=1', '/proxy#frag']) {
340340
const result = parseCliArgs(['serve', '--base-path', value])
341341
expect(result.ok).toBe(false)
342342
if (!result.ok) {

tests/playwright/proxy.spec.ts

Lines changed: 40 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -63,17 +63,39 @@ async function waitForHttp(url: string, timeoutMs = 10_000): Promise<void> {
6363
throw new Error(`timed out waiting for ${url}`)
6464
}
6565

66+
function rewriteProxyPath(requestUrl: string | undefined, basePath: string): string | null {
67+
const path = requestUrl ?? '/'
68+
if (basePath === '/') {
69+
return path
70+
}
71+
if (path === basePath || path === `${basePath}/`) {
72+
return path
73+
}
74+
if (!path.startsWith(`${basePath}/`)) {
75+
return null
76+
}
77+
return path
78+
}
79+
6680
async function createReverseProxy(
6781
backendPort: number,
6882
proxyPort: number,
83+
basePath = '/',
6984
): Promise<{ close(): Promise<void> }> {
7085
const sockets = new Set<Socket>()
7186
const server = createServer((request, response) => {
87+
const upstreamPath = rewriteProxyPath(request.url, basePath)
88+
if (upstreamPath === null) {
89+
response.statusCode = 404
90+
response.end('not found')
91+
return
92+
}
93+
7294
const upstream = httpRequest(
7395
{
7496
hostname: '127.0.0.1',
7597
port: backendPort,
76-
path: request.url ?? '/',
98+
path: upstreamPath,
7799
method: request.method,
78100
headers: request.headers,
79101
},
@@ -98,6 +120,12 @@ async function createReverseProxy(
98120
})
99121

100122
server.on('upgrade', (request, socket, head) => {
123+
const upstreamPath = rewriteProxyPath(request.url, basePath)
124+
if (upstreamPath === null) {
125+
socket.destroy()
126+
return
127+
}
128+
101129
const upstreamSocket = connect(backendPort, '127.0.0.1', () => {
102130
const headerLines = Object.entries(request.headers).flatMap(([name, value]) => {
103131
if (typeof value === 'string') {
@@ -109,7 +137,7 @@ async function createReverseProxy(
109137
return []
110138
})
111139
const handshake = [
112-
`${request.method ?? 'GET'} ${request.url ?? '/ws'} HTTP/${request.httpVersion}`,
140+
`${request.method ?? 'GET'} ${upstreamPath} HTTP/${request.httpVersion}`,
113141
...headerLines,
114142
'',
115143
'',
@@ -153,9 +181,12 @@ async function createReverseProxy(
153181
}
154182
}
155183

156-
test('reverse-proxied access uses request-scoped CSP and a live websocket', async ({ page }) => {
184+
test('reverse-proxied subpath access uses request-scoped CSP and a live websocket', async ({
185+
page,
186+
}) => {
157187
const backendPort = await reservePort()
158188
const proxyPort = await reservePort()
189+
const basePath = '/random-token'
159190
const home = mkdtempSync(join(tmpdir(), 'remobi-playwright-home-'))
160191
tempDirs.push(home)
161192

@@ -168,6 +199,8 @@ test('reverse-proxied access uses request-scoped CSP and a live websocket', asyn
168199
'serve',
169200
'--port',
170201
String(backendPort),
202+
'--base-path',
203+
basePath,
171204
'--',
172205
'bash',
173206
'--norc',
@@ -186,7 +219,7 @@ test('reverse-proxied access uses request-scoped CSP and a live websocket', asyn
186219
exited = true
187220
})
188221

189-
const proxy = await createReverseProxy(backendPort, proxyPort)
222+
const proxy = await createReverseProxy(backendPort, proxyPort, basePath)
190223
const consoleErrors: string[] = []
191224
page.on('console', (message) => {
192225
if (message.type() === 'error') {
@@ -196,11 +229,12 @@ test('reverse-proxied access uses request-scoped CSP and a live websocket', asyn
196229

197230
try {
198231
await waitForHttp(`http://127.0.0.1:${backendPort}`)
199-
await waitForHttp(`http://127.0.0.1:${proxyPort}`)
232+
await waitForHttp(`http://127.0.0.1:${proxyPort}${basePath}`)
200233

201-
const response = await page.goto(`http://127.0.0.1:${proxyPort}`)
234+
const response = await page.goto(`http://127.0.0.1:${proxyPort}${basePath}`)
202235
expect(response).not.toBeNull()
203236
const csp = response?.headers()['content-security-policy'] ?? ''
237+
expect(page.url()).toBe(`http://127.0.0.1:${proxyPort}${basePath}/`)
204238
expect(csp).toContain(`ws://127.0.0.1:${proxyPort}`)
205239
expect(csp).toContain(`wss://127.0.0.1:${proxyPort}`)
206240
expect(csp).not.toContain(`ws://127.0.0.1:${backendPort}`)

tests/pwa.test.ts

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -70,6 +70,15 @@ describe('generateManifest', () => {
7070
}
7171
})
7272

73+
test('prefixes start_url, scope, and icon paths when mounted under a subpath', () => {
74+
const manifest = generateManifest('remobi', defaultPwa, '/proxy')
75+
expect(manifest.start_url).toBe('/proxy/')
76+
expect(manifest.scope).toBe('/proxy/')
77+
for (const icon of manifest.icons) {
78+
expect(icon.src.startsWith('/proxy/')).toBe(true)
79+
}
80+
})
81+
7382
test('custom name is reflected', () => {
7483
const manifest = generateManifest('My Terminal', { ...defaultPwa, shortName: 'Term' })
7584
expect(manifest.name).toBe('My Terminal')
@@ -99,6 +108,7 @@ describe('manifestToJson', () => {
99108
const manifest = generateManifest('remobi', defaultPwa)
100109
expect(parsed.name).toBe(manifest.name)
101110
expect(parsed.display).toBe(manifest.display)
111+
expect(parsed.scope).toBe(manifest.scope)
102112
})
103113
})
104114

@@ -109,6 +119,12 @@ describe('generatePwaHtml', () => {
109119
expect(html).toContain('href="/manifest.json"')
110120
})
111121

122+
test('prefixes asset links when mounted under a subpath', () => {
123+
const html = generatePwaHtml('remobi', defaultPwa, '/proxy')
124+
expect(html).toContain('href="/proxy/manifest.json"')
125+
expect(html).toContain('href="/proxy/apple-touch-icon.png"')
126+
})
127+
112128
test('includes theme-color meta', () => {
113129
const html = generatePwaHtml('remobi', defaultPwa)
114130
expect(html).toContain('name="theme-color"')

0 commit comments

Comments
 (0)