extension: normalise the address, and always https

Pasting the address you use for Officer produced http://https://platform.officer.dev:18792/ — the
scheme was taken as part of the hostname and http:// prepended anyway. It failed as "Relay not
reachable", which reads as the relay being down rather than the URL being mangled.

normalizeHost strips a scheme, a path and credentials, keeps IPv6 brackets, and lifts a port out of
the address so pasting host:9000 moves the port field instead of dropping it. The field is rewritten
with the result, so what is shown is what is stored.

The scheme is now always https, and wss for the socket, whatever was typed. The relay carries a
pairing token on every request and drives a browser someone is signed into; plaintext because a field
was left blank is not a default worth having.

CONSEQUENCE, stated rather than discovered: the relay itself still serves plain HTTP on 18792, so the
extension cannot connect until it is behind TLS. That is a proxy host away and it is the right shape —
the raw port was never reachable from anywhere but the tailnet anyway.

The unreachable message now says what was tried and that the port is normally private, instead of
"verify the server address", which is true and useless when the address is fine.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
2026-08-21 01:47:31 +00:00
co-authored by Claude Opus 5
parent c20d03e9f5
commit 3346b4c853
4 changed files with 66 additions and 11 deletions
+4 -2
View File
@@ -38,8 +38,10 @@ export async function buildRelayWsUrl(host, port, gatewayToken) {
"Missing relay token in extension settings (chrome.storage.local.relayToken)",
);
}
// Token is already derived server-side — use it directly
return `ws://${host}:${port}/extension?token=${encodeURIComponent(token)}`;
// wss, always — the matching half of the options page forcing https. The token is on the query string
// because a WebSocket handshake from an extension cannot set headers, so plaintext here would put a
// live credential on the wire in full view.
return `wss://${host}:${port}/extension?token=${encodeURIComponent(token)}`;
}
export function isRetryableReconnectError(err) {
+1 -1
View File
@@ -132,7 +132,7 @@ async function ensureRelayConnection() {
const port = await getRelayPort()
const host = await getRelayHost()
const token = await getRelayToken()
const httpBase = `http://${host}:${port}`
const httpBase = `https://${host}:${port}`
const wsUrl = await buildRelayWsUrl(host, port, token)
try {
+50 -2
View File
@@ -5,6 +5,49 @@ function hasCdpVersionShape(data) {
return !!data && typeof data === 'object' && 'Browser' in data && 'Protocol-Version' in data
}
/**
* Turn whatever someone typed into a bare host.
*
* People paste the address they use for Officer, which has a scheme on it and sometimes a port and a
* path. Taking it raw produced `http://https://platform.officer.dev:18792/` — a URL that cannot resolve,
* reported as "Relay not reachable", which reads as the relay being down rather than the address being
* mangled. Reported by the owner, with a screenshot, as "very very bad".
*
* Returns the host and, when the input carried one, the port — so pasting `host:1234` moves the port
* field rather than being silently dropped.
*
* ── The scheme is always https ──
*
* Whatever was typed. `http://` in the box becomes https, and nothing becomes https. The relay carries a
* pairing token on every request and drives a browser someone is signed into; sending that over plaintext
* because a field was left blank is not a default worth having.
*/
export function normalizeHost(input) {
let value = String(input || '').trim()
if (!value) return { host: '', port: null }
value = value.replace(/^[a-z][a-z0-9+.-]*:\/\//i, '') // scheme — discarded, https is forced below
value = value.replace(/\/.*$/, '') // path
value = value.replace(/^[^@]*@/, '') // credentials, if someone pastes them
// IPv6 in brackets keeps its colons; anything else splits on the last one.
let port = null
const bracketed = value.match(/^\[(.+)\](?::(\d+))?$/)
if (bracketed) {
value = bracketed[1]
if (bracketed[2]) port = Number(bracketed[2])
} else if (/^[^:]+:(\d+)$/.test(value)) {
const [h, p] = value.split(':')
value = h
port = Number(p)
}
return { host: value, port }
}
/** The one place a relay URL is spelled. https always — see `normalizeHost`. */
export const relayUrl = (host, port, path = '') => `https://${host}:${port}${path}`
export function classifyRelayCheckResponse(res, host, port) {
if (!res) {
return { action: 'throw', error: 'No response from service worker' }
@@ -47,7 +90,7 @@ export function classifyRelayCheckResponse(res, host, port) {
}
}
return { action: 'status', kind: 'ok', message: `Relay reachable and authenticated at http://${host}:${port}/` }
return { action: 'status', kind: 'ok', message: `Relay reachable and authenticated at ${relayUrl(host, port, '/')}` }
}
export function classifyRelayCheckException(err, host, port) {
@@ -59,8 +102,13 @@ export function classifyRelayCheckException(err, host, port) {
}
}
// Say what was actually tried and what usually explains it. "Verify the server address" is true and
// useless — the address is usually a real one that simply does not carry this port.
return {
kind: 'error',
message: `Relay not reachable at http://${host}:${port}/. Verify the server address and relay token.`,
message:
`Nothing answered at ${relayUrl(host, port, '/')}. The relay listens on the machine Officer runs on, ` +
`on a port that is normally reachable only over your private network — a public hostname will not ` +
`have it open. Try the address you use to SSH to that machine.`,
}
}
+11 -6
View File
@@ -1,4 +1,4 @@
import { classifyRelayCheckException, classifyRelayCheckResponse } from './options-validation.js'
import { classifyRelayCheckException, classifyRelayCheckResponse, normalizeHost, relayUrl } from './options-validation.js'
const DEFAULT_PORT = 18792
const DEFAULT_HOST = '127.0.0.1'
@@ -13,7 +13,7 @@ function clampPort(value) {
function updateRelayUrl(host, port) {
const el = document.getElementById('relay-url')
if (!el) return
el.textContent = `http://${host}:${port}/`
el.textContent = relayUrl(host, port, '/')
}
function setStatus(kind, message) {
@@ -24,7 +24,7 @@ function setStatus(kind, message) {
}
async function checkRelayReachable(host, port, token) {
const url = `http://${host}:${port}/json/version`
const url = relayUrl(host, port, '/json/version')
const trimmedToken = String(token || '').trim()
if (!trimmedToken) {
setStatus('error', 'Relay token required. Save your token to connect.')
@@ -49,7 +49,7 @@ async function checkRelayReachable(host, port, token) {
async function load() {
const stored = await chrome.storage.local.get(['relayPort', 'relayHost', 'relayToken'])
const port = clampPort(stored.relayPort)
const host = String(stored.relayHost || '').trim() || DEFAULT_HOST
const host = normalizeHost(stored.relayHost).host || DEFAULT_HOST
const token = String(stored.relayToken || '').trim()
document.getElementById('host').value = host
document.getElementById('port').value = String(port)
@@ -62,8 +62,13 @@ async function save() {
const hostInput = document.getElementById('host')
const portInput = document.getElementById('port')
const tokenInput = document.getElementById('token')
const host = String(hostInput.value || '').trim() || DEFAULT_HOST
const port = clampPort(portInput.value)
// Normalised on the way in, and the field is rewritten with the result — so what is stored and what is
// shown are the same string, and a pasted `https://host/path` visibly becomes `host` rather than being
// quietly reinterpreted.
const parsed = normalizeHost(hostInput.value)
const host = parsed.host || DEFAULT_HOST
// A port in the address wins over a stale one in the port box: someone pasting `host:9000` means 9000.
const port = clampPort(parsed.port ?? portInput.value)
const token = String(tokenInput.value || '').trim()
await chrome.storage.local.set({ relayHost: host, relayPort: port, relayToken: token })
hostInput.value = host