Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
51 changes: 32 additions & 19 deletions packages/opencode/src/altimate/workspace/browser-handoff.ts
Original file line number Diff line number Diff line change
@@ -1,10 +1,10 @@
// altimate_change - new file
//
// Browser-based workspace creation handoff. CLI opens Ralph's SaaS approval
// modal at ``<tenant>.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 ``<tenant>.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
Expand All @@ -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
Expand Down Expand Up @@ -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 `<!doctype html><meta charset="utf-8"><title>Altimate Code</title>
<meta http-equiv="refresh" content="0;url=${safe}">
Expand Down Expand Up @@ -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) {
Expand All @@ -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
Expand All @@ -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()
Expand All @@ -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
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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)
Expand Down
81 changes: 73 additions & 8 deletions packages/opencode/test/altimate/workspace/browser-handoff.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -52,13 +52,20 @@ function parseHandoffUrl(url: string): { port: number; state: string; redirect:
return { port, state, redirect }
}

async function fireCallback(redirect: string, params: Record<string, string>): Promise<void> {
async function fireCallback(redirect: string, params: Record<string, string>): Promise<string> {
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(/<meta http-equiv="refresh" content="0;url=([^"]+)">/)?.[1]
}

/** Send a raw HTTP/1.1 request with an attacker-controlled ``Host`` header —
Expand Down Expand Up @@ -106,10 +113,10 @@ function isolateWebUrlOverride() {

describe("resolveWorkspaceWebUrl", () => {
isolateWebUrlOverride()
test("freemium API host resolves to <tenant>.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)", () => {
Expand All @@ -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/<id> 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",
)
})

Expand All @@ -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")
})
})
})

// ─────────────────────────────────────────────────────────────────────────────
Expand Down Expand Up @@ -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<string> | 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<string> | 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<string> | 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")
})
})

Expand Down
12 changes: 6 additions & 6 deletions packages/opencode/test/cli/cmd/link.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -195,33 +195,33 @@ 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")
})

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\\",
)
})

test("sanitizes an adversarial name so it cannot open a second, spoofed link", () => {
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)
Expand Down
Loading