Add --disable-file-cache flag (#8085)

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.
This commit is contained in:
Andy Piper
2026-11-02 14:14:26 -09:00
committed by GitHub
parent ee16a40e41
commit 79d2c29c49
6 changed files with 336 additions and 0 deletions
+7
View File
@@ -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.144.0](https://github.com/coder/code-server/releases/tag/v4.144.0) - 2026-10-29
Code v1.144.0
+188
View File
@@ -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=<image>` 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<Uint8Array<ArrayBuffer>> | 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');
+1
View File
@@ -27,3 +27,4 @@ app-name.diff
remote-storage.diff
remote-secret-storage.diff
csp-hashes.diff
disable-file-cache.diff
+5
View File
@@ -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<Required<UserProvidedArgs>> = {
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.",
+132
View File
@@ -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<Page> {
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<string[]> {
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")
})
})
+3
View File
@@ -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,