Files
llmgw/internal/vscodeext/ext/extension.js
T
Ilker ManapandClaude Opus 5.5 ecf5545a7a VS Code's sign-in keeps its place; recordings checked for secrets
From review.
- /profile asked before signing in keeps its query for ten minutes, and
  the sign-in (password or OIDC) returns to it: VS Code's sign-in state
  came back lost, and the confirmation came every time. Only /profile is
  ever returned to.
- The extension keeps an https gateway at https when a link names it by
  plain http (a proxy ending TLS unannounced), so the key is never sent in
  the clear. The install command says --force, since dev builds share a
  version and VS Code kept the old one.
- Every frame of every install recording is checked for a key, a
  password or a private key, not only the page.
- The admin API document again matches the API: f94d902's
  MCPServerRequest.clear was not in it.
- CHANGELOG for this round's fixes.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-29 11:40:19 +01:00

260 lines
9.9 KiB
JavaScript

'use strict';
// llmgw for VS Code: the models a gateway serves, offered to VS Code as a
// language-model provider, so its chat and any extension that asks VS Code
// for a model can use them.
//
// The key lives in VS Code's secret storage, one per gateway, and is put
// there one of two ways: the gateway's account page makes one and hands it
// back through a vscode:// link, or the person pastes one.
const vscode = require('vscode');
const crypto = require('crypto');
const lib = require('./lib');
const VENDOR = 'llmgw';
const SIGN_IN_TTL_MS = 15 * 60 * 1000;
/** @param {vscode.ExtensionContext} context */
function activate(context) {
const changed = new vscode.EventEmitter();
const version = context.extension.packageJSON.version || '0.0.0';
const userAgent = `llmgw-vscode/${version} vscode/${vscode.version}`;
const gateway = () => lib.gatewayBase(vscode.workspace.getConfiguration('llmgw').get('gatewayUrl'));
const secretName = (base) => `llmgw.key:${base}`;
const keyFor = async (base) => (base ? context.secrets.get(secretName(base)) : undefined);
// The sign-in this window started: what the link back must carry to be
// taken without asking.
let pending = null;
async function askGateway() {
const now = gateway();
const typed = await vscode.window.showInputBox({
title: 'llmgw gateway',
prompt: 'The address of your llmgw gateway, such as https://llm.example.com',
value: now,
ignoreFocusOut: true,
validateInput: (v) => (lib.gatewayBase(v) ? undefined : 'An http:// or https:// address'),
});
if (typed === undefined) return '';
const base = lib.gatewayBase(typed);
if (base !== now) {
await vscode.workspace.getConfiguration('llmgw').update('gatewayUrl', base, vscode.ConfigurationTarget.Global);
}
return base;
}
async function signIn() {
const base = gateway() || (await askGateway());
if (!base) return;
pending = { state: crypto.randomBytes(18).toString('base64url'), base, at: Date.now() };
const page = vscode.Uri.parse(`${base}/profile?tool=vscode&state=${pending.state}#connect`);
const opened = await vscode.env.openExternal(page);
if (!opened) return;
vscode.window.showInformationMessage(
`Sign in on ${base} in your browser and choose "Make a key and the configuration". The page sends the key back to VS Code.`,
);
}
async function enterKey() {
const base = gateway() || (await askGateway());
if (!base) return;
const key = await vscode.window.showInputBox({
title: `llmgw key for ${base}`,
prompt: 'A key from your account page on the gateway, starting sk-llmgw-',
password: true,
ignoreFocusOut: true,
validateInput: (v) => (v.trim() ? undefined : 'The key'),
});
if (!key) return;
await storeKey(base, key.trim());
}
async function storeKey(base, key) {
// Tried before it is kept: a wrong key or a gateway that is not there is
// better said now than as an empty model list later.
try {
await listModels(base, key);
} catch (err) {
vscode.window.showErrorMessage(String(err && err.message ? err.message : err));
return;
}
await context.secrets.store(secretName(base), key);
changed.fire();
vscode.window.showInformationMessage(`llmgw: connected to ${base}. Its models are in the chat's model picker.`);
}
async function signOut() {
const base = gateway();
if (!base) return;
await context.secrets.delete(secretName(base));
changed.fire();
vscode.window.showInformationMessage(
`llmgw: signed out of ${base}. The key still works until it is revoked on the gateway's account page.`,
);
}
// The account page's "Open in VS Code" link: vscode://llmgw.llmgw/connect
// with the gateway, the key it just made, and the state this window sent.
// A link from anywhere else is asked about first: a page that could hand
// VS Code a key of its own choosing would also choose where code is sent.
async function handleUri(uri) {
if (uri.path !== '/connect') return;
const q = new URLSearchParams(uri.query);
let base = lib.gatewayBase(q.get('gateway'));
const key = (q.get('key') || '').trim();
if (!base || !key) {
vscode.window.showErrorMessage('llmgw: the link has no gateway or no key in it.');
return;
}
// The same gateway by plain http, behind a proxy that ends TLS without
// saying so: kept at https, so the key is never sent in the clear.
const current = gateway();
if (current && lib.downgrades(current, base)) {
base = current;
}
const expected =
pending && pending.base === base && q.get('state') === pending.state && Date.now() - pending.at < SIGN_IN_TTL_MS;
pending = null;
if (!expected) {
const yes = 'Connect';
const choice = await vscode.window.showWarningMessage(
`Connect VS Code to the llmgw gateway at ${base}?`,
{
modal: true,
detail: 'What you ask its models, and the code they are given, will go to that address. Connect only if you just made this key on its account page.',
},
yes,
);
if (choice !== yes) return;
}
if (base !== gateway()) {
await vscode.workspace.getConfiguration('llmgw').update('gatewayUrl', base, vscode.ConfigurationTarget.Global);
}
await storeKey(base, key);
}
async function call(base, key, path, init) {
const res = await fetch(base + path, {
...init,
headers: {
Authorization: `Bearer ${key}`,
'Content-Type': 'application/json',
'User-Agent': userAgent,
...(init && init.headers),
},
});
if (!res.ok) throw new Error(lib.errorMessage(res.status, await res.text()));
return res;
}
async function listModels(base, key) {
let res;
try {
res = await call(base, key, '/v1/models', { method: 'GET', signal: AbortSignal.timeout(15000) });
} catch (err) {
if (err && err.message && err.message.startsWith('llmgw:')) throw err;
throw new Error(`llmgw: could not reach ${base} (${err && err.cause ? err.cause.message || err.cause : err}).`);
}
const body = await res.json();
return Array.isArray(body.data) ? body.data : [];
}
const provider = {
onDidChangeLanguageModelChatInformation: changed.event,
async provideLanguageModelChatInformation(options) {
const base = gateway();
const key = await keyFor(base);
if (!base || !key) {
// Offered, not waited for: VS Code is waiting on this list, and a
// notification can sit unanswered for as long as the person likes.
// Signing in fires the change event, and VS Code asks again.
if (!options.silent) {
vscode.window
.showInformationMessage('llmgw: sign in to a gateway to use its models.', 'Sign In', 'Enter API Key')
.then((pick) => {
if (pick === 'Sign In') return signIn();
if (pick === 'Enter API Key') return enterKey();
});
}
return [];
}
try {
const show = vscode.workspace.getConfiguration('llmgw').get('showModelsWithoutTools', true);
return (await listModels(base, key)).map((e) => lib.modelInfo(e, show)).filter(Boolean);
} catch (err) {
if (!options.silent) vscode.window.showErrorMessage(String(err.message || err));
return [];
}
},
async provideLanguageModelChatResponse(model, messages, options, progress, token) {
const base = gateway();
const key = await keyFor(base);
if (!base || !key) throw new Error('llmgw: not signed in. Run "llmgw: Sign In".');
const abort = new AbortController();
const sub = token.onCancellationRequested(() => abort.abort());
try {
const res = await call(base, key, '/v1/chat/completions', {
method: 'POST',
body: JSON.stringify(lib.requestBody(model, messages, options)),
signal: abort.signal,
});
const answer = new lib.Answer();
for await (const data of lib.sseData(res.body)) {
const text = answer.add(data);
if (text) progress.report(new vscode.LanguageModelTextPart(text));
}
for (const c of answer.toolCalls()) {
progress.report(new vscode.LanguageModelToolCallPart(c.id, c.name, c.input));
}
} catch (err) {
if (token.isCancellationRequested) return;
throw err;
} finally {
sub.dispose();
}
},
async provideTokenCount(_model, text) {
if (typeof text === 'string') return lib.tokenGuess(text);
const parts = (text && text.content) || [];
return parts.reduce((n, p) => n + lib.tokenGuess(p && typeof p.value === 'string' ? p.value : JSON.stringify(p)), 0);
},
};
context.subscriptions.push(
changed,
vscode.lm.registerLanguageModelChatProvider(VENDOR, provider),
vscode.window.registerUriHandler({ handleUri }),
vscode.commands.registerCommand('llmgw.signIn', signIn),
vscode.commands.registerCommand('llmgw.enterKey', enterKey),
vscode.commands.registerCommand('llmgw.signOut', signOut),
vscode.commands.registerCommand('llmgw.refresh', () => changed.fire()),
vscode.workspace.onDidChangeConfiguration((e) => {
if (e.affectsConfiguration('llmgw')) changed.fire();
}),
);
// For another extension, or a test, that has a gateway and a key in hand:
// the same as the account page's link, without the question, since the
// caller already runs with every right VS Code gives an extension.
return {
async connect(gatewayUrl, key) {
const base = lib.gatewayBase(gatewayUrl);
if (!base || !key) throw new Error('llmgw: connect needs a gateway address and a key');
if (base !== gateway()) {
await vscode.workspace.getConfiguration('llmgw').update('gatewayUrl', base, vscode.ConfigurationTarget.Global);
}
await storeKey(base, String(key).trim());
},
};
}
function deactivate() {}
module.exports = { activate, deactivate };