@dotdrelle/wiki-manager 0.15.93 → 0.15.94

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.
@@ -1103,6 +1103,64 @@ test('runtime runs are seeded with the chat that preceded them', async () => {
1103
1103
  assert.equal(clipped[0].content.length, 2000);
1104
1104
  });
1105
1105
 
1106
+ test('a compact boundary hides the prior exchanges from the seed but keeps them in the conversation', async () => {
1107
+ const { conversationSeed } = await import('./runner.js');
1108
+ const session = {
1109
+ agentProjection: {
1110
+ conversation: [
1111
+ { role: 'user', content: 'avant 1' },
1112
+ { role: 'assistant', content: 'avant 2' },
1113
+ { role: 'user', content: 'après 1' },
1114
+ { role: 'assistant', content: 'après 2' },
1115
+ ],
1116
+ conversationSeedStart: 2,
1117
+ },
1118
+ };
1119
+ const seed = conversationSeed(session, 'nouvelle question');
1120
+ assert.deepEqual(seed, [
1121
+ { role: 'user', content: 'après 1' },
1122
+ { role: 'assistant', content: 'après 2' },
1123
+ ]);
1124
+ // The pre-compact exchange is gone from the grounding…
1125
+ assert.equal(seed.some((message) => /avant/.test(message.content)), false);
1126
+ // …but it stays in the displayed conversation.
1127
+ assert.equal(session.agentProjection.conversation.length, 4);
1128
+ });
1129
+
1130
+ test('a stored compact summary is prepended to the seed, ahead of the recent exchanges', async () => {
1131
+ const { conversationSeed } = await import('./runner.js');
1132
+ const session = {
1133
+ agentProjection: {
1134
+ conversation: [
1135
+ { role: 'user', content: 'après 1' },
1136
+ { role: 'assistant', content: 'après 2' },
1137
+ ],
1138
+ conversationSeedStart: 0,
1139
+ conversationSummary: 'Résumé condensé des échanges précédents.',
1140
+ },
1141
+ };
1142
+ const seed = conversationSeed(session, 'nouvelle question');
1143
+ assert.equal(seed.length, 3);
1144
+ assert.equal(seed[0].role, 'user');
1145
+ assert.match(seed[0].content, /Résumé condensé des échanges précédents\./);
1146
+ assert.deepEqual(seed.slice(1), [
1147
+ { role: 'user', content: 'après 1' },
1148
+ { role: 'assistant', content: 'après 2' },
1149
+ ]);
1150
+ });
1151
+
1152
+ test('no stored summary means no synthetic entry is added to the seed', async () => {
1153
+ const { conversationSeed } = await import('./runner.js');
1154
+ const session = {
1155
+ agentProjection: {
1156
+ conversation: [{ role: 'user', content: 'salut' }],
1157
+ conversationSeedStart: 0,
1158
+ },
1159
+ };
1160
+ const seed = conversationSeed(session, 'autre question');
1161
+ assert.deepEqual(seed, [{ role: 'user', content: 'salut' }]);
1162
+ });
1163
+
1106
1164
  test('skipImpossibleTasks propage un échec jusqu’au point fixe', () => {
1107
1165
  /*
1108
1166
  Marquer les seules tâches directement bloquées laissait un résidu : A en
@@ -15,6 +15,29 @@ import { cancelControlChain, cancelQueuedControlItem } from './controlCancellati
15
15
  import { generateSkillAcknowledgment, runSkillChain } from './skillRun.js';
16
16
  import { emitRuntimeLog } from './supervisor.js';
17
17
  import { findSkill, listSkills } from '../core/skills.js';
18
+ import {
19
+ enrollment,
20
+ isLoopbackAddress,
21
+ isTotpEnabled,
22
+ issueSessionWithTotp,
23
+ loginAttemptAllowed,
24
+ loginStatus,
25
+ pruneLoginAttempts,
26
+ resetLoginAttempts,
27
+ revokeSession,
28
+ verifySessionToken,
29
+ } from './loginSession.js';
30
+ import { loginPageHtml } from './loginPage.js';
31
+
32
+ function loginErrorText(code) {
33
+ const messages = {
34
+ totp_disabled: 'TOTP login is disabled on this manager.',
35
+ no_enrollment: 'Enrollment expired. Reload this page.',
36
+ enrollment_requires_loopback: 'Enrollment must be done from the machine running the manager.',
37
+ invalid_code: 'Invalid verification code.',
38
+ };
39
+ return messages[code] ?? 'Verification failed.';
40
+ }
18
41
 
19
42
  const PRIVATE_CONTROL_INPUTS = new WeakMap();
20
43
 
@@ -75,12 +98,85 @@ export function startRuntimeServer({
75
98
 
76
99
  const server = createServer(async (request, response) => {
77
100
  try {
101
+ const url = new URL(request.url ?? '/', `http://${request.headers.host ?? 'localhost'}`);
102
+
103
+ // ── TOTP login surface — public by design: it is the door, not a room ──
104
+ if (url.pathname === '/login' && request.method === 'GET') {
105
+ const remoteAddress = request.socket?.remoteAddress ?? null;
106
+ const current = enrollment();
107
+ const status = loginStatus();
108
+ if (!status.enabled) {
109
+ sendHtml(response, 200, loginPageHtml({ error: 'TOTP login is disabled on this manager.' }));
110
+ return;
111
+ }
112
+ if (!status.enrolled && !isLoopbackAddress(remoteAddress)) {
113
+ sendHtml(response, 403, loginPageHtml({ error: 'Enrollment must be done from the machine running the manager.' }));
114
+ return;
115
+ }
116
+ sendHtml(response, 200, loginPageHtml({
117
+ enrolled: status.enrolled,
118
+ secret: current?.secret ?? null,
119
+ uri: current?.uri ?? null,
120
+ }));
121
+ return;
122
+ }
123
+ if (url.pathname === '/login/verify' && request.method === 'POST') {
124
+ const remoteAddress = request.socket?.remoteAddress ?? null;
125
+ const allowed = loginAttemptAllowed(remoteAddress);
126
+ if (!allowed.ok) {
127
+ sendJson(response, 429, { ok: false, error: `Too many attempts. Try again in ${allowed.retryAfterSeconds}s.` });
128
+ return;
129
+ }
130
+ const body = await readJson(request).catch(() => ({}));
131
+ const result = issueSessionWithTotp(body?.code, { remoteAddress });
132
+ if (!result.ok) {
133
+ sendJson(response, 401, { ok: false, error: loginErrorText(result.error) });
134
+ return;
135
+ }
136
+ resetLoginAttempts(remoteAddress);
137
+ // Hand the session to the browser as a cookie, on the runtime's own
138
+ // origin. Cookies ignore the port, so a browser that logged in here
139
+ // (the ShellUI gate) carries the same `wiki_session` to `serve` when
140
+ // it runs on this host — one TOTP login for both surfaces. serve sets
141
+ // its own cookie too when a browser reaches it first.
142
+ setSessionCookie(response, result.token, result.expiresAt, request);
143
+ sendJson(response, 200, {
144
+ ok: true,
145
+ token: result.token,
146
+ expiresAt: result.expiresAt,
147
+ page: loginPageHtml({ enrolled: true, sessionExpiresAt: result.expiresAt }),
148
+ });
149
+ return;
150
+ }
151
+ if (url.pathname === '/login/status' && request.method === 'GET') {
152
+ sendJson(response, 200, { ok: true, ...loginStatus() });
153
+ return;
154
+ }
155
+ if (url.pathname === '/logout' && request.method === 'POST') {
156
+ const body = await readJson(request).catch(() => ({}));
157
+ // revokeSession refuses without the session's own token — this route
158
+ // sits before the bearer gate by design, so the token itself is the
159
+ // only proof that the caller is the session being revoked, not an
160
+ // unauthenticated third party forcing the operator out.
161
+ const revoked = revokeSession(body?.token ?? null);
162
+ sendJson(response, 200, { ok: true, revoked });
163
+ return;
164
+ }
165
+ if (request.method === 'GET' && url.pathname === '/session/verify') {
166
+ // Public on purpose: the token IS the credential being checked — the
167
+ // caller already holds it, so verifying it leaks nothing beyond what
168
+ // possession implies. serve and the shell call this without bearer.
169
+ const sessionToken = String(url.searchParams.get('token') ?? '').trim();
170
+ const result = verifySessionToken(sessionToken);
171
+ sendJson(response, 200, { ok: result.ok, ...result });
172
+ return;
173
+ }
174
+
78
175
  if (!isAuthorized(request, token)) {
79
176
  sendJson(response, 401, { error: 'Unauthorized' });
80
177
  return;
81
178
  }
82
179
 
83
- const url = new URL(request.url ?? '/', `http://${request.headers.host ?? 'localhost'}`);
84
180
  if (request.method === 'GET' && url.pathname === '/health') {
85
181
  const workspace = workspaceFromUrl(url);
86
182
  const context = workspace ? await resolveContext({ workspace }) : null;
@@ -649,6 +745,51 @@ export function startRuntimeServer({
649
745
  sendJson(response, 200, { truncated: true, index, removedEvents });
650
746
  return;
651
747
  }
748
+ // Compact: a deliberate "forget everything said so far in this
749
+ // workspace" action (served chat's memory gauge). Unlike
750
+ // /conversation/truncate above, nothing is deleted from the event log —
751
+ // the audit trail (GET /audit) stays intact. A single conversation_reset
752
+ // event is enough: the reducer (core/agentEvents.js) moves the
753
+ // conversationSeedStart boundary on it, and since executeInteractiveTurn
754
+ // rebuilds its conversationSeed from a fresh reduceAgentEvents() replay
755
+ // on every turn, future turns stop seeing anything before this point
756
+ // while the displayed conversation (and the ShellUI thread) stays whole.
757
+ if (request.method === 'POST' && url.pathname === '/conversation/compact') {
758
+ const { workspace, context } = await resolveBodyContext(request, url);
759
+ if (context?.running) {
760
+ sendJson(response, 409, { compacted: false, reason: 'run_active' });
761
+ return;
762
+ }
763
+ const resolvedWorkspace = context?.workspace ?? workspace ?? null;
764
+ if (!resolvedWorkspace) {
765
+ sendJson(response, 400, { compacted: false, reason: 'workspace_required' });
766
+ return;
767
+ }
768
+ let summary = null;
769
+ if (context?.session) {
770
+ const conversation = Array.isArray(context.session.agentProjection?.conversation)
771
+ ? context.session.agentProjection.conversation
772
+ : [];
773
+ const seedStart = Math.max(0, Number(context.session.agentProjection?.conversationSeedStart) || 0);
774
+ const previousSummary = context.session.agentProjection?.conversationSummary ?? null;
775
+ // Summarize BEFORE dispatching: the event's payload carries the
776
+ // result so the reducer only ever has to store a plain string, and
777
+ // a run cannot start concurrently (already refused with 409 above)
778
+ // to move conversation.length out from under this read.
779
+ summary = await summarizeCompactedConversation(context.session, {
780
+ previousSummary,
781
+ segment: conversation.slice(seedStart),
782
+ });
783
+ dispatchAgentEvent(context.session, createAgentEvent('conversation_reset', {
784
+ origin: 'user',
785
+ workspace: resolvedWorkspace,
786
+ payload: summary ? { summary } : {},
787
+ }));
788
+ }
789
+ publishState(resolvedWorkspace, context);
790
+ sendJson(response, 200, { compacted: true, summary });
791
+ return;
792
+ }
652
793
  if (request.method === 'POST' && url.pathname === '/resume') {
653
794
  const workspace = workspaceFromUrl(url);
654
795
  const result = await resume?.({ workspace });
@@ -681,6 +822,12 @@ export function startRuntimeServer({
681
822
  }
682
823
  });
683
824
 
825
+ // Housekeeping for the in-memory login-attempt rate limiter: nothing else
826
+ // ever calls pruneLoginAttempts, so without this the `attempts` Map grows
827
+ // by one entry per distinct source address for the life of the process.
828
+ const loginAttemptPruneTimer = setInterval(() => pruneLoginAttempts(), 10 * 60 * 1000);
829
+ loginAttemptPruneTimer.unref?.();
830
+
684
831
  return new Promise((resolve, reject) => {
685
832
  server.once('error', reject);
686
833
  server.listen(port, host, () => {
@@ -692,6 +839,7 @@ export function startRuntimeServer({
692
839
  publish,
693
840
  drainControl: (context) => drainControlQueue(context),
694
841
  close: () => new Promise((closeResolve, closeReject) => {
842
+ clearInterval(loginAttemptPruneTimer);
695
843
  for (const client of clients) client.response.end();
696
844
  clients.clear();
697
845
  server.close((err) => (err ? closeReject(err) : closeResolve()));
@@ -1206,6 +1354,44 @@ async function handleControlMessage(context, store, input, { intent = null, star
1206
1354
  falls back to the deterministic English catalog when no LLM is configured or
1207
1355
  the call fails. The fallback is what keeps the lane deterministic-under-failure.
1208
1356
  */
1357
+ const CONVERSATION_SUMMARY_TIMEOUT_MS = 20_000;
1358
+ const CONVERSATION_SUMMARY_MAX_INPUT_CHARS = 8_000;
1359
+
1360
+ /*
1361
+ A compact does not just cut older turns from conversationSeed — it replaces
1362
+ them with a short rolling summary, so a decision made 20 messages ago is not
1363
+ gone from Donna's grounding entirely, only condensed. Best-effort: no LLM
1364
+ configured, an empty reply, or a call failure all fall back to keeping
1365
+ whatever summary already existed (never worse than before this compact),
1366
+ the same deterministic-under-failure shape as generateControlAcknowledgment.
1367
+ */
1368
+ async function summarizeCompactedConversation(session, { previousSummary, segment }) {
1369
+ const llm = session?.llm;
1370
+ const transcript = (Array.isArray(segment) ? segment : [])
1371
+ .filter((message) => ['user', 'assistant'].includes(message?.role) && String(message?.content ?? '').trim())
1372
+ .map((message) => `${message.role === 'user' ? 'User' : 'Assistant'}: ${String(message.content).trim()}`)
1373
+ .join('\n')
1374
+ .slice(0, CONVERSATION_SUMMARY_MAX_INPUT_CHARS);
1375
+ if (!transcript) return previousSummary || null;
1376
+ if (!(llm && typeof llm.complete === 'function')) return previousSummary || null;
1377
+ try {
1378
+ const reply = await llm.complete({
1379
+ system: 'You maintain a compact working memory for Donna, a workspace assistant. You are shown an optional PREVIOUS SUMMARY and a NEW SEGMENT of conversation about to leave the assistant\'s context window. Write ONE updated summary that preserves the facts, decisions, open questions and user preferences that still matter for future turns. Be concise: well under 200 words. Return only the summary text — no preamble, no meta-commentary, no headings.',
1380
+ input: [
1381
+ previousSummary ? `PREVIOUS SUMMARY:\n${previousSummary}` : null,
1382
+ `NEW SEGMENT:\n${transcript}`,
1383
+ ].filter(Boolean).join('\n\n'),
1384
+ signal: AbortSignal.timeout(CONVERSATION_SUMMARY_TIMEOUT_MS),
1385
+ });
1386
+ const text = String(reply ?? '').trim();
1387
+ if (text) return text;
1388
+ emitRuntimeLog(session, 'conversation-compact: LLM returned an empty summary, keeping the previous one');
1389
+ } catch (err) {
1390
+ emitRuntimeLog(session, `conversation-compact: summary LLM call failed, keeping the previous summary — ${err instanceof Error ? err.message : String(err)}`);
1391
+ }
1392
+ return previousSummary || null;
1393
+ }
1394
+
1209
1395
  async function generateControlAcknowledgment(session, { kind, input }) {
1210
1396
  const language = String(session?.language ?? '').trim().toLowerCase() || 'en';
1211
1397
  const llm = session?.llm;
@@ -1549,6 +1735,25 @@ function sendJson(response, statusCode, value) {
1549
1735
  response.end(`${JSON.stringify(value)}\n`);
1550
1736
  }
1551
1737
 
1738
+ function sendHtml(response, statusCode, html) {
1739
+ response.writeHead(statusCode, { 'Content-Type': 'text/html; charset=utf-8' });
1740
+ response.end(html);
1741
+ }
1742
+
1743
+ // Mirrors serve's cookie (same name, flags and lifetime) so one runtime-issued
1744
+ // session is also the one serve validates. Secure only when the request
1745
+ // already arrived over TLS — a localhost HTTP install must still get the
1746
+ // cookie, but a proxied HTTPS one must not leak it.
1747
+ function setSessionCookie(response, token, expiresAt, request) {
1748
+ const maxAgeSeconds = Math.max(1, Math.floor((Number(expiresAt) - Date.now()) / 1000));
1749
+ const tls = Boolean(request.socket?.encrypted) || request.headers['x-forwarded-proto'] === 'https';
1750
+ const secure = tls ? '; Secure' : '';
1751
+ response.setHeader(
1752
+ 'Set-Cookie',
1753
+ `wiki_session=${encodeURIComponent(token)}; Path=/; HttpOnly; SameSite=Lax; Max-Age=${maxAgeSeconds}${secure}`,
1754
+ );
1755
+ }
1756
+
1552
1757
  function readRequiredPatchId(body, response) {
1553
1758
  const patchId = String(body.patchId ?? body.id ?? '').trim();
1554
1759
  if (!patchId) {
@@ -0,0 +1,87 @@
1
+ import { createHmac, randomBytes, timingSafeEqual, createHash } from 'node:crypto';
2
+
3
+ /*
4
+ RFC 6238 TOTP with the defaults every authenticator app ships:
5
+ HMAC-SHA1, 6 digits, 30-second period. Self-contained — no dependency.
6
+
7
+ The secret is base32 (RFC 4648, no padding, uppercase), 20 bytes by default.
8
+ Verification accepts a ±1 window so a drifting clock or a code typed at the
9
+ end of its period is not a refusal, and compares DIGESTS (constant-time),
10
+ never the raw strings.
11
+ */
12
+
13
+ const BASE32_ALPHABET = 'ABCDEFGHIJKLMNOPQRSTUVWXYZ234567';
14
+
15
+ export function base32Encode(buffer) {
16
+ let bits = 0;
17
+ let value = 0;
18
+ let output = '';
19
+ for (const byte of buffer) {
20
+ value = (value << 8) | byte;
21
+ bits += 8;
22
+ while (bits >= 5) {
23
+ output += BASE32_ALPHABET[(value >>> (bits - 5)) & 31];
24
+ bits -= 5;
25
+ }
26
+ }
27
+ if (bits > 0) output += BASE32_ALPHABET[(value << (5 - bits)) & 31];
28
+ return output;
29
+ }
30
+
31
+ export function base32Decode(input) {
32
+ const clean = String(input ?? '').toUpperCase().replace(/[^A-Z2-7]/g, '');
33
+ let bits = 0;
34
+ let value = 0;
35
+ const output = [];
36
+ for (const char of clean) {
37
+ value = (value << 5) | BASE32_ALPHABET.indexOf(char);
38
+ bits += 5;
39
+ if (bits >= 8) {
40
+ output.push((value >>> (bits - 8)) & 0xff);
41
+ bits -= 8;
42
+ }
43
+ }
44
+ return Buffer.from(output);
45
+ }
46
+
47
+ export function generateTotpSecret(bytes = 20) {
48
+ return base32Encode(randomBytes(bytes));
49
+ }
50
+
51
+ export function totpCode(secret, timestamp = Date.now(), { digits = 6, period = 30 } = {}) {
52
+ const counter = Math.floor(Number(timestamp) / 1000 / period);
53
+ const message = Buffer.alloc(8);
54
+ message.writeBigUInt64BE(BigInt(counter));
55
+ const digest = createHmac('sha1', base32Decode(secret)).update(message).digest();
56
+ const offset = digest[digest.length - 1] & 0x0f;
57
+ const binary =
58
+ ((digest[offset] & 0x7f) << 24) |
59
+ ((digest[offset + 1] & 0xff) << 16) |
60
+ ((digest[offset + 2] & 0xff) << 8) |
61
+ (digest[offset + 3] & 0xff);
62
+ return String(binary % 10 ** digits).padStart(digits, '0');
63
+ }
64
+
65
+ export function digestEqual(left, right) {
66
+ const a = createHash('sha256').update(String(left ?? '')).digest();
67
+ const b = createHash('sha256').update(String(right ?? '')).digest();
68
+ return timingSafeEqual(a, b);
69
+ }
70
+
71
+ export function normalizeTotpCode(code) {
72
+ return String(code ?? '').replace(/\D/g, '').padStart(6, '0').slice(0, 6);
73
+ }
74
+
75
+ export function verifyTotp(secret, code, { window = 1, period = 30, timestamp = Date.now() } = {}) {
76
+ const wanted = normalizeTotpCode(code);
77
+ for (let step = -window; step <= window; step++) {
78
+ const candidate = totpCode(secret, timestamp + step * period * 1000, { period });
79
+ if (digestEqual(candidate, wanted)) return true;
80
+ }
81
+ return false;
82
+ }
83
+
84
+ export function otpauthUri(secret, { label = 'wiki', issuer = 'wikiLLM' } = {}) {
85
+ const params = new URLSearchParams({ secret, issuer, digits: '6', period: '30', algorithm: 'SHA1' });
86
+ return `otpauth://totp/${encodeURIComponent(`${issuer}:${label}`)}?${params.toString()}`;
87
+ }
@@ -0,0 +1,80 @@
1
+ import test from 'node:test';
2
+ import assert from 'node:assert/strict';
3
+ import {
4
+ base32Decode,
5
+ base32Encode,
6
+ generateTotpSecret,
7
+ normalizeTotpCode,
8
+ otpauthUri,
9
+ totpCode,
10
+ verifyTotp,
11
+ } from './totp.js';
12
+
13
+ test('base32 round-trips arbitrary bytes', () => {
14
+ const bytes = Buffer.from([0, 1, 2, 0x7f, 0x80, 0xff, 0x41, 0x42]);
15
+ assert.equal(base32Encode(bytes), 'AAAQE74A75AUE');
16
+ assert.deepEqual(base32Decode(base32Encode(bytes)), bytes);
17
+ // Decoding tolerates lowercase and padding noise.
18
+ assert.deepEqual(base32Decode('aaaQE74a75aue==='), bytes);
19
+ });
20
+
21
+ test('generateTotpSecret returns a 32-char base32 secret of 20 bytes', () => {
22
+ const secret = generateTotpSecret();
23
+ assert.match(secret, /^[A-Z2-7]{32}$/);
24
+ assert.equal(base32Decode(secret).length, 20);
25
+ });
26
+
27
+ // RFC 6238 test vectors (Appendix B), SHA-1, 20-byte ASCII secret
28
+ // "12345678901234567890" — encoded in base32 as the well-known
29
+ // GEZDGNBVGY3TQOJQ GEZDGNBVGY3TQOJQ.
30
+ const RFC_SECRET = 'GEZDGNBVGY3TQOJQGEZDGNBVGY3TQOJQ';
31
+
32
+ test('totpCode matches the RFC 6238 vectors (8 digits, then 6)', () => {
33
+ const vectors = [
34
+ [59, '94287082'],
35
+ [1111111109, '07081804'],
36
+ [1111111111, '14050471'],
37
+ [1234567890, '89005924'],
38
+ [2000000000, '69279037'],
39
+ [20000000000, '65353130'],
40
+ ];
41
+ for (const [time, expected8] of vectors) {
42
+ assert.equal(totpCode(RFC_SECRET, time * 1000, { digits: 8 }), expected8, `T=${time}`);
43
+ assert.equal(totpCode(RFC_SECRET, time * 1000), expected8.slice(2), `T=${time} 6 digits`);
44
+ }
45
+ });
46
+
47
+ test('verifyTotp accepts the current step and the ±1 window', () => {
48
+ const secret = generateTotpSecret();
49
+ const now = 1_700_000_000_000;
50
+ const code = totpCode(secret, now);
51
+ assert.equal(verifyTotp(secret, code, { timestamp: now }), true);
52
+ assert.equal(verifyTotp(secret, code, { timestamp: now + 30_000 }), true);
53
+ assert.equal(verifyTotp(secret, code, { timestamp: now - 30_000 }), true);
54
+ assert.equal(verifyTotp(secret, code, { timestamp: now + 60_000 }), false);
55
+ assert.equal(verifyTotp(secret, code, { timestamp: now - 60_000 }), false);
56
+ });
57
+
58
+ test('verifyTotp normalizes spacing and a wrong code is refused', () => {
59
+ const secret = generateTotpSecret();
60
+ const now = 1_700_000_000_000;
61
+ const code = totpCode(secret, now);
62
+ assert.equal(verifyTotp(secret, ` ${code.slice(0, 3)} ${code.slice(3)} `, { timestamp: now }), true);
63
+ assert.equal(verifyTotp(secret, '000000', { timestamp: now }), false);
64
+ });
65
+
66
+ test('normalizeTotpCode keeps six digits', () => {
67
+ assert.equal(normalizeTotpCode(' 123 456 '), '123456');
68
+ assert.equal(normalizeTotpCode('42'), '000042');
69
+ assert.equal(normalizeTotpCode('1234567'), '123456');
70
+ });
71
+
72
+ test('otpauthUri carries the standard parameters', () => {
73
+ const uri = otpauthUri('ABCDEF', { label: 'demo', issuer: 'wikiLLM' });
74
+ assert.ok(uri.startsWith('otpauth://totp/wikiLLM%3Ademo?'));
75
+ assert.ok(uri.includes('secret=ABCDEF'));
76
+ assert.ok(uri.includes('issuer=wikiLLM'));
77
+ assert.ok(uri.includes('digits=6'));
78
+ assert.ok(uri.includes('period=30'));
79
+ assert.ok(uri.includes('algorithm=SHA1'));
80
+ });
@@ -0,0 +1,123 @@
1
+ import { spawn } from 'node:child_process';
2
+ import { setTimeout as sleep } from 'node:timers/promises';
3
+
4
+ /*
5
+ The interactive entry gate: `wiki-manager login` opens the runtime's TOTP
6
+ login page in the browser and polls until a session exists. The shell startup
7
+ runs the same flow when a session is required and missing.
8
+
9
+ Headless/CI never passes through here — it uses the runtime bearer token, not
10
+ the human session.
11
+ */
12
+
13
+ export function runtimeBaseUrl(runtime) {
14
+ const base = String(runtime?.url ?? process.env.WIKI_MANAGER_RUNTIME_URL ?? 'http://127.0.0.1:7788');
15
+ return base.replace(/\/+$/, '');
16
+ }
17
+
18
+ export function loginPageUrl(runtime) {
19
+ return `${runtimeBaseUrl(runtime)}/login`;
20
+ }
21
+
22
+ // Returns a promise that reflects the real outcome: spawn() itself does not
23
+ // throw for a missing opener (e.g. no xdg-open on a headless box) — that
24
+ // surfaces asynchronously as an 'error' event — so callers that want to fall
25
+ // back on failure need this to actually reject rather than resolve blindly.
26
+ export function openBrowser(url) {
27
+ const target = String(url);
28
+ const options = { detached: true, stdio: 'ignore' };
29
+ return new Promise((resolveOpen, rejectOpen) => {
30
+ let child = null;
31
+ if (process.platform === 'darwin') {
32
+ child = spawn('open', [target], options);
33
+ } else if (process.platform === 'win32') {
34
+ child = spawn('cmd', ['/c', 'start', '', target], options);
35
+ } else {
36
+ child = spawn('xdg-open', [target], options);
37
+ }
38
+ child.once('error', rejectOpen);
39
+ child.once('spawn', () => {
40
+ child.unref?.();
41
+ resolveOpen();
42
+ });
43
+ });
44
+ }
45
+
46
+ export async function fetchLoginStatus(runtime) {
47
+ try {
48
+ const response = await fetch(`${runtimeBaseUrl(runtime)}/login/status`, {
49
+ signal: AbortSignal.timeout(3000),
50
+ });
51
+ if (!response.ok) return null;
52
+ return await response.json();
53
+ } catch {
54
+ return null;
55
+ }
56
+ }
57
+
58
+ // /logout requires the session's own token as proof of possession (it sits
59
+ // before the bearer gate by design). The CLI runs on the same host as the
60
+ // runtime, so it reads the active token itself — see currentSessionToken in
61
+ // loginSession.js — rather than calling this without proof.
62
+ export async function revokeRuntimeSession(runtime, token = null) {
63
+ try {
64
+ const response = await fetch(`${runtimeBaseUrl(runtime)}/logout`, {
65
+ method: 'POST',
66
+ headers: { 'Content-Type': 'application/json' },
67
+ body: JSON.stringify({ token }),
68
+ signal: AbortSignal.timeout(3000),
69
+ });
70
+ if (!response.ok) return false;
71
+ const payload = await response.json().catch(() => ({}));
72
+ return payload?.revoked === true;
73
+ } catch {
74
+ return false;
75
+ }
76
+ }
77
+
78
+ /*
79
+ Requires a valid TOTP session on the runtime, opening the login page and
80
+ polling when one is missing. Returns:
81
+ { ok: true } — session active (or nothing to do: disabled /
82
+ runtime unreachable, which the caller announces).
83
+ { ok: false, error } — the user did not log in within the timeout.
84
+ */
85
+ export async function requestTotpSession(runtime, { timeoutMs = 5 * 60 * 1000, open = true, quiet = false } = {}) {
86
+ const status = await fetchLoginStatus(runtime);
87
+ // Every caller reaches this only after ensureRuntime() already confirmed
88
+ // the runtime is up via /health, so a /login/status failure right here is
89
+ // a real problem (route error, blip), not "nothing to gate" — fail CLOSED,
90
+ // matching the documented contract for the same scenario in serve, and say
91
+ // so instead of silently letting the shell start with no TOTP check at all.
92
+ if (!status) {
93
+ return { ok: false, error: `Could not reach the runtime's login status (${runtimeBaseUrl(runtime)}/login/status) — refusing to skip the TOTP check.` };
94
+ }
95
+ if (!status.enabled) return { ok: true, reason: 'totp_disabled' };
96
+ if (status.sessionActive) return { ok: true, expiresAt: status.sessionExpiresAt, reason: 'already_active' };
97
+
98
+ const url = loginPageUrl(runtime);
99
+ if (!quiet) console.log(`\x1b[33mTOTP login required — ${url}\x1b[0m`);
100
+ if (open) {
101
+ try {
102
+ await openBrowser(url);
103
+ } catch {
104
+ if (!quiet) console.log(`Open ${url} and enter the code from your authenticator.`);
105
+ }
106
+ } else if (!quiet) {
107
+ console.log(`Open ${url} in your browser and enter the code from your authenticator.`);
108
+ }
109
+
110
+ const deadline = Date.now() + timeoutMs;
111
+ while (Date.now() < deadline) {
112
+ const current = await fetchLoginStatus(runtime);
113
+ if (current?.sessionActive) {
114
+ if (!quiet) {
115
+ const until = new Date(current.sessionExpiresAt).toLocaleString();
116
+ console.log(`\x1b[32mSession active until ${until}\x1b[0m`);
117
+ }
118
+ return { ok: true, expiresAt: current.sessionExpiresAt, reason: 'logged_in' };
119
+ }
120
+ await sleep(1000);
121
+ }
122
+ return { ok: false, error: `No TOTP session after ${Math.round(timeoutMs / 60_000)} min — run "wiki-manager login" to retry.` };
123
+ }