mirror of
https://github.com/QwenLM/qwen-code.git
synced 2026-11-07 06:19:43 -10:00
feat(serve): support multi-workspace rewind and shell (#6826)
* feat(serve): support multi-workspace rewind and shell Co-authored-by: Qwen-Coder <[email protected]> * codex: address PR review feedback (#6826) Co-authored-by: Qwen-Coder <[email protected]> * codex: address PR review feedback (#6826) Co-authored-by: Qwen-Coder <[email protected]> --------- Co-authored-by: Qwen-Coder <[email protected]>
This commit is contained in:
1 parent
71c7e448f0
commit
b933b90172
27 files changed
+1211
-71
No files matched your search
@@ -181,6 +181,9 @@ npm run preflight # Full check: clean → install → format → lint → build
|
||||
pass suffices for a trivial change.
|
||||
5. **Code review** — run `/review` when available. Triage each comment:
|
||||
valid / false positive / overthinking. Fixes go back through steps 3-4.
|
||||
Here, `/review` means the Codex code-review workflow, not Qwen Review or
|
||||
the `qwen-review` plugin. Do not invoke Qwen Review unless the user
|
||||
explicitly requests it by name.
|
||||
|
||||
### Feature development
|
||||
|
||||
|
||||
@@ -1,5 +1,10 @@
|
||||
# Phase 2a Multi-Workspace Sessions Foundation
|
||||
|
||||
> **Historical status:** The live-session rewind snapshots, rewind, and shell
|
||||
> limitations recorded in this document are superseded by
|
||||
> [`daemon-multi-workspace-session-file-ops.md`](./daemon-multi-workspace-session-file-ops.md).
|
||||
> The remaining Phase 2a scope statements are unchanged.
|
||||
|
||||
## Summary
|
||||
|
||||
This document records the multi-workspace sessions contract for issue #6378
|
||||
|
||||
@@ -0,0 +1,89 @@
|
||||
# Daemon Multi-Workspace Session Rewind and Shell
|
||||
|
||||
## Status
|
||||
|
||||
Final implementation design. This document supersedes the Phase 2a
|
||||
primary-only statement for live-session rewind snapshots, rewind, and shell.
|
||||
|
||||
## Problem
|
||||
|
||||
The daemon exposes singular session APIs, while a multi-workspace daemon owns
|
||||
one bridge per workspace runtime. Most live session routes already resolve the
|
||||
session owner, but rewind snapshots, rewind, and shell were still bound to the
|
||||
primary bridge or rejected a secondary owner. That made a valid live secondary
|
||||
session indistinguishable from an unsupported route to clients.
|
||||
|
||||
## Decision
|
||||
|
||||
Keep the singular REST API and resolve the owning live runtime on every request:
|
||||
|
||||
- `GET /session/:id/rewind/snapshots` uses owner-aware read routing.
|
||||
- `POST /session/:id/rewind` and `POST /session/:id/shell` use owner-aware
|
||||
mutable routing and the shared session archive coordinator.
|
||||
- SDK rewind calls always select direct REST, even when the client is configured
|
||||
with ACP transport. This preserves the strict REST mutation gate.
|
||||
- SDK shell keeps its configured transport. The default REST transport gains
|
||||
owner routing; a workspace-qualified ACP client keeps `_qwen/session/shell`.
|
||||
- No workspace-qualified session REST API, ACP rewind method, core change, ACP
|
||||
child change, or FileHistory migration is introduced.
|
||||
|
||||
## Ownership and authorization
|
||||
|
||||
The workspace registry searches all live bridge summaries for the session id.
|
||||
Exactly one trusted owner dispatches to that runtime. No owner returns
|
||||
`404 session_not_found`; an untrusted owner returns `403 untrusted_workspace`;
|
||||
multiple owners return `500 ambiguous_session_owner`. All three outcomes occur
|
||||
before the target bridge operation runs. Persisted sessions must first be loaded
|
||||
or resumed into a runtime.
|
||||
|
||||
Rewind and shell retain `mutate({ strict: true })`. Shell additionally requires
|
||||
effective shell enablement, a valid session-bound client id, and a non-empty
|
||||
command. Rewind forwards an optional client id and accepts `rewindFiles` only
|
||||
when omitted or boolean. Omitted means `true`; any other JSON type returns
|
||||
`400 invalid_rewind_files_flag`.
|
||||
|
||||
## Behavior boundaries
|
||||
|
||||
Shell starts in the owning session workspace cwd and is not a filesystem path
|
||||
sandbox. Rewind restores only snapshots recorded for `edit` and `write_file`.
|
||||
It does not undo shell, Git, script, or manual changes. File restore is
|
||||
best-effort: the conversation may already be rewound when the response reports
|
||||
`rewound: false` with `filesFailed[]`. Active prompts retain `409 session_busy`
|
||||
and `Retry-After: 5`; invalid targets retain `400 invalid_rewind_target`.
|
||||
Web Shell continues to request `rewindFiles: false`.
|
||||
|
||||
The existing `~/.qwen/file-history/<sessionId>` layout is unchanged. A live UUID
|
||||
collision therefore fails closed through owner ambiguity rather than selecting
|
||||
the primary runtime.
|
||||
|
||||
## Capabilities
|
||||
|
||||
`multi_workspace_session_rewind` is advertised only while more than one runtime
|
||||
exists. `multi_workspace_session_shell` additionally requires effective session
|
||||
shell enablement, which means both the enable flag and a configured token.
|
||||
|
||||
Client preflight is additive:
|
||||
|
||||
- Primary rewind: `session_rewind`.
|
||||
- Secondary rewind: `session_rewind` and
|
||||
`multi_workspace_session_rewind`.
|
||||
- Primary shell: `session_shell_command`.
|
||||
- Secondary shell: `session_shell_command` and
|
||||
`multi_workspace_session_shell`.
|
||||
|
||||
ACP-native clients use initialize `_qwen.methods`; the daemon does not advertise
|
||||
an ACP rewind vendor method.
|
||||
|
||||
## Verification
|
||||
|
||||
Unit coverage pins owner dispatch, zero calls to non-owning bridges, trust and
|
||||
ambiguity failures, strict validation order, `rewindFiles` semantics, SDK REST
|
||||
fallback, unchanged shell transport, conditional capability advertising, and
|
||||
the absence of ACP rewind mappings. ACP workspace tests retain the invariant
|
||||
that an A connection cannot operate a B session while a workspace-qualified B
|
||||
shell succeeds.
|
||||
|
||||
The E2E scenario creates a session and tracked edits in workspace B, verifies
|
||||
snapshots and shell cwd are B-scoped, checks both rewind file modes, proves a
|
||||
shell-created file survives rewind, and records busy, partial restore, and
|
||||
untrusted-secondary outcomes.
|
||||
@@ -20,7 +20,7 @@
|
||||
|
||||
**App factory**: `createServeApp(opts, getPort, deps)` in `packages/cli/src/serve/server.ts`. Builds the Express `Application`. Direct embedders and tests call it without the bootstrap wrapper.
|
||||
|
||||
**Capability registry**: `SERVE_CAPABILITY_REGISTRY` in `packages/cli/src/serve/capabilities.ts`. Each tag has a `since` version and optional `modes`. Ten conditional tags (`require_auth`, `mcp_workspace_pool`, `mcp_pool_restart`, `allow_origin`, `prompt_absolute_deadline`, `writer_idle_timeout`, `workspace_settings`, `session_shell_command`, `rate_limit`, `workspace_reload`) are omitted when their corresponding toggle is off. See [`11-capabilities-versioning.md`](./11-capabilities-versioning.md).
|
||||
**Capability registry**: `SERVE_CAPABILITY_REGISTRY` in `packages/cli/src/serve/capabilities.ts`. Each tag has a `since` version and optional `modes`. Conditional tags are omitted when their deployment or runtime predicate is false; the registry and predicate map are the source of truth. See [`11-capabilities-versioning.md`](./11-capabilities-versioning.md).
|
||||
|
||||
**Middleware** (`packages/cli/src/serve/auth.ts` and `server.ts`):
|
||||
|
||||
|
||||
@@ -194,7 +194,23 @@ cache path for a single-turn, no-tool LLM call and returns
|
||||
without routing through the LLM. It streams output on the session SSE bus via
|
||||
`user_shell_command` / `user_shell_result` events and injects the command plus
|
||||
result into the LLM conversation history. The response is
|
||||
`{ exitCode, output, aborted }`.
|
||||
`{ exitCode, output, aborted }`. For a live secondary-workspace session, the
|
||||
singular REST route resolves the session owner and executes on that runtime's
|
||||
bridge, so the command starts in the owning workspace cwd. The route does not
|
||||
provide a path sandbox. Workspace-qualified ACP clients may continue to use
|
||||
`_qwen/session/shell` on the owning workspace connection.
|
||||
|
||||
### Session Rewind
|
||||
|
||||
`GET /session/:id/rewind/snapshots` and `POST /session/:id/rewind` resolve the
|
||||
owning live workspace runtime. Persisted sessions must be loaded or resumed
|
||||
before rewind. Rewind truncates conversation history and optionally restores
|
||||
files tracked by `edit` and `write_file`; it does not undo shell commands, Git,
|
||||
scripts, or manual changes. File restoration is best-effort, so a response may
|
||||
report `rewound: false` and `filesFailed[]` after the conversation history has
|
||||
already moved. SDK rewind calls always use owner-aware REST, including when the
|
||||
client otherwise uses ACP transport, because the mutation must retain strict
|
||||
REST authentication.
|
||||
|
||||
### Session Detach
|
||||
|
||||
|
||||
@@ -81,6 +81,16 @@ export const CONDITIONAL_SERVE_FEATURES: ReadonlyMap<
|
||||
(t) => t.voiceTranscriptionAvailable === true,
|
||||
],
|
||||
['session_shell_command', (t) => t.sessionShellCommandEnabled === true],
|
||||
[
|
||||
'multi_workspace_session_rewind',
|
||||
(t) => t.multiWorkspaceSessionsEnabled === true,
|
||||
],
|
||||
[
|
||||
'multi_workspace_session_shell',
|
||||
(t) =>
|
||||
t.multiWorkspaceSessionsEnabled === true &&
|
||||
t.sessionShellCommandEnabled === true,
|
||||
],
|
||||
['rate_limit', (t) => t.rateLimit === true],
|
||||
['workspace_reload', (t) => t.reloadAvailable === true],
|
||||
['voice_transcribe', (t) => t.voiceWsAvailable !== false],
|
||||
@@ -94,7 +104,7 @@ The `Map` stores membership and predicate together. Adding a new conditional tag
|
||||
|
||||
Baseline tags are not present in the `Map` and are advertised unconditionally. This is intentionally represented by absence rather than by a separate Set.
|
||||
|
||||
### 75 tags (v1, grouped by domain)
|
||||
### v1 tags grouped by domain
|
||||
|
||||
Foundation: `health`, `daemon_status`, `capabilities`.
|
||||
|
||||
@@ -120,6 +130,15 @@ Voice: **`workspace_voice`** (conditional), **`workspace_voice_transcription`**
|
||||
|
||||
Rate limiting: **`rate_limit`** (conditional).
|
||||
|
||||
Multi-workspace session routing: **`multi_workspace_sessions`** (conditional),
|
||||
**`multi_workspace_session_rewind`** (conditional), and
|
||||
**`multi_workspace_session_shell`** (conditional). A client may use rewind for
|
||||
a primary session with `session_rewind`; a secondary live session additionally
|
||||
requires `multi_workspace_session_rewind`. Shell uses the equivalent
|
||||
`session_shell_command` plus `multi_workspace_session_shell` pairing for a
|
||||
secondary session. ACP-native clients continue to use the `_qwen.methods`
|
||||
returned by initialize; no ACP rewind vendor method is advertised.
|
||||
|
||||
Bold tags have `modes` or are conditional.
|
||||
|
||||
## Flow
|
||||
@@ -167,23 +186,26 @@ sequenceDiagram
|
||||
## Dependencies
|
||||
|
||||
- Read by `packages/cli/src/serve/server.ts` when building `/capabilities` responses.
|
||||
- Toggle input comes from `runQwenServe` / `createServeApp`: `{ requireAuth, mcpPoolActive, allowOriginActive, promptDeadlineMs, writerIdleTimeoutMs, persistSettingAvailable, sessionShellCommandEnabled, rateLimit, reloadAvailable }`.
|
||||
- Toggle input comes from `runQwenServe` / `createServeApp`, including
|
||||
authentication, MCP, origin, prompt, settings, shell, rate-limit, reload, and
|
||||
live workspace-runtime-count state.
|
||||
- The active `permission` policy in the envelope comes from `BridgeOptions.permissionPolicy`, which itself reads `settings.json` `policy.permissionStrategy`.
|
||||
|
||||
## Configuration
|
||||
|
||||
| Source | Knob | Effect on capabilities |
|
||||
| -------------------------- | --------------------------------------------------------------- | ----------------------------------------------------------------------------------------------------------------------------- |
|
||||
| CLI flag | `--require-auth` | Advertises `require_auth`. |
|
||||
| Env | `QWEN_SERVE_NO_MCP_POOL=1` | Stops advertising `mcp_workspace_pool` and `mcp_pool_restart`; MCP events no longer stamp `scope: 'workspace'`. |
|
||||
| CLI flag | `--mcp-client-budget=N`, `--mcp-budget-mode={off,warn,enforce}` | Does not change the tag set (`mcp_guardrails` is always advertised), but changes per-server reservation and refusal behavior. |
|
||||
| CLI flag / env | `--rate-limit` / `QWEN_SERVE_RATE_LIMIT=1` | Advertises `rate_limit`. |
|
||||
| Embedded option | `persistSettingAvailable` | Advertises `workspace_settings` and `workspace_voice`. |
|
||||
| Embedded option | `voiceTranscriptionAvailable` | Advertises `workspace_voice_transcription`. |
|
||||
| CLI flag / embedded option | `--enable-session-shell` / `sessionShellCommandEnabled` | Advertises `session_shell_command`. |
|
||||
| Embedded option | `reloadAvailable` | Advertises `workspace_reload`. |
|
||||
| Embedded option | `voiceWsAvailable` | Advertises `voice_transcribe`. |
|
||||
| `settings.json` | `policy.permissionStrategy` | Sets envelope `policy.permission`. |
|
||||
| Source | Knob | Effect on capabilities |
|
||||
| -------------------------- | --------------------------------------------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
||||
| CLI flag | `--require-auth` | Advertises `require_auth`. |
|
||||
| Env | `QWEN_SERVE_NO_MCP_POOL=1` | Stops advertising `mcp_workspace_pool` and `mcp_pool_restart`; MCP events no longer stamp `scope: 'workspace'`. |
|
||||
| CLI flag | `--mcp-client-budget=N`, `--mcp-budget-mode={off,warn,enforce}` | Does not change the tag set (`mcp_guardrails` is always advertised), but changes per-server reservation and refusal behavior. |
|
||||
| CLI flag / env | `--rate-limit` / `QWEN_SERVE_RATE_LIMIT=1` | Advertises `rate_limit`. |
|
||||
| Embedded option | `persistSettingAvailable` | Advertises `workspace_settings` and `workspace_voice`. |
|
||||
| Embedded option | `voiceTranscriptionAvailable` | Advertises `workspace_voice_transcription`. |
|
||||
| CLI flag / embedded option | `--enable-session-shell` / `sessionShellCommandEnabled` | Advertises `session_shell_command`. |
|
||||
| Runtime state | More than one registered workspace runtime | Advertises `multi_workspace_sessions` and `multi_workspace_session_rewind`; also advertises `multi_workspace_session_shell` when session shell is effectively enabled. |
|
||||
| Embedded option | `reloadAvailable` | Advertises `workspace_reload`. |
|
||||
| Embedded option | `voiceWsAvailable` | Advertises `voice_transcribe`. |
|
||||
| `settings.json` | `policy.permissionStrategy` | Sets envelope `policy.permission`. |
|
||||
|
||||
## Caveats and known limits
|
||||
|
||||
|
||||
@@ -138,7 +138,15 @@ The `code: 'token_required'` shape is distinct from `bearerAuth`'s plain `Unauth
|
||||
`/workspace/tools/:name/enable`, `/workspace/mcp/:server/restart`,
|
||||
`/workspace/mcp/:server/{enable,disable,authenticate,clear-auth}`,
|
||||
`/workspace/mcp/servers` (POST/DELETE), `/workspace/auth/device-flow`,
|
||||
`/workspace/init`, `/session/:id/approval-mode`.
|
||||
`/workspace/init`, `/session/:id/approval-mode`, `/session/:id/rewind`, and
|
||||
`/session/:id/shell`.
|
||||
|
||||
Rewind remains REST-only in the TypeScript SDK even when an ACP transport is
|
||||
configured. This preserves the strict mutation gate and bearer/client identity
|
||||
headers; the ACP route table intentionally has no rewind mapping. Owner routing
|
||||
also rechecks workspace trust before either rewind or shell reaches a secondary
|
||||
runtime bridge. Duplicate live session ids fail closed as
|
||||
`ambiguous_session_owner` instead of falling back to the primary runtime.
|
||||
|
||||
### `/health` exemption
|
||||
|
||||
|
||||
@@ -187,7 +187,8 @@ 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', 'persistent_workspace_registration',
|
||||
'multi_workspace_sessions', 'multi_workspace_session_rewind',
|
||||
'multi_workspace_session_shell', 'persistent_workspace_registration',
|
||||
'workspace_qualified_rest_core', 'workspace_persisted_transcript',
|
||||
'client_mcp_over_ws', 'cdp_tunnel_over_ws', 'browser_automation_mcp']
|
||||
```
|
||||
@@ -256,6 +257,8 @@ operator diagnostic snapshot documented below.
|
||||
| `writer_idle_timeout` | `--writer-idle-timeout-ms` / `QWEN_SERVE_WRITER_IDLE_TIMEOUT_MS` / `ServeOptions.writerIdleTimeoutMs` is set to a positive integer. |
|
||||
| `workspace_settings` | the daemon was created with settings persistence available. |
|
||||
| `session_shell_command` | session shell execution is explicitly enabled. |
|
||||
| `multi_workspace_session_rewind` | more than one workspace runtime is registered; singular live-session rewind routes resolve the owning runtime. |
|
||||
| `multi_workspace_session_shell` | more than one workspace runtime is registered and session shell execution is explicitly enabled; singular REST shell resolves the owning runtime. |
|
||||
| `rate_limit` | `--rate-limit` / `QWEN_SERVE_RATE_LIMIT=1` / `ServeOptions.rateLimit` is enabled. |
|
||||
| `workspace_reload` | workspace reload support is available in the embedded route configuration. |
|
||||
| `persistent_workspace_registration` | a workspace registration store is wired into the daemon. Production `runQwenServe` supplies the user-level store automatically; direct `createServeApp` embeds must inject one explicitly and own startup restoration of their workspace registry. |
|
||||
|
||||
@@ -1169,6 +1169,9 @@ describe('ACP Streamable HTTP transport (over the wire)', () => {
|
||||
expect(result.agentCapabilities._meta.qwen.methods).toContain(
|
||||
'_qwen/session/shell',
|
||||
);
|
||||
expect(result.agentCapabilities._meta.qwen.methods).not.toContain(
|
||||
'_qwen/session/rewind',
|
||||
);
|
||||
});
|
||||
|
||||
it('initialize advertises _qwen/session/lsp', async () => {
|
||||
|
||||
@@ -38,7 +38,20 @@ const PARENT_ENV: WorkspaceRuntimeEnvMetadata = {
|
||||
|
||||
function makeBridge(): HttpAcpBridge {
|
||||
return {
|
||||
spawnOrAttach: vi.fn(async (req: { workspaceCwd: string }) => ({
|
||||
sessionId:
|
||||
req.workspaceCwd === '/ws-b' ? 'secondary-session' : 'primary-session',
|
||||
workspaceCwd: req.workspaceCwd,
|
||||
attached: false,
|
||||
clientId:
|
||||
req.workspaceCwd === '/ws-b' ? 'secondary-client' : 'primary-client',
|
||||
})),
|
||||
detachClient: vi.fn(async () => {}),
|
||||
executeShellCommand: vi.fn(async () => ({
|
||||
exitCode: 0,
|
||||
output: 'ok',
|
||||
aborted: false,
|
||||
})),
|
||||
isWorkspaceMemoryRememberAvailable: vi.fn(async () => true),
|
||||
runWorkspaceMemoryRemember: vi.fn(async () => ({
|
||||
filesTouched: [],
|
||||
@@ -172,6 +185,7 @@ describe('workspace-qualified ACP (/workspaces/:workspace/acp)', () => {
|
||||
cdpTunnelOverWs: true,
|
||||
cdpTunnelRegistry: cdpRegistry,
|
||||
checkRate,
|
||||
sessionShellCommandEnabled: true,
|
||||
workspaceRememberLane: new WorkspaceRememberTaskLane(primaryBridge),
|
||||
});
|
||||
|
||||
@@ -251,6 +265,44 @@ describe('workspace-qualified ACP (/workspaces/:workspace/acp)', () => {
|
||||
});
|
||||
}
|
||||
|
||||
async function sendWsRequests(
|
||||
pathname: string,
|
||||
requests: Array<Record<string, unknown>>,
|
||||
): Promise<Array<Record<string, unknown>>> {
|
||||
return new Promise((resolve, reject) => {
|
||||
const responses: Array<Record<string, unknown>> = [];
|
||||
const ws = new WebSocket(`ws://127.0.0.1:${port}${pathname}`, {
|
||||
handshakeTimeout: 2000,
|
||||
});
|
||||
ws.on('open', () => ws.send(INITIALIZE));
|
||||
ws.on('message', (data: WebSocket.RawData) => {
|
||||
try {
|
||||
const message = JSON.parse(data.toString()) as Record<
|
||||
string,
|
||||
unknown
|
||||
>;
|
||||
if (message['id'] === 1) {
|
||||
ws.send(JSON.stringify(requests[0]));
|
||||
return;
|
||||
}
|
||||
const request = requests[responses.length];
|
||||
if (message['id'] !== request?.['id']) return;
|
||||
responses.push(message);
|
||||
if (responses.length === requests.length) {
|
||||
ws.close();
|
||||
resolve(responses);
|
||||
return;
|
||||
}
|
||||
ws.send(JSON.stringify(requests[responses.length]));
|
||||
} catch (err) {
|
||||
ws.terminate();
|
||||
reject(err as Error);
|
||||
}
|
||||
});
|
||||
ws.on('error', reject);
|
||||
});
|
||||
}
|
||||
|
||||
async function initializeWs(pathname: string): Promise<{
|
||||
result?: {
|
||||
protocolVersion?: number;
|
||||
@@ -369,6 +421,43 @@ describe('workspace-qualified ACP (/workspaces/:workspace/acp)', () => {
|
||||
expect(primary.status).toBe(404);
|
||||
});
|
||||
|
||||
it('keeps ACP shell scoped to the workspace-qualified connection', async () => {
|
||||
const secondary = await sendWsRequests('/workspaces/secondary-id/acp', [
|
||||
{
|
||||
jsonrpc: '2.0',
|
||||
id: 2,
|
||||
method: 'session/new',
|
||||
params: { workspaceCwd: '/ws-b' },
|
||||
},
|
||||
{
|
||||
jsonrpc: '2.0',
|
||||
id: 3,
|
||||
method: '_qwen/session/shell',
|
||||
params: { sessionId: 'secondary-session', command: 'pwd' },
|
||||
},
|
||||
]);
|
||||
expect(secondary[1]?.['result']).toMatchObject({
|
||||
exitCode: 0,
|
||||
output: 'ok',
|
||||
});
|
||||
expect(secondaryBridge.executeShellCommand).toHaveBeenCalledWith(
|
||||
'secondary-session',
|
||||
'pwd',
|
||||
expect.any(AbortSignal),
|
||||
expect.objectContaining({ clientId: 'secondary-client' }),
|
||||
);
|
||||
expect(primaryBridge.executeShellCommand).not.toHaveBeenCalled();
|
||||
|
||||
const primary = await sendWsRequest('/acp', {
|
||||
jsonrpc: '2.0',
|
||||
id: 4,
|
||||
method: '_qwen/session/shell',
|
||||
params: { sessionId: 'secondary-session', command: 'pwd' },
|
||||
});
|
||||
expect(primary['error']).toMatchObject({ code: -32602 });
|
||||
expect(primaryBridge.executeShellCommand).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('rejects a body workspaceCwd that differs from the selected mount', async () => {
|
||||
const response = await sendWsRequest('/workspaces/secondary-id/acp', {
|
||||
jsonrpc: '2.0',
|
||||
|
||||
@@ -273,6 +273,10 @@ 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' },
|
||||
// Singular session rewind routes resolve the owning live workspace runtime.
|
||||
multi_workspace_session_rewind: { since: 'v1' },
|
||||
// Singular session shell routes resolve the owning live workspace runtime.
|
||||
multi_workspace_session_shell: { since: 'v1' },
|
||||
persistent_workspace_registration: { since: 'v1' },
|
||||
// Workspace-qualified core REST routes under `/workspaces/:workspace/...`.
|
||||
// Covers core file/status/permissions/trust/lifecycle/MCP/tool, memory,
|
||||
@@ -450,6 +454,16 @@ export const CONDITIONAL_SERVE_FEATURES: ReadonlyMap<
|
||||
'multi_workspace_sessions',
|
||||
(toggles) => toggles.multiWorkspaceSessionsEnabled === true,
|
||||
],
|
||||
[
|
||||
'multi_workspace_session_rewind',
|
||||
(toggles) => toggles.multiWorkspaceSessionsEnabled === true,
|
||||
],
|
||||
[
|
||||
'multi_workspace_session_shell',
|
||||
(toggles) =>
|
||||
toggles.multiWorkspaceSessionsEnabled === true &&
|
||||
toggles.sessionShellCommandEnabled === true,
|
||||
],
|
||||
[
|
||||
'persistent_workspace_registration',
|
||||
(toggles) => toggles.persistentWorkspaceRegistrationAvailable === true,
|
||||
|
||||
@@ -16,6 +16,8 @@ import {
|
||||
setDebugLogSession,
|
||||
} from '@qwen-code/qwen-code-core';
|
||||
import {
|
||||
InvalidRewindTargetError,
|
||||
SessionBusyError,
|
||||
SessionNotFoundError,
|
||||
type AcpSessionBridge,
|
||||
type BridgeClientRequestContext,
|
||||
@@ -117,6 +119,18 @@ interface FakeBridge extends AcpSessionBridge {
|
||||
taskKind: 'agent' | 'shell' | 'monitor';
|
||||
}>;
|
||||
readonly goalClearCalls: string[];
|
||||
readonly rewindSnapshotCalls: string[];
|
||||
readonly rewindCalls: Array<{
|
||||
sessionId: string;
|
||||
req: Parameters<AcpSessionBridge['rewindSession']>[1];
|
||||
context?: BridgeClientRequestContext;
|
||||
}>;
|
||||
readonly shellCalls: Array<{
|
||||
sessionId: string;
|
||||
command: string;
|
||||
signal?: AbortSignal;
|
||||
context?: BridgeClientRequestContext;
|
||||
}>;
|
||||
}
|
||||
|
||||
function makeSummary(
|
||||
@@ -212,7 +226,11 @@ async function withRuntimeDir<T>(fn: () => Promise<T>): Promise<T> {
|
||||
function makeBridge(
|
||||
workspaceCwd: string,
|
||||
summaries: BridgeSessionSummary[] = [],
|
||||
options: { channelLive?: boolean } = {},
|
||||
options: {
|
||||
channelLive?: boolean;
|
||||
rewindImpl?: AcpSessionBridge['rewindSession'];
|
||||
shellImpl?: AcpSessionBridge['executeShellCommand'];
|
||||
} = {},
|
||||
): FakeBridge {
|
||||
const live = new Map(
|
||||
summaries.map((summary) => [summary.sessionId, summary]),
|
||||
@@ -238,6 +256,9 @@ function makeBridge(
|
||||
const midTurnMessageCalls: FakeBridge['midTurnMessageCalls'] = [];
|
||||
const taskCancelCalls: FakeBridge['taskCancelCalls'] = [];
|
||||
const goalClearCalls: string[] = [];
|
||||
const rewindSnapshotCalls: string[] = [];
|
||||
const rewindCalls: FakeBridge['rewindCalls'] = [];
|
||||
const shellCalls: FakeBridge['shellCalls'] = [];
|
||||
const bridge = {
|
||||
permissionPolicy: 'first-responder' as const,
|
||||
spawnCalls,
|
||||
@@ -261,6 +282,9 @@ function makeBridge(
|
||||
midTurnMessageCalls,
|
||||
taskCancelCalls,
|
||||
goalClearCalls,
|
||||
rewindSnapshotCalls,
|
||||
rewindCalls,
|
||||
shellCalls,
|
||||
get sessionCount() {
|
||||
return live.size;
|
||||
},
|
||||
@@ -445,6 +469,60 @@ function makeBridge(
|
||||
condition: workspaceCwd,
|
||||
};
|
||||
},
|
||||
async getRewindSnapshots(sessionId: string) {
|
||||
rewindSnapshotCalls.push(sessionId);
|
||||
return {
|
||||
snapshots: [
|
||||
{
|
||||
promptId: `${workspaceCwd}-prompt`,
|
||||
turnIndex: 0,
|
||||
timestamp: '2026-07-08T00:00:00.000Z',
|
||||
diffStats: {
|
||||
filesChanged: 1,
|
||||
insertions: 1,
|
||||
deletions: 0,
|
||||
},
|
||||
},
|
||||
],
|
||||
};
|
||||
},
|
||||
async rewindSession(
|
||||
sessionId: string,
|
||||
req: Parameters<AcpSessionBridge['rewindSession']>[1],
|
||||
context?: BridgeClientRequestContext,
|
||||
) {
|
||||
rewindCalls.push({
|
||||
sessionId,
|
||||
req,
|
||||
...(context ? { context } : {}),
|
||||
});
|
||||
if (options.rewindImpl) {
|
||||
return options.rewindImpl(sessionId, req, context);
|
||||
}
|
||||
return {
|
||||
rewound: true,
|
||||
targetTurnIndex: 0,
|
||||
filesChanged: req.rewindFiles === false ? [] : ['tracked.txt'],
|
||||
filesFailed: [],
|
||||
};
|
||||
},
|
||||
async executeShellCommand(
|
||||
sessionId: string,
|
||||
command: string,
|
||||
signal?: AbortSignal,
|
||||
context?: BridgeClientRequestContext,
|
||||
) {
|
||||
shellCalls.push({
|
||||
sessionId,
|
||||
command,
|
||||
...(signal ? { signal } : {}),
|
||||
...(context ? { context } : {}),
|
||||
});
|
||||
if (options.shellImpl) {
|
||||
return options.shellImpl(sessionId, command, signal, context);
|
||||
}
|
||||
return { exitCode: 0, output: workspaceCwd, aborted: false };
|
||||
},
|
||||
async cancelSession(sessionId: string) {
|
||||
cancelCalls.push(sessionId);
|
||||
},
|
||||
@@ -542,12 +620,16 @@ function makeHarness(opts?: {
|
||||
secondaryTrusted?: boolean;
|
||||
secondaryChannelLive?: boolean;
|
||||
daemonLog?: DaemonLogger;
|
||||
primarySummaries?: BridgeSessionSummary[];
|
||||
secondarySummaries?: BridgeSessionSummary[];
|
||||
token?: string;
|
||||
secondaryRewindImpl?: AcpSessionBridge['rewindSession'];
|
||||
secondaryShellImpl?: AcpSessionBridge['executeShellCommand'];
|
||||
serveOptions?: Partial<ServeOptions>;
|
||||
}) {
|
||||
const primaryBridge = makeBridge(
|
||||
PRIMARY_CWD,
|
||||
[makeSummary('primary-session', PRIMARY_CWD)],
|
||||
opts?.primarySummaries ?? [makeSummary('primary-session', PRIMARY_CWD)],
|
||||
{ channelLive: true },
|
||||
);
|
||||
const secondaryBridge = makeBridge(
|
||||
@@ -555,7 +637,15 @@ function makeHarness(opts?: {
|
||||
opts?.secondarySummaries ?? [
|
||||
makeSummary('secondary-session', SECONDARY_CWD),
|
||||
],
|
||||
{ channelLive: opts?.secondaryChannelLive ?? true },
|
||||
{
|
||||
channelLive: opts?.secondaryChannelLive ?? true,
|
||||
...(opts?.secondaryRewindImpl
|
||||
? { rewindImpl: opts.secondaryRewindImpl }
|
||||
: {}),
|
||||
...(opts?.secondaryShellImpl
|
||||
? { shellImpl: opts.secondaryShellImpl }
|
||||
: {}),
|
||||
},
|
||||
);
|
||||
const registry = createWorkspaceRegistry([
|
||||
makeRuntime({
|
||||
@@ -578,6 +668,7 @@ function makeHarness(opts?: {
|
||||
...baseOpts,
|
||||
workspace: PRIMARY_CWD,
|
||||
...(opts?.token !== undefined ? { token: opts.token } : {}),
|
||||
...opts?.serveOptions,
|
||||
},
|
||||
undefined,
|
||||
{
|
||||
@@ -600,6 +691,8 @@ describe('multi-workspace session dispatch', () => {
|
||||
expect(res.status).toBe(200);
|
||||
expect(res.body.workspaceCwd).toBe(PRIMARY_CWD);
|
||||
expect(res.body.features).toContain('multi_workspace_sessions');
|
||||
expect(res.body.features).toContain('multi_workspace_session_rewind');
|
||||
expect(res.body.features).not.toContain('multi_workspace_session_shell');
|
||||
expect(res.body.features).toContain('workspace_persisted_transcript');
|
||||
expect(res.body.workspaces).toEqual([
|
||||
{ id: 'primary-id', cwd: PRIMARY_CWD, primary: true, trusted: true },
|
||||
@@ -614,6 +707,20 @@ describe('multi-workspace session dispatch', () => {
|
||||
expect(res.body.limits.maxTotalSessions).toBeNull();
|
||||
});
|
||||
|
||||
it('advertises multi-workspace shell only when effective session shell is enabled', async () => {
|
||||
const { app } = makeHarness({
|
||||
serveOptions: { token: 'secret', enableSessionShell: true },
|
||||
});
|
||||
const res = await request(app)
|
||||
.get('/capabilities')
|
||||
.set('Host', host())
|
||||
.set('Authorization', 'Bearer secret');
|
||||
|
||||
expect(res.status).toBe(200);
|
||||
expect(res.body.features).toContain('session_shell_command');
|
||||
expect(res.body.features).toContain('multi_workspace_session_shell');
|
||||
});
|
||||
|
||||
it('aggregates daemon status session count and exposes workspace metadata', async () => {
|
||||
const { app } = makeHarness();
|
||||
const res = await request(app).get('/daemon/status').set('Host', host());
|
||||
@@ -788,6 +895,389 @@ describe('multi-workspace session dispatch', () => {
|
||||
expect(secondaryBridge.detachCalls).toEqual(['secondary-session']);
|
||||
});
|
||||
|
||||
it('routes secondary rewind snapshots, rewind, and shell only to the owner bridge', async () => {
|
||||
const daemonLog = makeDaemonLog();
|
||||
const { app, primaryBridge, secondaryBridge } = makeHarness({
|
||||
daemonLog,
|
||||
serveOptions: { token: 'secret', enableSessionShell: true },
|
||||
});
|
||||
const auth = (test: request.Test) =>
|
||||
test.set('Host', host()).set('Authorization', 'Bearer secret');
|
||||
|
||||
const snapshots = await auth(
|
||||
request(app).get('/session/secondary-session/rewind/snapshots'),
|
||||
);
|
||||
expect(snapshots.status).toBe(200);
|
||||
expect(snapshots.body.snapshots[0].promptId).toBe(
|
||||
`${SECONDARY_CWD}-prompt`,
|
||||
);
|
||||
|
||||
const rewind = await auth(
|
||||
request(app).post('/session/secondary-session/rewind'),
|
||||
)
|
||||
.set('X-Qwen-Client-Id', 'client-2')
|
||||
.send({ promptId: 'secondary-prompt', rewindFiles: true });
|
||||
expect(rewind.status).toBe(200);
|
||||
expect(rewind.body.filesChanged).toEqual(['tracked.txt']);
|
||||
|
||||
const shell = await auth(
|
||||
request(app).post('/session/secondary-session/shell'),
|
||||
)
|
||||
.set('X-Qwen-Client-Id', 'client-2')
|
||||
.send({ command: ' pwd ' });
|
||||
expect(shell.status).toBe(200);
|
||||
expect(shell.body.output).toBe(SECONDARY_CWD);
|
||||
|
||||
expect(primaryBridge.rewindSnapshotCalls).toEqual([]);
|
||||
expect(primaryBridge.rewindCalls).toEqual([]);
|
||||
expect(primaryBridge.shellCalls).toEqual([]);
|
||||
expect(secondaryBridge.rewindSnapshotCalls).toEqual(['secondary-session']);
|
||||
expect(secondaryBridge.rewindCalls).toEqual([
|
||||
{
|
||||
sessionId: 'secondary-session',
|
||||
req: { promptId: 'secondary-prompt', rewindFiles: true },
|
||||
context: { clientId: 'client-2' },
|
||||
},
|
||||
]);
|
||||
expect(secondaryBridge.shellCalls).toEqual([
|
||||
{
|
||||
sessionId: 'secondary-session',
|
||||
command: 'pwd',
|
||||
signal: expect.any(AbortSignal),
|
||||
context: { clientId: 'client-2' },
|
||||
},
|
||||
]);
|
||||
expect(daemonLog.info).toHaveBeenCalledWith('rewind snapshots loaded', {
|
||||
sessionId: 'secondary-session',
|
||||
snapshotCount: 1,
|
||||
workspaceId: 'secondary-id',
|
||||
workspaceCwd: SECONDARY_CWD,
|
||||
});
|
||||
expect(daemonLog.info).toHaveBeenCalledWith('session rewind completed', {
|
||||
sessionId: 'secondary-session',
|
||||
promptId: 'secondary-prompt',
|
||||
rewindFiles: true,
|
||||
rewound: true,
|
||||
filesChangedCount: 1,
|
||||
filesFailedCount: 0,
|
||||
workspaceId: 'secondary-id',
|
||||
workspaceCwd: SECONDARY_CWD,
|
||||
});
|
||||
expect(daemonLog.info).toHaveBeenCalledWith('shell command completed', {
|
||||
sessionId: 'secondary-session',
|
||||
clientId: 'client-2',
|
||||
exitCode: 0,
|
||||
workspaceId: 'secondary-id',
|
||||
workspaceCwd: SECONDARY_CWD,
|
||||
});
|
||||
});
|
||||
|
||||
it('preserves rewindFiles defaults and rejects non-boolean values', async () => {
|
||||
const { app, primaryBridge, secondaryBridge } = makeHarness({
|
||||
serveOptions: { token: 'secret' },
|
||||
});
|
||||
const rewind = (body: Record<string, unknown>) =>
|
||||
request(app)
|
||||
.post('/session/secondary-session/rewind')
|
||||
.set('Host', host())
|
||||
.set('Authorization', 'Bearer secret')
|
||||
.send(body);
|
||||
|
||||
for (const [body, expected] of [
|
||||
[{ promptId: 'omitted' }, true],
|
||||
[{ promptId: 'true', rewindFiles: true }, true],
|
||||
[{ promptId: 'false', rewindFiles: false }, false],
|
||||
] as const) {
|
||||
await rewind(body).expect(200);
|
||||
expect(secondaryBridge.rewindCalls.at(-1)?.req.rewindFiles).toBe(
|
||||
expected,
|
||||
);
|
||||
}
|
||||
|
||||
for (const rewindFiles of ['false', 0, null]) {
|
||||
const response = await rewind({ promptId: 'invalid', rewindFiles });
|
||||
expect(response.status).toBe(400);
|
||||
expect(response.body.code).toBe('invalid_rewind_files_flag');
|
||||
}
|
||||
|
||||
for (const body of [{}, { promptId: '' }]) {
|
||||
const response = await rewind(body);
|
||||
expect(response.status).toBe(400);
|
||||
expect(response.body.code).toBe('missing_prompt_id');
|
||||
}
|
||||
|
||||
expect(primaryBridge.rewindCalls).toEqual([]);
|
||||
expect(secondaryBridge.rewindCalls).toHaveLength(3);
|
||||
});
|
||||
|
||||
it('fails closed for unknown, untrusted, and ambiguous rewind owners', async () => {
|
||||
const unknown = makeHarness();
|
||||
const unknownRes = await request(unknown.app)
|
||||
.get('/session/missing/rewind/snapshots')
|
||||
.set('Host', host());
|
||||
expect(unknownRes.status).toBe(404);
|
||||
expect(unknownRes.body.code).toBe('session_not_found');
|
||||
|
||||
const untrusted = makeHarness({ secondaryTrusted: false });
|
||||
const untrustedRes = await request(untrusted.app)
|
||||
.get('/session/secondary-session/rewind/snapshots')
|
||||
.set('Host', host());
|
||||
expect(untrustedRes.status).toBe(403);
|
||||
expect(untrustedRes.body.code).toBe('untrusted_workspace');
|
||||
expect(untrusted.secondaryBridge.rewindSnapshotCalls).toEqual([]);
|
||||
|
||||
const duplicate = makeSummary('duplicate-session', PRIMARY_CWD);
|
||||
const ambiguous = makeHarness({
|
||||
primarySummaries: [duplicate],
|
||||
secondarySummaries: [makeSummary('duplicate-session', SECONDARY_CWD)],
|
||||
});
|
||||
const ambiguousRes = await request(ambiguous.app)
|
||||
.get('/session/duplicate-session/rewind/snapshots')
|
||||
.set('Host', host());
|
||||
expect(ambiguousRes.status).toBe(500);
|
||||
expect(ambiguousRes.body.code).toBe('ambiguous_session_owner');
|
||||
expect(ambiguous.primaryBridge.rewindSnapshotCalls).toEqual([]);
|
||||
expect(ambiguous.secondaryBridge.rewindSnapshotCalls).toEqual([]);
|
||||
});
|
||||
|
||||
it('rejects untrusted secondary rewind and shell before bridge execution', async () => {
|
||||
const { app, secondaryBridge } = makeHarness({
|
||||
secondaryTrusted: false,
|
||||
serveOptions: { token: 'secret', enableSessionShell: true },
|
||||
});
|
||||
const auth = (test: request.Test) =>
|
||||
test.set('Host', host()).set('Authorization', 'Bearer secret');
|
||||
|
||||
const rewind = await auth(
|
||||
request(app).post('/session/secondary-session/rewind'),
|
||||
).send({ promptId: 'secondary-prompt' });
|
||||
expect(rewind.status).toBe(403);
|
||||
expect(rewind.body.code).toBe('untrusted_workspace');
|
||||
|
||||
const shell = await auth(
|
||||
request(app).post('/session/secondary-session/shell'),
|
||||
)
|
||||
.set('X-Qwen-Client-Id', 'client-2')
|
||||
.send({ command: 'pwd' });
|
||||
expect(shell.status).toBe(403);
|
||||
expect(shell.body.code).toBe('untrusted_workspace');
|
||||
expect(secondaryBridge.rewindCalls).toEqual([]);
|
||||
expect(secondaryBridge.shellCalls).toEqual([]);
|
||||
});
|
||||
|
||||
it('aborts only the owning secondary shell when the HTTP client disconnects', async () => {
|
||||
let markStarted: (() => void) | undefined;
|
||||
const started = new Promise<void>((resolve) => {
|
||||
markStarted = resolve;
|
||||
});
|
||||
let markAborted: (() => void) | undefined;
|
||||
const aborted = new Promise<void>((resolve) => {
|
||||
markAborted = resolve;
|
||||
});
|
||||
let secondarySignal: AbortSignal | undefined;
|
||||
const { app, primaryBridge, secondaryBridge } = makeHarness({
|
||||
serveOptions: { token: 'secret', enableSessionShell: true },
|
||||
secondaryShellImpl: async (_sessionId, _command, signal) => {
|
||||
secondarySignal = signal;
|
||||
markStarted?.();
|
||||
await new Promise<void>((resolve) => {
|
||||
signal?.addEventListener(
|
||||
'abort',
|
||||
() => {
|
||||
markAborted?.();
|
||||
resolve();
|
||||
},
|
||||
{ once: true },
|
||||
);
|
||||
});
|
||||
return { exitCode: null, output: '', aborted: true };
|
||||
},
|
||||
});
|
||||
const pending = request(app)
|
||||
.post('/session/secondary-session/shell')
|
||||
.set('Host', host())
|
||||
.set('Authorization', 'Bearer secret')
|
||||
.set('X-Qwen-Client-Id', 'client-2')
|
||||
.send({ command: 'sleep 10' });
|
||||
const response = pending.then(
|
||||
() => undefined,
|
||||
() => undefined,
|
||||
);
|
||||
|
||||
await started;
|
||||
pending.abort();
|
||||
await Promise.all([response, aborted]);
|
||||
|
||||
expect(secondarySignal?.aborted).toBe(true);
|
||||
expect(primaryBridge.shellCalls).toEqual([]);
|
||||
expect(secondaryBridge.shellCalls).toHaveLength(1);
|
||||
});
|
||||
|
||||
it('preserves strict shell validation order for a secondary owner', async () => {
|
||||
const disabled = makeHarness({ serveOptions: { token: 'secret' } });
|
||||
const disabledResponse = await request(disabled.app)
|
||||
.post('/session/secondary-session/shell')
|
||||
.set('Host', host())
|
||||
.set('Authorization', 'Bearer secret')
|
||||
.send({ command: '' });
|
||||
expect(disabledResponse.status).toBe(403);
|
||||
expect(disabledResponse.body.code).toBe('session_shell_disabled');
|
||||
|
||||
const enabled = makeHarness({
|
||||
serveOptions: { token: 'secret', enableSessionShell: true },
|
||||
});
|
||||
const tokenRequired = await request(enabled.app)
|
||||
.post('/session/secondary-session/shell')
|
||||
.set('Host', host())
|
||||
.send({ command: 'pwd' });
|
||||
expect(tokenRequired.status).toBe(401);
|
||||
expect(tokenRequired.body.error).toBe('Unauthorized');
|
||||
|
||||
const clientRequired = await request(enabled.app)
|
||||
.post('/session/secondary-session/shell')
|
||||
.set('Host', host())
|
||||
.set('Authorization', 'Bearer secret')
|
||||
.send({ command: '' });
|
||||
expect(clientRequired.status).toBe(403);
|
||||
expect(clientRequired.body.code).toBe('client_id_required');
|
||||
|
||||
const emptyCommand = await request(enabled.app)
|
||||
.post('/session/secondary-session/shell')
|
||||
.set('Host', host())
|
||||
.set('Authorization', 'Bearer secret')
|
||||
.set('X-Qwen-Client-Id', 'client-2')
|
||||
.send({ command: ' ' });
|
||||
expect(emptyCommand.status).toBe(400);
|
||||
|
||||
expect(disabled.secondaryBridge.shellCalls).toEqual([]);
|
||||
expect(enabled.primaryBridge.shellCalls).toEqual([]);
|
||||
expect(enabled.secondaryBridge.shellCalls).toEqual([]);
|
||||
});
|
||||
|
||||
it('keeps primary rewind and shell behavior when multiple runtimes exist', async () => {
|
||||
const { app, primaryBridge, secondaryBridge } = makeHarness({
|
||||
serveOptions: { token: 'secret', enableSessionShell: true },
|
||||
});
|
||||
|
||||
await request(app)
|
||||
.get('/session/primary-session/rewind/snapshots')
|
||||
.set('Host', host())
|
||||
.set('Authorization', 'Bearer secret')
|
||||
.expect(200);
|
||||
await request(app)
|
||||
.post('/session/primary-session/rewind')
|
||||
.set('Host', host())
|
||||
.set('Authorization', 'Bearer secret')
|
||||
.send({ promptId: 'primary-prompt', rewindFiles: false })
|
||||
.expect(200);
|
||||
await request(app)
|
||||
.post('/session/primary-session/shell')
|
||||
.set('Host', host())
|
||||
.set('Authorization', 'Bearer secret')
|
||||
.set('X-Qwen-Client-Id', 'client-1')
|
||||
.send({ command: 'pwd' })
|
||||
.expect(200);
|
||||
|
||||
expect(primaryBridge.rewindSnapshotCalls).toEqual(['primary-session']);
|
||||
expect(primaryBridge.rewindCalls).toHaveLength(1);
|
||||
expect(primaryBridge.shellCalls).toHaveLength(1);
|
||||
expect(secondaryBridge.rewindSnapshotCalls).toEqual([]);
|
||||
expect(secondaryBridge.rewindCalls).toEqual([]);
|
||||
expect(secondaryBridge.shellCalls).toEqual([]);
|
||||
});
|
||||
|
||||
it('keeps rewind and shell behavior in a single-workspace daemon', async () => {
|
||||
const bridge = makeBridge(PRIMARY_CWD, [
|
||||
makeSummary('primary-session', PRIMARY_CWD),
|
||||
]);
|
||||
const app = createServeApp(
|
||||
{
|
||||
...baseOpts,
|
||||
workspace: PRIMARY_CWD,
|
||||
token: 'secret',
|
||||
enableSessionShell: true,
|
||||
},
|
||||
undefined,
|
||||
{ bridge },
|
||||
);
|
||||
const auth = (test: request.Test) =>
|
||||
test.set('Host', host()).set('Authorization', 'Bearer secret');
|
||||
|
||||
const capabilities = await auth(request(app).get('/capabilities'));
|
||||
expect(capabilities.body.features).not.toContain(
|
||||
'multi_workspace_session_rewind',
|
||||
);
|
||||
expect(capabilities.body.features).not.toContain(
|
||||
'multi_workspace_session_shell',
|
||||
);
|
||||
|
||||
await auth(
|
||||
request(app).get('/session/primary-session/rewind/snapshots'),
|
||||
).expect(200);
|
||||
await auth(request(app).post('/session/primary-session/rewind'))
|
||||
.send({ promptId: 'primary-prompt', rewindFiles: false })
|
||||
.expect(200);
|
||||
await auth(request(app).post('/session/primary-session/shell'))
|
||||
.set('X-Qwen-Client-Id', 'client-1')
|
||||
.send({ command: 'pwd' })
|
||||
.expect(200);
|
||||
|
||||
expect(bridge.rewindSnapshotCalls).toEqual(['primary-session']);
|
||||
expect(bridge.rewindCalls).toHaveLength(1);
|
||||
expect(bridge.shellCalls).toHaveLength(1);
|
||||
});
|
||||
|
||||
it('preserves rewind busy, invalid-target, and partial-restore contracts', async () => {
|
||||
const postRewind = (
|
||||
app: ReturnType<typeof createServeApp>,
|
||||
promptId: string,
|
||||
) =>
|
||||
request(app)
|
||||
.post('/session/secondary-session/rewind')
|
||||
.set('Host', host())
|
||||
.set('Authorization', 'Bearer secret')
|
||||
.send({ promptId });
|
||||
|
||||
const busy = makeHarness({
|
||||
serveOptions: { token: 'secret' },
|
||||
secondaryRewindImpl: async (sessionId) => {
|
||||
throw new SessionBusyError(sessionId);
|
||||
},
|
||||
});
|
||||
const busyResponse = await postRewind(busy.app, 'busy');
|
||||
expect(busyResponse.status).toBe(409);
|
||||
expect(busyResponse.body.code).toBe('session_busy');
|
||||
expect(busyResponse.headers['retry-after']).toBe('5');
|
||||
|
||||
const invalid = makeHarness({
|
||||
serveOptions: { token: 'secret' },
|
||||
secondaryRewindImpl: async (sessionId) => {
|
||||
throw new InvalidRewindTargetError(sessionId);
|
||||
},
|
||||
});
|
||||
const invalidResponse = await postRewind(invalid.app, 'invalid');
|
||||
expect(invalidResponse.status).toBe(400);
|
||||
expect(invalidResponse.body.code).toBe('invalid_rewind_target');
|
||||
|
||||
const partial = makeHarness({
|
||||
serveOptions: { token: 'secret' },
|
||||
secondaryRewindImpl: async () => ({
|
||||
rewound: false,
|
||||
targetTurnIndex: 1,
|
||||
filesChanged: ['restored.txt'],
|
||||
filesFailed: ['failed.txt'],
|
||||
}),
|
||||
});
|
||||
const partialResponse = await postRewind(partial.app, 'partial');
|
||||
expect(partialResponse.status).toBe(200);
|
||||
expect(partialResponse.body).toEqual({
|
||||
rewound: false,
|
||||
targetTurnIndex: 1,
|
||||
filesChanged: ['restored.txt'],
|
||||
filesFailed: ['failed.txt'],
|
||||
});
|
||||
});
|
||||
|
||||
it('dispatches secondary events, permissions, pending prompts, and close', async () => {
|
||||
const { app, primaryBridge, secondaryBridge } = makeHarness();
|
||||
|
||||
|
||||
@@ -2887,9 +2887,9 @@ export function registerSessionRoutes(
|
||||
app.post(
|
||||
'/session/:id/shell',
|
||||
mutate({ strict: true }),
|
||||
withMutableSession(
|
||||
withOwnerMutableSession(
|
||||
'POST /session/:id/shell',
|
||||
async (req, res, sessionId) => {
|
||||
async (req, res, sessionId, runtime) => {
|
||||
if (!sessionShellCommandEnabled) {
|
||||
sendBridgeError(res, new SessionShellDisabledError(), {
|
||||
route: 'POST /session/:id/shell',
|
||||
@@ -2922,7 +2922,7 @@ export function registerSessionRoutes(
|
||||
};
|
||||
res.once('close', onResClose);
|
||||
try {
|
||||
const result = await bridge.executeShellCommand(
|
||||
const result = await runtime.bridge.executeShellCommand(
|
||||
sessionId,
|
||||
command.trim(),
|
||||
abort.signal,
|
||||
@@ -2933,6 +2933,8 @@ export function registerSessionRoutes(
|
||||
sessionId,
|
||||
clientId,
|
||||
exitCode: result.exitCode,
|
||||
workspaceId: runtime.workspaceId,
|
||||
workspaceCwd: runtime.workspaceCwd,
|
||||
});
|
||||
}
|
||||
res.status(200).json(result);
|
||||
@@ -2955,30 +2957,31 @@ export function registerSessionRoutes(
|
||||
),
|
||||
);
|
||||
|
||||
app.get('/session/:id/rewind/snapshots', async (req, res) => {
|
||||
const sessionId = req.params['id'];
|
||||
if (!sessionId) {
|
||||
res
|
||||
.status(400)
|
||||
.json({ error: '`sessionId` route parameter is required' });
|
||||
return;
|
||||
}
|
||||
try {
|
||||
res.status(200).json(await bridge.getRewindSnapshots(sessionId));
|
||||
} catch (err) {
|
||||
sendBridgeError(res, err, {
|
||||
route: 'GET /session/:id/rewind/snapshots',
|
||||
sessionId,
|
||||
});
|
||||
}
|
||||
});
|
||||
app.get(
|
||||
'/session/:id/rewind/snapshots',
|
||||
withOwnerReadSession(
|
||||
'GET /session/:id/rewind/snapshots',
|
||||
async (_req, res, sessionId, runtime) => {
|
||||
const response = await runtime.bridge.getRewindSnapshots(sessionId);
|
||||
if (daemonLog) {
|
||||
daemonLog.info('rewind snapshots loaded', {
|
||||
sessionId,
|
||||
snapshotCount: response.snapshots.length,
|
||||
workspaceId: runtime.workspaceId,
|
||||
workspaceCwd: runtime.workspaceCwd,
|
||||
});
|
||||
}
|
||||
res.status(200).json(response);
|
||||
},
|
||||
),
|
||||
);
|
||||
|
||||
app.post(
|
||||
'/session/:id/rewind',
|
||||
mutate({ strict: true }),
|
||||
withMutableSession(
|
||||
withOwnerMutableSession(
|
||||
'POST /session/:id/rewind',
|
||||
async (req, res, sessionId) => {
|
||||
async (req, res, sessionId, runtime) => {
|
||||
const body = safeBody(req);
|
||||
const promptId = body['promptId'];
|
||||
if (typeof promptId !== 'string' || promptId.length === 0) {
|
||||
@@ -2988,13 +2991,33 @@ export function registerSessionRoutes(
|
||||
});
|
||||
return;
|
||||
}
|
||||
const rewindFiles = body['rewindFiles'];
|
||||
if (rewindFiles !== undefined && typeof rewindFiles !== 'boolean') {
|
||||
res.status(400).json({
|
||||
error: '`rewindFiles` must be a boolean when provided',
|
||||
code: 'invalid_rewind_files_flag',
|
||||
});
|
||||
return;
|
||||
}
|
||||
const clientId = parseClientIdHeader(req, res);
|
||||
if (clientId === null) return;
|
||||
const response = await bridge.rewindSession(
|
||||
const response = await runtime.bridge.rewindSession(
|
||||
sessionId,
|
||||
{ promptId, rewindFiles: body['rewindFiles'] !== false },
|
||||
{ promptId, rewindFiles: rewindFiles !== false },
|
||||
clientId !== undefined ? { clientId } : undefined,
|
||||
);
|
||||
if (daemonLog) {
|
||||
daemonLog.info('session rewind completed', {
|
||||
sessionId,
|
||||
promptId,
|
||||
rewindFiles: rewindFiles !== false,
|
||||
rewound: response.rewound,
|
||||
filesChangedCount: response.filesChanged.length,
|
||||
filesFailedCount: response.filesFailed.length,
|
||||
workspaceId: runtime.workspaceId,
|
||||
workspaceCwd: runtime.workspaceCwd,
|
||||
});
|
||||
}
|
||||
res.status(200).json(response);
|
||||
},
|
||||
),
|
||||
|
||||
@@ -394,6 +394,8 @@ const EXPECTED_REGISTERED_FEATURES = [
|
||||
'channel_reload',
|
||||
'channel_control',
|
||||
'multi_workspace_sessions',
|
||||
'multi_workspace_session_rewind',
|
||||
'multi_workspace_session_shell',
|
||||
'persistent_workspace_registration',
|
||||
'workspace_qualified_rest_core',
|
||||
'workspace_persisted_transcript',
|
||||
@@ -2238,6 +2240,53 @@ describe('createServeApp', () => {
|
||||
);
|
||||
continue;
|
||||
}
|
||||
if (feature === 'multi_workspace_session_rewind') {
|
||||
expect(predicate({ multiWorkspaceSessionsEnabled: true })).toBe(true);
|
||||
expect(predicate({ multiWorkspaceSessionsEnabled: false })).toBe(
|
||||
false,
|
||||
);
|
||||
expect(predicate({})).toBe(false);
|
||||
expect(
|
||||
getAdvertisedServeFeatures(undefined, {
|
||||
multiWorkspaceSessionsEnabled: true,
|
||||
}),
|
||||
).toContain(feature);
|
||||
expect(getAdvertisedServeFeatures(undefined, {})).not.toContain(
|
||||
feature,
|
||||
);
|
||||
continue;
|
||||
}
|
||||
if (feature === 'multi_workspace_session_shell') {
|
||||
expect(
|
||||
predicate({
|
||||
multiWorkspaceSessionsEnabled: true,
|
||||
sessionShellCommandEnabled: true,
|
||||
}),
|
||||
).toBe(true);
|
||||
expect(
|
||||
predicate({
|
||||
multiWorkspaceSessionsEnabled: true,
|
||||
sessionShellCommandEnabled: false,
|
||||
}),
|
||||
).toBe(false);
|
||||
expect(
|
||||
predicate({
|
||||
multiWorkspaceSessionsEnabled: false,
|
||||
sessionShellCommandEnabled: true,
|
||||
}),
|
||||
).toBe(false);
|
||||
expect(predicate({})).toBe(false);
|
||||
expect(
|
||||
getAdvertisedServeFeatures(undefined, {
|
||||
multiWorkspaceSessionsEnabled: true,
|
||||
sessionShellCommandEnabled: true,
|
||||
}),
|
||||
).toContain(feature);
|
||||
expect(getAdvertisedServeFeatures(undefined, {})).not.toContain(
|
||||
feature,
|
||||
);
|
||||
continue;
|
||||
}
|
||||
if (feature === 'persistent_workspace_registration') {
|
||||
expect(
|
||||
predicate({ persistentWorkspaceRegistrationAvailable: true }),
|
||||
|
||||
@@ -1000,27 +1000,40 @@ export function createServeApp(
|
||||
});
|
||||
|
||||
app.use(
|
||||
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;
|
||||
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;
|
||||
}
|
||||
} catch {
|
||||
return primaryBoundWorkspace;
|
||||
}
|
||||
}
|
||||
return primaryBoundWorkspace;
|
||||
}, deps.recordDaemonRequest),
|
||||
return primaryBoundWorkspace;
|
||||
},
|
||||
deps.recordDaemonRequest,
|
||||
(sessionId) => {
|
||||
try {
|
||||
const owner = workspaceRegistry.resolveLiveSessionOwner(sessionId);
|
||||
return owner.kind === 'found'
|
||||
? owner.runtime.workspaceCwd
|
||||
: undefined;
|
||||
} catch {
|
||||
return undefined;
|
||||
}
|
||||
},
|
||||
),
|
||||
);
|
||||
|
||||
const buildWorkspaceCtx = createBuildWorkspaceCtx(primaryBoundWorkspace);
|
||||
|
||||
@@ -140,6 +140,93 @@ describe('daemonTelemetryMiddleware — recordRequest seam', () => {
|
||||
);
|
||||
});
|
||||
|
||||
it('attributes singular rewind and shell routes to the live session owner', () => {
|
||||
const resolveSessionWorkspaceCwd = vi.fn(() => '/workspace/secondary');
|
||||
const mw = daemonTelemetryMiddleware(
|
||||
() => '/workspace/primary',
|
||||
undefined,
|
||||
resolveSessionWorkspaceCwd,
|
||||
);
|
||||
|
||||
for (const [method, path, route] of [
|
||||
[
|
||||
'GET',
|
||||
'/session/secondary-session/rewind/snapshots',
|
||||
'GET /session/:id/rewind/snapshots',
|
||||
],
|
||||
['POST', '/session/secondary-session/rewind', 'POST /session/:id/rewind'],
|
||||
['POST', '/session/secondary-session/shell', 'POST /session/:id/shell'],
|
||||
] 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,
|
||||
sessionId: 'secondary-session',
|
||||
workspaceHash: 'hash:/workspace/secondary',
|
||||
}),
|
||||
expect.any(Function),
|
||||
);
|
||||
}
|
||||
|
||||
expect(resolveSessionWorkspaceCwd).toHaveBeenCalledTimes(3);
|
||||
expect(resolveSessionWorkspaceCwd).toHaveBeenCalledWith(
|
||||
'secondary-session',
|
||||
);
|
||||
});
|
||||
|
||||
it('decodes session ids before owner lookup and span attribution', () => {
|
||||
const resolveSessionWorkspaceCwd = vi.fn(() => '/workspace/secondary');
|
||||
const mw = daemonTelemetryMiddleware(
|
||||
() => '/workspace/primary',
|
||||
undefined,
|
||||
resolveSessionWorkspaceCwd,
|
||||
);
|
||||
const res = mockRes(200);
|
||||
|
||||
mw(
|
||||
mockReq('POST', '/session/secondary%2Fsession/rewind'),
|
||||
res,
|
||||
vi.fn() as unknown as NextFunction,
|
||||
);
|
||||
res.emit('finish');
|
||||
|
||||
expect(resolveSessionWorkspaceCwd).toHaveBeenCalledWith(
|
||||
'secondary/session',
|
||||
);
|
||||
expect(coreMocks.withDaemonRequestSpan).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ sessionId: 'secondary/session' }),
|
||||
expect.any(Function),
|
||||
);
|
||||
});
|
||||
|
||||
it('keeps malformed session id encodings without throwing', () => {
|
||||
const resolveSessionWorkspaceCwd = vi.fn(() => undefined);
|
||||
const mw = daemonTelemetryMiddleware(
|
||||
() => '/workspace/primary',
|
||||
undefined,
|
||||
resolveSessionWorkspaceCwd,
|
||||
);
|
||||
const res = mockRes(200);
|
||||
|
||||
expect(() => {
|
||||
mw(
|
||||
mockReq('POST', '/session/bad%ZZ/rewind'),
|
||||
res,
|
||||
vi.fn() as unknown as NextFunction,
|
||||
);
|
||||
}).not.toThrow();
|
||||
res.emit('finish');
|
||||
|
||||
expect(resolveSessionWorkspaceCwd).toHaveBeenCalledWith('bad%ZZ');
|
||||
expect(coreMocks.withDaemonRequestSpan).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ sessionId: 'bad%ZZ' }),
|
||||
expect.any(Function),
|
||||
);
|
||||
});
|
||||
|
||||
it('normalizes plural workspace agent routes to stable route labels', () => {
|
||||
const mw = daemonTelemetryMiddleware(() => '/ws');
|
||||
for (const [method, path, route] of [
|
||||
|
||||
@@ -18,6 +18,14 @@ import {
|
||||
MAX_CLIENT_ID_LENGTH,
|
||||
} from './request-helpers.js';
|
||||
|
||||
function decodePathSegment(value: string): string {
|
||||
try {
|
||||
return decodeURIComponent(value);
|
||||
} catch {
|
||||
return value;
|
||||
}
|
||||
}
|
||||
|
||||
// Route handlers are split across `routes/*.ts`; any added or renamed route
|
||||
// that needs daemon telemetry must keep these patterns in sync.
|
||||
export function resolveDaemonTelemetryRoute(
|
||||
@@ -35,6 +43,13 @@ export function resolveDaemonTelemetryRoute(
|
||||
if (req.method === 'GET' && path === '/daemon/status') {
|
||||
return { route: 'GET /daemon/status' };
|
||||
}
|
||||
const rewindSnapshots = path.match(/^\/session\/([^/]+)\/rewind\/snapshots$/);
|
||||
if (rewindSnapshots?.[1] && req.method === 'GET') {
|
||||
return {
|
||||
route: 'GET /session/:id/rewind/snapshots',
|
||||
sessionId: rewindSnapshots[1],
|
||||
};
|
||||
}
|
||||
const sessionAction = path.match(
|
||||
/^\/session\/([^/]+)\/(load|resume|prompt|cancel|recap|btw|mid-turn-message|model|shell|detach|rewind|approval-mode|language|a2ui-action)$/,
|
||||
);
|
||||
@@ -295,6 +310,7 @@ export function daemonTelemetryMiddleware(
|
||||
// the OTel counter's scope, so the "requests" line reflects daemon API
|
||||
// traffic rather than static-asset or unrouted noise.
|
||||
recordRequest?: (durationMs: number, statusCode: number) => void,
|
||||
resolveSessionWorkspaceCwd?: (sessionId: string) => string | undefined,
|
||||
): (req: Request, res: Response, next: NextFunction) => void {
|
||||
const workspaceHashByCwd = new Map<string, string>();
|
||||
const resolveWorkspaceHash = (workspaceCwd: string): string => {
|
||||
@@ -311,7 +327,18 @@ export function daemonTelemetryMiddleware(
|
||||
next();
|
||||
return;
|
||||
}
|
||||
const workspaceHash = resolveWorkspaceHash(resolveWorkspaceCwd(req));
|
||||
const resolveOwnerWorkspace =
|
||||
route.route === 'GET /session/:id/rewind/snapshots' ||
|
||||
route.route === 'POST /session/:id/rewind' ||
|
||||
route.route === 'POST /session/:id/shell';
|
||||
const sessionId = route.sessionId
|
||||
? decodePathSegment(route.sessionId)
|
||||
: undefined;
|
||||
const workspaceCwd =
|
||||
(resolveOwnerWorkspace && sessionId
|
||||
? resolveSessionWorkspaceCwd?.(sessionId)
|
||||
: undefined) ?? resolveWorkspaceCwd(req);
|
||||
const workspaceHash = resolveWorkspaceHash(workspaceCwd);
|
||||
const rawClientId = req.get(CLIENT_ID_HEADER);
|
||||
const clientId =
|
||||
rawClientId !== undefined &&
|
||||
@@ -326,7 +353,7 @@ export function daemonTelemetryMiddleware(
|
||||
method: req.method,
|
||||
route: route.route,
|
||||
workspaceHash,
|
||||
...(route.sessionId ? { sessionId: route.sessionId } : {}),
|
||||
...(sessionId ? { sessionId } : {}),
|
||||
...(route.permissionRequestId
|
||||
? { permissionRequestId: route.permissionRequestId }
|
||||
: {}),
|
||||
|
||||
@@ -199,6 +199,7 @@ export class AcpHttpTransport implements DaemonTransport {
|
||||
|
||||
readonly type = 'acp-http' as const;
|
||||
readonly supportsReplay = true;
|
||||
readonly restFetch: typeof globalThis.fetch;
|
||||
|
||||
constructor(
|
||||
baseUrl: string,
|
||||
@@ -208,6 +209,7 @@ export class AcpHttpTransport implements DaemonTransport {
|
||||
this.baseUrl = baseUrl;
|
||||
this.token = token;
|
||||
this._fetch = fetchFn;
|
||||
this.restFetch = fetchFn;
|
||||
}
|
||||
|
||||
get connected(): boolean {
|
||||
|
||||
@@ -109,10 +109,16 @@ export class AcpWsTransport implements DaemonTransport {
|
||||
|
||||
readonly type = 'acp-ws' as const;
|
||||
readonly supportsReplay = false;
|
||||
readonly restFetch: typeof globalThis.fetch | undefined;
|
||||
|
||||
constructor(wsUrl: string, token?: string) {
|
||||
constructor(
|
||||
wsUrl: string,
|
||||
token?: string,
|
||||
restFetch?: typeof globalThis.fetch,
|
||||
) {
|
||||
this.wsUrl = wsUrl;
|
||||
this.token = token;
|
||||
this.restFetch = restFetch;
|
||||
}
|
||||
|
||||
get connected(): boolean {
|
||||
|
||||
@@ -52,6 +52,7 @@ export class AutoReconnectTransport implements DaemonTransport {
|
||||
private reconnecting: Promise<void> | undefined;
|
||||
|
||||
readonly supportsReplay: boolean;
|
||||
readonly restFetch: typeof globalThis.fetch;
|
||||
|
||||
constructor(opts: {
|
||||
baseUrl: string;
|
||||
@@ -63,7 +64,11 @@ export class AutoReconnectTransport implements DaemonTransport {
|
||||
}) {
|
||||
this.baseUrl = opts.baseUrl;
|
||||
this.token = opts.token;
|
||||
this.fetchFn = opts.fetch ?? globalThis.fetch.bind(globalThis);
|
||||
this.fetchFn =
|
||||
opts.fetch ??
|
||||
opts.initial?.restFetch ??
|
||||
globalThis.fetch.bind(globalThis);
|
||||
this.restFetch = this.fetchFn;
|
||||
this.preferredType = opts.preferredType ?? 'rest';
|
||||
this.factory = opts.factory;
|
||||
this.inner =
|
||||
|
||||
@@ -205,7 +205,9 @@ export interface DaemonClientOptions {
|
||||
* Pluggable transport. When omitted, a `RestSseTransport` is created
|
||||
* automatically — this preserves the existing REST+SSE behavior with
|
||||
* zero caller-side changes. Pass an `AcpWsTransport` or
|
||||
* `AcpHttpTransport` to use JSON-RPC over WebSocket or HTTP.
|
||||
* `AcpHttpTransport` to use JSON-RPC over WebSocket or HTTP. Rewind APIs
|
||||
* intentionally use direct REST even when an ACP transport is configured so
|
||||
* owner routing and strict mutation authentication remain authoritative.
|
||||
*/
|
||||
transport?: DaemonTransport;
|
||||
}
|
||||
@@ -460,7 +462,10 @@ export class DaemonClient {
|
||||
// thread the value through every construction. See
|
||||
// `readTokenFromEnv` above for browser-safety + trim semantics.
|
||||
this.token = opts.token ?? readTokenFromEnv();
|
||||
this._fetch = opts.fetch ?? globalThis.fetch.bind(globalThis);
|
||||
this._fetch =
|
||||
opts.fetch ??
|
||||
opts.transport?.restFetch ??
|
||||
globalThis.fetch.bind(globalThis);
|
||||
// Coerce non-positive / non-finite to 0 (= disabled). Without this
|
||||
// a caller passing `-1` or `NaN` would slip past the
|
||||
// `Number.isFinite` check inside `fetchWithTimeout` (NaN fails
|
||||
@@ -2043,6 +2048,8 @@ export class DaemonClient {
|
||||
}
|
||||
return (await res.json()) as { snapshots: DaemonRewindSnapshotInfo[] };
|
||||
},
|
||||
undefined,
|
||||
'rest',
|
||||
);
|
||||
}
|
||||
|
||||
@@ -2072,6 +2079,8 @@ export class DaemonClient {
|
||||
}
|
||||
return (await res.json()) as DaemonRewindResult;
|
||||
},
|
||||
undefined,
|
||||
'rest',
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -53,6 +53,9 @@ export type DaemonTransportType = 'rest' | 'acp-http' | 'acp-ws';
|
||||
* `DaemonClient` needs no control-flow changes.
|
||||
*/
|
||||
export interface DaemonTransport {
|
||||
/** Native HTTP fetch used by REST-only daemon endpoints. */
|
||||
readonly restFetch?: typeof globalThis.fetch;
|
||||
|
||||
/**
|
||||
* Issue an HTTP-shaped request. REST transports delegate to the
|
||||
* underlying `fetch`; ACP transports translate the URL + body into
|
||||
|
||||
@@ -30,6 +30,7 @@ export class RestSseTransport implements DaemonTransport {
|
||||
|
||||
readonly type = 'rest' as const;
|
||||
readonly supportsReplay = true;
|
||||
readonly restFetch: typeof globalThis.fetch;
|
||||
|
||||
constructor(
|
||||
baseUrl: string,
|
||||
@@ -39,6 +40,7 @@ export class RestSseTransport implements DaemonTransport {
|
||||
this.baseUrl = baseUrl;
|
||||
this.token = token;
|
||||
this._fetch = fetchFn;
|
||||
this.restFetch = fetchFn;
|
||||
}
|
||||
|
||||
get connected(): boolean {
|
||||
|
||||
@@ -92,7 +92,7 @@ export async function negotiateTransport(
|
||||
const { AcpWsTransport } = await import('./AcpWsTransport.js');
|
||||
// Convert http(s) → ws(s) for the WS URL.
|
||||
const wsUrl = baseUrl.replace(/^https:/, 'wss:').replace(/^http:/, 'ws:');
|
||||
const transport = new AcpWsTransport(wsUrl + '/acp', token);
|
||||
const transport = new AcpWsTransport(wsUrl + '/acp', token, fetchFn);
|
||||
// Probe: try to connect with a timeout.
|
||||
const probeTimer = setTimeout(() => {
|
||||
/* timeout — handled by race */
|
||||
|
||||
@@ -58,6 +58,18 @@ describe('AutoReconnectTransport', () => {
|
||||
// ---- Delegation to inner transport ------------------------------------
|
||||
|
||||
describe('delegation', () => {
|
||||
it('preserves the initial transport native fetch for REST-only calls', () => {
|
||||
const restFetch = vi.fn() as unknown as typeof globalThis.fetch;
|
||||
const inner = createMockTransport({ restFetch });
|
||||
const transport = new AutoReconnectTransport({
|
||||
baseUrl: 'http://d',
|
||||
initial: inner,
|
||||
});
|
||||
|
||||
expect(transport.restFetch).toBe(restFetch);
|
||||
transport.dispose();
|
||||
});
|
||||
|
||||
it('type delegates to inner transport', () => {
|
||||
const inner = createMockTransport({ type: 'acp-ws' });
|
||||
const transport = new AutoReconnectTransport({
|
||||
|
||||
@@ -14,6 +14,7 @@ import {
|
||||
normalizePendingPromptLimit,
|
||||
} from '../../src/daemon/DaemonClient.js';
|
||||
import type { DaemonTransport } from '../../src/daemon/DaemonTransport.js';
|
||||
import { negotiateTransport } from '../../src/daemon/negotiateTransport.js';
|
||||
import {
|
||||
DaemonCapabilityMissingError,
|
||||
isDaemonContentHash,
|
||||
@@ -705,6 +706,9 @@ describe('DaemonClient', () => {
|
||||
type: 'acp-http',
|
||||
supportsReplay: true,
|
||||
connected: true,
|
||||
restFetch: vi.fn(async () => {
|
||||
throw new Error('transport REST fetch must not override opts.fetch');
|
||||
}) as unknown as typeof globalThis.fetch,
|
||||
fetch: transportFetch,
|
||||
async *subscribeEvents() {},
|
||||
dispose() {},
|
||||
@@ -1175,6 +1179,157 @@ describe('DaemonClient', () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe('session rewind transport', () => {
|
||||
it('reuses the negotiated native fetch for REST-only rewind calls', async () => {
|
||||
const negotiatedFetch = vi.fn(async (input: string | URL | Request) => {
|
||||
const url = String(input);
|
||||
if (url.endsWith('/capabilities')) {
|
||||
return jsonResponse(200, { transports: ['acp-http'] });
|
||||
}
|
||||
if (url.endsWith('/rewind/snapshots')) {
|
||||
return jsonResponse(200, { snapshots: [] });
|
||||
}
|
||||
return jsonResponse(200, {
|
||||
rewound: true,
|
||||
targetTurnIndex: 0,
|
||||
filesChanged: [],
|
||||
filesFailed: [],
|
||||
});
|
||||
}) as unknown as typeof globalThis.fetch;
|
||||
const globalFetch = vi
|
||||
.spyOn(globalThis, 'fetch')
|
||||
.mockRejectedValue(new Error('global fetch must not handle rewind'));
|
||||
|
||||
try {
|
||||
const transport = await negotiateTransport('http://daemon', 'secret', {
|
||||
fetchFn: negotiatedFetch,
|
||||
});
|
||||
const client = new DaemonClient({
|
||||
baseUrl: 'http://daemon',
|
||||
token: 'secret',
|
||||
transport,
|
||||
});
|
||||
|
||||
await expect(client.getRewindSnapshots('s-1')).resolves.toEqual({
|
||||
snapshots: [],
|
||||
});
|
||||
await expect(
|
||||
client.rewindSession('s-1', 'prompt-1'),
|
||||
).resolves.toMatchObject({ rewound: true });
|
||||
|
||||
expect(negotiatedFetch).toHaveBeenCalledTimes(3);
|
||||
expect(globalFetch).not.toHaveBeenCalled();
|
||||
} finally {
|
||||
globalFetch.mockRestore();
|
||||
}
|
||||
});
|
||||
|
||||
it('forces owner-aware REST with auth, client id, timeout, and boolean body', async () => {
|
||||
const { fetch, calls } = recordingFetch((req) => {
|
||||
if (req.url.endsWith('/rewind/snapshots')) {
|
||||
return jsonResponse(200, { snapshots: [] });
|
||||
}
|
||||
return jsonResponse(200, {
|
||||
rewound: true,
|
||||
targetTurnIndex: 0,
|
||||
filesChanged: [],
|
||||
filesFailed: [],
|
||||
});
|
||||
});
|
||||
const transportFetch = vi.fn(async () => {
|
||||
throw new Error('ACP transport must not handle rewind');
|
||||
});
|
||||
const transport: DaemonTransport = {
|
||||
type: 'acp-http',
|
||||
supportsReplay: true,
|
||||
connected: true,
|
||||
fetch: transportFetch,
|
||||
async *subscribeEvents() {},
|
||||
dispose() {},
|
||||
};
|
||||
const client = new DaemonClient({
|
||||
baseUrl: 'http://daemon',
|
||||
token: 'secret',
|
||||
fetch,
|
||||
transport,
|
||||
fetchTimeoutMs: 1234,
|
||||
});
|
||||
|
||||
await expect(client.getRewindSnapshots('with/slash')).resolves.toEqual({
|
||||
snapshots: [],
|
||||
});
|
||||
await expect(
|
||||
client.rewindSession('with/slash', 'prompt-1', {
|
||||
clientId: 'client-1',
|
||||
rewindFiles: false,
|
||||
}),
|
||||
).resolves.toMatchObject({ rewound: true });
|
||||
|
||||
expect(transportFetch).not.toHaveBeenCalled();
|
||||
expect(calls).toHaveLength(2);
|
||||
expect(calls[0]).toMatchObject({
|
||||
url: 'http://daemon/session/with%2Fslash/rewind/snapshots',
|
||||
method: 'GET',
|
||||
headers: { authorization: 'Bearer secret' },
|
||||
signal: expect.any(AbortSignal),
|
||||
});
|
||||
expect(calls[1]).toMatchObject({
|
||||
url: 'http://daemon/session/with%2Fslash/rewind',
|
||||
method: 'POST',
|
||||
headers: {
|
||||
authorization: 'Bearer secret',
|
||||
'content-type': 'application/json',
|
||||
'x-qwen-client-id': 'client-1',
|
||||
},
|
||||
signal: expect.any(AbortSignal),
|
||||
});
|
||||
expect(JSON.parse(calls[1]!.body!)).toEqual({
|
||||
promptId: 'prompt-1',
|
||||
rewindFiles: false,
|
||||
});
|
||||
});
|
||||
|
||||
it('keeps shell on the configured transport', async () => {
|
||||
const nativeFetch = vi.fn(async () => {
|
||||
throw new Error('native REST fetch must not handle ACP shell');
|
||||
}) as unknown as typeof globalThis.fetch;
|
||||
const transportFetch = vi.fn(async () =>
|
||||
jsonResponse(200, { exitCode: 0, output: '/work/b', aborted: false }),
|
||||
);
|
||||
const transport: DaemonTransport = {
|
||||
type: 'acp-http',
|
||||
supportsReplay: true,
|
||||
connected: true,
|
||||
fetch: transportFetch,
|
||||
async *subscribeEvents() {},
|
||||
dispose() {},
|
||||
};
|
||||
const client = new DaemonClient({
|
||||
baseUrl: 'http://daemon',
|
||||
token: 'secret',
|
||||
fetch: nativeFetch,
|
||||
transport,
|
||||
});
|
||||
|
||||
await expect(
|
||||
client.shellCommand('session-b', 'pwd', { clientId: 'client-b' }),
|
||||
).resolves.toMatchObject({ output: '/work/b' });
|
||||
expect(nativeFetch).not.toHaveBeenCalled();
|
||||
expect(transportFetch).toHaveBeenCalledOnce();
|
||||
expect(transportFetch).toHaveBeenCalledWith(
|
||||
'http://daemon/session/session-b/shell',
|
||||
expect.objectContaining({
|
||||
method: 'POST',
|
||||
body: JSON.stringify({ command: 'pwd' }),
|
||||
headers: expect.objectContaining({
|
||||
Authorization: 'Bearer secret',
|
||||
'X-Qwen-Client-Id': 'client-b',
|
||||
}),
|
||||
}),
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe('bearer auth', () => {
|
||||
it('attaches Authorization: Bearer when token is set', async () => {
|
||||
const { fetch, calls } = recordingFetch(() =>
|
||||
|
||||
@@ -900,6 +900,11 @@ describe('acpRouteTable – matchRoute', () => {
|
||||
expect(matchRoute('/session/s12/approval-mode', 'POST')).toBeNull();
|
||||
});
|
||||
|
||||
it('keeps rewind routes off ACP so strict REST auth cannot be bypassed', () => {
|
||||
expect(matchRoute('/session/s12/rewind/snapshots', 'GET')).toBeNull();
|
||||
expect(matchRoute('/session/s12/rewind', 'POST')).toBeNull();
|
||||
});
|
||||
|
||||
// ---- Unknown/unmatched routes ---------------------------------------
|
||||
|
||||
it('returns null for unknown path', () => {
|
||||
|
||||
Reference in new issue
Block a user