diff --git a/package-lock.json b/package-lock.json index 60022db..3571ac3 100644 --- a/package-lock.json +++ b/package-lock.json @@ -10647,6 +10647,7 @@ "version": "0.0.1", "dependencies": { "@hocuspocus/provider": "^4.0", + "minimatch": "^10.2", "ws": "^8.20", "zod": "^4.4" }, @@ -10660,7 +10661,43 @@ "esbuild": "^0.28" }, "engines": { - "vscode": "^1.109.5" + "vscode": "^1.122.1" + } + }, + "vscode-workbench/node_modules/balanced-match": { + "version": "4.0.4", + "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-4.0.4.tgz", + "integrity": "sha512-BLrgEcRTwX2o6gGxGOCNyMvGSp35YofuYzw9h1IMTRmKqttAZZVU67bdb9Pr2vUHA8+j3i2tJfjO6C6+4myGTA==", + "license": "MIT", + "engines": { + "node": "18 || 20 || >=22" + } + }, + "vscode-workbench/node_modules/brace-expansion": { + "version": "5.0.6", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-5.0.6.tgz", + "integrity": "sha512-kLpxurY4Z4r9sgMsyG0Z9uzsBlgiU/EFKhj/h91/8yHu0edo7XuixOIH3VcJ8kkxs6/jPzoI6U9Vj3WqbMQ94g==", + "license": "MIT", + "dependencies": { + "balanced-match": "^4.0.2" + }, + "engines": { + "node": "18 || 20 || >=22" + } + }, + "vscode-workbench/node_modules/minimatch": { + "version": "10.2.5", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-10.2.5.tgz", + "integrity": "sha512-MULkVLfKGYDFYejP07QOurDLLQpcjk7Fw+7jXS2R2czRQzR56yHRveU5NDJEOviH+hETZKSkIk5c+T23GjFUMg==", + "license": "BlueOak-1.0.0", + "dependencies": { + "brace-expansion": "^5.0.5" + }, + "engines": { + "node": "18 || 20 || >=22" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" } } } diff --git a/src/lib/server/collabServer.ts b/src/lib/server/collabServer.ts index c1cfd1b..52b46a4 100644 --- a/src/lib/server/collabServer.ts +++ b/src/lib/server/collabServer.ts @@ -16,7 +16,7 @@ export const COLLAB_DB_FILENAME = 'collab.db' export class CollabServerHandle { /** Unique ID of this `collab-server` instance. */ readonly uuid = crypto.randomUUID() - /** Directory in which `collab-server` places its files. */ + /** Directory in which `collab-server` places its ephemeral files. */ readonly workDir: string = `/tmp/collab-server-${this.uuid}/` /** Path to the `collab-server` UDS file. */ readonly socketPath: string = path.join(this.workDir, COLLAB_SOCKET_FILENAME) @@ -45,8 +45,11 @@ export class CollabServerHandle { * The returned promise resolves when the server is ready * and has created its UDS file. * Throws if the server fails to start or set up. - * Repeated calls produce the same promise. */ - async start() { + * Repeated calls (with any arguments) produce the same promise. */ + async start( + /** Additional arguments to `bwrap` placed at the end. */ + bwrapArgs: string[], + ) { if (this.starting) return this.starting this.starting = (async () => { await fs.mkdir(this.workDir, { recursive: true }) @@ -66,6 +69,8 @@ export class CollabServerHandle { '--bind', this.projectDir, sandboxProjectDir, '--bind', this.workDir, '/workspace/.collab-server', '--chdir', '/workspace/.collab-server', + ...bwrapArgs, + '--', '/usr/bin/node', path.join(getCollabServerDir(), 'dist', 'server.js'), sandboxProjectDir, diff --git a/src/lib/server/editorSessions.ts b/src/lib/server/editorSessions.ts index 4bcabd7..0c33e55 100644 --- a/src/lib/server/editorSessions.ts +++ b/src/lib/server/editorSessions.ts @@ -4,9 +4,11 @@ import { getWorkspacesDir } from '@/lib/server/config' import { getDb } from '@/lib/server/db' import { VscodeServerHandle } from '@/lib/server/vscodeServer' import type { Project } from '@/prisma/generated/client' +import fs from 'node:fs/promises' import path from 'node:path' import { EventEmitter } from 'node:stream' import 'server-only' +import { buildPackageOverlays } from './util' /** Admin-visible information about a running editor session. */ export interface EditorSessionInfo { @@ -52,7 +54,7 @@ export class EditorSessionManager { } /** Find a running `collab-server` for the given project, - * or create one and store it if none are running. + * or create one and store it in {@link collabServers} if none are running. * Does not start the server. */ private findCollabServer(project: Project, projectDir: string): CollabServerHandle { let server = this.collabServers.get(project.id) @@ -73,35 +75,46 @@ export class EditorSessionManager { * Returns the path to the corresponding VSCode `iframe`. */ async ensureSession(viewer: User, owner: User, project: Project): Promise { const projectSessions = this.vscServers.get(project.id) ?? [] - let session = projectSessions.find(s => s.viewer.id === viewer.id) - if (!session) { + let vscServer = projectSessions.find(s => s.viewer.id === viewer.id) + if (!vscServer) { const projectDir = path.join(getWorkspacesDir(), owner.name, project.id) + try { + await fs.access(projectDir) + } catch (err) { + throw new Error(`Could not open project directory '${projectDir}': ${String(err)}`) + } + const overlayWorkDir = path.join(getWorkspacesDir(), owner.name, 'overlay-work', project.id) + const overlayArgs = await buildPackageOverlays(project.id, project.name, projectDir, overlayWorkDir) const collabServer = this.findCollabServer(project, projectDir) - session = new VscodeServerHandle(viewer, owner, project, projectDir, collabServer.workDir) - session.addDisposable(async () => { + collabServer.addDisposable(async () => { + // collab-server is the last process to exit - remove the overlayfs workdir when it does. + await fs.rm(overlayWorkDir, { recursive: true, force: true }) + }) + vscServer = new VscodeServerHandle(viewer, owner, project, projectDir, collabServer.workDir) + vscServer.addDisposable(async () => { this.vscServers.set( project.id, - (this.vscServers.get(project.id) ?? []).filter(s => s !== session), + (this.vscServers.get(project.id) ?? []).filter(s => s !== vscServer), ) - this.vscServerEvents.emit('close', session!) + this.vscServerEvents.emit('close', vscServer!) }) // Insertion happens in same transaction as failed lookup (before any `await`) - this.vscServers.set(project.id, [...(this.vscServers.get(project.id) ?? []), session]) + this.vscServers.set(project.id, [...(this.vscServers.get(project.id) ?? []), vscServer]) await Promise.all([ - collabServer.start().catch(async e => { + collabServer.start(overlayArgs).catch(async e => { await collabServer.dispose() throw e }), - session.start().catch(async e => { - await session!.dispose() + vscServer.start(overlayArgs).catch(async e => { + await vscServer!.dispose() throw e }), ]) } - await session.start() - return session.vscodeIframePath + await vscServer.start([]) + return vscServer.vscodeIframePath } killSession(projectId: string, sessionId: string): void { diff --git a/src/lib/server/util.ts b/src/lib/server/util.ts index 5ceeeac..e8ec46c 100644 --- a/src/lib/server/util.ts +++ b/src/lib/server/util.ts @@ -1,5 +1,8 @@ +import { getPackageSetsDir } from '@/lib/server/config' +import { getDb } from '@/lib/server/db' import type { Project } from '@/prisma/generated/client' import fs from 'node:fs/promises' +import path from 'node:path' import 'server-only' import type { User } from './auth' @@ -32,7 +35,7 @@ export async function readProcesses(): Promise> { return procs } -/** Arguments that we pass to every bubblewrap sandbox. */ +/** Arguments that we pass to every bubblewrap sandbox before any other arguments. */ export const BWRAP_ARGS = /* prettier-ignore */ [ '--ro-bind', '/usr', '/usr', @@ -64,6 +67,51 @@ export function bwrapProjectDir(projectName: string) { return `/workspace/${projectName}/` } +/** Build `bwrap` arguments that mount a project's package sets inside the sandbox, + * while creating the necessary directories on the host. + * + * Packages are mounted as writable overlays, + * with `/.lake/packages/` (i.e., the usual directory) as the upper layer + * and `//` as the lower (read-only) layer. + * Overlayfs work directories are created as subdirectories of {@link overlayWorkDir}, + * which per overlayfs requirements must be on the same filesystem as {@link projectDir}. + * {@link overlayWorkDir} should also not be a subdirectory of {@link projectDir}, + * to prevent access from within the sandbox. */ +export async function buildPackageOverlays( + projectId: string, + projectName: string, + projectDir: string, + overlayWorkDir: string, +): Promise { + const packageSets = await getDb().projectPackageSet.findMany({ where: { projectId } }) + const sandboxProjectDir = bwrapProjectDir(projectName) + const args: string[] = [] + for (const { packageSet } of packageSets) { + const pkgSetDir = path.join(getPackageSetsDir(), packageSet) + const packagesFile = path.join(pkgSetDir, 'packages.txt') + try { + await fs.access(packagesFile) + } catch { + console.warn(`[buildPackageOverlays] failed to access ${packagesFile}`) + continue + } + const packages = (await fs.readFile(packagesFile, 'utf-8')).split('\n').filter(Boolean) + for (const pkg of packages) { + const lakePkgDir = path.join('.lake', 'packages', pkg) + const upperDir = path.join(projectDir, lakePkgDir) + const pkgWorkDir = path.join(overlayWorkDir, pkg) + await Promise.all([fs.mkdir(upperDir, { recursive: true }), fs.mkdir(pkgWorkDir, { recursive: true })]) + const sandboxPkgDir = path.join(sandboxProjectDir, lakePkgDir) + // prettier-ignore + args.push( + '--overlay-src', path.join(pkgSetDir, pkg), + '--overlay', upperDir, pkgWorkDir, sandboxPkgDir, + ) + } + } + return args +} + export function canAccessProject(user: User, project: Project) { const isOwner = user.id === project.userId return isOwner || project.isPublic diff --git a/src/lib/server/vscodeServer.ts b/src/lib/server/vscodeServer.ts index 342dff4..a68ea93 100644 --- a/src/lib/server/vscodeServer.ts +++ b/src/lib/server/vscodeServer.ts @@ -3,11 +3,9 @@ import { getNginxConfDir, getNginxLogDir, getOpenVscodeServerDir, - getPackageSetsDir, getWorkspacesDir, isDevMode, } from '@/lib/server/config' -import { getDb } from '@/lib/server/db' import { BWRAP_ARGS, bwrapProjectDir, readProcesses } from '@/lib/server/util' import { Project } from '@/prisma/generated/client' import { User } from 'better-auth' @@ -136,46 +134,16 @@ export class VscodeServerHandle { await fs.writeFile(this.nginxUserRoutePath, conf) } - /** Build `--overlay-src/--tmp-overlay` args for the associated project's package sets. - * These mount each package in the package set in the `bubblewrap` sandbox. - * Writes go to a tmpfs and are discarded when the container exits. */ - private async buildOverlayArgs(sandboxProjectDir: string): Promise { - const packageSets = await getDb().projectPackageSet.findMany({ where: { projectId: this.project.id } }) - const args: string[] = [] - for (const { packageSet } of packageSets) { - const setDir = path.join(getPackageSetsDir(), packageSet) - const packagesFile = path.join(setDir, 'packages.txt') - try { - await fs.access(packagesFile) - } catch { - continue - } - const packages = (await fs.readFile(packagesFile, 'utf-8')).split('\n').filter(Boolean) - for (const pkg of packages) { - await fs.mkdir(path.join(this.projectDir, '.lake', 'packages', pkg), { recursive: true }) - // prettier-ignore - args.push( - '--overlay-src', path.join(setDir, pkg), - '--tmp-overlay', path.join(sandboxProjectDir, '.lake', 'packages', pkg), - ) - } - } - return args - } - /** Signal the server to start. * The returned promise resolves after the server has started listening on its port, * and the Nginx route for {@link vscodeIframePath} has been set up. - * Repeated calls produce the same promise. */ - async start() { + * Repeated calls (with any arguments) produce the same promise. */ + async start( + /** Additional arguments to `bwrap` placed at the end. */ + bwrapArgs: string[], + ) { if (this.starting) return this.starting this.starting = (async () => { - try { - await fs.access(this.projectDir) - } catch (err) { - throw new Error(`Could not open project directory '${this.projectDir}': ${String(err)}`) - } - await fs.mkdir(this.socketDir, { recursive: true }) this.disposables.defer(async () => { await fs.rm(this.socketDir, { recursive: true, force: true }) @@ -187,7 +155,6 @@ export class VscodeServerHandle { await ensureMachineSettings(vscServerDataDir) const sandboxProjectDir = bwrapProjectDir(this.project.name) - const overlayArgs = await this.buildOverlayArgs(sandboxProjectDir) const devArgs = isDevMode() ? /* prettier-ignore */ [ @@ -214,7 +181,6 @@ export class VscodeServerHandle { '--bind', this.collabWorkDir, '/workspace/.collab-server', '--bind', this.socketDir, '/workspace/.vscode-server', '--ro-bind-data', '3', '/workspace/.lean-workbench.json', - ...overlayArgs, '--setenv', 'HOME', '/workspace', '--setenv', 'ELAN_HOME', getElanDir(), '--setenv', 'PATH', `${getElanDir()}/bin:/usr/local/bin:/usr/bin:/bin`, @@ -226,6 +192,7 @@ export class VscodeServerHandle { '--setenv', 'GIT_CONFIG_KEY_0', 'safe.directory', '--setenv', 'GIT_CONFIG_VALUE_0', '*', ...devArgs, + ...bwrapArgs, '--', path.join(getOpenVscodeServerDir(), 'bin', 'code-server'), '--socket', `/workspace/.vscode-server/${VSCODE_SOCKET_FILENAME}`, @@ -271,6 +238,7 @@ export class VscodeServerHandle { name: this.viewer.name, image: this.viewer.image, }, + syncPatterns: [path.join(sandboxProjectDir, '**', '*')], }), ) await this.writeNginxUserRoute() diff --git a/vscode-workbench/package.json b/vscode-workbench/package.json index 16de7e6..393bf78 100644 --- a/vscode-workbench/package.json +++ b/vscode-workbench/package.json @@ -63,6 +63,7 @@ }, "dependencies": { "@hocuspocus/provider": "^4.0", + "minimatch": "^10.2", "ws": "^8.20", "zod": "^4.4" } diff --git a/vscode-workbench/src/extension.ts b/vscode-workbench/src/extension.ts index 37588f6..34483e5 100644 --- a/vscode-workbench/src/extension.ts +++ b/vscode-workbench/src/extension.ts @@ -18,7 +18,7 @@ async function readWorkspaceMdata(log: vs.LogOutputChannel): Promise f.uri.scheme === 'file').map(f => f.uri.fsPath) -} - /** Extensions that intercept text input and conflict with collaborative editing. */ const CONFLICTING_EXTENSIONS = ['vscodevim.vim', 'asvetliakov.vscode-neovim'] @@ -48,6 +44,7 @@ export async function activate(ctx: vs.ExtensionContext) { const mdata = await readWorkspaceMdata(log) if (!mdata) return + log.trace(`workspace metadata: ${JSON.stringify(mdata)}`) checkInstalledExtensions() ctx.subscriptions.push(vs.extensions.onDidChange(checkInstalledExtensions)) @@ -59,10 +56,9 @@ export async function activate(ctx: vs.ExtensionContext) { // We apply collaborative syncing to open folders (usually just the project folder) only. // User-specific folders such as /workspace/.vscode-remote are not synced // (though they would be if someone opens /workspace - TODO better UX). - const bindings = new YTextBindingManager(collabServer.collabSock, syncableDirs(), log) + const bindings = new YTextBindingManager(collabServer.collabSock, mdata, log) ctx.subscriptions.push( bindings, - vs.workspace.onDidChangeWorkspaceFolders(() => bindings.updateSyncableDirs(syncableDirs())), // Remote presence indicators new RemoteSelectionDecorator(collabServer.awareness), ) diff --git a/vscode-workbench/src/textBinding.ts b/vscode-workbench/src/textBinding.ts index 1684f9e..de938b5 100644 --- a/vscode-workbench/src/textBinding.ts +++ b/vscode-workbench/src/textBinding.ts @@ -1,8 +1,7 @@ import { HocuspocusProvider, HocuspocusProviderWebsocket } from '@hocuspocus/provider' -import path from 'node:path' import vs from 'vscode' import * as Y from 'yjs' -import { Logger, logWithPrefix, YTEXT_KEY } from './util' +import { Logger, logWithPrefix, shouldSyncPath, WorkspaceMetadata, YTEXT_KEY } from './util' /** Maintains a {@link YTextBinding} binding for every open {@link vs.TextDocument} * whose path lies within one of the syncable directories. */ @@ -12,8 +11,7 @@ export class YTextBindingManager implements vs.Disposable { constructor( private readonly collabSock: HocuspocusProviderWebsocket, - /** Directories to sync. Files not contained in any of these are not synced. */ - private syncDirs: string[], + private readonly mdata: WorkspaceMetadata, private readonly log: vs.LogOutputChannel, ) { this.disposables.push( @@ -25,31 +23,12 @@ export class YTextBindingManager implements vs.Disposable { for (const doc of vs.workspace.textDocuments) this.onDidOpenTextDocument(doc) } - /** Replace the set of syncable directories. */ - updateSyncableDirs(syncDirs: string[]) { - this.syncDirs = syncDirs - // Tear down bindings no longer in any syncable dir - for (const [filePath, binding] of this.bindings) { - if (this.shouldSyncPath(filePath)) continue - this.bindings.delete(filePath) - binding.dispose() - } - // Rebind already-open buffers in case they are now syncable - // (no-op if already bound) - for (const doc of vs.workspace.textDocuments) this.onDidOpenTextDocument(doc) - } - - private shouldSyncPath(filePath: string): boolean { - return this.syncDirs.some(d => { - const rel = path.relative(d, filePath) - return rel === '' || (!rel.startsWith('..') && !path.isAbsolute(rel)) - }) - } - private onDidOpenTextDocument(doc: vs.TextDocument) { if (doc.uri.scheme !== 'file') return const filePath = doc.uri.fsPath - if (!this.shouldSyncPath(filePath)) return + const shouldSync = shouldSyncPath(this.mdata, filePath) + this.log.trace(`[YTextBindingManager] opened ${filePath}, shouldSync=${shouldSync}`) + if (!shouldSync) return // TODO: can one path have multiple `TextDocument`s? if (this.bindings.has(filePath)) return this.bindings.set(filePath, new YTextBinding(doc, this.collabSock, this.log)) @@ -69,7 +48,7 @@ export class YTextBindingManager implements vs.Disposable { const filePath = e.document.uri.fsPath const binding = this.bindings.get(filePath) if (!binding) { - if (this.shouldSyncPath(filePath)) { + if (shouldSyncPath(this.mdata, filePath)) { this.log.warn(`[onDidChangeTextDocument] dropped edit on '${filePath}', missing YTextBinding`) } return @@ -279,19 +258,18 @@ export class YTextBinding implements vs.Disposable { const remoteStr = this.remoteYtext.toString() const localStr = this.doc.getText() - let success = false if (remoteStr === localStr) { - success = true - } else { - success = await this.makeLocalEdit(b => { - const fullRange = new vs.Range(new vs.Position(0, 0), this.doc.positionAt(this.doc.getText().length)) - b.replace(fullRange, remoteStr) - }) + this.log.trace('[initFromRemote] synced without edit') + return } + const success = await this.makeLocalEdit(b => { + const fullRange = new vs.Range(new vs.Position(0, 0), this.doc.positionAt(this.doc.getText().length)) + b.replace(fullRange, remoteStr) + }) if (success) { - this.log.trace('[initFromRemote] synced') + this.log.trace('[initFromRemote] synced with edit') } else { - this.log.error(`[initFromRemote] failed to overwrite document`) + this.log.error('[initFromRemote] failed to edit document') } } diff --git a/vscode-workbench/src/util.ts b/vscode-workbench/src/util.ts index 417a169..53de780 100644 --- a/vscode-workbench/src/util.ts +++ b/vscode-workbench/src/util.ts @@ -1,3 +1,4 @@ +import { minimatch } from 'minimatch' import fs from 'node:fs/promises' import vs from 'vscode' import { z } from 'zod' @@ -51,11 +52,25 @@ export const zWorkspaceMetadata = z.object({ name: z.string(), image: z.nullish(z.string()), }), + /** Files that should be synced collaboratively across viewers. + * Patterns are matched with minimatch. */ + syncPatterns: z.array(z.string()), + /** Files that should be excluded from collaborative sync. + * Patterns are matched with minimatch. */ + excludeSyncPatterns: z.array(z.string()).optional(), }) /** Metadata of a Lean Workbench project workspace. */ export type WorkspaceMetadata = z.infer +/** Whether `filePath` should be collaboratively synced, + * i.e. it matches some {@link WorkspaceMetadata.syncPatterns} entry + * and no {@link WorkspaceMetadata.excludeSyncPatterns} entry. */ +export function shouldSyncPath(mdata: WorkspaceMetadata, filePath: string): boolean { + const matches = (pattern: string) => minimatch(filePath, pattern, { dot: true }) + return mdata.syncPatterns.some(matches) && !(mdata.excludeSyncPatterns ?? []).some(matches) +} + /** Working directory of collab-server in the VSCode and collab-server bwraps. */ export const BWRAP_COLLAB_SERVER_DIR = '/workspace/.collab-server'