1import {
2 AuthEnvironmentMaintainScope,
3 AuthDiagnosticsReadScope,
4 AuthFilesystemReadScope,
5 AuthProvidersManageScope,
6 AuthSettingsWriteScope,
7 DEFAULT_SERVER_SETTINGS,
8 ProviderInstanceId,
9 ThreadId,
10 AuthOrchestrationOperateScope,
11 ORCHESTRATION_V2_WS_METHODS,
12 AuthOrchestrationReadScope,
13 AuthSourceControlWriteScope,
14 AuthPreviewOperateScope,
15 AuthRelayReadScope,
16 AuthRelayWriteScope,
17 AuthTerminalReadScope,
18 AuthTerminalOperateScope,
19 WS_METHODS,
20 WsRpcGroup,
21} from "@t3tools/contracts";
22import { describe, expect, it } from "@effect/vitest";
23import * as Effect from "effect/Effect";
24import * as Layer from "effect/Layer";
25import * as RpcTest from "effect/rpc/RpcTest";
26
27import {
28 RPC_REQUIRED_SCOPES,
29 requiredScopeForRpcMethod,
30 requiredScopeForDeviceList,
31} from "./RpcAuthorization.ts";
32import * as RpcAuthorization from "./RpcAuthorization.ts";
33
34describe("RPC authorization scopes", () => {
35 it("reads personal UI profiles under read access and protects writes", () => {
36 expect(requiredScopeForRpcMethod(WS_METHODS.uiSettingsGet)).toBe(AuthOrchestrationReadScope);
37 expect(requiredScopeForRpcMethod(WS_METHODS.uiSettingsSubscribe)).toBe(
38 AuthOrchestrationReadScope,
39 );
40 expect(requiredScopeForRpcMethod(WS_METHODS.uiSettingsPatch)).toBe(AuthSettingsWriteScope);
41 });
42
43 it("requires filesystem read permission to watch file and folder changes", () => {
44 expect(requiredScopeForRpcMethod(WS_METHODS.projectsWatch)).toBe(AuthFilesystemReadScope);
45 });
46 it("reads script progress under orchestration read access", () => {
47 expect(requiredScopeForRpcMethod(WS_METHODS.subscribeScriptProgress)).toBe(
48 AuthOrchestrationReadScope,
49 );
50 });
51 it("declares exactly one scope for every RPC in the server group", () => {
52 expect(new Set(Object.keys(RPC_REQUIRED_SCOPES))).toEqual(new Set(WsRpcGroup.requests.keys()));
53 });
54
55 it("requires operate access to stop a workflow", () => {
56 expect(requiredScopeForRpcMethod(ORCHESTRATION_V2_WS_METHODS.stopWorkflow)).toBe(
57 AuthOrchestrationOperateScope,
58 );
59 });
60
61 it("authorizes background policy reporting and observation deliberately", () => {
62 expect(requiredScopeForRpcMethod(WS_METHODS.serverReportClientActivity)).toBe(
63 AuthOrchestrationReadScope,
64 );
65 expect(requiredScopeForRpcMethod(WS_METHODS.serverReportHostPowerState)).toBe(
66 AuthEnvironmentMaintainScope,
67 );
68 expect(requiredScopeForRpcMethod(WS_METHODS.serverGetBackgroundPolicy)).toBe(
69 AuthOrchestrationReadScope,
70 );
71 expect(requiredScopeForRpcMethod(WS_METHODS.subscribeBackgroundPolicy)).toBe(
72 AuthOrchestrationReadScope,
73 );
74 });
75
76 it("keeps webhook delivery logs, which hold request bodies, behind operate scope", () => {
77 for (const method of [
78 WS_METHODS.scheduledTasksListWebhookDeliveries,
79 WS_METHODS.scheduledTasksGetWebhookDelivery,
80 WS_METHODS.scheduledTasksRotateWebhookToken,
81 ]) {
82 expect(requiredScopeForRpcMethod(method)).toBe(AuthOrchestrationOperateScope);
83 }
84 });
85
86 it("allows relay status reads without granting relay installation access", () => {
87 expect(requiredScopeForRpcMethod(WS_METHODS.cloudGetRelayClientStatus)).toBe(
88 AuthRelayReadScope,
89 );
90 expect(requiredScopeForRpcMethod(WS_METHODS.cloudInstallRelayClient)).toBe(AuthRelayWriteScope);
91 });
92
93 it("requires permission to operate on a thread before uploading feedback", () => {
94 expect(requiredScopeForRpcMethod(WS_METHODS.providerUploadFeedback)).toBe(
95 AuthOrchestrationOperateScope,
96 );
97 });
98
99 it("requires write access to import agent session history", () => {
100 expect(requiredScopeForRpcMethod(WS_METHODS.agentSessionsScan)).toBe(
101 AuthOrchestrationReadScope,
102 );
103 expect(requiredScopeForRpcMethod(WS_METHODS.agentSessionsImport)).toBe(
104 AuthOrchestrationOperateScope,
105 );
106 });
107
108 it("separates ACP Registry discovery from provisioning", () => {
109 expect(requiredScopeForRpcMethod(WS_METHODS.serverSearchAcpRegistry)).toBe(
110 AuthOrchestrationReadScope,
111 );
112 expect(requiredScopeForRpcMethod(WS_METHODS.serverPrepareAcpRegistryAgent)).toBe(
113 AuthProvidersManageScope,
114 );
115 expect(requiredScopeForRpcMethod(WS_METHODS.serverUninstallAcpRegistryManagedBinary)).toBe(
116 AuthProvidersManageScope,
117 );
118 expect(requiredScopeForRpcMethod(WS_METHODS.serverAcceptAcpRegistryUrlAuth)).toBe(
119 AuthProvidersManageScope,
120 );
121 expect(requiredScopeForRpcMethod(WS_METHODS.serverListAcpRegistrySessions)).toBe(
122 AuthOrchestrationReadScope,
123 );
124 expect(requiredScopeForRpcMethod(WS_METHODS.serverImportAcpRegistrySession)).toBe(
125 AuthOrchestrationOperateScope,
126 );
127 expect(requiredScopeForRpcMethod(WS_METHODS.serverLogoutAcpRegistry)).toBe(
128 AuthProvidersManageScope,
129 );
130 });
131
132 it("reads the reviewer menu under the same scope as the pull request it belongs to", () => {
133 // The candidate list is a read like the detail beside it, and asking somebody for a review is
134 // a write like every other pull request operation.
135 expect(requiredScopeForRpcMethod(WS_METHODS.pullRequestsChecks)).toBe(
136 AuthOrchestrationReadScope,
137 );
138 expect(requiredScopeForRpcMethod(WS_METHODS.pullRequestsReviewerCandidates)).toBe(
139 requiredScopeForRpcMethod(WS_METHODS.pullRequestsDetail),
140 );
141 expect(requiredScopeForRpcMethod(WS_METHODS.pullRequestsRequestReviewers)).toBe(
142 requiredScopeForRpcMethod(WS_METHODS.pullRequestsComment),
143 );
144 });
145
146 it("requires source control writes to start, retry, or cancel project clones", () => {
147 for (const method of [
148 WS_METHODS.projectCloneStart,
149 WS_METHODS.projectCloneRetry,
150 WS_METHODS.projectCloneCancel,
151 ]) {
152 expect(requiredScopeForRpcMethod(method)).toBe(AuthSourceControlWriteScope);
153 }
154 expect(requiredScopeForRpcMethod(WS_METHODS.subscribeProjectClones)).toBe(
155 AuthOrchestrationReadScope,
156 );
157 });
158
159 it("separates viewing pull request file progress from writing it", () => {
160 expect(requiredScopeForRpcMethod(WS_METHODS.pullRequestsFilesViewed)).toBe(
161 AuthOrchestrationReadScope,
162 );
163 expect(requiredScopeForRpcMethod(WS_METHODS.pullRequestsSetFilesViewed)).toBe(
164 AuthSourceControlWriteScope,
165 );
166 });
167
168 it("requires orchestration reads for Mergebot snapshots and live updates", () => {
169 for (const method of [
170 WS_METHODS.mergebotQueue,
171 WS_METHODS.mergebotSubscribe,
172 WS_METHODS.mergebotSubscribePullRequestStatus,
173 WS_METHODS.mergebotSubscribeDeployments,
174 ]) {
175 expect(requiredScopeForRpcMethod(method)).toBe(AuthOrchestrationReadScope);
176 }
177 });
178
179 it("requires source-control writes for Jujutsu and Mergebot mutations", () => {
180 for (const method of [
181 WS_METHODS.mergebotOpenPullRequest,
182 WS_METHODS.mergebotSetAutoMerge,
183 WS_METHODS.mergebotUpdatePullRequest,
184 WS_METHODS.mergebotDeployProduction,
185 WS_METHODS.jjDescribe,
186 WS_METHODS.jjSquash,
187 WS_METHODS.jjPush,
188 ]) {
189 expect(requiredScopeForRpcMethod(method)).toBe(AuthSourceControlWriteScope);
190 }
191 for (const method of [WS_METHODS.jjSubscribeStack, WS_METHODS.jjSnapshot, WS_METHODS.jjDiff]) {
192 expect(requiredScopeForRpcMethod(method)).toBe(AuthFilesystemReadScope);
193 }
194 });
195
196 it("separates preview control from observation", () => {
197 for (const method of [
198 WS_METHODS.previewOpen,
199 WS_METHODS.previewNavigate,
200 WS_METHODS.previewResize,
201 WS_METHODS.previewRefresh,
202 WS_METHODS.previewClose,
203 WS_METHODS.previewReportStatus,
204 WS_METHODS.previewAdjust,
205 WS_METHODS.previewClearProfile,
206 WS_METHODS.previewReportProfiles,
207 ]) {
208 expect(requiredScopeForRpcMethod(method)).toBe(AuthPreviewOperateScope);
209 }
210 for (const method of [
211 WS_METHODS.previewList,
212 WS_METHODS.subscribePreviewEvents,
213 WS_METHODS.subscribeDiscoveredLocalServers,
214 ]) {
215 expect(requiredScopeForRpcMethod(method)).toBe(AuthOrchestrationReadScope);
216 }
217 });
218
219 it("separates passive terminal observation from operations that can change a shell", () => {
220 for (const method of [
221 WS_METHODS.terminalObserve,
222 WS_METHODS.subscribeTerminalEvents,
223 WS_METHODS.subscribeTerminalMetadata,
224 ]) {
225 expect(requiredScopeForRpcMethod(method)).toBe(AuthTerminalReadScope);
226 }
227 for (const method of [
228 WS_METHODS.terminalAttach,
229 WS_METHODS.terminalOpen,
230 WS_METHODS.terminalWrite,
231 WS_METHODS.terminalResize,
232 WS_METHODS.terminalClear,
233 WS_METHODS.terminalRestart,
234 WS_METHODS.terminalClose,
235 ]) {
236 expect(requiredScopeForRpcMethod(method)).toBe(AuthTerminalOperateScope);
237 }
238 });
239
240 it("rejects unknown RPC method names", () => {
241 for (const method of ["server.notRegistered", "toString", "constructor"]) {
242 expect(() => requiredScopeForRpcMethod(method)).toThrow(
243 `RPC method ${method} has no declared authorization scope.`,
244 );
245 }
246 });
247});
248
249it("requires operate permission for host retry while preserving read-only listing", () => {
250 expect(requiredScopeForDeviceList({})).toBe(AuthOrchestrationReadScope);
251 expect(requiredScopeForDeviceList({ retryHostId: "remote-host" })).toBe(
252 AuthOrchestrationOperateScope,
253 );
254});
255
256it("requires operate permission for tool updates even alongside a read-only check", () => {
257 expect(requiredScopeForDeviceList({ updateTool: "agent", inspectOnly: true })).toBe(
258 AuthOrchestrationOperateScope,
259 );
260 expect(requiredScopeForDeviceList({ updateTool: "hub" })).toBe(AuthOrchestrationOperateScope);
261});
262
263describe("dev server RPC authority", () => {
264 const tested = [WS_METHODS.devServerStatus, WS_METHODS.devServerStart] as const;
265 const group = WsRpcGroup.omit(
266 ...[...WsRpcGroup.requests.keys()].filter(
267 (tag): tag is Exclude<keyof typeof RPC_REQUIRED_SCOPES, (typeof tested)[number]> =>
268 !(tested as ReadonlyArray<string>).includes(tag),
269 ),
270 );
271 it.effect.each([
272 {
273 method: WS_METHODS.devServerStart,
274 scopes: [AuthFilesystemReadScope, AuthOrchestrationReadScope],
275 missing: AuthTerminalOperateScope,
276 },
277 {
278 method: WS_METHODS.devServerStatus,
279 scopes: [AuthOrchestrationReadScope],
280 missing: AuthFilesystemReadScope,
281 },
282 {
283 method: WS_METHODS.devServerStatus,
284 scopes: [AuthFilesystemReadScope],
285 missing: AuthOrchestrationReadScope,
286 },
287 ])("rejects $method without $missing before its handler runs", ({ method, scopes, missing }) =>
288 Effect.scoped(
289 Effect.gen(function* () {
290 let handled = false;
291 const response = {
292 workspaceRoot: "/workspace",
293 canStart: false,
294 environment: null,
295 stopping: false,
296 services: [],
297 frontendUrl: null,
298 };
299 const handler = () =>
300 Effect.sync(() => {
301 handled = true;
302 return response;
303 });
304 const client = yield* RpcTest.makeClient(group).pipe(
305 Effect.provide(
306 Layer.mergeAll(
307 group.toLayerHandler(WS_METHODS.devServerStatus, handler),
308 group.toLayerHandler(WS_METHODS.devServerStart, handler),
309 RpcAuthorization.layer(scopes),
310 ),
311 ),
312 );
313 const error = yield* client[method]({
314 _tag: "Thread",
315 threadId: ThreadId.make("thread"),
316 }).pipe(Effect.flip);
317 expect(error).toMatchObject({
318 _tag: "EnvironmentAuthorizationError",
319 requiredPermission: missing,
320 });
321 expect(handled).toBe(false);
322 }),
323 ),
324 );
325});
326
327describe("RPC scope middleware", () => {
328 const tested = [WS_METHODS.serverProbe, WS_METHODS.serverRetryResourceTelemetry] as const;
329 const group = WsRpcGroup.omit(
330 ...[...WsRpcGroup.requests.keys()].filter(
331 (tag): tag is Exclude<keyof typeof RPC_REQUIRED_SCOPES, (typeof tested)[number]> =>
332 !(tested as ReadonlyArray<string>).includes(tag),
333 ),
334 );
335
336 it.effect.each([
337 { scopes: [AuthOrchestrationReadScope], missing: AuthEnvironmentMaintainScope },
338 {
339 scopes: [AuthOrchestrationReadScope, AuthEnvironmentMaintainScope],
340 missing: AuthDiagnosticsReadScope,
341 },
342 {
343 scopes: [AuthOrchestrationReadScope, AuthDiagnosticsReadScope],
344 missing: AuthEnvironmentMaintainScope,
345 },
346 ])("rejects telemetry retry without $missing before its handler runs", ({ scopes, missing }) =>
347 Effect.gen(function* () {
348 const handled: Array<string> = [];
349 const client = yield* RpcTest.makeClient(group).pipe(
350 Effect.provide(
351 Layer.mergeAll(
352 group.toLayerHandler(WS_METHODS.serverProbe, () => Effect.succeed({})),
353 group.toLayerHandler(WS_METHODS.serverRetryResourceTelemetry, () =>
354 Effect.sync(() => handled.push("retry")).pipe(Effect.andThen(Effect.never)),
355 ),
356 RpcAuthorization.layer(scopes),
357 ),
358 ),
359 );
360
361 expect(yield* client[WS_METHODS.serverProbe]({})).toEqual({});
362 expect(
363 yield* client[WS_METHODS.serverRetryResourceTelemetry]({}).pipe(Effect.flip),
364 ).toMatchObject({
365 _tag: "EnvironmentAuthorizationError",
366 requiredPermission: missing,
367 });
368 expect(handled).toEqual([]);
369 }).pipe(Effect.scoped),
370 );
371});
372
373describe("settings mutation authorization", () => {
374 const group = WsRpcGroup.omit(
375 ...[...WsRpcGroup.requests.keys()].filter(
376 (
377 tag,
378 ): tag is Exclude<keyof typeof RPC_REQUIRED_SCOPES, typeof WS_METHODS.serverUpdateSettings> =>
379 tag !== WS_METHODS.serverUpdateSettings,
380 ),
381 );
382 const providerInstanceMutation = {
383 operation: "remove" as const,
384 instanceId: ProviderInstanceId.make("codex_work"),
385 };
386
387 it.effect("allows provider-only mutations while denying mixed settings without their grant", () =>
388 Effect.gen(function* () {
389 let handled = 0;
390 const client = yield* RpcTest.makeClient(group).pipe(
391 Effect.provide(
392 Layer.mergeAll(
393 group.toLayerHandler(WS_METHODS.serverUpdateSettings, () =>
394 Effect.sync(() => {
395 handled++;
396 return DEFAULT_SERVER_SETTINGS;
397 }),
398 ),
399 RpcAuthorization.layer([AuthProvidersManageScope]),
400 ),
401 ),
402 );
403 yield* client[WS_METHODS.serverUpdateSettings]({ patch: {}, providerInstanceMutation });
404 expect(handled).toBe(1);
405 expect(
406 yield* client[WS_METHODS.serverUpdateSettings]({
407 patch: { defaultRuntimeMode: "full-access" },
408 providerInstanceMutation,
409 }).pipe(Effect.flip),
410 ).toMatchObject({ requiredPermission: AuthSettingsWriteScope });
411 expect(handled).toBe(1);
412 }).pipe(Effect.scoped),
413 );
414
415 it.effect("does not let a settings grant create or remove providers", () =>
416 Effect.gen(function* () {
417 let handled = false;
418 const client = yield* RpcTest.makeClient(group).pipe(
419 Effect.provide(
420 Layer.mergeAll(
421 group.toLayerHandler(WS_METHODS.serverUpdateSettings, () =>
422 Effect.sync(() => {
423 handled = true;
424 return DEFAULT_SERVER_SETTINGS;
425 }),
426 ),
427 RpcAuthorization.layer([AuthSettingsWriteScope]),
428 ),
429 ),
430 );
431 expect(
432 yield* client[WS_METHODS.serverUpdateSettings]({
433 patch: {},
434 providerInstanceMutation,
435 }).pipe(Effect.flip),
436 ).toMatchObject({ requiredPermission: AuthProvidersManageScope });
437 expect(handled).toBe(false);
438 }).pipe(Effect.scoped),
439 );
440});
441
442it.effect("requires task permission before attaching a prepared worktree to a thread", () =>
443 Effect.gen(function* () {
444 const group = WsRpcGroup.omit(
445 ...[...WsRpcGroup.requests.keys()].filter(
446 (
447 tag,
448 ): tag is Exclude<
449 keyof typeof RPC_REQUIRED_SCOPES,
450 typeof WS_METHODS.gitPreparePullRequestThread
451 > => tag !== WS_METHODS.gitPreparePullRequestThread,
452 ),
453 );
454 let handled = false;
455 const client = yield* RpcTest.makeClient(group).pipe(
456 Effect.provide(
457 Layer.mergeAll(
458 group.toLayerHandler(WS_METHODS.gitPreparePullRequestThread, () =>
459 Effect.sync(() => {
460 handled = true;
461 }).pipe(Effect.andThen(Effect.never)),
462 ),
463 RpcAuthorization.layer([AuthSourceControlWriteScope]),
464 ),
465 ),
466 );
467 expect(
468 yield* client[WS_METHODS.gitPreparePullRequestThread]({
469 cwd: "/repo",
470 reference: "42",
471 mode: "worktree",
472 threadId: ThreadId.make("thread"),
473 }).pipe(Effect.flip),
474 ).toMatchObject({ requiredPermission: AuthOrchestrationOperateScope });
475 expect(handled).toBe(false);
476 }).pipe(Effect.scoped),
477);
478
479it.effect("separates host file URLs from readable attachment URLs", () =>
480 Effect.gen(function* () {
481 const group = WsRpcGroup.omit(
482 ...[...WsRpcGroup.requests.keys()].filter(
483 (
484 tag,
485 ): tag is Exclude<keyof typeof RPC_REQUIRED_SCOPES, typeof WS_METHODS.assetsCreateUrl> =>
486 tag !== WS_METHODS.assetsCreateUrl,
487 ),
488 );
489 let handled = 0;
490 const client = yield* RpcTest.makeClient(group).pipe(
491 Effect.provide(
492 Layer.mergeAll(
493 group.toLayerHandler(WS_METHODS.assetsCreateUrl, () =>
494 Effect.sync(() => {
495 handled++;
496 return { relativeUrl: "/api/assets/file", expiresAt: 1 };
497 }),
498 ),
499 RpcAuthorization.layer([AuthOrchestrationReadScope]),
500 ),
501 ),
502 );
503 yield* client[WS_METHODS.assetsCreateUrl]({
504 resource: { _tag: "attachment", attachmentId: "image" },
505 });
506 for (const resource of [
507 { _tag: "workspace-file", threadId: ThreadId.make("thread"), path: "file.txt" },
508 { _tag: "media-file", threadId: ThreadId.make("thread"), path: "/repo/image.png" },
509 { _tag: "draft-workspace-file", cwd: "/repo", path: "file.txt" },
510 ] as const) {
511 expect(
512 yield* client[WS_METHODS.assetsCreateUrl]({ resource }).pipe(Effect.flip),
513 ).toMatchObject({
514 requiredScope: AuthOrchestrationReadScope,
515 requiredPermission: AuthFilesystemReadScope,
516 });
517 }
518 expect(handled).toBe(1);
519 }).pipe(Effect.scoped),
520);
521
522it.effect("denies manual cleanup before the handler without settings permission", () =>
523 Effect.gen(function* () {
524 const group = WsRpcGroup.omit(
525 ...[...WsRpcGroup.requests.keys()].filter(
526 (
527 tag,
528 ): tag is Exclude<
529 keyof typeof RPC_REQUIRED_SCOPES,
530 typeof WS_METHODS.serverRunStorageCleanup
531 > => tag !== WS_METHODS.serverRunStorageCleanup,
532 ),
533 );
534 let handled = false;
535 const client = yield* RpcTest.makeClient(group).pipe(
536 Effect.provide(
537 Layer.mergeAll(
538 group.toLayerHandler(WS_METHODS.serverRunStorageCleanup, () =>
539 Effect.sync(() => {
540 handled = true;
541 return {
542 trigger: "manual" as const,
543 startedAt: "2026-10-09T12:00:00.000Z",
544 finishedAt: "2026-10-09T12:00:00.000Z",
545 entries: [],
546 counts: { removed: 0, kept: 0, failed: 0 },
547 omittedCount: 0,
548 bytesFreed: 0,
549 };
550 }),
551 ),
552 RpcAuthorization.layer([AuthOrchestrationReadScope]),
553 ),
554 ),
555 );
556 expect(yield* client[WS_METHODS.serverRunStorageCleanup]({}).pipe(Effect.flip)).toMatchObject({
557 requiredPermission: AuthSettingsWriteScope,
558 });
559 expect(handled).toBe(false);
560 }).pipe(Effect.scoped),
561);