@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.
- package/docker-compose.yml +1 -1
- package/package.json +2 -2
- package/src/agent/graph.js +42 -8
- package/src/agent/graph.test.js +24 -1
- package/src/cli/wiki-manager.js +103 -1
- package/src/commands/slash.js +38 -4
- package/src/commands/slash.test.js +11 -1
- package/src/core/agentEvents.js +23 -0
- package/src/core/agentEvents.test.js +37 -0
- package/src/core/buildInfo.json +2 -2
- package/src/core/dockerCompose.test.js +14 -0
- package/src/core/mcp.js +1 -1
- package/src/core/skillCompiler.test.js +21 -1
- package/src/orchestrator/objectiveResolver.test.js +27 -0
- package/src/runtime/loginPage.js +132 -0
- package/src/runtime/loginRoutes.test.js +131 -0
- package/src/runtime/loginSession.js +226 -0
- package/src/runtime/loginSession.test.js +143 -0
- package/src/runtime/qrCode.js +15 -0
- package/src/runtime/runner.js +12 -0
- package/src/runtime/runner.test.js +58 -0
- package/src/runtime/server.js +206 -1
- package/src/runtime/totp.js +87 -0
- package/src/runtime/totp.test.js +80 -0
- package/src/runtime/totpLogin.js +123 -0
- package/src/runtime/vendor/qrcode.cjs +2297 -0
|
@@ -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
|
package/src/runtime/server.js
CHANGED
|
@@ -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
|
+
}
|