Skip to content
Draft
Show file tree
Hide file tree
Changes from 1 commit
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
12 changes: 12 additions & 0 deletions .devcontainer/Dockerfile
Original file line number Diff line number Diff line change
@@ -1,5 +1,17 @@
FROM mcr.microsoft.com/devcontainers/typescript-node:24-bookworm

# Install a Linux Secret Service backend so VS Code's SecretStorage /
# keytar / libsecret consumers (incl. GitHub Copilot) get a local
# encrypted credential vault instead of the plaintext fallback store.
RUN apt-get update \
&& DEBIAN_FRONTEND=noninteractive apt-get install -y --no-install-recommends \
gnome-keyring \
libsecret-1-0 \
libsecret-tools \
dbus \
python3-jeepney \
&& rm -rf /var/lib/apt/lists/*

# Wire up the tmux configuration for the default container user (node)
COPY tmux.conf /home/node/.tmux.conf
RUN chown node:node /home/node/.tmux.conf
Expand Down
Binary file not shown.
15 changes: 15 additions & 0 deletions .devcontainer/devcontainer.json
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,21 @@
},
"overrideFeatureInstallOrder": ["ghcr.io/devcontainers/features/rust"],

// Predictable bus + runtime dir so EVERY VS Code Server child process
// (extensions, terminals, tasks) finds the same gnome-keyring instance.
// Without it, extensions race the postStartCommand and fall back to the
// plaintext SecretStorage store. UID 1000 is the `node` user.
"remoteEnv": {
"XDG_RUNTIME_DIR": "/tmp/runtime-1000",
"DBUS_SESSION_BUS_ADDRESS": "unix:path=/tmp/runtime-1000/bus"
},

// postStartCommand fires on every container start (incl. Codespaces resume).
// postAttachCommand fires on every VS Code reconnect, so a crashed daemon
// is restarted without rebuilding the container.
"postStartCommand": ".devcontainer/keyring-init.sh",
"postAttachCommand": ".devcontainer/keyring-init.sh",

// Features to add to the dev container. More info: https://containers.dev/features.
// "features": {},

Expand Down
65 changes: 65 additions & 0 deletions .devcontainer/keyring-bootstrap.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,65 @@
#!/usr/bin/env python3
"""Headlessly create a persistent default Secret Service collection.

gnome-keyring's spec-compliant CreateCollection requires gcr-prompter
(GTK/Wayland), which devcontainers don't have. This script calls the
non-spec InternalUnsupportedGuiltRiddenInterface.CreateWithMasterPassword
method, which accepts the master password inline. Read from
KEYRING_PASSWORD env (empty by default).

Idempotent: exits 0 if the 'default' alias already resolves.
"""
from __future__ import annotations
import os, sys
from jeepney import DBusAddress, new_method_call
from jeepney.io.blocking import open_dbus_connection

SP, SB = "/org/freedesktop/secrets", "org.freedesktop.secrets"
SS = "org.freedesktop.Secret.Service"
GI = "org.gnome.keyring.InternalUnsupportedGuiltRiddenInterface"


def main() -> int:
conn = open_dbus_connection(bus="SESSION")
svc = DBusAddress(SP, bus_name=SB, interface=SS)

reply = conn.send_and_get_reply(new_method_call(svc, "ReadAlias", "s", ("default",)))
if reply.body and reply.body[0] != "/":
print(f"[keyring-bootstrap] default alias already set to {reply.body[0]}")
return 0

# 'plain' session: master password travels in cleartext over the
# local unix socket. That's fine because the password is empty (or
# an environment variable already inside the container).
reply = conn.send_and_get_reply(
new_method_call(svc, "OpenSession", "sv", ("plain", ("s", "")))
)
_, session_path = reply.body

password = os.environ.get("KEYRING_PASSWORD", "").encode("utf-8")
master = (session_path, b"", password, "text/plain")
attrs = {"org.freedesktop.Secret.Collection.Label": ("s", "Login")}

guilt = DBusAddress(SP, bus_name=SB, interface=GI)
reply = conn.send_and_get_reply(
new_method_call(guilt, "CreateWithMasterPassword",
"a{sv}(oayays)", (attrs, master))
)
if reply.header.message_type.name != "method_return":
print(f"[keyring-bootstrap] CreateWithMasterPassword failed: {reply.body}", file=sys.stderr)
return 1
coll_path = reply.body[0]
print(f"[keyring-bootstrap] created collection {coll_path}")

reply = conn.send_and_get_reply(
new_method_call(svc, "SetAlias", "so", ("default", coll_path))
)
if reply.header.message_type.name != "method_return":
print(f"[keyring-bootstrap] SetAlias failed: {reply.body}", file=sys.stderr)
return 1
print("[keyring-bootstrap] bound 'default' alias")
return 0


if __name__ == "__main__":
sys.exit(main())
97 changes: 97 additions & 0 deletions .devcontainer/keyring-init.sh
Original file line number Diff line number Diff line change
@@ -0,0 +1,97 @@
#!/usr/bin/env bash
# Bootstraps a per-container Linux Secret Service so VS Code's
# SecretStorage / keytar / libsecret consumers (incl. GitHub Copilot)
# have a local encrypted credential vault.
#
# Idempotent: safe to run on every container start and VS Code attach.
# Run as the remote user; do NOT run as root.

set -euo pipefail

log() { printf '[keyring-init] %s\n' "$*"; }
warn() { printf '[keyring-init] WARNING: %s\n' "$*" >&2; }

UID_NUM="$(id -u)"
XDG_RUNTIME_DIR="${XDG_RUNTIME_DIR:-/tmp/runtime-${UID_NUM}}"
mkdir -p "$XDG_RUNTIME_DIR"
chmod 700 "$XDG_RUNTIME_DIR"
export XDG_RUNTIME_DIR

DBUS_SOCKET="${XDG_RUNTIME_DIR}/bus"
DBUS_PID_FILE="${XDG_RUNTIME_DIR}/dbus.pid"
ENV_FILE="${XDG_RUNTIME_DIR}/keyring.env"

# --- 1. DBus session bus on a stable socket path --------------------------
dbus_alive() {
[[ -S "$DBUS_SOCKET" ]] || return 1
[[ -f "$DBUS_PID_FILE" ]] || return 1
local pid; pid="$(cat "$DBUS_PID_FILE" 2>/dev/null || true)"
[[ -n "$pid" ]] && kill -0 "$pid" 2>/dev/null
}

if dbus_alive; then
log "reusing existing dbus-daemon on $DBUS_SOCKET"
else
rm -f "$DBUS_SOCKET" "$DBUS_PID_FILE"
if ! dbus-daemon --session --address="unix:path=${DBUS_SOCKET}" \
--nopidfile --fork --print-pid=3 3>"$DBUS_PID_FILE"; then
warn "failed to start dbus-daemon"; exit 0
fi
for _ in 1 2 3 4 5 6 7 8 9 10; do
[[ -S "$DBUS_SOCKET" ]] && break
sleep 0.1
done
[[ -S "$DBUS_SOCKET" ]] || { warn "dbus socket never appeared"; exit 0; }
log "started dbus-daemon (pid $(cat "$DBUS_PID_FILE")) on $DBUS_SOCKET"
fi
export DBUS_SESSION_BUS_ADDRESS="unix:path=${DBUS_SOCKET}"

# --- 2. gnome-keyring-daemon ----------------------------------------------
keyring_alive() {
pgrep -u "$UID_NUM" -x gnome-keyring-d >/dev/null 2>&1
}

if keyring_alive; then
log "reusing existing gnome-keyring-daemon"
else
KEYRING_PASS="${KEYRING_PASSWORD-}"
gk_env="$(printf '%s' "$KEYRING_PASS" \
| gnome-keyring-daemon --daemonize --unlock --components=secrets 2>/dev/null)" || {
warn "gnome-keyring-daemon failed to start"; exit 0
}
log "started gnome-keyring-daemon"
printf '%s\n' "$gk_env" > "${XDG_RUNTIME_DIR}/keyring.daemon-env"
fi

# --- 3. Bootstrap the persistent default collection (first run only) ------
if command -v python3 >/dev/null 2>&1 \
&& python3 -c 'import jeepney' >/dev/null 2>&1; then
KEYRING_PASSWORD="${KEYRING_PASSWORD-}" \
python3 "$(dirname "$0")/keyring-bootstrap.py" \
|| warn "default-collection bootstrap failed; SecretStorage may be read-only"
else
warn "python3-jeepney not available; cannot bootstrap default collection"
fi

# --- 4. Persist env for non-VS-Code shells --------------------------------
{
echo "export XDG_RUNTIME_DIR=${XDG_RUNTIME_DIR}"
echo "export DBUS_SESSION_BUS_ADDRESS=${DBUS_SESSION_BUS_ADDRESS}"
[[ -f "${XDG_RUNTIME_DIR}/keyring.daemon-env" ]] \
&& sed -n 's/^\([A-Z_]\+\)=\(.*\)$/export \1=\2/p' \
"${XDG_RUNTIME_DIR}/keyring.daemon-env"
} > "$ENV_FILE"

# --- 5. Verify Secret Service round-trip ----------------------------------
if command -v secret-tool >/dev/null 2>&1; then
probe_value="ok-$$"
if printf '%s' "$probe_value" \
| secret-tool store --label=devcontainer-keyring-probe \
app devcontainer-keyring-probe 2>/dev/null \
&& [[ "$(secret-tool lookup app devcontainer-keyring-probe 2>/dev/null)" == "$probe_value" ]]; then
secret-tool clear app devcontainer-keyring-probe 2>/dev/null || true
log "Secret Service verified on $DBUS_SESSION_BUS_ADDRESS"
else
warn "Secret Service probe failed; VS Code extensions may fall back to the plaintext basic store"
fi
fi
10 changes: 10 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,16 @@ My personal devcontainer for VSCode.
- htop
- tmux
- vim
- gnome-keyring / libsecret (Linux Secret Service for VS Code SecretStorage)

## Secret storage

A per-container Linux Secret Service (`gnome-keyring`) is started on
container start/attach so VS Code's `SecretStorage` / `keytar` /
`libsecret` consumers (including GitHub Copilot) store credentials in a
local encrypted vault instead of VS Code's plaintext fallback. See
`.devcontainer/keyring-init.sh` and `.devcontainer/keyring-bootstrap.py`.


## VSCode extensions

Expand Down