Files
Codeman/src/config/file-editing.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

153 lines
3.8 KiB
TypeScript

/**
* @fileoverview File Viewer edit-mode policy (issue #212).
*
* Pure, IO-free policy for which workspace files the in-viewer editor may read
* for editing and write back. Consumed by the `edit=1` branch of
* `GET /api/sessions/:id/file-content` and by `PUT /api/sessions/:id/file-content`
* in `src/web/routes/file-routes.ts`.
*
* Design (docs/file-viewer-edit-plan.md):
* - ALLOWLIST of text extensions/basenames, not a blocklist — matching the
* attachment-guard precedent. `svg` and `env` are deliberately absent: svg is
* treated as untrusted on the read side, and `.env` is sensitive-path blocked
* anyway; excluding them here keeps a single obvious refusal.
* - The `.git/` subtree is denied outright: `.git/hooks/*` is code execution and
* a corrupted index looks unrecoverable to a user who wanted to fix a typo.
* - EOL helpers exist because a browser <textarea> normalizes to LF; the server
* re-applies the file's original ending so a two-line edit of a CRLF file does
* not become a whole-file diff. Mixed-EOL files normalize to the dominant
* style (documented lossy edge).
*/
/** Hard cap for edit-mode reads AND writes (bytes of file content). */
export const MAX_EDITABLE_BYTES = 512 * 1024;
/** Lowercase extensions (no dot) the editor will open and save. */
export const EDITABLE_EXTENSIONS: ReadonlySet<string> = new Set([
// JS/TS ecosystem
'ts',
'tsx',
'js',
'jsx',
'mjs',
'cjs',
'json',
'jsonc',
// Docs / plain text
'md',
'mdx',
'txt',
'rst',
'adoc',
// Web
'css',
'scss',
'less',
'html',
'htm',
'xml',
// Config
'yml',
'yaml',
'toml',
'ini',
'cfg',
'conf',
'properties',
// Shell
'sh',
'bash',
'zsh',
'fish',
// Languages
'py',
'rb',
'go',
'rs',
'java',
'kt',
'swift',
'c',
'h',
'cpp',
'hpp',
'cc',
'cs',
'php',
'sql',
'graphql',
'proto',
'lua',
'pl',
'r',
'jl',
'tf',
'gradle',
// Data / misc text
'csv',
'tsv',
'log',
'diff',
'patch',
]);
/** Extensionless (or dot-led) file names that are still editable text. */
export const EDITABLE_BASENAMES: ReadonlySet<string> = new Set([
'dockerfile',
'makefile',
'license',
'readme',
'changelog',
'authors',
'codeowners',
'procfile',
'.gitignore',
'.gitattributes',
'.dockerignore',
'.prettierignore',
'.prettierrc',
'.editorconfig',
'.nvmrc',
'.npmrc',
'.eslintignore',
]);
/** Whether a file name (basename only) is eligible for in-viewer editing. */
export function isEditableFileName(fileName: string): boolean {
const lower = fileName.toLowerCase();
if (EDITABLE_BASENAMES.has(lower)) return true;
const dot = lower.lastIndexOf('.');
// No extension (or a bare dotfile like `.bashrc`): only the basename list applies.
if (dot <= 0) return false;
return EDITABLE_EXTENSIONS.has(lower.slice(dot + 1));
}
/**
* Whether a workspace-relative path is denied for editing regardless of its
* extension. Currently: anything inside a `.git` directory at any depth.
*/
export function isDeniedEditRelativePath(relativePath: string): boolean {
return relativePath.split('/').some((segment) => segment === '.git');
}
export type FileEol = 'lf' | 'crlf';
/** Dominant line-ending style of a text buffer (LF when tied or single-line). */
export function detectEol(text: string): FileEol {
let crlf = 0;
let lf = 0;
for (let i = 0; i < text.length; i++) {
if (text.charCodeAt(i) === 10) {
if (i > 0 && text.charCodeAt(i - 1) === 13) crlf++;
else lf++;
}
}
return crlf > lf ? 'crlf' : 'lf';
}
/** Normalize every line ending in `text` to the requested style. */
export function applyEol(text: string, eol: FileEol): string {
const normalized = text.replace(/\r\n/g, '\n');
return eol === 'crlf' ? normalized.replace(/\n/g, '\r\n') : normalized;
}