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:
@@ -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
@@ -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
@@ -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 } }
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user