Skip to content
Merged
Show file tree
Hide file tree
Changes from 2 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
48 changes: 48 additions & 0 deletions packages/acp-bridge/src/workspacePaths.sandbox.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,48 @@
/**
* @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',
() => {
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);
},
);
});
79 changes: 78 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,77 @@ 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('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');
});
});
54 changes: 52 additions & 2 deletions packages/acp-bridge/src/workspacePaths.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,9 +4,55 @@
* 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.
*/
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 ?? 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, '/')}`;
return exists(translated) ? translated : 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.

Fixed in 60705e1 — the candidate is resolved and must stay under its drive prefix before the existence probe runs; C:\..\..\etc now passes through untranslated while in-mount .. (C:\work\..\proj) still translates. Pinned by two new tests. 中文:候选路径先 resolve 并校验仍在盘符前缀内才走存在性探针;越界 .. 原样返回、盘内 .. 正常翻译,两条新测试钉住。

}

/**
* 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 +86,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
11 changes: 11 additions & 0 deletions packages/cli/src/serve/process-env-guard.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -52,6 +52,17 @@ const allowedProcessEnvAccesses = normalizeAllowances([
accesses: { whole: 1 },
},
],
[
'packages/acp-bridge/src/workspacePaths.ts',
{
reason:
'Whether the daemon runs inside a container sandbox is process-scoped: ' +
'the sandbox launcher marks the whole process via the SANDBOX env, and ' +
'workspace canonicalization uses it to map Windows-shaped host paths ' +
'to their bind-mount location (#7139).',
accesses: { 'key:SANDBOX': 1 },
},
],
[
'packages/cli/src/serve/acp-http-enabled.ts',
{
Expand Down
Loading