惯性聚合 高效追踪和阅读你感兴趣的博客、新闻、科技资讯
阅读原文 在惯性聚合中打开

推荐订阅源

Cisco Talos Blog
Cisco Talos Blog
阮一峰的网络日志
阮一峰的网络日志
云风的 BLOG
云风的 BLOG
D
Docker
Vercel News
Vercel News
IT之家
IT之家
Recent Announcements
Recent Announcements
Last Week in AI
Last Week in AI
V
Visual Studio Blog
Engineering at Meta
Engineering at Meta
腾讯CDC
Google DeepMind News
Google DeepMind News
I
InfoQ
博客园 - 三生石上(FineUI控件)
Apple Machine Learning Research
Apple Machine Learning Research
The GitHub Blog
The GitHub Blog
博客园 - Franky
The Cloudflare Blog
A
About on SuperTechFans
有赞技术团队
有赞技术团队
Y
Y Combinator Blog
T
Tenable Blog
P
Proofpoint News Feed
Recorded Future
Recorded Future
Security Latest
Security Latest
H
Hackread – Cybersecurity News, Data Breaches, AI and More
K
KPMG report finds enterprise disconnect between AI and its ROI | CIO
博客园 - 聂微东
CTFtime.org: upcoming CTF events
CTFtime.org: upcoming CTF events
Threat Intelligence Blog | Flashpoint
Threat Intelligence Blog | Flashpoint
Google Online Security Blog
Google Online Security Blog
酷 壳 – CoolShell
酷 壳 – CoolShell
Cyber Security Advisories - MS-ISAC
Cyber Security Advisories - MS-ISAC
Simon Willison's Weblog
Simon Willison's Weblog
The Last Watchdog
The Last Watchdog
freeCodeCamp Programming Tutorials: Python, JavaScript, Git & More
OSCHINA 社区最新新闻
OSCHINA 社区最新新闻
N
News and Events Feed by Topic
TaoSecurity Blog
TaoSecurity Blog
U
Unit 42
The Hacker News
The Hacker News
Martin Fowler
Martin Fowler
T
Threat Research - Cisco Blogs
NISL@THU
NISL@THU
F
Full Disclosure
M
MIT News - Artificial intelligence
人人都是产品经理
人人都是产品经理
Hugging Face - Blog
Hugging Face - Blog
V
V2EX
Project Zero
Project Zero

ChrAlpha's Blog

AI Agent 病毒:从 OpenClaw 病毒式成功到成为病毒 | ChrAlpha's Blog 写给非科班的 HPC 无痛上手:在超算节点上使用 VS Code | ChrAlpha's Blog Tailscale 配合 Mihomo(Clash.Meta) TUN/Quantumult X VPN 共存使用技巧 | ChrAlpha's Blog 用 Conda 管理 R 环境并配合 VS Code 优化数据分析代码体验 | ChrAlpha's Blog Pixel SIM 里外配合实践:大陆 SIM + 外地 eSIM WiFi Calling | ChrAlpha's Blog 因 Magisk 模块致 Pixel 8 卡死在 Fastboot 的救砖记录 | ChrAlpha's Blog 用 Thanox 在不支持地区启用 Google 地图时间轴(位置记录) | ChrAlpha's Blog 接纳不等于忍受,为舒服使用 Windows 11 的若干优化调整记录 | ChrAlpha's Blog Win10 生命末尾、Win11 是否有必要执着于 LTSC? | ChrAlpha's Blog 再谈在 GitHub Issues 写作,顺带算是样式开源
嵌套 Devcontainer:放心 dangerously skip permissions 无看管 Agent Coding | ChrAlpha's Blog
ChrAlpha · 2026-05-23 · via ChrAlpha's Blog

过去几个月,我越来越频繁地让 Claude Code 和 Codex 在后台自主执行任务——--dangerously-skip-permissions 开着,approval policy 设成 never,切到别的窗口干别的事。等我再切回来,通常已经跑完了一串操作。

解放了我很多精力和多线程工作的可能性,但每次想到 agent 在容器里拥有不受限制的 shell 权限,还是会有顾虑。特别是有用户借助 Claude Code 整理下载文件夹或图库时被误删宝贵资料,虽然随着模型本身的改进这种风险越来越小,但依然不为 0。因此我一直将 YOLO mode coding agent 关在 dev container 中,利用容器的隔离特性来限制它的可影响范围。

对于这种情况,麻烦的点不在于 agent 会做出什么出格操作——devcontainer 本身就是隔离沙箱,大不了重建。但是每次重建容器,所有状态跟着一起消失。而且 dev container 配置中塞进了很多我自己习惯的方便开发配置而非项目本身初始化所必要的东西,因此也不方便 commit 进项目代码库。随着 dev container 越来越多,配置文件的管理也超渐渐出手动管理的能力范围。

本文记录我为解决这一问题所搭建的嵌套 devcontainer 配置:核心思路是通过 Docker 命名卷将状态持久化到容器外部,再通过中央仓库统一管理多个项目的 devcontainer 配置,同时用 dotfiles 处理个性化设置。

我很早就借助 dev container 管理开发环境了。最初是由于我同时使用 Windows、macOS 和 Linux 机器,dev container 能够方便提供跨平台一致开发环境。一开始还只在 github workspace 统一配置一个 dev container,后来随着项目增多,每个项目都需要不同的环境(Node 版本、Python 版本、工具链等),就逐渐演化成每个项目一个 dev container。

随着 dev container 管理逐渐细分,更新配置也更倾向于重建容器而非在原有容器上修改。第一次意识到这个问题,是因为需要更换项目的 Node 版本而重建容器。重建后:

  • Claude Code 要求重新登录。不只是重新跑 claude login——所有本地 settings、project 级配置、session 历史一并清空。
  • Codex 同样需要重新认证。更麻烦的是 ~/.codex/config.toml 中配置的 project trust levels(每个项目路径对应的 trust_level = "trusted")全部丢失,需要逐个项目重新确认。
  • pnpm store 清空,pnpm install 需要从网络重新下载所有包。
  • ~/.config 下积累的各工具配置归零。

恢复这些花了一个多小时。所有状态都在容器文件系统内,而容器文件系统在重建时被完全丢弃。

明确问题之后,我逐一梳理了需要跨容器保留的目录。这个列表是逐步完善的——每次发现遗漏就补一项。

Claude Code 的状态集中在 ~/.claude/。核心认证文件有两个:auth-claude.json 存储应用级状态,.credentials.json 存储实际的 OAuth access token 和 refresh token。此外 Claude Code 还期望 ~/.claude.json 指向 ~/.claude/auth-claude.json——这不是默认行为,而是需要手动创建的 symlink。如果不做这个 symlink,就需要单独同步不在 ~/.claude/ 内的 ~/.claude.json,会更加麻烦。

除此以外 ~/.claude/ 下还有 settings.json(本地配置)、history.jsonl(对话历史,约 249KB)、projects/(项目级设置)、session-env/(会话环境变量)、tasks/(后台任务状态)等。

Codex 的状态~/.codex/,体量更大。auth.json 负责认证;config.toml 包含所有运行参数——模型选择、API endpoint、MCP server 配置(含 API key)、项目信任级别列表、sandbox 模式设置。实际观察中,Codex 的 SQLite 数据库文件合计超过 1.6GB,主要包括 state_5.sqlitelogs_2.sqlitememories_1.sqlitegoals_1.sqlite。如果不用卷持久化,每次重建容器这些都会从头开始。

另外 Codex 的 config.toml 中配置了 base_url = "http://host.docker.internal:8317/v1"(我即借助 cli proxy api 统一调度两个 Pro 账号的额度,以免频繁切换账号),这依赖容器的 --add-host=host.docker.internal:host-gateway runArg 将宿主机地址映射进容器。这条 runArg 在所有子项目的 devcontainer.json 中都需要保留。

开发基础设施:SSH 和密钥相关已经全部托付给 1Password 管理,但是 ~/.docker/(Docker registry 登录凭证,配合 docker-outside-of-docker 使用)、~/.config/(gh CLI 的 hosts.yml、以及其他工具的配置目录)等仍需持久化。

包管理器缓存:这是最容易膨胀也最不该反复下载的部分。在我当前环境中,Node 系列、Python 系列、Go 系列和 Docker 等合计占据 157GB,这要是再每个容器都重建一次,我的硬盘空间绝对顶不住。

确定了需要保留的目录后,实现方式是在 devcontainer.json 中通过 mounts 字段挂载 Docker 命名卷。

"mounts": [
  "source=claude-data,target=/home/vscode/.claude,type=volume",
  "source=codex-data,target=/home/vscode/.codex,type=volume",
  "source=ssh-data,target=/home/vscode/.ssh,type=volume",
  "source=gnupg-data,target=/home/vscode/.gnupg,type=volume",
  "source=npm-cache,target=/home/vscode/.npm,type=volume",
  "source=pnpm-store,target=/home/vscode/.local/share/pnpm/store,type=volume",
  "source=home-cache,target=/home/vscode/.cache,type=volume"
]

Docker 命名卷的生命周期独立于容器:容器删除后卷仍然保留,下次创建容器时重新挂载即可恢复数据。同一命名卷可以被多个容器共享——claude-data 挂载到不同项目的 devcontainer 中,只要容器内用户路径一致(都是 /home/vscode),Claude Code 就无需重复登录。

实现中有几个细节值得注意。

一是 devcontainer.jsonmounts 字段只接受字符串数组格式。如果写成对象语法(类似 Docker Compose 的写法),devcontainer 会静默忽略而不报错。这是 VS Code Remote-Containers 扩展的行为,与 Docker Compose 的 volumes 语法不同。

二是 pnpm store 的默认路径不是 ~/.pnpm-store 而是 ~/.local/share/pnpm/store(遵循 XDG 规范)。挂载到错误路径不会报错,但卷实际上不会生效。

三是 ~/.ssh~/.gnupg 需要 chmod 700 权限。Docker 卷首次挂载时由 Docker daemon 创建目录,默认权限可能不符合 SSH/GnuPG 的要求。不应依赖 postCreate 来修正——postCreate 在每次容器创建时都执行,而 Dockerfile 中的 RUN mkdir -p && chmod 700 只在镜像构建时执行一次。

四是在 Dockerfile 中预建所有挂载点目录并 chownvscode 用户。如果不预建,Docker 在首次挂载命名卷时会以 root 身份自动创建目录,导致 agent 工具因权限不足无法写入。

FROM mcr.microsoft.com/devcontainers/base:bookworm

ARG USERNAME=vscode

RUN mkdir -p \
    /home/${USERNAME}/.claude \
    /home/${USERNAME}/.codex \
    /home/${USERNAME}/.ssh \
    /home/${USERNAME}/.gnupg \
    /home/${USERNAME}/.docker \
    /home/${USERNAME}/.config \
    /home/${USERNAME}/.npm \
    /home/${USERNAME}/.local/share/pnpm/store \
    /home/${USERNAME}/.yarn \
    /home/${USERNAME}/.bun \
    /home/${USERNAME}/.cargo/registry \
    /home/${USERNAME}/.cargo/git \
    /home/${USERNAME}/.m2 \
    /home/${USERNAME}/.gradle/caches \
    /home/${USERNAME}/.cache \
    /go/pkg/mod \
    && chown -R ${USERNAME}:${USERNAME} \
    /home/${USERNAME}/.claude \
    /home/${USERNAME}/.codex \
    /home/${USERNAME}/.ssh \
    /home/${USERNAME}/.gnupg \
    /home/${USERNAME}/.docker \
    /home/${USERNAME}/.config \
    /home/${USERNAME}/.npm \
    /home/${USERNAME}/.local \
    /home/${USERNAME}/.yarn \
    /home/${USERNAME}/.bun \
    /home/${USERNAME}/.cargo \
    /home/${USERNAME}/.m2 \
    /home/${USERNAME}/.gradle \
    /home/${USERNAME}/.cache \
    /go \
    && chmod 700 /home/${USERNAME}/.ssh /home/${USERNAME}/.gnupg

另外 devcontainer.json 中设置了 "shutdownAction": "none"。默认情况下 VS Code 关闭窗口时会停止容器,设为 none 后容器将持续保持运行,避免每次打开窗口都要等待容器启动。

卷挂载解决了单个容器内的状态持久化。但另一个问题随之而来:绝大多数 dev container 配置不适合直接 commit 进项目代码库,因为它们包含了很多个人习惯的配置,如我习惯在 dev container 内使用 tmux、zsh、fzf 等工具,并且安装了很多全局 CLI 工具,这些配置并非项目初始化所必需,其他协作者可能也不需要。更何况随着项目增多,维护多个 devcontainer.json 的一致性也变得越来越麻烦。

在每个 devcontainer.json 中复制粘贴相同的 mounts 配置不可持续:新增一个缓存目录就要逐个项目修改。因此我将 .devcontainer/ 重构为这一层 workspace 的中央仓库。

/workspaces/github/.devcontainer/        # 中央仓库(私有)
├── Dockerfile                            # 基础镜像:建目录 + 设权限
├── devcontainer.json                     # 母容器配置:通用卷挂载 + 基础 features
├── devcontainer-lock.json                # features 版本锁定(SHA256)
├── post-create.sh                        # 轻量钩子
├── scripts/
│   ├── sync-devcontainer.sh              # rsync 中央配置到各子项目
│   └── test-sync-devcontainer.sh
└── configs/                              # 每个子项目的独立配置
    ├── foo/
    │   └── bar/.devcontainer/
    ├── foo/.devcontainer/
    ├── bar/.devcontainer/

中央仓库本身是一个 .devcontainer/,在 /workspaces/github 打开时被 VS Code 识别,作为所有子项目的「母容器」。它使用 base:bookworm 镜像,挂载所有通用命名卷,安装基础 features(common-utilstmuxnode),并通过 devcontainer-lock.json 锁定 features 的 SHA256 版本。

子项目的配置则各有侧重。以 blog-motion 为例,它使用 typescript-node:1-22-bookworm 镜像,额外安装 docker-outside-of-dockergithub-clicopilot-cli,并在 post-create.sh 中自动完成 Claude Code CLI 和 Codex CLI 的安装、全局 skills 的注册。

选择 docker-outside-of-docker 而非 docker-in-docker 的原因是:在 devcontainer 场景中通常不需要在容器内运行独立的 Docker daemon,只需要复用宿主机的 Docker socket 来执行 docker 命令(如构建镜像、管理卷)。前者共享宿主 Docker 环境,后者会在容器内启动一个完整的 Docker daemon,额外占用资源且增加复杂度。

#!/bin/bash
set -euo pipefail

# 确定用户家目录
if [ -n "${SUDO_USER:-}" ] && [ "${SUDO_USER}" != "root" ]; then
  HOME_DIR="$(getent passwd "${SUDO_USER}" | cut -d: -f6)"
else
  HOME_DIR="${HOME:-$(getent passwd "$(whoami)" | cut -d: -f6)}"
fi

# 确保 auth-claude.json 存在并建立 symlink
# Claude Code 期望 ~/.claude.json -> ~/.claude/auth-claude.json
mkdir -p "${HOME_DIR}/.claude"
if [ ! -f "${HOME_DIR}/.claude/auth-claude.json" ]; then
    echo "{}" > "${HOME_DIR}/.claude/auth-claude.json"
fi
ln -sf "${HOME_DIR}/.claude/auth-claude.json" "${HOME_DIR}/.claude.json"

# 安装 Claude Code CLI
curl -fsSL https://claude.ai/install.sh | bash

# 安装 Codex CLI
npm install -g @openai/codex

# 全局注册 skills
npx skills add vercel-labs/agent-skills -g -a claude-code -s '*' -y
npx skills add anthropics/skills -g -a claude-code -s '*' -y
npx skills add antfu/skills -g -a claude-code -s '*' -y

同步机制

中央仓库的配置通过 rsync 同步到各子项目的 .devcontainer/ 目录。

选择 rsync 而非 Git submodule 或 symlink 经过了两次尝试。Git submodule 的问题在于每次修改中央仓库后需要在每个子项目中执行 git submodule update --remote,项目一多操作繁琐。Symlink 的问题在于 Docker 容器挂载 Windows 宿主上的 symlink 时兼容性不稳定——挂载后 symlink 目标经常变成 broken link,容器内实际读取不到中央仓库的文件。rsync 虽然是一个「笨」方案,但行为确定,不依赖文件系统特性。

#!/usr/bin/env bash
set -euo pipefail

DEFAULT_REPOS=(
  // ... 这里列出所有需要同步的子项目路径,相对于 BASE
)

BASE=""
CENTRAL=""
CONFIG_ROOT=""
DRY_RUN=0
DELETE_MODE=1
WRITE_EXCLUDE=1
REPOS=()

usage() {
  cat <<USAGE
Usage:
  $0 [--repo PATH ...] [--base /workspaces/github] [--central /workspaces/github/.devcontainer] [--config-root /workspaces/github/.devcontainer/configs] [--dry-run] [--delete|--no-delete] [--write-exclude|--no-write-exclude]

Defaults:
  base:    parent directory of this central .devcontainer repo
  central: <base>/.devcontainer
  configs: <central>/configs
  repos:   ${DEFAULT_REPOS[*]}
USAGE
}

SCRIPT_DIR="$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")" && pwd)"
DEFAULT_CENTRAL="$(cd -- "$SCRIPT_DIR/.." && pwd)"
DEFAULT_BASE="$(cd -- "$DEFAULT_CENTRAL/.." && pwd)"

while [[ $# -gt 0 ]]; do
  case "$1" in
    --base)       BASE="$2"; shift 2 ;;
    --central)    CENTRAL="$2"; shift 2 ;;
    --config-root) CONFIG_ROOT="$2"; shift 2 ;;
    --repo)       REPOS+=("$2"); shift 2 ;;
    --all)        REPOS=("${DEFAULT_REPOS[@]}"); shift ;;
    --dry-run)    DRY_RUN=1; shift ;;
    --delete)     DELETE_MODE=1; shift ;;
    --no-delete)  DELETE_MODE=0; shift ;;
    --write-exclude)   WRITE_EXCLUDE=1; shift ;;
    --no-write-exclude) WRITE_EXCLUDE=0; shift ;;
    -h|--help)    usage; exit 0 ;;
    *)            echo "Unknown arg: $1" >&2; usage; exit 1 ;;
  esac
done

BASE="${BASE:-$DEFAULT_BASE}"
CENTRAL="${CENTRAL:-$BASE/.devcontainer}"
CONFIG_ROOT="${CONFIG_ROOT:-$CENTRAL/configs}"

if [[ "${#REPOS[@]}" -eq 0 ]]; then
  REPOS=("${DEFAULT_REPOS[@]}")
fi

sync_one() {
  local repo="$1"
  local source_dir="$CONFIG_ROOT/$repo/.devcontainer/"
  local target_repo="$BASE/$repo"
  local dest_dir="$target_repo/.devcontainer/"

  if [[ ! -d "$source_dir" ]]; then
    echo "[$repo] missing central config: $source_dir" >&2
    return 1
  fi

  if [[ ! -d "$target_repo" ]]; then
    echo "[$repo] missing target repo: $target_repo" >&2
    return 1
  fi

  mkdir -p "$dest_dir"

  local rsync_opts=(
    -a
    --checksum
    --exclude ".git/"
    --exclude ".github/"
  )

  if [[ "$DELETE_MODE" -eq 1 ]]; then
    rsync_opts+=(--delete)
  fi

  if [[ "$DRY_RUN" -eq 1 ]]; then
    rsync_opts+=(--dry-run --itemize-changes)
  fi

  echo "[$repo] rsync $source_dir -> $dest_dir"
  rsync "${rsync_opts[@]}" "$source_dir" "$dest_dir"

  if [[ "$DRY_RUN" -eq 0 && "$WRITE_EXCLUDE" -eq 1 && -d "$target_repo/.git/info" ]]; then
    local exclude_file="$target_repo/.git/info/exclude"
    touch "$exclude_file"
    if ! grep -Fxq ".devcontainer/" "$exclude_file"; then
      {
        printf '\n'
        printf '# Generated from %s/%s/.devcontainer by .devcontainer/scripts/sync-devcontainer.sh\n' "$CONFIG_ROOT" "$repo"
        printf '.devcontainer/\n'
      } >> "$exclude_file"
      echo "[$repo] added .devcontainer/ to .git/info/exclude"
    fi
  fi
}

for repo in "${REPOS[@]}"; do
  sync_one "$repo"
done

脚本的核心逻辑在 sync_one 函数中。rsync 使用 -a --checksum 确保内容级比对而非仅依赖时间戳和文件大小,--exclude ".git/"--exclude ".github/" 避免干扰目标仓库的 Git 元数据。--delete 确保中央仓库中删除的文件也从子项目中移除——如果不带这个标志,重命名或删除配置文件后子项目会残留旧副本。--dry-run 配合 --itemize-changes 可以逐项预览将要同步的变更。

WRITE_EXCLUDE 部分是另一个实用细节:脚本会检查目标项目的 .git/info/exclude 中是否已有 .devcontainer/ 行,没有则追加并附上一行注释标注来源。这比改动 .gitignore 更干净——.git/info/exclude 是本地的、不会被跟踪,不会影响项目的协作者。

dotfiles 边界

中央仓库负责环境和工具配置。zsh 别名、fzf 快捷键、tmux 配色等个人偏好通过 dotfiles 管理,在 devcontainer.json 中声明:

"customizations": {
  "vscode": {
    "settings": {
      "dotfiles.repository": "git@github.com:ChrAlpha/dotfiles.git",
      "dotfiles.targetPath": "~/dotfiles",
      "dotfiles.installCommand": "install/bootstrap.sh"
    }
  }
}

dotfiles 通过 SSH 拉取,而 SSH 密钥来自 ssh-data 卷。首次创建容器时该卷为空,无法完成认证。解决方案是依赖 VS Code Remote-Containers 扩展的 SSH agent forwarding——在宿主机上先 ssh-add 所需密钥,首次创建时 agent forwarding 可完成 dotfiles 仓库的拉取。之后 ssh-data 卷中已有密钥,后续重建不再依赖宿主 agent。使用 1Password SSH Agent 等外部 agent 的,在创建容器前通过 ssh-add -l 确认密钥可见。

以打开 blog-motion 项目为例:

  1. VS Code 检测到 .devcontainer/devcontainer.json,提示 Reopen in Container
  2. 容器启动,Docker 挂载所有命名卷——已存在的卷直接复用数据,不存在的创建空卷
  3. post-create.sh 执行:安装 CLI 工具、注册 skills、建立 symlink
  4. dotfiles 拉取并安装,zsh 和 tmux 配置就位
  5. claude-datacodex-datassh-data 等卷跨项目共享,agent 工具直接可用,无需二次认证

重建容器的体验:post-create 完成后,Claude Code 已处于登录状态(OAuth token 在 .credentials.json 中持久化),Git 操作无需重新配置 SSH 密钥,pnpm install 因 store 缓存命中基本达到本地磁盘速度。

需要注意的一点是:Docker 的 docker volume prune 命令会清理所有未被任何容器使用的命名卷。如果所有容器都恰好处于停止状态,Docker 会将这些卷标记为 unused 并清除。自此之后,跑 prune 之前一定会先 docker volume ls 确认。