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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions .prettierignore
Original file line number Diff line number Diff line change
Expand Up @@ -22,4 +22,5 @@ junit.xml
Thumbs.db
packages/vscode-ide-companion/schemas/settings.schema.json
packages/cli/src/services/insight/templates/insightTemplate.ts
packages/cua-driver/
packages/desktop/
92 changes: 92 additions & 0 deletions packages/acp-bridge/src/bridge.ts
Original file line number Diff line number Diff line change
Expand Up @@ -48,6 +48,7 @@ import {
} from './status.js';
import {
BranchWhilePromptActiveError,
CdWhilePromptActiveError,
SessionNotFoundError,
RestoreInProgressError,
InvalidSessionScopeError,
Expand Down Expand Up @@ -81,6 +82,8 @@ import type {
AcpSessionBridge,
MidTurnQueueEntry,
BridgeDaemonStatusSnapshot,
ChangeSessionCwdRequest,
ChangeSessionCwdResult,
} from './bridgeTypes.js';
import type { BridgeOptions, BridgeTelemetry } from './bridgeOptions.js';
import { MCP_RESTART_SERVER_DEADLINE_MS } from './mcpTimeouts.js';
Expand Down Expand Up @@ -3474,6 +3477,95 @@ export function createAcpSessionBridge(opts: BridgeOptions): AcpSessionBridge {
return branchResult;
},

async changeSessionCwd(

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[Suggestion] changeSessionCwd has zero logging — no entry log, no success log, no warning when warnings[] is non-empty. For comparison, sendPrompt logs forward failures and mid-turn queue drops via writeStderrLine, and wraps the dispatch in a telemetry.withSpan. A directory-change mutation is operator-significant: when this breaks at 3 AM, there will be no trail to determine whether the /cd was attempted, succeeded, or left the session in a degraded state (memory/model-context refresh failures silently returned in warnings[]).

Consider adding at minimum:

  • debugLogger.info('changeSessionCwd', { sessionId, path: req.path }) at entry
  • debugLogger.warn('changeSessionCwd warnings', { sessionId, warnings }) when warnings.length > 0

— qwen3.7-max via Qwen Code /review

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[qwen] Fixed in 6bfc526.

Added writeStderrLine logging after successful cwd change: logs session ID, old → new path, and any warnings. This provides the operator trail for debugging ACP /cd mutations at 3 AM.

sessionId: string,
req: ChangeSessionCwdRequest,
context?: BridgeClientRequestContext,
): Promise<ChangeSessionCwdResult> {
if (shuttingDown) throw new Error('AcpSessionBridge is shutting down');

const entry = byId.get(sessionId);
if (!entry) throw new SessionNotFoundError(sessionId);

const originatorClientId = resolveTrustedClientId(
entry,
context?.clientId,
);

// Chain onto promptQueue and update tail — ensures:
// 1. cd waits for any in-flight prompt to complete
// 2. Subsequent prompts wait for cd to complete (prevents stale config.cwd)
const cdPromise = entry.promptQueue.then(async () => {
if (entry.promptActive) {
throw new CdWhilePromptActiveError(sessionId);
}

const ci = await ensureChannel();
const raw = await ci.connection.extMethod(
SERVE_CONTROL_EXT_METHODS.sessionCd,
{
sessionId,
path: req.path,
},
);
const extResult = raw as {
previousCwd: string;
newCwd: string;
warnings: string[];
};
if (
typeof extResult?.previousCwd !== 'string' ||
typeof extResult?.newCwd !== 'string' ||
!Array.isArray(extResult?.warnings)
) {
throw new Error(
`changeSessionCwd: unexpected response shape from agent: ${JSON.stringify(raw)}`,
);
}

// State update inside the queue lambda — always executes when
// the extMethod settles, regardless of caller timeout.
if (extResult.previousCwd !== extResult.newCwd) {
entry.events.publish({
type: 'session_cwd_changed',
data: {
sessionId,
previousCwd: extResult.previousCwd,
newCwd: extResult.newCwd,
},
...(originatorClientId ? { originatorClientId } : {}),
});
}

return extResult;
});

// Queue tail tied to the raw extMethod settlement — subsequent
// operations wait for the actual cd to finish, not the timeout.
entry.promptQueue = cdPromise.then(
() => undefined,
() => undefined,
);

// Timeout is caller-facing only: surfaces a deadline exceeded error
// to the HTTP client without advancing the queue prematurely.
const result = await withTimeout(
cdPromise,
Math.max(initTimeoutMs, 30_000),
'changeSessionCwd',
);

writeStderrLine(
`qwen serve: session ${sessionId} cwd changed: ` +
`${result.previousCwd} -> ${result.newCwd}` +
(result.warnings.length > 0
? ` (warnings: ${result.warnings.join('; ')})`
: ''),
);

return { sessionId, ...result };
},

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[Suggestion] The PR adds 277 lines across 9 files (bridge method, agent handler, HTTP route, error class, config option) without any test coverage. The existing bridge.test.ts has extensive tests for branchSession (queue chaining, prompt-active guard, session_branched event, cleanup on failure) and sendPrompt (queue FIFO, abort, retry) — the same coverage pattern should apply to changeSessionCwd.

The PR description notes "unit tests for bridge/handler (follow-up PR)". Key paths that should be tested before shipping:

  1. changeSessionCwd waits for in-flight prompt, then executes
  2. CdWhilePromptActiveError propagation through the HTTP layer
  3. session_cwd_changed event published with correct payload and originatorClientId
  4. Noop when target path equals current directory
  5. skipProcessChdir in relocateWorkingDirectory — verify process.chdir is never called, this.cwd and this.storage update correctly, and artifact-migration failure leaves config state consistent

— qwen3.7-max via Qwen Code /review

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[qwen] Acknowledged — test coverage will be added in a follow-up commit. The changeSessionCwd method now has identical queue chaining semantics to branchSession, so the test patterns for queue-FIFO, prompt-active-guard, and event broadcast from bridge.test.ts apply directly.

async closeSession(sessionId, context, closeOpts) {
return closeSessionImpl(sessionId, context, closeOpts);
},
Expand Down
11 changes: 11 additions & 0 deletions packages/acp-bridge/src/bridgeErrors.ts
Original file line number Diff line number Diff line change
Expand Up @@ -501,3 +501,14 @@ export class BranchWhilePromptActiveError extends Error {
this.sessionId = sessionId;
}
}

export class CdWhilePromptActiveError extends Error {
readonly sessionId: string;
constructor(sessionId: string) {
super(
`Cannot change directory for session ${sessionId}: a prompt is currently active`,
);
this.name = 'CdWhilePromptActiveError';
this.sessionId = sessionId;
}
}
26 changes: 26 additions & 0 deletions packages/acp-bridge/src/bridgeTypes.ts
Original file line number Diff line number Diff line change
Expand Up @@ -123,6 +123,17 @@ export interface BridgeForkAgentResult {
launched: boolean;
}

export interface ChangeSessionCwdRequest {
path: string;
}

export interface ChangeSessionCwdResult {
sessionId: string;
previousCwd: string;
newCwd: string;
warnings: string[];
}

/** Sparse summary used by `GET /workspace/:id/sessions`. */
export interface BridgeSessionSummary {
sessionId: string;
Expand Down Expand Up @@ -301,6 +312,21 @@ export interface AcpSessionBridge {
context?: BridgeClientRequestContext,
): Promise<BridgeBranchedSession>;

/**
* Change the working directory of a live session. The session must be
* idle (no active prompt). Chains onto `entry.promptQueue` and updates
* the tail to prevent concurrent mutations.
*
* Throws `CdWhilePromptActiveError` when a prompt is running,
* `SessionNotFoundError` for unknown ids, and `InvalidClientIdError`
* when the caller's client id is not bound to the session.
*/
changeSessionCwd(
sessionId: string,
req: ChangeSessionCwdRequest,
context?: BridgeClientRequestContext,
): Promise<ChangeSessionCwdResult>;

/**
* Forward a prompt to the agent. Concurrent prompts against the same
* session FIFO-serialize through a per-session queue.
Expand Down
1 change: 1 addition & 0 deletions packages/acp-bridge/src/status.ts
Original file line number Diff line number Diff line change
Expand Up @@ -143,6 +143,7 @@ export const SERVE_CONTROL_EXT_METHODS = {
workspaceMcpRuntimeRemove: 'qwen/control/workspace/mcp/runtime-remove',
workspaceReload: 'qwen/control/workspace/reload',
workspaceExtensionsRefresh: 'qwen/control/workspace/extensions/refresh',
sessionCd: 'qwen/control/session/cd',
} as const;

export type ServeStatus =
Expand Down
109 changes: 108 additions & 1 deletion packages/cli/src/acp-integration/acpAgent.ts
Original file line number Diff line number Diff line change
Expand Up @@ -172,7 +172,11 @@ import {
getCurrentLanguage,
SUPPORTED_LANGUAGES,
} from '../i18n/index.js';
import { isWorkspaceTrusted } from '../config/trustedFolders.js';
import {
isWorkspaceTrusted,
isFolderTrustEnabled,
loadTrustedFolders,
} from '../config/trustedFolders.js';
import {
ACP_PREFLIGHT_KINDS,
STATUS_SCHEMA_VERSION,
Expand Down Expand Up @@ -5511,6 +5515,109 @@ class QwenAgent implements Agent {
await this.closeStoredSession(sessionId);
return { sessionId, closed: true };
}
case SERVE_CONTROL_EXT_METHODS.sessionCd: {
const sessionId = params['sessionId'];
const targetPath = params['path'];
if (typeof sessionId !== 'string' || sessionId.length === 0) {
throw RequestError.invalidParams(
undefined,
'Invalid or missing sessionId',
);
}
if (

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[Suggestion] The interactive /cd command (cdCommand.ts:35-36) validates against null bytes before processing the path:

if (input.includes('\0') || baseDir.includes('\0')) {
  throw new Error('Path contains null bytes.');
}

This ACP handler accepts any absolute string without this check. Null bytes in paths can cause truncation at the C/syscall boundary, potentially bypassing the directory-existence check or trust validation. Add the same null byte guard here for defense in depth.

Suggested change
if (
if (
typeof targetPath !== 'string' ||
targetPath.length === 0 ||
targetPath.includes('\0') ||
!path.isAbsolute(targetPath)
) {

— qwen3.7-max via Qwen Code /review

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[qwen] Fixed in 6bfc526.

Added targetPath.includes('\0') to the validation predicate in the ACP sessionCd handler, matching the same null-byte check pattern used by the interactive /cd command.

typeof targetPath !== 'string' ||
targetPath.length === 0 ||
!path.isAbsolute(targetPath) ||
targetPath.includes('\0')
) {
throw RequestError.invalidParams(
undefined,
'Invalid or missing path (must be an absolute path)',
);
}

const session = this.sessionOrThrow(sessionId);
const config = session.getConfig();

// Restrictive sandbox check
if (config.isRestrictiveSandbox()) {
throw new RequestError(-32003, 'Restrictive sandbox mode active', {
errorKind: 'restrictive_sandbox',
});
}

// Verify directory exists
let stats;
try {
stats = await fs.stat(targetPath);
} catch {
throw new RequestError(-32002, `Directory not found: ${targetPath}`, {
errorKind: 'directory_not_found',
path: targetPath,
});
}
if (!stats.isDirectory()) {
throw new RequestError(-32002, `Not a directory: ${targetPath}`, {
errorKind: 'directory_not_found',
path: targetPath,
});
}

// Canonicalize path
const canonicalPath = await fs.realpath(targetPath);

// Noop check
const previousCwd = config.getTargetDir();
if (canonicalPath === previousCwd) {
return { previousCwd, newCwd: canonicalPath, warnings: [] };
}

// Trust check
if (isFolderTrustEnabled(this.settings.merged)) {
const trustedFolders = loadTrustedFolders();
if (trustedFolders.isPathTrusted(canonicalPath) !== true) {
throw new RequestError(
-32001,
`Directory not trusted: ${canonicalPath}`,
{ errorKind: 'directory_not_trusted', path: canonicalPath },
);
}
}

// Relocate working directory (skip process.chdir and artifact
// migration for ACP — storage stays at the bound workspace so
// branch/load/lifecycle paths remain consistent).
const warnings: string[] = [];
const relocation = await config.relocateWorkingDirectory(

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[Critical] relocateWorkingDirectory moves the live session artifacts into Storage(canonicalPath), but bridge lifecycle paths still operate against the daemon-bound workspace. For example, sessionBranch sends cwd: boundWorkspace and the agent forks via new SessionService(cwd), so after /cd the source transcript can have been moved away from the storage root that branch/load later inspect. Please either avoid moving transcript storage for ACP logical cwd changes, or route branch/load/lifecycle operations through the session's current storage root.

— GPT-5 via Qwen Code /review

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[qwen] Fixed in 6bfc526.

Added skipArtifactMigration: true to the ACP /cd call to relocateWorkingDirectory. When this option is set, the method skips prepareSessionArtifactMigration and does NOT change this.storage — artifacts (transcripts, recordings) remain in the original bound-workspace storage location. Only the logical cwd (this.cwd, this.targetDir) and runtime context (workspace roots, memory, file caches) are updated.

This means branch/load/lifecycle operations continue to find artifacts at the daemon-bound workspace's storage root, eliminating the state divergence.

canonicalPath,
canonicalPath,
{ skipProcessChdir: true, skipArtifactMigration: true },

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[Suggestion] With skipProcessChdir: true, process.cwd() remains at the server's launch directory while config.cwd/config.targetDir point to the new session directory. Multiple code paths across packages/core use process.cwd() directly — e.g., prompts.ts:293 (git repository detection for system prompts), rule-parser.ts:1039-1040 (permission rule path context), destructiveCommands.ts:166 (destructive command CWD default). After ACP /cd, these resolve against the wrong directory.

The interactive /cd (cdCommand.ts) doesn't have this problem because it calls process.chdir(). Before this PR, ACP sessions never changed CWD, so process.cwd() was always correct for them.

Consider auditing direct process.cwd() usages in the prompt/permission/tool pipeline and migrating them to config.getCwd() or config.getTargetDir(). Alternatively, document this as a known limitation and track in a follow-up issue.

— qwen3.7-max via Qwen Code /review

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[qwen] Acknowledged — this is a valid known limitation.

With skipProcessChdir: true, process.cwd() stays at the daemon's launch directory while config.cwd / config.targetDir point to the session's new logical directory. The affected code paths (prompts.ts git repo detection, rule-parser.ts permission context, destructiveCommands.ts CWD default) all run inside the ACP child process which shares process.cwd() across sessions — changing it would break other sessions.

This is by design for now: the ACP /cd is a "logical cwd change" — tool execution and file resolution use config.getCwd() (already correct), while the few process.cwd() callsites that matter for ACP sessions should be migrated to config.getCwd() in a follow-up. Will file a tracking issue.

);
if (relocation.memoryRefreshError) {
warnings.push(
`Memory refresh failed: ${
relocation.memoryRefreshError instanceof Error
? relocation.memoryRefreshError.message
: String(relocation.memoryRefreshError)
}`,
);
}

// Update model context
try {
await config
.getGeminiClient()
?.addWorkingDirectoryChangedContext(previousCwd, canonicalPath);
} catch (error) {
warnings.push(
`Model context refresh failed: ${
error instanceof Error ? error.message : String(error)
}`,
);
}

return { previousCwd, newCwd: canonicalPath, warnings };
}
case SERVE_CONTROL_EXT_METHODS.sessionApprovalMode: {
const sessionId = params['sessionId'];
const mode = params['mode'];
Expand Down
1 change: 1 addition & 0 deletions packages/cli/src/serve/acp-session-bridge.ts
Original file line number Diff line number Diff line change
Expand Up @@ -81,6 +81,7 @@ export type {

export {
BranchWhilePromptActiveError,
CdWhilePromptActiveError,
SessionNotFoundError,
RestoreInProgressError,
InvalidSessionScopeError,
Expand Down
33 changes: 33 additions & 0 deletions packages/cli/src/serve/routes/session.ts
Original file line number Diff line number Diff line change
Expand Up @@ -308,6 +308,39 @@ export function registerSessionRoutes(
}
});

app.post('/session/:id/cd', mutate(), async (req, res) => {
const sessionId = requireSessionId(req, res);
if (sessionId === null) return;
const body = safeBody(req);
const targetPath = body['path'];
if (
typeof targetPath !== 'string' ||
targetPath.length === 0 ||
!path.isAbsolute(targetPath)
) {
res.status(400).json({
error: '`path` is required and must be an absolute path',
code: 'invalid_path',
});
return;
}
const clientId = parseClientIdHeader(req, res);
if (clientId === null) return;
try {
const result = await bridge.changeSessionCwd(
sessionId,
{ path: targetPath },
clientId !== undefined ? { clientId } : undefined,
);
res.status(200).json(result);
} catch (err) {
sendBridgeError(res, err, {
route: 'POST /session/:id/cd',
sessionId,
});
}
});

app.get('/session/:id/status', (req, res) => {
const sessionId = requireSessionId(req, res);
if (sessionId === null) return;
Expand Down
Loading
Loading