Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
134 changes: 134 additions & 0 deletions docs/design/extension-git-credentials.md
Original file line number Diff line number Diff line change
@@ -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.<clean-repository-url>.extraHeader
GIT_CONFIG_VALUE_0=Authorization: Basic <base64(username:password)>
```

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.
10 changes: 8 additions & 2 deletions docs/developers/qwen-serve-protocol.md
Original file line number Diff line number Diff line change
Expand Up @@ -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',
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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:

Expand Down
1 change: 1 addition & 0 deletions integration-tests/cli/qwen-serve-routes.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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',
Expand Down
4 changes: 3 additions & 1 deletion packages/acp-bridge/src/status.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1078,7 +1078,8 @@ export type ServeExtensionInstallType =
| 'link'
| 'github-release'
| 'npm'
| 'archive-url';
| 'archive-url'
| 'snapshot';

export type ServeExtensionOriginSource =
| 'QwenCode'
Expand Down Expand Up @@ -1133,6 +1134,7 @@ export interface ServeExtensionEntry {
originSource?: ServeExtensionOriginSource;
ref?: string;
autoUpdate?: boolean;
credentialPersistence?: 'stored' | 'one_time';
updateState?: ServeExtensionUpdateState;
capabilities: ServeExtensionCapabilities;
details?: ServeExtensionDetails;
Expand Down
1 change: 1 addition & 0 deletions packages/cli/src/serve/capabilities.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
17 changes: 15 additions & 2 deletions packages/cli/src/serve/routes/workspace-extensions-controller.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<string, string>;
Expand Down Expand Up @@ -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,
Expand All @@ -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) : [],
Expand Down
Loading
Loading