fix: resolve dependency audit findings precisely (#361)

* fix: harden ACP communication boundaries

Harden ACP communication boundaries

Remote ACP sessions now cannot widen permission mode through untrusted
metadata or client payloads. WebSocket ACP ingress measures payloads by bytes
before binary decode, and prompt queue handoff keeps exactly one prompt active
while queued prompts are drained FIFO.

Constraint: ACP remote clients must not be able to open bypassPermissions without local launch intent
Constraint: WebSocket payload limits must be byte-based and checked before binary decode
Rejected: Keep promptToQueryContent wrapper | no production consumers remained after prompt conversion single-sourcing
Confidence: high
Scope-risk: moderate
Directive: Do not re-enable remote bypassPermissions from _meta unless a local launch gate is verified in both acp-link and agent
Tested: targeted ACP/RCS/acp-link prompt queue, bridge, permission, payload, and prompt conversion tests; bun run typecheck; bun run build
Not-tested: Manual live ACP/RCS session against an external client

* fix: restore repository verification gates

Keep the full repository test, typecheck, build, and Biome lint gates usable
after the ACP fix pass. This commit is intentionally separate from the ACP
behavior change: it fixes Windows-safe Langfuse home redaction, removes stale
lint suppressions, resolves Biome warning/info diagnostics, and keeps env
expansion tests explicit without template-placeholder lint noise.

Constraint: The project completion contract requires full typecheck, lint, test, and build evidence
Rejected: Leave warning/info diagnostics as historical noise | they obscure future gate regressions and weaken flow-impact claims
Confidence: high
Scope-risk: narrow
Directive: Keep repository gate cleanup separate from feature fixes when it is not part of the same runtime path
Tested: bunx biome lint src/; bunx tsc --noEmit; bun test src/services/mcp/__tests__/envExpansion.test.ts src/utils/__tests__/sliceAnsi.test.ts src/utils/__tests__/stringUtils.test.ts; bun test; bun run build
Not-tested: Manual Langfuse export against a real external Langfuse service

* fix: harden ACP failure boundaries after review

Deep review found several paths that made ACP communication failures look normal: prompt errors could finish as end_turn, permission pipeline exceptions could fall through to client approval, tool rawInput was deep-copied with JSON, and acp-link accepted unbounded or unvalidated WebSocket payloads. This keeps the behavior fail-closed, validates WS payloads before dispatch, caps payload size before JSON parse, and preserves cancellation intent with a generation counter.

Constraint: User explicitly rejected pseudo-fixes, fallback behavior, and unbounded payload handling

Rejected: Keep JSON stringify/parse rawInput copy | duplicates large payloads and silently drops non-JSON inputs

Rejected: Delegate permission pipeline errors to client approval | allows a broken local permission check to be bypassed

Confidence: high

Scope-risk: moderate

Directive: Do not convert ACP errors into normal end_turn responses without a protocol-level reason and regression tests

Tested: bun test src/services/acp/__tests__/agent.test.ts src/services/acp/__tests__/bridge.test.ts src/services/acp/__tests__/permissions.test.ts

Tested: bun test packages/acp-link/src/__tests__/server.test.ts

Tested: bunx tsc --noEmit

Tested: bunx biome lint src/ packages/acp-link/src/

Tested: bun run test:all

Tested: bun run build

Not-tested: Manual end-to-end ACP client session over a real editor WebSocket

* fix: prevent ACP coverage runs from seeing partial mocks

GitHub Actions failed under bun test --coverage because permissions.test.ts replaced ../bridge.js with a partial mock that omitted forwardSessionUpdates. Coverage worker ordering on Linux let sibling tests observe that incomplete module.

This isolates ACP test mocks by snapshotting real exports, overriding only requested symbols, and restoring mocks in LIFO order. The shared helper also keeps the same behavior in agent.test.ts without duplicating mock infrastructure.

Constraint: bun:test mock.module is process-global inside a worker.

Rejected: Add fallback exports or production guards | the bridge export exists; the failure was test mock pollution.

Rejected: Keep per-file helper copies | duplication would let restore semantics drift again.

Confidence: high

Scope-risk: narrow

Directive: Prefer safeMockModule for partial mocks of real modules in ACP tests; plain mock.module is only appropriate for fully synthetic modules or isolated tests.

Tested: bun test src/services/acp/__tests__/agent.test.ts src/services/acp/__tests__/bridge.test.ts src/services/acp/__tests__/permissions.test.ts

Tested: bun test --coverage --coverage-reporter=lcov

Tested: bunx tsc --noEmit

Tested: bun run lint

Tested: git diff --check

Not-tested: Linux runner directly before push

* fix: normalize ACP bypass requests without warning noise

The previous CI repair removed the failing partial bridge mock, but it also added a shared safeMockModule helper and left the acp-link bypass normalization warning in the real new_session path.

This tightens the fix: acp-link now treats an unauthorized client bypass request as normal permission-mode normalization without emitting a warning, and the ACP permission test explicitly preserves the real bridge and permission exports instead of using a shared helper. The agent test keeps its local mock preservation but names it by behavior and restores mocks in LIFO order.

Constraint: CI output should not contain expected warning noise for covered policy branches.

Rejected: Silence the test only | the normal new_session path would still warn for an expected normalization branch.

Rejected: Keep the shared safeMockModule helper | the failing module was specific and should be fixed by preserving real exports at the mocking site.

Confidence: high

Scope-risk: narrow

Directive: Treat client-requested bypassPermissions as data to normalize unless the local default explicitly enables bypass.

Tested: bun test packages/acp-link/src/__tests__/server.test.ts

Tested: bun test src/services/acp/__tests__/agent.test.ts src/services/acp/__tests__/bridge.test.ts src/services/acp/__tests__/permissions.test.ts

Tested: bun test --coverage --coverage-reporter=lcov with UPPER_WARN_COUNT=0

Tested: bun run test:all

Tested: bun run lint

Tested: bunx tsc --noEmit

Tested: git diff --check

* fix: harden ACP bypass and CI warning gates

ACP clients must not be able to enter bypassPermissions unless the local ACP gate and process environment both allow it. The same gate now controls session creation, explicit mode changes, and the ExitPlanMode option list, while session setup restores process.cwd so coverage and later work do not inherit ACP session state.

Constraint: CI must stay warning-clean without hiding real ACP permission failures

Rejected: Logging rejected bypass requests on the normal new_session path | it preserves audit text but reintroduces warning noise the runtime should not emit

Rejected: Broad CI=true postinstall skip | it hides explicit Chrome MCP setup checks outside the install path

Confidence: high

Scope-risk: moderate

Directive: Keep bypassPermissions gated through one ACP availability decision before exposing it to clients

Tested: bun test src/services/acp/__tests__/permissions.test.ts src/services/acp/__tests__/agent.test.ts packages/acp-link/src/__tests__/server.test.ts

Tested: bun run test:all

Tested: bun run lint

Tested: bun run build:vite with zero warning matches

Tested: bun test --coverage --coverage-reporter lcov --coverage-dir coverage produced non-empty lcov with SF records and zero filtered warning matches

Not-tested: GitHub Actions result after this push

* fix: remove remaining CI warning noise

The CI log still had three non-failing warnings after the ACP hardening commit: git init default-branch advice from checkout, a Node 20 action-runtime deprecation, and one additional known Vite dynamic-import diagnostic that only surfaced on Linux. The workflow now provides explicit git config and opts actions into Node 24, while Vite keeps a narrow allowlist for acknowledged optimizer diagnostics.

Constraint: Do not use shell log filtering to hide warnings after they happen

Rejected: Grep warning lines out of CI output | it would make future diagnostics harder to find

Confidence: high

Scope-risk: narrow

Directive: Add new Vite warning allowlist entries only after checking that they are existing optimizer diagnostics, not new application defects

Tested: bunx tsc --noEmit --pretty false

Tested: bunx biome lint .github/workflows/ci.yml vite.config.ts

Tested: bun run build:vite with zero warning matches

Not-tested: GitHub Actions result after this push

* fix: reject unauthorized ACP bypass and harden CI actions

ACP clients now fail closed when permissionMode is malformed, unknown, or requests bypass without a local bypass opt-in. acp-link validates new_session input before forwarding to the agent and returns client error frames for expected unauthorized requests without logging create-failed noise. The direct AcpAgent path independently rejects invalid _meta.permissionMode and unauthorized bypass instead of falling back to settings.

CI workflows and generated GitHub App templates now use Node 24-compatible actions pinned to immutable commit SHAs, and acp-link startup output no longer prints the auth token.

Constraint: Must not hide warnings with test isolation or log filtering

Rejected: Silent fallback to local permission mode | accepts invalid client intent and masks boundary behavior

Rejected: Broad dependency churn from bun update | audit remained failing while package and lockfile churn expanded scope

Confidence: high

Scope-risk: moderate

Directive: Client-provided permissionMode must stay fail-closed before reaching AcpAgent; only local settings.defaultMode may fall back to default on invalid local config

Tested: bun test packages/acp-link/src/__tests__/server.test.ts src/services/acp/__tests__/agent.test.ts src/services/acp/__tests__/permissions.test.ts src/services/skillLearning/__tests__/skillLifecycle.test.ts src/utils/settings/__tests__/config.test.ts

Tested: bunx tsc -p packages/acp-link/tsconfig.json --noEmit --pretty false

Tested: bunx tsc --noEmit --pretty false

Tested: bun run lint

Tested: bun run test:all

Tested: local CI equivalent install/typecheck/coverage/build with warning_scan=0

Not-tested: Pre-existing bun audit vulnerabilities require a separate dependency-hardening PR

* fix: resolve dependency audit findings precisely

Use dependency-native upgrades and lockfile resolution to close the audit findings without suppressions. Keep the chrome MCP setup aligned with the new dependency graph and add real integration coverage so the override behavior stays verified.

Constraint: no audit ignores or warning suppression
Rejected: broad google-auth/protobuf overrides | replaced with upstream-compatible resolution
Confidence: high
Scope-risk: moderate
Directive: keep dependency fixes upstream-compatible; do not reintroduce blanket overrides unless the audit surface changes materially
Tested: bun audit; bun audit --json; bun install --frozen-lockfile with CLAUDE_CODE_SKIP_CHROME_MCP_SETUP=1; bunx tsc --noEmit --pretty false; bun run lint; targeted tests; bun run test:all; bun test --coverage --coverage-reporter lcov --coverage-dir coverage; bun run build:vite
Not-tested: unrelated pre-existing ACP/CORS/token fallback residual risks

* fix: keep ACP auth tokens out of URLs

Replace the ad hoc URL-token flow with crypto UUID-backed transport identifiers so the bearer token stays in structured request data instead of query strings. Keep the server, web client, and transport helpers aligned so the ACP/RCS handshake remains compatible after the API shape change.

Constraint: token must not be embedded in the URL
Rejected: token-as-uuid query fallback | leaked bearer tokens in URLs
Confidence: high
Scope-risk: moderate
Directive: preserve the structured auth path; do not reintroduce query-token fallback when adjusting ACP transport code
Tested: targeted ACP/RCS transport tests
Not-tested: unrelated pre-existing ACP/CORS/token fallback residual risks

* fix: normalize WebFetch request headers

Normalize WebFetch headers before dispatch so canonicalization preserves auth semantics and duplicate forms do not slip through. Keep the behavior locked with a focused header test instead of broadening the request pipeline.

Constraint: preserve header semantics without widening the fetch surface
Rejected: ad hoc caller-side normalization | too easy to bypass in future call sites
Confidence: high
Scope-risk: narrow
Directive: keep header normalization close to the WebFetch utility so future callers inherit the same behavior automatically
Tested: targeted WebFetch header tests
Not-tested: unrelated fetch backend behavior beyond header normalization

* fix: harden ACP remote auth surfaces

Tighten the remaining Claude security artifact items by requiring API keys on ACP global reads and relay upgrades, moving WebSocket tokens out of URLs, and replacing open web CORS with an explicit allowlist.

Constraint: Browser WebSocket clients cannot set arbitrary Authorization headers, so the token is carried in a selected subprotocol instead of a query string.
Rejected: Keep UUID auth for ACP channel groups | any caller can mint a UUID and read global ACP data.
Rejected: Preserve ?token= compatibility | secrets leak into logs, history, referrers, and intermediaries.
Confidence: high
Scope-risk: moderate
Directive: Do not reintroduce query-string bearer tokens; use Authorization or rcs.auth.<base64url-token>.
Tested: bunx tsc --noEmit --pretty false
Tested: bun run typecheck in packages/remote-control-server
Tested: bun run build in packages/acp-link
Tested: bun run lint
Tested: bun audit
Tested: focused RCS/acp-link/web tests, 160 pass
Tested: Edge headless browser WebSocket subprotocol handshake
Tested: bun run test:all, 3669 pass
Tested: bun run build:vite
Tested: bun run build
Not-tested: Manual end-to-end relay with a live external ACP agent

* fix: resolve CI dependency override lookup

The CI runner does not expose @grpc/proto-loader as a root-resolvable package, and the test was relying on local hoisting rather than the real dependency owner. Resolve proto-loader through @opentelemetry/exporter-trace-otlp-grpc and @grpc/grpc-js so the smoke test follows the package graph it is validating.

Constraint: Do not add a new root dependency for a transitive smoke test.

Rejected: Skip or weaken the test | the test protects the protobuf 7 override path and should keep exercising loadSync.

Rejected: Add @grpc/proto-loader directly to root package.json | that hides the owning-package resolution issue and broadens dependency surface.

Confidence: high

Scope-risk: narrow

Directive: Dependency override smoke tests should resolve from the package that actually owns the dependency, not from incidental root hoisting.

Tested: bun test tests/integration/dependency-overrides.test.ts; bunx tsc --noEmit --pretty false; bun run lint; bun audit; bun run test:all; git diff --check

---------

Co-authored-by: unraid <local@unraid.local>
This commit is contained in:
Dosion
2026-04-26 19:49:54 +08:00
committed by GitHub
parent fc438bd222
commit c2ac9a74c1
144 changed files with 4406 additions and 1644 deletions

View File

@@ -86,7 +86,7 @@ function substituteVariables(
// (replacer fn treats $ literally), and (2) double-substitution when user
// content happens to contain {{varName}} matching a later variable.
return template.replace(/\{\{(\w+)\}\}/g, (match, key: string) =>
Object.prototype.hasOwnProperty.call(variables, key)
Object.hasOwn(variables, key)
? variables[key]!
: match,
)

View File

@@ -206,7 +206,7 @@ function substituteVariables(
// (replacer fn treats $ literally), and (2) double-substitution when user
// content happens to contain {{varName}} matching a later variable.
return template.replace(/\{\{(\w+)\}\}/g, (match, key: string) =>
Object.prototype.hasOwnProperty.call(variables, key)
Object.hasOwn(variables, key)
? variables[key]!
: match,
)

View File

@@ -4,56 +4,74 @@ import {
test,
mock,
beforeEach,
afterEach,
afterAll,
spyOn,
} from 'bun:test'
// ── Mock infrastructure ──────────────────────────────────────────
// bun:test mock.module is process-global: it leaks to sibling test files
// in the same worker. safeMockModule snapshots real exports before mocking
// in the same worker. Preserve real exports before partial module mocking
// so afterAll can restore them, preventing cross-file pollution.
const _restores: (() => void)[] = []
const originalCwd = process.cwd()
const originalAcpPermissionMode = process.env.ACP_PERMISSION_MODE
const originalAcpAllowBypass = process.env.CLAUDE_CODE_ACP_ALLOW_BYPASS_PERMISSIONS
function safeMockModule(tsPath: string, overrides: Record<string, unknown>) {
function mockModulePreservingExports(
tsPath: string,
overrides: Record<string, unknown>,
) {
const jsPath = tsPath.replace(/\.ts$/, '.js')
const real = require(tsPath)
const snapshot = { ...real }
const snapshot = { ...(require(tsPath) as Record<string, unknown>) }
mock.module(jsPath, () => ({ ...snapshot, ...overrides }))
_restores.push(() => mock.module(jsPath, () => snapshot))
}
afterAll(() => {
for (let i = _restores.length - 1; i >= 0; i--) {
_restores[i]()
}
_restores.length = 0
restoreEnv('ACP_PERMISSION_MODE', originalAcpPermissionMode)
restoreEnv(
'CLAUDE_CODE_ACP_ALLOW_BYPASS_PERMISSIONS',
originalAcpAllowBypass,
)
})
// ── Module mocks (must precede any import of the module under test) ──
const mockSetModel = mock(() => {})
const mockSubmitMessage = mock(async function* (_input: string) {})
// Fully synthetic — no real module to snapshot, so plain mock.module suffices.
mock.module('../../../QueryEngine.js', () => ({
mockModulePreservingExports('../../../QueryEngine.ts', {
QueryEngine: class MockQueryEngine {
submitMessage = mock(async function* () {})
submitMessage = mockSubmitMessage
interrupt = mock(() => {})
resetAbortController = mock(() => {})
getAbortSignal = mock(() => new AbortController().signal)
setModel = mockSetModel
},
}))
})
safeMockModule('../../../tools.ts', {
mockModulePreservingExports('../../../tools.ts', {
getTools: mock(() => []),
})
safeMockModule('../../../Tool.ts', {
mockModulePreservingExports('../../../Tool.ts', {
toolMatchesName: mock(() => false),
findToolByName: mock(() => undefined),
filterToolProgressMessages: mock(() => []),
buildTool: mock((def: any) => def),
})
safeMockModule('../../../utils/config.ts', {
mockModulePreservingExports('../../../utils/config.ts', {
enableConfigs: mock(() => {}),
})
safeMockModule('../../../bootstrap/state.ts', {
mockModulePreservingExports('../../../bootstrap/state.ts', {
setOriginalCwd: mock(() => {}),
addSlowOperation: mock(() => {}),
})
@@ -75,24 +93,16 @@ const mockGetDefaultAppState = mock(() => ({
mainLoopModelForSession: null,
}))
safeMockModule('../../../state/AppStateStore.ts', {
mockModulePreservingExports('../../../state/AppStateStore.ts', {
getDefaultAppState: mockGetDefaultAppState,
})
// Single export, fully synthetic — no real module to snapshot.
mock.module('../permissions.js', () => ({
createAcpCanUseTool: mock(() =>
mock(async () => ({ behavior: 'allow', updatedInput: {} })),
),
}))
safeMockModule('../utils.ts', {
resolvePermissionMode: mock(() => 'default'),
mockModulePreservingExports('../utils.ts', {
computeSessionFingerprint: mock(() => '{}'),
sanitizeTitle: mock((s: string) => s),
})
safeMockModule('../bridge.ts', {
mockModulePreservingExports('../bridge.ts', {
forwardSessionUpdates: mock(async () => ({
stopReason: 'end_turn' as const,
})),
@@ -105,33 +115,38 @@ safeMockModule('../bridge.ts', {
})),
})
safeMockModule('../../../utils/listSessionsImpl.ts', {
mockModulePreservingExports('../../../utils/listSessionsImpl.ts', {
listSessionsImpl: mock(async () => []),
})
const mockGetMainLoopModel = mock(() => 'claude-sonnet-4-6')
safeMockModule('../../../utils/model/model.ts', {
mockModulePreservingExports('../../../utils/model/model.ts', {
getMainLoopModel: mockGetMainLoopModel,
})
safeMockModule('../../../utils/model/modelOptions.ts', {
mockModulePreservingExports('../../../utils/model/modelOptions.ts', {
getModelOptions: mock(() => []),
})
const mockApplySafeEnvVars = mock(() => {})
safeMockModule('../../../utils/managedEnv.ts', {
mockModulePreservingExports('../../../utils/managedEnv.ts', {
applySafeConfigEnvironmentVariables: mockApplySafeEnvVars,
})
const mockGetSettings = mock(() => ({}))
mockModulePreservingExports('../../../utils/settings/settings.ts', {
getSettings_DEPRECATED: mockGetSettings,
})
const mockDeserializeMessages = mock((msgs: unknown[]) => msgs)
safeMockModule('../../../utils/conversationRecovery.ts', {
mockModulePreservingExports('../../../utils/conversationRecovery.ts', {
deserializeMessages: mockDeserializeMessages,
})
const mockGetLastSessionLog = mock(async () => null)
const mockSessionIdExists = mock(() => false)
safeMockModule('../../../utils/sessionStorage.ts', {
mockModulePreservingExports('../../../utils/sessionStorage.ts', {
getLastSessionLog: mockGetLastSessionLog,
sessionIdExists: mockSessionIdExists,
})
@@ -161,7 +176,7 @@ const mockGetCommands = mock(async () => [
},
])
safeMockModule('../../../commands.ts', {
mockModulePreservingExports('../../../commands.ts', {
getCommands: mockGetCommands,
})
@@ -181,16 +196,48 @@ function makeConn() {
} as any
}
function removeBypassMode(session: any) {
session.modes = {
...session.modes,
availableModes: session.modes.availableModes.filter(
(mode: any) => mode.id !== 'bypassPermissions',
),
}
session.appState.toolPermissionContext = {
...session.appState.toolPermissionContext,
isBypassPermissionsModeAvailable: false,
}
}
function restoreEnv(name: string, value: string | undefined) {
if (value === undefined) {
delete process.env[name]
} else {
process.env[name] = value
}
}
// ── Tests ─────────────────────────────────────────────────────────
describe('AcpAgent', () => {
afterAll(() => {
for (const restore of _restores) restore()
})
beforeEach(() => {
delete process.env.ACP_PERMISSION_MODE
delete process.env.CLAUDE_CODE_ACP_ALLOW_BYPASS_PERMISSIONS
mockSetModel.mockClear()
mockSubmitMessage.mockReset()
mockSubmitMessage.mockImplementation(async function* (_input: string) {})
mockGetMainLoopModel.mockClear()
mockGetDefaultAppState.mockClear()
mockGetSettings.mockReset()
mockGetSettings.mockImplementation(() => ({}))
;(forwardSessionUpdates as ReturnType<typeof mock>).mockReset()
;(forwardSessionUpdates as ReturnType<typeof mock>).mockImplementation(
async () => ({ stopReason: 'end_turn' as const }),
)
})
afterEach(() => {
process.chdir(originalCwd)
})
describe('initialize', () => {
@@ -255,6 +302,13 @@ describe('AcpAgent', () => {
expect(r1.sessionId).not.toBe(r2.sessionId)
})
test('does not leave process cwd changed after session creation', async () => {
const cwdBeforeSession = process.cwd()
const agent = new AcpAgent(makeConn())
await agent.newSession({ cwd: '/tmp' } as any)
expect(process.cwd()).toBe(cwdBeforeSession)
})
test('calls getDefaultAppState to build session appState', async () => {
const agent = new AcpAgent(makeConn())
await agent.newSession({ cwd: '/tmp' } as any)
@@ -290,6 +344,99 @@ describe('AcpAgent', () => {
const res = await agent.newSession({ cwd: '/tmp' } as any)
expect(res.sessionId).toBeDefined()
})
test('uses settings permissions.defaultMode when _meta does not provide a mode', async () => {
mockGetSettings.mockImplementationOnce(() => ({
permissions: { defaultMode: 'acceptEdits' },
}))
const agent = new AcpAgent(makeConn())
const res = await agent.newSession({ cwd: '/tmp' } as any)
expect(res.modes?.currentModeId).toBe('acceptEdits')
})
test('uses _meta.permissionMode before settings permissions.defaultMode', async () => {
mockGetSettings.mockImplementationOnce(() => ({
permissions: { defaultMode: 'acceptEdits' },
}))
const agent = new AcpAgent(makeConn())
const res = await agent.newSession({
cwd: '/tmp',
_meta: { permissionMode: 'plan' },
} as any)
expect(res.modes?.currentModeId).toBe('plan')
})
test('rejects _meta.permissionMode bypass without a local ACP bypass gate', async () => {
mockGetSettings.mockImplementationOnce(() => ({
permissions: { defaultMode: 'acceptEdits' },
}))
const consoleErrorSpy = spyOn(console, 'error').mockImplementation(() => {})
const agent = new AcpAgent(makeConn())
try {
await expect(
agent.newSession({
cwd: '/tmp',
_meta: { permissionMode: 'bypassPermissions' },
} as any),
).rejects.toThrow('Mode not available: bypassPermissions')
expect(consoleErrorSpy).not.toHaveBeenCalled()
} finally {
consoleErrorSpy.mockRestore()
}
})
test('honors _meta.permissionMode bypass with a local ACP bypass gate', async () => {
process.env.CLAUDE_CODE_ACP_ALLOW_BYPASS_PERMISSIONS = '1'
const agent = new AcpAgent(makeConn())
const res = await agent.newSession({
cwd: '/tmp',
_meta: { permissionMode: 'bypassPermissions' },
} as any)
expect(res.modes?.currentModeId).toBe('bypassPermissions')
expect(res.modes?.availableModes.map((mode: any) => mode.id)).toContain(
'bypassPermissions',
)
})
test('falls back to default when settings permissions.defaultMode is invalid', async () => {
mockGetSettings.mockImplementationOnce(() => ({
permissions: { defaultMode: 'invalid-mode' },
}))
const consoleErrorSpy = spyOn(console, 'error').mockImplementation(() => {})
const agent = new AcpAgent(makeConn())
try {
const res = await agent.newSession({ cwd: '/tmp' } as any)
expect(res.modes?.currentModeId).toBe('default')
expect(consoleErrorSpy).toHaveBeenCalled()
} finally {
consoleErrorSpy.mockRestore()
}
})
test('rejects invalid _meta.permissionMode without falling back to settings', async () => {
mockGetSettings.mockImplementationOnce(() => ({
permissions: { defaultMode: 'acceptEdits' },
}))
const consoleErrorSpy = spyOn(console, 'error').mockImplementation(() => {})
const agent = new AcpAgent(makeConn())
try {
await expect(
agent.newSession({
cwd: '/tmp',
_meta: { permissionMode: 'invalid-mode' },
} as any),
).rejects.toThrow('Invalid _meta.permissionMode: invalid-mode')
expect(consoleErrorSpy).not.toHaveBeenCalled()
} finally {
consoleErrorSpy.mockRestore()
}
})
})
describe('prompt', () => {
@@ -375,7 +522,7 @@ describe('AcpAgent', () => {
expect(res2.stopReason).toBe('end_turn')
})
test('returns end_turn on unexpected error', async () => {
test('propagates unexpected prompt errors', async () => {
const agent = new AcpAgent(makeConn())
const { sessionId } = await agent.newSession({ cwd: '/tmp' } as any)
;(
@@ -383,16 +530,13 @@ describe('AcpAgent', () => {
).mockImplementationOnce(async () => {
throw new Error('unexpected')
})
const errorSpy = spyOn(console, 'error').mockImplementation(() => {})
try {
const res = await agent.prompt({
await expect(
agent.prompt({
sessionId,
prompt: [{ type: 'text', text: 'hello' }],
} as any)
expect(res.stopReason).toBe('end_turn')
} finally {
errorSpy.mockRestore()
}
} as any),
).rejects.toThrow('unexpected')
})
test('returns usage from forwardSessionUpdates', async () => {
@@ -676,15 +820,28 @@ describe('AcpAgent', () => {
).rejects.toThrow('Session not found')
})
test('availableModes includes bypassPermissions when not root', async () => {
test('availableModes excludes bypassPermissions without a local ACP bypass gate', async () => {
const agent = new AcpAgent(makeConn())
const { sessionId } = await agent.newSession({ cwd: '/tmp' } as any)
const session = agent.sessions.get(sessionId)
const modeIds = session?.modes.availableModes.map((m: any) => m.id)
expect(modeIds).toContain('bypassPermissions')
expect(modeIds).not.toContain('bypassPermissions')
})
test('can switch to bypassPermissions mode', async () => {
test('rejects bypassPermissions without a local ACP bypass gate', async () => {
const agent = new AcpAgent(makeConn())
const { sessionId } = await agent.newSession({ cwd: '/tmp' } as any)
await expect(
agent.setSessionMode({ sessionId, modeId: 'bypassPermissions' } as any),
).rejects.toThrow('Mode not available')
const session = agent.sessions.get(sessionId)
expect(session?.modes.currentModeId).toBe('default')
expect(session?.appState.toolPermissionContext.mode).toBe('default')
})
test('can switch to bypassPermissions mode with a local ACP bypass gate', async () => {
process.env.CLAUDE_CODE_ACP_ALLOW_BYPASS_PERMISSIONS = '1'
const agent = new AcpAgent(makeConn())
const { sessionId } = await agent.newSession({ cwd: '/tmp' } as any)
await agent.setSessionMode({
@@ -697,6 +854,21 @@ describe('AcpAgent', () => {
'bypassPermissions',
)
})
test('rejects bypassPermissions when the session does not expose it', async () => {
process.env.CLAUDE_CODE_ACP_ALLOW_BYPASS_PERMISSIONS = '1'
const agent = new AcpAgent(makeConn())
const { sessionId } = await agent.newSession({ cwd: '/tmp' } as any)
const session = agent.sessions.get(sessionId)
removeBypassMode(session)
await expect(
agent.setSessionMode({ sessionId, modeId: 'bypassPermissions' } as any),
).rejects.toThrow('Mode not available')
expect(session?.modes.currentModeId).toBe('default')
expect(session?.appState.toolPermissionContext.mode).toBe('default')
})
})
describe('setSessionConfigOption', () => {
@@ -723,6 +895,24 @@ describe('AcpAgent', () => {
} as any),
).rejects.toThrow('Invalid value')
})
test('rejects unavailable mode config values', async () => {
const agent = new AcpAgent(makeConn())
const { sessionId } = await agent.newSession({ cwd: '/tmp' } as any)
const session = agent.sessions.get(sessionId)
removeBypassMode(session)
await expect(
agent.setSessionConfigOption({
sessionId,
configId: 'mode',
value: 'bypassPermissions',
} as any),
).rejects.toThrow('Mode not available')
expect(session?.modes.currentModeId).toBe('default')
expect(session?.appState.toolPermissionContext.mode).toBe('default')
})
})
describe('prompt queueing', () => {
@@ -758,6 +948,94 @@ describe('AcpAgent', () => {
expect(r2.stopReason).toBe('end_turn')
})
test('drains 1000 queued prompts in FIFO order without sorting the pending map', async () => {
const agent = new AcpAgent(makeConn())
const { sessionId } = await agent.newSession({ cwd: '/tmp' } as any)
let resolveFirst!: () => void
;(
forwardSessionUpdates as ReturnType<typeof mock>
).mockImplementationOnce(
() =>
new Promise<{ stopReason: string }>(resolve => {
resolveFirst = () => resolve({ stopReason: 'end_turn' })
}),
)
const first = agent.prompt({
sessionId,
prompt: [{ type: 'text', text: 'first' }],
} as any)
const queued = Array.from({ length: 1000 }, (_, index) =>
agent.prompt({
sessionId,
prompt: [{ type: 'text', text: `queued-${index}` }],
} as any),
)
resolveFirst()
const results = await Promise.all([first, ...queued])
expect(results.every(result => result.stopReason === 'end_turn')).toBe(true)
expect(mockSubmitMessage.mock.calls.map(call => call[0])).toEqual([
'first',
...Array.from({ length: 1000 }, (_, index) => `queued-${index}`),
])
})
test('keeps promptRunning true while handing off to the next queued prompt', async () => {
const agent = new AcpAgent(makeConn())
const { sessionId } = await agent.newSession({ cwd: '/tmp' } as any)
let resolveFirst!: () => void
let resolveSecond!: () => void
;(forwardSessionUpdates as ReturnType<typeof mock>).mockImplementationOnce(
() =>
new Promise<{ stopReason: string }>(resolve => {
resolveFirst = () => resolve({ stopReason: 'end_turn' })
}),
)
;(forwardSessionUpdates as ReturnType<typeof mock>).mockImplementationOnce(
() =>
new Promise<{ stopReason: string }>(resolve => {
resolveSecond = () => resolve({ stopReason: 'end_turn' })
}),
)
const p1 = agent.prompt({
sessionId,
prompt: [{ type: 'text', text: 'first' }],
} as any)
const p2 = agent.prompt({
sessionId,
prompt: [{ type: 'text', text: 'second' }],
} as any)
const p3 = p1.then(() =>
agent.prompt({
sessionId,
prompt: [{ type: 'text', text: 'third' }],
} as any),
)
resolveFirst()
await p1
const session = agent.sessions.get(sessionId)
expect(session?.promptRunning).toBe(true)
expect(mockSubmitMessage.mock.calls.map(call => call[0])).toEqual([
'first',
'second',
])
resolveSecond()
await Promise.all([p2, p3])
expect(mockSubmitMessage.mock.calls.map(call => call[0])).toEqual([
'first',
'second',
'third',
])
})
test('queued prompts return cancelled when session is cancelled', async () => {
const agent = new AcpAgent(makeConn())
const { sessionId } = await agent.newSession({ cwd: '/tmp' } as any)
@@ -787,6 +1065,46 @@ describe('AcpAgent', () => {
expect(r1.stopReason).toBe('cancelled')
expect(r2.stopReason).toBe('cancelled')
})
test('queued prompt does not clear active prompt cancellation', async () => {
const agent = new AcpAgent(makeConn())
const { sessionId } = await agent.newSession({ cwd: '/tmp' } as any)
let resolveFirst!: () => void
;(
forwardSessionUpdates as ReturnType<typeof mock>
).mockImplementationOnce(
() =>
new Promise<{ stopReason: string }>(resolve => {
resolveFirst = () => resolve({ stopReason: 'end_turn' })
}),
)
;(forwardSessionUpdates as ReturnType<typeof mock>).mockResolvedValueOnce(
{ stopReason: 'end_turn' },
)
const p1 = agent.prompt({
sessionId,
prompt: [{ type: 'text', text: 'first' }],
} as any)
await agent.cancel({ sessionId } as any)
const p2 = agent.prompt({
sessionId,
prompt: [{ type: 'text', text: 'second' }],
} as any)
resolveFirst()
const [r1, r2] = await Promise.all([p1, p2])
expect(r1.stopReason).toBe('cancelled')
expect(r2.stopReason).toBe('end_turn')
expect(mockSubmitMessage.mock.calls.map(call => call[0])).toEqual([
'first',
'second',
])
})
})
describe('commands', () => {

View File

@@ -5,6 +5,7 @@ import {
toolUpdateFromEditToolResponse,
forwardSessionUpdates,
} from '../bridge.js'
import { promptToQueryInput } from '../promptConversion.js'
import { markdownEscape, toDisplayPath } from '../utils.js'
import type { AgentSideConnection, ToolKind } from '@agentclientprotocol/sdk'
import type { SDKMessage } from '../../../entrypoints/sdk/coreTypes.js'
@@ -336,6 +337,20 @@ describe('toolInfoFromToolUse', () => {
})
})
describe('promptToQueryInput', () => {
test('uses shared prompt conversion for resource links', () => {
expect(
promptToQueryInput([
{
type: 'resource_link',
name: 'Spec',
uri: 'file:///tmp/spec.md',
} as any,
]),
).toBe('Resource link: name=Spec, uri=file:///tmp/spec.md')
})
})
// ── toolUpdateFromToolResult ───────────────────────────────────────
describe('toolUpdateFromToolResult', () => {
@@ -709,6 +724,87 @@ describe('forwardSessionUpdates', () => {
expect(result.stopReason).toBe('cancelled')
})
test('cleans abort listeners when sdkMessages.next wins repeatedly', async () => {
const ac = new AbortController()
let abortListeners = 0
const add = ac.signal.addEventListener.bind(ac.signal)
const remove = ac.signal.removeEventListener.bind(ac.signal)
const addEventListener: AbortSignal['addEventListener'] = (
type: keyof AbortSignalEventMap,
listener: EventListenerOrEventListenerObject,
options?: boolean | AddEventListenerOptions,
) => {
if (type === 'abort') abortListeners++
return add(type, listener, options)
}
const removeEventListener: AbortSignal['removeEventListener'] = (
type: keyof AbortSignalEventMap,
listener: EventListenerOrEventListenerObject,
options?: boolean | EventListenerOptions,
) => {
if (type === 'abort') abortListeners--
return remove(type, listener, options)
}
ac.signal.addEventListener = addEventListener
ac.signal.removeEventListener = removeEventListener
const msgs = Array.from({ length: 10_000 }, () => ({
type: 'system',
subtype: 'api_retry',
}) as unknown as SDKMessage)
const result = await forwardSessionUpdates(
's1',
makeStream(msgs),
makeConn(),
ac.signal,
{},
)
expect(result.stopReason).toBe('end_turn')
expect(abortListeners).toBe(0)
})
test('cleans abort listeners when abort wins the race', async () => {
const ac = new AbortController()
let abortListeners = 0
const add = ac.signal.addEventListener.bind(ac.signal)
const remove = ac.signal.removeEventListener.bind(ac.signal)
ac.signal.addEventListener = (
type: keyof AbortSignalEventMap,
listener: EventListenerOrEventListenerObject,
options?: boolean | AddEventListenerOptions,
) => {
if (type === 'abort') abortListeners++
return add(type, listener, options)
}
ac.signal.removeEventListener = (
type: keyof AbortSignalEventMap,
listener: EventListenerOrEventListenerObject,
options?: boolean | EventListenerOptions,
) => {
if (type === 'abort') abortListeners--
return remove(type, listener, options)
}
async function* never(): AsyncGenerator<SDKMessage, void, unknown> {
await new Promise(() => {})
}
const resultPromise = forwardSessionUpdates(
's1',
never(),
makeConn(),
ac.signal,
{},
)
ac.abort()
const result = await resultPromise
expect(result.stopReason).toBe('cancelled')
expect(abortListeners).toBe(0)
})
test('forwards assistant text message as agent_message_chunk', async () => {
const conn = makeConn()
const msgs: SDKMessage[] = [
@@ -765,6 +861,7 @@ describe('forwardSessionUpdates', () => {
test('forwards tool_use block as tool_call', async () => {
const conn = makeConn()
const input = { command: 'ls' }
const msgs: SDKMessage[] = [
{
type: 'assistant',
@@ -774,7 +871,7 @@ describe('forwardSessionUpdates', () => {
type: 'tool_use',
id: 'tu_1',
name: 'Bash',
input: { command: 'ls' },
input,
},
],
role: 'assistant',
@@ -794,6 +891,8 @@ describe('forwardSessionUpdates', () => {
expect(update.toolCallId).toBe('tu_1')
expect(update.kind).toBe('execute' as ToolKind)
expect(update.status).toBe('pending')
expect(update.rawInput).toEqual(input)
expect(update.rawInput).not.toBe(input)
})
test('sends usage_update on result message with correct tokens', async () => {

View File

@@ -1,144 +1,306 @@
import { describe, expect, test, mock } from 'bun:test'
import { afterAll, beforeEach, describe, expect, mock, spyOn, test } from 'bun:test'
import type { AgentSideConnection } from '@agentclientprotocol/sdk'
import type { Tool as ToolType } from '../../../Tool.js'
import type { Tool as ToolType, ToolUseContext } from '../../../Tool.js'
import type { AssistantMessage } from '../../../types/message.js'
// ── Inline re-implementation of createAcpCanUseTool for isolated testing ──
// We cannot import the real permissions.js because agent.test.ts mocks it globally.
// Instead we re-implement the core logic here, using our own mocked bridge.js.
const askDecision = {
behavior: 'ask',
message: 'approval required',
decisionReason: { type: 'mode', mode: 'default' },
} as const
function createAcpCanUseTool(
conn: AgentSideConnection,
sessionId: string,
getCurrentMode: () => string,
): any {
return async (
tool: { name: string },
input: Record<string, unknown>,
_context: any,
_assistantMessage: any,
toolUseID: string,
): Promise<{ behavior: string; message?: string; updatedInput?: Record<string, unknown> }> => {
if (getCurrentMode() === 'bypassPermissions') {
return { behavior: 'allow', updatedInput: input }
}
const hasPermissionsMock = mock(async (): Promise<unknown> => askDecision)
const toolInfoMock = mock(() => ({
title: 'Bash',
kind: 'execute',
content: [],
locations: [],
}))
const TOOL_KIND_MAP: Record<string, string> = {
Read: 'read', Edit: 'edit', Write: 'edit',
Bash: 'execute', Glob: 'search', Grep: 'search',
WebFetch: 'fetch', WebSearch: 'fetch',
}
const toolCall = {
toolCallId: toolUseID,
title: tool.name,
kind: TOOL_KIND_MAP[tool.name] ?? 'other',
status: 'pending',
rawInput: input,
}
const options = [
{ kind: 'allow_always', name: 'Always Allow', optionId: 'allow_always' },
{ kind: 'allow_once', name: 'Allow', optionId: 'allow' },
{ kind: 'reject_once', name: 'Reject', optionId: 'reject' },
]
try {
const response = await (conn as any).requestPermission({ sessionId, toolCall, options })
if (response.outcome.outcome === 'cancelled') {
return { behavior: 'deny', message: 'Permission request cancelled by client' }
}
if (response.outcome.outcome === 'selected' && response.outcome.optionId !== undefined) {
const optionId = response.outcome.optionId
if (optionId === 'allow' || optionId === 'allow_always') {
return { behavior: 'allow', updatedInput: input }
}
}
return { behavior: 'deny', message: 'Permission denied by client' }
} catch {
return { behavior: 'deny', message: 'Permission request failed' }
}
}
const permissionsModuleSnapshot = {
...(require('../../../utils/permissions/permissions.ts') as Record<
string,
unknown
>),
}
const bridgeModuleSnapshot = {
...(require('../bridge.ts') as Record<string, unknown>),
}
function makeConn(permissionResponse: Record<string, unknown>) {
afterAll(() => {
mock.module('../bridge.js', () => bridgeModuleSnapshot)
mock.module('../../../utils/permissions/permissions.js', () => permissionsModuleSnapshot)
})
mock.module('../../../utils/permissions/permissions.js', () => ({
...permissionsModuleSnapshot,
hasPermissionsToUseTool: hasPermissionsMock,
}))
mock.module('../bridge.js', () => ({
...bridgeModuleSnapshot,
toolInfoFromToolUse: toolInfoMock,
}))
const { createAcpCanUseTool } = await import('../permissions.js')
type PermissionResponse =
| { outcome: { outcome: 'cancelled' } }
| { outcome: { outcome: 'selected'; optionId: string } }
function makeConn(
permissionResponse: PermissionResponse = {
outcome: { outcome: 'selected', optionId: 'allow' },
},
): AgentSideConnection {
return {
requestPermission: mock(async () => permissionResponse),
sessionUpdate: mock(async () => {}),
} as unknown as AgentSideConnection
}
function makeTool(name: string) {
function makeTool(name: string): ToolType {
return { name } as unknown as ToolType
}
const dummyContext = {} as Record<string, unknown>
const dummyMsg = {} as Record<string, unknown>
const dummyContext = {} as unknown as ToolUseContext
const dummyMsg = {} as unknown as AssistantMessage
describe('createAcpCanUseTool', () => {
test('returns allow when client selects allow option', async () => {
const conn = makeConn({ outcome: { outcome: 'selected', optionId: 'allow' } })
const canUseTool = createAcpCanUseTool(conn, 'sess-1', () => 'default')
const result = await canUseTool(makeTool('Bash'), { command: 'ls' }, dummyContext as any, dummyMsg as any, 'tu_1')
expect(result.behavior).toBe('allow')
beforeEach(() => {
hasPermissionsMock.mockReset()
hasPermissionsMock.mockResolvedValue(askDecision)
toolInfoMock.mockClear()
})
test('returns deny when client selects reject option', async () => {
const conn = makeConn({ outcome: { outcome: 'selected', optionId: 'reject' } })
test('returns pipeline allow without client delegation', async () => {
const conn = makeConn()
const input = { command: 'ls' }
hasPermissionsMock.mockResolvedValueOnce({
behavior: 'allow',
updatedInput: input,
})
const canUseTool = createAcpCanUseTool(conn, 'sess-1', () => 'default')
const result = await canUseTool(makeTool('Bash'), {}, dummyContext as any, dummyMsg as any, 'tu_2')
const result = await canUseTool(
makeTool('Bash'),
input,
dummyContext,
dummyMsg,
'tu_1',
)
expect(result).toEqual({ behavior: 'allow', updatedInput: input })
expect(
(conn.requestPermission as ReturnType<typeof mock>).mock.calls,
).toHaveLength(0)
})
test('returns pipeline deny without client delegation', async () => {
const conn = makeConn()
hasPermissionsMock.mockResolvedValueOnce({
behavior: 'deny',
message: 'blocked by policy',
decisionReason: { type: 'other', reason: 'blocked by policy' },
})
const canUseTool = createAcpCanUseTool(conn, 'sess-1', () => 'default')
const result = await canUseTool(
makeTool('Bash'),
{ command: 'rm -rf /' },
dummyContext,
dummyMsg,
'tu_2',
)
expect(result.behavior).toBe('deny')
expect(
(conn.requestPermission as ReturnType<typeof mock>).mock.calls,
).toHaveLength(0)
})
test('returns deny when client cancels', async () => {
const conn = makeConn({ outcome: { outcome: 'cancelled' } })
test('denies when the permission pipeline throws', async () => {
const conn = makeConn()
hasPermissionsMock.mockRejectedValueOnce(new Error('rule loader failed'))
const errorSpy = spyOn(console, 'error').mockImplementation(() => {})
try {
const canUseTool = createAcpCanUseTool(conn, 'sess-1', () => 'default')
const result = await canUseTool(
makeTool('Edit'),
{ file_path: '/tmp/x' },
dummyContext,
dummyMsg,
'tu_3',
)
expect(result).toMatchObject({
behavior: 'deny',
decisionReason: { type: 'other', reason: 'Permission pipeline failed' },
toolUseID: 'tu_3',
})
if (result.behavior !== 'deny') {
throw new Error('expected deny result')
}
expect(result.message).toBe('Permission pipeline failed')
expect(
(conn.requestPermission as ReturnType<typeof mock>).mock.calls,
).toHaveLength(0)
} finally {
errorSpy.mockRestore()
}
})
test('delegates ask decisions to the ACP client', async () => {
const conn = makeConn({
outcome: { outcome: 'selected', optionId: 'allow' },
})
const input = { command: 'ls' }
const canUseTool = createAcpCanUseTool(conn, 'sess-1', () => 'default')
const result = await canUseTool(makeTool('Read'), { file_path: '/tmp/x' }, dummyContext as any, dummyMsg as any, 'tu_3')
expect(result.behavior).toBe('deny')
const result = await canUseTool(
makeTool('Bash'),
input,
dummyContext,
dummyMsg,
'tu_4',
)
expect(result).toEqual({ behavior: 'allow', updatedInput: input })
const callArgs = (conn.requestPermission as ReturnType<typeof mock>).mock
.calls[0][0] as Record<string, unknown>
expect(callArgs.sessionId).toBe('sess-1')
expect((callArgs.toolCall as Record<string, unknown>).toolCallId).toBe(
'tu_4',
)
})
test('returns deny when requestPermission throws', async () => {
test('returns deny when the client rejects or cancels', async () => {
const rejectConn = makeConn({
outcome: { outcome: 'selected', optionId: 'reject' },
})
const cancelConn = makeConn({ outcome: { outcome: 'cancelled' } })
const rejectResult = await createAcpCanUseTool(
rejectConn,
'sess-1',
() => 'default',
)(makeTool('Bash'), {}, dummyContext, dummyMsg, 'tu_5')
const cancelResult = await createAcpCanUseTool(
cancelConn,
'sess-1',
() => 'default',
)(makeTool('Read'), {}, dummyContext, dummyMsg, 'tu_6')
expect(rejectResult.behavior).toBe('deny')
expect(cancelResult.behavior).toBe('deny')
})
test('returns deny when client permission request fails', async () => {
const conn = {
requestPermission: mock(async () => { throw new Error('connection lost') }),
requestPermission: mock(async () => {
throw new Error('connection lost')
}),
sessionUpdate: mock(async () => {}),
} as unknown as AgentSideConnection
const canUseTool = createAcpCanUseTool(conn, 'sess-1', () => 'default')
const result = await canUseTool(makeTool('Edit'), {}, dummyContext as any, dummyMsg as any, 'tu_4')
expect(result.behavior).toBe('deny')
const errorSpy = spyOn(console, 'error').mockImplementation(() => {})
try {
const result = await createAcpCanUseTool(conn, 'sess-1', () => 'default')(
makeTool('Write'),
{},
dummyContext,
dummyMsg,
'tu_7',
)
expect(result.behavior).toBe('deny')
if (result.behavior !== 'deny') {
throw new Error('expected deny result')
}
expect(result.message).toContain('Permission request failed')
} finally {
errorSpy.mockRestore()
}
})
test('passes correct sessionId and toolCallId to requestPermission', async () => {
const conn = makeConn({ outcome: { outcome: 'selected', optionId: 'allow' } })
const canUseTool = createAcpCanUseTool(conn, 'my-session', () => 'default')
await canUseTool(makeTool('Glob'), { pattern: '**/*.ts' }, dummyContext as any, dummyMsg as any, 'tu_99')
const rpMock = conn.requestPermission as ReturnType<typeof mock>
expect(rpMock.mock.calls.length).toBeGreaterThan(0)
const callArgs = rpMock.mock.calls[0][0] as Record<string, unknown>
expect(callArgs.sessionId).toBe('my-session')
expect((callArgs.toolCall as Record<string, unknown>).toolCallId).toBe('tu_99')
})
test('returns allow in bypassPermissions mode without calling requestPermission', async () => {
const conn = makeConn({ outcome: { outcome: 'selected', optionId: 'allow' } })
const canUseTool = createAcpCanUseTool(conn, 'sess-bypass', () => 'bypassPermissions')
const result = await canUseTool(makeTool('Bash'), { command: 'rm -rf /' }, dummyContext as any, dummyMsg as any, 'tu_bp')
expect(result.behavior).toBe('allow')
const rpMock = conn.requestPermission as ReturnType<typeof mock>
expect(rpMock.mock.calls).toHaveLength(0)
})
test('options include allow_always, allow_once and reject_once', async () => {
test('options include allow always, allow once, and reject once', async () => {
const conn = makeConn({ outcome: { outcome: 'cancelled' } })
const canUseTool = createAcpCanUseTool(conn, 'sess-3', () => 'default')
await canUseTool(makeTool('Write'), {}, dummyContext as any, dummyMsg as any, 'tu_6')
const rpMock = conn.requestPermission as ReturnType<typeof mock>
expect(rpMock.mock.calls.length).toBeGreaterThan(0)
const { options } = rpMock.mock.calls[0][0] as Record<string, unknown>
await canUseTool(makeTool('Write'), {}, dummyContext, dummyMsg, 'tu_8')
const { options } = (conn.requestPermission as ReturnType<typeof mock>).mock
.calls[0][0] as Record<string, unknown>
const opts = options as Array<Record<string, unknown>>
expect(opts.find((o) => o.kind === 'allow_always')).toBeTruthy()
expect(opts.find((o) => o.kind === 'allow_once')).toBeTruthy()
expect(opts.find((o) => o.kind === 'reject_once')).toBeTruthy()
expect(opts.find(option => option.kind === 'allow_always')).toBeTruthy()
expect(opts.find(option => option.kind === 'allow_once')).toBeTruthy()
expect(opts.find(option => option.kind === 'reject_once')).toBeTruthy()
})
test('ExitPlanMode omits bypass option when the session does not expose it', async () => {
const conn = makeConn({ outcome: { outcome: 'cancelled' } })
const canUseTool = createAcpCanUseTool(
conn,
'sess-4',
() => 'plan',
undefined,
undefined,
undefined,
() => false,
)
await canUseTool(makeTool('ExitPlanMode'), {}, dummyContext, dummyMsg, 'tu_9')
const { options } = (conn.requestPermission as ReturnType<typeof mock>).mock
.calls[0][0] as Record<string, unknown>
const opts = options as Array<Record<string, unknown>>
expect(opts.some(option => option.optionId === 'bypassPermissions')).toBe(false)
})
test('ExitPlanMode includes bypass option when the session exposes it', async () => {
const conn = makeConn({ outcome: { outcome: 'cancelled' } })
const canUseTool = createAcpCanUseTool(
conn,
'sess-5',
() => 'plan',
undefined,
undefined,
undefined,
() => true,
)
await canUseTool(makeTool('ExitPlanMode'), {}, dummyContext, dummyMsg, 'tu_10')
const { options } = (conn.requestPermission as ReturnType<typeof mock>).mock
.calls[0][0] as Record<string, unknown>
const opts = options as Array<Record<string, unknown>>
expect(opts.some(option => option.optionId === 'bypassPermissions')).toBe(true)
})
test('ExitPlanMode rejects a bypass selection that was not offered', async () => {
const conn = makeConn({
outcome: { outcome: 'selected', optionId: 'bypassPermissions' },
})
const onModeChange = mock(() => {})
const canUseTool = createAcpCanUseTool(
conn,
'sess-6',
() => 'plan',
undefined,
undefined,
onModeChange,
() => false,
)
const result = await canUseTool(
makeTool('ExitPlanMode'),
{},
dummyContext,
dummyMsg,
'tu_11',
)
expect(result.behavior).toBe('deny')
expect(onModeChange).not.toHaveBeenCalled()
expect((conn.sessionUpdate as ReturnType<typeof mock>).mock.calls).toHaveLength(0)
})
})

View File

@@ -0,0 +1,28 @@
import { describe, expect, test } from 'bun:test'
import { promptToQueryInput } from '../promptConversion.js'
describe('promptToQueryInput', () => {
test('converts text and embedded text resources', () => {
expect(
promptToQueryInput([
{ type: 'text', text: 'hello' },
{
type: 'resource',
resource: { text: 'resource body' },
} as any,
]),
).toBe('hello\nresource body')
})
test('renders resource_link as plain metadata instead of markdown link', () => {
expect(
promptToQueryInput([
{
type: 'resource_link',
name: 'Spec',
uri: 'file:///tmp/spec.md',
} as any,
]),
).toBe('Resource link: name=Spec, uri=file:///tmp/spec.md')
})
})

View File

@@ -33,7 +33,6 @@ import type {
SetSessionModelResponse,
SetSessionConfigOptionRequest,
SetSessionConfigOptionResponse,
ContentBlock,
ClientCapabilities,
SessionModeState,
SessionModelState,
@@ -63,31 +62,39 @@ import {
computeSessionFingerprint,
sanitizeTitle,
} from './utils.js'
import { promptToQueryInput } from './promptConversion.js'
import {
listSessionsImpl,
} from '../../utils/listSessionsImpl.js'
import { getMainLoopModel } from '../../utils/model/model.js'
import { getModelOptions } from '../../utils/model/modelOptions.js'
import { getSettings_DEPRECATED } from '../../utils/settings/settings.js'
// ── Session state ─────────────────────────────────────────────────
type AcpSession = {
queryEngine: QueryEngine
cancelled: boolean
cancelGeneration: number
cwd: string
sessionFingerprint: string
modes: SessionModeState
models: SessionModelState
configOptions: SessionConfigOption[]
promptRunning: boolean
pendingMessages: Map<string, { resolve: (cancelled: boolean) => void; order: number }>
nextPendingOrder: number
pendingMessages: Map<string, PendingPrompt>
pendingQueue: string[]
pendingQueueHead: number
toolUseCache: ToolUseCache
clientCapabilities?: ClientCapabilities
appState: AppState
commands: Command[]
}
type PendingPrompt = {
resolve: (cancelled: boolean) => void
}
// ── Agent class ───────────────────────────────────────────────────
export class AcpAgent implements Agent {
@@ -157,7 +164,9 @@ export class AcpAgent implements Agent {
// ── newSession ────────────────────────────────────────────────
async newSession(params: NewSessionRequest): Promise<NewSessionResponse> {
return this.createSession(params)
const result = await this.createSession(params)
this.scheduleAvailableCommandsUpdate(result.sessionId)
return result
}
// ── resumeSession ──────────────────────────────────────────────
@@ -166,9 +175,7 @@ export class AcpAgent implements Agent {
params: ResumeSessionRequest,
): Promise<ResumeSessionResponse> {
const result = await this.getOrCreateSession(params)
setTimeout(() => {
this.sendAvailableCommandsUpdate(params.sessionId)
}, 0)
this.scheduleAvailableCommandsUpdate(result.sessionId)
return result
}
@@ -176,9 +183,7 @@ export class AcpAgent implements Agent {
async loadSession(params: LoadSessionRequest): Promise<LoadSessionResponse> {
const result = await this.getOrCreateSession(params)
setTimeout(() => {
this.sendAvailableCommandsUpdate(params.sessionId)
}, 0)
this.scheduleAvailableCommandsUpdate(result.sessionId)
return result
}
@@ -216,9 +221,7 @@ export class AcpAgent implements Agent {
_meta: params._meta,
},
)
setTimeout(() => {
this.sendAvailableCommandsUpdate(response.sessionId)
}, 0)
this.scheduleAvailableCommandsUpdate(response.sessionId)
return response
}
@@ -243,9 +246,6 @@ export class AcpAgent implements Agent {
throw new Error(`Session ${params.sessionId} not found`)
}
// Reset cancelled state at the start of each prompt (matches official impl)
session.cancelled = false
// Extract text/image content from the prompt
const promptInput = promptToQueryInput(params.prompt)
@@ -253,18 +253,27 @@ export class AcpAgent implements Agent {
return { stopReason: 'end_turn' }
}
const promptCancelGeneration = session.cancelGeneration
// Handle prompt queuing — if a prompt is already running, queue this one
if (session.promptRunning) {
const order = session.nextPendingOrder++
const promptUuid = randomUUID()
const cancelled = await new Promise<boolean>((resolve) => {
session.pendingMessages.set(promptUuid, { resolve, order })
session.pendingQueue.push(promptUuid)
session.pendingMessages.set(promptUuid, { resolve })
})
if (cancelled) {
return { stopReason: 'cancelled' }
}
}
if (session.cancelGeneration !== promptCancelGeneration) {
return { stopReason: 'cancelled' }
}
// Reset cancellation only when this prompt is about to run. Queued prompts
// must not clear the cancellation state for the active prompt.
session.cancelled = false
session.promptRunning = true
try {
@@ -324,19 +333,15 @@ export class AcpAgent implements Agent {
)
}
console.error('[ACP] prompt error:', err)
return { stopReason: 'end_turn' }
throw err
} finally {
session.promptRunning = false
// Resolve next pending prompt if any
if (session.pendingMessages.size > 0) {
const next = [...session.pendingMessages.entries()].sort(
(a, b) => a[1].order - b[1].order,
)[0]
if (next) {
next[1].resolve(false)
session.pendingMessages.delete(next[0])
}
const nextPrompt = popNextPendingPrompt(session)
if (nextPrompt) {
session.promptRunning = true
nextPrompt.resolve(false)
} else {
session.promptRunning = false
}
}
}
@@ -349,12 +354,15 @@ export class AcpAgent implements Agent {
// Set cancelled flag — checked by prompt() loop to break out
session.cancelled = true
session.cancelGeneration += 1
// Cancel any queued prompts
for (const [, pending] of session.pendingMessages) {
pending.resolve(true)
}
session.pendingMessages.clear()
session.pendingQueue = []
session.pendingQueueHead = 0
// Interrupt the query engine to abort the current API call
session.queryEngine.interrupt()
@@ -379,7 +387,7 @@ export class AcpAgent implements Agent {
async unstable_setSessionModel(
params: SetSessionModelRequest,
): Promise<SetSessionModelResponse | void> {
): Promise<SetSessionModelResponse> {
const session = this.sessions.get(params.sessionId)
if (!session) {
throw new Error('Session not found')
@@ -388,6 +396,7 @@ export class AcpAgent implements Agent {
// parseUserSpecifiedModel() to resolve aliases (e.g. "sonnet" → "glm-5.1-turbo")
session.queryEngine.setModel(params.modelId)
await this.updateConfigOption(params.sessionId, 'model', params.modelId)
return {}
}
// ── setSessionConfigOption ──────────────────────────────────────
@@ -449,23 +458,32 @@ export class AcpAgent implements Agent {
// Set CWD for the session
setOriginalCwd(cwd)
const previousProcessCwd = process.cwd()
let processCwdChanged = false
try {
process.chdir(cwd)
processCwdChanged = true
} catch {
// CWD may not exist yet; best-effort
}
try {
// Build tools with a permissive permission context.
const permissionContext = getEmptyToolPermissionContext()
const tools: Tools = getTools(permissionContext)
// Parse permission mode from _meta (passed by RCS/acp-link) or fall back to settings
const metaPermissionMode = (params._meta as Record<string, unknown> | null | undefined)?.permissionMode as string | undefined
console.log('[ACP Agent] Session create _meta:', JSON.stringify(params._meta), 'extracted mode:', metaPermissionMode)
const permissionMode = resolvePermissionMode(
metaPermissionMode ?? this.getSetting<string>('permissions.defaultMode'),
// Parse permission mode from _meta (passed by RCS/acp-link) or settings.
const meta = params._meta as Record<string, unknown> | null | undefined
const hasMetaPermissionMode = hasOwnField(meta, 'permissionMode')
const metaPermissionMode = hasMetaPermissionMode
? meta?.permissionMode
: undefined
const settingsPermissionMode = this.getSetting<string>('permissions.defaultMode')
const permissionMode = resolveSessionPermissionMode(
metaPermissionMode,
hasMetaPermissionMode,
settingsPermissionMode,
)
console.log('[ACP Agent] Resolved permissionMode:', permissionMode)
// Create the permission bridge canUseTool function
const canUseTool = createAcpCanUseTool(
@@ -475,15 +493,15 @@ export class AcpAgent implements Agent {
this.clientCapabilities,
cwd,
(modeId: string) => { this.applySessionMode(sessionId, modeId) },
() => this.sessions.get(sessionId)?.appState
.toolPermissionContext.isBypassPermissionsModeAvailable ?? false,
)
// Parse MCP servers from ACP params
// MCP server config is handled separately in the tools system
// Check if bypass permissions is available (not running as root unless in sandbox)
const isBypassAvailable =
(typeof process.geteuid === 'function' ? process.geteuid() !== 0 : true) ||
!!process.env.IS_SANDBOX
// ACP clients can expose bypass only when both the process and local config allow it.
const isBypassAvailable = isAcpBypassPermissionModeAvailable(settingsPermissionMode)
// Create a mutable AppState for the session
const appState: AppState = {
@@ -519,7 +537,7 @@ export class AcpAgent implements Agent {
const queryEngine = new QueryEngine(engineConfig)
// Build modes — bypassPermissions only available when not running as root (or in sandbox)
// Build modes — bypassPermissions is opt-in for ACP clients.
const availableModes = [
{ id: 'default', name: 'Default', description: 'Standard behavior, prompts for dangerous operations' },
{ id: 'acceptEdits', name: 'Accept Edits', description: 'Auto-accept file edit operations' },
@@ -557,13 +575,15 @@ export class AcpAgent implements Agent {
const session: AcpSession = {
queryEngine,
cancelled: false,
cancelGeneration: 0,
cwd,
modes,
models,
configOptions,
promptRunning: false,
pendingMessages: new Map(),
nextPendingOrder: 0,
pendingQueue: [],
pendingQueueHead: 0,
toolUseCache: {},
clientCapabilities: this.clientCapabilities,
appState,
@@ -576,17 +596,17 @@ export class AcpAgent implements Agent {
this.sessions.set(sessionId, session)
// Send available commands after session creation
setTimeout(() => {
this.sendAvailableCommandsUpdate(sessionId)
}, 0)
return {
sessionId,
models,
modes,
configOptions,
}
} finally {
if (processCwdChanged) {
process.chdir(previousProcessCwd)
}
}
}
private async getOrCreateSession(params: {
@@ -672,12 +692,22 @@ export class AcpAgent implements Agent {
}
private applySessionMode(sessionId: string, modeId: string): void {
const validModes = ['auto', 'default', 'acceptEdits', 'bypassPermissions', 'dontAsk', 'plan']
if (!validModes.includes(modeId)) {
if (!isPermissionMode(modeId)) {
throw new Error(`Invalid mode: ${modeId}`)
}
const session = this.sessions.get(sessionId)
if (session) {
if (
modeId === 'bypassPermissions' &&
!session.appState.toolPermissionContext.isBypassPermissionsModeAvailable
) {
throw new Error(`Mode not available: ${modeId}`)
}
const isAvailable = session.modes.availableModes.some(mode => mode.id === modeId)
if (!isAvailable) {
throw new Error(`Mode not available: ${modeId}`)
}
session.modes = { ...session.modes, currentModeId: modeId }
// Sync mode to appState so the permission pipeline sees the correct mode
session.appState.toolPermissionContext = {
@@ -750,38 +780,160 @@ export class AcpAgent implements Agent {
})
}
private scheduleAvailableCommandsUpdate(sessionId: string): void {
setTimeout(() => {
void this.sendAvailableCommandsUpdate(sessionId).catch(err => {
console.error('[ACP] Failed to send available commands update:', err)
})
}, 0)
}
/** Read a setting from Claude config (simplified — no file watching) */
private getSetting<T>(key: string): T | undefined {
// Simplified: read from environment or return undefined
// In a full implementation, this would read from settings.json
return undefined as T | undefined
const settings = getSettings_DEPRECATED() as Record<string, unknown>
const value = key.split('.').reduce<unknown>((current, segment) => {
if (!current || typeof current !== 'object') return undefined
return (current as Record<string, unknown>)[segment]
}, settings)
return value as T | undefined
}
}
// ── Helpers ────────────────────────────────────────────────────────
/** Extract prompt text from ACP ContentBlock array for QueryEngine input */
function promptToQueryInput(
prompt: Array<ContentBlock> | undefined,
): string {
if (!prompt || prompt.length === 0) return ''
const permissionModeIds: readonly PermissionMode[] = [
'auto',
'default',
'acceptEdits',
'bypassPermissions',
'dontAsk',
'plan',
]
const parts: string[] = []
for (const block of prompt) {
const b = block as Record<string, unknown>
if (b.type === 'text') {
parts.push(b.text as string)
} else if (b.type === 'resource_link') {
parts.push(`[${b.name ?? ''}](${b.uri as string})`)
} else if (b.type === 'resource') {
const resource = b.resource as Record<string, unknown> | undefined
if (resource && 'text' in resource) {
parts.push(resource.text as string)
}
function isPermissionMode(modeId: string): modeId is PermissionMode {
return (permissionModeIds as readonly string[]).includes(modeId)
}
function resolveSessionPermissionMode(
metaMode: unknown,
hasMetaMode: boolean,
settingsMode: unknown,
): PermissionMode {
if (hasMetaMode) {
const metaResolved = resolveRequiredPermissionMode(
metaMode,
'_meta.permissionMode',
)
if (
metaResolved === 'bypassPermissions' &&
!isAcpBypassPermissionModeAvailable(settingsMode)
) {
throw new Error(
'Mode not available: bypassPermissions requires a local ACP bypass opt-in.',
)
}
// Ignore image and other types for text-based prompt
return metaResolved
}
const settingsResolved = resolveConfiguredPermissionMode(settingsMode)
return settingsResolved ?? 'default'
}
function resolveRequiredPermissionMode(
mode: unknown,
source: string,
): PermissionMode {
if (mode === undefined || mode === null) {
throw new Error(`Invalid ${source}: expected a string.`)
}
return resolvePermissionMode(mode, source) as PermissionMode
}
function resolveConfiguredPermissionMode(mode: unknown): PermissionMode | undefined {
if (mode === undefined || mode === null) return undefined
try {
return resolvePermissionMode(mode, 'permissions.defaultMode') as PermissionMode
} catch (err: unknown) {
const reason = err instanceof Error ? err.message : String(err)
console.error('[ACP] Invalid permissions.defaultMode, using default:', reason)
return undefined
}
}
function hasOwnField(
value: Record<string, unknown> | null | undefined,
key: string,
): boolean {
return !!value && Object.hasOwn(value, key)
}
function isAcpBypassPermissionModeAvailable(settingsMode?: unknown): boolean {
return (
isProcessBypassPermissionModeAvailable() &&
(isAcpBypassLocallyEnabled() || isSettingsBypassPermissionMode(settingsMode))
)
}
function isProcessBypassPermissionModeAvailable(): boolean {
if (process.env.IS_SANDBOX) return true
if (typeof process.geteuid === 'function') return process.geteuid() !== 0
if (typeof process.getuid === 'function') return process.getuid() !== 0
return true
}
function isAcpBypassLocallyEnabled(): boolean {
return (
process.env.ACP_PERMISSION_MODE === 'bypassPermissions' ||
isTruthyEnv(process.env.CLAUDE_CODE_ACP_ALLOW_BYPASS_PERMISSIONS)
)
}
function isSettingsBypassPermissionMode(settingsMode: unknown): boolean {
try {
return resolvePermissionMode(settingsMode) === 'bypassPermissions'
} catch {
return false
}
}
function isTruthyEnv(value: string | undefined): boolean {
return value === '1' || value?.toLowerCase() === 'true'
}
function popNextPendingPrompt(session: AcpSession): PendingPrompt | undefined {
while (session.pendingQueueHead < session.pendingQueue.length) {
const nextId = session.pendingQueue[session.pendingQueueHead++]
if (!nextId) continue
const next = session.pendingMessages.get(nextId)
if (!next) continue
session.pendingMessages.delete(nextId)
compactPendingQueue(session)
return next
}
compactPendingQueue(session)
return undefined
}
function compactPendingQueue(session: AcpSession): void {
if (session.pendingQueueHead === 0) return
if (session.pendingQueueHead >= session.pendingQueue.length) {
session.pendingQueue = []
session.pendingQueueHead = 0
return
}
if (
session.pendingQueueHead > 1024 &&
session.pendingQueueHead * 2 > session.pendingQueue.length
) {
session.pendingQueue = session.pendingQueue.slice(session.pendingQueueHead)
session.pendingQueueHead = 0
}
return parts.join('\n')
}
function buildConfigOptions(

View File

@@ -514,28 +514,25 @@ export function toolUpdateFromEditToolResponse(toolResponse: unknown): {
return result
}
// ── Prompt conversion ─────────────────────────────────────────────
function nextSdkMessageOrAbort(
sdkMessages: AsyncGenerator<SDKMessage, void, unknown>,
abortSignal: AbortSignal,
): Promise<IteratorResult<SDKMessage, void>> {
if (abortSignal.aborted) {
return Promise.resolve({ done: true, value: undefined })
}
/**
* Convert ACP PromptRequest content blocks into content for QueryEngine.
*/
export function promptToQueryContent(
prompt: Array<ContentBlock> | undefined,
): string {
if (!prompt) return ''
return prompt
.map((block) => {
const b = block as Record<string, unknown>
if (b.type === 'text') return b.text as string
if (b.type === 'resource_link') return `[${b.name ?? ''}](${b.uri as string})`
if (b.type === 'resource') {
const resource = b.resource as Record<string, unknown> | undefined
if (resource && 'text' in resource) return resource.text as string
}
return ''
})
.filter(Boolean)
.join('\n')
let abortHandler: (() => void) | undefined
const abortPromise = new Promise<IteratorResult<SDKMessage, void>>((resolve) => {
abortHandler = () => resolve({ done: true, value: undefined })
abortSignal.addEventListener('abort', abortHandler, { once: true })
})
return Promise.race([sdkMessages.next(), abortPromise]).finally(() => {
if (abortHandler) {
abortSignal.removeEventListener('abort', abortHandler)
}
})
}
// ── Main forwarding function ──────────────────────────────────────
@@ -573,17 +570,7 @@ export async function forwardSessionUpdates(
// Race the next message against the abort signal so we unblock
// immediately when cancelled, even if the generator is waiting for
// a slow API response.
const nextResult = await Promise.race([
sdkMessages.next(),
new Promise<IteratorResult<SDKMessage, void>>((resolve) => {
if (abortSignal.aborted) {
resolve({ done: true, value: undefined })
return
}
const handler = () => resolve({ done: true, value: undefined })
abortSignal.addEventListener('abort', handler, { once: true })
}),
])
const nextResult = await nextSdkMessageOrAbort(sdkMessages, abortSignal)
if (nextResult.done || abortSignal.aborted) break
const msg = nextResult.value
@@ -1059,12 +1046,7 @@ function toAcpNotifications(
}
} else {
// Regular tool call
let rawInput: Record<string, unknown> | undefined
try {
rawInput = JSON.parse(JSON.stringify(toolInput ?? {}))
} catch {
// Ignore parse failures
}
const rawInput = toolInput ? { ...toolInput } : {}
if (alreadyCached) {
// Second encounter — send as tool_call_update

View File

@@ -25,14 +25,6 @@ import type { AssistantMessage } from '../../types/message.js'
import { hasPermissionsToUseTool } from '../../utils/permissions/permissions.js'
import { toolInfoFromToolUse } from './bridge.js'
const IS_ROOT =
typeof process.geteuid === 'function'
? process.geteuid() === 0
: typeof process.getuid === 'function'
? process.getuid() === 0
: false
const ALLOW_BYPASS = !IS_ROOT || !!process.env.IS_SANDBOX
/**
* Creates a CanUseToolFn that delegates permission decisions to the
* ACP client via requestPermission().
@@ -44,6 +36,7 @@ export function createAcpCanUseTool(
clientCapabilities?: ClientCapabilities,
cwd?: string,
onModeChange?: (modeId: string) => void,
isBypassModeAvailable?: () => boolean,
): CanUseToolFn {
return async (
tool: ToolType,
@@ -59,6 +52,7 @@ export function createAcpCanUseTool(
if (tool.name === 'ExitPlanMode') {
return handleExitPlanMode(
conn, sessionId, toolUseID, input, supportsTerminalOutput, cwd, onModeChange,
isBypassModeAvailable,
)
}
@@ -84,8 +78,16 @@ export function createAcpCanUseTool(
}
// behavior === 'ask' → fall through to client delegation
} catch (err) {
// If the pipeline fails, fall through to client delegation
console.error('[ACP Permissions] Pipeline error, falling back to client:', err)
console.error('[ACP Permissions] Pipeline error:', err)
return {
behavior: 'deny',
message: 'Permission pipeline failed',
decisionReason: {
type: 'other',
reason: 'Permission pipeline failed',
},
toolUseID,
}
}
// ── Delegate to ACP client for interactive permission decision ──
@@ -144,7 +146,8 @@ export function createAcpCanUseTool(
message: 'Permission denied by client',
decisionReason: { type: 'mode', mode: 'default' },
}
} catch {
} catch (err) {
console.error('[ACP Permissions] Client request error:', err)
return {
behavior: 'deny',
message: 'Permission request failed',
@@ -162,6 +165,7 @@ async function handleExitPlanMode(
supportsTerminalOutput: boolean,
cwd?: string,
onModeChange?: (modeId: string) => void,
isBypassModeAvailable?: () => boolean,
): Promise<PermissionAllowDecision | PermissionDenyDecision> {
const options: Array<PermissionOption> = [
{ kind: 'allow_always', name: 'Yes, and use "auto" mode', optionId: 'auto' },
@@ -169,7 +173,7 @@ async function handleExitPlanMode(
{ kind: 'allow_once', name: 'Yes, and manually approve edits', optionId: 'default' },
{ kind: 'reject_once', name: 'No, keep planning', optionId: 'plan' },
]
if (ALLOW_BYPASS) {
if (isBypassModeAvailable?.() === true) {
options.unshift({
kind: 'allow_always',
name: 'Yes, and bypass permissions',
@@ -211,11 +215,15 @@ async function handleExitPlanMode(
response.outcome.optionId !== undefined
) {
const selectedOption = response.outcome.optionId
const isOfferedOption = options.some(option => option.optionId === selectedOption)
if (
selectedOption === 'default' ||
selectedOption === 'acceptEdits' ||
selectedOption === 'auto' ||
selectedOption === 'bypassPermissions'
isOfferedOption &&
(
selectedOption === 'default' ||
selectedOption === 'acceptEdits' ||
selectedOption === 'auto' ||
selectedOption === 'bypassPermissions'
)
) {
// Sync mode to session state and appState
onModeChange?.(selectedOption)

View File

@@ -0,0 +1,40 @@
import type { ContentBlock } from '@agentclientprotocol/sdk'
export function promptToQueryInput(
prompt: Array<ContentBlock> | undefined,
): string {
if (!prompt || prompt.length === 0) return ''
const parts: string[] = []
for (const block of prompt) {
const b = block as Record<string, unknown>
if (b.type === 'text') {
parts.push(String(b.text ?? ''))
} else if (b.type === 'resource_link') {
const name = typeof b.name === 'string' ? b.name : undefined
const uri = typeof b.uri === 'string' ? b.uri : undefined
// Keep resource links as metadata, not markdown links, so models do not
// infer user-visible click targets or silently rewrite URI semantics.
parts.push(formatResourceLink(name, uri))
} else if (b.type === 'resource') {
const resource = b.resource as Record<string, unknown> | undefined
if (resource && typeof resource.text === 'string') {
parts.push(resource.text)
}
}
}
return parts.filter(part => part.length > 0).join('\n')
}
function formatResourceLink(
name: string | undefined,
uri: string | undefined,
): string {
const details: string[] = []
if (name && name.length > 0) details.push(`name=${name}`)
if (uri && uri.length > 0) details.push(`uri=${uri}`)
return details.length > 0
? `Resource link: ${details.join(', ')}`
: 'Resource link'
}

View File

@@ -121,30 +121,31 @@ const PERMISSION_MODE_ALIASES: Record<string, PermissionMode> = {
bypass: 'bypassPermissions',
}
export function resolvePermissionMode(defaultMode?: unknown): PermissionMode {
export function resolvePermissionMode(
defaultMode?: unknown,
source = 'permissions.defaultMode',
): PermissionMode {
if (defaultMode === undefined) {
return 'default'
}
if (typeof defaultMode !== 'string') {
throw new Error('Invalid permissions.defaultMode: expected a string.')
throw new Error(`Invalid ${source}: expected a string.`)
}
const normalized = defaultMode.trim().toLowerCase()
if (normalized === '') {
throw new Error(
'Invalid permissions.defaultMode: expected a non-empty string.',
)
throw new Error(`Invalid ${source}: expected a non-empty string.`)
}
const mapped = PERMISSION_MODE_ALIASES[normalized]
if (!mapped) {
throw new Error(`Invalid permissions.defaultMode: ${defaultMode}.`)
throw new Error(`Invalid ${source}: ${defaultMode}.`)
}
if (mapped === 'bypassPermissions' && !ALLOW_BYPASS) {
throw new Error(
'Invalid permissions.defaultMode: bypassPermissions is not available when running as root.',
`Invalid ${source}: bypassPermissions is not available when running as root.`,
)
}

View File

@@ -20,7 +20,7 @@ type LogEventMetadata = { [key: string]: boolean | number | undefined }
const DATADOG_GATE_NAME = 'tengu_log_datadog_events'
// Module-level gate state - starts undefined, initialized during startup
let isDatadogGateEnabled: boolean | undefined = undefined
let isDatadogGateEnabled: boolean | undefined
/**
* Check if Datadog tracking is enabled.

View File

@@ -1544,11 +1544,11 @@ async function* queryModel(
let start = Date.now()
let attemptNumber = 0
const attemptStartTimes: number[] = []
let stream: Stream<BetaRawMessageStreamEvent> | undefined = undefined
let streamRequestId: string | null | undefined = undefined
let clientRequestId: string | undefined = undefined
let stream: Stream<BetaRawMessageStreamEvent> | undefined
let streamRequestId: string | null | undefined
let clientRequestId: string | undefined
// eslint-disable-next-line eslint-plugin-n/no-unsupported-features/node-builtins -- Response is available in Node 18+ and is used by the SDK
let streamResponse: Response | undefined = undefined
let streamResponse: Response | undefined
// Release all stream resources to prevent native memory leaks.
// The Response object holds native TLS/socket buffers that live outside the
@@ -1634,7 +1634,7 @@ async function* queryModel(
const hasThinking =
thinkingConfig.type !== 'disabled' &&
!isEnvTruthy(process.env.CLAUDE_CODE_DISABLE_THINKING)
let thinking: BetaMessageStreamParams['thinking'] | undefined = undefined
let thinking: BetaMessageStreamParams['thinking'] | undefined
// IMPORTANT: Do not change the adaptive-vs-budget thinking selection below
// without notifying the model launch DRI and research. This is a sensitive
@@ -1804,7 +1804,7 @@ async function* queryModel(
const newMessages: AssistantMessage[] = []
let ttftMs = 0
let partialMessage: BetaMessage | undefined = undefined
let partialMessage: BetaMessage | undefined
const contentBlocks: (BetaContentBlock | ConnectorTextBlock)[] = []
let usage: NonNullableUsage = EMPTY_USAGE
let costUSD = 0
@@ -1812,8 +1812,8 @@ async function* queryModel(
let didFallBackToNonStreaming = false
let fallbackMessage: AssistantMessage | undefined
let maxOutputTokens = 0
let responseHeaders: globalThis.Headers | undefined = undefined
let research: unknown = undefined
let responseHeaders: globalThis.Headers | undefined
let research: unknown
let isFastModeRequest = isFastMode // Keep separate state as it may change if falling back
let isAdvisorInProgress = false

View File

@@ -113,7 +113,7 @@ async function retryWithBackoff<T>(
)
if (attempt < MAX_RETRIES) {
const delayMs = BASE_DELAY_MS * Math.pow(2, attempt - 1)
const delayMs = BASE_DELAY_MS * 2 ** (attempt - 1)
logDebug(`Retrying ${operation} in ${delayMs}ms...`)
await sleep(delayMs)
}

View File

@@ -103,7 +103,7 @@ export async function* queryModelGemini(
const adaptedStream = adaptGeminiStreamToAnthropic(stream, geminiModel)
const contentBlocks: Record<number, any> = {}
const collectedMessages: AssistantMessage[] = []
let partialMessage: any = undefined
let partialMessage: any
let ttftMs = 0
const start = Date.now()

View File

@@ -95,7 +95,7 @@ export async function* queryModelGrok(
const contentBlocks: Record<number, any> = {}
const collectedMessages: AssistantMessage[] = []
let partialMessage: any = undefined
let partialMessage: any
let usage = {
input_tokens: 0,
output_tokens: 0,

View File

@@ -175,7 +175,7 @@ async function appendSessionLogImpl(
return false
}
const delayMs = Math.min(BASE_DELAY_MS * Math.pow(2, attempt - 1), 8000)
const delayMs = Math.min(BASE_DELAY_MS * 2 ** (attempt - 1), 8000)
logForDebugging(
`Remote persistence attempt ${attempt}/${MAX_RETRIES} failed, retrying in ${delayMs}ms…`,
)

View File

@@ -540,7 +540,7 @@ export function getRetryDelay(
}
const baseDelay = Math.min(
BASE_DELAY_MS * Math.pow(2, attempt - 1),
BASE_DELAY_MS * 2 ** (attempt - 1),
maxDelayMs,
)
const jitter = Math.random() * 0.25 * baseDelay

View File

@@ -1,12 +1,31 @@
import { homedir } from 'os'
const MAX_OUTPUT_LENGTH = 500
const REDACTED_FILE_TOOLS = new Set(['FileReadTool', 'FileWriteTool', 'FileEditTool'])
const REDACTED_SHELL_TOOLS = new Set(['BashTool', 'PowerShellTool'])
const SENSITIVE_OUTPUT_TOOLS = new Set(['ConfigTool', 'MCPTool'])
const HOME_DIR_PATTERN = new RegExp(
(process.env.HOME ?? '/Users/[^/]+').replace(/[.*+?^${}()|[\]\\]/g, '\\$&'),
'g',
)
function escapeRegExp(value: string): string {
return value.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')
}
function homePathPatterns(): string[] {
const homes = new Set<string>()
for (const value of [process.env.HOME, process.env.USERPROFILE, homedir()]) {
if (value) {
homes.add(value)
homes.add(value.replace(/\\/g, '/'))
}
}
return [
...Array.from(homes, escapeRegExp),
'/Users/[^/\\\\]+',
'[A-Za-z]:[/\\\\]Users[/\\\\][^/\\\\]+',
]
}
const HOME_DIR_PATTERN = new RegExp(`(?:${homePathPatterns().join('|')})`, 'g')
const SENSITIVE_KEY_PATTERN = /(?:api_?key|token|secret|password|credential|auth_header)/i

View File

@@ -387,7 +387,7 @@ export function createLSPServerInstance(
isContentModifiedError &&
attempt < MAX_RETRIES_FOR_TRANSIENT_ERRORS
) {
const delay = RETRY_BASE_DELAY_MS * Math.pow(2, attempt)
const delay = RETRY_BASE_DELAY_MS * 2 ** attempt
logForDebugging(
`LSP request '${method}' to '${name}' got ContentModified error, ` +
`retrying in ${delay}ms (attempt ${attempt + 1}/${MAX_RETRIES_FOR_TRANSIENT_ERRORS})…`,

View File

@@ -1,6 +1,10 @@
import { describe, expect, test, beforeEach, afterEach } from "bun:test";
import { expandEnvVarsInString } from "../envExpansion";
const ENV_OPEN = "$" + "{";
const ENV_CLOSE = "}";
const envExpr = (value: string): string => `${ENV_OPEN}${value}${ENV_CLOSE}`;
describe("expandEnvVarsInString", () => {
// Save and restore env vars touched by tests
const savedEnv: Record<string, string | undefined> = {};
@@ -33,21 +37,21 @@ describe("expandEnvVarsInString", () => {
test("expands a single env var that exists", () => {
process.env.TEST_HOME = "/home/user";
const result = expandEnvVarsInString("${TEST_HOME}");
const result = expandEnvVarsInString(envExpr("TEST_HOME"));
expect(result.expanded).toBe("/home/user");
expect(result.missingVars).toEqual([]);
});
test("returns original placeholder and tracks missing var when not found", () => {
delete process.env.MISSING;
const result = expandEnvVarsInString("${MISSING}");
expect(result.expanded).toBe("${MISSING}");
const result = expandEnvVarsInString(envExpr("MISSING"));
expect(result.expanded).toBe(envExpr("MISSING"));
expect(result.missingVars).toEqual(["MISSING"]);
});
test("uses default value when var is missing and default is provided", () => {
delete process.env.MISSING;
const result = expandEnvVarsInString("${MISSING:-fallback}");
const result = expandEnvVarsInString(envExpr("MISSING:-fallback"));
expect(result.expanded).toBe("fallback");
expect(result.missingVars).toEqual([]);
});
@@ -55,7 +59,9 @@ describe("expandEnvVarsInString", () => {
test("expands multiple vars", () => {
process.env.TEST_A = "hello";
process.env.TEST_B = "world";
const result = expandEnvVarsInString("${TEST_A}/${TEST_B}");
const result = expandEnvVarsInString(
`${envExpr("TEST_A")}/${envExpr("TEST_B")}`,
);
expect(result.expanded).toBe("hello/world");
expect(result.missingVars).toEqual([]);
});
@@ -63,8 +69,10 @@ describe("expandEnvVarsInString", () => {
test("handles mix of found and missing vars", () => {
process.env.TEST_FOUND = "yes";
delete process.env.MISSING;
const result = expandEnvVarsInString("${TEST_FOUND}-${MISSING}");
expect(result.expanded).toBe("yes-${MISSING}");
const result = expandEnvVarsInString(
`${envExpr("TEST_FOUND")}-${envExpr("MISSING")}`,
);
expect(result.expanded).toBe(`yes-${envExpr("MISSING")}`);
expect(result.missingVars).toEqual(["MISSING"]);
});
@@ -76,14 +84,14 @@ describe("expandEnvVarsInString", () => {
test("expands empty env var value", () => {
process.env.TEST_EMPTY = "";
const result = expandEnvVarsInString("${TEST_EMPTY}");
const result = expandEnvVarsInString(envExpr("TEST_EMPTY"));
expect(result.expanded).toBe("");
expect(result.missingVars).toEqual([]);
});
test("prefers env var value over default when var exists", () => {
process.env.TEST_X = "real";
const result = expandEnvVarsInString("${TEST_X:-default}");
const result = expandEnvVarsInString(envExpr("TEST_X:-default"));
expect(result.expanded).toBe("real");
expect(result.missingVars).toEqual([]);
});
@@ -91,7 +99,7 @@ describe("expandEnvVarsInString", () => {
test("handles default value containing colons", () => {
// split(':-', 2) means only the first :- is the delimiter
delete process.env.TEST_X;
const result = expandEnvVarsInString("${TEST_X:-value:-with:-colons}");
const result = expandEnvVarsInString(envExpr("TEST_X:-value:-with:-colons"));
// The default is "value" because split(':-', 2) gives ["TEST_X", "value"]
// Wait -- actually split(':-', 2) on "TEST_X:-value:-with:-colons" gives:
// ["TEST_X", "value"] because limit=2 stops at 2 pieces
@@ -103,11 +111,12 @@ describe("expandEnvVarsInString", () => {
// ${${VAR}} - the regex [^}]+ matches "${VAR" (up to first })
// so varName would be "${VAR" which won't be found in env
delete process.env.VAR;
const result = expandEnvVarsInString("${${VAR}}");
const nestedExpr = `${ENV_OPEN}${envExpr("VAR")}${ENV_CLOSE}`;
const result = expandEnvVarsInString(nestedExpr);
// The regex \$\{([^}]+)\} matches "${${VAR}" with capture "${VAR"
// That env var won't exist, so it stays as "${${VAR}" + remaining "}"
expect(result.missingVars).toEqual(["${VAR"]);
expect(result.expanded).toBe("${${VAR}}");
expect(result.missingVars).toEqual([`${ENV_OPEN}VAR`]);
expect(result.expanded).toBe(nestedExpr);
});
test("handles empty string input", () => {
@@ -118,14 +127,14 @@ describe("expandEnvVarsInString", () => {
test("handles var surrounded by text", () => {
process.env.TEST_A = "middle";
const result = expandEnvVarsInString("before-${TEST_A}-after");
const result = expandEnvVarsInString(`before-${envExpr("TEST_A")}-after`);
expect(result.expanded).toBe("before-middle-after");
expect(result.missingVars).toEqual([]);
});
test("handles default value that is empty string", () => {
delete process.env.MISSING;
const result = expandEnvVarsInString("${MISSING:-}");
const result = expandEnvVarsInString(envExpr("MISSING:-"));
expect(result.expanded).toBe("");
expect(result.missingVars).toEqual([]);
});

View File

@@ -2346,7 +2346,7 @@ export class ClaudeAuthProvider implements OAuthClientProvider {
return undefined
}
const delayMs = 1000 * Math.pow(2, attempt - 1) // 1s, 2s, 4s
const delayMs = 1000 * 2 ** (attempt - 1) // 1s, 2s, 4s
logMCPDebug(
this.serverName,
`Token refresh failed, retrying in ${delayMs}ms (attempt ${attempt}/${MAX_ATTEMPTS})`,

View File

@@ -57,8 +57,6 @@ export async function findAvailablePort(): Promise<number> {
})
return port
} catch {
// Port in use, try another random port
continue
}
}

View File

@@ -14,7 +14,7 @@ type RegistryResponse = {
// URLs stripped of query string and trailing slash — matches the normalization
// done by getLoggingSafeMcpBaseUrl so direct Set.has() lookup works.
let officialUrls: Set<string> | undefined = undefined
let officialUrls: Set<string> | undefined
function normalizeUrl(url: string): string | undefined {
try {

View File

@@ -445,7 +445,7 @@ export function useManageMCPConnections(
// Schedule next retry with exponential backoff
const backoffMs = Math.min(
INITIAL_BACKOFF_MS * Math.pow(2, attempt - 1),
INITIAL_BACKOFF_MS * 2 ** (attempt - 1),
MAX_BACKOFF_MS,
)
logMCPDebug(

View File

@@ -353,7 +353,6 @@ export async function installPluginOp(
}
} catch (error) {
logError(toError(error))
continue
}
}
}

View File

@@ -310,7 +310,6 @@ export async function countTokensViaHaikuFallback(
? betas.filter(b => VERTEX_COUNT_TOKENS_ALLOWED_BETAS.has(b))
: betas
// biome-ignore lint/plugin: token counting needs specialized parameters (thinking, betas) that sideQuery doesn't support
const apiStart = Date.now()
const langfuseTrace = isLangfuseEnabled()
? createTrace({