@omercnet/paseo-omp 0.2.1 → 0.3.0-next.101.1

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 (64) hide show
  1. package/CHANGELOG.md +26 -0
  2. package/README.md +25 -13
  3. package/SUPPORT.md +7 -3
  4. package/TESTING.md +21 -18
  5. package/client/composer-pill-settings.tsx +157 -0
  6. package/client/external-url.ts +15 -0
  7. package/client/mcp-authorization.tsx +169 -0
  8. package/client/mcp-popover.tsx +155 -0
  9. package/client/memory-panel.tsx +8 -3
  10. package/client/memory-popover.tsx +8 -4
  11. package/client/omp-config-surface.tsx +189 -29
  12. package/client/omp-plugin-manager.tsx +302 -131
  13. package/client/omp-store-picker.tsx +89 -0
  14. package/client/omp-store-state.ts +45 -0
  15. package/client/paseo-types.ts +9 -0
  16. package/client/provider-diagnostics-state.ts +18 -7
  17. package/client/quota-popover.tsx +8 -3
  18. package/client/quota-state.ts +16 -7
  19. package/client/sessions-popover.tsx +8 -3
  20. package/docs/alpha-release-checklist.md +6 -8
  21. package/docs/configuration.md +8 -4
  22. package/docs/core-provider-issue-audit.md +3 -2
  23. package/docs/images/mcp-authorization-compact.png +0 -0
  24. package/docs/images/mcp-controls-wide.png +0 -0
  25. package/docs/images/plugin-manager.png +0 -0
  26. package/docs/images/workspace-settings.png +0 -0
  27. package/docs/installation.md +35 -19
  28. package/index.client.tsx +339 -123
  29. package/index.server.ts +44 -14
  30. package/package.json +7 -8
  31. package/paseo-plugin.json +2 -2
  32. package/scripts/prepare-dependencies.mjs +24 -0
  33. package/server/mcp-browser.ts +95 -0
  34. package/server/memory.ts +2 -2
  35. package/server/omp-config.ts +16 -7
  36. package/server/omp-plugins.ts +70 -21
  37. package/server/omp-settings.ts +232 -24
  38. package/server/paths.ts +128 -11
  39. package/server/provider/catalog.ts +3 -4
  40. package/server/provider/connection.ts +248 -17
  41. package/server/provider/host-tools.ts +294 -26
  42. package/server/provider/omp-rpc.ts +499 -72
  43. package/server/provider/profile-providers.ts +249 -0
  44. package/server/provider/registration.ts +11 -0
  45. package/server/provider/security.ts +8 -10
  46. package/server/provider/session-descriptors.ts +340 -1
  47. package/server/provider/session.ts +716 -249
  48. package/server/provider/subsessions.ts +25 -2
  49. package/server/provider/timeline-projector.ts +104 -44
  50. package/server/provider-diagnostics.ts +122 -36
  51. package/server/quota.ts +3 -2
  52. package/server/sessions.ts +2 -2
  53. package/shared/composer-pill-settings.ts +28 -0
  54. package/shared/external-url.ts +21 -0
  55. package/shared/hub.ts +3 -3
  56. package/shared/mcp.ts +47 -0
  57. package/shared/memory.ts +2 -1
  58. package/shared/omp-config.ts +5 -1
  59. package/shared/omp-plugins.ts +74 -33
  60. package/shared/omp-settings.ts +8 -1
  61. package/shared/omp-store.ts +58 -0
  62. package/shared/provider-diagnostics.ts +12 -3
  63. package/shared/quota.ts +2 -1
  64. package/shared/sessions.ts +2 -1
@@ -3,6 +3,7 @@ import type {
3
3
  ProviderMcpServerConfig,
4
4
  ProviderSessionConfig,
5
5
  } from "@getpaseo/plugin/server/provider";
6
+ import { isValidImagePayload } from "./image";
6
7
  import {
7
8
  type ConnectedMcpClient,
8
9
  type ConnectedMcpTool,
@@ -37,11 +38,19 @@ const MAX_HOST_TOOL_DESCRIPTION_BYTES = 64 * 1024;
37
38
  const MAX_HOST_TOOL_SCHEMA_BYTES = 256 * 1024;
38
39
  const MAX_HOST_TOOL_CATALOG_BYTES = 768 * 1024;
39
40
  const MAX_HOST_TOOL_RESULT_BYTES = 12 * 1024 * 1024;
41
+ const MAX_HOST_TOOL_CONTENT_BLOCKS = 512;
42
+ const MAX_HOST_TOOL_TEXT_BYTES = 1024 * 1024;
43
+ const MAX_HOST_TOOL_IMAGE_DATA_BYTES = 8 * 1024 * 1024;
40
44
  const MAX_STRUCTURED_CONTENT_FALLBACK_BYTES = 1024 * 1024;
45
+ const CONTENT_TRUNCATED_NOTICE = "[MCP result content truncated: exceeded size limits]";
46
+ const DETAILS_OMITTED_NOTICE = "[MCP structured content omitted: exceeded size limits]";
47
+ const RESULT_TRUNCATED_NOTICE = "[MCP content truncated; details omitted]";
41
48
  const MAX_PENDING_HOST_TOOL_CALLS = 64;
42
49
  const MAX_PENDING_HOST_TOOL_BYTES = 8 * 1024 * 1024;
43
50
  const DEFAULT_INITIALIZATION_TIMEOUT_MS = 20_000;
44
51
  const DEFAULT_MCP_CALL_LIFETIME_MS = 5 * 60 * 1000;
52
+ const MAX_DIRECT_BROWSER_CALLS = 4;
53
+ const BROWSER_CALL_TIMEOUT_MS = 20_000;
45
54
 
46
55
  export type OmpMcpTool = ConnectedMcpTool;
47
56
  export type OmpMcpToolPage = ConnectedMcpToolPage;
@@ -270,42 +279,171 @@ async function discoverMcpTools(
270
279
  throw new OmpPublicError("MCP server tool-list pagination exceeds the supported limit");
271
280
  }
272
281
 
273
- function normalizeResult(result: unknown): OmpHostToolResult["result"] {
274
- if (
275
- !result ||
276
- typeof result !== "object" ||
282
+ type HostToolContentBlock = OmpHostToolResult["result"]["content"][number];
283
+
284
+ function hostToolContentIsBounded(content: readonly HostToolContentBlock[]): boolean {
285
+ return (
286
+ boundedJsonBytes(
287
+ content,
288
+ MAX_HOST_TOOL_RESULT_BYTES,
289
+ MAX_HOST_TOOL_CONTENT_BLOCKS,
290
+ MAX_HOST_TOOL_IMAGE_DATA_BYTES,
291
+ 4_096,
292
+ ) !== Number.POSITIVE_INFINITY
293
+ );
294
+ }
295
+
296
+ function noticeBlock(text: string): HostToolContentBlock {
297
+ return { type: "text", text };
298
+ }
299
+
300
+ function appendBoundedNotice(
301
+ content: readonly HostToolContentBlock[],
302
+ notice: string,
303
+ ): HostToolContentBlock[] {
304
+ const trailingNotice = content.at(-1)?.text;
305
+ const contentWasTruncated =
306
+ trailingNotice === CONTENT_TRUNCATED_NOTICE || trailingNotice === RESULT_TRUNCATED_NOTICE;
307
+ const prefix = (contentWasTruncated ? content.slice(0, -1) : content).slice(
308
+ 0,
309
+ MAX_HOST_TOOL_CONTENT_BLOCKS - 1,
310
+ );
311
+ const markerText =
312
+ notice === DETAILS_OMITTED_NOTICE && contentWasTruncated ? RESULT_TRUNCATED_NOTICE : notice;
313
+ const marker = noticeBlock(markerText);
314
+ while (prefix.length > 0 && !hostToolContentIsBounded([...prefix, marker])) prefix.pop();
315
+ return [...prefix, marker];
316
+ }
317
+
318
+ function boundedTextPrefix(
319
+ prefix: readonly HostToolContentBlock[],
320
+ block: HostToolContentBlock,
321
+ ): HostToolContentBlock | undefined {
322
+ if (block.type !== "text" || typeof block.text !== "string") return;
323
+ const marker = noticeBlock(CONTENT_TRUNCATED_NOTICE);
324
+ let low = 0;
325
+ let high = block.text.length;
326
+ let best = "";
327
+ while (low <= high) {
328
+ const middle = Math.floor((low + high) / 2);
329
+ let text = block.text.slice(0, middle);
330
+ if (/\p{Surrogate}$/u.test(text)) text = text.slice(0, -1);
331
+ const candidate = { type: "text", text } satisfies HostToolContentBlock;
332
+ if (hostToolContentIsBounded([...prefix, candidate, marker])) {
333
+ best = text;
334
+ low = middle + 1;
335
+ } else {
336
+ high = middle - 1;
337
+ }
338
+ }
339
+ return best ? { type: "text", text: best } : undefined;
340
+ }
341
+
342
+ function normalizeContent(content: readonly unknown[]): HostToolContentBlock[] {
343
+ const normalized: HostToolContentBlock[] = [];
344
+ for (const value of content) {
345
+ if (!value || typeof value !== "object" || Array.isArray(value)) {
346
+ throw new Error("MCP tool returned invalid content");
347
+ }
348
+ const block = value as Record<string, unknown>;
349
+ if (typeof block.type !== "string" || !block.type || utf8Bytes(block.type) > 256) {
350
+ throw new Error("MCP tool returned invalid content");
351
+ }
352
+
353
+ let candidate: HostToolContentBlock;
354
+ let blockWasTruncated = false;
355
+ if (block.type === "text") {
356
+ if (typeof block.text !== "string") throw new Error("MCP tool returned invalid text content");
357
+ const text = truncateUtf8(block.text, MAX_HOST_TOOL_TEXT_BYTES);
358
+ candidate = { ...block, type: "text", text } as HostToolContentBlock;
359
+ blockWasTruncated = text !== block.text;
360
+ } else if (block.type === "image") {
361
+ if (typeof block.data !== "string" || typeof block.mimeType !== "string") {
362
+ throw new Error("MCP tool returned invalid image content");
363
+ }
364
+ if (block.data.length > MAX_HOST_TOOL_IMAGE_DATA_BYTES) {
365
+ return appendBoundedNotice(normalized, CONTENT_TRUNCATED_NOTICE);
366
+ }
367
+ if (!isValidImagePayload(block.data, block.mimeType, MAX_HOST_TOOL_IMAGE_DATA_BYTES)) {
368
+ throw new Error("MCP tool returned invalid image content");
369
+ }
370
+ candidate = {
371
+ ...block,
372
+ type: "image",
373
+ data: block.data,
374
+ mimeType: block.mimeType,
375
+ } as HostToolContentBlock;
376
+ } else {
377
+ candidate = block as HostToolContentBlock;
378
+ }
379
+
380
+ if (normalized.length >= MAX_HOST_TOOL_CONTENT_BLOCKS) {
381
+ return appendBoundedNotice(normalized, CONTENT_TRUNCATED_NOTICE);
382
+ }
383
+ if (!hostToolContentIsBounded([...normalized, candidate])) {
384
+ const prefix = boundedTextPrefix(normalized, candidate);
385
+ return appendBoundedNotice(
386
+ prefix ? [...normalized, prefix] : normalized,
387
+ CONTENT_TRUNCATED_NOTICE,
388
+ );
389
+ }
390
+ normalized.push(candidate);
391
+ if (blockWasTruncated) return appendBoundedNotice(normalized, CONTENT_TRUNCATED_NOTICE);
392
+ }
393
+ return normalized;
394
+ }
395
+
396
+ function structuredContentIsBounded(value: unknown): boolean {
397
+ return (
277
398
  boundedJsonBytes(
278
- result,
399
+ value,
279
400
  MAX_HOST_TOOL_RESULT_BYTES,
280
401
  1_024,
281
402
  MAX_HOST_TOOL_RESULT_BYTES,
282
403
  4_096,
283
- ) === Number.POSITIVE_INFINITY
284
- ) {
285
- throw new Error("MCP tool returned an invalid or oversized result");
404
+ ) !== Number.POSITIVE_INFINITY
405
+ );
406
+ }
407
+
408
+ function normalizeResult(result: unknown): OmpHostToolResult["result"] {
409
+ if (!result || typeof result !== "object" || Array.isArray(result)) {
410
+ throw new Error("MCP tool returned an invalid result");
286
411
  }
287
412
  const record = result as Record<string, unknown>;
288
413
  if (Array.isArray(record.content)) {
289
414
  const details = record.structuredContent;
290
- const content =
291
- record.content.length === 0 && details !== undefined
292
- ? [
293
- {
294
- type: "text",
295
- text: truncateUtf8(JSON.stringify(details), MAX_STRUCTURED_CONTENT_FALLBACK_BYTES),
296
- },
297
- ]
298
- : record.content;
299
- return parseOmpHostToolAgentResult({
415
+ const detailsAreBounded = details === undefined || structuredContentIsBounded(details);
416
+ let content = normalizeContent(record.content);
417
+ if (content.length === 0 && details !== undefined && detailsAreBounded) {
418
+ content = [
419
+ {
420
+ type: "text",
421
+ text: truncateUtf8(JSON.stringify(details), MAX_STRUCTURED_CONTENT_FALLBACK_BYTES),
422
+ },
423
+ ];
424
+ }
425
+ if (!detailsAreBounded) content = appendBoundedNotice(content, DETAILS_OMITTED_NOTICE);
426
+
427
+ let normalized = parseOmpHostToolAgentResult({
300
428
  content,
301
- ...(details !== undefined ? { details } : {}),
429
+ ...(details !== undefined && detailsAreBounded ? { details } : {}),
302
430
  ...(typeof record.isError === "boolean" ? { isError: record.isError } : {}),
303
431
  });
432
+ if (!structuredContentIsBounded(normalized)) {
433
+ normalized = parseOmpHostToolAgentResult({
434
+ content: appendBoundedNotice(content, DETAILS_OMITTED_NOTICE),
435
+ ...(typeof record.isError === "boolean" ? { isError: record.isError } : {}),
436
+ });
437
+ }
438
+ return normalized;
304
439
  }
305
440
  if (Object.hasOwn(record, "toolResult")) {
441
+ const details = record.toolResult;
442
+ const detailsAreBounded = structuredContentIsBounded(details);
306
443
  return parseOmpHostToolAgentResult({
307
- content: [{ type: "text", text: "MCP tool completed" }],
308
- details: record.toolResult,
444
+ content: [noticeBlock(detailsAreBounded ? "MCP tool completed" : DETAILS_OMITTED_NOTICE)],
445
+ ...(detailsAreBounded ? { details } : {}),
446
+ ...(typeof record.isError === "boolean" ? { isError: record.isError } : {}),
309
447
  });
310
448
  }
311
449
  throw new Error("MCP tool returned an unsupported result");
@@ -331,6 +469,17 @@ async function settleCleanup(promises: readonly Promise<void>[]): Promise<void>
331
469
  .map((result) => result.reason);
332
470
  if (failures.length > 0) throw new AggregateError(failures, "OMP MCP cleanup failed");
333
471
  }
472
+ function paseoBrowserFailure(error: unknown): OmpPublicError {
473
+ const detail = error instanceof Error ? error.message : String(error);
474
+ if (/browser_no_host/iu.test(detail)) {
475
+ return new OmpPublicError("No Paseo desktop browser host is connected");
476
+ }
477
+ if (/browser_disabled/iu.test(detail)) {
478
+ return new OmpPublicError("Paseo browser tools are disabled on this host");
479
+ }
480
+ return new OmpPublicError("Paseo could not open the MCP authorization browser tab");
481
+ }
482
+
334
483
  export class OmpHostToolsBridge {
335
484
  private runtime: OmpRuntimeSession | null = null;
336
485
  private readonly pending = new Map<string, PendingCall>();
@@ -338,6 +487,7 @@ export class OmpHostToolsBridge {
338
487
  private generation = 0;
339
488
  private closePromise: Promise<void> | null = null;
340
489
  private fatalHandler: ((error: Error) => void) | null = null;
490
+ private activeDirectBrowserCalls = 0;
341
491
 
342
492
  readonly labels: ReadonlyMap<string, string>;
343
493
  private constructor(
@@ -515,6 +665,63 @@ export class OmpHostToolsBridge {
515
665
  onFatal(handler: (error: Error) => void): void {
516
666
  this.fatalHandler = handler;
517
667
  }
668
+ async openPaseoBrowser(url: string): Promise<void> {
669
+ let parsed: URL;
670
+ try {
671
+ parsed = new URL(url);
672
+ } catch {
673
+ throw new OmpPublicError("MCP authorization URL is invalid");
674
+ }
675
+ if (
676
+ (parsed.protocol !== "http:" && parsed.protocol !== "https:") ||
677
+ parsed.username ||
678
+ parsed.password
679
+ ) {
680
+ throw new OmpPublicError("MCP authorization URL is not safe to open");
681
+ }
682
+ if (!this.runtime || this.closePromise) {
683
+ throw new OmpPublicError("The OMP session is not ready for browser authorization");
684
+ }
685
+ const target = this.targets.get("browser_new_tab");
686
+ if (!target) {
687
+ throw new OmpPublicError("Paseo browser tools are unavailable in this OMP session");
688
+ }
689
+ if (this.activeDirectBrowserCalls >= MAX_DIRECT_BROWSER_CALLS) {
690
+ throw new OmpPublicError("Too many Paseo browser requests are already running");
691
+ }
692
+
693
+ this.activeDirectBrowserCalls += 1;
694
+ const controller = new AbortController();
695
+ const deadline = this.callScheduler.set(
696
+ () => controller.abort(new Error("Paseo browser request timed out")),
697
+ BROWSER_CALL_TIMEOUT_MS,
698
+ );
699
+ try {
700
+ const result = normalizeResult(
701
+ await target.connection.callTool(
702
+ target.toolName,
703
+ { i: "Opening MCP authorization", url },
704
+ {
705
+ signal: controller.signal,
706
+ maxTotalTimeoutMs: BROWSER_CALL_TIMEOUT_MS,
707
+ onProgress() {},
708
+ },
709
+ ),
710
+ );
711
+ if (result.isError) {
712
+ const detail = result.content
713
+ .flatMap((part) => (part.type === "text" && part.text ? [part.text] : []))
714
+ .join("\n");
715
+ throw paseoBrowserFailure(new Error(detail));
716
+ }
717
+ } catch (error) {
718
+ if (error instanceof OmpPublicError) throw error;
719
+ throw paseoBrowserFailure(error);
720
+ } finally {
721
+ this.callScheduler.clear(deadline);
722
+ this.activeDirectBrowserCalls -= 1;
723
+ }
724
+ }
518
725
 
519
726
  handle(
520
727
  event: OmpHostToolCall | { type: "host_tool_cancel"; id: string; targetId: string },
@@ -633,12 +840,73 @@ export class OmpHostToolsBridge {
633
840
  result: OmpHostToolResult,
634
841
  ): OmpHostToolResult {
635
842
  const limit = runtime.maxHostToolFrameBytes ?? 1024 * 1024;
636
- try {
637
- if (Buffer.byteLength(`${JSON.stringify(result)}\n`) <= limit) return result;
638
- } catch {
639
- // Fall through to the bounded error result.
843
+ const fits = (candidate: OmpHostToolResult) => {
844
+ try {
845
+ return Buffer.byteLength(`${JSON.stringify(candidate)}\n`) <= limit;
846
+ } catch {
847
+ return false;
848
+ }
849
+ };
850
+ if (fits(result)) return result;
851
+
852
+ const withoutDetails: OmpHostToolResult = {
853
+ ...result,
854
+ result: {
855
+ content: appendBoundedNotice(result.result.content, DETAILS_OMITTED_NOTICE),
856
+ ...(result.result.isError !== undefined ? { isError: result.result.isError } : {}),
857
+ },
858
+ };
859
+ if (result.result.details !== undefined && fits(withoutDetails)) return withoutDetails;
860
+
861
+ const trailingNotice = result.result.content.at(-1)?.text;
862
+ const detailsWereOmitted =
863
+ trailingNotice === DETAILS_OMITTED_NOTICE || trailingNotice === RESULT_TRUNCATED_NOTICE;
864
+ const contentWasTruncated =
865
+ trailingNotice === CONTENT_TRUNCATED_NOTICE || trailingNotice === RESULT_TRUNCATED_NOTICE;
866
+ const marker = noticeBlock(
867
+ result.result.details !== undefined || detailsWereOmitted
868
+ ? RESULT_TRUNCATED_NOTICE
869
+ : CONTENT_TRUNCATED_NOTICE,
870
+ );
871
+ const sourceContent =
872
+ detailsWereOmitted || contentWasTruncated
873
+ ? result.result.content.slice(0, -1)
874
+ : result.result.content;
875
+ const boundedContent: HostToolContentBlock[] = [];
876
+ const terminalWith = (content: HostToolContentBlock[]): OmpHostToolResult => ({
877
+ ...result,
878
+ result: {
879
+ content,
880
+ ...(result.result.isError !== undefined ? { isError: result.result.isError } : {}),
881
+ },
882
+ });
883
+ for (const block of sourceContent) {
884
+ const candidate = terminalWith([...boundedContent, block, marker]);
885
+ if (fits(candidate)) {
886
+ boundedContent.push(block);
887
+ continue;
888
+ }
889
+ if (block.type === "text" && typeof block.text === "string") {
890
+ let low = 0;
891
+ let high = block.text.length;
892
+ let best = "";
893
+ while (low <= high) {
894
+ const middle = Math.floor((low + high) / 2);
895
+ let text = block.text.slice(0, middle);
896
+ if (/\p{Surrogate}$/u.test(text)) text = text.slice(0, -1);
897
+ if (fits(terminalWith([...boundedContent, { type: "text", text }, marker]))) {
898
+ best = text;
899
+ low = middle + 1;
900
+ } else {
901
+ high = middle - 1;
902
+ }
903
+ }
904
+ if (best) boundedContent.push({ type: "text", text: best });
905
+ }
906
+ break;
640
907
  }
641
- return errorResult(result.id, OMP_HOST_TOOL_FRAME_LIMIT_ERROR);
908
+ const bounded = terminalWith([...boundedContent, marker]);
909
+ return fits(bounded) ? bounded : errorResult(result.id, OMP_HOST_TOOL_FRAME_LIMIT_ERROR);
642
910
  }
643
911
 
644
912
  private sendTerminal(