routstrd 0.4.0 → 0.4.2

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.
@@ -8,6 +8,8 @@ import { callDaemon, getDaemonBaseUrl } from "../utils/daemon-client";
8
8
  type PiModelEntry = {
9
9
  id: string;
10
10
  contextWindow?: number;
11
+ // Preserve arbitrary manual fields (reasoning, thinkingLevelMap, compat, cost, maxTokens, etc.)
12
+ [key: string]: unknown;
11
13
  };
12
14
 
13
15
  type PiProviderConfig = {
@@ -61,15 +63,29 @@ export async function installPiIntegration(
61
63
  return;
62
64
  }
63
65
 
66
+ // Preserve any manually-added per-model fields (reasoning, thinkingLevelMap,
67
+ // compat, cost, maxTokens, etc.) across refreshes. Only `id` and `contextWindow`
68
+ // are managed by routstrd.
69
+ const existingModels = new Map<string, PiModelEntry>(
70
+ (piConfig.providers["routstr"]?.models ?? []).map((m) => [m.id, m]),
71
+ );
72
+
64
73
  const providerModels: PiModelEntry[] = models.map((model) => {
65
- const entry: PiModelEntry = { id: model.id };
74
+ const previous = existingModels.get(model.id);
75
+ const entry: PiModelEntry = previous ? { ...previous } : { id: model.id };
76
+
77
+ entry.id = model.id;
66
78
  if (model.context_length !== undefined && model.context_length > 0) {
67
79
  entry.contextWindow = model.context_length;
68
80
  }
81
+
69
82
  return entry;
70
83
  });
71
84
 
85
+ // Preserve provider-level manual fields (compat, headers, name, etc.); only update
86
+ // the routstrd-managed fields below.
72
87
  piConfig.providers["routstr"] = {
88
+ ...(piConfig.providers["routstr"] ?? {}),
73
89
  baseUrl,
74
90
  api: "openai-completions",
75
91
  apiKey,
@@ -170,6 +170,22 @@ async function startDaemonUnlocked(
170
170
 
171
171
  proc.unref();
172
172
 
173
+ // The daemon must outlive a successful start, but a start we give up on must not
174
+ // leave a detached process holding the wallet locks. `detached` makes it a group leader.
175
+ const killSpawnedDaemon = (): void => {
176
+ try {
177
+ process.kill(-proc.pid, "SIGTERM");
178
+ } catch {
179
+ // Already exited.
180
+ }
181
+ };
182
+ const abandonOnSignal = (): void => {
183
+ killSpawnedDaemon();
184
+ process.exit(1);
185
+ };
186
+ process.once("SIGINT", abandonOnSignal);
187
+ process.once("SIGTERM", abandonOnSignal);
188
+
173
189
  let exitCode: number | null = null;
174
190
  proc.exited.then((code) => {
175
191
  exitCode = code;
@@ -212,6 +228,8 @@ async function startDaemonUnlocked(
212
228
  }
213
229
 
214
230
  if (await healthyDaemonHost(port, host)) {
231
+ process.off("SIGINT", abandonOnSignal);
232
+ process.off("SIGTERM", abandonOnSignal);
215
233
  printStartupProgress(progressLogOffset);
216
234
  console.log(
217
235
  `Routstr daemon started (PID: ${proc.pid}, ${formatElapsed(Date.now() - startedAt)}).`,
@@ -220,8 +238,10 @@ async function startDaemonUnlocked(
220
238
  }
221
239
  }
222
240
 
241
+ killSpawnedDaemon();
223
242
  throw new Error(
224
- `Daemon failed to start within ${Math.round(startupTimeoutMs / 1000)} seconds. Check logs in ${LOGS_DIR}`,
243
+ `Daemon did not become healthy within ${Math.round(startupTimeoutMs / 1000)} seconds; ` +
244
+ `the incomplete daemon (PID ${proc.pid}) was stopped. Check logs in ${LOGS_DIR}`,
225
245
  );
226
246
  }
227
247
 
@@ -1,5 +1,10 @@
1
1
  import type { UsageTrackingEntry } from "../../daemon/types.ts";
2
- import { callDaemon, isDaemonRunning } from "../../utils/daemon-client.ts";
2
+ import {
3
+ callDaemon,
4
+ getDaemonBaseUrl,
5
+ isDaemonRunning,
6
+ loadConfig,
7
+ } from "../../utils/daemon-client.ts";
3
8
  import type { UsageStats, UsageSummary } from "./types.ts";
4
9
 
5
10
  export { isDaemonRunning };
@@ -21,12 +26,17 @@ export interface StatusInfo {
21
26
  daemon: string;
22
27
  wallet: string;
23
28
  mode: "xcashu" | "apikeys";
29
+ /** URL the TUI is connected through (local bind or remote daemonUrl). */
30
+ url?: string;
24
31
  error?: string;
25
32
  }
26
33
 
27
34
  export async function fetchStatus(): Promise<StatusInfo | null> {
28
35
  try {
29
- const result = await callDaemon("/status");
36
+ const [result, config] = await Promise.all([
37
+ callDaemon("/status"),
38
+ loadConfig(),
39
+ ]);
30
40
  if (result.error) return null;
31
41
 
32
42
  const output = result.output as {
@@ -40,6 +50,7 @@ export async function fetchStatus(): Promise<StatusInfo | null> {
40
50
  daemon: output?.daemon || "unknown",
41
51
  wallet: output?.wallet || "unknown",
42
52
  mode: output?.mode || "apikeys",
53
+ url: getDaemonBaseUrl(config),
43
54
  error: output?.error,
44
55
  };
45
56
  } catch {
@@ -162,6 +162,14 @@ export function renderOverview(stats: UsageStats, balance: BalanceInfo | null, s
162
162
  const walletColor = status.wallet === "connected" ? COLORS.green : COLORS.red;
163
163
  const modeColor = COLORS.cyan;
164
164
  statusLines.push(`${COLORS.bold}Daemon:${COLORS.reset} ${daemonColor}${status.daemon}${COLORS.reset}`);
165
+ if (status.url) {
166
+ const label = "Running on: ";
167
+ const maxUrlLen = Math.max(8, halfWidth2 - 4 - label.length);
168
+ const url = status.url.length > maxUrlLen
169
+ ? `${status.url.slice(0, Math.max(0, maxUrlLen - 1))}…`
170
+ : status.url;
171
+ statusLines.push(`${COLORS.bold}${label}${COLORS.reset} ${COLORS.cyan}${url}${COLORS.reset}`);
172
+ }
165
173
  statusLines.push(`${COLORS.bold}Wallet:${COLORS.reset} ${walletColor}${status.wallet}${COLORS.reset}`);
166
174
  statusLines.push(`${COLORS.bold}Mode:${COLORS.reset} ${modeColor}${status.mode}${COLORS.reset}`);
167
175
  if (status.error) {
@@ -48,8 +48,10 @@ export interface RoutstrdConfig {
48
48
  * Defaults to daemonUrl or localhost:{port} if not set. */
49
49
  authUrl?: string;
50
50
  nsec?: string;
51
- /** Nostr hex pubkey for routstr review/model events (kind 38425/38423). */
51
+ /** Nostr hex pubkey for routstr review/audit events (kind 38425). */
52
52
  routstrPubkey?: string;
53
+ /** Nostr hex pubkey for the routstr-21 model list only (kind 38423). Falls back to routstrPubkey. */
54
+ routstrModelsPubkey?: string;
53
55
  /** Nostr relay URLs for provider/model discovery (kinds 38421/38423/38425).
54
56
  * When unset, each method uses its own built-in defaults. */
55
57
  relays?: string[];
@@ -0,0 +1,55 @@
1
+ import { describe, expect, test } from "bun:test";
2
+ import { spawnSync } from "child_process";
3
+ import { mkdtempSync, rmSync } from "fs";
4
+ import { join } from "path";
5
+ import { tmpdir } from "os";
6
+
7
+ // These scenarios mutate process.env.ROUTSTRD_DIR before the config-store
8
+ // module is loaded, and other test files import the same module singleton
9
+ // first — so each scenario runs in an isolated bun subprocess where the env
10
+ // is guaranteed to be in place before module evaluation.
11
+
12
+ const SCENARIO = join(import.meta.dir, "config-store.scenario.ts");
13
+
14
+ function runScenario(name: string): { code: number; out: string } {
15
+ const dir = mkdtempSync(join(tmpdir(), `routstrd-cfg-${name}-`));
16
+ const res = spawnSync("bun", [SCENARIO, name], {
17
+ env: {
18
+ ...process.env,
19
+ NODE_ENV: "test",
20
+ ROUTSTRD_DIR: join(dir, "daemon"),
21
+ },
22
+ stdout: "pipe",
23
+ stderr: "pipe",
24
+ });
25
+ const out = `${res.stdout}${res.stderr}`;
26
+ rmSync(dir, { recursive: true, force: true });
27
+ // bun's spawnSync reports the exit code in `status` (exitCode is undefined).
28
+ return { code: res.status ?? res.exitCode ?? -1, out };
29
+ }
30
+
31
+ describe("daemon config store permissions", () => {
32
+ test("fresh install: config dir 0700, config file 0600, atomic write", () => {
33
+ const { code, out } = runScenario("fresh-install");
34
+ expect(out).toContain("SCENARIO-OK");
35
+ expect(code).toBe(0);
36
+ });
37
+
38
+ test("existing over-permissive install (0755/0644) is repaired", () => {
39
+ const { code, out } = runScenario("repair-perms");
40
+ expect(out).toContain("SCENARIO-OK");
41
+ expect(code).toBe(0);
42
+ });
43
+
44
+ test("write failure surfaces synchronously with no temp file left", () => {
45
+ const { code, out } = runScenario("write-failure");
46
+ expect(out).toContain("SCENARIO-OK");
47
+ expect(code).toBe(0);
48
+ });
49
+
50
+ test("corrupt JSON falls back to defaults", () => {
51
+ const { code, out } = runScenario("corrupt-json");
52
+ expect(out).toContain("SCENARIO-OK");
53
+ expect(code).toBe(0);
54
+ });
55
+ });
@@ -0,0 +1,113 @@
1
+ // Scenario runner for config-store.perms.test.ts — executed as a standalone
2
+ // bun process with ROUTSTRD_DIR set before module evaluation. Not a test file.
3
+ import {
4
+ chmodSync,
5
+ existsSync,
6
+ readdirSync,
7
+ statSync,
8
+ writeFileSync,
9
+ } from "fs";
10
+
11
+ const { CONFIG_DIR, CONFIG_FILE } = await import("../../src/utils/config");
12
+ const {
13
+ ensureDirs,
14
+ ensureDirsSync,
15
+ loadDaemonConfig,
16
+ loadDaemonConfigSync,
17
+ saveDaemonConfig,
18
+ REQUESTS_DIR,
19
+ } = await import("../../src/daemon/config-store");
20
+
21
+ const modeOf = (p: string): number => statSync(p).mode & 0o777;
22
+
23
+ const baseConfig = {
24
+ port: 8008,
25
+ host: "127.0.0.1",
26
+ provider: null,
27
+ cocodPath: null,
28
+ };
29
+
30
+ function assert(cond: unknown, msg: string): void {
31
+ if (!cond) {
32
+ console.error(`ASSERT-FAIL: ${msg}`);
33
+ process.exit(1);
34
+ }
35
+ }
36
+
37
+ const scenario = process.argv[2];
38
+
39
+ switch (scenario) {
40
+ case "fresh-install": {
41
+ saveDaemonConfig({
42
+ ...baseConfig,
43
+ nsec: "nsec1qqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqq",
44
+ });
45
+ assert(modeOf(CONFIG_DIR) === 0o700, `dir mode ${modeOf(CONFIG_DIR).toString(8)} != 700`);
46
+ assert(modeOf(CONFIG_FILE) === 0o600, `file mode ${modeOf(CONFIG_FILE).toString(8)} != 600`);
47
+ assert(
48
+ readdirSync(CONFIG_DIR).filter((f) => f.endsWith(".tmp")).length === 0,
49
+ "temp file left behind",
50
+ );
51
+ const loaded = await loadDaemonConfig();
52
+ assert(
53
+ loaded.nsec === "nsec1qqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqq",
54
+ "nsec round-trip failed",
55
+ );
56
+ assert(loaded.port === 8008, "port round-trip failed");
57
+
58
+ await ensureDirs();
59
+ assert(modeOf(CONFIG_DIR) === 0o700, "ensureDirs dir mode");
60
+ assert(modeOf(REQUESTS_DIR) === 0o700, "ensureDirs requests dir mode");
61
+ break;
62
+ }
63
+
64
+ case "repair-perms": {
65
+ // Simulate an install written by the vulnerable version.
66
+ ensureDirsSync();
67
+ writeFileSync(CONFIG_FILE, JSON.stringify(baseConfig), { mode: 0o644 });
68
+ chmodSync(CONFIG_DIR, 0o755);
69
+ chmodSync(CONFIG_FILE, 0o644);
70
+ assert(modeOf(CONFIG_FILE) === 0o644, "setup: file not 0644");
71
+
72
+ // Load repairs the file mode...
73
+ loadDaemonConfigSync();
74
+ assert(modeOf(CONFIG_FILE) === 0o600, "load did not repair file mode");
75
+
76
+ // ...and the next save repairs the directory mode too.
77
+ chmodSync(CONFIG_DIR, 0o755);
78
+ saveDaemonConfig({ ...baseConfig, port: 9009 });
79
+ assert(modeOf(CONFIG_DIR) === 0o700, "save did not repair dir mode");
80
+ assert(modeOf(CONFIG_FILE) === 0o600, "save did not keep file mode");
81
+ assert(loadDaemonConfigSync().port === 9009, "save did not persist port");
82
+ break;
83
+ }
84
+
85
+ case "write-failure": {
86
+ // Turn the config dir into a regular file: mkdir fails and no temp write
87
+ // can succeed, so saveDaemonConfig must surface the error synchronously.
88
+ writeFileSync(process.env.ROUTSTRD_DIR!, "blocked");
89
+ let threw = false;
90
+ try {
91
+ saveDaemonConfig(baseConfig);
92
+ } catch {
93
+ threw = true;
94
+ }
95
+ assert(threw, "saveDaemonConfig did not throw on unwritable target");
96
+ break;
97
+ }
98
+
99
+ case "corrupt-json": {
100
+ ensureDirsSync();
101
+ writeFileSync(CONFIG_FILE, "{ not json");
102
+ const loaded = await loadDaemonConfig();
103
+ assert(loaded.port === 8008, "did not fall back to defaults");
104
+ assert(existsSync(CONFIG_FILE), "config file vanished");
105
+ break;
106
+ }
107
+
108
+ default:
109
+ console.error(`unknown scenario: ${scenario}`);
110
+ process.exit(2);
111
+ }
112
+
113
+ console.log("SCENARIO-OK");
@@ -66,7 +66,7 @@ other_setting: true
66
66
  });
67
67
  });
68
68
 
69
- it("keeps an existing Routstr provider unchanged without duplicating it", () => {
69
+ it("updates an existing Routstr provider in place without duplicating it", () => {
70
70
  const existing = `custom_providers:
71
71
  - name: Routstr (old-host:8008)
72
72
  base_url: http://old-host:8008/v1
@@ -83,14 +83,51 @@ other_setting: true
83
83
 
84
84
  expect(routstrProviders).toHaveLength(1);
85
85
  expect(routstrProviders[0]).toEqual({
86
- name: "Routstr (old-host:8008)",
87
- base_url: "http://old-host:8008/v1",
88
- api_key: "old-key",
89
- model: "old-model",
86
+ name: "Routstr (localhost:8008)",
87
+ base_url: ROUTSTR.baseUrl,
88
+ api_key: ROUTSTR.apiKey,
89
+ model: ROUTSTR.defaultModel,
90
90
  });
91
91
  expect(twice).toBe(once);
92
92
  });
93
93
 
94
+ it("repoints model.provider when the Routstr provider is renamed", () => {
95
+ const existing = `model:
96
+ default: old-model
97
+ provider: custom:routstr-(old-host:8008)
98
+
99
+ custom_providers:
100
+ - name: Routstr (old-host:8008)
101
+ base_url: http://old-host:8008/v1
102
+ api_key: old-key
103
+ model: old-model
104
+ `;
105
+
106
+ const merged = parse(mergeHermesConfig(existing, ROUTSTR));
107
+
108
+ expect(merged.model).toEqual({
109
+ default: "old-model",
110
+ provider: "custom:routstr-(localhost:8008)",
111
+ });
112
+ });
113
+
114
+ it("leaves model.provider alone when it does not reference the Routstr provider", () => {
115
+ const existing = `model:
116
+ default: user-model
117
+ provider: custom:user-provider
118
+
119
+ custom_providers:
120
+ - name: Routstr (old-host:8008)
121
+ base_url: http://old-host:8008/v1
122
+ api_key: old-key
123
+ model: old-model
124
+ `;
125
+
126
+ const merged = parse(mergeHermesConfig(existing, ROUTSTR));
127
+
128
+ expect(merged.model.provider).toBe("custom:user-provider");
129
+ });
130
+
94
131
  it("rejects malformed YAML instead of replacing it", () => {
95
132
  expect(() => mergeHermesConfig("model: [unterminated", ROUTSTR)).toThrow();
96
133
  });