limbo-ai 1.21.0 → 1.22.0

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 CHANGED
@@ -52,12 +52,12 @@ Headless mode skips Telegram setup. To add Telegram later, run `npx limbo-ai sta
52
52
  ### Available commands
53
53
 
54
54
  ```sh
55
- npx limbo-ai start # Install and start (default if no command given)
56
- npx limbo-ai stop # Stop the container
57
- npx limbo-ai update # Pull latest image and restart
58
- npx limbo-ai status # Show container status
59
- npx limbo-ai logs # Tail container logs
60
- npx limbo-ai start --reconfigure # Change API keys or settings
55
+ npx limbo-ai@latest start # Install and start (default if no command given)
56
+ npx limbo-ai@latest stop # Stop the container
57
+ npx limbo-ai@latest update # Pull latest image and restart
58
+ npx limbo-ai@latest status # Show container status
59
+ npx limbo-ai@latest logs # Tail container logs
60
+ npx limbo-ai@latest start --reconfigure # Change API keys or settings
61
61
  ```
62
62
 
63
63
  ---
@@ -65,7 +65,7 @@ npx limbo-ai start --reconfigure # Change API keys or settings
65
65
  ## Updating
66
66
 
67
67
  ```sh
68
- npx limbo-ai update
68
+ npx limbo-ai@latest update
69
69
  ```
70
70
 
71
71
  Pulls the latest Limbo image and restarts the container. Your vault data is persisted in the `limbo-data` Docker volume and is not affected.
package/cli.js CHANGED
@@ -6,6 +6,7 @@
6
6
  const { execSync, spawn, spawnSync } = require('child_process');
7
7
  const crypto = require('crypto');
8
8
  const fs = require('fs');
9
+ const https = require('https');
9
10
  const os = require('os');
10
11
  const path = require('path');
11
12
  const readline = require('readline');
@@ -1828,27 +1829,118 @@ ${c.bold}Data directory:${c.reset} ${LIMBO_DIR}
1828
1829
  `);
1829
1830
  }
1830
1831
 
1832
+ // ─── Update Notifier ─────────────────────────────────────────────────────────
1833
+
1834
+ const UPDATE_CHECK_FILE = path.join(LIMBO_DIR, '.update-check');
1835
+ const UPDATE_CHECK_INTERVAL = 24 * 60 * 60 * 1000; // 24 hours
1836
+
1837
+ // Spawn a detached background process to check the npm registry.
1838
+ // Writes {latest, checkedAt} to UPDATE_CHECK_FILE and exits.
1839
+ function checkForUpdateInBackground() {
1840
+ try {
1841
+ let shouldCheck = true;
1842
+ if (fs.existsSync(UPDATE_CHECK_FILE)) {
1843
+ const cached = JSON.parse(fs.readFileSync(UPDATE_CHECK_FILE, 'utf8'));
1844
+ if (Date.now() - cached.checkedAt < UPDATE_CHECK_INTERVAL) shouldCheck = false;
1845
+ }
1846
+ if (!shouldCheck) return;
1847
+
1848
+ // Spawn detached child that hits the registry and writes cache
1849
+ const child = spawn(process.execPath, ['-e', `
1850
+ const https = require('https');
1851
+ const fs = require('fs');
1852
+ const req = https.get('https://registry.npmjs.org/limbo-ai/latest', { timeout: 5000 }, (res) => {
1853
+ let data = '';
1854
+ res.on('data', (chunk) => data += chunk);
1855
+ res.on('end', () => {
1856
+ try {
1857
+ const { version } = JSON.parse(data);
1858
+ fs.mkdirSync('${LIMBO_DIR.replace(/\\/g, '\\\\')}', { recursive: true });
1859
+ fs.writeFileSync('${UPDATE_CHECK_FILE.replace(/\\/g, '\\\\')}', JSON.stringify({ latest: version, checkedAt: Date.now() }));
1860
+ } catch {}
1861
+ });
1862
+ });
1863
+ req.on('error', () => {});
1864
+ req.end();
1865
+ `], { detached: true, stdio: 'ignore' });
1866
+ child.unref();
1867
+ } catch {}
1868
+ }
1869
+
1870
+ // Read cache and print banner if a newer version is available.
1871
+ function notifyUpdate() {
1872
+ try {
1873
+ if (!fs.existsSync(UPDATE_CHECK_FILE)) return;
1874
+ const { latest } = JSON.parse(fs.readFileSync(UPDATE_CHECK_FILE, 'utf8'));
1875
+ const pkg = require('./package.json');
1876
+ if (!latest || latest === pkg.version) return;
1877
+
1878
+ // Simple semver compare: split on dots, compare numerically
1879
+ const cur = pkg.version.split('.').map(Number);
1880
+ const lat = latest.split('.').map(Number);
1881
+ const isNewer = lat[0] > cur[0] || (lat[0] === cur[0] && lat[1] > cur[1]) ||
1882
+ (lat[0] === cur[0] && lat[1] === cur[1] && lat[2] > cur[2]);
1883
+ if (!isNewer) return;
1884
+
1885
+ // Strip ANSI escapes for visible-length padding
1886
+ const strip = (s) => s.replace(/\x1b\[[0-9;]*m/g, '');
1887
+ const pad = (s, w) => s + ' '.repeat(Math.max(0, w - strip(s).length));
1888
+
1889
+ const line = ` Update available: ${c.dim}${pkg.version}${c.reset} → ${c.green}${latest}${c.reset} `;
1890
+ const instruction = ` Run ${c.cyan}npx limbo-ai@latest update${c.reset} to update `;
1891
+ const inner = Math.max(strip(line).length, strip(instruction).length);
1892
+ const border = '─'.repeat(inner);
1893
+ console.error(`\n ${c.dim}╭${border}╮${c.reset}`);
1894
+ console.error(` ${c.dim}│${c.reset}${pad(line, inner)}${c.dim}│${c.reset}`);
1895
+ console.error(` ${c.dim}│${c.reset}${pad(instruction, inner)}${c.dim}│${c.reset}`);
1896
+ console.error(` ${c.dim}╰${border}╯${c.reset}\n`);
1897
+ } catch {}
1898
+ }
1899
+
1900
+ // ─── Exports (for testing) ────────────────────────────────────────────────────
1901
+
1902
+ module.exports = {
1903
+ MODEL_CATALOG,
1904
+ normalizeConfig,
1905
+ parseEnvFile,
1906
+ deriveProviderFamily,
1907
+ getModelCatalog,
1908
+ parseCallbackInput,
1909
+ decodeJwtPayload,
1910
+ parseClaudeSetupToken,
1911
+ buildCodexAuthProfile,
1912
+ buildAnthropicAuthProfile,
1913
+ generatePKCE,
1914
+ buildOAuthUrl,
1915
+ };
1916
+
1831
1917
  // ─── Main ────────────────────────────────────────────────────────────────────
1832
1918
 
1833
- const [,, cmd = 'start'] = process.argv;
1834
-
1835
- (async () => {
1836
- switch (cmd) {
1837
- case 'start':
1838
- case 'install': await cmdStart(); break;
1839
- case 'stop': cmdStop(); break;
1840
- case 'logs': cmdLogs(); break;
1841
- case 'update': cmdUpdate(); break;
1842
- case 'status': cmdStatus(); break;
1843
- case 'config': cmdConfig(); break;
1844
- case 'help':
1845
- case '--help':
1846
- case '-h': cmdHelp(); break;
1847
- default:
1848
- warn(t('en', 'unknownCommand', cmd));
1849
- cmdHelp();
1850
- process.exit(1);
1851
- }
1852
- })().catch((err) => {
1853
- die(err.message || String(err));
1854
- });
1919
+ if (require.main === module) {
1920
+ const [,, cmd = 'start'] = process.argv;
1921
+
1922
+ (async () => {
1923
+ checkForUpdateInBackground();
1924
+
1925
+ switch (cmd) {
1926
+ case 'start':
1927
+ case 'install': await cmdStart(); break;
1928
+ case 'stop': cmdStop(); break;
1929
+ case 'logs': cmdLogs(); break;
1930
+ case 'update': cmdUpdate(); break;
1931
+ case 'status': cmdStatus(); break;
1932
+ case 'config': cmdConfig(); break;
1933
+ case 'help':
1934
+ case '--help':
1935
+ case '-h': cmdHelp(); break;
1936
+ default:
1937
+ warn(t('en', 'unknownCommand', cmd));
1938
+ cmdHelp();
1939
+ process.exit(1);
1940
+ }
1941
+
1942
+ notifyUpdate();
1943
+ })().catch((err) => {
1944
+ die(err.message || String(err));
1945
+ });
1946
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "limbo-ai",
3
- "version": "1.21.0",
3
+ "version": "1.22.0",
4
4
  "description": "Your personal AI memory agent — install and manage Limbo via npx",
5
5
  "type": "commonjs",
6
6
  "bin": {
@@ -14,7 +14,7 @@
14
14
  },
15
15
  "scripts": {
16
16
  "start": "node cli.js start",
17
- "test": "node --test test/cli-filter.test.js test/zeroclaw-migration.test.js"
17
+ "test": "node --test test/cli-filter.test.js test/cli-auth.test.js test/zeroclaw-migration.test.js test/setup-server.test.js"
18
18
  },
19
19
  "keywords": [
20
20
  "limbo",
@@ -154,7 +154,10 @@ function ensureSetupToken() {
154
154
  return token;
155
155
  }
156
156
 
157
- const SETUP_TOKEN = ensureSetupToken();
157
+ let SETUP_TOKEN = null;
158
+ if (require.main === module) {
159
+ SETUP_TOKEN = ensureSetupToken();
160
+ }
158
161
 
159
162
  function checkToken(req) {
160
163
  const parsed = new URL(req.url, `http://${req.headers.host}`);
@@ -851,17 +854,46 @@ async function handleRequest(req, res) {
851
854
  }
852
855
  }
853
856
 
857
+ // ─── Exports (for testing) ────────────────────────────────────────────────────
858
+
859
+ module.exports = {
860
+ MODEL_CATALOG,
861
+ KEY_PREFIXES,
862
+ MIME_TYPES,
863
+ parseJSON,
864
+ generatePKCE,
865
+ buildOAuthUrl,
866
+ decodeJwtPayload,
867
+ buildCodexAuthProfile,
868
+ buildAnthropicAuthProfile,
869
+ handleRequest,
870
+ _internals: {
871
+ OPENAI_OAUTH,
872
+ readBody,
873
+ sendJSON,
874
+ sendError,
875
+ checkToken,
876
+ writeSecretFile,
877
+ readSecretFile,
878
+ ensureGatewayToken,
879
+ ensureSetupToken,
880
+ writeAuthProfiles,
881
+ },
882
+ };
883
+
854
884
  // ─── Server ──────────────────────────────────────────────────────────────────
855
885
 
856
- const server = http.createServer(handleRequest);
886
+ if (require.main === module) {
887
+ const server = http.createServer(handleRequest);
857
888
 
858
- server.listen(PORT, '0.0.0.0', () => {
859
- log(`Limbo Setup Wizard listening on port ${PORT}`);
860
- log(`SETUP_URL=http://0.0.0.0:${PORT}/?token=${SETUP_TOKEN}`);
861
- log('Share the URL above with the user to complete setup.');
862
- });
889
+ server.listen(PORT, '0.0.0.0', () => {
890
+ log(`Limbo Setup Wizard listening on port ${PORT}`);
891
+ log(`SETUP_URL=http://0.0.0.0:${PORT}/?token=${SETUP_TOKEN}`);
892
+ log('Share the URL above with the user to complete setup.');
893
+ });
863
894
 
864
- server.on('error', (err) => {
865
- log(`Server error: ${err.message}`);
866
- process.exit(1);
867
- });
895
+ server.on('error', (err) => {
896
+ log(`Server error: ${err.message}`);
897
+ process.exit(1);
898
+ });
899
+ }
@@ -1,250 +1,356 @@
1
1
  // test/cli-auth.test.js
2
- // Unit tests for the streamFilteredAuth pure-logic functions exported from cli.js.
2
+ // Unit tests for CLI install-phase pure functions exported from cli.js.
3
3
  // Run with: node --test test/cli-auth.test.js
4
4
  'use strict';
5
5
 
6
6
  const { test } = require('node:test');
7
7
  const assert = require('node:assert/strict');
8
+ const crypto = require('node:crypto');
9
+
10
+ const {
11
+ MODEL_CATALOG,
12
+ normalizeConfig,
13
+ deriveProviderFamily,
14
+ getModelCatalog,
15
+ parseCallbackInput,
16
+ decodeJwtPayload,
17
+ parseClaudeSetupToken,
18
+ buildCodexAuthProfile,
19
+ buildAnthropicAuthProfile,
20
+ generatePKCE,
21
+ buildOAuthUrl,
22
+ } = require('../cli.js');
23
+
24
+ // ─── deriveProviderFamily ─────────────────────────────────────────────────────
25
+
26
+ test('deriveProviderFamily: null/undefined returns anthropic', () => {
27
+ assert.equal(deriveProviderFamily(null), 'anthropic');
28
+ assert.equal(deriveProviderFamily(undefined), 'anthropic');
29
+ });
8
30
 
9
- const { stripAnsi, AUTH_URL_RE, TUI_CHROME_RE, flushStreamLines } = require('../cli.js');
10
-
11
- // ─── stripAnsi ────────────────────────────────────────────────────────────────
31
+ test('deriveProviderFamily: openai-codex returns openai', () => {
32
+ assert.equal(deriveProviderFamily('openai-codex'), 'openai');
33
+ });
12
34
 
13
- test('stripAnsi: strips standard CSI sequences', () => {
14
- assert.equal(stripAnsi('\x1b[32mgreen\x1b[0m'), 'green');
15
- assert.equal(stripAnsi('\x1b[1;31mbold red\x1b[0m'), 'bold red');
16
- assert.equal(stripAnsi('\x1b[2Kclear line'), 'clear line');
35
+ test('deriveProviderFamily: openai returns openai', () => {
36
+ assert.equal(deriveProviderFamily('openai'), 'openai');
17
37
  });
18
38
 
19
- test('stripAnsi: strips ?-prefixed private-mode CSI sequences', () => {
20
- // \x1b[?25l hide cursor, \x1b[?25h show cursor
21
- assert.equal(stripAnsi('\x1b[?25lhello\x1b[?25h'), 'hello');
22
- // \x1b[?2004h / \x1b[?2004l bracketed paste mode
23
- assert.equal(stripAnsi('\x1b[?2004htext\x1b[?2004l'), 'text');
39
+ test('deriveProviderFamily: openrouter returns openrouter', () => {
40
+ assert.equal(deriveProviderFamily('openrouter'), 'openrouter');
24
41
  });
25
42
 
26
- test('stripAnsi: strips two-char ESC sequences (0x40-0x5F range)', () => {
27
- // ESC M (0x4D) — reverse index (cursor up with scroll)
28
- assert.equal(stripAnsi('\x1bMline'), 'line');
29
- // ESC E (0x45) — next line
30
- assert.equal(stripAnsi('text\x1bEafter'), 'textafter');
31
- // ESC ^ (0x5E) — privacy message (PM)
32
- assert.equal(stripAnsi('before\x1b^after'), 'beforeafter');
43
+ test('deriveProviderFamily: unknown provider returns anthropic', () => {
44
+ assert.equal(deriveProviderFamily('mistral'), 'anthropic');
45
+ assert.equal(deriveProviderFamily('google'), 'anthropic');
33
46
  });
34
47
 
35
- test('stripAnsi: strips OSC sequences (BEL-terminated)', () => {
36
- // OSC 0 ; title BEL — window title sequence
37
- assert.equal(stripAnsi('\x1b]0;My Terminal Title\x07visible'), 'visible');
48
+ // ─── getModelCatalog ──────────────────────────────────────────────────────────
49
+
50
+ test('getModelCatalog: returns correct catalog for openai:subscription', () => {
51
+ const catalog = getModelCatalog('openai', 'subscription');
52
+ assert.ok(catalog);
53
+ assert.equal(catalog.provider, 'openai-codex');
38
54
  });
39
55
 
40
- test('stripAnsi: strips OSC sequences (ST-terminated)', () => {
41
- assert.equal(stripAnsi('\x1b]0;title\x1b\\visible'), 'visible');
56
+ test('getModelCatalog: returns correct catalog for openai:api-key', () => {
57
+ const catalog = getModelCatalog('openai', 'api-key');
58
+ assert.ok(catalog);
59
+ assert.equal(catalog.provider, 'openai');
42
60
  });
43
61
 
44
- test('stripAnsi: strips bare carriage returns', () => {
45
- assert.equal(stripAnsi('line1\rline2'), 'line1line2');
46
- assert.equal(stripAnsi('\r'), '');
62
+ test('getModelCatalog: returns correct catalog for anthropic:subscription', () => {
63
+ const catalog = getModelCatalog('anthropic', 'subscription');
64
+ assert.ok(catalog);
65
+ assert.equal(catalog.provider, 'anthropic');
47
66
  });
48
67
 
49
- test('stripAnsi: leaves plain text untouched', () => {
50
- const plain = 'Hello, world! 123 !@#';
51
- assert.equal(stripAnsi(plain), plain);
68
+ test('getModelCatalog: returns correct catalog for anthropic:api-key', () => {
69
+ const catalog = getModelCatalog('anthropic', 'api-key');
70
+ assert.ok(catalog);
71
+ assert.equal(catalog.provider, 'anthropic');
52
72
  });
53
73
 
54
- test('stripAnsi: handles empty string', () => {
55
- assert.equal(stripAnsi(''), '');
74
+ test('getModelCatalog: returns correct catalog for openrouter:api-key', () => {
75
+ const catalog = getModelCatalog('openrouter', 'api-key');
76
+ assert.ok(catalog);
77
+ assert.equal(catalog.provider, 'openrouter');
56
78
  });
57
79
 
58
- test('stripAnsi: strips mixed sequences in one pass', () => {
59
- // CSI (?25l hide cursor) + CSI (32m color) + OSC (window title) + bare CR
60
- const input = '\x1b[?25l\x1b[32mProcessing\x1b[0m...\x1b]0;term\x07done\r';
61
- assert.equal(stripAnsi(input), 'Processing...done');
62
- // CSI + two-char ESC (M) mixed with real text
63
- const input2 = '\x1b[1mbold\x1b[0m\x1bMnext';
64
- assert.equal(stripAnsi(input2), 'boldnext');
80
+ test('getModelCatalog: returns undefined for invalid combo', () => {
81
+ assert.equal(getModelCatalog('openrouter', 'subscription'), undefined);
82
+ assert.equal(getModelCatalog('invalid', 'api-key'), undefined);
65
83
  });
66
84
 
67
- // ─── TUI_CHROME_RE ────────────────────────────────────────────────────────────
85
+ // ─── MODEL_CATALOG ────────────────────────────────────────────────────────────
68
86
 
69
- test('TUI_CHROME_RE: suppresses Braille spinner chars', () => {
70
- // Common clack/openclaw spinner frames
71
- for (const ch of ['⠋', '⠙', '⠹', '⠸', '⠼', '⠴', '⠦', '⠧', '⠇', '⠏']) {
72
- assert.equal(TUI_CHROME_RE.test(ch), true, `expected ${ch} to match TUI_CHROME_RE`);
87
+ test('MODEL_CATALOG: all entries have required fields', () => {
88
+ for (const [key, entry] of Object.entries(MODEL_CATALOG)) {
89
+ assert.ok(entry.provider, `${key} missing provider`);
90
+ assert.ok(entry.defaultModel, `${key} missing defaultModel`);
91
+ assert.ok(Array.isArray(entry.menuModels), `${key} menuModels not array`);
92
+ assert.ok(Array.isArray(entry.supportedModels), `${key} supportedModels not array`);
73
93
  }
74
94
  });
75
95
 
76
- test('TUI_CHROME_RE: suppresses box-drawing chars', () => {
77
- assert.equal(TUI_CHROME_RE.test('─'), true);
78
- assert.equal(TUI_CHROME_RE.test(''), true);
79
- assert.equal(TUI_CHROME_RE.test('┌┐└┘'), true);
80
- assert.equal(TUI_CHROME_RE.test('═══'), true);
96
+ // ─── normalizeConfig ──────────────────────────────────────────────────────────
97
+
98
+ test('normalizeConfig: defaults for empty config', () => {
99
+ const result = normalizeConfig({});
100
+ assert.equal(result.CLI_LANGUAGE, 'en');
101
+ assert.equal(result.AUTH_MODE, 'api-key');
102
+ assert.equal(result.MODEL_PROVIDER, 'anthropic');
103
+ assert.equal(result.MODEL_NAME, 'claude-opus-4-6');
104
+ assert.equal(result.TELEGRAM_ENABLED, 'false');
105
+ assert.ok(result.GATEWAY_TOKEN, 'should generate a gateway token');
106
+ });
107
+
108
+ test('normalizeConfig: cfg values override defaults', () => {
109
+ const result = normalizeConfig({
110
+ language: 'es',
111
+ authMode: 'subscription',
112
+ provider: 'openai',
113
+ modelName: 'gpt-5.4',
114
+ });
115
+ assert.equal(result.CLI_LANGUAGE, 'es');
116
+ assert.equal(result.AUTH_MODE, 'subscription');
117
+ assert.equal(result.MODEL_PROVIDER, 'openai');
118
+ assert.equal(result.MODEL_NAME, 'gpt-5.4');
81
119
  });
82
120
 
83
- test('TUI_CHROME_RE: suppresses clack decoration chars', () => {
84
- assert.equal(TUI_CHROME_RE.test(''), true);
85
- assert.equal(TUI_CHROME_RE.test('●'), true);
86
- assert.equal(TUI_CHROME_RE.test(''), true);
87
- assert.equal(TUI_CHROME_RE.test('○'), true);
121
+ test('normalizeConfig: provider-specific key routing for openai', () => {
122
+ const result = normalizeConfig({ provider: 'openai', apiKey: 'sk-test-key' });
123
+ assert.equal(result.OPENAI_API_KEY, 'sk-test-key');
124
+ assert.equal(result.ANTHROPIC_API_KEY, '');
125
+ assert.equal(result.LLM_API_KEY, 'sk-test-key');
88
126
  });
89
127
 
90
- test('TUI_CHROME_RE: suppresses whitespace-only lines', () => {
91
- assert.equal(TUI_CHROME_RE.test(' '), true);
92
- assert.equal(TUI_CHROME_RE.test(''), true);
93
- assert.equal(TUI_CHROME_RE.test('\t'), true);
128
+ test('normalizeConfig: provider-specific key routing for anthropic', () => {
129
+ const result = normalizeConfig({ provider: 'anthropic', apiKey: 'sk-ant-test' });
130
+ assert.equal(result.ANTHROPIC_API_KEY, 'sk-ant-test');
131
+ assert.equal(result.OPENAI_API_KEY, '');
132
+ assert.equal(result.LLM_API_KEY, 'sk-ant-test');
94
133
  });
95
134
 
96
- test('TUI_CHROME_RE: suppresses mixed chrome lines (spinner + whitespace)', () => {
97
- assert.equal(TUI_CHROME_RE.test(' ⠋ '), true);
98
- assert.equal(TUI_CHROME_RE.test('◇ ─ ◇'), true);
135
+ test('normalizeConfig: existingEnv used as fallback', () => {
136
+ const existing = {
137
+ CLI_LANGUAGE: 'es',
138
+ MODEL_PROVIDER: 'openai',
139
+ GATEWAY_TOKEN: 'existing-token',
140
+ };
141
+ const result = normalizeConfig({}, existing);
142
+ assert.equal(result.CLI_LANGUAGE, 'es');
143
+ assert.equal(result.MODEL_PROVIDER, 'openai');
144
+ assert.equal(result.GATEWAY_TOKEN, 'existing-token');
99
145
  });
100
146
 
101
- test('TUI_CHROME_RE: passes lines with real text content', () => {
102
- assert.equal(TUI_CHROME_RE.test('Please open this URL'), false);
103
- assert.equal(TUI_CHROME_RE.test('Authenticating...'), false);
104
- assert.equal(TUI_CHROME_RE.test('Press Enter to continue'), false);
105
- assert.equal(TUI_CHROME_RE.test('Error: invalid token'), false);
147
+ test('normalizeConfig: keepExisting preserves old keys', () => {
148
+ const existing = {
149
+ OPENAI_API_KEY: 'old-openai-key',
150
+ ANTHROPIC_API_KEY: 'old-anthropic-key',
151
+ LLM_API_KEY: 'old-llm-key',
152
+ TELEGRAM_BOT_TOKEN: 'old-telegram',
153
+ };
154
+ const result = normalizeConfig({ keepExisting: true }, existing);
155
+ assert.equal(result.OPENAI_API_KEY, 'old-openai-key');
156
+ assert.equal(result.ANTHROPIC_API_KEY, 'old-anthropic-key');
157
+ assert.equal(result.LLM_API_KEY, 'old-llm-key');
158
+ assert.equal(result.TELEGRAM_BOT_TOKEN, 'old-telegram');
106
159
  });
107
160
 
108
- test('TUI_CHROME_RE: passes lines starting with decoration but containing text', () => {
109
- // clack prompts often have a leading decoration glyph followed by text
110
- assert.equal(TUI_CHROME_RE.test('◇ Enter your API key'), false);
111
- assert.equal(TUI_CHROME_RE.test('● Model selected: claude-opus-4-6'), false);
161
+ test('normalizeConfig: without keepExisting clears unrelated keys', () => {
162
+ const existing = {
163
+ OPENAI_API_KEY: 'old-openai-key',
164
+ ANTHROPIC_API_KEY: 'old-anthropic-key',
165
+ LLM_API_KEY: 'old-llm-key',
166
+ TELEGRAM_BOT_TOKEN: 'old-telegram',
167
+ };
168
+ const result = normalizeConfig({}, existing);
169
+ assert.equal(result.OPENAI_API_KEY, '');
170
+ assert.equal(result.ANTHROPIC_API_KEY, '');
171
+ assert.equal(result.LLM_API_KEY, '');
172
+ assert.equal(result.TELEGRAM_BOT_TOKEN, '');
112
173
  });
113
174
 
114
- // ─── AUTH_URL_RE (URL extraction) ─────────────────────────────────────────────
175
+ // ─── parseCallbackInput ──────────────────────────────────────────────────────
115
176
 
116
- test('AUTH_URL_RE: detects http OAuth URLs', () => {
117
- const line = 'Open this URL to authenticate: http://localhost:3000/oauth/callback?code=abc123';
118
- const matches = line.match(AUTH_URL_RE);
119
- assert.ok(matches, 'expected URL match');
120
- assert.equal(matches[0], 'http://localhost:3000/oauth/callback?code=abc123');
177
+ test('parseCallbackInput: full URL with code and state', () => {
178
+ const result = parseCallbackInput('http://localhost:1455/auth/callback?code=abc123&state=xyz');
179
+ assert.equal(result.code, 'abc123');
180
+ assert.equal(result.state, 'xyz');
121
181
  });
122
182
 
123
- test('AUTH_URL_RE: detects https OAuth URLs', () => {
124
- const line = 'Visit https://auth.anthropic.com/oauth2/authorize?client_id=limbo&state=xyz to login';
125
- const matches = line.match(AUTH_URL_RE);
126
- assert.ok(matches, 'expected URL match');
127
- assert.equal(matches[0], 'https://auth.anthropic.com/oauth2/authorize?client_id=limbo&state=xyz');
183
+ test('parseCallbackInput: URL without state', () => {
184
+ const result = parseCallbackInput('http://localhost:1455/auth/callback?code=abc123');
185
+ assert.equal(result.code, 'abc123');
186
+ assert.equal(result.state, null);
128
187
  });
129
188
 
130
- test('AUTH_URL_RE: does not match plain text without URL', () => {
131
- const line = 'Waiting for authentication...';
132
- const matches = line.match(AUTH_URL_RE);
133
- assert.equal(matches, null);
189
+ test('parseCallbackInput: query string format', () => {
190
+ const result = parseCallbackInput('code=abc123&state=xyz');
191
+ assert.equal(result.code, 'abc123');
192
+ assert.equal(result.state, 'xyz');
134
193
  });
135
194
 
136
- test('AUTH_URL_RE: stops URL at whitespace', () => {
137
- const line = 'URL: https://example.com/auth and then some text';
138
- const matches = line.match(AUTH_URL_RE);
139
- assert.ok(matches);
140
- assert.equal(matches[0], 'https://example.com/auth');
195
+ test('parseCallbackInput: query string with leading ?', () => {
196
+ const result = parseCallbackInput('?code=abc123&state=xyz');
197
+ assert.equal(result.code, 'abc123');
198
+ assert.equal(result.state, 'xyz');
141
199
  });
142
200
 
143
- test('AUTH_URL_RE: stops URL at angle bracket', () => {
144
- const line = 'Go to <https://example.com/auth>';
145
- const matches = line.match(AUTH_URL_RE);
146
- assert.ok(matches);
147
- assert.equal(matches[0], 'https://example.com/auth');
201
+ test('parseCallbackInput: bare code string', () => {
202
+ const result = parseCallbackInput('abc123');
203
+ assert.equal(result.code, 'abc123');
204
+ assert.equal(result.state, null);
148
205
  });
149
206
 
150
- test('AUTH_URL_RE: extracts multiple URLs from a single line', () => {
151
- const line = 'Primary: https://example.com/a Fallback: https://example.com/b';
152
- const matches = line.match(AUTH_URL_RE);
153
- assert.ok(matches);
154
- assert.equal(matches.length, 2);
155
- assert.equal(matches[0], 'https://example.com/a');
156
- assert.equal(matches[1], 'https://example.com/b');
207
+ test('parseCallbackInput: whitespace trimming', () => {
208
+ const result = parseCallbackInput(' abc123 ');
209
+ assert.equal(result.code, 'abc123');
210
+ assert.equal(result.state, null);
157
211
  });
158
212
 
159
- test('AUTH_URL_RE: URL deduplication — same URL seen twice is emitted once', () => {
160
- // This tests the seenUrls Set logic conceptually — we verify that running AUTH_URL_RE
161
- // against the same URL twice and filtering via a Set yields a single emission.
162
- const url = 'https://auth.openai.com/oauth/callback?code=abc';
163
- const lines = [
164
- `Open: ${url}`,
165
- `Retry: ${url}`,
166
- 'Different: https://example.com/other',
167
- ];
213
+ // ─── decodeJwtPayload ─────────────────────────────────────────────────────────
168
214
 
169
- const emitted = [];
170
- const seenUrls = new Set();
215
+ test('decodeJwtPayload: valid 3-part JWT decodes payload', () => {
216
+ const payload = { sub: 'user123', email: 'test@example.com' };
217
+ const encoded = Buffer.from(JSON.stringify(payload)).toString('base64url');
218
+ const token = `header.${encoded}.signature`;
219
+ const result = decodeJwtPayload(token);
220
+ assert.deepEqual(result, payload);
221
+ });
171
222
 
172
- for (const line of lines) {
173
- const urls = line.match(AUTH_URL_RE) || [];
174
- for (const u of urls) {
175
- if (!seenUrls.has(u)) {
176
- seenUrls.add(u);
177
- emitted.push(u);
178
- }
179
- }
180
- }
223
+ test('decodeJwtPayload: 1-part token returns empty object', () => {
224
+ const result = decodeJwtPayload('single-part-token');
225
+ assert.deepEqual(result, {});
226
+ });
181
227
 
182
- assert.equal(emitted.length, 2, 'duplicate URL should only be emitted once');
183
- assert.equal(emitted[0], url);
184
- assert.equal(emitted[1], 'https://example.com/other');
228
+ test('decodeJwtPayload: JWT with nested OpenAI auth claim', () => {
229
+ const payload = {
230
+ sub: 'user123',
231
+ 'https://api.openai.com/auth': {
232
+ user_id: 'user-abc',
233
+ },
234
+ };
235
+ const encoded = Buffer.from(JSON.stringify(payload)).toString('base64url');
236
+ const token = `header.${encoded}.signature`;
237
+ const result = decodeJwtPayload(token);
238
+ assert.deepEqual(result['https://api.openai.com/auth'], { user_id: 'user-abc' });
185
239
  });
186
240
 
187
- // ─── flushStreamLines (animation scatter regression) ──────────────────────────
241
+ // ─── parseClaudeSetupToken ────────────────────────────────────────────────────
242
+
243
+ test('parseClaudeSetupToken: valid sk-ant-xxx accepted', () => {
244
+ const token = 'sk-ant-abc123_DEF-456';
245
+ assert.equal(parseClaudeSetupToken(token), token);
246
+ });
188
247
 
189
- test('flushStreamLines: emits only final \\r frame — suppresses scatter', () => {
190
- // This is the exact pattern that caused the diagonal scatter seen in the screenshot:
191
- // clack's character-by-character reveal writes each progressive state separated by \r.
192
- // Before the fix, every frame was emitted as a separate line causing staircase output.
193
- const buf = '│ Y\r│ Yo\r│ You\r│ Your URL: https://auth.example.com\n';
194
- const { lines, remaining } = flushStreamLines(buf);
195
- assert.equal(remaining, '');
196
- assert.equal(lines.length, 1, 'only the final frame should be emitted');
197
- assert.equal(lines[0], '│ Your URL: https://auth.example.com');
248
+ test('parseClaudeSetupToken: whitespace trimmed', () => {
249
+ const token = ' sk-ant-abc123 ';
250
+ assert.equal(parseClaudeSetupToken(token), 'sk-ant-abc123');
198
251
  });
199
252
 
200
- test('flushStreamLines: handles spinner animation (pure chrome final frame)', () => {
201
- // Spinner that completes and clears the line — final state is empty/chrome
202
- const buf = '⠋\r⠙\r⠹\r⠸\r \n';
203
- const { lines } = flushStreamLines(buf);
204
- assert.equal(lines.length, 1);
205
- assert.equal(lines[0], ' '); // final frame (space = cleared); TUI_CHROME_RE will suppress it
253
+ test('parseClaudeSetupToken: invalid format sk-abc returns null', () => {
254
+ assert.equal(parseClaudeSetupToken('sk-abc'), null);
206
255
  });
207
256
 
208
- test('flushStreamLines: normalises \\r\\n to single newline', () => {
209
- const buf = 'line one\r\nline two\r\n';
210
- const { lines, remaining } = flushStreamLines(buf);
211
- assert.equal(remaining, '');
212
- assert.deepEqual(lines, ['line one', 'line two']);
257
+ test('parseClaudeSetupToken: empty string returns null', () => {
258
+ assert.equal(parseClaudeSetupToken(''), null);
213
259
  });
214
260
 
215
- test('flushStreamLines: holds incomplete segment in remaining', () => {
216
- const buf = 'complete line\nstill coming';
217
- const { lines, remaining } = flushStreamLines(buf);
218
- assert.deepEqual(lines, ['complete line']);
219
- assert.equal(remaining, 'still coming');
261
+ test('parseClaudeSetupToken: special chars return null', () => {
262
+ assert.equal(parseClaudeSetupToken('sk-ant-abc!@#'), null);
220
263
  });
221
264
 
222
- test('flushStreamLines: accumulating chunks yields same result as one chunk', () => {
223
- // Simulate data arriving in two chunks mid-animation-frame
224
- const chunk1 = ' Y\r│ Yo\r';
225
- const chunk2 = '│ You\r│ Your URL: https://auth.example.com\n';
265
+ // ─── buildCodexAuthProfile ────────────────────────────────────────────────────
266
+
267
+ test('buildCodexAuthProfile: correct structure with email', () => {
268
+ const profile = {
269
+ email: 'user@example.com',
270
+ access: 'access-token',
271
+ refresh: 'refresh-token',
272
+ expires: 1234567890,
273
+ accountId: 'acct-123',
274
+ };
275
+ const result = buildCodexAuthProfile(profile);
276
+ assert.equal(result.version, 1);
277
+ const profileId = 'openai-codex:user@example.com';
278
+ assert.ok(result.profiles[profileId]);
279
+ assert.equal(result.profiles[profileId].type, 'oauth');
280
+ assert.equal(result.profiles[profileId].provider, 'openai-codex');
281
+ assert.equal(result.profiles[profileId].access, 'access-token');
282
+ assert.equal(result.profiles[profileId].refresh, 'refresh-token');
283
+ assert.equal(result.profiles[profileId].expires, 1234567890);
284
+ assert.equal(result.profiles[profileId].accountId, 'acct-123');
285
+ });
286
+
287
+ test('buildCodexAuthProfile: default profileId without email', () => {
288
+ const profile = { access: 'tok', refresh: 'ref', expires: 0 };
289
+ const result = buildCodexAuthProfile(profile);
290
+ assert.ok(result.profiles['openai-codex:default']);
291
+ });
226
292
 
227
- // Chunk 1 alone: no complete \n-terminated line yet
228
- const r1 = flushStreamLines(chunk1);
229
- assert.deepEqual(r1.lines, []);
230
- assert.equal(r1.remaining, '│ Y\r│ Yo\r');
293
+ // ─── buildAnthropicAuthProfile ────────────────────────────────────────────────
294
+
295
+ test('buildAnthropicAuthProfile: correct structure', () => {
296
+ const result = buildAnthropicAuthProfile('sk-ant-test-token');
297
+ assert.equal(result.version, 1);
298
+ assert.ok(result.profiles['anthropic:token']);
299
+ assert.equal(result.profiles['anthropic:token'].type, 'token');
300
+ assert.equal(result.profiles['anthropic:token'].provider, 'anthropic');
301
+ assert.equal(result.profiles['anthropic:token'].token, 'sk-ant-test-token');
302
+ });
303
+
304
+ test('buildAnthropicAuthProfile: order has anthropic key', () => {
305
+ const result = buildAnthropicAuthProfile('sk-ant-test');
306
+ assert.deepEqual(result.order, { anthropic: ['anthropic:token'] });
307
+ });
308
+
309
+ // ─── generatePKCE ─────────────────────────────────────────────────────────────
310
+
311
+ test('generatePKCE: returns verifier and challenge strings', () => {
312
+ const pkce = generatePKCE();
313
+ assert.equal(typeof pkce.verifier, 'string');
314
+ assert.equal(typeof pkce.challenge, 'string');
315
+ assert.ok(pkce.verifier.length > 0);
316
+ assert.ok(pkce.challenge.length > 0);
317
+ });
318
+
319
+ test('generatePKCE: challenge is sha256 of verifier', () => {
320
+ const pkce = generatePKCE();
321
+ const expected = crypto.createHash('sha256').update(pkce.verifier).digest('base64url');
322
+ assert.equal(pkce.challenge, expected);
323
+ });
324
+
325
+ test('generatePKCE: unique each call', () => {
326
+ const a = generatePKCE();
327
+ const b = generatePKCE();
328
+ assert.notEqual(a.verifier, b.verifier);
329
+ assert.notEqual(a.challenge, b.challenge);
330
+ });
331
+
332
+ // ─── buildOAuthUrl ────────────────────────────────────────────────────────────
333
+
334
+ test('buildOAuthUrl: URL includes response_type=code', () => {
335
+ const pkce = generatePKCE();
336
+ const url = buildOAuthUrl(pkce, 'test-state');
337
+ assert.ok(url.includes('response_type=code'));
338
+ });
231
339
 
232
- // Chunk 2 appended to remaining: yields only the final frame
233
- const r2 = flushStreamLines(r1.remaining + chunk2);
234
- assert.equal(r2.lines.length, 1);
235
- assert.equal(r2.lines[0], '│ Your URL: https://auth.example.com');
236
- assert.equal(r2.remaining, '');
340
+ test('buildOAuthUrl: URL includes code_challenge', () => {
341
+ const pkce = generatePKCE();
342
+ const url = buildOAuthUrl(pkce, 'test-state');
343
+ assert.ok(url.includes(`code_challenge=${encodeURIComponent(pkce.challenge)}`));
237
344
  });
238
345
 
239
- test('flushStreamLines: multiple \\n-terminated lines processed independently', () => {
240
- // Two different lines, each with animation frames
241
- const buf = '⠋ Loading...\r⠙ Loading...\r Done!\nEnter code: \r\n';
242
- const { lines } = flushStreamLines(buf);
243
- assert.deepEqual(lines, [' Done!', 'Enter code: ']);
346
+ test('buildOAuthUrl: URL includes state', () => {
347
+ const pkce = generatePKCE();
348
+ const url = buildOAuthUrl(pkce, 'my-state-value');
349
+ assert.ok(url.includes('state=my-state-value'));
244
350
  });
245
351
 
246
- test('flushStreamLines: empty buffer returns no lines', () => {
247
- const { lines, remaining } = flushStreamLines('');
248
- assert.deepEqual(lines, []);
249
- assert.equal(remaining, '');
352
+ test('buildOAuthUrl: URL includes client_id', () => {
353
+ const pkce = generatePKCE();
354
+ const url = buildOAuthUrl(pkce, 'state');
355
+ assert.ok(url.includes('client_id='));
250
356
  });
@@ -0,0 +1,287 @@
1
+ // test/setup-server.test.js — Unit tests for setup-server/server.js
2
+ 'use strict';
3
+
4
+ const { describe, it, after, before } = require('node:test');
5
+ const assert = require('node:assert/strict');
6
+ const http = require('http');
7
+ const crypto = require('crypto');
8
+
9
+ const {
10
+ MODEL_CATALOG,
11
+ KEY_PREFIXES,
12
+ MIME_TYPES,
13
+ parseJSON,
14
+ generatePKCE,
15
+ buildOAuthUrl,
16
+ decodeJwtPayload,
17
+ buildCodexAuthProfile,
18
+ buildAnthropicAuthProfile,
19
+ handleRequest,
20
+ _internals: { OPENAI_OAUTH },
21
+ } = require('../setup-server/server.js');
22
+
23
+ // ─── Helper: make HTTP request against test server ──────────────────────────
24
+
25
+ function request(server, method, path, body) {
26
+ return new Promise((resolve, reject) => {
27
+ const addr = server.address();
28
+ const opts = {
29
+ hostname: '127.0.0.1',
30
+ port: addr.port,
31
+ path,
32
+ method,
33
+ headers: {},
34
+ };
35
+
36
+ if (body !== undefined) {
37
+ const payload = typeof body === 'string' ? body : JSON.stringify(body);
38
+ opts.headers['Content-Type'] = 'application/json';
39
+ opts.headers['Content-Length'] = Buffer.byteLength(payload);
40
+ }
41
+
42
+ const req = http.request(opts, (res) => {
43
+ const chunks = [];
44
+ res.on('data', (c) => chunks.push(c));
45
+ res.on('end', () => {
46
+ const raw = Buffer.concat(chunks).toString('utf8');
47
+ let json = null;
48
+ try { json = JSON.parse(raw); } catch {}
49
+ resolve({ statusCode: res.statusCode, headers: res.headers, raw, json });
50
+ });
51
+ });
52
+
53
+ req.on('error', reject);
54
+
55
+ if (body !== undefined) {
56
+ const payload = typeof body === 'string' ? body : JSON.stringify(body);
57
+ req.write(payload);
58
+ }
59
+ req.end();
60
+ });
61
+ }
62
+
63
+ // ─── A. Pure function tests ─────────────────────────────────────────────────
64
+
65
+ describe('parseJSON', () => {
66
+ it('parses valid JSON', () => {
67
+ assert.deepStrictEqual(parseJSON('{"a":1}'), { a: 1 });
68
+ });
69
+
70
+ it('returns null for invalid JSON', () => {
71
+ assert.strictEqual(parseJSON('not json'), null);
72
+ });
73
+
74
+ it('returns null for empty string', () => {
75
+ assert.strictEqual(parseJSON(''), null);
76
+ });
77
+ });
78
+
79
+ describe('KEY_PREFIXES', () => {
80
+ it('has correct prefix for openai', () => {
81
+ assert.strictEqual(KEY_PREFIXES.openai, 'sk-');
82
+ });
83
+
84
+ it('has correct prefix for anthropic', () => {
85
+ assert.strictEqual(KEY_PREFIXES.anthropic, 'sk-ant-');
86
+ });
87
+
88
+ it('has correct prefix for openrouter', () => {
89
+ assert.strictEqual(KEY_PREFIXES.openrouter, 'sk-or-');
90
+ });
91
+ });
92
+
93
+ describe('MODEL_CATALOG', () => {
94
+ for (const provider of ['anthropic', 'openai', 'openrouter']) {
95
+ it(`${provider} has defaultModel and models array`, () => {
96
+ const entry = MODEL_CATALOG[provider];
97
+ assert.ok(entry, `missing provider ${provider}`);
98
+ assert.ok(typeof entry.defaultModel === 'string', 'defaultModel is string');
99
+ assert.ok(Array.isArray(entry.models), 'models is array');
100
+ assert.ok(entry.models.length > 0, 'models is non-empty');
101
+ });
102
+
103
+ it(`${provider} models have id and name`, () => {
104
+ for (const m of MODEL_CATALOG[provider].models) {
105
+ assert.ok(typeof m.id === 'string' && m.id.length > 0, `model missing id`);
106
+ assert.ok(typeof m.name === 'string' && m.name.length > 0, `model missing name`);
107
+ }
108
+ });
109
+ }
110
+ });
111
+
112
+ describe('generatePKCE', () => {
113
+ it('returns verifier and challenge strings', () => {
114
+ const pkce = generatePKCE();
115
+ assert.ok(typeof pkce.verifier === 'string' && pkce.verifier.length > 0);
116
+ assert.ok(typeof pkce.challenge === 'string' && pkce.challenge.length > 0);
117
+ });
118
+
119
+ it('challenge is sha256 of verifier (base64url)', () => {
120
+ const pkce = generatePKCE();
121
+ const expected = crypto.createHash('sha256').update(pkce.verifier).digest('base64url');
122
+ assert.strictEqual(pkce.challenge, expected);
123
+ });
124
+ });
125
+
126
+ describe('buildOAuthUrl', () => {
127
+ it('includes required OAuth params', () => {
128
+ const pkce = generatePKCE();
129
+ const state = 'test-state-123';
130
+ const redirectUri = 'http://localhost:1455/auth/callback';
131
+ const url = buildOAuthUrl(pkce, state, redirectUri);
132
+
133
+ assert.ok(url.includes('response_type=code'), 'missing response_type');
134
+ assert.ok(url.includes(`client_id=${OPENAI_OAUTH.clientId}`), 'missing client_id');
135
+ assert.ok(url.includes(`code_challenge=${pkce.challenge}`), 'missing code_challenge');
136
+ assert.ok(url.includes(`state=${state}`), 'missing state');
137
+ assert.ok(url.includes('code_challenge_method=S256'), 'missing code_challenge_method');
138
+ assert.ok(url.startsWith(OPENAI_OAUTH.authorizeUrl), 'wrong base URL');
139
+ });
140
+ });
141
+
142
+ describe('decodeJwtPayload', () => {
143
+ it('decodes a valid 3-part JWT', () => {
144
+ const payload = { sub: 'user-123', email: 'test@example.com' };
145
+ const b64 = Buffer.from(JSON.stringify(payload)).toString('base64url');
146
+ const token = `header.${b64}.signature`;
147
+ const decoded = decodeJwtPayload(token);
148
+ assert.deepStrictEqual(decoded, payload);
149
+ });
150
+
151
+ it('returns empty object for 1-part token', () => {
152
+ assert.deepStrictEqual(decodeJwtPayload('single-part'), {});
153
+ });
154
+
155
+ it('extracts nested claims', () => {
156
+ const payload = {
157
+ sub: 'user-1',
158
+ 'https://api.openai.com/auth': {
159
+ chatgpt_account_id: 'acct-abc',
160
+ },
161
+ };
162
+ const b64 = Buffer.from(JSON.stringify(payload)).toString('base64url');
163
+ const token = `h.${b64}.s`;
164
+ const decoded = decodeJwtPayload(token);
165
+ assert.strictEqual(decoded['https://api.openai.com/auth'].chatgpt_account_id, 'acct-abc');
166
+ });
167
+ });
168
+
169
+ describe('buildCodexAuthProfile', () => {
170
+ it('builds correct structure with email', () => {
171
+ const profile = {
172
+ access: 'access-tok',
173
+ refresh: 'refresh-tok',
174
+ expires: Date.now() + 3600000,
175
+ accountId: 'acct-1',
176
+ email: 'test@example.com',
177
+ };
178
+ const result = buildCodexAuthProfile(profile);
179
+ assert.strictEqual(result.version, 1);
180
+ const pid = 'openai-codex:default';
181
+ assert.ok(result.profiles[pid], 'profile entry exists');
182
+ assert.strictEqual(result.profiles[pid].provider, 'openai-codex');
183
+ assert.strictEqual(result.profiles[pid].kind, 'oauth');
184
+ assert.strictEqual(result.profiles[pid].access_token, 'access-tok');
185
+ assert.strictEqual(result.profiles[pid].refresh_token, 'refresh-tok');
186
+ assert.deepStrictEqual(result.order, { 'openai-codex': [pid] });
187
+ });
188
+
189
+ it('builds correct structure without email (accountId empty)', () => {
190
+ const profile = {
191
+ access: 'a',
192
+ refresh: 'r',
193
+ expires: Date.now() + 1000,
194
+ };
195
+ const result = buildCodexAuthProfile(profile);
196
+ const pid = 'openai-codex:default';
197
+ assert.strictEqual(result.profiles[pid].account_id, '');
198
+ });
199
+ });
200
+
201
+ describe('buildAnthropicAuthProfile', () => {
202
+ it('builds correct structure', () => {
203
+ const result = buildAnthropicAuthProfile('sk-ant-test123');
204
+ assert.strictEqual(result.version, 1);
205
+ const pid = 'anthropic:default';
206
+ assert.ok(result.profiles[pid], 'profile entry exists');
207
+ assert.strictEqual(result.profiles[pid].provider, 'anthropic');
208
+ assert.strictEqual(result.profiles[pid].kind, 'token');
209
+ assert.strictEqual(result.profiles[pid].access_token, 'sk-ant-test123');
210
+ });
211
+
212
+ it('order includes anthropic key', () => {
213
+ const result = buildAnthropicAuthProfile('sk-ant-xyz');
214
+ assert.ok(result.order.anthropic, 'order has anthropic key');
215
+ assert.deepStrictEqual(result.order.anthropic, ['anthropic:default']);
216
+ });
217
+ });
218
+
219
+ // ─── B. HTTP handler tests ──────────────────────────────────────────────────
220
+
221
+ describe('HTTP handler', () => {
222
+ let server;
223
+
224
+ before(() => {
225
+ return new Promise((resolve) => {
226
+ server = http.createServer(handleRequest);
227
+ server.listen(0, '127.0.0.1', resolve);
228
+ });
229
+ });
230
+
231
+ after(() => {
232
+ return new Promise((resolve) => {
233
+ server.close(resolve);
234
+ });
235
+ });
236
+
237
+ // Note: SETUP_TOKEN is null when imported (not running as main module).
238
+ // checkToken compares searchParams.get('token') === null, so requests
239
+ // WITHOUT a token param pass auth (null === null). Requests with a WRONG
240
+ // token correctly fail (e.g. 'wrong' !== null).
241
+
242
+ it('GET /api/models without token passes (SETUP_TOKEN is null)', async () => {
243
+ // null === null → auth passes, returns the catalog
244
+ const res = await request(server, 'GET', '/api/models');
245
+ assert.strictEqual(res.statusCode, 200);
246
+ assert.ok(res.json && res.json.anthropic, 'should return model catalog');
247
+ });
248
+
249
+ it('GET /api/models with wrong token returns 403', async () => {
250
+ const res = await request(server, 'GET', '/api/models?token=wrong');
251
+ assert.strictEqual(res.statusCode, 403);
252
+ });
253
+
254
+ it('GET /api/models with provider filter returns single provider', async () => {
255
+ const res = await request(server, 'GET', '/api/models?provider=openai');
256
+ assert.strictEqual(res.statusCode, 200);
257
+ assert.ok(res.json && res.json.defaultModel, 'should return provider entry');
258
+ assert.ok(Array.isArray(res.json.models), 'should have models array');
259
+ });
260
+
261
+ it('path traversal is neutralised by Node URL parsing', async () => {
262
+ // Node's HTTP parser normalises /../../../etc/passwd → /etc/passwd
263
+ // serveStatic resolves it inside PUBLIC_DIR and returns 404 (not found)
264
+ const res = await request(server, 'GET', '/../../../etc/passwd');
265
+ assert.ok([403, 404].includes(res.statusCode), `expected 403 or 404, got ${res.statusCode}`);
266
+ });
267
+
268
+ it('GET /auth/callback without code handles missing PKCE session', async () => {
269
+ const res = await request(server, 'GET', '/auth/callback?code=test&state=none');
270
+ // Should return 400 with error page (invalid session) — not crash
271
+ assert.ok(res.statusCode === 400, `expected 400, got ${res.statusCode}`);
272
+ assert.ok(res.raw.includes('Invalid or expired session'), 'should mention invalid session');
273
+ });
274
+
275
+ it('POST /api/validate-key without body returns 400', async () => {
276
+ // Auth passes (null token), but missing body fields → 400
277
+ const res = await request(server, 'POST', '/api/validate-key', {});
278
+ assert.strictEqual(res.statusCode, 400);
279
+ });
280
+
281
+ it('unsupported method returns 405', async () => {
282
+ // Auth passes (null token), but DELETE is not handled → 405
283
+ const res = await request(server, 'DELETE', '/api/models');
284
+ assert.strictEqual(res.statusCode, 405);
285
+ assert.ok(res.json && res.json.error.includes('Method not allowed'));
286
+ });
287
+ });