Merge #797: the File Viewer edits workspace dotenv files

Co-Authored-By: Claude Fable 5.2 <noreply@anthropic.com>
This commit is contained in:
Codeman maintainer committed 2026-11-11 20:23:25 +01:00
commit 18b309fc02
13 files changed
+417 -34

No files matched your search

@@ -0,0 +1,83 @@
/**
* Cross-workspace attachment extensions with real files.
* Port: N/A (app.inject doesn't open ports)
*/
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import { mkdtempSync, realpathSync, rmSync, writeFileSync } from 'node:fs';
import { tmpdir } from 'node:os';
import { join } from 'node:path';
import { attachmentRegistry } from '../../src/attachment-registry.js';
import { registerFileRoutes } from '../../src/web/routes/file-routes.js';
import { createRouteTestHarness, type RouteTestHarness } from './_route-test-utils.js';
describe('cross-workspace attachment extensions (real fs)', () => {
let harness: RouteTestHarness;
let workDir: string;
let outsideDir: string;
beforeEach(async () => {
vi.stubEnv('CODEMAN_ATTACHMENT_CONFINE', undefined);
vi.stubEnv('CODEMAN_ATTACHMENT_BLOCKED_PATHS', undefined);
harness = await createRouteTestHarness(registerFileRoutes);
workDir = realpathSync(mkdtempSync(join(tmpdir(), 'codeman-attachment-ws-')));
outsideDir = realpathSync(mkdtempSync(join(tmpdir(), 'codeman-attachment-out-')));
harness.ctx._session.workingDir = workDir;
attachmentRegistry.clearSession(harness.ctx._sessionId);
});
afterEach(async () => {
await harness.app.close();
attachmentRegistry.clearSession(harness.ctx._sessionId);
rmSync(workDir, { recursive: true, force: true });
rmSync(outsideDir, { recursive: true, force: true });
vi.unstubAllEnvs();
});
it.each(['prod.env', 'CONFIG.ENV', 'unsupported.xyz'])(
'refuses outside %s as an unsupported attachment without an id',
async (fileName) => {
const path = join(outsideDir, fileName);
writeFileSync(path, 'FIXTURE_ONLY=value\n');
const res = await harness.app.inject({
method: 'POST',
url: `/api/sessions/${harness.ctx._sessionId}/attachments`,
payload: { path },
});
const body = res.json();
expect.soft(res.statusCode).toBe(400);
expect.soft(body.success).toBe(false);
expect.soft(body.errorCode).toBe('INVALID_INPUT');
expect.soft(body.data?.attachmentId).toBe(undefined);
expect.soft(body.attachmentId).toBe(undefined);
}
);
it('registers outside notes.md with default attachment settings', async () => {
const path = join(outsideDir, 'notes.md');
const content = '# Outside notes\n';
writeFileSync(path, content);
const res = await harness.app.inject({
method: 'POST',
url: `/api/sessions/${harness.ctx._sessionId}/attachments`,
payload: { path },
});
const body = res.json();
expect(res.statusCode).toBe(200);
expect(body.success).toBe(true);
expect(body.data.fileName).toBe('notes.md');
expect(body.data.extension).toBe('md');
expect(body.data.size).toBe(Buffer.byteLength(content));
const raw = await harness.app.inject({
method: 'GET',
url: `/api/sessions/${harness.ctx._sessionId}/attachments/${body.data.attachmentId}/raw`,
});
expect(raw.statusCode).toBe(200);
expect(raw.body).toBe(content);
});
});
+21
View File
@@ -1125,5 +1125,26 @@ describe('file-routes', () => {
expect(res.statusCode).toBe(403);
});
it('refuses a workspace dotenv download without reading or streaming its content', async () => {
const content = 'TEST_DOWNLOAD_VALUE=must-stay-private';
const path = `${harness.ctx._session.workingDir}/.env`;
mockedReadFile.mockImplementation(async (requestedPath) =>
String(requestedPath) === path ? (content as never) : ('{}' as never)
);
mockedCreateReadStream.mockImplementation(() => Readable.from([Buffer.from(content)]) as never);
const res = await harness.app.inject({
method: 'GET',
url: `/api/download?sessionId=${harness.ctx._sessionId}&path=.env`,
});
expect(res.statusCode).toBe(403);
expect(res.json().success).toBe(false);
expect(res.json().data).toBeUndefined();
expect(res.headers['content-disposition']).toBeUndefined();
expect(mockedCreateReadStream.mock.calls).toEqual([]);
expect(mockedReadFile.mock.calls.filter(([requestedPath]) => String(requestedPath) === path)).toEqual([]);
});
});
});
+148 -8
View File
@@ -20,6 +20,8 @@ import {
symlinkSync,
chmodSync,
statSync,
lstatSync,
readlinkSync,
realpathSync,
readdirSync,
rmSync,
@@ -27,7 +29,7 @@ import {
} from 'node:fs';
import { createHash } from 'node:crypto';
import { tmpdir } from 'node:os';
import { join } from 'node:path';
import { dirname, join } from 'node:path';
import { createRouteTestHarness, type RouteTestHarness } from './_route-test-utils.js';
import { registerFileRoutes } from '../../src/web/routes/file-routes.js';
import { MAX_EDITABLE_BYTES } from '../../src/config/file-editing.js';
@@ -209,13 +211,6 @@ describe('file viewer edit mode (real fs)', () => {
expect(res.statusCode).toBe(403);
});
it('rejects a .env file (allowlist first, sensitive-path as backstop)', async () => {
writeFileSync(join(workDir, '.env'), 'SECRET=1');
const res = await putFile('.env', { content: 'SECRET=2', baseHash: sha256('SECRET=1') });
expect([400, 403]).toContain(res.statusCode);
expect(readFileSync(join(workDir, '.env'), 'utf8')).toBe('SECRET=1');
});
it('400s when the current file contains a NUL byte', async () => {
writeFileSync(join(workDir, 'weird.txt'), Buffer.from([0x61, 0x00, 0x62]));
const res = await putFile('weird.txt', { content: 'ab', baseHash: sha256(Buffer.from([0x61, 0x00, 0x62])) });
@@ -324,6 +319,151 @@ describe('file viewer edit mode (real fs)', () => {
});
});
describe('dotenv files', () => {
const original = 'TEST_VALUE=original\nTEST_LABEL=café';
const updated = 'TEST_VALUE=updated\nTEST_LABEL=été';
const editablePaths = ['.env', '.env.local', '.env.example', 'config.env', 'app/.env'];
const forbiddenPaths = ['.ssh/.env', '.codeman/.env', '.codeman-dev/.env', '.npmrc', 'credentials.json'];
const getPreview = (path: string) =>
harness.app.inject({
method: 'GET',
url: `/api/sessions/${sessionId}/file-content?path=${encodeURIComponent(path)}`,
});
describe.each(editablePaths)('%s', (path) => {
beforeEach(() => {
mkdirSync(dirname(join(workDir, path)), { recursive: true });
writeFileSync(join(workDir, path), original);
});
it('plain preview returns exact content and advertises editable:true', async () => {
const res = await getPreview(path);
expect(res.statusCode).toBe(200);
const body = res.json();
expect(body.success).toBe(true);
expect(body.data.content).toBe(original);
expect(body.data.editable).toBe(true);
});
it('edit=1 returns full content and the SHA-256 of the file bytes', async () => {
const res = await getEdit(path);
expect(res.statusCode).toBe(200);
const body = res.json();
expect(body.success).toBe(true);
expect(body.data.content).toBe(original);
expect(body.data.hash).toBe(sha256(Buffer.from(original, 'utf8')));
expect(body.data.editable).toBe(true);
expect(body.data.truncated).toBe(false);
expect(body.data.totalLines).toBe(2);
expect(body.data.eol).toBe('lf');
});
it('PUT saves exact bytes and leaves no temp files', async () => {
const directory = dirname(join(workDir, path));
const before = readdirSync(directory).sort();
const res = await putFile(path, { content: updated, baseHash: sha256(original) });
expect.soft(res.statusCode).toBe(200);
const body = res.json();
expect.soft(body.success).toBe(true);
expect.soft(body.data?.hash).toBe(sha256(updated));
expect.soft(body.data?.eol).toBe('lf');
expect.soft(body.data?.size).toBe(Buffer.byteLength(updated));
expect.soft(readFileSync(join(workDir, path))).toEqual(Buffer.from(updated));
expect(readdirSync(directory).sort()).toEqual(before);
});
});
it.each([
['0600', 0o600],
['0640', 0o640],
])('preserves dotenv mode %s across a save', async (_label, mode) => {
const path = join(workDir, '.env');
writeFileSync(path, original);
chmodSync(path, mode);
const res = await putFile('.env', { content: updated, baseHash: sha256(original) });
expect.soft(res.statusCode).toBe(200);
expect.soft(res.json().success).toBe(true);
expect.soft(readFileSync(path)).toEqual(Buffer.from(updated));
expect(statSync(path).mode & 0o777).toBe(mode);
});
it('rejects a stale dotenv baseHash with 409 and leaves the bytes unchanged', async () => {
writeFileSync(join(workDir, '.env'), original);
const res = await putFile('.env', { content: updated, baseHash: sha256('TEST_VALUE=older') });
expect.soft(res.statusCode).toBe(409);
expect.soft(res.json().success).toBe(false);
expect.soft(res.json().errorCode).toBe('CONFLICT');
expect(readFileSync(join(workDir, '.env'))).toEqual(Buffer.from(original));
});
describe.each(forbiddenPaths)('protected %s', (path) => {
beforeEach(() => {
mkdirSync(dirname(join(workDir, path)), { recursive: true });
writeFileSync(join(workDir, path), original);
});
it('plain preview advertises editable:false', async () => {
const res = await getPreview(path);
expect(res.statusCode).toBe(200);
expect(res.json().success).toBe(true);
expect(res.json().data.editable).toBe(false);
});
it('edit=1 refuses with 403', async () => {
const res = await getEdit(path);
expect(res.statusCode).toBe(403);
expect(res.json().success).toBe(false);
expect(res.json().errorCode).toBe('FORBIDDEN');
});
it('PUT refuses with FORBIDDEN and leaves the bytes unchanged', async () => {
const res = await putFile(path, { content: updated, baseHash: sha256(original) });
expect.soft(res.statusCode).toBe(403);
expect.soft(res.json().success).toBe(false);
expect.soft(res.json().errorCode).toBe('FORBIDDEN');
expect(readFileSync(join(workDir, path))).toEqual(Buffer.from(original));
});
});
it('refuses a dotenv symlink to credentials.json with 403 and preserves its target', async () => {
const target = join(workDir, 'credentials.json');
writeFileSync(target, original);
symlinkSync(target, join(workDir, '.env'));
const res = await putFile('.env', { content: updated, baseHash: sha256(original) });
expect.soft(res.statusCode).toBe(403);
expect.soft(res.json().success).toBe(false);
expect.soft(res.json().errorCode).toBe('FORBIDDEN');
expect(readFileSync(target)).toEqual(Buffer.from(original));
});
it('refuses a dotenv symlink outside the workspace with 404 and preserves its target', async () => {
const target = join(outsideDir, 'outside.env');
writeFileSync(target, original);
symlinkSync(target, join(workDir, '.env'));
const res = await putFile('.env', { content: updated, baseHash: sha256(original) });
expect.soft(res.statusCode).toBe(404);
expect.soft(res.json().success).toBe(false);
expect(readFileSync(target)).toEqual(Buffer.from(original));
});
it('writes through a dotenv symlink to real.env and preserves the symlink', async () => {
const target = join(workDir, 'real.env');
const link = join(workDir, '.env');
writeFileSync(target, original);
symlinkSync(target, link);
const before = readdirSync(workDir).sort();
const res = await putFile('.env', { content: updated, baseHash: sha256(original) });
expect.soft(res.statusCode).toBe(200);
expect.soft(res.json().success).toBe(true);
expect.soft(readFileSync(target)).toEqual(Buffer.from(updated));
expect.soft(readFileSync(link)).toEqual(Buffer.from(updated));
expect.soft(lstatSync(link).isSymbolicLink()).toBe(true);
expect.soft(readlinkSync(link)).toBe(target);
expect(readdirSync(workDir).sort()).toEqual(before);
});
});
// ========== multi-user scoping ==========
describe('multi-user ownership', () => {