mirror of
https://github.com/QwenLM/qwen-code.git
synced 2026-11-07 18:36:00 -10:00
feat(serve): hide workspace Browse on headless daemon hosts
The Web Shell "Add workspace" dialog shows a Browse button that opens a native OS directory picker on the daemon host (osascript on macOS, PowerShell on Windows, zenity on Linux). On headless hosts the picker can never open — zenity exits with "cannot open display" — so the button only surfaces a guaranteed error toast on every click. - Add isNativeDirectoryPickerAvailable: macOS/Windows always pass; Linux requires DISPLAY/WAYLAND_DISPLAY plus an executable zenity file on PATH (a directory named zenity does not count) - Advertise a new conditional native_directory_picker serve feature only when the probe passes; the bootstrap reduced set omits it like its sibling registration tags, which fails closed - Web Shell passes onPick to the Add workspace dialog only when the feature is present, so the button is hidden on headless hosts - Tolerate the host-conditional tag in the integration capabilities snapshot and register the new process.env access with the guard
This commit is contained in:
1 parent
8c6822cd8c
commit
c1161a736c
12 files changed
+208
-6
No files matched your search
@@ -139,3 +139,6 @@ tmp/
|
||||
# Auto-generated computer-use marker can also appear under nested packages.
|
||||
**/.qwen/computer-use/
|
||||
.playwright-mcp/
|
||||
|
||||
# Tool state written to the repo root when $HOME is unset (e.g. gh CLI)
|
||||
.local/
|
||||
@@ -480,6 +480,7 @@ operator diagnostic snapshot documented below.
|
||||
| `persistent_workspace_registration` | a workspace registration store is wired into the daemon. Production `runQwenServe` supplies the user-level store automatically; direct `createServeApp` embeds must inject one explicitly and own startup restoration of their workspace registry. |
|
||||
| `scratch_workspace_registration` | managed scratch workspace creation is available — a runtime factory, a validated managed scratch root, and runtime disposal are wired, and every managed runtime respects the scratch root boundary. |
|
||||
| `workspace_runtime_removal` | removable dynamic or persistence-restored secondary runtimes can be drained and removed through the management route. |
|
||||
| `native_directory_picker` | the daemon host can open a native OS directory picker (`osascript` on macOS, PowerShell on Windows, `zenity` on a Linux host with a display). Headless hosts omit the tag so clients hide the Browse affordance instead of surfacing a guaranteed picker failure. |
|
||||
| `workspace_qualified_acp` | ACP HTTP and multi-workspace runtimes are active, so the plural ACP endpoint can select a secondary runtime. |
|
||||
| `workspace_qualified_voice` | multi-workspace runtimes and the shared ACP/Voice WebSocket listener are active, so every workspace-qualified Voice modality is reachable for a secondary runtime. |
|
||||
| `workspace_qualified_memory` | ACP HTTP and multi-workspace runtimes are active, so workspace-qualified managed-memory routes can select a per-workspace task lane for remember, forget, and dream operations. |
|
||||
|
||||
@@ -40,6 +40,7 @@ import {
|
||||
Storage,
|
||||
type ChatRecord,
|
||||
} from '@qwen-code/qwen-code-core';
|
||||
import { isNativeDirectoryPickerAvailable } from '../../packages/cli/src/serve/native-directory-picker.js';
|
||||
|
||||
const __dirname = path.dirname(fileURLToPath(import.meta.url));
|
||||
// Match the rest of the integration suite: prefer the bundled CLI
|
||||
@@ -293,6 +294,9 @@ describe('qwen serve — capabilities envelope', () => {
|
||||
// `require_auth`, `allow_origin`, `cdp_tunnel_over_ws`,
|
||||
// `prompt_absolute_deadline`, `writer_idle_timeout`,
|
||||
// `workspace_voice_transcription`, `rate_limit`, `channel_reload`.
|
||||
// `native_directory_picker` is host-conditional (the daemon host's GUI
|
||||
// environment, not a spawn flag) and is spliced at its registry
|
||||
// position below.
|
||||
// Pool tags (`mcp_workspace_pool`, `mcp_pool_restart`) ARE present
|
||||
// because the workspace MCP pool is on by default, as are
|
||||
// `workspace_settings`, `workspace_permissions`, `workspace_voice`,
|
||||
@@ -402,6 +406,9 @@ describe('qwen serve — capabilities envelope', () => {
|
||||
'persistent_workspace_registration',
|
||||
'workspace_display_name',
|
||||
'workspace_runtime_removal',
|
||||
...(isNativeDirectoryPickerAvailable()
|
||||
? ['native_directory_picker']
|
||||
: []),
|
||||
'workspace_qualified_rest_core',
|
||||
'extension_management_v2',
|
||||
'workspace_persisted_transcript',
|
||||
|
||||
@@ -354,6 +354,11 @@ export const SERVE_CAPABILITY_REGISTRY = {
|
||||
workspace_display_name: { since: 'v1' },
|
||||
scratch_workspace_registration: { since: 'v1' },
|
||||
workspace_runtime_removal: { since: 'v1' },
|
||||
// A native OS directory picker can be opened on the daemon host
|
||||
// (osascript on macOS, PowerShell on Windows, zenity on a Linux host
|
||||
// with a display). Headless hosts omit the tag so clients hide the
|
||||
// Browse affordance instead of surfacing a guaranteed picker failure.
|
||||
native_directory_picker: { since: 'v1' },
|
||||
// Workspace-qualified core REST routes under `/workspaces/:workspace/...`.
|
||||
// Covers core file read/write/upload, status/permissions/trust/lifecycle/MCP/tool,
|
||||
// memory, workspace agent CRUD, and persisted session organization surfaces.
|
||||
@@ -495,6 +500,7 @@ export interface AdvertiseFeatureToggles {
|
||||
persistentWorkspaceRegistrationAvailable?: boolean;
|
||||
scratchWorkspaceRegistrationAvailable?: boolean;
|
||||
workspaceRuntimeRemovalAvailable?: boolean;
|
||||
nativeDirectoryPickerAvailable?: boolean;
|
||||
/**
|
||||
* Whether the HTTP ACP surface is enabled (default on; opts out via
|
||||
* QWEN_SERVE_ACP_HTTP=0). Workspace-qualified ACP is only advertised when on.
|
||||
@@ -624,6 +630,10 @@ export const CONDITIONAL_SERVE_FEATURES: ReadonlyMap<
|
||||
'workspace_runtime_removal',
|
||||
(toggles) => toggles.workspaceRuntimeRemovalAvailable === true,
|
||||
],
|
||||
[
|
||||
'native_directory_picker',
|
||||
(toggles) => toggles.nativeDirectoryPickerAvailable === true,
|
||||
],
|
||||
[
|
||||
'workspace_qualified_acp',
|
||||
// The plural routes are pre-mounted for workspaces registered after app
|
||||
|
||||
@@ -4,6 +4,9 @@
|
||||
* SPDX-License-Identifier: Apache-2.0
|
||||
*/
|
||||
|
||||
import { chmodSync, mkdirSync, mkdtempSync, writeFileSync } from 'node:fs';
|
||||
import { tmpdir } from 'node:os';
|
||||
import { join } from 'node:path';
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
|
||||
const { execFileAsyncMock } = vi.hoisted(() => ({
|
||||
@@ -22,8 +25,21 @@ vi.mock('node:child_process', async (importOriginal) => {
|
||||
};
|
||||
});
|
||||
|
||||
const { pickNativeDirectory, NativeDirectoryPickerUnavailableError } =
|
||||
await import('./native-directory-picker.js');
|
||||
const {
|
||||
pickNativeDirectory,
|
||||
isNativeDirectoryPickerAvailable,
|
||||
NativeDirectoryPickerUnavailableError,
|
||||
} = await import('./native-directory-picker.js');
|
||||
|
||||
const zenityDir = mkdtempSync(join(tmpdir(), 'picker-zenity-'));
|
||||
writeFileSync(join(zenityDir, 'zenity'), '#!/bin/sh\nexit 0\n');
|
||||
chmodSync(join(zenityDir, 'zenity'), 0o755);
|
||||
const emptyDir = mkdtempSync(join(tmpdir(), 'picker-empty-'));
|
||||
const nonExecutableZenityDir = mkdtempSync(join(tmpdir(), 'picker-nonexec-'));
|
||||
writeFileSync(join(nonExecutableZenityDir, 'zenity'), '#!/bin/sh\nexit 0\n');
|
||||
chmodSync(join(nonExecutableZenityDir, 'zenity'), 0o644);
|
||||
const zenityDirectoryEntryDir = mkdtempSync(join(tmpdir(), 'picker-subdir-'));
|
||||
mkdirSync(join(zenityDirectoryEntryDir, 'zenity'));
|
||||
|
||||
function setPlatform(platform: NodeJS.Platform) {
|
||||
vi.spyOn(process, 'platform', 'get').mockReturnValue(platform);
|
||||
@@ -189,3 +205,65 @@ describe('pickNativeDirectory', () => {
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe('isNativeDirectoryPickerAvailable', () => {
|
||||
it('is available on macOS and Windows regardless of env', () => {
|
||||
setPlatform('darwin');
|
||||
expect(isNativeDirectoryPickerAvailable({})).toBe(true);
|
||||
setPlatform('win32');
|
||||
expect(isNativeDirectoryPickerAvailable({})).toBe(true);
|
||||
});
|
||||
|
||||
it('is unavailable on unsupported platforms', () => {
|
||||
setPlatform('aix');
|
||||
expect(
|
||||
isNativeDirectoryPickerAvailable({ DISPLAY: ':0', PATH: zenityDir }),
|
||||
).toBe(false);
|
||||
});
|
||||
|
||||
it('requires a display on Linux', () => {
|
||||
setPlatform('linux');
|
||||
expect(isNativeDirectoryPickerAvailable({ PATH: zenityDir })).toBe(false);
|
||||
});
|
||||
|
||||
it('requires an executable zenity on PATH on Linux', () => {
|
||||
setPlatform('linux');
|
||||
expect(
|
||||
isNativeDirectoryPickerAvailable({ DISPLAY: ':0', PATH: emptyDir }),
|
||||
).toBe(false);
|
||||
expect(
|
||||
isNativeDirectoryPickerAvailable({ DISPLAY: ':0', PATH: zenityDir }),
|
||||
).toBe(true);
|
||||
expect(
|
||||
isNativeDirectoryPickerAvailable({
|
||||
WAYLAND_DISPLAY: 'wayland-0',
|
||||
PATH: zenityDir,
|
||||
}),
|
||||
).toBe(true);
|
||||
});
|
||||
|
||||
it('rejects a zenity without the executable bit on Linux', () => {
|
||||
setPlatform('linux');
|
||||
expect(
|
||||
isNativeDirectoryPickerAvailable({
|
||||
DISPLAY: ':0',
|
||||
PATH: nonExecutableZenityDir,
|
||||
}),
|
||||
).toBe(false);
|
||||
});
|
||||
|
||||
it('rejects a directory named zenity on PATH on Linux', () => {
|
||||
setPlatform('linux');
|
||||
expect(
|
||||
isNativeDirectoryPickerAvailable({
|
||||
DISPLAY: ':0',
|
||||
PATH: zenityDirectoryEntryDir,
|
||||
}),
|
||||
).toBe(false);
|
||||
});
|
||||
|
||||
it('requires PATH to be set on Linux', () => {
|
||||
setPlatform('linux');
|
||||
expect(isNativeDirectoryPickerAvailable({ DISPLAY: ':0' })).toBe(false);
|
||||
});
|
||||
});
|
||||
@@ -5,6 +5,8 @@
|
||||
*/
|
||||
|
||||
import { execFile } from 'node:child_process';
|
||||
import { accessSync, constants, statSync } from 'node:fs';
|
||||
import { delimiter, join } from 'node:path';
|
||||
import { promisify } from 'node:util';
|
||||
|
||||
const execFileAsync = promisify(execFile);
|
||||
@@ -16,6 +18,34 @@ const PICKER_TIMEOUT_MS = 300_000;
|
||||
|
||||
export class NativeDirectoryPickerUnavailableError extends Error {}
|
||||
|
||||
// Startup probe so `/capabilities` can omit the picker feature on headless
|
||||
// hosts and clients hide the Browse affordance instead of surfacing a
|
||||
// guaranteed `cannot open display` failure.
|
||||
export function isNativeDirectoryPickerAvailable(
|
||||
env: Readonly<Record<string, string | undefined>> = process.env,
|
||||
): boolean {
|
||||
if (process.platform === 'darwin' || process.platform === 'win32') {
|
||||
return true;
|
||||
}
|
||||
if (process.platform !== 'linux') return false;
|
||||
if (!env['DISPLAY'] && !env['WAYLAND_DISPLAY']) return false;
|
||||
return (env['PATH'] ?? '')
|
||||
.split(delimiter)
|
||||
.some((dir) => dir !== '' && isExecutableFile(join(dir, 'zenity')));
|
||||
}
|
||||
|
||||
function isExecutableFile(file: string): boolean {
|
||||
try {
|
||||
// A directory passes an X_OK probe (search permission) but cannot be
|
||||
// exec'd, so it must not count as an installed zenity.
|
||||
if (!statSync(file).isFile()) return false;
|
||||
accessSync(file, constants.X_OK);
|
||||
return true;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
export async function pickNativeDirectory(
|
||||
signal?: AbortSignal,
|
||||
): Promise<string | undefined> {
|
||||
|
||||
@@ -142,6 +142,14 @@ const allowedProcessEnvAccesses = normalizeAllowances([
|
||||
accesses: { 'key:QWEN_AUDIT_RAW_PATHS': 1 },
|
||||
},
|
||||
],
|
||||
[
|
||||
'packages/cli/src/serve/native-directory-picker.ts',
|
||||
{
|
||||
reason:
|
||||
'The native directory picker availability probe defaults to the daemon process environment when no explicit environment is supplied.',
|
||||
accesses: { whole: 1 },
|
||||
},
|
||||
],
|
||||
[
|
||||
'packages/cli/src/serve/run-qwen-serve.ts',
|
||||
{
|
||||
|
||||
@@ -66,6 +66,7 @@ import {
|
||||
SERVE_CAPABILITY_REGISTRY,
|
||||
type ServeProtocolVersion,
|
||||
} from './capabilities.js';
|
||||
import { isNativeDirectoryPickerAvailable } from './native-directory-picker.js';
|
||||
import type {
|
||||
CancelNotification,
|
||||
PromptRequest,
|
||||
@@ -754,6 +755,7 @@ const EXPECTED_REGISTERED_FEATURES = [
|
||||
'workspace_display_name',
|
||||
'scratch_workspace_registration',
|
||||
'workspace_runtime_removal',
|
||||
'native_directory_picker',
|
||||
'workspace_qualified_rest_core',
|
||||
'workspace_qualified_voice',
|
||||
'workspace_qualified_memory',
|
||||
@@ -3053,6 +3055,24 @@ describe('createServeApp', () => {
|
||||
);
|
||||
continue;
|
||||
}
|
||||
if (feature === 'native_directory_picker') {
|
||||
expect(predicate({ nativeDirectoryPickerAvailable: true })).toBe(
|
||||
true,
|
||||
);
|
||||
expect(predicate({ nativeDirectoryPickerAvailable: false })).toBe(
|
||||
false,
|
||||
);
|
||||
expect(predicate({})).toBe(false);
|
||||
expect(
|
||||
getAdvertisedServeFeatures(undefined, {
|
||||
nativeDirectoryPickerAvailable: true,
|
||||
}),
|
||||
).toContain(feature);
|
||||
expect(getAdvertisedServeFeatures(undefined, {})).not.toContain(
|
||||
feature,
|
||||
);
|
||||
continue;
|
||||
}
|
||||
if (feature === 'workspace_trust_hot_reload') {
|
||||
expect(predicate({ workspaceTrustHotReloadAvailable: true })).toBe(
|
||||
true,
|
||||
@@ -3899,6 +3919,9 @@ describe('createServeApp', () => {
|
||||
sessionGenerationAvailable: true,
|
||||
workspaceGenerationAvailable: true,
|
||||
acpHttpEnabled: true,
|
||||
// Mirror the server.ts probe so the expectation matches on both
|
||||
// GUI and headless hosts.
|
||||
nativeDirectoryPickerAvailable: isNativeDirectoryPickerAvailable(),
|
||||
}),
|
||||
);
|
||||
expect(res.body.modelServices).toEqual([]);
|
||||
|
||||
@@ -229,6 +229,7 @@ import {
|
||||
type WorkspaceManagementHandle,
|
||||
type WorkspaceRuntimeRemovalController,
|
||||
} from './routes/workspace-management.js';
|
||||
import { isNativeDirectoryPickerAvailable } from './native-directory-picker.js';
|
||||
import type { WorkspaceRegistrationStore } from './workspace-registration-store.js';
|
||||
import {
|
||||
registerWorkspaceGitRoutes,
|
||||
@@ -974,6 +975,7 @@ export function createServeApp(
|
||||
acpHttpEnabled: acpHttpEnabledAtBoot,
|
||||
workspaceRuntimeRemovalAvailable:
|
||||
deps.workspaceRuntimeRemoval !== undefined,
|
||||
nativeDirectoryPickerAvailable: isNativeDirectoryPickerAvailable(),
|
||||
workspaceTrustHotReloadAvailable:
|
||||
deps.workspaceTrustHotReloadAvailable === true,
|
||||
isPrimaryWorkspaceTrusted: () => isPrimaryWorkspaceTrusted(),
|
||||
|
||||
@@ -57,6 +57,7 @@ interface CreateServeFeaturesDeps {
|
||||
realtimeVoiceEnabled: () => boolean;
|
||||
acpHttpEnabled?: boolean;
|
||||
workspaceRuntimeRemovalAvailable?: boolean;
|
||||
nativeDirectoryPickerAvailable?: boolean;
|
||||
workspaceTrustHotReloadAvailable?: boolean;
|
||||
isPrimaryWorkspaceTrusted?: () => boolean;
|
||||
env?: Readonly<Record<string, string | undefined>>;
|
||||
@@ -91,6 +92,7 @@ export function createServeFeatures(
|
||||
realtimeVoiceEnabled,
|
||||
acpHttpEnabled,
|
||||
workspaceRuntimeRemovalAvailable,
|
||||
nativeDirectoryPickerAvailable,
|
||||
workspaceTrustHotReloadAvailable,
|
||||
} = deps;
|
||||
const getEnv = deps.getEnv ?? (() => deps.env ?? process.env);
|
||||
@@ -143,6 +145,7 @@ export function createServeFeatures(
|
||||
scratchWorkspaceRegistrationAvailable:
|
||||
scratchWorkspaceRegistrationAvailable(),
|
||||
workspaceRuntimeRemovalAvailable,
|
||||
nativeDirectoryPickerAvailable,
|
||||
workspaceTrustHotReloadAvailable,
|
||||
acpHttpEnabled: currentAcpHttpEnabled,
|
||||
realtimeVoiceEnabled: realtimeVoiceEnabled(),
|
||||
|
||||
@@ -8733,6 +8733,7 @@ describe('App session callbacks', () => {
|
||||
'dynamic_workspace_registration',
|
||||
'persistent_workspace_registration',
|
||||
'workspace_display_name',
|
||||
'native_directory_picker',
|
||||
],
|
||||
workspaces: [
|
||||
{
|
||||
@@ -8792,6 +8793,32 @@ describe('App session callbacks', () => {
|
||||
).toHaveLength(1);
|
||||
});
|
||||
|
||||
it('omits the directory picker on headless daemon hosts', async () => {
|
||||
mockWorkspace.capabilities = {
|
||||
features: ['dynamic_workspace_registration'],
|
||||
workspaces: [
|
||||
{
|
||||
id: 'primary',
|
||||
cwd: '/tmp/project',
|
||||
primary: true,
|
||||
trusted: true,
|
||||
},
|
||||
],
|
||||
} as typeof mockWorkspace.capabilities;
|
||||
const { container } = renderApp();
|
||||
await flush();
|
||||
|
||||
act(() => {
|
||||
container
|
||||
.querySelector<HTMLButtonElement>('[data-testid="open-add-workspace"]')
|
||||
?.click();
|
||||
});
|
||||
expect(
|
||||
container.querySelectorAll('[data-testid="add-workspace-dialog"]'),
|
||||
).toHaveLength(1);
|
||||
expect(testState.latestAddWorkspaceDialogProps?.onPick).toBeUndefined();
|
||||
});
|
||||
|
||||
it('forwards a supported workspace display name through the shared mutation lane', async () => {
|
||||
const added = {
|
||||
id: 'payments',
|
||||
|
||||
@@ -2250,6 +2250,11 @@ export function App({
|
||||
const workspaceDisplayNameSupported =
|
||||
workspace.capabilities?.features?.includes('workspace_display_name') ===
|
||||
true;
|
||||
// Headless daemon hosts omit the tag so the Browse affordance stays
|
||||
// hidden instead of failing on every click.
|
||||
const nativeDirectoryPickerSupported =
|
||||
workspace.capabilities?.features?.includes('native_directory_picker') ===
|
||||
true;
|
||||
const gitHubPrsSupported =
|
||||
workspace.capabilities?.features?.includes('workspace_github_prs') === true;
|
||||
const [showAddWorkspaceDialog, setShowAddWorkspaceDialog] = useState(false);
|
||||
@@ -11308,10 +11313,15 @@ export function App({
|
||||
onClose={() => setShowAddWorkspaceDialog(false)}
|
||||
onAdd={handleAddWorkspace}
|
||||
onSuggest={workspaceActions.suggestWorkspacePaths}
|
||||
onPick={async () => {
|
||||
const result = await workspaceActions.pickWorkspaceDirectory();
|
||||
return result.selected ? result.path : undefined;
|
||||
}}
|
||||
onPick={
|
||||
nativeDirectoryPickerSupported
|
||||
? async () => {
|
||||
const result =
|
||||
await workspaceActions.pickWorkspaceDirectory();
|
||||
return result.selected ? result.path : undefined;
|
||||
}
|
||||
: undefined
|
||||
}
|
||||
persistenceSupported={
|
||||
persistentWorkspaceRegistrationSupported
|
||||
}
|
||||
|
||||
Reference in new issue
Block a user