@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
@@ -0,0 +1,249 @@
1
+ import { type Dir, opendirSync } from "node:fs";
2
+ import { opendir } from "node:fs/promises";
3
+ import { homedir } from "node:os";
4
+ import { basename, join, resolve } from "node:path";
5
+ import { isOmpProfileName } from "../../shared/omp-store";
6
+ import { ompAgentDir, ompCacheDir, ompDataDir, ompSessionDir, ompStateDir } from "../paths";
7
+ import { OmpRpcRuntime, type OmpRuntime, type OmpStartOptions } from "./omp-rpc";
8
+ import { parseOmpProviderOptions } from "./provider-options";
9
+ import { createOmpProvider, type OmpProviderOptions } from "./registration";
10
+ import { OmpPublicError } from "./security";
11
+
12
+ const MAX_PROFILES = 128;
13
+ const MAX_DIRECTORY_ENTRIES = 4_096;
14
+ const STORE_ENV_NAMES: Readonly<Record<string, true>> = {
15
+ OMP_PROFILE: true,
16
+ PI_PROFILE: true,
17
+ PASEO_OMP_AGENT_DIR: true,
18
+ OMP_AGENT_DIR: true,
19
+ PI_CODING_AGENT_DIR: true,
20
+ OMP_SESSION_DIR: true,
21
+ PI_CODING_AGENT_SESSION_DIR: true,
22
+ PI_CONFIG_FILES: true,
23
+ };
24
+ const PROFILE_OVERRIDE_ENV_NAMES: Readonly<Record<string, true>> = {
25
+ ...STORE_ENV_NAMES,
26
+ PI_CONFIG_DIR: true,
27
+ HOME: true,
28
+ USERPROFILE: true,
29
+ XDG_DATA_HOME: true,
30
+ XDG_STATE_HOME: true,
31
+ XDG_CACHE_HOME: true,
32
+ };
33
+
34
+ function validateProfile(profile: string): void {
35
+ if (!isOmpProfileName(profile)) throw new OmpPublicError("Invalid named OMP profile");
36
+ }
37
+
38
+ function profileDirectory(environment: NodeJS.ProcessEnv): string {
39
+ return join(
40
+ environment.HOME ?? environment.USERPROFILE ?? homedir(),
41
+ environment.PI_CONFIG_DIR || ".omp",
42
+ "profiles",
43
+ );
44
+ }
45
+
46
+ /** Paseo 0.8 contribution registration is synchronous; inspect directory names only. */
47
+ export function discoverOmpProfilesSync(environment: NodeJS.ProcessEnv = process.env): string[] {
48
+ let directory: Dir;
49
+ try {
50
+ directory = opendirSync(profileDirectory(environment));
51
+ } catch (error) {
52
+ if ((error as NodeJS.ErrnoException).code === "ENOENT") return [];
53
+ throw new OmpPublicError("OMP profile directory could not be read");
54
+ }
55
+ const profiles: string[] = [];
56
+ let count = 0;
57
+ try {
58
+ for (let entry = directory.readSync(); entry; entry = directory.readSync()) {
59
+ if (++count > MAX_DIRECTORY_ENTRIES)
60
+ throw new OmpPublicError("OMP profile directory is too large");
61
+ if (entry.isDirectory() && isOmpProfileName(entry.name)) profiles.push(entry.name);
62
+ }
63
+ } finally {
64
+ directory.closeSync();
65
+ }
66
+ return profiles.sort().slice(0, MAX_PROFILES);
67
+ }
68
+
69
+ /** Enumerate names only; never open profile configuration, databases, or credential files. */
70
+ export async function discoverOmpProfiles(
71
+ environment: NodeJS.ProcessEnv = process.env,
72
+ ): Promise<string[]> {
73
+ let directory: Dir;
74
+ try {
75
+ directory = await opendir(profileDirectory(environment));
76
+ } catch (error) {
77
+ if ((error as NodeJS.ErrnoException).code === "ENOENT") return [];
78
+ throw new OmpPublicError("OMP profile directory could not be read");
79
+ }
80
+ const profiles: string[] = [];
81
+ let count = 0;
82
+ for await (const entry of directory) {
83
+ if (++count > MAX_DIRECTORY_ENTRIES) {
84
+ throw new OmpPublicError("OMP profile directory is too large");
85
+ }
86
+ if (entry.isDirectory() && isOmpProfileName(entry.name)) profiles.push(entry.name);
87
+ }
88
+ return profiles.sort().slice(0, MAX_PROFILES);
89
+ }
90
+
91
+ export function profileProviderId(profile: string): string {
92
+ validateProfile(profile);
93
+ return `omp-plugin-${profile}`;
94
+ }
95
+
96
+ function fixedEnvironment(profile: string, source: NodeJS.ProcessEnv): NodeJS.ProcessEnv {
97
+ const environment = { ...source };
98
+ for (const name of Object.keys(environment)) {
99
+ if (STORE_ENV_NAMES[name.toUpperCase()]) delete environment[name];
100
+ }
101
+ environment.OMP_PROFILE = profile;
102
+ environment.PI_CODING_AGENT_DIR = join(profileDirectory(source), profile, "agent");
103
+ return environment;
104
+ }
105
+
106
+ function profileCommand(
107
+ command: readonly string[],
108
+ profile: string,
109
+ sessionDir: string,
110
+ ): readonly string[] {
111
+ // Environment-control flags can erase or replace the fixed profile/config/XDG
112
+ // roots before OMP sees --profile. Allow plain assignment wrappers, not env's
113
+ // -i, -u, -S, -C (or their long forms), including wrappers nested after `--`.
114
+ for (let index = 0; index < command.length; index += 1) {
115
+ const executable = basename(command[index]).toLowerCase();
116
+ if (executable !== "env" && executable !== "env.exe") continue;
117
+ for (const argument of command.slice(index + 1)) {
118
+ if (argument === "--") break;
119
+ if (argument.startsWith("-"))
120
+ throw new OmpPublicError("OMP profile command cannot alter its environment with env flags");
121
+ if (!/^[A-Za-z_][A-Za-z0-9_]*=/u.test(argument)) break;
122
+ }
123
+ }
124
+ let hasProfile = false;
125
+ for (let index = 1; index < command.length; index += 1) {
126
+ const argument = command[index];
127
+ const assignment = /^([A-Za-z_][A-Za-z0-9_]*)=/u.exec(argument);
128
+ if (assignment && PROFILE_OVERRIDE_ENV_NAMES[assignment[1].toUpperCase()]) {
129
+ throw new OmpPublicError("OMP profile command cannot override its selected store");
130
+ }
131
+ if (argument === "--profile" || argument.startsWith("--profile=")) {
132
+ const selected = argument === "--profile" ? command[++index] : argument.slice(10);
133
+ if (selected !== profile) {
134
+ throw new OmpPublicError("OMP command profile conflicts with the selected provider");
135
+ }
136
+ hasProfile = true;
137
+ } else if (argument === "--session-dir" || argument.startsWith("--session-dir=")) {
138
+ const selected = argument === "--session-dir" ? command[++index] : argument.slice(14);
139
+ if (!selected || resolve(selected) !== sessionDir) {
140
+ throw new OmpPublicError(
141
+ "OMP command session directory conflicts with the selected profile",
142
+ );
143
+ }
144
+ }
145
+ }
146
+ return hasProfile ? command : [...command, "--profile", profile];
147
+ }
148
+
149
+ /** Each provider owns one profile before the host requests a catalog or creates an agent. */
150
+ export function createProfileOmpProvider(profile: string, options: OmpProviderOptions = {}) {
151
+ const id = profileProviderId(profile);
152
+ const environment = fixedEnvironment(profile, options.environment ?? process.env);
153
+ const sessionDir = resolve(ompSessionDir(environment));
154
+ const runtime = options.runtime ?? new OmpRpcRuntime({ environment });
155
+ const readPersistedSessionTranscript = runtime.readPersistedSessionTranscript?.bind(runtime);
156
+ const assertSessionDir = (requested?: string) => {
157
+ if (requested !== undefined && resolve(requested) !== sessionDir) {
158
+ throw new OmpPublicError("OMP session directory conflicts with the selected profile");
159
+ }
160
+ };
161
+ const assertEnvironment = (sessionEnv?: Readonly<Record<string, string>>) => {
162
+ for (const name of Object.keys(sessionEnv ?? {})) {
163
+ if (PROFILE_OVERRIDE_ENV_NAMES[name.toUpperCase()]) {
164
+ throw new OmpPublicError("OMP session environment cannot override its selected profile");
165
+ }
166
+ }
167
+ };
168
+ const scopedRuntime: OmpRuntime = {
169
+ get supportsPersistence() {
170
+ return runtime.supportsPersistence;
171
+ },
172
+ async startSession(start: OmpStartOptions) {
173
+ assertSessionDir(start.sessionDir);
174
+ assertEnvironment(start.env);
175
+ return runtime.startSession({
176
+ ...start,
177
+ command: profileCommand(
178
+ start.command ?? [environment.OMP_COMMAND ?? "omp"],
179
+ profile,
180
+ sessionDir,
181
+ ),
182
+ environment,
183
+ sessionDir,
184
+ });
185
+ },
186
+ async listSessions(listOptions) {
187
+ assertSessionDir(listOptions.sessionDir);
188
+ return runtime.listSessions({ ...listOptions, sessionDir });
189
+ },
190
+ ...(readPersistedSessionTranscript
191
+ ? {
192
+ readPersistedSessionTranscript(input) {
193
+ return readPersistedSessionTranscript(input);
194
+ },
195
+ }
196
+ : {}),
197
+ readPersistedSubagentTranscript(input) {
198
+ return runtime.readPersistedSubagentTranscript(input);
199
+ },
200
+ };
201
+ const provider = createOmpProvider({
202
+ ...options,
203
+ runtime: scopedRuntime,
204
+ environment,
205
+ catalogIdentity: {
206
+ profile,
207
+ agentRoot: ompAgentDir(environment),
208
+ dataRoot: ompDataDir(environment),
209
+ cacheRoot: ompCacheDir(environment),
210
+ stateRoot: ompStateDir(environment),
211
+ sessionRoot: sessionDir,
212
+ },
213
+ });
214
+ const validateCatalog = (
215
+ input: Parameters<NonNullable<typeof provider.getCatalogCacheKey>>[0],
216
+ ) => {
217
+ const parsed = parseOmpProviderOptions(input.providerOptions);
218
+ assertEnvironment(parsed.env);
219
+ assertSessionDir(parsed.params?.sessionDir);
220
+ return profileCommand(
221
+ parsed.command ?? [environment.OMP_COMMAND ?? "omp"],
222
+ profile,
223
+ sessionDir,
224
+ );
225
+ };
226
+ return {
227
+ ...provider,
228
+ async getCatalogCacheKey(
229
+ input: Parameters<NonNullable<typeof provider.getCatalogCacheKey>>[0],
230
+ ) {
231
+ validateCatalog(input);
232
+ return provider.getCatalogCacheKey?.(input);
233
+ },
234
+ async checkAvailability(
235
+ input: Parameters<NonNullable<typeof provider.checkAvailability>>[0],
236
+ context?: Parameters<NonNullable<typeof provider.checkAvailability>>[1],
237
+ ) {
238
+ const command = validateCatalog(input);
239
+ if (!provider.checkAvailability)
240
+ throw new OmpPublicError("OMP availability probe is unavailable");
241
+ return provider.checkAvailability(
242
+ { ...input, providerOptions: { ...input.providerOptions, command } },
243
+ context,
244
+ );
245
+ },
246
+ id,
247
+ label: `OMP · ${profile}`,
248
+ };
249
+ }
@@ -2,6 +2,7 @@ import { createHash } from "node:crypto";
2
2
  import { homedir } from "node:os";
3
3
  import type { ProviderRegistration } from "@getpaseo/plugin/server/provider";
4
4
  import { z } from "zod";
5
+ import type { OmpBrowserAuthorizationRegistry } from "../mcp-browser";
5
6
  import { probeOmpAvailability } from "../provider-diagnostics";
6
7
  import { createOmpConnection, OmpNativeSessionReservations } from "./connection";
7
8
  import type { OmpMcpConnector } from "./host-tools";
@@ -48,6 +49,7 @@ const CAPABILITIES = [
48
49
  "session.subsession",
49
50
  "session.revert.conversation",
50
51
  "permission",
52
+ "timeline.plugin",
51
53
  ] as const;
52
54
  const ConnectRequestSchema = z.object({
53
55
  versions: z.array(z.number().int().positive().max(16)).min(1).max(8),
@@ -59,8 +61,11 @@ export interface OmpProviderOptions {
59
61
  timelineScheduler?: OmpTimelineScheduler;
60
62
  replayTimeoutMs?: number;
61
63
  environment?: NodeJS.ProcessEnv;
64
+ /** Fixed, non-secret profile/store identity for providers whose configuration is server-owned. */
65
+ catalogIdentity?: Readonly<Record<string, string>>;
62
66
  mcpInitializationTimeoutMs?: number;
63
67
  mcpConnector?: OmpMcpConnector;
68
+ browserAuthorizationRegistry?: OmpBrowserAuthorizationRegistry;
64
69
  availabilityProbe?: (
65
70
  options: ProviderCatalogOptionsCompat,
66
71
  timeoutMs: number | undefined,
@@ -90,9 +95,14 @@ export function createOmpProvider(options: OmpProviderOptions = {}): ProviderReg
90
95
  providerOptionsSchema: OmpProviderOptionsSchema,
91
96
  async getCatalogCacheKey(catalogOptions) {
92
97
  const providerOptions = parseOmpProviderOptions(catalogOptions.providerOptions);
98
+ // Profile providers cannot safely hash explicit environment values because they may be
99
+ // credentials. Disable sharing for that case; otherwise retain every normalized option
100
+ // that can change discovery alongside the fixed, non-secret store identity.
101
+ if (options.catalogIdentity && Object.keys(providerOptions.env ?? {}).length > 0) return;
93
102
  const identity = {
94
103
  scope: catalogOptions.scope,
95
104
  ...(catalogOptions.scope === "workspace" ? { cwd: catalogOptions.cwd } : {}),
105
+ ...(options.catalogIdentity ? { store: options.catalogIdentity } : {}),
96
106
  providerOptions,
97
107
  settings: catalogOptions.settings ?? {},
98
108
  defaultCommand: (options.environment ?? process.env).OMP_COMMAND ?? "omp",
@@ -145,6 +155,7 @@ export function createOmpProvider(options: OmpProviderOptions = {}): ProviderReg
145
155
  options.mcpConnector,
146
156
  options.mcpInitializationTimeoutMs,
147
157
  options.replayTimeoutMs,
158
+ options.browserAuthorizationRegistry,
148
159
  );
149
160
  },
150
161
  };
@@ -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 {