From 18cc73ce0519c9c8ed6bc76795c467eaed684658 Mon Sep 17 00:00:00 2001 From: ytahdn <1294726970@qq.com> Date: Sat, 20 Jun 2026 08:37:22 +0800 Subject: [PATCH] feat(web-shell): add extension management (#5398) Co-authored-by: ytahdn --- eslint.config.js | 1 + packages/acp-bridge/src/bridge.test.ts | 112 ++ packages/acp-bridge/src/bridge.ts | 55 + packages/acp-bridge/src/bridgeTypes.ts | 30 + packages/acp-bridge/src/status.ts | 23 + .../cli/src/acp-integration/acpAgent.test.ts | 112 ++ packages/cli/src/acp-integration/acpAgent.ts | 27 + packages/cli/src/serve/runQwenServe.ts | 2 + packages/cli/src/serve/server.test.ts | 1477 ++++++++++++++++- packages/cli/src/serve/server.ts | 865 +++++++++- .../__tests__/facade.test.ts | 41 + .../cli/src/serve/workspace-service/index.ts | 15 + .../cli/src/serve/workspace-service/types.ts | 17 + packages/sdk-typescript/scripts/build.js | 4 +- .../sdk-typescript/src/daemon/DaemonClient.ts | 117 +- packages/sdk-typescript/src/daemon/events.ts | 66 +- packages/sdk-typescript/src/daemon/index.ts | 9 + packages/sdk-typescript/src/daemon/types.ts | 53 + .../src/daemon/ui/normalizer.ts | 43 + .../sdk-typescript/src/daemon/ui/terminal.ts | 28 + .../sdk-typescript/src/daemon/ui/types.ts | 19 + .../sdk-typescript/test/unit/daemonUi.test.ts | 69 + packages/web-shell/client/App.tsx | 201 ++- .../client/completions/slashCompletion.ts | 8 + .../components/dialogs/ExtensionsDialog.tsx | 574 +++++++ .../client/constants/localCommands.ts | 6 + packages/web-shell/client/i18n.tsx | 149 ++ packages/webui/src/daemon-react-sdk.ts | 3 + .../daemon/session/DaemonSessionProvider.tsx | 24 +- packages/webui/src/daemon/session/types.ts | 16 + .../webui/src/daemon/workspace/actions.ts | 64 + packages/webui/src/daemon/workspace/types.ts | 38 + 32 files changed, 4213 insertions(+), 55 deletions(-) create mode 100644 packages/web-shell/client/components/dialogs/ExtensionsDialog.tsx diff --git a/eslint.config.js b/eslint.config.js index 7415eeab4a..a27c07f821 100644 --- a/eslint.config.js +++ b/eslint.config.js @@ -26,6 +26,7 @@ export default tseslint.config( '.integration-tests/**', 'packages/**/.integration-test/**', 'dist/**', + 'demo/**/dist/**', 'docs-site/.next/**', 'docs-site/out/**', '.qwen/**', diff --git a/packages/acp-bridge/src/bridge.test.ts b/packages/acp-bridge/src/bridge.test.ts index d4c61a5d9e..35eac91e98 100644 --- a/packages/acp-bridge/src/bridge.test.ts +++ b/packages/acp-bridge/src/bridge.test.ts @@ -384,6 +384,118 @@ describe('createAcpSessionBridge', () => { await bridge.shutdown(); }); + it('refreshes extensions across live sessions and broadcasts merged results', async () => { + const handles: ChannelHandle[] = []; + const bridge = makeBridge({ + channelFactory: async () => { + const h = makeChannel({ + extMethodImpl: (method, params) => { + if ( + method === 'qwen/control/workspace/extensions/refresh' && + String(params['sessionId']).endsWith('#2') + ) { + throw new Error('refresh failed'); + } + return {}; + }, + }); + handles.push(h); + return h.channel; + }, + }); + const first = await bridge.spawnOrAttach({ + workspaceCwd: WS_A, + sessionScope: 'thread', + }); + const second = await bridge.spawnOrAttach({ + workspaceCwd: WS_A, + sessionScope: 'thread', + }); + const abort = new AbortController(); + const iter = bridge.subscribeEvents(first.sessionId, { + signal: abort.signal, + }); + const nextEvent = iter[Symbol.asyncIterator]().next(); + + const result = await bridge.refreshExtensionsForAllSessions({ + status: 'updated', + name: 'test-ext', + }); + + expect(result).toEqual({ refreshed: 1, failed: 1 }); + expect(handles[0]?.agent.extMethodCalls).toEqual([ + { + method: 'qwen/control/workspace/extensions/refresh', + params: { sessionId: first.sessionId }, + }, + { + method: 'qwen/control/workspace/extensions/refresh', + params: { sessionId: second.sessionId }, + }, + ]); + const event = await nextEvent; + expect(event.value).toMatchObject({ + type: 'extensions_changed', + data: { + status: 'updated', + name: 'test-ext', + refreshed: 1, + failed: 1, + }, + }); + abort.abort(); + await bridge.shutdown(); + }); + + it('does not refresh or broadcast extensions when no sessions are live', async () => { + const bridge = makeBridge(); + + await expect(bridge.refreshExtensionsForAllSessions()).resolves.toEqual({ + refreshed: 0, + failed: 0, + }); + + await bridge.shutdown(); + }); + + it('skips dying sessions when refreshing extensions', async () => { + let releaseKill: (() => void) | undefined; + const handles: ChannelHandle[] = []; + const bridge = makeBridge({ + channelFactory: async () => { + const h = makeChannel(); + const originalKill = h.channel.kill; + h.channel.kill = async () => { + await new Promise((resolve) => { + releaseKill = resolve; + }); + await originalKill(); + }; + handles.push(h); + return h.channel; + }, + }); + const session = await bridge.spawnOrAttach({ workspaceCwd: WS_A }); + const killPromise = bridge.killSession(session.sessionId); + await vi.waitFor(() => { + expect(releaseKill).toBeDefined(); + }); + + await expect(bridge.refreshExtensionsForAllSessions()).resolves.toEqual({ + refreshed: 0, + failed: 0, + }); + expect( + handles[0]?.agent.extMethodCalls.filter( + (call) => call.method === 'qwen/control/workspace/extensions/refresh', + ), + ).toEqual([]); + + releaseKill?.(); + await killPromise; + await bridge.shutdown(); + }); + it('rejects session status requests for unknown sessions', async () => { const bridge = makeBridge({ channelFactory: async () => makeChannel().channel, diff --git a/packages/acp-bridge/src/bridge.ts b/packages/acp-bridge/src/bridge.ts index 706f89cb22..48858f5525 100644 --- a/packages/acp-bridge/src/bridge.ts +++ b/packages/acp-bridge/src/bridge.ts @@ -3812,6 +3812,61 @@ export function createAcpSessionBridge(opts: BridgeOptions): AcpSessionBridge { ); }, + async refreshExtensionsForAllSessions(data) { + const sessions = Array.from(byId.values()); + + const results = await Promise.all( + sessions.map(async (entry) => { + const info = channelInfoForEntry(entry); + if (!info || info.isDying) { + return { refreshed: 0, failed: 0 }; + } + try { + await Promise.race([ + withTimeout( + entry.connection.extMethod( + SERVE_CONTROL_EXT_METHODS.workspaceExtensionsRefresh, + { sessionId: entry.sessionId }, + ), + 30_000, + SERVE_CONTROL_EXT_METHODS.workspaceExtensionsRefresh, + ), + getTransportClosedReject(entry), + ]); + return { refreshed: 1, failed: 0 }; + } catch (err) { + writeServeDebugLine( + `refreshExtensions: session ${entry.sessionId} failed: ` + + `${err instanceof Error ? err.message : String(err)}`, + ); + return { refreshed: 0, failed: 1 }; + } + }), + ); + + const refreshed = results.reduce( + (sum, result) => sum + result.refreshed, + 0, + ); + const failed = results.reduce((sum, result) => sum + result.failed, 0); + + if (refreshed > 0 || failed > 0 || data?.status !== undefined) { + broadcastWorkspaceEvent({ + type: 'extensions_changed', + data: { ...data, refreshed, failed }, + }); + } + + return { refreshed, failed }; + }, + + broadcastExtensionsChanged(data) { + broadcastWorkspaceEvent({ + type: 'extensions_changed', + data, + }); + }, + async setSessionModel(sessionId, req, context) { const entry = byId.get(sessionId); if (!entry) throw new SessionNotFoundError(sessionId); diff --git a/packages/acp-bridge/src/bridgeTypes.ts b/packages/acp-bridge/src/bridgeTypes.ts index c4ee71e968..16a51f7a31 100644 --- a/packages/acp-bridge/src/bridgeTypes.ts +++ b/packages/acp-bridge/src/bridgeTypes.ts @@ -239,6 +239,22 @@ export interface BridgeDaemonStatusSnapshot { sessions: BridgeDaemonSessionDiagnostic[]; } +export interface BridgeExtensionsChangedData { + refreshed: number; + failed: number; + status?: + | 'installed' + | 'enabled' + | 'disabled' + | 'updated' + | 'uninstalled' + | 'failed'; + source?: string; + name?: string; + version?: string; + error?: string; +} + export interface AcpSessionBridge { /** Read-only daemon diagnostics for status endpoints. */ getDaemonStatusSnapshot(): BridgeDaemonStatusSnapshot; @@ -471,6 +487,20 @@ export interface AcpSessionBridge { /** Read workspace-level installed extension status. */ getWorkspaceExtensionsStatus(): Promise; + /** + * Broadcast extension refresh to all active sessions and emit an + * `extensions_changed` workspace event when complete. + */ + refreshExtensionsForAllSessions( + data?: Omit, + ): Promise<{ + refreshed: number; + failed: number; + }>; + + /** Emit an extension lifecycle event without refreshing sessions. */ + broadcastExtensionsChanged(data: BridgeExtensionsChangedData): void; + /** * Switch the active model service for a session. Throws * `SessionNotFoundError` for unknown ids. diff --git a/packages/acp-bridge/src/status.ts b/packages/acp-bridge/src/status.ts index 068c645f63..3cf5f0253c 100644 --- a/packages/acp-bridge/src/status.ts +++ b/packages/acp-bridge/src/status.ts @@ -140,6 +140,7 @@ export const SERVE_CONTROL_EXT_METHODS = { workspaceMcpRuntimeAdd: 'qwen/control/workspace/mcp/runtime-add', workspaceMcpRuntimeRemove: 'qwen/control/workspace/mcp/runtime-remove', workspaceReload: 'qwen/control/workspace/reload', + workspaceExtensionsRefresh: 'qwen/control/workspace/extensions/refresh', } as const; export type ServeStatus = @@ -873,6 +874,26 @@ export interface ServeExtensionCapabilities { hasSettings: boolean; } +export type ServeExtensionUpdateState = + | 'checking for updates' + | 'updated, needs restart' + | 'updating' + | 'updated' + | 'update available' + | 'up to date' + | 'error' + | 'not updatable' + | 'unknown'; + +export interface ServeExtensionDetails { + mcpServers: string[]; + commands: string[]; + skills: string[]; + agents: string[]; + contextFiles: string[]; + settings: string[]; +} + export interface ServeExtensionEntry { kind: 'extension'; id: string; @@ -886,7 +907,9 @@ export interface ServeExtensionEntry { originSource?: ServeExtensionOriginSource; ref?: string; autoUpdate?: boolean; + updateState?: ServeExtensionUpdateState; capabilities: ServeExtensionCapabilities; + details?: ServeExtensionDetails; } export interface ServeWorkspaceExtensionsStatus { diff --git a/packages/cli/src/acp-integration/acpAgent.test.ts b/packages/cli/src/acp-integration/acpAgent.test.ts index 5bbbe5fcf9..f10d45056f 100644 --- a/packages/cli/src/acp-integration/acpAgent.test.ts +++ b/packages/cli/src/acp-integration/acpAgent.test.ts @@ -6245,4 +6245,116 @@ describe('sessionLanguage multi-session propagation', () => { mockConnectionState.resolve(); await agentPromise; }); + + it('refreshes extension commands for the live session', async () => { + const extensionManager = { + refreshCache: vi.fn().mockResolvedValue(undefined), + refreshTools: vi.fn().mockResolvedValue(undefined), + }; + const cfg = makeConfig({ + getSessionId: vi.fn().mockReturnValue('s-ext'), + getExtensionManager: vi.fn().mockReturnValue(extensionManager), + }); + const sendAvailableCommandsUpdate = vi.fn().mockResolvedValue(undefined); + + vi.mocked(loadSettings).mockReturnValue({ + merged: { mcpServers: {} }, + getUserHooks: vi.fn().mockReturnValue({}), + getProjectHooks: vi.fn().mockReturnValue({}), + } as unknown as LoadedSettings); + vi.mocked(loadCliConfig).mockResolvedValue(cfg as unknown as Config); + vi.mocked(Session).mockImplementation( + () => + ({ + getId: vi.fn().mockReturnValue('s-ext'), + getConfig: vi.fn().mockReturnValue(cfg), + sendAvailableCommandsUpdate, + installRewriter: vi.fn(), + startCronScheduler: vi.fn(), + dispose: vi.fn(), + }) as unknown as InstanceType, + ); + + const agentPromise = runAcpAgent( + makeConfig() as unknown as Config, + { merged: { mcpServers: {} } } as unknown as LoadedSettings, + mockArgv, + ); + await vi.waitFor(() => expect(capturedAgentFactory).toBeDefined()); + const agent = capturedAgentFactory!({ + get closed() { + return mockConnectionState.promise; + }, + }); + + await agent.newSession({ cwd: '/ext', mcpServers: [] }); + await expect( + agent.extMethod(SERVE_CONTROL_EXT_METHODS.workspaceExtensionsRefresh, { + sessionId: 's-ext', + }), + ).resolves.toEqual({ ok: true }); + + expect(extensionManager.refreshCache).toHaveBeenCalledOnce(); + expect(extensionManager.refreshTools).toHaveBeenCalledOnce(); + expect(sendAvailableCommandsUpdate).toHaveBeenCalledOnce(); + + mockConnectionState.resolve(); + await agentPromise; + }); + + it('still sends available commands update when extension tool refresh fails', async () => { + const extensionManager = { + refreshCache: vi.fn().mockResolvedValue(undefined), + refreshTools: vi.fn().mockRejectedValue(new Error('bad tool schema')), + }; + const cfg = makeConfig({ + getSessionId: vi.fn().mockReturnValue('s-ext'), + getExtensionManager: vi.fn().mockReturnValue(extensionManager), + }); + const sendAvailableCommandsUpdate = vi.fn().mockResolvedValue(undefined); + + vi.mocked(loadSettings).mockReturnValue({ + merged: { mcpServers: {} }, + getUserHooks: vi.fn().mockReturnValue({}), + getProjectHooks: vi.fn().mockReturnValue({}), + } as unknown as LoadedSettings); + vi.mocked(loadCliConfig).mockResolvedValue(cfg as unknown as Config); + vi.mocked(Session).mockImplementation( + () => + ({ + getId: vi.fn().mockReturnValue('s-ext'), + getConfig: vi.fn().mockReturnValue(cfg), + sendAvailableCommandsUpdate, + installRewriter: vi.fn(), + startCronScheduler: vi.fn(), + dispose: vi.fn(), + }) as unknown as InstanceType, + ); + + const agentPromise = runAcpAgent( + makeConfig() as unknown as Config, + { merged: { mcpServers: {} } } as unknown as LoadedSettings, + mockArgv, + ); + await vi.waitFor(() => expect(capturedAgentFactory).toBeDefined()); + const agent = capturedAgentFactory!({ + get closed() { + return mockConnectionState.promise; + }, + }); + + await agent.newSession({ cwd: '/ext', mcpServers: [] }); + await expect( + agent.extMethod(SERVE_CONTROL_EXT_METHODS.workspaceExtensionsRefresh, { + sessionId: 's-ext', + }), + ).resolves.toEqual({ ok: true }); + + expect(extensionManager.refreshCache).toHaveBeenCalledOnce(); + expect(extensionManager.refreshTools).toHaveBeenCalledOnce(); + expect(sendAvailableCommandsUpdate).toHaveBeenCalledOnce(); + + mockConnectionState.resolve(); + await agentPromise; + }); }); diff --git a/packages/cli/src/acp-integration/acpAgent.ts b/packages/cli/src/acp-integration/acpAgent.ts index b8f5a75644..92afd42b79 100644 --- a/packages/cli/src/acp-integration/acpAgent.ts +++ b/packages/cli/src/acp-integration/acpAgent.ts @@ -4545,6 +4545,16 @@ class QwenAgent implements Agent { ? { autoUpdate: ext.installMetadata.autoUpdate } : {}), capabilities, + updateState: ext.installMetadata ? 'unknown' : 'not updatable', + details: { + mcpServers: ext.mcpServers ? Object.keys(ext.mcpServers) : [], + commands: ext.commands ?? [], + skills: ext.skills?.map((skill) => skill.name) ?? [], + agents: ext.agents?.map((agent) => agent.name) ?? [], + contextFiles: ext.contextFiles, + settings: + ext.resolvedSettings?.map((setting) => setting.name) ?? [], + }, }; }, ); @@ -6004,6 +6014,23 @@ class QwenAgent implements Agent { ); return result as unknown as Record; } + case SERVE_CONTROL_EXT_METHODS.workspaceExtensionsRefresh: { + const sessionId = params['sessionId'] as string; + const session = this.sessionOrThrow(sessionId); + const extensionManager = session.getConfig().getExtensionManager(); + await extensionManager.refreshCache(); + try { + await extensionManager.refreshTools(); + } catch (err) { + debugLogger.warn( + `Extension tool refresh failed for session ${sessionId}: ${ + err instanceof Error ? err.message : String(err) + }`, + ); + } + await session.sendAvailableCommandsUpdate(); + return { ok: true }; + } case 'deleteSession': { const sessionId = params['sessionId'] as string; if (!sessionId || !SESSION_ID_RE.test(sessionId)) { diff --git a/packages/cli/src/serve/runQwenServe.ts b/packages/cli/src/serve/runQwenServe.ts index 7eb6c0d8af..c3c888b5f9 100644 --- a/packages/cli/src/serve/runQwenServe.ts +++ b/packages/cli/src/serve/runQwenServe.ts @@ -983,6 +983,8 @@ export async function runQwenServe( bridge.queryWorkspaceStatus(method, idle), invokeWorkspaceCommand: (method, params, invokeOpts) => bridge.invokeWorkspaceCommand(method, params, invokeOpts), + refreshExtensionsForAllSessions: () => + bridge.refreshExtensionsForAllSessions(), publishWorkspaceEvent: (event) => bridge.publishWorkspaceEvent(event), }); diff --git a/packages/cli/src/serve/server.test.ts b/packages/cli/src/serve/server.test.ts index 6933f0ff72..b412b2e003 100644 --- a/packages/cli/src/serve/server.test.ts +++ b/packages/cli/src/serve/server.test.ts @@ -39,10 +39,14 @@ import type { } from '@agentclientprotocol/sdk'; import { ApprovalMode, + ExtensionManager, + ExtensionUpdateState, SessionService, Storage, TrustGateError, + type Extension, } from '@qwen-code/qwen-code-core'; +import * as qwenCore from '@qwen-code/qwen-code-core'; import { CancelSentinelCollisionError, InvalidClientIdError, @@ -495,6 +499,21 @@ interface FakeBridge extends AcpSessionBridge { workspacePreflightCalls: number; workspaceHooksCalls: number; workspaceExtensionsCalls: number; + extensionEvents: Array<{ + refreshed: number; + failed: number; + status?: + | 'installed' + | 'enabled' + | 'disabled' + | 'updated' + | 'uninstalled' + | 'failed'; + source?: string; + name?: string; + version?: string; + error?: string; + }>; sessionContextCalls: string[]; sessionContextUsageCalls: string[]; sessionSupportedCommandsCalls: string[]; @@ -598,6 +617,7 @@ function fakeBridge(opts: FakeBridgeOpts = {}): FakeBridge { let workspacePreflightCalls = 0; let workspaceHooksCalls = 0; let workspaceExtensionsCalls = 0; + const extensionEvents: FakeBridge['extensionEvents'] = []; const sessionContextCalls: string[] = []; const sessionSupportedCommandsCalls: string[] = []; const sessionTasksCalls: string[] = []; @@ -929,6 +949,7 @@ function fakeBridge(opts: FakeBridgeOpts = {}): FakeBridge { sessionPermissionVotes, listCalls, workspaceMcpToolsCalls, + extensionEvents, sessionContextCalls, sessionContextUsageCalls, sessionSupportedCommandsCalls, @@ -1100,6 +1121,13 @@ function fakeBridge(opts: FakeBridgeOpts = {}): FakeBridge { workspaceExtensionsCalls += 1; return workspaceExtensionsImpl(); }, + async refreshExtensionsForAllSessions(data) { + extensionEvents.push({ ...data, refreshed: 1, failed: 0 }); + return { refreshed: 1, failed: 0 }; + }, + broadcastExtensionsChanged(data) { + extensionEvents.push(data); + }, async getSessionContextStatus(sessionId) { sessionContextCalls.push(sessionId); return sessionContextImpl(sessionId); @@ -2320,37 +2348,8 @@ describe('createServeApp', () => { expect(bridge.sessionHooksCalls).toEqual(['s-1']); }); - it('returns workspace extensions status from the bridge', async () => { - const extensions: ServeWorkspaceExtensionsStatus = { - v: 1, - workspaceCwd: WS_BOUND, - initialized: true, - extensions: [ - { - kind: 'extension', - id: 'abc123', - name: 'test-ext', - version: '1.0.0', - isActive: true, - path: '/home/user/.qwen/extensions/test-ext', - source: 'https://github.com/org/test-ext', - installType: 'git', - capabilities: { - mcpServerCount: 1, - skillCount: 2, - agentCount: 0, - hookCount: 0, - commandCount: 1, - contextFileCount: 1, - channelCount: 0, - hasSettings: false, - }, - }, - ], - }; - const bridge = fakeBridge({ - workspaceExtensionsImpl: async () => extensions, - }); + it('returns workspace extensions status without depending on a live session', async () => { + const bridge = fakeBridge(); const app = createServeApp( { ...baseOpts, workspace: WS_BOUND }, undefined, @@ -2361,8 +2360,1420 @@ describe('createServeApp', () => { .set('Host', `127.0.0.1:${baseOpts.port}`); expect(res.status).toBe(200); - expect(res.body).toEqual(extensions); - expect(bridge.workspaceExtensionsCalls).toBe(1); + expect(res.body).toMatchObject({ + v: 1, + workspaceCwd: WS_BOUND, + initialized: true, + }); + expect(Array.isArray(res.body.extensions)).toBe(true); + expect(bridge.workspaceExtensionsCalls).toBe(0); + }); + + it('caches local workspace extension status briefly', async () => { + const restore = mockExtensionManagerMethods(); + try { + const bridge = fakeBridge(); + const app = createServeApp( + { ...baseOpts, workspace: WS_BOUND }, + undefined, + { bridge }, + ); + + const first = await request(app) + .get('/workspace/extensions') + .set('Host', `127.0.0.1:${baseOpts.port}`); + const second = await request(app) + .get('/workspace/extensions') + .set('Host', `127.0.0.1:${baseOpts.port}`); + + expect(first.status).toBe(200); + expect(second.status).toBe(200); + expect( + vi.mocked(ExtensionManager.prototype.refreshCache), + ).toHaveBeenCalledTimes(1); + } finally { + restore(); + } + }); + + it('redacts extension source URLs in workspace extension status', async () => { + const restore = mockExtensionManagerMethods({ + getLoadedExtensions: () => [ + { + ...testExtension('private-ext'), + installMetadata: { + source: 'https://user:token@example.com/private-ext', + originSource: 'QwenCode', + }, + }, + ], + }); + try { + const bridge = fakeBridge(); + const app = createServeApp( + { ...baseOpts, workspace: WS_BOUND }, + undefined, + { bridge }, + ); + + const res = await request(app) + .get('/workspace/extensions') + .set('Host', `127.0.0.1:${baseOpts.port}`); + + expect(res.status).toBe(200); + expect(res.body.extensions[0]).toMatchObject({ + source: 'https://***REDACTED***@example.com/private-ext', + originSource: 'QwenCode', + }); + } finally { + restore(); + } + }); + + const testExtension = (name = 'test-ext'): Extension => + ({ + name, + config: { version: '1.2.3' }, + installMetadata: { source: `https://example.com/${name}` }, + contextFiles: [], + commands: [], + }) as unknown as Extension; + + const mockExtensionManagerMethods = (overrides?: { + refreshCache?: () => Promise; + installExtension?: () => Promise; + getLoadedExtensions?: () => Extension[]; + enableExtension?: () => Promise; + disableExtension?: () => Promise; + uninstallExtension?: () => Promise; + checkForAllExtensionUpdates?: ( + cb: (name: string, state: ExtensionUpdateState) => void, + ) => Promise; + updateExtension?: () => Promise<{ updatedVersion?: string } | undefined>; + }) => { + const spies = [ + vi + .spyOn(ExtensionManager.prototype, 'refreshCache') + .mockImplementation( + overrides?.refreshCache ?? (async () => undefined), + ), + vi + .spyOn(ExtensionManager.prototype, 'installExtension') + .mockImplementation( + overrides?.installExtension ?? + (async () => testExtension('installed-ext')), + ), + vi + .spyOn(ExtensionManager.prototype, 'getLoadedExtensions') + .mockImplementation( + overrides?.getLoadedExtensions ?? + (() => [testExtension('test-ext')]), + ), + vi + .spyOn(ExtensionManager.prototype, 'enableExtension') + .mockImplementation( + overrides?.enableExtension ?? (async () => undefined), + ), + vi + .spyOn(ExtensionManager.prototype, 'disableExtension') + .mockImplementation( + overrides?.disableExtension ?? (async () => undefined), + ), + vi + .spyOn(ExtensionManager.prototype, 'uninstallExtension') + .mockImplementation( + overrides?.uninstallExtension ?? (async () => undefined), + ), + vi + .spyOn(ExtensionManager.prototype, 'checkForAllExtensionUpdates') + .mockImplementation( + overrides?.checkForAllExtensionUpdates ?? + (async (cb) => { + cb('test-ext', ExtensionUpdateState.UPDATE_AVAILABLE); + }), + ), + vi + .spyOn(ExtensionManager.prototype, 'updateExtension') + .mockImplementation( + overrides?.updateExtension ?? + (async () => ({ + name: 'test-ext', + originalVersion: '1.2.3', + updatedVersion: '1.2.4', + })), + ), + ]; + return () => { + for (const spy of spies) { + spy.mockRestore(); + } + }; + }; + + it('rejects extension install without a registered workspace client id', async () => { + const tokenOpts: ServeOptions = { ...baseOpts, token: 'secret' }; + const bridge = fakeBridge(); + const app = createServeApp( + { ...tokenOpts, workspace: WS_BOUND }, + undefined, + { bridge }, + ); + + const res = await request(app) + .post('/workspace/extensions/install') + .set('Host', `127.0.0.1:${tokenOpts.port}`) + .set('Authorization', 'Bearer secret') + .send({ source: 'owner/repo', consent: true }); + + expect(res.status).toBe(400); + expect(res.body.code).toBe('missing_client_id'); + }); + + it('rejects extension install from an unknown workspace client id', async () => { + const tokenOpts: ServeOptions = { ...baseOpts, token: 'secret' }; + const bridge = fakeBridge({ knownClientIds: ['client-1'] }); + const app = createServeApp( + { ...tokenOpts, workspace: WS_BOUND }, + undefined, + { bridge }, + ); + + const res = await request(app) + .post('/workspace/extensions/install') + .set('Host', `127.0.0.1:${tokenOpts.port}`) + .set('Authorization', 'Bearer secret') + .set('X-Qwen-Client-Id', 'client-2') + .send({ source: 'owner/repo', consent: true }); + + expect(res.status).toBe(400); + expect(res.body.code).toBe('invalid_client_id'); + }); + + it('queues extension install and refreshes active sessions', async () => { + let requestSettingError: string | undefined; + const restore = mockExtensionManagerMethods({ + async installExtension() { + const manager = this as unknown as { + requestSetting?: (setting: { envVar: string }) => Promise; + }; + try { + await manager.requestSetting?.({ envVar: 'API_KEY' }); + } catch (error) { + requestSettingError = + error instanceof Error ? error.message : String(error); + } + return testExtension('installed-ext'); + }, + }); + try { + const tokenOpts: ServeOptions = { ...baseOpts, token: 'secret' }; + const bridge = fakeBridge({ knownClientIds: ['client-1'] }); + const app = createServeApp( + { ...tokenOpts, workspace: WS_BOUND }, + undefined, + { bridge }, + ); + + const res = await request(app) + .post('/workspace/extensions/install') + .set('Host', `127.0.0.1:${tokenOpts.port}`) + .set('Authorization', 'Bearer secret') + .set('X-Qwen-Client-Id', 'client-1') + .send({ + source: 'https://example.com/installed-ext', + ref: 'v1.2.3', + autoUpdate: true, + allowPreRelease: true, + consent: true, + }); + + expect(res.status).toBe(202); + expect(res.body).toEqual({ accepted: true }); + await vi.waitFor(() => { + expect(bridge.extensionEvents.at(-1)).toMatchObject({ + status: 'installed', + source: 'https://example.com/installed-ext', + name: 'installed-ext', + version: '1.2.3', + refreshed: 1, + failed: 0, + }); + }); + expect( + vi.mocked(ExtensionManager.prototype.installExtension), + ).toHaveBeenCalledWith( + expect.objectContaining({ + ref: 'v1.2.3', + autoUpdate: true, + allowPreRelease: true, + }), + expect.any(Function), + ); + expect(requestSettingError).toContain( + 'requires interactive configuration', + ); + } finally { + restore(); + } + }); + + it('broadcasts a failed extension install with redacted error details', async () => { + const restore = mockExtensionManagerMethods({ + installExtension: async () => { + throw new Error('https://user:token@example.com/private-ext failed'); + }, + }); + try { + const tokenOpts: ServeOptions = { ...baseOpts, token: 'secret' }; + const bridge = fakeBridge({ knownClientIds: ['client-1'] }); + const app = createServeApp( + { ...tokenOpts, workspace: WS_BOUND }, + undefined, + { bridge }, + ); + + const res = await request(app) + .post('/workspace/extensions/install') + .set('Host', `127.0.0.1:${tokenOpts.port}`) + .set('Authorization', 'Bearer secret') + .set('X-Qwen-Client-Id', 'client-1') + .send({ + source: 'https://example.com/private-ext', + consent: true, + }); + + expect(res.status).toBe(202); + await vi.waitFor(() => { + expect(bridge.extensionEvents.at(-1)).toMatchObject({ + status: 'failed', + source: 'https://example.com/private-ext', + refreshed: 0, + failed: 0, + error: 'https://***REDACTED***@example.com/private-ext failed', + }); + }); + expect(bridge.extensionEvents.at(-1)?.error).not.toContain('token'); + } finally { + restore(); + } + }); + + it('does not report a successful extension install as failed when session refresh fails', async () => { + const restore = mockExtensionManagerMethods({ + async installExtension() { + return testExtension('installed-ext'); + }, + }); + try { + const tokenOpts: ServeOptions = { ...baseOpts, token: 'secret' }; + const bridge = fakeBridge({ knownClientIds: ['client-1'] }); + bridge.refreshExtensionsForAllSessions = async () => { + throw new Error('refresh broke'); + }; + const app = createServeApp( + { ...tokenOpts, workspace: WS_BOUND }, + undefined, + { bridge }, + ); + + const res = await request(app) + .post('/workspace/extensions/install') + .set('Host', `127.0.0.1:${tokenOpts.port}`) + .set('Authorization', 'Bearer secret') + .set('X-Qwen-Client-Id', 'client-1') + .send({ + source: 'https://example.com/installed-ext', + consent: true, + }); + + expect(res.status).toBe(202); + await vi.waitFor(() => { + expect(bridge.extensionEvents.at(-1)).toMatchObject({ + status: 'installed', + source: 'https://example.com/installed-ext', + name: 'installed-ext', + refreshed: 0, + failed: 1, + error: 'refresh broke', + }); + }); + } finally { + restore(); + } + }); + + it('rejects extension mutations when the operation queue is full', async () => { + let releaseInstall: (() => void) | undefined; + const installBlocker = new Promise((resolve) => { + releaseInstall = resolve; + }); + const restore = mockExtensionManagerMethods({ + installExtension: async () => { + await installBlocker; + return testExtension('installed-ext'); + }, + }); + try { + const tokenOpts: ServeOptions = { ...baseOpts, token: 'secret' }; + const bridge = fakeBridge({ knownClientIds: ['client-1'] }); + const app = createServeApp( + { ...tokenOpts, workspace: WS_BOUND }, + undefined, + { bridge }, + ); + + const install = () => + request(app) + .post('/workspace/extensions/install') + .set('Host', `127.0.0.1:${tokenOpts.port}`) + .set('Authorization', 'Bearer secret') + .set('X-Qwen-Client-Id', 'client-1') + .send({ + source: 'https://example.com/installed-ext', + consent: true, + }); + + const accepted = await Promise.all( + Array.from({ length: 10 }, () => install()), + ); + const rejected = await install(); + + expect(accepted.every((res) => res.status === 202)).toBe(true); + expect(rejected.status).toBe(429); + expect(rejected.body).toMatchObject({ + code: 'extension_queue_full', + }); + releaseInstall?.(); + await vi.waitFor(() => { + expect( + bridge.extensionEvents.filter( + (event) => event.status === 'installed', + ), + ).toHaveLength(10); + }); + } finally { + releaseInstall?.(); + restore(); + } + }); + + it('requires explicit consent for extension install', async () => { + const tokenOpts: ServeOptions = { ...baseOpts, token: 'secret' }; + const bridge = fakeBridge({ knownClientIds: ['client-1'] }); + const app = createServeApp( + { ...tokenOpts, workspace: WS_BOUND }, + undefined, + { bridge }, + ); + + const res = await request(app) + .post('/workspace/extensions/install') + .set('Host', `127.0.0.1:${tokenOpts.port}`) + .set('Authorization', 'Bearer secret') + .set('X-Qwen-Client-Id', 'client-1') + .send({ source: 'owner/repo' }); + + expect(res.status).toBe(400); + expect(res.body.error).toBe( + 'Extension installation requires explicit consent', + ); + }); + + it('rejects an empty extension install ref', async () => { + const tokenOpts: ServeOptions = { ...baseOpts, token: 'secret' }; + const bridge = fakeBridge({ knownClientIds: ['client-1'] }); + const app = createServeApp( + { ...tokenOpts, workspace: WS_BOUND }, + undefined, + { bridge }, + ); + + const res = await request(app) + .post('/workspace/extensions/install') + .set('Host', `127.0.0.1:${tokenOpts.port}`) + .set('Authorization', 'Bearer secret') + .set('X-Qwen-Client-Id', 'client-1') + .send({ source: 'owner/repo', ref: '', consent: true }); + + expect(res.status).toBe(400); + expect(res.body.error).toBe('`ref` must be a string'); + }); + + it('validates extension install option types', async () => { + const tokenOpts: ServeOptions = { ...baseOpts, token: 'secret' }; + const bridge = fakeBridge({ knownClientIds: ['client-1'] }); + const app = createServeApp( + { ...tokenOpts, workspace: WS_BOUND }, + undefined, + { bridge }, + ); + + const res = await request(app) + .post('/workspace/extensions/install') + .set('Host', `127.0.0.1:${tokenOpts.port}`) + .set('Authorization', 'Bearer secret') + .set('X-Qwen-Client-Id', 'client-1') + .send({ source: 'owner/repo', ref: 123, consent: true }); + + expect(res.status).toBe(400); + expect(res.body.error).toBe('`ref` must be a string'); + }); + + it('broadcasts failed local extension installs over the daemon endpoint', async () => { + const localExtensionDir = await fsp.mkdtemp( + path.join(os.tmpdir(), 'qwen-local-extension-'), + ); + const tokenOpts: ServeOptions = { ...baseOpts, token: 'secret' }; + const bridge = fakeBridge({ knownClientIds: ['client-1'] }); + const app = createServeApp( + { ...tokenOpts, workspace: WS_BOUND }, + undefined, + { bridge }, + ); + + const res = await request(app) + .post('/workspace/extensions/install') + .set('Host', `127.0.0.1:${tokenOpts.port}`) + .set('Authorization', 'Bearer secret') + .set('X-Qwen-Client-Id', 'client-1') + .send({ source: localExtensionDir, consent: true }); + + expect(res.status).toBe(202); + await vi.waitFor(() => { + expect(bridge.extensionEvents.at(-1)).toMatchObject({ + status: 'failed', + source: localExtensionDir, + error: + 'Only GitHub, Git, and npm extension installs are supported over the daemon endpoint.', + }); + }); + }); + + it('treats Windows drive paths as local extension sources', async () => { + const restore = mockExtensionManagerMethods(); + try { + const tokenOpts: ServeOptions = { ...baseOpts, token: 'secret' }; + const bridge = fakeBridge({ knownClientIds: ['client-1'] }); + const app = createServeApp( + { ...tokenOpts, workspace: WS_BOUND }, + undefined, + { bridge }, + ); + const source = 'C:\\Users\\test\\qwen-local-extension'; + + const res = await request(app) + .post('/workspace/extensions/install') + .set('Host', `127.0.0.1:${tokenOpts.port}`) + .set('Authorization', 'Bearer secret') + .set('X-Qwen-Client-Id', 'client-1') + .send({ source, consent: true }); + + expect(res.status).toBe(202); + await vi.waitFor(() => { + expect(bridge.extensionEvents.at(-1)).toMatchObject({ + status: 'failed', + source, + error: `Install source not found: ${source}`, + }); + }); + } finally { + restore(); + } + }); + + it('rejects extension source hosts on blocked networks', async () => { + const tokenOpts: ServeOptions = { ...baseOpts, token: 'secret' }; + const bridge = fakeBridge({ knownClientIds: ['client-1'] }); + const app = createServeApp( + { ...tokenOpts, workspace: WS_BOUND }, + undefined, + { bridge }, + ); + + const res = await request(app) + .post('/workspace/extensions/install') + .set('Host', `127.0.0.1:${tokenOpts.port}`) + .set('Authorization', 'Bearer secret') + .set('X-Qwen-Client-Id', 'client-1') + .send({ + source: 'http://169.254.169.254/latest/meta-data', + consent: true, + }); + + expect(res.status).toBe(400); + expect(res.body.error).toBe('`source` host is not allowed'); + }); + + it('rejects extension source URLs with unsupported protocols', async () => { + const tokenOpts: ServeOptions = { ...baseOpts, token: 'secret' }; + const bridge = fakeBridge({ knownClientIds: ['client-1'] }); + const app = createServeApp( + { ...tokenOpts, workspace: WS_BOUND }, + undefined, + { bridge }, + ); + + for (const source of [ + 'http://example.com/repo', + 'ftp://example.com/repo', + 'file:///tmp/repo', + ]) { + const res = await request(app) + .post('/workspace/extensions/install') + .set('Host', `127.0.0.1:${tokenOpts.port}`) + .set('Authorization', 'Bearer secret') + .set('X-Qwen-Client-Id', 'client-1') + .send({ + source, + consent: true, + }); + + expect(res.status).toBe(400); + expect(res.body.error).toBe('`source` must use https or ssh'); + } + }); + + it('rejects extension ssh source hosts with legacy private IP encodings', async () => { + const tokenOpts: ServeOptions = { ...baseOpts, token: 'secret' }; + const bridge = fakeBridge({ knownClientIds: ['client-1'] }); + const app = createServeApp( + { ...tokenOpts, workspace: WS_BOUND }, + undefined, + { bridge }, + ); + + for (const source of [ + 'git@0177.0.0.1:owner/repo.git', + 'git@0177.1:owner/repo.git', + 'git@0x7f.1:owner/repo.git', + 'git@127.0x1:owner/repo.git', + 'git@0x7f000001:owner/repo.git', + 'git@2130706433:owner/repo.git', + ]) { + const res = await request(app) + .post('/workspace/extensions/install') + .set('Host', `127.0.0.1:${tokenOpts.port}`) + .set('Authorization', 'Bearer secret') + .set('X-Qwen-Client-Id', 'client-1') + .send({ source, consent: true }); + + expect(res.status).toBe(400); + expect(res.body.error).toBe('`source` host is not allowed'); + } + }); + + it('rejects extension ssh source hosts with bracketed private IPv6 literals', async () => { + const tokenOpts: ServeOptions = { ...baseOpts, token: 'secret' }; + const bridge = fakeBridge({ knownClientIds: ['client-1'] }); + const app = createServeApp( + { ...tokenOpts, workspace: WS_BOUND }, + undefined, + { bridge }, + ); + + for (const source of [ + 'git@[::1]:owner/repo.git', + 'git@[fd00::1]:owner/repo.git', + 'git@[fe80::1]:owner/repo.git', + ]) { + const res = await request(app) + .post('/workspace/extensions/install') + .set('Host', `127.0.0.1:${tokenOpts.port}`) + .set('Authorization', 'Bearer secret') + .set('X-Qwen-Client-Id', 'client-1') + .send({ source, consent: true }); + + expect(res.status).toBe(400); + expect(res.body.error).toBe('`source` host is not allowed'); + } + }); + + it('rejects extension install refs that look like git options', async () => { + const tokenOpts: ServeOptions = { ...baseOpts, token: 'secret' }; + const bridge = fakeBridge({ knownClientIds: ['client-1'] }); + const app = createServeApp( + { ...tokenOpts, workspace: WS_BOUND }, + undefined, + { bridge }, + ); + + const res = await request(app) + .post('/workspace/extensions/install') + .set('Host', `127.0.0.1:${tokenOpts.port}`) + .set('Authorization', 'Bearer secret') + .set('X-Qwen-Client-Id', 'client-1') + .send({ + source: 'https://example.com/repo', + ref: '--upload-pack=/bin/sh', + consent: true, + }); + + expect(res.status).toBe(400); + expect(res.body.error).toBe('`ref` must not start with "-"'); + }); + + it('rejects extension source URLs with credentials', async () => { + const tokenOpts: ServeOptions = { ...baseOpts, token: 'secret' }; + const bridge = fakeBridge({ knownClientIds: ['client-1'] }); + const app = createServeApp( + { ...tokenOpts, workspace: WS_BOUND }, + undefined, + { bridge }, + ); + + const res = await request(app) + .post('/workspace/extensions/install') + .set('Host', `127.0.0.1:${tokenOpts.port}`) + .set('Authorization', 'Bearer secret') + .set('X-Qwen-Client-Id', 'client-1') + .send({ + source: 'https://user:pass@example.com/repo', + consent: true, + }); + + expect(res.status).toBe(400); + expect(res.body.error).toBe('`source` must not include credentials'); + }); + + it('broadcasts failed npm extension install with ref', async () => { + const tokenOpts: ServeOptions = { ...baseOpts, token: 'secret' }; + const bridge = fakeBridge({ knownClientIds: ['client-1'] }); + const app = createServeApp( + { ...tokenOpts, workspace: WS_BOUND }, + undefined, + { bridge }, + ); + + const res = await request(app) + .post('/workspace/extensions/install') + .set('Host', `127.0.0.1:${tokenOpts.port}`) + .set('Authorization', 'Bearer secret') + .set('X-Qwen-Client-Id', 'client-1') + .send({ source: '@scope/ext', ref: 'v1', consent: true }); + + expect(res.status).toBe(202); + await vi.waitFor(() => { + expect(bridge.extensionEvents.at(-1)).toMatchObject({ + status: 'failed', + source: '@scope/ext', + error: '--ref is not applicable for npm extensions.', + }); + }); + }); + + it('broadcasts failed registry use for non-npm extension installs', async () => { + const tokenOpts: ServeOptions = { ...baseOpts, token: 'secret' }; + const bridge = fakeBridge({ knownClientIds: ['client-1'] }); + const app = createServeApp( + { ...tokenOpts, workspace: WS_BOUND }, + undefined, + { bridge }, + ); + + const res = await request(app) + .post('/workspace/extensions/install') + .set('Host', `127.0.0.1:${tokenOpts.port}`) + .set('Authorization', 'Bearer secret') + .set('X-Qwen-Client-Id', 'client-1') + .send({ + source: 'https://example.com/repo', + registry: 'https://registry.example.com', + consent: true, + }); + + expect(res.status).toBe(202); + await vi.waitFor(() => { + expect(bridge.extensionEvents.at(-1)).toMatchObject({ + status: 'failed', + source: 'https://example.com/repo', + error: '--registry is only applicable for npm extensions.', + }); + }); + }); + + it('rejects non-https npm registries', async () => { + const tokenOpts: ServeOptions = { ...baseOpts, token: 'secret' }; + const bridge = fakeBridge({ knownClientIds: ['client-1'] }); + const app = createServeApp( + { ...tokenOpts, workspace: WS_BOUND }, + undefined, + { bridge }, + ); + + const res = await request(app) + .post('/workspace/extensions/install') + .set('Host', `127.0.0.1:${tokenOpts.port}`) + .set('Authorization', 'Bearer secret') + .set('X-Qwen-Client-Id', 'client-1') + .send({ + source: '@scope/ext', + registry: 'http://registry.example.com', + consent: true, + }); + + expect(res.status).toBe(400); + expect(res.body.error).toBe('`registry` must use https'); + }); + + it('rejects invalid npm registry URLs', async () => { + const tokenOpts: ServeOptions = { ...baseOpts, token: 'secret' }; + const bridge = fakeBridge({ knownClientIds: ['client-1'] }); + const app = createServeApp( + { ...tokenOpts, workspace: WS_BOUND }, + undefined, + { bridge }, + ); + + const res = await request(app) + .post('/workspace/extensions/install') + .set('Host', `127.0.0.1:${tokenOpts.port}`) + .set('Authorization', 'Bearer secret') + .set('X-Qwen-Client-Id', 'client-1') + .send({ + source: '@scope/ext', + registry: 'not a url', + consent: true, + }); + + expect(res.status).toBe(400); + expect(res.body.error).toBe('`registry` must be a valid URL'); + }); + + it('rejects npm registries on blocked networks', async () => { + const tokenOpts: ServeOptions = { ...baseOpts, token: 'secret' }; + const bridge = fakeBridge({ knownClientIds: ['client-1'] }); + const app = createServeApp( + { ...tokenOpts, workspace: WS_BOUND }, + undefined, + { bridge }, + ); + + const res = await request(app) + .post('/workspace/extensions/install') + .set('Host', `127.0.0.1:${tokenOpts.port}`) + .set('Authorization', 'Bearer secret') + .set('X-Qwen-Client-Id', 'client-1') + .send({ + source: '@scope/ext', + registry: 'https://169.254.169.254', + consent: true, + }); + + expect(res.status).toBe(400); + expect(res.body.error).toBe('`registry` host is not allowed'); + }); + + it('rejects npm registries with credentials', async () => { + const tokenOpts: ServeOptions = { ...baseOpts, token: 'secret' }; + const bridge = fakeBridge({ knownClientIds: ['client-1'] }); + const app = createServeApp( + { ...tokenOpts, workspace: WS_BOUND }, + undefined, + { bridge }, + ); + + const res = await request(app) + .post('/workspace/extensions/install') + .set('Host', `127.0.0.1:${tokenOpts.port}`) + .set('Authorization', 'Bearer secret') + .set('X-Qwen-Client-Id', 'client-1') + .send({ + source: '@scope/ext', + registry: 'https://user:pass@registry.example.com', + consent: true, + }); + + expect(res.status).toBe(400); + expect(res.body.error).toBe('`registry` must not include credentials'); + }); + + it('returns extension update states from check-updates', async () => { + const restore = mockExtensionManagerMethods({ + checkForAllExtensionUpdates: async (cb) => { + cb('test-ext', ExtensionUpdateState.UP_TO_DATE); + cb('other-ext', ExtensionUpdateState.NOT_UPDATABLE); + }, + }); + try { + const tokenOpts: ServeOptions = { ...baseOpts, token: 'secret' }; + const bridge = fakeBridge({ knownClientIds: ['client-1'] }); + const app = createServeApp( + { ...tokenOpts, workspace: WS_BOUND }, + undefined, + { bridge }, + ); + + const res = await request(app) + .post('/workspace/extensions/check-updates') + .set('Host', `127.0.0.1:${tokenOpts.port}`) + .set('Authorization', 'Bearer secret') + .set('X-Qwen-Client-Id', 'client-1') + .send({}); + + expect(res.status).toBe(200); + expect(res.body).toEqual({ + states: { + 'test-ext': ExtensionUpdateState.UP_TO_DATE, + 'other-ext': ExtensionUpdateState.NOT_UPDATABLE, + }, + }); + } finally { + restore(); + } + }); + + it('serializes check-updates behind queued extension mutations', async () => { + let releaseInstall: (() => void) | undefined; + const calls: string[] = []; + const restore = mockExtensionManagerMethods({ + installExtension: async () => { + calls.push('install:start'); + await new Promise((resolve) => { + releaseInstall = resolve; + }); + calls.push('install:end'); + return testExtension('installed-ext'); + }, + checkForAllExtensionUpdates: async (cb) => { + calls.push('check-updates'); + cb('test-ext', ExtensionUpdateState.UP_TO_DATE); + }, + }); + try { + const tokenOpts: ServeOptions = { ...baseOpts, token: 'secret' }; + const bridge = fakeBridge({ knownClientIds: ['client-1'] }); + const app = createServeApp( + { ...tokenOpts, workspace: WS_BOUND }, + undefined, + { bridge }, + ); + + await request(app) + .post('/workspace/extensions/install') + .set('Host', `127.0.0.1:${tokenOpts.port}`) + .set('Authorization', 'Bearer secret') + .set('X-Qwen-Client-Id', 'client-1') + .send({ + source: 'https://example.com/installed-ext', + consent: true, + }); + await vi.waitFor(() => { + expect(calls).toEqual(['install:start']); + }); + + const checkUpdates = request(app) + .post('/workspace/extensions/check-updates') + .set('Host', `127.0.0.1:${tokenOpts.port}`) + .set('Authorization', 'Bearer secret') + .set('X-Qwen-Client-Id', 'client-1') + .send({}) + .then((response) => response); + await new Promise((resolve) => setTimeout(resolve, 0)); + expect(calls).toEqual(['install:start']); + + releaseInstall?.(); + const res = await checkUpdates; + expect(res.status).toBe(200); + expect(calls).toEqual([ + 'install:start', + 'install:end', + 'check-updates', + ]); + } finally { + releaseInstall?.(); + restore(); + } + }); + + it('validates extension enable scope', async () => { + const tokenOpts: ServeOptions = { ...baseOpts, token: 'secret' }; + const bridge = fakeBridge({ knownClientIds: ['client-1'] }); + const app = createServeApp( + { ...tokenOpts, workspace: WS_BOUND }, + undefined, + { bridge }, + ); + + const res = await request(app) + .post('/workspace/extensions/test-ext/enable') + .set('Host', `127.0.0.1:${tokenOpts.port}`) + .set('Authorization', 'Bearer secret') + .set('X-Qwen-Client-Id', 'client-1') + .send({ scope: 'team' }); + + expect(res.status).toBe(400); + expect(res.body.error).toBe( + '`scope` must be either "user" or "workspace"', + ); + }); + + it('queues extension enable and disable mutations', async () => { + const restore = mockExtensionManagerMethods(); + try { + const tokenOpts: ServeOptions = { ...baseOpts, token: 'secret' }; + const bridge = fakeBridge({ knownClientIds: ['client-1'] }); + const app = createServeApp( + { ...tokenOpts, workspace: WS_BOUND }, + undefined, + { bridge }, + ); + + const enable = await request(app) + .post('/workspace/extensions/TEST-EXT/enable') + .set('Host', `127.0.0.1:${tokenOpts.port}`) + .set('Authorization', 'Bearer secret') + .set('X-Qwen-Client-Id', 'client-1') + .send({ scope: 'workspace' }); + expect(enable.status).toBe(202); + + const disable = await request(app) + .post('/workspace/extensions/TEST-EXT/disable') + .set('Host', `127.0.0.1:${tokenOpts.port}`) + .set('Authorization', 'Bearer secret') + .set('X-Qwen-Client-Id', 'client-1') + .send({ scope: 'user' }); + expect(disable.status).toBe(202); + + await vi.waitFor(() => { + expect(bridge.extensionEvents).toEqual( + expect.arrayContaining([ + expect.objectContaining({ + status: 'enabled', + name: 'test-ext', + refreshed: 1, + failed: 0, + }), + expect.objectContaining({ + status: 'disabled', + name: 'test-ext', + refreshed: 1, + failed: 0, + }), + ]), + ); + }); + expect( + vi.mocked(ExtensionManager.prototype.enableExtension), + ).toHaveBeenCalledWith('test-ext', expect.anything(), WS_BOUND); + expect( + vi.mocked(ExtensionManager.prototype.disableExtension), + ).toHaveBeenCalledWith('test-ext', expect.anything(), WS_BOUND); + } finally { + restore(); + } + }); + + it('refreshes extensions for all sessions on request', async () => { + const tokenOpts: ServeOptions = { ...baseOpts, token: 'secret' }; + const bridge = fakeBridge({ knownClientIds: ['client-1'] }); + const app = createServeApp( + { ...tokenOpts, workspace: WS_BOUND }, + undefined, + { bridge }, + ); + + const res = await request(app) + .post('/workspace/extensions/refresh') + .set('Host', `127.0.0.1:${tokenOpts.port}`) + .set('Authorization', 'Bearer secret') + .set('X-Qwen-Client-Id', 'client-1') + .send({}); + + expect(res.status).toBe(200); + expect(res.body).toEqual({ refreshed: 1, failed: 0 }); + expect(bridge.extensionEvents.at(-1)).toMatchObject({ + refreshed: 1, + failed: 0, + }); + }); + + it('serializes extension refresh behind queued mutations', async () => { + let releaseInstall: (() => void) | undefined; + const restore = mockExtensionManagerMethods({ + installExtension: async () => { + await new Promise((resolve) => { + releaseInstall = resolve; + }); + return testExtension('installed-ext'); + }, + }); + try { + const tokenOpts: ServeOptions = { ...baseOpts, token: 'secret' }; + const bridge = fakeBridge({ knownClientIds: ['client-1'] }); + const app = createServeApp( + { ...tokenOpts, workspace: WS_BOUND }, + undefined, + { bridge }, + ); + + await request(app) + .post('/workspace/extensions/install') + .set('Host', `127.0.0.1:${tokenOpts.port}`) + .set('Authorization', 'Bearer secret') + .set('X-Qwen-Client-Id', 'client-1') + .send({ + source: 'https://example.com/installed-ext', + consent: true, + }); + await vi.waitFor(() => { + expect( + vi.mocked(ExtensionManager.prototype.installExtension), + ).toHaveBeenCalled(); + }); + + const refresh = request(app) + .post('/workspace/extensions/refresh') + .set('Host', `127.0.0.1:${tokenOpts.port}`) + .set('Authorization', 'Bearer secret') + .set('X-Qwen-Client-Id', 'client-1') + .send({}) + .then((response) => response); + await new Promise((resolve) => setTimeout(resolve, 0)); + expect(bridge.extensionEvents).toEqual([]); + + releaseInstall?.(); + const res = await refresh; + expect(res.status).toBe(200); + expect(bridge.extensionEvents).toEqual([ + expect.objectContaining({ status: 'installed' }), + expect.objectContaining({ refreshed: 1, failed: 0 }), + ]); + } finally { + releaseInstall?.(); + restore(); + } + }); + + it('rejects extension update from an unknown workspace client id', async () => { + const tokenOpts: ServeOptions = { ...baseOpts, token: 'secret' }; + const bridge = fakeBridge({ knownClientIds: ['client-1'] }); + const app = createServeApp( + { ...tokenOpts, workspace: WS_BOUND }, + undefined, + { bridge }, + ); + + const res = await request(app) + .post('/workspace/extensions/test-ext/update') + .set('Host', `127.0.0.1:${tokenOpts.port}`) + .set('Authorization', 'Bearer secret') + .set('X-Qwen-Client-Id', 'client-2') + .send({}); + + expect(res.status).toBe(400); + expect(res.body.code).toBe('invalid_client_id'); + }); + + it('queues extension update when an update is available', async () => { + const restore = mockExtensionManagerMethods(); + const checkForExtensionUpdate = vi + .spyOn(qwenCore, 'checkForExtensionUpdate') + .mockResolvedValue(ExtensionUpdateState.UPDATE_AVAILABLE); + try { + const tokenOpts: ServeOptions = { ...baseOpts, token: 'secret' }; + const bridge = fakeBridge({ knownClientIds: ['client-1'] }); + const app = createServeApp( + { ...tokenOpts, workspace: WS_BOUND }, + undefined, + { bridge }, + ); + + const res = await request(app) + .post('/workspace/extensions/test-ext/update') + .set('Host', `127.0.0.1:${tokenOpts.port}`) + .set('Authorization', 'Bearer secret') + .set('X-Qwen-Client-Id', 'client-1') + .send({}); + + expect(res.status).toBe(202); + await vi.waitFor(() => { + expect(bridge.extensionEvents.at(-1)).toMatchObject({ + status: 'updated', + name: 'test-ext', + version: '1.2.4', + refreshed: 1, + failed: 0, + }); + }); + expect(checkForExtensionUpdate).toHaveBeenCalledTimes(1); + expect( + vi.mocked(ExtensionManager.prototype.checkForAllExtensionUpdates), + ).not.toHaveBeenCalled(); + } finally { + checkForExtensionUpdate.mockRestore(); + restore(); + } + }); + + it('broadcasts failed extension update when the extension is missing', async () => { + const restore = mockExtensionManagerMethods({ + getLoadedExtensions: () => [], + }); + try { + const tokenOpts: ServeOptions = { ...baseOpts, token: 'secret' }; + const bridge = fakeBridge({ knownClientIds: ['client-1'] }); + const app = createServeApp( + { ...tokenOpts, workspace: WS_BOUND }, + undefined, + { bridge }, + ); + + const res = await request(app) + .post('/workspace/extensions/missing-ext/update') + .set('Host', `127.0.0.1:${tokenOpts.port}`) + .set('Authorization', 'Bearer secret') + .set('X-Qwen-Client-Id', 'client-1') + .send({}); + + expect(res.status).toBe(202); + await vi.waitFor(() => { + expect(bridge.extensionEvents.at(-1)).toMatchObject({ + status: 'failed', + name: 'missing-ext', + refreshed: 0, + failed: 0, + error: 'Extension "missing-ext" not found', + }); + }); + } finally { + restore(); + } + }); + + it('broadcasts failed extension update when no update is available', async () => { + const restore = mockExtensionManagerMethods(); + const checkForExtensionUpdate = vi + .spyOn(qwenCore, 'checkForExtensionUpdate') + .mockResolvedValue(ExtensionUpdateState.UP_TO_DATE); + try { + const tokenOpts: ServeOptions = { ...baseOpts, token: 'secret' }; + const bridge = fakeBridge({ knownClientIds: ['client-1'] }); + const app = createServeApp( + { ...tokenOpts, workspace: WS_BOUND }, + undefined, + { bridge }, + ); + + const res = await request(app) + .post('/workspace/extensions/test-ext/update') + .set('Host', `127.0.0.1:${tokenOpts.port}`) + .set('Authorization', 'Bearer secret') + .set('X-Qwen-Client-Id', 'client-1') + .send({}); + + expect(res.status).toBe(202); + await vi.waitFor(() => { + expect(bridge.extensionEvents.at(-1)).toMatchObject({ + status: 'failed', + name: 'test-ext', + error: 'Extension "test-ext" has no update', + }); + }); + } finally { + checkForExtensionUpdate.mockRestore(); + restore(); + } + }); + + it('broadcasts failed extension update when update check fails', async () => { + const restore = mockExtensionManagerMethods(); + const checkForExtensionUpdate = vi + .spyOn(qwenCore, 'checkForExtensionUpdate') + .mockRejectedValue( + new Error('https://user:token@example.com/check failed'), + ); + try { + const tokenOpts: ServeOptions = { ...baseOpts, token: 'secret' }; + const bridge = fakeBridge({ knownClientIds: ['client-1'] }); + const app = createServeApp( + { ...tokenOpts, workspace: WS_BOUND }, + undefined, + { bridge }, + ); + + const res = await request(app) + .post('/workspace/extensions/test-ext/update') + .set('Host', `127.0.0.1:${tokenOpts.port}`) + .set('Authorization', 'Bearer secret') + .set('X-Qwen-Client-Id', 'client-1') + .send({}); + + expect(res.status).toBe(202); + await vi.waitFor(() => { + expect(bridge.extensionEvents.at(-1)).toMatchObject({ + status: 'failed', + name: 'test-ext', + error: + 'Update check failed for extension "test-ext": https://***REDACTED***@example.com/check failed', + }); + }); + } finally { + checkForExtensionUpdate.mockRestore(); + restore(); + } + }); + + it('rejects extension uninstall without a registered workspace client id', async () => { + const tokenOpts: ServeOptions = { ...baseOpts, token: 'secret' }; + const bridge = fakeBridge(); + const app = createServeApp( + { ...tokenOpts, workspace: WS_BOUND }, + undefined, + { bridge }, + ); + + const res = await request(app) + .delete('/workspace/extensions/test-ext') + .set('Host', `127.0.0.1:${tokenOpts.port}`) + .set('Authorization', 'Bearer secret'); + + expect(res.status).toBe(400); + expect(res.body.code).toBe('missing_client_id'); + }); + + it('queues extension uninstall mutations', async () => { + const restore = mockExtensionManagerMethods(); + try { + const tokenOpts: ServeOptions = { ...baseOpts, token: 'secret' }; + const bridge = fakeBridge({ knownClientIds: ['client-1'] }); + const app = createServeApp( + { ...tokenOpts, workspace: WS_BOUND }, + undefined, + { bridge }, + ); + + const res = await request(app) + .delete('/workspace/extensions/TEST-EXT') + .set('Host', `127.0.0.1:${tokenOpts.port}`) + .set('Authorization', 'Bearer secret') + .set('X-Qwen-Client-Id', 'client-1'); + + expect(res.status).toBe(202); + await vi.waitFor(() => { + expect(bridge.extensionEvents.at(-1)).toMatchObject({ + status: 'uninstalled', + name: 'test-ext', + refreshed: 1, + failed: 0, + }); + }); + expect( + vi.mocked(ExtensionManager.prototype.uninstallExtension), + ).toHaveBeenCalledWith('test-ext', false, WS_BOUND); + } finally { + restore(); + } + }); + + it('queues extension uninstall mutations by source URL', async () => { + const restore = mockExtensionManagerMethods(); + try { + const tokenOpts: ServeOptions = { ...baseOpts, token: 'secret' }; + const bridge = fakeBridge({ knownClientIds: ['client-1'] }); + const app = createServeApp( + { ...tokenOpts, workspace: WS_BOUND }, + undefined, + { bridge }, + ); + + const res = await request(app) + .delete('/workspace/extensions/https%3A%2F%2Fexample.com%2Ftest-ext') + .set('Host', `127.0.0.1:${tokenOpts.port}`) + .set('Authorization', 'Bearer secret') + .set('X-Qwen-Client-Id', 'client-1'); + + expect(res.status).toBe(202); + await vi.waitFor(() => { + expect(bridge.extensionEvents.at(-1)).toMatchObject({ + status: 'uninstalled', + name: 'test-ext', + refreshed: 1, + failed: 0, + }); + }); + expect( + vi.mocked(ExtensionManager.prototype.uninstallExtension), + ).toHaveBeenCalledWith('test-ext', false, WS_BOUND); + } finally { + restore(); + } + }); + + it('does not treat plain extension names as install source lookups', async () => { + const restore = mockExtensionManagerMethods({ + getLoadedExtensions: () => [ + { + ...testExtension('source-only'), + installMetadata: { source: 'plain-name' }, + }, + ], + }); + try { + const tokenOpts: ServeOptions = { ...baseOpts, token: 'secret' }; + const bridge = fakeBridge({ knownClientIds: ['client-1'] }); + const app = createServeApp( + { ...tokenOpts, workspace: WS_BOUND }, + undefined, + { bridge }, + ); + + const res = await request(app) + .delete('/workspace/extensions/plain-name') + .set('Host', `127.0.0.1:${tokenOpts.port}`) + .set('Authorization', 'Bearer secret') + .set('X-Qwen-Client-Id', 'client-1'); + + expect(res.status).toBe(202); + await vi.waitFor(() => { + expect(bridge.extensionEvents.at(-1)).toMatchObject({ + status: 'failed', + name: 'plain-name', + error: 'Extension "plain-name" not found', + }); + }); + expect( + vi.mocked(ExtensionManager.prototype.uninstallExtension), + ).not.toHaveBeenCalled(); + } finally { + restore(); + } + }); + + it('does not throw when source lookup sees extensions without install metadata', async () => { + const restore = mockExtensionManagerMethods({ + getLoadedExtensions: () => [ + { + ...testExtension('no-metadata'), + installMetadata: undefined, + }, + ], + }); + try { + const tokenOpts: ServeOptions = { ...baseOpts, token: 'secret' }; + const bridge = fakeBridge({ knownClientIds: ['client-1'] }); + const app = createServeApp( + { ...tokenOpts, workspace: WS_BOUND }, + undefined, + { bridge }, + ); + + const res = await request(app) + .delete('/workspace/extensions/https%3A%2F%2Fexample.com%2Fmissing') + .set('Host', `127.0.0.1:${tokenOpts.port}`) + .set('Authorization', 'Bearer secret') + .set('X-Qwen-Client-Id', 'client-1'); + + expect(res.status).toBe(202); + await vi.waitFor(() => { + expect(bridge.extensionEvents.at(-1)).toMatchObject({ + status: 'failed', + name: 'https://example.com/missing', + error: 'Extension "https://example.com/missing" not found', + }); + }); + } finally { + restore(); + } }); it('returns read-only session snapshots from the bridge', async () => { diff --git a/packages/cli/src/serve/server.ts b/packages/cli/src/serve/server.ts index 5375e8906a..ac38b6c5d8 100644 --- a/packages/cli/src/serve/server.ts +++ b/packages/cli/src/serve/server.ts @@ -9,11 +9,16 @@ import * as net from 'node:net'; import * as path from 'node:path'; import express from 'express'; import type { Application, NextFunction, Request, Response } from 'express'; -import type { ApprovalMode } from '@qwen-code/qwen-code-core'; import { APPROVAL_MODES, ALL_PROVIDERS, BTW_MAX_INPUT_LENGTH, + ExtensionUpdateState, + ExtensionManager, + checkForExtensionUpdate, + redactUrlCredentials, + SettingScope, + parseInstallSource, SessionService, shouldShowStep, TrustGateError, @@ -25,6 +30,10 @@ import { recordDaemonHttpRequest, recordDaemonHttpResponse, withDaemonRequestSpan, + type ApprovalMode, + type Extension, + type ExtensionInstallMetadata, + type ExtensionSetting, } from '@qwen-code/qwen-code-core'; import { writeStderrLine } from '../utils/stdioHelpers.js'; import type { DaemonLogger } from './daemonLogger.js'; @@ -53,6 +62,8 @@ import { createBridgeFileSystemAdapter } from './bridgeFileSystemAdapter.js'; import { createDaemonStatusProvider } from './daemonStatusProvider.js'; import { isServeDebugMode } from './debugMode.js'; import { SUPPORTED_LANGUAGES } from '../i18n/index.js'; +import { loadSettings } from '../config/settings.js'; +import { isWorkspaceTrusted } from '../config/trustedFolders.js'; import { isLoopbackBind } from './loopbackBinds.js'; import { mountAcpHttp, type AcpHttpHandle } from './acpHttp/index.js'; import { @@ -128,6 +139,12 @@ import { setRateLimiter, type RateLimiterInstance, } from './rateLimit.js'; +import { + STATUS_SCHEMA_VERSION, + type ServeExtensionCapabilities, + type ServeExtensionEntry, + type ServeWorkspaceExtensionsStatus, +} from './status.js'; let activeSseCount = 0; export function getActiveSseCount(): number { @@ -432,6 +449,59 @@ function parseIPv6FirstHextet(host: string): number | undefined { return Number.parseInt(first, 16); } +function parseLegacyIPv4Part(value: string, max: number): number | undefined { + let parsed: number; + if (/^0x[0-9a-f]+$/i.test(value)) { + parsed = Number.parseInt(value.slice(2), 16); + } else if (/^0[0-7]+$/.test(value)) { + parsed = Number.parseInt(value, 8); + } else if (/^[0-9]+$/.test(value)) { + parsed = Number.parseInt(value, 10); + } else { + return undefined; + } + return parsed <= max ? parsed : undefined; +} + +// Match URL parsers that still accept inet_aton-style IPv4 aliases, so blocked +// host checks also catch SSH sources such as git@0177.1:owner/repo.git. +function parseLegacyIPv4Host(host: string): string | undefined { + const parts = host.split('.'); + if (parts.length < 1 || parts.length > 4 || parts.some((part) => !part)) { + return undefined; + } + + // In legacy one-, two-, and three-part IPv4 forms, the final part carries the + // remaining bytes rather than a single octet. + const maxLastPart = [0xffffffff, 0xffffff, 0xffff, 0xff][parts.length - 1]; + if (maxLastPart === undefined) return undefined; + + const parsed = parts.map((part, index) => + parseLegacyIPv4Part(part, index === parts.length - 1 ? maxLastPart : 0xff), + ); + if (parsed.some((part) => part === undefined)) return undefined; + + const values = parsed as number[]; + const numeric = + values.length === 1 + ? values[0] + : values.length === 2 + ? values[0] * 0x1000000 + values[1] + : values.length === 3 + ? values[0] * 0x1000000 + values[1] * 0x10000 + values[2] + : values[0] * 0x1000000 + + values[1] * 0x10000 + + values[2] * 0x100 + + values[3]; + + return [ + Math.floor(numeric / 0x1000000) & 0xff, + Math.floor(numeric / 0x10000) & 0xff, + Math.floor(numeric / 0x100) & 0xff, + numeric & 0xff, + ].join('.'); +} + function isBlockedAuthProviderHost(hostname: string): boolean { const stripped = hostname.endsWith('.') ? hostname.slice(0, -1) : hostname; const host = stripped.toLowerCase(); @@ -439,9 +509,10 @@ function isBlockedAuthProviderHost(hostname: string): boolean { const bareHost = host.startsWith('[') && host.endsWith(']') ? host.slice(1, -1) : host; - const ipVersion = net.isIP(bareHost); + const blocklistHost = parseLegacyIPv4Host(bareHost) ?? bareHost; + const ipVersion = net.isIP(blocklistHost); if (ipVersion === 4) { - const parts = bareHost.split('.').map((part) => Number(part)); + const parts = blocklistHost.split('.').map((part) => Number(part)); const [a, b] = parts; return ( a === 0 || @@ -455,8 +526,8 @@ function isBlockedAuthProviderHost(hostname: string): boolean { } if (ipVersion === 6) { - if (bareHost === '::' || bareHost === '::1') return true; - const firstHextet = parseIPv6FirstHextet(bareHost); + if (blocklistHost === '::' || blocklistHost === '::1') return true; + const firstHextet = parseIPv6FirstHextet(blocklistHost); if ( firstHextet !== undefined && ((firstHextet >= 0xfe80 && firstHextet <= 0xfebf) || @@ -464,8 +535,8 @@ function isBlockedAuthProviderHost(hostname: string): boolean { ) { return true; } - if (bareHost.startsWith('::ffff:')) { - const suffix = bareHost.slice('::ffff:'.length); + if (blocklistHost.startsWith('::ffff:')) { + const suffix = blocklistHost.slice('::ffff:'.length); if (net.isIP(suffix) === 4) { return isBlockedAuthProviderHost(suffix); } @@ -1133,8 +1204,375 @@ export function createServeApp( bridge.queryWorkspaceStatus(method, idle), invokeWorkspaceCommand: (method, params, invokeOpts) => bridge.invokeWorkspaceCommand(method, params, invokeOpts), + refreshExtensionsForAllSessions: () => + bridge.refreshExtensionsForAllSessions(), publishWorkspaceEvent: (event) => bridge.publishWorkspaceEvent(event), }); + let extensionInstallQueue: Promise = Promise.resolve(); + let extensionInstallQueueDepth = 0; + const MAX_EXTENSION_INSTALL_QUEUE_DEPTH = 10; + const enqueueExtensionInstall = async (run: () => Promise) => { + if (extensionInstallQueueDepth >= MAX_EXTENSION_INSTALL_QUEUE_DEPTH) { + throw new Error('Extension operation queue is full'); + } + extensionInstallQueueDepth += 1; + const next = extensionInstallQueue.then(run, run).finally(() => { + extensionInstallQueueDepth -= 1; + }); + extensionInstallQueue = next.catch(() => undefined); + return next; + }; + const EXTENSION_MUTATION_TIMEOUT_MS = 120_000; + const EXTENSION_REFRESH_TIMEOUT_MS = 30_000; + const isExtensionQueueFullError = (err: unknown): boolean => + err instanceof Error && err.message === 'Extension operation queue is full'; + const sendExtensionQueueFull = (res: Response) => { + res.status(429).json({ + error: 'Extension operation queue is full', + code: 'extension_queue_full', + }); + }; + const withExtensionTimeout = async ( + promise: Promise, + timeoutMs: number, + operation: string, + ): Promise => + await new Promise((resolve, reject) => { + const timeout = setTimeout(() => { + reject(new Error(`${operation} timed out after ${timeoutMs}ms`)); + }, timeoutMs); + promise.then( + (value) => { + clearTimeout(timeout); + resolve(value); + }, + (err: unknown) => { + clearTimeout(timeout); + reject(err); + }, + ); + }); + const createExtensionManager = () => + new ExtensionManager({ + workspaceDir: boundWorkspace, + isWorkspaceTrusted: !!isWorkspaceTrusted( + loadSettings(boundWorkspace).merged, + ), + requestConsent: () => Promise.resolve(), + requestSetting: async (setting: ExtensionSetting) => { + throw new Error( + `Extension setting "${setting.envVar}" requires interactive configuration and is not supported over the daemon install endpoint.`, + ); + }, + requestChoicePlugin: async () => { + throw new Error( + 'Marketplace plugin selection is not supported over the daemon install endpoint. Specify a plugin name in the source.', + ); + }, + }); + const validateExtensionMutationClient = ( + req: Request, + res: Response, + route: string, + ): boolean => { + const clientId = parseAndValidateWorkspaceClientId(req, res, bridge); + if (clientId === null) return false; + if (clientId === undefined) { + res.status(400).json({ + error: 'Missing X-Qwen-Client-Id header', + code: 'missing_client_id', + }); + return false; + } + buildWorkspaceCtx(req, route, clientId); + return true; + }; + const parseExtensionScope = ( + body: Record, + res: Response, + ): SettingScope | null => { + const scope = body['scope']; + if (scope !== 'user' && scope !== 'workspace') { + res + .status(400) + .json({ error: '`scope` must be either "user" or "workspace"' }); + return null; + } + return scope === 'user' ? SettingScope.User : SettingScope.Workspace; + }; + const parseExtensionRegistryUrl = ( + value: string, + res: Response, + ): string | null => { + let parsed: URL; + try { + parsed = new URL(value); + } catch { + res.status(400).json({ error: '`registry` must be a valid URL' }); + return null; + } + if (parsed.protocol !== 'https:') { + res.status(400).json({ error: '`registry` must use https' }); + return null; + } + if (parsed.username || parsed.password) { + res + .status(400) + .json({ error: '`registry` must not include credentials' }); + return null; + } + if (isBlockedAuthProviderHost(parsed.hostname)) { + res.status(400).json({ error: '`registry` host is not allowed' }); + return null; + } + return parsed.toString().replace(/\/$/, ''); + }; + const parsePotentialSourceUrl = (source: string): URL | null => { + if (/^[a-zA-Z]:[\\/]/.test(source)) return null; + try { + return new URL(source); + } catch { + const sshMatch = /^(?:[^@]+@)?(\[[^\]]+\]|[^:]+):/.exec(source); + if (!sshMatch?.[1]) return null; + try { + return new URL(`ssh://${sshMatch[1]}`); + } catch { + return null; + } + } + }; + const validateExtensionSourceHost = ( + source: string, + res: Response, + ): boolean => { + const parsed = parsePotentialSourceUrl(source); + if (!parsed) return true; + if (parsed.username || parsed.password) { + res.status(400).json({ error: '`source` must not include credentials' }); + return false; + } + if (isBlockedAuthProviderHost(parsed.hostname)) { + res.status(400).json({ error: '`source` host is not allowed' }); + return false; + } + if (parsed.protocol !== 'https:' && parsed.protocol !== 'ssh:') { + res.status(400).json({ error: '`source` must use https or ssh' }); + return false; + } + return true; + }; + const validateExtensionSourceMetadata = ( + installMetadata: ExtensionInstallMetadata, + ): boolean => { + if (installMetadata.type !== 'git') return true; + const parsed = parsePotentialSourceUrl(installMetadata.source); + return ( + !!parsed && + (parsed.protocol === 'https:' || parsed.protocol === 'ssh:') && + !isBlockedAuthProviderHost(parsed.hostname) + ); + }; + const findLoadedExtension = ( + extensionManager: ExtensionManager, + extensionName: string, + ): Extension | undefined => { + const requested = extensionName.toLowerCase(); + const extensions = extensionManager.getLoadedExtensions(); + const byName = extensions.find( + (extension) => extension.name.toLowerCase() === requested, + ); + if (byName) return byName; + if (!extensionName.includes('://') && !extensionName.includes('@')) { + return undefined; + } + return extensions.find( + (extension) => + extension.installMetadata?.source?.toLowerCase() === requested, + ); + }; + type ExtensionMutationEvent = { + status: 'installed' | 'enabled' | 'disabled' | 'updated' | 'uninstalled'; + source?: string; + name?: string; + version?: string; + }; + const runQueuedExtensionMutation = ( + operation: string, + failureContext: { source?: string; name?: string }, + res: Response, + run: ( + extensionManager: ExtensionManager, + ) => Promise, + ): void => { + if (extensionInstallQueueDepth >= MAX_EXTENSION_INSTALL_QUEUE_DEPTH) { + sendExtensionQueueFull(res); + return; + } + res.status(202).json({ accepted: true }); + void enqueueExtensionInstall(async () => { + try { + const extensionManager = createExtensionManager(); + await extensionManager.refreshCache(); + const event = await withExtensionTimeout( + run(extensionManager), + EXTENSION_MUTATION_TIMEOUT_MS, + `extension ${operation}`, + ); + extensionsStatusCache = undefined; + try { + const result = await bridge.refreshExtensionsForAllSessions(event); + writeStderrLine( + `qwen serve: extensions ${operation}: refreshed ${result.refreshed} session(s), ${result.failed} failed`, + ); + } catch (refreshErr) { + const message = redactUrlCredentials( + refreshErr instanceof Error + ? refreshErr.message + : String(refreshErr), + ); + try { + bridge.broadcastExtensionsChanged({ + ...event, + refreshed: 0, + failed: 1, + error: message.slice(0, 500), + }); + } catch (broadcastErr) { + writeStderrLine( + `qwen serve: extensions ${operation}: failed to broadcast refresh failure: ${ + broadcastErr instanceof Error + ? redactUrlCredentials(broadcastErr.message) + : String(broadcastErr) + }`, + ); + } + writeStderrLine( + `qwen serve: extensions ${operation}: mutation succeeded but refresh failed: ${message}`, + ); + } + } catch (err) { + const message = redactUrlCredentials( + err instanceof Error ? err.message : String(err), + ); + try { + bridge.broadcastExtensionsChanged({ + status: 'failed', + ...(failureContext.source + ? { source: redactUrlCredentials(failureContext.source) } + : {}), + ...(failureContext.name ? { name: failureContext.name } : {}), + refreshed: 0, + failed: 0, + error: message.slice(0, 500), + }); + } catch (broadcastErr) { + writeStderrLine( + `qwen serve: extensions ${operation}: failed to broadcast failure: ${ + broadcastErr instanceof Error + ? redactUrlCredentials(broadcastErr.message) + : String(broadcastErr) + }`, + ); + } + try { + writeStderrLine( + `qwen serve: extensions ${operation}: background task failed: ${message}`, + ); + } catch { + // Keep queued background work from surfacing as unhandledRejection. + } + } + }).catch((err) => { + try { + writeStderrLine( + `qwen serve: extensions ${operation}: queued task failed: ${ + err instanceof Error ? err.message : String(err) + }`, + ); + } catch { + // Last-resort guard for detached async work. + } + }); + }; + let extensionsStatusCache: + | { expiresAt: number; value: ServeWorkspaceExtensionsStatus } + | undefined; + const buildLocalExtensionsStatus = + async (): Promise => { + const now = Date.now(); + if (extensionsStatusCache && extensionsStatusCache.expiresAt > now) { + return extensionsStatusCache.value; + } + const extensionManager = createExtensionManager(); + await extensionManager.refreshCache(); + const entries: ServeExtensionEntry[] = extensionManager + .getLoadedExtensions() + .map((ext): ServeExtensionEntry => { + const capabilities: ServeExtensionCapabilities = { + mcpServerCount: ext.mcpServers + ? Object.keys(ext.mcpServers).length + : 0, + skillCount: ext.skills?.length ?? 0, + agentCount: ext.agents?.length ?? 0, + hookCount: ext.hooks + ? Object.values(ext.hooks).reduce( + (sum, defs) => sum + (defs?.length ?? 0), + 0, + ) + : 0, + commandCount: ext.commands?.length ?? 0, + contextFileCount: ext.contextFiles.length, + channelCount: ext.channels ? Object.keys(ext.channels).length : 0, + hasSettings: (ext.settings?.length ?? 0) > 0, + }; + return { + kind: 'extension', + id: ext.id, + name: ext.name, + ...(ext.displayName ? { displayName: ext.displayName } : {}), + version: ext.version, + isActive: ext.isActive, + path: ext.path, + ...(ext.installMetadata?.source + ? { source: redactUrlCredentials(ext.installMetadata.source) } + : {}), + ...(ext.installMetadata?.type + ? { installType: ext.installMetadata.type } + : {}), + ...(ext.installMetadata?.originSource + ? { originSource: ext.installMetadata.originSource } + : {}), + ...(ext.installMetadata?.ref + ? { ref: ext.installMetadata.ref } + : {}), + ...(ext.installMetadata?.autoUpdate !== undefined + ? { autoUpdate: ext.installMetadata.autoUpdate } + : {}), + updateState: ext.installMetadata ? 'unknown' : 'not updatable', + capabilities, + details: { + mcpServers: ext.mcpServers ? Object.keys(ext.mcpServers) : [], + commands: ext.commands ?? [], + skills: ext.skills?.map((skill) => skill.name) ?? [], + agents: ext.agents?.map((agent) => agent.name) ?? [], + contextFiles: ext.contextFiles, + settings: + ext.resolvedSettings?.map((setting) => setting.name) ?? [], + }, + }; + }); + const status = { + v: STATUS_SCHEMA_VERSION, + workspaceCwd: boundWorkspace, + initialized: true, + extensions: entries, + }; + extensionsStatusCache = { + expiresAt: now + 2_000, + value: status, + }; + return status; + }; // Order matters: rejection guards (CORS / Host allowlist / bearer auth) // run BEFORE the JSON body parser. Otherwise an unauthenticated POST @@ -1618,13 +2056,422 @@ export function createServeApp( // GET /workspace/extensions — read-only installed extension status. app.get('/workspace/extensions', async (req, res) => { try { - const ctx = buildWorkspaceCtx(req, 'GET /workspace/extensions'); - res.status(200).json(await workspace.getWorkspaceExtensionsStatus(ctx)); + buildWorkspaceCtx(req, 'GET /workspace/extensions'); + res.status(200).json(await buildLocalExtensionsStatus()); } catch (err) { sendBridgeError(res, err, { route: 'GET /workspace/extensions' }); } }); + // POST /workspace/extensions/install — install an extension and refresh + // all active sessions asynchronously. + app.post( + '/workspace/extensions/install', + mutate({ strict: true }), + async (req, res) => { + try { + if ( + !validateExtensionMutationClient( + req, + res, + 'POST /workspace/extensions/install', + ) + ) { + return; + } + const body = safeBody(req); + const source = body['source']; + const ref = body['ref']; + const autoUpdate = body['autoUpdate']; + const allowPreRelease = body['allowPreRelease']; + const registry = body['registry']; + const consent = body['consent']; + + if (!source || typeof source !== 'string') { + res.status(400).json({ error: 'Missing or invalid source' }); + return; + } + if (ref !== undefined && (typeof ref !== 'string' || ref === '')) { + res.status(400).json({ error: '`ref` must be a string' }); + return; + } + if (typeof ref === 'string' && ref.startsWith('-')) { + res.status(400).json({ error: '`ref` must not start with "-"' }); + return; + } + if (autoUpdate !== undefined && typeof autoUpdate !== 'boolean') { + res.status(400).json({ error: '`autoUpdate` must be a boolean' }); + return; + } + if ( + allowPreRelease !== undefined && + typeof allowPreRelease !== 'boolean' + ) { + res + .status(400) + .json({ error: '`allowPreRelease` must be a boolean' }); + return; + } + if (registry !== undefined && typeof registry !== 'string') { + res.status(400).json({ error: '`registry` must be a string' }); + return; + } + const registryUrl = + registry !== undefined + ? parseExtensionRegistryUrl(registry, res) + : undefined; + if (registryUrl === null) return; + if (consent !== true) { + res.status(400).json({ + error: 'Extension installation requires explicit consent', + }); + return; + } + if (!validateExtensionSourceHost(source, res)) { + return; + } + + runQueuedExtensionMutation( + 'install', + { source }, + res, + async (extensionManager) => { + const installMetadata = await parseInstallSource(source); + + if ( + installMetadata.type !== 'git' && + installMetadata.type !== 'github-release' && + installMetadata.type !== 'npm' + ) { + throw new Error( + 'Only GitHub, Git, and npm extension installs are supported over the daemon endpoint.', + ); + } + if (installMetadata.type === 'npm' && ref) { + throw new Error('--ref is not applicable for npm extensions.'); + } + if (installMetadata.type !== 'npm' && registry) { + throw new Error( + '--registry is only applicable for npm extensions.', + ); + } + if (!validateExtensionSourceMetadata(installMetadata)) { + throw new Error('`source` host is not allowed'); + } + if (installMetadata.type === 'npm' && registryUrl) { + installMetadata.registryUrl = registryUrl; + } + const extension = await extensionManager.installExtension( + { ...installMetadata, ref, autoUpdate, allowPreRelease }, + () => Promise.resolve(), + ); + return { + status: 'installed', + source, + name: extension.name, + version: extension.config.version, + }; + }, + ); + } catch (err) { + sendBridgeError(res, err, { + route: 'POST /workspace/extensions/install', + }); + } + }, + ); + + app.post( + '/workspace/extensions/check-updates', + mutate({ strict: true }), + async (req, res) => { + try { + if ( + !validateExtensionMutationClient( + req, + res, + 'POST /workspace/extensions/check-updates', + ) + ) { + return; + } + const states = await enqueueExtensionInstall(async () => + withExtensionTimeout( + (async () => { + const extensionManager = createExtensionManager(); + await extensionManager.refreshCache(); + const updateStates: Record = {}; + await extensionManager.checkForAllExtensionUpdates( + (name, state) => { + updateStates[name] = state; + }, + ); + return updateStates; + })(), + EXTENSION_REFRESH_TIMEOUT_MS, + 'extension update check', + ), + ); + res.status(200).json({ states }); + } catch (err) { + if (isExtensionQueueFullError(err)) { + sendExtensionQueueFull(res); + return; + } + sendBridgeError(res, err, { + route: 'POST /workspace/extensions/check-updates', + }); + } + }, + ); + + app.post( + '/workspace/extensions/refresh', + mutate({ strict: true }), + async (req, res) => { + try { + if ( + !validateExtensionMutationClient( + req, + res, + 'POST /workspace/extensions/refresh', + ) + ) { + return; + } + const result = await enqueueExtensionInstall(async () => + withExtensionTimeout( + workspace.refreshExtensionsForAllSessions(), + EXTENSION_REFRESH_TIMEOUT_MS, + 'extension refresh', + ), + ); + res.status(200).json(result); + } catch (err) { + if (isExtensionQueueFullError(err)) { + sendExtensionQueueFull(res); + return; + } + sendBridgeError(res, err, { + route: 'POST /workspace/extensions/refresh', + }); + } + }, + ); + + app.post( + '/workspace/extensions/:name/enable', + mutate({ strict: true }), + async (req, res) => { + try { + if ( + !validateExtensionMutationClient( + req, + res, + 'POST /workspace/extensions/:name/enable', + ) + ) { + return; + } + const name = req.params['name']; + if (!name) { + res.status(400).json({ error: 'Missing extension name' }); + return; + } + const scope = parseExtensionScope(safeBody(req), res); + if (scope === null) return; + runQueuedExtensionMutation( + 'enable', + { name }, + res, + async (extensionManager) => { + const extension = findLoadedExtension(extensionManager, name); + if (!extension) { + throw new Error(`Extension "${name}" not found`); + } + await extensionManager.enableExtension( + extension.name, + scope, + boundWorkspace, + ); + return { status: 'enabled', name: extension.name }; + }, + ); + } catch (err) { + sendBridgeError(res, err, { + route: 'POST /workspace/extensions/:name/enable', + }); + } + }, + ); + + app.post( + '/workspace/extensions/:name/disable', + mutate({ strict: true }), + async (req, res) => { + try { + if ( + !validateExtensionMutationClient( + req, + res, + 'POST /workspace/extensions/:name/disable', + ) + ) { + return; + } + const name = req.params['name']; + if (!name) { + res.status(400).json({ error: 'Missing extension name' }); + return; + } + const scope = parseExtensionScope(safeBody(req), res); + if (scope === null) return; + runQueuedExtensionMutation( + 'disable', + { name }, + res, + async (extensionManager) => { + const extension = findLoadedExtension(extensionManager, name); + if (!extension) { + throw new Error(`Extension "${name}" not found`); + } + await extensionManager.disableExtension( + extension.name, + scope, + boundWorkspace, + ); + return { status: 'disabled', name: extension.name }; + }, + ); + } catch (err) { + sendBridgeError(res, err, { + route: 'POST /workspace/extensions/:name/disable', + }); + } + }, + ); + + app.post( + '/workspace/extensions/:name/update', + mutate({ strict: true }), + async (req, res) => { + try { + if ( + !validateExtensionMutationClient( + req, + res, + 'POST /workspace/extensions/:name/update', + ) + ) { + return; + } + const name = req.params['name']; + if (!name) { + res.status(400).json({ error: 'Missing extension name' }); + return; + } + runQueuedExtensionMutation( + 'update', + { name }, + res, + async (extensionManager) => { + const extension = findLoadedExtension(extensionManager, name); + if (!extension) { + throw new Error(`Extension "${name}" not found`); + } + let updateError: unknown; + const updateState = await withExtensionTimeout( + checkForExtensionUpdate(extension, extensionManager).catch( + (err: unknown) => { + updateError = err; + return ExtensionUpdateState.ERROR; + }, + ), + EXTENSION_REFRESH_TIMEOUT_MS, + 'extension update check', + ); + if (updateState === ExtensionUpdateState.ERROR) { + const message = + updateError === undefined + ? undefined + : redactUrlCredentials( + updateError instanceof Error + ? updateError.message + : String(updateError), + ); + throw new Error( + `Update check failed for extension "${extension.name}"${ + message ? `: ${message}` : '' + }`, + ); + } + if (updateState !== ExtensionUpdateState.UPDATE_AVAILABLE) { + throw new Error(`Extension "${extension.name}" has no update`); + } + const info = await extensionManager.updateExtension( + extension, + updateState, + () => undefined, + ); + return { + status: 'updated', + name: extension.name, + ...(info?.updatedVersion ? { version: info.updatedVersion } : {}), + }; + }, + ); + } catch (err) { + sendBridgeError(res, err, { + route: 'POST /workspace/extensions/:name/update', + }); + } + }, + ); + + app.delete( + '/workspace/extensions/:name', + mutate({ strict: true }), + async (req, res) => { + try { + if ( + !validateExtensionMutationClient( + req, + res, + 'DELETE /workspace/extensions/:name', + ) + ) { + return; + } + const name = req.params['name']; + if (!name) { + res.status(400).json({ error: 'Missing extension name' }); + return; + } + runQueuedExtensionMutation( + 'uninstall', + { name }, + res, + async (extensionManager) => { + const extension = findLoadedExtension(extensionManager, name); + if (!extension) { + throw new Error(`Extension "${name}" not found`); + } + await extensionManager.uninstallExtension( + extension.name, + false, + boundWorkspace, + ); + return { status: 'uninstalled', name: extension.name }; + }, + ); + } catch (err) { + sendBridgeError(res, err, { + route: 'DELETE /workspace/extensions/:name', + }); + } + }, + ); + // Workspace file routes (read-only + mutation). registerWorkspaceFileReadRoutes(app, { parseClientId: parseClientIdHeader, diff --git a/packages/cli/src/serve/workspace-service/__tests__/facade.test.ts b/packages/cli/src/serve/workspace-service/__tests__/facade.test.ts index e1c80069c6..8d3a279192 100644 --- a/packages/cli/src/serve/workspace-service/__tests__/facade.test.ts +++ b/packages/cli/src/serve/workspace-service/__tests__/facade.test.ts @@ -389,6 +389,47 @@ describe('createDaemonWorkspaceService', () => { }); }); + describe('refreshExtensionsForAllSessions', () => { + it('delegates to the all-session refresh callback', async () => { + const invokeWorkspaceCommand = vi.fn(); + const refreshExtensionsForAllSessions = vi + .fn() + .mockResolvedValue({ refreshed: 2, failed: 1 }); + const svc = createDaemonWorkspaceService( + makeDeps({ invokeWorkspaceCommand, refreshExtensionsForAllSessions }), + ); + + const result = await svc.refreshExtensionsForAllSessions(); + + expect(result).toEqual({ refreshed: 2, failed: 1 }); + expect(refreshExtensionsForAllSessions).toHaveBeenCalledOnce(); + expect(invokeWorkspaceCommand).not.toHaveBeenCalled(); + }); + + it('returns a failed result when the refresh callback is not wired', async () => { + const svc = createDaemonWorkspaceService(makeDeps()); + + await expect(svc.refreshExtensionsForAllSessions()).resolves.toEqual({ + refreshed: 0, + failed: 1, + }); + }); + + it('returns a failed result when the refresh callback rejects', async () => { + const refreshExtensionsForAllSessions = vi + .fn() + .mockRejectedValue(new Error('bridge down')); + const svc = createDaemonWorkspaceService( + makeDeps({ refreshExtensionsForAllSessions }), + ); + + await expect(svc.refreshExtensionsForAllSessions()).resolves.toEqual({ + refreshed: 0, + failed: 1, + }); + }); + }); + describe('restartMcpServer', () => { it('calls invokeWorkspaceCommand with correct method and params', async () => { const invokeWorkspaceCommand = vi.fn().mockResolvedValue({ diff --git a/packages/cli/src/serve/workspace-service/index.ts b/packages/cli/src/serve/workspace-service/index.ts index 9688b21355..de6ab43a2b 100644 --- a/packages/cli/src/serve/workspace-service/index.ts +++ b/packages/cli/src/serve/workspace-service/index.ts @@ -139,6 +139,7 @@ export function createDaemonWorkspaceService( persistDisabledTools, queryWorkspaceStatus, invokeWorkspaceCommand, + refreshExtensionsForAllSessions: refreshExtensionsForAllSessionsOnBridge, publishWorkspaceEvent, } = deps; @@ -620,5 +621,19 @@ export function createDaemonWorkspaceService( childError, }; }, + + async refreshExtensionsForAllSessions() { + try { + if (!refreshExtensionsForAllSessionsOnBridge) { + throw new Error('refreshExtensionsForAllSessions is not wired'); + } + return await refreshExtensionsForAllSessionsOnBridge(); + } catch (err) { + writeStderrLine( + `qwen serve: refreshExtensionsForAllSessions failed: ${err instanceof Error ? err.message : String(err)}`, + ); + return { refreshed: 0, failed: 1 }; + } + }, }; } diff --git a/packages/cli/src/serve/workspace-service/types.ts b/packages/cli/src/serve/workspace-service/types.ts index fd980ce939..f2b07a6cce 100644 --- a/packages/cli/src/serve/workspace-service/types.ts +++ b/packages/cli/src/serve/workspace-service/types.ts @@ -76,6 +76,11 @@ export type InvokeWorkspaceCommandFn = ( opts?: { timeoutMs?: number }, ) => Promise; +export type RefreshExtensionsForAllSessionsFn = () => Promise<{ + refreshed: number; + failed: number; +}>; + /** * The unified facade for workspace-scoped daemon operations. Routes * delegate here instead of reaching into the bridge for workspace @@ -143,6 +148,12 @@ export interface DaemonWorkspaceService { /** Reload all settings (env + model + permissions + tools + memory). */ reload(ctx: WorkspaceRequestContext): Promise; + + /** Broadcast extension refresh to all active sessions (fire-and-forget). */ + refreshExtensionsForAllSessions(): Promise<{ + refreshed: number; + failed: number; + }>; } // -- Result types for workspace mutations -- @@ -232,6 +243,12 @@ export interface DaemonWorkspaceServiceDeps { */ invokeWorkspaceCommand: InvokeWorkspaceCommandFn; + /** + * Broadcast an extension refresh to every live session. This must not + * delegate to `invokeWorkspaceCommand`, which targets only one live channel. + */ + refreshExtensionsForAllSessions?: RefreshExtensionsForAllSessionsFn; + /** * Publish a workspace-wide event to all sessions' SSE buses. * Used after mutations that affect all connected clients. diff --git a/packages/sdk-typescript/scripts/build.js b/packages/sdk-typescript/scripts/build.js index ecca56c30e..a0227c6e0a 100755 --- a/packages/sdk-typescript/scripts/build.js +++ b/packages/sdk-typescript/scripts/build.js @@ -25,7 +25,9 @@ const rootDir = join(__dirname, '..'); // Bumped from 116KB to 118KB for the transport abstraction layer (~1.5KB). // Bumped from 118KB to 119KB for the mid-turn drain surface (enqueue methods + // `mid_turn_message_injected` event type/guard/registration, ~150 bytes). -const MAX_DAEMON_BROWSER_BUNDLE_BYTES = 119 * 1024; +// Bumped from 119KB to 122KB for the workspace extension management surface +// (install/update/enable/disable/uninstall/refresh/check update endpoints). +const MAX_DAEMON_BROWSER_BUNDLE_BYTES = 122 * 1024; rmSync(join(rootDir, 'dist'), { recursive: true, force: true }); mkdirSync(join(rootDir, 'dist'), { recursive: true }); diff --git a/packages/sdk-typescript/src/daemon/DaemonClient.ts b/packages/sdk-typescript/src/daemon/DaemonClient.ts index a7b4f4decf..7cf712ff6b 100644 --- a/packages/sdk-typescript/src/daemon/DaemonClient.ts +++ b/packages/sdk-typescript/src/daemon/DaemonClient.ts @@ -81,6 +81,12 @@ import type { DaemonRewindResult, DaemonSessionHooksStatus, DaemonWorkspaceExtensionsStatus, + ExtensionMutationResponse, + ExtensionInstallRequest, + ExtensionInstallResponse, + ExtensionScopeRequest, + ExtensionRefreshResponse, + ExtensionUpdateCheckResponse, DaemonWorkspaceHooksStatus, DaemonWorkspaceSettingsStatus, DaemonSettingUpdateResult, @@ -562,6 +568,29 @@ export class DaemonClient { return new DaemonHttpError(res.status, body, `${label}: ${detail}`); } + private async jsonRequest( + path: string, + label: string, + opts: { method?: string; body?: unknown; clientId?: string } = {}, + ): Promise { + const hasBody = opts.body !== undefined; + return await this.fetchWithTimeout( + `${this.baseUrl}${path}`, + { + ...(opts.method ? { method: opts.method } : {}), + headers: this.headers( + hasBody ? { 'Content-Type': 'application/json' } : {}, + opts.clientId, + ), + ...(hasBody ? { body: JSON.stringify(opts.body) } : {}), + }, + async (res) => { + if (!res.ok) throw await this.failOnError(res, label); + return (await res.json()) as T; + }, + ); + } + // -- Lifecycle / discovery --------------------------------------------- async health(): Promise<{ status: string }> { @@ -662,14 +691,86 @@ export class DaemonClient { } async workspaceExtensions(): Promise { - return await this.fetchWithTimeout( - `${this.baseUrl}/workspace/extensions`, - { headers: this.headers() }, - async (res) => { - if (!res.ok) - throw await this.failOnError(res, 'GET /workspace/extensions'); - return (await res.json()) as DaemonWorkspaceExtensionsStatus; - }, + return await this.jsonRequest( + '/workspace/extensions', + 'GET /workspace/extensions', + ); + } + + async installExtension( + params: ExtensionInstallRequest, + clientId?: string, + ): Promise { + return await this.jsonRequest( + '/workspace/extensions/install', + 'POST /workspace/extensions/install', + { method: 'POST', body: params, clientId }, + ); + } + + async checkExtensionUpdates( + clientId?: string, + ): Promise { + return await this.jsonRequest( + '/workspace/extensions/check-updates', + 'POST /workspace/extensions/check-updates', + { method: 'POST', body: {}, clientId }, + ); + } + + async refreshExtensions( + clientId?: string, + ): Promise { + return await this.jsonRequest( + '/workspace/extensions/refresh', + 'POST /workspace/extensions/refresh', + { method: 'POST', body: {}, clientId }, + ); + } + + async enableExtension( + name: string, + params: ExtensionScopeRequest, + clientId?: string, + ): Promise { + return await this.jsonRequest( + `/workspace/extensions/${encodeURIComponent(name)}/enable`, + 'POST /workspace/extensions/:name/enable', + { method: 'POST', body: params, clientId }, + ); + } + + async disableExtension( + name: string, + params: ExtensionScopeRequest, + clientId?: string, + ): Promise { + return await this.jsonRequest( + `/workspace/extensions/${encodeURIComponent(name)}/disable`, + 'POST /workspace/extensions/:name/disable', + { method: 'POST', body: params, clientId }, + ); + } + + async updateExtension( + name: string, + clientId?: string, + ): Promise { + return await this.jsonRequest( + `/workspace/extensions/${encodeURIComponent(name)}/update`, + 'POST /workspace/extensions/:name/update', + { method: 'POST', body: {}, clientId }, + ); + } + + async uninstallExtension( + name: string, + clientId?: string, + ): Promise { + return await this.jsonRequest( + `/workspace/extensions/${encodeURIComponent(name)}`, + 'DELETE /workspace/extensions/:name', + { method: 'DELETE', clientId }, ); } diff --git a/packages/sdk-typescript/src/daemon/events.ts b/packages/sdk-typescript/src/daemon/events.ts index 4535b6fe28..d44af58970 100644 --- a/packages/sdk-typescript/src/daemon/events.ts +++ b/packages/sdk-typescript/src/daemon/events.ts @@ -83,6 +83,10 @@ export const DAEMON_KNOWN_EVENT_TYPE_VALUES = [ // `DELETE /workspace/mcp/servers/:name` when an entry was actually // removed. Idempotent skip ('not_present') does NOT emit. 'mcp_server_removed', + // Extensions lifecycle events. Fired by background extension install/refresh + // work. Carries refreshed/failed session counts, and may include install + // success/failure details. + 'extensions_changed', // Multi-client permission coordination events. // `permission_partial_vote` only fires under `consensus` policy; // `permission_forbidden` fires under `designated` (originator @@ -738,6 +742,28 @@ export type DaemonMcpServerRemovedEvent = DaemonEventEnvelope< DaemonMcpServerRemovedData >; +export interface DaemonExtensionsChangedData { + readonly refreshed: number; + readonly failed: number; + readonly status?: + | 'installed' + | 'enabled' + | 'disabled' + | 'updated' + | 'uninstalled' + | 'failed'; + readonly source?: string; + readonly name?: string; + readonly version?: string; + readonly error?: string; + [key: string]: unknown; +} + +export type DaemonExtensionsChangedEvent = DaemonEventEnvelope< + 'extensions_changed', + DaemonExtensionsChangedData +>; + export interface DaemonSessionSnapshotData { sessionId: string; currentModelId: string | null; @@ -968,7 +994,8 @@ export type DaemonMcpGuardrailEvent = */ export type DaemonWorkspaceMutationEvent = | DaemonMemoryChangedEvent - | DaemonAgentChangedEvent; + | DaemonAgentChangedEvent + | DaemonExtensionsChangedEvent; /** * Daemon assist push events — non-terminal UX hints emitted by the ACP @@ -1469,6 +1496,10 @@ export function asKnownDaemonEvent( return isMcpServerRemovedData(event.data) ? (event as DaemonMcpServerRemovedEvent) : undefined; + case 'extensions_changed': + return isExtensionsChangedData(event.data) + ? (event as DaemonExtensionsChangedEvent) + : undefined; case 'turn_complete': return isTurnCompleteData(event.data) ? (event as DaemonTurnCompleteEvent) @@ -1860,6 +1891,7 @@ export function reduceDaemonSessionEvent( case 'mcp_server_added': case 'mcp_server_removed': case 'settings_reloaded': + case 'extensions_changed': case MID_TURN_MESSAGE_INJECTED_EVENT: return base; case 'session_rewound': @@ -2634,6 +2666,38 @@ function isMcpServerRemovedData( return true; } +function isExtensionsChangedData( + value: unknown, +): value is DaemonExtensionsChangedData { + if (!isRecord(value)) return false; + if (typeof value['refreshed'] !== 'number') return false; + if (typeof value['failed'] !== 'number') return false; + if ( + value['status'] !== undefined && + value['status'] !== 'installed' && + value['status'] !== 'enabled' && + value['status'] !== 'disabled' && + value['status'] !== 'updated' && + value['status'] !== 'uninstalled' && + value['status'] !== 'failed' + ) { + return false; + } + if (value['source'] !== undefined && typeof value['source'] !== 'string') { + return false; + } + if (value['name'] !== undefined && typeof value['name'] !== 'string') { + return false; + } + if (value['version'] !== undefined && typeof value['version'] !== 'string') { + return false; + } + if (value['error'] !== undefined && typeof value['error'] !== 'string') { + return false; + } + return true; +} + function isSessionBranchedData( value: unknown, ): value is DaemonSessionBranchedData { diff --git a/packages/sdk-typescript/src/daemon/index.ts b/packages/sdk-typescript/src/daemon/index.ts index 9d62e742bf..c71d5b7a81 100644 --- a/packages/sdk-typescript/src/daemon/index.ts +++ b/packages/sdk-typescript/src/daemon/index.ts @@ -413,8 +413,17 @@ export type { DaemonExtensionInstallType, DaemonExtensionOriginSource, DaemonExtensionCapabilities, + DaemonExtensionDetails, DaemonExtensionEntry, + DaemonExtensionUpdateState, DaemonWorkspaceExtensionsStatus, + ExtensionInstallRequest, + ExtensionInstallResponse, + ExtensionMutationResponse, + ExtensionRefreshResponse, + ExtensionScope, + ExtensionScopeRequest, + ExtensionUpdateCheckResponse, HeartbeatResult, MCPServerConfigShape, PermissionOutcome, diff --git a/packages/sdk-typescript/src/daemon/types.ts b/packages/sdk-typescript/src/daemon/types.ts index cbc437e486..fb1a42df78 100644 --- a/packages/sdk-typescript/src/daemon/types.ts +++ b/packages/sdk-typescript/src/daemon/types.ts @@ -1685,10 +1685,31 @@ export interface DaemonExtensionCapabilities { hasSettings: boolean; } +export type DaemonExtensionUpdateState = + | 'checking for updates' + | 'updated, needs restart' + | 'updating' + | 'updated' + | 'update available' + | 'up to date' + | 'error' + | 'not updatable' + | 'unknown'; + +export interface DaemonExtensionDetails { + mcpServers: string[]; + commands: string[]; + skills: string[]; + agents: string[]; + contextFiles: string[]; + settings: string[]; +} + export interface DaemonExtensionEntry { kind: 'extension'; id: string; name: string; + displayName?: string; version: string; isActive: boolean; path: string; @@ -1697,7 +1718,9 @@ export interface DaemonExtensionEntry { originSource?: DaemonExtensionOriginSource; ref?: string; autoUpdate?: boolean; + updateState?: DaemonExtensionUpdateState; capabilities: DaemonExtensionCapabilities; + details?: DaemonExtensionDetails; } export interface DaemonWorkspaceExtensionsStatus { @@ -1707,3 +1730,33 @@ export interface DaemonWorkspaceExtensionsStatus { extensions: DaemonExtensionEntry[]; errors?: DaemonStatusCell[]; } + +export interface ExtensionInstallRequest { + source: string; + ref?: string; + autoUpdate?: boolean; + allowPreRelease?: boolean; + registry?: string; + consent?: boolean; +} + +export interface ExtensionInstallResponse { + accepted: true; +} + +export type ExtensionMutationResponse = ExtensionInstallResponse; + +export type ExtensionScope = 'user' | 'workspace'; + +export interface ExtensionScopeRequest { + scope: ExtensionScope; +} + +export interface ExtensionUpdateCheckResponse { + states: Record; +} + +export interface ExtensionRefreshResponse { + refreshed: number; + failed: number; +} diff --git a/packages/sdk-typescript/src/daemon/ui/normalizer.ts b/packages/sdk-typescript/src/daemon/ui/normalizer.ts index 13aeaebe88..3b708ffe3d 100644 --- a/packages/sdk-typescript/src/daemon/ui/normalizer.ts +++ b/packages/sdk-typescript/src/daemon/ui/normalizer.ts @@ -267,6 +267,9 @@ export function normalizeDaemonEvent( case 'mcp_server_restart_refused': return normalizeMcpServerRestartRefused(event, base); + case 'extensions_changed': + return normalizeExtensionsChanged(event, base); + // ── Auth device-flow events (RFC 8628) ───────────────── case 'auth_device_flow_started': return normalizeAuthDeviceFlowStarted(event, base); @@ -1252,6 +1255,46 @@ function normalizeMcpServerRestartRefused( ]; } +function normalizeExtensionsChanged( + event: DaemonEvent, + base: NormalizedEventBase, +): DaemonUiEvent[] { + const refreshed = numberField(event.data, 'refreshed'); + const failed = numberField(event.data, 'failed'); + const status = getString(event.data, 'status'); + const source = getString(event.data, 'source'); + const name = getString(event.data, 'name'); + const version = getString(event.data, 'version'); + const error = getString(event.data, 'error'); + if (refreshed === undefined || failed === undefined) { + return fallbackDebug(event, base, 'malformed extensions_changed payload'); + } + if ( + status !== undefined && + status !== 'installed' && + status !== 'enabled' && + status !== 'disabled' && + status !== 'updated' && + status !== 'uninstalled' && + status !== 'failed' + ) { + return fallbackDebug(event, base, 'malformed extensions_changed payload'); + } + return [ + { + ...base, + type: 'workspace.extensions.changed', + refreshed, + failed, + ...(status ? { status } : {}), + ...(source ? { source } : {}), + ...(name ? { name } : {}), + ...(version ? { version } : {}), + ...(error ? { error } : {}), + }, + ]; +} + function normalizeAuthDeviceFlowStarted( event: DaemonEvent, base: NormalizedEventBase, diff --git a/packages/sdk-typescript/src/daemon/ui/terminal.ts b/packages/sdk-typescript/src/daemon/ui/terminal.ts index 7691d36d2d..50153140de 100644 --- a/packages/sdk-typescript/src/daemon/ui/terminal.ts +++ b/packages/sdk-typescript/src/daemon/ui/terminal.ts @@ -144,6 +144,34 @@ export function daemonUiEventToTerminalText(event: DaemonUiEvent): string { `${event.serverName} restart refused: ${event.reason}`, '33', ); + case 'workspace.extensions.changed': + if (event.status === 'failed') { + return terminalLine( + 'ext', + `extension action failed${ + event.name + ? ` ${event.name}` + : event.source + ? ` ${event.source}` + : '' + }: ${event.error ?? 'unknown error'}`, + '31', + ); + } + if (event.status === 'installed') { + return terminalLine( + 'ext', + `installed ${event.name ?? event.source ?? 'extension'}${ + event.version ? ` v${event.version}` : '' + } (${event.refreshed} refreshed, ${event.failed} failed)`, + '36', + ); + } + return terminalLine( + 'ext', + `extensions refreshed (${event.refreshed} ok, ${event.failed} failed)`, + '36', + ); case 'auth.device_flow.started': return terminalLine( 'auth', diff --git a/packages/sdk-typescript/src/daemon/ui/types.ts b/packages/sdk-typescript/src/daemon/ui/types.ts index 650131914c..7ea228eabf 100644 --- a/packages/sdk-typescript/src/daemon/ui/types.ts +++ b/packages/sdk-typescript/src/daemon/ui/types.ts @@ -52,6 +52,7 @@ export type DaemonUiEventType = | 'workspace.mcp.child_refused' | 'workspace.mcp.server_restarted' | 'workspace.mcp.server_restart_refused' + | 'workspace.extensions.changed' // Auth flow events (Wave 4 OAuth) | 'auth.device_flow.started' | 'auth.device_flow.throttled' @@ -431,6 +432,23 @@ export interface DaemonUiMcpServerRestartRefusedEvent reason: 'in_flight' | 'disabled' | 'budget_would_exceed'; } +export interface DaemonUiExtensionsChangedEvent extends DaemonUiEventBase { + type: 'workspace.extensions.changed'; + refreshed: number; + failed: number; + status?: + | 'installed' + | 'enabled' + | 'disabled' + | 'updated' + | 'uninstalled' + | 'failed'; + source?: string; + name?: string; + version?: string; + error?: string; +} + /* ────────────────────────────────────────────────────────────────────────── * Auth device-flow events (Wave 4 OAuth, RFC 8628) * ──────────────────────────────────────────────────────────────────────── */ @@ -513,6 +531,7 @@ export type DaemonUiEvent = | DaemonUiMcpChildRefusedEvent | DaemonUiMcpServerRestartedEvent | DaemonUiMcpServerRestartRefusedEvent + | DaemonUiExtensionsChangedEvent // Auth device-flow events | DaemonUiAuthDeviceFlowEvent; diff --git a/packages/sdk-typescript/test/unit/daemonUi.test.ts b/packages/sdk-typescript/test/unit/daemonUi.test.ts index edb1242501..20df7ffc27 100644 --- a/packages/sdk-typescript/test/unit/daemonUi.test.ts +++ b/packages/sdk-typescript/test/unit/daemonUi.test.ts @@ -1543,6 +1543,21 @@ describe('daemon UI normalizer and transcript reducer', () => { expect(output).not.toContain('\x00'); }); + it('renders extension failures without assuming install failed', () => { + const output = daemonUiEventToTerminalText({ + type: 'workspace.extensions.changed', + refreshed: 0, + failed: 0, + status: 'failed', + name: 'test-extension', + error: 'Extension mutation failed', + }); + + expect(output).toContain('extension action failed test-extension'); + expect(output).toContain('Extension mutation failed'); + expect(output).not.toContain('install failed'); + }); + it('strips terminal control and bidi spoofing sequences', () => { const output = sanitizeTerminalText( '\u202etxt.exe\u001b[31mred\roverwrite\u001bPhidden\u001b\\ok', @@ -2058,6 +2073,60 @@ describe('daemon UI normalizer — Wave 3/4 event coverage (PR-A)', () => { }); }); + it('normalizes extension install lifecycle details', () => { + const installed = normalizeDaemonEvent( + envelopeOf('extensions_changed', { + refreshed: 1, + failed: 0, + status: 'installed', + source: 'owner/repo', + name: 'test-extension', + version: '1.2.3', + }), + ); + expect(installed[0]).toMatchObject({ + type: 'workspace.extensions.changed', + refreshed: 1, + failed: 0, + status: 'installed', + source: 'owner/repo', + name: 'test-extension', + version: '1.2.3', + }); + + const updated = normalizeDaemonEvent( + envelopeOf('extensions_changed', { + refreshed: 1, + failed: 0, + status: 'updated', + name: 'test-extension', + version: '1.2.4', + }), + ); + expect(updated[0]).toMatchObject({ + type: 'workspace.extensions.changed', + status: 'updated', + name: 'test-extension', + version: '1.2.4', + }); + + const failed = normalizeDaemonEvent( + envelopeOf('extensions_changed', { + refreshed: 0, + failed: 0, + status: 'failed', + source: 'owner/repo', + error: 'install failed', + }), + ); + expect(failed[0]).toMatchObject({ + type: 'workspace.extensions.changed', + status: 'failed', + source: 'owner/repo', + error: 'install failed', + }); + }); + it('normalizes auth_device_flow lifecycle (started → throttled → authorized)', () => { const started = normalizeDaemonEvent( envelopeOf('auth_device_flow_started', { diff --git a/packages/web-shell/client/App.tsx b/packages/web-shell/client/App.tsx index ea548e0d54..3445922232 100644 --- a/packages/web-shell/client/App.tsx +++ b/packages/web-shell/client/App.tsx @@ -18,6 +18,7 @@ import { useTranscriptBlocks, useTranscriptStore, useWorkspaceActions, + useWorkspaceEventSignals, type DaemonSessionNotice, type DaemonStreamingState, } from '@qwen-code/webui/daemon-react-sdk'; @@ -65,6 +66,7 @@ import { AuthMessage, } from './components/messages/AuthMessage'; import { ToolsDialog } from './components/dialogs/ToolsDialog'; +import { ExtensionsDialog } from './components/dialogs/ExtensionsDialog'; import { SETTINGS_ACTIVE_EVENT, SettingsMessage, @@ -1005,11 +1007,78 @@ export function App({ const [showHelpDialog, setShowHelpDialog] = useState(false); const [showThemeDialog, setShowThemeDialog] = useState(false); const [showToolsDialog, setShowToolsDialog] = useState(false); + const [showExtensionsDialog, setShowExtensionsDialog] = useState(false); const [settingsInlineOpen, setSettingsInlineOpen] = useState(false); const [memoryInlineOpen, setMemoryInlineOpen] = useState(false); const [authInlineOpen, setAuthInlineOpen] = useState(false); const [memoryRefreshSignal, setMemoryRefreshSignal] = useState(0); const [memoryAddSignal, setMemoryAddSignal] = useState(0); + + // Refresh commands when extensions change (install/uninstall/update). + const workspaceEventSignals = useWorkspaceEventSignals(); + const extensionsVersionRef = useRef( + workspaceEventSignals?.extensionsVersion ?? 0, + ); + useEffect(() => { + const current = workspaceEventSignals?.extensionsVersion ?? 0; + if (current !== extensionsVersionRef.current) { + extensionsVersionRef.current = current; + const change = workspaceEventSignals?.lastExtensionChange; + if (change?.status === 'failed') { + store.dispatch([ + { + type: 'error', + text: t('extensions.action.failed', { + name: change.name ?? '', + source: change.source ?? '', + error: change.error ?? t('error.unknown'), + }), + }, + ]); + return; + } + if (change?.status === 'installed') { + const name = change.name ?? change.source ?? t('extensions.label'); + store.dispatch([ + { + type: 'status', + text: change.version + ? t('extensions.install.installedWithVersion', { + name, + version: change.version, + }) + : t('extensions.install.installed', { name }), + }, + ]); + } else if (change?.status) { + const name = change.name ?? change.source ?? t('extensions.label'); + const key = + change.status === 'updated' && change.version + ? 'extensions.manage.updatedWithVersion' + : `extensions.manage.${change.status}`; + store.dispatch([ + { + type: 'status', + text: t(key, { name, version: change.version ?? '' }), + }, + ]); + } + sessionActions.refreshCommands().catch(() => { + store.dispatch([ + { + type: 'error', + text: t('extensions.commands.refreshFailed'), + }, + ]); + }); + } + }, [ + workspaceEventSignals?.extensionsVersion, + workspaceEventSignals?.lastExtensionChange, + sessionActions, + store, + t, + ]); const [memoryAddScope, setMemoryAddScope] = useState<'workspace' | 'global'>( 'workspace', ); @@ -1051,7 +1120,8 @@ export function App({ showReleaseDialog || showHelpDialog || showThemeDialog || - showToolsDialog; + showToolsDialog || + showExtensionsDialog; const inlinePanelOpen = approvalModeInlineOpen || authInlineOpen || @@ -2245,6 +2315,130 @@ export function App({ setAgentsInlineMode(agentsMode); return true; } + if (cmd === 'extensions') { + const args = text.slice(match[0].length).trim(); + const subCommand = args.split(/\s+/)[0]?.toLowerCase(); + if (!subCommand || subCommand === 'manage') { + store.appendLocalUserMessage(text); + setShowExtensionsDialog(true); + return true; + } + if (subCommand === 'install') { + const tokens = args.slice('install'.length).trim().split(/\s+/); + let source = ''; + let ref: string | undefined; + let registry: string | undefined; + let autoUpdate: boolean | undefined; + let allowPreRelease: boolean | undefined; + let parseError: string | null = null; + for (let index = 0; index < tokens.length; index++) { + const token = tokens[index]; + if (!token) continue; + if (token === '--auto-update') { + autoUpdate = true; + } else if ( + token === '--pre-release' || + token === '--allow-pre-release' + ) { + allowPreRelease = true; + } else if (token === '--ref' || token === '--registry') { + const value = tokens[index + 1]; + if (!value || value.startsWith('--')) { + parseError = t('extensions.install.missingOptionValue', { + option: token, + }); + break; + } + if (token === '--ref') { + ref = value; + } else { + registry = value; + } + index += 1; + } else if (token.startsWith('--')) { + parseError = t('extensions.install.unknownOption', { + option: token, + }); + break; + } else if (!source) { + source = token; + } else { + parseError = t('extensions.install.usage'); + break; + } + } + if (parseError) { + store.appendLocalUserMessage(text); + store.dispatch([{ type: 'error', text: parseError }]); + return true; + } + if (!source) { + store.appendLocalUserMessage(text); + store.dispatch([ + { + type: 'error', + text: t('extensions.install.usage'), + }, + ]); + return true; + } + if (promptBlocked) { + store.appendLocalUserMessage(text); + store.dispatch([ + { + type: 'error', + text: t('extensions.install.waitForTurn'), + }, + ]); + return true; + } + const clientId = connectionRef.current.clientId; + if (!clientId) { + store.appendLocalUserMessage(text); + store.dispatch([ + { + type: 'error', + text: t('extensions.install.waitForSession'), + }, + ]); + return true; + } + store.appendLocalUserMessage(text); + store.dispatch([ + { + type: 'status', + text: t('extensions.install.started', { source }), + }, + ]); + workspaceActions + .installExtension( + { + source, + ...(ref ? { ref } : {}), + ...(registry ? { registry } : {}), + ...(autoUpdate !== undefined ? { autoUpdate } : {}), + ...(allowPreRelease !== undefined + ? { allowPreRelease } + : {}), + consent: true, + }, + clientId, + ) + .then(() => undefined) + .catch((error: unknown) => { + reportError(error, t('extensions.install.requestFailed')); + }); + return true; + } + store.appendLocalUserMessage(text); + store.dispatch([ + { + type: 'error', + text: t('extensions.install.usage'), + }, + ]); + return true; + } if (cmd === 'clear') { sessionActions.newSession().catch((error: unknown) => { reportError(error, 'Failed to create a new session'); @@ -2865,6 +3059,11 @@ export function App({ {showToolsDialog && ( setShowToolsDialog(false)} /> )} + {showExtensionsDialog && ( + setShowExtensionsDialog(false)} + /> + )} )} diff --git a/packages/web-shell/client/completions/slashCompletion.ts b/packages/web-shell/client/completions/slashCompletion.ts index f80b2e0b11..e40f7c0ff8 100644 --- a/packages/web-shell/client/completions/slashCompletion.ts +++ b/packages/web-shell/client/completions/slashCompletion.ts @@ -55,6 +55,10 @@ const SUBCOMMAND_TREE_ZH: Record = { }, { name: 'output', description: '设置 LLM 输出语言' }, ], + extensions: [ + { name: 'manage', description: '管理扩展' }, + { name: 'install', description: '安装扩展' }, + ], }; const SUBCOMMAND_TREE_EN: Record = { @@ -83,6 +87,10 @@ const SUBCOMMAND_TREE_EN: Record = { }, { name: 'output', description: 'Set LLM output language' }, ], + extensions: [ + { name: 'manage', description: 'Manage installed extensions' }, + { name: 'install', description: 'Install an extension from a source' }, + ], }; const IMPLICIT_SUBCOMMAND_TREE_ZH: Record = { diff --git a/packages/web-shell/client/components/dialogs/ExtensionsDialog.tsx b/packages/web-shell/client/components/dialogs/ExtensionsDialog.tsx new file mode 100644 index 0000000000..a5799f5854 --- /dev/null +++ b/packages/web-shell/client/components/dialogs/ExtensionsDialog.tsx @@ -0,0 +1,574 @@ +import { useCallback, useEffect, useMemo, useRef, useState } from 'react'; +import { + useConnection, + useWorkspaceActions, + useWorkspaceEventSignals, +} from '@qwen-code/webui/daemon-react-sdk'; +import type { + DaemonExtensionEntry, + DaemonExtensionUpdateState, +} from '@qwen-code/sdk/daemon'; +import { useDelayedGlobalKeyDown } from '../../hooks/useDelayedGlobalKeyDown'; +import { useI18n } from '../../i18n'; +import { dp } from './dialogStyles'; + +interface ExtensionsDialogProps { + onClose: () => void; +} + +type View = 'list' | 'actions' | 'details' | 'scope' | 'uninstall'; +type Scope = 'user' | 'workspace'; +type Mutation = 'enable' | 'disable'; + +interface ExtensionAction { + label: string; + hint?: string; + disabled?: boolean; + run: () => void; +} + +const UPDATE_AVAILABLE: DaemonExtensionUpdateState = 'update available'; + +function statusLabel( + extension: DaemonExtensionEntry, + t: ReturnType['t'], +): string { + return extension.isActive + ? t('extensions.manage.status.enabled') + : t('extensions.manage.status.disabled'); +} + +function updateLabel( + state: DaemonExtensionUpdateState | undefined, + t: ReturnType['t'], +): string { + switch (state) { + case 'update available': + return t('extensions.manage.updateAvailable'); + case 'up to date': + return t('extensions.manage.upToDate'); + case 'not updatable': + return t('extensions.manage.notUpdatable'); + case 'checking for updates': + return t('extensions.manage.checkingUpdates'); + case 'error': + return t('extensions.manage.updateError'); + default: + return t('extensions.manage.unknownUpdate'); + } +} + +function joinList(values: string[] | undefined): string { + return values && values.length > 0 ? values.join(', ') : '-'; +} + +export function ExtensionsDialog({ onClose }: ExtensionsDialogProps) { + const { t } = useI18n(); + const connection = useConnection(); + const actions = useWorkspaceActions(); + const signals = useWorkspaceEventSignals(); + const [view, setView] = useState('list'); + const [selectedIdx, setSelectedIdx] = useState(0); + const [actionIdx, setActionIdx] = useState(0); + const [scopeIdx, setScopeIdx] = useState(0); + const [scopeMutation, setScopeMutation] = useState('disable'); + const [extensions, setExtensions] = useState([]); + const [updateStates, setUpdateStates] = useState< + Record + >({}); + const [loading, setLoading] = useState(false); + const [checking, setChecking] = useState(false); + const [busyName, setBusyName] = useState(null); + const [message, setMessage] = useState(null); + const listRef = useRef(null); + + const selected = extensions[selectedIdx]; + + const load = useCallback(() => { + setLoading(true); + return actions + .loadExtensionsStatus() + .then((status) => { + setExtensions(status.extensions ?? []); + setMessage(status.errors?.[0]?.error ?? null); + }) + .catch((error: unknown) => { + setMessage(error instanceof Error ? error.message : String(error)); + }) + .finally(() => setLoading(false)); + }, [actions]); + + const checkUpdates = useCallback(() => { + const clientId = connection.clientId; + if (!clientId) { + setMessage(t('extensions.install.waitForSession')); + return Promise.resolve(); + } + setChecking(true); + return actions + .checkExtensionUpdates(clientId) + .then((result) => setUpdateStates(result.states)) + .catch((error: unknown) => { + setMessage(error instanceof Error ? error.message : String(error)); + }) + .finally(() => setChecking(false)); + }, [actions, connection.clientId, t]); + + const refreshSessions = useCallback(() => { + const clientId = connection.clientId; + if (!clientId) { + setMessage(t('extensions.install.waitForSession')); + return; + } + setChecking(true); + actions + .refreshExtensions(clientId) + .then(async (result) => { + setMessage( + t('extensions.manage.refreshed', { + refreshed: result.refreshed, + failed: result.failed, + }), + ); + await load(); + await checkUpdates(); + }) + .catch((error: unknown) => { + setMessage(error instanceof Error ? error.message : String(error)); + }) + .finally(() => setChecking(false)); + }, [actions, checkUpdates, connection.clientId, load, t]); + + useEffect(() => { + load(); + }, [load]); + + useEffect(() => { + if (extensions.length > 0) checkUpdates(); + }, [checkUpdates, extensions.length]); + + useEffect(() => { + if ((signals?.extensionsVersion ?? 0) > 0) { + setUpdateStates({}); + load(); + } + }, [load, signals?.extensionsVersion]); + + useEffect(() => { + if (selectedIdx >= extensions.length && extensions.length > 0) { + setSelectedIdx(extensions.length - 1); + } + }, [extensions.length, selectedIdx]); + + useEffect(() => { + const activeIndex = + view === 'actions' + ? actionIdx + : view === 'scope' + ? scopeIdx + : selectedIdx; + const el = listRef.current?.children[activeIndex] as + | HTMLElement + | undefined; + el?.scrollIntoView({ block: 'nearest' }); + }, [actionIdx, scopeIdx, selectedIdx, view]); + + const runMutation = useCallback( + (run: (clientId: string) => Promise, name: string) => { + const clientId = connection.clientId; + if (!clientId) { + setMessage(t('extensions.install.waitForSession')); + return; + } + setBusyName(name); + setMessage(null); + run(clientId) + .then(() => setMessage(t('extensions.manage.queued', { name }))) + .catch((error: unknown) => { + setMessage(error instanceof Error ? error.message : String(error)); + }) + .finally(() => setBusyName(null)); + }, + [connection.clientId, t], + ); + + const actionsForSelected: ExtensionAction[] = useMemo(() => { + if (!selected) return []; + const state = updateStates[selected.name] ?? selected.updateState; + const items: ExtensionAction[] = [ + { + label: t('extensions.manage.viewDetails'), + run: () => setView('details'), + }, + ]; + items.push({ + label: t('extensions.manage.update'), + hint: updateLabel(state, t), + disabled: state !== UPDATE_AVAILABLE || busyName === selected.name, + run: () => + runMutation( + (clientId) => actions.updateExtension(selected.name, clientId), + selected.name, + ), + }); + items.push({ + label: selected.isActive + ? t('extensions.manage.disable') + : t('extensions.manage.enable'), + disabled: busyName === selected.name, + run: () => { + setScopeMutation(selected.isActive ? 'disable' : 'enable'); + setScopeIdx(0); + setView('scope'); + }, + }); + items.push({ + label: t('extensions.manage.uninstallAction'), + disabled: busyName === selected.name, + run: () => setView('uninstall'), + }); + return items; + }, [actions, busyName, runMutation, selected, t, updateStates]); + + useEffect(() => { + if (actionIdx >= actionsForSelected.length && actionsForSelected.length > 0) + setActionIdx(actionsForSelected.length - 1); + }, [actionIdx, actionsForSelected.length]); + + const selectScope = useCallback( + (scope: Scope) => { + if (!selected) return; + runMutation( + (clientId) => + scopeMutation === 'enable' + ? actions.enableExtension(selected.name, { scope }, clientId) + : actions.disableExtension(selected.name, { scope }, clientId), + selected.name, + ); + setView('actions'); + }, + [actions, runMutation, scopeMutation, selected], + ); + + useDelayedGlobalKeyDown( + (e: KeyboardEvent) => { + if (e.key === 'Escape') { + e.preventDefault(); + if (view === 'list') onClose(); + else if (view === 'actions') setView('list'); + else if (view === 'details') setView('actions'); + else if (view === 'scope') setView('actions'); + else if (view === 'uninstall') setView('actions'); + return; + } + if (e.key === 'ArrowDown' || e.key === 'j') { + e.preventDefault(); + if (view === 'list') + setSelectedIdx((i) => + Math.min(i + 1, Math.max(extensions.length - 1, 0)), + ); + else if (view === 'actions') + setActionIdx((i) => + Math.min(i + 1, Math.max(actionsForSelected.length - 1, 0)), + ); + else if (view === 'scope') setScopeIdx((i) => Math.min(i + 1, 1)); + return; + } + if (e.key === 'ArrowUp' || e.key === 'k') { + e.preventDefault(); + if (view === 'list') setSelectedIdx((i) => Math.max(i - 1, 0)); + else if (view === 'actions') setActionIdx((i) => Math.max(i - 1, 0)); + else if (view === 'scope') setScopeIdx((i) => Math.max(i - 1, 0)); + return; + } + if (e.key === 'Enter') { + e.preventDefault(); + if (view === 'list' && selected) { + setView('actions'); + setActionIdx(0); + } else if (view === 'actions') { + const action = actionsForSelected[actionIdx]; + if (action && !action.disabled) action.run(); + } else if (view === 'scope') { + selectScope(scopeIdx === 0 ? 'user' : 'workspace'); + } else if (view === 'uninstall' && selected) { + runMutation( + (clientId) => actions.uninstallExtension(selected.name, clientId), + selected.name, + ); + setView('list'); + } + return; + } + if (e.key === 'r') { + e.preventDefault(); + if (view === 'list') { + refreshSessions(); + } + } + }, + [ + actionIdx, + actions, + actionsForSelected, + checkUpdates, + extensions.length, + load, + onClose, + refreshSessions, + runMutation, + scopeIdx, + selectScope, + selected, + view, + ], + ); + + const title = + view === 'list' + ? t('extensions.manage.title') + : view === 'details' + ? t('extensions.manage.detailsTitle') + : (selected?.name ?? t('extensions.manage.title')); + const footer = + view === 'list' + ? t('extensions.manage.footer.list') + : view === 'details' + ? t('extensions.manage.footer.back') + : view === 'uninstall' + ? t('extensions.manage.footer.confirm') + : t('extensions.manage.footer.select'); + + return ( +
+
+ {title} + {view === 'list' && ( + + {t('extensions.manage.count', { count: extensions.length })} + + )} + +
+ +
+ + {message || + (loading + ? t('extensions.manage.loading') + : checking + ? t('extensions.manage.checkingUpdates') + : t('common.enterSelect'))} + +
+ +
+ + {view === 'list' && ( +
+ {!loading && extensions.length === 0 && ( +
+ {t('extensions.manage.empty')} +
+ )} + {extensions.map((extension, i) => { + const state = updateStates[extension.name] ?? extension.updateState; + return ( +
{ + setSelectedIdx(i); + setView('actions'); + setActionIdx(0); + }} + onMouseEnter={() => setSelectedIdx(i)} + > +
+ + {i === selectedIdx ? '›' : ' '} + + + {extension.name} + + + v{extension.version} + + + {statusLabel(extension, t)} + +
+
+ {updateLabel(state, t)} +
+
+ ); + })} +
+ )} + + {view === 'actions' && selected && ( +
+
+
+ {t('extensions.manage.version')} {selected.version} +
+
+ {t('extensions.manage.status')} {statusLabel(selected, t)} +
+
+ {t('extensions.manage.source')} {selected.source ?? '-'} +
+
+ {actionsForSelected.map((action, i) => ( +
{ + if (!action.disabled) action.run(); + }} + onMouseEnter={() => setActionIdx(i)} + > + + {i === actionIdx ? '›' : ' '} + + + {action.label} + + {action.hint && ( + + {action.hint} + + )} +
+ ))} +
+ )} + + {view === 'details' && selected && ( + + )} + + {view === 'scope' && selected && ( +
+ {(['user', 'workspace'] as const).map((scope, i) => ( +
selectScope(scope)} + onMouseEnter={() => setScopeIdx(i)} + > + + {i === scopeIdx ? '›' : ' '} + + + {scope === 'user' + ? t('settings.scope.user') + : t('settings.scope.workspace')} + +
+ ))} +
+ )} + + {view === 'uninstall' && selected && ( +
+
+ {t('extensions.manage.uninstallConfirm', { name: selected.name })} +
+ +
+ )} + +
+
{footer}
+
+ ); +} + +function ExtensionDetails({ extension }: { extension: DaemonExtensionEntry }) { + const { t } = useI18n(); + const details = extension.details; + return ( +
+
+ + + + + + + + + + + +
+
+ ); +} + +function Detail({ label, value }: { label: string; value: string }) { + return ( +
+ {label} + {value} +
+ ); +} diff --git a/packages/web-shell/client/constants/localCommands.ts b/packages/web-shell/client/constants/localCommands.ts index 77a5884ffd..81e03e1ff5 100644 --- a/packages/web-shell/client/constants/localCommands.ts +++ b/packages/web-shell/client/constants/localCommands.ts @@ -97,6 +97,12 @@ export function getLocalCommands(t: Translate): CommandInfo[] { argumentHint: '', }, { name: 'settings', description: t('local.settings') }, + { + name: 'extensions', + description: t('local.extensions'), + argumentHint: 'manage|install ', + subcommands: ['manage', 'install'], + }, ]; return commands.map((command) => ({ ...command, diff --git a/packages/web-shell/client/i18n.tsx b/packages/web-shell/client/i18n.tsx index 303d32c18f..b06d805ced 100644 --- a/packages/web-shell/client/i18n.tsx +++ b/packages/web-shell/client/i18n.tsx @@ -355,6 +355,7 @@ const EN: Messages = { 'bug.popupBlocked': 'Popup blocked — please allow popups and try again.', 'bug.submitted': 'Bug report opened in a new tab.', 'clear.blocked': 'Cannot clear while streaming — cancel first (Esc).', + 'error.unknown': 'Unknown error', 'shell.command': 'Shell Command', 'compact.enabled': 'Compact mode enabled', 'compact.disabled': 'Compact mode disabled', @@ -457,6 +458,84 @@ const EN: Messages = { 'local.status': 'Show version info', 'local.theme': 'Change theme', 'local.settings': 'View and edit settings', + 'local.extensions': + 'Manage extensions. Usage: /extensions manage|install ', + 'extensions.label': 'extension', + 'extensions.action.failed': (v) => + `Extension action failed${ + v?.name ? ` for "${v.name}"` : v?.source ? ` from "${v.source}"` : '' + }: ${v?.error ?? 'Unknown error'}`, + 'extensions.commands.refreshFailed': 'Failed to refresh extension commands.', + 'extensions.install.failed': (v) => + `Failed to install extension${ + v?.source ? ` from "${v.source}"` : '' + }: ${v?.error ?? 'Unknown error'}`, + 'extensions.manage.agents': 'Agents:', + 'extensions.manage.checkingUpdates': 'Checking for updates...', + 'extensions.manage.commands': 'Commands:', + 'extensions.manage.contextFiles': 'Context files:', + 'extensions.manage.count': (v) => `${v?.count ?? 0} extensions installed`, + 'extensions.manage.detailsTitle': 'Extension Details', + 'extensions.manage.disable': 'Disable Extension', + 'extensions.manage.disabled': (v) => + `Extension "${v?.name ?? 'extension'}" disabled.`, + 'extensions.manage.empty': 'No extensions installed.', + 'extensions.manage.enable': 'Enable Extension', + 'extensions.manage.enabled': (v) => + `Extension "${v?.name ?? 'extension'}" enabled.`, + 'extensions.manage.footer.back': 'Esc to go back', + 'extensions.manage.footer.confirm': 'Enter confirm · Esc cancel', + 'extensions.manage.footer.list': + '↑↓ to navigate · Enter select · r refresh · Esc close', + 'extensions.manage.footer.select': '↑↓ to navigate · Enter select · Esc back', + 'extensions.manage.loading': 'Loading extensions...', + 'extensions.manage.mcpServers': 'MCP servers:', + 'extensions.manage.name': 'Name:', + 'extensions.manage.notUpdatable': 'not updatable', + 'extensions.manage.path': 'Path:', + 'extensions.manage.queued': (v) => + `Extension action queued for "${v?.name ?? 'extension'}".`, + 'extensions.manage.refreshed': (v) => + `Extensions refreshed in ${v?.refreshed ?? 0} session(s), ${v?.failed ?? 0} failed.`, + 'extensions.manage.settings': 'Settings:', + 'extensions.manage.skills': 'Skills:', + 'extensions.manage.source': 'Source:', + 'extensions.manage.status': 'Status:', + 'extensions.manage.status.disabled': 'disabled', + 'extensions.manage.status.enabled': 'enabled', + 'extensions.manage.title': 'Manage Extensions', + 'extensions.manage.unknownUpdate': 'unknown', + 'extensions.manage.uninstalled': (v) => + `Extension "${v?.name ?? 'extension'}" uninstalled.`, + 'extensions.manage.uninstallAction': 'Uninstall Extension', + 'extensions.manage.uninstallConfirm': (v) => + `Uninstall extension "${v?.name ?? 'extension'}"?`, + 'extensions.manage.upToDate': 'up to date', + 'extensions.manage.update': 'Update Extension', + 'extensions.manage.updateAvailable': 'update available', + 'extensions.manage.updateError': 'update check failed', + 'extensions.manage.updated': (v) => + `Extension "${v?.name ?? 'extension'}" updated.`, + 'extensions.manage.updatedWithVersion': (v) => + `Extension "${v?.name ?? 'extension'}" updated to v${v?.version ?? ''}.`, + 'extensions.manage.version': 'Version:', + 'extensions.manage.viewDetails': 'View Details', + 'extensions.install.installed': (v) => + `Extension "${v?.name ?? 'extension'}" installed.`, + 'extensions.install.installedWithVersion': (v) => + `Extension "${v?.name ?? 'extension'}" v${v?.version ?? ''} installed.`, + 'extensions.install.missingOptionValue': (v) => + `Missing value for ${v?.option ?? 'option'}`, + 'extensions.install.requestFailed': 'Failed to install extension', + 'extensions.install.started': (v) => + `Installing extension from "${v?.source ?? ''}"...`, + 'extensions.install.unknownOption': (v) => + `Unknown option ${v?.option ?? ''}`, + 'extensions.install.usage': 'Usage: /extensions manage|install ', + 'extensions.install.waitForSession': + 'Wait for the session to connect before installing an extension.', + 'extensions.install.waitForTurn': + 'Wait for the current turn to finish before installing an extension.', 'local.tools': 'List available tools. Usage: /tools [desc]', 'loadWarning.commands': 'Failed to load command list; slash commands may be incomplete.', @@ -1249,6 +1328,7 @@ const ZH: Messages = { 'bug.popupBlocked': '弹窗被拦截,请允许弹窗后重试。', 'bug.submitted': 'Bug 报告已在新标签页中打开。', 'clear.blocked': '流式输出中无法清屏 — 先按 Esc 取消。', + 'error.unknown': '未知错误', 'shell.command': 'Shell 命令', 'compact.enabled': '紧凑模式已开启', 'compact.disabled': '紧凑模式已关闭', @@ -1347,6 +1427,75 @@ const ZH: Messages = { 'local.status': '查看版本信息', 'local.theme': '切换主题', 'local.settings': '查看和编辑设置', + 'local.extensions': '管理扩展。用法: /extensions manage|install ', + 'extensions.label': '扩展', + 'extensions.action.failed': (v) => + `扩展操作失败${v?.name ? `(${v.name})` : v?.source ? `(${v.source})` : ''}:${ + v?.error ?? '未知错误' + }`, + 'extensions.commands.refreshFailed': '刷新扩展命令失败。', + 'extensions.install.failed': (v) => + `安装扩展失败${v?.source ? `(${v.source})` : ''}:${ + v?.error ?? '未知错误' + }`, + 'extensions.manage.agents': '智能体:', + 'extensions.manage.checkingUpdates': '正在检查更新...', + 'extensions.manage.commands': '命令:', + 'extensions.manage.contextFiles': '上下文文件:', + 'extensions.manage.count': (v) => `已安装 ${v?.count ?? 0} 个扩展`, + 'extensions.manage.detailsTitle': '扩展详情', + 'extensions.manage.disable': '禁用扩展', + 'extensions.manage.disabled': (v) => `扩展 "${v?.name ?? '扩展'}" 已禁用。`, + 'extensions.manage.empty': '未安装扩展。', + 'extensions.manage.enable': '启用扩展', + 'extensions.manage.enabled': (v) => `扩展 "${v?.name ?? '扩展'}" 已启用。`, + 'extensions.manage.footer.back': 'Esc 返回', + 'extensions.manage.footer.confirm': 'Enter 确认 · Esc 取消', + 'extensions.manage.footer.list': '↑↓ 导航 · Enter 选择 · r 刷新 · Esc 关闭', + 'extensions.manage.footer.select': '↑↓ 导航 · Enter 选择 · Esc 返回', + 'extensions.manage.loading': '正在加载扩展...', + 'extensions.manage.mcpServers': 'MCP servers:', + 'extensions.manage.name': '名称:', + 'extensions.manage.notUpdatable': '不可更新', + 'extensions.manage.path': '路径:', + 'extensions.manage.queued': (v) => + `扩展 "${v?.name ?? '扩展'}" 的操作已提交。`, + 'extensions.manage.refreshed': (v) => + `已刷新 ${v?.refreshed ?? 0} 个 session,${v?.failed ?? 0} 个失败。`, + 'extensions.manage.settings': '设置:', + 'extensions.manage.skills': 'Skills:', + 'extensions.manage.source': '来源:', + 'extensions.manage.status': '状态:', + 'extensions.manage.status.disabled': '已禁用', + 'extensions.manage.status.enabled': '已启用', + 'extensions.manage.title': '管理扩展', + 'extensions.manage.unknownUpdate': '未知', + 'extensions.manage.uninstalled': (v) => + `扩展 "${v?.name ?? '扩展'}" 已卸载。`, + 'extensions.manage.uninstallAction': '卸载扩展', + 'extensions.manage.uninstallConfirm': (v) => + `确定卸载扩展 "${v?.name ?? '扩展'}"?`, + 'extensions.manage.upToDate': '已是最新', + 'extensions.manage.update': '更新扩展', + 'extensions.manage.updateAvailable': '有可用更新', + 'extensions.manage.updateError': '检查更新失败', + 'extensions.manage.updated': (v) => `扩展 "${v?.name ?? '扩展'}" 已更新。`, + 'extensions.manage.updatedWithVersion': (v) => + `扩展 "${v?.name ?? '扩展'}" 已更新到 v${v?.version ?? ''}。`, + 'extensions.manage.version': '版本:', + 'extensions.manage.viewDetails': '查看详情', + 'extensions.install.installed': (v) => `扩展 "${v?.name ?? '扩展'}" 已安装。`, + 'extensions.install.installedWithVersion': (v) => + `扩展 "${v?.name ?? '扩展'}" v${v?.version ?? ''} 已安装。`, + 'extensions.install.missingOptionValue': (v) => + `${v?.option ?? '选项'} 缺少值`, + 'extensions.install.requestFailed': '安装扩展失败', + 'extensions.install.started': (v) => + `正在从 "${v?.source ?? ''}" 安装扩展...`, + 'extensions.install.unknownOption': (v) => `未知选项 ${v?.option ?? ''}`, + 'extensions.install.usage': '用法: /extensions manage|install ', + 'extensions.install.waitForSession': '等待会话连接后再安装扩展。', + 'extensions.install.waitForTurn': '等待当前回合结束后再安装扩展。', 'local.tools': '列出可用工具。用法: /tools [desc]', 'loadWarning.commands': '命令列表加载失败,斜杠命令可能不完整。', 'loadWarning.context': '会话上下文加载失败,当前模式可能不准确。', diff --git a/packages/webui/src/daemon-react-sdk.ts b/packages/webui/src/daemon-react-sdk.ts index def486f5a5..35b94767a2 100644 --- a/packages/webui/src/daemon-react-sdk.ts +++ b/packages/webui/src/daemon-react-sdk.ts @@ -117,6 +117,9 @@ export { useDaemonWorkspaceActions as useWorkspaceActions } from './daemon/index /** Like `useWorkspace()` but returns null when outside a WorkspaceProvider. */ export { useOptionalDaemonWorkspace as useOptionalWorkspace } from './daemon/index.js'; +/** Workspace-level event signals (memory/agents/tools/settings/mcp/extensions version counters). */ +export { useDaemonWorkspaceEventSignals as useWorkspaceEventSignals } from './daemon/index.js'; + // ── Transcript Hooks (low-level) ────────────────────────────────── /** Raw transcript blocks from the SSE stream. For custom message conversion. */ diff --git a/packages/webui/src/daemon/session/DaemonSessionProvider.tsx b/packages/webui/src/daemon/session/DaemonSessionProvider.tsx index a52a09df04..00bf44af2b 100644 --- a/packages/webui/src/daemon/session/DaemonSessionProvider.tsx +++ b/packages/webui/src/daemon/session/DaemonSessionProvider.tsx @@ -142,6 +142,7 @@ const INITIAL_WORKSPACE_EVENT_SIGNALS: DaemonWorkspaceEventSignals = { toolsVersion: 0, settingsVersion: 0, mcpVersion: 0, + extensionsVersion: 0, initVersion: 0, authVersion: 0, }; @@ -1578,7 +1579,7 @@ function getNumber( } function bumpWorkspaceEventSignals( - events: ReadonlyArray<{ type: string }>, + events: readonly DaemonUiEvent[], setSignals: Dispatch>, ): void { let memory = 0; @@ -1586,6 +1587,10 @@ function bumpWorkspaceEventSignals( let tools = 0; let settings = 0; let mcp = 0; + let extensions = 0; + let lastExtensionChange: + | DaemonWorkspaceEventSignals['lastExtensionChange'] + | undefined; let init = 0; let auth = 0; @@ -1609,6 +1614,18 @@ function bumpWorkspaceEventSignals( case 'workspace.mcp.server_restart_refused': mcp += 1; break; + case 'workspace.extensions.changed': + extensions += 1; + lastExtensionChange = { + ...(event.status ? { status: event.status } : {}), + ...(event.source ? { source: event.source } : {}), + ...(event.name ? { name: event.name } : {}), + ...(event.version ? { version: event.version } : {}), + ...(event.error ? { error: event.error } : {}), + refreshed: event.refreshed, + failed: event.failed, + }; + break; case 'workspace.initialized': init += 1; break; @@ -1624,7 +1641,8 @@ function bumpWorkspaceEventSignals( } } - if (memory + agents + tools + settings + mcp + init + auth === 0) return; + if (memory + agents + tools + settings + mcp + extensions + init + auth === 0) + return; setSignals((current) => ({ memoryVersion: current.memoryVersion + memory, @@ -1632,6 +1650,8 @@ function bumpWorkspaceEventSignals( toolsVersion: current.toolsVersion + tools, settingsVersion: current.settingsVersion + settings, mcpVersion: current.mcpVersion + mcp, + extensionsVersion: current.extensionsVersion + extensions, + ...(lastExtensionChange ? { lastExtensionChange } : {}), initVersion: current.initVersion + init, authVersion: current.authVersion + auth, })); diff --git a/packages/webui/src/daemon/session/types.ts b/packages/webui/src/daemon/session/types.ts index f51fb345f5..0b30db6fc4 100644 --- a/packages/webui/src/daemon/session/types.ts +++ b/packages/webui/src/daemon/session/types.ts @@ -322,6 +322,22 @@ export interface DaemonWorkspaceEventSignals { toolsVersion: number; settingsVersion: number; mcpVersion: number; + extensionsVersion: number; + lastExtensionChange?: { + status?: + | 'installed' + | 'enabled' + | 'disabled' + | 'updated' + | 'uninstalled' + | 'failed'; + source?: string; + name?: string; + version?: string; + error?: string; + refreshed: number; + failed: number; + }; initVersion: number; authVersion: number; } diff --git a/packages/webui/src/daemon/workspace/actions.ts b/packages/webui/src/daemon/workspace/actions.ts index 37931c7f04..3f05348bce 100644 --- a/packages/webui/src/daemon/workspace/actions.ts +++ b/packages/webui/src/daemon/workspace/actions.ts @@ -119,6 +119,14 @@ export function createDaemonWorkspaceActions({ ); }, + async loadExtensionsStatus() { + const client = requireClient(getClient, 'Load extensions failed'); + return withActionTimeout( + client.workspaceExtensions(), + 'Load extensions timed out', + ); + }, + async loadToolsStatus() { const client = requireClient(getClient, 'Load tools failed'); return withActionTimeout(client.workspaceTools(), 'Load tools timed out'); @@ -345,6 +353,62 @@ export function createDaemonWorkspaceActions({ ); }, + async installExtension(params, clientId) { + const client = requireClient(getClient, 'Install extension failed'); + return withActionTimeout( + client.installExtension(params, clientId), + 'Install extension timed out', + ); + }, + + async checkExtensionUpdates(clientId) { + const client = requireClient(getClient, 'Check extension updates failed'); + return withActionTimeout( + client.checkExtensionUpdates(clientId), + 'Check extension updates timed out', + ); + }, + + async refreshExtensions(clientId) { + const client = requireClient(getClient, 'Refresh extensions failed'); + return withActionTimeout( + client.refreshExtensions(clientId), + 'Refresh extensions timed out', + ); + }, + + async enableExtension(name, params, clientId) { + const client = requireClient(getClient, 'Enable extension failed'); + return withActionTimeout( + client.enableExtension(name, params, clientId), + 'Enable extension timed out', + ); + }, + + async disableExtension(name, params, clientId) { + const client = requireClient(getClient, 'Disable extension failed'); + return withActionTimeout( + client.disableExtension(name, params, clientId), + 'Disable extension timed out', + ); + }, + + async updateExtension(name, clientId) { + const client = requireClient(getClient, 'Update extension failed'); + return withActionTimeout( + client.updateExtension(name, clientId), + 'Update extension timed out', + ); + }, + + async uninstallExtension(name, clientId) { + const client = requireClient(getClient, 'Uninstall extension failed'); + return withActionTimeout( + client.uninstallExtension(name, clientId), + 'Uninstall extension timed out', + ); + }, + async startDeviceFlow(providerId) { const client = requireClient(getClient, 'Start device flow failed'); return withActionTimeout( diff --git a/packages/webui/src/daemon/workspace/types.ts b/packages/webui/src/daemon/workspace/types.ts index aed55264c6..b776c00c36 100644 --- a/packages/webui/src/daemon/workspace/types.ts +++ b/packages/webui/src/daemon/workspace/types.ts @@ -18,6 +18,12 @@ import type { DaemonGeneratedAgentContent, DaemonDeviceFlowStartResult, DaemonDeviceFlowState, + ExtensionMutationResponse, + ExtensionRefreshResponse, + ExtensionScopeRequest, + ExtensionInstallRequest, + ExtensionInstallResponse, + ExtensionUpdateCheckResponse, DaemonInitWorkspaceResult, DaemonMcpRestartResult, DaemonMcpManageAction, @@ -26,6 +32,7 @@ import type { DaemonWorkspaceAgentDetail, DaemonWorkspaceAgentsStatus, DaemonWorkspaceEnvStatus, + DaemonWorkspaceExtensionsStatus, DaemonWorkspaceFile, DaemonWorkspaceFileBytes, DaemonWorkspaceFileEditRequest, @@ -153,6 +160,9 @@ export interface DaemonWorkspaceActions { // Skills (read-only) loadSkillsStatus(): Promise; + // Extensions + loadExtensionsStatus(): Promise; + // Tools loadToolsStatus(): Promise; setWorkspaceToolEnabled(toolName: string, enabled: boolean): Promise; @@ -212,6 +222,34 @@ export interface DaemonWorkspaceActions { scope?: 'workspace' | 'global', ): Promise; + // Extensions + installExtension( + params: ExtensionInstallRequest, + clientId?: string, + ): Promise; + checkExtensionUpdates( + clientId?: string, + ): Promise; + refreshExtensions(clientId?: string): Promise; + enableExtension( + name: string, + params: ExtensionScopeRequest, + clientId?: string, + ): Promise; + disableExtension( + name: string, + params: ExtensionScopeRequest, + clientId?: string, + ): Promise; + updateExtension( + name: string, + clientId?: string, + ): Promise; + uninstallExtension( + name: string, + clientId?: string, + ): Promise; + // Auth device-flow startDeviceFlow( providerId: DaemonAuthProviderId,