Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
23 commits
Select commit Hold shift + click to select a range
ff63f26
fix(core): honor usage-statistics opt-out for extension lifecycle events
yiliang114 Sep 26, 2026
d30c301
test(cli): allow the extension proxy env fallback in the serve guard
yiliang114 Sep 26, 2026
40f203c
fix(core): never let throwaway telemetry Config abort an extension mu…
yiliang114 Sep 26, 2026
152d708
Merge branch 'main' into fix/issue-12770-telemetry-optout
yiliang114 Sep 27, 2026
93529a0
fix(cli): keep daemon extension telemetry inside the owning workspace
yiliang114 Sep 27, 2026
c5648ef
fix(core,cli): keep extension telemetry off process-global proxy and …
yiliang114 Sep 27, 2026
0bb64d7
Merge remote-tracking branch 'origin/main' into fix/issue-12770-telem…
yiliang114 Sep 27, 2026
9e855ca
Merge branch 'fix/issue-12770-telemetry-optout' into r1 repair
yiliang114 Sep 27, 2026
a79fd7f
fix(core): honor NO_PROXY for usage-statistics RUM uploads
yiliang114 Sep 27, 2026
38bd5f6
Merge remote-tracking branch 'origin/main' into fix/issue-12770-telem…
yiliang114 Sep 27, 2026
d6f637c
fix(cli): restore paired workspace-control liveness wiring dropped by…
yiliang114 Sep 27, 2026
e771234
fix(cli): resolve extension telemetry consent and proxy per runtime
yiliang114 Sep 27, 2026
6981ff3
fix(cli): gate extension status locale and preserve corruption markers
yiliang114 Sep 27, 2026
2ad8b46
Merge remote-tracking branch 'origin/main' into fix/issue-12770-telem…
yiliang114 Sep 29, 2026
e2f1528
test(cli): pin that an untrusted workspace cannot pick the status locale
yiliang114 Sep 29, 2026
8d6a328
fix(cli): bind extension telemetry env to its workspace, stop probing…
yiliang114 Sep 29, 2026
4e30880
Merge remote-tracking branch 'origin/main' into fix/issue-12770-telem…
yiliang114 Sep 29, 2026
a740058
Merge remote-tracking branch 'origin/main' into fix/issue-12770-telem…
yiliang114 Sep 29, 2026
7d002fe
refactor(cli,core): split status-route hardening and NO_PROXY into th…
yiliang114 Sep 29, 2026
61bf531
Merge origin/main into fix/issue-12770-telemetry-optout
yiliang114 Sep 29, 2026
33c78c3
fix(serve): refuse an ambient usage-statistics opt-in a workspace did…
yiliang114 Sep 29, 2026
8592e0d
fix(cli): register the serve ambient usage-statistics opt-out with th…
yiliang114 Sep 30, 2026
24433f2
Merge branch 'main' into fix/issue-12770-telemetry-optout
yiliang114 Sep 30, 2026
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
52 changes: 52 additions & 0 deletions packages/cli/src/commands/extensions/install.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,15 @@ const mockIsWorkspaceTrusted = vi.hoisted(() => vi.fn());
const mockLoadSettings = vi.hoisted(() => vi.fn());
const mockWriteStdoutLine = vi.hoisted(() => vi.fn());
const mockWriteStderrLine = vi.hoisted(() => vi.fn());
// Recording pass-throughs rather than inline arrows: the stub keeps the
// settings term as the whole story for this file, so the assertions below pin
// that `install.ts` still routes through the resolvers at all.
const mockResolveUsageStatisticsEnabled = vi.hoisted(() =>
vi.fn((settingsValue?: boolean) => settingsValue ?? true),
);
const mockResolveExtensionTelemetryProxy = vi.hoisted(() =>
vi.fn((settingsProxy?: string) => settingsProxy),
);

vi.mock('@qwen-code/qwen-code-core', () => ({
ExtensionManager: vi.fn().mockImplementation(() => ({
Expand All @@ -26,6 +35,8 @@ vi.mock('@qwen-code/qwen-code-core', () => ({
setExtensionScope: mockSetExtensionScope,
})),
parseInstallSource: mockParseInstallSource,
resolveUsageStatisticsEnabled: mockResolveUsageStatisticsEnabled,
resolveExtensionTelemetryProxy: mockResolveExtensionTelemetryProxy,
isExtensionCommittedWithWarningsError: (error: unknown) =>
error instanceof Error &&
(error as Error & { code?: string; committed?: boolean }).code ===
Expand Down Expand Up @@ -108,6 +119,47 @@ describe('handleInstall', () => {
processSpy.mockRestore();
});

it('forwards the resolved telemetry opt-out and proxy to the ExtensionManager', async () => {
const processSpy = vi
.spyOn(process, 'exit')
.mockImplementation(() => undefined as never);
const { ExtensionManager } = await import('@qwen-code/qwen-code-core');
mockLoadSettings.mockReturnValue({
merged: {
privacy: { usageStatisticsEnabled: false },
proxy: 'http://settings-proxy:8080',
},
});
mockParseInstallSource.mockResolvedValue({
type: 'http',
url: 'http://google.com',
});
mockInstallExtension.mockResolvedValue({ name: 'http-extension' });

await handleInstall({
source: 'http://google.com',
});

expect(ExtensionManager).toHaveBeenCalledWith(
expect.objectContaining({
usageStatisticsEnabled: false,
proxy: 'http://settings-proxy:8080',
}),
);
// The stubbed resolvers make the settings term the whole story above, so
// pin the routing itself: replacing the two calls in `install.ts` with raw
// `settings.privacy?.usageStatisticsEnabled ?? true` / `settings.proxy`
// reads must red here. The env-vs-settings precedence each resolver
// implements is pinned where the real ones run (core's `config.test.ts`,
// and `uninstall.test.ts` / `utils.test.ts` for the call-site env term).
expect(mockResolveUsageStatisticsEnabled).toHaveBeenCalledWith(false);
expect(mockResolveExtensionTelemetryProxy).toHaveBeenCalledWith(
'http://settings-proxy:8080',
);

processSpy.mockRestore();
});

it('should install an extension from a https source', async () => {
const processSpy = vi
.spyOn(process, 'exit')
Expand Down
10 changes: 8 additions & 2 deletions packages/cli/src/commands/extensions/install.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,8 @@ import {
ExtensionManager,
isExtensionCommittedWithWarningsError,
parseInstallSource,
resolveExtensionTelemetryProxy,
resolveUsageStatisticsEnabled,
type ExtensionScope,
} from '@qwen-code/qwen-code-core';
import { getErrorMessage } from '../../utils/errors.js';
Expand Down Expand Up @@ -85,13 +87,17 @@ export async function handleInstall(args: InstallArgs) {
? () => Promise.resolve()
: requestConsentOrFail.bind(null, requestConsentNonInteractive);
const workspaceDir = process.cwd();
const settings = loadSettings(workspaceDir).merged;
extensionManager = new ExtensionManager({
workspaceDir,
locale: getCurrentLanguage(),
isWorkspaceTrusted:
isWorkspaceTrusted(loadSettings(workspaceDir).merged).isTrusted ?? true,
isWorkspaceTrusted: isWorkspaceTrusted(settings).isTrusted ?? true,
requestConsent,
requestChoicePlugin: requestChoicePluginNonInteractive,
usageStatisticsEnabled: resolveUsageStatisticsEnabled(
settings.privacy?.usageStatisticsEnabled,
),
proxy: resolveExtensionTelemetryProxy(settings.proxy),
Comment thread
yiliang114 marked this conversation as resolved.
});
await extensionManager.refreshCache();

Expand Down
82 changes: 81 additions & 1 deletion packages/cli/src/commands/extensions/uninstall.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@ const mockUninstallExtension = vi.hoisted(() =>
);
const mockWriteStdoutLine = vi.hoisted(() => vi.fn());
const mockWriteStderrLine = vi.hoisted(() => vi.fn());
const mockLoadSettings = vi.hoisted(() => vi.fn());

vi.mock('@qwen-code/qwen-code-core', async (importOriginal) => {
const actual =
Expand All @@ -33,7 +34,7 @@ vi.mock('../../utils/stdioHelpers.js', () => ({
}));

vi.mock('../../config/settings.js', () => ({
loadSettings: vi.fn(() => ({ merged: {} })),
loadSettings: mockLoadSettings,
}));

vi.mock('../../config/trustedFolders.js', () => ({
Expand All @@ -45,6 +46,7 @@ describe('extensions uninstall command', () => {
vi.clearAllMocks();
mockRefreshCache.mockResolvedValue(undefined);
mockUninstallExtension.mockResolvedValue({ warnings: [] });
mockLoadSettings.mockReturnValue({ merged: {} });
});

it('should fail if no source is provided', () => {
Expand Down Expand Up @@ -76,4 +78,82 @@ describe('extensions uninstall command', () => {
'extension_preferences_cleanup_failed: cleanup failed',
);
});

it('forwards the resolved telemetry opt-out and proxy to the ExtensionManager', async () => {
const { ExtensionManager } = await import('@qwen-code/qwen-code-core');
mockLoadSettings.mockReturnValue({
merged: {
privacy: { usageStatisticsEnabled: false },
proxy: 'http://settings-proxy:8080',
},
});
// The real resolvers consult the ambient env; pin it out of the
// assertion so the test decides the outcome, not the runner's env.
const envKeys = [
'QWEN_USAGE_STATISTICS_ENABLED',
'HTTPS_PROXY',
'https_proxy',
'HTTP_PROXY',
'http_proxy',
];
const saved = envKeys.map(
(key) => [key, process.env[key]] as [string, string | undefined],
);
for (const key of envKeys) delete process.env[key];
try {
await handleUninstall({ name: 'test-extension' });

expect(ExtensionManager).toHaveBeenCalledWith(
expect.objectContaining({
usageStatisticsEnabled: false,
proxy: 'http://settings-proxy:8080',
}),
);
} finally {
for (const [key, value] of saved) {
if (value === undefined) delete process.env[key];
else process.env[key] = value;
}
}
});

it('resolves consent from the env term and the proxy from the env fallback at this call site', async () => {
const { ExtensionManager } = await import('@qwen-code/qwen-code-core');
// Settings alone would opt out and declare no proxy, so only the env terms
// can produce the expected values: replacing the two resolver calls in
// `uninstall.ts` with raw `settings.privacy?.usageStatisticsEnabled ??
// true` / `settings.proxy` reads reds this case. The winners are opposite
// on purpose — consent is env-then-settings, proxy settings-then-env.
mockLoadSettings.mockReturnValue({
merged: { privacy: { usageStatisticsEnabled: false } },
});
const envKeys = [
'QWEN_USAGE_STATISTICS_ENABLED',
'HTTPS_PROXY',
'https_proxy',
'HTTP_PROXY',
'http_proxy',
];
const saved = envKeys.map(
(key) => [key, process.env[key]] as [string, string | undefined],
);
for (const key of envKeys) delete process.env[key];
process.env['QWEN_USAGE_STATISTICS_ENABLED'] = 'true';
process.env['HTTPS_PROXY'] = 'http://env-proxy:3128';
try {
await handleUninstall({ name: 'test-extension' });

expect(ExtensionManager).toHaveBeenCalledWith(
expect.objectContaining({
usageStatisticsEnabled: true,
proxy: 'http://env-proxy:3128',
}),
);
} finally {
for (const [key, value] of saved) {
if (value === undefined) delete process.env[key];
else process.env[key] = value;
}
}
});
});
14 changes: 11 additions & 3 deletions packages/cli/src/commands/extensions/uninstall.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,11 @@
import type { CommandModule } from 'yargs';
import { getErrorMessage } from '../../utils/errors.js';
import { writeStdoutLine, writeStderrLine } from '../../utils/stdioHelpers.js';
import { ExtensionManager } from '@qwen-code/qwen-code-core';
import {
ExtensionManager,
resolveExtensionTelemetryProxy,
resolveUsageStatisticsEnabled,
} from '@qwen-code/qwen-code-core';
import {
requestConsentNonInteractive,
requestConsentOrFail,
Expand All @@ -23,15 +27,19 @@ interface UninstallArgs {
export async function handleUninstall(args: UninstallArgs) {
try {
const workspaceDir = process.cwd();
const settings = loadSettings(workspaceDir).merged;
const extensionManager = new ExtensionManager({
workspaceDir,
locale: getCurrentLanguage(),
requestConsent: requestConsentOrFail.bind(
null,
requestConsentNonInteractive,
),
isWorkspaceTrusted:
isWorkspaceTrusted(loadSettings(workspaceDir).merged).isTrusted ?? true,
isWorkspaceTrusted: isWorkspaceTrusted(settings).isTrusted ?? true,
usageStatisticsEnabled: resolveUsageStatisticsEnabled(
settings.privacy?.usageStatisticsEnabled,
),
proxy: resolveExtensionTelemetryProxy(settings.proxy),
});
await extensionManager.refreshCache();
const result = await extensionManager.uninstallExtension(args.name, false);
Expand Down
86 changes: 83 additions & 3 deletions packages/cli/src/commands/extensions/utils.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@ const mockRefreshCache = vi.fn();
const mockExtensionManagerInstance = {
refreshCache: mockRefreshCache,
};
const mockLoadSettings = vi.hoisted(() => vi.fn());

vi.mock('@qwen-code/qwen-code-core', async (importOriginal) => {
const actual =
Expand All @@ -31,9 +32,7 @@ vi.mock('../../config/settings.js', () => ({
System: 'System',
SystemDefaults: 'SystemDefaults',
},
loadSettings: vi.fn().mockReturnValue({
merged: {},
}),
loadSettings: mockLoadSettings,
}));

vi.mock('../../config/trustedFolders.js', () => ({
Expand All @@ -50,6 +49,7 @@ describe('getExtensionManager', () => {
beforeEach(() => {
vi.clearAllMocks();
mockRefreshCache.mockResolvedValue(undefined);
mockLoadSettings.mockReturnValue({ merged: {} });
});

it('should return an ExtensionManager instance', async () => {
Expand All @@ -76,6 +76,86 @@ describe('getExtensionManager', () => {
}),
);
});

it('forwards the resolved telemetry opt-out and proxy to the ExtensionManager', async () => {
const { ExtensionManager } = await import('@qwen-code/qwen-code-core');
mockLoadSettings.mockReturnValue({
merged: {
privacy: { usageStatisticsEnabled: false },
proxy: 'http://settings-proxy:8080',
},
});
// The real resolvers consult the ambient env; pin it out of the
// assertion so the test decides the outcome, not the runner's env.
const envKeys = [
'QWEN_USAGE_STATISTICS_ENABLED',
'HTTPS_PROXY',
'https_proxy',
'HTTP_PROXY',
'http_proxy',
];
const saved = envKeys.map(
(key) => [key, process.env[key]] as [string, string | undefined],
);
for (const key of envKeys) delete process.env[key];
try {
await getExtensionManager();

expect(ExtensionManager).toHaveBeenCalledWith(
expect.objectContaining({
usageStatisticsEnabled: false,
proxy: 'http://settings-proxy:8080',
}),
);
} finally {
for (const [key, value] of saved) {
if (value === undefined) delete process.env[key];
else process.env[key] = value;
}
}
});

it('resolves consent from the env term and the proxy from the env fallback at this call site', async () => {
const { ExtensionManager } = await import('@qwen-code/qwen-code-core');
// Settings alone would opt out and declare no proxy, so only the env terms
// can produce the expected values: replacing the two resolver calls in
// `utils.ts` with raw `settings.privacy?.usageStatisticsEnabled ?? true` /
// `settings.proxy` reads reds this case, which is what re-opens the
// `QwenLogger.getInstance` gate for enable/disable/link/update. The
// winners are opposite on purpose — consent is env-then-settings, proxy
// settings-then-env.
mockLoadSettings.mockReturnValue({
merged: { privacy: { usageStatisticsEnabled: false } },
});
const envKeys = [
'QWEN_USAGE_STATISTICS_ENABLED',
'HTTPS_PROXY',
'https_proxy',
'HTTP_PROXY',
'http_proxy',
];
const saved = envKeys.map(
(key) => [key, process.env[key]] as [string, string | undefined],
);
for (const key of envKeys) delete process.env[key];
process.env['QWEN_USAGE_STATISTICS_ENABLED'] = 'true';
process.env['HTTPS_PROXY'] = 'http://env-proxy:3128';
try {
await getExtensionManager();

expect(ExtensionManager).toHaveBeenCalledWith(
expect.objectContaining({
usageStatisticsEnabled: true,
proxy: 'http://env-proxy:3128',
}),
);
} finally {
for (const [key, value] of saved) {
if (value === undefined) delete process.env[key];
else process.env[key] = value;
}
}
});
});

describe('extensionToOutputString', () => {
Expand Down
10 changes: 8 additions & 2 deletions packages/cli/src/commands/extensions/utils.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,8 @@ import {
redactUrlCredentials,
getExtensionDisplayName,
getExtensionDescription,
resolveExtensionTelemetryProxy,
resolveUsageStatisticsEnabled,
type Extension,
} from '@qwen-code/qwen-code-core';
import { loadSettings, SettingScope } from '../../config/settings.js';
Expand All @@ -25,6 +27,7 @@ import { t, getCurrentLanguage } from '../../i18n/index.js';

export async function getExtensionManager(): Promise<ExtensionManager> {
const workspaceDir = process.cwd();
const settings = loadSettings(workspaceDir).merged;
const extensionManager = new ExtensionManager({
workspaceDir,
locale: getCurrentLanguage(),
Expand All @@ -33,8 +36,11 @@ export async function getExtensionManager(): Promise<ExtensionManager> {
requestConsentNonInteractive,
),
requestChoicePlugin: requestChoicePluginNonInteractive,
isWorkspaceTrusted:
isWorkspaceTrusted(loadSettings(workspaceDir).merged).isTrusted ?? true,
isWorkspaceTrusted: isWorkspaceTrusted(settings).isTrusted ?? true,
usageStatisticsEnabled: resolveUsageStatisticsEnabled(
settings.privacy?.usageStatisticsEnabled,
),
proxy: resolveExtensionTelemetryProxy(settings.proxy),
});
await extensionManager.refreshCache();
return extensionManager;
Expand Down
Loading
Loading