Skip to content

Commit 688524b

Browse files
t3dotggclaude
andcommitted
feat(web): derive remote provider access from real session scopes
Non-primary environments previously assumed operate access, so a narrow-scope credential was offered edit controls whose writes the environment RPC would reject. Each environment's /api/auth/session now answers what this client may change, and sessions without orchestration:operate see the full provider layout greyed out and inert behind a limited-permissions notice instead of a separate stripped view. Co-Authored-By: Claude Fable 5 <[email protected]>
1 parent f43e38e commit 688524b

6 files changed

Lines changed: 476 additions & 283 deletions

File tree

‎apps/web/src/components/settings/ProviderSettingsPanel.environment.test.tsx‎

Lines changed: 37 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -81,6 +81,10 @@ vi.mock("../../environments/primary", () => ({
8181
usePrimarySessionState: () => ({ data: null, error: null, isPending: false, refresh: vi.fn() }),
8282
}));
8383

84+
vi.mock("../../state/session", () => ({
85+
useEnvironmentSessionState: () => ({ data: null, hasError: false, isPending: true }),
86+
}));
87+
8488
import { EnvironmentProviderSettings } from "./ProviderSettingsPanel";
8589

8690
const environmentId = EnvironmentId.make("remote-device");
@@ -112,11 +116,14 @@ function provider(): ServerProvider {
112116
};
113117
}
114118

115-
function renderPanel(): ReactElement<Record<string, unknown>> {
119+
function renderPanel(options?: {
120+
readonly readOnly?: boolean;
121+
}): ReactElement<Record<string, unknown>> {
116122
hooks.beginRender();
117123
return EnvironmentProviderSettings({
118124
environmentId,
119125
environmentLabel: "Remote device",
126+
...(options?.readOnly === undefined ? {} : { readOnly: options.readOnly }),
120127
}) as ReactElement<Record<string, unknown>>;
121128
}
122129

@@ -171,6 +178,35 @@ describe("EnvironmentProviderSettings routing", () => {
171178
});
172179
});
173180

181+
it("renders the provider layout inert with a limited-permissions notice when read only", () => {
182+
atoms.providers = [provider()];
183+
const panel = renderPanel({ readOnly: true });
184+
185+
const inertWrapper = visitElements(panel, (element) => element.props.inert === true);
186+
expect(inertWrapper).not.toBeNull();
187+
const providerCard = visitElements(panel, (element) => element.props.instanceId === codexId);
188+
expect(providerCard).not.toBeNull();
189+
190+
const notice = visitElements(panel, (element) => element.props.title === "Limited permissions");
191+
expect(notice).not.toBeNull();
192+
193+
expect(
194+
visitElements(panel, (element) => element.props["aria-label"] === "Add provider instance"),
195+
).toBeNull();
196+
expect(
197+
visitElements(panel, (element) => element.props["aria-label"] === "Refresh provider status"),
198+
).toBeNull();
199+
});
200+
201+
it("keeps the editable layout interactive when not read only", () => {
202+
atoms.providers = [provider()];
203+
const panel = renderPanel();
204+
expect(visitElements(panel, (element) => element.props.inert === true)).toBeNull();
205+
expect(
206+
visitElements(panel, (element) => element.props.title === "Limited permissions"),
207+
).toBeNull();
208+
});
209+
174210
it("deletes and resets provider configuration without erasing shared preferences", () => {
175211
settingsState.value = {
176212
...DEFAULT_UNIFIED_SETTINGS,

‎apps/web/src/components/settings/ProviderSettingsPanel.logic.test.ts‎

Lines changed: 55 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@ import {
55
buildProviderEnvironmentOptions,
66
classifyProviderEnvironmentAccess,
77
resolvePrimaryOperateAccess,
8+
resolveRemoteOperateAccess,
89
resolveSelectedProviderEnvironmentId,
910
} from "./ProviderSettingsPanel.logic";
1011

@@ -203,3 +204,57 @@ describe("primary operate access", () => {
203204
).toBe("granted");
204205
});
205206
});
207+
208+
describe("remote operate access", () => {
209+
it("derives access from the environment session's granted scopes", () => {
210+
expect(
211+
resolveRemoteOperateAccess({
212+
session: { authenticated: true, scopes: [AuthOrchestrationOperateScope] },
213+
isPending: false,
214+
hasError: false,
215+
}),
216+
).toBe("granted");
217+
expect(
218+
resolveRemoteOperateAccess({
219+
session: { authenticated: true, scopes: ["orchestration:read"] },
220+
isPending: false,
221+
hasError: false,
222+
}),
223+
).toBe("denied");
224+
expect(
225+
resolveRemoteOperateAccess({
226+
session: { authenticated: false },
227+
isPending: false,
228+
hasError: false,
229+
}),
230+
).toBe("denied");
231+
});
232+
233+
it("reports pending before the first session resolve, then keeps cached data", () => {
234+
expect(resolveRemoteOperateAccess({ session: null, isPending: true, hasError: false })).toBe(
235+
"pending",
236+
);
237+
expect(
238+
resolveRemoteOperateAccess({
239+
session: { authenticated: true, scopes: [AuthOrchestrationOperateScope] },
240+
isPending: true,
241+
hasError: false,
242+
}),
243+
).toBe("granted");
244+
});
245+
246+
it("stays optimistic when the session fetch fails or an older server omits scopes", () => {
247+
// Transport failures and pre-scope-reporting servers are not permission
248+
// decisions; the environment RPC layer still rejects unauthorized writes.
249+
expect(resolveRemoteOperateAccess({ session: null, isPending: false, hasError: true })).toBe(
250+
"granted",
251+
);
252+
expect(
253+
resolveRemoteOperateAccess({
254+
session: { authenticated: true },
255+
isPending: false,
256+
hasError: false,
257+
}),
258+
).toBe("granted");
259+
});
260+
});

‎apps/web/src/components/settings/ProviderSettingsPanel.logic.ts‎

Lines changed: 51 additions & 18 deletions
Original file line numberDiff line numberDiff line change
@@ -64,28 +64,26 @@ export type ProviderEnvironmentAccess =
6464
export type ProviderOperateAccess = "granted" | "denied" | "pending";
6565

6666
/**
67-
* Resolve whether the session may reconfigure providers on an environment.
67+
* Resolve operate access from an environment's `/api/auth/session` answer.
6868
*
69-
* Only the primary environment exposes its own granted scopes to the client
70-
* today, so remote sessions are optimistic: the connection brokers request
71-
* `orchestration:operate`, and the environment RPC layer stays authoritative if
72-
* a narrower credential was minted.
69+
* Cached session data wins over an in-flight revalidation. The session atoms
70+
* are SWR-backed, so they report `isPending` on every background refresh;
71+
* treating that as unknown would flip a working panel back to loading and
72+
* discard in-progress edits.
7373
*
74-
* Cached session data wins over an in-flight revalidation. The session atom is
75-
* SWR-backed, so it reports `isPending` on every background refresh; treating
76-
* that as unknown would flip a working panel back to loading and discard
77-
* in-progress edits.
74+
* `missingScopesAccess` decides the case where the session resolved but did
75+
* not report scopes: the primary serves the web app itself so its server
76+
* always reports them (absence means denial), while a remote device may run an
77+
* older server version that predates scope reporting, where denial would lock
78+
* out a legitimate session. The environment RPC layer stays authoritative
79+
* either way.
7880
*/
79-
export function resolvePrimaryOperateAccess(input: {
80-
readonly isPrimary: boolean;
81-
readonly hasDesktopBridge: boolean;
81+
function resolveSessionOperateAccess(input: {
8282
readonly session: Pick<AuthSessionState, "authenticated" | "scopes"> | null;
8383
readonly isPending: boolean;
8484
readonly hasError: boolean;
85+
readonly missingScopesAccess: "granted" | "denied";
8586
}): ProviderOperateAccess {
86-
if (!input.isPrimary || input.hasDesktopBridge) {
87-
return "granted";
88-
}
8987
if (input.session === null) {
9088
if (input.isPending) {
9189
return "pending";
@@ -98,9 +96,44 @@ export function resolvePrimaryOperateAccess(input: {
9896
if (!input.session.authenticated) {
9997
return "denied";
10098
}
101-
return (input.session.scopes ?? []).includes(AuthOrchestrationOperateScope)
102-
? "granted"
103-
: "denied";
99+
if (input.session.scopes === undefined) {
100+
return input.missingScopesAccess;
101+
}
102+
return input.session.scopes.includes(AuthOrchestrationOperateScope) ? "granted" : "denied";
103+
}
104+
105+
/** Operate access for the primary environment's own browser session. */
106+
export function resolvePrimaryOperateAccess(input: {
107+
readonly isPrimary: boolean;
108+
readonly hasDesktopBridge: boolean;
109+
readonly session: Pick<AuthSessionState, "authenticated" | "scopes"> | null;
110+
readonly isPending: boolean;
111+
readonly hasError: boolean;
112+
}): ProviderOperateAccess {
113+
if (!input.isPrimary || input.hasDesktopBridge) {
114+
return "granted";
115+
}
116+
return resolveSessionOperateAccess({
117+
session: input.session,
118+
isPending: input.isPending,
119+
hasError: input.hasError,
120+
missingScopesAccess: "denied",
121+
});
122+
}
123+
124+
/**
125+
* Operate access for a non-primary environment, derived from the scopes its
126+
* `/api/auth/session` endpoint reports for this client's credential.
127+
*/
128+
export function resolveRemoteOperateAccess(input: {
129+
readonly session: Pick<AuthSessionState, "authenticated" | "scopes"> | null;
130+
readonly isPending: boolean;
131+
readonly hasError: boolean;
132+
}): ProviderOperateAccess {
133+
return resolveSessionOperateAccess({
134+
...input,
135+
missingScopesAccess: "granted",
136+
});
104137
}
105138

106139
export function classifyProviderEnvironmentAccess(input: {

0 commit comments

Comments
 (0)