@remcp/runtime 0.2.108 → 0.2.110

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.
@@ -1,5 +1,8 @@
1
1
  import process from 'node:process';
2
- import { copyFile, rm } from 'node:fs/promises';
2
+ import path from 'node:path';
3
+ import { randomUUID } from 'node:crypto';
4
+ import { constants } from 'node:fs';
5
+ import { lstat, open, rename, unlink } from 'node:fs/promises';
3
6
  import { fileURLToPath } from 'node:url';
4
7
 
5
8
  const PORTAL_NAME = 'org.freedesktop.portal.Desktop';
@@ -133,6 +136,115 @@ function createResponseCollector(bus, dbus, initialPath, timeoutMs) {
133
136
  };
134
137
  }
135
138
 
139
+ async function openDirectoryNoFollow(directory) {
140
+ if (process.platform !== 'linux' || !constants.O_NOFOLLOW || !constants.O_DIRECTORY) {
141
+ const info = await lstat(directory);
142
+ if (info.isSymbolicLink() || !info.isDirectory()) throw new Error('screenshot path parent is not a safe directory');
143
+ return { anchor:directory, close:async() => {} };
144
+ }
145
+ const descriptor = directory.match(/^(\/proc\/(?:self|\d+)\/fd\/\d+)(?:\/(.*))?$/);
146
+ const directories = [];
147
+ let current;
148
+ try {
149
+ if (descriptor) {
150
+ const base = await open(`${descriptor[1]}/.`, constants.O_RDONLY | constants.O_DIRECTORY | constants.O_NOFOLLOW);
151
+ directories.push(base);
152
+ current = `/proc/self/fd/${base.fd}`;
153
+ } else {
154
+ const root = path.parse(directory).root;
155
+ current = root;
156
+ const parts = directory.slice(root.length).split(path.sep).filter(Boolean);
157
+ if (!parts.length) {
158
+ const handle = await open(root, constants.O_RDONLY | constants.O_DIRECTORY | constants.O_NOFOLLOW);
159
+ return { anchor:`/proc/self/fd/${handle.fd}`, close:() => handle.close() };
160
+ }
161
+ for (const part of parts) {
162
+ const handle = await open(path.join(current, part), constants.O_RDONLY | constants.O_DIRECTORY | constants.O_NOFOLLOW);
163
+ const info = await handle.stat();
164
+ if (!info.isDirectory()) {
165
+ await handle.close().catch(() => {});
166
+ throw new Error('screenshot path parent is not a safe directory');
167
+ }
168
+ directories.push(handle);
169
+ current = `/proc/self/fd/${handle.fd}`;
170
+ }
171
+ const handle = directories.pop();
172
+ return {
173
+ anchor:current,
174
+ close:async() => {
175
+ try { await handle.close(); }
176
+ finally { for (const entry of directories.reverse()) await entry.close().catch(() => {}); }
177
+ },
178
+ };
179
+ }
180
+ for (const part of (descriptor?.[2] || '').split(path.sep).filter(Boolean)) {
181
+ const handle = await open(path.join(current, part), constants.O_RDONLY | constants.O_DIRECTORY | constants.O_NOFOLLOW);
182
+ const info = await handle.stat();
183
+ if (!info.isDirectory()) {
184
+ await handle.close().catch(() => {});
185
+ throw new Error('screenshot path parent is not a safe directory');
186
+ }
187
+ directories.push(handle);
188
+ current = `/proc/self/fd/${handle.fd}`;
189
+ }
190
+ const handle = directories.pop();
191
+ return {
192
+ anchor:current,
193
+ close:async() => {
194
+ try { await handle.close(); }
195
+ finally { for (const entry of directories.reverse()) await entry.close().catch(() => {}); }
196
+ },
197
+ };
198
+ } catch (error) {
199
+ for (const entry of directories.reverse()) await entry.close().catch(() => {});
200
+ throw error;
201
+ }
202
+ }
203
+
204
+ async function copyPortalFile(source, destination) {
205
+ let sourceParent;
206
+ let destinationParent;
207
+ let input;
208
+ let output;
209
+ let temporary;
210
+ try {
211
+ sourceParent = await openDirectoryNoFollow(path.dirname(source));
212
+ destinationParent = await openDirectoryNoFollow(path.dirname(destination));
213
+ input = await open(path.join(sourceParent.anchor, path.basename(source)), constants.O_RDONLY | constants.O_NOFOLLOW);
214
+ const info = await input.stat();
215
+ if (!info.isFile()) throw new Error('desktop portal returned a non-regular screenshot file');
216
+ const destinationPath = path.join(destinationParent.anchor, path.basename(destination));
217
+ const existing = await lstat(destinationPath).catch(() => null);
218
+ if (existing?.isSymbolicLink()) throw new Error('desktop portal screenshot destination cannot be a symbolic link');
219
+ temporary = path.join(destinationParent.anchor, `.${path.basename(destination)}.${randomUUID()}.tmp`);
220
+ output = await open(temporary, constants.O_WRONLY | constants.O_CREAT | constants.O_EXCL | constants.O_NOFOLLOW, 0o600);
221
+ const buffer = Buffer.allocUnsafe(1024 * 1024);
222
+ let offset = 0;
223
+ for (;;) {
224
+ const { bytesRead } = await input.read(buffer, 0, buffer.length, offset);
225
+ if (!bytesRead) break;
226
+ let written = 0;
227
+ while (written < bytesRead) {
228
+ const result = await output.write(buffer, written, bytesRead - written, offset + written);
229
+ if (!result.bytesWritten) throw new Error('Could not finish copying the desktop portal screenshot');
230
+ written += result.bytesWritten;
231
+ }
232
+ offset += bytesRead;
233
+ }
234
+ await output.sync();
235
+ await output.close();
236
+ output = null;
237
+ await rename(temporary, destinationPath);
238
+ temporary = null;
239
+ } finally {
240
+ if (output) await output.close().catch(() => {});
241
+ if (temporary) await unlink(temporary).catch(() => {});
242
+ if (input) await input.close().catch(() => {});
243
+ if (destinationParent) await destinationParent.close().catch(() => {});
244
+ if (sourceParent) await sourceParent.close().catch(() => {});
245
+ }
246
+ }
247
+
136
248
  export async function capturePortalScreenshot(destination, {
137
249
  timeoutMs = DEFAULT_TIMEOUT_MS,
138
250
  loadDbus = () => import('@jellybrick/dbus-next'),
@@ -184,12 +296,9 @@ export async function capturePortalScreenshot(destination, {
184
296
  const uri = results?.uri?.value;
185
297
  if (typeof uri !== 'string') throw new Error('desktop portal returned no screenshot URI');
186
298
  const sourceUrl = new URL(uri);
187
- if (sourceUrl.protocol !== 'file:') throw new Error(`desktop portal returned unsupported URI scheme ${sourceUrl.protocol}`);
299
+ if (sourceUrl.protocol !== 'file:' || (sourceUrl.hostname && sourceUrl.hostname !== 'localhost')) throw new Error('desktop portal returned an unsupported file URI');
188
300
  const source = fileURLToPath(sourceUrl);
189
- await copyFile(source, destination);
190
- // GNOME commonly writes the portal result to ~/Pictures. The runtime owns this capture, so remove
191
- // that portal-created intermediate after copying it to the caller-selected temporary location.
192
- if (source !== destination) await rm(source, { force: true }).catch(() => {});
301
+ await copyPortalFile(source, destination);
193
302
  return destination;
194
303
  } catch (error) {
195
304
  const wrapped = new Error(`xdg-desktop-portal: ${cleanError(error)}`);
package/src/sessions.mjs CHANGED
@@ -190,6 +190,7 @@ export function createSearchSession({ type, pattern, path, filePattern }) {
190
190
  error: null,
191
191
  warning: null,
192
192
  cancel: null,
193
+ release: null,
193
194
  waiters: [],
194
195
  lastActivityAt: Date.now(),
195
196
  };
@@ -209,6 +210,9 @@ export function finishSearchSession(session, status, error = null) {
209
210
  session.error = error ? String(error) : null;
210
211
  session.finishedAt = Date.now();
211
212
  session.cancel = null;
213
+ const release = session.release;
214
+ session.release = null;
215
+ if (release) void Promise.resolve(release()).catch(() => {});
212
216
  notify(session);
213
217
  }
214
218