limbo-ai 1.21.0 → 1.22.1
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 +38 -7
- package/cli.js +140 -25
- package/package.json +2 -2
- package/setup-server/server.js +43 -11
- package/test/cli-auth.test.js +280 -174
- package/test/setup-server.test.js +287 -0
package/README.md
CHANGED
|
@@ -52,20 +52,49 @@ 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
|
+
npx limbo-ai@latest config # Configure optional features (voice, web-search)
|
|
61
62
|
```
|
|
62
63
|
|
|
63
64
|
---
|
|
64
65
|
|
|
66
|
+
## Optional Features
|
|
67
|
+
|
|
68
|
+
Limbo supports optional features that can be enabled during the setup wizard (step 7) or anytime via the CLI.
|
|
69
|
+
|
|
70
|
+
### Voice Messages
|
|
71
|
+
|
|
72
|
+
Transcribe Telegram voice notes using [Groq](https://groq.com) Whisper. Requires a Groq API key (`gsk_...`).
|
|
73
|
+
|
|
74
|
+
```sh
|
|
75
|
+
npx limbo-ai@latest config voice --enable --api-key gsk_xxx
|
|
76
|
+
npx limbo-ai@latest config voice --status
|
|
77
|
+
npx limbo-ai@latest config voice --disable
|
|
78
|
+
```
|
|
79
|
+
|
|
80
|
+
### Web Search
|
|
81
|
+
|
|
82
|
+
Give Limbo real-time web search via the [Brave Search API](https://brave.com/search/api/). Requires a Brave API key (`BSA...`).
|
|
83
|
+
|
|
84
|
+
```sh
|
|
85
|
+
npx limbo-ai@latest config web-search --enable --api-key BSAxxx
|
|
86
|
+
npx limbo-ai@latest config web-search --status
|
|
87
|
+
npx limbo-ai@latest config web-search --disable
|
|
88
|
+
```
|
|
89
|
+
|
|
90
|
+
Both features store API keys as Docker secrets and toggle config sections in the container on restart.
|
|
91
|
+
|
|
92
|
+
---
|
|
93
|
+
|
|
65
94
|
## Updating
|
|
66
95
|
|
|
67
96
|
```sh
|
|
68
|
-
npx limbo-ai update
|
|
97
|
+
npx limbo-ai@latest update
|
|
69
98
|
```
|
|
70
99
|
|
|
71
100
|
Pulls the latest Limbo image and restarts the container. Your vault data is persisted in the `limbo-data` Docker volume and is not affected.
|
|
@@ -124,6 +153,8 @@ Managed automatically by `npx limbo-ai start`, stored in `~/.limbo/.env`.
|
|
|
124
153
|
| `MODEL_NAME` | no | `claude-opus-4-6` | Model name (e.g. `claude-opus-4-6`, `claude-sonnet-4-6`, `gpt-5.4`) |
|
|
125
154
|
| `TELEGRAM_ENABLED` | no | `false` | Enable Telegram bot integration |
|
|
126
155
|
| `TELEGRAM_BOT_TOKEN` | no | — | Telegram bot token (required if `TELEGRAM_ENABLED=true`) |
|
|
156
|
+
| `VOICE_ENABLED` | no | `false` | Enable voice transcription (requires Groq API key as Docker secret) |
|
|
157
|
+
| `WEB_SEARCH_ENABLED` | no | `false` | Enable web search (requires Brave API key as Docker secret) |
|
|
127
158
|
|
|
128
159
|
> \* API keys are required only for `AUTH_MODE=api-key`. Subscription auth uses ZeroClaw auth profiles instead.
|
|
129
160
|
|
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');
|
|
@@ -1066,7 +1067,7 @@ function teardownSetupTunnel(tunnel) {
|
|
|
1066
1067
|
function installGlobalAlias() {
|
|
1067
1068
|
// Create a `limbo` shell wrapper so users don't have to type `npx limbo-ai` every time.
|
|
1068
1069
|
// Tries /usr/local/bin first (macOS, Linux with sudo), falls back to ~/.local/bin (no sudo).
|
|
1069
|
-
const wrapper = '#!/bin/sh\nexec npx limbo-ai "$@"\n';
|
|
1070
|
+
const wrapper = '#!/bin/sh\nexec npx limbo-ai@latest "$@"\n';
|
|
1070
1071
|
const candidates = [
|
|
1071
1072
|
path.join(os.homedir(), '.local', 'bin', 'limbo'),
|
|
1072
1073
|
'/usr/local/bin/limbo',
|
|
@@ -1074,10 +1075,10 @@ function installGlobalAlias() {
|
|
|
1074
1075
|
|
|
1075
1076
|
for (const target of candidates) {
|
|
1076
1077
|
try {
|
|
1077
|
-
// Skip if already installed and current
|
|
1078
|
+
// Skip if already installed and current (must include @latest)
|
|
1078
1079
|
if (fs.existsSync(target)) {
|
|
1079
1080
|
const existing = fs.readFileSync(target, 'utf8');
|
|
1080
|
-
if (existing.includes('limbo-ai')) return;
|
|
1081
|
+
if (existing.includes('limbo-ai@latest')) return;
|
|
1081
1082
|
}
|
|
1082
1083
|
const dir = path.dirname(target);
|
|
1083
1084
|
if (!fs.existsSync(dir)) fs.mkdirSync(dir, { recursive: true });
|
|
@@ -1669,9 +1670,32 @@ function cmdLogs() {
|
|
|
1669
1670
|
run('docker compose logs -f');
|
|
1670
1671
|
}
|
|
1671
1672
|
|
|
1673
|
+
function selfUpdateCli() {
|
|
1674
|
+
const pkg = require('./package.json');
|
|
1675
|
+
try {
|
|
1676
|
+
const latest = execSync('npm view limbo-ai version', { encoding: 'utf8', timeout: 10000 }).trim();
|
|
1677
|
+
if (!latest || latest === pkg.version) return;
|
|
1678
|
+
const cur = pkg.version.split('.').map(Number);
|
|
1679
|
+
const lat = latest.split('.').map(Number);
|
|
1680
|
+
const isNewer = lat[0] > cur[0] || (lat[0] === cur[0] && lat[1] > cur[1]) ||
|
|
1681
|
+
(lat[0] === cur[0] && lat[1] === cur[1] && lat[2] > cur[2]);
|
|
1682
|
+
if (!isNewer) return;
|
|
1683
|
+
|
|
1684
|
+
log(`Updating CLI: ${pkg.version} → ${latest}...`);
|
|
1685
|
+
execSync('npm install -g limbo-ai@latest', { stdio: 'inherit', timeout: 60000 });
|
|
1686
|
+
ok(`CLI updated to ${latest}.`);
|
|
1687
|
+
} catch {
|
|
1688
|
+
warn('Could not self-update CLI. Run: npm install -g limbo-ai@latest');
|
|
1689
|
+
}
|
|
1690
|
+
}
|
|
1691
|
+
|
|
1672
1692
|
function cmdUpdate() {
|
|
1673
1693
|
if (!fs.existsSync(COMPOSE_FILE)) die(t('en', 'installMissing'));
|
|
1674
1694
|
|
|
1695
|
+
// Self-update the CLI if installed globally
|
|
1696
|
+
const isGlobal = !process.argv[1].includes('npx') && !process.argv[1].includes('node_modules/.cache');
|
|
1697
|
+
if (isGlobal) selfUpdateCli();
|
|
1698
|
+
|
|
1675
1699
|
// Patch image tag to :latest in existing compose files (handles upgrades from pinned tags)
|
|
1676
1700
|
let compose = fs.readFileSync(COMPOSE_FILE, 'utf8');
|
|
1677
1701
|
const patched = compose.replace(
|
|
@@ -1828,27 +1852,118 @@ ${c.bold}Data directory:${c.reset} ${LIMBO_DIR}
|
|
|
1828
1852
|
`);
|
|
1829
1853
|
}
|
|
1830
1854
|
|
|
1855
|
+
// ─── Update Notifier ─────────────────────────────────────────────────────────
|
|
1856
|
+
|
|
1857
|
+
const UPDATE_CHECK_FILE = path.join(LIMBO_DIR, '.update-check');
|
|
1858
|
+
const UPDATE_CHECK_INTERVAL = 24 * 60 * 60 * 1000; // 24 hours
|
|
1859
|
+
|
|
1860
|
+
// Spawn a detached background process to check the npm registry.
|
|
1861
|
+
// Writes {latest, checkedAt} to UPDATE_CHECK_FILE and exits.
|
|
1862
|
+
function checkForUpdateInBackground() {
|
|
1863
|
+
try {
|
|
1864
|
+
let shouldCheck = true;
|
|
1865
|
+
if (fs.existsSync(UPDATE_CHECK_FILE)) {
|
|
1866
|
+
const cached = JSON.parse(fs.readFileSync(UPDATE_CHECK_FILE, 'utf8'));
|
|
1867
|
+
if (Date.now() - cached.checkedAt < UPDATE_CHECK_INTERVAL) shouldCheck = false;
|
|
1868
|
+
}
|
|
1869
|
+
if (!shouldCheck) return;
|
|
1870
|
+
|
|
1871
|
+
// Spawn detached child that hits the registry and writes cache
|
|
1872
|
+
const child = spawn(process.execPath, ['-e', `
|
|
1873
|
+
const https = require('https');
|
|
1874
|
+
const fs = require('fs');
|
|
1875
|
+
const req = https.get('https://registry.npmjs.org/limbo-ai/latest', { timeout: 5000 }, (res) => {
|
|
1876
|
+
let data = '';
|
|
1877
|
+
res.on('data', (chunk) => data += chunk);
|
|
1878
|
+
res.on('end', () => {
|
|
1879
|
+
try {
|
|
1880
|
+
const { version } = JSON.parse(data);
|
|
1881
|
+
fs.mkdirSync('${LIMBO_DIR.replace(/\\/g, '\\\\')}', { recursive: true });
|
|
1882
|
+
fs.writeFileSync('${UPDATE_CHECK_FILE.replace(/\\/g, '\\\\')}', JSON.stringify({ latest: version, checkedAt: Date.now() }));
|
|
1883
|
+
} catch {}
|
|
1884
|
+
});
|
|
1885
|
+
});
|
|
1886
|
+
req.on('error', () => {});
|
|
1887
|
+
req.end();
|
|
1888
|
+
`], { detached: true, stdio: 'ignore' });
|
|
1889
|
+
child.unref();
|
|
1890
|
+
} catch {}
|
|
1891
|
+
}
|
|
1892
|
+
|
|
1893
|
+
// Read cache and print banner if a newer version is available.
|
|
1894
|
+
function notifyUpdate() {
|
|
1895
|
+
try {
|
|
1896
|
+
if (!fs.existsSync(UPDATE_CHECK_FILE)) return;
|
|
1897
|
+
const { latest } = JSON.parse(fs.readFileSync(UPDATE_CHECK_FILE, 'utf8'));
|
|
1898
|
+
const pkg = require('./package.json');
|
|
1899
|
+
if (!latest || latest === pkg.version) return;
|
|
1900
|
+
|
|
1901
|
+
// Simple semver compare: split on dots, compare numerically
|
|
1902
|
+
const cur = pkg.version.split('.').map(Number);
|
|
1903
|
+
const lat = latest.split('.').map(Number);
|
|
1904
|
+
const isNewer = lat[0] > cur[0] || (lat[0] === cur[0] && lat[1] > cur[1]) ||
|
|
1905
|
+
(lat[0] === cur[0] && lat[1] === cur[1] && lat[2] > cur[2]);
|
|
1906
|
+
if (!isNewer) return;
|
|
1907
|
+
|
|
1908
|
+
// Strip ANSI escapes for visible-length padding
|
|
1909
|
+
const strip = (s) => s.replace(/\x1b\[[0-9;]*m/g, '');
|
|
1910
|
+
const pad = (s, w) => s + ' '.repeat(Math.max(0, w - strip(s).length));
|
|
1911
|
+
|
|
1912
|
+
const line = ` Update available: ${c.dim}${pkg.version}${c.reset} → ${c.green}${latest}${c.reset} `;
|
|
1913
|
+
const instruction = ` Run ${c.cyan}npx limbo-ai@latest update${c.reset} to update `;
|
|
1914
|
+
const inner = Math.max(strip(line).length, strip(instruction).length);
|
|
1915
|
+
const border = '─'.repeat(inner);
|
|
1916
|
+
console.error(`\n ${c.dim}╭${border}╮${c.reset}`);
|
|
1917
|
+
console.error(` ${c.dim}│${c.reset}${pad(line, inner)}${c.dim}│${c.reset}`);
|
|
1918
|
+
console.error(` ${c.dim}│${c.reset}${pad(instruction, inner)}${c.dim}│${c.reset}`);
|
|
1919
|
+
console.error(` ${c.dim}╰${border}╯${c.reset}\n`);
|
|
1920
|
+
} catch {}
|
|
1921
|
+
}
|
|
1922
|
+
|
|
1923
|
+
// ─── Exports (for testing) ────────────────────────────────────────────────────
|
|
1924
|
+
|
|
1925
|
+
module.exports = {
|
|
1926
|
+
MODEL_CATALOG,
|
|
1927
|
+
normalizeConfig,
|
|
1928
|
+
parseEnvFile,
|
|
1929
|
+
deriveProviderFamily,
|
|
1930
|
+
getModelCatalog,
|
|
1931
|
+
parseCallbackInput,
|
|
1932
|
+
decodeJwtPayload,
|
|
1933
|
+
parseClaudeSetupToken,
|
|
1934
|
+
buildCodexAuthProfile,
|
|
1935
|
+
buildAnthropicAuthProfile,
|
|
1936
|
+
generatePKCE,
|
|
1937
|
+
buildOAuthUrl,
|
|
1938
|
+
};
|
|
1939
|
+
|
|
1831
1940
|
// ─── Main ────────────────────────────────────────────────────────────────────
|
|
1832
1941
|
|
|
1833
|
-
|
|
1834
|
-
|
|
1835
|
-
|
|
1836
|
-
|
|
1837
|
-
|
|
1838
|
-
|
|
1839
|
-
|
|
1840
|
-
|
|
1841
|
-
|
|
1842
|
-
|
|
1843
|
-
|
|
1844
|
-
|
|
1845
|
-
|
|
1846
|
-
|
|
1847
|
-
|
|
1848
|
-
|
|
1849
|
-
cmdHelp();
|
|
1850
|
-
|
|
1851
|
-
|
|
1852
|
-
|
|
1853
|
-
|
|
1854
|
-
}
|
|
1942
|
+
if (require.main === module) {
|
|
1943
|
+
const [,, cmd = 'start'] = process.argv;
|
|
1944
|
+
|
|
1945
|
+
(async () => {
|
|
1946
|
+
checkForUpdateInBackground();
|
|
1947
|
+
|
|
1948
|
+
switch (cmd) {
|
|
1949
|
+
case 'start':
|
|
1950
|
+
case 'install': await cmdStart(); break;
|
|
1951
|
+
case 'stop': cmdStop(); break;
|
|
1952
|
+
case 'logs': cmdLogs(); break;
|
|
1953
|
+
case 'update': cmdUpdate(); break;
|
|
1954
|
+
case 'status': cmdStatus(); break;
|
|
1955
|
+
case 'config': cmdConfig(); break;
|
|
1956
|
+
case 'help':
|
|
1957
|
+
case '--help':
|
|
1958
|
+
case '-h': cmdHelp(); break;
|
|
1959
|
+
default:
|
|
1960
|
+
warn(t('en', 'unknownCommand', cmd));
|
|
1961
|
+
cmdHelp();
|
|
1962
|
+
process.exit(1);
|
|
1963
|
+
}
|
|
1964
|
+
|
|
1965
|
+
notifyUpdate();
|
|
1966
|
+
})().catch((err) => {
|
|
1967
|
+
die(err.message || String(err));
|
|
1968
|
+
});
|
|
1969
|
+
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "limbo-ai",
|
|
3
|
-
"version": "1.
|
|
3
|
+
"version": "1.22.1",
|
|
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",
|
package/setup-server/server.js
CHANGED
|
@@ -154,7 +154,10 @@ function ensureSetupToken() {
|
|
|
154
154
|
return token;
|
|
155
155
|
}
|
|
156
156
|
|
|
157
|
-
|
|
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
|
-
|
|
886
|
+
if (require.main === module) {
|
|
887
|
+
const server = http.createServer(handleRequest);
|
|
857
888
|
|
|
858
|
-
server.listen(PORT, '0.0.0.0', () => {
|
|
859
|
-
|
|
860
|
-
|
|
861
|
-
|
|
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
|
-
|
|
866
|
-
|
|
867
|
-
});
|
|
895
|
+
server.on('error', (err) => {
|
|
896
|
+
log(`Server error: ${err.message}`);
|
|
897
|
+
process.exit(1);
|
|
898
|
+
});
|
|
899
|
+
}
|
package/test/cli-auth.test.js
CHANGED
|
@@ -1,250 +1,356 @@
|
|
|
1
1
|
// test/cli-auth.test.js
|
|
2
|
-
// Unit tests for
|
|
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
|
-
|
|
10
|
-
|
|
11
|
-
|
|
31
|
+
test('deriveProviderFamily: openai-codex returns openai', () => {
|
|
32
|
+
assert.equal(deriveProviderFamily('openai-codex'), 'openai');
|
|
33
|
+
});
|
|
12
34
|
|
|
13
|
-
test('
|
|
14
|
-
assert.equal(
|
|
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('
|
|
20
|
-
|
|
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('
|
|
27
|
-
|
|
28
|
-
assert.equal(
|
|
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
|
-
|
|
36
|
-
|
|
37
|
-
|
|
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('
|
|
41
|
-
|
|
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('
|
|
45
|
-
|
|
46
|
-
assert.
|
|
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('
|
|
50
|
-
const
|
|
51
|
-
assert.
|
|
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('
|
|
55
|
-
|
|
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('
|
|
59
|
-
|
|
60
|
-
|
|
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
|
-
// ───
|
|
85
|
+
// ─── MODEL_CATALOG ────────────────────────────────────────────────────────────
|
|
68
86
|
|
|
69
|
-
test('
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
assert.
|
|
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
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
assert.equal(
|
|
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('
|
|
84
|
-
|
|
85
|
-
assert.equal(
|
|
86
|
-
assert.equal(
|
|
87
|
-
assert.equal(
|
|
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('
|
|
91
|
-
|
|
92
|
-
assert.equal(
|
|
93
|
-
assert.equal(
|
|
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('
|
|
97
|
-
|
|
98
|
-
|
|
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('
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
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('
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
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
|
-
// ───
|
|
175
|
+
// ─── parseCallbackInput ──────────────────────────────────────────────────────
|
|
115
176
|
|
|
116
|
-
test('
|
|
117
|
-
const
|
|
118
|
-
|
|
119
|
-
assert.
|
|
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('
|
|
124
|
-
const
|
|
125
|
-
|
|
126
|
-
assert.
|
|
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('
|
|
131
|
-
const
|
|
132
|
-
|
|
133
|
-
assert.equal(
|
|
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('
|
|
137
|
-
const
|
|
138
|
-
|
|
139
|
-
assert.
|
|
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('
|
|
144
|
-
const
|
|
145
|
-
|
|
146
|
-
assert.
|
|
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('
|
|
151
|
-
const
|
|
152
|
-
|
|
153
|
-
assert.
|
|
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
|
-
|
|
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
|
-
|
|
170
|
-
const
|
|
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
|
-
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
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
|
-
|
|
183
|
-
|
|
184
|
-
|
|
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
|
-
// ───
|
|
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('
|
|
190
|
-
|
|
191
|
-
|
|
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('
|
|
201
|
-
|
|
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('
|
|
209
|
-
|
|
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('
|
|
216
|
-
|
|
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
|
-
|
|
223
|
-
|
|
224
|
-
|
|
225
|
-
const
|
|
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
|
-
|
|
228
|
-
|
|
229
|
-
|
|
230
|
-
|
|
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
|
-
|
|
233
|
-
const
|
|
234
|
-
|
|
235
|
-
assert.
|
|
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('
|
|
240
|
-
|
|
241
|
-
const
|
|
242
|
-
|
|
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('
|
|
247
|
-
const
|
|
248
|
-
|
|
249
|
-
assert.
|
|
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
|
+
});
|