@peers-app/peers-ui 0.22.0 → 0.23.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (31) hide show
  1. package/dist/components/access-level-field.d.ts +10 -0
  2. package/dist/components/access-level-field.js +28 -0
  3. package/dist/components/access-level-field.test.d.ts +1 -0
  4. package/dist/components/access-level-field.test.js +28 -0
  5. package/dist/components/message-logs/message-logs.d.ts +2 -1
  6. package/dist/components/message-logs/message-logs.js +44 -2
  7. package/dist/components/message-logs/tool-access-approval-card.d.ts +13 -0
  8. package/dist/components/message-logs/tool-access-approval-card.js +81 -0
  9. package/dist/components/message-logs/tool-access-approval-card.test.d.ts +1 -0
  10. package/dist/components/message-logs/tool-access-approval-card.test.js +102 -0
  11. package/dist/screens/assistants/assistant-info.d.ts +1 -0
  12. package/dist/screens/assistants/assistant-info.js +3 -1
  13. package/dist/screens/assistants/assistant-list.d.ts +1 -0
  14. package/dist/screens/assistants/assistant-list.js +8 -1
  15. package/dist/screens/tools/tool-access-ipc.d.ts +5 -0
  16. package/dist/screens/tools/tool-access-ipc.js +65 -0
  17. package/dist/screens/tools/tool-access-ipc.test.d.ts +1 -0
  18. package/dist/screens/tools/tool-access-ipc.test.js +40 -0
  19. package/dist/screens/tools/tool-info.d.ts +1 -0
  20. package/dist/screens/tools/tool-info.js +3 -1
  21. package/package.json +3 -3
  22. package/src/components/access-level-field.test.tsx +32 -0
  23. package/src/components/access-level-field.tsx +68 -0
  24. package/src/components/message-logs/message-logs.tsx +58 -1
  25. package/src/components/message-logs/tool-access-approval-card.test.tsx +127 -0
  26. package/src/components/message-logs/tool-access-approval-card.tsx +178 -0
  27. package/src/screens/assistants/assistant-info.tsx +4 -0
  28. package/src/screens/assistants/assistant-list.tsx +29 -2
  29. package/src/screens/tools/tool-access-ipc.test.ts +55 -0
  30. package/src/screens/tools/tool-access-ipc.ts +88 -0
  31. package/src/screens/tools/tool-info.tsx +4 -0
@@ -0,0 +1,68 @@
1
+ import {
2
+ getAccessLevelLabel,
3
+ listAccessLevelPresets,
4
+ type Observable,
5
+ resolveAccessLevel,
6
+ } from "@peers-app/peers-sdk";
7
+ import { useObservable } from "../hooks";
8
+ import { Input } from "./input";
9
+
10
+ interface AccessLevelFieldProps {
11
+ value: number | Observable<number | undefined>;
12
+ disabled?: boolean;
13
+ }
14
+
15
+ /**
16
+ * Named-preset plus custom 0–100 editor for tool and Assistant access levels.
17
+ */
18
+ export function AccessLevelField(props: AccessLevelFieldProps) {
19
+ const [value, setValue] = useObservable(props.value);
20
+ const level = resolveAccessLevel(value);
21
+ const presets = listAccessLevelPresets();
22
+ const isNamed = presets.some((preset) => preset.value === level);
23
+
24
+ return (
25
+ <div className="mb-3">
26
+ <small>Access level:</small>
27
+ <div className="d-flex gap-2 align-items-center">
28
+ <select
29
+ className="form-select form-select-sm"
30
+ disabled={props.disabled}
31
+ value={isNamed ? String(level) : "custom"}
32
+ onChange={(evt) => {
33
+ if (evt.target.value === "custom") {
34
+ setValue(level);
35
+ return;
36
+ }
37
+ setValue(Number(evt.target.value));
38
+ }}
39
+ >
40
+ {presets.map((preset) => (
41
+ <option key={preset.value} value={preset.value}>
42
+ {preset.label} ({preset.value})
43
+ </option>
44
+ ))}
45
+ <option value="custom">Custom</option>
46
+ </select>
47
+ <Input
48
+ type="number"
49
+ min={0}
50
+ max={100}
51
+ disabled={props.disabled}
52
+ className="form-control form-control-sm"
53
+ style={{ maxWidth: 90 }}
54
+ value={level}
55
+ onChange={(evt) => {
56
+ const next = Number((evt.target as HTMLInputElement).value);
57
+ if (Number.isInteger(next) && next >= 0 && next <= 100) {
58
+ setValue(next);
59
+ }
60
+ }}
61
+ />
62
+ </div>
63
+ <div className="form-text">
64
+ {getAccessLevelLabel(level)} ({level}). Unclassified records default to Self (100).
65
+ </div>
66
+ </div>
67
+ );
68
+ }
@@ -1,11 +1,14 @@
1
1
  import {
2
2
  Assistants,
3
3
  formatMention,
4
+ getUserContext,
5
+ type IToolAccessApproval,
4
6
  type IWorkflowLog,
5
7
  type IWorkflowRun,
6
8
  type Observable,
7
9
  observable,
8
10
  runWorkflowToolId,
11
+ ToolAccessApprovals,
9
12
  Tools,
10
13
  WorkflowLogs,
11
14
  WorkflowRuns,
@@ -16,11 +19,13 @@ import moment from "moment";
16
19
  import { useCallback, useEffect, useState } from "react";
17
20
  import { useObservable, useObservableState, usePromise } from "../../hooks";
18
21
  import { MarkdownWithMentions } from "../markdown-with-mentions";
22
+ import { ToolAccessApprovalCard } from "./tool-access-approval-card";
19
23
 
20
24
  interface MessageLogsProps {
21
25
  messageId: string;
22
26
  }
23
27
 
28
+ /** Workflow logs and tool-access approval cards for a thread message. */
24
29
  export const MessageLogs = ({ messageId }: MessageLogsProps) => {
25
30
  const workflowRunIds = useObservableState<string[]>([]);
26
31
  const showLogs = useObservableState(false);
@@ -114,7 +119,7 @@ export const MessageLogs = ({ messageId }: MessageLogsProps) => {
114
119
  }, [messageId, workflowRunIds]);
115
120
 
116
121
  if (!workflowRunIds().length) {
117
- return false;
122
+ return <ThreadAccessApprovals messageId={messageId} />;
118
123
  }
119
124
 
120
125
  return (
@@ -122,6 +127,7 @@ export const MessageLogs = ({ messageId }: MessageLogsProps) => {
122
127
  {/* this space prevents the logs and replies indicator from being included when selecting and copying a message text */}
123
128
  &nbsp;
124
129
  <ShowLogsLink logIds={logIds} showLogs={showLogs} runs={runs} />
130
+ <ThreadAccessApprovals messageId={messageId} />
125
131
  {showLogs() && (
126
132
  <div>
127
133
  <AllMessageLogs logIds={logIds} runs={runs} />
@@ -131,6 +137,57 @@ export const MessageLogs = ({ messageId }: MessageLogsProps) => {
131
137
  );
132
138
  };
133
139
 
140
+ const ThreadAccessApprovals = ({ messageId }: { messageId: string }) => {
141
+ const [approvals] = useState(() => observable([] as IToolAccessApproval[]));
142
+ useObservable(approvals);
143
+
144
+ useEffect(() => {
145
+ let disposed = false;
146
+ let unsubscribe: (() => void) | undefined;
147
+
148
+ function setRows(rows: IToolAccessApproval[]) {
149
+ approvals(rows.sort((a, b) => a.createdAt.getTime() - b.createdAt.getTime()));
150
+ }
151
+
152
+ async function subscribeToContext() {
153
+ const userContext = await getUserContext();
154
+ if (disposed) return;
155
+ const dataContext = userContext.defaultDataContext();
156
+ const approvalsTable = ToolAccessApprovals(dataContext);
157
+ const sub = approvalsTable.dataChanged.subscribe((evt) => {
158
+ const row = evt.dataObject;
159
+ if (disposed || row.threadMessageId !== messageId) return;
160
+ const next = approvals().filter(
161
+ (item) => item.toolAccessApprovalId !== row.toolAccessApprovalId,
162
+ );
163
+ if (evt.op !== "delete") next.push(row);
164
+ setRows(next);
165
+ });
166
+ unsubscribe = () => sub.unsubscribe();
167
+ const rows = await approvalsTable.list({ threadMessageId: messageId });
168
+ if (!disposed) setRows(rows);
169
+ }
170
+
171
+ void subscribeToContext();
172
+ return () => {
173
+ disposed = true;
174
+ unsubscribe?.();
175
+ };
176
+ }, [messageId, approvals]);
177
+
178
+ if (!approvals().length) {
179
+ return false;
180
+ }
181
+
182
+ return (
183
+ <div>
184
+ {approvals().map((approval) => (
185
+ <ToolAccessApprovalCard key={approval.toolAccessApprovalId} approval={approval} />
186
+ ))}
187
+ </div>
188
+ );
189
+ };
190
+
134
191
  const ShowLogsLink = (props: {
135
192
  showLogs: Observable<boolean>;
136
193
  logIds: Observable<string[]>;
@@ -0,0 +1,127 @@
1
+ import {
2
+ AccessLevel,
3
+ ToolAccessApprovalScope,
4
+ ToolAccessApprovalStatus,
5
+ } from "@peers-app/peers-sdk";
6
+ import { fireEvent, render, screen, waitFor } from "@testing-library/react";
7
+ import { ToolAccessApprovalCard } from "./tool-access-approval-card";
8
+
9
+ const approve = jest.fn(async () => ({ result: { status: "executed" } }));
10
+ const reject = jest.fn(async () => ({ result: { status: "rejected" } }));
11
+ const getDataContext = jest.fn((dataContextId: string) => ({ dataContextId }));
12
+ const getToolAccessControlConsumer = jest.fn(async (_dataContextId: string) => ({
13
+ tools: { approve, reject },
14
+ }));
15
+
16
+ jest.mock("../../screens/tools/tool-access-ipc", () => ({
17
+ getToolAccessControlConsumer: (dataContextId: string) =>
18
+ getToolAccessControlConsumer(dataContextId),
19
+ }));
20
+
21
+ jest.mock("@peers-app/peers-sdk", () => {
22
+ const actual = jest.requireActual("@peers-app/peers-sdk");
23
+ return {
24
+ ...actual,
25
+ getUserContext: async () => ({
26
+ getDataContext,
27
+ }),
28
+ getCurrentUserAccessLevel: async () => actual.AccessLevel.Self,
29
+ };
30
+ });
31
+
32
+ const pending = {
33
+ toolAccessApprovalId: "00testapproval0000000001",
34
+ userId: "00testuser000000000000001",
35
+ assistantId: "00testassistant000000001",
36
+ toolId: "00testtooladmin00000000",
37
+ toolName: "queryDatabase",
38
+ dataContextId: "00testdatacontext00000001",
39
+ argsHash: "hash",
40
+ argsPreview: '{"sql":"select 1"}',
41
+ frozenArgs: { sql: "select 1" },
42
+ requiredAccessLevel: AccessLevel.Owner,
43
+ scope: ToolAccessApprovalScope.once,
44
+ status: ToolAccessApprovalStatus.pending,
45
+ nonce: "nonce-from-table",
46
+ expiresAt: new Date(Date.now() + 60_000),
47
+ createdAt: new Date(),
48
+ };
49
+
50
+ describe("ToolAccessApprovalCard", () => {
51
+ beforeEach(() => {
52
+ approve.mockClear();
53
+ reject.mockClear();
54
+ getDataContext.mockClear();
55
+ getToolAccessControlConsumer.mockClear();
56
+ });
57
+
58
+ it("renders a pending card with actions", () => {
59
+ render(<ToolAccessApprovalCard approval={pending as any} />);
60
+ expect(screen.getByText("Approval required")).toBeInTheDocument();
61
+ expect(screen.getByText("queryDatabase")).toBeInTheDocument();
62
+ expect(screen.getByText(/Owner \(80\)/)).toBeInTheDocument();
63
+ expect(screen.getByRole("button", { name: "Allow once" })).toBeInTheDocument();
64
+ expect(screen.getByRole("button", { name: "Allow in thread (15 min)" })).toBeInTheDocument();
65
+ expect(
66
+ screen.getByRole("button", { name: "Allow in this context (15 min)" }),
67
+ ).toBeInTheDocument();
68
+ expect(screen.getByRole("button", { name: "Deny" })).toBeInTheDocument();
69
+ });
70
+
71
+ it("evaluates the user role in the approval context", async () => {
72
+ render(<ToolAccessApprovalCard approval={pending as any} />);
73
+ await waitFor(() => {
74
+ expect(getDataContext).toHaveBeenCalledWith(pending.dataContextId);
75
+ });
76
+ });
77
+
78
+ it("approves once using the table nonce", async () => {
79
+ render(<ToolAccessApprovalCard approval={pending as any} />);
80
+ fireEvent.click(screen.getByRole("button", { name: "Allow once" }));
81
+ await waitFor(() => {
82
+ expect(getToolAccessControlConsumer).toHaveBeenCalledWith(pending.dataContextId);
83
+ expect(approve).toHaveBeenCalledWith({
84
+ approvalId: pending.toolAccessApprovalId,
85
+ nonce: "nonce-from-table",
86
+ scope: "once",
87
+ });
88
+ });
89
+ });
90
+
91
+ it("denies using the table nonce", async () => {
92
+ render(<ToolAccessApprovalCard approval={pending as any} />);
93
+ fireEvent.click(screen.getByRole("button", { name: "Deny" }));
94
+ await waitFor(() => {
95
+ expect(reject).toHaveBeenCalledWith({
96
+ approvalId: pending.toolAccessApprovalId,
97
+ nonce: "nonce-from-table",
98
+ });
99
+ });
100
+ });
101
+
102
+ it("hides empty argument previews", () => {
103
+ render(
104
+ <ToolAccessApprovalCard
105
+ approval={{ ...pending, argsPreview: "{}", frozenArgs: {} } as any}
106
+ />,
107
+ );
108
+ expect(screen.queryByText("{}")).not.toBeInTheDocument();
109
+ });
110
+
111
+ it("shows a decided card without actions", () => {
112
+ render(
113
+ <ToolAccessApprovalCard
114
+ approval={
115
+ {
116
+ ...pending,
117
+ status: ToolAccessApprovalStatus.rejected,
118
+ resultSummary: "Rejected by user",
119
+ } as any
120
+ }
121
+ />,
122
+ );
123
+ expect(screen.getByText("Access rejected")).toBeInTheDocument();
124
+ expect(screen.getByText("Rejected by user")).toBeInTheDocument();
125
+ expect(screen.queryByRole("button", { name: "Allow once" })).not.toBeInTheDocument();
126
+ });
127
+ });
@@ -0,0 +1,178 @@
1
+ import {
2
+ getAccessLevelLabel,
3
+ getCurrentUserAccessLevel,
4
+ getUserContext,
5
+ type IToolAccessApproval,
6
+ recordAccessLevel,
7
+ TOOL_ACCESS_GRANT_TTL_MS,
8
+ ToolAccessApprovalStatus,
9
+ } from "@peers-app/peers-sdk";
10
+ import { useState } from "react";
11
+ import { usePromise } from "../../hooks";
12
+ import { getToolAccessControlConsumer } from "../../screens/tools/tool-access-ipc";
13
+
14
+ const GRANT_TTL_MINUTES = TOOL_ACCESS_GRANT_TTL_MS / (60 * 1000);
15
+
16
+ /** Props for {@link ToolAccessApprovalCard}. */
17
+ export interface ToolAccessApprovalCardProps {
18
+ /** Approval or grant row to render. */
19
+ approval: IToolAccessApproval;
20
+ }
21
+
22
+ /**
23
+ * Inline thread card for a pending or decided tool-access approval.
24
+ *
25
+ * Uses Bootstrap theme tokens (`bg-body-*`, `text-body-*`) so the card follows
26
+ * light and dark `data-bs-theme` instead of a hardcoded `bg-light` panel.
27
+ */
28
+ export function ToolAccessApprovalCard(props: ToolAccessApprovalCardProps) {
29
+ const { approval } = props;
30
+ const [busy, setBusy] = useState(false);
31
+ const [error, setError] = useState<string | undefined>();
32
+ const userLevel = usePromise(
33
+ async () => {
34
+ const userContext = await getUserContext();
35
+ return getCurrentUserAccessLevel(userContext.getDataContext(approval.dataContextId));
36
+ },
37
+ recordAccessLevel({ accessLevel: 100 }),
38
+ [approval.dataContextId],
39
+ );
40
+
41
+ const required = approval.requiredAccessLevel;
42
+ const current = userLevel ?? 100;
43
+ const pending = approval.status === ToolAccessApprovalStatus.pending;
44
+ const expired = approval.expiresAt.getTime() <= Date.now();
45
+ const argsPreview = meaningfulArgsPreview(approval.argsPreview);
46
+ const statusStyle = statusAppearance(approval.status, expired);
47
+
48
+ async function decide(scope?: "once" | "thread" | "context", reject = false) {
49
+ setBusy(true);
50
+ setError(undefined);
51
+ try {
52
+ const consumer = await getToolAccessControlConsumer(approval.dataContextId);
53
+ if (reject) {
54
+ await consumer.tools.reject({
55
+ approvalId: approval.toolAccessApprovalId,
56
+ nonce: approval.nonce,
57
+ });
58
+ } else {
59
+ await consumer.tools.approve({
60
+ approvalId: approval.toolAccessApprovalId,
61
+ nonce: approval.nonce,
62
+ scope,
63
+ });
64
+ }
65
+ } catch (err) {
66
+ setError(String(err));
67
+ } finally {
68
+ setBusy(false);
69
+ }
70
+ }
71
+
72
+ return (
73
+ <div
74
+ className="card my-2 bg-body-secondary border-secondary-subtle"
75
+ data-testid="tool-access-approval-card"
76
+ >
77
+ <div className="card-body py-2 px-3">
78
+ <div className="d-flex align-items-start gap-2">
79
+ <i
80
+ className={`bi ${statusStyle.icon} ${statusStyle.iconClass} mt-1`}
81
+ aria-hidden="true"
82
+ />
83
+ <div className="flex-grow-1 min-w-0">
84
+ <div className="fw-semibold text-body">
85
+ {pending ? "Approval required" : `Access ${approval.status}`}
86
+ </div>
87
+ <div className="small text-body">
88
+ <code className="text-body">{approval.toolName}</code> needs{" "}
89
+ {getAccessLevelLabel(required)} ({required}). You have {getAccessLevelLabel(current)}{" "}
90
+ ({current}).
91
+ </div>
92
+ {argsPreview && (
93
+ <pre
94
+ className="small mb-2 mt-2 p-2 bg-body-tertiary border rounded text-body"
95
+ style={{ whiteSpace: "pre-wrap" }}
96
+ >
97
+ {argsPreview}
98
+ </pre>
99
+ )}
100
+ <div className="text-body-secondary small mt-1">
101
+ Expires {approval.expiresAt.toLocaleTimeString()}
102
+ {expired ? " (expired)" : ""}
103
+ </div>
104
+ {approval.resultSummary && (
105
+ <div className="small text-body mt-1">{approval.resultSummary}</div>
106
+ )}
107
+ {error && <div className="text-danger small mt-1">{error}</div>}
108
+ {pending && !expired && (
109
+ <div className="d-flex flex-wrap gap-1 mt-2">
110
+ <button
111
+ type="button"
112
+ className="btn btn-sm btn-primary"
113
+ disabled={busy}
114
+ onClick={() => decide("once")}
115
+ >
116
+ Allow once
117
+ </button>
118
+ <button
119
+ type="button"
120
+ className="btn btn-sm btn-outline-secondary"
121
+ disabled={busy}
122
+ onClick={() => decide("thread")}
123
+ >
124
+ Allow in thread ({GRANT_TTL_MINUTES} min)
125
+ </button>
126
+ <button
127
+ type="button"
128
+ className="btn btn-sm btn-outline-secondary"
129
+ disabled={busy}
130
+ onClick={() => decide("context")}
131
+ >
132
+ Allow in this context ({GRANT_TTL_MINUTES} min)
133
+ </button>
134
+ <button
135
+ type="button"
136
+ className="btn btn-sm btn-outline-danger"
137
+ disabled={busy}
138
+ onClick={() => decide(undefined, true)}
139
+ >
140
+ Deny
141
+ </button>
142
+ </div>
143
+ )}
144
+ </div>
145
+ </div>
146
+ </div>
147
+ </div>
148
+ );
149
+ }
150
+
151
+ function meaningfulArgsPreview(preview: string | undefined): string | undefined {
152
+ const trimmed = preview?.trim();
153
+ if (!trimmed || trimmed === "{}" || trimmed === "[]") {
154
+ return undefined;
155
+ }
156
+ return trimmed;
157
+ }
158
+
159
+ function statusAppearance(
160
+ status: ToolAccessApprovalStatus,
161
+ expired: boolean,
162
+ ): { icon: string; iconClass: string } {
163
+ if (status === ToolAccessApprovalStatus.pending && expired) {
164
+ return { icon: "bi-shield-x", iconClass: "text-body-secondary" };
165
+ }
166
+ switch (status) {
167
+ case ToolAccessApprovalStatus.pending:
168
+ return { icon: "bi-shield-lock", iconClass: "text-warning" };
169
+ case ToolAccessApprovalStatus.approved:
170
+ case ToolAccessApprovalStatus.executed:
171
+ return { icon: "bi-shield-check", iconClass: "text-success" };
172
+ case ToolAccessApprovalStatus.rejected:
173
+ case ToolAccessApprovalStatus.failed:
174
+ return { icon: "bi-shield-x", iconClass: "text-danger" };
175
+ default:
176
+ return { icon: "bi-shield", iconClass: "text-body-secondary" };
177
+ }
178
+ }
@@ -1,4 +1,5 @@
1
1
  import type { IAssistant, IDoc, Observable } from "@peers-app/peers-sdk";
2
+ import { AccessLevelField } from "../../components/access-level-field";
2
3
  import { Input } from "../../components/input";
3
4
  import { MarkdownEditorInline } from "../../components/markdown-editor/editor-inline";
4
5
 
@@ -10,6 +11,7 @@ interface AssistantInfoProps {
10
11
  onSetGroupPrimary: (checked: boolean) => void;
11
12
  }
12
13
 
14
+ /** Info tab for an Assistant, including its access-level policy. */
13
15
  export const AssistantInfo = (props: AssistantInfoProps) => {
14
16
  const {
15
17
  assistant,
@@ -53,6 +55,8 @@ export const AssistantInfo = (props: AssistantInfoProps) => {
53
55
  />
54
56
  </div>
55
57
 
58
+ <AccessLevelField value={assistant.qs.accessLevel} />
59
+
56
60
  <div className="mt-2">
57
61
  <small>Description:</small>
58
62
  <MarkdownEditorInline value={assistant.qs.description as Observable<string>} />
@@ -1,9 +1,14 @@
1
1
  import {
2
2
  Assistants,
3
+ canInvokeAssistant,
4
+ getAccessLevelLabel,
5
+ getCurrentUserAccessLevel,
6
+ getUserContext,
3
7
  type IAssistant,
4
8
  type ICursorIterable,
5
9
  newid,
6
10
  observable,
11
+ recordAccessLevel,
7
12
  } from "@peers-app/peers-sdk";
8
13
  import type React from "react";
9
14
  import { useEffect, useState } from "react";
@@ -12,14 +17,19 @@ import { LazyList } from "../../components/lazy-list";
12
17
  import { LoadingIndicator } from "../../components/loading-indicator";
13
18
  import { MarkdownWithMentions } from "../../components/markdown-with-mentions";
14
19
  import { isDesktop, mainContentPath } from "../../globals";
15
- import { useObservable } from "../../hooks";
20
+ import { useObservable, usePromise } from "../../hooks";
16
21
 
22
+ /** Searchable Assistant list with access-level visibility. */
17
23
  export const AssistantList = () => {
18
24
  const [searchTextObs] = useState(() => observable(""));
19
25
  const [searchText] = useObservable(searchTextObs);
20
26
 
21
27
  const [cursorObs] = useState(() => observable<ICursorIterable<IAssistant> | undefined>());
22
28
  const [cursorId] = useState(() => observable(newid()));
29
+ const userLevel = usePromise(async () => {
30
+ const userContext = await getUserContext();
31
+ return getCurrentUserAccessLevel(userContext.defaultDataContext());
32
+ }, 100);
23
33
 
24
34
  async function newCursor() {
25
35
  let cursor: ICursorIterable<IAssistant>;
@@ -90,12 +100,29 @@ export const AssistantList = () => {
90
100
  scrollThreshold={0.6}
91
101
  renderItems={(assistants) => {
92
102
  return assistants.map((assistant) => {
103
+ const required = recordAccessLevel(assistant);
104
+ const allowed = canInvokeAssistant(userLevel ?? 100, required);
93
105
  return (
94
- <div key={assistant.assistantId} className="container-fluid pb-4">
106
+ <div
107
+ key={assistant.assistantId}
108
+ className="container-fluid pb-4"
109
+ style={allowed ? undefined : { opacity: 0.65 }}
110
+ >
95
111
  <i className="bi bi-person-fill-gear"></i>&nbsp;
96
112
  <a href={`#assistants/${assistant.assistantId}`}>{assistant.name}</a>
113
+ {!allowed && (
114
+ <span className="badge text-bg-secondary ms-2">
115
+ Requires {getAccessLevelLabel(required)} ({required})
116
+ </span>
117
+ )}
97
118
  <div style={{ paddingLeft: "20px" }}>
98
119
  <MarkdownWithMentions content={assistant.description ?? ""} />
120
+ {!allowed && (
121
+ <div className="small text-muted">
122
+ You cannot invoke this Assistant with your current access (
123
+ {getAccessLevelLabel(userLevel ?? 100)}).
124
+ </div>
125
+ )}
99
126
  </div>
100
127
  </div>
101
128
  );
@@ -0,0 +1,55 @@
1
+ import {
2
+ CONTRACT_REQUEST_CHANNEL,
3
+ inProcessTransportPair,
4
+ newid,
5
+ registerHostContractTransport,
6
+ } from "@peers-app/peers-sdk";
7
+ import { getToolAccessControlConsumer } from "./tool-access-ipc";
8
+
9
+ function setupHost() {
10
+ const calls: Array<{
11
+ dataContextId?: string;
12
+ member: string;
13
+ name: string;
14
+ operation: string;
15
+ }> = [];
16
+ const [consumerTransport, providerTransport] = inProcessTransportPair();
17
+ providerTransport.on(CONTRACT_REQUEST_CHANNEL, (call) => {
18
+ calls.push(call);
19
+ if (call.member === "tool" && call.name === "approve") {
20
+ return { result: { status: "executed" } };
21
+ }
22
+ return undefined;
23
+ });
24
+ return {
25
+ calls,
26
+ dispose: registerHostContractTransport(consumerTransport),
27
+ };
28
+ }
29
+
30
+ describe("Tool Access Control host consumers", () => {
31
+ it("caches per context and scopes calls to the requested context", async () => {
32
+ const host = setupHost();
33
+ const groupContextId = newid();
34
+ const personalContextId = newid();
35
+ const groupConsumer = await getToolAccessControlConsumer(groupContextId);
36
+
37
+ expect(await getToolAccessControlConsumer(groupContextId)).toBe(groupConsumer);
38
+ expect(await getToolAccessControlConsumer(personalContextId)).not.toBe(groupConsumer);
39
+
40
+ await groupConsumer.tools.approve({
41
+ approvalId: newid(),
42
+ nonce: newid(),
43
+ scope: "once",
44
+ });
45
+ expect(host.calls).toContainEqual(
46
+ expect.objectContaining({
47
+ dataContextId: groupContextId,
48
+ member: "tool",
49
+ name: "approve",
50
+ }),
51
+ );
52
+
53
+ host.dispose();
54
+ });
55
+ });