diff --git a/packages/cli/src/serve/run-qwen-serve.test.ts b/packages/cli/src/serve/run-qwen-serve.test.ts index 9a40063fda1..906891dea3f 100644 --- a/packages/cli/src/serve/run-qwen-serve.test.ts +++ b/packages/cli/src/serve/run-qwen-serve.test.ts @@ -34,6 +34,7 @@ import { waitForRuntimeStartingForShutdown, } from './run-qwen-serve.js'; import { isBrowserAutomationMcpAvailable } from './cdp-mcp-command.js'; +import * as nativeDirectoryPicker from './native-directory-picker.js'; import { loadServeFastPathEnvironment } from './fast-path-settings.js'; import { loadEnvironment } from '../config/environment.js'; import { RUNTIME_STARTUP_CANCELLED_MESSAGE } from './runtime-startup-errors.js'; @@ -10615,6 +10616,61 @@ describe('runQwenServe runtime startup failures', () => { } }); + it.each([true, false])( + 'mirrors the native directory picker probe on the bootstrap envelopes (available: %s)', + async (available) => { + tmpDir = fs.realpathSync( + fs.mkdtempSync(path.join(os.tmpdir(), 'qws-bootstrap-picker-')), + ); + // Keep the runtime from mounting so the bootstrap `/capabilities` and + // `/daemon/status` envelopes stay the ones being served. + vi.spyOn(acpBridge, 'createAcpSessionBridge').mockImplementation(() => { + throw new Error('runtime boom'); + }); + const probe = vi + .spyOn(nativeDirectoryPicker, 'isNativeDirectoryPickerAvailable') + .mockReturnValue(available); + const handle = await runQwenServe( + { + port: 0, + hostname: '127.0.0.1', + mode: 'http-bridge', + workspace: tmpDir, + maxSessions: 1, + serveWebShell: false, + }, + { resolveOnListen: true }, + ); + try { + await expect(handle.runtimeReady).rejects.toThrow('runtime boom'); + const probeCallsAfterBoot = probe.mock.calls.length; + const capabilities = (await ( + await fetch(`${handle.url}/capabilities`) + ).json()) as { features: string[] }; + const status = (await ( + await fetch(`${handle.url}/daemon/status`) + ).json()) as { capabilities: { features: string[] } }; + if (available) { + expect(capabilities.features).toContain('native_directory_picker'); + expect(status.capabilities.features).toContain( + 'native_directory_picker', + ); + } else { + expect(capabilities.features).not.toContain( + 'native_directory_picker', + ); + expect(status.capabilities.features).not.toContain( + 'native_directory_picker', + ); + } + // Probed once while the bootstrap app was built, not per request. + expect(probe.mock.calls.length).toBe(probeCallsAfterBoot); + } finally { + await handle.close(); + } + }, + ); + it('shuts down a bridge when runtime mounting fails after bridge creation', async () => { tmpDir = fs.realpathSync( fs.mkdtempSync(path.join(os.tmpdir(), 'qws-runtime-partial-fail-')), diff --git a/packages/cli/src/serve/run-qwen-serve.ts b/packages/cli/src/serve/run-qwen-serve.ts index f84fb6021fc..ac96b3d5257 100644 --- a/packages/cli/src/serve/run-qwen-serve.ts +++ b/packages/cli/src/serve/run-qwen-serve.ts @@ -112,6 +112,7 @@ import { getServeProtocolVersions, SERVE_CAPABILITY_REGISTRY, } from './capabilities.js'; +import { isNativeDirectoryPickerAvailable } from './native-directory-picker.js'; import { EXTERNAL_TOOL_GUARD_PROVIDER_ATTACHED_VALUE, EXTERNAL_TOOL_GUARD_REQUIRED_VALUE, @@ -2289,6 +2290,7 @@ function currentServeFeaturesForRunQwenServe( sessionArtifactsPersistenceAvailable: boolean, currentSessionSchedulingAvailable: boolean, env: Readonly>, + nativeDirectoryPickerAvailable: boolean, ): string[] { return getAdvertisedServeFeatures(undefined, { requireAuth: opts.requireAuth === true, @@ -2315,8 +2317,10 @@ function currentServeFeaturesForRunQwenServe( channelManagementAvailable: true, persistentWorkspaceRegistrationAvailable: true, workspaceRuntimeRemovalAvailable: true, - // Advertise the same WS feature flags as the runtime path (serve-features.ts) - // so the bootstrap `/capabilities` window doesn't briefly under-report them. + // Advertise the same host-conditional and WS feature flags as the runtime + // path (serve-features.ts) so the bootstrap `/capabilities` window doesn't + // briefly under-report them. + nativeDirectoryPickerAvailable, clientMcpOverWsEnabled: opts.clientMcpOverWs === true, cdpTunnelOverWsEnabled: opts.cdpTunnelOverWs === true, browserAutomationMcpAvailable: isBrowserAutomationMcpAvailable(opts, env), @@ -2332,6 +2336,7 @@ function createBootstrapCapabilities(input: { currentSessionSchedulingAvailable: boolean; permissionPolicy: PermissionPolicy | undefined; env: Readonly>; + nativeDirectoryPickerAvailable: boolean; }): CapabilitiesEnvelope { return { v: CAPABILITIES_SCHEMA_VERSION, @@ -2346,6 +2351,7 @@ function createBootstrapCapabilities(input: { input.sessionArtifactsPersistenceAvailable, input.currentSessionSchedulingAvailable, input.env, + input.nativeDirectoryPickerAvailable, ), modelServices: [], workspaceCwd: input.boundWorkspace, @@ -2566,6 +2572,11 @@ function createBootstrapServeApp(input: { onHealthServed, } = input; const app = express(); + // The probe stats `/dev/console` (macOS) or scans `PATH` for `zenity` + // (Linux), and both bootstrap endpoints below rebuild their envelope per + // request, so evaluate it once here — the runtime path likewise probes once, + // at `createApp` time (server.ts). + const nativeDirectoryPickerAvailable = isNativeDirectoryPickerAvailable(); installSameOriginOriginStrip(app, getPort); if (opts.allowOrigins && opts.allowOrigins.length > 0) { @@ -2626,6 +2637,7 @@ function createBootstrapServeApp(input: { currentSessionSchedulingAvailable, permissionPolicy, env: process.env, + nativeDirectoryPickerAvailable, }), ); }); @@ -2765,6 +2777,7 @@ function createBootstrapServeApp(input: { sessionArtifactsPersistenceAvailable, currentSessionSchedulingAvailable, process.env, + nativeDirectoryPickerAvailable, ), }, runtime: {