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.
- package/COCO-2.0.0-MIGRATION-PLAN.md +1191 -0
- package/README.md +5 -0
- package/bun.lock +316 -2
- package/dist/daemon/index.js +70789 -54681
- package/dist/index.js +729 -183
- package/package.json +2 -2
- package/src/cli.ts +258 -38
- package/src/daemon/config-store.ts +79 -4
- package/src/daemon/fatal-error.test.ts +104 -0
- package/src/daemon/fatal-error.ts +48 -0
- package/src/daemon/models.test.ts +81 -0
- package/src/daemon/models.ts +18 -2
- package/src/daemon/wallet/auto-refill.ts +4 -6
- package/src/daemon/wallet/cleanup.test.ts +168 -0
- package/src/daemon/wallet/cleanup.ts +104 -0
- package/src/daemon/wallet/coco-client.test.ts +191 -30
- package/src/daemon/wallet/coco-client.ts +867 -74
- package/src/daemon/wallet/cocod-client.ts +49 -1
- package/src/daemon/wallet/diagnostics.test.ts +378 -0
- package/src/daemon/wallet/diagnostics.ts +471 -0
- package/src/daemon/wallet/migration.test.ts +30 -6
- package/src/daemon/wallet/migration.ts +30 -85
- package/src/daemon/wallet/receive-dedup.test.ts +238 -0
- package/src/daemon/wallet/receive-dedup.ts +384 -0
- package/src/daemon/wallet/wallet-state.ts +75 -0
- package/src/integrations/pi.ts +17 -1
- package/src/start-daemon.ts +21 -1
- package/src/tui/usage/data.ts +13 -2
- package/src/tui/usage/render.ts +8 -0
- package/src/utils/config.ts +3 -1
- package/tests/daemon/config-store.perms.test.ts +55 -0
- package/tests/daemon/config-store.scenario.ts +113 -0
- package/tests/integrations/hermes.test.ts +42 -5
package/src/integrations/pi.ts
CHANGED
|
@@ -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
|
|
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,
|
package/src/start-daemon.ts
CHANGED
|
@@ -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
|
|
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
|
|
package/src/tui/usage/data.ts
CHANGED
|
@@ -1,5 +1,10 @@
|
|
|
1
1
|
import type { UsageTrackingEntry } from "../../daemon/types.ts";
|
|
2
|
-
import {
|
|
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
|
|
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 {
|
package/src/tui/usage/render.ts
CHANGED
|
@@ -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) {
|
package/src/utils/config.ts
CHANGED
|
@@ -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/
|
|
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("
|
|
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 (
|
|
87
|
-
base_url:
|
|
88
|
-
api_key:
|
|
89
|
-
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
|
});
|