diff --git a/docs/design/extension-git-credentials.md b/docs/design/extension-git-credentials.md new file mode 100644 index 00000000000..5705ccfaad9 --- /dev/null +++ b/docs/design/extension-git-credentials.md @@ -0,0 +1,134 @@ +# Authenticated HTTPS Git extension installs + +## Status + +Implemented for the daemon, Core extension manager, and TypeScript SDK. WebShell +selection UI is intentionally deferred. + +## Problem + +The daemon rejects every extension source URL that contains HTTPS userinfo. +That prevents users from installing a private repository with a narrowly scoped +personal access token, even when the token is limited to one repository. Passing +the credential through the source URL without additional handling would be +unsafe: Git can persist the URL in `.git/config`, process arguments can expose +it, and extension metadata, operation history, logs, or telemetry can retain it. + +## Goals + +- Accept generic HTTPS Git sources whose userinfo contains a username and/or + token. +- Default old clients to a safe one-time install when they omit a persistence + choice. +- Offer an explicit stored mode that remains updatable across daemon restarts. +- Keep credentials out of URLs after request validation and out of Git argv, + remote configuration, artifacts, metadata, logs, operation history, and + telemetry. +- Preserve identity and update behavior for every existing installation and + every new installation without URL credentials. + +## Non-goals + +- Add the WebShell confirmation UI. A follow-up can use the + `extension_git_credentials` capability to offer stored, one-time, or cancel. +- Accept credentials for npm, archives, SSH Git, or local sources. +- Migrate existing extension artifacts or Agent Plugin data directories. +- Revoke, rotate, or validate the repository scope of a user-provided token. + +## Protocol + +Both daemon install endpoints accept: + +```ts +credentialPersistence?: 'stored' | 'one_time'; +``` + +The field is valid only when `source` is an HTTPS URL with userinfo. Omission in +that case means `one_time`; supplying the field without userinfo is a `400`. +Credentialed sources must parse as Git after the existing public-network source +policy is applied. GitHub credentialed URLs bypass release downloads and use +Git clone directly. + +The route decodes and validates userinfo before the operation is queued. Empty +userinfo, malformed encoding, control characters, NUL, CR/LF, usernames over +256 UTF-8 bytes, and passwords over 4096 UTF-8 bytes are rejected. The route +then removes userinfo. Only the clean URL and an in-memory credential object can +cross into Core. + +One-time operation history does not include the source. Successful results +expose only `credentialPersistence`; stored results may additionally expose the +clean source and `credentialStorage` (`keychain` or `encrypted_file`). No +response contains a credential or authorization header. + +## Git authentication + +Clone, fetch, and remote listing always receive the clean repository URL. The +credential is supplied only in the Git child environment with Git's counted +configuration variables: + +```text +GIT_CONFIG_KEY_0=http..extraHeader +GIT_CONFIG_VALUE_0=Authorization: Basic +``` + +The key is scoped to the exact clean repository URL. Public Git operations keep +the existing system/global Git configuration isolation, redirect and proxy +disablement, and DNS/IP pinning. `GITHUB_TOKEN` uses the same header mechanism +instead of being inserted into a clone URL. Newly cloned remote extensions do +not copy the root `.git` directory into the installed artifact. + +The child environment necessarily contains the short-lived header while Git is +running. The design protects durable product state and process arguments; it +does not claim to protect against an already-compromised same-user process that +can inspect another process's environment or system keychain. + +## Stored credential lifecycle + +Stored mode uses the existing hybrid secret storage. The system keychain is +preferred; when unavailable, the existing host/user-bound encrypted file is +used. The staged extension contains a mode-`0600` selector with only a version, +backend, and random secret key. The secret value is a JSON object containing +the username and password and never enters the artifact. + +Preparation writes the secret and selector. An artifact commit activates the +selector; failed preparation and disposal delete an unselected secret. Update +resolves the selector before any network access and copies a newly controlled +selector into the replacement artifact. Missing, malformed, forged, or +unreadable managed selectors fail with `extension_credential_unavailable` +without modifying the installed artifact. A repository-provided selector is +always removed before the managed selector is written. + +Uninstall commits artifact removal first and then best-effort deletes the +secret. Cleanup failure does not restore the artifact; it returns an +`extension_credential_cleanup_failed` warning so an operator can remove the +orphaned secret. + +## One-time snapshots + +After a one-time clone succeeds, durable install metadata is converted to the +new `snapshot` type. Snapshot metadata contains no repository source, ref, +commit, update flag, or credential. Catalog and status projections omit source, +report `credentialPersistence: one_time`, and report `not updatable`. An update +request fails with `extension_not_updatable`. + +Telemetry uses the generic snapshot category rather than the repository URL. +This deliberately trades updateability for the absence of a durable repository +locator and credential. + +## Identity compatibility + +Each credentialed install generates a random 64-character lowercase hexadecimal +`installId`. Stored updates retain it; one-time snapshots reload it from install +metadata, so restart does not change activation or Agent Plugin data identity. +Uninstall followed by reinstall creates a new id. + +Existing metadata without `installId` continues to use the current source/name +formula. Non-credentialed installs also keep that formula. No migration or data +directory movement is performed. + +## Rollout + +The daemon advertises `extension_git_credentials`. A later WebShell change can +gate its three-way confirmation on that capability: store and update, install +once without updates, or cancel before sending a request. Older daemons remain +detectable because they lack the capability and continue rejecting userinfo. diff --git a/docs/developers/qwen-serve-protocol.md b/docs/developers/qwen-serve-protocol.md index 6d58afc2c2a..667b852cc7b 100644 --- a/docs/developers/qwen-serve-protocol.md +++ b/docs/developers/qwen-serve-protocol.md @@ -211,7 +211,7 @@ registry. Clients **must** gate UI off `features`, not off `mode` (per design 'multi_workspace_session_shell', 'persistent_workspace_registration', 'workspace_display_name', 'workspace_qualified_rest_core', 'workspace_qualified_voice', - 'workspace_qualified_memory', 'extension_management_v2', + 'workspace_qualified_memory', 'extension_management_v2', 'extension_git_credentials', 'workspace_persisted_transcript', 'workspace_session_export', 'workspace_archived_session_export', 'workspace_session_live_state', @@ -294,6 +294,8 @@ The same tag also exposes workspace-qualified project-agent CRUD at `/workspaces `extension_management_v2` advertises a user-level extension catalog and mutation surface at `/extensions/*`, plus workspace activation projections at `/workspaces/:workspace/extensions/*`. Artifacts are global; workspace routes expose only projection reads, exact activation overrides, and runtime refresh. Reads may target an untrusted registered workspace, while activation, refresh, and workspace-scoped install require a trusted target. Slow mutations use daemon-local operations at `/extensions/operations/:operationId`; store generation, not operation history, is authoritative across restart and across daemons. The published `workspace_extensions` capability and `/workspace/extensions/*` routes remain a primary-workspace compatibility adapter. Clients must preflight `extension_management_v2` and must not infer it from daemon mode or `workspace_qualified_rest_core`. +`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_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 @@ -379,6 +381,10 @@ 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. +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`. + +Credentialed install responses and operations expose `credentialPersistence` and may expose `credentialStorage` as `keychain` or `encrypted_file`. One-time operations omit `source`; stored operations may return the clean source. Snapshot catalog/status entries omit source, set `credentialPersistence` to `one_time`, and report `not updatable`. Update fails with `extension_not_updatable`; an unavailable stored secret fails before network access with `extension_credential_unavailable`. + Global and workspace activation `PUT` requests use the same body: ```json @@ -427,7 +433,7 @@ An operation snapshot has this shape: } ``` -`status` transitions from `queued` to `running`, then to `succeeded`, `succeeded_with_warnings`, or `failed`. While running, `phase` is `preparing`, `committing`, or `reconciling`. Terminal success may include `result` with `status` equal to `installed`, `enabled`, `disabled`, `updated`, `uninstalled`, `checked`, or `refreshed`; reconciliation results can additionally contain `refreshed`, `failed`, and `error`, while batch activation results contain ordered `results`. Update checks return `result.states`, keyed by extension name, with values such as `checking for updates`, `update available`, `up to date`, `not updatable`, or `error`. +`status` transitions from `queued` to `running`, then to `succeeded`, `succeeded_with_warnings`, or `failed`. While running, `phase` is `preparing`, `committing`, or `reconciling`. Terminal success may include `result` with `status` equal to `installed`, `enabled`, `disabled`, `updated`, `uninstalled`, `checked`, or `refreshed`; reconciliation results can additionally contain `refreshed`, `failed`, and `error`, while batch activation results contain ordered `results`. Update checks return `result.states`, keyed by extension name, with values such as `checking for updates`, `update available`, `up to date`, `not updatable`, or `error`. Credentials and authorization headers are never operation fields. A durable commit followed by incomplete cleanup or runtime reconciliation is not reported as a failed mutation. It returns `succeeded_with_warnings` and preserves the committed result: diff --git a/integration-tests/cli/qwen-serve-routes.test.ts b/integration-tests/cli/qwen-serve-routes.test.ts index cda5f57b964..1718224378e 100644 --- a/integration-tests/cli/qwen-serve-routes.test.ts +++ b/integration-tests/cli/qwen-serve-routes.test.ts @@ -406,6 +406,7 @@ describe('qwen serve — capabilities envelope', () => { 'workspace_runtime_removal', 'workspace_qualified_rest_core', 'extension_management_v2', + 'extension_git_credentials', 'workspace_persisted_transcript', 'workspace_session_export', 'workspace_archived_session_export', diff --git a/packages/acp-bridge/src/status.ts b/packages/acp-bridge/src/status.ts index 1faf337d7cc..51cb8ac6035 100644 --- a/packages/acp-bridge/src/status.ts +++ b/packages/acp-bridge/src/status.ts @@ -1078,7 +1078,8 @@ export type ServeExtensionInstallType = | 'link' | 'github-release' | 'npm' - | 'archive-url'; + | 'archive-url' + | 'snapshot'; export type ServeExtensionOriginSource = | 'QwenCode' @@ -1133,6 +1134,7 @@ export interface ServeExtensionEntry { originSource?: ServeExtensionOriginSource; ref?: string; autoUpdate?: boolean; + credentialPersistence?: 'stored' | 'one_time'; updateState?: ServeExtensionUpdateState; capabilities: ServeExtensionCapabilities; details?: ServeExtensionDetails; diff --git a/packages/cli/src/serve/capabilities.ts b/packages/cli/src/serve/capabilities.ts index 199c87d6ef7..7ff94d2284c 100644 --- a/packages/cli/src/serve/capabilities.ts +++ b/packages/cli/src/serve/capabilities.ts @@ -379,6 +379,7 @@ export const SERVE_CAPABILITY_REGISTRY = { // projections. This is additive to the legacy primary-workspace // `workspace_extensions` contract. extension_management_v2: { since: 'v1' }, + extension_git_credentials: { 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-controller.ts b/packages/cli/src/serve/routes/workspace-extensions-controller.ts index 1c44454edf4..6fd48a145c2 100644 --- a/packages/cli/src/serve/routes/workspace-extensions-controller.ts +++ b/packages/cli/src/serve/routes/workspace-extensions-controller.ts @@ -99,6 +99,8 @@ export type ExtensionMutationEvent = { source?: string; name?: string; version?: string; + credentialPersistence?: 'stored' | 'one_time'; + credentialStorage?: 'keychain' | 'encrypted_file'; updated?: boolean; reason?: string; states?: Record; @@ -1033,7 +1035,8 @@ export function createExtensionsController( version: ext.version, isActive: ext.isActive, path: ext.path, - ...(ext.installMetadata?.source + ...(ext.installMetadata?.source && + ext.installMetadata.type !== 'snapshot' ? { source: redactExtensionDisplaySource( ext.installMetadata.source, @@ -1052,7 +1055,17 @@ export function createExtensionsController( ...(ext.installMetadata?.autoUpdate !== undefined ? { autoUpdate: ext.installMetadata.autoUpdate } : {}), - updateState: ext.installMetadata ? 'unknown' : 'not updatable', + ...(ext.installMetadata?.type === 'snapshot' + ? { credentialPersistence: 'one_time' as const } + : ext.installMetadata?.credentialPersistence === 'stored' + ? { credentialPersistence: 'stored' as const } + : {}), + updateState: + ext.installMetadata?.type === 'snapshot' + ? 'not updatable' + : ext.installMetadata + ? 'unknown' + : 'not updatable', capabilities, details: { mcpServers: ext.mcpServers ? Object.keys(ext.mcpServers) : [], diff --git a/packages/cli/src/serve/routes/workspace-extensions.ts b/packages/cli/src/serve/routes/workspace-extensions.ts index c919639aab3..241f9500971 100644 --- a/packages/cli/src/serve/routes/workspace-extensions.ts +++ b/packages/cli/src/serve/routes/workspace-extensions.ts @@ -18,6 +18,10 @@ import { type ExtensionManager, type ClaudeMarketplaceConfig, type ExtensionSetting, + type ExtensionCredentialPersistence, + type ExtensionGitCredential, + ExtensionNotUpdatableError, + isSupportedArchiveUrl, } from '@qwen-code/qwen-code-core'; import express, { type Application, @@ -206,25 +210,96 @@ const parsePotentialSourceUrl = (source: string): URL | null => { } }; -const validateExtensionSourceHost = ( +interface ParsedExtensionInstallSource { + source: string; + gitCredential?: ExtensionGitCredential; +} + +const parseExtensionInstallSource = ( source: string, + persistence: unknown, res: Response, -): boolean => { +): ParsedExtensionInstallSource | null => { + if ( + persistence !== undefined && + persistence !== 'stored' && + persistence !== 'one_time' + ) { + res.status(400).json({ + error: '`credentialPersistence` must be "stored" or "one_time"', + }); + return null; + } 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 (!parsed) { + if (persistence !== undefined) { + res.status(400).json({ + error: '`credentialPersistence` requires source URL credentials', + }); + return null; + } + return { source }; } if (isBlockedAuthProviderHost(parsed.hostname)) { res.status(400).json({ error: '`source` host is not allowed' }); - return false; + return null; } if (parsed.protocol !== 'https:') { res.status(400).json({ error: '`source` must use https' }); - return false; + return null; + } + const authority = /^[a-z][a-z\d+.-]*:\/\/([^/?#]*)/i.exec(source)?.[1]; + const hasUserInfo = + !!parsed.username || !!parsed.password || authority?.includes('@') === true; + if (!hasUserInfo) { + if (persistence !== undefined) { + res.status(400).json({ + error: '`credentialPersistence` requires source URL credentials', + }); + return null; + } + return { source }; + } + const hasControlCharacter = (value: string): boolean => + Array.from(value).some((character) => { + const code = character.charCodeAt(0); + return code <= 0x1f || (code >= 0x7f && code <= 0x9f); + }); + if (hasControlCharacter(source)) { + res.status(400).json({ error: '`source` credentials are invalid' }); + return null; + } + let username: string; + let password: string; + try { + username = decodeURIComponent(parsed.username); + password = decodeURIComponent(parsed.password); + } catch { + res.status(400).json({ error: '`source` credentials are invalid' }); + return null; } - return true; + const invalidText = (value: string, maxBytes: number): boolean => + Buffer.byteLength(value, 'utf8') > maxBytes || hasControlCharacter(value); + if ( + (!username && !password) || + invalidText(username, 256) || + invalidText(password, 4096) + ) { + res.status(400).json({ error: '`source` credentials are invalid' }); + return null; + } + parsed.username = ''; + parsed.password = ''; + const credentialPersistence = (persistence ?? + 'one_time') as ExtensionCredentialPersistence; + return { + source: parsed.toString(), + gitCredential: { + username, + password, + persistence: credentialPersistence, + }, + }; }; const validateExtensionSourceMetadata = ( @@ -927,6 +1002,7 @@ export function registerWorkspaceExtensionRoutes( const allowPreRelease = body['allowPreRelease']; const registry = body['registry']; const consent = body['consent']; + const credentialPersistence = body['credentialPersistence']; if (!source || typeof source !== 'string') { res.status(400).json({ error: 'Missing or invalid source' }); @@ -957,7 +1033,6 @@ export function registerWorkspaceExtensionRoutes( res.status(400).json({ error: '`registry` must be a string' }); return; } - const sourceValue = source; const refValue = typeof ref === 'string' ? ref : undefined; const autoUpdateValue = typeof autoUpdate === 'boolean' ? autoUpdate : undefined; @@ -976,7 +1051,25 @@ export function registerWorkspaceExtensionRoutes( }); return; } - if (!validateExtensionSourceHost(sourceValue, res)) { + const parsedSource = parseExtensionInstallSource( + source, + credentialPersistence, + res, + ); + if (!parsedSource) return; + const sourceValue = parsedSource.source; + body['source'] = sourceValue; + const gitCredential = parsedSource.gitCredential; + if (gitCredential?.persistence === 'one_time' && autoUpdateValue) { + res.status(400).json({ + error: '`autoUpdate` is not supported with one-time credentials', + }); + return; + } + if (gitCredential && isSupportedArchiveUrl(sourceValue)) { + res.status(400).json({ + error: 'Git credentials require an HTTPS Git install source.', + }); return; } const localSource = @@ -1041,7 +1134,9 @@ export function registerWorkspaceExtensionRoutes( ctrl.runQueuedExtensionMutation( 'install', - { source: sourceValue }, + gitCredential?.persistence === 'one_time' + ? {} + : { source: sourceValue }, res, async (extensionManager, _signal, context, operationId) => { const prepared = await context!.prepare(async (signal) => { @@ -1058,6 +1153,15 @@ export function registerWorkspaceExtensionRoutes( 'Only GitHub, Git, and npm extension installs are supported over the daemon endpoint.', ); } + if ( + gitCredential && + installMetadata.type !== 'git' && + installMetadata.type !== 'github-release' + ) { + throw new Error( + 'Git credentials require an HTTPS Git install source.', + ); + } if (installMetadata.type === 'npm' && refValue) { throw new Error('--ref is not applicable for npm extensions.'); } @@ -1083,6 +1187,7 @@ export function registerWorkspaceExtensionRoutes( initialActivation: { scope: 'user' }, requestConsent: () => Promise.resolve(), signal, + ...(gitCredential ? { gitCredential } : {}), }); }); try { @@ -1095,9 +1200,19 @@ export function registerWorkspaceExtensionRoutes( ); return { status: 'installed', - source: sourceValue, + ...(gitCredential?.persistence === 'one_time' + ? {} + : { source: sourceValue }), name: committed.identity.name, version: committed.version, + ...(gitCredential + ? { + credentialPersistence: gitCredential.persistence, + ...(prepared.credentialStorage + ? { credentialStorage: prepared.credentialStorage } + : {}), + } + : {}), }; } finally { await extensionManager.disposePreparedExtension(prepared); @@ -1601,6 +1716,11 @@ export function registerWorkspaceExtensionRoutes( ...(extension.installMetadata?.type ? { installType: extension.installMetadata.type } : {}), + ...(extension.installMetadata?.type === 'snapshot' + ? { credentialPersistence: 'one_time' as const } + : extension.installMetadata?.credentialPersistence === 'stored' + ? { credentialPersistence: 'stored' as const } + : {}), defaultActivation: policy?.defaultActivation ?? 'enabled', workspaceOverrideCount: Object.values( policy?.workspaceOverrides ?? {}, @@ -1724,6 +1844,7 @@ export function registerWorkspaceExtensionRoutes( const autoUpdate = body['autoUpdate']; const allowPreRelease = body['allowPreRelease']; const registry = body['registry']; + const credentialPersistence = body['credentialPersistence']; if (typeof source !== 'string' || !source) { res.status(400).json({ error: 'Missing or invalid source' }); return; @@ -1759,7 +1880,27 @@ export function registerWorkspaceExtensionRoutes( }); return; } - if (!validateExtensionSourceHost(source, res)) return; + const parsedSource = parseExtensionInstallSource( + source, + credentialPersistence, + res, + ); + if (!parsedSource) return; + const sourceValue = parsedSource.source; + body['source'] = sourceValue; + const gitCredential = parsedSource.gitCredential; + if (gitCredential?.persistence === 'one_time' && autoUpdate === true) { + res.status(400).json({ + error: '`autoUpdate` is not supported with one-time credentials', + }); + return; + } + if (gitCredential && isSupportedArchiveUrl(sourceValue)) { + res.status(400).json({ + error: 'Git credentials require an HTTPS Git install source.', + }); + return; + } if (!activation || typeof activation !== 'object') { res.status(400).json({ error: 'Missing initial activation' }); return; @@ -1804,10 +1945,10 @@ export function registerWorkspaceExtensionRoutes( 'POST /extensions/install', manager, 'install', - { source }, + gitCredential?.persistence === 'one_time' ? {} : { source: sourceValue }, async (extensionManager, _signal, context) => { const prepared = await context!.prepare(async (signal) => { - const metadata = await parseInstallSource(source, { + const metadata = await parseInstallSource(sourceValue, { networkPolicy: 'public', }); if ( @@ -1819,6 +1960,15 @@ export function registerWorkspaceExtensionRoutes( 'Only GitHub, Git, and npm extension installs are supported over the daemon endpoint.', ); } + if ( + gitCredential && + metadata.type !== 'git' && + metadata.type !== 'github-release' + ) { + throw new Error( + 'Git credentials require an HTTPS Git install source.', + ); + } if (!validateExtensionSourceMetadata(metadata)) { throw new Error('`source` host is not allowed'); } @@ -1846,6 +1996,7 @@ export function registerWorkspaceExtensionRoutes( cwd: boundWorkspace, initialActivation, signal, + ...(gitCredential ? { gitCredential } : {}), }); }); try { @@ -1858,9 +2009,19 @@ export function registerWorkspaceExtensionRoutes( ); return { status: 'installed', - source, + ...(gitCredential?.persistence === 'one_time' + ? {} + : { source: sourceValue }), name: committed.identity.name, version: committed.version, + ...(gitCredential + ? { + credentialPersistence: gitCredential.persistence, + ...(prepared.credentialStorage + ? { credentialStorage: prepared.credentialStorage } + : {}), + } + : {}), }; } finally { await extensionManager.disposePreparedExtension(prepared); @@ -1936,9 +2097,7 @@ export function registerWorkspaceExtensionRoutes( extension.installMetadata?.type !== 'github-release' && extension.installMetadata?.type !== 'npm' ) { - throw new Error( - `Extension "${extension.name}" is not remotely updatable.`, - ); + throw new ExtensionNotUpdatableError(extension.name); } const preparedResult = await context!.prepare( async (signal) => 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 3f29ba475e8..88ecd3e6f37 100644 --- a/packages/cli/src/serve/routes/workspace-qualified-extensions.test.ts +++ b/packages/cli/src/serve/routes/workspace-qualified-extensions.test.ts @@ -194,7 +194,7 @@ function auth(pending: request.Test): request.Test { } function mockExtensionManager( - installType: 'archive-url' | 'local' = 'archive-url', + installType: 'archive-url' | 'local' | 'snapshot' = 'archive-url', ): Extension { const extension = { id: extensionId, @@ -208,7 +208,9 @@ function mockExtensionManager( source: installType === 'archive-url' ? 'https://example.com/demo.zip' - : '/extensions/demo.zip', + : installType === 'snapshot' + ? 'snapshot' + : '/extensions/demo.zip', }, contextFiles: [], } as Extension; @@ -339,6 +341,7 @@ describe('extension management v2 REST', () => { const response = await auth(request(h.app).get('/capabilities')); 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_batch_activation_v2'); expect(response.body.features).not.toContain( 'workspace_qualified_extensions', @@ -1681,6 +1684,89 @@ describe('extension management v2 REST', () => { } }); + it.each([ + { persistence: undefined, expected: 'one_time' as const }, + { persistence: 'one_time' as const, expected: 'one_time' as const }, + { persistence: 'stored' as const, expected: 'stored' as const }, + ])( + 'installs a credentialed HTTPS Git source through V2 with $expected persistence', + async ({ persistence, expected }) => { + const h = await makeHarness(); + mockExtensionManager(); + const prepareInstall = vi + .spyOn(ExtensionManager.prototype, 'prepareExtensionInstall') + .mockResolvedValue({ + ...(expected === 'stored' + ? { credentialStorage: 'encrypted_file' } + : {}), + } as never); + vi.spyOn( + ExtensionManager.prototype, + 'commitPreparedExtension', + ).mockResolvedValue({ + identity: { id: extensionId, name: 'demo' }, + version: '1.0.0', + generation: 7, + } as never); + vi.spyOn( + ExtensionManager.prototype, + 'disposePreparedExtension', + ).mockResolvedValue(); + try { + const started = await request(h.app) + .post('/extensions/install') + .set('Host', host()) + .set('Authorization', 'Bearer secret') + .send({ + source: + 'https://user:fine-grained-token@git.example.com/org/repository.git', + consent: true, + activation: { scope: 'user' }, + ...(persistence ? { credentialPersistence: persistence } : {}), + }); + + expect(started.status).toBe(202); + const operation = await pollOperation(h.app, started.body.operationId); + expect(operation).toMatchObject({ + status: 'succeeded', + result: { + status: 'installed', + name: 'demo', + credentialPersistence: expected, + ...(expected === 'stored' + ? { + source: 'https://git.example.com/org/repository.git', + credentialStorage: 'encrypted_file', + } + : {}), + }, + }); + if (expected === 'one_time') { + expect(operation.result).not.toHaveProperty('source'); + } + expect(prepareInstall).toHaveBeenCalledWith( + expect.objectContaining({ + installMetadata: expect.objectContaining({ + source: 'https://git.example.com/org/repository.git', + type: 'git', + }), + gitCredential: { + username: 'user', + password: 'fine-grained-token', + persistence: expected, + }, + }), + ); + expect(JSON.stringify(operation)).not.toContain('fine-grained-token'); + expect(JSON.stringify(h.primary.bridge)).not.toContain( + 'fine-grained-token', + ); + } finally { + await fsp.rm(h.scratch, { recursive: true, force: true }); + } + }, + ); + it('preserves prototype-named extension update states', async () => { const h = await makeHarness(); mockExtensionManager(); @@ -1995,9 +2081,9 @@ describe('extension management v2 REST', () => { } }); - it('still rejects non-updatable extensions through the global V2 route', async () => { + it('returns the stable not-updatable code for snapshot extensions', async () => { const h = await makeHarness(); - mockExtensionManager('local'); + mockExtensionManager('snapshot'); vi.spyOn(process.stderr, 'write').mockReturnValue(true); const prepareUpdate = vi.spyOn( ExtensionManager.prototype, @@ -2013,6 +2099,7 @@ describe('extension management v2 REST', () => { pollOperation(h.app, started.body.operationId), ).resolves.toMatchObject({ status: 'failed', + code: 'extension_not_updatable', error: 'Extension "demo" is not remotely updatable.', }); expect(prepareUpdate).not.toHaveBeenCalled(); diff --git a/packages/cli/src/serve/server.test.ts b/packages/cli/src/serve/server.test.ts index ae118564843..765f44943e6 100644 --- a/packages/cli/src/serve/server.test.ts +++ b/packages/cli/src/serve/server.test.ts @@ -644,6 +644,7 @@ const EXPECTED_STAGE1_FEATURES = [ 'workspace_display_name', 'workspace_qualified_rest_core', 'extension_management_v2', + 'extension_git_credentials', 'workspace_persisted_transcript', 'workspace_session_export', 'workspace_archived_session_export', @@ -705,6 +706,7 @@ const EXPECTED_REGISTERED_FEATURES = [ f !== 'workspace_display_name' && f !== 'workspace_qualified_rest_core' && f !== 'extension_management_v2' && + f !== 'extension_git_credentials' && f !== 'workspace_persisted_transcript' && f !== 'workspace_session_export' && f !== 'workspace_archived_session_export' && @@ -762,6 +764,7 @@ const EXPECTED_REGISTERED_FEATURES = [ 'workspace_qualified_voice', 'workspace_qualified_memory', 'extension_management_v2', + 'extension_git_credentials', 'workspace_persisted_transcript', 'workspace_session_export', 'workspace_archived_session_export', @@ -5226,6 +5229,42 @@ describe('createServeApp', () => { } }); + it('omits source and updates for one-time snapshot status', async () => { + const restore = mockExtensionManagerMethods({ + getLoadedExtensions: () => [ + { + ...testExtension('snapshot-ext'), + installMetadata: { + source: 'snapshot', + type: 'snapshot', + installId: 'a'.repeat(64), + }, + }, + ], + }); + try { + const app = createServeApp( + { ...baseOpts, workspace: WS_BOUND }, + undefined, + { bridge: fakeBridge() }, + ); + + 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({ + installType: 'snapshot', + credentialPersistence: 'one_time', + updateState: 'not updatable', + }); + expect(res.body.extensions[0]).not.toHaveProperty('source'); + } finally { + restore(); + } + }); + const testExtension = (name = 'test-ext'): Extension => ({ name, @@ -7588,7 +7627,75 @@ describe('createServeApp', () => { expect(res.body.error).toBe('`ref` must not start with "-"'); }); - it('rejects extension source URLs with credentials', async () => { + it.each([ + { persistence: undefined, expected: 'one_time' as const }, + { persistence: 'stored' as const, expected: 'stored' as const }, + ])( + 'accepts extension source credentials with $expected persistence', + async ({ persistence, expected }) => { + let captured: PrepareExtensionInstallOptions | undefined; + const restore = mockExtensionManagerMethods({ + async prepareExtensionInstall(options) { + captured = options; + return testExtension('credentialed-extension'); + }, + }); + const tokenOpts: ServeOptions = { ...baseOpts, token: 'secret' }; + const bridge = fakeBridge({ knownClientIds: ['client-1'] }); + const app = createServeApp( + { ...tokenOpts, workspace: WS_BOUND }, + undefined, + { bridge }, + ); + + try { + 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:fine-grained-token@example.com/repository.git', + consent: true, + ...(persistence ? { credentialPersistence: persistence } : {}), + }); + + expect(res.status).toBe(202); + await vi.waitFor(() => expect(captured).toBeDefined()); + expect(captured).toMatchObject({ + installMetadata: { + source: 'https://example.com/repository.git', + type: 'git', + }, + gitCredential: { + username: 'user', + password: 'fine-grained-token', + persistence: expected, + }, + }); + await vi.waitFor(() => + expect(bridge.extensionEvents.at(-1)).toMatchObject({ + status: 'installed', + credentialPersistence: expected, + ...(expected === 'one_time' + ? {} + : { source: 'https://example.com/repository.git' }), + }), + ); + expect(JSON.stringify(bridge.extensionEvents)).not.toContain( + 'fine-grained-token', + ); + if (expected === 'one_time') { + expect(bridge.extensionEvents.at(-1)).not.toHaveProperty('source'); + } + } finally { + restore(); + } + }, + ); + + it('rejects credential persistence without URL credentials', async () => { const tokenOpts: ServeOptions = { ...baseOpts, token: 'secret' }; const bridge = fakeBridge({ knownClientIds: ['client-1'] }); const app = createServeApp( @@ -7603,14 +7710,69 @@ describe('createServeApp', () => { .set('Authorization', 'Bearer secret') .set('X-Qwen-Client-Id', 'client-1') .send({ - source: 'https://user:pass@example.com/repo', + source: 'https://example.com/repository.git', + credentialPersistence: 'stored', consent: true, }); expect(res.status).toBe(400); - expect(res.body.error).toBe('`source` must not include credentials'); + expect(res.body.error).toContain('requires source URL credentials'); }); + it.each([ + { + body: { + source: 'https://user:token@example.com/repository.git', + credentialPersistence: 'forever', + }, + error: '`credentialPersistence` must be "stored" or "one_time"', + }, + { + body: { source: 'https://@example.com/repository.git' }, + error: '`source` credentials are invalid', + }, + { + body: { source: 'https://user:token%0D@example.com/repository.git' }, + error: '`source` credentials are invalid', + }, + { + body: { source: 'https://user:token%C2%85@example.com/repository.git' }, + error: '`source` credentials are invalid', + }, + { + body: { source: 'https://user:token@example.com/extension.zip' }, + error: 'Git credentials require an HTTPS Git install source.', + }, + { + body: { + source: 'https://user:token@example.com/repository.git', + autoUpdate: true, + }, + error: '`autoUpdate` is not supported with one-time credentials', + }, + ])( + 'rejects invalid credentialed install input', + async ({ body, error }) => { + 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({ ...body, consent: true }); + + expect(res.status).toBe(400); + expect(res.body.error).toBe(error); + }, + ); + it('rejects an npm extension install with ref before queuing', async () => { const tokenOpts: ServeOptions = { ...baseOpts, token: 'secret' }; const bridge = fakeBridge({ knownClientIds: ['client-1'] }); diff --git a/packages/core/src/config/config.ts b/packages/core/src/config/config.ts index 2357466a47a..8fde6a320a3 100644 --- a/packages/core/src/config/config.ts +++ b/packages/core/src/config/config.ts @@ -699,7 +699,16 @@ export type ExtensionNetworkPolicy = 'public'; export interface ExtensionInstallMetadata { source: string; - type: 'git' | 'local' | 'link' | 'github-release' | 'npm' | 'archive-url'; + type: + | 'git' + | 'local' + | 'link' + | 'github-release' + | 'npm' + | 'archive-url' + | 'snapshot'; + installId?: string; + credentialPersistence?: 'stored'; originSource?: ExtensionOriginSource; releaseTag?: string; // Only present for github-release and npm installs. gitCommit?: string; // Commit recorded when the installation source was cloned. diff --git a/packages/core/src/extension/extension-git-credentials.test.ts b/packages/core/src/extension/extension-git-credentials.test.ts new file mode 100644 index 00000000000..47a45f92e72 --- /dev/null +++ b/packages/core/src/extension/extension-git-credentials.test.ts @@ -0,0 +1,137 @@ +/** + * @license + * Copyright 2026 Qwen Team + * SPDX-License-Identifier: Apache-2.0 + */ + +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; +import * as fs from 'node:fs/promises'; +import * as os from 'node:os'; +import * as path from 'node:path'; +import { + EXTENSION_GIT_CREDENTIAL_SELECTOR_FILENAME, + prepareStoredGitCredential, + removeGitCredentialSelector, + resolveStoredGitCredential, + writeGitCredentialSelector, +} from './extension-git-credentials.js'; +import { TokenStorageType } from '../mcp/token-storage/types.js'; +import { KeychainTokenStorage } from '../mcp/token-storage/keychain-token-storage.js'; + +describe('extension Git credential storage', () => { + let tempDir: string; + + beforeEach(async () => { + tempDir = await fs.mkdtemp(path.join(os.tmpdir(), 'extension-git-auth-')); + vi.stubEnv('QWEN_HOME', path.join(tempDir, 'qwen-home')); + vi.stubEnv('QWEN_CODE_FORCE_FILE_STORAGE', 'true'); + }); + + afterEach(async () => { + vi.unstubAllEnvs(); + await fs.rm(tempDir, { recursive: true, force: true }); + }); + + it('stages an encrypted-file credential and resolves it through its selector', async () => { + const extensionDir = path.join(tempDir, 'extension'); + await fs.mkdir(extensionDir); + const prepared = await prepareStoredGitCredential(extensionDir, { + username: 'user', + password: 'fine-grained-token', + }); + + const selectorPath = path.join( + extensionDir, + EXTENSION_GIT_CREDENTIAL_SELECTOR_FILENAME, + ); + const selectorContent = await fs.readFile(selectorPath, 'utf8'); + expect(selectorContent).not.toContain('user'); + expect(selectorContent).not.toContain('fine-grained-token'); + expect((await fs.stat(selectorPath)).mode & 0o777).toBe(0o600); + await expect(resolveStoredGitCredential(extensionDir)).resolves.toEqual({ + credential: { username: 'user', password: 'fine-grained-token' }, + selector: prepared.selector, + }); + + prepared.commit(); + await prepared.discard(); + await expect(resolveStoredGitCredential(extensionDir)).resolves.toEqual({ + credential: { username: 'user', password: 'fine-grained-token' }, + selector: prepared.selector, + }); + }); + + it('deletes an uncommitted secret when preparation is discarded', async () => { + const extensionDir = path.join(tempDir, 'extension'); + await fs.mkdir(extensionDir); + const prepared = await prepareStoredGitCredential(extensionDir, { + username: 'user', + password: 'token', + }); + + await prepared.discard(); + + await expect( + resolveStoredGitCredential(extensionDir), + ).rejects.toMatchObject({ code: 'extension_credential_unavailable' }); + }); + + it('selects the keychain when it is available', async () => { + vi.stubEnv('QWEN_CODE_FORCE_FILE_STORAGE', 'false'); + const isAvailable = vi + .spyOn(KeychainTokenStorage.prototype, 'isAvailable') + .mockResolvedValue(true); + const setSecret = vi + .spyOn(KeychainTokenStorage.prototype, 'setSecret') + .mockResolvedValue(); + const deleteSecret = vi + .spyOn(KeychainTokenStorage.prototype, 'deleteSecret') + .mockResolvedValue(); + const extensionDir = path.join(tempDir, 'extension'); + await fs.mkdir(extensionDir); + + const prepared = await prepareStoredGitCredential(extensionDir, { + username: 'user', + password: 'token', + }); + + expect(prepared.storageType).toBe(TokenStorageType.KEYCHAIN); + expect(prepared.selector.backend).toBe(TokenStorageType.KEYCHAIN); + expect(isAvailable).toHaveBeenCalled(); + expect(setSecret).toHaveBeenCalledWith( + prepared.selector.secretKey, + JSON.stringify({ username: 'user', password: 'token' }), + ); + await prepared.discard(); + expect(deleteSecret).toHaveBeenCalledWith(prepared.selector.secretKey); + }); + + it('rejects a symlinked selector and removes repository-provided selectors', async () => { + const extensionDir = path.join(tempDir, 'extension'); + await fs.mkdir(extensionDir); + const outside = path.join(tempDir, 'outside.json'); + await fs.writeFile(outside, '{}'); + await fs.symlink( + outside, + path.join(extensionDir, EXTENSION_GIT_CREDENTIAL_SELECTOR_FILENAME), + ); + + await expect( + resolveStoredGitCredential(extensionDir), + ).rejects.toMatchObject({ code: 'extension_credential_unavailable' }); + await removeGitCredentialSelector(extensionDir); + await writeGitCredentialSelector(extensionDir, { + version: 1, + backend: TokenStorageType.ENCRYPTED_FILE, + secretKey: '$qwen:extension-git:v1:test', + }); + expect(await fs.lstat(outside)).toBeDefined(); + expect( + ( + await fs.lstat( + path.join(extensionDir, EXTENSION_GIT_CREDENTIAL_SELECTOR_FILENAME), + ) + ).isSymbolicLink(), + ).toBe(false); + }); +}); diff --git a/packages/core/src/extension/extension-git-credentials.ts b/packages/core/src/extension/extension-git-credentials.ts new file mode 100644 index 00000000000..1784defa22e --- /dev/null +++ b/packages/core/src/extension/extension-git-credentials.ts @@ -0,0 +1,201 @@ +/** + * @license + * Copyright 2025 Qwen Team + * SPDX-License-Identifier: Apache-2.0 + */ + +import * as fs from 'node:fs/promises'; +import * as path from 'node:path'; +import { randomUUID } from 'node:crypto'; +import { atomicWriteJSON } from '../utils/atomicFileWrite.js'; +import { FileTokenStorage } from '../mcp/token-storage/file-token-storage.js'; +import { HybridTokenStorage } from '../mcp/token-storage/hybrid-token-storage.js'; +import { KeychainTokenStorage } from '../mcp/token-storage/keychain-token-storage.js'; +import { + TokenStorageType, + type SecretStorage, +} from '../mcp/token-storage/types.js'; + +export const EXTENSION_GIT_CREDENTIAL_SELECTOR_FILENAME = + '.qwen-extension-git-credentials.json'; + +const GIT_CREDENTIAL_SERVICE_NAME = 'Qwen Code Extension Git Credentials'; +const GIT_CREDENTIAL_KEY_PREFIX = '$qwen:extension-git:v1:'; + +export type ExtensionCredentialPersistence = 'stored' | 'one_time'; + +export interface GitCredential { + username: string; + password: string; +} + +export interface ExtensionGitCredential extends GitCredential { + persistence: ExtensionCredentialPersistence; +} + +export interface ExtensionGitCredentialSelector { + version: 1; + backend: TokenStorageType; + secretKey: string; +} + +export interface ResolvedStoredGitCredential { + credential: GitCredential; + selector: ExtensionGitCredentialSelector; +} + +export interface PreparedStoredGitCredential { + storageType: TokenStorageType; + selector: ExtensionGitCredentialSelector; + commit(): void; + discard(): Promise; +} + +export class ExtensionCredentialUnavailableError extends Error { + readonly code = 'extension_credential_unavailable'; + + constructor( + message = 'Stored extension Git credentials are unavailable.', + options?: ErrorOptions, + ) { + super(message, options); + this.name = 'ExtensionCredentialUnavailableError'; + } +} + +const selectorPath = (extensionDir: string): string => + path.join(extensionDir, EXTENSION_GIT_CREDENTIAL_SELECTOR_FILENAME); + +function createSelectedStorage(backend: TokenStorageType): SecretStorage { + return backend === TokenStorageType.KEYCHAIN + ? new KeychainTokenStorage(GIT_CREDENTIAL_SERVICE_NAME) + : new FileTokenStorage(GIT_CREDENTIAL_SERVICE_NAME); +} + +function parseSelector(content: string): ExtensionGitCredentialSelector { + const value: unknown = JSON.parse(content); + if ( + !value || + typeof value !== 'object' || + !('version' in value) || + value.version !== 1 || + !('backend' in value) || + !Object.values(TokenStorageType).includes( + value.backend as TokenStorageType, + ) || + !('secretKey' in value) || + typeof value.secretKey !== 'string' || + !value.secretKey.startsWith(GIT_CREDENTIAL_KEY_PREFIX) + ) { + throw new Error('Stored extension Git credential selector is invalid.'); + } + return value as ExtensionGitCredentialSelector; +} + +async function readSelector( + extensionDir: string, +): Promise { + try { + const target = selectorPath(extensionDir); + const stats = await fs.lstat(target); + if (!stats.isFile() || stats.isSymbolicLink() || stats.size > 4096) { + throw new Error('Stored extension Git credential selector is invalid.'); + } + return parseSelector(await fs.readFile(target, 'utf8')); + } catch (error) { + if (error instanceof ExtensionCredentialUnavailableError) throw error; + throw new ExtensionCredentialUnavailableError(undefined, { cause: error }); + } +} + +function parseCredential(content: string): GitCredential { + const value: unknown = JSON.parse(content); + if ( + !value || + typeof value !== 'object' || + !('username' in value) || + typeof value.username !== 'string' || + !('password' in value) || + typeof value.password !== 'string' + ) { + throw new Error('Stored extension Git credential is invalid.'); + } + return { username: value.username, password: value.password }; +} + +export async function resolveStoredGitCredential( + extensionDir: string, +): Promise { + try { + const selector = await readSelector(extensionDir); + const storage = createSelectedStorage(selector.backend); + const content = await storage.getSecret(selector.secretKey); + if (content === null) throw new Error('Stored secret is missing.'); + return { credential: parseCredential(content), selector }; + } catch (error) { + if (error instanceof ExtensionCredentialUnavailableError) throw error; + throw new ExtensionCredentialUnavailableError(undefined, { cause: error }); + } +} + +export async function removeGitCredentialSelector( + extensionDir: string, +): Promise { + await fs.rm(selectorPath(extensionDir), { force: true }); +} + +export async function writeGitCredentialSelector( + extensionDir: string, + selector: ExtensionGitCredentialSelector, +): Promise { + await atomicWriteJSON(selectorPath(extensionDir), selector, { + mode: 0o600, + forceMode: true, + noFollow: true, + }); +} + +export async function prepareStoredGitCredential( + extensionDir: string, + credential: GitCredential, +): Promise { + const storage = new HybridTokenStorage(GIT_CREDENTIAL_SERVICE_NAME); + const secretKey = `${GIT_CREDENTIAL_KEY_PREFIX}${randomUUID()}`; + await storage.setSecret(secretKey, JSON.stringify(credential)); + const selector: ExtensionGitCredentialSelector = { + version: 1, + backend: await storage.getStorageType(), + secretKey, + }; + try { + await writeGitCredentialSelector(extensionDir, selector); + } catch (error) { + await storage.deleteSecret(secretKey).catch(() => undefined); + throw error; + } + let committed = false; + let discarded = false; + return { + storageType: selector.backend, + selector, + commit: () => { + committed = true; + }, + discard: async () => { + if (committed || discarded) return; + const selectedStorage = createSelectedStorage(selector.backend); + await selectedStorage.deleteSecret(secretKey); + discarded = true; + }, + }; +} + +export async function prepareStoredGitCredentialDeletion( + extensionDir: string, +): Promise<() => Promise> { + const selector = await readSelector(extensionDir); + return async () => { + const storage = createSelectedStorage(selector.backend); + await storage.deleteSecret(selector.secretKey); + }; +} diff --git a/packages/core/src/extension/extensionManager.test.ts b/packages/core/src/extension/extensionManager.test.ts index b207e6d6c2c..c7f6f180e3b 100644 --- a/packages/core/src/extension/extensionManager.test.ts +++ b/packages/core/src/extension/extensionManager.test.ts @@ -34,6 +34,11 @@ import { AGENT_PLUGIN_MCP_SCHEMA, AGENT_PLUGIN_SCHEMA, } from './agent-plugins-v1/index.js'; +import { + EXTENSION_GIT_CREDENTIAL_SELECTOR_FILENAME, + resolveStoredGitCredential, +} from './extension-git-credentials.js'; +import { FileTokenStorage } from '../mcp/token-storage/file-token-storage.js'; const mockGit = { clone: vi.fn(), @@ -195,9 +200,11 @@ describe('extension tests', () => { let tempWorkspaceDir: string; let userExtensionsDir: string; let savedQwenHome: string | undefined; + let savedForceFileStorage: string | undefined; beforeEach(() => { savedQwenHome = process.env['QWEN_HOME']; + savedForceFileStorage = process.env['QWEN_CODE_FORCE_FILE_STORAGE']; delete process.env['QWEN_HOME']; tempHomeDir = fs.mkdtempSync( path.join(os.tmpdir(), 'qwen-code-test-home-'), @@ -224,6 +231,11 @@ describe('extension tests', () => { } else { process.env['QWEN_HOME'] = savedQwenHome; } + if (savedForceFileStorage === undefined) { + delete process.env['QWEN_CODE_FORCE_FILE_STORAGE']; + } else { + process.env['QWEN_CODE_FORCE_FILE_STORAGE'] = savedForceFileStorage; + } vi.restoreAllMocks(); }); @@ -530,6 +542,191 @@ describe('extension tests', () => { ).toBe(false); }); + it('persists a credentialed one-time install as a source-free snapshot', async () => { + mockGit.env.mockReturnValue(mockGit); + mockGit.clone.mockImplementation(async () => { + const destination = mockGit.path(); + writeExtractedExtension(destination, 'one-time-extension'); + fs.mkdirSync(path.join(destination, '.git'), { recursive: true }); + fs.writeFileSync( + path.join(destination, '.git', 'config'), + 'credential must not be copied', + ); + }); + mockGit.getRemotes.mockResolvedValue([ + { + name: 'origin', + refs: { fetch: 'https://git.example.com/team/extension.git' }, + }, + ]); + const manager = createExtensionManager(); + await manager.refreshCache(); + + const prepared = await manager.prepareExtensionInstall({ + installMetadata: { + type: 'git', + source: 'https://git.example.com/team/extension.git', + }, + initialActivation: { scope: 'user' }, + requestConsent: async () => {}, + gitCredential: { + username: 'user', + password: 'fine-grained-token', + persistence: 'one_time', + }, + }); + + expect(prepared.installMetadata).toMatchObject({ + type: 'snapshot', + source: 'snapshot', + installId: expect.stringMatching(/^[a-f0-9]{64}$/), + }); + expect(prepared.identity.id).toBe(prepared.installMetadata.installId); + expect(fs.existsSync(path.join(prepared.stagingDirectory, '.git'))).toBe( + false, + ); + expect( + fs.existsSync( + path.join( + prepared.stagingDirectory, + EXTENSION_GIT_CREDENTIAL_SELECTOR_FILENAME, + ), + ), + ).toBe(false); + const stagedMetadata = fs.readFileSync( + path.join(prepared.stagingDirectory, INSTALL_METADATA_FILENAME), + 'utf8', + ); + expect(stagedMetadata).not.toContain('git.example.com'); + expect(stagedMetadata).not.toContain('fine-grained-token'); + + mockLogExtensionInstallEvent.mockClear(); + const committed = await manager.commitPreparedExtension(prepared); + expect(committed.extension?.id).toBe(prepared.identity.id); + expect(committed.extension?.installMetadata?.type).toBe('snapshot'); + expect(mockLogExtensionInstallEvent).toHaveBeenCalledWith( + expect.anything(), + expect.objectContaining({ + extension_source: 'snapshot', + status: 'success', + }), + ); + const telemetryEvents = mockLogExtensionInstallEvent.mock.calls.map( + ([, event]) => event, + ); + expect(JSON.stringify(telemetryEvents)).not.toContain('git.example.com'); + expect(JSON.stringify(telemetryEvents)).not.toContain( + 'fine-grained-token', + ); + const reloadedManager = createExtensionManager(); + await reloadedManager.refreshCache(); + expect(reloadedManager.getLoadedExtensions()[0]?.id).toBe( + prepared.identity.id, + ); + await expect( + reloadedManager.updateExtension( + reloadedManager.getLoadedExtensions()[0]!, + ExtensionUpdateState.UPDATE_AVAILABLE, + () => {}, + ), + ).rejects.toMatchObject({ code: 'extension_not_updatable' }); + await manager.disposePreparedExtension(prepared); + }); + + it('stores managed Git credentials separately from install metadata', async () => { + process.env['QWEN_HOME'] = tempHomeDir; + process.env['QWEN_CODE_FORCE_FILE_STORAGE'] = 'true'; + mockGit.env.mockReturnValue(mockGit); + mockGit.clone.mockImplementation(async () => { + writeExtractedExtension(mockGit.path(), 'stored-extension'); + }); + mockGit.getRemotes.mockResolvedValue([ + { + name: 'origin', + refs: { fetch: 'https://git.example.com/team/extension.git' }, + }, + ]); + const manager = createExtensionManager(); + await manager.refreshCache(); + + const prepared = await manager.prepareExtensionInstall({ + installMetadata: { + type: 'git', + source: 'https://git.example.com/team/extension.git', + }, + initialActivation: { scope: 'user' }, + requestConsent: async () => {}, + gitCredential: { + username: 'user', + password: 'fine-grained-token', + persistence: 'stored', + }, + }); + + expect(prepared.installMetadata).toMatchObject({ + type: 'git', + source: 'https://git.example.com/team/extension.git', + credentialPersistence: 'stored', + installId: expect.stringMatching(/^[a-f0-9]{64}$/), + }); + const metadata = fs.readFileSync( + path.join(prepared.stagingDirectory, INSTALL_METADATA_FILENAME), + 'utf8', + ); + expect(metadata).not.toContain('fine-grained-token'); + + const committed = await manager.commitPreparedExtension(prepared); + const resolved = await resolveStoredGitCredential( + committed.extension!.path, + ); + expect(resolved).toMatchObject({ + credential: { username: 'user', password: 'fine-grained-token' }, + }); + const originalId = committed.identity.id; + + await manager.updateExtension( + committed.extension!, + ExtensionUpdateState.UPDATE_AVAILABLE, + () => {}, + ); + + expect(manager.getLoadedExtensions()[0]?.id).toBe(originalId); + expect(mockGit.clone).toHaveBeenLastCalledWith( + 'https://git.example.com/team/extension.git', + './', + expect.any(Array), + ); + expect(mockGit.env).toHaveBeenLastCalledWith( + expect.objectContaining({ + GIT_CONFIG_KEY_0: + 'http.https://git.example.com/team/extension.git.extraHeader', + }), + ); + const storage = new FileTokenStorage( + 'Qwen Code Extension Git Credentials', + ); + await expect( + storage.getSecret(resolved.selector.secretKey), + ).resolves.not.toBeNull(); + + fs.writeFileSync( + path.join( + userExtensionsDir, + 'stored-extension', + EXTENSIONS_CONFIG_FILENAME, + ), + '{', + ); + const unloadedManager = createExtensionManager(); + await unloadedManager.refreshCache(); + expect(unloadedManager.getLoadedExtensions()).toEqual([]); + await unloadedManager.uninstallExtensionById(originalId, false); + await expect( + storage.getSecret(resolved.selector.secretKey), + ).resolves.toBeNull(); + await manager.disposePreparedExtension(prepared); + }); + it('installs and uninstalls within an injected extension store root', async () => { const archivePath = path.join(tempWorkspaceDir, 'custom-root.zip'); fs.writeFileSync(archivePath, 'archive'); @@ -3052,6 +3249,32 @@ describe('extension tests', () => { }); describe('updateExtension', () => { + it('fails before Git access when managed credentials are unavailable', async () => { + createExtension({ + extensionsDir: userExtensionsDir, + installMetadata: { + type: 'git', + source: 'https://git.example.com/team/extension.git', + gitCommit: 'sample-commit', + credentialPersistence: 'stored', + installId: 'a'.repeat(64), + }, + }); + const manager = createExtensionManager({ networkPolicy: 'public' }); + await manager.refreshCache(); + const extension = manager.getLoadedExtensions()[0]!; + + await expect( + manager.updateExtension( + extension, + ExtensionUpdateState.UPDATE_AVAILABLE, + () => {}, + ), + ).rejects.toMatchObject({ code: 'extension_credential_unavailable' }); + expect(mockGit.clone).not.toHaveBeenCalled(); + expect(mockGit.listRemote).not.toHaveBeenCalled(); + }); + it('applies the update network policy without mutating cached metadata', async () => { createExtension({ extensionsDir: userExtensionsDir, @@ -3845,6 +4068,46 @@ describe('extension tests', () => { }); describe('getExtensionId', () => { + it('uses a persisted install id instead of the source', () => { + const installId = 'a'.repeat(64); + expect( + getExtensionId( + { name: 'test-ext', version: '1.0.0' }, + { + type: 'git', + source: 'https://example.com/repo', + installId, + credentialPersistence: 'stored', + }, + ), + ).toBe(installId); + }); + + it('ignores install ids on unmanaged metadata', () => { + const config = { name: 'test-ext', version: '1.0.0' }; + const source = 'https://example.com/repo'; + expect( + getExtensionId(config, { + type: 'git', + source, + installId: 'a'.repeat(64), + }), + ).toBe(getExtensionId(config, { type: 'git', source })); + }); + + it('rejects an invalid persisted install id', () => { + expect(() => + getExtensionId( + { name: 'test-ext', version: '1.0.0' }, + { + type: 'snapshot', + source: 'snapshot', + installId: '../invalid', + }, + ), + ).toThrow('Stored extension install id is invalid'); + }); + it('should use hashed name when no install metadata', () => { const config: ExtensionConfig = { name: 'test-ext', version: '1.0.0' }; const id = getExtensionId(config); diff --git a/packages/core/src/extension/extensionManager.ts b/packages/core/src/extension/extensionManager.ts index 6a9a81acd4c..3c4bd933b95 100644 --- a/packages/core/src/extension/extensionManager.ts +++ b/packages/core/src/extension/extensionManager.ts @@ -65,7 +65,7 @@ import { } from './marketplace.js'; import { convertCompatibleExtension } from './extension-converter.js'; import { glob } from 'glob'; -import { createHash } from 'node:crypto'; +import { createHash, randomBytes } from 'node:crypto'; import { ExtensionStorage } from './storage.js'; import { resolveExtensionConfigLocale, @@ -116,6 +116,17 @@ import { loadAgentPluginSkills, } from './agent-plugins-v1/index.js'; import { resolveContainedExistingPath } from './agent-plugins-v1/paths.js'; +import { + prepareStoredGitCredential, + prepareStoredGitCredentialDeletion, + removeGitCredentialSelector, + resolveStoredGitCredential, + writeGitCredentialSelector, + type ExtensionGitCredential, + type ExtensionGitCredentialSelector, + type PreparedStoredGitCredential, +} from './extension-git-credentials.js'; +import type { TokenStorageType } from '../mcp/token-storage/types.js'; const debugLogger = createDebugLogger('EXTENSIONS'); @@ -269,6 +280,7 @@ export interface ExtensionManagerOptions { export interface PrepareExtensionInstallOptions { installMetadata: ExtensionInstallMetadata; initialActivation: InitialExtensionActivation; + gitCredential?: ExtensionGitCredential; localSourcePath?: string; requestConsent?: (options?: ExtensionRequestOptions) => Promise; requestSetting?: (setting: ExtensionSetting) => Promise; @@ -307,8 +319,16 @@ export interface PreparedExtensionMutation { /** @internal */ readonly discardSettings?: () => Promise; /** @internal */ + readonly credentialStorage?: TokenStorageType; + /** @internal */ + readonly commitGitCredential?: () => void; + /** @internal */ + readonly discardGitCredential?: () => Promise; + /** @internal */ settingsActivated: boolean; /** @internal */ + gitCredentialActivated: boolean; + /** @internal */ consumed: boolean; /** @internal */ disposed: boolean; @@ -347,6 +367,19 @@ export class InvalidPreparedExtensionError extends Error { } } +export class ExtensionNotUpdatableError extends Error { + readonly code = 'extension_not_updatable'; + + constructor(name: string) { + super(`Extension "${name}" is not remotely updatable.`); + this.name = 'ExtensionNotUpdatableError'; + } +} + +interface RuntimeGitCredential extends ExtensionGitCredential { + selector?: ExtensionGitCredentialSelector; +} + export interface ExtensionMutationEvent { id: number; phase: 'start' | 'end'; @@ -1818,6 +1851,7 @@ export class ExtensionManager { true, false, options.localSourcePath, + options.gitCredential, )) as PreparedExtensionMutation; } @@ -1829,9 +1863,21 @@ export class ExtensionManager { if (!installMetadata?.type || installMetadata.type === 'link') { throw new Error(`Extension ${extension.name} cannot be updated.`); } + if (installMetadata.type === 'snapshot') { + throw new ExtensionNotUpdatableError(extension.name); + } const previousConfig = this.loadExtensionConfig({ extensionDir: extension.path, }); + let gitCredential: RuntimeGitCredential | undefined; + if (installMetadata.credentialPersistence === 'stored') { + const stored = await resolveStoredGitCredential(extension.path); + gitCredential = { + ...stored.credential, + persistence: 'stored', + selector: stored.selector, + }; + } return (await this.installExtensionInternal( { ...installMetadata }, undefined, @@ -1842,6 +1888,8 @@ export class ExtensionManager { signal, true, false, + undefined, + gitCredential, )) as PreparedExtensionMutation; } @@ -1866,6 +1914,9 @@ export class ExtensionManager { if (state === ExtensionUpdateState.UP_TO_DATE) { return { upToDate: true, extension: options.extension }; } + if (state === ExtensionUpdateState.NOT_UPDATABLE) { + throw new ExtensionNotUpdatableError(options.extension.name); + } if (state !== ExtensionUpdateState.UPDATE_AVAILABLE) { throw new Error( `Extension "${options.extension.name}" update check returned ${state}.`, @@ -1895,18 +1946,34 @@ export class ExtensionManager { prepareOnly: boolean, emitMutation: boolean, localSourcePathOverride?: string, + gitCredential?: RuntimeGitCredential, ): Promise { if (localSourcePathOverride && installMetadata.type !== 'local') { throw new Error('A local source path requires a local install.'); } installMetadata = this.withNetworkPolicy(installMetadata)!; + const remoteGitInstall = + installMetadata.type === 'git' || + installMetadata.type === 'github-release'; + if (gitCredential && !remoteGitInstall) { + throw new Error('Git credentials require an HTTPS Git install source.'); + } + if (gitCredential?.persistence === 'one_time' && previousExtensionConfig) { + throw new ExtensionNotUpdatableError(previousExtensionConfig.name); + } + if (gitCredential && !installMetadata.installId) { + installMetadata.installId = randomBytes(32).toString('hex'); + } const currentDir = cwd ?? this.workspaceDir; const telemetryConfig = getTelemetryConfig( currentDir, this.telemetrySettings, ); let extension: Extension | null; - const redactedInstallSource = redactUrlCredentials(installMetadata.source); + const redactedInstallSource = + gitCredential?.persistence === 'one_time' + ? 'credentialed HTTPS Git source' + : redactUrlCredentials(installMetadata.source); const isUpdate = !!previousExtensionConfig; const expectedArtifactGeneration = previousExtensionConfig @@ -1920,6 +1987,7 @@ export class ExtensionManager { let convertedSourcePath: string | undefined; let stagingPath: string | undefined; let preparedSettings: PreparedExtensionSettingsMutation | undefined; + let preparedGitCredential: PreparedStoredGitCredential | undefined; let ownershipTransferred = false; const endMutation = emitMutation @@ -1962,37 +2030,43 @@ export class ExtensionManager { installMetadata.type === 'github-release' ) { tempDir = await ExtensionStorage.createTmpDir(); - try { - const result = await downloadFromGitHubRelease( - installMetadata, - tempDir, - signal, - ); - if ( - installMetadata.type === 'git' || - installMetadata.type === 'github-release' - ) { - installMetadata.type = result.type; - installMetadata.releaseTag = result.tagName; - } - } catch (_error) { - signal?.throwIfAborted(); - // downloadFromGitHubRelease may have written a partial archive or - // extracted files into tempDir before failing (e.g. a repo whose - // latest release is a source tarball that isn't a valid extension - // archive). Reusing that dirty directory makes `git clone` fail with - // "destination path '.' already exists and is not an empty directory". - // Recreate a clean tempDir before falling back to a plain clone. - // See #6334. - await fs.promises.rm(tempDir, { recursive: true, force: true }); - await fs.promises.mkdir(tempDir, { recursive: true }); + if (gitCredential) { + installMetadata.type = 'git'; + installMetadata.releaseTag = undefined; installMetadata.gitCommit = await cloneFromGit( installMetadata, tempDir, signal, + gitCredential, + gitCredential.persistence === 'one_time', ); - if (installMetadata.type === 'github-release') { - installMetadata.type = 'git'; + } else { + try { + const result = await downloadFromGitHubRelease( + installMetadata, + tempDir, + signal, + ); + if ( + installMetadata.type === 'git' || + installMetadata.type === 'github-release' + ) { + installMetadata.type = result.type; + installMetadata.releaseTag = result.tagName; + } + } catch (_error) { + signal?.throwIfAborted(); + // Release extraction may leave a partial destination behind. + await fs.promises.rm(tempDir, { recursive: true, force: true }); + await fs.promises.mkdir(tempDir, { recursive: true }); + installMetadata.gitCommit = await cloneFromGit( + installMetadata, + tempDir, + signal, + ); + if (installMetadata.type === 'github-release') { + installMetadata.type = 'git'; + } } } localSourcePath = tempDir; @@ -2056,6 +2130,25 @@ export class ExtensionManager { installMetadata.gitCommit = undefined; } + if (gitCredential?.persistence === 'stored') { + installMetadata.type = 'git'; + installMetadata.credentialPersistence = 'stored'; + } else if ( + gitCredential?.persistence === 'one_time' && + !previousExtensionConfig + ) { + installMetadata = { + source: 'snapshot', + type: 'snapshot', + installId: installMetadata.installId, + ...(originSource ? { originSource } : {}), + ...(externalContent ? { externalContent: true } : {}), + ...(installMetadata.pluginName + ? { pluginName: installMetadata.pluginName } + : {}), + }; + } + newExtensionConfig = this.loadExtensionConfig({ extensionDir: localSourcePath, workspaceDir: currentDir, @@ -2131,7 +2224,7 @@ export class ExtensionManager { previousCommands, previousSkills, previousSubagents, - originSource: installMetadata.originSource, + originSource, }); } else { await this.requestConsent({ @@ -2143,7 +2236,7 @@ export class ExtensionManager { previousCommands, previousSkills, previousSubagents, - originSource: installMetadata.originSource, + originSource, }); } @@ -2165,8 +2258,23 @@ export class ExtensionManager { if (installMetadata.type !== 'link') { await copyExtension(localSourcePath, stagingPath, { skipSymlinks: isAgentPlugin, + excludeRootGitDirectory: remoteGitInstall, }); } + await removeGitCredentialSelector(stagingPath); + if (gitCredential?.persistence === 'stored') { + if (gitCredential.selector) { + await writeGitCredentialSelector( + stagingPath, + gitCredential.selector, + ); + } else { + preparedGitCredential = await prepareStoredGitCredential( + stagingPath, + gitCredential, + ); + } + } if (isUpdate) { preparedSettings = await maybePromptForSettings( @@ -2260,7 +2368,17 @@ export class ExtensionManager { discardSettings: preparedSettings.discard, } : {}), + ...(preparedGitCredential + ? { + credentialStorage: preparedGitCredential.storageType, + commitGitCredential: preparedGitCredential.commit, + discardGitCredential: preparedGitCredential.discard, + } + : gitCredential?.selector + ? { credentialStorage: gitCredential.selector.backend } + : {}), settingsActivated: false, + gitCredentialActivated: false, consumed: false, disposed: false, }; @@ -2278,6 +2396,8 @@ export class ExtensionManager { ? {} : { expectedArtifactGeneration }), }); + preparedGitCredential?.commit(); + preparedGitCredential = undefined; await preparedSettings?.commit().catch((error) => { debugLogger.warn( `Extension "${newExtensionName}" settings compatibility cleanup failed: ${getErrorMessage(error)}`, @@ -2345,7 +2465,7 @@ export class ExtensionManager { new ExtensionInstallEvent( newExtensionConfig.name, newExtensionConfig!.version, - redactUrlCredentials(installMetadata.source), + redactedInstallSource, 'success', ), ); @@ -2357,6 +2477,13 @@ export class ExtensionManager { ); }); } finally { + if (!ownershipTransferred && preparedGitCredential) { + await preparedGitCredential.discard().catch((error) => { + debugLogger.warn( + `Failed to discard prepared extension Git credentials: ${getErrorMessage(error)}`, + ); + }); + } if (!ownershipTransferred && preparedSettings) { await preparedSettings.discard().catch((error) => { debugLogger.warn( @@ -2441,7 +2568,7 @@ export class ExtensionManager { new ExtensionInstallEvent( newExtensionConfig?.name ?? '', newExtensionConfig?.version ?? '', - redactUrlCredentials(installMetadata.source), + redactedInstallSource, 'error', ), ); @@ -2512,6 +2639,8 @@ export class ExtensionManager { prepared.expectedArtifactGeneration ?? 0, }), }); + prepared.commitGitCredential?.(); + prepared.gitCredentialActivated = true; prepared.settingsActivated = true; } catch (error) { const telemetryConfig = getTelemetryConfig( @@ -2648,9 +2777,16 @@ export class ExtensionManager { !prepared.settingsActivated && prepared.discardSettings ? await Promise.allSettled([prepared.discardSettings()]) : []; + const credentialCleanup = + !prepared.gitCredentialActivated && prepared.discardGitCredential + ? await Promise.allSettled([prepared.discardGitCredential()]) + : []; const settingsErrors = settingsCleanup.flatMap((result) => result.status === 'rejected' ? [result.reason] : [], ); + const credentialErrors = credentialCleanup.flatMap((result) => + result.status === 'rejected' ? [result.reason] : [], + ); const paths = [prepared.stagingDirectory, ...prepared.cleanupPaths]; let failedPaths = paths; let pathErrors: unknown[] = []; @@ -2667,7 +2803,7 @@ export class ExtensionManager { (_target, index) => results[index]?.status === 'rejected', ); } - const errors = [...settingsErrors, ...pathErrors]; + const errors = [...settingsErrors, ...credentialErrors, ...pathErrors]; prepared.disposed = errors.length === 0; return errors; } @@ -2707,6 +2843,7 @@ export class ExtensionManager { isUpdate, telemetryConfig, onCommitted, + extension.installMetadata?.credentialPersistence === 'stored', ); } finally { endMutation(); @@ -2727,14 +2864,20 @@ export class ExtensionManager { const extension = this.getLoadedExtensions().find( (candidate) => candidate.id === extensionId, ); - return await this.uninstallExtensionPolicy( - { id: extensionId, name: policy.name }, + const destinationDirectory = extension && extension.installMetadata?.type !== 'link' ? extension.path - : path.join(this.configDir, policy.name), + : path.join(this.configDir, policy.name); + const installMetadata = + extension?.installMetadata ?? + this.loadInstallMetadata(destinationDirectory); + return await this.uninstallExtensionPolicy( + { id: extensionId, name: policy.name }, + destinationDirectory, isUpdate, getTelemetryConfig(cwd ?? this.workspaceDir, this.telemetrySettings), onCommitted, + installMetadata?.credentialPersistence === 'stored', ); } finally { endMutation(); @@ -2747,7 +2890,18 @@ export class ExtensionManager { isUpdate: boolean, telemetryConfig: Config, onCommitted?: ExtensionCommitCallback, + hasStoredGitCredential = false, ): Promise { + let deleteGitCredential: (() => Promise) | undefined; + let credentialCleanupError: unknown; + if (hasStoredGitCredential && !isUpdate) { + try { + deleteGitCredential = + await prepareStoredGitCredentialDeletion(destinationDirectory); + } catch (error) { + credentialCleanupError = error; + } + } const snapshot = await this.extensionStore.commitArtifact({ operation: 'uninstall', identity, @@ -2757,6 +2911,19 @@ export class ExtensionManager { this.extensionCache?.delete(identity.name); if (isUpdate) return snapshot; const warnings: NonNullable = []; + if (deleteGitCredential) { + try { + await deleteGitCredential(); + } catch (error) { + credentialCleanupError = error; + } + } + if (credentialCleanupError) { + warnings.push({ + code: 'extension_credential_cleanup_failed', + error: getErrorMessage(credentialCleanupError), + }); + } try { this.preferencesStore.clear(identity.name); } catch (error) { @@ -2878,6 +3045,10 @@ export class ExtensionManager { `Extension ${extension.name} cannot be updated, type is unknown.`, ); } + if (installMetadata.type === 'snapshot') { + callback(extension.name, ExtensionUpdateState.NOT_UPDATABLE); + throw new ExtensionNotUpdatableError(extension.name); + } if (installMetadata?.type === 'link') { callback(extension.name, ExtensionUpdateState.UP_TO_DATE); throw new Error(`Extension is linked so does not need to be updated`); @@ -2962,7 +3133,10 @@ export class ExtensionManager { export async function copyExtension( source: string, destination: string, - options: { skipSymlinks?: boolean } = {}, + options: { + skipSymlinks?: boolean; + excludeRootGitDirectory?: boolean; + } = {}, ): Promise { const copySource = options.skipSymlinks ? await fs.promises.realpath(source) @@ -2972,6 +3146,12 @@ export async function copyExtension( dereference: !options.skipSymlinks, filter: async (src: string) => { try { + if ( + options.excludeRootGitDirectory && + path.relative(copySource, src) === '.git' + ) { + return false; + } const stats = options.skipSymlinks ? await fs.promises.lstat(src) : await fs.promises.stat(src); @@ -2991,6 +3171,16 @@ export function getExtensionId( config: ExtensionConfig, installMetadata?: ExtensionInstallMetadata, ): string { + if ( + installMetadata?.installId && + (installMetadata.type === 'snapshot' || + installMetadata.credentialPersistence === 'stored') + ) { + if (!/^[a-f0-9]{64}$/.test(installMetadata.installId)) { + throw new Error('Stored extension install id is invalid.'); + } + return installMetadata.installId; + } let idValue = config.name; let githubUrlParts = null; if ( diff --git a/packages/core/src/extension/github.test.ts b/packages/core/src/extension/github.test.ts index d3325783354..589102d5cfb 100644 --- a/packages/core/src/extension/github.test.ts +++ b/packages/core/src/extension/github.test.ts @@ -40,6 +40,7 @@ import { QODER_PLUGIN_MANIFEST } from './qoder-converter.js'; import { ExtensionStorage } from './storage.js'; import { assertTarArchiveHasNoLinks } from './archive-safety.js'; import { AGENT_PLUGIN_SCHEMA } from './agent-plugins-v1/index.js'; +import { prepareStoredGitCredential } from './extension-git-credentials.js'; const mockPlatform = vi.hoisted(() => vi.fn()); const mockArch = vi.hoisted(() => vi.fn()); @@ -318,6 +319,79 @@ describe('git extension helpers', () => { ); }); + it('passes explicit credentials through scoped Git config without changing the URL', async () => { + vi.spyOn(dns, 'lookup').mockResolvedValue([ + { address: '8.8.8.8', family: 4 }, + ] as never); + const source = 'https://git.example.com/owner/repo.git'; + mockGit.getRemotes.mockResolvedValue([ + { name: 'origin', refs: { fetch: source } }, + ]); + + await cloneFromGit( + { source, type: 'git', networkPolicy: 'public' }, + '/dest', + undefined, + { username: 'user', password: 'fine-grained-token' }, + ); + + expect(mockGit.clone).toHaveBeenCalledWith(source, './', [ + '-c', + 'core.symlinks=true', + '--depth', + '1', + ]); + expect(mockGit.env).toHaveBeenCalledWith( + expect.objectContaining({ + GIT_CONFIG_COUNT: '1', + GIT_CONFIG_KEY_0: `http.${source}.extraHeader`, + GIT_CONFIG_VALUE_0: `Authorization: Basic ${Buffer.from( + 'user:fine-grained-token', + ).toString('base64')}`, + }), + ); + const gitEnvironment = mockGit.env.mock.calls.at(-1)?.[0]; + expect(gitEnvironment).not.toHaveProperty('GIT_CONFIG_PARAMETERS'); + expect(gitEnvironment).not.toHaveProperty('GIT_CONFIG_SYSTEM'); + expect(gitEnvironment).not.toHaveProperty('HOME'); + expect(gitEnvironment).not.toHaveProperty('HTTP_PROXY'); + expect(JSON.stringify(mockGit.clone.mock.calls)).not.toContain( + 'fine-grained-token', + ); + }); + + it('injects GITHUB_TOKEN without adding it to the clone URL', async () => { + vi.stubEnv('GITHUB_TOKEN', 'ambient-token'); + vi.spyOn(dns, 'lookup').mockResolvedValue([ + { address: '8.8.8.8', family: 4 }, + ] as never); + const source = 'https://github.com/owner/repo.git'; + mockGit.getRemotes.mockResolvedValue([ + { name: 'origin', refs: { fetch: source } }, + ]); + + await cloneFromGit( + { source, type: 'git', networkPolicy: 'public' }, + '/dest', + ); + + expect(mockGit.clone).toHaveBeenCalledWith( + source, + './', + expect.any(Array), + ); + expect(mockGit.env).toHaveBeenCalledWith( + expect.objectContaining({ + GIT_CONFIG_VALUE_0: `Authorization: Basic ${Buffer.from( + 'ambient-token:', + ).toString('base64')}`, + }), + ); + expect(JSON.stringify(mockGit.clone.mock.calls)).not.toContain( + 'ambient-token', + ); + }); + it('rejects SSH Git traffic under the public network policy', async () => { await expect( cloneFromGit( @@ -616,6 +690,85 @@ describe('git extension helpers', () => { expect(result).toBe(ExtensionUpdateState.UPDATE_AVAILABLE); }); + it('uses stored credentials for a clean exact-scope remote check', async () => { + const tempDir = await fs.mkdtemp( + path.join(os.tmpdir(), 'stored-git-update-test-'), + ); + vi.stubEnv('QWEN_HOME', path.join(tempDir, 'qwen-home')); + vi.stubEnv('QWEN_CODE_FORCE_FILE_STORAGE', 'true'); + vi.spyOn(dns, 'lookup').mockResolvedValue([ + { address: '8.8.8.8', family: 4 }, + ] as never); + const source = 'https://git.example.com/owner/repo.git'; + const extensionPath = path.join(tempDir, 'extension'); + await fs.mkdir(extensionPath); + const stored = await prepareStoredGitCredential(extensionPath, { + username: 'user', + password: 'fine-grained-token', + }); + stored.commit(); + mockGit.listRemote.mockResolvedValue('remote-hash\tHEAD'); + try { + const result = await checkForExtensionUpdate( + createExtension({ + path: extensionPath, + installMetadata: { + type: 'git', + source, + gitCommit: 'local-hash', + credentialPersistence: 'stored', + networkPolicy: 'public', + }, + }), + mockExtensionManager, + ); + + expect(result).toBe(ExtensionUpdateState.UPDATE_AVAILABLE); + expect(mockGit.listRemote).toHaveBeenCalledWith([source, 'HEAD']); + expect(mockGit.env).toHaveBeenLastCalledWith( + expect.objectContaining({ + GIT_CONFIG_KEY_0: `http.${source}.extraHeader`, + GIT_CONFIG_VALUE_0: `Authorization: Basic ${Buffer.from( + 'user:fine-grained-token', + ).toString('base64')}`, + }), + ); + expect(JSON.stringify(mockGit.listRemote.mock.calls)).not.toContain( + 'fine-grained-token', + ); + } finally { + await fs.rm(tempDir, { recursive: true, force: true }); + } + }); + + it('fails a stored update check before Git when its selector is missing', async () => { + const tempDir = await fs.mkdtemp( + path.join(os.tmpdir(), 'missing-git-credential-test-'), + ); + try { + await expect( + checkForExtensionUpdate( + createExtension({ + path: tempDir, + installMetadata: { + type: 'git', + source: 'https://git.example.com/owner/repo.git', + gitCommit: 'local-hash', + credentialPersistence: 'stored', + networkPolicy: 'public', + }, + }), + mockExtensionManager, + ), + ).rejects.toMatchObject({ + code: 'extension_credential_unavailable', + }); + expect(mockGit.listRemote).not.toHaveBeenCalled(); + } finally { + await fs.rm(tempDir, { recursive: true, force: true }); + } + }); + it.each(['Qoder', 'Claude'] as const)( 'checks a converted %s Git extension using its recorded commit', async (originSource) => { diff --git a/packages/core/src/extension/github.ts b/packages/core/src/extension/github.ts index 0fe26bae59c..869b318ae6d 100644 --- a/packages/core/src/extension/github.ts +++ b/packages/core/src/extension/github.ts @@ -34,6 +34,11 @@ import { assertTarArchiveHasNoLinks } from './archive-safety.js'; import { resolveNetworkTarget } from './network-policy.js'; import { extractZipArchive } from './zip-extraction.js'; import { loadSimpleGit } from '../utils/load-simple-git.js'; +import { + ExtensionCredentialUnavailableError, + resolveStoredGitCredential, + type GitCredential, +} from './extension-git-credentials.js'; const debugLogger = createDebugLogger('EXT_GITHUB'); const SUPPORTED_ARCHIVE_EXTENSIONS = ['.tar.gz', '.zip'] as const; @@ -113,9 +118,9 @@ function getGitHubToken(): string | undefined { return process.env['GITHUB_TOKEN']; } -function addGitHubToken(source: string): string { +function getGitHubCredential(source: string): GitCredential | undefined { const token = getGitHubToken(); - if (!token) return source; + if (!token) return undefined; try { const parsedUrl = new URL(source); if ( @@ -123,13 +128,12 @@ function addGitHubToken(source: string): string { parsedUrl.hostname === 'github.com' && !parsedUrl.username ) { - parsedUrl.username = token; - return parsedUrl.toString(); + return { username: token, password: '' }; } } catch { - return source; + return undefined; } - return source; + return undefined; } async function assertPinnedGitSupported(): Promise { @@ -157,8 +161,9 @@ function createPinnedGitConfig(curlResolve: string): string[] { function restrictGitEnvironment( git: SimpleGit, networkPolicy?: ExtensionInstallMetadata['networkPolicy'], + authentication?: { source: string; credential: GitCredential }, ): SimpleGit { - if (networkPolicy !== 'public') return git; + if (networkPolicy !== 'public' && !authentication) return git; const environment: Record = { GIT_CONFIG_NOSYSTEM: '1', GIT_CONFIG_GLOBAL: os.devNull, @@ -176,6 +181,16 @@ function restrictGitEnvironment( const value = process.env[key]; if (value !== undefined) environment[key] = value; } + if (authentication) { + const value = Buffer.from( + `${authentication.credential.username}:${authentication.credential.password}`, + 'utf8', + ).toString('base64'); + environment['GIT_CONFIG_COUNT'] = '1'; + environment['GIT_CONFIG_KEY_0'] = + `http.${authentication.source}.extraHeader`; + environment['GIT_CONFIG_VALUE_0'] = `Authorization: Basic ${value}`; + } return git.env(environment); } @@ -188,8 +203,12 @@ export async function cloneFromGit( installMetadata: ExtensionInstallMetadata, destination: string, signal?: AbortSignal, + credential?: GitCredential, + hideSource = false, ): Promise { - const redactedSource = redactUrlCredentials(installMetadata.source); + const redactedSource = hideSource + ? 'credentialed HTTPS Git source' + : redactUrlCredentials(installMetadata.source); try { const { simpleGit } = await loadSimpleGit(); let networkConfig: string[] = []; @@ -207,6 +226,8 @@ export async function cloneFromGit( ? createPinnedGitConfig(networkTarget.curlResolve) : []; } + const effectiveCredential = + credential ?? getGitHubCredential(installMetadata.source); const git = restrictGitEnvironment( simpleGit(destination, { ...(signal ? { abort: signal } : {}), @@ -221,13 +242,15 @@ export async function cloneFromGit( : {}), }), installMetadata.networkPolicy, + effectiveCredential + ? { source: installMetadata.source, credential: effectiveCredential } + : undefined, ); signal?.throwIfAborted(); - const sourceUrl = addGitHubToken(installMetadata.source); // On Windows, symlinks require elevated privileges by default, so we // disable them to avoid "Permission denied" errors during checkout. const symlinkValue = os.platform() === 'win32' ? 'false' : 'true'; - await git.clone(sourceUrl, './', [ + await git.clone(installMetadata.source, './', [ '-c', `core.symlinks=${symlinkValue}`, '--depth', @@ -445,6 +468,10 @@ export async function checkForExtensionUpdate( } try { if (installMetadata.type === 'git') { + const storedCredential = + installMetadata.credentialPersistence === 'stored' + ? (await resolveStoredGitCredential(extension.path)).credential + : undefined; const { simpleGit } = await loadSimpleGit(); if (installMetadata.networkPolicy === 'public') { await assertPinnedGitSupported(); @@ -452,7 +479,7 @@ export async function checkForExtensionUpdate( let remoteUrl: string; let localHash: string; if (installMetadata.gitCommit) { - remoteUrl = addGitHubToken(installMetadata.source); + remoteUrl = installMetadata.source; localHash = installMetadata.gitCommit; } else { if ( @@ -496,6 +523,8 @@ export async function checkForExtensionUpdate( : []; } signal?.throwIfAborted(); + const effectiveCredential = + storedCredential ?? getGitHubCredential(remoteUrl); const git = restrictGitEnvironment( simpleGit(extension.path, { ...(signal ? { abort: signal } : {}), @@ -510,6 +539,9 @@ export async function checkForExtensionUpdate( : {}), }), installMetadata.networkPolicy, + effectiveCredential + ? { source: remoteUrl, credential: effectiveCredential } + : undefined, ); const refToCheck = installMetadata.ref || 'HEAD'; const refPatterns = installMetadata.ref @@ -563,6 +595,7 @@ export async function checkForExtensionUpdate( return ExtensionUpdateState.UP_TO_DATE; } } catch (error) { + if (error instanceof ExtensionCredentialUnavailableError) throw error; signal?.throwIfAborted(); debugLogger.error( `Failed to check for updates for extension "${redactUrlCredentials(installMetadata.source)}": ${redactUrlCredentials(getErrorMessage(error))}`, diff --git a/packages/core/src/extension/index.ts b/packages/core/src/extension/index.ts index 96ca90d34e8..af087ec5d9f 100644 --- a/packages/core/src/extension/index.ts +++ b/packages/core/src/extension/index.ts @@ -3,6 +3,7 @@ export * from './i18n.js'; export * from './variables.js'; export * from './github.js'; export * from './extensionSettings.js'; +export * from './extension-git-credentials.js'; export * from './marketplace.js'; export * from './sourceRegistry.js'; export * from './extensionPreferences.js'; diff --git a/packages/sdk-typescript/src/daemon/types.ts b/packages/sdk-typescript/src/daemon/types.ts index 1781af0375f..fcc54e97616 100644 --- a/packages/sdk-typescript/src/daemon/types.ts +++ b/packages/sdk-typescript/src/daemon/types.ts @@ -4153,7 +4153,8 @@ export type DaemonExtensionInstallType = | 'link' | 'archive-url' | 'github-release' - | 'npm'; + | 'npm' + | 'snapshot'; export type DaemonExtensionOriginSource = | 'QwenCode' @@ -4208,6 +4209,7 @@ export interface DaemonExtensionEntry { originSource?: DaemonExtensionOriginSource; ref?: string; autoUpdate?: boolean; + credentialPersistence?: 'stored' | 'one_time'; updateState?: DaemonExtensionUpdateState; capabilities: DaemonExtensionCapabilities; details?: DaemonExtensionDetails; @@ -4223,6 +4225,7 @@ export interface DaemonWorkspaceExtensionsStatus { export interface ExtensionInstallRequest { source: string; + credentialPersistence?: 'stored' | 'one_time'; ref?: string; autoUpdate?: boolean; allowPreRelease?: boolean; @@ -4257,6 +4260,7 @@ export interface ExtensionCatalogEntry { name: string; version: string; installType?: DaemonExtensionInstallType; + credentialPersistence?: 'stored' | 'one_time'; defaultActivation: ExtensionActivationState; workspaceOverrideCount: number; } @@ -4319,6 +4323,8 @@ export interface ExtensionOperationResult { source?: string; name?: string; version?: string; + credentialPersistence?: 'stored' | 'one_time'; + credentialStorage?: 'keychain' | 'encrypted_file'; refreshed?: number; failed?: number; error?: string;