clauderipple 0.4.1 → 0.5.1

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.
@@ -1,9 +1,9 @@
1
1
  #!/usr/bin/env node
2
2
  // clauderipple — install / uninstall / status / start / stop / restart / logs / config
3
3
  //
4
- // install: generates the local CA + leaf, writes a starter config, points
5
- // ~/.claude/settings.json env at the router, registers the launchd agent,
6
- // then probes the whole chain end to end.
4
+ // install: generates the local CA + leaf, writes a starter config, registers the supervisor
5
+ // (launchd / Task Scheduler / systemd), probes the whole chain end to end, and only
6
+ // then points ~/.claude/settings.json env at the router.
7
7
  // uninstall: reverses exactly that. Home dir is kept unless --purge.
8
8
  import fs from "node:fs";
9
9
  import net from "node:net";
@@ -13,10 +13,10 @@ import { execFileSync } from "node:child_process";
13
13
  import { ConfigStore, DEFAULTS, homeDir, configPath } from "../../router/src/config.js";
14
14
  import { adminPort } from "../../router/src/admin.js";
15
15
  import { certsExist, certPaths, generateCerts } from "./certs.js";
16
- import { applyProxyEnv, currentProxyEnv, removeProxyEnv, settingsPath } from "./settings.js";
17
- import { agentDefinitionPath, agentState, installAgent, isSupported, isWindows, removeAgent, restartAgent, startAgent, stopAgent, supervisorName } from "./supervisor.js";
16
+ import { applyProxyEnv, checkProxyEnv, currentProxyEnv, removeProxyEnv, settingsPath } from "./settings.js";
17
+ import { agentDefinitionPath, agentState, installAgent, isLinux, isSupported, isWindows, removeAgent, restartAgent, startAgent, stopAgent, supervisorName } from "./supervisor.js";
18
18
  import { BUNDLE_ID, removeBundle, writeBundle } from "./bundle.js";
19
- import { applyAppProxy, caTrusted, currentAppProxy, removeAppProxy, trustCa, untrustCa } from "./picker.js";
19
+ import { applyAppProxy, caTrusted, currentAppProxy, nssDb, removeAppProxy, trustCa, untrustCa } from "./picker.js";
20
20
  import { runtime } from "./runtime.js";
21
21
  import { codexOff, codexOn } from "./codex.js";
22
22
  import { ingressModels } from "../../router/src/ingress/models.js";
@@ -36,14 +36,21 @@ async function pickerOn() {
36
36
  const caPem = certPaths(home).caPem;
37
37
  if (!fs.existsSync(path.join(home, "ca.key")))
38
38
  throw new Error("ca.key missing; run `clauderipple install` first");
39
+ // With picker mode on, every byte Claude Desktop sends goes through the router (ARCHITECTURE §5):
40
+ // pointing the app at one that is not answering leaves it a blank page, so check first.
41
+ const ready = await probe({ host: cfg.listen.host, port: cfg.listen.port, caPem, upstream: cfg.upstream });
42
+ if (!ready.ok)
43
+ throw new Error(`the router is not answering (${ready.detail}); picker mode not enabled. Start it with \`clauderipple start\` and try again.`);
39
44
  console.log("Picker mode makes Claude Desktop's own claude.ai traffic go through ClaudeRipple so the model picker can list your GPT models.");
40
45
  console.log(isWindows
41
46
  ? "Step 1/3: trusting the ClaudeRipple CA for your Windows user account. Windows will show a confirmation dialog with the certificate fingerprint — answer Yes. No administrator rights are needed."
42
- : "Step 1/3: trusting the ClaudeRipple CA in your login keychain. macOS will ask for your password (ClaudeRipple never sees it).");
47
+ : isLinux
48
+ ? `Step 1/3: trusting the ClaudeRipple CA in your NSS database (${nssDb()}), which Chromium and so Claude Desktop reads. Your user only; no password, no sudo.`
49
+ : "Step 1/3: trusting the ClaudeRipple CA in your login keychain. macOS will ask for your password (ClaudeRipple never sees it).");
43
50
  trustCa(caPem);
44
- if (!caTrusted())
51
+ if (!caTrusted(caPem))
45
52
  throw new Error("CA is not trusted; picker mode not enabled");
46
- console.log(isWindows ? "✓ CA trusted (current user only)" : "✓ CA trusted (login keychain only)");
53
+ console.log(isWindows || isLinux ? "✓ CA trusted (current user only)" : "✓ CA trusted (login keychain only)");
47
54
  const proxyUrl = proxyUrlFor(cfg.listen.port);
48
55
  const r = applyAppProxy(proxyUrl);
49
56
  console.log(`✓ Claude Desktop config library entry applied (${r.id}${r.replaced ? `, previous entry ${r.replaced} remembered` : ""}): egressProxyUrl=${proxyUrl}`);
@@ -72,7 +79,7 @@ function pickerOff() {
72
79
  }
73
80
  if (isWindows)
74
81
  console.log("Removing the CA: Windows will ask you to confirm once more.");
75
- const store = isWindows ? "your user certificate store" : "the login keychain";
82
+ const store = isWindows ? "your user certificate store" : isLinux ? `your NSS database (${nssDb()})` : "the login keychain";
76
83
  console.log(untrustCa(certPaths(home).caPem) ? `✓ CA removed from ${store}` : `✓ CA was not in ${store}`);
77
84
  console.log("\nQuit and reopen Claude Desktop to apply.");
78
85
  }
@@ -119,7 +126,7 @@ async function install() {
119
126
  const home = homeDir();
120
127
  const port = Number(opt("port") ?? DEFAULTS.listen.port);
121
128
  if (!isSupported)
122
- throw new Error(`install supports macOS and Windows; on ${process.platform} run the router manually.`);
129
+ throw new Error(`install supports macOS, Windows and Linux (systemd); on ${process.platform} run the router manually.`);
123
130
  fs.mkdirSync(home, { recursive: true, mode: 0o700 });
124
131
  if (!certsExist(home)) {
125
132
  generateCerts(home, DEFAULTS.upstream);
@@ -137,10 +144,10 @@ async function install() {
137
144
  const proxyUrl = proxyUrlFor(cfg.listen.port);
138
145
  const caPath = certPaths(home).caPem;
139
146
  const maxCtx = opt("max-context-tokens");
140
- const edit = applyProxyEnv({ proxyUrl, caPath, force: flag("force"), ...(maxCtx ? { maxContextTokens: Number(maxCtx) } : {}), models: cfg.cli.models ?? {} });
141
- console.log(edit.changed ? `✓ ${settingsPath()} updated (backup: ${edit.backup ?? "none"})` : `✓ ${settingsPath()} already correct`);
142
- for (const n of edit.notes)
143
- console.log(` note: ${n}`);
147
+ // settings.json is written last, after the probe: from that write on, every new Claude Code
148
+ // session sends everything to this router, so it must not point at one that never came up.
149
+ // A foreign proxy is still refused here, before anything is registered.
150
+ checkProxyEnv({ proxyUrl, force: flag("force") });
144
151
  // Persist the single execution contract so the GUI, admin API, hooks, and supervisor all use
145
152
  // this installation's runtime rather than any Node installation on the user's PATH.
146
153
  fs.writeFileSync(path.join(home, "paths.json"), JSON.stringify({
@@ -151,9 +158,9 @@ async function install() {
151
158
  hookScript: installedRuntime.hookScript,
152
159
  repo: installedRuntime.repo,
153
160
  }, null, 2) + "\n");
154
- // Windows registers node + the router script directly (schtasks.ts writes its own .cmd launcher);
155
- // a macOS source checkout gets a small .app so the agent shows a real name in Login Items.
156
- if (installedRuntime.packaged || isWindows) {
161
+ // Windows and Linux register node + the router script directly (schtasks.ts writes its own .cmd
162
+ // launcher); a macOS source checkout gets a small .app so the agent shows a real name in Login Items.
163
+ if (installedRuntime.packaged || isWindows || isLinux) {
157
164
  const plist = installAgent({
158
165
  program: installedRuntime.node,
159
166
  args: [installedRuntime.router],
@@ -161,7 +168,7 @@ async function install() {
161
168
  home,
162
169
  env: { ...installedRuntime.env, ...(process.env.CLAUDE_SETTINGS_PATH ? { CLAUDE_SETTINGS_PATH: process.env.CLAUDE_SETTINGS_PATH } : {}) },
163
170
  });
164
- console.log(`✓ ${supervisorName()} registered: ${plist} (shows as "ClaudeRipple" in Login Items)`);
171
+ console.log(`✓ ${supervisorName()} registered: ${plist}${isLinux ? "" : ' (shows as "ClaudeRipple" in Login Items)'}`);
165
172
  }
166
173
  else {
167
174
  const launcher = writeBundle({ home, node: installedRuntime.node, script: installedRuntime.router, version: VERSION });
@@ -180,10 +187,16 @@ async function install() {
180
187
  console.log(started === "failed" ? `✗ could not start the router (${supervisorName()})` : `✓ router ${started === "already-running" ? "already running" : "started"}`);
181
188
  const p = await probeWithRetry({ host: cfg.listen.host, port: cfg.listen.port, caPem: caPath, upstream: cfg.upstream });
182
189
  console.log(p.ok ? `✓ end-to-end probe passed (${p.detail}, ${p.ms}ms)` : `✗ probe failed: ${p.detail}`);
183
- if (!p.ok)
190
+ if (!p.ok) {
184
191
  process.exitCode = 1;
185
- else
186
- console.log("\nDone. New Claude Desktop Code sessions go through ClaudeRipple. Existing sessions pick up settings.json env changes on their next request.");
192
+ console.log(`${settingsPath()} was left as it was, so Claude Code does not depend on a router that is not answering. Fix the router, then run install again.`);
193
+ return;
194
+ }
195
+ const edit = applyProxyEnv({ proxyUrl, caPath, force: flag("force"), ...(maxCtx ? { maxContextTokens: Number(maxCtx) } : {}), models: cfg.cli.models ?? {} });
196
+ console.log(edit.changed ? `✓ ${settingsPath()} updated (backup: ${edit.backup ?? "none"})` : `✓ ${settingsPath()} already correct`);
197
+ for (const n of edit.notes)
198
+ console.log(` note: ${n}`);
199
+ console.log("\nDone. New Claude Desktop Code sessions go through ClaudeRipple. Existing sessions pick up settings.json env changes on their next request.");
187
200
  }
188
201
  function uninstall() {
189
202
  const home = homeDir();
@@ -238,7 +251,7 @@ async function status() {
238
251
  rows.push(["settings.json", env.HTTPS_PROXY === proxyUrl && env.NODE_EXTRA_CA_CERTS === caPath ? "points at ClaudeRipple" : `HTTPS_PROXY=${env.HTTPS_PROXY ?? "-"} NODE_EXTRA_CA_CERTS=${env.NODE_EXTRA_CA_CERTS ?? "-"}`]);
239
252
  rows.push([supervisorName(), agentState()]);
240
253
  const ap = currentAppProxy();
241
- rows.push(["picker mode", cfg.picker?.enabled ? `on · CA ${caTrusted() ? "trusted" : "NOT trusted"} · app proxy ${ap.ours ? ap.egressProxyUrl : "NOT set"}` : `off${ap.ours ? " (app proxy entry still present — run `picker off`)" : ""}`]);
254
+ rows.push(["picker mode", cfg.picker?.enabled ? `on · CA ${caTrusted(caPath) ? "trusted" : "NOT trusted"} · app proxy ${ap.ours ? ap.egressProxyUrl : "NOT set"}` : `off${ap.ours ? " (app proxy entry still present — run `picker off`)" : ""}`]);
242
255
  const p = await probe({ host: cfg.listen.host, port: cfg.listen.port, caPem: caPath, upstream: cfg.upstream });
243
256
  rows.push(["probe", `${p.ok ? "ok" : "FAIL"}: ${p.detail} (${p.ms}ms)`]);
244
257
  for (const [name, p2] of Object.entries(cfg.providers)) {
@@ -1,6 +1,8 @@
1
1
  // `clauderipple picker on|off` — put real model names into Claude Desktop's model picker.
2
2
  //
3
- // on: 1. trust our CA in the login keychain (macOS asks the user for their password; we never see it)
3
+ // on: 1. trust our CA for this user: login keychain (macOS asks for the password; we never see it),
4
+ // Cert:\CurrentUser\Root (Windows shows a confirmation dialog), or the NSS database Chromium
5
+ // reads on Linux (~/.pki/nssdb; no prompt, no sudo)
4
6
  // 2. point the app itself at the router: Config Library entry {egressProxyUrl} + _meta.json appliedId
5
7
  // (~/Library/Application Support/Claude-3p/configLibrary/ — the app keeps its managed-config
6
8
  // library under the "-3p" userData dir in BOTH deployment modes; read once at start, no MDM needed)
@@ -8,10 +10,11 @@
8
10
  // → the user restarts Claude Desktop.
9
11
  // off: reverse all three.
10
12
  import { execFileSync } from "node:child_process";
11
- import crypto from "node:crypto";
13
+ import crypto, { X509Certificate } from "node:crypto";
12
14
  import fs from "node:fs";
13
15
  import os from "node:os";
14
16
  import path from "node:path";
17
+ import { windowsPowerShellEnv } from "./windows-powershell.js";
15
18
  export const CA_NAME = "ClaudeRipple local CA";
16
19
  function appSupport() {
17
20
  // Verified in index.pre.js (AW()): userData + "-3p" regardless of 1P/3P mode. Not the plain "Claude" dir.
@@ -22,6 +25,10 @@ function appSupport() {
22
25
  if (process.platform === "win32") {
23
26
  return path.join(process.env.LOCALAPPDATA ?? path.join(os.homedir(), "AppData", "Local"), "Claude-3p");
24
27
  }
28
+ // Linux userData is $XDG_CONFIG_HOME/Claude, so the same "-3p" rule gives ~/.config/Claude-3p
29
+ // (Claude Desktop 2.2553.13's bundle: `${app.getPath("userData")}-3p`, no Linux special case).
30
+ if (process.platform === "linux")
31
+ return path.join(process.env.XDG_CONFIG_HOME || path.join(os.homedir(), ".config"), "Claude-3p");
25
32
  return path.join(os.homedir(), "Library", "Application Support", "Claude-3p");
26
33
  }
27
34
  export function configLibraryDir() {
@@ -31,6 +38,84 @@ function loginKeychain() {
31
38
  return path.join(os.homedir(), "Library", "Keychains", "login.keychain-db");
32
39
  }
33
40
  const isWindows = process.platform === "win32";
41
+ const isLinux = process.platform === "linux";
42
+ // ---- Linux: the NSS database ------------------------------------------------------------
43
+ //
44
+ // Chromium on Linux takes locally added roots from the user's NSS database, not from /etc/ssl,
45
+ // and Claude Desktop is Chromium: a CA only in the system bundle would leave the app a blank page
46
+ // behind the router. certutil comes from libnss3-tools, which a desktop install does not always have.
47
+ /** Test-only override; production always uses the database Chromium reads. */
48
+ export function nssDb() {
49
+ return process.env.CLAUDERIPPLE_NSS_DB ?? path.join(os.homedir(), ".pki", "nssdb");
50
+ }
51
+ /**
52
+ * The fingerprint is part of the name so a CA from an earlier install is never taken for the
53
+ * current one: trusting the old one would pass a check by name and still fail every handshake.
54
+ */
55
+ export function nssNickname(caPem) {
56
+ return `${CA_NAME} ${new X509Certificate(fs.readFileSync(caPem)).fingerprint256.replace(/:/g, "").slice(0, 24)}`;
57
+ }
58
+ function certutil(args) {
59
+ return execFileSync("certutil", ["-d", `sql:${nssDb()}`, ...args], { encoding: "utf8", stdio: ["ignore", "pipe", "pipe"] });
60
+ }
61
+ /** Every ClaudeRipple CA in the database, including ones left by an earlier install. */
62
+ function nssOurNicknames() {
63
+ let out;
64
+ try {
65
+ out = certutil(["-L"]);
66
+ }
67
+ catch {
68
+ return [];
69
+ }
70
+ // One line per certificate: the nickname, then its trust flags ("C,,") as the last column.
71
+ return out
72
+ .split("\n")
73
+ .map((line) => /^(.*\S)\s+\S*,\S*,\S*\s*$/.exec(line)?.[1])
74
+ .filter((name) => !!name && name.startsWith(CA_NAME));
75
+ }
76
+ export function nssTrusted(caPem) {
77
+ try {
78
+ const name = nssNickname(caPem);
79
+ const installed = new X509Certificate(certutil(["-L", "-n", name, "-a"]));
80
+ if (installed.fingerprint256 !== new X509Certificate(fs.readFileSync(caPem)).fingerprint256)
81
+ return false;
82
+ // Validated as an SSL CA (-u L): that is the trust a handshake needs, not mere presence.
83
+ certutil(["-V", "-n", name, "-u", "L"]);
84
+ return true;
85
+ }
86
+ catch {
87
+ return false;
88
+ }
89
+ }
90
+ export function nssTrust(caPem) {
91
+ if (nssTrusted(caPem))
92
+ return;
93
+ try {
94
+ fs.mkdirSync(nssDb(), { recursive: true, mode: 0o700 });
95
+ if (!fs.existsSync(path.join(nssDb(), "cert9.db")))
96
+ certutil(["-N", "--empty-password"]);
97
+ certutil(["-A", "-n", nssNickname(caPem), "-t", "C,,", "-i", caPem]);
98
+ }
99
+ catch (e) {
100
+ if (e.code === "ENOENT") {
101
+ throw new Error("certutil not found. Install it (Ubuntu/Debian: `sudo apt install libnss3-tools`, Fedora: `sudo dnf install nss-tools`), then run `clauderipple picker on` again");
102
+ }
103
+ throw e;
104
+ }
105
+ }
106
+ /** Removes every ClaudeRipple CA, current or left over from an earlier install. */
107
+ export function nssUntrust() {
108
+ const names = nssOurNicknames();
109
+ for (const name of names) {
110
+ try {
111
+ certutil(["-D", "-n", name]);
112
+ }
113
+ catch {
114
+ /* already gone */
115
+ }
116
+ }
117
+ return names.length > 0;
118
+ }
34
119
  /** Single-quoted PowerShell literal; the only escape inside one is a doubled quote. */
35
120
  function ps(value) {
36
121
  return `'${value.replace(/'/g, "''")}'`;
@@ -45,19 +130,23 @@ function powershell(script, opts = {}) {
45
130
  const stdio = opts.stdio ?? "pipe";
46
131
  const args = ["-NoProfile", ...(opts.interactive ? [] : ["-NonInteractive"]), "-Command", script];
47
132
  return execFileSync("powershell.exe", args, {
133
+ env: windowsPowerShellEnv(),
48
134
  stdio: stdio === "pipe" ? ["ignore", "pipe", "pipe"] : stdio,
49
135
  windowsHide: !opts.interactive,
50
136
  })?.toString() ?? "";
51
137
  }
52
138
  /**
53
- * Both platforms trust the CA for the current user only — never machine-wide, which would need
139
+ * Every platform trusts the CA for the current user only — never machine-wide, which would need
54
140
  * administrator rights and would affect everyone on the box.
55
141
  * macOS : login keychain, and the OS asks for the account password.
56
142
  * Windows : Cert:\CurrentUser\Root, and the OS shows a confirmation dialog with the fingerprint.
143
+ * Linux : the user's NSS database (see nssDb), with no prompt at all.
57
144
  * Measured on Windows 11 (2026-09-14) as a standard user: the import succeeded with no UAC prompt,
58
145
  * only that dialog. Removal shows a second confirmation, so `picker off` prompts the user too.
59
146
  */
60
- export function caTrusted() {
147
+ export function caTrusted(caPem) {
148
+ if (isLinux)
149
+ return nssTrusted(caPem);
61
150
  try {
62
151
  if (isWindows) {
63
152
  const out = powershell(`@(Get-ChildItem Cert:\\CurrentUser\\Root | Where-Object { $_.Subject -eq 'CN=${CA_NAME}' }).Count`);
@@ -72,7 +161,9 @@ export function caTrusted() {
72
161
  }
73
162
  /** Adds the CA as a trusted root for the current user. The OS shows its own prompt; we never see a password. */
74
163
  export function trustCa(caPem) {
75
- if (caTrusted())
164
+ if (isLinux)
165
+ return nssTrust(caPem);
166
+ if (caTrusted(caPem))
76
167
  return;
77
168
  if (isWindows) {
78
169
  // stdio inherit: the confirmation dialog is the OS's, but errors should reach the user's terminal.
@@ -82,7 +173,9 @@ export function trustCa(caPem) {
82
173
  execFileSync("security", ["add-trusted-cert", "-r", "trustRoot", "-k", loginKeychain(), caPem], { stdio: "inherit" });
83
174
  }
84
175
  export function untrustCa(caPem) {
85
- if (!caTrusted())
176
+ if (isLinux)
177
+ return nssUntrust();
178
+ if (!caTrusted(caPem))
86
179
  return false;
87
180
  if (isWindows) {
88
181
  try {
@@ -16,6 +16,7 @@ import { execFileSync } from "node:child_process";
16
16
  import fs from "node:fs";
17
17
  import os from "node:os";
18
18
  import path from "node:path";
19
+ import { windowsPowerShellEnv } from "./windows-powershell.js";
19
20
  export const TASK_NAME = "ClaudeRippleRouter";
20
21
  export function taskName() {
21
22
  return process.env.CLAUDERIPPLE_TASK_NAME ?? TASK_NAME;
@@ -27,6 +28,7 @@ function ps(value) {
27
28
  function run(script) {
28
29
  try {
29
30
  const out = execFileSync("powershell.exe", ["-NoProfile", "-NonInteractive", "-Command", script], {
31
+ env: windowsPowerShellEnv(),
30
32
  stdio: ["ignore", "pipe", "pipe"],
31
33
  windowsHide: true,
32
34
  }).toString();
@@ -37,6 +39,25 @@ function run(script) {
37
39
  return { ok: false, out: `${err.stdout?.toString() ?? ""}${err.stderr?.toString() ?? ""}`.trim() };
38
40
  }
39
41
  }
42
+ /**
43
+ * The account the task runs as, qualified with its domain. A bare name is looked up as a machine
44
+ * name too, so on a local account named after its computer (user X on host X) it resolves to
45
+ * nothing and Register-ScheduledTask fails with 0x80070057 (#38). `X\X` names the user.
46
+ */
47
+ export function taskUser(username = os.userInfo().username, domain = process.env.USERDOMAIN) {
48
+ return domain ? `${domain}\\${username}` : username;
49
+ }
50
+ /**
51
+ * Windows stores the qualified name as given on the trigger but the bare one on the principal
52
+ * (reported in #38), and a task registered before that fix carries the bare name on both, so
53
+ * either spelling of the same account is a match.
54
+ */
55
+ function sameAccount(stored, expected) {
56
+ if (stored === undefined)
57
+ return false;
58
+ const bare = expected.slice(expected.lastIndexOf("\\") + 1);
59
+ return [expected, bare].some((name) => name.toLowerCase() === stored.toLowerCase());
60
+ }
40
61
  export function taskDefinitionMatches(value, expected) {
41
62
  if (!value || typeof value !== "object")
42
63
  return false;
@@ -44,12 +65,12 @@ export function taskDefinitionMatches(value, expected) {
44
65
  return (task.actionCount === 1 &&
45
66
  task.execute?.toLowerCase() === "powershell.exe" &&
46
67
  task.arguments === expected.arguments &&
47
- task.userId?.toLowerCase() === expected.userId.toLowerCase() &&
68
+ sameAccount(task.userId, expected.userId) &&
48
69
  task.runLevel === 0 &&
49
70
  task.logonType === 3 &&
50
71
  task.triggerCount === 1 &&
51
72
  task.triggerType === "MSFT_TaskLogonTrigger" &&
52
- task.triggerUserId?.toLowerCase() === expected.userId.toLowerCase() &&
73
+ sameAccount(task.triggerUserId, expected.userId) &&
53
74
  task.restartCount === 99 &&
54
75
  task.restartInterval === "PT1M" &&
55
76
  task.executionTimeLimit === "PT0S" &&
@@ -96,30 +117,38 @@ function writeLauncher(opts) {
96
117
  fs.writeFileSync(launcher, lines.join("\r\n") + "\r\n");
97
118
  return launcher;
98
119
  }
120
+ /**
121
+ * The registered task in the shape taskDefinitionMatches reads, or null. The settings object has
122
+ * no AllowStartIfOnBatteries/DontStopIfGoingOnBatteries — the switches of the same names set
123
+ * DisallowStartIfOnBatteries/StopIfGoingOnBatteries to false — so reading the switch names gave
124
+ * null, and until #38 no existing task ever matched: every install re-registered.
125
+ */
126
+ export function existingTaskDefinition() {
127
+ const existing = run([
128
+ `$t = Get-ScheduledTask -TaskName ${ps(taskName())} -ErrorAction SilentlyContinue`,
129
+ `if ($null -eq $t) { 'null'; exit 0 }`,
130
+ `$a = @($t.Actions); $p = $t.Principal; $tr = @($t.Triggers); $s = $t.Settings`,
131
+ `[pscustomobject]@{ actionCount = $a.Count; execute = $a[0].Execute; arguments = $a[0].Arguments; userId = $p.UserId; runLevel = [int]$p.RunLevel; logonType = [int]$p.LogonType; triggerCount = $tr.Count; triggerType = $tr[0].CimClass.CimClassName; triggerUserId = $tr[0].UserId; restartCount = $s.RestartCount; restartInterval = [string]$s.RestartInterval; executionTimeLimit = [string]$s.ExecutionTimeLimit; multipleInstances = [int]$s.MultipleInstances; allowStartIfOnBatteries = $s.DisallowStartIfOnBatteries -eq $false; dontStopIfGoingOnBatteries = $s.StopIfGoingOnBatteries -eq $false } | ConvertTo-Json -Compress`,
132
+ ].join("; "));
133
+ if (!existing.ok)
134
+ return null;
135
+ try {
136
+ return JSON.parse(existing.out);
137
+ }
138
+ catch {
139
+ // A malformed inspection result is not trusted; the caller falls through to registration.
140
+ return null;
141
+ }
142
+ }
99
143
  export function installAgent(opts) {
100
144
  const launcher = writeLauncher({ program: opts.program, args: opts.args ?? [], home: opts.home, ...(opts.env ? { env: opts.env } : {}) });
101
- const user = `${os.userInfo().username}`;
145
+ const user = taskUser();
102
146
  const actionArgs = `-NoProfile -NonInteractive -WindowStyle Hidden -ExecutionPolicy Bypass -File "${launcher}"`;
103
147
  // Updating the generated launcher does not require re-registering an otherwise correct task.
104
148
  // Some Windows installations protect an existing per-user task's security descriptor so that
105
149
  // even the same unelevated user gets E_ACCESSDENIED from Register-ScheduledTask -Force. Treat the
106
150
  // verified existing definition as success; never silently accept a same-named foreign task.
107
- const existing = run([
108
- `$t = Get-ScheduledTask -TaskName ${ps(taskName())} -ErrorAction SilentlyContinue`,
109
- `if ($null -eq $t) { 'null'; exit 0 }`,
110
- `$a = @($t.Actions); $p = $t.Principal; $tr = @($t.Triggers); $s = $t.Settings`,
111
- `[pscustomobject]@{ actionCount = $a.Count; execute = $a[0].Execute; arguments = $a[0].Arguments; userId = $p.UserId; runLevel = [int]$p.RunLevel; logonType = [int]$p.LogonType; triggerCount = $tr.Count; triggerType = $tr[0].CimClass.CimClassName; triggerUserId = $tr[0].UserId; restartCount = $s.RestartCount; restartInterval = [string]$s.RestartInterval; executionTimeLimit = [string]$s.ExecutionTimeLimit; multipleInstances = [int]$s.MultipleInstances; allowStartIfOnBatteries = $s.AllowStartIfOnBatteries; dontStopIfGoingOnBatteries = $s.DontStopIfGoingOnBatteries } | ConvertTo-Json -Compress`,
112
- ].join("; "));
113
- let definition = null;
114
- if (existing.ok) {
115
- try {
116
- definition = JSON.parse(existing.out);
117
- }
118
- catch {
119
- // A malformed inspection result is not trusted; fall through to registration.
120
- }
121
- }
122
- if (taskDefinitionMatches(definition, { arguments: actionArgs, userId: user }))
151
+ if (taskDefinitionMatches(existingTaskDefinition(), { arguments: actionArgs, userId: user }))
123
152
  return launcher;
124
153
  const script = [
125
154
  `$ErrorActionPreference = 'Stop'`,
@@ -36,15 +36,22 @@ export const MODEL_SLOT_ENV = {
36
36
  smallFast: "ANTHROPIC_SMALL_FAST_MODEL",
37
37
  subagent: "CLAUDE_CODE_SUBAGENT_MODEL",
38
38
  };
39
+ function refuseForeignProxy(existing, proxyUrl, force) {
40
+ if (existing && existing !== proxyUrl && !force) {
41
+ throw new Error(`settings.json env.HTTPS_PROXY is already "${existing}". Re-run with --force to replace it, or uninstall the other proxy first.`);
42
+ }
43
+ }
44
+ /** The refusal `applyProxyEnv` would give, without writing: lets `install` fail before it registers anything. */
45
+ export function checkProxyEnv(opts) {
46
+ refuseForeignProxy(currentProxyEnv().HTTPS_PROXY, opts.proxyUrl, opts.force);
47
+ }
39
48
  export function applyProxyEnv(opts) {
40
49
  const file = settingsPath();
41
50
  const s = readSettings(file);
42
51
  const env = { ...(s.env ?? {}) };
43
52
  const notes = [];
44
53
  const existing = env.HTTPS_PROXY;
45
- if (existing && existing !== opts.proxyUrl && !opts.force) {
46
- throw new Error(`settings.json env.HTTPS_PROXY is already "${existing}". Re-run with --force to replace it, or uninstall the other proxy first.`);
47
- }
54
+ refuseForeignProxy(existing, opts.proxyUrl, opts.force);
48
55
  if (existing && existing !== opts.proxyUrl)
49
56
  notes.push(`replaced HTTPS_PROXY ${existing}`);
50
57
  const want = { HTTPS_PROXY: opts.proxyUrl, NODE_EXTRA_CA_CERTS: opts.caPath };
@@ -1,32 +1,38 @@
1
- // One supervisor interface, two implementations: launchd on macOS, Task Scheduler on Windows.
1
+ // One supervisor interface, three implementations: launchd on macOS, Task Scheduler on Windows,
2
+ // a systemd user service on Linux.
2
3
  // Everything outside this module talks to the router's lifecycle through here.
3
4
  import { execFileSync } from "node:child_process";
4
5
  import { ConfigStore, configPath } from "../../router/src/config.js";
5
6
  import { adminPort } from "../../router/src/admin.js";
6
7
  import * as launchd from "./launchd.js";
7
8
  import * as schtasks from "./schtasks.js";
9
+ import * as systemd from "./systemd.js";
10
+ import { windowsPowerShellEnv } from "./windows-powershell.js";
8
11
  export const isWindows = process.platform === "win32";
9
- export const isSupported = isWindows || process.platform === "darwin";
12
+ export const isLinux = process.platform === "linux";
13
+ export const isSupported = isWindows || isLinux || process.platform === "darwin";
10
14
  /** What the supervisor is called on this platform, for messages the user reads. */
11
15
  export function supervisorName() {
12
- return isWindows ? "scheduled task" : "launchd agent";
16
+ return isWindows ? "scheduled task" : isLinux ? "systemd user service" : "launchd agent";
13
17
  }
14
18
  export function installAgent(opts) {
15
- return isWindows ? schtasks.installAgent(opts) : launchd.installAgent(opts);
19
+ return isWindows ? schtasks.installAgent(opts) : isLinux ? systemd.installAgent(opts) : launchd.installAgent(opts);
16
20
  }
17
21
  export function removeAgent() {
18
- return isWindows ? schtasks.removeAgent() : launchd.removeAgent();
22
+ return isWindows ? schtasks.removeAgent() : isLinux ? systemd.removeAgent() : launchd.removeAgent();
19
23
  }
20
24
  export function agentState() {
21
- return isWindows ? schtasks.agentState() : launchd.agentState();
25
+ return isWindows ? schtasks.agentState() : isLinux ? systemd.agentState() : launchd.agentState();
22
26
  }
23
27
  export function agentPid() {
24
- return isWindows ? schtasks.agentPid() : launchd.agentPid();
28
+ return isWindows ? schtasks.agentPid() : isLinux ? systemd.agentPid() : launchd.agentPid();
25
29
  }
26
30
  export function startAgent() {
27
- return isWindows ? schtasks.startAgent() : launchd.startAgent();
31
+ return isWindows ? schtasks.startAgent() : isLinux ? systemd.startAgent() : launchd.startAgent();
28
32
  }
29
33
  export function stopAgent() {
34
+ if (isLinux)
35
+ return systemd.stopAgent();
30
36
  if (!isWindows)
31
37
  return launchd.stopAgent();
32
38
  // Ask the router to drain first: a clean exit also ends the launcher's supervision loop, so the
@@ -38,7 +44,7 @@ export function stopAgent() {
38
44
  /** Blocking POST to the router's shutdown endpoint. Returns false if it did not answer. */
39
45
  function askShutdown() {
40
46
  try {
41
- execFileSync("powershell.exe", ["-NoProfile", "-NonInteractive", "-Command", `Invoke-RestMethod -Method Post -Uri '${adminUrl()}/api/shutdown' -TimeoutSec 10 | Out-Null`], { stdio: ["ignore", "pipe", "pipe"], windowsHide: true });
47
+ execFileSync("powershell.exe", ["-NoProfile", "-NonInteractive", "-Command", `Invoke-RestMethod -Method Post -Uri '${adminUrl()}/api/shutdown' -TimeoutSec 10 | Out-Null`], { env: windowsPowerShellEnv(), stdio: ["ignore", "pipe", "pipe"], windowsHide: true });
42
48
  return true;
43
49
  }
44
50
  catch {
@@ -92,9 +98,9 @@ function restartWindows(opts) {
92
98
  return schtasks.startAgent() === "failed" ? "failed" : "drained";
93
99
  }
94
100
  export function restartAgent(opts = {}) {
95
- return isWindows ? restartWindows(opts) : launchd.restartAgent(opts);
101
+ return isWindows ? restartWindows(opts) : isLinux ? systemd.restartAgent(opts) : launchd.restartAgent(opts);
96
102
  }
97
103
  /** Path to the supervisor's own definition, for status output. */
98
104
  export function agentDefinitionPath() {
99
- return isWindows ? `Task Scheduler\\${schtasks.taskName()}` : launchd.plistPath();
105
+ return isWindows ? `Task Scheduler\\${schtasks.taskName()}` : isLinux ? systemd.unitPath() : launchd.plistPath();
100
106
  }
@@ -0,0 +1,126 @@
1
+ // Linux supervisor: a per-user systemd service that keeps the router alive. No sudo, nothing
2
+ // machine-wide. Restart=always + RestartSec=2 is launchd's KeepAlive + ThrottleInterval 2: a
3
+ // self-exit (code 75) is followed by a fresh process two seconds later, and StartLimitIntervalSec=0
4
+ // keeps systemd from giving up on it after a burst of those.
5
+ import { execFileSync } from "node:child_process";
6
+ import fs from "node:fs";
7
+ import os from "node:os";
8
+ import path from "node:path";
9
+ const HEADER = "[Unit]\nDescription=ClaudeRipple local model router\n";
10
+ /** Test-only override; production always uses clauderipple.service. */
11
+ export function unitName() {
12
+ const name = process.env.CLAUDERIPPLE_SYSTEMD_UNIT ?? "clauderipple.service";
13
+ if (!/^[A-Za-z0-9_.-]+\.service$/.test(name))
14
+ throw new Error(`invalid systemd unit name: ${name}`);
15
+ return name;
16
+ }
17
+ export function unitPath() {
18
+ const config = process.env.XDG_CONFIG_HOME || path.join(os.homedir(), ".config");
19
+ return path.join(config, "systemd", "user", unitName());
20
+ }
21
+ /**
22
+ * One quoted unit-file word. `%` is a specifier and `$` an environment substitution in ExecStart
23
+ * (systemd.service(5)), so a path containing either has to be escaped or it runs something else.
24
+ */
25
+ function quote(value, dollar) {
26
+ if (/[\r\n\0]/.test(value))
27
+ throw new Error("newlines and NUL cannot appear in a systemd unit value");
28
+ let s = value.replace(/\\/g, "\\\\").replace(/"/g, '\\"').replace(/%/g, "%%");
29
+ if (dollar)
30
+ s = s.replace(/\$/g, "$$$$");
31
+ return `"${s}"`;
32
+ }
33
+ export function renderUnit(opts) {
34
+ if (!path.isAbsolute(opts.program))
35
+ throw new Error("the service program must be an absolute path");
36
+ const env = { CLAUDERIPPLE_HOME: opts.home, ...(opts.env ?? {}) };
37
+ const envLines = Object.entries(env).map(([k, v]) => {
38
+ if (!/^[A-Za-z_][A-Za-z0-9_]*$/.test(k))
39
+ throw new Error(`invalid environment name: ${k}`);
40
+ return `Environment=${quote(`${k}=${v}`, false)}`;
41
+ });
42
+ // TimeoutStopSec covers the router's 90s drain on SIGTERM (ARCHITECTURE §5) with room to spare;
43
+ // KillMode=mixed sends that SIGTERM to the router alone and SIGKILLs only what outlives the budget.
44
+ return `${HEADER}StartLimitIntervalSec=0
45
+
46
+ [Service]
47
+ Type=simple
48
+ ExecStart=${[opts.program, ...(opts.args ?? [])].map((a) => quote(a, true)).join(" ")}
49
+ ${envLines.join("\n")}
50
+ Restart=always
51
+ RestartSec=2
52
+ KillSignal=SIGTERM
53
+ KillMode=mixed
54
+ TimeoutStopSec=120
55
+ UMask=0077
56
+
57
+ [Install]
58
+ WantedBy=default.target
59
+ `;
60
+ }
61
+ function systemctl(args) {
62
+ try {
63
+ const out = execFileSync("systemctl", ["--user", ...args], { encoding: "utf8", stdio: ["ignore", "pipe", "pipe"], timeout: 150_000 });
64
+ return { ok: true, out };
65
+ }
66
+ catch (e) {
67
+ const err = e;
68
+ return { ok: false, out: err.stderr?.toString().trim() || err.message || "systemctl failed" };
69
+ }
70
+ }
71
+ function mustSystemctl(args) {
72
+ const r = systemctl(args);
73
+ if (!r.ok)
74
+ throw new Error(`systemctl --user ${args.join(" ")} failed: ${r.out}`);
75
+ }
76
+ export function installAgent(opts) {
77
+ // No user manager (an ssh login without lingering, a container) means nothing would ever start
78
+ // the router: fail here, before any file is written.
79
+ mustSystemctl(["show-environment"]);
80
+ const file = unitPath();
81
+ if (fs.existsSync(file) && !fs.readFileSync(file, "utf8").startsWith(HEADER)) {
82
+ throw new Error(`${file} exists and is not ClaudeRipple's; refusing to replace it`);
83
+ }
84
+ fs.mkdirSync(path.dirname(file), { recursive: true });
85
+ fs.writeFileSync(file, renderUnit(opts), { mode: 0o600 });
86
+ mustSystemctl(["daemon-reload"]);
87
+ mustSystemctl(["enable", unitName()]);
88
+ return file;
89
+ }
90
+ export function removeAgent() {
91
+ const file = unitPath();
92
+ if (!fs.existsSync(file))
93
+ return false;
94
+ mustSystemctl(["disable", "--now", unitName()]);
95
+ fs.unlinkSync(file);
96
+ mustSystemctl(["daemon-reload"]);
97
+ return true;
98
+ }
99
+ export function agentState() {
100
+ const r = systemctl(["show", unitName(), "--property=ActiveState", "--value"]);
101
+ if (r.ok && r.out.trim() === "active")
102
+ return "running";
103
+ return fs.existsSync(unitPath()) ? "loaded" : "not-loaded";
104
+ }
105
+ export function agentPid() {
106
+ const r = systemctl(["show", unitName(), "--property=MainPID", "--value"]);
107
+ const pid = Number(r.out.trim());
108
+ return r.ok && Number.isSafeInteger(pid) && pid > 0 ? pid : null;
109
+ }
110
+ /** Idempotent, like launchd's: a running router is left alone (ARCHITECTURE §5, "Start Router"). */
111
+ export function startAgent() {
112
+ if (agentState() === "running")
113
+ return "already-running";
114
+ return systemctl(["start", unitName()]).ok ? "started" : "failed";
115
+ }
116
+ export function stopAgent() {
117
+ return systemctl(["stop", unitName()]).ok;
118
+ }
119
+ /**
120
+ * systemd sends the SIGTERM, waits for the process to exit (up to TimeoutStopSec) and then starts
121
+ * a new one, so the drain is never cut the way `launchctl kickstart -k` cut it.
122
+ */
123
+ export function restartAgent(opts = {}) {
124
+ opts.onProgress?.("waiting for in-flight model calls to finish (up to 120s)…");
125
+ return systemctl(["restart", unitName()]).ok ? "drained" : "failed";
126
+ }
@@ -0,0 +1,13 @@
1
+ // Environment for every powershell.exe (Windows PowerShell 5.1) the CLI and router start.
2
+ //
3
+ // PowerShell 7 puts its own module directories at the front of PSModulePath. pwsh strips them
4
+ // when it starts powershell.exe itself, but not when another process sits in between: from a pwsh
5
+ // terminal the chain pwsh → node → powershell.exe hands 5.1 the PowerShell 7 copy of
6
+ // Microsoft.PowerShell.Security, which fails to load, and the Cert: drive goes with it.
7
+ // `picker on` then stopped at "Cannot find drive 'Cert'" and `status` read a trusted CA as
8
+ // untrusted (measured on Windows 11 with PowerShell 7.6.6, issue #35). Without the variable,
9
+ // Windows PowerShell rebuilds its own default from the registry.
10
+ /** process.env minus PSModulePath, matched without regard to case as Windows does. */
11
+ export function windowsPowerShellEnv() {
12
+ return Object.fromEntries(Object.entries(process.env).filter(([k]) => k.toLowerCase() !== "psmodulepath"));
13
+ }