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
16 changes: 14 additions & 2 deletions apps/server/src/auth/EnvironmentAuth.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,8 @@ import {
AuthAdministrativeScopes,
AuthOrchestrationOperateScope,
AuthOrchestrationReadScope,
AuthSettingsWriteScope,
AuthSourceControlWriteScope,
AuthStandardClientScopes,
type AuthAccessTokenResult,
type AuthBrowserSessionResult,
Expand Down Expand Up @@ -74,7 +76,10 @@ export interface IssuedBearerSession {
* token cannot reach the full RPC surface around that cap.
*
* A read-only grant holds `orchestration:read` alone. Any other grant also
* holds `orchestration:operate` and carries its runtime-mode ceiling.
* holds `orchestration:operate` and carries its runtime-mode ceiling. A
* full-access client may also change environment preferences and clone
* repositories, so it holds the scopes those need over RPC. Approving a grant
* requires every scope it holds.
*/
const MCP_CLIENT_SUBJECT = "mcp-client";
const MCP_CLIENT_SESSION_TTL = Duration.days(30);
Expand All @@ -84,7 +89,14 @@ export const mcpClientScopes = (
): ReadonlyArray<AuthEnvironmentScope> =>
access === "read-only"
? [AuthOrchestrationReadScope]
: [AuthOrchestrationReadScope, AuthOrchestrationOperateScope];
: access === "full-access"
? [
AuthOrchestrationReadScope,
AuthOrchestrationOperateScope,
AuthSettingsWriteScope,
AuthSourceControlWriteScope,
]
: [AuthOrchestrationReadScope, AuthOrchestrationOperateScope];

export interface McpClientSession {
readonly sessionId: AuthSessionId;
Expand Down
9 changes: 9 additions & 0 deletions apps/server/src/auth/McpOAuth.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -451,6 +451,15 @@ it.live(
const readOnlyResponse = yield* approveWith(readOnly.credential);
expect(readOnlyResponse.status).toBe(400);
expect(readOnlyResponse.message).toContain("cannot grant this access");
const operator = yield* auth.issuePairingCredential({
scopes: ["orchestration:read", "orchestration:operate"],
});
const operatorFull = yield* decide(handler, params, {
_tag: "pairing-code",
access: "full-access",
code: operator.credential,
});
expect(operatorFull.status).toBe(400);

// A read-only code can approve read-only access.
const readOnlyCode = yield* auth.issuePairingCredential({ scopes: ["orchestration:read"] });
Expand Down
7 changes: 7 additions & 0 deletions apps/server/src/auth/RpcAuthorization.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,8 @@ import {
ServerSettingsPatch,
ProviderInstanceMutation,
requiredScopesForServerSettingsPatch,
requiredScopesForProjectMutation,
ProjectMutation,
AuthSettingsWriteScope,
AuthProvidersManageScope,
AuthEnvironmentMaintainScope,
Expand Down Expand Up @@ -242,6 +244,8 @@ const requiredScopesForSettingsUpdate = (payload: unknown) => {
: [...new Set([...scopes, AuthProvidersManageScope])];
};

const decodeProjectMutation = Schema.decodeUnknownSync(ProjectMutation);

const requiredScopesForRpcCall = (
method: string,
payload: unknown,
Expand All @@ -260,6 +264,9 @@ const requiredScopesForRpcCall = (
];
}
if (method === WS_METHODS.serverUpdateSettings) return requiredScopesForSettingsUpdate(payload);
if (method === WS_METHODS.projectsMutate) {
return requiredScopesForProjectMutation(decodeProjectMutation(payload));
}
const guarded = clientRpcRequiredScopes(method, payload);
if (guarded.length > 0) return guarded;
return [requiredScopeForRpcMethod(method)];
Expand Down
29 changes: 29 additions & 0 deletions apps/server/src/preview/ServerBrowser.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1126,6 +1126,35 @@ it.live("a page's file picker goes to the controller and takes its uploaded file
).pipe(Effect.provide(layer)),
);

it.live("only the current controller can answer a file picker", () =>
Effect.scoped(
Effect.gen(function* () {
const { browser, tabId } = yield* ready;
const first = yield* browser.attachViewer(viewerInput(tabId, true));
yield* first.input({ type: "takeControl" });
const setFiles = vi.fn(async (_files: unknown) => {});
contexts[0]!.page.emit("filechooser", {
isMultiple: () => false,
element: () => ({ getAttribute: async () => "" }),
setFiles,
});
let offered = yield* Queue.take(first.output);
while (offered._tag !== "fileChooser") offered = yield* Queue.take(first.output);
const second = yield* browser.attachViewer(viewerInput(tabId, true));
yield* first.input({ type: "releaseControl" });
yield* second.input({ type: "takeControl" });
const answered = yield* browser.answerFileChooser({
threadId: scope.thread.threadId,
tabId,
chooserId: offered.id,
files: [{ name: "a.txt", mimeType: "text/plain", buffer: Buffer.from("a") }],
});
expect(answered).toBe(false);
expect(setFiles).not.toHaveBeenCalled();
}),
).pipe(Effect.provide(layer)),
);

it.live("a file picker replaces a stalled viewer backlog instead of being dropped", () =>
Effect.scoped(
Effect.gen(function* () {
Expand Down
34 changes: 29 additions & 5 deletions apps/server/src/preview/ServerBrowser.ts
Original file line number Diff line number Diff line change
Expand Up @@ -310,11 +310,15 @@ interface ServerTab {
readonly openerTabId: string | undefined;
/** Finished downloads, newest last; files live until the tab closes. */
readonly downloads: Array<ServerDownload>;
/** A page's open file picker, waiting for the controlling viewer's files. */
/**
* A page's open file picker, waiting for the controlling viewer's files. Its
* id is reissued for each viewer it is offered to, so only that viewer can answer.
*/
fileChooser: {
readonly id: string;
readonly chooser: FileChooser;
readonly accept: string;
readonly offeredTo: string | null;
} | null;
dialog: Dialog | null;
setting: PreviewViewportSetting;
Expand Down Expand Up @@ -1027,14 +1031,27 @@ const make = Effect.gen(function* () {
.catch(() => null)) ?? "";
// A newer picker replaces an unanswered one, as a real browser allows only one.
if (previous) closeFileChooser(tab);
tab.fileChooser = { id: NodeCrypto.randomUUID(), chooser, accept: accept.slice(0, 1024) };
tab.fileChooser = {
id: NodeCrypto.randomUUID(),
chooser,
accept: accept.slice(0, 1024),
offeredTo: null,
};
pushFileChooser(tab);
};

const pushFileChooser = (tab: ServerTab) => {
const message = fileChooserMessage(tab);
const open = tab.fileChooser;
const controller = [...tab.viewers].find((viewer) => viewer.id === tab.control.controller);
if (message) controller?.push(message);
if (!open || !controller) return;
if (open.offeredTo !== controller.id) {
// A new controller gets a fresh id; the previous one can no longer answer.
tab.fileChooser = { ...open, id: NodeCrypto.randomUUID(), offeredTo: controller.id };
if (open.offeredTo !== null)
for (const viewer of tab.viewers) viewer.push({ _tag: "fileChooserClosed", id: open.id });
}
const message = fileChooserMessage(tab);
if (message) controller.push(message);
};

const closeFileChooser = (tab: ServerTab) => {
Expand All @@ -1050,7 +1067,14 @@ const make = Effect.gen(function* () {
) => {
const tab = tabs.get(tabKey(input.threadId, input.tabId));
const open = tab?.fileChooser;
if (!tab || !open || open.id !== input.chooserId) return false;
if (
!tab ||
!open ||
open.id !== input.chooserId ||
open.offeredTo === null ||
open.offeredTo !== tab.control.controller
Comment thread
coderabbitai[bot] marked this conversation as resolved.
)
return false;
if (input.files.length > 0) {
await open.chooser.setFiles(
open.chooser.isMultiple() ? [...input.files] : input.files.slice(0, 1),
Expand Down
8 changes: 5 additions & 3 deletions apps/server/src/preview/ServerBrowserStream.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ import { expect, it } from "@effect/vitest";
import {
AuthOrchestrationOperateScope,
AuthOrchestrationReadScope,
AuthPreviewOperateScope,
AuthSessionId,
PREVIEW_STREAM_HOST_SETUP_CLOSE_CODE,
PreviewStreamHostSetup,
Expand Down Expand Up @@ -71,7 +72,7 @@ it.effect.each([
Effect.gen(function* () {
const canOperate = hasOperateScope && interactive;
const scopes = hasOperateScope
? [AuthOrchestrationReadScope, AuthOrchestrationOperateScope]
? [AuthOrchestrationReadScope, AuthPreviewOperateScope]
: [AuthOrchestrationReadScope];
const auth = makeAuth(scopes);
const inputs: unknown[] = [];
Expand Down Expand Up @@ -293,11 +294,12 @@ it.effect("passes uploaded files to the page's open picker and needs operate sco
});
expect((yield* upload([AuthOrchestrationReadScope], "chooser-1")).status).toBe(403);
expect(answers).toEqual([]);
expect((yield* upload([AuthOrchestrationOperateScope], "chooser-1")).status).toBe(204);
expect((yield* upload([AuthOrchestrationOperateScope], "chooser-1")).status).toBe(403);
expect((yield* upload([AuthPreviewOperateScope], "chooser-1")).status).toBe(204);
expect(answers).toEqual([
{ chooserId: "chooser-1", files: [{ name: "notes.txt", text: "hello" }] },
]);
expect((yield* upload([AuthOrchestrationOperateScope], "stale")).status).toBe(409);
expect((yield* upload([AuthPreviewOperateScope], "stale")).status).toBe(409);
}).pipe(Effect.scoped),
);

Expand Down
8 changes: 4 additions & 4 deletions apps/server/src/preview/ServerBrowserStream.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
import * as NodeHttpServerRequest from "@effect/platform-node/NodeHttpServerRequest";
import {
AuthOrchestrationOperateScope,
AuthOrchestrationReadScope,
AuthPreviewOperateScope,
PREVIEW_STREAM_HOST_SETUP_CLOSE_CODE,
PreviewStreamHostSetup,
} from "@t3tools/contracts";
Expand Down Expand Up @@ -71,9 +71,9 @@ const makeHandler = (browser: ServerBrowser.ServerBrowser["Service"]) =>
}
const session = yield* authenticateMediaRequest(AuthOrchestrationReadScope);
const params = url.value.searchParams;
// Watching needs read access; driving the page is a preview mutation.
const canOperate =
session.scopes.includes(AuthOrchestrationOperateScope) &&
params.get("interactive") !== "false";
session.scopes.includes(AuthPreviewOperateScope) && params.get("interactive") !== "false";
const threadId = params.get("threadId") ?? "";
const tabId = params.get("tabId") ?? "";
if (threadId.length === 0 || tabId.length === 0) {
Expand Down Expand Up @@ -238,7 +238,7 @@ const UPLOAD_MAX_FILES = 20;
*/
const receiveUpload = (browser: ServerBrowser.ServerBrowser["Service"], params: URLSearchParams) =>
Effect.gen(function* () {
yield* authenticateMediaRequest(AuthOrchestrationOperateScope);
yield* authenticateMediaRequest(AuthPreviewOperateScope);
const request = yield* HttpServerRequest.HttpServerRequest;
const fs = yield* FileSystem.FileSystem;
const parts = yield* request.multipart.pipe(
Expand Down
6 changes: 4 additions & 2 deletions apps/server/src/project/http.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
import {
AuthOrchestrationOperateScope,
AuthOrchestrationReadScope,
EnvironmentHttpApi,
requiredScopesForProjectMutation,
} from "@t3tools/contracts";
import * as Effect from "effect/Effect";
import * as HttpApiBuilder from "effect/http-api/HttpApiBuilder";
Expand Down Expand Up @@ -53,7 +53,9 @@ export const layer = HttpApiBuilder.group(
"mutate",
Effect.fn("environment.projects.mutate")(function* (args) {
yield* annotateEnvironmentRequest(args.endpoint.name);
yield* requireEnvironmentScope(AuthOrchestrationOperateScope);
for (const scope of requiredScopesForProjectMutation(args.payload)) {
yield* requireEnvironmentScope(scope);
}
const operation = projectMutationOperation(projects, args.payload);
return yield* startup
.enqueueCommand(operation)
Expand Down
21 changes: 20 additions & 1 deletion packages/contracts/src/settings.test.ts
Original file line number Diff line number Diff line change
@@ -1,13 +1,14 @@
import { describe, expect, it } from "vite-plus/test";
import * as Schema from "effect/Schema";

import type { ProjectId } from "./baseSchemas.ts";
import type { CommandId, ProjectId } from "./baseSchemas.ts";
import { ProviderDriverKind, ProviderInstanceId } from "./providerInstance.ts";
import {
ClientSettingsSchema,
ClientSettingsPatch,
ClaudeSettings,
DEFAULT_SERVER_SETTINGS,
requiredScopesForProjectMutation,
resolveProviderInstanceEnabled,
ServerSettings,
ServerSettingsPatch,
Expand Down Expand Up @@ -1068,3 +1069,21 @@ describe("ServerSettings.removeAgentCreditsOnMerge", () => {
).toBe(true);
});
});

describe("requiredScopesForProjectMutation", () => {
it("adds the settings scope only when a mutation carries scripts", () => {
const projectId = "project-1" as ProjectId;
const commandId = "command-1" as CommandId;
expect(
requiredScopesForProjectMutation({ type: "project.update", commandId, projectId }),
).toEqual(["orchestration:operate"]);
expect(
requiredScopesForProjectMutation({
type: "project.update",
commandId,
projectId,
scripts: [],
}),
).toEqual(["orchestration:operate", "settings:write"]);
});
});
20 changes: 19 additions & 1 deletion packages/contracts/src/settings.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
import { SshDeviceHostConfigs } from "./device.ts";
import {
AuthOrchestrationOperateScope,
AuthSettingsWriteScope,
AuthProvidersManageScope,
type AuthEnvironmentScope,
Expand Down Expand Up @@ -27,7 +28,7 @@ import {
ProviderOptionSelections,
} from "./model.ts";
import { ModelSelection } from "./modelSelection.ts";
import { ProjectScript } from "./project.ts";
import { ProjectScript, type ProjectMutation } from "./project.ts";
import { DEFAULT_RUNTIME_MODE, RuntimeMode } from "./providerPolicy.ts";
import { BrowserProfile, BrowserProfileId, DEFAULT_BROWSER_PROFILE_ID } from "./browserProfile.ts";
import {
Expand Down Expand Up @@ -1583,6 +1584,23 @@ export function requiredScopesForServerSettingsPatch(
];
}

/**
* Project scripts run on the host when a worktree is created or a thread
* settles, so a project mutation that carries them needs the same grant as
* saving project scripts through settings, on top of `orchestration:operate`.
*/
export function requiredScopesForProjectMutation(
mutation: ProjectMutation,
): ReadonlyArray<AuthEnvironmentScope> {
if (mutation.type === "project.delete" || mutation.scripts === undefined) {
return [AuthOrchestrationOperateScope];
}
return [
AuthOrchestrationOperateScope,
...requiredScopesForServerSettingsPatch({ defaultProjectScripts: mutation.scripts }),
];
}

export const ClientSettingsPatch = Schema.Struct({
notificationMode: Schema.optionalKey(NotificationMode),
inAppNotificationsEnabled: Schema.optionalKey(Schema.Boolean),
Expand Down
Loading