Files
openwork/apps/app/scripts/session-scope.ts
ben 75532f9f77 refactor(app): branded TransportDirectory type prevents Windows path mismatch (#1257)
The server compares session.directory with strict equality, but call sites
could silently pick the wrong path formatter (normalizeDirectoryQueryPath
vs toSessionTransportDirectory) — producing forward-slash paths on Windows
where the server stores native backslashes.

Changes:
- Add TransportDirectory branded type so the compiler rejects raw strings
  where a transport-formatted directory is expected.
- Fix connections/store.ts: switch path discovery from normalizeDirectoryQueryPath
  to toSessionTransportDirectory — this value feeds mcp.status/disconnect calls
  that use the same exact-match semantics (latent Windows bug).
- Fix workspace.ts: remote directory discovery now goes through
  toSessionTransportDirectory (caught by the branded type at compile time).
- Mark normalizeDirectoryQueryPath with JSDoc deprecation for server-query use.
- Add round-trip invariant and idempotency tests that assert create-path
  equals list-path for both Unix and Windows directory formats.
2026-03-31 11:14:00 -07:00

255 lines
7.8 KiB
TypeScript

import assert from "node:assert/strict";
Object.defineProperty(globalThis, "navigator", {
configurable: true,
value: {
platform: "MacIntel",
userAgent: "Mozilla/5.0 (Macintosh; Intel Mac OS X 14_0)",
},
});
const {
describeDirectoryScope,
resolveScopedClientDirectory,
scopedRootsMatch,
shouldApplyScopedSessionLoad,
shouldRedirectMissingSessionAfterScopedLoad,
toSessionTransportDirectory,
} = await import("../src/app/lib/session-scope.ts");
const starterRoot = "/Users/test/OpenWork/starter";
const otherRoot = "/Users/test/OpenWork/second";
const results = {
ok: true,
steps: [] as Array<Record<string, unknown>>,
};
async function step(name: string, fn: () => void | Promise<void>) {
results.steps.push({ name, status: "running" });
const index = results.steps.length - 1;
try {
await fn();
results.steps[index] = { name, status: "ok" };
} catch (error) {
results.ok = false;
results.steps[index] = {
name,
status: "error",
error: error instanceof Error ? error.message : String(error),
};
throw error;
}
}
try {
await step("local connect prefers explicit target root", () => {
assert.equal(
resolveScopedClientDirectory({ workspaceType: "local", targetRoot: starterRoot }),
starterRoot,
);
assert.equal(
resolveScopedClientDirectory({
workspaceType: "local",
directory: otherRoot,
targetRoot: starterRoot,
}),
otherRoot,
);
});
await step("remote connect still waits for remote discovery", () => {
assert.equal(resolveScopedClientDirectory({ workspaceType: "remote", targetRoot: starterRoot }), "");
});
await step("scope matching is stable on desktop-style paths", () => {
assert.equal(scopedRootsMatch(`${starterRoot}/`, starterRoot.toUpperCase()), true);
assert.equal(scopedRootsMatch(starterRoot, otherRoot), false);
});
await step("stale session loads cannot overwrite another workspace sidebar", () => {
for (let index = 0; index < 50; index += 1) {
assert.equal(
shouldApplyScopedSessionLoad({
loadedScopeRoot: otherRoot,
workspaceRoot: starterRoot,
}),
false,
);
}
});
await step("same-scope session loads still update the active workspace", () => {
assert.equal(
shouldApplyScopedSessionLoad({
loadedScopeRoot: `${starterRoot}/`,
workspaceRoot: starterRoot,
}),
true,
);
});
await step("windows create and list use the same transport directory", () => {
Object.defineProperty(globalThis, "navigator", {
configurable: true,
value: {
platform: "Win32",
userAgent: "Mozilla/5.0 (Windows NT 10.0; Win64; x64)",
},
});
const winRoot = String.raw`C:\Users\Test\OpenWork\starter`;
const transport = toSessionTransportDirectory(winRoot);
assert.equal(transport, winRoot);
assert.equal(resolveScopedClientDirectory({ workspaceType: "local", targetRoot: winRoot }), transport);
assert.equal(resolveScopedClientDirectory({ workspaceType: "local", directory: winRoot }), transport);
const uncRoot = String.raw`\\?\UNC\server\share\starter`;
assert.equal(toSessionTransportDirectory(uncRoot), String.raw`\\server\share\starter`);
assert.equal(describeDirectoryScope(uncRoot).normalized, "//server/share/starter");
const verbatimDriveRoot = String.raw`\\?\C:\Users\Test\OpenWork\starter`;
assert.equal(toSessionTransportDirectory(verbatimDriveRoot), String.raw`C:\Users\Test\OpenWork\starter`);
assert.equal(describeDirectoryScope(verbatimDriveRoot).normalized, "c:/users/test/openwork/starter");
});
await step("round-trip invariant: every query path equals the create path (unix)", () => {
// Restore macOS navigator for this step.
Object.defineProperty(globalThis, "navigator", {
configurable: true,
value: {
platform: "MacIntel",
userAgent: "Mozilla/5.0 (Macintosh; Intel Mac OS X 14_0)",
},
});
const unixPaths = [
"/Users/test/OpenWork/starter",
"/Users/test/OpenWork/starter/",
"/home/user/projects/my-app",
"/tmp/sandbox",
"/private/tmp/sandbox",
];
for (const raw of unixPaths) {
const createDir = toSessionTransportDirectory(raw);
const listDir = toSessionTransportDirectory(raw);
const resolvedDir = resolveScopedClientDirectory({ workspaceType: "local", targetRoot: raw });
assert.equal(createDir, listDir, `create vs list mismatch for: ${raw}`);
assert.equal(createDir, resolvedDir, `create vs resolved mismatch for: ${raw}`);
}
});
await step("round-trip invariant: every query path equals the create path (windows)", () => {
Object.defineProperty(globalThis, "navigator", {
configurable: true,
value: {
platform: "Win32",
userAgent: "Mozilla/5.0 (Windows NT 10.0; Win64; x64)",
},
});
// Use escaped strings — Bun's parser chokes on String.raw inside array literals.
const windowsPaths = [
"C:\\Users\\Test\\OpenWork\\starter",
"C:\\Users\\Test\\OpenWork\\starter\\",
"D:\\projects\\my-app",
"\\\\server\\share\\starter",
"\\\\?\\C:\\Users\\Test\\OpenWork\\starter",
"\\\\?\\UNC\\server\\share\\starter",
];
for (const raw of windowsPaths) {
const createDir = toSessionTransportDirectory(raw);
const listDir = toSessionTransportDirectory(raw);
const resolvedDir = resolveScopedClientDirectory({ workspaceType: "local", targetRoot: raw });
assert.equal(createDir, listDir, `create vs list mismatch for: ${raw}`);
assert.equal(createDir, resolvedDir, `create vs resolved mismatch for: ${raw}`);
}
});
await step("idempotency: double-converting a transport directory is stable", () => {
// Restore macOS for Unix paths.
Object.defineProperty(globalThis, "navigator", {
configurable: true,
value: {
platform: "MacIntel",
userAgent: "Mozilla/5.0 (Macintosh; Intel Mac OS X 14_0)",
},
});
const samples = [
"/Users/test/OpenWork/starter",
"/home/user/projects/my-app",
];
for (const raw of samples) {
const once = toSessionTransportDirectory(raw);
const twice = toSessionTransportDirectory(once);
assert.equal(once, twice, `not idempotent for unix path: ${raw}`);
}
// Switch to Windows.
Object.defineProperty(globalThis, "navigator", {
configurable: true,
value: {
platform: "Win32",
userAgent: "Mozilla/5.0 (Windows NT 10.0; Win64; x64)",
},
});
const winSamples = [
"C:\\Users\\Test\\OpenWork\\starter",
"\\\\server\\share\\starter",
];
for (const raw of winSamples) {
const once = toSessionTransportDirectory(raw);
const twice = toSessionTransportDirectory(once);
assert.equal(once, twice, `not idempotent for win path: ${raw}`);
}
});
await step("route guard only redirects when the loaded scope matches", () => {
assert.equal(
shouldRedirectMissingSessionAfterScopedLoad({
loadedScopeRoot: otherRoot,
workspaceRoot: starterRoot,
hasMatchingSession: false,
}),
false,
);
assert.equal(
shouldRedirectMissingSessionAfterScopedLoad({
loadedScopeRoot: starterRoot,
workspaceRoot: starterRoot,
hasMatchingSession: false,
}),
true,
);
assert.equal(
shouldRedirectMissingSessionAfterScopedLoad({
loadedScopeRoot: starterRoot,
workspaceRoot: starterRoot,
hasMatchingSession: true,
}),
false,
);
});
console.log(JSON.stringify(results, null, 2));
} catch (error) {
results.ok = false;
console.error(
JSON.stringify(
{
...results,
error: error instanceof Error ? error.message : String(error),
},
null,
2,
),
);
process.exitCode = 1;
}