From 6a85d9d7a7e97e53da1b9203916e585c162e75c5 Mon Sep 17 00:00:00 2001 From: Andy Piper Date: Wed, 28 Oct 2026 09:02:59 -0400 Subject: [PATCH] Add --disable-file-cache flag The browser writes some workspace files to the client disk during normal use. The webview service worker stores resources that have an ETag in Cache Storage, and responses from /vscode-remote-resource can end up in the HTTP disk cache. --disable-file-downloads does not cover either. With --disable-file-cache, the webview resource loader sends no ETag for a resource outside the webview's own extension, so the service worker does not store it. /vscode-remote-resource responds with `Cache-Control: no-store` for files outside the extension directories. Extension files keep their ETags, so webviews still load extension code from the cache. The flag is off by default. The VS Code changes are in a new patch that follows the plumbing of external-file-actions.diff. The e2e tests check the Cache Storage of the Markdown preview webview and the headers of /vscode-remote-resource. --- CHANGELOG.md | 7 ++ patches/disable-file-cache.diff | 188 ++++++++++++++++++++++++++++++++ patches/series | 1 + src/node/cli.ts | 5 + test/e2e/fileCache.test.ts | 132 ++++++++++++++++++++++ test/unit/node/cli.test.ts | 3 + 6 files changed, 336 insertions(+) create mode 100644 patches/disable-file-cache.diff create mode 100644 test/e2e/fileCache.test.ts diff --git a/CHANGELOG.md b/CHANGELOG.md index 4ba693eb6d5b..46efa06189ce 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -22,6 +22,13 @@ Code v99.99.999 ## Unreleased +### Added + +- `--disable-file-cache` keeps workspace files out of the browser cache. It + stops the webview service worker from storing them in Cache Storage and + serves them from `/vscode-remote-resource` with `Cache-Control: no-store`. + Extension files stay cached. + ## [4.143.1](https://github.com/coder/code-server/releases/tag/v4.143.1) - 2026-10-23 Code v1.143.1 diff --git a/patches/disable-file-cache.diff b/patches/disable-file-cache.diff new file mode 100644 index 000000000000..4aca32a0af1c --- /dev/null +++ b/patches/disable-file-cache.diff @@ -0,0 +1,188 @@ +Add option to keep workspace files out of the browser cache + +This patch adds support for a new CLI flag called `--disable-file-cache`. When +set, workspace files are not stored on the client disk through two caches: + +- The webview resource loader sends no ETag for a resource outside the + webview's own extension, and the webview service worker only stores responses + that have an ETag, so these resources stay out of Cache Storage. +- `/vscode-remote-resource` responds with `Cache-Control: no-store` for files + outside the extension directories. + +Extension resources keep their ETags so webviews still load extension code from +the cache. The default value for this is `false`. + +To test, start code-server with `--disable-file-cache`, open a Markdown file +that embeds an image from the workspace, and open the preview. In the browser +dev tools, the `vscode-resource-cache-*` Cache Storage of the webview service +worker should have no entry for the image, and a request to +`/vscode-remote-resource?path=` should respond with +`Cache-Control: no-store`. + +Index: code-server/lib/vscode/src/vs/server/node/remoteExtensionHostAgentServer.ts +=================================================================== +--- code-server.orig/lib/vscode/src/vs/server/node/remoteExtensionHostAgentServer.ts ++++ code-server/lib/vscode/src/vs/server/node/remoteExtensionHostAgentServer.ts +@@ -177,15 +177,14 @@ class RemoteExtensionHostAgentServer ext + + const requestOrigin = req.headers['origin']; + const responseHeaders = getRemoteResourceResponseHeaders(requestOrigin, origin => this._webEndpointOriginChecker.matches(origin)); +- if (this._environmentService.isBuilt) { +- if (isEqualOrParent(filePath, this._environmentService.builtinExtensionsPath, !platform.isLinux) +- || isEqualOrParent(filePath, this._environmentService.extensionsPath, !platform.isLinux) +- ) { +- responseHeaders['Cache-Control'] = 'public, max-age=31536000'; +- } ++ const isExtensionFile = isEqualOrParent(filePath, this._environmentService.builtinExtensionsPath, !platform.isLinux) ++ || isEqualOrParent(filePath, this._environmentService.extensionsPath, !platform.isLinux); ++ if (this._environmentService.isBuilt && isExtensionFile) { ++ responseHeaders['Cache-Control'] = 'public, max-age=31536000'; + } + +- return serveFile(filePath, CacheControl.ETAG, this._logService, req, res, responseHeaders); ++ const cacheControl = this._environmentService.args['disable-file-cache'] && !isExtensionFile ? CacheControl.NO_CACHING : CacheControl.ETAG; ++ return serveFile(filePath, cacheControl, this._logService, req, res, responseHeaders); + } + + // workbench web UI +Index: code-server/lib/vscode/src/vs/server/node/serverEnvironmentService.ts +=================================================================== +--- code-server.orig/lib/vscode/src/vs/server/node/serverEnvironmentService.ts ++++ code-server/lib/vscode/src/vs/server/node/serverEnvironmentService.ts +@@ -37,6 +37,7 @@ export const serverOptions: OptionDescri + 'auth': { type: 'string' }, + 'disable-file-downloads': { type: 'boolean' }, + 'disable-file-uploads': { type: 'boolean' }, ++ 'disable-file-cache': { type: 'boolean' }, + 'disable-getting-started-override': { type: 'boolean' }, + 'locale': { type: 'string' }, + 'link-protection-trusted-domains': { type: 'string[]' }, +@@ -139,6 +140,7 @@ export interface ServerParsedArgs { + 'auth'?: string; + 'disable-file-downloads'?: boolean; + 'disable-file-uploads'?: boolean; ++ 'disable-file-cache'?: boolean; + 'disable-getting-started-override'?: boolean, + 'locale'?: string + 'link-protection-trusted-domains'?: string[], +Index: code-server/lib/vscode/src/vs/server/node/webClientServer.ts +=================================================================== +--- code-server.orig/lib/vscode/src/vs/server/node/webClientServer.ts ++++ code-server/lib/vscode/src/vs/server/node/webClientServer.ts +@@ -442,6 +442,7 @@ export class WebClientServer { + userDataPath: this._environmentService.userDataPath, + isEnabledFileDownloads: !this._environmentService.args['disable-file-downloads'], + isEnabledFileUploads: !this._environmentService.args['disable-file-uploads'], ++ isEnabledFileCache: !this._environmentService.args['disable-file-cache'], + isEnabledCoderGettingStarted: !this._environmentService.args['disable-getting-started-override'], + _wrapWebWorkerExtHostInIframe, + developmentOptions: { enableSmokeTestDriver: this._environmentService.args['enable-smoke-test-driver'] ? true : undefined, logLevel: this._logService.getLevel() }, +Index: code-server/lib/vscode/src/vs/workbench/browser/web.api.ts +=================================================================== +--- code-server.orig/lib/vscode/src/vs/workbench/browser/web.api.ts ++++ code-server/lib/vscode/src/vs/workbench/browser/web.api.ts +@@ -330,6 +330,11 @@ export interface IWorkbenchConstructionO + readonly isEnabledFileUploads?: boolean + + /** ++ * Whether the browser may cache workspace files. ++ */ ++ readonly isEnabledFileCache?: boolean ++ ++ /** + * Whether to use Coder's custom Getting Started text. + */ + readonly isEnabledCoderGettingStarted?: boolean +Index: code-server/lib/vscode/src/vs/workbench/contrib/webview/browser/webviewElement.ts +=================================================================== +--- code-server.orig/lib/vscode/src/vs/workbench/contrib/webview/browser/webviewElement.ts ++++ code-server/lib/vscode/src/vs/workbench/contrib/webview/browser/webviewElement.ts +@@ -15,7 +15,7 @@ import { Lazy } from '../../../../base/c + import { Disposable, DisposableStore, IDisposable, MutableDisposable, toDisposable } from '../../../../base/common/lifecycle.js'; + import { COI, Schemas } from '../../../../base/common/network.js'; + import { observableValue } from '../../../../base/common/observable.js'; +-import { isEqualAuthority } from '../../../../base/common/resources.js'; ++import { isEqualAuthority, isEqualOrParent } from '../../../../base/common/resources.js'; + import { listenStream } from '../../../../base/common/stream.js'; + import { URI } from '../../../../base/common/uri.js'; + import { generateUuid } from '../../../../base/common/uuid.js'; +@@ -32,6 +32,7 @@ import { INotificationService } from '.. + import { IRemoteAuthorityResolverService } from '../../../../platform/remote/common/remoteAuthorityResolver.js'; + import { ITunnelService } from '../../../../platform/tunnel/common/tunnel.js'; + import { WebviewPortMappingManager } from '../../../../platform/webview/common/webviewPortMapping.js'; ++import { IBrowserWorkbenchEnvironmentService } from '../../../services/environment/browser/environmentService.js'; + import { IWorkbenchEnvironmentService } from '../../../services/environment/common/environmentService.js'; + import { IRemoteAgentService } from '../../../services/remote/common/remoteAgentService.js'; + import { decodeAuthority, webviewGenericCspSource, webviewRootResourceAuthority } from '../common/webview.js'; +@@ -864,6 +865,8 @@ export class WebviewElement extends Disp + const rangeHeader = range + ? `bytes ${range.start}-${rangeEnd}/${result.size}` + : undefined; ++ // The service worker only stores responses that have an etag. ++ const etag = this._isFileCacheAllowed(uri) ? result.etag : undefined; + if (WebviewElement._supportsTransferableStreams.value) { + let controller: ReadableStreamDefaultController> | undefined; + let closed = false; +@@ -915,7 +918,7 @@ export class WebviewElement extends Disp + status: range ? 206 : 200, + path: uri.path, + mime: result.mimeType, +- etag: result.etag, ++ etag, + mtime: result.mtime, + range: rangeHeader, + stream, +@@ -927,7 +930,7 @@ export class WebviewElement extends Disp + status: range ? 206 : 200, + path: uri.path, + mime: result.mimeType, +- etag: result.etag, ++ etag, + mtime: result.mtime, + range: rangeHeader, + }); +@@ -984,6 +987,14 @@ export class WebviewElement extends Disp + }); + } + ++ private _isFileCacheAllowed(uri: URI): boolean { ++ if ((this._environmentService as IBrowserWorkbenchEnvironmentService).isEnabledFileCache) { ++ return true; ++ } ++ const extensionLocation = this.extension?.location; ++ return !!extensionLocation && isEqualOrParent(uri, extensionLocation); ++ } ++ + private async localLocalhost(id: string, origin: string) { + const authority = this._environmentService.remoteAuthority; + const resolveAuthority = authority ? await this._remoteAuthorityResolverService.resolveAuthority(authority) : undefined; +Index: code-server/lib/vscode/src/vs/workbench/services/environment/browser/environmentService.ts +=================================================================== +--- code-server.orig/lib/vscode/src/vs/workbench/services/environment/browser/environmentService.ts ++++ code-server/lib/vscode/src/vs/workbench/services/environment/browser/environmentService.ts +@@ -49,6 +49,11 @@ export interface IBrowserWorkbenchEnviro + readonly isEnabledFileUploads?: boolean; + + /** ++ * Allow the browser to cache workspace files. ++ */ ++ readonly isEnabledFileCache?: boolean; ++ ++ /** + * Enable Coder's custom getting started text. + */ + readonly isEnabledCoderGettingStarted?: boolean; +@@ -145,6 +150,13 @@ export class BrowserWorkbenchEnvironment + return this.options.isEnabledFileUploads; + } + ++ get isEnabledFileCache(): boolean { ++ if (typeof this.options.isEnabledFileCache === "undefined") { ++ throw new Error('isEnabledFileCache was not provided to the browser'); ++ } ++ return this.options.isEnabledFileCache; ++ } ++ + get isEnabledCoderGettingStarted(): boolean { + if (typeof this.options.isEnabledCoderGettingStarted === "undefined") { + throw new Error('isEnabledCoderGettingStarted was not provided to the browser'); diff --git a/patches/series b/patches/series index b102e9d2c5c6..3fe2274d6a55 100644 --- a/patches/series +++ b/patches/series @@ -27,3 +27,4 @@ app-name.diff remote-storage.diff remote-secret-storage.diff csp-hashes.diff +disable-file-cache.diff diff --git a/src/node/cli.ts b/src/node/cli.ts index 6597a3b6ae6b..8ed868577356 100644 --- a/src/node/cli.ts +++ b/src/node/cli.ts @@ -51,6 +51,7 @@ export interface UserProvidedCodeArgs { "disable-update-check"?: boolean "disable-file-downloads"?: boolean "disable-file-uploads"?: boolean + "disable-file-cache"?: boolean "disable-workspace-trust"?: boolean "disable-getting-started-override"?: boolean "disable-proxy"?: boolean @@ -193,6 +194,10 @@ export const options: Options> = { type: "boolean", description: "Disable file uploads.", }, + "disable-file-cache": { + type: "boolean", + description: "Keep workspace files out of the browser cache.", + }, "disable-workspace-trust": { type: "boolean", description: "Disable Workspace Trust feature. This switch only affects the current session.", diff --git a/test/e2e/fileCache.test.ts b/test/e2e/fileCache.test.ts new file mode 100644 index 000000000000..334ce4b8d190 --- /dev/null +++ b/test/e2e/fileCache.test.ts @@ -0,0 +1,132 @@ +import { promises as fs } from "fs" +import * as path from "path" +import { Page } from "playwright" +import { clean, getMaybeProxiedCodeServer } from "../utils/helpers" +import { describe, test, expect } from "./baseFixture" +import { CodeServerPage } from "./models/CodeServer" + +// A 1x1 PNG. +const png = Buffer.from( + "iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mNkYPhfDwAChwGA60e6kgAAAABJRU5ErkJggg==", + "base64", +) + +/** + * Open a Markdown preview that embeds a PNG from the workspace and wait for + * the image to render. + */ +async function previewMarkdownWithImage(codeServerPage: CodeServerPage, imageName: string): Promise { + const dir = await codeServerPage.workspaceDir + await fs.writeFile(path.join(dir, imageName), png) + const markdown = path.join(dir, `${imageName}.md`) + await fs.writeFile(markdown, `# Preview\n\n![workspace image](${imageName})\n`) + await codeServerPage.openFile(markdown) + await codeServerPage.executeCommandViaMenus("Markdown: Open Preview to the Side") + await codeServerPage.waitForTab(`Preview ${markdown}`) + + const image = codeServerPage.page + .frameLocator("iframe.webview.ready") + .frameLocator("#active-frame") + .locator(`img[alt="workspace image"]`) + await expect.poll(() => image.evaluate((img: HTMLImageElement) => img.naturalWidth)).toBeGreaterThan(0) + return codeServerPage.page +} + +/** + * URLs in the webview service worker's Cache Storage. + */ +async function cachedWebviewUrls(page: Page): Promise { + const frame = await (await page.locator("iframe.webview.ready").elementHandle())?.contentFrame() + if (!frame) { + throw new Error("webview frame not found") + } + return frame.evaluate(async () => { + const urls: string[] = [] + for (const name of await caches.keys()) { + if (name.startsWith("vscode-resource-cache-")) { + for (const request of await (await caches.open(name)).keys()) { + urls.push(request.url) + } + } + } + return urls + }) +} + +async function fetchRemoteResource(codeServerPage: CodeServerPage, file: string) { + const address = await getMaybeProxiedCodeServer(codeServerPage) + const url = new URL(`vscode-remote-resource?path=${encodeURIComponent(file)}`, address) + return codeServerPage.page.request.get(url.toString()) +} + +/** + * Write a workspace file and a file in the user extensions directory. + */ +async function writeResourceFiles(codeServerPage: CodeServerPage, name: string) { + const dir = await codeServerPage.workspaceDir + const workspaceFile = path.join(dir, `${name}-workspace.txt`) + const extensionDir = path.join(dir, "extensions", `${name}-extension`) + const extensionFile = path.join(extensionDir, "resource.txt") + await fs.writeFile(workspaceFile, "workspace") + await fs.mkdir(extensionDir, { recursive: true }) + await fs.writeFile(extensionFile, "extension") + return { workspaceFile, extensionFile } +} + +describe("File cache (enabled)", ["--disable-workspace-trust"], {}, async () => { + const testName = "file-cache-enabled" + test.beforeAll(async () => { + await clean(testName) + }) + + test("should cache workspace files in the webview Cache Storage", async ({ codeServerPage }) => { + const page = await previewMarkdownWithImage(codeServerPage, "cache-enabled.png") + await expect + .poll(async () => (await cachedWebviewUrls(page)).filter((url) => url.includes("cache-enabled.png"))) + .toHaveLength(1) + }) + + test("should serve /vscode-remote-resource with an ETag and without no-store", async ({ codeServerPage }) => { + const { workspaceFile, extensionFile } = await writeResourceFiles(codeServerPage, "enabled") + for (const file of [workspaceFile, extensionFile]) { + const response = await fetchRemoteResource(codeServerPage, file) + expect(response.status()).toBe(200) + expect(response.headers()["etag"]).toBeTruthy() + expect(response.headers()["cache-control"] ?? "").not.toContain("no-store") + } + }) +}) + +describe("File cache (disabled)", ["--disable-workspace-trust", "--disable-file-cache"], {}, async () => { + const testName = "file-cache-disabled" + test.beforeAll(async () => { + await clean(testName) + }) + + test("should keep workspace files out of the webview Cache Storage but cache extension files", async ({ + codeServerPage, + }) => { + const page = await previewMarkdownWithImage(codeServerPage, "cache-disabled.png") + // Cached extension files show that the flag, not a cache failure, keeps the image out. + await expect + .poll(async () => (await cachedWebviewUrls(page)).filter((url) => url.includes("markdown-language-features"))) + .not.toHaveLength(0) + expect((await cachedWebviewUrls(page)).filter((url) => url.includes("cache-disabled.png"))).toHaveLength(0) + }) + + test("should serve /vscode-remote-resource with no-store for workspace files and an ETag for extension files", async ({ + codeServerPage, + }) => { + const { workspaceFile, extensionFile } = await writeResourceFiles(codeServerPage, "disabled") + + const workspaceResponse = await fetchRemoteResource(codeServerPage, workspaceFile) + expect(workspaceResponse.status()).toBe(200) + expect(workspaceResponse.headers()["cache-control"]).toBe("no-store") + expect(workspaceResponse.headers()["etag"]).toBeUndefined() + + const extensionResponse = await fetchRemoteResource(codeServerPage, extensionFile) + expect(extensionResponse.status()).toBe(200) + expect(extensionResponse.headers()["etag"]).toBeTruthy() + expect(extensionResponse.headers()["cache-control"] ?? "").not.toContain("no-store") + }) +}) diff --git a/test/unit/node/cli.test.ts b/test/unit/node/cli.test.ts index d31a35fbe2d9..ba5ad6950629 100644 --- a/test/unit/node/cli.test.ts +++ b/test/unit/node/cli.test.ts @@ -109,6 +109,8 @@ describe("parser", () => { "--disable-file-downloads", + "--disable-file-cache", + "--disable-getting-started-override", "--disable-proxy", @@ -137,6 +139,7 @@ describe("parser", () => { cert: { value: path.resolve("path/to/cert"), }, + "disable-file-cache": true, "disable-file-downloads": true, "disable-getting-started-override": true, "disable-proxy": true,