From 612b52d5b291a52faf153a5b8f2d7d07a1a6f65a Mon Sep 17 00:00:00 2001 From: Curtis Man Date: Fri, 15 May 2026 18:43:42 -0700 Subject: [PATCH 1/5] ssh container --- .devcontainer/README.md | 129 ++++++- .devcontainer/devcontainer-lock.json | 15 + .devcontainer/devcontainer.json | 39 ++- .devcontainer/scripts/post-create.sh | 178 ++++++++-- .devcontainer/scripts/setup-ssh-access.sh | 354 ++++++++++++++++++++ .devcontainer/scripts/start-devcontainer.sh | 136 ++++++++ .devcontainer/vnc/devcontainer.json | 30 +- 7 files changed, 822 insertions(+), 59 deletions(-) create mode 100755 .devcontainer/scripts/setup-ssh-access.sh create mode 100755 .devcontainer/scripts/start-devcontainer.sh diff --git a/.devcontainer/README.md b/.devcontainer/README.md index e9b1bf63d..d95a36fc4 100644 --- a/.devcontainer/README.md +++ b/.devcontainer/README.md @@ -54,6 +54,78 @@ cd ts && pnpm run shell ## Working with the Container +### SSH Access + +The universal devcontainer image includes an SSH server. To set up key-based +access from your host machine, run: + +```bash +.devcontainer/scripts/setup-ssh-access.sh +``` + +The script will: + +- create `~/.ssh/typeagent-devcontainer` if it does not already exist +- find the running TypeAgent devcontainer from Docker labels +- add the public key to `~/.ssh/authorized_keys` for the `codespace` user in the container +- add or update an SSH config entry named `typeagent-devcontainer` +- enforce key-only SSH auth in both client config and container sshd settings +- use `StrictHostKeyChecking accept-new` by default +- when run in WSL, also detect your Windows `%USERPROFILE%/.ssh` directory +- copy the same keypair into Windows `~/.ssh` so Windows OpenSSH can use it directly +- add or update the same `typeagent-devcontainer` host entry in Windows SSH config +- use Windows-native paths for the copied key and `known_hosts` entry in that Windows config + +By default, generated SSH config uses `StrictHostKeyChecking accept-new`. +For local-only workflows where you intentionally want host key checks disabled, +use: + +```bash +.devcontainer/scripts/setup-ssh-access.sh --insecure-local +``` + +After it completes, connect with: + +```bash +ssh typeagent-devcontainer +``` + +If you use a non-default devcontainer config, pass it explicitly: + +```bash +.devcontainer/scripts/setup-ssh-access.sh --config .devcontainer/vnc/devcontainer.json +``` + +### One-Command Start (and Optional SSH Setup) + +Use this wrapper to start the devcontainer from the command line. If your +VS Code agent window only supports tunnel/SSH connections, pass `--ssh` to +also configure host SSH access in the same step: + +```bash +.devcontainer/scripts/start-devcontainer.sh # start only +.devcontainer/scripts/start-devcontainer.sh --ssh # start + configure SSH +``` + +Useful options: + +```bash +# Recreate container first +.devcontainer/scripts/start-devcontainer.sh --remove-existing-container --ssh + +# Use alternate devcontainer config +.devcontainer/scripts/start-devcontainer.sh --config .devcontainer/vnc/devcontainer.json + +# Local-only mode with host key checks disabled (implies --ssh) +.devcontainer/scripts/start-devcontainer.sh --insecure-local +``` + +After it completes with `--ssh`: + +```bash +ssh typeagent-devcontainer +``` + ### Common Commands ```bash @@ -64,6 +136,24 @@ pnpm run test:local # Run unit tests pnpm run start:agent-server # Start agent server ``` +### Git Configuration + +During container creation, the post-create script keeps any existing container +Git identity, or sets `user.name` and `user.email` from the +`LOCAL_GIT_USER_NAME` and `LOCAL_GIT_USER_EMAIL` environment variables when +those values are provided. The devcontainer configs pass those host-side +environment variables through with `${localEnv:...}`, and the +`start-devcontainer.sh` wrapper fills them from your host `~/.gitconfig` +using `git config --global` before calling `devcontainer up`. + +After rebuilding the container, you can verify with: + +```bash +git config --global --list +git config user.name +git config user.email +``` + ## Using with AI Agents ### Claude Code @@ -99,14 +189,15 @@ Each worktree shares the git history but has independent: ## Forwarded Ports -| Port | Service | -| ---- | ------------------------------- | -| 3000 | API Server (HTTP) | -| 3443 | API Server (HTTPS) | -| 8999 | Agent Server (WebSocket) | -| 8081 | Browser Agent (WebSocket) | -| 8082 | Code Agent (WebSocket) | -| 6080 | noVNC Desktop (VNC config only) | +| Port | Service | +| ---- | ---------------------------------- | +| 2222 | Dev Container SSH (host-published) | +| 3000 | API Server (HTTP) | +| 3443 | API Server (HTTPS) | +| 8999 | Agent Server (WebSocket) | +| 8081 | Browser Agent (WebSocket) | +| 8082 | Code Agent (WebSocket) | +| 6080 | noVNC Desktop (VNC config only) | ## Troubleshooting @@ -150,6 +241,28 @@ The container runs as the `codespace` user. If you encounter permission issues: sudo chown -R codespace:codespace /workspaces/TypeAgent ``` +### Agent window cannot create `/workspaces/*.worktrees` (access denied) + +Agent windows may create sibling worktree folders such as +`/workspaces/TypeAgent3.worktrees`. +If this path is root-owned, worktree creation fails with access denied. + +Run: + +```bash +sudo mkdir -p /workspaces/TypeAgent3.worktrees +sudo chown codespace:codespace /workspaces/TypeAgent3.worktrees +``` + +Then retry creating the worktree. + +This is also handled automatically on fresh container creation by +`.devcontainer/scripts/post-create.sh`. + +The standard and VNC devcontainer configs now mount a dedicated Docker +named volume at `/workspaces/.worktrees` so agent-window worktrees +have a stable writable location across container restarts and rebuilds. + ### Line ending issues (Windows) If scripts fail with `\r': command not found`, the repository may have CRLF line endings. Fix with: diff --git a/.devcontainer/devcontainer-lock.json b/.devcontainer/devcontainer-lock.json index 68cf7dcf4..6eeb1ab70 100644 --- a/.devcontainer/devcontainer-lock.json +++ b/.devcontainer/devcontainer-lock.json @@ -10,11 +10,21 @@ "resolved": "ghcr.io/devcontainers/features/azure-cli@sha256:4549175fbfd3475d1d62e82f6e5425d03954a6ae06027b2515b0ba41a8206417", "integrity": "sha256:4549175fbfd3475d1d62e82f6e5425d03954a6ae06027b2515b0ba41a8206417" }, + "ghcr.io/devcontainers/features/common-utils:2": { + "version": "2.5.8", + "resolved": "ghcr.io/devcontainers/features/common-utils@sha256:c42fdefe6d737a3a6f61cc52b23c7c9a565d08cc4d9c303669a7cf2ee5fd81fc", + "integrity": "sha256:c42fdefe6d737a3a6f61cc52b23c7c9a565d08cc4d9c303669a7cf2ee5fd81fc" + }, "ghcr.io/devcontainers/features/dotnet:2": { "version": "2.5.0", "resolved": "ghcr.io/devcontainers/features/dotnet@sha256:0fc16547ed4db6d7ff2a9f5981d2b93eb314e568affb9958029ad794f1f9a093", "integrity": "sha256:0fc16547ed4db6d7ff2a9f5981d2b93eb314e568affb9958029ad794f1f9a093" }, + "ghcr.io/devcontainers/features/git-lfs:1": { + "version": "1.2.5", + "resolved": "ghcr.io/devcontainers/features/git-lfs@sha256:71c2b371cf12ab7fcec47cf17369c6f59156100dad9abf9e4c593049d789de72", + "integrity": "sha256:71c2b371cf12ab7fcec47cf17369c6f59156100dad9abf9e4c593049d789de72" + }, "ghcr.io/devcontainers/features/github-cli:1": { "version": "1.1.0", "resolved": "ghcr.io/devcontainers/features/github-cli@sha256:d22f50b70ed75339b4eed1ba9ecde3a1791f90e88d37936517e3bace0bbad671", @@ -29,6 +39,11 @@ "version": "1.8.0", "resolved": "ghcr.io/devcontainers/features/python@sha256:fbcad6955caeecc5ad3f7886baf652e25cba5225a6c4c2287c536de2e5607511", "integrity": "sha256:fbcad6955caeecc5ad3f7886baf652e25cba5225a6c4c2287c536de2e5607511" + }, + "ghcr.io/devcontainers/features/sshd:1": { + "version": "1.1.0", + "resolved": "ghcr.io/devcontainers/features/sshd@sha256:f5251b8e4325f68f7280973c6cd65daff414449c66f240621502d4e8e74eb7ee", + "integrity": "sha256:f5251b8e4325f68f7280973c6cd65daff414449c66f240621502d4e8e74eb7ee" } } } diff --git a/.devcontainer/devcontainer.json b/.devcontainer/devcontainer.json index 0035eacef..ada8923dc 100644 --- a/.devcontainer/devcontainer.json +++ b/.devcontainer/devcontainer.json @@ -1,17 +1,23 @@ { "name": "TypeAgent Development", - "image": "mcr.microsoft.com/devcontainers/universal:6", + "image": "mcr.microsoft.com/devcontainers/base:ubuntu-24.04", + // Multi-arch base image (linux/amd64 and linux/arm64) so this configuration + // works on Apple Silicon as well as x86_64 hosts. + // // This configuration works with: - // - VS Code + Docker Desktop (local) + // - VS Code + Docker Desktop (local, including macOS arm64) // - GitHub Codespaces (cloud) // - JetBrains IDEs with Dev Containers support - // Fix stale yarn GPG key in base image before features install - "initializeCommand": "docker run --rm mcr.microsoft.com/devcontainers/universal:6 echo 'Pulling base image...' || true", - "features": { "ghcr.io/anthropics/devcontainer-features/claude-code:1.0": {}, + "ghcr.io/devcontainers/features/sshd:1": { + "version": "latest" + }, + "ghcr.io/devcontainers/features/git-lfs:1": { + "autoPull": false + }, "ghcr.io/devcontainers/features/node:1": { "version": "22", "nodeGypDependencies": true @@ -24,12 +30,21 @@ "version": "8.0" }, "ghcr.io/devcontainers/features/azure-cli:1": {}, - "ghcr.io/devcontainers/features/github-cli:1": {} - // Note: common-utils removed - universal:2 already includes zsh, oh-my-zsh, and common utilities + "ghcr.io/devcontainers/features/github-cli:1": {}, + // base:ubuntu-24.04 does not pre-install zsh / oh-my-zsh, so add common-utils + // and create the non-root `codespace` user expected by the rest of this config. + "ghcr.io/devcontainers/features/common-utils:2": { + "installZsh": true, + "configureZshAsDefaultShell": true, + "installOhMyZsh": true, + "username": "codespace", + "userUid": "1001", + "userGid": "1001" + } }, - // Fix yarn GPG key issue in base image - "onCreateCommand": "sudo rm -f /etc/apt/sources.list.d/yarn.list || true", + // Publish SSH port to host so host-side `ssh typeagent-devcontainer` works. + "appPort": ["2222:2222"], "forwardPorts": [8999, 8081, 8082, 3000, 3443], "portsAttributes": { @@ -45,12 +60,16 @@ "source=pnpm-global-store,target=/home/codespace/.local/share/pnpm/store,type=volume", // Per-container node_modules (symlinks to global store) "source=typeagent-node_modules-${devcontainerId},target=${containerWorkspaceFolder}/ts/node_modules,type=volume", + // Per-container writable sibling worktree root for agent windows + "source=typeagent-agent-worktrees-${devcontainerId},target=/workspaces/${localWorkspaceFolderBasename}.worktrees,type=volume", // Claude Code config persistence "source=claude-code-config-${devcontainerId},target=/home/codespace/.claude,type=volume" ], "containerEnv": { - "PNPM_HOME": "/home/codespace/.local/share/pnpm" + "PNPM_HOME": "/home/codespace/.local/share/pnpm", + "LOCAL_GIT_USER_NAME": "${localEnv:LOCAL_GIT_USER_NAME}", + "LOCAL_GIT_USER_EMAIL": "${localEnv:LOCAL_GIT_USER_EMAIL}" // For Codespaces, set these as repository secrets: // - AZURE_OPENAI_API_KEY // - AZURE_OPENAI_ENDPOINT diff --git a/.devcontainer/scripts/post-create.sh b/.devcontainer/scripts/post-create.sh index bf4147460..0309d0a08 100644 --- a/.devcontainer/scripts/post-create.sh +++ b/.devcontainer/scripts/post-create.sh @@ -7,6 +7,8 @@ # Runs once when the container is first created # +set -euo pipefail + echo "╔══════════════════════════════════════════════════════════════╗" echo "║ TypeAgent DevContainer Setup ║" echo "╚══════════════════════════════════════════════════════════════╝" @@ -14,10 +16,10 @@ echo "" # Detect environment detect_env() { - if [[ "$CODESPACES" == "true" ]]; then + if [[ "${CODESPACES:-}" == "true" ]]; then echo "codespaces" - elif [[ -n "$WSL_DISTRO_NAME" ]] || grep -qi "wsl" /proc/version 2>/dev/null; then - if [[ -n "$WAYLAND_DISPLAY" ]] || [[ -n "$DISPLAY" ]]; then + elif [[ -n "${WSL_DISTRO_NAME:-}" ]] || grep -qi "wsl" /proc/version 2>/dev/null; then + if [[ -n "${WAYLAND_DISPLAY:-}" ]] || [[ -n "${DISPLAY:-}" ]]; then echo "wsl2-gui" else echo "wsl2" @@ -31,6 +33,28 @@ ENV=$(detect_env) echo "Environment: $ENV" echo "" +# Ensure worktree roots are writable for agent windows. +echo "Preparing worktree roots for agent windows..." +WORKSPACE_DIR=$(pwd -P) +WORKTREES_DIR="${WORKSPACE_DIR}.worktrees" +for dir in "$WORKTREES_DIR"; do + if [[ ! -d "$dir" ]]; then + if sudo mkdir -p "$dir"; then + echo " created $dir" + else + echo " warn: could not create $dir" + continue + fi + fi + + if sudo chown codespace:codespace "$dir"; then + echo " $dir owned by codespace" + else + echo " warn: could not set ownership for $dir" + fi +done +echo "" + # Fix ownership of Docker named-volume mount points. # Named volumes mounted into the container are owned by root:root by default, # which prevents the non-root `codespace` user from writing into them @@ -54,18 +78,25 @@ fi for p in "${VOLUME_PATHS[@]}"; do if [[ -e "$p" ]]; then - sudo chown -R codespace:codespace "$p" 2>/dev/null \ - && echo " chowned $p" \ - || echo " warn: could not chown $p" + if sudo chown -R codespace:codespace "$p"; then + echo " chowned $p" + else + if [[ "$p" == *"/pnpm/store" ]] || [[ "$p" == *"/node_modules" ]]; then + echo "Error: failed to chown critical path $p" >&2 + exit 1 + fi + echo " warn: could not chown $p" + fi fi done echo "" # Navigate to TypeScript workspace echo "Looking for TypeScript workspace..." -if [[ -d "/workspaces/TypeAgent/ts" ]]; then - cd /workspaces/TypeAgent/ts - echo "Found: /workspaces/TypeAgent/ts" +REPO_ROOT=$(git rev-parse --show-toplevel 2>/dev/null || true) +if [[ -n "$REPO_ROOT" ]] && [[ -d "$REPO_ROOT/ts" ]]; then + cd "$REPO_ROOT/ts" + echo "Found: $REPO_ROOT/ts" else # Try glob pattern TS_DIR=$(find /workspaces -maxdepth 2 -type d -name "ts" 2>/dev/null | head -1) @@ -105,25 +136,72 @@ fi echo "pnpm version: $(pnpm --version)" +echo "" +echo "Installing system libraries required by TypeAgent..." +# libsecret is required by keytar / native credential storage used by some +# TypeAgent packages (libsecret-1.so.0 at runtime, libsecret-1-dev for builds). +APT_PACKAGES=( + libsecret-1-0 + libsecret-1-dev +) +if command -v apt-get &> /dev/null; then + if ! sudo DEBIAN_FRONTEND=noninteractive apt-get update -y; then + echo " warn: apt-get update failed" + fi + if ! sudo DEBIAN_FRONTEND=noninteractive apt-get install -y --no-install-recommends "${APT_PACKAGES[@]}"; then + echo " warn: failed to install: ${APT_PACKAGES[*]}" + fi +else + echo " warn: apt-get not available, skipping system library install" +fi + +echo "" +echo "Configuring Git identity..." +CURRENT_GIT_NAME=$(git config --global --get user.name 2>/dev/null || true) +CURRENT_GIT_EMAIL=$(git config --global --get user.email 2>/dev/null || true) +DESIRED_GIT_NAME="${LOCAL_GIT_USER_NAME:-}" +DESIRED_GIT_EMAIL="${LOCAL_GIT_USER_EMAIL:-}" + +if [[ -n "$CURRENT_GIT_NAME" ]]; then + echo " git user.name already set" +elif [[ -n "$DESIRED_GIT_NAME" ]]; then + git config --global user.name "$DESIRED_GIT_NAME" + echo " git user.name set" +else + echo " note: no LOCAL_GIT_USER_NAME provided" +fi + +if [[ -n "$CURRENT_GIT_EMAIL" ]]; then + echo " git user.email already set" +elif [[ -n "$DESIRED_GIT_EMAIL" ]]; then + git config --global user.email "$DESIRED_GIT_EMAIL" + echo " git user.email set" +else + echo " note: no LOCAL_GIT_USER_EMAIL provided" +fi + # Install dependencies echo "" echo "Installing pnpm dependencies..." echo "This may take a few minutes on first run..." -pnpm install || { +if ! pnpm install; then echo "" - echo "Warning: pnpm install failed. You may need to run it manually." - echo "This is often due to network issues or missing system dependencies." -} + echo "Error: pnpm install failed." >&2 + echo "This is often due to network issues or missing system dependencies." >&2 + exit 1 +fi -# Set up git hooks for lock file sync (non-critical) +# Set up git hooks for lock file sync without clobbering existing hooks (for git-lfs compatibility) echo "" -echo "Setting up git hooks for dependency synchronization..." +echo "Configuring TypeAgent git hook helpers..." -HOOKS_DIR="../.git/hooks" -if [[ -d "$HOOKS_DIR" ]]; then - # Post-checkout hook - cat > "$HOOKS_DIR/post-checkout" << 'EOF' -#!/bin/bash +HOOKS_DIR=$(git rev-parse --git-path hooks 2>/dev/null || true) +if [[ -n "$HOOKS_DIR" ]] && [[ -d "$HOOKS_DIR" ]]; then + TYPEAGENT_HOOK_DIR="$HOOKS_DIR/typeagent" + mkdir -p "$TYPEAGENT_HOOK_DIR" + + cat > "$TYPEAGENT_HOOK_DIR/post-checkout.sh" << 'EOF' +#!/bin/sh PREV_HEAD=$1 NEW_HEAD=$2 BRANCH_CHECKOUT=$3 @@ -131,31 +209,61 @@ BRANCH_CHECKOUT=$3 if [ "$BRANCH_CHECKOUT" != "1" ]; then exit 0; fi LOCKFILE_CHANGED=$(git diff "$PREV_HEAD" "$NEW_HEAD" --name-only 2>/dev/null | grep -c "pnpm-lock.yaml" || true) +if [ "$LOCKFILE_CHANGED" -gt 0 ]; then + REPO_ROOT=$(git rev-parse --show-toplevel 2>/dev/null || true) + if [ -n "$REPO_ROOT" ] && [ -d "$REPO_ROOT/ts" ]; then + echo "pnpm-lock.yaml changed. Running pnpm install..." + cd "$REPO_ROOT/ts" && pnpm install --frozen-lockfile + echo "Dependencies synchronized" + fi +fi +EOF + chmod +x "$TYPEAGENT_HOOK_DIR/post-checkout.sh" + cat > "$TYPEAGENT_HOOK_DIR/post-merge.sh" << 'EOF' +#!/bin/sh +LOCKFILE_CHANGED=$(git diff HEAD@{1} HEAD --name-only 2>/dev/null | grep -c "pnpm-lock.yaml" || true) if [ "$LOCKFILE_CHANGED" -gt 0 ]; then - echo "pnpm-lock.yaml changed. Running pnpm install..." - cd ts && pnpm install --frozen-lockfile - echo "Dependencies synchronized" + REPO_ROOT=$(git rev-parse --show-toplevel 2>/dev/null || true) + if [ -n "$REPO_ROOT" ] && [ -d "$REPO_ROOT/ts" ]; then + echo "pnpm-lock.yaml changed after merge. Running pnpm install..." + cd "$REPO_ROOT/ts" && pnpm install --frozen-lockfile + echo "Dependencies synchronized" + fi fi EOF - chmod +x "$HOOKS_DIR/post-checkout" + chmod +x "$TYPEAGENT_HOOK_DIR/post-merge.sh" - # Post-merge hook - cat > "$HOOKS_DIR/post-merge" << 'EOF' -#!/bin/bash -LOCKFILE_CHANGED=$(git diff HEAD@{1} HEAD --name-only | grep -c "pnpm-lock.yaml" || true) + ensure_hook_chain() { + local hook_file=$1 + local helper_script=$2 + local marker="# TypeAgent dependency sync" -if [ "$LOCKFILE_CHANGED" -gt 0 ]; then - echo "pnpm-lock.yaml changed after merge. Running pnpm install..." - cd ts && pnpm install --frozen-lockfile - echo "Dependencies synchronized" + if [[ ! -f "$hook_file" ]]; then + cat > "$hook_file" << 'EOF' +#!/bin/sh +EOF + chmod +x "$hook_file" + fi + + if ! grep -Fq "$marker" "$hook_file"; then + cat >> "$hook_file" << EOF + +$marker +HOOK_DIR="\$(cd "\$(dirname "\$0")" && pwd)" +if [ -x "\$HOOK_DIR/typeagent/$helper_script" ]; then + "\$HOOK_DIR/typeagent/$helper_script" "\$@" fi EOF - chmod +x "$HOOKS_DIR/post-merge" + fi + } + + ensure_hook_chain "$HOOKS_DIR/post-checkout" "post-checkout.sh" + ensure_hook_chain "$HOOKS_DIR/post-merge" "post-merge.sh" - echo "Git hooks installed for automatic dependency sync" + echo "TypeAgent hook helpers installed (compatible with existing hooks)" else - echo "Note: .git/hooks directory not found, skipping git hooks setup" + echo "Note: Could not resolve .git/hooks directory, skipping hook helper setup" fi echo "" diff --git a/.devcontainer/scripts/setup-ssh-access.sh b/.devcontainer/scripts/setup-ssh-access.sh new file mode 100755 index 000000000..1e5195db0 --- /dev/null +++ b/.devcontainer/scripts/setup-ssh-access.sh @@ -0,0 +1,354 @@ +#!/usr/bin/env bash +set -euo pipefail + +SCRIPT_DIR=$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")" && pwd) +REPO_ROOT=$(cd -- "$SCRIPT_DIR/../.." && pwd) +WORKSPACE_FOLDER=$(cd -- "$REPO_ROOT" && pwd) +DEFAULT_KEY_NAME="typeagent-devcontainer" +DEFAULT_KEY_PATH="$HOME/.ssh/$DEFAULT_KEY_NAME" +DEFAULT_CONFIG_PATH="$HOME/.ssh/config" +DEFAULT_LOCAL_PORT="2222" +REMOTE_USER="codespace" + +usage() { + cat <&2 + exit 1 +} + +require_cmd() { + command -v "$1" >/dev/null 2>&1 || fail "Required command not found: $1" +} + +is_wsl() { + [[ -n "${WSL_DISTRO_NAME:-}" ]] || grep -qiE "microsoft|wsl" /proc/version 2>/dev/null +} + +ensure_ssh_config_block() { + local config_path=$1 + local host_alias=$2 + local key_path_for_config=$3 + local strict_host_key_checking=$4 + local user_known_hosts_file=$5 + local global_known_hosts_file=$6 + + local ssh_config_block + ssh_config_block=$(cat </dev/null || true + + if [[ $PRINT_ONLY -eq 1 ]]; then + log "Would ensure SSH config block in $config_path" + return 0 + fi + + local tmp_file + tmp_file=$(mktemp) + trap 'rm -f "$tmp_file"' RETURN + + awk \ + -v alias="$host_alias" \ + -v begin_marker="$config_begin_marker" \ + -v end_marker="$config_end_marker" \ + -v legacy_marker="$legacy_marker" ' + $0 == begin_marker { in_managed=1; next } + in_managed { + if ($0 == end_marker) { + in_managed=0 + } + next + } + $0 == legacy_marker { next } + $0 ~ ("^Host[[:space:]]+" alias "$") { in_alias_stanza=1; next } + in_alias_stanza { + if ($0 ~ /^Host[[:space:]]+/) { + in_alias_stanza=0 + print + } + next + } + { print } + ' "$config_path" > "$tmp_file" + mv "$tmp_file" "$config_path" + trap - RETURN + + { + if [[ -s "$config_path" ]] && [[ "$(tail -c 1 "$config_path")" != "" ]]; then + printf '\n' + fi + printf '%s\n%s\n%s\n' "$config_begin_marker" "$ssh_config_block" "$config_end_marker" + } >> "$config_path" +} + +WORKSPACE_MATCH="$WORKSPACE_FOLDER" +CONFIG_MATCH="" +KEY_PATH="$DEFAULT_KEY_PATH" +HOST_ALIAS="$DEFAULT_KEY_NAME" +LOCAL_PORT="$DEFAULT_LOCAL_PORT" +PRINT_ONLY=0 +INSECURE_LOCAL=0 + +while [[ $# -gt 0 ]]; do + case "$1" in + --workspace-folder) + [[ $# -ge 2 ]] || fail "Missing value for $1" + WORKSPACE_MATCH=$(cd -- "$2" && pwd) + shift 2 + ;; + --config) + [[ $# -ge 2 ]] || fail "Missing value for $1" + CONFIG_MATCH=$(cd -- "$(dirname -- "$2")" && pwd)/$(basename -- "$2") + shift 2 + ;; + --key-path) + [[ $# -ge 2 ]] || fail "Missing value for $1" + KEY_PATH="$2" + shift 2 + ;; + --host-alias) + [[ $# -ge 2 ]] || fail "Missing value for $1" + HOST_ALIAS="$2" + shift 2 + ;; + --local-port) + [[ $# -ge 2 ]] || fail "Missing value for $1" + LOCAL_PORT="$2" + shift 2 + ;; + --insecure-local) + INSECURE_LOCAL=1 + shift + ;; + --print-only) + PRINT_ONLY=1 + shift + ;; + -h|--help) + usage + exit 0 + ;; + *) + fail "Unknown argument: $1" + ;; + esac +done + +require_cmd docker +require_cmd jq +require_cmd ssh-keygen +require_cmd ssh + +[[ "$HOST_ALIAS" =~ ^[a-zA-Z0-9._-]+$ ]] || fail "Invalid host alias: $HOST_ALIAS" +[[ "$LOCAL_PORT" =~ ^[0-9]+$ ]] || fail "Invalid local port: $LOCAL_PORT" + +mkdir -p "$HOME/.ssh" +chmod 700 "$HOME/.ssh" + +STRICT_HOST_KEY_CHECKING="accept-new" +USER_KNOWN_HOSTS_FILE="$HOME/.ssh/known_hosts" +GLOBAL_KNOWN_HOSTS_FILE="/etc/ssh/ssh_known_hosts" +if [[ $INSECURE_LOCAL -eq 1 ]]; then + STRICT_HOST_KEY_CHECKING="no" + USER_KNOWN_HOSTS_FILE="/dev/null" + GLOBAL_KNOWN_HOSTS_FILE="/dev/null" +fi + +find_container() { + local workspace=$1 + local config=$2 + local container_ids=() + mapfile -t container_ids < <(docker ps -q) + [[ ${#container_ids[@]} -gt 0 ]] || return 0 + + docker inspect "${container_ids[@]}" | jq -r --arg workspace "$workspace" --arg config "$config" ' + .[] + | select(.Config.Labels["devcontainer.local_folder"] == $workspace) + | select(($config == "") or (.Config.Labels["devcontainer.config_file"] == $config)) + | .Name + | ltrimstr("/") + ' | head -n 1 +} + +CONTAINER_NAME=$(find_container "$WORKSPACE_MATCH" "$CONFIG_MATCH") +[[ -n "$CONTAINER_NAME" ]] || fail "No running devcontainer found for $WORKSPACE_MATCH${CONFIG_MATCH:+ using $CONFIG_MATCH}" + +log "Using container: $CONTAINER_NAME" +log "Workspace match: $WORKSPACE_MATCH" +if [[ -n "$CONFIG_MATCH" ]]; then + log "Config match: $CONFIG_MATCH" +fi + +if [[ ! -f "$KEY_PATH" ]]; then + if [[ $PRINT_ONLY -eq 1 ]]; then + log "Would create SSH key: $KEY_PATH" + else + log "Creating SSH key: $KEY_PATH" + ssh-keygen -t ed25519 -f "$KEY_PATH" -N '' -C "$HOST_ALIAS" + fi +else + log "Using existing SSH key: $KEY_PATH" +fi + +PUB_KEY_PATH="$KEY_PATH.pub" +if [[ ! -f "$PUB_KEY_PATH" ]]; then + if [[ $PRINT_ONLY -eq 1 ]]; then + log "Would create public key: $PUB_KEY_PATH" + PUB_KEY="" + else + fail "Public key not found: $PUB_KEY_PATH" + fi +else + PUB_KEY=$(cat "$PUB_KEY_PATH") +fi + +if [[ $PRINT_ONLY -eq 1 ]]; then + log "Would install public key into container user $REMOTE_USER" +else + log "Installing public key into container authorized_keys if needed" + docker exec -u "$REMOTE_USER" "$CONTAINER_NAME" sh -lc ' + set -eu + umask 077 + mkdir -p "$HOME/.ssh" + touch "$HOME/.ssh/authorized_keys" + chmod 700 "$HOME/.ssh" + chmod 600 "$HOME/.ssh/authorized_keys" + ' + + docker exec -i -u "$REMOTE_USER" "$CONTAINER_NAME" sh -lc ' + set -eu + key=$(cat) + auth="$HOME/.ssh/authorized_keys" + if ! grep -Fqx "$key" "$auth"; then + printf "%s\n" "$key" >> "$auth" + echo "added" + else + echo "present" + fi + ' <<< "$PUB_KEY" >/tmp/typeagent-devcontainer-ssh-key-status.$$ || fail "Failed to install public key into container" + + KEY_STATUS=$(cat /tmp/typeagent-devcontainer-ssh-key-status.$$) + rm -f /tmp/typeagent-devcontainer-ssh-key-status.$$ + log "Container key status: $KEY_STATUS" +fi + +if [[ $PRINT_ONLY -eq 1 ]]; then + log "Would harden container sshd to key-only authentication" +else + log "Hardening container sshd configuration to key-only authentication" + docker exec -u root "$CONTAINER_NAME" sh -lc ' + set -eu + install -d -m 755 /etc/ssh/sshd_config.d + cat > /etc/ssh/sshd_config.d/99-typeagent-key-only.conf <<"EOF" +PubkeyAuthentication yes +PasswordAuthentication no +KbdInteractiveAuthentication no +ChallengeResponseAuthentication no +UsePAM yes +EOF + + if pgrep sshd >/dev/null 2>&1; then + pkill -HUP sshd || true + elif command -v service >/dev/null 2>&1; then + service ssh restart || service sshd restart || true + fi + ' || fail "Failed to harden sshd in container" + log "Container sshd hardening applied" +fi + +CONFIG_PATH="$DEFAULT_CONFIG_PATH" +log "Writing SSH config block for $HOST_ALIAS" +ensure_ssh_config_block \ + "$CONFIG_PATH" \ + "$HOST_ALIAS" \ + "$KEY_PATH" \ + "$STRICT_HOST_KEY_CHECKING" \ + "$USER_KNOWN_HOSTS_FILE" \ + "$GLOBAL_KNOWN_HOSTS_FILE" + +if is_wsl && command -v cmd.exe >/dev/null 2>&1 && command -v wslpath >/dev/null 2>&1; then + WINDOWS_USERPROFILE_WIN=$(cmd.exe /C "echo %USERPROFILE%" 2>/dev/null | tr -d '\r' || true) + if [[ -n "$WINDOWS_USERPROFILE_WIN" ]] && [[ "$WINDOWS_USERPROFILE_WIN" != "%USERPROFILE%" ]]; then + WINDOWS_USERPROFILE_WSL=$(wslpath -u "$WINDOWS_USERPROFILE_WIN" 2>/dev/null || true) + if [[ -n "$WINDOWS_USERPROFILE_WSL" ]]; then + WINDOWS_SSH_DIR_WSL="$WINDOWS_USERPROFILE_WSL/.ssh" + WINDOWS_KEY_BASENAME=$(basename "$KEY_PATH") + WINDOWS_KEY_PATH_WSL="$WINDOWS_SSH_DIR_WSL/$WINDOWS_KEY_BASENAME" + WINDOWS_PUB_KEY_PATH_WSL="$WINDOWS_KEY_PATH_WSL.pub" + WINDOWS_CONFIG_PATH_WSL="$WINDOWS_SSH_DIR_WSL/config" + WINDOWS_KEY_PATH_CONFIG=$(wslpath -m "$WINDOWS_KEY_PATH_WSL" 2>/dev/null || true) + WINDOWS_KNOWN_HOSTS_CONFIG=$(wslpath -m "$WINDOWS_SSH_DIR_WSL/known_hosts" 2>/dev/null || true) + [[ -n "$WINDOWS_KEY_PATH_CONFIG" ]] || fail "Failed to convert WSL key path to Windows path" + [[ -n "$WINDOWS_KNOWN_HOSTS_CONFIG" ]] || fail "Failed to convert WSL known_hosts path to Windows path" + + if [[ $PRINT_ONLY -eq 1 ]]; then + log "Would copy keypair to Windows SSH dir: $WINDOWS_SSH_DIR_WSL" + else + mkdir -p "$WINDOWS_SSH_DIR_WSL" + cp -f "$KEY_PATH" "$WINDOWS_KEY_PATH_WSL" + cp -f "$PUB_KEY_PATH" "$WINDOWS_PUB_KEY_PATH_WSL" + log "Copied keypair to Windows SSH dir: $WINDOWS_SSH_DIR_WSL" + fi + + log "Writing Windows SSH config block for $HOST_ALIAS" + ensure_ssh_config_block \ + "$WINDOWS_CONFIG_PATH_WSL" \ + "$HOST_ALIAS" \ + "$WINDOWS_KEY_PATH_CONFIG" \ + "$STRICT_HOST_KEY_CHECKING" \ + "$WINDOWS_KNOWN_HOSTS_CONFIG" \ + "none" + fi + fi +fi + +printf '\nSSH setup complete.\n\n' +printf 'Container: %s\n' "$CONTAINER_NAME" +printf 'Host alias: %s\n' "$HOST_ALIAS" +printf 'Key: %s\n' "$KEY_PATH" +printf 'Local port: %s\n\n' "$LOCAL_PORT" +printf 'Connect with:\n' +printf ' ssh %s\n\n' "$HOST_ALIAS" +printf 'Or without SSH config:\n' +printf ' ssh -i %s -p %s %s@localhost\n' "$KEY_PATH" "$LOCAL_PORT" "$REMOTE_USER" diff --git a/.devcontainer/scripts/start-devcontainer.sh b/.devcontainer/scripts/start-devcontainer.sh new file mode 100755 index 000000000..d7bae8170 --- /dev/null +++ b/.devcontainer/scripts/start-devcontainer.sh @@ -0,0 +1,136 @@ +#!/usr/bin/env bash +set -euo pipefail + +SCRIPT_DIR=$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")" && pwd) +REPO_ROOT=$(cd -- "$SCRIPT_DIR/../.." && pwd) +DEFAULT_WORKSPACE_FOLDER="$REPO_ROOT" + +usage() { + cat <&2 + exit 1 +} + +read_git_identity() { + local key=$1 + git config --global --get "$key" 2>/dev/null || true +} + +WORKSPACE_FOLDER="$DEFAULT_WORKSPACE_FOLDER" +CONFIG_PATH="" +REMOVE_EXISTING=0 +SETUP_SSH=0 +INSECURE_LOCAL=0 + +while [[ $# -gt 0 ]]; do + case "$1" in + --workspace-folder) + [[ $# -ge 2 ]] || fail "Missing value for $1" + WORKSPACE_FOLDER=$(cd -- "$2" && pwd) + shift 2 + ;; + --config) + [[ $# -ge 2 ]] || fail "Missing value for $1" + CONFIG_PATH=$(cd -- "$(dirname -- "$2")" && pwd)/$(basename -- "$2") + shift 2 + ;; + --remove-existing-container) + REMOVE_EXISTING=1 + shift + ;; + --ssh) + SETUP_SSH=1 + shift + ;; + --insecure-local) + SETUP_SSH=1 + INSECURE_LOCAL=1 + shift + ;; + -h|--help) + usage + exit 0 + ;; + *) + fail "Unknown argument: $1" + ;; + esac +done + +if ! command -v docker >/dev/null 2>&1; then + fail "docker is required" +fi + +if command -v devcontainer >/dev/null 2>&1; then + DEVCONTAINER_CMD=(devcontainer) +else + DEVCONTAINER_CMD=(npx -y @devcontainers/cli) +fi + +HOST_GIT_USER_NAME=$(read_git_identity user.name) +HOST_GIT_USER_EMAIL=$(read_git_identity user.email) + +if [[ -n "$HOST_GIT_USER_NAME" ]]; then + export LOCAL_GIT_USER_NAME="$HOST_GIT_USER_NAME" + log "Using host git user.name from ~/.gitconfig" +fi +if [[ -n "$HOST_GIT_USER_EMAIL" ]]; then + export LOCAL_GIT_USER_EMAIL="$HOST_GIT_USER_EMAIL" + log "Using host git user.email from ~/.gitconfig" +fi + +UP_CMD=("${DEVCONTAINER_CMD[@]}" up --workspace-folder "$WORKSPACE_FOLDER") +if [[ -n "$CONFIG_PATH" ]]; then + UP_CMD+=(--config "$CONFIG_PATH") +fi +if [[ $REMOVE_EXISTING -eq 1 ]]; then + UP_CMD+=(--remove-existing-container) +fi + +log "Starting devcontainer..." +"${UP_CMD[@]}" + +if [[ $SETUP_SSH -eq 1 ]]; then + SSH_SETUP_CMD=("$SCRIPT_DIR/setup-ssh-access.sh" --workspace-folder "$WORKSPACE_FOLDER") + if [[ -n "$CONFIG_PATH" ]]; then + SSH_SETUP_CMD+=(--config "$CONFIG_PATH") + fi + if [[ $INSECURE_LOCAL -eq 1 ]]; then + SSH_SETUP_CMD+=(--insecure-local) + fi + + log "Configuring SSH access..." + "${SSH_SETUP_CMD[@]}" + + printf '\nDone. Connect with:\n' + printf ' ssh typeagent-devcontainer\n' +else + printf '\nDone. Devcontainer is running.\n' + printf 'To set up host SSH access, re-run with --ssh, or invoke:\n' + printf ' %s/setup-ssh-access.sh\n' "$SCRIPT_DIR" +fi diff --git a/.devcontainer/vnc/devcontainer.json b/.devcontainer/vnc/devcontainer.json index b569dc78f..6775a845f 100644 --- a/.devcontainer/vnc/devcontainer.json +++ b/.devcontainer/vnc/devcontainer.json @@ -4,10 +4,11 @@ // Use this configuration when you need GUI support in Codespaces // or environments without WSLg. Access via http://localhost:6080 - "image": "mcr.microsoft.com/devcontainers/universal:2", - - // Fix stale yarn GPG key in base image - "onCreateCommand": "sudo rm -f /etc/apt/sources.list.d/yarn.list || true", + // Multi-arch base image (linux/amd64 and linux/arm64). Note: the + // desktop-lite feature historically supports amd64 only, so on Apple + // Silicon this config may still require Rosetta/QEMU emulation just for + // the VNC desktop. The standard (non-VNC) config runs natively on arm64. + "image": "mcr.microsoft.com/devcontainers/base:ubuntu-24.04", "features": { // VNC desktop for Electron shell support @@ -17,6 +18,12 @@ "vncPort": "5901" }, "ghcr.io/anthropics/devcontainer-features/claude-code:1.0": {}, + "ghcr.io/devcontainers/features/sshd:1": { + "version": "latest" + }, + "ghcr.io/devcontainers/features/git-lfs:1": { + "autoPull": false + }, "ghcr.io/devcontainers/features/node:1": { "version": "22", "nodeGypDependencies": true @@ -26,7 +33,15 @@ "installTools": true }, "ghcr.io/devcontainers/features/azure-cli:1": {}, - "ghcr.io/devcontainers/features/github-cli:1": {} + "ghcr.io/devcontainers/features/github-cli:1": {}, + "ghcr.io/devcontainers/features/common-utils:2": { + "installZsh": true, + "configureZshAsDefaultShell": true, + "installOhMyZsh": true, + "username": "codespace", + "userUid": "1001", + "userGid": "1001" + } }, "forwardPorts": [6080, 5901, 8999, 8081, 8082, 3000], @@ -42,11 +57,14 @@ "mounts": [ "source=pnpm-global-store,target=/home/codespace/.local/share/pnpm/store,type=volume", "source=typeagent-node_modules-${devcontainerId},target=${containerWorkspaceFolder}/ts/node_modules,type=volume", + "source=typeagent-agent-worktrees-${devcontainerId},target=/workspaces/${localWorkspaceFolderBasename}.worktrees,type=volume", "source=claude-code-config-${devcontainerId},target=/home/codespace/.claude,type=volume" ], "containerEnv": { - "PNPM_HOME": "/home/codespace/.local/share/pnpm" + "PNPM_HOME": "/home/codespace/.local/share/pnpm", + "LOCAL_GIT_USER_NAME": "${localEnv:LOCAL_GIT_USER_NAME}", + "LOCAL_GIT_USER_EMAIL": "${localEnv:LOCAL_GIT_USER_EMAIL}" }, "postCreateCommand": "cat .devcontainer/scripts/post-create.sh | tr -d '\\r' | bash && cat .devcontainer/scripts/install-electron-deps.sh | tr -d '\\r' | bash", From 93a5046f5cdf1971d4aa8d650e50fcdd13e92ef5 Mon Sep 17 00:00:00 2001 From: Curtis Man Date: Sat, 16 May 2026 04:41:22 +0000 Subject: [PATCH 2/5] security: harden devcontainer sudo and SSH exposure - Replace blanket NOPASSWD:ALL sudo with a minimal allowlist (apt-get, chown, mkdir, service ssh) applied at the end of post-create after setup no longer needs full root. - Remove appPort Docker host binding for SSH (port 2222) and move it to forwardPorts, which tunnels through VS Code / Codespaces instead of exposing on the host network. - Add portsAttributes entry for SSH port. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .devcontainer/devcontainer.json | 9 ++++---- .devcontainer/scripts/post-create.sh | 31 ++++++++++++++++++++++++++++ 2 files changed, 36 insertions(+), 4 deletions(-) diff --git a/.devcontainer/devcontainer.json b/.devcontainer/devcontainer.json index ada8923dc..3e5b6f875 100644 --- a/.devcontainer/devcontainer.json +++ b/.devcontainer/devcontainer.json @@ -43,11 +43,12 @@ } }, - // Publish SSH port to host so host-side `ssh typeagent-devcontainer` works. - "appPort": ["2222:2222"], - - "forwardPorts": [8999, 8081, 8082, 3000, 3443], + // SSH is forwarded via forwardPorts (VS Code / Codespaces handles secure + // tunnelling). Avoid "appPort" which binds directly on the Docker host + // network and may be reachable from adjacent containers or the LAN. + "forwardPorts": [2222, 8999, 8081, 8082, 3000, 3443], "portsAttributes": { + "2222": { "label": "SSH", "onAutoForward": "silent" }, "8999": { "label": "Agent Server (WebSocket)", "onAutoForward": "notify" }, "8081": { "label": "Browser Agent (WebSocket)", "onAutoForward": "notify" }, "8082": { "label": "Code Agent (WebSocket)", "onAutoForward": "notify" }, diff --git a/.devcontainer/scripts/post-create.sh b/.devcontainer/scripts/post-create.sh index 0309d0a08..a7a68de60 100644 --- a/.devcontainer/scripts/post-create.sh +++ b/.devcontainer/scripts/post-create.sh @@ -266,6 +266,37 @@ else echo "Note: Could not resolve .git/hooks directory, skipping hook helper setup" fi +# ── Security hardening: restrict sudo to a minimal allowlist ────────── +# During post-create we needed unrestricted root access to install +# packages and fix volume ownership. Now that setup is done, replace +# the blanket NOPASSWD:ALL rule with the narrowest set of commands the +# codespace user is likely to need at runtime. +echo "" +echo "Hardening sudo access..." +SUDOERS_FILE="/etc/sudoers.d/codespace-restricted" +sudo tee "$SUDOERS_FILE" > /dev/null << 'SUDOERS' +# Restricted sudo for the codespace user (post-setup hardening). +# Only allow package management, ownership fixes, and directory creation. +codespace ALL=(root) NOPASSWD: /usr/bin/apt-get update*, \ + /usr/bin/apt-get install*, \ + /usr/bin/apt-get upgrade*, \ + /usr/bin/apt-get autoremove*, \ + /bin/chown *, \ + /usr/bin/chown *, \ + /bin/mkdir *, \ + /usr/bin/mkdir *, \ + /usr/sbin/service ssh * +SUDOERS +sudo chmod 0440 "$SUDOERS_FILE" +# Remove the blanket rule that grants unrestricted root. The common-utils +# devcontainer feature writes it to /etc/sudoers.d/codespace (filename +# matches the username). +if [[ -f /etc/sudoers.d/codespace ]]; then + sudo rm /etc/sudoers.d/codespace + echo " Removed blanket NOPASSWD:ALL rule" +fi +echo " Sudo restricted to: apt-get, chown, mkdir, service ssh" + echo "" echo "╔══════════════════════════════════════════════════════════════╗" echo "║ Setup Complete! ║" From 07dd6d779516841810a4482f4d261912aa82c230 Mon Sep 17 00:00:00 2001 From: Curtis Man Date: Fri, 15 May 2026 21:53:27 -0700 Subject: [PATCH 3/5] fix(devcontainer): publish SSH on loopback so host ssh works The earlier hardening commit replaced appPort with forwardPorts for 2222. forwardPorts is only honored by the VS Code/Codespaces port-forwarding UI and does not publish a Docker port when the container is started via the devcontainer CLI, leaving localhost:2222 unreachable (Connection refused). Restore appPort but bind to 127.0.0.1 only, so host-side 'ssh typeagent-devcontainer' works without exposing 2222 on the LAN. --- .devcontainer/devcontainer.json | 13 ++++++++----- 1 file changed, 8 insertions(+), 5 deletions(-) diff --git a/.devcontainer/devcontainer.json b/.devcontainer/devcontainer.json index 3e5b6f875..f12643925 100644 --- a/.devcontainer/devcontainer.json +++ b/.devcontainer/devcontainer.json @@ -43,12 +43,15 @@ } }, - // SSH is forwarded via forwardPorts (VS Code / Codespaces handles secure - // tunnelling). Avoid "appPort" which binds directly on the Docker host - // network and may be reachable from adjacent containers or the LAN. - "forwardPorts": [2222, 8999, 8081, 8082, 3000, 3443], + // Publish SSH port on loopback only so host-side `ssh typeagent-devcontainer` + // works without exposing 2222 on the LAN. Plain `forwardPorts` is only + // honored by the VS Code / Codespaces port-forwarding UI and does NOT + // actually publish the port when the container is started via the + // devcontainer CLI, which leaves `localhost:2222` unreachable. + "appPort": ["127.0.0.1:2222:2222"], + + "forwardPorts": [8999, 8081, 8082, 3000, 3443], "portsAttributes": { - "2222": { "label": "SSH", "onAutoForward": "silent" }, "8999": { "label": "Agent Server (WebSocket)", "onAutoForward": "notify" }, "8081": { "label": "Browser Agent (WebSocket)", "onAutoForward": "notify" }, "8082": { "label": "Code Agent (WebSocket)", "onAutoForward": "notify" }, From 3f2ebaae42969c2030723b05a22f3fb65f4e4688 Mon Sep 17 00:00:00 2001 From: Curtis Man Date: Fri, 15 May 2026 22:10:18 -0700 Subject: [PATCH 4/5] devcontainer: apply review fixes - VNC config: add dotnet:2 feature, appPort 127.0.0.1:2222:2222, port 3443, missing extensions (csharp, azure-functions, rest-client) - post-create: remove TypeAgent git hook helpers entirely; consolidate git identity warning into one message; replace em-dash header comment - post-create: expand restricted sudoers allowlist (apt-get remove, dpkg -i / --configure, service sshd) for legitimate package operations - setup-ssh-access: explicit warnings when WSL Windows SSH sync is skipped (missing cmd.exe / wslpath, unresolved %USERPROFILE%, wslpath translation failure) - README: fix 'teh' typo, split forwarded-ports table by config, add Container User section explaining UID/GID 1001 choice - devcontainer.json / vnc/devcontainer.json: drop redundant 'tr -d \r' wrapper in postCreateCommand/postStartCommand (.gitattributes already enforces eol=lf) --- .devcontainer/README.md | 37 ++++++--- .devcontainer/devcontainer.json | 4 +- .devcontainer/scripts/post-create.sh | 99 ++++------------------- .devcontainer/scripts/setup-ssh-access.sh | 68 +++++++++------- .devcontainer/vnc/devcontainer.json | 21 +++-- 5 files changed, 100 insertions(+), 129 deletions(-) diff --git a/.devcontainer/README.md b/.devcontainer/README.md index d95a36fc4..c108cb063 100644 --- a/.devcontainer/README.md +++ b/.devcontainer/README.md @@ -42,7 +42,7 @@ Default configuration for most development work. Includes: - Azure CLI, GitHub CLI - Claude Code -**Note:** The Electron shell requires GUI support. To use the shell with devcontainer, you need to start teh agent-server in the container and run the shell on your host machine. The agent server port is forwarded to the host, so the shell will connect correctly: +**Note:** The Electron shell requires GUI support. To use the shell with devcontainer, you need to start the agent-server in the container and run the shell on your host machine. The agent server port is forwarded to the host, so the shell will connect correctly: ```bash # In container - start the backend @@ -189,15 +189,32 @@ Each worktree shares the git history but has independent: ## Forwarded Ports -| Port | Service | -| ---- | ---------------------------------- | -| 2222 | Dev Container SSH (host-published) | -| 3000 | API Server (HTTP) | -| 3443 | API Server (HTTPS) | -| 8999 | Agent Server (WebSocket) | -| 8081 | Browser Agent (WebSocket) | -| 8082 | Code Agent (WebSocket) | -| 6080 | noVNC Desktop (VNC config only) | +Standard config (`devcontainer.json`): + +| Port | Service | +| ---- | ----------------------------------------------- | +| 2222 | Dev Container SSH (host-published on 127.0.0.1) | +| 3000 | API Server (HTTP) | +| 3443 | API Server (HTTPS) | +| 8999 | Agent Server (WebSocket) | +| 8081 | Browser Agent (WebSocket) | +| 8082 | Code Agent (WebSocket) | + +VNC config (`vnc/devcontainer.json`) adds: + +| Port | Service | +| ---- | ----------------- | +| 6080 | noVNC Web Desktop | +| 5901 | VNC Client | + +## Container User + +The container runs as `codespace` with UID/GID 1001 (matches the Codespaces +convention and the previous universal base image). All workspace and cache +paths are accessed via Docker named volumes, not host bind mounts of source +files, so this UID does not need to match your host user. If you add a host +bind mount later and your host user shares UID 1001, be aware of the implicit +file-ownership overlap. ## Troubleshooting diff --git a/.devcontainer/devcontainer.json b/.devcontainer/devcontainer.json index f12643925..d0709645b 100644 --- a/.devcontainer/devcontainer.json +++ b/.devcontainer/devcontainer.json @@ -80,8 +80,8 @@ // - ANTHROPIC_API_KEY (for Claude Code) }, - "postCreateCommand": "cat .devcontainer/scripts/post-create.sh | tr -d '\\r' | bash", - "postStartCommand": "cat .devcontainer/scripts/post-start.sh | tr -d '\\r' | bash", + "postCreateCommand": "bash .devcontainer/scripts/post-create.sh", + "postStartCommand": "bash .devcontainer/scripts/post-start.sh", "customizations": { "vscode": { diff --git a/.devcontainer/scripts/post-create.sh b/.devcontainer/scripts/post-create.sh index a7a68de60..60e343ff8 100644 --- a/.devcontainer/scripts/post-create.sh +++ b/.devcontainer/scripts/post-create.sh @@ -167,8 +167,6 @@ if [[ -n "$CURRENT_GIT_NAME" ]]; then elif [[ -n "$DESIRED_GIT_NAME" ]]; then git config --global user.name "$DESIRED_GIT_NAME" echo " git user.name set" -else - echo " note: no LOCAL_GIT_USER_NAME provided" fi if [[ -n "$CURRENT_GIT_EMAIL" ]]; then @@ -176,8 +174,16 @@ if [[ -n "$CURRENT_GIT_EMAIL" ]]; then elif [[ -n "$DESIRED_GIT_EMAIL" ]]; then git config --global user.email "$DESIRED_GIT_EMAIL" echo " git user.email set" -else - echo " note: no LOCAL_GIT_USER_EMAIL provided" +fi + +if [[ -z "$CURRENT_GIT_NAME" && -z "$DESIRED_GIT_NAME" ]] || \ + [[ -z "$CURRENT_GIT_EMAIL" && -z "$DESIRED_GIT_EMAIL" ]]; then + echo "" + echo " Warning: no host git identity provided." + echo " Start the container via .devcontainer/scripts/start-devcontainer.sh" + echo " to inherit host ~/.gitconfig, or set it manually inside the container:" + echo " git config --global user.name \"Your Name\"" + echo " git config --global user.email \"you@example.com\"" fi # Install dependencies @@ -191,82 +197,7 @@ if ! pnpm install; then exit 1 fi -# Set up git hooks for lock file sync without clobbering existing hooks (for git-lfs compatibility) -echo "" -echo "Configuring TypeAgent git hook helpers..." - -HOOKS_DIR=$(git rev-parse --git-path hooks 2>/dev/null || true) -if [[ -n "$HOOKS_DIR" ]] && [[ -d "$HOOKS_DIR" ]]; then - TYPEAGENT_HOOK_DIR="$HOOKS_DIR/typeagent" - mkdir -p "$TYPEAGENT_HOOK_DIR" - - cat > "$TYPEAGENT_HOOK_DIR/post-checkout.sh" << 'EOF' -#!/bin/sh -PREV_HEAD=$1 -NEW_HEAD=$2 -BRANCH_CHECKOUT=$3 - -if [ "$BRANCH_CHECKOUT" != "1" ]; then exit 0; fi - -LOCKFILE_CHANGED=$(git diff "$PREV_HEAD" "$NEW_HEAD" --name-only 2>/dev/null | grep -c "pnpm-lock.yaml" || true) -if [ "$LOCKFILE_CHANGED" -gt 0 ]; then - REPO_ROOT=$(git rev-parse --show-toplevel 2>/dev/null || true) - if [ -n "$REPO_ROOT" ] && [ -d "$REPO_ROOT/ts" ]; then - echo "pnpm-lock.yaml changed. Running pnpm install..." - cd "$REPO_ROOT/ts" && pnpm install --frozen-lockfile - echo "Dependencies synchronized" - fi -fi -EOF - chmod +x "$TYPEAGENT_HOOK_DIR/post-checkout.sh" - - cat > "$TYPEAGENT_HOOK_DIR/post-merge.sh" << 'EOF' -#!/bin/sh -LOCKFILE_CHANGED=$(git diff HEAD@{1} HEAD --name-only 2>/dev/null | grep -c "pnpm-lock.yaml" || true) -if [ "$LOCKFILE_CHANGED" -gt 0 ]; then - REPO_ROOT=$(git rev-parse --show-toplevel 2>/dev/null || true) - if [ -n "$REPO_ROOT" ] && [ -d "$REPO_ROOT/ts" ]; then - echo "pnpm-lock.yaml changed after merge. Running pnpm install..." - cd "$REPO_ROOT/ts" && pnpm install --frozen-lockfile - echo "Dependencies synchronized" - fi -fi -EOF - chmod +x "$TYPEAGENT_HOOK_DIR/post-merge.sh" - - ensure_hook_chain() { - local hook_file=$1 - local helper_script=$2 - local marker="# TypeAgent dependency sync" - - if [[ ! -f "$hook_file" ]]; then - cat > "$hook_file" << 'EOF' -#!/bin/sh -EOF - chmod +x "$hook_file" - fi - - if ! grep -Fq "$marker" "$hook_file"; then - cat >> "$hook_file" << EOF - -$marker -HOOK_DIR="\$(cd "\$(dirname "\$0")" && pwd)" -if [ -x "\$HOOK_DIR/typeagent/$helper_script" ]; then - "\$HOOK_DIR/typeagent/$helper_script" "\$@" -fi -EOF - fi - } - - ensure_hook_chain "$HOOKS_DIR/post-checkout" "post-checkout.sh" - ensure_hook_chain "$HOOKS_DIR/post-merge" "post-merge.sh" - - echo "TypeAgent hook helpers installed (compatible with existing hooks)" -else - echo "Note: Could not resolve .git/hooks directory, skipping hook helper setup" -fi - -# ── Security hardening: restrict sudo to a minimal allowlist ────────── +# - Security hardening: restrict sudo to a minimal allowlist # During post-create we needed unrestricted root access to install # packages and fix volume ownership. Now that setup is done, replace # the blanket NOPASSWD:ALL rule with the narrowest set of commands the @@ -281,11 +212,15 @@ codespace ALL=(root) NOPASSWD: /usr/bin/apt-get update*, \ /usr/bin/apt-get install*, \ /usr/bin/apt-get upgrade*, \ /usr/bin/apt-get autoremove*, \ + /usr/bin/apt-get remove*, \ + /usr/bin/dpkg -i *, \ + /usr/bin/dpkg --configure *, \ /bin/chown *, \ /usr/bin/chown *, \ /bin/mkdir *, \ /usr/bin/mkdir *, \ - /usr/sbin/service ssh * + /usr/sbin/service ssh *, \ + /usr/sbin/service sshd * SUDOERS sudo chmod 0440 "$SUDOERS_FILE" # Remove the blanket rule that grants unrestricted root. The common-utils @@ -295,7 +230,7 @@ if [[ -f /etc/sudoers.d/codespace ]]; then sudo rm /etc/sudoers.d/codespace echo " Removed blanket NOPASSWD:ALL rule" fi -echo " Sudo restricted to: apt-get, chown, mkdir, service ssh" +echo " Sudo restricted to: apt-get, dpkg, chown, mkdir, service ssh" echo "" echo "╔══════════════════════════════════════════════════════════════╗" diff --git a/.devcontainer/scripts/setup-ssh-access.sh b/.devcontainer/scripts/setup-ssh-access.sh index 1e5195db0..0d8b8535a 100755 --- a/.devcontainer/scripts/setup-ssh-access.sh +++ b/.devcontainer/scripts/setup-ssh-access.sh @@ -307,38 +307,46 @@ ensure_ssh_config_block \ "$USER_KNOWN_HOSTS_FILE" \ "$GLOBAL_KNOWN_HOSTS_FILE" -if is_wsl && command -v cmd.exe >/dev/null 2>&1 && command -v wslpath >/dev/null 2>&1; then - WINDOWS_USERPROFILE_WIN=$(cmd.exe /C "echo %USERPROFILE%" 2>/dev/null | tr -d '\r' || true) - if [[ -n "$WINDOWS_USERPROFILE_WIN" ]] && [[ "$WINDOWS_USERPROFILE_WIN" != "%USERPROFILE%" ]]; then - WINDOWS_USERPROFILE_WSL=$(wslpath -u "$WINDOWS_USERPROFILE_WIN" 2>/dev/null || true) - if [[ -n "$WINDOWS_USERPROFILE_WSL" ]]; then - WINDOWS_SSH_DIR_WSL="$WINDOWS_USERPROFILE_WSL/.ssh" - WINDOWS_KEY_BASENAME=$(basename "$KEY_PATH") - WINDOWS_KEY_PATH_WSL="$WINDOWS_SSH_DIR_WSL/$WINDOWS_KEY_BASENAME" - WINDOWS_PUB_KEY_PATH_WSL="$WINDOWS_KEY_PATH_WSL.pub" - WINDOWS_CONFIG_PATH_WSL="$WINDOWS_SSH_DIR_WSL/config" - WINDOWS_KEY_PATH_CONFIG=$(wslpath -m "$WINDOWS_KEY_PATH_WSL" 2>/dev/null || true) - WINDOWS_KNOWN_HOSTS_CONFIG=$(wslpath -m "$WINDOWS_SSH_DIR_WSL/known_hosts" 2>/dev/null || true) - [[ -n "$WINDOWS_KEY_PATH_CONFIG" ]] || fail "Failed to convert WSL key path to Windows path" - [[ -n "$WINDOWS_KNOWN_HOSTS_CONFIG" ]] || fail "Failed to convert WSL known_hosts path to Windows path" - - if [[ $PRINT_ONLY -eq 1 ]]; then - log "Would copy keypair to Windows SSH dir: $WINDOWS_SSH_DIR_WSL" +if is_wsl; then + if ! command -v cmd.exe >/dev/null 2>&1 || ! command -v wslpath >/dev/null 2>&1; then + log "Warning: WSL detected but cmd.exe / wslpath unavailable; skipping Windows SSH sync" + else + WINDOWS_USERPROFILE_WIN=$(cmd.exe /C "echo %USERPROFILE%" 2>/dev/null | tr -d '\r' || true) + if [[ -z "$WINDOWS_USERPROFILE_WIN" ]] || [[ "$WINDOWS_USERPROFILE_WIN" == "%USERPROFILE%" ]]; then + log "Warning: could not resolve Windows %USERPROFILE%; skipping Windows SSH sync" + else + WINDOWS_USERPROFILE_WSL=$(wslpath -u "$WINDOWS_USERPROFILE_WIN" 2>/dev/null || true) + if [[ -z "$WINDOWS_USERPROFILE_WSL" ]]; then + log "Warning: wslpath could not translate %USERPROFILE% ($WINDOWS_USERPROFILE_WIN); skipping Windows SSH sync" else - mkdir -p "$WINDOWS_SSH_DIR_WSL" - cp -f "$KEY_PATH" "$WINDOWS_KEY_PATH_WSL" - cp -f "$PUB_KEY_PATH" "$WINDOWS_PUB_KEY_PATH_WSL" - log "Copied keypair to Windows SSH dir: $WINDOWS_SSH_DIR_WSL" + WINDOWS_SSH_DIR_WSL="$WINDOWS_USERPROFILE_WSL/.ssh" + WINDOWS_KEY_BASENAME=$(basename "$KEY_PATH") + WINDOWS_KEY_PATH_WSL="$WINDOWS_SSH_DIR_WSL/$WINDOWS_KEY_BASENAME" + WINDOWS_PUB_KEY_PATH_WSL="$WINDOWS_KEY_PATH_WSL.pub" + WINDOWS_CONFIG_PATH_WSL="$WINDOWS_SSH_DIR_WSL/config" + WINDOWS_KEY_PATH_CONFIG=$(wslpath -m "$WINDOWS_KEY_PATH_WSL" 2>/dev/null || true) + WINDOWS_KNOWN_HOSTS_CONFIG=$(wslpath -m "$WINDOWS_SSH_DIR_WSL/known_hosts" 2>/dev/null || true) + [[ -n "$WINDOWS_KEY_PATH_CONFIG" ]] || fail "Failed to convert WSL key path to Windows path" + [[ -n "$WINDOWS_KNOWN_HOSTS_CONFIG" ]] || fail "Failed to convert WSL known_hosts path to Windows path" + + if [[ $PRINT_ONLY -eq 1 ]]; then + log "Would copy keypair to Windows SSH dir: $WINDOWS_SSH_DIR_WSL" + else + mkdir -p "$WINDOWS_SSH_DIR_WSL" + cp -f "$KEY_PATH" "$WINDOWS_KEY_PATH_WSL" + cp -f "$PUB_KEY_PATH" "$WINDOWS_PUB_KEY_PATH_WSL" + log "Copied keypair to Windows SSH dir: $WINDOWS_SSH_DIR_WSL" + fi + + log "Writing Windows SSH config block for $HOST_ALIAS" + ensure_ssh_config_block \ + "$WINDOWS_CONFIG_PATH_WSL" \ + "$HOST_ALIAS" \ + "$WINDOWS_KEY_PATH_CONFIG" \ + "$STRICT_HOST_KEY_CHECKING" \ + "$WINDOWS_KNOWN_HOSTS_CONFIG" \ + "none" fi - - log "Writing Windows SSH config block for $HOST_ALIAS" - ensure_ssh_config_block \ - "$WINDOWS_CONFIG_PATH_WSL" \ - "$HOST_ALIAS" \ - "$WINDOWS_KEY_PATH_CONFIG" \ - "$STRICT_HOST_KEY_CHECKING" \ - "$WINDOWS_KNOWN_HOSTS_CONFIG" \ - "none" fi fi fi diff --git a/.devcontainer/vnc/devcontainer.json b/.devcontainer/vnc/devcontainer.json index 6775a845f..0209a37b0 100644 --- a/.devcontainer/vnc/devcontainer.json +++ b/.devcontainer/vnc/devcontainer.json @@ -32,6 +32,9 @@ "version": "3.12", "installTools": true }, + "ghcr.io/devcontainers/features/dotnet:2": { + "version": "8.0" + }, "ghcr.io/devcontainers/features/azure-cli:1": {}, "ghcr.io/devcontainers/features/github-cli:1": {}, "ghcr.io/devcontainers/features/common-utils:2": { @@ -44,14 +47,19 @@ } }, - "forwardPorts": [6080, 5901, 8999, 8081, 8082, 3000], + // Publish SSH port on loopback only so host-side `ssh typeagent-devcontainer` + // works without exposing 2222 on the LAN. See standard devcontainer.json for details. + "appPort": ["127.0.0.1:2222:2222"], + + "forwardPorts": [6080, 5901, 8999, 8081, 8082, 3000, 3443], "portsAttributes": { "6080": { "label": "noVNC Web Desktop", "onAutoForward": "openBrowser" }, "5901": { "label": "VNC Client" }, "8999": { "label": "Agent Server (WebSocket)", "onAutoForward": "notify" }, "8081": { "label": "Browser Agent (WebSocket)", "onAutoForward": "notify" }, "8082": { "label": "Code Agent (WebSocket)", "onAutoForward": "notify" }, - "3000": { "label": "API Server (HTTP)", "onAutoForward": "notify" } + "3000": { "label": "API Server (HTTP)", "onAutoForward": "notify" }, + "3443": { "label": "API Server (HTTPS)", "onAutoForward": "notify" } }, "mounts": [ @@ -67,8 +75,8 @@ "LOCAL_GIT_USER_EMAIL": "${localEnv:LOCAL_GIT_USER_EMAIL}" }, - "postCreateCommand": "cat .devcontainer/scripts/post-create.sh | tr -d '\\r' | bash && cat .devcontainer/scripts/install-electron-deps.sh | tr -d '\\r' | bash", - "postStartCommand": "cat .devcontainer/scripts/post-start.sh | tr -d '\\r' | bash", + "postCreateCommand": "bash .devcontainer/scripts/post-create.sh && bash .devcontainer/scripts/install-electron-deps.sh", + "postStartCommand": "bash .devcontainer/scripts/post-start.sh", "customizations": { "vscode": { @@ -76,7 +84,10 @@ "dbaeumer.vscode-eslint", "esbenp.prettier-vscode", "ms-python.python", - "github.copilot" + "ms-dotnettools.csharp", + "ms-azuretools.vscode-azurefunctions", + "github.copilot", + "humao.rest-client" ] } }, From c5f5e69246c028ed36d8442409ad19b4bcd7109f Mon Sep 17 00:00:00 2001 From: Curtis Man Date: Sat, 16 May 2026 00:45:54 -0700 Subject: [PATCH 5/5] lint --- .devcontainer/scripts/setup-ssh-access.sh | 3 +++ .devcontainer/scripts/start-devcontainer.sh | 3 +++ 2 files changed, 6 insertions(+) diff --git a/.devcontainer/scripts/setup-ssh-access.sh b/.devcontainer/scripts/setup-ssh-access.sh index 0d8b8535a..7917c8766 100755 --- a/.devcontainer/scripts/setup-ssh-access.sh +++ b/.devcontainer/scripts/setup-ssh-access.sh @@ -1,4 +1,7 @@ #!/usr/bin/env bash +# Copyright (c) Microsoft Corporation. +# Licensed under the MIT License. + set -euo pipefail SCRIPT_DIR=$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")" && pwd) diff --git a/.devcontainer/scripts/start-devcontainer.sh b/.devcontainer/scripts/start-devcontainer.sh index d7bae8170..029fe787b 100755 --- a/.devcontainer/scripts/start-devcontainer.sh +++ b/.devcontainer/scripts/start-devcontainer.sh @@ -1,4 +1,7 @@ #!/usr/bin/env bash +# Copyright (c) Microsoft Corporation. +# Licensed under the MIT License. + set -euo pipefail SCRIPT_DIR=$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")" && pwd)