ompclaw 0.3.0
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 +34 -0
- package/LICENSE +21 -0
- package/NOTICE +10 -0
- package/README.md +152 -0
- package/SECURITY.md +61 -0
- package/config.example.json +45 -0
- package/docs/guide.md +360 -0
- package/docs/rpc-service.md +240 -0
- package/package.json +93 -0
- package/src/api.ts +556 -0
- package/src/gateway-app.ts +393 -0
- package/src/gateway-config.ts +379 -0
- package/src/gateway-core.ts +410 -0
- package/src/gateway-scheduler.ts +425 -0
- package/src/gateway-store.ts +947 -0
- package/src/gateway-tools.ts +443 -0
- package/src/gateway-types.ts +290 -0
- package/src/inbox.ts +77 -0
- package/src/index.ts +13 -0
- package/src/markdown.ts +156 -0
- package/src/outbound.ts +353 -0
- package/src/rpc-cli.ts +408 -0
- package/src/rpc-client.ts +308 -0
- package/src/rpc-config.ts +70 -0
- package/src/rpc-profile.ts +215 -0
- package/src/rpc-protocol.ts +326 -0
- package/src/rpc-runtime.ts +875 -0
- package/src/rpc-service.ts +191 -0
- package/src/rpc-ui.ts +218 -0
- package/src/transports/telegram/adapter.ts +829 -0
- package/src/transports/websocket/adapter.ts +704 -0
- package/src/transports/websocket/protocol.ts +256 -0
- package/src/type-guards.ts +4 -0
- package/tsconfig.json +13 -0
|
@@ -0,0 +1,308 @@
|
|
|
1
|
+
import { spawn, type ChildProcessWithoutNullStreams } from "node:child_process";
|
|
2
|
+
import { setTimeout as sleep } from "node:timers/promises";
|
|
3
|
+
import { RpcFrameDecoder, type RpcCommand, type RpcInboundFrame, type RpcResponse, isRpcReady, isRpcResponse } from "./rpc-protocol";
|
|
4
|
+
import { isRecord } from "./type-guards";
|
|
5
|
+
|
|
6
|
+
export interface OmpRpcClientOptions {
|
|
7
|
+
argv: string[];
|
|
8
|
+
cwd: string;
|
|
9
|
+
env: Record<string, string | undefined>;
|
|
10
|
+
readyTimeoutMs?: number;
|
|
11
|
+
commandTimeoutMs?: number;
|
|
12
|
+
maxStderrBytes?: number;
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
export interface RpcCommandInput {
|
|
16
|
+
type: string;
|
|
17
|
+
[key: string]: unknown;
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
export type RpcFrameListener = (frame: Record<string, unknown>) => void | Promise<void>;
|
|
21
|
+
export type RpcExitListener = (error: Error) => void | Promise<void>;
|
|
22
|
+
|
|
23
|
+
interface PendingRequest {
|
|
24
|
+
command: string;
|
|
25
|
+
resolve(response: RpcResponse): void;
|
|
26
|
+
reject(error: Error): void;
|
|
27
|
+
timer: ReturnType<typeof setTimeout>;
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
export class RpcCommandError extends Error {
|
|
31
|
+
constructor(
|
|
32
|
+
message: string,
|
|
33
|
+
readonly command: string,
|
|
34
|
+
readonly code?: string,
|
|
35
|
+
) {
|
|
36
|
+
super(message);
|
|
37
|
+
this.name = "RpcCommandError";
|
|
38
|
+
}
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
/** Process-backed OMP RPC client with v2 negotiation, lossless frames, and restart-safe teardown. */
|
|
42
|
+
export class OmpRpcClient {
|
|
43
|
+
readonly #options: Required<Pick<OmpRpcClientOptions, "readyTimeoutMs" | "commandTimeoutMs" | "maxStderrBytes">> & OmpRpcClientOptions;
|
|
44
|
+
readonly #frameListeners = new Set<RpcFrameListener>();
|
|
45
|
+
readonly #exitListeners = new Set<RpcExitListener>();
|
|
46
|
+
readonly #pending = new Map<string, PendingRequest>();
|
|
47
|
+
#eventQueue: Promise<void> = Promise.resolve();
|
|
48
|
+
#child: ChildProcessWithoutNullStreams | undefined;
|
|
49
|
+
#exited: Promise<void> | undefined;
|
|
50
|
+
#requestId = 0;
|
|
51
|
+
#stderr = "";
|
|
52
|
+
#protocolVersion = 1;
|
|
53
|
+
#stopping = false;
|
|
54
|
+
#ready = false;
|
|
55
|
+
|
|
56
|
+
constructor(options: OmpRpcClientOptions) {
|
|
57
|
+
if (options.argv.length === 0) throw new Error("OMP RPC argv must not be empty");
|
|
58
|
+
this.#options = {
|
|
59
|
+
readyTimeoutMs: options.readyTimeoutMs ?? 30_000,
|
|
60
|
+
commandTimeoutMs: options.commandTimeoutMs ?? 30_000,
|
|
61
|
+
maxStderrBytes: options.maxStderrBytes ?? 32 * 1024,
|
|
62
|
+
...options,
|
|
63
|
+
};
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
get running(): boolean {
|
|
67
|
+
return this.#child !== undefined && this.#ready;
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
get protocolVersion(): number {
|
|
71
|
+
return this.#protocolVersion;
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
get stderr(): string {
|
|
75
|
+
return this.#stderr;
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
onFrame(listener: RpcFrameListener): () => void {
|
|
79
|
+
this.#frameListeners.add(listener);
|
|
80
|
+
return () => this.#frameListeners.delete(listener);
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
onExit(listener: RpcExitListener): () => void {
|
|
84
|
+
this.#exitListeners.add(listener);
|
|
85
|
+
return () => this.#exitListeners.delete(listener);
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
async start(): Promise<void> {
|
|
89
|
+
if (this.#child) throw new Error("OMP RPC client is already started");
|
|
90
|
+
this.#stopping = false;
|
|
91
|
+
this.#ready = false;
|
|
92
|
+
this.#protocolVersion = 1;
|
|
93
|
+
this.#stderr = "";
|
|
94
|
+
|
|
95
|
+
const [executable, ...args] = this.#options.argv;
|
|
96
|
+
const child = spawn(executable, args, {
|
|
97
|
+
cwd: this.#options.cwd,
|
|
98
|
+
env: this.#options.env,
|
|
99
|
+
stdio: "pipe",
|
|
100
|
+
});
|
|
101
|
+
this.#child = child;
|
|
102
|
+
|
|
103
|
+
const ready = Promise.withResolvers<Record<string, unknown>>();
|
|
104
|
+
let readySettled = false;
|
|
105
|
+
const settleReady = (frame: Record<string, unknown>): void => {
|
|
106
|
+
if (readySettled) return;
|
|
107
|
+
readySettled = true;
|
|
108
|
+
ready.resolve(frame);
|
|
109
|
+
};
|
|
110
|
+
const rejectReady = (error: Error): void => {
|
|
111
|
+
if (readySettled) return;
|
|
112
|
+
readySettled = true;
|
|
113
|
+
ready.reject(error);
|
|
114
|
+
};
|
|
115
|
+
|
|
116
|
+
child.once("error", (cause) => {
|
|
117
|
+
rejectReady(cause);
|
|
118
|
+
if (!this.#stopping) void this.#handleUnexpectedExit(cause);
|
|
119
|
+
});
|
|
120
|
+
const exited = Promise.withResolvers<void>();
|
|
121
|
+
this.#exited = exited.promise;
|
|
122
|
+
child.once("close", (code, signal) => {
|
|
123
|
+
const error = new Error(`OMP RPC process exited with ${signal ? `signal ${signal}` : `code ${code}`}${this.#stderr ? `: ${this.#stderr}` : ""}`);
|
|
124
|
+
rejectReady(error);
|
|
125
|
+
if (!this.#stopping) void this.#handleUnexpectedExit(error);
|
|
126
|
+
exited.resolve();
|
|
127
|
+
});
|
|
128
|
+
void this.#readStderr(child);
|
|
129
|
+
void this.#readStdout(child, settleReady).catch((cause: unknown) => {
|
|
130
|
+
const error = cause instanceof Error ? cause : new Error(String(cause));
|
|
131
|
+
rejectReady(error);
|
|
132
|
+
if (!this.#stopping) void this.#handleUnexpectedExit(error);
|
|
133
|
+
});
|
|
134
|
+
|
|
135
|
+
const timer = setTimeout(() => {
|
|
136
|
+
rejectReady(new Error(`Timed out waiting for OMP RPC readiness${this.#stderr ? `: ${this.#stderr}` : ""}`));
|
|
137
|
+
}, this.#options.readyTimeoutMs);
|
|
138
|
+
timer.unref?.();
|
|
139
|
+
|
|
140
|
+
try {
|
|
141
|
+
const frame = await ready.promise;
|
|
142
|
+
this.#ready = true;
|
|
143
|
+
const versions = Array.isArray(frame.supportedProtocolVersions) ? frame.supportedProtocolVersions : [];
|
|
144
|
+
if (versions.includes(2)) {
|
|
145
|
+
const advertised = frame.maxReassembledFrameBytes;
|
|
146
|
+
if (!Number.isSafeInteger(advertised) || Number(advertised) <= 0) throw new Error("OMP advertised an invalid RPC reassembly limit");
|
|
147
|
+
const response = await this.send({ type: "negotiate_protocol", protocolVersion: 2 });
|
|
148
|
+
if (!response.success || !isRecord(response.data) || response.data.protocolVersion !== 2) {
|
|
149
|
+
throw new Error("OMP RPC protocol-v2 negotiation failed");
|
|
150
|
+
}
|
|
151
|
+
this.#protocolVersion = 2;
|
|
152
|
+
}
|
|
153
|
+
} catch (error) {
|
|
154
|
+
await this.stop();
|
|
155
|
+
throw error;
|
|
156
|
+
} finally {
|
|
157
|
+
clearTimeout(timer);
|
|
158
|
+
}
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
async stop(): Promise<void> {
|
|
162
|
+
const child = this.#child;
|
|
163
|
+
if (!child) return;
|
|
164
|
+
this.#stopping = true;
|
|
165
|
+
this.#child = undefined;
|
|
166
|
+
this.#ready = false;
|
|
167
|
+
const error = new Error("OMP RPC client stopped");
|
|
168
|
+
for (const pending of this.#pending.values()) {
|
|
169
|
+
clearTimeout(pending.timer);
|
|
170
|
+
pending.reject(error);
|
|
171
|
+
}
|
|
172
|
+
this.#pending.clear();
|
|
173
|
+
|
|
174
|
+
child.stdin.end();
|
|
175
|
+
const exited = this.#exited ?? Promise.resolve();
|
|
176
|
+
if (!(await Promise.race([exited.then(() => true), sleep(1_500, false)]))) {
|
|
177
|
+
child.kill("SIGTERM");
|
|
178
|
+
if (!(await Promise.race([exited.then(() => true), sleep(1_500, false)]))) child.kill("SIGKILL");
|
|
179
|
+
}
|
|
180
|
+
await exited;
|
|
181
|
+
this.#exited = undefined;
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
send(command: RpcCommandInput, timeoutMs = this.#options.commandTimeoutMs): Promise<RpcResponse> {
|
|
185
|
+
const child = this.#child;
|
|
186
|
+
if (!child) throw new Error("OMP RPC client is not started");
|
|
187
|
+
const id = `tg_${++this.#requestId}`;
|
|
188
|
+
const frame = { ...command, id } as RpcCommand;
|
|
189
|
+
const pending = Promise.withResolvers<RpcResponse>();
|
|
190
|
+
const timer = setTimeout(() => {
|
|
191
|
+
this.#pending.delete(id);
|
|
192
|
+
pending.reject(new Error(`Timed out waiting for OMP RPC ${command.type}${this.#stderr ? `: ${this.#stderr}` : ""}`));
|
|
193
|
+
}, timeoutMs);
|
|
194
|
+
timer.unref?.();
|
|
195
|
+
this.#pending.set(id, {
|
|
196
|
+
command: command.type,
|
|
197
|
+
timer,
|
|
198
|
+
resolve: pending.resolve,
|
|
199
|
+
reject: pending.reject,
|
|
200
|
+
});
|
|
201
|
+
try {
|
|
202
|
+
this.write(frame);
|
|
203
|
+
} catch (error) {
|
|
204
|
+
clearTimeout(timer);
|
|
205
|
+
this.#pending.delete(id);
|
|
206
|
+
pending.reject(error instanceof Error ? error : new Error(String(error)));
|
|
207
|
+
}
|
|
208
|
+
return pending.promise;
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
write(frame: RpcInboundFrame): void {
|
|
212
|
+
const child = this.#child;
|
|
213
|
+
if (!child) throw new Error("OMP RPC client is not started");
|
|
214
|
+
child.stdin.write(`${JSON.stringify(frame)}\n`);
|
|
215
|
+
}
|
|
216
|
+
|
|
217
|
+
async #readStdout(child: ChildProcessWithoutNullStreams, onReady: (frame: Record<string, unknown>) => void): Promise<void> {
|
|
218
|
+
const textDecoder = new TextDecoder();
|
|
219
|
+
const frameDecoder = new RpcFrameDecoder();
|
|
220
|
+
let buffer = "";
|
|
221
|
+
let maxFrameBytes = 1024 * 1024;
|
|
222
|
+
for await (const chunk of child.stdout) {
|
|
223
|
+
buffer += textDecoder.decode(chunk, { stream: true });
|
|
224
|
+
while (true) {
|
|
225
|
+
const newline = buffer.indexOf("\n");
|
|
226
|
+
if (newline < 0) break;
|
|
227
|
+
const line = buffer.slice(0, newline).trim();
|
|
228
|
+
buffer = buffer.slice(newline + 1);
|
|
229
|
+
if (!line) continue;
|
|
230
|
+
if (Buffer.byteLength(line) > maxFrameBytes) throw new Error("OMP RPC physical frame exceeded the advertised limit");
|
|
231
|
+
const parsed: unknown = JSON.parse(line);
|
|
232
|
+
if (!isRecord(parsed)) throw new Error("OMP RPC frame is not an object");
|
|
233
|
+
if (isRpcReady(parsed)) {
|
|
234
|
+
if (typeof parsed.maxFrameBytes === "number" && Number.isSafeInteger(parsed.maxFrameBytes) && parsed.maxFrameBytes > 0) {
|
|
235
|
+
maxFrameBytes = parsed.maxFrameBytes;
|
|
236
|
+
}
|
|
237
|
+
if (
|
|
238
|
+
typeof parsed.maxReassembledFrameBytes === "number" &&
|
|
239
|
+
Number.isSafeInteger(parsed.maxReassembledFrameBytes) &&
|
|
240
|
+
parsed.maxReassembledFrameBytes > 0
|
|
241
|
+
) {
|
|
242
|
+
frameDecoder.setMaxBytes(parsed.maxReassembledFrameBytes);
|
|
243
|
+
}
|
|
244
|
+
onReady(parsed);
|
|
245
|
+
continue;
|
|
246
|
+
}
|
|
247
|
+
if (parsed.type === "rpc_chunk" && this.#protocolVersion !== 2) {
|
|
248
|
+
throw new Error("OMP sent an RPC chunk before protocol-v2 negotiation");
|
|
249
|
+
}
|
|
250
|
+
const decoded = frameDecoder.push(parsed);
|
|
251
|
+
if (decoded === undefined) continue;
|
|
252
|
+
if (!isRecord(decoded)) throw new Error("Decoded OMP RPC frame is not an object");
|
|
253
|
+
this.#handleFrame(decoded);
|
|
254
|
+
}
|
|
255
|
+
if (Buffer.byteLength(buffer) > maxFrameBytes) throw new Error("OMP RPC unterminated frame exceeded the advertised limit");
|
|
256
|
+
}
|
|
257
|
+
buffer += textDecoder.decode();
|
|
258
|
+
if (buffer.trim().length > 0) throw new Error("OMP RPC stdout ended with an incomplete frame");
|
|
259
|
+
}
|
|
260
|
+
|
|
261
|
+
async #readStderr(child: ChildProcessWithoutNullStreams): Promise<void> {
|
|
262
|
+
const decoder = new TextDecoder();
|
|
263
|
+
for await (const chunk of child.stderr) {
|
|
264
|
+
this.#stderr += decoder.decode(chunk, { stream: true });
|
|
265
|
+
if (Buffer.byteLength(this.#stderr) > this.#options.maxStderrBytes) {
|
|
266
|
+
this.#stderr = this.#stderr.slice(-this.#options.maxStderrBytes);
|
|
267
|
+
}
|
|
268
|
+
}
|
|
269
|
+
this.#stderr += decoder.decode();
|
|
270
|
+
}
|
|
271
|
+
|
|
272
|
+
#handleFrame(frame: Record<string, unknown>): void {
|
|
273
|
+
if (isRpcResponse(frame) && frame.id) {
|
|
274
|
+
const pending = this.#pending.get(frame.id);
|
|
275
|
+
if (pending) {
|
|
276
|
+
this.#pending.delete(frame.id);
|
|
277
|
+
clearTimeout(pending.timer);
|
|
278
|
+
if (frame.success) pending.resolve(frame);
|
|
279
|
+
else pending.reject(new RpcCommandError(frame.error ?? `${pending.command} failed`, frame.command, frame.code));
|
|
280
|
+
return;
|
|
281
|
+
}
|
|
282
|
+
}
|
|
283
|
+
const delivery = this.#eventQueue.then(async () => {
|
|
284
|
+
for (const listener of this.#frameListeners) await listener(frame);
|
|
285
|
+
});
|
|
286
|
+
this.#eventQueue = delivery.catch(() => {});
|
|
287
|
+
}
|
|
288
|
+
|
|
289
|
+
async #handleUnexpectedExit(error: Error): Promise<void> {
|
|
290
|
+
const child = this.#child;
|
|
291
|
+
if (this.#stopping || !child) return;
|
|
292
|
+
this.#child = undefined;
|
|
293
|
+
this.#ready = false;
|
|
294
|
+
for (const pending of this.#pending.values()) {
|
|
295
|
+
clearTimeout(pending.timer);
|
|
296
|
+
pending.reject(error);
|
|
297
|
+
}
|
|
298
|
+
this.#pending.clear();
|
|
299
|
+
|
|
300
|
+
if (child.exitCode == null && child.signalCode == null) {
|
|
301
|
+
child.kill("SIGTERM");
|
|
302
|
+
const exited = this.#exited ?? Promise.resolve();
|
|
303
|
+
if (!(await Promise.race([exited.then(() => true), sleep(1_500, false)]))) child.kill("SIGKILL");
|
|
304
|
+
await exited;
|
|
305
|
+
}
|
|
306
|
+
for (const listener of this.#exitListeners) await listener(error);
|
|
307
|
+
}
|
|
308
|
+
}
|
|
@@ -0,0 +1,70 @@
|
|
|
1
|
+
import { lstatSync, readFileSync } from "node:fs";
|
|
2
|
+
|
|
3
|
+
export interface RpcRuntimeConfig {
|
|
4
|
+
cwd: string;
|
|
5
|
+
stateDir: string;
|
|
6
|
+
profile: string;
|
|
7
|
+
ompCommand: string;
|
|
8
|
+
model?: string;
|
|
9
|
+
resume?: string;
|
|
10
|
+
sessionDir?: string;
|
|
11
|
+
configFiles: string[];
|
|
12
|
+
ompArgs: string[];
|
|
13
|
+
authBrokerTokenFile?: string;
|
|
14
|
+
allowRpcBash: boolean;
|
|
15
|
+
inheritHarness: boolean;
|
|
16
|
+
autoRestart: boolean;
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
export function buildOmpRpcArgv(config: RpcRuntimeConfig, resume = config.resume): string[] {
|
|
20
|
+
const argv = [config.ompCommand, "--mode", "rpc-ui", "--cwd", config.cwd, "--profile", config.profile, "--no-title"];
|
|
21
|
+
if (resume) argv.push("--resume", resume);
|
|
22
|
+
if (config.model) argv.push("--model", config.model);
|
|
23
|
+
if (config.sessionDir) argv.push("--session-dir", config.sessionDir);
|
|
24
|
+
for (const file of config.configFiles) argv.push("--config", file);
|
|
25
|
+
argv.push(...config.ompArgs);
|
|
26
|
+
return argv;
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
function readPrivateFile(path: string, label: string): string {
|
|
30
|
+
const info = lstatSync(path);
|
|
31
|
+
if (info.isSymbolicLink() || !info.isFile()) throw new Error(`${label} must be a regular file, not a symlink`);
|
|
32
|
+
if (process.platform !== "win32") {
|
|
33
|
+
const uid = process.getuid?.();
|
|
34
|
+
if (uid != null && info.uid !== uid) throw new Error(`${label} must be owned by the current user`);
|
|
35
|
+
if ((info.mode & 0o077) !== 0) throw new Error(`${label} permissions must be 0600 or stricter`);
|
|
36
|
+
}
|
|
37
|
+
return readFileSync(path, "utf8");
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
/** OMP gets the normal harness environment without the Telegram transport token. */
|
|
41
|
+
export function buildOmpChildEnv(
|
|
42
|
+
env: NodeJS.ProcessEnv = process.env,
|
|
43
|
+
config?: Pick<RpcRuntimeConfig, "authBrokerTokenFile">,
|
|
44
|
+
): Record<string, string | undefined> {
|
|
45
|
+
const childEnv: Record<string, string | undefined> = { ...env };
|
|
46
|
+
delete childEnv.TELEGRAM_BOT_TOKEN;
|
|
47
|
+
if (config?.authBrokerTokenFile) {
|
|
48
|
+
childEnv.OMP_AUTH_BROKER_TOKEN = readPrivateFile(config.authBrokerTokenFile, "Auth broker token file").trim();
|
|
49
|
+
if (!childEnv.OMP_AUTH_BROKER_TOKEN) throw new Error("Auth broker token file is empty");
|
|
50
|
+
}
|
|
51
|
+
return childEnv;
|
|
52
|
+
}
|
|
53
|
+
/** Load literal KEY=VALUE pairs. Existing process variables always win. */
|
|
54
|
+
export function loadLiteralEnvFile(path: string, env: NodeJS.ProcessEnv = process.env): void {
|
|
55
|
+
const content = readPrivateFile(path, "Environment file");
|
|
56
|
+
for (const rawLine of content.split(/\r?\n/)) {
|
|
57
|
+
const line = rawLine.trim();
|
|
58
|
+
if (!line || line.startsWith("#")) continue;
|
|
59
|
+
const normalized = line.startsWith("export ") ? line.slice(7).trim() : line;
|
|
60
|
+
const split = normalized.indexOf("=");
|
|
61
|
+
if (split <= 0) continue;
|
|
62
|
+
const key = normalized.slice(0, split).trim();
|
|
63
|
+
if (!/^[A-Za-z_][A-Za-z0-9_]*$/.test(key) || env[key] !== undefined) continue;
|
|
64
|
+
let value = normalized.slice(split + 1).trim();
|
|
65
|
+
if ((value.startsWith('"') && value.endsWith('"')) || (value.startsWith("'") && value.endsWith("'"))) {
|
|
66
|
+
value = value.slice(1, -1);
|
|
67
|
+
}
|
|
68
|
+
env[key] = value;
|
|
69
|
+
}
|
|
70
|
+
}
|
|
@@ -0,0 +1,215 @@
|
|
|
1
|
+
import {
|
|
2
|
+
chmodSync,
|
|
3
|
+
copyFileSync,
|
|
4
|
+
existsSync,
|
|
5
|
+
lstatSync,
|
|
6
|
+
mkdirSync,
|
|
7
|
+
readlinkSync,
|
|
8
|
+
readdirSync,
|
|
9
|
+
realpathSync,
|
|
10
|
+
renameSync,
|
|
11
|
+
rmSync,
|
|
12
|
+
symlinkSync,
|
|
13
|
+
writeFileSync,
|
|
14
|
+
} from "node:fs";
|
|
15
|
+
import { homedir } from "node:os";
|
|
16
|
+
import { basename, dirname, join, relative, resolve } from "node:path";
|
|
17
|
+
import { randomUUID } from "node:crypto";
|
|
18
|
+
import type { RpcRuntimeConfig } from "./rpc-config";
|
|
19
|
+
import type { GatewayConfig } from "./gateway-config";
|
|
20
|
+
|
|
21
|
+
const SNAPSHOT_DIRECTORIES = ["skills", "rules", "commands", "agents", "docs", "bin"] as const;
|
|
22
|
+
const COPIED_FILES = ["AGENTS.md", "RULES.md", "SYSTEM.md", "WATCHDOG.md", "APPEND_SYSTEM.md", "config.yml", "mcp.json", "models.yml", "ssh.json"] as const;
|
|
23
|
+
const SNAPSHOT_IGNORED_DIRECTORIES = new Set([
|
|
24
|
+
".git",
|
|
25
|
+
".venv",
|
|
26
|
+
".venv_old",
|
|
27
|
+
".cache",
|
|
28
|
+
".mypy_cache",
|
|
29
|
+
".pytest_cache",
|
|
30
|
+
".ruff_cache",
|
|
31
|
+
"__pycache__",
|
|
32
|
+
"node_modules",
|
|
33
|
+
"logs",
|
|
34
|
+
"browser_profile",
|
|
35
|
+
"browser_profile_old",
|
|
36
|
+
"browser-profile",
|
|
37
|
+
"chrome_profile",
|
|
38
|
+
"chrome-profile",
|
|
39
|
+
]);
|
|
40
|
+
|
|
41
|
+
/** Materialize OMP's experimental memory and auto-learn settings in OmpClaw-owned state. */
|
|
42
|
+
export function prepareLearningOverlay(config: Pick<GatewayConfig, "stateDir" | "learning">): string | undefined {
|
|
43
|
+
if (!config.learning.enabled) return undefined;
|
|
44
|
+
const memoryDirectory = join(config.stateDir, "memory");
|
|
45
|
+
mkdirSync(memoryDirectory, { recursive: true, mode: 0o700 });
|
|
46
|
+
const path = join(config.stateDir, "omp-learning.json");
|
|
47
|
+
const temporary = `${path}.${process.pid}.${randomUUID()}.tmp`;
|
|
48
|
+
const content = {
|
|
49
|
+
memory: { backend: "mnemopi" },
|
|
50
|
+
mnemopi: {
|
|
51
|
+
dbPath: join(memoryDirectory, "mnemopi.sqlite"),
|
|
52
|
+
bank: "gateway",
|
|
53
|
+
scoping: "global",
|
|
54
|
+
autoRecall: true,
|
|
55
|
+
autoRetain: true,
|
|
56
|
+
},
|
|
57
|
+
autolearn: {
|
|
58
|
+
enabled: true,
|
|
59
|
+
autoContinue: config.learning.autoCapture,
|
|
60
|
+
minToolCalls: config.learning.minToolCalls,
|
|
61
|
+
},
|
|
62
|
+
providers: { memoryModel: config.learning.memoryModel },
|
|
63
|
+
};
|
|
64
|
+
writeFileSync(temporary, `${JSON.stringify(content, null, 2)}\n`, { mode: 0o600 });
|
|
65
|
+
renameSync(temporary, path);
|
|
66
|
+
if (process.platform !== "win32") chmodSync(path, 0o600);
|
|
67
|
+
return path;
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
/**
|
|
71
|
+
* Seed a named transport profile with a read-only snapshot of the default
|
|
72
|
+
* profile's harness surface. The gateway's writable managed skills, memories,
|
|
73
|
+
* configuration, databases, sessions, blobs, credentials, and environment
|
|
74
|
+
* remain in its named profile and never flow back into the default profile.
|
|
75
|
+
*/
|
|
76
|
+
export function prepareInheritedHarness(config: RpcRuntimeConfig): string | undefined {
|
|
77
|
+
if (!config.inheritHarness) return undefined;
|
|
78
|
+
if (!/^[A-Za-z0-9._-]+$/.test(config.profile)) throw new Error("Profile name contains unsupported characters");
|
|
79
|
+
const root = process.env.OMP_HOME ?? join(homedir(), ".omp");
|
|
80
|
+
const source = join(root, "agent");
|
|
81
|
+
const target = join(root, "profiles", config.profile, "agent");
|
|
82
|
+
if (!existsSync(source)) throw new Error(`Default OMP agent directory not found: ${source}`);
|
|
83
|
+
mkdirSync(target, { recursive: true, mode: 0o700 });
|
|
84
|
+
const snapshotRoot = join(target, ".gateway-inherited");
|
|
85
|
+
mkdirSync(snapshotRoot, { recursive: true, mode: 0o700 });
|
|
86
|
+
|
|
87
|
+
for (const name of SNAPSHOT_DIRECTORIES) {
|
|
88
|
+
const from = join(source, name);
|
|
89
|
+
const to = join(target, name);
|
|
90
|
+
if (!existsSync(from)) continue;
|
|
91
|
+
snapshotDirectory(from, to, snapshotRoot, name);
|
|
92
|
+
}
|
|
93
|
+
for (const name of COPIED_FILES) {
|
|
94
|
+
const from = join(source, name);
|
|
95
|
+
const to = join(target, name);
|
|
96
|
+
if (!existsSync(from)) continue;
|
|
97
|
+
snapshotFile(from, to);
|
|
98
|
+
}
|
|
99
|
+
return target;
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
function snapshotFile(from: string, to: string): void {
|
|
103
|
+
const temporary = `${to}.gateway-next-${process.pid}-${randomUUID()}`;
|
|
104
|
+
try {
|
|
105
|
+
copyFileSync(from, temporary);
|
|
106
|
+
if (process.platform !== "win32") chmodSync(temporary, 0o444);
|
|
107
|
+
renameSync(temporary, to);
|
|
108
|
+
} catch (error) {
|
|
109
|
+
rmSync(temporary, { force: true });
|
|
110
|
+
throw error;
|
|
111
|
+
}
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
function snapshotDirectory(from: string, to: string, snapshotRoot: string, name: string): void {
|
|
115
|
+
let existing: ReturnType<typeof lstatSync> | undefined;
|
|
116
|
+
try {
|
|
117
|
+
existing = lstatSync(to);
|
|
118
|
+
} catch {
|
|
119
|
+
existing = undefined;
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
let previousSnapshot: string | undefined;
|
|
123
|
+
if (existing !== undefined) {
|
|
124
|
+
if (!existing.isSymbolicLink()) return;
|
|
125
|
+
const destination = resolve(dirname(to), readlinkSync(to));
|
|
126
|
+
const ownedPrefix = `${resolve(snapshotRoot, name)}-`;
|
|
127
|
+
if (destination !== resolve(from) && !destination.startsWith(ownedPrefix)) return;
|
|
128
|
+
if (destination.startsWith(ownedPrefix)) previousSnapshot = destination;
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
const snapshot = join(snapshotRoot, `${name}-${randomUUID()}`);
|
|
132
|
+
const nextLink = `${to}.gateway-next-${process.pid}-${randomUUID()}`;
|
|
133
|
+
try {
|
|
134
|
+
copySnapshotTree(from, snapshot);
|
|
135
|
+
makeTreeReadOnly(snapshot);
|
|
136
|
+
symlinkSync(relative(dirname(to), snapshot), nextLink, "dir");
|
|
137
|
+
renameSync(nextLink, to);
|
|
138
|
+
} catch (error) {
|
|
139
|
+
rmSync(nextLink, { force: true });
|
|
140
|
+
makeTreeRemovable(snapshot);
|
|
141
|
+
rmSync(snapshot, { force: true, recursive: true });
|
|
142
|
+
throw error;
|
|
143
|
+
}
|
|
144
|
+
if (previousSnapshot !== undefined) {
|
|
145
|
+
makeTreeRemovable(previousSnapshot);
|
|
146
|
+
rmSync(previousSnapshot, { force: true, recursive: true });
|
|
147
|
+
}
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
function copySnapshotTree(source: string, destination: string, ancestors: ReadonlySet<string> = new Set()): void {
|
|
151
|
+
const name = basename(source);
|
|
152
|
+
if (name === ".DS_Store" || name.startsWith(".env") || name.startsWith(".chrome")) return;
|
|
153
|
+
|
|
154
|
+
let resolved: string;
|
|
155
|
+
let info: ReturnType<typeof lstatSync>;
|
|
156
|
+
try {
|
|
157
|
+
resolved = realpathSync(source);
|
|
158
|
+
info = lstatSync(resolved);
|
|
159
|
+
} catch (error) {
|
|
160
|
+
if (isMissingPath(error)) return;
|
|
161
|
+
throw error;
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
if (info.isDirectory()) {
|
|
165
|
+
if (isIgnoredSnapshotDirectory(name) || ancestors.has(resolved)) return;
|
|
166
|
+
mkdirSync(destination, { mode: 0o700 });
|
|
167
|
+
const nestedAncestors = new Set(ancestors);
|
|
168
|
+
nestedAncestors.add(resolved);
|
|
169
|
+
let entries: string[];
|
|
170
|
+
try {
|
|
171
|
+
entries = readdirSync(source);
|
|
172
|
+
} catch (error) {
|
|
173
|
+
if (isMissingPath(error)) return;
|
|
174
|
+
throw error;
|
|
175
|
+
}
|
|
176
|
+
for (const entry of entries) copySnapshotTree(join(source, entry), join(destination, entry), nestedAncestors);
|
|
177
|
+
return;
|
|
178
|
+
}
|
|
179
|
+
if (!info.isFile()) return;
|
|
180
|
+
try {
|
|
181
|
+
copyFileSync(source, destination);
|
|
182
|
+
} catch (error) {
|
|
183
|
+
if (!isMissingPath(error)) throw error;
|
|
184
|
+
}
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
function isIgnoredSnapshotDirectory(name: string): boolean {
|
|
188
|
+
return SNAPSHOT_IGNORED_DIRECTORIES.has(name)
|
|
189
|
+
|| name.startsWith("browser_profile")
|
|
190
|
+
|| name.startsWith("browser-profile")
|
|
191
|
+
|| name.startsWith("chrome_profile")
|
|
192
|
+
|| name.startsWith("chrome-profile");
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
function isMissingPath(error: unknown): boolean {
|
|
196
|
+
return typeof error === "object" && error !== null && "code" in error && error.code === "ENOENT";
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
function makeTreeReadOnly(path: string): void {
|
|
200
|
+
const info = lstatSync(path);
|
|
201
|
+
if (info.isDirectory()) {
|
|
202
|
+
for (const entry of readdirSync(path)) makeTreeReadOnly(join(path, entry));
|
|
203
|
+
if (process.platform !== "win32") chmodSync(path, 0o555);
|
|
204
|
+
return;
|
|
205
|
+
}
|
|
206
|
+
if (process.platform !== "win32") chmodSync(path, (info.mode & 0o111) === 0 ? 0o444 : 0o555);
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
function makeTreeRemovable(path: string): void {
|
|
210
|
+
if (!existsSync(path) || process.platform === "win32") return;
|
|
211
|
+
const info = lstatSync(path);
|
|
212
|
+
if (!info.isDirectory()) return;
|
|
213
|
+
chmodSync(path, 0o700);
|
|
214
|
+
for (const entry of readdirSync(path)) makeTreeRemovable(join(path, entry));
|
|
215
|
+
}
|