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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
24 changes: 14 additions & 10 deletions apps/server/src/lazurio/browserView.ts
Original file line number Diff line number Diff line change
@@ -1,12 +1,12 @@
/**
* Lazurio overlay: a Remote Environment's people's view is at `https://browser.<labels>.lazurio.io`,
* Lazurio overlay: a Remote Environment's people's view is at `https://browser.<base>.lazurio.io`,
* where `/t/<DevTools target id>` 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.<labels>.lazurio.io/t/<target id>`. */
/** The view, `https://browser.<base>.lazurio.io/t/<target id>`. */
readonly view: string;
readonly origin: string;
/** The remote tab's DevTools target id: 32 hexadecimal digits. */
Expand All @@ -21,7 +21,8 @@ export interface LazurioBrowserView {

/**
* The remote tab whose view is at `address`, or null unless it is exactly
* `https://browser.<one or more labels>.lazurio.io/t/<32 hex digits>`: no port, credentials, query
* `https://browser.<base>.lazurio.io/t/<32 hex digits>`, `<base>` 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 {
Expand All @@ -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,
};
}
320 changes: 247 additions & 73 deletions apps/server/src/lazurio/environmentBrowser.test.ts

Large diffs are not rendered by default.

27 changes: 18 additions & 9 deletions apps/server/src/lazurio/environmentBrowser.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 `/`,
Expand Down Expand Up @@ -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")),
);
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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());
Expand Down
10 changes: 9 additions & 1 deletion apps/web/src/lazurio/LazurioEnvironmentBrowser.browser.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -672,6 +672,8 @@ describe("the chat's hook", () => {
<a href={viewOf(7)}>The agent's tab</a>
<a href={foreignView}>The agent's tab on another Environment</a>
<a href="https://example.com/">Elsewhere</a>
<a href={viewOf(7).replace("https://browser", "https://BROWSER")}>Not exactly the tab</a>
<a href={foreignView.replace(".io/", ".io:443/")}>Not exactly the other tab</a>
</div>
);
}
Expand Down Expand Up @@ -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) => {
Expand All @@ -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();
Expand Down
4 changes: 3 additions & 1 deletion apps/web/src/lazurio/LazurioEnvironmentBrowser.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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();
Expand Down
22 changes: 13 additions & 9 deletions apps/web/src/lazurio/browserView.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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.<labels>.lazurio.io/t/<target id>`. */
/** The view, `https://browser.<base>.lazurio.io/t/<target id>`. */
readonly view: string;
readonly origin: string;
/** The remote tab's DevTools target id: 32 hexadecimal digits. */
Expand All @@ -21,7 +21,8 @@ export interface LazurioBrowserView {

/**
* The remote tab whose view is at `address`, or null unless it is exactly
* `https://browser.<one or more labels>.lazurio.io/t/<32 hex digits>`: no port, credentials, query
* `https://browser.<base>.lazurio.io/t/<32 hex digits>`, `<base>` 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 {
Expand All @@ -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,
};
}
18 changes: 13 additions & 5 deletions apps/web/src/lazurio/environmentBrowser.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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]);
Expand Down Expand Up @@ -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", () => {
Expand All @@ -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`,
Expand All @@ -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();
});
});

Expand Down
15 changes: 11 additions & 4 deletions apps/web/src/lazurio/environmentBrowser.ts
Original file line number Diff line number Diff line change
Expand Up @@ -19,9 +19,10 @@ export interface EnvironmentBrowserTab {
const TAB_VIEW_PATH = /^\/t\/([0-9A-Fa-f]{32})$/;

/**
* Null unless exactly `https://<host>/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://<host>/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,
Expand All @@ -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 };
Expand Down
3 changes: 3 additions & 0 deletions scripts/lazurio-release-contract.test.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -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",
]) {
Expand Down
Loading