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
package/lib/nodeStore.js CHANGED
@@ -1,15 +1,19 @@
1
- import { writeFile, readFile, access, mkdir } from "fs/promises";
2
- import { join, resolve, sep } from "path";
1
+ import { writeFile, readFile, access, mkdir, realpath } from "fs/promises";
2
+ import { resolve, sep } from "path";
3
3
  import { randomBytes } from "crypto";
4
4
  import { config } from "../config.js";
5
- import { embedImageMetadataBestEffort } from "./imageMetadataStore.js";
5
+ import { embedImageMetadataBestEffort, isSupportedMetadataFormat } from "./imageMetadataStore.js";
6
6
  import { invalidateHistoryIndex } from "./historyIndex.js";
7
7
  export function newNodeId() {
8
8
  return "n_" + randomBytes(config.ids.nodeHexBytes).toString("hex");
9
9
  }
10
10
  export async function saveNode(rootDir, { nodeId, b64, meta, ext = "png", generatedDir = config.storage.generatedDir }) {
11
- void rootDir;
11
+ if (typeof ext !== "string" || !isSupportedMetadataFormat(ext)) {
12
+ throw Object.assign(new Error("Format must be png, jpeg, jpg, or webp."), { code: "INVALID_FORMAT", status: 400 });
13
+ }
12
14
  const filename = `${nodeId}.${ext}`;
15
+ const imagePath = resolveGeneratedPath(rootDir, filename, generatedDir);
16
+ const metadataPath = resolveGeneratedPath(rootDir, filename + ".json", generatedDir);
13
17
  await mkdir(generatedDir, { recursive: true });
14
18
  const imageMeta = {
15
19
  ...meta,
@@ -22,8 +26,8 @@ export async function saveNode(rootDir, { nodeId, b64, meta, ext = "png", genera
22
26
  if (!embedded.embedded) {
23
27
  console.warn("[nodeStore] metadata embed skipped:", embedded.warning);
24
28
  }
25
- await writeFile(join(generatedDir, filename), embedded.buffer);
26
- await writeFile(join(generatedDir, filename + ".json"), JSON.stringify(meta, null, 2));
29
+ await writeFile(imagePath, embedded.buffer);
30
+ await writeFile(metadataPath, JSON.stringify(meta, null, 2));
27
31
  invalidateHistoryIndex();
28
32
  return { filename };
29
33
  }
@@ -38,13 +42,15 @@ export async function loadNodeB64(rootDir, filename, generatedDir = config.stora
38
42
  err.status = 404;
39
43
  throw err;
40
44
  }
41
- const buf = await readFile(p);
45
+ const containedPath = await resolveCanonicalGeneratedPath(rootDir, p, generatedDir);
46
+ const buf = await readFile(containedPath);
42
47
  return buf.toString("base64");
43
48
  }
44
49
  export async function loadNodeMeta(rootDir, nodeId, ext = "png", generatedDir = config.storage.generatedDir) {
45
- void rootDir;
46
50
  try {
47
- return JSON.parse(await readFile(join(generatedDir, `${nodeId}.${ext}.json`), "utf-8"));
51
+ const metadataPath = resolveGeneratedPath(rootDir, `${nodeId}.${ext}.json`, generatedDir);
52
+ const containedPath = await resolveCanonicalGeneratedPath(rootDir, metadataPath, generatedDir);
53
+ return JSON.parse(await readFile(containedPath, "utf-8"));
48
54
  }
49
55
  catch {
50
56
  return null;
@@ -61,9 +67,16 @@ export async function loadAssetB64(rootDir, externalSrc, generatedDir = config.s
61
67
  err.status = 404;
62
68
  throw err;
63
69
  }
64
- const buf = await readFile(p);
70
+ const containedPath = await resolveCanonicalGeneratedPath(rootDir, p, generatedDir);
71
+ const buf = await readFile(containedPath);
65
72
  return buf.toString("base64");
66
73
  }
74
+ async function resolveCanonicalGeneratedPath(rootDir, path, generatedDir) {
75
+ const canonicalRoot = await realpath(generatedDir);
76
+ const canonicalPath = await realpath(path);
77
+ // Reuse the explicit-dir boundary after resolving directory and leaf symlinks.
78
+ return resolveGeneratedPath(rootDir, canonicalPath, canonicalRoot);
79
+ }
67
80
  function resolveGeneratedPath(rootDir, relPath, generatedDir = config.storage.generatedDir) {
68
81
  void rootDir;
69
82
  if (typeof relPath !== "string" || relPath.length === 0) {
@@ -74,7 +87,8 @@ function resolveGeneratedPath(rootDir, relPath, generatedDir = config.storage.ge
74
87
  }
75
88
  const baseDir = resolve(generatedDir);
76
89
  const target = resolve(baseDir, relPath);
77
- if (target !== baseDir && !target.startsWith(baseDir + sep)) {
90
+ const prefix = baseDir.endsWith(sep) ? baseDir : baseDir + sep;
91
+ if (target !== baseDir && !target.startsWith(prefix)) {
78
92
  const err = new Error(`Asset path escapes generated/: ${relPath}`);
79
93
  err.code = "NODE_SOURCE_INVALID";
80
94
  err.status = 400;
@@ -1,10 +1,14 @@
1
1
  import { validateAndNormalizeRefs } from "./refs.js";
2
2
  import { validateModeration } from "./routeHelpers.js";
3
3
  import { validateGenerationPrompt } from "./generationInputValidation.js";
4
- export function validateNodeInputs(ctx, prompt, references, moderation) {
4
+ import { isSupportedMetadataFormat } from "./imageMetadataStore.js";
5
+ export function validateNodeInputs(ctx, prompt, references, moderation, format = "png") {
5
6
  const promptError = validateGenerationPrompt(prompt);
6
7
  if (promptError)
7
8
  return promptError;
9
+ if (typeof format !== "string" || !isSupportedMetadataFormat(format)) {
10
+ return { error: { code: "INVALID_FORMAT", message: "Format must be png, jpeg, jpg, or webp." } };
11
+ }
8
12
  const refCheckResult = validateAndNormalizeRefs(references);
9
13
  if (refCheckResult.error) {
10
14
  return {
@@ -0,0 +1,174 @@
1
+ import { request as httpRequest } from "node:http";
2
+ import { request as httpsRequest } from "node:https";
3
+ import { isIP } from "node:net";
4
+ import { resolvePublicDownloadTarget } from "./grokImageDownloadPolicy.js";
5
+ export class PinnedGetFailure extends Error {
6
+ }
7
+ function pinnedLookup(target) {
8
+ const hostname = target.url.hostname.replace(/^\[|\]$/g, "");
9
+ return (host, options, callback) => {
10
+ const family = options.family === "IPv4" ? 4 : options.family === "IPv6" ? 6 : options.family || 0;
11
+ const addresses = target.addresses.filter((address) => !family || address.family === family);
12
+ const first = addresses[0];
13
+ if (host !== hostname || !first || (family !== 0 && family !== 4 && family !== 6)) {
14
+ callback(new PinnedGetFailure("Image download address selection failed"), []);
15
+ return;
16
+ }
17
+ if (options.all)
18
+ callback(null, addresses.map((address) => ({ ...address })));
19
+ else
20
+ callback(null, first.address, first.family);
21
+ };
22
+ }
23
+ /** Own error-event handling independently of the already-settled header promise. */
24
+ class PinnedGetLifecycle {
25
+ signal;
26
+ requestUrl;
27
+ resolve;
28
+ reject;
29
+ request;
30
+ response;
31
+ stopped = false;
32
+ headersReceived = false;
33
+ onAbort;
34
+ constructor(signal, requestUrl, resolve, reject) {
35
+ this.signal = signal;
36
+ this.requestUrl = requestUrl;
37
+ this.resolve = resolve;
38
+ this.reject = reject;
39
+ this.onAbort = () => {
40
+ this.reject(signal.reason);
41
+ this.cancel(signal.reason instanceof Error ? signal.reason : undefined);
42
+ };
43
+ signal.addEventListener("abort", this.onAbort, { once: true });
44
+ if (signal.aborted)
45
+ this.onAbort();
46
+ }
47
+ cancel = (reason) => {
48
+ if (this.stopped)
49
+ return;
50
+ this.stopped = true;
51
+ this.signal.removeEventListener("abort", this.onAbort);
52
+ this.response?.destroy(reason);
53
+ this.request?.destroy(reason);
54
+ };
55
+ fail = (error) => {
56
+ this.reject(error); // Raw pre-header reset/EPIPE must reach the real retry classifier.
57
+ this.cancel(error instanceof Error ? error : undefined);
58
+ };
59
+ attach(request) {
60
+ this.request = request;
61
+ request.on("error", this.fail);
62
+ // Keep the response handler available for a late response even after abort.
63
+ request.on("response", this.accept);
64
+ request.once("close", () => {
65
+ request.removeListener("error", this.fail);
66
+ if (!this.headersReceived && !this.stopped) {
67
+ this.fail(new PinnedGetFailure("Image download closed before response"));
68
+ }
69
+ });
70
+ if (this.stopped)
71
+ request.destroy();
72
+ }
73
+ accept = (response) => {
74
+ const onError = (error) => this.fail(error);
75
+ response.on("error", onError);
76
+ response.once("close", () => response.removeListener("error", onError));
77
+ if (this.stopped || this.signal.aborted || this.headersReceived) {
78
+ response.destroy();
79
+ return;
80
+ }
81
+ this.response = response;
82
+ this.headersReceived = true;
83
+ this.resolve({
84
+ url: this.requestUrl,
85
+ status: response.statusCode ?? 0,
86
+ headers: { get: (name) => {
87
+ const value = response.headers[name.toLowerCase()];
88
+ return Array.isArray(value) ? value.join(", ") : value ?? null;
89
+ } },
90
+ body: response,
91
+ cancel: this.cancel,
92
+ });
93
+ };
94
+ }
95
+ export async function pinnedHttpGet(target, signal, options = {}) {
96
+ signal.throwIfAborted();
97
+ return new Promise((resolve, reject) => {
98
+ const lifecycle = new PinnedGetLifecycle(signal, target.url.href, resolve, reject);
99
+ try {
100
+ if (lifecycle.stopped)
101
+ return;
102
+ const hostname = target.url.hostname.replace(/^\[|\]$/g, "");
103
+ const request = target.url.protocol === "https:" ? httpsRequest : httpRequest;
104
+ lifecycle.attach(request(target.url, {
105
+ method: "GET", agent: false, signal,
106
+ headers: { ...options.headers, Host: target.url.host },
107
+ ...(options.family ? { family: options.family } : {}),
108
+ lookup: pinnedLookup(target),
109
+ ...(isIP(hostname) ? {} : { servername: hostname }),
110
+ }));
111
+ if (!lifecycle.stopped)
112
+ lifecycle.request?.end();
113
+ }
114
+ catch (error) {
115
+ lifecycle.fail(error);
116
+ }
117
+ });
118
+ }
119
+ const MAX_PUBLIC_REDIRECTS = 5;
120
+ const REDIRECT_STATUSES = new Set([301, 302, 303, 307, 308]);
121
+ /** Public consumers cannot opt into Grok's configured proxy exception. */
122
+ export async function publicPinnedHttpGet(rawUrl, signal, validateUrl, headers) {
123
+ let url = new URL(rawUrl);
124
+ try {
125
+ for (let hop = 0;; hop++) {
126
+ signal.throwIfAborted();
127
+ validateUrl(url.href); // Includes scheme/allowhost, before DNS and every socket.
128
+ const target = await resolvePublicDownloadTarget(url, signal);
129
+ const response = await pinnedHttpGet(target, signal, headers ? { headers } : {});
130
+ if (!REDIRECT_STATUSES.has(response.status))
131
+ return response;
132
+ try {
133
+ const location = response.headers.get("location");
134
+ if (!location?.trim() || hop >= MAX_PUBLIC_REDIRECTS)
135
+ throw new Error("PINNED_GET_REDIRECT_REFUSED");
136
+ url = new URL(location, url);
137
+ }
138
+ finally {
139
+ response.cancel();
140
+ }
141
+ }
142
+ }
143
+ catch (error) {
144
+ throw error;
145
+ }
146
+ }
147
+ export class PinnedBodyTooLarge extends Error {
148
+ constructor() { super("Remote file is too large"); }
149
+ }
150
+ /** Bound bytes while streaming, including absent or dishonest Content-Length. */
151
+ export async function readPinnedBody(response, maxBytes) {
152
+ try {
153
+ if (!Number.isSafeInteger(maxBytes) || maxBytes < 0)
154
+ throw new PinnedBodyTooLarge();
155
+ if (Number(response.headers.get("content-length")) > maxBytes)
156
+ throw new PinnedBodyTooLarge();
157
+ const chunks = [];
158
+ let total = 0;
159
+ if (response.body)
160
+ for await (const chunk of response.body) {
161
+ total += chunk.byteLength;
162
+ if (total > maxBytes)
163
+ throw new PinnedBodyTooLarge();
164
+ chunks.push(Buffer.from(chunk));
165
+ }
166
+ return Buffer.concat(chunks, total); // Empty text remains a valid import input.
167
+ }
168
+ catch (error) {
169
+ throw error;
170
+ }
171
+ finally {
172
+ response.cancel();
173
+ }
174
+ }
@@ -1,6 +1,7 @@
1
1
  import { createHash } from "node:crypto";
2
2
  import { promptImportError } from "./errors.js";
3
3
  import { errInfo } from "../errInfo.js";
4
+ import { publicPinnedHttpGet, readPinnedBody, PinnedBodyTooLarge } from "../pinnedHttpGet.js";
4
5
  const GITHUB_HOST = "github.com";
5
6
  const GITHUB_API_HOST = "api.github.com";
6
7
  const RAW_HOST = "raw.githubusercontent.com";
@@ -27,7 +28,13 @@ function safePath(path) {
27
28
  if (decoded.includes("\\") || decoded.split("/").includes("..")) {
28
29
  throw promptImportError("INVALID_GITHUB_SOURCE", "GitHub folder traversal is not allowed");
29
30
  }
30
- return decoded.replace(/^\/+|\/+$/g, "");
31
+ let start = 0;
32
+ let end = decoded.length;
33
+ while (start < end && decoded[start] === "/")
34
+ start++;
35
+ while (end > start && decoded[end - 1] === "/")
36
+ end--;
37
+ return decoded.slice(start, end);
31
38
  }
32
39
  function assertOwnerRepo(owner, repo) {
33
40
  if (!OWNER_REPO_RE.test(owner || "") || !OWNER_REPO_RE.test(repo || "")) {
@@ -118,7 +125,7 @@ function assertGithubApiUrl(rawUrl) {
118
125
  catch {
119
126
  throw promptImportError("INVALID_GITHUB_SOURCE", "GitHub folder fetch returned an invalid URL");
120
127
  }
121
- if (!["http:", "https:"].includes(url.protocol) || url.hostname !== GITHUB_API_HOST) {
128
+ if (url.protocol !== "https:" || url.hostname !== GITHUB_API_HOST) {
122
129
  throw promptImportError("INVALID_GITHUB_SOURCE", "GitHub folder fetch used an unsupported host");
123
130
  }
124
131
  }
@@ -130,7 +137,7 @@ function assertRawDownloadUrl(rawUrl) {
130
137
  catch {
131
138
  throw promptImportError("INVALID_GITHUB_SOURCE", "GitHub folder file has an invalid download URL");
132
139
  }
133
- if (!["http:", "https:"].includes(url.protocol) || url.hostname !== RAW_HOST) {
140
+ if (url.protocol !== "https:" || url.hostname !== RAW_HOST) {
134
141
  throw promptImportError("INVALID_GITHUB_SOURCE", "GitHub folder file download host is unsupported");
135
142
  }
136
143
  const path = safePath(url.pathname.split("/").filter(Boolean).slice(3).join("/"));
@@ -177,20 +184,24 @@ async function fetchJson(url, limits) {
177
184
  assertGithubApiUrl(url);
178
185
  const controller = new AbortController();
179
186
  const timer = setTimeout(() => controller.abort(), limits.fetchTimeoutMs);
187
+ let response;
180
188
  try {
181
- const response = await fetch(url, {
182
- signal: controller.signal,
183
- headers: { Accept: "application/vnd.github+json" },
184
- });
185
- assertGithubApiUrl(response.url || url);
186
- if (response.status === 404)
189
+ response = await publicPinnedHttpGet(url, controller.signal, assertGithubApiUrl, { Accept: "application/vnd.github+json", "User-Agent": "ima2-gen" });
190
+ if (response.status === 404) {
191
+ await response.cancel();
187
192
  return { notFound: true };
188
- if (!response.ok) {
193
+ }
194
+ if (response.status < 200 || response.status >= 300) {
195
+ await response.cancel();
189
196
  throw promptImportError("GITHUB_FOLDER_NOT_FOUND", `GitHub folder fetch failed with ${response.status}`, 422);
190
197
  }
191
- return { json: await response.json() };
198
+ const body = await readPinnedBody(response, limits.maxFileBytesForPreview);
199
+ return { json: JSON.parse(new TextDecoder().decode(body)) };
192
200
  }
193
201
  catch (error) {
202
+ if (error instanceof PinnedBodyTooLarge) {
203
+ throw promptImportError("REMOTE_FILE_TOO_LARGE", "GitHub folder index is too large", 413);
204
+ }
194
205
  const err = errInfo(error);
195
206
  if (err.name === "AbortError") {
196
207
  throw promptImportError("REMOTE_FETCH_TIMEOUT", "GitHub folder fetch timed out", 504);
@@ -198,6 +209,7 @@ async function fetchJson(url, limits) {
198
209
  throw error;
199
210
  }
200
211
  finally {
212
+ response?.cancel();
201
213
  clearTimeout(timer);
202
214
  }
203
215
  }
@@ -283,20 +295,18 @@ async function fetchRawFile(rawUrl, limits) {
283
295
  assertRawDownloadUrl(rawUrl);
284
296
  const controller = new AbortController();
285
297
  const timer = setTimeout(() => controller.abort(), limits.fetchTimeoutMs);
298
+ let response;
286
299
  try {
287
- const response = await fetch(rawUrl, { signal: controller.signal });
288
- assertRawDownloadUrl(response.url || rawUrl);
289
- if (!response.ok) {
300
+ response = await publicPinnedHttpGet(rawUrl, controller.signal, assertRawDownloadUrl);
301
+ if (response.status < 200 || response.status >= 300) {
302
+ await response.cancel();
290
303
  throw promptImportError("INVALID_GITHUB_SOURCE", `GitHub folder file fetch failed with ${response.status}`, 422);
291
304
  }
292
305
  const contentLength = Number(response.headers.get("content-length") || 0);
293
306
  if (contentLength > limits.maxFileBytesForPreview) {
294
307
  throw promptImportError("GITHUB_FOLDER_FILE_TOO_LARGE", "Folder file is too large", 413);
295
308
  }
296
- const buffer = await response.arrayBuffer();
297
- if (buffer.byteLength > limits.maxFileBytesForPreview) {
298
- throw promptImportError("GITHUB_FOLDER_FILE_TOO_LARGE", "Folder file is too large", 413);
299
- }
309
+ const buffer = await readPinnedBody(response, limits.maxFileBytesForPreview);
300
310
  const text = new TextDecoder("utf-8", { fatal: false }).decode(buffer);
301
311
  return {
302
312
  text,
@@ -304,6 +314,9 @@ async function fetchRawFile(rawUrl, limits) {
304
314
  };
305
315
  }
306
316
  catch (error) {
317
+ if (error instanceof PinnedBodyTooLarge) {
318
+ throw promptImportError("GITHUB_FOLDER_FILE_TOO_LARGE", "Folder file is too large", 413);
319
+ }
307
320
  const err = errInfo(error);
308
321
  if (err.name === "AbortError") {
309
322
  throw promptImportError("REMOTE_FETCH_TIMEOUT", "GitHub folder file fetch timed out", 504);
@@ -311,6 +324,7 @@ async function fetchRawFile(rawUrl, limits) {
311
324
  throw error;
312
325
  }
313
326
  finally {
327
+ response?.cancel();
314
328
  clearTimeout(timer);
315
329
  }
316
330
  }
@@ -1,6 +1,7 @@
1
1
  import { createHash } from "node:crypto";
2
2
  import { promptImportError } from "./errors.js";
3
3
  import { errInfo } from "../errInfo.js";
4
+ import { publicPinnedHttpGet, readPinnedBody, PinnedBodyTooLarge } from "../pinnedHttpGet.js";
4
5
  const ALLOWED_HOSTS = new Set(["github.com", "raw.githubusercontent.com"]);
5
6
  const SUPPORTED_EXTENSIONS = new Set(["md", "markdown", "txt"]);
6
7
  const OWNER_REPO_RE = /^[A-Za-z0-9_.-]+$/;
@@ -53,8 +54,8 @@ function normalizeUrlInput(input) {
53
54
  catch {
54
55
  return null;
55
56
  }
56
- if (!["http:", "https:"].includes(url.protocol)) {
57
- throw promptImportError("INVALID_GITHUB_SOURCE", "Only http(s) GitHub URLs are supported");
57
+ if (url.protocol !== "https:") {
58
+ throw promptImportError("INVALID_GITHUB_SOURCE", "Only HTTPS GitHub URLs are supported");
58
59
  }
59
60
  if (!ALLOWED_HOSTS.has(url.hostname)) {
60
61
  throw promptImportError("INVALID_GITHUB_SOURCE", "Only GitHub file URLs are supported");
@@ -157,7 +158,7 @@ function validateFinalFetchUrl(rawUrl) {
157
158
  catch {
158
159
  throw promptImportError("INVALID_GITHUB_SOURCE", "GitHub fetch returned an invalid final URL");
159
160
  }
160
- if (!["http:", "https:"].includes(url.protocol)) {
161
+ if (url.protocol !== "https:") {
161
162
  throw promptImportError("INVALID_GITHUB_SOURCE", "GitHub fetch returned an unsupported protocol");
162
163
  }
163
164
  if (!ALLOWED_HOSTS.has(url.hostname)) {
@@ -189,20 +190,19 @@ export function normalizeGitHubSource(input) {
189
190
  export async function fetchGitHubSource(source, limits) {
190
191
  const controller = new AbortController();
191
192
  const timer = setTimeout(() => controller.abort(), limits.fetchTimeoutMs);
193
+ let response;
192
194
  try {
193
- const response = await fetch(source.rawUrl, { signal: controller.signal });
194
- validateFinalFetchUrl(response.url);
195
- if (!response.ok) {
195
+ response = await publicPinnedHttpGet(source.rawUrl, controller.signal, validateFinalFetchUrl);
196
+ if (response.status < 200 || response.status >= 300) {
197
+ await response.cancel();
196
198
  throw promptImportError("INVALID_GITHUB_SOURCE", `GitHub file fetch failed with ${response.status}`, 422);
197
199
  }
198
200
  const contentLength = Number(response.headers.get("content-length") || 0);
199
201
  if (contentLength > limits.maxFileBytesForPreview) {
202
+ await response.cancel();
200
203
  throw promptImportError("REMOTE_FILE_TOO_LARGE", "Remote file is too large", 413);
201
204
  }
202
- const buffer = await response.arrayBuffer();
203
- if (buffer.byteLength > limits.maxFileBytesForPreview) {
204
- throw promptImportError("REMOTE_FILE_TOO_LARGE", "Remote file is too large", 413);
205
- }
205
+ const buffer = await readPinnedBody(response, limits.maxFileBytesForPreview);
206
206
  const text = new TextDecoder("utf-8", { fatal: false }).decode(buffer);
207
207
  return {
208
208
  text,
@@ -213,6 +213,9 @@ export async function fetchGitHubSource(source, limits) {
213
213
  };
214
214
  }
215
215
  catch (error) {
216
+ if (error instanceof PinnedBodyTooLarge) {
217
+ throw promptImportError("REMOTE_FILE_TOO_LARGE", "Remote file is too large", 413);
218
+ }
216
219
  const err = errInfo(error);
217
220
  if (err.name === "AbortError") {
218
221
  throw promptImportError("REMOTE_FETCH_TIMEOUT", "GitHub fetch timed out", 504);
@@ -220,6 +223,7 @@ export async function fetchGitHubSource(source, limits) {
220
223
  throw error;
221
224
  }
222
225
  finally {
226
+ response?.cancel();
223
227
  clearTimeout(timer);
224
228
  }
225
229
  }
@@ -1,9 +1,39 @@
1
1
  import { createHash } from "crypto";
2
2
  function normalizeWhitespace(text) {
3
- return text.replace(/\r\n/g, "\n").replace(/[ \t]+\n/g, "\n").trim();
3
+ return text.replace(/\r\n/g, "\n").split("\n").map((line, index, lines) => {
4
+ if (index === lines.length - 1)
5
+ return line;
6
+ let end = line.length;
7
+ while (end > 0 && (line[end - 1] === " " || line[end - 1] === "\t"))
8
+ end--;
9
+ return line.slice(0, end);
10
+ }).join("\n").trim();
4
11
  }
5
12
  function stripFrontmatter(text) {
6
- return text.replace(/^---\s*\n[\s\S]*?\n---\s*\n/, "");
13
+ if (!text.startsWith("---"))
14
+ return text;
15
+ const openingEnd = text.indexOf("\n");
16
+ if (openingEnd < 0 || text.slice(3, openingEnd).trim() !== "")
17
+ return text;
18
+ let searchFrom = openingEnd + 1;
19
+ while (searchFrom < text.length) {
20
+ const closingStart = text.indexOf("\n---", searchFrom);
21
+ if (closingStart < 0)
22
+ break;
23
+ let end = closingStart + 4;
24
+ let lastNewline = -1;
25
+ // A complete delimiter requires whitespace through a newline. Consume the
26
+ // same trailing whitespace as the old regex without retrying suffixes.
27
+ while (end < text.length && /\s/.test(text[end])) {
28
+ if (text[end] === "\n")
29
+ lastNewline = end;
30
+ end++;
31
+ }
32
+ if (lastNewline >= 0)
33
+ return text.slice(lastNewline + 1);
34
+ searchFrom = end;
35
+ }
36
+ return text;
7
37
  }
8
38
  function isBoilerplate(line) {
9
39
  const trimmed = line.trim();