From 14fa02ca7b828c7898542423450c13653a7c0ec4 Mon Sep 17 00:00:00 2001 From: ralphstodomingo Date: Wed, 23 Sep 2026 11:57:16 +0800 Subject: [PATCH] feat: open workspace pages in the SaaS app's /workspaces mount Workspace URLs now target `.app.myaltimate.com/workspaces` instead of `.ws.myaltimate.com`. The hand-off page, success bounce and cancel landing share one join that keeps the base path. Co-Authored-By: Claude Opus 5.5 (1M context) Claude-Session: https://claude.ai/code/session_01LXpVmgHbm9Qzn54SQPpqDK --- .../src/altimate/workspace/browser-handoff.ts | 51 +++++++----- .../workspace/browser-handoff.test.ts | 81 +++++++++++++++++-- packages/opencode/test/cli/cmd/link.test.ts | 12 +-- 3 files changed, 111 insertions(+), 33 deletions(-) diff --git a/packages/opencode/src/altimate/workspace/browser-handoff.ts b/packages/opencode/src/altimate/workspace/browser-handoff.ts index e5405ae351..1f9cb6468d 100644 --- a/packages/opencode/src/altimate/workspace/browser-handoff.ts +++ b/packages/opencode/src/altimate/workspace/browser-handoff.ts @@ -1,10 +1,10 @@ // altimate_change - new file // // Browser-based workspace creation handoff. CLI opens Ralph's SaaS approval -// modal at ``.ws.myaltimate.com/create-and-link`` with the current -// project's context, user approves, the SaaS creates a workspace and delivers -// its ID back to the CLI via a loopback callback. The CLI then binds the -// current project to that workspace via the existing +// modal at ``.app.myaltimate.com/workspaces/create-and-link`` with the +// current project's context, user approves, the SaaS creates a workspace and +// delivers its ID back to the CLI via a loopback callback. The CLI then binds +// the current project to that workspace via the existing // ``POST /datamate-project-bindings/bind`` endpoint. // // This module deliberately DUPLICATES the loopback listener pattern from @@ -26,12 +26,16 @@ import { Log } from "@/altimate/util/log" import type { ProjectIdentifier } from "./api-client" -// Freemium is the only deployment served by the workspace stack today. When +// Freemium is the only deployment the CLI hands off to today. When // altimate-backend goes multi-deployment (enterprise), extend this to a small // mapping. Returning null means "not supported here" — the CLI hides the // browser-handoff option entirely rather than open a broken URL. const FREEMIUM_API_HOST = "api.myaltimate.com" -const FREEMIUM_WORKSPACE_HOST = "ws.myaltimate.com" +const FREEMIUM_APP_HOST = "app.myaltimate.com" +// The SaaS app serves the workspace pages under this path. Every workspace +// URL the CLI builds (hand-off page, manage page, cancel landing) is joined +// onto the base under it. +const WORKSPACES_PATH = "/workspaces" /** DNS-label-shaped tenant guard for the freemium subdomain. Credentials * only require ``altimateInstanceName`` to be a non-empty string, so a tenant @@ -96,7 +100,7 @@ function htmlError(msg: string): string { * so the user doesn't get stranded on the plain loopback page. Same top-level * navigation mechanism as ``deliverySuccessHtml``. */ function cancelHtml(workspaceWebBase: URL): string { - const home = workspaceWebBase.toString().replace(/\/$/, "") + "/" + const home = joinWorkspacePath(workspaceWebBase, "/") const safe = escapeHtml(home) return `Altimate Code @@ -144,15 +148,16 @@ export interface HandoffFailure { } export type HandoffResult = HandoffSuccess | HandoffFailure -/** Compute the workspace-stack URL for a given API host + tenant, or null if - * this deployment isn't supported (localhost, enterprise, custom domain). +/** Compute the base URL of the tenant's workspace pages (the SaaS app's + * ``/workspaces`` mount) for a given API host + tenant, or null if this + * deployment isn't supported (localhost, enterprise, custom domain). * * Dev escape hatch: ``ALTIMATE_WORKSPACE_WEB_URL`` overrides the tenant map * lookup when set. The override is DEV-ONLY — it returns the URL as-is - * without tenant scoping (which is what a local ``altimate2.localhost:3003`` - * dev server needs). Production callers must not set it; if it is somehow - * present and points off-tenant, the CSRF ``state`` still gates the callback - * so no cross-workspace bind is possible. */ + * without tenant scoping, so it names the mount itself (a local dev server's + * ``http://altimate2.localhost:3003/workspaces``). Production callers must not + * set it; if it is somehow present and points off-tenant, the CSRF ``state`` + * still gates the callback so no cross-workspace bind is possible. */ export function resolveWorkspaceWebUrl(altimateUrl: string, tenant: string): URL | null { const override = process.env["ALTIMATE_WORKSPACE_WEB_URL"] if (override) { @@ -176,8 +181,8 @@ export function resolveWorkspaceWebUrl(altimateUrl: string, tenant: string): URL // refuse rather than emit a URL that points off-domain. if (!TENANT_LABEL_RE.test(tenant)) return null const lower = tenant.toLowerCase() - const u = new URL(`https://${lower}.${FREEMIUM_WORKSPACE_HOST}`) - if (u.hostname !== `${lower}.${FREEMIUM_WORKSPACE_HOST}`) return null + const u = new URL(`https://${lower}.${FREEMIUM_APP_HOST}${WORKSPACES_PATH}`) + if (u.hostname !== `${lower}.${FREEMIUM_APP_HOST}`) return null return u } catch { return null @@ -201,8 +206,16 @@ export function resolveWorkspaceWebUrl(altimateUrl: string, tenant: string): URL * — one bug (missing this exact fix) needed three separate edits to close. * (multi-model review, PR #1274 round 7.) */ export function buildManageUrl(base: URL, workspaceId: number): string { + return joinWorkspacePath(base, `/w/${workspaceId}`) +} + +/** Join a root-relative workspace page path onto ``base``'s pathname, keeping + * the base's own path (the ``/workspaces`` mount, or an override's path) — + * ``new URL("/x", base)`` would replace it. The one join behind every + * workspace URL this module builds. */ +function joinWorkspacePath(base: URL, path: string): string { const u = new URL(base) - u.pathname = `${u.pathname.replace(/\/+$/, "")}/w/${workspaceId}` + u.pathname = `${u.pathname.replace(/\/+$/, "")}${path}` u.search = "" u.hash = "" return u.toString() @@ -211,7 +224,7 @@ export function buildManageUrl(base: URL, workspaceId: number): string { interface HandoffPending { state: string expectedTenant: string - /** Base URL for the tenant's SaaS workspace stack, used to build the + /** Base URL for the tenant's SaaS workspace pages, used to build the * ``/w/:id`` bounce target that the loopback success HTML redirects to. */ workspaceWebBase: URL resolve: (v: HandoffSuccess) => void @@ -326,7 +339,7 @@ async function startListener(pending: HandoffPending): Promise<{ server: Server; // Bounce the browser back to the SaaS workspace page. Loopback constructs // the URL itself (no need to trust a `return` query param) — the base is // deterministic from the tenant we already validated above. - const manageUrl = `${pending.workspaceWebBase.toString().replace(/\/$/, "")}/w/${workspaceId}` + const manageUrl = buildManageUrl(pending.workspaceWebBase, workspaceId) respond(200, deliverySuccessHtml(manageUrl)) // Callback validated — but the SUCCESS payload carries the credentials // snapshot the handoff was started against; the caller re-verifies @@ -549,7 +562,7 @@ export async function runHandoffWithOpener( }) const redirect = `http://127.0.0.1:${boundPort}/workspace-bound` - const target = new URL("/create-and-link", webUrl) + const target = new URL(joinWorkspacePath(webUrl, "/create-and-link")) target.searchParams.set("client", "altimate-code") target.searchParams.set("redirect", redirect) target.searchParams.set("state", state) diff --git a/packages/opencode/test/altimate/workspace/browser-handoff.test.ts b/packages/opencode/test/altimate/workspace/browser-handoff.test.ts index f94ce5ffc0..aa0d66c8ed 100644 --- a/packages/opencode/test/altimate/workspace/browser-handoff.test.ts +++ b/packages/opencode/test/altimate/workspace/browser-handoff.test.ts @@ -52,13 +52,20 @@ function parseHandoffUrl(url: string): { port: number; state: string; redirect: return { port, state, redirect } } -async function fireCallback(redirect: string, params: Record): Promise { +async function fireCallback(redirect: string, params: Record): Promise { const target = new URL(redirect) for (const [k, v] of Object.entries(params)) target.searchParams.set(k, v) const res = await fetch(target.toString(), { method: "GET" }) // Drain body so the connection can close and let the CLI's `close()` - // proceed without hanging on lingering sockets. - await res.text().catch(() => "") + // proceed without hanging on lingering sockets. Returned for the tests that + // read where the loopback page sends the browser next — the flow settles on + // the request, before the body is read, so await the call itself. + return res.text().catch(() => "") +} + +/** The URL a loopback response page navigates the browser to. */ +function bounceTarget(html: string): string | undefined { + return html.match(//)?.[1] } /** Send a raw HTTP/1.1 request with an attacker-controlled ``Host`` header — @@ -106,10 +113,10 @@ function isolateWebUrlOverride() { describe("resolveWorkspaceWebUrl", () => { isolateWebUrlOverride() - test("freemium API host resolves to .ws.myaltimate.com", () => { + test("freemium API host resolves to the SaaS app's /workspaces mount", () => { const url = resolveWorkspaceWebUrl("https://api.myaltimate.com", "acme") expect(url).not.toBeNull() - expect(url!.toString()).toBe("https://acme.ws.myaltimate.com/") + expect(url!.toString()).toBe("https://acme.app.myaltimate.com/workspaces") }) test("localhost API returns null (browser flow not supported in dev)", () => { @@ -134,8 +141,8 @@ describe("resolveWorkspaceWebUrl", () => { // one bug (a naive string-concat URL join) needed three separate fixes to close. describe("buildManageUrl", () => { test("appends /w/ to a bare origin", () => { - expect(buildManageUrl(new URL("https://tenant.ws.myaltimate.com"), 4242)).toBe( - "https://tenant.ws.myaltimate.com/w/4242", + expect(buildManageUrl(new URL("https://tenant.app.myaltimate.com"), 4242)).toBe( + "https://tenant.app.myaltimate.com/w/4242", ) }) @@ -150,6 +157,14 @@ describe("buildManageUrl", () => { test("normalizes a trailing slash on the base path", () => { expect(buildManageUrl(new URL("https://host/base/"), 7)).toBe("https://host/base/w/7") }) + + describe("on a resolved base", () => { + isolateWebUrlOverride() + test("keeps the /workspaces mount", () => { + const base = resolveWorkspaceWebUrl("https://api.myaltimate.com", "acme")! + expect(buildManageUrl(base, 4242)).toBe("https://acme.app.myaltimate.com/workspaces/w/4242") + }) + }) }) // ───────────────────────────────────────────────────────────────────────────── @@ -359,7 +374,57 @@ describe("runHandoffWithOpener end-to-end", () => { expect(frag.get("project_name")).toBe("foo") expect(frag.get("project_remote")).toBe("git@github.com:acme/foo.git") expect(frag.get("project_path")).toBe("/w/foo") - expect(u.pathname).toBe("/create-and-link") + expect(u.origin).toBe("https://acme.app.myaltimate.com") + expect(u.pathname).toBe("/workspaces/create-and-link") + }) + + test("success page sends the browser to the workspace's page under /workspaces", async () => { + let page: Promise | undefined + const result = await runHandoffWithOpener( + { identifier: { projectPath: "/x" }, projectName: "x" }, + async (url) => { + const { state, redirect } = parseHandoffUrl(url) + page = fireCallback(redirect, { workspace_id: "42", state, tenant: "acme" }) + await page + }, + ) + expect(result.ok).toBe(true) + expect(bounceTarget(await page!)).toBe("https://acme.app.myaltimate.com/workspaces/w/42") + }) + + test("cancel page sends the browser to the workspaces list", async () => { + let page: Promise | undefined + const result = await runHandoffWithOpener( + { identifier: { projectPath: "/x" }, projectName: "x" }, + async (url) => { + const { state, redirect } = parseHandoffUrl(url) + page = fireCallback(redirect, { state, error: "cancelled", tenant: "acme" }) + await page + }, + ) + expect(result.ok).toBe(false) + expect(bounceTarget(await page!)).toBe("https://acme.app.myaltimate.com/workspaces/") + }) + + // The dev override names a local server's mount; its path must survive into + // every URL, which ``new URL("/create-and-link", base)`` would have dropped. + test("the dev override's path prefixes the hand-off page and the manage page", async () => { + process.env["ALTIMATE_WORKSPACE_WEB_URL"] = "http://acme.localhost:3000/workspaces" + let authorizeUrl = "" + let page: Promise | undefined + const result = await runHandoffWithOpener( + { identifier: { projectPath: "/x" }, projectName: "x" }, + async (url) => { + authorizeUrl = url + const { state, redirect } = parseHandoffUrl(url) + page = fireCallback(redirect, { workspace_id: "7", state, tenant: "acme" }) + await page + }, + ) + expect(result.ok).toBe(true) + const u = new URL(authorizeUrl) + expect(u.origin + u.pathname).toBe("http://acme.localhost:3000/workspaces/create-and-link") + expect(bounceTarget(await page!)).toBe("http://acme.localhost:3000/workspaces/w/7") }) }) diff --git a/packages/opencode/test/cli/cmd/link.test.ts b/packages/opencode/test/cli/cmd/link.test.ts index 3d29a6ef49..6d325d7560 100644 --- a/packages/opencode/test/cli/cmd/link.test.ts +++ b/packages/opencode/test/cli/cmd/link.test.ts @@ -195,7 +195,7 @@ describe("hyperlink", () => { // raw OSC 8 would land as literal junk in the captured output. setTTY(false) process.env.TERM_PROGRAM = "iTerm.app" - const out = hyperlink("anas-skill-test", "https://tenant.ws.myaltimate.com/w/4242") + const out = hyperlink("anas-skill-test", "https://tenant.app.myaltimate.com/workspaces/w/4242") expect(out).toBe("anas-skill-test") expect(out).not.toContain("\x1b") }) @@ -203,17 +203,17 @@ describe("hyperlink", () => { test("wraps text in OSC 8 with no underline on a TTY whose terminal isn't recognized", () => { setTTY(true) delete process.env.TERM_PROGRAM - const out = hyperlink("anas-skill-test", "https://tenant.ws.myaltimate.com/w/4242") - expect(out).toBe("\x1b]8;;https://tenant.ws.myaltimate.com/w/4242\x1b\\anas-skill-test\x1b]8;;\x1b\\") + const out = hyperlink("anas-skill-test", "https://tenant.app.myaltimate.com/workspaces/w/4242") + expect(out).toBe("\x1b]8;;https://tenant.app.myaltimate.com/workspaces/w/4242\x1b\\anas-skill-test\x1b]8;;\x1b\\") expect(out).not.toContain("\x1b[4m") }) test("wraps text in OSC 8 plus underline when the terminal is recognized as supporting", () => { setTTY(true) process.env.TERM_PROGRAM = "iTerm.app" - const out = hyperlink("anas-skill-test", "https://tenant.ws.myaltimate.com/w/4242") + const out = hyperlink("anas-skill-test", "https://tenant.app.myaltimate.com/workspaces/w/4242") expect(out).toBe( - "\x1b]8;;https://tenant.ws.myaltimate.com/w/4242\x1b\\\x1b[4manas-skill-test\x1b[24m\x1b]8;;\x1b\\", + "\x1b]8;;https://tenant.app.myaltimate.com/workspaces/w/4242\x1b\\\x1b[4manas-skill-test\x1b[24m\x1b]8;;\x1b\\", ) }) @@ -221,7 +221,7 @@ describe("hyperlink", () => { setTTY(true) delete process.env.TERM_PROGRAM const malicious = "name\x1b]8;;http://evil.example\x1b\\CLICK ME\x1b]8;;\x1b\\" - const out = hyperlink(malicious, "https://tenant.ws.myaltimate.com/w/4242") + const out = hyperlink(malicious, "https://tenant.app.myaltimate.com/workspaces/w/4242") // Exactly one real OSC 8 open + one real OSC 8 close — the malicious // payload's own OSC 8 bytes were stripped, leaving only inert text. expect(out.split("\x1b]8;;").length - 1).toBe(2)