mirror of
https://github.com/Ark0N/Codeman.git
synced 2026-10-02 13:39:41 +02:00
Merge pull request #421 from Randalix/fix/remote-file-access
fix(files): read remote-case previews, downloads and attachments over ssh
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 { SessionAttachmentHistoryItem, SessionStatus, SessionRemote } from '../../src/types.js';
|
||||
|
||||
/**
|
||||
* Enhanced mock session for testing RespawnController.
|
||||
@@ -13,6 +13,18 @@ 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;
|
||||
/** Mirrors Session.attachmentHistory (the attachment panel's source of truth). */
|
||||
attachmentHistory: SessionAttachmentHistoryItem[] = [];
|
||||
/** Mirrors Session.getAttachmentHistoryForPersist(). */
|
||||
getAttachmentHistoryForPersist(): SessionAttachmentHistoryItem[] {
|
||||
return this.attachmentHistory;
|
||||
}
|
||||
/**
|
||||
* 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,581 @@
|
||||
/**
|
||||
* @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 { attachmentRegistry } from '../../src/attachment-registry.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;
|
||||
harness.ctx._session.attachmentHistory = [];
|
||||
// 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(() => {
|
||||
// The registry is process-global: a record left behind would leak into the next
|
||||
// test's by-id requests.
|
||||
attachmentRegistry.clearSession(sessionId);
|
||||
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);
|
||||
});
|
||||
});
|
||||
describe('attachments — a file click OUTSIDE the case directory (#415)', () => {
|
||||
const outsidePath = '/tmp/agent-output/shot.png';
|
||||
|
||||
async function publish(path: string): Promise<{ statusCode: number; body: unknown }> {
|
||||
const res = await harness.app.inject({
|
||||
method: 'POST',
|
||||
url: `/api/sessions/${sessionId}/attachments`,
|
||||
payload: { path, notify: false },
|
||||
});
|
||||
return { statusCode: res.statusCode, body: JSON.parse(res.body) };
|
||||
}
|
||||
|
||||
it('registers an out-of-workspace remote path by probing the remote host', async () => {
|
||||
mockedProbePaths.mockResolvedValue([fileProbe(outsidePath, 42), dirProbe]);
|
||||
|
||||
const res = await publish(outsidePath);
|
||||
|
||||
expect(res.statusCode).toBe(200);
|
||||
const data = (res.body as { data: { attachmentId: string; size: number; fileName: string } }).data;
|
||||
expect(data.attachmentId).toMatch(/^att_/);
|
||||
expect(data.fileName).toBe('shot.png');
|
||||
expect(data.size).toBe(42);
|
||||
// The path is outside the workspace, so a workspace-relative resolution could
|
||||
// never have found it — the probe is what makes this work at all.
|
||||
expect(mockedProbePaths).toHaveBeenCalledWith(expect.objectContaining({ host: '192.0.2.10' }), [
|
||||
outsidePath,
|
||||
REMOTE_DIR,
|
||||
]);
|
||||
});
|
||||
|
||||
it("serves the registered remote attachment's bytes by id, with range support", async () => {
|
||||
mockedProbePaths.mockResolvedValue([fileProbe(outsidePath, 42), dirProbe]);
|
||||
const published = await publish(outsidePath);
|
||||
const attachmentId = (published.body as { data: { attachmentId: string } }).data.attachmentId;
|
||||
|
||||
// The by-id route re-probes (guard defense-in-depth) before streaming.
|
||||
mockedProbePaths.mockResolvedValue([fileProbe(outsidePath, 42), dirProbe]);
|
||||
const res = await harness.app.inject({
|
||||
method: 'GET',
|
||||
url: `/api/sessions/${sessionId}/attachments/${attachmentId}/raw`,
|
||||
});
|
||||
|
||||
expect(res.statusCode).toBe(200);
|
||||
expect(res.headers['content-type']).toBe('image/png');
|
||||
expect(res.body).toBe('remote bytes');
|
||||
expect(mockedCreateReadStream).toHaveBeenCalledWith(expect.anything(), outsidePath, undefined);
|
||||
|
||||
const ranged = await harness.app.inject({
|
||||
method: 'GET',
|
||||
url: `/api/sessions/${sessionId}/attachments/${attachmentId}/raw`,
|
||||
headers: { range: 'bytes=1-3' },
|
||||
});
|
||||
expect(ranged.statusCode).toBe(206);
|
||||
expect(ranged.headers['content-range']).toBe('bytes 1-3/42');
|
||||
});
|
||||
|
||||
it('reports the remote size in the attachment metadata poll', async () => {
|
||||
mockedProbePaths.mockResolvedValue([fileProbe(outsidePath, 42), dirProbe]);
|
||||
const published = await publish(outsidePath);
|
||||
const attachmentId = (published.body as { data: { attachmentId: string } }).data.attachmentId;
|
||||
|
||||
mockedProbePaths.mockResolvedValue([fileProbe(outsidePath, 84), dirProbe]);
|
||||
const res = await harness.app.inject({
|
||||
method: 'GET',
|
||||
url: `/api/sessions/${sessionId}/attachments/${attachmentId}`,
|
||||
});
|
||||
|
||||
expect(res.statusCode).toBe(200);
|
||||
expect(JSON.parse(res.body).data.size).toBe(84);
|
||||
});
|
||||
|
||||
it('404s a remote path that does not exist instead of reporting it as unreadable', async () => {
|
||||
mockedProbePaths.mockResolvedValue([null, dirProbe]);
|
||||
|
||||
const res = await publish('/tmp/agent-output/gone.png');
|
||||
|
||||
expect(res.statusCode).toBe(404);
|
||||
expect(JSON.stringify(res.body)).toContain('Attachment file not found');
|
||||
});
|
||||
|
||||
it('reports an unreachable host as 502 for the click path too', async () => {
|
||||
mockedProbePaths.mockRejectedValue(new RemoteFileAccessError('remote host testhost unreachable: timed out'));
|
||||
|
||||
const res = await publish(outsidePath);
|
||||
|
||||
expect(res.statusCode).toBe(502);
|
||||
});
|
||||
|
||||
it('still refuses a blocked remote path (the blocklist is host-agnostic)', async () => {
|
||||
mockedProbePaths.mockResolvedValue([fileProbe('/etc/shadow', 10), dirProbe]);
|
||||
|
||||
const res = await publish('/etc/shadow');
|
||||
|
||||
// 403 from the guard (the same answer the local path gives for a blocked tree).
|
||||
expect(res.statusCode).toBe(403);
|
||||
expect(JSON.stringify(res.body)).toMatch(/blocked/i);
|
||||
});
|
||||
|
||||
it('does not offer office previews or thumbnails for a remote attachment', async () => {
|
||||
mockedProbePaths.mockResolvedValue([fileProbe('/tmp/agent-output/report.docx', 10), dirProbe]);
|
||||
const published = await publish('/tmp/agent-output/report.docx');
|
||||
const attachmentId = (published.body as { data: { attachmentId: string } }).data.attachmentId;
|
||||
|
||||
mockedProbePaths.mockResolvedValue([fileProbe('/tmp/agent-output/report.docx', 10), dirProbe]);
|
||||
const preview = await harness.app.inject({
|
||||
method: 'GET',
|
||||
url: `/api/sessions/${sessionId}/attachments/${attachmentId}/preview`,
|
||||
});
|
||||
const thumbnail = await harness.app.inject({
|
||||
method: 'GET',
|
||||
url: `/api/sessions/${sessionId}/attachments/${attachmentId}/thumbnail`,
|
||||
});
|
||||
|
||||
expect(preview.statusCode).toBe(400);
|
||||
expect(thumbnail.statusCode).toBe(400);
|
||||
});
|
||||
|
||||
it('lists an out-of-workspace remote history entry without marking it missing', async () => {
|
||||
harness.ctx._session.attachmentHistory = [
|
||||
{
|
||||
id: 'hist-1',
|
||||
sessionId,
|
||||
fileName: 'shot.png',
|
||||
extension: 'png',
|
||||
attachmentType: 'image',
|
||||
size: 1,
|
||||
mtimeMs: 1,
|
||||
timestamp: 1,
|
||||
source: 'external',
|
||||
externalPath: outsidePath,
|
||||
},
|
||||
];
|
||||
mockedProbePaths.mockResolvedValue([fileProbe(outsidePath, 42), dirProbe]);
|
||||
|
||||
const res = await harness.app.inject({ method: 'GET', url: `/api/sessions/${sessionId}/attachments` });
|
||||
|
||||
expect(res.statusCode).toBe(200);
|
||||
const [item] = JSON.parse(res.body).data.items;
|
||||
expect(item.missing).toBe(false);
|
||||
expect(item.size).toBe(42);
|
||||
expect(item.attachmentId).toBeTruthy();
|
||||
});
|
||||
|
||||
it('resolves a workspace-relative history entry over ssh', async () => {
|
||||
harness.ctx._session.attachmentHistory = [
|
||||
{
|
||||
id: 'hist-2',
|
||||
sessionId,
|
||||
fileName: 'out.png',
|
||||
extension: 'png',
|
||||
attachmentType: 'image',
|
||||
size: 1,
|
||||
mtimeMs: 1,
|
||||
timestamp: 1,
|
||||
source: 'detected',
|
||||
relativePath: 'out.png',
|
||||
},
|
||||
];
|
||||
mockedProbePaths.mockResolvedValue([fileProbe(`${REMOTE_DIR}/out.png`, 7), dirProbe]);
|
||||
|
||||
const res = await harness.app.inject({ method: 'GET', url: `/api/sessions/${sessionId}/attachments` });
|
||||
|
||||
const [item] = JSON.parse(res.body).data.items;
|
||||
expect(item.missing).toBe(false);
|
||||
expect(item.size).toBe(7);
|
||||
expect(item.rawUrl).toContain('file-raw');
|
||||
});
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user