Skip to content
Source
Sign inSign up

RpcAuthorization.test.ts

474 lines16.8 KB

1import {
2 AuthEnvironmentMaintainScope,
3 AuthDiagnosticsReadScope,
4 AuthFilesystemReadScope,
5 AuthProvidersManageScope,
6 AuthSettingsWriteScope,
7 DEFAULT_SERVER_SETTINGS,
8 ProviderDriverKind,
9 ProviderInstanceId,
10 SecretProviderId,
11 ThreadId,
12 AuthOrchestrationOperateScope,
13 AuthOrchestrationReadScope,
14 AuthSourceControlWriteScope,
15 AuthTerminalReadScope,
16 AuthTerminalOperateScope,
17 WS_METHODS,
18 WsRpcGroup,
19} from "@t3tools/contracts";
20import { describe, expect, it } from "@effect/vitest";
21import * as Effect from "effect/Effect";
22import * as Layer from "effect/Layer";
23import * as Stream from "effect/Stream";
24import * as RpcTest from "effect/rpc/RpcTest";
25​
26import {
27 RPC_REQUIRED_SCOPES,
28 requiredScopeForRpcMethod,
29 requiredScopeForDeviceList,
30} from "./RpcAuthorization.ts";
31import * as RpcAuthorization from "./RpcAuthorization.ts";
32​
33describe("RPC authorization scopes", () => {
34 it("declares exactly one scope for every RPC in the server group", () => {
35 expect(new Set(Object.keys(RPC_REQUIRED_SCOPES))).toEqual(new Set(WsRpcGroup.requests.keys()));
36 });
37​
38 it("keeps webhook delivery logs, which hold request bodies, behind operate scope", () => {
39 for (const method of [
40 WS_METHODS.scheduledTasksListWebhookDeliveries,
41 WS_METHODS.scheduledTasksGetWebhookDelivery,
42 WS_METHODS.scheduledTasksRotateWebhookToken,
43 ]) {
44 expect(requiredScopeForRpcMethod(method)).toBe(AuthOrchestrationOperateScope);
45 }
46 });
47​
48 it("keeps triage, which seeds an agent that reads logs and the database, behind operate scope", () => {
49 expect(requiredScopeForRpcMethod(WS_METHODS.serverPrepareTriage)).toBe(
50 AuthOrchestrationOperateScope,
51 );
52 });
53​
54 it("separates passive terminal observation from operations that can change a shell", () => {
55 for (const method of [WS_METHODS.terminalObserve, WS_METHODS.subscribeTerminalMetadata]) {
56 expect(requiredScopeForRpcMethod(method)).toBe(AuthTerminalReadScope);
57 }
58 for (const method of [
59 WS_METHODS.terminalAttach,
60 WS_METHODS.terminalOpen,
61 WS_METHODS.terminalWrite,
62 WS_METHODS.terminalResize,
63 WS_METHODS.terminalClear,
64 WS_METHODS.terminalClose,
65 WS_METHODS.previewStopLocalServer,
66 ]) {
67 expect(requiredScopeForRpcMethod(method)).toBe(AuthTerminalOperateScope);
68 }
69 });
70​
71 it("lets thread readers observe and refresh checkout branches", () => {
72 for (const method of [WS_METHODS.subscribeVcsBranch, WS_METHODS.vcsRefreshBranch]) {
73 expect(requiredScopeForRpcMethod(method)).toBe(AuthOrchestrationReadScope);
74 }
75 });
76​
77 it("rejects unknown RPC method names", () => {
78 for (const method of ["server.notRegistered", "toString", "constructor"]) {
79 expect(() => requiredScopeForRpcMethod(method)).toThrow(
80 `RPC method ${method} has no declared authorization scope.`,
81 );
82 }
83 });
84});
85​
86it("requires operate permission for host retry while preserving read-only listing", () => {
87 expect(requiredScopeForDeviceList({})).toBe(AuthOrchestrationReadScope);
88 expect(requiredScopeForDeviceList({ retryHostId: "remote-host" })).toBe(
89 AuthOrchestrationOperateScope,
90 );
91});
92​
93it("requires operate permission for tool updates even alongside a read-only check", () => {
94 expect(requiredScopeForDeviceList({ updateTool: "agent", inspectOnly: true })).toBe(
95 AuthOrchestrationOperateScope,
96 );
97 expect(requiredScopeForDeviceList({ updateTool: "hub" })).toBe(AuthOrchestrationOperateScope);
98});
99​
100describe("RPC scope middleware", () => {
101 const tested = [WS_METHODS.serverProbe, WS_METHODS.serverRetryResourceTelemetry] as const;
102 const group = WsRpcGroup.omit(
103 ...[...WsRpcGroup.requests.keys()].filter(
104 (tag): tag is Exclude<keyof typeof RPC_REQUIRED_SCOPES, (typeof tested)[number]> =>
105 !(tested as ReadonlyArray<string>).includes(tag),
106 ),
107 );
108​
109 it.effect.each([
110 { scopes: [AuthOrchestrationReadScope], missing: AuthEnvironmentMaintainScope },
111 {
112 scopes: [AuthOrchestrationReadScope, AuthEnvironmentMaintainScope],
113 missing: AuthDiagnosticsReadScope,
114 },
115 ])("rejects telemetry retry without $missing before its handler runs", ({ scopes, missing }) =>
116 Effect.gen(function* () {
117 const handled: Array<string> = [];
118 const client = yield* RpcTest.makeClient(group).pipe(
119 Effect.provide(
120 Layer.mergeAll(
121 group.toLayerHandler(WS_METHODS.serverProbe, () => Effect.succeed({})),
122 group.toLayerHandler(WS_METHODS.serverRetryResourceTelemetry, () =>
123 Effect.sync(() => handled.push("retry")).pipe(Effect.andThen(Effect.never)),
124 ),
125 RpcAuthorization.layer(scopes),
126 ),
127 ),
128 );
129​
130 expect(yield* client[WS_METHODS.serverProbe]({})).toEqual({});
131 expect(
132 yield* client[WS_METHODS.serverRetryResourceTelemetry]({}).pipe(Effect.flip),
133 ).toMatchObject({
134 _tag: "EnvironmentAuthorizationError",
135 requiredPermission: missing,
136 });
137 expect(handled).toEqual([]);
138 }),
139 );
140});
141​
142it.effect.each([WS_METHODS.subscribeVcsBranch, WS_METHODS.subscribeVcsStatus] as const)(
143 "enforces read permission on stream %s",
144 (method) =>
145 Effect.gen(function* () {
146 const group = WsRpcGroup.omit(
147 ...[...WsRpcGroup.requests.keys()].filter(
148 (
149 tag,
150 ): tag is Exclude<
151 keyof typeof RPC_REQUIRED_SCOPES,
152 typeof WS_METHODS.subscribeVcsBranch | typeof WS_METHODS.subscribeVcsStatus
153 > => tag !== WS_METHODS.subscribeVcsBranch && tag !== WS_METHODS.subscribeVcsStatus,
154 ),
155 );
156 let handled = 0;
157 const handlers = Layer.mergeAll(
158 group.toLayerHandler(WS_METHODS.subscribeVcsBranch, () =>
159 Stream.fromEffect(
160 Effect.sync(() => {
161 handled++;
162 return { refName: "main" };
163 }),
164 ),
165 ),
166 group.toLayerHandler(WS_METHODS.subscribeVcsStatus, () =>
167 Stream.fromEffect(
168 Effect.sync(() => {
169 handled++;
170 }),
171 ).pipe(Stream.drain),
172 ),
173 );
174 const denied = yield* RpcTest.makeClient(group).pipe(
175 Effect.provide(Layer.merge(handlers, RpcAuthorization.layer([]))),
176 );
177 expect(
178 yield* Stream.runDrain(
179 method === WS_METHODS.subscribeVcsBranch
180 ? denied[method]({ cwd: "/repo" }).pipe(Stream.drain)
181 : denied[method]({ cwd: "/repo" }).pipe(Stream.drain),
182 ).pipe(Effect.flip),
183 ).toMatchObject({ requiredPermission: AuthOrchestrationReadScope });
184 expect(handled).toBe(0);
185 const granted = yield* RpcTest.makeClient(group).pipe(
186 Effect.provide(Layer.merge(handlers, RpcAuthorization.layer([AuthOrchestrationReadScope]))),
187 );
188 yield* Stream.runDrain(
189 method === WS_METHODS.subscribeVcsBranch
190 ? granted[method]({ cwd: "/repo" }).pipe(Stream.drain)
191 : granted[method]({ cwd: "/repo" }).pipe(Stream.drain),
192 );
193 expect(handled).toBe(1);
194 }),
195);
196​
197describe("settings mutation authorization", () => {
198 const group = WsRpcGroup.omit(
199 ...[...WsRpcGroup.requests.keys()].filter(
200 (
201 tag,
202 ): tag is Exclude<keyof typeof RPC_REQUIRED_SCOPES, typeof WS_METHODS.serverUpdateSettings> =>
203 tag !== WS_METHODS.serverUpdateSettings,
204 ),
205 );
206 const providerInstanceMutation = {
207 operation: "remove" as const,
208 instanceId: ProviderInstanceId.make("codex_work"),
209 };
210​
211 it.effect("allows provider-only mutations while denying mixed settings without their grant", () =>
212 Effect.gen(function* () {
213 let handled = 0;
214 const client = yield* RpcTest.makeClient(group).pipe(
215 Effect.provide(
216 Layer.mergeAll(
217 group.toLayerHandler(WS_METHODS.serverUpdateSettings, () =>
218 Effect.sync(() => {
219 handled++;
220 return DEFAULT_SERVER_SETTINGS;
221 }),
222 ),
223 RpcAuthorization.layer([AuthProvidersManageScope]),
224 ),
225 ),
226 );
227 yield* client[WS_METHODS.serverUpdateSettings]({ patch: {}, providerInstanceMutation });
228 expect(handled).toBe(1);
229 yield* client[WS_METHODS.serverUpdateSettings]({
230 patch: {},
231 providerInstanceMutation: {
232 operation: "upsert",
233 instanceId: ProviderInstanceId.make("codex_work"),
234 instance: {
235 driver: ProviderDriverKind.make("codex"),
236 environment: [
237 {
238 name: "OPENAI_API_KEY",
239 sensitive: true,
240 value: {
241 provider: SecretProviderId.make("personal"),
242 reference: "op://Private/OpenAI/credential",
243 },
244 },
245 ],
246 },
247 },
248 });
249 expect(handled).toBe(2);
250 expect(
251 yield* client[WS_METHODS.serverUpdateSettings]({
252 patch: { defaultRuntimeMode: "full-access" },
253 providerInstanceMutation,
254 }).pipe(Effect.flip),
255 ).toMatchObject({ requiredPermission: AuthSettingsWriteScope });
256 // Removing a secret provider clears settings fields that read from it.
257 expect(
258 yield* client[WS_METHODS.serverUpdateSettings]({
259 patch: { secretProviders: { [SecretProviderId.make("personal")]: null } },
260 }).pipe(Effect.flip),
261 ).toMatchObject({ requiredPermission: AuthSettingsWriteScope });
262 expect(handled).toBe(2);
263 }),
264 );
265​
266 it.effect("does not let a settings grant create or remove providers or secret providers", () =>
267 Effect.gen(function* () {
268 let handled = false;
269 const client = yield* RpcTest.makeClient(group).pipe(
270 Effect.provide(
271 Layer.mergeAll(
272 group.toLayerHandler(WS_METHODS.serverUpdateSettings, () =>
273 Effect.sync(() => {
274 handled = true;
275 return DEFAULT_SERVER_SETTINGS;
276 }),
277 ),
278 RpcAuthorization.layer([AuthSettingsWriteScope]),
279 ),
280 ),
281 );
282 expect(
283 yield* client[WS_METHODS.serverUpdateSettings]({
284 patch: {},
285 providerInstanceMutation,
286 }).pipe(Effect.flip),
287 ).toMatchObject({ requiredPermission: AuthProvidersManageScope });
288 expect(
289 yield* client[WS_METHODS.serverUpdateSettings]({
290 patch: { secretProviders: { [SecretProviderId.make("personal")]: null } },
291 }).pipe(Effect.flip),
292 ).toMatchObject({ requiredPermission: AuthProvidersManageScope });
293 expect(handled).toBe(false);
294 }),
295 );
296});
297​
298it.effect("requires task permission before attaching a prepared worktree to a thread", () =>
299 Effect.gen(function* () {
300 const group = WsRpcGroup.omit(
301 ...[...WsRpcGroup.requests.keys()].filter(
302 (
303 tag,
304 ): tag is Exclude<
305 keyof typeof RPC_REQUIRED_SCOPES,
306 typeof WS_METHODS.gitPreparePullRequestThread
307 > => tag !== WS_METHODS.gitPreparePullRequestThread,
308 ),
309 );
310 let handled = false;
311 const client = yield* RpcTest.makeClient(group).pipe(
312 Effect.provide(
313 Layer.mergeAll(
314 group.toLayerHandler(WS_METHODS.gitPreparePullRequestThread, () =>
315 Effect.sync(() => {
316 handled = true;
317 }).pipe(Effect.andThen(Effect.never)),
318 ),
319 RpcAuthorization.layer([AuthSourceControlWriteScope]),
320 ),
321 ),
322 );
323 expect(
324 yield* client[WS_METHODS.gitPreparePullRequestThread]({
325 cwd: "/repo",
326 reference: "42",
327 mode: "worktree",
328 threadId: ThreadId.make("thread"),
329 }).pipe(Effect.flip),
330 ).toMatchObject({ requiredPermission: AuthOrchestrationOperateScope });
331 expect(handled).toBe(false);
332 }),
333);
334​
335describe("thread context authorization", () => {
336 const group = WsRpcGroup.omit(
337 ...[...WsRpcGroup.requests.keys()].filter(
338 (
339 tag,
340 ): tag is Exclude<keyof typeof RPC_REQUIRED_SCOPES, typeof WS_METHODS.gitRunStackedAction> =>
341 tag !== WS_METHODS.gitRunStackedAction,
342 ),
343 );
344​
345 it.effect.each(["commit", "create_pr", "commit_push_pr"] as const)(
346 "%s needs thread read permission only when generating a PR from a thread",
347 (action) =>
348 Effect.gen(function* () {
349 const handled: string[] = [];
350 const handler = group.toLayerHandler(WS_METHODS.gitRunStackedAction, ({ actionId }) =>
351 Stream.fromEffect(Effect.sync(() => handled.push(actionId))).pipe(Stream.drain),
352 );
353 const client = yield* RpcTest.makeClient(group).pipe(
354 Effect.provide(
355 Layer.merge(handler, RpcAuthorization.layer([AuthSourceControlWriteScope])),
356 ),
357 );
358 const input = { actionId: "without-thread", cwd: "/repo", action };
359 const withThread = { ...input, actionId: "with-thread", threadId: ThreadId.make("thread") };
360 yield* Stream.runDrain(client[WS_METHODS.gitRunStackedAction](input));
361 if (action === "create_pr" || action === "commit_push_pr") {
362 expect(
363 yield* Stream.runDrain(client[WS_METHODS.gitRunStackedAction](withThread)).pipe(
364 Effect.flip,
365 ),
366 ).toMatchObject({ requiredPermission: AuthOrchestrationReadScope });
367 expect(handled).toEqual(["without-thread"]);
368 } else {
369 yield* Stream.runDrain(client[WS_METHODS.gitRunStackedAction](withThread));
370 expect(handled).toEqual(["without-thread", "with-thread"]);
371 }
372 const reader = yield* RpcTest.makeClient(group).pipe(
373 Effect.provide(
374 Layer.merge(
375 handler,
376 RpcAuthorization.layer([AuthSourceControlWriteScope, AuthOrchestrationReadScope]),
377 ),
378 ),
379 );
380 yield* Stream.runDrain(
381 reader[WS_METHODS.gitRunStackedAction]({ ...withThread, actionId: "allowed" }),
382 );
383 expect(handled.at(-1)).toBe("allowed");
384 }),
385 );
386});
387​
388it.effect("separates host file URLs from readable attachment URLs", () =>
389 Effect.gen(function* () {
390 const group = WsRpcGroup.omit(
391 ...[...WsRpcGroup.requests.keys()].filter(
392 (
393 tag,
394 ): tag is Exclude<keyof typeof RPC_REQUIRED_SCOPES, typeof WS_METHODS.assetsCreateUrl> =>
395 tag !== WS_METHODS.assetsCreateUrl,
396 ),
397 );
398 let handled = 0;
399 const client = yield* RpcTest.makeClient(group).pipe(
400 Effect.provide(
401 Layer.mergeAll(
402 group.toLayerHandler(WS_METHODS.assetsCreateUrl, () =>
403 Effect.sync(() => {
404 handled++;
405 return { relativeUrl: "/api/assets/file", expiresAt: 1 };
406 }),
407 ),
408 RpcAuthorization.layer([AuthOrchestrationReadScope]),
409 ),
410 ),
411 );
412 yield* client[WS_METHODS.assetsCreateUrl]({
413 resource: { _tag: "attachment", attachmentId: "image" },
414 });
415 yield* client[WS_METHODS.assetsCreateUrl]({
416 resource: { _tag: "native-app-icon", app: { _tag: "app-id", appId: "com.apple.calculator" } },
417 });
418 for (const resource of [
419 { _tag: "workspace-file", threadId: ThreadId.make("thread"), path: "file.txt" },
420 { _tag: "media-file", threadId: ThreadId.make("thread"), path: "/repo/image.png" },
421 { _tag: "draft-workspace-file", cwd: "/repo", path: "file.txt" },
422 { _tag: "native-app-icon", app: { _tag: "app-path", path: "/private/Secret.app" } },
423 ] as const) {
424 expect(
425 yield* client[WS_METHODS.assetsCreateUrl]({ resource }).pipe(Effect.flip),
426 ).toMatchObject({
427 requiredScope: AuthOrchestrationReadScope,
428 requiredPermission: AuthFilesystemReadScope,
429 });
430 }
431 expect(handled).toBe(2);
432 }),
433);
434​
435it.effect("denies manual cleanup before the handler without settings permission", () =>
436 Effect.gen(function* () {
437 const group = WsRpcGroup.omit(
438 ...[...WsRpcGroup.requests.keys()].filter(
439 (
440 tag,
441 ): tag is Exclude<
442 keyof typeof RPC_REQUIRED_SCOPES,
443 typeof WS_METHODS.serverRunStorageCleanup
444 > => tag !== WS_METHODS.serverRunStorageCleanup,
445 ),
446 );
447 let handled = false;
448 const client = yield* RpcTest.makeClient(group).pipe(
449 Effect.provide(
450 Layer.mergeAll(
451 group.toLayerHandler(WS_METHODS.serverRunStorageCleanup, () =>
452 Effect.sync(() => {
453 handled = true;
454 return {
455 trigger: "manual" as const,
456 startedAt: "2026-10-09T12:00:00.000Z",
457 finishedAt: "2026-10-09T12:00:00.000Z",
458 entries: [],
459 counts: { removed: 0, kept: 0, failed: 0 },
460 omittedCount: 0,
461 bytesFreed: 0,
462 };
463 }),
464 ),
465 RpcAuthorization.layer([AuthOrchestrationReadScope]),
466 ),
467 ),
468 );
469 expect(yield* client[WS_METHODS.serverRunStorageCleanup]({}).pipe(Effect.flip)).toMatchObject({
470 requiredPermission: AuthSettingsWriteScope,
471 });
472 expect(handled).toBe(false);
473 }),
474);