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,191 @@
1
+ import { accessSync, constants, lstatSync, mkdirSync, unlinkSync, writeFileSync } from "node:fs";
2
+ import { homedir } from "node:os";
3
+ import { delimiter, dirname, isAbsolute, join, resolve } from "node:path";
4
+ import { spawnSync } from "node:child_process";
5
+ import type { GatewayConfig } from "./gateway-config";
6
+
7
+ export interface ServiceInstallResult {
8
+ path: string;
9
+ manager: "launchd" | "systemd";
10
+ }
11
+
12
+ export interface GatewayServicePaths {
13
+ readonly configPath: string;
14
+ readonly envFile: string;
15
+ }
16
+
17
+ const SERVICE_NAME = "com.ompclaw";
18
+ const SYSTEMD_UNIT = "ompclaw.service";
19
+ const GATEWAY_COMMAND = "ompclaw";
20
+
21
+ function xml(value: string): string {
22
+ return value.replaceAll("&", "&amp;").replaceAll("<", "&lt;").replaceAll(">", "&gt;").replaceAll('"', "&quot;");
23
+ }
24
+
25
+ function systemdQuote(value: string): string {
26
+ return `"${value.replaceAll("\\", "\\\\").replaceAll('"', '\\"')}"`;
27
+ }
28
+
29
+ function resolveCommand(command: string): string {
30
+ if (command.includes("/")) return command;
31
+ for (const directory of (process.env.PATH ?? "").split(delimiter)) {
32
+ if (!directory) continue;
33
+ const candidate = join(directory, command);
34
+ try {
35
+ accessSync(candidate, constants.X_OK);
36
+ return candidate;
37
+ } catch {
38
+ // Keep searching.
39
+ }
40
+ }
41
+ return command;
42
+ }
43
+
44
+ export function resolveGatewayServicePaths(
45
+ configPath: string | undefined,
46
+ envFile: string | undefined,
47
+ ): GatewayServicePaths {
48
+ return {
49
+ configPath: requireAbsoluteRegularFile(configPath, "--config"),
50
+ envFile: requireAbsoluteRegularFile(envFile, "--env-file", 0o600),
51
+ };
52
+ }
53
+
54
+ function requireAbsoluteRegularFile(value: string | undefined, flag: string, mode?: number): string {
55
+ if (value === undefined) throw new Error(`service-install requires ${flag}`);
56
+ if (!isAbsolute(value)) throw new Error(`${flag} must be an absolute path`);
57
+ const path = resolve(value);
58
+ const info = lstatSync(path);
59
+ if (!info.isFile() || info.isSymbolicLink()) throw new Error(`${flag} must be a regular file, not a symlink`);
60
+ if (mode !== undefined && (info.mode & 0o777) !== mode) {
61
+ throw new Error(`${flag} must have mode ${mode.toString(8)}`);
62
+ }
63
+ return path;
64
+ }
65
+
66
+ function servicePath(program: string): string {
67
+ const programDirectory = program.includes("/") ? dirname(program) : undefined;
68
+ return [...new Set([
69
+ dirname(process.execPath),
70
+ ...(programDirectory === undefined ? [] : [programDirectory]),
71
+ ...(process.env.PATH ?? "").split(delimiter).filter(Boolean),
72
+ ])].join(delimiter);
73
+ }
74
+
75
+ function serviceArguments(paths: GatewayServicePaths): string[] {
76
+ return [resolveCommand(GATEWAY_COMMAND), "run", "--config", paths.configPath, "--env-file", paths.envFile];
77
+ }
78
+
79
+ const MANAGER_RETRY_WAIT = new Int32Array(new SharedArrayBuffer(4));
80
+
81
+ function runManager(executable: string, args: string[], retries = 0): void {
82
+ let failure = "";
83
+ for (let attempt = 0; attempt <= retries; attempt++) {
84
+ const result = spawnSync(executable, args, { encoding: "utf8" });
85
+ if (result.status === 0) return;
86
+ failure = (result.stderr || result.stdout).trim();
87
+ if (attempt < retries) Atomics.wait(MANAGER_RETRY_WAIT, 0, 0, 100);
88
+ }
89
+ throw new Error(`${executable} ${args.join(" ")} failed: ${failure}`);
90
+ }
91
+
92
+ export function installRpcService(
93
+ config: GatewayConfig,
94
+ configPath: string,
95
+ envFile: string,
96
+ ): ServiceInstallResult {
97
+ const paths = resolveGatewayServicePaths(configPath, envFile);
98
+ const args = serviceArguments(paths);
99
+ const logs = join(config.stateDir, "logs");
100
+ mkdirSync(logs, { recursive: true, mode: 0o700 });
101
+
102
+ if (process.platform === "darwin") {
103
+ const path = join(homedir(), "Library", "LaunchAgents", `${SERVICE_NAME}.plist`);
104
+ mkdirSync(join(homedir(), "Library", "LaunchAgents"), { recursive: true });
105
+ const plist = `<?xml version="1.0" encoding="UTF-8"?>
106
+ <!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
107
+ <plist version="1.0">
108
+ <dict>
109
+ <key>EnvironmentVariables</key>
110
+ <dict><key>PATH</key><string>${xml(servicePath(args[0]!))}</string></dict>
111
+ <key>Label</key><string>${SERVICE_NAME}</string>
112
+ <key>ProgramArguments</key>
113
+ <array>${args.map((arg) => `\n <string>${xml(arg)}</string>`).join("")}\n </array>
114
+ <key>WorkingDirectory</key><string>${xml(config.workspace)}</string>
115
+ <key>RunAtLoad</key><true/>
116
+ <key>KeepAlive</key><dict><key>SuccessfulExit</key><false/></dict>
117
+ <key>ThrottleInterval</key><integer>10</integer>
118
+ <key>StandardOutPath</key><string>${xml(join(logs, "stdout.log"))}</string>
119
+ <key>ExitTimeOut</key><integer>15</integer>
120
+ <key>StandardErrorPath</key><string>${xml(join(logs, "stderr.log"))}</string>
121
+ <key>ProcessType</key><string>Background</string>
122
+ </dict>
123
+ </plist>
124
+ `;
125
+ writeFileSync(path, plist, { mode: 0o644 });
126
+ const domain = `gui/${process.getuid?.() ?? 0}`;
127
+ spawnSync("launchctl", ["bootout", "--wait", `${domain}/${SERVICE_NAME}`], { encoding: "utf8", timeout: 10_000 });
128
+ runManager("launchctl", ["bootstrap", domain, path], 20);
129
+ runManager("launchctl", ["enable", `${domain}/${SERVICE_NAME}`]);
130
+ return { path, manager: "launchd" };
131
+ }
132
+
133
+ if (process.platform === "linux") {
134
+ const dir = join(homedir(), ".config", "systemd", "user");
135
+ const path = join(dir, SYSTEMD_UNIT);
136
+ mkdirSync(dir, { recursive: true, mode: 0o700 });
137
+ const unit = `[Unit]
138
+ Description=OmpClaw authenticated OMP gateway
139
+ After=network-online.target
140
+ Wants=network-online.target
141
+
142
+ [Service]
143
+ Type=simple
144
+ WorkingDirectory=${systemdQuote(config.workspace)}
145
+ ExecStart=${args.map(systemdQuote).join(" ")}
146
+ Environment=${systemdQuote(`PATH=${servicePath(args[0]!)}`)}
147
+ Restart=on-failure
148
+ RestartSec=5
149
+ UMask=0077
150
+ NoNewPrivileges=true
151
+ ProtectSystem=strict
152
+ ProtectHome=read-only
153
+ ReadWritePaths=${systemdQuote(config.stateDir)} ${systemdQuote(config.workspace)}
154
+
155
+ [Install]
156
+ WantedBy=default.target
157
+ `;
158
+ writeFileSync(path, unit, { mode: 0o600 });
159
+ runManager("systemctl", ["--user", "daemon-reload"]);
160
+ runManager("systemctl", ["--user", "enable", "--now", SYSTEMD_UNIT]);
161
+ return { path, manager: "systemd" };
162
+ }
163
+
164
+ throw new Error(`Service installation is not supported on ${process.platform}`);
165
+ }
166
+
167
+ export function uninstallRpcService(): ServiceInstallResult {
168
+ if (process.platform === "darwin") {
169
+ const path = join(homedir(), "Library", "LaunchAgents", `${SERVICE_NAME}.plist`);
170
+ const domain = `gui/${process.getuid?.() ?? 0}`;
171
+ spawnSync("launchctl", ["bootout", `${domain}/${SERVICE_NAME}`], { encoding: "utf8" });
172
+ try {
173
+ unlinkSync(path);
174
+ } catch (error) {
175
+ if ((error as NodeJS.ErrnoException).code !== "ENOENT") throw error;
176
+ }
177
+ return { path, manager: "launchd" };
178
+ }
179
+ if (process.platform === "linux") {
180
+ const path = join(homedir(), ".config", "systemd", "user", SYSTEMD_UNIT);
181
+ spawnSync("systemctl", ["--user", "disable", "--now", SYSTEMD_UNIT], { encoding: "utf8" });
182
+ try {
183
+ unlinkSync(path);
184
+ } catch (error) {
185
+ if ((error as NodeJS.ErrnoException).code !== "ENOENT") throw error;
186
+ }
187
+ runManager("systemctl", ["--user", "daemon-reload"]);
188
+ return { path, manager: "systemd" };
189
+ }
190
+ throw new Error(`Service removal is not supported on ${process.platform}`);
191
+ }
package/src/rpc-ui.ts ADDED
@@ -0,0 +1,218 @@
1
+ import type {
2
+ ConversationAddress,
3
+ DeliveryContext,
4
+ UiRequest,
5
+ UiResponse,
6
+ } from "./gateway-types";
7
+ import type { GatewayDelivery } from "./gateway-tools";
8
+ import type { RpcExtensionUiRequest, RpcExtensionUiResponse } from "./rpc-protocol";
9
+
10
+ export interface RpcUiLogger {
11
+ warn(message: string): void;
12
+ }
13
+
14
+ export interface RpcGatewayUiTarget {
15
+ readonly address: ConversationAddress;
16
+ readonly deliveryContext: DeliveryContext;
17
+ }
18
+
19
+ export interface RpcGatewayUiBrokerOptions {
20
+ readonly delivery: GatewayDelivery;
21
+ readonly sendResponse: (response: RpcExtensionUiResponse) => void;
22
+ readonly getTarget: () => RpcGatewayUiTarget | undefined;
23
+ readonly log: RpcUiLogger;
24
+ }
25
+
26
+ interface PendingUi {
27
+ readonly rpcId: string;
28
+ readonly request: Exclude<RpcExtensionUiRequest, { method: "cancel" }>;
29
+ readonly controller: AbortController;
30
+ timer?: NodeJS.Timeout;
31
+ timedOut: boolean;
32
+ }
33
+
34
+ /** Bridges OMP RPC UI requests to the active authenticated gateway delivery context. */
35
+ export class RpcGatewayUiBroker {
36
+ readonly #options: RpcGatewayUiBrokerOptions;
37
+ readonly #pendingByRpcId = new Map<string, PendingUi>();
38
+ readonly #statuses: Record<string, string> = {};
39
+ readonly #widgets: Record<string, string[]> = {};
40
+ #title = "OMP";
41
+ #editorText = "";
42
+
43
+ constructor(options: RpcGatewayUiBrokerOptions) {
44
+ this.#options = options;
45
+ }
46
+
47
+ statusText(): string {
48
+ const lines = [`Surface: ${this.#title}`];
49
+ for (const [key, value] of Object.entries(this.#statuses)) lines.push(`${key}: ${value}`);
50
+ for (const [key, value] of Object.entries(this.#widgets)) lines.push(`${key}: ${value.join(" | ")}`);
51
+ if (this.#editorText) lines.push(`Suggested input: ${this.#editorText}`);
52
+ return lines.join("\n");
53
+ }
54
+
55
+ async handle(request: RpcExtensionUiRequest): Promise<void> {
56
+ if (request.method === "cancel") {
57
+ this.cancel(request.targetId);
58
+ return;
59
+ }
60
+
61
+ this.#rememberDisplayState(request);
62
+ const target = this.#options.getTarget();
63
+ if (!target) {
64
+ this.#logMissingTarget(request);
65
+ return;
66
+ }
67
+
68
+ this.cancel(request.id);
69
+ const pending: PendingUi = {
70
+ rpcId: request.id,
71
+ request: request,
72
+ controller: new AbortController(),
73
+ timedOut: false,
74
+ };
75
+ this.#pendingByRpcId.set(pending.rpcId, pending);
76
+ const timeout = this.#timeoutFor(request);
77
+ if (timeout != null) {
78
+ pending.timer = setTimeout(() => {
79
+ pending.timedOut = true;
80
+ this.cancel(pending.rpcId);
81
+ }, timeout);
82
+ pending.timer.unref?.();
83
+ }
84
+ void this.#present(pending, target);
85
+ }
86
+
87
+ cancel(rpcId: string): void {
88
+ const pending = this.#pendingByRpcId.get(rpcId);
89
+ if (!pending || !this.#pendingByRpcId.delete(rpcId)) return;
90
+ clearTimeout(pending.timer);
91
+ pending.controller.abort();
92
+ this.#sendCancelled(pending);
93
+ }
94
+
95
+ shutdown(): void {
96
+ for (const rpcId of [...this.#pendingByRpcId.keys()]) this.cancel(rpcId);
97
+ }
98
+
99
+ async #present(pending: PendingUi, target: RpcGatewayUiTarget): Promise<void> {
100
+ try {
101
+ const response = await this.#options.delivery.presentUi(
102
+ target.address,
103
+ this.#toGatewayRequest(pending.request),
104
+ target.deliveryContext,
105
+ pending.controller.signal,
106
+ );
107
+ if (!this.#pendingByRpcId.delete(pending.rpcId)) return;
108
+ clearTimeout(pending.timer);
109
+ this.#sendResponse(pending.request, response);
110
+ } catch (error) {
111
+ if (!this.#pendingByRpcId.delete(pending.rpcId)) return;
112
+ clearTimeout(pending.timer);
113
+ if (pending.controller.signal.aborted) return;
114
+ this.#options.log.warn(`[ompclaw rpc] UI ${pending.request.method} failed: ${error instanceof Error ? error.message : String(error)}`);
115
+ this.#sendCancelled(pending);
116
+ }
117
+ }
118
+
119
+ #toGatewayRequest(request: Exclude<RpcExtensionUiRequest, { method: "cancel" }>): UiRequest {
120
+ switch (request.method) {
121
+ case "select":
122
+ return {
123
+ type: "select",
124
+ title: request.title,
125
+ options: request.options.map((value, index) => {
126
+ const description = request.optionDetails?.[index]?.description;
127
+ return { value, label: value, ...(description ? { description } : {}) };
128
+ }),
129
+ };
130
+ case "confirm":
131
+ return { type: "confirm", title: request.title, message: request.message };
132
+ case "input":
133
+ return {
134
+ type: "input",
135
+ title: request.title,
136
+ ...(request.placeholder ? { prompt: request.placeholder, placeholder: request.placeholder } : {}),
137
+ };
138
+ case "editor":
139
+ return { type: "editor", title: request.title, initialValue: request.prefill ?? "" };
140
+ case "notify":
141
+ return { type: "notify", message: request.message, ...(request.notifyType ? { level: request.notifyType } : {}) };
142
+ case "setStatus":
143
+ return { type: "status", key: request.statusKey, ...(request.statusText ? { text: request.statusText } : {}) };
144
+ case "setWidget":
145
+ return {
146
+ type: "widget",
147
+ key: request.widgetKey,
148
+ ...(request.widgetLines ? { lines: request.widgetLines } : {}),
149
+ ...(request.widgetPlacement ? { placement: request.widgetPlacement } : {}),
150
+ };
151
+ case "setTitle":
152
+ return { type: "title", title: request.title };
153
+ case "set_editor_text":
154
+ return { type: "editor_text", text: request.text };
155
+ case "open_url":
156
+ return { type: "open_url", url: request.launchUrl ?? request.url, ...(request.instructions ? { label: request.instructions } : {}) };
157
+ }
158
+ }
159
+
160
+ #sendResponse(request: RpcExtensionUiRequest, response: UiResponse): void {
161
+ switch (request.method) {
162
+ case "select":
163
+ if (response.type === "select") this.#options.sendResponse({ type: "extension_ui_response", id: request.id, value: response.selected[0] ?? "" });
164
+ return;
165
+ case "confirm":
166
+ if (response.type === "confirm") this.#options.sendResponse({ type: "extension_ui_response", id: request.id, confirmed: response.confirmed });
167
+ return;
168
+ case "input":
169
+ case "editor":
170
+ if (response.type === request.method) {
171
+ if (response.cancelled) this.#options.sendResponse({ type: "extension_ui_response", id: request.id, cancelled: true });
172
+ else this.#options.sendResponse({ type: "extension_ui_response", id: request.id, value: response.value });
173
+ }
174
+ return;
175
+ default:
176
+ return;
177
+ }
178
+ }
179
+
180
+ #sendCancelled(pending: PendingUi): void {
181
+ if (pending.request.method === "select" || pending.request.method === "confirm" || pending.request.method === "input" || pending.request.method === "editor") {
182
+ this.#options.sendResponse({
183
+ type: "extension_ui_response",
184
+ id: pending.rpcId,
185
+ cancelled: true,
186
+ ...(pending.timedOut ? { timedOut: true } : {}),
187
+ });
188
+ }
189
+ }
190
+
191
+ #rememberDisplayState(request: Exclude<RpcExtensionUiRequest, { method: "cancel" }>): void {
192
+ if (request.method === "setStatus") {
193
+ if (request.statusText) this.#statuses[request.statusKey] = request.statusText;
194
+ else delete this.#statuses[request.statusKey];
195
+ } else if (request.method === "setWidget") {
196
+ if (request.widgetLines) this.#widgets[request.widgetKey] = request.widgetLines;
197
+ else delete this.#widgets[request.widgetKey];
198
+ } else if (request.method === "setTitle") this.#title = request.title;
199
+ else if (request.method === "set_editor_text") this.#editorText = request.text;
200
+ }
201
+
202
+ #timeoutFor(request: Exclude<RpcExtensionUiRequest, { method: "cancel" }>): number | undefined {
203
+ return "timeout" in request && typeof request.timeout === "number" && request.timeout > 0 ? request.timeout : undefined;
204
+ }
205
+
206
+ #logMissingTarget(request: Exclude<RpcExtensionUiRequest, { method: "cancel" }>): void {
207
+ if (
208
+ request.method === "setStatus"
209
+ || request.method === "setWidget"
210
+ || request.method === "setTitle"
211
+ || request.method === "set_editor_text"
212
+ ) return;
213
+ this.#options.log.warn(`[ompclaw rpc] Cannot present ${request.method}: no active delivery context`);
214
+ if (request.method === "select" || request.method === "confirm" || request.method === "input" || request.method === "editor") {
215
+ this.#options.sendResponse({ type: "extension_ui_response", id: request.id, cancelled: true });
216
+ }
217
+ }
218
+ }