@xpufx/paseo-twofado 0.1.0
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/LICENSE +21 -0
- package/README.md +62 -0
- package/client/approvals.tsx +1525 -0
- package/client/error-boundary.tsx +28 -0
- package/client/vendor/paseo-plugin-helper/command-center.ts +43 -0
- package/client/vendor/paseo-plugin-helper/components/AboutSection.tsx +493 -0
- package/client/vendor/paseo-plugin-helper/components/AttentionBeacon.tsx +250 -0
- package/client/vendor/paseo-plugin-helper/components/Badge.tsx +156 -0
- package/client/vendor/paseo-plugin-helper/components/Button.tsx +178 -0
- package/client/vendor/paseo-plugin-helper/components/Card.tsx +225 -0
- package/client/vendor/paseo-plugin-helper/components/CodeBlock.tsx +196 -0
- package/client/vendor/paseo-plugin-helper/components/Collapsible.tsx +277 -0
- package/client/vendor/paseo-plugin-helper/components/CommandBox.tsx +172 -0
- package/client/vendor/paseo-plugin-helper/components/CopyButton.tsx +180 -0
- package/client/vendor/paseo-plugin-helper/components/DataTable.tsx +200 -0
- package/client/vendor/paseo-plugin-helper/components/EmptyState.tsx +97 -0
- package/client/vendor/paseo-plugin-helper/components/HighlightedText.tsx +70 -0
- package/client/vendor/paseo-plugin-helper/components/InlineButton.tsx +73 -0
- package/client/vendor/paseo-plugin-helper/components/KeyValue.tsx +446 -0
- package/client/vendor/paseo-plugin-helper/components/MetricGauge.tsx +247 -0
- package/client/vendor/paseo-plugin-helper/components/ProgressBar.tsx +117 -0
- package/client/vendor/paseo-plugin-helper/components/Responsive.tsx +53 -0
- package/client/vendor/paseo-plugin-helper/components/SearchInput.tsx +118 -0
- package/client/vendor/paseo-plugin-helper/components/SectionHeader.tsx +80 -0
- package/client/vendor/paseo-plugin-helper/components/Select.tsx +215 -0
- package/client/vendor/paseo-plugin-helper/components/StatusDot.tsx +80 -0
- package/client/vendor/paseo-plugin-helper/components/Tabs.tsx +319 -0
- package/client/vendor/paseo-plugin-helper/components/TextInput.tsx +150 -0
- package/client/vendor/paseo-plugin-helper/components/Toggle.tsx +163 -0
- package/client/vendor/paseo-plugin-helper/components/TruncatedText.tsx +157 -0
- package/client/vendor/paseo-plugin-helper/components/index.ts +25 -0
- package/client/vendor/paseo-plugin-helper/custom-pills.tsx +224 -0
- package/client/vendor/paseo-plugin-helper/forge-icon.tsx +79 -0
- package/client/vendor/paseo-plugin-helper/host.ts +277 -0
- package/client/vendor/paseo-plugin-helper/icon.tsx +39 -0
- package/client/vendor/paseo-plugin-helper/index.ts +28 -0
- package/client/vendor/paseo-plugin-helper/layout/ActionBar.tsx +49 -0
- package/client/vendor/paseo-plugin-helper/layout/FormRow.tsx +103 -0
- package/client/vendor/paseo-plugin-helper/layout/Grid.tsx +65 -0
- package/client/vendor/paseo-plugin-helper/layout/ModalBody.tsx +378 -0
- package/client/vendor/paseo-plugin-helper/layout/ModalContent.tsx +49 -0
- package/client/vendor/paseo-plugin-helper/layout/Row.tsx +39 -0
- package/client/vendor/paseo-plugin-helper/layout/Stack.tsx +39 -0
- package/client/vendor/paseo-plugin-helper/layout/index.ts +7 -0
- package/client/vendor/paseo-plugin-helper/panel.tsx +81 -0
- package/client/vendor/paseo-plugin-helper/pill.tsx +884 -0
- package/client/vendor/paseo-plugin-helper/query-refresh.ts +79 -0
- package/client/vendor/paseo-plugin-helper/query.ts +66 -0
- package/client/vendor/paseo-plugin-helper/settings-screen.tsx +372 -0
- package/client/vendor/paseo-plugin-helper/settings.ts +181 -0
- package/client/vendor/paseo-plugin-helper/shared-settings.ts +46 -0
- package/client/vendor/paseo-plugin-helper/snapshot.ts +68 -0
- package/client/vendor/paseo-plugin-helper/surface.tsx +80 -0
- package/client/vendor/paseo-plugin-helper/theme/color-utils.ts +118 -0
- package/client/vendor/paseo-plugin-helper/theme/flair.ts +76 -0
- package/client/vendor/paseo-plugin-helper/theme/host-variables.ts +121 -0
- package/client/vendor/paseo-plugin-helper/theme/index.ts +7 -0
- package/client/vendor/paseo-plugin-helper/theme/provider.tsx +214 -0
- package/client/vendor/paseo-plugin-helper/theme/responsive.ts +213 -0
- package/client/vendor/paseo-plugin-helper/theme/tokens.ts +161 -0
- package/client/vendor/paseo-plugin-helper/theme/useResponsive.ts +57 -0
- package/client/vendor/paseo-plugin-helper/utils/clipboard.ts +149 -0
- package/client/vendor/paseo-plugin-helper/utils/haptics.ts +34 -0
- package/package.json +36 -0
- package/paseo-plugin.json +6 -0
- package/server/twofado.ts +536 -0
- package/server/vendor/paseo-plugin-helper/agent.ts +85 -0
- package/server/vendor/paseo-plugin-helper/custom-pills.ts +344 -0
- package/server/vendor/paseo-plugin-helper/index.ts +18 -0
- package/server/vendor/paseo-plugin-helper/jsonc.ts +78 -0
- package/server/vendor/paseo-plugin-helper/logger.ts +210 -0
- package/server/vendor/paseo-plugin-helper/mcp-config.ts +367 -0
- package/server/vendor/paseo-plugin-helper/mcp-injection.ts +85 -0
- package/server/vendor/paseo-plugin-helper/network.ts +91 -0
- package/server/vendor/paseo-plugin-helper/plugins.ts +160 -0
- package/server/vendor/paseo-plugin-helper/process.ts +186 -0
- package/server/vendor/paseo-plugin-helper/redact.ts +86 -0
- package/server/vendor/paseo-plugin-helper/rpc-guard.ts +77 -0
- package/server/vendor/paseo-plugin-helper/settings.ts +97 -0
- package/server/vendor/paseo-plugin-helper/shared-settings.ts +243 -0
- package/server/vendor/paseo-plugin-helper/storage.ts +244 -0
- package/server/vendor/paseo-plugin-helper/system.ts +128 -0
- package/server/vendor/paseo-plugin-helper/task.ts +116 -0
- package/server/vendor/paseo-plugin-helper/version.ts +153 -0
- package/server/vendor/paseo-plugin-helper/workspace-beacon.ts +418 -0
- package/shared/approval.ts +254 -0
- package/shared/vendor/paseo-plugin-helper/async.ts +35 -0
- package/shared/vendor/paseo-plugin-helper/custom-pills.ts +169 -0
- package/shared/vendor/paseo-plugin-helper/forge.ts +110 -0
- package/shared/vendor/paseo-plugin-helper/formatters.ts +271 -0
- package/shared/vendor/paseo-plugin-helper/highlight.ts +184 -0
- package/shared/vendor/paseo-plugin-helper/index.ts +10 -0
- package/shared/vendor/paseo-plugin-helper/rpc.ts +72 -0
- package/shared/vendor/paseo-plugin-helper/settings.ts +138 -0
- package/shared/vendor/paseo-plugin-helper/suite-settings.ts +17 -0
- package/shared/vendor/paseo-plugin-helper/suppressed.ts +31 -0
- package/shared/vendor/paseo-plugin-helper/types.ts +36 -0
|
@@ -0,0 +1,1525 @@
|
|
|
1
|
+
import { useRpc } from "@getpaseo/plugin/client";
|
|
2
|
+
import type {
|
|
3
|
+
PluginButtonIconProps,
|
|
4
|
+
PluginButtonRegistration,
|
|
5
|
+
PluginSurfaceProps,
|
|
6
|
+
} from "@getpaseo/plugin/client";
|
|
7
|
+
import { Icon, useToast } from "@getpaseo/plugin/client/react-native";
|
|
8
|
+
import {
|
|
9
|
+
AttentionBeacon,
|
|
10
|
+
Badge,
|
|
11
|
+
Button,
|
|
12
|
+
Card,
|
|
13
|
+
CodeBlock,
|
|
14
|
+
Collapsible,
|
|
15
|
+
CommandBox,
|
|
16
|
+
EmptyState,
|
|
17
|
+
KeyValue,
|
|
18
|
+
KeyValueGroup,
|
|
19
|
+
ModalBody,
|
|
20
|
+
PluginThemeProvider,
|
|
21
|
+
StatusDot,
|
|
22
|
+
Tabs,
|
|
23
|
+
TextInput,
|
|
24
|
+
copyToClipboard,
|
|
25
|
+
usePluginSettings,
|
|
26
|
+
usePluginTheme,
|
|
27
|
+
} from "paseo-plugin-helper/client";
|
|
28
|
+
import { useQuery, useQueryClient } from "@tanstack/react-query";
|
|
29
|
+
import { useEffect, useRef, useState } from "react";
|
|
30
|
+
import { Easing, Text, View } from "react-native";
|
|
31
|
+
import { ErrorBoundary } from "./error-boundary";
|
|
32
|
+
import {
|
|
33
|
+
approvalAck,
|
|
34
|
+
approvalSettings,
|
|
35
|
+
approvalStatus,
|
|
36
|
+
approvalTelegramInfo,
|
|
37
|
+
daemonHealth,
|
|
38
|
+
pendingList,
|
|
39
|
+
policyAddRule,
|
|
40
|
+
recentList,
|
|
41
|
+
verdict,
|
|
42
|
+
} from "../shared/approval";
|
|
43
|
+
|
|
44
|
+
const LIST_KEY = ["twofado", "pending"];
|
|
45
|
+
const RECENT_KEY = ["twofado", "recent"];
|
|
46
|
+
const POLL_MS = 3000;
|
|
47
|
+
const RECENT_POLL_MS = 5000;
|
|
48
|
+
// The plugin SDK only takes a label string (the host owns the Text), so the
|
|
49
|
+
// header marquee must step the store instead of animating client-side.
|
|
50
|
+
const HEADER_MARQUEE_MS = 1000;
|
|
51
|
+
const RECENT_LIMIT = 10;
|
|
52
|
+
const OUTPUT_PREVIEW = 2000;
|
|
53
|
+
const EXPIRY_URGENT_S = 30;
|
|
54
|
+
// `CommandBox` renders a single-line, 2-line-clamped view. Heredocs, petition
|
|
55
|
+
// scripts, and other long argv payloads lose their newlines and get clipped
|
|
56
|
+
// there, so past this length (or on any embedded newline) we switch to the
|
|
57
|
+
// scrollable, copyable `CodeBlock` instead.
|
|
58
|
+
const COMMAND_MULTILINE_THRESHOLD = 140;
|
|
59
|
+
const COMMAND_BLOCK_MAX_HEIGHT = 280;
|
|
60
|
+
|
|
61
|
+
// Keep the approvals surface a readable centered column instead of stretching
|
|
62
|
+
// edge-to-edge on large viewports. The cap lives in the helper
|
|
63
|
+
// (`ModalBody maxContentWidth`); this is the only place the surface picks
|
|
64
|
+
// the value.
|
|
65
|
+
const TWOFADO_CONTENT_MAX_WIDTH = 600;
|
|
66
|
+
|
|
67
|
+
const seenIds = new Set<string>();
|
|
68
|
+
const SEEN_IDS_CAP = 500;
|
|
69
|
+
|
|
70
|
+
function useNewPendingToast(
|
|
71
|
+
items: Array<{ id: string; step?: "initial" | "confirm" }> | undefined,
|
|
72
|
+
) {
|
|
73
|
+
const toast = useToast();
|
|
74
|
+
useEffect(() => {
|
|
75
|
+
const fresh = (items ?? []).filter((item) => !seenIds.has(item.id));
|
|
76
|
+
if (fresh.length === 0) return;
|
|
77
|
+
for (const item of fresh) seenIds.add(item.id);
|
|
78
|
+
while (seenIds.size > SEEN_IDS_CAP) {
|
|
79
|
+
const oldest = seenIds.values().next();
|
|
80
|
+
if (oldest.done) break;
|
|
81
|
+
seenIds.delete(oldest.value);
|
|
82
|
+
}
|
|
83
|
+
const hasConfirm = fresh.some((item) => item.step === "confirm");
|
|
84
|
+
if (hasConfirm) {
|
|
85
|
+
toast.show("⚠️ Are you sure? 2fado confirmation required", { variant: "warning", durationMs: 15_000 });
|
|
86
|
+
} else {
|
|
87
|
+
toast.show(
|
|
88
|
+
fresh.length === 1 ? "2fado approval needed" : `${fresh.length} 2fado approvals needed`,
|
|
89
|
+
{ variant: "warning", durationMs: 10_000 },
|
|
90
|
+
);
|
|
91
|
+
}
|
|
92
|
+
}, [items, toast]);
|
|
93
|
+
}
|
|
94
|
+
const headerRegistry = new Map<string, PluginButtonRegistration>();
|
|
95
|
+
|
|
96
|
+
export function trackHeaderButton(workspaceId: string, registration: PluginButtonRegistration) {
|
|
97
|
+
headerRegistry.get(workspaceId)?.remove();
|
|
98
|
+
headerRegistry.set(workspaceId, registration);
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
export function untrackHeaderButton(workspaceId: string) {
|
|
102
|
+
headerRegistry.get(workspaceId)?.remove();
|
|
103
|
+
headerRegistry.delete(workspaceId);
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
export function useSocketPath(): string | undefined {
|
|
107
|
+
const { settings } = usePluginSettings(approvalSettings);
|
|
108
|
+
const trimmed = settings.socketPath.trim();
|
|
109
|
+
return trimmed.length > 0 ? trimmed : undefined;
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
export function usePendingList() {
|
|
113
|
+
const list = useRpc(pendingList);
|
|
114
|
+
const socketPath = useSocketPath();
|
|
115
|
+
return useQuery({
|
|
116
|
+
queryKey: [...LIST_KEY, socketPath ?? ""],
|
|
117
|
+
queryFn: () => list({ socketPath }),
|
|
118
|
+
refetchInterval: POLL_MS,
|
|
119
|
+
retry: false,
|
|
120
|
+
});
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
export function useRecentList() {
|
|
124
|
+
const recent = useRpc(recentList);
|
|
125
|
+
const socketPath = useSocketPath();
|
|
126
|
+
return useQuery({
|
|
127
|
+
queryKey: [...RECENT_KEY, socketPath ?? ""],
|
|
128
|
+
queryFn: () => recent({ socketPath, limit: RECENT_LIMIT }),
|
|
129
|
+
refetchInterval: RECENT_POLL_MS,
|
|
130
|
+
retry: false,
|
|
131
|
+
});
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
const TELEGRAM_KEY = ["twofado", "telegram"];
|
|
135
|
+
const HEALTH_KEY = ["twofado", "health"];
|
|
136
|
+
|
|
137
|
+
export function useTelegramInfo() {
|
|
138
|
+
const getTelegram = useRpc(approvalTelegramInfo);
|
|
139
|
+
const socketPath = useSocketPath();
|
|
140
|
+
return useQuery({
|
|
141
|
+
queryKey: [...TELEGRAM_KEY, socketPath ?? ""],
|
|
142
|
+
queryFn: () => getTelegram({ socketPath }),
|
|
143
|
+
refetchInterval: 15_000,
|
|
144
|
+
retry: false,
|
|
145
|
+
});
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
export function useDaemonHealth() {
|
|
149
|
+
const probe = useRpc(daemonHealth);
|
|
150
|
+
const socketPath = useSocketPath();
|
|
151
|
+
return useQuery({
|
|
152
|
+
queryKey: [...HEALTH_KEY, socketPath ?? ""],
|
|
153
|
+
queryFn: async () => {
|
|
154
|
+
try {
|
|
155
|
+
return await probe({ socketPath });
|
|
156
|
+
} catch {
|
|
157
|
+
return { reachable: false as const };
|
|
158
|
+
}
|
|
159
|
+
},
|
|
160
|
+
refetchInterval: POLL_MS,
|
|
161
|
+
retry: false,
|
|
162
|
+
});
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
function ApprovalHeaderIconInner(props: PluginButtonIconProps) {
|
|
166
|
+
const { theme } = props;
|
|
167
|
+
const workspaceId = props.workspaceId;
|
|
168
|
+
const size = props.size;
|
|
169
|
+
const color = props.color;
|
|
170
|
+
const { data } = usePendingList();
|
|
171
|
+
const health = useDaemonHealth();
|
|
172
|
+
const down = health.data ? !health.data.reachable : health.isError;
|
|
173
|
+
const count = data?.items.length ?? 0;
|
|
174
|
+
const hasConfirm = data?.items.some((item) => item.step === "confirm") ?? false;
|
|
175
|
+
|
|
176
|
+
useNewPendingToast(data?.items);
|
|
177
|
+
|
|
178
|
+
const prevCountRef = useRef<number | null>(null);
|
|
179
|
+
const prevDownRef = useRef<boolean | null>(null);
|
|
180
|
+
|
|
181
|
+
useEffect(() => {
|
|
182
|
+
if (prevCountRef.current === count && prevDownRef.current === down) return;
|
|
183
|
+
prevCountRef.current = count;
|
|
184
|
+
prevDownRef.current = down;
|
|
185
|
+
// Defer the button store update out of the render/commit phase
|
|
186
|
+
let interval: ReturnType<typeof setInterval> | undefined;
|
|
187
|
+
const timer = setTimeout(() => {
|
|
188
|
+
const reg = headerRegistry.get(workspaceId);
|
|
189
|
+
if (!reg) return;
|
|
190
|
+
// Dedupe consecutive identical publishes, but always publish the first
|
|
191
|
+
// label of an effect run. Clearing the badge calls `show(undefined)`;
|
|
192
|
+
// with `last` starting `undefined`, a plain equality guard would treat
|
|
193
|
+
// that clear as a no-op and leave the stale `Pending (N)` label pinned
|
|
194
|
+
// after the queue drains (xpufx-org/paseo#238).
|
|
195
|
+
let last: string | undefined;
|
|
196
|
+
let published = false;
|
|
197
|
+
const show = (label: string | undefined) => {
|
|
198
|
+
if (published && label === last) return;
|
|
199
|
+
published = true;
|
|
200
|
+
last = label;
|
|
201
|
+
try {
|
|
202
|
+
reg.update({ label });
|
|
203
|
+
} catch (err) {
|
|
204
|
+
console.warn("[2fado] Failed to update header button label:", err);
|
|
205
|
+
}
|
|
206
|
+
};
|
|
207
|
+
if (down) {
|
|
208
|
+
show("2fadod down");
|
|
209
|
+
return;
|
|
210
|
+
}
|
|
211
|
+
if (count === 0) {
|
|
212
|
+
show(undefined);
|
|
213
|
+
return;
|
|
214
|
+
}
|
|
215
|
+
const unit = `Pending (${count}) • 2fado • `;
|
|
216
|
+
const track = unit.repeat(3);
|
|
217
|
+
const width = unit.length;
|
|
218
|
+
let offset = 0;
|
|
219
|
+
show(unit);
|
|
220
|
+
interval = setInterval(() => {
|
|
221
|
+
offset = (offset + 1) % unit.length;
|
|
222
|
+
show(track.slice(offset, offset + width));
|
|
223
|
+
}, HEADER_MARQUEE_MS);
|
|
224
|
+
}, 0);
|
|
225
|
+
return () => {
|
|
226
|
+
clearTimeout(timer);
|
|
227
|
+
if (interval) clearInterval(interval);
|
|
228
|
+
};
|
|
229
|
+
}, [workspaceId, count, down]);
|
|
230
|
+
|
|
231
|
+
const activeColor = down
|
|
232
|
+
? theme.colors.statusDanger || "#ef4444"
|
|
233
|
+
: count > 0
|
|
234
|
+
? hasConfirm
|
|
235
|
+
? theme.colors.statusDanger || "#ef4444"
|
|
236
|
+
: theme.colors.statusWarning || "#f59e0b"
|
|
237
|
+
: color;
|
|
238
|
+
|
|
239
|
+
return (
|
|
240
|
+
<PluginThemeProvider theme={{ colors: theme.colors }}>
|
|
241
|
+
<AttentionBeacon
|
|
242
|
+
mode="pulse"
|
|
243
|
+
tone={down || hasConfirm ? "danger" : "warning"}
|
|
244
|
+
active={!down && count > 0}
|
|
245
|
+
easing={Easing.inOut(Easing.ease)}
|
|
246
|
+
accessibilityLabel={count > 0 ? `${count} pending approvals` : "No pending approvals"}
|
|
247
|
+
>
|
|
248
|
+
<View style={{ width: size, height: size, alignItems: "center", justifyContent: "center" }}>
|
|
249
|
+
<Icon
|
|
250
|
+
name="ShieldCheck"
|
|
251
|
+
size={size}
|
|
252
|
+
color={activeColor}
|
|
253
|
+
/>
|
|
254
|
+
</View>
|
|
255
|
+
</AttentionBeacon>
|
|
256
|
+
</PluginThemeProvider>
|
|
257
|
+
);
|
|
258
|
+
}
|
|
259
|
+
|
|
260
|
+
export function ApprovalHeaderIcon(props: PluginButtonIconProps) {
|
|
261
|
+
const { size, color } = props;
|
|
262
|
+
return (
|
|
263
|
+
<ErrorBoundary
|
|
264
|
+
label="ApprovalHeaderIcon"
|
|
265
|
+
fallback={<Icon name="ShieldCheck" size={size} color={color} />}
|
|
266
|
+
>
|
|
267
|
+
<ApprovalHeaderIconInner {...props} />
|
|
268
|
+
</ErrorBoundary>
|
|
269
|
+
);
|
|
270
|
+
}
|
|
271
|
+
|
|
272
|
+
function SectionHeader({ title, count }: { title: string; count?: number }) {
|
|
273
|
+
const { colors } = usePluginTheme();
|
|
274
|
+
return (
|
|
275
|
+
<View style={{ flexDirection: "row", alignItems: "center", gap: 8, marginTop: 8, marginBottom: 2 }}>
|
|
276
|
+
<Text
|
|
277
|
+
style={{
|
|
278
|
+
color: colors.foregroundMuted,
|
|
279
|
+
fontSize: 11,
|
|
280
|
+
fontWeight: "700",
|
|
281
|
+
textTransform: "uppercase",
|
|
282
|
+
letterSpacing: 0.8,
|
|
283
|
+
}}
|
|
284
|
+
>
|
|
285
|
+
{title}
|
|
286
|
+
</Text>
|
|
287
|
+
{count !== undefined ? (
|
|
288
|
+
<Badge
|
|
289
|
+
label={String(count)}
|
|
290
|
+
variant={count > 0 ? "warning" : "neutral"}
|
|
291
|
+
styleVariant={count > 0 ? "solid" : "tinted"}
|
|
292
|
+
/>
|
|
293
|
+
) : null}
|
|
294
|
+
</View>
|
|
295
|
+
);
|
|
296
|
+
}
|
|
297
|
+
|
|
298
|
+
/**
|
|
299
|
+
* Join argv into one display/copy string without collapsing newlines. Mirrors
|
|
300
|
+
* `CommandBox`'s quoting for space-bearing args, but leaves multiline args raw
|
|
301
|
+
* so heredocs and petition blocks read verbatim.
|
|
302
|
+
*/
|
|
303
|
+
export function formatCommandBlock(argv: string[]): string {
|
|
304
|
+
return argv
|
|
305
|
+
.map((arg) => (arg.includes(" ") && !/[\r\n]/.test(arg) ? JSON.stringify(arg) : arg))
|
|
306
|
+
.join(" ");
|
|
307
|
+
}
|
|
308
|
+
|
|
309
|
+
/**
|
|
310
|
+
* True when a command should render as a scrollable code block instead of the
|
|
311
|
+
* 2-line `CommandBox`: any embedded newline, or a rendered length that would
|
|
312
|
+
* otherwise be clamped and unreadable.
|
|
313
|
+
*/
|
|
314
|
+
export function isMultilineCommand(argv: string[]): boolean {
|
|
315
|
+
if (argv.some((arg) => /[\r\n]/.test(arg))) return true;
|
|
316
|
+
return formatCommandBlock(argv).length > COMMAND_MULTILINE_THRESHOLD;
|
|
317
|
+
}
|
|
318
|
+
|
|
319
|
+
/**
|
|
320
|
+
* Renders short commands with the compact `CommandBox` and long/multiline ones
|
|
321
|
+
* with an expandable `CodeBlock` so the full payload preserves its newlines and
|
|
322
|
+
* stays copyable. Pending approvals open expanded (operator must read them);
|
|
323
|
+
* history opens collapsed to keep the timeline compact.
|
|
324
|
+
*/
|
|
325
|
+
function CommandView({
|
|
326
|
+
argv,
|
|
327
|
+
defaultExpanded = true,
|
|
328
|
+
}: {
|
|
329
|
+
argv: string[];
|
|
330
|
+
defaultExpanded?: boolean;
|
|
331
|
+
}) {
|
|
332
|
+
if (!isMultilineCommand(argv)) {
|
|
333
|
+
return <CommandBox argv={argv} />;
|
|
334
|
+
}
|
|
335
|
+
const code = formatCommandBlock(argv);
|
|
336
|
+
const lineCount = code.split("\n").length;
|
|
337
|
+
return (
|
|
338
|
+
<Collapsible
|
|
339
|
+
title={lineCount > 1 ? `Command · ${lineCount} lines` : "Command (long)"}
|
|
340
|
+
icon="Terminal"
|
|
341
|
+
initiallyExpanded={defaultExpanded}
|
|
342
|
+
>
|
|
343
|
+
<CodeBlock code={code} maxHeight={COMMAND_BLOCK_MAX_HEIGHT} copyable />
|
|
344
|
+
</Collapsible>
|
|
345
|
+
);
|
|
346
|
+
}
|
|
347
|
+
|
|
348
|
+
function ApprovalItem({
|
|
349
|
+
item,
|
|
350
|
+
deciding,
|
|
351
|
+
onDecide,
|
|
352
|
+
onAlways,
|
|
353
|
+
}: {
|
|
354
|
+
item: {
|
|
355
|
+
id: string;
|
|
356
|
+
argv: string[];
|
|
357
|
+
host: string;
|
|
358
|
+
caller: string;
|
|
359
|
+
cwd: string;
|
|
360
|
+
expiresIn: number;
|
|
361
|
+
step?: "initial" | "confirm";
|
|
362
|
+
confirmOf?: string;
|
|
363
|
+
preview?: {
|
|
364
|
+
resolvedBinary?: string;
|
|
365
|
+
targetCwd?: string;
|
|
366
|
+
affectedCount?: number;
|
|
367
|
+
samplePaths?: string[];
|
|
368
|
+
riskLevel?: "low" | "medium" | "high" | "critical";
|
|
369
|
+
riskReason?: string;
|
|
370
|
+
};
|
|
371
|
+
};
|
|
372
|
+
deciding?: "approve" | "deny";
|
|
373
|
+
onDecide(item: { id: string; argv: string[]; cwd: string }, decision: "approve" | "deny"): void;
|
|
374
|
+
onAlways(item: { id: string; argv: string[]; cwd: string; preview?: { resolvedBinary?: string } }, target: "whitelist" | "blacklist"): void;
|
|
375
|
+
}) {
|
|
376
|
+
const { colors, fonts } = usePluginTheme();
|
|
377
|
+
const [program] = item.argv;
|
|
378
|
+
const isConfirm = item.step === "confirm";
|
|
379
|
+
const urgent = item.expiresIn <= EXPIRY_URGENT_S;
|
|
380
|
+
const fontFamily = fonts.mono;
|
|
381
|
+
const isDeciding = Boolean(deciding);
|
|
382
|
+
|
|
383
|
+
return (
|
|
384
|
+
<Card
|
|
385
|
+
variant="elevated"
|
|
386
|
+
style={{
|
|
387
|
+
borderLeftWidth: 4,
|
|
388
|
+
borderLeftColor: isConfirm || urgent ? colors.statusDanger : colors.statusWarning,
|
|
389
|
+
gap: 8,
|
|
390
|
+
padding: 12,
|
|
391
|
+
}}
|
|
392
|
+
>
|
|
393
|
+
{isConfirm ? (
|
|
394
|
+
<View
|
|
395
|
+
style={{
|
|
396
|
+
flexDirection: "row",
|
|
397
|
+
alignItems: "center",
|
|
398
|
+
gap: 8,
|
|
399
|
+
backgroundColor: colors.statusDanger + "15",
|
|
400
|
+
borderColor: colors.statusDanger + "40",
|
|
401
|
+
borderWidth: 1,
|
|
402
|
+
borderRadius: 6,
|
|
403
|
+
paddingVertical: 6,
|
|
404
|
+
paddingHorizontal: 10,
|
|
405
|
+
}}
|
|
406
|
+
>
|
|
407
|
+
<Icon name="AlertTriangle" size={14} color={colors.statusDanger} />
|
|
408
|
+
<View style={{ flex: 1 }}>
|
|
409
|
+
<Text style={{ color: colors.statusDanger, fontSize: 12, fontWeight: "700" }}>
|
|
410
|
+
ARE YOU SURE? Tap again to run
|
|
411
|
+
</Text>
|
|
412
|
+
<Text style={{ color: colors.foregroundMuted, fontSize: 10 }}>
|
|
413
|
+
Step 2 of 2: High-security confirmation required
|
|
414
|
+
</Text>
|
|
415
|
+
</View>
|
|
416
|
+
<Badge label="2-step" variant="danger" styleVariant="solid" />
|
|
417
|
+
</View>
|
|
418
|
+
) : null}
|
|
419
|
+
|
|
420
|
+
<View style={{ flexDirection: "row", alignItems: "center", justifyContent: "space-between", gap: 8 }}>
|
|
421
|
+
<View style={{ flexDirection: "row", alignItems: "center", gap: 8, flex: 1 }}>
|
|
422
|
+
<View
|
|
423
|
+
style={{
|
|
424
|
+
width: 22,
|
|
425
|
+
height: 22,
|
|
426
|
+
borderRadius: 4,
|
|
427
|
+
backgroundColor: isConfirm || urgent ? colors.statusDanger : colors.statusWarning,
|
|
428
|
+
alignItems: "center",
|
|
429
|
+
justifyContent: "center",
|
|
430
|
+
}}
|
|
431
|
+
>
|
|
432
|
+
<Icon name={isConfirm ? "AlertTriangle" : "Terminal"} size={12} color="#ffffff" />
|
|
433
|
+
</View>
|
|
434
|
+
<Text style={{ color: colors.foreground, fontSize: 14, fontWeight: "700", flex: 1 }}>
|
|
435
|
+
{program ?? "(empty)"}
|
|
436
|
+
</Text>
|
|
437
|
+
</View>
|
|
438
|
+
<Badge
|
|
439
|
+
label={urgent ? `expires in ${item.expiresIn}s` : `${item.expiresIn}s left`}
|
|
440
|
+
variant={urgent || isConfirm ? "danger" : "warning"}
|
|
441
|
+
styleVariant="solid"
|
|
442
|
+
icon="Timer"
|
|
443
|
+
/>
|
|
444
|
+
</View>
|
|
445
|
+
|
|
446
|
+
<CommandView argv={item.argv} />
|
|
447
|
+
|
|
448
|
+
<View
|
|
449
|
+
style={{
|
|
450
|
+
flexDirection: "row",
|
|
451
|
+
alignItems: "center",
|
|
452
|
+
justifyContent: "space-between",
|
|
453
|
+
flexWrap: "wrap",
|
|
454
|
+
gap: 6,
|
|
455
|
+
}}
|
|
456
|
+
>
|
|
457
|
+
<View style={{ flexDirection: "row", alignItems: "center", gap: 5 }}>
|
|
458
|
+
<Icon name="User" size={11} color={colors.foregroundMuted} />
|
|
459
|
+
<Text style={{ color: colors.foregroundMuted, fontSize: 11 }}>
|
|
460
|
+
Caller <Text style={{ color: colors.foreground, fontWeight: "600" }}>{item.caller}</Text> on{" "}
|
|
461
|
+
<Text style={{ color: colors.foreground, fontWeight: "600" }}>{item.host}</Text>
|
|
462
|
+
</Text>
|
|
463
|
+
</View>
|
|
464
|
+
<View style={{ flexDirection: "row", alignItems: "center", gap: 4, maxWidth: 280 }}>
|
|
465
|
+
<Icon name="Folder" size={11} color={colors.foregroundMuted} />
|
|
466
|
+
<Text
|
|
467
|
+
numberOfLines={1}
|
|
468
|
+
ellipsizeMode="middle"
|
|
469
|
+
style={{ color: colors.foregroundMuted, fontSize: 11, fontFamily }}
|
|
470
|
+
>
|
|
471
|
+
{item.cwd}
|
|
472
|
+
</Text>
|
|
473
|
+
</View>
|
|
474
|
+
</View>
|
|
475
|
+
|
|
476
|
+
<Collapsible
|
|
477
|
+
title={
|
|
478
|
+
item.preview?.riskLevel === "critical" || item.preview?.riskLevel === "high"
|
|
479
|
+
? `Impact Preview · ${item.preview.riskLevel.toUpperCase()} RISK`
|
|
480
|
+
: "Impact Preview"
|
|
481
|
+
}
|
|
482
|
+
icon={
|
|
483
|
+
item.preview?.riskLevel === "critical" || item.preview?.riskLevel === "high"
|
|
484
|
+
? "AlertTriangle"
|
|
485
|
+
: "Terminal"
|
|
486
|
+
}
|
|
487
|
+
initiallyExpanded={item.preview?.riskLevel === "critical" || item.preview?.riskLevel === "high"}
|
|
488
|
+
>
|
|
489
|
+
<View style={{ gap: 6, paddingTop: 4 }}>
|
|
490
|
+
{item.preview?.riskReason ? (
|
|
491
|
+
<View
|
|
492
|
+
style={{
|
|
493
|
+
backgroundColor: colors.statusDanger + "15",
|
|
494
|
+
borderColor: colors.statusDanger + "40",
|
|
495
|
+
borderWidth: 1,
|
|
496
|
+
borderRadius: 6,
|
|
497
|
+
padding: 8,
|
|
498
|
+
flexDirection: "row",
|
|
499
|
+
alignItems: "center",
|
|
500
|
+
gap: 6,
|
|
501
|
+
}}
|
|
502
|
+
>
|
|
503
|
+
<Icon name="AlertTriangle" size={13} color={colors.statusDanger} />
|
|
504
|
+
<Text style={{ color: colors.statusDanger, fontSize: 11, fontWeight: "600", flex: 1 }}>
|
|
505
|
+
{item.preview.riskReason}
|
|
506
|
+
</Text>
|
|
507
|
+
</View>
|
|
508
|
+
) : null}
|
|
509
|
+
|
|
510
|
+
<View style={{ flexDirection: "row", alignItems: "center", justifyContent: "space-between" }}>
|
|
511
|
+
<Text style={{ color: colors.foregroundMuted, fontSize: 11 }}>Resolved Binary</Text>
|
|
512
|
+
<Text style={{ color: colors.foreground, fontSize: 11, fontFamily, fontWeight: "600" }}>
|
|
513
|
+
{item.preview?.resolvedBinary ?? `/usr/bin/${program ?? "command"}`}
|
|
514
|
+
</Text>
|
|
515
|
+
</View>
|
|
516
|
+
|
|
517
|
+
<View style={{ flexDirection: "row", alignItems: "center", justifyContent: "space-between" }}>
|
|
518
|
+
<Text style={{ color: colors.foregroundMuted, fontSize: 11 }}>Target Directory</Text>
|
|
519
|
+
<Text
|
|
520
|
+
numberOfLines={1}
|
|
521
|
+
ellipsizeMode="middle"
|
|
522
|
+
style={{ color: colors.foreground, fontSize: 11, fontFamily, maxWidth: 220 }}
|
|
523
|
+
>
|
|
524
|
+
{item.preview?.targetCwd ?? item.cwd}
|
|
525
|
+
</Text>
|
|
526
|
+
</View>
|
|
527
|
+
|
|
528
|
+
{item.preview?.affectedCount !== undefined ? (
|
|
529
|
+
<View style={{ flexDirection: "row", alignItems: "center", justifyContent: "space-between" }}>
|
|
530
|
+
<Text style={{ color: colors.foregroundMuted, fontSize: 11 }}>Affected Files</Text>
|
|
531
|
+
<Badge
|
|
532
|
+
label={`${item.preview.affectedCount} target${item.preview.affectedCount === 1 ? "" : "s"}`}
|
|
533
|
+
variant={item.preview.affectedCount > 10 ? "warning" : "neutral"}
|
|
534
|
+
styleVariant="tinted"
|
|
535
|
+
/>
|
|
536
|
+
</View>
|
|
537
|
+
) : null}
|
|
538
|
+
|
|
539
|
+
{item.preview?.samplePaths && item.preview.samplePaths.length > 0 ? (
|
|
540
|
+
<View style={{ gap: 2, marginTop: 4 }}>
|
|
541
|
+
<Text style={{ color: colors.foregroundMuted, fontSize: 10, fontWeight: "600" }}>
|
|
542
|
+
Target Samples:
|
|
543
|
+
</Text>
|
|
544
|
+
{item.preview.samplePaths.slice(0, 3).map((p, idx) => (
|
|
545
|
+
<Text
|
|
546
|
+
key={idx}
|
|
547
|
+
numberOfLines={1}
|
|
548
|
+
ellipsizeMode="middle"
|
|
549
|
+
style={{ color: colors.foregroundMuted, fontSize: 10, fontFamily }}
|
|
550
|
+
>
|
|
551
|
+
• {p}
|
|
552
|
+
</Text>
|
|
553
|
+
))}
|
|
554
|
+
</View>
|
|
555
|
+
) : null}
|
|
556
|
+
</View>
|
|
557
|
+
</Collapsible>
|
|
558
|
+
|
|
559
|
+
<View style={{ flexDirection: "row", gap: 8, paddingTop: 2 }}>
|
|
560
|
+
<View style={{ flex: 1 }}>
|
|
561
|
+
<Button
|
|
562
|
+
label={isConfirm ? "Confirm & Run" : "Approve"}
|
|
563
|
+
variant={isConfirm ? "danger" : "primary"}
|
|
564
|
+
size="sm"
|
|
565
|
+
icon={isConfirm ? "AlertTriangle" : "Check"}
|
|
566
|
+
accessibilityLabel={`${isConfirm ? "Confirm" : "Approve"} ${item.id}`}
|
|
567
|
+
loading={deciding === "approve"}
|
|
568
|
+
disabled={isDeciding}
|
|
569
|
+
onPress={() => onDecide(item, "approve")}
|
|
570
|
+
/>
|
|
571
|
+
</View>
|
|
572
|
+
<View style={{ flex: 1 }}>
|
|
573
|
+
<Button
|
|
574
|
+
label={isConfirm ? "Cancel & Deny" : "Deny"}
|
|
575
|
+
variant={isConfirm ? "secondary" : "danger"}
|
|
576
|
+
size="sm"
|
|
577
|
+
icon="X"
|
|
578
|
+
accessibilityLabel={`Deny ${item.id}`}
|
|
579
|
+
loading={deciding === "deny"}
|
|
580
|
+
disabled={isDeciding}
|
|
581
|
+
onPress={() => onDecide(item, "deny")}
|
|
582
|
+
/>
|
|
583
|
+
</View>
|
|
584
|
+
</View>
|
|
585
|
+
<Collapsible title="Policy shortcuts" icon="Settings" initiallyExpanded={false}>
|
|
586
|
+
<View style={{ flexDirection: "row", gap: 8, paddingTop: 4 }}>
|
|
587
|
+
<View style={{ flex: 1 }}>
|
|
588
|
+
<Button
|
|
589
|
+
label="Always approve"
|
|
590
|
+
variant="secondary"
|
|
591
|
+
size="sm"
|
|
592
|
+
icon="Check"
|
|
593
|
+
accessibilityLabel={`Always approve ${item.id}`}
|
|
594
|
+
disabled={isDeciding}
|
|
595
|
+
onPress={() => onAlways(item, "whitelist")}
|
|
596
|
+
/>
|
|
597
|
+
</View>
|
|
598
|
+
<View style={{ flex: 1 }}>
|
|
599
|
+
<Button
|
|
600
|
+
label="Always deny"
|
|
601
|
+
variant="secondary"
|
|
602
|
+
size="sm"
|
|
603
|
+
icon="X"
|
|
604
|
+
accessibilityLabel={`Always deny ${item.id}`}
|
|
605
|
+
disabled={isDeciding}
|
|
606
|
+
onPress={() => onAlways(item, "blacklist")}
|
|
607
|
+
/>
|
|
608
|
+
</View>
|
|
609
|
+
</View>
|
|
610
|
+
</Collapsible>
|
|
611
|
+
|
|
612
|
+
</Card>
|
|
613
|
+
);
|
|
614
|
+
}
|
|
615
|
+
|
|
616
|
+
function NotifyItem({
|
|
617
|
+
item,
|
|
618
|
+
acking,
|
|
619
|
+
onAck,
|
|
620
|
+
}: {
|
|
621
|
+
item: {
|
|
622
|
+
id: string;
|
|
623
|
+
link?: string;
|
|
624
|
+
summary?: string;
|
|
625
|
+
host: string;
|
|
626
|
+
caller: string;
|
|
627
|
+
expiresIn: number;
|
|
628
|
+
};
|
|
629
|
+
acking?: boolean;
|
|
630
|
+
onAck(item: { id: string }): void;
|
|
631
|
+
}) {
|
|
632
|
+
const { colors, fonts } = usePluginTheme();
|
|
633
|
+
const toast = useToast();
|
|
634
|
+
const [copied, setCopied] = useState(false);
|
|
635
|
+
const urgent = item.expiresIn <= EXPIRY_URGENT_S;
|
|
636
|
+
const fontFamily = fonts.mono;
|
|
637
|
+
|
|
638
|
+
const handleCopyLink = async () => {
|
|
639
|
+
if (!item.link) return;
|
|
640
|
+
const ok = await copyToClipboard(item.link, { toast, toastMessage: "Link" });
|
|
641
|
+
if (ok) {
|
|
642
|
+
setCopied(true);
|
|
643
|
+
setTimeout(() => setCopied(false), 2000);
|
|
644
|
+
}
|
|
645
|
+
};
|
|
646
|
+
|
|
647
|
+
return (
|
|
648
|
+
<Card
|
|
649
|
+
variant="elevated"
|
|
650
|
+
style={{
|
|
651
|
+
borderLeftWidth: 4,
|
|
652
|
+
borderLeftColor: urgent ? colors.statusDanger : colors.accent,
|
|
653
|
+
gap: 8,
|
|
654
|
+
padding: 12,
|
|
655
|
+
}}
|
|
656
|
+
>
|
|
657
|
+
<View style={{ flexDirection: "row", alignItems: "center", justifyContent: "space-between", gap: 8 }}>
|
|
658
|
+
<View style={{ flexDirection: "row", alignItems: "center", gap: 8, flex: 1 }}>
|
|
659
|
+
<View
|
|
660
|
+
style={{
|
|
661
|
+
width: 22,
|
|
662
|
+
height: 22,
|
|
663
|
+
borderRadius: 4,
|
|
664
|
+
backgroundColor: urgent ? colors.statusDanger : colors.accent,
|
|
665
|
+
alignItems: "center",
|
|
666
|
+
justifyContent: "center",
|
|
667
|
+
}}
|
|
668
|
+
>
|
|
669
|
+
<Icon name="Bell" size={12} color="#ffffff" />
|
|
670
|
+
</View>
|
|
671
|
+
<Text style={{ color: colors.foreground, fontSize: 14, fontWeight: "700", flex: 1 }}>
|
|
672
|
+
Action needed
|
|
673
|
+
</Text>
|
|
674
|
+
</View>
|
|
675
|
+
<Badge
|
|
676
|
+
label={urgent ? `expires in ${item.expiresIn}s` : `${item.expiresIn}s left`}
|
|
677
|
+
variant={urgent ? "danger" : "accent"}
|
|
678
|
+
styleVariant="solid"
|
|
679
|
+
icon="Timer"
|
|
680
|
+
/>
|
|
681
|
+
</View>
|
|
682
|
+
|
|
683
|
+
{item.summary ? (
|
|
684
|
+
<Text style={{ color: colors.foreground, fontSize: 13 }}>{item.summary}</Text>
|
|
685
|
+
) : null}
|
|
686
|
+
|
|
687
|
+
{item.link ? (
|
|
688
|
+
<View
|
|
689
|
+
style={{
|
|
690
|
+
flexDirection: "row",
|
|
691
|
+
alignItems: "center",
|
|
692
|
+
justifyContent: "space-between",
|
|
693
|
+
backgroundColor: colors.surface2,
|
|
694
|
+
borderColor: colors.border,
|
|
695
|
+
borderWidth: 1,
|
|
696
|
+
borderRadius: 6,
|
|
697
|
+
paddingHorizontal: 10,
|
|
698
|
+
paddingVertical: 6,
|
|
699
|
+
gap: 8,
|
|
700
|
+
}}
|
|
701
|
+
>
|
|
702
|
+
<Text
|
|
703
|
+
selectable
|
|
704
|
+
numberOfLines={2}
|
|
705
|
+
style={{ color: colors.accent, fontFamily, fontSize: 12, flex: 1 }}
|
|
706
|
+
>
|
|
707
|
+
{item.link}
|
|
708
|
+
</Text>
|
|
709
|
+
<Button
|
|
710
|
+
variant="ghost"
|
|
711
|
+
size="sm"
|
|
712
|
+
icon={copied ? "Check" : "Copy"}
|
|
713
|
+
accessibilityLabel="Copy link"
|
|
714
|
+
onPress={() => void handleCopyLink()}
|
|
715
|
+
/>
|
|
716
|
+
</View>
|
|
717
|
+
) : null}
|
|
718
|
+
|
|
719
|
+
<View
|
|
720
|
+
style={{
|
|
721
|
+
flexDirection: "row",
|
|
722
|
+
alignItems: "center",
|
|
723
|
+
justifyContent: "space-between",
|
|
724
|
+
flexWrap: "wrap",
|
|
725
|
+
gap: 6,
|
|
726
|
+
}}
|
|
727
|
+
>
|
|
728
|
+
<View style={{ flexDirection: "row", alignItems: "center", gap: 5 }}>
|
|
729
|
+
<Icon name="User" size={11} color={colors.foregroundMuted} />
|
|
730
|
+
<Text style={{ color: colors.foregroundMuted, fontSize: 11 }}>
|
|
731
|
+
From <Text style={{ color: colors.foreground, fontWeight: "600" }}>{item.caller}</Text> on{" "}
|
|
732
|
+
<Text style={{ color: colors.foreground, fontWeight: "600" }}>{item.host}</Text>
|
|
733
|
+
</Text>
|
|
734
|
+
</View>
|
|
735
|
+
</View>
|
|
736
|
+
|
|
737
|
+
<View style={{ flexDirection: "row", gap: 8, paddingTop: 2 }}>
|
|
738
|
+
<View style={{ flex: 1 }}>
|
|
739
|
+
<Button
|
|
740
|
+
label="Ack"
|
|
741
|
+
variant="primary"
|
|
742
|
+
size="sm"
|
|
743
|
+
icon="Check"
|
|
744
|
+
accessibilityLabel={`Acknowledge ${item.id}`}
|
|
745
|
+
loading={acking}
|
|
746
|
+
disabled={acking}
|
|
747
|
+
onPress={() => onAck(item)}
|
|
748
|
+
/>
|
|
749
|
+
</View>
|
|
750
|
+
</View>
|
|
751
|
+
</Card>
|
|
752
|
+
);
|
|
753
|
+
}
|
|
754
|
+
|
|
755
|
+
function ExecutingItem({ item,
|
|
756
|
+
}: {
|
|
757
|
+
item: {
|
|
758
|
+
id: string;
|
|
759
|
+
argv?: string[];
|
|
760
|
+
cwd?: string;
|
|
761
|
+
status: string;
|
|
762
|
+
exit?: number;
|
|
763
|
+
output?: string;
|
|
764
|
+
};
|
|
765
|
+
}) {
|
|
766
|
+
const { colors, fonts } = usePluginTheme();
|
|
767
|
+
const [program] = item.argv ?? ["(command)"];
|
|
768
|
+
const isConfirming = item.status === "confirming";
|
|
769
|
+
const isCompleted = item.status === "completed";
|
|
770
|
+
const isFailed = isCompleted && item.exit !== 0;
|
|
771
|
+
const isSuccess = isCompleted && item.exit === 0;
|
|
772
|
+
|
|
773
|
+
const statusColor = isSuccess
|
|
774
|
+
? colors.statusSuccess
|
|
775
|
+
: isFailed || isConfirming
|
|
776
|
+
? colors.statusWarning
|
|
777
|
+
: colors.accent;
|
|
778
|
+
|
|
779
|
+
const fontFamily = fonts.mono;
|
|
780
|
+
|
|
781
|
+
return (
|
|
782
|
+
<Card
|
|
783
|
+
variant="elevated"
|
|
784
|
+
style={{
|
|
785
|
+
borderLeftWidth: 4,
|
|
786
|
+
borderLeftColor: statusColor,
|
|
787
|
+
gap: 8,
|
|
788
|
+
padding: 12,
|
|
789
|
+
}}
|
|
790
|
+
>
|
|
791
|
+
<View style={{ flexDirection: "row", alignItems: "center", justifyContent: "space-between", gap: 8 }}>
|
|
792
|
+
<View style={{ flexDirection: "row", alignItems: "center", gap: 8, flex: 1 }}>
|
|
793
|
+
<View
|
|
794
|
+
style={{
|
|
795
|
+
width: 22,
|
|
796
|
+
height: 22,
|
|
797
|
+
borderRadius: 4,
|
|
798
|
+
backgroundColor: statusColor,
|
|
799
|
+
alignItems: "center",
|
|
800
|
+
justifyContent: "center",
|
|
801
|
+
}}
|
|
802
|
+
>
|
|
803
|
+
<Icon
|
|
804
|
+
name={isSuccess ? "Check" : isFailed || isConfirming ? "AlertTriangle" : "Activity"}
|
|
805
|
+
size={12}
|
|
806
|
+
color="#ffffff"
|
|
807
|
+
/>
|
|
808
|
+
</View>
|
|
809
|
+
<Text style={{ color: colors.foreground, fontSize: 14, fontWeight: "700", flex: 1 }}>
|
|
810
|
+
{program}
|
|
811
|
+
</Text>
|
|
812
|
+
</View>
|
|
813
|
+
<Badge
|
|
814
|
+
label={
|
|
815
|
+
isSuccess
|
|
816
|
+
? `exit ${item.exit}`
|
|
817
|
+
: isFailed
|
|
818
|
+
? `exit ${item.exit}`
|
|
819
|
+
: isConfirming
|
|
820
|
+
? "confirming…"
|
|
821
|
+
: "running…"
|
|
822
|
+
}
|
|
823
|
+
variant={isSuccess ? "success" : isFailed || isConfirming ? "warning" : "accent"}
|
|
824
|
+
styleVariant="solid"
|
|
825
|
+
icon={isSuccess ? "Check" : isFailed || isConfirming ? "AlertTriangle" : "Activity"}
|
|
826
|
+
/>
|
|
827
|
+
</View>
|
|
828
|
+
|
|
829
|
+
{item.argv && item.argv.length > 0 ? <CommandView argv={item.argv} /> : null}
|
|
830
|
+
|
|
831
|
+
<View style={{ flexDirection: "row", alignItems: "center", justifyContent: "space-between", gap: 6 }}>
|
|
832
|
+
{item.cwd ? (
|
|
833
|
+
<View style={{ flexDirection: "row", alignItems: "center", gap: 4, maxWidth: 280 }}>
|
|
834
|
+
<Icon name="Folder" size={11} color={colors.foregroundMuted} />
|
|
835
|
+
<Text
|
|
836
|
+
numberOfLines={1}
|
|
837
|
+
ellipsizeMode="middle"
|
|
838
|
+
style={{ color: colors.foregroundMuted, fontSize: 11, fontFamily }}
|
|
839
|
+
>
|
|
840
|
+
{item.cwd}
|
|
841
|
+
</Text>
|
|
842
|
+
</View>
|
|
843
|
+
) : null}
|
|
844
|
+
<Text style={{ color: colors.foregroundMuted, fontSize: 11 }}>
|
|
845
|
+
{isCompleted
|
|
846
|
+
? isSuccess
|
|
847
|
+
? "Executed successfully"
|
|
848
|
+
: `Failed with exit ${item.exit}`
|
|
849
|
+
: isConfirming
|
|
850
|
+
? "Step 1 approved · Awaiting 2nd confirmation…"
|
|
851
|
+
: "Executing on host…"}
|
|
852
|
+
</Text>
|
|
853
|
+
</View>
|
|
854
|
+
</Card>
|
|
855
|
+
);
|
|
856
|
+
}
|
|
857
|
+
|
|
858
|
+
function RecentItem({
|
|
859
|
+
item,
|
|
860
|
+
}: {
|
|
861
|
+
item: {
|
|
862
|
+
id: string;
|
|
863
|
+
argv: string[];
|
|
864
|
+
cwd: string;
|
|
865
|
+
decision: string;
|
|
866
|
+
by: string;
|
|
867
|
+
exit: number;
|
|
868
|
+
output: string;
|
|
869
|
+
step?: "initial" | "confirm";
|
|
870
|
+
confirmOf?: string;
|
|
871
|
+
kind?: string;
|
|
872
|
+
link?: string;
|
|
873
|
+
summary?: string;
|
|
874
|
+
};
|
|
875
|
+
}) {
|
|
876
|
+
const { colors, fonts } = usePluginTheme();
|
|
877
|
+
const isNotify = item.kind === "notify";
|
|
878
|
+
const acked = item.decision === "ack";
|
|
879
|
+
const approved = item.decision === "approve" || acked;
|
|
880
|
+
const executed = item.exit >= 0;
|
|
881
|
+
const failed = executed && item.exit !== 0;
|
|
882
|
+
const isTwoStep = item.step === "confirm" || Boolean(item.confirmOf);
|
|
883
|
+
const preview =
|
|
884
|
+
item.output.length > OUTPUT_PREVIEW
|
|
885
|
+
? `${item.output.slice(0, OUTPUT_PREVIEW)}\n…[truncated]`
|
|
886
|
+
: item.output;
|
|
887
|
+
const [program] = item.argv;
|
|
888
|
+
const statusColor = !approved
|
|
889
|
+
? colors.statusDanger
|
|
890
|
+
: failed
|
|
891
|
+
? colors.statusWarning
|
|
892
|
+
: colors.statusSuccess;
|
|
893
|
+
|
|
894
|
+
const fontFamily = fonts.mono;
|
|
895
|
+
|
|
896
|
+
return (
|
|
897
|
+
<Card
|
|
898
|
+
style={{
|
|
899
|
+
borderLeftWidth: 3,
|
|
900
|
+
borderLeftColor: statusColor,
|
|
901
|
+
gap: 6,
|
|
902
|
+
padding: 10,
|
|
903
|
+
}}
|
|
904
|
+
>
|
|
905
|
+
<View style={{ flexDirection: "row", alignItems: "center", justifyContent: "space-between", gap: 8 }}>
|
|
906
|
+
<View style={{ flexDirection: "row", alignItems: "center", gap: 6, flex: 1 }}>
|
|
907
|
+
<Icon
|
|
908
|
+
name={approved ? (failed ? "AlertTriangle" : "Check") : "X"}
|
|
909
|
+
size={13}
|
|
910
|
+
color={statusColor}
|
|
911
|
+
/>
|
|
912
|
+
<Text style={{ color: colors.foreground, fontSize: 13, fontWeight: "600" }}>
|
|
913
|
+
{isNotify ? (item.summary ?? "(notify)") : (program ?? "(empty)")}
|
|
914
|
+
</Text>
|
|
915
|
+
<Text style={{ color: colors.foregroundMuted, fontSize: 11 }}>
|
|
916
|
+
· {item.by ? `by ${item.by}` : "local"}
|
|
917
|
+
</Text>
|
|
918
|
+
</View>
|
|
919
|
+
<View style={{ flexDirection: "row", alignItems: "center", gap: 6 }}>
|
|
920
|
+
{isTwoStep ? (
|
|
921
|
+
<Badge label="2-step" variant="danger" styleVariant="tinted" />
|
|
922
|
+
) : null}
|
|
923
|
+
<Badge
|
|
924
|
+
label={
|
|
925
|
+
acked ? "acked" : approved ? (executed ? `exit ${item.exit}` : "approved") : "denied"
|
|
926
|
+
}
|
|
927
|
+
variant={approved ? (failed ? "warning" : "success") : "danger"}
|
|
928
|
+
styleVariant="tinted"
|
|
929
|
+
/>
|
|
930
|
+
</View>
|
|
931
|
+
</View>
|
|
932
|
+
|
|
933
|
+
{isNotify && item.link ? (
|
|
934
|
+
<Text
|
|
935
|
+
selectable
|
|
936
|
+
numberOfLines={2}
|
|
937
|
+
style={{ color: colors.accent, fontSize: 11, fontFamily }}
|
|
938
|
+
>
|
|
939
|
+
{item.link}
|
|
940
|
+
</Text>
|
|
941
|
+
) : null}
|
|
942
|
+
|
|
943
|
+
{!isNotify ? <CommandView argv={item.argv} defaultExpanded={false} /> : null}
|
|
944
|
+
|
|
945
|
+
<KeyValueGroup columns={2}>
|
|
946
|
+
<KeyValue label="By" value={item.by || "local"} />
|
|
947
|
+
<KeyValue label="CWD" value={item.cwd} mono stackOnCompact />
|
|
948
|
+
</KeyValueGroup>
|
|
949
|
+
|
|
950
|
+
{executed && preview.trim().length > 0 ? (
|
|
951
|
+
<Collapsible
|
|
952
|
+
title={failed ? `Output · exit ${item.exit}` : "Output"}
|
|
953
|
+
icon="ScrollText"
|
|
954
|
+
initiallyExpanded={false}
|
|
955
|
+
>
|
|
956
|
+
<CodeBlock code={preview} maxHeight={180} />
|
|
957
|
+
</Collapsible>
|
|
958
|
+
) : null}
|
|
959
|
+
</Card>
|
|
960
|
+
);
|
|
961
|
+
}
|
|
962
|
+
|
|
963
|
+
function TelegramStatusBar() {
|
|
964
|
+
const { colors } = usePluginTheme();
|
|
965
|
+
const tg = useTelegramInfo();
|
|
966
|
+
const info = tg.data;
|
|
967
|
+
|
|
968
|
+
if (tg.isPending && !info) return null;
|
|
969
|
+
|
|
970
|
+
const isConnected = info?.status === "connected" || Boolean(info?.configured);
|
|
971
|
+
const badgeVariant = isConnected ? "success" : info?.status === "error" ? "danger" : "neutral";
|
|
972
|
+
|
|
973
|
+
return (
|
|
974
|
+
<Card variant="flat" style={{ gap: 6 }}>
|
|
975
|
+
<View style={{ flexDirection: "row", alignItems: "center", justifyContent: "space-between" }}>
|
|
976
|
+
<View style={{ flexDirection: "row", alignItems: "center", gap: 8 }}>
|
|
977
|
+
<StatusDot variant={badgeVariant} size="md" pulse={isConnected} />
|
|
978
|
+
<Text style={{ color: colors.foreground, fontSize: 12, fontWeight: "600" }}>
|
|
979
|
+
Telegram {isConnected ? "Active" : "Fallback"}
|
|
980
|
+
</Text>
|
|
981
|
+
</View>
|
|
982
|
+
<View style={{ flexDirection: "row", alignItems: "center", gap: 6 }}>
|
|
983
|
+
<Badge
|
|
984
|
+
label={isConnected ? "connected" : "unconfigured"}
|
|
985
|
+
variant={badgeVariant}
|
|
986
|
+
styleVariant="tinted"
|
|
987
|
+
/>
|
|
988
|
+
</View>
|
|
989
|
+
</View>
|
|
990
|
+
{(info?.botUsername || info?.chatId) ? (
|
|
991
|
+
<KeyValueGroup columns={2}>
|
|
992
|
+
{info?.botUsername ? (
|
|
993
|
+
<KeyValue label="Bot" value={`@${info.botUsername}`} mono />
|
|
994
|
+
) : null}
|
|
995
|
+
{info?.chatId ? (
|
|
996
|
+
<KeyValue label="Chat ID" value={info.chatId} mono copyable />
|
|
997
|
+
) : null}
|
|
998
|
+
</KeyValueGroup>
|
|
999
|
+
) : null}
|
|
1000
|
+
</Card>
|
|
1001
|
+
);
|
|
1002
|
+
}
|
|
1003
|
+
|
|
1004
|
+
function ApprovalSurfaceInner({
|
|
1005
|
+
theme,
|
|
1006
|
+
layout,
|
|
1007
|
+
}: PluginSurfaceProps) {
|
|
1008
|
+
const toast = useToast();
|
|
1009
|
+
const query = usePendingList();
|
|
1010
|
+
const recent = useRecentList();
|
|
1011
|
+
const socketPath = useSocketPath();
|
|
1012
|
+
const decide = useRpc(verdict);
|
|
1013
|
+
const ackNotify = useRpc(approvalAck);
|
|
1014
|
+
const getStatus = useRpc(approvalStatus);
|
|
1015
|
+
const addRule = useRpc(policyAddRule);
|
|
1016
|
+
const queryClient = useQueryClient();
|
|
1017
|
+
const isMountedRef = useRef(true);
|
|
1018
|
+
|
|
1019
|
+
useEffect(() => {
|
|
1020
|
+
isMountedRef.current = true;
|
|
1021
|
+
return () => {
|
|
1022
|
+
isMountedRef.current = false;
|
|
1023
|
+
};
|
|
1024
|
+
}, []);
|
|
1025
|
+
|
|
1026
|
+
const [decidingMap, setDecidingMap] = useState<Record<string, "approve" | "deny">>({});
|
|
1027
|
+
const [ackingMap, setAckingMap] = useState<Record<string, boolean>>({});
|
|
1028
|
+
const [policyDialog, setPolicyDialog] = useState<{
|
|
1029
|
+
item: { id: string; argv: string[]; cwd: string; preview?: { resolvedBinary?: string } };
|
|
1030
|
+
target: "whitelist" | "blacklist";
|
|
1031
|
+
} | null>(null);
|
|
1032
|
+
const [policyScope, setPolicyScope] = useState<"exact" | "base" | "custom">("exact");
|
|
1033
|
+
const [customPattern, setCustomPattern] = useState("");
|
|
1034
|
+
const [policySaving, setPolicySaving] = useState(false);
|
|
1035
|
+
const [activeTab, setActiveTab] = useState<"pending" | "history">("pending");
|
|
1036
|
+
const [activeExecutions, setActiveExecutions] = useState<
|
|
1037
|
+
Record<
|
|
1038
|
+
string,
|
|
1039
|
+
{
|
|
1040
|
+
id: string;
|
|
1041
|
+
decision: "approve" | "deny";
|
|
1042
|
+
status:
|
|
1043
|
+
| "running"
|
|
1044
|
+
| "confirming"
|
|
1045
|
+
| "completed"
|
|
1046
|
+
| "denied"
|
|
1047
|
+
| "timeout"
|
|
1048
|
+
| "not_found"
|
|
1049
|
+
| "client_aborted"
|
|
1050
|
+
| "confirmation_timeout";
|
|
1051
|
+
argv?: string[];
|
|
1052
|
+
cwd?: string;
|
|
1053
|
+
exit?: number;
|
|
1054
|
+
output?: string;
|
|
1055
|
+
}
|
|
1056
|
+
>
|
|
1057
|
+
>({});
|
|
1058
|
+
|
|
1059
|
+
const handleDecide = async (
|
|
1060
|
+
item: { id: string; argv: string[]; cwd: string },
|
|
1061
|
+
decision: "approve" | "deny",
|
|
1062
|
+
) => {
|
|
1063
|
+
const { id } = item;
|
|
1064
|
+
setDecidingMap((prev) => ({ ...prev, [id]: decision }));
|
|
1065
|
+
|
|
1066
|
+
try {
|
|
1067
|
+
const res = await decide({ id, decision, socketPath });
|
|
1068
|
+
if (!isMountedRef.current) return;
|
|
1069
|
+
|
|
1070
|
+
if (!res.recorded) {
|
|
1071
|
+
setDecidingMap((prev) => {
|
|
1072
|
+
const next = { ...prev };
|
|
1073
|
+
delete next[id];
|
|
1074
|
+
return next;
|
|
1075
|
+
});
|
|
1076
|
+
toast.error("Already decided or 2fadod unreachable.");
|
|
1077
|
+
void queryClient.invalidateQueries({ queryKey: LIST_KEY });
|
|
1078
|
+
void queryClient.invalidateQueries({ queryKey: RECENT_KEY });
|
|
1079
|
+
return;
|
|
1080
|
+
}
|
|
1081
|
+
|
|
1082
|
+
setDecidingMap((prev) => {
|
|
1083
|
+
const next = { ...prev };
|
|
1084
|
+
delete next[id];
|
|
1085
|
+
return next;
|
|
1086
|
+
});
|
|
1087
|
+
void queryClient.invalidateQueries({ queryKey: LIST_KEY });
|
|
1088
|
+
|
|
1089
|
+
if (decision === "deny") {
|
|
1090
|
+
toast.show("Denied command", { variant: "default" });
|
|
1091
|
+
void queryClient.invalidateQueries({ queryKey: RECENT_KEY });
|
|
1092
|
+
return;
|
|
1093
|
+
}
|
|
1094
|
+
|
|
1095
|
+
// If approved, track in activeExecutions and poll status
|
|
1096
|
+
setActiveExecutions((prev) => ({
|
|
1097
|
+
...prev,
|
|
1098
|
+
[id]: {
|
|
1099
|
+
id,
|
|
1100
|
+
decision: "approve",
|
|
1101
|
+
status: "running",
|
|
1102
|
+
argv: item.argv,
|
|
1103
|
+
cwd: item.cwd,
|
|
1104
|
+
},
|
|
1105
|
+
}));
|
|
1106
|
+
|
|
1107
|
+
// Start targeted polling for this request
|
|
1108
|
+
void (async () => {
|
|
1109
|
+
await new Promise((r) => setTimeout(r, 250));
|
|
1110
|
+
const maxAttempts = 60; // 60 * 350ms ~ 21s
|
|
1111
|
+
for (let attempt = 0; attempt < maxAttempts; attempt++) {
|
|
1112
|
+
if (!isMountedRef.current) return;
|
|
1113
|
+
try {
|
|
1114
|
+
const statusRes = await getStatus({ id, socketPath });
|
|
1115
|
+
if (!isMountedRef.current) return;
|
|
1116
|
+
|
|
1117
|
+
if (statusRes.status === "confirming") {
|
|
1118
|
+
setActiveExecutions((prev) => {
|
|
1119
|
+
const next = { ...prev };
|
|
1120
|
+
delete next[id];
|
|
1121
|
+
return next;
|
|
1122
|
+
});
|
|
1123
|
+
void queryClient.invalidateQueries({ queryKey: LIST_KEY });
|
|
1124
|
+
void queryClient.invalidateQueries({ queryKey: RECENT_KEY });
|
|
1125
|
+
return;
|
|
1126
|
+
}
|
|
1127
|
+
|
|
1128
|
+
if (statusRes.status === "completed") {
|
|
1129
|
+
setActiveExecutions((prev) => ({
|
|
1130
|
+
...prev,
|
|
1131
|
+
[id]: {
|
|
1132
|
+
id,
|
|
1133
|
+
decision: "approve",
|
|
1134
|
+
status: "completed",
|
|
1135
|
+
argv: statusRes.argv ?? item.argv,
|
|
1136
|
+
cwd: statusRes.cwd ?? item.cwd,
|
|
1137
|
+
exit: statusRes.exit,
|
|
1138
|
+
output: statusRes.output,
|
|
1139
|
+
},
|
|
1140
|
+
}));
|
|
1141
|
+
void queryClient.invalidateQueries({ queryKey: RECENT_KEY });
|
|
1142
|
+
|
|
1143
|
+
const prog = statusRes.argv?.[0] ?? item.argv[0] ?? "Command";
|
|
1144
|
+
if (statusRes.exit === 0) {
|
|
1145
|
+
toast.show(`${prog} completed (exit 0)`, { variant: "success" });
|
|
1146
|
+
} else {
|
|
1147
|
+
toast.show(`${prog} failed (exit ${statusRes.exit})`, { variant: "warning" });
|
|
1148
|
+
}
|
|
1149
|
+
|
|
1150
|
+
// Keep card visible briefly to show final exit badge, then clean up
|
|
1151
|
+
setTimeout(() => {
|
|
1152
|
+
if (!isMountedRef.current) return;
|
|
1153
|
+
setActiveExecutions((prev) => {
|
|
1154
|
+
const next = { ...prev };
|
|
1155
|
+
delete next[id];
|
|
1156
|
+
return next;
|
|
1157
|
+
});
|
|
1158
|
+
}, 800);
|
|
1159
|
+
return;
|
|
1160
|
+
}
|
|
1161
|
+
|
|
1162
|
+
if (
|
|
1163
|
+
statusRes.status === "denied" ||
|
|
1164
|
+
statusRes.status === "timeout" ||
|
|
1165
|
+
statusRes.status === "not_found" ||
|
|
1166
|
+
statusRes.status === "client_aborted" ||
|
|
1167
|
+
statusRes.status === "confirmation_timeout"
|
|
1168
|
+
) {
|
|
1169
|
+
void queryClient.invalidateQueries({ queryKey: LIST_KEY });
|
|
1170
|
+
void queryClient.invalidateQueries({ queryKey: RECENT_KEY });
|
|
1171
|
+
setActiveExecutions((prev) => {
|
|
1172
|
+
const next = { ...prev };
|
|
1173
|
+
delete next[id];
|
|
1174
|
+
return next;
|
|
1175
|
+
});
|
|
1176
|
+
if (statusRes.status === "confirmation_timeout") {
|
|
1177
|
+
toast.error("Confirmation timed out — command cancelled");
|
|
1178
|
+
} else if (statusRes.status === "client_aborted") {
|
|
1179
|
+
toast.error("Calling process aborted command");
|
|
1180
|
+
} else if (statusRes.status === "timeout") {
|
|
1181
|
+
toast.error("Execution timed out");
|
|
1182
|
+
}
|
|
1183
|
+
return;
|
|
1184
|
+
}
|
|
1185
|
+
} catch {
|
|
1186
|
+
// Transient error; continue polling
|
|
1187
|
+
}
|
|
1188
|
+
await new Promise((r) => setTimeout(r, 350));
|
|
1189
|
+
}
|
|
1190
|
+
|
|
1191
|
+
// Timeout polling fallback
|
|
1192
|
+
if (isMountedRef.current) {
|
|
1193
|
+
void queryClient.invalidateQueries({ queryKey: RECENT_KEY });
|
|
1194
|
+
setActiveExecutions((prev) => {
|
|
1195
|
+
const next = { ...prev };
|
|
1196
|
+
delete next[id];
|
|
1197
|
+
return next;
|
|
1198
|
+
});
|
|
1199
|
+
}
|
|
1200
|
+
})();
|
|
1201
|
+
} catch (err) {
|
|
1202
|
+
if (!isMountedRef.current) return;
|
|
1203
|
+
setDecidingMap((prev) => {
|
|
1204
|
+
const next = { ...prev };
|
|
1205
|
+
delete next[id];
|
|
1206
|
+
return next;
|
|
1207
|
+
});
|
|
1208
|
+
console.warn("[2fado] Verdict RPC failed:", err);
|
|
1209
|
+
toast.error("Verdict failed — 2fadod unreachable.");
|
|
1210
|
+
}
|
|
1211
|
+
};
|
|
1212
|
+
|
|
1213
|
+
const handleAck = async (item: { id: string }) => {
|
|
1214
|
+
const { id } = item;
|
|
1215
|
+
setAckingMap((prev) => ({ ...prev, [id]: true }));
|
|
1216
|
+
try {
|
|
1217
|
+
const res = await ackNotify({ id, socketPath });
|
|
1218
|
+
if (!isMountedRef.current) return;
|
|
1219
|
+
setAckingMap((prev) => {
|
|
1220
|
+
const next = { ...prev };
|
|
1221
|
+
delete next[id];
|
|
1222
|
+
return next;
|
|
1223
|
+
});
|
|
1224
|
+
if (!res.acked) {
|
|
1225
|
+
toast.error("Already acked, expired, or 2fadod unreachable.");
|
|
1226
|
+
} else {
|
|
1227
|
+
toast.show("Acknowledged", { variant: "success" });
|
|
1228
|
+
}
|
|
1229
|
+
void queryClient.invalidateQueries({ queryKey: LIST_KEY });
|
|
1230
|
+
void queryClient.invalidateQueries({ queryKey: RECENT_KEY });
|
|
1231
|
+
} catch (err) {
|
|
1232
|
+
if (!isMountedRef.current) return;
|
|
1233
|
+
setAckingMap((prev) => {
|
|
1234
|
+
const next = { ...prev };
|
|
1235
|
+
delete next[id];
|
|
1236
|
+
return next;
|
|
1237
|
+
});
|
|
1238
|
+
console.warn("[2fado] Ack RPC failed:", err);
|
|
1239
|
+
toast.error("Ack failed — 2fadod unreachable.");
|
|
1240
|
+
}
|
|
1241
|
+
};
|
|
1242
|
+
|
|
1243
|
+
useNewPendingToast(query.data?.items);
|
|
1244
|
+
|
|
1245
|
+
const items = query.data?.items ?? [];
|
|
1246
|
+
const notifyItems = items.filter((i) => i.kind === "notify");
|
|
1247
|
+
const execItems = items.filter((i) => i.kind !== "notify");
|
|
1248
|
+
const rawRecentItems = recent.data?.items ?? [];
|
|
1249
|
+
|
|
1250
|
+
// When a 2-step confirmation is in progress, the second approval replaces the first one:
|
|
1251
|
+
// 1. Hide approved 1st step from Recent while its 2nd step confirmation is active in Pending.
|
|
1252
|
+
// 2. Hide approved 1st step from Recent if the completed 2nd step confirmation is already in Recent.
|
|
1253
|
+
const pendingConfirmOfSet = new Set(
|
|
1254
|
+
items.map((i) => i.confirmOf).filter((c): c is string => Boolean(c)),
|
|
1255
|
+
);
|
|
1256
|
+
const recentConfirmOfSet = new Set(
|
|
1257
|
+
rawRecentItems.map((i) => i.confirmOf).filter((c): c is string => Boolean(c)),
|
|
1258
|
+
);
|
|
1259
|
+
|
|
1260
|
+
const recentItems = rawRecentItems.filter(
|
|
1261
|
+
(item) => !pendingConfirmOfSet.has(item.id) && !recentConfirmOfSet.has(item.id),
|
|
1262
|
+
);
|
|
1263
|
+
const activeList = Object.values(activeExecutions);
|
|
1264
|
+
|
|
1265
|
+
const openPolicyDialog = (
|
|
1266
|
+
item: { id: string; argv: string[]; cwd: string; preview?: { resolvedBinary?: string } },
|
|
1267
|
+
target: "whitelist" | "blacklist",
|
|
1268
|
+
) => {
|
|
1269
|
+
setPolicyScope("exact");
|
|
1270
|
+
setCustomPattern(item.argv.join(" "));
|
|
1271
|
+
setPolicyDialog({ item, target });
|
|
1272
|
+
};
|
|
1273
|
+
|
|
1274
|
+
const submitPolicyRule = async () => {
|
|
1275
|
+
if (!policyDialog || policySaving) return;
|
|
1276
|
+
const { item, target } = policyDialog;
|
|
1277
|
+
const base = item.preview?.resolvedBinary || item.argv[0] || "";
|
|
1278
|
+
const pattern =
|
|
1279
|
+
policyScope === "exact"
|
|
1280
|
+
? item.argv
|
|
1281
|
+
: policyScope === "base"
|
|
1282
|
+
? [base]
|
|
1283
|
+
: customPattern.split(/\s+/).filter(Boolean);
|
|
1284
|
+
if (pattern.length === 0) {
|
|
1285
|
+
toast.error("Pattern must not be empty.");
|
|
1286
|
+
return;
|
|
1287
|
+
}
|
|
1288
|
+
setPolicySaving(true);
|
|
1289
|
+
try {
|
|
1290
|
+
const res = await addRule({
|
|
1291
|
+
target,
|
|
1292
|
+
match_type: policyScope,
|
|
1293
|
+
pattern,
|
|
1294
|
+
socketPath,
|
|
1295
|
+
});
|
|
1296
|
+
if (!res.success) {
|
|
1297
|
+
toast.error(`Rule save failed: ${res.error ?? "unknown error"}`);
|
|
1298
|
+
return;
|
|
1299
|
+
}
|
|
1300
|
+
toast.show(
|
|
1301
|
+
`Rule saved (${res.rules_count ?? "?"} total) — ${target === "whitelist" ? "approving" : "denying"}`,
|
|
1302
|
+
{ variant: "success" },
|
|
1303
|
+
);
|
|
1304
|
+
setPolicyDialog(null);
|
|
1305
|
+
await handleDecide(item, target === "whitelist" ? "approve" : "deny");
|
|
1306
|
+
} catch (err) {
|
|
1307
|
+
console.warn("[2fado] Policy rule save failed:", err);
|
|
1308
|
+
toast.error("Rule save failed — 2fadod unreachable.");
|
|
1309
|
+
} finally {
|
|
1310
|
+
if (isMountedRef.current) setPolicySaving(false);
|
|
1311
|
+
}
|
|
1312
|
+
};
|
|
1313
|
+
|
|
1314
|
+
const policyBase = policyDialog
|
|
1315
|
+
? policyDialog.item.preview?.resolvedBinary || policyDialog.item.argv[0] || ""
|
|
1316
|
+
: "";
|
|
1317
|
+
const policyExact = policyDialog ? policyDialog.item.argv.join(" ") : "";
|
|
1318
|
+
|
|
1319
|
+
return (
|
|
1320
|
+
<PluginThemeProvider theme={{ colors: theme.colors }} layout={layout}>
|
|
1321
|
+
<ModalBody
|
|
1322
|
+
size="large"
|
|
1323
|
+
maxContentWidth={TWOFADO_CONTENT_MAX_WIDTH}
|
|
1324
|
+
style={{ backgroundColor: theme.colors.surface0 }}
|
|
1325
|
+
contentContainerStyle={{
|
|
1326
|
+
paddingHorizontal: layout.compact ? 12 : 20,
|
|
1327
|
+
paddingTop: layout.compact ? 12 : 20,
|
|
1328
|
+
}}
|
|
1329
|
+
>
|
|
1330
|
+
<View style={{ width: "100%", gap: 10 }}>
|
|
1331
|
+
<Card.Header
|
|
1332
|
+
title="2fado approvals"
|
|
1333
|
+
subtitle="Privileged command gating"
|
|
1334
|
+
icon="ShieldCheck"
|
|
1335
|
+
/>
|
|
1336
|
+
|
|
1337
|
+
<TelegramStatusBar />
|
|
1338
|
+
|
|
1339
|
+
<Tabs
|
|
1340
|
+
tabs={[
|
|
1341
|
+
{
|
|
1342
|
+
id: "pending",
|
|
1343
|
+
label: "Pending",
|
|
1344
|
+
icon: "ShieldCheck",
|
|
1345
|
+
badge: items.length > 0 ? items.length : undefined,
|
|
1346
|
+
},
|
|
1347
|
+
{
|
|
1348
|
+
id: "history",
|
|
1349
|
+
label: "History",
|
|
1350
|
+
icon: "History",
|
|
1351
|
+
},
|
|
1352
|
+
]}
|
|
1353
|
+
activeTab={activeTab}
|
|
1354
|
+
onTabChange={(id) => setActiveTab(id as "pending" | "history")}
|
|
1355
|
+
/>
|
|
1356
|
+
|
|
1357
|
+
{activeTab === "pending" ? (
|
|
1358
|
+
<>
|
|
1359
|
+
{query.isPending ? (
|
|
1360
|
+
<Text style={{ color: theme.colors.foregroundMuted, fontSize: 12 }}>Loading…</Text>
|
|
1361
|
+
) : null}
|
|
1362
|
+
{query.isError ? (
|
|
1363
|
+
<Card style={{ borderLeftWidth: 3, borderLeftColor: theme.colors.statusDanger }}>
|
|
1364
|
+
<View style={{ flexDirection: "row", alignItems: "center", gap: 8 }}>
|
|
1365
|
+
<Icon name="CloudOff" size={16} color={theme.colors.statusDanger} />
|
|
1366
|
+
<Text style={{ color: theme.colors.foreground, fontSize: 13, fontWeight: "600" }}>
|
|
1367
|
+
2fadod unreachable
|
|
1368
|
+
</Text>
|
|
1369
|
+
</View>
|
|
1370
|
+
<Text style={{ color: theme.colors.foregroundMuted, fontSize: 12, marginTop: 4 }}>
|
|
1371
|
+
Check the 2fadod socket path in daemon config.
|
|
1372
|
+
</Text>
|
|
1373
|
+
</Card>
|
|
1374
|
+
) : null}
|
|
1375
|
+
{!query.isPending && !query.isError && items.length === 0 && activeList.length === 0 ? (
|
|
1376
|
+
<EmptyState
|
|
1377
|
+
icon="ShieldCheck"
|
|
1378
|
+
title="All clear"
|
|
1379
|
+
description="No commands waiting for authorization."
|
|
1380
|
+
/>
|
|
1381
|
+
) : null}
|
|
1382
|
+
{notifyItems.map((item) => (
|
|
1383
|
+
<NotifyItem
|
|
1384
|
+
key={item.id}
|
|
1385
|
+
item={item}
|
|
1386
|
+
acking={ackingMap[item.id]}
|
|
1387
|
+
onAck={(target) => void handleAck(target)}
|
|
1388
|
+
/>
|
|
1389
|
+
))}
|
|
1390
|
+
{execItems.map((item) => (
|
|
1391
|
+
<ApprovalItem
|
|
1392
|
+
key={item.id}
|
|
1393
|
+
item={item}
|
|
1394
|
+
deciding={decidingMap[item.id]}
|
|
1395
|
+
onDecide={(target, decision) => handleDecide(target, decision)}
|
|
1396
|
+
onAlways={(target, decision) => openPolicyDialog(target, decision)}
|
|
1397
|
+
/>
|
|
1398
|
+
))}
|
|
1399
|
+
{policyDialog ? (
|
|
1400
|
+
<Card
|
|
1401
|
+
style={{
|
|
1402
|
+
borderLeftWidth: 3,
|
|
1403
|
+
borderLeftColor: theme.colors.accent,
|
|
1404
|
+
gap: 8,
|
|
1405
|
+
padding: 12,
|
|
1406
|
+
}}
|
|
1407
|
+
>
|
|
1408
|
+
<Text style={{ color: theme.colors.foreground, fontSize: 13, fontWeight: "700" }}>
|
|
1409
|
+
{policyDialog.target === "whitelist" ? "Always approve" : "Always deny"} — pick scope
|
|
1410
|
+
</Text>
|
|
1411
|
+
<Tabs
|
|
1412
|
+
tabs={[
|
|
1413
|
+
{ id: "exact", label: "Exact match", icon: "Check" },
|
|
1414
|
+
{ id: "base", label: "Base binary", icon: "Terminal" },
|
|
1415
|
+
{ id: "custom", label: "Custom pattern", icon: "Edit" },
|
|
1416
|
+
]}
|
|
1417
|
+
activeTab={policyScope}
|
|
1418
|
+
onTabChange={(id) => setPolicyScope(id as "exact" | "base" | "custom")}
|
|
1419
|
+
/>
|
|
1420
|
+
<Text style={{ color: theme.colors.foregroundMuted, fontSize: 11 }}>
|
|
1421
|
+
{policyScope === "exact"
|
|
1422
|
+
? policyExact
|
|
1423
|
+
: policyScope === "base"
|
|
1424
|
+
? policyBase
|
|
1425
|
+
: "Edit arguments below"}
|
|
1426
|
+
</Text>
|
|
1427
|
+
{policyScope === "custom" ? (
|
|
1428
|
+
<TextInput
|
|
1429
|
+
value={customPattern}
|
|
1430
|
+
onChangeText={setCustomPattern}
|
|
1431
|
+
placeholder="command arguments"
|
|
1432
|
+
mono
|
|
1433
|
+
/>
|
|
1434
|
+
) : null}
|
|
1435
|
+
<View style={{ flexDirection: "row", gap: 8 }}>
|
|
1436
|
+
<View style={{ flex: 1 }}>
|
|
1437
|
+
<Button
|
|
1438
|
+
label="Cancel"
|
|
1439
|
+
variant="secondary"
|
|
1440
|
+
size="sm"
|
|
1441
|
+
onPress={() => setPolicyDialog(null)}
|
|
1442
|
+
disabled={policySaving}
|
|
1443
|
+
/>
|
|
1444
|
+
</View>
|
|
1445
|
+
<View style={{ flex: 1 }}>
|
|
1446
|
+
<Button
|
|
1447
|
+
label={policyDialog.target === "whitelist" ? "Save & approve" : "Save & deny"}
|
|
1448
|
+
variant="primary"
|
|
1449
|
+
size="sm"
|
|
1450
|
+
icon="Check"
|
|
1451
|
+
onPress={() => void submitPolicyRule()}
|
|
1452
|
+
loading={policySaving}
|
|
1453
|
+
disabled={policySaving}
|
|
1454
|
+
/>
|
|
1455
|
+
</View>
|
|
1456
|
+
</View>
|
|
1457
|
+
</Card>
|
|
1458
|
+
) : null}
|
|
1459
|
+
|
|
1460
|
+
{activeList.length > 0 ? (
|
|
1461
|
+
<>
|
|
1462
|
+
<SectionHeader title="In flight" count={activeList.length} />
|
|
1463
|
+
{activeList.map((item) => (
|
|
1464
|
+
<ExecutingItem key={item.id} item={item} />
|
|
1465
|
+
))}
|
|
1466
|
+
</>
|
|
1467
|
+
) : null}
|
|
1468
|
+
</>
|
|
1469
|
+
) : (
|
|
1470
|
+
<>
|
|
1471
|
+
{recent.isPending ? (
|
|
1472
|
+
<Text style={{ color: theme.colors.foregroundMuted, fontSize: 12 }}>Loading…</Text>
|
|
1473
|
+
) : null}
|
|
1474
|
+
{!recent.isPending && recentItems.length === 0 ? (
|
|
1475
|
+
<EmptyState
|
|
1476
|
+
icon="History"
|
|
1477
|
+
title="No history yet"
|
|
1478
|
+
description="Decided requests will appear here with execution status and output."
|
|
1479
|
+
/>
|
|
1480
|
+
) : null}
|
|
1481
|
+
{recentItems.map((item) => (
|
|
1482
|
+
<RecentItem key={item.id} item={item} />
|
|
1483
|
+
))}
|
|
1484
|
+
</>
|
|
1485
|
+
)}
|
|
1486
|
+
|
|
1487
|
+
</View>
|
|
1488
|
+
</ModalBody>
|
|
1489
|
+
</PluginThemeProvider>
|
|
1490
|
+
);
|
|
1491
|
+
}
|
|
1492
|
+
|
|
1493
|
+
export function ApprovalSurface(props: PluginSurfaceProps) {
|
|
1494
|
+
const { theme, layout } = props;
|
|
1495
|
+
return (
|
|
1496
|
+
<ErrorBoundary
|
|
1497
|
+
label="ApprovalSurface"
|
|
1498
|
+
fallback={
|
|
1499
|
+
<PluginThemeProvider theme={{ colors: theme.colors }} layout={layout}>
|
|
1500
|
+
<ModalBody
|
|
1501
|
+
maxContentWidth={TWOFADO_CONTENT_MAX_WIDTH}
|
|
1502
|
+
style={{ backgroundColor: theme.colors.surface0 }}
|
|
1503
|
+
contentContainerStyle={{ padding: 20 }}
|
|
1504
|
+
>
|
|
1505
|
+
<View style={{ width: "100%", gap: 10 }}>
|
|
1506
|
+
<Card style={{ borderLeftWidth: 3, borderLeftColor: theme.colors.statusDanger }}>
|
|
1507
|
+
<View style={{ flexDirection: "row", alignItems: "center", gap: 8 }}>
|
|
1508
|
+
<Icon name="CloudOff" size={16} color={theme.colors.statusDanger} />
|
|
1509
|
+
<Text style={{ color: theme.colors.foreground, fontSize: 13, fontWeight: "600" }}>
|
|
1510
|
+
2fado panel hit a render error
|
|
1511
|
+
</Text>
|
|
1512
|
+
</View>
|
|
1513
|
+
<Text style={{ color: theme.colors.foregroundMuted, fontSize: 12, marginTop: 4 }}>
|
|
1514
|
+
Details are in the plugin logs. Re-open the panel to retry.
|
|
1515
|
+
</Text>
|
|
1516
|
+
</Card>
|
|
1517
|
+
</View>
|
|
1518
|
+
</ModalBody>
|
|
1519
|
+
</PluginThemeProvider>
|
|
1520
|
+
}
|
|
1521
|
+
>
|
|
1522
|
+
<ApprovalSurfaceInner {...props} />
|
|
1523
|
+
</ErrorBoundary>
|
|
1524
|
+
);
|
|
1525
|
+
}
|