fix(cwd): resolve detached session cwd via persistence index, not host process cwd (#372)

* fix(cwd): resolve detached session cwd via persistence index, not host process cwd

sessionCwdOf fell back to process.cwd() when a session was not yet
attached and the client did not send a cwd — on Windows the host
process cwd is the DSH source root (dsh.cmd's pushd %DSH_ROOT%),
so every user-project path failed the workspace containment guard
with 'path "..." is outside workspace'. The bug surfaced when
clicking chat file links at first paint (session still hydrating)
or any flow that hit a sidebar route before the client's
fetchedCwd effect resolved.

Fix: thread ctx.get('sessionPersistence') as the fallback between
the client cwd and the host process cwd. The persistence index
holds every persisted session's header (including cwd), so a cold
(detached) session resolves its real project cwd instead of the
DSH source root. The host process cwd remains the final fallback
for deployments without persistence (tests / stripped-down hosts);
production always provides persistence.

sessionCwdOf is now async; all 8 call sites await it. The two tool
resolvers (registerTools / registerOpenTool) take an async
resolveCwd callback; the one execute path that used it
(terminal_create) is now async.

Tests: two new regression cases in smoke.spec.ts cover the cold
session via persistence path and the persistence-without-cwd
fallback. Existing stubs updated to the async resolveCwd signature.

* Address Copilot review: fix JSDoc accuracy and validate persistence cwd

- tools.ts / agent-opens.ts: correct the resolveCwd JSDoc — the resolver
  does fall back to the host process cwd as a final tier (tests /
  stripped-down hosts); the previous "never falls back" wording
  contradicted the actual sessionCwdOf contract.
- index.ts: route the persistence meta.cwd through requireAbsolute,
  matching the clientCwd path. A buggy/corrupt persistence layer that
  stored a relative cwd would otherwise flow into the workspace guard,
  be resolved against the host process cwd, and potentially recreate
  the original "outside workspace" misclassification in a harder-to-
  debug way.
- smoke.spec.ts: use resolvePath() for the cold-cwd fixture so it is
  absolute on both POSIX and Windows; add a regression test for the
  relative-cwd rejection path.
This commit is contained in:
多玩幻灵qwq
2026-08-27 21:44:04 +08:00
committed by GitHub
parent 20da6479f1
commit d71261ace3
6 changed files with 104 additions and 35 deletions
+7 -7
View File
@@ -28,7 +28,7 @@ function exec(sessionId: string): ToolRunContext {
}
/** Register the tool against a fake service + real registry. */
function mount(overrides: { prefs?: Partial<SidebarPrefs>; resolveCwd?: (sessionId: string) => string } = {}) {
function mount(overrides: { prefs?: Partial<SidebarPrefs>; resolveCwd?: (sessionId: string) => Promise<string> } = {}) {
const captured: CapturedTool[] = []
let disposeCount = 0
const ctx = {
@@ -44,7 +44,7 @@ function mount(overrides: { prefs?: Partial<SidebarPrefs>; resolveCwd?: (session
const dispose = registerOpenTool(
ctx,
registry,
overrides.resolveCwd ?? (() => '/cwd'),
overrides.resolveCwd ?? (async () => '/cwd'),
() => prefs,
)
return { captured, registry, prefs, dispose: () => { dispose(); return disposeCount } }
@@ -135,7 +135,7 @@ describe('sidebar_open tool', () => {
try {
const file = join(dir, 'a.txt')
writeFileSync(file, 'hello')
const { captured, registry } = mount({ resolveCwd: () => dir })
const { captured, registry } = mount({ resolveCwd: async () => dir })
const received: unknown[] = []
const detach = registry.attach('s1', (request) => { received.push(request) })
const tool = toolOf(captured, 'sidebar_open')
@@ -153,7 +153,7 @@ describe('sidebar_open tool', () => {
const dir = mkdtempSync(join(tmpdir(), 'sidebar-open-'))
try {
writeFileSync(join(dir, 'note.md'), '# hi')
const { captured, registry } = mount({ resolveCwd: () => dir })
const { captured, registry } = mount({ resolveCwd: async () => dir })
const tool = toolOf(captured, 'sidebar_open')
const value = await tool.execute({ target: 'note.md' }, exec('s1'))
expect(value).toMatchObject({ kind: 'file', target: join(dir, 'note.md'), title: 'note.md', delivered: false })
@@ -172,7 +172,7 @@ describe('sidebar_open tool', () => {
try {
const sub = join(dir, 'src')
mkdirSync(sub)
const { captured } = mount({ resolveCwd: () => dir })
const { captured } = mount({ resolveCwd: async () => dir })
const tool = toolOf(captured, 'sidebar_open')
const value = await tool.execute({ target: sub, title: 'Sources' }, exec('s1'))
expect(value).toEqual({ kind: 'folder', target: sub, title: 'Sources', delivered: false })
@@ -198,7 +198,7 @@ describe('sidebar_open tool', () => {
})
it('reports a missing local path instead of opening it', async () => {
const { captured } = mount({ resolveCwd: () => '/definitely/missing' })
const { captured } = mount({ resolveCwd: async () => '/definitely/missing' })
const tool = toolOf(captured, 'sidebar_open')
await expect(tool.execute({ target: '/definitely/missing/nope.txt' }, exec('s1')))
.rejects.toThrow(/does not exist/)
@@ -213,7 +213,7 @@ describe('sidebar_open tool', () => {
const dir = mkdtempSync(join(tmpdir(), 'sidebar-open-'))
try {
writeFileSync(join(dir, 'f.txt'), 'x')
const { captured: fileCaptured } = mount({ resolveCwd: () => dir, prefs: { tabsEnabled: { editor: false } } })
const { captured: fileCaptured } = mount({ resolveCwd: async () => dir, prefs: { tabsEnabled: { editor: false } } })
const fileTool = toolOf(fileCaptured, 'sidebar_open')
// A URL is unaffected by the editor gate; a path is refused with cause.
await expect(fileTool.execute({ target: 'https://example.com/' }, exec('s1'))).resolves.toMatchObject({ kind: 'url' })
+47 -1
View File
@@ -485,6 +485,7 @@ describe('git destructive operations (scratch repository)', () => {
describe('session cwd resolution over the API route', () => {
interface CtxOverrides {
sessions?: { get: (id: string) => { header: { cwd?: string } } | undefined }
sessionPersistence?: { inspect: (id: string) => Promise<{ meta: { cwd?: string } }> }
}
const mountAll = (overrides: CtxOverrides = {}): SidebarWebRoute[] => {
@@ -502,7 +503,7 @@ describe('session cwd resolution over the API route', () => {
// No settings service: the namespace registration never runs.
inject: () => () => {},
// No jobs/agents services in the smoke context: the routes degrade.
get: () => undefined,
get: (key: string) => key === 'sessionPersistence' ? overrides.sessionPersistence : undefined,
}
apply(ctx as never)
return routes
@@ -558,6 +559,51 @@ describe('session cwd resolution over the API route', () => {
expect(result.value?.cwd).toBe(process.cwd())
})
it('resolves a cold (detached) session cwd through the persistence index', async () => {
// Regression: a detached first request (session not yet attached, no
// client cwd) must resolve the cwd from the session-persistence index
// instead of the host process cwd. On Windows the host process cwd is
// the DSH source root (dsh.cmd's `pushd`), so every user-project path
// was misclassified as "outside workspace" by the realpath guard.
const coldCwd = resolvePath('/cold-project-cwd')
const route = mount({
sessionPersistence: {
inspect: async (id) => ({
meta: id === 's-cold' ? { cwd: coldCwd } : {},
}),
},
})
const result = await invoke(route, 'session.cwd', { sessionId: 's-cold' })
expect(result.ok).toBe(true)
expect(result.value?.cwd).toBe(coldCwd)
})
it('rejects a relative cwd from the persistence index', async () => {
// A buggy / corrupt persistence layer that stored a relative cwd must
// be rejected by requireAbsolute instead of flowing into the workspace
// guard, where it would be resolved against the host process cwd and
// potentially recreate the original "outside workspace" misclassification.
const route = mount({
sessionPersistence: {
inspect: async () => ({ meta: { cwd: 'relative/path' } }),
},
})
const result = await invoke(route, 'session.cwd', { sessionId: 's-bad' })
expect(result.ok).toBe(false)
expect(result.error?.message).toMatch(/invalid working directory/)
})
it('falls back to the process cwd when persistence has no cwd for the session', async () => {
const route = mount({
sessionPersistence: {
inspect: async () => ({ meta: {} }),
},
})
const result = await invoke(route, 'session.cwd', { sessionId: 's-blank' })
expect(result.ok).toBe(true)
expect(result.value?.cwd).toBe(process.cwd())
})
it('prefers the attached session header over the client summary', async () => {
const route = mount({
sessions: {
+1 -1
View File
@@ -87,7 +87,7 @@ function mount(): { captured: CapturedTool[]; registry: FakeRegistry; dispose: (
},
} as unknown as Context
const registry = new FakeRegistry()
const dispose = registerTools(ctx, registry as unknown as AgentPtyRegistry, () => '/cwd', () => ({}))
const dispose = registerTools(ctx, registry as unknown as AgentPtyRegistry, async () => '/cwd', () => ({}))
return { captured, registry, dispose: () => { dispose(); return disposeCount } }
}