mirror of
https://github.com/Ark0N/Codeman.git
synced 2026-10-20 09:09:35 +02:00
fix(html-view): a ~/ HTML link renders as a page too (#547/#604 integration)
#604 makes `~/` links clickable and passes them through unchanged, since only the server knows the session host's home. The html-view mint read `~/x.html` as workspace-relative and answered 404, so the preview fell back to the source view. It now expands `~/` against this host's home, as the attachment route does, and refuses it in a Docker case (that `~` is the container's) with the same message as the attachment and folder routes. Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
This commit is contained in:
1 parent
1172ef56f8
commit
5e83cd3885
4 files changed
+38
-8
No files matched your search
@@ -605,8 +605,9 @@ bucket. The response shape is unchanged (`path`, `filename`).
|
||||
An `.html`/`.htm` file renders as a page in a sandbox, in two steps
|
||||
(`src/web/routes/html-view-routes.ts`).
|
||||
|
||||
**`POST /api/v1/sessions/:id/html-view`** with `{ "path": "<file>" }` (absolute, or
|
||||
relative to the session's workspace; a non-empty string of at most 4096 characters
|
||||
**`POST /api/v1/sessions/:id/html-view`** with `{ "path": "<file>" }` (absolute,
|
||||
relative to the session's workspace, or `~/` for this host's home, which a Docker
|
||||
case refuses with `400` since its `~` is the container's; a non-empty string of at most 4096 characters
|
||||
without NUL or line breaks, and no other keys) answers `data: { url }`, a
|
||||
`/html-view/<cap>/<file>` URL. The capability grants GET access to the files under
|
||||
that file's own folder, so a page's relative CSS, scripts, images and data files
|
||||
|
||||
@@ -580,7 +580,7 @@ interface SessionFileScope {
|
||||
* host's home would open a different file than the one the agent printed, so
|
||||
* the form is refused instead of guessed.
|
||||
*/
|
||||
const DOCKER_HOME_PATH_ERROR =
|
||||
export const DOCKER_HOME_PATH_ERROR =
|
||||
'A ~/ path inside a Docker case is the container home, which this server cannot read; use the absolute path';
|
||||
|
||||
/**
|
||||
|
||||
@@ -29,13 +29,13 @@ import type { FastifyInstance } from 'fastify';
|
||||
import { realpathSync } from 'node:fs';
|
||||
import fs from 'node:fs/promises';
|
||||
import { homedir, tmpdir } from 'node:os';
|
||||
import { basename, dirname, extname, isAbsolute, relative, resolve, sep } from 'node:path';
|
||||
import { basename, dirname, extname, isAbsolute, join, relative, resolve, sep } from 'node:path';
|
||||
import { ApiErrorCode, createErrorResponse } from '../../types.js';
|
||||
import { isBlockedAttachmentPath, isUnderTree, loadAttachmentGuardConfig } from '../../config/attachment-guard.js';
|
||||
import { HTML_VIEW_PREFIX, htmlViewCapabilities } from '../../html-view-capabilities.js';
|
||||
import { CASES_DIR, findSessionOrFail, ownerFor, parseBody, validateSessionFilePath } from '../route-helpers.js';
|
||||
import { HtmlViewOpenSchema } from '../schemas.js';
|
||||
import { localFileSource, sendFileBody } from './file-routes.js';
|
||||
import { DOCKER_HOME_PATH_ERROR, localFileSource, sendFileBody } from './file-routes.js';
|
||||
import { getUserSpacesDir } from '../../config/multiuser.js';
|
||||
import { exceedsDownloadLimit } from '../../config/buffer-limits.js';
|
||||
import type { SessionPort } from '../ports/index.js';
|
||||
@@ -160,20 +160,31 @@ export function registerHtmlViewRoutes(app: FastifyInstance, ctx: SessionPort):
|
||||
reply.code(400).send(createErrorResponse(ApiErrorCode.INVALID_INPUT, 'Not an HTML file'));
|
||||
return;
|
||||
}
|
||||
// A `~/` link (the terminal passes a home-relative path through, since only
|
||||
// the server knows the home) is this host's home, as the attachment route
|
||||
// expands it. A Docker case's `~` is the container's, so it is refused there.
|
||||
let requestedPath = body.path;
|
||||
if (requestedPath.startsWith('~/')) {
|
||||
if (session.docker) {
|
||||
reply.code(400).send(createErrorResponse(ApiErrorCode.INVALID_INPUT, DOCKER_HOME_PATH_ERROR));
|
||||
return;
|
||||
}
|
||||
requestedPath = join(homedir(), requestedPath.slice(2));
|
||||
}
|
||||
|
||||
// Inside the workspace: the same check every file route uses. Outside it: the
|
||||
// attachment guard, exactly as an external file preview is admitted.
|
||||
let resolvedPath: string;
|
||||
const inWorkspace = validateSessionFilePath(session.workingDir, body.path);
|
||||
const inWorkspace = validateSessionFilePath(session.workingDir, requestedPath);
|
||||
if (inWorkspace) {
|
||||
resolvedPath = inWorkspace.resolvedPath;
|
||||
} else {
|
||||
if (!isAbsolute(body.path)) {
|
||||
if (!isAbsolute(requestedPath)) {
|
||||
reply.code(404).send(createErrorResponse(ApiErrorCode.NOT_FOUND, 'File not found'));
|
||||
return;
|
||||
}
|
||||
try {
|
||||
resolvedPath = await fs.realpath(body.path);
|
||||
resolvedPath = await fs.realpath(requestedPath);
|
||||
} catch {
|
||||
reply.code(404).send(createErrorResponse(ApiErrorCode.NOT_FOUND, 'File not found'));
|
||||
return;
|
||||
|
||||
@@ -66,6 +66,24 @@ describe('html-view routes', () => {
|
||||
expect(res.statusCode).toBe(200);
|
||||
});
|
||||
|
||||
it('expands a ~/ path against this host home, as the attachment route does', async () => {
|
||||
// test/setup.ts points HOME at a temp dir, so this never touches the real home.
|
||||
// The terminal passes a `~/` link through unchanged; only the server knows the home.
|
||||
const site = join(homedir(), 'html-view-tilde', 'site');
|
||||
mkdirSync(site, { recursive: true });
|
||||
writeFileSync(join(site, 'page.html'), '<h1>home</h1>');
|
||||
try {
|
||||
const res = await open('~/html-view-tilde/site/page.html');
|
||||
expect(res.statusCode).toBe(200);
|
||||
expect(JSON.parse(res.body).data.url).toMatch(/^\/html-view\/[A-Za-z0-9_-]{16,}\/page\.html$/);
|
||||
// A Docker case's `~` is the container's home, never this host's.
|
||||
(harness.ctx.sessions.get(sid) as { docker?: unknown }).docker = { caseName: 'c' };
|
||||
expect((await open('~/html-view-tilde/site/page.html')).statusCode).toBe(400);
|
||||
} finally {
|
||||
rmSync(join(homedir(), 'html-view-tilde'), { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
it('validates the body', async () => {
|
||||
const post = (payload: unknown) =>
|
||||
harness.app.inject({ method: 'POST', url: `/api/sessions/${sid}/html-view`, payload: payload as object });
|
||||
|
||||
Reference in new issue
Block a user