Files
Codeman/test/file-editing-policy.test.ts
T
Codeman maintainer 4ea781c80f feat(file-viewer): edit mode for text files (edit + save in the viewer)
Closes #212. The file-preview overlay can now edit workspace text files in
place, phone-first: agent writes a file, you review it in the viewer, tweak
two lines, save, tell the agent to continue.

Backend (file-routes.ts, policy in src/config/file-editing.ts):
- GET file-content?edit=1: read-for-edit that never truncates (a truncated
  buffer must never become an edit buffer), 512KB cap (413 over it), and
  returns the sha256 hash + detected EOL the client echoes back on save.
- PUT /api/sessions/:id/file-content: edit-in-place only, with no O_CREAT
  anywhere in the handler. Confinement matches the read path (realpath +
  workspace boundary + ownership via findSessionOrFail), plus sensitive-path
  and attachment-guard blocklists, a .git subtree deny, and an extension
  allowlist (svg and env deliberately excluded). Optimistic concurrency via
  baseHash: mismatch is a 409 unless force. Writes are wx-temp + fchmod +
  fsync + rename, closing the validate-then-write TOCTOU window.
- Corruption guards: NUL sniff + UTF-8 round-trip compare (refuses binary
  and latin-1), and server-side EOL re-application so a textarea's LF
  normalization cannot rewrite every line of a CRLF file.
- Plain reads gain an additive editable flag the UI keys the button off.

Frontend (panels-ui.js + overlay markup/styles):
- Edit button on editable text previews; textarea editor with Save/Cancel,
  dirty indicator, discard-confirm on cancel/close, and a conflict dialog
  that offers overwrite (force) when the file changed on disk mid-edit.
- Phone: full-bleed window sized by --app-height so the editor and Save bar
  track the OS keyboard; 16px editor font (iOS zoom guard); no autofocus.
- zh-CN strings for the new chrome.

Tests: pure policy unit tests plus a route suite that deliberately does NOT
mock node:fs. It runs against a real temp workspace so symlink escapes,
write-through of in-workspace symlinks, mode preservation, CRLF round-trip,
409/force, and the no-create property are exercised for real. Also verified
end to end on an isolated beta instance: 39-check curl matrix, Playwright
desktop flow (real clicks and typing, bytes asserted on disk, live conflict
with an external rewrite), and a 393px phone profile.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-05 08:44:47 +02:00

99 lines
3.6 KiB
TypeScript
Raw Blame History

This file contains invisible Unicode characters
This file contains invisible Unicode characters that are indistinguishable to humans but may be processed differently by a computer. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/**
* @fileoverview Unit tests for the File Viewer edit-mode policy module.
*
* Pure functions only — no IO, no server.
* Port: N/A (no server)
*/
import { describe, it, expect } from 'vitest';
import {
MAX_EDITABLE_BYTES,
applyEol,
detectEol,
isDeniedEditRelativePath,
isEditableFileName,
} from '../src/config/file-editing.js';
describe('file-editing policy', () => {
describe('isEditableFileName', () => {
it('allows common text extensions', () => {
for (const name of ['a.ts', 'b.md', 'c.json', 'd.py', 'style.css', 'notes.txt', 'x.yml', 'Q.SQL']) {
expect(isEditableFileName(name), name).toBe(true);
}
});
it('allows well-known basenames regardless of case', () => {
for (const name of ['Dockerfile', 'Makefile', 'LICENSE', '.gitignore', '.editorconfig', '.nvmrc']) {
expect(isEditableFileName(name), name).toBe(true);
}
});
it('rejects binary/media/document extensions', () => {
for (const name of ['a.png', 'b.pdf', 'c.docx', 'd.zip', 'e.woff2', 'f.mp4', 'g.exe']) {
expect(isEditableFileName(name), name).toBe(false);
}
});
it('rejects svg and env (deliberate v1 exclusions)', () => {
expect(isEditableFileName('image.svg')).toBe(false);
expect(isEditableFileName('config.env')).toBe(false);
});
it('rejects extensionless and unknown-dotfile names not on the basename list', () => {
expect(isEditableFileName('somebinary')).toBe(false);
expect(isEditableFileName('.bashrc')).toBe(false);
expect(isEditableFileName('archive.xyz')).toBe(false);
});
});
describe('isDeniedEditRelativePath', () => {
it('denies anything inside a .git directory at any depth', () => {
expect(isDeniedEditRelativePath('.git/config')).toBe(true);
expect(isDeniedEditRelativePath('.git/hooks/pre-commit')).toBe(true);
expect(isDeniedEditRelativePath('sub/module/.git/HEAD')).toBe(true);
});
it('allows non-.git paths, including names merely containing "git"', () => {
expect(isDeniedEditRelativePath('src/index.ts')).toBe(false);
expect(isDeniedEditRelativePath('.github/workflows/ci.yml')).toBe(false);
expect(isDeniedEditRelativePath('digits/file.md')).toBe(false);
expect(isDeniedEditRelativePath('.gitignore')).toBe(false);
});
});
describe('detectEol / applyEol', () => {
it('detects LF, CRLF, and defaults to LF for single-line text', () => {
expect(detectEol('a\nb\nc')).toBe('lf');
expect(detectEol('a\r\nb\r\nc')).toBe('crlf');
expect(detectEol('no newline at all')).toBe('lf');
expect(detectEol('')).toBe('lf');
});
it('picks the dominant style for mixed-EOL text', () => {
expect(detectEol('a\r\nb\r\nc\nd')).toBe('crlf');
expect(detectEol('a\nb\nc\r\nd')).toBe('lf');
});
it('applyEol round-trips a textarea-normalized (LF) buffer back to CRLF', () => {
const original = 'line1\r\nline2\r\nline3';
const textareaValue = original.replace(/\r\n/g, '\n');
expect(applyEol(textareaValue, detectEol(original))).toBe(original);
});
it('applyEol is idempotent and never doubles CR', () => {
expect(applyEol('a\r\nb', 'crlf')).toBe('a\r\nb');
expect(applyEol('a\r\nb', 'lf')).toBe('a\nb');
expect(applyEol('a\nb', 'lf')).toBe('a\nb');
});
it('preserves a UTF-8 BOM through the EOL rewrite', () => {
const withBom = 'hello\nworld';
expect(applyEol(withBom, 'crlf')).toBe('hello\r\nworld');
});
});
it('exposes a sane editable-bytes cap', () => {
expect(MAX_EDITABLE_BYTES).toBe(512 * 1024);
});
});