mirror of
https://github.com/claude-code-best/claude-code.git
synced 2026-06-18 14:25:51 +00:00
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:
@@ -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,
|
||||
)
|
||||
|
||||
@@ -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,
|
||||
)
|
||||
|
||||
@@ -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', () => {
|
||||
|
||||
@@ -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 () => {
|
||||
|
||||
@@ -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)
|
||||
})
|
||||
})
|
||||
|
||||
28
src/services/acp/__tests__/promptConversion.test.ts
Normal file
28
src/services/acp/__tests__/promptConversion.test.ts
Normal 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')
|
||||
})
|
||||
})
|
||||
@@ -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(
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
|
||||
40
src/services/acp/promptConversion.ts
Normal file
40
src/services/acp/promptConversion.ts
Normal 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'
|
||||
}
|
||||
@@ -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.`,
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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()
|
||||
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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…`,
|
||||
)
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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})…`,
|
||||
|
||||
@@ -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([]);
|
||||
});
|
||||
|
||||
@@ -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})`,
|
||||
|
||||
@@ -57,8 +57,6 @@ export async function findAvailablePort(): Promise<number> {
|
||||
})
|
||||
return port
|
||||
} catch {
|
||||
// Port in use, try another random port
|
||||
continue
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -353,7 +353,6 @@ export async function installPluginOp(
|
||||
}
|
||||
} catch (error) {
|
||||
logError(toError(error))
|
||||
continue
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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({
|
||||
|
||||
Reference in New Issue
Block a user