@steerable/agent-shell-web 0.6.15

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 (121) hide show
  1. package/LICENSE +91 -0
  2. package/README.md +20 -0
  3. package/package.json +69 -0
  4. package/public/favicon.ico +0 -0
  5. package/public/favicon.png +0 -0
  6. package/src/AppShell.tsx +24 -0
  7. package/src/assets/logo-generic.png +0 -0
  8. package/src/brand.test.ts +22 -0
  9. package/src/brand.ts +48 -0
  10. package/src/components/AgentSidebar.test.tsx +127 -0
  11. package/src/components/AgentSidebar.tsx +1010 -0
  12. package/src/components/ChatHeader.test.tsx +230 -0
  13. package/src/components/ChatHeader.tsx +436 -0
  14. package/src/components/DockPanelHeader.tsx +69 -0
  15. package/src/components/LocalLlmSettingsModal.test.tsx +372 -0
  16. package/src/components/LocalLlmSettingsModal.tsx +72 -0
  17. package/src/components/TerminalPanel.tsx +68 -0
  18. package/src/components/TerminalView.tsx +228 -0
  19. package/src/components/chat/ApprovalModal.test.tsx +83 -0
  20. package/src/components/chat/ApprovalModal.tsx +229 -0
  21. package/src/components/chat/AskUserModalHost.test.tsx +176 -0
  22. package/src/components/chat/AskUserModalHost.tsx +61 -0
  23. package/src/components/chat/AskUserQuestionMenu.tsx +621 -0
  24. package/src/components/chat/AssistantMessage.test.tsx +68 -0
  25. package/src/components/chat/AssistantMessage.tsx +469 -0
  26. package/src/components/chat/ChatInput.test.tsx +609 -0
  27. package/src/components/chat/ChatInput.tsx +2068 -0
  28. package/src/components/chat/ChatProjectBadge.tsx +362 -0
  29. package/src/components/chat/EmptyChat.tsx +18 -0
  30. package/src/components/chat/ExecPolicyPicker.test.tsx +26 -0
  31. package/src/components/chat/ExecPolicyPicker.tsx +120 -0
  32. package/src/components/chat/ExecutedActionsCard.tsx +216 -0
  33. package/src/components/chat/InterruptedTurnCard.tsx +53 -0
  34. package/src/components/chat/LocalChatPanel.tsx +408 -0
  35. package/src/components/chat/Markdown.test.tsx +170 -0
  36. package/src/components/chat/Markdown.tsx +432 -0
  37. package/src/components/chat/MessageList.tsx +332 -0
  38. package/src/components/chat/ModelPicker.test.tsx +149 -0
  39. package/src/components/chat/ModelPicker.tsx +289 -0
  40. package/src/components/chat/OrchestrationChildrenCard.tsx +40 -0
  41. package/src/components/chat/PlanTodoList.tsx +121 -0
  42. package/src/components/chat/ProjectTrustBanner.tsx +91 -0
  43. package/src/components/chat/SessionTreeModal.test.tsx +161 -0
  44. package/src/components/chat/SessionTreeModal.tsx +221 -0
  45. package/src/components/chat/StreamingStatus.tsx +95 -0
  46. package/src/components/chat/TaskOutcomeCards.test.tsx +97 -0
  47. package/src/components/chat/TaskOutcomeCards.tsx +128 -0
  48. package/src/components/chat/TaskPanelModal.test.tsx +198 -0
  49. package/src/components/chat/TaskPanelModal.tsx +310 -0
  50. package/src/components/chat/TaskProcessPanel.test.tsx +111 -0
  51. package/src/components/chat/TaskProcessPanel.tsx +208 -0
  52. package/src/components/chat/TurnProcessGroup.test.tsx +161 -0
  53. package/src/components/chat/TurnProcessGroup.tsx +193 -0
  54. package/src/components/chat/UnifiedDiff.tsx +31 -0
  55. package/src/components/chat/UserMessage.tsx +155 -0
  56. package/src/components/chat/elapsed.test.ts +42 -0
  57. package/src/components/chat/elapsed.ts +45 -0
  58. package/src/components/chat/executed-actions-model.test.ts +134 -0
  59. package/src/components/chat/executed-actions-model.ts +143 -0
  60. package/src/components/chat/orchestration-children-model.test.ts +115 -0
  61. package/src/components/chat/orchestration-children-model.ts +104 -0
  62. package/src/components/chat/timestamp.ts +24 -0
  63. package/src/components/chat/turn-timeline.test.ts +97 -0
  64. package/src/components/chat/turn-timeline.ts +132 -0
  65. package/src/components/chat/useChatTasks.test.ts +136 -0
  66. package/src/components/chat/useChatTasks.ts +134 -0
  67. package/src/components/chat/useCopy.ts +62 -0
  68. package/src/components/settings/AgentCapabilityFields.tsx +343 -0
  69. package/src/components/settings/AgentsSettingsPanel.test.tsx +229 -0
  70. package/src/components/settings/AgentsSettingsPanel.tsx +412 -0
  71. package/src/components/settings/DiagnoseSettingsPanel.tsx +119 -0
  72. package/src/components/settings/InsightsSettingsPanel.test.tsx +132 -0
  73. package/src/components/settings/InsightsSettingsPanel.tsx +309 -0
  74. package/src/components/settings/LlmSettingsPanel.tsx +875 -0
  75. package/src/components/settings/McpSettingsPanel.tsx +466 -0
  76. package/src/components/settings/ModelCapabilityChips.tsx +46 -0
  77. package/src/components/settings/ModelIdCombobox.test.tsx +88 -0
  78. package/src/components/settings/ModelIdCombobox.tsx +185 -0
  79. package/src/components/settings/SecuritySettingsPanel.test.tsx +122 -0
  80. package/src/components/settings/SecuritySettingsPanel.tsx +169 -0
  81. package/src/components/settings/SettingsSaveButton.tsx +35 -0
  82. package/src/components/settings/SkillsSettingsPanel.test.tsx +62 -0
  83. package/src/components/settings/SkillsSettingsPanel.tsx +334 -0
  84. package/src/components/settings/TelemetrySettingsPanel.tsx +173 -0
  85. package/src/components/settings/UsagePanel.tsx +196 -0
  86. package/src/components/settings/WebSearchSettingsPanel.test.tsx +90 -0
  87. package/src/components/settings/WebSearchSettingsPanel.tsx +183 -0
  88. package/src/components/settings/compat-flags-model.test.ts +71 -0
  89. package/src/components/settings/compat-flags-model.ts +57 -0
  90. package/src/components/settings/llm-vendors.test.ts +171 -0
  91. package/src/components/settings/llm-vendors.ts +275 -0
  92. package/src/components/settings/model-capabilities.test.ts +88 -0
  93. package/src/components/settings/model-capabilities.ts +123 -0
  94. package/src/components/settings/preset-choice-model.test.ts +96 -0
  95. package/src/components/settings/preset-choice-model.ts +75 -0
  96. package/src/fixtures/browser-dev-data.json +315 -0
  97. package/src/hooks/useChatsAndAgents.ts +182 -0
  98. package/src/layouts/AgentLayout.tsx +574 -0
  99. package/src/lib/attachments.ts +78 -0
  100. package/src/lib/browser-dev-electron-mock.ts +640 -0
  101. package/src/lib/chat-title.ts +33 -0
  102. package/src/lib/chat-transport.ts +346 -0
  103. package/src/lib/date-groups.ts +34 -0
  104. package/src/lib/demo-flag.ts +15 -0
  105. package/src/lib/electron-bridge.ts +270 -0
  106. package/src/lib/exec-policy.ts +25 -0
  107. package/src/lib/http-bridge.ts +293 -0
  108. package/src/lib/insights.ts +17 -0
  109. package/src/lib/local-api.ts +714 -0
  110. package/src/lib/pending-first-message.test.ts +17 -0
  111. package/src/lib/pending-first-message.ts +36 -0
  112. package/src/lib/slash-sources.ts +191 -0
  113. package/src/main.tsx +103 -0
  114. package/src/packs/registry.ts +64 -0
  115. package/src/pages/AgentPage.tsx +1226 -0
  116. package/src/pages/ChatPanelPreviewPage.tsx +264 -0
  117. package/src/pages/SettingsPage.test.tsx +102 -0
  118. package/src/pages/SettingsPage.tsx +197 -0
  119. package/src/styles/index.css +156 -0
  120. package/src/vite-env.d.ts +1 -0
  121. package/vite.base.ts +94 -0
@@ -0,0 +1,185 @@
1
+ import { useEffect, useMemo, useRef, useState } from 'react';
2
+ import { createPortal } from 'react-dom';
3
+ import { LuChevronDown } from 'react-icons/lu';
4
+ import { ModelCapabilityChips } from '@/components/settings/ModelCapabilityChips';
5
+ import type { ModelPickerRow } from '@/components/settings/llm-vendors';
6
+
7
+ interface ModelIdComboboxProps {
8
+ value: string;
9
+ options: Array<string | ModelPickerRow>;
10
+ placeholder?: string;
11
+ onChange: (value: string) => void;
12
+ }
13
+
14
+ interface MenuBox {
15
+ top: number;
16
+ left: number;
17
+ width: number;
18
+ }
19
+
20
+ function toRow(option: string | ModelPickerRow): ModelPickerRow {
21
+ return typeof option === 'string' ? { id: option, entry: null } : option;
22
+ }
23
+
24
+ /**
25
+ * 可手填的模型 id 选择器。不用原生 datalist:Electron / 滚动容器里
26
+ * 系统建议层会相对输入框错位,也无法跟主题对齐。
27
+ */
28
+ export function ModelIdCombobox({
29
+ value,
30
+ options,
31
+ placeholder,
32
+ onChange,
33
+ }: ModelIdComboboxProps) {
34
+ const [open, setOpen] = useState(false);
35
+ const rootRef = useRef<HTMLDivElement>(null);
36
+ const menuRef = useRef<HTMLUListElement>(null);
37
+ const [menuBox, setMenuBox] = useState<MenuBox | null>(null);
38
+
39
+ const rows = useMemo(() => options.map(toRow), [options]);
40
+
41
+ const visible = useMemo(() => {
42
+ const q = value.trim().toLowerCase();
43
+ if (!q || rows.some((row) => row.id === value)) return rows;
44
+ return rows.filter((row) => {
45
+ if (row.id.toLowerCase().includes(q)) return true;
46
+ const name = entryName(row);
47
+ return name != null && name.toLowerCase().includes(q);
48
+ });
49
+ }, [rows, value]);
50
+
51
+ const syncMenuBox = () => {
52
+ const el = rootRef.current;
53
+ if (!el) return;
54
+ const r = el.getBoundingClientRect();
55
+ setMenuBox({ top: r.bottom + 4, left: r.left, width: r.width });
56
+ };
57
+
58
+ const show = () => {
59
+ setOpen(true);
60
+ syncMenuBox();
61
+ };
62
+
63
+ useEffect(() => {
64
+ if (!open) return;
65
+ syncMenuBox();
66
+ const onLayout = () => syncMenuBox();
67
+ window.addEventListener('resize', onLayout);
68
+ document.addEventListener('scroll', onLayout, true);
69
+ return () => {
70
+ window.removeEventListener('resize', onLayout);
71
+ document.removeEventListener('scroll', onLayout, true);
72
+ };
73
+ }, [open]);
74
+
75
+ useEffect(() => {
76
+ if (!open) return;
77
+ const onPointerDown = (event: MouseEvent) => {
78
+ const target = event.target as Node;
79
+ if (rootRef.current?.contains(target) || menuRef.current?.contains(target)) return;
80
+ setOpen(false);
81
+ };
82
+ const onKey = (event: KeyboardEvent) => {
83
+ if (event.key !== 'Escape') return;
84
+ event.preventDefault();
85
+ event.stopImmediatePropagation();
86
+ setOpen(false);
87
+ };
88
+ document.addEventListener('mousedown', onPointerDown);
89
+ window.addEventListener('keydown', onKey, true);
90
+ return () => {
91
+ document.removeEventListener('mousedown', onPointerDown);
92
+ window.removeEventListener('keydown', onKey, true);
93
+ };
94
+ }, [open]);
95
+
96
+ const menu =
97
+ open && visible.length > 0 && menuBox && typeof document !== 'undefined'
98
+ ? createPortal(
99
+ <ul
100
+ ref={menuRef}
101
+ role="listbox"
102
+ data-testid="llm-model-options"
103
+ style={{ top: menuBox.top, left: menuBox.left, width: menuBox.width }}
104
+ className="fixed z-[200] max-h-72 overflow-y-auto rounded-agent-md border border-agent-border bg-agent-canvas py-1 shadow-lg"
105
+ >
106
+ {visible.map((row) => {
107
+ const selected = row.id === value;
108
+ const name = entryName(row);
109
+ return (
110
+ <li key={row.id}>
111
+ <button
112
+ type="button"
113
+ role="option"
114
+ aria-label={row.id}
115
+ aria-selected={selected}
116
+ onMouseDown={(event) => event.preventDefault()}
117
+ onClick={() => {
118
+ onChange(row.id);
119
+ setOpen(false);
120
+ }}
121
+ className={[
122
+ 'flex w-full flex-col gap-1 px-3 py-1.5 text-left text-sm',
123
+ selected
124
+ ? 'bg-agent-foreground/10 text-agent-foreground'
125
+ : 'text-agent-foreground hover:bg-agent-foreground/5',
126
+ ].join(' ')}
127
+ >
128
+ <span className="truncate">{row.id}</span>
129
+ {(name || row.entry) && (
130
+ <span className="flex min-w-0 flex-wrap items-center gap-1">
131
+ {name ? (
132
+ <span className="truncate text-[11px] text-agent-muted-foreground">
133
+ {name}
134
+ </span>
135
+ ) : null}
136
+ <ModelCapabilityChips entry={row.entry} />
137
+ </span>
138
+ )}
139
+ </button>
140
+ </li>
141
+ );
142
+ })}
143
+ </ul>,
144
+ document.body,
145
+ )
146
+ : null;
147
+
148
+ return (
149
+ <div ref={rootRef} className="relative">
150
+ <input
151
+ type="text"
152
+ data-testid="llm-model-input"
153
+ value={value}
154
+ autoComplete="off"
155
+ spellCheck={false}
156
+ onChange={(event) => {
157
+ onChange(event.target.value);
158
+ show();
159
+ }}
160
+ onFocus={show}
161
+ placeholder={placeholder}
162
+ className="h-9 w-full rounded-agent-md border border-agent-border bg-agent-canvas px-3 pr-8 text-sm text-agent-foreground focus:outline-none focus:ring-2 focus:ring-agent-foreground/30"
163
+ />
164
+ <button
165
+ type="button"
166
+ tabIndex={-1}
167
+ aria-label="打开模型列表"
168
+ onClick={() => {
169
+ if (open) setOpen(false);
170
+ else show();
171
+ }}
172
+ className="absolute right-1.5 top-1/2 -translate-y-1/2 rounded p-1 text-agent-muted-foreground hover:text-agent-foreground"
173
+ >
174
+ <LuChevronDown className="h-4 w-4" />
175
+ </button>
176
+ {menu}
177
+ </div>
178
+ );
179
+ }
180
+
181
+ function entryName(row: ModelPickerRow): string | null {
182
+ const name = row.entry?.name?.trim();
183
+ if (!name || name === row.id) return null;
184
+ return name;
185
+ }
@@ -0,0 +1,122 @@
1
+ import { cleanup, render, screen } from '@testing-library/react';
2
+ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
3
+ import type { SidecarSandboxPosture } from '@/lib/local-api';
4
+
5
+ // 沙箱诚实告知的渲染层锁定:收容失败必须说出「无法收容、已拒绝启动」,
6
+ // 而不是谎称进程仍在无隔离运行。posture/API 那一层由
7
+ // tests/sidecar/supervisor-sandbox.test.ts 与 sandbox-posture.e2e.test.ts 覆盖;
8
+ // 这里只管「用户看不看得见」。
9
+
10
+ const getSidecarSandboxPosture = vi.fn();
11
+
12
+ vi.mock('@/lib/electron-bridge', () => ({ isElectron: () => true }));
13
+ vi.mock('@/lib/local-api', () => ({
14
+ getSidecarSandboxPosture: () => getSidecarSandboxPosture(),
15
+ }));
16
+
17
+ const { SecuritySettingsPanel } = await import('./SecuritySettingsPanel');
18
+
19
+ function posture(over: Partial<SidecarSandboxPosture>): SidecarSandboxPosture {
20
+ return { backend: 'none', enforcement: 'none', reason: 'platform_unsupported', ...over };
21
+ }
22
+
23
+ beforeEach(() => {
24
+ getSidecarSandboxPosture.mockReset();
25
+ });
26
+
27
+ afterEach(cleanup);
28
+
29
+ describe('SecuritySettingsPanel — layer-1 态势披露', () => {
30
+ it.each([
31
+ ['platform_unsupported', '当前平台没有可用的进程沙箱后端'],
32
+ ['seatbelt_missing', '未找到 /usr/bin/sandbox-exec'],
33
+ ['profile_failed', '沙箱配置(Seatbelt profile)生成失败'],
34
+ ['wrap_failed', 'Linux 进程沙箱'],
35
+ ['helper_missing', '未找到 win-spawn-helper.exe'],
36
+ ] as const)('收容失败 %s:告知已拒绝启动,不谎称仍在跑', async (reason, detail) => {
37
+ getSidecarSandboxPosture.mockResolvedValue({ posture: posture({ reason }) });
38
+ render(<SecuritySettingsPanel />);
39
+
40
+ const headline = await screen.findByText(/无法收容、已拒绝启动 —/);
41
+ expect(headline.textContent).toContain(detail);
42
+ expect(screen.getByText(/没有启动/)).toBeTruthy();
43
+ expect(screen.queryByText(/未沙箱 —/)).toBeNull();
44
+ expect(screen.getByText(/STEERABLE_SIDECAR_SANDBOX=0/)).toBeTruthy();
45
+ });
46
+
47
+ it('手动关闭中性展示,不冒充告警也不谎称已隔离', async () => {
48
+ getSidecarSandboxPosture.mockResolvedValue({
49
+ posture: posture({ reason: 'disabled_by_env' }),
50
+ });
51
+ render(<SecuritySettingsPanel />);
52
+
53
+ expect((await screen.findByText(/已手动关闭 —/)).textContent).toContain(
54
+ 'STEERABLE_SIDECAR_SANDBOX=0',
55
+ );
56
+ expect(screen.queryByText(/无法收容、已拒绝启动 —/)).toBeNull();
57
+ expect(screen.getByText(/无操作系统隔离运行/)).toBeTruthy();
58
+ });
59
+
60
+ it('Seatbelt 生效时记为「部分」,不谎称完全强制', async () => {
61
+ getSidecarSandboxPosture.mockResolvedValue({
62
+ posture: posture({ backend: 'seatbelt', enforcement: 'partial', reason: 'active' }),
63
+ });
64
+ render(<SecuritySettingsPanel />);
65
+
66
+ expect(await screen.findByText(/Seatbelt · 部分强制/)).toBeTruthy();
67
+ expect(screen.getByText(/不识别主机名/)).toBeTruthy();
68
+ expect(screen.queryByText(/无法收容、已拒绝启动 —/)).toBeNull();
69
+ });
70
+
71
+ it('bwrap 生效时记为部分强制', async () => {
72
+ getSidecarSandboxPosture.mockResolvedValue({
73
+ posture: posture({ backend: 'bwrap', enforcement: 'partial', reason: 'active' }),
74
+ });
75
+ render(<SecuritySettingsPanel />);
76
+ expect(await screen.findByText(/bwrap · 部分强制/)).toBeTruthy();
77
+ });
78
+
79
+ it('sidecar 未就绪时不冒充任何一种姿态', async () => {
80
+ getSidecarSandboxPosture.mockRejectedValue(new Error('503'));
81
+ render(<SecuritySettingsPanel />);
82
+
83
+ expect(await screen.findByText(/sidecar 未就绪/)).toBeTruthy();
84
+ expect(screen.queryByText(/无法收容、已拒绝启动 —/)).toBeNull();
85
+ expect(screen.queryByText(/部分强制/)).toBeNull();
86
+ });
87
+
88
+ it('出网退回端口级时披露原因(此前只有主进程日志可见)', async () => {
89
+ getSidecarSandboxPosture.mockResolvedValue({
90
+ posture: posture({ backend: 'seatbelt', enforcement: 'partial', reason: 'active' }),
91
+ egress: {
92
+ mode: 'port-only-fallback',
93
+ reason: '检测到系统/环境代理(127.0.0.1:7890),按主机管控已退回端口级',
94
+ },
95
+ });
96
+ render(<SecuritySettingsPanel />);
97
+
98
+ expect(await screen.findByText(/已退回端口级/)).toBeTruthy();
99
+ expect(screen.getByText(/127\.0\.0\.1:7890/)).toBeTruthy();
100
+ });
101
+
102
+ it('按主机代理生效时说明逐项放行语义', async () => {
103
+ getSidecarSandboxPosture.mockResolvedValue({
104
+ posture: posture({ backend: 'seatbelt', enforcement: 'partial', reason: 'active' }),
105
+ egress: { mode: 'per-host-proxy', reason: null },
106
+ });
107
+ render(<SecuritySettingsPanel />);
108
+
109
+ expect(await screen.findByText(/按主机白名单代理生效中/)).toBeTruthy();
110
+ expect(screen.getByText(/逐项放行/)).toBeTruthy();
111
+ });
112
+
113
+ it('无 egress 字段(旧后端)时不渲染出网行', async () => {
114
+ getSidecarSandboxPosture.mockResolvedValue({
115
+ posture: posture({ backend: 'seatbelt', enforcement: 'partial', reason: 'active' }),
116
+ });
117
+ render(<SecuritySettingsPanel />);
118
+
119
+ await screen.findByText(/Seatbelt · 部分强制/);
120
+ expect(screen.queryByText(/出网管控/)).toBeNull();
121
+ });
122
+ });
@@ -0,0 +1,169 @@
1
+ import { useCallback, useEffect, useState } from 'react';
2
+ import { LuLoaderCircle, LuShieldCheck } from 'react-icons/lu';
3
+ import { isElectron } from '@/lib/electron-bridge';
4
+ import {
5
+ getSidecarSandboxPosture,
6
+ type EgressPosture,
7
+ type SidecarSandboxPosture,
8
+ } from '@/lib/local-api';
9
+
10
+ /**
11
+ * SecuritySettingsPanel — W4-3 layer-1(sidecar 进程沙箱)态势的持久披露。
12
+ *
13
+ * sidecar 进程持有服务商 API 密钥并接收不可信工具输出。收容失败时主进程
14
+ * 拒绝启动(不是裸跑),态势记在 supervisor / lastSpawnRefusal 上
15
+ * (GET /api/v2/sidecar/sandbox-posture)。手动关闭(sandbox:false /
16
+ * STEERABLE_SIDECAR_SANDBOX=0)中性展示;收容失败告知「无法收容、已拒绝启动」。
17
+ * layer-3(每次命令的沙箱)披露在工具卡片,不在此重复。
18
+ *
19
+ * 数据自管理:挂载拉一次,走 local-backend REST,无专用 IPC channel。
20
+ */
21
+
22
+ type PostureReason = SidecarSandboxPosture['reason'];
23
+
24
+ /** 收容失败、已拒绝启动(告警行首句)。 */
25
+ const REFUSED_REASON_TEXT: Partial<Record<PostureReason, string>> = {
26
+ platform_unsupported: '当前平台没有可用的进程沙箱后端',
27
+ seatbelt_missing: '未找到 /usr/bin/sandbox-exec',
28
+ profile_failed: '沙箱配置(Seatbelt profile)生成失败',
29
+ wrap_failed: 'Linux 进程沙箱(bwrap / Landlock)未能包住 sidecar',
30
+ helper_missing: '未找到 win-spawn-helper.exe',
31
+ };
32
+
33
+ /** 手动关闭原因的中文说明(中性行,不告警)。 */
34
+ const OPT_OUT_REASON_TEXT: Partial<Record<PostureReason, string>> = {
35
+ disabled_by_option: '已通过 sandbox: false 关闭',
36
+ disabled_by_env: '已通过 STEERABLE_SIDECAR_SANDBOX=0 关闭',
37
+ };
38
+
39
+ function activeHeadline(backend: SidecarSandboxPosture['backend']): string {
40
+ switch (backend) {
41
+ case 'seatbelt':
42
+ return 'Seatbelt · 部分强制';
43
+ case 'bwrap':
44
+ return 'bwrap · 部分强制';
45
+ case 'landlock':
46
+ return 'Landlock · 部分强制';
47
+ case 'windows-restricted-token':
48
+ return 'Windows 受限令牌 · 部分强制';
49
+ case 'none':
50
+ return '未沙箱';
51
+ }
52
+ }
53
+
54
+ function activeBody(backend: SidecarSandboxPosture['backend']): string {
55
+ switch (backend) {
56
+ case 'seatbelt':
57
+ return 'sidecar 进程在 macOS Seatbelt 下运行:写入限制在 ~/.steerable 与临时目录。远程主机的出站网络只能按端口限制(Seatbelt 不识别主机名),故记为「部分」而非「完全」。';
58
+ case 'bwrap':
59
+ return 'sidecar 进程在 bubblewrap 下运行:写入限制在 ~/.steerable 与临时目录。出站网络保持开启(sidecar 要访问 LLM),故记为「部分」。';
60
+ case 'landlock':
61
+ return 'sidecar 进程在 Linux Landlock 下运行:写入限制在 ~/.steerable 与临时目录。出站网络保持开启(sidecar 要访问 LLM),故记为「部分」。';
62
+ case 'windows-restricted-token':
63
+ return 'sidecar 进程在 Windows 受限令牌 + Job Object 下运行:写入限制在 ~/.steerable。网络策略未由 helper 强制(无 WFP),故记为「部分」。';
64
+ case 'none':
65
+ return '';
66
+ }
67
+ }
68
+
69
+ /** 出网管控态势行(W-egress-posture):退回分支此前只有主进程日志可见。 */
70
+ function EgressPostureRow({ egress }: { egress: EgressPosture }) {
71
+ if (egress.mode === 'per-host-proxy') {
72
+ return (
73
+ <p className="text-[11px] text-agent-muted-foreground">
74
+ 出网管控:按主机白名单代理生效中;白名单外的请求会请你逐项放行(仅本次会话有效)。
75
+ </p>
76
+ );
77
+ }
78
+ if (egress.mode === 'disabled') {
79
+ return (
80
+ <p className="text-[11px] text-agent-muted-foreground">
81
+ 出网管控:已关闭{egress.reason ? ` — ${egress.reason}` : ''}。
82
+ </p>
83
+ );
84
+ }
85
+ return (
86
+ <p className="text-[11px] text-amber-600 dark:text-amber-400">
87
+ 出网管控:已退回端口级(较弱的管控粒度){egress.reason ? ` — ${egress.reason}` : ''}。
88
+ </p>
89
+ );
90
+ }
91
+
92
+ export function SecuritySettingsPanel() {
93
+ const [posture, setPosture] = useState<SidecarSandboxPosture | null>(null);
94
+ const [egress, setEgress] = useState<EgressPosture | null>(null);
95
+ const [loading, setLoading] = useState(false);
96
+ const [unavailable, setUnavailable] = useState<string | null>(null);
97
+
98
+ const fetchPosture = useCallback(async () => {
99
+ if (!isElectron()) return;
100
+ setLoading(true);
101
+ setUnavailable(null);
102
+ try {
103
+ const res = await getSidecarSandboxPosture();
104
+ setPosture(res.posture ?? null);
105
+ setEgress(res.egress ?? null);
106
+ if (!res.posture) setUnavailable('无法读取沙箱状态');
107
+ } catch {
108
+ // 503 = sidecar 未就绪且没有收容失败记录。
109
+ setPosture(null);
110
+ setEgress(null);
111
+ setUnavailable('sidecar 未就绪 — 无法读取沙箱状态');
112
+ } finally {
113
+ setLoading(false);
114
+ }
115
+ }, []);
116
+
117
+ useEffect(() => {
118
+ void fetchPosture();
119
+ }, [fetchPosture]);
120
+
121
+ const refusedReason = posture ? REFUSED_REASON_TEXT[posture.reason] : undefined;
122
+ const optOutReason = posture ? OPT_OUT_REASON_TEXT[posture.reason] : undefined;
123
+
124
+ return (
125
+ <div className="bg-agent-muted/30 border border-agent-border/60 rounded-agent-md p-3.5 space-y-3">
126
+ <h4 className="text-xs font-semibold text-agent-foreground flex items-center gap-1.5">
127
+ <LuShieldCheck className="h-3.5 w-3.5 text-agent-muted-foreground" />
128
+ Sidecar 进程沙箱
129
+ </h4>
130
+
131
+ {!isElectron() ? (
132
+ <p className="text-[11px] text-agent-muted-foreground">需要在桌面客户端中打开</p>
133
+ ) : loading ? (
134
+ <div className="flex items-center gap-2 py-2 text-xs text-agent-muted-foreground">
135
+ <LuLoaderCircle className="h-3.5 w-3.5 animate-spin" />
136
+ 读取沙箱状态...
137
+ </div>
138
+ ) : unavailable ? (
139
+ <p className="text-[11px] text-agent-muted-foreground">{unavailable}</p>
140
+ ) : posture && refusedReason ? (
141
+ <div className="rounded-agent-md border border-agent-destructive/20 bg-agent-destructive/10 p-2.5 text-xs text-agent-destructive space-y-1.5">
142
+ <p className="font-medium">无法收容、已拒绝启动 — {refusedReason}。</p>
143
+ <p>
144
+ 持有服务商 API 密钥的 sidecar 进程没有启动,因此也没有在无隔离状态下运行。
145
+ 唯一裸跑入口是显式设置 STEERABLE_SIDECAR_SANDBOX=0。
146
+ </p>
147
+ <p>
148
+ 如需隔离,请安装对应平台的收容后端(macOS Seatbelt、Linux bwrap/Landlock、Windows
149
+ win-spawn-helper),或将本应用运行在容器内。
150
+ </p>
151
+ </div>
152
+ ) : posture && optOutReason ? (
153
+ <div className="rounded-agent-md border border-agent-border/60 bg-agent-canvas p-2.5 text-xs text-agent-muted-foreground space-y-1.5">
154
+ <p className="font-medium text-agent-foreground">已手动关闭 — {optOutReason}。</p>
155
+ <p>
156
+ sidecar 进程(持有服务商 API 密钥)无操作系统隔离运行。如需恢复,移除该设置并重启应用。
157
+ </p>
158
+ </div>
159
+ ) : posture ? (
160
+ <div className="rounded-agent-md border border-agent-border/60 bg-agent-canvas p-2.5 text-xs text-agent-muted-foreground space-y-1.5">
161
+ <p className="font-medium text-agent-foreground">{activeHeadline(posture.backend)}</p>
162
+ <p>{activeBody(posture.backend)}</p>
163
+ </div>
164
+ ) : null}
165
+
166
+ {egress && <EgressPostureRow egress={egress} />}
167
+ </div>
168
+ );
169
+ }
@@ -0,0 +1,35 @@
1
+ import { LuCheck, LuLoaderCircle } from 'react-icons/lu';
2
+
3
+ interface SettingsSaveButtonProps {
4
+ saving: boolean;
5
+ savedOk: boolean;
6
+ disabled?: boolean;
7
+ onClick: () => void;
8
+ testId?: string;
9
+ }
10
+
11
+ /** 设置页标题栏 / 本地模型表单共用的保存按钮。 */
12
+ export function SettingsSaveButton({
13
+ saving,
14
+ savedOk,
15
+ disabled,
16
+ onClick,
17
+ testId,
18
+ }: SettingsSaveButtonProps) {
19
+ return (
20
+ <button
21
+ type="button"
22
+ data-testid={testId}
23
+ onClick={onClick}
24
+ disabled={disabled}
25
+ className="flex h-8 items-center gap-1.5 rounded-full bg-agent-foreground px-5 text-xs font-medium text-agent-canvas transition-all hover:opacity-90 disabled:opacity-50"
26
+ >
27
+ {saving ? (
28
+ <LuLoaderCircle className="h-3.5 w-3.5 animate-spin" />
29
+ ) : savedOk ? (
30
+ <LuCheck className="h-3.5 w-3.5" />
31
+ ) : null}
32
+ 保存
33
+ </button>
34
+ );
35
+ }
@@ -0,0 +1,62 @@
1
+ import { cleanup, render, screen } from '@testing-library/react';
2
+ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
3
+
4
+ const request = vi.fn();
5
+
6
+ vi.mock('@/lib/electron-bridge', () => ({
7
+ isElectron: () => true,
8
+ getElectronBridge: () => ({
9
+ localBackend: { request },
10
+ local: {},
11
+ }),
12
+ }));
13
+
14
+ const { SkillsSettingsPanel } = await import('./SkillsSettingsPanel');
15
+
16
+ beforeEach(() => {
17
+ request.mockReset();
18
+ });
19
+
20
+ afterEach(cleanup);
21
+
22
+ describe('SkillsSettingsPanel', () => {
23
+ it('shows a 工作区 badge and hides uninstall for workspace skills', async () => {
24
+ request.mockResolvedValue({
25
+ skills: [
26
+ {
27
+ name: 'compute-recursion',
28
+ displayName: '递归函数计算',
29
+ description: '算递推第 n 项',
30
+ origin: 'workspace',
31
+ isBuiltin: false,
32
+ layer: 'catalog',
33
+ modelInvocable: false,
34
+ },
35
+ ],
36
+ });
37
+ render(<SkillsSettingsPanel />);
38
+ const row = await screen.findByTestId('skill-row-compute-recursion');
39
+ expect(row.textContent).toContain('工作区');
40
+ expect(row.textContent).not.toContain('内置');
41
+ expect(screen.queryByTestId('skill-uninstall-compute-recursion')).toBeNull();
42
+ });
43
+
44
+ it('keeps uninstall on imported user skills', async () => {
45
+ request.mockResolvedValue({
46
+ skills: [
47
+ {
48
+ name: 'frontend-design',
49
+ displayName: '',
50
+ description: 'UI skill',
51
+ origin: 'user',
52
+ isBuiltin: false,
53
+ layer: 'catalog',
54
+ modelInvocable: true,
55
+ },
56
+ ],
57
+ });
58
+ render(<SkillsSettingsPanel />);
59
+ await screen.findByTestId('skill-row-frontend-design');
60
+ expect(screen.getByTestId('skill-uninstall-frontend-design')).toBeTruthy();
61
+ });
62
+ });