diff --git a/apps/server/src/lazurio/browserView.ts b/apps/server/src/lazurio/browserView.ts index 7ec1919d9f380..82a65dea13fb7 100644 --- a/apps/server/src/lazurio/browserView.ts +++ b/apps/server/src/lazurio/browserView.ts @@ -1,12 +1,12 @@ /** - * Lazurio overlay: a Remote Environment's people's view is at `https://browser..lazurio.io`, + * Lazurio overlay: a Remote Environment's people's view is at `https://browser..lazurio.io`, * where `/t/` shows one remote tab. `apps/web/src/lazurio/browserView.ts` holds * a copy of `lazurioBrowserView`; the release contract test keeps the two equal. */ /** One remote tab of a Lazurio Environment browser, read from the address of its view. */ export interface LazurioBrowserView { - /** The view, `https://browser..lazurio.io/t/`. */ + /** The view, `https://browser..lazurio.io/t/`. */ readonly view: string; readonly origin: string; /** The remote tab's DevTools target id: 32 hexadecimal digits. */ @@ -21,7 +21,8 @@ export interface LazurioBrowserView { /** * The remote tab whose view is at `address`, or null unless it is exactly - * `https://browser..lazurio.io/t/<32 hex digits>`: no port, credentials, query + * `https://browser..lazurio.io/t/<32 hex digits>`, `` an Environment id of one or two + * DNS labels (LazurioPlatform F50, `environmentIdOf` and `dnsLabel`): no port, credentials, query * or fragment, not even an empty one. */ export function lazurioBrowserView(address: string): LazurioBrowserView | null { @@ -34,17 +35,20 @@ export function lazurioBrowserView(address: string): LazurioBrowserView | null { if (url.protocol !== "https:" || url.port !== "" || url.href !== `${url.origin}${url.pathname}`) { return null; } - const labels = /^browser\.((?:[a-z0-9-]+\.)+)lazurio\.io$/.exec(url.hostname)?.[1]?.split("."); + // A label starts and ends with a letter or digit and has at most 63 characters. + const base = + /^browser\.([a-z0-9](?:[a-z0-9-]{0,61}[a-z0-9])?)(?:\.([a-z0-9](?:[a-z0-9-]{0,61}[a-z0-9])?))?\.lazurio\.io$/.exec( + url.hostname, + ); const targetId = /^\/t\/([0-9A-Fa-f]{32})$/.exec(url.pathname)?.[1]; - if (labels === undefined || targetId === undefined) return null; - // The captured labels end in a dot, so the last item is empty. - labels.pop(); + if (base === null || targetId === undefined) return null; + const [, environment, owner] = base; return { view: url.href, origin: url.origin, targetId, - owner: labels.at(-1)!, - environment: labels[0]!, - personal: labels.length === 1, + owner: owner ?? environment!, + environment: environment!, + personal: owner === undefined, }; } diff --git a/apps/server/src/lazurio/environmentBrowser.test.ts b/apps/server/src/lazurio/environmentBrowser.test.ts index 39b30de850057..4e157f64f28a2 100644 --- a/apps/server/src/lazurio/environmentBrowser.test.ts +++ b/apps/server/src/lazurio/environmentBrowser.test.ts @@ -20,6 +20,7 @@ import * as HostProcess from "@t3tools/shared/HostProcess"; import * as Deferred from "effect/Deferred"; import * as Effect from "effect/Effect"; import * as Layer from "effect/Layer"; +import * as Result from "effect/Result"; import * as Fiber from "effect/Fiber"; import * as Schema from "effect/Schema"; import * as Stream from "effect/Stream"; @@ -841,51 +842,6 @@ it.effect("refuses to drive another Environment's tab, and preview_open comes ba ), ); -// A fragment never reaches the gateway, and a query, another path or a port is still that -// Environment's browser: preview_open and preview_navigate fail closed for any of them, before -// this browser or its lazurio command is asked (only the exact view of one tab is answered). -it.effect("fails closed for any other address in another Environment's browser", () => - Effect.scoped( - Effect.gen(function* () { - const host = yield* startHost({ link: declared }); - yield* host.registered; - for (const address of [ - `${foreignView}#view`, - `${foreignView}?source=agent`, - "https://browser.vm-02.acme.lazurio.io/", - `https://browser.vm-02.acme.lazurio.io:8443/t/${foreignId}`, - `https://BROWSER.vm-02.acme.lazurio.io/t/${foreignId}/live`, - // F50 takes the view only as written, not as `new URL` normalizes it. - `https://BROWSER.vm-02.acme.lazurio.io/t/${foreignId}`, - `https://browser.vm-02.acme.lazurio.io:443/t/${foreignId}`, - `https://browser.vm-02.acme.lazurio.io/x/../t/${foreignId}`, - // The DNS root's trailing dot names the same host (LazurioPlatform F50 point 2). - `https://browser.vm-02.acme.lazurio.io./t/${foreignId}`, - `https://browser.vm-02.acme.lazurio.io../t/${foreignId}`, - ]) { - for (const operation of ["open", "navigate"] as const) { - const error = yield* invoke(host.broker, operation, { url: address }).pipe( - Effect.flip, - ); - expect([operation, address, error]).toEqual([ - operation, - address, - expect.any(PreviewAutomationExecutionError), - ]); - expect(error.message).toContain( - "in another Environment's browser (browser.vm-02.acme.lazurio.io), which this Environment's browser does not load", - ); - } - expect((yield* Effect.flip(host.pages.open(threadId, address))).reason).toBe( - "invalid-request", - ); - } - expect(host.browser.connections).toHaveLength(0); - expect(host.runs).toEqual([{ command: lazurio, args: ["browser", "link", "--json"] }]); - }), - ), -); - /** An Environment that declares its own view on a Lazurio origin. */ const declaredOnLazurio: CliAnswer = { code: 0, @@ -920,34 +876,7 @@ it.effect( expect(host.browser.opened).toEqual([]); expect(host.held).toEqual([{ threadId: otherThreadId, tabId: personTab }]); - // Any other address on the own view host is refused, the trailing dot's form included. - for (const address of [ - `https://browser.vm-01.acme.lazurio.io./t/${personTab}`, - `${ownView}?source=agent`, - `https://BROWSER.vm-01.acme.lazurio.io/t/${personTab}`, - `https://browser.vm-01.acme.lazurio.io:443/t/${personTab}`, - `https://browser.vm-01.acme.lazurio.io/x/../t/${personTab}`, - "https://browser.vm-01.acme.lazurio.io./", - "https://browser.vm-01.acme.lazurio.io/?", - "https://browser.vm-01.acme.lazurio.io:443/", - "https://BROWSER.vm-01.acme.lazurio.io/", - ]) { - for (const operation of ["open", "navigate"] as const) { - const error = yield* invoke(host.broker, operation, { url: address }).pipe( - Effect.flip, - ); - expect([operation, address, error.message]).toEqual([ - operation, - address, - expect.stringContaining( - "is this Environment's own browser view (browser.vm-01.acme.lazurio.io), which this browser does not load inside itself", - ), - ]); - } - expect((yield* Effect.flip(host.pages.open(threadId, address))).reason).toBe( - "invalid-request", - ); - } + // Navigating there is refused, as for every address on a view host (the F50 table below). const navigation = yield* invoke(host.broker, "navigate", { url: ownView }).pipe( Effect.flip, ); @@ -1036,6 +965,251 @@ it.effect("takes a view's tab id in either case and hands on its upper-case form ), ); +// LazurioPlatform F50 point 2 (`viewAddressOf`, `isViewHost`), case by case: a view address is +// taken only as written, its own bare origin a new tab, `/t/` its tab or another Environment's. +// Any other address on a view host is refused, as written or as `normalizePreviewUrl` makes it, +// before this browser loads it; an address on any other host loads. +const ownHost = "browser.vm-01.acme.lazurio.io"; +const foreignHost = "browser.vm-02.acme.lazurio.io"; +type Takes = + | "new tab" + | "own tab" + | { readonly foreign: string } + | { readonly refused: string } + /** `normalizePreviewUrl` reads no URL in it, so nothing is opened or loaded. */ + | "invalid" + /** What this browser loads. */ + | { readonly loads: string }; + +/** Each address on the view host `host` that F50 point 2 does not take, around the tab `id`. */ +const offContract = (host: string, id: string): ReadonlyArray => [ + // The host as written: its trailing dot, upper case, or a scheme in upper case. + `https://${host}./t/${id}`, + `https://${host}../t/${id}`, + `https://${host}%2e/t/${id}`, + `https://${host}\u3002/t/${id}`, + `https://${host.toUpperCase()}/t/${id}`, + `https://${host.replace("browser", "BROWSER")}/t/${id}`, + `HTTPS://${host}/t/${id}`, + // A port, even the default one, or another scheme. + `https://${host}:443/t/${id}`, + `https://${host}:8443/t/${id}`, + `https://${host}:/t/${id}`, + `http://${host}/t/${id}`, + // What only `new URL` or `normalizePreviewUrl` makes a view address of. + `https:/${host}/t/${id}`, + `//${host}/t/${id}`, + `${host}/t/${id}`, + ` https://${host}/t/${id} `, + `https://${host}/t/${id}\n`, + `https://${host}\\t\\${id}`, + `https:\\\\${host}\\t\\${id}`, + `ht\ttps://${host}/t/${id}`, + `\u00a0https://${host}/t/${id}`, + // Another path. + `https://${host}/x/../t/${id}`, + `https://${host}/t/./${id}`, + `https://${host}//t/${id}`, + `https://${host}/t/${id}/`, + `https://${host}/t/${id}/live`, + `https://${host}/t/`, + `https://${host}/t`, + `https://${host}/x`, + `https://${host}/T/${id}`, + `https://${host}/%74/${id}`, + // A query, a fragment or credentials, even empty ones. + `https://${host}/t/${id}?source=agent`, + `https://${host}/t/${id}?`, + `https://${host}/t/${id}#view`, + `https://${host}/t/${id}#`, + `https://user@${host}/t/${id}`, + `https://user:pw@${host}/t/${id}`, + `https://@${host}/t/${id}`, + // No target id: 31 or 33 digits, not hexadecimal, or percent-encoded. + `https://${host}/t/${id.slice(1)}`, + `https://${host}/t/${id}0`, + `https://${host}/t/${"G".repeat(32)}`, + `https://${host}/t/%41${id.slice(1)}`, + // The bare origin in any form but the two. + `https://${host}/?`, + `https://${host}/?source=agent`, + `https://${host}#`, + `https://${host}/#`, + `https://${host}:443`, + `https://${host}:443/`, + `https://${host}:/`, + `https://:@${host}/`, + `\ufeffhttps://${host}/`, + `https://${host}./`, + `https://${host}/./`, + `https://${host}//`, + `https://${host.toUpperCase()}/`, + `https://${host.replace("browser", "BROWSER")}/`, + ` https://${host}/`, + host, +]; +const mixedCase = (id: string) => + [...id].map((digit, index) => (index % 2 === 0 ? digit.toLowerCase() : digit)).join(""); +const personalHost = "browser.example-person.lazurio.io"; +const personalView = `https://${personalHost}/t/${foreignId}`; +const longLabel = "a".repeat(63); +const contractCases: ReadonlyArray<{ readonly address: string; readonly takes: Takes }> = [ + { address: `https://${ownHost}`, takes: "new tab" }, + { address: `https://${ownHost}/`, takes: "new tab" }, + { address: `https://${ownHost}/t/${personTab}`, takes: "own tab" }, + { address: `https://${ownHost}/t/${personTab.toLowerCase()}`, takes: "own tab" }, + { address: `https://${ownHost}/t/${mixedCase(personTab)}`, takes: "own tab" }, + { address: foreignView, takes: { foreign: foreignView } }, + { + address: `https://${foreignHost}/t/${foreignId.toLowerCase()}`, + takes: { foreign: foreignView }, + }, + { address: `https://${foreignHost}/t/${mixedCase(foreignId)}`, takes: { foreign: foreignView } }, + { address: personalView, takes: { foreign: personalView } }, + { + address: `https://browser.${longLabel}.a-1.lazurio.io/t/${foreignId}`, + takes: { foreign: `https://browser.${longLabel}.a-1.lazurio.io/t/${foreignId}` }, + }, + ...offContract(ownHost, personTab).map((address) => ({ address, takes: { refused: ownHost } })), + ...offContract(foreignHost, foreignId).map((address) => ({ + address, + takes: { refused: foreignHost }, + })), + ...offContract(personalHost, foreignId).map((address) => ({ + address, + takes: { refused: personalHost }, + })), + // Another Environment's bare origin is a new tab of its browser, which this host does not open. + { address: `https://${foreignHost}`, takes: { refused: foreignHost } }, + { address: `https://${foreignHost}/`, takes: { refused: foreignHost } }, + // A base that is no Environment id: three labels, none, or a label that is no DNS label. + ...[ + "browser.a.vm-02.acme.lazurio.io", + "browser.lazurio.io", + "browser.-vm-02.acme.lazurio.io", + "browser.vm-02-.acme.lazurio.io", + `browser.${"a".repeat(64)}.acme.lazurio.io`, + "browser.vm_02.acme.lazurio.io", + ].map((host) => ({ address: `https://${host}/t/${foreignId}`, takes: { refused: host } })), + // A label is written as the host is: an internationalized name only in its ASCII form. + { + address: `https://browser.vm-02.xn--cme-dla.lazurio.io/t/${foreignId}`, + takes: { foreign: `https://browser.vm-02.xn--cme-dla.lazurio.io/t/${foreignId}` }, + }, + { + address: `https://browser.vm-02.ácme.lazurio.io/t/${foreignId}`, + takes: { refused: "browser.vm-02.xn--cme-dla.lazurio.io" }, + }, + { address: `https:${ownHost}/t/${personTab}`, takes: "invalid" }, + ...[`wss://${ownHost}/t/${personTab}`, `view-source:https://${ownHost}/t/${personTab}`].map( + (address) => ({ address, takes: "invalid" as const }), + ), + // Any other host loads, a Lazurio app or a look-alike of a view host. + ...[ + "https://xbrowser.vm-01.acme.lazurio.io/", + `https://t3code.vm-01.acme.lazurio.io/t/${personTab}`, + `https://browser.vm-01.acme.lazurio.io.example.test/t/${personTab}`, + "https://browser.example.test/", + `https://${ownHost}@example.test/`, + ].map((address) => ({ address, takes: { loads: address } })), + { address: "example.test/t/x", takes: { loads: "https://example.test/t/x" } }, +]; + +it.effect.each(contractCases)("takes $address as F50 point 2 does", ({ address, takes }) => + Effect.scoped( + Effect.gen(function* () { + const host = yield* startHost({ + link: declaredOnLazurio, + window: () => { + host.browser.addPage(threadWindow, null); + return windowAnswer(threadWindow, true); + }, + }); + host.browser.addPage(personTab, "https://person.example.test/"); + yield* host.registered; + // Navigating is asked first, of a thread without a tab, which still gets the advice. + const navigated = yield* invoke(host.broker, "navigate", { url: address }).pipe( + Effect.result, + ); + const navigatedTo = yield* invoke(host.broker, "navigate", { + target: { kind: "url", url: address }, + }).pipe(Effect.result); + const opened = yield* invoke<{ readonly tabId: string; readonly view?: string }>( + host.broker, + "open", + { url: address }, + ).pipe(Effect.result); + const paged = yield* host.pages.open(otherThreadId, address).pipe(Effect.result); + const windowRuns = host.runs.filter((run) => run.args[1] === "window"); + + const refusedOn = (viewHost: string) => + viewHost === ownHost + ? `is this Environment's own browser view (${viewHost}), which this browser does not load inside itself` + : `in another Environment's browser (${viewHost}), which this Environment's browser does not load`; + const refusal = (on: string) => + Result.fail(expect.objectContaining({ message: expect.stringContaining(refusedOn(on)) })); + // By `url` and by `target.url` alike. + const expectNavigated = (expected: unknown) => + expect([navigated, navigatedTo]).toEqual([expected, expected]); + const nothingTouched = () => { + expect(host.browser.connections).toHaveLength(0); + expect(windowRuns).toEqual([]); + expect(paged).toEqual(Result.fail(expect.objectContaining({ reason: "invalid-request" }))); + }; + if (takes === "new tab") { + const tabId = Result.getOrThrow(opened).tabId; + expect(tabId).not.toBe(personTab); + expect(Result.getOrThrow(opened).view).toBe(`https://${ownHost}/t/${tabId}`); + expect(Result.getOrThrow(paged)).not.toBe(tabId); + expect(host.browser.opened).toEqual([undefined, undefined]); + expectNavigated(refusal(ownHost)); + expect(windowRuns).toEqual([]); + } else if (takes === "own tab") { + expect(Result.getOrThrow(opened)).toMatchObject({ + tabId: personTab, + view: `https://${ownHost}/t/${personTab}`, + }); + expect(paged).toEqual(Result.succeed(personTab)); + expect(host.browser.opened).toEqual([]); + expectNavigated(refusal(ownHost)); + expect(windowRuns).toEqual([]); + } else if (takes === "invalid") { + expect(opened).toEqual(Result.fail(expect.any(PreviewAutomationExecutionError))); + expectNavigated(Result.fail(expect.any(PreviewAutomationExecutionError))); + nothingTouched(); + } else if ("foreign" in takes) { + expect(Result.getOrThrow(opened)).toMatchObject({ + tabId: takes.foreign.slice(-32), + view: takes.foreign, + }); + expect(host.foreignShown).toEqual([ + { threadId, tabId: takes.foreign.slice(-32), view: takes.foreign, reveal: true }, + ]); + expectNavigated(refusal(new URL(takes.foreign).hostname)); + nothingTouched(); + } else if ("refused" in takes) { + expect(opened).toEqual(refusal(takes.refused)); + expectNavigated(refusal(takes.refused)); + expect(host.foreignShown).toEqual([]); + nothingTouched(); + } else { + expectNavigated(Result.fail(expect.any(PreviewAutomationTabNotFoundError))); + expect(Result.getOrThrow(opened).tabId).toBe(threadWindow); + expect(windowRuns.map((run) => run.args.slice(4, 6))).toEqual([["--url", takes.loads]]); + yield* invoke(host.broker, "navigate", { url: address }); + expect(host.browser.pages.get(threadWindow)?.log).toEqual([ + "settle", + `navigate ${takes.loads}`, + ]); + expect(paged).toEqual(Result.succeed(expect.any(String))); + expect(host.browser.opened).toEqual([takes.loads]); + } + // The person's tab is never loaded or driven. + expect(host.browser.pages.get(personTab)?.log).toEqual([]); + }), + ), +); + // What the MCP server sends as preview_open's structured content: the tool's success schema. const encodeOpenResult = Schema.encodeUnknownEffect( PreviewStandardToolkit.tools.preview_open.successSchema, diff --git a/apps/server/src/lazurio/environmentBrowser.ts b/apps/server/src/lazurio/environmentBrowser.ts index 3b2721cb1df4a..309abcb2987a2 100644 --- a/apps/server/src/lazurio/environmentBrowser.ts +++ b/apps/server/src/lazurio/environmentBrowser.ts @@ -292,12 +292,20 @@ export const make = Effect.gen(function* () { foreignTabs.delete(oldest); } }; - /** Why this browser does not load `url`, on a view host, or undefined for any other address. */ - const viewRefusal = (url: string) => { - const host = viewHostOf(url); - if (host === undefined) return undefined; - const own = ownViewOrigin === undefined ? undefined : bareHost(new URL(ownViewOrigin).hostname); - return host === own ? ownNavigation(url, host) : foreignNavigation(url, host); + /** + * Why this browser does not load an address, on a view host, or undefined for any other address. + * The address is read as the agent wrote it and as `normalizePreviewUrl` made it, so that neither + * form reaches a view host (F50 point 2 reads the address as written). + */ + const viewRefusal = (written: string | undefined, url: string) => { + for (const address of written === undefined ? [url] : [written, url]) { + const host = viewHostOf(address); + if (host === undefined) continue; + const own = + ownViewOrigin === undefined ? undefined : bareHost(new URL(ownViewOrigin).hostname); + return host === own ? ownNavigation(address, host) : foreignNavigation(address, host); + } + return undefined; }; /** * Whether `address` is the bare origin of this Environment's own view, with or without its `/`, @@ -580,7 +588,7 @@ export const make = Effect.gen(function* () { return viewed; } const fresh = ownNewTab(address); - if (!fresh && viewRefusal(url) !== undefined) return yield* refusal("invalid-request"); + if (!fresh && viewRefusal(address, url) !== undefined) return yield* refusal("invalid-request"); const targetId = yield* newWindow(fresh ? undefined : url).pipe( Effect.mapError(() => refusal("unavailable")), ); @@ -611,7 +619,7 @@ export const make = Effect.gen(function* () { const fresh = input.url !== undefined && ownNewTab(input.url); const viewed = input.url === undefined ? undefined : ownTab(input.url); const refused = - url === undefined || viewed !== undefined || fresh ? undefined : viewRefusal(url); + url === undefined || viewed !== undefined || fresh ? undefined : viewRefusal(input.url, url); if (refused !== undefined) return yield* Effect.fail(refused); const load = viewed === undefined && !fresh ? url : undefined; // A named tab is handed over. Otherwise the current tab is reused while it is open, and a @@ -709,7 +717,8 @@ export const make = Effect.gen(function* () { }); // A view host in this browser would sign it in to that gateway; said first, so that a // thread without a tab gets the same advice. - const refused = viewRefusal(url); + const written = input.url ?? (input.target?.kind === "url" ? input.target.url : undefined); + const refused = viewRefusal(written, url); if (refused !== undefined) return yield* Effect.fail(refused); const targetId = requestTab(request); if (targetId === undefined) return yield* Effect.fail(noTab()); diff --git a/apps/web/src/lazurio/LazurioEnvironmentBrowser.browser.test.tsx b/apps/web/src/lazurio/LazurioEnvironmentBrowser.browser.test.tsx index 26373a89fe959..075d91bf736b6 100644 --- a/apps/web/src/lazurio/LazurioEnvironmentBrowser.browser.test.tsx +++ b/apps/web/src/lazurio/LazurioEnvironmentBrowser.browser.test.tsx @@ -672,6 +672,8 @@ describe("the chat's hook", () => { The agent's tab The agent's tab on another Environment Elsewhere + Not exactly the tab + Not exactly the other tab ); } @@ -857,7 +859,9 @@ describe("the chat's hook", () => { using page = setup(); await using screen = await renderUi(chatOn(threadA)); await page.stream().emit("snapshot", snapshot([])); - const [agentTab, foreignLink, outside] = [...screen.container.querySelectorAll("a")]; + const [agentTab, foreignLink, outside, inexact, inexactForeign] = [ + ...screen.container.querySelectorAll("a"), + ]; // What the browser would follow: every click the panel leaves alone, never followed here. let followed: string | null = null; const follow = (event: MouseEvent) => { @@ -882,6 +886,10 @@ describe("the chat's hook", () => { expect(await click(agentTab, gesture)).toBe(viewOf(7)); } expect(await click(outside)).toBe("https://example.com/"); + // A tab only as written (LazurioPlatform F50 point 2), not as the browser reads the link. + expect(await click(inexact)).toBe(viewOf(7)); + expect(await click(inexactForeign)).toBe(foreignView); + expect(panel().surfaces).toEqual([]); expect(await click(agentTab)).toBeNull(); expect(panel()).toEqual({ isOpen: true, activeSurfaceId: tab(7).id, surfaces: [tab(7)] }); await settled(); diff --git a/apps/web/src/lazurio/LazurioEnvironmentBrowser.tsx b/apps/web/src/lazurio/LazurioEnvironmentBrowser.tsx index d295f613a8461..40882f755475f 100644 --- a/apps/web/src/lazurio/LazurioEnvironmentBrowser.tsx +++ b/apps/web/src/lazurio/LazurioEnvironmentBrowser.tsx @@ -205,7 +205,9 @@ export function useLazurioThreadTabs( if (!isPlainPrimaryClick(event) || !(event.target instanceof Element)) return; const link = event.target.closest("a[href]"); if (!(link instanceof HTMLAnchorElement) || link.closest(CHAT_ROW) === null) return; - if (!openLinkInPanel(threadRef, link.href)) return; + // As written: `href` is the address the browser made of it (F50 point 2). + const written = link.getAttribute("href"); + if (written === null || !openLinkInPanel(threadRef, written)) return; // The panel took this click: no new browser tab, and no link handling of the chat's own. event.preventDefault(); event.stopPropagation(); diff --git a/apps/web/src/lazurio/browserView.ts b/apps/web/src/lazurio/browserView.ts index 396c3aa15deaa..9e5cb8eeed69f 100644 --- a/apps/web/src/lazurio/browserView.ts +++ b/apps/web/src/lazurio/browserView.ts @@ -6,7 +6,7 @@ /** One remote tab of a Lazurio Environment browser, read from the address of its view. */ export interface LazurioBrowserView { - /** The view, `https://browser..lazurio.io/t/`. */ + /** The view, `https://browser..lazurio.io/t/`. */ readonly view: string; readonly origin: string; /** The remote tab's DevTools target id: 32 hexadecimal digits. */ @@ -21,7 +21,8 @@ export interface LazurioBrowserView { /** * The remote tab whose view is at `address`, or null unless it is exactly - * `https://browser..lazurio.io/t/<32 hex digits>`: no port, credentials, query + * `https://browser..lazurio.io/t/<32 hex digits>`, `` an Environment id of one or two + * DNS labels (LazurioPlatform F50, `environmentIdOf` and `dnsLabel`): no port, credentials, query * or fragment, not even an empty one. */ export function lazurioBrowserView(address: string): LazurioBrowserView | null { @@ -34,17 +35,20 @@ export function lazurioBrowserView(address: string): LazurioBrowserView | null { if (url.protocol !== "https:" || url.port !== "" || url.href !== `${url.origin}${url.pathname}`) { return null; } - const labels = /^browser\.((?:[a-z0-9-]+\.)+)lazurio\.io$/.exec(url.hostname)?.[1]?.split("."); + // A label starts and ends with a letter or digit and has at most 63 characters. + const base = + /^browser\.([a-z0-9](?:[a-z0-9-]{0,61}[a-z0-9])?)(?:\.([a-z0-9](?:[a-z0-9-]{0,61}[a-z0-9])?))?\.lazurio\.io$/.exec( + url.hostname, + ); const targetId = /^\/t\/([0-9A-Fa-f]{32})$/.exec(url.pathname)?.[1]; - if (labels === undefined || targetId === undefined) return null; - // The captured labels end in a dot, so the last item is empty. - labels.pop(); + if (base === null || targetId === undefined) return null; + const [, environment, owner] = base; return { view: url.href, origin: url.origin, targetId, - owner: labels.at(-1)!, - environment: labels[0]!, - personal: labels.length === 1, + owner: owner ?? environment!, + environment: environment!, + personal: owner === undefined, }; } diff --git a/apps/web/src/lazurio/environmentBrowser.test.ts b/apps/web/src/lazurio/environmentBrowser.test.ts index 631e7da0de43e..0b41bfa5ca64c 100644 --- a/apps/web/src/lazurio/environmentBrowser.test.ts +++ b/apps/web/src/lazurio/environmentBrowser.test.ts @@ -48,6 +48,14 @@ describe("a tab of the panel", () => { tabView.replace("https://", "https://person:secret@"), `/t/${targetId}`, `javascript:alert(1)//t/${targetId}`, + // F50 point 2 takes the address as written, not as the browser reads it. + tabView.replace("https://browser", "https://BROWSER"), + tabView.replace("https:", "HTTPS:"), + tabView.replace(".io/", ".io:443/"), + tabView.replace("/t/", "/x/../t/"), + tabView.replace("https://", "https:/"), + tabView.replace("https://", "https:\\\\"), + ` ${tabView}`, "not an address", ]) { expect([address, environmentBrowserTab(address)]).toEqual([address, null]); @@ -137,10 +145,6 @@ describe("another Lazurio Environment's tab", () => { }); // Another Machine of the same Organization is another Environment as well. expect(tabOf(`https://browser.vm-03.example.lazurio.io/t/${foreignId}`)).not.toBeNull(); - // Upper-case hosts are the same hosts. - expect(tabOf(organization.replace("browser.vm-02.acme", "BROWSER.VM-02.Acme"))?.view).toBe( - organization, - ); }); it("is never this Environment's own view, named or not", () => { @@ -160,6 +164,8 @@ describe("another Lazurio Environment's tab", () => { `https://browser.vm-02.acme.lazurio.io.example.com/t/${foreignId}`, organization.replace("https:", "http:"), organization.replace(".io/", ".io:8443/"), + organization.replace(".io/", ".io:443/"), + organization.replace("browser.vm-02.acme", "BROWSER.VM-02.Acme"), `${organization}?session=t3-other`, `${organization}?`, `${organization}#x`, @@ -185,7 +191,9 @@ describe("another Lazurio Environment's tab", () => { expect(idOf(organization)).toBe("vm-02.acme"); expect(idOf(personal)).toBe("example-person"); // The shell decides what an id names; the fork reads no Organization or Machine out of it. - expect(idOf(`https://browser.a.b.c.lazurio.io/t/${foreignId}`)).toBe("a.b.c"); + expect(idOf(`https://browser.a-1.b.lazurio.io/t/${foreignId}`)).toBe("a-1.b"); + // F50 knows no Environment of a base of three labels (`environmentIdOf`): no tab of one. + expect(tabOf(`https://browser.a.b.c.lazurio.io/t/${foreignId}`)).toBeNull(); }); }); diff --git a/apps/web/src/lazurio/environmentBrowser.ts b/apps/web/src/lazurio/environmentBrowser.ts index 3f4abfeb3c5a4..a5bdde7fd3c27 100644 --- a/apps/web/src/lazurio/environmentBrowser.ts +++ b/apps/web/src/lazurio/environmentBrowser.ts @@ -19,9 +19,10 @@ export interface EnvironmentBrowserTab { const TAB_VIEW_PATH = /^\/t\/([0-9A-Fa-f]{32})$/; /** - * Null unless exactly `https:///t/<32 hex digits>`, with no credentials, query or fragment, - * so a kept address names one remote tab and carries nothing else. With `pageOrigin`, the page's - * own origin is refused too: the view is never framed on the page's origin. + * Null unless exactly `https:///t/<32 hex digits>` as written (LazurioPlatform F50 point 2), + * with no credentials, query or fragment, so a kept address names one remote tab and carries + * nothing else. With `pageOrigin`, the page's own origin is refused too: the view is never framed + * on the page's origin. */ export function environmentBrowserTab( view: unknown, @@ -34,7 +35,13 @@ export function environmentBrowserTab( } catch { return null; } - if (url.protocol !== "https:" || url.href !== `${url.origin}${url.pathname}`) return null; + if ( + view !== url.href || + url.protocol !== "https:" || + url.href !== `${url.origin}${url.pathname}` + ) { + return null; + } if (url.origin === pageOrigin) return null; const target = TAB_VIEW_PATH.exec(url.pathname)?.[1]; return target === undefined ? null : { id: `environment-browser:${target}`, view: url.href }; diff --git a/scripts/lazurio-release-contract.test.mjs b/scripts/lazurio-release-contract.test.mjs index 309f76553b467..bf27365f446cf 100644 --- a/scripts/lazurio-release-contract.test.mjs +++ b/scripts/lazurio-release-contract.test.mjs @@ -2166,6 +2166,9 @@ NodeTest.test("the server and the web read a Lazurio browser view's address alik `https://browser.example-person.lazurio.io/t/${targetId}`, `https://browser.example-person.lazurio.io/t/${targetId}?`, `https://browser.example-person.lazurio.io:8443/t/${targetId}`, + // F50 takes a base of one or two DNS labels only. + `https://browser.a.vm-01.acme.lazurio.io/t/${targetId}`, + `https://browser.-vm-01.acme.lazurio.io/t/${targetId}`, `https://t3code.example-person.lazurio.io/t/${targetId}`, "not an address", ]) {