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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
24 changes: 24 additions & 0 deletions apps/server/src/auth/RpcAuthorization.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ import {
AuthEnvironmentMaintainScope,
AuthOrchestrationOperateScope,
AuthOrchestrationReadScope,
AuthPreviewOperateScope,
AuthRelayReadScope,
AuthRelayWriteScope,
WS_METHODS,
Expand Down Expand Up @@ -55,6 +56,29 @@ describe("RPC authorization scopes", () => {
);
});

it("separates preview control from observation", () => {
for (const method of [
WS_METHODS.previewOpen,
WS_METHODS.previewNavigate,
WS_METHODS.previewResize,
WS_METHODS.previewRefresh,
WS_METHODS.previewClose,
WS_METHODS.previewReportStatus,
WS_METHODS.previewAutomationConnect,
WS_METHODS.previewAutomationRespond,
WS_METHODS.previewAutomationFocusHost,
]) {
expect(requiredScopeForRpcMethod(method)).toBe(AuthPreviewOperateScope);
}
for (const method of [
WS_METHODS.previewList,
WS_METHODS.subscribePreviewEvents,
WS_METHODS.subscribeDiscoveredLocalServers,
]) {
expect(requiredScopeForRpcMethod(method)).toBe(AuthOrchestrationReadScope);
}
});

it("rejects unknown RPC method names", () => {
for (const method of ["server.notRegistered", "toString", "constructor"]) {
expect(() => requiredScopeForRpcMethod(method)).toThrow(
Expand Down
19 changes: 10 additions & 9 deletions apps/server/src/auth/RpcAuthorization.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@ import {
AuthFilesystemWriteScope,
AuthOrchestrationOperateScope,
AuthOrchestrationReadScope,
AuthPreviewOperateScope,
AuthRelayReadScope,
AuthRelayWriteScope,
AuthSourceControlWriteScope,
Expand Down Expand Up @@ -132,16 +133,16 @@ export const RPC_REQUIRED_SCOPES = {
[WS_METHODS.terminalClose]: AuthTerminalOperateScope,
[WS_METHODS.subscribeTerminalEvents]: AuthTerminalOperateScope,
[WS_METHODS.subscribeTerminalMetadata]: AuthTerminalOperateScope,
[WS_METHODS.previewOpen]: AuthOrchestrationOperateScope,
[WS_METHODS.previewNavigate]: AuthOrchestrationOperateScope,
[WS_METHODS.previewResize]: AuthOrchestrationOperateScope,
[WS_METHODS.previewRefresh]: AuthOrchestrationOperateScope,
[WS_METHODS.previewClose]: AuthOrchestrationOperateScope,
[WS_METHODS.previewOpen]: AuthPreviewOperateScope,
[WS_METHODS.previewNavigate]: AuthPreviewOperateScope,
[WS_METHODS.previewResize]: AuthPreviewOperateScope,
[WS_METHODS.previewRefresh]: AuthPreviewOperateScope,
[WS_METHODS.previewClose]: AuthPreviewOperateScope,
[WS_METHODS.previewList]: AuthOrchestrationReadScope,
[WS_METHODS.previewReportStatus]: AuthOrchestrationOperateScope,
[WS_METHODS.previewAutomationConnect]: AuthOrchestrationOperateScope,
[WS_METHODS.previewAutomationRespond]: AuthOrchestrationOperateScope,
[WS_METHODS.previewAutomationFocusHost]: AuthOrchestrationOperateScope,
[WS_METHODS.previewReportStatus]: AuthPreviewOperateScope,
[WS_METHODS.previewAutomationConnect]: AuthPreviewOperateScope,
[WS_METHODS.previewAutomationRespond]: AuthPreviewOperateScope,
[WS_METHODS.previewAutomationFocusHost]: AuthPreviewOperateScope,
[WS_METHODS.subscribePreviewEvents]: AuthOrchestrationReadScope,
[WS_METHODS.subscribeDiscoveredLocalServers]: AuthOrchestrationReadScope,
[WS_METHODS.subscribeServerConfig]: AuthOrchestrationReadScope,
Expand Down
68 changes: 68 additions & 0 deletions apps/server/src/server.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@ import {
AuthAdministrativeScopes,
AuthOrchestrationOperateScope,
AuthSourceControlWriteScope,
AuthPreviewOperateScope,
AuthStandardClientScopes,
AuthEnvironmentBootstrapTokenType,
AuthTokenExchangeGrantType,
Expand Down Expand Up @@ -518,6 +519,7 @@ const buildAppUnderTest = (options?: {
ProjectSetupScriptRunner.ProjectSetupScriptRunner["Service"]
>;
terminalManager?: Partial<TerminalManager.TerminalManager["Service"]>;
previewManager?: Partial<PreviewManager.PreviewManager["Service"]>;
orchestrationEngine?: Partial<OrchestrationEngine.OrchestrationEngineService["Service"]>;
threadDeletionReactor?: Partial<ThreadDeletionReactor["Service"]>;
analyticsService?: Partial<AnalyticsService.AnalyticsService["Service"]>;
Expand Down Expand Up @@ -912,6 +914,7 @@ const buildAppUnderTest = (options?: {
subscribeEvents: Effect.flatMap(PubSub.unbounded<PreviewEvent>(), (pubsub) =>
PubSub.subscribe(pubsub),
),
...options?.layers?.previewManager,
}),
Layer.mock(PortScanner.PortDiscovery)({
scan: () => Effect.succeed([]),
Expand Down Expand Up @@ -5666,6 +5669,71 @@ it.layer(NodeServices.layer)("server router seam", (it) => {
}).pipe(Effect.provide(NodeHttpServer.layerTest)),
);

it.effect("requires an explicit preview grant for control and automation streams", () =>
Effect.gen(function* () {
let refreshes = 0;
yield* buildAppUnderTest({
layers: {
previewManager: {
refresh: () =>
Effect.sync(() => {
refreshes += 1;
}),
},
},
});
const threadId = ThreadId.make("preview-scope-thread");
const host = {
clientId: "preview-scope-host",
environmentId: testEnvironmentDescriptor.environmentId,
} as const;
const legacyScopes = "orchestration:read orchestration:operate";
for (const scope of [legacyScopes, `${legacyScopes} ${AuthPreviewOperateScope}`]) {
const token = yield* exchangeAccessToken(defaultDesktopBootstrapToken, { scope });
assert.equal(token.response.status, 200);
const ticketResponse = yield* HttpClient.post("/api/auth/websocket-ticket", {
headers: { authorization: `Bearer ${token.body.access_token ?? ""}` },
});
const { ticket } = yield* responseJsonEffect<{ readonly ticket: string }>(ticketResponse);
const wsUrl = `${yield* getWsServerUrl("/ws", { authenticated: false })}?wsTicket=${encodeURIComponent(ticket)}`;
yield* Effect.scoped(
withWsRpcClient(wsUrl, (client) =>
Effect.gen(function* () {
const previews = yield* client[WS_METHODS.previewList]({ threadId });
assert.deepEqual(previews.sessions, []);
if (scope === legacyScopes) {
const errors = [
yield* client[WS_METHODS.previewRefresh]({ threadId, tabId: "tab" }).pipe(
Effect.flip,
),
yield* client[WS_METHODS.previewAutomationConnect](host).pipe(
Stream.runHead,
Effect.flip,
),
];
for (const error of errors) {
assert.equal(error._tag, "EnvironmentAuthorizationError");
if (error._tag === "EnvironmentAuthorizationError") {
assert.equal(error.requiredScope, AuthPreviewOperateScope);
}
}
assert.equal(refreshes, 0);
} else {
yield* client[WS_METHODS.previewRefresh]({ threadId, tabId: "tab" });
const connected = yield* client[WS_METHODS.previewAutomationConnect](host).pipe(
Stream.runHead,
Effect.map(Option.getOrThrow),
);
assert.equal(connected.type, "connected");
assert.equal(refreshes, 1);
}
}),
),
);
}
}).pipe(Effect.provide(NodeHttpServer.layerTest)),
);

it.effect("shares one preview automation broker across websocket sessions", () =>
Effect.scoped(
Effect.gen(function* () {
Expand Down
15 changes: 12 additions & 3 deletions apps/web/src/browser/ElectronBrowserHost.tsx
Original file line number Diff line number Diff line change
@@ -1,12 +1,13 @@
"use client";

import { parseScopedThreadKey } from "@t3tools/client-runtime/environment";
import { FILL_PREVIEW_VIEWPORT } from "@t3tools/contracts";
import { useEffect, useMemo } from "react";
import { AuthPreviewOperateScope, FILL_PREVIEW_VIEWPORT } from "@t3tools/contracts";
import { type ComponentProps, useEffect, useMemo } from "react";

import { isElectron } from "~/env";
import { useTheme } from "~/hooks/useTheme";
import { useActivePreviewSessions } from "~/previewStateStore";
import { useEnvironmentScope } from "~/state/session";

import { readPreviewAnnotationTheme } from "./annotationTheme";
import { useBrowserPointerStore } from "./browserPointerStore";
Expand Down Expand Up @@ -85,7 +86,7 @@ export function ElectronBrowserHost() {
{sessions.map(({ threadRef, snapshot, runtimeTabId, pictureInPicture, zoomFactor }) => {
const url = snapshot.navStatus._tag === "Idle" ? null : snapshot.navStatus.url;
return (
<HostedBrowserWebview
<AuthorizedBrowserWebview
key={runtimeTabId}
threadRef={threadRef}
tabId={snapshot.tabId}
Expand All @@ -101,3 +102,11 @@ export function ElectronBrowserHost() {
</div>
);
}

function AuthorizedBrowserWebview(props: ComponentProps<typeof HostedBrowserWebview>) {
const canOperatePreview = useEnvironmentScope(
props.threadRef.environmentId,
AuthPreviewOperateScope,
);
return canOperatePreview ? <HostedBrowserWebview {...props} /> : null;
}
8 changes: 6 additions & 2 deletions apps/web/src/browser/useOpenLink.ts
Original file line number Diff line number Diff line change
@@ -1,10 +1,11 @@
import type { ScopedThreadRef } from "@t3tools/contracts";
import { AuthPreviewOperateScope, type ScopedThreadRef } from "@t3tools/contracts";
import { isAtomCommandInterrupted } from "@t3tools/client-runtime/state/runtime";
import { useCallback } from "react";

import { recordVisitForThread } from "~/browserHistoryStore";
import { readLocalApi } from "~/localApi";
import { previewEnvironment } from "~/state/preview";
import { readEnvironmentScope } from "~/state/session";
import { useAtomCommand } from "~/state/use-atom-command";

import {
Expand Down Expand Up @@ -43,7 +44,10 @@ export function useOpenLink(threadRef: ScopedThreadRef | null | undefined): (
url,
event: options.event ?? NO_MODIFIER,
preference: await resolveBrowserLinkTargetPreference(),
canOpenInApp: canOpenLinksInApp(Boolean(targetThreadRef)),
canOpenInApp:
targetThreadRef != null &&
readEnvironmentScope(targetThreadRef.environmentId, AuthPreviewOperateScope) &&
canOpenLinksInApp(true),
});
if (target === "app" && targetThreadRef) {
const result = await openUrlInPreview({ threadRef: targetThreadRef, url, openPreview });
Expand Down
19 changes: 14 additions & 5 deletions apps/web/src/components/ChatMarkdown.test.tsx
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import { EnvironmentId } from "@t3tools/contracts";
import { EnvironmentId, type AuthEnvironmentScope } from "@t3tools/contracts";
import { act, type ComponentProps, type ReactNode } from "react";
import { renderToStaticMarkup } from "react-dom/server";
import { create, type ReactTestRenderer } from "react-test-renderer";
Expand Down Expand Up @@ -35,10 +35,19 @@ vi.mock("./ui/tooltip", async () => {
});
vi.mock("../state/use-atom-query-runner", () => ({ useAtomQueryRunner: () => vi.fn() }));
vi.mock("../state/use-atom-command", () => ({ useAtomCommand: () => vi.fn() }));
vi.mock("../state/session", async (importOriginal) => ({
...(await importOriginal<typeof import("../state/session")>()),
usePreparedConnection: () => ({ _tag: "Loading" }),
}));
vi.mock("../state/session", async (importOriginal) => {
const actual = await importOriginal<typeof import("../state/session")>();
const { AuthStandardClientScopes } = await import("@t3tools/contracts");
const grantedScopes = new Set<AuthEnvironmentScope>(AuthStandardClientScopes);
const hasScope = (environmentId: EnvironmentId | null, scope: AuthEnvironmentScope) =>
environmentId !== null && grantedScopes.has(scope);
return {
...actual,
useEnvironmentScope: hasScope,
readEnvironmentScope: hasScope,
usePreparedConnection: () => ({ _tag: "Loading" }),
};
});
vi.mock("../state/entities", () => ({
readThreadShell: () => null,
useProjects: () => [],
Expand Down
34 changes: 21 additions & 13 deletions apps/web/src/components/ChatMarkdown.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -22,12 +22,13 @@ import {
WrapTextIcon,
type LucideIcon,
} from "lucide-react";
import type {
AssetResource,
EnvironmentId,
ScopedThreadRef,
ServerProviderSkill,
ThreadLinkedPullRequest,
import {
AuthPreviewOperateScope,
type AssetResource,
type EnvironmentId,
type ScopedThreadRef,
type ServerProviderSkill,
type ThreadLinkedPullRequest,
} from "@t3tools/contracts";
import { faviconUrlForOrigin } from "@t3tools/shared/favicon";
import {
Expand Down Expand Up @@ -148,7 +149,7 @@ import { readThreadShell, useProjects } from "../state/entities";
import { serverEnvironment } from "../state/server";
import { shellEnvironment } from "../state/shell";
import { assetEnvironment } from "../state/assets";
import { readEnvironmentScope, usePreparedConnection } from "../state/session";
import { readEnvironmentScope, usePreparedConnection, useEnvironmentScope } from "../state/session";
import { previewEnvironment } from "../state/preview";
import { useAtomCommand } from "../state/use-atom-command";
import { useAtomQueryRunner } from "../state/use-atom-query-runner";
Expand Down Expand Up @@ -1994,6 +1995,7 @@ function useChatMarkdownState({
reportFailure: false,
});
const environmentId = threadRef?.environmentId ?? explicitEnvironmentId ?? null;
const canOperatePreview = useEnvironmentScope(environmentId, AuthPreviewOperateScope);
const remoteOpen = useRemoteOpenResolution(environmentId);
const canUseShellActions = canUseMarkdownFileShellActions(
environmentId,
Expand Down Expand Up @@ -2178,12 +2180,12 @@ function useChatMarkdownState({
);
const openExternalLinkInPreview = useCallback(
(url: string) => {
if (!threadRef) {
if (!threadRef || !canOperatePreview) {
return Promise.resolve(
AsyncResult.failure<void, BrowserPreviewUnavailableError>(
Cause.fail(
new BrowserPreviewUnavailableError({
message: "Thread context is unavailable.",
message: "Preview access is unavailable for this client.",
}),
),
),
Expand All @@ -2194,11 +2196,11 @@ function useChatMarkdownState({
return result;
});
},
[openPreview, threadRef],
[canOperatePreview, openPreview, threadRef],
);
const openMarkdownFileInPreview = useCallback(
(path: string) => {
if (!threadRef || preparedConnection._tag === "None") {
if (!threadRef || !canOperatePreview || preparedConnection._tag === "None") {
return Promise.resolve(
AsyncResult.failure<void, BrowserPreviewUnavailableError>(
Cause.fail(
Expand All @@ -2218,7 +2220,7 @@ function useChatMarkdownState({
openPreview,
});
},
[createAssetUrl, cwd, openPreview, preparedConnection, threadRef],
[canOperatePreview, createAssetUrl, cwd, openPreview, preparedConnection, threadRef],
);
const findWorkspaceBasenameMatch = useCallback(
async (workspaceRelativePath: string) => {
Expand Down Expand Up @@ -2336,6 +2338,7 @@ function useChatMarkdownState({
revealLabel={revealInFileManagerLabel}
onOpenInBrowser={
threadRef &&
canOperatePreview &&
isPreviewSupportedInRuntime() &&
isBrowserPreviewFile(fileLinkMeta.filePath)
? () => openMarkdownFileInPreview(fileLinkMeta.filePath)
Expand All @@ -2347,6 +2350,7 @@ function useChatMarkdownState({
},
[
canUseShellActions,
canOperatePreview,
fileLinkParentSuffixByPath,
openFileInPanel,
openInPreferredEditor,
Expand All @@ -2362,6 +2366,7 @@ function useChatMarkdownState({

const componentState = useMemo(
() => ({
canOperatePreview,
cwd,
diffThemeName,
environmentId,
Expand All @@ -2388,6 +2393,7 @@ function useChatMarkdownState({
updateThreadPullRequestLink,
}),
[
canOperatePreview,
cwd,
diffThemeName,
environmentId,
Expand Down Expand Up @@ -2511,6 +2517,7 @@ const CHAT_MARKDOWN_COMPONENTS = {
},
a: function MarkdownAnchor({ node, href, children, title: _title, ...props }) {
const {
canOperatePreview,
cwd,
environmentId,
imageBaseDir,
Expand Down Expand Up @@ -2576,7 +2583,8 @@ const CHAT_MARKDOWN_COMPONENTS = {
};
const isSameDocumentLink = href?.startsWith("#") ?? false;
const onClick = props.onClick;
const canOpenInPreview = Boolean(threadRef) && isPreviewSupportedInRuntime();
const canOpenInPreview =
canOperatePreview && Boolean(threadRef) && isPreviewSupportedInRuntime();
const linkChildren = <MarkdownLinkContext value>{children}</MarkdownLinkContext>;
const link = (
<a
Expand Down
Loading
Loading