diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 03f8deb..42a3e5a 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -2,18 +2,23 @@ name: CI on: push: - branches: [main] + branches: [main, 'fix/**'] pull_request: jobs: check: - runs-on: ubuntu-latest + strategy: + fail-fast: false + matrix: + os: [ubuntu-latest, windows-latest] + node: [20, 22] + runs-on: ${{ matrix.os }} steps: - uses: actions/checkout@v4 - uses: pnpm/action-setup@v4 - uses: actions/setup-node@v4 with: - node-version: 22 + node-version: ${{ matrix.node }} cache: pnpm - uses: oven-sh/setup-bun@v2 with: @@ -22,4 +27,4 @@ jobs: - run: pnpm typecheck - run: pnpm test - name: Bundle for npm and smoke it - run: pnpm build:npm && node packages/cli/bin/vibi.mjs --version + run: pnpm build:npm && node packages/cli/dist/vibi.mjs --version && node packages/cli/dist/vibi.mjs --help diff --git a/package.json b/package.json index 1529ea4..a726ce6 100644 --- a/package.json +++ b/package.json @@ -5,7 +5,7 @@ "packageManager": "pnpm@12.8.1", "scripts": { "typecheck": "pnpm -r typecheck", - "test": "tsx --test packages/shared/test/*.test.ts packages/cli/test/*.test.ts", + "test": "node scripts/test.mjs", "build:npm": "pnpm --filter vibi build:npm", "build:host": "pnpm --filter vibi build:tui:host && pnpm --filter vibi build:host", "build": "pnpm --filter vibi build:tui && pnpm --filter vibi build" diff --git a/packages/cli/src/harnesses/install.ts b/packages/cli/src/harnesses/install.ts index c5bf529..28f94a2 100644 --- a/packages/cli/src/harnesses/install.ts +++ b/packages/cli/src/harnesses/install.ts @@ -41,11 +41,18 @@ export function encodeClaudeProjectDir(projectDir: string) { } function originalBasename(metadata: TraceMetadata, fallback: string) { - const base = metadata.sourcePath ? path.basename(metadata.sourcePath) : ''; - return base && base.endsWith('.jsonl') ? base : fallback; + const base = path.win32.basename(path.posix.basename(metadata.sourcePath)); + return isSafeFilename(base) && base.endsWith('.jsonl') ? base : fallback; +} + +function isSafeFilename(value: string): boolean { + return Boolean(value) && value !== '.' && value !== '..' && + !/[<>:"/\\|?*\x00-\x1f]/.test(value) && !/[. ]$/.test(value) && + !/^(con|prn|aux|nul|com[1-9]|lpt[1-9])(?:\.|$)/i.test(value); } export function installPathFor(t: Omit): string { + if (!isSafeFilename(t.harnessSessionId)) throw new Error('Invalid harness session id for a local file'); switch (t.harness) { case 'claude': return path.join( diff --git a/packages/cli/test/install-paths.test.ts b/packages/cli/test/install-paths.test.ts new file mode 100644 index 0000000..658c88c --- /dev/null +++ b/packages/cli/test/install-paths.test.ts @@ -0,0 +1,22 @@ +import assert from 'node:assert/strict'; +import path from 'node:path'; +import { test } from 'node:test'; +import { installPathFor } from '../src/harnesses/install'; +import type { Harness } from '@vibivibi/shared/sessions'; + +const base = { harnessSessionId: 'session-id', harnessUpdatedAt: '2026-10-01T10:00:00Z', home: path.resolve('test-home'), metadata: {title: '', cwd: '', model: '', messageCount: 0, sourcePath: '', startedAt: null} }; +test('untrusted session IDs cannot escape installation directories or create invalid Windows files', () => { + for (const harness of ['claude', 'codex', 'pi', 'sc', 'opencode'] as Harness[]) { + for (const id of ['../escape', '..\\escape', '/absolute', 'C:\\absolute', '', '.', '..', 'bad\0id', 'CON', 'name:stream', 'trailing.']) { + assert.throws(() => installPathFor({...base, harness, harnessSessionId: id}), /Invalid harness session id/); + } + } +}); +test('source filenames survive Windows to Unix and Unix to Windows transfers', () => { + for (const sourcePath of ['C:\\Users\\sender\\rollout-original.jsonl', '/Users/sender/rollout-original.jsonl']) { + const file = installPathFor({...base, harness: 'codex', metadata: {...base.metadata, sourcePath}}); + assert.equal(path.basename(file), 'rollout-original.jsonl'); + assert.equal(path.dirname(file), path.join(base.home, '.codex', 'sessions', '2026', '10', '01')); + } + assert.equal(path.basename(installPathFor({...base, harness: 'pi', metadata: {...base.metadata, sourcePath: '/x/bad:name.jsonl'}})), 'session-id.jsonl'); +}); diff --git a/packages/cli/test/install.test.ts b/packages/cli/test/install.test.ts index 80bfa39..3b2a76b 100644 --- a/packages/cli/test/install.test.ts +++ b/packages/cli/test/install.test.ts @@ -17,10 +17,11 @@ after(async () => { const meta = { title: 't', cwd: '/work/x', model: '', messageCount: 1, sourcePath: '', startedAt: null }; test('Claude Code project directories are encoded the way Claude Code does it', () => { - assert.equal(encodeClaudeProjectDir('/Users/hongyinluo/Desktop/cybermind'), '-Users-hongyinluo-Desktop-cybermind'); - assert.equal(encodeClaudeProjectDir('/Users/me/my_app.v2'), '-Users-me-my-app-v2'); + const prefix = process.platform === 'win32' ? `${path.parse(process.cwd()).root[0]}--` : '-'; + assert.equal(encodeClaudeProjectDir('/Users/hongyinluo/Desktop/cybermind'), `${prefix}Users-hongyinluo-Desktop-cybermind`); + assert.equal(encodeClaudeProjectDir('/Users/me/my_app.v2'), `${prefix}Users-me-my-app-v2`); const file = installPathFor({ harness: 'claude', harnessSessionId: 'abc', harnessUpdatedAt: '2026-10-01T10:00:00Z', metadata: meta, home, projectDir: '/work/x' }); - assert.equal(file, path.join(home, '.claude', 'projects', '-work-x', 'abc.jsonl')); + assert.equal(file, path.join(home, '.claude', 'projects', `${prefix}work-x`, 'abc.jsonl')); }); test('other harnesses keep their original file names and date folders', () => { diff --git a/scripts/test.mjs b/scripts/test.mjs new file mode 100644 index 0000000..c1b7768 --- /dev/null +++ b/scripts/test.mjs @@ -0,0 +1,11 @@ +import { spawnSync } from 'node:child_process'; +import { readdirSync } from 'node:fs'; +import { fileURLToPath } from 'node:url'; + +const root = fileURLToPath(new URL('../', import.meta.url)); +const files = ['packages/shared/test', 'packages/cli/test'].flatMap((dir) => + readdirSync(new URL(`../${dir}/`, import.meta.url)).filter((name) => name.endsWith('.test.ts')).sort().map((name) => `${dir}/${name}`) +); +const result = spawnSync(process.execPath, ['--import', 'tsx', '--test', ...files], {cwd: root, stdio: 'inherit'}); +if (result.error) throw result.error; +process.exit(result.status ?? 1);