@dotdrelle/wiki-manager 0.15.93 → 0.15.96

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.
Files changed (48) hide show
  1. package/README.md +39 -28
  2. package/docker-compose.yml +1 -1
  3. package/package.json +2 -2
  4. package/src/agent/graph.js +78 -26
  5. package/src/agent/graph.test.js +28 -2
  6. package/src/cli/wiki-manager.js +138 -18
  7. package/src/commands/slash.js +38 -4
  8. package/src/commands/slash.test.js +11 -1
  9. package/src/core/agentEvents.js +24 -3
  10. package/src/core/agentEvents.test.js +37 -0
  11. package/src/core/buildInfo.json +2 -2
  12. package/src/core/dockerCompose.test.js +14 -0
  13. package/src/core/googleGrants.js +0 -3
  14. package/src/core/json.js +9 -0
  15. package/src/core/mcp.js +1 -1
  16. package/src/core/plan.js +0 -4
  17. package/src/core/progressNotes.js +0 -4
  18. package/src/core/skillChainView.js +3 -1
  19. package/src/core/skillCompiler.test.js +21 -1
  20. package/src/core/toolLoop.js +56 -2
  21. package/src/core/toolLoop.test.js +35 -4
  22. package/src/orchestrator/agentRegistry.js +1 -3
  23. package/src/orchestrator/dependencyResolver.js +0 -3
  24. package/src/orchestrator/objectiveResolver.test.js +27 -0
  25. package/src/orchestrator/planValidator.js +1 -3
  26. package/src/orchestrator/providers/runtimeProvider.js +0 -14
  27. package/src/orchestrator/taskStatuses.js +8 -0
  28. package/src/runtime/client.js +0 -16
  29. package/src/runtime/controlDrain.js +6 -3
  30. package/src/runtime/deltaCoalescer.js +53 -0
  31. package/src/runtime/deltaCoalescer.test.js +56 -0
  32. package/src/runtime/loginPage.js +129 -0
  33. package/src/runtime/loginRoutes.test.js +131 -0
  34. package/src/runtime/loginSession.js +223 -0
  35. package/src/runtime/loginSession.test.js +143 -0
  36. package/src/runtime/qrCode.js +15 -0
  37. package/src/runtime/runner.js +29 -3
  38. package/src/runtime/runner.test.js +90 -1
  39. package/src/runtime/server.js +240 -1
  40. package/src/runtime/server.test.js +87 -0
  41. package/src/runtime/skillRun.js +1 -1
  42. package/src/runtime/skillRun.test.js +3 -0
  43. package/src/runtime/totp.js +87 -0
  44. package/src/runtime/totp.test.js +80 -0
  45. package/src/runtime/totpLogin.js +123 -0
  46. package/src/runtime/vendor/qrcode.cjs +2297 -0
  47. package/src/shell/repl.js +7 -3
  48. package/src/orchestrator/.fuse_hidden0000001c00000001 +0 -316
@@ -1733,6 +1733,93 @@ test('POST /turn keeps informational skill and build questions conversational',
1733
1733
  }
1734
1734
  });
1735
1735
 
1736
+ test('POST /turn answers a run status question from the runtime instead of the model', async (t) => {
1737
+ const session = { workspace: 'acme', controlQueue: [] };
1738
+ const context = { workspace: 'acme', session, running: true, currentAbortController: null };
1739
+ const status = {
1740
+ status: 'running',
1741
+ running: true,
1742
+ plan: [{ step: 1, description: 'Build TechSections', status: 'running' }],
1743
+ queue: [],
1744
+ controlQueue: [],
1745
+ approvals: [],
1746
+ conversation: [],
1747
+ };
1748
+ let turns = 0;
1749
+ let handle;
1750
+ try {
1751
+ handle = await startRuntimeServer({
1752
+ host: '127.0.0.1', port: 0,
1753
+ store: { dbPath: ':memory:', getState: () => status, listEvents: () => [] },
1754
+ getContext: async () => context,
1755
+ run: async () => new Promise(() => {}),
1756
+ turn: async () => { turns += 1; return { ok: true }; },
1757
+ });
1758
+ } catch (err) {
1759
+ if (err?.code === 'EPERM') { t.skip('network listen is not permitted in this sandbox'); return; }
1760
+ throw err;
1761
+ }
1762
+ try {
1763
+ // The model once mistook the runtime runId for a production job id and
1764
+ // answered "job not found". The runtime answers its own status.
1765
+ const response = await fetch(`http://127.0.0.1:${handle.port}/turn?workspace=acme`, {
1766
+ method: 'POST', headers: { 'content-type': 'application/json' },
1767
+ body: JSON.stringify({ input: 'donne le status du job en cours', mode: 'agent' }),
1768
+ });
1769
+ const body = await response.json();
1770
+ assert.equal(response.status, 200);
1771
+ assert.equal(body.kind, 'observe');
1772
+ assert.match(body.explanation, /Build TechSections/);
1773
+ assert.equal(turns, 0);
1774
+ } finally {
1775
+ context.currentAbortController?.abort();
1776
+ await handle.close();
1777
+ }
1778
+ });
1779
+
1780
+ test('POST /turn treats a bare confirmation during a run as a status check', async (t) => {
1781
+ const session = { workspace: 'acme', controlQueue: [] };
1782
+ const context = { workspace: 'acme', session, running: true, currentAbortController: null };
1783
+ const status = {
1784
+ status: 'running',
1785
+ running: true,
1786
+ plan: [{ step: 1, description: 'Rebuild the wiki', status: 'running' }],
1787
+ queue: [],
1788
+ controlQueue: [],
1789
+ approvals: [],
1790
+ conversation: [],
1791
+ };
1792
+ let turns = 0;
1793
+ let handle;
1794
+ try {
1795
+ handle = await startRuntimeServer({
1796
+ host: '127.0.0.1', port: 0,
1797
+ store: { dbPath: ':memory:', getState: () => status, listEvents: () => [] },
1798
+ getContext: async () => context,
1799
+ run: async () => new Promise(() => {}),
1800
+ turn: async () => { turns += 1; return { ok: true }; },
1801
+ });
1802
+ } catch (err) {
1803
+ if (err?.code === 'EPERM') { t.skip('network listen is not permitted in this sandbox'); return; }
1804
+ throw err;
1805
+ }
1806
+ try {
1807
+ // "oui" answers the launch acknowledgement. It must reach the runtime's
1808
+ // status, not a read-only chat turn that lectures about switching modes.
1809
+ const response = await fetch(`http://127.0.0.1:${handle.port}/turn?workspace=acme`, {
1810
+ method: 'POST', headers: { 'content-type': 'application/json' },
1811
+ body: JSON.stringify({ input: 'oui', mode: 'agent' }),
1812
+ });
1813
+ const body = await response.json();
1814
+ assert.equal(response.status, 200);
1815
+ assert.equal(body.kind, 'observe');
1816
+ assert.equal(turns, 0);
1817
+ } finally {
1818
+ context.currentAbortController?.abort();
1819
+ await handle.close();
1820
+ }
1821
+ });
1822
+
1736
1823
  test('POST /run accepts named skill arguments and deduplicates an explicit retry key', async (t) => {
1737
1824
  const root = mkdtempSync(join(tmpdir(), 'runtime-named-skill-'));
1738
1825
  mkdirSync(join(root, '.wiki', 'skills'), { recursive: true });
@@ -165,7 +165,7 @@ export async function generateSkillAcknowledgment(session, { publicInput, object
165
165
  // slow provider must not block the skill-launch HTTP response forever.
166
166
  const reply = await llm.complete({
167
167
  system: 'You are Donna, the workspace assistant. You acknowledge a launched workflow in the user\'s language. Be concise: exactly one short sentence.',
168
- input: `The user just launched the workspace skill ${publicInput}. It was compiled into ${count} step(s) and is now running.\n\nWrite ONE short sentence in ${language} that confirms the launch, echoes the skill and its arguments, and says progress will be reported. Return only that sentence, nothing else.`,
168
+ input: `The user just launched the workspace skill ${publicInput}. It was compiled into ${count} step(s) and is now running.\n\nWrite ONE short sentence in ${language} that confirms the launch, echoes the skill and its arguments, and says progress will be reported. Do not ask a question, do not propose options, and do not offer to check, monitor or cancel anything: the runtime reports progress on its own and this acknowledgement is not a decision point. Return only that sentence, nothing else.`,
169
169
  signal: AbortSignal.timeout(8_000),
170
170
  });
171
171
  const text = String(reply ?? '').trim();
@@ -94,6 +94,9 @@ test('generateSkillAcknowledgment asks Donna in the session language and echoes
94
94
  assert.equal(calls.length, 1);
95
95
  assert.match(calls[0].input, /es/);
96
96
  assert.match(calls[0].input, /\/deliver deliverable="Informe"/);
97
+ // The acknowledgement is not a decision point: it must not invite the user
98
+ // into a dialog the runtime cannot act on.
99
+ assert.match(calls[0].input, /Do not ask a question/);
97
100
  });
98
101
 
99
102
  test('generateSkillAcknowledgment degrades to a neutral message without an LLM client', async () => {
@@ -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
+ }