@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.
- package/dist/components/access-level-field.d.ts +10 -0
- package/dist/components/access-level-field.js +28 -0
- package/dist/components/access-level-field.test.d.ts +1 -0
- package/dist/components/access-level-field.test.js +28 -0
- package/dist/components/message-logs/message-logs.d.ts +2 -1
- package/dist/components/message-logs/message-logs.js +44 -2
- package/dist/components/message-logs/tool-access-approval-card.d.ts +13 -0
- package/dist/components/message-logs/tool-access-approval-card.js +81 -0
- package/dist/components/message-logs/tool-access-approval-card.test.d.ts +1 -0
- package/dist/components/message-logs/tool-access-approval-card.test.js +102 -0
- package/dist/screens/assistants/assistant-info.d.ts +1 -0
- package/dist/screens/assistants/assistant-info.js +3 -1
- package/dist/screens/assistants/assistant-list.d.ts +1 -0
- package/dist/screens/assistants/assistant-list.js +8 -1
- package/dist/screens/tools/tool-access-ipc.d.ts +5 -0
- package/dist/screens/tools/tool-access-ipc.js +65 -0
- package/dist/screens/tools/tool-access-ipc.test.d.ts +1 -0
- package/dist/screens/tools/tool-access-ipc.test.js +40 -0
- package/dist/screens/tools/tool-info.d.ts +1 -0
- package/dist/screens/tools/tool-info.js +3 -1
- package/package.json +3 -3
- package/src/components/access-level-field.test.tsx +32 -0
- package/src/components/access-level-field.tsx +68 -0
- package/src/components/message-logs/message-logs.tsx +58 -1
- package/src/components/message-logs/tool-access-approval-card.test.tsx +127 -0
- package/src/components/message-logs/tool-access-approval-card.tsx +178 -0
- package/src/screens/assistants/assistant-info.tsx +4 -0
- package/src/screens/assistants/assistant-list.tsx +29 -2
- package/src/screens/tools/tool-access-ipc.test.ts +55 -0
- package/src/screens/tools/tool-access-ipc.ts +88 -0
- 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
|
|
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
|
|
|
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
|
|
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>
|
|
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
|
+
});
|