From eb316e8de5bfcc3be12d64eb198e13e462809345 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E5=8F=B6=E5=85=AC?= Date: Wed, 26 Aug 2026 17:48:06 +0800 Subject: [PATCH 1/4] feat(daemon): support local path extension installs --- .../daemon-extension-local-path-install.md | 66 +++++++ .../daemon/11-capabilities-versioning.md | 2 + docs/developers/qwen-serve-protocol.md | 5 +- packages/cli/src/serve/capabilities.ts | 1 + .../src/serve/routes/workspace-extensions.ts | 78 ++++---- .../workspace-qualified-extensions.test.ts | 91 ++++++++++ packages/cli/src/serve/server.test.ts | 167 ++++++++++++++++-- packages/sdk-typescript/src/daemon/types.ts | 1 + .../test/unit/DaemonClient.test.ts | 27 +++ 9 files changed, 389 insertions(+), 49 deletions(-) create mode 100644 docs/design/daemon-extension-local-path-install.md diff --git a/docs/design/daemon-extension-local-path-install.md b/docs/design/daemon-extension-local-path-install.md new file mode 100644 index 00000000000..a79b16822e0 --- /dev/null +++ b/docs/design/daemon-extension-local-path-install.md @@ -0,0 +1,66 @@ +# Daemon Extension Local Path Installation + +## Problem + +The CLI can install an Extension from a path on the daemon host, but the daemon +REST install routes reject the same source. Callers that already placed an +Extension on the daemon filesystem must therefore start a terminal command +instead of using the asynchronous Extension operation API. + +## Design + +Allow an existing daemon-local path in the `source` field of both install +contracts: + +- `POST /workspace/extensions/install` remains the primary-workspace + compatibility route and installs with user activation. +- `POST /extensions/install` remains the process-global V2 route and uses its + existing explicit user or workspace activation request. + +No new route or SDK method is needed. `DaemonClient.installExtension()` and +`DaemonClient.installUserExtension()` already send the shared +`ExtensionInstallRequest.source` field and poll the existing operation model. +The accepted source set becomes Git, GitHub, npm, or a path that exists on the +daemon host and is classified as `local` by the existing source parser. + +Local installation keeps the current CLI semantics: the Extension manager +copies the source into managed Extension storage rather than linking it. A +directory or a supported local archive path therefore uses the same parsing, +conversion, consent, staging, commit, and cleanup logic already exercised by +the CLI. The REST contract accepts only absolute local paths. This avoids +resolving a relative path against the daemon process working directory and +prevents an existing local `owner/repo` directory from shadowing a remote +GitHub shorthand. + +Advertise `extension_local_path_install` so clients can distinguish daemons +that accept local paths from older daemons that reject them. The capability +covers both install routes; clients must still preflight +`extension_management_v2` before using the V2 route. + +## Security and failure behavior + +- Existing bearer authentication, strict mutation gating, workspace trust, + client identity validation, explicit consent, operation admission, and + runtime reconciliation remain unchanged. +- The path is resolved and read by the daemon host, not the SDK caller. +- Relative local paths are rejected; callers must send an absolute path in the + daemon host's path syntax. +- A missing path is rejected before the legacy compatibility route queues an + operation. V2 preparation reports source parsing failures through the + existing operation status contract. +- Local sources do not accept URL credentials, `ref`, or `autoUpdate`. + Existing npm registry and activation validation remains authoritative. +- Installed metadata records the local source path, matching CLI behavior. + +## Test plan + +- Verify the primary-workspace route queues an existing local path and prepares + it as `type: "local"`. +- Verify the V2 route queues the same source and preserves its explicit initial + activation. +- Verify missing local paths, explicit consent, client identity, workspace + trust, and unsupported remote source behavior remain unchanged. +- Verify `/capabilities` advertises `extension_local_path_install`. +- Install a minimal Extension from an absolute temporary directory through the + daemon, poll the operation to success, and confirm the installed catalog + entry. diff --git a/docs/developers/daemon/11-capabilities-versioning.md b/docs/developers/daemon/11-capabilities-versioning.md index 1f792404d1e..9222d0ffd45 100644 --- a/docs/developers/daemon/11-capabilities-versioning.md +++ b/docs/developers/daemon/11-capabilities-versioning.md @@ -120,6 +120,8 @@ Workspace read-only snapshots: `workspace_mcp`, `workspace_skills`, `workspace_p Extension management: `extension_management_v2` adds the global `/extensions/*` catalog/mutation/operation contract and the workspace activation projection. It is separate from the published `workspace_extensions` compatibility surface and from `workspace_qualified_rest_core`. +Local Extension installation: `extension_local_path_install` allows an absolute path on the daemon host in the existing `source` field of both Extension install routes. It is separate from `extension_management_v2` because the primary-workspace compatibility route also supports it, and clients must not send local paths to older daemons. + V2 Extension batch activation: `extension_batch_activation_v2` adds queued global default-activation and selected-workspace override batches to `extension_management_v2`. Clients must pre-flight it independently because older V2 daemons expose only singular activation routes. Workspace-qualified session reads: `workspace_persisted_transcript`, `workspace_session_export`, `workspace_archived_session_export`, `workspace_session_live_state`. The active and archived export tags are independent from each other and from `session_export` and `workspace_qualified_rest_core`, so clients must pre-flight the exact storage state they intend to export. Persisted transcript paging permits an untrusted secondary under its bounded read policy; both full export paths remain trusted-only. `workspace_session_live_state` is likewise independent from `workspace_qualified_rest_core` and is trusted-only: it serves the selected runtime's memory-only live-session snapshot and catalog version and does not extend the untrusted-secondary persisted read policy to live bridge state. diff --git a/docs/developers/qwen-serve-protocol.md b/docs/developers/qwen-serve-protocol.md index 20a653bc456..c504fdbb108 100644 --- a/docs/developers/qwen-serve-protocol.md +++ b/docs/developers/qwen-serve-protocol.md @@ -214,6 +214,7 @@ registry. Clients **must** gate UI off `features`, not off `mode` (per design 'workspace_display_name', 'workspace_qualified_rest_core', 'workspace_qualified_voice', 'workspace_qualified_memory', 'extension_management_v2', 'extension_git_credentials', + 'extension_local_path_install', 'workspace_persisted_transcript', 'workspace_session_export', 'workspace_archived_session_export', 'workspace_session_live_state', @@ -298,6 +299,8 @@ The same tag also exposes workspace-qualified project-agent CRUD at `/workspaces `extension_git_credentials` advertises authenticated HTTPS Git installs on both `POST /workspace/extensions/install` and `POST /extensions/install`. Clients must preflight this tag before sending URL userinfo or `credentialPersistence`; older daemons reject URL credentials. The tag describes backend protocol support, not the availability of a keychain: stored mode reports the selected backend in the terminal operation result. +`extension_local_path_install` advertises daemon-local Extension sources on both `POST /workspace/extensions/install` and `POST /extensions/install`. The `source` must be an absolute path that exists on the daemon host. Relative paths remain unsupported so daemon process cwd cannot change source identity or shadow a GitHub `owner/repo` shorthand. The existing install operation copies the Extension into managed storage; it does not link the source. Clients must preflight this tag because older daemons reject local sources. + `extension_batch_activation_v2` adds `PUT /extensions/activation` and `PUT /workspaces/:workspace/extensions/activation`. Both accept 1–100 names in `extensionNames`, deduplicate them case-insensitively while preserving first-seen order, persist changed targets in one generation, and return one `202` operation handle. A target does not need to be installed when setting `enabled` or `disabled`: its name creates a desired-state declaration that is preserved when an Extension with that name is installed. The global route accepts `state: "enabled" | "disabled"`, writes V2 `defaultActivation`, and reconciles every registered runtime. The workspace route also accepts `"inherit"`, applies or clears exact overrides for the selected trusted runtime, and reconciles only that runtime. `inherit` does not declare an unknown name; an all-unknown clear reports `updated: false` and skips reconciliation. Singular activation routes remain installed-only and id-addressed. ### Extension Management V2 wire contract @@ -381,7 +384,7 @@ Install requires explicit consent and an initial activation: } ``` -For workspace-only initial activation use `{ "scope": "workspace", "workspaceId": "target-workspace-id" }`; the target must exist and be trusted. Daemon installs accept GitHub, Git, and npm sources. `ref` does not apply to npm, and `registry` applies only to npm. `ref`, `autoUpdate`, `allowPreRelease`, and `registry` are optional. +For workspace-only initial activation use `{ "scope": "workspace", "workspaceId": "target-workspace-id" }`; the target must exist and be trusted. Daemon installs accept GitHub, Git, and npm sources. When `extension_local_path_install` is advertised, they also accept an absolute path that exists on the daemon host. `ref` does not apply to npm, `ref` and `autoUpdate` do not apply to local sources, and `registry` applies only to npm. `ref`, `autoUpdate`, `allowPreRelease`, and `registry` are optional. When `extension_git_credentials` is advertised, an HTTPS Git source may include userinfo, for example `https://username:token@git.example.com/org/repository.git`. `credentialPersistence` is valid only with such a source. It is `stored` or `one_time` and defaults to `one_time` when omitted. Stored mode saves the credential through the daemon's hybrid secret storage and keeps only the clean repository URL in install metadata, so the extension remains updatable. One-time mode saves neither the repository URL nor the credential and creates a non-updatable `snapshot`; `autoUpdate: true` is rejected for this mode. Supplying the field without URL credentials, supplying invalid credentials, or using credentials with npm, archive, local, SSH, or non-Git sources returns `400`. diff --git a/packages/cli/src/serve/capabilities.ts b/packages/cli/src/serve/capabilities.ts index 93bc4c6255d..33c4cd6c331 100644 --- a/packages/cli/src/serve/capabilities.ts +++ b/packages/cli/src/serve/capabilities.ts @@ -381,6 +381,7 @@ export const SERVE_CAPABILITY_REGISTRY = { // `workspace_extensions` contract. extension_management_v2: { since: 'v1' }, extension_git_credentials: { since: 'v1' }, + extension_local_path_install: { since: 'v1' }, // Workspace-qualified, daemon-local persisted transcript paging. The tag is // unconditional because the route also serves a trusted single-workspace // primary; authorization is evaluated for the selected runtime per request. diff --git a/packages/cli/src/serve/routes/workspace-extensions.ts b/packages/cli/src/serve/routes/workspace-extensions.ts index 241f9500971..a8c57ad2cec 100644 --- a/packages/cli/src/serve/routes/workspace-extensions.ts +++ b/packages/cli/src/serve/routes/workspace-extensions.ts @@ -72,6 +72,35 @@ const extensionArchiveBodyParser = express.raw({ limit: EXTENSION_ARCHIVE_UPLOAD_LIMIT, }); +const assertDaemonExtensionInstallSource = ( + installMetadata: ExtensionInstallMetadata, + source: string, + ref: string | undefined, + autoUpdate: boolean | undefined, +): void => { + if (installMetadata.type === 'local') { + if (!path.isAbsolute(source)) { + throw new Error('Local extension install paths must be absolute.'); + } + if (ref || autoUpdate) { + throw new Error( + '`ref` and `autoUpdate` are not applicable for local extensions.', + ); + } + return; + } + if ( + installMetadata.type === 'git' || + installMetadata.type === 'github-release' || + installMetadata.type === 'npm' + ) { + return; + } + throw new Error( + 'Only GitHub, Git, npm, and absolute local path extension installs are supported over the daemon endpoint.', + ); +}; + const parseExtensionArchiveFilename = ( value: unknown, ): { filename: string; suffix: '.zip' | '.tar.gz' } | null => { @@ -1073,25 +1102,20 @@ export function registerWorkspaceExtensionRoutes( return; } const localSource = + path.isAbsolute(sourceValue) || /^[A-Za-z]:[\\/]/.test(sourceValue) || - sourceValue.startsWith('/') || sourceValue.startsWith('.'); if (localSource) { try { const metadata = await parseInstallSource(sourceValue, { networkPolicy: 'public', }); - if ( - metadata.type !== 'git' && - metadata.type !== 'github-release' && - metadata.type !== 'npm' - ) { - res.status(400).json({ - error: - 'Only GitHub, Git, and npm extension installs are supported over the daemon endpoint.', - }); - return; - } + assertDaemonExtensionInstallSource( + metadata, + sourceValue, + refValue, + autoUpdateValue, + ); } catch (error) { const message = error instanceof Error ? error.message : 'Invalid install source'; @@ -1144,15 +1168,12 @@ export function registerWorkspaceExtensionRoutes( networkPolicy: 'public', }); - 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.', - ); - } + assertDaemonExtensionInstallSource( + installMetadata, + sourceValue, + refValue, + autoUpdateValue, + ); if ( gitCredential && installMetadata.type !== 'git' && @@ -1951,15 +1972,12 @@ export function registerWorkspaceExtensionRoutes( const metadata = await parseInstallSource(sourceValue, { networkPolicy: 'public', }); - if ( - metadata.type !== 'git' && - metadata.type !== 'github-release' && - metadata.type !== 'npm' - ) { - throw new Error( - 'Only GitHub, Git, and npm extension installs are supported over the daemon endpoint.', - ); - } + assertDaemonExtensionInstallSource( + metadata, + sourceValue, + typeof ref === 'string' ? ref : undefined, + typeof autoUpdate === 'boolean' ? autoUpdate : undefined, + ); if ( gitCredential && metadata.type !== 'git' && diff --git a/packages/cli/src/serve/routes/workspace-qualified-extensions.test.ts b/packages/cli/src/serve/routes/workspace-qualified-extensions.test.ts index 88ecd3e6f37..62089b80f0c 100644 --- a/packages/cli/src/serve/routes/workspace-qualified-extensions.test.ts +++ b/packages/cli/src/serve/routes/workspace-qualified-extensions.test.ts @@ -342,6 +342,7 @@ describe('extension management v2 REST', () => { expect(response.status).toBe(200); expect(response.body.features).toContain('extension_management_v2'); expect(response.body.features).toContain('extension_git_credentials'); + expect(response.body.features).toContain('extension_local_path_install'); expect(response.body.features).toContain('extension_batch_activation_v2'); expect(response.body.features).not.toContain( 'workspace_qualified_extensions', @@ -1684,6 +1685,96 @@ describe('extension management v2 REST', () => { } }); + it('installs a daemon-local path through the global V2 route', async () => { + const h = await makeHarness(); + mockExtensionManager(); + const source = path.join(h.scratch, 'local-extension'); + await fsp.mkdir(source); + const prepareInstall = vi + .spyOn(ExtensionManager.prototype, 'prepareExtensionInstall') + .mockResolvedValue({} as never); + vi.spyOn( + ExtensionManager.prototype, + 'commitPreparedExtension', + ).mockResolvedValue({ + identity: { id: extensionId, name: 'local-extension' }, + version: '1.0.0', + generation: 7, + } as never); + vi.spyOn( + ExtensionManager.prototype, + 'disposePreparedExtension', + ).mockResolvedValue(); + try { + const started = await auth( + request(h.app) + .post('/extensions/install') + .send({ + source, + consent: true, + activation: { + scope: 'workspace', + workspaceId: h.secondary.workspaceId, + }, + }), + ); + + expect(started.status).toBe(202); + await expect( + pollOperation(h.app, started.body.operationId), + ).resolves.toMatchObject({ + status: 'succeeded', + result: { + status: 'installed', + source, + name: 'local-extension', + }, + }); + expect(prepareInstall).toHaveBeenCalledWith( + expect.objectContaining({ + installMetadata: expect.objectContaining({ source, type: 'local' }), + initialActivation: { + scope: 'workspace', + workspacePath: h.secondary.workspaceCwd, + }, + }), + ); + } finally { + await fsp.rm(h.scratch, { recursive: true, force: true }); + } + }); + + it('does not install a relative local path through the global V2 route', async () => { + const h = await makeHarness(); + mockExtensionManager(); + const prepareInstall = vi.spyOn( + ExtensionManager.prototype, + 'prepareExtensionInstall', + ); + try { + const started = await auth( + request(h.app) + .post('/extensions/install') + .send({ + source: '.', + consent: true, + activation: { scope: 'user' }, + }), + ); + + expect(started.status).toBe(202); + await expect( + pollOperation(h.app, started.body.operationId), + ).resolves.toMatchObject({ + status: 'failed', + error: 'Local extension install paths must be absolute.', + }); + expect(prepareInstall).not.toHaveBeenCalled(); + } finally { + await fsp.rm(h.scratch, { recursive: true, force: true }); + } + }); + it.each([ { persistence: undefined, expected: 'one_time' as const }, { persistence: 'one_time' as const, expected: 'one_time' as const }, diff --git a/packages/cli/src/serve/server.test.ts b/packages/cli/src/serve/server.test.ts index 8dd8da14103..8ccee241dba 100644 --- a/packages/cli/src/serve/server.test.ts +++ b/packages/cli/src/serve/server.test.ts @@ -87,6 +87,7 @@ import { readSessionPrs, upsertSessionPr, type Extension, + type ExtensionInstallMetadata, type CommittedExtensionMutation, type PrepareExtensionInstallOptions, type PreparedExtensionMutation, @@ -652,6 +653,7 @@ const EXPECTED_STAGE1_FEATURES = [ 'workspace_qualified_rest_core', 'extension_management_v2', 'extension_git_credentials', + 'extension_local_path_install', 'workspace_persisted_transcript', 'workspace_session_export', 'workspace_archived_session_export', @@ -717,6 +719,7 @@ const EXPECTED_REGISTERED_FEATURES = [ f !== 'workspace_qualified_rest_core' && f !== 'extension_management_v2' && f !== 'extension_git_credentials' && + f !== 'extension_local_path_install' && f !== 'workspace_persisted_transcript' && f !== 'workspace_session_export' && f !== 'workspace_archived_session_export' && @@ -775,6 +778,7 @@ const EXPECTED_REGISTERED_FEATURES = [ 'workspace_qualified_memory', 'extension_management_v2', 'extension_git_credentials', + 'extension_local_path_install', 'workspace_persisted_transcript', 'workspace_session_export', 'workspace_archived_session_export', @@ -7598,30 +7602,157 @@ describe('createServeApp', () => { expect(res.body.error).toBe('`ref` must be a string'); }); - it('rejects unsupported local extension installs before queuing', async () => { + it('queues extension installs from daemon-local paths', 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 }, - ); + let installMetadata: ExtensionInstallMetadata | undefined; + const restore = mockExtensionManagerMethods({ + async prepareExtensionInstall(options) { + installMetadata = options.installMetadata; + return testExtension('local-extension'); + }, + }); + 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: localExtensionDir, consent: true }); + 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(400); - expect(res.body.error).toBe( - 'Only GitHub, Git, and npm extension installs are supported over the daemon endpoint.', + expect(res.status).toBe(202); + await vi.waitFor(() => { + expect(bridge.extensionEvents.at(-1)).toMatchObject({ + status: 'installed', + source: localExtensionDir, + name: 'local-extension', + }); + }); + expect(installMetadata).toMatchObject({ + source: localExtensionDir, + type: 'local', + }); + } finally { + restore(); + await fsp.rm(localExtensionDir, { recursive: true, force: true }); + } + }); + + it('rejects relative local extension paths before queuing', 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) + .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( + 'Local extension install paths must be absolute.', + ); + expect(bridge.extensionEvents).toEqual([]); + } finally { + restore(); + } + }); + + it('rejects update options for daemon-local extension paths', async () => { + const localExtensionDir = await fsp.mkdtemp( + path.join(os.tmpdir(), 'qwen-local-extension-options-'), ); - expect(bridge.extensionEvents).toEqual([]); + 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) + .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, + autoUpdate: true, + }); + + expect(res.status).toBe(400); + expect(res.body.error).toBe( + '`ref` and `autoUpdate` are not applicable for local extensions.', + ); + expect(bridge.extensionEvents).toEqual([]); + } finally { + restore(); + await fsp.rm(localExtensionDir, { recursive: true, force: true }); + } + }); + + it('does not let an existing local path shadow owner/repo sources', async () => { + const relativeSource = 'qwen-local-source-shadow/extension'; + const localSource = path.resolve(relativeSource); + await fsp.mkdir(localSource, { recursive: true }); + let prepareCalled = false; + const restore = mockExtensionManagerMethods({ + async prepareExtensionInstall() { + prepareCalled = true; + return testExtension('shadowed-extension'); + }, + }); + 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: relativeSource, consent: true }); + + expect(res.status).toBe(202); + await vi.waitFor(() => { + expect(bridge.extensionEvents.at(-1)).toMatchObject({ + status: 'failed', + error: 'Local extension install paths must be absolute.', + }); + }); + expect(prepareCalled).toBe(false); + } finally { + restore(); + await fsp.rm(path.dirname(localSource), { + recursive: true, + force: true, + }); + } }); it('rejects missing Windows local extension sources before queuing', async () => { diff --git a/packages/sdk-typescript/src/daemon/types.ts b/packages/sdk-typescript/src/daemon/types.ts index 22b02452f64..9b0283f7b27 100644 --- a/packages/sdk-typescript/src/daemon/types.ts +++ b/packages/sdk-typescript/src/daemon/types.ts @@ -4303,6 +4303,7 @@ export interface DaemonWorkspaceExtensionsStatus { } export interface ExtensionInstallRequest { + /** Git, GitHub, npm, or an absolute path on the daemon host. */ source: string; credentialPersistence?: 'stored' | 'one_time'; ref?: string; diff --git a/packages/sdk-typescript/test/unit/DaemonClient.test.ts b/packages/sdk-typescript/test/unit/DaemonClient.test.ts index 6fdadbf6839..3373518feda 100644 --- a/packages/sdk-typescript/test/unit/DaemonClient.test.ts +++ b/packages/sdk-typescript/test/unit/DaemonClient.test.ts @@ -5898,6 +5898,33 @@ describe('DaemonClient', () => { }); describe('extension operations', () => { + it.each(['/tmp/demo-extension', 'C:\\demo-extension'])( + 'sends daemon-local extension path %s unchanged', + async (source) => { + const { fetch, calls } = recordingFetch(() => + jsonResponse(202, { + accepted: true, + operationId: 'op-local', + }), + ); + const client = new DaemonClient({ baseUrl: 'http://daemon', fetch }); + + await expect( + client.installExtension({ source, consent: true }, 'client-1'), + ).resolves.toEqual({ accepted: true, operationId: 'op-local' }); + + expect(calls[0]?.url).toBe( + 'http://daemon/workspace/extensions/install', + ); + expect(calls[0]?.method).toBe('POST'); + expect(calls[0]?.headers['x-qwen-client-id']).toBe('client-1'); + expect(JSON.parse(calls[0]!.body!)).toEqual({ + source, + consent: true, + }); + }, + ); + it('POSTs an extension archive as a binary body', async () => { let capturedUrl = ''; let capturedInit: RequestInit | undefined; From b0d1ace46ca713967f5e66601a066f0432cd3c29 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E5=8F=B6=E5=85=AC?= Date: Wed, 26 Aug 2026 18:33:24 +0800 Subject: [PATCH 2/4] test(cli): include local path install capability --- integration-tests/cli/qwen-serve-routes.test.ts | 1 + 1 file changed, 1 insertion(+) diff --git a/integration-tests/cli/qwen-serve-routes.test.ts b/integration-tests/cli/qwen-serve-routes.test.ts index 0121e8e1810..7d5b1586dd6 100644 --- a/integration-tests/cli/qwen-serve-routes.test.ts +++ b/integration-tests/cli/qwen-serve-routes.test.ts @@ -413,6 +413,7 @@ describe('qwen serve — capabilities envelope', () => { 'workspace_qualified_rest_core', 'extension_management_v2', 'extension_git_credentials', + 'extension_local_path_install', 'workspace_persisted_transcript', 'workspace_session_export', 'workspace_archived_session_export', From 61a1f256d7d34af31f0e15b59869e527a7f04316 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E5=8F=B6=E5=85=AC?= Date: Wed, 26 Aug 2026 20:17:58 +0800 Subject: [PATCH 3/4] test(daemon): strengthen local path install coverage --- .../cli/qwen-serve-routes.test.ts | 47 ++++++++++++ .../src/serve/routes/workspace-extensions.ts | 5 +- .../workspace-qualified-extensions.test.ts | 35 +++++++++ packages/cli/src/serve/server.test.ts | 73 +++++++++++++++++++ 4 files changed, 157 insertions(+), 3 deletions(-) diff --git a/integration-tests/cli/qwen-serve-routes.test.ts b/integration-tests/cli/qwen-serve-routes.test.ts index 7d5b1586dd6..bd99a960160 100644 --- a/integration-tests/cli/qwen-serve-routes.test.ts +++ b/integration-tests/cli/qwen-serve-routes.test.ts @@ -424,6 +424,53 @@ describe('qwen serve — capabilities envelope', () => { }); }); +describe('qwen serve — local Extension install', () => { + it('installs an absolute daemon-local directory into managed storage', async () => { + const source = path.join(homeDir, 'local-extension-source'); + mkdirSync(source, { recursive: true }); + writeFileSync( + path.join(source, 'qwen-extension.json'), + JSON.stringify({ + name: 'daemon-local-path-e2e', + version: '1.0.0', + }), + 'utf8', + ); + + const accepted = await client.installExtension({ source, consent: true }); + await expect + .poll( + async () => + (await client.extensionOperationStatus(accepted.operationId)).status, + { timeout: 10_000 }, + ) + .toBe('succeeded'); + + const operation = await client.extensionOperationStatus( + accepted.operationId, + ); + expect(operation.result).toMatchObject({ + status: 'installed', + source, + name: 'daemon-local-path-e2e', + }); + const status = await client.workspaceExtensions(); + const installed = status.extensions.find( + (extension) => extension.name === 'daemon-local-path-e2e', + ); + expect(installed).toMatchObject({ + source, + installType: 'local', + }); + expect(installed?.path).not.toBe(source); + expect( + installed?.path.startsWith( + `${path.join(homeDir, '.qwen', 'extensions')}${path.sep}`, + ), + ).toBe(true); + }); +}); + describe('qwen serve — transcript paging route', () => { const getTranscript = (sessionId: string, query = '') => fetch(`${base}/session/${sessionId}/transcript${query}`, { diff --git a/packages/cli/src/serve/routes/workspace-extensions.ts b/packages/cli/src/serve/routes/workspace-extensions.ts index a8c57ad2cec..fde57a5e317 100644 --- a/packages/cli/src/serve/routes/workspace-extensions.ts +++ b/packages/cli/src/serve/routes/workspace-extensions.ts @@ -36,6 +36,7 @@ import { isBlockedAuthProviderHost } from '../server/auth-provider-helpers.js'; import type { SendBridgeError } from '../server/error-response.js'; import type { safeBody as safeBodyType } from '../server/request-helpers.js'; import { + isPortableAbsolutePath, requireTrustedWorkspaceRuntime, resolveWorkspaceRuntimeFromParam, sendGenerationClosedError, @@ -1102,9 +1103,7 @@ export function registerWorkspaceExtensionRoutes( return; } const localSource = - path.isAbsolute(sourceValue) || - /^[A-Za-z]:[\\/]/.test(sourceValue) || - sourceValue.startsWith('.'); + isPortableAbsolutePath(sourceValue) || sourceValue.startsWith('.'); if (localSource) { try { const metadata = await parseInstallSource(sourceValue, { diff --git a/packages/cli/src/serve/routes/workspace-qualified-extensions.test.ts b/packages/cli/src/serve/routes/workspace-qualified-extensions.test.ts index 62089b80f0c..a66ec90e6c1 100644 --- a/packages/cli/src/serve/routes/workspace-qualified-extensions.test.ts +++ b/packages/cli/src/serve/routes/workspace-qualified-extensions.test.ts @@ -1775,6 +1775,41 @@ describe('extension management v2 REST', () => { } }); + it('does not install a daemon-local path with a ref through the global V2 route', async () => { + const h = await makeHarness(); + mockExtensionManager(); + const source = path.join(h.scratch, 'local-extension'); + await fsp.mkdir(source); + const prepareInstall = vi.spyOn( + ExtensionManager.prototype, + 'prepareExtensionInstall', + ); + try { + const started = await auth( + request(h.app) + .post('/extensions/install') + .send({ + source, + ref: 'v1', + consent: true, + activation: { scope: 'user' }, + }), + ); + + expect(started.status).toBe(202); + await expect( + pollOperation(h.app, started.body.operationId), + ).resolves.toMatchObject({ + status: 'failed', + error: + '`ref` and `autoUpdate` are not applicable for local extensions.', + }); + expect(prepareInstall).not.toHaveBeenCalled(); + } finally { + await fsp.rm(h.scratch, { recursive: true, force: true }); + } + }); + it.each([ { persistence: undefined, expected: 'one_time' as const }, { persistence: 'one_time' as const, expected: 'one_time' as const }, diff --git a/packages/cli/src/serve/server.test.ts b/packages/cli/src/serve/server.test.ts index 8ccee241dba..870422a1c62 100644 --- a/packages/cli/src/serve/server.test.ts +++ b/packages/cli/src/serve/server.test.ts @@ -7711,6 +7711,79 @@ describe('createServeApp', () => { } }); + it('rejects refs for daemon-local extension paths', async () => { + const localExtensionDir = await fsp.mkdtemp( + path.join(os.tmpdir(), 'qwen-local-extension-ref-'), + ); + 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) + .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, ref: 'v1' }); + + expect(res.status).toBe(400); + expect(res.body.error).toBe( + '`ref` and `autoUpdate` are not applicable for local extensions.', + ); + expect(bridge.extensionEvents).toEqual([]); + } finally { + restore(); + await fsp.rm(localExtensionDir, { recursive: true, force: true }); + } + }); + + it('rejects unsupported archive URL extension installs', async () => { + let prepareCalled = false; + const restore = mockExtensionManagerMethods({ + async prepareExtensionInstall() { + prepareCalled = true; + return testExtension('archive-extension'); + }, + }); + 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/extension.zip', + consent: true, + }); + + expect(res.status).toBe(202); + await vi.waitFor(() => { + expect(bridge.extensionEvents.at(-1)).toMatchObject({ + status: 'failed', + error: + 'Only GitHub, Git, npm, and absolute local path extension installs are supported over the daemon endpoint.', + }); + }); + expect(prepareCalled).toBe(false); + } finally { + restore(); + } + }); + it('does not let an existing local path shadow owner/repo sources', async () => { const relativeSource = 'qwen-local-source-shadow/extension'; const localSource = path.resolve(relativeSource); From 9ac5cab2e52dff5daed5ae3b8a233fb67bfaadba Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E5=8F=B6=E5=85=AC?= Date: Wed, 26 Aug 2026 21:58:31 +0800 Subject: [PATCH 4/4] fix(daemon): clarify local path rejection --- .../src/serve/routes/workspace-extensions.ts | 4 +- .../workspace-qualified-extensions.test.ts | 73 ++++++++++--------- packages/cli/src/serve/server.test.ts | 5 +- 3 files changed, 46 insertions(+), 36 deletions(-) diff --git a/packages/cli/src/serve/routes/workspace-extensions.ts b/packages/cli/src/serve/routes/workspace-extensions.ts index fde57a5e317..5854f37016c 100644 --- a/packages/cli/src/serve/routes/workspace-extensions.ts +++ b/packages/cli/src/serve/routes/workspace-extensions.ts @@ -81,7 +81,9 @@ const assertDaemonExtensionInstallSource = ( ): void => { if (installMetadata.type === 'local') { if (!path.isAbsolute(source)) { - throw new Error('Local extension install paths must be absolute.'); + throw new Error( + 'Local extension sources must be absolute daemon-host paths; relative paths are not supported over the daemon endpoint.', + ); } if (ref || autoUpdate) { throw new Error( diff --git a/packages/cli/src/serve/routes/workspace-qualified-extensions.test.ts b/packages/cli/src/serve/routes/workspace-qualified-extensions.test.ts index a66ec90e6c1..1021a9a164c 100644 --- a/packages/cli/src/serve/routes/workspace-qualified-extensions.test.ts +++ b/packages/cli/src/serve/routes/workspace-qualified-extensions.test.ts @@ -1767,7 +1767,8 @@ describe('extension management v2 REST', () => { pollOperation(h.app, started.body.operationId), ).resolves.toMatchObject({ status: 'failed', - error: 'Local extension install paths must be absolute.', + error: + 'Local extension sources must be absolute daemon-host paths; relative paths are not supported over the daemon endpoint.', }); expect(prepareInstall).not.toHaveBeenCalled(); } finally { @@ -1775,40 +1776,46 @@ describe('extension management v2 REST', () => { } }); - it('does not install a daemon-local path with a ref through the global V2 route', async () => { - const h = await makeHarness(); - mockExtensionManager(); - const source = path.join(h.scratch, 'local-extension'); - await fsp.mkdir(source); - const prepareInstall = vi.spyOn( - ExtensionManager.prototype, - 'prepareExtensionInstall', - ); - try { - const started = await auth( - request(h.app) - .post('/extensions/install') - .send({ - source, - ref: 'v1', - consent: true, - activation: { scope: 'user' }, - }), + it.each([ + { option: 'ref', installOptions: { ref: 'v1' } }, + { option: 'autoUpdate', installOptions: { autoUpdate: true } }, + ])( + 'does not install a daemon-local path with $option through the global V2 route', + async ({ installOptions }) => { + const h = await makeHarness(); + mockExtensionManager(); + const source = path.join(h.scratch, 'local-extension'); + await fsp.mkdir(source); + const prepareInstall = vi.spyOn( + ExtensionManager.prototype, + 'prepareExtensionInstall', ); + try { + const started = await auth( + request(h.app) + .post('/extensions/install') + .send({ + source, + ...installOptions, + consent: true, + activation: { scope: 'user' }, + }), + ); - expect(started.status).toBe(202); - await expect( - pollOperation(h.app, started.body.operationId), - ).resolves.toMatchObject({ - status: 'failed', - error: - '`ref` and `autoUpdate` are not applicable for local extensions.', - }); - expect(prepareInstall).not.toHaveBeenCalled(); - } finally { - await fsp.rm(h.scratch, { recursive: true, force: true }); - } - }); + expect(started.status).toBe(202); + await expect( + pollOperation(h.app, started.body.operationId), + ).resolves.toMatchObject({ + status: 'failed', + error: + '`ref` and `autoUpdate` are not applicable for local extensions.', + }); + expect(prepareInstall).not.toHaveBeenCalled(); + } finally { + await fsp.rm(h.scratch, { recursive: true, force: true }); + } + }, + ); it.each([ { persistence: undefined, expected: 'one_time' as const }, diff --git a/packages/cli/src/serve/server.test.ts b/packages/cli/src/serve/server.test.ts index 870422a1c62..1b92770cb09 100644 --- a/packages/cli/src/serve/server.test.ts +++ b/packages/cli/src/serve/server.test.ts @@ -7667,7 +7667,7 @@ describe('createServeApp', () => { expect(res.status).toBe(400); expect(res.body.error).toBe( - 'Local extension install paths must be absolute.', + 'Local extension sources must be absolute daemon-host paths; relative paths are not supported over the daemon endpoint.', ); expect(bridge.extensionEvents).toEqual([]); } finally { @@ -7815,7 +7815,8 @@ describe('createServeApp', () => { await vi.waitFor(() => { expect(bridge.extensionEvents.at(-1)).toMatchObject({ status: 'failed', - error: 'Local extension install paths must be absolute.', + error: + 'Local extension sources must be absolute daemon-host paths; relative paths are not supported over the daemon endpoint.', }); }); expect(prepareCalled).toBe(false);