From ef5eba4348e89dc0d036d9ccfa528cacef034ca2 Mon Sep 17 00:00:00 2001 From: Limbo Date: Fri, 18 Sep 2026 04:41:01 +0800 Subject: [PATCH 1/4] Preserve path, query, and fragment in proxy URL rewrite (#8012) --- CHANGELOG.md | 5 +++ patches/proxy-uri.diff | 19 +++++++++--- test/e2e/extensions.test.ts | 61 +++++++++++++++++++++++++++++++++++++ 3 files changed, 81 insertions(+), 4 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index d9a3585ef0d5..aefb9e997b66 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -22,6 +22,11 @@ Code v99.99.999 ## Unreleased +### Fixed + +- Preserve the original path, query parameters, and fragment when rewriting + localhost URLs through the port proxy (#7668). + ## [4.137.0](https://github.com/coder/code-server/releases/tag/v4.137.0) - 2026-09-11 Code v1.137.0 diff --git a/patches/proxy-uri.diff b/patches/proxy-uri.diff index 6bda9e885a4e..fffa19459f9b 100644 --- a/patches/proxy-uri.diff +++ b/patches/proxy-uri.diff @@ -13,12 +13,18 @@ This has e2e tests. For the `asExternalUri` changes, you'll need to test manually by: 1. running code-server with the test extension 2. Command Palette > code-server: asExternalUri test -3. input a url like http://localhost:3000 -4. it should show a notification and show output as /proxy/3000 +3. input a url like http://localhost:3000/my/path?token=abc#section +4. it should show a notification and show output as + /proxy/3000/my/path?token%3Dabc#section Do the same thing but set `VSCODE_PROXY_URI: "https://{{port}}-main-workspace-name-user-name.coder.com"` and the output should replace `{{port}}` with port used in input url. +The rewritten URI must preserve the original path, query, and fragment (see +#7668). Append the original path to the proxy path using URI components so +encoded characters are not decoded or encoded twice. This also works with +proxy templates that do not have a trailing slash. + This also enables the forwared ports view panel by default. Lastly, it adds a tunnelProvider so that ports are forwarded using code-server's @@ -104,7 +110,7 @@ Index: code-server/lib/vscode/src/vs/code/browser/workbench/workbench.ts import type { IURLCallbackProvider } from '../../../workbench/services/url/browser/urlService.js'; import { create } from '../../../workbench/workbench.web.main.internal.js'; -@@ -612,6 +613,39 @@ class WorkspaceProvider implements IWork +@@ -612,6 +613,44 @@ class WorkspaceProvider implements IWork settingsSyncOptions: config.settingsSyncOptions ? { enabled: config.settingsSyncOptions.enabled, } : undefined, workspaceProvider: WorkspaceProvider.create(config), urlCallbackProvider: new LocalStorageURLCallbackProvider(config.callbackRoute), @@ -116,7 +122,12 @@ Index: code-server/lib/vscode/src/vs/code/browser/workbench/workbench.ts + const renderedTemplate = config.productConfiguration.proxyEndpointTemplate + .replace('{{port}}', localhostMatch.port.toString()) + .replace('{{host}}', window.location.host) -+ resolvedUri = URI.parse(new URL(renderedTemplate, window.location.href).toString()) ++ const proxyUri = URI.parse(new URL(renderedTemplate, window.location.href).toString()) ++ resolvedUri = proxyUri.with({ ++ path: proxyUri.path.replace(/\/$/, '') + uri.path, ++ query: uri.query, ++ fragment: uri.fragment, ++ }) + } else { + throw new Error(`Failed to resolve external URI: ${uri.toString()}. Could not determine base url because productConfiguration missing.`) + } diff --git a/test/e2e/extensions.test.ts b/test/e2e/extensions.test.ts index 9861e0004e7b..70869c03c062 100644 --- a/test/e2e/extensions.test.ts +++ b/test/e2e/extensions.test.ts @@ -15,6 +15,56 @@ function runTestExtensionTests() { const normalizedAddress = address.replace(/\/+$/, "") await expect(codeServerPage.page.getByText(`Info: proxyUri: ${normalizedAddress}/proxy/{{port}}/`)).toBeVisible() }) + + runExternalUriTests() +} + +function runExternalUriTests(proxyEndpointTemplate?: string) { + const cases = [ + { + name: "path", + input: "http://127.0.0.1:1234/my/path", + suffix: "/my/path", + }, + { + name: "query and fragment", + input: "http://localhost:1234/my/path?token=abc&mode=preview#section", + suffix: "/my/path?token%3Dabc%26mode%3Dpreview#section", + }, + { + name: "encoded components", + input: "http://0.0.0.0:1234/my%20path/%23file?token=a%20b#my%20section", + suffix: "/my%20path/%23file?token%3Da%20b#my%20section", + }, + { + name: "root path", + input: "http://127.0.0.1:1234/", + suffix: "/", + }, + ] + + for (const { name, input, suffix } of cases) { + test(`asExternalUri should preserve ${name}`, async ({ codeServerPage }) => { + const address = await getMaybeProxiedCodeServer(codeServerPage) + const normalizedAddress = address.replace(/\/+$/, "") + const proxyBase = proxyEndpointTemplate + ? new URL(proxyEndpointTemplate.replace("{{port}}", "1234"), `${normalizedAddress}/`).toString() + : `${normalizedAddress}/proxy/1234/` + + await codeServerPage.waitForTestExtensionLoaded() + await codeServerPage.executeCommandViaMenus("code-server: asExternalUri test") + + const inputBox = codeServerPage.page.locator(".quick-input-widget input") + await inputBox.fill(input) + await inputBox.press("Enter") + + // The test extension displays URI.toString(), which also encodes query delimiters. + const output = `${proxyBase.replace(/\/$/, "")}${suffix}` + await expect( + codeServerPage.page.getByText(`Info: input: ${input} output: ${output}`, { exact: true }), + ).toBeVisible() + }) + } } const flags = ["--disable-workspace-trust", "--extensions-dir", path.join(__dirname, "./extensions")] @@ -23,6 +73,17 @@ describe("Extensions", flags, {}, () => { runTestExtensionTests() }) +for (const proxyEndpointTemplate of ["./proxy/{{port}}", "https://{{port}}-workspace.example.com"]) { + describe( + `Extensions with VSCODE_PROXY_URI=${proxyEndpointTemplate}`, + flags, + { VSCODE_PROXY_URI: proxyEndpointTemplate }, + () => { + runExternalUriTests(proxyEndpointTemplate) + }, + ) +} + if (process.env.USE_PROXY !== "1") { describe("Extensions with --cert", [...flags, "--cert"], {}, () => { runTestExtensionTests() From 59c988c744a240b05b039f57b856a5312f19d5b1 Mon Sep 17 00:00:00 2001 From: cdrci <78873720+cdrci@users.noreply.github.com> Date: Sat, 19 Sep 2026 09:44:50 +1000 Subject: [PATCH 2/4] Update Code to 1.138.0 (#8011) --- CHANGELOG.md | 9 ++++++++- lib/vscode | 2 +- patches/external-file-actions.diff | 4 ++-- patches/getting-started.diff | 4 ++-- patches/proposed-api.diff | 2 +- 5 files changed, 14 insertions(+), 7 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index aefb9e997b66..9798e0589002 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -22,10 +22,17 @@ Code v99.99.999 ## Unreleased +Code v1.138.0 + +### Changed + +- Update to Code 1.138.0 + ### Fixed - Preserve the original path, query parameters, and fragment when rewriting - localhost URLs through the port proxy (#7668). + localhost URLs through the port proxy. If `VSCODE_PROXY_URI` has any query + parameters or fragments, those will be lost. ## [4.137.0](https://github.com/coder/code-server/releases/tag/v4.137.0) - 2026-09-11 diff --git a/lib/vscode b/lib/vscode index 645f29cc3176..7debcd0e2acd 160000 --- a/lib/vscode +++ b/lib/vscode @@ -1 +1 @@ -Subproject commit 645f29cc3176500b4b5762ba887cf2a7f0ffdf2c +Subproject commit 7debcd0e2acdea1c52de81bf9ee1620444407dda diff --git a/patches/external-file-actions.diff b/patches/external-file-actions.diff index f7261328f880..494df5ddc481 100644 --- a/patches/external-file-actions.diff +++ b/patches/external-file-actions.diff @@ -128,7 +128,7 @@ Index: code-server/lib/vscode/src/vs/workbench/browser/contextkeys.ts @@ -6,10 +6,10 @@ import { Disposable } from '../../base/common/lifecycle.js'; import { IContextKeyService, IContextKey, setConstant as setConstantContextKey } from '../../platform/contextkey/common/contextkey.js'; - import { IsMacContext, IsLinuxContext, IsWindowsContext, IsWebContext, IsMacNativeContext, IsDevelopmentContext, IsIOSContext, ProductQualityContext, IsMobileContext } from '../../platform/contextkey/common/contextkeys.js'; + import { IsMacContext, IsLinuxContext, IsWindowsContext, IsWebContext, IsMacNativeContext, IsDevelopmentContext, IsIOSContext, ProductQualityContext, IsMobileContext, IsChromeOSContext } from '../../platform/contextkey/common/contextkeys.js'; -import { SplitEditorsVertically, InEditorZenModeContext, AuxiliaryBarVisibleContext, SecondarySideBarVisibleContext, SideBarVisibleContext, PanelAlignmentContext, PanelMaximizedContext, PanelVisibleContext, EmbedderIdentifierContext, EditorTabsVisibleContext, IsMainEditorCenteredLayoutContext, MainEditorAreaVisibleContext, DirtyWorkingCopiesContext, EmptyWorkspaceSupportContext, EnterMultiRootWorkspaceSupportContext, HasWebFileSystemAccess, IsMainWindowFullscreenContext, OpenFolderWorkspaceSupportContext, RemoteNameContext, VirtualWorkspaceContext, WorkbenchStateContext, WorkspaceFolderCountContext, PanelPositionContext, TemporaryWorkspaceContext, TitleBarVisibleContext, TitleBarStyleContext, IsAuxiliaryWindowFocusedContext, ActiveEditorGroupEmptyContext, ActiveEditorGroupIndexContext, ActiveEditorGroupLastContext, ActiveEditorGroupLockedContext, MultipleEditorGroupsContext, EditorsVisibleContext, AuxiliaryBarMaximizedContext, InAutomationContext, IsSessionsWindowContext } from '../common/contextkeys.js'; +import { IsEnabledFileDownloads, IsEnabledFileUploads, SplitEditorsVertically, InEditorZenModeContext, AuxiliaryBarVisibleContext, SecondarySideBarVisibleContext, SideBarVisibleContext, PanelAlignmentContext, PanelMaximizedContext, PanelVisibleContext, EmbedderIdentifierContext, EditorTabsVisibleContext, IsMainEditorCenteredLayoutContext, MainEditorAreaVisibleContext, DirtyWorkingCopiesContext, EmptyWorkspaceSupportContext, EnterMultiRootWorkspaceSupportContext, HasWebFileSystemAccess, IsMainWindowFullscreenContext, OpenFolderWorkspaceSupportContext, RemoteNameContext, VirtualWorkspaceContext, WorkbenchStateContext, WorkspaceFolderCountContext, PanelPositionContext, TemporaryWorkspaceContext, TitleBarVisibleContext, TitleBarStyleContext, IsAuxiliaryWindowFocusedContext, ActiveEditorGroupEmptyContext, ActiveEditorGroupIndexContext, ActiveEditorGroupLastContext, ActiveEditorGroupLockedContext, MultipleEditorGroupsContext, EditorsVisibleContext, AuxiliaryBarMaximizedContext, InAutomationContext, IsSessionsWindowContext } from '../common/contextkeys.js'; import { preferredSideBySideGroupDirection, GroupDirection, IEditorGroupsService } from '../services/editor/common/editorGroupsService.js'; @@ -147,7 +147,7 @@ Index: code-server/lib/vscode/src/vs/workbench/browser/contextkeys.ts @IProductService private readonly productService: IProductService, @IEditorGroupsService private readonly editorGroupService: IEditorGroupsService, @IEditorService private readonly editorService: IEditorService, -@@ -205,6 +205,10 @@ export class WorkbenchContextKeysHandler +@@ -206,6 +206,10 @@ export class WorkbenchContextKeysHandler this.auxiliaryBarMaximizedContext = AuxiliaryBarMaximizedContext.bindTo(this.contextKeyService); this.auxiliaryBarMaximizedContext.set(this.layoutService.isAuxiliaryBarMaximized()); diff --git a/patches/getting-started.diff b/patches/getting-started.diff index 7bfbf3d6d542..a8ba914dfb37 100644 --- a/patches/getting-started.diff +++ b/patches/getting-started.diff @@ -216,13 +216,13 @@ Index: code-server/lib/vscode/src/vs/workbench/browser/contextkeys.ts @@ -6,7 +6,7 @@ import { Disposable } from '../../base/common/lifecycle.js'; import { IContextKeyService, IContextKey, setConstant as setConstantContextKey } from '../../platform/contextkey/common/contextkey.js'; - import { IsMacContext, IsLinuxContext, IsWindowsContext, IsWebContext, IsMacNativeContext, IsDevelopmentContext, IsIOSContext, ProductQualityContext, IsMobileContext } from '../../platform/contextkey/common/contextkeys.js'; + import { IsMacContext, IsLinuxContext, IsWindowsContext, IsWebContext, IsMacNativeContext, IsDevelopmentContext, IsIOSContext, ProductQualityContext, IsMobileContext, IsChromeOSContext } from '../../platform/contextkey/common/contextkeys.js'; -import { IsEnabledFileDownloads, IsEnabledFileUploads, SplitEditorsVertically, InEditorZenModeContext, AuxiliaryBarVisibleContext, SecondarySideBarVisibleContext, SideBarVisibleContext, PanelAlignmentContext, PanelMaximizedContext, PanelVisibleContext, EmbedderIdentifierContext, EditorTabsVisibleContext, IsMainEditorCenteredLayoutContext, MainEditorAreaVisibleContext, DirtyWorkingCopiesContext, EmptyWorkspaceSupportContext, EnterMultiRootWorkspaceSupportContext, HasWebFileSystemAccess, IsMainWindowFullscreenContext, OpenFolderWorkspaceSupportContext, RemoteNameContext, VirtualWorkspaceContext, WorkbenchStateContext, WorkspaceFolderCountContext, PanelPositionContext, TemporaryWorkspaceContext, TitleBarVisibleContext, TitleBarStyleContext, IsAuxiliaryWindowFocusedContext, ActiveEditorGroupEmptyContext, ActiveEditorGroupIndexContext, ActiveEditorGroupLastContext, ActiveEditorGroupLockedContext, MultipleEditorGroupsContext, EditorsVisibleContext, AuxiliaryBarMaximizedContext, InAutomationContext, IsSessionsWindowContext } from '../common/contextkeys.js'; +import { IsEnabledFileDownloads, IsEnabledFileUploads, IsEnabledCoderGettingStarted, SplitEditorsVertically, InEditorZenModeContext, AuxiliaryBarVisibleContext, SecondarySideBarVisibleContext, SideBarVisibleContext, PanelAlignmentContext, PanelMaximizedContext, PanelVisibleContext, EmbedderIdentifierContext, EditorTabsVisibleContext, IsMainEditorCenteredLayoutContext, MainEditorAreaVisibleContext, DirtyWorkingCopiesContext, EmptyWorkspaceSupportContext, EnterMultiRootWorkspaceSupportContext, HasWebFileSystemAccess, IsMainWindowFullscreenContext, OpenFolderWorkspaceSupportContext, RemoteNameContext, VirtualWorkspaceContext, WorkbenchStateContext, WorkspaceFolderCountContext, PanelPositionContext, TemporaryWorkspaceContext, TitleBarVisibleContext, TitleBarStyleContext, IsAuxiliaryWindowFocusedContext, ActiveEditorGroupEmptyContext, ActiveEditorGroupIndexContext, ActiveEditorGroupLastContext, ActiveEditorGroupLockedContext, MultipleEditorGroupsContext, EditorsVisibleContext, AuxiliaryBarMaximizedContext, InAutomationContext, IsSessionsWindowContext } from '../common/contextkeys.js'; import { preferredSideBySideGroupDirection, GroupDirection, IEditorGroupsService } from '../services/editor/common/editorGroupsService.js'; import { IConfigurationService } from '../../platform/configuration/common/configuration.js'; import { IBrowserWorkbenchEnvironmentService } from '../services/environment/browser/environmentService.js'; -@@ -208,6 +208,7 @@ export class WorkbenchContextKeysHandler +@@ -209,6 +209,7 @@ export class WorkbenchContextKeysHandler // code-server IsEnabledFileDownloads.bindTo(this.contextKeyService).set(this.environmentService.isEnabledFileDownloads ?? true) IsEnabledFileUploads.bindTo(this.contextKeyService).set(this.environmentService.isEnabledFileUploads ?? true) diff --git a/patches/proposed-api.diff b/patches/proposed-api.diff index 3b2286b043e7..edb6f54a7743 100644 --- a/patches/proposed-api.diff +++ b/patches/proposed-api.diff @@ -10,7 +10,7 @@ Index: code-server/lib/vscode/src/vs/workbench/services/extensions/common/extens =================================================================== --- code-server.orig/lib/vscode/src/vs/workbench/services/extensions/common/extensions.ts +++ code-server/lib/vscode/src/vs/workbench/services/extensions/common/extensions.ts -@@ -324,6 +324,10 @@ function extensionDescriptionArrayToMap( +@@ -321,6 +321,10 @@ function extensionDescriptionArrayToMap( } export function isProposedApiEnabled(extension: IExtensionDescription, proposal: ApiProposalName): boolean { From cd0f21dc725ac6d4f1f874288ef7927f902177ea Mon Sep 17 00:00:00 2001 From: cdrci <78873720+cdrci@users.noreply.github.com> Date: Sun, 20 Sep 2026 20:51:41 +1000 Subject: [PATCH 3/4] Update Helm chart and changelog with 4.138.0 (#8014) --- CHANGELOG.md | 2 ++ ci/helm-chart/Chart.yaml | 4 ++-- ci/helm-chart/values.yaml | 2 +- 3 files changed, 5 insertions(+), 3 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 9798e0589002..64499d67d767 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -22,6 +22,8 @@ Code v99.99.999 ## Unreleased +## [4.138.0](https://github.com/coder/code-server/releases/tag/v4.138.0) - 2026-09-19 + Code v1.138.0 ### Changed diff --git a/ci/helm-chart/Chart.yaml b/ci/helm-chart/Chart.yaml index 3609d3167998..65a74020c3e9 100644 --- a/ci/helm-chart/Chart.yaml +++ b/ci/helm-chart/Chart.yaml @@ -15,9 +15,9 @@ type: application # This is the chart version. This version number should be incremented each time you make changes # to the chart and its templates, including the app version. # Versions are expected to follow Semantic Versioning (https://semver.org/) -version: 3.52.0 +version: 3.53.0 # This is the version number of the application being deployed. This version number should be # incremented each time you make changes to the application. Versions are not expected to # follow Semantic Versioning. They should reflect the version the application is using. -appVersion: 4.137.0 +appVersion: 4.138.0 diff --git a/ci/helm-chart/values.yaml b/ci/helm-chart/values.yaml index 2f9de03cfa42..1dc291014901 100644 --- a/ci/helm-chart/values.yaml +++ b/ci/helm-chart/values.yaml @@ -6,7 +6,7 @@ replicaCount: 1 image: repository: codercom/code-server - tag: '4.137.0' + tag: '4.138.0' pullPolicy: Always # Specifies one or more secrets to be used when pulling images from a From 8a7bf87a4d66914e328d97815e2faa9f25097866 Mon Sep 17 00:00:00 2001 From: Leo Camus Date: Sun, 20 Sep 2026 12:53:04 +0200 Subject: [PATCH 4/4] Fix --idle-timeout-seconds validation being skipped (#8009) The lower bound check ran before the parser had resolved the value, so it only saw a value with the --idle-timeout-seconds= form. With the space-separated form the value was still undefined at that point, Number(undefined) is NaN, and NaN <= 60 is false, so anything got through. Move the check below the block that pulls the value from the next argument so both forms are validated the same way. --- CHANGELOG.md | 5 +++++ src/node/cli.ts | 8 ++++---- test/unit/node/cli.test.ts | 10 ++++++++++ 3 files changed, 19 insertions(+), 4 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 64499d67d767..0f159ce410f1 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -22,6 +22,11 @@ Code v99.99.999 ## Unreleased +### Fixed + +- `--idle-timeout-seconds` was only validated when passed as `--idle-timeout-seconds=`; + values of 60 or less passed as `--idle-timeout-seconds ` were silently accepted. + ## [4.138.0](https://github.com/coder/code-server/releases/tag/v4.138.0) - 2026-09-19 Code v1.138.0 diff --git a/src/node/cli.ts b/src/node/cli.ts index 623268f47e32..df915c0c532b 100644 --- a/src/node/cli.ts +++ b/src/node/cli.ts @@ -429,10 +429,6 @@ export const parse = ( throw new Error("--github-auth can only be set in the config file or passed in via $GITHUB_TOKEN") } - if (key === "idle-timeout-seconds" && Number(value) <= 60) { - throw new Error("--idle-timeout-seconds must be greater than 60 seconds.") - } - const option = options[key] if (option.type === "boolean") { ;(args[key] as boolean) = true @@ -452,6 +448,10 @@ export const parse = ( throw error(`--${key} requires a value`) } + if (key === "idle-timeout-seconds" && Number(value) <= 60) { + throw new Error("--idle-timeout-seconds must be greater than 60 seconds.") + } + if (option.type === OptionalString && value === "false") { continue } diff --git a/test/unit/node/cli.test.ts b/test/unit/node/cli.test.ts index 17f57a9666ef..e15a6afbfd04 100644 --- a/test/unit/node/cli.test.ts +++ b/test/unit/node/cli.test.ts @@ -268,6 +268,16 @@ describe("parser", () => { expect(() => parse(["--log", "invalid"])).toThrowError(/--log valid values: \[trace, debug, info, warn, error\]/) }) + it("should error if idle-timeout-seconds is too low", () => { + expect(() => parse(["--idle-timeout-seconds=60"])).toThrowError( + /--idle-timeout-seconds must be greater than 60 seconds/, + ) + expect(() => parse(["--idle-timeout-seconds", "60"])).toThrowError( + /--idle-timeout-seconds must be greater than 60 seconds/, + ) + expect(parse(["--idle-timeout-seconds", "61"])).toEqual({ "idle-timeout-seconds": 61 }) + }) + it("should error if the option doesn't exist", () => { expect(() => parse(["--foo"])).toThrowError(/Unknown option --foo/) })