fix:disable-agent-sandbox-at-runtime
This commit is contained in:
1 parent
3be45999f7
commit
c7d6c558e6
24 files changed
+147
-500
No files matched your search
@@ -8,21 +8,11 @@
|
||||
*/
|
||||
|
||||
import { promises as fs } from 'node:fs'
|
||||
import { join } from 'node:path'
|
||||
import { dirname } from 'node:path'
|
||||
import { dirname, isAbsolute, join, relative, resolve, sep } from 'node:path'
|
||||
import { spawn } from 'node:child_process'
|
||||
import { randomUUID } from 'node:crypto'
|
||||
import type { ToolSpec } from '@shared/types'
|
||||
import type { PermissionMode } from '@shared/conversation'
|
||||
import { assertActive, resolveSandboxPath } from '../../sandbox/policy'
|
||||
import { commitChanges, digest, fileChangeDigest, type FileChange } from '../../sandbox/transactions'
|
||||
import { executeSandboxCommand } from '../../sandbox/service'
|
||||
import { effectiveSandboxPolicy, sandboxPathDenied } from '../../sandbox/policy-store'
|
||||
import { authorizeSandboxWrite } from '../../sandbox/authorizations'
|
||||
import { verifiedBinary } from '../../sandbox/native-component'
|
||||
import { NativeWorkspaceReader } from '../../sandbox/filesystem-reader'
|
||||
import { sandboxWorkspaceTool } from './sandbox-workspace'
|
||||
import { sandboxGitTool } from './sandbox-git'
|
||||
import { browserTool } from './browser'
|
||||
|
||||
export interface ToolContext {
|
||||
@@ -51,18 +41,27 @@ export interface Tool {
|
||||
}
|
||||
|
||||
class ToolError extends Error {}
|
||||
function assertActive(signal?: AbortSignal): void {
|
||||
if (signal?.aborted) throw Object.assign(new Error('操作已取消'), { name: 'AbortError' })
|
||||
}
|
||||
function within(root: string, target: string): boolean {
|
||||
const value = relative(root, target)
|
||||
return value !== '..' && !value.startsWith(`..${sep}`) && !isAbsolute(value)
|
||||
}
|
||||
async function writeTextAtomically(path: string, content: string): Promise<void> {
|
||||
const temporary = join(dirname(path), `.ycode-write-${randomUUID()}.tmp`)
|
||||
try { await fs.writeFile(temporary, content, 'utf8'); await fs.rename(temporary, path) }
|
||||
finally { await fs.unlink(temporary).catch(() => undefined) }
|
||||
}
|
||||
|
||||
/** 把用户/模型给的路径解析到工作区内,越界即抛错 */
|
||||
/** 文件工具仍以工作区为边界;命令工具直接使用宿主 shell。 */
|
||||
async function resolveInWorkspace(input: unknown, ctx: ToolContext): Promise<string> {
|
||||
const raw = typeof input === 'string' && input.trim() ? input.trim() : '.'
|
||||
const path = await resolveSandboxPath(ctx.workspaceRoot, raw, ctx.permissionMode === 'full-access')
|
||||
if (ctx.permissionMode !== 'full-access' && sandboxPathDenied(await effectiveSandboxPolicy(ctx.workspaceRoot), path)) throw new ToolError('沙盒策略不允许访问此路径')
|
||||
return path
|
||||
if (raw.includes('\0')) throw new ToolError('文件路径无效')
|
||||
const root = await fs.realpath(ctx.workspaceRoot)
|
||||
const target = resolve(root, raw)
|
||||
if (!within(root, target)) throw new ToolError('文件路径必须位于当前工作区内')
|
||||
return target
|
||||
}
|
||||
|
||||
const listDir: Tool = {
|
||||
@@ -134,23 +133,8 @@ const readFile: Tool = {
|
||||
}
|
||||
}
|
||||
|
||||
async function commitApprovedWrite(ctx: ToolContext, description: string, changes: FileChange[]): Promise<string> {
|
||||
assertActive(ctx.signal)
|
||||
const policy = await effectiveSandboxPolicy(ctx.workspaceRoot)
|
||||
for (const change of changes) {
|
||||
if (sandboxPathDenied(policy, change.path) || change.after && change.after.byteLength > policy.limits.fileBytes) throw new ToolError('文件写入违反沙盒路径或容量策略')
|
||||
}
|
||||
if (changes.length > policy.maxChanges) throw new ToolError('文件变更超过沙盒批次上限')
|
||||
const workspaceIdentity = (await NativeWorkspaceReader.create(await verifiedBinary(), policy, ctx.signal)).rootIdentity
|
||||
const targetDigest = fileChangeDigest(changes)
|
||||
const authorization = await authorizeSandboxWrite(policy, description, ctx, targetDigest)
|
||||
if (!authorization) throw new ToolError('用户未批准此操作')
|
||||
return commitChanges(ctx.workspaceRoot, changes, { runId: ctx.runId, toolCallId: ctx.toolCallId, sessionId: ctx.sessionId, policyRevision: policy.revision, policyFingerprint: policy.fingerprint, authorizationId: authorization.id }, ctx.signal,
|
||||
{ beforeApply: () => authorization.consume(fileChangeDigest(changes)), check: () => authorization.assertValid(targetDigest), workspaceIdentity })
|
||||
}
|
||||
|
||||
const writeFile: Tool = {
|
||||
name: 'write_file', description: '创建或更新文本文件;默认权限下会请求用户确认。',
|
||||
name: 'write_file', description: '在当前工作区创建或更新文本文件。',
|
||||
parameters: { type: 'object', properties: { path: { type: 'string' }, content: { type: 'string' } }, required: ['path', 'content'] },
|
||||
async run(args, ctx) {
|
||||
if (typeof args.path !== 'string' || typeof args.content !== 'string') throw new ToolError('文件参数无效')
|
||||
@@ -160,20 +144,17 @@ const writeFile: Tool = {
|
||||
if ((error as NodeJS.ErrnoException).code === 'ENOENT') return null
|
||||
throw error
|
||||
})
|
||||
let transactionId: string | undefined
|
||||
if (ctx.permissionMode === 'full-access') {
|
||||
assertActive(ctx.signal)
|
||||
await fs.mkdir(dirname(path), { recursive: true })
|
||||
path = await resolveInWorkspace(inputPath, ctx)
|
||||
const current = await fs.readFile(path, 'utf8').catch(error => { if ((error as NodeJS.ErrnoException).code === 'ENOENT') return null; throw error })
|
||||
if (current !== oldContent) throw new ToolError('文件在写入前已变化,请重新读取后写入')
|
||||
await writeTextAtomically(path, content)
|
||||
} else transactionId = await commitApprovedWrite(ctx, `写入文件 ${inputPath}`, [{ path, beforeHash: oldContent === null ? null : digest(Buffer.from(oldContent)), after: Buffer.from(content) }])
|
||||
return { ok: true, content: `已写入 ${inputPath}`, data: { path, oldContent, newContent: content, ...(transactionId ? { transactionId } : {}) } }
|
||||
assertActive(ctx.signal)
|
||||
await fs.mkdir(dirname(path), { recursive: true })
|
||||
path = await resolveInWorkspace(inputPath, ctx)
|
||||
const current = await fs.readFile(path, 'utf8').catch(error => { if ((error as NodeJS.ErrnoException).code === 'ENOENT') return null; throw error })
|
||||
if (current !== oldContent) throw new ToolError('文件在写入前已变化,请重新读取后写入')
|
||||
await writeTextAtomically(path, content)
|
||||
return { ok: true, content: `已写入 ${inputPath}`, data: { path, oldContent, newContent: content } }
|
||||
}
|
||||
}
|
||||
const editFile: Tool = {
|
||||
name: 'edit_file', description: '精确替换文本文件中的唯一匹配内容;默认权限下会请求用户确认。',
|
||||
name: 'edit_file', description: '精确替换当前工作区文本文件中的唯一匹配内容。',
|
||||
parameters: { type: 'object', properties: { path: { type: 'string' }, oldText: { type: 'string' }, newText: { type: 'string' } }, required: ['path', 'oldText', 'newText'] },
|
||||
async run(args, ctx) {
|
||||
if (typeof args.path !== 'string' || typeof args.oldText !== 'string' || !args.oldText || typeof args.newText !== 'string') throw new ToolError('编辑参数无效')
|
||||
@@ -181,30 +162,19 @@ const editFile: Tool = {
|
||||
const oldContent = await fs.readFile(path, 'utf8')
|
||||
if (oldContent.split(args.oldText).length !== 2) throw new ToolError('待替换内容必须唯一匹配')
|
||||
const newContent = oldContent.replace(args.oldText, args.newText)
|
||||
let transactionId: string | undefined
|
||||
if (ctx.permissionMode === 'full-access') {
|
||||
assertActive(ctx.signal)
|
||||
path = await resolveInWorkspace(args.path, ctx)
|
||||
if (await fs.readFile(path, 'utf8') !== oldContent) throw new ToolError('文件在编辑前已变化,请重新读取后编辑')
|
||||
await writeTextAtomically(path, newContent)
|
||||
}
|
||||
else transactionId = await commitApprovedWrite(ctx, `编辑文件 ${args.path}`, [{ path, beforeHash: digest(Buffer.from(oldContent)), after: Buffer.from(newContent) }])
|
||||
return { ok: true, content: `已编辑 ${args.path}`, data: { path, oldContent, newContent, ...(transactionId ? { transactionId } : {}) } }
|
||||
assertActive(ctx.signal)
|
||||
path = await resolveInWorkspace(args.path, ctx)
|
||||
if (await fs.readFile(path, 'utf8') !== oldContent) throw new ToolError('文件在编辑前已变化,请重新读取后编辑')
|
||||
await writeTextAtomically(path, newContent)
|
||||
return { ok: true, content: `已编辑 ${args.path}`, data: { path, oldContent, newContent } }
|
||||
}
|
||||
}
|
||||
const command: Tool = {
|
||||
name: 'execute_command', description: '执行 Windows cmd 命令。默认权限使用禁网隔离副本,变更经批准写回。runtimes 选择 Node/npm、Python/pip、Git;Git 默认随应用提供,Node/Python 需维护者导入,不继承宿主 PATH。未导入仓库时 Git 基线是过滤快照,不表示宿主历史/索引;真实 Git 工作流优先用 sandbox_git:import 审批导入、run 接 argv、commit 审批写回文件和 Git,远程操作单独审批。导入或 init/clone 后,本会话 Git 调用默认进入长期镜像;显式 snapshot 使用独立快照。workspace=persistent 跨命令保留草稿,成功后不自动写宿主,用 sandbox_workspace 或 sandbox_git 查看/提交/丢弃。普通文件工具仍作用宿主。敏感文件、宿主 .git、node_modules、链接不进入普通文件副本,使用相对路径;Node 同步子进程捕获可用 node --require "%YCODE_SANDBOX_PROCESS%" script.cjs。完全访问且未显式选工作区时用普通 shell;显式 workspace 始终使用沙盒。',
|
||||
parameters: { type: 'object', properties: { command: { type: 'string' }, workspace: { type: 'string', enum: ['snapshot', 'persistent'], description: 'snapshot 为每命令副本并审批写回;persistent 为当前会话的长期草稿,不自动写宿主,用 sandbox_workspace commit 提交。' }, runtimes: { type: 'array', items: { type: 'string', enum: ['node', 'python', 'git'] }, description: '所需沙盒运行时;git 默认随应用提供,无需注册,对应沙盒专用仓库(过滤快照基线,无宿主历史/索引);Node/Python 需先导入。省略时仅提供系统 shell。' }, dependencies: { type: 'array', items: { type: 'string', enum: ['node', 'python'] }, description: '显式使用已为本工作区导入的只读依赖缓存,锁文件变化后须重新导入。' }, bridge: { type: 'string', enum: ['workspace-read'], description: '可选的工作区只读文件桥(需 node);require(process.env.YCODE_SANDBOX_BRIDGE).connectSync() 提供 readFileSync/statSync/readdirSync;异步用 connect(),一个 Job 使用一个客户端。写入仍通过副本和审批。' } }, required: ['command'] },
|
||||
name: 'execute_command', description: '在当前工作目录直接执行 Windows shell 命令。使用宿主环境、Git 历史、已安装工具和正常网络。',
|
||||
parameters: { type: 'object', properties: { command: { type: 'string' } }, required: ['command'] },
|
||||
async run(args, ctx) {
|
||||
if (typeof args.command !== 'string' || !args.command.trim()) throw new ToolError('命令为空')
|
||||
assertActive(ctx.signal)
|
||||
if (args.workspace !== undefined && args.workspace !== 'snapshot' && args.workspace !== 'persistent') throw new ToolError('沙盒工作区参数无效')
|
||||
if (ctx.permissionMode !== 'full-access' || args.workspace !== undefined) {
|
||||
if (args.runtimes !== undefined && (!Array.isArray(args.runtimes) || args.runtimes.some(value => typeof value !== 'string'))) throw new ToolError('沙盒运行时参数无效')
|
||||
if (args.dependencies !== undefined && (!Array.isArray(args.dependencies) || args.dependencies.some(value => typeof value !== 'string'))) throw new ToolError('沙盒依赖参数无效')
|
||||
if (args.bridge !== undefined && args.bridge !== 'workspace-read') throw new ToolError('沙盒文件桥参数无效')
|
||||
return executeSandboxCommand(args.command, { ...ctx, runtimes: args.runtimes as string[] | undefined, dependencies: args.dependencies as string[] | undefined, readBridge: args.bridge === 'workspace-read', workspaceMode: args.workspace as 'snapshot' | 'persistent' | undefined })
|
||||
}
|
||||
return new Promise((resolveResult, reject) => {
|
||||
const child = spawn(args.command as string, { shell: true, cwd: ctx.workspaceRoot, windowsHide: true, detached: process.platform !== 'win32' })
|
||||
const onAbort = (): void => {
|
||||
@@ -231,7 +201,7 @@ const askQuestion: Tool = { name: 'ask_user_question', description: '暂停并
|
||||
const followupQuestion: Tool = { ...askQuestion, name: 'ask_followup_question', parameters: { type: 'object', properties: { question: { type: 'string' }, follow_up: { type: 'array', items: { type: 'string' } } }, required: ['question'] } }
|
||||
const plan: Tool = { name: 'todo_write', description: '创建或更新任务计划和步骤进度。', parameters: { type: 'object', properties: { title: { type: 'string' }, todos: { type: 'array', items: { type: 'object', properties: { id: { type: 'string' }, content: { type: 'string' }, status: { type: 'string', enum: ['pending', 'in_progress', 'completed', 'cancelled'] } }, required: ['content'] } } }, required: ['todos'] }, async run() { return { ok: false, content: '计划宿主未连接' } } }
|
||||
|
||||
export const ALL_TOOLS: Tool[] = [listDir, readFile, writeFile, editFile, command, sandboxWorkspaceTool, sandboxGitTool, askQuestion, followupQuestion, plan, browserTool]
|
||||
export const ALL_TOOLS: Tool[] = [listDir, readFile, writeFile, editFile, command, askQuestion, followupQuestion, plan, browserTool]
|
||||
|
||||
export function toolSpecs(): ToolSpec[] {
|
||||
return ALL_TOOLS.map((t) => ({
|
||||
|
||||
@@ -33,7 +33,7 @@ export function createAutomationHost(options: AutomationHostOptions) {
|
||||
return { status: 'cancelled', error: '执行已取消' }
|
||||
}
|
||||
const started = await options.chatTurns.startTurn({
|
||||
sessionId, text: definition.prompt, permissionMode: definition.execution.permissionMode ?? 'default-sandbox'
|
||||
sessionId, text: definition.prompt
|
||||
})
|
||||
chatRunId = started.runId
|
||||
completion = started.completion
|
||||
|
||||
@@ -96,10 +96,7 @@ function owned(args: Record<string, unknown>, key: string): ManagedBrowser {
|
||||
if (!browser || browser.scope !== key || browser.window.isDestroyed()) throw new Error('浏览器标签不存在、不属于当前会话,或存在多个标签;请先 list 并指定 tabId')
|
||||
return browser
|
||||
}
|
||||
async function approve(action: string, args: Record<string, unknown>, ctx: ToolContext): Promise<void> {
|
||||
if (ctx.permissionMode === 'full-access' || !['open', 'navigate', 'click', 'type', 'press_key'].includes(action)) return
|
||||
const target = action === 'open' || action === 'navigate' ? url(args.url) : String(args.ref || args.selector || args.key || '')
|
||||
if (!ctx.approve || !await ctx.approve(`浏览器 ${action}:${target}`, ctx.signal)) throw new Error('浏览器操作未获用户批准')
|
||||
async function approve(_action: string, _args: Record<string, unknown>, ctx: ToolContext): Promise<void> {
|
||||
active(ctx)
|
||||
}
|
||||
function create(key: string, ctx: ToolContext): ManagedBrowser {
|
||||
|
||||
@@ -80,15 +80,6 @@ export async function createCapabilitySnapshot(ids: string[], cwd: string, optio
|
||||
return { ok: true, content }
|
||||
}
|
||||
if (!names.has(name)) return undefined
|
||||
if (ctx.permissionMode !== 'full-access') {
|
||||
if (!ctx.interactions) return { ok: false, content: 'MCP 调用需要用户确认' }
|
||||
const identity = mcp.describe(name)
|
||||
const decision = await ctx.interactions.approval(ctx.toolCallId, identity ? `调用 ${identity.serverName} 的 ${identity.toolName} 工具` : `调用连接器工具 ${name}`, [
|
||||
{ optionId: 'allow-once', name: '允许一次', kind: 'allow_once' },
|
||||
{ optionId: 'reject-once', name: '拒绝', kind: 'reject_once' }
|
||||
])
|
||||
if (decision.optionId !== 'allow-once') return { ok: false, content: '用户拒绝了连接器调用' }
|
||||
}
|
||||
const result = await mcp.execute(name, args, ctx.signal)
|
||||
return { ...result, metadata: { capability: 'mcp', ...mcp.describe(name) } }
|
||||
}
|
||||
|
||||
@@ -428,7 +428,7 @@ export function createChatTurnService({ projects, sessionRoot, broadcastChat, ge
|
||||
signal: controller.signal, emit, capabilities: capabilitySnapshot,
|
||||
onUsage: reported => { childUsage = addUsage(childUsage, reported); publishReportedUsage() },
|
||||
worker: (task, tools) => createSubagentWorker({ task, tools, model: selectedModel, workspaceRoot, parentRunId: runId, parentSessionId: sessionId,
|
||||
native: !!kernel, permissionMode: payload.permissionMode ?? 'default-sandbox',
|
||||
native: !!kernel,
|
||||
thinkingLevel: modelThinkingLevel(selectedModel.profile, context.thinkingLevel), emitParent: emit })
|
||||
})
|
||||
const teamScheduler = scheduler
|
||||
@@ -483,7 +483,6 @@ export function createChatTurnService({ projects, sessionRoot, broadcastChat, ge
|
||||
userText: promptText,
|
||||
images: promptAttachments.images,
|
||||
workspaceRoot,
|
||||
permissionMode: payload.permissionMode ?? 'default-sandbox',
|
||||
interactions: interactionBroker,
|
||||
signal: controller.signal,
|
||||
emit
|
||||
@@ -518,7 +517,6 @@ export function createChatTurnService({ projects, sessionRoot, broadcastChat, ge
|
||||
userText: promptText,
|
||||
images: promptAttachments.images,
|
||||
interactions: interactionBroker,
|
||||
permissionMode: payload.permissionMode ?? 'default-sandbox',
|
||||
signal: controller.signal,
|
||||
emit,
|
||||
gateway: new Gateway({ ...snapshot, thinkingLevel: modelThinkingLevel(selectedModel.profile, context.thinkingLevel) },body=>{
|
||||
|
||||
+1
-69
@@ -9,21 +9,10 @@ import { dirname, join } from 'node:path'
|
||||
import { fileURLToPath } from 'node:url'
|
||||
import { disposeOmpKernels, registerIpc, setTaskWindowFactory, startBackgroundServices, stopBackgroundServices } from './ipc'
|
||||
import { disposeMcpServers } from './capabilities/mcp'
|
||||
import { getPreventSleep, getWorkspaceRoot } from './config'
|
||||
import { getPreventSleep } from './config'
|
||||
import { configurePreventSleep } from './general-settings'
|
||||
import { configureSandbox } from './sandbox/configuration'
|
||||
import { disposeSandboxCommands, sandboxStatus } from './sandbox/service'
|
||||
import { readSandboxPolicy, updateSandboxPolicy } from './sandbox/policy-store'
|
||||
import { runSandboxSelfTest } from './sandbox/self-test'
|
||||
import { sandboxOperation, sandboxBrokerOperation } from './sandbox/maintenance'
|
||||
import type { SandboxPolicyUpdate, SandboxSelfTestSummary } from '@shared/sandbox'
|
||||
import { executeBrowserTool, installBrowserAutomationLifecycle } from './browser-automation'
|
||||
import { configureBrowserTool } from './agent/tools/browser'
|
||||
import { sandboxWorkspaceStatus } from './sandbox/workspace-service'
|
||||
import { hostGitStatus } from './sandbox/git-host-service'
|
||||
import { gitRemoteOperation } from './sandbox/git-remote'
|
||||
import { disposeGitControls } from './sandbox/git-control'
|
||||
import { getSessionContext } from './sessions'
|
||||
|
||||
let mainWindow: BrowserWindow | null = null
|
||||
let settingsWindow: BrowserWindow | null = null
|
||||
@@ -37,24 +26,6 @@ let settingsRequestId = 0
|
||||
let allowQuit = false
|
||||
let shutdownPromise: Promise<void> | null = null
|
||||
const mainDirectory = dirname(fileURLToPath(import.meta.url))
|
||||
function sandboxSender(event: Electron.IpcMainInvokeEvent): void {
|
||||
const owned = [mainWindow, settingsWindow, ...taskWindows.values()].some(window => window && !window.isDestroyed() && window.webContents === event.sender)
|
||||
if (!owned || event.senderFrame !== event.sender.mainFrame) throw new Error('沙盒设置请求不属于应用主页面')
|
||||
const url = new URL(event.senderFrame.url)
|
||||
const renderer = process.env.ELECTRON_RENDERER_URL
|
||||
const entry = settingsWindow?.webContents === event.sender ? 'settings.html' : 'index.html'
|
||||
if (renderer && !app.isPackaged) {
|
||||
const expected = entry === 'settings.html' ? new URL(entry, renderer) : new URL(renderer)
|
||||
if (url.origin === expected.origin && url.pathname === expected.pathname) return
|
||||
} else if (url.protocol === 'file:' && fileURLToPath(url).toLowerCase() === join(mainDirectory, '../renderer', entry).toLowerCase()) return
|
||||
throw new Error('沙盒设置请求来自未授权页面')
|
||||
}
|
||||
function sandboxChanged(): void {
|
||||
for (const window of [mainWindow, settingsWindow, ...taskWindows.values()]) {
|
||||
if (!window || window.isDestroyed() || window.webContents.isDestroyed()) continue
|
||||
try { window.webContents.send('sandbox:changed') } catch { /* A closing window can read the persisted policy when reopened. */ }
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 应用图标路径。
|
||||
@@ -310,45 +281,6 @@ if (process.env.YCODE_DISABLE_GPU === '1') {
|
||||
app.whenReady().then(() => {
|
||||
configureBrowserTool(executeBrowserTool)
|
||||
installBrowserAutomationLifecycle()
|
||||
configureSandbox({ stateRoot: join(app.getPath('userData'), 'sandbox'), resourceDirectory: app.isPackaged
|
||||
? join(process.resourcesPath, 'sandbox', 'win32-x64') : join(mainDirectory, '../../resources/sandbox/win32-x64') })
|
||||
app.on('before-quit', disposeSandboxCommands)
|
||||
app.on('before-quit', disposeGitControls)
|
||||
ipcMain.handle('sandbox:status', sandboxStatus)
|
||||
ipcMain.handle('sandbox:policy', (event) => { sandboxSender(event); return readSandboxPolicy() })
|
||||
ipcMain.handle('sandbox:operation', (event, id: string) => { sandboxSender(event); return sandboxOperation(id) })
|
||||
ipcMain.handle('sandbox:broker-operation', (event, runId: string, requestId: string) => { sandboxSender(event); return sandboxBrokerOperation(runId, requestId) })
|
||||
ipcMain.handle('sandbox:workspace-status', async (event, sessionId: unknown) => {
|
||||
sandboxSender(event)
|
||||
if (typeof sessionId !== 'string' || !/^[a-f0-9-]{36}$/.test(sessionId)) throw new Error('长期工作区会话身份无效')
|
||||
const context = await getSessionContext(sessionId)
|
||||
return sandboxWorkspaceStatus(context.cwd ?? await getWorkspaceRoot(), sessionId)
|
||||
})
|
||||
ipcMain.handle('sandbox:git-status', async (event, sessionId: unknown) => {
|
||||
sandboxSender(event)
|
||||
if (typeof sessionId !== 'string' || !/^[a-f0-9-]{36}$/.test(sessionId)) throw new Error('Git 会话身份无效')
|
||||
const context = await getSessionContext(sessionId)
|
||||
return hostGitStatus(context.cwd ?? await getWorkspaceRoot(), sessionId)
|
||||
})
|
||||
ipcMain.handle('sandbox:git-remote-operation', async (event, sessionId: unknown, operationId: unknown) => {
|
||||
sandboxSender(event)
|
||||
if (typeof sessionId !== 'string' || !/^[a-f0-9-]{36}$/.test(sessionId) || typeof operationId !== 'string') throw new Error('Git 远程结果参数无效')
|
||||
const context = await getSessionContext(sessionId)
|
||||
return gitRemoteOperation({ workspaceRoot: context.cwd ?? await getWorkspaceRoot(), sessionId }, operationId)
|
||||
})
|
||||
ipcMain.handle('sandbox:policy:update', async (event, update: SandboxPolicyUpdate) => {
|
||||
sandboxSender(event)
|
||||
if (!update || Object.hasOwn(update, 'workspaceRoot')) throw new Error('应用设置仅接受全局策略;工作区策略由所属会话解析')
|
||||
const result = await updateSandboxPolicy(update)
|
||||
disposeSandboxCommands(); disposeGitControls(); sandboxChanged()
|
||||
return result
|
||||
})
|
||||
let selfTest: Promise<SandboxSelfTestSummary> | undefined
|
||||
ipcMain.handle('sandbox:self-test', (event) => {
|
||||
sandboxSender(event)
|
||||
selfTest ??= runSandboxSelfTest().finally(() => { selfTest = undefined; sandboxChanged() })
|
||||
return selfTest
|
||||
})
|
||||
// 通用设置:启动时套用持久化偏好(运行任务时阻止系统休眠)。
|
||||
void getPreventSleep().then((enabled) => configurePreventSleep(enabled)).catch(() => undefined)
|
||||
registerIpc(() => mainWindow)
|
||||
|
||||
@@ -1,12 +1,12 @@
|
||||
import { randomUUID } from 'node:crypto'
|
||||
import { isAbsolute, relative, sep } from 'node:path'
|
||||
import { isAbsolute, relative, resolve, sep } from 'node:path'
|
||||
import { stat } from 'node:fs/promises'
|
||||
import { realpath } from 'node:fs/promises'
|
||||
import type { ChatEvent, ToolCallRecord, TokenUsage } from '@shared/types'
|
||||
import type { SubagentSnapshot } from '@shared/subagents'
|
||||
import { addUsage, zeroUsage } from '@shared/subagents'
|
||||
import type { CapabilityToolSnapshot } from '../capabilities/runtime'
|
||||
import type { ConversationToolContext, ConversationToolResult } from '../agent/conversation-tools'
|
||||
import { resolveSandboxPath } from '../sandbox/policy'
|
||||
import { SUBAGENT_TOOLS, SUBAGENT_TOOL_NAMES } from './tools'
|
||||
|
||||
export interface SubagentWorker {
|
||||
@@ -45,6 +45,13 @@ const WRITE_TOOLS = new Set(['write_file', 'edit_file'])
|
||||
const READ_TOOLS = new Set(['list_dir', 'read_file', 'ycode_load_skill', 'ycode_read_skill_resource', 'ycode_expert_resource'])
|
||||
const normalize = (path: string): string => process.platform === 'win32' ? path.toLowerCase() : path
|
||||
const overlaps = (left: string, right: string): boolean => left === right || left.startsWith(right + sep) || right.startsWith(left + sep)
|
||||
async function resolveWorkspacePath(rootInput: string, input: string): Promise<string> {
|
||||
const root = await realpath(rootInput)
|
||||
const target = resolve(root, input.trim() || '.')
|
||||
const value = relative(root, target)
|
||||
if (value === '..' || value.startsWith(`..${sep}`) || isAbsolute(value)) throw new Error('路径必须位于当前工作区内')
|
||||
return target
|
||||
}
|
||||
const aborted = (): Error => Object.assign(new Error('子智能体任务已取消'), { name: 'AbortError' })
|
||||
const text = (value: unknown, max: number, optional = false): string => {
|
||||
if (optional && value === undefined) return ''
|
||||
@@ -126,12 +133,12 @@ export class SubagentScheduler {
|
||||
}
|
||||
private async canonicalFiles(input: unknown): Promise<{ files: string[]; paths: string[] }> {
|
||||
if (!Array.isArray(input) || !input.length || input.length > 32) throw new Error('write 模式需要声明1至32个明确的文件路径')
|
||||
const root = await resolveSandboxPath(this.options.workspaceRoot, '.', true)
|
||||
const root = await realpath(this.options.workspaceRoot)
|
||||
const paths: string[] = [], files: string[] = []
|
||||
for (const raw of input) {
|
||||
const file = text(raw, 1500)
|
||||
if (isAbsolute(file) || /[*?\0]/.test(file)) throw new Error('可写文件必须是工作区相对路径,不能使用通配符')
|
||||
const path = await resolveSandboxPath(root, file, true)
|
||||
const path = await resolveWorkspacePath(root, file)
|
||||
const rel = relative(root, path)
|
||||
if (!rel || rel === '..' || rel.startsWith('..' + sep) || isAbsolute(rel)) throw new Error('可写文件不能越出工作区')
|
||||
const info = await stat(path).catch(error => { if ((error as NodeJS.ErrnoException).code !== 'ENOENT') throw error; return undefined })
|
||||
@@ -143,7 +150,7 @@ export class SubagentScheduler {
|
||||
private async rootExecute(name: string, args: Record<string, unknown>, execute: () => Promise<ConversationToolResult>): Promise<ConversationToolResult> {
|
||||
if (SUBAGENT_TOOL_NAMES.has(name) || READ_TOOLS.has(name) || ['browser', 'todo_write', 'create_plan', 'ask_user_question', 'ask_followup_question'].includes(name)) return execute()
|
||||
let path: string
|
||||
try { path = normalize(await resolveSandboxPath(this.options.workspaceRoot, WRITE_TOOLS.has(name) ? String(args.path ?? '') : '.', true)) }
|
||||
try { path = normalize(await resolveWorkspacePath(this.options.workspaceRoot, WRITE_TOOLS.has(name) ? String(args.path ?? '') : '.')) }
|
||||
catch (error) { return { ok: false, content: error instanceof Error ? error.message : String(error) } }
|
||||
if ([...leases.keys()].some(owned => overlaps(owned, path))) return { ok: false, content: '相关文件正由其他任务修改,请等待子任务或当前文件操作结束后再执行。' }
|
||||
const owner = randomUUID()
|
||||
@@ -161,7 +168,7 @@ export class SubagentScheduler {
|
||||
if (entry.controller.signal.aborted || this.options.signal.aborted) throw aborted()
|
||||
if (!allowed(name) || SUBAGENT_TOOL_NAMES.has(name)) return { ok: false, content: '子智能体仅允许读取和在声明范围内编辑文件;命令、连接器、浏览器及嵌套调度由主智能体处理。' }
|
||||
if (WRITE_TOOLS.has(name)) {
|
||||
const path = normalize(await resolveSandboxPath(this.options.workspaceRoot, String(args.path ?? ''), true))
|
||||
const path = normalize(await resolveWorkspacePath(this.options.workspaceRoot, String(args.path ?? '')))
|
||||
if (!entry.paths.includes(path) || leases.get(path) !== entry.view.id) return { ok: false, content: '该文件不在当前子智能体的可写范围内。' }
|
||||
}
|
||||
return this.options.capabilities.execute(name, args, ctx)
|
||||
|
||||
@@ -4,7 +4,6 @@ import { join } from 'node:path'
|
||||
import { randomUUID } from 'node:crypto'
|
||||
import type { ResolvedModel } from '@shared/models'
|
||||
import type { ChatEvent, ChatMessage } from '@shared/types'
|
||||
import type { PermissionMode } from '@shared/conversation'
|
||||
import type { SubagentSnapshot } from '@shared/subagents'
|
||||
import type { ThinkingLevel } from '@shared/workspaces'
|
||||
import { applyAssistantEvent } from '@shared/message-parts'
|
||||
@@ -23,7 +22,7 @@ import type { ConversationToolResult } from '../agent/conversation-tools'
|
||||
|
||||
export async function createSubagentWorker(options: {
|
||||
task: SubagentSnapshot; tools: CapabilityToolSnapshot; model: ResolvedModel; workspaceRoot: string
|
||||
parentRunId: string; permissionMode: PermissionMode; native: boolean; thinkingLevel?: ThinkingLevel
|
||||
parentRunId: string; native: boolean; thinkingLevel?: ThinkingLevel
|
||||
parentSessionId?: string
|
||||
emitParent(event: ChatEvent): void
|
||||
}): Promise<SubagentWorker> {
|
||||
@@ -82,7 +81,7 @@ export async function createSubagentWorker(options: {
|
||||
const observe = (event: ChatEvent): void => { assistant = applyAssistantEvent(assistant, event); emit(event) }
|
||||
activeEmit = observe
|
||||
const userText = role + '\n\n' + prompt
|
||||
const common = { runId, sessionId, userText, signal, emit: observe, workspaceRoot, permissionMode: options.permissionMode,
|
||||
const common = { runId, sessionId, userText, signal, emit: observe, workspaceRoot,
|
||||
agentMode: 'craft' as const, capabilityTools: trackedTools, supportsToolCall: model.profile.supportsToolCall,
|
||||
supportsImages: model.profile.supportsImages === true, interactions }
|
||||
let result: Awaited<ReturnType<typeof runTurn>> & { status?: string }
|
||||
|
||||
@@ -91,20 +91,6 @@ const api: YCodeApi = {
|
||||
},
|
||||
app: {
|
||||
info: () => ipcRenderer.invoke('app:info'),
|
||||
sandboxStatus: () => ipcRenderer.invoke('sandbox:status'),
|
||||
sandboxPolicy: () => ipcRenderer.invoke('sandbox:policy'),
|
||||
setSandboxPolicy: (update) => ipcRenderer.invoke('sandbox:policy:update', update),
|
||||
sandboxSelfTest: () => ipcRenderer.invoke('sandbox:self-test'),
|
||||
sandboxOperation: (id) => ipcRenderer.invoke('sandbox:operation', id),
|
||||
sandboxBrokerOperation: (runId, requestId) => ipcRenderer.invoke('sandbox:broker-operation', runId, requestId),
|
||||
sandboxWorkspace: (sessionId) => ipcRenderer.invoke('sandbox:workspace-status', sessionId),
|
||||
sandboxGit: (sessionId) => ipcRenderer.invoke('sandbox:git-status', sessionId),
|
||||
sandboxGitRemoteOperation: (sessionId, operationId) => ipcRenderer.invoke('sandbox:git-remote-operation', sessionId, operationId),
|
||||
onSandboxChanged: (listener) => {
|
||||
const handler = (): void => listener()
|
||||
ipcRenderer.on('sandbox:changed', handler)
|
||||
return () => ipcRenderer.removeListener('sandbox:changed', handler)
|
||||
},
|
||||
openConfigDir: () => ipcRenderer.invoke('app:openConfigDir'),
|
||||
openSettings: (section, action) => ipcRenderer.invoke('app:openSettings', section, action),
|
||||
settingsReady: () => ipcRenderer.invoke('app:settingsReady'),
|
||||
|
||||
@@ -6,7 +6,6 @@ import ProjectListPage from './projects/ProjectListPage'
|
||||
import LibraryPage from './library/LibraryPage'
|
||||
import AutomationPage from './automations/AutomationPage'
|
||||
import type { ProjectRecord } from '@shared/projects'
|
||||
import type { PermissionMode } from '@shared/conversation'
|
||||
import type { ThinkingLevel } from '@shared/workspaces'
|
||||
import type { LibraryArtifactEntry } from '@shared/library'
|
||||
import { type SkillRecord } from '@shared/capabilities'
|
||||
@@ -86,7 +85,7 @@ export default function App(): React.JSX.Element {
|
||||
setLeftDrawerOpen(false)
|
||||
}
|
||||
const startProjectTask = async (project: ProjectRecord, prompt: string,
|
||||
options?: { permissionMode?: PermissionMode; thinkingLevel?: ThinkingLevel }): Promise<void> => {
|
||||
options?: { thinkingLevel?: ThinkingLevel }): Promise<void> => {
|
||||
useChatStore.getState().startTask({ projectId: project.projectId, workspaceId: project.workspaceId,
|
||||
modelId: useSettingsStore.getState().selectedModelId ?? undefined,
|
||||
...(options?.thinkingLevel ? { thinkingLevel: options.thinkingLevel } : {}) })
|
||||
@@ -96,7 +95,7 @@ export default function App(): React.JSX.Element {
|
||||
if (state.draftId === draft && state.currentSessionId && !createdOwner) createdOwner = state.currentSessionId
|
||||
})
|
||||
setRoute('chat')
|
||||
try { await useChatStore.getState().send(prompt, { permissionMode: options?.permissionMode }) }
|
||||
try { await useChatStore.getState().send(prompt) }
|
||||
catch (error) {
|
||||
const state = useChatStore.getState()
|
||||
if (state.draftId === draft && (!state.currentSessionId || state.currentSessionId === createdOwner)) {
|
||||
|
||||
@@ -1,19 +1,16 @@
|
||||
import { useEffect, useMemo, useRef, useState, type ReactNode } from 'react'
|
||||
import { useEffect, useMemo, useState, type ReactNode } from 'react'
|
||||
import type { AutomationApi } from '@shared/automation-api'
|
||||
import type { ProjectRecord } from '@shared/projects'
|
||||
import {
|
||||
IconClose,
|
||||
IconPlus,
|
||||
IconShield
|
||||
IconPlus
|
||||
} from '../components/icons'
|
||||
import SourceSelect from '../components/source-select/SourceSelect'
|
||||
import { useSettingsStore } from '../stores/settings'
|
||||
import SourceModelSelector from '../workbuddy/composer/model-selector/SourceModelSelector'
|
||||
import AutomationScheduleFields from './AutomationScheduleFields'
|
||||
import {
|
||||
AutomationFolderOpenIcon,
|
||||
AutomationGlobeIcon,
|
||||
AutomationTerminalIcon
|
||||
AutomationFolderOpenIcon
|
||||
} from './automation-source-icons'
|
||||
import {
|
||||
automationFormErrors,
|
||||
@@ -50,15 +47,6 @@ function useDarkMode(): boolean {
|
||||
return dark
|
||||
}
|
||||
|
||||
export function shouldConfirmAutomationFullAccess(
|
||||
mode: 'create' | 'edit',
|
||||
initialPermissionMode: AutomationFormState['permissionMode'],
|
||||
finalPermissionMode: AutomationFormState['permissionMode']
|
||||
): boolean {
|
||||
return finalPermissionMode === 'full-access' &&
|
||||
(mode === 'create' || initialPermissionMode !== 'full-access')
|
||||
}
|
||||
|
||||
function CompactSelect({
|
||||
ariaLabel,
|
||||
label,
|
||||
@@ -101,150 +89,6 @@ function CompactSelect({
|
||||
)
|
||||
}
|
||||
|
||||
function FullAccessConfirmDialog({
|
||||
submitting,
|
||||
confirmed,
|
||||
onConfirmedChange,
|
||||
onCancel,
|
||||
onConfirm
|
||||
}: {
|
||||
submitting: boolean
|
||||
confirmed: boolean
|
||||
onConfirmedChange(value: boolean): void
|
||||
onCancel(): void
|
||||
onConfirm(): void
|
||||
}): React.JSX.Element {
|
||||
const dialogRef = useRef<HTMLElement>(null)
|
||||
|
||||
useEffect(() => {
|
||||
const previousFocus = document.activeElement instanceof HTMLElement
|
||||
? document.activeElement
|
||||
: null
|
||||
const focusFrame = window.requestAnimationFrame(() => {
|
||||
dialogRef.current
|
||||
?.querySelector<HTMLElement>('input:not(:disabled), button:not(:disabled)')
|
||||
?.focus()
|
||||
})
|
||||
const onKeyDown = (event: KeyboardEvent): void => {
|
||||
if (event.key === 'Escape' && !submitting) {
|
||||
event.preventDefault()
|
||||
event.stopPropagation()
|
||||
onCancel()
|
||||
return
|
||||
}
|
||||
if (event.key !== 'Tab') return
|
||||
const focusable = [...(dialogRef.current?.querySelectorAll<HTMLElement>(
|
||||
'button:not(:disabled), input:not(:disabled), [tabindex]:not([tabindex="-1"])'
|
||||
) ?? [])]
|
||||
if (focusable.length === 0) return
|
||||
const first = focusable[0]!
|
||||
const last = focusable[focusable.length - 1]!
|
||||
if (event.shiftKey && document.activeElement === first) {
|
||||
event.preventDefault()
|
||||
last.focus()
|
||||
} else if (!event.shiftKey && document.activeElement === last) {
|
||||
event.preventDefault()
|
||||
first.focus()
|
||||
}
|
||||
}
|
||||
window.addEventListener('keydown', onKeyDown, true)
|
||||
return () => {
|
||||
window.cancelAnimationFrame(focusFrame)
|
||||
window.removeEventListener('keydown', onKeyDown, true)
|
||||
previousFocus?.focus()
|
||||
}
|
||||
}, [onCancel, submitting])
|
||||
|
||||
return (
|
||||
<div
|
||||
className="automation-full-access-overlay"
|
||||
role="presentation"
|
||||
onMouseDown={event => {
|
||||
if (event.target === event.currentTarget && !submitting) onCancel()
|
||||
}}
|
||||
>
|
||||
<section
|
||||
ref={dialogRef}
|
||||
className="automation-full-access-confirm"
|
||||
data-testid="automation-full-access-confirm"
|
||||
role="alertdialog"
|
||||
aria-modal="true"
|
||||
aria-labelledby="automation-full-access-title"
|
||||
>
|
||||
<header className="automation-full-access-confirm__header">
|
||||
<IconShield className="automation-full-access-confirm__warning-icon" width={20} height={20} />
|
||||
<h2 id="automation-full-access-title">确认允许完全访问?</h2>
|
||||
<button
|
||||
type="button"
|
||||
className="automation-icon-button"
|
||||
aria-label="关闭"
|
||||
disabled={submitting}
|
||||
onClick={onCancel}
|
||||
>
|
||||
<IconClose width={14} height={14} />
|
||||
</button>
|
||||
</header>
|
||||
<div className="automation-full-access-confirm__content">
|
||||
<p className="automation-full-access-confirm__description">
|
||||
开启完全访问后,该定时任务运行时可减少确认步骤并直接操作您的电脑,请谨慎操作。包括以下内容:
|
||||
</p>
|
||||
<div className="automation-full-access-confirm__capabilities">
|
||||
<div className="automation-full-access-confirm__capability">
|
||||
<span className="automation-full-access-confirm__capability-icon"><AutomationFolderOpenIcon /></span>
|
||||
<span className="automation-full-access-confirm__capability-text">
|
||||
<span className="automation-full-access-confirm__capability-title">文件操作</span>
|
||||
<span className="automation-full-access-confirm__capability-description">读取、创建、修改、上传或删除此计算机上任意位置的文件</span>
|
||||
</span>
|
||||
</div>
|
||||
<div className="automation-full-access-confirm__capability">
|
||||
<span className="automation-full-access-confirm__capability-icon"><AutomationTerminalIcon /></span>
|
||||
<span className="automation-full-access-confirm__capability-text">
|
||||
<span className="automation-full-access-confirm__capability-title">终端命令</span>
|
||||
<span className="automation-full-access-confirm__capability-description">运行命令、安装软件和更改系统设置</span>
|
||||
</span>
|
||||
</div>
|
||||
<div className="automation-full-access-confirm__capability">
|
||||
<span className="automation-full-access-confirm__capability-icon"><AutomationGlobeIcon /></span>
|
||||
<span className="automation-full-access-confirm__capability-text">
|
||||
<span className="automation-full-access-confirm__capability-title">访问互联网</span>
|
||||
<span className="automation-full-access-confirm__capability-description">访问网站、发送数据并使用已启用的连接器</span>
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
<label className="automation-full-access-confirm__checkbox">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={confirmed}
|
||||
disabled={submitting}
|
||||
onChange={event => onConfirmedChange(event.target.checked)}
|
||||
/>
|
||||
<span>已了解风险,并对自己的数据安全负责</span>
|
||||
</label>
|
||||
</div>
|
||||
<footer className="automation-full-access-confirm__footer">
|
||||
<button
|
||||
type="button"
|
||||
className="automation-button automation-button--secondary"
|
||||
disabled={submitting}
|
||||
onClick={onCancel}
|
||||
>
|
||||
取消
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className="automation-button automation-button--danger"
|
||||
data-testid="automation-full-access-confirm-ok"
|
||||
disabled={!confirmed || submitting}
|
||||
onClick={onConfirm}
|
||||
>
|
||||
{submitting ? '保存中…' : '允许完全访问'}
|
||||
</button>
|
||||
</footer>
|
||||
</section>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default function AutomationEditor({
|
||||
api,
|
||||
initialForm,
|
||||
@@ -262,8 +106,6 @@ export default function AutomationEditor({
|
||||
const [advancedOpen, setAdvancedOpen] = useState(false)
|
||||
const [saving, setSaving] = useState(false)
|
||||
const [saveError, setSaveError] = useState('')
|
||||
const [confirmOpen, setConfirmOpen] = useState(false)
|
||||
const [confirmChecked, setConfirmChecked] = useState(false)
|
||||
const errors = useMemo(() => automationFormErrors(form), [form])
|
||||
const visibleErrors = showErrors ? errors : {}
|
||||
const hasErrors = Object.keys(errors).length > 0
|
||||
@@ -310,20 +152,6 @@ export default function AutomationEditor({
|
||||
const submit = (): void => {
|
||||
setShowErrors(true)
|
||||
if (saving || hasErrors) return
|
||||
if (shouldConfirmAutomationFullAccess(
|
||||
editing ? 'edit' : 'create',
|
||||
initialForm.permissionMode,
|
||||
form.permissionMode
|
||||
)) {
|
||||
setConfirmChecked(false)
|
||||
setConfirmOpen(true)
|
||||
return
|
||||
}
|
||||
void persist()
|
||||
}
|
||||
|
||||
const confirmFullAccess = (): void => {
|
||||
if (!confirmChecked || saving) return
|
||||
void persist()
|
||||
}
|
||||
|
||||
@@ -427,22 +255,6 @@ export default function AutomationEditor({
|
||||
disabled={saving || Boolean(form.projectId)}
|
||||
onChange={workspaceId => update({ workspaceId })}
|
||||
/>
|
||||
<CompactSelect
|
||||
ariaLabel="执行权限"
|
||||
className={form.permissionMode === 'full-access'
|
||||
? 'atm-composer-chip--danger'
|
||||
: undefined}
|
||||
leading={<IconShield width={15} height={15} />}
|
||||
value={form.permissionMode}
|
||||
options={[
|
||||
{ value: 'default-sandbox', label: '默认权限' },
|
||||
{ value: 'full-access', label: '允许完全访问' }
|
||||
]}
|
||||
disabled={saving}
|
||||
onChange={permissionMode => update({
|
||||
permissionMode: permissionMode as AutomationFormState['permissionMode']
|
||||
})}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -512,23 +324,12 @@ export default function AutomationEditor({
|
||||
disabled={saving || requiredIncomplete}
|
||||
onClick={submit}
|
||||
>
|
||||
{saving && !confirmOpen ? '保存中…' : '确定'}
|
||||
{saving ? '保存中…' : '确定'}
|
||||
</button>
|
||||
</footer>
|
||||
</section>
|
||||
</div>
|
||||
|
||||
{confirmOpen ? (
|
||||
<FullAccessConfirmDialog
|
||||
submitting={saving}
|
||||
confirmed={confirmChecked}
|
||||
onConfirmedChange={setConfirmChecked}
|
||||
onCancel={() => {
|
||||
if (!saving) setConfirmOpen(false)
|
||||
}}
|
||||
onConfirm={confirmFullAccess}
|
||||
/>
|
||||
) : null}
|
||||
</>
|
||||
)
|
||||
}
|
||||
@@ -20,7 +20,7 @@ import SourceSkillPicker from '../capabilities/SourceSkillPicker'
|
||||
import { ComposerControls, type ComposerSendPayload } from '../workbuddy/composer/ComposerControls'
|
||||
import { ComposerBranchLabel, ContextUsageRing } from '../workbuddy/composer/context-info/ComposerContextInfo'
|
||||
import { QuestionFloating } from '../workbuddy/conversation'
|
||||
import type { AgentMode, ChatAttachment, MessageQuote, PermissionMode, UserContentBlock } from '@shared/conversation'
|
||||
import type { AgentMode, ChatAttachment, MessageQuote, UserContentBlock } from '@shared/conversation'
|
||||
import type { UserMessageEditContent } from '../workbuddy/conversation/UserMessageEditor'
|
||||
import type { ChatMessage } from '@shared/types'
|
||||
import { getRevertChanges, type RevertChange } from '@shared/revert'
|
||||
@@ -113,8 +113,6 @@ export default function ChatPane({ rightVisible, onToggleRight, onOpenSettings,
|
||||
return () => { window.removeEventListener('ycode:select-skill', selectSkill); window.removeEventListener('ycode:composer-intent', intent); window.removeEventListener('ycode:composer-owner-migrated', migrate); window.removeEventListener('ycode:expert-input', expertInput) }
|
||||
}, [])
|
||||
useEffect(() => { setSkillPicker(null); setExpertPicker(null) }, [draftKey, hidden])
|
||||
const [permissionMode, setPermissionMode] = useState<PermissionMode>('default-sandbox')
|
||||
const [confirmFullAccess, setConfirmFullAccess] = useState(true)
|
||||
const [editingMessageId, setEditingMessageId] = useState<string | null>(null)
|
||||
const [rewindTarget, setRewindTarget] = useState<ChatMessage | null>(null)
|
||||
const [rewindError, setRewindError] = useState<string | null>(null)
|
||||
@@ -238,7 +236,7 @@ export default function ChatPane({ rightVisible, onToggleRight, onOpenSettings,
|
||||
if (el) el.scrollTop = el.scrollHeight
|
||||
}, [currentId])
|
||||
|
||||
const submit = async ({ text, attachments, resources, quotes, userContent, mode }: ComposerSendPayload): Promise<void> => {
|
||||
const submit = async ({ text, attachments, resources, quotes, userContent }: ComposerSendPayload): Promise<void> => {
|
||||
if ((!text && !attachments.length && !quotes.length && !resources.length && !entry.skills?.length) || running || sending) return
|
||||
followBottom.current = true
|
||||
// 原版发送即清空输入框并立即切到对话页:先清空草稿,失败时再恢复(TODO 08)。
|
||||
@@ -246,7 +244,7 @@ export default function ChatPane({ rightVisible, onToggleRight, onOpenSettings,
|
||||
const restore = { ...entry, text, attachments, resources, quotes, userContent }
|
||||
setDrafts((all) => ({ ...all, [submitKey]: { text: '', attachments: [] } }))
|
||||
try {
|
||||
await send(text, { attachments, resources, quotes, userContent, skillIds: entry.skills?.map(item => item.id), permissionMode: mode })
|
||||
await send(text, { attachments, resources, quotes, userContent, skillIds: entry.skills?.map(item => item.id) })
|
||||
// 会话创建后草稿所有权已迁移到会话 id;两处都清空,避免留下旧文本。
|
||||
const destination = draftMoves.current.get(submitKey) ?? submitKey
|
||||
draftMoves.current.delete(submitKey)
|
||||
@@ -293,12 +291,12 @@ export default function ChatPane({ rightVisible, onToggleRight, onOpenSettings,
|
||||
}
|
||||
if (!owner || useChatStore.getState().currentSessionId !== owner) return
|
||||
await send(text, { attachments: edited?.attachments ?? message.attachments, resources: edited?.resources ?? message.resources, quotes: edited?.quotes ?? message.quotes,
|
||||
userContent: edited?.userContent ?? message.userContent, skillIds: edited?.skillIds ?? message.skills?.map(item => item.id), permissionMode, fromMessageId: message.id, action: 'edit', rollbackArtifacts })
|
||||
userContent: edited?.userContent ?? message.userContent, skillIds: edited?.skillIds ?? message.skills?.map(item => item.id), fromMessageId: message.id, action: 'edit', rollbackArtifacts })
|
||||
setEditingMessageId(null)
|
||||
}
|
||||
const regenerate = (_requestId: string, message: ChatMessage): Promise<void> => {
|
||||
const preceding = messages.slice(0, messages.findIndex((item) => item.id === message.id)).findLast((item) => item.role === 'user')
|
||||
return preceding ? send(preceding.content, { attachments: preceding.attachments, resources: preceding.resources, quotes: preceding.quotes, userContent: preceding.userContent, skillIds: preceding.skills?.map(item => item.id), permissionMode, fromMessageId: message.id, action: 'regenerate' }) : Promise.resolve()
|
||||
return preceding ? send(preceding.content, { attachments: preceding.attachments, resources: preceding.resources, quotes: preceding.quotes, userContent: preceding.userContent, skillIds: preceding.skills?.map(item => item.id), fromMessageId: message.id, action: 'regenerate' }) : Promise.resolve()
|
||||
}
|
||||
const lastAssistantId = messages.findLast((message) => message.role === 'assistant')?.id
|
||||
const question = messages.flatMap((message) => message.interactions ?? []).find((item) => item.kind === 'question' && ['pending', 'responding', 'error'].includes(item.status))
|
||||
@@ -402,8 +400,7 @@ export default function ChatPane({ rightVisible, onToggleRight, onOpenSettings,
|
||||
}}
|
||||
speech={{ transcribe: ({ wavBase64, mimeType }) => window.ycode.speech.transcribe({ audioBase64: wavBase64, mimeType }) }}
|
||||
onTranscript={(text) => setDraft((current) => `${current}${current ? ' ' : ''}${text}`)}
|
||||
permissionMode={permissionMode} onModeChange={setPermissionMode} permissionPlacement={isHome ? 'footer' : 'toolbar'} confirmFullAccessOnChange={confirmFullAccess}
|
||||
onSkipFutureFullAccessConfirmationChange={(skip) => setConfirmFullAccess(!skip)}
|
||||
permissionPlacement={isHome ? 'footer' : 'toolbar'}
|
||||
onSend={submit} sending={sending || !!running} onStop={running ? cancel : undefined}
|
||||
modelSlot={<SourceModelSelector models={models} selectedId={effectiveModelId} label={modelLabel} disabled={sending || !!running} darkMode={darkMode}
|
||||
thinkingLevel={effectiveThinkingLevel}
|
||||
@@ -454,7 +451,7 @@ export default function ChatPane({ rightVisible, onToggleRight, onOpenSettings,
|
||||
artifactCount={!running && !sending && message.id === lastAssistantId ? artifactCount : 0}
|
||||
onShowArtifacts={() => window.dispatchEvent(new CustomEvent('ycode:open-artifacts', { detail: { sessionId: currentId } }))}
|
||||
buildingPlanId={running ? messages.find((item) => item.role === 'user' && item.requestId === running.runId)?.planId : undefined}
|
||||
onBuildPlan={async (plan) => { if (running || sending) throw new Error('请等待当前运行结束'); await send(`请执行以下计划:\n\n${planMarkdown(plan)}`, { permissionMode, planId: plan.id }) }}
|
||||
onBuildPlan={async (plan) => { if (running || sending) throw new Error('请等待当前运行结束'); await send(`请执行以下计划:\n\n${planMarkdown(plan)}`, { planId: plan.id }) }}
|
||||
actions={{ busyState: running || sending ? 'talking' : 'idle', canRevert: rewindEntryVisible(message) && !rewinding,
|
||||
rollbackPending: rewinding,
|
||||
canRegenerate: !running && !sending, isSessionActive: true, isLastAssistant: message.id === lastAssistantId,
|
||||
|
||||
@@ -211,7 +211,6 @@ export default function GeneralSettingsPanel(): React.JSX.Element {
|
||||
const openFolder = (kind: 'data' | 'workspace'): void => { void window.ycode.general.openFolder(kind).catch((reason) => setError(String((reason as Error)?.message ?? reason))) }
|
||||
const changeWorkspaceRoot = (): void => { void apply(window.ycode.general.changeWorkspaceRoot()) }
|
||||
const openNotificationSettings = (): void => { void window.ycode.general.openSystemNotificationSettings().catch((reason) => setError(String((reason as Error)?.message ?? reason))) }
|
||||
|
||||
const storage = storageUsagePresentation({
|
||||
dataBytes: view?.dataBytes ?? 0,
|
||||
dataUsagePartial: view?.dataUsagePartial === true,
|
||||
|
||||
@@ -1,5 +1,4 @@
|
||||
import { useEffect, useState } from 'react'
|
||||
import type { PermissionMode } from '@shared/conversation'
|
||||
import type { ThinkingLevel } from '@shared/workspaces'
|
||||
import { ComposerControls, type ComposerSendPayload } from '../workbuddy/composer/ComposerControls'
|
||||
import SourceModelSelector from '../workbuddy/composer/model-selector/SourceModelSelector'
|
||||
@@ -9,7 +8,6 @@ import { useSettingsStore } from '../stores/settings'
|
||||
|
||||
export interface ProjectTaskStartOptions {
|
||||
thinkingLevel?: ThinkingLevel
|
||||
permissionMode?: PermissionMode
|
||||
}
|
||||
|
||||
interface Props {
|
||||
@@ -39,7 +37,6 @@ export default function ProjectComposer({ workspaceId, value, sending, onChange,
|
||||
const workspaces = useSettingsStore(state => state.workspaces)
|
||||
const loaded = useSettingsStore(state => state.loaded)
|
||||
const load = useSettingsStore(state => state.load)
|
||||
const [permissionMode, setPermissionMode] = useState<PermissionMode>('default-sandbox')
|
||||
const [thinkingLevel, setThinkingLevel] = useState<ThinkingLevel>('high')
|
||||
|
||||
useEffect(() => {
|
||||
@@ -52,7 +49,6 @@ export default function ProjectComposer({ workspaceId, value, sending, onChange,
|
||||
}
|
||||
await onSend(payload.text, {
|
||||
thinkingLevel,
|
||||
permissionMode: payload.mode,
|
||||
})
|
||||
}
|
||||
|
||||
@@ -94,9 +90,6 @@ export default function ProjectComposer({ workspaceId, value, sending, onChange,
|
||||
value={value}
|
||||
onChange={onChange}
|
||||
onSend={submit}
|
||||
permissionMode={permissionMode}
|
||||
onModeChange={setPermissionMode}
|
||||
permissionLabels={{ 'default-sandbox': '默认权限', 'full-access': '允许完全访问' }}
|
||||
permissionPlacement="footer"
|
||||
modelSlot={modelSlot}
|
||||
workspaceFooter={workspaceFooter}
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { useEffect, useRef, useState, type ReactNode } from 'react'
|
||||
import type { ChatAttachment, PermissionMode, UserContentBlock } from '@shared/conversation'
|
||||
import type { ChatAttachment, UserContentBlock } from '@shared/conversation'
|
||||
import { trimUserContent } from '@shared/user-content'
|
||||
import { IconStop } from '../../components/icons'
|
||||
import { RuntimeSendIcon } from '../message-actions/RuntimeIcons'
|
||||
@@ -11,7 +11,6 @@ import { SourceFilesSubmenu } from './add-menu/SourceSubmenus'
|
||||
import type { MessageQuote } from '@shared/conversation'
|
||||
import { ReferenceContentEditor, type ReferenceContentEditorHandle } from '../references/ReferenceContentEditor'
|
||||
import { useReferenceViews, type DraftAttachment } from '../references/reference-views'
|
||||
import SourcePermissionControl from './permission/SourcePermissionControl'
|
||||
|
||||
export const MAX_ATTACHMENT_BYTES = 20 * 1024 * 1024
|
||||
|
||||
@@ -28,7 +27,6 @@ export interface ComposerSendPayload {
|
||||
/** 引用进对话的内容块(WorkBuddy message.quote)。 */
|
||||
quotes: MessageQuote[]
|
||||
userContent?: UserContentBlock[]
|
||||
mode: PermissionMode
|
||||
}
|
||||
|
||||
export interface ComposerControlsProps {
|
||||
@@ -54,11 +52,6 @@ export interface ComposerControlsProps {
|
||||
onReferencePath?(path: string): void | Promise<void>
|
||||
speech?: SpeechTranscriber
|
||||
onTranscript?(text: string): void
|
||||
permissionMode?: PermissionMode
|
||||
onModeChange?(mode: PermissionMode): void | Promise<void>
|
||||
permissionLabels?: Partial<Record<PermissionMode, string>>
|
||||
confirmFullAccessOnChange?: boolean
|
||||
onSkipFutureFullAccessConfirmationChange?(skip: boolean): void | Promise<void>
|
||||
sessionId?: string | null
|
||||
modelSlot?: ReactNode
|
||||
/** 左组紧邻「+」之后:Git 分支标签(TODO 21)。 */
|
||||
@@ -96,11 +89,10 @@ export function buildComposerSendPayload(
|
||||
text: string,
|
||||
attachments: ChatAttachment[],
|
||||
resources: string[],
|
||||
mode: PermissionMode,
|
||||
quotes: MessageQuote[] = [],
|
||||
userContent?: UserContentBlock[]
|
||||
): ComposerSendPayload {
|
||||
return { text: text.trim(), attachments, resources: [...resources], quotes: [...quotes], mode, ...(userContent ? { userContent: trimUserContent(userContent) } : {}) }
|
||||
return { text: text.trim(), attachments, resources: [...resources], quotes: [...quotes], ...(userContent ? { userContent: trimUserContent(userContent) } : {}) }
|
||||
}
|
||||
|
||||
async function encodeFile(file: File): Promise<string> {
|
||||
@@ -158,10 +150,6 @@ export function ComposerControls({
|
||||
onReferencePath,
|
||||
speech,
|
||||
onTranscript,
|
||||
permissionMode = 'default-sandbox',
|
||||
onModeChange,
|
||||
permissionLabels,
|
||||
confirmFullAccessOnChange = true,
|
||||
sessionId,
|
||||
modelSlot,
|
||||
branchLabelSlot,
|
||||
@@ -375,7 +363,7 @@ export function ComposerControls({
|
||||
sendInFlightRef.current = true
|
||||
beginBusy()
|
||||
try {
|
||||
await onSend(buildComposerSendPayload(value, attachments, resourcesRef.current, permissionMode, quotesRef.current, richEditorRef.current?.content() ?? userContent))
|
||||
await onSend(buildComposerSendPayload(value, attachments, resourcesRef.current, quotesRef.current, richEditorRef.current?.content() ?? userContent))
|
||||
} catch (reason) {
|
||||
setError(errorText(reason))
|
||||
} finally {
|
||||
@@ -409,22 +397,9 @@ export function ComposerControls({
|
||||
previouslyRich.current = referenceViews.length > 0
|
||||
}, [referenceViews.length])
|
||||
|
||||
const permissionControl = onModeChange ? <SourcePermissionControl
|
||||
mode={permissionMode}
|
||||
labels={permissionLabels}
|
||||
disabled={disabled || busy}
|
||||
confirmFullAccessOnChange={confirmFullAccessOnChange}
|
||||
onError={setError}
|
||||
onChange={async mode => {
|
||||
beginBusy()
|
||||
try { await onModeChange(mode) } finally { endBusy() }
|
||||
}}
|
||||
/> : null
|
||||
|
||||
const homeFooter = (workspaceFooter || (permissionPlacement === 'footer' && permissionControl)) && (
|
||||
const homeFooter = workspaceFooter && (
|
||||
<div className="wb-composer__workspace-footer">
|
||||
<div className="wb-composer__workspace-slot">{workspaceFooter}</div>
|
||||
{permissionPlacement === 'footer' && permissionControl}
|
||||
</div>
|
||||
)
|
||||
|
||||
@@ -507,7 +482,6 @@ export function ComposerControls({
|
||||
}} />
|
||||
{branchLabelSlot}
|
||||
{expertSlot}
|
||||
{permissionPlacement === 'toolbar' && permissionControl}
|
||||
</div>
|
||||
|
||||
<div className="wb-composer__group cr-input-toolbar__right">
|
||||
|
||||
@@ -25,7 +25,6 @@ import type {
|
||||
import type { ContextUsageInfo } from './types'
|
||||
import type { DownloadPayload, DownloadResult, ResourceEntry, ResourceRange, WorkspaceResource } from './resources'
|
||||
import type { ChatAttachment, InteractionResponse, MessageQuote, PermissionMode } from './conversation'
|
||||
import type { SandboxStatus, SandboxPolicyDocument, SandboxPolicyUpdate, SandboxSelfTestSummary, SandboxOperationView, SandboxBrokerOperationView, SandboxWorkspaceView, SandboxGitHostView, SandboxGitRemoteResult } from './sandbox'
|
||||
import type { CustomModelInput, ModelRegistryView, ModelTestResult, ModelDiscoveryInput, ModelDiscoveryResult, ModelMetadata, ModelMetadataStatus, ModelProtocol } from './models'
|
||||
import type { SessionContext, WorkspaceRegistryView } from './workspaces'
|
||||
import type { BrowserHostApi } from './browser-host'
|
||||
@@ -127,16 +126,6 @@ export interface YCodeApi {
|
||||
}
|
||||
app: {
|
||||
info(): Ipc<AppInfo>
|
||||
sandboxStatus(): Ipc<SandboxStatus>
|
||||
sandboxPolicy(): Ipc<SandboxPolicyDocument>
|
||||
setSandboxPolicy(update: Pick<SandboxPolicyUpdate, 'expectedRevision' | 'rules'>): Ipc<SandboxPolicyDocument>
|
||||
sandboxSelfTest(): Ipc<SandboxSelfTestSummary>
|
||||
sandboxOperation(id: string): Ipc<SandboxOperationView>
|
||||
sandboxBrokerOperation(runId: string, requestId: string): Ipc<SandboxBrokerOperationView>
|
||||
sandboxWorkspace(sessionId: string): Ipc<SandboxWorkspaceView>
|
||||
sandboxGit(sessionId: string): Ipc<SandboxGitHostView>
|
||||
sandboxGitRemoteOperation(sessionId: string, operationId: string): Ipc<SandboxGitRemoteResult>
|
||||
onSandboxChanged(listener: () => void): () => void
|
||||
openConfigDir(): Ipc<void>
|
||||
openSettings(section?: string, action?: 'new'): Ipc<void>
|
||||
onSettingsChanged(listener: () => void): () => void
|
||||
|
||||
Reference in new issue
Block a user