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.
Files changed (183) hide show
  1. package/README.md +1 -1
  2. package/dist/attachment-registry.d.ts +20 -0
  3. package/dist/attachment-registry.d.ts.map +1 -1
  4. package/dist/attachment-registry.js +75 -10
  5. package/dist/attachment-registry.js.map +1 -1
  6. package/dist/config/cli-registry/schema.d.ts +25 -0
  7. package/dist/config/cli-registry/schema.d.ts.map +1 -1
  8. package/dist/config/cli-registry/schema.js +43 -0
  9. package/dist/config/cli-registry/schema.js.map +1 -1
  10. package/dist/config/cli-registry/stock.d.ts.map +1 -1
  11. package/dist/config/cli-registry/stock.js +157 -2
  12. package/dist/config/cli-registry/stock.js.map +1 -1
  13. package/dist/config/cli-registry/types.d.ts +60 -0
  14. package/dist/config/cli-registry/types.d.ts.map +1 -1
  15. package/dist/custom-model-hosts.d.ts +39 -0
  16. package/dist/custom-model-hosts.d.ts.map +1 -0
  17. package/dist/custom-model-hosts.js +35 -0
  18. package/dist/custom-model-hosts.js.map +1 -0
  19. package/dist/custom-model-injection-apply.d.ts +49 -0
  20. package/dist/custom-model-injection-apply.d.ts.map +1 -0
  21. package/dist/custom-model-injection-apply.js +77 -0
  22. package/dist/custom-model-injection-apply.js.map +1 -0
  23. package/dist/custom-model-injection.d.ts +79 -0
  24. package/dist/custom-model-injection.d.ts.map +1 -0
  25. package/dist/custom-model-injection.js +169 -0
  26. package/dist/custom-model-injection.js.map +1 -0
  27. package/dist/docker-hosts.d.ts +42 -0
  28. package/dist/docker-hosts.d.ts.map +1 -1
  29. package/dist/docker-hosts.js +18 -0
  30. package/dist/docker-hosts.js.map +1 -1
  31. package/dist/generated-artifact-attachments.d.ts +3 -0
  32. package/dist/generated-artifact-attachments.d.ts.map +1 -1
  33. package/dist/generated-artifact-attachments.js +18 -8
  34. package/dist/generated-artifact-attachments.js.map +1 -1
  35. package/dist/mux-interface.d.ts +7 -0
  36. package/dist/mux-interface.d.ts.map +1 -1
  37. package/dist/remote-files.d.ts +145 -0
  38. package/dist/remote-files.d.ts.map +1 -0
  39. package/dist/remote-files.js +347 -0
  40. package/dist/remote-files.js.map +1 -0
  41. package/dist/remote-hosts.d.ts +12 -0
  42. package/dist/remote-hosts.d.ts.map +1 -1
  43. package/dist/remote-hosts.js +7 -1
  44. package/dist/remote-hosts.js.map +1 -1
  45. package/dist/remote-ssh-limiter.d.ts +42 -0
  46. package/dist/remote-ssh-limiter.d.ts.map +1 -0
  47. package/dist/remote-ssh-limiter.js +84 -0
  48. package/dist/remote-ssh-limiter.js.map +1 -0
  49. package/dist/session-manager.d.ts.map +1 -1
  50. package/dist/session-manager.js +8 -1
  51. package/dist/session-manager.js.map +1 -1
  52. package/dist/session.d.ts +79 -2
  53. package/dist/session.d.ts.map +1 -1
  54. package/dist/session.js +163 -2
  55. package/dist/session.js.map +1 -1
  56. package/dist/tmux-manager.d.ts.map +1 -1
  57. package/dist/tmux-manager.js +27 -13
  58. package/dist/tmux-manager.js.map +1 -1
  59. package/dist/types/api.d.ts +6 -0
  60. package/dist/types/api.d.ts.map +1 -1
  61. package/dist/types/api.js.map +1 -1
  62. package/dist/types/session.d.ts +30 -0
  63. package/dist/types/session.d.ts.map +1 -1
  64. package/dist/types/session.js.map +1 -1
  65. package/dist/web/middleware/auth.d.ts +11 -1
  66. package/dist/web/middleware/auth.d.ts.map +1 -1
  67. package/dist/web/middleware/auth.js +69 -1
  68. package/dist/web/middleware/auth.js.map +1 -1
  69. package/dist/web/public/admin-ui.js.gz +0 -0
  70. package/dist/web/public/api-client.c9b1cddc.js.gz +0 -0
  71. package/dist/web/public/{app.78bf0bf8.js → app.556be563.js} +7 -7
  72. package/dist/web/public/app.556be563.js.br +0 -0
  73. package/dist/web/public/app.556be563.js.gz +0 -0
  74. package/dist/web/public/approvals-ui.js.gz +0 -0
  75. package/dist/web/public/constants.258b140f.js.gz +0 -0
  76. package/dist/web/public/cron-ui.js.gz +0 -0
  77. package/dist/web/public/entrance-animations.js.gz +0 -0
  78. package/dist/web/public/home-sessions.js.gz +0 -0
  79. package/dist/web/public/i18n.561ca09c.js.gz +0 -0
  80. package/dist/web/public/image-input.cd4b97c4.js.gz +0 -0
  81. package/dist/web/public/index.html +14 -7
  82. package/dist/web/public/index.html.br +0 -0
  83. package/dist/web/public/index.html.gz +0 -0
  84. package/dist/web/public/input-cjk.8bc46081.js +1 -0
  85. package/dist/web/public/input-cjk.8bc46081.js.br +0 -0
  86. package/dist/web/public/input-cjk.8bc46081.js.gz +0 -0
  87. package/dist/web/public/{keyboard-accessory.1d0dda9b.js → keyboard-accessory.2cf04f17.js} +38 -3
  88. package/dist/web/public/keyboard-accessory.2cf04f17.js.br +0 -0
  89. package/dist/web/public/keyboard-accessory.2cf04f17.js.gz +0 -0
  90. package/dist/web/public/mobile-handlers.6f354a87.js.gz +0 -0
  91. package/dist/web/public/mobile-overview.js.gz +0 -0
  92. package/dist/web/public/mobile.e9d0b53e.css.gz +0 -0
  93. package/dist/web/public/notification-manager.36ea4624.js.gz +0 -0
  94. package/dist/web/public/orchestrator-panel.js.gz +0 -0
  95. package/dist/web/public/panels-ui.5b07ad14.js.gz +0 -0
  96. package/dist/web/public/ralph-panel.6de2d0f8.js.gz +0 -0
  97. package/dist/web/public/ralph-wizard.13a1831e.js.gz +0 -0
  98. package/dist/web/public/readmymind-ui.js.gz +0 -0
  99. package/dist/web/public/respawn-ui.ff0dae4c.js.gz +0 -0
  100. package/dist/web/public/sanitize-html.bc7078d6.js.gz +0 -0
  101. package/dist/web/public/session-lineage.js.gz +0 -0
  102. package/dist/web/public/{session-ui.5d0a7b15.js → session-ui.42b81477.js} +13 -13
  103. package/dist/web/public/session-ui.42b81477.js.br +0 -0
  104. package/dist/web/public/session-ui.42b81477.js.gz +0 -0
  105. package/dist/web/public/settings-ui.e0c7f6b4.js.gz +0 -0
  106. package/dist/web/public/{styles.5d36726d.css → styles.6add175d.css} +1 -1
  107. package/dist/web/public/styles.6add175d.css.br +0 -0
  108. package/dist/web/public/{styles.5d36726d.css.gz → styles.6add175d.css.gz} +0 -0
  109. package/dist/web/public/subagent-windows.e6ca799f.js.gz +0 -0
  110. package/dist/web/public/sw.js.gz +0 -0
  111. package/dist/web/public/tab-rail-resize.42c24949.js.gz +0 -0
  112. package/dist/web/public/terminal-keycode229-recovery.6ea8fe37.js.gz +0 -0
  113. package/dist/web/public/terminal-ui.38c49244.js +2 -0
  114. package/dist/web/public/terminal-ui.38c49244.js.br +0 -0
  115. package/dist/web/public/terminal-ui.38c49244.js.gz +0 -0
  116. package/dist/web/public/ultracode-panel.js.gz +0 -0
  117. package/dist/web/public/ultracode-windows.js.gz +0 -0
  118. package/dist/web/public/upload.html.gz +0 -0
  119. package/dist/web/public/vendor/dompurify.min.js.gz +0 -0
  120. package/dist/web/public/vendor/marked.min.js.gz +0 -0
  121. package/dist/web/public/vendor/xterm-addon-fit.min.js.gz +0 -0
  122. package/dist/web/public/vendor/xterm-addon-serialize.min.js.gz +0 -0
  123. package/dist/web/public/vendor/xterm-addon-unicode11.min.js.gz +0 -0
  124. package/dist/web/public/vendor/xterm-addon-webgl.min.js.gz +0 -0
  125. package/dist/web/public/vendor/xterm-predictive-echo.bd6882b8.js.gz +0 -0
  126. package/dist/web/public/vendor/xterm-zerolag-input.6fee72f2.js.gz +0 -0
  127. package/dist/web/public/vendor/xterm.css.gz +0 -0
  128. package/dist/web/public/vendor/xterm.min.js.gz +0 -0
  129. package/dist/web/public/voice-input.c4b51eb6.js.gz +0 -0
  130. package/dist/web/public/voice-pcm-worklet.js.gz +0 -0
  131. package/dist/web/public/webview-tabs.js +56 -4
  132. package/dist/web/public/webview-tabs.js.br +0 -0
  133. package/dist/web/public/webview-tabs.js.gz +0 -0
  134. package/dist/web/route-helpers.d.ts +20 -0
  135. package/dist/web/route-helpers.d.ts.map +1 -1
  136. package/dist/web/route-helpers.js +29 -2
  137. package/dist/web/route-helpers.js.map +1 -1
  138. package/dist/web/routes/case-routes.d.ts.map +1 -1
  139. package/dist/web/routes/case-routes.js +23 -6
  140. package/dist/web/routes/case-routes.js.map +1 -1
  141. package/dist/web/routes/custom-model-routes.d.ts +18 -0
  142. package/dist/web/routes/custom-model-routes.d.ts.map +1 -0
  143. package/dist/web/routes/custom-model-routes.js +136 -0
  144. package/dist/web/routes/custom-model-routes.js.map +1 -0
  145. package/dist/web/routes/file-routes.d.ts.map +1 -1
  146. package/dist/web/routes/file-routes.js +484 -100
  147. package/dist/web/routes/file-routes.js.map +1 -1
  148. package/dist/web/routes/index.d.ts +1 -0
  149. package/dist/web/routes/index.d.ts.map +1 -1
  150. package/dist/web/routes/index.js +1 -0
  151. package/dist/web/routes/index.js.map +1 -1
  152. package/dist/web/routes/session-routes.d.ts.map +1 -1
  153. package/dist/web/routes/session-routes.js +81 -1
  154. package/dist/web/routes/session-routes.js.map +1 -1
  155. package/dist/web/routes/ws-routes.d.ts.map +1 -1
  156. package/dist/web/routes/ws-routes.js +26 -2
  157. package/dist/web/routes/ws-routes.js.map +1 -1
  158. package/dist/web/schemas.d.ts +20 -0
  159. package/dist/web/schemas.d.ts.map +1 -1
  160. package/dist/web/schemas.js +31 -0
  161. package/dist/web/schemas.js.map +1 -1
  162. package/dist/web/server.d.ts +8 -0
  163. package/dist/web/server.d.ts.map +1 -1
  164. package/dist/web/server.js +65 -3
  165. package/dist/web/server.js.map +1 -1
  166. package/dist/web/webview-proxy.d.ts +41 -0
  167. package/dist/web/webview-proxy.d.ts.map +1 -1
  168. package/dist/web/webview-proxy.js +102 -1
  169. package/dist/web/webview-proxy.js.map +1 -1
  170. package/package.json +2 -2
  171. package/dist/web/public/app.78bf0bf8.js.br +0 -0
  172. package/dist/web/public/app.78bf0bf8.js.gz +0 -0
  173. package/dist/web/public/input-cjk.87f14251.js +0 -1
  174. package/dist/web/public/input-cjk.87f14251.js.br +0 -0
  175. package/dist/web/public/input-cjk.87f14251.js.gz +0 -0
  176. package/dist/web/public/keyboard-accessory.1d0dda9b.js.br +0 -0
  177. package/dist/web/public/keyboard-accessory.1d0dda9b.js.gz +0 -0
  178. package/dist/web/public/session-ui.5d0a7b15.js.br +0 -0
  179. package/dist/web/public/session-ui.5d0a7b15.js.gz +0 -0
  180. package/dist/web/public/styles.5d36726d.css.br +0 -0
  181. package/dist/web/public/terminal-ui.0cbe8637.js +0 -2
  182. package/dist/web/public/terminal-ui.0cbe8637.js.br +0 -0
  183. 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, resolvedPath, size, rangeHeader) {
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
- sendRawStream(reply, createReadStream(resolvedPath, { start: range.start, end: range.end }));
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
- sendRawStream(reply, createReadStream(resolvedPath));
149
+ const { content, cleanup } = open();
150
+ sendRawStream(reply, content, cleanup);
121
151
  }
122
- async function serveRawFile(reply, resolvedPath, fileName, extension, download, rangeHeader) {
123
- const stat = await fs.stat(resolvedPath);
124
- if (exceedsDownloadLimit(stat.size)) {
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, resolvedPath, stat.size, rangeHeader);
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, resolvedPath, stat.size, rangeHeader);
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, resolvedPath, stat.size, rangeHeader);
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 true (and sends a
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, sessionWorkingDir) {
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 && (!sessionWorkingDir || !validateSessionFilePath(sessionWorkingDir, pathToCheck)));
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, workingDir, item) {
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 validated = validateSessionFilePath(workingDir, item.relativePath);
561
- if (!validated) {
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
- try {
567
- const stat = await fs.stat(validated.resolvedPath);
568
- size = stat.size;
569
- mtimeMs = stat.mtimeMs ?? mtimeMs;
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
- catch {
572
- return { ...safe, missing: true };
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, sessionWorkingDir) {
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, { sessionWorkingDir });
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
- return { ...safe, missing: true };
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
- const validated = validateSessionFilePath(session.workingDir, filePath);
1019
- if (!validated) {
1020
- return createErrorResponse(ApiErrorCode.NOT_FOUND, 'File not found');
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
- const stat = await fs.stat(resolvedPath);
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 = await fs.readFile(resolvedPath);
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
- const guard = await loadAttachmentGuardConfig();
1177
- const editable = isEditableFileName(pathBasename(resolvedPath)) &&
1178
- !isDeniedEditRelativePath(relativePath) &&
1179
- !isSensitivePath(resolvedPath) &&
1180
- !isBlockedAttachmentPath(resolvedPath, guard.blockedTrees) &&
1181
- stat.size <= MAX_EDITABLE_BYTES &&
1182
- Buffer.from(content, 'utf8').equals(buf);
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 validated = validateSessionFilePath(session.workingDir, filePath);
1305
- if (!validated) {
1306
- reply.code(404).send(createErrorResponse(ApiErrorCode.NOT_FOUND, 'File not found'));
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 stat = await fs.stat(resolvedPath);
1314
- if (exceedsDownloadLimit(stat.size)) {
1315
- reply.code(413).send(createErrorResponse(ApiErrorCode.INVALID_INPUT, downloadTooLargeMessage(stat.size)));
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, resolvedPath, stat.size, req.headers.range);
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, resolvedPath, stat.size, req.headers.range);
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
- const event = await registerExternalAttachment(id, body.path, { sessionWorkingDir: session.workingDir });
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.workingDir)
1413
- : buildDetectedAttachmentRouteItem(id, sessionHistory.workingDir, item)).catch(() => ({ ...sanitizeAttachmentHistoryItem(item), missing: true }))));
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 workingDir = getKnownSessionWorkingDir(ctx, id, reply, req);
1427
- if (!workingDir)
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
- if (!(await resolveServableAttachmentPath(reply, record, workingDir)))
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
- try {
1438
- const stat = await fs.stat(record.filePath);
1439
- size = stat.size;
1440
- mtimeMs = stat.mtimeMs ?? 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
- catch {
1443
- // File temporarily unavailable mid-write — keep cached values.
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 servePath = await resolveServableAttachmentPath(reply, record, session.workingDir);
1471
- if (!servePath)
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
- await serveRawFile(reply, servePath, record.fileName, record.extension, download === 'true', req.headers.range);
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 workingDir = getKnownSessionWorkingDir(ctx, id, reply, req);
1487
- if (!workingDir)
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 servePath = await resolveServableAttachmentPath(reply, record, workingDir);
1493
- if (!servePath)
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
- await serveConvertedPreview(reply, servePath, record.fileName, record.extension);
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 workingDir = getKnownSessionWorkingDir(ctx, id, reply, req);
1507
- if (!workingDir)
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 servePath = await resolveServableAttachmentPath(reply, record, workingDir);
1513
- if (!servePath)
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
- await serveThumbnail(reply, servePath, record.extension);
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 workingDir = getKnownSessionWorkingDir(ctx, id, reply, req);
1523
- if (!workingDir)
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 validated = validateSessionFilePath(workingDir, filePath);
1530
- if (!validated) {
1531
- reply.code(404).send(createErrorResponse(ApiErrorCode.NOT_FOUND, 'File not found'));
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
- await serveConvertedPreview(reply, resolvedPath, filePath, ext);
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 workingDir = getKnownSessionWorkingDir(ctx, id, reply, req);
1547
- if (!workingDir)
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 validated = validateSessionFilePath(workingDir, filePath);
1554
- if (!validated) {
1555
- reply.code(404).send(createErrorResponse(ApiErrorCode.NOT_FOUND, 'File not found'));
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
- await serveThumbnail(reply, validated.resolvedPath, ext);
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, resolvedPath, stat.size, req.headers.range);
2074
+ sendFileBody(reply, stat.size, req.headers.range, localFileSource(resolvedPath));
1691
2075
  return;
1692
2076
  }
1693
2077
  catch (err) {