diff --git a/package.json b/package.json index b89eb9a..7b00771 100644 --- a/package.json +++ b/package.json @@ -10,6 +10,7 @@ "test:core": "npm run typecheck && npm run test:chat && npm run test:sessions && npm run test:persistence && npm run test:conversation", "test:alignment": "node scripts/run-alignment-regression.mjs", "test:alignment:dom": "node scripts/run-alignment-regression.mjs --require-dom", + "test:permissions:loopback": "node scripts/build-full-access-loopback-kernel.mjs && node scripts/test-full-access-loopback.mjs --kernel", "test:reporting": "node --test scripts/regression-reporting.test.mjs", "test:capability-slash:ui": "node scripts/test-capability-slash-ui.mjs", "test:recommended-skills": "esbuild scripts/recommended-skills-backend-regression.ts --bundle --platform=node --format=esm --external:esbuild --outfile=.tmp/recommended-skills-backend-regression.mjs --log-level=warning && node .tmp/recommended-skills-backend-regression.mjs", diff --git a/scripts/README.md b/scripts/README.md index 6dd3be1..5b3c25e 100644 --- a/scripts/README.md +++ b/scripts/README.md @@ -35,6 +35,8 @@ PowerShell 使用 `$env:YCODE_TEST_DOM_MODULE = <绝对路径>`,再运行相 ## Windows / 真实窗口验证 +`npm run test:permissions:loopback` 是独立的 Linux 本地权限集成验证:实际 ClaudeKernel/SDK/CLI、仅回环假模型、一次性 HOME/userData/workspace。会执行固定测试目录中的读写和 Bash 命令,不使用用户供应商或实际数据,不属于默认离线基线。其他平台明确阻塞;Windows/PowerShell 和真实窗口需另验。建议外层使用 `timeout --signal=TERM --kill-after=5s 120s npm run test:permissions:loopback`,脚本内部另有请求和时间上限。 + 例如 `test:recommended-skills:ui`、`node scripts/verify-task-menu.cjs`、`node scripts/test-mode-chip-native.mjs`。这些是特定场景,不是统一全量 UI 集。先读脚本并确认临时 userData/工作目录、受控宿主、是否访问模型/网络;不得连接生产数据或原版 WorkBuddy 进行未经允许的操作。 `test:recommended-skills:app`、SDK smoke、网络包验证等可能需要额外资源或有外部行为,不纳入默认离线入口。无 DISPLAY、native 模块或权限导致启动失败,应标为**环境阻塞**,不是通过。`--build-only`、SSR、jsdom、静态 CSS 断言都不等于真实布局或像素验收。 diff --git a/scripts/b03-policy-regression.ts b/scripts/b03-policy-regression.ts new file mode 100644 index 0000000..2fff961 --- /dev/null +++ b/scripts/b03-policy-regression.ts @@ -0,0 +1,124 @@ +/** B03 offline host contract: query is stubbed; no SDK/CLI/model/filesystem tools run. */ +import assert from 'node:assert/strict' +import { mkdtemp, rm } from 'node:fs/promises' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { ClaudeKernel } from '../src/main/agent/claude/claude-kernel' +import { clearSessionGrants, sessionGrantRules } from '../src/main/agent/claude/session-grants' +const root = await mkdtemp(join(tmpdir(), 'b03-offline-')) +process.env.CLAUDE_TEST_USER_DATA = root +const logs: string[] = [] +const info = console.info +console.info = (...args) => logs.push(args.join(' ')) +const queries: any[] = [] +let approvals = 0, questions = 0, assertions = 0 +function check(actual: unknown, expected: unknown, message: string) { assert.deepEqual(actual, expected, message); assertions++ } +function deferred() { let resolve!: () => void; const promise = new Promise(r => { resolve = r }); return { promise, resolve } } +;(globalThis as any).__b03Query = ({ options }: any) => { + const stopped = deferred() + const instance = { + closed: false, modes: [] as string[], applied: [] as unknown[], + async *[Symbol.asyncIterator]() { await stopped.promise }, + setPermissionMode: async (mode: string) => { instance.modes.push(mode) }, + applyFlagSettings: async (value: unknown) => { instance.applied.push(value) }, + close: () => { instance.closed = true; stopped.resolve() }, interrupt: async () => undefined + } + queries.push({ options, instance }); return instance +} +const make = (permissionMode: 'default-sandbox' | 'full-access', agentMode: 'craft' | 'ask' | 'plan' = 'craft') => new ClaudeKernel({ + sessionId: `offline-${queries.length}`, workspaceRoot: root, permissionMode, agentMode, + model: { config: { model: 'offline-model' }, profile: { authMode: 'none' } } as any, onSessionId() {} +}) +function active(kernel: ClaudeKernel) { + const raw = kernel as any, signal = new AbortController().signal + const state = { settled: false, content: '', planEmitted: false, appends: new Map(), request: { runId: 'offline', signal, + emit() {}, interactions: { + approval: async () => { approvals++; return { kind: 'approval', optionId: 'allow-once' } }, + question: async () => { questions++; return { kind: 'question', answer: { questions: [] } } } + } }, resolve() {}, reject() {} } + raw.active = state + return { raw, state, invoke: (name = 'Bash', metadata = {}) => raw.canUseTool(name, { command: 'SECRET_COMMAND', file_path: 'SECRET_PATH', questions: [{ question: 'Business?', options: [{ label: 'Yes' }] }] }, { signal, toolUseID: 'SECRET_TOOL_ID', ...metadata }) } +} +const kernels: ClaudeKernel[] = [] +try { + for (const permission of ['default-sandbox', 'full-access'] as const) for (const mode of ['craft', 'ask', 'plan'] as const) { + const kernel = make(permission, mode); kernels.push(kernel); await kernel.ensureStarted() + const { options, instance } = queries.at(-1) + check(options.permissionMode, mode === 'plan' ? 'plan' : 'default', 'initial SDK mode matches host-gate design') + check(options.allowDangerouslySkipPermissions, undefined, 'no bypass capability') + check(options.allowedTools, [], 'no global whitelist') + const test = active(kernel), hook = options.hooks.PreToolUse[0].hooks[0] + for (const toolName of ['Read', 'Write', 'Edit', 'Bash', 'PowerShell']) { + const response = await hook({ hook_event_name: 'PreToolUse', tool_name: toolName, tool_use_id: 'SECRET_TOOL_ID', tool_input: {} }) + const expected = mode !== 'craft' && toolName !== 'Read' ? 'deny' : permission === 'full-access' && mode === 'craft' ? 'allow' : undefined + check(response.hookSpecificOutput?.permissionDecision, expected, `${permission}/${mode}/${toolName} hook`) + } + if (permission === 'full-access' && mode === 'craft') { + const beforeFull = approvals + const metadataCases = [ {}, { classifierApprovable: false }, { classifier_approvable: false }, + { decisionReasonType: 'subcommandResults' }, { decision_reason_type: 'subcommandResults' }, + { decisionReasonType: 'safetyCheck' }, { decision_reason_type: 'safetyCheck' }, + { decisionReasonCode: 'SECRET_CODE' }, { decision_reason_code: 'SECRET_CODE' }, + { requiresUserInteraction: true }, { requires_user_interaction: true }, { serverPolicyAskOnly: true }, + { matchedAskRule: { source: 'projectSettings', toolName: 'Bash' } }, + { matched_ask_rule: { source: 'projectSettings', tool_name: 'Bash' } }, { matchesAskRule: true }, + { futureUnknownField: { text: 'SECRET_CODE' } } ] + for (const tool of ['Read', 'Write', 'Edit', 'Bash', 'PowerShell']) for (const metadata of metadataCases) { + const response = await test.invoke(tool, metadata) + check(response.behavior, 'allow', `full access callback preauthorizes ${tool} regardless of metadata`) + check(response.updatedPermissions, undefined, 'preauthorization does not return permission grants') + } + check(approvals, beforeFull, 'every full-access callback metadata combination has zero approvals') + } + const before = approvals + const result = await test.invoke('Bash', { matchedAskRule: { source: 'projectSettings', toolName: 'Bash' }, decisionReasonType: 'subcommandResults', decisionReasonCode: 'SECRET_CODE' }) + check(result.behavior, mode === 'craft' ? 'allow' : 'deny', 'callback respects mode') + check(approvals - before, mode === 'craft' && permission !== 'full-access' ? 1 : 0, 'only default permission asks') + check((await hook({ hook_event_name: 'PreToolUse', tool_name: 'AskUserQuestion', tool_use_id: 'question', tool_input: {} })).hookSpecificOutput?.permissionDecision, 'ask', 'business question remains interactive') + const q = questions; await test.invoke('AskUserQuestion'); check(questions, q + 1, 'business callback not masked') + check((await options.hooks.PreToolUse[1].hooks[0]({ hook_event_name: 'PreToolUse', tool_input: { plan: 'Test plan' } })).hookSpecificOutput.permissionDecision, 'deny', 'plan content does not authorize execution') + check(test.state.planEmitted, true, 'plan content still emitted') + check(instance.applied, [], 'preauthorization never writes grants') + check(sessionGrantRules((kernel as any).options.sessionId, root), [], 'no remembered full-access grants') + test.raw.active = null; await kernel.close() + } + const kernel = make('default-sandbox'); kernels.push(kernel); await kernel.ensureStarted() + const test = active(kernel), first = queries.at(-1), gate = deferred(), called = deferred() + first.instance.setPermissionMode = async (mode: string) => { first.instance.modes.push(mode); called.resolve(); await gate.promise } + const switching = kernel.setPolicy('full-access', 'craft'); await called.promise + check((await test.invoke()).behavior, 'deny', 'pending transition fails closed') + const second = kernel.setPolicy('full-access', 'plan') + gate.resolve(); await Promise.all([switching, second]) + check(first.instance.modes, ['default', 'plan'], 'SDK updates serialized') + check(test.raw.agentMode, 'plan', 'latest policy wins') + check((await test.invoke()).behavior, 'deny', 'full-access plan cannot execute') + await kernel.setPolicy('default-sandbox', 'craft') + const answer = deferred(), approvalStarted = deferred() + test.state.request.interactions.approval = async () => { approvals++; approvalStarted.resolve(); await answer.promise; return { kind: 'approval', optionId: 'allow-once' } } + const stale = test.invoke(); await approvalStarted.promise + await kernel.setPolicy('full-access', 'craft'); answer.resolve() + check((await stale).behavior, 'deny', 'stale approval cannot execute after policy switch') + test.raw.active = null + first.instance.setPermissionMode = async () => { throw new Error('synthetic switch rejection') } + await assert.rejects(kernel.setPolicy('default-sandbox', 'ask'), /synthetic switch rejection/); assertions++ + check(first.instance.closed, true, 'failed switch retires uncertain producer') + check(test.raw.instance, null, 'failed switch cannot reuse CLI') + await assert.rejects(kernel.ensureStarted(), /权限切换失败/); assertions++ + // Caller retries its requested persisted policy before the next turn. + await kernel.setPolicy('default-sandbox', 'ask'); await kernel.ensureStarted() + check(queries.at(-1).options.permissionMode, 'default', 'rebuilt SDK uses latest mode') + check(test.raw.agentMode, 'ask', 'rebuilt host uses latest policy') + const rebuilt = active(kernel); check((await rebuilt.invoke()).behavior, 'deny', 'rebuilt ask still denies command') + rebuilt.raw.active = null + const text = logs.join('\n') + for (const secret of ['SECRET_COMMAND', 'SECRET_PATH', 'SECRET_TOOL_ID', 'SECRET_CODE']) check(text.includes(secret), false, 'diagnostics exclude raw data') + const diagnostic = logs.map(line => JSON.parse(line.slice(line.indexOf('{')))).find(item => item.decisionCodePresent) + check(diagnostic.decisionCode, 'unknown', 'unrecognized code is explicitly unknown') + check(diagnostic.decisionCategory, 'subcommandResults', 'known category retained') + check(diagnostic.matchedAskRule, true, 'true matched rule distinguished') + info(`B03 offline host policy: ${assertions} checks passed; no real SDK/CLI/model execution.`) +} finally { + console.info = info + for (const kernel of kernels) { (kernel as any).active = null; await kernel.close() } + clearSessionGrants(); await rm(root, { recursive: true, force: true }) +} diff --git a/scripts/build-b03-policy-regression.mjs b/scripts/build-b03-policy-regression.mjs new file mode 100644 index 0000000..005290f --- /dev/null +++ b/scripts/build-b03-policy-regression.mjs @@ -0,0 +1,12 @@ +import { build } from 'esbuild' +await build({ + entryPoints: ['scripts/b03-policy-regression.ts'], outfile: '.tmp/b03-policy-regression.mjs', + bundle: true, platform: 'node', format: 'esm', logLevel: 'warning', + banner: { js: `import { createRequire } from 'node:module'; const require = createRequire(import.meta.url);` }, + plugins: [{ name: 'b03-offline-stubs', setup(plugin) { + plugin.onResolve({ filter: /^(electron|@anthropic-ai\/claude-agent-sdk)$/ }, args => ({ path: args.path, namespace: 'b03-stub' })) + plugin.onLoad({ filter: /.*/, namespace: 'b03-stub' }, args => ({ loader: 'js', contents: args.path === 'electron' + ? `export const app={getPath:()=>process.env.CLAUDE_TEST_USER_DATA,getAppPath:()=>process.cwd()};export const safeStorage={isEncryptionAvailable:()=>false};` + : `export const query = args => globalThis.__b03Query(args); export const createSdkMcpServer = x => x; export const tool = (...args) => args;` })) + } }] +}) diff --git a/scripts/build-full-access-loopback-kernel.mjs b/scripts/build-full-access-loopback-kernel.mjs new file mode 100644 index 0000000..c39318b --- /dev/null +++ b/scripts/build-full-access-loopback-kernel.mjs @@ -0,0 +1,14 @@ +import { build } from 'esbuild' +await build({ + entryPoints: ['src/main/agent/claude/claude-kernel.ts'], outfile: '.tmp/b03-real-kernel.mjs', + bundle: true, platform: 'node', format: 'esm', external: ['@anthropic-ai/claude-agent-sdk'], + logLevel: 'warning', + banner: { js: `import { createRequire } from 'node:module'; const require = createRequire(import.meta.url);` }, + plugins: [{ name: 'isolated-electron-paths-only', setup(plugin) { + plugin.onResolve({ filter: /^electron$/ }, () => ({ path: 'electron', namespace: 'b03-test' })) + plugin.onLoad({ filter: /.*/, namespace: 'b03-test' }, () => ({ loader: 'js', contents: ` + export const app = { getPath: name => { if (name !== 'userData') throw new Error('Unexpected Electron path request'); return process.env.CLAUDE_TEST_USER_DATA }, getAppPath: () => process.cwd() }; + export const safeStorage = { isEncryptionAvailable: () => false }; + ` })) + } }] +}) diff --git a/scripts/claude-approval-policy-regression.ts b/scripts/claude-approval-policy-regression.ts index c2180bd..55c5290 100644 --- a/scripts/claude-approval-policy-regression.ts +++ b/scripts/claude-approval-policy-regression.ts @@ -29,7 +29,14 @@ try { assert.deepEqual(denied.events.map(event=>event.progress.phase),['waiting-approval','thinking'],'rejected approval retires waiting status before next model event') for(const mode of ['ask','plan'] as const) {const old=calls;assert.equal((await setup(create(mode,mode,'full-access')).invoke()).behavior,'deny');assert.equal(calls,old)} const full=setup(create('full','craft','full-access'));const old=calls;assert.equal((await full.invoke()).behavior,'allow');assert.equal(calls,old) - await full.invoke({matchedAskRule:{source:'projectSettings',toolName:'Bash',ruleContent:'echo safe:*'}});assert.equal(calls,old+1) + await full.invoke({matchedAskRule:{source:'projectSettings',toolName:'Bash',ruleContent:'echo safe:*'}});assert.equal(calls,old) + for (const metadata of [{classifierApprovable:false},{decisionReasonType:'subcommandResults'},{decisionReasonType:'safetyCheck'},{decisionReasonCode:'ordinary-ask'},{requiresUserInteraction:true},{futureUnknownField:true}]) { + assert.equal((await full.invoke(metadata)).behavior,'allow'); assert.equal(calls,old) + } + assert.deepEqual(sessionGrantRules('full',temp),[],'full access never creates a session grant') + const rule=setup(create('rule'),'allow-once');let requestKind='' + rule.raw.active.request.interactions.approval=async(...args:any[])=>{calls++;requestKind=args[5].requestKind;return {kind:'approval',optionId:'allow-once'}} + await rule.invoke({matchedAskRule:{source:'projectSettings',toolName:'Bash'}});assert.equal(calls,old+1);assert.equal(requestKind,'rule-intercept') const failed=setup(create('failed'));failed.raw.instance.applyFlagSettings=async()=>{throw new Error('apply failed')};assert.equal((await failed.invoke()).behavior,'deny');assert.equal(sessionGrantRules('failed',temp).length,0) const stale=setup(create('stale'));stale.raw.active.request.interactions.approval=async()=>{stale.controller.abort();return {kind:'approval',optionId:'allow-always'}} assert.equal((await stale.invoke()).behavior,'deny');assert.equal(sessionGrantRules('stale',temp).length,0) diff --git a/scripts/input-composition-regression.ts b/scripts/input-composition-regression.ts index 38780b9..1c52517 100644 --- a/scripts/input-composition-regression.ts +++ b/scripts/input-composition-regression.ts @@ -26,6 +26,7 @@ for (const kind of ['question', 'approval', 'plan', 'mcp'] as const) { running: true }) assert.equal(result.interaction?.kind, kind) + assert.equal(result.input.visible, kind !== 'approval', 'only actual approval replaces normal input') assert.equal(result.input.blockedByInteraction, true) assert.equal(result.input.destination, 'blocked') assert.equal(result.input.preservesDraft, true, 'takeover never clears the draft') @@ -46,6 +47,7 @@ result = composeComposer({ queueCount: 2 }) assert.equal(result.interaction, null, 'settled interactions no longer take over') +assert.equal(result.input.visible, true) assert.equal(result.queue.visible, true) assert.deepEqual(result.stack.beforeQueue.map(panel => panel.kind), ['task']) assert.equal(result.stack.queue?.kind, 'queue') diff --git a/scripts/permission-intercept-dom-regression.tsx b/scripts/permission-intercept-dom-regression.tsx index efa8bf3..e4c6197 100644 --- a/scripts/permission-intercept-dom-regression.tsx +++ b/scripts/permission-intercept-dom-regression.tsx @@ -1,16 +1,22 @@ /** jsdom behavior only; no layout, browser pixels, SDK process or model calls. */ import assert from 'node:assert/strict' import { createRequire } from 'node:module' +import { readFileSync } from 'node:fs' const externalRequire=createRequire(process.cwd()+'/package.json') const {JSDOM}=externalRequire(process.env.YCODE_TEST_DOM_MODULE||'jsdom') const dom=new JSDOM('
',{url:'http://localhost/',pretendToBeVisual:true}) -for(const key of ['window','document','Node','Element','HTMLElement','HTMLInputElement','HTMLTextAreaElement','MouseEvent','KeyboardEvent']) Object.defineProperty(globalThis,key,{value:dom.window[key],configurable:true}) +for(const key of ['window','document','Node','Element','HTMLElement','HTMLInputElement','HTMLTextAreaElement','MouseEvent','KeyboardEvent','Document','Text','DOMParser','MutationObserver','Range','ShadowRoot','File','FileReader','Event','getComputedStyle']) Object.defineProperty(globalThis,key,{value:dom.window[key],configurable:true}) Object.defineProperty(globalThis,'navigator',{value:dom.window.navigator,configurable:true}) -Object.assign(globalThis,{IS_REACT_ACT_ENVIRONMENT:true}) +Object.assign(globalThis,{IS_REACT_ACT_ENVIRONMENT:true,requestAnimationFrame:dom.window.requestAnimationFrame.bind(dom.window),cancelAnimationFrame:dom.window.cancelAnimationFrame.bind(dom.window)}) +const composerStyle=document.createElement('style') +composerStyle.textContent=['composer.css','composer-source.css'].map(file=>readFileSync(`src/renderer/src/workbuddy/composer/${file}`,'utf8')).join('\n') +document.head.append(composerStyle) const React=require('react') as typeof import('react') const {act}=React const {createRoot}=require('react-dom/client') as typeof import('react-dom/client') const {PermissionConfirm}=require('../src/renderer/src/workbuddy/conversation/PermissionConfirm') as typeof import('../src/renderer/src/workbuddy/conversation/PermissionConfirm') +const {ComposerControls}=require('../src/renderer/src/workbuddy/composer/ComposerControls') as typeof import('../src/renderer/src/workbuddy/composer/ComposerControls') +const {composeComposer}=require('../src/renderer/src/workbuddy/composer/input-composition') as typeof import('../src/renderer/src/workbuddy/composer/input-composition') const root=createRoot(document.getElementById('root')!) let count=0,completed=0,declined=0, resolve!:()=>void,reject!:(e:Error)=>void let checks=0 @@ -48,6 +54,81 @@ async function main(){ check(document.querySelector('section')?.textContent?.includes('Cancelled'),'cancelled remains visible') id='e';status='pending';await render();await act(async()=>click('.conversation-intercept-card__rule-action')) check(document.querySelector('[role="status"]')?.textContent?.includes('未提供命中规则详情'),'missing rule explanation explicit') + // Exercise the real composer with the real approval surface, not a skeleton. + let session='session-a', active=true, sends=0, stops=0, changes=0, stopFails=false, request='request-1', stopPending=false + let rejectStop!: (error: Error)=>void + let settled: 'answered'|'rejected'|undefined + const draft='preserved draft' + const attachments=[{id:'attachment-a',name:'a.txt',mimeType:'text/plain',size:1}] + const resources=['/fixture/reference.ts'] + const quotes=[{id:'quote-a',kind:'table' as const,sourceId:'source-a',markdown:'table',preview:'table'}] + const renderComposer=()=>act(async()=>{ + const composition=composeComposer({interaction:active?{id:session,kind:'approval',status:'pending'}:null,queueCount:2,running:true}) + root.render({changes++}} onSend={()=>{sends++}} + attachments={attachments} resources={resources} quotes={quotes} onStop={()=>{stops++;if(stopFails)throw new Error('stop fixture failed');if(stopPending)return new Promise((_yes,no)=>{rejectStop=no})}} + queueSlot={composition.queue.visible?
two queued messages
:undefined} + approvalKey={request} approvalSlot={!composition.input.visible?{declined++}} onDecisionComplete={decision=>{settled=decision}}/>:undefined}/>) + }) + await renderComposer() + const stack=document.querySelector('.conversation-input-area__input-stack')! + check(stack.hidden&&stack.hasAttribute('inert'),'approval hides and inerts preserved input stack') + check(getComputedStyle(stack).display==='none','composer CSS removes the normal input from layout during approval') + check(document.querySelectorAll('[data-composer-approval] [role="dialog"]').length===1,'one bottom approval surface') + check(!document.querySelector('.wb-composer__toolbar'),'normal toolbar and send controls removed during approval') + check(!document.querySelector('[data-queue]'),'queue hidden without deleting queue state') + const editor=stack.querySelector('[contenteditable]')! + check(editor?.getAttribute('contenteditable')==='false','rich editor disabled during approval') + const changesBefore=changes + await act(async()=>{editor.focus();editor.dispatchEvent(new KeyboardEvent('keydown',{key:'Enter',bubbles:true,cancelable:true}))}) + check(!stack.contains(document.activeElement)&&sends===0&&changes===changesBefore,'hidden editor cannot focus, type or submit') + await act(async()=>click('[data-option-id="once"]')) + await act(async()=>reject(new Error('composer retry fixture'))) + check(document.querySelector('[role="alert"]')?.textContent?.includes('composer retry fixture'),'submission failure stays in replacement surface') + check(stack.hidden&&!document.querySelector('.wb-composer__toolbar'),'failure does not expose editor/send') + await act(async()=>click('[data-option-id="once"]'));await act(async()=>resolve()) + check(settled==='answered','allow completes through real composer') + active=false;await renderComposer() + check(!stack.hidden&&!stack.hasAttribute('inert'),'completion restores input stack') + check(getComputedStyle(stack).display!=='none','composer CSS restores normal input layout') + check(stack.textContent?.includes('a.txt')&&stack.textContent?.includes('reference.ts')&&stack.textContent?.includes('会话表格'),'attachment resource and quote survive approval') + check(stack.textContent?.includes(draft),'rich draft survives approval') + check(!!document.querySelector('[data-queue]')&&!!document.querySelector('.wb-composer__toolbar'),'queue and send toolbar restore') + active=true;settled=undefined;await renderComposer();await act(async()=>key('Escape')) + check(settled==='rejected','deny completes through real composer') + active=false;await renderComposer();check(!stack.hidden,'deny restores editor after terminal interaction') + active=true;await renderComposer();await act(async()=>click('[data-option-id="once"]'));const staleResolve=resolve + session='session-b';active=false;settled=undefined;await renderComposer();await act(async()=>staleResolve()) + check(!document.querySelector('[data-composer-approval]')&&settled===undefined,'session switch unmounts old request and ignores its late result') + active=true;stopFails=true;await renderComposer();await act(async()=>click('.wb-composer__approval-stop')) + check(document.querySelector('[data-composer-approval] [role="alert"]')?.textContent?.includes('stop fixture failed'),'failed cancellation visible in approval surface') + request='request-2';stopFails=false;await renderComposer() + check(!document.querySelector('[data-composer-approval] [role="alert"]'),'next request clears previous cancellation failure') + await act(async()=>click('.wb-composer__approval-stop')) + check(!document.querySelector('[data-composer-approval] [role="alert"]'),'cancellation retry clears error') + check(stops===2,'stop remains reachable independently of normal send') + stopPending=true;await renderComposer();await act(async()=>click('.wb-composer__approval-stop')) + check(document.querySelector('.wb-composer__approval-stop')?.disabled,'pending stop disabled against duplicate cancellation') + request='request-3';stopPending=false;await renderComposer();await act(async()=>rejectStop(new Error('obsolete stop failure'))) + check(!document.querySelector('[data-composer-approval] [role="alert"]')&&!document.querySelector('.wb-composer__approval-stop')?.disabled,'late previous-request cancellation cannot alter new request') + active=false;await renderComposer();check(!document.querySelector('[data-composer-approval]'),'cancelled interaction restores ordinary input') + // An upload already started before takeover must finish into the same draft without stealing focus. + let uploaded: typeof attachments=[], stagingResolve!: (value: typeof attachments[number])=>void, uploadActive=false + const uploadHost={pick:async()=>[],stage:()=>new Promise(yes=>{stagingResolve=yes})} + const renderUpload=()=>act(async()=>root.render({}} onSend={()=>{sends++}} + attachments={uploaded} onAttachmentsChange={items=>{uploaded=items}} attachmentHost={uploadHost} + approvalKey="upload-approval" approvalSlot={uploadActive?{}}/>:undefined}/>)) + await renderUpload() + const drop=new Event('drop',{bubbles:true,cancelable:true}) + Object.defineProperty(drop,'dataTransfer',{value:{files:[new File(['fixture'],'upload.txt',{type:'text/plain'})],types:[],getData:()=>''}}) + await act(async()=>{document.querySelector('.wb-composer')!.dispatchEvent(drop);await new Promise(yes=>setTimeout(yes,30))}) + check(typeof stagingResolve==='function','attachment staging started before takeover') + uploadActive=true;await renderUpload() + await act(async()=>stagingResolve({id:'uploaded',name:'upload.txt',mimeType:'text/plain',size:7})) + await renderUpload() + check(uploaded.length===1,'in-flight attachment preserved in owning draft during approval') + check(!document.querySelector('.conversation-input-area__input-stack')!.contains(document.activeElement),'upload completion does not focus hidden editor') + uploadActive=false;await renderUpload() + check(document.querySelector('.conversation-input-area__input-stack')?.textContent?.includes('upload.txt'),'completed attachment restored after approval') await act(async()=>root.unmount());dom.window.close() console.log(`Permission intercept DOM: ${checks} checks passed (not pixel/SDK verification)`) process.exit(0) diff --git a/scripts/run-alignment-regression.mjs b/scripts/run-alignment-regression.mjs index 2665b18..02447dc 100644 --- a/scripts/run-alignment-regression.mjs +++ b/scripts/run-alignment-regression.mjs @@ -34,7 +34,7 @@ for (const file of tests) { } // Host/SDK-interface fixtures; these builders stub query and never launch the CLI. -for (const file of ['scripts/build-runtime-regressions.mjs', '.tmp/claude-approval-policy-regression.mjs', '.tmp/claude-grant-replay-regression.mjs']) { +for (const file of ['scripts/build-runtime-regressions.mjs', '.tmp/claude-approval-policy-regression.mjs', '.tmp/claude-grant-replay-regression.mjs', 'scripts/build-b03-policy-regression.mjs', '.tmp/b03-policy-regression.mjs']) { const result = spawnSync(process.execPath, [file], { stdio: 'inherit', timeout: 120000 }) if (result.error) throw result.error if (result.status !== 0) process.exit(result.status ?? 1) @@ -54,7 +54,7 @@ if (domModule) { if (modeChip.status !== 0) process.exit(modeChip.status ?? 1) } -console.log(JSON.stringify({ result: 'PASS', phase: 'alignment-offline', suites: tests.length + 2 })) +console.log(JSON.stringify({ result: 'PASS', phase: 'alignment-offline', suites: tests.length + 3 })) console.log(JSON.stringify(domModule ? { result: 'PASS', phase: 'alignment-dom', suites: 7 } : { result: 'SKIP', phase: 'alignment-dom', suites: 7, reason: 'YCODE_TEST_DOM_MODULE is not set; DOM coverage was not run. Use --require-dom to forbid this skip.' })) diff --git a/scripts/test-full-access-loopback.mjs b/scripts/test-full-access-loopback.mjs new file mode 100644 index 0000000..badd1f8 --- /dev/null +++ b/scripts/test-full-access-loopback.mjs @@ -0,0 +1,177 @@ +// B03: real SDK/CLI, deterministic loopback model, disposable fixtures only. +// Default: SDK protocol policy. --kernel: bundled production ClaudeKernel, real SDK. +import assert from 'node:assert/strict' +import { createServer } from 'node:http' +import { mkdtemp, mkdir, writeFile, readFile, stat, rm } from 'node:fs/promises' +import { tmpdir } from 'node:os' +import { join, resolve } from 'node:path' + +if (process.platform !== 'linux') throw new Error('B03 loopback harness supports Linux Bash only; Windows/PowerShell requires separate validation') + +const executable = resolve(`node_modules/@anthropic-ai/claude-agent-sdk-${process.platform}-${process.arch}/${process.platform === 'win32' ? 'claude.exe' : 'claude'}`) +const root = await mkdtemp(join(tmpdir(), 'ycode-b03-loopback-')) +const workspace = join(root, 'workspace') +for (const name of ['workspace', 'home', 'config', 'tmp', 'user-data']) await mkdir(join(root, name)) +const env = { + PATH: '/usr/bin:/bin', HOME: join(root, 'home'), USERPROFILE: join(root, 'home'), + TMPDIR: join(root, 'tmp'), TMP: join(root, 'tmp'), TEMP: join(root, 'tmp'), + CLAUDE_CONFIG_DIR: join(root, 'config'), CLAUDE_SECURESTORAGE_CONFIG_DIR: join(root, 'config'), + CLAUDE_TEST_USER_DATA: join(root, 'user-data'), + CLAUDE_CODE_DISABLE_NONESSENTIAL_TRAFFIC: '1', DISABLE_TELEMETRY: '1', DISABLE_ERROR_REPORTING: '1', + ANTHROPIC_API_KEY: 'isolated-loopback-placeholder-not-a-credential', + ANTHROPIC_MODEL: 'claude-loopback', ANTHROPIC_DEFAULT_OPUS_MODEL: 'claude-loopback', + ANTHROPIC_DEFAULT_SONNET_MODEL: 'claude-loopback', ANTHROPIC_DEFAULT_HAIKU_MODEL: 'claude-loopback' +} +// Clear inheritance before SDK import as well as passing an explicit child env. +for (const key of Object.keys(process.env)) delete process.env[key] +Object.assign(process.env, env) +let stage = 'bind', activeCase, instance, kernel, requests = 0 +const kernelMode = process.argv.includes('--kernel') +const results = [] +function send(res, event, data) { res.write(`event: ${event}\ndata: ${JSON.stringify(data)}\n\n`) } +const server = createServer((req, res) => { + if (++requests > 60) { res.writeHead(429).end(); return } + if (req.method !== 'POST' || !req.url?.startsWith('/v1/messages')) { res.writeHead(404).end(); return } + let raw = '' + req.on('data', chunk => { raw += chunk; if (raw.length > 1024 * 1024) req.destroy() }) + req.on('end', () => { + try { + const body = JSON.parse(raw) + const c = activeCase + assert.ok(c) + const next = c.steps[c.issued++] + res.writeHead(200, { 'content-type': 'text/event-stream' }) + send(res, 'message_start', { type: 'message_start', message: { id: `msg_${requests}`, type: 'message', role: 'assistant', model: body.model, content: [], stop_reason: null, stop_sequence: null, usage: { input_tokens: 10, output_tokens: 1 } } }) + const content = next ? { type: 'tool_use', id: `tool_${requests}`, name: next.name, input: {} } : { type: 'text', text: '' } + send(res, 'content_block_start', { type: 'content_block_start', index: 0, content_block: content }) + send(res, 'content_block_delta', { type: 'content_block_delta', index: 0, delta: next ? { type: 'input_json_delta', partial_json: JSON.stringify(next.input) } : { type: 'text_delta', text: 'Fixture complete.' } }) + send(res, 'content_block_stop', { type: 'content_block_stop', index: 0 }) + send(res, 'message_delta', { type: 'message_delta', delta: { stop_reason: next ? 'tool_use' : 'end_turn', stop_sequence: null }, usage: { output_tokens: 5 } }) + send(res, 'message_stop', { type: 'message_stop' }); res.end() + } catch { res.destroy() } + }) +}) +const abortController = new AbortController() +const timer = setTimeout(() => abortController.abort(), 100000) +const write = (file, content) => ({ name: 'Write', input: { file_path: join(workspace, file), content } }) +async function verifyCompound() { + assert.equal(await readFile(join(workspace, 'compound-complete.txt'), 'utf8'), 'complete') + await assert.rejects(stat(join(workspace, 'disposable')), { code: 'ENOENT' }) + await assert.rejects(stat(join(workspace, 'disposable/item.txt')), { code: 'ENOENT' }) +} +const question = { name: 'AskUserQuestion', input: { questions: [{ question: 'Which fixture?', header: 'Fixture', multiSelect: false, options: [{ label: 'Alpha', description: 'First fixture' }, { label: 'Beta', description: 'Second fixture' }] }] } } +try { + // Binding is deliberately first: on denial do not import/start the SDK. + await new Promise((ok, fail) => { server.once('error', fail); server.listen(0, '127.0.0.1', ok) }) + env.ANTHROPIC_BASE_URL = `http://127.0.0.1:${server.address().port}` + Object.assign(process.env, env) + stage = 'sdk-import' + const { query } = await import('@anthropic-ai/claude-agent-sdk') + await writeFile(join(workspace, 'existing.txt'), 'before\n') + const cases = [ + { label: 'craft-full', mode: 'craft', full: true, steps: [ + { name: 'Read', input: { file_path: join(workspace, 'existing.txt') } }, + { name: 'Edit', input: { file_path: join(workspace, 'existing.txt'), old_string: 'before', new_string: 'after' } }, + write('created.txt', 'created\n'), + { name: 'Bash', input: { command: 'printf simple > simple.txt', timeout: 5000 } }, + { name: 'Bash', input: { command: 'mkdir disposable && printf fixture > disposable/item.txt && rm disposable/item.txt && rmdir disposable && printf complete > compound-complete.txt', timeout: 5000 } }, question + ] }, + { label: 'craft-default', mode: 'craft', full: false, steps: [write('default-denied.txt', 'blocked')] }, + { label: 'plan-full', mode: 'plan', full: true, steps: [write('plan-denied.txt', 'blocked')] }, + { label: 'ask-full', mode: 'ask', full: true, steps: [write('ask-denied.txt', 'blocked')] } + ] + if (kernelMode) { + stage = 'kernel-import' + const { ClaudeKernel } = await import('../.tmp/b03-real-kernel.mjs') + const model = { + profile: { id: 'b03-fixture', name: 'B03 fixture', model: 'claude-loopback', authMode: 'api-key', protocol: 'anthropic', contextWindowTokens: 200000 }, + config: { api: 'anthropic-messages', baseUrl: env.ANTHROPIC_BASE_URL + '/v1', model: 'claude-loopback', apiKey: env.ANTHROPIC_API_KEY, authMode: 'api-key' } + } + const makeKernel = permissionMode => new ClaudeKernel({ sessionId: crypto.randomUUID(), workspaceRoot: workspace, model, permissionMode, agentMode: 'craft', thinkingLevel: 'off', forceNewSession: true, onSessionId() {} }) + kernel = makeKernel('default-sandbox') + const sequence = [cases[1], cases[0], cases[2], cases[3], { ...cases[1], label: 'default-return' }, + { label: 'ask-rule-full', mode: 'craft', full: true, rule: true, steps: [{ name: 'Bash', input: { command: 'printf rule >> rule-marker.txt', timeout: 5000 } }] }, + { label: 'ask-rule-default', mode: 'craft', full: false, rule: true, steps: [{ name: 'Bash', input: { command: 'printf rule >> rule-marker.txt', timeout: 5000 } }] }, + { label: 'rebuilt-full', mode: 'craft', full: true, rebuild: true, steps: [write('rebuilt.txt', 'rebuilt'), question] }, + { label: 'full-execution-errors', mode: 'craft', full: true, errors: true, steps: [ + { name: 'Edit', input: { file_path: join(workspace, 'does-not-exist.txt'), old_string: 'before', new_string: 'after' } }, + { name: 'Bash', input: { command: 'exit 7', timeout: 5000 } } + ] }] + for (const c of sequence) { + stage = c.label + if (c.rebuild) { await kernel.close(); kernel = makeKernel('full-access') } + await kernel.setPolicy(c.full ? 'full-access' : 'default-sandbox', c.mode) + if (c.rule) { + // Query is the real initialized SDK instance, not a replacement. This + // API changes only its session flag layer, never user config files. + assert.ok(kernel.instance) + await kernel.instance.applyFlagSettings({ permissions: { ask: ['Bash'] } }) + } + activeCase = { ...c, issued: 0 } + let approvals = 0, questions = 0, ruleApprovals = 0 + const output = await kernel.runTurn({ runId: crypto.randomUUID(), userMessageId: crypto.randomUUID(), prompt: `Run deterministic fixture ${c.label}.`, signal: abortController.signal, + emit() {}, interactions: { + async approval(...args) { approvals++; if (args[5]?.requestKind === 'rule-intercept') ruleApprovals++; return { kind: 'approval', optionId: 'reject-once' } }, + async question(_id, _title, items) { questions++; return { kind: 'question', answer: { questions: items.map(item => ({ id: item.id, question: item.question, answers: ['Alpha'] })) } } } + } + }) + const toolErrors = output.tools.filter(tool => tool.status === 'error').length + assert.equal(output.tools.length, c.steps.length) + if (c.rule) { + assert.equal(approvals, c.full ? 0 : 1) + assert.equal(ruleApprovals, c.full ? 0 : 1) + assert.equal(toolErrors, c.full ? 0 : 1) + assert.equal(await readFile(join(workspace, 'rule-marker.txt'), 'utf8'), 'rule') + } else if (c.errors) { assert.equal(approvals, 0); assert.equal(toolErrors, 2) } + else if (c.mode === 'craft' && c.full) { + assert.equal(approvals, 0); assert.equal(questions, 1); assert.equal(toolErrors, 0) + if (c.rebuild) assert.equal(await readFile(join(workspace, 'rebuilt.txt'), 'utf8'), 'rebuilt') + else { assert.equal(await readFile(join(workspace, 'existing.txt'), 'utf8'), 'after\n'); assert.equal(await readFile(join(workspace, 'created.txt'), 'utf8'), 'created\n'); assert.equal(await readFile(join(workspace, 'simple.txt'), 'utf8'), 'simple'); await verifyCompound() } + } else { + assert.equal(toolErrors, 1) + assert.equal(approvals, c.mode === 'craft' ? 1 : 0) + await assert.rejects(readFile(join(workspace, `${c.mode === 'craft' ? 'default' : c.mode}-denied.txt`)), { code: 'ENOENT' }) + } + results.push({ case: c.label, approvals, questions, ruleApprovals, toolErrors, ...(c.label === 'craft-full' ? { compoundMarker: true, disposableAbsent: true } : {}) }) + } + await kernel.close(); kernel = undefined + } else for (const c of cases) { + stage = c.label + activeCase = { ...c, issued: 0 }; let approvals = 0, questions = 0, hookDenials = 0, toolErrors = 0 + instance = query({ prompt: 'Run only the deterministic local fixture sequence.', options: { + cwd: workspace, pathToClaudeCodeExecutable: executable, env, abortController, + model: 'claude-loopback', permissionMode: 'default', permissionPrompts: 'host', + settingSources: [], strictMcpConfig: true, mcpServers: {}, plugins: [], agents: {}, + persistSession: false, maxTurns: 12, tools: ['Read', 'Edit', 'Write', 'Bash', 'AskUserQuestion'], + hooks: { PreToolUse: [{ hooks: [async hook => { + if (hook.tool_name === 'AskUserQuestion') return { hookSpecificOutput: { hookEventName: 'PreToolUse', permissionDecision: 'ask' } } + if (c.mode !== 'craft' && ['Write', 'Edit', 'Bash'].includes(hook.tool_name)) { + hookDenials++; return { hookSpecificOutput: { hookEventName: 'PreToolUse', permissionDecision: 'deny', permissionDecisionReason: 'Fixture read-only mode' } } + } + return c.full && c.mode === 'craft' ? { hookSpecificOutput: { hookEventName: 'PreToolUse', permissionDecision: 'allow' } } : {} + }] }] }, + canUseTool: async (name, input) => { + if (name === 'AskUserQuestion') { questions++; return { behavior: 'allow', updatedInput: { ...input, answers: { 'Which fixture?': 'Alpha' } } } } + approvals++; return { behavior: 'deny', message: 'Default fixture approval intentionally denied' } + } + } }) + let completed = false + for await (const message of instance) { + if (message.type === 'user' && Array.isArray(message.message?.content)) toolErrors += message.message.content.filter(x => x.type === 'tool_result' && x.is_error).length + if (message.type === 'result') { assert.equal(message.subtype, 'success'); completed = true; break } + } + instance.close(); instance = undefined + assert.ok(completed) + if (c.label === 'craft-full') { assert.equal(approvals, 0); assert.equal(questions, 1); assert.equal(toolErrors, 0); assert.equal(await readFile(join(workspace, 'existing.txt'), 'utf8'), 'after\n'); assert.equal(await readFile(join(workspace, 'created.txt'), 'utf8'), 'created\n'); assert.equal(await readFile(join(workspace, 'simple.txt'), 'utf8'), 'simple'); await verifyCompound() } + else { await assert.rejects(readFile(join(workspace, `${c.mode === 'craft' ? 'default' : c.mode}-denied.txt`)), { code: 'ENOENT' }); if (c.mode === 'craft') assert.ok(approvals > 0); else assert.ok(hookDenials > 0) } + results.push({ case: c.label, approvals, questions, hookDenials, toolErrors }) + } + console.log(JSON.stringify({ status: 'PASS', scope: kernelMode ? 'Actual ClaudeKernel and SDK; temporary preferences supplied, no Windows/UI coverage' : 'SDK protocol fixture, not actual application kernel or Windows', requests, cases: results })) +} catch (error) { + console.error(JSON.stringify({ status: 'BLOCKED_OR_FAILED', stage, code: error?.code ?? null, name: error?.name ?? 'Error', completedCases: results })) + process.exitCode = 1 +} finally { + clearTimeout(timer); abortController.abort(); instance?.close(); await kernel?.close() + server.closeAllConnections(); if (server.listening) await new Promise(ok => server.close(ok)) + await rm(root, { recursive: true, force: true }) +} diff --git a/src/main/agent/claude/claude-kernel.ts b/src/main/agent/claude/claude-kernel.ts index 8a01459..9d5f02d 100644 --- a/src/main/agent/claude/claude-kernel.ts +++ b/src/main/agent/claude/claude-kernel.ts @@ -1,7 +1,7 @@ import { app } from 'electron' import { existsSync, mkdirSync } from 'node:fs' import { join } from 'node:path' -import { randomUUID } from 'node:crypto' +import { createHash, randomUUID } from 'node:crypto' import { createSdkMcpServer, query, @@ -256,10 +256,10 @@ function modelEnvironment(model: ResolvedModel): NodeJS.ProcessEnv { return env } -function permissionModeFor(mode: PermissionMode, agentMode: AgentMode): 'default' | 'acceptEdits' | 'plan' { - if (agentMode === 'plan') return 'plan' - if (agentMode === 'ask') return 'default' - return mode === 'full-access' ? 'acceptEdits' : 'default' +function permissionModeFor(_mode: PermissionMode, agentMode: AgentMode): 'default' | 'plan' { + // Full access is explicit host preauthorization in PreToolUse, not acceptEdits + // (which still asks for commands), nor a process-wide bypass capability. + return agentMode === 'plan' ? 'plan' : 'default' } function thinkingOptions(level: ThinkingLevel | undefined): { @@ -328,6 +328,10 @@ export class ClaudeKernel { private active: ActiveTurn | null = null private starting = false private permissionUpdates: Promise = Promise.resolve() + private policyTransitions = 0 + private policyRevision = 0 + private policyError: Error | null = null + private sdkPermissionMode: ReturnType | null = null private draining: Promise | null = null private contextQuery: ReturnType | null = null private lastRequestId?: string @@ -358,6 +362,9 @@ export class ClaudeKernel { } async ensureStarted(): Promise { + await this.permissionUpdates + await this.draining + if (this.policyError) throw this.policyError if (this.instance) return if (this.closed) throw new Error('Claude 内核已释放') const executable = claudeExecutablePath() @@ -399,10 +406,22 @@ export class ClaudeKernel { if (hook.hook_event_name !== 'PreToolUse') return {} const input = hook.tool_input && typeof hook.tool_input === 'object' ? hook.tool_input as Record : {} // SDK allow rules can bypass canUseTool, but never this host mode boundary. - if (!this.active || this.active.request.signal.aborted || (this.agentMode !== 'craft' && !isModeAllowedCall(this.agentMode, hook.tool_name, input))) { + if (this.policyTransitions || this.policyError || !this.active || this.active.settled || this.active.request.signal.aborted || (this.agentMode !== 'craft' && !isModeAllowedCall(this.agentMode, hook.tool_name, input))) { + this.recordPermissionDecision(hook.tool_use_id, 'hook', 'mode-or-inactive-deny') return { hookSpecificOutput: { hookEventName: 'PreToolUse' as const, permissionDecision: 'deny' as const, - permissionDecisionReason: '当前回合或对话模式不允许此操作。' } } + permissionDecisionReason: '当前回合、权限切换或对话模式不允许此操作。' } } } + // Business questions are not tool permission approvals. Force the + // callback even when a session grant would otherwise auto-allow. + if (hook.tool_name === 'AskUserQuestion') { + this.recordPermissionDecision(hook.tool_use_id, 'hook', 'business-question') + return { hookSpecificOutput: { hookEventName: 'PreToolUse' as const, permissionDecision: 'ask' as const } } + } + if (hook.tool_name !== 'ExitPlanMode' && this.permissionMode === 'full-access' && this.agentMode === 'craft') { + this.recordPermissionDecision(hook.tool_use_id, 'hook', 'full-access-preauthorized') + return { hookSpecificOutput: { hookEventName: 'PreToolUse' as const, permissionDecision: 'allow' as const } } + } + this.recordPermissionDecision(hook.tool_use_id, 'hook', hook.tool_name === 'ExitPlanMode' ? 'plan-content' : 'sdk-policy') return {} }] }, { @@ -510,6 +529,7 @@ export class ClaudeKernel { this.input = input this.processAbort = processAbort this.instance = instance + this.sdkPermissionMode = permissionModeFor(this.permissionMode, this.agentMode) const consuming = this.consume(instance) this.consuming = consuming void consuming.finally(() => { @@ -543,6 +563,7 @@ export class ClaudeKernel { instance.close() this.instance = null this.contextQuery = null + this.sdkPermissionMode = null this.input = null this.processAbort = null } @@ -551,10 +572,16 @@ export class ClaudeKernel { private canUseTool: CanUseTool = async (toolName, input, options): Promise => { const active = await this.waitForSegment() - if (!active) return { behavior: 'deny', message: '当前没有正在运行的 YCode 回合。' } + const revision = this.policyRevision + const record = (branch: PermissionBranch): void => this.recordPermissionDecision(options.toolUseID, 'callback', branch, options) + if (this.policyTransitions || this.policyError || !active || active.settled) { + record('mode-or-inactive-deny') + return { behavior: 'deny', message: '当前没有可执行操作的 YCode 回合,或权限正在切换。' } + } if (options.signal.aborted || active.request.signal.aborted) return { behavior: 'deny', message: '操作已取消。' } try { if (toolName === 'ExitPlanMode') { + record('plan-content') if (!active.planEmitted) { active.planEmitted = true const plan = planFromExitInput(input, active.content) @@ -567,12 +594,15 @@ export class ClaudeKernel { return { behavior: 'deny', message: '计划已显示在 YCode 中,请切换到 craft 后开始实施。' } } if (this.agentMode === 'ask' && !isModeAllowedCall('ask', toolName, input)) { + record('mode-or-inactive-deny') return { behavior: 'deny', message: 'Ask 模式只允许读取资料、搜索和提问,不能修改文件或执行命令。' } } if (this.agentMode === 'plan' && !isModeAllowedCall('plan', toolName, input)) { + record('mode-or-inactive-deny') return { behavior: 'deny', message: 'Plan 模式只允许分析和制定计划,不能修改文件或执行命令。' } } if (toolName === 'AskUserQuestion') { + record('business-question') const questions = questionsFromInput(input) if (!questions.length) return { behavior: 'deny', message: '问题内容为空。' } active.request.emit({ runId: active.request.runId, type: 'progress', progress: { source: 'interaction', phase: 'waiting-answer', toolName } }) @@ -582,7 +612,7 @@ export class ClaudeKernel { questions, options.signal ) - if (this.active !== active || active.settled || options.signal.aborted || active.request.signal.aborted) return { behavior: 'deny', message: '操作已取消。' } + if (this.policyTransitions || this.policyRevision !== revision || this.active !== active || active.settled || options.signal.aborted || active.request.signal.aborted) return { behavior: 'deny', message: '操作已取消。' } active.request.emit({ runId: active.request.runId, type: 'progress', progress: { source: 'agent', phase: 'thinking' } }) if (response.kind !== 'question') return { behavior: 'deny', message: '问答响应无效。' } if (response.skipped) return { behavior: 'deny', message: '用户关闭了问题,未提供回答。' } @@ -593,9 +623,11 @@ export class ClaudeKernel { } return { behavior: 'allow', updatedInput: { ...input, answers } } } - if (this.permissionMode === 'full-access' && this.agentMode === 'craft' && !mustAskInFullAccess(options)) { + if (this.permissionMode === 'full-access' && this.agentMode === 'craft') { + record('full-access-preauthorized') return { behavior: 'allow', updatedInput: input } } + record(options.matchedAskRule ? 'rule-approval' : 'permission-approval') const grant = !options.suppressAlwaysAllowRule ? sessionGrantProposal(options.suggestions) : undefined const optionsForApproval = [ { optionId: 'allow-once', name: '允许一次', kind: 'allow_once' }, @@ -623,12 +655,12 @@ export class ClaudeKernel { if (decision.kind !== 'approval' || decision.optionId !== 'allow-always') return if (!grant) throw new Error('会话授权不可用。') const apply = async (): Promise => { - if (this.active !== active || active.settled || options.signal.aborted || active.request.signal.aborted) throw abortError() + if (this.policyTransitions || this.policyRevision !== revision || this.active !== active || active.settled || options.signal.aborted || active.request.signal.aborted) throw abortError() const instance = this.instance if (!instance) throw new Error('内核已断开,授权未应用。') const rules = [...new Set([...sessionGrantRules(this.options.sessionId, this.options.workspaceRoot), ...grant.rules])] await instance.applyFlagSettings({ permissions: { allow: rules } }) - if (this.active !== active || active.settled || options.signal.aborted || active.request.signal.aborted) { + if (this.policyTransitions || this.policyRevision !== revision || this.active !== active || active.settled || options.signal.aborted || active.request.signal.aborted) { instance.close() throw abortError() } @@ -639,7 +671,7 @@ export class ClaudeKernel { await applying } ) - if (this.active !== active || active.settled || options.signal.aborted || active.request.signal.aborted) return { behavior: 'deny', message: '操作已取消。' } + if (this.policyTransitions || this.policyRevision !== revision || this.active !== active || active.settled || options.signal.aborted || active.request.signal.aborted) return { behavior: 'deny', message: '操作已取消。' } if (response.kind !== 'approval' || response.optionId === 'reject-once') { active.request.emit({ runId: active.request.runId, type: 'progress', progress: { source: 'agent', phase: 'thinking' } }) return { behavior: 'deny', message: '用户拒绝了此操作。' } @@ -654,6 +686,7 @@ export class ClaudeKernel { : {}) } } catch (error) { + record('error-deny') return { behavior: 'deny', message: error instanceof Error ? error.message : String(error) } } } @@ -912,6 +945,8 @@ export class ClaudeKernel { try { this.capabilities.setRequestSelection({ explicitSkillIds: request.skillIds, selectedMcpServerIds: request.mcpServerIds }) await this.ensureStarted() + await this.permissionUpdates + if (this.policyError) throw this.policyError const input = this.input if (!input) throw new Error('Claude Code 输入流未就绪') if (request.signal.aborted) throw abortError() @@ -1019,10 +1054,67 @@ export class ClaudeKernel { } async setPolicy(permissionMode: PermissionMode, agentMode: AgentMode): Promise { - await this.draining - this.permissionMode = permissionMode - this.agentMode = agentMode - if (this.instance) await this.instance.setPermissionMode(permissionModeFor(permissionMode, agentMode)) + // Invalidate outstanding approvals immediately, including while queued behind + // an earlier SDK update. No tool may run against an intermediate policy. + this.policyTransitions++ + this.policyRevision++ + const apply = async (): Promise => { + await this.draining + if (this.closed) throw new Error('Claude 内核已释放') + const instance = this.instance + const sdkMode = permissionModeFor(permissionMode, agentMode) + try { + if (instance) await instance.setPermissionMode(sdkMode) + this.policyError = null + this.permissionMode = permissionMode + this.agentMode = agentMode + this.sdkPermissionMode = instance ? sdkMode : null + } catch (error) { + // A rejected control request leaves the CLI state uncertain. Never keep + // using that producer or silently grant the requested host policy. + this.policyError = new Error('权限切换失败,请重试。') + this.failActive(this.policyError) + this.input?.close() + this.processAbort?.abort() + instance?.close() + if (this.instance === instance) { + this.instance = null + this.input = null + this.processAbort = null + this.sdkPermissionMode = null + } + const draining = (this.consuming ?? Promise.resolve()).catch(() => undefined) + this.draining = draining + void draining.finally(() => { if (this.draining === draining) this.draining = null }) + throw error + } + } + const applying = this.permissionUpdates.then(apply).finally(() => { this.policyTransitions-- }) + this.permissionUpdates = applying.catch(() => undefined) + await applying + } + + private recordPermissionDecision(toolUseId: unknown, source: 'hook' | 'callback', branch: PermissionBranch, + options?: Parameters[2]): void { + const metadata = options as unknown as Record | undefined + // SDK 0.3.304 forwards these fields at runtime but omits them from CanUseTool. + // Never log arbitrary strings: unknown categories/codes remain explicitly + // unknown, and IDs are one-way correlations rather than raw tool/request data. + const category = metadata?.decisionReasonType + console.info('[claude-permission]', JSON.stringify({ + permissionMode: this.permissionMode, + agentMode: this.agentMode, + sdkMode: this.sdkPermissionMode ?? 'unknown', + sdkModeSource: 'host-query-or-acknowledged-control', + policyTransition: this.policyTransitions > 0, + source, + toolCall: typeof toolUseId === 'string' ? createHash('sha256').update(toolUseId).digest('hex').slice(0, 16) : 'unknown', + decisionCategory: typeof category === 'string' && ['subcommandResults', 'safetyCheck', 'rule', 'mode', 'other'].includes(category) ? category : 'unknown', + decisionCode: 'unknown', + decisionCodePresent: typeof metadata?.decisionReasonCode === 'string' && metadata.decisionReasonCode.length > 0, + matchedAskRule: options ? !!options.matchedAskRule : 'unknown', + branch + })) } /** Read only: never start a kernel or request per-category token-count calls. */ @@ -1086,22 +1178,8 @@ export class ClaudeKernel { } } -function mustAskInFullAccess(options: Parameters[2]): boolean { - const raw = options as unknown as Record - return raw.classifierApprovable === false || - raw.classifier_approvable === false || - raw.decisionReasonType === 'subcommandResults' || - raw.decision_reason_type === 'subcommandResults' || - (raw.decisionReasonType === 'safetyCheck' && raw.classifierApprovable !== true) || - (raw.decision_reason_type === 'safetyCheck' && raw.classifier_approvable !== true) || - raw.matchedAskRule != null || - raw.matchesAskRule != null || - raw.matched_ask_rule != null || - raw.requiresUserInteraction === true || - raw.requires_user_interaction === true || - (raw.decisionReasonCode != null && raw.decisionReasonCode !== '') || - (raw.decision_reason_code != null && raw.decision_reason_code !== '') -} +type PermissionBranch = 'mode-or-inactive-deny' | 'business-question' | 'plan-content' | + 'full-access-preauthorized' | 'sdk-policy' | 'rule-approval' | 'permission-approval' | 'error-deny' function isModeAllowedCall(mode: AgentMode, toolName: string, input: Record): boolean { if (mode === 'ask' && ['TodoWrite', 'EnterPlanMode'].includes(toolName)) return false diff --git a/src/renderer/src/components/ChatPane.tsx b/src/renderer/src/components/ChatPane.tsx index 59d1bf4..6c54301 100644 --- a/src/renderer/src/components/ChatPane.tsx +++ b/src/renderer/src/components/ChatPane.tsx @@ -412,8 +412,13 @@ export default function ChatPane({ rightVisible, onToggleRight, onOpenSettings, const availableReferenceFiles = referenceFiles.owner === referenceScope ? referenceFiles.items : [] const addMenuError = (reason: unknown): void => useChatStore.setState({ error: reason instanceof Error ? reason.message : String(reason) }) - const activeInteraction = messages.flatMap((message) => message.interactions ?? []).findLast((item) => - (item.kind === 'question' || item.kind === 'approval') && ['pending', 'responding', 'error'].includes(item.status)) + const activeInteraction = !hidden && !loading && !loadError && currentId ? messages.flatMap((message) => message.interactions ?? []).findLast((item) => + (item.kind === 'question' || item.kind === 'approval') && ['pending', 'responding', 'error'].includes(item.status)) : undefined + const respondToActive: typeof respond = (id, response) => { + const state = useChatStore.getState() + if (state.currentSessionId !== currentId || state.loading || state.loadError || hidden) return Promise.reject(new Error('审批所属会话已切换')) + return respond(id, response) + } const composerError = error ?? queueState.error ?? capabilityCatalog.error const composition = composeComposer({ interaction: activeInteraction ? { id: activeInteraction.id, kind: activeInteraction.kind, status: activeInteraction.status } : null, @@ -612,20 +617,20 @@ export default function ChatPane({ rightVisible, onToggleRight, onOpenSettings, } const floatingSurface = activeInteraction?.kind === 'question' - ? respond(activeInteraction.id, { kind: 'question', answer })} - onSkip={() => respond(activeInteraction.id, { kind: 'question', answer: { questions: activeInteraction.questions.map((item) => ({ id: item.id, question: item.question, answers: [] })) }, skipped: true })} /> + onSelect={(_text, answer) => respondToActive(activeInteraction.id, { kind: 'question', answer })} + onSkip={() => respondToActive(activeInteraction.id, { kind: 'question', answer: { questions: activeInteraction.questions.map((item) => ({ id: item.id, question: item.question, answers: [] })) }, skipped: true })} /> : activeInteraction?.kind === 'approval' - ? message.tools ?? []).find(tool => tool.id === activeInteraction.toolCallId)?.name ?? '工具操作'} title={activeInteraction.title} options={activeInteraction.options} status={activeInteraction.status} error={activeInteraction.error} labels={{ confirm: '允许一次', alwaysAllow: '本次会话内始终允许', reject: '拒绝', working: '提交中', approved: '已允许', rejected: '已拒绝', cancelled: '已取消', failed: '提交失败' }} - resolve={(id, decision) => respond(id, { kind: 'approval', optionId: decision.optionId })} + resolve={(id, decision) => respondToActive(id, { kind: 'approval', optionId: decision.optionId })} reject={(id) => { const option = activeInteraction.options.find((item) => item.kind.startsWith('reject')) - return option ? respond(id, { kind: 'approval', optionId: option.optionId }) : Promise.reject(new Error('审批选项不可用')) + return option ? respondToActive(id, { kind: 'approval', optionId: option.optionId }) : Promise.reject(new Error('审批选项不可用')) }} /> : composition.notice?.kind === 'error' && composerError ?
{composerError}
: undefined const queuePanel = composition.stack.queue && queueState.snapshot @@ -673,7 +678,9 @@ export default function ChatPane({ rightVisible, onToggleRight, onOpenSettings, }} userContent={entry.userContent} onUserContentChange={userContent => setDrafts(all => ({ ...all, [draftKey]: { ...(all[draftKey] ?? EMPTY_DRAFT), userContent } }))} - floatingSlot={floatingSurface} + approvalKey={activeInteraction ? `${currentId}:${activeInteraction.id}` : undefined} + approvalSlot={!composition.input.visible ? floatingSurface : undefined} + floatingSlot={composition.input.visible ? floatingSurface : undefined} queueSlot={queuePanel} onResourcesChange={(resources) => setDrafts((all) => ({ ...all, [draftKey]: { ...(all[draftKey] ?? EMPTY_DRAFT), resources } }))} quotes={entry.quotes ?? []} @@ -705,7 +712,11 @@ export default function ChatPane({ rightVisible, onToggleRight, onOpenSettings, try { window.localStorage.setItem(PERMISSION_MODE_KEY, mode) } catch { /* Storage is optional. */ } }} onError={(message) => useChatStore.setState({ error: message })} />} - onSend={submit} sending={sending} onStop={running ? cancel : undefined} + onSend={submit} sending={sending} onStop={running ? () => { + const state = useChatStore.getState() + if (state.currentSessionId !== currentId || state.running?.runId !== running.runId || hidden) return Promise.reject(new Error('运行所属会话已切换')) + return cancel() + } : undefined} modelSlot={ useChatStore.getState().selectThinkingLevel(level)} diff --git a/src/renderer/src/workbuddy/composer/ComposerControls.tsx b/src/renderer/src/workbuddy/composer/ComposerControls.tsx index 10a3009..7e56363 100644 --- a/src/renderer/src/workbuddy/composer/ComposerControls.tsx +++ b/src/renderer/src/workbuddy/composer/ComposerControls.tsx @@ -67,6 +67,9 @@ export interface ComposerControlsProps { permissionSlot?: ReactNode modeSlot?: ReactNode expertSlot?: ReactNode + /** An active tool approval replaces, rather than floats above, normal editing. */ + approvalSlot?: ReactNode + approvalKey?: string floatingSlot?: ReactNode queueSlot?: ReactNode capabilityItems?: readonly CapabilityPickerItem[] @@ -143,6 +146,27 @@ export function readReferenceUris(transfer: Pick): stri return transfer.getData('text/uri-list').split(/\r\n?|\n/).filter((value) => value.trim() && !value.startsWith('#')) } +/** Keyed by request identity so a late cancellation result cannot affect its successor. */ +function ApprovalComposerSurface({ children, onStop }: { children: ReactNode; onStop?: () => void | Promise }): React.JSX.Element { + const [error, setError] = useState(null) + const [busy, setBusy] = useState(false) + const busyRef = useRef(false) + const mounted = useRef(true) + useEffect(() => { mounted.current = true; return () => { mounted.current = false } }, []) + return
+ {children} + {onStop && } + {error &&
{error}
} +
+} + export function ComposerControls({ value, onChange, @@ -172,6 +196,8 @@ export function ComposerControls({ modeSlot, expertSlot, floatingSlot, + approvalSlot, + approvalKey, queueSlot, capabilityItems = [], selectedCapabilities = [], @@ -186,7 +212,7 @@ export function ComposerControls({ onOpenAddMenu, workspaceFooter, permissionPlacement = 'toolbar', - disabled = false, + disabled: disabledProp = false, sendDisabled = false, sending = false, onStop, @@ -194,6 +220,19 @@ export function ComposerControls({ inputAriaLabel, className }: ComposerControlsProps): React.JSX.Element { + const approvalTakeover = approvalSlot != null + const disabled = disabledProp || approvalTakeover + const takeoverRef = useRef(approvalTakeover) + takeoverRef.current = approvalTakeover + const inputStackRef = useRef(null) + useEffect(() => { + if (!approvalTakeover) return + restoreCapabilityFocus.current = false + setSlash(null) + setDragging(false) + const focused = document.activeElement + if (focused instanceof HTMLElement && inputStackRef.current?.contains(focused)) focused.blur() + }, [approvalTakeover]) const fileInputRef = useRef(null) const queueRef = useRef(null) const [queueHeight, setQueueHeight] = useState(0) @@ -216,23 +255,25 @@ export function ComposerControls({ const dismissedSlashValue = useRef(null) const restoreCapabilityFocus = useRef(false) useEffect(() => { - if (slash || !restoreCapabilityFocus.current) return + if (approvalTakeover || slash || !restoreCapabilityFocus.current) return // The picker input must be removed before restoring Slate's native selection. const timer = window.setTimeout(() => { + if (takeoverRef.current) return restoreCapabilityFocus.current = false editorRef.current?.focus() editorRef.current?.setSelectionRange(cursorRef.current, cursorRef.current) richEditorRef.current?.focus() }, 0) return () => window.clearTimeout(timer) - }, [slash, value, skills.length]) + }, [slash, value, skills.length, approvalTakeover]) useEffect(() => { - if (!canSelectCapabilities || composingRef.current) return + if (approvalTakeover || !canSelectCapabilities || composingRef.current) return if (dismissedSlashValue.current === value) return dismissedSlashValue.current = null setSlash(detectCapabilitySlashTrigger(value, cursorRef.current, capabilityItems)) - }, [value, capabilityItems, canSelectCapabilities, compositionVersion]) + }, [value, capabilityItems, canSelectCapabilities, compositionVersion, approvalTakeover]) const selectCapability = (item: CapabilityPickerItem): void => { + if (takeoverRef.current) return if (slash) { const text = value.slice(0, slash.start) + value.slice(slash.end) const content = richEditorRef.current?.deleteTextRange(slash.start, slash.end) ?? [{ type: 'text' as const, text }] @@ -245,7 +286,7 @@ export function ComposerControls({ onSelectCapability?.(item, { source: 'slash', text, userContent: content }) editorRef.current?.focus() richEditorRef.current?.focus() - requestAnimationFrame(() => { editorRef.current?.focus(); editorRef.current?.setSelectionRange(slash.start, slash.start); richEditorRef.current?.focus() }) + requestAnimationFrame(() => { if (takeoverRef.current) return; editorRef.current?.focus(); editorRef.current?.setSelectionRange(slash.start, slash.start); richEditorRef.current?.focus() }) } else onSelectCapability?.(item, { source: 'menu' }) } const mounted = useRef(true) @@ -350,7 +391,7 @@ export function ComposerControls({ } const pickAttachments = async (): Promise => { - if (pickInFlightRef.current) return + if (takeoverRef.current || pickInFlightRef.current) return if (!onAttachmentsChange) { setError('附件列表为只读') return @@ -427,7 +468,7 @@ export function ComposerControls({ const submit = async (): Promise => { const editorContent = richEditorRef.current?.content() ?? userContent const plainText = draftPlainText({ text: value, userContent: editorContent }) - if (sendInFlightRef.current || disabled || sendDisabled || sending || busyCountRef.current > 0 || pendingAttachments.length || + if (takeoverRef.current || sendInFlightRef.current || disabled || sendDisabled || sending || busyCountRef.current > 0 || pendingAttachments.length || (!plainText.trim() && attachments.length === 0 && resourcesRef.current.length === 0 && quotesRef.current.length === 0 && !skills.length && !selectedCapabilities.length && !hasDraftUserContent({ userContent: editorContent }))) return const tooLarge = attachments.filter((attachment) => attachment.size > MAX_ATTACHMENT_BYTES) @@ -457,6 +498,7 @@ export function ComposerControls({ } const receiveResources = async (transfer: DataTransfer): Promise => { + if (takeoverRef.current) return if (transfer.files.length) await stageFiles(transfer.files) for (const candidate of readReferenceUris(transfer)) { const path = parseResourcePath(candidate) @@ -473,7 +515,7 @@ export function ComposerControls({ } : undefined }) const previouslyRich = useRef(false) useEffect(() => { - if (!referenceViews.length && previouslyRich.current && editorRef.current) { + if (!takeoverRef.current && !referenceViews.length && previouslyRich.current && editorRef.current) { editorRef.current.focus() const offset = Math.min(value.length, cursorRef.current) editorRef.current.setSelectionRange(offset, offset) @@ -493,7 +535,7 @@ export function ComposerControls({
{ event.preventDefault(); setDragging(true) }} + onDragEnter={(event) => { event.preventDefault(); if (!approvalTakeover) setDragging(true) }} onDragOver={(event) => event.preventDefault()} onDragLeave={(event) => { if (!event.currentTarget.contains(event.relatedTarget as Node | null)) setDragging(false) @@ -514,8 +556,13 @@ export function ComposerControls({ } }} > - {floatingSlot &&
{floatingSlot}
} -
+ {approvalTakeover && {approvalSlot}} + {!approvalTakeover && floatingSlot &&
{floatingSlot}
} +
{homeFooter} @@ -612,7 +659,7 @@ export function ComposerControls({
- {error &&
{error}
} + {!approvalTakeover && error &&
{error}
} diff --git a/src/renderer/src/workbuddy/composer/composer.css b/src/renderer/src/workbuddy/composer/composer.css index 7edb46c..9d0ee88 100644 --- a/src/renderer/src/workbuddy/composer/composer.css +++ b/src/renderer/src/workbuddy/composer/composer.css @@ -546,3 +546,8 @@ right: auto; } } + +/* B03: approval owns the bottom surface; keep draft state mounted, never visible/focusable. */ +.wb-composer .conversation-input-area__input-stack[hidden] { display: none; } +.conversation-input-area__approval { width: 100%; min-width: 0; } +.wb-composer__approval-stop { display: block; margin: var(--yc-space-3) 0 0 auto; cursor: pointer; } diff --git a/src/renderer/src/workbuddy/composer/input-composition.ts b/src/renderer/src/workbuddy/composer/input-composition.ts index ffe14bf..64fe33b 100644 --- a/src/renderer/src/workbuddy/composer/input-composition.ts +++ b/src/renderer/src/workbuddy/composer/input-composition.ts @@ -66,6 +66,8 @@ export interface ComposerQueueComposition { export interface ComposerInputComposition { /** The editor stays mounted so draft, attachments and capability selections survive takeover. */ mounted: true + /** Only real tool approval replaces the normal input surface. */ + visible: boolean disabled: boolean blockedByInteraction: boolean destination: 'send' | 'queue' | 'blocked' @@ -190,6 +192,7 @@ export function composeComposer(input: ComposerCompositionInput = {}): ComposerC queue, input: { mounted: true, + visible: interaction?.kind !== 'approval', disabled, blockedByInteraction: interactionTakeover, destination: interactionTakeover ? 'blocked' : running ? 'queue' : 'send',