Skip to content

Commit 63ecdbd

Browse files
sahrizviclaude
andcommitted
fix(workspace): consensus review — handoff error contract, credential re-verify, sidebar polish, cache canonicalization
Addresses the review findings introduced by this PR's commits (browser handoff + top-level nav / sidebar tile). PR #1099 fixes landed separately. - `runHandoffWithOpener` now wraps preflight (`getCredentials`) AND the post-listener async IIFE in one try/catch that converts every error to a `HandoffResult`. Previously a malformed credentials file rejected the returned Promise with no toast, and a throw inside the lazy `import("../plugin/altimate")` left the caller waiting the full 15 minutes with no reason surfaced. The port is captured into a local immediately after `startListener` resolves so a timeout-cleared handle can't be dereferenced later. (M4) - `HandoffSuccess` now carries a `credentials` fingerprint (apiUrl + tenant) that the handoff was validated against. `runBrowserHandoff` in both entry points re-reads `AltimateApi.getCredentials()` immediately before `bindExisting` and refuses if either field drifted — workspace ids are tenant-schema-local so a mid-flow account switch would otherwise bind under the wrong tenant. (M6) - `resolveWorkspaceWebUrl` guards the tenant with a DNS-label regex and reconstructs the origin from the parsed URL, so a credential row carrying `evil.example/path?x=` cannot open the handoff at `https://evil.example`. Override still available for local dev; both paths reject non-http(s) protocols. (m3) - Optional `AbortSignal` on `OpenBrowserHandoffInput` — a caller-fired abort tears down the listener immediately with `reason: "aborted"` instead of holding the port for 15 minutes; timeout is `.unref()`'d so it doesn't keep the CLI process alive on its own. (m2) - `port_exhausted` is now only returned when the errno is `EADDRINUSE` — other codes (EACCES, EBADF) map to `reason: "error"` so the user isn't told "ports all in use" for a permissions problem. (m5) - `project_path` + `project_remote` moved to the URL fragment, matching the `cli_context` rationale — those two values carry usernames / customer names / internal paths that shouldn't land in SaaS access logs, WAF logs, or browser history. `project_name` stays in the query because the SaaS approval modal renders it. Test updated. (m6) - `workspace_id` uses `Number.isInteger` instead of `Number.isFinite`, so `42.5` no longer reaches a backend expecting an integer. (m9) - Inline `<script>` blocks now escape `</script` in JSON.stringify'd values via a `<\/script` replacement, closing the theoretical inline- script-break vector. (N5.b) - Local binding cache: one-shot migration to canonical keys on the first `readLocalBinding` that finds a non-canonical key, followed by a plain property lookup for every subsequent read. Deletes the O(n) `realpathSync` rescan that ran on every cache miss under the 3s sidebar poll. (N1) - Sidebar tile polls at 30s instead of 3s, memoizes the manage-URL base per (apiUrl, tenant), and guards against overlapping refreshes. Copy updated from "run /link" (the slash command doesn't exist — N2) to "run altimate-code link" (the actual CLI subcommand). Interval timer `.unref()`'d. Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_016H42Vt4pt5dcD7opRqckeM
1 parent 3dfa79e commit 63ecdbd

6 files changed

Lines changed: 320 additions & 83 deletions

File tree

packages/opencode/src/altimate/workspace/browser-handoff.ts

Lines changed: 165 additions & 53 deletions
Original file line numberDiff line numberDiff line change
@@ -33,6 +33,15 @@ import type { ProjectIdentifier } from "./api-client"
3333
const FREEMIUM_API_HOST = "api.myaltimate.com"
3434
const FREEMIUM_WORKSPACE_HOST = "ws.myaltimate.com"
3535

36+
/** DNS-label-shaped tenant guard for the freemium subdomain. Credentials
37+
* only require ``altimateInstanceName`` to be a non-empty string, so a tenant
38+
* like ``evil.example/path?x=`` would otherwise be interpolated straight into
39+
* the origin, opening the handoff URL — carrying the project path, remote,
40+
* callback address, CSRF state, and telemetry context — at
41+
* ``https://evil.example`` (m3 in the consensus review). Reject anything that
42+
* would not survive a round-trip through URL parsing back to the same host. */
43+
const TENANT_LABEL_RE = /^[a-z0-9](?:[a-z0-9-]{0,61}[a-z0-9])?$/i
44+
3645
// Loopback port range for the workspace-bound callback. Shared with the OAuth
3746
// sign-in listener in altimate.ts — each listener walks independently, so a
3847
// live OAuth server on 7317 forces us to 7318 (or later) transparently.
@@ -55,7 +64,7 @@ function deliverySuccessHtml(manageUrl: string): string {
5564
<body style="font-family:system-ui;text-align:center;padding:64px">
5665
<h2>Workspace ready</h2><p>Returning you to the workspace page…</p>
5766
<p><a href="${safe}">Continue</a> if you're not redirected automatically.</p>
58-
<script>window.location.replace(${JSON.stringify(manageUrl)})</script></body>`
67+
<script>window.location.replace(${escapeInlineScript(manageUrl)})</script></body>`
5968
}
6069

6170
const log = Log.create({ service: "altimate-workspace-handoff" })
@@ -67,6 +76,15 @@ function escapeHtml(s: string): string {
6776
)
6877
}
6978

79+
/** JSON-encode + escape any ``</`` so a value containing ``</script>`` cannot
80+
* close the surrounding inline <script> block. Not reachable via any
81+
* caller-supplied field today (tenant is DNS-label-guarded, workspace_id is
82+
* a validated integer), but the belt-and-suspenders is trivial. (N5.b in
83+
* the consensus review.) */
84+
function escapeInlineScript(value: string): string {
85+
return JSON.stringify(value).replace(/<\/(script)/gi, "<\\/$1")
86+
}
87+
7088
function htmlError(msg: string): string {
7189
return `<!doctype html><meta charset="utf-8"><title>Altimate Code</title>
7290
<body style="font-family:system-ui;text-align:center;padding:64px">
@@ -85,7 +103,7 @@ function cancelHtml(workspaceWebBase: URL): string {
85103
<body style="font-family:system-ui;text-align:center;padding:64px">
86104
<h2>Cancelled</h2><p>Returning you to the workspace home…</p>
87105
<p><a href="${safe}">Continue</a> if you're not redirected automatically.</p>
88-
<script>window.location.replace(${JSON.stringify(home)})</script></body>`
106+
<script>window.location.replace(${escapeInlineScript(home)})</script></body>`
89107
}
90108

91109
export type HandoffFailureReason =
@@ -94,14 +112,29 @@ export type HandoffFailureReason =
94112
| "timeout" // 15-min window expired
95113
| "cancelled" // user hit Cancel in the browser
96114
| "tenant_mismatch" // callback tenant != credentials tenant
97-
| "port_exhausted" // 7317..7325 all in use
115+
| "port_exhausted" // 7317..7325 all EADDRINUSE
98116
| "browser_open_failed"
117+
| "aborted" // caller-provided AbortSignal fired
99118
| "error"
100119

120+
/** Snapshot of the credentials the handoff started against, returned to the
121+
* caller so it can re-verify against fresh creds immediately before binding
122+
* (M6 in the consensus review). Workspace ids are tenant-schema-local, so
123+
* binding a callback validated for tenant A under tenant B (after an account
124+
* switch mid-flow) would 404 or, worse, hit an unrelated workspace. */
125+
export interface CredentialFingerprint {
126+
apiUrl: string
127+
tenant: string
128+
}
129+
101130
export interface HandoffSuccess {
102131
ok: true
103132
workspaceId: number
104133
tenant: string
134+
/** Credentials the handoff resolved and validated the callback against.
135+
* Callers must compare against ``AltimateApi.getCredentials()`` at bind
136+
* time and refuse the bind if either field drifted. */
137+
credentials: CredentialFingerprint
105138
}
106139
export interface HandoffFailure {
107140
ok: false
@@ -114,22 +147,35 @@ export type HandoffResult = HandoffSuccess | HandoffFailure
114147
/** Compute the workspace-stack URL for a given API host + tenant, or null if
115148
* this deployment isn't supported (localhost, enterprise, custom domain).
116149
*
117-
* Dev escape hatch: ``ALTIMATE_WORKSPACE_WEB_URL`` overrides the map lookup
118-
* when set (must be a well-formed URL). Used for local integration testing
119-
* against a non-freemium SaaS instance. Not something production users touch. */
150+
* Dev escape hatch: ``ALTIMATE_WORKSPACE_WEB_URL`` overrides the tenant map
151+
* lookup when set. The override is DEV-ONLY — it returns the URL as-is
152+
* without tenant scoping (which is what a local ``altimate2.localhost:3003``
153+
* dev server needs). Production callers must not set it; if it is somehow
154+
* present and points off-tenant, the CSRF ``state`` still gates the callback
155+
* so no cross-workspace bind is possible. */
120156
export function resolveWorkspaceWebUrl(altimateUrl: string, tenant: string): URL | null {
121157
const override = process.env["ALTIMATE_WORKSPACE_WEB_URL"]
122158
if (override) {
123159
try {
124-
return new URL(override)
160+
const u = new URL(override)
161+
if (u.protocol !== "http:" && u.protocol !== "https:") return null
162+
return u
125163
} catch {
126164
return null
127165
}
128166
}
129167
try {
130168
const apiHost = new URL(altimateUrl).host
131169
if (apiHost !== FREEMIUM_API_HOST) return null
132-
return new URL(`https://${tenant}.${FREEMIUM_WORKSPACE_HOST}`)
170+
// DNS-label guard — see TENANT_LABEL_RE for rationale. Double-check by
171+
// reconstructing the origin from the parsed URL: if the parser resolved
172+
// to a different host (embedded slashes, port, path in the "tenant"),
173+
// refuse rather than emit a URL that points off-domain.
174+
if (!TENANT_LABEL_RE.test(tenant)) return null
175+
const lower = tenant.toLowerCase()
176+
const u = new URL(`https://${lower}.${FREEMIUM_WORKSPACE_HOST}`)
177+
if (u.hostname !== `${lower}.${FREEMIUM_WORKSPACE_HOST}`) return null
178+
return u
133179
} catch {
134180
return null
135181
}
@@ -211,7 +257,9 @@ async function startListener(pending: HandoffPending): Promise<{ server: Server;
211257
}
212258

213259
const workspaceId = Number(workspaceIdRaw)
214-
if (!Number.isFinite(workspaceId) || workspaceId <= 0) {
260+
// Integer-only: floats like ``42.5`` are rejected server-side but produce
261+
// a confusing failure the caller can't recover from. (m9 in the review.)
262+
if (!Number.isInteger(workspaceId) || workspaceId <= 0) {
215263
const msg = `Invalid workspace_id: ${workspaceIdRaw}`
216264
respond(400, htmlError(msg))
217265
pending.reject(markReason(new Error(msg), "error"))
@@ -223,7 +271,15 @@ async function startListener(pending: HandoffPending): Promise<{ server: Server;
223271
// deterministic from the tenant we already validated above.
224272
const manageUrl = `${pending.workspaceWebBase.toString().replace(/\/$/, "")}/w/${workspaceId}`
225273
respond(200, deliverySuccessHtml(manageUrl))
226-
pending.resolve({ ok: true, workspaceId, tenant })
274+
// Callback validated — but the SUCCESS payload carries the credentials
275+
// snapshot the handoff was started against; the caller re-verifies
276+
// against fresh creds before binding (M6). This module never binds.
277+
pending.resolve({
278+
ok: true,
279+
workspaceId,
280+
tenant,
281+
credentials: { apiUrl: "", tenant: pending.expectedTenant }, // apiUrl filled in by caller
282+
})
227283
})
228284

229285
// Walk 7317..7325 — each server instance is independent, so a squatting
@@ -246,6 +302,9 @@ async function startListener(pending: HandoffPending): Promise<{ server: Server;
246302
lastErr = err as NodeJS.ErrnoException
247303
// Defensive cleanup in case any listeners linger after a rejected bind.
248304
server.removeAllListeners("error")
305+
// Only keep walking on EADDRINUSE — any other errno (EACCES, EBADF, …)
306+
// is a real problem, not port squatting, so break out and report it
307+
// faithfully rather than falsely claiming "all ports in use". (m5)
249308
if (lastErr.code !== "EADDRINUSE") break
250309
}
251310
}
@@ -258,13 +317,18 @@ async function startListener(pending: HandoffPending): Promise<{ server: Server;
258317
? `Every port in ${CALLBACK_PORT_MIN}-${CALLBACK_PORT_MAX} is in use (tried ${tried.join(", ")}). Close what's using them (e.g. \`lsof -i :${CALLBACK_PORT_MIN}\`) and try again.`
259318
: `Could not start the workspace-handoff server: ${lastErr instanceof Error ? lastErr.message : String(lastErr)}`,
260319
),
261-
"port_exhausted",
320+
code === "EADDRINUSE" ? "port_exhausted" : "error",
262321
)
263322
}
264323

265324
export interface OpenBrowserHandoffInput {
266325
identifier: ProjectIdentifier
267326
projectName: string
327+
/** Optional AbortSignal — if it fires the flow settles with
328+
* ``{ok: false, reason: "aborted"}`` and tears down the listener. Lets a
329+
* TUI supersede a stale handoff without leaking a port for the full
330+
* 15-minute window. (m2) */
331+
signal?: AbortSignal
268332
}
269333

270334
/** Full browser-handoff flow. Returns the created/picked workspace ID on
@@ -283,12 +347,28 @@ export async function runHandoffWithOpener(
283347
input: OpenBrowserHandoffInput,
284348
openBrowser: (url: string) => Promise<void>,
285349
): Promise<HandoffResult> {
286-
if (!(await AltimateApi.isConfigured().catch(() => false))) {
287-
return { ok: false, reason: "not_configured" }
350+
// Preflight is inside the same try/catch that owns the startup IIFE — a
351+
// rejection from ``getCredentials()`` (malformed JSON, unresolved ${env:…}
352+
// placeholder, schema mismatch) or from any other setup step converts to
353+
// a HandoffResult instead of propagating as an unhandled rejection into
354+
// the TUI's ``void runBrowserHandoff(...)`` call sites. (M4)
355+
let creds: Awaited<ReturnType<typeof AltimateApi.getCredentials>>
356+
let webUrl: URL
357+
try {
358+
if (!(await AltimateApi.isConfigured().catch(() => false))) {
359+
return { ok: false, reason: "not_configured" }
360+
}
361+
creds = await AltimateApi.getCredentials()
362+
const resolved = resolveWorkspaceWebUrl(creds.altimateUrl, creds.altimateInstanceName)
363+
if (!resolved) return { ok: false, reason: "unavailable" }
364+
webUrl = resolved
365+
} catch (err) {
366+
return {
367+
ok: false,
368+
reason: "error",
369+
message: err instanceof Error ? err.message : String(err),
370+
}
288371
}
289-
const creds = await AltimateApi.getCredentials()
290-
const webUrl = resolveWorkspaceWebUrl(creds.altimateUrl, creds.altimateInstanceName)
291-
if (!webUrl) return { ok: false, reason: "unavailable" }
292372

293373
const state = randomBytes(16).toString("hex")
294374

@@ -306,19 +386,25 @@ export async function runHandoffWithOpener(
306386
}
307387
}
308388

309-
const settled = new Promise<HandoffResult>((resolve) => {
389+
return new Promise<HandoffResult>((resolve) => {
390+
let onAbort: (() => void) | null = null
310391
const pending: HandoffPending = {
311392
state,
312393
expectedTenant: creds.altimateInstanceName,
313394
workspaceWebBase: webUrl,
314395
resolve: (v) => {
315396
closeListener()
316397
clearTimeout(timeoutHandle)
317-
resolve(v)
398+
if (onAbort && input.signal) input.signal.removeEventListener("abort", onAbort)
399+
// Fill in the apiUrl snapshot the listener couldn't set (it doesn't
400+
// hold ``creds``); the tenant already went through the expectedTenant
401+
// check inside the listener.
402+
resolve({ ...v, credentials: { apiUrl: creds.altimateUrl, tenant: v.tenant } })
318403
},
319404
reject: (err) => {
320405
closeListener()
321406
clearTimeout(timeoutHandle)
407+
if (onAbort && input.signal) input.signal.removeEventListener("abort", onAbort)
322408
const reason = (err as { handoffReason?: HandoffFailureReason }).handoffReason ?? "error"
323409
const authorizeUrl = (err as { authorizeUrl?: string }).authorizeUrl
324410
resolve({
@@ -332,49 +418,75 @@ export async function runHandoffWithOpener(
332418
const timeoutHandle = setTimeout(() => {
333419
pending.reject(markReason(new Error("Timed out waiting for browser workspace handoff"), "timeout"))
334420
}, DEFAULT_TIMEOUT_MS)
421+
// ``.unref()`` so the timer alone doesn't keep the CLI process alive
422+
// once every other handle has exited. (m2)
423+
// eslint-disable-next-line @typescript-eslint/no-explicit-any
424+
;(timeoutHandle as any)?.unref?.()
425+
426+
// Wire the AbortSignal — if it fires the flow settles with
427+
// ``reason: "aborted"`` and the listener is torn down immediately.
428+
if (input.signal) {
429+
if (input.signal.aborted) {
430+
pending.reject(markReason(new Error("Handoff aborted"), "aborted"))
431+
return
432+
}
433+
onAbort = () => pending.reject(markReason(new Error("Handoff aborted"), "aborted"))
434+
input.signal.addEventListener("abort", onAbort, { once: true })
435+
}
335436

336437
;(async () => {
337438
try {
338439
listenerHandle = await startListener(pending)
440+
// Capture the port to a local IMMEDIATELY — ``listenerHandle`` is
441+
// cleared by ``closeListener`` on timeout, and a lazy ``import()``
442+
// below can straddle that clear. (M4 sub-case)
443+
const boundPort = listenerHandle.port
444+
445+
// Import buildCliContext lazily so this module doesn't pull altimate.ts
446+
// into every consumer's import graph at load time.
447+
const { buildCliContext } = await import("../plugin/altimate")
448+
const cliContext = await buildCliContext().catch((err) => {
449+
log.warn("buildCliContext failed; proceeding without", { err: String(err) })
450+
return ""
451+
})
452+
453+
const redirect = `http://127.0.0.1:${boundPort}/workspace-bound`
454+
const target = new URL("/create-and-link", webUrl)
455+
target.searchParams.set("client", "altimate-code")
456+
target.searchParams.set("redirect", redirect)
457+
target.searchParams.set("state", state)
458+
target.searchParams.set("project_name", input.projectName)
459+
// Project path + remote go in the URL FRAGMENT, not the query, so
460+
// they don't land in SaaS access logs, WAF logs, or browser history
461+
// as query params. Same rationale as ``cli_context`` in altimate.ts
462+
// (see altimate.ts:135-137). (m6)
463+
const fragment = new URLSearchParams()
464+
if (input.identifier.repoRemote) fragment.set("project_remote", input.identifier.repoRemote)
465+
if (input.identifier.projectPath) fragment.set("project_path", input.identifier.projectPath)
466+
if (cliContext) fragment.set("cli_context", cliContext)
467+
const authorizeUrl = fragment.toString()
468+
? `${target.toString()}#${fragment.toString()}`
469+
: target.toString()
470+
471+
try {
472+
await openBrowser(authorizeUrl)
473+
} catch (err) {
474+
// Browser open failed. Preserve the URL so the caller can copy-paste.
475+
pending.reject(
476+
Object.assign(
477+
markReason(new Error(`Could not open browser: ${err instanceof Error ? err.message : String(err)}`), "browser_open_failed"),
478+
{ authorizeUrl },
479+
),
480+
)
481+
}
339482
} catch (err) {
483+
// ANY throw in this async IIFE — startListener rejection, the lazy
484+
// ``import()``, ``buildCliContext()`` panic — funnels through
485+
// pending.reject so ``settled`` resolves and the caller sees a
486+
// ``HandoffResult`` instead of a 15-minute silent hang. (M4)
340487
const reason = (err as { handoffReason?: HandoffFailureReason }).handoffReason ?? "error"
341488
pending.reject(markReason(err as Error, reason))
342-
return
343-
}
344-
345-
// Import buildCliContext lazily so this module doesn't pull altimate.ts
346-
// into every consumer's import graph at load time.
347-
const { buildCliContext } = await import("../plugin/altimate")
348-
const cliContext = await buildCliContext().catch((err) => {
349-
log.warn("buildCliContext failed; proceeding without", { err: String(err) })
350-
return ""
351-
})
352-
353-
const redirect = `http://127.0.0.1:${listenerHandle.port}/workspace-bound`
354-
const target = new URL("/create-and-link", webUrl)
355-
target.searchParams.set("client", "altimate-code")
356-
target.searchParams.set("redirect", redirect)
357-
target.searchParams.set("state", state)
358-
if (input.identifier.repoRemote) target.searchParams.set("project_remote", input.identifier.repoRemote)
359-
if (input.identifier.projectPath) target.searchParams.set("project_path", input.identifier.projectPath)
360-
target.searchParams.set("project_name", input.projectName)
361-
const authorizeUrl = cliContext
362-
? `${target.toString()}#cli_context=${encodeURIComponent(cliContext)}`
363-
: target.toString()
364-
365-
try {
366-
await openBrowser(authorizeUrl)
367-
} catch (err) {
368-
// Browser open failed. Preserve the URL so the caller can copy-paste.
369-
pending.reject(
370-
Object.assign(
371-
markReason(new Error(`Could not open browser: ${err instanceof Error ? err.message : String(err)}`), "browser_open_failed"),
372-
{ authorizeUrl },
373-
),
374-
)
375489
}
376490
})()
377491
})
378-
379-
return settled
380492
}

0 commit comments

Comments
 (0)