Skip to content

Commit f6faf75

Browse files
fix(ui): land on home when an impersonation starts, and keep MCP agent sets out of the person permission-set picker (#30482)
<!-- claude-projects-attribution: {"github_login":"os-sales"} --> _Requested by **Sales** · [project thread](https://claude.ai/code/project/chan_01D5bWFY3rFzgJcytD5gD4Uu?thread=cmsg_01D5bWFY3rFzgJcytD5gD4UuVbp5Gt3wFjzgF6n64t9ySi)_ domain:ui#6 · os-sales · session_01L6bhgUiuTxKJNYhUNmgKS6 (card thread for seat session_01L6bhgUR8Nu4M7tbtgES7q2) Refs #30212 — rows 1 and 4 only. Rows 2 and 3 stay on the card (see Acceptance notes). Clause-②: no Metadata-protocol: none ## 维护者速读 - 改了什么:在系统设置里开始模拟用户后,直接进入被模拟用户的首页,不再停在「你没有访问此应用的权限」;给人员授权时的权限集下拉不再列出三个「MCP AI 助手」权限集。 - 为什么改:真实用户走查(#30212 第 1、4 行)发现模拟后落到无权限页,且给人授权时能选到只属于 AI 助手的权限集。 - 风险与回滚:只改控制台前端两处,无接口、无元数据变化;回滚即还原本 PR。已存的「人员持有 MCP 集」授权仍照常显示。 - 先看:`ui/packages/app-shell/src/layout/ImpersonationBanner.tsx` 的 `useLandOnImpersonationStart`;`ui/packages/fields/src/widgets/RegistryRefField.tsx` 的 `nonGrantableNames`。 - 两分钟验证:以管理员打开 系统设置 → 用户 → 任一非管理员用户 → 点「模拟用户」并确认,应落到该用户的首页且顶部有模拟横幅;在该用户的「权限集」页签点「新建」,下拉中不应出现「MCP AI 助手」。 - 需要你做的:无,席位落地。 ## What changes - **Row 1, impersonation landing.** `ImpersonationBanner` gains `useLandOnImpersonationStart`: when a session the console already held as an ordinary one becomes impersonated, it navigates to `/`, where `RootLandingRedirect` resolves the landing for the identity now held. A boot into an impersonated session (reload, new tab, deep link) stays on its URL, and stopping an impersonation does not move the page. The navigate is the host's (`useHostNavigation`, supplied by `ConsoleShell`'s `HostNavigationBridge`), so the deployment basename applies and a tree with no host navigate does not move. - **Row 4, agent sets in the person picker.** The `permission-ref` widget (the `permission_set` field of the `sys_user_permission_set` grant form opened from a person's Permission Sets tab) leaves the spec's `MCP_AGENT_PERMISSION_SETS` out of its options, through the same `nonGrantableNames` mechanism `position-ref` uses for the audience anchors. A stored agent set still shows as the row's own option, unflagged. `position-ref` is unchanged. The constant is imported statically from `@objectstack/spec/ai` (no root export); the console's OAuth consent page already imports that module's scope constants statically. - One root changeset, `@object-ui/app-shell` + `@object-ui/fields` patch. No new language-pack key. ## Tests - `ImpersonationBanner.test.tsx` — new top-level describe mounted under `MemoryRouter` + `HostNavigationBridge`: an ordinary session becoming impersonated navigates to `/`; a boot already impersonated stays on its URL; ending the impersonation does not move the page. - `RegistryRefField.audienceAnchors-12200.test.tsx` — new describe: `permission-ref` offers no agent set; a stored agent set still shows by its label beside its name and writes nothing; `position-ref` does not apply the agent list. The old "permission-ref filters nothing" describe is renamed to what it still pins (it does not apply the anchor list). <details><summary>Positive control: the new pins are red on main</summary> Source files restored to the base (`git restore --source=c5800818`), tests kept, markers counted 0, then restored with `git checkout HEAD --` and `git diff HEAD` empty: ``` × offers the sets a person can hold and no agent set × a stored agent set still shows by its label beside its name, unflagged, and writes nothing × navigates to / when an ordinary session becomes impersonated Tests 3 failed | 21 passed (24) ``` The boot-impersonated and stop cases pass on both sides by design (they pin what must not move). </details> <details><summary>Suites on 8b921e1</summary> `pnpm --dir ui exec vitest run` over ImpersonationBanner, RemediationOverlay.impersonation-13208, RegistryRefField.audienceAnchors-12200, RegistryRefField.registryName-7611, RegistryRefCell.readFace: Test Files 5 passed, Tests 49 passed. `turbo run type-check --filter=@object-ui/fields --filter=@object-ui/app-shell`: 69/69 tasks successful (both packages' `tsc --noEmit` and `tsconfig.test.json`). eslint on the four changed files: 0 errors (2 pre-existing `no-explicit-any` warnings in the anchor test's harness). </details> ## Acceptance notes - **Rows not shipped.** Row 2 (bulk-import set-password link lives 60 minutes) is a server credential-lifetime change owned by the server lane, and the card asks the dev to confirm the lifetime with the security seat. Row 3 (mail sender name defaults to `ObjectStack`) lives in `packages/plugins/plugin-email`, server lane. Both were outside this claim (`packages/**` fenced) and stay on #30212. - Row 4 needed no server filter: the agent sets are named by the spec constant `MCP_AGENT_PERMISSION_SETS`. The picker lives in `ui/packages/fields`, not `views/metadata-admin`; the claim was amended to that surface before this PR. - `pnpm check:eager-closure` was not run locally (needs a full console build); CI's bundle job weighs the static `@objectstack/spec/ai` import. ## Verification Live, on a fresh `objectstack dev --seed-admin` showcase stack with the console on Vite, zh-CN, 1440 wide, admin impersonating a fresh member. Before = base source served, after = this branch. Screenshots sit on the never-merged branch `claude/project-thread-cro7hi-evidence`. | | Before (main) | After | |:--|:--|:--| | Row 1, after confirming 「模拟用户」 on the member's Setup record | [lands on 你没有访问此应用的权限](https://github.com/objectstack-ai/objectstack/blob/65737b6c99c82685b06aa84c9102573b43923723/evidence/30212/before-row1-2-landed-zh-1440.png?raw=true) | [lands on the member's home (showcase landing page), banner up](https://github.com/objectstack-ai/objectstack/blob/65737b6c99c82685b06aa84c9102573b43923723/evidence/30212/after-row1-2-landed-zh-1440.png?raw=true) | | Row 4, 权限集 tab → 新建 → 权限集 picker | [lists the three MCP AI 助手 sets](https://github.com/objectstack-ai/objectstack/blob/65737b6c99c82685b06aa84c9102573b43923723/evidence/30212/before-row4-permission-picker-zh-1440.png?raw=true) | [15 sets, no MCP AI 助手](https://github.com/objectstack-ai/objectstack/blob/65737b6c99c82685b06aa84c9102573b43923723/evidence/30212/after-row4-permission-picker-zh-1440.png?raw=true) | Confirm dialog (same on both sides): [before-row1-1-confirm](https://github.com/objectstack-ai/objectstack/blob/65737b6c99c82685b06aa84c9102573b43923723/evidence/30212/before-row1-1-confirm-zh-1440.png?raw=true). Ending the impersonation kept the URL on both sides. <details><summary>Local gates on 8b921e1</summary> `node scripts/pm/dispatch-gates.mjs --commands`: 35 derived; all 35 run, each exit 0 (`pnpm --dir ui exec vitest run scripts/` was killed once by the container's memory limit and re-run alone under the lock: 170 files / 4985 tests passed). `--ran` reconciliation: 35 derived, 35 run, 0 NOT-MEASURED, 0 UNRUN. ui checks: `check:new-line-citations` 0 new, `check:test-path-roots` OK. `check:spec-symbols` NOT MEASURED locally: it exits 1 on 22 stale-ALLOW entries in packages this diff does not touch (cause in this container not established); this diff declares no new type or interface. CI's Type Check UI judges it. </details> --- _Generated by [Claude Code](https://claude.ai/code/session_01L6bhgUiuTxKJNYhUNmgKS6)_ Co-authored-by: Claude <noreply@anthropic.com>
1 parent f4cd4ec commit f6faf75

5 files changed

Lines changed: 220 additions & 14 deletions

File tree

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,12 @@
1+
---
2+
'@object-ui/app-shell': patch
3+
'@object-ui/fields': patch
4+
---
5+
6+
fix(console): starting an impersonation lands on home, and a person's permission-set picker no longer offers the MCP agent sets
7+
8+
Clause-②: no
9+
Metadata-protocol: none
10+
11+
- **Impersonation landing.** An administrator who starts an impersonation from a Setup page used to stay on that page, which the impersonated user usually cannot open, so the console showed "You don't have access to this app". The impersonation banner now navigates to `/` the moment a session the console held as an ordinary one becomes impersonated, and the console resolves the landing for the user it now holds. A console that boots into an impersonated session (a reload, a new tab, a deep link) stays on the URL it was opened at, and ending an impersonation does not move the page. The navigation goes through the host navigate the console supplies, so a deployment's basename applies.
12+
- **Agent sets in the person picker.** The `permission-ref` field (the permission set on a person's grant, `sys_user_permission_set.permission_set`) no longer offers the three MCP agent ceiling sets (`mcp_agent_data_read`, `mcp_agent_data_write`, `mcp_agent_restricted`, the spec's `MCP_AGENT_PERMISSION_SETS`). Those are an AI assistant's own grants, which the runtime puts on the agent principal directly. A grant that already names one still shows it as the row's value, by its label beside its name. `position-ref` is unchanged.

‎ui/packages/app-shell/src/layout/ImpersonationBanner.tsx‎

Lines changed: 43 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -50,12 +50,28 @@
5050
* session impersonated keeps the banner up and states what happened. Silently
5151
* appearing to succeed would be strictly worse than the original defect —
5252
* the operator would go back to ordinary work believing they were themselves.
53+
*
54+
* ## Starting one lands on home
55+
*
56+
* An administrator starts an impersonation from a Setup page, and the user they
57+
* become usually cannot open Setup: left where it was, the console would answer
58+
* the page the administrator was on with "no access to this app". So the moment
59+
* a session this console already held as an ordinary one becomes impersonated,
60+
* the banner navigates to `/`, where the console resolves the landing for the
61+
* identity it now holds. Only that transition moves the page: a console that
62+
* boots into an impersonated session (a reload, a new tab, a deep link) stays on
63+
* the URL it was opened at, and ending an impersonation moves nothing.
64+
*
65+
* The navigate is the host's (`useHostNavigation`), which the console supplies
66+
* from its router so the deployment's basename applies. A tree with no host
67+
* navigate, such as a test that mounts the banner alone, does not move.
5368
*/
5469

55-
import { useEffect, useState } from 'react';
70+
import { useEffect, useRef, useState } from 'react';
5671
import { UserCog } from 'lucide-react';
5772
import { Button } from '@object-ui/components';
58-
import { createAuthenticatedFetch } from '@object-ui/auth';
73+
import { createAuthenticatedFetch, useAuth } from '@object-ui/auth';
74+
import { useHostNavigation } from '@object-ui/react';
5975
import { GetDataResponseSchema } from '@objectstack/spec/api';
6076
import { useObjectTranslation } from '@object-ui/i18n';
6177
import { PERSON_DISPLAY_FIELDS, rowLabel } from '../utils/approverIdentity.js';
@@ -108,6 +124,30 @@ function useAdministratorName(adminId: string | undefined): AdministratorName {
108124
return answer.name;
109125
}
110126

127+
/** What the last render saw: whether a session was resolved, and whom it impersonated. */
128+
interface ObservedSession {
129+
resolved: boolean;
130+
impersonatedBy: string | undefined;
131+
}
132+
133+
/**
134+
* Navigate home when an ordinary session this console held becomes
135+
* impersonated. See the module header for which transitions move the page.
136+
*/
137+
function useLandOnImpersonationStart(impersonatedBy: string | undefined): void {
138+
const { session } = useAuth();
139+
const { navigate } = useHostNavigation();
140+
const resolved = session != null;
141+
const previous = useRef<ObservedSession | null>(null);
142+
143+
useEffect(() => {
144+
const before = previous.current;
145+
previous.current = { resolved, impersonatedBy };
146+
if (!navigate || !impersonatedBy) return;
147+
if (before?.resolved && !before.impersonatedBy) navigate('/');
148+
}, [resolved, impersonatedBy, navigate]);
149+
}
150+
111151
export function ImpersonationBanner() {
112152
const { t } = useObjectTranslation();
113153
// The exit itself — request, token adoption, outcome — is shared with the
@@ -116,6 +156,7 @@ export function ImpersonationBanner() {
116156
const { impersonatedBy, impersonatedName, stop, stopping, stopError, notRestored } =
117157
useImpersonationExit();
118158
const administrator = useAdministratorName(impersonatedBy);
159+
useLandOnImpersonationStart(impersonatedBy);
119160

120161
// Nothing to say on an ordinary session. Below every hook, so the hook order
121162
// is stable across the transition out of impersonation.

‎ui/packages/app-shell/src/layout/__tests__/ImpersonationBanner.test.tsx‎

Lines changed: 84 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -36,7 +36,9 @@ import userEvent from '@testing-library/user-event';
3636
import { AuthProvider, TokenStorage } from '@object-ui/auth';
3737
import type { AuthClient } from '@object-ui/auth';
3838
import { I18nProvider } from '@object-ui/i18n';
39+
import { MemoryRouter, useLocation } from 'react-router-dom';
3940
import { ImpersonationBanner } from '../ImpersonationBanner';
41+
import { HostNavigationBridge } from '../../console/HostNavigationBridge';
4042

4143
const ADMIN_TOKEN = 'admin-token';
4244
const IMPERSONATED_TOKEN = 'impersonated-token';
@@ -306,3 +308,85 @@ describe('ImpersonationBanner (#4467)', () => {
306308
expect(screen.queryByTestId('impersonation-stop-error')).toBeNull();
307309
});
308310
});
311+
312+
/**
313+
* Starting an impersonation from a Setup page lands the administrator on home,
314+
* not on the page they were on, which the impersonated user usually cannot
315+
* open. Mounted the way `ConsoleShell` mounts the banner: under a router, inside
316+
* the console's `HostNavigationBridge`, with the current path read back.
317+
*/
318+
describe('ImpersonationBanner: starting an impersonation lands on home', () => {
319+
const SETUP_PAGE = '/apps/setup/sys_user/usr_dave';
320+
321+
function LocationProbe() {
322+
const location = useLocation();
323+
return <output data-testid="location">{location.pathname}</output>;
324+
}
325+
326+
function renderInConsole(initialPath: string) {
327+
return render(
328+
<MemoryRouter initialEntries={[initialPath]}>
329+
<HostNavigationBridge>
330+
<I18nProvider config={{ defaultLanguage: 'en', detectBrowserLanguage: false }}>
331+
<AuthProvider authUrl="/api/v1/auth" client={tokenBoundClient()}>
332+
<ImpersonationBanner />
333+
<LocationProbe />
334+
</AuthProvider>
335+
</I18nProvider>
336+
</HostNavigationBridge>
337+
</MemoryRouter>,
338+
);
339+
}
340+
341+
const location = () => screen.getByTestId('location').textContent;
342+
343+
beforeEach(() => {
344+
TokenStorage.clear();
345+
stubStopEndpoint((url) =>
346+
url.endsWith(STOP_PATH)
347+
? new Response('{}', { status: 200, headers: { 'set-auth-token': ADMIN_TOKEN } })
348+
: new Response('{}'),
349+
);
350+
});
351+
352+
afterEach(() => {
353+
vi.unstubAllGlobals();
354+
vi.restoreAllMocks();
355+
TokenStorage.clear();
356+
});
357+
358+
it('navigates to / when an ordinary session becomes impersonated', async () => {
359+
TokenStorage.set(ADMIN_TOKEN);
360+
renderInConsole(SETUP_PAGE);
361+
// The administrator's ordinary session has resolved on the Setup page.
362+
await waitFor(() => expect(TokenStorage.get()).toBe(ADMIN_TOKEN));
363+
await new Promise((r) => setTimeout(r, 0));
364+
expect(location()).toBe(SETUP_PAGE);
365+
366+
// The impersonate action's response rotates the token; the provider
367+
// re-resolves identity from it.
368+
TokenStorage.set(IMPERSONATED_TOKEN);
369+
370+
await screen.findByTestId('impersonation-banner');
371+
await waitFor(() => expect(location()).toBe('/'));
372+
});
373+
374+
it('stays on the URL it was opened at when the console boots already impersonated', async () => {
375+
TokenStorage.set(IMPERSONATED_TOKEN);
376+
renderInConsole(SETUP_PAGE);
377+
378+
await screen.findByTestId('impersonation-banner');
379+
await new Promise((r) => setTimeout(r, 0));
380+
expect(location()).toBe(SETUP_PAGE);
381+
});
382+
383+
it('does not move the page when the impersonation ends', async () => {
384+
TokenStorage.set(IMPERSONATED_TOKEN);
385+
renderInConsole(SETUP_PAGE);
386+
387+
await screen.findByTestId('impersonation-banner');
388+
await userEvent.click(screen.getByTestId('impersonation-stop'));
389+
await waitFor(() => expect(screen.queryByTestId('impersonation-banner')).toBeNull());
390+
expect(location()).toBe(SETUP_PAGE);
391+
});
392+
});

‎ui/packages/fields/src/widgets/RegistryRefField.tsx‎

Lines changed: 26 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,7 @@ import { AlertTriangle } from 'lucide-react';
1111
import { Combobox, EmptyValue, Input, cn } from '@object-ui/components';
1212
import { SchemaRendererContext } from '@object-ui/react';
1313
import { AUDIENCE_ANCHOR_POSITIONS } from '@objectstack/spec';
14+
import { MCP_AGENT_PERMISSION_SETS } from '@objectstack/spec/ai';
1415
import type { FieldWidgetComponentProps } from './types.js';
1516
import { registryLister, type RegistryItemHeader } from './registryCatalog.js';
1617
import { toDomProps } from './toDomProps.js';
@@ -56,10 +57,13 @@ import { useFieldTranslation } from './useFieldTranslation.js';
5657
* grant cannot name an audience anchor — `everyone` or `guest` (ADR-0090
5758
* D5/D9), held implicitly by every authenticated or anonymous principal — and
5859
* the server refuses a `sys_user_position` row that does. So `position-ref`
59-
* leaves the anchors out of its options ({@link nonGrantableNames}). A stored
60-
* anchor is still the row's value: it stays on screen as its own option, by its
61-
* label beside its name, and it is not flagged "not found", because the
62-
* registry does list it.
60+
* leaves the anchors out of its options ({@link nonGrantableNames}). A
61+
* permission grant names a set for a PERSON, and the MCP agent ceiling sets
62+
* (ADR-0090 D10) are an AI assistant's own grants, which the runtime injects
63+
* onto the agent principal directly rather than through any grant row; so
64+
* `permission-ref` leaves them out. A stored name left out is still the row's value: it stays on screen as
65+
* its own option, by its label beside its name, and it is not flagged "not
66+
* found", because the registry does list it.
6367
*
6468
* ⛔ No arm writes anything but a name the person picked or typed: no label,
6569
* no id, no first-item default.
@@ -83,27 +87,40 @@ function failureMessage(err: unknown): string {
8387
}
8488

8589
const AUDIENCE_ANCHORS: ReadonlySet<string> = new Set(AUDIENCE_ANCHOR_POSITIONS);
90+
const AGENT_CEILING_SETS: ReadonlySet<string> = new Set(MCP_AGENT_PERMISSION_SETS);
8691
const NO_EXCLUSIONS: ReadonlySet<string> = new Set();
8792

8893
/**
8994
* The names of a registry type that a grant cannot name, which the picker
9095
* therefore does not offer. For `position`, the audience anchors: the list is
9196
* the spec's own `AUDIENCE_ANCHOR_POSITIONS`, read and never copied, so an
92-
* anchor the spec adds is left out with no change here. Every other type
93-
* excludes nothing: a permission set named like an anchor is an ordinary set.
97+
* anchor the spec adds is left out with no change here. For `permission`, the
98+
* MCP agent ceiling sets, read the same way from the spec's
99+
* `MCP_AGENT_PERMISSION_SETS`. Every other type excludes nothing, and each list
100+
* applies to its own type only: a permission set named like an anchor is an
101+
* ordinary set.
94102
*
95-
* Imported statically from the spec's ROOT entry, as `@object-ui/auth` imports
96-
* `BUILTIN_MEMBERSHIP_ROLES`, and not from `@objectstack/spec/identity`. The
103+
* The anchors are imported statically from the spec's ROOT entry, as
104+
* `@object-ui/auth` imports `BUILTIN_MEMBERSHIP_ROLES`, and not from
105+
* `@objectstack/spec/identity`. The
97106
* console builds this widget into an eager chunk (`ui-components`), so a
98107
* static `/identity` import would carry that subpath's whole module into every
99108
* page load; `pnpm check:eager-closure` is the instrument that weighs it. A
100109
* lazy `import()` keeps that module out of the eager closure, but out of
101110
* `pnpm check:spec-floors` too: that gate judges the `@objectstack/spec` floor
102111
* from static imports only, and on the publish lane it reports a subpath it
103112
* cannot judge as a finding.
113+
*
114+
* The agent sets have no root export, so they come from `@objectstack/spec/ai`,
115+
* statically for the same `check:spec-floors` reason. That subpath's
116+
* `mcp.zod` module already reaches the eager closure through the console's
117+
* OAuth consent page, which imports its scope constants statically;
118+
* `pnpm check:eager-closure` weighs what this import adds.
104119
*/
105120
function nonGrantableNames(type: string): ReadonlySet<string> {
106-
return type === 'position' ? AUDIENCE_ANCHORS : NO_EXCLUSIONS;
121+
if (type === 'position') return AUDIENCE_ANCHORS;
122+
if (type === 'permission') return AGENT_CEILING_SETS;
123+
return NO_EXCLUSIONS;
107124
}
108125

109126
/** A settled answer, with the inputs it answers for. */

‎ui/packages/fields/src/widgets/__tests__/RegistryRefField.audienceAnchors-12200.test.tsx‎

Lines changed: 55 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -17,14 +17,18 @@
1717
* - a registry that lists nothing BUT anchors offers no picker;
1818
* - a stored anchor still shows, by its label beside its name, unflagged, and
1919
* is not offered when the row holds another value;
20-
* - `permission-ref` filters nothing: a permission set named like an anchor is
21-
* an ordinary set.
20+
* - `permission-ref` does not apply the anchor list: a permission set named
21+
* like an anchor is an ordinary set;
22+
* - `permission-ref` leaves out exactly the spec's `MCP_AGENT_PERMISSION_SETS`,
23+
* the AI assistant ceiling sets a person is never granted, and a stored one
24+
* still shows as the row's value.
2225
*/
2326
import { describe, it, expect, vi, afterEach } from 'vitest';
2427
import React from 'react';
2528
import { render, screen, waitFor, fireEvent, cleanup } from '@testing-library/react';
2629
import '@testing-library/jest-dom';
2730
import { AUDIENCE_ANCHOR_POSITIONS } from '@objectstack/spec';
31+
import { MCP_AGENT_PERMISSION_SETS } from '@objectstack/spec/ai';
2832
import { PositionRefField, PermissionRefField } from '../RegistryRefField';
2933

3034
/** The assignable positions, by label beside name, as the picker reads them. */
@@ -118,7 +122,7 @@ describe('a stored anchor still shows, and is not offered as a fresh choice (obj
118122
});
119123
});
120124

121-
describe('permission-ref filters nothing (objectui#12200)', () => {
125+
describe('permission-ref does not apply the anchor list (objectui#12200)', () => {
122126
it('lists, and writes, a permission set named like an anchor', async () => {
123127
const sets = lister([
124128
{ name: 'everyone', label: 'Everyone Baseline' },
@@ -132,3 +136,51 @@ describe('permission-ref filters nothing (objectui#12200)', () => {
132136
expect(onChange).toHaveBeenCalledWith('everyone');
133137
});
134138
});
139+
140+
describe('permission-ref leaves the MCP agent sets out of what it offers', () => {
141+
/** The person-assignable sets, by label beside name, as the picker reads them. */
142+
const PERSON_SETS = ['Sales User (sales_user)', 'Standard User (standard_user)'];
143+
144+
const SETS = [
145+
{ name: 'standard_user', label: 'Standard User' },
146+
{ name: 'sales_user', label: 'Sales User' },
147+
// The agent ceiling sets, as the permission registry serves them: listed
148+
// beside the sets a person can hold.
149+
{ name: 'mcp_agent_data_read', label: 'MCP Agent — Read Only' },
150+
{ name: 'mcp_agent_data_write', label: 'MCP Agent — Read & Write' },
151+
{ name: 'mcp_agent_restricted', label: 'MCP Agent — No Data Access' },
152+
];
153+
154+
it('offers the sets a person can hold and no agent set', async () => {
155+
// The names the registry serves are the ones the spec publishes.
156+
expect([...MCP_AGENT_PERMISSION_SETS].sort()).toEqual(
157+
['mcp_agent_data_read', 'mcp_agent_data_write', 'mcp_agent_restricted'],
158+
);
159+
160+
mount(PermissionRefField, { dataSource: lister(SETS) });
161+
162+
expect(await openOptions()).toEqual(PERSON_SETS);
163+
for (const set of MCP_AGENT_PERMISSION_SETS) {
164+
expect(screen.queryByText(new RegExp(`\\(${set}\\)$`))).toBeNull();
165+
}
166+
});
167+
168+
it('a stored agent set still shows by its label beside its name, unflagged, and writes nothing', async () => {
169+
const { onChange } = mount(PermissionRefField, { dataSource: lister(SETS), value: 'mcp_agent_data_read' });
170+
171+
const trigger = await screen.findByRole('combobox');
172+
expect(trigger).toHaveTextContent('MCP Agent — Read Only (mcp_agent_data_read)');
173+
expect(trigger).not.toHaveAttribute('data-not-found');
174+
expect(onChange).not.toHaveBeenCalled();
175+
// Its own option, as the row's value; the other agent sets are not offered.
176+
expect(await openOptions()).toEqual(['MCP Agent — Read Only (mcp_agent_data_read)', ...PERSON_SETS]);
177+
});
178+
179+
it('position-ref does not apply the agent list: a position named like an agent set is ordinary', async () => {
180+
mount(PositionRefField, {
181+
dataSource: lister([{ name: 'mcp_agent_restricted', label: 'Restricted Desk' }, { name: 'auditor' }]),
182+
});
183+
184+
expect(await openOptions()).toEqual(['auditor', 'Restricted Desk (mcp_agent_restricted)']);
185+
});
186+
});

0 commit comments

Comments
 (0)