Skip to content

Commit 0ebbca1

Browse files
committed
feat(web): the browser says where a key is kept, and a BYOK end-to-end check
- web/ai-extension/copy.js: the three desktop sentences about the OS keychain and 'your machine' are rewritten for a tab at build time, and the build fails if one is no longer in the source, so the browser never ships a stale claim about where a secret lives. - web/test/e2e-byok.mjs: set a key through the real command, ask the agent, and see the request go from the tab straight to the provider with that key and no LevelCode account call. - the stand-in backend gains an OpenAI-compatible provider endpoint with the CORS real ones send.
1 parent 044d71a commit 0ebbca1

6 files changed

Lines changed: 201 additions & 8 deletions

File tree

‎web/ai-extension/build.mjs‎

Lines changed: 11 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -36,6 +36,8 @@ const esbuild = await import(esbuildEntry);
3636
const nodePaths = [path.join(vscodeDir, 'node_modules'), path.join(vscodeDir, 'build', 'node_modules')].filter((p) => fs.existsSync(p));
3737
if (!nodePaths.some((p) => fs.existsSync(path.join(p, 'buffer')))) { console.error('the "buffer" package is missing from ' + vscodeDir + '/node_modules'); process.exit(2); }
3838

39+
const { applyCopy } = createRequire(import.meta.url)('./copy.js');
40+
3941
const outDir = path.join(outRoot, 'levelcode-ai');
4042
fs.rmSync(outDir, { recursive: true, force: true });
4143
fs.mkdirSync(outDir, { recursive: true });
@@ -50,7 +52,7 @@ function collect(rel, filter) {
5052
if (e.isDirectory()) { walk(r); continue; }
5153
if (filter && !filter(r)) { continue; }
5254
const buf = fs.readFileSync(path.join(SRC, r));
53-
out[r] = TEXT.has(path.extname(r)) ? buf.toString('utf8') : { base64: buf.toString('base64') };
55+
out[r] = TEXT.has(path.extname(r)) ? applyCopy(r, buf.toString('utf8')) : { base64: buf.toString('base64') };
5456
}
5557
};
5658
if (fs.existsSync(path.join(SRC, rel))) { walk(rel); }
@@ -69,11 +71,14 @@ const dirnamePlugin = {
6971
name: 'levelcode-dirname',
7072
setup(b) {
7173
b.onLoad({ filter: /extensions[\\/]levelcode-ai[\\/].*\.js$/ }, (a) => {
72-
let text = fs.readFileSync(a.path, 'utf8');
73-
if (!/\b__dirname\b/.test(text)) { return null; }
74-
const rel = path.relative(SRC, path.dirname(a.path)).split(path.sep).join('/');
75-
text = text.replace(/\b__dirname\b/g, `(globalThis.__lcExtPath + ${JSON.stringify(rel ? '/' + rel : '')})`);
76-
return { contents: text, loader: 'js' };
74+
const file = path.relative(SRC, a.path).split(path.sep).join('/');
75+
const original = fs.readFileSync(a.path, 'utf8');
76+
let text = applyCopy(file, original); // words that are only true on the desktop
77+
if (/\b__dirname\b/.test(text)) {
78+
const rel = path.relative(SRC, path.dirname(a.path)).split(path.sep).join('/');
79+
text = text.replace(/\b__dirname\b/g, `(globalThis.__lcExtPath + ${JSON.stringify(rel ? '/' + rel : '')})`);
80+
}
81+
return text === original ? null : { contents: text, loader: 'js' };
7782
});
7883
},
7984
};

‎web/ai-extension/copy.js‎

Lines changed: 44 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,44 @@
1+
// Words that are true on the desktop and false in a tab, and what the browser build says instead.
2+
//
3+
// The desktop extension's text is written for a Mac: keys in the OS keychain, nothing leaves "your
4+
// machine". In a browser the same facts read differently (keys are sealed in this browser's
5+
// storage; the page runs on the user's machine but the files may be in the browser's own store).
6+
// A claim about where a secret lives has to be right, so the browser build rewrites these exact
7+
// strings and FAILS if one is not found: when the desktop copy changes, the build says so instead
8+
// of quietly shipping the old claim.
9+
//
10+
// Plain CommonJS; web/test/unit/copy.test.js pins it against the real sources.
11+
'use strict';
12+
13+
/** [file relative to extensions/levelcode-ai, exact desktop text, browser text] */
14+
const REPLACEMENTS = [
15+
['extension.js',
16+
'Stored encrypted in your OS keychain — it never leaves your machine except to ',
17+
'Stored encrypted in this browser — it never leaves this browser except to '],
18+
['media/chat.html',
19+
'Bring your own API key, stored in your OS keychain.',
20+
'Bring your own API key, stored encrypted in this browser.'],
21+
['media/chat.html',
22+
'Your keys never leave your machine — requests go direct to your provider.',
23+
'Your keys never leave this browser — requests go direct to your provider.'],
24+
];
25+
26+
/**
27+
* @param {string} file path relative to extensions/levelcode-ai
28+
* @param {string} text the file's contents
29+
* @returns {string} the text with every replacement for `file` applied
30+
* @throws if a replacement's desktop text is not in the file
31+
*/
32+
function applyCopy(file, text) {
33+
let out = text;
34+
for (const [f, from, to] of REPLACEMENTS) {
35+
if (f !== file) { continue; }
36+
if (!out.includes(from)) {
37+
throw new Error(`browser copy: "${from.slice(0, 60)}…" is no longer in ${file}; update web/ai-extension/copy.js`);
38+
}
39+
out = out.split(from).join(to);
40+
}
41+
return out;
42+
}
43+
44+
module.exports = { REPLACEMENTS, applyCopy };

‎web/serve.mjs‎

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -29,6 +29,7 @@ const config = webConfig({
2929
extensions: (args['extension-names'] ?? 'levelcode-ai,levelcode-web').split(',').filter(Boolean),
3030
development: true,
3131
productConfiguration: args.product ? JSON.parse(args.product) : undefined,
32+
configurationDefaults: args['config-defaults'] ? JSON.parse(args['config-defaults']) : undefined,
3233
});
3334

3435
const TYPES = {

‎web/test/e2e-byok.mjs‎

Lines changed: 89 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,89 @@
1+
#!/usr/bin/env node
2+
// Bring-your-own-key in a tab: set a key through the real command, ask the agent for something, and see
3+
// that the request went from the browser straight to the provider with that key — and that no LevelCode
4+
// account token was involved. Uses the same stand-in backend as e2e.mjs, as an OpenAI-compatible provider.
5+
//
6+
// node web/test/e2e-byok.mjs --static <vscode-web dir> --extensions <dir>
7+
//
8+
// The provider settings (custom, OpenAI-compatible, at the stand-in's address) are configuration defaults of
9+
// the development server; a release bakes its own defaults, so this runs against the development layout.
10+
import { spawn } from 'node:child_process';
11+
import fs from 'node:fs';
12+
import path from 'node:path';
13+
import { fileURLToPath } from 'node:url';
14+
import { startStub } from './stub-backend.mjs';
15+
import { Chrome, sleep } from './cdp.mjs';
16+
17+
const HERE = path.dirname(fileURLToPath(import.meta.url));
18+
const args = Object.fromEntries(process.argv.slice(2).reduce((a, v, i, all) => { if (v.startsWith('--')) { a.push([v.slice(2), all[i + 1] && !all[i + 1].startsWith('--') ? all[i + 1] : 'true']); } return a; }, []));
19+
const STATIC = path.resolve(args.static || process.env.LEVELCODE_WEB_STATIC || '');
20+
const EXTS = path.resolve(args.extensions || process.env.LEVELCODE_WEB_EXTENSIONS || '');
21+
const PORT = Number(args.port || 8805);
22+
const editorOrigin = `http://localhost:${PORT}`;
23+
const KEY = ['sk', 'byok', 'test', '0123456789'].join('-');
24+
const results = [];
25+
const check = (name, ok, detail = '') => { results.push(ok); console.log(`${ok ? 'PASS' : 'FAIL'} ${name}${detail ? ' — ' + detail : ''}`); };
26+
27+
const stub = await startStub({ editorOrigin, port: args['stub-port'] ? Number(args['stub-port']) : 0 });
28+
const defaults = JSON.stringify({ 'levelcode.ai.providerMode': 'byok', 'levelcode.ai.provider': 'custom', 'levelcode.ai.baseURL': stub.origin + '/v1' });
29+
const server = spawn(process.execPath, [path.join(HERE, '..', 'serve.mjs'), '--port', String(PORT),
30+
'--static', STATIC, '--extensions', EXTS, '--extension-names', 'levelcode-ai,levelcode-web', '--account', stub.origin, '--config-defaults', defaults], { stdio: 'inherit' });
31+
let chrome;
32+
const finish = async (code) => { try { chrome && chrome.close(); } catch {} try { server.kill(); } catch {} try { await stub.close(); } catch {} process.exit(code); };
33+
34+
try {
35+
for (let i = 0; i < 50; i++) { try { if ((await fetch(editorOrigin + '/')).ok) { break; } } catch {} await sleep(100); }
36+
chrome = await Chrome.launch({ port: Number(args['debug-port'] || 9556), args: ['--ignore-certificate-errors'] });
37+
const page = await chrome.newPage(editorOrigin + '/');
38+
await page.waitFor(() => page.eval(`!!document.querySelector('.monaco-workbench .part.editor')`), { ms: 90000, label: 'workbench' });
39+
await page.waitFor(async () => (await page.eval(`[...document.querySelectorAll('.tabs-container .tab')].some((e) => /LevelCode AI/.test(e.getAttribute('aria-label') || ''))`)), { ms: 60000, label: 'chat tab' });
40+
await sleep(2500);
41+
42+
const palette = async (text) => {
43+
await page.key('F1', 'F1');
44+
await page.waitFor(() => page.eval(`!!document.querySelector('.quick-input-widget:not([style*="display: none"]) .quick-input-box input')`), { ms: 8000, label: 'command palette' });
45+
await page.type(text);
46+
await sleep(900);
47+
await page.key('Enter', 'Enter');
48+
};
49+
50+
await palette('Set API Key');
51+
await page.waitFor(() => page.eval(`!!document.querySelector('.quick-input-widget:not([style*="display: none"]) .quick-input-box input[type=password]')`), { ms: 10000, label: 'key prompt' });
52+
const prompt = await page.eval(`(document.querySelector('.quick-input-widget .quick-input-message') || {}).textContent`);
53+
check('the key prompt says where the key is kept, truthfully for a tab', /this browser/i.test(String(prompt)) && !/keychain/i.test(String(prompt)), JSON.stringify(prompt));
54+
await page.type(KEY);
55+
await page.key('Enter', 'Enter');
56+
await sleep(800);
57+
58+
// the chat webview
59+
const activate = async () => {
60+
const r = await page.eval(`(() => { const t = [...document.querySelectorAll('.tabs-container .tab')].find((e) => /LevelCode AI/.test(e.getAttribute('aria-label') || '')); const b = t.getBoundingClientRect(); return { x: b.x + b.width / 2, y: b.y + b.height / 2 }; })()`);
61+
await page.click(r.x, r.y);
62+
await sleep(700);
63+
};
64+
await activate();
65+
const inChat = async (expr) => {
66+
for (const [id, s] of page.sessions) {
67+
if (s.type !== 'iframe') { continue; }
68+
try {
69+
const v = await page.eval(`(() => { const f = document.getElementById('active-frame'); if (!f || !f.contentDocument || !f.contentDocument.getElementById('input')) { return { __no: true }; } const d = f.contentDocument; return { v: (${expr}) }; })()`, { sessionId: id });
70+
if (v && !v.__no) { return v.v; }
71+
} catch { /* another frame */ }
72+
}
73+
return undefined;
74+
};
75+
await page.waitFor(async () => (await inChat('!!d.getElementById("send")')) === true, { ms: 30000, label: 'chat' });
76+
const label = await inChat(`(d.getElementById('st-key') || {}).textContent`);
77+
check('the chat shows that it is using a key of the user\'s own', /BYO|key/i.test(String(label)), JSON.stringify(label));
78+
await inChat(`(() => { const i = d.getElementById('input'); i.focus(); if ('value' in i) { i.value = 'Please create a hello file'; } else { i.textContent = 'Please create a hello file'; } i.dispatchEvent(new Event('input', { bubbles: true })); d.getElementById('send').click(); return true; })()`);
79+
await page.waitFor(() => stub.state.chats >= 2, { ms: 60000, label: 'two model turns' }).catch(() => null);
80+
check('the agent reached the provider straight from the tab', stub.state.chats >= 2, 'turns: ' + stub.state.chats);
81+
const auth = stub.state.byokAuth || [];
82+
check('the request carried the user\'s key, and only that', auth.length > 0 && auth.every((a) => a === 'Bearer ' + KEY), JSON.stringify(auth.slice(0, 2)));
83+
check('no LevelCode account call was made (no sign-in, no gateway)', !stub.state.log.some((e) => /^\/api\/levelcode\/v1\/(ai|auth)\//.test(e.path)), JSON.stringify(stub.state.log.filter((e) => e.path.startsWith('/api')).map((e) => e.path).slice(0, 4)));
84+
} catch (e) {
85+
check('the run completed', false, String((e && e.stack) || e));
86+
}
87+
const failed = results.filter((r) => !r).length;
88+
console.log(`\n${results.length - failed}/${results.length} checks passed`);
89+
await finish(failed ? 1 : 0);

‎web/test/stub-backend.mjs‎

Lines changed: 12 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -85,7 +85,7 @@ export async function startStub(o) {
8585
cors.vary = 'Origin';
8686
cors['access-control-expose-headers'] = '';
8787
}
88-
if (req.method === 'OPTIONS') {
88+
if (req.method === 'OPTIONS' && url.pathname !== '/v1/chat/completions') {
8989
if (cors['access-control-allow-origin']) {
9090
const wanted = String(req.headers['access-control-request-headers'] || '').toLowerCase().split(',').map((s) => s.trim()).filter(Boolean);
9191
const ok = wanted.every((h) => ['authorization', 'content-type', 'accept'].includes(h));
@@ -158,7 +158,17 @@ export async function startStub(o) {
158158
] });
159159
}
160160

161-
if (url.pathname === '/api/levelcode/v1/ai/chat/completions' && req.method === 'POST') {
161+
// A provider of the user's own (BYOK): any origin may call it, as real providers allow, and the
162+
// key arrives as the bearer. Same scripted model as the gateway.
163+
const isByok = url.pathname === '/v1/chat/completions';
164+
if (isByok) {
165+
cors['access-control-allow-origin'] = '*';
166+
cors['access-control-allow-headers'] = 'authorization, content-type';
167+
cors['access-control-allow-methods'] = 'POST, OPTIONS';
168+
if (req.method === 'OPTIONS') { res.writeHead(204, cors); res.end(); return; }
169+
state.byokAuth = (state.byokAuth || []).concat(req.headers.authorization || '');
170+
}
171+
if ((url.pathname === '/api/levelcode/v1/ai/chat/completions' || isByok) && req.method === 'POST') {
162172
const body = JSON.parse((await readBody()) || '{}');
163173
state.chats++;
164174
state.lastChatBody = body;

‎web/test/unit/copy.test.js‎

Lines changed: 44 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,44 @@
1+
/*---------------------------------------------------------------------------------------------
2+
* The browser build's copy replacements — run: node web/test/unit/copy.test.js
3+
*
4+
* Every replacement must still find its desktop text in the real source (otherwise the browser
5+
* build would quietly ship the old claim), and no replacement may say something the browser
6+
* cannot stand behind.
7+
*--------------------------------------------------------------------------------------------*/
8+
// @ts-check
9+
'use strict';
10+
11+
const assert = require('assert');
12+
const fs = require('fs');
13+
const path = require('path');
14+
const { REPLACEMENTS, applyCopy } = require('../../ai-extension/copy.js');
15+
16+
const SRC = path.join(__dirname, '..', '..', '..', 'extensions', 'levelcode-ai');
17+
let n = 0;
18+
function test(name, fn) { fn(); n++; console.log(' ok - ' + name); }
19+
20+
test('every desktop string is still in the source it names', () => {
21+
for (const [file, from] of REPLACEMENTS) {
22+
const text = fs.readFileSync(path.join(SRC, file), 'utf8');
23+
assert.ok(text.includes(from), `${file} no longer contains: ${from}`);
24+
}
25+
});
26+
test('applying them changes the file and leaves no desktop-only claim about keys behind', () => {
27+
for (const file of new Set(REPLACEMENTS.map((r) => r[0]))) {
28+
const text = fs.readFileSync(path.join(SRC, file), 'utf8');
29+
const out = applyCopy(file, text);
30+
assert.notStrictEqual(out, text, file + ' came out unchanged');
31+
for (const [f, from] of REPLACEMENTS) { if (f === file) { assert.ok(!out.includes(from), 'still says: ' + from); } }
32+
}
33+
});
34+
test('a replacement whose desktop text has gone is an error, not a silent skip', () => {
35+
assert.throws(() => applyCopy('extension.js', 'nothing relevant here'), /no longer in extension\.js/);
36+
});
37+
test('the browser text claims only what the browser build does', () => {
38+
for (const [, , to] of REPLACEMENTS) {
39+
assert.ok(!/keychain|your machine|OS\b/i.test(to), 'the browser text still talks like the desktop: ' + to);
40+
assert.ok(!/hackable/i.test(to));
41+
}
42+
});
43+
44+
console.log(`\n${n} tests passed`);

0 commit comments

Comments
 (0)