@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.
Files changed (97) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +62 -0
  3. package/client/approvals.tsx +1525 -0
  4. package/client/error-boundary.tsx +28 -0
  5. package/client/vendor/paseo-plugin-helper/command-center.ts +43 -0
  6. package/client/vendor/paseo-plugin-helper/components/AboutSection.tsx +493 -0
  7. package/client/vendor/paseo-plugin-helper/components/AttentionBeacon.tsx +250 -0
  8. package/client/vendor/paseo-plugin-helper/components/Badge.tsx +156 -0
  9. package/client/vendor/paseo-plugin-helper/components/Button.tsx +178 -0
  10. package/client/vendor/paseo-plugin-helper/components/Card.tsx +225 -0
  11. package/client/vendor/paseo-plugin-helper/components/CodeBlock.tsx +196 -0
  12. package/client/vendor/paseo-plugin-helper/components/Collapsible.tsx +277 -0
  13. package/client/vendor/paseo-plugin-helper/components/CommandBox.tsx +172 -0
  14. package/client/vendor/paseo-plugin-helper/components/CopyButton.tsx +180 -0
  15. package/client/vendor/paseo-plugin-helper/components/DataTable.tsx +200 -0
  16. package/client/vendor/paseo-plugin-helper/components/EmptyState.tsx +97 -0
  17. package/client/vendor/paseo-plugin-helper/components/HighlightedText.tsx +70 -0
  18. package/client/vendor/paseo-plugin-helper/components/InlineButton.tsx +73 -0
  19. package/client/vendor/paseo-plugin-helper/components/KeyValue.tsx +446 -0
  20. package/client/vendor/paseo-plugin-helper/components/MetricGauge.tsx +247 -0
  21. package/client/vendor/paseo-plugin-helper/components/ProgressBar.tsx +117 -0
  22. package/client/vendor/paseo-plugin-helper/components/Responsive.tsx +53 -0
  23. package/client/vendor/paseo-plugin-helper/components/SearchInput.tsx +118 -0
  24. package/client/vendor/paseo-plugin-helper/components/SectionHeader.tsx +80 -0
  25. package/client/vendor/paseo-plugin-helper/components/Select.tsx +215 -0
  26. package/client/vendor/paseo-plugin-helper/components/StatusDot.tsx +80 -0
  27. package/client/vendor/paseo-plugin-helper/components/Tabs.tsx +319 -0
  28. package/client/vendor/paseo-plugin-helper/components/TextInput.tsx +150 -0
  29. package/client/vendor/paseo-plugin-helper/components/Toggle.tsx +163 -0
  30. package/client/vendor/paseo-plugin-helper/components/TruncatedText.tsx +157 -0
  31. package/client/vendor/paseo-plugin-helper/components/index.ts +25 -0
  32. package/client/vendor/paseo-plugin-helper/custom-pills.tsx +224 -0
  33. package/client/vendor/paseo-plugin-helper/forge-icon.tsx +79 -0
  34. package/client/vendor/paseo-plugin-helper/host.ts +277 -0
  35. package/client/vendor/paseo-plugin-helper/icon.tsx +39 -0
  36. package/client/vendor/paseo-plugin-helper/index.ts +28 -0
  37. package/client/vendor/paseo-plugin-helper/layout/ActionBar.tsx +49 -0
  38. package/client/vendor/paseo-plugin-helper/layout/FormRow.tsx +103 -0
  39. package/client/vendor/paseo-plugin-helper/layout/Grid.tsx +65 -0
  40. package/client/vendor/paseo-plugin-helper/layout/ModalBody.tsx +378 -0
  41. package/client/vendor/paseo-plugin-helper/layout/ModalContent.tsx +49 -0
  42. package/client/vendor/paseo-plugin-helper/layout/Row.tsx +39 -0
  43. package/client/vendor/paseo-plugin-helper/layout/Stack.tsx +39 -0
  44. package/client/vendor/paseo-plugin-helper/layout/index.ts +7 -0
  45. package/client/vendor/paseo-plugin-helper/panel.tsx +81 -0
  46. package/client/vendor/paseo-plugin-helper/pill.tsx +884 -0
  47. package/client/vendor/paseo-plugin-helper/query-refresh.ts +79 -0
  48. package/client/vendor/paseo-plugin-helper/query.ts +66 -0
  49. package/client/vendor/paseo-plugin-helper/settings-screen.tsx +372 -0
  50. package/client/vendor/paseo-plugin-helper/settings.ts +181 -0
  51. package/client/vendor/paseo-plugin-helper/shared-settings.ts +46 -0
  52. package/client/vendor/paseo-plugin-helper/snapshot.ts +68 -0
  53. package/client/vendor/paseo-plugin-helper/surface.tsx +80 -0
  54. package/client/vendor/paseo-plugin-helper/theme/color-utils.ts +118 -0
  55. package/client/vendor/paseo-plugin-helper/theme/flair.ts +76 -0
  56. package/client/vendor/paseo-plugin-helper/theme/host-variables.ts +121 -0
  57. package/client/vendor/paseo-plugin-helper/theme/index.ts +7 -0
  58. package/client/vendor/paseo-plugin-helper/theme/provider.tsx +214 -0
  59. package/client/vendor/paseo-plugin-helper/theme/responsive.ts +213 -0
  60. package/client/vendor/paseo-plugin-helper/theme/tokens.ts +161 -0
  61. package/client/vendor/paseo-plugin-helper/theme/useResponsive.ts +57 -0
  62. package/client/vendor/paseo-plugin-helper/utils/clipboard.ts +149 -0
  63. package/client/vendor/paseo-plugin-helper/utils/haptics.ts +34 -0
  64. package/package.json +36 -0
  65. package/paseo-plugin.json +6 -0
  66. package/server/twofado.ts +536 -0
  67. package/server/vendor/paseo-plugin-helper/agent.ts +85 -0
  68. package/server/vendor/paseo-plugin-helper/custom-pills.ts +344 -0
  69. package/server/vendor/paseo-plugin-helper/index.ts +18 -0
  70. package/server/vendor/paseo-plugin-helper/jsonc.ts +78 -0
  71. package/server/vendor/paseo-plugin-helper/logger.ts +210 -0
  72. package/server/vendor/paseo-plugin-helper/mcp-config.ts +367 -0
  73. package/server/vendor/paseo-plugin-helper/mcp-injection.ts +85 -0
  74. package/server/vendor/paseo-plugin-helper/network.ts +91 -0
  75. package/server/vendor/paseo-plugin-helper/plugins.ts +160 -0
  76. package/server/vendor/paseo-plugin-helper/process.ts +186 -0
  77. package/server/vendor/paseo-plugin-helper/redact.ts +86 -0
  78. package/server/vendor/paseo-plugin-helper/rpc-guard.ts +77 -0
  79. package/server/vendor/paseo-plugin-helper/settings.ts +97 -0
  80. package/server/vendor/paseo-plugin-helper/shared-settings.ts +243 -0
  81. package/server/vendor/paseo-plugin-helper/storage.ts +244 -0
  82. package/server/vendor/paseo-plugin-helper/system.ts +128 -0
  83. package/server/vendor/paseo-plugin-helper/task.ts +116 -0
  84. package/server/vendor/paseo-plugin-helper/version.ts +153 -0
  85. package/server/vendor/paseo-plugin-helper/workspace-beacon.ts +418 -0
  86. package/shared/approval.ts +254 -0
  87. package/shared/vendor/paseo-plugin-helper/async.ts +35 -0
  88. package/shared/vendor/paseo-plugin-helper/custom-pills.ts +169 -0
  89. package/shared/vendor/paseo-plugin-helper/forge.ts +110 -0
  90. package/shared/vendor/paseo-plugin-helper/formatters.ts +271 -0
  91. package/shared/vendor/paseo-plugin-helper/highlight.ts +184 -0
  92. package/shared/vendor/paseo-plugin-helper/index.ts +10 -0
  93. package/shared/vendor/paseo-plugin-helper/rpc.ts +72 -0
  94. package/shared/vendor/paseo-plugin-helper/settings.ts +138 -0
  95. package/shared/vendor/paseo-plugin-helper/suite-settings.ts +17 -0
  96. package/shared/vendor/paseo-plugin-helper/suppressed.ts +31 -0
  97. 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
+ }