From 6a8f73b707c0d89ee3e62f27da703df0703445c7 Mon Sep 17 00:00:00 2001 From: levivilet <72156503+levivilet@users.noreply.github.com> Date: Sun, 4 Oct 2026 16:57:27 +0000 Subject: [PATCH 1/5] fix: identify devcontainer workspaces by source --- README.md | 12 +++-- .../src/parts/ContainerUri/ContainerUri.ts | 52 +++++++++++++++---- .../CreateDevContainer/CreateDevContainer.ts | 3 +- .../DevContainerState/DevContainerState.ts | 17 ++++-- .../test/ContainerUri.test.ts | 25 +++++++++ .../test/DevContainer.test.ts | 5 +- .../test/DevContainerState.test.ts | 4 +- 7 files changed, 97 insertions(+), 21 deletions(-) create mode 100644 packages/devcontainer-worker/test/ContainerUri.test.ts diff --git a/README.md b/README.md index 905eba3..964c939 100644 --- a/README.md +++ b/README.md @@ -75,11 +75,13 @@ The runner allows three minutes per test and removes containers belonging to its Use **Dev Containers: Reopen in Container** or **Dev Containers: Start Current Workspace** to build/start the current workspace's configuration and open its configured `workspaceFolder` at -`devcontainers:///`. Explorer and file editing use Docker inside the -container as `remoteUser`; the local bind mount is not used as a substitute for -the container filesystem. A failed build or connection leaves the local workspace -open. The existing execution, stop, and remove commands also work from the -container workspace. +`devcontainers:///!`. The local path keeps recent entries +recognizable; container-relative file paths follow the `!` separator. Older +`devcontainers:///` workspace URLs remain supported. Explorer and +file editing use Docker inside the container as `remoteUser`; the local bind +mount is not used as a substitute for the container filesystem. A failed build +or connection leaves the local workspace open. The existing execution, stop, +and remove commands also work from the container workspace. New terminals run an interactive shell through `devcontainer exec`, using the configured remote user and environment. Opening a terminal from an Explorer diff --git a/packages/devcontainer-worker/src/parts/ContainerUri/ContainerUri.ts b/packages/devcontainer-worker/src/parts/ContainerUri/ContainerUri.ts index 78406e2..d53d159 100644 --- a/packages/devcontainer-worker/src/parts/ContainerUri/ContainerUri.ts +++ b/packages/devcontainer-worker/src/parts/ContainerUri/ContainerUri.ts @@ -1,12 +1,4 @@ -export const parse = (uri: string): { id: string; path: string } => { - const url = new URL(uri) - if (url.protocol !== 'devcontainers:' || url.host || url.search || url.hash) { - throw new Error('Invalid devcontainer URI') - } - const [, id, ...segments] = url.pathname.split('/') - if (!id || !/^[a-zA-Z0-9-]+$/.test(id)) { - throw new Error('Invalid devcontainer id') - } +const decodeSegments = (segments: string[]): string[] => { const decoded = segments.map(decodeURIComponent) if ( decoded.some( @@ -19,5 +11,47 @@ export const parse = (uri: string): { id: string; path: string } => { ) { throw new Error('Invalid devcontainer path') } + return decoded +} + +const encodePath = (path: string): string => + path + .split('/') + .filter(Boolean) + .map((segment) => encodeURIComponent(segment).replaceAll('!', '%21')) + .join('/') + +export const create = (workspaceFolder: string): string => { + if (!workspaceFolder.startsWith('/')) { + throw new Error('Invalid devcontainer workspace folder') + } + return `devcontainers:///${encodePath(workspaceFolder)}!` +} + +export const parse = ( + uri: string, +): { id: string; path: string; workspaceFolder?: string } => { + const url = new URL(uri) + if (url.protocol !== 'devcontainers:' || url.host || url.search || url.hash) { + throw new Error('Invalid devcontainer URI') + } + const separator = url.pathname.indexOf('!') + if (separator !== -1) { + const source = decodeSegments( + url.pathname.slice(0, separator).split('/').filter(Boolean), + ) + if (!source.length) { + throw new Error('Invalid devcontainer workspace folder') + } + const workspaceFolder = `/${source.join('/')}` + const rawPath = url.pathname.slice(separator + 1).replace(/^\//, '') + const path = decodeSegments(rawPath ? rawPath.split('/') : []).join('/') + return { id: `workspace:${workspaceFolder}`, path, workspaceFolder } + } + const [, id, ...segments] = url.pathname.split('/') + if (!id || !/^[a-zA-Z0-9-]+$/.test(id)) { + throw new Error('Invalid devcontainer id') + } + const decoded = decodeSegments(segments) return { id, path: decoded.join('/') } } diff --git a/packages/devcontainer-worker/src/parts/CreateDevContainer/CreateDevContainer.ts b/packages/devcontainer-worker/src/parts/CreateDevContainer/CreateDevContainer.ts index 6a16fff..468d374 100644 --- a/packages/devcontainer-worker/src/parts/CreateDevContainer/CreateDevContainer.ts +++ b/packages/devcontainer-worker/src/parts/CreateDevContainer/CreateDevContainer.ts @@ -2,6 +2,7 @@ import type * as Config from '../DevContainerConfig/DevContainerConfig.ts' import type * as Client from '../DevContainerNodeClient/DevContainerNodeClient.ts' import type * as State from '../DevContainerState/DevContainerState.ts' import type * as Workspace from '../WorkspaceFolder/WorkspaceFolder.ts' +import * as ContainerUri from '../ContainerUri/ContainerUri.ts' export interface Host { cancelStart?: (workspaceFolder: string) => Promise @@ -286,7 +287,7 @@ export const createDevContainer = ({ remoteWorkspaceFolder: state.remoteWorkspaceFolder, }) await DevContainerState.persist(workspaceFolder) - return { ok: true, workspaceUri: `devcontainers:///${state.containerId}` } + return { ok: true, workspaceUri: ContainerUri.create(workspaceFolder) } } const openWorkspace = async ({ diff --git a/packages/devcontainer-worker/src/parts/DevContainerState/DevContainerState.ts b/packages/devcontainer-worker/src/parts/DevContainerState/DevContainerState.ts index 8a3def8..a389845 100644 --- a/packages/devcontainer-worker/src/parts/DevContainerState/DevContainerState.ts +++ b/packages/devcontainer-worker/src/parts/DevContainerState/DevContainerState.ts @@ -35,14 +35,23 @@ export const reset = () => { } export const getWorkspaceFolder = async (uri: string): Promise => { - const { id } = ContainerUri.parse(uri) + const { id, workspaceFolder: sourceWorkspaceFolder } = ContainerUri.parse(uri) for (const [workspaceFolder, value] of state) { - if (value.containerId === id) { + if ( + (sourceWorkspaceFolder && + workspaceFolder === sourceWorkspaceFolder && + id === `workspace:${workspaceFolder}`) || + (!sourceWorkspaceFolder && value.containerId === id) + ) { return workspaceFolder } } - const restored = await restore(id) - if (restored && state.get(restored)?.containerId === id) { + const key = sourceWorkspaceFolder || id + const restored = await restore(key) + if ( + restored && + (state.get(restored)?.containerId === id || id === `workspace:${restored}`) + ) { return restored } throw new Error( diff --git a/packages/devcontainer-worker/test/ContainerUri.test.ts b/packages/devcontainer-worker/test/ContainerUri.test.ts new file mode 100644 index 0000000..5e8cbd8 --- /dev/null +++ b/packages/devcontainer-worker/test/ContainerUri.test.ts @@ -0,0 +1,25 @@ +import { expect, test } from '@jest/globals' +import * as ContainerUri from '../src/parts/ContainerUri/ContainerUri.ts' + +test('creates readable workspace URIs and preserves paths inside the container', () => { + const uri = ContainerUri.create('/home/simon/Projects/my project!') + + expect(uri).toBe('devcontainers:///home/simon/Projects/my%20project%21!') + expect(ContainerUri.parse(uri)).toEqual({ + id: 'workspace:/home/simon/Projects/my project!', + path: '', + workspaceFolder: '/home/simon/Projects/my project!', + }) + expect(ContainerUri.parse(`${uri}/src/a%20b.ts`)).toEqual({ + id: 'workspace:/home/simon/Projects/my project!', + path: 'src/a b.ts', + workspaceFolder: '/home/simon/Projects/my project!', + }) +}) + +test('continues to parse saved container-id URIs', () => { + expect(ContainerUri.parse('devcontainers:///abc123/src/a%20b.ts')).toEqual({ + id: 'abc123', + path: 'src/a b.ts', + }) +}) diff --git a/packages/devcontainer-worker/test/DevContainer.test.ts b/packages/devcontainer-worker/test/DevContainer.test.ts index 40f4e59..cf176ad 100644 --- a/packages/devcontainer-worker/test/DevContainer.test.ts +++ b/packages/devcontainer-worker/test/DevContainer.test.ts @@ -248,7 +248,10 @@ test('openWorkspace builds, checks the remote folder, and resolves lifecycle com DevContainer.openWorkspace({ workspaceFolder }), DevContainer.openWorkspace({ workspaceFolder }), ]) - expect(first).toEqual({ ok: true, workspaceUri: 'devcontainers:///abc123' }) + expect(first).toEqual({ + ok: true, + workspaceUri: `devcontainers:///${workspaceFolder.slice(1).replaceAll(' ', '%20')}!`, + }) expect(second).toEqual(first) expect(operations).toEqual(['build', 'connect']) expect( diff --git a/packages/devcontainer-worker/test/DevContainerState.test.ts b/packages/devcontainer-worker/test/DevContainerState.test.ts index 4c96723..5626340 100644 --- a/packages/devcontainer-worker/test/DevContainerState.test.ts +++ b/packages/devcontainer-worker/test/DevContainerState.test.ts @@ -54,7 +54,9 @@ test.each([undefined, 'podman'])( DevContainerState.reset() expect( - await WorkspaceFolder.toPath('devcontainers:///abc123/file.txt'), + await WorkspaceFolder.toPath( + `devcontainers://${workspaceFolder.replaceAll(' ', '%20')}!/file.txt`, + ), ).toBe(workspaceFolder) expect(DevContainerState.get(workspaceFolder)).toEqual(state) expect(inspectedEngines).toEqual([containerCli]) From f182a6204f50685ac4f5d0ce6dfd2025835df490 Mon Sep 17 00:00:00 2001 From: levivilet <72156503+levivilet@users.noreply.github.com> Date: Sun, 4 Oct 2026 17:01:05 +0000 Subject: [PATCH 2/5] fix: satisfy explicit length check --- .../devcontainer-worker/src/parts/ContainerUri/ContainerUri.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/devcontainer-worker/src/parts/ContainerUri/ContainerUri.ts b/packages/devcontainer-worker/src/parts/ContainerUri/ContainerUri.ts index d53d159..4e73dd3 100644 --- a/packages/devcontainer-worker/src/parts/ContainerUri/ContainerUri.ts +++ b/packages/devcontainer-worker/src/parts/ContainerUri/ContainerUri.ts @@ -40,7 +40,7 @@ export const parse = ( const source = decodeSegments( url.pathname.slice(0, separator).split('/').filter(Boolean), ) - if (!source.length) { + if (source.length === 0) { throw new Error('Invalid devcontainer workspace folder') } const workspaceFolder = `/${source.join('/')}` From eaa7a57f31c8014a9e0389241dd570eab0474b33 Mon Sep 17 00:00:00 2001 From: levivilet <72156503+levivilet@users.noreply.github.com> Date: Sun, 4 Oct 2026 17:10:32 +0000 Subject: [PATCH 3/5] fix: support Windows workspace URLs --- .../src/parts/ContainerUri/ContainerUri.ts | 25 +++++++++---------- .../test/ContainerUri.test.ts | 16 +++++++----- .../test/DevContainer.test.ts | 3 ++- .../test/DevContainerState.test.ts | 3 ++- 4 files changed, 26 insertions(+), 21 deletions(-) diff --git a/packages/devcontainer-worker/src/parts/ContainerUri/ContainerUri.ts b/packages/devcontainer-worker/src/parts/ContainerUri/ContainerUri.ts index 4e73dd3..1810431 100644 --- a/packages/devcontainer-worker/src/parts/ContainerUri/ContainerUri.ts +++ b/packages/devcontainer-worker/src/parts/ContainerUri/ContainerUri.ts @@ -1,3 +1,6 @@ +import { isAbsolute } from 'node:path' +import { fileURLToPath, pathToFileURL } from 'node:url' + const decodeSegments = (segments: string[]): string[] => { const decoded = segments.map(decodeURIComponent) if ( @@ -14,18 +17,15 @@ const decodeSegments = (segments: string[]): string[] => { return decoded } -const encodePath = (path: string): string => - path - .split('/') - .filter(Boolean) - .map((segment) => encodeURIComponent(segment).replaceAll('!', '%21')) - .join('/') - export const create = (workspaceFolder: string): string => { - if (!workspaceFolder.startsWith('/')) { + if (!isAbsolute(workspaceFolder)) { throw new Error('Invalid devcontainer workspace folder') } - return `devcontainers:///${encodePath(workspaceFolder)}!` + const sourcePath = pathToFileURL(workspaceFolder).pathname.replaceAll( + '!', + '%21', + ) + return `devcontainers://${sourcePath}!` } export const parse = ( @@ -37,13 +37,12 @@ export const parse = ( } const separator = url.pathname.indexOf('!') if (separator !== -1) { - const source = decodeSegments( - url.pathname.slice(0, separator).split('/').filter(Boolean), - ) + const sourcePath = url.pathname.slice(0, separator) + const source = decodeSegments(sourcePath.split('/').filter(Boolean)) if (source.length === 0) { throw new Error('Invalid devcontainer workspace folder') } - const workspaceFolder = `/${source.join('/')}` + const workspaceFolder = fileURLToPath(new URL(`file://${sourcePath}`)) const rawPath = url.pathname.slice(separator + 1).replace(/^\//, '') const path = decodeSegments(rawPath ? rawPath.split('/') : []).join('/') return { id: `workspace:${workspaceFolder}`, path, workspaceFolder } diff --git a/packages/devcontainer-worker/test/ContainerUri.test.ts b/packages/devcontainer-worker/test/ContainerUri.test.ts index 5e8cbd8..19c476e 100644 --- a/packages/devcontainer-worker/test/ContainerUri.test.ts +++ b/packages/devcontainer-worker/test/ContainerUri.test.ts @@ -1,19 +1,23 @@ import { expect, test } from '@jest/globals' +import { tmpdir } from 'node:os' +import { join } from 'node:path' import * as ContainerUri from '../src/parts/ContainerUri/ContainerUri.ts' test('creates readable workspace URIs and preserves paths inside the container', () => { - const uri = ContainerUri.create('/home/simon/Projects/my project!') + const workspaceFolder = join(tmpdir(), 'my project!') + const uri = ContainerUri.create(workspaceFolder) - expect(uri).toBe('devcontainers:///home/simon/Projects/my%20project%21!') + expect(uri).toContain('my%20project%21!') + expect(uri).toMatch(/^devcontainers:\/\//) expect(ContainerUri.parse(uri)).toEqual({ - id: 'workspace:/home/simon/Projects/my project!', + id: `workspace:${workspaceFolder}`, path: '', - workspaceFolder: '/home/simon/Projects/my project!', + workspaceFolder, }) expect(ContainerUri.parse(`${uri}/src/a%20b.ts`)).toEqual({ - id: 'workspace:/home/simon/Projects/my project!', + id: `workspace:${workspaceFolder}`, path: 'src/a b.ts', - workspaceFolder: '/home/simon/Projects/my project!', + workspaceFolder, }) }) diff --git a/packages/devcontainer-worker/test/DevContainer.test.ts b/packages/devcontainer-worker/test/DevContainer.test.ts index cf176ad..dcd7b7f 100644 --- a/packages/devcontainer-worker/test/DevContainer.test.ts +++ b/packages/devcontainer-worker/test/DevContainer.test.ts @@ -3,6 +3,7 @@ import { mkdir, mkdtemp, rm, writeFile } from 'node:fs/promises' import { tmpdir } from 'node:os' import { join } from 'node:path' import { pathToFileURL } from 'node:url' +import * as ContainerUri from '../src/parts/ContainerUri/ContainerUri.ts' import * as DevContainer from '../src/parts/DevContainer/DevContainer.js' import * as DevContainerNodeClient from '../src/parts/DevContainerNodeClient/DevContainerNodeClient.js' import * as DevContainerState from '../src/parts/DevContainerState/DevContainerState.js' @@ -250,7 +251,7 @@ test('openWorkspace builds, checks the remote folder, and resolves lifecycle com ]) expect(first).toEqual({ ok: true, - workspaceUri: `devcontainers:///${workspaceFolder.slice(1).replaceAll(' ', '%20')}!`, + workspaceUri: ContainerUri.create(workspaceFolder), }) expect(second).toEqual(first) expect(operations).toEqual(['build', 'connect']) diff --git a/packages/devcontainer-worker/test/DevContainerState.test.ts b/packages/devcontainer-worker/test/DevContainerState.test.ts index 5626340..cd802a5 100644 --- a/packages/devcontainer-worker/test/DevContainerState.test.ts +++ b/packages/devcontainer-worker/test/DevContainerState.test.ts @@ -2,6 +2,7 @@ import { afterEach, beforeEach, expect, test } from '@jest/globals' import { mkdtemp, rm } from 'node:fs/promises' import { tmpdir } from 'node:os' import { join } from 'node:path' +import * as ContainerUri from '../src/parts/ContainerUri/ContainerUri.ts' import * as DevContainerNodeClient from '../src/parts/DevContainerNodeClient/DevContainerNodeClient.ts' import * as DevContainerState from '../src/parts/DevContainerState/DevContainerState.ts' import * as WorkspaceFolder from '../src/parts/WorkspaceFolder/WorkspaceFolder.ts' @@ -55,7 +56,7 @@ test.each([undefined, 'podman'])( expect( await WorkspaceFolder.toPath( - `devcontainers://${workspaceFolder.replaceAll(' ', '%20')}!/file.txt`, + `${ContainerUri.create(workspaceFolder)}/file.txt`, ), ).toBe(workspaceFolder) expect(DevContainerState.get(workspaceFolder)).toEqual(state) From 2df5427d8a49cb4e09edf50db3717a77f97fa1f8 Mon Sep 17 00:00:00 2001 From: levivilet <72156503+levivilet@users.noreply.github.com> Date: Sun, 4 Oct 2026 17:15:24 +0000 Subject: [PATCH 4/5] fix: keep workspace URI shared with browser --- .../src/parts/ContainerUri/ContainerUri.ts | 26 ++++++++++++------- .../test/ContainerUri.test.ts | 12 +++++++++ 2 files changed, 29 insertions(+), 9 deletions(-) diff --git a/packages/devcontainer-worker/src/parts/ContainerUri/ContainerUri.ts b/packages/devcontainer-worker/src/parts/ContainerUri/ContainerUri.ts index 1810431..cd6e0ad 100644 --- a/packages/devcontainer-worker/src/parts/ContainerUri/ContainerUri.ts +++ b/packages/devcontainer-worker/src/parts/ContainerUri/ContainerUri.ts @@ -1,6 +1,3 @@ -import { isAbsolute } from 'node:path' -import { fileURLToPath, pathToFileURL } from 'node:url' - const decodeSegments = (segments: string[]): string[] => { const decoded = segments.map(decodeURIComponent) if ( @@ -17,14 +14,22 @@ const decodeSegments = (segments: string[]): string[] => { return decoded } +const encodeSegment = (segment: string): string => + encodeURIComponent(segment).replaceAll('!', '%21') + export const create = (workspaceFolder: string): string => { - if (!isAbsolute(workspaceFolder)) { + const windowsPath = /^([a-zA-Z]:)[\\/](.*)$/.exec(workspaceFolder) + if (!workspaceFolder.startsWith('/') && !windowsPath) { throw new Error('Invalid devcontainer workspace folder') } - const sourcePath = pathToFileURL(workspaceFolder).pathname.replaceAll( - '!', - '%21', - ) + const sourceSegments = windowsPath + ? [windowsPath[1], ...windowsPath[2].split(/[\\/]/).filter(Boolean)] + : workspaceFolder.split('/').filter(Boolean) + const sourcePath = `/${sourceSegments + .map((segment, index) => + windowsPath && index === 0 ? segment : encodeSegment(segment), + ) + .join('/')}` return `devcontainers://${sourcePath}!` } @@ -42,7 +47,10 @@ export const parse = ( if (source.length === 0) { throw new Error('Invalid devcontainer workspace folder') } - const workspaceFolder = fileURLToPath(new URL(`file://${sourcePath}`)) + const drive = /^([a-zA-Z]):$/.exec(source[0]) + const workspaceFolder = drive + ? `${drive[1]}:\\${source.slice(1).join('\\')}` + : `/${source.join('/')}` const rawPath = url.pathname.slice(separator + 1).replace(/^\//, '') const path = decodeSegments(rawPath ? rawPath.split('/') : []).join('/') return { id: `workspace:${workspaceFolder}`, path, workspaceFolder } diff --git a/packages/devcontainer-worker/test/ContainerUri.test.ts b/packages/devcontainer-worker/test/ContainerUri.test.ts index 19c476e..f11c041 100644 --- a/packages/devcontainer-worker/test/ContainerUri.test.ts +++ b/packages/devcontainer-worker/test/ContainerUri.test.ts @@ -21,6 +21,18 @@ test('creates readable workspace URIs and preserves paths inside the container', }) }) +test('round-trips Windows workspace paths', () => { + const workspaceFolder = 'C:\\Users\\simon\\my project!' + const uri = ContainerUri.create(workspaceFolder) + + expect(uri).toBe('devcontainers:///C:/Users/simon/my%20project%21!') + expect(ContainerUri.parse(uri)).toEqual({ + id: `workspace:${workspaceFolder}`, + path: '', + workspaceFolder, + }) +}) + test('continues to parse saved container-id URIs', () => { expect(ContainerUri.parse('devcontainers:///abc123/src/a%20b.ts')).toEqual({ id: 'abc123', From b4a0c58390ce33213345bd85310cdeb7f5d864f6 Mon Sep 17 00:00:00 2001 From: levivilet <72156503+levivilet@users.noreply.github.com> Date: Sun, 4 Oct 2026 17:51:47 +0000 Subject: [PATCH 5/5] fix: recognize readable container workspace URLs --- packages/e2e/helpers/waitForContainerWorkspace.ts | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/packages/e2e/helpers/waitForContainerWorkspace.ts b/packages/e2e/helpers/waitForContainerWorkspace.ts index 296f46f..5d3422e 100644 --- a/packages/e2e/helpers/waitForContainerWorkspace.ts +++ b/packages/e2e/helpers/waitForContainerWorkspace.ts @@ -9,7 +9,8 @@ export const waitForContainerWorkspace = async ({ workspaceUri = await Command.execute('Workspace.getUri') if ( typeof workspaceUri === 'string' && - /^devcontainers:\/\/\/[a-f0-9]+$/.test(workspaceUri) + (workspaceUri.endsWith('!') || + /^devcontainers:\/\/\/[a-f0-9]+$/.test(workspaceUri)) ) { const state = await Command.executeExtensionCommand( 'devcontainer.getState',