@oar-sh/oar 0.9.24 → 0.9.26

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 (92) hide show
  1. package/.github/extensions/web-relay/skills/reasoning-stream.mjs +3 -1
  2. package/CHANGELOG.md +284 -0
  3. package/README.md +12 -11
  4. package/package.json +5 -1
  5. package/server/claude-worker/claude-remote-relay-tool.mjs +15 -0
  6. package/server/claude-worker/claude-sdk-adapter.mjs +8 -4
  7. package/server/claude-worker/claude-session-process.mjs +4 -0
  8. package/server/claude-worker/claude-turn-publisher.mjs +25 -11
  9. package/server/claude-worker/sdk-message-normalizer.mjs +29 -1
  10. package/server/copilot-worker/copilot-prompt-context.mjs +2 -1
  11. package/server/copilot-worker/copilot-sdk-session-process.mjs +11 -9
  12. package/server/cursor-worker/cursor-turn-runner.mjs +3 -1
  13. package/server/cursor-worker/sdk-message-normalizer.mjs +13 -0
  14. package/server/db-schema.mjs +19 -0
  15. package/server/grok-worker/grok-turn-runner.mjs +3 -1
  16. package/server/grok-worker/sdk-message-normalizer.mjs +3 -0
  17. package/server/migrations/0039-media-index.mjs +167 -0
  18. package/server/migrations/0040-image-session-defaults.mjs +93 -0
  19. package/server/public/app/api-client.js +58 -0
  20. package/server/public/app/ask-user-view.js +2 -1
  21. package/server/public/app/attachments-view.js +66 -9
  22. package/server/public/app/bootstrap.js +85 -25
  23. package/server/public/app/chat-actions-menu-state.mjs +1 -0
  24. package/server/public/app/context-media-tab.js +143 -0
  25. package/server/public/app/context-usage-view.mjs +2 -2
  26. package/server/public/app/conversation-view.js +90 -5
  27. package/server/public/app/image-composer-ui.js +518 -0
  28. package/server/public/app/image-turn-placeholder.mjs +233 -0
  29. package/server/public/app/journal-view.js +12 -2
  30. package/server/public/app/live-activity-cap.mjs +3 -3
  31. package/server/public/app/markdown-math.mjs +471 -0
  32. package/server/public/app/media-gallery-model.mjs +202 -0
  33. package/server/public/app/media-gallery-ui.js +524 -0
  34. package/server/public/app/media-view.js +79 -0
  35. package/server/public/app/openai-image-composer.mjs +492 -0
  36. package/server/public/app/openai-image-cost-ui.js +426 -0
  37. package/server/public/app/openai-image-spend.mjs +443 -0
  38. package/server/public/app/plan-usage-view.mjs +38 -6
  39. package/server/public/app/pwa-install.js +25 -4
  40. package/server/public/app/relay-error-ctas.mjs +5 -0
  41. package/server/public/app/relay-prompt-sanitizer.mjs +3 -0
  42. package/server/public/app/relay-restart-ui.js +2 -2
  43. package/server/public/app/remote-relays-settings.js +33 -6
  44. package/server/public/app/router.js +49 -237
  45. package/server/public/app/session-history-ui.js +2 -0
  46. package/server/public/app/settings-modal.js +4 -0
  47. package/server/public/app/settings-tabs.js +10 -1
  48. package/server/public/app/socket-handlers.js +26 -0
  49. package/server/public/app/store.js +2 -0
  50. package/server/public/app/stream-view-preference.mjs +1 -1
  51. package/server/public/app/tab-strip-fade.mjs +1 -1
  52. package/server/public/app/thoughts-preference.mjs +1 -1
  53. package/server/public/app/tmux-inspector-view.js +29 -16
  54. package/server/public/app/viewer-gallery.mjs +23 -1
  55. package/server/public/index.html +517 -123
  56. package/server/relay-tools.md +1 -0
  57. package/server/repositories/image-conversation-repository.mjs +69 -0
  58. package/server/repositories/media-repository.mjs +305 -0
  59. package/server/routes/media-routes.mjs +361 -0
  60. package/server/routes/messages-routes.mjs +1410 -360
  61. package/server/routes/relay-board-routes.mjs +46 -1
  62. package/server/routes/remote-relay-routes.mjs +1 -1
  63. package/server/routes/sessions-routes.mjs +361 -14
  64. package/server/server-runtime.mjs +21 -0
  65. package/server/services/database-file.mjs +6 -4
  66. package/server/services/generated-image-cleanup-service.mjs +2 -1
  67. package/server/services/generated-image-files.mjs +110 -0
  68. package/server/services/image-input-paths.mjs +389 -0
  69. package/server/services/image-operation-service.mjs +6 -2
  70. package/server/services/media-extract.mjs +653 -0
  71. package/server/services/media-index-service.mjs +221 -0
  72. package/server/services/openai-image-preview-turns.mjs +330 -0
  73. package/server/services/openai-image-spend-service.mjs +516 -0
  74. package/server/services/openai-image-stream.mjs +170 -0
  75. package/server/services/plan-usage-openai-image.mjs +148 -0
  76. package/server/services/relay-prompt-sanitizer.mjs +7 -0
  77. package/server/services/relay-restart-queue.mjs +1 -1
  78. package/server/services/remote-relay-caller-context.mjs +6 -0
  79. package/server/services/remote-relay-dispatcher.mjs +409 -23
  80. package/server/services/remote-relay-registry-service.mjs +10 -2
  81. package/server/services/session-worker-stop-service.mjs +50 -18
  82. package/server/services/token-usage-service.mjs +15 -0
  83. package/shared/image-header.mjs +90 -0
  84. package/shared/math-instructions.mjs +30 -0
  85. package/shared/openai-image-options.mjs +299 -0
  86. package/shared/openai-image-presets.mjs +175 -0
  87. package/shared/openai-image-previews.mjs +40 -0
  88. package/shared/openai-image-prices.mjs +291 -0
  89. package/shared/openai-image-spend.mjs +90 -0
  90. package/shared/remote-relay-contract.mjs +126 -4
  91. package/shared/worker-runtime/direct-openai-image.mjs +96 -50
  92. package/server/public/app/math-dollars.mjs +0 -89
@@ -84,6 +84,7 @@ import { sessionLaunchMutex } from './services/session-launch-mutex-service.mjs'
84
84
  import { createWorkerRecycleService, sweepArchivedWorkers } from './services/worker-recycle-service.mjs';
85
85
  import { createClaudeApiUsageService } from './services/claude-api-usage-service.mjs';
86
86
  import { createTokenUsageService } from './services/token-usage-service.mjs';
87
+ import { createOpenAIImageSpendService } from './services/openai-image-spend-service.mjs';
87
88
  import { createClaudeApiCapWatch, createClaudeApiPlanUsage, createClaudeApiStopNoteLedger } from './services/plan-usage-claude-api.mjs';
88
89
  import { DEEPSEEK_DEFAULT_AUTO_COMPACT_WINDOW, DEFAULT_DEEPSEEK_MODEL, deepseekModelLabel } from '../shared/deepseek-models.mjs';
89
90
  import { createClaudeCloudSessionService } from './services/claude-cloud-session-service.mjs';
@@ -2400,6 +2401,8 @@ function setPwaAppName(rawValue) {
2400
2401
  } else {
2401
2402
  stmts.deleteAppSetting.run(PWA_APP_NAME_SETTING_KEY);
2402
2403
  }
2404
+ // Settings open on another device show the new name (Settings › Relays).
2405
+ try { remoteRelayRegistry.notifySelfChanged(); } catch {}
2403
2406
  return { ok: true, appName: normalized.value };
2404
2407
  }
2405
2408
 
@@ -5807,6 +5810,16 @@ const claudeApiUsageService = createClaudeApiUsageService({ db, logger: console
5807
5810
  // Tokens per turn and per day for every provider, booked from the
5808
5811
  // workers' reports; the Tokens sections in Check Usage and About read it.
5809
5812
  const tokenUsageService = createTokenUsageService({ db, logger: console });
5813
+ // OpenAI image spend against its daily cap: one ledger of the tries in
5814
+ // flight for the image routes and the settings route alike. Its events go
5815
+ // to the owner's clients once the socket server is up.
5816
+ const openAIImageSpend = createOpenAIImageSpendService({
5817
+ tokenUsageService,
5818
+ readSetting: readAppSettingValue,
5819
+ db,
5820
+ emit: (event, payload) => io?.emit?.(event, payload),
5821
+ logger: console,
5822
+ });
5810
5823
  // This month's spend on the key against the cap, and the Check Usage card
5811
5824
  // (services/plan-usage-claude-api.mjs), over the same local days.
5812
5825
  const claudeApiPlanUsage = createClaudeApiPlanUsage({
@@ -8175,6 +8188,11 @@ const sharedRouteDeps = {
8175
8188
  path,
8176
8189
  uploadsDir: UPLOAD_DIR,
8177
8190
  uploadReadDirs: UPLOAD_READ_DIRS,
8191
+ // Never read for a send's imagePaths, even inside the session's folder.
8192
+ imageInputDenyPaths: {
8193
+ dirs: [DATA_DIR],
8194
+ files: [DB_PATH, `${DB_PATH}-wal`, `${DB_PATH}-shm`, `${DB_PATH}-journal`, CONFIG_PATH],
8195
+ },
8178
8196
  fetchUsageSummary,
8179
8197
  fetchCopilotBillingUsage,
8180
8198
  fetchGrokBillingUsage: createGrokBillingUsageFetcher(),
@@ -8231,6 +8249,9 @@ const sharedRouteDeps = {
8231
8249
  claudeBillingSwitchService,
8232
8250
  claudeApiUsageService,
8233
8251
  tokenUsageService,
8252
+ openAIImageSpend,
8253
+ getOpenAIImageSpendState: (options) => openAIImageSpend.buildState(options),
8254
+ notifyOpenAIImageSpendChanged: () => openAIImageSpend.notifyImageSpendChanged(),
8234
8255
  claudeApiClaimCheck,
8235
8256
  onClaudeBillingFence: handleClaudeBillingFence,
8236
8257
  claudeApiPlanUsage,
@@ -45,10 +45,12 @@ export const STARTUP_SETTING_KEYS = Object.freeze([
45
45
  'model_refresh_last',
46
46
  ]);
47
47
  // And the tables such a start fills that hold no one's work: the model
48
- // catalog and its refresh state, the restart orchestrator's one row. (The
49
- // shadow tables of the message search are left out too: they follow
50
- // messages.) Any other table with a row makes the start refuse.
51
- export const STARTUP_TABLES = Object.freeze(['model_variants', 'model_selector_state', 'relay_restart_orchestrator']);
48
+ // catalog and its refresh state, the restart orchestrator's one row, the
49
+ // media index's extractor version (migration 0039; its items and queue
50
+ // follow messages and stay empty without them). (The shadow tables of the
51
+ // message search are left out too: they follow messages.) Any other table
52
+ // with a row makes the start refuse.
53
+ export const STARTUP_TABLES = Object.freeze(['model_variants', 'model_selector_state', 'relay_restart_orchestrator', 'media_index_meta']);
52
54
 
53
55
  /** A database's files: the main file, its -wal and its -shm. */
54
56
  export function sideFiles(name) {
@@ -25,7 +25,8 @@ function parseMessageAttachments(rawAttachments, { parseAttachments, hydrateAtta
25
25
  return attachments.map((entry) => hydrateAttachment(entry)).filter(Boolean);
26
26
  }
27
27
 
28
- function resolveGeneratedImagePath({
28
+ /** The file of a generated-image attachment under `root`, or null for one that would leave its folder. */
29
+ export function resolveGeneratedImagePath({
29
30
  root,
30
31
  sessionId,
31
32
  relativePath,
@@ -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',