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 +7 -7
- package/cli.js +114 -22
- 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,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
|
-
|
|
1834
|
-
|
|
1835
|
-
|
|
1836
|
-
|
|
1837
|
-
|
|
1838
|
-
|
|
1839
|
-
|
|
1840
|
-
|
|
1841
|
-
|
|
1842
|
-
|
|
1843
|
-
|
|
1844
|
-
|
|
1845
|
-
|
|
1846
|
-
|
|
1847
|
-
|
|
1848
|
-
|
|
1849
|
-
cmdHelp();
|
|
1850
|
-
|
|
1851
|
-
|
|
1852
|
-
|
|
1853
|
-
|
|
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.
|
|
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",
|
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
|
+
});
|