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:
qqqys committed 2026-08-18 18:46:04 +08:00
1 parent 8c6822cd8c
commit c1161a736c
12 files changed
+208 -6

No files matched your search

+3
View File
@@ -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/
+1
View File
@@ -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',
+10
View File
@@ -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',
{
+23
View File
@@ -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([]);
+2
View File
@@ -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(),
+27
View File
@@ -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',
+14 -4
View File
@@ -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
}