mirror of
https://github.com/Ark0N/Codeman.git
synced 2026-10-03 14:09:42 +02:00
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>
153 lines
3.8 KiB
TypeScript
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;
|
|
}
|