From 8e141cfbef79c5c88d1b0176040b28ec59dd85be Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E7=A8=8B=E5=BA=8F=E5=91=98=E9=98=BF=E6=B1=9F=28Relakkes?= =?UTF-8?q?=29?= Date: Thu, 1 Oct 2026 18:10:57 +0800 Subject: [PATCH] fix(permissions): use live module binding for mode updates --- .../PermissionUpdate.importOrder.test.ts | 132 ++++++++++++++++++ src/utils/permissions/PermissionUpdate.ts | 11 +- 2 files changed, 136 insertions(+), 7 deletions(-) create mode 100644 src/utils/permissions/PermissionUpdate.importOrder.test.ts diff --git a/src/utils/permissions/PermissionUpdate.importOrder.test.ts b/src/utils/permissions/PermissionUpdate.importOrder.test.ts new file mode 100644 index 00000000..273178ea --- /dev/null +++ b/src/utils/permissions/PermissionUpdate.importOrder.test.ts @@ -0,0 +1,132 @@ +import { describe, expect, it } from 'bun:test' +import { feature } from 'bun:bundle' +import { mkdtempSync, rmSync } from 'node:fs' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { createSandboxedTestEnvironment } from '../../../scripts/pr/test-environment.js' + +const entryOrders = [ + ['PermissionUpdate', 'permissionSetup', 'Tool'], + ['permissionSetup', 'PermissionUpdate', 'Tool'], + ['Tool', 'PermissionUpdate', 'permissionSetup'], + ['permissions', 'permissionSetup', 'PermissionUpdate', 'Tool'], +] as const + +const moduleUrls = { + PermissionUpdate: new URL('./PermissionUpdate.ts', import.meta.url).href, + permissionSetup: new URL('./permissionSetup.ts', import.meta.url).href, + Tool: new URL('../../Tool.ts', import.meta.url).href, + permissions: new URL('./permissions.ts', import.meta.url).href, +} + +describe('permission mode updates across module entry orders', () => { + for (const order of entryOrders) { + it(`preserves transitions and the bypass gate after ${order.join(' → ')}`, async () => { + // A fresh process is essential: a previous test can finish loading the + // cycle and hide an incomplete export captured during module evaluation. + const sandboxHome = mkdtempSync(join(tmpdir(), 'permission-import-order-')) + const source = ` + import assert from 'node:assert/strict' + import { feature } from 'bun:bundle' + ${order.map(name => `const ${name} = await import(${JSON.stringify(moduleUrls[name])})`).join('\n')} + const state = await import(${JSON.stringify(new URL('../../bootstrap/state.ts', import.meta.url).href)}) + const { applyPermissionUpdate, applyPermissionUpdates } = PermissionUpdate + const { getEmptyToolPermissionContext } = Tool + const reset = () => { + state.setHasExitedPlanMode(false) + state.setNeedsPlanModeExitAttachment(false) + } + const planContext = available => ({ + ...getEmptyToolPermissionContext(), + mode: 'plan', + prePlanMode: 'default', + isBypassPermissionsModeAvailable: available, + }) + const setMode = mode => ({ type: 'setMode', mode, destination: 'session' }) + + reset() + const approved = applyPermissionUpdate(planContext(true), setMode('bypassPermissions')) + assert.equal(approved.mode, 'bypassPermissions') + assert.equal(approved.prePlanMode, undefined) + assert.equal(state.hasExitedPlanModeInSession(), true) + assert.equal(state.needsPlanModeExitAttachment(), true) + + reset() + const blockedContext = planContext(false) + assert.equal(applyPermissionUpdate(blockedContext, setMode('bypassPermissions')), blockedContext) + const blockedBatch = applyPermissionUpdates(blockedContext, [ + setMode('bypassPermissions'), + { type: 'addRules', rules: [{ toolName: 'Read' }], behavior: 'allow', destination: 'session' }, + ]) + assert.equal(blockedBatch.mode, 'plan') + assert.equal(blockedBatch.prePlanMode, 'default') + assert.deepEqual(blockedBatch.alwaysAllowRules.session, ['Read']) + assert.equal(state.hasExitedPlanModeInSession(), false) + assert.equal(state.needsPlanModeExitAttachment(), false) + + reset() + const batch = applyPermissionUpdates(planContext(false), [setMode('acceptEdits')]) + assert.equal(batch.mode, 'acceptEdits') + assert.equal(batch.prePlanMode, undefined) + assert.equal(state.hasExitedPlanModeInSession(), true) + assert.equal(state.needsPlanModeExitAttachment(), true) + + reset() + const disabled = permissionSetup.createDisabledBypassPermissionsContext(approved) + assert.equal(disabled.mode, 'default') + assert.equal(disabled.isBypassPermissionsModeAvailable, false) + assert.equal(state.hasExitedPlanModeInSession(), false) + assert.equal(state.needsPlanModeExitAttachment(), false) + + if (feature('TRANSCRIPT_CLASSIFIER')) { + const autoState = await import(${JSON.stringify(new URL('./autoModeState.ts', import.meta.url).href)}) + reset() + state.setNeedsAutoModeExitAttachment(false) + autoState.setAutoModeActive(true) + const restored = applyPermissionUpdate({ + ...planContext(true), + prePlanMode: 'auto', + strippedDangerousRules: { session: ['Bash(python:*)'] }, + }, setMode('default')) + assert.equal(restored.prePlanMode, undefined) + assert.equal(restored.strippedDangerousRules, undefined) + assert.deepEqual(restored.alwaysAllowRules.session, ['Bash(python:*)']) + assert.equal(autoState.isAutoModeActive(), false) + assert.equal(state.hasExitedPlanModeInSession(), true) + assert.equal(state.needsPlanModeExitAttachment(), true) + assert.equal(state.needsAutoModeExitAttachment(), true) + + autoState.setAutoModeCircuitBroken(true) + assert.throws( + () => applyPermissionUpdate(getEmptyToolPermissionContext(), setMode('auto')), + /Cannot transition to auto mode: gate is not enabled/, + ) + assert.equal(autoState.isAutoModeActive(), false) + } + ` + const featureFlags = feature('TRANSCRIPT_CLASSIFIER') + ? ['--feature=TRANSCRIPT_CLASSIFIER'] + : [] + const child = Bun.spawn([process.execPath, '--no-env-file', ...featureFlags, '-e', source], { + cwd: join(import.meta.dir, '../../..'), + env: createSandboxedTestEnvironment(sandboxHome), + stdout: 'pipe', + stderr: 'pipe', + }) + const timeout = setTimeout(() => child.kill(), 10_000) + try { + const [exitCode, stdout, stderr] = await Promise.all([ + child.exited, + new Response(child.stdout).text(), + new Response(child.stderr).text(), + ]) + expect(exitCode, stdout + stderr).toBe(0) + } finally { + clearTimeout(timeout) + child.kill() + await child.exited + rmSync(sandboxHome, { recursive: true, force: true }) + } + }, 15_000) + } +}) diff --git a/src/utils/permissions/PermissionUpdate.ts b/src/utils/permissions/PermissionUpdate.ts index 0ba39c84..ef8baf90 100644 --- a/src/utils/permissions/PermissionUpdate.ts +++ b/src/utils/permissions/PermissionUpdate.ts @@ -22,14 +22,11 @@ import { permissionRuleValueFromString, permissionRuleValueToString, } from './permissionRuleParser.js' +// permissionSetup imports applyPermissionUpdate back. ESM keeps this binding +// live; Bun's require interop can retain incomplete exports from the cycle. +import { transitionPermissionMode } from './permissionSetup.js' import { addPermissionRulesToSettings } from './permissionsLoader.js' -/* eslint-disable @typescript-eslint/no-require-imports */ -// permissionSetup imports this module back (`applyPermissionUpdate`), so a -// static import would close a cycle. Resolve it at call time instead. -const permissionSetupModule = require('./permissionSetup.js') as typeof import('./permissionSetup.js') -/* eslint-enable @typescript-eslint/no-require-imports */ - // Re-export for backwards compatibility export type { AdditionalWorkingDirectory, WorkingDirectorySource } @@ -88,7 +85,7 @@ export function applyPermissionUpdate( // update — the plan-approval dialog, a host, an edit suggestion — used to // skip it and leave a half-applied plan exit behind. return { - ...permissionSetupModule.transitionPermissionMode( + ...transitionPermissionMode( context.mode, update.mode, context,