From 1ffdacb2c4dd0387ecf370b0ecde41979f046126 Mon Sep 17 00:00:00 2001 From: Turtle Date: Fri, 24 Jul 2026 21:50:11 +0800 Subject: [PATCH] fix(jsonl): handle filesystem path aliases --- ...7-24-project-session-directories.i18n.yaml | 4 +-- .../2026-07-24-project-session-directories.md | 2 ++ ...26-07-24-project-session-directories.zh.md | 2 ++ .../session-persistence-jsonl/README.md | 2 +- .../session-persistence-jsonl/src/index.ts | 27 +++++++++++++++---- .../session-persistence-jsonl/src/win32.ts | 6 +++-- .../tests/jsonl.spec.ts | 19 ++++++++++++- .../tests/win32.spec.ts | 9 +++++++ 8 files changed, 60 insertions(+), 11 deletions(-) diff --git a/.agents/notes/implemented/architecture/2026-07-24-project-session-directories.i18n.yaml b/.agents/notes/implemented/architecture/2026-07-24-project-session-directories.i18n.yaml index a848e64c8f..321b958dc6 100644 --- a/.agents/notes/implemented/architecture/2026-07-24-project-session-directories.i18n.yaml +++ b/.agents/notes/implemented/architecture/2026-07-24-project-session-directories.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write -2026-07-24-project-session-directories.md: 2091027e67528855a3d9722bc63347319aada678 -2026-07-24-project-session-directories.zh.md: a161cff5ac42fb67650bdff593f91b95af471d1b +2026-07-24-project-session-directories.md: 0aa3f513d5a1bb3e44cf33a0ae1eb791ee3a46c2 +2026-07-24-project-session-directories.zh.md: f6bb1bd0ddb1067b68d1389182ce5b3397ad81fd diff --git a/.agents/notes/implemented/architecture/2026-07-24-project-session-directories.md b/.agents/notes/implemented/architecture/2026-07-24-project-session-directories.md index 2091027e67..0aa3f513d5 100644 --- a/.agents/notes/implemented/architecture/2026-07-24-project-session-directories.md +++ b/.agents/notes/implemented/architecture/2026-07-24-project-session-directories.md @@ -25,6 +25,8 @@ Raw mode uses `session.jsonl`, and sessions without a cwd use `_no-cwd`. Filesys The project key intentionally has no hash suffix. This follows the common human-readable convention used by coding agents and keeps the normalized project path as the complete directory name. The normalization is lossy: paths such as `/a/b-c` and `/a-b/c`, or long paths with the same retained prefix, share one project directory. Their distinct session ids still select separate session directories; reuse of the same session id remains a storage collision and is rejected. +Case-insensitive filesystems can also make differently cased project keys refer to one physical directory. Identity validation accepts such an alternate spelling only when filesystem canonicalization resolves the discovered and expected paths to the same transcript. A different canonical path remains corruption, so case aliases do not weaken the same-id collision check on case-sensitive stores. + The configured root remains a deployment choice. The layout neither selects a global root nor requires projects to share one. When a deployment does centralize storage, project paths remain recognizable; a project-local root uses the same deterministic structure. The encoded session id names an ownership directory rather than the transcript itself. `SessionPersistence.locate()` continues to return the fixed transcript path, preserving hook `transcript_path` and `DSH_SESSION_JSONL` semantics. Discovery ignores other entries inside the session directory so the backend can add session-owned artifacts without another layout change. diff --git a/.agents/notes/implemented/architecture/2026-07-24-project-session-directories.zh.md b/.agents/notes/implemented/architecture/2026-07-24-project-session-directories.zh.md index a161cff5ac..f6bb1bd0dd 100644 --- a/.agents/notes/implemented/architecture/2026-07-24-project-session-directories.zh.md +++ b/.agents/notes/implemented/architecture/2026-07-24-project-session-directories.zh.md @@ -25,6 +25,8 @@ JSONL 后端按可读的项目键存储会话,并为每个会话提供独立 项目键有意不带哈希后缀。这遵循 coding agent(编码智能体)常用的易读约定,使规范化后的项目路径本身就是完整的目录名。规范化过程有损:`/a/b-c` 与 `/a-b/c` 等路径,或者保留前缀相同的长路径,会共用同一个项目目录。不同的会话 id 仍会选择不同的会话目录;复用相同的会话 id 仍构成存储冲突,系统会予以拒绝。 +在不区分大小写的文件系统上,大小写不同的项目键也可能指向同一个物理目录。只有当文件系统路径规范化将发现路径和预期路径解析为同一个 transcript 时,身份验证才接受这种拼写变体。规范化后的路径如果不同,仍视为存储损坏,因此大小写别名不会让区分大小写的存储放宽同一 id 的冲突检查。 + 根目录由部署配置决定。这种布局既不选择全局根目录,也不要求项目共享根目录。部署选择集中存储时,目录名仍能让项目路径易于辨认;使用项目本地根目录时,也采用同样的确定性结构。 编码后的会话 id 用于命名归属目录,而不是 transcript(文本记录)文件本身。`SessionPersistence.locate()` 仍返回固定的 transcript 路径,从而保持钩子 `transcript_path` 和 `DSH_SESSION_JSONL` 的语义不变。发现过程会忽略会话目录中的其他条目,因此后端以后添加会话自有产物时无需再次改变布局。 diff --git a/packages/session-persistence/session-persistence-jsonl/README.md b/packages/session-persistence/session-persistence-jsonl/README.md index b90b47d3f5..a665b688ab 100644 --- a/packages/session-persistence/session-persistence-jsonl/README.md +++ b/packages/session-persistence/session-persistence-jsonl/README.md @@ -14,7 +14,7 @@ The JSONL durable session-persistence backend — a concrete `SessionPersistence - The first logical line is the immutable `SessionHeader` tagged `{ type: 'session', version, id, cwd?, createdAt, parentSession?, seedLength?, delegationDepth }`. `delegationDepth` is required on disk and is `0` for a top-level session; a missing or invalid value rejects the log. Every subsequent logical line is one storage record; `assistant/chunk` events are never dropped, and `seq` stays contiguous across the decoded log (`events[i].seq === i`). - A storage record is a `SessionEvent` JSON verbatim, or — written only under `packChunks` — a **packed chunk row** (`text-chunks` / `reasoning-chunks` / `tool-call-chunks`; bare slash-less tags like the header's `session`, so row tags cannot be confused with event types): one line holding a run of ≥3 consecutive same-block `assistant/chunk` delta events, `seq0`/`time0` plus per-member `dt` gaps reconstructing every member's `seq`/`time` exactly. The lossless codec lives in `@deepseek-ai/dsh-session` (`packChunkRuns`/`decodeStorageRecord`) and whitelists exact shapes — anything unrecognized stores verbatim. Reading is layout-blind: `load` always decodes rows, so packed, unpacked, and mixed files load identically. -- The project directory keeps the normalized cwd readable for navigation and is bounded for filesystem component limits. Separator replacement and truncation are intentionally lossy, so cwd strings that normalize alike share a project directory; session ids still select distinct session directories. The configured root remains deployment-controlled: it may be project-local, shared, temporary, or centralized. The [project-session directory decision](../../../.agents/notes/implemented/architecture/2026-07-24-project-session-directories.md) records this tradeoff. +- The project directory keeps the normalized cwd readable for navigation and is bounded for filesystem component limits. Separator replacement and truncation are intentionally lossy, so cwd strings that normalize alike share a project directory; session ids still select distinct session directories. On a case-insensitive filesystem, identity validation accepts an alternate path spelling only when filesystem canonicalization resolves both spellings to the same transcript. The configured root remains deployment-controlled: it may be project-local, shared, temporary, or centralized. The [project-session directory decision](../../../.agents/notes/implemented/architecture/2026-07-24-project-session-directories.md) records this tradeoff. - Session ids are unvalidated branded strings, so they are injectively escaped to a single safe path segment before use (no traversal, no collision). The resulting directory is reserved for additional session-owned artifacts; discovery reads only the fixed transcript filename. ## Config diff --git a/packages/session-persistence/session-persistence-jsonl/src/index.ts b/packages/session-persistence/session-persistence-jsonl/src/index.ts index ad58cfe145..69b1d371d7 100644 --- a/packages/session-persistence/session-persistence-jsonl/src/index.ts +++ b/packages/session-persistence/session-persistence-jsonl/src/index.ts @@ -9,7 +9,7 @@ import { Context } from 'cordis' import z from 'schemastery' import { readdirSync } from 'node:fs' -import { open, mkdir, readFile, readdir, link, rm, stat, truncate } from 'node:fs/promises' +import { open, mkdir, readFile, readdir, realpath, link, rm, stat, truncate } from 'node:fs/promises' import { dirname, join, resolve } from 'node:path' import { randomBytes } from 'node:crypto' import { @@ -168,7 +168,7 @@ export class SessionPersistenceJsonl extends SessionPersistence implements Persi : {}, } } - this.assertStoredIdentity(path, prefix.meta, expectedId) + await this.assertStoredIdentity(path, prefix.meta, expectedId) return prefix } @@ -291,7 +291,7 @@ export class SessionPersistenceJsonl extends SessionPersistence implements Persi if (first === undefined) continue // empty/half-written file const meta = parseHeaderMeta(first) if (meta === undefined) continue // not a session header - this.assertStoredIdentity(path, meta) + await this.assertStoredIdentity(path, meta) if (ids.has(meta.id)) { throw new Error(`duplicate JSONL session id "${meta.id}" appears in multiple project directories`) } @@ -578,7 +578,7 @@ export class SessionPersistenceJsonl extends SessionPersistence implements Persi } /** Reject metadata that does not identify the selected physical log. */ - private assertStoredIdentity(path: string, meta: SessionHeader, expectedId?: SessionId): void { + private async assertStoredIdentity(path: string, meta: SessionHeader, expectedId?: SessionId): Promise { if (expectedId !== undefined && meta.id !== expectedId) { throw new Error(`corrupt session log "${path}": requested id "${expectedId}" does not match header id "${meta.id}"`) } @@ -588,11 +588,28 @@ export class SessionPersistenceJsonl extends SessionPersistence implements Persi } catch (error) { throw new Error(`corrupt session log "${path}": header id cannot name a storage path`, { cause: error }) } - if (path !== expectedPath) { + if (path !== expectedPath && !await this.sameFile(path, expectedPath)) { throw new Error(`corrupt session log "${path}": header id "${meta.id}" and cwd identify "${expectedPath}"`) } } + /** + * Whether two path spellings resolve to the same physical file. This admits + * case aliases on case-insensitive filesystems without weakening identity + * checks on case-sensitive stores. + */ + private async sameFile(path: string, expectedPath: string): Promise { + try { + const [actual, expected] = await Promise.all([realpath(path), realpath(expectedPath)]) + return actual === expected + } catch (error) { + /* v8 ignore else -- non-ENOENT realpath failures require an external permission or I/O fault */ + if (isENOENT(error)) return false + /* v8 ignore next -- non-ENOENT realpath failures are external I/O faults, propagated unchanged */ + throw error + } + } + /** The human-readable project directories under the configured root. */ private async listProjectDirs(): Promise { try { diff --git a/packages/session-persistence/session-persistence-jsonl/src/win32.ts b/packages/session-persistence/session-persistence-jsonl/src/win32.ts index a8c1b6fb8d..5b2b034574 100644 --- a/packages/session-persistence/session-persistence-jsonl/src/win32.ts +++ b/packages/session-persistence/session-persistence-jsonl/src/win32.ts @@ -12,7 +12,7 @@ */ import { mkdtemp, rm, stat } from 'node:fs/promises' -import { basename, join, parse, resolve, toNamespacedPath } from 'node:path' +import { join, parse, resolve, toNamespacedPath } from 'node:path' type MoveFileExW = (existing: string, replacement: string, flags: number) => number type GetLastError = () => number @@ -139,7 +139,9 @@ export async function ensureDurableDirectoryWin32(target: string): Promise } async function createLeafDirectoryWin32(parent: string, target: string): Promise { - const staging = await mkdtemp(join(parent, `.dsh-mkdir-${basename(target)}-`)) + // Keep the staging component independent of the target basename so a legal + // 255-byte target component does not make mkdtemp's sibling name too long. + const staging = await mkdtemp(join(parent, '.dsh-mkdir-')) try { await publishNewFileWin32(staging, target) } catch (error) { diff --git a/packages/session-persistence/session-persistence-jsonl/tests/jsonl.spec.ts b/packages/session-persistence/session-persistence-jsonl/tests/jsonl.spec.ts index 5afa17f461..6f4da5fbfd 100644 --- a/packages/session-persistence/session-persistence-jsonl/tests/jsonl.spec.ts +++ b/packages/session-persistence/session-persistence-jsonl/tests/jsonl.spec.ts @@ -1,6 +1,6 @@ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' import { Context } from 'cordis' -import { appendFile, mkdtemp, mkdir, rm, readFile, writeFile, readdir, stat } from 'node:fs/promises' +import { appendFile, mkdtemp, mkdir, rm, readFile, writeFile, readdir, stat, symlink } from 'node:fs/promises' import { tmpdir } from 'node:os' import { isAbsolute, join, relative, resolve } from 'node:path' import SessionStore, { SessionId } from '@deepseek-ai/dsh-session' @@ -913,6 +913,23 @@ describe('SessionPersistenceJsonl: edge cases', () => { await expect(ctx.sessionPersistence.list()).rejects.toThrow(/and cwd identify/) }) + it('accepts an alternate project path only when it identifies the same physical log', async () => { + const m = meta('physical-alias', '/stored') + await ctx.sessionPersistence.create(m) + await ctx.sessionPersistence.append(m.id, oneTurnLog()) + const path = rawLogPath(root, m.cwd, m.id) + const aliasCwd = '/alias' + await symlink( + projectDir(root, m.cwd), + projectDir(root, aliasCwd), + process.platform === 'win32' ? 'junction' : 'dir', + ) + await rewriteHeader(path, (header) => { header.cwd = aliasCwd }) + + expect((await ctx.sessionPersistence.load(m.id)).meta.cwd).toBe(aliasCwd) + expect((await ctx.sessionPersistence.list()).map(header => header.id)).toContain(m.id) + }) + it('list rejects a session header whose id cannot name a storage path', async () => { const dir = join(projectDir(root, undefined), 'invalid-id') await mkdir(dir, { recursive: true }) diff --git a/packages/session-persistence/session-persistence-jsonl/tests/win32.spec.ts b/packages/session-persistence/session-persistence-jsonl/tests/win32.spec.ts index b4a2d11f28..647ff8b292 100644 --- a/packages/session-persistence/session-persistence-jsonl/tests/win32.spec.ts +++ b/packages/session-persistence/session-persistence-jsonl/tests/win32.spec.ts @@ -151,6 +151,15 @@ describe('Windows durable namespace helpers', () => { expect(existsSync(raced)).toBe(true) }) + it('keeps staging names valid for a maximum-length target component', async () => { + const { ensureDurableDirectoryWin32 } = await importWithFilesystemMove() + const root = await tempRoot() + const target = join(root, 'x'.repeat(255)) + + await ensureDurableDirectoryWin32(target) + expect(existsSync(target)).toBe(true) + }) + it('surfaces directory publication failures other than an existing-target race', async () => { const { ensureDurableDirectoryWin32 } = await importWithError(ERROR_ACCESS_DENIED) const root = await tempRoot()