diff --git a/desktop/src/__tests__/generalSettings.test.tsx b/desktop/src/__tests__/generalSettings.test.tsx index a0cf84d2..6491d660 100644 --- a/desktop/src/__tests__/generalSettings.test.tsx +++ b/desktop/src/__tests__/generalSettings.test.tsx @@ -1547,6 +1547,20 @@ describe('Settings > Providers tab', () => { ]) }) + it('falls back to the default provider order when stored order is missing', () => { + providerStoreState.providerOrder = undefined as unknown as string[] + + render() + + const rows = screen.getAllByRole('button', { name: 'Drag to reorder' }) + .map((handle) => handle.closest('[data-testid]')?.getAttribute('data-testid')) + expect(rows).toEqual([ + 'provider-provider-1', + 'claude-official-provider', + 'openai-official-provider', + ]) + }) + it('requires confirmation before deleting a provider', async () => { render() diff --git a/desktop/src/pages/Settings.tsx b/desktop/src/pages/Settings.tsx index 83bf956c..3b757d49 100644 --- a/desktop/src/pages/Settings.tsx +++ b/desktop/src/pages/Settings.tsx @@ -270,12 +270,16 @@ function defaultProviderOrder(providers: SavedProvider[]): string[] { ] } -function normalizeProviderOrder(providerOrder: string[], providers: SavedProvider[]): string[] { +function normalizeProviderOrder(providerOrder: string[] | undefined, providers: SavedProvider[]): string[] { const knownIds = new Set(defaultProviderOrder(providers)) const seen = new Set() const order: string[] = [] - for (const id of providerOrder.length > 0 ? providerOrder : defaultProviderOrder(providers)) { + const source = providerOrder && providerOrder.length > 0 + ? providerOrder + : defaultProviderOrder(providers) + + for (const id of source) { if (!knownIds.has(id) || seen.has(id)) continue seen.add(id) order.push(id) @@ -292,7 +296,7 @@ function normalizeProviderOrder(providerOrder: string[], providers: SavedProvide function buildProviderListItems( providers: SavedProvider[], - providerOrder: string[], + providerOrder: string[] | undefined, ): ProviderListItem[] { const savedItems = new Map( providers.map((provider) => [ diff --git a/scripts/pr/quality-contract.test.ts b/scripts/pr/quality-contract.test.ts index 128c37d9..d249a20f 100644 --- a/scripts/pr/quality-contract.test.ts +++ b/scripts/pr/quality-contract.test.ts @@ -11,12 +11,12 @@ describe('feature quality contract', () => { expect(agents).toContain('`~/.claude/settings.json` is user-owned shared state') expect(agents).toContain('persistence upgrade gate') expect(agents).toContain('Production code changes under `desktop/src`, `src/server`, `src/tools`, `src/utils`, or `adapters` must include a same-area test file') - expect(agents).toContain('Coverage is part of the feature, not an afterthought.') + expect(agents).toContain('Coverage is part of PR readiness, not an afterthought.') expect(agents).toContain('changed executable production line must meet the changed-line coverage gate') expect(agents).toContain('E2E is required when the feature crosses process boundaries') expect(agents).toContain('AI agents must include this evidence') expect(agents).toContain('Unified local entrypoint: `bun run verify`') - expect(agents).toContain('If `bun run verify` fails, do not stop at reporting the failure') + expect(agents).toContain('If `bun run verify` is intentionally run and fails, do not stop at reporting the failure') }) test('keeps PR authors accountable for tests, coverage, E2E, and risk', () => { diff --git a/src/server/__tests__/workspace-service.test.ts b/src/server/__tests__/workspace-service.test.ts index 8ac31c59..0b0dc9a6 100644 --- a/src/server/__tests__/workspace-service.test.ts +++ b/src/server/__tests__/workspace-service.test.ts @@ -115,6 +115,19 @@ describe('WorkspaceService outside-workspace preview', () => { await expect(service.readFile('session-1', unrelatedFile)).rejects.toThrow(/outside workspace/) }) + + it('does not allow relative traversal through a registered outside dir', async () => { + const baseDir = await makeTempDir('workspace-service-base-') + const workDir = path.join(baseDir, 'work') + const outsideFile = path.join(baseDir, 'outside.txt') + await fs.mkdir(workDir) + await fs.writeFile(outsideFile, 'secret\n') + registerFilesystemAccessRoot(baseDir) + + const service = new WorkspaceService(async (sessionId) => sessionId === 'session-1' ? workDir : null) + + await expect(service.readFile('session-1', '../outside.txt')).rejects.toThrow(/outside workspace/) + }) }) describe('WorkspaceService', () => { diff --git a/src/server/services/workspaceService.ts b/src/server/services/workspaceService.ts index 8e339e37..eda596e0 100644 --- a/src/server/services/workspaceService.ts +++ b/src/server/services/workspaceService.ts @@ -1065,7 +1065,7 @@ export class WorkspaceService { // registered as access roots when the turn checkpoint is built. Preview // those by absolute path — they have no workspace-relative form — instead // of rejecting them as out-of-sandbox. - if (isWithinRegisteredFilesystemRoot(absolutePath)) { + if (this.isAbsoluteRequestPath(requestedPath) && isWithinRegisteredFilesystemRoot(absolutePath)) { return this.resolveOutsideWorkspacePath(absolutePath, requestedPath) } throw new Error(`Path is outside workspace: ${requestedPath}`) @@ -1192,6 +1192,11 @@ export class WorkspaceService { return isSameOrInsidePathForPlatform(targetPath, rootPath) } + private isAbsoluteRequestPath(requestedPath: string): boolean { + const pathApi = process.platform === 'win32' ? path.win32 : path + return pathApi.isAbsolute(normalizeDriveRootPathForPlatform(requestedPath)) + } + private normalizeRelativePath(filePath: string): string { if (!filePath || filePath === '.') return '' return filePath.split(path.sep).join('/')