@omercnet/paseo-omp 0.4.0-next.114.1 → 0.4.0-next.120.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.
@@ -4,7 +4,8 @@ import { homedir } from "node:os";
4
4
  import { basename, join, resolve } from "node:path";
5
5
  import { isOmpProfileName } from "../../shared/omp-store";
6
6
  import { ompAgentDir, ompCacheDir, ompDataDir, ompSessionDir, ompStateDir } from "../paths";
7
- import { OmpRpcRuntime, type OmpRuntime, type OmpStartOptions } from "./omp-rpc";
7
+ import { OmpRpcRuntime, type OmpRuntime } from "./omp-rpc";
8
+ import type { OmpStartOptions } from "./omp-rpc-environment";
8
9
  import { parseOmpProviderOptions } from "./provider-options";
9
10
  import { createOmpProvider, type OmpProviderOptions } from "./registration";
10
11
  import { OmpPublicError } from "./security";
@@ -0,0 +1,247 @@
1
+ import type { ProviderContent, ProviderInput } from "@getpaseo/plugin/server/provider";
2
+ import { getForgeDefinitionOrNeutral } from "@getpaseo/protocol/forge-manifest";
3
+ import { isValidImagePayload } from "./image";
4
+ import type { OmpImage } from "./omp-rpc";
5
+ import { OmpPublicError, utf8Bytes } from "./security";
6
+
7
+ type SessionPromptInput = Extract<ProviderInput, { type: "session.prompt" }>;
8
+ const MAX_PROMPT_PARTS = 64;
9
+ export const MAX_PROMPT_TEXT_LENGTH = 1024 * 1024;
10
+ const RPC_REQUEST_ID_BYTES = 36;
11
+
12
+ export function isSafeCommandName(name: string): boolean {
13
+ return /^[A-Za-z0-9][A-Za-z0-9._-]*(?::[A-Za-z0-9][A-Za-z0-9._-]*)*$/u.test(name);
14
+ }
15
+
16
+ export type OmpPromptPayload = { text: string; images: OmpImage[]; commandName?: string };
17
+
18
+ const REVIEW_LINE_MARKERS = { add: "+", remove: "-", context: " " } as const;
19
+
20
+ function renderPromptAttachmentAsText(part: Exclude<ProviderContent, { type: "image" }>): string {
21
+ switch (part.type) {
22
+ case "forge_change_request": {
23
+ return renderChangeRequestAttachment({
24
+ forge: part.forge ?? "github",
25
+ number: part.number,
26
+ title: part.title,
27
+ url: part.url,
28
+ body: part.body,
29
+ projectPath: part.projectPath,
30
+ baseRefName: part.baseRefName,
31
+ headRefName: part.headRefName,
32
+ });
33
+ }
34
+ case "github_pr": {
35
+ return renderChangeRequestAttachment({
36
+ forge: "github",
37
+ number: part.number,
38
+ title: part.title,
39
+ url: part.url,
40
+ body: part.body,
41
+ baseRefName: part.baseRefName,
42
+ headRefName: part.headRefName,
43
+ });
44
+ }
45
+ case "forge_issue": {
46
+ return renderIssueAttachment({
47
+ forge: part.forge ?? "github",
48
+ number: part.number,
49
+ title: part.title,
50
+ url: part.url,
51
+ body: part.body,
52
+ projectPath: part.projectPath,
53
+ });
54
+ }
55
+ case "github_issue": {
56
+ return renderIssueAttachment({
57
+ forge: "github",
58
+ number: part.number,
59
+ title: part.title,
60
+ url: part.url,
61
+ body: part.body,
62
+ });
63
+ }
64
+ case "text": {
65
+ return part.text;
66
+ }
67
+ case "review": {
68
+ const lines = [`Paseo review attachment (${part.mode})`, `CWD: ${part.cwd}`];
69
+ if (part.baseRef) {
70
+ lines.push(`Base: ${part.baseRef}`);
71
+ }
72
+ part.comments.forEach((comment, index) => {
73
+ lines.push(
74
+ "",
75
+ `Comment ${index + 1}: ${comment.filePath}:${comment.side}:${comment.lineNumber}`,
76
+ comment.body,
77
+ comment.context.hunkHeader,
78
+ );
79
+ const target = comment.context.targetLine;
80
+ for (const line of comment.context.lines) {
81
+ const isTarget =
82
+ line.oldLineNumber === target.oldLineNumber &&
83
+ line.newLineNumber === target.newLineNumber &&
84
+ line.type === target.type &&
85
+ line.content === target.content;
86
+ const prefix = isTarget ? "> " : " ";
87
+ const oldLn = padLineNumber(line.oldLineNumber);
88
+ const newLn = padLineNumber(line.newLineNumber);
89
+ lines.push(`${prefix}${oldLn} ${newLn} ${REVIEW_LINE_MARKERS[line.type]}${line.content}`);
90
+ }
91
+ });
92
+ return lines.join("\n");
93
+ }
94
+ case "uploaded_file": {
95
+ return [
96
+ `Uploaded file: ${part.fileName}`,
97
+ `Path: ${part.path}`,
98
+ `MIME: ${part.mimeType}`,
99
+ `Size: ${part.size} bytes`,
100
+ ].join("\n");
101
+ }
102
+ default:
103
+ throw new Error("unreachable");
104
+ }
105
+ }
106
+
107
+ function renderChangeRequestAttachment(input: {
108
+ forge: string;
109
+ number: number;
110
+ title: string;
111
+ url: string;
112
+ body?: string | null;
113
+ projectPath?: string;
114
+ baseRefName?: string | null;
115
+ headRefName?: string | null;
116
+ }): string {
117
+ const lines = [
118
+ `${formatForgeLabel(input.forge)} ${formatChangeRequestAbbrev(input.forge)} ${formatChangeRequestNumber(input.forge, input.number)}: ${input.title}`,
119
+ input.url,
120
+ ];
121
+ if (input.projectPath) {
122
+ lines.push(`Project: ${input.projectPath}`);
123
+ }
124
+ if (input.baseRefName) {
125
+ lines.push(`Base: ${input.baseRefName}`);
126
+ }
127
+ if (input.headRefName) {
128
+ lines.push(`Head: ${input.headRefName}`);
129
+ }
130
+ if (input.body) {
131
+ lines.push("", input.body);
132
+ }
133
+ return lines.join("\n");
134
+ }
135
+
136
+ function renderIssueAttachment(input: {
137
+ forge: string;
138
+ number: number;
139
+ title: string;
140
+ url: string;
141
+ body?: string | null;
142
+ projectPath?: string;
143
+ }): string {
144
+ const lines = [
145
+ `${formatForgeLabel(input.forge)} Issue ${formatIssueNumber(input.forge, input.number)}: ${input.title}`,
146
+ input.url,
147
+ ];
148
+ if (input.projectPath) {
149
+ lines.push(`Project: ${input.projectPath}`);
150
+ }
151
+ if (input.body) {
152
+ lines.push("", input.body);
153
+ }
154
+ return lines.join("\n");
155
+ }
156
+
157
+ function formatForgeLabel(forge: string): string {
158
+ return getForgeDefinitionOrNeutral(forge).displayName;
159
+ }
160
+
161
+ function formatChangeRequestAbbrev(forge: string): string {
162
+ return getForgeDefinitionOrNeutral(forge).changeRequestAbbrev;
163
+ }
164
+
165
+ function formatChangeRequestNumber(forge: string, number: number): string {
166
+ return `${getForgeDefinitionOrNeutral(forge).changeRequestNumberPrefix}${number}`;
167
+ }
168
+
169
+ function formatIssueNumber(forge: string, number: number): string {
170
+ return `${getForgeDefinitionOrNeutral(forge).issueNumberPrefix}${number}`;
171
+ }
172
+
173
+ function padLineNumber(lineNumber: number | null): string {
174
+ return (lineNumber?.toString() ?? "-").padStart(2);
175
+ }
176
+
177
+ export function promptPayload(input: SessionPromptInput): OmpPromptPayload {
178
+ if (input.prompt.outputSchema !== undefined || input.prompt.clearPendingPermissions) {
179
+ throw new OmpPublicError("OMP does not support structured output or permission controls");
180
+ }
181
+ if (input.prompt.input.type === "command") {
182
+ const name = input.prompt.input.name.trim();
183
+ if (!isSafeCommandName(name)) throw new OmpPublicError("Invalid OMP command name");
184
+ const argumentsText = input.prompt.input.arguments.trim();
185
+ const text = `/${name}${argumentsText ? ` ${argumentsText}` : ""}`;
186
+ if (utf8Bytes(text) > MAX_PROMPT_TEXT_LENGTH)
187
+ throw new OmpPublicError("OMP command is too large");
188
+ return { text, images: [], commandName: name };
189
+ }
190
+ if (input.prompt.input.content.length > MAX_PROMPT_PARTS) {
191
+ throw new OmpPublicError("OMP prompt has too many content parts");
192
+ }
193
+ const parts: string[] = [];
194
+ const images: OmpImage[] = [];
195
+ let length = 0;
196
+ const appendText = (text: string) => {
197
+ length += utf8Bytes(text) + (parts.length > 0 ? 2 : 0);
198
+ if (length > MAX_PROMPT_TEXT_LENGTH) throw new OmpPublicError("OMP prompt is too large");
199
+ parts.push(text);
200
+ };
201
+ for (const part of input.prompt.input.content) {
202
+ if (part.type === "text") {
203
+ appendText(part.text);
204
+ continue;
205
+ }
206
+ if (part.type === "image") {
207
+ if (!isValidImagePayload(part.data, part.mimeType, 8 * 1024 * 1024)) {
208
+ throw new OmpPublicError("OMP prompt image is invalid");
209
+ }
210
+ images.push({ type: "image", data: part.data, mimeType: part.mimeType });
211
+ continue;
212
+ }
213
+ appendText(renderPromptAttachmentAsText(part));
214
+ }
215
+ const text = parts.join("\n\n").trim();
216
+ if (!text && images.length === 0) throw new OmpPublicError("OMP prompt cannot be empty");
217
+ return { text, images };
218
+ }
219
+
220
+ export function inlinePromptFrameBytes(
221
+ payload: OmpPromptPayload,
222
+ delivery: "prompt" | "steer",
223
+ ): number {
224
+ const images = payload.images.map(({ type, mimeType }) => ({ type, data: "", mimeType }));
225
+ const frame = {
226
+ type: delivery,
227
+ message: payload.text,
228
+ ...(images.length > 0 ? { images } : {}),
229
+ ...(delivery === "prompt" ? { id: "" } : {}),
230
+ };
231
+ const requestIdBytes = delivery === "prompt" ? RPC_REQUEST_ID_BYTES : 0;
232
+ return (
233
+ utf8Bytes(JSON.stringify(frame)) +
234
+ requestIdBytes +
235
+ payload.images.reduce((total, image) => total + image.data.length, 0) +
236
+ 1
237
+ );
238
+ }
239
+
240
+ export function slashCommandName(text: string): string | undefined {
241
+ if (!text.startsWith("/")) return undefined;
242
+ const body = text.slice(1);
243
+ if (!body) return undefined;
244
+ const firstWhitespace = body.search(/\s/u);
245
+ const name = firstWhitespace === -1 ? body : body.slice(0, firstWhitespace);
246
+ return name || undefined;
247
+ }
@@ -2,7 +2,7 @@ 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
7
  import { isValidImagePayload } from "./image";
8
8
 
@@ -510,16 +510,19 @@ interface PersistedTranscriptNode {
510
510
  message?: unknown;
511
511
  }
512
512
 
513
- function persistedTranscriptMessage(record: Record<string, unknown>, entryId: string): unknown {
513
+ function persistedTranscriptMessage(record: Record<string, unknown>, entryId?: string): unknown {
514
514
  if (record.type === "message") {
515
515
  if (!record.message || typeof record.message !== "object" || Array.isArray(record.message))
516
516
  return;
517
- return { ...(record.message as Record<string, unknown>), entryId };
517
+ return {
518
+ ...(record.message as Record<string, unknown>),
519
+ ...(entryId ? { entryId } : {}),
520
+ };
518
521
  }
519
522
  if (record.type !== "custom_message") return;
520
523
  return {
521
524
  role: "custom",
522
- entryId,
525
+ ...(entryId ? { entryId } : {}),
523
526
  customType: record.customType,
524
527
  content: record.content,
525
528
  display: record.display,
@@ -680,6 +683,7 @@ export async function readOmpPersistedSubagentTranscript(
680
683
  childTranscriptId: string,
681
684
  cwd: string,
682
685
  signal?: AbortSignal,
686
+ childSessionFile?: string,
683
687
  ): Promise<OmpPersistedSubagentTranscript> {
684
688
  signal?.throwIfAborted();
685
689
  if (
@@ -687,7 +691,12 @@ export async function readOmpPersistedSubagentTranscript(
687
691
  !parentSessionFile.endsWith(".jsonl") ||
688
692
  parentSessionFile.includes("\0") ||
689
693
  !CHILD_TRANSCRIPT_ID.test(childTranscriptId) ||
690
- 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`))
691
700
  ) {
692
701
  throw new Error("Invalid OMP child transcript descriptor");
693
702
  }
@@ -707,34 +716,53 @@ export async function readOmpPersistedSubagentTranscript(
707
716
  await parentHandle.close().catch(() => undefined);
708
717
  }
709
718
  const canonicalParent = await realpath(parentSessionFile);
710
- const parentExtension = extname(canonicalParent);
711
- const expectedDirectory = canonicalParent.slice(0, -parentExtension.length);
719
+ const expectedDirectory = canonicalParent.slice(0, -".jsonl".length);
712
720
  const canonicalDirectory = await realpath(expectedDirectory).catch(() => undefined);
713
721
  if (!canonicalDirectory || canonicalDirectory !== expectedDirectory) {
714
722
  throw new Error("OMP child transcript directory is not canonically owned by its parent");
715
723
  }
716
- const sessionFile = join(canonicalDirectory, `${childTranscriptId}.jsonl`);
724
+ const requestedFile = resolve(
725
+ childSessionFile ?? join(canonicalDirectory, `${childTranscriptId}.jsonl`),
726
+ );
717
727
  let handle: FileHandle;
718
728
  try {
719
729
  handle = await open(
720
- sessionFile,
730
+ requestedFile,
721
731
  constants.O_RDONLY | (constants.O_NOFOLLOW ?? 0) | (constants.O_NONBLOCK ?? 0),
722
732
  );
723
733
  } catch {
724
734
  throw new Error("OMP child transcript could not be opened");
725
735
  }
726
736
  try {
727
- 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
+ ]);
728
742
  if (
729
743
  !stat.isFile() ||
730
- stat.size > MAX_CHILD_TRANSCRIPT_BYTES ||
731
- dirname(canonicalChild) !== canonicalDirectory ||
732
- 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
733
749
  ) {
734
750
  throw new Error("OMP child transcript failed ownership validation");
735
751
  }
736
- const bytes = await handle.readFile();
737
- 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
+ );
738
766
  const text = new TextDecoder("utf-8", { fatal: true }).decode(bytes);
739
767
  const messages: unknown[] = [];
740
768
  let nativeSessionId: string | undefined;
@@ -752,15 +780,21 @@ export async function readOmpPersistedSubagentTranscript(
752
780
  nativeSessionId ??= candidateId;
753
781
  if (nativeSessionId !== candidateId)
754
782
  throw new Error("OMP child transcript identity changed");
755
- } 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;
756
790
  if (messages.length >= MAX_CHILD_TRANSCRIPT_MESSAGES) {
757
791
  throw new Error("OMP child transcript exceeds message limits");
758
792
  }
759
- messages.push(record.message);
793
+ messages.push(message);
760
794
  }
761
795
  }
762
796
  if (!nativeSessionId) throw new Error("OMP child transcript is missing session identity");
763
- return { sessionFile, nativeSessionId, byteLength: bytes.byteLength, messages };
797
+ return { sessionFile: canonicalChild, nativeSessionId, byteLength: bytes.byteLength, messages };
764
798
  } catch (error) {
765
799
  if (error instanceof Error) throw error;
766
800
  throw new Error("OMP child transcript could not be decoded");