@omercnet/paseo-omp 0.2.1 → 0.3.0-next.92.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.
- package/CHANGELOG.md +26 -0
- package/README.md +25 -13
- package/SUPPORT.md +6 -2
- package/TESTING.md +21 -18
- package/client/composer-pill-settings.tsx +157 -0
- package/client/external-url.ts +15 -0
- package/client/mcp-authorization.tsx +169 -0
- package/client/mcp-popover.tsx +155 -0
- package/client/memory-panel.tsx +8 -3
- package/client/memory-popover.tsx +8 -4
- package/client/omp-config-surface.tsx +189 -29
- package/client/omp-plugin-manager.tsx +302 -131
- package/client/omp-store-picker.tsx +89 -0
- package/client/omp-store-state.ts +45 -0
- package/client/paseo-types.ts +9 -0
- package/client/provider-diagnostics-state.ts +18 -7
- package/client/quota-popover.tsx +8 -3
- package/client/quota-state.ts +16 -7
- package/client/sessions-popover.tsx +8 -3
- package/docs/alpha-release-checklist.md +6 -8
- package/docs/configuration.md +8 -4
- package/docs/core-provider-issue-audit.md +3 -2
- package/docs/images/mcp-authorization-compact.png +0 -0
- package/docs/images/mcp-controls-wide.png +0 -0
- package/docs/images/plugin-manager.png +0 -0
- package/docs/images/workspace-settings.png +0 -0
- package/docs/installation.md +35 -19
- package/index.client.tsx +339 -123
- package/index.server.ts +44 -14
- package/package.json +7 -8
- package/paseo-plugin.json +2 -2
- package/scripts/prepare-dependencies.mjs +24 -0
- package/server/mcp-browser.ts +95 -0
- package/server/memory.ts +2 -2
- package/server/omp-config.ts +16 -7
- package/server/omp-plugins.ts +70 -21
- package/server/omp-settings.ts +232 -24
- package/server/paths.ts +128 -11
- package/server/provider/catalog.ts +3 -4
- package/server/provider/connection.ts +213 -9
- package/server/provider/host-tools.ts +71 -0
- package/server/provider/omp-rpc.ts +82 -15
- package/server/provider/profile-providers.ts +249 -0
- package/server/provider/registration.ts +11 -0
- package/server/provider/session-descriptors.ts +306 -1
- package/server/provider/session.ts +704 -249
- package/server/provider/subsessions.ts +4 -1
- package/server/provider/timeline-projector.ts +70 -33
- package/server/provider-diagnostics.ts +122 -36
- package/server/quota.ts +3 -2
- package/server/sessions.ts +2 -2
- package/shared/composer-pill-settings.ts +28 -0
- package/shared/external-url.ts +21 -0
- package/shared/hub.ts +3 -3
- package/shared/mcp.ts +47 -0
- package/shared/memory.ts +2 -1
- package/shared/omp-config.ts +5 -1
- package/shared/omp-plugins.ts +74 -33
- package/shared/omp-settings.ts +8 -1
- package/shared/omp-store.ts +58 -0
- package/shared/provider-diagnostics.ts +12 -3
- package/shared/quota.ts +2 -1
- 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
|
};
|
|
@@ -1,5 +1,6 @@
|
|
|
1
|
+
import { createHash } from "node:crypto";
|
|
1
2
|
import { constants, type Dir } from "node:fs";
|
|
2
|
-
import { type FileHandle, open, opendir, realpath } from "node:fs/promises";
|
|
3
|
+
import { type FileHandle, lstat, open, opendir, realpath } from "node:fs/promises";
|
|
3
4
|
import { homedir } from "node:os";
|
|
4
5
|
import { basename, dirname, extname, isAbsolute, join, resolve } from "node:path";
|
|
5
6
|
import { ompSessionDir } from "../paths";
|
|
@@ -16,6 +17,12 @@ const SCAN_YIELD_INTERVAL = 128;
|
|
|
16
17
|
const MAX_LIST_RESULTS = 500;
|
|
17
18
|
const MAX_CHILD_TRANSCRIPT_BYTES = 16 * 1024 * 1024;
|
|
18
19
|
const MAX_CHILD_TRANSCRIPT_MESSAGES = 100_000;
|
|
20
|
+
const MAX_SESSION_TRANSCRIPT_BYTES = 64 * 1024 * 1024;
|
|
21
|
+
const MAX_SESSION_TRANSCRIPT_ENTRIES = 200_000;
|
|
22
|
+
const MAX_SESSION_TRANSCRIPT_BLOB_BYTES = 16 * 1024 * 1024;
|
|
23
|
+
const MAX_SESSION_IMAGE_BLOB_BYTES = 6 * 1024 * 1024;
|
|
24
|
+
const FILE_READ_CHUNK_BYTES = 64 * 1024;
|
|
25
|
+
const BLOB_REFERENCE = /^blob:sha256:([a-f0-9]{64})$/u;
|
|
19
26
|
const CHILD_TRANSCRIPT_ID = /^[A-Za-z0-9][A-Za-z0-9._-]{0,255}$/u;
|
|
20
27
|
const NATIVE_SESSION_ID = /^[A-Za-z0-9][A-Za-z0-9_-]{7,127}$/u;
|
|
21
28
|
|
|
@@ -35,6 +42,13 @@ export interface OmpPersistedSubagentTranscript {
|
|
|
35
42
|
byteLength: number;
|
|
36
43
|
messages: unknown[];
|
|
37
44
|
}
|
|
45
|
+
|
|
46
|
+
export interface OmpPersistedSessionTranscript {
|
|
47
|
+
sessionFile: string;
|
|
48
|
+
nativeSessionId: string;
|
|
49
|
+
byteLength: number;
|
|
50
|
+
messages: unknown[];
|
|
51
|
+
}
|
|
38
52
|
export interface OmpSessionListOptions {
|
|
39
53
|
cwd?: string;
|
|
40
54
|
query?: string;
|
|
@@ -142,6 +156,128 @@ async function yieldToEventLoop(): Promise<void> {
|
|
|
142
156
|
setImmediate(result.resolve);
|
|
143
157
|
await result.promise;
|
|
144
158
|
}
|
|
159
|
+
async function readStableFile(
|
|
160
|
+
handle: FileHandle,
|
|
161
|
+
byteLength: number,
|
|
162
|
+
signal: AbortSignal | undefined,
|
|
163
|
+
changedMessage: string,
|
|
164
|
+
): Promise<Buffer> {
|
|
165
|
+
const bytes = Buffer.allocUnsafe(byteLength);
|
|
166
|
+
let offset = 0;
|
|
167
|
+
while (offset < byteLength) {
|
|
168
|
+
signal?.throwIfAborted();
|
|
169
|
+
const { bytesRead } = await handle.read(
|
|
170
|
+
bytes,
|
|
171
|
+
offset,
|
|
172
|
+
Math.min(FILE_READ_CHUNK_BYTES, byteLength - offset),
|
|
173
|
+
offset,
|
|
174
|
+
);
|
|
175
|
+
if (bytesRead === 0) throw new Error(changedMessage);
|
|
176
|
+
offset += bytesRead;
|
|
177
|
+
}
|
|
178
|
+
signal?.throwIfAborted();
|
|
179
|
+
const extra = Buffer.allocUnsafe(1);
|
|
180
|
+
if ((await handle.read(extra, 0, 1, byteLength)).bytesRead !== 0) throw new Error(changedMessage);
|
|
181
|
+
return bytes;
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
async function hydrateBlobImageData(
|
|
185
|
+
data: string,
|
|
186
|
+
blobDirectory: string,
|
|
187
|
+
budget: { bytes: number },
|
|
188
|
+
signal?: AbortSignal,
|
|
189
|
+
): Promise<string> {
|
|
190
|
+
const match = BLOB_REFERENCE.exec(data);
|
|
191
|
+
if (!match) return data;
|
|
192
|
+
const hash = match[1];
|
|
193
|
+
const blobFile = join(blobDirectory, hash);
|
|
194
|
+
let handle: FileHandle;
|
|
195
|
+
try {
|
|
196
|
+
handle = await open(
|
|
197
|
+
blobFile,
|
|
198
|
+
constants.O_RDONLY | (constants.O_NOFOLLOW ?? 0) | (constants.O_NONBLOCK ?? 0),
|
|
199
|
+
);
|
|
200
|
+
} catch {
|
|
201
|
+
throw new Error("OMP transcript image blob could not be opened");
|
|
202
|
+
}
|
|
203
|
+
try {
|
|
204
|
+
const [stat, pathStat] = await Promise.all([handle.stat(), lstat(blobFile)]);
|
|
205
|
+
if (
|
|
206
|
+
!stat.isFile() ||
|
|
207
|
+
!pathStat.isFile() ||
|
|
208
|
+
pathStat.isSymbolicLink() ||
|
|
209
|
+
stat.dev !== pathStat.dev ||
|
|
210
|
+
stat.ino !== pathStat.ino ||
|
|
211
|
+
stat.size > MAX_SESSION_IMAGE_BLOB_BYTES ||
|
|
212
|
+
budget.bytes + stat.size > MAX_SESSION_TRANSCRIPT_BLOB_BYTES
|
|
213
|
+
) {
|
|
214
|
+
throw new Error("OMP transcript image blob failed ownership or size validation");
|
|
215
|
+
}
|
|
216
|
+
const bytes = await readStableFile(
|
|
217
|
+
handle,
|
|
218
|
+
stat.size,
|
|
219
|
+
signal,
|
|
220
|
+
"OMP transcript image blob changed while reading",
|
|
221
|
+
);
|
|
222
|
+
if (createHash("sha256").update(bytes).digest("hex") !== hash) {
|
|
223
|
+
throw new Error("OMP transcript image blob failed integrity validation");
|
|
224
|
+
}
|
|
225
|
+
budget.bytes += bytes.byteLength;
|
|
226
|
+
return bytes.toString("base64");
|
|
227
|
+
} finally {
|
|
228
|
+
await handle.close().catch(() => undefined);
|
|
229
|
+
}
|
|
230
|
+
}
|
|
231
|
+
|
|
232
|
+
async function hydrateImageParts(
|
|
233
|
+
value: unknown,
|
|
234
|
+
blobDirectory: string,
|
|
235
|
+
budget: { bytes: number },
|
|
236
|
+
signal?: AbortSignal,
|
|
237
|
+
): Promise<unknown> {
|
|
238
|
+
if (!Array.isArray(value)) return value;
|
|
239
|
+
let hydrated: unknown[] | undefined;
|
|
240
|
+
for (let index = 0; index < value.length; index += 1) {
|
|
241
|
+
const part = value[index];
|
|
242
|
+
if (
|
|
243
|
+
!part ||
|
|
244
|
+
typeof part !== "object" ||
|
|
245
|
+
Array.isArray(part) ||
|
|
246
|
+
!("type" in part) ||
|
|
247
|
+
part.type !== "image" ||
|
|
248
|
+
!("data" in part) ||
|
|
249
|
+
typeof part.data !== "string" ||
|
|
250
|
+
!BLOB_REFERENCE.test(part.data)
|
|
251
|
+
) {
|
|
252
|
+
continue;
|
|
253
|
+
}
|
|
254
|
+
const data = await hydrateBlobImageData(part.data, blobDirectory, budget, signal);
|
|
255
|
+
hydrated ??= [...value];
|
|
256
|
+
hydrated[index] = { ...part, data };
|
|
257
|
+
}
|
|
258
|
+
return hydrated ?? value;
|
|
259
|
+
}
|
|
260
|
+
|
|
261
|
+
async function hydratePersistedMessageImages(
|
|
262
|
+
message: unknown,
|
|
263
|
+
blobDirectory: string | undefined,
|
|
264
|
+
budget: { bytes: number },
|
|
265
|
+
signal?: AbortSignal,
|
|
266
|
+
): Promise<unknown> {
|
|
267
|
+
if (!blobDirectory || !message || typeof message !== "object" || Array.isArray(message)) {
|
|
268
|
+
return message;
|
|
269
|
+
}
|
|
270
|
+
const record = message as Record<string, unknown>;
|
|
271
|
+
let content = await hydrateImageParts(record.content, blobDirectory, budget, signal);
|
|
272
|
+
if (content && typeof content === "object" && !Array.isArray(content)) {
|
|
273
|
+
const contentRecord = content as Record<string, unknown>;
|
|
274
|
+
const nested = await hydrateImageParts(contentRecord.content, blobDirectory, budget, signal);
|
|
275
|
+
if (nested !== contentRecord.content) content = { ...contentRecord, content: nested };
|
|
276
|
+
}
|
|
277
|
+
const images = await hydrateImageParts(record.images, blobDirectory, budget, signal);
|
|
278
|
+
if (content === record.content && images === record.images) return message;
|
|
279
|
+
return { ...record, content, images };
|
|
280
|
+
}
|
|
145
281
|
|
|
146
282
|
async function parseDescriptor(
|
|
147
283
|
file: string,
|
|
@@ -336,6 +472,175 @@ export async function listOmpSessionDescriptors(
|
|
|
336
472
|
return matches;
|
|
337
473
|
}
|
|
338
474
|
|
|
475
|
+
interface PersistedTranscriptNode {
|
|
476
|
+
parentId: string | null;
|
|
477
|
+
message?: unknown;
|
|
478
|
+
}
|
|
479
|
+
|
|
480
|
+
function persistedTranscriptMessage(record: Record<string, unknown>, entryId: string): unknown {
|
|
481
|
+
if (record.type === "message") {
|
|
482
|
+
if (!record.message || typeof record.message !== "object" || Array.isArray(record.message))
|
|
483
|
+
return;
|
|
484
|
+
return { ...(record.message as Record<string, unknown>), entryId };
|
|
485
|
+
}
|
|
486
|
+
if (record.type !== "custom_message") return;
|
|
487
|
+
return {
|
|
488
|
+
role: "custom",
|
|
489
|
+
entryId,
|
|
490
|
+
customType: record.customType,
|
|
491
|
+
content: record.content,
|
|
492
|
+
display: record.display,
|
|
493
|
+
details: record.details,
|
|
494
|
+
};
|
|
495
|
+
}
|
|
496
|
+
|
|
497
|
+
/**
|
|
498
|
+
* Reads the active root-to-leaf display history from an already authorized native transcript.
|
|
499
|
+
* OMP's get_messages endpoint exposes model-safe context and deliberately removes failed turns,
|
|
500
|
+
* so persisted replay must use the journal to preserve user-visible assistant and tool history.
|
|
501
|
+
*/
|
|
502
|
+
export async function readOmpPersistedSessionTranscript(
|
|
503
|
+
sessionFile: string,
|
|
504
|
+
sessionId: string,
|
|
505
|
+
cwd: string,
|
|
506
|
+
signal?: AbortSignal,
|
|
507
|
+
blobDirectory?: string,
|
|
508
|
+
): Promise<OmpPersistedSessionTranscript> {
|
|
509
|
+
signal?.throwIfAborted();
|
|
510
|
+
const expectedSessionId = validateNativeSessionId(sessionId);
|
|
511
|
+
if (
|
|
512
|
+
!isAbsolute(sessionFile) ||
|
|
513
|
+
!sessionFile.endsWith(".jsonl") ||
|
|
514
|
+
sessionFile.includes("\0") ||
|
|
515
|
+
validatedCwd(cwd) !== cwd
|
|
516
|
+
) {
|
|
517
|
+
throw new Error("Invalid OMP session transcript descriptor");
|
|
518
|
+
}
|
|
519
|
+
const expectedFile = resolve(sessionFile);
|
|
520
|
+
let handle: FileHandle;
|
|
521
|
+
try {
|
|
522
|
+
handle = await open(
|
|
523
|
+
expectedFile,
|
|
524
|
+
constants.O_RDONLY | (constants.O_NOFOLLOW ?? 0) | (constants.O_NONBLOCK ?? 0),
|
|
525
|
+
);
|
|
526
|
+
} catch {
|
|
527
|
+
throw new Error("OMP session transcript could not be opened");
|
|
528
|
+
}
|
|
529
|
+
try {
|
|
530
|
+
const [stat, pathStat, canonicalFile] = await Promise.all([
|
|
531
|
+
handle.stat(),
|
|
532
|
+
lstat(expectedFile),
|
|
533
|
+
realpath(expectedFile),
|
|
534
|
+
]);
|
|
535
|
+
if (
|
|
536
|
+
!stat.isFile() ||
|
|
537
|
+
!pathStat.isFile() ||
|
|
538
|
+
pathStat.isSymbolicLink() ||
|
|
539
|
+
stat.dev !== pathStat.dev ||
|
|
540
|
+
stat.ino !== pathStat.ino ||
|
|
541
|
+
stat.size > MAX_SESSION_TRANSCRIPT_BYTES
|
|
542
|
+
) {
|
|
543
|
+
throw new Error("OMP session transcript failed ownership validation");
|
|
544
|
+
}
|
|
545
|
+
const bytes = await readStableFile(
|
|
546
|
+
handle,
|
|
547
|
+
stat.size,
|
|
548
|
+
signal,
|
|
549
|
+
"OMP session transcript changed while reading",
|
|
550
|
+
);
|
|
551
|
+
const text = new TextDecoder("utf-8", { fatal: true }).decode(bytes);
|
|
552
|
+
const nodes = new Map<string, PersistedTranscriptNode>();
|
|
553
|
+
let nativeSessionId: string | undefined;
|
|
554
|
+
let leafId: string | undefined;
|
|
555
|
+
let recordsRead = 0;
|
|
556
|
+
for (const line of text.split("\n")) {
|
|
557
|
+
signal?.throwIfAborted();
|
|
558
|
+
recordsRead += 1;
|
|
559
|
+
if (recordsRead % 1_024 === 0) await yieldToEventLoop();
|
|
560
|
+
if (!line.trim()) continue;
|
|
561
|
+
const value: unknown = JSON.parse(line);
|
|
562
|
+
if (!value || typeof value !== "object" || Array.isArray(value)) continue;
|
|
563
|
+
const record = value as Record<string, unknown>;
|
|
564
|
+
if (record.type === "session") {
|
|
565
|
+
const candidateId = validateNativeSessionId(record.id);
|
|
566
|
+
if (candidateId !== expectedSessionId || validatedCwd(record.cwd) !== cwd) {
|
|
567
|
+
throw new Error("OMP session transcript identity does not match its descriptor");
|
|
568
|
+
}
|
|
569
|
+
nativeSessionId ??= candidateId;
|
|
570
|
+
if (nativeSessionId !== candidateId) {
|
|
571
|
+
throw new Error("OMP session transcript identity changed");
|
|
572
|
+
}
|
|
573
|
+
continue;
|
|
574
|
+
}
|
|
575
|
+
if (typeof record.id !== "string" || !CHILD_TRANSCRIPT_ID.test(record.id)) {
|
|
576
|
+
if (record.type === "message" || record.type === "custom_message") {
|
|
577
|
+
throw new Error("OMP session transcript contains an unlinked message");
|
|
578
|
+
}
|
|
579
|
+
continue;
|
|
580
|
+
}
|
|
581
|
+
const parentId = record.parentId;
|
|
582
|
+
if (
|
|
583
|
+
parentId !== null &&
|
|
584
|
+
(typeof parentId !== "string" || !CHILD_TRANSCRIPT_ID.test(parentId))
|
|
585
|
+
) {
|
|
586
|
+
throw new Error("OMP session transcript contains an invalid parent identity");
|
|
587
|
+
}
|
|
588
|
+
if (nodes.size >= MAX_SESSION_TRANSCRIPT_ENTRIES) {
|
|
589
|
+
throw new Error("OMP session transcript exceeds entry limits");
|
|
590
|
+
}
|
|
591
|
+
if (nodes.has(record.id))
|
|
592
|
+
throw new Error("OMP session transcript contains duplicate entries");
|
|
593
|
+
nodes.set(record.id, {
|
|
594
|
+
parentId,
|
|
595
|
+
message: persistedTranscriptMessage(record, record.id),
|
|
596
|
+
});
|
|
597
|
+
leafId = record.id;
|
|
598
|
+
}
|
|
599
|
+
if (!nativeSessionId) throw new Error("OMP session transcript is missing session identity");
|
|
600
|
+
|
|
601
|
+
const messages: unknown[] = [];
|
|
602
|
+
const seen = new Set<string>();
|
|
603
|
+
let currentId = leafId;
|
|
604
|
+
let pathEntriesRead = 0;
|
|
605
|
+
while (currentId) {
|
|
606
|
+
pathEntriesRead += 1;
|
|
607
|
+
if (pathEntriesRead % 1_024 === 0) {
|
|
608
|
+
await yieldToEventLoop();
|
|
609
|
+
signal?.throwIfAborted();
|
|
610
|
+
}
|
|
611
|
+
if (seen.has(currentId)) throw new Error("OMP session transcript contains a parent cycle");
|
|
612
|
+
seen.add(currentId);
|
|
613
|
+
const node = nodes.get(currentId);
|
|
614
|
+
if (!node) throw new Error("OMP session transcript contains an unresolved parent");
|
|
615
|
+
if (node.message !== undefined) messages.push(node.message);
|
|
616
|
+
currentId = node.parentId ?? undefined;
|
|
617
|
+
}
|
|
618
|
+
messages.reverse();
|
|
619
|
+
if (messages.length > MAX_CHILD_TRANSCRIPT_MESSAGES) {
|
|
620
|
+
throw new Error("OMP session transcript exceeds message limits");
|
|
621
|
+
}
|
|
622
|
+
const hydratedMessages: unknown[] = [];
|
|
623
|
+
const blobBudget = { bytes: 0 };
|
|
624
|
+
for (const message of messages) {
|
|
625
|
+
signal?.throwIfAborted();
|
|
626
|
+
hydratedMessages.push(
|
|
627
|
+
await hydratePersistedMessageImages(message, blobDirectory, blobBudget, signal),
|
|
628
|
+
);
|
|
629
|
+
}
|
|
630
|
+
return {
|
|
631
|
+
sessionFile: canonicalFile,
|
|
632
|
+
nativeSessionId,
|
|
633
|
+
byteLength: bytes.byteLength,
|
|
634
|
+
messages: hydratedMessages,
|
|
635
|
+
};
|
|
636
|
+
} catch (error) {
|
|
637
|
+
if (error instanceof Error) throw error;
|
|
638
|
+
throw new Error("OMP session transcript could not be decoded");
|
|
639
|
+
} finally {
|
|
640
|
+
await handle.close().catch(() => undefined);
|
|
641
|
+
}
|
|
642
|
+
}
|
|
643
|
+
|
|
339
644
|
export async function readOmpPersistedSubagentTranscript(
|
|
340
645
|
parentSessionFile: string,
|
|
341
646
|
childTranscriptId: string,
|