@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.
- package/.github/extensions/web-relay/skills/reasoning-stream.mjs +3 -1
- package/CHANGELOG.md +142 -0
- package/README.md +12 -11
- package/package.json +3 -1
- package/server/claude-worker/claude-remote-relay-tool.mjs +15 -0
- package/server/claude-worker/sdk-message-normalizer.mjs +29 -1
- package/server/copilot-worker/copilot-sdk-session-process.mjs +11 -9
- package/server/cursor-worker/sdk-message-normalizer.mjs +13 -0
- package/server/db-schema.mjs +19 -0
- package/server/grok-worker/sdk-message-normalizer.mjs +3 -0
- package/server/migrations/0039-media-index.mjs +167 -0
- package/server/migrations/0040-image-session-defaults.mjs +93 -0
- package/server/public/app/api-client.js +58 -0
- package/server/public/app/ask-user-view.js +2 -1
- package/server/public/app/attachments-view.js +66 -9
- package/server/public/app/bootstrap.js +85 -25
- package/server/public/app/chat-actions-menu-state.mjs +1 -0
- package/server/public/app/context-media-tab.js +143 -0
- package/server/public/app/conversation-view.js +90 -5
- package/server/public/app/image-composer-ui.js +518 -0
- package/server/public/app/image-turn-placeholder.mjs +233 -0
- package/server/public/app/journal-view.js +12 -2
- package/server/public/app/media-gallery-model.mjs +202 -0
- package/server/public/app/media-gallery-ui.js +524 -0
- package/server/public/app/media-view.js +79 -0
- package/server/public/app/openai-image-composer.mjs +492 -0
- package/server/public/app/openai-image-cost-ui.js +426 -0
- package/server/public/app/openai-image-spend.mjs +443 -0
- package/server/public/app/plan-usage-view.mjs +38 -6
- package/server/public/app/relay-error-ctas.mjs +5 -0
- package/server/public/app/session-history-ui.js +2 -0
- package/server/public/app/settings-modal.js +4 -0
- package/server/public/app/socket-handlers.js +26 -0
- package/server/public/app/tab-strip-fade.mjs +1 -1
- package/server/public/app/viewer-gallery.mjs +23 -1
- package/server/public/index.html +383 -7
- package/server/relay-tools.md +1 -0
- package/server/repositories/image-conversation-repository.mjs +69 -0
- package/server/repositories/media-repository.mjs +305 -0
- package/server/routes/media-routes.mjs +361 -0
- package/server/routes/messages-routes.mjs +1410 -360
- package/server/routes/sessions-routes.mjs +361 -14
- package/server/server-runtime.mjs +19 -0
- package/server/services/database-file.mjs +6 -4
- package/server/services/generated-image-cleanup-service.mjs +2 -1
- package/server/services/generated-image-files.mjs +110 -0
- package/server/services/image-input-paths.mjs +389 -0
- package/server/services/image-operation-service.mjs +6 -2
- package/server/services/media-extract.mjs +653 -0
- package/server/services/media-index-service.mjs +221 -0
- package/server/services/openai-image-preview-turns.mjs +330 -0
- package/server/services/openai-image-spend-service.mjs +516 -0
- package/server/services/openai-image-stream.mjs +170 -0
- package/server/services/plan-usage-openai-image.mjs +148 -0
- package/server/services/remote-relay-caller-context.mjs +6 -0
- package/server/services/remote-relay-dispatcher.mjs +386 -22
- package/server/services/remote-relay-registry-service.mjs +3 -1
- package/server/services/token-usage-service.mjs +15 -0
- package/shared/image-header.mjs +90 -0
- package/shared/openai-image-options.mjs +299 -0
- package/shared/openai-image-presets.mjs +175 -0
- package/shared/openai-image-previews.mjs +40 -0
- package/shared/openai-image-prices.mjs +291 -0
- package/shared/openai-image-spend.mjs +90 -0
- package/shared/remote-relay-contract.mjs +126 -4
- 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:
|
|
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:
|
|
101
|
+
prompt: imagePrompt,
|
|
98
102
|
selectedImageModel,
|
|
99
103
|
orchestrationModel,
|
|
100
104
|
provider: 'openai',
|