diff --git a/eslint.config.js b/eslint.config.js index caffbdf336b..da1dc61caae 100644 --- a/eslint.config.js +++ b/eslint.config.js @@ -17,6 +17,21 @@ import storybook from 'eslint-plugin-storybook'; import checkFile from 'eslint-plugin-check-file'; import { legacyFilenames } from './eslint.legacy-filenames.mjs'; +// General syntax restrictions applied to every TS/TSX source file. Hoisted so +// surface-specific overrides (flat config keeps only the last +// no-restricted-syntax setting per file) can repeat them without drift. +const generalRestrictedSyntaxSelectors = [ + { + selector: 'CallExpression[callee.name="require"]', + message: 'Avoid using require(). Use ES6 imports instead.', + }, + { + selector: 'ThrowStatement > Literal:not([value=/^\\w+Error:/])', + message: + 'Do not throw string literals or non-Error objects. Throw new Error("...") instead.', + }, +]; + export default tseslint.config( { // Global ignores @@ -72,6 +87,26 @@ export default tseslint.config( 'import/namespace': 'off', // Disabled due to https://github.com/import-js/eslint-plugin-import/issues/2866 }, }, + { + // ACP integration and the daemon are separate runtime surfaces that happen + // to share a package directory. ACP may consume neutral contracts under + // `runtime/`, but never `serve/` implementation modules — see #8084. + files: ['packages/cli/src/acp-integration/**/*.{ts,tsx,js}'], + rules: { + 'no-restricted-imports': [ + 'error', + { + patterns: [ + { + group: ['**/serve', '**/serve/**'], + message: + 'acp-integration must not import serve/ internals. Put shared, lifecycle-free logic in packages/cli/src/runtime/ instead (#8084).', + }, + ], + }, + ], + }, + }, { // `utils/` is the layer every other directory imports, so it must not // import back into one. The daemon direction is clean and enforced here; @@ -168,18 +203,7 @@ export default tseslint.config( 'no-cond-assign': 'error', 'no-debugger': 'error', 'no-duplicate-case': 'error', - 'no-restricted-syntax': [ - 'error', - { - selector: 'CallExpression[callee.name="require"]', - message: 'Avoid using require(). Use ES6 imports instead.', - }, - { - selector: 'ThrowStatement > Literal:not([value=/^\\w+Error:/])', - message: - 'Do not throw string literals or non-Error objects. Throw new Error("...") instead.', - }, - ], + 'no-restricted-syntax': ['error', ...generalRestrictedSyntaxSelectors], 'no-unsafe-finally': 'error', 'no-console': 'error', 'no-unused-expressions': 'off', // Disable base rule @@ -197,6 +221,26 @@ export default tseslint.config( 'default-case': 'error', }, }, + { + // no-restricted-imports only sees static import/export declarations, so a + // dynamic `await import('../serve/...')` would slip past the #8084 guard + // above. Kept after the general TS block because flat config applies only + // the last no-restricted-syntax setting per file, hence the repeated + // general selectors. + files: ['packages/cli/src/acp-integration/**/*.{ts,tsx,js}'], + rules: { + 'no-restricted-syntax': [ + 'error', + ...generalRestrictedSyntaxSelectors, + { + // \x2f is '/' — esquery selector regexes cannot contain a literal '/'. + selector: "ImportExpression[source.value=/(^|\\x2f)serve(\\x2f|$)/i]", + message: + 'acp-integration must not dynamically import serve/ internals. Put shared, lifecycle-free logic in packages/cli/src/runtime/ instead (#8084).', + }, + ], + }, + }, { files: [ 'packages/web-shell/client/**/*.{ts,tsx}', diff --git a/packages/cli/src/acp-integration/acpAgent.ts b/packages/cli/src/acp-integration/acpAgent.ts index 5dfeaa1ae4b..b9052dadb1f 100644 --- a/packages/cli/src/acp-integration/acpAgent.ts +++ b/packages/cli/src/acp-integration/acpAgent.ts @@ -224,7 +224,7 @@ import { type PermissionRuleSet, } from '../config/permission-settings.js'; import { createLoadedSettingsAdapter } from '../config/loadedSettingsAdapter.js'; -import { isCompatibleLiveSessionSource } from '../serve/conversations/session-source.js'; +import { isCompatibleLiveSessionSource } from '../runtime/live-session-source.js'; import type { ApprovalModeValue } from './session/types.js'; import { z } from 'zod'; import type { CliArgs } from '../config/config.js'; diff --git a/packages/cli/src/serve/live/capture-screen-context.test.ts b/packages/cli/src/acp-integration/live/capture-screen-context.test.ts similarity index 100% rename from packages/cli/src/serve/live/capture-screen-context.test.ts rename to packages/cli/src/acp-integration/live/capture-screen-context.test.ts diff --git a/packages/cli/src/serve/live/capture-screen-context.ts b/packages/cli/src/acp-integration/live/capture-screen-context.ts similarity index 100% rename from packages/cli/src/serve/live/capture-screen-context.ts rename to packages/cli/src/acp-integration/live/capture-screen-context.ts diff --git a/packages/cli/src/serve/live/live-backend-instructions.test.ts b/packages/cli/src/acp-integration/live/live-backend-instructions.test.ts similarity index 100% rename from packages/cli/src/serve/live/live-backend-instructions.test.ts rename to packages/cli/src/acp-integration/live/live-backend-instructions.test.ts diff --git a/packages/cli/src/serve/live/live-backend-instructions.ts b/packages/cli/src/acp-integration/live/live-backend-instructions.ts similarity index 100% rename from packages/cli/src/serve/live/live-backend-instructions.ts rename to packages/cli/src/acp-integration/live/live-backend-instructions.ts diff --git a/packages/cli/src/serve/live/live-speak-to-user.test.ts b/packages/cli/src/acp-integration/live/live-speak-to-user.test.ts similarity index 100% rename from packages/cli/src/serve/live/live-speak-to-user.test.ts rename to packages/cli/src/acp-integration/live/live-speak-to-user.test.ts diff --git a/packages/cli/src/serve/live/live-speak-to-user.ts b/packages/cli/src/acp-integration/live/live-speak-to-user.ts similarity index 100% rename from packages/cli/src/serve/live/live-speak-to-user.ts rename to packages/cli/src/acp-integration/live/live-speak-to-user.ts diff --git a/packages/cli/src/serve/live/live-task-tools.test.ts b/packages/cli/src/acp-integration/live/live-task-tools.test.ts similarity index 100% rename from packages/cli/src/serve/live/live-task-tools.test.ts rename to packages/cli/src/acp-integration/live/live-task-tools.test.ts diff --git a/packages/cli/src/serve/live/live-task-tools.ts b/packages/cli/src/acp-integration/live/live-task-tools.ts similarity index 100% rename from packages/cli/src/serve/live/live-task-tools.ts rename to packages/cli/src/acp-integration/live/live-task-tools.ts diff --git a/packages/cli/src/acp-integration/session/Session.test.ts b/packages/cli/src/acp-integration/session/Session.test.ts index 91a03d63810..11046c442fa 100644 --- a/packages/cli/src/acp-integration/session/Session.test.ts +++ b/packages/cli/src/acp-integration/session/Session.test.ts @@ -54,8 +54,8 @@ import * as nonInteractiveCliCommands from '../../nonInteractiveCliCommands.js'; import { CommandKind } from '../../ui/commands/types.js'; import { buildAcpModelOptions } from '../../utils/acpModelUtils.js'; import { CHANNEL_PROMPT_META_KEY } from '@qwen-code/channel-base'; -import { CAPTURE_SCREEN_CONTEXT_TOOL_NAME } from '../../serve/live/capture-screen-context.js'; -import { SPEAK_TO_USER_TOOL_NAME } from '../../serve/live/live-speak-to-user.js'; +import { CAPTURE_SCREEN_CONTEXT_TOOL_NAME } from '../live/capture-screen-context.js'; +import { SPEAK_TO_USER_TOOL_NAME } from '../live/live-speak-to-user.js'; import { collectHistoryReplayUpdates, createReplayCumulativeUsage, diff --git a/packages/cli/src/acp-integration/session/Session.ts b/packages/cli/src/acp-integration/session/Session.ts index c60c5998bd8..4e69b2f7d6b 100644 --- a/packages/cli/src/acp-integration/session/Session.ts +++ b/packages/cli/src/acp-integration/session/Session.ts @@ -223,19 +223,19 @@ import { normalizeChannelDeliveryText } from '../../runtime/channel-delivery.js' import { CAPTURE_SCREEN_CONTEXT_TOOL_NAME, CaptureScreenContextTool, -} from '../../serve/live/capture-screen-context.js'; +} from '../live/capture-screen-context.js'; import { createLiveTaskTools, type LiveTaskTool, -} from '../../serve/live/live-task-tools.js'; +} from '../live/live-task-tools.js'; import { SPEAK_TO_USER_TOOL_NAME, SpeakToUserTool, -} from '../../serve/live/live-speak-to-user.js'; +} from '../live/live-speak-to-user.js'; import { LIVE_BACKEND_END_INSTRUCTIONS, LIVE_BACKEND_START_INSTRUCTIONS, -} from '../../serve/live/live-backend-instructions.js'; +} from '../live/live-backend-instructions.js'; import { readVoiceModel } from '../../services/voice-settings.js'; import { MAX_AUDIO_BYTES, diff --git a/packages/cli/src/serve/conversations/session-source.test.ts b/packages/cli/src/runtime/live-session-source.test.ts similarity index 99% rename from packages/cli/src/serve/conversations/session-source.test.ts rename to packages/cli/src/runtime/live-session-source.test.ts index f1e5ca33caa..b27e0dfa77f 100644 --- a/packages/cli/src/serve/conversations/session-source.test.ts +++ b/packages/cli/src/runtime/live-session-source.test.ts @@ -13,7 +13,7 @@ import { readLoadableLiveConversationMetadata, type ConversationSessionMetadataStore, type LiveSessionCreationMetadata, -} from './session-source.js'; +} from './live-session-source.js'; const LIVE_ID = '550e8400-e29b-41d4-a716-446655440000'; const LIVE_CHILD_ID = '550e8400-e29b-41d4-a716-446655440001'; diff --git a/packages/cli/src/serve/conversations/session-source.ts b/packages/cli/src/runtime/live-session-source.ts similarity index 99% rename from packages/cli/src/serve/conversations/session-source.ts rename to packages/cli/src/runtime/live-session-source.ts index cae870144a0..28595f799ed 100644 --- a/packages/cli/src/serve/conversations/session-source.ts +++ b/packages/cli/src/runtime/live-session-source.ts @@ -7,7 +7,7 @@ import { isValidSessionId, normalizeSessionIdForLookup, -} from '../../config/session-id.js'; +} from '../config/session-id.js'; export const LIVE_SESSION_SOURCE_PREFIX = 'realtime_voice:'; export const STANDALONE_SESSION_SOURCE_TYPE = 'standalone'; diff --git a/packages/cli/src/serve/acp-http/dispatch.ts b/packages/cli/src/serve/acp-http/dispatch.ts index 461cacfabd7..876400f2986 100644 --- a/packages/cli/src/serve/acp-http/dispatch.ts +++ b/packages/cli/src/serve/acp-http/dispatch.ts @@ -59,7 +59,7 @@ import { isReservedLiveSessionSource, isReservedStandaloneSessionSource, readLoadableLiveConversationMetadata, -} from '../conversations/session-source.js'; +} from '../../runtime/live-session-source.js'; import { translateAndCheckAbsoluteWorkspacePath, canonicalizeWorkspace, diff --git a/packages/cli/src/serve/live/live-session-coordinator.ts b/packages/cli/src/serve/live/live-session-coordinator.ts index 566f1c46b2f..7f566002400 100644 --- a/packages/cli/src/serve/live/live-session-coordinator.ts +++ b/packages/cli/src/serve/live/live-session-coordinator.ts @@ -42,10 +42,10 @@ import type { LiveProviderCredential } from './provider-credentials.js'; import { isCompatibleLiveSessionSource, LIVE_SESSION_SOURCE_PREFIX, -} from '../conversations/session-source.js'; +} from '../../runtime/live-session-source.js'; import type { LiveProviderReadiness, LiveSessionLocator } from './types.js'; -export { LIVE_SESSION_SOURCE_PREFIX } from '../conversations/session-source.js'; +export { LIVE_SESSION_SOURCE_PREFIX } from '../../runtime/live-session-source.js'; const MAX_COORDINATOR_REQUEST_CHARS = 32_000; const MAX_COORDINATOR_RESULT_CHARS = 48_000; diff --git a/packages/cli/src/serve/live/live-task-service.test.ts b/packages/cli/src/serve/live/live-task-service.test.ts index f955dce0bf7..43787c968f7 100644 --- a/packages/cli/src/serve/live/live-task-service.test.ts +++ b/packages/cli/src/serve/live/live-task-service.test.ts @@ -17,7 +17,7 @@ import type { WorkspaceRuntime, } from '../workspace-registry.js'; import { isLiveTaskToolName, LiveTaskService } from './live-task-service.js'; -import { LIVE_SESSION_SOURCE_PREFIX } from '../conversations/session-source.js'; +import { LIVE_SESSION_SOURCE_PREFIX } from '../../runtime/live-session-source.js'; const persistedSessions = vi.hoisted(() => new Map()); const persistedSessionOwners = vi.hoisted(() => new Map()); diff --git a/packages/cli/src/serve/live/live-task-service.ts b/packages/cli/src/serve/live/live-task-service.ts index 3b4364f9abe..f39209dfd85 100644 --- a/packages/cli/src/serve/live/live-task-service.ts +++ b/packages/cli/src/serve/live/live-task-service.ts @@ -39,7 +39,7 @@ import { laterActivityTimestamp } from '../server/activity-timestamp.js'; import { isCompatibleLiveSessionSource, readLoadableLiveConversationMetadata, -} from '../conversations/session-source.js'; +} from '../../runtime/live-session-source.js'; import { conversationRuntimeUnavailableError } from '../conversations/conversation-runtime-errors.js'; const DEFAULT_LIST_LIMIT = 20; diff --git a/packages/cli/src/serve/multi-workspace-sessions.test.ts b/packages/cli/src/serve/multi-workspace-sessions.test.ts index 3444c9af91a..e9744ef088d 100644 --- a/packages/cli/src/serve/multi-workspace-sessions.test.ts +++ b/packages/cli/src/serve/multi-workspace-sessions.test.ts @@ -40,7 +40,7 @@ import { } from './workspace-registry.js'; import type { WorkspaceRuntimeProvenance } from './managed-scratch-workspace.js'; import type { ConversationWorkspace } from './conversations/conversation-workspace.js'; -import { LIVE_SESSION_SOURCE_PREFIX } from './conversations/session-source.js'; +import { LIVE_SESSION_SOURCE_PREFIX } from '../runtime/live-session-source.js'; import { createSessionOrganizationService } from './session-organization-helpers.js'; import { serializeWorkspaceTranscriptResponseForTesting, diff --git a/packages/cli/src/serve/routes/session.ts b/packages/cli/src/serve/routes/session.ts index 010253e727b..d72d03dd31e 100644 --- a/packages/cli/src/serve/routes/session.ts +++ b/packages/cli/src/serve/routes/session.ts @@ -43,7 +43,7 @@ import { isReservedLiveSessionSource, isReservedStandaloneSessionSource, readLoadableLiveConversationMetadata, -} from '../conversations/session-source.js'; +} from '../../runtime/live-session-source.js'; import type { ConversationRuntimeActivityGate } from '../conversations/conversation-runtime-activity.js'; import { ConversationRuntimeOwnershipError } from '../conversations/conversation-runtime-errors.js'; import express, { diff --git a/scripts/tests/acp-serve-boundary-guard.test.js b/scripts/tests/acp-serve-boundary-guard.test.js new file mode 100644 index 00000000000..b8fbb05b6b2 --- /dev/null +++ b/scripts/tests/acp-serve-boundary-guard.test.js @@ -0,0 +1,103 @@ +/** + * @license + * Copyright 2026 Qwen Team + * SPDX-License-Identifier: Apache-2.0 + */ + +import { dirname, join } from 'node:path'; +import { fileURLToPath } from 'node:url'; +import { ESLint } from 'eslint'; +import { expect, it } from 'vitest'; + +const root = join(dirname(fileURLToPath(import.meta.url)), '../..'); + +const eslint = new ESLint({ cwd: root }); + +// Static imports are reported by no-restricted-imports; dynamic import() is +// reported by no-restricted-syntax because the former never sees it. +const BOUNDARY_RULES = new Set([ + 'no-restricted-imports', + 'no-restricted-syntax', +]); + +async function restrictedReports(statement) { + const filePath = join( + root, + 'packages/cli/src/acp-integration/boundary-probe.ts', + ); + const [result] = await eslint.lintText(`${statement}\n`, { filePath }); + return result.messages.filter((m) => BOUNDARY_RULES.has(m.ruleId)); +} + +// Bare-directory specifiers resolve to packages/cli/src/serve/index.ts, a +// barrel re-exporting the full daemon surface — they must be caught by the +// same guard that blocks deep serve/ internals (#8084). +it.each(['../serve', '../../serve'])( + 'blocks the bare barrel specifier %s from acp-integration', + async (specifier) => { + const reports = await restrictedReports( + `import { createServeApp } from '${specifier}';`, + ); + expect(reports).toHaveLength(1); + expect(reports[0].message).toContain('acp-integration'); + }, +); + +it('blocks a bare barrel re-export from acp-integration', async () => { + const reports = await restrictedReports( + `export { createServeApp } from '../serve';`, + ); + expect(reports).toHaveLength(1); +}); + +it('still blocks deep serve/ internals from acp-integration', async () => { + const reports = await restrictedReports( + `import { createServeApp } from '../serve/index.js';`, + ); + expect(reports).toHaveLength(1); +}); + +it('blocks type-only imports and re-exports from serve/ from acp-integration', async () => { + expect( + await restrictedReports(`import type { ServeAppDeps } from '../serve';`), + ).toHaveLength(1); + expect( + await restrictedReports(`export type { ServeAppDeps } from '../serve';`), + ).toHaveLength(1); +}); + +it('blocks a dynamic import() of serve/ from acp-integration', async () => { + const reports = await restrictedReports( + `async function probe() { await import('../serve/index.js'); }`, + ); + expect(reports).toHaveLength(1); + expect(reports[0].message).toContain('acp-integration'); +}); + +it('blocks a case-variant dynamic import() of Serve/ from acp-integration', async () => { + const reports = await restrictedReports( + `async function probe() { await import('../Serve/index.js'); }`, + ); + expect(reports).toHaveLength(1); +}); + +it('allows neutral runtime/ contracts from acp-integration', async () => { + expect( + await restrictedReports( + `import { something } from '../runtime/contracts.js';`, + ), + ).toHaveLength(0); + expect( + await restrictedReports( + `async function probe() { await import('../runtime/contracts.js'); }`, + ), + ).toHaveLength(0); + // A computed specifier (not a string literal) has no source.value, so the + // dynamic guard must not reject it — the import target is unknowable at + // lint time. + expect( + await restrictedReports( + `async function probe() { const target = '../runtime/contracts.js'; await import(target); }`, + ), + ).toHaveLength(0); +}); diff --git a/scripts/tests/cross-package-contracts.test.js b/scripts/tests/cross-package-contracts.test.js index e9a47fe80a8..ce3afc86f8a 100644 --- a/scripts/tests/cross-package-contracts.test.js +++ b/scripts/tests/cross-package-contracts.test.js @@ -55,7 +55,7 @@ const imports = [ ], [ 'LIVE_TASK_TOOL_NAMES', - 'packages/cli/src/serve/live/live-task-tools.ts', + 'packages/cli/src/acp-integration/live/live-task-tools.ts', '@qwen-code/acp-bridge/bridgeOptions', ], [ @@ -65,7 +65,7 @@ const imports = [ ], [ 'LiveTaskToolName', - 'packages/cli/src/serve/live/live-task-tools.ts', + 'packages/cli/src/acp-integration/live/live-task-tools.ts', '@qwen-code/acp-bridge/bridgeOptions', ], [