fix(files): serve remote-case attachments, the path a click takes outside the case

A clicked path that points OUTSIDE the case directory goes through the attachment
routes (the frontend's `_isExternalPreviewPath` sends every absolute path not under
`workingDir` to `POST /attachments`), and those had the same local-`fs` assumption
as file-raw: `realpathSync`/`fs.stat` on a path that only exists on the remote host,
so the file never opened — the case the #415 report was actually about.

- `registerExternalAttachment()` accepts `remote` and resolves through
  `remoteProbePaths` (canonical path, size/mtime, kind, plus the workspace root for
  the confinement check). Everything around it — blocklist, extension allowlist,
  workspace confinement, registry/dedupe — is now shared by both branches, so the
  remote path cannot drift from the local one.
- The by-id routes (`raw`, `preview`, `thumbnail`), the metadata poll and the
  attachment history list resolve over ssh too. `raw` streams with the same
  Range contract as file-raw; `preview` (office) and `thumbnail` answer 400 for a
  remote record; an unreachable host answers 502, a vanished file 404.
- Which host a record is read from follows the SESSION, never the path string: the
  same absolute path is a different file on each host, and a remote session never
  falls back to a local file with that name.
- Codex generated artifacts keep force-workspace confinement for a remote case: the
  well-known artifact directories are anchored at THIS host's home, so only a file
  inside the remote workspace is trusted.

Still local-only by design: writes, office conversion, thumbnails, the file
tree/picker and tail-file.
This commit is contained in:
Randalix
2026-09-14 17:06:42 +02:00
parent 013a5d9cc8
commit 63aafdf274
10 changed files with 566 additions and 86 deletions
+7 -1
View File
@@ -4,7 +4,7 @@
*/
import { EventEmitter } from 'node:events';
import { vi } from 'vitest';
import type { SessionStatus, SessionRemote } from '../../src/types.js';
import type { SessionAttachmentHistoryItem, SessionStatus, SessionRemote } from '../../src/types.js';
/**
* Enhanced mock session for testing RespawnController.
@@ -19,6 +19,12 @@ export class MockSession extends EventEmitter {
* 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
+173
View File
@@ -14,6 +14,7 @@ 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';
@@ -62,6 +63,7 @@ describe('file routes in a remote (SSH) case', () => {
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 };
@@ -76,6 +78,9 @@ describe('file routes in a remote (SSH) case', () => {
});
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();
});
@@ -405,4 +410,172 @@ describe('file routes in a remote (SSH) case', () => {
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');
});
});
});