aicodeman 1.28.2 → 1.29.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +1 -1
- package/dist/attachment-registry.d.ts +20 -0
- package/dist/attachment-registry.d.ts.map +1 -1
- package/dist/attachment-registry.js +75 -10
- package/dist/attachment-registry.js.map +1 -1
- package/dist/config/cli-registry/schema.d.ts +25 -0
- package/dist/config/cli-registry/schema.d.ts.map +1 -1
- package/dist/config/cli-registry/schema.js +43 -0
- package/dist/config/cli-registry/schema.js.map +1 -1
- package/dist/config/cli-registry/stock.d.ts.map +1 -1
- package/dist/config/cli-registry/stock.js +157 -2
- package/dist/config/cli-registry/stock.js.map +1 -1
- package/dist/config/cli-registry/types.d.ts +60 -0
- package/dist/config/cli-registry/types.d.ts.map +1 -1
- package/dist/custom-model-hosts.d.ts +39 -0
- package/dist/custom-model-hosts.d.ts.map +1 -0
- package/dist/custom-model-hosts.js +35 -0
- package/dist/custom-model-hosts.js.map +1 -0
- package/dist/custom-model-injection-apply.d.ts +49 -0
- package/dist/custom-model-injection-apply.d.ts.map +1 -0
- package/dist/custom-model-injection-apply.js +77 -0
- package/dist/custom-model-injection-apply.js.map +1 -0
- package/dist/custom-model-injection.d.ts +79 -0
- package/dist/custom-model-injection.d.ts.map +1 -0
- package/dist/custom-model-injection.js +169 -0
- package/dist/custom-model-injection.js.map +1 -0
- package/dist/docker-hosts.d.ts +42 -0
- package/dist/docker-hosts.d.ts.map +1 -1
- package/dist/docker-hosts.js +18 -0
- package/dist/docker-hosts.js.map +1 -1
- package/dist/generated-artifact-attachments.d.ts +3 -0
- package/dist/generated-artifact-attachments.d.ts.map +1 -1
- package/dist/generated-artifact-attachments.js +18 -8
- package/dist/generated-artifact-attachments.js.map +1 -1
- package/dist/mux-interface.d.ts +7 -0
- package/dist/mux-interface.d.ts.map +1 -1
- package/dist/remote-files.d.ts +145 -0
- package/dist/remote-files.d.ts.map +1 -0
- package/dist/remote-files.js +347 -0
- package/dist/remote-files.js.map +1 -0
- package/dist/remote-hosts.d.ts +12 -0
- package/dist/remote-hosts.d.ts.map +1 -1
- package/dist/remote-hosts.js +7 -1
- package/dist/remote-hosts.js.map +1 -1
- package/dist/remote-ssh-limiter.d.ts +42 -0
- package/dist/remote-ssh-limiter.d.ts.map +1 -0
- package/dist/remote-ssh-limiter.js +84 -0
- package/dist/remote-ssh-limiter.js.map +1 -0
- package/dist/session-manager.d.ts.map +1 -1
- package/dist/session-manager.js +8 -1
- package/dist/session-manager.js.map +1 -1
- package/dist/session.d.ts +79 -2
- package/dist/session.d.ts.map +1 -1
- package/dist/session.js +163 -2
- package/dist/session.js.map +1 -1
- package/dist/tmux-manager.d.ts.map +1 -1
- package/dist/tmux-manager.js +27 -13
- package/dist/tmux-manager.js.map +1 -1
- package/dist/types/api.d.ts +6 -0
- package/dist/types/api.d.ts.map +1 -1
- package/dist/types/api.js.map +1 -1
- package/dist/types/session.d.ts +30 -0
- package/dist/types/session.d.ts.map +1 -1
- package/dist/types/session.js.map +1 -1
- package/dist/web/middleware/auth.d.ts +11 -1
- package/dist/web/middleware/auth.d.ts.map +1 -1
- package/dist/web/middleware/auth.js +69 -1
- package/dist/web/middleware/auth.js.map +1 -1
- package/dist/web/public/admin-ui.js.gz +0 -0
- package/dist/web/public/api-client.c9b1cddc.js.gz +0 -0
- package/dist/web/public/{app.78bf0bf8.js → app.556be563.js} +7 -7
- package/dist/web/public/app.556be563.js.br +0 -0
- package/dist/web/public/app.556be563.js.gz +0 -0
- package/dist/web/public/approvals-ui.js.gz +0 -0
- package/dist/web/public/constants.258b140f.js.gz +0 -0
- package/dist/web/public/cron-ui.js.gz +0 -0
- package/dist/web/public/entrance-animations.js.gz +0 -0
- package/dist/web/public/home-sessions.js.gz +0 -0
- package/dist/web/public/i18n.561ca09c.js.gz +0 -0
- package/dist/web/public/image-input.cd4b97c4.js.gz +0 -0
- package/dist/web/public/index.html +14 -7
- package/dist/web/public/index.html.br +0 -0
- package/dist/web/public/index.html.gz +0 -0
- package/dist/web/public/input-cjk.8bc46081.js +1 -0
- package/dist/web/public/input-cjk.8bc46081.js.br +0 -0
- package/dist/web/public/input-cjk.8bc46081.js.gz +0 -0
- package/dist/web/public/{keyboard-accessory.1d0dda9b.js → keyboard-accessory.2cf04f17.js} +38 -3
- package/dist/web/public/keyboard-accessory.2cf04f17.js.br +0 -0
- package/dist/web/public/keyboard-accessory.2cf04f17.js.gz +0 -0
- package/dist/web/public/mobile-handlers.6f354a87.js.gz +0 -0
- package/dist/web/public/mobile-overview.js.gz +0 -0
- package/dist/web/public/mobile.e9d0b53e.css.gz +0 -0
- package/dist/web/public/notification-manager.36ea4624.js.gz +0 -0
- package/dist/web/public/orchestrator-panel.js.gz +0 -0
- package/dist/web/public/panels-ui.5b07ad14.js.gz +0 -0
- package/dist/web/public/ralph-panel.6de2d0f8.js.gz +0 -0
- package/dist/web/public/ralph-wizard.13a1831e.js.gz +0 -0
- package/dist/web/public/readmymind-ui.js.gz +0 -0
- package/dist/web/public/respawn-ui.ff0dae4c.js.gz +0 -0
- package/dist/web/public/sanitize-html.bc7078d6.js.gz +0 -0
- package/dist/web/public/session-lineage.js.gz +0 -0
- package/dist/web/public/{session-ui.5d0a7b15.js → session-ui.42b81477.js} +13 -13
- package/dist/web/public/session-ui.42b81477.js.br +0 -0
- package/dist/web/public/session-ui.42b81477.js.gz +0 -0
- package/dist/web/public/settings-ui.e0c7f6b4.js.gz +0 -0
- package/dist/web/public/{styles.5d36726d.css → styles.6add175d.css} +1 -1
- package/dist/web/public/styles.6add175d.css.br +0 -0
- package/dist/web/public/{styles.5d36726d.css.gz → styles.6add175d.css.gz} +0 -0
- package/dist/web/public/subagent-windows.e6ca799f.js.gz +0 -0
- package/dist/web/public/sw.js.gz +0 -0
- package/dist/web/public/tab-rail-resize.42c24949.js.gz +0 -0
- package/dist/web/public/terminal-keycode229-recovery.6ea8fe37.js.gz +0 -0
- package/dist/web/public/terminal-ui.38c49244.js +2 -0
- package/dist/web/public/terminal-ui.38c49244.js.br +0 -0
- package/dist/web/public/terminal-ui.38c49244.js.gz +0 -0
- package/dist/web/public/ultracode-panel.js.gz +0 -0
- package/dist/web/public/ultracode-windows.js.gz +0 -0
- package/dist/web/public/upload.html.gz +0 -0
- package/dist/web/public/vendor/dompurify.min.js.gz +0 -0
- package/dist/web/public/vendor/marked.min.js.gz +0 -0
- package/dist/web/public/vendor/xterm-addon-fit.min.js.gz +0 -0
- package/dist/web/public/vendor/xterm-addon-serialize.min.js.gz +0 -0
- package/dist/web/public/vendor/xterm-addon-unicode11.min.js.gz +0 -0
- package/dist/web/public/vendor/xterm-addon-webgl.min.js.gz +0 -0
- package/dist/web/public/vendor/xterm-predictive-echo.bd6882b8.js.gz +0 -0
- package/dist/web/public/vendor/xterm-zerolag-input.6fee72f2.js.gz +0 -0
- package/dist/web/public/vendor/xterm.css.gz +0 -0
- package/dist/web/public/vendor/xterm.min.js.gz +0 -0
- package/dist/web/public/voice-input.c4b51eb6.js.gz +0 -0
- package/dist/web/public/voice-pcm-worklet.js.gz +0 -0
- package/dist/web/public/webview-tabs.js +56 -4
- package/dist/web/public/webview-tabs.js.br +0 -0
- package/dist/web/public/webview-tabs.js.gz +0 -0
- package/dist/web/route-helpers.d.ts +20 -0
- package/dist/web/route-helpers.d.ts.map +1 -1
- package/dist/web/route-helpers.js +29 -2
- package/dist/web/route-helpers.js.map +1 -1
- package/dist/web/routes/case-routes.d.ts.map +1 -1
- package/dist/web/routes/case-routes.js +23 -6
- package/dist/web/routes/case-routes.js.map +1 -1
- package/dist/web/routes/custom-model-routes.d.ts +18 -0
- package/dist/web/routes/custom-model-routes.d.ts.map +1 -0
- package/dist/web/routes/custom-model-routes.js +136 -0
- package/dist/web/routes/custom-model-routes.js.map +1 -0
- package/dist/web/routes/file-routes.d.ts.map +1 -1
- package/dist/web/routes/file-routes.js +484 -100
- package/dist/web/routes/file-routes.js.map +1 -1
- package/dist/web/routes/index.d.ts +1 -0
- package/dist/web/routes/index.d.ts.map +1 -1
- package/dist/web/routes/index.js +1 -0
- package/dist/web/routes/index.js.map +1 -1
- package/dist/web/routes/session-routes.d.ts.map +1 -1
- package/dist/web/routes/session-routes.js +81 -1
- package/dist/web/routes/session-routes.js.map +1 -1
- package/dist/web/routes/ws-routes.d.ts.map +1 -1
- package/dist/web/routes/ws-routes.js +26 -2
- package/dist/web/routes/ws-routes.js.map +1 -1
- package/dist/web/schemas.d.ts +20 -0
- package/dist/web/schemas.d.ts.map +1 -1
- package/dist/web/schemas.js +31 -0
- package/dist/web/schemas.js.map +1 -1
- package/dist/web/server.d.ts +8 -0
- package/dist/web/server.d.ts.map +1 -1
- package/dist/web/server.js +65 -3
- package/dist/web/server.js.map +1 -1
- package/dist/web/webview-proxy.d.ts +41 -0
- package/dist/web/webview-proxy.d.ts.map +1 -1
- package/dist/web/webview-proxy.js +102 -1
- package/dist/web/webview-proxy.js.map +1 -1
- package/package.json +2 -2
- package/dist/web/public/app.78bf0bf8.js.br +0 -0
- package/dist/web/public/app.78bf0bf8.js.gz +0 -0
- package/dist/web/public/input-cjk.87f14251.js +0 -1
- package/dist/web/public/input-cjk.87f14251.js.br +0 -0
- package/dist/web/public/input-cjk.87f14251.js.gz +0 -0
- package/dist/web/public/keyboard-accessory.1d0dda9b.js.br +0 -0
- package/dist/web/public/keyboard-accessory.1d0dda9b.js.gz +0 -0
- package/dist/web/public/session-ui.5d0a7b15.js.br +0 -0
- package/dist/web/public/session-ui.5d0a7b15.js.gz +0 -0
- package/dist/web/public/styles.5d36726d.css.br +0 -0
- package/dist/web/public/terminal-ui.0cbe8637.js +0 -2
- package/dist/web/public/terminal-ui.0cbe8637.js.br +0 -0
- package/dist/web/public/terminal-ui.0cbe8637.js.gz +0 -0
|
@@ -19,7 +19,8 @@ import { getOfficePreviewPdfPath, getPreviewPdfDownloadName } from '../../docume
|
|
|
19
19
|
import { sanitizeAttachmentHistoryItem } from '../../session-attachment-history.js';
|
|
20
20
|
import { isBlockedAttachmentPath, isUnderTree, loadAttachmentGuardConfig } from '../../config/attachment-guard.js';
|
|
21
21
|
import { isMultiUserMode, userSpacePath } from '../../config/multiuser.js';
|
|
22
|
-
import { CASES_DIR, canAccessOwned, findSessionOrFail, getAuthUser, parseBody, validateSessionFilePath, } from '../route-helpers.js';
|
|
22
|
+
import { CASES_DIR, canAccessOwned, findSessionOrFail, getAuthUser, parseBody, validateSessionFilePath, validateSessionFilePathLexical, } from '../route-helpers.js';
|
|
23
|
+
import { RemoteFileAccessError, remoteCreateReadStream, remoteProbePaths, remoteReadFile, } from '../../remote-files.js';
|
|
23
24
|
import { downloadTooLargeMessage, exceedsDownloadLimit } from '../../config/buffer-limits.js';
|
|
24
25
|
import { parseByteRange } from '../http-range.js';
|
|
25
26
|
import { isSensitivePath } from '../sensitive-path.js';
|
|
@@ -63,7 +64,7 @@ function buildContentDisposition(disposition, fileName) {
|
|
|
63
64
|
const encoded = encodeURIComponent(cleaned).replace(/['()*]/g, (char) => `%${char.charCodeAt(0).toString(16).toUpperCase()}`);
|
|
64
65
|
return `${disposition}; filename="${fallback}"; filename*=UTF-8''${encoded}`;
|
|
65
66
|
}
|
|
66
|
-
function sendRawStream(reply, content) {
|
|
67
|
+
function sendRawStream(reply, content, cleanup) {
|
|
67
68
|
const headers = reply.getHeaders();
|
|
68
69
|
// hijack() answers on reply.raw, which keeps Fastify's own status handling out
|
|
69
70
|
// of the picture — so a 206 set with reply.code() has to be carried across by
|
|
@@ -77,6 +78,23 @@ function sendRawStream(reply, content) {
|
|
|
77
78
|
reply.raw.setHeader(name, value);
|
|
78
79
|
}
|
|
79
80
|
}
|
|
81
|
+
// A remote body is an `ssh` child process, not a file handle: it has to be reaped
|
|
82
|
+
// when the client goes away (tab closed, video seek, a cancelled fetch), or the
|
|
83
|
+
// ssh process outlives the request. Registered here because this is the one place
|
|
84
|
+
// that owns the response's lifecycle.
|
|
85
|
+
//
|
|
86
|
+
// ⚠️ Check BEFORE attaching: the guard probe that ran ahead of this is an ssh round
|
|
87
|
+
// trip, and a client that gave up during it has already closed the response, so
|
|
88
|
+
// `close` has already fired and a listener attached now would never run. The
|
|
89
|
+
// `open()` call above still spawned the body's ssh child; reap it here instead.
|
|
90
|
+
if (reply.raw.destroyed) {
|
|
91
|
+
cleanup?.();
|
|
92
|
+
content.destroy();
|
|
93
|
+
return;
|
|
94
|
+
}
|
|
95
|
+
if (cleanup) {
|
|
96
|
+
reply.raw.on('close', cleanup);
|
|
97
|
+
}
|
|
80
98
|
content.on('error', (err) => {
|
|
81
99
|
if (reply.raw.headersSent) {
|
|
82
100
|
reply.raw.destroy(err);
|
|
@@ -87,6 +105,12 @@ function sendRawStream(reply, content) {
|
|
|
87
105
|
});
|
|
88
106
|
content.pipe(reply.raw);
|
|
89
107
|
}
|
|
108
|
+
/** Byte source for a LOCAL file (the historical, only path). */
|
|
109
|
+
function localFileSource(resolvedPath) {
|
|
110
|
+
return (range) => ({
|
|
111
|
+
content: createReadStream(resolvedPath, range ? { start: range.start, end: range.end } : undefined),
|
|
112
|
+
});
|
|
113
|
+
}
|
|
90
114
|
/**
|
|
91
115
|
* Stream a file body, honoring a `Range` request header.
|
|
92
116
|
*
|
|
@@ -97,8 +121,12 @@ function sendRawStream(reply, content) {
|
|
|
97
121
|
* does nothing and `currentTime = x` is silently reverted (measured against an
|
|
98
122
|
* 18MB mp4 before this existed). It also stops each seek from re-reading the
|
|
99
123
|
* whole file into memory.
|
|
124
|
+
*
|
|
125
|
+
* `open` supplies the bytes for the (optional) range, which is what lets a remote
|
|
126
|
+
* case reuse this instead of re-implementing the 206/416 contract: same headers,
|
|
127
|
+
* same status codes, whether the file sits on this host or behind an ssh pipe.
|
|
100
128
|
*/
|
|
101
|
-
function sendFileBody(reply,
|
|
129
|
+
function sendFileBody(reply, size, rangeHeader, open) {
|
|
102
130
|
reply.header('Accept-Ranges', 'bytes');
|
|
103
131
|
const range = parseByteRange(rangeHeader, size);
|
|
104
132
|
if (range.kind === 'unsatisfiable') {
|
|
@@ -113,16 +141,17 @@ function sendFileBody(reply, resolvedPath, size, rangeHeader) {
|
|
|
113
141
|
reply.code(206);
|
|
114
142
|
reply.header('Content-Range', `bytes ${range.start}-${range.end}/${size}`);
|
|
115
143
|
reply.header('Content-Length', range.end - range.start + 1);
|
|
116
|
-
|
|
144
|
+
const { content, cleanup } = open({ start: range.start, end: range.end });
|
|
145
|
+
sendRawStream(reply, content, cleanup);
|
|
117
146
|
return;
|
|
118
147
|
}
|
|
119
148
|
reply.header('Content-Length', size);
|
|
120
|
-
|
|
149
|
+
const { content, cleanup } = open();
|
|
150
|
+
sendRawStream(reply, content, cleanup);
|
|
121
151
|
}
|
|
122
|
-
async function serveRawFile(reply,
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
reply.code(413).send(createErrorResponse(ApiErrorCode.INVALID_INPUT, downloadTooLargeMessage(stat.size)));
|
|
152
|
+
async function serveRawFile(reply, target, size, fileName, extension, download, rangeHeader) {
|
|
153
|
+
if (exceedsDownloadLimit(size)) {
|
|
154
|
+
reply.code(413).send(createErrorResponse(ApiErrorCode.INVALID_INPUT, downloadTooLargeMessage(size)));
|
|
126
155
|
return;
|
|
127
156
|
}
|
|
128
157
|
// Markup is download-only: served with a renderable type on our own origin it
|
|
@@ -135,7 +164,7 @@ async function serveRawFile(reply, resolvedPath, fileName, extension, download,
|
|
|
135
164
|
reply.header('Content-Type', markupOnly ? 'application/octet-stream' : MIME_TYPES[extension] || 'application/octet-stream');
|
|
136
165
|
reply.header('Content-Disposition', buildContentDisposition('attachment', fileName));
|
|
137
166
|
reply.header('X-Content-Type-Options', 'nosniff');
|
|
138
|
-
sendFileBody(reply,
|
|
167
|
+
sendFileBody(reply, size, rangeHeader, fileTargetSource(target));
|
|
139
168
|
return;
|
|
140
169
|
}
|
|
141
170
|
// Plain text with no dedicated MIME entry (code, config, logs, csv, xml) goes
|
|
@@ -145,13 +174,13 @@ async function serveRawFile(reply, resolvedPath, fileName, extension, download,
|
|
|
145
174
|
reply.header('Content-Type', 'text/plain; charset=utf-8');
|
|
146
175
|
reply.header('Content-Disposition', buildContentDisposition('inline', fileName));
|
|
147
176
|
reply.header('X-Content-Type-Options', 'nosniff');
|
|
148
|
-
sendFileBody(reply,
|
|
177
|
+
sendFileBody(reply, size, rangeHeader, fileTargetSource(target));
|
|
149
178
|
return;
|
|
150
179
|
}
|
|
151
180
|
reply.header('Content-Type', MIME_TYPES[extension] || 'application/octet-stream');
|
|
152
181
|
reply.header('Content-Disposition', buildContentDisposition('inline', fileName));
|
|
153
182
|
reply.header('X-Content-Type-Options', 'nosniff');
|
|
154
|
-
sendFileBody(reply,
|
|
183
|
+
sendFileBody(reply, size, rangeHeader, fileTargetSource(target));
|
|
155
184
|
}
|
|
156
185
|
function getAttachmentOr404(reply, sessionId, attachmentId) {
|
|
157
186
|
const record = attachmentRegistry.get(sessionId, attachmentId);
|
|
@@ -169,10 +198,18 @@ function getAttachmentOr404(reply, sessionId, attachmentId) {
|
|
|
169
198
|
* record pointing at a symlink that now resolves to a sensitive target is also
|
|
170
199
|
* caught; if the path can't be resolved (deleted/unreadable) the check still
|
|
171
200
|
* runs on the stored path. When workspace confinement is enabled it additionally
|
|
172
|
-
* rejects any record outside the session workspace. Returns
|
|
201
|
+
* rejects any record outside the session workspace. Returns null (and sends a
|
|
173
202
|
* 403) when blocked.
|
|
203
|
+
*
|
|
204
|
+
* A remote case resolves the same checks on the remote host (see
|
|
205
|
+
* {@link resolveServableRemoteAttachment}); `scope` — not just the working dir — is
|
|
206
|
+
* what tells the two apart, because the same absolute path STRING means a different
|
|
207
|
+
* file on each host.
|
|
174
208
|
*/
|
|
175
|
-
async function resolveServableAttachmentPath(reply, record,
|
|
209
|
+
async function resolveServableAttachmentPath(reply, record, scope) {
|
|
210
|
+
if (scope.remote) {
|
|
211
|
+
return resolveServableRemoteAttachment(reply, record, scope);
|
|
212
|
+
}
|
|
176
213
|
let pathToCheck = record.filePath;
|
|
177
214
|
let resolved = false;
|
|
178
215
|
try {
|
|
@@ -185,7 +222,7 @@ async function resolveServableAttachmentPath(reply, record, sessionWorkingDir) {
|
|
|
185
222
|
const guard = await loadAttachmentGuardConfig();
|
|
186
223
|
const blocked = isBlockedAttachmentPath(pathToCheck, guard.blockedTrees) ||
|
|
187
224
|
isBlockedAttachmentPath(record.filePath, guard.blockedTrees) ||
|
|
188
|
-
(guard.confineToWorkspace && (!
|
|
225
|
+
(guard.confineToWorkspace && (!scope.workingDir || !validateSessionFilePath(scope.workingDir, pathToCheck)));
|
|
189
226
|
if (blocked) {
|
|
190
227
|
reply.code(403).send(createErrorResponse(ApiErrorCode.INVALID_INPUT, 'Access to this file is blocked'));
|
|
191
228
|
return null;
|
|
@@ -193,7 +230,51 @@ async function resolveServableAttachmentPath(reply, record, sessionWorkingDir) {
|
|
|
193
230
|
// Serve the freshly-resolved path, not the stored one: if a path component
|
|
194
231
|
// became a symlink after registration, the guard checked the resolved target
|
|
195
232
|
// but streaming record.filePath would follow the symlink to a swapped file.
|
|
196
|
-
return resolved ? pathToCheck : record.filePath;
|
|
233
|
+
return { path: resolved ? pathToCheck : record.filePath };
|
|
234
|
+
}
|
|
235
|
+
/**
|
|
236
|
+
* Remote counterpart of {@link resolveServableAttachmentPath}.
|
|
237
|
+
*
|
|
238
|
+
* The record's stored path was already symlink-resolved on the remote host at
|
|
239
|
+
* registration time; re-probing keeps the same defense-in-depth against a path that
|
|
240
|
+
* changed into a symlink afterwards, and yields the size/mtime the serving route needs
|
|
241
|
+
* anyway — so this costs one ssh round trip, not two.
|
|
242
|
+
*
|
|
243
|
+
* The blocked-tree list is a pattern list over absolute paths, so it is host-agnostic
|
|
244
|
+
* and applies unchanged. An unreachable host is a 502, not a silent "blocked".
|
|
245
|
+
*/
|
|
246
|
+
async function resolveServableRemoteAttachment(reply, record, scope) {
|
|
247
|
+
const remote = scope.remote;
|
|
248
|
+
if (!remote)
|
|
249
|
+
return null;
|
|
250
|
+
let probes;
|
|
251
|
+
try {
|
|
252
|
+
probes = await remoteProbePaths(remote, [record.filePath, scope.workingDir]);
|
|
253
|
+
}
|
|
254
|
+
catch (err) {
|
|
255
|
+
const detail = err instanceof RemoteFileAccessError ? err.message : getErrorMessage(err);
|
|
256
|
+
reply.code(502).send(createErrorResponse(ApiErrorCode.OPERATION_FAILED, detail));
|
|
257
|
+
return null;
|
|
258
|
+
}
|
|
259
|
+
const [probe, rootProbe] = probes;
|
|
260
|
+
// Unlike the local branch there is no stale-path fallback to fall back TO: the file
|
|
261
|
+
// is either on the remote host or it is gone, and the local `fs` was never able to
|
|
262
|
+
// answer for it. A vanished attachment answers 404 here (the local path lets its
|
|
263
|
+
// stat throw and answers 500 — a historical wart, not worth copying).
|
|
264
|
+
if (!probe) {
|
|
265
|
+
reply.code(404).send(createErrorResponse(ApiErrorCode.NOT_FOUND, 'Attachment file not found'));
|
|
266
|
+
return null;
|
|
267
|
+
}
|
|
268
|
+
const guard = await loadAttachmentGuardConfig();
|
|
269
|
+
const root = rootProbe?.realPath ?? scope.workingDir;
|
|
270
|
+
const blocked = isBlockedAttachmentPath(probe.realPath, guard.blockedTrees) ||
|
|
271
|
+
isBlockedAttachmentPath(record.filePath, guard.blockedTrees) ||
|
|
272
|
+
(guard.confineToWorkspace && !isPathWithinRoot(root, probe.realPath));
|
|
273
|
+
if (blocked) {
|
|
274
|
+
reply.code(403).send(createErrorResponse(ApiErrorCode.INVALID_INPUT, 'Access to this file is blocked'));
|
|
275
|
+
return null;
|
|
276
|
+
}
|
|
277
|
+
return { path: probe.realPath, probe };
|
|
197
278
|
}
|
|
198
279
|
/**
|
|
199
280
|
* Convert a DOCX/PPTX to a single-PDF preview (LibreOffice when available) and
|
|
@@ -245,18 +326,128 @@ async function serveThumbnail(reply, resolvedPath, extension) {
|
|
|
245
326
|
* that has since detached. Sends a 404 and returns undefined when unknown.
|
|
246
327
|
*/
|
|
247
328
|
function getKnownSessionWorkingDir(ctx, sessionId, reply, req) {
|
|
329
|
+
// One implementation of the live-or-persisted lookup and its ownership rule; this
|
|
330
|
+
// wrapper exists for the callers that only need the workspace PATH.
|
|
331
|
+
return getKnownSessionFileScope(ctx, sessionId, reply, req)?.workingDir;
|
|
332
|
+
}
|
|
333
|
+
/**
|
|
334
|
+
* The file-access scope of a session, live or persisted — the `workingDir`-only
|
|
335
|
+
* variant of {@link getKnownSessionWorkingDir}, plus the remote metadata the routes
|
|
336
|
+
* need to decide WHERE to read. Same 404-and-return-undefined contract for an
|
|
337
|
+
* unknown/foreign session, so multi-user scoping is unchanged.
|
|
338
|
+
*/
|
|
339
|
+
function getKnownSessionFileScope(ctx, sessionId, reply, req) {
|
|
248
340
|
// Multi-user: a non-admin may only reach their OWN session's files. A foreign
|
|
249
341
|
// (or missing) session is reported identically as 404 so existence isn't leaked.
|
|
250
342
|
const user = getAuthUser(req);
|
|
251
343
|
const liveSession = ctx.sessions.get(sessionId);
|
|
252
|
-
if (liveSession && canAccessOwned(user, liveSession.owner))
|
|
253
|
-
return liveSession.workingDir;
|
|
344
|
+
if (liveSession && canAccessOwned(user, liveSession.owner)) {
|
|
345
|
+
return { workingDir: liveSession.workingDir, remote: liveSession.remote };
|
|
346
|
+
}
|
|
254
347
|
const stored = ctx.store.getSession(sessionId);
|
|
255
|
-
if (stored && canAccessOwned(user, stored.owner))
|
|
256
|
-
return stored.workingDir;
|
|
348
|
+
if (stored && canAccessOwned(user, stored.owner)) {
|
|
349
|
+
return { workingDir: stored.workingDir, remote: stored.remote };
|
|
350
|
+
}
|
|
257
351
|
reply.code(404).send(createErrorResponse(ApiErrorCode.NOT_FOUND, `Session ${sessionId} not found`));
|
|
258
352
|
return undefined;
|
|
259
353
|
}
|
|
354
|
+
/**
|
|
355
|
+
* Resolve a request's `?path=` against the session's file scope.
|
|
356
|
+
*
|
|
357
|
+
* Never throws: a transport failure comes back as `status: 502` with the remote
|
|
358
|
+
* reason, so an unreachable host reads as an infrastructure problem instead of the
|
|
359
|
+
* 404 that used to make it look like user error.
|
|
360
|
+
*/
|
|
361
|
+
async function resolveFileTarget(scope, filePath) {
|
|
362
|
+
if (!scope.remote) {
|
|
363
|
+
const validated = validateSessionFilePath(scope.workingDir, filePath);
|
|
364
|
+
if (!validated) {
|
|
365
|
+
return {
|
|
366
|
+
ok: false,
|
|
367
|
+
reason: 'not-found',
|
|
368
|
+
status: 404,
|
|
369
|
+
errorCode: ApiErrorCode.NOT_FOUND,
|
|
370
|
+
message: 'File not found',
|
|
371
|
+
};
|
|
372
|
+
}
|
|
373
|
+
return { ok: true, target: { kind: 'local', ...validated } };
|
|
374
|
+
}
|
|
375
|
+
const remote = scope.remote;
|
|
376
|
+
// Cheap lexical reject BEFORE opening a connection: a `../` escape never needs to
|
|
377
|
+
// be asked about on the remote host.
|
|
378
|
+
const lexical = validateSessionFilePathLexical(scope.workingDir, filePath);
|
|
379
|
+
if (!lexical) {
|
|
380
|
+
return {
|
|
381
|
+
ok: false,
|
|
382
|
+
reason: 'not-found',
|
|
383
|
+
status: 404,
|
|
384
|
+
errorCode: ApiErrorCode.NOT_FOUND,
|
|
385
|
+
message: 'File not found',
|
|
386
|
+
};
|
|
387
|
+
}
|
|
388
|
+
let probes;
|
|
389
|
+
try {
|
|
390
|
+
// Both paths in one ssh round trip: the containment check below is only honest
|
|
391
|
+
// when the workspace itself is canonicalized remotely too (a symlinked
|
|
392
|
+
// `remotePath` is ordinary, and comparing a realpath'd file against a
|
|
393
|
+
// non-canonical root would refuse every read in that case).
|
|
394
|
+
probes = await remoteProbePaths(remote, [lexical.resolvedPath, scope.workingDir]);
|
|
395
|
+
}
|
|
396
|
+
catch (err) {
|
|
397
|
+
const detail = err instanceof RemoteFileAccessError ? err.message : getErrorMessage(err);
|
|
398
|
+
return {
|
|
399
|
+
ok: false,
|
|
400
|
+
reason: 'unreachable',
|
|
401
|
+
status: 502,
|
|
402
|
+
errorCode: ApiErrorCode.OPERATION_FAILED,
|
|
403
|
+
message: detail,
|
|
404
|
+
};
|
|
405
|
+
}
|
|
406
|
+
const [fileProbe, rootProbe] = probes;
|
|
407
|
+
if (!fileProbe) {
|
|
408
|
+
return {
|
|
409
|
+
ok: false,
|
|
410
|
+
reason: 'not-found',
|
|
411
|
+
status: 404,
|
|
412
|
+
errorCode: ApiErrorCode.NOT_FOUND,
|
|
413
|
+
message: 'File not found',
|
|
414
|
+
};
|
|
415
|
+
}
|
|
416
|
+
const root = rootProbe?.realPath ?? resolve(scope.workingDir);
|
|
417
|
+
if (!isPathWithinRoot(root, fileProbe.realPath)) {
|
|
418
|
+
return {
|
|
419
|
+
ok: false,
|
|
420
|
+
reason: 'not-found',
|
|
421
|
+
status: 404,
|
|
422
|
+
errorCode: ApiErrorCode.NOT_FOUND,
|
|
423
|
+
message: 'File not found',
|
|
424
|
+
};
|
|
425
|
+
}
|
|
426
|
+
return {
|
|
427
|
+
ok: true,
|
|
428
|
+
target: {
|
|
429
|
+
kind: 'remote',
|
|
430
|
+
resolvedPath: fileProbe.realPath,
|
|
431
|
+
relativePath: relative(root, fileProbe.realPath),
|
|
432
|
+
remote,
|
|
433
|
+
probe: fileProbe,
|
|
434
|
+
},
|
|
435
|
+
};
|
|
436
|
+
}
|
|
437
|
+
/**
|
|
438
|
+
* The bytes of a resolved target, as a Range-aware source for `sendFileBody`.
|
|
439
|
+
*
|
|
440
|
+
* The remote source's `cleanup` is what keeps a client that aborts a download from
|
|
441
|
+
* leaving an `ssh` process behind (see `sendRawStream`).
|
|
442
|
+
*/
|
|
443
|
+
function fileTargetSource(target) {
|
|
444
|
+
if (target.kind === 'local')
|
|
445
|
+
return localFileSource(target.resolvedPath);
|
|
446
|
+
return (range) => {
|
|
447
|
+
const remote = remoteCreateReadStream(target.remote, target.resolvedPath, range);
|
|
448
|
+
return { content: remote.stream, cleanup: () => remote.close() };
|
|
449
|
+
};
|
|
450
|
+
}
|
|
260
451
|
const FILESYSTEM_PICKER_ENTRY_LIMIT = 500;
|
|
261
452
|
const FILESYSTEM_TEXT_PREVIEW_LIMIT = 2 * 1024 * 1024;
|
|
262
453
|
const FILESYSTEM_BINARY_PREVIEW_LIMIT = 50 * 1024 * 1024;
|
|
@@ -492,6 +683,19 @@ function sniffsBinary(buf) {
|
|
|
492
683
|
* failures grep distinctly.
|
|
493
684
|
*/
|
|
494
685
|
function throwFileEditError(statusCode, code, message) {
|
|
686
|
+
throwRouteError(statusCode, code, message);
|
|
687
|
+
}
|
|
688
|
+
/**
|
|
689
|
+
* Throw an error the central route handler renders at `statusCode`.
|
|
690
|
+
*
|
|
691
|
+
* The one way to answer a NON-2xx status from a handler that otherwise RETURNS its
|
|
692
|
+
* error envelope: a returned envelope is wrapped by the preSerialization hook in
|
|
693
|
+
* production but arrives as a plain 200 in the `app.inject()` harness, so a status
|
|
694
|
+
* asserted from a return value would be a test that cannot fail. `file-content`
|
|
695
|
+
* predates that and keeps its returned envelopes for the errors it always had; every
|
|
696
|
+
* NEW failure reason there (and everywhere in `file-raw`) goes through here.
|
|
697
|
+
*/
|
|
698
|
+
function throwRouteError(statusCode, code, message) {
|
|
495
699
|
throw Object.assign(new Error(message), {
|
|
496
700
|
statusCode,
|
|
497
701
|
body: createErrorResponse(code, message),
|
|
@@ -538,7 +742,7 @@ function getSessionAttachmentHistory(ctx, sessionId, req) {
|
|
|
538
742
|
if (!canAccessOwned(user, liveSession.owner))
|
|
539
743
|
return undefined;
|
|
540
744
|
return {
|
|
541
|
-
workingDir: liveSession.workingDir,
|
|
745
|
+
scope: { workingDir: liveSession.workingDir, remote: liveSession.remote },
|
|
542
746
|
history: liveSession.getAttachmentHistoryForPersist() ?? liveSession.attachmentHistory ?? [],
|
|
543
747
|
};
|
|
544
748
|
}
|
|
@@ -546,30 +750,92 @@ function getSessionAttachmentHistory(ctx, sessionId, req) {
|
|
|
546
750
|
if (!stored || !canAccessOwned(user, stored.owner))
|
|
547
751
|
return undefined;
|
|
548
752
|
return {
|
|
549
|
-
workingDir: stored.workingDir,
|
|
753
|
+
scope: { workingDir: stored.workingDir, remote: stored.remote },
|
|
550
754
|
history: stored.__attachmentHistory ?? stored.attachmentHistory ?? [],
|
|
551
755
|
};
|
|
552
756
|
}
|
|
757
|
+
async function probeRemoteAttachmentHistory(scope, history) {
|
|
758
|
+
const remote = scope.remote;
|
|
759
|
+
if (!remote || history.length === 0)
|
|
760
|
+
return undefined;
|
|
761
|
+
const paths = new Set();
|
|
762
|
+
for (const item of history) {
|
|
763
|
+
if (item.source === 'external') {
|
|
764
|
+
if (item.externalPath)
|
|
765
|
+
paths.add(item.externalPath);
|
|
766
|
+
}
|
|
767
|
+
else if (item.relativePath) {
|
|
768
|
+
const lexical = validateSessionFilePathLexical(scope.workingDir, item.relativePath);
|
|
769
|
+
if (lexical)
|
|
770
|
+
paths.add(lexical.resolvedPath);
|
|
771
|
+
}
|
|
772
|
+
}
|
|
773
|
+
const list = [...paths];
|
|
774
|
+
try {
|
|
775
|
+
const [root, ...rest] = await remoteProbePaths(remote, [scope.workingDir, ...list]);
|
|
776
|
+
return { root, byPath: new Map(list.map((path, index) => [path, rest[index] ?? null])), unreachable: false };
|
|
777
|
+
}
|
|
778
|
+
catch {
|
|
779
|
+
return { root: null, byPath: new Map(), unreachable: true };
|
|
780
|
+
}
|
|
781
|
+
}
|
|
553
782
|
// History item for a file detected inside the workspace: re-stat for live
|
|
554
783
|
// size/mtime and resolve preview/thumbnail/raw routes off the relative path.
|
|
555
|
-
async function buildDetectedAttachmentRouteItem(sessionId,
|
|
784
|
+
async function buildDetectedAttachmentRouteItem(sessionId, scope, item, batch) {
|
|
556
785
|
const safe = sanitizeAttachmentHistoryItem(item);
|
|
557
786
|
if (!item.relativePath) {
|
|
558
787
|
return { ...safe, missing: true };
|
|
559
788
|
}
|
|
560
|
-
const
|
|
561
|
-
|
|
562
|
-
return { ...safe, missing: true };
|
|
563
|
-
}
|
|
789
|
+
const workingDir = scope.workingDir;
|
|
790
|
+
let resolvedPath;
|
|
564
791
|
let size = item.size;
|
|
565
792
|
let mtimeMs = item.mtimeMs;
|
|
566
|
-
|
|
567
|
-
|
|
568
|
-
|
|
569
|
-
|
|
793
|
+
if (scope.remote) {
|
|
794
|
+
// Same check as the local branch (a workspace-relative entry must still resolve
|
|
795
|
+
// inside the workspace), executed on the host that owns the files.
|
|
796
|
+
const lexical = validateSessionFilePathLexical(workingDir, item.relativePath);
|
|
797
|
+
if (!lexical)
|
|
798
|
+
return { ...safe, missing: true };
|
|
799
|
+
let probe;
|
|
800
|
+
let rootProbe;
|
|
801
|
+
if (batch) {
|
|
802
|
+
// The list route resolved the whole history in one round trip.
|
|
803
|
+
if (batch.unreachable)
|
|
804
|
+
return { ...safe, missing: false, size, mtimeMs };
|
|
805
|
+
probe = batch.byPath.get(lexical.resolvedPath) ?? null;
|
|
806
|
+
rootProbe = batch.root;
|
|
807
|
+
}
|
|
808
|
+
else {
|
|
809
|
+
try {
|
|
810
|
+
[probe, rootProbe] = await remoteProbePaths(scope.remote, [lexical.resolvedPath, workingDir]);
|
|
811
|
+
}
|
|
812
|
+
catch {
|
|
813
|
+
// Unreachable host: the entry is not "missing", it is unknown. Reporting it as
|
|
814
|
+
// missing would tell the user their file is gone when its host is merely asleep.
|
|
815
|
+
return { ...safe, missing: false, size, mtimeMs };
|
|
816
|
+
}
|
|
817
|
+
}
|
|
818
|
+
if (!probe || !isPathWithinRoot(rootProbe?.realPath ?? workingDir, probe.realPath)) {
|
|
819
|
+
return { ...safe, missing: true };
|
|
820
|
+
}
|
|
821
|
+
resolvedPath = probe.realPath;
|
|
822
|
+
size = probe.size;
|
|
823
|
+
mtimeMs = probe.mtimeMs;
|
|
570
824
|
}
|
|
571
|
-
|
|
572
|
-
|
|
825
|
+
else {
|
|
826
|
+
const validated = validateSessionFilePath(workingDir, item.relativePath);
|
|
827
|
+
if (!validated) {
|
|
828
|
+
return { ...safe, missing: true };
|
|
829
|
+
}
|
|
830
|
+
resolvedPath = validated.resolvedPath;
|
|
831
|
+
try {
|
|
832
|
+
const stat = await fs.stat(resolvedPath);
|
|
833
|
+
size = stat.size;
|
|
834
|
+
mtimeMs = stat.mtimeMs ?? mtimeMs;
|
|
835
|
+
}
|
|
836
|
+
catch {
|
|
837
|
+
return { ...safe, missing: true };
|
|
838
|
+
}
|
|
573
839
|
}
|
|
574
840
|
const encodedPath = encodeURIComponent(item.relativePath);
|
|
575
841
|
const rawUrl = `/api/sessions/${sessionId}/file-raw?path=${encodedPath}`;
|
|
@@ -593,13 +859,22 @@ async function buildDetectedAttachmentRouteItem(sessionId, workingDir, item) {
|
|
|
593
859
|
}
|
|
594
860
|
// History item for an explicitly published external file: re-register it to mint
|
|
595
861
|
// a fresh id + by-id routes (the guard runs again), or mark it missing.
|
|
596
|
-
async function buildExternalAttachmentRouteItem(sessionId, item,
|
|
862
|
+
async function buildExternalAttachmentRouteItem(sessionId, item, scope, batch) {
|
|
597
863
|
const safe = sanitizeAttachmentHistoryItem(item);
|
|
598
864
|
if (!item.externalPath) {
|
|
599
865
|
return { ...safe, missing: true };
|
|
600
866
|
}
|
|
867
|
+
// Same answer as the detected branch for the same event: an unreachable host makes
|
|
868
|
+
// the entry unknown, never missing.
|
|
869
|
+
if (batch?.unreachable) {
|
|
870
|
+
return { ...safe, missing: false };
|
|
871
|
+
}
|
|
601
872
|
try {
|
|
602
|
-
const event = await registerExternalAttachment(sessionId, item.externalPath, {
|
|
873
|
+
const event = await registerExternalAttachment(sessionId, item.externalPath, {
|
|
874
|
+
sessionWorkingDir: scope.workingDir,
|
|
875
|
+
remote: scope.remote,
|
|
876
|
+
remoteProbes: batch ? [batch.byPath.get(item.externalPath) ?? null, batch.root] : undefined,
|
|
877
|
+
});
|
|
603
878
|
return {
|
|
604
879
|
...safe,
|
|
605
880
|
fileName: event.fileName,
|
|
@@ -617,7 +892,9 @@ async function buildExternalAttachmentRouteItem(sessionId, item, sessionWorkingD
|
|
|
617
892
|
}
|
|
618
893
|
catch (err) {
|
|
619
894
|
if (err instanceof AttachmentRegistrationError) {
|
|
620
|
-
|
|
895
|
+
// 502 is the transport, not the file (see resolveRemoteAttachment): unknown,
|
|
896
|
+
// like the detected branch. Anything else (404, 403, wrong kind) is missing.
|
|
897
|
+
return { ...safe, missing: err.statusCode === 502 ? false : true };
|
|
621
898
|
}
|
|
622
899
|
throw err;
|
|
623
900
|
}
|
|
@@ -809,7 +1086,7 @@ export function registerFileRoutes(app, ctx) {
|
|
|
809
1086
|
await serveConvertedPreview(reply, resolvedPath, fileName, extension);
|
|
810
1087
|
return;
|
|
811
1088
|
}
|
|
812
|
-
await serveRawFile(reply, resolvedPath, fileName, extension, false, req.headers.range);
|
|
1089
|
+
await serveRawFile(reply, { kind: 'local', resolvedPath, relativePath: '' }, stat.size, fileName, extension, false, req.headers.range);
|
|
813
1090
|
});
|
|
814
1091
|
// File tree listing
|
|
815
1092
|
app.get('/api/sessions/:id/files', async (req) => {
|
|
@@ -1015,11 +1292,26 @@ export function registerFileRoutes(app, ctx) {
|
|
|
1015
1292
|
return createErrorResponse(ApiErrorCode.INVALID_INPUT, 'Missing path parameter');
|
|
1016
1293
|
}
|
|
1017
1294
|
// Validate path is within working directory (security: resolve symlinks to prevent traversal)
|
|
1018
|
-
|
|
1019
|
-
|
|
1020
|
-
|
|
1295
|
+
// For a remote (SSH) case the same boundary is resolved on the remote host (#415),
|
|
1296
|
+
// where the path actually lives.
|
|
1297
|
+
const resolution = await resolveFileTarget({ workingDir: session.workingDir, remote: session.remote }, filePath);
|
|
1298
|
+
if (!resolution.ok) {
|
|
1299
|
+
// An unreachable remote is thrown so the shared handler answers a real 502 (and
|
|
1300
|
+
// the test can assert it); a refusal keeps this route's historical returned
|
|
1301
|
+
// envelope, which its existing tests pin as 200 + success:false.
|
|
1302
|
+
if (resolution.reason === 'unreachable') {
|
|
1303
|
+
throwRouteError(resolution.status, resolution.errorCode, resolution.message);
|
|
1304
|
+
}
|
|
1305
|
+
return createErrorResponse(resolution.errorCode, resolution.message);
|
|
1306
|
+
}
|
|
1307
|
+
const target = resolution.target;
|
|
1308
|
+
const { resolvedPath, relativePath } = target;
|
|
1309
|
+
// Remote WRITES are out of scope by design (docs/file-viewer-edit-plan.md §6: "do
|
|
1310
|
+
// not attempt an SFTP path"). Say so instead of returning the misleading 404 the
|
|
1311
|
+
// pre-#415 code produced, and never offer the editor: `editable` stays false below.
|
|
1312
|
+
if ((edit === '1' || edit === 'true') && target.kind === 'remote') {
|
|
1313
|
+
throwRouteError(400, ApiErrorCode.INVALID_INPUT, 'Editing is not supported for files in a remote (SSH) case');
|
|
1021
1314
|
}
|
|
1022
|
-
const { resolvedPath, relativePath } = validated;
|
|
1023
1315
|
// Read-for-edit: never truncated (a truncated buffer must never become an
|
|
1024
1316
|
// edit buffer), tighter size cap, full editability gate, and the hash/eol
|
|
1025
1317
|
// the client must echo back on PUT. Outside the shared try/catch below so
|
|
@@ -1060,7 +1352,11 @@ export function registerFileRoutes(app, ctx) {
|
|
|
1060
1352
|
};
|
|
1061
1353
|
}
|
|
1062
1354
|
try {
|
|
1063
|
-
|
|
1355
|
+
// Local: one stat. Remote: the resolution's probe already carries the size and
|
|
1356
|
+
// mtime, so the read path adds no second ssh round trip.
|
|
1357
|
+
const stat = target.kind === 'local'
|
|
1358
|
+
? await fs.stat(target.resolvedPath)
|
|
1359
|
+
: { size: target.probe.size, mtimeMs: target.probe.mtimeMs };
|
|
1064
1360
|
// Classify by extension. Known media types render with a dedicated player;
|
|
1065
1361
|
// other known-binary types are flagged so the client offers a download
|
|
1066
1362
|
// affordance instead of trying to decode the bytes as text. Matches the
|
|
@@ -1140,7 +1436,9 @@ export function registerFileRoutes(app, ctx) {
|
|
|
1140
1436
|
// is actually binary would otherwise be dumped to the viewer as UTF-8
|
|
1141
1437
|
// mojibake; a NUL byte in the first 8KB is a reliable binary signal that
|
|
1142
1438
|
// (unlike a static extension list) catches arbitrary binary formats.
|
|
1143
|
-
const fileBuffer =
|
|
1439
|
+
const fileBuffer = target.kind === 'local'
|
|
1440
|
+
? await fs.readFile(target.resolvedPath)
|
|
1441
|
+
: await remoteReadFile(target.remote, target.resolvedPath, MAX_TEXT_FILE_SIZE);
|
|
1144
1442
|
const buf = Buffer.isBuffer(fileBuffer) ? fileBuffer : Buffer.from(String(fileBuffer));
|
|
1145
1443
|
const sniffLength = Math.min(buf.length, 8192);
|
|
1146
1444
|
let looksBinary = false;
|
|
@@ -1173,13 +1471,21 @@ export function registerFileRoutes(app, ctx) {
|
|
|
1173
1471
|
// succeed. The UTF-8 round-trip compare is a cheap memcmp and mirrors
|
|
1174
1472
|
// decodeEditableText; no hash here — the Edit action re-fetches with
|
|
1175
1473
|
// edit=1, which is where the baseHash comes from.
|
|
1176
|
-
|
|
1177
|
-
|
|
1178
|
-
|
|
1179
|
-
|
|
1180
|
-
|
|
1181
|
-
|
|
1182
|
-
|
|
1474
|
+
//
|
|
1475
|
+
// A remote case is false by construction: the advertisement is a promise the
|
|
1476
|
+
// PUT route cannot keep over ssh, and the viewer keys its Edit affordance off
|
|
1477
|
+
// this flag.
|
|
1478
|
+
let editable = false;
|
|
1479
|
+
if (target.kind === 'local') {
|
|
1480
|
+
const guard = await loadAttachmentGuardConfig();
|
|
1481
|
+
editable =
|
|
1482
|
+
isEditableFileName(pathBasename(target.resolvedPath)) &&
|
|
1483
|
+
!isDeniedEditRelativePath(relativePath) &&
|
|
1484
|
+
!isSensitivePath(target.resolvedPath) &&
|
|
1485
|
+
!isBlockedAttachmentPath(target.resolvedPath, guard.blockedTrees) &&
|
|
1486
|
+
stat.size <= MAX_EDITABLE_BYTES &&
|
|
1487
|
+
Buffer.from(content, 'utf8').equals(buf);
|
|
1488
|
+
}
|
|
1183
1489
|
return {
|
|
1184
1490
|
success: true,
|
|
1185
1491
|
data: {
|
|
@@ -1210,6 +1516,16 @@ export function registerFileRoutes(app, ctx) {
|
|
|
1210
1516
|
app.put('/api/sessions/:id/file-content', { bodyLimit: 4 * 1024 * 1024 }, async (req) => {
|
|
1211
1517
|
const { id } = req.params;
|
|
1212
1518
|
const session = findSessionOrFail(ctx, id, req);
|
|
1519
|
+
// Remote WRITES are out of scope by design (docs/file-viewer-edit-plan.md §6),
|
|
1520
|
+
// and this guard must sit ahead of `validateSessionFilePath`: that helper
|
|
1521
|
+
// resolves against the LOCAL filesystem, so with a directory of the same
|
|
1522
|
+
// absolute name on this host (an sshfs mount of the remote tree, `/srv/case`,
|
|
1523
|
+
// a same-named home) the write would land on the local twin while the viewer
|
|
1524
|
+
// believes it edited the remote file. The read-remote/write-local split is
|
|
1525
|
+
// exactly what the no-local-fallback rule exists to prevent.
|
|
1526
|
+
if (session.remote) {
|
|
1527
|
+
throwFileEditError(400, ApiErrorCode.INVALID_INPUT, 'Editing is not supported for files in a remote (SSH) case');
|
|
1528
|
+
}
|
|
1213
1529
|
const body = parseBody(FileWriteSchema, req.body);
|
|
1214
1530
|
// Exact byte cap — the schema's .max() counts UTF-16 code units and is
|
|
1215
1531
|
// only a coarse pre-filter.
|
|
@@ -1301,18 +1617,22 @@ export function registerFileRoutes(app, ctx) {
|
|
|
1301
1617
|
return;
|
|
1302
1618
|
}
|
|
1303
1619
|
// Validate path is within working directory (security: resolve symlinks to prevent traversal)
|
|
1304
|
-
const
|
|
1305
|
-
if (!
|
|
1306
|
-
reply.code(
|
|
1620
|
+
const resolution = await resolveFileTarget({ workingDir: session.workingDir, remote: session.remote }, filePath);
|
|
1621
|
+
if (!resolution.ok) {
|
|
1622
|
+
reply.code(resolution.status).send(createErrorResponse(resolution.errorCode, resolution.message));
|
|
1623
|
+
return;
|
|
1624
|
+
}
|
|
1625
|
+
const target = resolution.target;
|
|
1626
|
+
if (target.kind === 'remote' && target.probe.kind !== 'file') {
|
|
1627
|
+
reply.code(400).send(createErrorResponse(ApiErrorCode.INVALID_INPUT, 'Path is not a file'));
|
|
1307
1628
|
return;
|
|
1308
1629
|
}
|
|
1309
|
-
const { resolvedPath } = validated;
|
|
1310
1630
|
try {
|
|
1311
1631
|
// Sanity bound only: the body below is streamed and Range-aware, so size
|
|
1312
1632
|
// does not translate into resident memory. Configurable, 0 = unlimited.
|
|
1313
|
-
const
|
|
1314
|
-
if (exceedsDownloadLimit(
|
|
1315
|
-
reply.code(413).send(createErrorResponse(ApiErrorCode.INVALID_INPUT, downloadTooLargeMessage(
|
|
1633
|
+
const size = target.kind === 'local' ? (await fs.stat(target.resolvedPath)).size : target.probe.size;
|
|
1634
|
+
if (exceedsDownloadLimit(size)) {
|
|
1635
|
+
reply.code(413).send(createErrorResponse(ApiErrorCode.INVALID_INPUT, downloadTooLargeMessage(size)));
|
|
1316
1636
|
return;
|
|
1317
1637
|
}
|
|
1318
1638
|
const ext = filePath.split('.').pop()?.toLowerCase() || '';
|
|
@@ -1347,18 +1667,20 @@ export function registerFileRoutes(app, ctx) {
|
|
|
1347
1667
|
reply.header('Content-Type', ext === 'svg' ? 'application/octet-stream' : mimeTypes[ext] || 'application/octet-stream');
|
|
1348
1668
|
reply.header('Content-Disposition', `attachment; filename="${basename}"`);
|
|
1349
1669
|
reply.header('X-Content-Type-Options', 'nosniff');
|
|
1350
|
-
sendFileBody(reply,
|
|
1670
|
+
sendFileBody(reply, size, req.headers.range, fileTargetSource(target));
|
|
1351
1671
|
return;
|
|
1352
1672
|
}
|
|
1353
1673
|
reply.header('Content-Type', mimeTypes[ext] || 'application/octet-stream');
|
|
1354
1674
|
reply.header('X-Content-Type-Options', 'nosniff');
|
|
1355
1675
|
// Streamed, range-aware: this is the <video>/<audio> source the file
|
|
1356
1676
|
// viewer points at, and a 200-only response makes the media unseekable.
|
|
1357
|
-
sendFileBody(reply,
|
|
1677
|
+
sendFileBody(reply, size, req.headers.range, fileTargetSource(target));
|
|
1358
1678
|
}
|
|
1359
1679
|
catch (err) {
|
|
1680
|
+
// A failure of the remote read is an infrastructure answer, not a 500 with a
|
|
1681
|
+
// stack: the case points at a host this server could not reach.
|
|
1360
1682
|
reply
|
|
1361
|
-
.code(500)
|
|
1683
|
+
.code(err instanceof RemoteFileAccessError ? 502 : 500)
|
|
1362
1684
|
.send(createErrorResponse(ApiErrorCode.OPERATION_FAILED, `Failed to read file: ${getErrorMessage(err)}`));
|
|
1363
1685
|
}
|
|
1364
1686
|
});
|
|
@@ -1377,7 +1699,13 @@ export function registerFileRoutes(app, ctx) {
|
|
|
1377
1699
|
return;
|
|
1378
1700
|
}
|
|
1379
1701
|
try {
|
|
1380
|
-
|
|
1702
|
+
// A remote case registers a path that lives on the REMOTE host: the guard and
|
|
1703
|
+
// the reachability check happen there (#415 — this is the path a clicked
|
|
1704
|
+
// terminal link takes when the file is OUTSIDE the case directory).
|
|
1705
|
+
const event = await registerExternalAttachment(id, body.path, {
|
|
1706
|
+
sessionWorkingDir: session.workingDir,
|
|
1707
|
+
remote: session.remote,
|
|
1708
|
+
});
|
|
1381
1709
|
// `notify: false` registers QUIETLY. The file-preview overlay uses it to
|
|
1382
1710
|
// mint an id for a path the user just clicked (a terminal or response-viewer
|
|
1383
1711
|
// link pointing outside the workspace): it is already opening the file, so
|
|
@@ -1408,9 +1736,12 @@ export function registerFileRoutes(app, ctx) {
|
|
|
1408
1736
|
reply.code(404).send(createErrorResponse(ApiErrorCode.NOT_FOUND, `Session ${id} not found`));
|
|
1409
1737
|
return;
|
|
1410
1738
|
}
|
|
1739
|
+
// Remote: every entry's realpath + stat in one batched probe, never one ssh per
|
|
1740
|
+
// item (see probeRemoteAttachmentHistory). Local: undefined, each item stats itself.
|
|
1741
|
+
const batch = await probeRemoteAttachmentHistory(sessionHistory.scope, sessionHistory.history);
|
|
1411
1742
|
const items = await Promise.all(sessionHistory.history.map((item) => (item.source === 'external'
|
|
1412
|
-
? buildExternalAttachmentRouteItem(id, item, sessionHistory.
|
|
1413
|
-
: buildDetectedAttachmentRouteItem(id, sessionHistory.
|
|
1743
|
+
? buildExternalAttachmentRouteItem(id, item, sessionHistory.scope, batch)
|
|
1744
|
+
: buildDetectedAttachmentRouteItem(id, sessionHistory.scope, item, batch)).catch(() => ({ ...sanitizeAttachmentHistoryItem(item), missing: true }))));
|
|
1414
1745
|
return {
|
|
1415
1746
|
success: true,
|
|
1416
1747
|
data: {
|
|
@@ -1423,24 +1754,32 @@ export function registerFileRoutes(app, ctx) {
|
|
|
1423
1754
|
// size/mtime as the underlying file is rewritten).
|
|
1424
1755
|
app.get('/api/sessions/:id/attachments/:attachmentId', async (req, reply) => {
|
|
1425
1756
|
const { id, attachmentId } = req.params;
|
|
1426
|
-
const
|
|
1427
|
-
if (!
|
|
1757
|
+
const scope = getKnownSessionFileScope(ctx, id, reply, req);
|
|
1758
|
+
if (!scope)
|
|
1428
1759
|
return;
|
|
1429
1760
|
const record = getAttachmentOr404(reply, id, attachmentId);
|
|
1430
1761
|
if (!record)
|
|
1431
1762
|
return;
|
|
1432
|
-
|
|
1763
|
+
const servable = await resolveServableAttachmentPath(reply, record, scope);
|
|
1764
|
+
if (!servable)
|
|
1433
1765
|
return;
|
|
1434
1766
|
const event = attachmentRecordToEvent(record);
|
|
1435
1767
|
let size = record.size;
|
|
1436
1768
|
let mtimeMs = record.mtimeMs;
|
|
1437
|
-
|
|
1438
|
-
|
|
1439
|
-
size =
|
|
1440
|
-
mtimeMs =
|
|
1769
|
+
if (servable.probe) {
|
|
1770
|
+
// Remote: the guard re-probe already stat'ed it over ssh — no second round trip.
|
|
1771
|
+
size = servable.probe.size || record.size;
|
|
1772
|
+
mtimeMs = servable.probe.mtimeMs || mtimeMs;
|
|
1441
1773
|
}
|
|
1442
|
-
|
|
1443
|
-
|
|
1774
|
+
else {
|
|
1775
|
+
try {
|
|
1776
|
+
const stat = await fs.stat(record.filePath);
|
|
1777
|
+
size = stat.size;
|
|
1778
|
+
mtimeMs = stat.mtimeMs ?? mtimeMs;
|
|
1779
|
+
}
|
|
1780
|
+
catch {
|
|
1781
|
+
// File temporarily unavailable mid-write — keep cached values.
|
|
1782
|
+
}
|
|
1444
1783
|
}
|
|
1445
1784
|
return {
|
|
1446
1785
|
success: true,
|
|
@@ -1467,15 +1806,26 @@ export function registerFileRoutes(app, ctx) {
|
|
|
1467
1806
|
const record = getAttachmentOr404(reply, id, attachmentId);
|
|
1468
1807
|
if (!record)
|
|
1469
1808
|
return;
|
|
1470
|
-
const
|
|
1471
|
-
|
|
1809
|
+
const servable = await resolveServableAttachmentPath(reply, record, {
|
|
1810
|
+
workingDir: session.workingDir,
|
|
1811
|
+
remote: session.remote,
|
|
1812
|
+
});
|
|
1813
|
+
if (!servable)
|
|
1472
1814
|
return;
|
|
1473
1815
|
try {
|
|
1474
|
-
|
|
1816
|
+
// A remote record streams over ssh exactly like file-raw, with the same
|
|
1817
|
+
// 200/206/416 contract, and its size comes from the guard's own re-probe — so
|
|
1818
|
+
// serving a remote attachment needs no stat the local branch would not also need.
|
|
1819
|
+
const remote = session.remote;
|
|
1820
|
+
const target = servable.probe && remote
|
|
1821
|
+
? { kind: 'remote', resolvedPath: servable.path, relativePath: '', remote, probe: servable.probe }
|
|
1822
|
+
: { kind: 'local', resolvedPath: servable.path, relativePath: '' };
|
|
1823
|
+
const size = servable.probe ? servable.probe.size : (await fs.stat(servable.path)).size;
|
|
1824
|
+
await serveRawFile(reply, target, size, record.fileName, record.extension, download === 'true', req.headers.range);
|
|
1475
1825
|
}
|
|
1476
1826
|
catch (err) {
|
|
1477
1827
|
reply
|
|
1478
|
-
.code(500)
|
|
1828
|
+
.code(err instanceof RemoteFileAccessError ? 502 : 500)
|
|
1479
1829
|
.send(createErrorResponse(ApiErrorCode.OPERATION_FAILED, `Failed to read file: ${getErrorMessage(err)}`));
|
|
1480
1830
|
}
|
|
1481
1831
|
});
|
|
@@ -1483,14 +1833,14 @@ export function registerFileRoutes(app, ctx) {
|
|
|
1483
1833
|
// convert server-side; PDF/PNG/text redirect to the raw route.
|
|
1484
1834
|
app.get('/api/sessions/:id/attachments/:attachmentId/preview', async (req, reply) => {
|
|
1485
1835
|
const { id, attachmentId } = req.params;
|
|
1486
|
-
const
|
|
1487
|
-
if (!
|
|
1836
|
+
const scope = getKnownSessionFileScope(ctx, id, reply, req);
|
|
1837
|
+
if (!scope)
|
|
1488
1838
|
return;
|
|
1489
1839
|
const record = getAttachmentOr404(reply, id, attachmentId);
|
|
1490
1840
|
if (!record)
|
|
1491
1841
|
return;
|
|
1492
|
-
const
|
|
1493
|
-
if (!
|
|
1842
|
+
const servable = await resolveServableAttachmentPath(reply, record, scope);
|
|
1843
|
+
if (!servable)
|
|
1494
1844
|
return;
|
|
1495
1845
|
// Only Office formats need server-side conversion; PDF/PNG and text formats
|
|
1496
1846
|
// (md/txt) preview directly from their raw bytes.
|
|
@@ -1498,61 +1848,88 @@ export function registerFileRoutes(app, ctx) {
|
|
|
1498
1848
|
reply.redirect(`/api/sessions/${id}/attachments/${encodeURIComponent(attachmentId)}/raw`);
|
|
1499
1849
|
return;
|
|
1500
1850
|
}
|
|
1501
|
-
|
|
1851
|
+
if (servable.probe) {
|
|
1852
|
+
// Conversion needs LibreOffice reading the bytes off THIS host's disk, and a
|
|
1853
|
+
// remote read must never spill remote bytes onto the server (see file-preview).
|
|
1854
|
+
reply
|
|
1855
|
+
.code(400)
|
|
1856
|
+
.send(createErrorResponse(ApiErrorCode.INVALID_INPUT, 'Office document preview is not available for files in a remote (SSH) case'));
|
|
1857
|
+
return;
|
|
1858
|
+
}
|
|
1859
|
+
await serveConvertedPreview(reply, servable.path, record.fileName, record.extension);
|
|
1502
1860
|
});
|
|
1503
1861
|
// Serve a first-page thumbnail of a registered attachment by id.
|
|
1504
1862
|
app.get('/api/sessions/:id/attachments/:attachmentId/thumbnail', async (req, reply) => {
|
|
1505
1863
|
const { id, attachmentId } = req.params;
|
|
1506
|
-
const
|
|
1507
|
-
if (!
|
|
1864
|
+
const scope = getKnownSessionFileScope(ctx, id, reply, req);
|
|
1865
|
+
if (!scope)
|
|
1508
1866
|
return;
|
|
1509
1867
|
const record = getAttachmentOr404(reply, id, attachmentId);
|
|
1510
1868
|
if (!record)
|
|
1511
1869
|
return;
|
|
1512
|
-
const
|
|
1513
|
-
if (!
|
|
1870
|
+
const servable = await resolveServableAttachmentPath(reply, record, scope);
|
|
1871
|
+
if (!servable)
|
|
1872
|
+
return;
|
|
1873
|
+
if (servable.probe) {
|
|
1874
|
+
// Same reason as the office preview: rendering needs the bytes locally.
|
|
1875
|
+
reply
|
|
1876
|
+
.code(400)
|
|
1877
|
+
.send(createErrorResponse(ApiErrorCode.INVALID_INPUT, 'Thumbnails are not available for files in a remote (SSH) case'));
|
|
1514
1878
|
return;
|
|
1515
|
-
|
|
1879
|
+
}
|
|
1880
|
+
await serveThumbnail(reply, servable.path, record.extension);
|
|
1516
1881
|
});
|
|
1517
1882
|
// Serve converted document previews for a workspace-relative path. DOCX/PPTX
|
|
1518
1883
|
// are converted to PDF via LibreOffice; PDF/PNG/text preview through file-raw.
|
|
1519
1884
|
app.get('/api/sessions/:id/file-preview', async (req, reply) => {
|
|
1520
1885
|
const { id } = req.params;
|
|
1521
1886
|
const { path: filePath } = req.query;
|
|
1522
|
-
const
|
|
1523
|
-
if (!
|
|
1887
|
+
const scope = getKnownSessionFileScope(ctx, id, reply, req);
|
|
1888
|
+
if (!scope)
|
|
1524
1889
|
return;
|
|
1525
1890
|
if (!filePath) {
|
|
1526
1891
|
reply.code(400).send(createErrorResponse(ApiErrorCode.INVALID_INPUT, 'Missing path parameter'));
|
|
1527
1892
|
return;
|
|
1528
1893
|
}
|
|
1529
|
-
const
|
|
1530
|
-
if (!
|
|
1531
|
-
reply.code(
|
|
1894
|
+
const resolution = await resolveFileTarget(scope, filePath);
|
|
1895
|
+
if (!resolution.ok) {
|
|
1896
|
+
reply.code(resolution.status).send(createErrorResponse(resolution.errorCode, resolution.message));
|
|
1532
1897
|
return;
|
|
1533
1898
|
}
|
|
1534
|
-
const { resolvedPath } = validated;
|
|
1535
1899
|
const ext = filePath.split('.').pop()?.toLowerCase() || '';
|
|
1536
1900
|
if (ext !== 'docx' && ext !== 'pptx') {
|
|
1901
|
+
// Everything that is not an office document IS the raw route (PDF, images,
|
|
1902
|
+
// text), which is now remote-aware too — so this redirect works for both kinds
|
|
1903
|
+
// of case with no extra branching here.
|
|
1537
1904
|
reply.redirect(`/api/sessions/${id}/file-raw?path=${encodeURIComponent(filePath)}`);
|
|
1538
1905
|
return;
|
|
1539
1906
|
}
|
|
1540
|
-
|
|
1907
|
+
if (resolution.target.kind === 'remote') {
|
|
1908
|
+
// Converting requires LibreOffice reading the bytes off THIS host's disk, so it
|
|
1909
|
+
// needs a local temp copy first — deliberately not part of the read-only remote
|
|
1910
|
+
// path (a remote preview must not spill remote bytes onto the server). Say what
|
|
1911
|
+
// to do instead of 404-ing like the pre-#415 code did.
|
|
1912
|
+
reply
|
|
1913
|
+
.code(400)
|
|
1914
|
+
.send(createErrorResponse(ApiErrorCode.INVALID_INPUT, 'Office document preview is not available for files in a remote (SSH) case'));
|
|
1915
|
+
return;
|
|
1916
|
+
}
|
|
1917
|
+
await serveConvertedPreview(reply, resolution.target.resolvedPath, filePath, ext);
|
|
1541
1918
|
});
|
|
1542
1919
|
// Serve a first-page thumbnail for a workspace-relative path.
|
|
1543
1920
|
app.get('/api/sessions/:id/file-thumbnail', async (req, reply) => {
|
|
1544
1921
|
const { id } = req.params;
|
|
1545
1922
|
const { path: filePath } = req.query;
|
|
1546
|
-
const
|
|
1547
|
-
if (!
|
|
1923
|
+
const scope = getKnownSessionFileScope(ctx, id, reply, req);
|
|
1924
|
+
if (!scope)
|
|
1548
1925
|
return;
|
|
1549
1926
|
if (!filePath) {
|
|
1550
1927
|
reply.code(400).send(createErrorResponse(ApiErrorCode.INVALID_INPUT, 'Missing path parameter'));
|
|
1551
1928
|
return;
|
|
1552
1929
|
}
|
|
1553
|
-
const
|
|
1554
|
-
if (!
|
|
1555
|
-
reply.code(
|
|
1930
|
+
const resolution = await resolveFileTarget(scope, filePath);
|
|
1931
|
+
if (!resolution.ok) {
|
|
1932
|
+
reply.code(resolution.status).send(createErrorResponse(resolution.errorCode, resolution.message));
|
|
1556
1933
|
return;
|
|
1557
1934
|
}
|
|
1558
1935
|
const ext = filePath.split('.').pop()?.toLowerCase() || '';
|
|
@@ -1562,7 +1939,14 @@ export function registerFileRoutes(app, ctx) {
|
|
|
1562
1939
|
.send(createErrorResponse(ApiErrorCode.INVALID_INPUT, 'Thumbnail is not supported for this file type'));
|
|
1563
1940
|
return;
|
|
1564
1941
|
}
|
|
1565
|
-
|
|
1942
|
+
if (resolution.target.kind === 'remote') {
|
|
1943
|
+
// Same reason as the office preview above: rendering needs the bytes locally.
|
|
1944
|
+
reply
|
|
1945
|
+
.code(400)
|
|
1946
|
+
.send(createErrorResponse(ApiErrorCode.INVALID_INPUT, 'Thumbnails are not available for files in a remote (SSH) case'));
|
|
1947
|
+
return;
|
|
1948
|
+
}
|
|
1949
|
+
await serveThumbnail(reply, resolution.target.resolvedPath, ext);
|
|
1566
1950
|
});
|
|
1567
1951
|
// Stream file content via tail -f (SSE endpoint)
|
|
1568
1952
|
app.get('/api/sessions/:id/tail-file', async (req, reply) => {
|
|
@@ -1687,7 +2071,7 @@ export function registerFileRoutes(app, ctx) {
|
|
|
1687
2071
|
reply.header('Content-Type', mimeTypes[ext] || 'application/octet-stream');
|
|
1688
2072
|
reply.header('Content-Disposition', buildContentDisposition('attachment', filename));
|
|
1689
2073
|
reply.header('X-Content-Type-Options', 'nosniff');
|
|
1690
|
-
sendFileBody(reply,
|
|
2074
|
+
sendFileBody(reply, stat.size, req.headers.range, localFileSource(resolvedPath));
|
|
1691
2075
|
return;
|
|
1692
2076
|
}
|
|
1693
2077
|
catch (err) {
|