@ethisyscore/extension-runtime 1.137.0 → 1.139.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.
@@ -145,6 +145,157 @@ function classifyHostError(err) {
145
145
  return "internal";
146
146
  }
147
147
 
148
+ // src/plugin/pluginStorageUpload.ts
149
+ var PLUGIN_STORAGE_UPLOAD_MAX_BYTES = 30 * 1024 * 1024;
150
+ var PLUGIN_STORAGE_PREFIX_MAX_LENGTH = 84;
151
+ var PLUGIN_STORAGE_PATH_MAX_LENGTH = 128;
152
+ var PluginStorageUploadError = class extends McpToolError {
153
+ /** What went wrong, in terms a caller branches on. */
154
+ reason;
155
+ /** HTTP status from the server, when the host surfaced one. */
156
+ status;
157
+ /**
158
+ * Seconds the server asked the caller to wait (`Retry-After`), on a `rate_limited` failure
159
+ * when the host forwarded the header. Absent otherwise.
160
+ */
161
+ retryAfterSeconds;
162
+ /** Structural marker for {@link isPluginStorageUploadError}. */
163
+ isPluginStorageUploadError = true;
164
+ constructor(message, reason, options = {}) {
165
+ super(message, options.code ?? codeForReason(reason, options.status));
166
+ this.name = "PluginStorageUploadError";
167
+ this.reason = reason;
168
+ this.status = options.status;
169
+ if (options.retryAfterSeconds !== void 0) {
170
+ this.retryAfterSeconds = options.retryAfterSeconds;
171
+ }
172
+ }
173
+ };
174
+ function parsePluginStorageRetryAfter(raw, nowMs = Date.now()) {
175
+ if (typeof raw === "number") {
176
+ return Number.isFinite(raw) && raw >= 0 ? Math.ceil(raw) : void 0;
177
+ }
178
+ if (typeof raw !== "string" || raw.trim().length === 0) {
179
+ return void 0;
180
+ }
181
+ const text = raw.trim();
182
+ if (/^\d+$/.test(text)) {
183
+ return Number(text);
184
+ }
185
+ const at = /[A-Za-z]/.test(text) ? Date.parse(text) : Number.NaN;
186
+ return Number.isNaN(at) ? void 0 : Math.max(0, Math.ceil((at - nowMs) / 1e3));
187
+ }
188
+ function validatePluginStoragePrefix(raw) {
189
+ if (typeof raw !== "string" || raw.trim().length === 0) {
190
+ return { ok: false, error: "A storage path prefix is required." };
191
+ }
192
+ if (raw.length > PLUGIN_STORAGE_PREFIX_MAX_LENGTH) {
193
+ return {
194
+ ok: false,
195
+ error: `The storage path prefix must be at most ${PLUGIN_STORAGE_PREFIX_MAX_LENGTH} characters, so the stored path fits ${PLUGIN_STORAGE_PATH_MAX_LENGTH}.`
196
+ };
197
+ }
198
+ if (!/^[A-Za-z0-9_\-/]+$/.test(raw)) {
199
+ return {
200
+ ok: false,
201
+ error: "The storage path prefix may contain only letters, digits, '-', '_' and '/' separators."
202
+ };
203
+ }
204
+ if (raw.split("/").some((segment) => segment.length === 0)) {
205
+ return {
206
+ ok: false,
207
+ error: "The storage path prefix must not start or end with '/' or contain an empty segment."
208
+ };
209
+ }
210
+ return { ok: true, prefix: raw };
211
+ }
212
+ var PLUGIN_STORAGE_UPLOAD_CONTENT_TYPES = Object.freeze({
213
+ pdf: ["application/pdf"],
214
+ png: ["image/png"],
215
+ jpg: ["image/jpeg", "image/pjpeg", "image/jpg"],
216
+ jpeg: ["image/jpeg", "image/pjpeg", "image/jpg"],
217
+ gif: ["image/gif"],
218
+ webp: ["image/webp"],
219
+ doc: ["application/msword"],
220
+ docx: ["application/vnd.openxmlformats-officedocument.wordprocessingml.document"],
221
+ xls: ["application/vnd.ms-excel"],
222
+ xlsx: ["application/vnd.openxmlformats-officedocument.spreadsheetml.sheet"],
223
+ ppt: ["application/vnd.ms-powerpoint"],
224
+ pptx: ["application/vnd.openxmlformats-officedocument.presentationml.presentation"],
225
+ odt: ["application/vnd.oasis.opendocument.text"],
226
+ ods: ["application/vnd.oasis.opendocument.spreadsheet"],
227
+ odp: ["application/vnd.oasis.opendocument.presentation"],
228
+ txt: ["text/plain"],
229
+ csv: ["text/csv", "application/vnd.ms-excel", "application/csv", "text/plain"],
230
+ zip: ["application/zip", "application/x-zip-compressed", "application/x-zip"],
231
+ mp3: ["audio/mpeg", "audio/mp3"],
232
+ m4a: ["audio/mp4", "audio/x-m4a"],
233
+ mp4: ["video/mp4"]
234
+ });
235
+ var OCTET_STREAM = "application/octet-stream";
236
+ function pluginStorageSafeExtension(fileName) {
237
+ if (typeof fileName !== "string" || fileName.trim().length === 0) {
238
+ return "";
239
+ }
240
+ const leaf = fileName.split(/[\\/]/).pop() ?? "";
241
+ const dot = leaf.lastIndexOf(".");
242
+ if (dot < 0) {
243
+ return "";
244
+ }
245
+ const body = leaf.slice(dot + 1);
246
+ return /^[A-Za-z0-9]{1,10}$/.test(body) ? `.${body.toLowerCase()}` : "";
247
+ }
248
+ function resolvePluginStorageContentType(fileName, declaredContentType) {
249
+ const extension = pluginStorageSafeExtension(fileName).slice(1);
250
+ const allowed = extension.length > 0 && Object.prototype.hasOwnProperty.call(PLUGIN_STORAGE_UPLOAD_CONTENT_TYPES, extension) ? PLUGIN_STORAGE_UPLOAD_CONTENT_TYPES[extension] : void 0;
251
+ if (allowed === void 0) {
252
+ return {
253
+ ok: false,
254
+ error: "This file type is not allowed. Allowed: " + Object.keys(PLUGIN_STORAGE_UPLOAD_CONTENT_TYPES).sort().join(", ") + "."
255
+ };
256
+ }
257
+ const declared = mediaTypeOf(declaredContentType);
258
+ if (declared === void 0 || declared === OCTET_STREAM || allowed.includes(declared)) {
259
+ return { ok: true, contentType: allowed[0] };
260
+ }
261
+ return {
262
+ ok: false,
263
+ error: `The declared content type '${declared}' does not match the file extension '.${extension}'.`
264
+ };
265
+ }
266
+ function mediaTypeOf(raw) {
267
+ if (typeof raw !== "string") {
268
+ return void 0;
269
+ }
270
+ const mediaType = raw.split(";")[0].trim().toLowerCase();
271
+ return /^[a-z0-9!#$&^_.+-]+\/[a-z0-9!#$&^_.+-]+$/.test(mediaType) ? mediaType : void 0;
272
+ }
273
+ function codeForReason(reason, status) {
274
+ if (status !== void 0) {
275
+ return mcpErrorCodeFromHttpStatus(status);
276
+ }
277
+ switch (reason) {
278
+ case "invalid_prefix":
279
+ case "too_large":
280
+ case "unsupported_type":
281
+ return "invalid_request";
282
+ case "timeout":
283
+ return "timeout";
284
+ case "unauthorized":
285
+ return "unauthorized";
286
+ case "forbidden":
287
+ return "forbidden";
288
+ case "busy":
289
+ return "unavailable";
290
+ case "rate_limited":
291
+ return "rate_limited";
292
+ case "aborted":
293
+ return "timeout";
294
+ default:
295
+ return "internal";
296
+ }
297
+ }
298
+
148
299
  // src/host/version-skew.ts
149
300
  var EXTENSION_VERSION_HEADER = "x-extension-version";
150
301
  var EXTENSION_ACTIVE_VERSION_HEADER = "x-extension-active-version";
@@ -729,7 +880,10 @@ var WorkerRemoteDomTransport = class {
729
880
  // The HTTP status, on failure only. A bare number carries no host internals, and it
730
881
  // is the only way the plugin can tell 413 (file too large) from 507 (quota) from 503
731
882
  // (storage lock busy): the closed MCP code folds all three into `internal`/`unavailable`.
732
- ...!result.ok && typeof result.status === "number" ? { status: result.status } : {}
883
+ ...!result.ok && typeof result.status === "number" ? { status: result.status } : {},
884
+ // The server's `Retry-After` (429), as whole seconds, so a plugin can show how long
885
+ // to wait. Never acted on here: an upload is not retried automatically.
886
+ ...retryAfterField(result)
733
887
  });
734
888
  } catch (err) {
735
889
  this.replyError(message.id, resultType, err, true);
@@ -738,16 +892,26 @@ var WorkerRemoteDomTransport = class {
738
892
  replyError(id, type, err, includeStatus = false) {
739
893
  const message = err instanceof Error ? err.message : "MCP request failed";
740
894
  const status = includeStatus ? statusOf(err) : void 0;
895
+ const retryAfterSeconds = includeStatus && err !== null && typeof err === "object" ? parsePluginStorageRetryAfter(err.retryAfterSeconds) : void 0;
741
896
  this.safePostMessage({
742
897
  id,
743
898
  type,
744
899
  ok: false,
745
900
  error: message,
746
901
  code: classifyHostError(err),
747
- ...status !== void 0 ? { status } : {}
902
+ ...status !== void 0 ? { status } : {},
903
+ ...retryAfterSeconds !== void 0 ? { retryAfterSeconds } : {}
748
904
  });
749
905
  }
750
906
  };
907
+ function retryAfterField(result) {
908
+ if (result.ok || !result.headers) {
909
+ return {};
910
+ }
911
+ const key = Object.keys(result.headers).find((k) => k.toLowerCase() === "retry-after");
912
+ const seconds = key === void 0 ? void 0 : parsePluginStorageRetryAfter(result.headers[key]);
913
+ return seconds === void 0 ? {} : { retryAfterSeconds: seconds };
914
+ }
751
915
  function statusOf(err) {
752
916
  if (err === null || typeof err !== "object") {
753
917
  return void 0;
@@ -778,138 +942,20 @@ var CONTRACT_B_PRIMITIVES = [
778
942
  ];
779
943
  new Set(CONTRACT_B_PRIMITIVES);
780
944
 
781
- // src/plugin/pluginStorageUpload.ts
782
- var PLUGIN_STORAGE_UPLOAD_MAX_BYTES = 30 * 1024 * 1024;
783
- var PLUGIN_STORAGE_PREFIX_MAX_LENGTH = 84;
784
- var PLUGIN_STORAGE_PATH_MAX_LENGTH = 128;
785
- var PluginStorageUploadError = class extends McpToolError {
786
- /** What went wrong, in terms a caller branches on. */
787
- reason;
788
- /** HTTP status from the server, when the host surfaced one. */
789
- status;
790
- /** Structural marker for {@link isPluginStorageUploadError}. */
791
- isPluginStorageUploadError = true;
792
- constructor(message, reason, options = {}) {
793
- super(message, options.code ?? codeForReason(reason, options.status));
794
- this.name = "PluginStorageUploadError";
795
- this.reason = reason;
796
- this.status = options.status;
797
- }
798
- };
799
- function validatePluginStoragePrefix(raw) {
800
- if (typeof raw !== "string" || raw.trim().length === 0) {
801
- return { ok: false, error: "A storage path prefix is required." };
802
- }
803
- if (raw.length > PLUGIN_STORAGE_PREFIX_MAX_LENGTH) {
804
- return {
805
- ok: false,
806
- error: `The storage path prefix must be at most ${PLUGIN_STORAGE_PREFIX_MAX_LENGTH} characters, so the stored path fits ${PLUGIN_STORAGE_PATH_MAX_LENGTH}.`
807
- };
808
- }
809
- if (!/^[A-Za-z0-9_\-/]+$/.test(raw)) {
810
- return {
811
- ok: false,
812
- error: "The storage path prefix may contain only letters, digits, '-', '_' and '/' separators."
813
- };
814
- }
815
- if (raw.split("/").some((segment) => segment.length === 0)) {
816
- return {
817
- ok: false,
818
- error: "The storage path prefix must not start or end with '/' or contain an empty segment."
819
- };
820
- }
821
- return { ok: true, prefix: raw };
822
- }
823
- var PLUGIN_STORAGE_UPLOAD_CONTENT_TYPES = Object.freeze({
824
- pdf: ["application/pdf"],
825
- png: ["image/png"],
826
- jpg: ["image/jpeg", "image/pjpeg", "image/jpg"],
827
- jpeg: ["image/jpeg", "image/pjpeg", "image/jpg"],
828
- gif: ["image/gif"],
829
- webp: ["image/webp"],
830
- doc: ["application/msword"],
831
- docx: ["application/vnd.openxmlformats-officedocument.wordprocessingml.document"],
832
- xls: ["application/vnd.ms-excel"],
833
- xlsx: ["application/vnd.openxmlformats-officedocument.spreadsheetml.sheet"],
834
- ppt: ["application/vnd.ms-powerpoint"],
835
- pptx: ["application/vnd.openxmlformats-officedocument.presentationml.presentation"],
836
- odt: ["application/vnd.oasis.opendocument.text"],
837
- ods: ["application/vnd.oasis.opendocument.spreadsheet"],
838
- odp: ["application/vnd.oasis.opendocument.presentation"],
839
- txt: ["text/plain"],
840
- csv: ["text/csv", "application/vnd.ms-excel", "application/csv", "text/plain"],
841
- zip: ["application/zip", "application/x-zip-compressed", "application/x-zip"],
842
- mp3: ["audio/mpeg", "audio/mp3"],
843
- m4a: ["audio/mp4", "audio/x-m4a"],
844
- mp4: ["video/mp4"]
845
- });
846
- var OCTET_STREAM = "application/octet-stream";
847
- function pluginStorageSafeExtension(fileName) {
848
- if (typeof fileName !== "string" || fileName.trim().length === 0) {
849
- return "";
850
- }
851
- const leaf = fileName.split(/[\\/]/).pop() ?? "";
852
- const dot = leaf.lastIndexOf(".");
853
- if (dot < 0) {
854
- return "";
855
- }
856
- const body = leaf.slice(dot + 1);
857
- return /^[A-Za-z0-9]{1,10}$/.test(body) ? `.${body.toLowerCase()}` : "";
858
- }
859
- function resolvePluginStorageContentType(fileName, declaredContentType) {
860
- const extension = pluginStorageSafeExtension(fileName).slice(1);
861
- const allowed = extension.length > 0 && Object.prototype.hasOwnProperty.call(PLUGIN_STORAGE_UPLOAD_CONTENT_TYPES, extension) ? PLUGIN_STORAGE_UPLOAD_CONTENT_TYPES[extension] : void 0;
862
- if (allowed === void 0) {
863
- return {
864
- ok: false,
865
- error: "This file type is not allowed. Allowed: " + Object.keys(PLUGIN_STORAGE_UPLOAD_CONTENT_TYPES).sort().join(", ") + "."
866
- };
867
- }
868
- const declared = mediaTypeOf(declaredContentType);
869
- if (declared === void 0 || declared === OCTET_STREAM || allowed.includes(declared)) {
870
- return { ok: true, contentType: allowed[0] };
871
- }
872
- return {
873
- ok: false,
874
- error: `The declared content type '${declared}' does not match the file extension '.${extension}'.`
875
- };
876
- }
877
- function mediaTypeOf(raw) {
878
- if (typeof raw !== "string") {
879
- return void 0;
880
- }
881
- const mediaType = raw.split(";")[0].trim().toLowerCase();
882
- return /^[a-z0-9!#$&^_.+-]+\/[a-z0-9!#$&^_.+-]+$/.test(mediaType) ? mediaType : void 0;
883
- }
884
- function codeForReason(reason, status) {
885
- if (status !== void 0) {
886
- return mcpErrorCodeFromHttpStatus(status);
887
- }
888
- switch (reason) {
889
- case "invalid_prefix":
890
- case "too_large":
891
- case "unsupported_type":
892
- return "invalid_request";
893
- case "timeout":
894
- return "timeout";
895
- case "unauthorized":
896
- return "unauthorized";
897
- case "forbidden":
898
- return "forbidden";
899
- case "busy":
900
- return "unavailable";
901
- case "aborted":
902
- return "timeout";
903
- default:
904
- return "internal";
905
- }
906
- }
907
-
908
945
  // src/mock-host/MockPluginStorage.ts
909
946
  var MockPluginStorage = class {
910
947
  files = /* @__PURE__ */ new Map();
948
+ rateLimit;
949
+ now;
950
+ windowStartMs = 0;
951
+ windowCount = 0;
952
+ constructor(options = {}) {
953
+ this.rateLimit = options.rateLimit;
954
+ this.now = options.now ?? Date.now;
955
+ }
911
956
  /** Store `buffer` under a new path below `meta.pathPrefix`. */
912
957
  store(meta, buffer) {
958
+ this.enforceRateLimit();
913
959
  const prefix = validatePluginStoragePrefix(meta.pathPrefix);
914
960
  if (!prefix.ok) {
915
961
  throw new PluginStorageUploadError(prefix.error, "invalid_prefix", { status: 400 });
@@ -931,6 +977,27 @@ var MockPluginStorage = class {
931
977
  this.files.set(path2, { path: path2, fileName, contentType: type.contentType, bytes });
932
978
  return { path: path2, fileName, contentType: type.contentType, sizeBytes: bytes.byteLength };
933
979
  }
980
+ /** The kernel refuses before the handler runs, so this is checked before anything else. */
981
+ enforceRateLimit() {
982
+ if (this.rateLimit === void 0) {
983
+ return;
984
+ }
985
+ const windowMs = (this.rateLimit.windowSeconds ?? 60) * 1e3;
986
+ const now = this.now();
987
+ if (this.windowCount === 0 || now - this.windowStartMs >= windowMs) {
988
+ this.windowStartMs = now;
989
+ this.windowCount = 0;
990
+ }
991
+ this.windowCount += 1;
992
+ if (this.windowCount > this.rateLimit.maxRequests) {
993
+ const retryAfterSeconds = Math.max(1, Math.ceil((this.windowStartMs + windowMs - now) / 1e3));
994
+ throw new PluginStorageUploadError(
995
+ "Rate limit exceeded. Please try again later.",
996
+ "rate_limited",
997
+ { status: 429, retryAfterSeconds }
998
+ );
999
+ }
1000
+ }
934
1001
  /** The stored file at `path`, or `undefined`. */
935
1002
  get(path2) {
936
1003
  return this.files.get(path2);