feat: add secure multi-runtime controls

Make agent backends configurable and governable with encrypted credentials, explicit context sharing, and tool approval boundaries.

Co-authored-by: factory-droid[bot] <138933559+factory-droid[bot]@users.noreply.github.com>
This commit is contained in:
lofyer
2026-07-30 10:52:35 +08:00
co-authored by factory-droid[bot] <138933559+factory-droid[bot]@users.noreply.github.com>
parent 1b6178b841
commit 698a15ad14
27 changed files with 2465 additions and 91 deletions
+72 -3
View File
@@ -1,5 +1,12 @@
import { act, fireEvent, render, screen, waitFor } from '@testing-library/react'
import { beforeEach, describe, expect, it, vi } from 'vitest'
import {
act,
cleanup,
fireEvent,
render,
screen,
waitFor
} from '@testing-library/react'
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import type { AgentEvent, DesktopApi } from '../../shared/contracts'
import App from './App'
@@ -28,25 +35,57 @@ const api: DesktopApi = {
})),
run,
cancel: vi.fn(async () => {}),
respondApproval: vi.fn(async () => {}),
onEvent: vi.fn((listener) => {
agentListener = listener
return () => {
agentListener = undefined
}
})
},
settings: {
getRuntime: vi.fn<DesktopApi['settings']['getRuntime']>(async () => ({
provider: 'auto',
bigtokenBaseUrl: 'https://bigtoken.ai',
bigtokenModel: 'sonnet-5',
apiKeyConfigured: false,
credentialSource: 'none',
secureStorageAvailable: true,
toolApproval: 'always'
})),
updateRuntime: vi.fn<DesktopApi['settings']['updateRuntime']>(
async (input) => ({
provider: input.provider,
bigtokenBaseUrl: input.bigtokenBaseUrl,
bigtokenModel: input.bigtokenModel,
apiKeyConfigured: input.apiKey.action === 'replace',
credentialSource:
input.apiKey.action === 'replace' ? 'encrypted' : 'none',
secureStorageAvailable: true,
toolApproval: input.toolApproval
})
)
},
context: {
selectFiles: vi.fn(async () => []),
remove: vi.fn(async () => {})
}
}
describe('App', () => {
beforeEach(() => {
localStorage.clear()
run.mockReset()
vi.clearAllMocks()
Object.defineProperty(window, 'goodbuddy', {
configurable: true,
value: api
})
})
afterEach(() => {
cleanup()
})
it('sends a prompt and renders streamed agent content', async () => {
render(<App />)
@@ -76,4 +115,34 @@ describe('App', () => {
expect(await screen.findByText('这是回答内容')).toBeInTheDocument()
})
it('configures a runtime without reading an existing API key', async () => {
render(<App />)
fireEvent.click(await screen.findByText('本地工作区'))
expect(
await screen.findByRole('heading', {
name: '模型与 Agent Runtime'
})
).toBeInTheDocument()
const apiKeyInput = screen.getByLabelText('API Key')
expect(apiKeyInput).toHaveValue('')
fireEvent.change(apiKeyInput, {
target: { value: 'test-api-key' }
})
fireEvent.click(screen.getByRole('button', { name: '保存设置' }))
await waitFor(() =>
expect(api.settings.updateRuntime).toHaveBeenCalledWith(
expect.objectContaining({
apiKey: {
action: 'replace',
value: 'test-api-key'
}
})
)
)
await waitFor(() => expect(apiKeyInput).toHaveValue(''))
})
})
+182 -9
View File
@@ -21,8 +21,10 @@ import { useCallback, useEffect, useMemo, useRef, useState } from 'react'
import type {
AgentEvent,
AgentRuntimeStatus,
AppInfo
AppInfo,
ContextAttachment
} from '../../shared/contracts'
import { SettingsPanel } from './SettingsPanel'
type ToolActivity = {
name: string
@@ -38,6 +40,11 @@ type Message = {
state: 'streaming' | 'complete' | 'error'
status?: string
tools?: ToolActivity[]
approval?: {
id: string
title: string
description: string
}
}
type Conversation = {
@@ -120,6 +127,9 @@ function App(): React.JSX.Element {
const [runtime, setRuntime] = useState<AgentRuntimeStatus>()
const [appInfo, setAppInfo] = useState<AppInfo>()
const [sidebarOpen, setSidebarOpen] = useState(true)
const [settingsOpen, setSettingsOpen] = useState(false)
const [attachments, setAttachments] = useState<ContextAttachment[]>([])
const [contextError, setContextError] = useState<string>()
const activeRuns = useRef(new Map<string, ActiveRun>())
const inputRef = useRef<HTMLTextAreaElement>(null)
const scrollRef = useRef<HTMLDivElement>(null)
@@ -186,11 +196,22 @@ function App(): React.JSX.Element {
}
return { ...message, tools }
})
} else if (event.type === 'approval') {
updateMessage(run.conversationId, run.messageId, (message) => ({
...message,
status: undefined,
approval: {
id: event.approvalId,
title: event.title,
description: event.description
}
}))
} else {
updateMessage(run.conversationId, run.messageId, (message) => ({
...message,
state: event.type === 'error' ? 'error' : 'complete',
status: event.type === 'error' ? event.message : undefined,
approval: undefined,
content:
event.type === 'error' && !message.content
? event.message
@@ -203,7 +224,10 @@ function App(): React.JSX.Element {
)
useEffect(() => {
localStorage.setItem(storageKey, JSON.stringify(conversations))
const timeout = setTimeout(() => {
localStorage.setItem(storageKey, JSON.stringify(conversations))
}, 200)
return () => clearTimeout(timeout)
}, [conversations])
useEffect(() => {
@@ -216,6 +240,12 @@ function App(): React.JSX.Element {
const conversation = createConversation()
setConversations((current) => [conversation, ...current])
setActiveId(conversation.id)
setAttachments((current) => {
for (const attachment of current) {
void window.goodbuddy.context.remove(attachment.id)
}
return []
})
inputRef.current?.focus()
})
return () => {
@@ -225,10 +255,13 @@ function App(): React.JSX.Element {
}, [handleAgentEvent])
useEffect(() => {
scrollRef.current?.scrollTo({
top: scrollRef.current.scrollHeight,
behavior: 'smooth'
const frame = requestAnimationFrame(() => {
scrollRef.current?.scrollTo({
top: scrollRef.current.scrollHeight,
behavior: 'auto'
})
})
return () => cancelAnimationFrame(frame)
}, [activeConversation?.messages])
const newConversation = (): void => {
@@ -236,6 +269,10 @@ function App(): React.JSX.Element {
setConversations((current) => [conversation, ...current])
setActiveId(conversation.id)
setInput('')
for (const attachment of attachments) {
void window.goodbuddy.context.remove(attachment.id)
}
setAttachments([])
inputRef.current?.focus()
}
@@ -292,8 +329,13 @@ function App(): React.JSX.Element {
await window.goodbuddy.agent.run({
requestId,
conversationId,
prompt
prompt,
contextIds: attachments.map((attachment) => attachment.id)
})
for (const attachment of attachments) {
void window.goodbuddy.context.remove(attachment.id)
}
setAttachments([])
} catch (error) {
handleAgentEvent({
requestId,
@@ -312,6 +354,29 @@ function App(): React.JSX.Element {
}
}
const respondToApproval = async (
conversationId: string,
messageId: string,
approvalId: string,
approved: boolean
): Promise<void> => {
try {
await window.goodbuddy.agent.respondApproval(approvalId, approved)
updateMessage(conversationId, messageId, (message) => ({
...message,
approval: undefined,
status: approved
? '已授权,Agent 正在执行'
: '已拒绝工具执行'
}))
} catch {
updateMessage(conversationId, messageId, (message) => ({
...message,
status: '审批响应失败,请重试'
}))
}
}
const isRunning =
activeConversation?.messages.some(
(message) => message.state === 'streaming'
@@ -378,7 +443,11 @@ function App(): React.JSX.Element {
</div>
<div className="sidebar-footer">
<button className="user-card" type="button">
<button
className="user-card"
type="button"
onClick={() => setSettingsOpen(true)}
>
<span className="avatar">GB</span>
<span className="user-card__copy">
<strong></strong>
@@ -486,6 +555,43 @@ function App(): React.JSX.Element {
<small>{tool.state}</small>
</div>
))}
{message.approval && (
<div className="approval-card">
<ShieldCheck size={18} />
<div>
<strong>{message.approval.title}</strong>
<p>{message.approval.description}</p>
</div>
<button
className="approval-card__deny"
onClick={() =>
void respondToApproval(
activeConversation.id,
message.id,
message.approval!.id,
false
)
}
type="button"
>
</button>
<button
className="approval-card__allow"
onClick={() =>
void respondToApproval(
activeConversation.id,
message.id,
message.approval!.id,
true
)
}
type="button"
>
</button>
</div>
)}
{message.status && (
<div
className={
@@ -506,6 +612,39 @@ function App(): React.JSX.Element {
<footer className="composer-wrap">
<div className="composer">
{attachments.length > 0 && (
<div className="context-list">
{attachments.map((attachment) => (
<div
className="context-chip"
key={attachment.id}
title={attachment.preview}
>
<FileText size={14} />
<span>
<strong>{attachment.name}</strong>
<small>
{Math.max(1, Math.ceil(attachment.size / 1024))} KB
</small>
</span>
<button
aria-label={`移除 ${attachment.name}`}
onClick={() => {
void window.goodbuddy.context.remove(attachment.id)
setAttachments((current) =>
current.filter(
(item) => item.id !== attachment.id
)
)
}}
type="button"
>
×
</button>
</div>
))}
</div>
)}
<textarea
aria-label="向 GoodBuddy 提问"
placeholder="给 GoodBuddy 发消息…"
@@ -522,7 +661,33 @@ function App(): React.JSX.Element {
/>
<div className="composer__toolbar">
<div className="composer__attachments">
<button type="button" aria-label="添加附件" title="下一阶段开放">
<button
type="button"
aria-label="添加附件"
onClick={() => {
setContextError(undefined)
void window.goodbuddy.context
.selectFiles()
.then((selected) => {
setAttachments((current) => [
...current,
...selected.filter(
(item) =>
!current.some(
(existing) => existing.id === item.id
)
)
])
})
.catch((reason: unknown) => {
setContextError(
reason instanceof Error
? reason.message
: '添加文件失败'
)
})
}}
>
<Paperclip size={18} />
</button>
<span className="divider" />
@@ -555,11 +720,19 @@ function App(): React.JSX.Element {
</div>
</div>
<p className="composer-hint">
AI
{contextError ??
'AI 可能会犯错。工具执行前请检查参数和权限。'}
{appInfo?.shortcut && ` 快捷唤起:${appInfo.shortcut}`}
</p>
</footer>
</main>
<SettingsPanel
open={settingsOpen}
onClose={() => setSettingsOpen(false)}
onSaved={() => {
void window.goodbuddy.agent.getStatus().then(setRuntime)
}}
/>
</div>
)
}
+276
View File
@@ -0,0 +1,276 @@
import { Check, KeyRound, LockKeyhole, X } from 'lucide-react'
import { useEffect, useState } from 'react'
import type {
RuntimeSettings,
RuntimeSettingsInput
} from '../../shared/contracts'
import { defaultRuntimeSettings } from '../../shared/contracts'
type SettingsPanelProps = {
open: boolean
onClose: () => void
onSaved: (settings: RuntimeSettings) => void
}
const credentialLabels: Record<
RuntimeSettings['credentialSource'],
string
> = {
none: '尚未配置',
encrypted: '已由系统安全存储加密',
environment: '由环境变量提供'
}
export function SettingsPanel({
open,
onClose,
onSaved
}: SettingsPanelProps): React.JSX.Element | null {
const [settings, setSettings] = useState<RuntimeSettings>()
const [provider, setProvider] =
useState<RuntimeSettingsInput['provider']>(
defaultRuntimeSettings.provider
)
const [baseUrl, setBaseUrl] = useState<string>(
defaultRuntimeSettings.bigtokenBaseUrl
)
const [model, setModel] = useState<string>(
defaultRuntimeSettings.bigtokenModel
)
const [apiKey, setApiKey] = useState('')
const [clearApiKey, setClearApiKey] = useState(false)
const [toolApproval, setToolApproval] =
useState<RuntimeSettingsInput['toolApproval']>(
defaultRuntimeSettings.toolApproval
)
const [saving, setSaving] = useState(false)
const [error, setError] = useState<string>()
const [saved, setSaved] = useState(false)
useEffect(() => {
if (!open) {
return
}
void window.goodbuddy.settings
.getRuntime()
.then((value) => {
setError(undefined)
setSaved(false)
setApiKey('')
setClearApiKey(false)
setSettings(value)
setProvider(value.provider)
setBaseUrl(value.bigtokenBaseUrl)
setModel(value.bigtokenModel)
setToolApproval(value.toolApproval)
})
.catch((reason: unknown) => {
setError(reason instanceof Error ? reason.message : '读取设置失败')
})
}, [open])
if (!open) {
return null
}
const environmentManaged = settings?.credentialSource === 'environment'
const close = (): void => {
setApiKey('')
setClearApiKey(false)
setError(undefined)
onClose()
}
const save = async (): Promise<void> => {
setSaving(true)
setError(undefined)
setSaved(false)
try {
const apiKeyUpdate: RuntimeSettingsInput['apiKey'] = clearApiKey
? { action: 'clear' }
: apiKey.trim()
? { action: 'replace', value: apiKey.trim() }
: { action: 'keep' }
const value = await window.goodbuddy.settings.updateRuntime({
provider,
bigtokenBaseUrl: baseUrl,
bigtokenModel: model,
apiKey: apiKeyUpdate,
toolApproval
})
setSettings(value)
setApiKey('')
setClearApiKey(false)
setSaved(true)
onSaved(value)
} catch (reason) {
setError(reason instanceof Error ? reason.message : '保存设置失败')
} finally {
setSaving(false)
}
}
return (
<div className="settings-backdrop" role="presentation">
<section
aria-labelledby="settings-title"
aria-modal="true"
className="settings-panel"
role="dialog"
>
<header className="settings-panel__header">
<div>
<p className="eyebrow">RUNTIME CONTROL</p>
<h2 id="settings-title"> Agent Runtime</h2>
</div>
<button
aria-label="关闭设置"
className="icon-button"
onClick={close}
type="button"
>
<X size={19} />
</button>
</header>
<div className="settings-panel__body">
<label className="field">
<span> Runtime</span>
<select
value={provider}
onChange={(event) =>
setProvider(
event.target.value as RuntimeSettingsInput['provider']
)
}
>
<option value="auto"></option>
<option value="bigtoken">Bigtoken </option>
<option value="opencode">OpenCode Agent</option>
<option value="continue">Continue CLI Agent</option>
</select>
<small>
使 OpenCode使 Bigtoken
</small>
</label>
<div className="settings-section">
<div className="settings-section__title">
<KeyRound size={17} />
<div>
<strong>Bigtoken</strong>
<small>Anthropic Messages API</small>
</div>
</div>
<label className="field">
<span></span>
<input
disabled={environmentManaged}
inputMode="url"
onChange={(event) => setBaseUrl(event.target.value)}
value={baseUrl}
/>
</label>
<label className="field">
<span></span>
<input
disabled={environmentManaged}
onChange={(event) => setModel(event.target.value)}
value={model}
/>
</label>
<label className="field">
<span>API Key</span>
<input
autoComplete="off"
disabled={
environmentManaged || !settings?.secureStorageAvailable
}
onChange={(event) => {
setApiKey(event.target.value)
setClearApiKey(false)
}}
placeholder={
settings?.apiKeyConfigured
? '已配置,留空保持不变'
: '输入 API Key'
}
type="password"
value={apiKey}
/>
</label>
<div className="credential-state">
<LockKeyhole size={15} />
<span>
{settings
? credentialLabels[settings.credentialSource]
: '正在读取凭据状态'}
</span>
{settings?.credentialSource === 'encrypted' && (
<button
onClick={() => {
setApiKey('')
setClearApiKey(true)
}}
type="button"
>
{clearApiKey ? '保存后清除' : '清除凭据'}
</button>
)}
</div>
{settings && !settings.secureStorageAvailable && (
<p className="settings-warning">
使
API Key
</p>
)}
</div>
<label className="field">
<span></span>
<select
value={toolApproval}
onChange={(event) =>
setToolApproval(
event.target.value as RuntimeSettingsInput['toolApproval']
)
}
>
<option value="always"></option>
<option value="session"></option>
<option value="workspace"></option>
<option value="policy"></option>
</select>
</label>
</div>
<footer className="settings-panel__footer">
<div className="settings-feedback">
{error && <span className="settings-error">{error}</span>}
{saved && (
<span className="settings-success">
<Check size={14} />
Runtime
</span>
)}
</div>
<button className="secondary-button" onClick={close} type="button">
</button>
<button
className="primary-button"
disabled={saving}
onClick={() => void save()}
type="button"
>
{saving ? '保存中…' : '保存设置'}
</button>
</footer>
</section>
</div>
)
}
+323
View File
@@ -603,6 +603,56 @@ textarea:focus-visible {
text-transform: uppercase;
}
.approval-card {
display: grid;
align-items: center;
padding: 12px;
border: 1px solid #dfc38f;
border-radius: 11px;
margin-top: 10px;
background: #fbf1dc;
color: #74552d;
gap: 10px;
grid-template-columns: auto minmax(0, 1fr) auto auto;
}
.approval-card > div {
min-width: 0;
}
.approval-card strong {
display: block;
color: #644822;
font-size: 11px;
}
.approval-card p {
margin: 3px 0 0;
color: #8a704c;
font-size: 9px;
line-height: 1.5;
}
.approval-card button {
min-height: 29px;
padding: 0 10px;
border-radius: 7px;
cursor: pointer;
font-size: 10px;
font-weight: 650;
}
.approval-card__deny {
border: 1px solid #dbc7a5;
background: #fffaf0;
color: #815e34;
}
.approval-card__allow {
background: #285943;
color: #fff;
}
.composer-wrap {
padding: 8px max(28px, calc((100% - 820px) / 2)) 15px;
background: linear-gradient(transparent, #f7f5f0 22%);
@@ -618,6 +668,59 @@ textarea:focus-visible {
0 2px 5px rgb(59 48 34 / 4%);
}
.context-list {
display: flex;
padding: 0 1px 9px;
overflow-x: auto;
gap: 7px;
}
.context-chip {
display: flex;
min-width: 150px;
max-width: 220px;
align-items: center;
padding: 7px 8px;
border: 1px solid #ddd8ce;
border-radius: 9px;
background: #f4f2ec;
color: #4a6055;
gap: 7px;
}
.context-chip > span {
display: flex;
min-width: 0;
flex: 1;
flex-direction: column;
}
.context-chip strong {
overflow: hidden;
font-size: 9px;
text-overflow: ellipsis;
white-space: nowrap;
}
.context-chip small {
color: #92958f;
font-size: 8px;
}
.context-chip button {
width: 21px;
height: 21px;
border-radius: 6px;
background: transparent;
color: #92958f;
cursor: pointer;
}
.context-chip button:hover {
background: #e6e2d9;
color: #9b5148;
}
.composer:focus-within {
border-color: #c9af82;
box-shadow:
@@ -720,6 +823,226 @@ textarea:focus-visible {
text-align: center;
}
.settings-backdrop {
position: fixed;
z-index: 50;
display: grid;
background: rgb(12 29 22 / 42%);
inset: 0;
place-items: center;
backdrop-filter: blur(8px);
}
.settings-panel {
display: grid;
width: min(620px, calc(100vw - 40px));
max-height: calc(100vh - 48px);
border: 1px solid #d9d2c6;
border-radius: 18px;
overflow: hidden;
background: #fbfaf6;
box-shadow: 0 30px 90px rgb(15 35 26 / 28%);
grid-template-rows: auto minmax(0, 1fr) auto;
}
.settings-panel__header {
display: flex;
align-items: center;
padding: 22px 24px 18px;
border-bottom: 1px solid #e6e1d8;
}
.settings-panel__header > div {
flex: 1;
}
.settings-panel__header .eyebrow {
margin-bottom: 5px;
}
.settings-panel__header h2 {
margin: 0;
color: #223d31;
font-family: Georgia, "Songti SC", serif;
font-size: 22px;
font-weight: 500;
}
.settings-panel__body {
display: flex;
flex-direction: column;
padding: 20px 24px 26px;
overflow-y: auto;
gap: 18px;
}
.settings-section {
display: flex;
flex-direction: column;
padding: 16px;
border: 1px solid #e2ddd4;
border-radius: 13px;
background: #f5f3ed;
gap: 13px;
}
.settings-section__title {
display: flex;
align-items: center;
color: #375a48;
gap: 9px;
}
.settings-section__title > div {
display: flex;
flex-direction: column;
}
.settings-section__title strong {
color: #2b4438;
font-size: 12px;
}
.settings-section__title small {
color: #90918c;
font-size: 9px;
}
.field {
display: flex;
flex-direction: column;
color: #3e4d45;
gap: 7px;
}
.field > span {
font-size: 11px;
font-weight: 650;
}
.field input,
.field select {
width: 100%;
min-height: 38px;
padding: 0 11px;
border: 1px solid #d8d2c7;
border-radius: 9px;
outline: 0;
background: #fffefa;
color: #34433b;
font-size: 12px;
}
.field input:focus,
.field select:focus {
border-color: #b9955e;
box-shadow: 0 0 0 3px rgb(185 149 94 / 12%);
}
.field input:disabled,
.field select:disabled {
background: #ebe9e3;
color: #92938e;
}
.field small {
color: #92938e;
font-size: 9px;
line-height: 1.45;
}
.credential-state {
display: flex;
align-items: center;
color: #6b756f;
font-size: 10px;
gap: 7px;
}
.credential-state span {
flex: 1;
}
.credential-state button {
padding: 4px 7px;
border-radius: 6px;
background: transparent;
color: #9b554d;
cursor: pointer;
font-size: 9px;
}
.credential-state button:hover {
background: #eee4df;
}
.settings-warning {
padding: 9px 10px;
border: 1px solid #e6cda5;
border-radius: 8px;
margin: 0;
background: #fbf0dc;
color: #856536;
font-size: 10px;
line-height: 1.55;
}
.settings-panel__footer {
display: flex;
align-items: center;
padding: 14px 24px;
border-top: 1px solid #e6e1d8;
background: #f7f5ef;
gap: 8px;
}
.settings-feedback {
flex: 1;
min-width: 0;
font-size: 10px;
}
.settings-error {
color: #a14940;
}
.settings-success {
display: flex;
align-items: center;
color: #397157;
gap: 5px;
}
.primary-button,
.secondary-button {
min-height: 35px;
padding: 0 13px;
border-radius: 8px;
cursor: pointer;
font-size: 11px;
font-weight: 650;
}
.primary-button {
background: #1d4a37;
color: #fff;
}
.primary-button:hover {
background: #286047;
}
.primary-button:disabled {
cursor: wait;
opacity: 0.55;
}
.secondary-button {
border: 1px solid #dad5cb;
background: #fffefa;
color: #59655f;
}
@keyframes pulse {
0%,
100% {