mirror of
https://github.com/Ark0N/Codeman.git
synced 2026-10-02 13:39:41 +02:00
fix(files): read remote-case file previews and downloads over ssh
A remote case's workingDir is an absolute path on the remote host, but the file read routes resolved it with local `fs`: `validateSessionFilePath`'s realpathSync fails for a path that does not exist on the Codeman host, so every preview of an agent-written file answered "File not found" (#415). Add src/remote-files.ts as the single remote-read layer, built on the same buildSshConnectionArgs() the launch uses: - remoteProbePaths(): ONE round trip returning realpath + stat for the requested path AND the workspace root, so containment is checked against a remotely canonicalized root (a symlinked remotePath is ordinary). - remoteCreateReadStream(): streams the body (cat, or tail -c +N | head -c L for a Range) with nothing buffered in memory, and reaps the ssh child when the response ends so an aborted download cannot orphan it. - remoteReadFile(): bounded read for file-content. file-raw, file-content, file-preview and file-thumbnail now share one local/ remote target resolution. Guards keep their local strength: lexical pre-check, remote realpath, workspace containment, sensitive-path blocklist, and the size cap applied to the remote size before any bytes are read. An unreachable host answers 502 with the remote reason instead of a misleading 404. Nothing is ever copied to the Codeman host and there is NO local fallback (an sshfs mount of the same tree must not shadow the remote bytes). Deliberately unchanged: writes (edit=1 / PUT now answer 400 explicitly while the viewer hides its Edit affordance), office previews, thumbnails, file tree, picker, external attachment registration and tail-file stay local-only.
This commit is contained in:
@@ -4,7 +4,7 @@
|
||||
*/
|
||||
import { EventEmitter } from 'node:events';
|
||||
import { vi } from 'vitest';
|
||||
import type { SessionStatus } from '../../src/types.js';
|
||||
import type { SessionStatus, SessionRemote } from '../../src/types.js';
|
||||
|
||||
/**
|
||||
* Enhanced mock session for testing RespawnController.
|
||||
@@ -13,6 +13,12 @@ import type { SessionStatus } from '../../src/types.js';
|
||||
export class MockSession extends EventEmitter {
|
||||
id: string;
|
||||
workingDir: string = '/tmp/test-workdir';
|
||||
/**
|
||||
* Mirrors `Session.remote` — set to a `SessionRemote` to model a remote-SSH case,
|
||||
* whose `workingDir` is an absolute path on ANOTHER host. File routes must read it
|
||||
* over ssh instead of with local `fs` (#415).
|
||||
*/
|
||||
remote?: SessionRemote;
|
||||
/**
|
||||
* The REAL union, deliberately. This used to be `'idle' | 'working'`, and
|
||||
* `'working'` is not a `SessionStatus` at all — so `signalForStatus()` fell to its
|
||||
|
||||
@@ -0,0 +1,256 @@
|
||||
/**
|
||||
* @fileoverview Tests for remote (SSH) file access (`src/remote-files.ts`).
|
||||
*
|
||||
* Two layers are covered:
|
||||
*
|
||||
* 1. PURE builders/parsers — command construction, escaping and probe parsing, no
|
||||
* connection involved.
|
||||
* 2. The probe SCRIPT itself, executed by a real `/bin/sh` against a real temp
|
||||
* directory. The remote shell is the one place where a quoting mistake becomes an
|
||||
* injection, and it cannot be exercised by an ssh-less unit test any other way: the
|
||||
* script IS the remote command, so `sh -c <script>` reproduces exactly what sshd
|
||||
* runs on the other end.
|
||||
*
|
||||
* Port: N/A (no HTTP server).
|
||||
*/
|
||||
|
||||
import { describe, it, expect, beforeAll, afterAll } from 'vitest';
|
||||
import { execFileSync } from 'node:child_process';
|
||||
import { mkdtempSync, mkdirSync, rmSync, writeFileSync, existsSync, statSync } from 'node:fs';
|
||||
import { join } from 'node:path';
|
||||
import { homedir, tmpdir } from 'node:os';
|
||||
import {
|
||||
RemoteFileAccessError,
|
||||
buildRemoteFileCommand,
|
||||
buildRemoteProbeCommand,
|
||||
buildRemoteReadCommand,
|
||||
parseRemoteProbeLine,
|
||||
parseRemoteProbeLines,
|
||||
} from '../src/remote-files.js';
|
||||
import type { SessionRemote } from '../src/types/session.js';
|
||||
|
||||
/**
|
||||
* Run a shell line through a real `/bin/sh` and return its `$@` as an argv array,
|
||||
* WITHOUT executing anything. This is how the tests see the exact argument vector a
|
||||
* command line would hand to the process — the local-shell half of the escaping chain.
|
||||
*/
|
||||
function shellArgv(command: string): string[] {
|
||||
const out = execFileSync('sh', ['-c', `set -- ${command}; printf '%s\\0' "$@"`]);
|
||||
// The trailing empty element is the printf format terminator.
|
||||
return out.toString().split('\0').slice(0, -1);
|
||||
}
|
||||
|
||||
/** A remote session fixture; every field is optional in production, so keep it minimal. */
|
||||
function remoteFixture(overrides: Partial<SessionRemote> = {}): SessionRemote {
|
||||
return {
|
||||
hostId: 'host-1',
|
||||
label: 'testhost',
|
||||
host: '192.0.2.10',
|
||||
username: 'j',
|
||||
remotePath: '/srv/case',
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
describe('buildRemoteFileCommand', () => {
|
||||
it('builds the ssh line from the shared connection args and one shellescaped command', () => {
|
||||
const argv = shellArgv(buildRemoteFileCommand(remoteFixture(), 'cat /etc/hostname'));
|
||||
|
||||
// buildSshConnectionArgs returns tokens, and the shell re-splits them into the
|
||||
// flags ssh actually wants (`-o` + `BatchMode=yes`), which is what this pins.
|
||||
expect(argv.slice(0, 3)).toEqual(['ssh', '-o', 'BatchMode=yes']);
|
||||
expect(argv).toContain('ConnectTimeout=10');
|
||||
expect(argv).toContain('j@192.0.2.10');
|
||||
// The remote command is ONE argument, whatever it contains.
|
||||
expect(argv[argv.length - 1]).toBe('cat /etc/hostname');
|
||||
expect(argv[argv.length - 2]).toBe('j@192.0.2.10');
|
||||
});
|
||||
|
||||
it('routes port, identity, jump host and extra options through buildSshConnectionArgs', () => {
|
||||
const argv = shellArgv(
|
||||
buildRemoteFileCommand(
|
||||
remoteFixture({
|
||||
port: 2222,
|
||||
identityFile: '~/.ssh/id_ed25519',
|
||||
jumpHost: 'bastion.example.com',
|
||||
extraSshOptions: ['StrictHostKeyChecking=accept-new'],
|
||||
}),
|
||||
'true'
|
||||
)
|
||||
);
|
||||
|
||||
expect(argv).toContain('-p');
|
||||
expect(argv).toContain('2222');
|
||||
expect(argv).toContain('-J');
|
||||
expect(argv).toContain('bastion.example.com');
|
||||
expect(argv).toContain('StrictHostKeyChecking=accept-new');
|
||||
// `~` is expanded before escaping: ssh does not expand it inside -i.
|
||||
expect(argv).toContain(join(homedir(), '.ssh/id_ed25519'));
|
||||
});
|
||||
|
||||
it('keeps a shell-metacharacter command as a single opaque argument', () => {
|
||||
const command = "cat '/tmp/it''s here' ; rm -rf ~ #";
|
||||
const argv = shellArgv(buildRemoteFileCommand(remoteFixture(), command));
|
||||
|
||||
expect(argv[argv.length - 1]).toBe(command);
|
||||
expect(argv).not.toContain('rm');
|
||||
expect(argv).not.toContain('-rf');
|
||||
});
|
||||
});
|
||||
|
||||
describe('buildRemoteProbeCommand', () => {
|
||||
it('probes every path exactly once, each as its own shell-quoted token', () => {
|
||||
const script = buildRemoteProbeCommand(['/srv/case/a.png', '/srv/case']);
|
||||
const probeCalls = script.split('\n').filter((line) => line.startsWith('probe '));
|
||||
|
||||
expect(probeCalls).toEqual(["probe '/srv/case/a.png'", "probe '/srv/case'"]);
|
||||
});
|
||||
|
||||
it('quotes a path with spaces, quotes and a command substitution', () => {
|
||||
const nasty = "/srv/case/it's $(touch /tmp/pwned).txt";
|
||||
const script = buildRemoteProbeCommand([nasty]);
|
||||
|
||||
expect(script).toContain(`probe '/srv/case/it'\\''s $(touch /tmp/pwned).txt'`);
|
||||
expect(shellArgv(buildRemoteFileCommand(remoteFixture(), script)).at(-1)).toBe(script);
|
||||
});
|
||||
});
|
||||
|
||||
describe('the probe script on a real shell', () => {
|
||||
let root: string;
|
||||
|
||||
beforeAll(() => {
|
||||
root = mkdtempSync(join(tmpdir(), 'codeman-remote-probe-'));
|
||||
});
|
||||
|
||||
afterAll(() => {
|
||||
rmSync(root, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
it('reports kind, size and realpath for a file, a directory and a missing path', () => {
|
||||
const filePath = join(root, 'image.png');
|
||||
writeFileSync(filePath, 'fake png bytes');
|
||||
|
||||
const probes = parseRemoteProbeLines(
|
||||
execFileSync('sh', ['-c', buildRemoteProbeCommand([filePath, root, join(root, 'nope.png')])]).toString(),
|
||||
[filePath, root, join(root, 'nope.png')]
|
||||
);
|
||||
|
||||
expect(probes[0]).toMatchObject({ kind: 'file', size: 14, realPath: filePath });
|
||||
expect(probes[0]?.mtimeMs).toBeGreaterThan(0);
|
||||
expect(probes[1]).toMatchObject({ kind: 'directory', size: 0, realPath: root });
|
||||
expect(probes[2]).toBeNull();
|
||||
});
|
||||
|
||||
it('resolves a symlink to its target', () => {
|
||||
const target = join(root, 'target.txt');
|
||||
const link = join(root, 'link.txt');
|
||||
writeFileSync(target, 'x');
|
||||
execFileSync('ln', ['-s', target, link]);
|
||||
|
||||
const [probe] = parseRemoteProbeLines(execFileSync('sh', ['-c', buildRemoteProbeCommand([link])]).toString(), [
|
||||
link,
|
||||
]);
|
||||
|
||||
expect(probe?.realPath).toBe(target);
|
||||
});
|
||||
|
||||
it('treats a hostile filename as data, never as a command', () => {
|
||||
// No slashes in the payload: it has to be a legal FILENAME on this host while
|
||||
// still being a command substitution to a shell.
|
||||
const marker = `codeman_pwned_${process.pid}`;
|
||||
const hostile = join(root, `it's; touch ${marker}; $(id).txt`);
|
||||
writeFileSync(hostile, 'hostile');
|
||||
|
||||
const [probe] = parseRemoteProbeLines(
|
||||
execFileSync('sh', ['-c', buildRemoteProbeCommand([hostile])], { cwd: root }).toString(),
|
||||
[hostile]
|
||||
);
|
||||
|
||||
expect(probe?.realPath).toBe(hostile);
|
||||
expect(existsSync(join(root, marker))).toBe(false);
|
||||
});
|
||||
|
||||
it('handles a path containing the field separator', () => {
|
||||
const pipePath = join(root, 'a|b.txt');
|
||||
writeFileSync(pipePath, 'xy');
|
||||
|
||||
const [probe] = parseRemoteProbeLines(execFileSync('sh', ['-c', buildRemoteProbeCommand([pipePath])]).toString(), [
|
||||
pipePath,
|
||||
]);
|
||||
|
||||
expect(probe?.realPath).toBe(pipePath);
|
||||
expect(probe?.size).toBe(2);
|
||||
});
|
||||
|
||||
it('walks into a nested directory that exists', () => {
|
||||
const nested = join(root, 'sub');
|
||||
mkdirSync(nested, { recursive: true });
|
||||
writeFileSync(join(nested, 'f.txt'), 'abc');
|
||||
|
||||
const [probe] = parseRemoteProbeLines(
|
||||
execFileSync('sh', ['-c', buildRemoteProbeCommand([join(nested, 'f.txt')])]).toString(),
|
||||
[join(nested, 'f.txt')]
|
||||
);
|
||||
|
||||
expect(probe?.size).toBe(3);
|
||||
expect(statSync(join(nested, 'f.txt')).size).toBe(3);
|
||||
});
|
||||
});
|
||||
|
||||
describe('parseRemoteProbeLine', () => {
|
||||
it('parses a file line and converts mtime to milliseconds', () => {
|
||||
expect(parseRemoteProbeLine('f|1234|1700000000|/srv/case/a.png')).toEqual({
|
||||
realPath: '/srv/case/a.png',
|
||||
kind: 'file',
|
||||
size: 1234,
|
||||
mtimeMs: 1700000000 * 1000,
|
||||
});
|
||||
});
|
||||
|
||||
it('keeps a path that itself contains the separator', () => {
|
||||
expect(parseRemoteProbeLine('f|7|0|/srv/ca|se/a b.txt')?.realPath).toBe('/srv/ca|se/a b.txt');
|
||||
});
|
||||
|
||||
it('maps directories, other kinds and the not-found marker', () => {
|
||||
expect(parseRemoteProbeLine('d|0|5|/srv/case')?.kind).toBe('directory');
|
||||
expect(parseRemoteProbeLine('o|0|0|/srv/case/sock')?.kind).toBe('other');
|
||||
expect(parseRemoteProbeLine('n')).toBeNull();
|
||||
expect(parseRemoteProbeLine('')).toBeNull();
|
||||
});
|
||||
|
||||
it('rejects malformed lines instead of inventing a path', () => {
|
||||
expect(parseRemoteProbeLine('f|1|2')).toBeNull();
|
||||
expect(parseRemoteProbeLine('x|1|2|/p')).toBeNull();
|
||||
expect(parseRemoteProbeLine('f|1|2|')).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe('parseRemoteProbeLines', () => {
|
||||
it('aligns the last N lines, so a login banner cannot shift the mapping', () => {
|
||||
const stdout = 'welcome to the remote box\nf|3|1|/srv/a.txt\nn\n';
|
||||
expect(parseRemoteProbeLines(stdout, ['/srv/a.txt', '/srv/b.txt'])).toEqual([
|
||||
{ realPath: '/srv/a.txt', kind: 'file', size: 3, mtimeMs: 1000 },
|
||||
null,
|
||||
]);
|
||||
});
|
||||
|
||||
it('throws when the remote shell returned too little output', () => {
|
||||
expect(() => parseRemoteProbeLines('f|3|1|/srv/a.txt\n', ['/a', '/b'])).toThrow(RemoteFileAccessError);
|
||||
});
|
||||
});
|
||||
|
||||
describe('buildRemoteReadCommand', () => {
|
||||
it('streams the whole file with cat', () => {
|
||||
expect(buildRemoteReadCommand("/srv/case/it's.mp4")).toBe("cat '/srv/case/it'\\''s.mp4'");
|
||||
});
|
||||
|
||||
it('turns a byte range into a constant-memory tail | head', () => {
|
||||
expect(buildRemoteReadCommand('/srv/case/v.mp4', { start: 2, end: 5 })).toBe(
|
||||
"tail -c +3 '/srv/case/v.mp4' | head -c 4"
|
||||
);
|
||||
});
|
||||
|
||||
it('covers the first byte of the file (tail -c +1, not +0)', () => {
|
||||
expect(buildRemoteReadCommand('/f', { start: 0, end: 0 })).toBe("tail -c +1 '/f' | head -c 1");
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,408 @@
|
||||
/**
|
||||
* @fileoverview Route tests for file READ routes in a remote (SSH) case (#415).
|
||||
*
|
||||
* The mirror image of `test/routes/file-routes.test.ts`: every request here resolves
|
||||
* against a path that exists only on another host, so the local `fs` layer must never
|
||||
* be the thing that answers. The ssh layer (`src/remote-files.ts`) is mocked — a test
|
||||
* never opens a connection — but the REAL module is kept alongside the mocks so
|
||||
* `RemoteFileAccessError` and the command builders stay authentic.
|
||||
*
|
||||
* Port: N/A (app.inject doesn't open ports)
|
||||
*/
|
||||
|
||||
import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest';
|
||||
import { Readable } from 'node:stream';
|
||||
import { createRouteTestHarness, type RouteTestHarness } from './_route-test-utils.js';
|
||||
import { registerFileRoutes } from '../../src/web/routes/file-routes.js';
|
||||
import { RemoteFileAccessError } from '../../src/remote-files.js';
|
||||
import type { RemoteProbe } from '../../src/remote-files.js';
|
||||
import type { SessionRemote } from '../../src/types/session.js';
|
||||
import { mkdtempSync, readFileSync, rmSync, writeFileSync } from 'node:fs';
|
||||
import { join } from 'node:path';
|
||||
import { tmpdir } from 'node:os';
|
||||
import { MAX_FILE_DOWNLOAD_BYTES } from '../../src/config/buffer-limits.js';
|
||||
|
||||
// Keep the pure builders + the error class real; replace only the IO.
|
||||
vi.mock('../../src/remote-files.js', async (importOriginal) => {
|
||||
const actual = await importOriginal<typeof import('../../src/remote-files.js')>();
|
||||
return {
|
||||
...actual,
|
||||
remoteProbePaths: vi.fn(),
|
||||
remoteReadFile: vi.fn(),
|
||||
remoteCreateReadStream: vi.fn(),
|
||||
};
|
||||
});
|
||||
|
||||
import { remoteProbePaths, remoteReadFile, remoteCreateReadStream } from '../../src/remote-files.js';
|
||||
|
||||
const mockedProbePaths = vi.mocked(remoteProbePaths);
|
||||
const mockedReadFile = vi.mocked(remoteReadFile);
|
||||
const mockedCreateReadStream = vi.mocked(remoteCreateReadStream);
|
||||
|
||||
const REMOTE_DIR = '/srv/remote/case';
|
||||
const remote: SessionRemote = {
|
||||
hostId: 'host-1',
|
||||
label: 'testhost',
|
||||
host: '192.0.2.10',
|
||||
username: 'j',
|
||||
remotePath: REMOTE_DIR,
|
||||
};
|
||||
|
||||
function fileProbe(realPath: string, size: number): RemoteProbe {
|
||||
return { realPath, kind: 'file', size, mtimeMs: 1_700_000_000_000 };
|
||||
}
|
||||
|
||||
const dirProbe: RemoteProbe = { realPath: REMOTE_DIR, kind: 'directory', size: 0, mtimeMs: 0 };
|
||||
|
||||
describe('file routes in a remote (SSH) case', () => {
|
||||
let harness: RouteTestHarness;
|
||||
let sessionId: string;
|
||||
let closeSpy: ReturnType<typeof vi.fn>;
|
||||
|
||||
beforeEach(async () => {
|
||||
harness = await createRouteTestHarness(registerFileRoutes);
|
||||
sessionId = harness.ctx._sessionId;
|
||||
// The whole point of the fixture: the workspace is a path on ANOTHER host.
|
||||
harness.ctx._session.workingDir = REMOTE_DIR;
|
||||
harness.ctx._session.remote = { ...remote };
|
||||
|
||||
closeSpy = vi.fn();
|
||||
mockedProbePaths.mockResolvedValue([fileProbe(`${REMOTE_DIR}/img.png`, 9), dirProbe]);
|
||||
mockedReadFile.mockResolvedValue(Buffer.from('remote text'));
|
||||
mockedCreateReadStream.mockReturnValue({
|
||||
stream: Readable.from([Buffer.from('remote bytes')]),
|
||||
close: closeSpy,
|
||||
} as never);
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
describe('GET /api/sessions/:id/file-raw', () => {
|
||||
it('streams the remote file and probes the path AND the workspace in one call', async () => {
|
||||
const res = await harness.app.inject({
|
||||
method: 'GET',
|
||||
url: `/api/sessions/${sessionId}/file-raw?path=img.png`,
|
||||
});
|
||||
|
||||
expect(res.statusCode).toBe(200);
|
||||
expect(res.headers['content-type']).toBe('image/png');
|
||||
expect(res.body).toBe('remote bytes');
|
||||
// Both paths in one ssh round trip: the workspace root is needed to check
|
||||
// containment against a REMOTELY canonicalized root.
|
||||
expect(mockedProbePaths).toHaveBeenCalledWith(expect.objectContaining({ host: '192.0.2.10' }), [
|
||||
`${REMOTE_DIR}/img.png`,
|
||||
REMOTE_DIR,
|
||||
]);
|
||||
expect(mockedCreateReadStream).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ host: '192.0.2.10' }),
|
||||
`${REMOTE_DIR}/img.png`,
|
||||
undefined
|
||||
);
|
||||
});
|
||||
|
||||
it('serves a byte range as a 206 from the remote host', async () => {
|
||||
mockedProbePaths.mockResolvedValue([fileProbe(`${REMOTE_DIR}/clip.mp4`, 100), dirProbe]);
|
||||
|
||||
const res = await harness.app.inject({
|
||||
method: 'GET',
|
||||
url: `/api/sessions/${sessionId}/file-raw?path=clip.mp4`,
|
||||
headers: { range: 'bytes=10-19' },
|
||||
});
|
||||
|
||||
expect(res.statusCode).toBe(206);
|
||||
expect(res.headers['content-range']).toBe('bytes 10-19/100');
|
||||
expect(res.headers['content-length']).toBe('10');
|
||||
expect(res.headers['accept-ranges']).toBe('bytes');
|
||||
expect(mockedCreateReadStream).toHaveBeenCalledWith(expect.anything(), `${REMOTE_DIR}/clip.mp4`, {
|
||||
start: 10,
|
||||
end: 19,
|
||||
});
|
||||
});
|
||||
|
||||
it('reaps the ssh stream when the response is done', async () => {
|
||||
await harness.app.inject({ method: 'GET', url: `/api/sessions/${sessionId}/file-raw?path=img.png` });
|
||||
// The cleanup is registered on the raw response's lifecycle; without it an
|
||||
// aborted download would leave the ssh child running.
|
||||
expect(closeSpy).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('refuses a path that escapes the workspace lexically, without connecting', async () => {
|
||||
const res = await harness.app.inject({
|
||||
method: 'GET',
|
||||
url: `/api/sessions/${sessionId}/file-raw?path=../../etc/shadow`,
|
||||
});
|
||||
|
||||
expect(res.statusCode).toBe(404);
|
||||
expect(mockedProbePaths).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('refuses a symlink that resolves outside the workspace on the remote host', async () => {
|
||||
mockedProbePaths.mockResolvedValue([fileProbe('/etc/shadow', 10), dirProbe]);
|
||||
|
||||
const res = await harness.app.inject({
|
||||
method: 'GET',
|
||||
url: `/api/sessions/${sessionId}/file-raw?path=innocent.png`,
|
||||
});
|
||||
|
||||
expect(res.statusCode).toBe(404);
|
||||
expect(mockedCreateReadStream).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('accepts a workspace reached through a remote symlink (both sides canonicalized)', async () => {
|
||||
// remotePath is a symlinked mount: the file's realpath is genuinely inside the
|
||||
// workspace's realpath, so refusing it would break the whole case.
|
||||
harness.ctx._session.workingDir = '/mnt/link/case';
|
||||
mockedProbePaths.mockResolvedValue([
|
||||
fileProbe('/srv/real/case/img.png', 3),
|
||||
{ realPath: '/srv/real/case', kind: 'directory', size: 0, mtimeMs: 0 },
|
||||
]);
|
||||
|
||||
const res = await harness.app.inject({
|
||||
method: 'GET',
|
||||
url: `/api/sessions/${sessionId}/file-raw?path=img.png`,
|
||||
});
|
||||
|
||||
expect(res.statusCode).toBe(200);
|
||||
});
|
||||
|
||||
it('404s a file that does not exist on the remote host', async () => {
|
||||
mockedProbePaths.mockResolvedValue([null, dirProbe]);
|
||||
|
||||
const res = await harness.app.inject({
|
||||
method: 'GET',
|
||||
url: `/api/sessions/${sessionId}/file-raw?path=gone.png`,
|
||||
});
|
||||
|
||||
expect(res.statusCode).toBe(404);
|
||||
expect(mockedCreateReadStream).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('reports an unreachable host as a gateway failure, not a 404 or a 500', async () => {
|
||||
mockedProbePaths.mockRejectedValue(
|
||||
new RemoteFileAccessError('remote host testhost unreachable: Connection refused')
|
||||
);
|
||||
|
||||
const res = await harness.app.inject({
|
||||
method: 'GET',
|
||||
url: `/api/sessions/${sessionId}/file-raw?path=img.png`,
|
||||
});
|
||||
|
||||
expect(res.statusCode).toBe(502);
|
||||
expect(JSON.parse(res.body).error).toContain('Connection refused');
|
||||
});
|
||||
|
||||
it('applies the size cap to the REMOTE size, before reading', async () => {
|
||||
mockedProbePaths.mockResolvedValue([fileProbe(`${REMOTE_DIR}/huge.mp4`, MAX_FILE_DOWNLOAD_BYTES + 1), dirProbe]);
|
||||
|
||||
const res = await harness.app.inject({
|
||||
method: 'GET',
|
||||
url: `/api/sessions/${sessionId}/file-raw?path=huge.mp4`,
|
||||
});
|
||||
|
||||
expect(res.statusCode).toBe(413);
|
||||
expect(mockedCreateReadStream).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('refuses a directory', async () => {
|
||||
mockedProbePaths.mockResolvedValue([dirProbe, dirProbe]);
|
||||
|
||||
const res = await harness.app.inject({
|
||||
method: 'GET',
|
||||
url: `/api/sessions/${sessionId}/file-raw?path=.`,
|
||||
});
|
||||
|
||||
expect(res.statusCode).toBe(400);
|
||||
});
|
||||
|
||||
it('does not touch the ssh layer for a local session', async () => {
|
||||
delete harness.ctx._session.remote;
|
||||
|
||||
await harness.app.inject({ method: 'GET', url: `/api/sessions/${sessionId}/file-raw?path=img.png` });
|
||||
|
||||
expect(mockedProbePaths).not.toHaveBeenCalled();
|
||||
expect(mockedCreateReadStream).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
describe('when a path with the same absolute name ALSO exists on this host', () => {
|
||||
// The case that really happens in practice: the remote tree is mounted on the
|
||||
// Codeman host at the identical absolute path (an sshfs mount, which is the
|
||||
// documented stop-gap workaround for this very bug). The remote host stays the
|
||||
// source of truth: there is deliberately no local fallback, because a fallback
|
||||
// would silently serve the OTHER filesystem's bytes under the same path.
|
||||
let shadowRoot: string;
|
||||
let shadowFile: string;
|
||||
|
||||
beforeEach(() => {
|
||||
shadowRoot = mkdtempSync(join(tmpdir(), 'codeman-remote-shadow-'));
|
||||
shadowFile = join(shadowRoot, 'img.png');
|
||||
writeFileSync(shadowFile, 'LOCAL BYTES');
|
||||
harness.ctx._session.workingDir = shadowRoot;
|
||||
harness.ctx._session.remote = { ...remote, remotePath: shadowRoot };
|
||||
mockedProbePaths.mockResolvedValue([fileProbe(shadowFile, 12), { ...dirProbe, realPath: shadowRoot }]);
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
rmSync(shadowRoot, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
it('serves the REMOTE bytes, never the local copy at the same path', async () => {
|
||||
const res = await harness.app.inject({
|
||||
method: 'GET',
|
||||
url: `/api/sessions/${sessionId}/file-raw?path=img.png`,
|
||||
});
|
||||
|
||||
expect(res.statusCode).toBe(200);
|
||||
expect(res.body).toBe('remote bytes');
|
||||
expect(res.body).not.toBe('LOCAL BYTES');
|
||||
// The local file is untouched, proving the local side was never the source.
|
||||
expect(readFileSync(shadowFile, 'utf8')).toBe('LOCAL BYTES');
|
||||
});
|
||||
|
||||
it('still 404s when the remote host does not have the file, even though a local one exists', async () => {
|
||||
mockedProbePaths.mockResolvedValue([null, { ...dirProbe, realPath: shadowRoot }]);
|
||||
|
||||
const res = await harness.app.inject({
|
||||
method: 'GET',
|
||||
url: `/api/sessions/${sessionId}/file-raw?path=img.png`,
|
||||
});
|
||||
|
||||
expect(res.statusCode).toBe(404);
|
||||
expect(mockedCreateReadStream).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('reads text from the remote host, not from the local twin', async () => {
|
||||
const localText = join(shadowRoot, 'notes.txt');
|
||||
writeFileSync(localText, 'local text');
|
||||
mockedProbePaths.mockResolvedValue([fileProbe(localText, 11), { ...dirProbe, realPath: shadowRoot }]);
|
||||
|
||||
const res = await harness.app.inject({
|
||||
method: 'GET',
|
||||
url: `/api/sessions/${sessionId}/file-content?path=notes.txt`,
|
||||
});
|
||||
|
||||
expect(JSON.parse(res.body).data.content).toBe('remote text');
|
||||
expect(mockedReadFile).toHaveBeenCalledWith(expect.anything(), localText, expect.any(Number));
|
||||
expect(readFileSync(localText, 'utf8')).toBe('local text');
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('GET /api/sessions/:id/file-content', () => {
|
||||
it('returns remote text content and never advertises the editor', async () => {
|
||||
mockedProbePaths.mockResolvedValue([fileProbe(`${REMOTE_DIR}/notes.txt`, 11), dirProbe]);
|
||||
|
||||
const res = await harness.app.inject({
|
||||
method: 'GET',
|
||||
url: `/api/sessions/${sessionId}/file-content?path=notes.txt`,
|
||||
});
|
||||
|
||||
expect(res.statusCode).toBe(200);
|
||||
const body = JSON.parse(res.body);
|
||||
expect(body.success).toBe(true);
|
||||
expect(body.data.content).toBe('remote text');
|
||||
expect(body.data.editable).toBe(false);
|
||||
expect(mockedReadFile).toHaveBeenCalledWith(expect.anything(), `${REMOTE_DIR}/notes.txt`, expect.any(Number));
|
||||
});
|
||||
|
||||
it('classifies remote media by extension without reading it', async () => {
|
||||
mockedProbePaths.mockResolvedValue([fileProbe(`${REMOTE_DIR}/logo.png`, 1024), dirProbe]);
|
||||
|
||||
const res = await harness.app.inject({
|
||||
method: 'GET',
|
||||
url: `/api/sessions/${sessionId}/file-content?path=logo.png`,
|
||||
});
|
||||
|
||||
const body = JSON.parse(res.body);
|
||||
expect(body.data.type).toBe('image');
|
||||
expect(body.data.url).toContain('file-raw');
|
||||
expect(mockedReadFile).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('turns an edit request into an explicit 400 instead of a misleading 404', async () => {
|
||||
mockedProbePaths.mockResolvedValue([fileProbe(`${REMOTE_DIR}/notes.txt`, 11), dirProbe]);
|
||||
|
||||
const res = await harness.app.inject({
|
||||
method: 'GET',
|
||||
url: `/api/sessions/${sessionId}/file-content?path=notes.txt&edit=1`,
|
||||
});
|
||||
|
||||
expect(res.statusCode).toBe(400);
|
||||
const body = JSON.parse(res.body);
|
||||
expect(body.success).toBe(false);
|
||||
expect(body.error).toContain('not supported for files in a remote');
|
||||
});
|
||||
|
||||
it('reports an unreachable host as a real 502 with the remote reason', async () => {
|
||||
mockedProbePaths.mockRejectedValue(new RemoteFileAccessError('remote host testhost unreachable: timed out'));
|
||||
|
||||
const res = await harness.app.inject({
|
||||
method: 'GET',
|
||||
url: `/api/sessions/${sessionId}/file-content?path=notes.txt`,
|
||||
});
|
||||
|
||||
expect(res.statusCode).toBe(502);
|
||||
const body = JSON.parse(res.body);
|
||||
expect(body.success).toBe(false);
|
||||
expect(body.error).toContain('timed out');
|
||||
});
|
||||
|
||||
it('rejects a path that escapes the workspace', async () => {
|
||||
const res = await harness.app.inject({
|
||||
method: 'GET',
|
||||
url: `/api/sessions/${sessionId}/file-content?path=../../../etc/passwd`,
|
||||
});
|
||||
|
||||
expect(JSON.parse(res.body).success).toBe(false);
|
||||
expect(mockedProbePaths).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
describe('GET /api/sessions/:id/file-preview and file-thumbnail', () => {
|
||||
it('redirects a non-office remote file to file-raw', async () => {
|
||||
const res = await harness.app.inject({
|
||||
method: 'GET',
|
||||
url: `/api/sessions/${sessionId}/file-preview?path=scan.pdf`,
|
||||
});
|
||||
|
||||
expect(res.statusCode).toBe(302);
|
||||
expect(res.headers.location).toContain('/file-raw');
|
||||
});
|
||||
|
||||
it('says office previews are unavailable rather than 404-ing', async () => {
|
||||
mockedProbePaths.mockResolvedValue([fileProbe(`${REMOTE_DIR}/doc.docx`, 10), dirProbe]);
|
||||
|
||||
const res = await harness.app.inject({
|
||||
method: 'GET',
|
||||
url: `/api/sessions/${sessionId}/file-preview?path=doc.docx`,
|
||||
});
|
||||
|
||||
expect(res.statusCode).toBe(400);
|
||||
expect(JSON.parse(res.body).error).toContain('not available for files in a remote');
|
||||
});
|
||||
|
||||
it('says thumbnails are unavailable for a remote file', async () => {
|
||||
mockedProbePaths.mockResolvedValue([fileProbe(`${REMOTE_DIR}/doc.pdf`, 10), dirProbe]);
|
||||
|
||||
const res = await harness.app.inject({
|
||||
method: 'GET',
|
||||
url: `/api/sessions/${sessionId}/file-thumbnail?path=doc.pdf`,
|
||||
});
|
||||
|
||||
expect(res.statusCode).toBe(400);
|
||||
expect(JSON.parse(res.body).error).toContain('not available for files in a remote');
|
||||
});
|
||||
|
||||
it('reports an unreachable host for previews too', async () => {
|
||||
mockedProbePaths.mockRejectedValue(new RemoteFileAccessError('remote host testhost unreachable: no route'));
|
||||
|
||||
const res = await harness.app.inject({
|
||||
method: 'GET',
|
||||
url: `/api/sessions/${sessionId}/file-preview?path=doc.docx`,
|
||||
});
|
||||
|
||||
expect(res.statusCode).toBe(502);
|
||||
});
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user