From 4b48bbb6c4b2ab6de6ca8208e0fe84c18a5c7150 Mon Sep 17 00:00:00 2001 From: Sergii Demianchuk Date: Fri, 9 Oct 2026 18:37:25 -0400 Subject: [PATCH 01/17] feat(host): the extension asks one module what the machine it runs on can do The agent read and wrote the workspace with synchronous fs and assumed it could start programs. LevelCode is about to run in a browser tab, where the project is whatever the editor's file system provider says it is and there is no shell. host.js is the one place that difference lives. On the desktop every function does what the call site did before, behind await; capabilities (shell, mcpStdio, ripgrep) are all true. Where a capability is missing the tool is withheld rather than offered and left to fail, the system prompt says what is missing, and content search falls back to a bounded scan. resolveWorkspacePath stays (the suites pin it); resolveWorkspacePathAsync is the same resolution for hosts that can only ask asynchronously. A workspace rooted at '/' now contains its files. All 63 suites pass on macOS. --- extensions/levelcode-ai/agent.js | 152 ++++++++++++---- extensions/levelcode-ai/extension.js | 51 +++++- extensions/levelcode-ai/host.js | 162 ++++++++++++++++++ extensions/levelcode-ai/reviewSession.js | 59 ++++--- .../levelcode-ai/test/diagramHost.test.js | 7 +- 5 files changed, 365 insertions(+), 66 deletions(-) create mode 100644 extensions/levelcode-ai/host.js diff --git a/extensions/levelcode-ai/agent.js b/extensions/levelcode-ai/agent.js index de92aac..8568893 100644 --- a/extensions/levelcode-ai/agent.js +++ b/extensions/levelcode-ai/agent.js @@ -14,6 +14,7 @@ const fs = require('fs'); const path = require('path'); const cp = require('child_process'); const os = require('os'); // MCP servers need a cwd even when no folder is open +const host = require('./host'); // the machine we run on: workspace I/O is async here, and capabilities differ (browser) const providers = require('./providers/index'); const { formatVerifyFeedback, verifyOutcome, looksUnrunnable, sniffPort, sniffPreviewUrl, looksReady } = require('./verify'); const { classifyCommand, dangerLabel } = require('./commandSafety'); @@ -24,7 +25,7 @@ const { connectAll, getServer } = require('./mcpClient'); const diagramTool = require('./diagram/tool'); const diagramRepair = require('./diagram/repair'); -const SYSTEM_BASE = [ +const SYSTEM_LINES = [ "You are LevelCode's built-in autonomous coding agent. You accomplish the user's goal in their", 'workspace using the provided tools. Rules:', '- Be DECISIVE and FAST. Read only the file you are changing (plus at most 1 other if truly needed), then ACT.', @@ -44,7 +45,24 @@ const SYSTEM_BASE = [ '- You have SKILLS — short expert playbooks for common task types, listed under "Available skills" below with a name and a one-line description. When the user\'s goal clearly matches a skill\'s description (e.g. reviewing a diff, fixing one reported bug, writing tests), call use_skill with that exact name FIRST, before other tools, and follow the steps it returns. Pick at most one; if nothing clearly fits, just proceed normally. Do not mention skills to the user.', '- When the goal is finished, end with a line starting with "Done:" — one crisp sentence of what was accomplished. For a substantial run (several files, a tricky diagnosis, or a decision the user should know about), follow that line with a SHORT wrap-up in plain lines: what landed, how you verified it, anything left open or worth pushing on. For a trivial goal the "Done:" line alone is right. Then STOP (no more tools).', '- After you finish, your work is verified automatically: editor diagnostics for the files you changed (plus a verify command, if the user configured one) are checked. If problems are found you will get them back as a follow-up message — fix the ones you introduced, then finish again with "Done:". If a reported problem is clearly pre-existing and unrelated to the goal, do not chase it: note it in one line and finish.' -].join('\n'); +]; +const SYSTEM_BASE = SYSTEM_LINES.join('\n'); + +// The same prompt for a host that cannot run programs (LevelCode in the browser). Derived from +// SYSTEM_LINES rather than written out again so the two cannot drift: lines about commands go, the +// two mixed lines lose their command sentences, and one line says plainly what is missing. +const BROWSER_NOTE = '- You are running in LevelCode in the user\'s browser. There is no terminal: you cannot run commands, tests, installs or servers, and there is no run_command tool. Read, search, create, edit and delete files, and when something must be run, say so in one line and tell the user the exact command to run on their own computer.'; +function browserLines(lines) { + return lines + .filter((l) => !/run_command background:true/.test(l)) + .map((l) => l + .replace(' Only run_command still needs approval; if the user skips a command, adapt or stop.', '') + .replace('EVERY run_command, read_file and search MUST include', 'EVERY read_file and search MUST include') + .replace(' ("Run the extension unit tests", "Find the insertion point in section 10", "Read the runAgent call site")', ' ("Find the insertion point in section 10", "Read the runAgent call site")') + .replace(' run_command accepts an optional "folder" to pick which folder it runs in.', '')) + .concat([BROWSER_NOTE]); +} +const SYSTEM_BASE_HOST = host.caps.shell ? SYSTEM_BASE : browserLines(SYSTEM_LINES).join('\n'); const TOOLS = [ { name: 'list_files', description: 'List workspace files (optional glob like "**/*.js"). Excludes node_modules/.git/build dirs.', input_schema: { type: 'object', properties: { glob: { type: 'string' } } } }, @@ -62,14 +80,14 @@ const TOOLS = [ // Rough token estimates (chars/4) for the static prompt segments, so the context popover can break // down "what's filling the window" — system + tools are sent on every request, the rest is messages. -const SYSTEM_TOKENS_EST = Math.round(SYSTEM_BASE.length / 4); +const SYSTEM_TOKENS_EST = Math.round(SYSTEM_BASE_HOST.length / 4); /** Append an "Available skills" name+description menu to the base prompt. Bodies NEVER go here — * progressive disclosure means only name+description are ever in the prompt (~100 words for a dozen). */ function buildSystem(menu) { - if (!menu || !menu.length) { return SYSTEM_BASE; } + if (!menu || !menu.length) { return SYSTEM_BASE_HOST; } const list = menu.map((s) => '- ' + s.name + ': ' + s.description).join('\n'); - return SYSTEM_BASE + '\n\nAvailable skills (call use_skill with the name):\n' + list; + return SYSTEM_BASE_HOST + '\n\nAvailable skills (call use_skill with the name):\n' + list; } // The tools that resolve a PATH or a CWD against the workspace root. With no folder open they have // nothing to resolve against, so they are withheld from the model rather than offered and left to fail @@ -87,7 +105,12 @@ const NEEDS_ROOT = new Set([ ]); const PORTABLE_TOOLS = TOOLS.filter((t) => !NEEDS_ROOT.has(t.name)); -const TOOLS_TOKENS_EST = Math.round(JSON.stringify(TOOLS).length / 4); +// A host that cannot start programs (LevelCode in the browser) never offers the shell tools: a tool +// that is present but always errors is worse than one that is absent. The tool list is narrowed where +// a run assembles it (see `withheld` in runAgent); this is the estimate for the list that is sent. +// PORTABLE_TOOLS never contains them, since both are in NEEDS_ROOT. +const NEEDS_SHELL = new Set(['run_command', 'read_command_output']); +const TOOLS_TOKENS_EST = Math.round(JSON.stringify(host.caps.shell ? TOOLS : TOOLS.filter((t) => !NEEDS_SHELL.has(t.name))).length / 4); // The same estimate for the rootless list, and it has to exist separately rather than be derived at // call time: the plain path deliberately never re-stringifies (see toolsTokensEst below), so without a // second constant a rootless run reports the FULL schema cost for a list it never sent — about 1000 @@ -116,6 +139,7 @@ function rgPath() { return cands.find((c) => { try { return fs.existsSync(c); } catch { return false; } }) || null; } function rgSearch(term, cwd) { + if (!host.caps.ripgrep) { return scanSearch(term, cwd); } return new Promise((resolve) => { const bin = rgPath(); if (!bin || !cwd) { resolve(''); return; } @@ -131,6 +155,48 @@ function rgSearch(term, cwd) { }); } +/** + * The same search without ripgrep: list the workspace's files and scan them. Output matches rg's + * (`./path:line:text`, case-insensitive literal, 20 matches per file, files over 1 MB skipped, capped + * at 6000 characters) so the callers that prefix folder names and the model's habits need no change. + * Used where there is no binary to run (LevelCode in the browser). + */ +async function scanSearch(term, root) { + if (!term || !root) { return ''; } + const needle = term.toLowerCase(); + const out = []; + let size = 0; + let uris = []; + try { uris = await host.findFiles('**/*', FILE_EXCLUDES, 2000); } catch { return ''; } + const rootPrefix = root.endsWith('/') ? root : root + '/'; + for (const uri of uris) { + if (size >= 6000) { break; } + const abs = uri.path; + if (abs !== root && !abs.startsWith(rootPrefix)) { continue; } // another workspace folder's file + let text; + try { + const st = await host.stat(abs); + if (!st || !st.isFile || st.size > 1024 * 1024) { continue; } + const bytes = await host.readBytes(abs); + if (bytes.subarray(0, 8000).includes(0)) { continue; } // binary, as rg would skip it + text = new TextDecoder('utf-8').decode(bytes); + } catch { continue; } + const rel = './' + abs.slice(rootPrefix.length); + let hits = 0; + const lines = text.split('\n'); + for (let i = 0; i < lines.length && hits < 20; i++) { + if (lines[i].toLowerCase().includes(needle)) { + const line = rel + ':' + (i + 1) + ':' + lines[i].replace(/\r$/, ''); + out.push(line); + size += line.length + 1; + hits++; + if (size >= 6000) { break; } + } + } + } + return out.join('\n').slice(0, 6000); +} + // ---- readable line diff ---------------------------------------------------- /** LCS diff of two small line arrays → [{type:'ctx'|'add'|'del', text}]. */ function lcsDiff(a, b) { @@ -185,7 +251,8 @@ function workspaceRoot() { } function safeJoin(root, rel) { const pth = path.resolve(root, rel); - if (pth !== root && !pth.startsWith(root + path.sep)) { return null; } + const prefix = root.endsWith(path.sep) ? root : root + path.sep; // a root of "/" is its own prefix + if (pth !== root && !pth.startsWith(prefix)) { return null; } return pth; } /** Resolve a model-supplied workspace-relative path to an absolute path, multi-root aware. @@ -197,6 +264,22 @@ function safeJoin(root, rel) { * the primary folder, then every other folder. Returns null if nowhere. * mustExist=false (create): the folder-name prefix targets that folder; else the primary. */ function resolveWorkspacePath(rel, opts) { + const r = resolveCandidates(rel, opts); + if (!r) { return null; } + if (!r.mustExist) { return r.create; } + for (const c of r.candidates) { if (c && fs.existsSync(c)) { return c; } } + return null; +} +/** The same resolution for a host that can only ask whether a file exists asynchronously. */ +async function resolveWorkspacePathAsync(rel, opts) { + const r = resolveCandidates(rel, opts); + if (!r) { return null; } + if (!r.mustExist) { return r.create; } + for (const c of r.candidates) { if (c && await host.exists(c)) { return c; } } + return null; +} +/** Where `rel` could be: the path to create it at, and the paths that count as "exists", in order. */ +function resolveCandidates(rel, opts) { const mustExist = !!(opts && opts.mustExist); const folders = workspaceFolderList(); if (!folders.length) { return null; } @@ -205,11 +288,9 @@ function resolveWorkspacePath(rel, opts) { const named = folders.length > 1 ? folders.find((f) => f.name === seg) : null; const namedAbs = named ? safeJoin(named.root, rel.slice(seg.length).replace(/^[\\/]+/, '')) : null; const primaryAbs = safeJoin(folders[0].root, rel); - if (!mustExist) { return namedAbs || primaryAbs; } const candidates = [namedAbs, primaryAbs]; for (const f of folders.slice(1)) { candidates.push(safeJoin(f.root, rel)); } - for (const c of candidates) { if (c && fs.existsSync(c)) { return c; } } - return null; + return { mustExist, create: namedAbs || primaryAbs, candidates }; } /** "file not found" help for the model — names the folders it can address in a multi-root workspace. */ function whereHint() { @@ -259,9 +340,7 @@ function inputPreview(input, redact) { } /** Cheap binary sniff — a NUL byte in the first 8KB means it isn't text we should round-trip as UTF-8. */ -function isBinaryFile(abs) { - try { const buf = fs.readFileSync(abs); const n = Math.min(buf.length, 8000); for (let i = 0; i < n; i++) { if (buf[i] === 0) { return true; } } return false; } catch { return false; } -} +function isBinaryFile(abs) { return host.isBinary(abs); } // Run a shell command, STREAMING its output: onChunk(text, 'stdout'|'stderr') fires as it runs (live // terminal in the chat), onExit(code, ms, how) at the end. onStart(child, stop) exposes a stop() that @@ -333,10 +412,10 @@ async function runTool(tu, ctx) { // label/path ride alongside the legacy `text` so the chat can title this action with the // model's own words ("Read the runAgent call site") and still attribute it to a file. ctx.post({ type: 'agentTool', icon: 'file', text: 'read ' + input.path, label: input.explanation || '', path: input.path, kind: 'read' }); - const abs = resolveWorkspacePath(input.path || '', { mustExist: true }); + const abs = await resolveWorkspacePathAsync(input.path || '', { mustExist: true }); if (!abs) { return 'ERROR: file not found: ' + input.path + whereHint(); } - if (isBinaryFile(abs)) { return 'ERROR: ' + input.path + ' looks like a binary file — not reading it as text.'; } - let body = fs.readFileSync(abs, 'utf8').replace(/^/, ''); // drop BOM so old_str matches cleanly + if (await isBinaryFile(abs)) { return 'ERROR: ' + input.path + ' looks like a binary file — not reading it as text.'; } + let body = (await host.readText(abs)).replace(/^/, ''); // drop BOM so old_str matches cleanly if (body.length > 100 * 1024) { body = body.slice(0, 100 * 1024) + '\n…(truncated)…'; } return body; } @@ -362,10 +441,10 @@ async function runTool(tu, ctx) { return 'Plan updated (' + done + '/' + todos.length + ' done).'; } if (tu.name === 'edit_file') { - const abs = resolveWorkspacePath(input.path || '', { mustExist: true }); + const abs = await resolveWorkspacePathAsync(input.path || '', { mustExist: true }); if (!abs) { return 'ERROR: file not found: ' + input.path + whereHint() + ' (use write_file to create it)'; } - if (isBinaryFile(abs)) { return 'ERROR: ' + input.path + ' looks like a binary file — refusing to edit it as text.'; } - const cur = fs.readFileSync(abs, 'utf8'); + if (await isBinaryFile(abs)) { return 'ERROR: ' + input.path + ' looks like a binary file — refusing to edit it as text.'; } + const cur = await host.readText(abs); const oldStr = String(input.old_str || ''); if (!oldStr) { return 'ERROR: old_str is empty.'; } const res = applyStringEdit(cur, oldStr, String(input.new_str || '')); @@ -379,12 +458,12 @@ async function runTool(tu, ctx) { if (tu.name === 'write_file') { const abs = resolveWorkspacePath(input.path || ''); if (!abs) { return 'ERROR: path is outside the workspace' + whereHint(); } - const existed = fs.existsSync(abs); + const existed = await host.exists(abs); let newStr = String(input.content || ''); if (existed) { - if (isBinaryFile(abs)) { return 'ERROR: ' + input.path + ' looks like a binary file — refusing to overwrite it.'; } - try { if (fs.statSync(abs).size > 100 * 1024) { return 'ERROR: ' + input.path + ' is large (>100KB) and you only saw the first 100KB on read. Use edit_file for targeted changes — a full write_file risks dropping content you did not see.'; } } catch { /* */ } - const raw = fs.readFileSync(abs, 'utf8'); const eol = raw.includes('\r\n') ? '\r\n' : '\n'; newStr = newStr.replace(/\r\n/g, '\n').replace(/\n/g, eol); + if (await isBinaryFile(abs)) { return 'ERROR: ' + input.path + ' looks like a binary file — refusing to overwrite it.'; } + try { const st = await host.stat(abs); if (st && st.size > 100 * 1024) { return 'ERROR: ' + input.path + ' is large (>100KB) and you only saw the first 100KB on read. Use edit_file for targeted changes — a full write_file risks dropping content you did not see.'; } } catch { /* */ } + const raw = await host.readText(abs); const eol = raw.includes('\r\n') ? '\r\n' : '\n'; newStr = newStr.replace(/\r\n/g, '\n').replace(/\n/g, eol); } const ok = await ctx.applyEdit({ path: input.path, exists: existed, proposed: newStr }); if (!ok) { return 'ERROR: could not write ' + input.path + ' (path may be read-only or in conflict).'; } @@ -393,9 +472,9 @@ async function runTool(tu, ctx) { return 'Applied edit to ' + input.path + ' (pending the user\'s Keep/Undo review).'; } if (tu.name === 'delete_file') { - const abs = resolveWorkspacePath(input.path || '', { mustExist: true }); + const abs = await resolveWorkspacePathAsync(input.path || '', { mustExist: true }); if (!abs) { return 'ERROR: file not found: ' + input.path + whereHint(); } - try { if (fs.statSync(abs).isDirectory()) { return 'ERROR: ' + input.path + ' is a directory — delete_file removes a single file.'; } } catch { /* */ } + try { const st = await host.stat(abs); if (st && st.isDirectory) { return 'ERROR: ' + input.path + ' is a directory — delete_file removes a single file.'; } } catch { /* */ } // Deletion is in the autopilot danger set — ask even when autopilot runs everything else silently. // (In manual mode nothing gates here: the edit is applied and reviewed with Keep/Undo as before.) if (ctx.autopilot && typeof ctx.approve === 'function') { @@ -678,12 +757,15 @@ function resetContextAnnounce() { lastContextSig = ''; } async function setupMcp(ctx, wsFolders, dbg) { const empty = { tools: [], routes: null }; const cfg = ctx.mcp || {}; + // MCP servers here are local processes spoken to over stdio. A host that cannot start programs has + // none to offer; saying nothing is right, since nothing is configured that it could have run. + if (!host.caps.mcpStdio) { return empty; } try { - const { servers, problems } = loadServerConfig({ + const { servers, problems } = await host.withReads((readFile) => loadServerConfig({ settings: cfg.servers, folders: wsFolders, - readFile: (abs) => { try { return fs.readFileSync(abs, 'utf8'); } catch { return null; } } - }); + readFile + })); for (const p of problems) { dbg('mcp.config', p); } if (!servers.length) { return empty; } @@ -780,7 +862,7 @@ async function runAgent(ctx) { // Multi-root: name every workspace folder so the model addresses them by prefix from turn one. const wsFolders = workspaceFolderList(); const multiRootNote = wsFolders.length > 1 - ? '\n\nWorkspace folders (multi-root — prefix paths with the folder name): ' + wsFolders.map((f) => f.name).join(', ') + '. The first folder ("' + wsFolders[0].name + '") is the default for unprefixed paths and run_command.' + ? '\n\nWorkspace folders (multi-root — prefix paths with the folder name): ' + wsFolders.map((f) => f.name).join(', ') + '. The first folder ("' + wsFolders[0].name + '") is the default for unprefixed paths' + (host.caps.shell ? ' and run_command.' : '.') : ''; // Rootless: say so plainly. Without this the model sees a tool list with no read_file and improvises — // answering about files it cannot see, or apologising for a limit it cannot name. Telling it WHY the @@ -796,13 +878,13 @@ async function runAgent(ctx) { // Autopilot: act decisively and self-verify rather than pausing. Commands run without approval (the // host still gates the danger set — deletion, sudo, force-push, remote|shell, publish, system writes), // so the model should lean on verification, not on asking, when it's unsure. - const autopilotNote = ctx.autopilot + const autopilotNote = ctx.autopilot && host.caps.shell ? '\n\nAUTOPILOT IS ON. Work end-to-end without pausing for confirmation. Your run_command calls execute immediately (only irreversible ones — deleting files, sudo, force-push, piping a remote script to a shell, publishing — still ask the user). Do NOT call ask_user for anything you can reasonably decide; pick a sensible default and proceed. When you are unsure whether a change is correct, do not stop to ask — verify it: run the build/tests/linters via run_command and read editor diagnostics, then fix and re-verify until clean, and only then move on. Prefer doing and checking over asking.' : ''; // Project rules: fold a repo's own AGENTS.md (or CLAUDE.md / .cursorrules) into the cached system // block so the agent follows the project's conventions from turn one. Read once per run — an edit is // picked up on the next run. - const rules = loadProjectRules(wsFolders, (abs) => { try { return fs.readFileSync(abs, 'utf8'); } catch { return null; } }); + const rules = await host.withReads((readFile) => loadProjectRules(wsFolders, readFile)); // Project memory: a tight, verify-first digest of what earlier sessions achieved (built by extension.js // from the per-project journal). Rides the SAME cached-system channel as project rules — always-on but // small — so a new session's first reply is continuous, not amnesiac. It is untrusted context like the @@ -859,6 +941,8 @@ async function runAgent(ctx) { if (ctx.recallSessions) { extras.push(RECALL_TOOL); } if (rich) { extras.push(diagramTool.RENDER_DIAGRAM); if (ctx.diagramsStubbed) { extras.push(diagramTool.GET_DIAGRAM); } } if (extras.length) { tools = tools.concat(extras); } + // Withhold what this host cannot do: no shell, no run_command, no read_command_output. + if (!host.caps.shell) { tools = tools.filter((t) => !NEEDS_SHELL.has(t.name)); } const baseTools = extras.length ? builtins.concat(extras) : builtins; // built-ins + extras; MCP is the rest // Recomputed only when MCP or a host-gated extra actually contributed tools, so the plain path keeps the module // constant and pays nothing for a feature it isn't using — but there are now TWO plain paths, and the @@ -904,7 +988,7 @@ async function runAgent(ctx) { let feedbackUsed = 0, verifySeq = 0; async function runVerifyOnce() { const id = 'verify-' + (++verifySeq); - const cmd = ((ctx.verify && ctx.verify.command) || '').trim(); + const cmd = host.caps.shell ? ((ctx.verify && ctx.verify.command) || '').trim() : ''; ctx.post({ type: 'verifyRun', id, command: cmd }); let ran = false, exitCode = 0, cmdTail = ''; if (cmd) { @@ -929,7 +1013,7 @@ async function runAgent(ctx) { if (!v || !v.enabled) { return false; } if (ctx.signal.aborted) { return false; } if (!(ctx.editCount > 0)) { return false; } // no edits this run → nothing to verify - const cmd = (v.command || '').trim(); + const cmd = host.caps.shell ? (v.command || '').trim() : ''; const touchedCount = ctx.getTouchedUris ? ctx.getTouchedUris().length : 0; if (!cmd && !touchedCount) { return false; } // nothing to check const max = Math.max(0, v.maxRounds || 0); @@ -1174,4 +1258,4 @@ async function runAgent(ctx) { } } -module.exports = { resetContextAnnounce, runAgent, makeDiff, resolveWorkspacePath, saysDone }; +module.exports = { resetContextAnnounce, runAgent, makeDiff, resolveWorkspacePath, resolveWorkspacePathAsync, saysDone }; diff --git a/extensions/levelcode-ai/extension.js b/extensions/levelcode-ai/extension.js index b4e7e13..4c2bc6f 100644 --- a/extensions/levelcode-ai/extension.js +++ b/extensions/levelcode-ai/extension.js @@ -14,6 +14,7 @@ const fs = require('fs'); const os = require('os'); const cp = require('child_process'); const crypto = require('crypto'); +const host = require('./host'); // workspace I/O + what this machine can do (browser vs desktop) const providers = require('./providers/index'); const catalog = require('./providers/catalog'); const { resolveGateway } = require('./providers/gateway'); @@ -919,6 +920,36 @@ function rgFiles(term, cwd) { }); } +/** + * Files whose content contains each term, for every term at once. ripgrep where the editor has one; + * a bounded scan of the workspace's text files where it does not (LevelCode in the browser). Returns + * one array of absolute paths per term, in order. + */ +async function contentHits(terms, dir) { + if (host.caps.ripgrep) { return Promise.all(terms.map((kw) => rgFiles(kw, dir))); } + const hits = terms.map(() => []); + if (!dir) { return hits; } + const prefix = dir.endsWith('/') ? dir : dir + '/'; + let uris = []; + try { uris = await host.findFiles('**/*', '{**/node_modules/**,**/.git/**,**/out/**,**/dist/**,**/*.map,**/*.min.*}', 400); } catch { return hits; } + const needles = terms.map((t) => t.toLowerCase()); + const deadline = Date.now() + 4000; // the same budget rgFiles gives ripgrep + for (const uri of uris) { + if (Date.now() > deadline) { break; } + const abs = uri.path; + if (!abs.startsWith(prefix)) { continue; } + try { + const st = await host.stat(abs); + if (!st || !st.isFile || st.size > 1024 * 1024) { continue; } + const bytes = await host.readBytes(abs); + if (bytes.subarray(0, 8000).includes(0)) { continue; } + const text = new TextDecoder('utf-8').decode(bytes).toLowerCase(); + needles.forEach((n, i) => { if (text.includes(n)) { hits[i].push(abs); } }); + } catch { /* unreadable: not a hit */ } + } + return hits; +} + /** Scope retrieval to the active file's top-level sub-project, so unrelated sibling trees * (e.g. a vendored source dump) don't pollute results. Returns the search dir + rel prefix. */ function activeProjectScope() { @@ -926,14 +957,17 @@ function activeProjectScope() { const fallback = { dir: folders.length ? folders[0].uri.fsPath : '', prefix: '' }; if (!aiConfig().get('chat.scopeToActiveProject', true)) { return fallback; } const ed = vscode.window.activeTextEditor; - if (!ed || ed.document.uri.scheme !== 'file') { return fallback; } + if (!ed || (!host.isBrowser && ed.document.uri.scheme !== 'file')) { return fallback; } const wsFolder = vscode.workspace.getWorkspaceFolder(ed.document.uri); if (!wsFolder) { return fallback; } const rel = path.relative(wsFolder.uri.fsPath, ed.document.uri.fsPath); if (!rel || rel.startsWith('..')) { return fallback; } const top = rel.split(path.sep)[0]; const topPath = path.join(wsFolder.uri.fsPath, top); - try { if (!fs.statSync(topPath).isDirectory()) { return fallback; } } catch { return fallback; } + // A first segment is a directory when the file sits below it. The desktop asks the disk, as it + // always did; a browser workspace cannot be asked synchronously and the path says the same thing. + if (host.isBrowser) { if (rel.indexOf(path.sep) < 0) { return fallback; } } + else { try { if (!fs.statSync(topPath).isDirectory()) { return fallback; } } catch { return fallback; } } return { dir: topPath, prefix: top + '/' }; } @@ -965,9 +999,9 @@ async function gatherAutoContext(question, allFiles) { // 1. CONTENT search via ripgrep (primary signal — finds files by what's inside them). const terms = keywords.slice(0, 6); - const hits = await Promise.all(terms.map((kw) => rgFiles(kw, scope.dir))); + const hits = await contentHits(terms, scope.dir); for (let i = 0; i < terms.length; i++) { - for (const abs of hits[i]) { bump(vscode.Uri.file(abs), contentWeight(terms[i])); } + for (const abs of hits[i]) { bump(host.uriFor(abs), contentWeight(terms[i])); } } // 2. filename matches (scoped). @@ -1087,7 +1121,7 @@ async function openDiagramLink(id, nodeId) { if (!r.ok) { vscode.window.showWarningMessage('LevelCode: that link cannot be opened — ' + r.reason + '.'); dbg('diagram.link.refused', { reason: r.reason }); return; } recordDiagramStat({ type: 'link' }); try { - const uri = vscode.Uri.file(r.abs); + const uri = host.uriFor(r.abs); const doc = await vscode.workspace.openTextDocument(uri); let line = Number.isInteger(node.link.line) ? node.link.line : null; if (node.link.symbol) { @@ -1881,6 +1915,7 @@ async function saveMcpLaunchTrust(store) { * broken configuration it exists to explain. */ function mcpOverview() { + if (!host.caps.mcpStdio) { return { configured: 0, running: 0, awaitingTrust: 0, servers: [], problems: [] }; } try { const cfg = aiConfig(); const folders = (vscode.workspace.workspaceFolders || []).map((f) => ({ name: f.name, root: f.uri.fsPath })); @@ -2282,8 +2317,8 @@ async function agentFlow(text, imageBlocks) { applyDelete: (req) => review.applyDelete(req), // file deletions: apply-then-review verify: verifyCfg, // M5 auto-verify settings touched: runTouched, // agent.js adds each edited abs path here - getTouchedUris: () => [...runTouched].map((p) => vscode.Uri.file(p)), - getNewDiagnostics: () => collectNewDiagnostics([...runTouched].map((p) => vscode.Uri.file(p)), diagBaseline, verifyCfg.includeWarnings), + getTouchedUris: () => [...runTouched].map((p) => host.uriFor(p)), + getNewDiagnostics: () => collectNewDiagnostics([...runTouched].map((p) => host.uriFor(p)), diagBaseline, verifyCfg.includeWarnings), signal: abort.signal }); } finally { @@ -2341,7 +2376,7 @@ async function attachImagePaths(paths) { const mt = { '.png': 'image/png', '.jpg': 'image/jpeg', '.jpeg': 'image/jpeg', '.gif': 'image/gif', '.webp': 'image/webp' }[ext]; if (!mt) { vscode.window.showWarningMessage(path.basename(fsPath) + ' is not an image LevelCode can read.'); continue; } - const buf = await fs.promises.readFile(fsPath); + const buf = Buffer.from(await host.readBytes(fsPath)); // Guard before the bytes cross into the webview: a 200MB file would otherwise be // base64-ed onto the message bus before anything got a chance to refuse it. if (buf.length > 25 * 1024 * 1024) { diff --git a/extensions/levelcode-ai/host.js b/extensions/levelcode-ai/host.js new file mode 100644 index 0000000..7a59a57 --- /dev/null +++ b/extensions/levelcode-ai/host.js @@ -0,0 +1,162 @@ +/*--------------------------------------------------------------------------------------------- + * LevelCode — AI: the machine this extension runs on. + * + * The extension was written for a Node extension host: the user's project is a directory it can + * read with `fs`, and programs can be started. LevelCode also runs in a browser tab (docs/WEB.md), + * where the project is whatever the editor's file system provider says it is, every read is + * asynchronous, and there is no shell. This module is the one place that difference lives. + * + * Two rules keep the desktop app exactly as it was: + * 1. On the desktop every function here does what the call site did before — the same `fs` + * call, the same error — only behind `await`. + * 2. Nothing outside this file asks "am I in a browser?". Call sites ask for a capability + * (`caps.shell`) or call a function that already knows. + * + * Paths are the absolute strings the extension has always used for workspace files + * (`Uri.fsPath` of a workspace folder, joined). In the browser they are URI paths, and `uriFor` + * maps one back to the workspace folder it belongs to, so the scheme (`file`, `levelcode-scratch`) + * is never guessed. + *--------------------------------------------------------------------------------------------*/ +// @ts-check +'use strict'; + +const vscode = require('vscode'); +const nodeFs = require('fs'); +const path = require('path'); + +/** True in the browser build (web/ai-extension/entry.js sets the flag before anything else loads). */ +const isBrowser = !!globalThis.__LEVELCODE_BROWSER_HOST__; + +/** What this host can do. Tools and prompt text that need a capability are withheld without it. */ +const caps = Object.freeze({ + /** Start programs: run_command, background commands, verify commands. */ + shell: !isBrowser, + /** Start MCP servers as local processes (stdio). */ + mcpStdio: !isBrowser, + /** Search file contents with the ripgrep binary the editor ships. */ + ripgrep: !isBrowser, +}); + +const TEXT_SNIFF_BYTES = 8000; + +/* ----- paths and URIs ------------------------------------------------------------------------ */ + +function folders() { return vscode.workspace.workspaceFolders || []; } + +/** + * The URI for a workspace path. On the desktop that is `Uri.file`, as ever. In the browser the + * workspace folder that contains the path supplies scheme and authority; a path inside none of + * them falls back to `Uri.file`, which is right for a folder opened from the user's computer. + * @param {string} abs + * @returns {vscode.Uri} + */ +function uriFor(abs) { + if (!isBrowser) { return vscode.Uri.file(abs); } + let best = null; + for (const f of folders()) { + const root = f.uri.path; + const prefix = root.endsWith('/') ? root : root + '/'; + if (abs === root || abs.startsWith(prefix)) { + if (!best || root.length > best.uri.path.length) { best = f; } + } + } + return best ? best.uri.with({ path: abs }) : vscode.Uri.file(abs); +} + +/* ----- reading and probing ------------------------------------------------------------------- */ + +/** @param {string} abs @returns {Promise} rejects when the file is missing or unreadable. */ +async function readBytes(abs) { + if (!isBrowser) { return nodeFs.promises.readFile(abs); } + return vscode.workspace.fs.readFile(uriFor(abs)); +} + +/** @param {string} abs @returns {Promise} UTF-8 text; a leading BOM is left for the caller to strip. */ +async function readText(abs) { + if (!isBrowser) { return nodeFs.promises.readFile(abs, 'utf8'); } + return new TextDecoder('utf-8').decode(await vscode.workspace.fs.readFile(uriFor(abs))); +} + +/** @param {string} abs @returns {Promise} null when it cannot be read — never throws. */ +async function readTextOrNull(abs) { + try { return await readText(abs); } catch { return null; } +} + +/** + * @param {string} abs + * @returns {Promise<{isFile: boolean, isDirectory: boolean, size: number}|null>} null when absent. + */ +async function stat(abs) { + try { + if (!isBrowser) { + const s = await nodeFs.promises.stat(abs); + return { isFile: s.isFile(), isDirectory: s.isDirectory(), size: s.size }; + } + const s = await vscode.workspace.fs.stat(uriFor(abs)); + return { isFile: (s.type & vscode.FileType.File) !== 0, isDirectory: (s.type & vscode.FileType.Directory) !== 0, size: s.size }; + } catch { return null; } +} + +/** @param {string} abs @returns {Promise} */ +async function exists(abs) { return (await stat(abs)) !== null; } + +/** A NUL byte in the first 8 KB means it is not text we should round-trip as UTF-8. Unreadable counts as text. */ +async function isBinary(abs) { + try { + const buf = await readBytes(abs); + const n = Math.min(buf.length, TEXT_SNIFF_BYTES); + for (let i = 0; i < n; i++) { if (buf[i] === 0) { return true; } } + return false; + } catch { return false; } +} + +/** + * Run a loader that reads files synchronously (project rules, MCP config) against files this host can + * only read asynchronously. The loader runs once to learn which paths it asks for, those are read, + * and it runs again with the answers. A path the loader only asks for after seeing another file's + * content is picked up by the next pass (at most four). A path never read in time is absent. + * + * On the desktop the loader simply runs once against `fs`, as before. + * @template T + * @param {(readFile: (abs: string) => string|null) => T} run + * @returns {Promise} + */ +async function withReads(run) { + if (!isBrowser) { + return run((abs) => { try { return nodeFs.readFileSync(abs, 'utf8'); } catch { return null; } }); + } + /** @type {Map} */ + const known = new Map(); + let result; + for (let pass = 0; pass < 4; pass++) { + const asked = new Set(); + result = run((abs) => { + if (known.has(abs)) { return known.get(abs); } + asked.add(abs); + return null; + }); + if (asked.size === 0) { break; } + await Promise.all([...asked].map(async (p) => { known.set(p, await readTextOrNull(p)); })); + } + return result; +} + +/* ----- finding files ------------------------------------------------------------------------- */ + +/** + * `vscode.workspace.findFiles`, which is what the editor's own search service answers. The browser + * build uses it too: LevelCode's scratch workspace registers a file search provider. + * @param {string} glob + * @param {string} exclude + * @param {number} max + */ +function findFiles(glob, exclude, max) { + return vscode.workspace.findFiles(glob, exclude, max); +} + +module.exports = { + isBrowser, caps, + uriFor, readBytes, readText, readTextOrNull, stat, exists, isBinary, withReads, findFiles, + // For tests. + _path: path, +}; diff --git a/extensions/levelcode-ai/reviewSession.js b/extensions/levelcode-ai/reviewSession.js index c23ecb3..0984800 100644 --- a/extensions/levelcode-ai/reviewSession.js +++ b/extensions/levelcode-ai/reviewSession.js @@ -16,7 +16,8 @@ const vscode = require('vscode'); const fs = require('fs'); const path = require('path'); -const { makeDiff, resolveWorkspacePath } = require('./agent'); +const { makeDiff, resolveWorkspacePathAsync } = require('./agent'); +const host = require('./host'); const PERSIST_KEY = 'levelcode.ai.pendingReviews'; @@ -108,18 +109,18 @@ function registerReview(context, post, dbg, recordTouch) { if (!workspaceRoot()) { return false; } // Multi-root aware — MUST resolve exactly like the agent's tool layer (same resolver), or an // edit the agent computed against folder N would be applied into folder 0. - const abs = resolveWorkspacePath(req.path || '', { mustExist: !!req.exists }); + const abs = await resolveWorkspacePathAsync(req.path || '', { mustExist: !!req.exists }); if (!abs) { return false; } - const uri = vscode.Uri.file(abs); + const uri = host.uriFor(abs); const key = uri.toString(); const proposed = String(req.proposed != null ? req.proposed : ''); - const existsNow = fs.existsSync(abs); // re-check at apply time (TOCTOU vs the agent's earlier check) + const existsNow = await host.exists(abs); // re-check at apply time (TOCTOU vs the agent's earlier check) // Snapshot the pre-run content ONCE, from DISK (the true baseline — never a dirty buffer). let snapshot; if (pending.has(key)) { snapshot = pending.get(key).snapshot; } else if (existsNow) { - try { snapshot = fs.readFileSync(abs, 'utf8').replace(/^/, ''); } + try { snapshot = (await host.readText(abs)).replace(/^/, ''); } catch { return false; } // can't read the file we're about to edit → abort (never a destructive empty-snapshot edit) } else { snapshot = ''; } @@ -179,13 +180,13 @@ function registerReview(context, post, dbg, recordTouch) { async function applyDelete(req) { if (!workspaceRoot()) { return false; } // Multi-root aware — same resolver as the agent's tool layer (see applyEdit). - const abs = resolveWorkspacePath(req.path || '', { mustExist: true }); + const abs = await resolveWorkspacePathAsync(req.path || '', { mustExist: true }); if (!abs) { return false; } - const uri = vscode.Uri.file(abs); + const uri = host.uriFor(abs); const key = uri.toString(); let snapshot; if (pending.has(key)) { snapshot = pending.get(key).snapshot; } - else { try { snapshot = fs.readFileSync(abs, 'utf8').replace(/^/, ''); } catch { return false; } } + else { try { snapshot = (await host.readText(abs)).replace(/^/, ''); } catch { return false; } } // Checkpoint hook: the file EXISTED (created=false) → per-turn Restore recreates it from this snapshot. if (recordTouch) { try { recordTouch(key, snapshot, false); } catch (e) { /* checkpoint capture must never break editing */ } } const edit = new vscode.WorkspaceEdit(); @@ -239,7 +240,7 @@ function registerReview(context, post, dbg, recordTouch) { const fr = pending.get(key); if (!fr) { return; } const edit = new vscode.WorkspaceEdit(); - const gone = !fs.existsSync(fr.uri.fsPath); + const gone = !(await host.exists(fr.uri.fsPath)); if (fr.exists && gone) { // the agent DELETED this file → recreate it from the snapshot edit.createFile(fr.uri, { ignoreIfExists: true }); @@ -333,11 +334,11 @@ function registerReview(context, post, dbg, recordTouch) { vscode.window.onDidChangeActiveTextEditor(() => updateActiveUi()), vscode.commands.registerCommand('levelcode.ai.review.keepActive', () => { const k = activePendingKey(); if (k) { keepFile(k, 'kept'); } }), vscode.commands.registerCommand('levelcode.ai.review.undoActive', () => { const k = activePendingKey(); if (k) { undoFile(k); } }), - vscode.languages.registerCodeLensProvider({ scheme: 'file' }, lensProvider), + vscode.languages.registerCodeLensProvider({ scheme: host.isBrowser ? '*' : 'file' }, lensProvider), vscode.workspace.registerTextDocumentContentProvider(SNAPSHOT_SCHEME, snapshotProvider), vscode.window.onDidChangeVisibleTextEditors(() => { for (const fr of pending.values()) { decorate(fr); } }), vscode.workspace.onDidChangeTextDocument((e) => { - if (e.document.uri.scheme !== 'file') { return; } + if (!host.isBrowser && e.document.uri.scheme !== 'file') { return; } // a browser workspace has its own schemes const key = e.document.uri.toString(); if (!pending.has(key)) { return; } if (expected.get(key) === e.document.getText()) { return; } // our own apply — ignore @@ -361,18 +362,32 @@ function registerReview(context, post, dbg, recordTouch) { ); // Rehydrate pending reviews from a previous window (so a reload mid-review keeps Keep/Undo working). - for (const s of (context.workspaceState.get(PERSIST_KEY, []) || [])) { - try { - const uri = vscode.Uri.parse(s.uri); - let current = ''; - try { current = fs.readFileSync(uri.fsPath, 'utf8').replace(/^/, ''); } catch { /* file gone */ } - const span = changedSpan(s.snapshot, current); - const { add, del } = countDiff(makeDiff(s.snapshot, current)); - pending.set(s.uri, { uri, rel: s.rel, exists: s.exists, snapshot: s.snapshot, range: new vscode.Range(span.start, 0, span.end, 0), add, del, appliedText: current }); - expected.set(s.uri, current); - } catch { /* */ } + const saved = context.workspaceState.get(PERSIST_KEY, []) || []; + function rehydrate(readCurrent) { + for (const s of saved) { + try { + const uri = vscode.Uri.parse(s.uri); + const current = readCurrent(uri, s); + const span = changedSpan(s.snapshot, current); + const { add, del } = countDiff(makeDiff(s.snapshot, current)); + pending.set(s.uri, { uri, rel: s.rel, exists: s.exists, snapshot: s.snapshot, range: new vscode.Range(span.start, 0, span.end, 0), add, del, appliedText: current }); + expected.set(s.uri, current); + } catch { /* */ } + } + if (pending.size) { for (const fr of pending.values()) { decorate(fr); } refreshLenses(); updateActiveUi(); } + } + if (!host.isBrowser) { + rehydrate((uri) => { try { return fs.readFileSync(uri.fsPath, 'utf8').replace(/^/, ''); } catch { return ''; /* file gone */ } }); + } else { + // A browser workspace can only be read asynchronously: read every pending file, then rehydrate. + void (async () => { + const current = new Map(); + for (const s of saved) { + try { current.set(s.uri, ((await host.readTextOrNull(vscode.Uri.parse(s.uri).fsPath)) || '').replace(/^/, '')); } catch { /* */ } + } + rehydrate((uri, s) => current.get(s.uri) || ''); + })(); } - if (pending.size) { for (const fr of pending.values()) { decorate(fr); } refreshLenses(); updateActiveUi(); } /** Re-send all pending review cards + state to the webview (called when the chat view becomes ready). */ function resync() { diff --git a/extensions/levelcode-ai/test/diagramHost.test.js b/extensions/levelcode-ai/test/diagramHost.test.js index 833c1b6..5ada661 100644 --- a/extensions/levelcode-ai/test/diagramHost.test.js +++ b/extensions/levelcode-ai/test/diagramHost.test.js @@ -114,7 +114,7 @@ const HOST = ['clientRender', 'openDiagramLink', 'diagramMarkdown', 'exportDiagr // eslint-disable-next-line no-new-func const makeHost = new Function('env', [ "'use strict';", - 'const { vscode, fs, path, os, catalog, sessionMemory, diagrams, diagramLinks, diagramExport, diagramText, diagramTool, diagramBundle, diagramAscii, aiConfig, dbg,', + 'const { vscode, host, fs, path, os, catalog, sessionMemory, diagrams, diagramLinks, diagramExport, diagramText, diagramTool, diagramBundle, diagramAscii, aiConfig, dbg,', ' recordDiagramStat, agentFlow, findCompactionCut, estimateMsgTokens, meterModel, prepProviderRequest, authRetry, providers, checkpoints, ctx,', ' sessionsManager, currentContextLimit, post, replaySessionExpired, postContextFiles, refreshSessions, focusChatView } = env;', "const COMPACT_SYSTEM = 'system', COMPACT_INSTRUCTIONS = 'Summarize:\\n';", @@ -134,7 +134,10 @@ function boot(over) { const settings = Object.assign({ 'diagrams.enabled': true }, over && over.settings); const diagrams = createDiagrams({ resolveLink: (link) => diagramLinks.resolveLink(link, [{ name: 'app', root: ws }]), onStat: (ev) => stats.push(ev) }); const env = { - vscode, fs, path, os, catalog, sessionMemory, diagrams, diagramLinks, diagramExport, diagramText, diagramTool, diagramBundle: bundle, + vscode, + // The host module (host.js) as the desktop sees it: workspace paths are files on disk. + host: { isBrowser: false, caps: { shell: true, mcpStdio: true, ripgrep: true }, uriFor: (abs) => vscode.Uri.file(abs) }, + fs, path, os, catalog, sessionMemory, diagrams, diagramLinks, diagramExport, diagramText, diagramTool, diagramBundle: bundle, diagramAscii: (over && over.ascii) || ascii, aiConfig: () => ({ get: (k, d) => (k in settings ? settings[k] : d) }), dbg: (label, data) => logs.push({ label, data }), From 9a875a45ac8a6a9515fc7a8406ba7c1249384751 Mon Sep 17 00:00:00 2001 From: Sergii Demianchuk Date: Fri, 9 Oct 2026 18:37:25 -0400 Subject: [PATCH 02/17] =?UTF-8?q?feat(web):=20LevelCode=20in=20your=20brow?= =?UTF-8?q?ser=20=E2=80=94=20the=20page,=20its=20entry,=20the=20scratch=20?= =?UTF-8?q?workspace=20and=20the=20browser=20build=20of=20levelcode-ai?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. --- web/ai-extension/build.mjs | 116 ++++++++ web/ai-extension/entry.js | 26 ++ web/ai-extension/inject.js | 4 + web/ai-extension/shims/child_process.js | 21 ++ web/ai-extension/shims/crypto.js | 104 +++++++ web/ai-extension/shims/fs.js | 349 ++++++++++++++++++++++++ web/ai-extension/shims/os.js | 18 ++ web/ai-extension/shims/path.js | 121 ++++++++ web/ai-extension/shims/process.js | 18 ++ web/boot.css | 26 ++ web/callback.css | 7 + web/callback.html | 22 ++ web/callback.js | 33 +++ web/icon.svg | 6 + web/index.html | 37 +++ web/lib/config.mjs | 47 ++++ web/main.js | 272 ++++++++++++++++++ web/serve.mjs | 70 +++++ web/test/cdp.mjs | 120 ++++++++ web/test/e2e.mjs | 157 +++++++++++ web/test/stub-backend.mjs | 203 ++++++++++++++ web/workspace/extension.js | 268 ++++++++++++++++++ web/workspace/package.json | 23 ++ 23 files changed, 2068 insertions(+) create mode 100644 web/ai-extension/build.mjs create mode 100644 web/ai-extension/entry.js create mode 100644 web/ai-extension/inject.js create mode 100644 web/ai-extension/shims/child_process.js create mode 100644 web/ai-extension/shims/crypto.js create mode 100644 web/ai-extension/shims/fs.js create mode 100644 web/ai-extension/shims/os.js create mode 100644 web/ai-extension/shims/path.js create mode 100644 web/ai-extension/shims/process.js create mode 100644 web/boot.css create mode 100644 web/callback.css create mode 100644 web/callback.html create mode 100644 web/callback.js create mode 100644 web/icon.svg create mode 100644 web/index.html create mode 100644 web/lib/config.mjs create mode 100644 web/main.js create mode 100644 web/serve.mjs create mode 100644 web/test/cdp.mjs create mode 100644 web/test/e2e.mjs create mode 100644 web/test/stub-backend.mjs create mode 100644 web/workspace/extension.js create mode 100644 web/workspace/package.json diff --git a/web/ai-extension/build.mjs b/web/ai-extension/build.mjs new file mode 100644 index 0000000..e7036eb --- /dev/null +++ b/web/ai-extension/build.mjs @@ -0,0 +1,116 @@ +#!/usr/bin/env node +// Build levelcode-ai for the browser: ONE file the web extension host can evaluate, plus the +// files the manifest points at. The desktop extension stays plain JS with no build step; this +// bundler only exists for the browser edition and writes nothing into extensions/. +// +// node web/ai-extension/build.mjs --out --vscode [--minify] +// +// Output (/levelcode-ai): package.json (+ "browser"), extension.web.js, media/, README.md +import fs from 'node:fs'; +import path from 'node:path'; +import { createRequire } from 'node:module'; +import { fileURLToPath } from 'node:url'; + +const HERE = path.dirname(fileURLToPath(import.meta.url)); +const REPO = path.resolve(HERE, '..', '..'); +const SRC = path.join(REPO, 'extensions', 'levelcode-ai'); + +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; +}, [])); +const outRoot = path.resolve(args.out || path.join(REPO, 'dist-web', 'extensions')); +const vscodeDir = path.resolve(args.vscode || path.join(REPO, 'vscode')); +const minify = args.minify === 'true'; + +/** Find esbuild and the `buffer` package in the Code-OSS checkout, which already depends on both. */ +function locate(name, bases) { + for (const b of bases) { + try { return createRequire(path.join(b, 'noop.js')).resolve(name); } catch { /* next */ } + } + return null; +} +const esbuildEntry = locate('esbuild', [path.join(vscodeDir, 'build'), path.join(vscodeDir, 'extensions'), vscodeDir, REPO]); +if (!esbuildEntry) { console.error('esbuild not found under ' + vscodeDir + ' (run scripts/bootstrap.sh first)'); process.exit(2); } +const esbuild = await import(esbuildEntry); +const nodePaths = [path.join(vscodeDir, 'node_modules'), path.join(vscodeDir, 'build', 'node_modules')].filter((p) => fs.existsSync(p)); +if (!nodePaths.some((p) => fs.existsSync(path.join(p, 'buffer')))) { console.error('the "buffer" package is missing from ' + vscodeDir + '/node_modules'); process.exit(2); } + +const outDir = path.join(outRoot, 'levelcode-ai'); +fs.rmSync(outDir, { recursive: true, force: true }); +fs.mkdirSync(outDir, { recursive: true }); + +/* ----- the extension's own files, embedded so synchronous reads keep working ------------------ */ +const TEXT = new Set(['.html', '.svg', '.md', '.json', '.css', '.js', '.txt']); +function collect(rel, filter) { + const out = {}; + const walk = (dir) => { + for (const e of fs.readdirSync(path.join(SRC, dir), { withFileTypes: true })) { + const r = path.posix.join(dir, e.name); + if (e.isDirectory()) { walk(r); continue; } + if (filter && !filter(r)) { continue; } + const buf = fs.readFileSync(path.join(SRC, r)); + out[r] = TEXT.has(path.extname(r)) ? buf.toString('utf8') : { base64: buf.toString('base64') }; + } + }; + if (fs.existsSync(path.join(SRC, rel))) { walk(rel); } + return out; +} +const assets = Object.assign({}, + collect('media'), + collect('skills'), + collect('diagram', (r) => r.endsWith('.js')), +); +const generated = path.join(HERE, 'assets.generated.js'); +fs.writeFileSync(generated, '// GENERATED by build.mjs — do not edit, do not commit.\nmodule.exports = ' + JSON.stringify(assets) + ';\n'); + +/* ----- __dirname has no meaning in a bundle: point it at the mounted copy -------------------- */ +const dirnamePlugin = { + name: 'levelcode-dirname', + setup(b) { + b.onLoad({ filter: /extensions[\\/]levelcode-ai[\\/].*\.js$/ }, (a) => { + let text = fs.readFileSync(a.path, 'utf8'); + if (!/\b__dirname\b/.test(text)) { return null; } + const rel = path.relative(SRC, path.dirname(a.path)).split(path.sep).join('/'); + text = text.replace(/\b__dirname\b/g, `(globalThis.__lcExtPath + ${JSON.stringify(rel ? '/' + rel : '')})`); + return { contents: text, loader: 'js' }; + }); + }, +}; + +const shim = (n) => path.join(HERE, 'shims', n + '.js'); +await esbuild.build({ + entryPoints: [path.join(HERE, 'entry.js')], + outfile: path.join(outDir, 'extension.web.js'), + bundle: true, + platform: 'browser', + format: 'cjs', + target: 'es2022', + external: ['vscode'], + alias: { + fs: shim('fs'), 'node:fs': shim('fs'), + path: shim('path'), 'node:path': shim('path'), + os: shim('os'), 'node:os': shim('os'), + crypto: shim('crypto'), 'node:crypto': shim('crypto'), + child_process: shim('child_process'), 'node:child_process': shim('child_process'), + }, + inject: [path.join(HERE, 'inject.js')], + define: { global: 'globalThis' }, + nodePaths, + plugins: [dirnamePlugin], + minify, + sourcemap: minify ? false : 'inline', + legalComments: 'none', + logLevel: 'info', +}); +fs.rmSync(generated, { force: true }); + +/* ----- manifest + the files it refers to ---------------------------------------------------- */ +const pkg = JSON.parse(fs.readFileSync(path.join(SRC, 'package.json'), 'utf8')); +pkg.browser = './extension.web.js'; +fs.writeFileSync(path.join(outDir, 'package.json'), JSON.stringify(pkg, null, '\t') + '\n'); +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)); } } +fs.cpSync(path.join(SRC, 'media'), path.join(outDir, 'media'), { recursive: true }); + +const size = fs.statSync(path.join(outDir, 'extension.web.js')).size; +console.log(`levelcode-ai (browser) -> ${outDir} extension.web.js ${(size / 1024).toFixed(0)} KB, ${Object.keys(assets).length} embedded files`); diff --git a/web/ai-extension/entry.js b/web/ai-extension/entry.js new file mode 100644 index 0000000..1d03740 --- /dev/null +++ b/web/ai-extension/entry.js @@ -0,0 +1,26 @@ +// Entry of the browser build of levelcode-ai. The extension itself is unchanged; this wrapper +// only does what a Node extension host does for it before `activate`: make the private file +// system ready and put the extension's own resources where `ctx.extensionPath` says they are. +'use strict'; + +// host.js reads this to know the code is running in the browser build (no shell, no ripgrep). +globalThis.__LEVELCODE_BROWSER_HOST__ = true; + +const fs = require('fs'); // aliased to ./shims/fs.js by build.mjs +const assets = require('./assets.generated.js'); +const real = require('../../extensions/levelcode-ai/extension.js'); + +async function activate(context) { + // Everything that reads a file synchronously needs the saved state in memory first. + await fs.__levelcode.ready; + fs.__levelcode.mountAssets(context.extensionPath, assets); + globalThis.__lcExtPath = context.extensionPath; // stands in for __dirname (see build.mjs) + return real.activate(context); +} + +async function deactivate() { + try { if (real.deactivate) { await real.deactivate(); } } + finally { await fs.__levelcode.flush(); } +} + +module.exports = { activate, deactivate }; diff --git a/web/ai-extension/inject.js b/web/ai-extension/inject.js new file mode 100644 index 0000000..4ebf787 --- /dev/null +++ b/web/ai-extension/inject.js @@ -0,0 +1,4 @@ +// What esbuild substitutes for the Node globals the extension's sources mention. +import proc from './shims/process.js'; +import { Buffer } from 'buffer'; +export { proc as process, Buffer }; diff --git a/web/ai-extension/shims/child_process.js b/web/ai-extension/shims/child_process.js new file mode 100644 index 0000000..830810e --- /dev/null +++ b/web/ai-extension/shims/child_process.js @@ -0,0 +1,21 @@ +// `child_process` for the browser build: there is no machine to run a program on. Every entry +// point fails the way Node fails for an unsupported syscall, so callers that already handle a +// spawn error (all of them do: it is what a missing ripgrep looks like) take their normal path. +// The agent's shell tools are not offered at all in this build (host.js `caps.shell`). +'use strict'; +function unavailable(name) { + return function () { + const e = new Error('child_process.' + name + ' is not available in LevelCode in the browser'); + e.code = 'ENOSYS'; + throw e; + }; +} +module.exports = { + spawn: unavailable('spawn'), + spawnSync: unavailable('spawnSync'), + exec: unavailable('exec'), + execSync: unavailable('execSync'), + execFile: unavailable('execFile'), + execFileSync: unavailable('execFileSync'), + fork: unavailable('fork'), +}; diff --git a/web/ai-extension/shims/crypto.js b/web/ai-extension/shims/crypto.js new file mode 100644 index 0000000..0082a53 --- /dev/null +++ b/web/ai-extension/shims/crypto.js @@ -0,0 +1,104 @@ +// `crypto` for the browser build: the three things the extension uses. +// +// randomBytes(n) from the platform CSPRNG +// randomUUID() +// createHash('sha256').update(x).digest(enc) SYNCHRONOUS, which WebCrypto is not, so the digest is +// computed here (FIPS 180-4). PKCE (extension.js) and the +// image store both need it inline. +'use strict'; +const { Buffer } = require('buffer'); + +const K = new Uint32Array([ + 0x428a2f98, 0x71374491, 0xb5c0fbcf, 0xe9b5dba5, 0x3956c25b, 0x59f111f1, 0x923f82a4, 0xab1c5ed5, + 0xd807aa98, 0x12835b01, 0x243185be, 0x550c7dc3, 0x72be5d74, 0x80deb1fe, 0x9bdc06a7, 0xc19bf174, + 0xe49b69c1, 0xefbe4786, 0x0fc19dc6, 0x240ca1cc, 0x2de92c6f, 0x4a7484aa, 0x5cb0a9dc, 0x76f988da, + 0x983e5152, 0xa831c66d, 0xb00327c8, 0xbf597fc7, 0xc6e00bf3, 0xd5a79147, 0x06ca6351, 0x14292967, + 0x27b70a85, 0x2e1b2138, 0x4d2c6dfc, 0x53380d13, 0x650a7354, 0x766a0abb, 0x81c2c92e, 0x92722c85, + 0xa2bfe8a1, 0xa81a664b, 0xc24b8b70, 0xc76c51a3, 0xd192e819, 0xd6990624, 0xf40e3585, 0x106aa070, + 0x19a4c116, 0x1e376c08, 0x2748774c, 0x34b0bcb5, 0x391c0cb3, 0x4ed8aa4a, 0x5b9cca4f, 0x682e6ff3, + 0x748f82ee, 0x78a5636f, 0x84c87814, 0x8cc70208, 0x90befffa, 0xa4506ceb, 0xbef9a3f7, 0xc67178f2, +]); + +class Sha256 { + constructor() { + this.h = new Uint32Array([0x6a09e667, 0xbb67ae85, 0x3c6ef372, 0xa54ff53a, 0x510e527f, 0x9b05688c, 0x1f83d9ab, 0x5be0cd19]); + this.block = new Uint8Array(64); + this.blockLen = 0; + this.total = 0; + this.w = new Uint32Array(64); + this.done = false; + } + update(data, encoding) { + if (this.done) { throw new Error('Digest already called'); } + const bytes = typeof data === 'string' ? Buffer.from(data, encoding || 'utf8') : data; + for (let i = 0; i < bytes.length; i++) { + this.block[this.blockLen++] = bytes[i]; + if (this.blockLen === 64) { this.compress(); this.blockLen = 0; } + } + this.total += bytes.length; + return this; + } + compress() { + const w = this.w; + const b = this.block; + 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]; } + for (let i = 16; i < 64; i++) { + const a = w[i - 15], c = w[i - 2]; + const s0 = ((a >>> 7) | (a << 25)) ^ ((a >>> 18) | (a << 14)) ^ (a >>> 3); + const s1 = ((c >>> 17) | (c << 15)) ^ ((c >>> 19) | (c << 13)) ^ (c >>> 10); + w[i] = (w[i - 16] + s0 + w[i - 7] + s1) | 0; + } + let [a, b2, c, d, e, f, g, h] = this.h; + for (let i = 0; i < 64; i++) { + const S1 = ((e >>> 6) | (e << 26)) ^ ((e >>> 11) | (e << 21)) ^ ((e >>> 25) | (e << 7)); + const ch = (e & f) ^ (~e & g); + const t1 = (h + S1 + ch + K[i] + w[i]) | 0; + const S0 = ((a >>> 2) | (a << 30)) ^ ((a >>> 13) | (a << 19)) ^ ((a >>> 22) | (a << 10)); + const maj = (a & b2) ^ (a & c) ^ (b2 & c); + const t2 = (S0 + maj) | 0; + h = g; g = f; f = e; e = (d + t1) | 0; d = c; c = b2; b2 = a; a = (t1 + t2) | 0; + } + const H = this.h; + H[0] = (H[0] + a) | 0; H[1] = (H[1] + b2) | 0; H[2] = (H[2] + c) | 0; H[3] = (H[3] + d) | 0; + H[4] = (H[4] + e) | 0; H[5] = (H[5] + f) | 0; H[6] = (H[6] + g) | 0; H[7] = (H[7] + h) | 0; + } + digest(encoding) { + if (this.done) { throw new Error('Digest already called'); } + this.done = true; + const bitLen = this.total * 8; + this.block[this.blockLen++] = 0x80; + if (this.blockLen > 56) { + this.block.fill(0, this.blockLen); + this.compress(); + this.blockLen = 0; + } + this.block.fill(0, this.blockLen, 56); + const hi = Math.floor(bitLen / 0x100000000); + const lo = bitLen >>> 0; + 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; } + this.compress(); + const out = Buffer.alloc(32); + for (let i = 0; i < 8; i++) { out.writeUInt32BE(this.h[i] >>> 0, i * 4); } + return encoding ? out.toString(encoding) : out; + } +} + +function createHash(algorithm) { + const name = String(algorithm).toLowerCase(); + if (name !== 'sha256') { + const e = new Error('Digest method not supported: ' + algorithm); + e.code = 'ERR_CRYPTO_INVALID_DIGEST'; + throw e; + } + return new Sha256(); +} + +function randomBytes(size) { + const out = Buffer.alloc(size); + globalThis.crypto.getRandomValues(out); + return out; +} + +function randomUUID() { return globalThis.crypto.randomUUID(); } + +module.exports = { createHash, randomBytes, randomUUID }; diff --git a/web/ai-extension/shims/fs.js b/web/ai-extension/shims/fs.js new file mode 100644 index 0000000..c46770c --- /dev/null +++ b/web/ai-extension/shims/fs.js @@ -0,0 +1,349 @@ +// `fs` for the browser build of the extension. +// +// The extension keeps private state (sessions, memory, images, sketches) and reads its own +// resources (media/chat.html, skills/, diagram modules) through synchronous Node `fs`. A browser +// has no such file system, and a synchronous API cannot sit on top of IndexedDB. So this is a +// small in-memory tree with the synchronous surface the extension uses, made durable by writing +// changes behind to IndexedDB, and loaded back once before the extension activates (`ready`). +// +// - private state lives under os.homedir() ("/home/levelcode"); it survives reloads. +// - the extension's own files are mounted read-only at ctx.extensionPath (mountAssets), from +// the files the build embedded. +// - the USER'S WORKSPACE is not here and must never be: it is reached through +// vscode.workspace.fs (host.js), which is asynchronous because the real files are. +// +// Only what the extension calls is implemented. Anything else throws ENOSYS so a new call fails +// loudly in a test rather than quietly returning nothing. +'use strict'; + +const { Buffer } = require('buffer'); +const pathMod = require('./path'); + +const DB_NAME = 'levelcode-ai-fs'; +const DB_VERSION = 1; +const STORE = 'files'; +const FLUSH_MS = 150; + +/** @type {Map} */ +const files = new Map(); +/** @type {Set} */ +const dirs = new Set(['/']); +const dirty = new Set(); +const removed = new Set(); +let flushTimer = null; +let db = null; +let persistent = false; + +function err(code, syscall, p, extra) { + const text = { + ENOENT: 'no such file or directory', + EEXIST: 'file already exists', + ENOTDIR: 'not a directory', + EISDIR: 'illegal operation on a directory', + ENOTEMPTY: 'directory not empty', + EROFS: 'read-only file system', + ENOSYS: 'function not implemented', + }[code] || code; + const e = new Error(`${code}: ${text}, ${syscall} '${p}'${extra ? ' -> ' + extra : ''}`); + e.code = code; + e.syscall = syscall; + e.path = p; + e.errno = { ENOENT: -2, EEXIST: -17, ENOTDIR: -20, EISDIR: -21, ENOTEMPTY: -39, EROFS: -30, ENOSYS: -38 }[code]; + return e; +} + +function abs(p) { + if (p && typeof p === 'object' && typeof p.pathname === 'string') { p = decodeURIComponent(p.pathname); } // file: URL + if (typeof p !== 'string' || p === '') { throw err('ENOENT', 'open', String(p)); } + return pathMod.resolve('/', p); +} + +function toBytes(data, encoding) { + if (typeof data === 'string') { return Buffer.from(data, encoding || 'utf8'); } + if (data instanceof Uint8Array) { return data; } + if (data instanceof ArrayBuffer) { return new Uint8Array(data); } + if (ArrayBuffer.isView(data)) { return new Uint8Array(data.buffer, data.byteOffset, data.byteLength); } + return Buffer.from(String(data)); +} + +function encodingOf(opts) { return typeof opts === 'string' ? opts : (opts && opts.encoding) || null; } + +function parentOf(p) { return pathMod.dirname(p); } + +function ensureDirs(p) { + // Every ancestor of `p` (not p itself) becomes a directory. + let cur = parentOf(p); + const chain = []; + while (!dirs.has(cur)) { chain.push(cur); cur = parentOf(cur); } + for (const d of chain) { + if (files.has(d)) { throw err('ENOTDIR', 'mkdir', d); } + dirs.add(d); + } +} + +function markDirty(p) { removed.delete(p); dirty.add(p); scheduleFlush(); } +function markRemoved(p) { dirty.delete(p); removed.add(p); scheduleFlush(); } + +/* ----- sync API ---------------------------------------------------------------------------- */ + +function existsSync(p) { + try { const a = abs(p); return files.has(a) || dirs.has(a); } catch { return false; } +} + +function readFileSync(p, opts) { + const a = abs(p); + const f = files.get(a); + if (!f) { throw dirs.has(a) ? err('EISDIR', 'read', a) : err('ENOENT', 'open', a); } + const enc = encodingOf(opts); + const buf = Buffer.from(f.data.buffer, f.data.byteOffset, f.data.byteLength); + return enc ? buf.toString(enc) : Buffer.from(buf); // a copy: callers may mutate +} + +function writeFileSync(p, data, opts) { + const a = abs(p); + if (dirs.has(a)) { throw err('EISDIR', 'open', a); } + const existing = files.get(a); + if (existing && existing.readonly) { throw err('EROFS', 'open', a); } + ensureDirs(a); + const now = Date.now(); + const bytes = toBytes(data, encodingOf(opts)); + files.set(a, { data: Uint8Array.from(bytes), mtimeMs: now, ctimeMs: existing ? existing.ctimeMs : now }); + markDirty(a); +} + +function appendFileSync(p, data, opts) { + const a = abs(p); + const existing = files.get(a); + if (!existing) { return writeFileSync(a, data, opts); } + if (existing.readonly) { throw err('EROFS', 'open', a); } + const add = toBytes(data, encodingOf(opts)); + const merged = new Uint8Array(existing.data.length + add.length); + merged.set(existing.data, 0); + merged.set(add, existing.data.length); + existing.data = merged; + existing.mtimeMs = Date.now(); + markDirty(a); +} + +function mkdirSync(p, opts) { + const a = abs(p); + const recursive = !!(opts && typeof opts === 'object' && opts.recursive); + if (files.has(a)) { throw err('EEXIST', 'mkdir', a); } + if (dirs.has(a)) { if (recursive) { return undefined; } throw err('EEXIST', 'mkdir', a); } + if (!recursive && !dirs.has(parentOf(a))) { throw err('ENOENT', 'mkdir', a); } + ensureDirs(a); + dirs.add(a); + markDirty(a + '/'); + return recursive ? a : undefined; +} + +function childrenOf(a) { + const prefix = a === '/' ? '/' : a + '/'; + const names = new Map(); + for (const f of files.keys()) { + if (f.startsWith(prefix)) { const rest = f.slice(prefix.length); const i = rest.indexOf('/'); const n = i < 0 ? rest : rest.slice(0, i); if (n) { names.set(n, i < 0 ? 'file' : 'dir'); } } + } + for (const d of dirs) { + if (d !== a && d.startsWith(prefix)) { const rest = d.slice(prefix.length); const i = rest.indexOf('/'); const n = i < 0 ? rest : rest.slice(0, i); if (n) { names.set(n, 'dir'); } } + } + return names; +} + +function readdirSync(p, opts) { + const a = abs(p); + if (!dirs.has(a)) { throw files.has(a) ? err('ENOTDIR', 'scandir', a) : err('ENOENT', 'scandir', a); } + const names = [...childrenOf(a).entries()].sort((x, y) => (x[0] < y[0] ? -1 : 1)); + if (opts && typeof opts === 'object' && opts.withFileTypes) { + return names.map(([name, kind]) => ({ name, isFile: () => kind === 'file', isDirectory: () => kind === 'dir', isSymbolicLink: () => false })); + } + return names.map(([name]) => name); +} + +function makeStats(kind, f) { + const m = f ? f.mtimeMs : 0; + const c = f ? f.ctimeMs : 0; + return { + size: f ? f.data.length : 0, + mode: kind === 'dir' ? 0o40755 : 0o100644, + mtimeMs: m, ctimeMs: c, atimeMs: m, birthtimeMs: c, + mtime: new Date(m), ctime: new Date(c), atime: new Date(m), birthtime: new Date(c), + isFile: () => kind === 'file', + isDirectory: () => kind === 'dir', + isSymbolicLink: () => false, + }; +} +function statSync(p, opts) { + const a = abs(p); + const f = files.get(a); + if (f) { return makeStats('file', f); } + if (dirs.has(a)) { return makeStats('dir', null); } + if (opts && opts.throwIfNoEntry === false) { return undefined; } + throw err('ENOENT', 'stat', a); +} + +function realpathSync(p) { const a = abs(p); if (!files.has(a) && !dirs.has(a)) { throw err('ENOENT', 'realpath', a); } return a; } + +function unlinkSync(p) { + const a = abs(p); + const f = files.get(a); + if (!f) { throw dirs.has(a) ? err('EISDIR', 'unlink', a) : err('ENOENT', 'unlink', a); } + if (f.readonly) { throw err('EROFS', 'unlink', a); } + files.delete(a); + markRemoved(a); +} + +function rmSync(p, opts) { + const a = abs(p); + const recursive = !!(opts && opts.recursive); + const force = !!(opts && opts.force); + if (files.has(a)) { return unlinkSync(a); } + if (dirs.has(a)) { + const kids = childrenOf(a); + if (kids.size && !recursive) { throw err('ENOTEMPTY', 'rm', a); } + const prefix = a === '/' ? '/' : a + '/'; + for (const f of [...files.keys()]) { if (f.startsWith(prefix)) { if (files.get(f).readonly) { throw err('EROFS', 'rm', f); } files.delete(f); markRemoved(f); } } + for (const d of [...dirs]) { if (d === a || d.startsWith(prefix)) { if (d !== '/') { dirs.delete(d); markRemoved(d + '/'); } } } + return undefined; + } + if (force) { return undefined; } + throw err('ENOENT', 'rm', a); +} + +function renameSync(from, to) { + const a = abs(from); + const b = abs(to); + const f = files.get(a); + if (f) { + if (f.readonly) { throw err('EROFS', 'rename', a); } + if (dirs.has(b)) { throw err('EISDIR', 'rename', b); } + ensureDirs(b); + files.delete(a); markRemoved(a); + files.set(b, Object.assign({}, f, { mtimeMs: Date.now() })); + markDirty(b); + return; + } + if (dirs.has(a)) { + const prefix = a + '/'; + ensureDirs(b); + for (const k of [...files.keys()]) { if (k.startsWith(prefix)) { const v = files.get(k); files.delete(k); markRemoved(k); files.set(b + '/' + k.slice(prefix.length), v); markDirty(b + '/' + k.slice(prefix.length)); } } + for (const d of [...dirs]) { if (d === a || d.startsWith(prefix)) { dirs.delete(d); markRemoved(d + '/'); const n = b + d.slice(a.length); dirs.add(n); markDirty(n + '/'); } } + return; + } + throw err('ENOENT', 'rename', a, b); +} + +function copyFileSync(from, to) { + const data = readFileSync(from); + writeFileSync(to, data); +} + +/* ----- promises + callbacks, derived from the sync core ----------------------------------- */ + +const promises = {}; +const callbacks = {}; +const SYNCS = { readFile: readFileSync, writeFile: writeFileSync, appendFile: appendFileSync, mkdir: mkdirSync, readdir: readdirSync, stat: statSync, lstat: statSync, realpath: realpathSync, unlink: unlinkSync, rm: rmSync, rename: renameSync, copyFile: copyFileSync }; +for (const [name, fn] of Object.entries(SYNCS)) { + promises[name] = (...args) => new Promise((resolve, reject) => { try { resolve(fn(...args)); } catch (e) { reject(e); } }); + callbacks[name] = (...args) => { + const cb = args.pop(); + if (typeof cb !== 'function') { throw new TypeError('The "cb" argument must be of type function'); } + let result, error = null; + try { result = fn(...args); } catch (e) { error = e; } + setTimeout(() => (error ? cb(error) : cb(null, result)), 0); + }; +} +promises.access = (p) => new Promise((resolve, reject) => (existsSync(p) ? resolve() : reject(err('ENOENT', 'access', abs(p))))); + +/* ----- durability --------------------------------------------------------------------------- */ + +function openDb() { + return new Promise((resolve, reject) => { + const req = indexedDB.open(DB_NAME, DB_VERSION); + req.onupgradeneeded = () => { req.result.createObjectStore(STORE, { keyPath: 'path' }); }; + req.onsuccess = () => resolve(req.result); + req.onerror = () => reject(req.error); + }); +} + +/** + * Load what an earlier session saved. The extension must not touch the file system before this + * resolves, or it would see an empty home directory and overwrite what is stored. Never rejects: + * where IndexedDB is unavailable (some private windows) the tree is simply session-only. + */ +const ready = (async () => { + if (typeof indexedDB === 'undefined') { return false; } + try { + db = await openDb(); + const rows = await new Promise((resolve, reject) => { + const req = db.transaction(STORE).objectStore(STORE).getAll(); + req.onsuccess = () => resolve(req.result); + req.onerror = () => reject(req.error); + }); + for (const row of rows) { + if (row.path.endsWith('/')) { dirs.add(row.path.slice(0, -1) || '/'); continue; } + if (files.has(row.path) && files.get(row.path).readonly) { continue; } + files.set(row.path, { data: row.data, mtimeMs: row.mtimeMs || 0, ctimeMs: row.ctimeMs || row.mtimeMs || 0 }); + ensureDirs(row.path); + } + persistent = true; + return true; + } catch (e) { + db = null; + return false; + } +})(); + +function scheduleFlush() { + if (!persistent || flushTimer) { return; } + flushTimer = setTimeout(() => { flushTimer = null; void flush(); }, FLUSH_MS); +} + +/** Write pending changes to IndexedDB. Resolves when they are stored (or if there is nowhere to store). */ +function flush() { + if (!persistent || !db) { dirty.clear(); removed.clear(); return Promise.resolve(); } + const puts = [...dirty]; + const dels = [...removed]; + dirty.clear(); removed.clear(); + if (!puts.length && !dels.length) { return Promise.resolve(); } + return new Promise((resolve) => { + try { + const tx = db.transaction(STORE, 'readwrite'); + const store = tx.objectStore(STORE); + for (const p of puts) { + if (p.endsWith('/')) { store.put({ path: p }); continue; } + const f = files.get(p); + if (f && !f.readonly) { store.put({ path: p, data: f.data, mtimeMs: f.mtimeMs, ctimeMs: f.ctimeMs }); } + } + for (const p of dels) { store.delete(p); } + tx.oncomplete = () => resolve(); + tx.onerror = () => resolve(); + tx.onabort = () => resolve(); + } catch (e) { resolve(); } + }); +} + +/** + * Mount read-only files under `base`. `assets` maps a path relative to `base` to its content + * (a string, or {base64}). Called at activation with ctx.extensionPath. + */ +function mountAssets(base, assets) { + const root = abs(base); + for (const [rel, value] of Object.entries(assets)) { + const p = pathMod.join(root, rel); + const data = typeof value === 'string' ? Buffer.from(value, 'utf8') : Buffer.from(value.base64, 'base64'); + ensureDirs(p); + files.set(p, { data: Uint8Array.from(data), mtimeMs: 0, ctimeMs: 0, readonly: true }); + } + dirs.add(root); +} + +module.exports = { + existsSync, readFileSync, writeFileSync, appendFileSync, mkdirSync, readdirSync, statSync, lstatSync: statSync, + realpathSync, unlinkSync, rmSync, rmdirSync: (p, o) => rmSync(p, Object.assign({ recursive: false }, o)), renameSync, copyFileSync, + promises, + readFile: callbacks.readFile, writeFile: callbacks.writeFile, appendFile: callbacks.appendFile, mkdir: callbacks.mkdir, + readdir: callbacks.readdir, stat: callbacks.stat, lstat: callbacks.lstat, unlink: callbacks.unlink, rm: callbacks.rm, + rename: callbacks.rename, copyFile: callbacks.copyFile, + // Not part of Node's `fs`: the build's own handles. + __levelcode: { ready, flush, mountAssets, isPersistent: () => persistent, snapshot: () => ({ files: files.size, dirs: dirs.size }) }, +}; diff --git a/web/ai-extension/shims/os.js b/web/ai-extension/shims/os.js new file mode 100644 index 0000000..5969b62 --- /dev/null +++ b/web/ai-extension/shims/os.js @@ -0,0 +1,18 @@ +// `os` for the browser build. The editor has no machine to describe; the extension only needs a +// home directory to hang its private state under (the in-browser file system in shims/fs.js). +'use strict'; +const HOME = '/home/levelcode'; +module.exports = { + EOL: '\n', + homedir: () => HOME, + tmpdir: () => '/tmp', + hostname: () => 'levelcode-web', + platform: () => 'browser', + type: () => 'Browser', + arch: () => 'wasm', + release: () => '', + cpus: () => [], + totalmem: () => 0, + freemem: () => 0, + userInfo: () => ({ username: 'levelcode', homedir: HOME, shell: null, uid: -1, gid: -1 }), +}; diff --git a/web/ai-extension/shims/path.js b/web/ai-extension/shims/path.js new file mode 100644 index 0000000..6c5c0be --- /dev/null +++ b/web/ai-extension/shims/path.js @@ -0,0 +1,121 @@ +// POSIX `path` for the browser build of the extension. The editor's browser workspaces are +// URI paths ("/src/a.js"), so only the posix flavour exists here. Behaviour follows Node's +// path.posix for the members the extension uses; test/webShims.test.js compares them to Node's. +'use strict'; + +function assertPath(p) { if (typeof p !== 'string') { throw new TypeError('The "path" argument must be of type string. Received ' + typeof p); } } + +/** Resolve '.' and '..' segments. `allowAboveRoot` keeps leading '..' for relative paths. */ +function normalizeString(p, allowAboveRoot) { + const out = []; + for (const seg of p.split('/')) { + if (seg === '' || seg === '.') { continue; } + if (seg === '..') { + if (out.length && out[out.length - 1] !== '..') { out.pop(); } + else if (allowAboveRoot) { out.push('..'); } + continue; + } + out.push(seg); + } + return out.join('/'); +} + +const path = { + sep: '/', + delimiter: ':', + + isAbsolute(p) { assertPath(p); return p.length > 0 && p[0] === '/'; }, + + normalize(p) { + assertPath(p); + if (p.length === 0) { return '.'; } + const abs = p[0] === '/'; + const trailing = p[p.length - 1] === '/'; + let out = normalizeString(p, !abs); + if (out.length === 0) { if (abs) { return '/'; } return trailing ? './' : '.'; } + if (trailing) { out += '/'; } + return abs ? '/' + out : out; + }, + + join(...parts) { + const nonEmpty = []; + for (const a of parts) { assertPath(a); if (a.length) { nonEmpty.push(a); } } + if (!nonEmpty.length) { return '.'; } + return path.normalize(nonEmpty.join('/')); + }, + + resolve(...parts) { + let resolved = ''; + let abs = false; + for (let i = parts.length - 1; i >= -1 && !abs; i--) { + const p = i >= 0 ? parts[i] : '/'; + assertPath(p); + if (p.length === 0) { continue; } + resolved = p + '/' + resolved; + abs = p[0] === '/'; + } + resolved = normalizeString(resolved, !abs); + return abs ? '/' + resolved : (resolved.length ? resolved : '.'); + }, + + dirname(p) { + assertPath(p); + if (p.length === 0) { return '.'; } + let end = p.length; + while (end > 1 && p[end - 1] === '/') { end--; } // trailing slashes do not count + const i = p.lastIndexOf('/', end - 1); + if (i === -1) { return '.'; } + if (i === 0) { return '/'; } + let j = i; + while (j > 1 && p[j - 1] === '/') { j--; } // collapse the slashes before the name + return p.slice(0, j); + }, + + basename(p, ext) { + assertPath(p); + let end = p.length; + while (end > 1 && p[end - 1] === '/') { end--; } + if (p === '/' || end === 0) { return ''; } + const start = p.lastIndexOf('/', end - 1) + 1; + let base = p.slice(start, end); + if (ext && base.endsWith(ext) && base !== ext) { base = base.slice(0, -ext.length); } + return base; + }, + + extname(p) { + const base = path.basename(p); + const i = base.lastIndexOf('.'); + return i <= 0 ? '' : base.slice(i); + }, + + relative(from, to) { + assertPath(from); assertPath(to); + if (from === to) { return ''; } + const f = path.resolve(from).split('/').filter(Boolean); + const t = path.resolve(to).split('/').filter(Boolean); + let i = 0; + while (i < f.length && i < t.length && f[i] === t[i]) { i++; } + return [...f.slice(i).map(() => '..'), ...t.slice(i)].join('/'); + }, + + parse(p) { + assertPath(p); + const root = p[0] === '/' ? '/' : ''; + const base = path.basename(p); + const ext = path.extname(p); + let dir = path.dirname(p); + if (dir === '.' && !p.startsWith('.')) { dir = ''; } + return { root, dir, base, ext, name: ext ? base.slice(0, -ext.length) : base }; + }, + + format(o) { + const dir = o.dir || o.root || ''; + const base = o.base || (o.name || '') + (o.ext || ''); + if (!dir) { return base; } + return dir === o.root ? dir + base : dir + '/' + base; + }, +}; + +path.posix = path; +path.win32 = path; +module.exports = path; diff --git a/web/ai-extension/shims/process.js b/web/ai-extension/shims/process.js new file mode 100644 index 0000000..d3e3b5a --- /dev/null +++ b/web/ai-extension/shims/process.js @@ -0,0 +1,18 @@ +// A `process` for code that was written for Node and only reads a few fields from it. esbuild +// injects this wherever the bundle mentions the free variable `process` (see build.mjs). +'use strict'; +const proc = { + platform: 'browser', + arch: 'wasm', + pid: 0, + env: {}, + argv: [], + versions: {}, + version: '', + cwd: () => '/', + kill() { const e = new Error('process.kill is not available in LevelCode in the browser'); e.code = 'ENOSYS'; throw e; }, + nextTick: (fn, ...args) => { queueMicrotask(() => fn(...args)); }, + emitWarning() { /* nothing to warn */ }, +}; +module.exports = proc; +module.exports.process = proc; diff --git a/web/boot.css b/web/boot.css new file mode 100644 index 0000000..8ed92a2 --- /dev/null +++ b/web/boot.css @@ -0,0 +1,26 @@ +/* The splash that covers the page until the workbench has painted. Colours are literals on + purpose: the workbench theme is not loaded yet, and a flash of the wrong ground is worse than + a hard-coded one. */ +:root { color-scheme: dark light; } +html, body { height: 100%; margin: 0; } +body { background: #0b0d14; } +@media (prefers-color-scheme: light) { body { background: #f4f1ea; } } + +#lc-boot { + position: fixed; inset: 0; z-index: 100000; display: grid; place-items: center; + background: #0b0d14; color: #e8e9f0; + font: 500 14px/1.5 -apple-system, BlinkMacSystemFont, "Segoe UI", system-ui, sans-serif; + transition: opacity .35s ease, visibility .35s; +} +@media (prefers-color-scheme: light) { #lc-boot { background: #f4f1ea; color: #15151b; } } +#lc-boot.lc-boot-done { opacity: 0; visibility: hidden; } +.lc-boot-inner { display: grid; justify-items: center; gap: 14px; padding: 24px; text-align: center; } +.lc-mark { width: 64px; height: 64px; } +.lc-word { font-size: 22px; font-weight: 650; letter-spacing: -.01em; } +.lc-bar { width: 160px; height: 2px; border-radius: 2px; background: rgba(127, 127, 150, .25); overflow: hidden; } +.lc-bar i { display: block; width: 40%; height: 100%; border-radius: 2px; background: linear-gradient(90deg, #7C5CD6, #7fd4ff); animation: lc-slide 1.15s ease-in-out infinite; } +.lc-msg { margin: 0; max-width: 34ch; opacity: .7; font-size: 13px; } +.lc-boot-failed .lc-bar { display: none; } +.lc-boot-failed .lc-msg { opacity: 1; } +@keyframes lc-slide { 0% { transform: translateX(-110%); } 100% { transform: translateX(260%); } } +@media (prefers-reduced-motion: reduce) { .lc-bar i { animation: none; width: 100%; opacity: .6; } } diff --git a/web/callback.css b/web/callback.css new file mode 100644 index 0000000..8747878 --- /dev/null +++ b/web/callback.css @@ -0,0 +1,7 @@ +html, body { height: 100%; margin: 0; } +body { display: grid; place-items: center; background: #0b0d14; color: #e8e9f0; font: 400 15px/1.55 -apple-system, BlinkMacSystemFont, "Segoe UI", system-ui, sans-serif; } +@media (prefers-color-scheme: light) { body { background: #f4f1ea; color: #15151b; } } +main { display: grid; justify-items: center; gap: 8px; padding: 24px; text-align: center; max-width: 36ch; } +.mark { width: 56px; height: 56px; color: #7C5CD6; margin-bottom: 8px; } +h1 { margin: 0; font-size: 20px; font-weight: 650; letter-spacing: -.01em; } +p { margin: 0; opacity: .72; } diff --git a/web/callback.html b/web/callback.html new file mode 100644 index 0000000..11e8243 --- /dev/null +++ b/web/callback.html @@ -0,0 +1,22 @@ + + + + + Signed in to LevelCode + + + + + + + +
+ +

Finishing sign-in

+

One moment.

+
+ + + diff --git a/web/callback.js b/web/callback.js new file mode 100644 index 0000000..828c09f --- /dev/null +++ b/web/callback.js @@ -0,0 +1,33 @@ +// The page the sign-in returns to. It hands the result to the editor tab through localStorage +// (the editor listens for the `storage` event) and then closes itself. Same contract as +// Code-OSS's callback.html; everything the page needs is in its own query string. +(function () { + var title = document.getElementById('title'); + var text = document.getElementById('text'); + function say(t, s) { title.textContent = t; text.textContent = s; } + function decode(v) { return v === null ? null : decodeURIComponent(v); } + try { + var params = new URL(location.href).searchParams; + var id = decode(params.get('vscode-reqid')); + var scheme = decode(params.get('vscode-scheme')); + var authority = decode(params.get('vscode-authority')); + if (!id || !scheme || !authority) { throw new Error('This page is only used to finish a sign-in from LevelCode.'); } + var uri = { scheme: scheme, authority: authority }; + var path = decode(params.get('vscode-path')); + var query = decode(params.get('vscode-query')); + var fragment = decode(params.get('vscode-fragment')); + ['vscode-reqid', 'vscode-scheme', 'vscode-authority', 'vscode-path', 'vscode-query', 'vscode-fragment'].forEach(function (k) { params.delete(k); }); + if (path) { uri.path = path; } + if (query) { new URLSearchParams(query).forEach(function (value, key) { params.set(key, value); }); } + var rest = params.toString(); + if (rest) { uri.query = rest; } + if (fragment) { uri.fragment = fragment; } + localStorage.setItem('vscode-web.url-callbacks[' + id + ']', JSON.stringify(uri)); + say('You are signed in', 'You can close this tab and go back to LevelCode.'); + // Take the one-time code out of the address bar and history, then try to close the tab. + history.replaceState(null, '', location.pathname); + setTimeout(function () { try { window.close(); } catch (e) { /* the text above still tells the user */ } }, 600); + } catch (e) { + say('This link does not look right', String((e && e.message) || e)); + } +})(); diff --git a/web/icon.svg b/web/icon.svg new file mode 100644 index 0000000..3b1cffd --- /dev/null +++ b/web/icon.svg @@ -0,0 +1,6 @@ + + + + + + diff --git a/web/index.html b/web/index.html new file mode 100644 index 0000000..66bb3cf --- /dev/null +++ b/web/index.html @@ -0,0 +1,37 @@ + + + + + LevelCode + + + + + + + + + + +
+
+ +
LevelCode
+ +

Opening your editor

+
+ +
+ + + diff --git a/web/lib/config.mjs b/web/lib/config.mjs new file mode 100644 index 0000000..625cdf2 --- /dev/null +++ b/web/lib/config.mjs @@ -0,0 +1,47 @@ +// The page configuration, in one place. `serve.mjs` (development) and `scripts/build-web.mjs` +// (release) both call webConfig() and renderIndex(), so what you test locally is what ships. +import fs from 'node:fs'; +import path from 'node:path'; +import { fileURLToPath } from 'node:url'; + +export const WEB_DIR = path.dirname(path.dirname(fileURLToPath(import.meta.url))); + +/** + * @param {object} o + * @param {string} [o.account] LevelCode Cloud origin the editor signs in to and calls (the gateway lives there). + * @param {string[]} [o.extensions] Folder names, under /extensions, of the extensions loaded as built-ins. + * @param {string} [o.staticBase] Where the Code-OSS web build is mounted. + * @param {object} [o.productConfiguration] Overrides merged over the product the build embeds. + * @param {object} [o.configurationDefaults] + * @param {boolean} [o.development] + */ +export function webConfig(o = {}) { + const account = trimSlash(o.account || 'https://levelcode.ai'); + return { + staticBase: o.staticBase || '/static', + callbackRoute: '/callback.html', + extensions: o.extensions || ['levelcode-ai'], + scratch: { scheme: 'levelcode-scratch', path: '/' }, + trustedDomains: [account], + productConfiguration: Object.assign({}, o.productConfiguration), + configurationDefaults: Object.assign({ + 'levelcode.cloud.endpoint': account, + 'workbench.startupEditor': 'none', + 'telemetry.telemetryLevel': 'off', + 'update.mode': 'none', + 'extensions.autoUpdate': false, + 'extensions.autoCheckUpdates': false, + 'security.workspace.trust.enabled': false, + }, o.configurationDefaults), + development: !!o.development, + }; +} + +export function renderIndex(config, htmlPath = path.join(WEB_DIR, 'index.html')) { + const json = JSON.stringify(config).replace(//g, '\\u003e').replace(/&/g, '\\u0026'); + return fs.readFileSync(htmlPath, 'utf8') + .replaceAll('{{STATIC}}', config.staticBase) + .replace('{{CONFIG}}', () => json); +} + +function trimSlash(s) { return String(s).replace(/\/+$/, ''); } diff --git a/web/main.js b/web/main.js new file mode 100644 index 0000000..f35270c --- /dev/null +++ b/web/main.js @@ -0,0 +1,272 @@ +/*--------------------------------------------------------------------------------------------- + * LevelCode in your browser — page entry. + * + * Code-OSS ships a web client (src/vs/code/browser/workbench/workbench.ts) but it is written for + * its own server: it reads its configuration from a template the server fills in and assumes a + * remote authority. A static host has neither, so this file is the small amount of embedder code + * that a static host needs. It follows workbench.ts closely on purpose (MIT, same behaviour for + * the URL callback, the workspace URL and the secret store) and differs where LevelCode differs: + * + * - configuration comes from @@ -32,6 +34,6 @@ - + diff --git a/web/lib/config.mjs b/web/lib/config.mjs index 625cdf2..6458703 100644 --- a/web/lib/config.mjs +++ b/web/lib/config.mjs @@ -18,14 +18,30 @@ export const WEB_DIR = path.dirname(path.dirname(fileURLToPath(import.meta.url)) export function webConfig(o = {}) { const account = trimSlash(o.account || 'https://levelcode.ai'); return { + // Where the page's own scripts and styles, the Code-OSS web build, and the extensions are mounted. + // A release puts all three under one build-addressed prefix (/_/) so they can be cached for + // a year; the development server mounts them at the root, /static and /extensions. + base: o.base || '', staticBase: o.staticBase || '/static', + extensionsBase: o.extensionsBase || '/extensions', callbackRoute: '/callback.html', extensions: o.extensions || ['levelcode-ai'], - scratch: { scheme: 'levelcode-scratch', path: '/' }, + // The folder is /scratch, not the provider's root: a root named "/" is what the title bar and the + // Explorer would show, and "scratch" is what it is. + scratch: { scheme: 'levelcode-scratch', path: '/scratch' }, trustedDomains: [account], - productConfiguration: Object.assign({}, o.productConfiguration), + productConfiguration: Object.assign({ + // The scratch workspace implements file and text search for its own scheme. Spelling the grant + // out here (rather than relying on "built-in extensions may declare proposals") is the + // sanctioned route and keeps the workbench from logging an error about it at start. + extensionEnabledApiProposals: { 'levelcode.levelcode-web': ['fileSearchProvider', 'textSearchProvider'] }, + }, o.productConfiguration), configurationDefaults: Object.assign({ 'levelcode.cloud.endpoint': account, + // LevelCode is the assistant here; the stock Copilot surfaces (status item, chat view, command + // center) are for an account this editor does not use. The desktop app sets the same. + 'chat.disableAIFeatures': true, + 'chat.commandCenter.enabled': false, 'workbench.startupEditor': 'none', 'telemetry.telemetryLevel': 'off', 'update.mode': 'none', @@ -40,6 +56,7 @@ export function webConfig(o = {}) { export function renderIndex(config, htmlPath = path.join(WEB_DIR, 'index.html')) { const json = JSON.stringify(config).replace(//g, '\\u003e').replace(/&/g, '\\u0026'); return fs.readFileSync(htmlPath, 'utf8') + .replaceAll('{{BASE}}', config.base) .replaceAll('{{STATIC}}', config.staticBase) .replace('{{CONFIG}}', () => json); } diff --git a/web/lib/extensions.mjs b/web/lib/extensions.mjs new file mode 100644 index 0000000..569b75e --- /dev/null +++ b/web/lib/extensions.mjs @@ -0,0 +1,62 @@ +// Which of Code-OSS's built-in extensions the browser edition carries, and how they are staged. +// +// The web build bakes into its bundle only the extensions that have a browser entry point (about +// twenty: JSON, CSS and HTML language features, Markdown, the TypeScript language service...). The +// declarative ones that give an editor its colour — TextMate grammars, language configurations, +// themes, file icons — are not packaged for the web by this checkout's tooling, so without them a +// file is plain text. Declaring them is all they need, so they are staged beside LevelCode's own +// extensions and loaded as additional built-ins (the workbench reads each package.json). +import fs from 'node:fs'; +import path from 'node:path'; + +/** Directories never copied: tests, sources, and package managers' droppings. */ +const SKIP = new Set(['node_modules', 'test', 'tests', 'src', '.vscode', '.git', 'build']); +/** Extensions that exist in a Code-OSS checkout for its own development. */ +const DEV_ONLY = new Set(['vscode-api-tests', 'vscode-colorize-tests', 'vscode-colorize-perf-tests', 'vscode-test-resolver', 'copilot']); + +/** Mirrors build/lib/extensions.ts isWebExtension() for a manifest with neither main nor browser. */ +export function isDeclarativeWebExtension(manifest) { + if (manifest.main || manifest.browser) { return false; } + const c = manifest.contributes || {}; + for (const id of ['debuggers', 'terminal', 'typescriptServerPlugins']) { if (Object.prototype.hasOwnProperty.call(c, id)) { return false; } } + return true; +} + +/** + * @param {string} extensionsDir Code-OSS's extensions/ directory + * @param {string} outDir where to stage + * @param {{ exclude?: Iterable }} [opts] names to leave out (already baked into the bundle) + * @returns {string[]} the staged extension names, sorted + */ +export function stageDeclarativeExtensions(extensionsDir, outDir, opts = {}) { + const exclude = new Set(opts.exclude || []); + const staged = []; + for (const name of fs.readdirSync(extensionsDir).sort()) { + if (exclude.has(name) || DEV_ONLY.has(name)) { continue; } + const dir = path.join(extensionsDir, name); + const pkgPath = path.join(dir, 'package.json'); + if (!fs.statSync(dir).isDirectory() || !fs.existsSync(pkgPath)) { continue; } + let manifest; + try { manifest = JSON.parse(fs.readFileSync(pkgPath, 'utf8')); } catch { continue; } + if (!isDeclarativeWebExtension(manifest)) { continue; } + copyExtension(dir, path.join(outDir, name)); + // The workbench asks every extension for a package.nls.json; an absent one is a 404 in the console. + const nls = path.join(outDir, name, 'package.nls.json'); + if (!fs.existsSync(nls)) { fs.writeFileSync(nls, '{}\n'); } + staged.push(name); + } + return staged; +} + +/** Copy an extension directory without its development files and its translations (English ships). */ +export function copyExtension(from, to) { + fs.mkdirSync(to, { recursive: true }); + for (const e of fs.readdirSync(from, { withFileTypes: true })) { + if (SKIP.has(e.name)) { continue; } + if (/^package\.nls\..+\.json$/.test(e.name)) { continue; } // localisations: the build is English-only + const src = path.join(from, e.name); + const dst = path.join(to, e.name); + if (e.isDirectory()) { copyExtension(src, dst); } + else if (e.isFile()) { fs.copyFileSync(src, dst); } + } +} diff --git a/web/lib/headers.mjs b/web/lib/headers.mjs new file mode 100644 index 0000000..a1602c9 --- /dev/null +++ b/web/lib/headers.mjs @@ -0,0 +1,117 @@ +// The HTTP headers a static host must send for the browser edition, in one place. +// +// They are written out as a `_headers` file (Netlify and Cloudflare Pages read it), an nginx +// snippet, and read back by web/serve.mjs so the end-to-end check runs under the same policy a +// release does. A policy that is only written down is a policy nobody has tried. +// +// What the editor needs, and why the policy is not tighter: +// script-src 'unsafe-eval' Monaco and the TextMate/WASM engines compile code at runtime. +// style-src 'unsafe-inline' the workbench sets styles from script. +// connect-src https: BYOK sends the user's key to the provider they chose, from the tab. +// frame-src webviews and (optionally) the extension host live on origins of +// their own; they are listed explicitly. + +/** + * @param {{ account: string, webviewOrigin?: string, extHostOrigin?: string, base?: string }} o + * webviewOrigin / extHostOrigin: origin patterns such as https://*.view.example.com; omitted when the + * editor uses Code-OSS's default webview host (https://*.vscode-cdn.net) or an in-page extension host. + */ +export function policy(o) { + const frames = ["'self'"]; + frames.push(o.webviewOrigin || 'https://*.vscode-cdn.net'); + if (o.extHostOrigin) { frames.push(o.extHostOrigin); } + frames.push('data:'); + const workbench = [ + "default-src 'self'", + "script-src 'self' 'unsafe-eval' blob:", + "style-src 'self' 'unsafe-inline'", + "img-src 'self' https: data: blob:", + "media-src 'self' blob: data:", + "font-src 'self' blob:", + `connect-src 'self' https: http://localhost:* http://127.0.0.1:*`, + `frame-src ${frames.join(' ')}`, + "worker-src 'self' data: blob:", + "child-src 'self' blob:", + "manifest-src 'self'", + "object-src 'none'", + "base-uri 'none'", + "form-action 'none'", + "frame-ancestors 'none'", + ].join('; '); + const callback = [ + "default-src 'none'", + "script-src 'self'", + "style-src 'self'", + "img-src 'self'", + "base-uri 'none'", + "form-action 'none'", + "frame-ancestors 'none'", + ].join('; '); + const common = { + 'X-Content-Type-Options': 'nosniff', + 'Referrer-Policy': 'no-referrer', + 'Permissions-Policy': 'camera=(), microphone=(), geolocation=(), payment=(), usb=()', + }; + return { + // Exact paths first; a later rule only adds headers the earlier ones did not set. + rules: [ + { path: '/', headers: { ...common, 'Content-Security-Policy': workbench, 'Cache-Control': 'no-cache' } }, + { path: '/index.html', headers: { ...common, 'Content-Security-Policy': workbench, 'Cache-Control': 'no-cache' } }, + { path: '/callback.html', headers: { ...common, 'Content-Security-Policy': callback, 'Cache-Control': 'no-store' } }, + { path: '/callback.js', headers: { 'Cache-Control': 'no-cache' } }, + { path: '/callback.css', headers: { 'Cache-Control': 'no-cache' } }, + { path: '/manifest.webmanifest', headers: { 'Cache-Control': 'no-cache', 'Content-Type': 'application/manifest+json' } }, + // Everything under the build prefix is addressed by its build id: it never changes. + { path: `${o.base || '/_'}/*`, headers: { 'X-Content-Type-Options': 'nosniff', 'Cache-Control': 'public, max-age=31536000, immutable', + 'Access-Control-Allow-Origin': '*', 'Cross-Origin-Resource-Policy': 'cross-origin' } }, + ], + }; +} + +/** Netlify / Cloudflare Pages `_headers` text. */ +export function toHeadersFile(p) { + return p.rules.map((r) => r.path + '\n' + Object.entries(r.headers).map(([k, v]) => ` ${k}: ${v}`).join('\n')).join('\n\n') + '\n'; +} + +/** Parse a `_headers` file back into rules (the dev server serves a built release with it). */ +export function parseHeadersFile(text) { + const rules = []; + let cur = null; + for (const raw of text.split('\n')) { + if (!raw.trim() || raw.trim().startsWith('#')) { continue; } + if (!/^\s/.test(raw)) { cur = { path: raw.trim(), headers: {} }; rules.push(cur); continue; } + const i = raw.indexOf(':'); + if (cur && i > 0) { cur.headers[raw.slice(0, i).trim()] = raw.slice(i + 1).trim(); } + } + return { rules }; +} + +/** Headers for a request path, later and more specific rules overriding earlier ones. */ +export function headersFor(p, pathname) { + const out = {}; + for (const r of p.rules) { + const star = r.path.indexOf('*'); + const match = star < 0 ? r.path === pathname : pathname.startsWith(r.path.slice(0, star)); + if (match) { for (const [k, v] of Object.entries(r.headers)) { if (!(k in out)) { out[k] = v; } } } + } + return out; +} + +/** An nginx `server` fragment with the same rules, for a host that is not Netlify or Cloudflare. */ +export function toNginx(p, root = '/srv/levelcode-web') { + const lines = ['# LevelCode in your browser: a static site, nothing else. Generated by scripts/build-web.mjs.', + 'server {', ' listen 80;', ' server_name _;', ` root ${root};`, ' index index.html;', ' default_type application/octet-stream;', ' gzip on;', + ' gzip_types text/css application/javascript text/javascript application/json image/svg+xml;', '']; + for (const r of p.rules) { + const star = r.path.indexOf('*'); + const loc = star < 0 ? `location = ${r.path}` : `location ^~ ${r.path.slice(0, star)}`; + lines.push(` ${loc} {`); + for (const [k, v] of Object.entries(r.headers)) { + if (k === 'Content-Type') { lines.push(` types { } default_type ${v};`); continue; } + lines.push(` add_header ${k} "${v.replace(/"/g, '\\"')}" always;`); + } + lines.push(' try_files $uri =404;', ' }', ''); + } + lines.push(' location / { try_files $uri $uri/ =404; }', '}'); + return lines.join('\n') + '\n'; +} diff --git a/web/main.js b/web/main.js index f35270c..7711ee0 100644 --- a/web/main.js +++ b/web/main.js @@ -41,7 +41,7 @@ try { additionalBuiltinExtensions: (config.extensions || []).map((name) => ({ scheme: location.protocol.slice(0, -1), authority: location.host, - path: '/extensions/' + name, + path: trimSlash(config.extensionsBase || '/extensions') + '/' + name, })), productConfiguration: config.productConfiguration || {}, configurationDefaults: config.configurationDefaults || {}, @@ -82,7 +82,7 @@ function createWorkspaceProvider(URI, cfg) { else if (key === 'payload') { try { payload = JSON.parse(value); } catch (e) { /* ignore a bad payload */ } } }); if (!found && cfg.scratch && cfg.scratch.scheme) { - workspace = { folderUri: URI.from({ scheme: cfg.scratch.scheme, path: cfg.scratch.path || '/' }) }; + workspace = { folderUri: URI.from({ scheme: cfg.scratch.scheme, path: cfg.scratch.path || '/scratch' }) }; } const query = (w) => { if (!w) { return 'ew=true'; } diff --git a/web/manifest.webmanifest b/web/manifest.webmanifest new file mode 100644 index 0000000..d66e9df --- /dev/null +++ b/web/manifest.webmanifest @@ -0,0 +1,15 @@ +{ + "name": "LevelCode", + "short_name": "LevelCode", + "description": "The AI code editor, in your browser.", + "start_url": "/", + "scope": "/", + "display": "standalone", + "background_color": "#0b0d14", + "theme_color": "#0b0d14", + "icons": [ + { "src": "/icon-192.png", "sizes": "192x192", "type": "image/png" }, + { "src": "/icon-512.png", "sizes": "512x512", "type": "image/png" }, + { "src": "/icon.svg", "sizes": "any", "type": "image/svg+xml", "purpose": "any" } + ] +} diff --git a/web/serve.mjs b/web/serve.mjs index 16980c2..e547e5c 100644 --- a/web/serve.mjs +++ b/web/serve.mjs @@ -9,6 +9,7 @@ import http from 'node:http'; import fs from 'node:fs'; import path from 'node:path'; import { webConfig, renderIndex, WEB_DIR } from './lib/config.mjs'; +import { parseHeadersFile, headersFor } from './lib/headers.mjs'; 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']); } @@ -16,9 +17,12 @@ const args = Object.fromEntries(process.argv.slice(2).reduce((a, v, i, all) => { }, [])); const PORT = Number(args.port || process.env.PORT || 8800); const HOST = args.host || '127.0.0.1'; +// --dist : serve a built release (scripts/build-web.mjs) exactly as a static host would, with its _headers. +const DIST = args.dist ? path.resolve(args.dist) : null; const STATIC_DIR = path.resolve(args.static || process.env.LEVELCODE_WEB_STATIC || ''); const EXT_DIR = path.resolve(args.extensions || process.env.LEVELCODE_WEB_EXTENSIONS || ''); -if (!args.static && !process.env.LEVELCODE_WEB_STATIC) { console.error('missing --static '); process.exit(2); } +if (!DIST && !args.static && !process.env.LEVELCODE_WEB_STATIC) { console.error('missing --static (or --dist )'); process.exit(2); } +const RULES = DIST && fs.existsSync(path.join(DIST, '_headers')) ? parseHeadersFile(fs.readFileSync(path.join(DIST, '_headers'), 'utf8')) : { rules: [] }; const config = webConfig({ account: args.account || process.env.LEVELCODE_ACCOUNT_ORIGIN, @@ -45,30 +49,40 @@ const server = http.createServer((req, res) => { let pathname; try { pathname = decodeURIComponent(url.pathname); } catch { res.writeHead(400).end('bad path'); return; } - if (pathname === '/' || pathname === '/index.html') { - res.writeHead(200, { 'content-type': TYPES['.html'], 'cache-control': 'no-cache' }); - res.end(renderIndex(config)); - return; - } let file = null; - if (pathname.startsWith('/static/')) { file = within(STATIC_DIR, pathname.slice('/static/'.length)); } - else if (pathname.startsWith('/extensions/')) { file = within(EXT_DIR, pathname.slice('/extensions/'.length)); } - else { file = within(WEB_DIR, pathname.slice(1)); } - if (!file || file.includes(`${path.sep}lib${path.sep}`) || file.endsWith('serve.mjs')) { res.writeHead(404).end('not found'); return; } + if (DIST) { + file = within(DIST, pathname === '/' ? 'index.html' : pathname.slice(1)); + if (file && fs.existsSync(file) && fs.statSync(file).isDirectory()) { file = path.join(file, 'index.html'); } + } else { + if (pathname === '/' || pathname === '/index.html') { + res.writeHead(200, { 'content-type': TYPES['.html'], 'cache-control': 'no-cache' }); + res.end(renderIndex(config)); + return; + } + if (pathname.startsWith('/static/')) { file = within(STATIC_DIR, pathname.slice('/static/'.length)); } + else if (pathname.startsWith('/extensions/')) { file = within(EXT_DIR, pathname.slice('/extensions/'.length)); } + else { file = within(WEB_DIR, pathname.slice(1)); } + if (!file || file.includes(`${path.sep}lib${path.sep}`) || file.endsWith('serve.mjs')) { res.writeHead(404).end('not found'); return; } + } + if (!file) { res.writeHead(404).end('not found'); return; } fs.stat(file, (err, st) => { if (err || !st.isFile()) { res.writeHead(404, { 'content-type': 'text/plain' }).end('not found: ' + pathname); return; } - res.writeHead(200, { + const base = { 'content-type': TYPES[path.extname(file)] || 'application/octet-stream', 'content-length': st.size, + }; + const extra = DIST ? headersFor(RULES, pathname === '/index.html' ? '/' : pathname) : { 'cache-control': 'no-cache', // The extension host and the webviews run on origins of their own and fetch these files. // They are public static assets and are never requested with credentials. 'access-control-allow-origin': '*', 'cross-origin-resource-policy': 'cross-origin', - }); + }; + if (extra['Content-Type']) { delete base['content-type']; } + res.writeHead(200, { ...base, ...extra }); if (req.method === 'HEAD') { res.end(); return; } fs.createReadStream(file).pipe(res); }); }); -server.listen(PORT, HOST, () => console.log(`LevelCode web: http://${HOST}:${PORT}/ (static ${STATIC_DIR}, extensions ${EXT_DIR})`)); +server.listen(PORT, HOST, () => console.log(DIST ? `LevelCode web: http://${HOST}:${PORT}/ (release ${DIST})` : `LevelCode web: http://${HOST}:${PORT}/ (static ${STATIC_DIR}, extensions ${EXT_DIR})`)); diff --git a/web/test/e2e.mjs b/web/test/e2e.mjs index 5f14f1c..b459e84 100644 --- a/web/test/e2e.mjs +++ b/web/test/e2e.mjs @@ -17,23 +17,29 @@ import { Chrome, sleep } from './cdp.mjs'; const HERE = path.dirname(fileURLToPath(import.meta.url)); 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; }, [])); +const DIST = args.dist ? path.resolve(args.dist) : null; // a release from scripts/build-web.mjs, served with its own _headers const STATIC = path.resolve(args.static || process.env.LEVELCODE_WEB_STATIC || ''); const EXTS = path.resolve(args.extensions || process.env.LEVELCODE_WEB_EXTENSIONS || ''); const SHOTS = args.shots ? path.resolve(args.shots) : null; const EDITOR_PORT = Number(args.port || 8801); // `localhost`, not 127.0.0.1: the extension host's own iframe policy admits https: and http://localhost:*. const editorOrigin = `http://localhost:${EDITOR_PORT}`; -if (!fs.existsSync(STATIC) || !fs.existsSync(EXTS)) { console.error('usage: e2e.mjs --static --extensions '); process.exit(2); } +if (DIST ? !fs.existsSync(DIST) : (!fs.existsSync(STATIC) || !fs.existsSync(EXTS))) { + console.error('usage: e2e.mjs --dist | e2e.mjs --static --extensions [--stub-port N] [--shots dir]'); + process.exit(2); +} if (SHOTS) { fs.mkdirSync(SHOTS, { recursive: true }); } const results = []; const check = (name, ok, detail = '') => { results.push({ name, ok }); console.log(`${ok ? 'PASS' : 'FAIL'} ${name}${detail ? ' — ' + detail : ''}`); return ok; }; const shot = async (page, name) => { if (SHOTS) { await page.screenshot(path.join(SHOTS, name + '.png')); } }; -const stub = await startStub({ editorOrigin }); -const server = spawn(process.execPath, [path.join(HERE, '..', 'serve.mjs'), '--static', STATIC, '--extensions', EXTS, - '--extension-names', args['extension-names'] || 'levelcode-ai,levelcode-web', '--account', stub.origin, '--port', String(EDITOR_PORT), - ...(args.product ? ['--product', args.product] : [])], { stdio: 'inherit' }); +// A release has its account origin baked into index.html, so the stand-in has to be where it was built to look. +const stub = await startStub({ editorOrigin, port: args['stub-port'] ? Number(args['stub-port']) : 0 }); +const server = spawn(process.execPath, [path.join(HERE, '..', 'serve.mjs'), '--port', String(EDITOR_PORT), ...(DIST + ? ['--dist', DIST] + : ['--static', STATIC, '--extensions', EXTS, '--extension-names', args['extension-names'] || 'levelcode-ai,levelcode-web', '--account', stub.origin, + ...(args.product ? ['--product', args.product] : [])])], { stdio: 'inherit' }); let chrome; const finish = async (code) => { try { chrome && chrome.close(); } catch {} try { server.kill(); } catch {} try { await stub.close(); } catch {} process.exit(code); }; process.on('SIGINT', () => finish(130)); @@ -94,7 +100,8 @@ try { }; // With the extension host isolated on its own origin (--product with webEndpointUrlTemplate) the scratch // workspace's IndexedDB belongs to THAT origin and the page cannot open it; the agent's read_file can. - const hardened = /webEndpointUrlTemplate/.test(args.product || ''); + let hardened = /webEndpointUrlTemplate/.test(args.product || ''); + if (DIST) { try { hardened = /\(same origin/.test(fs.readFileSync(path.join(DIST, 'build.json'), 'utf8')) ? false : true; } catch { /* keep */ } } const scratchFile = hardened ? (async (p) => { await ask('Please read the greeting'); const t = lastToolResult(); @@ -114,6 +121,8 @@ try { return r.length ? r : null; }, { ms: 30000, label: 'explorer rows' }).catch(() => null); check('the scratch workspace is open and has its README', !!rows && rows.includes('README.md'), JSON.stringify(rows)); + const title = await page.waitFor(async () => { const t = await page.eval('document.title'); return /scratch/i.test(t) ? t : null; }, { ms: 10000, label: 'window title' }).catch(() => page.eval('document.title')); + check('the workspace is called "scratch" (the window title), not "/"', /scratch/i.test(String(title)), JSON.stringify(title)); await shot(page, '1-boot'); /* 2. sign in: the user icon in the chat footer opens the account card, which has "Sign in with browser" */ @@ -160,7 +169,7 @@ try { const turns = await ask('Please create a hello file'); check('the agent called the model through the gateway and got the tool result back', turns >= 2, 'turns: ' + turns); - check('write_file created hello.txt in the scratch workspace', (await scratchFile('/hello.txt')) === 'hello from the browser\n', JSON.stringify(await scratchFile('/hello.txt'))); + check('write_file created hello.txt in the scratch workspace', (await scratchFile('/scratch/hello.txt')) === 'hello from the browser\n', JSON.stringify(await scratchFile('/scratch/hello.txt'))); const tools = (stub.state.lastChatBody && stub.state.lastChatBody.tools || []).map((t) => t.function && t.function.name); check('the agent was not offered a shell', tools.length > 0 && !tools.includes('run_command') && !tools.includes('read_command_output') && tools.includes('write_file'), tools.join(',')); await shot(page, '3-agent'); @@ -175,7 +184,7 @@ try { await ask('Please read the greeting'); check('read_file returns the file', /hello from the browser/.test(lastToolResult()), JSON.stringify(lastToolResult().slice(0, 80))); await ask('Please change the greeting'); - check('edit_file edits it in place', (await scratchFile('/hello.txt')) === 'hello again\n', JSON.stringify(await scratchFile('/hello.txt'))); + check('edit_file edits it in place', (await scratchFile('/scratch/hello.txt')) === 'hello again\n', JSON.stringify(await scratchFile('/scratch/hello.txt'))); await shot(page, '3b-edited'); // Quick Open is answered by the same provider. @@ -197,7 +206,7 @@ try { await page.waitFor(() => stub.state.log.filter((e) => e.path === '/api/levelcode/v1/account/models' || e.path === '/api/levelcode/v1/account/profile').length > before, { ms: 30000, label: 'account call after reload' }).catch(() => null); const after = stub.state.log.filter((e) => e.path === '/api/levelcode/v1/account/models' || e.path === '/api/levelcode/v1/account/profile').length; check('after a reload the editor is still signed in', after > before, `${before} -> ${after}`); - check('after a reload the scratch file is still there', (await scratchFile('/hello.txt')) === 'hello again\n'); + check('after a reload the scratch file is still there', (await scratchFile('/scratch/hello.txt')) === 'hello again\n'); await shot(page, '4-reload'); } catch (e) { check('the run completed', false, String((e && e.stack) || e)); diff --git a/web/workspace/extension.js b/web/workspace/extension.js index d459d56..7715821 100644 --- a/web/workspace/extension.js +++ b/web/workspace/extension.js @@ -339,12 +339,18 @@ class ScratchSearch { /* ----- activation ------------------------------------------------------------------------- */ +const HOME = '/scratch'; + async function seedWelcome(fsp) { - // A brand-new, empty scratch workspace gets one file so the Explorer is not a blank panel. + // The workspace folder is /scratch. A brand-new one is created, with one file so the Explorer is not a + // blank panel. An existing one is left exactly as it is. try { - const kids = await fsp.readDirectory(vscode.Uri.from({ scheme: SCHEME, path: '/' })); - if (kids.length === 0) { - await fsp.writeFile(vscode.Uri.from({ scheme: SCHEME, path: '/README.md' }), new TextEncoder().encode(WELCOME), { create: true, overwrite: false }); + const dir = vscode.Uri.from({ scheme: SCHEME, path: HOME }); + let exists = true; + try { await fsp.stat(dir); } catch { exists = false; } + if (!exists) { + await fsp.createDirectory(dir); + await fsp.writeFile(vscode.Uri.from({ scheme: SCHEME, path: HOME + '/README.md' }), new TextEncoder().encode(WELCOME), { create: true, overwrite: false }); return true; } } catch (e) { /* seeding is a courtesy */ } @@ -386,7 +392,7 @@ async function activate(context) { // and tells the user itself when the browser has no support for it. vscode.commands.registerCommand('levelcode.web.openLocalFolder', () => vscode.commands.executeCommand('workbench.action.files.openFolder')), vscode.commands.registerCommand('levelcode.web.openScratch', () => vscode.commands.executeCommand( - 'vscode.openFolder', vscode.Uri.from({ scheme: SCHEME, path: '/' }), { forceReuseWindow: true })), + 'vscode.openFolder', vscode.Uri.from({ scheme: SCHEME, path: HOME }), { forceReuseWindow: true })), vscode.commands.registerCommand('levelcode.web.aboutScratch', async () => { const open = 'Open folder from your computer'; const pick = await vscode.window.showInformationMessage( From 0ebbca1b35aaae0b872ddd7e81d095ca4ee9c6a9 Mon Sep 17 00:00:00 2001 From: Sergii Demianchuk Date: Fri, 9 Oct 2026 19:06:57 -0400 Subject: [PATCH 06/17] 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. --- web/ai-extension/build.mjs | 17 +++++--- web/ai-extension/copy.js | 44 +++++++++++++++++++ web/serve.mjs | 1 + web/test/e2e-byok.mjs | 89 ++++++++++++++++++++++++++++++++++++++ web/test/stub-backend.mjs | 14 +++++- web/test/unit/copy.test.js | 44 +++++++++++++++++++ 6 files changed, 201 insertions(+), 8 deletions(-) create mode 100644 web/ai-extension/copy.js create mode 100644 web/test/e2e-byok.mjs create mode 100644 web/test/unit/copy.test.js diff --git a/web/ai-extension/build.mjs b/web/ai-extension/build.mjs index 4ff27e0..94c4156 100644 --- a/web/ai-extension/build.mjs +++ b/web/ai-extension/build.mjs @@ -36,6 +36,8 @@ const esbuild = await import(esbuildEntry); const nodePaths = [path.join(vscodeDir, 'node_modules'), path.join(vscodeDir, 'build', 'node_modules')].filter((p) => fs.existsSync(p)); if (!nodePaths.some((p) => fs.existsSync(path.join(p, 'buffer')))) { console.error('the "buffer" package is missing from ' + vscodeDir + '/node_modules'); process.exit(2); } +const { applyCopy } = createRequire(import.meta.url)('./copy.js'); + const outDir = path.join(outRoot, 'levelcode-ai'); fs.rmSync(outDir, { recursive: true, force: true }); fs.mkdirSync(outDir, { recursive: true }); @@ -50,7 +52,7 @@ function collect(rel, filter) { if (e.isDirectory()) { walk(r); continue; } if (filter && !filter(r)) { continue; } const buf = fs.readFileSync(path.join(SRC, r)); - out[r] = TEXT.has(path.extname(r)) ? buf.toString('utf8') : { base64: buf.toString('base64') }; + out[r] = TEXT.has(path.extname(r)) ? applyCopy(r, buf.toString('utf8')) : { base64: buf.toString('base64') }; } }; if (fs.existsSync(path.join(SRC, rel))) { walk(rel); } @@ -69,11 +71,14 @@ const dirnamePlugin = { name: 'levelcode-dirname', setup(b) { b.onLoad({ filter: /extensions[\\/]levelcode-ai[\\/].*\.js$/ }, (a) => { - let text = fs.readFileSync(a.path, 'utf8'); - if (!/\b__dirname\b/.test(text)) { return null; } - const rel = path.relative(SRC, path.dirname(a.path)).split(path.sep).join('/'); - text = text.replace(/\b__dirname\b/g, `(globalThis.__lcExtPath + ${JSON.stringify(rel ? '/' + rel : '')})`); - return { contents: text, loader: 'js' }; + const file = path.relative(SRC, a.path).split(path.sep).join('/'); + const original = fs.readFileSync(a.path, 'utf8'); + let text = applyCopy(file, original); // words that are only true on the desktop + if (/\b__dirname\b/.test(text)) { + const rel = path.relative(SRC, path.dirname(a.path)).split(path.sep).join('/'); + text = text.replace(/\b__dirname\b/g, `(globalThis.__lcExtPath + ${JSON.stringify(rel ? '/' + rel : '')})`); + } + return text === original ? null : { contents: text, loader: 'js' }; }); }, }; diff --git a/web/ai-extension/copy.js b/web/ai-extension/copy.js new file mode 100644 index 0000000..0b06072 --- /dev/null +++ b/web/ai-extension/copy.js @@ -0,0 +1,44 @@ +// Words that are true on the desktop and false in a tab, and what the browser build says instead. +// +// The desktop extension's text is written for a Mac: keys in the OS keychain, nothing leaves "your +// machine". In a browser the same facts read differently (keys are sealed in this browser's +// storage; the page runs on the user's machine but the files may be in the browser's own store). +// A claim about where a secret lives has to be right, so the browser build rewrites these exact +// strings and FAILS if one is not found: when the desktop copy changes, the build says so instead +// of quietly shipping the old claim. +// +// Plain CommonJS; web/test/unit/copy.test.js pins it against the real sources. +'use strict'; + +/** [file relative to extensions/levelcode-ai, exact desktop text, browser text] */ +const REPLACEMENTS = [ + ['extension.js', + 'Stored encrypted in your OS keychain — it never leaves your machine except to ', + 'Stored encrypted in this browser — it never leaves this browser except to '], + ['media/chat.html', + 'Bring your own API key, stored in your OS keychain.', + 'Bring your own API key, stored encrypted in this browser.'], + ['media/chat.html', + 'Your keys never leave your machine — requests go direct to your provider.', + 'Your keys never leave this browser — requests go direct to your provider.'], +]; + +/** + * @param {string} file path relative to extensions/levelcode-ai + * @param {string} text the file's contents + * @returns {string} the text with every replacement for `file` applied + * @throws if a replacement's desktop text is not in the file + */ +function applyCopy(file, text) { + let out = text; + for (const [f, from, to] of REPLACEMENTS) { + if (f !== file) { continue; } + if (!out.includes(from)) { + throw new Error(`browser copy: "${from.slice(0, 60)}…" is no longer in ${file}; update web/ai-extension/copy.js`); + } + out = out.split(from).join(to); + } + return out; +} + +module.exports = { REPLACEMENTS, applyCopy }; diff --git a/web/serve.mjs b/web/serve.mjs index e547e5c..102551f 100644 --- a/web/serve.mjs +++ b/web/serve.mjs @@ -29,6 +29,7 @@ const config = webConfig({ extensions: (args['extension-names'] ?? 'levelcode-ai,levelcode-web').split(',').filter(Boolean), development: true, productConfiguration: args.product ? JSON.parse(args.product) : undefined, + configurationDefaults: args['config-defaults'] ? JSON.parse(args['config-defaults']) : undefined, }); const TYPES = { diff --git a/web/test/e2e-byok.mjs b/web/test/e2e-byok.mjs new file mode 100644 index 0000000..7b5c539 --- /dev/null +++ b/web/test/e2e-byok.mjs @@ -0,0 +1,89 @@ +#!/usr/bin/env node +// Bring-your-own-key in a tab: set a key through the real command, ask the agent for something, and see +// that the request went from the browser straight to the provider with that key — and that no LevelCode +// account token was involved. Uses the same stand-in backend as e2e.mjs, as an OpenAI-compatible provider. +// +// node web/test/e2e-byok.mjs --static --extensions +// +// The provider settings (custom, OpenAI-compatible, at the stand-in's address) are configuration defaults of +// the development server; a release bakes its own defaults, so this runs against the development layout. +import { spawn } from 'node:child_process'; +import fs from 'node:fs'; +import path from 'node:path'; +import { fileURLToPath } from 'node:url'; +import { startStub } from './stub-backend.mjs'; +import { Chrome, sleep } from './cdp.mjs'; + +const HERE = path.dirname(fileURLToPath(import.meta.url)); +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; }, [])); +const STATIC = path.resolve(args.static || process.env.LEVELCODE_WEB_STATIC || ''); +const EXTS = path.resolve(args.extensions || process.env.LEVELCODE_WEB_EXTENSIONS || ''); +const PORT = Number(args.port || 8805); +const editorOrigin = `http://localhost:${PORT}`; +const KEY = ['sk', 'byok', 'test', '0123456789'].join('-'); +const results = []; +const check = (name, ok, detail = '') => { results.push(ok); console.log(`${ok ? 'PASS' : 'FAIL'} ${name}${detail ? ' — ' + detail : ''}`); }; + +const stub = await startStub({ editorOrigin, port: args['stub-port'] ? Number(args['stub-port']) : 0 }); +const defaults = JSON.stringify({ 'levelcode.ai.providerMode': 'byok', 'levelcode.ai.provider': 'custom', 'levelcode.ai.baseURL': stub.origin + '/v1' }); +const server = spawn(process.execPath, [path.join(HERE, '..', 'serve.mjs'), '--port', String(PORT), + '--static', STATIC, '--extensions', EXTS, '--extension-names', 'levelcode-ai,levelcode-web', '--account', stub.origin, '--config-defaults', defaults], { stdio: 'inherit' }); +let chrome; +const finish = async (code) => { try { chrome && chrome.close(); } catch {} try { server.kill(); } catch {} try { await stub.close(); } catch {} process.exit(code); }; + +try { + for (let i = 0; i < 50; i++) { try { if ((await fetch(editorOrigin + '/')).ok) { break; } } catch {} await sleep(100); } + chrome = await Chrome.launch({ port: Number(args['debug-port'] || 9556), args: ['--ignore-certificate-errors'] }); + const page = await chrome.newPage(editorOrigin + '/'); + await page.waitFor(() => page.eval(`!!document.querySelector('.monaco-workbench .part.editor')`), { ms: 90000, label: 'workbench' }); + 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' }); + await sleep(2500); + + const palette = async (text) => { + await page.key('F1', 'F1'); + await page.waitFor(() => page.eval(`!!document.querySelector('.quick-input-widget:not([style*="display: none"]) .quick-input-box input')`), { ms: 8000, label: 'command palette' }); + await page.type(text); + await sleep(900); + await page.key('Enter', 'Enter'); + }; + + await palette('Set API Key'); + 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' }); + const prompt = await page.eval(`(document.querySelector('.quick-input-widget .quick-input-message') || {}).textContent`); + 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)); + await page.type(KEY); + await page.key('Enter', 'Enter'); + await sleep(800); + + // the chat webview + const activate = async () => { + 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 }; })()`); + await page.click(r.x, r.y); + await sleep(700); + }; + await activate(); + const inChat = async (expr) => { + for (const [id, s] of page.sessions) { + if (s.type !== 'iframe') { continue; } + try { + 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 }); + if (v && !v.__no) { return v.v; } + } catch { /* another frame */ } + } + return undefined; + }; + await page.waitFor(async () => (await inChat('!!d.getElementById("send")')) === true, { ms: 30000, label: 'chat' }); + const label = await inChat(`(d.getElementById('st-key') || {}).textContent`); + check('the chat shows that it is using a key of the user\'s own', /BYO|key/i.test(String(label)), JSON.stringify(label)); + 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; })()`); + await page.waitFor(() => stub.state.chats >= 2, { ms: 60000, label: 'two model turns' }).catch(() => null); + check('the agent reached the provider straight from the tab', stub.state.chats >= 2, 'turns: ' + stub.state.chats); + const auth = stub.state.byokAuth || []; + 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))); + 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))); +} catch (e) { + check('the run completed', false, String((e && e.stack) || e)); +} +const failed = results.filter((r) => !r).length; +console.log(`\n${results.length - failed}/${results.length} checks passed`); +await finish(failed ? 1 : 0); diff --git a/web/test/stub-backend.mjs b/web/test/stub-backend.mjs index e15f871..5b80d16 100644 --- a/web/test/stub-backend.mjs +++ b/web/test/stub-backend.mjs @@ -85,7 +85,7 @@ export async function startStub(o) { cors.vary = 'Origin'; cors['access-control-expose-headers'] = ''; } - if (req.method === 'OPTIONS') { + if (req.method === 'OPTIONS' && url.pathname !== '/v1/chat/completions') { if (cors['access-control-allow-origin']) { const wanted = String(req.headers['access-control-request-headers'] || '').toLowerCase().split(',').map((s) => s.trim()).filter(Boolean); const ok = wanted.every((h) => ['authorization', 'content-type', 'accept'].includes(h)); @@ -158,7 +158,17 @@ export async function startStub(o) { ] }); } - if (url.pathname === '/api/levelcode/v1/ai/chat/completions' && req.method === 'POST') { + // A provider of the user's own (BYOK): any origin may call it, as real providers allow, and the + // key arrives as the bearer. Same scripted model as the gateway. + const isByok = url.pathname === '/v1/chat/completions'; + if (isByok) { + cors['access-control-allow-origin'] = '*'; + cors['access-control-allow-headers'] = 'authorization, content-type'; + cors['access-control-allow-methods'] = 'POST, OPTIONS'; + if (req.method === 'OPTIONS') { res.writeHead(204, cors); res.end(); return; } + state.byokAuth = (state.byokAuth || []).concat(req.headers.authorization || ''); + } + if ((url.pathname === '/api/levelcode/v1/ai/chat/completions' || isByok) && req.method === 'POST') { const body = JSON.parse((await readBody()) || '{}'); state.chats++; state.lastChatBody = body; diff --git a/web/test/unit/copy.test.js b/web/test/unit/copy.test.js new file mode 100644 index 0000000..ddbfe50 --- /dev/null +++ b/web/test/unit/copy.test.js @@ -0,0 +1,44 @@ +/*--------------------------------------------------------------------------------------------- + * The browser build's copy replacements — run: node web/test/unit/copy.test.js + * + * Every replacement must still find its desktop text in the real source (otherwise the browser + * build would quietly ship the old claim), and no replacement may say something the browser + * cannot stand behind. + *--------------------------------------------------------------------------------------------*/ +// @ts-check +'use strict'; + +const assert = require('assert'); +const fs = require('fs'); +const path = require('path'); +const { REPLACEMENTS, applyCopy } = require('../../ai-extension/copy.js'); + +const SRC = path.join(__dirname, '..', '..', '..', 'extensions', 'levelcode-ai'); +let n = 0; +function test(name, fn) { fn(); n++; console.log(' ok - ' + name); } + +test('every desktop string is still in the source it names', () => { + for (const [file, from] of REPLACEMENTS) { + const text = fs.readFileSync(path.join(SRC, file), 'utf8'); + assert.ok(text.includes(from), `${file} no longer contains: ${from}`); + } +}); +test('applying them changes the file and leaves no desktop-only claim about keys behind', () => { + for (const file of new Set(REPLACEMENTS.map((r) => r[0]))) { + const text = fs.readFileSync(path.join(SRC, file), 'utf8'); + const out = applyCopy(file, text); + assert.notStrictEqual(out, text, file + ' came out unchanged'); + for (const [f, from] of REPLACEMENTS) { if (f === file) { assert.ok(!out.includes(from), 'still says: ' + from); } } + } +}); +test('a replacement whose desktop text has gone is an error, not a silent skip', () => { + assert.throws(() => applyCopy('extension.js', 'nothing relevant here'), /no longer in extension\.js/); +}); +test('the browser text claims only what the browser build does', () => { + for (const [, , to] of REPLACEMENTS) { + assert.ok(!/keychain|your machine|OS\b/i.test(to), 'the browser text still talks like the desktop: ' + to); + assert.ok(!/hackable/i.test(to)); + } +}); + +console.log(`\n${n} tests passed`); From 97c8af90f8e573970095c79648231b8a7ad50d51 Mon Sep 17 00:00:00 2001 From: Sergii Demianchuk Date: Fri, 9 Oct 2026 19:12:41 -0400 Subject: [PATCH 07/17] test(web): the end-to-end check also covers Keep and Undo, and runs against a release Undo of a file the agent created removes it, and so does everything else still pending from the session; Keep clears the bar. The account card that sign-in leaves open is closed the way a user would, before the chat is clicked. --- web/test/e2e.mjs | 27 +++++++++++++++++++++++++-- web/test/stub-backend.mjs | 6 +++++- 2 files changed, 30 insertions(+), 3 deletions(-) diff --git a/web/test/e2e.mjs b/web/test/e2e.mjs index b459e84..3fcc3da 100644 --- a/web/test/e2e.mjs +++ b/web/test/e2e.mjs @@ -103,7 +103,7 @@ try { let hardened = /webEndpointUrlTemplate/.test(args.product || ''); if (DIST) { try { hardened = /\(same origin/.test(fs.readFileSync(path.join(DIST, 'build.json'), 'utf8')) ? false : true; } catch { /* keep */ } } const scratchFile = hardened ? (async (p) => { - await ask('Please read the greeting'); + await ask('Please read ' + p.replace(/^\/scratch\//, '')); const t = lastToolResult(); return /ERROR/.test(t) ? null : t; }) : (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); })`); @@ -146,6 +146,11 @@ try { /* 3. chat and agent */ await activateTab(/LevelCode AI/); await page.waitFor(async () => (await inChat('!!d.getElementById("send")')) === true, { ms: 30000, label: 'chat webview' }); + // Signing in leaves the account card open over the chat, as it does on the desktop; close it like a user would. + if ((await inChat(`getComputedStyle(d.getElementById('acctClose')).display !== 'none' && d.getElementById('acctClose').offsetParent !== null`)) === true) { + await clickInChat('#acctClose'); + await sleep(400); + } check('the chat webview renders from the editor', true); /** Send a prompt and wait for the agent to finish its turns (a tool call, then its answer). */ const idle = () => inChat(`!d.getElementById('send').classList.contains('stop')`); @@ -197,7 +202,25 @@ try { const entries = await page.eval(`[...document.querySelectorAll('.quick-input-list .monaco-list-row')].map((e) => e.getAttribute('aria-label') || e.textContent)`); check('Quick Open finds files in the scratch workspace', entries.some((t) => /hello\.txt/.test(t)), JSON.stringify(entries.slice(0, 4))); await page.key('Escape', 'Escape'); + await page.waitFor(() => page.eval(`(() => { const w = document.querySelector('.quick-input-widget'); return !w || getComputedStyle(w).display === 'none'; })()`), { ms: 5000, label: 'quick open to close' }).catch(() => null); } else { check('Quick Open finds files in the scratch workspace', false, 'the quick open widget did not appear'); } + await activateTab(/LevelCode AI/); // focus goes back to the chat before it is clicked + + // Review: an agent edit is applied at once and offered for Keep or Undo. Undo of a file the agent created removes it. + await ask('Please make a notes file'); + check('the agent created notes.md', (await scratchFile('/scratch/notes.md')) === 'notes\n'); + await page.waitFor(async () => (await inChat(`getComputedStyle(d.getElementById('reviewBar')).display !== 'none'`)) === true, { ms: 10000, label: 'review bar' }).catch(() => null); + check('the chat offers Keep and Undo for it', (await inChat(`getComputedStyle(d.getElementById('reviewBar')).display !== 'none' && !!d.querySelector('#reviewBar .keepall') && !!d.querySelector('#reviewBar .undoall')`)) === true); + await shot(page, '3c-review'); + await clickInChat('#reviewBar .undoall'); + await sleep(2000); + check('Undo all removes the file the agent created', (await scratchFile('/scratch/notes.md')) === null); + check('and everything else still pending: hello.txt, created and edited earlier in the session, is gone too', (await scratchFile('/scratch/hello.txt')) === null); + await ask('Please make a notes file'); + await page.waitFor(async () => (await inChat(`getComputedStyle(d.getElementById('reviewBar')).display !== 'none'`)) === true, { ms: 10000, label: 'review bar' }).catch(() => null); + await clickInChat('#reviewBar .keepall'); + await sleep(1500); + check('Keep all keeps it, and the review bar goes away', (await scratchFile('/scratch/notes.md')) === 'notes\n' && (await inChat(`getComputedStyle(d.getElementById('reviewBar')).display === 'none'`)) === true); /* 4. a reload keeps the session and the files */ const before = stub.state.log.filter((e) => e.path === '/api/levelcode/v1/account/models' || e.path === '/api/levelcode/v1/account/profile').length; @@ -206,7 +229,7 @@ try { await page.waitFor(() => stub.state.log.filter((e) => e.path === '/api/levelcode/v1/account/models' || e.path === '/api/levelcode/v1/account/profile').length > before, { ms: 30000, label: 'account call after reload' }).catch(() => null); const after = stub.state.log.filter((e) => e.path === '/api/levelcode/v1/account/models' || e.path === '/api/levelcode/v1/account/profile').length; check('after a reload the editor is still signed in', after > before, `${before} -> ${after}`); - check('after a reload the scratch file is still there', (await scratchFile('/scratch/hello.txt')) === 'hello again\n'); + check('after a reload the file that was kept is still there', (await scratchFile('/scratch/notes.md')) === 'notes\n'); await shot(page, '4-reload'); } catch (e) { check('the run completed', false, String((e && e.stack) || e)); diff --git a/web/test/stub-backend.mjs b/web/test/stub-backend.mjs index 5b80d16..8d9eee1 100644 --- a/web/test/stub-backend.mjs +++ b/web/test/stub-backend.mjs @@ -52,7 +52,11 @@ export function defaultModel(messages) { if (/\bcreate\b/i.test(text)) { return call('write_file', { path: 'hello.txt', content: 'hello from the browser\n', explanation: 'Create the greeting file' }, 'I will create the file now.'); } if (/\blist\b/i.test(text)) { return call('list_files', {}, 'Let me look at the files.'); } if (/\bsearch\b/i.test(text)) { return call('search', { query: 'hello', explanation: 'Find the greeting' }, 'Searching.'); } - if (/\bread\b/i.test(text)) { return call('read_file', { path: 'hello.txt', explanation: 'Read the greeting file' }, 'Reading it.'); } + if (/\bmake\b/i.test(text)) { return call('write_file', { path: 'notes.md', content: 'notes\n', explanation: 'Make the notes file' }, 'Making it.'); } + if (/\bread\b/i.test(text)) { + const named = text.match(/read\s+(?:the\s+)?(\S+\.\w+)/i); + return call('read_file', { path: named ? named[1] : 'hello.txt', explanation: 'Read the file' }, 'Reading it.'); + } if (/\bchange\b/i.test(text)) { return call('edit_file', { path: 'hello.txt', old_str: 'hello from the browser', new_str: 'hello again', explanation: 'Change the greeting' }, 'Changing it.'); } return { text: 'Hello from the stand-in gateway.' }; } From ddd0e6950a2b0b5b93fcad59bd3bd8614d466bf7 Mon Sep 17 00:00:00 2001 From: Sergii Demianchuk Date: Fri, 9 Oct 2026 19:26:14 -0400 Subject: [PATCH 08/17] web: sign in without a second window MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A pop-up opened from a chain that begins in a webview is the first thing a strict browser (Safari, a phone) refuses. The editor now takes its own tab to the account site's sign-in and the sign-in returns to the same tab: - host.openAuth: in the browser the extension asks the page to navigate (levelcode.web.openAuthUrl, an embedder command) with the address the editor's own opener would have used; without the command it opens as before. The desktop is untouched. - web/main.js registers the command. It only goes to the account site's /ai/ pages: any extension in the page can call it, and it must not be an open redirect. - callback.html returns the tab to the editor when this tab left it (a sessionStorage mark), and otherwise says to close the tab and offers a link in. - At start-up the editor delivers a result the sign-in left in localStorage (a callback.html result written in the last two minutes: the one-time code lives for one), and ?signin=1 — the account site's "Open in browser" — starts the sign-in once and removes itself from the address. Tests: unit (host.openAuth; main.js's leaveForSignIn, drain and start-up, mutation-checked) and an end-to-end in headless Chrome (web/test/e2e-signin-tab.mjs): no second window, the verifier survives the reload, no repeat, no sign-in for a signed-in editor, a result waiting for a gone editor is delivered and an old one is dropped. --- extensions/levelcode-ai/host.js | 31 +++- web/ai-extension/copy.js | 6 +- web/callback.css | 2 + web/callback.html | 1 + web/callback.js | 20 ++- web/lib/config.mjs | 1 + web/main.js | 103 ++++++++++++- web/test/e2e-signin-tab.mjs | 131 ++++++++++++++++ web/test/unit/host.test.js | 44 +++++- web/test/unit/main.test.js | 259 ++++++++++++++++++++++++++++++++ 10 files changed, 589 insertions(+), 9 deletions(-) create mode 100644 web/test/e2e-signin-tab.mjs create mode 100644 web/test/unit/main.test.js diff --git a/extensions/levelcode-ai/host.js b/extensions/levelcode-ai/host.js index 7a59a57..fefc250 100644 --- a/extensions/levelcode-ai/host.js +++ b/extensions/levelcode-ai/host.js @@ -141,6 +141,35 @@ async function withReads(run) { return result; } +/* ----- sending the user to sign in ------------------------------------------------------------- */ + +/** Registered by the page (web/main.js); it takes the tab to the account site and the sign-in comes back to it. */ +const OPEN_AUTH_COMMAND = 'levelcode.web.openAuthUrl'; + +/** + * Open the account site's sign-in page. The desktop hands it to the system browser, as ever. A browser + * tab leaves the page for it: a pop-up opened from a chain that began in a webview is the first thing a + * strict browser (Safari, a phone) refuses, and the sign-in returns to the editor in the same tab. The + * page's entry script supplies the command that navigates; without it this falls back to openExternal. + * + * The string handed over is the one the editor's own opener would have opened — `encodeURI(uri.toString(true))` + * (src/vs/editor/browser/services/openerService.ts) — so the server sees the same address whichever way + * the sign-in was started, as it has from the desktop app all along. + * @param {vscode.Uri} uri + */ +async function openAuth(uri) { + if (isBrowser) { + try { + const known = await vscode.commands.getCommands(true); + if (known.includes(OPEN_AUTH_COMMAND)) { + await vscode.commands.executeCommand(OPEN_AUTH_COMMAND, encodeURI(uri.toString(true))); + return; + } + } catch { /* fall through to the pop-up */ } + } + await vscode.env.openExternal(uri); +} + /* ----- finding files ------------------------------------------------------------------------- */ /** @@ -156,7 +185,7 @@ function findFiles(glob, exclude, max) { module.exports = { isBrowser, caps, - uriFor, readBytes, readText, readTextOrNull, stat, exists, isBinary, withReads, findFiles, + uriFor, readBytes, readText, readTextOrNull, stat, exists, isBinary, withReads, findFiles, openAuth, // For tests. _path: path, }; diff --git a/web/ai-extension/copy.js b/web/ai-extension/copy.js index 0b06072..4d733e6 100644 --- a/web/ai-extension/copy.js +++ b/web/ai-extension/copy.js @@ -1,4 +1,4 @@ -// Words that are true on the desktop and false in a tab, and what the browser build says instead. +// What the desktop extension says or does that is not true in a tab, and what the browser build uses instead. // // The desktop extension's text is written for a Mac: keys in the OS keychain, nothing leaves "your // machine". In a browser the same facts read differently (keys are sealed in this browser's @@ -15,6 +15,10 @@ const REPLACEMENTS = [ ['extension.js', 'Stored encrypted in your OS keychain — it never leaves your machine except to ', 'Stored encrypted in this browser — it never leaves this browser except to '], + // Sign-in leaves the tab for the account page and returns to it (host.openAuth); the desktop opens its browser. + ['extension.js', + "\tdbg('account.signin', { provider: provider || 'browser', create: !!create });\n\tawait vscode.env.openExternal(vscode.Uri.parse(url));", + "\tdbg('account.signin', { provider: provider || 'browser', create: !!create });\n\tawait host.openAuth(vscode.Uri.parse(url));"], ['media/chat.html', 'Bring your own API key, stored in your OS keychain.', 'Bring your own API key, stored encrypted in this browser.'], diff --git a/web/callback.css b/web/callback.css index 8747878..3f92b50 100644 --- a/web/callback.css +++ b/web/callback.css @@ -5,3 +5,5 @@ main { display: grid; justify-items: center; gap: 8px; padding: 24px; text-align .mark { width: 56px; height: 56px; color: #7C5CD6; margin-bottom: 8px; } h1 { margin: 0; font-size: 20px; font-weight: 650; letter-spacing: -.01em; } p { margin: 0; opacity: .72; } +[hidden] { display: none !important; } +a { color: inherit; text-underline-offset: 3px; } diff --git a/web/callback.html b/web/callback.html index 11e8243..c854715 100644 --- a/web/callback.html +++ b/web/callback.html @@ -16,6 +16,7 @@

Finishing sign-in

One moment.

+ diff --git a/web/callback.js b/web/callback.js index 828c09f..76c09e1 100644 --- a/web/callback.js +++ b/web/callback.js @@ -22,11 +22,25 @@ var rest = params.toString(); if (rest) { uri.query = rest; } if (fragment) { uri.fragment = fragment; } + // `at` lets the editor tell this result from one left behind long ago; the URL it is revived from ignores it. + uri.at = Date.now(); localStorage.setItem('vscode-web.url-callbacks[' + id + ']', JSON.stringify(uri)); - say('You are signed in', 'You can close this tab and go back to LevelCode.'); - // Take the one-time code out of the address bar and history, then try to close the tab. + // Take the one-time code out of the address bar and history. history.replaceState(null, '', location.pathname); - setTimeout(function () { try { window.close(); } catch (e) { /* the text above still tells the user */ } }, 600); + var home = new URL('./', location.href).href; + var back = false; + // Set by the editor when it took THIS tab to the sign-in (web/main.js); the editor clears it at start-up. + try { back = sessionStorage.getItem('levelcode-web.return') === '1'; } catch (e) { /* no storage: the pop-up path */ } + if (back) { + // This tab left the editor for the sign-in: go back. The editor finds the result at start-up. + say('You are signed in', 'Back to LevelCode.'); + location.replace(home); + } else { + say('You are signed in', 'You can close this tab and go back to LevelCode.'); + var more = document.getElementById('more'); + if (more) { more.hidden = false; more.querySelector('a').href = home; } + setTimeout(function () { try { window.close(); } catch (e) { /* the text above still tells the user */ } }, 600); + } } catch (e) { say('This link does not look right', String((e && e.message) || e)); } diff --git a/web/lib/config.mjs b/web/lib/config.mjs index 6458703..68a8c1f 100644 --- a/web/lib/config.mjs +++ b/web/lib/config.mjs @@ -21,6 +21,7 @@ export function webConfig(o = {}) { // Where the page's own scripts and styles, the Code-OSS web build, and the extensions are mounted. // A release puts all three under one build-addressed prefix (/_/) so they can be cached for // a year; the development server mounts them at the root, /static and /extensions. + account, base: o.base || '', staticBase: o.staticBase || '/static', extensionsBase: o.extensionsBase || '/extensions', diff --git a/web/main.js b/web/main.js index 7711ee0..2887e5d 100644 --- a/web/main.js +++ b/web/main.js @@ -23,6 +23,13 @@ const staticBase = trimSlash(new URL(config.staticBase || '/static', origin).hre // The workbench resolves every asset (css, nls, workers, codicon font) against this root. globalThis._VSCODE_FILE_ROOT = staticBase + '/out/'; +// Declared up here, not beside the sign-in code: the block below awaits, and a const further down the module +// is not initialised until it finishes. +/** sessionStorage: this tab went to the account site's sign-in and is expected back (callback.js reads it). */ +const RETURN_KEY = 'levelcode-web.return'; +/** How long a sign-in result left in localStorage stays deliverable. The one-time code behind it lives for a minute. */ +const FRESH_MS = 2 * 60 * 1000; + const boot = createBootScreen(); try { @@ -32,11 +39,18 @@ try { const secrets = await createSecretStorage(); const workspaceProvider = createWorkspaceProvider(URI, config); + const callbacks = createUrlCallbackProvider(URI, config.callbackRoute || '/callback.html'); const workbench = create(document.body, { windowIndicator: { label: '$(globe) LevelCode Web', tooltip: 'LevelCode in your browser' }, workspaceProvider, - urlCallbackProvider: createUrlCallbackProvider(URI, config.callbackRoute || '/callback.html'), + urlCallbackProvider: callbacks, + commands: [{ + // The extension asks the page to take the tab to the account site's sign-in. A page opened by + // script is what a strict browser blocks; leaving the tab and coming back (callback.js) is not. + id: 'levelcode.web.openAuthUrl', + handler: (url) => leaveForSignIn(url, config), + }], secretStorageProvider: secrets, additionalBuiltinExtensions: (config.extensions || []).map((name) => ({ scheme: location.protocol.slice(0, -1), @@ -54,6 +68,7 @@ try { // create() resolves once the workbench has been constructed. The splash goes when its DOM exists. void workbench; boot.whenWorkbenchReady(); + startReturnFromSignIn(callbacks, URI); } catch (err) { console.error('[levelcode-web] failed to start', err); boot.fail(err); @@ -69,6 +84,54 @@ function readConfig() { function trimSlash(s) { return s.replace(/\/+$/, ''); } +/* ----- signing in ------------------------------------------------------------------------- */ + +/** + * Take this tab to the account site's sign-in page. Only that site's /ai/ pages: this is a command any + * extension in the page could call, and it must not be a way to send the tab anywhere else. + */ +function leaveForSignIn(url, cfg) { + let target; + try { target = new URL(String(url)); } catch { return; } + let account; + try { account = new URL(cfg.account); } catch { return; } + if (target.origin !== account.origin || !target.pathname.startsWith('/ai/')) { + console.warn('[levelcode-web] refused to navigate to', target.origin + target.pathname); + return; + } + // callback.html is a page of this tab when this is set: it comes straight back instead of closing. + try { sessionStorage.setItem(RETURN_KEY, '1'); } catch { /* the pop-up path still works without it */ } + location.assign(target.href); +} + +/** + * Two things can be waiting when the page starts, and both reach the extension as the "URL" the operating + * system would have given it on the desktop: + * - a sign-in that took this tab away and brought it back (callback.html) left its result in + * localStorage; it is delivered now. Only a result written in the last two minutes is: an older one is + * a code that cannot be exchanged any more, and is dropped; + * - ?signin=1 (the account site's "Open in browser") asks for the sign-in itself, once. The parameter is + * removed first, so a reload does not start it again. + */ +function startReturnFromSignIn(callbacks, URI) { + const here = new URL(location.href); + const launch = here.searchParams.get('signin') === '1'; + if (launch) { + here.searchParams.delete('signin'); + try { history.replaceState(null, '', here.pathname + here.search + here.hash); } catch { /* cosmetic */ } + } + try { sessionStorage.removeItem(RETURN_KEY); } catch { /* nothing to clear */ } + void callbacks.whenListening().then((listening) => { + // With nobody listening a result would be taken out of storage and dropped; it stays for the next start. + if (!listening) { return; } + // Deliveries made before the extension has registered its handler are held by the workbench and + // replayed to it (ExtensionUrlBootstrapHandler / uriBuffer), so there is nothing to wait for. + const delivered = callbacks.drain(FRESH_MS); + if (launch && delivered === 0) { + callbacks.fire(URI.from({ scheme: 'levelcode', authority: 'levelcode.levelcode-ai', path: '/launch' })); + } + }); +} /** The URL decides the workspace, exactly as Code-OSS's web client does: ?folder= / ?workspace= / ?ew=true. */ function createWorkspaceProvider(URI, cfg) { const params = new URL(location.href).searchParams; @@ -139,6 +202,44 @@ function createUrlCallbackProvider(URI, callbackRoute) { return { onCallback: (listener) => { listeners.add(listener); return { dispose: () => listeners.delete(listener) }; }, + /** Resolves true once the workbench's URL service is listening (it subscribes while it starts); false if it never did. */ + whenListening() { + return new Promise((resolve) => { + const t0 = Date.now(); + const tick = () => { + if (listeners.size > 0) { resolve(true); } + else if (Date.now() - t0 > 30000) { resolve(false); } + else { setTimeout(tick, 100); } + }; + tick(); + }); + }, + /** + * Deliver results a sign-in left in localStorage that this page was not waiting for (it left and came + * back). One not written within `maxAgeMs` is removed and not delivered. Returns how many were. + */ + drain(maxAgeMs) { + const keys = []; + for (let i = 0; i < localStorage.length; i++) { + const k = localStorage.key(i); + if (k && k.startsWith('vscode-web.url-callbacks[')) { keys.push(k); } + } + let n = 0; + for (const k of keys) { + const raw = localStorage.getItem(k); + localStorage.removeItem(k); + try { + const data = JSON.parse(raw); + if (typeof data.at === 'number' && Date.now() - data.at > maxAgeMs) { continue; } + const uri = URI.revive(data); + listeners.forEach((l) => l(uri)); + n++; + } catch (e) { console.error(e); } + } + return n; + }, + /** Hand the workbench a URL as if the OS had opened it. */ + fire(uri) { listeners.forEach((l) => l(uri)); }, create(options = {}) { const id = ++nextId; const query = [`vscode-reqid=${id}`]; diff --git a/web/test/e2e-signin-tab.mjs b/web/test/e2e-signin-tab.mjs new file mode 100644 index 0000000..e8ee9d2 --- /dev/null +++ b/web/test/e2e-signin-tab.mjs @@ -0,0 +1,131 @@ +#!/usr/bin/env node +// Signing in without a second window. +// +// A pop-up opened from a chain that began in a webview is the first thing a strict browser (Safari, a +// phone) refuses, so the editor takes its own tab to the account site and the sign-in comes back to it: +// +// /?signin=1 -> the editor starts the sign-in by itself (the account site's "Open in browser" link) +// -> the tab goes to /ai/login (stand-in, already signed in there) +// -> /callback.html writes the result and returns the tab to / +// -> the editor delivers it to the extension, which exchanges the code +// +// What this proves, in a real (headless) Chrome: +// - no second window is ever opened, and the tab ends on a clean address; +// - the sign-in completes across the page reload (the PKCE verifier survived it) and is not repeated; +// - ?signin=1 on a signed-in editor does not sign in again; +// - a result left by a callback page whose editor is gone is delivered when the editor next starts, +// and an old one is not. +// +// node web/test/e2e-signin-tab.mjs --static --extensions +// node web/test/e2e-signin-tab.mjs --dist +import { spawn } from 'node:child_process'; +import fs from 'node:fs'; +import path from 'node:path'; +import { fileURLToPath } from 'node:url'; +import { startStub } from './stub-backend.mjs'; +import { Chrome, sleep } from './cdp.mjs'; + +const HERE = path.dirname(fileURLToPath(import.meta.url)); +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; }, [])); +const DIST = args.dist ? path.resolve(args.dist) : null; +const STATIC = path.resolve(args.static || process.env.LEVELCODE_WEB_STATIC || ''); +const EXTS = path.resolve(args.extensions || process.env.LEVELCODE_WEB_EXTENSIONS || ''); +const PORT = Number(args.port || 8806); +// `localhost`, not 127.0.0.1: the extension host's own iframe policy admits https: and http://localhost:*. +const editorOrigin = `http://localhost:${PORT}`; +if (DIST ? !fs.existsSync(DIST) : (!fs.existsSync(STATIC) || !fs.existsSync(EXTS))) { + console.error('usage: e2e-signin-tab.mjs --dist | e2e-signin-tab.mjs --static --extensions [--stub-port N]'); + process.exit(2); +} + +const results = []; +const check = (name, ok, detail = '') => { results.push(ok); console.log(`${ok ? 'PASS' : 'FAIL'} ${name}${detail ? ' — ' + detail : ''}`); return ok; }; + +const stub = await startStub({ editorOrigin, port: args['stub-port'] ? Number(args['stub-port']) : 0 }); +const server = spawn(process.execPath, [path.join(HERE, '..', 'serve.mjs'), '--port', String(PORT), ...(DIST + ? ['--dist', DIST] + : ['--static', STATIC, '--extensions', EXTS, '--extension-names', 'levelcode-ai,levelcode-web', '--account', stub.origin])], { stdio: 'inherit' }); +let chrome; +const finish = async (code) => { try { chrome && chrome.close(); } catch {} try { server.kill(); } catch {} try { await stub.close(); } catch {} process.exit(code); }; +process.on('SIGINT', () => finish(130)); + +const exchanges = () => stub.state.log.filter((e) => e.path === '/api/levelcode/v1/auth/exchange' && e.method === 'POST').length; +const accountCalls = () => stub.state.log.filter((e) => /^\/api\/levelcode\/v1\/account\/(profile|models)$/.test(e.path) && e.auth).length; + +try { + for (let i = 0; i < 50; i++) { try { if ((await fetch(editorOrigin + '/')).ok) { break; } } catch {} await sleep(100); } + chrome = await Chrome.launch({ port: Number(args['debug-port'] || 9557), args: ['--ignore-certificate-errors'] }); + /** The tabs and windows the browser has open, other than the blank one it started with. */ + const openPages = async (page) => (await page.targets()).filter((t) => t.type === 'page' && t.url !== 'about:blank'); + const workbenchReady = (page) => page.waitFor(() => page.eval(`!!document.querySelector('.monaco-workbench .part.editor')`), { ms: 90000, label: 'workbench' }); + + /* 1. ?signin=1 on a signed-out editor */ + const page = await chrome.newPage(editorOrigin + '/?signin=1'); + await page.waitFor(() => stub.state.signIns >= 1, { ms: 90000, label: 'the sign-in page to be asked' }).catch(() => null); + check('?signin=1 starts the sign-in with no click', stub.state.signIns >= 1, 'sign-ins asked of the account site: ' + stub.state.signIns); + await page.waitFor(() => exchanges() >= 1, { ms: 90000, label: 'code exchange' }).catch(() => null); + check('the code was exchanged, with the verifier that was kept across the reload', exchanges() === 1, 'exchanges: ' + exchanges()); + const exch = stub.state.log.find((e) => e.path === '/api/levelcode/v1/auth/exchange' && e.method === 'POST') || {}; + check('the exchange came from the editor\'s origin (CORS answered)', exch.origin === editorOrigin, 'from ' + exch.origin); + await workbenchReady(page); + await page.waitFor(() => accountCalls() >= 1, { ms: 30000, label: 'the account API with the new token' }).catch(() => null); + check('the editor then used its session against the account API', accountCalls() >= 1, 'calls: ' + accountCalls()); + + const pages = await openPages(page); + check('no second window or tab was opened', pages.length === 1, JSON.stringify(pages.map((t) => t.url.slice(0, 60)))); + const here = new URL(await page.eval('location.href')); + check('the tab ends on the editor with nothing of the sign-in left in its address', here.origin === editorOrigin && here.pathname === '/' && here.search === '' && here.hash === '', here.href); + const leftover = await page.eval(`Object.keys(localStorage).filter((k) => k.startsWith('vscode-web.url-callbacks['))`); + check('the result was taken out of storage', Array.isArray(leftover) && leftover.length === 0, JSON.stringify(leftover)); + const flag = await page.eval(`sessionStorage.getItem('levelcode-web.return')`); + check('the tab is no longer marked as one expected back', flag === null, JSON.stringify(flag)); + + await sleep(6000); + check('and the sign-in is not repeated', stub.state.signIns === 1 && exchanges() === 1, `sign-ins ${stub.state.signIns}, exchanges ${exchanges()}`); + + /* 2. a signed-in editor opened with ?signin=1 does not sign in again */ + const calls = accountCalls(); + await page.goto(editorOrigin + '/?signin=1'); + await workbenchReady(page); + await page.waitFor(() => accountCalls() > calls, { ms: 30000, label: 'account call after the reload' }).catch(() => null); + await sleep(4000); + check('after a reload the editor is still signed in', accountCalls() > calls, `${calls} -> ${accountCalls()}`); + check('?signin=1 on a signed-in editor does not sign in again', stub.state.signIns === 1 && exchanges() === 1, `sign-ins ${stub.state.signIns}, exchanges ${exchanges()}`); + const again = new URL(await page.eval('location.href')); + check('the parameter is gone from the address', again.search === '', again.search); + + /* 3. a callback page whose editor tab is gone: the result waits, and the next start delivers it */ + const orphan = await chrome.newPage(); + const base = `${editorOrigin}/callback.html?vscode-reqid=7&vscode-scheme=levelcode&vscode-authority=levelcode.levelcode-ai&vscode-path=%2Fauth%2Fcallback`; + await orphan.goto(base + '&code=left-behind'); + await orphan.waitFor(() => orphan.eval(`document.getElementById('title').textContent`).then((t) => /signed in/i.test(t)), { ms: 15000, label: 'callback page' }); + const text = await orphan.eval(`document.getElementById('text').textContent`); + check('a callback page that was not opened from the editor says to close the tab', /close this tab/i.test(text), JSON.stringify(text)); + const link = await orphan.eval(`(() => { const m = document.getElementById('more'); return m && !m.hidden ? m.querySelector('a').href : null; })()`); + check('and offers a way into the editor, for a tab that cannot close itself', link === editorOrigin + '/', JSON.stringify(link)); + const stored = await orphan.eval(`JSON.parse(localStorage.getItem('vscode-web.url-callbacks[7]') || 'null')`); + check('the result is in storage, dated, without the code in the address bar', !!stored && stored.query === 'code=left-behind' && typeof stored.at === 'number' && !/code=/.test(await orphan.eval('location.href')), JSON.stringify(stored)); + // The editor tab that is already open hears the storage event for an id it is not waiting on and ignores it. + await sleep(1500); + check('an editor that is not waiting for it leaves it alone', (await orphan.eval(`localStorage.getItem('vscode-web.url-callbacks[7]') !== null`)) === true); + const before = exchanges(); + await orphan.goto(editorOrigin + '/'); + await workbenchReady(orphan); + await orphan.waitFor(() => exchanges() > before, { ms: 60000, label: 'delivery at start-up' }).catch(() => null); + check('the next start delivers it to the extension (the stand-in refuses the made-up code; the attempt is the proof)', exchanges() === before + 1, `${before} -> ${exchanges()}`); + check('and it is gone from storage', (await orphan.eval(`localStorage.getItem('vscode-web.url-callbacks[7]')`)) === null); + + // An old one is not delivered. Written here with an age beyond the limit, by hand. + await orphan.eval(`localStorage.setItem('vscode-web.url-callbacks[8]', JSON.stringify({ scheme: 'levelcode', authority: 'levelcode.levelcode-ai', path: '/auth/callback', query: 'code=too-old', at: Date.now() - 10 * 60 * 1000 }))`); + const before2 = exchanges(); + await orphan.goto(editorOrigin + '/'); + await workbenchReady(orphan); + await sleep(8000); + check('a result older than the code could live is dropped, not delivered', exchanges() === before2 && (await orphan.eval(`localStorage.getItem('vscode-web.url-callbacks[8]')`)) === null, `${before2} -> ${exchanges()}`); +} catch (e) { + check('the run completed', false, String((e && e.stack) || e)); +} + +const failed = results.filter((r) => !r).length; +console.log(`\n${results.length - failed}/${results.length} checks passed`); +await finish(failed ? 1 : 0); diff --git a/web/test/unit/host.test.js b/web/test/unit/host.test.js index 531924a..6c8f565 100644 --- a/web/test/unit/host.test.js +++ b/web/test/unit/host.test.js @@ -17,11 +17,14 @@ const Module = require('module'); // ---- a vscode stand-in with just what host.js touches --------------------------------------- class Uri { - constructor(scheme, p) { this.scheme = scheme; this.path = p; this.fsPath = p; } + constructor(scheme, p, query = '') { this.scheme = scheme; this.path = p; this.fsPath = p; this.query = query; } static file(p) { return new Uri('file', p); } - with(o) { return new Uri(o.scheme || this.scheme, o.path != null ? o.path : this.path); } - toString() { return this.scheme + '://' + this.path; } + with(o) { return new Uri(o.scheme || this.scheme, o.path != null ? o.path : this.path, o.query != null ? o.query : this.query); } + // Enough of vscode.Uri for the sign-in address: toString(true) keeps `=` and `&` and encodes only `?` and `#`. + toString(skipEncoding) { return this.scheme + '://' + this.path + (this.query ? '?' + (skipEncoding ? this.query.replace(/[?#]/g, (c) => (c === '?' ? '%3F' : '%23')) : encodeURIComponent(this.query)) : ''); } } +/** What the sign-in tests watch: commands run, and what was handed to the system browser. */ +const signIn = { commands: /** @type {string[]} */ ([]), executed: /** @type {any[][]} */ ([]), external: /** @type {any[]} */ ([]), getCommandsThrows: false }; const vsFiles = new Map(); // path -> Uint8Array, for the browser-side `workspace.fs` const reads = []; const vscodeMock = { @@ -39,6 +42,11 @@ const vscodeMock = { }, findFiles: (...a) => ['findFiles', ...a], }, + commands: { + async getCommands() { if (signIn.getCommandsThrows) { throw new Error('no ext host'); } return signIn.commands; }, + async executeCommand(...a) { signIn.executed.push(a); }, + }, + env: { async openExternal(u) { signIn.external.push(u); return true; } }, }; const origLoad = Module._load; // @ts-ignore @@ -90,6 +98,15 @@ const enc = (s) => new TextEncoder().encode(s); assert.deepStrictEqual(out, ['hello', null]); }); + await test('desktop: openAuth hands the address to the system browser and runs no command', async () => { + signIn.commands = ['levelcode.web.openAuthUrl']; // even if one were registered + signIn.executed.length = 0; signIn.external.length = 0; + const uri = new Uri('https', '/ai/login', 'redirect_uri=a&code_challenge=b'); + await host.openAuth(uri); + assert.deepStrictEqual(signIn.external, [uri]); + assert.deepStrictEqual(signIn.executed, []); + }); + /* ---- the browser ---- */ globalThis.__LEVELCODE_BROWSER_HOST__ = true; host = load(); @@ -153,6 +170,27 @@ const enc = (s) => new TextEncoder().encode(s); await test('browser: findFiles is the editor\'s own search', () => { assert.deepStrictEqual(host.findFiles('**/*', 'x', 5), ['findFiles', '**/*', 'x', 5]); }); + await test('browser: openAuth takes the tab there, with the address the editor\'s own opener would have used', async () => { + signIn.commands = ['workbench.action.files.save', 'levelcode.web.openAuthUrl']; signIn.getCommandsThrows = false; + signIn.executed.length = 0; signIn.external.length = 0; + const uri = new Uri('https', '/ai/login', 'redirect_uri=https://e.example/callback.html?x=1&code_challenge=b'); + await host.openAuth(uri); + assert.strictEqual(signIn.external.length, 0, 'no pop-up'); + assert.deepStrictEqual(signIn.executed, [['levelcode.web.openAuthUrl', encodeURI(uri.toString(true))]]); + const sent = signIn.executed[0][1]; + assert.ok(/[?]redirect_uri=https:\/\/e\.example\/callback\.html%253Fx=1&code_challenge=b$/.test(sent), 'a query a server can split: ' + sent); + }); + await test('browser: without the page\'s command, or when the command list cannot be had, it opens as before', async () => { + const uri = new Uri('https', '/ai/login', 'a=b'); + signIn.commands = ['workbench.action.files.save']; signIn.executed.length = 0; signIn.external.length = 0; + await host.openAuth(uri); + assert.deepStrictEqual(signIn.executed, []); + assert.deepStrictEqual(signIn.external, [uri]); + signIn.commands = ['levelcode.web.openAuthUrl']; signIn.getCommandsThrows = true; signIn.external.length = 0; + await host.openAuth(uri); + assert.deepStrictEqual(signIn.external, [uri]); + signIn.getCommandsThrows = false; + }); console.log(`\n${n} tests passed`); })().catch((e) => { console.error(e); process.exit(1); }); diff --git a/web/test/unit/main.test.js b/web/test/unit/main.test.js new file mode 100644 index 0000000..1561eb2 --- /dev/null +++ b/web/test/unit/main.test.js @@ -0,0 +1,259 @@ +/*--------------------------------------------------------------------------------------------- + * web/main.js — run: node web/test/unit/main.test.js + * + * main.js is a page entry: it reads the DOM and awaits the workbench at the top level, so it cannot be + * imported. The functions whose behaviour matters are plain top-level declarations, and this test runs + * them as they are written (it cuts them out of the file) against stand-ins for the globals they use. + * + * leaveForSignIn the one command the page offers an extension: it must take the tab to the + * account site's /ai/ pages and nowhere else + * createUrlCallbackProvider .drain / .whenListening: what is delivered when the page starts + * startReturnFromSignIn ?signin=1 and the return from a sign-in + *--------------------------------------------------------------------------------------------*/ +// @ts-check +'use strict'; + +const assert = require('assert'); +const fs = require('fs'); +const path = require('path'); + +const SOURCE = fs.readFileSync(path.join(__dirname, '..', '..', 'main.js'), 'utf8'); + +/** The text of a top-level `function name(...) { ... }` — it ends at the first `}` in column 0. */ +function extract(name) { + const start = SOURCE.indexOf('\nfunction ' + name + '('); + assert.ok(start >= 0, 'main.js has no top-level function ' + name); + const end = SOURCE.indexOf('\n}\n', start); + assert.ok(end > start, 'could not find the end of ' + name); + return SOURCE.slice(start + 1, end + 2); +} +/** The value of a top-level `const NAME = ;` on one line. */ +function constant(name) { + const m = SOURCE.match(new RegExp('^const ' + name + ' = (.+);$', 'm')); + assert.ok(m, 'main.js has no top-level const ' + name); + return new Function('return (' + m[1] + ')')(); +} + +const RETURN_KEY = constant('RETURN_KEY'); +const FRESH_MS = constant('FRESH_MS'); + +let n = 0; +async function test(name, fn) { await fn(); n++; console.log(' ok - ' + name); } + +/* ---- leaveForSignIn ------------------------------------------------------------------------------- */ +function makeLeave() { + const assigned = []; + const session = new Map(); + const warned = []; + const location = { assign: (u) => assigned.push(u) }; + const sessionStorage = { setItem: (k, v) => session.set(k, v), getItem: (k) => (session.has(k) ? session.get(k) : null), removeItem: (k) => session.delete(k) }; + const console_ = { warn: (...a) => warned.push(a.join(' ')) }; + const leave = new Function('location', 'sessionStorage', 'console', 'RETURN_KEY', 'URL', + extract('leaveForSignIn') + '\nreturn leaveForSignIn;')(location, sessionStorage, console_, RETURN_KEY, URL); + return { leave, assigned, session, warned }; +} +const ACCOUNT = 'https://levelcode.example'; + +(async () => { + await test('leaveForSignIn: the account site\'s sign-in page is where the tab goes, and the tab is marked as one to bring back', () => { + const t = makeLeave(); + const url = ACCOUNT + '/ai/login?redirect_uri=https://editor.example/callback.html%3Fvscode-reqid%3D1&code_challenge=abc&code_challenge_method=S256'; + t.leave(url, { account: ACCOUNT }); + assert.deepStrictEqual(t.assigned, [url]); + assert.strictEqual(t.session.get(RETURN_KEY), '1'); + }); + await test('leaveForSignIn: provider sign-in pages of the account site are fine too', () => { + const t = makeLeave(); + t.leave(ACCOUNT + '/ai/auth/oauth/github?redirect_uri=x', { account: ACCOUNT }); + assert.strictEqual(t.assigned.length, 1); + }); + await test('leaveForSignIn: an account given with a path or a trailing slash is still the same origin', () => { + const t = makeLeave(); + t.leave(ACCOUNT + '/ai/login', { account: ACCOUNT + '/' }); + assert.strictEqual(t.assigned.length, 1); + }); + await test('leaveForSignIn: nowhere else — other hosts, other paths, other schemes, tricks', () => { + const refused = [ + 'https://evil.example/ai/login', // another site + 'https://levelcode.example.evil.example/ai/login', // a host that begins with the account's + 'https://levelcode.example@evil.example/ai/login', // userinfo: the host is evil.example + 'http://levelcode.example/ai/login', // the same host, another scheme + 'https://levelcode.example:8443/ai/login', // another port + ACCOUNT + '/api/levelcode/v1/auth/web_handoff', // the account site, but not a sign-in page + ACCOUNT + '/ai', // /ai/ is the prefix, with its slash + ACCOUNT + '/aix/login', + ACCOUNT + '/ai/../api/anything', // dot segments are resolved before the test + ACCOUNT + '/ai%2Flogin', + 'javascript:alert(1)', + 'data:text/html,', + '//levelcode.example/ai/login', // no scheme: not an absolute address + '/ai/login', + 'not a url', + '', + ]; + for (const u of refused) { + const t = makeLeave(); + t.leave(u, { account: ACCOUNT }); + assert.deepStrictEqual(t.assigned, [], 'went to ' + JSON.stringify(u)); + assert.strictEqual(t.session.size, 0, 'marked the tab for ' + JSON.stringify(u)); + } + }); + await test('leaveForSignIn: arguments that are not addresses, and a page with no usable account, do nothing', () => { + for (const bad of [undefined, null, 42, {}, ['https://levelcode.example/ai/login', 'x']]) { + const t = makeLeave(); + t.leave(bad, { account: ACCOUNT }); + if (Array.isArray(bad)) { continue; } // String([..]) joins; the origin test still decides, below + assert.deepStrictEqual(t.assigned, [], 'went somewhere for ' + JSON.stringify(bad)); + } + const t = makeLeave(); + t.leave(ACCOUNT + '/ai/login', { account: '' }); + t.leave(ACCOUNT + '/ai/login', {}); + assert.deepStrictEqual(t.assigned, []); + }); + await test('leaveForSignIn: sessionStorage being unavailable does not stop the sign-in', () => { + const assigned = []; + const leave = new Function('location', 'sessionStorage', 'console', 'RETURN_KEY', 'URL', + extract('leaveForSignIn') + '\nreturn leaveForSignIn;')( + { assign: (u) => assigned.push(u) }, + { setItem() { throw new Error('denied'); } }, console, RETURN_KEY, URL); + leave(ACCOUNT + '/ai/login', { account: ACCOUNT }); + assert.strictEqual(assigned.length, 1); + }); + + /* ---- the callback provider --------------------------------------------------------------------- */ + function makeProvider(entries, now = 1_000_000) { + const store = new Map(Object.entries(entries)); + const localStorage = { + get length() { return store.size; }, + key: (i) => [...store.keys()][i] ?? null, + getItem: (k) => (store.has(k) ? store.get(k) : null), + setItem: (k, v) => store.set(k, String(v)), + removeItem: (k) => store.delete(k), + }; + const realNow = Date.now; + Date.now = () => now; + const URI = { revive: (d) => ({ ...d, revived: true }), from: (c) => c }; + const win = { addEventListener() {}, removeEventListener() {} }; + const factory = new Function('URI', 'localStorage', 'window', 'location', 'setTimeout', 'console', + extract('createUrlCallbackProvider') + '\nreturn createUrlCallbackProvider;'); + const provider = factory(URI, localStorage, win, { protocol: 'https:', host: 'editor.example' }, setTimeout, console)(URI, '/callback.html'); + return { provider, store, restore: () => { Date.now = realNow; } }; + } + const KEY = (i) => 'vscode-web.url-callbacks[' + i + ']'; + const NOW = 1_000_000; + + await test('drain: a fresh result is delivered once and removed; one with no age is delivered too', () => { + const t = makeProvider({ + [KEY(1)]: JSON.stringify({ scheme: 'levelcode', authority: 'levelcode.levelcode-ai', path: '/auth/callback', query: 'code=a', at: NOW - 1000 }), + [KEY(2)]: JSON.stringify({ scheme: 'levelcode', authority: 'levelcode.levelcode-ai', path: '/auth/callback', query: 'code=b' }), + unrelated: 'keep me', + }); + try { + const got = []; + t.provider.onCallback((u) => got.push(u)); + assert.strictEqual(t.provider.drain(FRESH_MS), 2); + assert.deepStrictEqual(got.map((u) => u.query).sort(), ['code=a', 'code=b']); + assert.ok(got.every((u) => u.revived), 'delivered as URIs'); + assert.deepStrictEqual([...t.store.keys()], ['unrelated'], 'only the sign-in results were taken'); + assert.strictEqual(t.provider.drain(FRESH_MS), 0, 'and not again'); + } finally { t.restore(); } + }); + await test('drain: a result older than the limit is removed and NOT delivered', () => { + const t = makeProvider({ + [KEY(1)]: JSON.stringify({ scheme: 'levelcode', authority: 'levelcode.levelcode-ai', path: '/auth/callback', query: 'code=old', at: NOW - FRESH_MS - 1 }), + }); + try { + const got = []; + t.provider.onCallback((u) => got.push(u)); + assert.strictEqual(t.provider.drain(FRESH_MS), 0); + assert.deepStrictEqual(got, []); + assert.strictEqual(t.store.size, 0, 'a code that cannot be exchanged any more is not kept'); + } finally { t.restore(); } + }); + await test('drain: an entry that is not JSON is dropped without stopping the rest', () => { + const t = makeProvider({ + [KEY(1)]: '{not json', + [KEY(2)]: JSON.stringify({ scheme: 'levelcode', authority: 'levelcode.levelcode-ai', path: '/auth/callback', query: 'code=ok', at: NOW }), + }); + const errors = console.error; + console.error = () => {}; + try { + const got = []; + t.provider.onCallback((u) => got.push(u)); + assert.strictEqual(t.provider.drain(FRESH_MS), 1); + assert.strictEqual(got[0].query, 'code=ok'); + assert.strictEqual(t.store.size, 0); + } finally { console.error = errors; t.restore(); } + }); + await test('whenListening: true as soon as the workbench has subscribed, false when it never does', async () => { + const t = makeProvider({}); + try { + let state = 'pending'; + const p = t.provider.whenListening().then((v) => { state = v; return v; }); + await new Promise((r) => setTimeout(r, 30)); + assert.strictEqual(state, 'pending'); + t.provider.onCallback(() => {}); + assert.strictEqual(await p, true); + } finally { t.restore(); } + }); + + /* ---- startReturnFromSignIn --------------------------------------------------------------------- */ + function makeStart(href, { drained = 0, listening = true, sessionThrows = false } = {}) { + const calls = { replaced: [], drain: [], fired: [], removed: [] }; + const location = { href }; + const history = { replaceState: (_s, _t, url) => calls.replaced.push(url) }; + const sessionStorage = { removeItem: (k) => { if (sessionThrows) { throw new Error('denied'); } calls.removed.push(k); } }; + const callbacks = { + whenListening: async () => listening, + drain: (ms) => { calls.drain.push(ms); return drained; }, + fire: (u) => calls.fired.push(u), + }; + const URI = { from: (c) => c }; + const start = new Function('location', 'history', 'sessionStorage', 'RETURN_KEY', 'FRESH_MS', 'URL', + extract('startReturnFromSignIn') + '\nreturn startReturnFromSignIn;')(location, history, sessionStorage, RETURN_KEY, FRESH_MS, URL); + return { start: () => start(callbacks, URI), calls }; + } + const tick = () => new Promise((r) => setTimeout(r, 5)); + + await test('start: ?signin=1 asks for the sign-in once, as the launch link, and leaves no trace in the address', async () => { + const t = makeStart('https://editor.example/?signin=1&keep=this'); + t.start(); await tick(); + assert.deepStrictEqual(t.calls.replaced, ['/?keep=this']); + assert.deepStrictEqual(t.calls.fired, [{ scheme: 'levelcode', authority: 'levelcode.levelcode-ai', path: '/launch' }]); + assert.deepStrictEqual(t.calls.drain, [FRESH_MS]); + assert.deepStrictEqual(t.calls.removed, [RETURN_KEY], 'the tab is no longer one that is expected back'); + }); + await test('start: when a sign-in result was waiting, it is delivered and no second sign-in is started', async () => { + const t = makeStart('https://editor.example/?signin=1', { drained: 1 }); + t.start(); await tick(); + assert.deepStrictEqual(t.calls.fired, []); + }); + await test('start: a plain start only delivers what is waiting', async () => { + const t = makeStart('https://editor.example/', { drained: 0 }); + t.start(); await tick(); + assert.deepStrictEqual(t.calls.replaced, [], 'the address is left alone'); + assert.deepStrictEqual(t.calls.fired, []); + assert.deepStrictEqual(t.calls.drain, [FRESH_MS]); + }); + await test('start: signin with any other value is not the parameter', async () => { + for (const q of ['?signin=0', '?signin=true', '?signin=', '?Signin=1']) { + const t = makeStart('https://editor.example/' + q); + t.start(); await tick(); + assert.deepStrictEqual(t.calls.fired, [], q); + assert.deepStrictEqual(t.calls.replaced, [], q); + } + }); + await test('start: with nobody listening nothing is taken out of storage and nothing is started', async () => { + const t = makeStart('https://editor.example/?signin=1', { listening: false }); + t.start(); await tick(); + assert.deepStrictEqual(t.calls.drain, []); + assert.deepStrictEqual(t.calls.fired, []); + }); + await test('start: sessionStorage being unavailable is not an error', async () => { + const t = makeStart('https://editor.example/?signin=1', { sessionThrows: true }); + t.start(); await tick(); + assert.strictEqual(t.calls.fired.length, 1); + }); + + console.log(`\n${n} tests passed`); +})().catch((e) => { console.error(e); process.exit(1); }); From 146f0f33f7fffa60120bf249ee3ea15c34e3e05a Mon Sep 17 00:00:00 2001 From: Sergii Demianchuk Date: Fri, 9 Oct 2026 19:30:50 -0400 Subject: [PATCH 09/17] test(web): the same-tab sign-in check also runs with the extension host on its own origin --- web/test/e2e-signin-tab.mjs | 14 ++++++++++---- 1 file changed, 10 insertions(+), 4 deletions(-) diff --git a/web/test/e2e-signin-tab.mjs b/web/test/e2e-signin-tab.mjs index e8ee9d2..c71beba 100644 --- a/web/test/e2e-signin-tab.mjs +++ b/web/test/e2e-signin-tab.mjs @@ -16,8 +16,8 @@ // - a result left by a callback page whose editor is gone is delivered when the editor next starts, // and an old one is not. // -// node web/test/e2e-signin-tab.mjs --static --extensions -// node web/test/e2e-signin-tab.mjs --dist +// node web/test/e2e-signin-tab.mjs --static --extensions [--product ] +// node web/test/e2e-signin-tab.mjs --dist --stub-port import { spawn } from 'node:child_process'; import fs from 'node:fs'; import path from 'node:path'; @@ -44,7 +44,12 @@ const check = (name, ok, detail = '') => { results.push(ok); console.log(`${ok ? const stub = await startStub({ editorOrigin, port: args['stub-port'] ? Number(args['stub-port']) : 0 }); const server = spawn(process.execPath, [path.join(HERE, '..', 'serve.mjs'), '--port', String(PORT), ...(DIST ? ['--dist', DIST] - : ['--static', STATIC, '--extensions', EXTS, '--extension-names', 'levelcode-ai,levelcode-web', '--account', stub.origin])], { stdio: 'inherit' }); + : ['--static', STATIC, '--extensions', EXTS, '--extension-names', 'levelcode-ai,levelcode-web', '--account', stub.origin, + ...(args.product ? ['--product', args.product] : [])])], { stdio: 'inherit' }); +// With the extension host on an origin of its own (--product with webEndpointUrlTemplate, or a release built with +// --ext-host-origin) its fetches carry THAT origin, not the editor's. +let hardened = /webEndpointUrlTemplate/.test(args.product || ''); +if (DIST) { try { hardened = !/\(same origin/.test(fs.readFileSync(path.join(DIST, 'build.json'), 'utf8')); } catch { /* keep */ } } let chrome; const finish = async (code) => { try { chrome && chrome.close(); } catch {} try { server.kill(); } catch {} try { await stub.close(); } catch {} process.exit(code); }; process.on('SIGINT', () => finish(130)); @@ -66,7 +71,8 @@ try { await page.waitFor(() => exchanges() >= 1, { ms: 90000, label: 'code exchange' }).catch(() => null); check('the code was exchanged, with the verifier that was kept across the reload', exchanges() === 1, 'exchanges: ' + exchanges()); const exch = stub.state.log.find((e) => e.path === '/api/levelcode/v1/auth/exchange' && e.method === 'POST') || {}; - check('the exchange came from the editor\'s origin (CORS answered)', exch.origin === editorOrigin, 'from ' + exch.origin); + check(hardened ? 'the exchange came from the extension host\'s own origin (CORS answered)' : 'the exchange came from the editor\'s origin (CORS answered)', + hardened ? /^https?:\/\/v--[a-z0-9]+\./.test(exch.origin || '') : exch.origin === editorOrigin, 'from ' + exch.origin); await workbenchReady(page); await page.waitFor(() => accountCalls() >= 1, { ms: 30000, label: 'the account API with the new token' }).catch(() => null); check('the editor then used its session against the account API', accountCalls() >= 1, 'calls: ' + accountCalls()); From 325ad4b1bbb4cab8ba5493361b46c41f60958235 Mon Sep 17 00:00:00 2001 From: Sergii Demianchuk Date: Fri, 9 Oct 2026 19:31:36 -0400 Subject: [PATCH 10/17] test(web): the shim tests do not assume a global crypto, which Node 18 lacks --- web/test/unit/shims.test.js | 2 ++ 1 file changed, 2 insertions(+) diff --git a/web/test/unit/shims.test.js b/web/test/unit/shims.test.js index fab3d6d..0899069 100644 --- a/web/test/unit/shims.test.js +++ b/web/test/unit/shims.test.js @@ -13,6 +13,8 @@ const assert = require('assert'); const nodePath = require('path'); const nodeCrypto = require('crypto'); +// A browser always has the global; Node has had it since 19 (18 only behind a flag). The shim is for the browser. +if (!globalThis.crypto) { globalThis.crypto = nodeCrypto.webcrypto; } const SHIMS = nodePath.join(__dirname, '..', '..', 'ai-extension', 'shims'); const shimPath = require(nodePath.join(SHIMS, 'path.js')); From af55efb9bfd44ac90795fc7a085ad9253840c342 Mon Sep 17 00:00:00 2001 From: Sergii Demianchuk Date: Fri, 9 Oct 2026 19:46:43 -0400 Subject: [PATCH 11/17] web: the session store belongs to every tab, not to the tab that started it MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The refresh token is rotated on use. The browser secret store loaded everything once and rewrote the whole thing on every change, so a second tab held the token the first had already rotated away: its renewal was refused, ended the session the first tab had just renewed, and wrote its start-up snapshot back over the first tab's tokens. Now nothing is cached. A read is a read of what is stored; a change reads, changes ONE key and writes, under a lock shared by the tabs (Web Locks; without it a tab still applies its own changes one at a time). A browser that refuses the write keeps the session in memory for that tab rather than completing a sign-in that is not there. Tests: unit (two tabs over one store, the rotated token, concurrent writers, unreadable store, no key, refused write — each mutation-checked) and a second tab in the end-to-end. --- web/main.js | 82 ++++++++++++----- web/test/e2e-signin-tab.mjs | 3 + web/test/unit/main.test.js | 170 +++++++++++++++++++++++++++++++++++- 3 files changed, 232 insertions(+), 23 deletions(-) diff --git a/web/main.js b/web/main.js index 2887e5d..7e7ed73 100644 --- a/web/main.js +++ b/web/main.js @@ -266,42 +266,82 @@ function createUrlCallbackProvider(URI, callbackRoute) { * running on this origin, which can call the same key: the editor therefore loads no third-party * extensions (see docs/WEB.md). Where IndexedDB or WebCrypto is unavailable (some private * windows), secrets live in memory for the session and the user signs in again after a reload. + * + * The store belongs to every tab of this origin, as the desktop's keychain belongs to every window, + * and the extension relies on that: the refresh token is rotated on use, so a tab that renewed the + * session has to be seen by the next tab that does, or that one presents a token that is no longer + * good and ends a session the first just renewed. So nothing is cached here: a read is a read of + * what is stored now, and a change is read-modify-write of ONE key under a lock shared by the tabs + * (the Web Locks API; without it, changes from one tab are still applied one at a time). */ async function createSecretStorage() { const STORE_KEY = 'levelcode-web.secrets.v1'; + const LOCK = 'levelcode-web.secrets'; let key; try { key = await loadOrCreateKey(); } catch (e) { key = undefined; } - let cache = {}; - const fromMemoryOnly = !key; - if (key) { + if (!key) { + const memory = new Map(); + return { + type: 'in-memory', + async get(k) { return memory.get(k); }, + async set(k, v) { memory.set(k, v); }, + async delete(k) { memory.delete(k); }, + async keys() { return [...memory.keys()]; }, + }; + } + + /** Everything stored now. Unreadable (key lost, tampered) is dropped: the user signs in again. */ + const read = async () => { + let sealed = null; + try { sealed = localStorage.getItem(STORE_KEY); } catch (e) { return {}; } + if (!sealed) { return {}; } try { - const sealed = localStorage.getItem(STORE_KEY); - if (sealed) { cache = JSON.parse(await unseal(key, sealed)); } + const all = JSON.parse(await unseal(key, sealed)); + return all && typeof all === 'object' ? all : {}; } catch (e) { - // An unreadable store (key lost, tampered) is dropped; the user signs in again. console.warn('[levelcode-web] the saved session could not be read and was cleared', e); try { localStorage.removeItem(STORE_KEY); } catch (e2) { /* ignore */ } - cache = {}; + return {}; } - } + }; - let writing = Promise.resolve(); - const persist = () => { - if (!key) { return writing; } - writing = writing.then(async () => { - try { localStorage.setItem(STORE_KEY, await seal(key, JSON.stringify(cache))); } - catch (e) { console.error('[levelcode-web] could not persist secrets', e); } - }); - return writing; + // One change at a time within this tab, and — where the browser has Web Locks — across tabs. + let queue = Promise.resolve(); + const exclusive = (fn) => { + const run = () => (globalThis.navigator && navigator.locks && navigator.locks.request + ? navigator.locks.request(LOCK, fn) + : fn()); + const next = queue.then(run, run); + queue = next.then(() => undefined, () => undefined); + return next; }; + // When the browser refuses the write (storage disabled or full) this tab keeps the session in memory and + // the user signs in again after a reload: better than a sign-in that completes and is not there. + /** @type {Map | null} */ + let inMemory = null; + const change = (mutate) => exclusive(async () => { + if (inMemory) { + const all = Object.fromEntries(inMemory); + mutate(all); + inMemory = new Map(Object.entries(all)); + return; + } + const all = await read(); + mutate(all); + try { localStorage.setItem(STORE_KEY, await seal(key, JSON.stringify(all))); } + catch (e) { + console.error('[levelcode-web] could not persist secrets; keeping them for this tab only', e); + inMemory = new Map(Object.entries(all)); + } + }); return { - type: fromMemoryOnly ? 'in-memory' : 'persisted', - async get(k) { return cache[k]; }, - async set(k, v) { cache[k] = v; await persist(); }, - async delete(k) { delete cache[k]; await persist(); }, - async keys() { return Object.keys(cache); }, + type: 'persisted', + async get(k) { return inMemory ? inMemory.get(k) : (await read())[k]; }, + async set(k, v) { await change((all) => { all[k] = v; }); }, + async delete(k) { await change((all) => { delete all[k]; }); }, + async keys() { return inMemory ? [...inMemory.keys()] : Object.keys(await read()); }, }; } diff --git a/web/test/e2e-signin-tab.mjs b/web/test/e2e-signin-tab.mjs index c71beba..a916bc5 100644 --- a/web/test/e2e-signin-tab.mjs +++ b/web/test/e2e-signin-tab.mjs @@ -115,9 +115,12 @@ try { await sleep(1500); check('an editor that is not waiting for it leaves it alone', (await orphan.eval(`localStorage.getItem('vscode-web.url-callbacks[7]') !== null`)) === true); const before = exchanges(); + const callsBefore = accountCalls(); await orphan.goto(editorOrigin + '/'); await workbenchReady(orphan); await orphan.waitFor(() => exchanges() > before, { ms: 60000, label: 'delivery at start-up' }).catch(() => null); + await orphan.waitFor(() => accountCalls() > callsBefore, { ms: 30000, label: 'the second tab\'s account calls' }).catch(() => null); + check('a second tab of the editor is signed in from the store the first one wrote, with no sign-in of its own', accountCalls() > callsBefore && stub.state.signIns === 1, `account calls ${callsBefore} -> ${accountCalls()}, sign-ins ${stub.state.signIns}`); check('the next start delivers it to the extension (the stand-in refuses the made-up code; the attempt is the proof)', exchanges() === before + 1, `${before} -> ${exchanges()}`); check('and it is gone from storage', (await orphan.eval(`localStorage.getItem('vscode-web.url-callbacks[7]')`)) === null); diff --git a/web/test/unit/main.test.js b/web/test/unit/main.test.js index 1561eb2..8820d6a 100644 --- a/web/test/unit/main.test.js +++ b/web/test/unit/main.test.js @@ -8,6 +8,7 @@ * leaveForSignIn the one command the page offers an extension: it must take the tab to the * account site's /ai/ pages and nowhere else * createUrlCallbackProvider .drain / .whenListening: what is delivered when the page starts + * createSecretStorage the session store, shared by every tab of the origin * startReturnFromSignIn ?signin=1 and the return from a sign-in *--------------------------------------------------------------------------------------------*/ // @ts-check @@ -19,10 +20,19 @@ const path = require('path'); const SOURCE = fs.readFileSync(path.join(__dirname, '..', '..', 'main.js'), 'utf8'); -/** The text of a top-level `function name(...) { ... }` — it ends at the first `}` in column 0. */ +/** + * The text of a top-level `function name(...) { ... }` (or `async function`): a one-line function is its + * line, any other ends at the first `}` in column 0. + */ function extract(name) { - const start = SOURCE.indexOf('\nfunction ' + name + '('); + let start = SOURCE.indexOf('\nfunction ' + name + '('); + if (start < 0) { start = SOURCE.indexOf('\nasync function ' + name + '('); } assert.ok(start >= 0, 'main.js has no top-level function ' + name); + const eol = SOURCE.indexOf('\n', start + 1); + const line = SOURCE.slice(start + 1, eol); + const opens = (line.match(/\{/g) || []).length; + const closes = (line.match(/\}/g) || []).length; + if (opens > 0 && opens === closes && line.endsWith('}')) { return line + '\n'; } const end = SOURCE.indexOf('\n}\n', start); assert.ok(end > start, 'could not find the end of ' + name); return SOURCE.slice(start + 1, end + 2); @@ -197,6 +207,162 @@ const ACCOUNT = 'https://levelcode.example'; } finally { t.restore(); } }); + /* ---- the secret store --------------------------------------------------------------------------- */ + const nodeCrypto = require('crypto'); + const STORE_KEY = 'levelcode-web.secrets.v1'; + const SECRET_SOURCE = ['b64', 'unb64', 'seal', 'unseal', 'createSecretStorage'].map(extract).join('\n'); + const memoryStorage = () => { + const m = new Map(); + return { + m, + getItem: (k) => (m.has(k) ? m.get(k) : null), + setItem: (k, v) => { m.set(k, String(v)); }, + removeItem: (k) => { m.delete(k); }, + }; + }; + /** A mutex shared by every "tab" that is handed the same one, as Web Locks is. */ + const sharedLocks = () => { + let tail = Promise.resolve(); + const held = { max: 0, now: 0 }; + return { + held, + request: (_name, fn) => { + const run = async () => { held.now++; held.max = Math.max(held.max, held.now); try { return await fn(); } finally { held.now--; } }; + const p = tail.then(run, run); + tail = p.then(() => undefined, () => undefined); + return p; + }, + }; + }; + /** One tab of the editor: its own storage object over the shared localStorage, the shared origin key. */ + const tab = async ({ storage, key, locks, failKey = false, noKey = false }) => { + const logs = { error: [], warn: [] }; + const factory = new Function('localStorage', 'navigator', 'console', 'loadOrCreateKey', 'crypto', 'TextEncoder', 'TextDecoder', 'btoa', 'atob', 'Uint8Array', + SECRET_SOURCE + '\nreturn createSecretStorage;'); + const create = factory(storage, locks ? { locks } : {}, { warn: (...a) => logs.warn.push(a), error: (...a) => logs.error.push(a) }, + async () => { if (noKey) { throw new Error('no indexedDB'); } return key; }, nodeCrypto.webcrypto, TextEncoder, TextDecoder, btoa, atob, Uint8Array); + return { store: await create(), logs }; + }; + const newKey = () => nodeCrypto.webcrypto.subtle.generateKey({ name: 'AES-GCM', length: 256 }, false, ['encrypt', 'decrypt']); + + await test('secrets: set, get, keys and delete, and they are there after a reload', async () => { + const storage = memoryStorage(); + const key = await newKey(); + const a = await tab({ storage, key }); + assert.strictEqual(a.store.type, 'persisted'); + await a.store.set('access', 'token-AAA'); + await a.store.set('refresh', 'token-RRR'); + assert.strictEqual(await a.store.get('access'), 'token-AAA'); + assert.deepStrictEqual((await a.store.keys()).sort(), ['access', 'refresh']); + await a.store.delete('access'); + assert.strictEqual(await a.store.get('access'), undefined); + const reloaded = await tab({ storage, key }); + assert.strictEqual(await reloaded.store.get('refresh'), 'token-RRR'); + assert.deepStrictEqual(await reloaded.store.keys(), ['refresh']); + }); + await test('secrets: what is stored is sealed — neither the value nor its base64 is in it', async () => { + const storage = memoryStorage(); + const a = await tab({ storage, key: await newKey() }); + await a.store.set('refresh', 'a-very-recognisable-secret-value'); + const blob = storage.getItem(STORE_KEY); + assert.ok(blob && blob.length > 20); + assert.ok(!blob.includes('recognisable') && !blob.includes(Buffer.from('a-very-recognisable-secret-value').toString('base64'))); + }); + await test('secrets: another tab\'s change is seen at once — the rotated refresh token is the one a second tab uses', async () => { + const storage = memoryStorage(); + const key = await newKey(); + const locks = sharedLocks(); + const first = await tab({ storage, key, locks }); + await first.store.set('refresh', 'R1'); + await first.store.set('access', 'A1'); + const second = await tab({ storage, key, locks }); // started while R1 was current + assert.strictEqual(await second.store.get('refresh'), 'R1'); + await first.store.set('refresh', 'R2'); // the first tab renews the session + await first.store.set('access', 'A2'); + assert.strictEqual(await second.store.get('refresh'), 'R2', 'a second tab must not present the refresh token that was rotated away'); + assert.strictEqual(await second.store.get('access'), 'A2'); + }); + await test('secrets: a change is one key — a tab does not write back what it saw at start-up', async () => { + const storage = memoryStorage(); + const key = await newKey(); + const locks = sharedLocks(); + const first = await tab({ storage, key, locks }); + await first.store.set('access', 'A1'); + const second = await tab({ storage, key, locks }); + await first.store.set('refresh', 'R2'); // after the second tab started + await second.store.set('verifier', 'V'); // an unrelated change in the second tab + assert.strictEqual(await first.store.get('refresh'), 'R2', 'the second tab\'s write did not undo the first\'s'); + assert.strictEqual(await first.store.get('verifier'), 'V'); + await second.store.delete('access'); // the second tab signs out of the access token + assert.strictEqual(await first.store.get('refresh'), 'R2'); + assert.strictEqual(await first.store.get('access'), undefined); + }); + await test('secrets: changes made at the same moment in two tabs all land, one at a time', async () => { + const storage = memoryStorage(); + const key = await newKey(); + const locks = sharedLocks(); + const a = await tab({ storage, key, locks }); + const b = await tab({ storage, key, locks }); + const work = []; + for (let i = 0; i < 12; i++) { + work.push(a.store.set('a' + i, 'x' + i)); + work.push(b.store.set('b' + i, 'y' + i)); + } + await Promise.all(work); + const keys = await a.store.keys(); + assert.strictEqual(keys.length, 24, 'lost: ' + [...Array(12).keys()].flatMap((i) => ['a' + i, 'b' + i]).filter((k) => !keys.includes(k)).join(',')); + assert.strictEqual(locks.held.max, 1, 'never two changes inside the lock at once'); + }); + await test('secrets: with no Web Locks a tab still applies its own changes one at a time', async () => { + const storage = memoryStorage(); + const a = await tab({ storage, key: await newKey() }); + await Promise.all([...Array(10).keys()].map((i) => a.store.set('k' + i, 'v' + i))); + assert.strictEqual((await a.store.keys()).length, 10); + }); + await test('secrets: a store that cannot be read is dropped, with a warning, and the tab carries on', async () => { + const storage = memoryStorage(); + storage.setItem(STORE_KEY, 'AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA'); + const a = await tab({ storage, key: await newKey() }); + assert.strictEqual(await a.store.get('refresh'), undefined); + assert.strictEqual(storage.getItem(STORE_KEY), null); + assert.strictEqual(a.logs.warn.length >= 1, true); + await a.store.set('refresh', 'R'); + assert.strictEqual(await a.store.get('refresh'), 'R'); + }); + await test('secrets: a key from another browser profile cannot read it — that is a sign-in again, not an error', async () => { + const storage = memoryStorage(); + const mine = await tab({ storage, key: await newKey() }); + await mine.store.set('refresh', 'R'); + const other = await tab({ storage, key: await newKey() }); + assert.strictEqual(await other.store.get('refresh'), undefined); + }); + await test('secrets: where there is no key the store is in memory, writes nothing, and works for the session', async () => { + const storage = memoryStorage(); + const a = await tab({ storage, key: null, noKey: true }); + assert.strictEqual(a.store.type, 'in-memory'); + await a.store.set('access', 'A'); + assert.strictEqual(await a.store.get('access'), 'A'); + assert.deepStrictEqual(await a.store.keys(), ['access']); + await a.store.delete('access'); + assert.strictEqual(await a.store.get('access'), undefined); + assert.strictEqual(storage.m.size, 0, 'nothing was written to localStorage'); + }); + await test('secrets: when the browser refuses the write the session is kept for this tab, not lost', async () => { + const storage = memoryStorage(); + const key = await newKey(); + const a = await tab({ storage, key }); + await a.store.set('refresh', 'R1'); + const realSet = storage.setItem; + storage.setItem = () => { throw new Error('QuotaExceededError'); }; + await a.store.set('access', 'A1'); // refused: kept in memory, with R1 carried over + storage.setItem = realSet; + assert.strictEqual(await a.store.get('access'), 'A1'); + assert.strictEqual(await a.store.get('refresh'), 'R1'); + await a.store.set('refresh', 'R2'); + assert.strictEqual(await a.store.get('refresh'), 'R2'); + assert.ok(a.logs.error.length >= 1, 'said so'); + }); + /* ---- startReturnFromSignIn --------------------------------------------------------------------- */ function makeStart(href, { drained = 0, listening = true, sessionThrows = false } = {}) { const calls = { replaced: [], drain: [], fired: [], removed: [] }; From 6895a7dd07698322309f3b7d4b4780738c53de3a Mon Sep 17 00:00:00 2001 From: Sergii Demianchuk Date: Fri, 9 Oct 2026 19:49:21 -0400 Subject: [PATCH 12/17] =?UTF-8?q?docs(web):=20LevelCode=20in=20your=20brow?= =?UTF-8?q?ser=20=E2=80=94=20architecture,=20build,=20deploy,=20security?= =?UTF-8?q?=20model,=20and=20what=20was=20not=20run?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- CLAUDE.md | 29 ++++- README.md | 3 +- docs/WEB.md | 302 ++++++++++++++++++++++++++++++++++++++++++++++++++++ 3 files changed, 331 insertions(+), 3 deletions(-) create mode 100644 docs/WEB.md diff --git a/CLAUDE.md b/CLAUDE.md index 6f15df8..1bc3149 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -40,8 +40,9 @@ extensions/levelcode-hackability/ user init script + Atom/NPP keymap presets + p extensions/levelcode-sync/ 'levelcode' auth provider that lights up the built-in Settings Sync (LevelCode Sync, S0) extensions/levelcode-updater/ notify-only update checker (polls the update feed; never auto-applies) patches/levelcode-core.patch our core source edits, applied on bootstrap -scripts/ bootstrap.sh, apply-branding.mjs, run-dev.sh, editor-identity.mjs, build-macos.sh, make-dmg.sh, make-icon.sh; atom (CLI launcher) + install-level.sh +scripts/ bootstrap.sh, apply-branding.mjs, run-dev.sh, editor-identity.mjs, build-macos.sh, make-dmg.sh, make-icon.sh, build-web.mjs; atom (CLI launcher) + install-level.sh tools/ dependency-free reference servers: sync-server (/v1 Settings-Sync), update-server (/api/update feed) +web/ LevelCode in a browser tab: page entry + callback, scratch-workspace extension, browser build of levelcode-ai, dev server, tests (docs/WEB.md) vscode/ GITIGNORED upstream Code-OSS checkout (generated) ``` @@ -82,6 +83,30 @@ To macOS a run from source and the installed LevelCode used to be ONE app — sa - A LaunchServices handler must live outside temp folders — a bundle under `/tmp` is registered but never chosen. Tests therefore run on fixtures and do not register anything (`test/editorIdentity.test.js`). +## LevelCode in a tab (`web/`) — read `docs/WEB.md` first + +The same `extensions/levelcode-ai` source runs in a browser tab (web-worker extension host). Rules that keep +the desktop app exactly as it was: + +- **A workspace read or write goes through `extensions/levelcode-ai/host.js`**, never `fs` on a workspace path: + in a tab the workspace is whatever the editor's file-system provider says, and every read is asynchronous. + On the desktop each `host` function does what the call site did before, behind `await`. +- **Nothing outside `host.js` asks "am I in a browser?"** — a call site asks for a capability (`host.caps.shell`, + `.mcpStdio`, `.ripgrep`) or calls a function that already knows. A new tool that needs a shell/disk/process is + listed in `NEEDS_SHELL` (agent.js) so the browser neither offers it nor promises it. +- The browser build is `web/ai-extension/` (esbuild + shims for `fs`/`path`/`os`/`crypto`/`child_process`). + Strings that are true on a Mac and false in a tab are rewritten by `web/ai-extension/copy.js`, which fails the + build when the desktop text it expects is gone — change both together. `manifest.js` drops the commands and + settings a tab cannot honour. +- `web/main.js` is a plain ES module with a **top-level `await`**: a `const` declared lower in the file is not + initialised until that await finishes (this cleared a saved session once). Declare constants above the + `try`, and use function declarations for helpers it calls. +- The session store (`createSecretStorage`) belongs to every tab: no cache, one-key read-modify-write under a + Web Lock. The refresh token is rotated on use; do not reintroduce a start-up snapshot. +- Build: `node scripts/build-web.mjs --account ` → `dist-web/`; serve with `node web/serve.mjs --dist dist-web`. + Tests: the unit gate (`scripts/test-extensions.sh` includes `web/test/unit/*.test.js`) and the end-to-end checks in + `web/test/e2e*.mjs` (headless Chrome, stand-in backend; Node 22+). What was not run is listed in `docs/WEB.md`. + ## Toolchain (hard requirements — these bit us) - **Node = `vscode/.nvmrc` (currently 24.15.0)**. Older majors fail to compile native modules. @@ -213,7 +238,7 @@ big-file mode badge. Files: extension.js + fileOps/lineOps/columnOps/encodingEol is not in the editor until you load it: `./scripts/run-dev.sh --extensionDevelopmentPath=/extensions/levelcode-ai` (from the main checkout; the dev extension replaces the built-in one). Uncommitted work is not "on the branch" — checking the branch out somewhere else gets none of it. Run it in the editor before telling anyone to try it. -- Commit `extensions/`, `patches/`, `branding/`, `scripts/`, `docs/`, `PLAN.md`, `CLAUDE.md`. Never commit `vscode/`. +- Commit `extensions/`, `patches/`, `branding/`, `scripts/`, `web/`, `docs/`, `PLAN.md`, `CLAUDE.md`. Never commit `vscode/` or `dist-web/`. - `extensions/levelcode-ai/diagram/` modules listed in `bundle.FILES` are pasted INTO a script block in `chat.html`. They must never contain the text of a script tag or an HTML comment opener — not even in a comment — or the block ends early; `bundle.js` refuses to build if one does. Keep them dependency-free and free of `require('vscode')`/`fs`. diff --git a/README.md b/README.md index f731685..95d2807 100644 --- a/README.md +++ b/README.md @@ -35,7 +35,8 @@ LevelCode is a **clean overlay on top of Code-OSS**, not a vendored copy of the - `extensions/` — the first-party extensions (native AI, Notepad++ pack, themes, hackability, sync, updater), plain JS with no build step. - `branding/` — the LevelCode identity (`product.overlay.json`) + icons, deep-merged onto upstream `product.json`; the extension gallery wired to **Open VSX**. - `patches/` — the small set of core source patches (each tagged `// [LevelCode]`). -- `scripts/` — bootstrap / run-dev / build / dmg / icon. +- `scripts/` — bootstrap / run-dev / build / dmg / icon, and `build-web.mjs` (LevelCode in a browser tab). +- `web/` — LevelCode in your browser: the same workbench and AI extension as a static site you can host; sign in with a LevelCode account or use your own key. See [`docs/WEB.md`](docs/WEB.md). - `tools/` — dependency-free reference servers (Settings-Sync feed, update feed). - `PLAN.md`, `docs/`, `CLAUDE.md` — the vision, roadmap, and repo map. diff --git a/docs/WEB.md b/docs/WEB.md new file mode 100644 index 0000000..4012799 --- /dev/null +++ b/docs/WEB.md @@ -0,0 +1,302 @@ +# LevelCode in your browser + +The same workbench, the same `levelcode-ai` extension, in a browser tab: sign in with a LevelCode +account and chat with the agent against the gateway, or bring a provider key. There is nothing to +install and no server of ours that runs code — the editor is a **static directory**. + +This page is the engineering reference: how it fits together, how to build and serve it, what the +host has to provide, the security model, and — kept separate on purpose — what has and has not been +run. For the product's own page and copy see the account site (`/web`, `/docs/browser`). + +- [What it is, and what it is not](#what-it-is-and-what-it-is-not) +- [How it fits together](#how-it-fits-together) +- [Signing in](#signing-in) +- [Build and run](#build-and-run) +- [Deploying](#deploying) — origins, headers, backend settings +- [Security model](#security-model) +- [Where state lives](#where-state-lives) +- [Keeping the desktop and the browser in step](#keeping-the-desktop-and-the-browser-in-step) +- [Tests](#tests) — and [what was not run](#what-was-not-run) +- [Decisions that are the owner's](#decisions-that-are-the-owners) + +## What it is, and what it is not + +A Code-OSS **web client** (the `vscode-web-min-ci` build of the pinned checkout) with a +**web-worker extension host**, plus LevelCode's own extensions loaded as built-ins. + +| Works in a tab | Stays in the Mac app | +| --- | --- | +| The workbench: explorer, editor tabs, themes, search, Quick Open | A terminal | +| Chat, inline completion, the agent: read / search / create / edit / delete files | `run_command`, background commands, auto-verify commands | +| Keep / Undo review of the agent's edits | MCP servers that start local processes | +| Sign in with a LevelCode account (gateway), **or** your own provider key | Installing extensions (no marketplace in the page) | +| A scratch workspace saved in the browser (every modern browser) | Importing another editor's settings, Settings Sync, the update check | +| A folder from your computer (File System Access API: Chrome, Edge) | The Notepad++ pack and the customization pack (not loaded in the page), Agent Sketch | + +The browser build offers the agent **only the tools it can honour**: with no shell the shell tools +are withheld from the model rather than offered and failing, and the system prompt is derived from +the same lines so it never promises one (`host.caps`, see below). + +## How it fits together + +``` + account site (levelcode.ai) editor origin (any static host) + ┌───────────────────────────┐ tab goes to ┌──────────────────────────────────────────────┐ + │ /ai/login (sign-in) │◀─────────────────│ index.html ← build-addressed /_//… │ + │ /api/levelcode/v1/… │──── ?code= ─────▶│ main.js embedder: config, callbacks, │ + │ auth/exchange │ /callback.html │ secrets, workspace, commands │ + │ account/models │ │ callback.html hands the result back (storage)│ + │ ai/chat/completions │◀──── fetch ──────│ ┌─ workbench (Code-OSS web client) ────────┐ │ + └───────────────────────────┘ (CORS) │ │ web-worker extension host (iframe) │ │ + │ │ levelcode-ai esbuild bundle + shims │ │ + provider you chose ◀──── fetch (BYOK) ──────│ │ levelcode-web scratch workspace │ │ + │ │ chat webview (own origin, see below) │ │ + │ └──────────────────────────────────────────┘ │ + └──────────────────────────────────────────────┘ +``` + +| Path | What it is | +| --- | --- | +| `scripts/build-web.mjs` | One command → `dist-web/`: the client, the extensions, `index.html` with the configuration baked in, `_headers`, an nginx fragment, `build.json`. Changes nothing outside `--out`. | +| `web/main.js`, `index.html`, `boot.css` | The page. Plain ES module, no build step: reads `#levelcode-web-config`, creates the workbench, supplies the URL-callback provider, the secret store, the workspace provider and the `levelcode.web.openAuthUrl` command. | +| `web/callback.html`, `callback.js` | The page a sign-in returns to. | +| `web/lib/config.mjs`, `headers.mjs`, `extensions.mjs` | The configuration, the HTTP policy and the extension staging — shared by the dev server and the release, so what is tested is what ships. | +| `web/ai-extension/` | The browser build of `levelcode-ai`: `build.mjs` (esbuild, `platform: browser`), `shims/` (`fs`, `path`, `os`, `crypto`, `child_process`, `process`), `manifest.js` (the desktop manifest minus what a tab cannot do), `copy.js` (the few strings that are true on a Mac and false in a tab). | +| `extensions/levelcode-ai/host.js` | The one place the desktop/browser difference lives in the extension (see below). | +| `web/workspace/` | The `levelcode-web` extension: the **scratch workspace** (`levelcode-scratch:` file system in IndexedDB, with file and text search), and the commands to open a local folder or the scratch. | +| `web/serve.mjs` | Dev / end-to-end server. Serves a build (`--dist`) with its own `_headers`, or the pieces (`--static`, `--extensions`). | +| `web/test/` | Unit tests (`unit/`) and the end-to-end checks (`e2e*.mjs`) with a hand-written CDP driver and a stand-in backend. | + +### The extension runs unchanged — through `host.js` + +`extensions/levelcode-ai` is the **same source** on both. Its Node-only assumptions are handled in +two places only: + +1. **`web/ai-extension/shims/`** — the build swaps `fs`, `path`, `os`, `crypto`, `child_process` + for small in-memory/IndexedDB stand-ins for the extension's *private* state (sessions, memory, + images). `path` and `crypto` are compared against Node's over many inputs. +2. **`extensions/levelcode-ai/host.js`** — the user's *workspace* is not in the shim and must never + be: in a tab it is whatever the editor's file-system provider says, and every read is + asynchronous. `host` gives `readText`, `readBytes`, `stat`, `exists`, `isBinary`, `findFiles`, + `withReads`, `openAuth` and the capability flags `caps.shell`, `caps.mcpStdio`, `caps.ripgrep`. + On the desktop each function does exactly what the call site did before, behind `await`. + +The two rules that keep the desktop app exactly as it was: + +- **On the desktop every `host` function does what the call site did before** — the same `fs` + call, the same error. +- **Nothing outside `host.js` asks "am I in a browser?".** A call site asks for a capability + (`host.caps.shell`) or calls a function that already knows. + +## Signing in + +The extension's existing PKCE flow, with the browser's pieces in place of the OS's: + +``` +1 extension accountSignIn(): verifier → SecretStorage; builds + /ai/login?redirect_uri=&code_challenge= +2 extension host.openAuth(uri) → command levelcode.web.openAuthUrl (registered by main.js) +3 main.js checks the address is /ai/…, marks the tab (sessionStorage), location.assign +4 account already signed in there → mints a one-time code bound to the challenge, 302 to + /callback.html?vscode-reqid=N&vscode-scheme=levelcode + &vscode-authority=levelcode.levelcode-ai&vscode-path=%2Fauth%2Fcallback&code=… + (not signed in → the login page first) +5 callback writes localStorage "vscode-web.url-callbacks[N]" (dated), sees the mark → goes back to / +6 main.js at start-up, once the workbench is listening, delivers a fresh result as + levelcode://levelcode.levelcode-ai/auth/callback?code=… to the URL service +7 extension handleAuthCallback → POST /api/levelcode/v1/auth/exchange {code, verifier} → tokens +``` + +Why the tab leaves instead of opening a pop-up: a window opened from a chain that began in a +webview is the first thing a strict browser (Safari, a phone) refuses. Leaving the tab and +returning needs no permission. The PKCE verifier lives in SecretStorage, so it survives the +navigation. + +- **`?signin=1`** (the account site's "Open LevelCode" link) makes step 1 happen by itself, once: + `main.js` removes the parameter, then delivers `levelcode://…/launch`, which focuses the chat and + starts the sign-in only when there is no session. +- **A result is delivered only if it is fresh** (written in the last two minutes — the one-time + code lives for a minute) and is removed either way. One left by a callback page whose editor + tab is gone is delivered when the editor next starts. +- **`levelcode.web.openAuthUrl` is an open-redirect if it is loose**, and any extension in the page + can call it. It goes only to `/ai/…`. +- The address handed over is `encodeURI(uri.toString(true))` — the string the editor's own opener + produces — so the server sees the same address however the sign-in was started. The backend + decodes `redirect_uri` until stable (three rounds at most) and then applies the exact rule in + `Levelcode::EditorCallback`. + +## Build and run + +Needs the bootstrapped Code-OSS checkout (`scripts/bootstrap.sh`, Node 24.x as the pin requires). + +```bash +# the release: one static directory (about a minute for the client, then bundling) +node scripts/build-web.mjs --account https://levelcode.ai --out dist-web + +# …serve it exactly as a static host would, with its own _headers +node web/serve.mjs --dist dist-web # http://127.0.0.1:8800 +``` + +| `build-web.mjs` flag | Meaning | +| --- | --- | +| `--account ` (`LEVELCODE_WEB_ACCOUNT`) | The LevelCode Cloud origin: where sign-in happens, and where the gateway is. Baked into `index.html`. | +| `--webview-origin 'https://{{uuid}}.view.example.com'` (`LEVELCODE_WEB_WEBVIEW_ORIGIN`) | Self-host the webview origin. Without it the page uses Code-OSS's CDN at this build's own commit. Needs wildcard DNS and TLS — see below. | +| `--ext-host-origin 'https://{{uuid}}.ext.example.com'` (`LEVELCODE_WEB_EXTHOST_ORIGIN`) | Run the extension host on an origin of its own. Optional while no third-party code is loaded. | +| `--static ` | Use a prebuilt client (skips the gulp build). | +| `--id ` | Override the build id (default: a fingerprint of the client, the page and the extension). | + +**Dev server** (`web/serve.mjs`): `--static --extensions --extension-names +levelcode-ai,levelcode-web --account `, plus `--product ` and `--config-defaults +` to override the product configuration. The extension is built with +`node web/ai-extension/build.mjs --out --vscode `. + +**Release layout.** `index.html` and the callback page are at the root, un-cached. Everything +else is under `/_//` and cached for a year (`immutable`): `static/` (the client), `extensions/`, +`main.js`, `boot.css`. A cold first load fetched about 20 MB uncompressed (5 MB gzip) over 147 +files from the editor's origin, plus the webview host page from the CDN; the whole directory is +about 195 MB, most of it grammars and extensions the page does not request until they are used. +(Measured as the size of the files the page asked for, not as bytes on the wire.) + +## Deploying + +It is a static site. What the host must supply: + +### Three origins, and which is optional + +| Origin | Default | Why it is separate | +| --- | --- | --- | +| **Editor** — `index.html`, `callback.html`, `/_/…` | the host you deploy to | The only origin that holds the session. | +| **Webview** — where the chat panel (and every webview) runs | `https://{{uuid}}.vscode-cdn.net`, Code-OSS's CDN at this build's own commit | Webview content must not share the editor's origin. **Each webview needs its own subdomain**, so a self-hosted origin has to be a wildcard (`*.view.example.com` with a certificate for it); a single host fails with "Expected '' as hostname or subdomain". | +| **Extension host** — the iframe the worker runs in | the editor's own origin | Isolating it (`--ext-host-origin`, a wildcard likewise) puts third-party extension code on a different origin than the session. Not needed while only LevelCode's extensions load. When it is on, **every** `fetch` an extension makes carries that origin, so the API's CORS must allow it. | + +### Headers + +`dist-web/_headers` (Netlify, Cloudflare Pages) and `dist-web/deploy/nginx.conf` carry the same +policy; `web/serve.mjs --dist` reads the same file, so the end-to-end checks run under it. The +Content-Security-Policy for the editor is the tightest the workbench allows: + +- `script-src 'self' 'unsafe-eval' blob:` — Monaco and the TextMate/WASM engines compile code at runtime; +- `connect-src 'self' https: http://localhost:* http://127.0.0.1:*` — a provider key is used from the tab, to the + provider the user chose; loopback is the one place a browser allows plain http from an https page, for a + model server on the user's own machine (an OpenAI-compatible endpoint through the custom provider — *not run*); +- `frame-src 'self' [] data:`; `object-src 'none'`, `base-uri 'none'`, + `form-action 'none'`, `frame-ancestors 'none'`. + +The callback page has `default-src 'none'` and only its own script and style. + +### On the account site + +Three settings on the Rails app (all default **off**; with none set nothing changes): + +| Variable | Meaning | +| --- | --- | +| `LEVELCODE_WEB_EDITOR_ORIGINS` | Comma list of origins the editor is served from, exactly (`https://editor.example.com`). A sign-in code is only ever sent to `/callback.html` with the right parameters. Also the CORS list. | +| `LEVELCODE_WEB_EDITOR_URL` | Where the account site's "Open LevelCode" goes: one of those origins and, optionally, a path. | +| `LEVELCODE_WEB_EXTENSION_HOST_ORIGINS` | Comma list (one wildcard each) of origins the extension host runs on, when isolated. CORS only — never a callback. | + +`GET /api/levelcode/v1/web_editor` answers `{ enabled, url }`; the account SPA shows its entry +points only when it is on. + +**Do not host the editor on a subdomain of the account host.** The account session cookie is +`SameSite=Lax`, which is a site boundary, not an origin one: it is sent on a request from a sibling +subdomain, and the API takes that cookie when there is no bearer token. A page that runs other +people's extensions there could cause credentialed requests (CORS stops it *reading* the answer, not +causing the effect). Use another registrable domain. + +## Security model + +- **The session is the user's tokens.** They are in SecretStorage, which in a tab is sealed with + AES-GCM under a **non-extractable** key kept in IndexedDB, and stored in localStorage. That keeps + tokens out of a localStorage dump, a backup or a copied profile. It is **not** a defence against + script running on the editor's origin, which can use the same key — so **the page loads no + third-party extension** (`extensionsGallery: null`) and nothing is installed into its origin. +- **The store belongs to every tab.** The refresh token is rotated on use; a tab that cached it + would present a token another tab had rotated away and end a session the other just renewed. Reads + go to storage each time and a change is read-modify-write of one key under a Web Lock. +- **One command, one destination** — `levelcode.web.openAuthUrl` (above). +- **A sign-in code is only handed to a listed origin, to the exact callback page**, by the backend; + the page then takes it out of the address bar (`history.replaceState`) and storage. +- **Keys of the user's own** (BYOK) are stored the same way and used from the tab to the provider the + user chose; no LevelCode call is involved (the end-to-end check asserts it). The provider must + accept requests from a web page; Anthropic's is called with its browser-access header. +- **The scratch workspace and the extension's private state are on the extension host's origin**, in + IndexedDB. With the host isolated they are not readable from the editor's page, by design. + +## Where state lives + +| What | Where | Notes | +| --- | --- | --- | +| Session tokens, BYOK keys | localStorage `levelcode-web.secrets.v1` (sealed); the key in IndexedDB `levelcode-web` → `keys` | editor origin; shared by tabs | +| Sign-in result in flight | localStorage `vscode-web.url-callbacks[N]`; sessionStorage `levelcode-web.return` | removed when read | +| Scratch workspace | IndexedDB `levelcode-scratch` → `nodes` | extension host origin; changes are broadcast to other tabs | +| Chat sessions, memory, images | IndexedDB `levelcode-ai-fs` → `files`, loaded at start | extension host origin; a change made in another tab shows after a reload | +| Editor settings, layout | the workbench's own storage | editor origin | + +Clearing the browser's site data removes all of it. A private window may discard it on close; the +secret store then falls back to memory for the session. + +## Keeping the desktop and the browser in step + +- **A new tool that needs a shell, a disk or a local process** declares it: add the tool name to + `NEEDS_SHELL` (or gate on `host.caps`) in `agent.js` so the browser neither offers nor promises it. + The browser system prompt is built from the same `SYSTEM_LINES`. +- **A new workspace read or write goes through `host`.** `fs` on a workspace path would work on the + desktop and quietly do nothing in a tab. `extensions/levelcode-ai/test/` and `web/test/unit/` check + the places this has bitten. +- **Copy that is true on a Mac and false in a tab** (keychain, "your machine") is rewritten by + `web/ai-extension/copy.js`, which **fails the build** when the desktop text it expects is gone. Update + the desktop and `copy.js` together. +- **`web/ai-extension/manifest.js`** removes the commands and settings a tab cannot honour. A new + command that shells out belongs in `REMOVED_COMMANDS`. +- **A Code-OSS bump** can change the web client's packaging and the webview service worker's version; + rebuild and run the end-to-end checks. The webview template names this build's own commit + (`{{quality}}/{{commit}}`) for that reason. + +## Tests + +| Check | Command | What it proves | +| --- | --- | --- | +| Unit gate | `./scripts/test-extensions.sh` | Includes `web/test/unit/*.test.js`: the shims against Node, `host.js` on both sides, `main.js`'s functions (address check, delivery, the shared secret store, `?signin=1`), the manifest, the copy rewrites. Runs in CI on every PR (`test.yml`). Also run on Linux / Node 18 in a container with the network refused. | +| End to end | `node web/test/e2e.mjs --dist dist-web --stub-port ` | Real headless Chrome: boot from static files, the extension activates in the worker, sign in through the real callback page, agent tool calls (write / list / search / read / edit) against the scratch workspace, Quick Open, Keep and Undo, a reload that keeps the session and files. | +| Same-tab sign-in | `node web/test/e2e-signin-tab.mjs --dist dist-web --stub-port ` | No second window; verifier survives the reload; no repeat; no sign-in for a signed-in editor; a second tab uses the same session; a stranded result is delivered, an old one dropped. | +| Bring your own key | `node web/test/e2e-byok.mjs --static … --extensions …` | The request goes from the tab to the provider with the user's key and nothing of LevelCode's. | + +The end-to-end checks use a **stand-in backend** (`web/test/stub-backend.mjs`, HTTPS with a +throwaway certificate) that applies the same redirect and CORS rules the real one must. They prove +the editor's half of the contract; the backend's half is its own specs. The hand-written CDP driver +needs Node 22+ and a Chrome (`CHROME=` to point at one). + +The checks were run against a release served with its own `_headers` (same-origin extension host, the +webview from Code-OSS's CDN), and against the development server with the webview and the extension +host on origins of their own (`--product` with `webviewContentExternalBaseUrlTemplate` and +`webEndpointUrlTemplate`). + +### What was not run + +Say these out loud rather than discover them in production: + +- **A real browser other than headless Chrome.** Safari and Firefox are untested; so are phones. +- **The File System Access path** (open a folder from the computer): it needs a user gesture and a + picker that headless Chrome does not offer. The code path is the editor's own (`showDirectoryPicker`), + and the agent's tools reach it through `vscode.workspace.fs` as for the scratch workspace. +- **The real account SPA and the real Rails app** in the same-tab flow. The stand-in plays "already + signed in there"; the backend's rules are its own specs, run in a separate checkout. +- **Another provider's CORS.** Anthropic's browser-access header is set; whether a given provider + answers a page's request is that provider's. +- **Production hosting:** DNS, a wildcard certificate, the CDN in front, the real headers on the real host. +- **Long sessions across the 8-hour access-token boundary in a tab** (the logic is the desktop's, + unchanged; the shared store was added for it and is unit-tested, not soaked). + +## Decisions that are the owner's + +1. **The editor's domain.** Not a subdomain of the account host (see Deploying). Which registrable + domain, and who holds it. +2. **The webview origin.** Keep Code-OSS's CDN (no setup; a dependency on Microsoft's hosting of the + published page for this build's commit) or self-host a wildcard origin. +3. **Isolating the extension host.** Worth it before any third-party extension is ever allowed; not + needed while the page loads only LevelCode's. +4. **Where the entry points point.** Today every "Open LevelCode" link carries `?signin=1`, so the + editor starts the sign-in itself. Whether visitors who are not signed in at the account site + should see the editor first instead. +5. **Support statement.** Chrome and Edge for folders; scratch workspace everywhere else; phones + unstated until run. From 8282fd0e6766c95922481554e29b045a3f53f8d9 Mon Sep 17 00:00:00 2001 From: Sergii Demianchuk Date: Fri, 9 Oct 2026 19:55:23 -0400 Subject: [PATCH 13/17] web: resolve navigator.locks the way the page does, so the shared-store tests mean the same on every Node --- web/main.js | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/web/main.js b/web/main.js index 7e7ed73..584a28c 100644 --- a/web/main.js +++ b/web/main.js @@ -309,7 +309,7 @@ async function createSecretStorage() { // One change at a time within this tab, and — where the browser has Web Locks — across tabs. let queue = Promise.resolve(); const exclusive = (fn) => { - const run = () => (globalThis.navigator && navigator.locks && navigator.locks.request + const run = () => (typeof navigator !== 'undefined' && navigator.locks && navigator.locks.request ? navigator.locks.request(LOCK, fn) : fn()); const next = queue.then(run, run); From 53939333c27dab59e3ba6d0d87c2f1fe6b34135c Mon Sep 17 00:00:00 2001 From: Sergii Demianchuk Date: Fri, 9 Oct 2026 21:40:05 -0400 Subject: [PATCH 14/17] web: "Open Folder from Your Computer" reaches the browser's folder picker MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Run for the first time against a real browser session and the real login page, the command did nothing useful from the default scratch workspace: the workbench chooses between its own file browser and the browser's picker by the scheme of the default location, and in the scratch workspace that scheme is ours — so it showed the scratch workspace's own folders. The command now asks the dialog from the `file` scheme, which the web workbench serves with the File System Access handles the user picks, and opens what came back in this tab. Tests: a unit test of the command against a stand-in for vscode (mutation-checked), and a section of the end-to-end check that opens a folder through a real FileSystemDirectoryHandle (the origin-private file system stands in for the OS picker) and has the agent list, read and write there. --- web/test/e2e.mjs | 38 ++++++++++++++++++++ web/test/unit/workspace.test.js | 64 +++++++++++++++++++++++++++++++++ web/workspace/extension.js | 32 ++++++++++++++--- 3 files changed, 130 insertions(+), 4 deletions(-) create mode 100644 web/test/unit/workspace.test.js diff --git a/web/test/e2e.mjs b/web/test/e2e.mjs index 3fcc3da..22cbd9f 100644 --- a/web/test/e2e.mjs +++ b/web/test/e2e.mjs @@ -231,6 +231,44 @@ try { check('after a reload the editor is still signed in', after > before, `${before} -> ${after}`); check('after a reload the file that was kept is still there', (await scratchFile('/scratch/notes.md')) === 'notes\n'); await shot(page, '4-reload'); + + /* 5. a folder from "the computer". The OS picker needs a person, so the page's File System Access entry point is + replaced by one that hands back a real FileSystemDirectoryHandle (a folder of the origin-private file system); + everything after the picker — the command, the workbench's file provider, the agent's reads and writes — is real. */ + await page.eval(`(async () => { + const root = await navigator.storage.getDirectory(); + try { await root.removeEntry('project', { recursive: true }); } catch (e) {} + const dir = await root.getDirectoryHandle('project', { create: true }); + const f = await dir.getFileHandle('main.py', { create: true }); + const w = await f.createWritable(); await w.write('print("hello from main.py")\\n'); await w.close(); + window.showDirectoryPicker = async () => { const r = await navigator.storage.getDirectory(); return r.getDirectoryHandle('project', { create: true }); }; + return true; + })()`); + await page.key('F1', 'F1'); + await page.waitFor(() => page.eval(`!!document.querySelector('.quick-input-widget:not([style*="display: none"]) .quick-input-box input')`), { ms: 8000, label: 'command palette' }); + await page.type('Open Folder from Your Computer'); + await sleep(900); + await page.key('Enter', 'Enter'); + const folderRows = await page.waitFor(async () => { + const r = await page.eval(`[...document.querySelectorAll('.explorer-folders-view .monaco-list-row')].map((e) => e.getAttribute('aria-label'))`); + return r.some((x) => /main\.py/.test(x)) ? r : null; + }, { ms: 90000, label: 'the folder in the Explorer' }).catch(() => null); + check('"Open Folder from Your Computer" opens the folder the picker returned, and the Explorer lists its file', !!folderRows, JSON.stringify(folderRows)); + const folderWindow = await page.eval('document.title'); + check('the workspace is that folder (the window title), not the scratch workspace', /project/.test(String(folderWindow)) && !/scratch/i.test(String(folderWindow)), JSON.stringify(folderWindow)); + await shot(page, '5-folder'); + const opfs = (name) => page.eval(`(async () => { try { const root = await navigator.storage.getDirectory(); const dir = await root.getDirectoryHandle('project'); const f = await dir.getFileHandle(${JSON.stringify(name)}); return await (await f.getFile()).text(); } catch (e) { return null; } })()`); + await activateTab(/LevelCode AI/); + await page.waitFor(async () => (await inChat('!!d.getElementById("send")')) === true, { ms: 30000, label: 'chat webview' }); + await ask('Please list the files'); + check('list_files sees the folder\'s files (the workbench searches a File System Access folder)', /main\.py/.test(lastToolResult()), JSON.stringify(lastToolResult().slice(0, 120))); + await ask('Please read main.py'); + check('read_file reads a file of the folder through the browser\'s file handle', /hello from main\.py/.test(lastToolResult()), JSON.stringify(lastToolResult().slice(0, 120))); + await ask('Please make a notes file'); + let notes = null; + for (let i = 0; i < 20 && notes === null; i++) { notes = await opfs('notes.md'); if (notes === null) { await sleep(500); } } + check('write_file creates the file in the folder on "the computer"', notes === 'notes\n', JSON.stringify(notes)); + await shot(page, '5b-folder-agent'); } catch (e) { check('the run completed', false, String((e && e.stack) || e)); } diff --git a/web/test/unit/workspace.test.js b/web/test/unit/workspace.test.js new file mode 100644 index 0000000..18ba0ff --- /dev/null +++ b/web/test/unit/workspace.test.js @@ -0,0 +1,64 @@ +/*--------------------------------------------------------------------------------------------- + * web/workspace/extension.js — run: node web/test/unit/workspace.test.js + * + * The scratch-workspace extension is evaluated as ONE file by the web extension host, so it cannot be split into + * modules; this test loads that file against a stand-in for `vscode` and runs what it exports. + * + * openLocalFolder must ask the dialog from the `file` scheme. From the scratch scheme the workbench shows its + * own file browser instead of the browser's picker (the command did nothing useful until + * it was run in a real browser). + *--------------------------------------------------------------------------------------------*/ +// @ts-check +'use strict'; + +const assert = require('assert'); +const path = require('path'); +const Module = require('module'); + +class Uri { + constructor(scheme, p) { this.scheme = scheme; this.path = p; } + static from(c) { return new Uri(c.scheme, c.path || '/'); } + toString() { return this.scheme + '://' + this.path; } +} +const calls = { dialogs: /** @type {any[]} */ ([]), commands: /** @type {any[][]} */ ([]) }; +let nextPick = /** @type {any} */ (undefined); +const vscodeMock = { + Uri, + window: { async showOpenDialog(o) { calls.dialogs.push(o); return nextPick; } }, + commands: { async executeCommand(...a) { calls.commands.push(a); } }, +}; +const origLoad = Module._load; +// @ts-ignore +Module._load = function (request, parent, isMain) { return request === 'vscode' ? vscodeMock : origLoad.call(this, request, parent, isMain); }; +const ext = require(path.join(__dirname, '..', '..', 'workspace', 'extension.js')); + +let n = 0; +async function test(name, fn) { calls.dialogs.length = 0; calls.commands.length = 0; await fn(); n++; console.log(' ok - ' + name); } + +(async () => { + await test('the picker is asked for a folder, starting from the file scheme, not the scratch scheme', async () => { + nextPick = [new Uri('file', '/my-project')]; + await ext.openLocalFolder(vscodeMock); + assert.strictEqual(calls.dialogs.length, 1); + const o = calls.dialogs[0]; + assert.strictEqual(o.defaultUri.scheme, 'file', 'a default location in another scheme gets the workbench\'s own file browser'); + assert.strictEqual(o.canSelectFolders, true); + assert.strictEqual(o.canSelectFiles, false); + assert.strictEqual(o.canSelectMany, false); + }); + await test('the folder that was picked becomes the workspace, in this tab', async () => { + const picked = new Uri('file', '/my-project'); + nextPick = [picked]; + assert.strictEqual(await ext.openLocalFolder(vscodeMock), true); + assert.deepStrictEqual(calls.commands, [['vscode.openFolder', picked, { forceReuseWindow: true }]]); + }); + await test('cancelling the picker does nothing', async () => { + for (const none of [undefined, []]) { + nextPick = none; + calls.commands.length = 0; + assert.strictEqual(await ext.openLocalFolder(vscodeMock), false); + assert.deepStrictEqual(calls.commands, []); + } + }); + console.log(`\n${n} tests passed`); +})().catch((e) => { console.error(e); process.exit(1); }); diff --git a/web/workspace/extension.js b/web/workspace/extension.js index 7715821..66fb217 100644 --- a/web/workspace/extension.js +++ b/web/workspace/extension.js @@ -388,9 +388,7 @@ async function activate(context) { context.subscriptions.push(item, vscode.workspace.onDidChangeWorkspaceFolders(refresh)); context.subscriptions.push( - // The workbench owns the picker (File System Access lives on the window, not in this worker) - // and tells the user itself when the browser has no support for it. - vscode.commands.registerCommand('levelcode.web.openLocalFolder', () => vscode.commands.executeCommand('workbench.action.files.openFolder')), + vscode.commands.registerCommand('levelcode.web.openLocalFolder', () => openLocalFolder(vscode)), vscode.commands.registerCommand('levelcode.web.openScratch', () => vscode.commands.executeCommand( 'vscode.openFolder', vscode.Uri.from({ scheme: SCHEME, path: HOME }), { forceReuseWindow: true })), vscode.commands.registerCommand('levelcode.web.aboutScratch', async () => { @@ -403,6 +401,32 @@ async function activate(context) { ); } +/** + * Open a folder from the user's computer: the browser's own directory picker, then that folder as the workspace. + * + * The workbench chooses between its own file browser and the browser's picker by the SCHEME of the default + * location, and from inside the scratch workspace that scheme is this extension's: `File: Open Folder` would show + * the scratch workspace's own folders and never reach the picker. A dialog that starts from the `file` scheme — + * which the web workbench serves with the File System Access handles the user picks — does. A browser without + * File System Access (Safari, Firefox) is told so by the workbench itself, and nothing here runs after that. + * + * @param {typeof import('vscode')} api + * @returns {Promise} true when a folder was opened; false when the user cancelled + */ +async function openLocalFolder(api) { + const picked = await api.window.showOpenDialog({ + canSelectFolders: true, + canSelectFiles: false, + canSelectMany: false, + defaultUri: api.Uri.from({ scheme: 'file', path: '/' }), + openLabel: 'Open Folder', + title: 'Open a folder from your computer', + }); + if (!picked || picked.length === 0) { return false; } + await api.commands.executeCommand('vscode.openFolder', picked[0], { forceReuseWindow: true }); + return true; +} + function deactivate() { } -module.exports = { activate, deactivate }; +module.exports = { activate, deactivate, openLocalFolder }; From 9c86c94b23af52047fe52010b0ce0ffe038ab0ff Mon Sep 17 00:00:00 2001 From: Sergii Demianchuk Date: Fri, 9 Oct 2026 21:40:05 -0400 Subject: [PATCH 15/17] web(build): the build id covers everything that ships under it MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A release puts everything under /_// and tells browsers it never changes. The id was a hash of the client, the page and the extension's top-level .js files, so a changed scratch-workspace extension, shim, media file or skill kept its id — and would have been served stale, from cache, for a year. The hash now covers the extension's whole tree, its browser build, the scratch workspace and the page's configuration code. --- scripts/build-web.mjs | 15 +++++-- web/lib/fingerprint.mjs | 29 +++++++++++++ web/test/unit/fingerprint.test.js | 67 +++++++++++++++++++++++++++++++ 3 files changed, 108 insertions(+), 3 deletions(-) create mode 100644 web/lib/fingerprint.mjs create mode 100644 web/test/unit/fingerprint.test.js diff --git a/scripts/build-web.mjs b/scripts/build-web.mjs index 6eedc5c..5ce2eab 100644 --- a/scripts/build-web.mjs +++ b/scripts/build-web.mjs @@ -1,7 +1,7 @@ #!/usr/bin/env node // Build LevelCode in your browser: one static directory, dist-web/, that any static host can serve. // -// node scripts/build-web.mjs [--vscode vscode] [--out dist-web] [--account https://levelcode.ai] +// node scripts/build-web.mjs [--vscode vscode] [--out dist-web] [--account https://levelcode.ai] [--api-url ] // [--webview-origin 'https://{{uuid}}.view.example.com'] // [--ext-host-origin 'https://{{uuid}}.ext.example.com'] // [--static ] [--id ] @@ -23,6 +23,7 @@ import { fileURLToPath } from 'node:url'; import { webConfig, renderIndex } from '../web/lib/config.mjs'; import { stageDeclarativeExtensions, copyExtension } from '../web/lib/extensions.mjs'; import { policy, toHeadersFile, toNginx } from '../web/lib/headers.mjs'; +import { hashTree } from '../web/lib/fingerprint.mjs'; const REPO = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..'); const WEB = path.join(REPO, 'web'); @@ -34,6 +35,8 @@ const args = Object.fromEntries(process.argv.slice(2).reduce((a, v, i, all) => { const vscodeDir = path.resolve(REPO, args.vscode || 'vscode'); const outDir = path.resolve(REPO, args.out || 'dist-web'); const account = String(args.account || process.env.LEVELCODE_WEB_ACCOUNT || 'https://levelcode.ai').replace(/\/+$/, ''); +// Only for a deployment whose API is not on the account origin (production has one host for both). +const apiUrl = String(args['api-url'] || process.env.LEVELCODE_WEB_API_URL || '').replace(/\/+$/, ''); const webviewOrigin = args['webview-origin'] || process.env.LEVELCODE_WEB_WEBVIEW_ORIGIN || ''; const extHostOrigin = args['ext-host-origin'] || process.env.LEVELCODE_WEB_EXTHOST_ORIGIN || ''; @@ -65,8 +68,13 @@ for (const must of ['out/vs/workbench/workbench.web.main.internal.js', 'out/vs/w const fingerprint = createHash('sha256'); fingerprint.update(fs.readFileSync(path.join(staticDir, 'out/vs/workbench/workbench.web.main.internal.js'))); for (const f of ['main.js', 'boot.css', 'index.html']) { fingerprint.update(fs.readFileSync(path.join(WEB, f))); } +// Everything that goes into /_// and is not the web client itself: the extension (its sources, media and skills), +// its browser build, the scratch workspace, and the page's own configuration code. const aiDir = path.join(REPO, 'extensions', 'levelcode-ai'); -for (const f of fs.readdirSync(aiDir)) { if (f.endsWith('.js')) { fingerprint.update(fs.readFileSync(path.join(aiDir, f))); } } +hashTree(fingerprint, aiDir, { skip: ['test', 'node_modules', 'scripts', '.DS_Store'] }); +hashTree(fingerprint, path.join(WEB, 'ai-extension'), { skip: ['assets.generated.js', 'node_modules', '.DS_Store'] }); +hashTree(fingerprint, path.join(WEB, 'workspace'), { skip: ['node_modules', '.DS_Store'] }); +hashTree(fingerprint, path.join(WEB, 'lib'), { skip: ['.DS_Store'] }); const id = String(args.id || fingerprint.digest('hex').slice(0, 12)); let gitHead = 'unknown'; try { gitHead = execFileSync('git', ['-C', REPO, 'rev-parse', '--short=10', 'HEAD'], { encoding: 'utf8' }).trim(); } catch { /* not a checkout */ } @@ -124,6 +132,7 @@ if (webviewOrigin) { productConfiguration.webviewContentExternalBaseUrlTemplate if (extHostOrigin) { productConfiguration.webEndpointUrlTemplate = `${extHostOrigin}${prefix}/static`; } const config = webConfig({ account, + apiUrl, extensions, base: prefix, staticBase: `${prefix}/static`, @@ -142,7 +151,7 @@ fs.writeFileSync(path.join(outDir, '_headers'), toHeadersFile(pol)); fs.mkdirSync(path.join(outDir, 'deploy'), { recursive: true }); fs.writeFileSync(path.join(outDir, 'deploy', 'nginx.conf'), toNginx(pol)); const info = { - id, levelcode: gitHead, builtAt: new Date().toISOString(), account, + id, levelcode: gitHead, builtAt: new Date().toISOString(), account, apiUrl: apiUrl || '(the account origin)', webviewOrigin: webviewOrigin || '(Code-OSS default: *.vscode-cdn.net)', extHostOrigin: extHostOrigin || '(same origin as the editor)', extensions, diff --git a/web/lib/fingerprint.mjs b/web/lib/fingerprint.mjs new file mode 100644 index 0000000..747cde9 --- /dev/null +++ b/web/lib/fingerprint.mjs @@ -0,0 +1,29 @@ +// What identifies a build. A release puts everything under /_// and tells browsers it never changes, so the id has to +// change whenever anything under it does: a file of the extension that was edited but not named here would be served +// stale, from cache, for a year. +import fs from 'node:fs'; +import path from 'node:path'; + +/** + * Feed every file under `dir` — its path relative to `dir`, then its bytes — into `hash`, in a stable order. + * Files and folders whose NAME is in `skip` are left out wherever they are. + * @param {import('node:crypto').Hash} hash + * @param {string} dir + * @param {{ skip?: string[] }} [opts] + * @returns {import('node:crypto').Hash} + */ +export function hashTree(hash, dir, { skip = [] } = {}) { + const skipped = new Set(skip); + const walk = (d, rel) => { + const entries = fs.readdirSync(d, { withFileTypes: true }).sort((a, b) => (a.name < b.name ? -1 : a.name > b.name ? 1 : 0)); + for (const e of entries) { + if (skipped.has(e.name)) { continue; } + const full = path.join(d, e.name); + const r = rel ? rel + '/' + e.name : e.name; + if (e.isDirectory()) { walk(full, r); } + else if (e.isFile()) { hash.update('\0' + r + '\0'); hash.update(fs.readFileSync(full)); } + } + }; + walk(dir, ''); + return hash; +} diff --git a/web/test/unit/fingerprint.test.js b/web/test/unit/fingerprint.test.js new file mode 100644 index 0000000..b636a1f --- /dev/null +++ b/web/test/unit/fingerprint.test.js @@ -0,0 +1,67 @@ +/*--------------------------------------------------------------------------------------------- + * web/lib/fingerprint.mjs — run: node web/test/unit/fingerprint.test.js + * + * A release is cached for a year under /_//. The id is a hash of what goes into it, so a changed file that the + * hash did not see is a stale file for a year. These tests are about what the hash sees. + *--------------------------------------------------------------------------------------------*/ +// @ts-check +'use strict'; + +const assert = require('assert'); +const fs = require('fs'); +const os = require('os'); +const path = require('path'); +const { createHash } = require('crypto'); +const { pathToFileURL } = require('url'); + +let n = 0; +async function test(name, fn) { await fn(); n++; console.log(' ok - ' + name); } +const write = (root, rel, text) => { const f = path.join(root, rel); fs.mkdirSync(path.dirname(f), { recursive: true }); fs.writeFileSync(f, text); }; + +(async () => { + const { hashTree } = await import(pathToFileURL(path.join(__dirname, '..', '..', 'lib', 'fingerprint.mjs')).href); + const digest = (dir, opts) => hashTree(createHash('sha256'), dir, opts).digest('hex'); + const make = () => { const d = fs.mkdtempSync(path.join(os.tmpdir(), 'lc-fp-')); write(d, 'a.js', 'A'); write(d, 'sub/b.json', '{"b":1}'); write(d, 'sub/deep/c.txt', 'C'); return d; }; + + await test('the same files give the same hash, wherever they are and in whatever order they were written', () => { + const one = make(); + const two = fs.mkdtempSync(path.join(os.tmpdir(), 'lc-fp-')); + write(two, 'sub/deep/c.txt', 'C'); write(two, 'sub/b.json', '{"b":1}'); write(two, 'a.js', 'A'); + assert.strictEqual(digest(one), digest(two)); + }); + await test('a changed byte in any file, at any depth, changes it', () => { + const d = make(); + const before = digest(d); + for (const rel of ['a.js', 'sub/b.json', 'sub/deep/c.txt']) { + const original = fs.readFileSync(path.join(d, rel), 'utf8'); + fs.writeFileSync(path.join(d, rel), original + 'x'); + assert.notStrictEqual(digest(d), before, rel + ' was not seen'); + fs.writeFileSync(path.join(d, rel), original); + } + assert.strictEqual(digest(d), before); + }); + await test('a new file, a removed file and a renamed file change it', () => { + const d = make(); + const before = digest(d); + write(d, 'new.js', ''); + assert.notStrictEqual(digest(d), before); + fs.rmSync(path.join(d, 'new.js')); + assert.strictEqual(digest(d), before); + fs.renameSync(path.join(d, 'a.js'), path.join(d, 'z.js')); + assert.notStrictEqual(digest(d), before, 'the same bytes under another name are another build'); + }); + await test('where the bytes end and the name begins cannot be confused', () => { + const one = fs.mkdtempSync(path.join(os.tmpdir(), 'lc-fp-')); write(one, 'ab', 'c'); + const two = fs.mkdtempSync(path.join(os.tmpdir(), 'lc-fp-')); write(two, 'a', 'bc'); + assert.notStrictEqual(digest(one), digest(two)); + }); + await test('skipped names are left out wherever they are, and nothing else is', () => { + const d = make(); + write(d, 'test/x.test.js', 't'); write(d, 'sub/node_modules/y.js', 'y'); + const without = digest(d, { skip: ['test', 'node_modules'] }); + write(d, 'test/x.test.js', 'changed'); write(d, 'sub/node_modules/y.js', 'changed'); + assert.strictEqual(digest(d, { skip: ['test', 'node_modules'] }), without); + assert.notStrictEqual(digest(d), without); + }); + console.log(`\n${n} tests passed`); +})().catch((e) => { console.error(e); process.exit(1); }); From c6d838b65184a7a7c073a8f2fb9e523c93e17941 Mon Sep 17 00:00:00 2001 From: Sergii Demianchuk Date: Fri, 9 Oct 2026 21:40:05 -0400 Subject: [PATCH 16/17] web: --api-url, and how to try it against a real backend The extension already supports an API host that is not the account origin (levelcode.cloud.apiUrl); the build can now bake it in, which is what a local account site with the backend behind a tunnel needs. docs/WEB.md says how to run the real stack locally and what to look for, and what was and was not run. --- docs/WEB.md | 48 +++++++++++++++++++++++++++++++++++++++++----- web/lib/config.mjs | 3 +++ 2 files changed, 46 insertions(+), 5 deletions(-) diff --git a/docs/WEB.md b/docs/WEB.md index 4012799..5cbab61 100644 --- a/docs/WEB.md +++ b/docs/WEB.md @@ -140,6 +140,7 @@ node web/serve.mjs --dist dist-web # http://127.0.0.1:8800 | `build-web.mjs` flag | Meaning | | --- | --- | | `--account ` (`LEVELCODE_WEB_ACCOUNT`) | The LevelCode Cloud origin: where sign-in happens, and where the gateway is. Baked into `index.html`. | +| `--api-url ` (`LEVELCODE_WEB_API_URL`) | Only when the API is not on the account origin (production has one host for both): a local account site with the backend behind a tunnel, say. The gateway must be `https`. | | `--webview-origin 'https://{{uuid}}.view.example.com'` (`LEVELCODE_WEB_WEBVIEW_ORIGIN`) | Self-host the webview origin. Without it the page uses Code-OSS's CDN at this build's own commit. Needs wildcard DNS and TLS — see below. | | `--ext-host-origin 'https://{{uuid}}.ext.example.com'` (`LEVELCODE_WEB_EXTHOST_ORIGIN`) | Run the extension host on an origin of its own. Optional while no third-party code is loaded. | | `--static ` | Use a prebuilt client (skips the gulp build). | @@ -271,16 +272,53 @@ webview from Code-OSS's CDN), and against the development server with the webvie host on origins of their own (`--product` with `webviewContentExternalBaseUrlTemplate` and `webEndpointUrlTemplate`). +### Trying it yourself, against a real backend + +The repo's checks use a stand-in for the account site. To use the real one, locally: + +1. **Backend** — the thin.ly branch with the web edition, its usual dev server and https tunnel, plus + `LEVELCODE_WEB_EDITOR_ORIGINS=http://localhost:8800` and `LEVELCODE_WEB_EDITOR_URL=http://localhost:8800/`. + (The gateway refuses plain http, so the editor's API host has to be the tunnel.) +2. **Account site** — the onetime branch: `npm run dev` (it proxies to the backend), at `http://localhost:5173/ai`. +3. **Editor:** + + ```bash + node scripts/build-web.mjs --account http://localhost:5173 --api-url https:// --out dist-web + node web/serve.mjs --dist dist-web # http://localhost:8800 + ``` + +4. **What to do, and what you should see:** + - Signed out everywhere: open `http://localhost:8800/?signin=1`. The tab goes to the account site's + sign-in page ("Connect the editor to your account…"). Sign in with an email code. The tab comes back to + the editor with a clean address, a "Signed in to LevelCode." message, and the model and plan in the chat footer. + - Signed in on the account site first: open `http://localhost:5173/ai/account` and use **Open in browser**. + The editor opens, the tab flashes through the login page ("Opening LevelCode…") and is back signed in, + with nothing typed. + - Ask the agent: *Create a file named hello.txt containing: hello.* The file appears in the Explorer. + - **LevelCode: Open Folder from Your Computer…** (palette) in Chrome or Edge: pick a folder, ask the agent + to read a file of it and write another. + - A second tab of the editor opens already signed in (it shares the session). + +Look at the browser's network panel while you do it: every call to the API should be `200`, with no CORS +errors in the console. A CORS error means the editor's origin is not in `LEVELCODE_WEB_EDITOR_ORIGINS`. + +This was run once, by a hand-driven script, against Rails from the thin.ly branch in test mode (email and +Stripe stubbed, a throwaway database): email-code sign-in from the editor, sign-in from an already +signed-in browser, a gateway chat, the agent writing a file, and a folder opened through a real +`FileSystemDirectoryHandle` (the origin-private file system stands in for the OS picker). That script needs +the other repository's internals and is not part of this repo's checks. + ### What was not run Say these out loud rather than discover them in production: - **A real browser other than headless Chrome.** Safari and Firefox are untested; so are phones. -- **The File System Access path** (open a folder from the computer): it needs a user gesture and a - picker that headless Chrome does not offer. The code path is the editor's own (`showDirectoryPicker`), - and the agent's tools reach it through `vscode.workspace.fs` as for the scratch workspace. -- **The real account SPA and the real Rails app** in the same-tab flow. The stand-in plays "already - signed in there"; the backend's rules are its own specs, run in a separate checkout. +- **The operating system's folder picker itself.** It needs a person. Everything after it — the command, the + workbench's `file` provider on a real `FileSystemDirectoryHandle`, the agent's list, read and write — is run + (`web/test/e2e.mjs` section 5), with the origin-private file system standing in for the picker. Permission + handling on a *persisted* handle after a reload (the browser asks again) is the browser's and was not run. +- **A production account site.** The real sign-in page and Rails were run locally (above), not the deployed + ones; the backend's rules are its own specs. - **Another provider's CORS.** Anthropic's browser-access header is set; whether a given provider answers a page's request is that provider's. - **Production hosting:** DNS, a wildcard certificate, the CDN in front, the real headers on the real host. diff --git a/web/lib/config.mjs b/web/lib/config.mjs index 68a8c1f..5f26681 100644 --- a/web/lib/config.mjs +++ b/web/lib/config.mjs @@ -9,6 +9,8 @@ export const WEB_DIR = path.dirname(path.dirname(fileURLToPath(import.meta.url)) /** * @param {object} o * @param {string} [o.account] LevelCode Cloud origin the editor signs in to and calls (the gateway lives there). + * @param {string} [o.apiUrl] The API host, when it is not the account origin (the extension's levelcode.cloud.apiUrl): + * a tunnel to a local backend, say. The gateway must be https. * @param {string[]} [o.extensions] Folder names, under /extensions, of the extensions loaded as built-ins. * @param {string} [o.staticBase] Where the Code-OSS web build is mounted. * @param {object} [o.productConfiguration] Overrides merged over the product the build embeds. @@ -39,6 +41,7 @@ export function webConfig(o = {}) { }, o.productConfiguration), configurationDefaults: Object.assign({ 'levelcode.cloud.endpoint': account, + ...(o.apiUrl ? { 'levelcode.cloud.apiUrl': trimSlash(o.apiUrl) } : {}), // LevelCode is the assistant here; the stock Copilot surfaces (status item, chat view, command // center) are for an account this editor does not use. The desktop app sets the same. 'chat.disableAIFeatures': true, From 54b479f2ea2243dd9fac35c6d8a55ec6efe20c0e Mon Sep 17 00:00:00 2001 From: Sergii Demianchuk Date: Fri, 9 Oct 2026 23:29:29 -0400 Subject: [PATCH 17/17] docs(web): the real-backend recipe has no placeholder to paste, and says what the build needs --- docs/WEB.md | 8 +++++--- scripts/build-web.mjs | 3 ++- 2 files changed, 7 insertions(+), 4 deletions(-) diff --git a/docs/WEB.md b/docs/WEB.md index 5cbab61..f0e8efa 100644 --- a/docs/WEB.md +++ b/docs/WEB.md @@ -280,11 +280,13 @@ The repo's checks use a stand-in for the account site. To use the real one, loca `LEVELCODE_WEB_EDITOR_ORIGINS=http://localhost:8800` and `LEVELCODE_WEB_EDITOR_URL=http://localhost:8800/`. (The gateway refuses plain http, so the editor's API host has to be the tunnel.) 2. **Account site** — the onetime branch: `npm run dev` (it proxies to the backend), at `http://localhost:5173/ai`. -3. **Editor:** +3. **Editor** (from the levelcode repo root; the build needs the bootstrapped `vscode/` checkout, and a git + worktree has none, so pass `--vscode ` there): ```bash - node scripts/build-web.mjs --account http://localhost:5173 --api-url https:// --out dist-web - node web/serve.mjs --dist dist-web # http://localhost:8800 + export API_URL=https://your-tunnel.example.com # the https address of your backend + node scripts/build-web.mjs --account http://localhost:5173 --api-url "$API_URL" --out dist-web + node web/serve.mjs --dist dist-web # http://localhost:8800 ``` 4. **What to do, and what you should see:** diff --git a/scripts/build-web.mjs b/scripts/build-web.mjs index 5ce2eab..84d2b38 100644 --- a/scripts/build-web.mjs +++ b/scripts/build-web.mjs @@ -14,7 +14,8 @@ // 3. lays everything out under a build-addressed prefix (/_//...), renders index.html with the // configuration baked in, and writes the HTTP policy for the host (_headers, nginx). // -// It changes nothing outside --out. It does not deploy. +// It changes nothing outside --out, except that building the client from the checkout leaves gulp's own output, +// vscode-web/, next to it (pass --static to use a client built earlier and skip that). It does not deploy. import { spawnSync, execFileSync } from 'node:child_process'; import fs from 'node:fs'; import path from 'node:path';