@omercnet/paseo-omp 0.3.0 → 0.4.0-next.119.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 (43) hide show
  1. package/README.md +19 -5
  2. package/client/omp-config-surface.tsx +243 -24
  3. package/client/omp-config-views.ts +24 -0
  4. package/client/omp-model-picker-state.ts +145 -0
  5. package/client/omp-model-picker.tsx +282 -0
  6. package/client/omp-routing-editor.tsx +307 -0
  7. package/client/support-diagnostics-state.ts +45 -0
  8. package/index.server.ts +27 -2
  9. package/package.json +4 -8
  10. package/paseo-plugin.json +1 -1
  11. package/server/omp-models.ts +59 -0
  12. package/server/omp-settings.ts +30 -20
  13. package/server/operational-failure-diagnostics.ts +76 -0
  14. package/server/package-version.ts +2 -0
  15. package/server/protocol-violation-diagnostics.ts +169 -0
  16. package/server/provider/catalog.ts +39 -10
  17. package/server/provider/connection.ts +88 -9
  18. package/server/provider/host-tools.ts +284 -34
  19. package/server/provider/mcp-transport.ts +2 -1
  20. package/server/provider/omp-rpc.ts +1785 -267
  21. package/server/provider/profile-providers.ts +7 -2
  22. package/server/provider/registration.ts +12 -2
  23. package/server/provider/security.ts +8 -10
  24. package/server/provider/session-descriptors.ts +97 -29
  25. package/server/provider/session.ts +212 -59
  26. package/server/provider/subsessions.ts +334 -74
  27. package/server/provider/timeline-projector.ts +125 -32
  28. package/server/support-diagnostics.ts +284 -0
  29. package/shared/omp-models.ts +49 -0
  30. package/shared/omp-settings.ts +227 -3
  31. package/shared/support-diagnostics.ts +32 -0
  32. package/CHANGELOG.md +0 -113
  33. package/SUPPORT.md +0 -44
  34. package/TESTING.md +0 -150
  35. package/docs/alpha-release-checklist.md +0 -68
  36. package/docs/configuration.md +0 -126
  37. package/docs/core-provider-issue-audit.md +0 -109
  38. package/docs/images/mcp-authorization-compact.png +0 -0
  39. package/docs/images/mcp-controls-wide.png +0 -0
  40. package/docs/images/plugin-manager.png +0 -0
  41. package/docs/images/workspace-settings.png +0 -0
  42. package/docs/installation.md +0 -89
  43. package/tsconfig.json +0 -16
@@ -43,7 +43,7 @@ function profileDirectory(environment: NodeJS.ProcessEnv): string {
43
43
  );
44
44
  }
45
45
 
46
- /** Paseo 0.8 contribution registration is synchronous; inspect directory names only. */
46
+ /** Contribution registration is synchronous; inspect directory names only. */
47
47
  export function discoverOmpProfilesSync(environment: NodeJS.ProcessEnv = process.env): string[] {
48
48
  let directory: Dir;
49
49
  try {
@@ -151,7 +151,12 @@ export function createProfileOmpProvider(profile: string, options: OmpProviderOp
151
151
  const id = profileProviderId(profile);
152
152
  const environment = fixedEnvironment(profile, options.environment ?? process.env);
153
153
  const sessionDir = resolve(ompSessionDir(environment));
154
- const runtime = options.runtime ?? new OmpRpcRuntime({ environment });
154
+ const runtime =
155
+ options.runtime ??
156
+ new OmpRpcRuntime({
157
+ environment,
158
+ reportProtocolViolation: options.reportProtocolViolation,
159
+ });
155
160
  const readPersistedSessionTranscript = runtime.readPersistedSessionTranscript?.bind(runtime);
156
161
  const assertSessionDir = (requested?: string) => {
157
162
  if (requested !== undefined && resolve(requested) !== sessionDir) {
@@ -3,10 +3,11 @@ import { homedir } from "node:os";
3
3
  import type { ProviderRegistration } from "@getpaseo/plugin/server/provider";
4
4
  import { z } from "zod";
5
5
  import type { OmpBrowserAuthorizationRegistry } from "../mcp-browser";
6
+ import type { OmpOperationalFailureReporter } from "../operational-failure-diagnostics";
6
7
  import { probeOmpAvailability } from "../provider-diagnostics";
7
8
  import { createOmpConnection, OmpNativeSessionReservations } from "./connection";
8
9
  import type { OmpMcpConnector } from "./host-tools";
9
- import { OmpRpcRuntime, type OmpRuntime } from "./omp-rpc";
10
+ import { type OmpProtocolViolationDiagnostic, OmpRpcRuntime, type OmpRuntime } from "./omp-rpc";
10
11
  import { parseOmpProviderOptions } from "./provider-options";
11
12
  import { boundedJsonBytes } from "./security";
12
13
  import { OmpProviderOptionsSchema } from "./settings";
@@ -66,6 +67,8 @@ export interface OmpProviderOptions {
66
67
  mcpInitializationTimeoutMs?: number;
67
68
  mcpConnector?: OmpMcpConnector;
68
69
  browserAuthorizationRegistry?: OmpBrowserAuthorizationRegistry;
70
+ reportProtocolViolation?: (diagnostic: OmpProtocolViolationDiagnostic) => void | Promise<void>;
71
+ reportOperationalFailure?: OmpOperationalFailureReporter;
69
72
  availabilityProbe?: (
70
73
  options: ProviderCatalogOptionsCompat,
71
74
  timeoutMs: number | undefined,
@@ -85,7 +88,12 @@ function stableJson(value: unknown): string {
85
88
  }
86
89
 
87
90
  export function createOmpProvider(options: OmpProviderOptions = {}): ProviderRegistrationCompat {
88
- const runtime = options.runtime ?? new OmpRpcRuntime({ environment: options.environment });
91
+ const runtime =
92
+ options.runtime ??
93
+ new OmpRpcRuntime({
94
+ environment: options.environment,
95
+ reportProtocolViolation: options.reportProtocolViolation,
96
+ });
89
97
  const nativeReservations = new OmpNativeSessionReservations();
90
98
  return {
91
99
  id: "omp-plugin",
@@ -156,6 +164,8 @@ export function createOmpProvider(options: OmpProviderOptions = {}): ProviderReg
156
164
  options.mcpInitializationTimeoutMs,
157
165
  options.replayTimeoutMs,
158
166
  options.browserAuthorizationRegistry,
167
+ undefined,
168
+ options.reportOperationalFailure,
159
169
  );
160
170
  },
161
171
  };
@@ -83,16 +83,14 @@ function truncateJsonString(value: string, maxBytes: number): string {
83
83
  export function truncateUtf8(value: string, maxBytes: number): string {
84
84
  if (utf8Bytes(value) <= maxBytes) return value;
85
85
  const suffix = "<truncated>";
86
- const budget = Math.max(0, maxBytes - utf8Bytes(suffix));
87
- let output = "";
88
- let bytes = 0;
89
- for (const character of value) {
90
- const characterBytes = utf8Bytes(character);
91
- if (bytes + characterBytes > budget) break;
92
- output += character;
93
- bytes += characterBytes;
94
- }
95
- return `${output}${suffix}`;
86
+ const suffixBytes = utf8Bytes(suffix);
87
+ if (maxBytes <= 0) return "";
88
+ if (maxBytes < suffixBytes) return suffix.slice(0, Math.floor(maxBytes));
89
+
90
+ const bytes = Buffer.from(value, "utf8");
91
+ let end = Math.min(maxBytes - suffixBytes, bytes.byteLength);
92
+ while (end > 0 && (bytes[end] & 0xc0) === 0x80) end -= 1;
93
+ return `${bytes.subarray(0, end).toString("utf8")}${suffix}`;
96
94
  }
97
95
 
98
96
  export interface BoundedJsonMetrics {
@@ -2,8 +2,9 @@ import { createHash } from "node:crypto";
2
2
  import { constants, type Dir } from "node:fs";
3
3
  import { type FileHandle, lstat, open, opendir, realpath } from "node:fs/promises";
4
4
  import { homedir } from "node:os";
5
- import { basename, dirname, extname, isAbsolute, join, resolve } from "node:path";
5
+ import { basename, isAbsolute, join, relative, resolve } from "node:path";
6
6
  import { ompSessionDir } from "../paths";
7
+ import { isValidImagePayload } from "./image";
7
8
 
8
9
  const MAX_DESCRIPTOR_PREFIX_BYTES = 64 * 1024;
9
10
  const MAX_DESCRIPTOR_SUFFIX_BYTES = 64 * 1024;
@@ -48,6 +49,7 @@ export interface OmpPersistedSessionTranscript {
48
49
  nativeSessionId: string;
49
50
  byteLength: number;
50
51
  messages: unknown[];
52
+ imageReplayWarning?: true;
51
53
  }
52
54
  export interface OmpSessionListOptions {
53
55
  cwd?: string;
@@ -66,6 +68,11 @@ interface ScanBudget {
66
68
  exhausted: boolean;
67
69
  }
68
70
 
71
+ interface BlobReplayBudget {
72
+ bytes: number;
73
+ imageReplayWarning?: true;
74
+ }
75
+
69
76
  export function validateNativeSessionId(value: unknown): string {
70
77
  if (typeof value !== "string" || !NATIVE_SESSION_ID.test(value)) {
71
78
  throw new Error("Invalid OMP session identifier");
@@ -156,6 +163,7 @@ async function yieldToEventLoop(): Promise<void> {
156
163
  setImmediate(result.resolve);
157
164
  await result.promise;
158
165
  }
166
+ const UNAVAILABLE_IMAGE_MARKER = "[Image unavailable during session replay]";
159
167
  async function readStableFile(
160
168
  handle: FileHandle,
161
169
  byteLength: number,
@@ -213,6 +221,7 @@ async function hydrateBlobImageData(
213
221
  ) {
214
222
  throw new Error("OMP transcript image blob failed ownership or size validation");
215
223
  }
224
+ budget.bytes += stat.size;
216
225
  const bytes = await readStableFile(
217
226
  handle,
218
227
  stat.size,
@@ -222,7 +231,6 @@ async function hydrateBlobImageData(
222
231
  if (createHash("sha256").update(bytes).digest("hex") !== hash) {
223
232
  throw new Error("OMP transcript image blob failed integrity validation");
224
233
  }
225
- budget.bytes += bytes.byteLength;
226
234
  return bytes.toString("base64");
227
235
  } finally {
228
236
  await handle.close().catch(() => undefined);
@@ -232,11 +240,13 @@ async function hydrateBlobImageData(
232
240
  async function hydrateImageParts(
233
241
  value: unknown,
234
242
  blobDirectory: string,
235
- budget: { bytes: number },
243
+ budget: BlobReplayBudget,
244
+ failureMode: "marker" | "omit",
236
245
  signal?: AbortSignal,
237
246
  ): Promise<unknown> {
238
247
  if (!Array.isArray(value)) return value;
239
248
  let hydrated: unknown[] | undefined;
249
+ let omitted: boolean[] | undefined;
240
250
  for (let index = 0; index < value.length; index += 1) {
241
251
  const part = value[index];
242
252
  if (
@@ -251,30 +261,53 @@ async function hydrateImageParts(
251
261
  ) {
252
262
  continue;
253
263
  }
254
- const data = await hydrateBlobImageData(part.data, blobDirectory, budget, signal);
255
- hydrated ??= [...value];
256
- hydrated[index] = { ...part, data };
264
+ try {
265
+ const data = await hydrateBlobImageData(part.data, blobDirectory, budget, signal);
266
+ const mimeType = "mimeType" in part ? part.mimeType : undefined;
267
+ if (typeof mimeType !== "string" || !isValidImagePayload(data, mimeType, data.length)) {
268
+ throw new Error("OMP transcript image blob failed MIME validation");
269
+ }
270
+ hydrated ??= [...value];
271
+ hydrated[index] = { ...part, data };
272
+ } catch {
273
+ signal?.throwIfAborted();
274
+ budget.imageReplayWarning = true;
275
+ hydrated ??= [...value];
276
+ if (failureMode === "marker") {
277
+ hydrated[index] = { type: "text", text: UNAVAILABLE_IMAGE_MARKER };
278
+ } else {
279
+ omitted ??= [];
280
+ omitted[index] = true;
281
+ }
282
+ }
257
283
  }
258
- return hydrated ?? value;
284
+ if (!hydrated) return value;
285
+ return omitted ? hydrated.filter((_, index) => !omitted[index]) : hydrated;
259
286
  }
260
287
 
261
288
  async function hydratePersistedMessageImages(
262
289
  message: unknown,
263
290
  blobDirectory: string | undefined,
264
- budget: { bytes: number },
291
+ budget: BlobReplayBudget,
265
292
  signal?: AbortSignal,
266
293
  ): Promise<unknown> {
267
294
  if (!blobDirectory || !message || typeof message !== "object" || Array.isArray(message)) {
268
295
  return message;
269
296
  }
270
297
  const record = message as Record<string, unknown>;
271
- let content = await hydrateImageParts(record.content, blobDirectory, budget, signal);
298
+ let content = await hydrateImageParts(record.content, blobDirectory, budget, "marker", signal);
272
299
  if (content && typeof content === "object" && !Array.isArray(content)) {
273
300
  const contentRecord = content as Record<string, unknown>;
274
- const nested = await hydrateImageParts(contentRecord.content, blobDirectory, budget, signal);
301
+ const nested = await hydrateImageParts(
302
+ contentRecord.content,
303
+ blobDirectory,
304
+ budget,
305
+ "marker",
306
+ signal,
307
+ );
275
308
  if (nested !== contentRecord.content) content = { ...contentRecord, content: nested };
276
309
  }
277
- const images = await hydrateImageParts(record.images, blobDirectory, budget, signal);
310
+ const images = await hydrateImageParts(record.images, blobDirectory, budget, "omit", signal);
278
311
  if (content === record.content && images === record.images) return message;
279
312
  return { ...record, content, images };
280
313
  }
@@ -477,16 +510,19 @@ interface PersistedTranscriptNode {
477
510
  message?: unknown;
478
511
  }
479
512
 
480
- function persistedTranscriptMessage(record: Record<string, unknown>, entryId: string): unknown {
513
+ function persistedTranscriptMessage(record: Record<string, unknown>, entryId?: string): unknown {
481
514
  if (record.type === "message") {
482
515
  if (!record.message || typeof record.message !== "object" || Array.isArray(record.message))
483
516
  return;
484
- return { ...(record.message as Record<string, unknown>), entryId };
517
+ return {
518
+ ...(record.message as Record<string, unknown>),
519
+ ...(entryId ? { entryId } : {}),
520
+ };
485
521
  }
486
522
  if (record.type !== "custom_message") return;
487
523
  return {
488
524
  role: "custom",
489
- entryId,
525
+ ...(entryId ? { entryId } : {}),
490
526
  customType: record.customType,
491
527
  content: record.content,
492
528
  display: record.display,
@@ -620,7 +656,7 @@ export async function readOmpPersistedSessionTranscript(
620
656
  throw new Error("OMP session transcript exceeds message limits");
621
657
  }
622
658
  const hydratedMessages: unknown[] = [];
623
- const blobBudget = { bytes: 0 };
659
+ const blobBudget: BlobReplayBudget = { bytes: 0 };
624
660
  for (const message of messages) {
625
661
  signal?.throwIfAborted();
626
662
  hydratedMessages.push(
@@ -632,6 +668,7 @@ export async function readOmpPersistedSessionTranscript(
632
668
  nativeSessionId,
633
669
  byteLength: bytes.byteLength,
634
670
  messages: hydratedMessages,
671
+ ...(blobBudget.imageReplayWarning ? { imageReplayWarning: true as const } : {}),
635
672
  };
636
673
  } catch (error) {
637
674
  if (error instanceof Error) throw error;
@@ -646,6 +683,7 @@ export async function readOmpPersistedSubagentTranscript(
646
683
  childTranscriptId: string,
647
684
  cwd: string,
648
685
  signal?: AbortSignal,
686
+ childSessionFile?: string,
649
687
  ): Promise<OmpPersistedSubagentTranscript> {
650
688
  signal?.throwIfAborted();
651
689
  if (
@@ -653,7 +691,12 @@ export async function readOmpPersistedSubagentTranscript(
653
691
  !parentSessionFile.endsWith(".jsonl") ||
654
692
  parentSessionFile.includes("\0") ||
655
693
  !CHILD_TRANSCRIPT_ID.test(childTranscriptId) ||
656
- basename(childTranscriptId) !== childTranscriptId
694
+ basename(childTranscriptId) !== childTranscriptId ||
695
+ (childSessionFile !== undefined &&
696
+ (!isAbsolute(childSessionFile) ||
697
+ !childSessionFile.endsWith(".jsonl") ||
698
+ childSessionFile.includes("\0") ||
699
+ basename(childSessionFile) !== `${childTranscriptId}.jsonl`))
657
700
  ) {
658
701
  throw new Error("Invalid OMP child transcript descriptor");
659
702
  }
@@ -673,34 +716,53 @@ export async function readOmpPersistedSubagentTranscript(
673
716
  await parentHandle.close().catch(() => undefined);
674
717
  }
675
718
  const canonicalParent = await realpath(parentSessionFile);
676
- const parentExtension = extname(canonicalParent);
677
- const expectedDirectory = canonicalParent.slice(0, -parentExtension.length);
719
+ const expectedDirectory = canonicalParent.slice(0, -".jsonl".length);
678
720
  const canonicalDirectory = await realpath(expectedDirectory).catch(() => undefined);
679
721
  if (!canonicalDirectory || canonicalDirectory !== expectedDirectory) {
680
722
  throw new Error("OMP child transcript directory is not canonically owned by its parent");
681
723
  }
682
- const sessionFile = join(canonicalDirectory, `${childTranscriptId}.jsonl`);
724
+ const requestedFile = resolve(
725
+ childSessionFile ?? join(canonicalDirectory, `${childTranscriptId}.jsonl`),
726
+ );
683
727
  let handle: FileHandle;
684
728
  try {
685
729
  handle = await open(
686
- sessionFile,
730
+ requestedFile,
687
731
  constants.O_RDONLY | (constants.O_NOFOLLOW ?? 0) | (constants.O_NONBLOCK ?? 0),
688
732
  );
689
733
  } catch {
690
734
  throw new Error("OMP child transcript could not be opened");
691
735
  }
692
736
  try {
693
- const [stat, canonicalChild] = await Promise.all([handle.stat(), realpath(sessionFile)]);
737
+ const [stat, pathStat, canonicalChild] = await Promise.all([
738
+ handle.stat(),
739
+ lstat(requestedFile),
740
+ realpath(requestedFile),
741
+ ]);
694
742
  if (
695
743
  !stat.isFile() ||
696
- stat.size > MAX_CHILD_TRANSCRIPT_BYTES ||
697
- dirname(canonicalChild) !== canonicalDirectory ||
698
- canonicalChild !== sessionFile
744
+ !pathStat.isFile() ||
745
+ pathStat.isSymbolicLink() ||
746
+ stat.dev !== pathStat.dev ||
747
+ stat.ino !== pathStat.ino ||
748
+ stat.size > MAX_CHILD_TRANSCRIPT_BYTES
699
749
  ) {
700
750
  throw new Error("OMP child transcript failed ownership validation");
701
751
  }
702
- const bytes = await handle.readFile();
703
- signal?.throwIfAborted();
752
+ const canonicalRelativeChild = relative(canonicalDirectory, canonicalChild);
753
+ if (
754
+ !canonicalRelativeChild ||
755
+ canonicalRelativeChild.startsWith("..") ||
756
+ isAbsolute(canonicalRelativeChild)
757
+ ) {
758
+ throw new Error("OMP child transcript failed ownership validation");
759
+ }
760
+ const bytes = await readStableFile(
761
+ handle,
762
+ stat.size,
763
+ signal,
764
+ "OMP child transcript changed while reading",
765
+ );
704
766
  const text = new TextDecoder("utf-8", { fatal: true }).decode(bytes);
705
767
  const messages: unknown[] = [];
706
768
  let nativeSessionId: string | undefined;
@@ -718,15 +780,21 @@ export async function readOmpPersistedSubagentTranscript(
718
780
  nativeSessionId ??= candidateId;
719
781
  if (nativeSessionId !== candidateId)
720
782
  throw new Error("OMP child transcript identity changed");
721
- } else if (record.type === "message" && record.message !== undefined) {
783
+ } else {
784
+ const entryId =
785
+ typeof record.id === "string" && CHILD_TRANSCRIPT_ID.test(record.id)
786
+ ? record.id
787
+ : undefined;
788
+ const message = persistedTranscriptMessage(record, entryId);
789
+ if (message === undefined) continue;
722
790
  if (messages.length >= MAX_CHILD_TRANSCRIPT_MESSAGES) {
723
791
  throw new Error("OMP child transcript exceeds message limits");
724
792
  }
725
- messages.push(record.message);
793
+ messages.push(message);
726
794
  }
727
795
  }
728
796
  if (!nativeSessionId) throw new Error("OMP child transcript is missing session identity");
729
- return { sessionFile, nativeSessionId, byteLength: bytes.byteLength, messages };
797
+ return { sessionFile: canonicalChild, nativeSessionId, byteLength: bytes.byteLength, messages };
730
798
  } catch (error) {
731
799
  if (error instanceof Error) throw error;
732
800
  throw new Error("OMP child transcript could not be decoded");