@gajae-code/coding-agent 0.7.2 → 0.7.4

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 (154) hide show
  1. package/CHANGELOG.md +86 -0
  2. package/bin/gjc.js +4 -0
  3. package/dist/types/cli/mcp-cli.d.ts +25 -0
  4. package/dist/types/cli/plugin-cli.d.ts +2 -0
  5. package/dist/types/cli.d.ts +6 -0
  6. package/dist/types/commands/mcp.d.ts +70 -0
  7. package/dist/types/commands/plugin.d.ts +6 -0
  8. package/dist/types/commands/session.d.ts +6 -0
  9. package/dist/types/config/keybindings.d.ts +2 -2
  10. package/dist/types/config/model-profile-activation.d.ts +8 -1
  11. package/dist/types/deep-interview/plaintext-gate-guard.d.ts +11 -0
  12. package/dist/types/extensibility/gjc-plugins/compiler.d.ts +19 -0
  13. package/dist/types/extensibility/gjc-plugins/constrained-hooks.d.ts +29 -0
  14. package/dist/types/extensibility/gjc-plugins/index.d.ts +9 -0
  15. package/dist/types/extensibility/gjc-plugins/injection.d.ts +9 -0
  16. package/dist/types/extensibility/gjc-plugins/installer.d.ts +13 -0
  17. package/dist/types/extensibility/gjc-plugins/mcp-policy.d.ts +26 -0
  18. package/dist/types/extensibility/gjc-plugins/observability.d.ts +27 -0
  19. package/dist/types/extensibility/gjc-plugins/prompt-appendix.d.ts +16 -0
  20. package/dist/types/extensibility/gjc-plugins/registry.d.ts +32 -0
  21. package/dist/types/extensibility/gjc-plugins/runtime-adapters.d.ts +64 -0
  22. package/dist/types/extensibility/gjc-plugins/session-validation.d.ts +42 -0
  23. package/dist/types/extensibility/gjc-plugins/types.d.ts +158 -2
  24. package/dist/types/extensibility/gjc-plugins/validation.d.ts +8 -1
  25. package/dist/types/gjc-runtime/launch-tmux.d.ts +1 -0
  26. package/dist/types/gjc-runtime/psmux-detect.d.ts +78 -0
  27. package/dist/types/gjc-runtime/team-runtime.d.ts +2 -0
  28. package/dist/types/gjc-runtime/tmux-common.d.ts +20 -1
  29. package/dist/types/gjc-runtime/tmux-sessions.d.ts +18 -0
  30. package/dist/types/main.d.ts +2 -0
  31. package/dist/types/modes/components/custom-editor.d.ts +1 -1
  32. package/dist/types/modes/components/model-selector.d.ts +8 -0
  33. package/dist/types/modes/components/status-line/git-utils.d.ts +6 -0
  34. package/dist/types/modes/theme/defaults/index.d.ts +99 -0
  35. package/dist/types/notifications/html-format.d.ts +11 -0
  36. package/dist/types/notifications/index.d.ts +149 -1
  37. package/dist/types/notifications/lifecycle-commands.d.ts +72 -0
  38. package/dist/types/notifications/lifecycle-control-runtime.d.ts +98 -0
  39. package/dist/types/notifications/lifecycle-orchestrator.d.ts +144 -0
  40. package/dist/types/notifications/operator-runtime.d.ts +52 -0
  41. package/dist/types/notifications/rate-limit-pool.d.ts +2 -0
  42. package/dist/types/notifications/recent-activity.d.ts +35 -0
  43. package/dist/types/notifications/telegram-daemon.d.ts +114 -16
  44. package/dist/types/notifications/telegram-reference.d.ts +3 -1
  45. package/dist/types/notifications/topic-registry.d.ts +12 -9
  46. package/dist/types/runtime-mcp/types.d.ts +7 -0
  47. package/dist/types/sdk.d.ts +2 -0
  48. package/dist/types/session/agent-session.d.ts +14 -4
  49. package/dist/types/session/blob-store.d.ts +25 -0
  50. package/dist/types/session/session-manager.d.ts +57 -0
  51. package/dist/types/slash-commands/helpers/fast-status-report.d.ts +6 -0
  52. package/dist/types/system-prompt.d.ts +2 -0
  53. package/dist/types/task/executor.d.ts +9 -1
  54. package/dist/types/tools/composer-bash-policy.d.ts +14 -0
  55. package/dist/types/tools/index.d.ts +3 -1
  56. package/dist/types/utils/changelog.d.ts +1 -0
  57. package/dist/types/web/insane/url-guard.d.ts +6 -3
  58. package/dist/types/web/scrapers/types.d.ts +5 -0
  59. package/dist/types/web/scrapers/utils.d.ts +7 -1
  60. package/package.json +11 -9
  61. package/scripts/g004-tmux-smoke.ts +100 -0
  62. package/scripts/g005-daemon-smoke.ts +181 -0
  63. package/scripts/g011-daemon-path-smoke.ts +153 -0
  64. package/src/cli/mcp-cli.ts +272 -0
  65. package/src/cli/plugin-cli.ts +66 -3
  66. package/src/cli.ts +27 -6
  67. package/src/commands/mcp.ts +117 -0
  68. package/src/commands/plugin.ts +4 -0
  69. package/src/commands/session.ts +18 -0
  70. package/src/config/keybindings.ts +2 -2
  71. package/src/config/model-profile-activation.ts +55 -7
  72. package/src/deep-interview/plaintext-gate-guard.ts +94 -0
  73. package/src/defaults/gjc/extensions/grok-cli-vendor/biome.json +1 -1
  74. package/src/defaults/gjc/skills/deep-interview/SKILL.md +7 -6
  75. package/src/defaults/gjc/skills/team/SKILL.md +5 -3
  76. package/src/defaults/gjc/skills/ultragoal/SKILL.md +41 -13
  77. package/src/export/html/index.ts +2 -2
  78. package/src/extensibility/extensions/runner.ts +1 -0
  79. package/src/extensibility/gjc-plugins/compiler.ts +351 -0
  80. package/src/extensibility/gjc-plugins/constrained-hooks.ts +170 -0
  81. package/src/extensibility/gjc-plugins/index.ts +9 -0
  82. package/src/extensibility/gjc-plugins/injection.ts +109 -0
  83. package/src/extensibility/gjc-plugins/installer.ts +434 -0
  84. package/src/extensibility/gjc-plugins/loader.ts +3 -1
  85. package/src/extensibility/gjc-plugins/mcp-policy.ts +239 -0
  86. package/src/extensibility/gjc-plugins/observability.ts +84 -0
  87. package/src/extensibility/gjc-plugins/paths.ts +1 -1
  88. package/src/extensibility/gjc-plugins/prompt-appendix.ts +109 -0
  89. package/src/extensibility/gjc-plugins/registry.ts +180 -0
  90. package/src/extensibility/gjc-plugins/runtime-adapters.ts +234 -0
  91. package/src/extensibility/gjc-plugins/schema.ts +250 -20
  92. package/src/extensibility/gjc-plugins/session-validation.ts +147 -0
  93. package/src/extensibility/gjc-plugins/types.ts +199 -3
  94. package/src/extensibility/gjc-plugins/validation.ts +80 -0
  95. package/src/extensibility/skills.ts +15 -0
  96. package/src/gjc-runtime/launch-tmux.ts +61 -7
  97. package/src/gjc-runtime/psmux-detect.ts +239 -0
  98. package/src/gjc-runtime/team-runtime.ts +56 -23
  99. package/src/gjc-runtime/tmux-common.ts +30 -3
  100. package/src/gjc-runtime/tmux-sessions.ts +51 -1
  101. package/src/gjc-runtime/ultragoal-guard.ts +25 -8
  102. package/src/gjc-runtime/ultragoal-runtime.ts +75 -15
  103. package/src/hooks/skill-state.ts +57 -0
  104. package/src/internal-urls/docs-index.generated.ts +12 -8
  105. package/src/main.ts +14 -3
  106. package/src/modes/bridge/bridge-mode.ts +11 -0
  107. package/src/modes/components/custom-editor.ts +2 -0
  108. package/src/modes/components/footer.ts +2 -3
  109. package/src/modes/components/hook-editor.ts +1 -1
  110. package/src/modes/components/hook-selector.ts +67 -43
  111. package/src/modes/components/model-selector.ts +56 -11
  112. package/src/modes/components/status-line/git-utils.ts +25 -0
  113. package/src/modes/components/status-line.ts +10 -11
  114. package/src/modes/components/welcome.ts +2 -3
  115. package/src/modes/controllers/extension-ui-controller.ts +0 -27
  116. package/src/modes/controllers/selector-controller.ts +53 -11
  117. package/src/modes/interactive-mode.ts +4 -1
  118. package/src/modes/shared/agent-wire/scopes.ts +1 -1
  119. package/src/modes/theme/defaults/gruvbox-dark.json +99 -0
  120. package/src/modes/theme/defaults/index.ts +2 -0
  121. package/src/modes/utils/hotkeys-markdown.ts +1 -1
  122. package/src/notifications/html-format.ts +38 -0
  123. package/src/notifications/index.ts +242 -12
  124. package/src/notifications/lifecycle-commands.ts +228 -0
  125. package/src/notifications/lifecycle-control-runtime.ts +400 -0
  126. package/src/notifications/lifecycle-orchestrator.ts +358 -0
  127. package/src/notifications/operator-runtime.ts +171 -0
  128. package/src/notifications/rate-limit-pool.ts +19 -0
  129. package/src/notifications/recent-activity.ts +132 -0
  130. package/src/notifications/telegram-daemon.ts +778 -257
  131. package/src/notifications/telegram-reference.ts +25 -7
  132. package/src/notifications/topic-registry.ts +23 -9
  133. package/src/prompts/agents/executor.md +2 -2
  134. package/src/runtime-mcp/transports/stdio.ts +38 -4
  135. package/src/runtime-mcp/types.ts +7 -0
  136. package/src/sdk.ts +157 -10
  137. package/src/session/agent-session.ts +166 -74
  138. package/src/session/blob-store.ts +196 -8
  139. package/src/session/session-manager.ts +678 -7
  140. package/src/slash-commands/builtin-registry.ts +23 -3
  141. package/src/slash-commands/helpers/fast-status-report.ts +13 -3
  142. package/src/slash-commands/helpers/parse.ts +2 -1
  143. package/src/system-prompt.ts +9 -0
  144. package/src/task/executor.ts +31 -7
  145. package/src/task/index.ts +2 -0
  146. package/src/tools/ask.ts +5 -1
  147. package/src/tools/bash.ts +9 -0
  148. package/src/tools/composer-bash-policy.ts +96 -0
  149. package/src/tools/fetch.ts +18 -2
  150. package/src/tools/index.ts +3 -1
  151. package/src/utils/changelog.ts +8 -0
  152. package/src/web/insane/url-guard.ts +18 -14
  153. package/src/web/scrapers/types.ts +143 -45
  154. package/src/web/scrapers/utils.ts +70 -19
@@ -6,6 +6,8 @@ import type TurndownService from "turndown";
6
6
 
7
7
  import type { AgentStorage } from "../../session/agent-storage";
8
8
  import { ToolAbortError } from "../../tools/tool-errors";
9
+ import type { AddressResolver } from "../insane/url-guard";
10
+ import { validatePublicHttpUrl } from "../insane/url-guard";
9
11
 
10
12
  export { formatNumber } from "@gajae-code/utils";
11
13
 
@@ -35,6 +37,7 @@ const USER_AGENTS = [
35
37
  "Mozilla/5.0 (compatible; TextBot/1.0)",
36
38
  "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Safari/537.36",
37
39
  ];
40
+ const REDIRECT_STATUSES = new Set([301, 302, 303, 307, 308]);
38
41
 
39
42
  function isBotBlocked(status: number, content: string): boolean {
40
43
  if (status === 403 || status === 503) {
@@ -70,6 +73,9 @@ export interface LoadPageOptions {
70
73
  body?: string;
71
74
  maxBytes?: number;
72
75
  signal?: AbortSignal;
76
+ publicUrlGuard?: boolean;
77
+ resolver?: AddressResolver;
78
+ maxRedirects?: number;
73
79
  }
74
80
 
75
81
  export interface LoadPageResult {
@@ -78,87 +84,179 @@ export interface LoadPageResult {
78
84
  finalUrl: string;
79
85
  ok: boolean;
80
86
  status?: number;
87
+ error?: string;
88
+ }
89
+
90
+ async function guardPublicFetchUrl(
91
+ rawUrl: string,
92
+ resolver: AddressResolver | undefined,
93
+ context: string,
94
+ ): Promise<{ ok: true; url: string } | { ok: false; error: string; finalUrl: string }> {
95
+ const guard = await validatePublicHttpUrl(rawUrl, { resolver });
96
+ if (guard.ok) return { ok: true, url: guard.url.toString() };
97
+ return {
98
+ ok: false,
99
+ error: `${context}: target URL is not public HTTP(S): ${guard.reason}`,
100
+ finalUrl: rawUrl,
101
+ };
102
+ }
103
+
104
+ function shouldRewriteRedirectMethod(status: number, method: string): boolean {
105
+ const normalized = method.toUpperCase();
106
+ return status === 303 || ((status === 301 || status === 302) && normalized === "POST");
81
107
  }
82
108
 
83
109
  /**
84
110
  * Fetch a page with timeout and size limit
85
111
  */
86
112
  export async function loadPage(url: string, options: LoadPageOptions = {}): Promise<LoadPageResult> {
87
- const { timeout = 20, headers = {}, maxBytes = MAX_BYTES, signal, method = "GET", body } = options;
113
+ const {
114
+ timeout = 20,
115
+ headers = {},
116
+ maxBytes = MAX_BYTES,
117
+ signal,
118
+ method = "GET",
119
+ body,
120
+ publicUrlGuard = true,
121
+ resolver,
122
+ maxRedirects = 10,
123
+ } = options;
124
+
125
+ let initialUrl = url;
126
+ if (publicUrlGuard) {
127
+ const guarded = await guardPublicFetchUrl(url, resolver, "Blocked URL fetch");
128
+ if (!guarded.ok) {
129
+ return {
130
+ content: "",
131
+ contentType: "",
132
+ finalUrl: guarded.finalUrl,
133
+ ok: false,
134
+ error: guarded.error,
135
+ };
136
+ }
137
+ initialUrl = guarded.url;
138
+ }
88
139
 
89
- for (let attempt = 0; attempt < USER_AGENTS.length; attempt++) {
140
+ attempts: for (let attempt = 0; attempt < USER_AGENTS.length; attempt++) {
90
141
  if (signal?.aborted) {
91
142
  throw new ToolAbortError();
92
143
  }
93
144
 
94
145
  const userAgent = USER_AGENTS[attempt];
95
146
  const requestSignal = ptree.combineSignals(signal, timeout * 1000);
147
+ let currentUrl = initialUrl;
148
+ let currentMethod = method;
149
+ let currentBody = body;
96
150
 
97
151
  try {
98
- const requestInit: RequestInit = {
99
- signal: requestSignal,
100
- method,
101
- headers: {
102
- "User-Agent": userAgent,
103
- Accept: "text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8",
104
- "Accept-Language": "en-US,en;q=0.5",
105
- "Accept-Encoding": "identity", // Cloudflare Markdown-for-Agents returns corrupted bytes when compression is negotiated
106
- ...headers,
107
- },
108
- redirect: "follow",
109
- };
152
+ for (let redirectCount = 0; redirectCount <= maxRedirects; redirectCount++) {
153
+ const requestInit: RequestInit = {
154
+ signal: requestSignal,
155
+ method: currentMethod,
156
+ headers: {
157
+ "User-Agent": userAgent,
158
+ Accept: "text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8",
159
+ "Accept-Language": "en-US,en;q=0.5",
160
+ "Accept-Encoding": "identity", // Cloudflare Markdown-for-Agents returns corrupted bytes when compression is negotiated
161
+ ...headers,
162
+ },
163
+ redirect: "manual",
164
+ };
165
+
166
+ if (currentBody !== undefined) {
167
+ requestInit.body = currentBody;
168
+ }
110
169
 
111
- if (body !== undefined) {
112
- requestInit.body = body;
113
- }
170
+ const response = await fetch(currentUrl, requestInit);
171
+ if (REDIRECT_STATUSES.has(response.status)) {
172
+ const location = response.headers.get("location");
173
+ if (!location) {
174
+ return {
175
+ content: "",
176
+ contentType: "",
177
+ finalUrl: currentUrl,
178
+ ok: false,
179
+ status: response.status,
180
+ error: "Redirect response missing Location header",
181
+ };
182
+ }
183
+ const redirectUrl = new URL(location, currentUrl).toString();
184
+ if (publicUrlGuard) {
185
+ const guarded = await guardPublicFetchUrl(redirectUrl, resolver, "Blocked URL redirect");
186
+ if (!guarded.ok) {
187
+ return {
188
+ content: "",
189
+ contentType: "",
190
+ finalUrl: guarded.finalUrl,
191
+ ok: false,
192
+ status: response.status,
193
+ error: guarded.error,
194
+ };
195
+ }
196
+ currentUrl = guarded.url;
197
+ } else {
198
+ currentUrl = redirectUrl;
199
+ }
200
+ if (shouldRewriteRedirectMethod(response.status, currentMethod)) {
201
+ currentMethod = "GET";
202
+ currentBody = undefined;
203
+ }
204
+ continue;
205
+ }
114
206
 
115
- const response = await fetch(url, requestInit);
207
+ const contentType = response.headers.get("content-type")?.split(";")[0]?.trim().toLowerCase() ?? "";
208
+ const finalUrl = response.url || currentUrl;
116
209
 
117
- const contentType = response.headers.get("content-type")?.split(";")[0]?.trim().toLowerCase() ?? "";
118
- const finalUrl = response.url;
210
+ const reader = response.body?.getReader();
211
+ if (!reader) {
212
+ return { content: "", contentType, finalUrl, ok: false, status: response.status };
213
+ }
119
214
 
120
- const reader = response.body?.getReader();
121
- if (!reader) {
122
- return { content: "", contentType, finalUrl, ok: false, status: response.status };
123
- }
215
+ const chunks: Uint8Array[] = [];
216
+ let totalSize = 0;
124
217
 
125
- const chunks: Uint8Array[] = [];
126
- let totalSize = 0;
218
+ while (true) {
219
+ const { done, value } = await reader.read();
220
+ if (done) break;
127
221
 
128
- while (true) {
129
- const { done, value } = await reader.read();
130
- if (done) break;
222
+ chunks.push(value);
223
+ totalSize += value.length;
131
224
 
132
- chunks.push(value);
133
- totalSize += value.length;
225
+ if (totalSize > maxBytes) {
226
+ reader.cancel();
227
+ break;
228
+ }
229
+ }
134
230
 
135
- if (totalSize > maxBytes) {
136
- reader.cancel();
137
- break;
231
+ const content = Buffer.concat(chunks).toString("utf-8");
232
+ if (isBotBlocked(response.status, content) && attempt < USER_AGENTS.length - 1) {
233
+ continue attempts;
138
234
  }
139
- }
140
235
 
141
- const content = Buffer.concat(chunks).toString("utf-8");
142
- if (isBotBlocked(response.status, content) && attempt < USER_AGENTS.length - 1) {
143
- continue;
144
- }
236
+ if (!response.ok) {
237
+ return { content, contentType, finalUrl, ok: false, status: response.status };
238
+ }
145
239
 
146
- if (!response.ok) {
147
- return { content, contentType, finalUrl, ok: false, status: response.status };
240
+ return { content, contentType, finalUrl, ok: true, status: response.status };
148
241
  }
149
-
150
- return { content, contentType, finalUrl, ok: true, status: response.status };
242
+ return {
243
+ content: "",
244
+ contentType: "",
245
+ finalUrl: currentUrl,
246
+ ok: false,
247
+ error: `Too many redirects (${maxRedirects})`,
248
+ };
151
249
  } catch {
152
250
  if (signal?.aborted) {
153
251
  throw new ToolAbortError();
154
252
  }
155
253
  if (attempt === USER_AGENTS.length - 1) {
156
- return { content: "", contentType: "", finalUrl: url, ok: false };
254
+ return { content: "", contentType: "", finalUrl: currentUrl, ok: false };
157
255
  }
158
256
  }
159
257
  }
160
258
 
161
- return { content: "", contentType: "", finalUrl: url, ok: false };
259
+ return { content: "", contentType: "", finalUrl: initialUrl, ok: false };
162
260
  }
163
261
 
164
262
  /** Module-level Turndown instance — built lazily on first use. */
@@ -4,6 +4,8 @@ export { isRecord };
4
4
 
5
5
  import { ToolAbortError } from "../../tools/tool-errors";
6
6
  import { convertBufferWithMarkit } from "../../utils/markit";
7
+ import type { AddressResolver } from "../insane/url-guard";
8
+ import { validatePublicHttpUrl } from "../insane/url-guard";
7
9
  import { MAX_BYTES } from "./types";
8
10
 
9
11
  export function asRecord(value: unknown): Record<string, unknown> | null {
@@ -28,6 +30,14 @@ export interface BinaryFetchSuccess {
28
30
 
29
31
  export type BinaryFetchResult = BinaryFetchSuccess | { ok: false; error?: string };
30
32
 
33
+ export interface FetchBinaryOptions {
34
+ publicUrlGuard?: boolean;
35
+ resolver?: AddressResolver;
36
+ maxRedirects?: number;
37
+ }
38
+
39
+ const REDIRECT_STATUSES = new Set([301, 302, 303, 307, 308]);
40
+
31
41
  async function readResponseWithLimit(response: Response, maxBytes: number, signal?: AbortSignal): Promise<Uint8Array> {
32
42
  const reader = response.body?.getReader();
33
43
  if (!reader) return new Uint8Array(0);
@@ -60,34 +70,75 @@ async function readResponseWithLimit(response: Response, maxBytes: number, signa
60
70
  return new Uint8Array(Buffer.concat(chunks, totalBytes));
61
71
  }
62
72
 
73
+ async function guardPublicBinaryUrl(
74
+ rawUrl: string,
75
+ resolver: AddressResolver | undefined,
76
+ context: string,
77
+ ): Promise<{ ok: true; url: string } | { ok: false; error: string }> {
78
+ const guard = await validatePublicHttpUrl(rawUrl, { resolver });
79
+ if (guard.ok) return { ok: true, url: guard.url.toString() };
80
+ return { ok: false, error: `${context}: target URL is not public HTTP(S): ${guard.reason}` };
81
+ }
82
+
63
83
  /**
64
84
  * Fetch binary content from a URL
65
85
  */
66
- export async function fetchBinary(url: string, timeout: number = 20, signal?: AbortSignal): Promise<BinaryFetchResult> {
86
+ export async function fetchBinary(
87
+ url: string,
88
+ timeout: number = 20,
89
+ signal?: AbortSignal,
90
+ options: FetchBinaryOptions = {},
91
+ ): Promise<BinaryFetchResult> {
67
92
  const requestSignal = ptree.combineSignals(signal, timeout * 1000);
93
+ const { publicUrlGuard = true, resolver, maxRedirects = 10 } = options;
68
94
  try {
69
- const response = await fetch(url, {
70
- signal: requestSignal,
71
- headers: {
72
- "User-Agent": "Mozilla/5.0 (compatible; TextBot/1.0)",
73
- },
74
- redirect: "follow",
75
- });
76
-
77
- if (!response.ok) {
78
- return { ok: false, error: `HTTP ${response.status}` };
95
+ let currentUrl = url;
96
+ if (publicUrlGuard) {
97
+ const guarded = await guardPublicBinaryUrl(url, resolver, "Blocked binary fetch");
98
+ if (!guarded.ok) return { ok: false, error: guarded.error };
99
+ currentUrl = guarded.url;
79
100
  }
80
101
 
81
- const contentDisposition = response.headers.get("content-disposition") || undefined;
82
- const contentLength = response.headers.get("content-length");
83
- if (contentLength) {
84
- const size = Number.parseInt(contentLength, 10);
85
- if (Number.isFinite(size) && size > MAX_BYTES) {
86
- return { ok: false, error: `content-length ${size} exceeds ${MAX_BYTES}` };
102
+ for (let redirectCount = 0; redirectCount <= maxRedirects; redirectCount++) {
103
+ const response = await fetch(currentUrl, {
104
+ signal: requestSignal,
105
+ headers: {
106
+ "User-Agent": "Mozilla/5.0 (compatible; TextBot/1.0)",
107
+ },
108
+ redirect: "manual",
109
+ });
110
+
111
+ if (REDIRECT_STATUSES.has(response.status)) {
112
+ const location = response.headers.get("location");
113
+ if (!location) return { ok: false, error: "Redirect response missing Location header" };
114
+ const redirectUrl = new URL(location, currentUrl).toString();
115
+ if (publicUrlGuard) {
116
+ const guarded = await guardPublicBinaryUrl(redirectUrl, resolver, "Blocked binary redirect");
117
+ if (!guarded.ok) return { ok: false, error: guarded.error };
118
+ currentUrl = guarded.url;
119
+ } else {
120
+ currentUrl = redirectUrl;
121
+ }
122
+ continue;
123
+ }
124
+
125
+ if (!response.ok) {
126
+ return { ok: false, error: `HTTP ${response.status}` };
87
127
  }
128
+
129
+ const contentDisposition = response.headers.get("content-disposition") || undefined;
130
+ const contentLength = response.headers.get("content-length");
131
+ if (contentLength) {
132
+ const size = Number.parseInt(contentLength, 10);
133
+ if (Number.isFinite(size) && size > MAX_BYTES) {
134
+ return { ok: false, error: `content-length ${size} exceeds ${MAX_BYTES}` };
135
+ }
136
+ }
137
+ const buffer = await readResponseWithLimit(response, MAX_BYTES, requestSignal);
138
+ return { ok: true, buffer, contentDisposition };
88
139
  }
89
- const buffer = await readResponseWithLimit(response, MAX_BYTES, requestSignal);
90
- return { ok: true, buffer, contentDisposition };
140
+
141
+ return { ok: false, error: `Too many redirects (${maxRedirects})` };
91
142
  } catch (err) {
92
143
  if (signal?.aborted) throw new ToolAbortError();
93
144
  if (requestSignal?.aborted) return { ok: false, error: "aborted" };