mirror of
https://github.com/QwenLM/qwen-code.git
synced 2026-07-09 17:19:02 +00:00
feat(cli): Add workspace-qualified core REST routes (#6567)
* feat(cli): Add workspace-qualified core REST routes Co-authored-by: Qwen-Coder <qwen-coder@alibabacloud.com> * fix(cli): Preserve encoded workspace cwd selectors Co-authored-by: Qwen-Coder <qwen-coder@alibabacloud.com> * codex: address PR review feedback (#6567) Co-authored-by: Qwen-Coder <qwen-coder@alibabacloud.com> * codex: fix CI failure on PR #6567 Co-authored-by: Qwen-Coder <qwen-coder@alibabacloud.com> * codex: address PR review feedback (#6567) Co-authored-by: Qwen-Coder <qwen-coder@alibabacloud.com> * codex: address PR review feedback (#6567) Co-authored-by: Qwen-Coder <qwen-coder@alibabacloud.com> * codex: address PR review feedback (#6567) Co-authored-by: Qwen-Coder <qwen-coder@alibabacloud.com> * codex: address PR review feedback (#6567) Co-authored-by: Qwen-Coder <qwen-coder@alibabacloud.com> * codex: address PR review feedback (#6567) Co-authored-by: Qwen-Coder <qwen-coder@alibabacloud.com> * codex: address PR review feedback (#6567) Co-authored-by: Qwen-Coder <qwen-coder@alibabacloud.com> * codex: address PR review feedback (#6567) Co-authored-by: Qwen-Coder <qwen-coder@alibabacloud.com> * codex: address PR review feedback (#6567) Co-authored-by: Qwen-Coder <qwen-coder@alibabacloud.com> * codex: address PR review feedback (#6567) Co-authored-by: Qwen-Coder <qwen-coder@alibabacloud.com> * codex: fix CI failure on PR #6567 Co-authored-by: Qwen-Coder <qwen-coder@alibabacloud.com> * codex: address PR review feedback (#6567) Co-authored-by: Qwen-Coder <qwen-coder@alibabacloud.com> --------- Co-authored-by: Qwen-Coder <qwen-coder@alibabacloud.com>
This commit is contained in:
parent
c412d62981
commit
f5d36aa5f1
29 changed files with 4451 additions and 139 deletions
|
|
@ -187,6 +187,7 @@ registry. Clients **must** gate UI off `features`, not off `mode` (per design
|
|||
'non_blocking_prompt', 'session_language', 'session_rewind',
|
||||
'workspace_hooks', 'session_hooks', 'workspace_extensions',
|
||||
'session_branch', 'rate_limit', 'workspace_reload',
|
||||
'multi_workspace_sessions', 'workspace_qualified_rest_core',
|
||||
'client_mcp_over_ws', 'cdp_tunnel_over_ws', 'browser_automation_mcp']
|
||||
```
|
||||
|
||||
|
|
@ -208,6 +209,8 @@ registry. Clients **must** gate UI off `features`, not off `mode` (per design
|
|||
|
||||
`session_archive` advertises the v1 directory-state archive API: `POST /sessions/archive`, `POST /sessions/unarchive`, and `GET /workspace/:id/sessions?archiveState=active|archived`. Archived sessions cannot be loaded or resumed until they are unarchived.
|
||||
|
||||
`workspace_qualified_rest_core` advertises plural core REST routes under `/workspaces/:workspace/...`. The selector resolves as exact workspace id first, then as a URL-encoded absolute cwd after canonicalization. On single-workspace daemons, `workspaces[]` is absent unless `multi_workspace_sessions` is also advertised, so clients use `capabilities.workspaceCwd` as the cwd selector. Trust status and trust request routes are available for registered untrusted workspaces; file read routes follow the existing filesystem read policy. File write routes and all other plural core routes require a trusted workspace and return `403 { code: "untrusted_workspace" }` when the selected runtime is untrusted. This plural trust gate is intentionally stricter than some legacy primary-workspace read routes, which keep their existing compatibility behavior and are not drop-in replacements. This tag covers the core file, status, settings, permissions, trust, lifecycle, MCP control, tool toggle, memory, workspace agent CRUD, and session storage surfaces. It does not cover auth, voice, extensions, ACP/WebSocket transport, or channel-worker routing.
|
||||
|
||||
`session_lsp` advertises `GET /session/:id/lsp`, the read-only structured LSP status snapshot for daemon clients. Older daemons return `404`; pre-flight this tag before exposing remote LSP status.
|
||||
|
||||
`session_status` advertises `GET /session/:id/status`, the live bridge summary for a single session by id (`clientCount` / `hasActivePrompt` and the core fields). Older daemons return `404`; pre-flight this tag before polling a single session's status instead of scanning the full session list.
|
||||
|
|
@ -227,6 +230,10 @@ The write tag means the route contract exists; it does not mean the current
|
|||
deployment is open for anonymous mutation. Write/edit are strict mutation
|
||||
routes and require a configured bearer token even on loopback.
|
||||
|
||||
When `workspace_qualified_rest_core` is advertised, the same file surface is also available at `/workspaces/:workspace/file`, `/workspaces/:workspace/file/bytes`, `/workspaces/:workspace/stat`, `/workspaces/:workspace/list`, `/workspaces/:workspace/glob`, `/workspaces/:workspace/file/write`, and `/workspaces/:workspace/file/edit`.
|
||||
|
||||
The same tag also exposes workspace-qualified project-agent CRUD at `/workspaces/:workspace/agents` and `/workspaces/:workspace/agents/:agentType`. These plural routes only read or mutate project-level agents for the selected workspace; `global` and `user` scope requests return `400 { code: "global_scope_not_supported_for_workspace_route" }`. Workspace-less `/workspace/agents` routes retain their existing primary-workspace behavior and remain the only REST surface for user-level agent scope.
|
||||
|
||||
`daemon_status` advertises `GET /daemon/status`, the consolidated read-only
|
||||
operator diagnostic snapshot documented below.
|
||||
|
||||
|
|
@ -1333,7 +1340,7 @@ Use `/load` when the client has no history rendered (cold reconnect, picker →
|
|||
|
||||
### `GET /workspace/:id/sessions` and `GET /workspaces/:workspace/sessions`
|
||||
|
||||
List sessions whose canonical workspace matches `:id` or `:workspace`. The path parameter first resolves as an exact workspace id and then as a URL-encoded absolute cwd. `GET /workspaces/:workspace/sessions` is a plural alias with the same response shape. Primary workspaces include the existing persisted/live merge: the default list is active sessions from `chats/`; pass `archiveState=archived` to list archived sessions from `chats/archive/`. Trusted non-primary workspaces include active persisted sessions from their own `chats/` store and merge matching live summaries without duplicates; if no active persisted sessions exist, the route preserves the previous live-only cursor behavior. Non-primary workspaces still reject archived, organized, or grouped queries. Untrusted non-primary workspaces return `403 { code: "untrusted_workspace" }`. `archiveState=all` is not supported in v1. Primary and persisted-backed lists keep the existing numeric `cursor` semantics; the no-persisted non-primary live fallback keeps its existing opaque live cursor.
|
||||
List sessions whose canonical workspace matches `:id` or `:workspace`. The path parameter first resolves as an exact workspace id and then as a URL-encoded absolute cwd. `GET /workspaces/:workspace/sessions` has the same response shape but follows the plural core trust gate. Primary workspaces include the existing persisted/live merge: the default list is active sessions from `chats/`; pass `archiveState=archived` to list archived sessions from `chats/archive/`. Trusted non-primary workspaces include active persisted sessions from their own `chats/` store and merge matching live summaries without duplicates; if no active persisted sessions exist, the route preserves the previous live-only cursor behavior. Non-primary workspaces still reject archived, organized, or grouped queries. Untrusted workspaces on plural routes return `403 { code: "untrusted_workspace" }`; legacy primary routes keep their existing compatibility behavior. `archiveState=all` is not supported in v1. Primary and persisted-backed lists keep the existing numeric `cursor` semantics; the no-persisted non-primary live fallback keeps its existing opaque live cursor.
|
||||
|
||||
```bash
|
||||
curl http://127.0.0.1:4170/workspace/$(jq -rn --arg c "$PWD" '$c|@uri')/sessions
|
||||
|
|
@ -1341,6 +1348,8 @@ curl http://127.0.0.1:4170/workspace/$(jq -rn --arg c "$PWD" '$c|@uri')/sessions
|
|||
curl http://127.0.0.1:4170/workspaces/<workspace-id>/sessions
|
||||
```
|
||||
|
||||
When `workspace_qualified_rest_core` is advertised, workspace-scoped session batch operations and group CRUD are available under `/workspaces/:workspace/sessions/{delete,archive,unarchive}` and `/workspaces/:workspace/session-groups`. Workspace-less batch routes remain primary-workspace-only for compatibility.
|
||||
|
||||
Query parameters:
|
||||
|
||||
| Field | Required | Notes |
|
||||
|
|
|
|||
|
|
@ -311,6 +311,7 @@ describe('qwen serve — capabilities envelope', () => {
|
|||
'workspace_extensions',
|
||||
'session_branch',
|
||||
'workspace_reload',
|
||||
'workspace_qualified_rest_core',
|
||||
'voice_transcribe',
|
||||
]);
|
||||
});
|
||||
|
|
|
|||
|
|
@ -269,6 +269,14 @@ export const SERVE_CAPABILITY_REGISTRY = {
|
|||
// Multi-workspace sessions closed loop (issue #6378 Phase 2a). Advertised
|
||||
// only when one daemon hosts more than one registered workspace runtime.
|
||||
multi_workspace_sessions: { since: 'v1' },
|
||||
// Workspace-qualified core REST routes under `/workspaces/:workspace/...`.
|
||||
// Covers core file/status/permissions/trust/lifecycle/MCP/tool, memory,
|
||||
// workspace agent CRUD, and persisted session organization surfaces.
|
||||
// Workspace-qualified settings also require the existing
|
||||
// `workspace_settings` tag because that surface depends on settings
|
||||
// persistence. ACP/WebSocket, auth, voice, and extensions stay on their
|
||||
// existing primary-workspace routes in this phase.
|
||||
workspace_qualified_rest_core: { since: 'v1' },
|
||||
// Phase 2 "reverse tool channel" (issue #5626). A connected WS client (e.g.
|
||||
// the Chrome extension) can host an MCP server that the daemon's agent
|
||||
// calls by carrying `mcp_message` JSON-RPC frames over the daemon WS,
|
||||
|
|
|
|||
|
|
@ -348,6 +348,7 @@ function makeDaemonLog(): DaemonLogger {
|
|||
}
|
||||
|
||||
function makeHarness(opts?: {
|
||||
primaryTrusted?: boolean;
|
||||
secondaryTrusted?: boolean;
|
||||
secondaryChannelLive?: boolean;
|
||||
daemonLog?: DaemonLogger;
|
||||
|
|
@ -370,7 +371,7 @@ function makeHarness(opts?: {
|
|||
workspaceId: 'primary-id',
|
||||
workspaceCwd: PRIMARY_CWD,
|
||||
primary: true,
|
||||
trusted: true,
|
||||
trusted: opts?.primaryTrusted ?? true,
|
||||
bridge: primaryBridge,
|
||||
}),
|
||||
makeRuntime({
|
||||
|
|
@ -488,6 +489,8 @@ describe('multi-workspace session dispatch', () => {
|
|||
expect(unknownRes.status).toBe(400);
|
||||
expect(unknownRes.body.code).toBe('workspace_mismatch');
|
||||
expect(unknownRes.body.workspaceCount).toBe(2);
|
||||
expect(unknownRes.body.boundWorkspace).toBe(PRIMARY_CWD);
|
||||
expect(unknownRes.body.requestedWorkspace).toBe(UNKNOWN_CWD);
|
||||
expect(unknown.primaryBridge.spawnCalls).toEqual([]);
|
||||
expect(unknown.secondaryBridge.spawnCalls).toEqual([]);
|
||||
|
||||
|
|
@ -500,6 +503,9 @@ describe('multi-workspace session dispatch', () => {
|
|||
|
||||
expect(untrustedRes.status).toBe(403);
|
||||
expect(untrustedRes.body.code).toBe('untrusted_workspace');
|
||||
expect(untrustedRes.body.error).toBe('Workspace is not trusted.');
|
||||
expect(untrustedRes.body.workspaceCwd).toBe(SECONDARY_CWD);
|
||||
expect(untrustedRes.body.workspaceId).toBe('secondary-id');
|
||||
expect(untrusted.secondaryBridge.spawnCalls).toEqual([]);
|
||||
expect(daemonLog.warn).toHaveBeenCalledWith(
|
||||
'session routing failed',
|
||||
|
|
@ -520,7 +526,10 @@ describe('multi-workspace session dispatch', () => {
|
|||
|
||||
expect(res.status).toBe(403);
|
||||
expect(res.body.code).toBe('untrusted_workspace');
|
||||
expect(res.body.error).toBe('Workspace is not trusted.');
|
||||
expect(res.body.sessionId).toBe('secondary-session');
|
||||
expect(res.body.workspaceCwd).toBe(SECONDARY_CWD);
|
||||
expect(res.body.workspaceId).toBe('secondary-id');
|
||||
expect(secondaryBridge.promptCalls).toEqual([]);
|
||||
});
|
||||
|
||||
|
|
@ -720,6 +729,8 @@ describe('multi-workspace session dispatch', () => {
|
|||
expect(unknownRes.status).toBe(400);
|
||||
expect(unknownRes.body.code).toBe('workspace_mismatch');
|
||||
expect(unknownRes.body.workspaceCount).toBe(2);
|
||||
expect(unknownRes.body.boundWorkspace).toBe(PRIMARY_CWD);
|
||||
expect(unknownRes.body.requestedWorkspace).toBe(UNKNOWN_CWD);
|
||||
expect(unknown.primaryBridge.restoreCalls).toEqual([]);
|
||||
expect(unknown.secondaryBridge.restoreCalls).toEqual([]);
|
||||
|
||||
|
|
@ -732,6 +743,9 @@ describe('multi-workspace session dispatch', () => {
|
|||
|
||||
expect(untrustedRes.status).toBe(403);
|
||||
expect(untrustedRes.body.code).toBe('untrusted_workspace');
|
||||
expect(untrustedRes.body.error).toBe('Workspace is not trusted.');
|
||||
expect(untrustedRes.body.workspaceCwd).toBe(SECONDARY_CWD);
|
||||
expect(untrustedRes.body.workspaceId).toBe('secondary-id');
|
||||
expect(untrusted.primaryBridge.restoreCalls).toEqual([]);
|
||||
expect(untrusted.secondaryBridge.restoreCalls).toEqual([]);
|
||||
expect(daemonLog.warn).toHaveBeenCalledWith(
|
||||
|
|
@ -847,6 +861,8 @@ describe('multi-workspace session dispatch', () => {
|
|||
expect(unknown.status).toBe(400);
|
||||
expect(unknown.body.code).toBe('workspace_mismatch');
|
||||
expect(unknown.body.workspaceCount).toBe(2);
|
||||
expect(unknown.body.boundWorkspace).toBe(PRIMARY_CWD);
|
||||
expect(unknown.body.requestedWorkspace).toBe(UNKNOWN_CWD);
|
||||
});
|
||||
|
||||
it('lists active persisted non-primary sessions by encoded workspace cwd', async () => {
|
||||
|
|
@ -989,7 +1005,9 @@ describe('multi-workspace session dispatch', () => {
|
|||
|
||||
expect(res.status).toBe(403);
|
||||
expect(res.body.code).toBe('untrusted_workspace');
|
||||
expect(res.body.workspaceCwd).toBe(SECONDARY_CWD);
|
||||
expect(res.body.error).toBe('Workspace is not trusted.');
|
||||
expect(res.body).not.toHaveProperty('workspaceCwd');
|
||||
expect(res.body).not.toHaveProperty('workspaceId');
|
||||
expect(secondaryBridge.listCalls).toEqual([]);
|
||||
expect(daemonLog.warn).toHaveBeenCalledWith(
|
||||
'session routing failed',
|
||||
|
|
@ -1001,6 +1019,147 @@ describe('multi-workspace session dispatch', () => {
|
|||
);
|
||||
});
|
||||
|
||||
it('rejects untrusted primary workspace on plural session routes', async () => {
|
||||
const daemonLog = makeDaemonLog();
|
||||
const { app } = makeHarness({
|
||||
primaryTrusted: false,
|
||||
daemonLog,
|
||||
});
|
||||
|
||||
const res = await request(app)
|
||||
.get('/workspaces/primary-id/session-groups')
|
||||
.set('Host', host());
|
||||
|
||||
expect(res.status).toBe(403);
|
||||
expect(res.body.code).toBe('untrusted_workspace');
|
||||
expect(res.body.error).toBe('Workspace is not trusted.');
|
||||
expect(res.body).not.toHaveProperty('workspaceCwd');
|
||||
expect(res.body).not.toHaveProperty('workspaceId');
|
||||
expect(daemonLog.warn).toHaveBeenCalledWith(
|
||||
'session routing failed',
|
||||
expect.objectContaining({
|
||||
route: 'GET /workspaces/:workspace/session-groups',
|
||||
resolutionKind: 'untrusted_workspace',
|
||||
workspaceCwd: PRIMARY_CWD,
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it('routes plural batch archive, unarchive, and delete to the selected workspace', async () => {
|
||||
await withRuntimeDir(async () => {
|
||||
const archiveId = '550e8400-e29b-41d4-a716-446655440120';
|
||||
const deleteId = '550e8400-e29b-41d4-a716-446655440121';
|
||||
await writeStoredSession({
|
||||
sessionId: archiveId,
|
||||
cwd: SECONDARY_CWD,
|
||||
timestamp: '2026-07-08T00:10:00.000Z',
|
||||
prompt: 'secondary archive target',
|
||||
mtime: new Date('2026-07-08T00:10:00.000Z'),
|
||||
});
|
||||
await writeStoredSession({
|
||||
sessionId: deleteId,
|
||||
cwd: SECONDARY_CWD,
|
||||
timestamp: '2026-07-08T00:11:00.000Z',
|
||||
prompt: 'secondary delete target',
|
||||
mtime: new Date('2026-07-08T00:11:00.000Z'),
|
||||
});
|
||||
const { app, primaryBridge, secondaryBridge } = makeHarness({
|
||||
secondarySummaries: [],
|
||||
});
|
||||
|
||||
const archived = await request(app)
|
||||
.post('/workspaces/secondary-id/sessions/archive')
|
||||
.set('Host', host())
|
||||
.send({ sessionIds: [archiveId] })
|
||||
.expect(200);
|
||||
expect(archived.body).toMatchObject({
|
||||
archived: [archiveId],
|
||||
alreadyArchived: [],
|
||||
notFound: [],
|
||||
errors: [],
|
||||
});
|
||||
|
||||
const unarchived = await request(app)
|
||||
.post('/workspaces/secondary-id/sessions/unarchive')
|
||||
.set('Host', host())
|
||||
.send({ sessionIds: [archiveId] })
|
||||
.expect(200);
|
||||
expect(unarchived.body).toMatchObject({
|
||||
unarchived: [archiveId],
|
||||
alreadyActive: [],
|
||||
notFound: [],
|
||||
errors: [],
|
||||
});
|
||||
|
||||
const deleted = await request(app)
|
||||
.post('/workspaces/secondary-id/sessions/delete')
|
||||
.set('Host', host())
|
||||
.send({ sessionIds: [deleteId] })
|
||||
.expect(200);
|
||||
expect(deleted.body).toMatchObject({
|
||||
removed: [deleteId],
|
||||
notFound: [],
|
||||
errors: [],
|
||||
});
|
||||
expect(primaryBridge.closeCalls).toEqual([]);
|
||||
expect(secondaryBridge.closeCalls).toEqual([archiveId, deleteId]);
|
||||
});
|
||||
});
|
||||
|
||||
it('routes plural session group CRUD to the selected workspace', async () => {
|
||||
await withRuntimeDir(async () => {
|
||||
const { app } = makeHarness();
|
||||
|
||||
const created = await request(app)
|
||||
.post('/workspaces/secondary-id/session-groups')
|
||||
.set('Host', host())
|
||||
.send({ name: 'Secondary Group', color: 'blue' })
|
||||
.expect(201);
|
||||
expect(created.body.group).toMatchObject({
|
||||
name: 'Secondary Group',
|
||||
color: 'blue',
|
||||
});
|
||||
const groupId = created.body.group.id as string;
|
||||
|
||||
const secondaryList = await request(app)
|
||||
.get('/workspaces/secondary-id/session-groups')
|
||||
.set('Host', host())
|
||||
.expect(200);
|
||||
expect(
|
||||
(secondaryList.body.groups as Array<{ id: string }>).map(
|
||||
(group) => group.id,
|
||||
),
|
||||
).toContain(groupId);
|
||||
|
||||
const primaryList = await request(app)
|
||||
.get('/workspaces/primary-id/session-groups')
|
||||
.set('Host', host())
|
||||
.expect(200);
|
||||
expect(
|
||||
(primaryList.body.groups as Array<{ id: string }>).map(
|
||||
(group) => group.id,
|
||||
),
|
||||
).not.toContain(groupId);
|
||||
|
||||
const updated = await request(app)
|
||||
.patch(`/workspaces/secondary-id/session-groups/${groupId}`)
|
||||
.set('Host', host())
|
||||
.send({ name: 'Secondary Renamed', order: 10 })
|
||||
.expect(200);
|
||||
expect(updated.body.group).toMatchObject({
|
||||
id: groupId,
|
||||
name: 'Secondary Renamed',
|
||||
order: 10,
|
||||
});
|
||||
|
||||
const deleted = await request(app)
|
||||
.delete(`/workspaces/secondary-id/session-groups/${groupId}`)
|
||||
.set('Host', host())
|
||||
.expect(200);
|
||||
expect(deleted.body).toEqual({ deleted: true });
|
||||
});
|
||||
});
|
||||
|
||||
it('pages live non-primary workspace sessions with a stable cursor', async () => {
|
||||
const { app } = makeHarness({
|
||||
secondarySummaries: [
|
||||
|
|
|
|||
|
|
@ -10,6 +10,7 @@ import type {
|
|||
WorkspaceRegistry,
|
||||
WorkspaceRuntime,
|
||||
} from '../workspace-registry.js';
|
||||
import { sendUntrustedWorkspaceResponse } from '../workspace-route-runtime.js';
|
||||
|
||||
export function requireSessionRuntime(opts: {
|
||||
sessionId: string;
|
||||
|
|
@ -43,12 +44,10 @@ export function requireSessionRuntime(opts: {
|
|||
workspaceCwd: runtime.workspaceCwd,
|
||||
...details,
|
||||
});
|
||||
res.status(403).json({
|
||||
error: `Workspace "${runtime.workspaceCwd}" is not trusted.`,
|
||||
code: 'untrusted_workspace',
|
||||
sendUntrustedWorkspaceResponse(res, {
|
||||
sessionId,
|
||||
workspaceId: runtime.workspaceId,
|
||||
workspaceCwd: runtime.workspaceCwd,
|
||||
workspaceId: runtime.workspaceId,
|
||||
});
|
||||
return undefined;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -63,6 +63,10 @@ import {
|
|||
} from '../server/session-export.js';
|
||||
import { createSessionOrganizationService } from '../session-organization-helpers.js';
|
||||
import { requireSessionRuntime } from './session-runtime.js';
|
||||
import {
|
||||
resolveWorkspaceRuntimeFromParam,
|
||||
sendUntrustedWorkspaceResponse,
|
||||
} from '../workspace-route-runtime.js';
|
||||
import type {
|
||||
WorkspaceRegistry,
|
||||
WorkspaceRuntime,
|
||||
|
|
@ -223,10 +227,9 @@ export function registerSessionRoutes(
|
|||
workspaceId: runtime.workspaceId,
|
||||
workspaceCwd: runtime.workspaceCwd,
|
||||
});
|
||||
res.status(403).json({
|
||||
error: `Workspace "${runtime.workspaceCwd}" is not trusted.`,
|
||||
code: 'untrusted_workspace',
|
||||
sendUntrustedWorkspaceResponse(res, {
|
||||
workspaceCwd: runtime.workspaceCwd,
|
||||
workspaceId: runtime.workspaceId,
|
||||
});
|
||||
return undefined;
|
||||
}
|
||||
|
|
@ -285,6 +288,29 @@ export function registerSessionRoutes(
|
|||
);
|
||||
};
|
||||
|
||||
const requireTrustedRuntimeForWorkspaceRoute = (
|
||||
req: Request,
|
||||
res: Response,
|
||||
route: string,
|
||||
): WorkspaceRuntime | null => {
|
||||
const runtime = resolveWorkspaceRuntimeFromParam(
|
||||
workspaceRegistry,
|
||||
req,
|
||||
res,
|
||||
'workspace',
|
||||
);
|
||||
if (runtime === null) return null;
|
||||
if (!runtime.trusted) {
|
||||
logSessionRoutingFailure(route, 'untrusted_workspace', {
|
||||
workspaceId: runtime.workspaceId,
|
||||
workspaceCwd: runtime.workspaceCwd,
|
||||
});
|
||||
sendUntrustedWorkspaceResponse(res);
|
||||
return null;
|
||||
}
|
||||
return runtime;
|
||||
};
|
||||
|
||||
const sendAmbiguousSessionOwner = (
|
||||
res: Response,
|
||||
route: string,
|
||||
|
|
@ -414,9 +440,7 @@ export function registerSessionRoutes(
|
|||
workspaceId: runtime.workspaceId,
|
||||
workspaceCwd: runtime.workspaceCwd,
|
||||
});
|
||||
res.status(403).json({
|
||||
error: `Workspace "${runtime.workspaceCwd}" is not trusted.`,
|
||||
code: 'untrusted_workspace',
|
||||
sendUntrustedWorkspaceResponse(res, {
|
||||
workspaceCwd: runtime.workspaceCwd,
|
||||
workspaceId: runtime.workspaceId,
|
||||
});
|
||||
|
|
@ -562,12 +586,7 @@ export function registerSessionRoutes(
|
|||
}
|
||||
const key = canonicalizeWorkspace(workspaceCwd);
|
||||
if (key !== boundWorkspace) {
|
||||
res.status(400).json({
|
||||
error: `Workspace mismatch: daemon is bound to "${boundWorkspace}"`,
|
||||
code: 'workspace_mismatch',
|
||||
boundWorkspace,
|
||||
requestedWorkspace: key,
|
||||
});
|
||||
sendWorkspaceMismatch(res, key);
|
||||
return null;
|
||||
}
|
||||
return key;
|
||||
|
|
@ -1532,6 +1551,98 @@ export function registerSessionRoutes(
|
|||
}
|
||||
});
|
||||
|
||||
app.post(
|
||||
'/workspaces/:workspace/sessions/delete',
|
||||
mutate(),
|
||||
async (req, res) => {
|
||||
const route = 'POST /workspaces/:workspace/sessions/delete';
|
||||
const runtime = requireTrustedRuntimeForWorkspaceRoute(req, res, route);
|
||||
if (!runtime) return;
|
||||
const clientId = parseClientIdHeader(req, res);
|
||||
if (clientId === null) return;
|
||||
const uniqueIds = parseSessionIdsBody(req, res);
|
||||
if (uniqueIds === undefined) return;
|
||||
try {
|
||||
const service = new SessionService(runtime.workspaceCwd);
|
||||
const result = await deleteDaemonSessions({
|
||||
sessionIds: uniqueIds,
|
||||
service,
|
||||
bridge: runtime.bridge,
|
||||
coordinator: archiveCoordinator,
|
||||
onError: ({ phase, sessionId, error }) => {
|
||||
writeStderrLine(
|
||||
`qwen serve: ${phase}Session failed for ${safeLogValue(sessionId)}: ${safeLogValue(error)}`,
|
||||
);
|
||||
},
|
||||
});
|
||||
res.status(200).json(result);
|
||||
} catch (err) {
|
||||
sendBridgeError(res, err, { route });
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
app.post(
|
||||
'/workspaces/:workspace/sessions/archive',
|
||||
mutate(),
|
||||
async (req, res) => {
|
||||
const route = 'POST /workspaces/:workspace/sessions/archive';
|
||||
const runtime = requireTrustedRuntimeForWorkspaceRoute(req, res, route);
|
||||
if (!runtime) return;
|
||||
const uniqueIds = parseSessionIdsBody(req, res);
|
||||
if (uniqueIds === undefined) return;
|
||||
const service = new SessionService(runtime.workspaceCwd, {
|
||||
onWarning: logSessionArchiveWarning,
|
||||
});
|
||||
try {
|
||||
const result = await archiveDaemonSessions({
|
||||
sessionIds: uniqueIds,
|
||||
service,
|
||||
bridge: runtime.bridge,
|
||||
coordinator: archiveCoordinator,
|
||||
});
|
||||
res.status(200).json({
|
||||
archived: result.archived,
|
||||
alreadyArchived: result.alreadyArchived,
|
||||
notFound: result.notFound,
|
||||
errors: serializeSessionErrors(result.errors),
|
||||
});
|
||||
} catch (err) {
|
||||
sendBridgeError(res, err, { route });
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
app.post(
|
||||
'/workspaces/:workspace/sessions/unarchive',
|
||||
mutate(),
|
||||
async (req, res) => {
|
||||
const route = 'POST /workspaces/:workspace/sessions/unarchive';
|
||||
const runtime = requireTrustedRuntimeForWorkspaceRoute(req, res, route);
|
||||
if (!runtime) return;
|
||||
const uniqueIds = parseSessionIdsBody(req, res);
|
||||
if (uniqueIds === undefined) return;
|
||||
const service = new SessionService(runtime.workspaceCwd, {
|
||||
onWarning: logSessionArchiveWarning,
|
||||
});
|
||||
try {
|
||||
const result = await unarchiveDaemonSessions({
|
||||
sessionIds: uniqueIds,
|
||||
service,
|
||||
coordinator: archiveCoordinator,
|
||||
});
|
||||
res.status(200).json({
|
||||
unarchived: result.unarchived,
|
||||
alreadyActive: result.alreadyActive,
|
||||
notFound: result.notFound,
|
||||
errors: serializeSessionErrors(result.errors),
|
||||
});
|
||||
} catch (err) {
|
||||
sendBridgeError(res, err, { route });
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
app.patch(
|
||||
'/session/:id/metadata',
|
||||
mutate({ strict: true }),
|
||||
|
|
@ -1730,6 +1841,95 @@ export function registerSessionRoutes(
|
|||
},
|
||||
);
|
||||
|
||||
app.get('/workspaces/:workspace/session-groups', async (req, res) => {
|
||||
const route = 'GET /workspaces/:workspace/session-groups';
|
||||
const runtime = requireTrustedRuntimeForWorkspaceRoute(req, res, route);
|
||||
if (!runtime) return;
|
||||
try {
|
||||
res
|
||||
.status(200)
|
||||
.json(
|
||||
await createSessionOrganizationService(
|
||||
runtime.workspaceCwd,
|
||||
).listGroups(),
|
||||
);
|
||||
} catch (err) {
|
||||
sendBridgeError(res, err, { route });
|
||||
}
|
||||
});
|
||||
|
||||
app.post(
|
||||
'/workspaces/:workspace/session-groups',
|
||||
mutate(),
|
||||
async (req, res) => {
|
||||
const route = 'POST /workspaces/:workspace/session-groups';
|
||||
const runtime = requireTrustedRuntimeForWorkspaceRoute(req, res, route);
|
||||
if (!runtime) return;
|
||||
const body = safeBody(req);
|
||||
try {
|
||||
const group = await createSessionOrganizationService(
|
||||
runtime.workspaceCwd,
|
||||
).createGroup({
|
||||
name: body['name'] as string,
|
||||
color: body['color'] as SessionGroupColor,
|
||||
});
|
||||
res.status(201).json({ group });
|
||||
} catch (err) {
|
||||
if (sendSessionOrganizationError(res, err)) return;
|
||||
sendBridgeError(res, err, { route });
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
app.patch(
|
||||
'/workspaces/:workspace/session-groups/:groupId',
|
||||
mutate(),
|
||||
async (req, res) => {
|
||||
const route = 'PATCH /workspaces/:workspace/session-groups/:groupId';
|
||||
const runtime = requireTrustedRuntimeForWorkspaceRoute(req, res, route);
|
||||
if (!runtime) return;
|
||||
const body = safeBody(req);
|
||||
try {
|
||||
const group = await createSessionOrganizationService(
|
||||
runtime.workspaceCwd,
|
||||
).updateGroup(req.params['groupId'] ?? '', {
|
||||
...(Object.prototype.hasOwnProperty.call(body, 'name')
|
||||
? { name: body['name'] as string }
|
||||
: {}),
|
||||
...(Object.prototype.hasOwnProperty.call(body, 'color')
|
||||
? { color: body['color'] as SessionGroupColor }
|
||||
: {}),
|
||||
...(Object.prototype.hasOwnProperty.call(body, 'order')
|
||||
? { order: body['order'] as number }
|
||||
: {}),
|
||||
});
|
||||
res.status(200).json({ group });
|
||||
} catch (err) {
|
||||
if (sendSessionOrganizationError(res, err)) return;
|
||||
sendBridgeError(res, err, { route });
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
app.delete(
|
||||
'/workspaces/:workspace/session-groups/:groupId',
|
||||
mutate(),
|
||||
async (req, res) => {
|
||||
const route = 'DELETE /workspaces/:workspace/session-groups/:groupId';
|
||||
const runtime = requireTrustedRuntimeForWorkspaceRoute(req, res, route);
|
||||
if (!runtime) return;
|
||||
try {
|
||||
const deleted = await createSessionOrganizationService(
|
||||
runtime.workspaceCwd,
|
||||
).deleteGroup(req.params['groupId'] ?? '');
|
||||
res.status(200).json({ deleted });
|
||||
} catch (err) {
|
||||
if (sendSessionOrganizationError(res, err)) return;
|
||||
sendBridgeError(res, err, { route });
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
const listWorkspaceSessionsHandler =
|
||||
(paramName: string): RequestHandler =>
|
||||
async (req, res) => {
|
||||
|
|
@ -1740,19 +1940,26 @@ export function registerSessionRoutes(
|
|||
// Express decodes URL-encoded path params automatically; clients pass
|
||||
// the absolute workspace cwd encoded (e.g.
|
||||
// GET /workspace/%2Fwork%2Fa/sessions).
|
||||
const runtime = resolveRuntimeFromWorkspaceParam(req, res, paramName);
|
||||
const runtime =
|
||||
paramName === 'workspace'
|
||||
? resolveWorkspaceRuntimeFromParam(
|
||||
workspaceRegistry,
|
||||
req,
|
||||
res,
|
||||
'workspace',
|
||||
)
|
||||
: resolveRuntimeFromWorkspaceParam(req, res, paramName);
|
||||
if (runtime === null) return;
|
||||
if (!runtime.primary && !runtime.trusted) {
|
||||
if (
|
||||
paramName === 'workspace'
|
||||
? !runtime.trusted
|
||||
: !runtime.primary && !runtime.trusted
|
||||
) {
|
||||
logSessionRoutingFailure(route, 'untrusted_workspace', {
|
||||
workspaceId: runtime.workspaceId,
|
||||
workspaceCwd: runtime.workspaceCwd,
|
||||
});
|
||||
res.status(403).json({
|
||||
error: `Workspace "${runtime.workspaceCwd}" is not trusted.`,
|
||||
code: 'untrusted_workspace',
|
||||
workspaceCwd: runtime.workspaceCwd,
|
||||
workspaceId: runtime.workspaceId,
|
||||
});
|
||||
sendUntrustedWorkspaceResponse(res);
|
||||
return;
|
||||
}
|
||||
const key = runtime.workspaceCwd;
|
||||
|
|
|
|||
|
|
@ -12,10 +12,12 @@ import { afterEach, beforeEach, describe, expect, it } from 'vitest';
|
|||
import request from 'supertest';
|
||||
import { Ignore } from '@qwen-code/qwen-code-core';
|
||||
import { createServeApp } from '../server.js';
|
||||
import { workspaceRelative } from './workspace-file-read.js';
|
||||
import {
|
||||
canonicalizeWorkspace,
|
||||
createWorkspaceFileSystemFactory,
|
||||
} from '../fs/index.js';
|
||||
import type { Request } from 'express';
|
||||
import type { BridgeEvent } from '@qwen-code/acp-bridge/eventBus';
|
||||
import type { ServeOptions } from '../types.js';
|
||||
|
||||
|
|
@ -67,6 +69,35 @@ function rawHash(data: string | Buffer): `sha256:${string}` {
|
|||
return `sha256:${createHash('sha256').update(data).digest('hex')}`;
|
||||
}
|
||||
|
||||
describe('workspaceRelative', () => {
|
||||
let scratch: string;
|
||||
|
||||
beforeEach(async () => {
|
||||
scratch = await fsp.mkdtemp(
|
||||
path.join(
|
||||
os.tmpdir(),
|
||||
`qwen-route-rel-${randomBytes(4).toString('hex')}-`,
|
||||
),
|
||||
);
|
||||
});
|
||||
|
||||
afterEach(async () => {
|
||||
await fsp.rm(scratch, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
it('rejects paths that remain outside the workspace after canonicalization', async () => {
|
||||
const workspace = path.join(scratch, 'ws');
|
||||
await fsp.mkdir(workspace);
|
||||
const req = {
|
||||
app: { locals: { boundWorkspace: workspace } },
|
||||
} as unknown as Request;
|
||||
|
||||
expect(() =>
|
||||
workspaceRelative(req, path.join(scratch, 'outside.txt')),
|
||||
).toThrow(expect.objectContaining({ kind: 'path_outside_workspace' }));
|
||||
});
|
||||
});
|
||||
|
||||
describe('GET /file', () => {
|
||||
let h: Harness;
|
||||
beforeEach(async () => {
|
||||
|
|
|
|||
|
|
@ -8,11 +8,18 @@ import * as path from 'node:path';
|
|||
import type { Application, Request, Response } from 'express';
|
||||
import { writeStderrLine } from '../../utils/stdioHelpers.js';
|
||||
import {
|
||||
FsError,
|
||||
MAX_READ_BYTES,
|
||||
canonicalizeWorkspace,
|
||||
isFsError,
|
||||
type FsError,
|
||||
type WorkspaceFileSystemFactory,
|
||||
} from '../fs/index.js';
|
||||
import {
|
||||
getWorkspaceRouteContext,
|
||||
resolveWorkspaceRuntimeFromParam,
|
||||
setWorkspaceRouteContext,
|
||||
} from '../workspace-route-runtime.js';
|
||||
import type { WorkspaceRegistry } from '../workspace-registry.js';
|
||||
|
||||
/**
|
||||
* Hard cap on entries returned from `GET /list`. The boundary probes
|
||||
|
|
@ -168,10 +175,18 @@ function requireStringQuery(
|
|||
* by a custom embed without injecting `deps.fsFactory`, which is a
|
||||
* deployment misconfiguration the route can't recover from.
|
||||
*/
|
||||
function routeName(req: Request, legacyRoute: string): string {
|
||||
const context = getWorkspaceRouteContext(req);
|
||||
if (!context) return legacyRoute;
|
||||
return `${context.routePrefix}${legacyRoute.slice('GET '.length)}`;
|
||||
}
|
||||
|
||||
function getFsFactory(
|
||||
req: Request,
|
||||
res: Response,
|
||||
): WorkspaceFileSystemFactory | null {
|
||||
const context = getWorkspaceRouteContext(req);
|
||||
if (context) return context.runtime.routeFileSystemFactory;
|
||||
const factory = (req.app.locals as { fsFactory?: WorkspaceFileSystemFactory })
|
||||
.fsFactory;
|
||||
if (!factory) {
|
||||
|
|
@ -202,7 +217,7 @@ async function handleGetFile(
|
|||
res: Response,
|
||||
deps: RegisterDeps,
|
||||
): Promise<void> {
|
||||
const ROUTE = 'GET /file';
|
||||
const ROUTE = routeName(req, 'GET /file');
|
||||
const factory = getFsFactory(req, res);
|
||||
if (!factory) return;
|
||||
const clientId = deps.parseClientId(req, res);
|
||||
|
|
@ -272,7 +287,7 @@ async function handleGetFileBytes(
|
|||
res: Response,
|
||||
deps: RegisterDeps,
|
||||
): Promise<void> {
|
||||
const ROUTE = 'GET /file/bytes';
|
||||
const ROUTE = routeName(req, 'GET /file/bytes');
|
||||
const factory = getFsFactory(req, res);
|
||||
if (!factory) return;
|
||||
const clientId = deps.parseClientId(req, res);
|
||||
|
|
@ -334,7 +349,7 @@ async function handleGetStat(
|
|||
res: Response,
|
||||
deps: RegisterDeps,
|
||||
): Promise<void> {
|
||||
const ROUTE = 'GET /stat';
|
||||
const ROUTE = routeName(req, 'GET /stat');
|
||||
const factory = getFsFactory(req, res);
|
||||
if (!factory) return;
|
||||
const clientId = deps.parseClientId(req, res);
|
||||
|
|
@ -366,7 +381,7 @@ async function handleGetList(
|
|||
res: Response,
|
||||
deps: RegisterDeps,
|
||||
): Promise<void> {
|
||||
const ROUTE = 'GET /list';
|
||||
const ROUTE = routeName(req, 'GET /list');
|
||||
const factory = getFsFactory(req, res);
|
||||
if (!factory) return;
|
||||
const clientId = deps.parseClientId(req, res);
|
||||
|
|
@ -408,7 +423,7 @@ async function handleGetGlob(
|
|||
res: Response,
|
||||
deps: RegisterDeps,
|
||||
): Promise<void> {
|
||||
const ROUTE = 'GET /glob';
|
||||
const ROUTE = routeName(req, 'GET /glob');
|
||||
const factory = getFsFactory(req, res);
|
||||
if (!factory) return;
|
||||
const clientId = deps.parseClientId(req, res);
|
||||
|
|
@ -502,16 +517,39 @@ async function handleGetGlob(
|
|||
* `/file`, `/stat`, `/list`, and `/glob` response paths.
|
||||
*/
|
||||
export function workspaceRelative(req: Request, resolved: string): string {
|
||||
const boundWorkspace = (req.app.locals as { boundWorkspace?: string })
|
||||
.boundWorkspace;
|
||||
const boundWorkspace =
|
||||
getWorkspaceRouteContext(req)?.runtime.workspaceCwd ??
|
||||
(req.app.locals as { boundWorkspace?: string }).boundWorkspace;
|
||||
if (!boundWorkspace) {
|
||||
throw new Error('bound workspace is not configured');
|
||||
}
|
||||
const rel = path.relative(boundWorkspace, resolved);
|
||||
let rel = path.relative(boundWorkspace, resolved);
|
||||
if (isOutsideWorkspaceRelative(rel)) {
|
||||
try {
|
||||
rel = path.relative(canonicalizeWorkspace(boundWorkspace), resolved);
|
||||
} catch {
|
||||
throw new FsError(
|
||||
'path_outside_workspace',
|
||||
'path resolved outside workspace',
|
||||
);
|
||||
}
|
||||
}
|
||||
if (isOutsideWorkspaceRelative(rel)) {
|
||||
throw new FsError(
|
||||
'path_outside_workspace',
|
||||
'path resolved outside workspace',
|
||||
);
|
||||
}
|
||||
if (rel === '') return '.';
|
||||
return path.sep === '/' ? rel : rel.split(path.sep).join('/');
|
||||
}
|
||||
|
||||
function isOutsideWorkspaceRelative(rel: string): boolean {
|
||||
return (
|
||||
rel === '..' || rel.startsWith(`..${path.sep}`) || path.isAbsolute(rel)
|
||||
);
|
||||
}
|
||||
|
||||
export function registerWorkspaceFileReadRoutes(
|
||||
app: Application,
|
||||
deps: RegisterDeps,
|
||||
|
|
@ -522,3 +560,42 @@ export function registerWorkspaceFileReadRoutes(
|
|||
app.get('/list', (req, res) => handleGetList(req, res, deps));
|
||||
app.get('/glob', (req, res) => handleGetGlob(req, res, deps));
|
||||
}
|
||||
|
||||
export function registerWorkspaceQualifiedFileReadRoutes(
|
||||
app: Application,
|
||||
deps: RegisterDeps & { workspaceRegistry: WorkspaceRegistry },
|
||||
): void {
|
||||
const resolve = (req: Request, res: Response): boolean => {
|
||||
const runtime = resolveWorkspaceRuntimeFromParam(
|
||||
deps.workspaceRegistry,
|
||||
req,
|
||||
res,
|
||||
);
|
||||
if (!runtime) return false;
|
||||
setWorkspaceRouteContext(req, {
|
||||
runtime,
|
||||
routePrefix: 'GET /workspaces/:workspace',
|
||||
});
|
||||
return true;
|
||||
};
|
||||
app.get('/workspaces/:workspace/file', (req, res) => {
|
||||
if (!resolve(req, res)) return;
|
||||
void handleGetFile(req, res, deps);
|
||||
});
|
||||
app.get('/workspaces/:workspace/file/bytes', (req, res) => {
|
||||
if (!resolve(req, res)) return;
|
||||
void handleGetFileBytes(req, res, deps);
|
||||
});
|
||||
app.get('/workspaces/:workspace/stat', (req, res) => {
|
||||
if (!resolve(req, res)) return;
|
||||
void handleGetStat(req, res, deps);
|
||||
});
|
||||
app.get('/workspaces/:workspace/list', (req, res) => {
|
||||
if (!resolve(req, res)) return;
|
||||
void handleGetList(req, res, deps);
|
||||
});
|
||||
app.get('/workspaces/:workspace/glob', (req, res) => {
|
||||
if (!resolve(req, res)) return;
|
||||
void handleGetGlob(req, res, deps);
|
||||
});
|
||||
}
|
||||
|
|
|
|||
|
|
@ -17,6 +17,13 @@ import {
|
|||
sendFsError,
|
||||
workspaceRelative,
|
||||
} from './workspace-file-read.js';
|
||||
import {
|
||||
getWorkspaceRouteContext,
|
||||
requireTrustedWorkspaceRuntime,
|
||||
resolveWorkspaceRuntimeFromParam,
|
||||
setWorkspaceRouteContext,
|
||||
} from '../workspace-route-runtime.js';
|
||||
import type { WorkspaceRegistry } from '../workspace-registry.js';
|
||||
|
||||
interface RegisterDeps {
|
||||
bridge: AcpSessionBridge;
|
||||
|
|
@ -29,6 +36,8 @@ function getFsFactory(
|
|||
req: Request,
|
||||
res: Response,
|
||||
): WorkspaceFileSystemFactory | null {
|
||||
const context = getWorkspaceRouteContext(req);
|
||||
if (context) return context.runtime.routeFileSystemFactory;
|
||||
const factory = (req.app.locals as { fsFactory?: WorkspaceFileSystemFactory })
|
||||
.fsFactory;
|
||||
if (!factory) {
|
||||
|
|
@ -43,6 +52,16 @@ function getFsFactory(
|
|||
return factory;
|
||||
}
|
||||
|
||||
function routeName(req: Request, legacyRoute: string): string {
|
||||
const context = getWorkspaceRouteContext(req);
|
||||
if (!context) return legacyRoute;
|
||||
return `${context.routePrefix}${legacyRoute.slice('POST '.length)}`;
|
||||
}
|
||||
|
||||
function getBridge(req: Request, deps: RegisterDeps): AcpSessionBridge {
|
||||
return getWorkspaceRouteContext(req)?.runtime.bridge ?? deps.bridge;
|
||||
}
|
||||
|
||||
function sendParseError(res: Response, _route: string, error: string): null {
|
||||
applyReadHeaders(res);
|
||||
res.status(400).json({
|
||||
|
|
@ -144,9 +163,11 @@ function resolveOriginatorClientId(
|
|||
clientId: string | undefined,
|
||||
deps: RegisterDeps,
|
||||
res: Response,
|
||||
req?: Request,
|
||||
): string | undefined | null {
|
||||
if (clientId === undefined) return undefined;
|
||||
if (!deps.bridge.knownClientIds().has(clientId)) {
|
||||
const bridge = req ? getBridge(req, deps) : deps.bridge;
|
||||
if (!bridge.knownClientIds().has(clientId)) {
|
||||
applyReadHeaders(res);
|
||||
res.status(400).json({
|
||||
error: `Client id "${clientId}" is not registered for this workspace`,
|
||||
|
|
@ -163,7 +184,7 @@ async function handlePostFileWrite(
|
|||
res: Response,
|
||||
deps: RegisterDeps,
|
||||
): Promise<void> {
|
||||
const ROUTE = 'POST /file/write';
|
||||
const ROUTE = routeName(req, 'POST /file/write');
|
||||
const factory = getFsFactory(req, res);
|
||||
if (!factory) return;
|
||||
const body = deps.safeBody(req);
|
||||
|
|
@ -193,7 +214,12 @@ async function handlePostFileWrite(
|
|||
if (lineEnding === null) return;
|
||||
const clientId = deps.parseClientId(req, res);
|
||||
if (clientId === null) return;
|
||||
const originatorClientId = resolveOriginatorClientId(clientId, deps, res);
|
||||
const originatorClientId = resolveOriginatorClientId(
|
||||
clientId,
|
||||
deps,
|
||||
res,
|
||||
req,
|
||||
);
|
||||
if (originatorClientId === null) return;
|
||||
const fs = factory.forRequest({
|
||||
originatorClientId,
|
||||
|
|
@ -231,7 +257,7 @@ async function handlePostFileEdit(
|
|||
res: Response,
|
||||
deps: RegisterDeps,
|
||||
): Promise<void> {
|
||||
const ROUTE = 'POST /file/edit';
|
||||
const ROUTE = routeName(req, 'POST /file/edit');
|
||||
const factory = getFsFactory(req, res);
|
||||
if (!factory) return;
|
||||
const body = deps.safeBody(req);
|
||||
|
|
@ -251,7 +277,12 @@ async function handlePostFileEdit(
|
|||
if (expectedHash === null) return;
|
||||
const clientId = deps.parseClientId(req, res);
|
||||
if (clientId === null) return;
|
||||
const originatorClientId = resolveOriginatorClientId(clientId, deps, res);
|
||||
const originatorClientId = resolveOriginatorClientId(
|
||||
clientId,
|
||||
deps,
|
||||
res,
|
||||
req,
|
||||
);
|
||||
if (originatorClientId === null) return;
|
||||
const fs = factory.forRequest({
|
||||
originatorClientId,
|
||||
|
|
@ -290,3 +321,39 @@ export function registerWorkspaceFileWriteRoutes(
|
|||
handlePostFileEdit(req, res, deps),
|
||||
);
|
||||
}
|
||||
|
||||
export function registerWorkspaceQualifiedFileWriteRoutes(
|
||||
app: Application,
|
||||
deps: RegisterDeps & { workspaceRegistry: WorkspaceRegistry },
|
||||
): void {
|
||||
const resolve = (req: Request, res: Response): boolean => {
|
||||
const runtime = resolveWorkspaceRuntimeFromParam(
|
||||
deps.workspaceRegistry,
|
||||
req,
|
||||
res,
|
||||
);
|
||||
if (!runtime) return false;
|
||||
if (!requireTrustedWorkspaceRuntime(runtime, res)) return false;
|
||||
setWorkspaceRouteContext(req, {
|
||||
runtime,
|
||||
routePrefix: 'POST /workspaces/:workspace',
|
||||
});
|
||||
return true;
|
||||
};
|
||||
app.post(
|
||||
'/workspaces/:workspace/file/write',
|
||||
deps.mutate({ strict: true }),
|
||||
(req, res) => {
|
||||
if (!resolve(req, res)) return;
|
||||
void handlePostFileWrite(req, res, deps);
|
||||
},
|
||||
);
|
||||
app.post(
|
||||
'/workspaces/:workspace/file/edit',
|
||||
deps.mutate({ strict: true }),
|
||||
(req, res) => {
|
||||
if (!resolve(req, res)) return;
|
||||
void handlePostFileEdit(req, res, deps);
|
||||
},
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -6,8 +6,16 @@
|
|||
|
||||
import type { Application, Request, RequestHandler, Response } from 'express';
|
||||
import type { SendBridgeError } from '../server/error-response.js';
|
||||
import { createBuildWorkspaceCtx } from '../server/request-helpers.js';
|
||||
import {
|
||||
createBuildWorkspaceCtx,
|
||||
parseAndValidateWorkspaceClientId,
|
||||
} from '../server/request-helpers.js';
|
||||
import type { DaemonWorkspaceService } from '../workspace-service/index.js';
|
||||
import {
|
||||
requireTrustedWorkspaceRuntime,
|
||||
resolveWorkspaceRuntimeFromParam,
|
||||
} from '../workspace-route-runtime.js';
|
||||
import type { WorkspaceRegistry } from '../workspace-registry.js';
|
||||
|
||||
interface RegisterWorkspaceLifecycleRoutesDeps {
|
||||
boundWorkspace: string;
|
||||
|
|
@ -73,3 +81,85 @@ export function registerWorkspaceLifecycleRoutes(
|
|||
}
|
||||
});
|
||||
}
|
||||
|
||||
export function registerWorkspaceQualifiedLifecycleRoutes(
|
||||
app: Application,
|
||||
deps: Pick<
|
||||
RegisterWorkspaceLifecycleRoutesDeps,
|
||||
'mutate' | 'safeBody' | 'sendBridgeError' | 'invalidateServeFeaturesCache'
|
||||
> & {
|
||||
workspaceRegistry: WorkspaceRegistry;
|
||||
},
|
||||
): void {
|
||||
app.post(
|
||||
'/workspaces/:workspace/init',
|
||||
deps.mutate({ strict: true }),
|
||||
async (req, res) => {
|
||||
const runtime = resolveWorkspaceRuntimeFromParam(
|
||||
deps.workspaceRegistry,
|
||||
req,
|
||||
res,
|
||||
);
|
||||
if (!runtime || !requireTrustedWorkspaceRuntime(runtime, res)) return;
|
||||
const body = deps.safeBody(req);
|
||||
const force = body['force'];
|
||||
if (force !== undefined && typeof force !== 'boolean') {
|
||||
res.status(400).json({
|
||||
error: '`force` must be a boolean when provided',
|
||||
code: 'invalid_force_flag',
|
||||
});
|
||||
return;
|
||||
}
|
||||
const clientId = parseAndValidateWorkspaceClientId(
|
||||
req,
|
||||
res,
|
||||
runtime.bridge,
|
||||
);
|
||||
if (clientId === null) return;
|
||||
const route = 'POST /workspaces/:workspace/init';
|
||||
try {
|
||||
const ctx = createBuildWorkspaceCtx(runtime.workspaceCwd)(
|
||||
route,
|
||||
clientId,
|
||||
);
|
||||
const result = await runtime.workspaceService.initWorkspace(ctx, {
|
||||
force: force === true,
|
||||
});
|
||||
res.status(200).json(result);
|
||||
} catch (err) {
|
||||
deps.sendBridgeError(res, err, { route });
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
app.post(
|
||||
'/workspaces/:workspace/reload',
|
||||
deps.mutate({ strict: true }),
|
||||
async (req, res) => {
|
||||
const runtime = resolveWorkspaceRuntimeFromParam(
|
||||
deps.workspaceRegistry,
|
||||
req,
|
||||
res,
|
||||
);
|
||||
if (!runtime || !requireTrustedWorkspaceRuntime(runtime, res)) return;
|
||||
const clientId = parseAndValidateWorkspaceClientId(
|
||||
req,
|
||||
res,
|
||||
runtime.bridge,
|
||||
);
|
||||
if (clientId === null) return;
|
||||
const route = 'POST /workspaces/:workspace/reload';
|
||||
try {
|
||||
const ctx = createBuildWorkspaceCtx(runtime.workspaceCwd)(
|
||||
route,
|
||||
clientId,
|
||||
);
|
||||
const result = await runtime.workspaceService.reload(ctx);
|
||||
deps.invalidateServeFeaturesCache();
|
||||
res.status(200).json(result);
|
||||
} catch (err) {
|
||||
deps.sendBridgeError(res, err, { route });
|
||||
}
|
||||
},
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -10,9 +10,18 @@ import type { SendBridgeError } from '../server/error-response.js';
|
|||
import {
|
||||
createBuildWorkspaceCtx,
|
||||
MAX_SERVER_NAME_LENGTH,
|
||||
parseAndValidateWorkspaceClientId,
|
||||
validateMcpRuntimeServerName,
|
||||
} from '../server/request-helpers.js';
|
||||
import type { DaemonWorkspaceService } from '../workspace-service/index.js';
|
||||
import {
|
||||
requireTrustedWorkspaceRuntime,
|
||||
resolveWorkspaceRuntimeFromParam,
|
||||
} from '../workspace-route-runtime.js';
|
||||
import type {
|
||||
WorkspaceRegistry,
|
||||
WorkspaceRuntime,
|
||||
} from '../workspace-registry.js';
|
||||
|
||||
interface RegisterWorkspaceMcpControlRoutesDeps {
|
||||
boundWorkspace: string;
|
||||
|
|
@ -219,3 +228,238 @@ export function registerWorkspaceMcpControlRoutes(
|
|||
},
|
||||
);
|
||||
}
|
||||
|
||||
function resolveTrustedMcpRuntime(
|
||||
registry: WorkspaceRegistry,
|
||||
req: Request,
|
||||
res: Response,
|
||||
): WorkspaceRuntime | null {
|
||||
const runtime = resolveWorkspaceRuntimeFromParam(registry, req, res);
|
||||
if (!runtime) return null;
|
||||
return requireTrustedWorkspaceRuntime(runtime, res) ? runtime : null;
|
||||
}
|
||||
|
||||
export function registerWorkspaceQualifiedMcpControlRoutes(
|
||||
app: Application,
|
||||
deps: Pick<
|
||||
RegisterWorkspaceMcpControlRoutesDeps,
|
||||
'mutate' | 'safeBody' | 'sendBridgeError'
|
||||
> & {
|
||||
workspaceRegistry: WorkspaceRegistry;
|
||||
},
|
||||
): void {
|
||||
app.post(
|
||||
'/workspaces/:workspace/mcp/:server/restart',
|
||||
deps.mutate({ strict: true }),
|
||||
async (req, res) => {
|
||||
const runtime = resolveTrustedMcpRuntime(
|
||||
deps.workspaceRegistry,
|
||||
req,
|
||||
res,
|
||||
);
|
||||
if (!runtime) return;
|
||||
const serverName = req.params['server'];
|
||||
if (!serverName || typeof serverName !== 'string') {
|
||||
res.status(400).json({
|
||||
error: 'Server name path parameter is required',
|
||||
code: 'invalid_server_name',
|
||||
});
|
||||
return;
|
||||
}
|
||||
if (serverName.length > MAX_SERVER_NAME_LENGTH) {
|
||||
res.status(400).json({
|
||||
error: `Server name exceeds ${MAX_SERVER_NAME_LENGTH}-character limit`,
|
||||
code: 'invalid_server_name',
|
||||
});
|
||||
return;
|
||||
}
|
||||
const clientId = parseAndValidateWorkspaceClientId(
|
||||
req,
|
||||
res,
|
||||
runtime.bridge,
|
||||
);
|
||||
if (clientId === null) return;
|
||||
const rawEntryIndex = req.query['entryIndex'];
|
||||
let entryIndex: number | undefined;
|
||||
if (rawEntryIndex !== undefined && rawEntryIndex !== '*') {
|
||||
const candidate =
|
||||
typeof rawEntryIndex === 'string' ? rawEntryIndex : undefined;
|
||||
const parsed =
|
||||
candidate !== undefined ? Number.parseInt(candidate, 10) : NaN;
|
||||
if (
|
||||
!Number.isInteger(parsed) ||
|
||||
parsed < 0 ||
|
||||
String(parsed) !== candidate
|
||||
) {
|
||||
res.status(400).json({
|
||||
error:
|
||||
'`entryIndex` query parameter must be a non-negative integer or "*"',
|
||||
code: 'invalid_entry_index',
|
||||
});
|
||||
return;
|
||||
}
|
||||
entryIndex = parsed;
|
||||
}
|
||||
const route = 'POST /workspaces/:workspace/mcp/:server/restart';
|
||||
try {
|
||||
const ctx = createBuildWorkspaceCtx(runtime.workspaceCwd)(
|
||||
route,
|
||||
clientId,
|
||||
);
|
||||
const result = await runtime.workspaceService.restartMcpServer(
|
||||
ctx,
|
||||
serverName,
|
||||
entryIndex !== undefined ? { entryIndex } : undefined,
|
||||
);
|
||||
res.status(200).json(result);
|
||||
} catch (err) {
|
||||
deps.sendBridgeError(res, err, { route });
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
for (const [routeAction, bridgeAction] of [
|
||||
['enable', 'enable'],
|
||||
['disable', 'disable'],
|
||||
['authenticate', 'authenticate'],
|
||||
['clear-auth', 'clear-auth'],
|
||||
] as const) {
|
||||
app.post(
|
||||
`/workspaces/:workspace/mcp/:server/${routeAction}`,
|
||||
deps.mutate({ strict: true }),
|
||||
async (req, res) => {
|
||||
const runtime = resolveTrustedMcpRuntime(
|
||||
deps.workspaceRegistry,
|
||||
req,
|
||||
res,
|
||||
);
|
||||
if (!runtime) return;
|
||||
const serverName = req.params['server'];
|
||||
if (!serverName || typeof serverName !== 'string') {
|
||||
res.status(400).json({
|
||||
error: 'Server name path parameter is required',
|
||||
code: 'invalid_server_name',
|
||||
});
|
||||
return;
|
||||
}
|
||||
if (serverName.length > MAX_SERVER_NAME_LENGTH) {
|
||||
res.status(400).json({
|
||||
error: `Server name exceeds ${MAX_SERVER_NAME_LENGTH}-character limit`,
|
||||
code: 'invalid_server_name',
|
||||
});
|
||||
return;
|
||||
}
|
||||
const clientId = parseAndValidateWorkspaceClientId(
|
||||
req,
|
||||
res,
|
||||
runtime.bridge,
|
||||
);
|
||||
if (clientId === null) return;
|
||||
const route = `POST /workspaces/:workspace/mcp/:server/${routeAction}`;
|
||||
try {
|
||||
const result = await runtime.bridge.manageMcpServer(
|
||||
serverName,
|
||||
bridgeAction,
|
||||
clientId,
|
||||
);
|
||||
res.status(200).json(result);
|
||||
} catch (err) {
|
||||
deps.sendBridgeError(res, err, { route });
|
||||
}
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
app.post(
|
||||
'/workspaces/:workspace/mcp/servers',
|
||||
deps.mutate({ strict: true }),
|
||||
async (req, res) => {
|
||||
const runtime = resolveTrustedMcpRuntime(
|
||||
deps.workspaceRegistry,
|
||||
req,
|
||||
res,
|
||||
);
|
||||
if (!runtime) return;
|
||||
const body = deps.safeBody(req);
|
||||
const name = body['name'];
|
||||
if (!validateMcpRuntimeServerName(name, res)) return;
|
||||
const config = body['config'];
|
||||
if (
|
||||
typeof config !== 'object' ||
|
||||
config === null ||
|
||||
Array.isArray(config)
|
||||
) {
|
||||
res.status(400).json({
|
||||
error: '`config` must be a non-null object',
|
||||
code: 'missing_required_field',
|
||||
field: 'config',
|
||||
});
|
||||
return;
|
||||
}
|
||||
const clientId = parseAndValidateWorkspaceClientId(
|
||||
req,
|
||||
res,
|
||||
runtime.bridge,
|
||||
);
|
||||
if (clientId === null) return;
|
||||
if (!clientId) {
|
||||
res.status(400).json({
|
||||
error:
|
||||
'`X-Qwen-Client-Id` header is required for runtime MCP mutation',
|
||||
code: 'missing_client_id',
|
||||
});
|
||||
return;
|
||||
}
|
||||
const route = 'POST /workspaces/:workspace/mcp/servers';
|
||||
try {
|
||||
const result = await runtime.bridge.addRuntimeMcpServer(
|
||||
name,
|
||||
config as Record<string, unknown>,
|
||||
clientId,
|
||||
);
|
||||
res.status(200).json(result);
|
||||
} catch (err) {
|
||||
deps.sendBridgeError(res, err, { route });
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
app.delete(
|
||||
'/workspaces/:workspace/mcp/servers/:name',
|
||||
deps.mutate({ strict: true }),
|
||||
async (req, res) => {
|
||||
const runtime = resolveTrustedMcpRuntime(
|
||||
deps.workspaceRegistry,
|
||||
req,
|
||||
res,
|
||||
);
|
||||
if (!runtime) return;
|
||||
const name = req.params['name'] ?? '';
|
||||
if (!validateMcpRuntimeServerName(name, res)) return;
|
||||
const clientId = parseAndValidateWorkspaceClientId(
|
||||
req,
|
||||
res,
|
||||
runtime.bridge,
|
||||
);
|
||||
if (clientId === null) return;
|
||||
if (!clientId) {
|
||||
res.status(400).json({
|
||||
error:
|
||||
'`X-Qwen-Client-Id` header is required for runtime MCP mutation',
|
||||
code: 'missing_client_id',
|
||||
});
|
||||
return;
|
||||
}
|
||||
const route = 'DELETE /workspaces/:workspace/mcp/servers/:name';
|
||||
try {
|
||||
const result = await runtime.bridge.removeRuntimeMcpServer(
|
||||
name,
|
||||
clientId,
|
||||
);
|
||||
res.status(200).json(result);
|
||||
} catch (err) {
|
||||
deps.sendBridgeError(res, err, { route });
|
||||
}
|
||||
},
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -18,6 +18,12 @@ import { loadSettings } from '../../config/settings.js';
|
|||
import { writeStderrLine } from '../../utils/stdioHelpers.js';
|
||||
import type { DaemonWorkspaceService } from '../workspace-service/types.js';
|
||||
import { WorkspacePermissionRulesSessionRequiredError } from '../workspace-service/types.js';
|
||||
import { parseAndValidateWorkspaceClientId } from '../server/request-helpers.js';
|
||||
import {
|
||||
requireTrustedWorkspaceRuntime,
|
||||
resolveWorkspaceRuntimeFromParam,
|
||||
} from '../workspace-route-runtime.js';
|
||||
import type { WorkspaceRegistry } from '../workspace-registry.js';
|
||||
|
||||
export interface WorkspacePermissionsRouteDeps {
|
||||
boundWorkspace: string;
|
||||
|
|
@ -145,3 +151,124 @@ export function registerWorkspacePermissionsRoutes(
|
|||
},
|
||||
);
|
||||
}
|
||||
|
||||
export function registerWorkspaceQualifiedPermissionsRoutes(
|
||||
app: Application,
|
||||
deps: Pick<WorkspacePermissionsRouteDeps, 'mutate' | 'safeBody'> & {
|
||||
workspaceRegistry: WorkspaceRegistry;
|
||||
},
|
||||
): void {
|
||||
app.get('/workspaces/:workspace/permissions', (req, res) => {
|
||||
const runtime = resolveWorkspaceRuntimeFromParam(
|
||||
deps.workspaceRegistry,
|
||||
req,
|
||||
res,
|
||||
);
|
||||
if (!runtime || !requireTrustedWorkspaceRuntime(runtime, res)) return;
|
||||
try {
|
||||
res
|
||||
.status(200)
|
||||
.json(buildPermissionSettings(loadSettings(runtime.workspaceCwd)));
|
||||
} catch (err) {
|
||||
writeStderrLine(
|
||||
`qwen serve: GET /workspaces/:workspace/permissions error: ${
|
||||
err instanceof Error ? err.message : String(err)
|
||||
}`,
|
||||
);
|
||||
res.status(500).json({
|
||||
error: 'Failed to load permission rules',
|
||||
code: 'internal_error',
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
app.post(
|
||||
'/workspaces/:workspace/permissions',
|
||||
deps.mutate({ strict: true }),
|
||||
async (req, res) => {
|
||||
const runtime = resolveWorkspaceRuntimeFromParam(
|
||||
deps.workspaceRegistry,
|
||||
req,
|
||||
res,
|
||||
);
|
||||
if (!runtime || !requireTrustedWorkspaceRuntime(runtime, res)) return;
|
||||
const body = deps.safeBody(req);
|
||||
const scope = body['scope'];
|
||||
const ruleType = body['ruleType'];
|
||||
|
||||
if (scope !== 'workspace') {
|
||||
res.status(400).json({
|
||||
error:
|
||||
'workspace-qualified permissions routes only support "workspace" scope',
|
||||
code: 'global_scope_not_supported_for_workspace_route',
|
||||
});
|
||||
return;
|
||||
}
|
||||
const permissionScope: PermissionSettingsScope = 'workspace';
|
||||
|
||||
if (!isPermissionRuleType(ruleType)) {
|
||||
res.status(400).json({
|
||||
error: 'ruleType must be "allow", "ask", or "deny"',
|
||||
code: 'invalid_rule_type',
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
let rules: string[];
|
||||
try {
|
||||
const settings = loadSettings(runtime.workspaceCwd);
|
||||
const existingRules = readPermissionRuleSet(
|
||||
settings.workspace.settings,
|
||||
)[ruleType];
|
||||
rules = normalizePermissionRules(body['rules'], { existingRules });
|
||||
} catch (err) {
|
||||
if (err instanceof PermissionRulesValidationError) {
|
||||
res.status(400).json({
|
||||
error: err.message,
|
||||
code: err.code,
|
||||
});
|
||||
return;
|
||||
}
|
||||
throw err;
|
||||
}
|
||||
|
||||
const clientId = parseAndValidateWorkspaceClientId(
|
||||
req,
|
||||
res,
|
||||
runtime.bridge,
|
||||
);
|
||||
if (clientId === null) return;
|
||||
|
||||
try {
|
||||
const liveResponse =
|
||||
await runtime.workspaceService.setWorkspacePermissionRules(
|
||||
{
|
||||
route: 'POST /workspaces/:workspace/permissions',
|
||||
workspaceCwd: runtime.workspaceCwd,
|
||||
...(clientId ? { originatorClientId: clientId } : {}),
|
||||
},
|
||||
{ scope: permissionScope, ruleType, rules },
|
||||
);
|
||||
res.status(200).json(liveResponse);
|
||||
} catch (err) {
|
||||
if (err instanceof WorkspacePermissionRulesSessionRequiredError) {
|
||||
res.status(409).json({
|
||||
error:
|
||||
'A live ACP session is required to update active permission rules.',
|
||||
code: 'permission_session_required',
|
||||
});
|
||||
return;
|
||||
}
|
||||
writeStderrLine(
|
||||
`qwen serve: POST /workspaces/:workspace/permissions ACP error (ruleType=${ruleType}, workspace=${runtime.workspaceCwd}): ${
|
||||
err instanceof Error ? err.message : String(err)
|
||||
}`,
|
||||
);
|
||||
res.status(500).json({
|
||||
error: 'Failed to update permission rules',
|
||||
code: 'permission_update_failed',
|
||||
});
|
||||
}
|
||||
},
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -18,6 +18,12 @@ import {
|
|||
validateSettingValue,
|
||||
} from '../../utils/settingsUtils.js';
|
||||
import { writeStderrLine } from '../../utils/stdioHelpers.js';
|
||||
import { parseAndValidateWorkspaceClientId } from '../server/request-helpers.js';
|
||||
import {
|
||||
requireTrustedWorkspaceRuntime,
|
||||
resolveWorkspaceRuntimeFromParam,
|
||||
} from '../workspace-route-runtime.js';
|
||||
import type { WorkspaceRegistry } from '../workspace-registry.js';
|
||||
|
||||
const TUI_ONLY_SETTINGS = new Set([
|
||||
'general.vimMode',
|
||||
|
|
@ -299,3 +305,151 @@ export function registerWorkspaceSettingsRoutes(
|
|||
},
|
||||
);
|
||||
}
|
||||
|
||||
export function registerWorkspaceQualifiedSettingsRoutes(
|
||||
app: Application,
|
||||
deps: Pick<
|
||||
WorkspaceSettingsRouteDeps,
|
||||
'mutate' | 'safeBody' | 'persistSetting'
|
||||
> & {
|
||||
workspaceRegistry: WorkspaceRegistry;
|
||||
invalidateServeFeaturesCache: () => void;
|
||||
},
|
||||
): void {
|
||||
const allowedKeys = getAllowedKeys();
|
||||
|
||||
app.get('/workspaces/:workspace/settings', (req: Request, res: Response) => {
|
||||
const runtime = resolveWorkspaceRuntimeFromParam(
|
||||
deps.workspaceRegistry,
|
||||
req,
|
||||
res,
|
||||
);
|
||||
// Legacy /workspace/settings remains primary-only and pre-trust for
|
||||
// compatibility; plural workspace-qualified settings intentionally follow
|
||||
// the Phase 3 core-route trust gate.
|
||||
if (!runtime || !requireTrustedWorkspaceRuntime(runtime, res)) return;
|
||||
try {
|
||||
const response = buildSettingsResponse(runtime.workspaceCwd, allowedKeys);
|
||||
res.status(200).json(response);
|
||||
} catch (err) {
|
||||
writeStderrLine(
|
||||
`qwen serve: GET /workspaces/:workspace/settings error: ${
|
||||
err instanceof Error ? err.message : String(err)
|
||||
}`,
|
||||
);
|
||||
res.status(500).json({
|
||||
error: 'Failed to load settings',
|
||||
code: 'internal_error',
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
app.post(
|
||||
'/workspaces/:workspace/settings',
|
||||
deps.mutate({ strict: true }),
|
||||
async (req: Request, res: Response) => {
|
||||
const runtime = resolveWorkspaceRuntimeFromParam(
|
||||
deps.workspaceRegistry,
|
||||
req,
|
||||
res,
|
||||
);
|
||||
if (!runtime || !requireTrustedWorkspaceRuntime(runtime, res)) return;
|
||||
const body = deps.safeBody(req);
|
||||
const scope = body['scope'];
|
||||
const key = body['key'];
|
||||
const value = body['value'];
|
||||
|
||||
if (typeof scope !== 'string' || !VALID_WRITE_SCOPES.has(scope)) {
|
||||
res.status(400).json({
|
||||
error: `scope must be one of: ${[...VALID_WRITE_SCOPES].join(', ')}`,
|
||||
code: 'invalid_scope',
|
||||
});
|
||||
return;
|
||||
}
|
||||
if (typeof key !== 'string' || !key) {
|
||||
res.status(400).json({
|
||||
error: 'key is required and must be a string',
|
||||
code: 'invalid_key',
|
||||
});
|
||||
return;
|
||||
}
|
||||
if (!allowedKeys.has(key)) {
|
||||
res.status(400).json({
|
||||
error: `Setting "${key}" is not modifiable via this API`,
|
||||
code: 'disallowed_key',
|
||||
});
|
||||
return;
|
||||
}
|
||||
if (value === undefined || value === null) {
|
||||
res.status(400).json({
|
||||
error: 'value is required',
|
||||
code: 'missing_value',
|
||||
});
|
||||
return;
|
||||
}
|
||||
const def = getSettingDefinition(key);
|
||||
if (!def) {
|
||||
res.status(400).json({
|
||||
error: `Unknown setting: ${key}`,
|
||||
code: 'unknown_key',
|
||||
});
|
||||
return;
|
||||
}
|
||||
const validationError = validateSettingValue(def, value);
|
||||
if (validationError) {
|
||||
res.status(400).json({
|
||||
error: validationError,
|
||||
code: 'invalid_value',
|
||||
});
|
||||
return;
|
||||
}
|
||||
const clientId = parseAndValidateWorkspaceClientId(
|
||||
req,
|
||||
res,
|
||||
runtime.bridge,
|
||||
);
|
||||
if (clientId === null) return;
|
||||
|
||||
const settingScope = SCOPE_MAP[scope];
|
||||
if (!settingScope) {
|
||||
res.status(400).json({
|
||||
error: `scope must be one of: ${[...VALID_WRITE_SCOPES].join(', ')}`,
|
||||
code: 'invalid_scope',
|
||||
});
|
||||
return;
|
||||
}
|
||||
try {
|
||||
await deps.persistSetting(
|
||||
runtime.workspaceCwd,
|
||||
settingScope,
|
||||
key,
|
||||
value,
|
||||
);
|
||||
} catch (err) {
|
||||
writeStderrLine(
|
||||
`qwen serve: POST /workspaces/:workspace/settings persist error (key=${key}, scope=${scope}, workspace=${runtime.workspaceCwd}): ${
|
||||
err instanceof Error ? err.message : String(err)
|
||||
}`,
|
||||
);
|
||||
res.status(500).json({
|
||||
error: 'Failed to persist setting',
|
||||
code: 'persist_error',
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
deps.invalidateServeFeaturesCache();
|
||||
runtime.bridge.publishWorkspaceEvent({
|
||||
type: 'settings_changed',
|
||||
data: { key, value, scope },
|
||||
...(clientId ? { originatorClientId: clientId } : {}),
|
||||
});
|
||||
res.status(200).json({
|
||||
key,
|
||||
scope,
|
||||
value,
|
||||
requiresRestart: def.requiresRestart,
|
||||
});
|
||||
},
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -4,7 +4,7 @@
|
|||
* SPDX-License-Identifier: Apache-2.0
|
||||
*/
|
||||
|
||||
import type { Application, RequestHandler } from 'express';
|
||||
import type { Application, Request, RequestHandler, Response } from 'express';
|
||||
import type { AcpSessionBridge } from '../acp-session-bridge.js';
|
||||
import type { SendBridgeError } from '../server/error-response.js';
|
||||
import {
|
||||
|
|
@ -12,6 +12,14 @@ import {
|
|||
MAX_SERVER_NAME_LENGTH,
|
||||
} from '../server/request-helpers.js';
|
||||
import type { DaemonWorkspaceService } from '../workspace-service/index.js';
|
||||
import {
|
||||
requireTrustedWorkspaceRuntime,
|
||||
resolveWorkspaceRuntimeFromParam,
|
||||
} from '../workspace-route-runtime.js';
|
||||
import type {
|
||||
WorkspaceRegistry,
|
||||
WorkspaceRuntime,
|
||||
} from '../workspace-registry.js';
|
||||
|
||||
const MAX_ACP_PREHEAT_TIMEOUT_MS = 60_000;
|
||||
|
||||
|
|
@ -156,6 +164,134 @@ export function registerWorkspaceStatusRoutes(
|
|||
});
|
||||
}
|
||||
|
||||
function resolveTrustedRuntime(
|
||||
registry: WorkspaceRegistry,
|
||||
req: Request,
|
||||
res: Response,
|
||||
): WorkspaceRuntime | null {
|
||||
const runtime = resolveWorkspaceRuntimeFromParam(registry, req, res);
|
||||
if (!runtime) return null;
|
||||
return requireTrustedWorkspaceRuntime(runtime, res) ? runtime : null;
|
||||
}
|
||||
|
||||
export function registerWorkspaceQualifiedStatusRoutes(
|
||||
app: Application,
|
||||
deps: Pick<RegisterWorkspaceStatusRoutesDeps, 'sendBridgeError'> & {
|
||||
workspaceRegistry: WorkspaceRegistry;
|
||||
},
|
||||
): void {
|
||||
const { workspaceRegistry, sendBridgeError } = deps;
|
||||
|
||||
app.get('/workspaces/:workspace/mcp', async (req, res) => {
|
||||
const runtime = resolveTrustedRuntime(workspaceRegistry, req, res);
|
||||
if (!runtime) return;
|
||||
const route = 'GET /workspaces/:workspace/mcp';
|
||||
const ctx = createBuildWorkspaceCtx(runtime.workspaceCwd)(route);
|
||||
try {
|
||||
res
|
||||
.status(200)
|
||||
.json(await runtime.workspaceService.getWorkspaceMcpStatus(ctx));
|
||||
} catch (err) {
|
||||
sendBridgeError(res, err, { route });
|
||||
}
|
||||
});
|
||||
|
||||
app.get('/workspaces/:workspace/mcp/:server/tools', async (req, res) => {
|
||||
const runtime = resolveTrustedRuntime(workspaceRegistry, req, res);
|
||||
if (!runtime) return;
|
||||
const serverName = req.params['server'];
|
||||
if (!serverName || typeof serverName !== 'string') {
|
||||
res.status(400).json({
|
||||
error: 'Server name path parameter is required',
|
||||
code: 'invalid_server_name',
|
||||
});
|
||||
return;
|
||||
}
|
||||
if (serverName.length > MAX_SERVER_NAME_LENGTH) {
|
||||
res.status(400).json({
|
||||
error: `Server name exceeds ${MAX_SERVER_NAME_LENGTH}-character limit`,
|
||||
code: 'invalid_server_name',
|
||||
});
|
||||
return;
|
||||
}
|
||||
const route = 'GET /workspaces/:workspace/mcp/:server/tools';
|
||||
try {
|
||||
res
|
||||
.status(200)
|
||||
.json(await runtime.bridge.getWorkspaceMcpToolsStatus(serverName));
|
||||
} catch (err) {
|
||||
sendBridgeError(res, err, { route });
|
||||
}
|
||||
});
|
||||
|
||||
app.get('/workspaces/:workspace/mcp/:server/resources', async (req, res) => {
|
||||
const runtime = resolveTrustedRuntime(workspaceRegistry, req, res);
|
||||
if (!runtime) return;
|
||||
const serverName = req.params['server'];
|
||||
if (!serverName || typeof serverName !== 'string') {
|
||||
res.status(400).json({
|
||||
error: 'Server name path parameter is required',
|
||||
code: 'invalid_server_name',
|
||||
});
|
||||
return;
|
||||
}
|
||||
if (serverName.length > MAX_SERVER_NAME_LENGTH) {
|
||||
res.status(400).json({
|
||||
error: `Server name exceeds ${MAX_SERVER_NAME_LENGTH}-character limit`,
|
||||
code: 'invalid_server_name',
|
||||
});
|
||||
return;
|
||||
}
|
||||
const route = 'GET /workspaces/:workspace/mcp/:server/resources';
|
||||
try {
|
||||
res
|
||||
.status(200)
|
||||
.json(await runtime.bridge.getWorkspaceMcpResourcesStatus(serverName));
|
||||
} catch (err) {
|
||||
sendBridgeError(res, err, { route });
|
||||
}
|
||||
});
|
||||
|
||||
app.get('/workspaces/:workspace/skills', async (req, res) => {
|
||||
const runtime = resolveTrustedRuntime(workspaceRegistry, req, res);
|
||||
if (!runtime) return;
|
||||
const route = 'GET /workspaces/:workspace/skills';
|
||||
const ctx = createBuildWorkspaceCtx(runtime.workspaceCwd)(route);
|
||||
try {
|
||||
res
|
||||
.status(200)
|
||||
.json(await runtime.workspaceService.getWorkspaceSkillsStatus(ctx));
|
||||
} catch (err) {
|
||||
sendBridgeError(res, err, { route });
|
||||
}
|
||||
});
|
||||
|
||||
app.get('/workspaces/:workspace/tools', async (req, res) => {
|
||||
const runtime = resolveTrustedRuntime(workspaceRegistry, req, res);
|
||||
if (!runtime) return;
|
||||
const route = 'GET /workspaces/:workspace/tools';
|
||||
try {
|
||||
res.status(200).json(await runtime.bridge.getWorkspaceToolsStatus());
|
||||
} catch (err) {
|
||||
sendBridgeError(res, err, { route });
|
||||
}
|
||||
});
|
||||
|
||||
app.get('/workspaces/:workspace/providers', async (req, res) => {
|
||||
const runtime = resolveTrustedRuntime(workspaceRegistry, req, res);
|
||||
if (!runtime) return;
|
||||
const route = 'GET /workspaces/:workspace/providers';
|
||||
const ctx = createBuildWorkspaceCtx(runtime.workspaceCwd)(route);
|
||||
try {
|
||||
res
|
||||
.status(200)
|
||||
.json(await runtime.workspaceService.getWorkspaceProvidersStatus(ctx));
|
||||
} catch (err) {
|
||||
sendBridgeError(res, err, { route });
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
export function registerWorkspaceDiagnosticStatusRoutes(
|
||||
app: Application,
|
||||
deps: RegisterWorkspaceStatusRoutesDeps,
|
||||
|
|
@ -199,3 +335,29 @@ export function registerWorkspaceDiagnosticStatusRoutes(
|
|||
}
|
||||
});
|
||||
}
|
||||
|
||||
export function registerWorkspaceQualifiedDiagnosticStatusRoutes(
|
||||
app: Application,
|
||||
deps: Pick<RegisterWorkspaceStatusRoutesDeps, 'sendBridgeError'> & {
|
||||
workspaceRegistry: WorkspaceRegistry;
|
||||
},
|
||||
): void {
|
||||
const { workspaceRegistry, sendBridgeError } = deps;
|
||||
for (const [pathSuffix, methodName] of [
|
||||
['env', 'getWorkspaceEnvStatus'],
|
||||
['preflight', 'getWorkspacePreflightStatus'],
|
||||
['hooks', 'getWorkspaceHooksStatus'],
|
||||
] as const) {
|
||||
app.get(`/workspaces/:workspace/${pathSuffix}`, async (req, res) => {
|
||||
const runtime = resolveTrustedRuntime(workspaceRegistry, req, res);
|
||||
if (!runtime) return;
|
||||
const route = `GET /workspaces/:workspace/${pathSuffix}`;
|
||||
const ctx = createBuildWorkspaceCtx(runtime.workspaceCwd)(route);
|
||||
try {
|
||||
res.status(200).json(await runtime.workspaceService[methodName](ctx));
|
||||
} catch (err) {
|
||||
sendBridgeError(res, err, { route });
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -9,8 +9,14 @@ import type { SendBridgeError } from '../server/error-response.js';
|
|||
import {
|
||||
createBuildWorkspaceCtx,
|
||||
MAX_TOOL_NAME_LENGTH,
|
||||
parseAndValidateWorkspaceClientId,
|
||||
} from '../server/request-helpers.js';
|
||||
import type { DaemonWorkspaceService } from '../workspace-service/index.js';
|
||||
import {
|
||||
requireTrustedWorkspaceRuntime,
|
||||
resolveWorkspaceRuntimeFromParam,
|
||||
} from '../workspace-route-runtime.js';
|
||||
import type { WorkspaceRegistry } from '../workspace-registry.js';
|
||||
|
||||
interface RegisterWorkspaceToolsRoutesDeps {
|
||||
boundWorkspace: string;
|
||||
|
|
@ -95,3 +101,79 @@ export function registerWorkspaceToolsRoutes(
|
|||
},
|
||||
);
|
||||
}
|
||||
|
||||
export function registerWorkspaceQualifiedToolsRoutes(
|
||||
app: Application,
|
||||
deps: Pick<
|
||||
RegisterWorkspaceToolsRoutesDeps,
|
||||
'mutate' | 'safeBody' | 'sendBridgeError'
|
||||
> & {
|
||||
workspaceRegistry: WorkspaceRegistry;
|
||||
},
|
||||
): void {
|
||||
app.post(
|
||||
'/workspaces/:workspace/tools/:name/enable',
|
||||
deps.mutate({ strict: true }),
|
||||
async (req, res) => {
|
||||
const runtime = resolveWorkspaceRuntimeFromParam(
|
||||
deps.workspaceRegistry,
|
||||
req,
|
||||
res,
|
||||
);
|
||||
if (!runtime || !requireTrustedWorkspaceRuntime(runtime, res)) return;
|
||||
const rawToolName = req.params['name'];
|
||||
if (!rawToolName || typeof rawToolName !== 'string') {
|
||||
res.status(400).json({
|
||||
error: 'Tool name path parameter is required',
|
||||
code: 'invalid_tool_name',
|
||||
});
|
||||
return;
|
||||
}
|
||||
const toolName = rawToolName.trim();
|
||||
if (toolName.length === 0) {
|
||||
res.status(400).json({
|
||||
error: 'Tool name path parameter is required',
|
||||
code: 'invalid_tool_name',
|
||||
});
|
||||
return;
|
||||
}
|
||||
if (toolName.length > MAX_TOOL_NAME_LENGTH) {
|
||||
res.status(400).json({
|
||||
error: `Tool name exceeds ${MAX_TOOL_NAME_LENGTH}-character limit`,
|
||||
code: 'invalid_tool_name',
|
||||
});
|
||||
return;
|
||||
}
|
||||
const body = deps.safeBody(req);
|
||||
const enabled = body['enabled'];
|
||||
if (typeof enabled !== 'boolean') {
|
||||
res.status(400).json({
|
||||
error: '`enabled` is required and must be a boolean',
|
||||
code: 'invalid_enabled_flag',
|
||||
});
|
||||
return;
|
||||
}
|
||||
const clientId = parseAndValidateWorkspaceClientId(
|
||||
req,
|
||||
res,
|
||||
runtime.bridge,
|
||||
);
|
||||
if (clientId === null) return;
|
||||
const route = 'POST /workspaces/:workspace/tools/:name/enable';
|
||||
try {
|
||||
const ctx = createBuildWorkspaceCtx(runtime.workspaceCwd)(
|
||||
route,
|
||||
clientId,
|
||||
);
|
||||
const result = await runtime.workspaceService.setWorkspaceToolEnabled(
|
||||
ctx,
|
||||
toolName,
|
||||
enabled,
|
||||
);
|
||||
res.status(200).json(result);
|
||||
} catch (err) {
|
||||
deps.sendBridgeError(res, err, { route });
|
||||
}
|
||||
},
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -9,6 +9,9 @@ import { FatalConfigError } from '@qwen-code/qwen-code-core';
|
|||
import type { DaemonWorkspaceService } from '../workspace-service/types.js';
|
||||
import { MAX_TRUST_REASON_LENGTH } from '../validation-limits.js';
|
||||
import { writeStderrLine } from '../../utils/stdioHelpers.js';
|
||||
import { resolveWorkspaceRuntimeFromParam } from '../workspace-route-runtime.js';
|
||||
import type { WorkspaceRegistry } from '../workspace-registry.js';
|
||||
import { parseAndValidateWorkspaceClientId } from '../server/request-helpers.js';
|
||||
|
||||
export interface WorkspaceTrustRouteDeps {
|
||||
boundWorkspace: string;
|
||||
|
|
@ -119,3 +122,97 @@ export function registerWorkspaceTrustRoutes(
|
|||
},
|
||||
);
|
||||
}
|
||||
|
||||
export function registerWorkspaceQualifiedTrustRoutes(
|
||||
app: Application,
|
||||
deps: Pick<WorkspaceTrustRouteDeps, 'mutate' | 'safeBody'> & {
|
||||
workspaceRegistry: WorkspaceRegistry;
|
||||
},
|
||||
): void {
|
||||
const { workspaceRegistry, mutate, safeBody } = deps;
|
||||
|
||||
app.get('/workspaces/:workspace/trust', async (req, res) => {
|
||||
const runtime = resolveWorkspaceRuntimeFromParam(
|
||||
workspaceRegistry,
|
||||
req,
|
||||
res,
|
||||
);
|
||||
if (!runtime) return;
|
||||
const route = 'GET /workspaces/:workspace/trust';
|
||||
try {
|
||||
const status = await runtime.workspaceService.getWorkspaceTrustStatus({
|
||||
route,
|
||||
workspaceCwd: runtime.workspaceCwd,
|
||||
});
|
||||
res.status(200).json(status);
|
||||
} catch (err) {
|
||||
sendTrustError(res, route, err);
|
||||
}
|
||||
});
|
||||
|
||||
app.post(
|
||||
'/workspaces/:workspace/trust/request',
|
||||
mutate({ strict: true }),
|
||||
async (req, res) => {
|
||||
const runtime = resolveWorkspaceRuntimeFromParam(
|
||||
workspaceRegistry,
|
||||
req,
|
||||
res,
|
||||
);
|
||||
if (!runtime) return;
|
||||
const body = safeBody(req);
|
||||
const desiredState = body['desiredState'];
|
||||
if (desiredState !== 'trusted' && desiredState !== 'untrusted') {
|
||||
res.status(400).json({
|
||||
error: 'desiredState must be "trusted" or "untrusted"',
|
||||
code: 'invalid_desired_state',
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
const reason = body['reason'];
|
||||
if (
|
||||
reason !== undefined &&
|
||||
(typeof reason !== 'string' || reason.length > MAX_TRUST_REASON_LENGTH)
|
||||
) {
|
||||
res.status(400).json({
|
||||
error: `reason must be a string up to ${MAX_TRUST_REASON_LENGTH} characters`,
|
||||
code: 'invalid_reason',
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
const clientId = parseAndValidateWorkspaceClientId(
|
||||
req,
|
||||
res,
|
||||
runtime.bridge,
|
||||
);
|
||||
if (clientId === null) return;
|
||||
const route = 'POST /workspaces/:workspace/trust/request';
|
||||
const ctx = {
|
||||
route,
|
||||
workspaceCwd: runtime.workspaceCwd,
|
||||
...(clientId !== undefined ? { originatorClientId: clientId } : {}),
|
||||
};
|
||||
try {
|
||||
const status =
|
||||
await runtime.workspaceService.getWorkspaceTrustStatus(ctx);
|
||||
if (!status.folderTrustEnabled) {
|
||||
res.status(409).json({
|
||||
error: 'Folder trust is disabled for this workspace',
|
||||
code: 'folder_trust_disabled',
|
||||
});
|
||||
return;
|
||||
}
|
||||
const result =
|
||||
await runtime.workspaceService.requestWorkspaceTrustChange(ctx, {
|
||||
desiredState,
|
||||
...(reason !== undefined ? { reason } : {}),
|
||||
});
|
||||
res.status(202).json(result);
|
||||
} catch (err) {
|
||||
sendTrustError(res, route, err);
|
||||
}
|
||||
},
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -306,6 +306,7 @@ const EXPECTED_STAGE1_FEATURES = [
|
|||
'session_hooks',
|
||||
'workspace_extensions',
|
||||
'session_branch',
|
||||
'workspace_qualified_rest_core',
|
||||
// Baseline (always advertised) — presence means the `/voice/stream`
|
||||
// endpoint exists; the WS errors if no voice model is configured.
|
||||
'voice_transcribe',
|
||||
|
|
@ -350,6 +351,7 @@ const EXPECTED_REGISTERED_FEATURES = [
|
|||
f !== 'session_hooks' &&
|
||||
f !== 'workspace_extensions' &&
|
||||
f !== 'session_branch' &&
|
||||
f !== 'workspace_qualified_rest_core' &&
|
||||
f !== 'voice_transcribe',
|
||||
),
|
||||
'workspace_settings',
|
||||
|
|
@ -382,6 +384,7 @@ const EXPECTED_REGISTERED_FEATURES = [
|
|||
'workspace_reload',
|
||||
'channel_reload',
|
||||
'multi_workspace_sessions',
|
||||
'workspace_qualified_rest_core',
|
||||
'client_mcp_over_ws',
|
||||
'cdp_tunnel_over_ws',
|
||||
'browser_automation_mcp',
|
||||
|
|
@ -6422,6 +6425,7 @@ describe('createServeApp', () => {
|
|||
|
||||
expect(res.status).toBe(400);
|
||||
expect(res.body.code).toBe('workspace_mismatch');
|
||||
expect(res.body.boundWorkspace).toBe(process.cwd());
|
||||
expect(res.body.requestedWorkspace).toBe(missingCwd);
|
||||
expect(bridge.loadCalls).toHaveLength(0);
|
||||
expect(bridge.resumeCalls).toHaveLength(0);
|
||||
|
|
@ -8273,6 +8277,7 @@ describe('createServeApp', () => {
|
|||
expect(res.status).toBe(400);
|
||||
expect(res.body.code).toBe('workspace_mismatch');
|
||||
expect(res.body.boundWorkspace).toBe(WS_BOUND);
|
||||
expect(res.body.requestedWorkspace).toBe(WS_DIFFERENT);
|
||||
expect(bridge.listCalls).toHaveLength(0);
|
||||
});
|
||||
|
||||
|
|
|
|||
|
|
@ -52,21 +52,36 @@ import {
|
|||
mountWebShellAssets,
|
||||
mountWebShellSpaFallback,
|
||||
} from './web-shell-static.js';
|
||||
import { mountWorkspaceMemoryRoutes } from './workspace-memory.js';
|
||||
import {
|
||||
mountWorkspaceMemoryRoutes,
|
||||
mountWorkspaceQualifiedMemoryRoutes,
|
||||
} from './workspace-memory.js';
|
||||
import {
|
||||
mountWorkspaceMemoryRememberRoutes,
|
||||
WorkspaceRememberTaskLane,
|
||||
} from './workspace-remember.js';
|
||||
import { mountWorkspaceAgentsRoutes } from './workspace-agents.js';
|
||||
import {
|
||||
mountWorkspaceAgentsRoutes,
|
||||
mountWorkspaceQualifiedAgentsRoutes,
|
||||
} from './workspace-agents.js';
|
||||
import { registerDaemonStatusRoutes } from './routes/daemon-status.js';
|
||||
import { createHealthDemoRoutes } from './routes/health-demo.js';
|
||||
import { registerWorkspaceAuthRoutes } from './routes/workspace-auth.js';
|
||||
import { registerWorkspaceExtensionRoutes } from './routes/workspace-extensions.js';
|
||||
import type { WorkspaceFileSystemFactory } from './fs/index.js';
|
||||
import { registerWorkspaceFileReadRoutes } from './routes/workspace-file-read.js';
|
||||
import { registerWorkspaceFileWriteRoutes } from './routes/workspace-file-write.js';
|
||||
import {
|
||||
registerWorkspaceFileReadRoutes,
|
||||
registerWorkspaceQualifiedFileReadRoutes,
|
||||
} from './routes/workspace-file-read.js';
|
||||
import {
|
||||
registerWorkspaceFileWriteRoutes,
|
||||
registerWorkspaceQualifiedFileWriteRoutes,
|
||||
} from './routes/workspace-file-write.js';
|
||||
import { registerWorkspaceSetupGithubRoutes } from './routes/workspace-setup-github.js';
|
||||
import { registerWorkspaceTrustRoutes } from './routes/workspace-trust.js';
|
||||
import {
|
||||
registerWorkspaceQualifiedTrustRoutes,
|
||||
registerWorkspaceTrustRoutes,
|
||||
} from './routes/workspace-trust.js';
|
||||
import { registerPermissionRoutes } from './routes/permission.js';
|
||||
import { registerSessionRoutes } from './routes/session.js';
|
||||
import { registerScheduledTasksRoutes } from './routes/scheduled-tasks.js';
|
||||
|
|
@ -77,6 +92,8 @@ import {
|
|||
} from './scheduled-task-keepalive.js';
|
||||
import {
|
||||
registerWorkspaceDiagnosticStatusRoutes,
|
||||
registerWorkspaceQualifiedDiagnosticStatusRoutes,
|
||||
registerWorkspaceQualifiedStatusRoutes,
|
||||
registerWorkspaceStatusRoutes,
|
||||
} from './routes/workspace-status.js';
|
||||
import {
|
||||
|
|
@ -84,8 +101,14 @@ import {
|
|||
type DaemonWorkspaceService,
|
||||
} from './workspace-service/index.js';
|
||||
import { registerCapabilitiesRoutes } from './routes/capabilities.js';
|
||||
import { registerWorkspacePermissionsRoutes } from './routes/workspace-permissions.js';
|
||||
import { registerWorkspaceSettingsRoutes } from './routes/workspace-settings.js';
|
||||
import {
|
||||
registerWorkspacePermissionsRoutes,
|
||||
registerWorkspaceQualifiedPermissionsRoutes,
|
||||
} from './routes/workspace-permissions.js';
|
||||
import {
|
||||
registerWorkspaceQualifiedSettingsRoutes,
|
||||
registerWorkspaceSettingsRoutes,
|
||||
} from './routes/workspace-settings.js';
|
||||
import {
|
||||
getActiveSseCount,
|
||||
registerSseEventsRoutes,
|
||||
|
|
@ -129,10 +152,23 @@ import {
|
|||
type WorkspaceRegistry,
|
||||
type WorkspaceRuntimeEnvMetadata,
|
||||
} from './workspace-registry.js';
|
||||
import { registerWorkspaceLifecycleRoutes } from './routes/workspace-lifecycle.js';
|
||||
import { registerWorkspaceMcpControlRoutes } from './routes/workspace-mcp-control.js';
|
||||
import {
|
||||
isPortableAbsolutePath,
|
||||
resolveRegisteredWorkspaceRuntimeByPathSelector,
|
||||
} from './workspace-route-runtime.js';
|
||||
import {
|
||||
registerWorkspaceLifecycleRoutes,
|
||||
registerWorkspaceQualifiedLifecycleRoutes,
|
||||
} from './routes/workspace-lifecycle.js';
|
||||
import {
|
||||
registerWorkspaceMcpControlRoutes,
|
||||
registerWorkspaceQualifiedMcpControlRoutes,
|
||||
} from './routes/workspace-mcp-control.js';
|
||||
import { registerWorkspaceChannelControlRoutes } from './routes/workspace-channel-control.js';
|
||||
import { registerWorkspaceToolsRoutes } from './routes/workspace-tools.js';
|
||||
import {
|
||||
registerWorkspaceQualifiedToolsRoutes,
|
||||
registerWorkspaceToolsRoutes,
|
||||
} from './routes/workspace-tools.js';
|
||||
|
||||
export {
|
||||
createDefaultFsAuditEmit,
|
||||
|
|
@ -768,10 +804,27 @@ export function createServeApp(
|
|||
});
|
||||
|
||||
app.use(
|
||||
daemonTelemetryMiddleware(
|
||||
() => primaryBoundWorkspace,
|
||||
deps.recordDaemonRequest,
|
||||
),
|
||||
daemonTelemetryMiddleware((req) => {
|
||||
const match = req.path.match(/^\/workspaces\/([^/]+)/);
|
||||
const rawSelector = match?.[1];
|
||||
if (rawSelector) {
|
||||
try {
|
||||
const selector = decodeURIComponent(rawSelector);
|
||||
const byId = workspaceRegistry.getByWorkspaceId(selector);
|
||||
if (byId) return byId.workspaceCwd;
|
||||
if (isPortableAbsolutePath(selector)) {
|
||||
const runtime = resolveRegisteredWorkspaceRuntimeByPathSelector(
|
||||
workspaceRegistry,
|
||||
selector,
|
||||
);
|
||||
if (runtime) return runtime.workspaceCwd;
|
||||
}
|
||||
} catch {
|
||||
return primaryBoundWorkspace;
|
||||
}
|
||||
}
|
||||
return primaryBoundWorkspace;
|
||||
}, deps.recordDaemonRequest),
|
||||
);
|
||||
|
||||
const buildWorkspaceCtx = createBuildWorkspaceCtx(primaryBoundWorkspace);
|
||||
|
|
@ -828,6 +881,10 @@ export function createServeApp(
|
|||
mutate,
|
||||
sendBridgeError,
|
||||
});
|
||||
registerWorkspaceQualifiedStatusRoutes(app, {
|
||||
workspaceRegistry,
|
||||
sendBridgeError,
|
||||
});
|
||||
|
||||
// Workspace memory + agents CRUD routes.
|
||||
mountWorkspaceMemoryRoutes(app, {
|
||||
|
|
@ -837,6 +894,12 @@ export function createServeApp(
|
|||
parseClientId: parseClientIdHeader,
|
||||
safeBody,
|
||||
});
|
||||
mountWorkspaceQualifiedMemoryRoutes(app, {
|
||||
workspaceRegistry,
|
||||
mutate,
|
||||
parseClientId: parseClientIdHeader,
|
||||
safeBody,
|
||||
});
|
||||
mountWorkspaceMemoryRememberRoutes(app, {
|
||||
bridge: primaryBridge,
|
||||
lane: workspaceRememberLane,
|
||||
|
|
@ -851,6 +914,12 @@ export function createServeApp(
|
|||
parseClientId: parseClientIdHeader,
|
||||
safeBody,
|
||||
});
|
||||
mountWorkspaceQualifiedAgentsRoutes(app, {
|
||||
workspaceRegistry,
|
||||
mutate,
|
||||
parseClientId: parseClientIdHeader,
|
||||
safeBody,
|
||||
});
|
||||
|
||||
registerWorkspaceDiagnosticStatusRoutes(app, {
|
||||
boundWorkspace: primaryBoundWorkspace,
|
||||
|
|
@ -859,6 +928,10 @@ export function createServeApp(
|
|||
mutate,
|
||||
sendBridgeError,
|
||||
});
|
||||
registerWorkspaceQualifiedDiagnosticStatusRoutes(app, {
|
||||
workspaceRegistry,
|
||||
sendBridgeError,
|
||||
});
|
||||
|
||||
registerWorkspaceExtensionRoutes(app, {
|
||||
boundWorkspace: primaryBoundWorkspace,
|
||||
|
|
@ -876,12 +949,23 @@ export function createServeApp(
|
|||
registerWorkspaceFileReadRoutes(app, {
|
||||
parseClientId: parseClientIdHeader,
|
||||
});
|
||||
registerWorkspaceQualifiedFileReadRoutes(app, {
|
||||
parseClientId: parseClientIdHeader,
|
||||
workspaceRegistry,
|
||||
});
|
||||
registerWorkspaceFileWriteRoutes(app, {
|
||||
bridge: primaryBridge,
|
||||
mutate,
|
||||
parseClientId: parseClientIdHeader,
|
||||
safeBody,
|
||||
});
|
||||
registerWorkspaceQualifiedFileWriteRoutes(app, {
|
||||
bridge: primaryBridge,
|
||||
mutate,
|
||||
parseClientId: parseClientIdHeader,
|
||||
safeBody,
|
||||
workspaceRegistry,
|
||||
});
|
||||
registerWorkspaceSetupGithubRoutes(app, {
|
||||
boundWorkspace: primaryBoundWorkspace,
|
||||
bridge: primaryBridge,
|
||||
|
|
@ -897,6 +981,11 @@ export function createServeApp(
|
|||
parseAndValidateClientId: (req, res) =>
|
||||
parseAndValidateWorkspaceClientId(req, res, primaryBridge),
|
||||
});
|
||||
registerWorkspaceQualifiedTrustRoutes(app, {
|
||||
workspaceRegistry,
|
||||
mutate,
|
||||
safeBody,
|
||||
});
|
||||
|
||||
const broadcastSettingsChanged = (
|
||||
key: string,
|
||||
|
|
@ -925,6 +1014,15 @@ export function createServeApp(
|
|||
parseAndValidateClientId: (req, res) =>
|
||||
parseAndValidateWorkspaceClientId(req, res, primaryBridge),
|
||||
});
|
||||
registerWorkspaceQualifiedSettingsRoutes(app, {
|
||||
workspaceRegistry,
|
||||
mutate,
|
||||
safeBody,
|
||||
persistSetting: async (...args) => {
|
||||
await persistSetting(...args);
|
||||
},
|
||||
invalidateServeFeaturesCache,
|
||||
});
|
||||
}
|
||||
registerWorkspacePermissionsRoutes(app, {
|
||||
boundWorkspace: primaryBoundWorkspace,
|
||||
|
|
@ -934,6 +1032,11 @@ export function createServeApp(
|
|||
parseAndValidateClientId: (req, res) =>
|
||||
parseAndValidateWorkspaceClientId(req, res, primaryBridge),
|
||||
});
|
||||
registerWorkspaceQualifiedPermissionsRoutes(app, {
|
||||
workspaceRegistry,
|
||||
mutate,
|
||||
safeBody,
|
||||
});
|
||||
registerWorkspaceVoiceRoutes(app, {
|
||||
boundWorkspace: primaryBoundWorkspace,
|
||||
mutate,
|
||||
|
|
@ -1000,6 +1103,12 @@ export function createServeApp(
|
|||
parseAndValidateClientId: (req, res) =>
|
||||
parseAndValidateWorkspaceClientId(req, res, primaryBridge),
|
||||
});
|
||||
registerWorkspaceQualifiedMcpControlRoutes(app, {
|
||||
workspaceRegistry,
|
||||
mutate,
|
||||
safeBody,
|
||||
sendBridgeError,
|
||||
});
|
||||
if (deps.getChannelWorkerSnapshot && deps.reloadChannelWorker) {
|
||||
const getChannelWorkerSnapshot = deps.getChannelWorkerSnapshot;
|
||||
const reloadChannelWorker = deps.reloadChannelWorker;
|
||||
|
|
@ -1022,6 +1131,13 @@ export function createServeApp(
|
|||
parseAndValidateClientId: (req, res) =>
|
||||
parseAndValidateWorkspaceClientId(req, res, primaryBridge),
|
||||
});
|
||||
registerWorkspaceQualifiedLifecycleRoutes(app, {
|
||||
workspaceRegistry,
|
||||
mutate,
|
||||
safeBody,
|
||||
sendBridgeError,
|
||||
invalidateServeFeaturesCache,
|
||||
});
|
||||
registerWorkspaceToolsRoutes(app, {
|
||||
boundWorkspace: primaryBoundWorkspace,
|
||||
workspace: primaryWorkspace,
|
||||
|
|
@ -1031,6 +1147,12 @@ export function createServeApp(
|
|||
parseAndValidateClientId: (req, res) =>
|
||||
parseAndValidateWorkspaceClientId(req, res, primaryBridge),
|
||||
});
|
||||
registerWorkspaceQualifiedToolsRoutes(app, {
|
||||
workspaceRegistry,
|
||||
mutate,
|
||||
safeBody,
|
||||
sendBridgeError,
|
||||
});
|
||||
|
||||
// Durable scheduled-tasks CRUD (the Web Shell "Scheduled tasks" page).
|
||||
// Reads/writes the per-project cron file only; firing stays with the
|
||||
|
|
|
|||
|
|
@ -118,6 +118,37 @@ describe('daemonTelemetryMiddleware — recordRequest seam', () => {
|
|||
);
|
||||
});
|
||||
|
||||
it('normalizes plural workspace agent routes to stable route labels', () => {
|
||||
const mw = daemonTelemetryMiddleware(() => '/ws');
|
||||
for (const [method, path, route] of [
|
||||
['GET', '/workspaces/ws-secondary/agents', 'GET /workspace/agents'],
|
||||
[
|
||||
'GET',
|
||||
'/workspaces/ws-secondary/agents/reviewer',
|
||||
'GET /workspace/agents/:agentType',
|
||||
],
|
||||
['POST', '/workspaces/ws-secondary/agents', 'POST /workspace/agents'],
|
||||
[
|
||||
'POST',
|
||||
'/workspaces/ws-secondary/agents/reviewer',
|
||||
'POST /workspace/agents/:agentType',
|
||||
],
|
||||
[
|
||||
'DELETE',
|
||||
'/workspaces/ws-secondary/agents/reviewer',
|
||||
'DELETE /workspace/agents/:agentType',
|
||||
],
|
||||
] as const) {
|
||||
const res = mockRes(200);
|
||||
mw(mockReq(method, path), res, vi.fn() as unknown as NextFunction);
|
||||
res.emit('finish');
|
||||
expect(coreMocks.withDaemonRequestSpan).toHaveBeenLastCalledWith(
|
||||
expect.objectContaining({ method, route }),
|
||||
expect.any(Function),
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
it('excludes the dashboard status poll (GET /daemon/status) from recordRequest', () => {
|
||||
const recordRequest = vi.fn();
|
||||
const mw = daemonTelemetryMiddleware(() => '/ws', recordRequest);
|
||||
|
|
|
|||
|
|
@ -117,6 +117,109 @@ export function resolveDaemonTelemetryRoute(
|
|||
if (req.method === 'GET' && /^\/workspaces\/[^/]+\/sessions$/.test(path)) {
|
||||
return { route: 'GET /workspace/:id/sessions' };
|
||||
}
|
||||
const pluralWorkspacePrefix = /^\/workspaces\/[^/]+/;
|
||||
if (pluralWorkspacePrefix.test(path)) {
|
||||
const suffix = path.replace(pluralWorkspacePrefix, '/workspace');
|
||||
if (req.method === 'GET') {
|
||||
if (
|
||||
suffix === '/workspace/mcp' ||
|
||||
suffix === '/workspace/skills' ||
|
||||
suffix === '/workspace/tools' ||
|
||||
suffix === '/workspace/providers' ||
|
||||
suffix === '/workspace/env' ||
|
||||
suffix === '/workspace/preflight' ||
|
||||
suffix === '/workspace/hooks' ||
|
||||
suffix === '/workspace/settings' ||
|
||||
suffix === '/workspace/permissions' ||
|
||||
suffix === '/workspace/trust' ||
|
||||
suffix === '/workspace/memory' ||
|
||||
suffix === '/workspace/agents'
|
||||
) {
|
||||
return { route: `GET ${suffix}` };
|
||||
}
|
||||
if (/^\/workspace\/agents\/[^/]+$/.test(suffix)) {
|
||||
return { route: 'GET /workspace/agents/:agentType' };
|
||||
}
|
||||
if (suffix === '/workspace/file') return { route: 'GET /file' };
|
||||
if (suffix === '/workspace/file/bytes') {
|
||||
return { route: 'GET /file/bytes' };
|
||||
}
|
||||
if (suffix === '/workspace/stat') return { route: 'GET /stat' };
|
||||
if (suffix === '/workspace/list') return { route: 'GET /list' };
|
||||
if (suffix === '/workspace/glob') return { route: 'GET /glob' };
|
||||
if (/^\/workspace\/mcp\/[^/]+\/tools$/.test(suffix)) {
|
||||
return { route: 'GET /workspace/mcp/:server/tools' };
|
||||
}
|
||||
if (/^\/workspace\/mcp\/[^/]+\/resources$/.test(suffix)) {
|
||||
return { route: 'GET /workspace/mcp/:server/resources' };
|
||||
}
|
||||
}
|
||||
if (req.method === 'POST') {
|
||||
if (
|
||||
suffix === '/workspace/settings' ||
|
||||
suffix === '/workspace/permissions' ||
|
||||
suffix === '/workspace/trust/request' ||
|
||||
suffix === '/workspace/init' ||
|
||||
suffix === '/workspace/reload' ||
|
||||
suffix === '/workspace/file/write' ||
|
||||
suffix === '/workspace/file/edit' ||
|
||||
suffix === '/workspace/mcp/servers' ||
|
||||
suffix === '/workspace/memory' ||
|
||||
suffix === '/workspace/agents' ||
|
||||
suffix === '/workspace/sessions/delete' ||
|
||||
suffix === '/workspace/sessions/archive' ||
|
||||
suffix === '/workspace/sessions/unarchive' ||
|
||||
suffix === '/workspace/session-groups'
|
||||
) {
|
||||
return { route: `POST ${suffix}` };
|
||||
}
|
||||
if (/^\/workspace\/tools\/[^/]+\/enable$/.test(suffix)) {
|
||||
return { route: 'POST /workspace/tools/:name/enable' };
|
||||
}
|
||||
if (/^\/workspace\/mcp\/[^/]+\/restart$/.test(suffix)) {
|
||||
return { route: 'POST /workspace/mcp/:server/restart' };
|
||||
}
|
||||
if (/^\/workspace\/agents\/[^/]+$/.test(suffix)) {
|
||||
return { route: 'POST /workspace/agents/:agentType' };
|
||||
}
|
||||
if (
|
||||
/^\/workspace\/mcp\/[^/]+\/(enable|disable|authenticate|clear-auth)$/.test(
|
||||
suffix,
|
||||
)
|
||||
) {
|
||||
return {
|
||||
route: `POST /workspace/mcp/:server/${suffix.split('/').at(-1)}`,
|
||||
};
|
||||
}
|
||||
}
|
||||
if (
|
||||
req.method === 'DELETE' &&
|
||||
/^\/workspace\/mcp\/servers\/[^/]+$/.test(suffix)
|
||||
) {
|
||||
return { route: 'DELETE /workspace/mcp/servers/:name' };
|
||||
}
|
||||
if (
|
||||
req.method === 'DELETE' &&
|
||||
/^\/workspace\/agents\/[^/]+$/.test(suffix)
|
||||
) {
|
||||
return { route: 'DELETE /workspace/agents/:agentType' };
|
||||
}
|
||||
if (suffix === '/workspace/session-groups' && req.method === 'GET') {
|
||||
return { route: 'GET /workspace/session-groups' };
|
||||
}
|
||||
if (
|
||||
/^\/workspace\/session-groups\/[^/]+$/.test(suffix) &&
|
||||
req.method === 'PATCH'
|
||||
) {
|
||||
return { route: 'PATCH /workspace/session-groups/:groupId' };
|
||||
}
|
||||
if (
|
||||
/^\/workspace\/session-groups\/[^/]+$/.test(suffix) &&
|
||||
req.method === 'DELETE'
|
||||
) {
|
||||
return { route: 'DELETE /workspace/session-groups/:groupId' };
|
||||
}
|
||||
}
|
||||
if (req.method === 'POST' && path === '/workspace/init') {
|
||||
return { route: 'POST /workspace/init' };
|
||||
}
|
||||
|
|
|
|||
|
|
@ -23,6 +23,14 @@ import {
|
|||
type AcpSessionBridge,
|
||||
} from './acp-session-bridge.js';
|
||||
import { safeLogValue } from './server/request-helpers.js';
|
||||
import {
|
||||
requireTrustedWorkspaceRuntime,
|
||||
resolveWorkspaceRuntimeFromParam,
|
||||
} from './workspace-route-runtime.js';
|
||||
import type {
|
||||
WorkspaceRegistry,
|
||||
WorkspaceRuntime,
|
||||
} from './workspace-registry.js';
|
||||
|
||||
/**
|
||||
* Pattern for the route-layer `:agentType` URL parameter. Matches the
|
||||
|
|
@ -101,6 +109,13 @@ export interface WorkspaceAgentsRouteDeps {
|
|||
safeBody: (req: Request) => Record<string, unknown>;
|
||||
}
|
||||
|
||||
export interface WorkspaceQualifiedAgentsRouteDeps {
|
||||
workspaceRegistry: WorkspaceRegistry;
|
||||
mutate: (opts?: { strict?: boolean }) => RequestHandler;
|
||||
parseClientId: (req: Request, res: Response) => string | undefined | null;
|
||||
safeBody: (req: Request) => Record<string, unknown>;
|
||||
}
|
||||
|
||||
export function mountWorkspaceAgentsRoutes(
|
||||
app: Application,
|
||||
deps: WorkspaceAgentsRouteDeps,
|
||||
|
|
@ -680,6 +695,318 @@ export function mountWorkspaceAgentsRoutes(
|
|||
);
|
||||
}
|
||||
|
||||
export function mountWorkspaceQualifiedAgentsRoutes(
|
||||
app: Application,
|
||||
deps: WorkspaceQualifiedAgentsRouteDeps,
|
||||
): void {
|
||||
app.get('/workspaces/:workspace/agents', async (req, res) => {
|
||||
const runtime = resolveTrustedWorkspaceAgentRuntime(deps, req, res);
|
||||
if (!runtime) return;
|
||||
const scopedLevel = parseWorkspaceOnlyAgentScopeQuery(req, res);
|
||||
if (scopedLevel === null) return;
|
||||
const manager = createDaemonSubagentManager(runtime.workspaceCwd);
|
||||
try {
|
||||
const agents = await manager.listSubagents({ force: true });
|
||||
const status: ServeWorkspaceAgentsStatus = {
|
||||
v: STATUS_SCHEMA_VERSION,
|
||||
workspaceCwd: runtime.workspaceCwd,
|
||||
agents: agents
|
||||
.filter((agent) => agent.level === 'project')
|
||||
.map(toSummary),
|
||||
};
|
||||
res.status(200).json(status);
|
||||
} catch (err) {
|
||||
writeStderrLine(
|
||||
`qwen serve: GET /workspaces/:workspace/agents failed: ${
|
||||
err instanceof Error ? (err.stack ?? err.message) : String(err)
|
||||
}`,
|
||||
);
|
||||
res.status(500).json({
|
||||
error: 'Failed to list workspace agents',
|
||||
code: 'agent_list_failed',
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
app.post(
|
||||
'/workspaces/:workspace/agents',
|
||||
deps.mutate({ strict: true }),
|
||||
async (req, res) => {
|
||||
const runtime = resolveTrustedWorkspaceAgentRuntime(deps, req, res);
|
||||
if (!runtime) return;
|
||||
const routeDeps = workspaceAgentDepsForRuntime(runtime, deps);
|
||||
const body = deps.safeBody(req);
|
||||
const clientIdResult = resolveOriginatorClientId(routeDeps, req, res);
|
||||
if (clientIdResult === null) return;
|
||||
const originatorClientId = clientIdResult;
|
||||
|
||||
const level = parseWorkspaceOnlyAgentBodyScope(body, res);
|
||||
if (level === null) return;
|
||||
const manager = createDaemonSubagentManager(runtime.workspaceCwd);
|
||||
const config = parseAgentConfig(body, level, res);
|
||||
if (!config) return;
|
||||
|
||||
const collision = await manager.loadSubagent(config.name, level);
|
||||
if (collision) {
|
||||
res.status(409).json({
|
||||
error: `Subagent "${config.name}" already exists at ${level} level`,
|
||||
code: 'agent_already_exists',
|
||||
name: config.name,
|
||||
level,
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
await manager.createSubagent(config, { level });
|
||||
} catch (err) {
|
||||
if (sendCreateAgentError(res, err, config.name)) return;
|
||||
writeStderrLine(
|
||||
`qwen serve: POST /workspaces/:workspace/agents failed: ${
|
||||
err instanceof Error ? (err.stack ?? err.message) : String(err)
|
||||
}`,
|
||||
);
|
||||
res.status(500).json({
|
||||
error: 'Failed to create workspace agent',
|
||||
code: 'agent_create_failed',
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
const created = await manager.loadSubagent(config.name, level);
|
||||
if (!created) {
|
||||
writeStderrLine(
|
||||
`qwen serve: agent_create_reload_failed (name=${safeLogValue(config.name)} ` +
|
||||
`level=${level}) — file likely persisted on disk; check ` +
|
||||
'`GET /workspaces/:workspace/agents` for a phantom entry',
|
||||
);
|
||||
res.status(500).json({
|
||||
error: 'Agent creation succeeded but reload failed',
|
||||
code: 'agent_create_reload_failed',
|
||||
name: config.name,
|
||||
level,
|
||||
});
|
||||
return;
|
||||
}
|
||||
runtime.bridge.publishWorkspaceEvent({
|
||||
type: 'agent_changed',
|
||||
data: { change: 'created', name: config.name, level: 'project' },
|
||||
...(originatorClientId ? { originatorClientId } : {}),
|
||||
});
|
||||
res.status(201).json({ ok: true, agent: toDetail(created) });
|
||||
},
|
||||
);
|
||||
|
||||
app.get('/workspaces/:workspace/agents/:agentType', async (req, res) => {
|
||||
const runtime = resolveTrustedWorkspaceAgentRuntime(deps, req, res);
|
||||
if (!runtime) return;
|
||||
const agentType = validateAgentType(req, res);
|
||||
if (agentType === null) return;
|
||||
const scopedLevel = parseWorkspaceOnlyAgentScopeQuery(req, res);
|
||||
if (scopedLevel === null) return;
|
||||
const manager = createDaemonSubagentManager(runtime.workspaceCwd);
|
||||
try {
|
||||
const config = await manager.loadSubagent(agentType, scopedLevel);
|
||||
if (!config) {
|
||||
res.status(404).json({
|
||||
error: `Subagent "${agentType}" not found`,
|
||||
code: 'agent_not_found',
|
||||
name: agentType,
|
||||
});
|
||||
return;
|
||||
}
|
||||
res.status(200).json(toDetail(config));
|
||||
} catch (err) {
|
||||
writeStderrLine(
|
||||
`qwen serve: GET /workspaces/:workspace/agents/${safeLogValue(agentType)} failed: ${
|
||||
err instanceof Error ? (err.stack ?? err.message) : String(err)
|
||||
}`,
|
||||
);
|
||||
res.status(500).json({
|
||||
error: 'Failed to read workspace agent',
|
||||
code: 'agent_read_failed',
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
app.post(
|
||||
'/workspaces/:workspace/agents/:agentType',
|
||||
deps.mutate({ strict: true }),
|
||||
async (req, res) => {
|
||||
const runtime = resolveTrustedWorkspaceAgentRuntime(deps, req, res);
|
||||
if (!runtime) return;
|
||||
const agentType = validateAgentType(req, res);
|
||||
if (agentType === null) return;
|
||||
const scopedLevel = parseWorkspaceOnlyAgentScopeQuery(req, res);
|
||||
if (scopedLevel === null) return;
|
||||
const routeDeps = workspaceAgentDepsForRuntime(runtime, deps);
|
||||
const clientIdResult = resolveOriginatorClientId(routeDeps, req, res);
|
||||
if (clientIdResult === null) return;
|
||||
const originatorClientId = clientIdResult;
|
||||
|
||||
const body = deps.safeBody(req);
|
||||
const updates = parseAgentUpdates(body, res);
|
||||
if (!updates) return;
|
||||
const manager = createDaemonSubagentManager(runtime.workspaceCwd);
|
||||
const existing = await manager.loadSubagent(agentType, scopedLevel);
|
||||
if (!existing) {
|
||||
res.status(404).json({
|
||||
error: `Subagent "${agentType}" not found`,
|
||||
code: 'agent_not_found',
|
||||
name: agentType,
|
||||
});
|
||||
return;
|
||||
}
|
||||
if (assertMutableLevel(existing, agentType, res)) return;
|
||||
|
||||
if (Object.keys(updates).length === 0) {
|
||||
res.status(400).json({
|
||||
error:
|
||||
'`POST /workspaces/:workspace/agents/:agentType` requires at least one updatable field in the body',
|
||||
code: 'invalid_config',
|
||||
name: agentType,
|
||||
});
|
||||
return;
|
||||
}
|
||||
if (isNoOpUpdate(existing, updates)) {
|
||||
res.status(200).json({
|
||||
ok: true,
|
||||
agent: toDetail(existing),
|
||||
changed: false,
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
await manager.updateSubagent(agentType, updates, existing.level);
|
||||
} catch (err) {
|
||||
if (sendUpdateAgentError(res, err, agentType)) return;
|
||||
writeStderrLine(
|
||||
`qwen serve: POST /workspaces/:workspace/agents/${safeLogValue(agentType)} failed: ${
|
||||
err instanceof Error ? (err.stack ?? err.message) : String(err)
|
||||
}`,
|
||||
);
|
||||
res.status(500).json({
|
||||
error: 'Failed to update workspace agent',
|
||||
code: 'agent_update_failed',
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
const updated = await manager.loadSubagent(agentType, existing.level);
|
||||
if (!updated) {
|
||||
writeStderrLine(
|
||||
`qwen serve: agent_update_reload_failed (name=${safeLogValue(agentType)} ` +
|
||||
`level=${existing.level}) — disk write completed; check ` +
|
||||
`\`GET /workspaces/:workspace/agents/${safeLogValue(agentType)}\` for the new state`,
|
||||
);
|
||||
res.status(500).json({
|
||||
error: 'Agent update succeeded but reload failed',
|
||||
code: 'agent_update_reload_failed',
|
||||
name: agentType,
|
||||
level: existing.level,
|
||||
});
|
||||
return;
|
||||
}
|
||||
runtime.bridge.publishWorkspaceEvent({
|
||||
type: 'agent_changed',
|
||||
data: { change: 'updated', name: existing.name, level: 'project' },
|
||||
...(originatorClientId ? { originatorClientId } : {}),
|
||||
});
|
||||
res
|
||||
.status(200)
|
||||
.json({ ok: true, agent: toDetail(updated), changed: true });
|
||||
},
|
||||
);
|
||||
|
||||
app.delete(
|
||||
'/workspaces/:workspace/agents/:agentType',
|
||||
deps.mutate({ strict: true }),
|
||||
async (req, res) => {
|
||||
const runtime = resolveTrustedWorkspaceAgentRuntime(deps, req, res);
|
||||
if (!runtime) return;
|
||||
const agentType = validateAgentType(req, res);
|
||||
if (agentType === null) return;
|
||||
const scopedLevel = parseWorkspaceOnlyAgentScopeQuery(req, res);
|
||||
if (scopedLevel === null) return;
|
||||
const routeDeps = workspaceAgentDepsForRuntime(runtime, deps);
|
||||
const clientIdResult = resolveOriginatorClientId(routeDeps, req, res);
|
||||
if (clientIdResult === null) return;
|
||||
const originatorClientId = clientIdResult;
|
||||
const manager = createDaemonSubagentManager(runtime.workspaceCwd);
|
||||
|
||||
const existing = await manager.loadSubagent(agentType, scopedLevel);
|
||||
if (existing && assertMutableLevel(existing, agentType, res)) return;
|
||||
|
||||
try {
|
||||
await manager.deleteSubagent(agentType, scopedLevel);
|
||||
} catch (err) {
|
||||
if (err instanceof SubagentError) {
|
||||
if (err.code === SubagentErrorCode.NOT_FOUND) {
|
||||
res.status(404).json({
|
||||
error: err.message,
|
||||
code: 'agent_not_found',
|
||||
name: err.subagentName ?? agentType,
|
||||
});
|
||||
return;
|
||||
}
|
||||
if (err.code === SubagentErrorCode.INVALID_CONFIG) {
|
||||
res.status(403).json({
|
||||
error: err.message,
|
||||
code: 'agent_readonly',
|
||||
name: err.subagentName ?? agentType,
|
||||
});
|
||||
return;
|
||||
}
|
||||
}
|
||||
writeStderrLine(
|
||||
`qwen serve: DELETE /workspaces/:workspace/agents/${safeLogValue(agentType)} failed: ${
|
||||
err instanceof Error ? (err.stack ?? err.message) : String(err)
|
||||
}`,
|
||||
);
|
||||
res.status(500).json({
|
||||
error: 'Failed to delete workspace agent',
|
||||
code: 'agent_delete_failed',
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
if (existing?.filePath) {
|
||||
try {
|
||||
await fs.access(existing.filePath);
|
||||
writeStderrLine(
|
||||
`qwen serve: DELETE /workspaces/:workspace/agents/${safeLogValue(agentType)} partial — ` +
|
||||
`remaining=${existing.level}:${existing.filePath}`,
|
||||
);
|
||||
res.status(500).json({
|
||||
error:
|
||||
`Failed to delete subagent "${agentType}" — ` +
|
||||
`${existing.level} level still has its file on disk`,
|
||||
code: 'agent_delete_partial',
|
||||
name: agentType,
|
||||
removedLevels: [],
|
||||
remainingLevels: [existing.level],
|
||||
});
|
||||
return;
|
||||
} catch {
|
||||
// Access failure means the project-level file is gone.
|
||||
}
|
||||
}
|
||||
|
||||
runtime.bridge.publishWorkspaceEvent({
|
||||
type: 'agent_changed',
|
||||
data: {
|
||||
change: 'deleted',
|
||||
name: existing?.name ?? agentType,
|
||||
level: 'project',
|
||||
},
|
||||
...(originatorClientId ? { originatorClientId } : {}),
|
||||
});
|
||||
res.status(204).end();
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Pull `:agentType` off the request and reject malformed values at
|
||||
* the route boundary. Returns the validated string, or `null` AFTER
|
||||
|
|
@ -794,6 +1121,167 @@ function resolveOriginatorClientId(
|
|||
return clientId;
|
||||
}
|
||||
|
||||
function resolveTrustedWorkspaceAgentRuntime(
|
||||
deps: WorkspaceQualifiedAgentsRouteDeps,
|
||||
req: Request,
|
||||
res: Response,
|
||||
): WorkspaceRuntime | undefined {
|
||||
const runtime = resolveWorkspaceRuntimeFromParam(
|
||||
deps.workspaceRegistry,
|
||||
req,
|
||||
res,
|
||||
);
|
||||
if (!runtime) return undefined;
|
||||
if (!requireTrustedWorkspaceRuntime(runtime, res)) return undefined;
|
||||
return runtime;
|
||||
}
|
||||
|
||||
function workspaceAgentDepsForRuntime(
|
||||
runtime: WorkspaceRuntime,
|
||||
deps: WorkspaceQualifiedAgentsRouteDeps,
|
||||
): WorkspaceAgentsRouteDeps {
|
||||
return {
|
||||
bridge: runtime.bridge,
|
||||
boundWorkspace: runtime.workspaceCwd,
|
||||
mutate: deps.mutate,
|
||||
parseClientId: deps.parseClientId,
|
||||
safeBody: deps.safeBody,
|
||||
};
|
||||
}
|
||||
|
||||
function parseWorkspaceOnlyAgentBodyScope(
|
||||
body: Record<string, unknown>,
|
||||
res: Response,
|
||||
): SubagentLevel | null {
|
||||
const scope = body['scope'];
|
||||
if (scope === undefined || scope === 'workspace' || scope === 'project') {
|
||||
return 'project';
|
||||
}
|
||||
return rejectWorkspaceQualifiedAgentScope(scope, res);
|
||||
}
|
||||
|
||||
function parseWorkspaceOnlyAgentScopeQuery(
|
||||
req: Request,
|
||||
res: Response,
|
||||
): SubagentLevel | null {
|
||||
const raw = req.query['scope'];
|
||||
if (raw === undefined || raw === 'workspace' || raw === 'project') {
|
||||
return 'project';
|
||||
}
|
||||
if (typeof raw !== 'string') {
|
||||
res.status(400).json({
|
||||
error: '`scope` query must be a single workspace/project value',
|
||||
code: 'invalid_scope',
|
||||
});
|
||||
return null;
|
||||
}
|
||||
return rejectWorkspaceQualifiedAgentScope(raw, res);
|
||||
}
|
||||
|
||||
function rejectWorkspaceQualifiedAgentScope(
|
||||
scope: unknown,
|
||||
res: Response,
|
||||
): null {
|
||||
if (scope === 'global' || scope === 'user') {
|
||||
res.status(400).json({
|
||||
error:
|
||||
'Workspace-qualified agents routes only support workspace/project scope',
|
||||
code: 'global_scope_not_supported_for_workspace_route',
|
||||
});
|
||||
return null;
|
||||
}
|
||||
res.status(400).json({
|
||||
error: '`scope` must be "workspace" or "project"',
|
||||
code: 'invalid_scope',
|
||||
});
|
||||
return null;
|
||||
}
|
||||
|
||||
function sendCreateAgentError(
|
||||
res: Response,
|
||||
err: unknown,
|
||||
name: string,
|
||||
): boolean {
|
||||
if (!(err instanceof SubagentError)) return false;
|
||||
if (err.code === SubagentErrorCode.ALREADY_EXISTS) {
|
||||
res.status(409).json({
|
||||
error: err.message,
|
||||
code: 'agent_already_exists',
|
||||
name: err.subagentName ?? name,
|
||||
});
|
||||
return true;
|
||||
}
|
||||
if (
|
||||
err.code === SubagentErrorCode.VALIDATION_ERROR ||
|
||||
err.code === SubagentErrorCode.INVALID_CONFIG ||
|
||||
err.code === SubagentErrorCode.INVALID_NAME ||
|
||||
err.code === SubagentErrorCode.TOOL_NOT_FOUND
|
||||
) {
|
||||
res.status(422).json({
|
||||
error: err.message,
|
||||
code: 'invalid_config',
|
||||
name: err.subagentName ?? name,
|
||||
});
|
||||
return true;
|
||||
}
|
||||
if (err.code === SubagentErrorCode.FILE_ERROR) {
|
||||
const debug = isServeDebugMode();
|
||||
res.status(500).json({
|
||||
error: debug ? err.message : 'Failed to write workspace agent file',
|
||||
code: 'file_error',
|
||||
name: err.subagentName ?? name,
|
||||
});
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
function sendUpdateAgentError(
|
||||
res: Response,
|
||||
err: unknown,
|
||||
agentType: string,
|
||||
): boolean {
|
||||
if (!(err instanceof SubagentError)) return false;
|
||||
if (err.code === SubagentErrorCode.NOT_FOUND) {
|
||||
res.status(404).json({
|
||||
error: err.message,
|
||||
code: 'agent_not_found',
|
||||
name: err.subagentName ?? agentType,
|
||||
});
|
||||
return true;
|
||||
}
|
||||
if (err.code === SubagentErrorCode.INVALID_CONFIG) {
|
||||
res.status(403).json({
|
||||
error: err.message,
|
||||
code: 'agent_readonly',
|
||||
name: err.subagentName ?? agentType,
|
||||
});
|
||||
return true;
|
||||
}
|
||||
if (
|
||||
err.code === SubagentErrorCode.VALIDATION_ERROR ||
|
||||
err.code === SubagentErrorCode.INVALID_NAME ||
|
||||
err.code === SubagentErrorCode.TOOL_NOT_FOUND
|
||||
) {
|
||||
res.status(422).json({
|
||||
error: err.message,
|
||||
code: 'invalid_config',
|
||||
name: err.subagentName ?? agentType,
|
||||
});
|
||||
return true;
|
||||
}
|
||||
if (err.code === SubagentErrorCode.FILE_ERROR) {
|
||||
const debug = isServeDebugMode();
|
||||
res.status(500).json({
|
||||
error: debug ? err.message : 'Failed to write workspace agent file',
|
||||
code: 'file_error',
|
||||
name: err.subagentName ?? agentType,
|
||||
});
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
function parseAgentConfig(
|
||||
body: Record<string, unknown>,
|
||||
level: SubagentLevel,
|
||||
|
|
|
|||
|
|
@ -24,6 +24,11 @@ import {
|
|||
type ServeWorkspaceMemoryFile,
|
||||
type ServeWorkspaceMemoryStatus,
|
||||
} from '@qwen-code/acp-bridge/status';
|
||||
import {
|
||||
requireTrustedWorkspaceRuntime,
|
||||
resolveWorkspaceRuntimeFromParam,
|
||||
} from './workspace-route-runtime.js';
|
||||
import type { WorkspaceRegistry } from './workspace-registry.js';
|
||||
|
||||
/**
|
||||
* Issue #4175 PR 16: workspace memory CRUD routes.
|
||||
|
|
@ -79,6 +84,78 @@ export interface WorkspaceMemoryRouteDeps {
|
|||
|
||||
const MAX_MEMORY_CONTENT_BYTES = 1024 * 1024;
|
||||
|
||||
function sendWorkspaceMemoryWriteError(
|
||||
res: Response,
|
||||
err: unknown,
|
||||
options: {
|
||||
route: string;
|
||||
scope: ServeContextFileScope;
|
||||
mode: 'append' | 'replace';
|
||||
},
|
||||
): void {
|
||||
const { route, scope, mode } = options;
|
||||
if (err instanceof WorkspaceMemoryWriteTimeoutError) {
|
||||
writeStderrLine(
|
||||
`qwen serve: ${route} timeout — file lock at ` +
|
||||
`${err.filePath} did not acquire within ${err.timeoutMs}ms ` +
|
||||
`(stalled FS / OneDrive / NFS)`,
|
||||
);
|
||||
const debug = isServeDebugMode();
|
||||
res.status(500).json({
|
||||
error: debug
|
||||
? err.message
|
||||
: 'Workspace memory write timed out waiting for the per-file lock. Retry or restart the daemon.',
|
||||
code: 'memory_write_timeout',
|
||||
scope,
|
||||
mode,
|
||||
timeoutMs: err.timeoutMs,
|
||||
...(debug ? { filePath: err.filePath } : {}),
|
||||
});
|
||||
return;
|
||||
}
|
||||
if (err instanceof WorkspaceMemoryFileTooLargeError) {
|
||||
writeStderrLine(
|
||||
`qwen serve: ${route} refused — existing file ` +
|
||||
`${err.filePath} is ${err.bytes} bytes (cap ${err.limit})`,
|
||||
);
|
||||
const debug = isServeDebugMode();
|
||||
res.status(413).json({
|
||||
error: debug
|
||||
? err.message
|
||||
: 'Existing memory file exceeds the safe-append cap. Trim the file or POST with mode=replace.',
|
||||
code: 'memory_file_too_large',
|
||||
scope,
|
||||
mode,
|
||||
...(debug ? { filePath: err.filePath } : {}),
|
||||
bytes: err.bytes,
|
||||
limit: err.limit,
|
||||
});
|
||||
return;
|
||||
}
|
||||
writeStderrLine(
|
||||
`qwen serve: ${route} failed (scope=${scope} mode=${mode}): ${
|
||||
err instanceof Error ? (err.stack ?? err.message) : String(err)
|
||||
}`,
|
||||
);
|
||||
const osCode =
|
||||
err && typeof err === 'object' && 'code' in err
|
||||
? (err as { code?: unknown }).code
|
||||
: undefined;
|
||||
const debug = isServeDebugMode();
|
||||
res.status(500).json({
|
||||
error: 'Failed to write workspace memory',
|
||||
code: 'file_error',
|
||||
scope,
|
||||
mode,
|
||||
...(typeof osCode === 'string' ? { osCode } : {}),
|
||||
...(debug
|
||||
? {
|
||||
errorMessage: err instanceof Error ? err.message : String(err),
|
||||
}
|
||||
: {}),
|
||||
});
|
||||
}
|
||||
|
||||
/** Mount the two memory routes on the supplied Express app. */
|
||||
export function mountWorkspaceMemoryRoutes(
|
||||
app: Application,
|
||||
|
|
@ -216,89 +293,138 @@ export function mountWorkspaceMemoryRoutes(
|
|||
}
|
||||
res.status(200).json(responseBody);
|
||||
} catch (err) {
|
||||
// 413 + structured fields for the "memory file is past the
|
||||
// safe-append cap" case so callers can tell pathological
|
||||
// file size apart from generic file errors. The helper
|
||||
// refuses to pull >16 MB into memory on append; clients
|
||||
// either trim the file or switch to mode=replace.
|
||||
if (err instanceof WorkspaceMemoryWriteTimeoutError) {
|
||||
writeStderrLine(
|
||||
`qwen serve: POST /workspace/memory timeout — file lock at ` +
|
||||
`${err.filePath} did not acquire within ${err.timeoutMs}ms ` +
|
||||
`(stalled FS / OneDrive / NFS)`,
|
||||
);
|
||||
const debug = isServeDebugMode();
|
||||
res.status(500).json({
|
||||
error: debug
|
||||
? err.message
|
||||
: 'Workspace memory write timed out waiting for the per-file lock. Retry or restart the daemon.',
|
||||
code: 'memory_write_timeout',
|
||||
scope,
|
||||
mode,
|
||||
timeoutMs: err.timeoutMs,
|
||||
...(debug ? { filePath: err.filePath } : {}),
|
||||
});
|
||||
return;
|
||||
}
|
||||
if (err instanceof WorkspaceMemoryFileTooLargeError) {
|
||||
writeStderrLine(
|
||||
`qwen serve: POST /workspace/memory refused — existing file ` +
|
||||
`${err.filePath} is ${err.bytes} bytes (cap ${err.limit})`,
|
||||
);
|
||||
// Path disclosure: both `error` (which embeds the absolute
|
||||
// file path in the constructor message — see
|
||||
// `WorkspaceMemoryFileTooLargeError`) and `filePath` are
|
||||
// gated behind QWEN_SERVE_DEBUG so production responses
|
||||
// don't include `/Users/<x>/.qwen/...` in the body.
|
||||
// Operators triaging an issue locally enable the debug
|
||||
// toggle to get the full text; in default mode SDK
|
||||
// callers branch on `code` + `bytes` / `limit` instead
|
||||
// (the structured discriminator survives without the
|
||||
// disclosure).
|
||||
const debug = isServeDebugMode();
|
||||
res.status(413).json({
|
||||
error: debug
|
||||
? err.message
|
||||
: 'Existing memory file exceeds the safe-append cap. Trim the file or POST with mode=replace.',
|
||||
code: 'memory_file_too_large',
|
||||
scope,
|
||||
mode,
|
||||
...(debug ? { filePath: err.filePath } : {}),
|
||||
bytes: err.bytes,
|
||||
limit: err.limit,
|
||||
});
|
||||
return;
|
||||
}
|
||||
writeStderrLine(
|
||||
`qwen serve: POST /workspace/memory failed (scope=${scope} mode=${mode}): ${
|
||||
err instanceof Error ? (err.stack ?? err.message) : String(err)
|
||||
}`,
|
||||
);
|
||||
// Surface enough context for callers to debug without leaking
|
||||
// absolute paths in the response body. `osCode` (`EACCES` /
|
||||
// `EROFS` / `EDQUOT` / `ENOSPC` / ...) stays unconditional so
|
||||
// SDK clients can branch on the failure class. The full
|
||||
// `errorMessage` (which often embeds the file path on Node's
|
||||
// ENOENT/EACCES messages) is gated behind `QWEN_SERVE_DEBUG`.
|
||||
// Without the debug toggle, callers see only the generic
|
||||
// `error` + `code` + `osCode` envelope; the daemon's stderr
|
||||
// log has the full message for the operator.
|
||||
const osCode =
|
||||
err && typeof err === 'object' && 'code' in err
|
||||
? (err as { code?: unknown }).code
|
||||
: undefined;
|
||||
const debug = isServeDebugMode();
|
||||
res.status(500).json({
|
||||
error: 'Failed to write workspace memory',
|
||||
code: 'file_error',
|
||||
sendWorkspaceMemoryWriteError(res, err, {
|
||||
route: 'POST /workspace/memory',
|
||||
scope,
|
||||
mode,
|
||||
...(typeof osCode === 'string' ? { osCode } : {}),
|
||||
...(debug
|
||||
? {
|
||||
errorMessage: err instanceof Error ? err.message : String(err),
|
||||
}
|
||||
: {}),
|
||||
});
|
||||
}
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
export function mountWorkspaceQualifiedMemoryRoutes(
|
||||
app: Application,
|
||||
deps: Omit<WorkspaceMemoryRouteDeps, 'bridge' | 'boundWorkspace'> & {
|
||||
workspaceRegistry: WorkspaceRegistry;
|
||||
},
|
||||
): void {
|
||||
app.get('/workspaces/:workspace/memory', async (req, res) => {
|
||||
const runtime = resolveWorkspaceRuntimeFromParam(
|
||||
deps.workspaceRegistry,
|
||||
req,
|
||||
res,
|
||||
);
|
||||
if (!runtime || !requireTrustedWorkspaceRuntime(runtime, res)) return;
|
||||
try {
|
||||
const collectStatus = deps.collectStatus ?? collectWorkspaceMemoryStatus;
|
||||
res.status(200).json(await collectStatus(runtime.workspaceCwd));
|
||||
} catch (err) {
|
||||
writeStderrLine(
|
||||
`qwen serve: GET /workspaces/:workspace/memory failed: ${
|
||||
err instanceof Error ? (err.stack ?? err.message) : String(err)
|
||||
}`,
|
||||
);
|
||||
res.status(500).json({
|
||||
error: 'Failed to discover workspace memory',
|
||||
code: 'memory_discovery_failed',
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
app.post(
|
||||
'/workspaces/:workspace/memory',
|
||||
deps.mutate({ strict: true }),
|
||||
async (req, res) => {
|
||||
const runtime = resolveWorkspaceRuntimeFromParam(
|
||||
deps.workspaceRegistry,
|
||||
req,
|
||||
res,
|
||||
);
|
||||
if (!runtime || !requireTrustedWorkspaceRuntime(runtime, res)) return;
|
||||
const body = deps.safeBody(req);
|
||||
if (body['scope'] !== 'workspace') {
|
||||
res.status(400).json({
|
||||
error:
|
||||
'workspace-qualified memory routes only support "workspace" scope',
|
||||
code: 'global_scope_not_supported_for_workspace_route',
|
||||
});
|
||||
return;
|
||||
}
|
||||
const modeRaw = body['mode'];
|
||||
if (
|
||||
modeRaw !== undefined &&
|
||||
modeRaw !== 'append' &&
|
||||
modeRaw !== 'replace'
|
||||
) {
|
||||
res.status(400).json({
|
||||
error: '`mode` must be "append", "replace", or omitted',
|
||||
code: 'invalid_mode',
|
||||
});
|
||||
return;
|
||||
}
|
||||
const mode: 'append' | 'replace' =
|
||||
modeRaw === 'replace' ? 'replace' : 'append';
|
||||
const content = body['content'];
|
||||
if (typeof content !== 'string') {
|
||||
res.status(400).json({
|
||||
error: '`content` must be a string',
|
||||
code: 'invalid_content',
|
||||
});
|
||||
return;
|
||||
}
|
||||
if (Buffer.byteLength(content, 'utf8') > MAX_MEMORY_CONTENT_BYTES) {
|
||||
res.status(400).json({
|
||||
error: `\`content\` exceeds the ${MAX_MEMORY_CONTENT_BYTES}-byte limit`,
|
||||
code: 'content_too_large',
|
||||
});
|
||||
return;
|
||||
}
|
||||
const clientId = deps.parseClientId(req, res);
|
||||
if (clientId === null) return;
|
||||
let originatorClientId: string | undefined;
|
||||
if (clientId !== undefined) {
|
||||
if (!runtime.bridge.knownClientIds().has(clientId)) {
|
||||
res.status(400).json({
|
||||
error: `Client id "${clientId}" is not registered for this workspace`,
|
||||
code: 'invalid_client_id',
|
||||
clientId,
|
||||
});
|
||||
return;
|
||||
}
|
||||
originatorClientId = clientId;
|
||||
}
|
||||
try {
|
||||
const result = await writeWorkspaceContextFile({
|
||||
scope: 'workspace',
|
||||
mode,
|
||||
content,
|
||||
projectRoot: runtime.workspaceCwd,
|
||||
});
|
||||
if (result.changed) {
|
||||
runtime.bridge.publishWorkspaceEvent({
|
||||
type: 'memory_changed',
|
||||
data: {
|
||||
scope: 'workspace',
|
||||
filePath: result.filePath,
|
||||
mode,
|
||||
bytesWritten: result.bytesWritten,
|
||||
},
|
||||
...(originatorClientId ? { originatorClientId } : {}),
|
||||
});
|
||||
}
|
||||
res.status(200).json({
|
||||
ok: true,
|
||||
filePath: result.filePath,
|
||||
bytesWritten: result.bytesWritten,
|
||||
mode,
|
||||
changed: result.changed,
|
||||
});
|
||||
} catch (err) {
|
||||
sendWorkspaceMemoryWriteError(res, err, {
|
||||
route: 'POST /workspaces/:workspace/memory',
|
||||
scope: 'workspace',
|
||||
mode,
|
||||
});
|
||||
}
|
||||
},
|
||||
|
|
|
|||
1079
packages/cli/src/serve/workspace-qualified-rest.test.ts
Normal file
1079
packages/cli/src/serve/workspace-qualified-rest.test.ts
Normal file
File diff suppressed because it is too large
Load diff
148
packages/cli/src/serve/workspace-route-runtime.ts
Normal file
148
packages/cli/src/serve/workspace-route-runtime.ts
Normal file
|
|
@ -0,0 +1,148 @@
|
|||
/**
|
||||
* @license
|
||||
* Copyright 2026 Qwen Team
|
||||
* SPDX-License-Identifier: Apache-2.0
|
||||
*/
|
||||
|
||||
import path from 'node:path';
|
||||
import type { Request, Response } from 'express';
|
||||
import { canonicalizeWorkspace } from './acp-session-bridge.js';
|
||||
import type {
|
||||
WorkspaceRegistry,
|
||||
WorkspaceRuntime,
|
||||
} from './workspace-registry.js';
|
||||
|
||||
export interface WorkspaceRouteContext {
|
||||
readonly runtime: WorkspaceRuntime;
|
||||
readonly routePrefix: string;
|
||||
}
|
||||
|
||||
export function isPortableAbsolutePath(value: string): boolean {
|
||||
return (
|
||||
path.isAbsolute(value) ||
|
||||
/^[A-Za-z]:[\\/]/.test(value) ||
|
||||
/^\\\\[^\\]+\\[^\\]+/.test(value)
|
||||
);
|
||||
}
|
||||
|
||||
function isUncPath(value: string): boolean {
|
||||
return /^\\\\[^\\]+\\[^\\]+/.test(value);
|
||||
}
|
||||
|
||||
function normalizePortableAbsolutePath(value: string): string {
|
||||
if (/^[A-Za-z]:[\\/]/.test(value) || /^\\\\[^\\]+\\[^\\]+/.test(value)) {
|
||||
return path.win32.normalize(value).toLowerCase();
|
||||
}
|
||||
return path.resolve(value);
|
||||
}
|
||||
|
||||
export function resolveRegisteredWorkspaceRuntimeByPathSelector(
|
||||
registry: WorkspaceRegistry,
|
||||
selector: string,
|
||||
): WorkspaceRuntime | undefined {
|
||||
const exact = registry.getByWorkspaceCwd(selector);
|
||||
if (exact) return exact;
|
||||
|
||||
if (path.isAbsolute(selector) && !isUncPath(selector)) {
|
||||
try {
|
||||
const canonicalSelector = canonicalizeWorkspace(selector);
|
||||
const canonicalMatch = registry.getByWorkspaceCwd(canonicalSelector);
|
||||
if (canonicalMatch) return canonicalMatch;
|
||||
for (const runtime of registry.list()) {
|
||||
if (canonicalizeWorkspace(runtime.workspaceCwd) === canonicalSelector) {
|
||||
return runtime;
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
// Fall through to lexical matching; unresolved selectors still return
|
||||
// workspace_mismatch without probing UNC/network paths.
|
||||
}
|
||||
}
|
||||
|
||||
const normalizedSelector = normalizePortableAbsolutePath(selector);
|
||||
return registry
|
||||
.list()
|
||||
.find(
|
||||
(runtime) =>
|
||||
normalizePortableAbsolutePath(runtime.workspaceCwd) ===
|
||||
normalizedSelector,
|
||||
);
|
||||
}
|
||||
|
||||
export function resolveWorkspaceRuntimeFromParam(
|
||||
registry: WorkspaceRegistry,
|
||||
req: Request,
|
||||
res: Response,
|
||||
paramName = 'workspace',
|
||||
): WorkspaceRuntime | null {
|
||||
const selector = req.params[paramName] ?? '';
|
||||
const byId = registry.getByWorkspaceId(selector);
|
||||
if (byId) return byId;
|
||||
|
||||
if (!isPortableAbsolutePath(selector)) {
|
||||
res.status(400).json({
|
||||
error: `\`:${paramName}\` must decode to a workspace id or absolute path`,
|
||||
code: 'workspace_mismatch',
|
||||
});
|
||||
return null;
|
||||
}
|
||||
|
||||
const runtime = resolveRegisteredWorkspaceRuntimeByPathSelector(
|
||||
registry,
|
||||
selector,
|
||||
);
|
||||
if (!runtime) {
|
||||
sendWorkspaceMismatch(res, registry);
|
||||
return null;
|
||||
}
|
||||
return runtime;
|
||||
}
|
||||
|
||||
export function requireTrustedWorkspaceRuntime(
|
||||
runtime: WorkspaceRuntime,
|
||||
res: Response,
|
||||
): boolean {
|
||||
if (runtime.trusted) return true;
|
||||
sendUntrustedWorkspaceResponse(res);
|
||||
return false;
|
||||
}
|
||||
|
||||
export function sendUntrustedWorkspaceResponse(
|
||||
res: Response,
|
||||
extra?: { sessionId?: string; workspaceCwd?: string; workspaceId?: string },
|
||||
): void {
|
||||
res.status(403).json({
|
||||
error: 'Workspace is not trusted.',
|
||||
code: 'untrusted_workspace',
|
||||
...extra,
|
||||
});
|
||||
}
|
||||
|
||||
export function getWorkspaceRouteContext(
|
||||
req: Request,
|
||||
): WorkspaceRouteContext | undefined {
|
||||
return (req as { workspaceRouteContext?: WorkspaceRouteContext })
|
||||
.workspaceRouteContext;
|
||||
}
|
||||
|
||||
export function setWorkspaceRouteContext(
|
||||
req: Request,
|
||||
context: WorkspaceRouteContext,
|
||||
): void {
|
||||
(
|
||||
req as { workspaceRouteContext?: WorkspaceRouteContext }
|
||||
).workspaceRouteContext = context;
|
||||
}
|
||||
|
||||
export function sendWorkspaceMismatch(
|
||||
res: Response,
|
||||
registry: WorkspaceRegistry,
|
||||
): void {
|
||||
const runtimes = registry.list();
|
||||
res.status(400).json({
|
||||
error:
|
||||
'Workspace mismatch: the requested workspace is not registered with this daemon.',
|
||||
code: 'workspace_mismatch',
|
||||
workspaceCount: runtimes.length,
|
||||
});
|
||||
}
|
||||
|
|
@ -49,7 +49,9 @@ const rootDir = join(__dirname, '..');
|
|||
// Bumped from 138KB to 139KB for workspace ACP status/preheat APIs.
|
||||
// Bumped from 139KB to 141KB after merging main (ACP status/preheat) into
|
||||
// the history_truncated/transcript-status branch.
|
||||
const MAX_DAEMON_BROWSER_BUNDLE_BYTES = 141 * 1024;
|
||||
// Bumped from 141KB to 150KB for WorkspaceDaemonClient's workspace-qualified
|
||||
// core REST helpers, including Phase 3 file/status/settings/agents/session APIs.
|
||||
const MAX_DAEMON_BROWSER_BUNDLE_BYTES = 150 * 1024;
|
||||
// The opt-in `daemon/transports` browser bundle legitimately ships the concrete
|
||||
// ACP transports (AcpHttpTransport/AcpWsTransport/AutoReconnect + negotiate), so
|
||||
// it's larger than the default barrel — but still budgeted so a future PR can't
|
||||
|
|
|
|||
|
|
@ -671,6 +671,72 @@ export class DaemonClient {
|
|||
);
|
||||
}
|
||||
|
||||
/** @internal */
|
||||
async workspaceJsonRequest<T>(
|
||||
workspaceSelector: string,
|
||||
path: string,
|
||||
label: string,
|
||||
opts: {
|
||||
method?: string;
|
||||
body?: unknown;
|
||||
clientId?: string;
|
||||
timeoutMs?: number;
|
||||
} = {},
|
||||
): Promise<T> {
|
||||
return await this.jsonRequest<T>(
|
||||
`/workspaces/${workspaceSelector}${path}`,
|
||||
label,
|
||||
opts,
|
||||
);
|
||||
}
|
||||
|
||||
/** @internal */
|
||||
async workspaceNoContentRequest(
|
||||
workspaceSelector: string,
|
||||
path: string,
|
||||
label: string,
|
||||
opts: {
|
||||
method?: string;
|
||||
clientId?: string;
|
||||
timeoutMs?: number;
|
||||
okNotFoundCode?: string;
|
||||
} = {},
|
||||
): Promise<void> {
|
||||
return await this.fetchWithTimeout(
|
||||
`${this.baseUrl}/workspaces/${workspaceSelector}${path}`,
|
||||
{
|
||||
...(opts.method ? { method: opts.method } : {}),
|
||||
headers: this.headers({}, opts.clientId),
|
||||
},
|
||||
async (res) => {
|
||||
if (res.status === 204) {
|
||||
try {
|
||||
await res.body?.cancel();
|
||||
} catch {
|
||||
/* body already consumed or no body */
|
||||
}
|
||||
return;
|
||||
}
|
||||
if (res.status === 404 && opts.okNotFoundCode) {
|
||||
const err = await this.failOnError(res, label);
|
||||
const body = err.body as { code?: unknown } | undefined;
|
||||
if (body?.code === opts.okNotFoundCode) return;
|
||||
throw err;
|
||||
}
|
||||
throw await this.failOnError(res, label);
|
||||
},
|
||||
opts.timeoutMs,
|
||||
);
|
||||
}
|
||||
|
||||
workspaceById(workspaceId: string): WorkspaceDaemonClient {
|
||||
return new WorkspaceDaemonClient(this, urlEncode(workspaceId));
|
||||
}
|
||||
|
||||
workspaceByCwd(workspaceCwd: string): WorkspaceDaemonClient {
|
||||
return new WorkspaceDaemonClient(this, urlEncode(workspaceCwd));
|
||||
}
|
||||
|
||||
// -- Lifecycle / discovery ---------------------------------------------
|
||||
|
||||
async health(): Promise<{ status: string }> {
|
||||
|
|
@ -3320,6 +3386,460 @@ export class DaemonClient {
|
|||
}
|
||||
}
|
||||
|
||||
export class WorkspaceDaemonClient {
|
||||
constructor(
|
||||
private readonly client: DaemonClient,
|
||||
private readonly workspaceSelector: string,
|
||||
) {}
|
||||
|
||||
workspaceMcp(): Promise<DaemonWorkspaceMcpStatus> {
|
||||
return this.get('/mcp', 'GET /workspaces/:workspace/mcp');
|
||||
}
|
||||
|
||||
workspaceSkills(): Promise<DaemonWorkspaceSkillsStatus> {
|
||||
return this.get('/skills', 'GET /workspaces/:workspace/skills');
|
||||
}
|
||||
|
||||
workspaceProviders(): Promise<DaemonWorkspaceProvidersStatus> {
|
||||
return this.get('/providers', 'GET /workspaces/:workspace/providers');
|
||||
}
|
||||
|
||||
workspaceHooks(): Promise<DaemonWorkspaceHooksStatus> {
|
||||
return this.get('/hooks', 'GET /workspaces/:workspace/hooks');
|
||||
}
|
||||
|
||||
workspaceEnv(): Promise<DaemonWorkspaceEnvStatus> {
|
||||
return this.get('/env', 'GET /workspaces/:workspace/env');
|
||||
}
|
||||
|
||||
workspacePreflight(): Promise<DaemonWorkspacePreflightStatus> {
|
||||
return this.get('/preflight', 'GET /workspaces/:workspace/preflight');
|
||||
}
|
||||
|
||||
workspaceTools(): Promise<DaemonWorkspaceToolsStatus> {
|
||||
return this.get('/tools', 'GET /workspaces/:workspace/tools');
|
||||
}
|
||||
|
||||
workspaceMemory(): Promise<DaemonWorkspaceMemoryStatus> {
|
||||
return this.get('/memory', 'GET /workspaces/:workspace/memory');
|
||||
}
|
||||
|
||||
writeWorkspaceMemory(
|
||||
req: Omit<DaemonWriteMemoryRequest, 'scope'> & { scope?: 'workspace' },
|
||||
clientId?: string,
|
||||
): Promise<DaemonWriteMemoryResult> {
|
||||
return this.post(
|
||||
'/memory',
|
||||
'POST /workspaces/:workspace/memory',
|
||||
{ ...req, scope: 'workspace' },
|
||||
clientId,
|
||||
);
|
||||
}
|
||||
|
||||
listWorkspaceAgents(): Promise<DaemonWorkspaceAgentsStatus> {
|
||||
return this.get('/agents', 'GET /workspaces/:workspace/agents');
|
||||
}
|
||||
|
||||
createWorkspaceAgent(
|
||||
req: Omit<DaemonCreateAgentRequest, 'scope'> & {
|
||||
scope?: 'workspace' | 'project';
|
||||
},
|
||||
clientId?: string,
|
||||
): Promise<DaemonAgentMutationResult> {
|
||||
return this.post(
|
||||
'/agents',
|
||||
'POST /workspaces/:workspace/agents',
|
||||
{ ...req, scope: req.scope ?? 'workspace' },
|
||||
clientId,
|
||||
);
|
||||
}
|
||||
|
||||
getWorkspaceAgent(agentType: string): Promise<DaemonWorkspaceAgentDetail> {
|
||||
return this.get(
|
||||
`/agents/${urlEncode(agentType)}`,
|
||||
'GET /workspaces/:workspace/agents/:agentType',
|
||||
);
|
||||
}
|
||||
|
||||
updateWorkspaceAgent(
|
||||
agentType: string,
|
||||
req: DaemonUpdateAgentRequest,
|
||||
opts: { scope?: 'workspace' | 'project'; clientId?: string } = {},
|
||||
): Promise<DaemonAgentMutationResult> {
|
||||
const query = opts.scope ? `?scope=${urlEncode(opts.scope)}` : '';
|
||||
return this.post(
|
||||
`/agents/${urlEncode(agentType)}${query}`,
|
||||
'POST /workspaces/:workspace/agents/:agentType',
|
||||
req,
|
||||
opts.clientId,
|
||||
);
|
||||
}
|
||||
|
||||
async deleteWorkspaceAgent(
|
||||
agentType: string,
|
||||
opts: { scope?: 'workspace' | 'project'; clientId?: string } = {},
|
||||
): Promise<void> {
|
||||
const query = opts.scope ? `?scope=${urlEncode(opts.scope)}` : '';
|
||||
return await this.client.workspaceNoContentRequest(
|
||||
this.workspaceSelector,
|
||||
`/agents/${urlEncode(agentType)}${query}`,
|
||||
'DELETE /workspaces/:workspace/agents/:agentType',
|
||||
{
|
||||
method: 'DELETE',
|
||||
clientId: opts.clientId,
|
||||
okNotFoundCode: 'agent_not_found',
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
listWorkspaceSessionsPage(
|
||||
options?: DaemonSessionListPageOptions,
|
||||
): Promise<DaemonSessionListPage> {
|
||||
const requestedPageSize =
|
||||
options?.pageSize ?? DEFAULT_SESSION_LIST_PAGE_SIZE;
|
||||
const pageSize = Math.max(
|
||||
1,
|
||||
Math.min(
|
||||
1000,
|
||||
Math.round(
|
||||
Number.isFinite(requestedPageSize)
|
||||
? requestedPageSize
|
||||
: DEFAULT_SESSION_LIST_PAGE_SIZE,
|
||||
),
|
||||
),
|
||||
);
|
||||
const query = new URLSearchParams({ size: String(pageSize) });
|
||||
if (options?.cursor !== undefined) query.set('cursor', options.cursor);
|
||||
if (options?.archiveState !== undefined) {
|
||||
query.set('archiveState', options.archiveState);
|
||||
}
|
||||
if (options?.view !== undefined) query.set('view', options.view);
|
||||
if (options?.group !== undefined) query.set('group', options.group);
|
||||
return this.get(
|
||||
`/sessions?${query.toString()}`,
|
||||
'GET /workspaces/:workspace/sessions',
|
||||
);
|
||||
}
|
||||
|
||||
async listWorkspaceSessions(
|
||||
options?: DaemonSessionListPageOptions,
|
||||
): Promise<DaemonSessionSummary[]> {
|
||||
const page = await this.listWorkspaceSessionsPage(options);
|
||||
return page.sessions;
|
||||
}
|
||||
|
||||
listSessionGroups(): Promise<DaemonSessionGroupCatalog> {
|
||||
return this.get(
|
||||
'/session-groups',
|
||||
'GET /workspaces/:workspace/session-groups',
|
||||
);
|
||||
}
|
||||
|
||||
async createSessionGroup(
|
||||
input: DaemonSessionGroupInput,
|
||||
): Promise<DaemonSessionGroup> {
|
||||
const body = await this.post<{ group: DaemonSessionGroup }>(
|
||||
'/session-groups',
|
||||
'POST /workspaces/:workspace/session-groups',
|
||||
input,
|
||||
);
|
||||
return body.group;
|
||||
}
|
||||
|
||||
async updateSessionGroup(
|
||||
groupId: string,
|
||||
update: DaemonSessionGroupUpdate,
|
||||
): Promise<DaemonSessionGroup> {
|
||||
const body = await this.client.workspaceJsonRequest<{
|
||||
group: DaemonSessionGroup;
|
||||
}>(
|
||||
this.workspaceSelector,
|
||||
`/session-groups/${urlEncode(groupId)}`,
|
||||
'PATCH /workspaces/:workspace/session-groups/:groupId',
|
||||
{ method: 'PATCH', body: update },
|
||||
);
|
||||
return body.group;
|
||||
}
|
||||
|
||||
deleteSessionGroup(groupId: string): Promise<{ deleted: boolean }> {
|
||||
return this.client.workspaceJsonRequest<{ deleted: boolean }>(
|
||||
this.workspaceSelector,
|
||||
`/session-groups/${urlEncode(groupId)}`,
|
||||
'DELETE /workspaces/:workspace/session-groups/:groupId',
|
||||
{ method: 'DELETE' },
|
||||
);
|
||||
}
|
||||
|
||||
deleteSessionsData(
|
||||
sessionIds: string[],
|
||||
clientId?: string,
|
||||
): Promise<{
|
||||
removed: string[];
|
||||
notFound: string[];
|
||||
errors: Array<{ sessionId: string; error: string }>;
|
||||
}> {
|
||||
return this.post(
|
||||
'/sessions/delete',
|
||||
'POST /workspaces/:workspace/sessions/delete',
|
||||
{ sessionIds },
|
||||
clientId,
|
||||
);
|
||||
}
|
||||
|
||||
archiveSessionsData(
|
||||
sessionIds: string[],
|
||||
clientId?: string,
|
||||
): Promise<DaemonArchiveSessionsResult> {
|
||||
return this.post(
|
||||
'/sessions/archive',
|
||||
'POST /workspaces/:workspace/sessions/archive',
|
||||
{ sessionIds },
|
||||
clientId,
|
||||
);
|
||||
}
|
||||
|
||||
unarchiveSessionsData(
|
||||
sessionIds: string[],
|
||||
clientId?: string,
|
||||
): Promise<DaemonUnarchiveSessionsResult> {
|
||||
return this.post(
|
||||
'/sessions/unarchive',
|
||||
'POST /workspaces/:workspace/sessions/unarchive',
|
||||
{ sessionIds },
|
||||
clientId,
|
||||
);
|
||||
}
|
||||
|
||||
readWorkspaceFile(
|
||||
filePath: string,
|
||||
opts: { maxBytes?: number; line?: number; limit?: number } = {},
|
||||
clientId?: string,
|
||||
): Promise<DaemonWorkspaceFile> {
|
||||
const query = new URLSearchParams({ path: filePath });
|
||||
if (opts.maxBytes !== undefined)
|
||||
query.set('maxBytes', String(opts.maxBytes));
|
||||
if (opts.line !== undefined) query.set('line', String(opts.line));
|
||||
if (opts.limit !== undefined) query.set('limit', String(opts.limit));
|
||||
return this.get(
|
||||
`/file?${query.toString()}`,
|
||||
'GET /workspaces/:workspace/file',
|
||||
clientId,
|
||||
);
|
||||
}
|
||||
|
||||
readWorkspaceFileBytes(
|
||||
filePath: string,
|
||||
opts: { offset?: number; maxBytes?: number } = {},
|
||||
clientId?: string,
|
||||
): Promise<DaemonWorkspaceFileBytes> {
|
||||
const query = new URLSearchParams({ path: filePath });
|
||||
if (opts.offset !== undefined) query.set('offset', String(opts.offset));
|
||||
if (opts.maxBytes !== undefined)
|
||||
query.set('maxBytes', String(opts.maxBytes));
|
||||
return this.get(
|
||||
`/file/bytes?${query.toString()}`,
|
||||
'GET /workspaces/:workspace/file/bytes',
|
||||
clientId,
|
||||
);
|
||||
}
|
||||
|
||||
fileStat(filePath: string): Promise<unknown> {
|
||||
const query = new URLSearchParams({ path: filePath });
|
||||
return this.get(
|
||||
`/stat?${query.toString()}`,
|
||||
'GET /workspaces/:workspace/stat',
|
||||
);
|
||||
}
|
||||
|
||||
dirList(dirPath: string): Promise<unknown> {
|
||||
const query = new URLSearchParams({ path: dirPath });
|
||||
return this.get(
|
||||
`/list?${query.toString()}`,
|
||||
'GET /workspaces/:workspace/list',
|
||||
);
|
||||
}
|
||||
|
||||
glob(pattern: string): Promise<unknown> {
|
||||
const query = new URLSearchParams({ pattern });
|
||||
return this.get(
|
||||
`/glob?${query.toString()}`,
|
||||
'GET /workspaces/:workspace/glob',
|
||||
);
|
||||
}
|
||||
|
||||
writeWorkspaceFile(
|
||||
req: DaemonWorkspaceFileWriteRequest,
|
||||
clientId?: string,
|
||||
): Promise<DaemonWorkspaceFileWriteResult> {
|
||||
return this.post(
|
||||
'/file/write',
|
||||
'POST /workspaces/:workspace/file/write',
|
||||
req,
|
||||
clientId,
|
||||
);
|
||||
}
|
||||
|
||||
editWorkspaceFile(
|
||||
req: DaemonWorkspaceFileEditRequest,
|
||||
clientId?: string,
|
||||
): Promise<DaemonWorkspaceFileEditResult> {
|
||||
return this.post(
|
||||
'/file/edit',
|
||||
'POST /workspaces/:workspace/file/edit',
|
||||
req,
|
||||
clientId,
|
||||
);
|
||||
}
|
||||
|
||||
workspaceSettings(opts?: {
|
||||
clientId?: string;
|
||||
}): Promise<DaemonWorkspaceSettingsStatus> {
|
||||
return this.get(
|
||||
'/settings',
|
||||
'GET /workspaces/:workspace/settings',
|
||||
opts?.clientId,
|
||||
);
|
||||
}
|
||||
|
||||
setWorkspaceSetting(
|
||||
scope: 'workspace',
|
||||
key: string,
|
||||
value: unknown,
|
||||
opts?: { clientId?: string },
|
||||
): Promise<DaemonSettingUpdateResult> {
|
||||
return this.post(
|
||||
'/settings',
|
||||
'POST /workspaces/:workspace/settings',
|
||||
{ scope, key, value },
|
||||
opts?.clientId,
|
||||
);
|
||||
}
|
||||
|
||||
workspaceTrust(opts?: {
|
||||
clientId?: string;
|
||||
}): Promise<DaemonWorkspaceTrustStatus> {
|
||||
return this.get(
|
||||
'/trust',
|
||||
'GET /workspaces/:workspace/trust',
|
||||
opts?.clientId,
|
||||
);
|
||||
}
|
||||
|
||||
requestWorkspaceTrustChange(
|
||||
request: DaemonWorkspaceTrustChangeRequest,
|
||||
clientId?: string,
|
||||
): Promise<DaemonWorkspaceTrustChangeResult> {
|
||||
return this.post(
|
||||
'/trust/request',
|
||||
'POST /workspaces/:workspace/trust/request',
|
||||
request,
|
||||
clientId,
|
||||
);
|
||||
}
|
||||
|
||||
workspacePermissions(opts?: {
|
||||
clientId?: string;
|
||||
}): Promise<DaemonWorkspacePermissionsStatus> {
|
||||
return this.get(
|
||||
'/permissions',
|
||||
'GET /workspaces/:workspace/permissions',
|
||||
opts?.clientId,
|
||||
);
|
||||
}
|
||||
|
||||
setWorkspacePermissionRules(
|
||||
ruleType: DaemonPermissionRuleType,
|
||||
rules: readonly string[],
|
||||
opts?: { clientId?: string },
|
||||
): Promise<DaemonWorkspacePermissionsStatus> {
|
||||
return this.post(
|
||||
'/permissions',
|
||||
'POST /workspaces/:workspace/permissions',
|
||||
{ scope: 'workspace', ruleType, rules: [...rules] },
|
||||
opts?.clientId,
|
||||
);
|
||||
}
|
||||
|
||||
setWorkspaceToolEnabled(
|
||||
toolName: string,
|
||||
enabled: boolean,
|
||||
opts?: { clientId?: string },
|
||||
): Promise<DaemonToolToggleResult> {
|
||||
return this.post(
|
||||
`/tools/${urlEncode(toolName)}/enable`,
|
||||
'POST /workspaces/:workspace/tools/:name/enable',
|
||||
{ enabled },
|
||||
opts?.clientId,
|
||||
);
|
||||
}
|
||||
|
||||
restartMcpServer(
|
||||
serverName: string,
|
||||
opts?: { clientId?: string; entryIndex?: number | '*'; timeoutMs?: number },
|
||||
): Promise<DaemonMcpRestartResult> {
|
||||
const query =
|
||||
opts?.entryIndex === undefined
|
||||
? ''
|
||||
: `?entryIndex=${urlEncode(String(opts.entryIndex))}`;
|
||||
return this.post(
|
||||
`/mcp/${urlEncode(serverName)}/restart${query}`,
|
||||
'POST /workspaces/:workspace/mcp/:server/restart',
|
||||
{},
|
||||
opts?.clientId,
|
||||
opts?.timeoutMs ?? MCP_RESTART_DEFAULT_TIMEOUT_MS,
|
||||
);
|
||||
}
|
||||
|
||||
reload(opts?: {
|
||||
clientId?: string;
|
||||
timeoutMs?: number;
|
||||
}): Promise<DaemonReloadResponse> {
|
||||
return this.post(
|
||||
'/reload',
|
||||
'POST /workspaces/:workspace/reload',
|
||||
{},
|
||||
opts?.clientId,
|
||||
opts?.timeoutMs,
|
||||
);
|
||||
}
|
||||
|
||||
initWorkspace(opts?: {
|
||||
force?: boolean;
|
||||
clientId?: string;
|
||||
}): Promise<DaemonInitWorkspaceResult> {
|
||||
return this.post(
|
||||
'/init',
|
||||
'POST /workspaces/:workspace/init',
|
||||
opts?.force === true ? { force: true } : {},
|
||||
opts?.clientId,
|
||||
);
|
||||
}
|
||||
|
||||
private get<T>(path: string, label: string, clientId?: string): Promise<T> {
|
||||
return this.client.workspaceJsonRequest<T>(
|
||||
this.workspaceSelector,
|
||||
path,
|
||||
label,
|
||||
{ clientId },
|
||||
);
|
||||
}
|
||||
|
||||
private post<T>(
|
||||
path: string,
|
||||
label: string,
|
||||
body: unknown,
|
||||
clientId?: string,
|
||||
timeoutMs?: number,
|
||||
): Promise<T> {
|
||||
return this.client.workspaceJsonRequest<T>(
|
||||
this.workspaceSelector,
|
||||
path,
|
||||
label,
|
||||
{ method: 'POST', body, clientId, timeoutMs },
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* `AbortSignal.timeout` is in every Node version this package supports
|
||||
* (`engines.node >=22.0.0` ships it natively). The feature-detect below
|
||||
|
|
|
|||
|
|
@ -8,6 +8,7 @@ export {
|
|||
DaemonClient,
|
||||
DaemonHttpError,
|
||||
DaemonPendingPromptLimitError,
|
||||
WorkspaceDaemonClient,
|
||||
isDaemonTurnError,
|
||||
isNonBlockingAccepted,
|
||||
matchTurnEvent,
|
||||
|
|
|
|||
|
|
@ -15,6 +15,7 @@ export {
|
|||
DaemonClient,
|
||||
DaemonHttpError,
|
||||
DaemonPendingPromptLimitError,
|
||||
WorkspaceDaemonClient,
|
||||
DaemonSessionClient,
|
||||
asKnownDaemonEvent,
|
||||
createDaemonSessionViewState,
|
||||
|
|
|
|||
|
|
@ -4306,6 +4306,178 @@ describe('DaemonClient', () => {
|
|||
);
|
||||
}
|
||||
});
|
||||
|
||||
it('workspaceById and workspaceByCwd call workspace-qualified agents routes', async () => {
|
||||
const list = {
|
||||
v: 1,
|
||||
workspaceCwd: '/work/a',
|
||||
agents: [],
|
||||
};
|
||||
const detail = {
|
||||
kind: 'agent' as const,
|
||||
name: 'reviewer',
|
||||
description: 'reviews code',
|
||||
level: 'project' as const,
|
||||
isBuiltin: false,
|
||||
hasTools: false,
|
||||
systemPrompt: 'you are a reviewer',
|
||||
};
|
||||
const mutation = { ok: true, agent: detail };
|
||||
const { fetch, calls } = recordingFetch((req) => {
|
||||
if (req.method === 'DELETE') return new Response(null, { status: 204 });
|
||||
if (req.url.includes('/agents/reviewer')) {
|
||||
return req.method === 'GET'
|
||||
? jsonResponse(200, detail)
|
||||
: jsonResponse(200, mutation);
|
||||
}
|
||||
if (req.url.endsWith('/agents')) {
|
||||
return req.method === 'POST'
|
||||
? jsonResponse(201, mutation)
|
||||
: jsonResponse(200, list);
|
||||
}
|
||||
return jsonResponse(500, { error: `unexpected ${req.url}` });
|
||||
});
|
||||
const client = new DaemonClient({ baseUrl: 'http://daemon', fetch });
|
||||
const byId = client.workspaceById('workspace/id');
|
||||
const byCwd = client.workspaceByCwd('/tmp/work space');
|
||||
|
||||
await expect(byId.listWorkspaceAgents()).resolves.toEqual(list);
|
||||
await expect(
|
||||
byCwd.createWorkspaceAgent(
|
||||
{
|
||||
name: 'reviewer',
|
||||
description: 'reviews code',
|
||||
systemPrompt: 'you are a reviewer',
|
||||
},
|
||||
'client-1',
|
||||
),
|
||||
).resolves.toEqual(mutation);
|
||||
await expect(byId.getWorkspaceAgent('reviewer')).resolves.toEqual(detail);
|
||||
await expect(
|
||||
byId.updateWorkspaceAgent(
|
||||
'reviewer',
|
||||
{ description: 'new description' },
|
||||
{ scope: 'project', clientId: 'client-2' },
|
||||
),
|
||||
).resolves.toEqual(mutation);
|
||||
await expect(
|
||||
byId.deleteWorkspaceAgent('reviewer', {
|
||||
scope: 'workspace',
|
||||
clientId: 'client-3',
|
||||
}),
|
||||
).resolves.toBeUndefined();
|
||||
|
||||
expect(calls.map((c) => [c.method, c.url])).toEqual([
|
||||
['GET', 'http://daemon/workspaces/workspace%2Fid/agents'],
|
||||
['POST', 'http://daemon/workspaces/%2Ftmp%2Fwork%20space/agents'],
|
||||
['GET', 'http://daemon/workspaces/workspace%2Fid/agents/reviewer'],
|
||||
[
|
||||
'POST',
|
||||
'http://daemon/workspaces/workspace%2Fid/agents/reviewer?scope=project',
|
||||
],
|
||||
[
|
||||
'DELETE',
|
||||
'http://daemon/workspaces/workspace%2Fid/agents/reviewer?scope=workspace',
|
||||
],
|
||||
]);
|
||||
expect(JSON.parse(calls[1]!.body!)).toMatchObject({
|
||||
name: 'reviewer',
|
||||
scope: 'workspace',
|
||||
});
|
||||
expect(calls[1]?.headers['x-qwen-client-id']).toBe('client-1');
|
||||
expect(calls[3]?.headers['x-qwen-client-id']).toBe('client-2');
|
||||
expect(calls[4]?.headers['x-qwen-client-id']).toBe('client-3');
|
||||
});
|
||||
|
||||
it('workspace-qualified deleteWorkspaceAgent preserves idempotent structured 404 handling', async () => {
|
||||
{
|
||||
const { fetch } = recordingFetch(() =>
|
||||
jsonResponse(404, { error: 'not found', code: 'agent_not_found' }),
|
||||
);
|
||||
const client = new DaemonClient({ baseUrl: 'http://daemon', fetch });
|
||||
await expect(
|
||||
client.workspaceById('workspace-id').deleteWorkspaceAgent('x'),
|
||||
).resolves.toBeUndefined();
|
||||
}
|
||||
{
|
||||
const { fetch } = recordingFetch(
|
||||
() =>
|
||||
new Response('Not Found', {
|
||||
status: 404,
|
||||
headers: { 'content-type': 'text/plain' },
|
||||
}),
|
||||
);
|
||||
const client = new DaemonClient({ baseUrl: 'http://daemon', fetch });
|
||||
await expect(
|
||||
client.workspaceById('workspace-id').deleteWorkspaceAgent('x'),
|
||||
).rejects.toBeInstanceOf(DaemonHttpError);
|
||||
}
|
||||
});
|
||||
|
||||
it('workspaceById destructive session helpers use workspace-qualified routes', async () => {
|
||||
const replies: Record<string, unknown> = {
|
||||
'/sessions/delete': { removed: ['s-1'], notFound: [], errors: [] },
|
||||
'/sessions/archive': {
|
||||
archived: ['s-1'],
|
||||
alreadyArchived: [],
|
||||
notFound: [],
|
||||
errors: [],
|
||||
},
|
||||
'/sessions/unarchive': {
|
||||
unarchived: ['s-1'],
|
||||
alreadyActive: [],
|
||||
notFound: [],
|
||||
errors: [],
|
||||
},
|
||||
};
|
||||
const { fetch, calls } = recordingFetch((req) => {
|
||||
const url = new URL(req.url);
|
||||
const suffix = url.pathname.replace('/workspaces/workspace-id', '');
|
||||
return jsonResponse(200, replies[suffix] ?? { unexpected: suffix });
|
||||
});
|
||||
const client = new DaemonClient({ baseUrl: 'http://daemon', fetch });
|
||||
const workspace = client.workspaceById('workspace-id');
|
||||
|
||||
await expect(
|
||||
workspace.deleteSessionsData(['s-1'], 'client-1'),
|
||||
).resolves.toEqual(replies['/sessions/delete']);
|
||||
await expect(
|
||||
workspace.archiveSessionsData(['s-1'], 'client-2'),
|
||||
).resolves.toEqual(replies['/sessions/archive']);
|
||||
await expect(
|
||||
workspace.unarchiveSessionsData(['s-1'], 'client-3'),
|
||||
).resolves.toEqual(replies['/sessions/unarchive']);
|
||||
|
||||
expect(calls.map((c) => [c.method, c.url])).toEqual([
|
||||
['POST', 'http://daemon/workspaces/workspace-id/sessions/delete'],
|
||||
['POST', 'http://daemon/workspaces/workspace-id/sessions/archive'],
|
||||
['POST', 'http://daemon/workspaces/workspace-id/sessions/unarchive'],
|
||||
]);
|
||||
expect(calls.map((c) => c.headers['x-qwen-client-id'])).toEqual([
|
||||
'client-1',
|
||||
'client-2',
|
||||
'client-3',
|
||||
]);
|
||||
for (const call of calls) {
|
||||
expect(JSON.parse(call.body!)).toEqual({ sessionIds: ['s-1'] });
|
||||
}
|
||||
});
|
||||
|
||||
it('workspaceByCwd deleteSessionGroup uses workspace-qualified group route', async () => {
|
||||
const { fetch, calls } = recordingFetch(() =>
|
||||
jsonResponse(200, { deleted: true }),
|
||||
);
|
||||
const client = new DaemonClient({ baseUrl: 'http://daemon', fetch });
|
||||
|
||||
await expect(
|
||||
client.workspaceByCwd('/tmp/work space').deleteSessionGroup('group/1'),
|
||||
).resolves.toEqual({ deleted: true });
|
||||
|
||||
expect(calls[0]?.method).toBe('DELETE');
|
||||
expect(calls[0]?.url).toBe(
|
||||
'http://daemon/workspaces/%2Ftmp%2Fwork%20space/session-groups/group%2F1',
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe('addRuntimeMcpServer (T2.8 #4514)', () => {
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue