| author | |
| committer | |
| log | 50647de0143e68fde051eb1a0ccf4fe63f9947b2 |
| tree | d8fe144cbca7298f194d7fd3ba7dbaa478df16d7 |
| parent | cfbd1457ad093b1cd7f6c53851009a62b676b203 |
| signature | Signed by PGP key B5690EEEBB952194 |
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>10 files changed, 140 insertions(+), 18 deletions(-)
apps/server/src/auth/EnvironmentAuth.ts+14-2| ... | ... | @@ -4,6 +4,8 @@ import { |
| 4 | 4 | AuthAdministrativeScopes, |
| 5 | 5 | AuthOrchestrationOperateScope, |
| 6 | 6 | AuthOrchestrationReadScope, |
| 7 | AuthSettingsWriteScope, | |
| 8 | AuthSourceControlWriteScope, | |
| 7 | 9 | AuthStandardClientScopes, |
| 8 | 10 | type AuthAccessTokenResult, |
| 9 | 11 | type AuthBrowserSessionResult, |
| ... | ... | @@ -74,7 +76,10 @@ export interface IssuedBearerSession { |
| 74 | 76 | * token cannot reach the full RPC surface around that cap. |
| 75 | 77 | * |
| 76 | 78 | * A read-only grant holds `orchestration:read` alone. Any other grant also |
| 77 | * holds `orchestration:operate` and carries its runtime-mode ceiling. | |
| 79 | * holds `orchestration:operate` and carries its runtime-mode ceiling. A | |
| 80 | * full-access client may also change environment preferences and clone | |
| 81 | * repositories, so it holds the scopes those need over RPC. Approving a grant | |
| 82 | * requires every scope it holds. | |
| 78 | 83 | */ |
| 79 | 84 | const MCP_CLIENT_SUBJECT = "mcp-client"; |
| 80 | 85 | const MCP_CLIENT_SESSION_TTL = Duration.days(30); |
| ... | ... | @@ -84,7 +89,14 @@ export const mcpClientScopes = ( |
| 84 | 89 | ): ReadonlyArray<AuthEnvironmentScope> => |
| 85 | 90 | access === "read-only" |
| 86 | 91 | ? [AuthOrchestrationReadScope] |
| 87 | : [AuthOrchestrationReadScope, AuthOrchestrationOperateScope]; | |
| 92 | : access === "full-access" | |
| 93 | ? [ | |
| 94 | AuthOrchestrationReadScope, | |
| 95 | AuthOrchestrationOperateScope, | |
| 96 | AuthSettingsWriteScope, | |
| 97 | AuthSourceControlWriteScope, | |
| 98 | ] | |
| 99 | : [AuthOrchestrationReadScope, AuthOrchestrationOperateScope]; | |
| 88 | 100 | |
| 89 | 101 | export interface McpClientSession { |
| 90 | 102 | readonly sessionId: AuthSessionId; |
apps/server/src/auth/McpOAuth.test.ts+9| ... | ... | @@ -451,6 +451,15 @@ it.live( |
| 451 | 451 | const readOnlyResponse = yield* approveWith(readOnly.credential); |
| 452 | 452 | expect(readOnlyResponse.status).toBe(400); |
| 453 | 453 | expect(readOnlyResponse.message).toContain("cannot grant this access"); |
| 454 | const operator = yield* auth.issuePairingCredential({ | |
| 455 | scopes: ["orchestration:read", "orchestration:operate"], | |
| 456 | }); | |
| 457 | const operatorFull = yield* decide(handler, params, { | |
| 458 | _tag: "pairing-code", | |
| 459 | access: "full-access", | |
| 460 | code: operator.credential, | |
| 461 | }); | |
| 462 | expect(operatorFull.status).toBe(400); | |
| 454 | 463 | |
| 455 | 464 | // A read-only code can approve read-only access. |
| 456 | 465 | const readOnlyCode = yield* auth.issuePairingCredential({ scopes: ["orchestration:read"] }); |
apps/server/src/auth/RpcAuthorization.ts+7| ... | ... | @@ -8,6 +8,8 @@ import { |
| 8 | 8 | ServerSettingsPatch, |
| 9 | 9 | ProviderInstanceMutation, |
| 10 | 10 | requiredScopesForServerSettingsPatch, |
| 11 | requiredScopesForProjectMutation, | |
| 12 | ProjectMutation, | |
| 11 | 13 | AuthSettingsWriteScope, |
| 12 | 14 | AuthProvidersManageScope, |
| 13 | 15 | AuthEnvironmentMaintainScope, |
| ... | ... | @@ -242,6 +244,8 @@ const requiredScopesForSettingsUpdate = (payload: unknown) => { |
| 242 | 244 | : [...new Set([...scopes, AuthProvidersManageScope])]; |
| 243 | 245 | }; |
| 244 | 246 | |
| 247 | const decodeProjectMutation = Schema.decodeUnknownSync(ProjectMutation); | |
| 248 | ||
| 245 | 249 | const requiredScopesForRpcCall = ( |
| 246 | 250 | method: string, |
| 247 | 251 | payload: unknown, |
| ... | ... | @@ -260,6 +264,9 @@ const requiredScopesForRpcCall = ( |
| 260 | 264 | ]; |
| 261 | 265 | } |
| 262 | 266 | if (method === WS_METHODS.serverUpdateSettings) return requiredScopesForSettingsUpdate(payload); |
| 267 | if (method === WS_METHODS.projectsMutate) { | |
| 268 | return requiredScopesForProjectMutation(decodeProjectMutation(payload)); | |
| 269 | } | |
| 263 | 270 | const guarded = clientRpcRequiredScopes(method, payload); |
| 264 | 271 | if (guarded.length > 0) return guarded; |
| 265 | 272 | return [requiredScopeForRpcMethod(method)]; |
apps/server/src/preview/ServerBrowser.test.ts+29| ... | ... | @@ -1126,6 +1126,35 @@ it.live("a page's file picker goes to the controller and takes its uploaded file |
| 1126 | 1126 | ).pipe(Effect.provide(layer)), |
| 1127 | 1127 | ); |
| 1128 | 1128 | |
| 1129 | it.live("only the current controller can answer a file picker", () => | |
| 1130 | Effect.scoped( | |
| 1131 | Effect.gen(function* () { | |
| 1132 | const { browser, tabId } = yield* ready; | |
| 1133 | const first = yield* browser.attachViewer(viewerInput(tabId, true)); | |
| 1134 | yield* first.input({ type: "takeControl" }); | |
| 1135 | const setFiles = vi.fn(async (_files: unknown) => {}); | |
| 1136 | contexts[0]!.page.emit("filechooser", { | |
| 1137 | isMultiple: () => false, | |
| 1138 | element: () => ({ getAttribute: async () => "" }), | |
| 1139 | setFiles, | |
| 1140 | }); | |
| 1141 | let offered = yield* Queue.take(first.output); | |
| 1142 | while (offered._tag !== "fileChooser") offered = yield* Queue.take(first.output); | |
| 1143 | const second = yield* browser.attachViewer(viewerInput(tabId, true)); | |
| 1144 | yield* first.input({ type: "releaseControl" }); | |
| 1145 | yield* second.input({ type: "takeControl" }); | |
| 1146 | const answered = yield* browser.answerFileChooser({ | |
| 1147 | threadId: scope.thread.threadId, | |
| 1148 | tabId, | |
| 1149 | chooserId: offered.id, | |
| 1150 | files: [{ name: "a.txt", mimeType: "text/plain", buffer: Buffer.from("a") }], | |
| 1151 | }); | |
| 1152 | expect(answered).toBe(false); | |
| 1153 | expect(setFiles).not.toHaveBeenCalled(); | |
| 1154 | }), | |
| 1155 | ).pipe(Effect.provide(layer)), | |
| 1156 | ); | |
| 1157 | ||
| 1129 | 1158 | it.live("a file picker replaces a stalled viewer backlog instead of being dropped", () => |
| 1130 | 1159 | Effect.scoped( |
| 1131 | 1160 | Effect.gen(function* () { |
apps/server/src/preview/ServerBrowser.ts+29-5| ... | ... | @@ -310,11 +310,15 @@ interface ServerTab { |
| 310 | 310 | readonly openerTabId: string | undefined; |
| 311 | 311 | /** Finished downloads, newest last; files live until the tab closes. */ |
| 312 | 312 | readonly downloads: Array<ServerDownload>; |
| 313 | /** A page's open file picker, waiting for the controlling viewer's files. */ | |
| 313 | /** | |
| 314 | * A page's open file picker, waiting for the controlling viewer's files. Its | |
| 315 | * id is reissued for each viewer it is offered to, so only that viewer can answer. | |
| 316 | */ | |
| 314 | 317 | fileChooser: { |
| 315 | 318 | readonly id: string; |
| 316 | 319 | readonly chooser: FileChooser; |
| 317 | 320 | readonly accept: string; |
| 321 | readonly offeredTo: string | null; | |
| 318 | 322 | } | null; |
| 319 | 323 | dialog: Dialog | null; |
| 320 | 324 | setting: PreviewViewportSetting; |
| ... | ... | @@ -1027,14 +1031,27 @@ const make = Effect.gen(function* () { |
| 1027 | 1031 | .catch(() => null)) ?? ""; |
| 1028 | 1032 | // A newer picker replaces an unanswered one, as a real browser allows only one. |
| 1029 | 1033 | if (previous) closeFileChooser(tab); |
| 1030 | tab.fileChooser = { id: NodeCrypto.randomUUID(), chooser, accept: accept.slice(0, 1024) }; | |
| 1034 | tab.fileChooser = { | |
| 1035 | id: NodeCrypto.randomUUID(), | |
| 1036 | chooser, | |
| 1037 | accept: accept.slice(0, 1024), | |
| 1038 | offeredTo: null, | |
| 1039 | }; | |
| 1031 | 1040 | pushFileChooser(tab); |
| 1032 | 1041 | }; |
| 1033 | 1042 | |
| 1034 | 1043 | const pushFileChooser = (tab: ServerTab) => { |
| 1035 | const message = fileChooserMessage(tab); | |
| 1044 | const open = tab.fileChooser; | |
| 1036 | 1045 | const controller = [...tab.viewers].find((viewer) => viewer.id === tab.control.controller); |
| 1037 | if (message) controller?.push(message); | |
| 1046 | if (!open || !controller) return; | |
| 1047 | if (open.offeredTo !== controller.id) { | |
| 1048 | // A new controller gets a fresh id; the previous one can no longer answer. | |
| 1049 | tab.fileChooser = { ...open, id: NodeCrypto.randomUUID(), offeredTo: controller.id }; | |
| 1050 | if (open.offeredTo !== null) | |
| 1051 | for (const viewer of tab.viewers) viewer.push({ _tag: "fileChooserClosed", id: open.id }); | |
| 1052 | } | |
| 1053 | const message = fileChooserMessage(tab); | |
| 1054 | if (message) controller.push(message); | |
| 1038 | 1055 | }; |
| 1039 | 1056 | |
| 1040 | 1057 | const closeFileChooser = (tab: ServerTab) => { |
| ... | ... | @@ -1050,7 +1067,14 @@ const make = Effect.gen(function* () { |
| 1050 | 1067 | ) => { |
| 1051 | 1068 | const tab = tabs.get(tabKey(input.threadId, input.tabId)); |
| 1052 | 1069 | const open = tab?.fileChooser; |
| 1053 | if (!tab || !open || open.id !== input.chooserId) return false; | |
| 1070 | if ( | |
| 1071 | !tab || | |
| 1072 | !open || | |
| 1073 | open.id !== input.chooserId || | |
| 1074 | open.offeredTo === null || | |
| 1075 | open.offeredTo !== tab.control.controller | |
| 1076 | ) | |
| 1077 | return false; | |
| 1054 | 1078 | if (input.files.length > 0) { |
| 1055 | 1079 | await open.chooser.setFiles( |
| 1056 | 1080 | open.chooser.isMultiple() ? [...input.files] : input.files.slice(0, 1), |
apps/server/src/preview/ServerBrowserStream.test.ts+5-3| ... | ... | @@ -5,6 +5,7 @@ import { expect, it } from "@effect/vitest"; |
| 5 | 5 | import { |
| 6 | 6 | AuthOrchestrationOperateScope, |
| 7 | 7 | AuthOrchestrationReadScope, |
| 8 | AuthPreviewOperateScope, | |
| 8 | 9 | AuthSessionId, |
| 9 | 10 | PREVIEW_STREAM_HOST_SETUP_CLOSE_CODE, |
| 10 | 11 | PreviewStreamHostSetup, |
| ... | ... | @@ -71,7 +72,7 @@ it.effect.each([ |
| 71 | 72 | Effect.gen(function* () { |
| 72 | 73 | const canOperate = hasOperateScope && interactive; |
| 73 | 74 | const scopes = hasOperateScope |
| 74 | ? [AuthOrchestrationReadScope, AuthOrchestrationOperateScope] | |
| 75 | ? [AuthOrchestrationReadScope, AuthPreviewOperateScope] | |
| 75 | 76 | : [AuthOrchestrationReadScope]; |
| 76 | 77 | const auth = makeAuth(scopes); |
| 77 | 78 | const inputs: unknown[] = []; |
| ... | ... | @@ -293,11 +294,12 @@ it.effect("passes uploaded files to the page's open picker and needs operate sco |
| 293 | 294 | }); |
| 294 | 295 | expect((yield* upload([AuthOrchestrationReadScope], "chooser-1")).status).toBe(403); |
| 295 | 296 | expect(answers).toEqual([]); |
| 296 | expect((yield* upload([AuthOrchestrationOperateScope], "chooser-1")).status).toBe(204); | |
| 297 | expect((yield* upload([AuthOrchestrationOperateScope], "chooser-1")).status).toBe(403); | |
| 298 | expect((yield* upload([AuthPreviewOperateScope], "chooser-1")).status).toBe(204); | |
| 297 | 299 | expect(answers).toEqual([ |
| 298 | 300 | { chooserId: "chooser-1", files: [{ name: "notes.txt", text: "hello" }] }, |
| 299 | 301 | ]); |
| 300 | expect((yield* upload([AuthOrchestrationOperateScope], "stale")).status).toBe(409); | |
| 302 | expect((yield* upload([AuthPreviewOperateScope], "stale")).status).toBe(409); | |
| 301 | 303 | }).pipe(Effect.scoped), |
| 302 | 304 | ); |
| 303 | 305 |
apps/server/src/preview/ServerBrowserStream.ts+4-4| ... | ... | @@ -1,7 +1,7 @@ |
| 1 | 1 | import * as NodeHttpServerRequest from "@effect/platform-node/NodeHttpServerRequest"; |
| 2 | 2 | import { |
| 3 | AuthOrchestrationOperateScope, | |
| 4 | 3 | AuthOrchestrationReadScope, |
| 4 | AuthPreviewOperateScope, | |
| 5 | 5 | PREVIEW_STREAM_HOST_SETUP_CLOSE_CODE, |
| 6 | 6 | PreviewStreamHostSetup, |
| 7 | 7 | } from "@t3tools/contracts"; |
| ... | ... | @@ -71,9 +71,9 @@ const makeHandler = (browser: ServerBrowser.ServerBrowser["Service"]) => |
| 71 | 71 | } |
| 72 | 72 | const session = yield* authenticateMediaRequest(AuthOrchestrationReadScope); |
| 73 | 73 | const params = url.value.searchParams; |
| 74 | // Watching needs read access; driving the page is a preview mutation. | |
| 74 | 75 | const canOperate = |
| 75 | session.scopes.includes(AuthOrchestrationOperateScope) && | |
| 76 | params.get("interactive") !== "false"; | |
| 76 | session.scopes.includes(AuthPreviewOperateScope) && params.get("interactive") !== "false"; | |
| 77 | 77 | const threadId = params.get("threadId") ?? ""; |
| 78 | 78 | const tabId = params.get("tabId") ?? ""; |
| 79 | 79 | if (threadId.length === 0 || tabId.length === 0) { |
| ... | ... | @@ -238,7 +238,7 @@ const UPLOAD_MAX_FILES = 20; |
| 238 | 238 | */ |
| 239 | 239 | const receiveUpload = (browser: ServerBrowser.ServerBrowser["Service"], params: URLSearchParams) => |
| 240 | 240 | Effect.gen(function* () { |
| 241 | yield* authenticateMediaRequest(AuthOrchestrationOperateScope); | |
| 241 | yield* authenticateMediaRequest(AuthPreviewOperateScope); | |
| 242 | 242 | const request = yield* HttpServerRequest.HttpServerRequest; |
| 243 | 243 | const fs = yield* FileSystem.FileSystem; |
| 244 | 244 | const parts = yield* request.multipart.pipe( |
apps/server/src/project/http.ts+4-2| ... | ... | @@ -1,7 +1,7 @@ |
| 1 | 1 | import { |
| 2 | AuthOrchestrationOperateScope, | |
| 3 | 2 | AuthOrchestrationReadScope, |
| 4 | 3 | EnvironmentHttpApi, |
| 4 | requiredScopesForProjectMutation, | |
| 5 | 5 | } from "@t3tools/contracts"; |
| 6 | 6 | import * as Effect from "effect/Effect"; |
| 7 | 7 | import * as HttpApiBuilder from "effect/http-api/HttpApiBuilder"; |
| ... | ... | @@ -53,7 +53,9 @@ export const layer = HttpApiBuilder.group( |
| 53 | 53 | "mutate", |
| 54 | 54 | Effect.fn("environment.projects.mutate")(function* (args) { |
| 55 | 55 | yield* annotateEnvironmentRequest(args.endpoint.name); |
| 56 | yield* requireEnvironmentScope(AuthOrchestrationOperateScope); | |
| 56 | for (const scope of requiredScopesForProjectMutation(args.payload)) { | |
| 57 | yield* requireEnvironmentScope(scope); | |
| 58 | } | |
| 57 | 59 | const operation = projectMutationOperation(projects, args.payload); |
| 58 | 60 | return yield* startup |
| 59 | 61 | .enqueueCommand(operation) |
packages/contracts/src/settings.test.ts+20-1| ... | ... | @@ -1,13 +1,14 @@ |
| 1 | 1 | import { describe, expect, it } from "vite-plus/test"; |
| 2 | 2 | import * as Schema from "effect/Schema"; |
| 3 | 3 | |
| 4 | import type { ProjectId } from "./baseSchemas.ts"; | |
| 4 | import type { CommandId, ProjectId } from "./baseSchemas.ts"; | |
| 5 | 5 | import { ProviderDriverKind, ProviderInstanceId } from "./providerInstance.ts"; |
| 6 | 6 | import { |
| 7 | 7 | ClientSettingsSchema, |
| 8 | 8 | ClientSettingsPatch, |
| 9 | 9 | ClaudeSettings, |
| 10 | 10 | DEFAULT_SERVER_SETTINGS, |
| 11 | requiredScopesForProjectMutation, | |
| 11 | 12 | resolveProviderInstanceEnabled, |
| 12 | 13 | ServerSettings, |
| 13 | 14 | ServerSettingsPatch, |
| ... | ... | @@ -1068,3 +1069,21 @@ describe("ServerSettings.removeAgentCreditsOnMerge", () => { |
| 1068 | 1069 | ).toBe(true); |
| 1069 | 1070 | }); |
| 1070 | 1071 | }); |
| 1072 | ||
| 1073 | describe("requiredScopesForProjectMutation", () => { | |
| 1074 | it("adds the settings scope only when a mutation carries scripts", () => { | |
| 1075 | const projectId = "project-1" as ProjectId; | |
| 1076 | const commandId = "command-1" as CommandId; | |
| 1077 | expect( | |
| 1078 | requiredScopesForProjectMutation({ type: "project.update", commandId, projectId }), | |
| 1079 | ).toEqual(["orchestration:operate"]); | |
| 1080 | expect( | |
| 1081 | requiredScopesForProjectMutation({ | |
| 1082 | type: "project.update", | |
| 1083 | commandId, | |
| 1084 | projectId, | |
| 1085 | scripts: [], | |
| 1086 | }), | |
| 1087 | ).toEqual(["orchestration:operate", "settings:write"]); | |
| 1088 | }); | |
| 1089 | }); |
packages/contracts/src/settings.ts+19-1| ... | ... | @@ -1,5 +1,6 @@ |
| 1 | 1 | import { SshDeviceHostConfigs } from "./device.ts"; |
| 2 | 2 | import { |
| 3 | AuthOrchestrationOperateScope, | |
| 3 | 4 | AuthSettingsWriteScope, |
| 4 | 5 | AuthProvidersManageScope, |
| 5 | 6 | type AuthEnvironmentScope, |
| ... | ... | @@ -27,7 +28,7 @@ import { |
| 27 | 28 | ProviderOptionSelections, |
| 28 | 29 | } from "./model.ts"; |
| 29 | 30 | import { ModelSelection } from "./modelSelection.ts"; |
| 30 | import { ProjectScript } from "./project.ts"; | |
| 31 | import { ProjectScript, type ProjectMutation } from "./project.ts"; | |
| 31 | 32 | import { DEFAULT_RUNTIME_MODE, RuntimeMode } from "./providerPolicy.ts"; |
| 32 | 33 | import { BrowserProfile, BrowserProfileId, DEFAULT_BROWSER_PROFILE_ID } from "./browserProfile.ts"; |
| 33 | 34 | import { |
| ... | ... | @@ -1583,6 +1584,23 @@ export function requiredScopesForServerSettingsPatch( |
| 1583 | 1584 | ]; |
| 1584 | 1585 | } |
| 1585 | 1586 | |
| 1587 | /** | |
| 1588 | * Project scripts run on the host when a worktree is created or a thread | |
| 1589 | * settles, so a project mutation that carries them needs the same grant as | |
| 1590 | * saving project scripts through settings, on top of `orchestration:operate`. | |
| 1591 | */ | |
| 1592 | export function requiredScopesForProjectMutation( | |
| 1593 | mutation: ProjectMutation, | |
| 1594 | ): ReadonlyArray<AuthEnvironmentScope> { | |
| 1595 | if (mutation.type === "project.delete" || mutation.scripts === undefined) { | |
| 1596 | return [AuthOrchestrationOperateScope]; | |
| 1597 | } | |
| 1598 | return [ | |
| 1599 | AuthOrchestrationOperateScope, | |
| 1600 | ...requiredScopesForServerSettingsPatch({ defaultProjectScripts: mutation.scripts }), | |
| 1601 | ]; | |
| 1602 | } | |
| 1603 | ||
| 1586 | 1604 | export const ClientSettingsPatch = Schema.Struct({ |
| 1587 | 1605 | notificationMode: Schema.optionalKey(NotificationMode), |
| 1588 | 1606 | inAppNotificationsEnabled: Schema.optionalKey(Schema.Boolean), |