fix: restore release gate checks

Keep provider ordering compatible with older desktop store state, keep workspace traversal blocked even when external changed-file roots are registered, and align the quality-contract test with the current AGENTS wording.

Tested: bun test scripts/pr/quality-contract.test.ts

Tested: cd desktop && bun run test -- src/__tests__/generalSettings.test.tsx src/__tests__/skillsSettings.test.tsx src/__tests__/pluginsSettings.test.tsx src/__tests__/diagnosticsSettings.test.tsx --run

Tested: bun run check:policy

Tested: bun run check:desktop

Tested: bun test src/server/__tests__/workspace-service.test.ts -t 'does not allow relative traversal'

Tested: bun test src/server/__tests__/sessions.test.ts -t 'workspace/file and tree should reject traversal|workspace/diff should reject traversal' --timeout=20000

Confidence: high

Scope-risk: narrow
This commit is contained in:
程序员阿江(Relakkes) 2026-06-13 12:09:57 +08:00
parent 7c37384ebc
commit 84bcdb5f52
5 changed files with 42 additions and 6 deletions

View File

@ -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(<Settings />)
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 () => { it('requires confirmation before deleting a provider', async () => {
render(<Settings />) render(<Settings />)

View File

@ -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<string>(defaultProviderOrder(providers)) const knownIds = new Set<string>(defaultProviderOrder(providers))
const seen = new Set<string>() const seen = new Set<string>()
const order: string[] = [] 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 if (!knownIds.has(id) || seen.has(id)) continue
seen.add(id) seen.add(id)
order.push(id) order.push(id)
@ -292,7 +296,7 @@ function normalizeProviderOrder(providerOrder: string[], providers: SavedProvide
function buildProviderListItems( function buildProviderListItems(
providers: SavedProvider[], providers: SavedProvider[],
providerOrder: string[], providerOrder: string[] | undefined,
): ProviderListItem[] { ): ProviderListItem[] {
const savedItems = new Map( const savedItems = new Map(
providers.map((provider) => [ providers.map((provider) => [

View File

@ -11,12 +11,12 @@ describe('feature quality contract', () => {
expect(agents).toContain('`~/.claude/settings.json` is user-owned shared state') expect(agents).toContain('`~/.claude/settings.json` is user-owned shared state')
expect(agents).toContain('persistence upgrade gate') 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('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('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('E2E is required when the feature crosses process boundaries')
expect(agents).toContain('AI agents must include this evidence') expect(agents).toContain('AI agents must include this evidence')
expect(agents).toContain('Unified local entrypoint: `bun run verify`') 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', () => { test('keeps PR authors accountable for tests, coverage, E2E, and risk', () => {

View File

@ -115,6 +115,19 @@ describe('WorkspaceService outside-workspace preview', () => {
await expect(service.readFile('session-1', unrelatedFile)).rejects.toThrow(/outside workspace/) 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', () => { describe('WorkspaceService', () => {

View File

@ -1065,7 +1065,7 @@ export class WorkspaceService {
// registered as access roots when the turn checkpoint is built. Preview // registered as access roots when the turn checkpoint is built. Preview
// those by absolute path — they have no workspace-relative form — instead // those by absolute path — they have no workspace-relative form — instead
// of rejecting them as out-of-sandbox. // of rejecting them as out-of-sandbox.
if (isWithinRegisteredFilesystemRoot(absolutePath)) { if (this.isAbsoluteRequestPath(requestedPath) && isWithinRegisteredFilesystemRoot(absolutePath)) {
return this.resolveOutsideWorkspacePath(absolutePath, requestedPath) return this.resolveOutsideWorkspacePath(absolutePath, requestedPath)
} }
throw new Error(`Path is outside workspace: ${requestedPath}`) throw new Error(`Path is outside workspace: ${requestedPath}`)
@ -1192,6 +1192,11 @@ export class WorkspaceService {
return isSameOrInsidePathForPlatform(targetPath, rootPath) 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 { private normalizeRelativePath(filePath: string): string {
if (!filePath || filePath === '.') return '' if (!filePath || filePath === '.') return ''
return filePath.split(path.sep).join('/') return filePath.split(path.sep).join('/')