ima2-gen 3.13.1 → 3.14.0

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 (157) hide show
  1. package/.env.example +10 -0
  2. package/CHANGELOG.md +14 -0
  3. package/README.md +15 -2
  4. package/bin/commands/capabilities.js +5 -3
  5. package/bin/commands/config.js +15 -4
  6. package/bin/commands/defaults.js +7 -5
  7. package/bin/commands/doctor.js +88 -39
  8. package/bin/commands/gen.js +21 -4
  9. package/bin/commands/models.js +2 -2
  10. package/bin/commands/ping.js +1 -1
  11. package/bin/commands/prompt.js +5 -3
  12. package/bin/commands/service.js +32 -4
  13. package/bin/commands/tools.js +19 -3
  14. package/bin/commands/upscale.js +18 -4
  15. package/bin/commands/video.js +37 -50
  16. package/bin/ima2.js +28 -9
  17. package/bin/lib/characterResolve.js +2 -1
  18. package/bin/lib/client.js +155 -20
  19. package/bin/lib/config-store.js +40 -7
  20. package/bin/lib/doctor-bundle.js +6 -2
  21. package/bin/lib/doctor-checks.js +13 -29
  22. package/bin/lib/doctor-media.js +3 -3
  23. package/bin/lib/doctor-providers.js +48 -35
  24. package/bin/lib/doctor-report.js +78 -0
  25. package/bin/lib/doctor-runtime.js +173 -0
  26. package/bin/lib/mcpJob.js +19 -7
  27. package/bin/lib/output.js +3 -0
  28. package/bin/lib/platform.js +12 -9
  29. package/bin/lib/recover-output.js +9 -2
  30. package/bin/lib/sse.js +9 -1
  31. package/bin/lib/videoMcp.js +16 -8
  32. package/config.js +41 -5
  33. package/docs/API.md +154 -16
  34. package/docs/CLI.md +65 -1
  35. package/docs/DOCKER.md +19 -4
  36. package/docs/DOCKER.zh-CN.md +11 -4
  37. package/docs/DOCKER.zh-TW.md +11 -4
  38. package/docs/FAQ.md +13 -0
  39. package/docs/NPX_QUICKSTART.md +6 -0
  40. package/docs/README.ja.md +15 -2
  41. package/docs/README.ko.md +15 -2
  42. package/docs/README.zh-CN.md +15 -2
  43. package/docs/README.zh-TW.md +15 -2
  44. package/docs/migration/runtime-test-inventory.md +79 -8
  45. package/lib/agyArtifact.js +82 -0
  46. package/lib/agyArtifactRead.js +249 -0
  47. package/lib/agyImageAdapter.js +2 -329
  48. package/lib/agyProcess.js +122 -0
  49. package/lib/apiRequestBudget.js +48 -0
  50. package/lib/assetLifecycle.js +42 -9
  51. package/lib/canvasVersionStore.js +46 -14
  52. package/lib/capabilities.js +7 -1
  53. package/lib/configKeys.js +2 -0
  54. package/lib/errors/providerMap.js +26 -0
  55. package/lib/eventBus.js +10 -6
  56. package/lib/eventsPolicy.js +9 -0
  57. package/lib/geminiApiImageAdapter.js +1 -214
  58. package/lib/generatePipeline.js +39 -165
  59. package/lib/generatedMediaAccess.js +74 -0
  60. package/lib/generationErrors.js +3 -22
  61. package/lib/grokImageAdapter.js +2 -353
  62. package/lib/grokImageCore.js +1 -57
  63. package/lib/grokImageDownload.js +159 -0
  64. package/lib/grokImageDownloadPolicy.js +124 -0
  65. package/lib/grokImagePlanner.js +301 -0
  66. package/lib/grokMultimodeAdapter.js +1 -75
  67. package/lib/grokVideoDownload.js +137 -34
  68. package/lib/inflight.js +112 -112
  69. package/lib/jobs/terminalStore.js +45 -0
  70. package/lib/lanSessionStore.js +131 -0
  71. package/lib/localAccessPolicy.js +180 -0
  72. package/lib/localLanAccess.js +281 -0
  73. package/lib/logger.js +22 -13
  74. package/lib/mcp/downloadMediaResult.js +72 -78
  75. package/lib/multimodePipeline.js +39 -126
  76. package/lib/nodeGeneration.js +44 -101
  77. package/lib/nodeStore.js +25 -11
  78. package/lib/nodeValidation.js +5 -1
  79. package/lib/pinnedHttpGet.js +174 -0
  80. package/lib/promptImport/githubFolder.js +32 -18
  81. package/lib/promptImport/githubSource.js +14 -10
  82. package/lib/promptImport/parsePromptCandidates.js +32 -2
  83. package/lib/providers/adapters/agyOperations.js +179 -0
  84. package/lib/providers/adapters/comfy.js +1 -0
  85. package/lib/providers/adapters/geminiOperations.js +214 -0
  86. package/lib/providers/adapters/googleExecution.js +97 -0
  87. package/lib/providers/adapters/grokExecution.js +137 -0
  88. package/lib/providers/adapters/grokMultimodeOperations.js +83 -0
  89. package/lib/providers/adapters/grokOperations.js +63 -0
  90. package/lib/providers/adapters/openaiExecution.js +118 -0
  91. package/lib/providers/adapters/openaiOperations.js +201 -0
  92. package/lib/providers/adapters/openaiTypes.js +1 -0
  93. package/lib/providers/derive.js +4 -0
  94. package/lib/providers/deriveCore.js +2 -7
  95. package/lib/providers/execution/admission.js +25 -0
  96. package/lib/providers/execution/index.js +34 -0
  97. package/lib/providers/execution/legacy.js +17 -0
  98. package/lib/providers/execution/legacyClassic.js +66 -0
  99. package/lib/providers/execution/legacyEdit.js +30 -0
  100. package/lib/providers/execution/legacyMultimode.js +34 -0
  101. package/lib/providers/execution/legacyNode.js +41 -0
  102. package/lib/providers/execution/types.js +1 -0
  103. package/lib/providers/registry.js +19 -14
  104. package/lib/providers/surfaceSupport.js +22 -0
  105. package/lib/responsesImageAdapter.js +1 -389
  106. package/lib/responsesTransport.js +190 -0
  107. package/lib/spriteJobEvents.js +20 -2
  108. package/lib/ssePublish.js +5 -4
  109. package/lib/videoExtendI2vOperation.js +76 -0
  110. package/package.json +5 -2
  111. package/routes/edit.js +31 -123
  112. package/routes/events.js +140 -63
  113. package/routes/mcpMedia.js +10 -2
  114. package/routes/models.js +28 -25
  115. package/routes/videoExtended.js +9 -68
  116. package/server.js +24 -46
  117. package/ui/dist/.vite/manifest.json +90 -63
  118. package/ui/dist/assets/{AgentWorkspace-PbBytTeV.js → AgentWorkspace-CL3MLv3l.js} +2 -2
  119. package/ui/dist/assets/App-B9cEM6dJ.js +9 -0
  120. package/ui/dist/assets/AssetGenWorkspace-swWPvXrx.js +2 -0
  121. package/ui/dist/assets/AssetsWorkspace-CRVeYU2F.js +1 -0
  122. package/ui/dist/assets/{CardNewsWorkspace-BS3jKOja.js → CardNewsWorkspace-BAOxWLKf.js} +2 -2
  123. package/ui/dist/assets/GenerationRequestLogPanel-DZtYZm-D.js +1 -0
  124. package/ui/dist/assets/HomeWorkspace-BeZ1s0Jm.js +1 -0
  125. package/ui/dist/assets/KeyingPanel-ZyBSXioh.js +1 -0
  126. package/ui/dist/assets/NodeCanvas-BvvcCErf.js +7 -0
  127. package/ui/dist/assets/{PromptBuilderPanel-D97A4pwB.js → PromptBuilderPanel-DBycmShA.js} +2 -2
  128. package/ui/dist/assets/PromptImportDialog-ChvMv7ek.js +2 -0
  129. package/ui/dist/assets/PromptImportDiscoverySection-60py7whH.js +1 -0
  130. package/ui/dist/assets/PromptImportFolderSection-CCRh_-_7.js +1 -0
  131. package/ui/dist/assets/PromptLibraryPanel-BFWQO8OT.js +2 -0
  132. package/ui/dist/assets/SettingsWorkspace-B5Ghsgdj.js +1 -0
  133. package/ui/dist/assets/SpriteRecipeWorkspace-DRWdkmwU.js +1 -0
  134. package/ui/dist/assets/index-7ngEOVxt.js +5 -0
  135. package/ui/dist/assets/index-B5jy7ePg.css +1 -0
  136. package/ui/dist/assets/index-gxh_02DQ.js +22 -0
  137. package/ui/dist/assets/{pptxgen.es-D4pedbwM.js → pptxgen.es-OUNhQLEV.js} +1 -1
  138. package/ui/dist/assets/promptBuilderStore-BTUUzbBT.js +1 -0
  139. package/ui/dist/assets/useAgentDialogFocus-DSc2qY8p.js +1 -0
  140. package/ui/dist/index.html +3 -2
  141. package/ui/dist/assets/AssetGenWorkspace-B-TGlvVb.js +0 -2
  142. package/ui/dist/assets/AssetsWorkspace-LnGaW151.js +0 -1
  143. package/ui/dist/assets/GenerationRequestLogPanel-DuDWD_ic.js +0 -1
  144. package/ui/dist/assets/HomeWorkspace-DVP7UG6O.js +0 -1
  145. package/ui/dist/assets/KeyingPanel-5bRUFQaq.js +0 -1
  146. package/ui/dist/assets/NodeCanvas-BBEpERTb.js +0 -7
  147. package/ui/dist/assets/PromptImportDialog-C89MN5JY.js +0 -2
  148. package/ui/dist/assets/PromptImportDiscoverySection-BgRfDOs2.js +0 -1
  149. package/ui/dist/assets/PromptImportFolderSection-zTMyriT1.js +0 -1
  150. package/ui/dist/assets/PromptLibraryPanel-BD96qZu_.js +0 -2
  151. package/ui/dist/assets/SettingsWorkspace-BAOk3X_8.js +0 -1
  152. package/ui/dist/assets/SpriteRecipeWorkspace-ClIM5QRo.js +0 -1
  153. package/ui/dist/assets/index-0fpfY8vu.css +0 -1
  154. package/ui/dist/assets/index-BFciPGTn.js +0 -5
  155. package/ui/dist/assets/index-BbykeXR9.js +0 -30
  156. package/ui/dist/assets/promptBuilderStore-Do7M8g7J.js +0 -1
  157. package/ui/dist/assets/useAgentDialogFocus-B6-QgEVI.js +0 -1
@@ -0,0 +1,122 @@
1
+ import { spawn } from "node:child_process";
2
+ import { AGY_PROCESS_POLICY } from "../config.js";
3
+ import { buildAgyPathEnv, resolveAgyBin } from "./agyCli.js";
4
+ export function agyError(message, status, code) {
5
+ const err = new Error(message);
6
+ err.status = status;
7
+ err.code = code;
8
+ return err;
9
+ }
10
+ class AgyProcessLifetime {
11
+ child;
12
+ signal;
13
+ resolve;
14
+ reject;
15
+ stdout = "";
16
+ stderr = "";
17
+ settled = false;
18
+ reason;
19
+ timer;
20
+ graceTimer;
21
+ constructor(child, signal, resolve, reject) {
22
+ this.child = child;
23
+ this.signal = signal;
24
+ this.resolve = resolve;
25
+ this.reject = reject;
26
+ }
27
+ start(prompt) {
28
+ this.child.stdout.on("data", this.onStdout);
29
+ this.child.stderr.on("data", this.onStderr);
30
+ this.child.on("error", this.onError);
31
+ this.child.once("close", this.finish);
32
+ this.child.stdin.on("error", this.onStdinError);
33
+ this.timer = setTimeout(() => {
34
+ this.terminate(agyError("Agy generation timed out", 504, "AGY_TIMEOUT"));
35
+ }, AGY_PROCESS_POLICY.timeoutMs);
36
+ this.signal?.addEventListener("abort", this.onAbort, { once: true });
37
+ if (this.signal?.aborted) {
38
+ this.onAbort();
39
+ return;
40
+ }
41
+ this.child.stdin.write(prompt);
42
+ this.child.stdin.end();
43
+ }
44
+ onStdout = (chunk) => {
45
+ if (this.stdout.length < AGY_PROCESS_POLICY.maxOutputBytes)
46
+ this.stdout += chunk.toString();
47
+ };
48
+ onStderr = (chunk) => {
49
+ if (this.stderr.length < AGY_PROCESS_POLICY.maxOutputBytes)
50
+ this.stderr += chunk.toString();
51
+ };
52
+ onStdinError = () => {
53
+ // Preserve ignored stdin errors; the child error/close owns the result.
54
+ };
55
+ onAbort = () => {
56
+ this.terminate(agyError("Generation canceled", 499, "GENERATION_CANCELED"));
57
+ };
58
+ terminate(reason) {
59
+ if (this.settled || this.reason)
60
+ return;
61
+ this.reason = reason;
62
+ clearTimeout(this.timer);
63
+ // Arm before TERM so even a synchronous close clears the grace timer.
64
+ this.graceTimer = setTimeout(() => {
65
+ if (!this.settled)
66
+ this.child.kill("SIGKILL");
67
+ }, AGY_PROCESS_POLICY.terminateGraceMs);
68
+ this.child.kill("SIGTERM");
69
+ }
70
+ onError = (err) => {
71
+ if (this.settled || this.reason)
72
+ return;
73
+ const bin = resolveAgyBin();
74
+ const hint = err.code === "ENOENT"
75
+ ? `. "${bin}" was not found — install agy or set IMA2_AGY_BIN=/absolute/path/to/agy`
76
+ : "";
77
+ this.reason = agyError(`Agy process error: ${err.message}${hint}`, 502, "AGY_PROCESS_ERROR");
78
+ };
79
+ finish = (code) => {
80
+ if (this.settled)
81
+ return;
82
+ this.settled = true;
83
+ clearTimeout(this.timer);
84
+ clearTimeout(this.graceTimer);
85
+ this.signal?.removeEventListener("abort", this.onAbort);
86
+ this.child.stdout.removeListener("data", this.onStdout);
87
+ this.child.stderr.removeListener("data", this.onStderr);
88
+ this.child.stdin.removeListener("error", this.onStdinError);
89
+ this.child.removeListener("error", this.onError);
90
+ this.child.removeListener("close", this.finish);
91
+ if (this.reason) {
92
+ this.reject(this.reason);
93
+ return;
94
+ }
95
+ if (code !== 0 && !this.stdout.trim()) {
96
+ this.reject(agyError(`Agy exited with code ${code}: ${this.stderr.slice(0, 200)}`, 502, "AGY_PROCESS_ERROR"));
97
+ return;
98
+ }
99
+ this.resolve({ stdout: this.stdout, stderr: this.stderr });
100
+ };
101
+ }
102
+ export function spawnAgy(prompt, signal) {
103
+ return new Promise((resolve, reject) => {
104
+ if (signal?.aborted) {
105
+ reject(agyError("Generation canceled", 499, "GENERATION_CANCELED"));
106
+ return;
107
+ }
108
+ const child = spawn(resolveAgyBin(), ["-p", "-"], {
109
+ stdio: ["pipe", "pipe", "pipe"],
110
+ env: {
111
+ PATH: buildAgyPathEnv(),
112
+ HOME: process.env.HOME,
113
+ USERPROFILE: process.env.USERPROFILE,
114
+ TMPDIR: process.env.TMPDIR,
115
+ TEMP: process.env.TEMP,
116
+ LANG: process.env.LANG,
117
+ GEMINI_API_KEY: process.env.GEMINI_API_KEY,
118
+ },
119
+ });
120
+ new AgyProcessLifetime(child, signal, resolve, reject).start(prompt);
121
+ });
122
+ }
@@ -0,0 +1,48 @@
1
+ /** Express routes are case-insensitive by default; do not protect /apix as /api. */
2
+ export function isApiRequestPath(path) {
3
+ return /^\/api(?:\/|$)/i.test(path);
4
+ }
5
+ function rejectBudget(res, expires, now) {
6
+ res.setHeader("Retry-After", String(Math.max(1, Math.ceil((expires - now) / 1000))));
7
+ return res.status(429).json({
8
+ error: { code: "API_RATE_LIMITED", message: "Too many API requests; retry after the indicated delay" },
9
+ });
10
+ }
11
+ /** App-owned windows, bounded memory and socket identity (never proxy headers). */
12
+ export function createApiRequestBudget(policy, clock = Date.now) {
13
+ const peers = new Map();
14
+ function prune(now) {
15
+ let earliest = now + policy.windowMs;
16
+ for (const [peer, window] of peers) {
17
+ if (window.expires <= now)
18
+ peers.delete(peer);
19
+ else
20
+ earliest = Math.min(earliest, window.expires);
21
+ }
22
+ return earliest;
23
+ }
24
+ return function apiRequestBudget(req, res, next) {
25
+ if (!isApiRequestPath(req.path))
26
+ return next();
27
+ const now = clock();
28
+ const peer = req.socket.remoteAddress ?? "unknown-peer";
29
+ let window = peers.get(peer);
30
+ if (!window || window.expires <= now) {
31
+ if (!window && peers.size >= policy.maxPeers) {
32
+ const earliest = prune(now);
33
+ if (peers.size >= policy.maxPeers)
34
+ return rejectBudget(res, earliest, now);
35
+ }
36
+ window = { expires: now + policy.windowMs, requests: 0, mutations: 0 };
37
+ peers.set(peer, window);
38
+ }
39
+ const mutation = !["GET", "HEAD", "OPTIONS"].includes(req.method);
40
+ if (window.requests >= policy.requests || (mutation && window.mutations >= policy.mutations)) {
41
+ return rejectBudget(res, window.expires, now);
42
+ }
43
+ window.requests++;
44
+ if (mutation)
45
+ window.mutations++;
46
+ return next();
47
+ };
48
+ }
@@ -1,6 +1,6 @@
1
1
  import { getDb } from "./db.js";
2
2
  import { mkdir, rename, unlink, lstat, realpath } from "fs/promises";
3
- import { resolve, sep } from "path";
3
+ import { basename, dirname, join, resolve, sep } from "path";
4
4
  import { moveToSystemTrash } from "./systemTrash.js";
5
5
  import { config } from "../config.js";
6
6
  export function resolveInGenerated(rootDir, relPath) {
@@ -19,7 +19,8 @@ export function resolveInGenerated(rootDir, relPath) {
19
19
  }
20
20
  const baseDir = resolve(config.storage.generatedDir);
21
21
  const target = resolve(baseDir, relPath);
22
- if (target !== baseDir && !target.startsWith(baseDir + sep)) {
22
+ const prefix = baseDir.endsWith(sep) ? baseDir : baseDir + sep;
23
+ if (target === baseDir || !target.startsWith(prefix)) {
23
24
  const err = new Error("filename escapes generated/");
24
25
  err.status = 400;
25
26
  err.code = "INVALID_FILENAME";
@@ -28,7 +29,16 @@ export function resolveInGenerated(rootDir, relPath) {
28
29
  return target;
29
30
  }
30
31
  export async function assertRegularGeneratedPath(path) {
31
- const baseDir = await realpath(resolve(config.storage.generatedDir));
32
+ try {
33
+ await regularFileWithin(path, config.storage.generatedDir);
34
+ }
35
+ catch (error) {
36
+ throw error;
37
+ }
38
+ }
39
+ /** Existing assets only; output filenames use the separate lexical resolver. */
40
+ async function regularFileWithin(path, root) {
41
+ const baseDir = await realpath(resolve(root));
32
42
  const stat = await lstat(path);
33
43
  if (stat.isSymbolicLink()) {
34
44
  const err = new Error("symbolic links are not valid assets");
@@ -36,13 +46,18 @@ export async function assertRegularGeneratedPath(path) {
36
46
  err.code = "INVALID_FILENAME";
37
47
  throw err;
38
48
  }
49
+ if (!stat.isFile()) {
50
+ throw Object.assign(new Error("only regular files are valid assets"), { status: 400, code: "INVALID_FILENAME" });
51
+ }
39
52
  const canonical = await realpath(path);
40
- if (canonical !== baseDir && !canonical.startsWith(baseDir + sep)) {
53
+ const prefix = baseDir.endsWith(sep) ? baseDir : baseDir + sep;
54
+ if (canonical === baseDir || !canonical.startsWith(prefix)) {
41
55
  const err = new Error("filename escapes generated/");
42
56
  err.status = 400;
43
57
  err.code = "INVALID_FILENAME";
44
58
  throw err;
45
59
  }
60
+ return canonical;
46
61
  }
47
62
  function nodesReferencingFilename(filename) {
48
63
  // The client stores imageUrl as `/generated/<encoded filename>` in node data JSON.
@@ -117,7 +132,7 @@ export async function trashAsset(rootDir, filename) {
117
132
  trashMethod = "internal";
118
133
  const trashDir = resolve(config.storage.trashDir);
119
134
  await mkdir(trashDir, { recursive: true });
120
- const trashId = `${Date.now()}_${filename}`;
135
+ const trashId = `${Date.now()}_${basename(src)}`;
121
136
  for (const p of paths) {
122
137
  const dest = resolve(trashDir, p.endsWith(".json") ? `${trashId}.json` : trashId);
123
138
  await rename(p, dest);
@@ -165,16 +180,34 @@ export async function deleteAssetPermanent(rootDir, filename) {
165
180
  };
166
181
  }
167
182
  export async function restoreAsset(rootDir, trashId, originalFilename) {
168
- void rootDir;
169
183
  const trashDir = resolve(config.storage.trashDir);
170
184
  const src = resolve(trashDir, trashId);
171
- if (!src.startsWith(trashDir + sep) && src !== trashDir) {
185
+ const prefix = trashDir.endsWith(sep) ? trashDir : trashDir + sep;
186
+ if (src === trashDir || !src.startsWith(prefix)) {
172
187
  const err = new Error("invalid trashId");
173
188
  err.status = 400;
189
+ err.code = "INVALID_FILENAME";
174
190
  throw err;
175
191
  }
192
+ const canonicalSrc = await regularFileWithin(src, trashDir);
193
+ let canonicalSidecar;
194
+ try {
195
+ canonicalSidecar = await regularFileWithin(`${src}.json`, trashDir);
196
+ }
197
+ catch (error) {
198
+ if (error.code !== "ENOENT")
199
+ throw error;
200
+ }
176
201
  const dst = resolveInGenerated(rootDir, originalFilename);
177
- await rename(src, dst);
178
- await rename(src + ".json", dst + ".json").catch(() => { });
202
+ const generatedRoot = await realpath(config.storage.generatedDir);
203
+ const parent = await realpath(dirname(dst));
204
+ const generatedPrefix = generatedRoot.endsWith(sep) ? generatedRoot : generatedRoot + sep;
205
+ if (parent !== generatedRoot && !parent.startsWith(generatedPrefix)) {
206
+ throw Object.assign(new Error("restore destination escapes generated/"), { status: 400, code: "INVALID_FILENAME" });
207
+ }
208
+ const canonicalDst = join(parent, basename(dst));
209
+ await rename(canonicalSrc, canonicalDst);
210
+ if (canonicalSidecar)
211
+ await rename(canonicalSidecar, `${canonicalDst}.json`);
179
212
  return { ok: true };
180
213
  }
@@ -1,6 +1,5 @@
1
- import { mkdir, writeFile, access, readFile } from "fs/promises";
2
- import { constants } from "fs";
3
- import { basename, join, normalize, parse } from "path";
1
+ import { mkdir, writeFile, readFile, lstat, realpath } from "fs/promises";
2
+ import { basename, dirname, resolve, parse, sep } from "path";
4
3
  import { randomBytes } from "crypto";
5
4
  import sharp from "sharp";
6
5
  import { embedImageMetadataBestEffort } from "./imageMetadataStore.js";
@@ -34,12 +33,20 @@ function assertSafeFilename(filename) {
34
33
  }
35
34
  function safeSourceBase(sourceFilename) {
36
35
  const parsed = parse(basename(String(sourceFilename || "image")));
37
- return parsed.name.replace(/[^a-zA-Z0-9_-]+/g, "-").replace(/^-+|-+$/g, "").slice(0, 80) || "image";
36
+ const safe = parsed.name.replace(/[^a-zA-Z0-9_-]+/g, "-");
37
+ let start = 0;
38
+ let end = safe.length;
39
+ while (start < end && safe[start] === "-")
40
+ start++;
41
+ while (end > start && safe[end - 1] === "-")
42
+ end--;
43
+ return safe.slice(start, Math.min(end, start + 80)) || "image";
38
44
  }
39
45
  function ensureInsideGeneratedDir(generatedDir, filename) {
40
- const full = normalize(join(generatedDir, filename));
41
- const root = normalize(generatedDir);
42
- if (!full.startsWith(root)) {
46
+ const full = resolve(generatedDir, filename);
47
+ const root = resolve(generatedDir);
48
+ const prefix = root.endsWith(sep) ? root : root + sep;
49
+ if (full === root || !full.startsWith(prefix)) {
43
50
  const err = new Error("Canvas version path escapes generated directory");
44
51
  err.status = 400;
45
52
  err.code = "CANVAS_VERSION_PATH_ESCAPE";
@@ -47,6 +54,28 @@ function ensureInsideGeneratedDir(generatedDir, filename) {
47
54
  }
48
55
  return full;
49
56
  }
57
+ /** Existing leaves must be regular files; only a new output leaf may be absent. */
58
+ async function checkedCanvasPath(generatedDir, filename, allowMissing = false) {
59
+ const lexical = ensureInsideGeneratedDir(generatedDir, filename);
60
+ const root = await realpath(generatedDir);
61
+ const parent = await realpath(dirname(lexical));
62
+ const prefix = root.endsWith(sep) ? root : root + sep;
63
+ if (parent !== root && !parent.startsWith(prefix)) {
64
+ throw Object.assign(new Error("Canvas path escapes generated directory"), { status: 400, code: "CANVAS_VERSION_PATH_ESCAPE" });
65
+ }
66
+ const full = resolve(parent, basename(lexical));
67
+ try {
68
+ const stat = await lstat(full);
69
+ if (stat.isSymbolicLink() || !stat.isFile()) {
70
+ throw Object.assign(new Error("Canvas path must be a regular file"), { status: 400, code: "CANVAS_VERSION_PATH_ESCAPE" });
71
+ }
72
+ }
73
+ catch (error) {
74
+ if (!allowMissing || error.code !== "ENOENT")
75
+ throw error;
76
+ }
77
+ return full;
78
+ }
50
79
  function makeCanvasFilename(sourceFilename) {
51
80
  const stamp = new Date().toISOString().slice(0, 19).replace(/[-:T]/g, "");
52
81
  const rand = randomBytes(3).toString("hex");
@@ -54,12 +83,13 @@ function makeCanvasFilename(sourceFilename) {
54
83
  }
55
84
  async function writeCanvasPng(ctx, filename, buffer, meta) {
56
85
  await mkdir(ctx.config.storage.generatedDir, { recursive: true });
57
- const full = ensureInsideGeneratedDir(ctx.config.storage.generatedDir, filename);
86
+ const full = await checkedCanvasPath(ctx.config.storage.generatedDir, filename, true);
87
+ const sidecar = await checkedCanvasPath(ctx.config.storage.generatedDir, `${filename}.json`, true);
58
88
  const embedded = await embedImageMetadataBestEffort(buffer, "png", meta, {
59
89
  version: ctx.packageVersion,
60
90
  });
61
91
  await writeFile(full, embedded.buffer);
62
- await writeFile(`${full}.json`, JSON.stringify(meta)).catch(() => { });
92
+ await writeFile(sidecar, JSON.stringify(meta)).catch(() => { });
63
93
  invalidateHistoryIndex();
64
94
  }
65
95
  async function readGeneratedMetadata(ctx, filename) {
@@ -67,7 +97,8 @@ async function readGeneratedMetadata(ctx, filename) {
67
97
  return null;
68
98
  try {
69
99
  const full = ensureInsideGeneratedDir(ctx.config.storage.generatedDir, basename(filename));
70
- return JSON.parse(await readFile(`${full}.json`, "utf8"));
100
+ const sidecar = await checkedCanvasPath(ctx.config.storage.generatedDir, `${full}.json`);
101
+ return JSON.parse(await readFile(sidecar, "utf8"));
71
102
  }
72
103
  catch {
73
104
  return null;
@@ -149,8 +180,9 @@ export async function createCanvasVersion(ctx, input) {
149
180
  export async function updateCanvasVersion(ctx, filename, input) {
150
181
  assertSafeFilename(filename);
151
182
  assertPngBuffer(input.buffer);
152
- const full = ensureInsideGeneratedDir(ctx.config.storage.generatedDir, filename);
153
- await access(full, constants.F_OK).catch(() => {
183
+ await checkedCanvasPath(ctx.config.storage.generatedDir, filename).catch((cause) => {
184
+ if (cause.code !== "ENOENT")
185
+ throw cause;
154
186
  const err = new Error("Canvas version not found");
155
187
  err.status = 404;
156
188
  err.code = "CANVAS_VERSION_NOT_FOUND";
@@ -185,7 +217,6 @@ export async function updateCanvasVersion(ctx, filename, input) {
185
217
  export async function recordCanvasAnnotationBake(ctx, filename, snapshot, annotationOnly) {
186
218
  assertSafeFilename(filename);
187
219
  assertAnnotationSnapshot(snapshot);
188
- const full = ensureInsideGeneratedDir(ctx.config.storage.generatedDir, filename);
189
220
  const previousMeta = await readGeneratedMetadata(ctx, filename);
190
221
  if (!previousMeta) {
191
222
  const err = new Error("Canvas version not found");
@@ -193,6 +224,7 @@ export async function recordCanvasAnnotationBake(ctx, filename, snapshot, annota
193
224
  err.code = "CANVAS_VERSION_NOT_FOUND";
194
225
  throw err;
195
226
  }
227
+ const full = await checkedCanvasPath(ctx.config.storage.generatedDir, filename);
196
228
  const buffer = await readFile(full);
197
229
  const meta = {
198
230
  ...previousMeta,
@@ -224,7 +256,7 @@ export async function revertCanvasAnnotations(ctx, filename) {
224
256
  err.code = "CANVAS_ANNOTATIONS_NOT_BAKED";
225
257
  throw err;
226
258
  }
227
- const sourceFull = ensureInsideGeneratedDir(ctx.config.storage.generatedDir, basename(sourceFilename));
259
+ const sourceFull = await checkedCanvasPath(ctx.config.storage.generatedDir, basename(sourceFilename));
228
260
  const cleanPng = await sharp(await readFile(sourceFull)).png().toBuffer();
229
261
  const snapshot = previousMeta.annotationSnapshot ?? null;
230
262
  const annotationOnly = Boolean(previousMeta.annotationOnly);
@@ -6,7 +6,8 @@ import { loadAllBundledSnapshots } from "./mcp/snapshotStore.js";
6
6
  import { KEY_TO_ENV, WRITABLE_CONFIG_KEYS } from "./configKeys.js";
7
7
  import { DEFAULT_IMAGE_QUALITY, VALID_IMAGE_QUALITIES } from "./oauthNormalize.js";
8
8
  import { MAX_REF2V_REFERENCES, MAX_REFERENCE_AUDIOS, MAX_VIDEO_DURATION, MIN_VIDEO_DURATION } from "./imageModels.js";
9
- import { deriveProviderIds } from "./providers/derive.js";
9
+ import { deriveProviderIds, getProviderSurfaceSupport } from "./providers/derive.js";
10
+ import { PROVIDER_SURFACES } from "./providers/surfaceSupport.js";
10
11
  const VALID_MODES = ["auto", "direct"];
11
12
  const VALID_PROVIDERS = ["auto", ...deriveProviderIds()];
12
13
  const AGENT_COMMANDS = [
@@ -39,6 +40,11 @@ export function buildIma2Capabilities({ appConfig = runtimeConfigDefault, packag
39
40
  server,
40
41
  version: packageVersion,
41
42
  ...(lanes ? { lanes } : {}),
43
+ providerSurfaces: Object.fromEntries(deriveProviderIds().map((id) => [id,
44
+ Object.fromEntries(PROVIDER_SURFACES.map((surface) => [surface,
45
+ getProviderSurfaceSupport(id, surface),
46
+ ])),
47
+ ])),
42
48
  commands: AGENT_COMMANDS,
43
49
  defaults: {
44
50
  oauth: {
package/lib/configKeys.js CHANGED
@@ -28,6 +28,7 @@ export const WRITABLE_CONFIG_KEYS = new Set([
28
28
  "storage.generatedDirName",
29
29
  "server.port",
30
30
  "server.host",
31
+ "server.publicOrigins",
31
32
  "server.bodyLimit",
32
33
  "oauth.proxyPort",
33
34
  "oauth.statusTimeoutMs",
@@ -49,6 +50,7 @@ export const KEY_TO_ENV = {
49
50
  "features.cardNews": "IMA2_CARD_NEWS",
50
51
  "server.port": "IMA2_PORT",
51
52
  "server.host": "IMA2_HOST",
53
+ "server.publicOrigins": "IMA2_PUBLIC_ORIGINS",
52
54
  "server.bodyLimit": "IMA2_BODY_LIMIT",
53
55
  "oauth.proxyPort": "IMA2_OAUTH_PROXY_PORT",
54
56
  "storage.generatedDir": "IMA2_GENERATED_DIR",
@@ -1,3 +1,27 @@
1
+ import { RESPONSE_DIAGNOSTIC_CODES } from "../responsesErrors.js";
2
+ export function statusForErrorCode(code, fallback = 500) {
3
+ if (code === "GROK_API_KEY_MISSING")
4
+ return 401;
5
+ if (code === "OAUTH_UNAVAILABLE" || code === "NETWORK_FAILED")
6
+ return 503;
7
+ if (code === "AUTH_CHATGPT_EXPIRED" || code === "AUTH_API_KEY_INVALID")
8
+ return 401;
9
+ if (code === "API_KEY_REQUIRED")
10
+ return 401;
11
+ if (code === "UPSTREAM_5XX")
12
+ return 502;
13
+ if (code === "RESPONSES_STREAM_ERROR")
14
+ return 502;
15
+ if (code === "OAUTH_IMAGE_TIMEOUT")
16
+ return 504;
17
+ if (code === "INVALID_REQUEST")
18
+ return 400;
19
+ if (RESPONSE_DIAGNOSTIC_CODES.has(code))
20
+ return 422;
21
+ if (code === "SAFETY_REFUSAL" || code === "MODERATION_REFUSED" || code === "moderation_blocked")
22
+ return 422;
23
+ return fallback;
24
+ }
1
25
  export const PROVIDER_ERROR_MAP = {
2
26
  MINIMAX_API_KEY_MISSING: "AUTH_INVALID",
3
27
  MINIMAX_AUTH_FAILED: "AUTH_INVALID",
@@ -43,6 +67,7 @@ export const PROVIDER_ERROR_MAP = {
43
67
  GEMINI_API_SAFETY_BLOCKED: "CONTENT_REJECTED",
44
68
  GEMINI_API_UPSTREAM_ERROR: "NETWORK_FAILURE",
45
69
  GEMINI_API_MASK_UNSUPPORTED: "CAPABILITY_UNSUPPORTED",
70
+ GROK_API_KEY_MISSING: "AUTH_INVALID",
46
71
  GROK_AUTH_FAILED: "AUTH_INVALID",
47
72
  GROK_BAD_REQUEST: "CAPABILITY_UNSUPPORTED",
48
73
  GROK_EMPTY_RESPONSE: "INTERNAL_STATE_ERROR",
@@ -78,6 +103,7 @@ export const PROVIDER_ERROR_MAP = {
78
103
  GROK_MASK_UNSUPPORTED: "CAPABILITY_UNSUPPORTED",
79
104
  GROK_REF_TOO_MANY: "CAPABILITY_UNSUPPORTED",
80
105
  AGY_ARTIFACT_NOT_FOUND: "INTERNAL_STATE_ERROR",
106
+ AGY_ARTIFACT_TOO_LARGE: "INTERNAL_STATE_ERROR",
81
107
  AGY_GENERATION_FAILED: "INTERNAL_STATE_ERROR",
82
108
  AGY_MALFORMED_RESULT: "INTERNAL_STATE_ERROR",
83
109
  AGY_PARSE_FAILED: "INTERNAL_STATE_ERROR",
package/lib/eventBus.js CHANGED
@@ -102,14 +102,18 @@ export function subscribe(listener) {
102
102
  export function replayOldestId() {
103
103
  return ring.length > 0 ? ring[0]?.id ?? null : null;
104
104
  }
105
- /** True when the ring has evicted events the client still expects from Last-Event-ID. */
105
+ /** Reads the process cursor without allocating an event. */
106
+ export function latestEventId() {
107
+ return seq;
108
+ }
109
+ /** True for an evicted cursor or a cursor ahead of this process. */
106
110
  export function hasReplayGap(lastEventId) {
107
- if (lastEventId <= 0 || ring.length === 0)
108
- return false;
109
- const oldest = ring[0]?.id;
110
- if (oldest === undefined)
111
+ if (!Number.isSafeInteger(lastEventId) || lastEventId < 0)
111
112
  return false;
112
- return lastEventId < oldest - 1;
113
+ if (lastEventId > seq)
114
+ return true;
115
+ const oldest = replayOldestId();
116
+ return oldest !== null && lastEventId < oldest - 1;
113
117
  }
114
118
  export function replaySince(lastEventId) {
115
119
  const idx = ring.findIndex(e => e.id > lastEventId);
@@ -0,0 +1,9 @@
1
+ /** Maximum time one accepted SSE write may wait for drain. */
2
+ export const SSE_STREAM_POLICY = Object.freeze({ drainTimeoutMs: 15_000 });
3
+ /** Parse the replay cursor header without accepting numeric coercions or overflow. */
4
+ export function parseEventCursor(value) {
5
+ if (value === null || !/^\d+$/.test(value))
6
+ return null;
7
+ const cursor = Number(value);
8
+ return Number.isSafeInteger(cursor) ? cursor : null;
9
+ }