diff --git a/packages/opencode/src/altimate/workspace/browser-handoff.ts b/packages/opencode/src/altimate/workspace/browser-handoff.ts index 7e815da525..e5405ae351 100644 --- a/packages/opencode/src/altimate/workspace/browser-handoff.ts +++ b/packages/opencode/src/altimate/workspace/browser-handoff.ts @@ -184,6 +184,30 @@ export function resolveWorkspaceWebUrl(altimateUrl: string, tenant: string): URL } } +/** Append ``/w/`` to ``base``'s pathname using real URL semantics, rather + * than string-concatenating ``toString()``. The dev-only + * ``ALTIMATE_WORKSPACE_WEB_URL`` override (above) can carry its own path/ + * query/fragment (e.g. a local dev server), and naive concatenation would + * land ``/w/`` inside the query string instead of the path — clears + * search/hash for the same reason. + * + * Lives beside ``resolveWorkspaceWebUrl`` (this module is already imported + * by both the CLI (``cli/cmd/link.ts``) and the TUI plugin + * (``plugin/tui/altimate/workspace.tsx`` / ``workspace-sidebar.tsx``) for + * that function, so sharing this one too doesn't cross the deliberate CLI/ + * TUI self-containment boundary the way importing FROM one side INTO the + * other would (see ``isSafeHttpUrl``'s split in ``link.ts`` for that + * reasoning). Previously duplicated as three near-identical private copies + * — 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 { + const u = new URL(base) + u.pathname = `${u.pathname.replace(/\/+$/, "")}/w/${workspaceId}` + u.search = "" + u.hash = "" + return u.toString() +} + interface HandoffPending { state: string expectedTenant: string diff --git a/packages/opencode/src/cli/cmd/link.ts b/packages/opencode/src/cli/cmd/link.ts index 587f4bf9d4..5ad18b8641 100644 --- a/packages/opencode/src/cli/cmd/link.ts +++ b/packages/opencode/src/cli/cmd/link.ts @@ -33,6 +33,7 @@ import { resolveProjectIdentifier, } from "@/altimate/workspace/detect" import { + buildManageUrl, openWorkspaceBrowserHandoff, resolveWorkspaceWebUrl, type HandoffResult, @@ -42,6 +43,121 @@ import { recordApprovedBinding } from "@/altimate/workspace/state" const CREATE_NEW_SENTINEL = "__create_new__" const SET_UP_IN_BROWSER_SENTINEL = "__browser_handoff__" +/** Strip C0/C1 control bytes (including ESC) from server-controlled text + * before it reaches a raw-stdout escape-sequence wrapper. Workspace names + * come from ``WorkspaceApi.listDatamates()`` with no charset validation — an + * attacker-controlled name containing its own ``\x1b]8;;`` could otherwise + * prematurely close our hyperlink and open a spoofed one pointing wherever + * they choose, with our trusted URL as the visible (but inert) prefix. + * (CodeRabbit + cubic, PR #1274.) */ +export function stripControlChars(text: string): string { + // C0 (\x00-\x1f) + DEL (\x7f) + C1 (\x80-\x9f) — the previous range only + // covered C0/DEL, leaving C1 controls unstripped. ESC (the OSC 8 breakout + // vector) was always covered, but the doc comment claimed C1 coverage it + // didn't have. (Kilo, PR #1274.) + // eslint-disable-next-line no-control-regex + return text.replace(/[\x00-\x1f\x7f-\x9f]/g, "") +} + +/** Sanitized display name for a ``ConflictError``'s existing-binding name, + * with a stable fallback when the server didn't send one. A third + * near-identical copy of this exact ternary appeared across three different + * catch blocks in this file before being extracted here — same drift risk + * ``buildManageUrl``'s move to ``browser-handoff.ts`` (see that function's + * comment) was extracted to avoid: a security-relevant pattern duplicated + * per call site only stays in sync by accident. (Kilo, PR #1274 round 8.) */ +function conflictExistingName(detail: { existing_datamate_name?: string | null }): string { + return detail.existing_datamate_name ? stripControlChars(detail.existing_datamate_name) : "another workspace" +} + +/** Conservative allowlist of terminals known to render OSC 8 hyperlinks. + * There's no capability query as reliable as opentui's device-attribute + * detection (used by the TUI side) available to a plain CLI process, so this + * errs toward false negatives — worst case a supporting terminal renders + * plain text instead of a link, which is a strict improvement over the + * inverse (underlining text that turns out not to be clickable). Mirrors the + * checks the `supports-hyperlinks` package uses, inlined to avoid a new + * dependency for one CLI affordance. + * + * Deliberately excludes ``Apple_Terminal`` (macOS Terminal.app): OSC 8 + * support only landed there in macOS Sequoia (Sept 2024) — older versions + * (Ventura/Sonoma and earlier) only auto-linkify plain-text URLs, not OSC 8. + * ``TERM_PROGRAM`` carries no OS/Terminal-version signal to tell those apart, + * and this function's own stated bias is toward false negatives, so it's + * left off the list rather than guessing the user is on a current-enough + * macOS. (Kilo, PR #1274 — corrects an earlier version of this list that + * included it.) */ +export function terminalSupportsHyperlinks(): boolean { + if (!process.stdout.isTTY) return false + if (process.env.TERM === "dumb" || process.env.TERM === "linux") return false + const termProgram = process.env.TERM_PROGRAM + if (termProgram && ["iTerm.app", "WezTerm", "Hyper", "vscode", "ghostty", "Tabby", "rio"].includes(termProgram)) + return true + if (process.env.WT_SESSION) return true // Windows Terminal + if (process.env.KONSOLE_VERSION) return true + const vte = Number(process.env.VTE_VERSION) + if (!Number.isNaN(vte) && vte >= 5000) return true // VTE >= 0.50.0 (GNOME Terminal and other VTE-based terms) + return false +} + +/** Wrap ``text`` in an OSC 8 terminal hyperlink pointing at ``url``, or return + * ``text`` unchanged when ``url`` is null. Unlike the TUI's `` (which + * crashes in the current @opentui/solid JSX layer — see workspace-sidebar.tsx), + * plain stdout can emit OSC 8 directly: a terminal directly interpreting the + * bytes either renders a real clickable link or silently skips the sequence + * it doesn't recognize — the visible text is unaffected either way. That + * "harmless when unrecognized" argument only holds when a terminal emulator + * is actually the one reading the bytes, though: with stdout redirected to a + * file or piped into another program (stdin can still be a TTY — the + * interactive-stdin check in the handler doesn't imply stdout is a terminal + * too), there's no interpreter to skip them, so the raw escape sequence + * would land as literal junk in the captured output. Skip the OSC 8 wrapping + * entirely in that case. The *underline* is additionally gated on + * ``terminalSupportsHyperlinks`` — a much older and more universally-rendered + * SGR code than OSC 8, so emitting it unconditionally would make the name + * look clickable in terminals where it isn't. (cubic, PR #1274, rounds 2 + 3.) */ +export function hyperlink(text: string, url: string | null): string { + if (!text) return text + const safeText = stripControlChars(text) + // Sanitize before checking `url` — the null-URL early return used to skip + // stripControlChars entirely, so a caller relying on hyperlink() as its + // sanitization boundary got the raw name whenever no manage URL existed + // (BYOK/unresolvable deployments). Every caller in this file now also + // sanitizes independently before calling this (defense in depth, not the + // sole boundary), but this fixes the function's own contract too. + // (CodeRabbit, PR #1274 round 4.) + // + // Also validate `url` itself, not just `text` — hyperlink() is exported + // (tests import it directly), so its contract is wider than its two + // in-file callers, both of which only ever pass a `buildManageUrl(...)`- + // derived trusted URL. A hypothetical external caller passing something + // unvalidated (e.g. a raw `manage_url` straight from an API response) + // would otherwise defeat the escaping this function is careful about on + // the `text` side while doing nothing for `url`. (multi-model review, PR + // #1274 round 7.) + // + // isSafeHttpUrl only checks that `url` PARSES as http(s) via `new URL()` + // — it doesn't sanitize, and doesn't return the re-serialized/encoded + // form. `new URL()` itself percent-encodes control bytes when it builds + // its own `.toString()`, but that encoding never reaches the ORIGINAL + // `url` string this function actually interpolates below — a string can + // contain a live ESC byte and still parse successfully as a valid + // https: URL (verified: `new URL("https://evil.example/\x1b]8;;...")` + // does not throw). So `isSafeHttpUrl` returning true does not mean `url` + // is free of control bytes; reject it separately, the same way `text` is + // sanitized above — rejecting (falling back to plain text) rather than + // stripping, since a mangled URL is worse than no link at all. (cubic, + // PR #1274 round 8.) + if (!url || stripControlChars(url) !== url || !isSafeHttpUrl(url)) return safeText + if (!process.stdout.isTTY) return safeText + const OSC8 = "\x1b]8;;" + const ST = "\x1b\\" + if (!terminalSupportsHyperlinks()) return `${OSC8}${url}${ST}${safeText}${OSC8}${ST}` + const UNDERLINE = "\x1b[4m" + const UNDERLINE_OFF = "\x1b[24m" + return `${OSC8}${url}${ST}${UNDERLINE}${safeText}${UNDERLINE_OFF}${OSC8}${ST}` +} + export const LinkCommand = cmd({ command: "link", describe: "Link this project to an Altimate workspace", @@ -122,14 +238,35 @@ export const LinkCommand = cmd({ ? projectNameFromRemote(identifier.repoRemote) : projectNameFromPath(identifier.projectPath) const currentId = existing?.datamate.id - const currentName = existing?.datamate.name + // Sanitized once here so every downstream display (the picker message, + // the "Kept" outro, hyperlink()'s own text) is covered — hyperlink() + // only sanitized its own `text` param, not the raw name reaching + // `prompts.outro`/`prompts.select`'s message directly. (CodeRabbit, + // PR #1274 round 4 — flagged one call site; the underlying gap was + // every raw name-interpolation in this file, not just that one.) + const currentName = existing ? stripControlChars(existing.datamate.name) : undefined // 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 workspaceWebBase = resolveWorkspaceWebUrl(creds.altimateUrl, creds.altimateInstanceName) + const browserAvailable = workspaceWebBase !== null + // Deterministic from tenant + id, same derivation as the TUI's + // buildManageUrl (workspace.tsx) — null on BYOK/unresolvable, in which + // case the name below prints as plain (non-clickable) text. + const currentManageUrl = + currentId !== undefined && workspaceWebBase ? buildManageUrl(workspaceWebBase, currentId) : null + // On a terminal `terminalSupportsHyperlinks()` doesn't recognize, the + // OSC 8 wrapping below is invisible bytes and the name renders as plain + // text with no indication a URL exists at all — unlike the TUI, which + // falls back to a toast ("Could not open browser. Copy this URL: ..."). + // Print the plain URL once as a fallback the terminal can't hide, rather + // than leaving it unreachable outside the allowlist. (multi-model + // review, PR #1274.) + if (currentManageUrl && !terminalSupportsHyperlinks()) { + prompts.log.info(`Manage it at: ${currentManageUrl}`) + } const options: Array<{ value: string; label: string; hint?: string }> = [ // Only offer browser handoff for UNLINKED projects (CodeRabbit cycle 5). @@ -160,16 +297,22 @@ export const LinkCommand = cmd({ ? "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), - label: dm.id === currentId ? `● ${dm.name}` : ` ${dm.name}`, - hint: dm.id === currentId ? "currently linked here" : undefined, - })), + ...list.map((dm) => { + // Every row's name is server-controlled (any workspace the account + // can see, not just ones this user created) — sanitize regardless + // of whether this row also goes through hyperlink() below. + const safeDmName = stripControlChars(dm.name) + return { + value: String(dm.id), + label: dm.id === currentId ? `● ${hyperlink(safeDmName, currentManageUrl)}` : ` ${safeDmName}`, + hint: dm.id === currentId ? "currently linked here" : undefined, + } + }), ] const pick = await prompts.select({ message: existing - ? `Currently linked to "${currentName}". Pick a workspace (or create a new one):` + ? `Currently linked to "${hyperlink(currentName!, currentManageUrl)}". Pick a workspace (or create a new one):` : "Pick a workspace to link (or create a new one):", options, initialValue: currentId !== undefined ? String(currentId) : CREATE_NEW_SENTINEL, @@ -261,7 +404,7 @@ async function runBrowserHandoff( projectPath: res.binding.project_path, linkedAt: Date.now(), }, { awaitBackfill: true }) - bindSpin.stop(`Linked to "${res.binding.datamate_name}".`) + bindSpin.stop(`Linked to "${stripControlChars(res.binding.datamate_name)}".`) prompts.log.info("Saved memory blocks will sync to this workspace if memory is enabled for it.") const manageUrl = await manageUrlFor(res.binding.datamate_id) if (manageUrl) prompts.log.info(`Manage it at: ${manageUrl}`) @@ -269,8 +412,9 @@ async function runBrowserHandoff( } catch (err) { bindSpin.stop("Link failed.", 1) if (err instanceof ConflictError) { + const existingName = conflictExistingName(err.detail) 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.`, + `This project is already linked to "${existingName}". 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.") @@ -284,13 +428,17 @@ async function runBrowserHandoff( } /** Best-effort manage-workspace URL for the current credentials. Returns null - * on BYOK / unresolvable deployments — callers omit the "Manage it at" line. */ + * on BYOK / unresolvable deployments — callers omit the "Manage it at" line. + * Delegates the actual join to ``buildManageUrl`` rather than re-deriving it — + * this function had its own copy of the pre-fix string-concatenation bug + * (cubic, PR #1274 round 3): two near-identical builders in the same file + * drifted, and only one got fixed the first time around. */ 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}` + return buildManageUrl(base, workspaceId) } catch { return null } @@ -343,8 +491,9 @@ async function createThenBindOrRebind( // can pick from the list; if the pre-check missed it, this is the // authoritative signal — surface it and hint the picker. if (err instanceof ConflictError) { + const existingName = conflictExistingName(err.detail) prompts.log.error( - `This project is already linked to "${err.detail.existing_datamate_name ?? "another workspace"}". Re-run \`altimate-code link\` to switch to a different workspace.`, + `This project is already linked to "${existingName}". Re-run \`altimate-code link\` to switch to a different workspace.`, ) } else { prompts.log.error(err instanceof Error ? err.message : String(err)) @@ -352,7 +501,11 @@ async function createThenBindOrRebind( process.exitCode = 1 return } - spin.stop(`Workspace "${created.datamate.name}" created.`) + // Sanitized once — echoed back from the create-workspace API response + // (not the locally-typed `name` param), so it's technically server data + // even though it usually just round-trips the caller's own auto-name. + const safeCreatedName = stripControlChars(created.datamate.name) + spin.stop(`Workspace "${safeCreatedName}" created.`) // If the project was already linked, the new workspace exists but the // binding still points at the OLD workspace — rebind so the project is @@ -360,7 +513,7 @@ async function createThenBindOrRebind( // wrote the binding as part of the atomic create; we're done. if (existing) { const rebindSpin = prompts.spinner() - rebindSpin.start(`Repointing project at "${created.datamate.name}"...`) + rebindSpin.start(`Repointing project at "${safeCreatedName}"...`) try { await rebindByMatchedIdentifier({ identifier, @@ -368,11 +521,11 @@ async function createThenBindOrRebind( expectedCurrentDatamateId: existing.datamate.id, matchedBy: existing.matchedBy, }) - rebindSpin.stop(`Project is now linked to "${created.datamate.name}".`) + rebindSpin.stop(`Project is now linked to "${safeCreatedName}".`) } catch (err) { rebindSpin.stop("Could not repoint the project.", 1) prompts.log.error( - `Workspace "${created.datamate.name}" was CREATED but could not be linked to this project. ${err instanceof Error ? err.message : String(err)} — re-run \`altimate-code link\` to retry (or delete the workspace in the SaaS).`, + `Workspace "${safeCreatedName}" was CREATED but could not be linked to this project. ${err instanceof Error ? err.message : String(err)} — re-run \`altimate-code link\` to retry (or delete the workspace in the SaaS).`, ) process.exitCode = 1 return @@ -490,7 +643,7 @@ async function bindOrRebind( targetDatamateId, }) } - rebindSpin.stop(`Re-linked to "${res.binding.datamate_name}".`) + rebindSpin.stop(`Re-linked to "${stripControlChars(res.binding.datamate_name)}".`) } catch (retryErr) { rebindSpin.stop("Re-link failed.", 1) throw retryErr @@ -508,11 +661,8 @@ async function bindOrRebind( projectPath: res.binding.project_path, linkedAt: Date.now(), }, { awaitBackfill: true }) - spin.stop( - isRebind - ? `Re-linked to "${res.binding.datamate_name}".` - : `Linked to "${res.binding.datamate_name}".`, - ) + const safeResName = stripControlChars(res.binding.datamate_name) + spin.stop(isRebind ? `Re-linked to "${safeResName}".` : `Linked to "${safeResName}".`) prompts.log.info("Saved memory blocks will sync to this workspace if memory is enabled for it.") const manageUrl = await manageUrlFor(res.binding.datamate_id) if (manageUrl) prompts.log.info(`Manage it at: ${manageUrl}`) @@ -520,9 +670,8 @@ async function bindOrRebind( } catch (err) { spin.stop(isRebind ? `Re-link failed.` : `Link failed.`, 1) if (err instanceof ConflictError) { - prompts.log.error( - `Already linked to "${err.detail.existing_datamate_name ?? "another workspace"}". Re-run \`altimate-code link\` to switch.`, - ) + const existingName = conflictExistingName(err.detail) + prompts.log.error(`Already linked to "${existingName}". Re-run \`altimate-code link\` to switch.`) } else if (err instanceof PreconditionFailedError) { prompts.log.error("Someone else re-linked this project — re-run and try again.") } else if (err instanceof NotFoundError) { diff --git a/packages/opencode/src/plugin/tui/altimate/workspace-sidebar.tsx b/packages/opencode/src/plugin/tui/altimate/workspace-sidebar.tsx index d9e88edf43..f00d8ab061 100644 --- a/packages/opencode/src/plugin/tui/altimate/workspace-sidebar.tsx +++ b/packages/opencode/src/plugin/tui/altimate/workspace-sidebar.tsx @@ -10,9 +10,10 @@ 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 { buildManageUrl, resolveWorkspaceWebUrl } from "@/altimate/workspace/browser-handoff" import { getResolvedWorkspaceId } from "@/altimate/workspace/session-context" import { AltimateApi } from "@/altimate/api/client" +import { openManageUrl } from "./workspace" const id = "altimate:sidebar-workspace" @@ -30,8 +31,8 @@ const POLL_MS = 30_000 * 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 { +let cachedManageBase: { apiUrl: string; tenant: string; base: URL | null } | null = null +async function resolveManageBase(): Promise { try { const creds = await AltimateApi.getCredentials() if ( @@ -41,8 +42,7 @@ async function resolveManageBase(): Promise { ) { return cachedManageBase.base } - const url = resolveWorkspaceWebUrl(creds.altimateUrl, creds.altimateInstanceName) - const base = url ? url.toString().replace(/\/$/, "") : null + const base = resolveWorkspaceWebUrl(creds.altimateUrl, creds.altimateInstanceName) cachedManageBase = { apiUrl: creds.altimateUrl, tenant: creds.altimateInstanceName, base } return base } catch { @@ -68,7 +68,7 @@ function View(props: { api: TuiPluginApi }) { return } const base = await resolveManageBase() - setManageUrl(base ? `${base}/w/${b.datamateId}` : null) + setManageUrl(base ? buildManageUrl(base, b.datamateId) : null) } finally { refreshInFlight = false } @@ -97,27 +97,50 @@ function View(props: { api: TuiPluginApi }) { > {(b) => ( <> - - {b().datamateName} - {/* ``pinned via --workspace`` means "this SESSION was launched - * with --workspace and it resolved to this id". It does NOT - * mean "the current binding was set by --workspace" — if the - * user relinks mid-session to a different workspace, the pin - * disappears (id mismatch); if they relink to the same id, - * the pin correctly stays because the launch fact is - * unchanged. Known imprecision: relink-to-same-id looks - * indistinguishable from "never relinked". Accepted per - * altimate-harness-bot round 8 (option b of the review). - * ``getResolvedWorkspaceId`` returns null when the launch - * had no --workspace flag or the flag failed to resolve, - * so the pin never falsely appears for a session that - * wasn't launched with the flag. */} - - {" (pinned via --workspace)"} + {/* Clicking the name (or the URL line below) opens the workspace + * in the browser — the manage URL is deterministic from tenant + * + id (see resolveManageBase above), so there's no extra + * round-trip before it's clickable. The whole line is the click + * target (mouse events only land on block-level ``/``, + * not inline ``/`` nodes), while only the name itself + * is styled to look like a link — matching the footer's docs/ + * community links (sidebar/footer.tsx), which use the same + * span-style + onMouseUp pair because raw `` hyperlink + * nodes crash in this JSX layer. ``onMouseUp`` is omitted + * entirely (not just a no-op) when there's no URL yet, so the + * name never advertises a click target that does nothing. The + * "pinned via --workspace" hint lives on its own line below + * (rather than appended inline here) so the click region + * doesn't extend over text that isn't part of the link — same + * reasoning as the URL line already being separate. (multi-model + * review, PR #1274.) */} + openManageUrl(props.api, manageUrl()!) : undefined}> + + {(_u) => {b().datamateName}} + {/* ``pinned via --workspace`` means "this SESSION was launched + * with --workspace and it resolved to this id". It does NOT + * mean "the current binding was set by --workspace" — if the + * user relinks mid-session to a different workspace, the pin + * disappears (id mismatch); if they relink to the same id, + * the pin correctly stays because the launch fact is + * unchanged. Known imprecision: relink-to-same-id looks + * indistinguishable from "never relinked". Accepted per + * altimate-harness-bot round 8 (option b of the review). + * ``getResolvedWorkspaceId`` returns null when the launch + * had no --workspace flag or the flag failed to resolve, + * so the pin never falsely appears for a session that + * wasn't launched with the flag. */} + + (pinned via --workspace) + - {(u) => {u()}} + {(u) => ( + openManageUrl(props.api, u())}> + {u()} + + )} )} diff --git a/packages/opencode/src/plugin/tui/altimate/workspace.tsx b/packages/opencode/src/plugin/tui/altimate/workspace.tsx index f59f0275ba..31cc3a7060 100644 --- a/packages/opencode/src/plugin/tui/altimate/workspace.tsx +++ b/packages/opencode/src/plugin/tui/altimate/workspace.tsx @@ -39,6 +39,7 @@ import { type ProjectIdentifier, } from "@/altimate/workspace/api-client" import { + buildManageUrl, openWorkspaceBrowserHandoff, resolveWorkspaceWebUrl, type HandoffResult, @@ -244,13 +245,21 @@ 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 { + * unresolvable) — the confirmation dialog degrades to id-only in that case. + * + * Named ``resolveManageUrl`` (not ``buildManageUrl``) so it doesn't collide + * with — and locally shadow the meaning of — the shared, imported + * ``buildManageUrl`` (browser-handoff.ts) this function delegates the + * actual join to. Before this rename, the import needed an alias + * (``buildManageUrl as joinManageUrlPath``) just to coexist with this + * function's own name, which made the *real* ``buildManageUrl`` invisible + * under that name inside this file. (Kilo, PR #1274 round 8.) */ +async function resolveManageUrl(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}` + return buildManageUrl(base, workspaceId) } catch { return null } @@ -297,26 +306,11 @@ function WorkspaceLinkedDialog(props: LinkedProps) { current={props.manageUrl ? "open" : "done"} onSelect={(option) => { 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, - }) - }) - } + // scheme handler. resolveManageUrl only ever emits http(s) URLs + // from resolveWorkspaceWebUrl, but the guard survives future changes. + openManageUrl(props.api, props.manageUrl) } props.api.ui.dialog.clear() }} @@ -332,7 +326,7 @@ async function showLinkedConfirmation( workspaceId: number, workspaceName: string, ): Promise { - const manageUrl = await buildManageUrl(workspaceId) + const manageUrl = await resolveManageUrl(workspaceId) api.ui.dialog.replace(() => ( )) @@ -578,9 +572,11 @@ async function createAndBindInline( /** 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). Kept exported - * as a top-level helper because both ``showLinkedConfirmation`` (below) and - * the on-demand link paths need the same guard. */ + * dispatch to whatever OS scheme handler matches the protocol). Not exported — + * ``openManageUrl`` below is the sole caller; ``cli/cmd/link.ts`` deliberately + * keeps its own private copy (CLI/TUI split, see that file's comment) rather + * than importing this one. (Kilo, PR #1274 — the prior `export` had no + * external importers.) */ function isSafeHttpUrl(url: string): boolean { try { const u = new URL(url) @@ -590,6 +586,31 @@ function isSafeHttpUrl(url: string): boolean { } } +/** Guarded ``open(url)`` for a workspace manage-URL: refuses (and toasts) a + * non-http(s) URL before calling ``open()``, and toasts if ``open()`` itself + * fails. The single implementation every "open in browser" action in this + * TUI plugin calls — ``WorkspaceLinkedDialog``, ``AlreadyLinkedDialog``, and + * ``workspace-sidebar.tsx`` (all live under this same TUI plugin path — + * unlike ``isSafeHttpUrl``'s CLI/TUI split, there's no reason for these call + * sites to diverge). */ +export function openManageUrl(api: TuiPluginApi, url: string) { + if (!isSafeHttpUrl(url)) { + api.ui.toast({ + variant: "warning", + message: `Refused to open a non-http URL: ${url}`, + duration: 15_000, + }) + return + } + open(url).catch(() => { + api.ui.toast({ + variant: "warning", + message: `Could not open browser. Copy this URL: ${url}`, + duration: 15_000, + }) + }) +} + /** Pick the rebind endpoint that matches which identifier the pre-check * resolved the binding on. Shared with cli/cmd/link.ts through duplicated * code (M3) — the modules deliberately don't cross-import so the CLI @@ -627,6 +648,9 @@ interface AlreadyLinkedProps { hasDrift: boolean driftedWas?: string | null unverified?: boolean + /** Pre-resolved by the caller — see ``AlreadyLinkedDialog``'s comment for + * why this must not be fetched async inside the dialog itself. */ + manageUrl: string | null /** Which identifier arm resolved the binding — remote-matched projects * rebind via ``/by-remote``, path-matched via ``/by-path``. Not the same * as ``identifier.repoRemote`` / ``identifier.projectPath``, which reflect @@ -636,9 +660,31 @@ interface AlreadyLinkedProps { } function AlreadyLinkedDialog(props: AlreadyLinkedProps) { + // ``manageUrl`` is a plain prop, resolved by the caller (``runFlow``) + // BEFORE this dialog is shown — not fetched async in an onMount here. + // dialog-select.tsx's ``store.selected`` is a raw numeric index, and + // nothing re-syncs it when ``props.options`` changes shape (the only + // effect that resyncs selection fires on `props.current`/`store.filter` + // changes, not on the options array). Options here start at 3 items and + // conditionally grow to 4 when "Open in browser" becomes available — if + // that insertion landed asynchronously after the dialog painted, a user + // who already pressed Down to reach "Skip for now" (index 2) would find + // Enter now submits "Open in browser" instead, since the array grew out + // from under a stale index. Keeping this component fully synchronous + // (matching ``WorkspaceLinkedDialog``'s ``manageUrl`` prop, resolved via + // ``showLinkedConfirmation`` before render) removes the moving target + // instead of trying to resync around it. (multi-model review, PR #1274.) + // Title carries the primary context (workspace name + drift/unverified hint) // since DialogSelect doesn't take a top-level description block. Verbose but // it puts the critical info in the user's field of view before they pick. + // + // The plugin-facing ``TuiDialogSelectProps`` (packages/plugin/src/tui.ts) + // only takes a plain ``title: string`` — no ``titleView``/JSX escape hatch + // like the native ``packages/tui`` DialogSelect has (see + // dialog-move-session.tsx) — so the workspace name inside the title can't + // be made clickable the way the sidebar tile is. "Open in browser" as a + // selectable option (below) is the equivalent affordance within that API. const title = () => { const parts: string[] = [`Project is linked to workspace "${props.workspaceName}"`] const now = props.identifier.repoRemote ?? props.identifier.projectPath @@ -646,32 +692,49 @@ function AlreadyLinkedDialog(props: AlreadyLinkedProps) { if (props.unverified) parts.push("(⚠ unverified — server unreachable, showing cached value)") return parts.join(" ") } + const options = () => { + const opts = [ + { + title: "Attach and continue", + value: "attach", + description: "Use this workspace for the session.", + }, + { + title: "Re-link to a different workspace", + value: "relink", + description: "Swap this project's workspace.", + }, + ] + if (props.manageUrl) { + opts.push({ + title: "Open in browser", + value: "open", + description: "View this workspace on the web.", + }) + } + opts.push({ + title: "Skip for now", + value: "skip", + description: "Close this prompt without changing the link.", + }) + return opts + } return ( { if (option.value === "attach" || option.value === "skip") { props.api.ui.dialog.clear() return } + if (option.value === "open") { + if (props.manageUrl) openManageUrl(props.api, props.manageUrl) + // Stay open — opening the browser isn't a decision about the link + // itself, so the user can still Attach/Re-link/Skip afterward. + return + } // relink → picker with the current workspace id as expected_current so // a concurrent re-link by another client 412s cleanly. matchedBy // determines which rebind endpoint the picker will call (M3). @@ -1087,6 +1150,9 @@ async function runFlow(api: TuiPluginApi, directory: string): Promise { const currentIdent = serverBinding.matchedBy === "remote" ? identifier.repoRemote : identifier.projectPath const hasDrift = boundIdent != null && currentIdent != null && boundIdent !== currentIdent + // Resolved before the dialog renders — see AlreadyLinkedDialog's comment + // on why this can't be fetched async inside the dialog itself. + const manageUrl = await resolveManageUrl(serverBinding.datamate.id) api.ui.dialog.replace(() => ( { matchedBy={serverBinding!.matchedBy} hasDrift={hasDrift} driftedWas={hasDrift ? boundIdent : undefined} + manageUrl={manageUrl} /> )) return @@ -1125,6 +1192,7 @@ async function runFlow(api: TuiPluginApi, directory: string): Promise { const currentIdent = cachedMatchedBy === "remote" ? identifier.repoRemote : identifier.projectPath const hasDrift = cachedIdent !== "" && currentIdent != null && cachedIdent !== currentIdent + const manageUrl = await resolveManageUrl(local.datamateId) api.ui.dialog.replace(() => ( { matchedBy={cachedMatchedBy} hasDrift={hasDrift} driftedWas={hasDrift ? cachedIdent : undefined} + manageUrl={manageUrl} unverified /> )) diff --git a/packages/opencode/test/altimate/workspace/browser-handoff.test.ts b/packages/opencode/test/altimate/workspace/browser-handoff.test.ts index 00ba3be32a..f94ce5ffc0 100644 --- a/packages/opencode/test/altimate/workspace/browser-handoff.test.ts +++ b/packages/opencode/test/altimate/workspace/browser-handoff.test.ts @@ -12,6 +12,7 @@ import { createServer, connect } from "node:net" import { AltimateApi } from "../../../src/altimate/api/client" import { + buildManageUrl, openWorkspaceBrowserHandoff, resolveWorkspaceWebUrl, runHandoffWithOpener, @@ -125,6 +126,32 @@ describe("resolveWorkspaceWebUrl", () => { }) }) +// Moved here from cli/cmd/link.test.ts (PR #1274 round 7) — buildManageUrl +// itself moved from a private cli/cmd/link.ts helper to live beside +// resolveWorkspaceWebUrl, since both the CLI and the TUI plugin already +// import this module for that function. Previously duplicated as three +// near-identical private copies (link.ts, workspace.tsx, workspace-sidebar.tsx); +// 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", + ) + }) + + test("joins via pathname, not string concatenation, when the base carries a query/fragment", () => { + // The dev-only ALTIMATE_WORKSPACE_WEB_URL override can be an arbitrary + // URL (e.g. a local dev server) — naive `toString() + "/w/id"` + // concatenation would land the path inside the query string instead. + const url = buildManageUrl(new URL("http://localhost:3003/base?x=1#frag"), 42) + expect(url).toBe("http://localhost:3003/base/w/42") + }) + + test("normalizes a trailing slash on the base path", () => { + expect(buildManageUrl(new URL("https://host/base/"), 7)).toBe("https://host/base/w/7") + }) +}) + // ───────────────────────────────────────────────────────────────────────────── // openWorkspaceBrowserHandoff — pre-flight failures (do not open a browser) // ───────────────────────────────────────────────────────────────────────────── diff --git a/packages/opencode/test/cli/cmd/link.test.ts b/packages/opencode/test/cli/cmd/link.test.ts new file mode 100644 index 0000000000..a8ab819880 --- /dev/null +++ b/packages/opencode/test/cli/cmd/link.test.ts @@ -0,0 +1,248 @@ +// altimate_change - new file +// Unit coverage for the pure-logic helpers in +// packages/opencode/src/cli/cmd/link.ts that back the `altimate-code link` +// picker's clickable-workspace-name affordance: control-char sanitization, +// terminal capability detection, and the OSC 8 wrapper itself. URL joining +// (`buildManageUrl`) is tested in +// test/altimate/workspace/browser-handoff.test.ts, where the function now +// lives (shared with the TUI plugin). The interactive `@clack/prompts` flow +// (LinkCommand.handler) needs a TTY and is covered by manual verification +// (PR #1274), not here. +import { afterEach, beforeEach, describe, expect, test } from "bun:test" +import { hyperlink, stripControlChars, terminalSupportsHyperlinks } from "../../../src/cli/cmd/link" + +// Shared by both describe blocks below that exercise terminalSupportsHyperlinks +// (directly, or indirectly via hyperlink()). Object.defineProperty defaults +// omitted attributes (enumerable/writable) to false, so restoring via +// `{ value, configurable: true }` alone would silently collapse those flags +// from whatever the real descriptor had — capture and restore the full +// descriptor instead. (CodeRabbit, PR #1274 round 4.) The env vars cleared +// here are every signal terminalSupportsHyperlinks() reads — an ambient +// WT_SESSION/KONSOLE_VERSION/VTE_VERSION on the host or CI runner would +// otherwise make an "unsupported" test spuriously pass. +const TERMINAL_ENV_KEYS = ["TERM", "TERM_PROGRAM", "WT_SESSION", "KONSOLE_VERSION", "VTE_VERSION"] as const +const ORIGINAL_TTY_DESCRIPTOR = Object.getOwnPropertyDescriptor(process.stdout, "isTTY") + +function clearTerminalEnv() { + for (const key of TERMINAL_ENV_KEYS) delete process.env[key] +} + +function setTTY(value: boolean) { + Object.defineProperty(process.stdout, "isTTY", { value, configurable: true }) +} + +function restoreTTY() { + // Whether `isTTY` is an own property of process.stdout genuinely depends + // on whether stdout IS a real TTY — it is NOT always inherited/undefined + // (a later review round claimed otherwise; verified wrong empirically, see + // below). Node backs `process.stdout` with different stream classes + // depending on what fd 1 actually is: a `tty.WriteStream` when it's a + // terminal (which sets `this.isTTY = true` as a genuine own instance + // property — confirmed via `Object.getOwnPropertyDescriptor` inside a real + // pty, e.g. `tmux new-session ... bun -e '...'`, where it returns + // `{value: true, writable: true, enumerable: true, configurable: true}`, + // not undefined), versus a plain stream with no `isTTY` at all when piped/ + // redirected (which is how it always runs under `bun test`/CI, hence + // ORIGINAL_TTY_DESCRIPTOR being undefined in THAT case specifically). + // So: re-define when there was a real descriptor to restore (interactive + // `bun test` run), delete when there wasn't (everywhere else) — both + // branches are reachable and necessary, not dead code. (cubic, PR #1274 + // round 5, on the previous version of this function that always no-op'd + // for the common non-TTY case.) + if (ORIGINAL_TTY_DESCRIPTOR) Object.defineProperty(process.stdout, "isTTY", ORIGINAL_TTY_DESCRIPTOR) + else delete (process.stdout as { isTTY?: boolean }).isTTY +} + +describe("restoreTTY (test-helper regression)", () => { + test("actually removes the isTTY property setTTY() added, instead of leaving it dangling", () => { + const before = Object.getOwnPropertyDescriptor(process.stdout, "isTTY") + setTTY(true) + // If the assertion below throws, restoreTTY() must still run — otherwise + // this test's own process-global mutation leaks into every test after + // it. (cubic, PR #1274 round 6.) + try { + expect(Object.getOwnPropertyDescriptor(process.stdout, "isTTY")).toBeDefined() + } finally { + restoreTTY() + } + expect(Object.getOwnPropertyDescriptor(process.stdout, "isTTY")).toEqual(before) + }) +}) + +describe("stripControlChars", () => { + test("removes C0 control bytes including ESC", () => { + expect(stripControlChars("a\x1bb\x00c")).toBe("abc") + }) + + test("removes DEL and C1 control bytes", () => { + expect(stripControlChars("a\x7fb\x9fc\x80d")).toBe("abcd") + }) + + test("neutralizes an embedded OSC 8 sequence into inert text", () => { + const malicious = "name\x1b]8;;http://evil.example\x1b\\CLICK ME\x1b]8;;\x1b\\" + const sanitized = stripControlChars(malicious) + expect(sanitized).not.toContain("\x1b") + // The literal (non-ESC) bytes survive as inert text — only the escape + // bytes that would make it a live control sequence are stripped. + expect(sanitized).toBe("name]8;;http://evil.example\\CLICK ME]8;;\\") + }) + + test("leaves ordinary printable text untouched", () => { + expect(stripControlChars("Rakuten Analytics Pipeline")).toBe("Rakuten Analytics Pipeline") + }) +}) + +describe("terminalSupportsHyperlinks", () => { + const ORIGINAL_ENV = { ...process.env } + + beforeEach(clearTerminalEnv) + + afterEach(() => { + process.env = { ...ORIGINAL_ENV } + restoreTTY() + }) + + test("false when stdout is not a TTY, regardless of TERM_PROGRAM", () => { + setTTY(false) + process.env.TERM_PROGRAM = "iTerm.app" + expect(terminalSupportsHyperlinks()).toBe(false) + }) + + test("false for TERM=dumb or TERM=linux even on a TTY", () => { + setTTY(true) + process.env.TERM = "dumb" + expect(terminalSupportsHyperlinks()).toBe(false) + process.env.TERM = "linux" + expect(terminalSupportsHyperlinks()).toBe(false) + }) + + test("true for known-supporting TERM_PROGRAM values", () => { + setTTY(true) + for (const program of ["iTerm.app", "WezTerm", "Hyper", "vscode", "ghostty", "Tabby", "rio"]) { + process.env.TERM_PROGRAM = program + expect(terminalSupportsHyperlinks()).toBe(true) + } + }) + + test("false for Apple_Terminal — OSC 8 support can't be inferred from TERM_PROGRAM alone", () => { + setTTY(true) + process.env.TERM_PROGRAM = "Apple_Terminal" + expect(terminalSupportsHyperlinks()).toBe(false) + }) + + test("true when WT_SESSION is set (Windows Terminal)", () => { + setTTY(true) + process.env.WT_SESSION = "some-guid" + expect(terminalSupportsHyperlinks()).toBe(true) + }) + + test("true when KONSOLE_VERSION is set", () => { + setTTY(true) + process.env.KONSOLE_VERSION = "220400" + expect(terminalSupportsHyperlinks()).toBe(true) + }) + + test("VTE_VERSION >= 5000 (>= 0.50.0) is supported, below it is not", () => { + setTTY(true) + process.env.VTE_VERSION = "5000" + expect(terminalSupportsHyperlinks()).toBe(true) + process.env.VTE_VERSION = "4800" + expect(terminalSupportsHyperlinks()).toBe(false) + }) + + test("false with no recognized signal at all", () => { + setTTY(true) + expect(terminalSupportsHyperlinks()).toBe(false) + }) +}) + +// buildManageUrl's own tests moved to +// test/altimate/workspace/browser-handoff.test.ts (PR #1274 round 7) — the +// function itself moved there too, since it's now shared by the CLI and the +// TUI plugin rather than a private cli/cmd/link.ts helper. + +describe("hyperlink", () => { + const ORIGINAL_ENV = { ...process.env } + + beforeEach(clearTerminalEnv) + + afterEach(() => { + process.env = { ...ORIGINAL_ENV } + restoreTTY() + }) + + test("returns text unchanged when url is null", () => { + expect(hyperlink("anas-skill-test", null)).toBe("anas-skill-test") + }) + + test("sanitizes text even when url is null", () => { + const malicious = "name\x1b]8;;http://evil.example\x1b\\CLICK ME\x1b]8;;\x1b\\" + const out = hyperlink(malicious, null) + expect(out).not.toContain("\x1b") + expect(out).toBe("name]8;;http://evil.example\\CLICK ME]8;;\\") + }) + + test("returns bare sanitized text with no escape bytes at all when stdout isn't a TTY", () => { + // Even with a recognized TERM_PROGRAM — stdin can be a TTY (satisfying + // the handler's interactive-input check) while stdout is redirected to + // a file or piped, in which case no terminal is reading these bytes and + // 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") + 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\\") + 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") + expect(out).toBe( + "\x1b]8;;https://tenant.ws.myaltimate.com/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") + // 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) + expect(out).toContain("name]8;;http://evil.example\\CLICK ME]8;;\\") + }) + + test("refuses a non-http(s) url — no OSC 8 bytes, just the sanitized text", () => { + setTTY(true) + process.env.TERM_PROGRAM = "iTerm.app" + for (const url of ["file:///etc/passwd", "javascript:alert(1)", "ftp://host/path"]) { + const out = hyperlink("name", url) + expect(out).toBe("name") + expect(out).not.toContain("\x1b") + } + }) + + test("refuses a url that parses as http(s) but still carries a live control byte", () => { + // isSafeHttpUrl only checks that `new URL(url)` parses and the protocol + // is http(s) — it does not sanitize, and a string can contain a live + // ESC byte and still parse successfully. hyperlink() interpolates the + // ORIGINAL string, not new URL(url)'s re-serialized/encoded form, so + // that parse check alone doesn't guarantee `url` is safe to embed. + setTTY(true) + process.env.TERM_PROGRAM = "iTerm.app" + const maliciousUrl = "https://evil.example/\x1b]8;;http://spoofed.example\x1b\\CLICK\x1b]8;;\x1b\\" + const out = hyperlink("name", maliciousUrl) + expect(out).toBe("name") + expect(out).not.toContain("\x1b") + }) +})