Skip to content

Commit 29ceb87

Browse files
committed
feat(web): a browser manifest that promises only what a tab can do, and unit suites for the stand-ins
- web/ai-extension/manifest.js: the desktop manifest minus the commands, settings and welcome copy that need a shell, a disk or a local process; a key the browser lets through (Ctrl+Shift+I is developer tools on Windows and Linux). - unit suites in the gate (scripts/test-extensions.sh now also runs web/test/unit): path and crypto are compared with Node over many inputs (the comparison found three differences in the first path shim), fs for the behaviours and error codes the extension relies on, host.js for both modes, the manifest for dangling references. - the e2e also runs with webviews and the extension host on origins of their own, where the extension's requests come from a per-session v--<hash> origin.
1 parent 8937f50 commit 29ceb87

11 files changed

Lines changed: 644 additions & 34 deletions

File tree

‎scripts/test-extensions.sh‎

Lines changed: 5 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -3,8 +3,9 @@
33
# Run every extension's unit suites. This is the gate: the release workflow runs it before spending
44
# an hour of macOS build minutes, and every pull request runs it too (.github/workflows/test.yml).
55
#
6-
# It DISCOVERS suites — extensions/*/test/*.test.js — so a new one is gated the moment it is added,
7-
# with no list to keep in sync. (The gate used to `cd extensions/levelcode-ai`; levelcode-updater's
6+
# It DISCOVERS suites — extensions/*/test/*.test.js, and the browser edition's unit suites in
7+
# web/test/unit/*.test.js — so a new one is gated the moment it is added, with no list to keep in sync.
8+
# (web/test/e2e.mjs drives a real browser against a built editor; it is not a unit suite and is not here.) (The gate used to `cd extensions/levelcode-ai`; levelcode-updater's
89
# suites never ran, including the one guarding the Download button against serving a raw .app.zip.)
910
# Requires are file-relative, so the suites run from the repo root.
1011
#
@@ -17,14 +18,14 @@ shopt -s nullglob
1718
cd "$(dirname "$0")/.."
1819

1920
count=0
20-
for t in extensions/*/test/*.test.js; do
21+
for t in extensions/*/test/*.test.js web/test/unit/*.test.js; do
2122
echo "── $t"
2223
node "$t" # `set -e` stops at the first suite that fails
2324
count=$((count + 1))
2425
done
2526
# A glob that matches nothing would otherwise report success and gate nothing. Fail loudly instead.
2627
if [ "$count" -eq 0 ]; then
27-
echo "::error::No suites matched extensions/*/test/*.test.js — the gate would pass vacuously."
28+
echo "::error::No suites matched extensions/*/test/*.test.js or web/test/unit/*.test.js — the gate would pass vacuously."
2829
exit 1
2930
fi
3031
echo "──────── $count test files passed ────────"

‎web/ai-extension/build.mjs‎

Lines changed: 4 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -106,10 +106,11 @@ await esbuild.build({
106106
fs.rmSync(generated, { force: true });
107107

108108
/* ----- manifest + the files it refers to ---------------------------------------------------- */
109-
const pkg = JSON.parse(fs.readFileSync(path.join(SRC, 'package.json'), 'utf8'));
110-
pkg.browser = './extension.web.js';
109+
const { toWebManifest } = createRequire(import.meta.url)('./manifest.js');
110+
const pkg = toWebManifest(JSON.parse(fs.readFileSync(path.join(SRC, 'package.json'), 'utf8')));
111111
fs.writeFileSync(path.join(outDir, 'package.json'), JSON.stringify(pkg, null, '\t') + '\n');
112-
for (const f of ['README.md', 'package.nls.json']) { if (fs.existsSync(path.join(SRC, f))) { fs.copyFileSync(path.join(SRC, f), path.join(outDir, f)); } }
112+
fs.writeFileSync(path.join(outDir, 'package.nls.json'), '{}\n'); // the workbench asks for it; there is nothing to localise
113+
for (const f of ['README.md']) { if (fs.existsSync(path.join(SRC, f))) { fs.copyFileSync(path.join(SRC, f), path.join(outDir, f)); } }
113114
fs.cpSync(path.join(SRC, 'media'), path.join(outDir, 'media'), { recursive: true });
114115

115116
const size = fs.statSync(path.join(outDir, 'extension.web.js')).size;

‎web/ai-extension/manifest.js‎

Lines changed: 92 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,92 @@
1+
// The manifest of levelcode-ai as the browser build ships it.
2+
//
3+
// The desktop manifest promises things a browser tab cannot do: an agent that runs commands, MCP
4+
// servers started as local processes, importing settings from VS Code on this machine, a folder
5+
// of sessions on disk. A browser build that lists them would offer a command that always fails
6+
// and a setting that does nothing. So the browser manifest is the desktop one with those removed,
7+
// and a welcome page that says what is here. Nothing in the desktop manifest changes.
8+
//
9+
// Plain CommonJS so the gate can run it (web/test/unit/manifest.test.js) and build.mjs can load it.
10+
'use strict';
11+
12+
/** Commands that need a machine, or a part of LevelCode that is not in the browser build. */
13+
const REMOVED_COMMANDS = new Set([
14+
'levelcode.customize', // the customization pack is a desktop extension
15+
'levelcode.import.vscode', // reads another editor's settings from this machine's disk
16+
'levelcode.ai.manageMcp', // MCP servers are local processes
17+
'levelcode.ai.sketch', // Agent Sketch has not been run in the browser
18+
]);
19+
20+
/** Settings that only mean something with a shell, a local disk or a local model server. */
21+
const REMOVED_SETTINGS = new Set([
22+
'levelcode.ai.commandTimeout',
23+
'levelcode.ai.verify.command',
24+
'levelcode.ai.preview.autoOpen',
25+
'levelcode.ai.mcp.servers',
26+
'levelcode.ai.mcp.toolPolicy',
27+
'levelcode.ai.sessions.dir',
28+
'levelcode.ai.ollama.url',
29+
'levelcode.ai.ollama.model',
30+
]);
31+
32+
const WELCOME = {
33+
id: 'welcome',
34+
title: 'Welcome to LevelCode',
35+
description: 'Your AI code editor, in a browser tab. Sign in and start.',
36+
steps: [
37+
{
38+
id: 'ai',
39+
title: 'Connect your AI',
40+
description: 'Chat with your files as context, get inline completions as you type, and let the agent read, search, create and edit files in your workspace. **Sign in and you are on your plan**, with no API key to manage. Prefer your own? Bring a key for Claude, OpenAI, Groq, Mistral and more.\n[Sign in to LevelCode](command:levelcode.ai.account)\n[Use your own key (BYOK)](command:levelcode.ai.setApiKey)\n[Pick a model](command:levelcode.ai.pickModel)\n[Open the chat](command:levelcode.ai.focus)',
41+
media: { svg: 'media/walkthrough/ai.svg', altText: 'Chat, inline completions and an agent built into the editor' },
42+
completionEvents: ['onCommand:levelcode.ai.setApiKey', 'onCommand:levelcode.ai.account'],
43+
},
44+
{
45+
id: 'files',
46+
title: 'Open your files',
47+
description: 'Work in the scratch workspace, which is saved in this browser, or open a folder from your computer (Chrome and Edge) and the agent edits it in place.\n[Open a folder from your computer](command:levelcode.web.openLocalFolder)\n[Go to the scratch workspace](command:levelcode.web.openScratch)\n[Choose a theme](command:workbench.action.selectTheme)',
48+
media: { svg: 'media/walkthrough/look.svg', altText: 'Themes and workspaces' },
49+
completionEvents: ['onCommand:levelcode.web.openLocalFolder'],
50+
},
51+
{
52+
id: 'limits',
53+
title: 'What stays in the Mac app',
54+
description: 'The browser edition has no terminal, runs no commands, and starts no local MCP servers. The agent can read, search, create, edit and delete files. For the rest, [get the Mac app](https://levelcode.ai/download).',
55+
media: { svg: 'media/walkthrough/updates.svg', altText: 'LevelCode for Mac' },
56+
},
57+
],
58+
};
59+
60+
/**
61+
* @param {any} pkg the desktop package.json
62+
* @param {{ entry?: string }} [opts]
63+
* @returns {any} a new object; `pkg` is not modified
64+
*/
65+
function toWebManifest(pkg, opts) {
66+
const out = JSON.parse(JSON.stringify(pkg));
67+
out.browser = (opts && opts.entry) || './extension.web.js';
68+
const c = out.contributes || (out.contributes = {});
69+
70+
if (Array.isArray(c.commands)) { c.commands = c.commands.filter((x) => !REMOVED_COMMANDS.has(x.command)); }
71+
for (const [where, items] of Object.entries(c.menus || {})) {
72+
c.menus[where] = items.filter((m) => !REMOVED_COMMANDS.has(m.command));
73+
}
74+
if (Array.isArray(c.keybindings)) {
75+
c.keybindings = c.keybindings.filter((k) => !REMOVED_COMMANDS.has(k.command));
76+
// Windows and Linux browsers keep Ctrl+Shift+I for developer tools, and the page never sees it.
77+
c.keybindings.push({ command: 'levelcode.ai.focus', key: 'ctrl+alt+i', when: 'isWeb && !isMac' });
78+
}
79+
if (c.configuration) {
80+
const sections = Array.isArray(c.configuration) ? c.configuration : [c.configuration];
81+
for (const s of sections) {
82+
for (const k of Object.keys(s.properties || {})) { if (REMOVED_SETTINGS.has(k)) { delete s.properties[k]; } }
83+
}
84+
}
85+
c.walkthroughs = [JSON.parse(JSON.stringify(WELCOME))];
86+
if (Array.isArray(out.activationEvents)) {
87+
out.activationEvents = out.activationEvents.filter((e) => !(e.startsWith('onCommand:') && REMOVED_COMMANDS.has(e.slice('onCommand:'.length))));
88+
}
89+
return out;
90+
}
91+
92+
module.exports = { toWebManifest, REMOVED_COMMANDS, REMOVED_SETTINGS, WELCOME };

‎web/ai-extension/shims/path.js‎

Lines changed: 66 additions & 20 deletions
Original file line numberDiff line numberDiff line change
@@ -58,17 +58,21 @@ const path = {
5858
return abs ? '/' + resolved : (resolved.length ? resolved : '.');
5959
},
6060

61+
// Node's scan, so odd inputs ('a//b' is 'a/', '//x' is '//') are answered as Node answers them.
6162
dirname(p) {
6263
assertPath(p);
6364
if (p.length === 0) { return '.'; }
64-
let end = p.length;
65-
while (end > 1 && p[end - 1] === '/') { end--; } // trailing slashes do not count
66-
const i = p.lastIndexOf('/', end - 1);
67-
if (i === -1) { return '.'; }
68-
if (i === 0) { return '/'; }
69-
let j = i;
70-
while (j > 1 && p[j - 1] === '/') { j--; } // collapse the slashes before the name
71-
return p.slice(0, j);
65+
const hasRoot = p[0] === '/';
66+
let end = -1;
67+
let matchedSlash = true;
68+
for (let i = p.length - 1; i >= 1; --i) {
69+
if (p[i] === '/') {
70+
if (!matchedSlash) { end = i; break; }
71+
} else { matchedSlash = false; }
72+
}
73+
if (end === -1) { return hasRoot ? '/' : '.'; }
74+
if (hasRoot && end === 1) { return '//'; }
75+
return p.slice(0, end);
7276
},
7377

7478
basename(p, ext) {
@@ -82,10 +86,27 @@ const path = {
8286
return base;
8387
},
8488

89+
// Node's own scan, so '..', '.', '.hidden', 'a.' and '..x' all come out as they do there.
8590
extname(p) {
86-
const base = path.basename(p);
87-
const i = base.lastIndexOf('.');
88-
return i <= 0 ? '' : base.slice(i);
91+
assertPath(p);
92+
let startDot = -1;
93+
let startPart = 0;
94+
let end = -1;
95+
let matchedSlash = true;
96+
let preDotState = 0; // 0: no char before the dot yet, 1: only dots, -1: some other char
97+
for (let i = p.length - 1; i >= 0; --i) {
98+
const ch = p[i];
99+
if (ch === '/') {
100+
if (!matchedSlash) { startPart = i + 1; break; }
101+
continue;
102+
}
103+
if (end === -1) { matchedSlash = false; end = i + 1; }
104+
if (ch === '.') {
105+
if (startDot === -1) { startDot = i; } else if (preDotState !== 1) { preDotState = 1; }
106+
} else if (startDot !== -1) { preDotState = -1; }
107+
}
108+
if (startDot === -1 || end === -1 || preDotState === 0 || (preDotState === 1 && startDot === end - 1 && startDot === startPart + 1)) { return ''; }
109+
return p.slice(startDot, end);
89110
},
90111

91112
relative(from, to) {
@@ -98,21 +119,46 @@ const path = {
98119
return [...f.slice(i).map(() => '..'), ...t.slice(i)].join('/');
99120
},
100121

122+
// Node's scan, as for extname.
101123
parse(p) {
102124
assertPath(p);
103-
const root = p[0] === '/' ? '/' : '';
104-
const base = path.basename(p);
105-
const ext = path.extname(p);
106-
let dir = path.dirname(p);
107-
if (dir === '.' && !p.startsWith('.')) { dir = ''; }
108-
return { root, dir, base, ext, name: ext ? base.slice(0, -ext.length) : base };
125+
const ret = { root: '', dir: '', base: '', ext: '', name: '' };
126+
if (p.length === 0) { return ret; }
127+
const isAbs = p[0] === '/';
128+
const start = isAbs ? 1 : 0;
129+
if (isAbs) { ret.root = '/'; }
130+
let startDot = -1, startPart = 0, end = -1, matchedSlash = true, preDotState = 0;
131+
for (let i = p.length - 1; i >= start; --i) {
132+
const ch = p[i];
133+
if (ch === '/') {
134+
if (!matchedSlash) { startPart = i + 1; break; }
135+
continue;
136+
}
137+
if (end === -1) { matchedSlash = false; end = i + 1; }
138+
if (ch === '.') {
139+
if (startDot === -1) { startDot = i; } else if (preDotState !== 1) { preDotState = 1; }
140+
} else if (startDot !== -1) { preDotState = -1; }
141+
}
142+
if (end !== -1) {
143+
const from = startPart === 0 && isAbs ? 1 : startPart;
144+
if (startDot === -1 || preDotState === 0 || (preDotState === 1 && startDot === end - 1 && startDot === startPart + 1)) {
145+
ret.base = ret.name = p.slice(from, end);
146+
} else {
147+
ret.name = p.slice(from, startDot);
148+
ret.base = p.slice(from, end);
149+
ret.ext = p.slice(startDot, end);
150+
}
151+
}
152+
if (startPart > 0) { ret.dir = p.slice(0, startPart - 1); } else if (isAbs) { ret.dir = '/'; }
153+
return ret;
109154
},
110155

111156
format(o) {
112-
const dir = o.dir || o.root || '';
113-
const base = o.base || (o.name || '') + (o.ext || '');
157+
const dir = o.dir || o.root;
158+
const ext = o.ext ? (o.ext[0] === '.' ? o.ext : '.' + o.ext) : '';
159+
const base = o.base || `${o.name || ''}${ext}`;
114160
if (!dir) { return base; }
115-
return dir === o.root ? dir + base : dir + '/' + base;
161+
return dir === o.root ? `${dir}${base}` : `${dir}/${base}`;
116162
},
117163
};
118164

‎web/serve.mjs‎

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -62,6 +62,10 @@ const server = http.createServer((req, res) => {
6262
'content-type': TYPES[path.extname(file)] || 'application/octet-stream',
6363
'content-length': st.size,
6464
'cache-control': 'no-cache',
65+
// The extension host and the webviews run on origins of their own and fetch these files.
66+
// They are public static assets and are never requested with credentials.
67+
'access-control-allow-origin': '*',
68+
'cross-origin-resource-policy': 'cross-origin',
6569
});
6670
if (req.method === 'HEAD') { res.end(); return; }
6771
fs.createReadStream(file).pipe(res);

‎web/test/cdp.mjs‎

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -74,6 +74,8 @@ export class Page {
7474
case 'Target.attachedToTarget':
7575
this.sessions.set(d.params.sessionId, { url: d.params.targetInfo.url, type: d.params.targetInfo.type, targetId: d.params.targetInfo.targetId });
7676
this.sendTo(d.params.sessionId, 'Runtime.enable').catch(() => {});
77+
this.sendTo(d.params.sessionId, 'Network.enable').catch(() => {});
78+
this.sendTo(d.params.sessionId, 'Log.enable').catch(() => {});
7779
this.sendTo(d.params.sessionId, 'Target.setAutoAttach', { autoAttach: true, waitForDebuggerOnStart: false, flatten: true }).catch(() => {});
7880
break;
7981
}

‎web/test/e2e.mjs‎

Lines changed: 14 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -21,7 +21,8 @@ const STATIC = path.resolve(args.static || process.env.LEVELCODE_WEB_STATIC || '
2121
const EXTS = path.resolve(args.extensions || process.env.LEVELCODE_WEB_EXTENSIONS || '');
2222
const SHOTS = args.shots ? path.resolve(args.shots) : null;
2323
const EDITOR_PORT = Number(args.port || 8801);
24-
const editorOrigin = `http://127.0.0.1:${EDITOR_PORT}`;
24+
// `localhost`, not 127.0.0.1: the extension host's own iframe policy admits https: and http://localhost:*.
25+
const editorOrigin = `http://localhost:${EDITOR_PORT}`;
2526
if (!fs.existsSync(STATIC) || !fs.existsSync(EXTS)) { console.error('usage: e2e.mjs --static <vscode-web dir> --extensions <dir>'); process.exit(2); }
2627
if (SHOTS) { fs.mkdirSync(SHOTS, { recursive: true }); }
2728

@@ -31,7 +32,8 @@ const shot = async (page, name) => { if (SHOTS) { await page.screenshot(path.joi
3132

3233
const stub = await startStub({ editorOrigin });
3334
const server = spawn(process.execPath, [path.join(HERE, '..', 'serve.mjs'), '--static', STATIC, '--extensions', EXTS,
34-
'--extension-names', args['extension-names'] || 'levelcode-ai,levelcode-web', '--account', stub.origin, '--port', String(EDITOR_PORT)], { stdio: 'inherit' });
35+
'--extension-names', args['extension-names'] || 'levelcode-ai,levelcode-web', '--account', stub.origin, '--port', String(EDITOR_PORT),
36+
...(args.product ? ['--product', args.product] : [])], { stdio: 'inherit' });
3537
let chrome;
3638
const finish = async (code) => { try { chrome && chrome.close(); } catch {} try { server.kill(); } catch {} try { await stub.close(); } catch {} process.exit(code); };
3739
process.on('SIGINT', () => finish(130));
@@ -90,7 +92,14 @@ try {
9092
await page.click(frame.x + inner.x, frame.y + inner.y);
9193
await sleep(400);
9294
};
93-
const scratchFile = (p) => page.eval(`new Promise((res, rej) => { const r = indexedDB.open('levelcode-scratch'); r.onsuccess = () => { const g = r.result.transaction('nodes').objectStore('nodes').get(${JSON.stringify(p)}); g.onsuccess = () => res(g.result ? new TextDecoder().decode(g.result.data) : null); g.onerror = () => rej(g.error); }; r.onerror = () => rej(r.error); })`);
95+
// With the extension host isolated on its own origin (--product with webEndpointUrlTemplate) the scratch
96+
// workspace's IndexedDB belongs to THAT origin and the page cannot open it; the agent's read_file can.
97+
const hardened = /webEndpointUrlTemplate/.test(args.product || '');
98+
const scratchFile = hardened ? (async (p) => {
99+
await ask('Please read the greeting');
100+
const t = lastToolResult();
101+
return /ERROR/.test(t) ? null : t;
102+
}) : (p) => page.eval(`new Promise((res, rej) => { const r = indexedDB.open('levelcode-scratch'); r.onsuccess = () => { const g = r.result.transaction('nodes').objectStore('nodes').get(${JSON.stringify(p)}); g.onsuccess = () => res(g.result ? new TextDecoder().decode(g.result.data) : null); g.onerror = () => rej(g.error); }; r.onerror = () => rej(r.error); })`);
94103

95104
/* 1. boot */
96105
await workbenchReady();
@@ -119,8 +128,8 @@ try {
119128
await clickInChat('[data-act="signin"]');
120129
await page.waitFor(() => stub.state.log.some((e) => e.path === '/api/levelcode/v1/auth/exchange'), { ms: 45000, label: 'code exchange' }).catch(() => null);
121130
check('the sign-in page accepted the editor callback and returned a code', stub.state.signIns >= 1);
122-
const exchanged = stub.state.log.some((e) => e.path === '/api/levelcode/v1/auth/exchange' && e.origin === editorOrigin);
123-
check('the editor exchanged the code from the editor origin (CORS answered)', exchanged);
131+
const exchangeOrigin = (stub.state.log.find((e) => e.path === '/api/levelcode/v1/auth/exchange' && e.method === 'POST') || {}).origin;
132+
check('the editor exchanged the code across origins (CORS answered)', !!exchangeOrigin, 'from ' + exchangeOrigin);
124133
await page.waitFor(() => stub.state.log.some((e) => e.path === '/api/levelcode/v1/account/profile' && e.auth), { ms: 20000, label: 'profile call' }).catch(() => null);
125134
check('the editor used its session token against the account API', stub.state.log.some((e) => e.path === '/api/levelcode/v1/account/profile' && e.auth));
126135
await shot(page, '2-signed-in');

‎web/test/stub-backend.mjs‎

Lines changed: 5 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -62,6 +62,8 @@ export function defaultModel(messages) {
6262
*/
6363
export async function startStub(o) {
6464
const editorOrigin = o.editorOrigin;
65+
// `v--<hash>` is the marker the editor puts on the label; the parent domain is the editor's own.
66+
const extHostOrigin = o.extHostOrigin || new RegExp('^' + (editorOrigin.startsWith('https') ? 'https' : 'http') + '://v--[a-z0-9]+\\.' + new URL(editorOrigin).hostname.replace(/\./g, '\\.') + '(:\\d+)?$');
6567
const model = o.model || defaultModel;
6668
const plan = o.plan || 'Pro';
6769
const codes = new Map(); // code -> { challenge, at }
@@ -75,9 +77,10 @@ export async function startStub(o) {
7577
const entry = { method: req.method, path: url.pathname, origin, auth: !!req.headers.authorization, at: Date.now() };
7678
log.push(entry);
7779

78-
// CORS: only the editor's origin, never credentials.
80+
// CORS: the editor's origin, or the origin of its isolated extension host (a per-session
81+
// `v--<hash>` subdomain) — never credentials.
7982
const cors = {};
80-
if (origin && origin === editorOrigin && url.pathname.startsWith('/api/levelcode/v1/')) {
83+
if (origin && (origin === editorOrigin || extHostOrigin.test(origin)) && url.pathname.startsWith('/api/levelcode/v1/')) {
8184
cors['access-control-allow-origin'] = origin;
8285
cors.vary = 'Origin';
8386
cors['access-control-expose-headers'] = '';

0 commit comments

Comments
 (0)