fix(B03): preauthorize full access and replace composer with active approval
This commit is contained in:
1 parent
0bc9faa129
commit
adbbb3bd37
15 files changed
+624
-60
No files matched your search
@@ -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",
|
||||
|
||||
@@ -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 断言都不等于真实布局或像素验收。
|
||||
|
||||
@@ -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<void>(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 })
|
||||
}
|
||||
@@ -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;` }))
|
||||
} }]
|
||||
})
|
||||
@@ -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 };
|
||||
` }))
|
||||
} }]
|
||||
})
|
||||
@@ -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)
|
||||
|
||||
@@ -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')
|
||||
|
||||
@@ -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('<html><body><main id="root"></main></body></html>',{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(<ComposerControls key={session} sessionId={session} value={draft} onChange={()=>{changes++}} onSend={()=>{sends++}}
|
||||
attachments={attachments} resources={resources} quotes={quotes} onStop={()=>{stops++;if(stopFails)throw new Error('stop fixture failed');if(stopPending)return new Promise<void>((_yes,no)=>{rejectStop=no})}}
|
||||
queueSlot={composition.queue.visible?<div data-queue="true">two queued messages</div>:undefined}
|
||||
approvalKey={request} approvalSlot={!composition.input.visible?<PermissionConfirm pendingId={session} toolName="Bash" options={options} resolve={pending} reject={async()=>{declined++}} onDecisionComplete={decision=>{settled=decision}}/>:undefined}/>)
|
||||
})
|
||||
await renderComposer()
|
||||
const stack=document.querySelector<HTMLElement>('.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<HTMLElement>('[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<HTMLButtonElement>('.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<HTMLButtonElement>('.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<typeof attachments[number]>(yes=>{stagingResolve=yes})}
|
||||
const renderUpload=()=>act(async()=>root.render(<ComposerControls key="upload-session" sessionId="upload-session" value="upload draft" onChange={()=>{}} onSend={()=>{sends++}}
|
||||
attachments={uploaded} onAttachmentsChange={items=>{uploaded=items}} attachmentHost={uploadHost}
|
||||
approvalKey="upload-approval" approvalSlot={uploadActive?<PermissionConfirm pendingId="upload-approval" toolName="Bash" options={options} resolve={pending} reject={async()=>{}}/>: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)
|
||||
|
||||
@@ -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.' }))
|
||||
@@ -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 })
|
||||
}
|
||||
@@ -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<void> = Promise.resolve()
|
||||
private policyTransitions = 0
|
||||
private policyRevision = 0
|
||||
private policyError: Error | null = null
|
||||
private sdkPermissionMode: ReturnType<typeof permissionModeFor> | null = null
|
||||
private draining: Promise<void> | null = null
|
||||
private contextQuery: ReturnType<Query['getContextUsage']> | null = null
|
||||
private lastRequestId?: string
|
||||
@@ -358,6 +362,9 @@ export class ClaudeKernel {
|
||||
}
|
||||
|
||||
async ensureStarted(): Promise<void> {
|
||||
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<string, unknown> : {}
|
||||
// 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<PermissionResult | null> => {
|
||||
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<void> => {
|
||||
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<void> {
|
||||
// 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<void> => {
|
||||
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
|
||||
if (this.instance) await this.instance.setPermissionMode(permissionModeFor(permissionMode, 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<CanUseTool>[2]): void {
|
||||
const metadata = options as unknown as Record<string, unknown> | 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<CanUseTool>[2]): boolean {
|
||||
const raw = options as unknown as Record<string, unknown>
|
||||
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<string, unknown>): boolean {
|
||||
if (mode === 'ask' && ['TodoWrite', 'EnterPlanMode'].includes(toolName)) return false
|
||||
|
||||
@@ -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'
|
||||
? <QuestionFloating key={activeInteraction.id} config={{ questions: activeInteraction.questions, title: activeInteraction.title, toolCallId: activeInteraction.toolCallId }}
|
||||
? <QuestionFloating key={`${currentId}:${activeInteraction.id}`} config={{ questions: activeInteraction.questions, title: activeInteraction.title, toolCallId: activeInteraction.toolCallId }}
|
||||
responding={activeInteraction.status === 'responding'} error={activeInteraction.error}
|
||||
labels={{ title: '需要你确认', skip: '跳过', next: '下一题', send: '发送回答', customAnswer: '输入其他回答', skipAll: '跳过全部', previousQuestion: '上一题', nextQuestion: '下一题', dismiss: '收起', notAnswered: '未回答' }}
|
||||
onSelect={(_text, answer) => 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'
|
||||
? <PermissionConfirm key={activeInteraction.id} className="permission-floating"
|
||||
? <PermissionConfirm key={`${currentId}:${activeInteraction.id}`} className="permission-floating"
|
||||
pendingId={activeInteraction.id} details={activeInteraction.details} toolName={activeInteraction.details?.toolName ?? messages.flatMap(message => 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 ? <div className="wb-composer__error" role="alert">{composerError}</div> : 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={<span title={running || sending ? '下次发送生效' : undefined}><SourceModelSelector models={models} selectedId={effectiveModelId} label={modelLabel} darkMode={darkMode}
|
||||
thinkingLevel={effectiveThinkingLevel}
|
||||
onThinkingLevelChange={(level) => useChatStore.getState().selectThinkingLevel(level)}
|
||||
|
||||
@@ -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<DataTransfer, 'getData'>): 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<void> }): React.JSX.Element {
|
||||
const [error, setError] = useState<string | null>(null)
|
||||
const [busy, setBusy] = useState(false)
|
||||
const busyRef = useRef(false)
|
||||
const mounted = useRef(true)
|
||||
useEffect(() => { mounted.current = true; return () => { mounted.current = false } }, [])
|
||||
return <div className="conversation-input-area__approval" data-composer-approval="true">
|
||||
{children}
|
||||
{onStop && <button type="button" className="wb-composer__approval-stop" aria-label="停止当前运行" disabled={busy} onClick={async () => {
|
||||
if (busyRef.current) return
|
||||
busyRef.current = true
|
||||
setBusy(true)
|
||||
setError(null)
|
||||
try { await onStop() } catch (reason) { if (mounted.current) setError(errorText(reason)) }
|
||||
finally { if (mounted.current) { busyRef.current = false; setBusy(false) } }
|
||||
}}>停止当前运行</button>}
|
||||
{error && <div className="wb-composer__error" role="alert">{error}</div>}
|
||||
</div>
|
||||
}
|
||||
|
||||
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<HTMLDivElement>(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<HTMLInputElement>(null)
|
||||
const queueRef = useRef<HTMLDivElement>(null)
|
||||
const [queueHeight, setQueueHeight] = useState(0)
|
||||
@@ -216,23 +255,25 @@ export function ComposerControls({
|
||||
const dismissedSlashValue = useRef<string | null>(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<void> => {
|
||||
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<void> => {
|
||||
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<void> => {
|
||||
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({
|
||||
<div
|
||||
className={['wb-composer', 'conversation-input-area', className].filter(Boolean).join(' ')}
|
||||
data-dragging={dragging || undefined}
|
||||
onDragEnter={(event) => { 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 && <div className="conversation-input-area__custom-floating">{floatingSlot}</div>}
|
||||
<div className={'wb-composer__input-stack conversation-input-area__input-stack' + (permissionPlacement === 'footer' ? ' wb-home-route__input' : '')}>
|
||||
{approvalTakeover && <ApprovalComposerSurface key={approvalKey ?? sessionId} onStop={onStop}>{approvalSlot}</ApprovalComposerSurface>}
|
||||
{!approvalTakeover && floatingSlot && <div className="conversation-input-area__custom-floating">{floatingSlot}</div>}
|
||||
<div ref={inputStackRef} hidden={approvalTakeover} inert={approvalTakeover} aria-hidden={approvalTakeover || undefined}
|
||||
onFocusCapture={event => { if (approvalTakeover) { event.stopPropagation(); (event.target as HTMLElement).blur() } }}
|
||||
onKeyDownCapture={event => { if (approvalTakeover) { event.preventDefault(); event.stopPropagation() } }}
|
||||
onClickCapture={event => { if (approvalTakeover) { event.preventDefault(); event.stopPropagation() } }}
|
||||
className={'wb-composer__input-stack conversation-input-area__input-stack' + (permissionPlacement === 'footer' ? ' wb-home-route__input' : '')}>
|
||||
{!floatingSlot && queueSlot && <div style={{ height: Math.max(0, queueHeight - 36), position: 'relative' }}>
|
||||
<div ref={queueRef} className="conversation-input-area__prompt-queue-overlay" style={{ bottom: 0 }}>{queueSlot}</div>
|
||||
</div>}
|
||||
@@ -565,7 +612,7 @@ export function ComposerControls({
|
||||
}}
|
||||
/>
|
||||
|
||||
<div className="wb-composer__toolbar cr-input-toolbar">
|
||||
{!approvalTakeover && <div className="wb-composer__toolbar cr-input-toolbar">
|
||||
<div className="wb-composer__group cr-input-toolbar__left">
|
||||
<SourceComposerAddMenu scopeKey={addMenuScopeKey ?? sessionId ?? 'pending'} disabled={disabled || busy} onOpen={onOpenAddMenu}
|
||||
entries={[...(addMenuEntries ?? [{ id: 'add-files', children: [{ id: 'local-files', label: '本地文件' }] }, ...(onOpenExperts ? [{ id: 'experts' }] : [])]),
|
||||
@@ -604,7 +651,7 @@ export function ComposerControls({
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>}
|
||||
</div>
|
||||
</div>
|
||||
{homeFooter}
|
||||
@@ -612,7 +659,7 @@ export function ComposerControls({
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{error && <div className="wb-composer__error" role="alert">{error}</div>}
|
||||
{!approvalTakeover && error && <div className="wb-composer__error" role="alert">{error}</div>}
|
||||
|
||||
|
||||
</div>
|
||||
|
||||
@@ -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; }
|
||||
@@ -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',
|
||||
|
||||
Reference in new issue
Block a user