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.
@@ -0,0 +1,379 @@
1
+ import { lstatSync, readFileSync } from "node:fs";
2
+ import { homedir } from "node:os";
3
+ import { isAbsolute, resolve } from "node:path";
4
+ import type { RpcRuntimeConfig } from "./rpc-config";
5
+ import { isRecord } from "./type-guards";
6
+
7
+ const MAX_CONFIG_BYTES = 256 * 1024;
8
+ const MAX_STRING_LENGTH = 4_096;
9
+ const MAX_OMP_ARGS = 64;
10
+ const MAX_TRANSPORT_CREDENTIALS = 128;
11
+ const GATEWAY_SECRET_ENV = /^OMPCLAW_[A-Z][A-Z0-9_]*$/;
12
+ const TELEGRAM_SECRET_ENV = "TELEGRAM_BOT_TOKEN";
13
+
14
+ export interface GatewayOmpConfig {
15
+ readonly command: string;
16
+ readonly model?: string;
17
+ readonly resume?: string;
18
+ readonly sessionDir?: string;
19
+ readonly configFiles: readonly string[];
20
+ readonly args: readonly string[];
21
+ readonly authBrokerTokenFile?: string;
22
+ readonly allowRpcBash: boolean;
23
+ readonly inheritHarness: boolean;
24
+ readonly autoRestart: boolean;
25
+ }
26
+
27
+ export interface GatewayTelegramConfig {
28
+ readonly enabled: boolean;
29
+ readonly account: string;
30
+ readonly tokenEnv: string;
31
+ }
32
+
33
+ export interface GatewayWebSocketCredentialConfig {
34
+ readonly tokenEnv: string;
35
+ readonly subject: string;
36
+ readonly channel: string;
37
+ readonly thread?: string;
38
+ }
39
+
40
+ export interface GatewayWebSocketConfig {
41
+ readonly enabled: boolean;
42
+ readonly hostname: string;
43
+ readonly port: number;
44
+ readonly account: string;
45
+ readonly credentials: readonly GatewayWebSocketCredentialConfig[];
46
+ }
47
+
48
+ export interface GatewayAutomationConfig {
49
+ readonly enabled: boolean;
50
+ readonly pollIntervalMs: number;
51
+ readonly retryDelayMs: number;
52
+ readonly maxAttempts: number;
53
+ }
54
+
55
+ export type GatewayMemoryModel = "online" | "qwen3-1.7b" | "llama3.2:3b" | "gemma-3-1b" | "qwen2.5-1.5b" | "lfm2-1.2b";
56
+
57
+ export interface GatewayLearningConfig {
58
+ readonly enabled: boolean;
59
+ readonly autoCapture: boolean;
60
+ readonly minToolCalls: number;
61
+ readonly memoryModel: GatewayMemoryModel;
62
+ }
63
+
64
+ export interface GatewayConfig {
65
+ readonly workspace: string;
66
+ readonly stateDir: string;
67
+ readonly profile: string;
68
+ readonly omp: GatewayOmpConfig;
69
+ readonly transports: {
70
+ readonly telegram?: GatewayTelegramConfig;
71
+ readonly websocket?: GatewayWebSocketConfig;
72
+ };
73
+ readonly automation: GatewayAutomationConfig;
74
+ readonly learning: GatewayLearningConfig;
75
+ }
76
+
77
+ export interface GatewaySecrets {
78
+ readonly telegramToken?: string;
79
+ readonly webSocketCredentials: readonly {
80
+ readonly token: string;
81
+ readonly subject: string;
82
+ readonly channel: string;
83
+ readonly thread?: string;
84
+ }[];
85
+ }
86
+
87
+ export interface LoadGatewayConfigOptions {
88
+ readonly path?: string;
89
+ readonly cwd?: string;
90
+ }
91
+
92
+ /** Expand a home-relative path without allowing a different user's home. */
93
+ export function expandGatewayPath(path: string, cwd: string = process.cwd()): string {
94
+ const value = nonEmptyString(path, "path");
95
+ if (value === "~") return homedir();
96
+ if (value.startsWith("~/")) return resolve(homedir(), value.slice(2));
97
+ return isAbsolute(value) ? resolve(value) : resolve(cwd, value);
98
+ }
99
+
100
+ /**
101
+ * Load the intentionally small, token-free gateway JSON document. Missing config
102
+ * is useful for tooling and has safe local defaults; a provided document is exact.
103
+ */
104
+ export function loadGatewayConfig(options: LoadGatewayConfigOptions = {}): GatewayConfig {
105
+ const cwd = resolve(options.cwd ?? process.cwd());
106
+ if (options.path === undefined) return parseGatewayConfig({}, cwd);
107
+
108
+ const path = expandGatewayPath(options.path, cwd);
109
+ const info = lstatSync(path);
110
+ if (!info.isFile() || info.isSymbolicLink()) throw new Error("OmpClaw config must be a regular file, not a symlink");
111
+ if (info.size > MAX_CONFIG_BYTES) throw new Error(`OmpClaw config exceeds ${MAX_CONFIG_BYTES} bytes`);
112
+
113
+ let parsed: unknown;
114
+ try {
115
+ parsed = JSON.parse(readFileSync(path, "utf8"));
116
+ } catch (error) {
117
+ throw new Error(`OmpClaw config is not valid JSON: ${error instanceof Error ? error.message : String(error)}`);
118
+ }
119
+ return parseGatewayConfig(parsed, cwd);
120
+ }
121
+
122
+ /** Parse a JSON value for callers that already bound file loading. */
123
+ export function parseGatewayConfig(value: unknown, cwd: string = process.cwd()): GatewayConfig {
124
+ const root = object(value, "OmpClaw config");
125
+ rejectUnknown(root, ["workspace", "stateDir", "profile", "omp", "transports", "automation", "learning"], "OmpClaw config");
126
+
127
+ const workspace = root.workspace === undefined ? resolve(cwd) : expandGatewayPath(string(root.workspace, "workspace"), cwd);
128
+ const stateDir = root.stateDir === undefined
129
+ ? resolve(homedir(), ".omp", "agent", "ompclaw")
130
+ : expandGatewayPath(string(root.stateDir, "stateDir"), cwd);
131
+ const profile = root.profile === undefined ? "ompclaw" : identifier(root.profile, "profile");
132
+ const omp = parseOmp(root.omp, cwd);
133
+ const transports = parseTransports(root.transports);
134
+ const automation = parseAutomation(root.automation);
135
+ const learning = parseLearning(root.learning);
136
+
137
+ return { workspace, stateDir, profile, omp, transports, automation, learning };
138
+ }
139
+
140
+ /** Resolve only the env names carried by config, keeping token values out of it. */
141
+ export function resolveGatewaySecrets(config: GatewayConfig, env: NodeJS.ProcessEnv = process.env): GatewaySecrets {
142
+ const telegram = config.transports.telegram;
143
+ const telegramToken = telegram?.enabled ? requiredEnv(env, telegram.tokenEnv) : undefined;
144
+ const webSocketCredentials = config.transports.websocket?.enabled
145
+ ? config.transports.websocket.credentials.map((credential) => ({
146
+ token: requiredEnv(env, credential.tokenEnv),
147
+ subject: credential.subject,
148
+ channel: credential.channel,
149
+ ...(credential.thread === undefined ? {} : { thread: credential.thread }),
150
+ }))
151
+ : [];
152
+ return {
153
+ ...(telegramToken === undefined ? {} : { telegramToken }),
154
+ webSocketCredentials,
155
+ };
156
+ }
157
+
158
+ /** Convert OmpClaw-owned OMP settings into the existing RPC runtime contract. */
159
+ export function gatewayRpcRuntimeConfig(config: GatewayConfig): RpcRuntimeConfig {
160
+ return {
161
+ cwd: config.workspace,
162
+ stateDir: config.stateDir,
163
+ profile: config.profile,
164
+ ompCommand: config.omp.command,
165
+ ...(config.omp.model === undefined ? {} : { model: config.omp.model }),
166
+ ...(config.omp.resume === undefined ? {} : { resume: config.omp.resume }),
167
+ ...(config.omp.sessionDir === undefined ? {} : { sessionDir: config.omp.sessionDir }),
168
+ configFiles: [...config.omp.configFiles],
169
+ ompArgs: [...config.omp.args],
170
+ ...(config.omp.authBrokerTokenFile === undefined ? {} : { authBrokerTokenFile: config.omp.authBrokerTokenFile }),
171
+ allowRpcBash: config.omp.allowRpcBash,
172
+ inheritHarness: config.omp.inheritHarness,
173
+ autoRestart: config.omp.autoRestart,
174
+ };
175
+ }
176
+
177
+ /** The OMP child must not inherit any gateway transport secret environment. */
178
+ export function stripGatewaySecretsFromChildEnv(env: NodeJS.ProcessEnv): Record<string, string | undefined> {
179
+ const child: Record<string, string | undefined> = { ...env };
180
+ for (const key of Object.keys(child)) {
181
+ if (
182
+ key === TELEGRAM_SECRET_ENV ||
183
+ key.startsWith("OMPCLAW_") ||
184
+ key.startsWith("OMP_GATEWAY_") ||
185
+ key.startsWith("GATEWAY_") ||
186
+ key.startsWith("OMP_TRANSPORT_") ||
187
+ key.startsWith("OMP_WEBSOCKET_") ||
188
+ key.startsWith("WEBSOCKET_")
189
+ ) {
190
+ delete child[key];
191
+ }
192
+ }
193
+ return child;
194
+ }
195
+
196
+ function parseOmp(value: unknown, cwd: string): GatewayOmpConfig {
197
+ if (value === undefined) {
198
+ return {
199
+ command: "omp",
200
+ configFiles: [],
201
+ args: [],
202
+ allowRpcBash: false,
203
+ inheritHarness: false,
204
+ autoRestart: true,
205
+ };
206
+ }
207
+ const omp = object(value, "omp");
208
+ rejectUnknown(
209
+ omp,
210
+ ["command", "model", "resume", "sessionDir", "configFiles", "args", "authBrokerTokenFile", "allowRpcBash", "inheritHarness", "autoRestart"],
211
+ "omp",
212
+ );
213
+ const configFiles = stringArray(omp.configFiles, "omp.configFiles", MAX_OMP_ARGS).map((path) => expandGatewayPath(path, cwd));
214
+ const args = stringArray(omp.args, "omp.args", MAX_OMP_ARGS);
215
+ return {
216
+ command: omp.command === undefined ? "omp" : nonEmptyString(omp.command, "omp.command"),
217
+ ...(omp.model === undefined ? {} : { model: nonEmptyString(omp.model, "omp.model") }),
218
+ ...(omp.resume === undefined ? {} : { resume: expandGatewayPath(string(omp.resume, "omp.resume"), cwd) }),
219
+ ...(omp.sessionDir === undefined ? {} : { sessionDir: expandGatewayPath(string(omp.sessionDir, "omp.sessionDir"), cwd) }),
220
+ configFiles,
221
+ args,
222
+ ...(omp.authBrokerTokenFile === undefined
223
+ ? {}
224
+ : { authBrokerTokenFile: expandGatewayPath(string(omp.authBrokerTokenFile, "omp.authBrokerTokenFile"), cwd) }),
225
+ allowRpcBash: boolean(omp.allowRpcBash, "omp.allowRpcBash", false),
226
+ inheritHarness: boolean(omp.inheritHarness, "omp.inheritHarness", false),
227
+ autoRestart: boolean(omp.autoRestart, "omp.autoRestart", true),
228
+ };
229
+ }
230
+
231
+ function parseTransports(value: unknown): GatewayConfig["transports"] {
232
+ if (value === undefined) return {};
233
+ const transports = object(value, "transports");
234
+ rejectUnknown(transports, ["telegram", "websocket"], "transports");
235
+ return {
236
+ ...(transports.telegram === undefined ? {} : { telegram: parseTelegram(transports.telegram) }),
237
+ ...(transports.websocket === undefined ? {} : { websocket: parseWebSocket(transports.websocket) }),
238
+ };
239
+ }
240
+
241
+ function parseTelegram(value: unknown): GatewayTelegramConfig {
242
+ const telegram = object(value, "transports.telegram");
243
+ rejectUnknown(telegram, ["enabled", "account", "tokenEnv"], "transports.telegram");
244
+ const tokenEnv = secretEnvName(telegram.tokenEnv, "transports.telegram.tokenEnv", true);
245
+ return {
246
+ enabled: boolean(telegram.enabled, "transports.telegram.enabled"),
247
+ account: identifier(telegram.account, "transports.telegram.account"),
248
+ tokenEnv,
249
+ };
250
+ }
251
+
252
+ function parseWebSocket(value: unknown): GatewayWebSocketConfig {
253
+ const websocket = object(value, "transports.websocket");
254
+ rejectUnknown(websocket, ["enabled", "hostname", "port", "account", "credentials"], "transports.websocket");
255
+ const credentials = array(websocket.credentials, "transports.websocket.credentials", MAX_TRANSPORT_CREDENTIALS).map((item, index) => {
256
+ const credential = object(item, `transports.websocket.credentials[${index}]`);
257
+ rejectUnknown(credential, ["tokenEnv", "subject", "channel", "thread"], `transports.websocket.credentials[${index}]`);
258
+ return {
259
+ tokenEnv: secretEnvName(credential.tokenEnv, `transports.websocket.credentials[${index}].tokenEnv`, false),
260
+ subject: nonEmptyString(credential.subject, `transports.websocket.credentials[${index}].subject`),
261
+ channel: nonEmptyString(credential.channel, `transports.websocket.credentials[${index}].channel`),
262
+ ...(credential.thread === undefined ? {} : { thread: nonEmptyString(credential.thread, `transports.websocket.credentials[${index}].thread`) }),
263
+ };
264
+ });
265
+ if (credentials.length === 0) throw new Error("transports.websocket.credentials must not be empty");
266
+ const port = websocket.port;
267
+ if (typeof port !== "number" || !Number.isSafeInteger(port) || port < 0 || port > 65_535) {
268
+ throw new Error("transports.websocket.port must be an integer between 0 and 65535");
269
+ }
270
+ return {
271
+ enabled: boolean(websocket.enabled, "transports.websocket.enabled"),
272
+ hostname: nonEmptyString(websocket.hostname, "transports.websocket.hostname"),
273
+ port,
274
+ account: identifier(websocket.account, "transports.websocket.account"),
275
+ credentials,
276
+ };
277
+ }
278
+
279
+ function parseAutomation(value: unknown): GatewayAutomationConfig {
280
+ if (value === undefined) {
281
+ return { enabled: false, pollIntervalMs: 1_000, retryDelayMs: 15_000, maxAttempts: 3 };
282
+ }
283
+ const automation = object(value, "automation");
284
+ rejectUnknown(automation, ["enabled", "pollIntervalMs", "retryDelayMs", "maxAttempts"], "automation");
285
+ return {
286
+ enabled: boolean(automation.enabled, "automation.enabled", false),
287
+ pollIntervalMs: integer(automation.pollIntervalMs, "automation.pollIntervalMs", 250, 60_000, 1_000),
288
+ retryDelayMs: integer(automation.retryDelayMs, "automation.retryDelayMs", 1_000, 3_600_000, 15_000),
289
+ maxAttempts: integer(automation.maxAttempts, "automation.maxAttempts", 1, 10, 3),
290
+ };
291
+ }
292
+
293
+ function parseLearning(value: unknown): GatewayLearningConfig {
294
+ if (value === undefined) {
295
+ return { enabled: false, autoCapture: false, minToolCalls: 5, memoryModel: "online" };
296
+ }
297
+ const learning = object(value, "learning");
298
+ rejectUnknown(learning, ["enabled", "autoCapture", "minToolCalls", "memoryModel"], "learning");
299
+ const memoryModel = learning.memoryModel === undefined ? "online" : nonEmptyString(learning.memoryModel, "learning.memoryModel");
300
+ if (!["online", "qwen3-1.7b", "llama3.2:3b", "gemma-3-1b", "qwen2.5-1.5b", "lfm2-1.2b"].includes(memoryModel)) {
301
+ throw new Error("learning.memoryModel is not a supported OMP memory model");
302
+ }
303
+ return {
304
+ enabled: boolean(learning.enabled, "learning.enabled", false),
305
+ autoCapture: boolean(learning.autoCapture, "learning.autoCapture", false),
306
+ minToolCalls: integer(learning.minToolCalls, "learning.minToolCalls", 1, 100, 5),
307
+ memoryModel: memoryModel as GatewayMemoryModel,
308
+ };
309
+ }
310
+
311
+ function object(value: unknown, label: string): Record<string, unknown> {
312
+ if (!isRecord(value) || Array.isArray(value)) throw new Error(`${label} must be an object`);
313
+ return value;
314
+ }
315
+
316
+ function rejectUnknown(value: Record<string, unknown>, keys: readonly string[], label: string): void {
317
+ for (const key of Object.keys(value)) {
318
+ if (!keys.includes(key)) throw new Error(`${label} contains unknown key ${key}`);
319
+ }
320
+ }
321
+
322
+ function array(value: unknown, label: string, maximum: number): unknown[] {
323
+ if (!Array.isArray(value)) throw new Error(`${label} must be an array`);
324
+ if (value.length > maximum) throw new Error(`${label} exceeds ${maximum} entries`);
325
+ return value;
326
+ }
327
+
328
+ function stringArray(value: unknown, label: string, maximum: number): string[] {
329
+ if (value === undefined) return [];
330
+ return array(value, label, maximum).map((item, index) => nonEmptyString(item, `${label}[${index}]`));
331
+ }
332
+
333
+ function string(value: unknown, label: string): string {
334
+ if (typeof value !== "string") throw new Error(`${label} must be a string`);
335
+ return value;
336
+ }
337
+
338
+ function nonEmptyString(value: unknown, label: string): string {
339
+ const text = string(value, label);
340
+ if (text.length === 0 || text.length > MAX_STRING_LENGTH || text.includes("\0")) {
341
+ throw new Error(`${label} must be a non-empty bounded string`);
342
+ }
343
+ return text;
344
+ }
345
+
346
+ function identifier(value: unknown, label: string): string {
347
+ const text = nonEmptyString(value, label);
348
+ if (!/^[A-Za-z0-9][A-Za-z0-9._-]{0,127}$/.test(text)) throw new Error(`${label} must be a bounded identifier`);
349
+ return text;
350
+ }
351
+
352
+ function boolean(value: unknown, label: string, defaultValue?: boolean): boolean {
353
+ if (value === undefined && defaultValue !== undefined) return defaultValue;
354
+ if (typeof value !== "boolean") throw new Error(`${label} must be a boolean`);
355
+ return value;
356
+ }
357
+
358
+ function integer(value: unknown, label: string, minimum: number, maximum: number, defaultValue: number): number {
359
+ if (value === undefined) return defaultValue;
360
+ if (typeof value !== "number" || !Number.isSafeInteger(value) || value < minimum || value > maximum) {
361
+ throw new Error(`${label} must be an integer between ${minimum} and ${maximum}`);
362
+ }
363
+ return value;
364
+ }
365
+
366
+ function secretEnvName(value: unknown, label: string, telegram: boolean): string {
367
+ const name = nonEmptyString(value, label);
368
+ if (name === TELEGRAM_SECRET_ENV && telegram) return name;
369
+ if (!GATEWAY_SECRET_ENV.test(name)) {
370
+ throw new Error(`${label} must name an OMPCLAW_ environment variable${telegram ? ` or ${TELEGRAM_SECRET_ENV}` : ""}`);
371
+ }
372
+ return name;
373
+ }
374
+
375
+ function requiredEnv(env: NodeJS.ProcessEnv, name: string): string {
376
+ const value = env[name];
377
+ if (typeof value !== "string" || value.length === 0) throw new Error(`Required gateway credential environment variable ${name} is not set`);
378
+ return value;
379
+ }