@infomity/remogent 0.0.0-stage → 0.2.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/dist/server.js ADDED
@@ -0,0 +1,161 @@
1
+ import { timingSafeEqual } from "node:crypto";
2
+ import { createServer } from "node:https";
3
+ import { WebSocketServer } from "ws";
4
+ import { PROTOCOL_VERSION, ProtocolError, parseClientMessage } from "./protocol.js";
5
+ import { SessionManager } from "./sessions.js";
6
+ export { PROTOCOL_VERSION };
7
+ function tokenMatches(expected, provided) {
8
+ if (!provided)
9
+ return false;
10
+ const a = Buffer.from(expected);
11
+ const b = Buffer.from(provided);
12
+ return a.length === b.length && timingSafeEqual(a, b);
13
+ }
14
+ function bearerFrom(req) {
15
+ const header = req.headers["authorization"];
16
+ if (header?.startsWith("Bearer "))
17
+ return header.slice(7);
18
+ // Fallback for clients that cannot set headers on the WS handshake.
19
+ const proto = req.headers["sec-websocket-protocol"];
20
+ if (typeof proto === "string") {
21
+ const part = proto.split(",").map((s) => s.trim()).find((s) => s.startsWith("remogent.token."));
22
+ if (part)
23
+ return part.slice("remogent.token.".length);
24
+ }
25
+ return undefined;
26
+ }
27
+ async function handleMessage(msg, sessions, viewer, ws) {
28
+ switch (msg.type) {
29
+ case "ping":
30
+ viewer.send({ type: "pong", t: msg.t ?? Date.now() });
31
+ return;
32
+ case "client_hello":
33
+ if (msg.protocol > PROTOCOL_VERSION) {
34
+ viewer.send({ type: "error", code: "invalid_message", message: `host speaks protocol ${PROTOCOL_VERSION}` });
35
+ ws.close(1002, "protocol version");
36
+ }
37
+ return;
38
+ case "session_create": {
39
+ const session = await sessions.create(msg.engine, msg.cwd, msg.permissionMode, msg.reqId);
40
+ viewer.send({ type: "session_created", reqId: msg.reqId, session });
41
+ return;
42
+ }
43
+ case "session_list":
44
+ sessions.addListener(viewer);
45
+ viewer.send({ type: "session_list", reqId: msg.reqId, sessions: sessions.list() });
46
+ return;
47
+ case "session_resume":
48
+ // Resume of exited engines arrives with the adapters (native session/load or `claude --resume`).
49
+ throw new ProtocolError("engine_unavailable", "session_resume is not implemented yet", msg.reqId);
50
+ case "session_attach":
51
+ sessions.attach(msg.sessionId, viewer, msg.afterSeq, msg.reqId);
52
+ return;
53
+ case "session_detach":
54
+ sessions.detach(msg.sessionId, viewer);
55
+ return;
56
+ case "session_prompt":
57
+ sessions.prompt(msg.sessionId, msg.text, msg.reqId);
58
+ viewer.send({ type: "ack", reqId: msg.reqId });
59
+ return;
60
+ case "session_cancel":
61
+ sessions.cancel(msg.sessionId);
62
+ if (msg.reqId)
63
+ viewer.send({ type: "ack", reqId: msg.reqId });
64
+ return;
65
+ case "session_close":
66
+ sessions.close(msg.sessionId, msg.reqId);
67
+ viewer.send({ type: "ack", reqId: msg.reqId });
68
+ return;
69
+ case "session_delete":
70
+ sessions.delete(msg.sessionId, msg.reqId);
71
+ viewer.send({ type: "ack", reqId: msg.reqId });
72
+ return;
73
+ case "session_rename":
74
+ sessions.rename(msg.sessionId, msg.title, msg.reqId);
75
+ viewer.send({ type: "ack", reqId: msg.reqId });
76
+ return;
77
+ case "permission_response":
78
+ sessions.respond(msg.sessionId, msg.requestId, msg.decision);
79
+ return;
80
+ }
81
+ }
82
+ export function startServer(identity, log = console.log, opts = {}) {
83
+ const sessions = opts.sessions ?? new SessionManager();
84
+ let connections = 0; // authenticated phone connections since start; `pair` watches it to know the scan worked
85
+ const https = createServer({ cert: identity.certPem, key: identity.keyPem }, (req, res) => {
86
+ if (req.url === "/health") {
87
+ res.writeHead(200, { "content-type": "application/json" });
88
+ res.end(JSON.stringify({ ok: true, name: identity.name, protocol: PROTOCOL_VERSION, connections }));
89
+ return;
90
+ }
91
+ res.writeHead(404).end();
92
+ });
93
+ const wss = new WebSocketServer({ noServer: true, maxPayload: 8 * 1024 * 1024 });
94
+ https.on("upgrade", (req, socket, head) => {
95
+ if (!tokenMatches(identity.token, bearerFrom(req))) {
96
+ socket.write("HTTP/1.1 401 Unauthorized\r\nConnection: close\r\n\r\n");
97
+ socket.destroy();
98
+ return;
99
+ }
100
+ wss.handleUpgrade(req, socket, head, (ws) => wss.emit("connection", ws, req));
101
+ });
102
+ wss.on("connection", (ws) => {
103
+ log("client connected");
104
+ connections++;
105
+ const viewer = {
106
+ send: (m) => {
107
+ if (ws.readyState === ws.OPEN)
108
+ ws.send(JSON.stringify(m));
109
+ },
110
+ };
111
+ viewer.send({ type: "hello", protocol: PROTOCOL_VERSION, name: identity.name, engines: sessions.engines() });
112
+ ws.on("message", (data, isBinary) => {
113
+ if (isBinary)
114
+ return; // PTY stream: Phase 1 later step
115
+ let raw;
116
+ try {
117
+ raw = JSON.parse(data.toString());
118
+ }
119
+ catch {
120
+ viewer.send({ type: "error", code: "invalid_json", message: "invalid JSON" });
121
+ return;
122
+ }
123
+ void (async () => {
124
+ let reqId = raw?.reqId;
125
+ try {
126
+ const msg = parseClientMessage(raw);
127
+ await handleMessage(msg, sessions, viewer, ws);
128
+ }
129
+ catch (e) {
130
+ if (e instanceof ProtocolError) {
131
+ reqId = e.reqId ?? (typeof reqId === "string" ? reqId : undefined);
132
+ viewer.send({ type: "error", ...(reqId ? { reqId } : {}), code: e.code, message: e.message });
133
+ }
134
+ else {
135
+ log("internal error: " + (e instanceof Error ? e.message : String(e)));
136
+ viewer.send({ type: "error", ...(typeof reqId === "string" ? { reqId } : {}), code: "internal", message: "internal error" });
137
+ }
138
+ }
139
+ })();
140
+ });
141
+ ws.on("close", () => {
142
+ sessions.viewerGone(viewer);
143
+ log("client disconnected");
144
+ });
145
+ });
146
+ return new Promise((resolve, reject) => {
147
+ https.once("error", reject);
148
+ https.listen(identity.port, "0.0.0.0", () => {
149
+ resolve({
150
+ sessions,
151
+ close: () => new Promise((done) => {
152
+ sessions.closeAll();
153
+ for (const c of wss.clients)
154
+ c.terminate();
155
+ wss.close(() => https.close(() => done()));
156
+ }),
157
+ });
158
+ });
159
+ });
160
+ }
161
+ //# sourceMappingURL=server.js.map
@@ -0,0 +1,244 @@
1
+ import { spawnSync } from "node:child_process";
2
+ import { existsSync, mkdirSync, rmSync, writeFileSync } from "node:fs";
3
+ import { request } from "node:https";
4
+ import { homedir, userInfo } from "node:os";
5
+ import { join } from "node:path";
6
+ import { fileURLToPath } from "node:url";
7
+ import { REMOGENT_DIR } from "./identity.js";
8
+ // Autostart for the host daemon, as the *current user* (not SYSTEM): the Gemini/Claude CLIs authenticate from the user's
9
+ // profile, so a machine-level service could not use them. Consequence: the daemon starts when you sign in, not before.
10
+ // - Windows: a Task Scheduler task (logon trigger, restart on failure) that runs a hidden wscript launcher.
11
+ // - macOS: a launchd LaunchAgent (RunAtLoad + KeepAlive). [written from docs, NOT yet run on a Mac]
12
+ // - Linux: a systemd --user unit (+ loginctl enable-linger). [written from docs, NOT yet run on Linux]
13
+ export const LOG_FILE = join(REMOGENT_DIR, "daemon.log");
14
+ const TASK_NAME = "Remogent Host";
15
+ const LABEL = "io.github.infomity.remogent";
16
+ const UNIT = "remogent.service";
17
+ const INDEX_JS = fileURLToPath(new URL("./index.js", import.meta.url));
18
+ function daemonArgs(port) {
19
+ return [INDEX_JS, "start", "--port", String(port), "--no-qr", "--log", LOG_FILE];
20
+ }
21
+ function run(cmd, args) {
22
+ const r = spawnSync(cmd, args, { encoding: "utf8", windowsHide: true });
23
+ if (r.error)
24
+ return { ok: false, out: r.error.message };
25
+ const out = (r.stdout ?? "").trim();
26
+ const err = (r.stderr ?? "").trim();
27
+ const ok = r.status === 0;
28
+ // stderr is only interesting when something failed (PowerShell also writes progress noise there).
29
+ return { ok, out: ok ? out : [out, err].filter(Boolean).join("\n") };
30
+ }
31
+ // ---------------------------------------------------------------- health
32
+ /** Asks a running daemon on this machine for /health (self-signed cert, so verification is off: it is loopback only). */
33
+ export function probeHealth(port, timeoutMs = 2000) {
34
+ return new Promise((resolve) => {
35
+ const req = request({ host: "127.0.0.1", port, path: "/health", method: "GET", rejectUnauthorized: false, timeout: timeoutMs }, (res) => {
36
+ let body = "";
37
+ res.on("data", (d) => (body += d.toString()));
38
+ res.on("end", () => {
39
+ try {
40
+ const j = JSON.parse(body);
41
+ resolve(j.ok ? { ...(j.name ? { name: j.name } : {}), ...(typeof j.connections === "number" ? { connections: j.connections } : {}) } : undefined);
42
+ }
43
+ catch {
44
+ resolve(undefined);
45
+ }
46
+ });
47
+ });
48
+ req.on("timeout", () => {
49
+ req.destroy();
50
+ resolve(undefined);
51
+ });
52
+ req.on("error", () => resolve(undefined));
53
+ req.end();
54
+ });
55
+ }
56
+ export async function waitHealthy(port, totalMs) {
57
+ const end = Date.now() + totalMs;
58
+ while (Date.now() < end) {
59
+ if (await probeHealth(port, 1500))
60
+ return true;
61
+ await new Promise((r) => setTimeout(r, 500));
62
+ }
63
+ return false;
64
+ }
65
+ // ---------------------------------------------------------------- Windows
66
+ const POWERSHELL = join(process.env["SystemRoot"] ?? "C:\\Windows", "System32", "WindowsPowerShell", "v1.0", "powershell.exe");
67
+ export function ps(script) {
68
+ const encoded = Buffer.from(`$ProgressPreference = 'SilentlyContinue'\n${script}`, "utf16le").toString("base64");
69
+ return run(POWERSHELL, ["-NoProfile", "-NonInteractive", "-ExecutionPolicy", "Bypass", "-EncodedCommand", encoded]);
70
+ }
71
+ const psq = (s) => `'${s.replace(/'/g, "''")}'`;
72
+ const WIN_VBS = join(REMOGENT_DIR, "remogent-host.vbs");
73
+ function winInstall(port) {
74
+ mkdirSync(REMOGENT_DIR, { recursive: true });
75
+ // wscript runs node with a hidden window and waits, passing node's exit code on, so a crash makes the task fail and
76
+ // Task Scheduler restarts it (a clean Ctrl+C-style exit 0 is not restarted).
77
+ const cmdline = [process.execPath, ...daemonArgs(port)].map((a) => `"${a}"`).join(" ");
78
+ writeFileSync(WIN_VBS, `' Started by the "${TASK_NAME}" scheduled task. Do not edit: \`remogent install\` rewrites it.\r\n` +
79
+ `Set sh = CreateObject("WScript.Shell")\r\n` +
80
+ `WScript.Quit sh.Run("${cmdline.replace(/"/g, '""')}", 0, True)\r\n`);
81
+ const r = ps(`
82
+ $ErrorActionPreference = 'Stop'
83
+ $me = [System.Security.Principal.WindowsIdentity]::GetCurrent().Name
84
+ $action = New-ScheduledTaskAction -Execute 'wscript.exe' -Argument ${psq(`"${WIN_VBS}"`)}
85
+ $trigger = New-ScheduledTaskTrigger -AtLogOn -User $me
86
+ $settings = New-ScheduledTaskSettingsSet -AllowStartIfOnBatteries -DontStopIfGoingOnBatteries -StartWhenAvailable -ExecutionTimeLimit ([TimeSpan]::Zero) -RestartCount 999 -RestartInterval (New-TimeSpan -Minutes 1) -MultipleInstances IgnoreNew
87
+ $principal = New-ScheduledTaskPrincipal -UserId $me -LogonType Interactive -RunLevel Limited
88
+ Register-ScheduledTask -TaskName ${psq(TASK_NAME)} -Action $action -Trigger $trigger -Settings $settings -Principal $principal -Description 'Remogent host companion (starts at sign-in, restarts on crash)' -Force | Out-Null
89
+ Start-ScheduledTask -TaskName ${psq(TASK_NAME)}
90
+ `);
91
+ return r.ok
92
+ ? { ok: true, message: `Registered the "${TASK_NAME}" scheduled task (starts at sign-in, restarts on crash) and started it.` }
93
+ : { ok: false, message: `Could not register the scheduled task: ${r.out}` };
94
+ }
95
+ function winStop() {
96
+ // Stop-ScheduledTask only ends the hidden wscript launcher; the node host it started keeps running and keeps the port
97
+ // (a re-install would then fail with "port in use" and keep the old secret). So also end the host the service started,
98
+ // found by its `--log <our log file>` command line (a hand-started `start` has no --log and is left alone), with its CLIs.
99
+ ps(`
100
+ Stop-ScheduledTask -TaskName ${psq(TASK_NAME)} -ErrorAction SilentlyContinue
101
+ $pattern = ${psq(`*--log*${LOG_FILE}*`)}
102
+ Get-CimInstance Win32_Process -Filter "Name='node.exe'" | Where-Object { $_.CommandLine -like $pattern } | ForEach-Object { & taskkill.exe /t /f /pid $_.ProcessId | Out-Null }
103
+ `);
104
+ }
105
+ function winUninstall() {
106
+ winStop();
107
+ const r = ps(`Unregister-ScheduledTask -TaskName ${psq(TASK_NAME)} -Confirm:$false -ErrorAction SilentlyContinue`);
108
+ rmSync(WIN_VBS, { force: true });
109
+ return { ok: r.ok, message: r.ok ? `Removed the "${TASK_NAME}" scheduled task.` : `Could not remove the task: ${r.out}` };
110
+ }
111
+ function winStatus() {
112
+ const r = ps(`
113
+ $t = Get-ScheduledTask -TaskName ${psq(TASK_NAME)} -ErrorAction SilentlyContinue
114
+ if (-not $t) { 'missing' } else { $i = $t | Get-ScheduledTaskInfo; "$($t.State)|$($i.LastTaskResult)|$($i.LastRunTime)" }
115
+ `);
116
+ if (!r.ok || r.out === "missing" || !r.out)
117
+ return { platformSupported: true, registered: false, detail: "not installed" };
118
+ const [state, last, when] = r.out.split("|");
119
+ // 267009 (0x41301) is just "currently running"; only show the last result when the task is not running.
120
+ const detail = state === "Running" ? `Running (since ${when})` : `${state}, last result ${last}, last run ${when}`;
121
+ return { platformSupported: true, registered: true, detail: `scheduled task "${TASK_NAME}": ${detail}` };
122
+ }
123
+ // ---------------------------------------------------------------- macOS
124
+ const MAC_PLIST = join(homedir(), "Library", "LaunchAgents", `${LABEL}.plist`);
125
+ const xmlEsc = (s) => s.replace(/&/g, "&amp;").replace(/</g, "&lt;").replace(/>/g, "&gt;");
126
+ const uid = () => (typeof process.getuid === "function" ? process.getuid() : userInfo().uid);
127
+ function macInstall(port) {
128
+ mkdirSync(join(homedir(), "Library", "LaunchAgents"), { recursive: true });
129
+ const args = [process.execPath, ...daemonArgs(port)].map((a) => `<string>${xmlEsc(a)}</string>`).join("");
130
+ // launchd starts agents with a minimal PATH, so pass the installer's PATH (where gemini/claude were found).
131
+ writeFileSync(MAC_PLIST, `<?xml version="1.0" encoding="UTF-8"?>
132
+ <!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
133
+ <plist version="1.0"><dict>
134
+ <key>Label</key><string>${LABEL}</string>
135
+ <key>ProgramArguments</key><array>${args}</array>
136
+ <key>EnvironmentVariables</key><dict><key>PATH</key><string>${xmlEsc(process.env["PATH"] ?? "")}</string></dict>
137
+ <key>WorkingDirectory</key><string>${xmlEsc(homedir())}</string>
138
+ <key>RunAtLoad</key><true/>
139
+ <key>KeepAlive</key><true/>
140
+ <key>ThrottleInterval</key><integer>10</integer>
141
+ </dict></plist>
142
+ `);
143
+ run("launchctl", ["bootout", `gui/${uid()}/${LABEL}`]);
144
+ const r = run("launchctl", ["bootstrap", `gui/${uid()}`, MAC_PLIST]);
145
+ return r.ok
146
+ ? { ok: true, message: `Installed the LaunchAgent ${MAC_PLIST} (starts at login, restarts on exit).` }
147
+ : { ok: false, message: `launchctl bootstrap failed: ${r.out}` };
148
+ }
149
+ function macUninstall() {
150
+ run("launchctl", ["bootout", `gui/${uid()}/${LABEL}`]);
151
+ rmSync(MAC_PLIST, { force: true });
152
+ return { ok: true, message: "Removed the LaunchAgent." };
153
+ }
154
+ function macStatus() {
155
+ if (!existsSync(MAC_PLIST))
156
+ return { platformSupported: true, registered: false, detail: "not installed" };
157
+ const r = run("launchctl", ["print", `gui/${uid()}/${LABEL}`]);
158
+ const state = /state = (\w+)/.exec(r.out)?.[1];
159
+ return { platformSupported: true, registered: true, detail: r.ok ? `LaunchAgent loaded${state ? `, state ${state}` : ""}` : "plist present but not loaded" };
160
+ }
161
+ // ---------------------------------------------------------------- Linux
162
+ const LINUX_UNIT = join(homedir(), ".config", "systemd", "user", UNIT);
163
+ const sdq = (s) => `"${s.replace(/\\/g, "\\\\").replace(/"/g, '\\"').replace(/%/g, "%%")}"`;
164
+ function linuxInstall(port) {
165
+ mkdirSync(join(homedir(), ".config", "systemd", "user"), { recursive: true });
166
+ const exec = [process.execPath, ...daemonArgs(port)].map(sdq).join(" ");
167
+ writeFileSync(LINUX_UNIT, `[Unit]
168
+ Description=Remogent host companion
169
+ After=network-online.target
170
+
171
+ [Service]
172
+ ExecStart=${exec}
173
+ Environment=${sdq(`PATH=${process.env["PATH"] ?? ""}`)}
174
+ Restart=on-failure
175
+ RestartSec=5
176
+
177
+ [Install]
178
+ WantedBy=default.target
179
+ `);
180
+ run("systemctl", ["--user", "daemon-reload"]);
181
+ const r = run("systemctl", ["--user", "enable", "--now", UNIT]);
182
+ if (!r.ok)
183
+ return { ok: false, message: `systemctl --user enable failed: ${r.out}` };
184
+ // Without lingering, a user unit only runs while you are logged in (and stops at logout).
185
+ const linger = run("loginctl", ["enable-linger", userInfo().username]);
186
+ return {
187
+ ok: true,
188
+ message: `Installed ${LINUX_UNIT} (restarts on failure).` +
189
+ (linger.ok ? " Lingering enabled: it also starts at boot without a login." : ` Could not enable lingering (${linger.out}); run \`sudo loginctl enable-linger ${userInfo().username}\` to start at boot without logging in.`),
190
+ };
191
+ }
192
+ function linuxUninstall() {
193
+ run("systemctl", ["--user", "disable", "--now", UNIT]);
194
+ rmSync(LINUX_UNIT, { force: true });
195
+ run("systemctl", ["--user", "daemon-reload"]);
196
+ return { ok: true, message: "Removed the systemd user unit." };
197
+ }
198
+ function linuxStatus() {
199
+ if (!existsSync(LINUX_UNIT))
200
+ return { platformSupported: true, registered: false, detail: "not installed" };
201
+ const active = run("systemctl", ["--user", "is-active", UNIT]).out;
202
+ const enabled = run("systemctl", ["--user", "is-enabled", UNIT]).out;
203
+ return { platformSupported: true, registered: true, detail: `systemd user unit ${UNIT}: ${active}, ${enabled}` };
204
+ }
205
+ // ---------------------------------------------------------------- public API
206
+ const unsupported = { ok: false, message: `Autostart is not supported on ${process.platform}.` };
207
+ /** Registers the daemon to start at sign-in and starts it now. Replaces an earlier registration. */
208
+ export function installService(port) {
209
+ if (process.platform === "win32")
210
+ return winInstall(port);
211
+ if (process.platform === "darwin")
212
+ return macInstall(port);
213
+ if (process.platform === "linux")
214
+ return linuxInstall(port);
215
+ return unsupported;
216
+ }
217
+ /** Stops a registered service (if any) without unregistering it. */
218
+ export function stopService() {
219
+ if (process.platform === "win32")
220
+ winStop();
221
+ else if (process.platform === "darwin")
222
+ run("launchctl", ["bootout", `gui/${uid()}/${LABEL}`]);
223
+ else if (process.platform === "linux")
224
+ run("systemctl", ["--user", "stop", UNIT]);
225
+ }
226
+ export function uninstallService() {
227
+ if (process.platform === "win32")
228
+ return winUninstall();
229
+ if (process.platform === "darwin")
230
+ return macUninstall();
231
+ if (process.platform === "linux")
232
+ return linuxUninstall();
233
+ return unsupported;
234
+ }
235
+ export function serviceStatus() {
236
+ if (process.platform === "win32")
237
+ return winStatus();
238
+ if (process.platform === "darwin")
239
+ return macStatus();
240
+ if (process.platform === "linux")
241
+ return linuxStatus();
242
+ return { platformSupported: false, registered: false, detail: unsupported.message };
243
+ }
244
+ //# sourceMappingURL=service.js.map