sigrank 0.17.2 → 0.17.4
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/README.md +1 -1
- package/keystore.mjs +81 -1
- package/package.json +1 -1
- package/tools.mjs +24 -0
- package/tui.mjs +66 -10
package/README.md
CHANGED
package/keystore.mjs
CHANGED
|
@@ -19,11 +19,15 @@ import {
|
|
|
19
19
|
existsSync,
|
|
20
20
|
chmodSync,
|
|
21
21
|
unlinkSync,
|
|
22
|
+
readdirSync,
|
|
23
|
+
copyFileSync,
|
|
22
24
|
} from "node:fs";
|
|
23
25
|
import { generateKeyPairSync, randomUUID } from "node:crypto";
|
|
24
26
|
|
|
25
27
|
const DIR = join(homedir(), ".sigrank-mcp");
|
|
26
28
|
const PATH = join(DIR, "identity.json");
|
|
29
|
+
const BACKUP_PREFIX = "identity.json.bak-";
|
|
30
|
+
const MAX_BACKUPS = 5;
|
|
27
31
|
|
|
28
32
|
/** SPKI DER prefix length for an ed25519 public key (12 bytes before the raw 32). */
|
|
29
33
|
const SPKI_PREFIX_LEN = 12;
|
|
@@ -54,7 +58,8 @@ export function loadIdentity() {
|
|
|
54
58
|
}
|
|
55
59
|
}
|
|
56
60
|
|
|
57
|
-
/** Write the identity with strict perms (dir 0700, file 0600).
|
|
61
|
+
/** Write the identity with strict perms (dir 0700, file 0600). Backs up the existing
|
|
62
|
+
* file before overwriting so a corrupt write or accidental reset can be recovered. */
|
|
58
63
|
export function persistIdentity(identity) {
|
|
59
64
|
mkdirSync(DIR, { recursive: true });
|
|
60
65
|
try {
|
|
@@ -62,6 +67,17 @@ export function persistIdentity(identity) {
|
|
|
62
67
|
} catch {
|
|
63
68
|
/* best-effort on platforms without chmod */
|
|
64
69
|
}
|
|
70
|
+
// Back up the existing identity before overwriting (resilience: if the write
|
|
71
|
+
// fails or the file is later lost, the backup carries the binding + keys).
|
|
72
|
+
if (existsSync(PATH)) {
|
|
73
|
+
try {
|
|
74
|
+
const stamp = Date.now();
|
|
75
|
+
copyFileSync(PATH, join(DIR, `${BACKUP_PREFIX}${stamp}`));
|
|
76
|
+
pruneBackups();
|
|
77
|
+
} catch {
|
|
78
|
+
/* best-effort — don't block the write */
|
|
79
|
+
}
|
|
80
|
+
}
|
|
65
81
|
writeFileSync(PATH, `${JSON.stringify(identity, null, 2)}\n`, {
|
|
66
82
|
mode: 0o600,
|
|
67
83
|
});
|
|
@@ -118,6 +134,9 @@ export function bindingForFreshIdentity(existing, fresh) {
|
|
|
118
134
|
/**
|
|
119
135
|
* Load the existing identity, or generate + persist a fresh one. Idempotent: a
|
|
120
136
|
* complete existing identity is returned untouched (never rotates a live key).
|
|
137
|
+
* Self-healing: if the identity has keys + device_id but no codename (binding
|
|
138
|
+
* lost via partial write / version transition), scans backups for a matching
|
|
139
|
+
* device_id and restores the binding before returning.
|
|
121
140
|
*/
|
|
122
141
|
export function ensureIdentity() {
|
|
123
142
|
const existing = loadIdentity();
|
|
@@ -126,6 +145,16 @@ export function ensureIdentity() {
|
|
|
126
145
|
existing?.public_key &&
|
|
127
146
|
existing?.device_id
|
|
128
147
|
) {
|
|
148
|
+
// Self-healing: keys present but binding lost → try to restore from backups.
|
|
149
|
+
if (!existing.codename || !existing.operator_id) {
|
|
150
|
+
const restored = restoreBindingFromBackups(existing.device_id);
|
|
151
|
+
if (restored) {
|
|
152
|
+
existing.codename = restored.codename;
|
|
153
|
+
existing.operator_id = restored.operator_id;
|
|
154
|
+
existing.enrolled_at = restored.enrolled_at;
|
|
155
|
+
return persistIdentity(existing);
|
|
156
|
+
}
|
|
157
|
+
}
|
|
129
158
|
return existing;
|
|
130
159
|
}
|
|
131
160
|
// Regeneration path: a partial/corrupt record. The binding is tied to device_id —
|
|
@@ -160,3 +189,54 @@ export function clearIdentity() {
|
|
|
160
189
|
/* best-effort */
|
|
161
190
|
}
|
|
162
191
|
}
|
|
192
|
+
|
|
193
|
+
/** Keep at most MAX_BACKUPS backup files (oldest deleted first). */
|
|
194
|
+
function pruneBackups() {
|
|
195
|
+
try {
|
|
196
|
+
const files = readdirSync(DIR)
|
|
197
|
+
.filter((f) => f.startsWith(BACKUP_PREFIX))
|
|
198
|
+
.sort(); // timestamp-suffixed → lexicographic = chronological
|
|
199
|
+
while (files.length > MAX_BACKUPS) {
|
|
200
|
+
unlinkSync(join(DIR, files.shift()));
|
|
201
|
+
}
|
|
202
|
+
} catch {
|
|
203
|
+
/* best-effort */
|
|
204
|
+
}
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
/** Scan backup files for one with the same device_id that carries a codename +
|
|
208
|
+
* operator_id binding. Returns { codename, operator_id, enrolled_at } or null.
|
|
209
|
+
* Used by ensureIdentity() to self-heal when the live file lost its binding
|
|
210
|
+
* (e.g. a partial write or version transition that preserved keys but dropped
|
|
211
|
+
* the binding metadata). Only restores from a backup with the SAME device_id —
|
|
212
|
+
* never crosses device boundaries (Frankenstein-identity guard). */
|
|
213
|
+
export function restoreBindingFromBackups(device_id) {
|
|
214
|
+
if (!device_id) return null;
|
|
215
|
+
try {
|
|
216
|
+
const files = readdirSync(DIR)
|
|
217
|
+
.filter((f) => f.startsWith(BACKUP_PREFIX))
|
|
218
|
+
.sort()
|
|
219
|
+
.reverse(); // newest first — prefer the most recent binding
|
|
220
|
+
for (const f of files) {
|
|
221
|
+
try {
|
|
222
|
+
const bak = JSON.parse(readFileSync(join(DIR, f), "utf-8"));
|
|
223
|
+
if (
|
|
224
|
+
bak.device_id === device_id &&
|
|
225
|
+
bak.codename &&
|
|
226
|
+
bak.operator_id
|
|
227
|
+
) {
|
|
228
|
+
return {
|
|
229
|
+
codename: bak.codename,
|
|
230
|
+
operator_id: bak.operator_id,
|
|
231
|
+
enrolled_at: bak.enrolled_at ?? null,
|
|
232
|
+
};
|
|
233
|
+
}
|
|
234
|
+
} catch {
|
|
235
|
+
/* skip corrupt backups */
|
|
236
|
+
}
|
|
237
|
+
}
|
|
238
|
+
} catch {
|
|
239
|
+
/* best-effort */
|
|
240
|
+
}
|
|
241
|
+
return null;
|
|
242
|
+
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "sigrank",
|
|
3
|
-
"version": "0.17.
|
|
3
|
+
"version": "0.17.4",
|
|
4
4
|
"author": "SunrisesIllNeverSee (https://github.com/SunrisesIllNeverSee)",
|
|
5
5
|
"description": "SigRank MCP server — the yield cascade + live leaderboard as MCP tools any agent can call",
|
|
6
6
|
"mcpName": "io.github.SunrisesIllNeverSee/sigrank-mcp",
|
package/tools.mjs
CHANGED
|
@@ -1461,6 +1461,30 @@ export async function callTool(name, args, opts = {}) {
|
|
|
1461
1461
|
trust_status: ack.trust_status ?? "trusted",
|
|
1462
1462
|
};
|
|
1463
1463
|
}
|
|
1464
|
+
// Recovery: if the server says device_already_enrolled but includes the
|
|
1465
|
+
// codename/operator_id, the device IS bound server-side — record it locally
|
|
1466
|
+
// and return enrolled instead of erroring. This handles the case where the
|
|
1467
|
+
// local binding was lost (partial write, version transition) but the device
|
|
1468
|
+
// is still enrolled server-side.
|
|
1469
|
+
if (
|
|
1470
|
+
ack.reason === "device_already_enrolled" &&
|
|
1471
|
+
ack.codename &&
|
|
1472
|
+
ack.operator_id
|
|
1473
|
+
) {
|
|
1474
|
+
if (!opts.identity)
|
|
1475
|
+
recordEnrollment({
|
|
1476
|
+
codename: ack.codename,
|
|
1477
|
+
operator_id: ack.operator_id,
|
|
1478
|
+
});
|
|
1479
|
+
return {
|
|
1480
|
+
status: "enrolled",
|
|
1481
|
+
codename: ack.codename,
|
|
1482
|
+
operator_id: ack.operator_id,
|
|
1483
|
+
device_id: id.device_id,
|
|
1484
|
+
trust_status: ack.trust_status ?? "trusted",
|
|
1485
|
+
recovered: true,
|
|
1486
|
+
};
|
|
1487
|
+
}
|
|
1464
1488
|
return {
|
|
1465
1489
|
status: "error",
|
|
1466
1490
|
httpStatus: res.status,
|
package/tui.mjs
CHANGED
|
@@ -2049,7 +2049,7 @@ export async function runTui({
|
|
|
2049
2049
|
renderConnect(connectId, codeBuf, connectMsg);
|
|
2050
2050
|
const signOutHint = isSignedIn(connectId)
|
|
2051
2051
|
? ` ${dim("[X]")} sign out`
|
|
2052
|
-
: "";
|
|
2052
|
+
: " " + dim("[X]") + " reset device";
|
|
2053
2053
|
setFooter([
|
|
2054
2054
|
` ${hr()}`,
|
|
2055
2055
|
` ${dim("[Enter]")} sign in ${dim("[Esc]")} clear/back${signOutHint} ${dim("← →")} tabs ${dim("[Q]")} quit`,
|
|
@@ -2136,8 +2136,17 @@ export async function runTui({
|
|
|
2136
2136
|
process.stdin.resume();
|
|
2137
2137
|
process.stdin.setEncoding("utf8");
|
|
2138
2138
|
|
|
2139
|
+
// Esc-sequence buffer: some terminals (SSH, tmux, certain emulators) split an
|
|
2140
|
+
// arrow key (\x1b[C) into two data events: \x1b then [C. Without buffering, the
|
|
2141
|
+
// lone \x1b triggers the Esc handler (clearing the code buffer / leaving the tab)
|
|
2142
|
+
// before the [C arrives. We hold \x1b for 25ms; if more bytes follow, we
|
|
2143
|
+
// reassemble and deliver the full sequence. If nothing follows, it's a real Esc.
|
|
2144
|
+
let escBuf = null;
|
|
2145
|
+
let escTimer = null;
|
|
2146
|
+
const ESC_HOLD_MS = 25;
|
|
2147
|
+
|
|
2139
2148
|
await new Promise((resolve) => {
|
|
2140
|
-
|
|
2149
|
+
const handleKey = async (key) => {
|
|
2141
2150
|
const k = key.toLowerCase();
|
|
2142
2151
|
|
|
2143
2152
|
if (k === "q" || k === "\x03") {
|
|
@@ -2166,14 +2175,16 @@ export async function runTui({
|
|
|
2166
2175
|
try {
|
|
2167
2176
|
const out = await callTool("enroll", { code });
|
|
2168
2177
|
if (out.status === "enrolled") {
|
|
2169
|
-
connectMsg =
|
|
2178
|
+
connectMsg = out.recovered
|
|
2179
|
+
? `${green("✓")} recovered account: ${cyan(out.codename || "(operator)")} ${dim("(device was already bound)")}`
|
|
2180
|
+
: `${green("✓")} signed in as ${cyan(out.codename || "(operator)")}`;
|
|
2170
2181
|
codeBuf = "";
|
|
2171
2182
|
} else {
|
|
2172
2183
|
const reasons = {
|
|
2173
2184
|
code_invalid:
|
|
2174
2185
|
"that code is invalid, expired, or already used — generate a fresh one.",
|
|
2175
2186
|
device_already_enrolled:
|
|
2176
|
-
'this device is already
|
|
2187
|
+
'this device is already bound. Press [X] to reset the device, then paste a fresh code from signalaf.com → Settings → "New key".',
|
|
2177
2188
|
bad_request: "the code or device key was malformed.",
|
|
2178
2189
|
rate_limited:
|
|
2179
2190
|
"too many attempts — wait a few minutes and retry.",
|
|
@@ -2214,14 +2225,20 @@ export async function runTui({
|
|
|
2214
2225
|
await redraw();
|
|
2215
2226
|
return;
|
|
2216
2227
|
} // backspace
|
|
2217
|
-
// [X] sign out / reset —
|
|
2218
|
-
//
|
|
2219
|
-
//
|
|
2220
|
-
// a fresh device_id (escapes the Frankenstein-
|
|
2221
|
-
|
|
2228
|
+
// [X] sign out / reset — when the code field is empty (so an 'x' typed mid-code
|
|
2229
|
+
// still feeds the buffer). Drops the local credential so a server-revoked OR
|
|
2230
|
+
// server-enrolled-but-locally-unbound device stops holding a stale device_id,
|
|
2231
|
+
// and the next enroll provisions a fresh device_id (escapes the Frankenstein-
|
|
2232
|
+
// identity / 409-re-enroll deadlock). Works whether or not isSignedIn() —
|
|
2233
|
+
// otherwise a device enrolled server-side but unbound locally is un-escapable
|
|
2234
|
+
// (can't sign in: device_already_enrolled; can't sign out: X gated off).
|
|
2235
|
+
if (k === "x" && !codeBuf) {
|
|
2236
|
+
const wasSignedIn = isSignedIn(loadIdentity());
|
|
2222
2237
|
clearIdentity();
|
|
2223
2238
|
codeBuf = "";
|
|
2224
|
-
connectMsg =
|
|
2239
|
+
connectMsg = wasSignedIn
|
|
2240
|
+
? `${green("✓")} signed out — paste a new code to sign in`
|
|
2241
|
+
: `${green("✓")} device reset — paste a new code to sign in`;
|
|
2225
2242
|
await redraw();
|
|
2226
2243
|
return;
|
|
2227
2244
|
}
|
|
@@ -2581,6 +2598,45 @@ export async function runTui({
|
|
|
2581
2598
|
|
|
2582
2599
|
if (switched) submitMsg = ""; // submit result is tied to the tab it was sent from
|
|
2583
2600
|
if (switched || k === "w") await redraw();
|
|
2601
|
+
};
|
|
2602
|
+
|
|
2603
|
+
// Buffered stdin listener: reassembles split Esc sequences before dispatch.
|
|
2604
|
+
process.stdin.on("data", (chunk) => {
|
|
2605
|
+
// If we have a pending \x1b held from a previous chunk, combine it with
|
|
2606
|
+
// this chunk to reassemble the full escape sequence (\x1b + [C = \x1b[C).
|
|
2607
|
+
if (escBuf) {
|
|
2608
|
+
clearTimeout(escTimer);
|
|
2609
|
+
const combined = escBuf + chunk;
|
|
2610
|
+
escBuf = null;
|
|
2611
|
+
escTimer = null;
|
|
2612
|
+
// If the combined result is \x1b followed by more \x1b (rare), just
|
|
2613
|
+
// deliver the first as Esc and re-buffer the second. Otherwise deliver
|
|
2614
|
+
// the combined sequence.
|
|
2615
|
+
if (combined.length > 1) {
|
|
2616
|
+
handleKey(combined);
|
|
2617
|
+
return;
|
|
2618
|
+
}
|
|
2619
|
+
// combined is still just \x1b (chunk was empty?) → fall through to hold
|
|
2620
|
+
}
|
|
2621
|
+
|
|
2622
|
+
// Fast path: chunk doesn't start with \x1b → deliver immediately.
|
|
2623
|
+
if (chunk[0] !== "\x1b") {
|
|
2624
|
+
handleKey(chunk);
|
|
2625
|
+
return;
|
|
2626
|
+
}
|
|
2627
|
+
// Chunk starts with \x1b and is longer than 1 byte → complete escape
|
|
2628
|
+
// sequence → deliver immediately.
|
|
2629
|
+
if (chunk.length > 1) {
|
|
2630
|
+
handleKey(chunk);
|
|
2631
|
+
return;
|
|
2632
|
+
}
|
|
2633
|
+
// Chunk is exactly \x1b → hold for ESC_HOLD_MS to see if more bytes follow.
|
|
2634
|
+
escBuf = chunk;
|
|
2635
|
+
escTimer = setTimeout(() => {
|
|
2636
|
+
handleKey(escBuf);
|
|
2637
|
+
escBuf = null;
|
|
2638
|
+
escTimer = null;
|
|
2639
|
+
}, ESC_HOLD_MS);
|
|
2584
2640
|
});
|
|
2585
2641
|
});
|
|
2586
2642
|
|