Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 2 additions & 1 deletion .gitignore
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
out
node_modules/
dist/
*.vsix
out/
out/result
37 changes: 36 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -25,7 +25,7 @@ GitHub 仓库:[https://github.com/Zwhy2025/open-dev-container ](https://githu
- 主机上可用的 Docker CLI。
- 主机上可用的 `ssh` 和 `ssh-keygen`。
- 容器允许执行 `docker exec -u 0`。
- 如果容器里没有 `sshd`,需要 `apt-get`、`apk`、`dnf`、`yum` 或 `microdnf` 之一。
- 如果容器里没有 `sshd`,需要 `apt-get`、`apk`、`dnf`、`yum`、`microdnf` 或 `pacman` 之一。

## 使用方法

Expand Down Expand Up @@ -55,3 +55,38 @@ Open Dev Container: Attach to Running Container
- `openDevContainer.sshConfigPath`:SSH 配置路径;留空表示 `~/.ssh/config`。

最近连接会保存在扩展全局存储中,编辑器重启后仍然可用。

## Nix / NixOS

This repo ships a flake that builds the extension as a nixpkgs-style VS Code
extension derivation (`share/vscode/extensions/Zwhy2025.open-dev-container`).

```nix
# flake.nix of your NixOS / home-manager config
inputs.open-dev-container.url = "github:int3hh/open-dev-container";

# home-manager (VS Code or VSCodium – set `package` accordingly)
programs.vscode = {
enable = true;
package = pkgs.vscodium; # omit for VS Code
profiles.default.extensions = [
inputs.open-dev-container.packages.${pkgs.system}.default
pkgs.vscode-extensions.jeanp413.open-remote-ssh # Remote SSH for VSCodium
];
};

# or plain nixpkgs
environment.systemPackages = [
(pkgs.vscode-with-extensions.override {
vscode = pkgs.vscodium; # omit for VS Code
vscodeExtensions = [ inputs.open-dev-container.packages.${pkgs.system}.default ];
})
];
```

VSCodium note: Microsoft's `ms-vscode-remote.remote-ssh` is not available for
VSCodium; use `jeanp413.open-remote-ssh` instead, which provides the same
`ssh-remote` authority this extension opens.

An overlay is also exported (`inputs.open-dev-container.overlays.default`)
which adds `pkgs.open-dev-container`. Build locally with `nix build`.
61 changes: 61 additions & 0 deletions flake.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

32 changes: 32 additions & 0 deletions flake.nix
Original file line number Diff line number Diff line change
@@ -0,0 +1,32 @@
{
description = "Open Dev Container – VS Code extension: attach to running Docker/Podman containers via Remote SSH";

inputs = {
nixpkgs.url = "github:NixOS/nixpkgs/nixos-unstable";
flake-utils.url = "github:numtide/flake-utils";
};

outputs = { self, nixpkgs, flake-utils }:
let
overlay = final: prev: {
open-dev-container = final.callPackage ./nix/package.nix { };
};
in
{
overlays.default = overlay;
}
// flake-utils.lib.eachDefaultSystem (system:
let
pkgs = import nixpkgs { inherit system; overlays = [ overlay ]; };
in
{
packages = {
default = pkgs.open-dev-container;
open-dev-container = pkgs.open-dev-container;
};

devShells.default = pkgs.mkShell {
packages = [ pkgs.nodejs pkgs.typescript ];
};
});
}
66 changes: 66 additions & 0 deletions nix/package.nix
Original file line number Diff line number Diff line change
@@ -0,0 +1,66 @@
# VS Code extension derivation for Open Dev Container.
#
# Compiles src/ with tsc (via buildNpmPackage) and wraps the result with
# vscode-utils.buildVscodeExtension so it can be used in
# programs.vscode.profiles.default.extensions (home-manager)
# vscode-with-extensions (nixpkgs)
{ lib
, buildNpmPackage
, importNpmLock
, vscode-utils
}:
let
manifest = lib.importJSON ../package.json;
inherit (manifest) name version publisher;

compiled = buildNpmPackage {
pname = "${name}-compiled";
inherit version;

src = lib.cleanSourceWith {
src = ../.;
filter = path: type:
let base = baseNameOf path; in
!(lib.elem base [ "node_modules" "out" "dist" ".git" "result" "nix" "flake.nix" "flake.lock" ]);
};

# No hash needed: dependencies are fetched straight from package-lock.json.
npmDeps = importNpmLock { npmRoot = ../.; };
npmConfigHook = importNpmLock.npmConfigHook;

# sharp (native, dev-only) is not needed to compile the extension.
npmFlags = [ "--ignore-scripts" ];
dontNpmRebuild = true;

npmBuildScript = "compile";

# Only ship what a .vsix would contain (see .vscodeignore).
installPhase = ''
runHook preInstall
# buildVscodeExtension expects the .vsix layout: everything under extension/
mkdir -p $out/extension
cp -r package.json out resources LICENSE README.md $out/extension/
runHook postInstall
'';
};
in
vscode-utils.buildVscodeExtension {
pname = name;
inherit version;
# Hand over the sub-directory, not the whole output: stdenv copies a
# directory src into the sandbox as ./extension (buildVscodeExtension's
# default sourceRoot) and chmods the copy. Pointing sourceRoot at the store
# path directly fails on a real NixOS store (read-only).
src = "${compiled}/extension";

vscodeExtPublisher = publisher;
vscodeExtName = name;
vscodeExtUniqueId = "${publisher}.${name}";

meta = with lib; {
description = manifest.description;
homepage = "https://github.com/int3hh/open-dev-container";
license = licenses.mit;
platforms = platforms.all;
};
}
8 changes: 4 additions & 4 deletions package.json
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
{
"name": "open-dev-container",
"displayName": "Open Dev Container",
"description": "Open running Docker containers as development workspaces via Remote SSH. Supports VS Code-compatible editors such as Trae CN.",
"displayName": "Open Dev Container (podman fork)",
"description": "Open running Docker containers as development workspaces via Remote SSH. Supports VSCodium and jeanp413 SSH extension",
"version": "0.0.3",
"publisher": "Zwhy2025",
"license": "MIT",
Expand Down Expand Up @@ -122,8 +122,8 @@
"properties": {
"openDevContainer.dockerPath": {
"type": "string",
"default": "docker",
"description": "Docker CLI executable used by this extension."
"default": "podman",
"description": "Container CLI executable used by this extension (podman or docker)."
},
"openDevContainer.remoteUser": {
"type": "string",
Expand Down
18 changes: 11 additions & 7 deletions src/commandRunner.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,10 @@ import { execFile } from 'node:child_process';
import { OpenDevContainerError, formatErrorDetail } from './errors';
import type { ExecResult } from './types';

function isContainerCli(file: string): boolean {
return /(^|[\\/])(docker|podman)(\.exe)?$/i.test(file);
}

export async function execFileAsync(
file: string,
args: string[],
Expand Down Expand Up @@ -29,10 +33,10 @@ export function normalizeCommandError(
const combined = [stderr.trim(), stdout.trim(), error.message].filter(Boolean).join('\n').trim();

if (error.code === 'ENOENT') {
if (file === 'docker') {
if (isContainerCli(file)) {
return new OpenDevContainerError(
'DOCKER_CLI_MISSING',
'Docker CLI was not found. Configure `openDevContainer.dockerPath` or install Docker.',
`${file} was not found. Configure \`openDevContainer.dockerPath\` or install Podman/Docker.`,
combined
);
}
Expand All @@ -46,20 +50,20 @@ export function normalizeCommandError(
}
}

if (file === 'docker') {
if (/cannot connect to the Docker daemon|permission denied while trying to connect to the Docker daemon|is the docker daemon running|error during connect/i.test(combined)) {
if (isContainerCli(file)) {
if (/cannot connect to the Docker daemon|permission denied while trying to connect to the Docker daemon|is the docker daemon running|error during connect|cannot connect to podman|unable to connect to podman socket|podman.sock/i.test(combined)) {
return new OpenDevContainerError(
'DOCKER_DAEMON_UNAVAILABLE',
'Docker daemon is not reachable or the current user cannot access it.',
'The container engine is not reachable or the current user cannot access it.',
combined,
typeof error.code === 'number' ? error.code : undefined
);
}

if (/permission denied|operation not permitted/i.test(combined) && /docker/i.test(combined)) {
if (/permission denied|operation not permitted/i.test(combined) && /docker|podman/i.test(combined)) {
return new OpenDevContainerError(
'DOCKER_PERMISSION_DENIED',
'Docker denied the requested operation. Check socket permissions, rootless mode, or container policy.',
'The container engine denied the requested operation. Check socket permissions, rootless mode, or container policy.',
combined,
typeof error.code === 'number' ? error.code : undefined
);
Expand Down
35 changes: 22 additions & 13 deletions src/containerProvisioner.ts
Original file line number Diff line number Diff line change
Expand Up @@ -90,6 +90,8 @@ if ! command -v sshd >/dev/null 2>&1 && [ ! -x /usr/sbin/sshd ]; then
yum install -y openssh-server
elif command -v microdnf >/dev/null 2>&1; then
microdnf install -y openssh-server
elif command -v pacman >/dev/null 2>&1; then
pacman -Sy --noconfirm openssh
else
echo "OPEN_DEV_CONTAINER_ERROR=NO_PACKAGE_MANAGER" >&2
echo "openssh-server is missing and no supported package manager was found." >&2
Expand Down Expand Up @@ -131,21 +133,28 @@ ssh-keygen -A >/dev/null 2>&1 || true

cat > ${shellQuote(CONTAINER_FORCE_COMMAND_SCRIPT)} <<'EOF'
#!/bin/sh
set -eu

# Run the requested command (or a login shell) and exit with its status.
# The channel must close when the command ends: Remote SSH clients such as
# open-remote-ssh wait for exec() to close before continuing, so keeping the
# session alive here would hang the connection forever.
# Commands run exactly like stock sshd does it: "$SHELL -c", *not* a login
# shell. Login startup files (/etc/profile, ~/.profile) often print banners
# or exec an interactive shell, which breaks Remote SSH's install script.
if [ -n "\${SSH_ORIGINAL_COMMAND:-}" ]; then
sh -lc "$SSH_ORIGINAL_COMMAND" || true
else
if command -v bash >/dev/null 2>&1; then
bash -l || true
else
/bin/sh || true
fi
CMD=$SSH_ORIGINAL_COMMAND
# open-remote-ssh installs the VSCodium server with "... | bash -l". A *login*
# shell sources the container's profile/rc files; in hand-built images those
# frequently read stdin or exit for non-interactive shells, which swallows the
# piped install script -> the client fails with "Failed parsing install script
# output". The install needs no login environment, so strip the login shell.
case "$CMD" in
*"| bash -l") CMD="\${CMD%| bash -l}| bash" ;;
*"| bash --login") CMD="\${CMD%| bash --login}| bash" ;;
esac
exec "\${SHELL:-/bin/sh}" -c "$CMD"
fi

while :; do
sleep 3600
done
# Interactive session: login shell.
exec "\${SHELL:-/bin/sh}" -l
EOF
chmod 755 ${shellQuote(CONTAINER_FORCE_COMMAND_SCRIPT)}

Expand Down
42 changes: 35 additions & 7 deletions src/dockerClient.ts
Original file line number Diff line number Diff line change
Expand Up @@ -21,13 +21,41 @@ export function parseDockerContainerList(stdout: string): DockerContainer[] {
.split(/\r?\n/)
.map((line) => line.trim())
.filter(Boolean)
.map((line) => {
try {
return JSON.parse(line) as DockerContainer;
} catch {
throw new OpenDevContainerError('DOCKER_OUTPUT_PARSE_FAILED', 'Docker returned container output that could not be parsed.', line);
}
});
.map((line) => normalizeContainerRecord(parseContainerLine(line), line));
}

function parseContainerLine(line: string): unknown {
try {
return JSON.parse(line);
} catch {
throw new OpenDevContainerError('DOCKER_OUTPUT_PARSE_FAILED', 'Docker returned container output that could not be parsed.', line);
}
}

// Podman's `{{json .}}` marshals the raw ListContainer struct instead of the accessor methods
// Docker exposes: the id is tagged `Id`, `Names` is an array, and `Status` is left empty.
export function normalizeContainerRecord(raw: unknown, line?: string): DockerContainer {
const record = (typeof raw === 'object' && raw !== null ? raw : {}) as Record<string, unknown>;
const names = record.Names;
const state = typeof record.State === 'string' ? record.State : '';
const status = typeof record.Status === 'string' ? record.Status : '';
const id = typeof record.ID === 'string' ? record.ID : typeof record.Id === 'string' ? record.Id : '';

if (!id) {
throw new OpenDevContainerError(
'DOCKER_OUTPUT_PARSE_FAILED',
'Docker returned a container entry without an ID.',
line ?? JSON.stringify(raw)
);
}

return {
ID: id,
Image: typeof record.Image === 'string' ? record.Image : '',
Names: Array.isArray(names) ? names.join(',') : typeof names === 'string' ? names : '',
State: state,
Status: status || state
};
}

export function parseDockerInspect(stdout: string, containerId: string): DockerInspect {
Expand Down
Loading