fix(B03): preauthorize full access and replace composer with active approval
This commit is contained in:
1 parent
0bc9faa129
commit
adbbb3bd37
15 files changed
+627
-63
No files matched your search
@@ -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 })
|
||||
}
|
||||
Reference in new issue
Block a user