Skip to content
Merged
Show file tree
Hide file tree
Changes from 7 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
61 changes: 61 additions & 0 deletions packages/acp-bridge/src/bridge.sandbox.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,61 @@
/**
* @license
* Copyright 2025 Qwen Team
* SPDX-License-Identifier: Apache-2.0
*/

import { afterEach, describe, expect, it, vi } from 'vitest';
import { makeBridge, makeChannel } from './internal/testUtils.js';
import { _setSandboxMountExistsForTest } from './workspacePaths.js';

// #7139 wiring for the bridge ingestion site: `resolveWorkspaceKey` guards
// with `path.isAbsolute` before canonicalizing, so a Windows-shaped
// `workspaceCwd` arriving via spawnOrAttach (clients, persisted
// registrations) must be translated to its bind mount first — the sibling
// of the dispatch/request-helpers/route/boot-validator wiring tests.
describe('bridge spawnOrAttach inside a POSIX container sandbox (#7139)', () => {
afterEach(() => {
vi.unstubAllEnvs();
_setSandboxMountExistsForTest(undefined);
});

it.skipIf(process.platform === 'win32')(
'accepts a Windows-shaped workspaceCwd bound to its mount location',
async () => {
vi.stubEnv('SANDBOX', 'qwen-code-sandbox-0');
_setSandboxMountExistsForTest((p) => p === '/c/qwen-repro');
const handle = makeChannel();
const bridge = makeBridge({
boundWorkspace: '/c/qwen-repro',
channelFactory: vi.fn().mockResolvedValue(handle.channel),
});
try {
const session = await bridge.spawnOrAttach({
workspaceCwd: 'C:\\qwen-repro',
});
expect(session.sessionId).toBeTruthy();
} finally {
await bridge.shutdown();
}
},
);

it.skipIf(process.platform === 'win32')(
'still rejects a Windows-shaped workspaceCwd outside a sandbox',
async () => {
vi.stubEnv('SANDBOX', '');
const handle = makeChannel();
const bridge = makeBridge({
boundWorkspace: '/c/qwen-repro',
channelFactory: vi.fn().mockResolvedValue(handle.channel),
});
try {
await expect(
bridge.spawnOrAttach({ workspaceCwd: 'C:\\qwen-repro' }),
).rejects.toThrow('workspaceCwd must be an absolute path');
} finally {
await bridge.shutdown();
}
},
);
});
12 changes: 10 additions & 2 deletions packages/acp-bridge/src/bridge.ts
Original file line number Diff line number Diff line change
Expand Up @@ -82,7 +82,10 @@ import {
SessionBusyError,
InvalidRewindTargetError,
} from './bridgeErrors.js';
import { canonicalizeWorkspace } from './workspacePaths.js';
import {
canonicalizeWorkspace,
translateWindowsWorkspaceForPosixSandbox,
} from './workspacePaths.js';
import { parseSessionSource } from './session-source.js';
import {
CHANNEL_STARTUP_PROFILE_META_KEY,
Expand Down Expand Up @@ -2845,7 +2848,12 @@ export function createAcpSessionBridge(opts: BridgeOptions): AcpSessionBridge {
return entry.transportClosedReject;
};

const resolveWorkspaceKey = (workspaceCwd: string): string => {
const resolveWorkspaceKey = (rawWorkspaceCwd: string): string => {
// #7139: host-shaped Windows paths reach the in-container bridge via
// clients and persisted registrations; map to the bind mount before
// the absolute-path guard (no-op outside a POSIX container sandbox).
const workspaceCwd =
translateWindowsWorkspaceForPosixSandbox(rawWorkspaceCwd);

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.

Added in e9c3b00 — bridge.sandbox.test.ts drives the real spawnOrAttach (fake channel harness) with workspaceCwd: 'C:\\qwen-repro' and boundWorkspace: '/c/qwen-repro': inside a sandbox (stubbed SANDBOX + mount seam) the session resolves; outside one the exact workspaceCwd must be an absolute path rejection is pinned. All five ingestion sites now have wiring coverage. 中文:已补第五个入口的接线测试——真实 spawnOrAttach 经 resolveWorkspaceKey 守卫,沙箱内解析成功、沙箱外钉住绝对路径拒绝。五个入口全部覆盖。

if (!path.isAbsolute(workspaceCwd)) {

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.

Implemented in 3675d96 — the ordering now has a single enforcement point: translateAndCheckAbsoluteWorkspacePath in workspacePaths.ts (translate, then platform-aware isAbsolute; returns the translated path or null), and all five ingestion sites consume it while keeping their own error surfaces (boot Error, AcpParamError, the two HTTP 400 shapes). A sixth endpoint now has one obvious call to make and cannot get the ordering wrong. Behavior unchanged — all five wiring tests pass untouched, plus a new unit matrix for the helper. acp-bridge 870/870, fast-path bundle guard passes.

中文:已在 3675d96 落地——顺序收敛到唯一强制点 translateAndCheckAbsoluteWorkspacePath(先翻译后平台感知 isAbsolute,返回翻译路径或 null),五个入口全部改用它并保留各自错误面。第六个入口只需调用一个函数,不可能再弄错顺序。行为不变:五个接线测试原样通过,另加 helper 单测矩阵;fast-path 守卫通过。

throw new Error(
`workspaceCwd must be an absolute path; got "${workspaceCwd}"`,
Expand Down
52 changes: 52 additions & 0 deletions packages/acp-bridge/src/workspacePaths.sandbox.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,52 @@
/**
* @license
* Copyright 2025 Qwen Team
* SPDX-License-Identifier: Apache-2.0
*/

import { afterEach, describe, expect, it, vi } from 'vitest';
import { canonicalizeWorkspace } from './workspacePaths.js';

// Isolated from workspacePaths.test.ts because it mocks node:fs — the other
// file drives real filesystem fixtures.
vi.mock('node:fs', async (importOriginal) => {
const actual = (await importOriginal()) as typeof import('node:fs');
return {
...actual,
existsSync: (p: unknown) =>
p === '/c/qwen-repro' ? true : actual.existsSync(p as never),
};
});

describe('canonicalizeWorkspace inside a POSIX container sandbox (#7139)', () => {
afterEach(() => {
vi.unstubAllEnvs();
});

it.skipIf(process.platform === 'win32')(
'resolves a Windows-shaped workspace to its bind-mount location',
() => {
vi.stubEnv('SANDBOX', 'qwen-code-sandbox-0');
// The mount exists (mocked), realpath on it ENOENTs on this host, so
// the fallback returns the translated absolute path — NOT the cwd
// concatenation `path.resolve` alone would produce.
expect(canonicalizeWorkspace('C:\\qwen-repro')).toBe('/c/qwen-repro');
},
);

it.skipIf(process.platform === 'win32')(
'keeps Windows-shaped input untouched outside a sandbox',
() => {
// Explicitly clear SANDBOX: when this suite itself runs inside the
// project's Docker sandbox the launcher sets it, and unstubAllEnvs
// would not remove a genuinely inherited value.
vi.stubEnv('SANDBOX', '');
const result = canonicalizeWorkspace('C:\\qwen-repro');
Comment on lines +37 to +44

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.

Fixed in 60705e1 — the outside-a-sandbox tests (both this file and the REST-helper sibling) now stub SANDBOX to '' explicitly, exactly for the inherited-env case you describe. 中文:两处 outside-a-sandbox 测试都显式 stubEnv('SANDBOX',''),覆盖套件本身跑在项目 Docker 沙箱内继承真实 SANDBOX 的情形。

// Unsandboxed POSIX behavior is unchanged: the string resolves
// relative to the cwd (and stays broken — which is what the
// pre-#7139 sandbox path produced too).
expect(result.endsWith('C:\\qwen-repro')).toBe(true);
expect(result.startsWith('/')).toBe(true);
},
);
});
97 changes: 96 additions & 1 deletion packages/acp-bridge/src/workspacePaths.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,10 @@ import { promises as fsp, realpathSync } from 'node:fs';
import * as os from 'node:os';
import * as path from 'node:path';
import { afterEach, describe, expect, it } from 'vitest';
import { canonicalizeWorkspaces } from './workspacePaths.js';
import {
canonicalizeWorkspaces,
translateWindowsWorkspaceForPosixSandbox,
} from './workspacePaths.js';

const scratches: string[] = [];

Expand Down Expand Up @@ -46,3 +49,95 @@ describe('canonicalizeWorkspaces', () => {
expect(canonicalizeWorkspaces([])).toEqual([]);
});
});

// Regression for #7139: a Windows host relaunching `qwen serve` into a Linux
// Docker sandbox forwards `--workspace C:\…` (and client/persisted workspace
// registrations) in host shape; every ACP child then failed with
// `chdir(2) ENOENT`. These tests pin the container-side translation.
describe('translateWindowsWorkspaceForPosixSandbox', () => {
const sandboxOpts = (exists: boolean) => ({
platform: 'linux' as NodeJS.Platform,
sandboxEnv: 'qwen-code-sandbox-0',
exists: () => exists,
});

it('maps a Windows-absolute path to its bind-mount location', () => {
expect(
translateWindowsWorkspaceForPosixSandbox(
'C:\\qwen-repro',
sandboxOpts(true),
),
).toBe('/c/qwen-repro');
expect(
translateWindowsWorkspaceForPosixSandbox(
'D:/Work/proj sub',
sandboxOpts(true),
),
).toBe('/d/Work/proj sub');
expect(
translateWindowsWorkspaceForPosixSandbox(
'C:\\nested\\dir\\leaf',
sandboxOpts(true),
),
).toBe('/c/nested/dir/leaf');
});

it('leaves the path alone when the translated mount does not exist', () => {
expect(
translateWindowsWorkspaceForPosixSandbox(
'C:\\qwen-repro',
sandboxOpts(false),
),
).toBe('C:\\qwen-repro');
});

it('refuses ..-laden input that escapes the drive mount', () => {
// existsSync would resolve /c/../../etc to /etc and return true — the
// guard must refuse before the probe can bless an out-of-mount path.
expect(
translateWindowsWorkspaceForPosixSandbox(
'C:\\..\\..\\etc',
sandboxOpts(true),
),
).toBe('C:\\..\\..\\etc');
// In-mount .. that stays under the drive prefix is still fine.
expect(
translateWindowsWorkspaceForPosixSandbox(
'C:\\work\\..\\proj',
sandboxOpts(true),
),
).toBe('/c/work/../proj');
});

it('leaves non-Windows-shaped paths alone', () => {
for (const p of ['/c/qwen-repro', 'relative/dir', 'C:', 'CC:\\x', '']) {
expect(
translateWindowsWorkspaceForPosixSandbox(p, sandboxOpts(true)),
).toBe(p);
}
});

it('is inert on Windows hosts, outside sandboxes, and under seatbelt', () => {
expect(
translateWindowsWorkspaceForPosixSandbox('C:\\qwen-repro', {
platform: 'win32',
sandboxEnv: 'qwen-code-sandbox-0',
exists: () => true,
}),
).toBe('C:\\qwen-repro');
expect(
translateWindowsWorkspaceForPosixSandbox('C:\\qwen-repro', {
platform: 'linux',
sandboxEnv: undefined,
exists: () => true,
}),
).toBe('C:\\qwen-repro');
expect(
translateWindowsWorkspaceForPosixSandbox('C:\\qwen-repro', {
platform: 'darwin',
sandboxEnv: 'sandbox-exec',
exists: () => true,
}),
).toBe('C:\\qwen-repro');
});
});
77 changes: 75 additions & 2 deletions packages/acp-bridge/src/workspacePaths.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,9 +4,78 @@
* SPDX-License-Identifier: Apache-2.0
*/

import { realpathSync } from 'node:fs';
import { existsSync, realpathSync } from 'node:fs';
import * as path from 'node:path';

const WINDOWS_ABSOLUTE_PATH_RE = /^([A-Za-z]):[\\/](.*)$/;

/**
* Maps a Windows-shaped absolute path to the container mount produced by the
* host-side sandbox launcher (`C:\work\proj` → `/c/work/proj`, mirroring
* `getContainerPath` in `cli/src/utils/sandbox.ts`).
*
* A Windows host relaunching `qwen serve` into a Linux Docker/Podman sandbox
* translates the bind mount and `--workdir`, but path-valued CLI arguments
* (`--workspace C:\…`), client-registered workspaces, and persisted
* registrations reach the in-container daemon in host shape. Left alone,
* `path.resolve` on Linux mangles them further (prepends the cwd) and every
* ACP child spawn fails with `chdir(2) ENOENT` before running anything
* (#7139).
*
* Deliberately conservative — the input is returned unchanged unless ALL of:
* - the daemon is running on POSIX inside a container sandbox (`SANDBOX` env
* set by the launcher; macOS `sandbox-exec` does not remap paths and is
* excluded),
* - the path is Windows-absolute (`<drive>:\…` or `<drive>:/…`),
* - the translated candidate actually exists (i.e. the drive really is
* mounted the way the launcher mounts workspaces).
*
* The `opts` seams exist for tests; production callers use the defaults.
*/
// Test-only override for the mount-existence probe: the translated target
// (`/c/…`) sits at the filesystem root, which tests cannot create, and
// cross-package node:fs mocks don't reach this module's binding. Follows
// the `_reset*ForTest` convention.
let sandboxMountExistsOverrideForTest: ((p: string) => boolean) | undefined;
export function _setSandboxMountExistsForTest(
fn?: (p: string) => boolean,
): void {
sandboxMountExistsOverrideForTest = fn;
}

export function translateWindowsWorkspaceForPosixSandbox(
p: string,
opts: {
platform?: NodeJS.Platform;
sandboxEnv?: string | undefined;
exists?: (candidate: string) => boolean;
} = {},
): string {
const platform = opts.platform ?? process.platform;
const sandboxEnv =
'sandboxEnv' in opts ? opts.sandboxEnv : process.env['SANDBOX'];
const exists = opts.exists ?? sandboxMountExistsOverrideForTest ?? existsSync;
if (platform === 'win32' || !sandboxEnv || sandboxEnv === 'sandbox-exec') {
return p;
}
const match = WINDOWS_ABSOLUTE_PATH_RE.exec(p);
if (!match) return p;
const translated = `/${match[1]!.toLowerCase()}/${match[2]!.replace(/\\/g, '/')}`;
// `..` segments would let the existence probe resolve outside the drive
// mount (`C:\..\..\etc` -> existsSync('/c/../../etc') === true via /etc),
// making the "translated candidate actually exists" contract a lie. Not a
// privilege escalation (downstream workspace binding still rejects it),
// but refuse to translate anything that escapes the drive prefix.
const resolvedTranslated = path.resolve(translated);
if (
resolvedTranslated !== `/${match[1]!.toLowerCase()}` &&
!resolvedTranslated.startsWith(`/${match[1]!.toLowerCase()}/`)
) {
return p;
}
return exists(resolvedTranslated) ? translated : p;
}

/**
* Canonicalize a workspace path so the boot-time bound path and every
* request's `workspaceCwd` collapse to the same key. `path.resolve`
Expand Down Expand Up @@ -40,7 +109,11 @@ import * as path from 'node:path';
* at the original location.
*/
export function canonicalizeWorkspace(p: string): string {
const resolved = path.resolve(p);
// #7139: inside a Linux container sandbox, host-shaped Windows workspace
// paths must be mapped to their bind-mount location BEFORE resolution —
// `path.resolve('C:\\x')` on POSIX treats the whole string as relative
// and prepends the cwd.
const resolved = path.resolve(translateWindowsWorkspaceForPosixSandbox(p));

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.

Confirmed and fixed in 3046e3b — the finding was exactly right: I verified validateAndCanonicalizeWorkspace (and the four sibling sites) guard with path.isAbsolute before ever reaching canonicalizeWorkspace, so the translation was unreachable on the real ingestion paths and the wiring test was only exercising the choke point in isolation.

translateWindowsWorkspaceForPosixSandbox is now applied ahead of the guard at each named site: validateAndCanonicalizeWorkspace (boot argv, per your suggested shape — the error messages keep the operator's original input), parseOptionalWorkspaceCwd in both request-helpers.ts and the ACP dispatch.ts, resolveWorkspaceKey in bridge.ts, and the dynamic registration route in workspace-management.ts (its realpathSync.native(resolve(…)) downstream also switched to the translated value). The canonicalizeWorkspace-level translation stays as a defense-in-depth backstop for guard-less callers.

Also added the wiring test you asked for: request-helpers.sandbox.test.ts drives the real exported REST parser through its isAbsolute guard — Windows-shaped cwd + SANDBOX env → returns /c/qwen-repro; no sandbox → still 400; unmounted drive → still 400. It fails when the call-site translation is removed (verified). The mount-existence probe is stubbed via a new _setSandboxMountExistsForTest seam because the translated /c/… target sits at the filesystem root and cross-package node:fs mocks don't reach the acp-bridge binding. acp-bridge 864/864, cli serve suites 807/807.

中文:发现完全正确——已核实五处调用点都在 canonicalizeWorkspace 之前做 isAbsolute 校验,原修复在真实路径上不可达。现已在每个调用点的守卫之前应用翻译(boot argv 按你给的形状实现、REST/ACP 两处 parseOptionalWorkspaceCwd、bridge 的 resolveWorkspaceKey、动态注册路由及其下游 realpath),choke point 内的翻译保留作纵深防御。并按要求补了穿过真实 isAbsolute 守卫的接线测试(移除调用点翻译即失败;挂载探针经新测试缝 stub,因 /c/… 位于文件系统根、跨包 node:fs mock 触不到 acp-bridge 绑定)。acp-bridge 864/864、cli serve 相关 807/807。

try {
// FIXME(stage-2): switch to `fs.promises.realpath` once the
// bridge call sites become async-friendly. This sync syscall
Expand Down
40 changes: 40 additions & 0 deletions packages/cli/src/serve/acp-http/dispatch.sandbox.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,40 @@
/**
* @license
* Copyright 2025 Qwen Team
* SPDX-License-Identifier: Apache-2.0
*/

import { afterEach, describe, expect, it, vi } from 'vitest';
import { _setSandboxMountExistsForTest } from '@qwen-code/acp-bridge/workspacePaths';
import { parseOptionalWorkspaceCwd } from './dispatch.js';

// #7139 wiring: the ACP JSON-RPC `cwd` entry point must translate a
// Windows-shaped path to its bind mount BEFORE its absolute-path guard —
// this is the dispatch-side sibling of request-helpers.sandbox.test.ts.
describe('ACP dispatch parseOptionalWorkspaceCwd inside a POSIX container sandbox (#7139)', () => {
afterEach(() => {
vi.unstubAllEnvs();
_setSandboxMountExistsForTest(undefined);
});

it.skipIf(process.platform === 'win32')(
'accepts a Windows-shaped cwd and returns its bind-mount location',
() => {
vi.stubEnv('SANDBOX', 'qwen-code-sandbox-0');
_setSandboxMountExistsForTest((p) => p === '/c/qwen-repro');
expect(
parseOptionalWorkspaceCwd({ cwd: 'C:\\qwen-repro' }, '/c/qwen-repro'),
).toBe('/c/qwen-repro');
},
);

it.skipIf(process.platform === 'win32')(
'still rejects a Windows-shaped cwd outside a sandbox',
() => {
vi.stubEnv('SANDBOX', '');
expect(() =>
parseOptionalWorkspaceCwd({ cwd: 'C:\\qwen-repro' }, '/tmp'),
).toThrow('`cwd` must be an absolute path when provided');
},
);
});
Loading
Loading