diff --git a/packages/opencode/src/altimate/workspace/browser-handoff.ts b/packages/opencode/src/altimate/workspace/browser-handoff.ts new file mode 100644 index 000000000..b58b45ead --- /dev/null +++ b/packages/opencode/src/altimate/workspace/browser-handoff.ts @@ -0,0 +1,533 @@ +// 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 +// ``POST /datamate-project-bindings/bind`` endpoint. +// +// This module deliberately DUPLICATES the loopback listener pattern from +// ``altimate/plugin/altimate.ts`` rather than sharing a helper — the two flows +// are similar enough that a naive extraction would trade duplication for +// coupling on state/global lifecycle. Refactor to a shared helper is a +// follow-up ticket once both flows have prod experience; the port range +// (7317..7325) is walked independently by each listener instance so a live +// OAuth server on 7317 forces workspace-handoff to bind 7318 without either +// close operation affecting the other. +// +// See docs `workspace-browser-handoff-plan-v3.md` for the design context. +import { createServer, type Server } from "http" +import { randomBytes } from "crypto" +import open from "open" + +import { AltimateApi } from "@/altimate/api/client" +import { Log } from "@/altimate/util/log" + +import type { ProjectIdentifier } from "./api-client" + +// Freemium is the only deployment served by the workspace stack 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" + +/** DNS-label-shaped tenant guard for the freemium subdomain. Credentials + * only require ``altimateInstanceName`` to be a non-empty string, so a tenant + * like ``evil.example/path?x=`` would otherwise be interpolated straight into + * the origin, opening the handoff URL — carrying the project path, remote, + * callback address, CSRF state, and telemetry context — at + * ``https://evil.example`` (m3 in the consensus review). Reject anything that + * would not survive a round-trip through URL parsing back to the same host. */ +const TENANT_LABEL_RE = /^[a-z0-9](?:[a-z0-9-]{0,61}[a-z0-9])?$/i + +// Loopback port range for the workspace-bound callback. Shared with the OAuth +// sign-in listener in altimate.ts — each listener walks independently, so a +// live OAuth server on 7317 forces us to 7318 (or later) transparently. +const CALLBACK_PORT_MIN = 7317 +const CALLBACK_PORT_MAX = 7325 + +const DEFAULT_TIMEOUT_MS = 15 * 60 * 1000 + +/** Loopback success page. We land the user back on the SaaS workspace page via + * top-level navigation — matches the OAuth sign-in pattern in altimate.ts, + * which is proven in prod. The rationale over a subresource fetch: HTTPS→HTTP + * loopback subresource fetches trigger Chrome/Safari Private Network Access + * checks (preflight OPTIONS with Access-Control-Request-Private-Network); a + * top-level navigation from an HTTP 302 or ``window.location.href`` bypasses + * PNA entirely. Meta refresh + JS assign for belt-and-suspenders. */ +function deliverySuccessHtml(manageUrl: string): string { + const safe = escapeHtml(manageUrl) + return `Altimate Code + + +

Workspace ready

Returning you to the workspace page…

+

Continue if you're not redirected automatically.

+` +} + +const log = Log.create({ service: "altimate-workspace-handoff" }) + +function escapeHtml(s: string): string { + return s.replace( + /[&<>"']/g, + (c) => ({ "&": "&", "<": "<", ">": ">", '"': """, "'": "'" })[c] as string, + ) +} + +/** JSON-encode + escape any ```` cannot + * close the surrounding inline ` +} + +export type HandoffFailureReason = + | "unavailable" // resolveWorkspaceWebUrl returned null (not freemium) + | "not_configured" // CLI credentials not present + | "timeout" // 15-min window expired + | "cancelled" // user hit Cancel in the browser + | "tenant_mismatch" // callback tenant != credentials tenant + | "port_exhausted" // 7317..7325 all EADDRINUSE + | "browser_open_failed" + | "aborted" // caller-provided AbortSignal fired + | "error" + +/** Snapshot of the credentials the handoff started against, returned to the + * caller so it can re-verify against fresh creds immediately before binding + * (M6 in the consensus review). Workspace ids are tenant-schema-local, so + * binding a callback validated for tenant A under tenant B (after an account + * switch mid-flow) would 404 or, worse, hit an unrelated workspace. */ +export interface CredentialFingerprint { + apiUrl: string + tenant: string +} + +export interface HandoffSuccess { + ok: true + workspaceId: number + tenant: string + /** Credentials the handoff resolved and validated the callback against. + * Callers must compare against ``AltimateApi.getCredentials()`` at bind + * time and refuse the bind if either field drifted. */ + credentials: CredentialFingerprint +} +export interface HandoffFailure { + ok: false + reason: HandoffFailureReason + message?: string + authorizeUrl?: string // set for browser_open_failed so caller can copy-paste +} +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). + * + * 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. */ +export function resolveWorkspaceWebUrl(altimateUrl: string, tenant: string): URL | null { + const override = process.env["ALTIMATE_WORKSPACE_WEB_URL"] + if (override) { + try { + const u = new URL(override) + if (u.protocol !== "http:" && u.protocol !== "https:") return null + return u + } catch { + return null + } + } + try { + const apiHost = new URL(altimateUrl).host + if (apiHost !== FREEMIUM_API_HOST) return null + // DNS-label guard — see TENANT_LABEL_RE for rationale. Double-check by + // reconstructing the origin from the parsed URL: if the parser resolved + // to a different host (embedded slashes, port, path in the "tenant"), + // 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 + return u + } catch { + return null + } +} + +interface HandoffPending { + state: string + expectedTenant: string + /** Base URL for the tenant's SaaS workspace stack, used to build the + * ``/w/:id`` bounce target that the loopback success HTML redirects to. */ + workspaceWebBase: URL + resolve: (v: HandoffSuccess) => void + reject: (err: Error & { handoffReason?: HandoffFailureReason }) => void +} + +function markReason(err: E, reason: HandoffFailureReason): E & { handoffReason: HandoffFailureReason } { + return Object.assign(err, { handoffReason: reason }) +} + +/** Start a per-flow loopback listener on the first available port in the + * shared 7317..7325 range. Own server, own pending map — no coupling to the + * OAuth listener in altimate.ts. */ +async function startListener(pending: HandoffPending): Promise<{ server: Server; port: number }> { + const server = createServer((req, res) => { + const port = (server.address() as { port?: number } | null)?.port ?? CALLBACK_PORT_MIN + const url = new URL(req.url || "/", `http://127.0.0.1:${port}`) + if (url.pathname !== "/workspace-bound") { + res.writeHead(404) + res.end("Not found") + return + } + + const respond = (status: number, body: string) => { + res.writeHead(status, { "Content-Type": "text/html" }) + res.end(body) + } + + // Validate state FIRST — a request without the right state can neither + // cancel nor deliver anything. + const state = url.searchParams.get("state") + if (!state || state !== pending.state) { + respond(400, htmlError("Invalid or unknown workspace-handoff state")) + return + } + + // Respond BEFORE resolving/rejecting the pending flow — the reject path + // closes the listener via closeListener(), which can race with the + // response flush and leave the client fetch hanging. Order matters. + const error = url.searchParams.get("error") + if (error) { + const reason: HandoffFailureReason = error === "cancelled" ? "cancelled" : "error" + // Cancel bounces the browser back to the SaaS workspace home so the user + // isn't stranded on the plain loopback page; hard errors keep the plain + // error card (there's no useful place to bounce them to). + const body = error === "cancelled" ? cancelHtml(pending.workspaceWebBase) : htmlError(error) + respond(200, body) + pending.reject(markReason(new Error(error), reason)) + return + } + + const workspaceIdRaw = url.searchParams.get("workspace_id") + const tenant = url.searchParams.get("tenant") + if (!workspaceIdRaw || !tenant) { + const msg = "Missing workspace_id or tenant in callback" + respond(400, htmlError(msg)) + pending.reject(markReason(new Error(msg), "error")) + return + } + + if (tenant !== pending.expectedTenant) { + // Cross-tenant defence: user created the workspace in a tenant that + // doesn't match the CLI's credentials. Refuse the bind — the workspace + // ID is tenant-schema-local so binding here would 404 or, worse, hit an + // unrelated workspace in the CLI's tenant. + const msg = `Workspace was created in tenant "${tenant}" but the CLI is signed into "${pending.expectedTenant}"` + respond(400, htmlError(msg)) + pending.reject(markReason(new Error(msg), "tenant_mismatch")) + return + } + + // Integer-only: floats like ``42.5`` are rejected server-side but produce + // a confusing failure the caller can't recover from. (m9 in the review.) + // Also reject non-canonical spellings — ``Number()`` happily coerces + // ``"1e2"``, ``"0x2a"``, and ``" 42 "`` into finite integers, so a + // callback URL carrying those forms would slip past ``isInteger`` and + // reach the bind payload. Requiring a plain decimal-digit string first + // is the tight gate. (cubic cycle 4/5.) + if (!/^[1-9][0-9]*$/.test(workspaceIdRaw)) { + const msg = `Invalid workspace_id: ${workspaceIdRaw}` + respond(400, htmlError(msg)) + pending.reject(markReason(new Error(msg), "error")) + return + } + const workspaceId = Number(workspaceIdRaw) + if (!Number.isInteger(workspaceId) || workspaceId <= 0) { + const msg = `Invalid workspace_id: ${workspaceIdRaw}` + respond(400, htmlError(msg)) + pending.reject(markReason(new Error(msg), "error")) + return + } + + // 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}` + respond(200, deliverySuccessHtml(manageUrl)) + // Callback validated — but the SUCCESS payload carries the credentials + // snapshot the handoff was started against; the caller re-verifies + // against fresh creds before binding (M6). This module never binds. + pending.resolve({ + ok: true, + workspaceId, + tenant, + credentials: { apiUrl: "", tenant: pending.expectedTenant }, // apiUrl filled in by caller + }) + }) + + // Walk 7317..7325 — each server instance is independent, so a squatting + // OAuth listener on 7317 just makes us bind 7318. + const tried: number[] = [] + let lastErr: NodeJS.ErrnoException | undefined + for (let port = CALLBACK_PORT_MIN; port <= CALLBACK_PORT_MAX; port++) { + tried.push(port) + try { + await new Promise((resolve, reject) => { + const onErr = (err: NodeJS.ErrnoException) => reject(err) + server.once("error", onErr) + server.listen(port, "127.0.0.1", () => { + server.removeListener("error", onErr) + resolve() + }) + }) + // Post-listen persistent error handler. Without this, any socket-level + // ``error`` event during the ~15-minute wait (spurious ECONNRESET, + // client abort mid-request, an OS EMFILE spike) is unhandled and takes + // the process down. We can't do anything useful with the error — the + // listener is per-flow and short-lived — so log-and-continue is the + // right call. (CodeRabbit cycle 6.) + server.on("error", (err: NodeJS.ErrnoException) => { + log.warn("handoff loopback listener emitted a post-listen error", { + code: err.code, + err: err.message, + }) + }) + return { server, port } + } catch (err) { + lastErr = err as NodeJS.ErrnoException + // Defensive cleanup in case any listeners linger after a rejected bind. + server.removeAllListeners("error") + // Only keep walking on EADDRINUSE — any other errno (EACCES, EBADF, …) + // is a real problem, not port squatting, so break out and report it + // faithfully rather than falsely claiming "all ports in use". (m5) + if (lastErr.code !== "EADDRINUSE") break + } + } + + server.close() + const code = lastErr?.code + throw markReason( + new Error( + code === "EADDRINUSE" + ? `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.` + : `Could not start the workspace-handoff server: ${lastErr instanceof Error ? lastErr.message : String(lastErr)}`, + ), + code === "EADDRINUSE" ? "port_exhausted" : "error", + ) +} + +export interface OpenBrowserHandoffInput { + identifier: ProjectIdentifier + projectName: string + /** Optional AbortSignal — if it fires the flow settles with + * ``{ok: false, reason: "aborted"}`` and tears down the listener. Lets a + * TUI supersede a stale handoff without leaking a port for the full + * 15-minute window. (m2) */ + signal?: AbortSignal +} + +/** Full browser-handoff flow. Returns the created/picked workspace ID on + * success, or a typed failure reason on any error path. Never throws — every + * error is expressed as ``{ok: false, reason}`` so the caller can toast the + * appropriate message. */ +export async function openWorkspaceBrowserHandoff(input: OpenBrowserHandoffInput): Promise { + return runHandoffWithOpener(input, (url) => open(url).then(() => undefined)) +} + +/** Same as ``openWorkspaceBrowserHandoff`` but takes the browser-open callback + * as a dependency so tests can inject a fake that fires the loopback callback + * synchronously instead of launching a real browser. Not exported from the + * package barrel — only tests import this directly. */ +export async function runHandoffWithOpener( + input: OpenBrowserHandoffInput, + openBrowser: (url: string) => Promise, +): Promise { + // Preflight is inside the same try/catch that owns the startup IIFE — a + // rejection from ``getCredentials()`` (malformed JSON, unresolved ${env:…} + // placeholder, schema mismatch) or from any other setup step converts to + // a HandoffResult instead of propagating as an unhandled rejection into + // the TUI's ``void runBrowserHandoff(...)`` call sites. (M4) + let creds: Awaited> + let webUrl: URL + try { + if (!(await AltimateApi.isConfigured().catch(() => false))) { + return { ok: false, reason: "not_configured" } + } + creds = await AltimateApi.getCredentials() + const resolved = resolveWorkspaceWebUrl(creds.altimateUrl, creds.altimateInstanceName) + if (!resolved) return { ok: false, reason: "unavailable" } + webUrl = resolved + } catch (err) { + return { + ok: false, + reason: "error", + message: err instanceof Error ? err.message : String(err), + } + } + + const state = randomBytes(16).toString("hex") + + // Register pending, then bind the listener. Timeout owns rejection with + // reason "timeout"; the listener's own reject paths mark their own reasons. + let listenerHandle: { server: Server; port: number } | undefined + // ``settled`` reflects whether ``pending.resolve``/``pending.reject`` has + // fired. Needed to close the server if the flow rejects (timeout / abort / + // browser-open failure) DURING the ``await startListener(pending)`` window + // — otherwise ``closeListener`` runs while ``listenerHandle`` is still + // undefined, then the awaited startListener returns a server that never + // gets closed and stays bound for the full 15-min timeout. (cubic cycle 5.) + let settled = false + const closeListener = () => { + if (listenerHandle) { + try { + listenerHandle.server.close() + } catch { + /* best effort */ + } + listenerHandle = undefined + } + } + + return new Promise((resolve) => { + let onAbort: (() => void) | null = null + const pending: HandoffPending = { + state, + expectedTenant: creds.altimateInstanceName, + workspaceWebBase: webUrl, + resolve: (v) => { + settled = true + closeListener() + clearTimeout(timeoutHandle) + if (onAbort && input.signal) input.signal.removeEventListener("abort", onAbort) + // Fill in the apiUrl snapshot the listener couldn't set (it doesn't + // hold ``creds``); the tenant already went through the expectedTenant + // check inside the listener. + resolve({ ...v, credentials: { apiUrl: creds.altimateUrl, tenant: v.tenant } }) + }, + reject: (err) => { + settled = true + closeListener() + clearTimeout(timeoutHandle) + if (onAbort && input.signal) input.signal.removeEventListener("abort", onAbort) + const reason = (err as { handoffReason?: HandoffFailureReason }).handoffReason ?? "error" + const authorizeUrl = (err as { authorizeUrl?: string }).authorizeUrl + resolve({ + ok: false, + reason, + message: err.message, + ...(authorizeUrl ? { authorizeUrl } : {}), + }) + }, + } + const timeoutHandle = setTimeout(() => { + pending.reject(markReason(new Error("Timed out waiting for browser workspace handoff"), "timeout")) + }, DEFAULT_TIMEOUT_MS) + // ``.unref()`` so the timer alone doesn't keep the CLI process alive + // once every other handle has exited. (m2) + // eslint-disable-next-line @typescript-eslint/no-explicit-any + ;(timeoutHandle as any)?.unref?.() + + // Wire the AbortSignal — if it fires the flow settles with + // ``reason: "aborted"`` and the listener is torn down immediately. + if (input.signal) { + if (input.signal.aborted) { + pending.reject(markReason(new Error("Handoff aborted"), "aborted")) + return + } + onAbort = () => pending.reject(markReason(new Error("Handoff aborted"), "aborted")) + input.signal.addEventListener("abort", onAbort, { once: true }) + } + + ;(async () => { + try { + listenerHandle = await startListener(pending) + // If the flow already settled during the ``await`` above (timeout + // fired, abort fired, browser-open failed), ``closeListener`` ran + // with ``listenerHandle`` still undefined — nothing was closed. Now + // that we own a real handle, close it and bail so it doesn't sit + // bound for the full timeout. (cubic cycle 5.) + if (settled) { + closeListener() + return + } + // Capture the port to a local IMMEDIATELY — ``listenerHandle`` is + // cleared by ``closeListener`` on timeout, and a lazy ``import()`` + // below can straddle that clear. (M4 sub-case) + const boundPort = listenerHandle.port + + // Import buildCliContext lazily so this module doesn't pull altimate.ts + // into every consumer's import graph at load time. + const { buildCliContext } = await import("../plugin/altimate") + const cliContext = await buildCliContext().catch((err) => { + log.warn("buildCliContext failed; proceeding without", { err: String(err) }) + return "" + }) + + const redirect = `http://127.0.0.1:${boundPort}/workspace-bound` + const target = new URL("/create-and-link", webUrl) + target.searchParams.set("client", "altimate-code") + target.searchParams.set("redirect", redirect) + target.searchParams.set("state", state) + target.searchParams.set("project_name", input.projectName) + // Project path + remote go in the URL FRAGMENT, not the query, so + // they don't land in SaaS access logs, WAF logs, or browser history + // as query params. Same rationale as ``cli_context`` in altimate.ts + // (see altimate.ts:135-137). (m6) + const fragment = new URLSearchParams() + if (input.identifier.repoRemote) fragment.set("project_remote", input.identifier.repoRemote) + if (input.identifier.projectPath) fragment.set("project_path", input.identifier.projectPath) + if (cliContext) fragment.set("cli_context", cliContext) + const authorizeUrl = fragment.toString() + ? `${target.toString()}#${fragment.toString()}` + : target.toString() + + try { + await openBrowser(authorizeUrl) + } catch (err) { + // Browser open failed. Preserve the URL so the caller can copy-paste. + pending.reject( + Object.assign( + markReason(new Error(`Could not open browser: ${err instanceof Error ? err.message : String(err)}`), "browser_open_failed"), + { authorizeUrl }, + ), + ) + } + } catch (err) { + // ANY throw in this async IIFE — startListener rejection, the lazy + // ``import()``, ``buildCliContext()`` panic — funnels through + // pending.reject so ``settled`` resolves and the caller sees a + // ``HandoffResult`` instead of a 15-minute silent hang. (M4) + const reason = (err as { handoffReason?: HandoffFailureReason }).handoffReason ?? "error" + pending.reject(markReason(err as Error, reason)) + } + })() + }) +} diff --git a/packages/opencode/src/altimate/workspace/state.ts b/packages/opencode/src/altimate/workspace/state.ts index 5ada4971f..075f861c7 100644 --- a/packages/opencode/src/altimate/workspace/state.ts +++ b/packages/opencode/src/altimate/workspace/state.ts @@ -21,24 +21,6 @@ const CACHE_VERSION = 1 const log = Log.create({ service: "altimate-workspace-state" }) -/** Canonicalize a directory into a stable cache key so callers passing - * ``/tmp/foo``, ``/private/tmp/foo`` (macOS symlink), ``/tmp/foo/``, or a - * relative path all read/write the same row. Uses ``path.resolve`` first so - * relative inputs anchor to cwd, then ``realpathSync`` to collapse symlinks - * and trailing separators. Falls back to the resolved-only form when the - * path doesn't exist on disk (e.g. cache written for a repo that has since - * moved) — better a stable-if-unresolved key than an exception that skips - * the cache entirely. (cubic + kilo cycle 6 — different clients keyed the - * same project under different paths.) */ -function canonicalDirKey(directory: string): string { - const resolved = path.resolve(directory) - try { - return realpathSync(resolved) - } catch { - return resolved - } -} - export interface CachedBinding { datamateId: number datamateName: string @@ -109,6 +91,32 @@ function readCache(): CacheFile | null { } } +/** True when every key in the cache is already the canonical form of itself + * (i.e. no earlier-CLI-build unresolved keys remain). Cheap side condition + * so we can skip the per-read migration once the cache has been rewritten. */ +function isCanonicalized(cache: CacheFile): boolean { + for (const k of Object.keys(cache.bindings)) { + if (canonicalizeKey(k) !== k) return false + } + return true +} + +/** One-shot migration: rewrite the cache with canonical keys, collapsing any + * pair that resolves to the same target (last-writer-wins by ``linkedAt``). + * After this runs the O(n) lookup-time rescan in ``readLocalBinding`` is + * dead code — every subsequent read hits the direct key lookup. */ +function migrateToCanonicalKeys(cache: CacheFile): CacheFile { + const migrated: Record = {} + for (const [k, v] of Object.entries(cache.bindings)) { + const canon = canonicalizeKey(k) + const existing = migrated[canon] + if (!existing || existing.linkedAt <= v.linkedAt) migrated[canon] = v + } + const next: CacheFile = { ...cache, bindings: migrated } + writeCache(next) + return next +} + function writeCache(cache: CacheFile): void { const p = cachePath() Filesystem.writeJsonAtomic(p, cache) @@ -124,6 +132,18 @@ function writeCache(cache: CacheFile): void { } } +/** Canonicalize a directory path so cache lookups survive symlink differences + * (macOS ``/tmp`` → ``/private/tmp`` is the common case). Writers and readers + * must both funnel through this or a shell-cwd write silently misses when the + * TUI's canonicalized ``state.path.directory`` looks it back up. */ +function canonicalizeKey(directory: string): string { + try { + return realpathSync(path.resolve(directory)) + } catch { + return path.resolve(directory) + } +} + async function tenantKey(): Promise<{ tenant: string; apiUrl: string } | null> { // Best-effort: ``AltimateApi.getCredentials`` can throw ``SyntaxError`` on // a corrupt credentials JSON, ``ZodError`` on schema drift, or a raw @@ -145,15 +165,27 @@ async function tenantKey(): Promise<{ tenant: string; apiUrl: string } | null> { } /** Read the local binding for ``directory`` — only returns a hit when the - * cache's stored (tenant, apiUrl) matches the current credentials. Directory - * is canonicalized so raw / symlink / trailing-slash variants collide. */ + * cache's stored (tenant, apiUrl) matches the current credentials. Runs a + * one-shot migration to canonical keys on the first read that finds an + * unresolved key (macOS ``/tmp`` → ``/private/tmp``), then relies on direct + * lookup for the process's remaining lifetime. */ export async function readLocalBinding(directory: string): Promise { const key = await tenantKey() if (!key) return null - const cache = readCache() + let cache = readCache() if (!cache) return null if (cache.tenant !== key.tenant || cache.apiUrl !== key.apiUrl) return null - return cache.bindings[canonicalDirKey(directory)] ?? null + const canon = canonicalizeKey(directory) + const direct = cache.bindings[canon] + if (direct) return direct + // Cache miss: check if the cache still has any non-canonical keys and + // migrate the whole file once. After migration the lookup is a plain + // property access on every future read. + if (!isCanonicalized(cache)) { + cache = migrateToCanonicalKeys(cache) + return cache.bindings[canon] ?? null + } + return null } export async function recordApprovedBinding( @@ -166,14 +198,15 @@ export async function recordApprovedBinding( // truth (the server-side binding is). If the state directory is read-only // or the disk is full, callers otherwise report "link failed" and prompt // duplicate retries against a workspace that IS bound server-side. - // (cubic round 3.) + // (cubic round 3.) canonicalizeKey resolves symlinks so writes and reads + // funnel through the same key (macOS ``/tmp`` → ``/private/tmp``). try { const existing = readCache() const cache: CacheFile = existing && existing.tenant === key.tenant && existing.apiUrl === key.apiUrl ? existing : { version: CACHE_VERSION, tenant: key.tenant, apiUrl: key.apiUrl, bindings: {} } - cache.bindings[canonicalDirKey(directory)] = binding + cache.bindings[canonicalizeKey(directory)] = binding writeCache(cache) } catch (err) { log.warn("could not persist workspace binding cache", { diff --git a/packages/opencode/src/cli/cmd/link.ts b/packages/opencode/src/cli/cmd/link.ts index 3c9371d73..0da0543de 100644 --- a/packages/opencode/src/cli/cmd/link.ts +++ b/packages/opencode/src/cli/cmd/link.ts @@ -32,9 +32,15 @@ import { projectNameFromRemote, resolveProjectIdentifier, } from "@/altimate/workspace/detect" +import { + openWorkspaceBrowserHandoff, + resolveWorkspaceWebUrl, + type HandoffResult, +} from "@/altimate/workspace/browser-handoff" import { recordApprovedBinding } from "@/altimate/workspace/state" const CREATE_NEW_SENTINEL = "__create_new__" +const SET_UP_IN_BROWSER_SENTINEL = "__browser_handoff__" export const LinkCommand = cmd({ command: "link", @@ -118,13 +124,41 @@ export const LinkCommand = cmd({ const currentId = existing?.datamate.id const currentName = existing?.datamate.name + // Only offer the browser-based handoff when the deployment supports it + // (freemium only today). Enterprise / localhost / custom-domain callers + // silently fall back to the CLI-side quick create. + const creds = await AltimateApi.getCredentials() + const browserAvailable = + resolveWorkspaceWebUrl(creds.altimateUrl, creds.altimateInstanceName) !== null + const options: Array<{ value: string; label: string; hint?: string }> = [ + // Only offer browser handoff for UNLINKED projects (CodeRabbit cycle 5). + // ``runBrowserHandoff`` creates a fresh workspace and calls + // ``bindExisting``, which 409s when there's already an active binding — + // leaving the browser-created workspace stranded and no rebind actually + // happening. If the project is already linked, the caller wants a + // rebind path (offered elsewhere in this menu), not create-and-bind. + // + // Also gate on ``preCheckOk`` (Kilo cycle 6): when the pre-check itself + // failed (network / 5xx), ``existing`` stays null but the project MAY + // be linked server-side. Offering the browser flow then would run the + // same 409 → stranded-workspace path. Better to hide the option until + // the caller can confirm the binding state. + ...(browserAvailable && !existing && preCheckOk + ? [ + { + value: SET_UP_IN_BROWSER_SENTINEL, + label: `+ Set up in browser "${autoName}"`, + hint: "Approve in the Altimate SaaS; CLI links your project automatically.", + }, + ] + : []), { value: CREATE_NEW_SENTINEL, - label: `+ Create a new workspace "${autoName}"`, + label: `+ Create a quick workspace "${autoName}" here`, hint: existing - ? "Creates a new workspace and repoints this project to it." - : "Named from this project; rename in the SaaS after.", + ? "Creates a new workspace and repoints this project to it (no browser step)." + : "No browser step; configure integrations later in the SaaS.", }, ...list.map((dm) => ({ value: String(dm.id), @@ -146,6 +180,11 @@ export const LinkCommand = cmd({ return } + if (pick === SET_UP_IN_BROWSER_SENTINEL) { + await runBrowserHandoff(identifier, autoName, args.directory) + return + } + if (pick === CREATE_NEW_SENTINEL) { await createThenBindOrRebind(identifier, autoName, args.directory, existing) return @@ -161,12 +200,124 @@ export const LinkCommand = cmd({ }, }) -/** "+ Create a new workspace" flow. When the project is already linked, this - * MUST rebind after create — otherwise the new workspace is a real (billable) - * SaaS resource the CLI knows nothing about and the project is still bound to - * the old workspace (M2 in the consensus review). When rebind fails, the - * error message tells the user the workspace was created and how to recover; - * we do NOT silently swallow the orphan. */ +/** Browser-based create-and-bind flow. Same handoff module the TUI post-scan + * dialog uses; on success, the CLI calls the existing bind endpoint to link + * the current project to the newly-created workspace. When the project is + * already linked, bindExisting will 409; the caller re-runs and picks + * "+ Create a quick workspace here" instead to trigger the create-and-rebind + * path. (Full create-then-rebind via the browser flow is deferred — the + * SaaS approval screen doesn't yet know how to receive a "rebind after + * create" instruction from the CLI.) */ +async function runBrowserHandoff( + identifier: ProjectIdentifier, + projectName: string, + directory: string, +): Promise { + const spin = prompts.spinner() + spin.start("Waiting for browser approval...") + const result: HandoffResult = await openWorkspaceBrowserHandoff({ identifier, projectName }) + if (!result.ok) { + spin.stop(handoffFailureMessage(result), 1) + process.exitCode = 1 + return + } + // M6 in the consensus review: re-verify credentials before binding. The + // browser window can stay open for up to 15 minutes; an account switch in + // that window would otherwise bind a callback validated for tenant A + // under tenant B (workspace ids are tenant-schema-local). + try { + const fresh = await AltimateApi.getCredentials() + if ( + fresh.altimateInstanceName !== result.credentials.tenant || + fresh.altimateUrl !== result.credentials.apiUrl + ) { + spin.stop( + `Credentials changed while the browser was open (was ${result.credentials.tenant}, now ${fresh.altimateInstanceName}). Re-run to link this project.`, + 1, + ) + process.exitCode = 1 + return + } + } catch { + spin.stop("Lost Altimate credentials while the browser was open — sign in and re-run.", 1) + process.exitCode = 1 + return + } + spin.stop(`Workspace approved. Binding to project...`) + const bindSpin = prompts.spinner() + bindSpin.start("Linking workspace...") + try { + const res = await WorkspaceApi.bindExisting(result.workspaceId, identifier) + await recordApprovedBinding(directory, { + datamateId: res.binding.datamate_id, + datamateName: res.binding.datamate_name, + repoRemote: res.binding.repo_remote, + projectPath: res.binding.project_path, + linkedAt: Date.now(), + }) + bindSpin.stop(`Linked to "${res.binding.datamate_name}".`) + const manageUrl = await manageUrlFor(res.binding.datamate_id) + if (manageUrl) prompts.log.info(`Manage it at: ${manageUrl}`) + prompts.outro("Done.") + } catch (err) { + bindSpin.stop("Link failed.", 1) + if (err instanceof ConflictError) { + prompts.log.error( + `This project is already linked to "${err.detail.existing_datamate_name ?? "another workspace"}". Workspace "${projectName}" was created but is not linked — re-run \`altimate-code link\` and pick a different action to switch, or delete the new workspace in the SaaS.`, + ) + } else if (err instanceof NotFoundError) { + prompts.log.error("Workspace not found — the tenant or workspace may have changed.") + } else if (err instanceof ForbiddenError) { + prompts.log.error("Only the workspace owner can bind projects to it.") + } else { + prompts.log.error(err instanceof Error ? err.message : String(err)) + } + process.exitCode = 1 + } +} + +/** Best-effort manage-workspace URL for the current credentials. Returns null + * on BYOK / unresolvable deployments — callers omit the "Manage it at" line. */ +async function manageUrlFor(workspaceId: number): Promise { + try { + const creds = await AltimateApi.getCredentials() + const base = resolveWorkspaceWebUrl(creds.altimateUrl, creds.altimateInstanceName) + if (!base) return null + return `${base.toString().replace(/\/$/, "")}/w/${workspaceId}` + } catch { + return null + } +} + +function handoffFailureMessage(result: Extract): string { + switch (result.reason) { + case "unavailable": + return "Browser handoff isn't available for this deployment." + case "not_configured": + return "Altimate credentials not configured — sign in first." + case "timeout": + return "Timed out waiting for browser approval (15 min)." + case "cancelled": + return "Cancelled by user." + case "tenant_mismatch": + return result.message ?? "Workspace was set up in a different tenant than the CLI's credentials." + case "port_exhausted": + return result.message ?? "Loopback ports 7317-7325 all in use." + case "browser_open_failed": + return `Could not open browser${result.authorizeUrl ? `. Open manually: ${result.authorizeUrl}` : "."}` + case "aborted": + return result.message ?? "Browser handoff was cancelled." + default: + return result.message ?? "Browser handoff failed." + } +} + +/** "+ Create a quick workspace here" flow. When the project is already + * linked, this MUST rebind after create — otherwise the new workspace is a + * real (billable) SaaS resource the CLI knows nothing about and the project + * is still bound to the old workspace (M2 in the consensus review). When + * rebind fails, the error message tells the user the workspace was created + * and how to recover; we do NOT silently swallow the orphan. */ async function createThenBindOrRebind( identifier: ProjectIdentifier, name: string, @@ -349,6 +500,8 @@ async function bindOrRebind( ? `Re-linked to "${res.binding.datamate_name}".` : `Linked to "${res.binding.datamate_name}".`, ) + const manageUrl = await manageUrlFor(res.binding.datamate_id) + if (manageUrl) prompts.log.info(`Manage it at: ${manageUrl}`) prompts.outro("Done.") } catch (err) { spin.stop(isRebind ? `Re-link failed.` : `Link failed.`, 1) diff --git a/packages/opencode/src/plugin/tui/altimate/index.ts b/packages/opencode/src/plugin/tui/altimate/index.ts index adb5662e6..8e90e364b 100644 --- a/packages/opencode/src/plugin/tui/altimate/index.ts +++ b/packages/opencode/src/plugin/tui/altimate/index.ts @@ -15,6 +15,7 @@ import PromptEnhance from "./prompt-enhance" import SkillOps from "./skill-ops" import TraceViewer from "./trace-viewer" import Workspace from "./workspace" +import WorkspaceSidebar from "./workspace-sidebar" // Feature plugins are registered here as they are ported from the pre-merge sources on `main` // (see the ADR re-home plan). Each lives in its own file under this directory and default-exports @@ -26,10 +27,11 @@ import Workspace from "./workspace" // import Workspace from "./workspace" export function altimateTuiPlugins(_flags: Pick): BuiltinTuiPlugin[] { const base = [ProviderCredentials, PromptEnhance, SkillOps, TraceViewer] - // Workspace TUI plugin is pilot-gated: only registered for users who - // opted into ALTIMATE_WORKSPACE. Otherwise the post-scan dialog + the - // altimate.workspace.link palette command would ship to 100% of users - // regardless of the flag setting. (M1 in the consensus review.) - return Flag.ALTIMATE_WORKSPACE ? [...base, Workspace] : base + // Workspace TUI plugin + right-pane sidebar tile are pilot-gated: only + // registered for users who opted into ALTIMATE_WORKSPACE. Otherwise the + // post-scan dialog, the altimate.workspace.link palette command, and the + // sidebar's 30s poll would ship to 100% of users regardless of the flag + // setting. (M1 in the consensus review.) + return Flag.ALTIMATE_WORKSPACE ? [...base, Workspace, WorkspaceSidebar] : base } // altimate_change end diff --git a/packages/opencode/src/plugin/tui/altimate/workspace-sidebar.tsx b/packages/opencode/src/plugin/tui/altimate/workspace-sidebar.tsx new file mode 100644 index 000000000..da6fdddb3 --- /dev/null +++ b/packages/opencode/src/plugin/tui/altimate/workspace-sidebar.tsx @@ -0,0 +1,128 @@ +// altimate_change - new file +// Right-pane sidebar tile that shows the workspace the current project +// directory is bound to (or "Not linked" with a hint). Reads from the local +// binding cache written by `../workspace.tsx` (post-scan dialog, on-demand +// picker, browser handoff). +// +// Deliberately read-only. All bind mutations live in workspace.tsx / link.ts; +// this tile just reflects state. +import type { TuiPlugin, TuiPluginApi } from "@opencode-ai/plugin/tui" +import type { BuiltinTuiPlugin } from "@opencode-ai/tui/builtins" +import { createSignal, onCleanup, onMount, Show } from "solid-js" +import { readLocalBinding, type CachedBinding } from "@/altimate/workspace/state" +import { resolveWorkspaceWebUrl } from "@/altimate/workspace/browser-handoff" +import { AltimateApi } from "@/altimate/api/client" + +const id = "altimate:sidebar-workspace" + +/** Cache-file poll cadence. Longer than a "reactive" ideal but the cheapest + * option that does not require plumbing an event bus through the binding + * writers. Trade-off documented (m1 in the consensus review): a fresh bind + * surfaces within one interval instead of instantly; a mostly-idle CLI reads + * the small cache file twice per minute. In-flight guard below prevents + * overlap when the file grows / the disk is slow. */ +const POLL_MS = 30_000 + +/** Cached credential lookup — the API is a network round-trip candidate in + * the general case, but the credentials source here (local file) rarely + * changes within a single CLI process. We memoize the resolved manage-URL + * base per (apiUrl, tenant) pair for the life of the process; if the file + * changes mid-session, the binding cache invalidation (in state.ts) still + * catches it via its own (tenant, apiUrl) top-level scoping. */ +let cachedManageBase: { apiUrl: string; tenant: string; base: string | null } | null = null +async function resolveManageBase(): Promise { + try { + const creds = await AltimateApi.getCredentials() + if ( + cachedManageBase && + cachedManageBase.apiUrl === creds.altimateUrl && + cachedManageBase.tenant === creds.altimateInstanceName + ) { + return cachedManageBase.base + } + const url = resolveWorkspaceWebUrl(creds.altimateUrl, creds.altimateInstanceName) + const base = url ? url.toString().replace(/\/$/, "") : null + cachedManageBase = { apiUrl: creds.altimateUrl, tenant: creds.altimateInstanceName, base } + return base + } catch { + return null + } +} + +function View(props: { api: TuiPluginApi }) { + const theme = () => props.api.theme.current + const [binding, setBinding] = createSignal(null) + const [manageUrl, setManageUrl] = createSignal(null) + + let refreshInFlight = false + const refresh = async () => { + if (refreshInFlight) return + refreshInFlight = true + try { + const dir = props.api.state.path.directory + const b = await readLocalBinding(dir).catch(() => null) + setBinding(b) + if (!b) { + setManageUrl(null) + return + } + const base = await resolveManageBase() + setManageUrl(base ? `${base}/w/${b.datamateId}` : null) + } finally { + refreshInFlight = false + } + } + + onMount(() => { + void refresh() + const timer = setInterval(() => void refresh(), POLL_MS) + // eslint-disable-next-line @typescript-eslint/no-explicit-any + ;(timer as any)?.unref?.() + onCleanup(() => clearInterval(timer)) + }) + + return ( + + + Workspace + + + Not linked — run altimate-code link + + } + > + {(b) => ( + <> + {b().datamateName} + + {(u) => {u()}} + + + )} + + + ) +} + +const tui: TuiPlugin = async (api) => { + api.slots.register({ + // Below MCP (200) and above LSP (300) — workspace identity is high-signal + // when present, but not more useful than the connection status above. + order: 250, + slots: { + sidebar_content() { + return + }, + }, + }) +} + +const plugin: BuiltinTuiPlugin = { + id, + tui, +} + +export default plugin diff --git a/packages/opencode/src/plugin/tui/altimate/workspace.tsx b/packages/opencode/src/plugin/tui/altimate/workspace.tsx index 440fa8412..a59293bcf 100644 --- a/packages/opencode/src/plugin/tui/altimate/workspace.tsx +++ b/packages/opencode/src/plugin/tui/altimate/workspace.tsx @@ -37,6 +37,11 @@ import { type ProjectBindingLookup, type ProjectIdentifier, } from "@/altimate/workspace/api-client" +import { + openWorkspaceBrowserHandoff, + resolveWorkspaceWebUrl, + type HandoffResult, +} from "@/altimate/workspace/browser-handoff" import { projectNameFromPath, projectNameFromRemote, @@ -50,6 +55,24 @@ const PLUGIN_ID = "altimate:workspace" const log = Log.create({ service: "altimate-workspace" }) +/** True when the browser-based workspace-creation handoff is available for + * the current credentials (freemium only today). Wrapped so both the post-scan + * flow and the on-demand `altimate-code link` picker can hide the option + * consistently when the deployment isn't supported. */ +async function isBrowserHandoffAvailable(): Promise { + // Both credential calls can throw (corrupt JSON, schema drift, unresolved + // ``${env:...}`` reference). Callers use this in the sync arm of dialog + // rendering, so an unhandled rejection would take the TUI down. Fail + // closed — treat any credential error as "handoff unavailable". (CR cycle 6.) + try { + if (!(await AltimateApi.isConfigured().catch(() => false))) return false + const creds = await AltimateApi.getCredentials() + return resolveWorkspaceWebUrl(creds.altimateUrl, creds.altimateInstanceName) !== null + } catch { + return false + } +} + // ───────────────────────────────────────────────────────────────────────────── // Skip latch (TUI-only). Uses TuiPluginApi.kv — persistent across sessions // via packages/tui/src/context/kv.tsx (state/kv.json). The `altimate link` @@ -132,6 +155,12 @@ interface OfferProps { api: TuiPluginApi identifier: ProjectIdentifier defaultName: string + /** True when this deployment supports the browser-based workspace-creation + * handoff (i.e. ``resolveWorkspaceWebUrl`` returned non-null for the current + * credentials). Resolved by the caller so the dialog doesn't need to await + * on mount. When false, the "Set up in browser" option is hidden and the + * dialog falls back to the pre-browser-handoff behavior. */ + browserAvailable: boolean /** (tenant, apiUrl) scope for the Skip latch. Resolved once by the caller * so the sync ``onSelect`` handler can call ``recordSkip`` without a * mid-render await. Null when creds are unavailable — latch falls back @@ -141,36 +170,52 @@ interface OfferProps { function OfferDialog(props: OfferProps) { const identLabel = () => props.identifier.repoRemote ?? props.identifier.projectPath ?? "this project" + const options = [ + ...(props.browserAvailable + ? [ + { + title: "Set up in browser (recommended)", + value: "browser", + description: `Approve and name "${props.defaultName}" in the Altimate SaaS; the CLI links your project automatically.`, + }, + ] + : []), + { + title: "Create quick workspace here", + value: "create", + description: `Auto-named "${props.defaultName}" from this repo — no browser step. Configure integrations later in the SaaS.`, + }, + { + title: "Link to an existing workspace", + value: "link", + description: "Attach this project to a workspace you already own.", + }, + { + title: "Skip for now", + value: "skip", + description: "Won't ask again for 7 days.", + }, + ] + const defaultValue = props.browserAvailable ? "browser" : "create" return ( { if (option.value === "skip") { recordSkip(props.api, props.identifier, props.latchScope, Date.now()) props.api.ui.dialog.clear() return } + if (option.value === "browser") { + void runBrowserHandoff(props.api, props.identifier, props.defaultName) + return + } if (option.value === "create") { - // Auto-name from git repo — no name prompt. The SaaS UI is the place to - // rename / configure; the CLI's job is just to establish the binding. + // Local direct-create — the CLI-only fallback. The SaaS UI is the + // place to rename / configure; this branch establishes the binding + // without a browser round-trip. void createAndBindInline(props.api, props.identifier, props.defaultName) return } @@ -183,6 +228,239 @@ function OfferDialog(props: OfferProps) { ) } +/** Build the SaaS manage-workspace URL for a bound workspace. Deterministic + * from tenant + id, so any caller can construct it without an extra round-trip. + * Returns null when the current deployment isn't the freemium web (BYOK or + * unresolvable) — the confirmation dialog degrades to id-only in that case. */ +async function buildManageUrl(workspaceId: number): Promise { + try { + const creds = await AltimateApi.getCredentials() + const base = resolveWorkspaceWebUrl(creds.altimateUrl, creds.altimateInstanceName) + if (!base) return null + return `${base.toString().replace(/\/$/, "")}/w/${workspaceId}` + } catch { + return null + } +} + +interface LinkedProps { + api: TuiPluginApi + workspaceName: string + manageUrl: string | null + /** Verb for the title — "Linked" / "Re-linked" / "Created". Keeps the + * three success paths visually consistent while still labelling what + * just happened. */ + verb: "Linked" | "Re-linked" | "Created" +} + +/** Persistent confirmation card shown after a successful bind. Replaces the + * transient success toast so the user has an unmissable "yes, it worked" and + * a stable CTA back to the browser. Dismissable via Done or Esc. */ +function WorkspaceLinkedDialog(props: LinkedProps) { + const title = () => { + const suffix = props.manageUrl ? ` — ${props.manageUrl}` : "" + return `${props.verb} workspace "${props.workspaceName}"${suffix}` + } + const options = () => { + if (props.manageUrl) { + return [ + { + title: "Continue editing in browser", + value: "open", + description: "Open the workspace in your browser.", + }, + { title: "Done", value: "done", description: "Close this dialog." }, + ] + } + return [{ title: "Done", value: "done", description: "Close this dialog." }] + } + return ( + { + if (option.value === "open" && props.manageUrl) { + const url = props.manageUrl + // Guard before delegating to open() — a rogue manage_url with a + // non-http protocol would otherwise dispatch to an unrelated OS + // scheme handler. buildManageUrl only ever emits http(s) URLs from + // resolveWorkspaceWebUrl, but the guard survives future changes. + if (!isSafeHttpUrl(url)) { + props.api.ui.toast({ + variant: "warning", + message: `Refused to open a non-http URL: ${url}`, + duration: 15_000, + }) + } else { + open(url).catch(() => { + props.api.ui.toast({ + variant: "warning", + message: `Could not open browser. Copy this URL: ${url}`, + duration: 15_000, + }) + }) + } + } + props.api.ui.dialog.clear() + }} + /> + ) +} + +/** Show the persistent linked-confirmation dialog. Builds the manage URL + * best-effort; degrades gracefully on BYOK/unresolvable. */ +async function showLinkedConfirmation( + api: TuiPluginApi, + verb: LinkedProps["verb"], + workspaceId: number, + workspaceName: string, +): Promise { + const manageUrl = await buildManageUrl(workspaceId) + api.ui.dialog.replace(() => ( + + )) +} + +/** Post-scan / on-demand browser-handoff runner. Opens the SaaS approval + * modal, waits for the callback, and binds the current project to the + * returned workspace via the existing ``POST /bind`` endpoint. Every failure + * mode surfaces as a toast; the user can always fall back to another option + * by re-invoking the dialog. */ +async function runBrowserHandoff( + api: TuiPluginApi, + identifier: ProjectIdentifier, + projectName: string, +): Promise { + api.ui.dialog.clear() + api.ui.toast({ + variant: "info", + message: "Opening browser to set up your workspace — approve there, then check back here for the confirmation.", + }) + const result: HandoffResult = await openWorkspaceBrowserHandoff({ identifier, projectName }) + if (!result.ok) { + toastHandoffFailure(api, result) + return + } + // M6 in the consensus review: the browser window can stay open for up to + // 15 minutes. If the user signs out or switches tenant mid-flow, the + // WorkspaceApi client re-reads credentials on every call — so a callback + // validated for tenant A would then bind under tenant B, and workspace + // ids are tenant-schema-local (same integer, different workspace). Compare + // the credential fingerprint the handoff was validated against with the + // credentials we're about to bind under, and refuse if either drifted. + try { + const fresh = await AltimateApi.getCredentials() + if ( + fresh.altimateInstanceName !== result.credentials.tenant || + fresh.altimateUrl !== result.credentials.apiUrl + ) { + api.ui.toast({ + variant: "error", + message: `Your Altimate credentials changed while the browser was open (was ${result.credentials.tenant}, now ${fresh.altimateInstanceName}). Re-run to link this project.`, + duration: 15_000, + }) + return + } + } catch { + api.ui.toast({ + variant: "error", + message: "Lost Altimate credentials while the browser was open — sign in and re-run.", + }) + return + } + // Handoff succeeded and credentials are still consistent — bind the project + // to the returned workspace via the existing bind endpoint. Same code path + // as PickerDialog's attach mode. + try { + const res = await WorkspaceApi.bindExisting(result.workspaceId, identifier) + await recordApprovedBinding(api.state.path.directory, { + datamateId: res.binding.datamate_id, + datamateName: res.binding.datamate_name, + repoRemote: res.binding.repo_remote, + projectPath: res.binding.project_path, + linkedAt: Date.now(), + }) + await showLinkedConfirmation(api, "Linked", res.binding.datamate_id, res.binding.datamate_name) + } catch (err) { + if (err instanceof ConflictError) { + api.ui.toast({ + variant: "warning", + message: `This project is already linked to "${err.detail.existing_datamate_name ?? "another workspace"}". Run \`altimate-code link\` to change.`, + }) + } else if (err instanceof NotFoundError) { + api.ui.toast({ + variant: "error", + message: "Workspace not found — the tenant or workspace may have changed. Try again.", + }) + } else if (err instanceof ForbiddenError) { + api.ui.toast({ + variant: "error", + message: "Only the workspace owner can bind projects to it.", + }) + } else { + api.ui.toast({ + variant: "error", + message: err instanceof Error ? err.message : "Failed to bind workspace", + }) + } + } +} + +function toastHandoffFailure(api: TuiPluginApi, result: Extract): void { + switch (result.reason) { + case "unavailable": + // Should not happen if browserAvailable was checked, but guard anyway. + api.ui.toast({ + variant: "warning", + message: "Browser-based workspace setup isn't available for this deployment. Use \"Create quick workspace here\" instead.", + }) + break + case "not_configured": + api.ui.toast({ + variant: "error", + message: "Altimate credentials not configured — sign in first, then re-run.", + }) + break + case "timeout": + api.ui.toast({ + variant: "warning", + message: "Workspace setup timed out (15 min). Re-run when you're ready.", + }) + break + case "cancelled": + api.ui.toast({ + variant: "info", + message: "Workspace setup cancelled.", + }) + break + case "tenant_mismatch": + api.ui.toast({ + variant: "error", + message: result.message ?? "Workspace was set up under a different account than the CLI is signed into.", + }) + break + case "port_exhausted": + api.ui.toast({ + variant: "error", + message: result.message ?? "Local ports 7317-7325 all in use — free one and try again.", + }) + break + case "browser_open_failed": + api.ui.toast({ + variant: "error", + message: `Could not open browser. ${result.authorizeUrl ? `Open this URL manually: ${result.authorizeUrl}` : ""}`, + duration: 15_000, + }) + break + default: + api.ui.toast({ + variant: "error", + message: result.message ?? "Workspace setup failed.", + }) + } +} + async function createAndBindInline( api: TuiPluginApi, identifier: ProjectIdentifier, @@ -238,10 +516,11 @@ async function createAndBindInline( // Post-success tail — this function is invoked fire-and-forget // (``void createAndBindInline(...)``), so a bare rejection here would // surface as an unhandled promise and terminate the TUI. Contain the - // fallout inside the function itself: ``recordApprovedBinding`` already - // swallows its own errors (state.ts is best-effort), but ``open()`` and - // the toast APIs can reject unexpectedly. Fall back to a plain info - // toast so the user still sees the URL. (Kilo cycle 5.) + // fallout inside the function itself. ``recordApprovedBinding`` already + // swallows its own errors (state.ts is best-effort), but + // ``showLinkedConfirmation`` can reject on dialog-teardown races — the + // toast fallback keeps the user informed without taking the process down. + // (Kilo cycle 5.) try { await recordApprovedBinding(api.state.path.directory, { datamateId: res.datamate.id, @@ -250,39 +529,24 @@ async function createAndBindInline( projectPath: res.binding.project_path, linkedAt: Date.now(), }) - // Guard against a non-http(s) manage_url — ``open`` dispatches to whatever - // OS handler matches the protocol, so a rogue value could launch an - // unrelated app. Fall through to the info toast (with the URL for manual - // copy) if the URL isn't a safe http/https link. - if (isSafeHttpUrl(res.manage_url)) { - try { - await open(res.manage_url) - api.ui.toast({ - variant: "success", - message: `Workspace "${res.datamate.name}" created. Opened ${res.manage_url} in your browser.`, - }) - return - } catch { - /* fall through to the "open manually" toast below */ - } - } - api.ui.toast({ - variant: "info", - message: `Workspace "${res.datamate.name}" created. Open ${res.manage_url} to configure it.`, - duration: 10_000, - }) + await showLinkedConfirmation(api, "Created", res.datamate.id, res.datamate.name) } catch (err) { + // Log the failure so a regression in ``showLinkedConfirmation`` doesn't + // vanish silently, then fall back to a plain toast. Previously ``void err`` + // discarded the diagnostic — Kilo cycle 6 called it out. + log.warn("workspace post-create confirmation failed", { err: String(err) }) api.ui.toast({ variant: "info", message: `Workspace "${res.datamate.name}" created and linked.`, }) - void err } } /** True when the URL parses and its protocol is exactly ``http:`` or ``https:``. * Used before handing a server-supplied URL to ``open()`` (which would otherwise - * dispatch to whatever OS scheme handler matches the protocol). */ + * dispatch to whatever OS scheme handler matches the protocol). Kept exported + * as a top-level helper because both ``showLinkedConfirmation`` (below) and + * the on-demand link paths need the same guard. */ function isSafeHttpUrl(url: string): boolean { try { const u = new URL(url) @@ -438,10 +702,13 @@ function PickerDialog(props: PickerProps) { projectPath: res.binding.project_path, linkedAt: Date.now(), }) - props.api.ui.toast({ - variant: "success", - message: `Linked to workspace "${res.binding.datamate_name}".`, - }) + await showLinkedConfirmation( + props.api, + "Linked", + res.binding.datamate_id, + res.binding.datamate_name, + ) + return } else { // Rebind: pick the endpoint that matches which identifier the // pre-check RESOLVED the binding on — not what the current identifier @@ -464,12 +731,14 @@ function PickerDialog(props: PickerProps) { projectPath: res.binding.project_path, linkedAt: Date.now(), }) - props.api.ui.toast({ - variant: "success", - message: `Re-linked to workspace "${res.binding.datamate_name}".`, - }) + await showLinkedConfirmation( + props.api, + "Re-linked", + res.binding.datamate_id, + res.binding.datamate_name, + ) + return } - props.api.ui.dialog.clear() } catch (err) { // Surface as a toast so the user sees the specific failure. Dialog // closes either way — the palette command ``altimate.workspace.link`` @@ -666,12 +935,12 @@ async function bindOrRebindInline( projectPath: res.binding.project_path, linkedAt: Date.now(), }) - api.ui.toast({ - variant: "success", - message: isRebind - ? `Re-linked to workspace "${res.binding.datamate_name}".` - : `Linked to workspace "${res.binding.datamate_name}".`, - }) + await showLinkedConfirmation( + api, + isRebind ? "Re-linked" : "Linked", + res.binding.datamate_id, + res.binding.datamate_name, + ) } catch (err) { let msg: string if (err instanceof ConflictError) { @@ -747,6 +1016,12 @@ async function runFlow(api: TuiPluginApi, directory: string): Promise { ? projectNameFromRemote(identifier.repoRemote) : projectNameFromPath(identifier.projectPath) + // Whether the browser-based handoff is available for this deployment. The + // OfferDialog hides the "Set up in browser" option when false, silently + // falling back to the pre-browser-handoff behavior. Compute here (once, + // async) so the dialog itself stays sync. + const browserAvailable = await isBrowserHandoffAvailable() + let serverBinding: ProjectBindingLookup | null | undefined try { serverBinding = await WorkspaceApi.getBindingForProject(identifier) @@ -799,6 +1074,7 @@ async function runFlow(api: TuiPluginApi, directory: string): Promise { api={api} identifier={identifier} defaultName={defaultName} + browserAvailable={browserAvailable} latchScope={latchScope} /> )) @@ -840,6 +1116,7 @@ async function runFlow(api: TuiPluginApi, directory: string): Promise { api={api} identifier={identifier} defaultName={defaultName} + browserAvailable={browserAvailable} latchScope={latchScope} /> )) diff --git a/packages/opencode/test/altimate/workspace/browser-handoff.test.ts b/packages/opencode/test/altimate/workspace/browser-handoff.test.ts new file mode 100644 index 000000000..a889d5744 --- /dev/null +++ b/packages/opencode/test/altimate/workspace/browser-handoff.test.ts @@ -0,0 +1,272 @@ +// altimate_change - new file +// Unit coverage for the browser-based workspace-creation handoff. +// (packages/opencode/src/altimate/workspace/browser-handoff.ts.) +// +// Uses ``runHandoffWithOpener`` (dependency-injected browser-open callback) +// so tests fire a synthetic callback at the live loopback listener instead of +// launching a real browser. The listener itself binds to 127.0.0.1, walks +// 7317..7325, and processes real HTTP requests — this is genuine end-to-end +// coverage for the callback validation path. +import { afterEach, beforeEach, describe, expect, test } from "bun:test" +import { createServer } from "node:net" + +import { AltimateApi } from "../../../src/altimate/api/client" +import { + openWorkspaceBrowserHandoff, + resolveWorkspaceWebUrl, + runHandoffWithOpener, +} from "../../../src/altimate/workspace/browser-handoff" + +// ── credential stubbing ───────────────────────────────────────────────────── +const originalIsConfigured = AltimateApi.isConfigured +const originalGetCreds = AltimateApi.getCredentials +type Creds = Awaited> +function stubCreds(tenant: string, apiUrl: string) { + ;(AltimateApi as unknown as { isConfigured: () => Promise }).isConfigured = + async () => true + ;(AltimateApi as unknown as { getCredentials: () => Promise }).getCredentials = + async () => + ({ + altimateInstanceName: tenant, + altimateUrl: apiUrl, + altimateApiKey: "dummy", + }) as Creds +} +function unstubCreds() { + ;(AltimateApi as unknown as { isConfigured: typeof originalIsConfigured }).isConfigured = + originalIsConfigured + ;(AltimateApi as unknown as { getCredentials: typeof originalGetCreds }).getCredentials = + originalGetCreds +} + +// ── helpers ───────────────────────────────────────────────────────────────── + +/** Parse the authorize URL the CLI wants to open; extract the loopback port + * and CSRF state so tests can fire the crafted callback at the right address. */ +function parseHandoffUrl(url: string): { port: number; state: string; redirect: string } { + const u = new URL(url) + const redirect = u.searchParams.get("redirect")! + const state = u.searchParams.get("state")! + const port = Number(new URL(redirect).port) + return { port, state, redirect } +} + +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(() => "") +} + +// ───────────────────────────────────────────────────────────────────────────── +// resolveWorkspaceWebUrl — the deployment-support gate +// ───────────────────────────────────────────────────────────────────────────── + +describe("resolveWorkspaceWebUrl", () => { + test("freemium API host resolves to .ws.myaltimate.com", () => { + const url = resolveWorkspaceWebUrl("https://api.myaltimate.com", "acme") + expect(url).not.toBeNull() + expect(url!.toString()).toBe("https://acme.ws.myaltimate.com/") + }) + + test("localhost API returns null (browser flow not supported in dev)", () => { + expect(resolveWorkspaceWebUrl("http://localhost:5001", "acme")).toBeNull() + }) + + test("enterprise API host returns null", () => { + expect(resolveWorkspaceWebUrl("https://acme.getaltimate.com", "acme")).toBeNull() + }) + + test("malformed URL returns null instead of throwing", () => { + expect(resolveWorkspaceWebUrl("not-a-url", "acme")).toBeNull() + expect(resolveWorkspaceWebUrl("", "acme")).toBeNull() + }) +}) + +// ───────────────────────────────────────────────────────────────────────────── +// openWorkspaceBrowserHandoff — pre-flight failures (do not open a browser) +// ───────────────────────────────────────────────────────────────────────────── + +describe("openWorkspaceBrowserHandoff pre-flight", () => { + afterEach(() => unstubCreds()) + + test("returns {unavailable} for localhost credentials", async () => { + stubCreds("acme", "http://localhost:5001") + const result = await openWorkspaceBrowserHandoff({ + identifier: { repoRemote: "git@github.com:acme/x.git", projectPath: "/x" }, + projectName: "x", + }) + expect(result.ok).toBe(false) + if (!result.ok) expect(result.reason).toBe("unavailable") + }) + + test("returns {not_configured} when credentials are missing", async () => { + ;(AltimateApi as unknown as { isConfigured: () => Promise }).isConfigured = + async () => false + const result = await openWorkspaceBrowserHandoff({ + identifier: { projectPath: "/x" }, + projectName: "x", + }) + expect(result.ok).toBe(false) + if (!result.ok) expect(result.reason).toBe("not_configured") + }) +}) + +// ───────────────────────────────────────────────────────────────────────────── +// End-to-end via runHandoffWithOpener — real loopback, injected browser-open +// ───────────────────────────────────────────────────────────────────────────── + +describe("runHandoffWithOpener end-to-end", () => { + beforeEach(() => stubCreds("acme", "https://api.myaltimate.com")) + afterEach(() => unstubCreds()) + + test("happy path: valid callback resolves with workspaceId + tenant", async () => { + const result = await runHandoffWithOpener( + { + identifier: { repoRemote: "git@github.com:acme/x.git", projectPath: "/x" }, + projectName: "x", + }, + async (url) => { + const { state, redirect } = parseHandoffUrl(url) + await fireCallback(redirect, { workspace_id: "42", state, tenant: "acme" }) + }, + ) + expect(result.ok).toBe(true) + if (result.ok) { + expect(result.workspaceId).toBe(42) + expect(result.tenant).toBe("acme") + } + }) + + test("tenant mismatch is refused", async () => { + const result = await runHandoffWithOpener( + { identifier: { projectPath: "/x" }, projectName: "x" }, + async (url) => { + const { state, redirect } = parseHandoffUrl(url) + await fireCallback(redirect, { workspace_id: "42", state, tenant: "not-acme" }) + }, + ) + expect(result.ok).toBe(false) + if (!result.ok) expect(result.reason).toBe("tenant_mismatch") + }) + + test("?error=cancelled callback resolves as {cancelled}", async () => { + const result = await runHandoffWithOpener( + { identifier: { projectPath: "/x" }, projectName: "x" }, + async (url) => { + const { state, redirect } = parseHandoffUrl(url) + await fireCallback(redirect, { state, error: "cancelled", tenant: "acme" }) + }, + ) + expect(result.ok).toBe(false) + if (!result.ok) expect(result.reason).toBe("cancelled") + }) + + test("missing workspace_id in callback resolves as {error}", async () => { + const result = await runHandoffWithOpener( + { identifier: { projectPath: "/x" }, projectName: "x" }, + async (url) => { + const { state, redirect } = parseHandoffUrl(url) + await fireCallback(redirect, { state, tenant: "acme" }) + }, + ) + expect(result.ok).toBe(false) + if (!result.ok) expect(result.reason).toBe("error") + }) + + test("invalid workspace_id (non-numeric) resolves as {error}", async () => { + const result = await runHandoffWithOpener( + { identifier: { projectPath: "/x" }, projectName: "x" }, + async (url) => { + const { state, redirect } = parseHandoffUrl(url) + await fireCallback(redirect, { workspace_id: "not-a-number", state, tenant: "acme" }) + }, + ) + expect(result.ok).toBe(false) + if (!result.ok) expect(result.reason).toBe("error") + }) + + test("browser open failure resolves as {browser_open_failed} with authorizeUrl", async () => { + const result = await runHandoffWithOpener( + { identifier: { projectPath: "/x" }, projectName: "x" }, + async () => { + throw new Error("mock: no browser available") + }, + ) + expect(result.ok).toBe(false) + if (!result.ok) { + expect(result.reason).toBe("browser_open_failed") + expect(result.authorizeUrl).toContain("/create-and-link") + expect(result.authorizeUrl).toContain("client=altimate-code") + expect(result.authorizeUrl).toContain("project_name=x") + } + }) + + test("URL includes project_name in query and project_remote/project_path in fragment", async () => { + // Per m6 in the consensus review: project_path + project_remote MUST NOT + // be sent as query params (they'd land in browser history, SaaS/CDN/WAF + // access logs, and REST-log aggregators). Move them to the URL fragment + // instead — same reason cli_context lives in the fragment. + let observed = "" + await runHandoffWithOpener( + { + identifier: { repoRemote: "git@github.com:acme/foo.git", projectPath: "/w/foo" }, + projectName: "foo", + }, + async (url) => { + observed = url + // fire callback so the flow doesn't hang for 15 min + const { state, redirect } = parseHandoffUrl(url) + await fireCallback(redirect, { workspace_id: "1", state, tenant: "acme" }) + }, + ) + const u = new URL(observed) + // project_name is a display-safe label — the SaaS approval screen + // renders it in the modal — so it stays in the query. + expect(u.searchParams.get("project_name")).toBe("foo") + // project_remote + project_path MUST NOT be in the query. + expect(u.searchParams.get("project_remote")).toBeNull() + expect(u.searchParams.get("project_path")).toBeNull() + // They live in the fragment instead. + const frag = new URLSearchParams(u.hash.replace(/^#/, "")) + 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") + }) +}) + +// ───────────────────────────────────────────────────────────────────────────── +// Port walk: a squatting listener on 7317 forces handoff to 7318+ +// ───────────────────────────────────────────────────────────────────────────── + +describe("port walk", () => { + beforeEach(() => stubCreds("acme", "https://api.myaltimate.com")) + afterEach(() => unstubCreds()) + + test("stale listener on 7317 forces handoff to 7318", async () => { + const squatter = createServer() + await new Promise((resolve, reject) => { + squatter.once("error", reject) + squatter.listen(7317, "127.0.0.1", () => resolve()) + }) + + try { + let observedPort = -1 + const result = await runHandoffWithOpener( + { identifier: { projectPath: "/x" }, projectName: "x" }, + async (url) => { + const { port, state, redirect } = parseHandoffUrl(url) + observedPort = port + await fireCallback(redirect, { workspace_id: "1", state, tenant: "acme" }) + }, + ) + expect(result.ok).toBe(true) + expect(observedPort).toBeGreaterThan(7317) + expect(observedPort).toBeLessThanOrEqual(7325) + } finally { + await new Promise((r) => squatter.close(() => r())) + } + }) +})