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.
- package/CHANGELOG.md +56 -0
- package/README.ko.md +96 -25
- package/README.md +115 -28
- package/dist/cli/src/browser.js +3 -2
- package/dist/cli/src/claude-auth.js +6 -0
- package/dist/cli/src/index.js +36 -23
- package/dist/cli/src/picker.js +99 -6
- package/dist/cli/src/schtasks.js +48 -19
- package/dist/cli/src/settings.js +10 -3
- package/dist/cli/src/supervisor.js +17 -11
- package/dist/cli/src/systemd.js +126 -0
- package/dist/cli/src/windows-powershell.js +13 -0
- package/dist/router/src/admin.js +16 -10
- package/dist/router/src/compat.js +35 -0
- package/dist/router/src/proxy.js +16 -0
- package/dist/router/src/thinking.js +39 -0
- package/dist/router/src/version.js +1 -1
- package/docs/ARCHITECTURE.md +4 -1
- package/package.json +7 -3
package/dist/cli/src/index.js
CHANGED
|
@@ -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,
|
|
5
|
-
//
|
|
6
|
-
// then
|
|
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
|
-
:
|
|
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
|
|
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
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
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
|
|
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
|
-
|
|
186
|
-
|
|
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)) {
|
package/dist/cli/src/picker.js
CHANGED
|
@@ -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
|
|
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
|
-
*
|
|
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 (
|
|
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 (
|
|
176
|
+
if (isLinux)
|
|
177
|
+
return nssUntrust();
|
|
178
|
+
if (!caTrusted(caPem))
|
|
86
179
|
return false;
|
|
87
180
|
if (isWindows) {
|
|
88
181
|
try {
|
package/dist/cli/src/schtasks.js
CHANGED
|
@@ -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
|
|
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
|
|
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 =
|
|
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
|
-
|
|
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'`,
|
package/dist/cli/src/settings.js
CHANGED
|
@@ -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
|
-
|
|
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,
|
|
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
|
|
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
|
+
}
|