@oar-sh/oar 0.9.24 → 0.9.25

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 (66) hide show
  1. package/.github/extensions/web-relay/skills/reasoning-stream.mjs +3 -1
  2. package/CHANGELOG.md +142 -0
  3. package/README.md +12 -11
  4. package/package.json +3 -1
  5. package/server/claude-worker/claude-remote-relay-tool.mjs +15 -0
  6. package/server/claude-worker/sdk-message-normalizer.mjs +29 -1
  7. package/server/copilot-worker/copilot-sdk-session-process.mjs +11 -9
  8. package/server/cursor-worker/sdk-message-normalizer.mjs +13 -0
  9. package/server/db-schema.mjs +19 -0
  10. package/server/grok-worker/sdk-message-normalizer.mjs +3 -0
  11. package/server/migrations/0039-media-index.mjs +167 -0
  12. package/server/migrations/0040-image-session-defaults.mjs +93 -0
  13. package/server/public/app/api-client.js +58 -0
  14. package/server/public/app/ask-user-view.js +2 -1
  15. package/server/public/app/attachments-view.js +66 -9
  16. package/server/public/app/bootstrap.js +85 -25
  17. package/server/public/app/chat-actions-menu-state.mjs +1 -0
  18. package/server/public/app/context-media-tab.js +143 -0
  19. package/server/public/app/conversation-view.js +90 -5
  20. package/server/public/app/image-composer-ui.js +518 -0
  21. package/server/public/app/image-turn-placeholder.mjs +233 -0
  22. package/server/public/app/journal-view.js +12 -2
  23. package/server/public/app/media-gallery-model.mjs +202 -0
  24. package/server/public/app/media-gallery-ui.js +524 -0
  25. package/server/public/app/media-view.js +79 -0
  26. package/server/public/app/openai-image-composer.mjs +492 -0
  27. package/server/public/app/openai-image-cost-ui.js +426 -0
  28. package/server/public/app/openai-image-spend.mjs +443 -0
  29. package/server/public/app/plan-usage-view.mjs +38 -6
  30. package/server/public/app/relay-error-ctas.mjs +5 -0
  31. package/server/public/app/session-history-ui.js +2 -0
  32. package/server/public/app/settings-modal.js +4 -0
  33. package/server/public/app/socket-handlers.js +26 -0
  34. package/server/public/app/tab-strip-fade.mjs +1 -1
  35. package/server/public/app/viewer-gallery.mjs +23 -1
  36. package/server/public/index.html +383 -7
  37. package/server/relay-tools.md +1 -0
  38. package/server/repositories/image-conversation-repository.mjs +69 -0
  39. package/server/repositories/media-repository.mjs +305 -0
  40. package/server/routes/media-routes.mjs +361 -0
  41. package/server/routes/messages-routes.mjs +1410 -360
  42. package/server/routes/sessions-routes.mjs +361 -14
  43. package/server/server-runtime.mjs +19 -0
  44. package/server/services/database-file.mjs +6 -4
  45. package/server/services/generated-image-cleanup-service.mjs +2 -1
  46. package/server/services/generated-image-files.mjs +110 -0
  47. package/server/services/image-input-paths.mjs +389 -0
  48. package/server/services/image-operation-service.mjs +6 -2
  49. package/server/services/media-extract.mjs +653 -0
  50. package/server/services/media-index-service.mjs +221 -0
  51. package/server/services/openai-image-preview-turns.mjs +330 -0
  52. package/server/services/openai-image-spend-service.mjs +516 -0
  53. package/server/services/openai-image-stream.mjs +170 -0
  54. package/server/services/plan-usage-openai-image.mjs +148 -0
  55. package/server/services/remote-relay-caller-context.mjs +6 -0
  56. package/server/services/remote-relay-dispatcher.mjs +386 -22
  57. package/server/services/remote-relay-registry-service.mjs +3 -1
  58. package/server/services/token-usage-service.mjs +15 -0
  59. package/shared/image-header.mjs +90 -0
  60. package/shared/openai-image-options.mjs +299 -0
  61. package/shared/openai-image-presets.mjs +175 -0
  62. package/shared/openai-image-previews.mjs +40 -0
  63. package/shared/openai-image-prices.mjs +291 -0
  64. package/shared/openai-image-spend.mjs +90 -0
  65. package/shared/remote-relay-contract.mjs +126 -4
  66. package/shared/worker-runtime/direct-openai-image.mjs +96 -50
@@ -0,0 +1,110 @@
1
+ 'use strict';
2
+
3
+ import fs from 'fs';
4
+
5
+ import { IMAGE_HEADER_BYTES, readImageHeader } from '../../shared/image-header.mjs';
6
+ import { resolveGeneratedImagePath } from './generated-image-cleanup-service.mjs';
7
+
8
+ // A generated image's attachment as GET /api/conversation/:id serves it: its
9
+ // absolute file on this relay (`generatedImage.path`) and its pixel size, so
10
+ // another relay's agent can name the file in a later send. The path is worked
11
+ // out on every read, never stored: checkouts and state folders move.
12
+
13
+ // PNG and WebP headers sit in the first bytes; a JPEG's frame header comes
14
+ // after its EXIF/ICC blocks, so only a JPEG that needs more reads further.
15
+ const FIRST_READ_BYTES = 4096;
16
+
17
+ // The conversation page is polled: a file read once is not read again while
18
+ // its size and time stay the same.
19
+ const HEADER_CACHE_MAX = 500;
20
+ const headerCache = new Map();
21
+
22
+ function readFirstBytes(filePath, length) {
23
+ let fd = null;
24
+ try {
25
+ fd = fs.openSync(filePath, 'r');
26
+ const buffer = Buffer.alloc(length);
27
+ const read = fs.readSync(fd, buffer, 0, length, 0);
28
+ return buffer.subarray(0, read);
29
+ } catch {
30
+ return null;
31
+ } finally {
32
+ if (fd !== null) {
33
+ try { fs.closeSync(fd); } catch {}
34
+ }
35
+ }
36
+ }
37
+
38
+ function imageHeaderOf(filePath, stat) {
39
+ const cached = headerCache.get(filePath);
40
+ if (cached && cached.size === stat.size && cached.mtimeMs === stat.mtimeMs) return cached.header;
41
+ const first = readFirstBytes(filePath, Math.min(FIRST_READ_BYTES, stat.size));
42
+ let header = first ? readImageHeader(first) : null;
43
+ if (!header && first && first[0] === 0xff && first[1] === 0xd8 && stat.size > first.length) {
44
+ const more = readFirstBytes(filePath, Math.min(IMAGE_HEADER_BYTES, stat.size));
45
+ header = more ? readImageHeader(more) : null;
46
+ }
47
+ if (headerCache.size >= HEADER_CACHE_MAX) headerCache.delete(headerCache.keys().next().value);
48
+ headerCache.set(filePath, { size: stat.size, mtimeMs: stat.mtimeMs, header });
49
+ return header;
50
+ }
51
+
52
+ function positiveInt(value) {
53
+ return Number.isInteger(value) && value > 0 ? value : null;
54
+ }
55
+
56
+ function withFile(attachment, root) {
57
+ const { path: _stored, ...generatedImage } = attachment.generatedImage;
58
+ const bare = { ...attachment, generatedImage };
59
+ const filePath = resolveGeneratedImagePath({
60
+ root,
61
+ sessionId: generatedImage.sessionId,
62
+ relativePath: generatedImage.relativePath,
63
+ });
64
+ if (!filePath) return bare;
65
+ let stat = null;
66
+ try {
67
+ stat = fs.statSync(filePath, { throwIfNoEntry: false }) || null;
68
+ } catch {}
69
+ if (!stat?.isFile()) return bare;
70
+ const width = positiveInt(attachment.width);
71
+ const height = positiveInt(attachment.height);
72
+ // New attachments carry their size; older ones are read.
73
+ const header = width && height ? null : imageHeaderOf(filePath, stat);
74
+ return {
75
+ ...bare,
76
+ ...(header ? { width: header.width, height: header.height } : {}),
77
+ ...(!attachment.type && header?.mime ? { type: header.mime } : {}),
78
+ generatedImage: { ...generatedImage, path: filePath },
79
+ };
80
+ }
81
+
82
+ /**
83
+ * `messages` with each generated-image attachment given its file path and
84
+ * pixel size, when the file is there. Messages without one come back as they
85
+ * are; a missing or unreadable file leaves the attachment without a path.
86
+ * Never throws.
87
+ */
88
+ export function withGeneratedImageFiles(messages, { resolveSessionStateRoot } = {}) {
89
+ if (!Array.isArray(messages) || typeof resolveSessionStateRoot !== 'function') return messages;
90
+ let root = '';
91
+ try {
92
+ root = String(resolveSessionStateRoot() || '').trim();
93
+ } catch {}
94
+ if (!root) return messages;
95
+ return messages.map((message) => {
96
+ const attachments = Array.isArray(message?.attachments) ? message.attachments : null;
97
+ if (!attachments || !attachments.some((entry) => entry?.generatedImage && typeof entry.generatedImage === 'object')) return message;
98
+ return {
99
+ ...message,
100
+ attachments: attachments.map((entry) => {
101
+ if (!entry?.generatedImage || typeof entry.generatedImage !== 'object') return entry;
102
+ try {
103
+ return withFile(entry, root);
104
+ } catch {
105
+ return entry;
106
+ }
107
+ }),
108
+ };
109
+ });
110
+ }
@@ -0,0 +1,389 @@
1
+ 'use strict';
2
+
3
+ // The images a send names by path (`imagePaths` of POST /api/message: a
4
+ // relay_tool send or create_session with `images`), read on this relay.
5
+ //
6
+ // A path is allowed when its real path lies in the session's folder, or is
7
+ // exactly a generated image of a session on this relay:
8
+ // <session-state root>/<session>/generated-images/<conversation>/<message>/img-NN.<ext>,
9
+ // of a conversation that is not deleted. The relay's own data (data dir,
10
+ // upload dirs, database and config files) and the rest of the session-state
11
+ // root are never read, even inside the session's folder. Every check runs
12
+ // before anything is stored: one refused path refuses the whole send.
13
+ //
14
+ // A hard link is a file of its own to these rules: one in the session's
15
+ // folder to a file elsewhere (the database, say) passes the path checks, as
16
+ // the path is all the relay sees. What stops it is the content: only PNG,
17
+ // JPEG and WebP bytes are ever attached, and only to the model.
18
+
19
+ import crypto from 'crypto';
20
+ import fsDefault from 'fs';
21
+ import path from 'path';
22
+
23
+ import { IMAGE_HEADER_BYTES, readImageHeader } from '../../shared/image-header.mjs';
24
+ import { OPENAI_IMAGE_LIMITS } from '../../shared/openai-image-options.mjs';
25
+
26
+ export { IMAGE_INPUT_REFUSED_CODE } from '../../shared/openai-image-options.mjs';
27
+
28
+ // The largest attached image OpenAI is sent: a side, and the pixels in all.
29
+ export const IMAGE_INPUT_EDGE_MAX = 16384;
30
+ export const IMAGE_INPUT_PIXELS_MAX = 64 * 1000 * 1000;
31
+ const PATH_CHARS_MAX = 4096;
32
+ // Paths one send may name before the duplicates go.
33
+ export const IMAGE_INPUT_PATHS_MAX = 16;
34
+ const READ_CHUNK_BYTES = 1024 * 1024;
35
+ // PNG chunks read for an acTL before the first IDAT.
36
+ const PNG_CHUNKS_SCANNED_MAX = 1024;
37
+
38
+ const WIN32_RESERVED_NAME = /^(?:con|prn|aux|nul|com[1-9]|lpt[1-9]|conin\$|conout\$)$/i;
39
+ const STORAGE_SEGMENT = /^[A-Za-z0-9_-]{1,96}$/;
40
+ const GENERATED_IMAGE_FILE = /^img-\d{2}\.(?:png|jpe?g|webp)$/i;
41
+
42
+ function shown(raw) {
43
+ const text = String(raw ?? '');
44
+ return text.length > 200 ? `${text.slice(0, 200)}…` : text;
45
+ }
46
+
47
+ function refused(error) {
48
+ return { ok: false, error };
49
+ }
50
+
51
+ function isCaseInsensitive(platform) {
52
+ return platform === 'win32' || platform === 'darwin';
53
+ }
54
+
55
+ /**
56
+ * Whether `raw` has the shape of an absolute local file path on `platform`,
57
+ * before anything touches the disk: `{ ok:true }` or `{ ok:false, error }`.
58
+ * Network and device paths (\\host, //host, \\?\, \\.\) are refused, since
59
+ * merely opening one can hand out the relay's credentials; on win32 also
60
+ * alternate data streams, segments that end in a dot or a space, and
61
+ * reserved device names (CON, NUL.png, COM1, …).
62
+ */
63
+ export function checkImageInputPathShape(raw, { platform = process.platform } = {}) {
64
+ if (typeof raw !== 'string' || !raw || raw.length > PATH_CHARS_MAX || raw.includes('\0')) {
65
+ return refused('an image path is empty, too long or not a path');
66
+ }
67
+ const win32 = platform === 'win32';
68
+ if (/^[\\/]{2}/.test(raw)) return refused(`"${shown(raw)}" is a network or device path`);
69
+ if (win32) {
70
+ if (!/^[A-Za-z]:[\\/]/.test(raw)) return refused(`"${shown(raw)}" is not an absolute path on this relay (C:\\… on Windows)`);
71
+ if (raw.indexOf(':', 2) !== -1) return refused(`"${shown(raw)}" names an alternate data stream`);
72
+ } else if (!path.posix.isAbsolute(raw)) {
73
+ return refused(`"${shown(raw)}" is not an absolute path on this relay`);
74
+ }
75
+ const segments = raw.split(win32 ? /[\\/]/ : '/').slice(win32 ? 1 : 0).filter(Boolean);
76
+ if (segments.some((segment) => segment === '.' || segment === '..')) {
77
+ return refused(`"${shown(raw)}" has . or .. segments; give the full path`);
78
+ }
79
+ if (win32) {
80
+ if (segments.some((segment) => /[. ]$/.test(segment))) {
81
+ return refused(`"${shown(raw)}" has a name that ends in a dot or a space`);
82
+ }
83
+ if (segments.some((segment) => WIN32_RESERVED_NAME.test(segment.split('.')[0].trimEnd()))) {
84
+ return refused(`"${shown(raw)}" names a reserved device`);
85
+ }
86
+ }
87
+ return { ok: true };
88
+ }
89
+
90
+ function realPathOf(fs, value) {
91
+ const text = String(value || '').trim();
92
+ if (!text) return null;
93
+ try {
94
+ return fs.realpathSync.native(text);
95
+ } catch {
96
+ return null;
97
+ }
98
+ }
99
+
100
+ function makePathKey(platform) {
101
+ return (value) => (isCaseInsensitive(platform) ? String(value).toLowerCase() : String(value));
102
+ }
103
+
104
+ /** The parts of `real` below `root` (both real paths), or null when it is not inside. */
105
+ function partsBelow(real, root, key, sep) {
106
+ const a = key(real);
107
+ const b = key(root);
108
+ if (a === b) return [];
109
+ const prefix = b.endsWith(sep) ? b : `${b}${sep}`;
110
+ if (!a.startsWith(prefix)) return null;
111
+ return real.slice(prefix.length).split(sep);
112
+ }
113
+
114
+ /**
115
+ * The roots one send's paths are checked against, resolved once: the
116
+ * session's folder, the session-state root, and the relay's own folders and
117
+ * files. A root that does not resolve allows nothing (no fail-open).
118
+ */
119
+ export function resolveImageInputRoots({
120
+ cwd = '',
121
+ sessionStateRoot = '',
122
+ deniedDirs = [],
123
+ deniedFiles = [],
124
+ fs = fsDefault,
125
+ } = {}) {
126
+ return {
127
+ cwd: realPathOf(fs, cwd),
128
+ cwdShown: String(cwd || '').trim(),
129
+ sessionStateRoot: realPathOf(fs, sessionStateRoot),
130
+ deniedDirs: (Array.isArray(deniedDirs) ? deniedDirs : []).map((dir) => realPathOf(fs, dir)).filter(Boolean),
131
+ deniedFiles: (Array.isArray(deniedFiles) ? deniedFiles : [])
132
+ .map((file) => realPathOf(fs, file) || (String(file || '').trim() ? path.resolve(String(file)) : null))
133
+ .filter(Boolean),
134
+ };
135
+ }
136
+
137
+ /**
138
+ * Where an image path may be read from: `{ ok:true, realPath, source }`
139
+ * (`source` 'cwd' or 'generated') or `{ ok:false, error }`. The path and the
140
+ * roots are real paths; `isLiveConversation(id)` says whether a conversation
141
+ * exists and is not deleted.
142
+ */
143
+ export function resolveImageInputPath(raw, roots, {
144
+ isLiveConversation = () => false,
145
+ platform = process.platform,
146
+ fs = fsDefault,
147
+ } = {}) {
148
+ const shape = checkImageInputPathShape(raw, { platform });
149
+ if (!shape.ok) return shape;
150
+ const key = makePathKey(platform);
151
+ const sep = platform === 'win32' ? '\\' : path.sep;
152
+ const outside = () => refused(`"${shown(raw)}" is outside this session's folder${roots?.cwdShown ? ` (${shown(roots.cwdShown)})` : ''} and is no image this relay generated`);
153
+ const realPath = realPathOf(fs, raw);
154
+ // Outside the roots a missing file reads like any other path there, so
155
+ // the answer tells nothing of what exists on this relay.
156
+ if (!realPath) return namedInsideRoots(raw, roots, { platform, key, sep }) ? refused(`"${shown(raw)}" was not found on this relay`) : outside();
157
+ // The file the checks below are about: the read compares its descriptor
158
+ // with it, so a folder swapped for a link in between is caught.
159
+ let identity = null;
160
+ try {
161
+ const stat = fs.statSync(realPath, { bigint: true });
162
+ identity = { dev: stat.dev, ino: stat.ino };
163
+ } catch {
164
+ return refused(`"${shown(raw)}" was not found on this relay`);
165
+ }
166
+
167
+ if (roots?.sessionStateRoot) {
168
+ const parts = partsBelow(realPath, roots.sessionStateRoot, key, sep);
169
+ if (parts) {
170
+ const [session, folder, conversationId, messageId, file, ...rest] = parts;
171
+ const shaped = parts.length === 5 && !rest.length
172
+ && STORAGE_SEGMENT.test(session)
173
+ && key(folder) === 'generated-images'
174
+ && STORAGE_SEGMENT.test(conversationId)
175
+ && STORAGE_SEGMENT.test(messageId)
176
+ && GENERATED_IMAGE_FILE.test(file);
177
+ if (!shaped) {
178
+ return refused(`"${shown(raw)}" is in this relay's session-state folder, where only the generated images (…/generated-images/<conversation>/<message>/img-NN.png) can be attached`);
179
+ }
180
+ if (!isLiveConversation(conversationId)) {
181
+ return refused(`"${shown(raw)}" is a generated image of a session that is gone from this relay`);
182
+ }
183
+ return { ok: true, realPath, source: 'generated', identity };
184
+ }
185
+ }
186
+ const own = (roots?.deniedDirs || []).some((dir) => partsBelow(realPath, dir, key, sep))
187
+ || (roots?.deniedFiles || []).some((file) => key(file) === key(realPath));
188
+ if (own) return refused(`"${shown(raw)}" is in this relay's own data (data, uploads, database or config), which is never attached`);
189
+ if (roots?.cwd && partsBelow(realPath, roots.cwd, key, sep)) return { ok: true, realPath, source: 'cwd', identity };
190
+ return outside();
191
+ }
192
+
193
+ /** Whether `raw`, as written, names a place under the session's folder or the session-state root. */
194
+ function namedInsideRoots(raw, roots, { platform, key, sep }) {
195
+ const flavour = platform === 'win32' ? path.win32 : path.posix;
196
+ const named = flavour.normalize(raw);
197
+ return [roots?.cwd, roots?.cwdShown, roots?.sessionStateRoot]
198
+ .filter(Boolean)
199
+ .some((root) => partsBelow(named, flavour.normalize(root), key, sep) !== null);
200
+ }
201
+
202
+ function u32be(bytes, at) {
203
+ return ((bytes[at] << 24) >>> 0) + (bytes[at + 1] << 16) + (bytes[at + 2] << 8) + bytes[at + 3];
204
+ }
205
+
206
+ /** Whether a PNG has an acTL chunk before its first IDAT (an animated PNG). */
207
+ function isAnimatedPng(bytes) {
208
+ let at = 8;
209
+ for (let count = 0; count < PNG_CHUNKS_SCANNED_MAX && at + 8 <= bytes.length; count += 1) {
210
+ const type = String.fromCharCode(bytes[at + 4], bytes[at + 5], bytes[at + 6], bytes[at + 7]);
211
+ if (type === 'acTL') return true;
212
+ if (type === 'IDAT' || type === 'IEND') return false;
213
+ at += 12 + u32be(bytes, at);
214
+ }
215
+ return false;
216
+ }
217
+
218
+ /** Whether a WebP is an extended one with its animation flag set. */
219
+ function isAnimatedWebp(bytes) {
220
+ return bytes.length > 20
221
+ && String.fromCharCode(bytes[12], bytes[13], bytes[14], bytes[15]) === 'VP8X'
222
+ && (bytes[20] & 0x02) !== 0;
223
+ }
224
+
225
+ /**
226
+ * What an attached image is, from its first bytes: `{ ok:true, mime, width,
227
+ * height }` for a still PNG, JPEG or WebP within the size caps, else
228
+ * `{ ok:false, error }` naming `label`. The type comes from the bytes, never
229
+ * from the name.
230
+ */
231
+ export function sniffImageInput(bytes, label = 'the image') {
232
+ const head = bytes.subarray(0, IMAGE_HEADER_BYTES);
233
+ const header = readImageHeader(head);
234
+ if (!header) return refused(`"${shown(label)}" is not a PNG, JPEG or WebP image`);
235
+ if ((header.mime === 'image/png' && isAnimatedPng(head)) || (header.mime === 'image/webp' && isAnimatedWebp(head))) {
236
+ return refused(`"${shown(label)}" is an animated image; only still images can be attached`);
237
+ }
238
+ if (header.width > IMAGE_INPUT_EDGE_MAX || header.height > IMAGE_INPUT_EDGE_MAX || header.width * header.height > IMAGE_INPUT_PIXELS_MAX) {
239
+ return refused(`"${shown(label)}" is ${header.width}x${header.height} pixels; an attached image has at most ${IMAGE_INPUT_EDGE_MAX} pixels a side and 64 megapixels`);
240
+ }
241
+ return { ok: true, ...header };
242
+ }
243
+
244
+ /**
245
+ * The bytes of one resolved image: opened once (on POSIX without following
246
+ * a link and without blocking on a FIFO), checked as a regular file of
247
+ * 1 byte to `maxBytes` that is still the file the path resolved to, and
248
+ * read from that descriptor in a bounded loop. `{ ok:true, bytes }` or
249
+ * `{ ok:false, error }`.
250
+ *
251
+ * O_NOFOLLOW guards the last part of the path only. So the descriptor's file
252
+ * is compared with `identity` (dev and inode the checks were made on, from
253
+ * resolveImageInputPath), and the path is resolved again after the open and
254
+ * must still be `realPath`: a parent folder swapped for a link in between
255
+ * fails one of the two.
256
+ *
257
+ * Platform note: Windows has no O_NOFOLLOW; there the file id comparison and
258
+ * the second resolve are the guard.
259
+ */
260
+ export function readImageInputFile(realPath, {
261
+ label = realPath,
262
+ maxBytes = OPENAI_IMAGE_LIMITS.inputImageBytesMax,
263
+ identity = null,
264
+ platform = process.platform,
265
+ fs = fsDefault,
266
+ } = {}) {
267
+ const { O_RDONLY, O_NOFOLLOW = 0, O_NONBLOCK = 0 } = fs.constants;
268
+ const flags = platform === 'win32' ? O_RDONLY : (O_RDONLY | O_NOFOLLOW | O_NONBLOCK);
269
+ const megabytes = Math.round(maxBytes / (1024 * 1024));
270
+ let fd = null;
271
+ try {
272
+ try {
273
+ fd = fs.openSync(realPath, flags);
274
+ } catch {
275
+ return refused(`"${shown(label)}" could not be opened`);
276
+ }
277
+ const opened = fs.fstatSync(fd, { bigint: true });
278
+ if (!opened.isFile()) return refused(`"${shown(label)}" is not a regular file`);
279
+ if (opened.size <= 0n) return refused(`"${shown(label)}" is empty`);
280
+ if (opened.size > BigInt(maxBytes)) return refused(`"${shown(label)}" is larger than ${megabytes} MB`);
281
+ let expected = identity;
282
+ if (!expected) {
283
+ try {
284
+ const stat = fs.statSync(realPath, { bigint: true });
285
+ expected = { dev: stat.dev, ino: stat.ino };
286
+ } catch {}
287
+ }
288
+ const key = makePathKey(platform);
289
+ const again = realPathOf(fs, realPath);
290
+ // The path once more, after the open: a folder swapped for a link before
291
+ // the checks and back after the open passes the two tests above (the
292
+ // checks saw the swapped file too), but not this one.
293
+ let after = null;
294
+ try {
295
+ after = fs.statSync(realPath, { bigint: true });
296
+ } catch {}
297
+ if (!expected || expected.dev !== opened.dev || expected.ino !== opened.ino || !again || key(again) !== key(realPath)
298
+ || !after || after.dev !== opened.dev || after.ino !== opened.ino || !descriptorPathMatches(fs, fd, realPath, key, platform)) {
299
+ return refused(`"${shown(label)}" changed while it was read`);
300
+ }
301
+ const chunks = [];
302
+ let total = 0;
303
+ while (total <= maxBytes) {
304
+ const chunk = Buffer.allocUnsafe(Math.min(READ_CHUNK_BYTES, maxBytes + 1 - total));
305
+ const read = fs.readSync(fd, chunk, 0, chunk.length, null);
306
+ if (read <= 0) break;
307
+ chunks.push(read === chunk.length ? chunk : chunk.subarray(0, read));
308
+ total += read;
309
+ }
310
+ if (total > maxBytes) return refused(`"${shown(label)}" is larger than ${megabytes} MB`);
311
+ if (total === 0) return refused(`"${shown(label)}" is empty`);
312
+ return { ok: true, bytes: Buffer.concat(chunks, total) };
313
+ } catch {
314
+ return refused(`"${shown(label)}" could not be read`);
315
+ } finally {
316
+ if (fd !== null) {
317
+ try { fs.closeSync(fd); } catch {}
318
+ }
319
+ }
320
+ }
321
+
322
+ /**
323
+ * Where the descriptor itself points, on Linux: /proc/self/fd/<fd> names the
324
+ * file it opened, whatever the path did since. Elsewhere, and on a Linux
325
+ * without a readable /proc (a container that hides it, say), this check
326
+ * passes (fails open) and the file-id checks stand alone.
327
+ */
328
+ function descriptorPathMatches(fs, fd, realPath, key, platform) {
329
+ if (platform !== 'linux') return true;
330
+ let target = null;
331
+ try {
332
+ target = fs.readlinkSync(`/proc/self/fd/${fd}`);
333
+ } catch {
334
+ return true;
335
+ }
336
+ return key(target) === key(realPath);
337
+ }
338
+
339
+ /**
340
+ * Every image of one send, all or nothing: `{ ok:true, images, dropped }`
341
+ * with `images` in the order named (the first is the base of an edit), each
342
+ * `{ realPath, source, name, bytes, sha256, mime, width, height }`, the same
343
+ * file named twice (also through a link) and the same bytes under two names
344
+ * kept once (`dropped` counts those); or `{ ok:false, error }` for the first
345
+ * path refused. Nothing is stored here.
346
+ */
347
+ export function resolveImageInputs(rawPaths, {
348
+ roots,
349
+ isLiveConversation,
350
+ maxBytes = OPENAI_IMAGE_LIMITS.inputImageBytesMax,
351
+ platform = process.platform,
352
+ fs = fsDefault,
353
+ } = {}) {
354
+ const list = Array.isArray(rawPaths) ? rawPaths : [];
355
+ if (list.length > IMAGE_INPUT_PATHS_MAX) return refused(`at most ${IMAGE_INPUT_PATHS_MAX} image paths per send`);
356
+ const key = makePathKey(platform);
357
+ const resolved = [];
358
+ const seenPaths = new Set();
359
+ for (const raw of list) {
360
+ const place = resolveImageInputPath(raw, roots, { isLiveConversation, platform, fs });
361
+ if (!place.ok) return place;
362
+ const pathKey = key(place.realPath);
363
+ if (seenPaths.has(pathKey)) continue;
364
+ seenPaths.add(pathKey);
365
+ resolved.push({ raw, ...place });
366
+ }
367
+ const images = [];
368
+ const seenHashes = new Set();
369
+ for (const entry of resolved) {
370
+ const file = readImageInputFile(entry.realPath, { label: entry.raw, maxBytes, identity: entry.identity, platform, fs });
371
+ if (!file.ok) return file;
372
+ const kind = sniffImageInput(file.bytes, entry.raw);
373
+ if (!kind.ok) return kind;
374
+ const sha256 = crypto.createHash('sha256').update(file.bytes).digest('hex');
375
+ if (seenHashes.has(sha256)) continue;
376
+ seenHashes.add(sha256);
377
+ images.push({
378
+ realPath: entry.realPath,
379
+ source: entry.source,
380
+ name: path.basename(entry.realPath),
381
+ bytes: file.bytes,
382
+ sha256,
383
+ mime: kind.mime,
384
+ width: kind.width,
385
+ height: kind.height,
386
+ });
387
+ }
388
+ return { ok: true, images, dropped: list.length - images.length };
389
+ }
@@ -1,6 +1,7 @@
1
1
  'use strict';
2
2
 
3
3
  import crypto from 'crypto';
4
+ import { stripRemotePromptHeader } from '../../shared/remote-relay-contract.mjs';
4
5
  import { createImageContextService } from './image-context-service.mjs';
5
6
 
6
7
  function canonicalJson(value) {
@@ -37,6 +38,9 @@ export function createImageOperationService({
37
38
  createdAt = new Date().toISOString(),
38
39
  } = {}) {
39
40
  const operationId = `img_op_${uuidv4()}`;
41
+ // The prompt OpenAI gets: an agent's prompt from another relay without
42
+ // the "[Remote prompt from …]" header the relay put above it.
43
+ const imagePrompt = stripRemotePromptHeader(String(prompt || '')).trim();
40
44
  let imageSessionId;
41
45
  let parentNodeId = null;
42
46
  let reconstructionMode = executionMode === 'native_responses' ? 'native' : 'image_only';
@@ -78,7 +82,7 @@ export function createImageOperationService({
78
82
  queueMessageId,
79
83
  kind,
80
84
  parentNodeId,
81
- prompt: String(prompt || ''),
85
+ prompt: imagePrompt,
82
86
  selectedImageModel,
83
87
  orchestrationModel,
84
88
  provider: 'openai',
@@ -94,7 +98,7 @@ export function createImageOperationService({
94
98
  queueMessageId,
95
99
  kind,
96
100
  parentNodeId,
97
- prompt: String(prompt || ''),
101
+ prompt: imagePrompt,
98
102
  selectedImageModel,
99
103
  orchestrationModel,
100
104
  provider: 'openai',