Reuse Host workspaces when Windows path casing differs.

Match workspace.list entries case-insensitively on Win32 so D: vs d: no longer misses an existing workspace and falls through to SESSION_CREATE.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
oliver 2026-09-16 21:27:39 +08:00
parent 7cdf1d6b94
commit f1c9abfd1d
6 changed files with 294 additions and 223 deletions

View file

@ -8,6 +8,9 @@ This file records the notable changes in each dsh-im release. Its format follows
### Fixed / 修复
- Windows 下机器人工作区路径与 Host `workspace.list` 仅盘符/路径大小写不同(如 `D:\…` vs `d:\…`)时,不再因严格字符串匹配漏掉已有工作区而去 `workspace.create` / `session.create`,避免误报 `SESSION_CREATE`。
On Windows, when the bot workspace path differs from a Host `workspace.list` entry only by drive/path casing (e.g. `D:\…` vs `d:\…`), IM now reuses that workspace instead of missing it via exact string match and falling through to `workspace.create` / `session.create` (which surfaced as `SESSION_CREATE`).
- `session.create` / `workspace.create` 的 Host `internal` 错误不再笼统显示 `HARNESS_SERVICE`:分别映射为 `SESSION_CREATE` / `WORKSPACE_UNAVAILABLE`,并写入 `%USERPROFILE%\\.dsh\\integrations\\dsh-im-ops\\last-message-failure.json`(含 method 与脱敏诊断),便于现场定位工作区路径问题。
Host `internal` failures on `session.create` / `workspace.create` no longer collapse to generic `HARNESS_SERVICE`: they map to `SESSION_CREATE` / `WORKSPACE_UNAVAILABLE`, and write `%USERPROFILE%\\.dsh\\integrations\\dsh-im-ops\\last-message-failure.json` (method + redacted diagnostics) for field triage of workspace path issues.

View file

@ -571,7 +571,7 @@ var React26 = __toESM(require("react"), 1);
// package.json
var package_default = {
name: "dsh-im-ops",
version: "4.9.1-ops.33",
version: "4.9.1-ops.34",
description: "Ops fork of dsh-im (all channels kept). Based on @xmanrui/dsh-im@4.9.1 - access/session policies owned here.",
keywords: [
"deepseek-harness",

File diff suppressed because one or more lines are too long

View file

@ -1,6 +1,6 @@
{
"name": "dsh-im-ops",
"version": "4.9.1-ops.33",
"version": "4.9.1-ops.34",
"description": "Ops fork of dsh-im (all channels kept). Based on @xmanrui/dsh-im@4.9.1 - access/session policies owned here.",
"keywords": [
"deepseek-harness",

View file

@ -213,13 +213,34 @@ function workspacePaths(value) {
));
}
/**
* Windows treats drive/path casing as equivalent; Host may store `d:\…`
* while the bot UI persists `D:\…`. Match like Win32, not POSIX string equality.
*/
function sameWorkspacePath(left, right) {
if (typeof left !== 'string' || typeof right !== 'string') return false;
const wanted = left.trim();
const got = right.trim();
if (!wanted || !got) return false;
const a = resolve(wanted);
const b = resolve(got);
return process.platform === 'win32'
? a.toLowerCase() === b.toLowerCase()
: a === b;
}
function findListedWorkspace(items, workspacePath) {
if (!Array.isArray(items)) return null;
return items.find((item) => sameWorkspacePath(item?.path, workspacePath)) ?? null;
}
function workspaceFromList(workspacePath, workspaceList) {
if (!Array.isArray(workspaceList?.items)
|| !Array.isArray(workspaceList?.archivedSessionIds)) {
throw new Error('Harness returned an invalid response for workspace.list');
}
const workspace = workspaceList.items.find((item) => item?.path === workspacePath);
const workspace = findListedWorkspace(workspaceList.items, workspacePath);
if (!workspace) return null;
if (!Array.isArray(workspace.sessionIds)
|| workspace.sessionIds.some((sessionId) => typeof sessionId !== 'string')) {
@ -910,7 +931,8 @@ export class HarnessClient {
async workspaceId(options = {}) {
const { workspace = this.#workspace, ...rpcOptions } = options;
const { items } = await this.rpc('workspace.list', {}, 30_000, rpcOptions);
const existing = items.find((item) => item.path === workspace);
// Reuse Host's listed entry under Win32 path-case aliases (D: vs d:).
const existing = findListedWorkspace(items, workspace);
if (existing) return existing.workspaceId;
const created = await this.rpc('workspace.create', { path: workspace }, 30_000, rpcOptions);
return created.workspace.workspaceId;
@ -989,13 +1011,6 @@ export class HarnessClient {
if (typeof sessionId !== 'string' || !sessionId) return false;
if (typeof workspacePath !== 'string' || !workspacePath.trim()) return false;
const wanted = resolve(workspacePath.trim());
const samePath = (value) => {
if (typeof value !== 'string' || !value.trim()) return false;
const got = resolve(value.trim());
return process.platform === 'win32'
? got.toLowerCase() === wanted.toLowerCase()
: got === wanted;
};
const sessionList = await this.rpc('session.list', {}, 30_000, options);
if (!sessionList || typeof sessionList !== 'object' || !Array.isArray(sessionList.items)) {
@ -1003,13 +1018,12 @@ export class HarnessClient {
}
const item = sessionList.items.find((entry) => entry?.sessionId === sessionId);
if (!item) return false;
if (typeof item.cwd === 'string' && item.cwd.trim()) return samePath(item.cwd);
if (typeof item.cwd === 'string' && item.cwd.trim()) {
return sameWorkspacePath(item.cwd, wanted);
}
const workspaceList = await this.rpc('workspace.list', {}, 30_000, options);
const workspace = workspaceFromList(wanted, workspaceList)
?? (Array.isArray(workspaceList?.items)
? workspaceList.items.find((entry) => samePath(entry?.path))
: null);
const workspace = workspaceFromList(wanted, workspaceList);
if (!workspace) return false;
return Array.isArray(workspace.sessionIds) && workspace.sessionIds.includes(sessionId);
}

View file

@ -594,6 +594,60 @@ test('HarnessClient reads the nested workspace.create response used by DSH rc.6'
assert.deepEqual(methods, ['workspace.list', 'workspace.create']);
});
test('HarnessClient reuses Host workspaces when only Windows path casing differs', async (t) => {
if (process.platform !== 'win32') {
t.skip('Win32 path-case aliasing only applies on Windows');
return;
}
const methods = [];
t.mock.method(globalThis, 'fetch', async (_url, options) => {
const request = JSON.parse(options.body);
methods.push(request.method);
assert.notEqual(request.method, 'workspace.create');
const value = request.method === 'session.list'
? {
items: [{
sessionId: 'session-one',
cwd: 'd:\\ai_code\\dsh',
}],
}
: {
items: [{
workspaceId: 'workspace-existing',
path: 'd:\\ai_code\\dsh',
sessionIds: ['session-one'],
}],
archivedSessionIds: [],
};
return {
ok: true,
async json() {
return {
type: 'server-response',
rpcId: request.rpcId,
result: { ok: true, value },
};
},
};
});
const client = new HarnessClient({
baseUrl: 'http://127.0.0.1:3080',
workspace: 'D:\\ai_code\\dsh',
agentPreset: 'standard',
autostart: false,
dshBin: 'dsh',
});
assert.equal(await client.workspaceId(), 'workspace-existing');
assert.deepEqual(methods, ['workspace.list']);
assert.equal(
await client.sessionBelongsToWorkspace('session-one', 'D:\\ai_code\\dsh'),
true,
);
});
test('HarnessClient asks do not control file-return tool availability', async () => {
const client = new HarnessClient({
baseUrl: 'http://127.0.0.1:3080',