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
66 changes: 66 additions & 0 deletions docs/design/daemon-extension-local-path-install.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,66 @@
# Daemon Extension Local Path Installation

## Problem

The CLI can install an Extension from a path on the daemon host, but the daemon
REST install routes reject the same source. Callers that already placed an
Extension on the daemon filesystem must therefore start a terminal command
instead of using the asynchronous Extension operation API.

## Design

Allow an existing daemon-local path in the `source` field of both install
contracts:

- `POST /workspace/extensions/install` remains the primary-workspace
compatibility route and installs with user activation.
- `POST /extensions/install` remains the process-global V2 route and uses its
existing explicit user or workspace activation request.

No new route or SDK method is needed. `DaemonClient.installExtension()` and
`DaemonClient.installUserExtension()` already send the shared
`ExtensionInstallRequest.source` field and poll the existing operation model.
The accepted source set becomes Git, GitHub, npm, or a path that exists on the
daemon host and is classified as `local` by the existing source parser.

Local installation keeps the current CLI semantics: the Extension manager
copies the source into managed Extension storage rather than linking it. A
directory or a supported local archive path therefore uses the same parsing,
conversion, consent, staging, commit, and cleanup logic already exercised by
the CLI. The REST contract accepts only absolute local paths. This avoids
resolving a relative path against the daemon process working directory and
prevents an existing local `owner/repo` directory from shadowing a remote
GitHub shorthand.

Advertise `extension_local_path_install` so clients can distinguish daemons
that accept local paths from older daemons that reject them. The capability
covers both install routes; clients must still preflight
`extension_management_v2` before using the V2 route.

## Security and failure behavior

- Existing bearer authentication, strict mutation gating, workspace trust,
client identity validation, explicit consent, operation admission, and
runtime reconciliation remain unchanged.
- The path is resolved and read by the daemon host, not the SDK caller.
- Relative local paths are rejected; callers must send an absolute path in the
daemon host's path syntax.
- A missing path is rejected before the legacy compatibility route queues an
operation. V2 preparation reports source parsing failures through the
existing operation status contract.
- Local sources do not accept URL credentials, `ref`, or `autoUpdate`.
Existing npm registry and activation validation remains authoritative.
- Installed metadata records the local source path, matching CLI behavior.

## Test plan

- Verify the primary-workspace route queues an existing local path and prepares
it as `type: "local"`.
- Verify the V2 route queues the same source and preserves its explicit initial
activation.
- Verify missing local paths, explicit consent, client identity, workspace
trust, and unsupported remote source behavior remain unchanged.
- Verify `/capabilities` advertises `extension_local_path_install`.
- Install a minimal Extension from an absolute temporary directory through the
daemon, poll the operation to success, and confirm the installed catalog
entry.
2 changes: 2 additions & 0 deletions docs/developers/daemon/11-capabilities-versioning.md
Original file line number Diff line number Diff line change
Expand Up @@ -120,6 +120,8 @@ Workspace read-only snapshots: `workspace_mcp`, `workspace_skills`, `workspace_p

Extension management: `extension_management_v2` adds the global `/extensions/*` catalog/mutation/operation contract and the workspace activation projection. It is separate from the published `workspace_extensions` compatibility surface and from `workspace_qualified_rest_core`.

Local Extension installation: `extension_local_path_install` allows an absolute path on the daemon host in the existing `source` field of both Extension install routes. It is separate from `extension_management_v2` because the primary-workspace compatibility route also supports it, and clients must not send local paths to older daemons.

V2 Extension batch activation: `extension_batch_activation_v2` adds queued global default-activation and selected-workspace override batches to `extension_management_v2`. Clients must pre-flight it independently because older V2 daemons expose only singular activation routes.

Workspace-qualified session reads: `workspace_persisted_transcript`, `workspace_session_export`, `workspace_archived_session_export`, `workspace_session_live_state`. The active and archived export tags are independent from each other and from `session_export` and `workspace_qualified_rest_core`, so clients must pre-flight the exact storage state they intend to export. Persisted transcript paging permits an untrusted secondary under its bounded read policy; both full export paths remain trusted-only. `workspace_session_live_state` is likewise independent from `workspace_qualified_rest_core` and is trusted-only: it serves the selected runtime's memory-only live-session snapshot and catalog version and does not extend the untrusted-secondary persisted read policy to live bridge state.
Expand Down
5 changes: 4 additions & 1 deletion docs/developers/qwen-serve-protocol.md
Original file line number Diff line number Diff line change
Expand Up @@ -214,6 +214,7 @@ registry. Clients **must** gate UI off `features`, not off `mode` (per design
'workspace_display_name',
'workspace_qualified_rest_core', 'workspace_qualified_voice',
'workspace_qualified_memory', 'extension_management_v2', 'extension_git_credentials',
'extension_local_path_install',
'workspace_persisted_transcript',
'workspace_session_export', 'workspace_archived_session_export',
'workspace_session_live_state',
Expand Down Expand Up @@ -298,6 +299,8 @@ The same tag also exposes workspace-qualified project-agent CRUD at `/workspaces

`extension_git_credentials` advertises authenticated HTTPS Git installs on both `POST /workspace/extensions/install` and `POST /extensions/install`. Clients must preflight this tag before sending URL userinfo or `credentialPersistence`; older daemons reject URL credentials. The tag describes backend protocol support, not the availability of a keychain: stored mode reports the selected backend in the terminal operation result.

`extension_local_path_install` advertises daemon-local Extension sources on both `POST /workspace/extensions/install` and `POST /extensions/install`. The `source` must be an absolute path that exists on the daemon host. Relative paths remain unsupported so daemon process cwd cannot change source identity or shadow a GitHub `owner/repo` shorthand. The existing install operation copies the Extension into managed storage; it does not link the source. Clients must preflight this tag because older daemons reject local sources.

`extension_batch_activation_v2` adds `PUT /extensions/activation` and `PUT /workspaces/:workspace/extensions/activation`. Both accept 1–100 names in `extensionNames`, deduplicate them case-insensitively while preserving first-seen order, persist changed targets in one generation, and return one `202` operation handle. A target does not need to be installed when setting `enabled` or `disabled`: its name creates a desired-state declaration that is preserved when an Extension with that name is installed. The global route accepts `state: "enabled" | "disabled"`, writes V2 `defaultActivation`, and reconciles every registered runtime. The workspace route also accepts `"inherit"`, applies or clears exact overrides for the selected trusted runtime, and reconciles only that runtime. `inherit` does not declare an unknown name; an all-unknown clear reports `updated: false` and skips reconciliation. Singular activation routes remain installed-only and id-addressed.

### Extension Management V2 wire contract
Expand Down Expand Up @@ -381,7 +384,7 @@ Install requires explicit consent and an initial activation:
}
```

For workspace-only initial activation use `{ "scope": "workspace", "workspaceId": "target-workspace-id" }`; the target must exist and be trusted. Daemon installs accept GitHub, Git, and npm sources. `ref` does not apply to npm, and `registry` applies only to npm. `ref`, `autoUpdate`, `allowPreRelease`, and `registry` are optional.
For workspace-only initial activation use `{ "scope": "workspace", "workspaceId": "target-workspace-id" }`; the target must exist and be trusted. Daemon installs accept GitHub, Git, and npm sources. When `extension_local_path_install` is advertised, they also accept an absolute path that exists on the daemon host. `ref` does not apply to npm, `ref` and `autoUpdate` do not apply to local sources, and `registry` applies only to npm. `ref`, `autoUpdate`, `allowPreRelease`, and `registry` are optional.

When `extension_git_credentials` is advertised, an HTTPS Git source may include userinfo, for example `https://username:token@git.example.com/org/repository.git`. `credentialPersistence` is valid only with such a source. It is `stored` or `one_time` and defaults to `one_time` when omitted. Stored mode saves the credential through the daemon's hybrid secret storage and keeps only the clean repository URL in install metadata, so the extension remains updatable. One-time mode saves neither the repository URL nor the credential and creates a non-updatable `snapshot`; `autoUpdate: true` is rejected for this mode. Supplying the field without URL credentials, supplying invalid credentials, or using credentials with npm, archive, local, SSH, or non-Git sources returns `400`.

Expand Down
48 changes: 48 additions & 0 deletions integration-tests/cli/qwen-serve-routes.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -413,6 +413,7 @@ describe('qwen serve — capabilities envelope', () => {
'workspace_qualified_rest_core',
'extension_management_v2',
'extension_git_credentials',
'extension_local_path_install',
'workspace_persisted_transcript',
'workspace_session_export',
'workspace_archived_session_export',
Expand All @@ -423,6 +424,53 @@ describe('qwen serve — capabilities envelope', () => {
});
});

describe('qwen serve — local Extension install', () => {
it('installs an absolute daemon-local directory into managed storage', async () => {
const source = path.join(homeDir, 'local-extension-source');
mkdirSync(source, { recursive: true });
writeFileSync(
path.join(source, 'qwen-extension.json'),
JSON.stringify({
name: 'daemon-local-path-e2e',
version: '1.0.0',
}),
'utf8',
);

const accepted = await client.installExtension({ source, consent: true });
await expect
.poll(
async () =>
(await client.extensionOperationStatus(accepted.operationId)).status,
{ timeout: 10_000 },
)
.toBe('succeeded');

const operation = await client.extensionOperationStatus(
accepted.operationId,
);
expect(operation.result).toMatchObject({
status: 'installed',
source,
name: 'daemon-local-path-e2e',
});
const status = await client.workspaceExtensions();
const installed = status.extensions.find(
(extension) => extension.name === 'daemon-local-path-e2e',
);
expect(installed).toMatchObject({
source,
installType: 'local',
});
expect(installed?.path).not.toBe(source);
expect(
installed?.path.startsWith(
`${path.join(homeDir, '.qwen', 'extensions')}${path.sep}`,
),
).toBe(true);
});
});

describe('qwen serve — transcript paging route', () => {
const getTranscript = (sessionId: string, query = '') =>
fetch(`${base}/session/${sessionId}/transcript${query}`, {
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 @@ -381,6 +381,7 @@ export const SERVE_CAPABILITY_REGISTRY = {
// `workspace_extensions` contract.
extension_management_v2: { since: 'v1' },
extension_git_credentials: { since: 'v1' },
extension_local_path_install: { since: 'v1' },
// Workspace-qualified, daemon-local persisted transcript paging. The tag is
// unconditional because the route also serves a trusted single-workspace
// primary; authorization is evaluated for the selected runtime per request.
Expand Down
83 changes: 51 additions & 32 deletions packages/cli/src/serve/routes/workspace-extensions.ts
Original file line number Diff line number Diff line change
Expand Up @@ -36,6 +36,7 @@ import { isBlockedAuthProviderHost } from '../server/auth-provider-helpers.js';
import type { SendBridgeError } from '../server/error-response.js';
import type { safeBody as safeBodyType } from '../server/request-helpers.js';
import {
isPortableAbsolutePath,
requireTrustedWorkspaceRuntime,
resolveWorkspaceRuntimeFromParam,
sendGenerationClosedError,
Expand Down Expand Up @@ -72,6 +73,37 @@ const extensionArchiveBodyParser = express.raw({
limit: EXTENSION_ARCHIVE_UPLOAD_LIMIT,
});

const assertDaemonExtensionInstallSource = (
installMetadata: ExtensionInstallMetadata,
source: string,
ref: string | undefined,
autoUpdate: boolean | undefined,
): void => {
if (installMetadata.type === 'local') {
if (!path.isAbsolute(source)) {
throw new Error(
'Local extension sources must be absolute daemon-host paths; relative paths are not supported over the daemon endpoint.',
);
}
Comment thread
callmeYe marked this conversation as resolved.
if (ref || autoUpdate) {
throw new Error(
'`ref` and `autoUpdate` are not applicable for local extensions.',
);
}
return;
}
if (
installMetadata.type === 'git' ||
installMetadata.type === 'github-release' ||
installMetadata.type === 'npm'
) {
return;
}
throw new Error(
'Only GitHub, Git, npm, and absolute local path extension installs are supported over the daemon endpoint.',
);
Comment thread
callmeYe marked this conversation as resolved.
};

const parseExtensionArchiveFilename = (
value: unknown,
): { filename: string; suffix: '.zip' | '.tar.gz' } | null => {
Expand Down Expand Up @@ -1073,25 +1105,18 @@ export function registerWorkspaceExtensionRoutes(
return;
}
const localSource =
/^[A-Za-z]:[\\/]/.test(sourceValue) ||
sourceValue.startsWith('/') ||
sourceValue.startsWith('.');
isPortableAbsolutePath(sourceValue) || sourceValue.startsWith('.');
if (localSource) {
try {
const metadata = await parseInstallSource(sourceValue, {
networkPolicy: 'public',
});
if (
metadata.type !== 'git' &&
metadata.type !== 'github-release' &&
metadata.type !== 'npm'
) {
res.status(400).json({
error:
'Only GitHub, Git, and npm extension installs are supported over the daemon endpoint.',
});
return;
}
assertDaemonExtensionInstallSource(
metadata,
sourceValue,
refValue,
autoUpdateValue,
);
} catch (error) {
const message =
error instanceof Error ? error.message : 'Invalid install source';
Expand Down Expand Up @@ -1144,15 +1169,12 @@ export function registerWorkspaceExtensionRoutes(
networkPolicy: 'public',
});

if (
installMetadata.type !== 'git' &&
installMetadata.type !== 'github-release' &&
installMetadata.type !== 'npm'
) {
throw new Error(
'Only GitHub, Git, and npm extension installs are supported over the daemon endpoint.',
);
}
assertDaemonExtensionInstallSource(
installMetadata,
sourceValue,
refValue,
autoUpdateValue,
);
if (
gitCredential &&
installMetadata.type !== 'git' &&
Expand Down Expand Up @@ -1951,15 +1973,12 @@ export function registerWorkspaceExtensionRoutes(
const metadata = await parseInstallSource(sourceValue, {
networkPolicy: 'public',
});
if (
metadata.type !== 'git' &&
metadata.type !== 'github-release' &&
metadata.type !== 'npm'
) {
throw new Error(
'Only GitHub, Git, and npm extension installs are supported over the daemon endpoint.',
);
}
assertDaemonExtensionInstallSource(
metadata,
sourceValue,
typeof ref === 'string' ? ref : undefined,
typeof autoUpdate === 'boolean' ? autoUpdate : undefined,
);
Comment thread
callmeYe marked this conversation as resolved.
if (
gitCredential &&
metadata.type !== 'git' &&
Expand Down
Loading
Loading