mirror of
https://github.com/QwenLM/qwen-code.git
synced 2026-01-13 04:19:15 +00:00
Compare commits
36 Commits
mingholy/f
...
feat/suppo
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
2f6b0b233a | ||
|
|
9a8ce605c5 | ||
|
|
afc693a4ab | ||
|
|
7173cba844 | ||
|
|
9b78c17638 | ||
|
|
bde31d1261 | ||
|
|
cba9c424eb | ||
|
|
6714f9ce3c | ||
|
|
155d1f9518 | ||
|
|
f776075aa8 | ||
|
|
36c142951a | ||
|
|
2b511d0b83 | ||
|
|
85bc0833b4 | ||
|
|
2662639280 | ||
|
|
b7ac94ecf6 | ||
|
|
be8259b218 | ||
|
|
ca4c36f233 | ||
|
|
f41308f34c | ||
|
|
0a33510304 | ||
|
|
82cbdee3b4 | ||
|
|
f6a753cf78 | ||
|
|
509d304742 | ||
|
|
6319a6ed56 | ||
|
|
ab07c2d89c | ||
|
|
0a0ab64da0 | ||
|
|
8a15017593 | ||
|
|
4d54a231b3 | ||
|
|
0f1cb162c9 | ||
|
|
3d059b71de | ||
|
|
87dc618a21 | ||
|
|
94a5d828bd | ||
|
|
fd41309ed2 | ||
|
|
48bc0f35d7 | ||
|
|
e30c2dbe23 | ||
|
|
e9204ecba9 | ||
|
|
f24bda3d7b |
@@ -49,6 +49,8 @@ Cross-platform sandboxing with complete process isolation.
|
||||
|
||||
By default, Qwen Code uses a published sandbox image (configured in the CLI package) and will pull it as needed.
|
||||
|
||||
The container sandbox mounts your workspace and your `~/.qwen` directory into the container so auth and settings persist between runs.
|
||||
|
||||
**Best for**: Strong isolation on any OS, consistent tooling inside a known image.
|
||||
|
||||
### Choosing a method
|
||||
@@ -157,7 +159,7 @@ For a working allowlist-style proxy example, see: [Example Proxy Script](/develo
|
||||
|
||||
## Linux UID/GID handling
|
||||
|
||||
The sandbox automatically handles user permissions on Linux. Override these permissions with:
|
||||
On Linux, Qwen Code defaults to enabling UID/GID mapping so the sandbox runs as your user (and reuses the mounted `~/.qwen`). Override with:
|
||||
|
||||
```bash
|
||||
export SANDBOX_SET_UID_GID=true # Force host UID/GID
|
||||
|
||||
12
package-lock.json
generated
12
package-lock.json
generated
@@ -1,12 +1,12 @@
|
||||
{
|
||||
"name": "@qwen-code/qwen-code",
|
||||
"version": "0.6.1",
|
||||
"version": "0.7.0",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "@qwen-code/qwen-code",
|
||||
"version": "0.6.1",
|
||||
"version": "0.7.0",
|
||||
"workspaces": [
|
||||
"packages/*"
|
||||
],
|
||||
@@ -17316,7 +17316,7 @@
|
||||
},
|
||||
"packages/cli": {
|
||||
"name": "@qwen-code/qwen-code",
|
||||
"version": "0.6.1",
|
||||
"version": "0.7.0",
|
||||
"dependencies": {
|
||||
"@google/genai": "1.30.0",
|
||||
"@iarna/toml": "^2.2.5",
|
||||
@@ -17953,7 +17953,7 @@
|
||||
},
|
||||
"packages/core": {
|
||||
"name": "@qwen-code/qwen-code-core",
|
||||
"version": "0.6.1",
|
||||
"version": "0.7.0",
|
||||
"hasInstallScript": true,
|
||||
"dependencies": {
|
||||
"@anthropic-ai/sdk": "^0.36.1",
|
||||
@@ -21413,7 +21413,7 @@
|
||||
},
|
||||
"packages/test-utils": {
|
||||
"name": "@qwen-code/qwen-code-test-utils",
|
||||
"version": "0.6.1",
|
||||
"version": "0.7.0",
|
||||
"dev": true,
|
||||
"license": "Apache-2.0",
|
||||
"devDependencies": {
|
||||
@@ -21425,7 +21425,7 @@
|
||||
},
|
||||
"packages/vscode-ide-companion": {
|
||||
"name": "qwen-code-vscode-ide-companion",
|
||||
"version": "0.6.1",
|
||||
"version": "0.7.0",
|
||||
"license": "LICENSE",
|
||||
"dependencies": {
|
||||
"@modelcontextprotocol/sdk": "^1.25.1",
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@qwen-code/qwen-code",
|
||||
"version": "0.6.1",
|
||||
"version": "0.7.0",
|
||||
"engines": {
|
||||
"node": ">=20.0.0"
|
||||
},
|
||||
@@ -13,7 +13,7 @@
|
||||
"url": "git+https://github.com/QwenLM/qwen-code.git"
|
||||
},
|
||||
"config": {
|
||||
"sandboxImageUri": "ghcr.io/qwenlm/qwen-code:0.6.1"
|
||||
"sandboxImageUri": "ghcr.io/qwenlm/qwen-code:0.7.0"
|
||||
},
|
||||
"scripts": {
|
||||
"start": "cross-env node scripts/start.js",
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@qwen-code/qwen-code",
|
||||
"version": "0.6.1",
|
||||
"version": "0.7.0",
|
||||
"description": "Qwen Code",
|
||||
"repository": {
|
||||
"type": "git",
|
||||
@@ -33,7 +33,7 @@
|
||||
"dist"
|
||||
],
|
||||
"config": {
|
||||
"sandboxImageUri": "ghcr.io/qwenlm/qwen-code:0.6.1"
|
||||
"sandboxImageUri": "ghcr.io/qwenlm/qwen-code:0.7.0"
|
||||
},
|
||||
"dependencies": {
|
||||
"@google/genai": "1.30.0",
|
||||
|
||||
@@ -311,7 +311,7 @@ class GeminiAgent {
|
||||
}
|
||||
|
||||
private async ensureAuthenticated(config: Config): Promise<void> {
|
||||
const selectedType = config.getAuthType();
|
||||
const selectedType = this.settings.merged.security?.auth?.selectedType;
|
||||
if (!selectedType) {
|
||||
throw acp.RequestError.authRequired('No Selected Type');
|
||||
}
|
||||
|
||||
@@ -256,12 +256,16 @@ export async function main() {
|
||||
// Validate authentication here because the sandbox will interfere with the Oauth2 web redirect.
|
||||
try {
|
||||
const authType = partialConfig.modelsConfig.getCurrentAuthType();
|
||||
const err = validateAuthMethod(authType, partialConfig);
|
||||
if (err) {
|
||||
throw new Error(err);
|
||||
}
|
||||
// Fresh users may not have selected/persisted an authType yet.
|
||||
// In that case, defer auth prompting/selection to the main interactive flow.
|
||||
if (authType) {
|
||||
const err = validateAuthMethod(authType, partialConfig);
|
||||
if (err) {
|
||||
throw new Error(err);
|
||||
}
|
||||
|
||||
await partialConfig.refreshAuth(authType);
|
||||
await partialConfig.refreshAuth(authType);
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('Error authenticating:', err);
|
||||
process.exit(1);
|
||||
|
||||
@@ -370,29 +370,30 @@ export const AppContainer = (props: AppContainerProps) => {
|
||||
// Check for enforced auth type mismatch
|
||||
useEffect(() => {
|
||||
// Check for initialization error first
|
||||
const currentAuthType = config.modelsConfig.getCurrentAuthType();
|
||||
|
||||
if (
|
||||
settings.merged.security?.auth?.enforcedType &&
|
||||
config.modelsConfig.getCurrentAuthType() &&
|
||||
settings.merged.security?.auth.enforcedType !==
|
||||
config.modelsConfig.getCurrentAuthType()
|
||||
currentAuthType &&
|
||||
settings.merged.security?.auth.enforcedType !== currentAuthType
|
||||
) {
|
||||
onAuthError(
|
||||
t(
|
||||
'Authentication is enforced to be {{enforcedType}}, but you are currently using {{currentType}}.',
|
||||
{
|
||||
enforcedType: settings.merged.security?.auth.enforcedType,
|
||||
currentType: config.modelsConfig.getCurrentAuthType(),
|
||||
enforcedType: String(settings.merged.security?.auth.enforcedType),
|
||||
currentType: String(currentAuthType),
|
||||
},
|
||||
),
|
||||
);
|
||||
} else if (!settings.merged.security?.auth?.useExternal) {
|
||||
const error = validateAuthMethod(
|
||||
config.modelsConfig.getCurrentAuthType(),
|
||||
config,
|
||||
);
|
||||
if (error) {
|
||||
onAuthError(error);
|
||||
// If no authType is selected yet, allow the auth UI flow to prompt the user.
|
||||
// Only validate credentials once a concrete authType exists.
|
||||
if (currentAuthType) {
|
||||
const error = validateAuthMethod(currentAuthType, config);
|
||||
if (error) {
|
||||
onAuthError(error);
|
||||
}
|
||||
}
|
||||
}
|
||||
}, [
|
||||
|
||||
@@ -11,9 +11,14 @@ import type { SlashCommand, type CommandContext } from './types.js';
|
||||
import { createMockCommandContext } from '../../test-utils/mockCommandContext.js';
|
||||
import { MessageType } from '../types.js';
|
||||
import type { LoadedSettings } from '../../config/settings.js';
|
||||
import { readFile } from 'node:fs/promises';
|
||||
import os from 'node:os';
|
||||
import path from 'node:path';
|
||||
import {
|
||||
getErrorMessage,
|
||||
loadServerHierarchicalMemory,
|
||||
QWEN_DIR,
|
||||
setGeminiMdFilename,
|
||||
type FileDiscoveryService,
|
||||
type LoadServerHierarchicalMemoryResponse,
|
||||
} from '@qwen-code/qwen-code-core';
|
||||
@@ -31,7 +36,18 @@ vi.mock('@qwen-code/qwen-code-core', async (importOriginal) => {
|
||||
};
|
||||
});
|
||||
|
||||
vi.mock('node:fs/promises', () => {
|
||||
const readFile = vi.fn();
|
||||
return {
|
||||
readFile,
|
||||
default: {
|
||||
readFile,
|
||||
},
|
||||
};
|
||||
});
|
||||
|
||||
const mockLoadServerHierarchicalMemory = loadServerHierarchicalMemory as Mock;
|
||||
const mockReadFile = readFile as unknown as Mock;
|
||||
|
||||
describe('memoryCommand', () => {
|
||||
let mockContext: CommandContext;
|
||||
@@ -52,6 +68,10 @@ describe('memoryCommand', () => {
|
||||
let mockGetGeminiMdFileCount: Mock;
|
||||
|
||||
beforeEach(() => {
|
||||
setGeminiMdFilename('QWEN.md');
|
||||
mockReadFile.mockReset();
|
||||
vi.restoreAllMocks();
|
||||
|
||||
showCommand = getSubCommand('show');
|
||||
|
||||
mockGetUserMemory = vi.fn();
|
||||
@@ -102,6 +122,52 @@ describe('memoryCommand', () => {
|
||||
expect.any(Number),
|
||||
);
|
||||
});
|
||||
|
||||
it('should show project memory from the configured context file', async () => {
|
||||
const projectCommand = showCommand.subCommands?.find(
|
||||
(cmd) => cmd.name === '--project',
|
||||
);
|
||||
if (!projectCommand?.action) throw new Error('Command has no action');
|
||||
|
||||
setGeminiMdFilename('AGENTS.md');
|
||||
vi.spyOn(process, 'cwd').mockReturnValue('/test/project');
|
||||
mockReadFile.mockResolvedValue('project memory');
|
||||
|
||||
await projectCommand.action(mockContext, '');
|
||||
|
||||
const expectedProjectPath = path.join('/test/project', 'AGENTS.md');
|
||||
expect(mockReadFile).toHaveBeenCalledWith(expectedProjectPath, 'utf-8');
|
||||
expect(mockContext.ui.addItem).toHaveBeenCalledWith(
|
||||
{
|
||||
type: MessageType.INFO,
|
||||
text: expect.stringContaining(expectedProjectPath),
|
||||
},
|
||||
expect.any(Number),
|
||||
);
|
||||
});
|
||||
|
||||
it('should show global memory from the configured context file', async () => {
|
||||
const globalCommand = showCommand.subCommands?.find(
|
||||
(cmd) => cmd.name === '--global',
|
||||
);
|
||||
if (!globalCommand?.action) throw new Error('Command has no action');
|
||||
|
||||
setGeminiMdFilename('AGENTS.md');
|
||||
vi.spyOn(os, 'homedir').mockReturnValue('/home/user');
|
||||
mockReadFile.mockResolvedValue('global memory');
|
||||
|
||||
await globalCommand.action(mockContext, '');
|
||||
|
||||
const expectedGlobalPath = path.join('/home/user', QWEN_DIR, 'AGENTS.md');
|
||||
expect(mockReadFile).toHaveBeenCalledWith(expectedGlobalPath, 'utf-8');
|
||||
expect(mockContext.ui.addItem).toHaveBeenCalledWith(
|
||||
{
|
||||
type: MessageType.INFO,
|
||||
text: expect.stringContaining('Global memory content'),
|
||||
},
|
||||
expect.any(Number),
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe('/memory add', () => {
|
||||
|
||||
@@ -6,12 +6,13 @@
|
||||
|
||||
import {
|
||||
getErrorMessage,
|
||||
getCurrentGeminiMdFilename,
|
||||
loadServerHierarchicalMemory,
|
||||
QWEN_DIR,
|
||||
} from '@qwen-code/qwen-code-core';
|
||||
import path from 'node:path';
|
||||
import os from 'os';
|
||||
import fs from 'fs/promises';
|
||||
import os from 'node:os';
|
||||
import fs from 'node:fs/promises';
|
||||
import { MessageType } from '../types.js';
|
||||
import type { SlashCommand, SlashCommandActionReturn } from './types.js';
|
||||
import { CommandKind } from './types.js';
|
||||
@@ -56,7 +57,12 @@ export const memoryCommand: SlashCommand = {
|
||||
kind: CommandKind.BUILT_IN,
|
||||
action: async (context) => {
|
||||
try {
|
||||
const projectMemoryPath = path.join(process.cwd(), 'QWEN.md');
|
||||
const workingDir =
|
||||
context.services.config?.getWorkingDir?.() ?? process.cwd();
|
||||
const projectMemoryPath = path.join(
|
||||
workingDir,
|
||||
getCurrentGeminiMdFilename(),
|
||||
);
|
||||
const memoryContent = await fs.readFile(
|
||||
projectMemoryPath,
|
||||
'utf-8',
|
||||
@@ -104,7 +110,7 @@ export const memoryCommand: SlashCommand = {
|
||||
const globalMemoryPath = path.join(
|
||||
os.homedir(),
|
||||
QWEN_DIR,
|
||||
'QWEN.md',
|
||||
getCurrentGeminiMdFilename(),
|
||||
);
|
||||
const globalMemoryContent = await fs.readFile(
|
||||
globalMemoryPath,
|
||||
|
||||
@@ -54,7 +54,7 @@ export function ApprovalModeDialog({
|
||||
}: ApprovalModeDialogProps): React.JSX.Element {
|
||||
// Start with User scope by default
|
||||
const [selectedScope, setSelectedScope] = useState<SettingScope>(
|
||||
SettingScope.User,
|
||||
SettingScope.Workspace,
|
||||
);
|
||||
|
||||
// Track the currently highlighted approval mode
|
||||
|
||||
@@ -146,7 +146,7 @@ export function ModelDialog({ onClose }: ModelDialogProps): React.JSX.Element {
|
||||
// Local error state for displaying errors within the dialog
|
||||
const [errorMessage, setErrorMessage] = useState<string | null>(null);
|
||||
|
||||
const authType = config?.getAuthType() ?? AuthType.QWEN_OAUTH;
|
||||
const authType = config?.getAuthType();
|
||||
const effectiveConfig =
|
||||
(config?.getContentGeneratorConfig?.() as
|
||||
| ContentGeneratorConfig
|
||||
@@ -208,7 +208,7 @@ export function ModelDialog({ onClose }: ModelDialogProps): React.JSX.Element {
|
||||
);
|
||||
|
||||
const preferredModelId = config?.getModel() || MAINLINE_CODER;
|
||||
const preferredKey = `${authType}::${preferredModelId}`;
|
||||
const preferredKey = authType ? `${authType}::${preferredModelId}` : '';
|
||||
|
||||
useKeypress(
|
||||
(key) => {
|
||||
@@ -219,10 +219,12 @@ export function ModelDialog({ onClose }: ModelDialogProps): React.JSX.Element {
|
||||
{ isActive: true },
|
||||
);
|
||||
|
||||
const initialIndex = useMemo(
|
||||
() => MODEL_OPTIONS.findIndex((option) => option.value === preferredKey),
|
||||
[MODEL_OPTIONS, preferredKey],
|
||||
);
|
||||
const initialIndex = useMemo(() => {
|
||||
const index = MODEL_OPTIONS.findIndex(
|
||||
(option) => option.value === preferredKey,
|
||||
);
|
||||
return index === -1 ? 0 : index;
|
||||
}, [MODEL_OPTIONS, preferredKey]);
|
||||
|
||||
const handleSelect = useCallback(
|
||||
async (selected: string) => {
|
||||
@@ -339,7 +341,7 @@ export function ModelDialog({ onClose }: ModelDialogProps): React.JSX.Element {
|
||||
{t(
|
||||
'No models available for the current authentication type ({{authType}}).',
|
||||
{
|
||||
authType,
|
||||
authType: authType ? String(authType) : t('(none)'),
|
||||
},
|
||||
)}
|
||||
</Text>
|
||||
|
||||
@@ -1,21 +1,58 @@
|
||||
/**
|
||||
* @license
|
||||
* Copyright 2025 Qwen
|
||||
* SPDX-License-Identifier: Apache-2.0
|
||||
*/
|
||||
|
||||
import { useCallback } from 'react';
|
||||
import { useStdin } from 'ink';
|
||||
import type { EditorType } from '@qwen-code/qwen-code-core';
|
||||
import {
|
||||
editorCommands,
|
||||
commandExists as coreCommandExists,
|
||||
} from '@qwen-code/qwen-code-core';
|
||||
import { spawnSync } from 'child_process';
|
||||
import { useSettings } from '../contexts/SettingsContext.js';
|
||||
|
||||
/**
|
||||
* Cache for command existence checks to avoid repeated execSync calls.
|
||||
*/
|
||||
const commandExistsCache = new Map<string, boolean>();
|
||||
|
||||
/**
|
||||
* Check if a command exists in the system with caching.
|
||||
* Results are cached to improve performance in test environments.
|
||||
*/
|
||||
function commandExists(cmd: string): boolean {
|
||||
if (commandExistsCache.has(cmd)) {
|
||||
return commandExistsCache.get(cmd)!;
|
||||
}
|
||||
|
||||
const exists = coreCommandExists(cmd);
|
||||
commandExistsCache.set(cmd, exists);
|
||||
return exists;
|
||||
}
|
||||
/**
|
||||
* Get the actual executable command for an editor type.
|
||||
*/
|
||||
function getExecutableCommand(editorType: EditorType): string {
|
||||
const commandConfig = editorCommands[editorType];
|
||||
const commands =
|
||||
process.platform === 'win32' ? commandConfig.win32 : commandConfig.default;
|
||||
|
||||
const availableCommand = commands.find((cmd) => commandExists(cmd));
|
||||
|
||||
if (!availableCommand) {
|
||||
throw new Error(
|
||||
`No available editor command found for ${editorType}. ` +
|
||||
`Tried: ${commands.join(', ')}. ` +
|
||||
`Please install one of these editors or set a different preferredEditor in settings.`,
|
||||
);
|
||||
}
|
||||
|
||||
return availableCommand;
|
||||
}
|
||||
|
||||
/**
|
||||
* Determines the editor command to use based on user preferences and platform.
|
||||
*/
|
||||
function getEditorCommand(preferredEditor?: EditorType): string {
|
||||
if (preferredEditor) {
|
||||
return preferredEditor;
|
||||
return getExecutableCommand(preferredEditor);
|
||||
}
|
||||
|
||||
// Platform-specific defaults with UI preference for macOS
|
||||
@@ -63,8 +100,14 @@ export function useLaunchEditor() {
|
||||
try {
|
||||
setRawMode?.(false);
|
||||
|
||||
// On Windows, .cmd and .bat files need shell: true
|
||||
const needsShell =
|
||||
process.platform === 'win32' &&
|
||||
(editorCommand.endsWith('.cmd') || editorCommand.endsWith('.bat'));
|
||||
|
||||
const { status, error } = spawnSync(editorCommand, editorArgs, {
|
||||
stdio: 'inherit',
|
||||
shell: needsShell,
|
||||
});
|
||||
|
||||
if (error) throw error;
|
||||
|
||||
@@ -79,7 +79,7 @@ export function resolveCliGenerationConfig(
|
||||
const { argv, settings, selectedAuthType } = inputs;
|
||||
const env = inputs.env ?? (process.env as Record<string, string | undefined>);
|
||||
|
||||
const authType = selectedAuthType ?? AuthType.QWEN_OAUTH;
|
||||
const authType = selectedAuthType;
|
||||
|
||||
const configSources: ModelConfigSourcesInput = {
|
||||
authType,
|
||||
|
||||
@@ -8,7 +8,6 @@ import { exec, execSync, spawn, type ChildProcess } from 'node:child_process';
|
||||
import os from 'node:os';
|
||||
import path from 'node:path';
|
||||
import fs from 'node:fs';
|
||||
import { readFile } from 'node:fs/promises';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
import { quote, parse } from 'shell-quote';
|
||||
import {
|
||||
@@ -50,16 +49,16 @@ const BUILTIN_SEATBELT_PROFILES = [
|
||||
|
||||
/**
|
||||
* Determines whether the sandbox container should be run with the current user's UID and GID.
|
||||
* This is often necessary on Linux systems (especially Debian/Ubuntu based) when using
|
||||
* rootful Docker without userns-remap configured, to avoid permission issues with
|
||||
* This is often necessary on Linux systems when using rootful Docker without userns-remap
|
||||
* configured, to avoid permission issues with
|
||||
* mounted volumes.
|
||||
*
|
||||
* The behavior is controlled by the `SANDBOX_SET_UID_GID` environment variable:
|
||||
* - If `SANDBOX_SET_UID_GID` is "1" or "true", this function returns `true`.
|
||||
* - If `SANDBOX_SET_UID_GID` is "0" or "false", this function returns `false`.
|
||||
* - If `SANDBOX_SET_UID_GID` is not set:
|
||||
* - On Debian/Ubuntu Linux, it defaults to `true`.
|
||||
* - On other OSes, or if OS detection fails, it defaults to `false`.
|
||||
* - On Linux, it defaults to `true`.
|
||||
* - On other OSes, it defaults to `false`.
|
||||
*
|
||||
* For more context on running Docker containers as non-root, see:
|
||||
* https://medium.com/redbubble/running-a-docker-container-as-a-non-root-user-7d2e00f8ee15
|
||||
@@ -76,31 +75,20 @@ async function shouldUseCurrentUserInSandbox(): Promise<boolean> {
|
||||
return false;
|
||||
}
|
||||
|
||||
// If environment variable is not explicitly set, check for Debian/Ubuntu Linux
|
||||
if (os.platform() === 'linux') {
|
||||
try {
|
||||
const osReleaseContent = await readFile('/etc/os-release', 'utf8');
|
||||
if (
|
||||
osReleaseContent.includes('ID=debian') ||
|
||||
osReleaseContent.includes('ID=ubuntu') ||
|
||||
osReleaseContent.match(/^ID_LIKE=.*debian.*/m) || // Covers derivatives
|
||||
osReleaseContent.match(/^ID_LIKE=.*ubuntu.*/m) // Covers derivatives
|
||||
) {
|
||||
// note here and below we use console.error for informational messages on stderr
|
||||
console.error(
|
||||
'INFO: Defaulting to use current user UID/GID for Debian/Ubuntu-based Linux.',
|
||||
);
|
||||
return true;
|
||||
}
|
||||
} catch (_err) {
|
||||
// Silently ignore if /etc/os-release is not found or unreadable.
|
||||
// The default (false) will be applied in this case.
|
||||
console.warn(
|
||||
'Warning: Could not read /etc/os-release to auto-detect Debian/Ubuntu for UID/GID default.',
|
||||
const debugEnv = [process.env['DEBUG'], process.env['DEBUG_MODE']].some(
|
||||
(v) => v === 'true' || v === '1',
|
||||
);
|
||||
if (debugEnv) {
|
||||
// Use stderr so it doesn't clutter normal STDOUT output (e.g. in `--prompt` runs).
|
||||
console.error(
|
||||
'INFO: Using current user UID/GID in Linux sandbox. Set SANDBOX_SET_UID_GID=false to disable.',
|
||||
);
|
||||
}
|
||||
return true;
|
||||
}
|
||||
return false; // Default to false if no other condition is met
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
// docker does not allow container names to contain ':' or '/', so we
|
||||
|
||||
@@ -20,21 +20,27 @@ export async function validateNonInteractiveAuth(
|
||||
try {
|
||||
// Get the actual authType from config which has already resolved CLI args, env vars, and settings
|
||||
const authType = nonInteractiveConfig.modelsConfig.getCurrentAuthType();
|
||||
if (!authType) {
|
||||
throw new Error(
|
||||
'No auth type is selected. Please configure an auth type (e.g. via settings or `--auth-type`) before running in non-interactive mode.',
|
||||
);
|
||||
}
|
||||
const resolvedAuthType: NonNullable<typeof authType> = authType;
|
||||
|
||||
const enforcedType = settings.merged.security?.auth?.enforcedType;
|
||||
if (enforcedType && enforcedType !== authType) {
|
||||
const message = `The configured auth type is ${enforcedType}, but the current auth type is ${authType}. Please re-authenticate with the correct type.`;
|
||||
if (enforcedType && enforcedType !== resolvedAuthType) {
|
||||
const message = `The configured auth type is ${enforcedType}, but the current auth type is ${resolvedAuthType}. Please re-authenticate with the correct type.`;
|
||||
throw new Error(message);
|
||||
}
|
||||
|
||||
if (!useExternalAuth) {
|
||||
const err = validateAuthMethod(authType, nonInteractiveConfig);
|
||||
const err = validateAuthMethod(resolvedAuthType, nonInteractiveConfig);
|
||||
if (err != null) {
|
||||
throw new Error(err);
|
||||
}
|
||||
}
|
||||
|
||||
await nonInteractiveConfig.refreshAuth(authType);
|
||||
await nonInteractiveConfig.refreshAuth(resolvedAuthType);
|
||||
return nonInteractiveConfig;
|
||||
} catch (error) {
|
||||
const outputFormat = nonInteractiveConfig.getOutputFormat();
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@qwen-code/qwen-code-core",
|
||||
"version": "0.6.1",
|
||||
"version": "0.7.0",
|
||||
"description": "Qwen Code Core",
|
||||
"repository": {
|
||||
"type": "git",
|
||||
|
||||
@@ -1276,7 +1276,7 @@ export class Config {
|
||||
}
|
||||
|
||||
getAuthType(): AuthType | undefined {
|
||||
return this.contentGeneratorConfig.authType;
|
||||
return this.contentGeneratorConfig?.authType;
|
||||
}
|
||||
|
||||
getCliVersion(): string | undefined {
|
||||
|
||||
@@ -1058,26 +1058,18 @@ describe('Gemini Client (client.ts)', () => {
|
||||
|
||||
// Assert
|
||||
expect(ideContextStore.get).toHaveBeenCalled();
|
||||
const expectedContext = `
|
||||
Here is the user's editor context as a JSON object. This is for your information only.
|
||||
\`\`\`json
|
||||
${JSON.stringify(
|
||||
{
|
||||
activeFile: {
|
||||
path: '/path/to/active/file.ts',
|
||||
cursor: {
|
||||
line: 5,
|
||||
character: 10,
|
||||
},
|
||||
selectedText: 'hello',
|
||||
},
|
||||
otherOpenFiles: ['/path/to/recent/file1.ts', '/path/to/recent/file2.ts'],
|
||||
},
|
||||
null,
|
||||
2,
|
||||
)}
|
||||
const expectedContext = `Here is the user's editor context. This is for your information only.
|
||||
Active file:
|
||||
Path: /path/to/active/file.ts
|
||||
Cursor: line 5, character 10
|
||||
Selected text:
|
||||
\`\`\`
|
||||
`.trim();
|
||||
hello
|
||||
\`\`\`
|
||||
|
||||
Other open files:
|
||||
- /path/to/recent/file1.ts
|
||||
- /path/to/recent/file2.ts`;
|
||||
const expectedRequest = [{ text: expectedContext }];
|
||||
expect(mockChat.addHistory).toHaveBeenCalledWith({
|
||||
role: 'user',
|
||||
@@ -1177,25 +1169,14 @@ ${JSON.stringify(
|
||||
|
||||
// Assert
|
||||
expect(ideContextStore.get).toHaveBeenCalled();
|
||||
const expectedContext = `
|
||||
Here is the user's editor context as a JSON object. This is for your information only.
|
||||
\`\`\`json
|
||||
${JSON.stringify(
|
||||
{
|
||||
activeFile: {
|
||||
path: '/path/to/active/file.ts',
|
||||
cursor: {
|
||||
line: 5,
|
||||
character: 10,
|
||||
},
|
||||
selectedText: 'hello',
|
||||
},
|
||||
},
|
||||
null,
|
||||
2,
|
||||
)}
|
||||
const expectedContext = `Here is the user's editor context. This is for your information only.
|
||||
Active file:
|
||||
Path: /path/to/active/file.ts
|
||||
Cursor: line 5, character 10
|
||||
Selected text:
|
||||
\`\`\`
|
||||
`.trim();
|
||||
hello
|
||||
\`\`\``;
|
||||
const expectedRequest = [{ text: expectedContext }];
|
||||
expect(mockChat.addHistory).toHaveBeenCalledWith({
|
||||
role: 'user',
|
||||
@@ -1254,18 +1235,10 @@ ${JSON.stringify(
|
||||
|
||||
// Assert
|
||||
expect(ideContextStore.get).toHaveBeenCalled();
|
||||
const expectedContext = `
|
||||
Here is the user's editor context as a JSON object. This is for your information only.
|
||||
\`\`\`json
|
||||
${JSON.stringify(
|
||||
{
|
||||
otherOpenFiles: ['/path/to/recent/file1.ts', '/path/to/recent/file2.ts'],
|
||||
},
|
||||
null,
|
||||
2,
|
||||
)}
|
||||
\`\`\`
|
||||
`.trim();
|
||||
const expectedContext = `Here is the user's editor context. This is for your information only.
|
||||
Other open files:
|
||||
- /path/to/recent/file1.ts
|
||||
- /path/to/recent/file2.ts`;
|
||||
const expectedRequest = [{ text: expectedContext }];
|
||||
expect(mockChat.addHistory).toHaveBeenCalledWith({
|
||||
role: 'user',
|
||||
@@ -1782,11 +1755,9 @@ ${JSON.stringify(
|
||||
// Also verify it's the full context, not a delta.
|
||||
const call = mockChat.addHistory.mock.calls[0][0];
|
||||
const contextText = call.parts[0].text;
|
||||
const contextJson = JSON.parse(
|
||||
contextText.match(/```json\n(.*)\n```/s)![1],
|
||||
);
|
||||
expect(contextJson).toHaveProperty('activeFile');
|
||||
expect(contextJson.activeFile.path).toBe('/path/to/active/file.ts');
|
||||
// Verify it contains the active file information in plain text format
|
||||
expect(contextText).toContain('Active file:');
|
||||
expect(contextText).toContain('Path: /path/to/active/file.ts');
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1989,7 +1960,7 @@ ${JSON.stringify(
|
||||
);
|
||||
expect(contextCall).toBeDefined();
|
||||
expect(JSON.stringify(contextCall![0])).toContain(
|
||||
"Here is the user's editor context as a JSON object",
|
||||
"Here is the user's editor context.",
|
||||
);
|
||||
// Check that the sent context is the new one (fileB.ts)
|
||||
expect(JSON.stringify(contextCall![0])).toContain('fileB.ts');
|
||||
@@ -2025,9 +1996,7 @@ ${JSON.stringify(
|
||||
|
||||
// Assert: Full context for fileA.ts was sent and stored.
|
||||
const initialCall = vi.mocked(mockChat.addHistory!).mock.calls[0][0];
|
||||
expect(JSON.stringify(initialCall)).toContain(
|
||||
"user's editor context as a JSON object",
|
||||
);
|
||||
expect(JSON.stringify(initialCall)).toContain("user's editor context.");
|
||||
expect(JSON.stringify(initialCall)).toContain('fileA.ts');
|
||||
// This implicitly tests that `lastSentIdeContext` is now set internally by the client.
|
||||
vi.mocked(mockChat.addHistory!).mockClear();
|
||||
@@ -2125,9 +2094,9 @@ ${JSON.stringify(
|
||||
const finalCall = vi.mocked(mockChat.addHistory!).mock.calls[0][0];
|
||||
expect(JSON.stringify(finalCall)).toContain('summary of changes');
|
||||
// The delta should reflect fileA being closed and fileC being opened.
|
||||
expect(JSON.stringify(finalCall)).toContain('filesClosed');
|
||||
expect(JSON.stringify(finalCall)).toContain('Files closed');
|
||||
expect(JSON.stringify(finalCall)).toContain('fileA.ts');
|
||||
expect(JSON.stringify(finalCall)).toContain('activeFileChanged');
|
||||
expect(JSON.stringify(finalCall)).toContain('Active file changed');
|
||||
expect(JSON.stringify(finalCall)).toContain('fileC.ts');
|
||||
});
|
||||
});
|
||||
|
||||
@@ -218,42 +218,48 @@ export class GeminiClient {
|
||||
}
|
||||
|
||||
if (forceFullContext || !this.lastSentIdeContext) {
|
||||
// Send full context as JSON
|
||||
// Send full context as plain text
|
||||
const openFiles = currentIdeContext.workspaceState?.openFiles || [];
|
||||
const activeFile = openFiles.find((f) => f.isActive);
|
||||
const otherOpenFiles = openFiles
|
||||
.filter((f) => !f.isActive)
|
||||
.map((f) => f.path);
|
||||
|
||||
const contextData: Record<string, unknown> = {};
|
||||
const contextLines: string[] = [];
|
||||
|
||||
if (activeFile) {
|
||||
contextData['activeFile'] = {
|
||||
path: activeFile.path,
|
||||
cursor: activeFile.cursor
|
||||
? {
|
||||
line: activeFile.cursor.line,
|
||||
character: activeFile.cursor.character,
|
||||
}
|
||||
: undefined,
|
||||
selectedText: activeFile.selectedText || undefined,
|
||||
};
|
||||
contextLines.push('Active file:');
|
||||
contextLines.push(` Path: ${activeFile.path}`);
|
||||
if (activeFile.cursor) {
|
||||
contextLines.push(
|
||||
` Cursor: line ${activeFile.cursor.line}, character ${activeFile.cursor.character}`,
|
||||
);
|
||||
}
|
||||
if (activeFile.selectedText) {
|
||||
contextLines.push(' Selected text:');
|
||||
contextLines.push('```');
|
||||
contextLines.push(activeFile.selectedText);
|
||||
contextLines.push('```');
|
||||
}
|
||||
}
|
||||
|
||||
if (otherOpenFiles.length > 0) {
|
||||
contextData['otherOpenFiles'] = otherOpenFiles;
|
||||
if (contextLines.length > 0) {
|
||||
contextLines.push('');
|
||||
}
|
||||
contextLines.push('Other open files:');
|
||||
for (const filePath of otherOpenFiles) {
|
||||
contextLines.push(` - ${filePath}`);
|
||||
}
|
||||
}
|
||||
|
||||
if (Object.keys(contextData).length === 0) {
|
||||
if (contextLines.length === 0) {
|
||||
return { contextParts: [], newIdeContext: currentIdeContext };
|
||||
}
|
||||
|
||||
const jsonString = JSON.stringify(contextData, null, 2);
|
||||
const contextParts = [
|
||||
"Here is the user's editor context as a JSON object. This is for your information only.",
|
||||
'```json',
|
||||
jsonString,
|
||||
'```',
|
||||
"Here is the user's editor context. This is for your information only.",
|
||||
contextLines.join('\n'),
|
||||
];
|
||||
|
||||
if (this.config.getDebugMode()) {
|
||||
@@ -264,9 +270,8 @@ export class GeminiClient {
|
||||
newIdeContext: currentIdeContext,
|
||||
};
|
||||
} else {
|
||||
// Calculate and send delta as JSON
|
||||
const delta: Record<string, unknown> = {};
|
||||
const changes: Record<string, unknown> = {};
|
||||
// Calculate and send delta as plain text
|
||||
const changeLines: string[] = [];
|
||||
|
||||
const lastFiles = new Map(
|
||||
(this.lastSentIdeContext.workspaceState?.openFiles || []).map(
|
||||
@@ -287,7 +292,10 @@ export class GeminiClient {
|
||||
}
|
||||
}
|
||||
if (openedFiles.length > 0) {
|
||||
changes['filesOpened'] = openedFiles;
|
||||
changeLines.push('Files opened:');
|
||||
for (const filePath of openedFiles) {
|
||||
changeLines.push(` - ${filePath}`);
|
||||
}
|
||||
}
|
||||
|
||||
const closedFiles: string[] = [];
|
||||
@@ -297,7 +305,13 @@ export class GeminiClient {
|
||||
}
|
||||
}
|
||||
if (closedFiles.length > 0) {
|
||||
changes['filesClosed'] = closedFiles;
|
||||
if (changeLines.length > 0) {
|
||||
changeLines.push('');
|
||||
}
|
||||
changeLines.push('Files closed:');
|
||||
for (const filePath of closedFiles) {
|
||||
changeLines.push(` - ${filePath}`);
|
||||
}
|
||||
}
|
||||
|
||||
const lastActiveFile = (
|
||||
@@ -309,16 +323,22 @@ export class GeminiClient {
|
||||
|
||||
if (currentActiveFile) {
|
||||
if (!lastActiveFile || lastActiveFile.path !== currentActiveFile.path) {
|
||||
changes['activeFileChanged'] = {
|
||||
path: currentActiveFile.path,
|
||||
cursor: currentActiveFile.cursor
|
||||
? {
|
||||
line: currentActiveFile.cursor.line,
|
||||
character: currentActiveFile.cursor.character,
|
||||
}
|
||||
: undefined,
|
||||
selectedText: currentActiveFile.selectedText || undefined,
|
||||
};
|
||||
if (changeLines.length > 0) {
|
||||
changeLines.push('');
|
||||
}
|
||||
changeLines.push('Active file changed:');
|
||||
changeLines.push(` Path: ${currentActiveFile.path}`);
|
||||
if (currentActiveFile.cursor) {
|
||||
changeLines.push(
|
||||
` Cursor: line ${currentActiveFile.cursor.line}, character ${currentActiveFile.cursor.character}`,
|
||||
);
|
||||
}
|
||||
if (currentActiveFile.selectedText) {
|
||||
changeLines.push(' Selected text:');
|
||||
changeLines.push('```');
|
||||
changeLines.push(currentActiveFile.selectedText);
|
||||
changeLines.push('```');
|
||||
}
|
||||
} else {
|
||||
const lastCursor = lastActiveFile.cursor;
|
||||
const currentCursor = currentActiveFile.cursor;
|
||||
@@ -328,42 +348,50 @@ export class GeminiClient {
|
||||
lastCursor.line !== currentCursor.line ||
|
||||
lastCursor.character !== currentCursor.character)
|
||||
) {
|
||||
changes['cursorMoved'] = {
|
||||
path: currentActiveFile.path,
|
||||
cursor: {
|
||||
line: currentCursor.line,
|
||||
character: currentCursor.character,
|
||||
},
|
||||
};
|
||||
if (changeLines.length > 0) {
|
||||
changeLines.push('');
|
||||
}
|
||||
changeLines.push('Cursor moved:');
|
||||
changeLines.push(` Path: ${currentActiveFile.path}`);
|
||||
changeLines.push(
|
||||
` New position: line ${currentCursor.line}, character ${currentCursor.character}`,
|
||||
);
|
||||
}
|
||||
|
||||
const lastSelectedText = lastActiveFile.selectedText || '';
|
||||
const currentSelectedText = currentActiveFile.selectedText || '';
|
||||
if (lastSelectedText !== currentSelectedText) {
|
||||
changes['selectionChanged'] = {
|
||||
path: currentActiveFile.path,
|
||||
selectedText: currentSelectedText,
|
||||
};
|
||||
if (changeLines.length > 0) {
|
||||
changeLines.push('');
|
||||
}
|
||||
changeLines.push('Selection changed:');
|
||||
changeLines.push(` Path: ${currentActiveFile.path}`);
|
||||
if (currentSelectedText) {
|
||||
changeLines.push(' Selected text:');
|
||||
changeLines.push('```');
|
||||
changeLines.push(currentSelectedText);
|
||||
changeLines.push('```');
|
||||
} else {
|
||||
changeLines.push(' Selected text: (none)');
|
||||
}
|
||||
}
|
||||
}
|
||||
} else if (lastActiveFile) {
|
||||
changes['activeFileChanged'] = {
|
||||
path: null,
|
||||
previousPath: lastActiveFile.path,
|
||||
};
|
||||
if (changeLines.length > 0) {
|
||||
changeLines.push('');
|
||||
}
|
||||
changeLines.push('Active file changed:');
|
||||
changeLines.push(' No active file');
|
||||
changeLines.push(` Previous path: ${lastActiveFile.path}`);
|
||||
}
|
||||
|
||||
if (Object.keys(changes).length === 0) {
|
||||
if (changeLines.length === 0) {
|
||||
return { contextParts: [], newIdeContext: currentIdeContext };
|
||||
}
|
||||
|
||||
delta['changes'] = changes;
|
||||
const jsonString = JSON.stringify(delta, null, 2);
|
||||
const contextParts = [
|
||||
"Here is a summary of changes in the user's editor context, in JSON format. This is for your information only.",
|
||||
'```json',
|
||||
jsonString,
|
||||
'```',
|
||||
"Here is a summary of changes in the user's editor context. This is for your information only.",
|
||||
changeLines.join('\n'),
|
||||
];
|
||||
|
||||
if (this.config.getDebugMode()) {
|
||||
|
||||
@@ -207,6 +207,27 @@ describe('OpenAIContentConverter', () => {
|
||||
expect.objectContaining({ text: 'visible text' }),
|
||||
);
|
||||
});
|
||||
|
||||
it('should not throw when streaming chunk has no delta', () => {
|
||||
const chunk = converter.convertOpenAIChunkToGemini({
|
||||
object: 'chat.completion.chunk',
|
||||
id: 'chunk-2',
|
||||
created: 456,
|
||||
choices: [
|
||||
{
|
||||
index: 0,
|
||||
// Some OpenAI-compatible providers may omit delta entirely.
|
||||
delta: undefined,
|
||||
finish_reason: null,
|
||||
logprobs: null,
|
||||
},
|
||||
],
|
||||
model: 'gpt-test',
|
||||
} as unknown as OpenAI.Chat.ChatCompletionChunk);
|
||||
|
||||
const parts = chunk.candidates?.[0]?.content?.parts;
|
||||
expect(parts).toEqual([]);
|
||||
});
|
||||
});
|
||||
|
||||
describe('convertGeminiToolsToOpenAI', () => {
|
||||
|
||||
@@ -799,7 +799,7 @@ export class OpenAIContentConverter {
|
||||
const parts: Part[] = [];
|
||||
|
||||
const reasoningText = (choice.delta as ExtendedCompletionChunkDelta)
|
||||
.reasoning_content;
|
||||
?.reasoning_content;
|
||||
if (reasoningText) {
|
||||
parts.push({ text: reasoningText, thought: true });
|
||||
}
|
||||
|
||||
@@ -75,7 +75,7 @@ export interface ModelConfigSettingsInput {
|
||||
*/
|
||||
export interface ModelConfigSourcesInput {
|
||||
/** Authentication type */
|
||||
authType: AuthType;
|
||||
authType?: AuthType;
|
||||
|
||||
/** CLI arguments (highest priority for user-provided values) */
|
||||
cli?: ModelConfigCliInput;
|
||||
@@ -128,9 +128,11 @@ export function resolveModelConfig(
|
||||
return resolveQwenOAuthConfig(input, warnings);
|
||||
}
|
||||
|
||||
// Get auth-specific env var mappings
|
||||
const envMapping =
|
||||
AUTH_ENV_MAPPINGS[authType] || AUTH_ENV_MAPPINGS[AuthType.USE_OPENAI];
|
||||
// Get auth-specific env var mappings.
|
||||
// If authType is not provided, do not read any auth env vars.
|
||||
const envMapping = authType
|
||||
? AUTH_ENV_MAPPINGS[authType]
|
||||
: { model: [], apiKey: [], baseUrl: [] };
|
||||
|
||||
// Build layers for each field in priority order
|
||||
// Priority: modelProvider > cli > env > settings > default
|
||||
@@ -138,7 +140,7 @@ export function resolveModelConfig(
|
||||
// ---- Model ----
|
||||
const modelLayers: Array<ConfigLayer<string>> = [];
|
||||
|
||||
if (modelProvider) {
|
||||
if (authType && modelProvider) {
|
||||
modelLayers.push(
|
||||
layer(
|
||||
modelProvider.id,
|
||||
@@ -156,7 +158,7 @@ export function resolveModelConfig(
|
||||
modelLayers.push(layer(settings.model, settingsSource('model.name')));
|
||||
}
|
||||
|
||||
const defaultModel = DEFAULT_MODELS[authType] || '';
|
||||
const defaultModel = authType ? DEFAULT_MODELS[authType] : '';
|
||||
const modelResult = resolveField(
|
||||
modelLayers,
|
||||
defaultModel,
|
||||
@@ -168,7 +170,7 @@ export function resolveModelConfig(
|
||||
const apiKeyLayers: Array<ConfigLayer<string>> = [];
|
||||
|
||||
// For modelProvider, read from the specified envKey
|
||||
if (modelProvider?.envKey) {
|
||||
if (authType && modelProvider?.envKey) {
|
||||
const apiKeyFromEnv = env[modelProvider.envKey];
|
||||
if (apiKeyFromEnv) {
|
||||
apiKeyLayers.push(
|
||||
@@ -200,7 +202,7 @@ export function resolveModelConfig(
|
||||
// ---- Base URL ----
|
||||
const baseUrlLayers: Array<ConfigLayer<string>> = [];
|
||||
|
||||
if (modelProvider?.baseUrl) {
|
||||
if (authType && modelProvider?.baseUrl) {
|
||||
baseUrlLayers.push(
|
||||
layer(
|
||||
modelProvider.baseUrl,
|
||||
@@ -227,7 +229,7 @@ export function resolveModelConfig(
|
||||
|
||||
// ---- API Key Env Key (for error messages) ----
|
||||
let apiKeyEnvKey: string | undefined;
|
||||
if (modelProvider?.envKey) {
|
||||
if (authType && modelProvider?.envKey) {
|
||||
apiKeyEnvKey = modelProvider.envKey;
|
||||
sources['apiKeyEnvKey'] = modelProvidersSource(
|
||||
authType,
|
||||
@@ -248,7 +250,7 @@ export function resolveModelConfig(
|
||||
// Build final config
|
||||
const config: ContentGeneratorConfig = {
|
||||
authType,
|
||||
model: modelResult.value,
|
||||
model: modelResult.value || '',
|
||||
apiKey: apiKeyResult?.value,
|
||||
apiKeyEnvKey,
|
||||
baseUrl: baseUrlResult?.value,
|
||||
@@ -335,7 +337,7 @@ function resolveQwenOAuthConfig(
|
||||
function resolveGenerationConfig(
|
||||
settingsConfig: Partial<ContentGeneratorConfig> | undefined,
|
||||
modelProviderConfig: Partial<ContentGeneratorConfig> | undefined,
|
||||
authType: AuthType,
|
||||
authType: AuthType | undefined,
|
||||
modelId: string | undefined,
|
||||
sources: ConfigSources,
|
||||
): Partial<ContentGeneratorConfig> {
|
||||
@@ -343,7 +345,7 @@ function resolveGenerationConfig(
|
||||
|
||||
for (const field of MODEL_GENERATION_CONFIG_FIELDS) {
|
||||
// ModelProvider config takes priority
|
||||
if (modelProviderConfig && field in modelProviderConfig) {
|
||||
if (authType && modelProviderConfig && field in modelProviderConfig) {
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
(result as any)[field] = modelProviderConfig[field];
|
||||
sources[field] = modelProvidersSource(
|
||||
|
||||
@@ -464,6 +464,22 @@ describe('ModelsConfig', () => {
|
||||
expect(gc.apiKeyEnvKey).toBeUndefined();
|
||||
});
|
||||
|
||||
it('should apply Qwen OAuth apiKey placeholder during syncAfterAuthRefresh for fresh users', () => {
|
||||
// Fresh user: authType not selected yet (currentAuthType undefined).
|
||||
const modelsConfig = new ModelsConfig();
|
||||
|
||||
// Config.refreshAuth passes modelId from modelsConfig.getModel(), which falls back to DEFAULT_QWEN_MODEL.
|
||||
modelsConfig.syncAfterAuthRefresh(
|
||||
AuthType.QWEN_OAUTH,
|
||||
modelsConfig.getModel(),
|
||||
);
|
||||
|
||||
const gc = currentGenerationConfig(modelsConfig);
|
||||
expect(gc.model).toBe('coder-model');
|
||||
expect(gc.apiKey).toBe('QWEN_OAUTH_DYNAMIC_TOKEN');
|
||||
expect(gc.apiKeyEnvKey).toBeUndefined();
|
||||
});
|
||||
|
||||
it('should maintain consistency between currentModelId and _generationConfig.model after initialization', () => {
|
||||
const modelProvidersConfig: ModelProvidersConfig = {
|
||||
openai: [
|
||||
|
||||
@@ -70,7 +70,7 @@ export class ModelsConfig {
|
||||
private readonly modelRegistry: ModelRegistry;
|
||||
|
||||
// Current selection state
|
||||
private currentAuthType: AuthType;
|
||||
private currentAuthType: AuthType | undefined;
|
||||
|
||||
// Generation config state
|
||||
private _generationConfig: Partial<ContentGeneratorConfig>;
|
||||
@@ -115,7 +115,7 @@ export class ModelsConfig {
|
||||
}
|
||||
|
||||
private snapshotState(): {
|
||||
currentAuthType: AuthType;
|
||||
currentAuthType: AuthType | undefined;
|
||||
generationConfig: Partial<ContentGeneratorConfig>;
|
||||
generationConfigSources: ContentGeneratorConfigSources;
|
||||
strictModelProviderSelection: boolean;
|
||||
@@ -162,7 +162,7 @@ export class ModelsConfig {
|
||||
this.authTypeWasExplicitlyProvided = options.initialAuthType !== undefined;
|
||||
|
||||
// Initialize selection state
|
||||
this.currentAuthType = options.initialAuthType || AuthType.QWEN_OAUTH;
|
||||
this.currentAuthType = options.initialAuthType;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -175,13 +175,13 @@ export class ModelsConfig {
|
||||
/**
|
||||
* Get current authType
|
||||
*/
|
||||
getCurrentAuthType(): AuthType {
|
||||
getCurrentAuthType(): AuthType | undefined {
|
||||
return this.currentAuthType;
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if authType was explicitly provided (via CLI or settings).
|
||||
* If false, the default QWEN_OAUTH is being used.
|
||||
* If false, no authType was provided yet (fresh user).
|
||||
*/
|
||||
wasAuthTypeExplicitlyProvided(): boolean {
|
||||
return this.authTypeWasExplicitlyProvided;
|
||||
@@ -191,7 +191,9 @@ export class ModelsConfig {
|
||||
* Get available models for current authType
|
||||
*/
|
||||
getAvailableModels(): AvailableModel[] {
|
||||
return this.modelRegistry.getModelsForAuthType(this.currentAuthType);
|
||||
return this.currentAuthType
|
||||
? this.modelRegistry.getModelsForAuthType(this.currentAuthType)
|
||||
: [];
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -231,7 +233,10 @@ export class ModelsConfig {
|
||||
}
|
||||
|
||||
// If model exists in registry, use full switch logic
|
||||
if (this.modelRegistry.hasModel(this.currentAuthType, newModel)) {
|
||||
if (
|
||||
this.currentAuthType &&
|
||||
this.modelRegistry.hasModel(this.currentAuthType, newModel)
|
||||
) {
|
||||
await this.switchModel(this.currentAuthType, newModel);
|
||||
return;
|
||||
}
|
||||
@@ -538,19 +543,26 @@ export class ModelsConfig {
|
||||
* - Qwen OAuth -> OpenAI: handled by switchModel(authType, modelId), always refreshes
|
||||
*/
|
||||
private checkRequiresRefresh(previousModelId: string): boolean {
|
||||
// Defensive: this method is only called after switchModel() sets currentAuthType,
|
||||
// but keep type safety for any future callsites.
|
||||
const authType = this.currentAuthType;
|
||||
if (!authType) {
|
||||
return true;
|
||||
}
|
||||
|
||||
// For Qwen OAuth, model switches within the same authType can always be hot-updated
|
||||
// (coder-model <-> vision-model don't require ContentGenerator recreation)
|
||||
if (this.currentAuthType === AuthType.QWEN_OAUTH) {
|
||||
if (authType === AuthType.QWEN_OAUTH) {
|
||||
return false;
|
||||
}
|
||||
|
||||
// Get previous and current model configs
|
||||
const previousModel = this.modelRegistry.getModel(
|
||||
this.currentAuthType,
|
||||
authType,
|
||||
previousModelId,
|
||||
);
|
||||
const currentModel = this.modelRegistry.getModel(
|
||||
this.currentAuthType,
|
||||
authType,
|
||||
this._generationConfig.model || '',
|
||||
);
|
||||
|
||||
@@ -602,8 +614,11 @@ export class ModelsConfig {
|
||||
if (modelId && this.modelRegistry.hasModel(authType, modelId)) {
|
||||
const resolved = this.modelRegistry.getModel(authType, modelId);
|
||||
if (resolved) {
|
||||
this.applyResolvedModelDefaults(resolved);
|
||||
// Ensure applyResolvedModelDefaults can correctly apply authType-specific
|
||||
// behavior (e.g., Qwen OAuth placeholder token) by setting currentAuthType
|
||||
// before applying defaults.
|
||||
this.currentAuthType = authType;
|
||||
this.applyResolvedModelDefaults(resolved);
|
||||
}
|
||||
} else {
|
||||
this.currentAuthType = authType;
|
||||
|
||||
@@ -601,8 +601,17 @@ async function authWithQwenDeviceFlow(
|
||||
console.log('Waiting for authorization to complete...\n');
|
||||
};
|
||||
|
||||
// If browser launch is not suppressed, try to open the URL
|
||||
if (!config.isBrowserLaunchSuppressed()) {
|
||||
// Always show the fallback message in non-interactive environments to ensure
|
||||
// users can see the authorization URL even if browser launching is attempted.
|
||||
// This is critical for headless/remote environments where browser launching
|
||||
// may silently fail without throwing an error.
|
||||
if (config.isBrowserLaunchSuppressed()) {
|
||||
// Browser launch is suppressed, show fallback message
|
||||
showFallbackMessage();
|
||||
} else {
|
||||
// Try to open the URL in browser, but always show the URL as fallback
|
||||
// to handle cases where browser launch silently fails (e.g., headless servers)
|
||||
showFallbackMessage();
|
||||
try {
|
||||
const childProcess = await open(deviceAuth.verification_uri_complete);
|
||||
|
||||
@@ -611,19 +620,19 @@ async function authWithQwenDeviceFlow(
|
||||
// in a minimal Docker container), it will emit an unhandled 'error' event,
|
||||
// causing the entire Node.js process to crash.
|
||||
if (childProcess) {
|
||||
childProcess.on('error', () => {
|
||||
childProcess.on('error', (err) => {
|
||||
console.debug(
|
||||
'Failed to open browser. Visit this URL to authorize:',
|
||||
'Browser launch failed:',
|
||||
err.message || 'Unknown error',
|
||||
);
|
||||
showFallbackMessage();
|
||||
});
|
||||
}
|
||||
} catch (_err) {
|
||||
showFallbackMessage();
|
||||
} catch (err) {
|
||||
console.debug(
|
||||
'Failed to open browser:',
|
||||
err instanceof Error ? err.message : 'Unknown error',
|
||||
);
|
||||
}
|
||||
} else {
|
||||
// Browser launch is suppressed, show fallback message
|
||||
showFallbackMessage();
|
||||
}
|
||||
|
||||
// Emit auth progress event
|
||||
|
||||
@@ -818,7 +818,7 @@ describe('ShellExecutionService child_process fallback', () => {
|
||||
});
|
||||
|
||||
describe('Platform-Specific Behavior', () => {
|
||||
it('should use cmd.exe on Windows', async () => {
|
||||
it('should use cmd.exe and hide window on Windows', async () => {
|
||||
mockPlatform.mockReturnValue('win32');
|
||||
await simulateExecution('dir "foo bar"', (cp) =>
|
||||
cp.emit('exit', 0, null),
|
||||
@@ -829,7 +829,8 @@ describe('ShellExecutionService child_process fallback', () => {
|
||||
[],
|
||||
expect.objectContaining({
|
||||
shell: true,
|
||||
detached: true,
|
||||
detached: false,
|
||||
windowsHide: true,
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
@@ -229,7 +229,8 @@ export class ShellExecutionService {
|
||||
stdio: ['ignore', 'pipe', 'pipe'],
|
||||
windowsVerbatimArguments: true,
|
||||
shell: isWindows ? true : 'bash',
|
||||
detached: true,
|
||||
detached: !isWindows,
|
||||
windowsHide: isWindows,
|
||||
env: {
|
||||
...process.env,
|
||||
QWEN_CODE: '1',
|
||||
|
||||
@@ -36,7 +36,7 @@ interface DiffCommand {
|
||||
args: string[];
|
||||
}
|
||||
|
||||
function commandExists(cmd: string): boolean {
|
||||
export function commandExists(cmd: string): boolean {
|
||||
try {
|
||||
execSync(
|
||||
process.platform === 'win32' ? `where.exe ${cmd}` : `command -v ${cmd}`,
|
||||
@@ -52,7 +52,7 @@ function commandExists(cmd: string): boolean {
|
||||
* Editor command configurations for different platforms.
|
||||
* Each editor can have multiple possible command names, listed in order of preference.
|
||||
*/
|
||||
const editorCommands: Record<
|
||||
export const editorCommands: Record<
|
||||
EditorType,
|
||||
{ win32: string[]; default: string[] }
|
||||
> = {
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@qwen-code/qwen-code-test-utils",
|
||||
"version": "0.6.1",
|
||||
"version": "0.7.0",
|
||||
"private": true,
|
||||
"main": "src/index.ts",
|
||||
"license": "Apache-2.0",
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
"name": "qwen-code-vscode-ide-companion",
|
||||
"displayName": "Qwen Code Companion",
|
||||
"description": "Enable Qwen Code with direct access to your VS Code workspace.",
|
||||
"version": "0.6.1",
|
||||
"version": "0.7.0",
|
||||
"publisher": "qwenlm",
|
||||
"icon": "assets/icon.png",
|
||||
"repository": {
|
||||
|
||||
Reference in New Issue
Block a user