Skip to content

Commit 9a875a4

Browse files
committed
feat(web): LevelCode in your browser — the page, its entry, the scratch workspace and the browser build of levelcode-ai
A static Code-OSS web build plus three things of ours: - web/main.js: the embedder entry a static host needs (Code-OSS's own workbench.ts is written for its server). Same URL-callback and workspace-URL contracts; secrets sealed with a non-extractable AES-GCM key in IndexedDB; opens the scratch workspace when no folder is given. - web/workspace: the scratch workspace, a FileSystemProvider over IndexedDB shared by every tab. - web/ai-extension: levelcode-ai bundled for the web worker extension host by esbuild with small shims (path, os, crypto incl. a synchronous SHA-256, child_process that refuses, and an in-memory fs made durable in IndexedDB). The desktop extension stays plain JS. web/test/e2e.mjs drives a real headless Chrome: boot, sign in through the real PKCE flow and callback page against a stand-in backend that applies the same redirect and CORS rules, ask the agent to create a file, reload.
1 parent 4b48bbb commit 9a875a4

23 files changed

Lines changed: 2068 additions & 0 deletions

‎web/ai-extension/build.mjs‎

Lines changed: 116 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,116 @@
1+
#!/usr/bin/env node
2+
// Build levelcode-ai for the browser: ONE file the web extension host can evaluate, plus the
3+
// files the manifest points at. The desktop extension stays plain JS with no build step; this
4+
// bundler only exists for the browser edition and writes nothing into extensions/.
5+
//
6+
// node web/ai-extension/build.mjs --out <dir> --vscode <Code-OSS checkout> [--minify]
7+
//
8+
// Output (<dir>/levelcode-ai): package.json (+ "browser"), extension.web.js, media/, README.md
9+
import fs from 'node:fs';
10+
import path from 'node:path';
11+
import { createRequire } from 'node:module';
12+
import { fileURLToPath } from 'node:url';
13+
14+
const HERE = path.dirname(fileURLToPath(import.meta.url));
15+
const REPO = path.resolve(HERE, '..', '..');
16+
const SRC = path.join(REPO, 'extensions', 'levelcode-ai');
17+
18+
const args = Object.fromEntries(process.argv.slice(2).reduce((a, v, i, all) => {
19+
if (v.startsWith('--')) { a.push([v.slice(2), all[i + 1] && !all[i + 1].startsWith('--') ? all[i + 1] : 'true']); }
20+
return a;
21+
}, []));
22+
const outRoot = path.resolve(args.out || path.join(REPO, 'dist-web', 'extensions'));
23+
const vscodeDir = path.resolve(args.vscode || path.join(REPO, 'vscode'));
24+
const minify = args.minify === 'true';
25+
26+
/** Find esbuild and the `buffer` package in the Code-OSS checkout, which already depends on both. */
27+
function locate(name, bases) {
28+
for (const b of bases) {
29+
try { return createRequire(path.join(b, 'noop.js')).resolve(name); } catch { /* next */ }
30+
}
31+
return null;
32+
}
33+
const esbuildEntry = locate('esbuild', [path.join(vscodeDir, 'build'), path.join(vscodeDir, 'extensions'), vscodeDir, REPO]);
34+
if (!esbuildEntry) { console.error('esbuild not found under ' + vscodeDir + ' (run scripts/bootstrap.sh first)'); process.exit(2); }
35+
const esbuild = await import(esbuildEntry);
36+
const nodePaths = [path.join(vscodeDir, 'node_modules'), path.join(vscodeDir, 'build', 'node_modules')].filter((p) => fs.existsSync(p));
37+
if (!nodePaths.some((p) => fs.existsSync(path.join(p, 'buffer')))) { console.error('the "buffer" package is missing from ' + vscodeDir + '/node_modules'); process.exit(2); }
38+
39+
const outDir = path.join(outRoot, 'levelcode-ai');
40+
fs.rmSync(outDir, { recursive: true, force: true });
41+
fs.mkdirSync(outDir, { recursive: true });
42+
43+
/* ----- the extension's own files, embedded so synchronous reads keep working ------------------ */
44+
const TEXT = new Set(['.html', '.svg', '.md', '.json', '.css', '.js', '.txt']);
45+
function collect(rel, filter) {
46+
const out = {};
47+
const walk = (dir) => {
48+
for (const e of fs.readdirSync(path.join(SRC, dir), { withFileTypes: true })) {
49+
const r = path.posix.join(dir, e.name);
50+
if (e.isDirectory()) { walk(r); continue; }
51+
if (filter && !filter(r)) { continue; }
52+
const buf = fs.readFileSync(path.join(SRC, r));
53+
out[r] = TEXT.has(path.extname(r)) ? buf.toString('utf8') : { base64: buf.toString('base64') };
54+
}
55+
};
56+
if (fs.existsSync(path.join(SRC, rel))) { walk(rel); }
57+
return out;
58+
}
59+
const assets = Object.assign({},
60+
collect('media'),
61+
collect('skills'),
62+
collect('diagram', (r) => r.endsWith('.js')),
63+
);
64+
const generated = path.join(HERE, 'assets.generated.js');
65+
fs.writeFileSync(generated, '// GENERATED by build.mjs — do not edit, do not commit.\nmodule.exports = ' + JSON.stringify(assets) + ';\n');
66+
67+
/* ----- __dirname has no meaning in a bundle: point it at the mounted copy -------------------- */
68+
const dirnamePlugin = {
69+
name: 'levelcode-dirname',
70+
setup(b) {
71+
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' };
77+
});
78+
},
79+
};
80+
81+
const shim = (n) => path.join(HERE, 'shims', n + '.js');
82+
await esbuild.build({
83+
entryPoints: [path.join(HERE, 'entry.js')],
84+
outfile: path.join(outDir, 'extension.web.js'),
85+
bundle: true,
86+
platform: 'browser',
87+
format: 'cjs',
88+
target: 'es2022',
89+
external: ['vscode'],
90+
alias: {
91+
fs: shim('fs'), 'node:fs': shim('fs'),
92+
path: shim('path'), 'node:path': shim('path'),
93+
os: shim('os'), 'node:os': shim('os'),
94+
crypto: shim('crypto'), 'node:crypto': shim('crypto'),
95+
child_process: shim('child_process'), 'node:child_process': shim('child_process'),
96+
},
97+
inject: [path.join(HERE, 'inject.js')],
98+
define: { global: 'globalThis' },
99+
nodePaths,
100+
plugins: [dirnamePlugin],
101+
minify,
102+
sourcemap: minify ? false : 'inline',
103+
legalComments: 'none',
104+
logLevel: 'info',
105+
});
106+
fs.rmSync(generated, { force: true });
107+
108+
/* ----- 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';
111+
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)); } }
113+
fs.cpSync(path.join(SRC, 'media'), path.join(outDir, 'media'), { recursive: true });
114+
115+
const size = fs.statSync(path.join(outDir, 'extension.web.js')).size;
116+
console.log(`levelcode-ai (browser) -> ${outDir} extension.web.js ${(size / 1024).toFixed(0)} KB, ${Object.keys(assets).length} embedded files`);

‎web/ai-extension/entry.js‎

Lines changed: 26 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,26 @@
1+
// Entry of the browser build of levelcode-ai. The extension itself is unchanged; this wrapper
2+
// only does what a Node extension host does for it before `activate`: make the private file
3+
// system ready and put the extension's own resources where `ctx.extensionPath` says they are.
4+
'use strict';
5+
6+
// host.js reads this to know the code is running in the browser build (no shell, no ripgrep).
7+
globalThis.__LEVELCODE_BROWSER_HOST__ = true;
8+
9+
const fs = require('fs'); // aliased to ./shims/fs.js by build.mjs
10+
const assets = require('./assets.generated.js');
11+
const real = require('../../extensions/levelcode-ai/extension.js');
12+
13+
async function activate(context) {
14+
// Everything that reads a file synchronously needs the saved state in memory first.
15+
await fs.__levelcode.ready;
16+
fs.__levelcode.mountAssets(context.extensionPath, assets);
17+
globalThis.__lcExtPath = context.extensionPath; // stands in for __dirname (see build.mjs)
18+
return real.activate(context);
19+
}
20+
21+
async function deactivate() {
22+
try { if (real.deactivate) { await real.deactivate(); } }
23+
finally { await fs.__levelcode.flush(); }
24+
}
25+
26+
module.exports = { activate, deactivate };

‎web/ai-extension/inject.js‎

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,4 @@
1+
// What esbuild substitutes for the Node globals the extension's sources mention.
2+
import proc from './shims/process.js';
3+
import { Buffer } from 'buffer';
4+
export { proc as process, Buffer };
Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,21 @@
1+
// `child_process` for the browser build: there is no machine to run a program on. Every entry
2+
// point fails the way Node fails for an unsupported syscall, so callers that already handle a
3+
// spawn error (all of them do: it is what a missing ripgrep looks like) take their normal path.
4+
// The agent's shell tools are not offered at all in this build (host.js `caps.shell`).
5+
'use strict';
6+
function unavailable(name) {
7+
return function () {
8+
const e = new Error('child_process.' + name + ' is not available in LevelCode in the browser');
9+
e.code = 'ENOSYS';
10+
throw e;
11+
};
12+
}
13+
module.exports = {
14+
spawn: unavailable('spawn'),
15+
spawnSync: unavailable('spawnSync'),
16+
exec: unavailable('exec'),
17+
execSync: unavailable('execSync'),
18+
execFile: unavailable('execFile'),
19+
execFileSync: unavailable('execFileSync'),
20+
fork: unavailable('fork'),
21+
};

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

Lines changed: 104 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,104 @@
1+
// `crypto` for the browser build: the three things the extension uses.
2+
//
3+
// randomBytes(n) from the platform CSPRNG
4+
// randomUUID()
5+
// createHash('sha256').update(x).digest(enc) SYNCHRONOUS, which WebCrypto is not, so the digest is
6+
// computed here (FIPS 180-4). PKCE (extension.js) and the
7+
// image store both need it inline.
8+
'use strict';
9+
const { Buffer } = require('buffer');
10+
11+
const K = new Uint32Array([
12+
0x428a2f98, 0x71374491, 0xb5c0fbcf, 0xe9b5dba5, 0x3956c25b, 0x59f111f1, 0x923f82a4, 0xab1c5ed5,
13+
0xd807aa98, 0x12835b01, 0x243185be, 0x550c7dc3, 0x72be5d74, 0x80deb1fe, 0x9bdc06a7, 0xc19bf174,
14+
0xe49b69c1, 0xefbe4786, 0x0fc19dc6, 0x240ca1cc, 0x2de92c6f, 0x4a7484aa, 0x5cb0a9dc, 0x76f988da,
15+
0x983e5152, 0xa831c66d, 0xb00327c8, 0xbf597fc7, 0xc6e00bf3, 0xd5a79147, 0x06ca6351, 0x14292967,
16+
0x27b70a85, 0x2e1b2138, 0x4d2c6dfc, 0x53380d13, 0x650a7354, 0x766a0abb, 0x81c2c92e, 0x92722c85,
17+
0xa2bfe8a1, 0xa81a664b, 0xc24b8b70, 0xc76c51a3, 0xd192e819, 0xd6990624, 0xf40e3585, 0x106aa070,
18+
0x19a4c116, 0x1e376c08, 0x2748774c, 0x34b0bcb5, 0x391c0cb3, 0x4ed8aa4a, 0x5b9cca4f, 0x682e6ff3,
19+
0x748f82ee, 0x78a5636f, 0x84c87814, 0x8cc70208, 0x90befffa, 0xa4506ceb, 0xbef9a3f7, 0xc67178f2,
20+
]);
21+
22+
class Sha256 {
23+
constructor() {
24+
this.h = new Uint32Array([0x6a09e667, 0xbb67ae85, 0x3c6ef372, 0xa54ff53a, 0x510e527f, 0x9b05688c, 0x1f83d9ab, 0x5be0cd19]);
25+
this.block = new Uint8Array(64);
26+
this.blockLen = 0;
27+
this.total = 0;
28+
this.w = new Uint32Array(64);
29+
this.done = false;
30+
}
31+
update(data, encoding) {
32+
if (this.done) { throw new Error('Digest already called'); }
33+
const bytes = typeof data === 'string' ? Buffer.from(data, encoding || 'utf8') : data;
34+
for (let i = 0; i < bytes.length; i++) {
35+
this.block[this.blockLen++] = bytes[i];
36+
if (this.blockLen === 64) { this.compress(); this.blockLen = 0; }
37+
}
38+
this.total += bytes.length;
39+
return this;
40+
}
41+
compress() {
42+
const w = this.w;
43+
const b = this.block;
44+
for (let i = 0; i < 16; i++) { w[i] = (b[i * 4] << 24) | (b[i * 4 + 1] << 16) | (b[i * 4 + 2] << 8) | b[i * 4 + 3]; }
45+
for (let i = 16; i < 64; i++) {
46+
const a = w[i - 15], c = w[i - 2];
47+
const s0 = ((a >>> 7) | (a << 25)) ^ ((a >>> 18) | (a << 14)) ^ (a >>> 3);
48+
const s1 = ((c >>> 17) | (c << 15)) ^ ((c >>> 19) | (c << 13)) ^ (c >>> 10);
49+
w[i] = (w[i - 16] + s0 + w[i - 7] + s1) | 0;
50+
}
51+
let [a, b2, c, d, e, f, g, h] = this.h;
52+
for (let i = 0; i < 64; i++) {
53+
const S1 = ((e >>> 6) | (e << 26)) ^ ((e >>> 11) | (e << 21)) ^ ((e >>> 25) | (e << 7));
54+
const ch = (e & f) ^ (~e & g);
55+
const t1 = (h + S1 + ch + K[i] + w[i]) | 0;
56+
const S0 = ((a >>> 2) | (a << 30)) ^ ((a >>> 13) | (a << 19)) ^ ((a >>> 22) | (a << 10));
57+
const maj = (a & b2) ^ (a & c) ^ (b2 & c);
58+
const t2 = (S0 + maj) | 0;
59+
h = g; g = f; f = e; e = (d + t1) | 0; d = c; c = b2; b2 = a; a = (t1 + t2) | 0;
60+
}
61+
const H = this.h;
62+
H[0] = (H[0] + a) | 0; H[1] = (H[1] + b2) | 0; H[2] = (H[2] + c) | 0; H[3] = (H[3] + d) | 0;
63+
H[4] = (H[4] + e) | 0; H[5] = (H[5] + f) | 0; H[6] = (H[6] + g) | 0; H[7] = (H[7] + h) | 0;
64+
}
65+
digest(encoding) {
66+
if (this.done) { throw new Error('Digest already called'); }
67+
this.done = true;
68+
const bitLen = this.total * 8;
69+
this.block[this.blockLen++] = 0x80;
70+
if (this.blockLen > 56) {
71+
this.block.fill(0, this.blockLen);
72+
this.compress();
73+
this.blockLen = 0;
74+
}
75+
this.block.fill(0, this.blockLen, 56);
76+
const hi = Math.floor(bitLen / 0x100000000);
77+
const lo = bitLen >>> 0;
78+
for (let i = 0; i < 4; i++) { this.block[56 + i] = (hi >>> (24 - i * 8)) & 0xff; this.block[60 + i] = (lo >>> (24 - i * 8)) & 0xff; }
79+
this.compress();
80+
const out = Buffer.alloc(32);
81+
for (let i = 0; i < 8; i++) { out.writeUInt32BE(this.h[i] >>> 0, i * 4); }
82+
return encoding ? out.toString(encoding) : out;
83+
}
84+
}
85+
86+
function createHash(algorithm) {
87+
const name = String(algorithm).toLowerCase();
88+
if (name !== 'sha256') {
89+
const e = new Error('Digest method not supported: ' + algorithm);
90+
e.code = 'ERR_CRYPTO_INVALID_DIGEST';
91+
throw e;
92+
}
93+
return new Sha256();
94+
}
95+
96+
function randomBytes(size) {
97+
const out = Buffer.alloc(size);
98+
globalThis.crypto.getRandomValues(out);
99+
return out;
100+
}
101+
102+
function randomUUID() { return globalThis.crypto.randomUUID(); }
103+
104+
module.exports = { createHash, randomBytes, randomUUID };

0 commit comments

Comments
 (0)