@cloudcli-ai/cloudcli 1.36.0 → 1.36.2

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 (217) hide show
  1. package/README.de.md +17 -18
  2. package/README.ja.md +16 -17
  3. package/README.ko.md +17 -18
  4. package/README.md +19 -20
  5. package/README.ru.md +17 -18
  6. package/README.tr.md +17 -18
  7. package/README.zh-CN.md +17 -18
  8. package/README.zh-TW.md +17 -18
  9. package/dist/api-docs.html +2 -3
  10. package/dist/assets/{index-S6cIy7hj.js → index-CDGzV0LN.js} +264 -259
  11. package/dist/assets/index-DLmhQ15L.css +32 -0
  12. package/dist/assets/{vendor-xterm-CJZjLICi.js → vendor-xterm-CS4rQ5Mr.js} +12 -12
  13. package/dist/index.html +3 -3
  14. package/dist-server/server/claude-sdk.js +37 -90
  15. package/dist-server/server/claude-sdk.js.map +1 -1
  16. package/dist-server/server/cli.js +1 -4
  17. package/dist-server/server/cli.js.map +1 -1
  18. package/dist-server/server/cursor-cli.js +12 -11
  19. package/dist-server/server/cursor-cli.js.map +1 -1
  20. package/dist-server/server/index.js +10 -141
  21. package/dist-server/server/index.js.map +1 -1
  22. package/dist-server/server/modules/assets/assets.routes.js +89 -0
  23. package/dist-server/server/modules/assets/assets.routes.js.map +1 -0
  24. package/dist-server/server/modules/assets/index.js +4 -0
  25. package/dist-server/server/modules/assets/index.js.map +1 -0
  26. package/dist-server/server/modules/assets/services/image-assets.service.js +57 -0
  27. package/dist-server/server/modules/assets/services/image-assets.service.js.map +1 -0
  28. package/dist-server/server/modules/assets/tests/image-assets.service.test.js +36 -0
  29. package/dist-server/server/modules/assets/tests/image-assets.service.test.js.map +1 -0
  30. package/dist-server/server/modules/browser-use/browser-use.service.js +6 -3
  31. package/dist-server/server/modules/browser-use/browser-use.service.js.map +1 -1
  32. package/dist-server/server/modules/database/tests/sessions-provider-mapping.test.js +2 -2
  33. package/dist-server/server/modules/database/tests/sessions-provider-mapping.test.js.map +1 -1
  34. package/dist-server/server/modules/notifications/services/notification-orchestrator.service.js +0 -1
  35. package/dist-server/server/modules/notifications/services/notification-orchestrator.service.js.map +1 -1
  36. package/dist-server/server/modules/projects/services/project-clone.service.js +2 -1
  37. package/dist-server/server/modules/projects/services/project-clone.service.js.map +1 -1
  38. package/dist-server/server/modules/providers/list/claude/claude-sessions.provider.js +28 -0
  39. package/dist-server/server/modules/providers/list/claude/claude-sessions.provider.js.map +1 -1
  40. package/dist-server/server/modules/providers/list/codex/codex-session-synchronizer.provider.js +54 -1
  41. package/dist-server/server/modules/providers/list/codex/codex-session-synchronizer.provider.js.map +1 -1
  42. package/dist-server/server/modules/providers/list/codex/codex-sessions.provider.js +36 -1
  43. package/dist-server/server/modules/providers/list/codex/codex-sessions.provider.js.map +1 -1
  44. package/dist-server/server/modules/providers/list/cursor/cursor-models.provider.js +3 -17
  45. package/dist-server/server/modules/providers/list/cursor/cursor-models.provider.js.map +1 -1
  46. package/dist-server/server/modules/providers/list/cursor/cursor-session-synchronizer.provider.js +7 -1
  47. package/dist-server/server/modules/providers/list/cursor/cursor-session-synchronizer.provider.js.map +1 -1
  48. package/dist-server/server/modules/providers/list/cursor/cursor-sessions.provider.js +49 -13
  49. package/dist-server/server/modules/providers/list/cursor/cursor-sessions.provider.js.map +1 -1
  50. package/dist-server/server/modules/providers/list/opencode/opencode-auth.provider.js +0 -1
  51. package/dist-server/server/modules/providers/list/opencode/opencode-auth.provider.js.map +1 -1
  52. package/dist-server/server/modules/providers/list/opencode/opencode-models.provider.js +8 -14
  53. package/dist-server/server/modules/providers/list/opencode/opencode-models.provider.js.map +1 -1
  54. package/dist-server/server/modules/providers/list/opencode/opencode-session-synchronizer.provider.js +24 -5
  55. package/dist-server/server/modules/providers/list/opencode/opencode-session-synchronizer.provider.js.map +1 -1
  56. package/dist-server/server/modules/providers/list/opencode/opencode-sessions.provider.js +10 -21
  57. package/dist-server/server/modules/providers/list/opencode/opencode-sessions.provider.js.map +1 -1
  58. package/dist-server/server/modules/providers/provider.registry.js +0 -2
  59. package/dist-server/server/modules/providers/provider.registry.js.map +1 -1
  60. package/dist-server/server/modules/providers/provider.routes.js +0 -1
  61. package/dist-server/server/modules/providers/provider.routes.js.map +1 -1
  62. package/dist-server/server/modules/providers/services/provider-capabilities.service.js +7 -14
  63. package/dist-server/server/modules/providers/services/provider-capabilities.service.js.map +1 -1
  64. package/dist-server/server/modules/providers/services/provider-models.service.js +1 -1
  65. package/dist-server/server/modules/providers/services/provider-models.service.js.map +1 -1
  66. package/dist-server/server/modules/providers/services/session-conversations-search.service.js +3 -77
  67. package/dist-server/server/modules/providers/services/session-conversations-search.service.js.map +1 -1
  68. package/dist-server/server/modules/providers/services/session-synchronizer.service.js +0 -1
  69. package/dist-server/server/modules/providers/services/session-synchronizer.service.js.map +1 -1
  70. package/dist-server/server/modules/providers/services/sessions-watcher.service.js +0 -13
  71. package/dist-server/server/modules/providers/services/sessions-watcher.service.js.map +1 -1
  72. package/dist-server/server/modules/providers/tests/codex-sessions.test.js +96 -0
  73. package/dist-server/server/modules/providers/tests/codex-sessions.test.js.map +1 -0
  74. package/dist-server/server/modules/providers/tests/mcp.test.js +3 -29
  75. package/dist-server/server/modules/providers/tests/mcp.test.js.map +1 -1
  76. package/dist-server/server/modules/providers/tests/opencode-models.test.js +7 -0
  77. package/dist-server/server/modules/providers/tests/opencode-models.test.js.map +1 -1
  78. package/dist-server/server/modules/providers/tests/opencode-sessions.test.js +117 -0
  79. package/dist-server/server/modules/providers/tests/opencode-sessions.test.js.map +1 -1
  80. package/dist-server/server/modules/providers/tests/provider-image-history.test.js +147 -0
  81. package/dist-server/server/modules/providers/tests/provider-image-history.test.js.map +1 -0
  82. package/dist-server/server/modules/providers/tests/provider-models.service.test.js +8 -8
  83. package/dist-server/server/modules/providers/tests/skills.test.js +3 -26
  84. package/dist-server/server/modules/providers/tests/skills.test.js.map +1 -1
  85. package/dist-server/server/modules/websocket/services/chat-run-registry.service.js +14 -0
  86. package/dist-server/server/modules/websocket/services/chat-run-registry.service.js.map +1 -1
  87. package/dist-server/server/modules/websocket/services/chat-websocket.service.js +36 -2
  88. package/dist-server/server/modules/websocket/services/chat-websocket.service.js.map +1 -1
  89. package/dist-server/server/modules/websocket/services/shell-websocket.service.js +3 -12
  90. package/dist-server/server/modules/websocket/services/shell-websocket.service.js.map +1 -1
  91. package/dist-server/server/modules/websocket/tests/chat-image-filter.test.js +33 -0
  92. package/dist-server/server/modules/websocket/tests/chat-image-filter.test.js.map +1 -0
  93. package/dist-server/server/modules/websocket/tests/chat-run-registry.test.js +37 -4
  94. package/dist-server/server/modules/websocket/tests/chat-run-registry.test.js.map +1 -1
  95. package/dist-server/server/openai-codex.js +8 -2
  96. package/dist-server/server/openai-codex.js.map +1 -1
  97. package/dist-server/server/opencode-cli.js +43 -6
  98. package/dist-server/server/opencode-cli.js.map +1 -1
  99. package/dist-server/server/opencode-cli.test.js +103 -3
  100. package/dist-server/server/opencode-cli.test.js.map +1 -1
  101. package/dist-server/server/routes/agent.js +9 -19
  102. package/dist-server/server/routes/agent.js.map +1 -1
  103. package/dist-server/server/routes/commands.js +1 -2
  104. package/dist-server/server/routes/commands.js.map +1 -1
  105. package/dist-server/server/routes/git.js +179 -53
  106. package/dist-server/server/routes/git.js.map +1 -1
  107. package/dist-server/server/routes/git.test.js +80 -0
  108. package/dist-server/server/routes/git.test.js.map +1 -0
  109. package/dist-server/server/routes/taskmaster.js +3 -1
  110. package/dist-server/server/routes/taskmaster.js.map +1 -1
  111. package/dist-server/server/routes/user.js +2 -1
  112. package/dist-server/server/routes/user.js.map +1 -1
  113. package/dist-server/server/shared/image-attachments.js +270 -0
  114. package/dist-server/server/shared/image-attachments.js.map +1 -0
  115. package/dist-server/server/shared/tests/image-attachments.test.js +220 -0
  116. package/dist-server/server/shared/tests/image-attachments.test.js.map +1 -0
  117. package/dist-server/server/shared/utils.js +43 -0
  118. package/dist-server/server/shared/utils.js.map +1 -1
  119. package/dist-server/server/utils/gitConfig.js +2 -1
  120. package/dist-server/server/utils/gitConfig.js.map +1 -1
  121. package/dist-server/server/utils/plugin-process-manager.js +2 -1
  122. package/dist-server/server/utils/plugin-process-manager.js.map +1 -1
  123. package/electron/launcher/launcher.js +1 -1
  124. package/package.json +4 -4
  125. package/public/api-docs.html +2 -3
  126. package/server/claude-sdk.js +37 -105
  127. package/server/cli.js +1 -4
  128. package/server/cursor-cli.js +13 -12
  129. package/server/index.js +11 -159
  130. package/server/modules/assets/assets.routes.ts +103 -0
  131. package/server/modules/assets/index.ts +3 -0
  132. package/server/modules/assets/services/image-assets.service.ts +82 -0
  133. package/server/modules/assets/tests/image-assets.service.test.ts +46 -0
  134. package/server/modules/browser-use/browser-use.service.ts +7 -3
  135. package/server/modules/database/tests/sessions-provider-mapping.test.ts +2 -2
  136. package/server/modules/notifications/services/notification-orchestrator.service.js +0 -1
  137. package/server/modules/projects/services/project-clone.service.ts +3 -1
  138. package/server/modules/providers/README.md +1 -7
  139. package/server/modules/providers/list/claude/claude-sessions.provider.ts +30 -0
  140. package/server/modules/providers/list/codex/codex-session-synchronizer.provider.ts +60 -1
  141. package/server/modules/providers/list/codex/codex-sessions.provider.ts +41 -1
  142. package/server/modules/providers/list/cursor/cursor-models.provider.ts +3 -17
  143. package/server/modules/providers/list/cursor/cursor-session-synchronizer.provider.ts +7 -1
  144. package/server/modules/providers/list/cursor/cursor-sessions.provider.ts +56 -15
  145. package/server/modules/providers/list/opencode/opencode-auth.provider.ts +0 -1
  146. package/server/modules/providers/list/opencode/opencode-models.provider.ts +15 -19
  147. package/server/modules/providers/list/opencode/opencode-session-synchronizer.provider.ts +25 -4
  148. package/server/modules/providers/list/opencode/opencode-sessions.provider.ts +10 -21
  149. package/server/modules/providers/provider.registry.ts +0 -2
  150. package/server/modules/providers/provider.routes.ts +0 -1
  151. package/server/modules/providers/services/provider-capabilities.service.ts +7 -14
  152. package/server/modules/providers/services/provider-models.service.ts +1 -1
  153. package/server/modules/providers/services/session-conversations-search.service.ts +3 -93
  154. package/server/modules/providers/services/session-synchronizer.service.ts +0 -1
  155. package/server/modules/providers/services/sessions-watcher.service.ts +0 -14
  156. package/server/modules/providers/tests/codex-sessions.test.ts +111 -0
  157. package/server/modules/providers/tests/mcp.test.ts +3 -34
  158. package/server/modules/providers/tests/opencode-models.test.ts +7 -0
  159. package/server/modules/providers/tests/opencode-sessions.test.ts +139 -0
  160. package/server/modules/providers/tests/provider-image-history.test.ts +180 -0
  161. package/server/modules/providers/tests/provider-models.service.test.ts +8 -8
  162. package/server/modules/providers/tests/skills.test.ts +3 -42
  163. package/server/modules/websocket/services/chat-run-registry.service.ts +16 -0
  164. package/server/modules/websocket/services/chat-websocket.service.ts +41 -2
  165. package/server/modules/websocket/services/shell-websocket.service.ts +1 -11
  166. package/server/modules/websocket/tests/chat-image-filter.test.ts +44 -0
  167. package/server/modules/websocket/tests/chat-run-registry.test.ts +42 -4
  168. package/server/openai-codex.js +9 -1
  169. package/server/opencode-cli.js +44 -6
  170. package/server/opencode-cli.test.js +112 -3
  171. package/server/routes/agent.js +9 -19
  172. package/server/routes/commands.js +1 -2
  173. package/server/routes/git.js +201 -60
  174. package/server/routes/git.test.js +106 -0
  175. package/server/routes/taskmaster.js +3 -1
  176. package/server/routes/user.js +2 -1
  177. package/server/shared/image-attachments.ts +341 -0
  178. package/server/shared/tests/image-attachments.test.ts +298 -0
  179. package/server/shared/types.ts +1 -1
  180. package/server/shared/utils.ts +45 -0
  181. package/server/utils/gitConfig.js +2 -1
  182. package/server/utils/plugin-process-manager.js +3 -1
  183. package/dist/assets/index-uLUE6Qt1.css +0 -32
  184. package/dist/icons/gemini-ai-icon.svg +0 -1
  185. package/dist-server/server/gemini-cli.js +0 -551
  186. package/dist-server/server/gemini-cli.js.map +0 -1
  187. package/dist-server/server/gemini-response-handler.js +0 -106
  188. package/dist-server/server/gemini-response-handler.js.map +0 -1
  189. package/dist-server/server/modules/providers/list/gemini/gemini-auth.provider.js +0 -254
  190. package/dist-server/server/modules/providers/list/gemini/gemini-auth.provider.js.map +0 -1
  191. package/dist-server/server/modules/providers/list/gemini/gemini-mcp.provider.js +0 -82
  192. package/dist-server/server/modules/providers/list/gemini/gemini-mcp.provider.js.map +0 -1
  193. package/dist-server/server/modules/providers/list/gemini/gemini-models.provider.js +0 -24
  194. package/dist-server/server/modules/providers/list/gemini/gemini-models.provider.js.map +0 -1
  195. package/dist-server/server/modules/providers/list/gemini/gemini-session-synchronizer.provider.js +0 -312
  196. package/dist-server/server/modules/providers/list/gemini/gemini-session-synchronizer.provider.js.map +0 -1
  197. package/dist-server/server/modules/providers/list/gemini/gemini-sessions.provider.js +0 -474
  198. package/dist-server/server/modules/providers/list/gemini/gemini-sessions.provider.js.map +0 -1
  199. package/dist-server/server/modules/providers/list/gemini/gemini-skills.provider.js +0 -40
  200. package/dist-server/server/modules/providers/list/gemini/gemini-skills.provider.js.map +0 -1
  201. package/dist-server/server/modules/providers/list/gemini/gemini.provider.js +0 -19
  202. package/dist-server/server/modules/providers/list/gemini/gemini.provider.js.map +0 -1
  203. package/dist-server/server/routes/gemini.js +0 -21
  204. package/dist-server/server/routes/gemini.js.map +0 -1
  205. package/dist-server/server/sessionManager.js +0 -194
  206. package/dist-server/server/sessionManager.js.map +0 -1
  207. package/server/gemini-cli.js +0 -638
  208. package/server/gemini-response-handler.js +0 -117
  209. package/server/modules/providers/list/gemini/gemini-auth.provider.ts +0 -307
  210. package/server/modules/providers/list/gemini/gemini-mcp.provider.ts +0 -110
  211. package/server/modules/providers/list/gemini/gemini-models.provider.ts +0 -39
  212. package/server/modules/providers/list/gemini/gemini-session-synchronizer.provider.ts +0 -405
  213. package/server/modules/providers/list/gemini/gemini-sessions.provider.ts +0 -540
  214. package/server/modules/providers/list/gemini/gemini-skills.provider.ts +0 -44
  215. package/server/modules/providers/list/gemini/gemini.provider.ts +0 -27
  216. package/server/routes/gemini.js +0 -25
  217. package/server/sessionManager.js +0 -226
@@ -0,0 +1,341 @@
1
+ import { promises as fs, realpathSync } from 'node:fs';
2
+ import os from 'node:os';
3
+ import path from 'node:path';
4
+
5
+ /**
6
+ * Shared image-attachment plumbing for every provider runtime.
7
+ *
8
+ * Uploaded chat images are persisted once in the global `~/.cloudcli/assets`
9
+ * folder and referenced by absolute path everywhere else:
10
+ * - Claude: paths are read back into base64 `image` content blocks.
11
+ * - Codex: paths become `local_image` input items.
12
+ * - Cursor/OpenCode: paths are appended to the prompt inside an
13
+ * `<images_input>` tag, which is stripped again when history is read.
14
+ *
15
+ * The chat UI loads them through the dedicated `/api/assets/images/:filename`
16
+ * route, which serves only from this folder.
17
+ */
18
+
19
+ /** Global storage folder for uploaded chat image attachments. */
20
+ export function getGlobalImageAssetsDir(): string {
21
+ return path.join(os.homedir(), '.cloudcli', 'assets');
22
+ }
23
+
24
+ export type ImageAttachmentDescriptor = {
25
+ /** Project-relative (preferred) or absolute path to the stored image. */
26
+ path: string;
27
+ name?: string;
28
+ mimeType?: string;
29
+ };
30
+
31
+ /** Media types the Claude Messages API accepts for base64 image blocks. */
32
+ const CLAUDE_IMAGE_MEDIA_TYPES = new Set([
33
+ 'image/jpeg',
34
+ 'image/png',
35
+ 'image/gif',
36
+ 'image/webp',
37
+ ]);
38
+
39
+ const EXTENSION_TO_MEDIA_TYPE: Record<string, string> = {
40
+ '.jpg': 'image/jpeg',
41
+ '.jpeg': 'image/jpeg',
42
+ '.png': 'image/png',
43
+ '.gif': 'image/gif',
44
+ '.webp': 'image/webp',
45
+ '.svg': 'image/svg+xml',
46
+ };
47
+
48
+ /**
49
+ * Accepts the loosely-typed `options.images` payload from chat.send and
50
+ * returns only well-formed descriptors. Plain path strings are supported so
51
+ * callers can also pass bare path arrays.
52
+ */
53
+ export function normalizeImageDescriptors(images: unknown): ImageAttachmentDescriptor[] {
54
+ if (!Array.isArray(images)) {
55
+ return [];
56
+ }
57
+
58
+ const descriptors: ImageAttachmentDescriptor[] = [];
59
+ for (const entry of images) {
60
+ if (typeof entry === 'string' && entry.trim()) {
61
+ descriptors.push({ path: entry.trim() });
62
+ continue;
63
+ }
64
+ if (entry && typeof entry === 'object') {
65
+ const record = entry as Record<string, unknown>;
66
+ const entryPath = typeof record.path === 'string' ? record.path.trim() : '';
67
+ if (!entryPath) {
68
+ continue;
69
+ }
70
+ descriptors.push({
71
+ path: entryPath,
72
+ name: typeof record.name === 'string' ? record.name : undefined,
73
+ mimeType: typeof record.mimeType === 'string' ? record.mimeType : undefined,
74
+ });
75
+ }
76
+ }
77
+ return descriptors;
78
+ }
79
+
80
+ /** Normalizes Windows separators so stored references stay portable. */
81
+ export function toPosixPath(value: string): string {
82
+ return value.replace(/\\/g, '/');
83
+ }
84
+
85
+ /** Resolves a project-relative image path against the run's working directory. */
86
+ export function resolveImageAbsolutePath(cwd: string | undefined, imagePath: string): string {
87
+ if (path.isAbsolute(imagePath)) {
88
+ return imagePath;
89
+ }
90
+ return path.resolve(cwd || process.cwd(), imagePath);
91
+ }
92
+
93
+ function isPathInsideDirectory(candidate: string, directory: string): boolean {
94
+ // resolve + startsWith(root + separator) is the containment idiom CodeQL
95
+ // recognizes as a path-injection barrier, and matches the check used by
96
+ // resolveImageAssetFile in the assets module. The root itself never
97
+ // matches (no trailing separator after resolve), only entries below it.
98
+ const resolvedRoot = path.resolve(directory) + path.sep;
99
+ return path.resolve(candidate).startsWith(resolvedRoot);
100
+ }
101
+
102
+ function getDirectoryPathVariants(directory: string): string[] {
103
+ const resolvedDirectory = path.resolve(directory);
104
+ try {
105
+ const canonicalDirectory = path.resolve(realpathSync(directory));
106
+ return canonicalDirectory === resolvedDirectory
107
+ ? [resolvedDirectory]
108
+ : [resolvedDirectory, canonicalDirectory];
109
+ } catch {
110
+ return [resolvedDirectory];
111
+ }
112
+ }
113
+
114
+ /**
115
+ * Second layer of the image trust boundary (the first is the chat.send filter
116
+ * in the websocket gateway): provider builders only reference files that live
117
+ * in the global upload store or inside the run's working directory — places
118
+ * the agent could already access on its own. Anything else (e.g. `~/.ssh`) is
119
+ * refused, so a caller-supplied descriptor can never leak arbitrary files.
120
+ */
121
+ export function isAllowedImageSourcePath(resolvedPath: string, cwd?: string): boolean {
122
+ return [getGlobalImageAssetsDir(), cwd || process.cwd()].some((directory) =>
123
+ getDirectoryPathVariants(directory).some((directoryVariant) =>
124
+ isPathInsideDirectory(resolvedPath, directoryVariant)
125
+ )
126
+ );
127
+ }
128
+
129
+ /**
130
+ * Resolves the media type for one image, preferring the uploaded mime type and
131
+ * falling back to the file extension.
132
+ */
133
+ export function resolveImageMediaType(descriptor: ImageAttachmentDescriptor): string | null {
134
+ if (descriptor.mimeType) {
135
+ return descriptor.mimeType;
136
+ }
137
+ const extension = path.extname(descriptor.path).toLowerCase();
138
+ return EXTENSION_TO_MEDIA_TYPE[extension] || null;
139
+ }
140
+
141
+ const IMAGES_INPUT_TAG_PATTERN = /\s*<images_input>([\s\S]*?)<\/images_input>\s*/g;
142
+
143
+ // One image reference recovered from an <images_input> block: the stored
144
+ // asset path plus the user's original filename when it was recorded.
145
+ export type ParsedImageAttachment = {
146
+ path: string;
147
+ name?: string;
148
+ };
149
+
150
+ // Result of stripping an <images_input> block out of persisted prompt text.
151
+ // `imagePaths` mirrors `attachments` for callers that only need paths.
152
+ export type ParsedImagesInput = {
153
+ text: string;
154
+ imagePaths: string[];
155
+ attachments: ParsedImageAttachment[];
156
+ };
157
+
158
+ /**
159
+ * Appends the `<images_input>` reference block used by the Cursor and
160
+ * OpenCode CLIs. The block carries one numbered line per attachment with
161
+ * the stored file path (quote-free on purpose — Windows .cmd shims mangle
162
+ * quoted text) and the user's original filename, plus an explicit instruction
163
+ * to read the files and keep the block out of the reply. The same block is
164
+ * stripped back out of persisted history by {@link parseImagesInputTag}.
165
+ */
166
+ export function appendImagesInputTag(prompt: string, images: unknown): string {
167
+ const descriptors = normalizeImageDescriptors(images);
168
+ if (descriptors.length === 0) {
169
+ return prompt;
170
+ }
171
+
172
+ const entryLines = descriptors.map((descriptor, index) => {
173
+ const entryPath = toPosixPath(descriptor.path);
174
+ // Parentheses and newlines would break the "(original name: ...)" suffix
175
+ // the parser looks for, so drop them from the display name.
176
+ const cleanName = descriptor.name?.replace(/[()\r\n]/g, '').trim();
177
+ return cleanName
178
+ ? `${index + 1}. ${entryPath} (original name: ${cleanName})`
179
+ : `${index + 1}. ${entryPath}`;
180
+ });
181
+
182
+ return [
183
+ prompt,
184
+ '',
185
+ '<images_input>',
186
+ `The user attached ${descriptors.length} image(s) to this message. Read each file listed below with your file/image reading tool and use what you see to answer the prompt above. Respond as if the images were attached directly. Do not mention this block or the file paths unless the user asks about them.`,
187
+ ...entryLines,
188
+ '</images_input>',
189
+ ].join('\n');
190
+ }
191
+
192
+ // Matches one numbered attachment entry inside the tag body. Works for both
193
+ // the multi-line block and the Windows-flattened single-line form, where the
194
+ // next ` N. ` marker (or the end of the body) delimits each entry.
195
+ const IMAGES_INPUT_ENTRY_PATTERN = /\d+\.\s+(.+?)(?=\s+\d+\.\s+|\s*$)/g;
196
+
197
+ const ORIGINAL_NAME_SUFFIX_PATTERN = /\(original name: ([^)]*)\)\s*$/;
198
+
199
+ function parseNumberedImageEntries(inner: string): ParsedImageAttachment[] {
200
+ const attachments: ParsedImageAttachment[] = [];
201
+ for (const entryMatch of inner.matchAll(IMAGES_INPUT_ENTRY_PATTERN)) {
202
+ let entryText = entryMatch[1].trim();
203
+ let name: string | undefined;
204
+
205
+ const nameMatch = ORIGINAL_NAME_SUFFIX_PATTERN.exec(entryText);
206
+ if (nameMatch) {
207
+ name = nameMatch[1].trim() || undefined;
208
+ entryText = entryText.slice(0, nameMatch.index).trim();
209
+ }
210
+
211
+ if (entryText) {
212
+ attachments.push(name ? { path: toPosixPath(entryText), name } : { path: toPosixPath(entryText) });
213
+ }
214
+ }
215
+ return attachments;
216
+ }
217
+
218
+ /**
219
+ * Strips one `<images_input>` block from persisted prompt text and returns
220
+ * the clean text plus the referenced attachments (path and original name).
221
+ *
222
+ * Only the LAST block in the text is treated as the attachment carrier — the
223
+ * composer always appends it at the end, so a user who literally typed
224
+ * `<images_input>` earlier in their prompt keeps that text intact.
225
+ *
226
+ * Understands the numbered-line body in both its multi-line and
227
+ * Windows-flattened single-line forms.
228
+ */
229
+ export function parseImagesInputTag(text: string): ParsedImagesInput {
230
+ if (typeof text !== 'string' || !text.includes('<images_input>')) {
231
+ return { text, imagePaths: [], attachments: [] };
232
+ }
233
+
234
+ let lastMatch: RegExpExecArray | null = null;
235
+ IMAGES_INPUT_TAG_PATTERN.lastIndex = 0;
236
+ for (let match = IMAGES_INPUT_TAG_PATTERN.exec(text); match; match = IMAGES_INPUT_TAG_PATTERN.exec(text)) {
237
+ lastMatch = match;
238
+ }
239
+ if (!lastMatch) {
240
+ return { text, imagePaths: [], attachments: [] };
241
+ }
242
+
243
+ const attachments = parseNumberedImageEntries(lastMatch[1]);
244
+
245
+ const stripped = (
246
+ text.slice(0, lastMatch.index) + '\n' + text.slice(lastMatch.index + lastMatch[0].length)
247
+ ).trim();
248
+
249
+ return {
250
+ text: stripped,
251
+ imagePaths: attachments.map((attachment) => attachment.path),
252
+ attachments,
253
+ };
254
+ }
255
+
256
+ /** Maps raw image paths to the attachment shape carried by NormalizedMessage.images. */
257
+ export function toImageAttachments(imagePaths: string[]): Array<{ path: string }> {
258
+ return imagePaths.map((imagePath) => ({ path: toPosixPath(imagePath) }));
259
+ }
260
+
261
+ type ClaudeContentBlock =
262
+ | { type: 'text'; text: string }
263
+ | { type: 'image'; source: { type: 'base64'; media_type: string; data: string } };
264
+
265
+ /**
266
+ * Builds the Claude user-message content list: the prompt text followed by one
267
+ * base64 `image` block per attachment. Images the Claude API cannot accept
268
+ * (e.g. SVG) or that fail to read are skipped with a warning so the prompt
269
+ * itself still goes through.
270
+ */
271
+ export async function buildClaudeUserContent(
272
+ prompt: string,
273
+ images: unknown,
274
+ cwd?: string,
275
+ ): Promise<ClaudeContentBlock[]> {
276
+ const blocks: ClaudeContentBlock[] = [{ type: 'text', text: prompt }];
277
+
278
+ for (const descriptor of normalizeImageDescriptors(images)) {
279
+ const mediaType = resolveImageMediaType(descriptor);
280
+ if (!mediaType || !CLAUDE_IMAGE_MEDIA_TYPES.has(mediaType)) {
281
+ console.warn(`[Images] Skipping unsupported Claude image type for ${descriptor.path}`);
282
+ continue;
283
+ }
284
+
285
+ const resolvedPath = resolveImageAbsolutePath(cwd, descriptor.path);
286
+ if (!isAllowedImageSourcePath(resolvedPath, cwd)) {
287
+ console.warn(`[Images] Refusing to read image outside allowed roots: ${descriptor.path}`);
288
+ continue;
289
+ }
290
+
291
+ try {
292
+ const canonicalPath = await fs.realpath(resolvedPath);
293
+ if (!isAllowedImageSourcePath(canonicalPath, cwd)) {
294
+ console.warn(`[Images] Refusing to read symlinked image outside allowed roots: ${descriptor.path}`);
295
+ continue;
296
+ }
297
+
298
+ const bytes = await fs.readFile(canonicalPath);
299
+ blocks.push({
300
+ type: 'image',
301
+ source: {
302
+ type: 'base64',
303
+ media_type: mediaType,
304
+ data: bytes.toString('base64'),
305
+ },
306
+ });
307
+ } catch (error) {
308
+ const message = error instanceof Error ? error.message : String(error);
309
+ console.warn(`[Images] Failed to read image ${descriptor.path}: ${message}`);
310
+ }
311
+ }
312
+
313
+ return blocks;
314
+ }
315
+
316
+ type CodexInputItem =
317
+ | { type: 'text'; text: string }
318
+ | { type: 'local_image'; path: string };
319
+
320
+ /**
321
+ * Builds the Codex `runStreamed` input list: prompt text plus one
322
+ * `local_image` item per attachment, resolved to absolute paths so the Codex
323
+ * runtime can read them regardless of its own working directory handling.
324
+ */
325
+ export function buildCodexInputItems(prompt: string, images: unknown, cwd?: string): CodexInputItem[] {
326
+ const items: CodexInputItem[] = [{ type: 'text', text: prompt }];
327
+ for (const descriptor of normalizeImageDescriptors(images)) {
328
+ const resolvedPath = resolveImageAbsolutePath(cwd, descriptor.path);
329
+ if (!isAllowedImageSourcePath(resolvedPath, cwd)) {
330
+ // Same trust boundary as buildClaudeUserContent — the Codex runtime
331
+ // reads this file, so it must stay within the allowed roots.
332
+ console.warn(`[Images] Refusing to attach image outside allowed roots: ${descriptor.path}`);
333
+ continue;
334
+ }
335
+ items.push({
336
+ type: 'local_image',
337
+ path: resolvedPath,
338
+ });
339
+ }
340
+ return items;
341
+ }
@@ -0,0 +1,298 @@
1
+ import assert from 'node:assert/strict';
2
+ import { mkdtemp, rm, symlink, writeFile } from 'node:fs/promises';
3
+ import os from 'node:os';
4
+ import path from 'node:path';
5
+ import test from 'node:test';
6
+
7
+ import {
8
+ appendImagesInputTag,
9
+ buildClaudeUserContent,
10
+ buildCodexInputItems,
11
+ isAllowedImageSourcePath,
12
+ normalizeImageDescriptors,
13
+ parseImagesInputTag,
14
+ resolveImageMediaType,
15
+ toImageAttachments,
16
+ } from '@/shared/image-attachments.js';
17
+
18
+ // 1x1 transparent PNG
19
+ const PNG_BYTES = Buffer.from(
20
+ 'iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mNkYPhfDwAChwGA60e6kgAAAABJRU5ErkJggg==',
21
+ 'base64',
22
+ );
23
+
24
+ const SYMLINK_UNSUPPORTED_CODES = new Set(['EACCES', 'EINVAL', 'ENOSYS', 'ENOTSUP', 'EPERM']);
25
+
26
+ function isErrnoException(error: unknown): error is NodeJS.ErrnoException {
27
+ return error instanceof Error && 'code' in error;
28
+ }
29
+
30
+ async function createSymlinkIfSupported(
31
+ target: string,
32
+ linkPath: string,
33
+ type: 'dir' | 'file' | 'junction',
34
+ ): Promise<boolean> {
35
+ try {
36
+ await symlink(target, linkPath, type);
37
+ return true;
38
+ } catch (error) {
39
+ if (
40
+ isErrnoException(error) &&
41
+ typeof error.code === 'string' &&
42
+ SYMLINK_UNSUPPORTED_CODES.has(error.code)
43
+ ) {
44
+ return false;
45
+ }
46
+ throw error;
47
+ }
48
+ }
49
+
50
+ test('normalizeImageDescriptors accepts objects and bare paths, drops junk', () => {
51
+ const descriptors = normalizeImageDescriptors([
52
+ { path: '.cloudcli/assets/a.png', name: 'a.png', mimeType: 'image/png' },
53
+ 'scripts/pic.jpg',
54
+ { name: 'no-path.png' },
55
+ 42,
56
+ null,
57
+ '',
58
+ ]);
59
+
60
+ assert.deepEqual(descriptors, [
61
+ { path: '.cloudcli/assets/a.png', name: 'a.png', mimeType: 'image/png' },
62
+ { path: 'scripts/pic.jpg' },
63
+ ]);
64
+ assert.deepEqual(normalizeImageDescriptors(undefined), []);
65
+ assert.deepEqual(normalizeImageDescriptors('not-an-array'), []);
66
+ });
67
+
68
+ test('appendImagesInputTag and parseImagesInputTag round-trip', () => {
69
+ const prompt = 'Describe these screenshots.\n\nFocus on the header.';
70
+ const tagged = appendImagesInputTag(prompt, [
71
+ { path: '.cloudcli/assets/1-a.png' },
72
+ { path: '.cloudcli\\assets\\2-b.jpg' },
73
+ ]);
74
+
75
+ assert.ok(tagged.startsWith(prompt));
76
+ assert.ok(tagged.includes('<images_input>'));
77
+ assert.ok(tagged.includes('</images_input>'));
78
+ assert.ok(tagged.includes('The user attached 2 image(s)'));
79
+
80
+ const parsed = parseImagesInputTag(tagged);
81
+ assert.equal(parsed.text, prompt);
82
+ // Backslashes are normalized so references stay portable.
83
+ assert.deepEqual(parsed.imagePaths, ['.cloudcli/assets/1-a.png', '.cloudcli/assets/2-b.jpg']);
84
+ });
85
+
86
+ test('original filenames round-trip through the tag', () => {
87
+ const tagged = appendImagesInputTag('compare these', [
88
+ { path: 'C:/Users/x/.cloudcli/assets/1-a.png', name: 'screenshot (final).png' },
89
+ { path: 'C:/Users/x/.cloudcli/assets/2-b.jpg' },
90
+ ]);
91
+
92
+ const parsed = parseImagesInputTag(tagged);
93
+ assert.equal(parsed.text, 'compare these');
94
+ // Parentheses are dropped from names so the "(original name: ...)" suffix
95
+ // stays parseable; the path-only entry carries no name.
96
+ assert.deepEqual(parsed.attachments, [
97
+ { path: 'C:/Users/x/.cloudcli/assets/1-a.png', name: 'screenshot final.png' },
98
+ { path: 'C:/Users/x/.cloudcli/assets/2-b.jpg' },
99
+ ]);
100
+ });
101
+
102
+ test('only the LAST images_input block is treated as the attachment carrier', () => {
103
+ const userTypedTag = 'What does <images_input> mean in this codebase?';
104
+ const tagged = appendImagesInputTag(
105
+ `${userTypedTag}\n\n<images_input>\nfake user block\n</images_input>\n\nAlso check this.`,
106
+ [{ path: 'C:/Users/x/.cloudcli/assets/real.png' }],
107
+ );
108
+
109
+ const parsed = parseImagesInputTag(tagged);
110
+ assert.ok(parsed.text.includes('fake user block'));
111
+ assert.ok(parsed.text.includes('Also check this.'));
112
+ assert.deepEqual(parsed.imagePaths, ['C:/Users/x/.cloudcli/assets/real.png']);
113
+ });
114
+
115
+ test('appendImagesInputTag without images returns the prompt untouched', () => {
116
+ assert.equal(appendImagesInputTag('hello', []), 'hello');
117
+ assert.equal(appendImagesInputTag('hello', undefined), 'hello');
118
+ });
119
+
120
+ test('parseImagesInputTag handles prompts flattened to one line for cmd.exe shims', () => {
121
+ // Windows spawn runtimes collapse newlines before passing the argument to
122
+ // .cmd-shimmed CLIs; the persisted prompt is then a single line.
123
+ const flattened = appendImagesInputTag('now?', [{ path: 'C:/Users/x/.cloudcli/assets/a.jpg' }])
124
+ .replace(/\s*\r?\n\s*/g, ' ')
125
+ .trim();
126
+
127
+ assert.ok(!flattened.includes('\n'));
128
+ const parsed = parseImagesInputTag(flattened);
129
+ assert.equal(parsed.text, 'now?');
130
+ assert.deepEqual(parsed.imagePaths, ['C:/Users/x/.cloudcli/assets/a.jpg']);
131
+ });
132
+
133
+ test('parseImagesInputTag leaves text without a tag untouched', () => {
134
+ const text = 'Just a normal prompt with [brackets] and JSON ["like"] content.';
135
+ const parsed = parseImagesInputTag(text);
136
+ assert.equal(parsed.text, text);
137
+ assert.deepEqual(parsed.imagePaths, []);
138
+ });
139
+
140
+ test('parseImagesInputTag strips a malformed tag body without attaching images', () => {
141
+ const text = 'prompt\n\n<images_input>\nnot json here\n</images_input>';
142
+ const parsed = parseImagesInputTag(text);
143
+ assert.equal(parsed.text, 'prompt');
144
+ assert.deepEqual(parsed.imagePaths, []);
145
+ });
146
+
147
+ test('toImageAttachments maps paths to posix attachment records', () => {
148
+ assert.deepEqual(toImageAttachments(['a\\b\\c.png', 'd/e.jpg']), [
149
+ { path: 'a/b/c.png' },
150
+ { path: 'd/e.jpg' },
151
+ ]);
152
+ });
153
+
154
+ test('resolveImageMediaType prefers the mime type and falls back to the extension', () => {
155
+ assert.equal(resolveImageMediaType({ path: 'x.bin', mimeType: 'image/webp' }), 'image/webp');
156
+ assert.equal(resolveImageMediaType({ path: 'x.JPG' }), 'image/jpeg');
157
+ assert.equal(resolveImageMediaType({ path: 'x.png' }), 'image/png');
158
+ assert.equal(resolveImageMediaType({ path: 'x.unknown' }), null);
159
+ });
160
+
161
+ test('buildClaudeUserContent reads image bytes into base64 blocks', async () => {
162
+ const tempDir = await mkdtemp(path.join(os.tmpdir(), 'image-attachments-'));
163
+ try {
164
+ await writeFile(path.join(tempDir, 'shot.png'), PNG_BYTES);
165
+
166
+ const content = await buildClaudeUserContent(
167
+ 'What is in this image?',
168
+ [{ path: 'shot.png', mimeType: 'image/png' }],
169
+ tempDir,
170
+ );
171
+
172
+ assert.equal(content.length, 2);
173
+ assert.deepEqual(content[0], { type: 'text', text: 'What is in this image?' });
174
+ assert.equal(content[1].type, 'image');
175
+ const imageBlock = content[1] as Extract<(typeof content)[number], { type: 'image' }>;
176
+ assert.equal(imageBlock.source.type, 'base64');
177
+ assert.equal(imageBlock.source.media_type, 'image/png');
178
+ assert.equal(imageBlock.source.data, PNG_BYTES.toString('base64'));
179
+ } finally {
180
+ await rm(tempDir, { recursive: true, force: true });
181
+ }
182
+ });
183
+
184
+ test('buildClaudeUserContent skips unsupported types and unreadable files', async () => {
185
+ const tempDir = await mkdtemp(path.join(os.tmpdir(), 'image-attachments-'));
186
+ try {
187
+ await writeFile(path.join(tempDir, 'vector.svg'), '<svg></svg>');
188
+
189
+ const content = await buildClaudeUserContent(
190
+ 'prompt',
191
+ [
192
+ { path: 'vector.svg', mimeType: 'image/svg+xml' },
193
+ { path: 'missing.png', mimeType: 'image/png' },
194
+ ],
195
+ tempDir,
196
+ );
197
+
198
+ // Only the text block survives; the prompt still goes through.
199
+ assert.deepEqual(content, [{ type: 'text', text: 'prompt' }]);
200
+ } finally {
201
+ await rm(tempDir, { recursive: true, force: true });
202
+ }
203
+ });
204
+
205
+ test('buildClaudeUserContent refuses symlinked images outside allowed roots', async (t) => {
206
+ const tempDir = await mkdtemp(path.join(os.tmpdir(), 'image-attachments-'));
207
+ const outsideDir = await mkdtemp(path.join(os.tmpdir(), 'image-attachments-outside-'));
208
+ try {
209
+ const outsideFile = path.join(outsideDir, 'secret.png');
210
+ await writeFile(outsideFile, PNG_BYTES);
211
+
212
+ const linkPath = path.join(tempDir, 'linked-secret.png');
213
+ if (!(await createSymlinkIfSupported(outsideFile, linkPath, 'file'))) {
214
+ t.skip('Symlink creation is not supported in this environment');
215
+ return;
216
+ }
217
+
218
+ const content = await buildClaudeUserContent(
219
+ 'prompt',
220
+ [{ path: 'linked-secret.png', mimeType: 'image/png' }],
221
+ tempDir,
222
+ );
223
+
224
+ assert.deepEqual(content, [{ type: 'text', text: 'prompt' }]);
225
+ } finally {
226
+ await rm(tempDir, { recursive: true, force: true });
227
+ await rm(outsideDir, { recursive: true, force: true });
228
+ }
229
+ });
230
+
231
+ test('buildClaudeUserContent accepts images under a symlinked cwd', async (t) => {
232
+ const realProjectDir = await mkdtemp(path.join(os.tmpdir(), 'image-attachments-project-'));
233
+ const linkParentDir = await mkdtemp(path.join(os.tmpdir(), 'image-attachments-link-'));
234
+ try {
235
+ await writeFile(path.join(realProjectDir, 'shot.png'), PNG_BYTES);
236
+
237
+ const linkCwd = path.join(linkParentDir, 'project-link');
238
+ const linkType = process.platform === 'win32' ? 'junction' : 'dir';
239
+ if (!(await createSymlinkIfSupported(realProjectDir, linkCwd, linkType))) {
240
+ t.skip('Symlink creation is not supported in this environment');
241
+ return;
242
+ }
243
+
244
+ const content = await buildClaudeUserContent(
245
+ 'prompt',
246
+ [{ path: 'shot.png', mimeType: 'image/png' }],
247
+ linkCwd,
248
+ );
249
+
250
+ assert.equal(content.length, 2);
251
+ assert.equal(content[1].type, 'image');
252
+ const imageBlock = content[1] as Extract<(typeof content)[number], { type: 'image' }>;
253
+ assert.equal(imageBlock.source.data, PNG_BYTES.toString('base64'));
254
+ } finally {
255
+ await rm(linkParentDir, { recursive: true, force: true });
256
+ await rm(realProjectDir, { recursive: true, force: true });
257
+ }
258
+ });
259
+
260
+ test('buildCodexInputItems emits text plus absolute local_image paths', () => {
261
+ const cwd = path.join(os.tmpdir(), 'codex-project');
262
+ const items = buildCodexInputItems('Describe this image:', [{ path: '.cloudcli/assets/pic.jpg' }], cwd);
263
+
264
+ assert.equal(items.length, 2);
265
+ assert.deepEqual(items[0], { type: 'text', text: 'Describe this image:' });
266
+ assert.equal(items[1].type, 'local_image');
267
+ const imageItem = items[1] as Extract<(typeof items)[number], { type: 'local_image' }>;
268
+ assert.ok(path.isAbsolute(imageItem.path));
269
+ assert.equal(imageItem.path, path.resolve(cwd, '.cloudcli/assets/pic.jpg'));
270
+ });
271
+
272
+ test('isAllowedImageSourcePath only accepts the upload store and the run cwd', () => {
273
+ const cwd = path.join(os.tmpdir(), 'some-project');
274
+ const uploadStore = path.join(os.homedir(), '.cloudcli', 'assets');
275
+
276
+ assert.equal(isAllowedImageSourcePath(path.join(uploadStore, 'shot.png'), cwd), true);
277
+ assert.equal(isAllowedImageSourcePath(path.join(cwd, 'docs', 'diagram.png'), cwd), true);
278
+
279
+ assert.equal(isAllowedImageSourcePath(path.join(os.homedir(), '.ssh', 'id_rsa'), cwd), false);
280
+ assert.equal(isAllowedImageSourcePath(path.join(cwd, '..', 'other-project', 'x.png'), cwd), false);
281
+ // The roots themselves are directories, not readable image files.
282
+ assert.equal(isAllowedImageSourcePath(cwd, cwd), false);
283
+ });
284
+
285
+ test('provider builders refuse descriptors outside the allowed roots', async () => {
286
+ const cwd = path.join(os.tmpdir(), 'codex-project');
287
+ const outsidePath = path.join(os.homedir(), '.ssh', 'id_rsa.png');
288
+
289
+ const codexItems = buildCodexInputItems('prompt', [{ path: outsidePath }], cwd);
290
+ assert.deepEqual(codexItems, [{ type: 'text', text: 'prompt' }]);
291
+
292
+ const claudeContent = await buildClaudeUserContent(
293
+ 'prompt',
294
+ [{ path: outsidePath, mimeType: 'image/png' }],
295
+ cwd,
296
+ );
297
+ assert.deepEqual(claudeContent, [{ type: 'text', text: 'prompt' }]);
298
+ });
@@ -65,7 +65,7 @@ export type AuthenticatedWebSocketRequest = IncomingMessage & {
65
65
  * Use this as the source of truth whenever a function or payload needs to identify
66
66
  * a specific LLM integration.
67
67
  */
68
- export type LLMProvider = 'claude' | 'codex' | 'gemini' | 'cursor' | 'opencode';
68
+ export type LLMProvider = 'claude' | 'codex' | 'cursor' | 'opencode';
69
69
 
70
70
  /**
71
71
  * One selectable model row in a provider model catalog.