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/src/rpc-cli.ts ADDED
@@ -0,0 +1,408 @@
1
+ #!/usr/bin/env bun
2
+ import { existsSync } from "node:fs";
3
+ import { OmpRpcClient, type OmpRpcClientOptions } from "./rpc-client";
4
+ import { buildOmpChildEnv, loadLiteralEnvFile } from "./rpc-config";
5
+ import {
6
+ expandGatewayPath,
7
+ gatewayRpcRuntimeConfig,
8
+ loadGatewayConfig,
9
+ resolveGatewaySecrets,
10
+ stripGatewaySecretsFromChildEnv,
11
+ type GatewayConfig,
12
+ } from "./gateway-config";
13
+ import { GatewayApplication } from "./gateway-app";
14
+ import { GatewayStore, type LegacyTelegramStateImportResult } from "./gateway-store";
15
+ import {
16
+ installRpcService,
17
+ resolveGatewayServicePaths,
18
+ uninstallRpcService,
19
+ type ServiceInstallResult,
20
+ } from "./rpc-service";
21
+ import type { RpcResponse, RpcSessionState } from "./rpc-protocol";
22
+ import { tg } from "./api";
23
+
24
+ const COMMANDS = [
25
+ "run",
26
+ "doctor",
27
+ "principal-add",
28
+ "identity-bind",
29
+ "telegram-allow",
30
+ "migrate-telegram",
31
+ "service-install",
32
+ "service-uninstall",
33
+ ] as const;
34
+
35
+ export type GatewayCliCommand = (typeof COMMANDS)[number];
36
+
37
+ export interface GatewayCliArgs {
38
+ readonly command: GatewayCliCommand;
39
+ readonly configPath?: string;
40
+ readonly envFile?: string;
41
+ readonly positionals: readonly string[];
42
+ }
43
+
44
+ export interface GatewayDoctorRpc {
45
+ readonly protocolVersion: number;
46
+ start(): Promise<void>;
47
+ stop(): Promise<void>;
48
+ send(command: { readonly type: "get_state" }): Promise<RpcResponse>;
49
+ }
50
+
51
+ export type GatewayTelegramCall = <Result>(
52
+ token: string,
53
+ method: string,
54
+ payload?: Record<string, unknown>,
55
+ options?: { readonly signal?: AbortSignal; readonly timeoutMs?: number },
56
+ ) => Promise<Result>;
57
+
58
+ export interface GatewayCliSeams {
59
+ readonly createApplication?: (config: GatewayConfig) => GatewayApplication;
60
+ readonly createStore?: (path: string) => GatewayStore;
61
+ readonly createDoctorRpc?: (options: OmpRpcClientOptions) => GatewayDoctorRpc;
62
+ readonly callTelegram?: GatewayTelegramCall;
63
+ readonly installService?: (config: GatewayConfig, configPath: string, envFile: string) => ServiceInstallResult;
64
+ readonly uninstallService?: () => ServiceInstallResult;
65
+ readonly write?: (line: string) => void;
66
+ }
67
+
68
+ const HELP = `ompclaw - authenticated multi-transport gateway for one persistent OMP session
69
+
70
+ Usage:
71
+ ompclaw run [--config <path>] [--env-file <path>]
72
+ ompclaw doctor [--config <path>] [--env-file <path>]
73
+ ompclaw principal-add <principal-id> [role ...] [--config <path>]
74
+ ompclaw identity-bind <transport> <account> <subject> <principal-id> [--config <path>]
75
+ ompclaw telegram-allow <numeric-user-id> [principal-id] [--config <path>]
76
+ ompclaw migrate-telegram <legacy-access.json> <legacy-rpc-state.json> [--config <path>]
77
+ ompclaw service-install --config <path> --env-file <path>
78
+ ompclaw service-uninstall
79
+
80
+ The JSON config carries only paths, OMP options, and credential environment names.
81
+ Transport token values stay in the environment and are removed from the OMP child.`;
82
+
83
+ /** Parse the small public gateway command surface without touching state. */
84
+ export function parseGatewayCliArgs(argv: readonly string[]): GatewayCliArgs {
85
+ const [commandValue, ...rest] = argv;
86
+ if (!isGatewayCliCommand(commandValue)) throw new Error(`Expected one of: ${COMMANDS.join(", ")}`);
87
+
88
+ let configPath: string | undefined;
89
+ let envFile: string | undefined;
90
+ const positionals: string[] = [];
91
+ for (let index = 0; index < rest.length; index += 1) {
92
+ const value = rest[index]!;
93
+ if (value === "--config" || value === "--env-file") {
94
+ const optionValue = rest[index + 1];
95
+ if (optionValue === undefined || optionValue.startsWith("--")) throw new Error(`${value} requires a value`);
96
+ if (value === "--config") {
97
+ if (configPath !== undefined) throw new Error("--config may be supplied only once");
98
+ configPath = optionValue;
99
+ } else {
100
+ if (envFile !== undefined) throw new Error("--env-file may be supplied only once");
101
+ envFile = optionValue;
102
+ }
103
+ index += 1;
104
+ continue;
105
+ }
106
+ if (value.startsWith("--")) throw new Error(`Unknown option ${value}`);
107
+ positionals.push(value);
108
+ }
109
+ return {
110
+ command: commandValue,
111
+ ...(configPath === undefined ? {} : { configPath }),
112
+ ...(envFile === undefined ? {} : { envFile }),
113
+ positionals,
114
+ };
115
+ }
116
+
117
+ /**
118
+ * Load config first, then copy only configured transport credentials from an
119
+ * optional private env file. Unrelated entries never enter the gateway process
120
+ * and therefore cannot leak into the OMP child.
121
+ */
122
+ export function loadGatewayCliConfig(args: GatewayCliArgs, env: NodeJS.ProcessEnv = process.env): GatewayConfig {
123
+ const config = loadGatewayConfig({ path: args.configPath });
124
+ if (args.envFile === undefined) return config;
125
+
126
+ const envFile = expandGatewayPath(args.envFile);
127
+ if (!existsSync(envFile)) throw new Error(`Environment file not found: ${envFile}`);
128
+ const fileEnv: NodeJS.ProcessEnv = {};
129
+ loadLiteralEnvFile(envFile, fileEnv);
130
+ const credentials = gatewayCredentialEnvNames(config).map((name) => {
131
+ const value = fileEnv[name];
132
+ if (typeof value !== "string" || value.length === 0) {
133
+ throw new Error(`Environment file does not define required gateway credential ${name}`);
134
+ }
135
+ return [name, value] as const;
136
+ });
137
+ for (const [name, value] of credentials) env[name] = value;
138
+ return config;
139
+ }
140
+
141
+ function gatewayCredentialEnvNames(config: GatewayConfig): string[] {
142
+ const names = new Set<string>();
143
+ const telegram = config.transports.telegram;
144
+ if (telegram?.enabled) names.add(telegram.tokenEnv);
145
+ const websocket = config.transports.websocket;
146
+ if (websocket?.enabled) {
147
+ for (const credential of websocket.credentials) names.add(credential.tokenEnv);
148
+ }
149
+ return [...names];
150
+ }
151
+
152
+ /** Database-backed principal management; arguments are fully validated before opening SQLite. */
153
+ export function principalAdd(config: GatewayConfig, positionals: readonly string[], seams: GatewayCliSeams = {}): void {
154
+ if (positionals.length === 0) throw new Error("principal-add requires a principal ID");
155
+ const id = requiredBoundedText(positionals[0], "principal ID");
156
+ const roles = positionals.length === 1
157
+ ? ["operator"]
158
+ : positionals.slice(1).map((role, index) => requiredRole(role, `role ${index + 1}`));
159
+ if (new Set(roles).size !== roles.length) throw new Error("principal roles must be unique");
160
+
161
+ const store = openStore(config, seams);
162
+ try {
163
+ store.upsertPrincipal({ id, roles });
164
+ } finally {
165
+ store.close();
166
+ }
167
+ }
168
+
169
+ /** Bind one exact external transport identity to an existing principal. */
170
+ export function identityBind(config: GatewayConfig, positionals: readonly string[], seams: GatewayCliSeams = {}): void {
171
+ if (positionals.length !== 4) throw new Error("identity-bind requires transport, account, subject, and principal ID");
172
+ const transport = requiredIdentifier(positionals[0], "transport");
173
+ const account = requiredIdentifier(positionals[1], "account");
174
+ const subject = requiredBoundedText(positionals[2], "subject");
175
+ const principalId = requiredBoundedText(positionals[3], "principal ID");
176
+
177
+ const store = openStore(config, seams);
178
+ try {
179
+ store.bindIdentity({ transport, account, subject }, principalId);
180
+ } finally {
181
+ store.close();
182
+ }
183
+ }
184
+
185
+ /** Create/update one operator principal and bind exactly one numeric Telegram identity. */
186
+ export function telegramAllow(config: GatewayConfig, positionals: readonly string[], seams: GatewayCliSeams = {}): string {
187
+ if (positionals.length < 1 || positionals.length > 2) throw new Error("telegram-allow requires a numeric Telegram user ID and optional principal ID");
188
+ const userId = requiredTelegramUserId(positionals[0]);
189
+ const account = config.transports.telegram?.account ?? "default";
190
+ const principalId = positionals[1] === undefined
191
+ ? `telegram:${account}:${userId}`
192
+ : requiredBoundedText(positionals[1], "principal ID");
193
+
194
+ const store = openStore(config, seams);
195
+ try {
196
+ store.upsertPrincipal({ id: principalId, roles: ["operator"] });
197
+ store.bindIdentity({ transport: "telegram", account, subject: userId }, principalId);
198
+ } finally {
199
+ store.close();
200
+ }
201
+ return principalId;
202
+ }
203
+
204
+ /** Invoke the store's token-free, transactional legacy Telegram importer. */
205
+ export function migrateTelegram(config: GatewayConfig, positionals: readonly string[], seams: GatewayCliSeams = {}): LegacyTelegramStateImportResult {
206
+ if (positionals.length !== 2) throw new Error("migrate-telegram requires legacy access and RPC state paths");
207
+ const accessPath = requiredExistingFile(positionals[0], "legacy access state");
208
+ const rpcStatePath = requiredExistingFile(positionals[1], "legacy RPC state");
209
+ const store = openStore(config, seams);
210
+ try {
211
+ return store.importLegacyTelegramState({ accessPath, rpcStatePath, workspace: config.workspace });
212
+ } finally {
213
+ store.close();
214
+ }
215
+ }
216
+
217
+ /** Validate credentials, database, Telegram reachability, and a short OMP get_state RPC. */
218
+ export async function doctor(config: GatewayConfig, seams: GatewayCliSeams = {}): Promise<void> {
219
+ const secrets = resolveGatewaySecrets(config);
220
+ const store = openStore(config, seams);
221
+ let sessionFile: string | undefined;
222
+ try {
223
+ const checkpoint = store.getCheckpoint("omp", "session_file");
224
+ if (checkpoint !== undefined && (typeof checkpoint !== "string" || checkpoint.length === 0)) {
225
+ throw new Error("OMP session checkpoint must be a non-empty string");
226
+ }
227
+ sessionFile = checkpoint;
228
+ } finally {
229
+ store.close();
230
+ }
231
+
232
+ const write = seams.write ?? console.log;
233
+ const telegram = config.transports.telegram;
234
+ if (telegram?.enabled) {
235
+ const callTelegram = seams.callTelegram ?? tg;
236
+ const bot = await callTelegram<{ id: number; username?: string }>(secrets.telegramToken!, "getMe");
237
+ const webhook = await callTelegram<{ url?: string; pending_update_count?: number }>(secrets.telegramToken!, "getWebhookInfo");
238
+ if (webhook.url) throw new Error(`Telegram webhook is configured at ${webhook.url}; long polling requires it to be removed`);
239
+ write(`Telegram: @${bot.username ?? bot.id}`);
240
+ write(`Webhook: none (${webhook.pending_update_count ?? 0} updates pending)`);
241
+ }
242
+
243
+ const rpcConfig = gatewayRpcRuntimeConfig(config);
244
+ const childEnv = stripGatewaySecretsFromChildEnv(buildOmpChildEnv(process.env, rpcConfig));
245
+ const client = (seams.createDoctorRpc ?? ((options: OmpRpcClientOptions) => new OmpRpcClient(options)))({
246
+ argv: [
247
+ rpcConfig.ompCommand,
248
+ "--mode",
249
+ "rpc-ui",
250
+ "--cwd",
251
+ rpcConfig.cwd,
252
+ "--profile",
253
+ rpcConfig.profile,
254
+ "--no-title",
255
+ ...(sessionFile === undefined ? [] : ["--resume", sessionFile]),
256
+ ...(sessionFile !== undefined || rpcConfig.resume === undefined ? [] : ["--resume", rpcConfig.resume]),
257
+ ...(rpcConfig.model === undefined ? [] : ["--model", rpcConfig.model]),
258
+ ...(rpcConfig.sessionDir === undefined ? [] : ["--session-dir", rpcConfig.sessionDir]),
259
+ ...rpcConfig.configFiles.flatMap((file) => ["--config", file]),
260
+ ...rpcConfig.ompArgs,
261
+ ],
262
+ cwd: rpcConfig.cwd,
263
+ env: childEnv,
264
+ });
265
+ try {
266
+ await client.start();
267
+ const response = await client.send({ type: "get_state" });
268
+ const state = response.data as RpcSessionState;
269
+ write(`OMP RPC: protocol v${client.protocolVersion}`);
270
+ write(`Session: ${state.sessionName ?? state.sessionId}`);
271
+ write(`Model: ${state.model?.provider ?? "?"}/${state.model?.id ?? "?"}`);
272
+ write("Doctor: ready");
273
+ } finally {
274
+ await client.stop();
275
+ }
276
+ }
277
+
278
+ /** Start the full application and stop it exactly once when the process is signalled. */
279
+ export async function runGateway(config: GatewayConfig, seams: GatewayCliSeams = {}): Promise<void> {
280
+ const application = (seams.createApplication ?? ((value: GatewayConfig) => new GatewayApplication({ config: value })))(config);
281
+ await application.start();
282
+ const stopped = Promise.withResolvers<void>();
283
+ const signals = process as unknown as {
284
+ once(event: "SIGINT" | "SIGTERM", listener: () => void): void;
285
+ off(event: "SIGINT" | "SIGTERM", listener: () => void): void;
286
+ };
287
+ let stopping = false;
288
+ const stop = (): void => {
289
+ if (stopping) return;
290
+ stopping = true;
291
+ signals.off("SIGINT", stop);
292
+ signals.off("SIGTERM", stop);
293
+ void application.stop().then(stopped.resolve, stopped.reject);
294
+ };
295
+ signals.once("SIGINT", stop);
296
+ signals.once("SIGTERM", stop);
297
+ await stopped.promise;
298
+ }
299
+
300
+ /** Execute a parsed command. This is exported for CLI contract tests and embedding. */
301
+ export async function executeGatewayCommand(args: GatewayCliArgs, config: GatewayConfig, seams: GatewayCliSeams = {}): Promise<void> {
302
+ const write = seams.write ?? console.log;
303
+ switch (args.command) {
304
+ case "run":
305
+ requireNoPositionals(args);
306
+ await runGateway(config, seams);
307
+ return;
308
+ case "doctor":
309
+ requireNoPositionals(args);
310
+ await doctor(config, seams);
311
+ return;
312
+ case "principal-add":
313
+ principalAdd(config, args.positionals, seams);
314
+ write(`Principal updated: ${args.positionals[0]}`);
315
+ return;
316
+ case "identity-bind":
317
+ identityBind(config, args.positionals, seams);
318
+ write(`Identity bound: ${args.positionals.slice(0, 3).join("/")}`);
319
+ return;
320
+ case "telegram-allow": {
321
+ const principalId = telegramAllow(config, args.positionals, seams);
322
+ write(`Telegram user allowed as ${principalId}`);
323
+ return;
324
+ }
325
+ case "migrate-telegram": {
326
+ const result = migrateTelegram(config, args.positionals, seams);
327
+ write(result.imported ? "Telegram state migrated" : "Telegram state was already migrated");
328
+ return;
329
+ }
330
+ case "service-install": {
331
+ requireNoPositionals(args);
332
+ const paths = resolveGatewayServicePaths(
333
+ args.configPath === undefined ? undefined : expandGatewayPath(args.configPath),
334
+ args.envFile === undefined ? undefined : expandGatewayPath(args.envFile),
335
+ );
336
+ resolveGatewaySecrets(config);
337
+ const result = (seams.installService ?? installRpcService)(config, paths.configPath, paths.envFile);
338
+ write(`Installed and started ${result.manager} service: ${result.path}`);
339
+ return;
340
+ }
341
+ case "service-uninstall": {
342
+ requireNoPositionals(args);
343
+ const result = (seams.uninstallService ?? uninstallRpcService)();
344
+ write(`Stopped and removed ${result.manager} service: ${result.path}`);
345
+ return;
346
+ }
347
+ }
348
+ }
349
+
350
+ export async function main(argv: readonly string[] = process.argv.slice(2), seams: GatewayCliSeams = {}): Promise<void> {
351
+ if (argv.length === 1 && (argv[0] === "--help" || argv[0] === "-h")) {
352
+ (seams.write ?? console.log)(HELP);
353
+ return;
354
+ }
355
+ const args = parseGatewayCliArgs(argv);
356
+ const config = loadGatewayCliConfig(args);
357
+ await executeGatewayCommand(args, config, seams);
358
+ }
359
+
360
+ function isGatewayCliCommand(value: string | undefined): value is GatewayCliCommand {
361
+ return typeof value === "string" && (COMMANDS as readonly string[]).includes(value);
362
+ }
363
+
364
+ function openStore(config: GatewayConfig, seams: GatewayCliSeams): GatewayStore {
365
+ return (seams.createStore ?? ((path: string) => new GatewayStore(path)))(`${config.stateDir}/ompclaw.sqlite`);
366
+ }
367
+
368
+ function requireNoPositionals(args: GatewayCliArgs): void {
369
+ if (args.positionals.length > 0) throw new Error(`${args.command} does not accept positional arguments`);
370
+ }
371
+
372
+ function requiredExistingFile(value: string | undefined, label: string): string {
373
+ const path = requiredBoundedText(value, label);
374
+ if (!existsSync(path)) throw new Error(`${label} file not found: ${path}`);
375
+ return path;
376
+ }
377
+
378
+ function requiredTelegramUserId(value: string | undefined): string {
379
+ const userId = requiredBoundedText(value, "Telegram user ID");
380
+ if (!/^[0-9]{1,19}$/.test(userId)) throw new Error("Telegram user ID must be numeric");
381
+ return userId;
382
+ }
383
+
384
+ function requiredIdentifier(value: string | undefined, label: string): string {
385
+ const text = requiredBoundedText(value, label);
386
+ if (!/^[A-Za-z0-9][A-Za-z0-9._-]{0,127}$/.test(text)) throw new Error(`${label} must be an identifier`);
387
+ return text;
388
+ }
389
+
390
+ function requiredRole(value: string | undefined, label: string): string {
391
+ const text = requiredIdentifier(value, label);
392
+ if (text.length > 64) throw new Error(`${label} is too long`);
393
+ return text;
394
+ }
395
+
396
+ function requiredBoundedText(value: string | undefined, label: string): string {
397
+ if (typeof value !== "string" || value.length === 0 || value.length > 256 || value.includes("\0")) {
398
+ throw new Error(`${label} must be a non-empty bounded string`);
399
+ }
400
+ return value;
401
+ }
402
+
403
+ if (import.meta.main) {
404
+ void main().catch((error: unknown) => {
405
+ console.error(error instanceof Error ? error.message : String(error));
406
+ process.exitCode = 1;
407
+ });
408
+ }