| 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,6 +4,8 @@ import { |
| 4 | AuthAdministrativeScopes, | 4 | AuthAdministrativeScopes, |
| 5 | AuthOrchestrationOperateScope, | 5 | AuthOrchestrationOperateScope, |
| 6 | AuthOrchestrationReadScope, | 6 | AuthOrchestrationReadScope, |
| 7 | AuthSettingsWriteScope, | ||
| 8 | AuthSourceControlWriteScope, | ||
| 7 | AuthStandardClientScopes, | 9 | AuthStandardClientScopes, |
| 8 | type AuthAccessTokenResult, | 10 | type AuthAccessTokenResult, |
| 9 | type AuthBrowserSessionResult, | 11 | type AuthBrowserSessionResult, |
| ... | @@ -74,7 +76,10 @@ export interface IssuedBearerSession { | ... | @@ -74,7 +76,10 @@ export interface IssuedBearerSession { |
| 74 | * token cannot reach the full RPC surface around that cap. | 76 | * token cannot reach the full RPC surface around that cap. |
| 75 | * | 77 | * |
| 76 | * A read-only grant holds `orchestration:read` alone. Any other grant also | 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 | const MCP_CLIENT_SUBJECT = "mcp-client"; | 84 | const MCP_CLIENT_SUBJECT = "mcp-client"; |
| 80 | const MCP_CLIENT_SESSION_TTL = Duration.days(30); | 85 | const MCP_CLIENT_SESSION_TTL = Duration.days(30); |
| ... | @@ -84,7 +89,14 @@ export const mcpClientScopes = ( | ... | @@ -84,7 +89,14 @@ export const mcpClientScopes = ( |
| 84 | ): ReadonlyArray<AuthEnvironmentScope> => | 89 | ): ReadonlyArray<AuthEnvironmentScope> => |
| 85 | access === "read-only" | 90 | access === "read-only" |
| 86 | ? [AuthOrchestrationReadScope] | 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 | export interface McpClientSession { | 101 | export interface McpClientSession { |
| 90 | readonly sessionId: AuthSessionId; | 102 | readonly sessionId: AuthSessionId; |
apps/server/src/auth/McpOAuth.test.ts+9| ... | @@ -451,6 +451,15 @@ it.live( | ... | @@ -451,6 +451,15 @@ it.live( |
| 451 | const readOnlyResponse = yield* approveWith(readOnly.credential); | 451 | const readOnlyResponse = yield* approveWith(readOnly.credential); |
| 452 | expect(readOnlyResponse.status).toBe(400); | 452 | expect(readOnlyResponse.status).toBe(400); |
| 453 | expect(readOnlyResponse.message).toContain("cannot grant this access"); | 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 | // A read-only code can approve read-only access. | 464 | // A read-only code can approve read-only access. |
| 456 | const readOnlyCode = yield* auth.issuePairingCredential({ scopes: ["orchestration:read"] }); | 465 | const readOnlyCode = yield* auth.issuePairingCredential({ scopes: ["orchestration:read"] }); |
apps/server/src/auth/RpcAuthorization.ts+7| ... | @@ -8,6 +8,8 @@ import { | ... | @@ -8,6 +8,8 @@ import { |
| 8 | ServerSettingsPatch, | 8 | ServerSettingsPatch, |
| 9 | ProviderInstanceMutation, | 9 | ProviderInstanceMutation, |
| 10 | requiredScopesForServerSettingsPatch, | 10 | requiredScopesForServerSettingsPatch, |
| 11 | requiredScopesForProjectMutation, | ||
| 12 | ProjectMutation, | ||
| 11 | AuthSettingsWriteScope, | 13 | AuthSettingsWriteScope, |
| 12 | AuthProvidersManageScope, | 14 | AuthProvidersManageScope, |
| 13 | AuthEnvironmentMaintainScope, | 15 | AuthEnvironmentMaintainScope, |
| ... | @@ -242,6 +244,8 @@ const requiredScopesForSettingsUpdate = (payload: unknown) => { | ... | @@ -242,6 +244,8 @@ const requiredScopesForSettingsUpdate = (payload: unknown) => { |
| 242 | : [...new Set([...scopes, AuthProvidersManageScope])]; | 244 | : [...new Set([...scopes, AuthProvidersManageScope])]; |
| 243 | }; | 245 | }; |
| 244 | 246 | ||
| 247 | const decodeProjectMutation = Schema.decodeUnknownSync(ProjectMutation); | ||
| 248 | |||
| 245 | const requiredScopesForRpcCall = ( | 249 | const requiredScopesForRpcCall = ( |
| 246 | method: string, | 250 | method: string, |
| 247 | payload: unknown, | 251 | payload: unknown, |
| ... | @@ -260,6 +264,9 @@ const requiredScopesForRpcCall = ( | ... | @@ -260,6 +264,9 @@ const requiredScopesForRpcCall = ( |
| 260 | ]; | 264 | ]; |
| 261 | } | 265 | } |
| 262 | if (method === WS_METHODS.serverUpdateSettings) return requiredScopesForSettingsUpdate(payload); | 266 | if (method === WS_METHODS.serverUpdateSettings) return requiredScopesForSettingsUpdate(payload); |
| 267 | if (method === WS_METHODS.projectsMutate) { | ||
| 268 | return requiredScopesForProjectMutation(decodeProjectMutation(payload)); | ||
| 269 | } | ||
| 263 | const guarded = clientRpcRequiredScopes(method, payload); | 270 | const guarded = clientRpcRequiredScopes(method, payload); |
| 264 | if (guarded.length > 0) return guarded; | 271 | if (guarded.length > 0) return guarded; |
| 265 | return [requiredScopeForRpcMethod(method)]; | 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,6 +1126,35 @@ it.live("a page's file picker goes to the controller and takes its uploaded file |
| 1126 | ).pipe(Effect.provide(layer)), | 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 | it.live("a file picker replaces a stalled viewer backlog instead of being dropped", () => | 1158 | it.live("a file picker replaces a stalled viewer backlog instead of being dropped", () => |
| 1130 | Effect.scoped( | 1159 | Effect.scoped( |
| 1131 | Effect.gen(function* () { | 1160 | Effect.gen(function* () { |
apps/server/src/preview/ServerBrowser.ts+29-5| ... | @@ -310,11 +310,15 @@ interface ServerTab { | ... | @@ -310,11 +310,15 @@ interface ServerTab { |
| 310 | readonly openerTabId: string | undefined; | 310 | readonly openerTabId: string | undefined; |
| 311 | /** Finished downloads, newest last; files live until the tab closes. */ | 311 | /** Finished downloads, newest last; files live until the tab closes. */ |
| 312 | readonly downloads: Array<ServerDownload>; | 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 | fileChooser: { | 317 | fileChooser: { |
| 315 | readonly id: string; | 318 | readonly id: string; |
| 316 | readonly chooser: FileChooser; | 319 | readonly chooser: FileChooser; |
| 317 | readonly accept: string; | 320 | readonly accept: string; |
| 321 | readonly offeredTo: string | null; | ||
| 318 | } | null; | 322 | } | null; |
| 319 | dialog: Dialog | null; | 323 | dialog: Dialog | null; |
| 320 | setting: PreviewViewportSetting; | 324 | setting: PreviewViewportSetting; |
| ... | @@ -1027,14 +1031,27 @@ const make = Effect.gen(function* () { | ... | @@ -1027,14 +1031,27 @@ const make = Effect.gen(function* () { |
| 1027 | .catch(() => null)) ?? ""; | 1031 | .catch(() => null)) ?? ""; |
| 1028 | // A newer picker replaces an unanswered one, as a real browser allows only one. | 1032 | // A newer picker replaces an unanswered one, as a real browser allows only one. |
| 1029 | if (previous) closeFileChooser(tab); | 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 | pushFileChooser(tab); | 1040 | pushFileChooser(tab); |
| 1032 | }; | 1041 | }; |
| 1033 | 1042 | ||
| 1034 | const pushFileChooser = (tab: ServerTab) => { | 1043 | const pushFileChooser = (tab: ServerTab) => { |
| 1035 | const message = fileChooserMessage(tab); | 1044 | const open = tab.fileChooser; |
| 1036 | const controller = [...tab.viewers].find((viewer) => viewer.id === tab.control.controller); | 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 | const closeFileChooser = (tab: ServerTab) => { | 1057 | const closeFileChooser = (tab: ServerTab) => { |
| ... | @@ -1050,7 +1067,14 @@ const make = Effect.gen(function* () { | ... | @@ -1050,7 +1067,14 @@ const make = Effect.gen(function* () { |
| 1050 | ) => { | 1067 | ) => { |
| 1051 | const tab = tabs.get(tabKey(input.threadId, input.tabId)); | 1068 | const tab = tabs.get(tabKey(input.threadId, input.tabId)); |
| 1052 | const open = tab?.fileChooser; | 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 | if (input.files.length > 0) { | 1078 | if (input.files.length > 0) { |
| 1055 | await open.chooser.setFiles( | 1079 | await open.chooser.setFiles( |
| 1056 | open.chooser.isMultiple() ? [...input.files] : input.files.slice(0, 1), | 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,6 +5,7 @@ import { expect, it } from "@effect/vitest"; |
| 5 | import { | 5 | import { |
| 6 | AuthOrchestrationOperateScope, | 6 | AuthOrchestrationOperateScope, |
| 7 | AuthOrchestrationReadScope, | 7 | AuthOrchestrationReadScope, |
| 8 | AuthPreviewOperateScope, | ||
| 8 | AuthSessionId, | 9 | AuthSessionId, |
| 9 | PREVIEW_STREAM_HOST_SETUP_CLOSE_CODE, | 10 | PREVIEW_STREAM_HOST_SETUP_CLOSE_CODE, |
| 10 | PreviewStreamHostSetup, | 11 | PreviewStreamHostSetup, |
| ... | @@ -71,7 +72,7 @@ it.effect.each([ | ... | @@ -71,7 +72,7 @@ it.effect.each([ |
| 71 | Effect.gen(function* () { | 72 | Effect.gen(function* () { |
| 72 | const canOperate = hasOperateScope && interactive; | 73 | const canOperate = hasOperateScope && interactive; |
| 73 | const scopes = hasOperateScope | 74 | const scopes = hasOperateScope |
| 74 | ? [AuthOrchestrationReadScope, AuthOrchestrationOperateScope] | 75 | ? [AuthOrchestrationReadScope, AuthPreviewOperateScope] |
| 75 | : [AuthOrchestrationReadScope]; | 76 | : [AuthOrchestrationReadScope]; |
| 76 | const auth = makeAuth(scopes); | 77 | const auth = makeAuth(scopes); |
| 77 | const inputs: unknown[] = []; | 78 | const inputs: unknown[] = []; |
| ... | @@ -293,11 +294,12 @@ it.effect("passes uploaded files to the page's open picker and needs operate sco | ... | @@ -293,11 +294,12 @@ it.effect("passes uploaded files to the page's open picker and needs operate sco |
| 293 | }); | 294 | }); |
| 294 | expect((yield* upload([AuthOrchestrationReadScope], "chooser-1")).status).toBe(403); | 295 | expect((yield* upload([AuthOrchestrationReadScope], "chooser-1")).status).toBe(403); |
| 295 | expect(answers).toEqual([]); | 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 | expect(answers).toEqual([ | 299 | expect(answers).toEqual([ |
| 298 | { chooserId: "chooser-1", files: [{ name: "notes.txt", text: "hello" }] }, | 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 | }).pipe(Effect.scoped), | 303 | }).pipe(Effect.scoped), |
| 302 | ); | 304 | ); |
| 303 | 305 |
apps/server/src/preview/ServerBrowserStream.ts+4-4| ... | @@ -1,7 +1,7 @@ | ... | @@ -1,7 +1,7 @@ |
| 1 | import * as NodeHttpServerRequest from "@effect/platform-node/NodeHttpServerRequest"; | 1 | import * as NodeHttpServerRequest from "@effect/platform-node/NodeHttpServerRequest"; |
| 2 | import { | 2 | import { |
| 3 | AuthOrchestrationOperateScope, | ||
| 4 | AuthOrchestrationReadScope, | 3 | AuthOrchestrationReadScope, |
| 4 | AuthPreviewOperateScope, | ||
| 5 | PREVIEW_STREAM_HOST_SETUP_CLOSE_CODE, | 5 | PREVIEW_STREAM_HOST_SETUP_CLOSE_CODE, |
| 6 | PreviewStreamHostSetup, | 6 | PreviewStreamHostSetup, |
| 7 | } from "@t3tools/contracts"; | 7 | } from "@t3tools/contracts"; |
| ... | @@ -71,9 +71,9 @@ const makeHandler = (browser: ServerBrowser.ServerBrowser["Service"]) => | ... | @@ -71,9 +71,9 @@ const makeHandler = (browser: ServerBrowser.ServerBrowser["Service"]) => |
| 71 | } | 71 | } |
| 72 | const session = yield* authenticateMediaRequest(AuthOrchestrationReadScope); | 72 | const session = yield* authenticateMediaRequest(AuthOrchestrationReadScope); |
| 73 | const params = url.value.searchParams; | 73 | const params = url.value.searchParams; |
| 74 | // Watching needs read access; driving the page is a preview mutation. | ||
| 74 | const canOperate = | 75 | const canOperate = |
| 75 | session.scopes.includes(AuthOrchestrationOperateScope) && | 76 | session.scopes.includes(AuthPreviewOperateScope) && params.get("interactive") !== "false"; |
| 76 | params.get("interactive") !== "false"; | ||
| 77 | const threadId = params.get("threadId") ?? ""; | 77 | const threadId = params.get("threadId") ?? ""; |
| 78 | const tabId = params.get("tabId") ?? ""; | 78 | const tabId = params.get("tabId") ?? ""; |
| 79 | if (threadId.length === 0 || tabId.length === 0) { | 79 | if (threadId.length === 0 || tabId.length === 0) { |
| ... | @@ -238,7 +238,7 @@ const UPLOAD_MAX_FILES = 20; | ... | @@ -238,7 +238,7 @@ const UPLOAD_MAX_FILES = 20; |
| 238 | */ | 238 | */ |
| 239 | const receiveUpload = (browser: ServerBrowser.ServerBrowser["Service"], params: URLSearchParams) => | 239 | const receiveUpload = (browser: ServerBrowser.ServerBrowser["Service"], params: URLSearchParams) => |
| 240 | Effect.gen(function* () { | 240 | Effect.gen(function* () { |
| 241 | yield* authenticateMediaRequest(AuthOrchestrationOperateScope); | 241 | yield* authenticateMediaRequest(AuthPreviewOperateScope); |
| 242 | const request = yield* HttpServerRequest.HttpServerRequest; | 242 | const request = yield* HttpServerRequest.HttpServerRequest; |
| 243 | const fs = yield* FileSystem.FileSystem; | 243 | const fs = yield* FileSystem.FileSystem; |
| 244 | const parts = yield* request.multipart.pipe( | 244 | const parts = yield* request.multipart.pipe( |
apps/server/src/project/http.ts+4-2| ... | @@ -1,7 +1,7 @@ | ... | @@ -1,7 +1,7 @@ |
| 1 | import { | 1 | import { |
| 2 | AuthOrchestrationOperateScope, | ||
| 3 | AuthOrchestrationReadScope, | 2 | AuthOrchestrationReadScope, |
| 4 | EnvironmentHttpApi, | 3 | EnvironmentHttpApi, |
| 4 | requiredScopesForProjectMutation, | ||
| 5 | } from "@t3tools/contracts"; | 5 | } from "@t3tools/contracts"; |
| 6 | import * as Effect from "effect/Effect"; | 6 | import * as Effect from "effect/Effect"; |
| 7 | import * as HttpApiBuilder from "effect/http-api/HttpApiBuilder"; | 7 | import * as HttpApiBuilder from "effect/http-api/HttpApiBuilder"; |
| ... | @@ -53,7 +53,9 @@ export const layer = HttpApiBuilder.group( | ... | @@ -53,7 +53,9 @@ export const layer = HttpApiBuilder.group( |
| 53 | "mutate", | 53 | "mutate", |
| 54 | Effect.fn("environment.projects.mutate")(function* (args) { | 54 | Effect.fn("environment.projects.mutate")(function* (args) { |
| 55 | yield* annotateEnvironmentRequest(args.endpoint.name); | 55 | yield* annotateEnvironmentRequest(args.endpoint.name); |
| 56 | yield* requireEnvironmentScope(AuthOrchestrationOperateScope); | 56 | for (const scope of requiredScopesForProjectMutation(args.payload)) { |
| 57 | yield* requireEnvironmentScope(scope); | ||
| 58 | } | ||
| 57 | const operation = projectMutationOperation(projects, args.payload); | 59 | const operation = projectMutationOperation(projects, args.payload); |
| 58 | return yield* startup | 60 | return yield* startup |
| 59 | .enqueueCommand(operation) | 61 | .enqueueCommand(operation) |
packages/contracts/src/settings.test.ts+20-1| ... | @@ -1,13 +1,14 @@ | ... | @@ -1,13 +1,14 @@ |
| 1 | import { describe, expect, it } from "vite-plus/test"; | 1 | import { describe, expect, it } from "vite-plus/test"; |
| 2 | import * as Schema from "effect/Schema"; | 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 | import { ProviderDriverKind, ProviderInstanceId } from "./providerInstance.ts"; | 5 | import { ProviderDriverKind, ProviderInstanceId } from "./providerInstance.ts"; |
| 6 | import { | 6 | import { |
| 7 | ClientSettingsSchema, | 7 | ClientSettingsSchema, |
| 8 | ClientSettingsPatch, | 8 | ClientSettingsPatch, |
| 9 | ClaudeSettings, | 9 | ClaudeSettings, |
| 10 | DEFAULT_SERVER_SETTINGS, | 10 | DEFAULT_SERVER_SETTINGS, |
| 11 | requiredScopesForProjectMutation, | ||
| 11 | resolveProviderInstanceEnabled, | 12 | resolveProviderInstanceEnabled, |
| 12 | ServerSettings, | 13 | ServerSettings, |
| 13 | ServerSettingsPatch, | 14 | ServerSettingsPatch, |
| ... | @@ -1068,3 +1069,21 @@ describe("ServerSettings.removeAgentCreditsOnMerge", () => { | ... | @@ -1068,3 +1069,21 @@ describe("ServerSettings.removeAgentCreditsOnMerge", () => { |
| 1068 | ).toBe(true); | 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,5 +1,6 @@ |
| 1 | import { SshDeviceHostConfigs } from "./device.ts"; | 1 | import { SshDeviceHostConfigs } from "./device.ts"; |
| 2 | import { | 2 | import { |
| 3 | AuthOrchestrationOperateScope, | ||
| 3 | AuthSettingsWriteScope, | 4 | AuthSettingsWriteScope, |
| 4 | AuthProvidersManageScope, | 5 | AuthProvidersManageScope, |
| 5 | type AuthEnvironmentScope, | 6 | type AuthEnvironmentScope, |
| ... | @@ -27,7 +28,7 @@ import { | ... | @@ -27,7 +28,7 @@ import { |
| 27 | ProviderOptionSelections, | 28 | ProviderOptionSelections, |
| 28 | } from "./model.ts"; | 29 | } from "./model.ts"; |
| 29 | import { ModelSelection } from "./modelSelection.ts"; | 30 | import { ModelSelection } from "./modelSelection.ts"; |
| 30 | import { ProjectScript } from "./project.ts"; | 31 | import { ProjectScript, type ProjectMutation } from "./project.ts"; |
| 31 | import { DEFAULT_RUNTIME_MODE, RuntimeMode } from "./providerPolicy.ts"; | 32 | import { DEFAULT_RUNTIME_MODE, RuntimeMode } from "./providerPolicy.ts"; |
| 32 | import { BrowserProfile, BrowserProfileId, DEFAULT_BROWSER_PROFILE_ID } from "./browserProfile.ts"; | 33 | import { BrowserProfile, BrowserProfileId, DEFAULT_BROWSER_PROFILE_ID } from "./browserProfile.ts"; |
| 33 | import { | 34 | import { |
| ... | @@ -1583,6 +1584,23 @@ export function requiredScopesForServerSettingsPatch( | ... | @@ -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 | export const ClientSettingsPatch = Schema.Struct({ | 1604 | export const ClientSettingsPatch = Schema.Struct({ |
| 1587 | notificationMode: Schema.optionalKey(NotificationMode), | 1605 | notificationMode: Schema.optionalKey(NotificationMode), |
| 1588 | inAppNotificationsEnabled: Schema.optionalKey(Schema.Boolean), | 1606 | inAppNotificationsEnabled: Schema.optionalKey(Schema.Boolean), |