Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -591,7 +591,7 @@ jobs:
QWEN_DEFAULT_AUTH_TYPE: ''
run: |-
mkdir -p "${HOME}" "${QWEN_HOME}"
npx tsc -p integration-tests/tsconfig.json --pretty false
npm run typecheck:integration
npm run test:integration:no-ak:sandbox:none

- name: 'Publish Test Report (for non-forks)'
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -114,7 +114,7 @@ key entirely (no `null`), matching the readable output.
Unreachable in normal operation (per-tool/global layers bound every result at
or below its declared budget). Covered deterministically by unit tests:

- `packages/core/src/utils/tool-result-retention.test.ts` (19 tests): counts,
- `packages/core/src/tools/tool-result-retention.test.ts` (19 tests): counts,
max, raw-char measurement of newline-dense outputs, strict `>` boundary
at 2x budget + slack, sentinel skip (truncation prefix and
`<persisted-output>` stubs on both `output` and `error` keys), per-tool
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -151,7 +151,7 @@ The outbound check happens after `JSON.stringify` and UTF-8 encoding. It prevent

**Session load and export are capped asymmetrically.** `packages/cli/src/serve/server/session-export.ts:83-108` passes a byte cap on the archived branch and calls `loadSession()` with none on the active branch — the same uncapped path used by daemon load and resume. The archived cap is 256 MB of JSONL, which parses to one to two gigabytes of objects, so neither branch is a real bound. `session-transcript-reader.ts` is the correct model and is already present.

**Workspace-supplied config files are read without a size gate.** `fs.readFileSync(path, 'utf-8')` on workspace `.qwen/settings.json` (`packages/cli/src/config/settings.ts:557,733`), trusted folders, the serve fast path (synchronous, so it also blocks the event loop), and every discovered `QWEN.md`, twenty concurrently (`packages/core/src/utils/memoryDiscovery.ts:225,245`). Registering a workspace containing a two-gigabyte `settings.json` exhausts the daemon with no session, no prompt, and no agent — the cheapest attack in the set, and the one furthest from anything a heap ledger would notice.
**Workspace-supplied config files are read without a size gate.** `fs.readFileSync(path, 'utf-8')` on workspace `.qwen/settings.json` (`packages/cli/src/config/settings.ts:557,733`), trusted folders, the serve fast path (synchronous, so it also blocks the event loop), and every discovered `QWEN.md`, twenty concurrently (`packages/core/src/memory/memoryDiscovery.ts:225,245`). Registering a workspace containing a two-gigabyte `settings.json` exhausts the daemon with no session, no prompt, and no agent — the cheapest attack in the set, and the one furthest from anything a heap ledger would notice.

**ACP HTTP pre-attach buffers are the next bounded-container increment.** Connection and session replies are serialized once at production time and retained only as UTF-8 `Buffer`s. Each stream owns at most 256 buffered frames, each logical connection owns at most 1,024 frames and 64 MiB, and one process-global budget shared by primary and dynamic workspace registries owns at most 4,096 frames and 256 MiB. Attach transfers a lease to pending delivery; it is released only after the SSE write chain or WebSocket send callback settles. Count or byte overflow does not evict an older frame: it retires the exact session, or the whole logical connection when the queue is connection-scoped or shares a WebSocket. Fresh and newly attached session ownership remains provisional until the granting response is locally delivered, so teardown or overflow can roll back every definitively undelivered grant without exposing a session the client never learned it owned. If SSE accepts a complete ownership-granting frame but closes before its final write callback, the outcome is unknown and the daemon preserves the session rather than deleting it: a live logical connection conservatively commits ownership, while connection teardown detaches the client but leaves persisted state available for resume. Server response serialization failures are contained to the offending frame instead of being classified as resource exhaustion for the whole connection. Existing live SSE and WebSocket frames, and transient single-frame serialization amplification, remain separate container work.

Expand Down
2 changes: 1 addition & 1 deletion docs/design/yaml-parser-replacement.md
Original file line number Diff line number Diff line change
Expand Up @@ -340,7 +340,7 @@ inputs. Document this as a deliberate guardrail in a one-line comment.
| `packages/core/src/index.ts:360` | re-exports `*` from yaml-parser | yes — same names |
| `packages/core/src/subagents/subagent-manager.ts:15` | `parse`, `stringify` | yes |
| `packages/core/src/extension/claude-converter.ts:26` | `parse`, `stringify` | yes — round-trip is now safe for `mcpServers` + `hooks` (see Phase 3) |
| `packages/core/src/utils/rulesDiscovery.ts:20` | `parse as parseYaml` | yes |
| `packages/core/src/config/rulesDiscovery.ts:20` | `parse as parseYaml` | yes |
| `packages/core/src/skills/skill-manager.ts:13` | `parse as parseYaml` (and `import * as yaml from 'yaml'` separately) | yes — and the duplicate `import * as yaml` can be removed in a follow-up |
| `packages/core/src/skills/skill-load.ts:11` | `parse as parseYaml` | yes |

Expand Down
2 changes: 1 addition & 1 deletion esbuild.config.js
Original file line number Diff line number Diff line change
Expand Up @@ -129,7 +129,7 @@ const syncFileEncodingTreeShakePlugin = {
name: 'sync-file-encoding-tree-shake',
setup(build) {
build.onResolve(
{ filter: /^\.\/utils\/sync-file-encoding\.js$/ },
{ filter: /^\.\/services\/sync-file-encoding\.js$/ },
(args) => {
if (
!/[\\/]packages[\\/]core[\\/](?:src|dist[\\/]src)[\\/]index\.(?:ts|js)$/.test(
Expand Down
203 changes: 203 additions & 0 deletions eslint-rules/no-core-utils-upward-import.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,203 @@
/**
* @license
* Copyright 2026 Qwen Team
* SPDX-License-Identifier: Apache-2.0
*/

/**
* @fileoverview Disallows runtime (value) imports from `packages/core/src/utils/`
* production modules into modules outside the utils/ directory. Type-only
* imports are permitted because they are erased at compile time and therefore
* introduce no runtime upward dependency.
*
* The goal is a leaf utils/ layer: every runtime dependency of a utils module
* must be a sibling utils module (or an external/npm package). A small
* allowlist carries the deferred inversions that cannot be leafed without a
* larger refactor (`Storage` and `getTraceContext` are stateful and live
* behind `debugLogger`).
*/

import { readFileSync } from 'node:fs';
import path from 'node:path';
import { URL } from 'node:url';

const CORE_SRC_MARKER = 'packages/core/src/';
const UTILS_SRC_MARKER = 'packages/core/src/utils/';
const CORE_PACKAGE_SPECIFIER = '@qwen-code/qwen-code-core';
const CORE_PACKAGE_SRC_PREFIX = `${CORE_PACKAGE_SPECIFIER}/src/`;
const CORE_PACKAGE_DIST_PREFIX = `${CORE_PACKAGE_SPECIFIER}/dist/`;
const CORE_PACKAGE_SUBPATH_PREFIX = `${CORE_PACKAGE_SPECIFIER}/`;

// Resolve named self-reference subpaths from the package contract so this
// boundary cannot drift from packages/core/package.json.
const CORE_PACKAGE_EXPORTS = JSON.parse(
readFileSync(
new URL('../packages/core/package.json', import.meta.url),
'utf8',
),
).exports;

// Deferred inversions, keyed by the utils-relative importer. Targets are
// relative to packages/core/src and omit their extension. See the file header
// for why these are tolerated rather than moved.
const ALLOWED_UPWARD_IMPORTS = new Map([
['debugLogger.ts', new Set(['config/storage', 'telemetry/trace-context'])],
]);

function isUtilsProductionFile(filename) {
if (!filename || filename === '<input>' || filename === '<text>') {
return false;
}
const normalized = path.normalize(filename).replaceAll('\\', '/');
const start = normalized.lastIndexOf(UTILS_SRC_MARKER);
if (start < 0) return false;
const relativePath = normalized.slice(start + UTILS_SRC_MARKER.length);
return !/\.(test|spec)\.[cm]?[jt]sx?$/.test(relativePath);
}

function coreSrcAbs(filename) {
const normalized = path.normalize(filename).replaceAll('\\', '/');
const start = normalized.lastIndexOf(CORE_SRC_MARKER);
if (start < 0) return null;
return path.resolve(normalized.slice(0, start + CORE_SRC_MARKER.length));
}

function stripExtension(rel) {
return rel.replace(/\.(js|ts|tsx|mjs|cjs)$/, '');
}

function corePackageSourcePath(importedPath) {
if (importedPath.startsWith(CORE_PACKAGE_SRC_PREFIX)) {
return importedPath.slice(CORE_PACKAGE_SRC_PREFIX.length);
}

if (importedPath.startsWith(CORE_PACKAGE_DIST_PREFIX)) {
const distRelative = importedPath.slice(CORE_PACKAGE_DIST_PREFIX.length);
return distRelative.startsWith('src/')
? distRelative.slice('src/'.length)
: distRelative;
}

if (!importedPath.startsWith(CORE_PACKAGE_SUBPATH_PREFIX)) {
return null;
}

const exportKey = `./${importedPath.slice(CORE_PACKAGE_SUBPATH_PREFIX.length)}`;
const exportEntry = CORE_PACKAGE_EXPORTS[exportKey];
const exportTarget =
typeof exportEntry === 'string' ? exportEntry : exportEntry?.import;
if (typeof exportTarget !== 'string') {
return null;
}
if (exportTarget.startsWith('./dist/src/')) {
return exportTarget.slice('./dist/src/'.length);
}
if (exportTarget.startsWith('./src/')) {
return exportTarget.slice('./src/'.length);
}
return null;
}

export default {
meta: {
type: 'problem',
docs: {
description: 'Disallow runtime upward imports from core utils modules.',
},
schema: [],
messages: {
noCoreUtilsUpwardImport:
"Core utils module '{{file}}' imports runtime value '{{importedPath}}' from outside utils/. Move the value into utils/ (or re-export it from its owner module) so utils/ stays a leaf layer. Type-only imports are allowed.",
},
},

create(context) {
const filename = context.filename;
if (!isUtilsProductionFile(filename)) {
return {};
}

const srcRoot = coreSrcAbs(filename);
if (!srcRoot) {
return {};
}
const utilsRoot = path.join(srcRoot, 'utils');
const importer = path
.relative(utilsRoot, path.resolve(filename))
.replaceAll('\\', '/');

function reportIfUpward(sourceNode, importedPath) {
if (typeof importedPath !== 'string') {
return;
}
let resolved;
if (importedPath.startsWith('.')) {
resolved = path.resolve(path.dirname(filename), importedPath);
} else {
const sourcePath = corePackageSourcePath(importedPath);
if (!sourcePath) {
return;
}
resolved = path.resolve(srcRoot, sourcePath);
}

// Leave cross-package relative imports to no-relative-cross-package-imports.
const relToCore = path.relative(srcRoot, resolved).replaceAll('\\', '/');
if (relToCore.startsWith('..') || path.isAbsolute(relToCore)) {
return;
}

if (
ALLOWED_UPWARD_IMPORTS.get(importer)?.has(stripExtension(relToCore))
) {
return;
}

const relToUtils = path.relative(utilsRoot, resolved);
if (relToUtils.startsWith('..') || path.isAbsolute(relToUtils)) {
context.report({
node: sourceNode,
messageId: 'noCoreUtilsUpwardImport',
data: {
file: path.relative(utilsRoot, filename),
importedPath,
},
});
}
}

function checkSource(node) {
if (node.source && typeof node.source.value === 'string') {
reportIfUpward(node.source, node.source.value);
}
}

function checkDynamicImport(node) {
const source = node.source;
if (source.type === 'Literal') {
reportIfUpward(source, source.value);
} else if (
source.type === 'TemplateLiteral' &&
source.quasis.length === 1
) {
reportIfUpward(source, source.quasis[0].value.cooked);
}
}

return {
ImportDeclaration(node) {
if (node.importKind === 'type') return;
checkSource(node);
},
ExportNamedDeclaration(node) {
if (node.exportKind === 'type') return;
checkSource(node);
},
ExportAllDeclaration(node) {
if (node.exportKind === 'type') return;
checkSource(node);
},
ImportExpression: checkDynamicImport,
};
},
};
11 changes: 9 additions & 2 deletions eslint.config.js
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,7 @@ import storybook from 'eslint-plugin-storybook';
import checkFile from 'eslint-plugin-check-file';
import noCoreRootBarrelImport from './eslint-rules/no-core-root-barrel-import.js';
import noUtilsUpwardImport from './eslint-rules/no-utils-upward-import.js';
import noCoreUtilsUpwardImport from './eslint-rules/no-core-utils-upward-import.js';
import { legacyFilenames } from './eslint.legacy-filenames.mjs';

// General syntax restrictions applied to every TS/TSX source file. Hoisted so
Expand Down Expand Up @@ -221,15 +222,21 @@ export default tseslint.config(
},
{
// The rule itself exempts tests, __tests__, and fixtures; repeating that
// here would give the exemption two sources of truth.
// here would give the exemption two sources of truth. The utils-upward
// rule self-scopes to packages/core/src/utils production files, so it can
// share this block without redefining the architecture plugin.
files: ['packages/core/src/**/*.{ts,tsx}'],
plugins: {
architecture: {
rules: { 'no-core-root-barrel-import': noCoreRootBarrelImport },
rules: {
'no-core-root-barrel-import': noCoreRootBarrelImport,
'no-core-utils-upward-import': noCoreUtilsUpwardImport,
},
},
},
rules: {
'architecture/no-core-root-barrel-import': 'error',
'architecture/no-core-utils-upward-import': 'error',
},
},
{
Expand Down
3 changes: 1 addition & 2 deletions integration-tests/globalSetup.ts
Original file line number Diff line number Diff line change
Expand Up @@ -20,8 +20,7 @@ import {
import { join, dirname } from 'node:path';
import { fileURLToPath } from 'node:url';

import { DEFAULT_CONTEXT_FILENAME } from '@qwen-code/qwen-code-core/src/memory/const.js';
import { Storage } from '@qwen-code/qwen-code-core/src/config/storage.js';
import { DEFAULT_CONTEXT_FILENAME, Storage } from '@qwen-code/qwen-code-core';

const __dirname = dirname(fileURLToPath(import.meta.url));
const rootDir = join(__dirname, '..');
Expand Down
3 changes: 2 additions & 1 deletion package.json
Original file line number Diff line number Diff line change
Expand Up @@ -71,7 +71,8 @@
"lint:all": "node scripts/lint.js",
"audit:runtime:critical": "node scripts/audit-runtime-critical.js",
"format": "prettier --experimental-cli --write .",
"typecheck": "npm run typecheck --workspaces --if-present",
"typecheck": "npm run typecheck --workspaces --if-present && npm run typecheck:integration",
"typecheck:integration": "tsc -p integration-tests/tsconfig.json --pretty false",
"typecheck:sdk:python": "python3 -m mypy --config-file packages/sdk-python/pyproject.toml packages/sdk-python/src",
"smoke:sdk:python": "python3 packages/sdk-python/scripts/smoke_real.py",
"build:sdk:python": "python3 -m build packages/sdk-python",
Expand Down
2 changes: 1 addition & 1 deletion packages/core/src/agents/background-agent-resume.ts
Original file line number Diff line number Diff line change
Expand Up @@ -35,7 +35,7 @@ import {
buildDeferredToolsReminder,
buildMcpServerInstructionsReminder,
getInitialChatHistory,
} from '../utils/environmentContext.js';
} from '../core/environmentContext.js';
import { runWithInvocationContext } from '../utils/invocation-context.js';
import { PermissionMode, type StopHookOutput } from '../hooks/types.js';
import {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -7,16 +7,13 @@
import { describe, it, expect, beforeEach, vi } from 'vitest';
import type { Config } from '../config/config.js';
import { Config as ConfigImpl, ApprovalMode } from '../config/config.js';
import { AgentHeadless } from '../agents/runtime/agent-headless.js';
import { AgentHeadless } from './runtime/agent-headless.js';
import {
AgentEventType,
type AgentEventEmitter,
} from '../agents/runtime/agent-events.js';
import { AgentTerminateMode } from '../agents/runtime/agent-types.js';
import type {
ModelConfig,
PromptConfig,
} from '../agents/runtime/agent-types.js';
} from './runtime/agent-events.js';
import { AgentTerminateMode } from './runtime/agent-types.js';
import type { ModelConfig, PromptConfig } from './runtime/agent-types.js';
import { runForkedAgent } from './forkedAgent.js';
import { ToolNames } from '../tools/tool-names.js';
import { EditTool } from '../tools/edit.js';
Expand All @@ -25,7 +22,7 @@ import {
TOOL_REGISTRY_REBUILT,
} from '../tools/agent/agent.js';
import { AuthType } from '../core/contentGenerator.js';
import type { RuntimeContentGeneratorView } from '../agents/runtime/agent-context.js';
import type { RuntimeContentGeneratorView } from './runtime/agent-context.js';
import { createRuntimeContentGeneratorView } from '../models/content-generator-config.js';

vi.mock('../models/content-generator-config.js', async (importOriginal) => {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -17,7 +17,7 @@ import type { Config } from '../config/config.js';
import { AuthType } from '../core/contentGenerator.js';
import { GeminiChat, StreamEventType } from '../core/geminiChat.js';
import { createRuntimeContentGeneratorView } from '../models/content-generator-config.js';
import type { RuntimeContentGeneratorView } from '../agents/runtime/agent-context.js';
import type { RuntimeContentGeneratorView } from './runtime/agent-context.js';

vi.mock('../core/geminiChat.js', async (importOriginal) => {
const actual = await importOriginal<typeof import('../core/geminiChat.js')>();
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -38,12 +38,12 @@ import type {
import {
runWithRuntimeContentGenerator,
type RuntimeContentGeneratorView,
} from '../agents/runtime/agent-context.js';
} from './runtime/agent-context.js';
import { ApprovalMode, type Config } from '../config/config.js';
import { GeminiChat, StreamEventType } from '../core/geminiChat.js';
import { createRuntimeContentGeneratorView } from '../models/content-generator-config.js';
import { createApprovalModeOverride } from '../tools/agent/agent.js';
import { createDebugLogger } from './debugLogger.js';
import { createDebugLogger } from '../utils/debugLogger.js';
import {
AgentHeadless,
AgentEventEmitter,
Expand All @@ -54,16 +54,16 @@ import {
type PromptConfig,
type RunConfig,
type ToolConfig,
} from '../agents/index.js';
import { toModelVisibleSubagentResult } from '../agents/subagent-result.js';
} from './index.js';
import { toModelVisibleSubagentResult } from './subagent-result.js';
import {
buildModelIdContext,
resolveModelId,
type ResolvedModelId,
} from './modelId.js';
} from '../utils/modelId.js';
import { ToolNames } from '../tools/tool-names.js';
import { getFunctionResponseParts } from '../services/compactionInputSlimming.js';
import { runWithChatRecordingSuppressed } from './chat-recording-suppression-context.js';
import { runWithChatRecordingSuppressed } from '../utils/chat-recording-suppression-context.js';

const debugLogger = createDebugLogger('FORKED_AGENT');

Expand Down
Loading
Loading