feat(serve): Expose read-only untrusted session catalogs (#6717)

* feat(serve): expose read-only untrusted session catalogs

Co-authored-by: Qwen-Coder <[email protected]>

* refactor(cli): address session catalog review feedback

Co-authored-by: Qwen-Coder <[email protected]>

* codex: address PR review feedback (#6717)

Co-authored-by: Qwen-Coder <[email protected]>

* codex: address PR review feedback (#6717)

Co-authored-by: Qwen-Coder <[email protected]>

* codex: address PR review feedback (#6717)

Co-authored-by: Qwen-Coder <[email protected]>

---------

Co-authored-by: Qwen-Coder <[email protected]>
Co-authored-by: qwen-code-dev-bot <[email protected]>
This commit is contained in:
authored and GitHub committed 2026-07-11 15:28:41 +00:00
1 parent 2b9c92e083
commit e403246dc2
16 files changed
+979 -150

No files matched your search

@@ -0,0 +1,122 @@
# Untrusted Workspace Read-only Session Catalog
## Summary
Multi-workspace daemons expose a narrow read-only catalog for registered,
non-primary workspaces whose boot-time trust state is `false`. The catalog
contains persisted session summaries and the session-organization sidecar. It
does not attach to a session, start an ACP child, merge live runtime state, or
interpret workspace-controlled capability definitions.
This is a route allowlist, not a workspace ACL. A client that holds the daemon
bearer token can read the allowed data for every registered workspace. Trust
continues to gate execution and mutation; it does not create a separate
authentication principal.
## Security Invariants
Every newly allowed untrusted-workspace read path must satisfy all of these
conditions:
- Do not call `loadSettings()` or any settings migration/repair path.
- Do not create, repair, rewrite, or otherwise modify storage.
- Suppress file-backed debug logging while the catalog reader is active so a
malformed record cannot create or append a debug log as a read side effect.
- Do not call `ensureChannel()` or any other ACP child startup path.
- Do not query or merge the untrusted runtime's live bridge state.
- Do not execute external commands.
- Do not discover or parse workspace agents, skills, hooks, MCP configuration,
or other project-controlled capability definitions.
The implementation enforces the live-state boundary with an internal
`mergeLive: false` read policy on all session-list shapes: default, organized,
and `parentSessionId` filtered. The same async read boundary suppresses only
file-backed debug logging for untrusted catalog reads; trusted requests and
logging outside that boundary are unchanged. Missing storage produces an empty
catalog, and malformed entries follow the existing best-effort read behavior
without repairing files.
## Route Matrix
The table describes an untrusted secondary workspace unless noted otherwise.
| Surface | Result | Data source and constraints |
| ------------------------------------------- | ----------------- | ------------------------------------------------------------------------------------- |
| `GET /workspace/:id/sessions` | 200 | Persisted session files only; id or encoded canonical cwd selector |
| `GET /workspaces/:workspace/sessions` | 200 | Same persisted-only catalog |
| `GET /workspace/:id/session-groups` | 200 | Organization sidecar only; any registered id or encoded cwd |
| `GET /workspaces/:workspace/session-groups` | 200 | Organization sidecar only |
| File read, bytes, stat, list, glob | Existing behavior | Existing filesystem read policy is unchanged |
| Workspace trust GET/request | Existing behavior | Existing trust configuration semantics are unchanged |
| `/capabilities`, `/daemon/status` | Existing behavior | Existing daemon diagnostics are unchanged |
| Plural session/group mutations | 403 | Mutation trust gate remains unchanged |
| Singular group mutations | Existing behavior | Remain primary-only; secondary selectors fail closed |
| Settings, permissions, providers | 403 | Settings loading may migrate, back up, or repair files |
| Memory | 403 | Current response includes global-memory paths rather than a workspace-only projection |
| Env | 403 | Exposes credential presence and proxy/host diagnostics |
| Preflight | 403 | May execute git, npm, ripgrep, or other probes |
| MCP, tools, hooks | 403 | Coupled to live bridge state or project configuration |
| Skills, agents | 403 | Discovers and parses project-controlled definitions |
| Transcript | 403 | Current path can start ACP and cursor initialization can write an HMAC key |
| Export, session status/context/tasks | 403 | No workspace-qualified persisted-only implementation |
| ACP HTTP/WebSocket, voice, channels | Rejected | Execution, process, or long-lived runtime capabilities |
Unknown absolute, nested, or unregistered workspace selectors continue to fail
closed with the existing `400 workspace_mismatch` response. A malformed legacy
singular selector retains its existing `400` validation message. Neither case
falls back to the primary workspace. Plural routes keep returning
`403 untrusted_workspace` for an untrusted primary workspace. Singular primary
routes retain their existing compatibility behavior.
## Session Catalog Semantics
The persisted-only mode retains the existing `archiveState`, `view=organized`,
`group`, `parentSessionId`, cursor, and page-size behavior. It never populates
pending interactions, turn errors, or client state from the live runtime;
existing persisted-summary defaults such as `clientCount: 0` and
`hasActivePrompt: false` remain wire-compatible. It never calls
`bridge.listWorkspaceSessions()`.
Trusted secondary and primary workspaces keep the existing persisted/live
merge. No route, wire field, schema, or capability tag is added: older clients
continue to handle `403`, while the bundled Web Shell consumes the new `200`
response when shipped with the daemon.
## Web Shell Behavior
An untrusted secondary workspace remains expandable and is labeled both
`untrusted` and `read-only`. Expanding it performs one catalog read. A
`reloadToken` change performs another read, but the usual ten-second poll is
disabled because this daemon cannot create sessions in that workspace.
Expanding does not select or activate the workspace. Persisted sessions are
rendered as non-interactive rows with `role="note"` and an accessible name that
includes the session name, date, and an explanation that the workspace must be
trusted before a session can be opened. The row does not bind mouse or keyboard
activation or receive active-session styling. Trusted workspace behavior is
unchanged. An untrusted primary remains disabled pending a separate primary
safe-mode design.
## Failure and Compatibility Behavior
- Missing session or organization storage returns an empty catalog.
- Unparseable and non-object JSONL records are skipped by the existing session
reader. This change does not add schema validation for structurally invalid
object records.
- An unreadable organization sidecar returns the existing empty read view and
warning; reads do not repair it.
- Web Shell request failures retain the existing empty state and console
warning.
- Trust GET continues to observe the current on-disk trust configuration and
tells callers that runtime changes require restart. It is not converted to a
boot snapshot in this change.
## Deferred Work
- A side-effect-free settings and trust snapshot loader.
- A workspace-only memory projection.
- Redacted environment and configuration inspection.
- Skills and agents inventory that does not parse project definitions.
- A daemon-local transcript reader that neither starts ACP nor initializes a
cursor HMAC key, plus a truly read-only session viewer.
- Dynamic trust application, runtime rebuild, and workspace removal/draining.
+5 -5
View File
@@ -211,7 +211,7 @@ 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.
`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. Registered untrusted secondary workspaces also expose persisted-only session and session-group catalogs: these reads do not attach to a session, start ACP, or merge live bridge state. File writes, catalog mutations, and every other plural core route require a trusted workspace and return `403 { code: "untrusted_workspace" }` when the selected runtime is untrusted. An untrusted primary continues to receive that `403` from the plural catalog routes; legacy singular primary routes keep their existing compatibility behavior. 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. Workspace trust is not an ACL: a client holding the daemon token can read every registered workspace surface allowed by this policy.
`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.
@@ -1412,7 +1412,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` 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. Trusted non-primary workspaces also support `archiveState=archived`, the organized `view=organized` list, and `group` filters, reading from their own `chats/`, `chats/archive/`, and session-organization stores; a combined `view=organized&archiveState=archived` query returns only archived sessions without a live merge. 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.
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. 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. Trusted non-primary workspaces also support `archiveState=archived`, the organized `view=organized` list, and `group` filters, reading from their own `chats/`, `chats/archive/`, and session-organization stores; a combined `view=organized&archiveState=archived` query returns only archived sessions without a live merge. Registered untrusted non-primary workspaces support the same list, filter, and pagination shapes but return persisted entries only: the daemon does not query the live bridge or populate pending interactions, turn errors, or client state from the runtime. Persisted defaults such as `clientCount: 0` and `hasActivePrompt: false` remain present for wire compatibility. Missing storage returns an empty list. The plural route still returns `403 { code: "untrusted_workspace" }` for an untrusted primary; 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 trusted 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
@@ -1420,7 +1420,7 @@ 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, group CRUD, and session organization mutation are available under `/workspaces/:workspace/sessions/{delete,archive,unarchive}`, `/workspaces/:workspace/session-groups`, and `/workspaces/:workspace/session/:id/organization`. Workspace-less batch and organization mutation routes remain primary-workspace-only for compatibility.
When `workspace_qualified_rest_core` is advertised, workspace-scoped session batch operations, group CRUD, and session organization mutation are available under `/workspaces/:workspace/sessions/{delete,archive,unarchive}`, `/workspaces/:workspace/session-groups`, and `/workspaces/:workspace/session/:id/organization`. For an untrusted secondary, group GET remains available; every group, session, and organization mutation remains trust-gated. Workspace-less batch and organization mutation routes remain primary-workspace-only for compatibility.
Query parameters:
@@ -1463,11 +1463,11 @@ Additional fields may appear on each session when `view=organized`:
}
```
Active lists include live daemon overlay fields such as `clientCount` and `hasActivePrompt`. Archived lists are storage-only: `isArchived` is `true`, and live overlay fields remain absent or false. Empty array (not 404) when no sessions exist — a session-picker UI shouldn't error just because the workspace is idle.
Trusted active lists include live daemon overlay fields such as `clientCount` and `hasActivePrompt`. Untrusted-secondary and archived lists are storage-only: live overlay fields remain absent or false, and archived entries set `isArchived` to `true`. Empty array (not 404) when no sessions exist — a session-picker UI shouldn't error just because the workspace is idle.
### `GET /workspace/:id/session-groups`
List user-defined session groups for a workspace. Pre-flight `caps.features.includes('session_organization')`.
List user-defined session groups for a workspace. The singular GET selector accepts any registered workspace id or URL-encoded canonical cwd. The plural GET alias is also available to an untrusted secondary and reads only the organization sidecar. Plural group mutations remain trust-gated, while singular group mutations retain their primary-only compatibility behavior. Pre-flight `caps.features.includes('session_organization')`.
Response:
+4 -2
View File
@@ -415,9 +415,11 @@ Both flags accept a positive integer in milliseconds; `0`, `NaN`, non-integer, o
## Multi-session & multi-workspace deployment
Pass `--workspace` more than once to register several workspaces in one `qwen serve` process. The first path is primary. Each registered workspace owns an isolated runtime and `qwen --acp` child, while the daemon-wide listener, authentication policy, and total-session limit are shared. Requests may select a registered workspace by canonical `cwd`; requests that omit `cwd` use the primary workspace.
Pass `--workspace` more than once to register several non-overlapping workspaces in one `qwen serve` process. The first path is primary. Each registered workspace owns an isolated runtime boundary, while the daemon-wide listener, authentication policy, and total-session limit are shared. Trusted workspaces start their own `qwen --acp` child on demand. Requests may select a registered workspace by canonical `cwd`; requests that omit `cwd` use the primary workspace. Use one daemon per user or security principal; workspace trust is an execution gate, not an ACL.
Use separate daemon processes when you need a smaller fault or security boundary, independent bearer tokens, or independent resource supervision. Multi-workspace mode is intended for one operator hosting several repos; it is not a multi-tenant isolation boundary.
An untrusted secondary workspace is visible in Web Shell as `untrusted` and `read-only`. It can be expanded to inspect the persisted session catalog, but it cannot be selected, opened, resumed, or used to create sessions. The REST API also exposes its persisted session-group catalog. Neither catalog includes transcript content or live runtime state, and catalog reads do not start an ACP child. Trust the workspace and restart the daemon before using execution or mutation features. An untrusted primary remains disabled in Web Shell.
Use separate daemon processes when you need a smaller fault or security boundary, independent bearer tokens, quotas, audit boundaries, operating-system isolation, or independent resource supervision. Multi-workspace mode is intended for one operator hosting several repos; it is not a multi-tenant isolation boundary. A single daemon token authorizes every route the daemon exposes, including the allowed read-only catalog for all registered workspaces.
> **Subscribe BEFORE posting `modelServiceId` on attach.** When a client `POST /session` with a `modelServiceId` and the workspace already has a session running a different model, the daemon issues an internal `setSessionModel` call — failures are NOT propagated as an HTTP error (the session stays operational on its current model). The visible failure signal is a `model_switch_failed` event on the session's SSE stream. If you call `POST /session` and only THEN open `GET /session/:id/events`, you'll miss the failure event and silently keep talking to the wrong model. Open the SSE stream first, or pass `Last-Event-ID: 0` on subscribe to replay the ring's oldest available event.
@@ -9,7 +9,12 @@ import { promises as fsp } from 'node:fs';
import * as os from 'node:os';
import { describe, expect, it, vi } from 'vitest';
import request from 'supertest';
import { Storage } from '@qwen-code/qwen-code-core';
import {
Storage,
createDebugLogger,
resetDebugLoggingState,
setDebugLogSession,
} from '@qwen-code/qwen-code-core';
import {
SessionNotFoundError,
type AcpSessionBridge,
@@ -94,23 +99,55 @@ async function writeStoredSession(input: {
timestamp: string;
prompt: string;
mtime: Date;
parentSessionId?: string;
}): Promise<void> {
const chatsDir = path.join(new Storage(input.cwd).getProjectDir(), 'chats');
await fsp.mkdir(chatsDir, { recursive: true });
const filePath = path.join(chatsDir, `${input.sessionId}.jsonl`);
const record = {
uuid: `${input.sessionId}-user-1`,
parentUuid: null,
sessionId: input.sessionId,
timestamp: input.timestamp,
type: 'user',
message: { role: 'user', parts: [{ text: input.prompt }] },
cwd: input.cwd,
};
await fsp.writeFile(filePath, `${JSON.stringify(record)}\n`, 'utf8');
const records: Array<Record<string, unknown>> = [
{
uuid: `${input.sessionId}-user-1`,
parentUuid: null,
sessionId: input.sessionId,
timestamp: input.timestamp,
type: 'user',
message: { role: 'user', parts: [{ text: input.prompt }] },
cwd: input.cwd,
},
];
if (input.parentSessionId !== undefined) {
records.push({
uuid: `${input.sessionId}-parent-1`,
parentUuid: `${input.sessionId}-user-1`,
sessionId: input.sessionId,
timestamp: input.timestamp,
type: 'system',
subtype: 'parent_session',
systemPayload: { parentSessionId: input.parentSessionId },
cwd: input.cwd,
});
}
await fsp.writeFile(
filePath,
`${records.map((record) => JSON.stringify(record)).join('\n')}\n`,
'utf8',
);
await fsp.utimes(filePath, input.mtime, input.mtime);
}
async function archiveStoredSession(
cwd: string,
sessionId: string,
): Promise<void> {
const chatsDir = path.join(new Storage(cwd).getProjectDir(), 'chats');
const archiveDir = path.join(chatsDir, 'archive');
await fsp.mkdir(archiveDir, { recursive: true });
await fsp.rename(
path.join(chatsDir, `${sessionId}.jsonl`),
path.join(archiveDir, `${sessionId}.jsonl`),
);
}
async function withRuntimeDir<T>(fn: () => Promise<T>): Promise<T> {
const previousRuntimeDir = process.env['QWEN_RUNTIME_DIR'];
const runtimeDir = await fsp.mkdtemp(
@@ -1024,33 +1061,99 @@ describe('multi-workspace session dispatch', () => {
});
});
it('rejects archived and organized non-primary session lists for untrusted workspaces', async () => {
const { app } = makeHarness({ secondaryTrusted: false });
it('lists archived and organized persisted sessions for an untrusted secondary without live merge', async () => {
await withRuntimeDir(async () => {
const activeId = '550e8400-e29b-41d4-a716-446655440160';
const archivedId = '550e8400-e29b-41d4-a716-446655440161';
const liveOnlyId = '550e8400-e29b-41d4-a716-446655440162';
await writeStoredSession({
sessionId: activeId,
cwd: SECONDARY_CWD,
timestamp: '2026-07-08T01:00:00.000Z',
prompt: 'untrusted active persisted',
mtime: new Date('2026-07-08T01:00:00.000Z'),
});
await writeStoredSession({
sessionId: archivedId,
cwd: SECONDARY_CWD,
timestamp: '2026-07-08T00:00:00.000Z',
prompt: 'untrusted archived persisted',
mtime: new Date('2026-07-08T00:00:00.000Z'),
});
await archiveStoredSession(SECONDARY_CWD, archivedId);
const organizationService =
createSessionOrganizationService(SECONDARY_CWD);
const group = await organizationService.createGroup({
name: 'Persisted group',
color: 'blue',
});
await organizationService.updateSessionOrganization(activeId, {
isPinned: true,
groupId: group.id,
});
const { app, secondaryBridge } = makeHarness({
secondaryTrusted: false,
secondarySummaries: [makeSummary(liveOnlyId, SECONDARY_CWD)],
});
const archived = await request(app)
.get('/workspaces/secondary-id/sessions?archiveState=archived')
.set('Host', host());
expect(archived.status).toBe(403);
expect(archived.body.code).toBe('untrusted_workspace');
const archived = await request(app)
.get('/workspaces/secondary-id/sessions?archiveState=archived')
.set('Host', host())
.expect(200);
expect(
archived.body.sessions.map((s: { sessionId: string }) => s.sessionId),
).toEqual([archivedId]);
const organized = await request(app)
.get('/workspaces/secondary-id/sessions?view=organized')
.set('Host', host());
expect(organized.status).toBe(403);
expect(organized.body.code).toBe('untrusted_workspace');
const organized = await request(app)
.get('/workspaces/secondary-id/sessions?view=organized&group=pinned')
.set('Host', host())
.expect(200);
expect(
organized.body.sessions.map((s: { sessionId: string }) => s.sessionId),
).toEqual([activeId]);
const namedGroup = await request(app)
.get(
`/workspaces/secondary-id/sessions?view=organized&group=${group.id}`,
)
.set('Host', host())
.expect(200);
expect(
namedGroup.body.sessions.map((s: { sessionId: string }) => s.sessionId),
).toEqual([activeId]);
expect(secondaryBridge.listCalls).toEqual([]);
});
});
it('returns workspace_mismatch for unknown absolute workspace session lists', async () => {
it('returns workspace_mismatch for unknown absolute workspace catalogs', async () => {
const { app } = makeHarness();
const unknown = await request(app)
.get(`/workspace/${encodeURIComponent(UNKNOWN_CWD)}/sessions`)
.set('Host', host());
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);
for (const selector of [UNKNOWN_CWD, path.join(PRIMARY_CWD, 'nested')]) {
for (const route of [
`/workspace/${encodeURIComponent(selector)}/sessions`,
`/workspace/${encodeURIComponent(selector)}/session-groups`,
]) {
const unknown = await request(app).get(route).set('Host', host());
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(selector);
}
for (const route of [
`/workspaces/${encodeURIComponent(selector)}/sessions`,
`/workspaces/${encodeURIComponent(selector)}/session-groups`,
]) {
const unknown = await request(app).get(route).set('Host', host());
expect(unknown.status).toBe(400);
expect(unknown.body).toEqual({
error:
'Workspace mismatch: the requested workspace is not registered with this daemon.',
code: 'workspace_mismatch',
workspaceCount: 2,
});
}
}
});
it('lists active persisted non-primary sessions by encoded workspace cwd', async () => {
@@ -1170,41 +1273,315 @@ describe('multi-workspace session dispatch', () => {
it('preserves the legacy invalid workspace selector message', async () => {
const { app } = makeHarness();
const res = await request(app)
.get('/workspace/not:an:absolute:path/sessions')
.set('Host', host());
for (const route of [
'/workspace/not:an:absolute:path/sessions',
'/workspace/not:an:absolute:path/session-groups',
]) {
const res = await request(app).get(route).set('Host', host());
expect(res.status).toBe(400);
expect(res.body.error).toBe(
'`:id` must decode to a workspace id or absolute path',
);
expect(res.status).toBe(400);
expect(res.body.error).toBe(
'`:id` must decode to a workspace id or absolute path',
);
}
});
it('rejects untrusted non-primary workspace session listing', async () => {
const daemonLog = makeDaemonLog();
const { app, secondaryBridge } = makeHarness({
secondaryTrusted: false,
daemonLog,
it('lists only persisted sessions for an untrusted secondary by id and encoded cwd without writing storage', async () => {
await withRuntimeDir(async () => {
const storedId = '550e8400-e29b-41d4-a716-446655440170';
const liveOnlyId = '550e8400-e29b-41d4-a716-446655440171';
await writeStoredSession({
sessionId: storedId,
cwd: SECONDARY_CWD,
timestamp: '2026-07-08T00:00:00.000Z',
prompt: 'untrusted persisted session',
mtime: new Date('2026-07-08T00:00:00.000Z'),
});
const chatsDir = path.join(
new Storage(SECONDARY_CWD).getProjectDir(),
'chats',
);
const storedPath = path.join(chatsDir, `${storedId}.jsonl`);
const beforeEntries = await fsp.readdir(chatsDir);
const beforeContent = await fsp.readFile(storedPath, 'utf8');
const { app, secondaryBridge } = makeHarness({
secondaryTrusted: false,
secondarySummaries: [makeSummary(liveOnlyId, SECONDARY_CWD)],
});
for (const route of [
'/workspaces/secondary-id/sessions',
`/workspaces/${encodeURIComponent(SECONDARY_CWD)}/sessions`,
'/workspace/secondary-id/sessions',
`/workspace/${encodeURIComponent(SECONDARY_CWD)}/sessions`,
]) {
const res = await request(app)
.get(route)
.set('Host', host())
.expect(200);
expect(
res.body.sessions.map((s: { sessionId: string }) => s.sessionId),
).toEqual([storedId]);
}
expect(secondaryBridge.listCalls).toEqual([]);
expect(await fsp.readdir(chatsDir)).toEqual(beforeEntries);
expect(await fsp.readFile(storedPath, 'utf8')).toBe(beforeContent);
});
});
const res = await request(app)
.get('/workspaces/secondary-id/sessions')
.set('Host', host());
it('returns empty untrusted catalogs without creating storage', async () => {
await withRuntimeDir(async () => {
const projectDir = new Storage(SECONDARY_CWD).getProjectDir();
const { app, secondaryBridge } = makeHarness({
secondaryTrusted: false,
});
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(secondaryBridge.listCalls).toEqual([]);
expect(daemonLog.warn).toHaveBeenCalledWith(
'session routing failed',
expect.objectContaining({
route: 'GET /workspaces/:workspace/sessions',
resolutionKind: 'untrusted_workspace',
workspaceCwd: SECONDARY_CWD,
}),
);
await expect(fsp.stat(projectDir)).rejects.toMatchObject({
code: 'ENOENT',
});
const sessions = await request(app)
.get('/workspaces/secondary-id/sessions?view=organized')
.set('Host', host())
.expect(200);
expect(sessions.body.sessions).toEqual([]);
const groups = await request(app)
.get('/workspaces/secondary-id/session-groups')
.set('Host', host())
.expect(200);
expect(groups.body.groups).toEqual([]);
expect(secondaryBridge.listCalls).toEqual([]);
await expect(fsp.stat(projectDir)).rejects.toMatchObject({
code: 'ENOENT',
});
});
});
it('does not repair malformed untrusted catalog storage', async () => {
await withRuntimeDir(async () => {
const previousDebugLogFile = process.env['QWEN_DEBUG_LOG_FILE'];
const storage = new Storage(SECONDARY_CWD);
const chatsDir = path.join(storage.getProjectDir(), 'chats');
const malformedSessionPath = path.join(
chatsDir,
'550e8400-e29b-41d4-a716-446655440190.jsonl',
);
const organizationPath = path.join(
storage.getProjectDir(),
'session-organization.v1.json',
);
await fsp.mkdir(chatsDir, { recursive: true });
await fsp.writeFile(malformedSessionPath, '{not-json}\n', 'utf8');
await fsp.writeFile(organizationPath, '{not-json}\n', 'utf8');
const beforeEntries = await fsp.readdir(storage.getProjectDir());
const beforeSession = await fsp.readFile(malformedSessionPath, 'utf8');
const beforeOrganization = await fsp.readFile(organizationPath, 'utf8');
const { app, secondaryBridge } = makeHarness({
secondaryTrusted: false,
});
const debugSessionId = '550e8400-e29b-41d4-a716-446655440191';
const debugLogPath = Storage.getDebugLogPath(debugSessionId);
process.env['QWEN_DEBUG_LOG_FILE'] = '1';
resetDebugLoggingState();
setDebugLogSession({ getSessionId: () => debugSessionId });
try {
const sessions = await request(app)
.get('/workspaces/secondary-id/sessions?view=organized')
.set('Host', host())
.expect(200);
expect(sessions.body.sessions).toEqual([]);
const groups = await request(app)
.get('/workspaces/secondary-id/session-groups')
.set('Host', host())
.expect(200);
expect(groups.body.groups).toEqual([]);
expect(secondaryBridge.listCalls).toEqual([]);
expect(await fsp.readdir(storage.getProjectDir())).toEqual(
beforeEntries,
);
expect(await fsp.readFile(malformedSessionPath, 'utf8')).toBe(
beforeSession,
);
expect(await fsp.readFile(organizationPath, 'utf8')).toBe(
beforeOrganization,
);
createDebugLogger('TEST').info('debug sentinel');
await vi.waitFor(async () => {
expect(await fsp.readFile(debugLogPath, 'utf8')).toContain(
'debug sentinel',
);
});
expect(await fsp.readFile(debugLogPath, 'utf8')).not.toContain(
'Failed to parse line',
);
} finally {
setDebugLogSession(null);
resetDebugLoggingState();
if (previousDebugLogFile === undefined) {
delete process.env['QWEN_DEBUG_LOG_FILE'];
} else {
process.env['QWEN_DEBUG_LOG_FILE'] = previousDebugLogFile;
}
}
});
});
it('paginates persisted child sessions for an untrusted secondary without live merge', async () => {
await withRuntimeDir(async () => {
const parentSessionId = '550e8400-e29b-41d4-a716-446655440180';
const childIds = [
'550e8400-e29b-41d4-a716-446655440181',
'550e8400-e29b-41d4-a716-446655440182',
];
for (const [index, sessionId] of childIds.entries()) {
await writeStoredSession({
sessionId,
cwd: SECONDARY_CWD,
timestamp: `2026-07-08T00:0${index}:00.000Z`,
prompt: `child ${index}`,
mtime: new Date(`2026-07-08T00:0${index}:00.000Z`),
parentSessionId,
});
}
const { app, secondaryBridge } = makeHarness({
secondaryTrusted: false,
});
const first = await request(app)
.get(
`/workspaces/secondary-id/sessions?parentSessionId=${parentSessionId}&size=1`,
)
.set('Host', host())
.expect(200);
expect(first.body.sessions).toHaveLength(1);
expect(first.body.nextCursor).toEqual(expect.any(String));
const second = await request(app)
.get(
`/workspaces/secondary-id/sessions?parentSessionId=${parentSessionId}&size=1&cursor=${encodeURIComponent(
first.body.nextCursor as string,
)}`,
)
.set('Host', host())
.expect(200);
expect(second.body.sessions).toHaveLength(1);
expect(second.body.sessions[0].sessionId).not.toBe(
first.body.sessions[0].sessionId,
);
expect(second.body.nextCursor).toBeUndefined();
expect(secondaryBridge.listCalls).toEqual([]);
});
});
it('paginates the default persisted catalog for an untrusted secondary', async () => {
await withRuntimeDir(async () => {
const sessionIds = [
'550e8400-e29b-41d4-a716-446655440192',
'550e8400-e29b-41d4-a716-446655440193',
];
for (const [index, sessionId] of sessionIds.entries()) {
await writeStoredSession({
sessionId,
cwd: SECONDARY_CWD,
timestamp: `2026-07-08T00:0${index}:00.000Z`,
prompt: `default page ${index}`,
mtime: new Date(`2026-07-08T00:0${index}:00.000Z`),
});
}
const { app, secondaryBridge } = makeHarness({
secondaryTrusted: false,
secondarySummaries: [
makeSummary('550e8400-e29b-41d4-a716-446655440194', SECONDARY_CWD),
],
});
const first = await request(app)
.get('/workspaces/secondary-id/sessions?size=1')
.set('Host', host())
.expect(200);
expect(
first.body.sessions.map(
(session: { sessionId: string }) => session.sessionId,
),
).toEqual([sessionIds[1]]);
expect(first.body.nextCursor).toEqual(expect.any(String));
const second = await request(app)
.get(
`/workspaces/secondary-id/sessions?size=1&cursor=${encodeURIComponent(
first.body.nextCursor as string,
)}`,
)
.set('Host', host())
.expect(200);
expect(
second.body.sessions.map(
(session: { sessionId: string }) => session.sessionId,
),
).toEqual([sessionIds[0]]);
expect(second.body.nextCursor).toBeUndefined();
expect(secondaryBridge.listCalls).toEqual([]);
});
});
it('lists session groups for an untrusted secondary while mutations stay blocked', async () => {
await withRuntimeDir(async () => {
const service = createSessionOrganizationService(SECONDARY_CWD);
const group = await service.createGroup({
name: 'Read only group',
color: 'blue',
});
const before = await fsp.readFile(service.getStorePath(), 'utf8');
const { app } = makeHarness({ secondaryTrusted: false });
for (const route of [
'/workspaces/secondary-id/session-groups',
`/workspaces/${encodeURIComponent(SECONDARY_CWD)}/session-groups`,
'/workspace/secondary-id/session-groups',
`/workspace/${encodeURIComponent(SECONDARY_CWD)}/session-groups`,
]) {
const res = await request(app)
.get(route)
.set('Host', host())
.expect(200);
expect(
(res.body.groups as Array<{ id: string }>).map((item) => item.id),
).toContain(group.id);
}
expect(await fsp.readFile(service.getStorePath(), 'utf8')).toBe(before);
await request(app)
.post('/workspaces/secondary-id/session-groups')
.set('Host', host())
.send({ name: 'Blocked', color: 'red' })
.expect(403);
await request(app)
.patch(`/workspaces/secondary-id/session-groups/${group.id}`)
.set('Host', host())
.send({ name: 'Blocked' })
.expect(403);
await request(app)
.delete(`/workspaces/secondary-id/session-groups/${group.id}`)
.set('Host', host())
.expect(403);
await request(app)
.post('/workspace/secondary-id/session-groups')
.set('Host', host())
.send({ name: 'Blocked', color: 'red' })
.expect(400);
await request(app)
.patch(`/workspace/secondary-id/session-groups/${group.id}`)
.set('Host', host())
.send({ name: 'Blocked' })
.expect(400);
await request(app)
.delete(`/workspace/secondary-id/session-groups/${group.id}`)
.set('Host', host())
.expect(400);
expect(await fsp.readFile(service.getStorePath(), 'utf8')).toBe(before);
});
});
it('rejects untrusted primary workspace on plural session routes', async () => {
@@ -1214,23 +1591,41 @@ describe('multi-workspace session dispatch', () => {
daemonLog,
});
const res = await request(app)
.get('/workspaces/primary-id/session-groups')
.set('Host', host());
for (const [route, routeLabel] of [
[
'/workspaces/primary-id/sessions',
'GET /workspaces/:workspace/sessions',
],
[
'/workspaces/primary-id/session-groups',
'GET /workspaces/:workspace/session-groups',
],
] as const) {
const res = await request(app).get(route).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,
}),
);
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: routeLabel,
resolutionKind: 'untrusted_workspace',
workspaceCwd: PRIMARY_CWD,
}),
);
}
await request(app)
.get('/workspace/primary-id/sessions')
.set('Host', host())
.expect(200);
await request(app)
.get('/workspace/primary-id/session-groups')
.set('Host', host())
.expect(200);
});
it('updates a persisted secondary session by encoded cwd without touching the primary sidecar', async () => {
+63 -30
View File
@@ -14,6 +14,7 @@ import {
SessionOrganizationError,
SESSION_TRANSCRIPT_MAX_LIMIT,
addDaemonRequestAttribute,
runWithoutDebugLogSession,
type ApprovalMode,
type SessionGroupColor,
type SessionArchiveState,
@@ -89,6 +90,19 @@ interface RegisterSessionRoutesDeps {
languageCodes: string[];
}
function isReadOnlyWorkspaceCatalog(runtime: WorkspaceRuntime): boolean {
return !runtime.primary && !runtime.trusted;
}
function runCatalogReadWithWorkspaceLogPolicy<T>(
runtime: WorkspaceRuntime,
read: () => Promise<T>,
): Promise<T> {
return isReadOnlyWorkspaceCatalog(runtime)
? runWithoutDebugLogSession(read)
: read();
}
function requireSessionArtifactClientId(
clientId: string | undefined,
res: Response,
@@ -360,6 +374,33 @@ export function registerSessionRoutes(
return runtime;
};
const resolveRuntimeForCatalogRoute = (
req: Request,
res: Response,
paramName: 'id' | 'workspace',
route: string,
): WorkspaceRuntime | null => {
const runtime =
paramName === 'workspace'
? resolveWorkspaceRuntimeFromParam(
workspaceRegistry,
req,
res,
'workspace',
)
: resolveRuntimeFromWorkspaceParam(req, res, paramName);
if (runtime === null) return null;
if (paramName === 'workspace' && runtime.primary && !runtime.trusted) {
logSessionRoutingFailure(route, 'untrusted_workspace', {
workspaceId: runtime.workspaceId,
workspaceCwd: runtime.workspaceCwd,
});
sendUntrustedWorkspaceResponse(res);
return null;
}
return runtime;
};
const hasActivePersistedSessions = async (workspaceCwd: string) => {
try {
const page = await new SessionService(workspaceCwd).listSessions({
@@ -2090,12 +2131,18 @@ export function registerSessionRoutes(
);
app.get('/workspace/:id/session-groups', async (req, res) => {
const key = resolveWorkspaceParam(req, res);
if (key === null) return;
// Preserve the legacy singular-route behavior for an untrusted primary;
// plural catalog routes intentionally retain their trust gate.
const runtime = resolveRuntimeFromWorkspaceParam(req, res);
if (runtime === null) return;
try {
res
.status(200)
.json(await createSessionOrganizationService(key).listGroups());
.json(
await runCatalogReadWithWorkspaceLogPolicy(runtime, () =>
createSessionOrganizationService(runtime.workspaceCwd).listGroups(),
),
);
} catch (err) {
sendBridgeError(res, err, {
route: 'GET /workspace/:id/session-groups',
@@ -2175,15 +2222,15 @@ 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);
const runtime = resolveRuntimeForCatalogRoute(req, res, 'workspace', route);
if (!runtime) return;
try {
res
.status(200)
.json(
await createSessionOrganizationService(
runtime.workspaceCwd,
).listGroups(),
await runCatalogReadWithWorkspaceLogPolicy(runtime, () =>
createSessionOrganizationService(runtime.workspaceCwd).listGroups(),
),
);
} catch (err) {
sendBridgeError(res, err, { route });
@@ -2263,7 +2310,7 @@ export function registerSessionRoutes(
);
const listWorkspaceSessionsHandler =
(paramName: string): RequestHandler =>
(paramName: 'id' | 'workspace'): RequestHandler =>
async (req, res) => {
const route =
paramName === 'workspace'
@@ -2272,29 +2319,10 @@ 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 =
paramName === 'workspace'
? resolveWorkspaceRuntimeFromParam(
workspaceRegistry,
req,
res,
'workspace',
)
: resolveRuntimeFromWorkspaceParam(req, res, paramName);
const runtime = resolveRuntimeForCatalogRoute(req, res, paramName, route);
if (runtime === null) return;
if (
paramName === 'workspace'
? !runtime.trusted
: !runtime.primary && !runtime.trusted
) {
logSessionRoutingFailure(route, 'untrusted_workspace', {
workspaceId: runtime.workspaceId,
workspaceCwd: runtime.workspaceCwd,
});
sendUntrustedWorkspaceResponse(res);
return;
}
const key = runtime.workspaceCwd;
const readOnlySecondary = isReadOnlyWorkspaceCatalog(runtime);
try {
const cursor =
typeof req.query['cursor'] === 'string'
@@ -2376,6 +2404,7 @@ export function registerSessionRoutes(
// (persisted + live) to filter completely and paginates with an opaque
// activity cursor, so the numeric-cursor live fallback can't serve it.
const usePersisted =
readOnlySecondary ||
runtime.primary ||
view === 'organized' ||
archiveState === 'archived' ||
@@ -2396,7 +2425,11 @@ export function registerSessionRoutes(
);
}
const result = usePersisted
? await listWorkspaceSessionsForResponse(runtime.bridge, key, options)
? await runCatalogReadWithWorkspaceLogPolicy(runtime, () =>
listWorkspaceSessionsForResponse(runtime.bridge, key, options, {
mergeLive: !readOnlySecondary,
}),
)
: listLiveWorkspaceSessionsForResponse(runtime.bridge, key, options);
res.status(200).json({
sessions: result.sessions,
+1
View File
@@ -200,6 +200,7 @@ export {
} from './server/session-list.js';
export type {
ListWorkspaceSessionsOptions,
ListWorkspaceSessionsReadOptions,
ListWorkspaceSessionsResult,
} from './server/session-list.js';
export { getActiveSseCount } from './routes/sse-events.js';
+17 -3
View File
@@ -45,6 +45,11 @@ export interface ListWorkspaceSessionsResult {
truncated?: boolean;
}
export interface ListWorkspaceSessionsReadOptions {
/** Merge live bridge state into persisted summaries. */
mergeLive?: boolean;
}
export class InvalidCursorError extends Error {
constructor(
cursor: string,
@@ -381,6 +386,7 @@ async function listOrganizedWorkspaceSessionsForResponse(
workspaceCwd: string,
options: ListWorkspaceSessionsOptions,
pageSize: number,
readOptions: ListWorkspaceSessionsReadOptions,
): Promise<ListWorkspaceSessionsResult> {
const archiveState = options.archiveState ?? 'active';
const sessionService = new SessionService(workspaceCwd);
@@ -419,7 +425,11 @@ async function listOrganizedWorkspaceSessionsForResponse(
);
}
if (archiveState !== 'archived' && isFirstPage) {
if (
readOptions.mergeLive !== false &&
archiveState !== 'archived' &&
isFirstPage
) {
try {
const liveSessions = bridge.listWorkspaceSessions(workspaceCwd);
for (const live of liveSessions) {
@@ -518,6 +528,7 @@ async function listWorkspaceSessionsByParentForResponse(
options: ListWorkspaceSessionsOptions,
pageSize: number,
parentSessionId: string,
readOptions: ListWorkspaceSessionsReadOptions,
): Promise<ListWorkspaceSessionsResult> {
const archiveState = options.archiveState ?? 'active';
const sessionService = new SessionService(workspaceCwd);
@@ -531,7 +542,7 @@ async function listWorkspaceSessionsByParentForResponse(
}
let liveMergeFailed = false;
if (archiveState !== 'archived') {
if (readOptions.mergeLive !== false && archiveState !== 'archived') {
try {
for (const live of bridge.listWorkspaceSessions(workspaceCwd)) {
const existing = bySessionId.get(live.sessionId);
@@ -606,6 +617,7 @@ export async function listWorkspaceSessionsForResponse(
bridge: AcpSessionBridge,
workspaceCwd: string,
options?: ListWorkspaceSessionsOptions,
readOptions: ListWorkspaceSessionsReadOptions = {},
): Promise<ListWorkspaceSessionsResult> {
const rawSize = options?.size;
const requestedSize =
@@ -620,6 +632,7 @@ export async function listWorkspaceSessionsForResponse(
workspaceCwd,
options,
pageSize,
readOptions,
);
}
@@ -630,6 +643,7 @@ export async function listWorkspaceSessionsForResponse(
options,
pageSize,
options.parentSessionId,
readOptions,
);
}
@@ -652,7 +666,7 @@ export async function listWorkspaceSessionsForResponse(
bySessionId.set(item.sessionId, toSummary(item));
}
if (archiveState === 'archived') {
if (archiveState === 'archived' || readOptions.mergeLive === false) {
const sessions = [...bySessionId.values()];
const nextCursor =
persisted.nextCursor != null ? String(persisted.nextCursor) : undefined;
@@ -464,7 +464,7 @@ describe('workspace-qualified core REST', () => {
}
});
it('allows untrusted workspace file reads but rejects other core reads', async () => {
it('allows untrusted workspace file reads but rejects non-catalog core reads', async () => {
const h = await makeHarness({ secondaryTrusted: false });
try {
const file = await request(h.app)
@@ -474,14 +474,28 @@ describe('workspace-qualified core REST', () => {
expect(file.status).toBe(200);
expect(file.body.content).toBe('secondary');
const mcp = await request(h.app)
.get(`/workspaces/${encodeURIComponent(h.secondaryId)}/mcp`)
.set('Host', host());
expect(mcp.status).toBe(403);
expect(mcp.body.code).toBe('untrusted_workspace');
expect(mcp.body.error).toBe('Workspace is not trusted.');
expect(mcp.body).not.toHaveProperty('workspaceCwd');
expect(mcp.body).not.toHaveProperty('workspaceId');
for (const route of [
'mcp',
'skills',
'providers',
'env',
'preflight',
'hooks',
'tools',
'settings',
'permissions',
'memory',
'agents',
]) {
const res = await request(h.app)
.get(`/workspaces/${encodeURIComponent(h.secondaryId)}/${route}`)
.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');
}
} finally {
await fsp.rm(h.scratch, { recursive: true, force: true });
}
@@ -9,6 +9,7 @@ import {
createDebugLogger,
isDebugLoggingDegraded,
resetDebugLoggingState,
runWithoutDebugLogSession,
setDebugLogSession,
type DebugLogSession,
} from './debugLogger.js';
@@ -79,6 +80,24 @@ describe('debugLogger', () => {
expect(fs.appendFile).not.toHaveBeenCalled();
});
it('suppresses the global debug session within an async context', async () => {
const logger = createDebugLogger('READ_ONLY');
await runWithoutDebugLogSession(async () => {
logger.warn('hidden before await');
await Promise.resolve();
logger.error('hidden after await');
});
await vi.runAllTimersAsync();
expect(fs.mkdir).not.toHaveBeenCalled();
expect(fs.appendFile).not.toHaveBeenCalled();
logger.info('visible outside context');
await vi.runAllTimersAsync();
expect(fs.appendFile).toHaveBeenCalledOnce();
});
it('writes debug log without trace context when telemetry context is unset', async () => {
const logger = createDebugLogger();
logger.debug('Hello world');
+9 -3
View File
@@ -33,7 +33,7 @@ let ensureDebugDirPromise: Promise<void> | null = null;
let ensuredDebugDirPath: string | null = null;
let hasWriteFailure = false;
let globalSession: DebugLogSession | null = null;
const sessionContext = new AsyncLocalStorage<DebugLogSession>();
const sessionContext = new AsyncLocalStorage<DebugLogSession | false>();
export function isDebugLogFileEnabled(): boolean {
const value = process.env['QWEN_DEBUG_LOG_FILE'];
@@ -43,7 +43,9 @@ export function isDebugLogFileEnabled(): boolean {
}
function getActiveSession(): DebugLogSession | null {
return sessionContext.getStore() ?? globalSession;
const contextSession = sessionContext.getStore();
if (contextSession === false) return null;
return contextSession ?? globalSession;
}
function ensureDebugDirExists(): Promise<void> {
@@ -193,11 +195,15 @@ export function runWithDebugLogSession<T>(
return sessionContext.run(session, fn);
}
export function runWithoutDebugLogSession<T>(fn: () => T): T {
return sessionContext.run(false, fn);
}
/**
* Creates a debug logger that writes to the current debug log session.
*
* Session resolution order:
* 1) async-local session (runWithDebugLogSession)
* 1) async-local suppression or session
* 2) process-wide session (setDebugLogSession)
*/
export function createDebugLogger(tag?: string): DebugLogger {
@@ -1545,8 +1545,8 @@ export class DaemonClient {
}
/**
* Enumerate live sessions in the given workspace. Used by session-picker
* UIs. Returns an empty list (not 404) when the workspace has no sessions.
* Enumerate the session catalog for a workspace. Used by session-picker UIs.
* Returns an empty list (not 404) when the workspace has no sessions.
*/
async listWorkspaceSessions(
workspaceCwd: string,
@@ -231,6 +231,7 @@ afterEach(() => {
act(() => root.unmount());
container.remove();
}
vi.useRealTimers();
vi.restoreAllMocks();
vi.unstubAllGlobals();
});
@@ -273,14 +274,175 @@ describe('WebShellSidebar — workspace picker', () => {
expect(labels.some((l) => l.includes('danger'))).toBe(true);
});
it('disables the untrusted workspace and enables trusted ones', () => {
it('lets an untrusted secondary expand as read-only without selecting or loading it', async () => {
mockWorkspace.capabilities = multiWorkspaceCaps;
const { container } = renderSidebar(false);
mockWorkspace.client.listWorkspaceSessions.mockImplementation(
async (cwd: string) =>
cwd === '/tmp/danger'
? [makeSession('danger-session', { workspaceCwd: cwd })]
: [],
);
const onSelectWorkspace = vi.fn();
const onLoadSession = vi.fn();
const { container } = renderSidebar(false, {
onSelectWorkspace,
onLoadSession,
});
const buttons = workspaceButtons(container);
const untrusted = buttons.find((b) => b.textContent?.includes('danger'));
const trusted = buttons.find((b) => b.textContent?.includes('other'));
expect(untrusted?.disabled).toBe(true);
expect(untrusted?.disabled).toBe(false);
expect(untrusted?.textContent).toContain('untrusted');
expect(untrusted?.textContent).toContain('read-only');
expect(trusted?.disabled).toBe(false);
await act(async () => {
untrusted?.dispatchEvent(new MouseEvent('click', { bubbles: true }));
});
await vi.waitFor(() =>
expect(mockWorkspace.client.listWorkspaceSessions).toHaveBeenCalledWith(
'/tmp/danger',
{ archiveState: 'active' },
),
);
expect(onSelectWorkspace).not.toHaveBeenCalled();
await vi.waitFor(() =>
expect(
container.querySelector<HTMLElement>('[role="note"]'),
).not.toBeNull(),
);
const session = container.querySelector<HTMLElement>('[role="note"]')!;
expect(session.textContent).toContain('Session danger-session');
expect(session.title).toBe('');
expect(session.getAttribute('role')).toBe('note');
expect(session.getAttribute('aria-disabled')).toBeNull();
expect(session.getAttribute('aria-label')).toBe(
`Session danger-session, ${new Date(
'2026-01-01T00:00:00.000Z',
).toLocaleDateString()}. Trust this workspace to open the session.`,
);
act(() => {
session.dispatchEvent(new MouseEvent('click', { bubbles: true }));
session.dispatchEvent(
new KeyboardEvent('keydown', { key: 'Enter', bubbles: true }),
);
session.dispatchEvent(
new KeyboardEvent('keydown', { key: ' ', bubbles: true }),
);
});
expect(onLoadSession).not.toHaveBeenCalled();
});
it('does not poll an expanded read-only workspace and reloads on token change', async () => {
vi.useFakeTimers();
mockWorkspace.capabilities = multiWorkspaceCaps;
mockActive.reload.mockResolvedValue(undefined);
const onNewSession = vi.fn().mockResolvedValue(true);
const { container } = renderSidebar(false, {
onSelectWorkspace: vi.fn(),
onNewSession,
});
const untrusted = workspaceButtons(container).find((button) =>
button.textContent?.includes('danger'),
);
await act(async () => {
untrusted?.dispatchEvent(new MouseEvent('click', { bubbles: true }));
});
const countWorkspaceCalls = (cwd: string) =>
mockWorkspace.client.listWorkspaceSessions.mock.calls.filter(
([calledCwd]) => calledCwd === cwd,
).length;
expect(countWorkspaceCalls('/tmp/danger')).toBe(1);
expect(countWorkspaceCalls('/tmp/project')).toBe(1);
await act(async () => {
vi.advanceTimersByTime(10_000);
});
expect(countWorkspaceCalls('/tmp/danger')).toBe(1);
expect(countWorkspaceCalls('/tmp/project')).toBe(2);
await act(async () => {
container
.querySelector<HTMLButtonElement>('button[aria-label="New chat"]')
?.dispatchEvent(new MouseEvent('click', { bubbles: true }));
});
expect(onNewSession).toHaveBeenCalledOnce();
expect(countWorkspaceCalls('/tmp/danger')).toBe(2);
});
it('shows an empty read-only catalog after a dynamically registered workspace appears', async () => {
mockWorkspace.capabilities = {
...multiWorkspaceCaps,
workspaces: [multiWorkspaceCaps.workspaces[0]!],
};
const { container, rerender } = renderSidebar(false);
expect(
workspaceButtons(container).some((button) =>
button.textContent?.includes('danger'),
),
).toBe(false);
mockWorkspace.capabilities = multiWorkspaceCaps;
rerender({});
const untrusted = workspaceButtons(container).find((button) =>
button.textContent?.includes('danger'),
);
expect(untrusted?.disabled).toBe(false);
await act(async () => {
untrusted?.dispatchEvent(new MouseEvent('click', { bubbles: true }));
});
await vi.waitFor(() =>
expect(mockWorkspace.client.listWorkspaceSessions).toHaveBeenCalledWith(
'/tmp/danger',
{ archiveState: 'active' },
),
);
await vi.waitFor(() =>
expect(untrusted?.parentElement?.textContent).toContain('No sessions.'),
);
});
it('keeps the read-only empty state when the catalog request fails', async () => {
mockWorkspace.capabilities = multiWorkspaceCaps;
const failure = new Error('catalog unavailable');
mockWorkspace.client.listWorkspaceSessions.mockRejectedValue(failure);
const warn = vi.spyOn(console, 'warn').mockImplementation(() => {});
const { container } = renderSidebar(false);
const untrusted = workspaceButtons(container).find((button) =>
button.textContent?.includes('danger'),
);
await act(async () => {
untrusted?.dispatchEvent(new MouseEvent('click', { bubbles: true }));
});
await vi.waitFor(() =>
expect(warn).toHaveBeenCalledWith(
'[WorkspaceSection] session poll failed:',
failure,
),
);
expect(untrusted?.parentElement?.textContent).toContain('No sessions.');
});
it('keeps an untrusted primary workspace disabled', () => {
mockWorkspace.capabilities = {
...multiWorkspaceCaps,
workspaces: [
{
id: 'ws-primary',
cwd: '/tmp/project',
primary: true,
trusted: false,
},
...multiWorkspaceCaps.workspaces.slice(1),
],
};
const { container } = renderSidebar(false);
const primary = workspaceButtons(container).find((button) =>
button.textContent?.includes('project'),
);
expect(primary?.disabled).toBe(true);
});
it('calls onSelectWorkspace with the chosen cwd for a secondary workspace', () => {
@@ -2556,7 +2556,10 @@ export function WebShellSidebar({
reloadToken={workspaceSessionsReloadToken}
primaryLabel={t('sidebar.workspacePrimary')}
untrustedLabel={t('sidebar.workspaceUntrusted')}
readOnlyLabel={t('sidebar.workspaceReadOnly')}
trustToOpenLabel={t('sidebar.workspaceTrustToOpen')}
noSessionsLabel={t('sidebar.noSessions')}
formatTime={(iso) => formatRelativeTime(iso, t)}
onSelectWorkspace={(cwd) => onSelectWorkspace?.(cwd)}
renderSession={(session) =>
renderSessionRow(session, { readOnly: !ws.primary })
@@ -63,10 +63,8 @@
border-radius: 4px;
}
/* Sessions render through the sidebar's shared `renderSessionRow`, whose
`.sessionRow` already carries its own left indent — so this container adds no
extra left padding, keeping per-workspace rows aligned with the single-
workspace list. */
/* Trusted sessions use the sidebar's shared row with its own left indent;
read-only rows apply the same indent below. */
.sessions {
display: flex;
flex-direction: column;
@@ -78,3 +76,27 @@
font-size: 13px;
color: var(--muted-foreground);
}
.sessionItemReadOnly {
display: flex;
align-items: center;
gap: 6px;
margin-left: 26px;
padding: 4px 8px;
border-radius: 8px;
color: var(--muted-foreground);
cursor: default;
}
.sessionName {
flex: 1;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.sessionTime {
flex: 0 0 auto;
font-size: 10px;
color: var(--muted-foreground);
}
@@ -15,6 +15,11 @@ function getWorkspaceName(cwd: string): string {
return parts.at(-1) ?? cwd;
}
function getSessionLabel(session: DaemonSessionSummary): string {
const displayName = session.displayName?.trim();
return displayName || session.sessionId.slice(0, 8);
}
function FolderIcon({ open }: { open: boolean }) {
return (
<svg
@@ -49,13 +54,14 @@ interface WorkspaceSectionProps {
reloadToken: number;
primaryLabel: string;
untrustedLabel: string;
readOnlyLabel: string;
trustToOpenLabel: string;
noSessionsLabel: string;
formatTime: (iso: string) => string;
onSelectWorkspace: (cwd: string | undefined) => void;
/**
* Render one session row. The sidebar passes its shared `renderSessionRow`
* so per-workspace sessions match the single-workspace list exactly — same
* type scale, hover actions (pin, archive, export, more…), and states —
* instead of a bespoke, feature-poor row.
* Render a trusted session row with the sidebar's shared interactions and
* styling. Untrusted rows stay non-interactive in this component.
*/
renderSession: (session: DaemonSessionSummary) => ReactNode;
}
@@ -67,12 +73,17 @@ export function WorkspaceSection({
reloadToken,
primaryLabel,
untrustedLabel,
readOnlyLabel,
trustToOpenLabel,
noSessionsLabel,
formatTime,
onSelectWorkspace,
renderSession,
}: WorkspaceSectionProps) {
const [sessions, setSessions] = useState<DaemonSessionSummary[]>([]);
const [expanded, setExpanded] = useState(workspace.primary);
const readOnly = !workspace.primary && !workspace.trusted;
const disabled = workspace.primary && !workspace.trusted;
// Sync if the primary flag changes after mount (e.g. capabilities refresh).
useEffect(() => {
@@ -80,7 +91,7 @@ export function WorkspaceSection({
}, [workspace.primary]);
const loadSessions = useCallback(async () => {
if (!workspace.trusted) return;
if (disabled) return;
try {
const result = await client.listWorkspaceSessions(workspace.cwd, {
archiveState: 'active',
@@ -92,14 +103,15 @@ export function WorkspaceSection({
console.warn('[WorkspaceSection] session poll failed:', err);
setSessions([]);
}
}, [client, workspace.cwd, workspace.trusted]);
}, [client, disabled, workspace.cwd]);
useEffect(() => {
if (!expanded) return;
void loadSessions();
if (readOnly) return;
const timer = setInterval(() => void loadSessions(), 10_000);
return () => clearInterval(timer);
}, [expanded, loadSessions, reloadToken]);
}, [expanded, loadSessions, readOnly, reloadToken]);
return (
<div className={styles.section}>
@@ -107,12 +119,14 @@ export function WorkspaceSection({
className={cx(
styles.header,
isActive && styles.headerActive,
!workspace.trusted && styles.headerDisabled,
disabled && styles.headerDisabled,
)}
disabled={!workspace.trusted}
disabled={disabled}
aria-expanded={expanded}
onClick={() => {
onSelectWorkspace(workspace.primary ? undefined : workspace.cwd);
if (!readOnly) {
onSelectWorkspace(workspace.primary ? undefined : workspace.cwd);
}
setExpanded((v) => !v);
}}
>
@@ -126,13 +140,31 @@ export function WorkspaceSection({
{!workspace.trusted && (
<span className={styles.badge}>{untrustedLabel}</span>
)}
{readOnly && <span className={styles.badge}>{readOnlyLabel}</span>}
</button>
{expanded && workspace.trusted && (
{expanded && !disabled && (
<div className={styles.sessions}>
{sessions.length === 0 ? (
<div className={styles.empty}>{noSessionsLabel}</div>
) : (
sessions.map((session) => renderSession(session))
sessions.map((session) => {
if (!readOnly) return renderSession(session);
const label = getSessionLabel(session);
const time = session.createdAt
? formatTime(session.createdAt)
: '';
return (
<div
key={session.sessionId}
className={styles.sessionItemReadOnly}
role="note"
aria-label={`${label}${time ? `, ${time}` : ''}. ${trustToOpenLabel}`}
>
<span className={styles.sessionName}>{label}</span>
{time && <span className={styles.sessionTime}>{time}</span>}
</div>
);
})
)}
</div>
)}
+4
View File
@@ -791,6 +791,8 @@ const EN: Messages = {
'sidebar.workspaceSelectLabel': 'Workspace',
'sidebar.workspacePrimary': 'primary',
'sidebar.workspaceUntrusted': 'untrusted',
'sidebar.workspaceReadOnly': 'read-only',
'sidebar.workspaceTrustToOpen': 'Trust this workspace to open the session.',
'sidebar.addWorkspace': 'Add workspace',
'sidebar.addWorkspaceTitle': 'Add Workspace',
'sidebar.addWorkspacePath': 'Directory path',
@@ -2543,6 +2545,8 @@ const ZH: Messages = {
'sidebar.workspaceSelectLabel': '工作区',
'sidebar.workspacePrimary': '主',
'sidebar.workspaceUntrusted': '未信任',
'sidebar.workspaceReadOnly': '只读',
'sidebar.workspaceTrustToOpen': '信任此工作区后才能打开会话。',
'sidebar.addWorkspace': '添加工作区',
'sidebar.addWorkspaceTitle': '添加工作区',
'sidebar.addWorkspacePath': '目录路径',