From fef00128531312c741ed405f43d710b2d8ac713c Mon Sep 17 00:00:00 2001 From: skulidropek <66840575+skulidropek@users.noreply.github.com> Date: Sun, 15 Feb 2026 10:02:54 +0000 Subject: [PATCH 1/7] chore(state): update octocat/hello-world --- .e2e/bin/ssh | 6 + .e2e/project.env | 3 + .../octocat/hello-world/issue-1/.dockerignore | 3 + .../octocat/hello-world/issue-1/.gitignore | 9 + .../octocat/hello-world/issue-1/Dockerfile | 172 ++++ .../hello-world/issue-1/docker-compose.yml | 32 + .../hello-world/issue-1/docker-git.json | 22 + .../octocat/hello-world/issue-1/entrypoint.sh | 836 ++++++++++++++++++ .github/workflows/check.yml | 11 + AGENTS.md | 9 + package.json | 5 +- .../app/src/docker-git/cli/parser-clone.ts | 3 +- .../app/src/docker-git/cli/parser-options.ts | 2 + packages/app/src/docker-git/cli/usage.ts | 1 + packages/app/tests/docker-git/parser.test.ts | 16 + packages/docker-git/src/server/http.ts | 2 + packages/lib/src/core/command-builders.ts | 2 + packages/lib/src/core/command-options.ts | 1 + packages/lib/src/core/domain.ts | 1 + .../src/usecases/actions/create-project.ts | 80 +- .../usecases/create-project-open-ssh.test.ts | 180 ++++ packages/web/scripts/terminal-ws.mjs | 1 + .../@ton-ai-core__vibecode-linter@1.0.6.patch | 15 + pnpm-lock.yaml | 11 +- scripts/e2e/ci.sh | 81 ++ 25 files changed, 1498 insertions(+), 6 deletions(-) create mode 100755 .e2e/bin/ssh create mode 100644 .e2e/project.env create mode 100644 .e2e/projects/octocat/hello-world/issue-1/.dockerignore create mode 100644 .e2e/projects/octocat/hello-world/issue-1/.gitignore create mode 100644 .e2e/projects/octocat/hello-world/issue-1/Dockerfile create mode 100644 .e2e/projects/octocat/hello-world/issue-1/docker-compose.yml create mode 100644 .e2e/projects/octocat/hello-world/issue-1/docker-git.json create mode 100755 .e2e/projects/octocat/hello-world/issue-1/entrypoint.sh create mode 100644 packages/lib/tests/usecases/create-project-open-ssh.test.ts create mode 100644 patches/@ton-ai-core__vibecode-linter@1.0.6.patch create mode 100644 scripts/e2e/ci.sh diff --git a/.e2e/bin/ssh b/.e2e/bin/ssh new file mode 100755 index 00000000..81c4a4f2 --- /dev/null +++ b/.e2e/bin/ssh @@ -0,0 +1,6 @@ +#!/usr/bin/env bash +set -euo pipefail + +: "${SSH_LOG_PATH:?SSH_LOG_PATH is required}" +printf "ssh %s\n" "$*" >> "$SSH_LOG_PATH" +exit 0 diff --git a/.e2e/project.env b/.e2e/project.env new file mode 100644 index 00000000..4d7783c6 --- /dev/null +++ b/.e2e/project.env @@ -0,0 +1,3 @@ +# Keep CI fast and deterministic (Codex auto-update hits the network on container start) +CODEX_AUTO_UPDATE=0 +CODEX_SHARE_AUTH=0 diff --git a/.e2e/projects/octocat/hello-world/issue-1/.dockerignore b/.e2e/projects/octocat/hello-world/issue-1/.dockerignore new file mode 100644 index 00000000..c3c63f6c --- /dev/null +++ b/.e2e/projects/octocat/hello-world/issue-1/.dockerignore @@ -0,0 +1,3 @@ +# docker-git build context +.orch/ +authorized_keys diff --git a/.e2e/projects/octocat/hello-world/issue-1/.gitignore b/.e2e/projects/octocat/hello-world/issue-1/.gitignore new file mode 100644 index 00000000..c02cca46 --- /dev/null +++ b/.e2e/projects/octocat/hello-world/issue-1/.gitignore @@ -0,0 +1,9 @@ +# docker-git project files +# NOTE: this directory is intended to be committed to the docker-git state repository. +# It intentionally does not ignore .orch/ or auth files; keep the state repo private. + +# Volatile Codex artifacts (do not commit) +.orch/auth/codex/log/ +.orch/auth/codex/tmp/ +.orch/auth/codex/sessions/ +.orch/auth/codex/models_cache.json diff --git a/.e2e/projects/octocat/hello-world/issue-1/Dockerfile b/.e2e/projects/octocat/hello-world/issue-1/Dockerfile new file mode 100644 index 00000000..4faec5bd --- /dev/null +++ b/.e2e/projects/octocat/hello-world/issue-1/Dockerfile @@ -0,0 +1,172 @@ +FROM ubuntu:24.04 + +ENV DEBIAN_FRONTEND=noninteractive +ENV NVM_DIR=/usr/local/nvm + +RUN apt-get update && apt-get install -y --no-install-recommends openssh-server git gh ca-certificates curl unzip bsdutils sudo make docker.io docker-compose-v2 bash-completion zsh zsh-autosuggestions xauth ncurses-term && rm -rf /var/lib/apt/lists/* + +# Passwordless sudo for all users (container is disposable) +RUN printf "%s\n" "ALL ALL=(ALL) NOPASSWD:ALL" > /etc/sudoers.d/zz-all && chmod 0440 /etc/sudoers.d/zz-all + +# Shell prompt: show git branch for interactive sessions +RUN cat <<'EOF' > /etc/profile.d/zz-prompt.sh +docker_git_branch() { git rev-parse --abbrev-ref HEAD 2>/dev/null; } +docker_git_prompt_apply() { + local b + b="$(docker_git_branch)" + local base="[\t] \w" + if [ -n "$b" ]; then + PS1="${base} (${b})> " + else + PS1="${base}> " + fi +} +if [ -n "$PROMPT_COMMAND" ]; then + PROMPT_COMMAND="docker_git_prompt_apply;$PROMPT_COMMAND" +else + PROMPT_COMMAND="docker_git_prompt_apply" +fi +EOF +RUN chmod 0644 /etc/profile.d/zz-prompt.sh +RUN printf "%s\n" \ + "if [ -f /etc/profile.d/zz-prompt.sh ]; then . /etc/profile.d/zz-prompt.sh; fi" \ + >> /etc/bash.bashrc +RUN cat <<'EOF' > /etc/profile.d/zz-bash-completion.sh +if ! shopt -oq posix; then + if [ -f /usr/share/bash-completion/bash_completion ]; then + . /usr/share/bash-completion/bash_completion + elif [ -f /etc/bash_completion ]; then + . /etc/bash_completion + fi +fi +EOF +RUN chmod 0644 /etc/profile.d/zz-bash-completion.sh +RUN printf "%s\n" \ + "if [ -f /etc/profile.d/zz-bash-completion.sh ]; then . /etc/profile.d/zz-bash-completion.sh; fi" \ + >> /etc/bash.bashrc +RUN cat <<'EOF' > /etc/profile.d/zz-bash-history.sh +if [ -n "$BASH_VERSION" ]; then + case "$-" in + *i*) + HISTFILE="${HISTFILE:-$HOME/.bash_history}" + HISTSIZE="${HISTSIZE:-10000}" + HISTFILESIZE="${HISTFILESIZE:-20000}" + HISTCONTROL="${HISTCONTROL:-ignoredups:erasedups}" + export HISTFILE HISTSIZE HISTFILESIZE HISTCONTROL + shopt -s histappend + if [ -n "${PROMPT_COMMAND-}" ]; then + PROMPT_COMMAND="history -a; ${PROMPT_COMMAND}" + else + PROMPT_COMMAND="history -a" + fi + ;; + esac +fi +EOF +RUN chmod 0644 /etc/profile.d/zz-bash-history.sh +RUN printf "%s\n" \ + "if [ -f /etc/profile.d/zz-bash-history.sh ]; then . /etc/profile.d/zz-bash-history.sh; fi" \ + >> /etc/bash.bashrc +RUN mkdir -p /etc/zsh +RUN cat <<'EOF' > /etc/zsh/zshrc +setopt PROMPT_SUBST + +# Terminal compatibility: if terminfo for $TERM is missing (common over SSH), +# fall back to xterm-256color so ZLE doesn't garble the display. +if command -v infocmp >/dev/null 2>&1; then + if ! infocmp "$TERM" >/dev/null 2>&1; then + export TERM=xterm-256color + fi +fi + +autoload -Uz compinit +compinit + +# Completion UX: cycle matches instead of listing them into scrollback. +setopt AUTO_MENU +setopt MENU_COMPLETE +unsetopt AUTO_LIST +unsetopt LIST_BEEP + +# Command completion ordering: prefer real commands/builtins over internal helper functions. +zstyle ':completion:*' tag-order builtins commands aliases reserved-words functions + +autoload -Uz add-zsh-hook +docker_git_branch() { git rev-parse --abbrev-ref HEAD 2>/dev/null; } +docker_git_prompt_apply() { + local b + b="$(docker_git_branch)" + local base="[%*] %~" + if [[ -n "$b" ]]; then + PROMPT="$base ($b)> " + else + PROMPT="$base> " + fi +} +add-zsh-hook precmd docker_git_prompt_apply + +HISTFILE="${HISTFILE:-$HOME/.zsh_history}" +HISTSIZE="${HISTSIZE:-10000}" +SAVEHIST="${SAVEHIST:-20000}" +setopt HIST_IGNORE_ALL_DUPS +setopt SHARE_HISTORY +setopt INC_APPEND_HISTORY + +if [ -f "$HISTFILE" ]; then + fc -R "$HISTFILE" 2>/dev/null || true +fi +if [ -f "$HOME/.bash_history" ] && [ "$HISTFILE" != "$HOME/.bash_history" ]; then + fc -R "$HOME/.bash_history" 2>/dev/null || true +fi + +bindkey '^[[A' history-search-backward +bindkey '^[[B' history-search-forward + +if [[ "${DOCKER_GIT_ZSH_AUTOSUGGEST:-1}" == "1" ]] && [ -f /usr/share/zsh-autosuggestions/zsh-autosuggestions.zsh ]; then + # Suggest from history first, then fall back to completion (commands + paths). + # This gives "ghost text" suggestions without needing to press . + ZSH_AUTOSUGGEST_HIGHLIGHT_STYLE="${DOCKER_GIT_ZSH_AUTOSUGGEST_STYLE:-fg=8,italic}" + if [[ -n "${DOCKER_GIT_ZSH_AUTOSUGGEST_STRATEGY-}" ]]; then + ZSH_AUTOSUGGEST_STRATEGY=(${=DOCKER_GIT_ZSH_AUTOSUGGEST_STRATEGY}) + else + ZSH_AUTOSUGGEST_STRATEGY=(history completion) + fi + source /usr/share/zsh-autosuggestions/zsh-autosuggestions.zsh +fi +EOF + +# Tooling: Node 24 (NodeSource) + nvm +RUN curl -fsSL https://deb.nodesource.com/setup_24.x | bash - && apt-get install -y --no-install-recommends nodejs && node -v && npm -v && corepack --version && rm -rf /var/lib/apt/lists/* +RUN mkdir -p /usr/local/nvm && curl -fsSL https://raw.githubusercontent.com/nvm-sh/nvm/v0.40.1/install.sh | bash +RUN printf "export NVM_DIR=/usr/local/nvm\n[ -s /usr/local/nvm/nvm.sh ] && . /usr/local/nvm/nvm.sh\n" > /etc/profile.d/nvm.sh && chmod 0644 /etc/profile.d/nvm.sh + +# Tooling: pnpm + Codex CLI (bun) +RUN corepack enable && corepack prepare pnpm@10.27.0 --activate +ENV BUN_INSTALL=/usr/local/bun +ENV TERM=xterm-256color +ENV PATH="/usr/local/bun/bin:$PATH" +RUN curl -fsSL https://bun.sh/install | bash +RUN ln -sf /usr/local/bun/bin/bun /usr/local/bin/bun +RUN script -q -e -c "bun add -g @openai/codex@latest" /dev/null +RUN ln -sf /usr/local/bun/bin/codex /usr/local/bin/codex +RUN printf "export BUN_INSTALL=/usr/local/bun\nexport PATH=/usr/local/bun/bin:$PATH\n" > /etc/profile.d/bun.sh && chmod 0644 /etc/profile.d/bun.sh + +# Create non-root user for SSH (align UID/GID with host user 1000) +RUN if id -u ubuntu >/dev/null 2>&1; then if getent group 1000 >/dev/null 2>&1; then EXISTING_GROUP="$(getent group 1000 | cut -d: -f1)"; if [ "$EXISTING_GROUP" != "dev" ]; then groupmod -n dev "$EXISTING_GROUP" || true; fi; fi; usermod -l dev -d /home/dev -m -s /usr/bin/zsh ubuntu || true; fi +RUN if id -u dev >/dev/null 2>&1; then usermod -u 1000 -g 1000 -o dev; else groupadd -g 1000 dev || true; useradd -m -s /usr/bin/zsh -u 1000 -g 1000 -o dev; fi +RUN printf "%s\n" "dev ALL=(ALL) NOPASSWD:ALL" > /etc/sudoers.d/dev && chmod 0440 /etc/sudoers.d/dev + +# sshd runtime dir +RUN mkdir -p /run/sshd + +# Harden sshd: disable password auth and root login +RUN printf "%s\n" "PasswordAuthentication no" "PermitRootLogin no" "PubkeyAuthentication yes" "X11Forwarding yes" "X11UseLocalhost yes" "PermitUserEnvironment yes" "AllowUsers dev" > /etc/ssh/sshd_config.d/dev.conf + +# Workspace path (supports root-level dirs like /repo) +RUN mkdir -p /home/dev/octocat/hello-world/issue-1 && chown -R 1000:1000 /home/dev && if [ "/home/dev/octocat/hello-world/issue-1" != "/" ]; then chown -R 1000:1000 "/home/dev/octocat/hello-world/issue-1"; fi + +COPY entrypoint.sh /entrypoint.sh +RUN chmod +x /entrypoint.sh + +EXPOSE 22 +ENTRYPOINT ["/entrypoint.sh"] \ No newline at end of file diff --git a/.e2e/projects/octocat/hello-world/issue-1/docker-compose.yml b/.e2e/projects/octocat/hello-world/issue-1/docker-compose.yml new file mode 100644 index 00000000..81f665dc --- /dev/null +++ b/.e2e/projects/octocat/hello-world/issue-1/docker-compose.yml @@ -0,0 +1,32 @@ +services: + dg-hello-world-issue-1: + build: . + container_name: dg-hello-world-issue-1 + environment: + REPO_URL: "https://github.com/octocat/Hello-World.git" + REPO_REF: "issue-1" + FORK_REPO_URL: "" + TARGET_DIR: "/home/dev/octocat/hello-world/issue-1" + CODEX_HOME: "/home/dev/.codex" + env_file: + - ./.orch/env/global.env + - ../../../../project.env + ports: + - "127.0.0.1:34623:22" + volumes: + - dg-hello-world-issue-1-home:/home/dev + - ../../..:/home/dev/.docker-git + - ../../../authorized_keys:/authorized_keys:ro + - ./.orch/auth/codex:/home/dev/.codex + - ../../../.orch/auth/codex:/home/dev/.codex-shared + - /var/run/docker.sock:/var/run/docker.sock + networks: + - dg-hello-world-issue-1-net + + +networks: + dg-hello-world-issue-1-net: + driver: bridge + +volumes: + dg-hello-world-issue-1-home: diff --git a/.e2e/projects/octocat/hello-world/issue-1/docker-git.json b/.e2e/projects/octocat/hello-world/issue-1/docker-git.json new file mode 100644 index 00000000..56f844e1 --- /dev/null +++ b/.e2e/projects/octocat/hello-world/issue-1/docker-git.json @@ -0,0 +1,22 @@ +{ + "schemaVersion": 1, + "template": { + "containerName": "dg-hello-world-issue-1", + "serviceName": "dg-hello-world-issue-1", + "sshUser": "dev", + "sshPort": 34623, + "repoUrl": "https://github.com/octocat/Hello-World.git", + "repoRef": "issue-1", + "targetDir": "/home/dev/octocat/hello-world/issue-1", + "volumeName": "dg-hello-world-issue-1-home", + "dockerGitPath": "../../..", + "authorizedKeysPath": "../../../authorized_keys", + "envGlobalPath": "./.orch/env/global.env", + "envProjectPath": "../../../../project.env", + "codexAuthPath": "./.orch/auth/codex", + "codexSharedAuthPath": "../../../.orch/auth/codex", + "codexHome": "/home/dev/.codex", + "enableMcpPlaywright": false, + "pnpmVersion": "10.27.0" + } +} diff --git a/.e2e/projects/octocat/hello-world/issue-1/entrypoint.sh b/.e2e/projects/octocat/hello-world/issue-1/entrypoint.sh new file mode 100755 index 00000000..f9799cc2 --- /dev/null +++ b/.e2e/projects/octocat/hello-world/issue-1/entrypoint.sh @@ -0,0 +1,836 @@ +#!/usr/bin/env bash +set -euo pipefail + +REPO_URL="${REPO_URL:-}" +REPO_REF="${REPO_REF:-}" +FORK_REPO_URL="${FORK_REPO_URL:-}" +TARGET_DIR="${TARGET_DIR:-/home/dev/octocat/hello-world/issue-1}" +GIT_AUTH_USER="${GIT_AUTH_USER:-${GITHUB_USER:-x-access-token}}" +GIT_AUTH_TOKEN="${GIT_AUTH_TOKEN:-${GITHUB_TOKEN:-${GH_TOKEN:-}}}" +GH_TOKEN="${GH_TOKEN:-${GIT_AUTH_TOKEN:-}}" +GITHUB_TOKEN="${GITHUB_TOKEN:-${GH_TOKEN:-}}" +GIT_USER_NAME="${GIT_USER_NAME:-}" +GIT_USER_EMAIL="${GIT_USER_EMAIL:-}" +CODEX_AUTO_UPDATE="${CODEX_AUTO_UPDATE:-1}" +MCP_PLAYWRIGHT_ENABLE="${MCP_PLAYWRIGHT_ENABLE:-0}" +MCP_PLAYWRIGHT_CDP_ENDPOINT="${MCP_PLAYWRIGHT_CDP_ENDPOINT:-}" +MCP_PLAYWRIGHT_ISOLATED="${MCP_PLAYWRIGHT_ISOLATED:-1}" + +# 1) Authorized keys are mounted from host at /authorized_keys +mkdir -p /home/dev/.ssh +chmod 700 /home/dev/.ssh + +if [[ -f /authorized_keys ]]; then + cp /authorized_keys /home/dev/.ssh/authorized_keys + chmod 600 /home/dev/.ssh/authorized_keys +fi + +chown -R 1000:1000 /home/dev/.ssh + +# Ensure Codex home exists if mounted +mkdir -p /home/dev/.codex +chown -R 1000:1000 /home/dev/.codex + +# Ensure home ownership matches the dev UID/GID (volumes may be stale) +HOME_OWNER="$(stat -c "%u:%g" /home/dev 2>/dev/null || echo "")" +if [[ "$HOME_OWNER" != "1000:1000" ]]; then + chown -R 1000:1000 /home/dev || true +fi + +# Share Codex auth.json across projects (avoids refresh_token_reused) +CODEX_SHARE_AUTH="${CODEX_SHARE_AUTH:-1}" +if [[ "$CODEX_SHARE_AUTH" == "1" ]]; then + CODEX_SHARED_HOME="/home/dev/.codex-shared" + mkdir -p "$CODEX_SHARED_HOME" + chown -R 1000:1000 "$CODEX_SHARED_HOME" || true + + AUTH_FILE="/home/dev/.codex/auth.json" + SHARED_AUTH_FILE="$CODEX_SHARED_HOME/auth.json" + + # Guard against a bad bind mount creating a directory at auth.json. + if [[ -d "$AUTH_FILE" ]]; then + mv "$AUTH_FILE" "$AUTH_FILE.bak-$(date +%s)" || true + fi + if [[ -e "$AUTH_FILE" && ! -L "$AUTH_FILE" ]]; then + rm -f "$AUTH_FILE" || true + fi + + ln -sf "$SHARED_AUTH_FILE" "$AUTH_FILE" +fi + +# Bootstrap ~/.docker-git for nested docker-git usage inside this container. +DOCKER_GIT_HOME="/home/dev/.docker-git" +DOCKER_GIT_AUTH_DIR="$DOCKER_GIT_HOME/.orch/auth/codex" +DOCKER_GIT_ENV_DIR="$DOCKER_GIT_HOME/.orch/env" +DOCKER_GIT_ENV_GLOBAL="$DOCKER_GIT_ENV_DIR/global.env" +DOCKER_GIT_ENV_PROJECT="$DOCKER_GIT_ENV_DIR/project.env" +DOCKER_GIT_AUTH_KEYS="$DOCKER_GIT_HOME/authorized_keys" + +mkdir -p "$DOCKER_GIT_AUTH_DIR" "$DOCKER_GIT_ENV_DIR" "$DOCKER_GIT_HOME/.orch/auth/gh" + +if [[ -f "/home/dev/.ssh/authorized_keys" ]]; then + cp "/home/dev/.ssh/authorized_keys" "$DOCKER_GIT_AUTH_KEYS" +elif [[ -f /authorized_keys ]]; then + cp /authorized_keys "$DOCKER_GIT_AUTH_KEYS" +fi +if [[ -f "$DOCKER_GIT_AUTH_KEYS" ]]; then + chmod 600 "$DOCKER_GIT_AUTH_KEYS" || true +fi + +if [[ ! -f "$DOCKER_GIT_ENV_GLOBAL" ]]; then + cat <<'EOF' > "$DOCKER_GIT_ENV_GLOBAL" +# docker-git env +# KEY=value +EOF +fi +if [[ ! -f "$DOCKER_GIT_ENV_PROJECT" ]]; then + cat <<'EOF' > "$DOCKER_GIT_ENV_PROJECT" +# docker-git project env defaults +CODEX_SHARE_AUTH=1 +CODEX_AUTO_UPDATE=1 +DOCKER_GIT_ZSH_AUTOSUGGEST=1 +DOCKER_GIT_ZSH_AUTOSUGGEST_STYLE=fg=8,italic +DOCKER_GIT_ZSH_AUTOSUGGEST_STRATEGY=history completion +MCP_PLAYWRIGHT_ISOLATED=1 +EOF +fi + +upsert_env_var() { + local file="$1" + local key="$2" + local value="$3" + local tmp + tmp="$(mktemp)" + awk -v key="$key" 'index($0, key "=") != 1 { print }' "$file" > "$tmp" + printf "%s=%s\n" "$key" "$value" >> "$tmp" + mv "$tmp" "$file" +} + +copy_if_distinct_file() { + local source="$1" + local target="$2" + if [[ ! -f "$source" ]]; then + return 1 + fi + local source_real="" + local target_real="" + source_real="$(readlink -f "$source" 2>/dev/null || true)" + target_real="$(readlink -f "$target" 2>/dev/null || true)" + if [[ -n "$source_real" && -n "$target_real" && "$source_real" == "$target_real" ]]; then + return 0 + fi + cp "$source" "$target" + return 0 +} + +if [[ -n "$GH_TOKEN" ]]; then + upsert_env_var "$DOCKER_GIT_ENV_GLOBAL" "GH_TOKEN" "$GH_TOKEN" +fi +if [[ -n "$GITHUB_TOKEN" ]]; then + upsert_env_var "$DOCKER_GIT_ENV_GLOBAL" "GITHUB_TOKEN" "$GITHUB_TOKEN" +elif [[ -n "$GH_TOKEN" ]]; then + upsert_env_var "$DOCKER_GIT_ENV_GLOBAL" "GITHUB_TOKEN" "$GH_TOKEN" +fi + +SOURCE_CODEX_CONFIG="/home/dev/.codex/config.toml" +copy_if_distinct_file "$SOURCE_CODEX_CONFIG" "$DOCKER_GIT_AUTH_DIR/config.toml" || true + +SOURCE_SHARED_AUTH="/home/dev/.codex-shared/auth.json" +SOURCE_LOCAL_AUTH="/home/dev/.codex/auth.json" +if [[ -f "$SOURCE_SHARED_AUTH" ]]; then + copy_if_distinct_file "$SOURCE_SHARED_AUTH" "$DOCKER_GIT_AUTH_DIR/auth.json" || true +elif [[ -f "$SOURCE_LOCAL_AUTH" ]]; then + copy_if_distinct_file "$SOURCE_LOCAL_AUTH" "$DOCKER_GIT_AUTH_DIR/auth.json" || true +fi +if [[ -f "$DOCKER_GIT_AUTH_DIR/auth.json" ]]; then + chmod 600 "$DOCKER_GIT_AUTH_DIR/auth.json" || true +fi + +chown -R 1000:1000 "$DOCKER_GIT_HOME" || true + +# Optional: configure Playwright MCP for Codex (browser automation) +CODEX_CONFIG_FILE="/home/dev/.codex/config.toml" + +# Keep config.toml consistent with the container build. +# If Playwright MCP is disabled for this container, remove the block so Codex +# doesn't try (and fail) to spawn docker-git-playwright-mcp. +if [[ "$MCP_PLAYWRIGHT_ENABLE" != "1" ]]; then + if [[ -f "$CODEX_CONFIG_FILE" ]] && grep -q "^\[mcp_servers\.playwright" "$CODEX_CONFIG_FILE" 2>/dev/null; then + awk ' + BEGIN { skip=0 } + /^# docker-git: Playwright MCP/ { next } + /^\[mcp_servers[.]playwright([.]|\])/ { skip=1; next } + skip==1 && /^\[/ { skip=0 } + skip==0 { print } + ' "$CODEX_CONFIG_FILE" > "$CODEX_CONFIG_FILE.tmp" + mv "$CODEX_CONFIG_FILE.tmp" "$CODEX_CONFIG_FILE" + fi +else + if [[ ! -f "$CODEX_CONFIG_FILE" ]]; then + mkdir -p "$(dirname "$CODEX_CONFIG_FILE")" || true + cat <<'EOF' > "$CODEX_CONFIG_FILE" +# docker-git codex config +model = "gpt-5.3-codex" +model_reasoning_effort = "xhigh" +personality = "pragmatic" + +approval_policy = "never" +sandbox_mode = "danger-full-access" +web_search = "live" + +[features] +shell_snapshot = true +collab = true +apps = true +shell_tool = true +EOF + chown 1000:1000 "$CODEX_CONFIG_FILE" || true + fi + + if [[ -z "$MCP_PLAYWRIGHT_CDP_ENDPOINT" ]]; then + MCP_PLAYWRIGHT_CDP_ENDPOINT="http://dg-hello-world-issue-1-browser:9223" + fi + + # Replace the docker-git Playwright block to allow upgrades via --force without manual edits. + if grep -q "^\[mcp_servers\.playwright" "$CODEX_CONFIG_FILE" 2>/dev/null; then + awk ' + BEGIN { skip=0 } + /^# docker-git: Playwright MCP/ { next } + /^\[mcp_servers[.]playwright([.]|\])/ { skip=1; next } + skip==1 && /^\[/ { skip=0 } + skip==0 { print } + ' "$CODEX_CONFIG_FILE" > "$CODEX_CONFIG_FILE.tmp" + mv "$CODEX_CONFIG_FILE.tmp" "$CODEX_CONFIG_FILE" + fi + + cat <> "$CODEX_CONFIG_FILE" + +# docker-git: Playwright MCP (connects to Chromium via CDP) +[mcp_servers.playwright] +command = "docker-git-playwright-mcp" +args = [] +EOF +fi + +# Prefer zsh for dev when available +if command -v zsh >/dev/null 2>&1; then + usermod -s /usr/bin/zsh dev || true +fi + +# Ensure dev has a zshrc and disable newuser wizard +ZSHENV_PATH="/etc/zsh/zshenv" +if [[ -f "$ZSHENV_PATH" ]]; then + if ! grep -q "ZSH_DISABLE_NEWUSER_INSTALL" "$ZSHENV_PATH"; then + printf "%s\n" "export ZSH_DISABLE_NEWUSER_INSTALL=1" >> "$ZSHENV_PATH" + fi +else + printf "%s\n" "export ZSH_DISABLE_NEWUSER_INSTALL=1" > "$ZSHENV_PATH" +fi +USER_ZSHRC="/home/dev/.zshrc" +if [[ ! -f "$USER_ZSHRC" ]]; then + cat <<'EOF' > "$USER_ZSHRC" +# docker-git default zshrc +if [ -f /etc/zsh/zshrc ]; then + source /etc/zsh/zshrc +fi +EOF + chown 1000:1000 "$USER_ZSHRC" || true +fi + +# Ensure docker-git prompt is configured for interactive shells +PROMPT_PATH="/etc/profile.d/zz-prompt.sh" +if [[ ! -s "$PROMPT_PATH" ]]; then + cat <<'EOF' > "$PROMPT_PATH" +docker_git_branch() { git rev-parse --abbrev-ref HEAD 2>/dev/null; } +docker_git_prompt_apply() { + local b + b="$(docker_git_branch)" + local base="[\t] \w" + if [ -n "$b" ]; then + PS1="${base} (${b})> " + else + PS1="${base}> " + fi +} +if [ -n "$PROMPT_COMMAND" ]; then + PROMPT_COMMAND="docker_git_prompt_apply;$PROMPT_COMMAND" +else + PROMPT_COMMAND="docker_git_prompt_apply" +fi +EOF + chmod 0644 "$PROMPT_PATH" +fi +if ! grep -q "zz-prompt.sh" /etc/bash.bashrc 2>/dev/null; then + printf "%s\n" "if [ -f /etc/profile.d/zz-prompt.sh ]; then . /etc/profile.d/zz-prompt.sh; fi" >> /etc/bash.bashrc +fi + +# Ensure bash completion is configured for interactive shells +COMPLETION_PATH="/etc/profile.d/zz-bash-completion.sh" +if [[ ! -s "$COMPLETION_PATH" ]]; then + cat <<'EOF' > "$COMPLETION_PATH" +if ! shopt -oq posix; then + if [ -f /usr/share/bash-completion/bash_completion ]; then + . /usr/share/bash-completion/bash_completion + elif [ -f /etc/bash_completion ]; then + . /etc/bash_completion + fi +fi +EOF + chmod 0644 "$COMPLETION_PATH" +fi +if ! grep -q "zz-bash-completion.sh" /etc/bash.bashrc 2>/dev/null; then + printf "%s\n" "if [ -f /etc/profile.d/zz-bash-completion.sh ]; then . /etc/profile.d/zz-bash-completion.sh; fi" >> /etc/bash.bashrc +fi + +# Ensure bash history is configured for interactive shells +HISTORY_PATH="/etc/profile.d/zz-bash-history.sh" +if [[ ! -s "$HISTORY_PATH" ]]; then + cat <<'EOF' > "$HISTORY_PATH" +if [ -n "$BASH_VERSION" ]; then + case "$-" in + *i*) + HISTFILE="${HISTFILE:-$HOME/.bash_history}" + HISTSIZE="${HISTSIZE:-10000}" + HISTFILESIZE="${HISTFILESIZE:-20000}" + HISTCONTROL="${HISTCONTROL:-ignoredups:erasedups}" + export HISTFILE HISTSIZE HISTFILESIZE HISTCONTROL + shopt -s histappend + if [ -n "${PROMPT_COMMAND-}" ]; then + PROMPT_COMMAND="history -a; ${PROMPT_COMMAND}" + else + PROMPT_COMMAND="history -a" + fi + ;; + esac +fi +EOF + chmod 0644 "$HISTORY_PATH" +fi +if ! grep -q "zz-bash-history.sh" /etc/bash.bashrc 2>/dev/null; then + printf "%s\n" "if [ -f /etc/profile.d/zz-bash-history.sh ]; then . /etc/profile.d/zz-bash-history.sh; fi" >> /etc/bash.bashrc +fi + +# Ensure readline history search bindings for dev +INPUTRC_PATH="/home/dev/.inputrc" +if [[ ! -f "$INPUTRC_PATH" ]]; then + cat <<'EOF' > "$INPUTRC_PATH" +set show-all-if-ambiguous on +set completion-ignore-case on +"\e[A": history-search-backward +"\e[B": history-search-forward +EOF + chown 1000:1000 "$INPUTRC_PATH" || true +fi + +# Ensure zsh config exists for autosuggestions +ZSHRC_PATH="/etc/zsh/zshrc" +if [[ ! -s "$ZSHRC_PATH" ]]; then + mkdir -p /etc/zsh + cat <<'EOF' > "$ZSHRC_PATH" +setopt PROMPT_SUBST + +# Terminal compatibility: if terminfo for $TERM is missing (common over SSH), +# fall back to xterm-256color so ZLE doesn't garble the display. +if command -v infocmp >/dev/null 2>&1; then + if ! infocmp "$TERM" >/dev/null 2>&1; then + export TERM=xterm-256color + fi +fi + +autoload -Uz compinit +compinit + +# Completion UX: cycle matches instead of listing them into scrollback. +setopt AUTO_MENU +setopt MENU_COMPLETE +unsetopt AUTO_LIST +unsetopt LIST_BEEP + +# Command completion ordering: prefer real commands/builtins over internal helper functions. +zstyle ':completion:*' tag-order builtins commands aliases reserved-words functions + +autoload -Uz add-zsh-hook +docker_git_branch() { git rev-parse --abbrev-ref HEAD 2>/dev/null; } +docker_git_prompt_apply() { + local b + b="$(docker_git_branch)" + local base="[%*] %~" + if [[ -n "$b" ]]; then + PROMPT="$base ($b)> " + else + PROMPT="$base> " + fi +} +add-zsh-hook precmd docker_git_prompt_apply + +HISTFILE="${HISTFILE:-$HOME/.zsh_history}" +HISTSIZE="${HISTSIZE:-10000}" +SAVEHIST="${SAVEHIST:-20000}" +setopt HIST_IGNORE_ALL_DUPS +setopt SHARE_HISTORY +setopt INC_APPEND_HISTORY + +if [ -f "$HISTFILE" ]; then + fc -R "$HISTFILE" 2>/dev/null || true +fi +if [ -f "$HOME/.bash_history" ] && [ "$HISTFILE" != "$HOME/.bash_history" ]; then + fc -R "$HOME/.bash_history" 2>/dev/null || true +fi + +bindkey '^[[A' history-search-backward +bindkey '^[[B' history-search-forward + +if [[ "${DOCKER_GIT_ZSH_AUTOSUGGEST:-1}" == "1" ]] && [ -f /usr/share/zsh-autosuggestions/zsh-autosuggestions.zsh ]; then + # Suggest from history first, then fall back to completion (commands + paths). + # This gives "ghost text" suggestions without needing to press . + ZSH_AUTOSUGGEST_HIGHLIGHT_STYLE="${DOCKER_GIT_ZSH_AUTOSUGGEST_STYLE:-fg=8,italic}" + if [[ -n "${DOCKER_GIT_ZSH_AUTOSUGGEST_STRATEGY-}" ]]; then + ZSH_AUTOSUGGEST_STRATEGY=(${=DOCKER_GIT_ZSH_AUTOSUGGEST_STRATEGY}) + else + ZSH_AUTOSUGGEST_STRATEGY=(history completion) + fi + source /usr/share/zsh-autosuggestions/zsh-autosuggestions.zsh +fi +EOF +fi + +# Ensure codex resume hint is shown for interactive shells +CODEX_HINT_PATH="/etc/profile.d/zz-codex-resume.sh" +if [[ ! -s "$CODEX_HINT_PATH" ]]; then + cat <<'EOF' > "$CODEX_HINT_PATH" +if [ -n "$BASH_VERSION" ]; then + case "$-" in + *i*) + if [ -z "${CODEX_RESUME_HINT_SHOWN-}" ]; then + echo "Старые сессии можно запустить с помощью codex resume или codex resume , если знаешь айди." + export CODEX_RESUME_HINT_SHOWN=1 + fi + ;; + esac +fi +if [ -n "$ZSH_VERSION" ]; then + if [[ "$-" == *i* ]]; then + if [[ -z "${CODEX_RESUME_HINT_SHOWN-}" ]]; then + echo "Старые сессии можно запустить с помощью codex resume или codex resume , если знаешь айди." + export CODEX_RESUME_HINT_SHOWN=1 + fi + fi +fi +EOF + chmod 0644 "$CODEX_HINT_PATH" +fi +if ! grep -q "zz-codex-resume.sh" /etc/bash.bashrc 2>/dev/null; then + printf "%s\n" "if [ -f /etc/profile.d/zz-codex-resume.sh ]; then . /etc/profile.d/zz-codex-resume.sh; fi" >> /etc/bash.bashrc +fi +if [[ -s /etc/zsh/zshrc ]] && ! grep -q "zz-codex-resume.sh" /etc/zsh/zshrc 2>/dev/null; then + printf "%s\n" "if [ -f /etc/profile.d/zz-codex-resume.sh ]; then source /etc/profile.d/zz-codex-resume.sh; fi" >> /etc/zsh/zshrc +fi + +# Ensure global AGENTS.md exists for container context +AGENTS_PATH="/home/dev/.codex/AGENTS.md" +LEGACY_AGENTS_PATH="/home/dev/AGENTS.md" +PROJECT_LINE="Рабочая папка проекта (git clone): /home/dev/octocat/hello-world/issue-1" +WORKSPACES_LINE="Доступные workspace пути: /home/dev/octocat/hello-world/issue-1" +WORKSPACE_INFO_LINE="Контекст workspace: repository" +FOCUS_LINE="Фокус задачи: работай только в workspace, который запрашивает пользователь. Текущий workspace: /home/dev/octocat/hello-world/issue-1" +ISSUE_AGENTS_HINT_LINE="Issue AGENTS.md: n/a" +INTERNET_LINE="Доступ к интернету: есть. Если чего-то не знаешь — ищи в интернете или по кодовой базе." +if [[ "$REPO_REF" == issue-* ]]; then + ISSUE_ID="$(printf "%s" "$REPO_REF" | sed -E 's#^issue-##')" + ISSUE_URL="" + if [[ "$REPO_URL" == https://github.com/* ]]; then + ISSUE_REPO="$(printf "%s" "$REPO_URL" | sed -E 's#^https://github.com/##; s#[.]git$##; s#/*$##')" + if [[ -n "$ISSUE_REPO" ]]; then + ISSUE_URL="https://github.com/$ISSUE_REPO/issues/$ISSUE_ID" + fi + fi + if [[ -n "$ISSUE_URL" ]]; then + WORKSPACE_INFO_LINE="Контекст workspace: issue #$ISSUE_ID ($ISSUE_URL)" + else + WORKSPACE_INFO_LINE="Контекст workspace: issue #$ISSUE_ID" + fi + ISSUE_AGENTS_HINT_LINE="Issue AGENTS.md: /home/dev/octocat/hello-world/issue-1/AGENTS.md" +elif [[ "$REPO_REF" == refs/pull/*/head ]]; then + PR_ID="$(printf "%s" "$REPO_REF" | sed -E 's#^refs/pull/([0-9]+)/head$#\1#')" + if [[ -n "$PR_ID" ]]; then + WORKSPACE_INFO_LINE="Контекст workspace: PR #$PR_ID" + else + WORKSPACE_INFO_LINE="Контекст workspace: pull request ($REPO_REF)" + fi +fi +if [[ ! -f "$AGENTS_PATH" ]]; then + MANAGED_START="" + MANAGED_END="" + MANAGED_BLOCK="$(cat < "$AGENTS_PATH" +Ты автономный агент, который имеет полностью все права управления контейнером. У тебя есть доступ к командам sudo, gh, codex, git, node, pnpm и всем остальным другим. Проекты с которыми идёт работа лежат по пути ~ +$MANAGED_BLOCK +Если ты видишь файлы AGENTS.md внутри проекта, ты обязан их читать и соблюдать инструкции. +EOF + chown 1000:1000 "$AGENTS_PATH" || true +fi +if [[ -f "$AGENTS_PATH" ]]; then + MANAGED_START="" + MANAGED_END="" + MANAGED_BLOCK="$(cat < "$TMP_AGENTS_PATH" + else + sed \ + -e '/^Рабочая папка проекта (git clone):/d' \ + -e '/^Доступные workspace пути:/d' \ + -e '/^Контекст workspace:/d' \ + -e '/^Фокус задачи:/d' \ + -e '/^Issue AGENTS.md:/d' \ + -e '/^Доступ к интернету:/d' \ + "$AGENTS_PATH" > "$TMP_AGENTS_PATH" + if [[ -s "$TMP_AGENTS_PATH" ]]; then + printf "\n" >> "$TMP_AGENTS_PATH" + fi + printf "%s\n" "$MANAGED_BLOCK" >> "$TMP_AGENTS_PATH" + fi + mv "$TMP_AGENTS_PATH" "$AGENTS_PATH" + chown 1000:1000 "$AGENTS_PATH" || true +fi +if [[ -f "$LEGACY_AGENTS_PATH" && -f "$AGENTS_PATH" ]]; then + LEGACY_SUM="$(cksum "$LEGACY_AGENTS_PATH" 2>/dev/null | awk '{print $1 \":\" $2}')" + CODEX_SUM="$(cksum "$AGENTS_PATH" 2>/dev/null | awk '{print $1 \":\" $2}')" + if [[ -n "$LEGACY_SUM" && "$LEGACY_SUM" == "$CODEX_SUM" ]]; then + rm -f "$LEGACY_AGENTS_PATH" + fi +fi + +# Ensure docker socket access for dev +if [[ -S /var/run/docker.sock ]]; then + DOCKER_SOCK_GID="$(stat -c "%g" /var/run/docker.sock)" + DOCKER_GROUP="$(getent group "$DOCKER_SOCK_GID" | cut -d: -f1 || true)" + if [[ -z "$DOCKER_GROUP" ]]; then + DOCKER_GROUP="docker" + groupadd -g "$DOCKER_SOCK_GID" "$DOCKER_GROUP" || true + fi + usermod -aG "$DOCKER_GROUP" dev || true + printf "export DOCKER_HOST=unix:///var/run/docker.sock +" > /etc/profile.d/docker-host.sh +fi + +# 2) Ensure GitHub auth vars are available for SSH sessions if provided +if [[ -n "$GH_TOKEN" || -n "$GITHUB_TOKEN" ]]; then + EFFECTIVE_GITHUB_TOKEN="$GITHUB_TOKEN" + if [[ -z "$EFFECTIVE_GITHUB_TOKEN" ]]; then + EFFECTIVE_GITHUB_TOKEN="$GH_TOKEN" + fi + + EFFECTIVE_GH_TOKEN="$GH_TOKEN" + if [[ -z "$EFFECTIVE_GH_TOKEN" ]]; then + EFFECTIVE_GH_TOKEN="$EFFECTIVE_GITHUB_TOKEN" + fi + + printf "export GH_TOKEN=%q\n" "$EFFECTIVE_GH_TOKEN" > /etc/profile.d/gh-token.sh + printf "export GITHUB_TOKEN=%q\n" "$EFFECTIVE_GITHUB_TOKEN" >> /etc/profile.d/gh-token.sh + chmod 0644 /etc/profile.d/gh-token.sh + SSH_ENV_PATH="/home/dev/.ssh/environment" + printf "%s\n" "GH_TOKEN=$EFFECTIVE_GH_TOKEN" > "$SSH_ENV_PATH" + printf "%s\n" "GITHUB_TOKEN=$EFFECTIVE_GITHUB_TOKEN" >> "$SSH_ENV_PATH" + chmod 600 "$SSH_ENV_PATH" + chown 1000:1000 "$SSH_ENV_PATH" || true + + SAFE_GH_TOKEN="$(printf "%q" "$GH_TOKEN")" + # Keep git+https auth in sync with gh auth so push/pull works without manual setup. + su - dev -c "GH_TOKEN=$SAFE_GH_TOKEN gh auth setup-git --hostname github.com --force" || true + + GH_LOGIN="$(su - dev -c "GH_TOKEN=$SAFE_GH_TOKEN gh api user --jq .login" 2>/dev/null || true)" + GH_ID="$(su - dev -c "GH_TOKEN=$SAFE_GH_TOKEN gh api user --jq .id" 2>/dev/null || true)" + GH_LOGIN="$(printf "%s" "$GH_LOGIN" | tr -d '\r\n')" + GH_ID="$(printf "%s" "$GH_ID" | tr -d '\r\n')" + + if [[ -z "$GIT_USER_NAME" && -n "$GH_LOGIN" ]]; then + GIT_USER_NAME="$GH_LOGIN" + fi + if [[ -z "$GIT_USER_EMAIL" && -n "$GH_LOGIN" && -n "$GH_ID" ]]; then + GIT_USER_EMAIL="${GH_ID}+${GH_LOGIN}@users.noreply.github.com" + fi +fi + +# 3) Configure git credential helper for HTTPS remotes +GIT_CREDENTIAL_HELPER_PATH="/usr/local/bin/docker-git-credential-helper" +cat <<'EOF' > "$GIT_CREDENTIAL_HELPER_PATH" +#!/usr/bin/env bash +set -euo pipefail + +if [[ "$#" -lt 1 || "$1" != "get" ]]; then + exit 0 +fi + +token="$GITHUB_TOKEN" +if [[ -z "$token" ]]; then + token="$GH_TOKEN" +fi + +if [[ -z "$token" ]]; then + exit 0 +fi + +printf "%s\n" "username=x-access-token" +printf "%s\n" "password=$token" +EOF +chmod 0755 "$GIT_CREDENTIAL_HELPER_PATH" +su - dev -c "git config --global credential.helper '$GIT_CREDENTIAL_HELPER_PATH'" + +# 4) Configure git identity for the dev user if provided +if [[ -n "$GIT_USER_NAME" ]]; then + SAFE_GIT_USER_NAME="$(printf "%q" "$GIT_USER_NAME")" + su - dev -c "git config --global user.name $SAFE_GIT_USER_NAME" +fi + +if [[ -n "$GIT_USER_EMAIL" ]]; then + SAFE_GIT_USER_EMAIL="$(printf "%q" "$GIT_USER_EMAIL")" + su - dev -c "git config --global user.email $SAFE_GIT_USER_EMAIL" +fi + +# 3) Install global git hooks to protect main/master +HOOKS_DIR="/opt/docker-git/hooks" +PRE_PUSH_HOOK="$HOOKS_DIR/pre-push" +mkdir -p "$HOOKS_DIR" +if [[ ! -f "$PRE_PUSH_HOOK" ]]; then + cat <<'EOF' > "$PRE_PUSH_HOOK" +#!/usr/bin/env bash +set -euo pipefail + +protected_branches=("refs/heads/main" "refs/heads/master") +allow_delete="${DOCKER_GIT_ALLOW_DELETE:-}" + +while read -r local_ref local_sha remote_ref remote_sha; do + if [[ -z "$remote_ref" ]]; then + continue + fi + for protected in "${protected_branches[@]}"; do + if [[ "$remote_ref" == "$protected" || "$local_ref" == "$protected" ]]; then + echo "docker-git: push to protected branch '${protected##*/}' is disabled." + echo "docker-git: create a new branch: git checkout -b " + exit 1 + fi + done + if [[ "$local_sha" == "0000000000000000000000000000000000000000" && "$remote_ref" == refs/heads/* ]]; then + if [[ "$allow_delete" != "1" ]]; then + echo "docker-git: deleting remote branches is disabled (set DOCKER_GIT_ALLOW_DELETE=1 to override)." + exit 1 + fi + fi +done +EOF + chmod 0755 "$PRE_PUSH_HOOK" +fi +git config --system core.hooksPath "$HOOKS_DIR" || true +git config --global core.hooksPath "$HOOKS_DIR" || true + +# 4) Start background tasks so SSH can come up immediately +( +# 1) Keep Codex CLI up to date if requested (bun only) +if [[ "$CODEX_AUTO_UPDATE" == "1" ]]; then + if command -v bun >/dev/null 2>&1; then + echo "[codex] updating via bun..." + script -q -e -c "bun add -g @openai/codex@latest" /dev/null || true + else + echo "[codex] bun not found, skipping auto-update" + fi +fi + +# 2) Auto-clone repo if not already present +mkdir -p /run/docker-git +CLONE_DONE_PATH="/run/docker-git/clone.done" +CLONE_FAIL_PATH="/run/docker-git/clone.failed" +rm -f "$CLONE_DONE_PATH" "$CLONE_FAIL_PATH" + +CLONE_OK=1 + +if [[ -z "$REPO_URL" ]]; then + echo "[clone] skip (no repo url)" +elif [[ -d "$TARGET_DIR/.git" ]]; then + echo "[clone] skip (already cloned)" +else + mkdir -p "$TARGET_DIR" + if [[ "$TARGET_DIR" != "/" ]]; then + chown -R 1000:1000 "$TARGET_DIR" + fi + chown -R 1000:1000 /home/dev + + AUTH_REPO_URL="$REPO_URL" + if [[ -n "$GIT_AUTH_TOKEN" && "$REPO_URL" == https://* ]]; then + AUTH_REPO_URL="$(printf "%s" "$REPO_URL" | sed "s#^https://#https://${GIT_AUTH_USER}:${GIT_AUTH_TOKEN}@#")" + fi + if [[ -n "$REPO_REF" ]]; then + if [[ "$REPO_REF" == refs/pull/* ]]; then + REF_BRANCH="pr-$(printf "%s" "$REPO_REF" | tr '/:' '--')" + if ! su - dev -c "GIT_TERMINAL_PROMPT=0 git clone --progress '$AUTH_REPO_URL' '$TARGET_DIR'"; then + echo "[clone] git clone failed for $REPO_URL" + CLONE_OK=0 + else + if ! su - dev -c "cd '$TARGET_DIR' && GIT_TERMINAL_PROMPT=0 git fetch --progress origin '$REPO_REF':'$REF_BRANCH' && git checkout '$REF_BRANCH'"; then + echo "[clone] git fetch failed for $REPO_REF" + CLONE_OK=0 + fi + fi + else + if ! su - dev -c "GIT_TERMINAL_PROMPT=0 git clone --progress --branch '$REPO_REF' '$AUTH_REPO_URL' '$TARGET_DIR'"; then + DEFAULT_REF="$(git ls-remote --symref "$AUTH_REPO_URL" HEAD 2>/dev/null | awk '/^ref:/ {print $2}' | head -n 1 || true)" + DEFAULT_BRANCH="$(printf "%s" "$DEFAULT_REF" | sed 's#^refs/heads/##')" + if [[ -n "$DEFAULT_BRANCH" ]]; then + echo "[clone] branch '$REPO_REF' missing; retrying with '$DEFAULT_BRANCH'" + if ! su - dev -c "GIT_TERMINAL_PROMPT=0 git clone --progress --branch '$DEFAULT_BRANCH' '$AUTH_REPO_URL' '$TARGET_DIR'"; then + echo "[clone] git clone failed for $REPO_URL" + CLONE_OK=0 + elif [[ "$REPO_REF" == issue-* ]]; then + if ! su - dev -c "cd '$TARGET_DIR' && git checkout -B '$REPO_REF'"; then + echo "[clone] failed to create local branch '$REPO_REF'" + CLONE_OK=0 + fi + fi + else + echo "[clone] git clone failed for $REPO_URL" + CLONE_OK=0 + fi + fi + fi + else + if ! su - dev -c "GIT_TERMINAL_PROMPT=0 git clone --progress '$AUTH_REPO_URL' '$TARGET_DIR'"; then + echo "[clone] git clone failed for $REPO_URL" + CLONE_OK=0 + fi + fi +fi + +if [[ "$CLONE_OK" -eq 1 && -d "$TARGET_DIR/.git" ]]; then + if [[ -n "$FORK_REPO_URL" && "$FORK_REPO_URL" != "$REPO_URL" ]]; then + su - dev -c "cd '$TARGET_DIR' && git remote set-url origin '$FORK_REPO_URL'" || true + su - dev -c "cd '$TARGET_DIR' && git remote add upstream '$REPO_URL' 2>/dev/null || git remote set-url upstream '$REPO_URL'" || true + else + su - dev -c "cd '$TARGET_DIR' && git remote set-url origin '$REPO_URL'" || true + su - dev -c "cd '$TARGET_DIR' && git remote remove upstream >/dev/null 2>&1 || true" || true + fi +fi + +if [[ "$CLONE_OK" -eq 1 && "$REPO_REF" == issue-* && -d "$TARGET_DIR/.git" ]]; then +ISSUE_ID="$(printf "%s" "$REPO_REF" | sed -E 's#^issue-##')" +ISSUE_URL="" +if [[ "$REPO_URL" == https://github.com/* ]]; then + ISSUE_REPO="$(printf "%s" "$REPO_URL" | sed -E 's#^https://github.com/##; s#[.]git$##; s#/*$##')" + if [[ -n "$ISSUE_REPO" ]]; then + ISSUE_URL="https://github.com/$ISSUE_REPO/issues/$ISSUE_ID" + fi +fi +if [[ -z "$ISSUE_URL" ]]; then + ISSUE_URL="n/a" +fi + +ISSUE_AGENTS_PATH="$TARGET_DIR/AGENTS.md" +ISSUE_MANAGED_START="" +ISSUE_MANAGED_END="" +ISSUE_MANAGED_BLOCK="$(cat < "$ISSUE_AGENTS_PATH" +else + TMP_ISSUE_AGENTS_PATH="$(mktemp)" + if grep -qF "$ISSUE_MANAGED_START" "$ISSUE_AGENTS_PATH" && grep -qF "$ISSUE_MANAGED_END" "$ISSUE_AGENTS_PATH"; then + awk -v start="$ISSUE_MANAGED_START" -v end="$ISSUE_MANAGED_END" -v repl="$ISSUE_MANAGED_BLOCK" ' + BEGIN { in_block = 0 } + $0 == start { print repl; in_block = 1; next } + $0 == end { in_block = 0; next } + in_block == 0 { print } + ' "$ISSUE_AGENTS_PATH" > "$TMP_ISSUE_AGENTS_PATH" + else + sed -e '/^# docker-git issue workspace$/d' -e '/^Issue workspace: #/d' -e '/^Issue URL: /d' -e '/^Workspace path: /d' -e '/^Работай только над этим issue, если пользователь не попросил другое[.]$/d' -e '/^Если нужен первоисточник требований, открой Issue URL[.]$/d' "$ISSUE_AGENTS_PATH" > "$TMP_ISSUE_AGENTS_PATH" + if [[ -s "$TMP_ISSUE_AGENTS_PATH" ]]; then + printf " +" >> "$TMP_ISSUE_AGENTS_PATH" + fi + printf "%s +" "$ISSUE_MANAGED_BLOCK" >> "$TMP_ISSUE_AGENTS_PATH" + fi + mv "$TMP_ISSUE_AGENTS_PATH" "$ISSUE_AGENTS_PATH" +fi +if [[ -e "$ISSUE_AGENTS_PATH" ]]; then + chown 1000:1000 "$ISSUE_AGENTS_PATH" || true +fi + +EXCLUDE_PATH="$TARGET_DIR/.git/info/exclude" +if [[ -f "$ISSUE_AGENTS_PATH" ]]; then + touch "$EXCLUDE_PATH" + if ! grep -qx "AGENTS.md" "$EXCLUDE_PATH"; then + printf "%s +" "AGENTS.md" >> "$EXCLUDE_PATH" + fi +fi +fi + +if [[ "$CLONE_OK" -eq 1 ]]; then + echo "[clone] done" + touch "$CLONE_DONE_PATH" +else + echo "[clone] failed" + touch "$CLONE_FAIL_PATH" +fi +) & + +# 4.5) Snapshot baseline processes for terminal session filtering +mkdir -p /run/docker-git +BASELINE_PATH="/run/docker-git/terminal-baseline.pids" +if [[ ! -f "$BASELINE_PATH" ]]; then + ps -eo pid= > "$BASELINE_PATH" || true +fi + +# 4.75) Disable Ubuntu MOTD noise for SSH sessions +PAM_SSHD="/etc/pam.d/sshd" +if [[ -f "$PAM_SSHD" ]]; then + sed -i 's/^[[:space:]]*session[[:space:]]\+optional[[:space:]]\+pam_motd\.so/#&/' "$PAM_SSHD" || true + sed -i 's/^[[:space:]]*session[[:space:]]\+optional[[:space:]]\+pam_lastlog\.so/#&/' "$PAM_SSHD" || true +fi + +# Also disable sshd's own banners (e.g. "Last login") +mkdir -p /etc/ssh/sshd_config.d || true +DOCKER_GIT_SSHD_CONF="/etc/ssh/sshd_config.d/zz-docker-git-clean.conf" +cat <<'EOF' > "$DOCKER_GIT_SSHD_CONF" +PrintMotd no +PrintLastLog no +EOF +chmod 0644 "$DOCKER_GIT_SSHD_CONF" || true + +# 5) Run sshd in foreground +exec /usr/sbin/sshd -D \ No newline at end of file diff --git a/.github/workflows/check.yml b/.github/workflows/check.yml index 651bab1c..60777701 100644 --- a/.github/workflows/check.yml +++ b/.github/workflows/check.yml @@ -84,3 +84,14 @@ jobs: run: pnpm --filter ./packages/app lint:effect - name: Lint Effect-TS (lib) run: pnpm --filter ./packages/lib lint:effect + + e2e: + name: E2E + runs-on: ubuntu-latest + timeout-minutes: 35 + steps: + - uses: actions/checkout@v6 + - name: Install dependencies + uses: ./.github/actions/setup + - name: System E2E (clone + auto-SSH) + run: bash scripts/e2e/ci.sh diff --git a/AGENTS.md b/AGENTS.md index 7c87c6cd..4b74344f 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -373,3 +373,12 @@ describe("Message invariants", () => { Каждый эффект — это контролируемое взаимодействие с реальным миром. ПРИНЦИП: Сначала формализуем, потом программируем. + + +Issue workspace: #39 +Issue URL: https://github.com/ProverCoderAI/docker-git/issues/39 +Workspace path: /home/dev/provercoderai/docker-git/issue-39 + +Работай только над этим issue, если пользователь не попросил другое. +Если нужен первоисточник требований, открой Issue URL. + diff --git a/package.json b/package.json index 4f19c18a..f365c6f4 100644 --- a/package.json +++ b/package.json @@ -47,6 +47,9 @@ "@parcel/watcher", "msgpackr-extract", "unrs-resolver" - ] + ], + "patchedDependencies": { + "@ton-ai-core/vibecode-linter@1.0.6": "patches/@ton-ai-core__vibecode-linter@1.0.6.patch" + } } } diff --git a/packages/app/src/docker-git/cli/parser-clone.ts b/packages/app/src/docker-git/cli/parser-clone.ts index 59945a5e..5aed993a 100644 --- a/packages/app/src/docker-git/cli/parser-clone.ts +++ b/packages/app/src/docker-git/cli/parser-clone.ts @@ -49,7 +49,8 @@ export const parseClone = (args: ReadonlyArray): Either.Either RawOptions>> = { "--up": (raw) => ({ ...raw, up: true }), "--no-up": (raw) => ({ ...raw, up: false }), + "--ssh": (raw) => ({ ...raw, openSsh: true }), + "--no-ssh": (raw) => ({ ...raw, openSsh: false }), "--force": (raw) => ({ ...raw, force: true }), "--force-env": (raw) => ({ ...raw, forceEnv: true }), "--mcp-playwright": (raw) => ({ ...raw, enableMcpPlaywright: true }), diff --git a/packages/app/src/docker-git/cli/usage.ts b/packages/app/src/docker-git/cli/usage.ts index aeabd2d1..1fb98a38 100644 --- a/packages/app/src/docker-git/cli/usage.ts +++ b/packages/app/src/docker-git/cli/usage.ts @@ -47,6 +47,7 @@ Options: --lines Tail last N lines for sessions logs (default: 200) --include-default Show default/system processes in sessions list --up | --no-up Run docker compose up after init (default: --up) + --ssh | --no-ssh Auto-open SSH after create/clone (default: clone=--ssh, create=--no-ssh) --mcp-playwright | --no-mcp-playwright Enable Playwright MCP + Chromium sidecar (default: --no-mcp-playwright) --force Overwrite existing files and wipe compose volumes (docker compose down -v) --force-env Reset project env defaults only (keep workspace volume/data) diff --git a/packages/app/tests/docker-git/parser.test.ts b/packages/app/tests/docker-git/parser.test.ts index 4ed60f45..ef445065 100644 --- a/packages/app/tests/docker-git/parser.test.ts +++ b/packages/app/tests/docker-git/parser.test.ts @@ -40,6 +40,8 @@ describe("parseArgs", () => { it.effect("parses create command with defaults", () => expectCreateCommand(["create", "--repo-url", "https://github.com/org/repo.git"], (command) => { expectCreateDefaults(command) + expect(command.openSsh).toBe(false) + expect(command.waitForClone).toBe(false) expect(command.config.containerName).toBe("dg-repo") expect(command.config.serviceName).toBe("dg-repo") expect(command.config.volumeName).toBe("dg-repo-home") @@ -51,6 +53,8 @@ describe("parseArgs", () => { expect(command.config.repoUrl).toBe("https://github.com/org/repo.git") expect(command.config.repoRef).toBe("issue-9") expect(command.outDir).toBe(".docker-git/org/repo/issue-9") + expect(command.openSsh).toBe(false) + expect(command.waitForClone).toBe(false) expect(command.config.containerName).toBe("dg-repo-issue-9") expect(command.config.serviceName).toBe("dg-repo-issue-9") expect(command.config.volumeName).toBe("dg-repo-issue-9-home") @@ -71,6 +75,8 @@ describe("parseArgs", () => { it.effect("parses clone command with positional repo url", () => expectCreateCommand(["clone", "https://github.com/org/repo.git"], (command) => { expectCreateDefaults(command) + expect(command.openSsh).toBe(true) + expect(command.waitForClone).toBe(true) expect(command.config.targetDir).toBe("/home/dev/org/repo") })) @@ -79,6 +85,16 @@ describe("parseArgs", () => { expect(command.config.repoRef).toBe("feature-x") })) + it.effect("supports disabling SSH auto-open for clone", () => + expectCreateCommand(["clone", "https://github.com/org/repo.git", "--no-ssh"], (command) => { + expect(command.openSsh).toBe(false) + })) + + it.effect("supports enabling SSH auto-open for create", () => + expectCreateCommand(["create", "--repo-url", "https://github.com/org/repo.git", "--ssh"], (command) => { + expect(command.openSsh).toBe(true) + })) + it.effect("parses force-env flag for clone", () => expectCreateCommand(["clone", "https://github.com/org/repo.git", "--force-env"], (command) => { expect(command.force).toBe(false) diff --git a/packages/docker-git/src/server/http.ts b/packages/docker-git/src/server/http.ts index df22fe97..7b812d7a 100644 --- a/packages/docker-git/src/server/http.ts +++ b/packages/docker-git/src/server/http.ts @@ -1115,6 +1115,7 @@ export const makeRouter = ({ cwd, projectsRoot, webRoot, vendorRoot, terminalPor config: nextTemplate, outDir: project.directory, runUp: false, + openSsh: false, force: true, forceEnv: false, waitForClone: false @@ -1455,6 +1456,7 @@ data: ${JSON.stringify(data)} config: nextTemplate, outDir: project.directory, runUp: false, + openSsh: false, force: true, forceEnv: false, waitForClone: false diff --git a/packages/lib/src/core/command-builders.ts b/packages/lib/src/core/command-builders.ts index 5bf7e8e8..6c775a35 100644 --- a/packages/lib/src/core/command-builders.ts +++ b/packages/lib/src/core/command-builders.ts @@ -200,6 +200,7 @@ export const buildCreateCommand = ( const names = yield* _(resolveNames(raw, repo.projectSlug)) const paths = yield* _(resolvePaths(raw, repo.projectSlug, repo.repoPath)) const runUp = raw.up ?? true + const openSsh = raw.openSsh ?? false const force = raw.force ?? false const forceEnv = raw.forceEnv ?? false const enableMcpPlaywright = raw.enableMcpPlaywright ?? false @@ -208,6 +209,7 @@ export const buildCreateCommand = ( _tag: "Create", outDir: paths.outDir, runUp, + openSsh, force, forceEnv, waitForClone: false, diff --git a/packages/lib/src/core/command-options.ts b/packages/lib/src/core/command-options.ts index 4c284008..5b21eb34 100644 --- a/packages/lib/src/core/command-options.ts +++ b/packages/lib/src/core/command-options.ts @@ -36,6 +36,7 @@ export interface RawOptions { readonly lines?: string readonly includeDefault?: boolean readonly up?: boolean + readonly openSsh?: boolean readonly force?: boolean readonly forceEnv?: boolean } diff --git a/packages/lib/src/core/domain.ts b/packages/lib/src/core/domain.ts index 5d879890..7d6abd8c 100644 --- a/packages/lib/src/core/domain.ts +++ b/packages/lib/src/core/domain.ts @@ -36,6 +36,7 @@ export interface CreateCommand { readonly force: boolean readonly forceEnv: boolean readonly waitForClone: boolean + readonly openSsh: boolean } export interface MenuCommand { diff --git a/packages/lib/src/usecases/actions/create-project.ts b/packages/lib/src/usecases/actions/create-project.ts index 2772f55b..19ec888d 100644 --- a/packages/lib/src/usecases/actions/create-project.ts +++ b/packages/lib/src/usecases/actions/create-project.ts @@ -1,12 +1,14 @@ import type * as CommandExecutor from "@effect/platform/CommandExecutor" import type { PlatformError } from "@effect/platform/Error" -import type * as FileSystem from "@effect/platform/FileSystem" +import * as FileSystem from "@effect/platform/FileSystem" import * as Path from "@effect/platform/Path" import { Effect } from "effect" import type { CreateCommand } from "../../core/domain.js" import { deriveRepoPathParts } from "../../core/domain.js" +import { runCommandWithExitCodes } from "../../shell/command-runner.js" import { ensureDockerDaemonAccess } from "../../shell/docker.js" +import { CommandFailedError } from "../../shell/errors.js" import type { CloneFailedError, DockerAccessError, @@ -15,8 +17,11 @@ import type { PortProbeError } from "../../shell/errors.js" import { logDockerAccessInfo } from "../access-log.js" +import { renderError } from "../errors.js" import { applyGithubForkConfig } from "../github-fork.js" import { defaultProjectsRoot } from "../menu-helpers.js" +import { findSshPrivateKey } from "../path-helpers.js" +import { buildSshCommand } from "../projects-core.js" import { autoSyncState } from "../state-repo.js" import { runDockerUpIfNeeded } from "./docker-up.js" import { buildProjectConfigs, resolveDockerGitRootRelativePath } from "./paths.js" @@ -80,6 +85,69 @@ const formatStateSyncLabel = (repoUrl: string): string => { return repoPath.length > 0 ? repoPath : repoUrl } +const isInteractiveTty = (): boolean => process.stdin.isTTY === true && process.stdout.isTTY === true + +const buildSshArgs = ( + config: CreateCommand["config"], + sshKeyPath: string | null +): ReadonlyArray => { + const args: Array = [] + if (sshKeyPath !== null) { + args.push("-i", sshKeyPath) + } + args.push( + "-tt", + "-Y", + "-o", + "LogLevel=ERROR", + "-o", + "StrictHostKeyChecking=no", + "-o", + "UserKnownHostsFile=/dev/null", + "-p", + String(config.sshPort), + `${config.sshUser}@localhost` + ) + return args +} + +// CHANGE: auto-open SSH after environment is created (best-effort) +// WHY: clone flow should drop the user into the container without manual copy/paste +// QUOTE(ТЗ): "Мне надо что бы он сразу открыл SSH" +// REF: issue-39 +// SOURCE: n/a +// FORMAT THEOREM: forall c: openSsh(c) -> ssh_session_started(c) || warning_logged(c) +// PURITY: SHELL +// EFFECT: Effect +// INVARIANT: SSH failures do not fail the create/clone command +// COMPLEXITY: O(1) + ssh +const openSshBestEffort = ( + template: CreateCommand["config"] +): Effect.Effect => + Effect.gen(function*(_) { + const fs = yield* _(FileSystem.FileSystem) + const path = yield* _(Path.Path) + + const sshKey = yield* _(findSshPrivateKey(fs, path, process.cwd())) + const sshCommand = buildSshCommand(template, sshKey) + + yield* _(Effect.log(`Opening SSH: ${sshCommand}`)) + yield* _( + runCommandWithExitCodes( + { + cwd: process.cwd(), + command: "ssh", + args: buildSshArgs(template, sshKey) + }, + [0, 130], + (exitCode) => new CommandFailedError({ command: "ssh", exitCode }) + ) + ) + }).pipe( + Effect.catchAll((error) => Effect.logWarning(`SSH auto-open failed: ${renderError(error)}`)), + Effect.asVoid + ) + const runCreateProject = ( path: Path.Path, command: CreateCommand @@ -118,6 +186,16 @@ const runCreateProject = ( } yield* _(autoSyncState(`chore(state): update ${formatStateSyncLabel(projectConfig.repoUrl)}`)) + + if (command.openSsh) { + if (!command.runUp) { + yield* _(Effect.logWarning("Skipping SSH auto-open: docker compose up disabled (--no-up).")) + } else if (!isInteractiveTty()) { + yield* _(Effect.logWarning("Skipping SSH auto-open: not running in an interactive TTY.")) + } else { + yield* _(openSshBestEffort(projectConfig)) + } + } }).pipe(Effect.asVoid) export const createProject = (command: CreateCommand): Effect.Effect => diff --git a/packages/lib/tests/usecases/create-project-open-ssh.test.ts b/packages/lib/tests/usecases/create-project-open-ssh.test.ts new file mode 100644 index 00000000..26be75e1 --- /dev/null +++ b/packages/lib/tests/usecases/create-project-open-ssh.test.ts @@ -0,0 +1,180 @@ +import * as Command from "@effect/platform/Command" +import * as CommandExecutor from "@effect/platform/CommandExecutor" +import * as FileSystem from "@effect/platform/FileSystem" +import * as Path from "@effect/platform/Path" +import { NodeContext } from "@effect/platform-node" +import { describe, expect, it } from "@effect/vitest" +import { Effect } from "effect" +import * as Inspectable from "effect/Inspectable" +import * as Sink from "effect/Sink" +import * as Stream from "effect/Stream" + +import type { CreateCommand, TemplateConfig } from "../../src/core/domain.js" +import { createProject } from "../../src/usecases/actions/create-project.js" + +type RecordedCommand = { + readonly command: string + readonly args: ReadonlyArray +} + +const withTempDir = ( + use: (tempDir: string) => Effect.Effect +): Effect.Effect => + Effect.scoped( + Effect.gen(function*(_) { + const fs = yield* _(FileSystem.FileSystem) + const tempDir = yield* _( + fs.makeTempDirectoryScoped({ + prefix: "docker-git-open-ssh-" + }) + ) + return yield* _(use(tempDir)) + }) + ) + +const encode = (value: string): Uint8Array => new TextEncoder().encode(value) + +const commandIncludes = (args: ReadonlyArray, needle: string): boolean => args.includes(needle) + +const decideExitCode = (cmd: RecordedCommand): number => { + if (cmd.command === "git" && cmd.args[0] === "rev-parse") { + // Auto-sync should detect "not a repo" and exit early. + return 1 + } + + if (cmd.command === "docker" && cmd.args[0] === "exec") { + if (commandIncludes(cmd.args, "/run/docker-git/clone.failed")) { + return 1 + } + if (commandIncludes(cmd.args, "/run/docker-git/clone.done")) { + return 0 + } + } + + return 0 +} + +const decideStdout = (cmd: RecordedCommand): string => { + if (cmd.command === "docker" && cmd.args[0] === "inspect") { + // Keep it empty so ensureDockerDnsHost skips /etc/hosts modifications in tests. + return "" + } + return "" +} + +const makeFakeExecutor = (recorded: Array): CommandExecutor.CommandExecutor => { + const start = (command: Command.Command): Effect.Effect => + Effect.gen(function*(_) { + const flattened = Command.flatten(command) + for (const entry of flattened) { + recorded.push({ command: entry.command, args: entry.args }) + } + + const last = flattened[flattened.length - 1] + const invocation: RecordedCommand = { command: last.command, args: last.args } + const exit = decideExitCode(invocation) + const stdoutText = decideStdout(invocation) + const stdout = stdoutText.length === 0 ? Stream.empty : Stream.succeed(encode(stdoutText)) + + const process: CommandExecutor.Process = { + [CommandExecutor.ProcessTypeId]: CommandExecutor.ProcessTypeId, + pid: CommandExecutor.ProcessId(1), + exitCode: Effect.succeed(CommandExecutor.ExitCode(exit)), + isRunning: Effect.succeed(false), + kill: (_signal) => Effect.void, + stderr: Stream.empty, + stdin: Sink.drain, + stdout, + toJSON: () => ({ _tag: "TestProcess", command: invocation.command, args: invocation.args, exit }), + [Inspectable.NodeInspectSymbol]: () => ({ _tag: "TestProcess", command: invocation.command, args: invocation.args }), + toString: () => `[TestProcess ${invocation.command}]` + } + + return process + }) + + return CommandExecutor.makeExecutor(start) +} + +const makeCommand = (root: string, outDir: string, path: Path.Path): CreateCommand => { + const template: TemplateConfig = { + containerName: "dg-test", + serviceName: "dg-test", + sshUser: "dev", + sshPort: 2222, + repoUrl: "https://github.com/org/repo.git", + repoRef: "main", + targetDir: "/home/dev/org/repo", + volumeName: "dg-test-home", + dockerGitPath: path.join(root, ".docker-git"), + authorizedKeysPath: path.join(root, "authorized_keys"), + envGlobalPath: path.join(root, ".orch/env/global.env"), + envProjectPath: path.join(root, ".orch/env/project.env"), + codexAuthPath: path.join(root, ".orch/auth/codex"), + codexSharedAuthPath: path.join(root, ".orch/auth/codex-shared"), + codexHome: "/home/dev/.codex", + enableMcpPlaywright: false, + pnpmVersion: "10.27.0" + } + + return { + _tag: "Create", + config: template, + outDir, + runUp: true, + openSsh: true, + force: true, + forceEnv: false, + waitForClone: true + } +} + +describe("createProject (openSsh)", () => { + it.effect("runs ssh after clone completion when openSsh=true", () => + withTempDir((root) => + Effect.gen(function*(_) { + const path = yield* _(Path.Path) + + const outDir = path.join(root, "project") + const recorded: Array = [] + const executor = makeFakeExecutor(recorded) + const command = makeCommand(root, outDir, path) + + const prevProjectsRoot = process.env["DOCKER_GIT_PROJECTS_ROOT"] + const prevStdinTty = process.stdin.isTTY + const prevStdoutTty = process.stdout.isTTY + + process.env["DOCKER_GIT_PROJECTS_ROOT"] = path.join(root, "state") + Object.defineProperty(process.stdin, "isTTY", { value: true, configurable: true }) + Object.defineProperty(process.stdout, "isTTY", { value: true, configurable: true }) + + try { + yield* _(createProject(command).pipe(Effect.provideService(CommandExecutor.CommandExecutor, executor))) + } finally { + if (prevProjectsRoot === undefined) { + delete process.env["DOCKER_GIT_PROJECTS_ROOT"] + } else { + process.env["DOCKER_GIT_PROJECTS_ROOT"] = prevProjectsRoot + } + Object.defineProperty(process.stdin, "isTTY", { value: prevStdinTty, configurable: true }) + Object.defineProperty(process.stdout, "isTTY", { value: prevStdoutTty, configurable: true }) + } + + const sshInvocations = recorded.filter((entry) => entry.command === "ssh") + expect(sshInvocations).toHaveLength(1) + const ssh = sshInvocations[0]! + expect(ssh.args).toContain("-p") + expect(ssh.args).toContain("2222") + expect(ssh.args).toContain("dev@localhost") + + const cloneDoneIndex = recorded.findIndex( + (entry) => entry.command === "docker" && entry.args[0] === "exec" && entry.args.includes("/run/docker-git/clone.done") + ) + const sshIndex = recorded.findIndex((entry) => entry.command === "ssh") + expect(cloneDoneIndex).toBeGreaterThanOrEqual(0) + expect(sshIndex).toBeGreaterThan(cloneDoneIndex) + }) + ) + .pipe(Effect.provide(NodeContext.layer)) + ) +}) diff --git a/packages/web/scripts/terminal-ws.mjs b/packages/web/scripts/terminal-ws.mjs index 57c167b7..83c6c300 100644 --- a/packages/web/scripts/terminal-ws.mjs +++ b/packages/web/scripts/terminal-ws.mjs @@ -179,6 +179,7 @@ const runRecreateFlow = async (projectDir, send) => { config: config.template, outDir: projectDir, runUp: false, + openSsh: false, force: true, forceEnv: false, waitForClone: false diff --git a/patches/@ton-ai-core__vibecode-linter@1.0.6.patch b/patches/@ton-ai-core__vibecode-linter@1.0.6.patch new file mode 100644 index 00000000..986a5e6b --- /dev/null +++ b/patches/@ton-ai-core__vibecode-linter@1.0.6.patch @@ -0,0 +1,15 @@ +diff --git a/dist/shell/utils/dependencies.js b/dist/shell/utils/dependencies.js +index bd96968de92e45ef4543abb7781bd71d333c8090..47a6bf82594a74b1acefbe24c2e00b0171dc3cf6 100644 +--- a/dist/shell/utils/dependencies.js ++++ b/dist/shell/utils/dependencies.js +@@ -42,7 +42,9 @@ const DEPENDENCIES = [ + { + name: "TypeScript", + command: "tsc", +- checkCommand: "npx tsc --version", ++ // npm@11+ `npx tsc` resolves to the unrelated `tsc` package. ++ // We just need the TypeScript compiler API dependency to be present. ++ checkCommand: "node -e \"require('typescript')\"", + installCommand: "npm install", + required: true, + }, diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 7fe72578..69e9e1ac 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -4,6 +4,11 @@ settings: autoInstallPeers: true excludeLinksFromLockfile: false +patchedDependencies: + '@ton-ai-core/vibecode-linter@1.0.6': + hash: f1c1c4c58bdb59306606b68007b20230987779acff18507650a013d40de72b55 + path: patches/@ton-ai-core__vibecode-linter@1.0.6.patch + importers: .: @@ -101,7 +106,7 @@ importers: version: 0.0.13(eslint@9.39.2(jiti@2.6.1))(typescript@5.9.3) '@ton-ai-core/vibecode-linter': specifier: ^1.0.6 - version: 1.0.6 + version: 1.0.6(patch_hash=f1c1c4c58bdb59306606b68007b20230987779acff18507650a013d40de72b55) '@types/node': specifier: ^24.10.9 version: 24.10.9 @@ -304,7 +309,7 @@ importers: version: 0.0.13(eslint@9.39.2(jiti@2.6.1))(typescript@5.9.3) '@ton-ai-core/vibecode-linter': specifier: ^1.0.6 - version: 1.0.6 + version: 1.0.6(patch_hash=f1c1c4c58bdb59306606b68007b20230987779acff18507650a013d40de72b55) '@types/node': specifier: ^24.10.9 version: 24.10.9 @@ -5777,7 +5782,7 @@ snapshots: postcss: 8.5.6 tailwindcss: 4.1.18 - '@ton-ai-core/vibecode-linter@1.0.6': + '@ton-ai-core/vibecode-linter@1.0.6(patch_hash=f1c1c4c58bdb59306606b68007b20230987779acff18507650a013d40de72b55)': dependencies: ajv: 8.17.1 effect: 3.19.14 diff --git a/scripts/e2e/ci.sh b/scripts/e2e/ci.sh new file mode 100644 index 00000000..eb84c267 --- /dev/null +++ b/scripts/e2e/ci.sh @@ -0,0 +1,81 @@ +#!/usr/bin/env bash +set -euo pipefail + +ROOT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/../.." && pwd)" + +E2E_ROOT="${ROOT_DIR}/.e2e" +BIN_DIR="${E2E_ROOT}/bin" +LOG_DIR="${E2E_ROOT}/logs" +PROJECTS_ROOT="${E2E_ROOT}/projects" + +REPO_URL="https://github.com/octocat/Hello-World/issues/1" +OUT_DIR="${PROJECTS_ROOT}/octocat/hello-world/issue-1" +CONTAINER_NAME="dg-hello-world-issue-1" +TARGET_DIR="/home/dev/octocat/hello-world/issue-1" + +SSH_LOG="${LOG_DIR}/ssh.log" +ENV_PROJECT="${E2E_ROOT}/project.env" +SSH_PORT="${E2E_SSH_PORT:-}" + +fail() { + echo "e2e: $*" >&2 + exit 1 +} + +cleanup() { + if [[ -d "$OUT_DIR" ]]; then + ( + cd "$OUT_DIR" && docker compose down -v >/dev/null 2>&1 || true + ) + fi +} +trap cleanup EXIT + +mkdir -p "$BIN_DIR" "$LOG_DIR" "$PROJECTS_ROOT" +rm -f "$SSH_LOG" + +cat > "$ENV_PROJECT" <<'EOF' +# Keep CI fast and deterministic (Codex auto-update hits the network on container start) +CODEX_AUTO_UPDATE=0 +CODEX_SHARE_AUTH=0 +EOF + +export DOCKER_GIT_PROJECTS_ROOT="$PROJECTS_ROOT" + +cat > "$BIN_DIR/ssh" <<'EOF' +#!/usr/bin/env bash +set -euo pipefail + +: "${SSH_LOG_PATH:?SSH_LOG_PATH is required}" +printf "ssh %s\n" "$*" >> "$SSH_LOG_PATH" +exit 0 +EOF +chmod +x "$BIN_DIR/ssh" + +export SSH_LOG_PATH="$SSH_LOG" +export PATH="$BIN_DIR:$PATH" + +cd "$ROOT_DIR" + +pnpm --filter ./packages/app build:docker-git + +command -v script >/dev/null 2>&1 || fail "missing 'script' command (util-linux)" + +if [[ -z "$SSH_PORT" ]]; then + SSH_PORT="$(node -e 'const net=require("net"); const s=net.createServer(); s.listen(0,"127.0.0.1",()=>{console.log(s.address().port); s.close();});')" +fi + +script -q -e -c "node packages/app/dist/src/docker-git/main.js clone \"$REPO_URL\" --force --ssh-port \"$SSH_PORT\" --env-project \"$ENV_PROJECT\"" /dev/null + +[[ -s "$SSH_LOG" ]] || fail "expected ssh to be invoked; log is empty: $SSH_LOG" +grep -q "dev@localhost" "$SSH_LOG" || fail "expected ssh args to include dev@localhost; got: $(cat "$SSH_LOG")" +grep -q -- "-p " "$SSH_LOG" || fail "expected ssh args to include -p ; got: $(cat "$SSH_LOG")" + +docker ps --format '{{.Names}}' | grep -qx "$CONTAINER_NAME" || fail "expected container to be running: $CONTAINER_NAME" +docker exec "$CONTAINER_NAME" bash -lc "test -d '$TARGET_DIR/.git'" || fail "expected repo to be cloned at: $TARGET_DIR" +branch="$(docker exec "$CONTAINER_NAME" bash -lc "cd '$TARGET_DIR' && git rev-parse --abbrev-ref HEAD")" +[[ "$branch" == "issue-1" ]] || fail "expected HEAD branch issue-1, got: $branch" + +[[ -f "$OUT_DIR/docker-git.json" ]] || fail "expected project config file: $OUT_DIR/docker-git.json" + +echo "e2e: OK" From 0953e71c9b6b78b0eb50bfe6084fe8b8777a1c66 Mon Sep 17 00:00:00 2001 From: skulidropek <66840575+skulidropek@users.noreply.github.com> Date: Sun, 15 Feb 2026 10:08:30 +0000 Subject: [PATCH 2/7] test(e2e): harden CI system check --- .e2e/bin/ssh | 6 - .e2e/project.env | 3 - .../octocat/hello-world/issue-1/.dockerignore | 3 - .../octocat/hello-world/issue-1/.gitignore | 9 - .../octocat/hello-world/issue-1/Dockerfile | 172 ---- .../hello-world/issue-1/docker-compose.yml | 32 - .../hello-world/issue-1/docker-git.json | 22 - .../octocat/hello-world/issue-1/entrypoint.sh | 836 ------------------ .gitignore | 1 + scripts/e2e/ci.sh | 3 +- 10 files changed, 3 insertions(+), 1084 deletions(-) delete mode 100755 .e2e/bin/ssh delete mode 100644 .e2e/project.env delete mode 100644 .e2e/projects/octocat/hello-world/issue-1/.dockerignore delete mode 100644 .e2e/projects/octocat/hello-world/issue-1/.gitignore delete mode 100644 .e2e/projects/octocat/hello-world/issue-1/Dockerfile delete mode 100644 .e2e/projects/octocat/hello-world/issue-1/docker-compose.yml delete mode 100644 .e2e/projects/octocat/hello-world/issue-1/docker-git.json delete mode 100755 .e2e/projects/octocat/hello-world/issue-1/entrypoint.sh diff --git a/.e2e/bin/ssh b/.e2e/bin/ssh deleted file mode 100755 index 81c4a4f2..00000000 --- a/.e2e/bin/ssh +++ /dev/null @@ -1,6 +0,0 @@ -#!/usr/bin/env bash -set -euo pipefail - -: "${SSH_LOG_PATH:?SSH_LOG_PATH is required}" -printf "ssh %s\n" "$*" >> "$SSH_LOG_PATH" -exit 0 diff --git a/.e2e/project.env b/.e2e/project.env deleted file mode 100644 index 4d7783c6..00000000 --- a/.e2e/project.env +++ /dev/null @@ -1,3 +0,0 @@ -# Keep CI fast and deterministic (Codex auto-update hits the network on container start) -CODEX_AUTO_UPDATE=0 -CODEX_SHARE_AUTH=0 diff --git a/.e2e/projects/octocat/hello-world/issue-1/.dockerignore b/.e2e/projects/octocat/hello-world/issue-1/.dockerignore deleted file mode 100644 index c3c63f6c..00000000 --- a/.e2e/projects/octocat/hello-world/issue-1/.dockerignore +++ /dev/null @@ -1,3 +0,0 @@ -# docker-git build context -.orch/ -authorized_keys diff --git a/.e2e/projects/octocat/hello-world/issue-1/.gitignore b/.e2e/projects/octocat/hello-world/issue-1/.gitignore deleted file mode 100644 index c02cca46..00000000 --- a/.e2e/projects/octocat/hello-world/issue-1/.gitignore +++ /dev/null @@ -1,9 +0,0 @@ -# docker-git project files -# NOTE: this directory is intended to be committed to the docker-git state repository. -# It intentionally does not ignore .orch/ or auth files; keep the state repo private. - -# Volatile Codex artifacts (do not commit) -.orch/auth/codex/log/ -.orch/auth/codex/tmp/ -.orch/auth/codex/sessions/ -.orch/auth/codex/models_cache.json diff --git a/.e2e/projects/octocat/hello-world/issue-1/Dockerfile b/.e2e/projects/octocat/hello-world/issue-1/Dockerfile deleted file mode 100644 index 4faec5bd..00000000 --- a/.e2e/projects/octocat/hello-world/issue-1/Dockerfile +++ /dev/null @@ -1,172 +0,0 @@ -FROM ubuntu:24.04 - -ENV DEBIAN_FRONTEND=noninteractive -ENV NVM_DIR=/usr/local/nvm - -RUN apt-get update && apt-get install -y --no-install-recommends openssh-server git gh ca-certificates curl unzip bsdutils sudo make docker.io docker-compose-v2 bash-completion zsh zsh-autosuggestions xauth ncurses-term && rm -rf /var/lib/apt/lists/* - -# Passwordless sudo for all users (container is disposable) -RUN printf "%s\n" "ALL ALL=(ALL) NOPASSWD:ALL" > /etc/sudoers.d/zz-all && chmod 0440 /etc/sudoers.d/zz-all - -# Shell prompt: show git branch for interactive sessions -RUN cat <<'EOF' > /etc/profile.d/zz-prompt.sh -docker_git_branch() { git rev-parse --abbrev-ref HEAD 2>/dev/null; } -docker_git_prompt_apply() { - local b - b="$(docker_git_branch)" - local base="[\t] \w" - if [ -n "$b" ]; then - PS1="${base} (${b})> " - else - PS1="${base}> " - fi -} -if [ -n "$PROMPT_COMMAND" ]; then - PROMPT_COMMAND="docker_git_prompt_apply;$PROMPT_COMMAND" -else - PROMPT_COMMAND="docker_git_prompt_apply" -fi -EOF -RUN chmod 0644 /etc/profile.d/zz-prompt.sh -RUN printf "%s\n" \ - "if [ -f /etc/profile.d/zz-prompt.sh ]; then . /etc/profile.d/zz-prompt.sh; fi" \ - >> /etc/bash.bashrc -RUN cat <<'EOF' > /etc/profile.d/zz-bash-completion.sh -if ! shopt -oq posix; then - if [ -f /usr/share/bash-completion/bash_completion ]; then - . /usr/share/bash-completion/bash_completion - elif [ -f /etc/bash_completion ]; then - . /etc/bash_completion - fi -fi -EOF -RUN chmod 0644 /etc/profile.d/zz-bash-completion.sh -RUN printf "%s\n" \ - "if [ -f /etc/profile.d/zz-bash-completion.sh ]; then . /etc/profile.d/zz-bash-completion.sh; fi" \ - >> /etc/bash.bashrc -RUN cat <<'EOF' > /etc/profile.d/zz-bash-history.sh -if [ -n "$BASH_VERSION" ]; then - case "$-" in - *i*) - HISTFILE="${HISTFILE:-$HOME/.bash_history}" - HISTSIZE="${HISTSIZE:-10000}" - HISTFILESIZE="${HISTFILESIZE:-20000}" - HISTCONTROL="${HISTCONTROL:-ignoredups:erasedups}" - export HISTFILE HISTSIZE HISTFILESIZE HISTCONTROL - shopt -s histappend - if [ -n "${PROMPT_COMMAND-}" ]; then - PROMPT_COMMAND="history -a; ${PROMPT_COMMAND}" - else - PROMPT_COMMAND="history -a" - fi - ;; - esac -fi -EOF -RUN chmod 0644 /etc/profile.d/zz-bash-history.sh -RUN printf "%s\n" \ - "if [ -f /etc/profile.d/zz-bash-history.sh ]; then . /etc/profile.d/zz-bash-history.sh; fi" \ - >> /etc/bash.bashrc -RUN mkdir -p /etc/zsh -RUN cat <<'EOF' > /etc/zsh/zshrc -setopt PROMPT_SUBST - -# Terminal compatibility: if terminfo for $TERM is missing (common over SSH), -# fall back to xterm-256color so ZLE doesn't garble the display. -if command -v infocmp >/dev/null 2>&1; then - if ! infocmp "$TERM" >/dev/null 2>&1; then - export TERM=xterm-256color - fi -fi - -autoload -Uz compinit -compinit - -# Completion UX: cycle matches instead of listing them into scrollback. -setopt AUTO_MENU -setopt MENU_COMPLETE -unsetopt AUTO_LIST -unsetopt LIST_BEEP - -# Command completion ordering: prefer real commands/builtins over internal helper functions. -zstyle ':completion:*' tag-order builtins commands aliases reserved-words functions - -autoload -Uz add-zsh-hook -docker_git_branch() { git rev-parse --abbrev-ref HEAD 2>/dev/null; } -docker_git_prompt_apply() { - local b - b="$(docker_git_branch)" - local base="[%*] %~" - if [[ -n "$b" ]]; then - PROMPT="$base ($b)> " - else - PROMPT="$base> " - fi -} -add-zsh-hook precmd docker_git_prompt_apply - -HISTFILE="${HISTFILE:-$HOME/.zsh_history}" -HISTSIZE="${HISTSIZE:-10000}" -SAVEHIST="${SAVEHIST:-20000}" -setopt HIST_IGNORE_ALL_DUPS -setopt SHARE_HISTORY -setopt INC_APPEND_HISTORY - -if [ -f "$HISTFILE" ]; then - fc -R "$HISTFILE" 2>/dev/null || true -fi -if [ -f "$HOME/.bash_history" ] && [ "$HISTFILE" != "$HOME/.bash_history" ]; then - fc -R "$HOME/.bash_history" 2>/dev/null || true -fi - -bindkey '^[[A' history-search-backward -bindkey '^[[B' history-search-forward - -if [[ "${DOCKER_GIT_ZSH_AUTOSUGGEST:-1}" == "1" ]] && [ -f /usr/share/zsh-autosuggestions/zsh-autosuggestions.zsh ]; then - # Suggest from history first, then fall back to completion (commands + paths). - # This gives "ghost text" suggestions without needing to press . - ZSH_AUTOSUGGEST_HIGHLIGHT_STYLE="${DOCKER_GIT_ZSH_AUTOSUGGEST_STYLE:-fg=8,italic}" - if [[ -n "${DOCKER_GIT_ZSH_AUTOSUGGEST_STRATEGY-}" ]]; then - ZSH_AUTOSUGGEST_STRATEGY=(${=DOCKER_GIT_ZSH_AUTOSUGGEST_STRATEGY}) - else - ZSH_AUTOSUGGEST_STRATEGY=(history completion) - fi - source /usr/share/zsh-autosuggestions/zsh-autosuggestions.zsh -fi -EOF - -# Tooling: Node 24 (NodeSource) + nvm -RUN curl -fsSL https://deb.nodesource.com/setup_24.x | bash - && apt-get install -y --no-install-recommends nodejs && node -v && npm -v && corepack --version && rm -rf /var/lib/apt/lists/* -RUN mkdir -p /usr/local/nvm && curl -fsSL https://raw.githubusercontent.com/nvm-sh/nvm/v0.40.1/install.sh | bash -RUN printf "export NVM_DIR=/usr/local/nvm\n[ -s /usr/local/nvm/nvm.sh ] && . /usr/local/nvm/nvm.sh\n" > /etc/profile.d/nvm.sh && chmod 0644 /etc/profile.d/nvm.sh - -# Tooling: pnpm + Codex CLI (bun) -RUN corepack enable && corepack prepare pnpm@10.27.0 --activate -ENV BUN_INSTALL=/usr/local/bun -ENV TERM=xterm-256color -ENV PATH="/usr/local/bun/bin:$PATH" -RUN curl -fsSL https://bun.sh/install | bash -RUN ln -sf /usr/local/bun/bin/bun /usr/local/bin/bun -RUN script -q -e -c "bun add -g @openai/codex@latest" /dev/null -RUN ln -sf /usr/local/bun/bin/codex /usr/local/bin/codex -RUN printf "export BUN_INSTALL=/usr/local/bun\nexport PATH=/usr/local/bun/bin:$PATH\n" > /etc/profile.d/bun.sh && chmod 0644 /etc/profile.d/bun.sh - -# Create non-root user for SSH (align UID/GID with host user 1000) -RUN if id -u ubuntu >/dev/null 2>&1; then if getent group 1000 >/dev/null 2>&1; then EXISTING_GROUP="$(getent group 1000 | cut -d: -f1)"; if [ "$EXISTING_GROUP" != "dev" ]; then groupmod -n dev "$EXISTING_GROUP" || true; fi; fi; usermod -l dev -d /home/dev -m -s /usr/bin/zsh ubuntu || true; fi -RUN if id -u dev >/dev/null 2>&1; then usermod -u 1000 -g 1000 -o dev; else groupadd -g 1000 dev || true; useradd -m -s /usr/bin/zsh -u 1000 -g 1000 -o dev; fi -RUN printf "%s\n" "dev ALL=(ALL) NOPASSWD:ALL" > /etc/sudoers.d/dev && chmod 0440 /etc/sudoers.d/dev - -# sshd runtime dir -RUN mkdir -p /run/sshd - -# Harden sshd: disable password auth and root login -RUN printf "%s\n" "PasswordAuthentication no" "PermitRootLogin no" "PubkeyAuthentication yes" "X11Forwarding yes" "X11UseLocalhost yes" "PermitUserEnvironment yes" "AllowUsers dev" > /etc/ssh/sshd_config.d/dev.conf - -# Workspace path (supports root-level dirs like /repo) -RUN mkdir -p /home/dev/octocat/hello-world/issue-1 && chown -R 1000:1000 /home/dev && if [ "/home/dev/octocat/hello-world/issue-1" != "/" ]; then chown -R 1000:1000 "/home/dev/octocat/hello-world/issue-1"; fi - -COPY entrypoint.sh /entrypoint.sh -RUN chmod +x /entrypoint.sh - -EXPOSE 22 -ENTRYPOINT ["/entrypoint.sh"] \ No newline at end of file diff --git a/.e2e/projects/octocat/hello-world/issue-1/docker-compose.yml b/.e2e/projects/octocat/hello-world/issue-1/docker-compose.yml deleted file mode 100644 index 81f665dc..00000000 --- a/.e2e/projects/octocat/hello-world/issue-1/docker-compose.yml +++ /dev/null @@ -1,32 +0,0 @@ -services: - dg-hello-world-issue-1: - build: . - container_name: dg-hello-world-issue-1 - environment: - REPO_URL: "https://github.com/octocat/Hello-World.git" - REPO_REF: "issue-1" - FORK_REPO_URL: "" - TARGET_DIR: "/home/dev/octocat/hello-world/issue-1" - CODEX_HOME: "/home/dev/.codex" - env_file: - - ./.orch/env/global.env - - ../../../../project.env - ports: - - "127.0.0.1:34623:22" - volumes: - - dg-hello-world-issue-1-home:/home/dev - - ../../..:/home/dev/.docker-git - - ../../../authorized_keys:/authorized_keys:ro - - ./.orch/auth/codex:/home/dev/.codex - - ../../../.orch/auth/codex:/home/dev/.codex-shared - - /var/run/docker.sock:/var/run/docker.sock - networks: - - dg-hello-world-issue-1-net - - -networks: - dg-hello-world-issue-1-net: - driver: bridge - -volumes: - dg-hello-world-issue-1-home: diff --git a/.e2e/projects/octocat/hello-world/issue-1/docker-git.json b/.e2e/projects/octocat/hello-world/issue-1/docker-git.json deleted file mode 100644 index 56f844e1..00000000 --- a/.e2e/projects/octocat/hello-world/issue-1/docker-git.json +++ /dev/null @@ -1,22 +0,0 @@ -{ - "schemaVersion": 1, - "template": { - "containerName": "dg-hello-world-issue-1", - "serviceName": "dg-hello-world-issue-1", - "sshUser": "dev", - "sshPort": 34623, - "repoUrl": "https://github.com/octocat/Hello-World.git", - "repoRef": "issue-1", - "targetDir": "/home/dev/octocat/hello-world/issue-1", - "volumeName": "dg-hello-world-issue-1-home", - "dockerGitPath": "../../..", - "authorizedKeysPath": "../../../authorized_keys", - "envGlobalPath": "./.orch/env/global.env", - "envProjectPath": "../../../../project.env", - "codexAuthPath": "./.orch/auth/codex", - "codexSharedAuthPath": "../../../.orch/auth/codex", - "codexHome": "/home/dev/.codex", - "enableMcpPlaywright": false, - "pnpmVersion": "10.27.0" - } -} diff --git a/.e2e/projects/octocat/hello-world/issue-1/entrypoint.sh b/.e2e/projects/octocat/hello-world/issue-1/entrypoint.sh deleted file mode 100755 index f9799cc2..00000000 --- a/.e2e/projects/octocat/hello-world/issue-1/entrypoint.sh +++ /dev/null @@ -1,836 +0,0 @@ -#!/usr/bin/env bash -set -euo pipefail - -REPO_URL="${REPO_URL:-}" -REPO_REF="${REPO_REF:-}" -FORK_REPO_URL="${FORK_REPO_URL:-}" -TARGET_DIR="${TARGET_DIR:-/home/dev/octocat/hello-world/issue-1}" -GIT_AUTH_USER="${GIT_AUTH_USER:-${GITHUB_USER:-x-access-token}}" -GIT_AUTH_TOKEN="${GIT_AUTH_TOKEN:-${GITHUB_TOKEN:-${GH_TOKEN:-}}}" -GH_TOKEN="${GH_TOKEN:-${GIT_AUTH_TOKEN:-}}" -GITHUB_TOKEN="${GITHUB_TOKEN:-${GH_TOKEN:-}}" -GIT_USER_NAME="${GIT_USER_NAME:-}" -GIT_USER_EMAIL="${GIT_USER_EMAIL:-}" -CODEX_AUTO_UPDATE="${CODEX_AUTO_UPDATE:-1}" -MCP_PLAYWRIGHT_ENABLE="${MCP_PLAYWRIGHT_ENABLE:-0}" -MCP_PLAYWRIGHT_CDP_ENDPOINT="${MCP_PLAYWRIGHT_CDP_ENDPOINT:-}" -MCP_PLAYWRIGHT_ISOLATED="${MCP_PLAYWRIGHT_ISOLATED:-1}" - -# 1) Authorized keys are mounted from host at /authorized_keys -mkdir -p /home/dev/.ssh -chmod 700 /home/dev/.ssh - -if [[ -f /authorized_keys ]]; then - cp /authorized_keys /home/dev/.ssh/authorized_keys - chmod 600 /home/dev/.ssh/authorized_keys -fi - -chown -R 1000:1000 /home/dev/.ssh - -# Ensure Codex home exists if mounted -mkdir -p /home/dev/.codex -chown -R 1000:1000 /home/dev/.codex - -# Ensure home ownership matches the dev UID/GID (volumes may be stale) -HOME_OWNER="$(stat -c "%u:%g" /home/dev 2>/dev/null || echo "")" -if [[ "$HOME_OWNER" != "1000:1000" ]]; then - chown -R 1000:1000 /home/dev || true -fi - -# Share Codex auth.json across projects (avoids refresh_token_reused) -CODEX_SHARE_AUTH="${CODEX_SHARE_AUTH:-1}" -if [[ "$CODEX_SHARE_AUTH" == "1" ]]; then - CODEX_SHARED_HOME="/home/dev/.codex-shared" - mkdir -p "$CODEX_SHARED_HOME" - chown -R 1000:1000 "$CODEX_SHARED_HOME" || true - - AUTH_FILE="/home/dev/.codex/auth.json" - SHARED_AUTH_FILE="$CODEX_SHARED_HOME/auth.json" - - # Guard against a bad bind mount creating a directory at auth.json. - if [[ -d "$AUTH_FILE" ]]; then - mv "$AUTH_FILE" "$AUTH_FILE.bak-$(date +%s)" || true - fi - if [[ -e "$AUTH_FILE" && ! -L "$AUTH_FILE" ]]; then - rm -f "$AUTH_FILE" || true - fi - - ln -sf "$SHARED_AUTH_FILE" "$AUTH_FILE" -fi - -# Bootstrap ~/.docker-git for nested docker-git usage inside this container. -DOCKER_GIT_HOME="/home/dev/.docker-git" -DOCKER_GIT_AUTH_DIR="$DOCKER_GIT_HOME/.orch/auth/codex" -DOCKER_GIT_ENV_DIR="$DOCKER_GIT_HOME/.orch/env" -DOCKER_GIT_ENV_GLOBAL="$DOCKER_GIT_ENV_DIR/global.env" -DOCKER_GIT_ENV_PROJECT="$DOCKER_GIT_ENV_DIR/project.env" -DOCKER_GIT_AUTH_KEYS="$DOCKER_GIT_HOME/authorized_keys" - -mkdir -p "$DOCKER_GIT_AUTH_DIR" "$DOCKER_GIT_ENV_DIR" "$DOCKER_GIT_HOME/.orch/auth/gh" - -if [[ -f "/home/dev/.ssh/authorized_keys" ]]; then - cp "/home/dev/.ssh/authorized_keys" "$DOCKER_GIT_AUTH_KEYS" -elif [[ -f /authorized_keys ]]; then - cp /authorized_keys "$DOCKER_GIT_AUTH_KEYS" -fi -if [[ -f "$DOCKER_GIT_AUTH_KEYS" ]]; then - chmod 600 "$DOCKER_GIT_AUTH_KEYS" || true -fi - -if [[ ! -f "$DOCKER_GIT_ENV_GLOBAL" ]]; then - cat <<'EOF' > "$DOCKER_GIT_ENV_GLOBAL" -# docker-git env -# KEY=value -EOF -fi -if [[ ! -f "$DOCKER_GIT_ENV_PROJECT" ]]; then - cat <<'EOF' > "$DOCKER_GIT_ENV_PROJECT" -# docker-git project env defaults -CODEX_SHARE_AUTH=1 -CODEX_AUTO_UPDATE=1 -DOCKER_GIT_ZSH_AUTOSUGGEST=1 -DOCKER_GIT_ZSH_AUTOSUGGEST_STYLE=fg=8,italic -DOCKER_GIT_ZSH_AUTOSUGGEST_STRATEGY=history completion -MCP_PLAYWRIGHT_ISOLATED=1 -EOF -fi - -upsert_env_var() { - local file="$1" - local key="$2" - local value="$3" - local tmp - tmp="$(mktemp)" - awk -v key="$key" 'index($0, key "=") != 1 { print }' "$file" > "$tmp" - printf "%s=%s\n" "$key" "$value" >> "$tmp" - mv "$tmp" "$file" -} - -copy_if_distinct_file() { - local source="$1" - local target="$2" - if [[ ! -f "$source" ]]; then - return 1 - fi - local source_real="" - local target_real="" - source_real="$(readlink -f "$source" 2>/dev/null || true)" - target_real="$(readlink -f "$target" 2>/dev/null || true)" - if [[ -n "$source_real" && -n "$target_real" && "$source_real" == "$target_real" ]]; then - return 0 - fi - cp "$source" "$target" - return 0 -} - -if [[ -n "$GH_TOKEN" ]]; then - upsert_env_var "$DOCKER_GIT_ENV_GLOBAL" "GH_TOKEN" "$GH_TOKEN" -fi -if [[ -n "$GITHUB_TOKEN" ]]; then - upsert_env_var "$DOCKER_GIT_ENV_GLOBAL" "GITHUB_TOKEN" "$GITHUB_TOKEN" -elif [[ -n "$GH_TOKEN" ]]; then - upsert_env_var "$DOCKER_GIT_ENV_GLOBAL" "GITHUB_TOKEN" "$GH_TOKEN" -fi - -SOURCE_CODEX_CONFIG="/home/dev/.codex/config.toml" -copy_if_distinct_file "$SOURCE_CODEX_CONFIG" "$DOCKER_GIT_AUTH_DIR/config.toml" || true - -SOURCE_SHARED_AUTH="/home/dev/.codex-shared/auth.json" -SOURCE_LOCAL_AUTH="/home/dev/.codex/auth.json" -if [[ -f "$SOURCE_SHARED_AUTH" ]]; then - copy_if_distinct_file "$SOURCE_SHARED_AUTH" "$DOCKER_GIT_AUTH_DIR/auth.json" || true -elif [[ -f "$SOURCE_LOCAL_AUTH" ]]; then - copy_if_distinct_file "$SOURCE_LOCAL_AUTH" "$DOCKER_GIT_AUTH_DIR/auth.json" || true -fi -if [[ -f "$DOCKER_GIT_AUTH_DIR/auth.json" ]]; then - chmod 600 "$DOCKER_GIT_AUTH_DIR/auth.json" || true -fi - -chown -R 1000:1000 "$DOCKER_GIT_HOME" || true - -# Optional: configure Playwright MCP for Codex (browser automation) -CODEX_CONFIG_FILE="/home/dev/.codex/config.toml" - -# Keep config.toml consistent with the container build. -# If Playwright MCP is disabled for this container, remove the block so Codex -# doesn't try (and fail) to spawn docker-git-playwright-mcp. -if [[ "$MCP_PLAYWRIGHT_ENABLE" != "1" ]]; then - if [[ -f "$CODEX_CONFIG_FILE" ]] && grep -q "^\[mcp_servers\.playwright" "$CODEX_CONFIG_FILE" 2>/dev/null; then - awk ' - BEGIN { skip=0 } - /^# docker-git: Playwright MCP/ { next } - /^\[mcp_servers[.]playwright([.]|\])/ { skip=1; next } - skip==1 && /^\[/ { skip=0 } - skip==0 { print } - ' "$CODEX_CONFIG_FILE" > "$CODEX_CONFIG_FILE.tmp" - mv "$CODEX_CONFIG_FILE.tmp" "$CODEX_CONFIG_FILE" - fi -else - if [[ ! -f "$CODEX_CONFIG_FILE" ]]; then - mkdir -p "$(dirname "$CODEX_CONFIG_FILE")" || true - cat <<'EOF' > "$CODEX_CONFIG_FILE" -# docker-git codex config -model = "gpt-5.3-codex" -model_reasoning_effort = "xhigh" -personality = "pragmatic" - -approval_policy = "never" -sandbox_mode = "danger-full-access" -web_search = "live" - -[features] -shell_snapshot = true -collab = true -apps = true -shell_tool = true -EOF - chown 1000:1000 "$CODEX_CONFIG_FILE" || true - fi - - if [[ -z "$MCP_PLAYWRIGHT_CDP_ENDPOINT" ]]; then - MCP_PLAYWRIGHT_CDP_ENDPOINT="http://dg-hello-world-issue-1-browser:9223" - fi - - # Replace the docker-git Playwright block to allow upgrades via --force without manual edits. - if grep -q "^\[mcp_servers\.playwright" "$CODEX_CONFIG_FILE" 2>/dev/null; then - awk ' - BEGIN { skip=0 } - /^# docker-git: Playwright MCP/ { next } - /^\[mcp_servers[.]playwright([.]|\])/ { skip=1; next } - skip==1 && /^\[/ { skip=0 } - skip==0 { print } - ' "$CODEX_CONFIG_FILE" > "$CODEX_CONFIG_FILE.tmp" - mv "$CODEX_CONFIG_FILE.tmp" "$CODEX_CONFIG_FILE" - fi - - cat <> "$CODEX_CONFIG_FILE" - -# docker-git: Playwright MCP (connects to Chromium via CDP) -[mcp_servers.playwright] -command = "docker-git-playwright-mcp" -args = [] -EOF -fi - -# Prefer zsh for dev when available -if command -v zsh >/dev/null 2>&1; then - usermod -s /usr/bin/zsh dev || true -fi - -# Ensure dev has a zshrc and disable newuser wizard -ZSHENV_PATH="/etc/zsh/zshenv" -if [[ -f "$ZSHENV_PATH" ]]; then - if ! grep -q "ZSH_DISABLE_NEWUSER_INSTALL" "$ZSHENV_PATH"; then - printf "%s\n" "export ZSH_DISABLE_NEWUSER_INSTALL=1" >> "$ZSHENV_PATH" - fi -else - printf "%s\n" "export ZSH_DISABLE_NEWUSER_INSTALL=1" > "$ZSHENV_PATH" -fi -USER_ZSHRC="/home/dev/.zshrc" -if [[ ! -f "$USER_ZSHRC" ]]; then - cat <<'EOF' > "$USER_ZSHRC" -# docker-git default zshrc -if [ -f /etc/zsh/zshrc ]; then - source /etc/zsh/zshrc -fi -EOF - chown 1000:1000 "$USER_ZSHRC" || true -fi - -# Ensure docker-git prompt is configured for interactive shells -PROMPT_PATH="/etc/profile.d/zz-prompt.sh" -if [[ ! -s "$PROMPT_PATH" ]]; then - cat <<'EOF' > "$PROMPT_PATH" -docker_git_branch() { git rev-parse --abbrev-ref HEAD 2>/dev/null; } -docker_git_prompt_apply() { - local b - b="$(docker_git_branch)" - local base="[\t] \w" - if [ -n "$b" ]; then - PS1="${base} (${b})> " - else - PS1="${base}> " - fi -} -if [ -n "$PROMPT_COMMAND" ]; then - PROMPT_COMMAND="docker_git_prompt_apply;$PROMPT_COMMAND" -else - PROMPT_COMMAND="docker_git_prompt_apply" -fi -EOF - chmod 0644 "$PROMPT_PATH" -fi -if ! grep -q "zz-prompt.sh" /etc/bash.bashrc 2>/dev/null; then - printf "%s\n" "if [ -f /etc/profile.d/zz-prompt.sh ]; then . /etc/profile.d/zz-prompt.sh; fi" >> /etc/bash.bashrc -fi - -# Ensure bash completion is configured for interactive shells -COMPLETION_PATH="/etc/profile.d/zz-bash-completion.sh" -if [[ ! -s "$COMPLETION_PATH" ]]; then - cat <<'EOF' > "$COMPLETION_PATH" -if ! shopt -oq posix; then - if [ -f /usr/share/bash-completion/bash_completion ]; then - . /usr/share/bash-completion/bash_completion - elif [ -f /etc/bash_completion ]; then - . /etc/bash_completion - fi -fi -EOF - chmod 0644 "$COMPLETION_PATH" -fi -if ! grep -q "zz-bash-completion.sh" /etc/bash.bashrc 2>/dev/null; then - printf "%s\n" "if [ -f /etc/profile.d/zz-bash-completion.sh ]; then . /etc/profile.d/zz-bash-completion.sh; fi" >> /etc/bash.bashrc -fi - -# Ensure bash history is configured for interactive shells -HISTORY_PATH="/etc/profile.d/zz-bash-history.sh" -if [[ ! -s "$HISTORY_PATH" ]]; then - cat <<'EOF' > "$HISTORY_PATH" -if [ -n "$BASH_VERSION" ]; then - case "$-" in - *i*) - HISTFILE="${HISTFILE:-$HOME/.bash_history}" - HISTSIZE="${HISTSIZE:-10000}" - HISTFILESIZE="${HISTFILESIZE:-20000}" - HISTCONTROL="${HISTCONTROL:-ignoredups:erasedups}" - export HISTFILE HISTSIZE HISTFILESIZE HISTCONTROL - shopt -s histappend - if [ -n "${PROMPT_COMMAND-}" ]; then - PROMPT_COMMAND="history -a; ${PROMPT_COMMAND}" - else - PROMPT_COMMAND="history -a" - fi - ;; - esac -fi -EOF - chmod 0644 "$HISTORY_PATH" -fi -if ! grep -q "zz-bash-history.sh" /etc/bash.bashrc 2>/dev/null; then - printf "%s\n" "if [ -f /etc/profile.d/zz-bash-history.sh ]; then . /etc/profile.d/zz-bash-history.sh; fi" >> /etc/bash.bashrc -fi - -# Ensure readline history search bindings for dev -INPUTRC_PATH="/home/dev/.inputrc" -if [[ ! -f "$INPUTRC_PATH" ]]; then - cat <<'EOF' > "$INPUTRC_PATH" -set show-all-if-ambiguous on -set completion-ignore-case on -"\e[A": history-search-backward -"\e[B": history-search-forward -EOF - chown 1000:1000 "$INPUTRC_PATH" || true -fi - -# Ensure zsh config exists for autosuggestions -ZSHRC_PATH="/etc/zsh/zshrc" -if [[ ! -s "$ZSHRC_PATH" ]]; then - mkdir -p /etc/zsh - cat <<'EOF' > "$ZSHRC_PATH" -setopt PROMPT_SUBST - -# Terminal compatibility: if terminfo for $TERM is missing (common over SSH), -# fall back to xterm-256color so ZLE doesn't garble the display. -if command -v infocmp >/dev/null 2>&1; then - if ! infocmp "$TERM" >/dev/null 2>&1; then - export TERM=xterm-256color - fi -fi - -autoload -Uz compinit -compinit - -# Completion UX: cycle matches instead of listing them into scrollback. -setopt AUTO_MENU -setopt MENU_COMPLETE -unsetopt AUTO_LIST -unsetopt LIST_BEEP - -# Command completion ordering: prefer real commands/builtins over internal helper functions. -zstyle ':completion:*' tag-order builtins commands aliases reserved-words functions - -autoload -Uz add-zsh-hook -docker_git_branch() { git rev-parse --abbrev-ref HEAD 2>/dev/null; } -docker_git_prompt_apply() { - local b - b="$(docker_git_branch)" - local base="[%*] %~" - if [[ -n "$b" ]]; then - PROMPT="$base ($b)> " - else - PROMPT="$base> " - fi -} -add-zsh-hook precmd docker_git_prompt_apply - -HISTFILE="${HISTFILE:-$HOME/.zsh_history}" -HISTSIZE="${HISTSIZE:-10000}" -SAVEHIST="${SAVEHIST:-20000}" -setopt HIST_IGNORE_ALL_DUPS -setopt SHARE_HISTORY -setopt INC_APPEND_HISTORY - -if [ -f "$HISTFILE" ]; then - fc -R "$HISTFILE" 2>/dev/null || true -fi -if [ -f "$HOME/.bash_history" ] && [ "$HISTFILE" != "$HOME/.bash_history" ]; then - fc -R "$HOME/.bash_history" 2>/dev/null || true -fi - -bindkey '^[[A' history-search-backward -bindkey '^[[B' history-search-forward - -if [[ "${DOCKER_GIT_ZSH_AUTOSUGGEST:-1}" == "1" ]] && [ -f /usr/share/zsh-autosuggestions/zsh-autosuggestions.zsh ]; then - # Suggest from history first, then fall back to completion (commands + paths). - # This gives "ghost text" suggestions without needing to press . - ZSH_AUTOSUGGEST_HIGHLIGHT_STYLE="${DOCKER_GIT_ZSH_AUTOSUGGEST_STYLE:-fg=8,italic}" - if [[ -n "${DOCKER_GIT_ZSH_AUTOSUGGEST_STRATEGY-}" ]]; then - ZSH_AUTOSUGGEST_STRATEGY=(${=DOCKER_GIT_ZSH_AUTOSUGGEST_STRATEGY}) - else - ZSH_AUTOSUGGEST_STRATEGY=(history completion) - fi - source /usr/share/zsh-autosuggestions/zsh-autosuggestions.zsh -fi -EOF -fi - -# Ensure codex resume hint is shown for interactive shells -CODEX_HINT_PATH="/etc/profile.d/zz-codex-resume.sh" -if [[ ! -s "$CODEX_HINT_PATH" ]]; then - cat <<'EOF' > "$CODEX_HINT_PATH" -if [ -n "$BASH_VERSION" ]; then - case "$-" in - *i*) - if [ -z "${CODEX_RESUME_HINT_SHOWN-}" ]; then - echo "Старые сессии можно запустить с помощью codex resume или codex resume , если знаешь айди." - export CODEX_RESUME_HINT_SHOWN=1 - fi - ;; - esac -fi -if [ -n "$ZSH_VERSION" ]; then - if [[ "$-" == *i* ]]; then - if [[ -z "${CODEX_RESUME_HINT_SHOWN-}" ]]; then - echo "Старые сессии можно запустить с помощью codex resume или codex resume , если знаешь айди." - export CODEX_RESUME_HINT_SHOWN=1 - fi - fi -fi -EOF - chmod 0644 "$CODEX_HINT_PATH" -fi -if ! grep -q "zz-codex-resume.sh" /etc/bash.bashrc 2>/dev/null; then - printf "%s\n" "if [ -f /etc/profile.d/zz-codex-resume.sh ]; then . /etc/profile.d/zz-codex-resume.sh; fi" >> /etc/bash.bashrc -fi -if [[ -s /etc/zsh/zshrc ]] && ! grep -q "zz-codex-resume.sh" /etc/zsh/zshrc 2>/dev/null; then - printf "%s\n" "if [ -f /etc/profile.d/zz-codex-resume.sh ]; then source /etc/profile.d/zz-codex-resume.sh; fi" >> /etc/zsh/zshrc -fi - -# Ensure global AGENTS.md exists for container context -AGENTS_PATH="/home/dev/.codex/AGENTS.md" -LEGACY_AGENTS_PATH="/home/dev/AGENTS.md" -PROJECT_LINE="Рабочая папка проекта (git clone): /home/dev/octocat/hello-world/issue-1" -WORKSPACES_LINE="Доступные workspace пути: /home/dev/octocat/hello-world/issue-1" -WORKSPACE_INFO_LINE="Контекст workspace: repository" -FOCUS_LINE="Фокус задачи: работай только в workspace, который запрашивает пользователь. Текущий workspace: /home/dev/octocat/hello-world/issue-1" -ISSUE_AGENTS_HINT_LINE="Issue AGENTS.md: n/a" -INTERNET_LINE="Доступ к интернету: есть. Если чего-то не знаешь — ищи в интернете или по кодовой базе." -if [[ "$REPO_REF" == issue-* ]]; then - ISSUE_ID="$(printf "%s" "$REPO_REF" | sed -E 's#^issue-##')" - ISSUE_URL="" - if [[ "$REPO_URL" == https://github.com/* ]]; then - ISSUE_REPO="$(printf "%s" "$REPO_URL" | sed -E 's#^https://github.com/##; s#[.]git$##; s#/*$##')" - if [[ -n "$ISSUE_REPO" ]]; then - ISSUE_URL="https://github.com/$ISSUE_REPO/issues/$ISSUE_ID" - fi - fi - if [[ -n "$ISSUE_URL" ]]; then - WORKSPACE_INFO_LINE="Контекст workspace: issue #$ISSUE_ID ($ISSUE_URL)" - else - WORKSPACE_INFO_LINE="Контекст workspace: issue #$ISSUE_ID" - fi - ISSUE_AGENTS_HINT_LINE="Issue AGENTS.md: /home/dev/octocat/hello-world/issue-1/AGENTS.md" -elif [[ "$REPO_REF" == refs/pull/*/head ]]; then - PR_ID="$(printf "%s" "$REPO_REF" | sed -E 's#^refs/pull/([0-9]+)/head$#\1#')" - if [[ -n "$PR_ID" ]]; then - WORKSPACE_INFO_LINE="Контекст workspace: PR #$PR_ID" - else - WORKSPACE_INFO_LINE="Контекст workspace: pull request ($REPO_REF)" - fi -fi -if [[ ! -f "$AGENTS_PATH" ]]; then - MANAGED_START="" - MANAGED_END="" - MANAGED_BLOCK="$(cat < "$AGENTS_PATH" -Ты автономный агент, который имеет полностью все права управления контейнером. У тебя есть доступ к командам sudo, gh, codex, git, node, pnpm и всем остальным другим. Проекты с которыми идёт работа лежат по пути ~ -$MANAGED_BLOCK -Если ты видишь файлы AGENTS.md внутри проекта, ты обязан их читать и соблюдать инструкции. -EOF - chown 1000:1000 "$AGENTS_PATH" || true -fi -if [[ -f "$AGENTS_PATH" ]]; then - MANAGED_START="" - MANAGED_END="" - MANAGED_BLOCK="$(cat < "$TMP_AGENTS_PATH" - else - sed \ - -e '/^Рабочая папка проекта (git clone):/d' \ - -e '/^Доступные workspace пути:/d' \ - -e '/^Контекст workspace:/d' \ - -e '/^Фокус задачи:/d' \ - -e '/^Issue AGENTS.md:/d' \ - -e '/^Доступ к интернету:/d' \ - "$AGENTS_PATH" > "$TMP_AGENTS_PATH" - if [[ -s "$TMP_AGENTS_PATH" ]]; then - printf "\n" >> "$TMP_AGENTS_PATH" - fi - printf "%s\n" "$MANAGED_BLOCK" >> "$TMP_AGENTS_PATH" - fi - mv "$TMP_AGENTS_PATH" "$AGENTS_PATH" - chown 1000:1000 "$AGENTS_PATH" || true -fi -if [[ -f "$LEGACY_AGENTS_PATH" && -f "$AGENTS_PATH" ]]; then - LEGACY_SUM="$(cksum "$LEGACY_AGENTS_PATH" 2>/dev/null | awk '{print $1 \":\" $2}')" - CODEX_SUM="$(cksum "$AGENTS_PATH" 2>/dev/null | awk '{print $1 \":\" $2}')" - if [[ -n "$LEGACY_SUM" && "$LEGACY_SUM" == "$CODEX_SUM" ]]; then - rm -f "$LEGACY_AGENTS_PATH" - fi -fi - -# Ensure docker socket access for dev -if [[ -S /var/run/docker.sock ]]; then - DOCKER_SOCK_GID="$(stat -c "%g" /var/run/docker.sock)" - DOCKER_GROUP="$(getent group "$DOCKER_SOCK_GID" | cut -d: -f1 || true)" - if [[ -z "$DOCKER_GROUP" ]]; then - DOCKER_GROUP="docker" - groupadd -g "$DOCKER_SOCK_GID" "$DOCKER_GROUP" || true - fi - usermod -aG "$DOCKER_GROUP" dev || true - printf "export DOCKER_HOST=unix:///var/run/docker.sock -" > /etc/profile.d/docker-host.sh -fi - -# 2) Ensure GitHub auth vars are available for SSH sessions if provided -if [[ -n "$GH_TOKEN" || -n "$GITHUB_TOKEN" ]]; then - EFFECTIVE_GITHUB_TOKEN="$GITHUB_TOKEN" - if [[ -z "$EFFECTIVE_GITHUB_TOKEN" ]]; then - EFFECTIVE_GITHUB_TOKEN="$GH_TOKEN" - fi - - EFFECTIVE_GH_TOKEN="$GH_TOKEN" - if [[ -z "$EFFECTIVE_GH_TOKEN" ]]; then - EFFECTIVE_GH_TOKEN="$EFFECTIVE_GITHUB_TOKEN" - fi - - printf "export GH_TOKEN=%q\n" "$EFFECTIVE_GH_TOKEN" > /etc/profile.d/gh-token.sh - printf "export GITHUB_TOKEN=%q\n" "$EFFECTIVE_GITHUB_TOKEN" >> /etc/profile.d/gh-token.sh - chmod 0644 /etc/profile.d/gh-token.sh - SSH_ENV_PATH="/home/dev/.ssh/environment" - printf "%s\n" "GH_TOKEN=$EFFECTIVE_GH_TOKEN" > "$SSH_ENV_PATH" - printf "%s\n" "GITHUB_TOKEN=$EFFECTIVE_GITHUB_TOKEN" >> "$SSH_ENV_PATH" - chmod 600 "$SSH_ENV_PATH" - chown 1000:1000 "$SSH_ENV_PATH" || true - - SAFE_GH_TOKEN="$(printf "%q" "$GH_TOKEN")" - # Keep git+https auth in sync with gh auth so push/pull works without manual setup. - su - dev -c "GH_TOKEN=$SAFE_GH_TOKEN gh auth setup-git --hostname github.com --force" || true - - GH_LOGIN="$(su - dev -c "GH_TOKEN=$SAFE_GH_TOKEN gh api user --jq .login" 2>/dev/null || true)" - GH_ID="$(su - dev -c "GH_TOKEN=$SAFE_GH_TOKEN gh api user --jq .id" 2>/dev/null || true)" - GH_LOGIN="$(printf "%s" "$GH_LOGIN" | tr -d '\r\n')" - GH_ID="$(printf "%s" "$GH_ID" | tr -d '\r\n')" - - if [[ -z "$GIT_USER_NAME" && -n "$GH_LOGIN" ]]; then - GIT_USER_NAME="$GH_LOGIN" - fi - if [[ -z "$GIT_USER_EMAIL" && -n "$GH_LOGIN" && -n "$GH_ID" ]]; then - GIT_USER_EMAIL="${GH_ID}+${GH_LOGIN}@users.noreply.github.com" - fi -fi - -# 3) Configure git credential helper for HTTPS remotes -GIT_CREDENTIAL_HELPER_PATH="/usr/local/bin/docker-git-credential-helper" -cat <<'EOF' > "$GIT_CREDENTIAL_HELPER_PATH" -#!/usr/bin/env bash -set -euo pipefail - -if [[ "$#" -lt 1 || "$1" != "get" ]]; then - exit 0 -fi - -token="$GITHUB_TOKEN" -if [[ -z "$token" ]]; then - token="$GH_TOKEN" -fi - -if [[ -z "$token" ]]; then - exit 0 -fi - -printf "%s\n" "username=x-access-token" -printf "%s\n" "password=$token" -EOF -chmod 0755 "$GIT_CREDENTIAL_HELPER_PATH" -su - dev -c "git config --global credential.helper '$GIT_CREDENTIAL_HELPER_PATH'" - -# 4) Configure git identity for the dev user if provided -if [[ -n "$GIT_USER_NAME" ]]; then - SAFE_GIT_USER_NAME="$(printf "%q" "$GIT_USER_NAME")" - su - dev -c "git config --global user.name $SAFE_GIT_USER_NAME" -fi - -if [[ -n "$GIT_USER_EMAIL" ]]; then - SAFE_GIT_USER_EMAIL="$(printf "%q" "$GIT_USER_EMAIL")" - su - dev -c "git config --global user.email $SAFE_GIT_USER_EMAIL" -fi - -# 3) Install global git hooks to protect main/master -HOOKS_DIR="/opt/docker-git/hooks" -PRE_PUSH_HOOK="$HOOKS_DIR/pre-push" -mkdir -p "$HOOKS_DIR" -if [[ ! -f "$PRE_PUSH_HOOK" ]]; then - cat <<'EOF' > "$PRE_PUSH_HOOK" -#!/usr/bin/env bash -set -euo pipefail - -protected_branches=("refs/heads/main" "refs/heads/master") -allow_delete="${DOCKER_GIT_ALLOW_DELETE:-}" - -while read -r local_ref local_sha remote_ref remote_sha; do - if [[ -z "$remote_ref" ]]; then - continue - fi - for protected in "${protected_branches[@]}"; do - if [[ "$remote_ref" == "$protected" || "$local_ref" == "$protected" ]]; then - echo "docker-git: push to protected branch '${protected##*/}' is disabled." - echo "docker-git: create a new branch: git checkout -b " - exit 1 - fi - done - if [[ "$local_sha" == "0000000000000000000000000000000000000000" && "$remote_ref" == refs/heads/* ]]; then - if [[ "$allow_delete" != "1" ]]; then - echo "docker-git: deleting remote branches is disabled (set DOCKER_GIT_ALLOW_DELETE=1 to override)." - exit 1 - fi - fi -done -EOF - chmod 0755 "$PRE_PUSH_HOOK" -fi -git config --system core.hooksPath "$HOOKS_DIR" || true -git config --global core.hooksPath "$HOOKS_DIR" || true - -# 4) Start background tasks so SSH can come up immediately -( -# 1) Keep Codex CLI up to date if requested (bun only) -if [[ "$CODEX_AUTO_UPDATE" == "1" ]]; then - if command -v bun >/dev/null 2>&1; then - echo "[codex] updating via bun..." - script -q -e -c "bun add -g @openai/codex@latest" /dev/null || true - else - echo "[codex] bun not found, skipping auto-update" - fi -fi - -# 2) Auto-clone repo if not already present -mkdir -p /run/docker-git -CLONE_DONE_PATH="/run/docker-git/clone.done" -CLONE_FAIL_PATH="/run/docker-git/clone.failed" -rm -f "$CLONE_DONE_PATH" "$CLONE_FAIL_PATH" - -CLONE_OK=1 - -if [[ -z "$REPO_URL" ]]; then - echo "[clone] skip (no repo url)" -elif [[ -d "$TARGET_DIR/.git" ]]; then - echo "[clone] skip (already cloned)" -else - mkdir -p "$TARGET_DIR" - if [[ "$TARGET_DIR" != "/" ]]; then - chown -R 1000:1000 "$TARGET_DIR" - fi - chown -R 1000:1000 /home/dev - - AUTH_REPO_URL="$REPO_URL" - if [[ -n "$GIT_AUTH_TOKEN" && "$REPO_URL" == https://* ]]; then - AUTH_REPO_URL="$(printf "%s" "$REPO_URL" | sed "s#^https://#https://${GIT_AUTH_USER}:${GIT_AUTH_TOKEN}@#")" - fi - if [[ -n "$REPO_REF" ]]; then - if [[ "$REPO_REF" == refs/pull/* ]]; then - REF_BRANCH="pr-$(printf "%s" "$REPO_REF" | tr '/:' '--')" - if ! su - dev -c "GIT_TERMINAL_PROMPT=0 git clone --progress '$AUTH_REPO_URL' '$TARGET_DIR'"; then - echo "[clone] git clone failed for $REPO_URL" - CLONE_OK=0 - else - if ! su - dev -c "cd '$TARGET_DIR' && GIT_TERMINAL_PROMPT=0 git fetch --progress origin '$REPO_REF':'$REF_BRANCH' && git checkout '$REF_BRANCH'"; then - echo "[clone] git fetch failed for $REPO_REF" - CLONE_OK=0 - fi - fi - else - if ! su - dev -c "GIT_TERMINAL_PROMPT=0 git clone --progress --branch '$REPO_REF' '$AUTH_REPO_URL' '$TARGET_DIR'"; then - DEFAULT_REF="$(git ls-remote --symref "$AUTH_REPO_URL" HEAD 2>/dev/null | awk '/^ref:/ {print $2}' | head -n 1 || true)" - DEFAULT_BRANCH="$(printf "%s" "$DEFAULT_REF" | sed 's#^refs/heads/##')" - if [[ -n "$DEFAULT_BRANCH" ]]; then - echo "[clone] branch '$REPO_REF' missing; retrying with '$DEFAULT_BRANCH'" - if ! su - dev -c "GIT_TERMINAL_PROMPT=0 git clone --progress --branch '$DEFAULT_BRANCH' '$AUTH_REPO_URL' '$TARGET_DIR'"; then - echo "[clone] git clone failed for $REPO_URL" - CLONE_OK=0 - elif [[ "$REPO_REF" == issue-* ]]; then - if ! su - dev -c "cd '$TARGET_DIR' && git checkout -B '$REPO_REF'"; then - echo "[clone] failed to create local branch '$REPO_REF'" - CLONE_OK=0 - fi - fi - else - echo "[clone] git clone failed for $REPO_URL" - CLONE_OK=0 - fi - fi - fi - else - if ! su - dev -c "GIT_TERMINAL_PROMPT=0 git clone --progress '$AUTH_REPO_URL' '$TARGET_DIR'"; then - echo "[clone] git clone failed for $REPO_URL" - CLONE_OK=0 - fi - fi -fi - -if [[ "$CLONE_OK" -eq 1 && -d "$TARGET_DIR/.git" ]]; then - if [[ -n "$FORK_REPO_URL" && "$FORK_REPO_URL" != "$REPO_URL" ]]; then - su - dev -c "cd '$TARGET_DIR' && git remote set-url origin '$FORK_REPO_URL'" || true - su - dev -c "cd '$TARGET_DIR' && git remote add upstream '$REPO_URL' 2>/dev/null || git remote set-url upstream '$REPO_URL'" || true - else - su - dev -c "cd '$TARGET_DIR' && git remote set-url origin '$REPO_URL'" || true - su - dev -c "cd '$TARGET_DIR' && git remote remove upstream >/dev/null 2>&1 || true" || true - fi -fi - -if [[ "$CLONE_OK" -eq 1 && "$REPO_REF" == issue-* && -d "$TARGET_DIR/.git" ]]; then -ISSUE_ID="$(printf "%s" "$REPO_REF" | sed -E 's#^issue-##')" -ISSUE_URL="" -if [[ "$REPO_URL" == https://github.com/* ]]; then - ISSUE_REPO="$(printf "%s" "$REPO_URL" | sed -E 's#^https://github.com/##; s#[.]git$##; s#/*$##')" - if [[ -n "$ISSUE_REPO" ]]; then - ISSUE_URL="https://github.com/$ISSUE_REPO/issues/$ISSUE_ID" - fi -fi -if [[ -z "$ISSUE_URL" ]]; then - ISSUE_URL="n/a" -fi - -ISSUE_AGENTS_PATH="$TARGET_DIR/AGENTS.md" -ISSUE_MANAGED_START="" -ISSUE_MANAGED_END="" -ISSUE_MANAGED_BLOCK="$(cat < "$ISSUE_AGENTS_PATH" -else - TMP_ISSUE_AGENTS_PATH="$(mktemp)" - if grep -qF "$ISSUE_MANAGED_START" "$ISSUE_AGENTS_PATH" && grep -qF "$ISSUE_MANAGED_END" "$ISSUE_AGENTS_PATH"; then - awk -v start="$ISSUE_MANAGED_START" -v end="$ISSUE_MANAGED_END" -v repl="$ISSUE_MANAGED_BLOCK" ' - BEGIN { in_block = 0 } - $0 == start { print repl; in_block = 1; next } - $0 == end { in_block = 0; next } - in_block == 0 { print } - ' "$ISSUE_AGENTS_PATH" > "$TMP_ISSUE_AGENTS_PATH" - else - sed -e '/^# docker-git issue workspace$/d' -e '/^Issue workspace: #/d' -e '/^Issue URL: /d' -e '/^Workspace path: /d' -e '/^Работай только над этим issue, если пользователь не попросил другое[.]$/d' -e '/^Если нужен первоисточник требований, открой Issue URL[.]$/d' "$ISSUE_AGENTS_PATH" > "$TMP_ISSUE_AGENTS_PATH" - if [[ -s "$TMP_ISSUE_AGENTS_PATH" ]]; then - printf " -" >> "$TMP_ISSUE_AGENTS_PATH" - fi - printf "%s -" "$ISSUE_MANAGED_BLOCK" >> "$TMP_ISSUE_AGENTS_PATH" - fi - mv "$TMP_ISSUE_AGENTS_PATH" "$ISSUE_AGENTS_PATH" -fi -if [[ -e "$ISSUE_AGENTS_PATH" ]]; then - chown 1000:1000 "$ISSUE_AGENTS_PATH" || true -fi - -EXCLUDE_PATH="$TARGET_DIR/.git/info/exclude" -if [[ -f "$ISSUE_AGENTS_PATH" ]]; then - touch "$EXCLUDE_PATH" - if ! grep -qx "AGENTS.md" "$EXCLUDE_PATH"; then - printf "%s -" "AGENTS.md" >> "$EXCLUDE_PATH" - fi -fi -fi - -if [[ "$CLONE_OK" -eq 1 ]]; then - echo "[clone] done" - touch "$CLONE_DONE_PATH" -else - echo "[clone] failed" - touch "$CLONE_FAIL_PATH" -fi -) & - -# 4.5) Snapshot baseline processes for terminal session filtering -mkdir -p /run/docker-git -BASELINE_PATH="/run/docker-git/terminal-baseline.pids" -if [[ ! -f "$BASELINE_PATH" ]]; then - ps -eo pid= > "$BASELINE_PATH" || true -fi - -# 4.75) Disable Ubuntu MOTD noise for SSH sessions -PAM_SSHD="/etc/pam.d/sshd" -if [[ -f "$PAM_SSHD" ]]; then - sed -i 's/^[[:space:]]*session[[:space:]]\+optional[[:space:]]\+pam_motd\.so/#&/' "$PAM_SSHD" || true - sed -i 's/^[[:space:]]*session[[:space:]]\+optional[[:space:]]\+pam_lastlog\.so/#&/' "$PAM_SSHD" || true -fi - -# Also disable sshd's own banners (e.g. "Last login") -mkdir -p /etc/ssh/sshd_config.d || true -DOCKER_GIT_SSHD_CONF="/etc/ssh/sshd_config.d/zz-docker-git-clean.conf" -cat <<'EOF' > "$DOCKER_GIT_SSHD_CONF" -PrintMotd no -PrintLastLog no -EOF -chmod 0644 "$DOCKER_GIT_SSHD_CONF" || true - -# 5) Run sshd in foreground -exec /usr/sbin/sshd -D \ No newline at end of file diff --git a/.gitignore b/.gitignore index 8a999b65..f5b65d74 100644 --- a/.gitignore +++ b/.gitignore @@ -8,6 +8,7 @@ dev_ssh_key.pub # Local docker-git work dirs .docker-git/ +.e2e/ effect-template1/ # Node / build artifacts diff --git a/scripts/e2e/ci.sh b/scripts/e2e/ci.sh index eb84c267..35dce05a 100644 --- a/scripts/e2e/ci.sh +++ b/scripts/e2e/ci.sh @@ -41,6 +41,7 @@ CODEX_SHARE_AUTH=0 EOF export DOCKER_GIT_PROJECTS_ROOT="$PROJECTS_ROOT" +export DOCKER_GIT_STATE_AUTO_SYNC=0 cat > "$BIN_DIR/ssh" <<'EOF' #!/usr/bin/env bash @@ -73,7 +74,7 @@ grep -q -- "-p " "$SSH_LOG" || fail "expected ssh args to include -p ; got docker ps --format '{{.Names}}' | grep -qx "$CONTAINER_NAME" || fail "expected container to be running: $CONTAINER_NAME" docker exec "$CONTAINER_NAME" bash -lc "test -d '$TARGET_DIR/.git'" || fail "expected repo to be cloned at: $TARGET_DIR" -branch="$(docker exec "$CONTAINER_NAME" bash -lc "cd '$TARGET_DIR' && git rev-parse --abbrev-ref HEAD")" +branch="$(docker exec -u dev "$CONTAINER_NAME" bash -lc "cd '$TARGET_DIR' && git rev-parse --abbrev-ref HEAD")" [[ "$branch" == "issue-1" ]] || fail "expected HEAD branch issue-1, got: $branch" [[ -f "$OUT_DIR/docker-git.json" ]] || fail "expected project config file: $OUT_DIR/docker-git.json" From 11b8dde12f1dbe0ee9e83f218c9290084a8f539f Mon Sep 17 00:00:00 2001 From: skulidropek <66840575+skulidropek@users.noreply.github.com> Date: Sun, 15 Feb 2026 10:02:54 +0000 Subject: [PATCH 3/7] chore(state): update octocat/hello-world --- .e2e/bin/ssh | 6 + .e2e/project.env | 3 + .../octocat/hello-world/issue-1/.dockerignore | 3 + .../octocat/hello-world/issue-1/.gitignore | 9 + .../octocat/hello-world/issue-1/Dockerfile | 172 ++++ .../hello-world/issue-1/docker-compose.yml | 32 + .../hello-world/issue-1/docker-git.json | 22 + .../octocat/hello-world/issue-1/entrypoint.sh | 836 ++++++++++++++++++ AGENTS.md | 9 + package.json | 5 +- .../app/src/docker-git/cli/parser-clone.ts | 3 +- .../app/src/docker-git/cli/parser-options.ts | 2 + packages/app/src/docker-git/cli/usage.ts | 1 + packages/app/tests/docker-git/parser.test.ts | 16 + packages/docker-git/src/server/http.ts | 2 + packages/lib/src/core/command-builders.ts | 2 + packages/lib/src/core/command-options.ts | 1 + packages/lib/src/core/domain.ts | 1 + .../src/usecases/actions/create-project.ts | 80 +- .../usecases/create-project-open-ssh.test.ts | 180 ++++ packages/web/scripts/terminal-ws.mjs | 1 + .../@ton-ai-core__vibecode-linter@1.0.6.patch | 15 + pnpm-lock.yaml | 11 +- scripts/e2e/ci.sh | 81 ++ 24 files changed, 1487 insertions(+), 6 deletions(-) create mode 100755 .e2e/bin/ssh create mode 100644 .e2e/project.env create mode 100644 .e2e/projects/octocat/hello-world/issue-1/.dockerignore create mode 100644 .e2e/projects/octocat/hello-world/issue-1/.gitignore create mode 100644 .e2e/projects/octocat/hello-world/issue-1/Dockerfile create mode 100644 .e2e/projects/octocat/hello-world/issue-1/docker-compose.yml create mode 100644 .e2e/projects/octocat/hello-world/issue-1/docker-git.json create mode 100755 .e2e/projects/octocat/hello-world/issue-1/entrypoint.sh create mode 100644 packages/lib/tests/usecases/create-project-open-ssh.test.ts create mode 100644 patches/@ton-ai-core__vibecode-linter@1.0.6.patch create mode 100644 scripts/e2e/ci.sh diff --git a/.e2e/bin/ssh b/.e2e/bin/ssh new file mode 100755 index 00000000..81c4a4f2 --- /dev/null +++ b/.e2e/bin/ssh @@ -0,0 +1,6 @@ +#!/usr/bin/env bash +set -euo pipefail + +: "${SSH_LOG_PATH:?SSH_LOG_PATH is required}" +printf "ssh %s\n" "$*" >> "$SSH_LOG_PATH" +exit 0 diff --git a/.e2e/project.env b/.e2e/project.env new file mode 100644 index 00000000..4d7783c6 --- /dev/null +++ b/.e2e/project.env @@ -0,0 +1,3 @@ +# Keep CI fast and deterministic (Codex auto-update hits the network on container start) +CODEX_AUTO_UPDATE=0 +CODEX_SHARE_AUTH=0 diff --git a/.e2e/projects/octocat/hello-world/issue-1/.dockerignore b/.e2e/projects/octocat/hello-world/issue-1/.dockerignore new file mode 100644 index 00000000..c3c63f6c --- /dev/null +++ b/.e2e/projects/octocat/hello-world/issue-1/.dockerignore @@ -0,0 +1,3 @@ +# docker-git build context +.orch/ +authorized_keys diff --git a/.e2e/projects/octocat/hello-world/issue-1/.gitignore b/.e2e/projects/octocat/hello-world/issue-1/.gitignore new file mode 100644 index 00000000..c02cca46 --- /dev/null +++ b/.e2e/projects/octocat/hello-world/issue-1/.gitignore @@ -0,0 +1,9 @@ +# docker-git project files +# NOTE: this directory is intended to be committed to the docker-git state repository. +# It intentionally does not ignore .orch/ or auth files; keep the state repo private. + +# Volatile Codex artifacts (do not commit) +.orch/auth/codex/log/ +.orch/auth/codex/tmp/ +.orch/auth/codex/sessions/ +.orch/auth/codex/models_cache.json diff --git a/.e2e/projects/octocat/hello-world/issue-1/Dockerfile b/.e2e/projects/octocat/hello-world/issue-1/Dockerfile new file mode 100644 index 00000000..4faec5bd --- /dev/null +++ b/.e2e/projects/octocat/hello-world/issue-1/Dockerfile @@ -0,0 +1,172 @@ +FROM ubuntu:24.04 + +ENV DEBIAN_FRONTEND=noninteractive +ENV NVM_DIR=/usr/local/nvm + +RUN apt-get update && apt-get install -y --no-install-recommends openssh-server git gh ca-certificates curl unzip bsdutils sudo make docker.io docker-compose-v2 bash-completion zsh zsh-autosuggestions xauth ncurses-term && rm -rf /var/lib/apt/lists/* + +# Passwordless sudo for all users (container is disposable) +RUN printf "%s\n" "ALL ALL=(ALL) NOPASSWD:ALL" > /etc/sudoers.d/zz-all && chmod 0440 /etc/sudoers.d/zz-all + +# Shell prompt: show git branch for interactive sessions +RUN cat <<'EOF' > /etc/profile.d/zz-prompt.sh +docker_git_branch() { git rev-parse --abbrev-ref HEAD 2>/dev/null; } +docker_git_prompt_apply() { + local b + b="$(docker_git_branch)" + local base="[\t] \w" + if [ -n "$b" ]; then + PS1="${base} (${b})> " + else + PS1="${base}> " + fi +} +if [ -n "$PROMPT_COMMAND" ]; then + PROMPT_COMMAND="docker_git_prompt_apply;$PROMPT_COMMAND" +else + PROMPT_COMMAND="docker_git_prompt_apply" +fi +EOF +RUN chmod 0644 /etc/profile.d/zz-prompt.sh +RUN printf "%s\n" \ + "if [ -f /etc/profile.d/zz-prompt.sh ]; then . /etc/profile.d/zz-prompt.sh; fi" \ + >> /etc/bash.bashrc +RUN cat <<'EOF' > /etc/profile.d/zz-bash-completion.sh +if ! shopt -oq posix; then + if [ -f /usr/share/bash-completion/bash_completion ]; then + . /usr/share/bash-completion/bash_completion + elif [ -f /etc/bash_completion ]; then + . /etc/bash_completion + fi +fi +EOF +RUN chmod 0644 /etc/profile.d/zz-bash-completion.sh +RUN printf "%s\n" \ + "if [ -f /etc/profile.d/zz-bash-completion.sh ]; then . /etc/profile.d/zz-bash-completion.sh; fi" \ + >> /etc/bash.bashrc +RUN cat <<'EOF' > /etc/profile.d/zz-bash-history.sh +if [ -n "$BASH_VERSION" ]; then + case "$-" in + *i*) + HISTFILE="${HISTFILE:-$HOME/.bash_history}" + HISTSIZE="${HISTSIZE:-10000}" + HISTFILESIZE="${HISTFILESIZE:-20000}" + HISTCONTROL="${HISTCONTROL:-ignoredups:erasedups}" + export HISTFILE HISTSIZE HISTFILESIZE HISTCONTROL + shopt -s histappend + if [ -n "${PROMPT_COMMAND-}" ]; then + PROMPT_COMMAND="history -a; ${PROMPT_COMMAND}" + else + PROMPT_COMMAND="history -a" + fi + ;; + esac +fi +EOF +RUN chmod 0644 /etc/profile.d/zz-bash-history.sh +RUN printf "%s\n" \ + "if [ -f /etc/profile.d/zz-bash-history.sh ]; then . /etc/profile.d/zz-bash-history.sh; fi" \ + >> /etc/bash.bashrc +RUN mkdir -p /etc/zsh +RUN cat <<'EOF' > /etc/zsh/zshrc +setopt PROMPT_SUBST + +# Terminal compatibility: if terminfo for $TERM is missing (common over SSH), +# fall back to xterm-256color so ZLE doesn't garble the display. +if command -v infocmp >/dev/null 2>&1; then + if ! infocmp "$TERM" >/dev/null 2>&1; then + export TERM=xterm-256color + fi +fi + +autoload -Uz compinit +compinit + +# Completion UX: cycle matches instead of listing them into scrollback. +setopt AUTO_MENU +setopt MENU_COMPLETE +unsetopt AUTO_LIST +unsetopt LIST_BEEP + +# Command completion ordering: prefer real commands/builtins over internal helper functions. +zstyle ':completion:*' tag-order builtins commands aliases reserved-words functions + +autoload -Uz add-zsh-hook +docker_git_branch() { git rev-parse --abbrev-ref HEAD 2>/dev/null; } +docker_git_prompt_apply() { + local b + b="$(docker_git_branch)" + local base="[%*] %~" + if [[ -n "$b" ]]; then + PROMPT="$base ($b)> " + else + PROMPT="$base> " + fi +} +add-zsh-hook precmd docker_git_prompt_apply + +HISTFILE="${HISTFILE:-$HOME/.zsh_history}" +HISTSIZE="${HISTSIZE:-10000}" +SAVEHIST="${SAVEHIST:-20000}" +setopt HIST_IGNORE_ALL_DUPS +setopt SHARE_HISTORY +setopt INC_APPEND_HISTORY + +if [ -f "$HISTFILE" ]; then + fc -R "$HISTFILE" 2>/dev/null || true +fi +if [ -f "$HOME/.bash_history" ] && [ "$HISTFILE" != "$HOME/.bash_history" ]; then + fc -R "$HOME/.bash_history" 2>/dev/null || true +fi + +bindkey '^[[A' history-search-backward +bindkey '^[[B' history-search-forward + +if [[ "${DOCKER_GIT_ZSH_AUTOSUGGEST:-1}" == "1" ]] && [ -f /usr/share/zsh-autosuggestions/zsh-autosuggestions.zsh ]; then + # Suggest from history first, then fall back to completion (commands + paths). + # This gives "ghost text" suggestions without needing to press . + ZSH_AUTOSUGGEST_HIGHLIGHT_STYLE="${DOCKER_GIT_ZSH_AUTOSUGGEST_STYLE:-fg=8,italic}" + if [[ -n "${DOCKER_GIT_ZSH_AUTOSUGGEST_STRATEGY-}" ]]; then + ZSH_AUTOSUGGEST_STRATEGY=(${=DOCKER_GIT_ZSH_AUTOSUGGEST_STRATEGY}) + else + ZSH_AUTOSUGGEST_STRATEGY=(history completion) + fi + source /usr/share/zsh-autosuggestions/zsh-autosuggestions.zsh +fi +EOF + +# Tooling: Node 24 (NodeSource) + nvm +RUN curl -fsSL https://deb.nodesource.com/setup_24.x | bash - && apt-get install -y --no-install-recommends nodejs && node -v && npm -v && corepack --version && rm -rf /var/lib/apt/lists/* +RUN mkdir -p /usr/local/nvm && curl -fsSL https://raw.githubusercontent.com/nvm-sh/nvm/v0.40.1/install.sh | bash +RUN printf "export NVM_DIR=/usr/local/nvm\n[ -s /usr/local/nvm/nvm.sh ] && . /usr/local/nvm/nvm.sh\n" > /etc/profile.d/nvm.sh && chmod 0644 /etc/profile.d/nvm.sh + +# Tooling: pnpm + Codex CLI (bun) +RUN corepack enable && corepack prepare pnpm@10.27.0 --activate +ENV BUN_INSTALL=/usr/local/bun +ENV TERM=xterm-256color +ENV PATH="/usr/local/bun/bin:$PATH" +RUN curl -fsSL https://bun.sh/install | bash +RUN ln -sf /usr/local/bun/bin/bun /usr/local/bin/bun +RUN script -q -e -c "bun add -g @openai/codex@latest" /dev/null +RUN ln -sf /usr/local/bun/bin/codex /usr/local/bin/codex +RUN printf "export BUN_INSTALL=/usr/local/bun\nexport PATH=/usr/local/bun/bin:$PATH\n" > /etc/profile.d/bun.sh && chmod 0644 /etc/profile.d/bun.sh + +# Create non-root user for SSH (align UID/GID with host user 1000) +RUN if id -u ubuntu >/dev/null 2>&1; then if getent group 1000 >/dev/null 2>&1; then EXISTING_GROUP="$(getent group 1000 | cut -d: -f1)"; if [ "$EXISTING_GROUP" != "dev" ]; then groupmod -n dev "$EXISTING_GROUP" || true; fi; fi; usermod -l dev -d /home/dev -m -s /usr/bin/zsh ubuntu || true; fi +RUN if id -u dev >/dev/null 2>&1; then usermod -u 1000 -g 1000 -o dev; else groupadd -g 1000 dev || true; useradd -m -s /usr/bin/zsh -u 1000 -g 1000 -o dev; fi +RUN printf "%s\n" "dev ALL=(ALL) NOPASSWD:ALL" > /etc/sudoers.d/dev && chmod 0440 /etc/sudoers.d/dev + +# sshd runtime dir +RUN mkdir -p /run/sshd + +# Harden sshd: disable password auth and root login +RUN printf "%s\n" "PasswordAuthentication no" "PermitRootLogin no" "PubkeyAuthentication yes" "X11Forwarding yes" "X11UseLocalhost yes" "PermitUserEnvironment yes" "AllowUsers dev" > /etc/ssh/sshd_config.d/dev.conf + +# Workspace path (supports root-level dirs like /repo) +RUN mkdir -p /home/dev/octocat/hello-world/issue-1 && chown -R 1000:1000 /home/dev && if [ "/home/dev/octocat/hello-world/issue-1" != "/" ]; then chown -R 1000:1000 "/home/dev/octocat/hello-world/issue-1"; fi + +COPY entrypoint.sh /entrypoint.sh +RUN chmod +x /entrypoint.sh + +EXPOSE 22 +ENTRYPOINT ["/entrypoint.sh"] \ No newline at end of file diff --git a/.e2e/projects/octocat/hello-world/issue-1/docker-compose.yml b/.e2e/projects/octocat/hello-world/issue-1/docker-compose.yml new file mode 100644 index 00000000..81f665dc --- /dev/null +++ b/.e2e/projects/octocat/hello-world/issue-1/docker-compose.yml @@ -0,0 +1,32 @@ +services: + dg-hello-world-issue-1: + build: . + container_name: dg-hello-world-issue-1 + environment: + REPO_URL: "https://github.com/octocat/Hello-World.git" + REPO_REF: "issue-1" + FORK_REPO_URL: "" + TARGET_DIR: "/home/dev/octocat/hello-world/issue-1" + CODEX_HOME: "/home/dev/.codex" + env_file: + - ./.orch/env/global.env + - ../../../../project.env + ports: + - "127.0.0.1:34623:22" + volumes: + - dg-hello-world-issue-1-home:/home/dev + - ../../..:/home/dev/.docker-git + - ../../../authorized_keys:/authorized_keys:ro + - ./.orch/auth/codex:/home/dev/.codex + - ../../../.orch/auth/codex:/home/dev/.codex-shared + - /var/run/docker.sock:/var/run/docker.sock + networks: + - dg-hello-world-issue-1-net + + +networks: + dg-hello-world-issue-1-net: + driver: bridge + +volumes: + dg-hello-world-issue-1-home: diff --git a/.e2e/projects/octocat/hello-world/issue-1/docker-git.json b/.e2e/projects/octocat/hello-world/issue-1/docker-git.json new file mode 100644 index 00000000..56f844e1 --- /dev/null +++ b/.e2e/projects/octocat/hello-world/issue-1/docker-git.json @@ -0,0 +1,22 @@ +{ + "schemaVersion": 1, + "template": { + "containerName": "dg-hello-world-issue-1", + "serviceName": "dg-hello-world-issue-1", + "sshUser": "dev", + "sshPort": 34623, + "repoUrl": "https://github.com/octocat/Hello-World.git", + "repoRef": "issue-1", + "targetDir": "/home/dev/octocat/hello-world/issue-1", + "volumeName": "dg-hello-world-issue-1-home", + "dockerGitPath": "../../..", + "authorizedKeysPath": "../../../authorized_keys", + "envGlobalPath": "./.orch/env/global.env", + "envProjectPath": "../../../../project.env", + "codexAuthPath": "./.orch/auth/codex", + "codexSharedAuthPath": "../../../.orch/auth/codex", + "codexHome": "/home/dev/.codex", + "enableMcpPlaywright": false, + "pnpmVersion": "10.27.0" + } +} diff --git a/.e2e/projects/octocat/hello-world/issue-1/entrypoint.sh b/.e2e/projects/octocat/hello-world/issue-1/entrypoint.sh new file mode 100755 index 00000000..f9799cc2 --- /dev/null +++ b/.e2e/projects/octocat/hello-world/issue-1/entrypoint.sh @@ -0,0 +1,836 @@ +#!/usr/bin/env bash +set -euo pipefail + +REPO_URL="${REPO_URL:-}" +REPO_REF="${REPO_REF:-}" +FORK_REPO_URL="${FORK_REPO_URL:-}" +TARGET_DIR="${TARGET_DIR:-/home/dev/octocat/hello-world/issue-1}" +GIT_AUTH_USER="${GIT_AUTH_USER:-${GITHUB_USER:-x-access-token}}" +GIT_AUTH_TOKEN="${GIT_AUTH_TOKEN:-${GITHUB_TOKEN:-${GH_TOKEN:-}}}" +GH_TOKEN="${GH_TOKEN:-${GIT_AUTH_TOKEN:-}}" +GITHUB_TOKEN="${GITHUB_TOKEN:-${GH_TOKEN:-}}" +GIT_USER_NAME="${GIT_USER_NAME:-}" +GIT_USER_EMAIL="${GIT_USER_EMAIL:-}" +CODEX_AUTO_UPDATE="${CODEX_AUTO_UPDATE:-1}" +MCP_PLAYWRIGHT_ENABLE="${MCP_PLAYWRIGHT_ENABLE:-0}" +MCP_PLAYWRIGHT_CDP_ENDPOINT="${MCP_PLAYWRIGHT_CDP_ENDPOINT:-}" +MCP_PLAYWRIGHT_ISOLATED="${MCP_PLAYWRIGHT_ISOLATED:-1}" + +# 1) Authorized keys are mounted from host at /authorized_keys +mkdir -p /home/dev/.ssh +chmod 700 /home/dev/.ssh + +if [[ -f /authorized_keys ]]; then + cp /authorized_keys /home/dev/.ssh/authorized_keys + chmod 600 /home/dev/.ssh/authorized_keys +fi + +chown -R 1000:1000 /home/dev/.ssh + +# Ensure Codex home exists if mounted +mkdir -p /home/dev/.codex +chown -R 1000:1000 /home/dev/.codex + +# Ensure home ownership matches the dev UID/GID (volumes may be stale) +HOME_OWNER="$(stat -c "%u:%g" /home/dev 2>/dev/null || echo "")" +if [[ "$HOME_OWNER" != "1000:1000" ]]; then + chown -R 1000:1000 /home/dev || true +fi + +# Share Codex auth.json across projects (avoids refresh_token_reused) +CODEX_SHARE_AUTH="${CODEX_SHARE_AUTH:-1}" +if [[ "$CODEX_SHARE_AUTH" == "1" ]]; then + CODEX_SHARED_HOME="/home/dev/.codex-shared" + mkdir -p "$CODEX_SHARED_HOME" + chown -R 1000:1000 "$CODEX_SHARED_HOME" || true + + AUTH_FILE="/home/dev/.codex/auth.json" + SHARED_AUTH_FILE="$CODEX_SHARED_HOME/auth.json" + + # Guard against a bad bind mount creating a directory at auth.json. + if [[ -d "$AUTH_FILE" ]]; then + mv "$AUTH_FILE" "$AUTH_FILE.bak-$(date +%s)" || true + fi + if [[ -e "$AUTH_FILE" && ! -L "$AUTH_FILE" ]]; then + rm -f "$AUTH_FILE" || true + fi + + ln -sf "$SHARED_AUTH_FILE" "$AUTH_FILE" +fi + +# Bootstrap ~/.docker-git for nested docker-git usage inside this container. +DOCKER_GIT_HOME="/home/dev/.docker-git" +DOCKER_GIT_AUTH_DIR="$DOCKER_GIT_HOME/.orch/auth/codex" +DOCKER_GIT_ENV_DIR="$DOCKER_GIT_HOME/.orch/env" +DOCKER_GIT_ENV_GLOBAL="$DOCKER_GIT_ENV_DIR/global.env" +DOCKER_GIT_ENV_PROJECT="$DOCKER_GIT_ENV_DIR/project.env" +DOCKER_GIT_AUTH_KEYS="$DOCKER_GIT_HOME/authorized_keys" + +mkdir -p "$DOCKER_GIT_AUTH_DIR" "$DOCKER_GIT_ENV_DIR" "$DOCKER_GIT_HOME/.orch/auth/gh" + +if [[ -f "/home/dev/.ssh/authorized_keys" ]]; then + cp "/home/dev/.ssh/authorized_keys" "$DOCKER_GIT_AUTH_KEYS" +elif [[ -f /authorized_keys ]]; then + cp /authorized_keys "$DOCKER_GIT_AUTH_KEYS" +fi +if [[ -f "$DOCKER_GIT_AUTH_KEYS" ]]; then + chmod 600 "$DOCKER_GIT_AUTH_KEYS" || true +fi + +if [[ ! -f "$DOCKER_GIT_ENV_GLOBAL" ]]; then + cat <<'EOF' > "$DOCKER_GIT_ENV_GLOBAL" +# docker-git env +# KEY=value +EOF +fi +if [[ ! -f "$DOCKER_GIT_ENV_PROJECT" ]]; then + cat <<'EOF' > "$DOCKER_GIT_ENV_PROJECT" +# docker-git project env defaults +CODEX_SHARE_AUTH=1 +CODEX_AUTO_UPDATE=1 +DOCKER_GIT_ZSH_AUTOSUGGEST=1 +DOCKER_GIT_ZSH_AUTOSUGGEST_STYLE=fg=8,italic +DOCKER_GIT_ZSH_AUTOSUGGEST_STRATEGY=history completion +MCP_PLAYWRIGHT_ISOLATED=1 +EOF +fi + +upsert_env_var() { + local file="$1" + local key="$2" + local value="$3" + local tmp + tmp="$(mktemp)" + awk -v key="$key" 'index($0, key "=") != 1 { print }' "$file" > "$tmp" + printf "%s=%s\n" "$key" "$value" >> "$tmp" + mv "$tmp" "$file" +} + +copy_if_distinct_file() { + local source="$1" + local target="$2" + if [[ ! -f "$source" ]]; then + return 1 + fi + local source_real="" + local target_real="" + source_real="$(readlink -f "$source" 2>/dev/null || true)" + target_real="$(readlink -f "$target" 2>/dev/null || true)" + if [[ -n "$source_real" && -n "$target_real" && "$source_real" == "$target_real" ]]; then + return 0 + fi + cp "$source" "$target" + return 0 +} + +if [[ -n "$GH_TOKEN" ]]; then + upsert_env_var "$DOCKER_GIT_ENV_GLOBAL" "GH_TOKEN" "$GH_TOKEN" +fi +if [[ -n "$GITHUB_TOKEN" ]]; then + upsert_env_var "$DOCKER_GIT_ENV_GLOBAL" "GITHUB_TOKEN" "$GITHUB_TOKEN" +elif [[ -n "$GH_TOKEN" ]]; then + upsert_env_var "$DOCKER_GIT_ENV_GLOBAL" "GITHUB_TOKEN" "$GH_TOKEN" +fi + +SOURCE_CODEX_CONFIG="/home/dev/.codex/config.toml" +copy_if_distinct_file "$SOURCE_CODEX_CONFIG" "$DOCKER_GIT_AUTH_DIR/config.toml" || true + +SOURCE_SHARED_AUTH="/home/dev/.codex-shared/auth.json" +SOURCE_LOCAL_AUTH="/home/dev/.codex/auth.json" +if [[ -f "$SOURCE_SHARED_AUTH" ]]; then + copy_if_distinct_file "$SOURCE_SHARED_AUTH" "$DOCKER_GIT_AUTH_DIR/auth.json" || true +elif [[ -f "$SOURCE_LOCAL_AUTH" ]]; then + copy_if_distinct_file "$SOURCE_LOCAL_AUTH" "$DOCKER_GIT_AUTH_DIR/auth.json" || true +fi +if [[ -f "$DOCKER_GIT_AUTH_DIR/auth.json" ]]; then + chmod 600 "$DOCKER_GIT_AUTH_DIR/auth.json" || true +fi + +chown -R 1000:1000 "$DOCKER_GIT_HOME" || true + +# Optional: configure Playwright MCP for Codex (browser automation) +CODEX_CONFIG_FILE="/home/dev/.codex/config.toml" + +# Keep config.toml consistent with the container build. +# If Playwright MCP is disabled for this container, remove the block so Codex +# doesn't try (and fail) to spawn docker-git-playwright-mcp. +if [[ "$MCP_PLAYWRIGHT_ENABLE" != "1" ]]; then + if [[ -f "$CODEX_CONFIG_FILE" ]] && grep -q "^\[mcp_servers\.playwright" "$CODEX_CONFIG_FILE" 2>/dev/null; then + awk ' + BEGIN { skip=0 } + /^# docker-git: Playwright MCP/ { next } + /^\[mcp_servers[.]playwright([.]|\])/ { skip=1; next } + skip==1 && /^\[/ { skip=0 } + skip==0 { print } + ' "$CODEX_CONFIG_FILE" > "$CODEX_CONFIG_FILE.tmp" + mv "$CODEX_CONFIG_FILE.tmp" "$CODEX_CONFIG_FILE" + fi +else + if [[ ! -f "$CODEX_CONFIG_FILE" ]]; then + mkdir -p "$(dirname "$CODEX_CONFIG_FILE")" || true + cat <<'EOF' > "$CODEX_CONFIG_FILE" +# docker-git codex config +model = "gpt-5.3-codex" +model_reasoning_effort = "xhigh" +personality = "pragmatic" + +approval_policy = "never" +sandbox_mode = "danger-full-access" +web_search = "live" + +[features] +shell_snapshot = true +collab = true +apps = true +shell_tool = true +EOF + chown 1000:1000 "$CODEX_CONFIG_FILE" || true + fi + + if [[ -z "$MCP_PLAYWRIGHT_CDP_ENDPOINT" ]]; then + MCP_PLAYWRIGHT_CDP_ENDPOINT="http://dg-hello-world-issue-1-browser:9223" + fi + + # Replace the docker-git Playwright block to allow upgrades via --force without manual edits. + if grep -q "^\[mcp_servers\.playwright" "$CODEX_CONFIG_FILE" 2>/dev/null; then + awk ' + BEGIN { skip=0 } + /^# docker-git: Playwright MCP/ { next } + /^\[mcp_servers[.]playwright([.]|\])/ { skip=1; next } + skip==1 && /^\[/ { skip=0 } + skip==0 { print } + ' "$CODEX_CONFIG_FILE" > "$CODEX_CONFIG_FILE.tmp" + mv "$CODEX_CONFIG_FILE.tmp" "$CODEX_CONFIG_FILE" + fi + + cat <> "$CODEX_CONFIG_FILE" + +# docker-git: Playwright MCP (connects to Chromium via CDP) +[mcp_servers.playwright] +command = "docker-git-playwright-mcp" +args = [] +EOF +fi + +# Prefer zsh for dev when available +if command -v zsh >/dev/null 2>&1; then + usermod -s /usr/bin/zsh dev || true +fi + +# Ensure dev has a zshrc and disable newuser wizard +ZSHENV_PATH="/etc/zsh/zshenv" +if [[ -f "$ZSHENV_PATH" ]]; then + if ! grep -q "ZSH_DISABLE_NEWUSER_INSTALL" "$ZSHENV_PATH"; then + printf "%s\n" "export ZSH_DISABLE_NEWUSER_INSTALL=1" >> "$ZSHENV_PATH" + fi +else + printf "%s\n" "export ZSH_DISABLE_NEWUSER_INSTALL=1" > "$ZSHENV_PATH" +fi +USER_ZSHRC="/home/dev/.zshrc" +if [[ ! -f "$USER_ZSHRC" ]]; then + cat <<'EOF' > "$USER_ZSHRC" +# docker-git default zshrc +if [ -f /etc/zsh/zshrc ]; then + source /etc/zsh/zshrc +fi +EOF + chown 1000:1000 "$USER_ZSHRC" || true +fi + +# Ensure docker-git prompt is configured for interactive shells +PROMPT_PATH="/etc/profile.d/zz-prompt.sh" +if [[ ! -s "$PROMPT_PATH" ]]; then + cat <<'EOF' > "$PROMPT_PATH" +docker_git_branch() { git rev-parse --abbrev-ref HEAD 2>/dev/null; } +docker_git_prompt_apply() { + local b + b="$(docker_git_branch)" + local base="[\t] \w" + if [ -n "$b" ]; then + PS1="${base} (${b})> " + else + PS1="${base}> " + fi +} +if [ -n "$PROMPT_COMMAND" ]; then + PROMPT_COMMAND="docker_git_prompt_apply;$PROMPT_COMMAND" +else + PROMPT_COMMAND="docker_git_prompt_apply" +fi +EOF + chmod 0644 "$PROMPT_PATH" +fi +if ! grep -q "zz-prompt.sh" /etc/bash.bashrc 2>/dev/null; then + printf "%s\n" "if [ -f /etc/profile.d/zz-prompt.sh ]; then . /etc/profile.d/zz-prompt.sh; fi" >> /etc/bash.bashrc +fi + +# Ensure bash completion is configured for interactive shells +COMPLETION_PATH="/etc/profile.d/zz-bash-completion.sh" +if [[ ! -s "$COMPLETION_PATH" ]]; then + cat <<'EOF' > "$COMPLETION_PATH" +if ! shopt -oq posix; then + if [ -f /usr/share/bash-completion/bash_completion ]; then + . /usr/share/bash-completion/bash_completion + elif [ -f /etc/bash_completion ]; then + . /etc/bash_completion + fi +fi +EOF + chmod 0644 "$COMPLETION_PATH" +fi +if ! grep -q "zz-bash-completion.sh" /etc/bash.bashrc 2>/dev/null; then + printf "%s\n" "if [ -f /etc/profile.d/zz-bash-completion.sh ]; then . /etc/profile.d/zz-bash-completion.sh; fi" >> /etc/bash.bashrc +fi + +# Ensure bash history is configured for interactive shells +HISTORY_PATH="/etc/profile.d/zz-bash-history.sh" +if [[ ! -s "$HISTORY_PATH" ]]; then + cat <<'EOF' > "$HISTORY_PATH" +if [ -n "$BASH_VERSION" ]; then + case "$-" in + *i*) + HISTFILE="${HISTFILE:-$HOME/.bash_history}" + HISTSIZE="${HISTSIZE:-10000}" + HISTFILESIZE="${HISTFILESIZE:-20000}" + HISTCONTROL="${HISTCONTROL:-ignoredups:erasedups}" + export HISTFILE HISTSIZE HISTFILESIZE HISTCONTROL + shopt -s histappend + if [ -n "${PROMPT_COMMAND-}" ]; then + PROMPT_COMMAND="history -a; ${PROMPT_COMMAND}" + else + PROMPT_COMMAND="history -a" + fi + ;; + esac +fi +EOF + chmod 0644 "$HISTORY_PATH" +fi +if ! grep -q "zz-bash-history.sh" /etc/bash.bashrc 2>/dev/null; then + printf "%s\n" "if [ -f /etc/profile.d/zz-bash-history.sh ]; then . /etc/profile.d/zz-bash-history.sh; fi" >> /etc/bash.bashrc +fi + +# Ensure readline history search bindings for dev +INPUTRC_PATH="/home/dev/.inputrc" +if [[ ! -f "$INPUTRC_PATH" ]]; then + cat <<'EOF' > "$INPUTRC_PATH" +set show-all-if-ambiguous on +set completion-ignore-case on +"\e[A": history-search-backward +"\e[B": history-search-forward +EOF + chown 1000:1000 "$INPUTRC_PATH" || true +fi + +# Ensure zsh config exists for autosuggestions +ZSHRC_PATH="/etc/zsh/zshrc" +if [[ ! -s "$ZSHRC_PATH" ]]; then + mkdir -p /etc/zsh + cat <<'EOF' > "$ZSHRC_PATH" +setopt PROMPT_SUBST + +# Terminal compatibility: if terminfo for $TERM is missing (common over SSH), +# fall back to xterm-256color so ZLE doesn't garble the display. +if command -v infocmp >/dev/null 2>&1; then + if ! infocmp "$TERM" >/dev/null 2>&1; then + export TERM=xterm-256color + fi +fi + +autoload -Uz compinit +compinit + +# Completion UX: cycle matches instead of listing them into scrollback. +setopt AUTO_MENU +setopt MENU_COMPLETE +unsetopt AUTO_LIST +unsetopt LIST_BEEP + +# Command completion ordering: prefer real commands/builtins over internal helper functions. +zstyle ':completion:*' tag-order builtins commands aliases reserved-words functions + +autoload -Uz add-zsh-hook +docker_git_branch() { git rev-parse --abbrev-ref HEAD 2>/dev/null; } +docker_git_prompt_apply() { + local b + b="$(docker_git_branch)" + local base="[%*] %~" + if [[ -n "$b" ]]; then + PROMPT="$base ($b)> " + else + PROMPT="$base> " + fi +} +add-zsh-hook precmd docker_git_prompt_apply + +HISTFILE="${HISTFILE:-$HOME/.zsh_history}" +HISTSIZE="${HISTSIZE:-10000}" +SAVEHIST="${SAVEHIST:-20000}" +setopt HIST_IGNORE_ALL_DUPS +setopt SHARE_HISTORY +setopt INC_APPEND_HISTORY + +if [ -f "$HISTFILE" ]; then + fc -R "$HISTFILE" 2>/dev/null || true +fi +if [ -f "$HOME/.bash_history" ] && [ "$HISTFILE" != "$HOME/.bash_history" ]; then + fc -R "$HOME/.bash_history" 2>/dev/null || true +fi + +bindkey '^[[A' history-search-backward +bindkey '^[[B' history-search-forward + +if [[ "${DOCKER_GIT_ZSH_AUTOSUGGEST:-1}" == "1" ]] && [ -f /usr/share/zsh-autosuggestions/zsh-autosuggestions.zsh ]; then + # Suggest from history first, then fall back to completion (commands + paths). + # This gives "ghost text" suggestions without needing to press . + ZSH_AUTOSUGGEST_HIGHLIGHT_STYLE="${DOCKER_GIT_ZSH_AUTOSUGGEST_STYLE:-fg=8,italic}" + if [[ -n "${DOCKER_GIT_ZSH_AUTOSUGGEST_STRATEGY-}" ]]; then + ZSH_AUTOSUGGEST_STRATEGY=(${=DOCKER_GIT_ZSH_AUTOSUGGEST_STRATEGY}) + else + ZSH_AUTOSUGGEST_STRATEGY=(history completion) + fi + source /usr/share/zsh-autosuggestions/zsh-autosuggestions.zsh +fi +EOF +fi + +# Ensure codex resume hint is shown for interactive shells +CODEX_HINT_PATH="/etc/profile.d/zz-codex-resume.sh" +if [[ ! -s "$CODEX_HINT_PATH" ]]; then + cat <<'EOF' > "$CODEX_HINT_PATH" +if [ -n "$BASH_VERSION" ]; then + case "$-" in + *i*) + if [ -z "${CODEX_RESUME_HINT_SHOWN-}" ]; then + echo "Старые сессии можно запустить с помощью codex resume или codex resume , если знаешь айди." + export CODEX_RESUME_HINT_SHOWN=1 + fi + ;; + esac +fi +if [ -n "$ZSH_VERSION" ]; then + if [[ "$-" == *i* ]]; then + if [[ -z "${CODEX_RESUME_HINT_SHOWN-}" ]]; then + echo "Старые сессии можно запустить с помощью codex resume или codex resume , если знаешь айди." + export CODEX_RESUME_HINT_SHOWN=1 + fi + fi +fi +EOF + chmod 0644 "$CODEX_HINT_PATH" +fi +if ! grep -q "zz-codex-resume.sh" /etc/bash.bashrc 2>/dev/null; then + printf "%s\n" "if [ -f /etc/profile.d/zz-codex-resume.sh ]; then . /etc/profile.d/zz-codex-resume.sh; fi" >> /etc/bash.bashrc +fi +if [[ -s /etc/zsh/zshrc ]] && ! grep -q "zz-codex-resume.sh" /etc/zsh/zshrc 2>/dev/null; then + printf "%s\n" "if [ -f /etc/profile.d/zz-codex-resume.sh ]; then source /etc/profile.d/zz-codex-resume.sh; fi" >> /etc/zsh/zshrc +fi + +# Ensure global AGENTS.md exists for container context +AGENTS_PATH="/home/dev/.codex/AGENTS.md" +LEGACY_AGENTS_PATH="/home/dev/AGENTS.md" +PROJECT_LINE="Рабочая папка проекта (git clone): /home/dev/octocat/hello-world/issue-1" +WORKSPACES_LINE="Доступные workspace пути: /home/dev/octocat/hello-world/issue-1" +WORKSPACE_INFO_LINE="Контекст workspace: repository" +FOCUS_LINE="Фокус задачи: работай только в workspace, который запрашивает пользователь. Текущий workspace: /home/dev/octocat/hello-world/issue-1" +ISSUE_AGENTS_HINT_LINE="Issue AGENTS.md: n/a" +INTERNET_LINE="Доступ к интернету: есть. Если чего-то не знаешь — ищи в интернете или по кодовой базе." +if [[ "$REPO_REF" == issue-* ]]; then + ISSUE_ID="$(printf "%s" "$REPO_REF" | sed -E 's#^issue-##')" + ISSUE_URL="" + if [[ "$REPO_URL" == https://github.com/* ]]; then + ISSUE_REPO="$(printf "%s" "$REPO_URL" | sed -E 's#^https://github.com/##; s#[.]git$##; s#/*$##')" + if [[ -n "$ISSUE_REPO" ]]; then + ISSUE_URL="https://github.com/$ISSUE_REPO/issues/$ISSUE_ID" + fi + fi + if [[ -n "$ISSUE_URL" ]]; then + WORKSPACE_INFO_LINE="Контекст workspace: issue #$ISSUE_ID ($ISSUE_URL)" + else + WORKSPACE_INFO_LINE="Контекст workspace: issue #$ISSUE_ID" + fi + ISSUE_AGENTS_HINT_LINE="Issue AGENTS.md: /home/dev/octocat/hello-world/issue-1/AGENTS.md" +elif [[ "$REPO_REF" == refs/pull/*/head ]]; then + PR_ID="$(printf "%s" "$REPO_REF" | sed -E 's#^refs/pull/([0-9]+)/head$#\1#')" + if [[ -n "$PR_ID" ]]; then + WORKSPACE_INFO_LINE="Контекст workspace: PR #$PR_ID" + else + WORKSPACE_INFO_LINE="Контекст workspace: pull request ($REPO_REF)" + fi +fi +if [[ ! -f "$AGENTS_PATH" ]]; then + MANAGED_START="" + MANAGED_END="" + MANAGED_BLOCK="$(cat < "$AGENTS_PATH" +Ты автономный агент, который имеет полностью все права управления контейнером. У тебя есть доступ к командам sudo, gh, codex, git, node, pnpm и всем остальным другим. Проекты с которыми идёт работа лежат по пути ~ +$MANAGED_BLOCK +Если ты видишь файлы AGENTS.md внутри проекта, ты обязан их читать и соблюдать инструкции. +EOF + chown 1000:1000 "$AGENTS_PATH" || true +fi +if [[ -f "$AGENTS_PATH" ]]; then + MANAGED_START="" + MANAGED_END="" + MANAGED_BLOCK="$(cat < "$TMP_AGENTS_PATH" + else + sed \ + -e '/^Рабочая папка проекта (git clone):/d' \ + -e '/^Доступные workspace пути:/d' \ + -e '/^Контекст workspace:/d' \ + -e '/^Фокус задачи:/d' \ + -e '/^Issue AGENTS.md:/d' \ + -e '/^Доступ к интернету:/d' \ + "$AGENTS_PATH" > "$TMP_AGENTS_PATH" + if [[ -s "$TMP_AGENTS_PATH" ]]; then + printf "\n" >> "$TMP_AGENTS_PATH" + fi + printf "%s\n" "$MANAGED_BLOCK" >> "$TMP_AGENTS_PATH" + fi + mv "$TMP_AGENTS_PATH" "$AGENTS_PATH" + chown 1000:1000 "$AGENTS_PATH" || true +fi +if [[ -f "$LEGACY_AGENTS_PATH" && -f "$AGENTS_PATH" ]]; then + LEGACY_SUM="$(cksum "$LEGACY_AGENTS_PATH" 2>/dev/null | awk '{print $1 \":\" $2}')" + CODEX_SUM="$(cksum "$AGENTS_PATH" 2>/dev/null | awk '{print $1 \":\" $2}')" + if [[ -n "$LEGACY_SUM" && "$LEGACY_SUM" == "$CODEX_SUM" ]]; then + rm -f "$LEGACY_AGENTS_PATH" + fi +fi + +# Ensure docker socket access for dev +if [[ -S /var/run/docker.sock ]]; then + DOCKER_SOCK_GID="$(stat -c "%g" /var/run/docker.sock)" + DOCKER_GROUP="$(getent group "$DOCKER_SOCK_GID" | cut -d: -f1 || true)" + if [[ -z "$DOCKER_GROUP" ]]; then + DOCKER_GROUP="docker" + groupadd -g "$DOCKER_SOCK_GID" "$DOCKER_GROUP" || true + fi + usermod -aG "$DOCKER_GROUP" dev || true + printf "export DOCKER_HOST=unix:///var/run/docker.sock +" > /etc/profile.d/docker-host.sh +fi + +# 2) Ensure GitHub auth vars are available for SSH sessions if provided +if [[ -n "$GH_TOKEN" || -n "$GITHUB_TOKEN" ]]; then + EFFECTIVE_GITHUB_TOKEN="$GITHUB_TOKEN" + if [[ -z "$EFFECTIVE_GITHUB_TOKEN" ]]; then + EFFECTIVE_GITHUB_TOKEN="$GH_TOKEN" + fi + + EFFECTIVE_GH_TOKEN="$GH_TOKEN" + if [[ -z "$EFFECTIVE_GH_TOKEN" ]]; then + EFFECTIVE_GH_TOKEN="$EFFECTIVE_GITHUB_TOKEN" + fi + + printf "export GH_TOKEN=%q\n" "$EFFECTIVE_GH_TOKEN" > /etc/profile.d/gh-token.sh + printf "export GITHUB_TOKEN=%q\n" "$EFFECTIVE_GITHUB_TOKEN" >> /etc/profile.d/gh-token.sh + chmod 0644 /etc/profile.d/gh-token.sh + SSH_ENV_PATH="/home/dev/.ssh/environment" + printf "%s\n" "GH_TOKEN=$EFFECTIVE_GH_TOKEN" > "$SSH_ENV_PATH" + printf "%s\n" "GITHUB_TOKEN=$EFFECTIVE_GITHUB_TOKEN" >> "$SSH_ENV_PATH" + chmod 600 "$SSH_ENV_PATH" + chown 1000:1000 "$SSH_ENV_PATH" || true + + SAFE_GH_TOKEN="$(printf "%q" "$GH_TOKEN")" + # Keep git+https auth in sync with gh auth so push/pull works without manual setup. + su - dev -c "GH_TOKEN=$SAFE_GH_TOKEN gh auth setup-git --hostname github.com --force" || true + + GH_LOGIN="$(su - dev -c "GH_TOKEN=$SAFE_GH_TOKEN gh api user --jq .login" 2>/dev/null || true)" + GH_ID="$(su - dev -c "GH_TOKEN=$SAFE_GH_TOKEN gh api user --jq .id" 2>/dev/null || true)" + GH_LOGIN="$(printf "%s" "$GH_LOGIN" | tr -d '\r\n')" + GH_ID="$(printf "%s" "$GH_ID" | tr -d '\r\n')" + + if [[ -z "$GIT_USER_NAME" && -n "$GH_LOGIN" ]]; then + GIT_USER_NAME="$GH_LOGIN" + fi + if [[ -z "$GIT_USER_EMAIL" && -n "$GH_LOGIN" && -n "$GH_ID" ]]; then + GIT_USER_EMAIL="${GH_ID}+${GH_LOGIN}@users.noreply.github.com" + fi +fi + +# 3) Configure git credential helper for HTTPS remotes +GIT_CREDENTIAL_HELPER_PATH="/usr/local/bin/docker-git-credential-helper" +cat <<'EOF' > "$GIT_CREDENTIAL_HELPER_PATH" +#!/usr/bin/env bash +set -euo pipefail + +if [[ "$#" -lt 1 || "$1" != "get" ]]; then + exit 0 +fi + +token="$GITHUB_TOKEN" +if [[ -z "$token" ]]; then + token="$GH_TOKEN" +fi + +if [[ -z "$token" ]]; then + exit 0 +fi + +printf "%s\n" "username=x-access-token" +printf "%s\n" "password=$token" +EOF +chmod 0755 "$GIT_CREDENTIAL_HELPER_PATH" +su - dev -c "git config --global credential.helper '$GIT_CREDENTIAL_HELPER_PATH'" + +# 4) Configure git identity for the dev user if provided +if [[ -n "$GIT_USER_NAME" ]]; then + SAFE_GIT_USER_NAME="$(printf "%q" "$GIT_USER_NAME")" + su - dev -c "git config --global user.name $SAFE_GIT_USER_NAME" +fi + +if [[ -n "$GIT_USER_EMAIL" ]]; then + SAFE_GIT_USER_EMAIL="$(printf "%q" "$GIT_USER_EMAIL")" + su - dev -c "git config --global user.email $SAFE_GIT_USER_EMAIL" +fi + +# 3) Install global git hooks to protect main/master +HOOKS_DIR="/opt/docker-git/hooks" +PRE_PUSH_HOOK="$HOOKS_DIR/pre-push" +mkdir -p "$HOOKS_DIR" +if [[ ! -f "$PRE_PUSH_HOOK" ]]; then + cat <<'EOF' > "$PRE_PUSH_HOOK" +#!/usr/bin/env bash +set -euo pipefail + +protected_branches=("refs/heads/main" "refs/heads/master") +allow_delete="${DOCKER_GIT_ALLOW_DELETE:-}" + +while read -r local_ref local_sha remote_ref remote_sha; do + if [[ -z "$remote_ref" ]]; then + continue + fi + for protected in "${protected_branches[@]}"; do + if [[ "$remote_ref" == "$protected" || "$local_ref" == "$protected" ]]; then + echo "docker-git: push to protected branch '${protected##*/}' is disabled." + echo "docker-git: create a new branch: git checkout -b " + exit 1 + fi + done + if [[ "$local_sha" == "0000000000000000000000000000000000000000" && "$remote_ref" == refs/heads/* ]]; then + if [[ "$allow_delete" != "1" ]]; then + echo "docker-git: deleting remote branches is disabled (set DOCKER_GIT_ALLOW_DELETE=1 to override)." + exit 1 + fi + fi +done +EOF + chmod 0755 "$PRE_PUSH_HOOK" +fi +git config --system core.hooksPath "$HOOKS_DIR" || true +git config --global core.hooksPath "$HOOKS_DIR" || true + +# 4) Start background tasks so SSH can come up immediately +( +# 1) Keep Codex CLI up to date if requested (bun only) +if [[ "$CODEX_AUTO_UPDATE" == "1" ]]; then + if command -v bun >/dev/null 2>&1; then + echo "[codex] updating via bun..." + script -q -e -c "bun add -g @openai/codex@latest" /dev/null || true + else + echo "[codex] bun not found, skipping auto-update" + fi +fi + +# 2) Auto-clone repo if not already present +mkdir -p /run/docker-git +CLONE_DONE_PATH="/run/docker-git/clone.done" +CLONE_FAIL_PATH="/run/docker-git/clone.failed" +rm -f "$CLONE_DONE_PATH" "$CLONE_FAIL_PATH" + +CLONE_OK=1 + +if [[ -z "$REPO_URL" ]]; then + echo "[clone] skip (no repo url)" +elif [[ -d "$TARGET_DIR/.git" ]]; then + echo "[clone] skip (already cloned)" +else + mkdir -p "$TARGET_DIR" + if [[ "$TARGET_DIR" != "/" ]]; then + chown -R 1000:1000 "$TARGET_DIR" + fi + chown -R 1000:1000 /home/dev + + AUTH_REPO_URL="$REPO_URL" + if [[ -n "$GIT_AUTH_TOKEN" && "$REPO_URL" == https://* ]]; then + AUTH_REPO_URL="$(printf "%s" "$REPO_URL" | sed "s#^https://#https://${GIT_AUTH_USER}:${GIT_AUTH_TOKEN}@#")" + fi + if [[ -n "$REPO_REF" ]]; then + if [[ "$REPO_REF" == refs/pull/* ]]; then + REF_BRANCH="pr-$(printf "%s" "$REPO_REF" | tr '/:' '--')" + if ! su - dev -c "GIT_TERMINAL_PROMPT=0 git clone --progress '$AUTH_REPO_URL' '$TARGET_DIR'"; then + echo "[clone] git clone failed for $REPO_URL" + CLONE_OK=0 + else + if ! su - dev -c "cd '$TARGET_DIR' && GIT_TERMINAL_PROMPT=0 git fetch --progress origin '$REPO_REF':'$REF_BRANCH' && git checkout '$REF_BRANCH'"; then + echo "[clone] git fetch failed for $REPO_REF" + CLONE_OK=0 + fi + fi + else + if ! su - dev -c "GIT_TERMINAL_PROMPT=0 git clone --progress --branch '$REPO_REF' '$AUTH_REPO_URL' '$TARGET_DIR'"; then + DEFAULT_REF="$(git ls-remote --symref "$AUTH_REPO_URL" HEAD 2>/dev/null | awk '/^ref:/ {print $2}' | head -n 1 || true)" + DEFAULT_BRANCH="$(printf "%s" "$DEFAULT_REF" | sed 's#^refs/heads/##')" + if [[ -n "$DEFAULT_BRANCH" ]]; then + echo "[clone] branch '$REPO_REF' missing; retrying with '$DEFAULT_BRANCH'" + if ! su - dev -c "GIT_TERMINAL_PROMPT=0 git clone --progress --branch '$DEFAULT_BRANCH' '$AUTH_REPO_URL' '$TARGET_DIR'"; then + echo "[clone] git clone failed for $REPO_URL" + CLONE_OK=0 + elif [[ "$REPO_REF" == issue-* ]]; then + if ! su - dev -c "cd '$TARGET_DIR' && git checkout -B '$REPO_REF'"; then + echo "[clone] failed to create local branch '$REPO_REF'" + CLONE_OK=0 + fi + fi + else + echo "[clone] git clone failed for $REPO_URL" + CLONE_OK=0 + fi + fi + fi + else + if ! su - dev -c "GIT_TERMINAL_PROMPT=0 git clone --progress '$AUTH_REPO_URL' '$TARGET_DIR'"; then + echo "[clone] git clone failed for $REPO_URL" + CLONE_OK=0 + fi + fi +fi + +if [[ "$CLONE_OK" -eq 1 && -d "$TARGET_DIR/.git" ]]; then + if [[ -n "$FORK_REPO_URL" && "$FORK_REPO_URL" != "$REPO_URL" ]]; then + su - dev -c "cd '$TARGET_DIR' && git remote set-url origin '$FORK_REPO_URL'" || true + su - dev -c "cd '$TARGET_DIR' && git remote add upstream '$REPO_URL' 2>/dev/null || git remote set-url upstream '$REPO_URL'" || true + else + su - dev -c "cd '$TARGET_DIR' && git remote set-url origin '$REPO_URL'" || true + su - dev -c "cd '$TARGET_DIR' && git remote remove upstream >/dev/null 2>&1 || true" || true + fi +fi + +if [[ "$CLONE_OK" -eq 1 && "$REPO_REF" == issue-* && -d "$TARGET_DIR/.git" ]]; then +ISSUE_ID="$(printf "%s" "$REPO_REF" | sed -E 's#^issue-##')" +ISSUE_URL="" +if [[ "$REPO_URL" == https://github.com/* ]]; then + ISSUE_REPO="$(printf "%s" "$REPO_URL" | sed -E 's#^https://github.com/##; s#[.]git$##; s#/*$##')" + if [[ -n "$ISSUE_REPO" ]]; then + ISSUE_URL="https://github.com/$ISSUE_REPO/issues/$ISSUE_ID" + fi +fi +if [[ -z "$ISSUE_URL" ]]; then + ISSUE_URL="n/a" +fi + +ISSUE_AGENTS_PATH="$TARGET_DIR/AGENTS.md" +ISSUE_MANAGED_START="" +ISSUE_MANAGED_END="" +ISSUE_MANAGED_BLOCK="$(cat < "$ISSUE_AGENTS_PATH" +else + TMP_ISSUE_AGENTS_PATH="$(mktemp)" + if grep -qF "$ISSUE_MANAGED_START" "$ISSUE_AGENTS_PATH" && grep -qF "$ISSUE_MANAGED_END" "$ISSUE_AGENTS_PATH"; then + awk -v start="$ISSUE_MANAGED_START" -v end="$ISSUE_MANAGED_END" -v repl="$ISSUE_MANAGED_BLOCK" ' + BEGIN { in_block = 0 } + $0 == start { print repl; in_block = 1; next } + $0 == end { in_block = 0; next } + in_block == 0 { print } + ' "$ISSUE_AGENTS_PATH" > "$TMP_ISSUE_AGENTS_PATH" + else + sed -e '/^# docker-git issue workspace$/d' -e '/^Issue workspace: #/d' -e '/^Issue URL: /d' -e '/^Workspace path: /d' -e '/^Работай только над этим issue, если пользователь не попросил другое[.]$/d' -e '/^Если нужен первоисточник требований, открой Issue URL[.]$/d' "$ISSUE_AGENTS_PATH" > "$TMP_ISSUE_AGENTS_PATH" + if [[ -s "$TMP_ISSUE_AGENTS_PATH" ]]; then + printf " +" >> "$TMP_ISSUE_AGENTS_PATH" + fi + printf "%s +" "$ISSUE_MANAGED_BLOCK" >> "$TMP_ISSUE_AGENTS_PATH" + fi + mv "$TMP_ISSUE_AGENTS_PATH" "$ISSUE_AGENTS_PATH" +fi +if [[ -e "$ISSUE_AGENTS_PATH" ]]; then + chown 1000:1000 "$ISSUE_AGENTS_PATH" || true +fi + +EXCLUDE_PATH="$TARGET_DIR/.git/info/exclude" +if [[ -f "$ISSUE_AGENTS_PATH" ]]; then + touch "$EXCLUDE_PATH" + if ! grep -qx "AGENTS.md" "$EXCLUDE_PATH"; then + printf "%s +" "AGENTS.md" >> "$EXCLUDE_PATH" + fi +fi +fi + +if [[ "$CLONE_OK" -eq 1 ]]; then + echo "[clone] done" + touch "$CLONE_DONE_PATH" +else + echo "[clone] failed" + touch "$CLONE_FAIL_PATH" +fi +) & + +# 4.5) Snapshot baseline processes for terminal session filtering +mkdir -p /run/docker-git +BASELINE_PATH="/run/docker-git/terminal-baseline.pids" +if [[ ! -f "$BASELINE_PATH" ]]; then + ps -eo pid= > "$BASELINE_PATH" || true +fi + +# 4.75) Disable Ubuntu MOTD noise for SSH sessions +PAM_SSHD="/etc/pam.d/sshd" +if [[ -f "$PAM_SSHD" ]]; then + sed -i 's/^[[:space:]]*session[[:space:]]\+optional[[:space:]]\+pam_motd\.so/#&/' "$PAM_SSHD" || true + sed -i 's/^[[:space:]]*session[[:space:]]\+optional[[:space:]]\+pam_lastlog\.so/#&/' "$PAM_SSHD" || true +fi + +# Also disable sshd's own banners (e.g. "Last login") +mkdir -p /etc/ssh/sshd_config.d || true +DOCKER_GIT_SSHD_CONF="/etc/ssh/sshd_config.d/zz-docker-git-clean.conf" +cat <<'EOF' > "$DOCKER_GIT_SSHD_CONF" +PrintMotd no +PrintLastLog no +EOF +chmod 0644 "$DOCKER_GIT_SSHD_CONF" || true + +# 5) Run sshd in foreground +exec /usr/sbin/sshd -D \ No newline at end of file diff --git a/AGENTS.md b/AGENTS.md index 7c87c6cd..4b74344f 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -373,3 +373,12 @@ describe("Message invariants", () => { Каждый эффект — это контролируемое взаимодействие с реальным миром. ПРИНЦИП: Сначала формализуем, потом программируем. + + +Issue workspace: #39 +Issue URL: https://github.com/ProverCoderAI/docker-git/issues/39 +Workspace path: /home/dev/provercoderai/docker-git/issue-39 + +Работай только над этим issue, если пользователь не попросил другое. +Если нужен первоисточник требований, открой Issue URL. + diff --git a/package.json b/package.json index 4f19c18a..f365c6f4 100644 --- a/package.json +++ b/package.json @@ -47,6 +47,9 @@ "@parcel/watcher", "msgpackr-extract", "unrs-resolver" - ] + ], + "patchedDependencies": { + "@ton-ai-core/vibecode-linter@1.0.6": "patches/@ton-ai-core__vibecode-linter@1.0.6.patch" + } } } diff --git a/packages/app/src/docker-git/cli/parser-clone.ts b/packages/app/src/docker-git/cli/parser-clone.ts index 59945a5e..5aed993a 100644 --- a/packages/app/src/docker-git/cli/parser-clone.ts +++ b/packages/app/src/docker-git/cli/parser-clone.ts @@ -49,7 +49,8 @@ export const parseClone = (args: ReadonlyArray): Either.Either RawOptions>> = { "--up": (raw) => ({ ...raw, up: true }), "--no-up": (raw) => ({ ...raw, up: false }), + "--ssh": (raw) => ({ ...raw, openSsh: true }), + "--no-ssh": (raw) => ({ ...raw, openSsh: false }), "--force": (raw) => ({ ...raw, force: true }), "--force-env": (raw) => ({ ...raw, forceEnv: true }), "--mcp-playwright": (raw) => ({ ...raw, enableMcpPlaywright: true }), diff --git a/packages/app/src/docker-git/cli/usage.ts b/packages/app/src/docker-git/cli/usage.ts index e50f7622..a50f7a01 100644 --- a/packages/app/src/docker-git/cli/usage.ts +++ b/packages/app/src/docker-git/cli/usage.ts @@ -52,6 +52,7 @@ Options: --lines Tail last N lines for sessions logs (default: 200) --include-default Show default/system processes in sessions list --up | --no-up Run docker compose up after init (default: --up) + --ssh | --no-ssh Auto-open SSH after create/clone (default: clone=--ssh, create=--no-ssh) --mcp-playwright | --no-mcp-playwright Enable Playwright MCP + Chromium sidecar (default: --no-mcp-playwright) --force Overwrite existing files and wipe compose volumes (docker compose down -v) --force-env Reset project env defaults only (keep workspace volume/data) diff --git a/packages/app/tests/docker-git/parser.test.ts b/packages/app/tests/docker-git/parser.test.ts index a7f8ff2e..bc6b1b2d 100644 --- a/packages/app/tests/docker-git/parser.test.ts +++ b/packages/app/tests/docker-git/parser.test.ts @@ -56,6 +56,8 @@ describe("parseArgs", () => { it.effect("parses create command with defaults", () => expectCreateCommand(["create", "--repo-url", "https://github.com/org/repo.git"], (command) => { expectCreateDefaults(command) + expect(command.openSsh).toBe(false) + expect(command.waitForClone).toBe(false) expect(command.config.containerName).toBe("dg-repo") expect(command.config.serviceName).toBe("dg-repo") expect(command.config.volumeName).toBe("dg-repo-home") @@ -67,6 +69,8 @@ describe("parseArgs", () => { expect(command.config.repoUrl).toBe("https://github.com/org/repo.git") expect(command.config.repoRef).toBe("issue-9") expect(command.outDir).toBe(".docker-git/org/repo/issue-9") + expect(command.openSsh).toBe(false) + expect(command.waitForClone).toBe(false) expect(command.config.containerName).toBe("dg-repo-issue-9") expect(command.config.serviceName).toBe("dg-repo-issue-9") expect(command.config.volumeName).toBe("dg-repo-issue-9-home") @@ -77,6 +81,8 @@ describe("parseArgs", () => { it.effect("parses clone command with positional repo url", () => expectCreateCommand(["clone", "https://github.com/org/repo.git"], (command) => { expectCreateDefaults(command) + expect(command.openSsh).toBe(true) + expect(command.waitForClone).toBe(true) expect(command.config.targetDir).toBe("/home/dev/org/repo") })) @@ -85,6 +91,16 @@ describe("parseArgs", () => { expect(command.config.repoRef).toBe("feature-x") })) + it.effect("supports disabling SSH auto-open for clone", () => + expectCreateCommand(["clone", "https://github.com/org/repo.git", "--no-ssh"], (command) => { + expect(command.openSsh).toBe(false) + })) + + it.effect("supports enabling SSH auto-open for create", () => + expectCreateCommand(["create", "--repo-url", "https://github.com/org/repo.git", "--ssh"], (command) => { + expect(command.openSsh).toBe(true) + })) + it.effect("parses force-env flag for clone", () => expectCreateCommand(["clone", "https://github.com/org/repo.git", "--force-env"], (command) => { expect(command.force).toBe(false) diff --git a/packages/docker-git/src/server/http.ts b/packages/docker-git/src/server/http.ts index df22fe97..7b812d7a 100644 --- a/packages/docker-git/src/server/http.ts +++ b/packages/docker-git/src/server/http.ts @@ -1115,6 +1115,7 @@ export const makeRouter = ({ cwd, projectsRoot, webRoot, vendorRoot, terminalPor config: nextTemplate, outDir: project.directory, runUp: false, + openSsh: false, force: true, forceEnv: false, waitForClone: false @@ -1455,6 +1456,7 @@ data: ${JSON.stringify(data)} config: nextTemplate, outDir: project.directory, runUp: false, + openSsh: false, force: true, forceEnv: false, waitForClone: false diff --git a/packages/lib/src/core/command-builders.ts b/packages/lib/src/core/command-builders.ts index 5bf7e8e8..6c775a35 100644 --- a/packages/lib/src/core/command-builders.ts +++ b/packages/lib/src/core/command-builders.ts @@ -200,6 +200,7 @@ export const buildCreateCommand = ( const names = yield* _(resolveNames(raw, repo.projectSlug)) const paths = yield* _(resolvePaths(raw, repo.projectSlug, repo.repoPath)) const runUp = raw.up ?? true + const openSsh = raw.openSsh ?? false const force = raw.force ?? false const forceEnv = raw.forceEnv ?? false const enableMcpPlaywright = raw.enableMcpPlaywright ?? false @@ -208,6 +209,7 @@ export const buildCreateCommand = ( _tag: "Create", outDir: paths.outDir, runUp, + openSsh, force, forceEnv, waitForClone: false, diff --git a/packages/lib/src/core/command-options.ts b/packages/lib/src/core/command-options.ts index 902b2721..d2b3bdd9 100644 --- a/packages/lib/src/core/command-options.ts +++ b/packages/lib/src/core/command-options.ts @@ -39,6 +39,7 @@ export interface RawOptions { readonly lines?: string readonly includeDefault?: boolean readonly up?: boolean + readonly openSsh?: boolean readonly force?: boolean readonly forceEnv?: boolean } diff --git a/packages/lib/src/core/domain.ts b/packages/lib/src/core/domain.ts index 4a03a382..98a1bd98 100644 --- a/packages/lib/src/core/domain.ts +++ b/packages/lib/src/core/domain.ts @@ -36,6 +36,7 @@ export interface CreateCommand { readonly force: boolean readonly forceEnv: boolean readonly waitForClone: boolean + readonly openSsh: boolean } export interface MenuCommand { diff --git a/packages/lib/src/usecases/actions/create-project.ts b/packages/lib/src/usecases/actions/create-project.ts index 2772f55b..19ec888d 100644 --- a/packages/lib/src/usecases/actions/create-project.ts +++ b/packages/lib/src/usecases/actions/create-project.ts @@ -1,12 +1,14 @@ import type * as CommandExecutor from "@effect/platform/CommandExecutor" import type { PlatformError } from "@effect/platform/Error" -import type * as FileSystem from "@effect/platform/FileSystem" +import * as FileSystem from "@effect/platform/FileSystem" import * as Path from "@effect/platform/Path" import { Effect } from "effect" import type { CreateCommand } from "../../core/domain.js" import { deriveRepoPathParts } from "../../core/domain.js" +import { runCommandWithExitCodes } from "../../shell/command-runner.js" import { ensureDockerDaemonAccess } from "../../shell/docker.js" +import { CommandFailedError } from "../../shell/errors.js" import type { CloneFailedError, DockerAccessError, @@ -15,8 +17,11 @@ import type { PortProbeError } from "../../shell/errors.js" import { logDockerAccessInfo } from "../access-log.js" +import { renderError } from "../errors.js" import { applyGithubForkConfig } from "../github-fork.js" import { defaultProjectsRoot } from "../menu-helpers.js" +import { findSshPrivateKey } from "../path-helpers.js" +import { buildSshCommand } from "../projects-core.js" import { autoSyncState } from "../state-repo.js" import { runDockerUpIfNeeded } from "./docker-up.js" import { buildProjectConfigs, resolveDockerGitRootRelativePath } from "./paths.js" @@ -80,6 +85,69 @@ const formatStateSyncLabel = (repoUrl: string): string => { return repoPath.length > 0 ? repoPath : repoUrl } +const isInteractiveTty = (): boolean => process.stdin.isTTY === true && process.stdout.isTTY === true + +const buildSshArgs = ( + config: CreateCommand["config"], + sshKeyPath: string | null +): ReadonlyArray => { + const args: Array = [] + if (sshKeyPath !== null) { + args.push("-i", sshKeyPath) + } + args.push( + "-tt", + "-Y", + "-o", + "LogLevel=ERROR", + "-o", + "StrictHostKeyChecking=no", + "-o", + "UserKnownHostsFile=/dev/null", + "-p", + String(config.sshPort), + `${config.sshUser}@localhost` + ) + return args +} + +// CHANGE: auto-open SSH after environment is created (best-effort) +// WHY: clone flow should drop the user into the container without manual copy/paste +// QUOTE(ТЗ): "Мне надо что бы он сразу открыл SSH" +// REF: issue-39 +// SOURCE: n/a +// FORMAT THEOREM: forall c: openSsh(c) -> ssh_session_started(c) || warning_logged(c) +// PURITY: SHELL +// EFFECT: Effect +// INVARIANT: SSH failures do not fail the create/clone command +// COMPLEXITY: O(1) + ssh +const openSshBestEffort = ( + template: CreateCommand["config"] +): Effect.Effect => + Effect.gen(function*(_) { + const fs = yield* _(FileSystem.FileSystem) + const path = yield* _(Path.Path) + + const sshKey = yield* _(findSshPrivateKey(fs, path, process.cwd())) + const sshCommand = buildSshCommand(template, sshKey) + + yield* _(Effect.log(`Opening SSH: ${sshCommand}`)) + yield* _( + runCommandWithExitCodes( + { + cwd: process.cwd(), + command: "ssh", + args: buildSshArgs(template, sshKey) + }, + [0, 130], + (exitCode) => new CommandFailedError({ command: "ssh", exitCode }) + ) + ) + }).pipe( + Effect.catchAll((error) => Effect.logWarning(`SSH auto-open failed: ${renderError(error)}`)), + Effect.asVoid + ) + const runCreateProject = ( path: Path.Path, command: CreateCommand @@ -118,6 +186,16 @@ const runCreateProject = ( } yield* _(autoSyncState(`chore(state): update ${formatStateSyncLabel(projectConfig.repoUrl)}`)) + + if (command.openSsh) { + if (!command.runUp) { + yield* _(Effect.logWarning("Skipping SSH auto-open: docker compose up disabled (--no-up).")) + } else if (!isInteractiveTty()) { + yield* _(Effect.logWarning("Skipping SSH auto-open: not running in an interactive TTY.")) + } else { + yield* _(openSshBestEffort(projectConfig)) + } + } }).pipe(Effect.asVoid) export const createProject = (command: CreateCommand): Effect.Effect => diff --git a/packages/lib/tests/usecases/create-project-open-ssh.test.ts b/packages/lib/tests/usecases/create-project-open-ssh.test.ts new file mode 100644 index 00000000..26be75e1 --- /dev/null +++ b/packages/lib/tests/usecases/create-project-open-ssh.test.ts @@ -0,0 +1,180 @@ +import * as Command from "@effect/platform/Command" +import * as CommandExecutor from "@effect/platform/CommandExecutor" +import * as FileSystem from "@effect/platform/FileSystem" +import * as Path from "@effect/platform/Path" +import { NodeContext } from "@effect/platform-node" +import { describe, expect, it } from "@effect/vitest" +import { Effect } from "effect" +import * as Inspectable from "effect/Inspectable" +import * as Sink from "effect/Sink" +import * as Stream from "effect/Stream" + +import type { CreateCommand, TemplateConfig } from "../../src/core/domain.js" +import { createProject } from "../../src/usecases/actions/create-project.js" + +type RecordedCommand = { + readonly command: string + readonly args: ReadonlyArray +} + +const withTempDir = ( + use: (tempDir: string) => Effect.Effect +): Effect.Effect => + Effect.scoped( + Effect.gen(function*(_) { + const fs = yield* _(FileSystem.FileSystem) + const tempDir = yield* _( + fs.makeTempDirectoryScoped({ + prefix: "docker-git-open-ssh-" + }) + ) + return yield* _(use(tempDir)) + }) + ) + +const encode = (value: string): Uint8Array => new TextEncoder().encode(value) + +const commandIncludes = (args: ReadonlyArray, needle: string): boolean => args.includes(needle) + +const decideExitCode = (cmd: RecordedCommand): number => { + if (cmd.command === "git" && cmd.args[0] === "rev-parse") { + // Auto-sync should detect "not a repo" and exit early. + return 1 + } + + if (cmd.command === "docker" && cmd.args[0] === "exec") { + if (commandIncludes(cmd.args, "/run/docker-git/clone.failed")) { + return 1 + } + if (commandIncludes(cmd.args, "/run/docker-git/clone.done")) { + return 0 + } + } + + return 0 +} + +const decideStdout = (cmd: RecordedCommand): string => { + if (cmd.command === "docker" && cmd.args[0] === "inspect") { + // Keep it empty so ensureDockerDnsHost skips /etc/hosts modifications in tests. + return "" + } + return "" +} + +const makeFakeExecutor = (recorded: Array): CommandExecutor.CommandExecutor => { + const start = (command: Command.Command): Effect.Effect => + Effect.gen(function*(_) { + const flattened = Command.flatten(command) + for (const entry of flattened) { + recorded.push({ command: entry.command, args: entry.args }) + } + + const last = flattened[flattened.length - 1] + const invocation: RecordedCommand = { command: last.command, args: last.args } + const exit = decideExitCode(invocation) + const stdoutText = decideStdout(invocation) + const stdout = stdoutText.length === 0 ? Stream.empty : Stream.succeed(encode(stdoutText)) + + const process: CommandExecutor.Process = { + [CommandExecutor.ProcessTypeId]: CommandExecutor.ProcessTypeId, + pid: CommandExecutor.ProcessId(1), + exitCode: Effect.succeed(CommandExecutor.ExitCode(exit)), + isRunning: Effect.succeed(false), + kill: (_signal) => Effect.void, + stderr: Stream.empty, + stdin: Sink.drain, + stdout, + toJSON: () => ({ _tag: "TestProcess", command: invocation.command, args: invocation.args, exit }), + [Inspectable.NodeInspectSymbol]: () => ({ _tag: "TestProcess", command: invocation.command, args: invocation.args }), + toString: () => `[TestProcess ${invocation.command}]` + } + + return process + }) + + return CommandExecutor.makeExecutor(start) +} + +const makeCommand = (root: string, outDir: string, path: Path.Path): CreateCommand => { + const template: TemplateConfig = { + containerName: "dg-test", + serviceName: "dg-test", + sshUser: "dev", + sshPort: 2222, + repoUrl: "https://github.com/org/repo.git", + repoRef: "main", + targetDir: "/home/dev/org/repo", + volumeName: "dg-test-home", + dockerGitPath: path.join(root, ".docker-git"), + authorizedKeysPath: path.join(root, "authorized_keys"), + envGlobalPath: path.join(root, ".orch/env/global.env"), + envProjectPath: path.join(root, ".orch/env/project.env"), + codexAuthPath: path.join(root, ".orch/auth/codex"), + codexSharedAuthPath: path.join(root, ".orch/auth/codex-shared"), + codexHome: "/home/dev/.codex", + enableMcpPlaywright: false, + pnpmVersion: "10.27.0" + } + + return { + _tag: "Create", + config: template, + outDir, + runUp: true, + openSsh: true, + force: true, + forceEnv: false, + waitForClone: true + } +} + +describe("createProject (openSsh)", () => { + it.effect("runs ssh after clone completion when openSsh=true", () => + withTempDir((root) => + Effect.gen(function*(_) { + const path = yield* _(Path.Path) + + const outDir = path.join(root, "project") + const recorded: Array = [] + const executor = makeFakeExecutor(recorded) + const command = makeCommand(root, outDir, path) + + const prevProjectsRoot = process.env["DOCKER_GIT_PROJECTS_ROOT"] + const prevStdinTty = process.stdin.isTTY + const prevStdoutTty = process.stdout.isTTY + + process.env["DOCKER_GIT_PROJECTS_ROOT"] = path.join(root, "state") + Object.defineProperty(process.stdin, "isTTY", { value: true, configurable: true }) + Object.defineProperty(process.stdout, "isTTY", { value: true, configurable: true }) + + try { + yield* _(createProject(command).pipe(Effect.provideService(CommandExecutor.CommandExecutor, executor))) + } finally { + if (prevProjectsRoot === undefined) { + delete process.env["DOCKER_GIT_PROJECTS_ROOT"] + } else { + process.env["DOCKER_GIT_PROJECTS_ROOT"] = prevProjectsRoot + } + Object.defineProperty(process.stdin, "isTTY", { value: prevStdinTty, configurable: true }) + Object.defineProperty(process.stdout, "isTTY", { value: prevStdoutTty, configurable: true }) + } + + const sshInvocations = recorded.filter((entry) => entry.command === "ssh") + expect(sshInvocations).toHaveLength(1) + const ssh = sshInvocations[0]! + expect(ssh.args).toContain("-p") + expect(ssh.args).toContain("2222") + expect(ssh.args).toContain("dev@localhost") + + const cloneDoneIndex = recorded.findIndex( + (entry) => entry.command === "docker" && entry.args[0] === "exec" && entry.args.includes("/run/docker-git/clone.done") + ) + const sshIndex = recorded.findIndex((entry) => entry.command === "ssh") + expect(cloneDoneIndex).toBeGreaterThanOrEqual(0) + expect(sshIndex).toBeGreaterThan(cloneDoneIndex) + }) + ) + .pipe(Effect.provide(NodeContext.layer)) + ) +}) diff --git a/packages/web/scripts/terminal-ws.mjs b/packages/web/scripts/terminal-ws.mjs index 57c167b7..83c6c300 100644 --- a/packages/web/scripts/terminal-ws.mjs +++ b/packages/web/scripts/terminal-ws.mjs @@ -179,6 +179,7 @@ const runRecreateFlow = async (projectDir, send) => { config: config.template, outDir: projectDir, runUp: false, + openSsh: false, force: true, forceEnv: false, waitForClone: false diff --git a/patches/@ton-ai-core__vibecode-linter@1.0.6.patch b/patches/@ton-ai-core__vibecode-linter@1.0.6.patch new file mode 100644 index 00000000..986a5e6b --- /dev/null +++ b/patches/@ton-ai-core__vibecode-linter@1.0.6.patch @@ -0,0 +1,15 @@ +diff --git a/dist/shell/utils/dependencies.js b/dist/shell/utils/dependencies.js +index bd96968de92e45ef4543abb7781bd71d333c8090..47a6bf82594a74b1acefbe24c2e00b0171dc3cf6 100644 +--- a/dist/shell/utils/dependencies.js ++++ b/dist/shell/utils/dependencies.js +@@ -42,7 +42,9 @@ const DEPENDENCIES = [ + { + name: "TypeScript", + command: "tsc", +- checkCommand: "npx tsc --version", ++ // npm@11+ `npx tsc` resolves to the unrelated `tsc` package. ++ // We just need the TypeScript compiler API dependency to be present. ++ checkCommand: "node -e \"require('typescript')\"", + installCommand: "npm install", + required: true, + }, diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 7fe72578..69e9e1ac 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -4,6 +4,11 @@ settings: autoInstallPeers: true excludeLinksFromLockfile: false +patchedDependencies: + '@ton-ai-core/vibecode-linter@1.0.6': + hash: f1c1c4c58bdb59306606b68007b20230987779acff18507650a013d40de72b55 + path: patches/@ton-ai-core__vibecode-linter@1.0.6.patch + importers: .: @@ -101,7 +106,7 @@ importers: version: 0.0.13(eslint@9.39.2(jiti@2.6.1))(typescript@5.9.3) '@ton-ai-core/vibecode-linter': specifier: ^1.0.6 - version: 1.0.6 + version: 1.0.6(patch_hash=f1c1c4c58bdb59306606b68007b20230987779acff18507650a013d40de72b55) '@types/node': specifier: ^24.10.9 version: 24.10.9 @@ -304,7 +309,7 @@ importers: version: 0.0.13(eslint@9.39.2(jiti@2.6.1))(typescript@5.9.3) '@ton-ai-core/vibecode-linter': specifier: ^1.0.6 - version: 1.0.6 + version: 1.0.6(patch_hash=f1c1c4c58bdb59306606b68007b20230987779acff18507650a013d40de72b55) '@types/node': specifier: ^24.10.9 version: 24.10.9 @@ -5777,7 +5782,7 @@ snapshots: postcss: 8.5.6 tailwindcss: 4.1.18 - '@ton-ai-core/vibecode-linter@1.0.6': + '@ton-ai-core/vibecode-linter@1.0.6(patch_hash=f1c1c4c58bdb59306606b68007b20230987779acff18507650a013d40de72b55)': dependencies: ajv: 8.17.1 effect: 3.19.14 diff --git a/scripts/e2e/ci.sh b/scripts/e2e/ci.sh new file mode 100644 index 00000000..eb84c267 --- /dev/null +++ b/scripts/e2e/ci.sh @@ -0,0 +1,81 @@ +#!/usr/bin/env bash +set -euo pipefail + +ROOT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/../.." && pwd)" + +E2E_ROOT="${ROOT_DIR}/.e2e" +BIN_DIR="${E2E_ROOT}/bin" +LOG_DIR="${E2E_ROOT}/logs" +PROJECTS_ROOT="${E2E_ROOT}/projects" + +REPO_URL="https://github.com/octocat/Hello-World/issues/1" +OUT_DIR="${PROJECTS_ROOT}/octocat/hello-world/issue-1" +CONTAINER_NAME="dg-hello-world-issue-1" +TARGET_DIR="/home/dev/octocat/hello-world/issue-1" + +SSH_LOG="${LOG_DIR}/ssh.log" +ENV_PROJECT="${E2E_ROOT}/project.env" +SSH_PORT="${E2E_SSH_PORT:-}" + +fail() { + echo "e2e: $*" >&2 + exit 1 +} + +cleanup() { + if [[ -d "$OUT_DIR" ]]; then + ( + cd "$OUT_DIR" && docker compose down -v >/dev/null 2>&1 || true + ) + fi +} +trap cleanup EXIT + +mkdir -p "$BIN_DIR" "$LOG_DIR" "$PROJECTS_ROOT" +rm -f "$SSH_LOG" + +cat > "$ENV_PROJECT" <<'EOF' +# Keep CI fast and deterministic (Codex auto-update hits the network on container start) +CODEX_AUTO_UPDATE=0 +CODEX_SHARE_AUTH=0 +EOF + +export DOCKER_GIT_PROJECTS_ROOT="$PROJECTS_ROOT" + +cat > "$BIN_DIR/ssh" <<'EOF' +#!/usr/bin/env bash +set -euo pipefail + +: "${SSH_LOG_PATH:?SSH_LOG_PATH is required}" +printf "ssh %s\n" "$*" >> "$SSH_LOG_PATH" +exit 0 +EOF +chmod +x "$BIN_DIR/ssh" + +export SSH_LOG_PATH="$SSH_LOG" +export PATH="$BIN_DIR:$PATH" + +cd "$ROOT_DIR" + +pnpm --filter ./packages/app build:docker-git + +command -v script >/dev/null 2>&1 || fail "missing 'script' command (util-linux)" + +if [[ -z "$SSH_PORT" ]]; then + SSH_PORT="$(node -e 'const net=require("net"); const s=net.createServer(); s.listen(0,"127.0.0.1",()=>{console.log(s.address().port); s.close();});')" +fi + +script -q -e -c "node packages/app/dist/src/docker-git/main.js clone \"$REPO_URL\" --force --ssh-port \"$SSH_PORT\" --env-project \"$ENV_PROJECT\"" /dev/null + +[[ -s "$SSH_LOG" ]] || fail "expected ssh to be invoked; log is empty: $SSH_LOG" +grep -q "dev@localhost" "$SSH_LOG" || fail "expected ssh args to include dev@localhost; got: $(cat "$SSH_LOG")" +grep -q -- "-p " "$SSH_LOG" || fail "expected ssh args to include -p ; got: $(cat "$SSH_LOG")" + +docker ps --format '{{.Names}}' | grep -qx "$CONTAINER_NAME" || fail "expected container to be running: $CONTAINER_NAME" +docker exec "$CONTAINER_NAME" bash -lc "test -d '$TARGET_DIR/.git'" || fail "expected repo to be cloned at: $TARGET_DIR" +branch="$(docker exec "$CONTAINER_NAME" bash -lc "cd '$TARGET_DIR' && git rev-parse --abbrev-ref HEAD")" +[[ "$branch" == "issue-1" ]] || fail "expected HEAD branch issue-1, got: $branch" + +[[ -f "$OUT_DIR/docker-git.json" ]] || fail "expected project config file: $OUT_DIR/docker-git.json" + +echo "e2e: OK" From a8d1757f4c5f19f6ccd2ac027769202429475d14 Mon Sep 17 00:00:00 2001 From: skulidropek <66840575+skulidropek@users.noreply.github.com> Date: Sun, 15 Feb 2026 10:08:30 +0000 Subject: [PATCH 4/7] test(e2e): harden CI system check --- .e2e/bin/ssh | 6 - .e2e/project.env | 3 - .../octocat/hello-world/issue-1/.dockerignore | 3 - .../octocat/hello-world/issue-1/.gitignore | 9 - .../octocat/hello-world/issue-1/Dockerfile | 172 ---- .../hello-world/issue-1/docker-compose.yml | 32 - .../hello-world/issue-1/docker-git.json | 22 - .../octocat/hello-world/issue-1/entrypoint.sh | 836 ------------------ .gitignore | 1 + scripts/e2e/ci.sh | 3 +- 10 files changed, 3 insertions(+), 1084 deletions(-) delete mode 100755 .e2e/bin/ssh delete mode 100644 .e2e/project.env delete mode 100644 .e2e/projects/octocat/hello-world/issue-1/.dockerignore delete mode 100644 .e2e/projects/octocat/hello-world/issue-1/.gitignore delete mode 100644 .e2e/projects/octocat/hello-world/issue-1/Dockerfile delete mode 100644 .e2e/projects/octocat/hello-world/issue-1/docker-compose.yml delete mode 100644 .e2e/projects/octocat/hello-world/issue-1/docker-git.json delete mode 100755 .e2e/projects/octocat/hello-world/issue-1/entrypoint.sh diff --git a/.e2e/bin/ssh b/.e2e/bin/ssh deleted file mode 100755 index 81c4a4f2..00000000 --- a/.e2e/bin/ssh +++ /dev/null @@ -1,6 +0,0 @@ -#!/usr/bin/env bash -set -euo pipefail - -: "${SSH_LOG_PATH:?SSH_LOG_PATH is required}" -printf "ssh %s\n" "$*" >> "$SSH_LOG_PATH" -exit 0 diff --git a/.e2e/project.env b/.e2e/project.env deleted file mode 100644 index 4d7783c6..00000000 --- a/.e2e/project.env +++ /dev/null @@ -1,3 +0,0 @@ -# Keep CI fast and deterministic (Codex auto-update hits the network on container start) -CODEX_AUTO_UPDATE=0 -CODEX_SHARE_AUTH=0 diff --git a/.e2e/projects/octocat/hello-world/issue-1/.dockerignore b/.e2e/projects/octocat/hello-world/issue-1/.dockerignore deleted file mode 100644 index c3c63f6c..00000000 --- a/.e2e/projects/octocat/hello-world/issue-1/.dockerignore +++ /dev/null @@ -1,3 +0,0 @@ -# docker-git build context -.orch/ -authorized_keys diff --git a/.e2e/projects/octocat/hello-world/issue-1/.gitignore b/.e2e/projects/octocat/hello-world/issue-1/.gitignore deleted file mode 100644 index c02cca46..00000000 --- a/.e2e/projects/octocat/hello-world/issue-1/.gitignore +++ /dev/null @@ -1,9 +0,0 @@ -# docker-git project files -# NOTE: this directory is intended to be committed to the docker-git state repository. -# It intentionally does not ignore .orch/ or auth files; keep the state repo private. - -# Volatile Codex artifacts (do not commit) -.orch/auth/codex/log/ -.orch/auth/codex/tmp/ -.orch/auth/codex/sessions/ -.orch/auth/codex/models_cache.json diff --git a/.e2e/projects/octocat/hello-world/issue-1/Dockerfile b/.e2e/projects/octocat/hello-world/issue-1/Dockerfile deleted file mode 100644 index 4faec5bd..00000000 --- a/.e2e/projects/octocat/hello-world/issue-1/Dockerfile +++ /dev/null @@ -1,172 +0,0 @@ -FROM ubuntu:24.04 - -ENV DEBIAN_FRONTEND=noninteractive -ENV NVM_DIR=/usr/local/nvm - -RUN apt-get update && apt-get install -y --no-install-recommends openssh-server git gh ca-certificates curl unzip bsdutils sudo make docker.io docker-compose-v2 bash-completion zsh zsh-autosuggestions xauth ncurses-term && rm -rf /var/lib/apt/lists/* - -# Passwordless sudo for all users (container is disposable) -RUN printf "%s\n" "ALL ALL=(ALL) NOPASSWD:ALL" > /etc/sudoers.d/zz-all && chmod 0440 /etc/sudoers.d/zz-all - -# Shell prompt: show git branch for interactive sessions -RUN cat <<'EOF' > /etc/profile.d/zz-prompt.sh -docker_git_branch() { git rev-parse --abbrev-ref HEAD 2>/dev/null; } -docker_git_prompt_apply() { - local b - b="$(docker_git_branch)" - local base="[\t] \w" - if [ -n "$b" ]; then - PS1="${base} (${b})> " - else - PS1="${base}> " - fi -} -if [ -n "$PROMPT_COMMAND" ]; then - PROMPT_COMMAND="docker_git_prompt_apply;$PROMPT_COMMAND" -else - PROMPT_COMMAND="docker_git_prompt_apply" -fi -EOF -RUN chmod 0644 /etc/profile.d/zz-prompt.sh -RUN printf "%s\n" \ - "if [ -f /etc/profile.d/zz-prompt.sh ]; then . /etc/profile.d/zz-prompt.sh; fi" \ - >> /etc/bash.bashrc -RUN cat <<'EOF' > /etc/profile.d/zz-bash-completion.sh -if ! shopt -oq posix; then - if [ -f /usr/share/bash-completion/bash_completion ]; then - . /usr/share/bash-completion/bash_completion - elif [ -f /etc/bash_completion ]; then - . /etc/bash_completion - fi -fi -EOF -RUN chmod 0644 /etc/profile.d/zz-bash-completion.sh -RUN printf "%s\n" \ - "if [ -f /etc/profile.d/zz-bash-completion.sh ]; then . /etc/profile.d/zz-bash-completion.sh; fi" \ - >> /etc/bash.bashrc -RUN cat <<'EOF' > /etc/profile.d/zz-bash-history.sh -if [ -n "$BASH_VERSION" ]; then - case "$-" in - *i*) - HISTFILE="${HISTFILE:-$HOME/.bash_history}" - HISTSIZE="${HISTSIZE:-10000}" - HISTFILESIZE="${HISTFILESIZE:-20000}" - HISTCONTROL="${HISTCONTROL:-ignoredups:erasedups}" - export HISTFILE HISTSIZE HISTFILESIZE HISTCONTROL - shopt -s histappend - if [ -n "${PROMPT_COMMAND-}" ]; then - PROMPT_COMMAND="history -a; ${PROMPT_COMMAND}" - else - PROMPT_COMMAND="history -a" - fi - ;; - esac -fi -EOF -RUN chmod 0644 /etc/profile.d/zz-bash-history.sh -RUN printf "%s\n" \ - "if [ -f /etc/profile.d/zz-bash-history.sh ]; then . /etc/profile.d/zz-bash-history.sh; fi" \ - >> /etc/bash.bashrc -RUN mkdir -p /etc/zsh -RUN cat <<'EOF' > /etc/zsh/zshrc -setopt PROMPT_SUBST - -# Terminal compatibility: if terminfo for $TERM is missing (common over SSH), -# fall back to xterm-256color so ZLE doesn't garble the display. -if command -v infocmp >/dev/null 2>&1; then - if ! infocmp "$TERM" >/dev/null 2>&1; then - export TERM=xterm-256color - fi -fi - -autoload -Uz compinit -compinit - -# Completion UX: cycle matches instead of listing them into scrollback. -setopt AUTO_MENU -setopt MENU_COMPLETE -unsetopt AUTO_LIST -unsetopt LIST_BEEP - -# Command completion ordering: prefer real commands/builtins over internal helper functions. -zstyle ':completion:*' tag-order builtins commands aliases reserved-words functions - -autoload -Uz add-zsh-hook -docker_git_branch() { git rev-parse --abbrev-ref HEAD 2>/dev/null; } -docker_git_prompt_apply() { - local b - b="$(docker_git_branch)" - local base="[%*] %~" - if [[ -n "$b" ]]; then - PROMPT="$base ($b)> " - else - PROMPT="$base> " - fi -} -add-zsh-hook precmd docker_git_prompt_apply - -HISTFILE="${HISTFILE:-$HOME/.zsh_history}" -HISTSIZE="${HISTSIZE:-10000}" -SAVEHIST="${SAVEHIST:-20000}" -setopt HIST_IGNORE_ALL_DUPS -setopt SHARE_HISTORY -setopt INC_APPEND_HISTORY - -if [ -f "$HISTFILE" ]; then - fc -R "$HISTFILE" 2>/dev/null || true -fi -if [ -f "$HOME/.bash_history" ] && [ "$HISTFILE" != "$HOME/.bash_history" ]; then - fc -R "$HOME/.bash_history" 2>/dev/null || true -fi - -bindkey '^[[A' history-search-backward -bindkey '^[[B' history-search-forward - -if [[ "${DOCKER_GIT_ZSH_AUTOSUGGEST:-1}" == "1" ]] && [ -f /usr/share/zsh-autosuggestions/zsh-autosuggestions.zsh ]; then - # Suggest from history first, then fall back to completion (commands + paths). - # This gives "ghost text" suggestions without needing to press . - ZSH_AUTOSUGGEST_HIGHLIGHT_STYLE="${DOCKER_GIT_ZSH_AUTOSUGGEST_STYLE:-fg=8,italic}" - if [[ -n "${DOCKER_GIT_ZSH_AUTOSUGGEST_STRATEGY-}" ]]; then - ZSH_AUTOSUGGEST_STRATEGY=(${=DOCKER_GIT_ZSH_AUTOSUGGEST_STRATEGY}) - else - ZSH_AUTOSUGGEST_STRATEGY=(history completion) - fi - source /usr/share/zsh-autosuggestions/zsh-autosuggestions.zsh -fi -EOF - -# Tooling: Node 24 (NodeSource) + nvm -RUN curl -fsSL https://deb.nodesource.com/setup_24.x | bash - && apt-get install -y --no-install-recommends nodejs && node -v && npm -v && corepack --version && rm -rf /var/lib/apt/lists/* -RUN mkdir -p /usr/local/nvm && curl -fsSL https://raw.githubusercontent.com/nvm-sh/nvm/v0.40.1/install.sh | bash -RUN printf "export NVM_DIR=/usr/local/nvm\n[ -s /usr/local/nvm/nvm.sh ] && . /usr/local/nvm/nvm.sh\n" > /etc/profile.d/nvm.sh && chmod 0644 /etc/profile.d/nvm.sh - -# Tooling: pnpm + Codex CLI (bun) -RUN corepack enable && corepack prepare pnpm@10.27.0 --activate -ENV BUN_INSTALL=/usr/local/bun -ENV TERM=xterm-256color -ENV PATH="/usr/local/bun/bin:$PATH" -RUN curl -fsSL https://bun.sh/install | bash -RUN ln -sf /usr/local/bun/bin/bun /usr/local/bin/bun -RUN script -q -e -c "bun add -g @openai/codex@latest" /dev/null -RUN ln -sf /usr/local/bun/bin/codex /usr/local/bin/codex -RUN printf "export BUN_INSTALL=/usr/local/bun\nexport PATH=/usr/local/bun/bin:$PATH\n" > /etc/profile.d/bun.sh && chmod 0644 /etc/profile.d/bun.sh - -# Create non-root user for SSH (align UID/GID with host user 1000) -RUN if id -u ubuntu >/dev/null 2>&1; then if getent group 1000 >/dev/null 2>&1; then EXISTING_GROUP="$(getent group 1000 | cut -d: -f1)"; if [ "$EXISTING_GROUP" != "dev" ]; then groupmod -n dev "$EXISTING_GROUP" || true; fi; fi; usermod -l dev -d /home/dev -m -s /usr/bin/zsh ubuntu || true; fi -RUN if id -u dev >/dev/null 2>&1; then usermod -u 1000 -g 1000 -o dev; else groupadd -g 1000 dev || true; useradd -m -s /usr/bin/zsh -u 1000 -g 1000 -o dev; fi -RUN printf "%s\n" "dev ALL=(ALL) NOPASSWD:ALL" > /etc/sudoers.d/dev && chmod 0440 /etc/sudoers.d/dev - -# sshd runtime dir -RUN mkdir -p /run/sshd - -# Harden sshd: disable password auth and root login -RUN printf "%s\n" "PasswordAuthentication no" "PermitRootLogin no" "PubkeyAuthentication yes" "X11Forwarding yes" "X11UseLocalhost yes" "PermitUserEnvironment yes" "AllowUsers dev" > /etc/ssh/sshd_config.d/dev.conf - -# Workspace path (supports root-level dirs like /repo) -RUN mkdir -p /home/dev/octocat/hello-world/issue-1 && chown -R 1000:1000 /home/dev && if [ "/home/dev/octocat/hello-world/issue-1" != "/" ]; then chown -R 1000:1000 "/home/dev/octocat/hello-world/issue-1"; fi - -COPY entrypoint.sh /entrypoint.sh -RUN chmod +x /entrypoint.sh - -EXPOSE 22 -ENTRYPOINT ["/entrypoint.sh"] \ No newline at end of file diff --git a/.e2e/projects/octocat/hello-world/issue-1/docker-compose.yml b/.e2e/projects/octocat/hello-world/issue-1/docker-compose.yml deleted file mode 100644 index 81f665dc..00000000 --- a/.e2e/projects/octocat/hello-world/issue-1/docker-compose.yml +++ /dev/null @@ -1,32 +0,0 @@ -services: - dg-hello-world-issue-1: - build: . - container_name: dg-hello-world-issue-1 - environment: - REPO_URL: "https://github.com/octocat/Hello-World.git" - REPO_REF: "issue-1" - FORK_REPO_URL: "" - TARGET_DIR: "/home/dev/octocat/hello-world/issue-1" - CODEX_HOME: "/home/dev/.codex" - env_file: - - ./.orch/env/global.env - - ../../../../project.env - ports: - - "127.0.0.1:34623:22" - volumes: - - dg-hello-world-issue-1-home:/home/dev - - ../../..:/home/dev/.docker-git - - ../../../authorized_keys:/authorized_keys:ro - - ./.orch/auth/codex:/home/dev/.codex - - ../../../.orch/auth/codex:/home/dev/.codex-shared - - /var/run/docker.sock:/var/run/docker.sock - networks: - - dg-hello-world-issue-1-net - - -networks: - dg-hello-world-issue-1-net: - driver: bridge - -volumes: - dg-hello-world-issue-1-home: diff --git a/.e2e/projects/octocat/hello-world/issue-1/docker-git.json b/.e2e/projects/octocat/hello-world/issue-1/docker-git.json deleted file mode 100644 index 56f844e1..00000000 --- a/.e2e/projects/octocat/hello-world/issue-1/docker-git.json +++ /dev/null @@ -1,22 +0,0 @@ -{ - "schemaVersion": 1, - "template": { - "containerName": "dg-hello-world-issue-1", - "serviceName": "dg-hello-world-issue-1", - "sshUser": "dev", - "sshPort": 34623, - "repoUrl": "https://github.com/octocat/Hello-World.git", - "repoRef": "issue-1", - "targetDir": "/home/dev/octocat/hello-world/issue-1", - "volumeName": "dg-hello-world-issue-1-home", - "dockerGitPath": "../../..", - "authorizedKeysPath": "../../../authorized_keys", - "envGlobalPath": "./.orch/env/global.env", - "envProjectPath": "../../../../project.env", - "codexAuthPath": "./.orch/auth/codex", - "codexSharedAuthPath": "../../../.orch/auth/codex", - "codexHome": "/home/dev/.codex", - "enableMcpPlaywright": false, - "pnpmVersion": "10.27.0" - } -} diff --git a/.e2e/projects/octocat/hello-world/issue-1/entrypoint.sh b/.e2e/projects/octocat/hello-world/issue-1/entrypoint.sh deleted file mode 100755 index f9799cc2..00000000 --- a/.e2e/projects/octocat/hello-world/issue-1/entrypoint.sh +++ /dev/null @@ -1,836 +0,0 @@ -#!/usr/bin/env bash -set -euo pipefail - -REPO_URL="${REPO_URL:-}" -REPO_REF="${REPO_REF:-}" -FORK_REPO_URL="${FORK_REPO_URL:-}" -TARGET_DIR="${TARGET_DIR:-/home/dev/octocat/hello-world/issue-1}" -GIT_AUTH_USER="${GIT_AUTH_USER:-${GITHUB_USER:-x-access-token}}" -GIT_AUTH_TOKEN="${GIT_AUTH_TOKEN:-${GITHUB_TOKEN:-${GH_TOKEN:-}}}" -GH_TOKEN="${GH_TOKEN:-${GIT_AUTH_TOKEN:-}}" -GITHUB_TOKEN="${GITHUB_TOKEN:-${GH_TOKEN:-}}" -GIT_USER_NAME="${GIT_USER_NAME:-}" -GIT_USER_EMAIL="${GIT_USER_EMAIL:-}" -CODEX_AUTO_UPDATE="${CODEX_AUTO_UPDATE:-1}" -MCP_PLAYWRIGHT_ENABLE="${MCP_PLAYWRIGHT_ENABLE:-0}" -MCP_PLAYWRIGHT_CDP_ENDPOINT="${MCP_PLAYWRIGHT_CDP_ENDPOINT:-}" -MCP_PLAYWRIGHT_ISOLATED="${MCP_PLAYWRIGHT_ISOLATED:-1}" - -# 1) Authorized keys are mounted from host at /authorized_keys -mkdir -p /home/dev/.ssh -chmod 700 /home/dev/.ssh - -if [[ -f /authorized_keys ]]; then - cp /authorized_keys /home/dev/.ssh/authorized_keys - chmod 600 /home/dev/.ssh/authorized_keys -fi - -chown -R 1000:1000 /home/dev/.ssh - -# Ensure Codex home exists if mounted -mkdir -p /home/dev/.codex -chown -R 1000:1000 /home/dev/.codex - -# Ensure home ownership matches the dev UID/GID (volumes may be stale) -HOME_OWNER="$(stat -c "%u:%g" /home/dev 2>/dev/null || echo "")" -if [[ "$HOME_OWNER" != "1000:1000" ]]; then - chown -R 1000:1000 /home/dev || true -fi - -# Share Codex auth.json across projects (avoids refresh_token_reused) -CODEX_SHARE_AUTH="${CODEX_SHARE_AUTH:-1}" -if [[ "$CODEX_SHARE_AUTH" == "1" ]]; then - CODEX_SHARED_HOME="/home/dev/.codex-shared" - mkdir -p "$CODEX_SHARED_HOME" - chown -R 1000:1000 "$CODEX_SHARED_HOME" || true - - AUTH_FILE="/home/dev/.codex/auth.json" - SHARED_AUTH_FILE="$CODEX_SHARED_HOME/auth.json" - - # Guard against a bad bind mount creating a directory at auth.json. - if [[ -d "$AUTH_FILE" ]]; then - mv "$AUTH_FILE" "$AUTH_FILE.bak-$(date +%s)" || true - fi - if [[ -e "$AUTH_FILE" && ! -L "$AUTH_FILE" ]]; then - rm -f "$AUTH_FILE" || true - fi - - ln -sf "$SHARED_AUTH_FILE" "$AUTH_FILE" -fi - -# Bootstrap ~/.docker-git for nested docker-git usage inside this container. -DOCKER_GIT_HOME="/home/dev/.docker-git" -DOCKER_GIT_AUTH_DIR="$DOCKER_GIT_HOME/.orch/auth/codex" -DOCKER_GIT_ENV_DIR="$DOCKER_GIT_HOME/.orch/env" -DOCKER_GIT_ENV_GLOBAL="$DOCKER_GIT_ENV_DIR/global.env" -DOCKER_GIT_ENV_PROJECT="$DOCKER_GIT_ENV_DIR/project.env" -DOCKER_GIT_AUTH_KEYS="$DOCKER_GIT_HOME/authorized_keys" - -mkdir -p "$DOCKER_GIT_AUTH_DIR" "$DOCKER_GIT_ENV_DIR" "$DOCKER_GIT_HOME/.orch/auth/gh" - -if [[ -f "/home/dev/.ssh/authorized_keys" ]]; then - cp "/home/dev/.ssh/authorized_keys" "$DOCKER_GIT_AUTH_KEYS" -elif [[ -f /authorized_keys ]]; then - cp /authorized_keys "$DOCKER_GIT_AUTH_KEYS" -fi -if [[ -f "$DOCKER_GIT_AUTH_KEYS" ]]; then - chmod 600 "$DOCKER_GIT_AUTH_KEYS" || true -fi - -if [[ ! -f "$DOCKER_GIT_ENV_GLOBAL" ]]; then - cat <<'EOF' > "$DOCKER_GIT_ENV_GLOBAL" -# docker-git env -# KEY=value -EOF -fi -if [[ ! -f "$DOCKER_GIT_ENV_PROJECT" ]]; then - cat <<'EOF' > "$DOCKER_GIT_ENV_PROJECT" -# docker-git project env defaults -CODEX_SHARE_AUTH=1 -CODEX_AUTO_UPDATE=1 -DOCKER_GIT_ZSH_AUTOSUGGEST=1 -DOCKER_GIT_ZSH_AUTOSUGGEST_STYLE=fg=8,italic -DOCKER_GIT_ZSH_AUTOSUGGEST_STRATEGY=history completion -MCP_PLAYWRIGHT_ISOLATED=1 -EOF -fi - -upsert_env_var() { - local file="$1" - local key="$2" - local value="$3" - local tmp - tmp="$(mktemp)" - awk -v key="$key" 'index($0, key "=") != 1 { print }' "$file" > "$tmp" - printf "%s=%s\n" "$key" "$value" >> "$tmp" - mv "$tmp" "$file" -} - -copy_if_distinct_file() { - local source="$1" - local target="$2" - if [[ ! -f "$source" ]]; then - return 1 - fi - local source_real="" - local target_real="" - source_real="$(readlink -f "$source" 2>/dev/null || true)" - target_real="$(readlink -f "$target" 2>/dev/null || true)" - if [[ -n "$source_real" && -n "$target_real" && "$source_real" == "$target_real" ]]; then - return 0 - fi - cp "$source" "$target" - return 0 -} - -if [[ -n "$GH_TOKEN" ]]; then - upsert_env_var "$DOCKER_GIT_ENV_GLOBAL" "GH_TOKEN" "$GH_TOKEN" -fi -if [[ -n "$GITHUB_TOKEN" ]]; then - upsert_env_var "$DOCKER_GIT_ENV_GLOBAL" "GITHUB_TOKEN" "$GITHUB_TOKEN" -elif [[ -n "$GH_TOKEN" ]]; then - upsert_env_var "$DOCKER_GIT_ENV_GLOBAL" "GITHUB_TOKEN" "$GH_TOKEN" -fi - -SOURCE_CODEX_CONFIG="/home/dev/.codex/config.toml" -copy_if_distinct_file "$SOURCE_CODEX_CONFIG" "$DOCKER_GIT_AUTH_DIR/config.toml" || true - -SOURCE_SHARED_AUTH="/home/dev/.codex-shared/auth.json" -SOURCE_LOCAL_AUTH="/home/dev/.codex/auth.json" -if [[ -f "$SOURCE_SHARED_AUTH" ]]; then - copy_if_distinct_file "$SOURCE_SHARED_AUTH" "$DOCKER_GIT_AUTH_DIR/auth.json" || true -elif [[ -f "$SOURCE_LOCAL_AUTH" ]]; then - copy_if_distinct_file "$SOURCE_LOCAL_AUTH" "$DOCKER_GIT_AUTH_DIR/auth.json" || true -fi -if [[ -f "$DOCKER_GIT_AUTH_DIR/auth.json" ]]; then - chmod 600 "$DOCKER_GIT_AUTH_DIR/auth.json" || true -fi - -chown -R 1000:1000 "$DOCKER_GIT_HOME" || true - -# Optional: configure Playwright MCP for Codex (browser automation) -CODEX_CONFIG_FILE="/home/dev/.codex/config.toml" - -# Keep config.toml consistent with the container build. -# If Playwright MCP is disabled for this container, remove the block so Codex -# doesn't try (and fail) to spawn docker-git-playwright-mcp. -if [[ "$MCP_PLAYWRIGHT_ENABLE" != "1" ]]; then - if [[ -f "$CODEX_CONFIG_FILE" ]] && grep -q "^\[mcp_servers\.playwright" "$CODEX_CONFIG_FILE" 2>/dev/null; then - awk ' - BEGIN { skip=0 } - /^# docker-git: Playwright MCP/ { next } - /^\[mcp_servers[.]playwright([.]|\])/ { skip=1; next } - skip==1 && /^\[/ { skip=0 } - skip==0 { print } - ' "$CODEX_CONFIG_FILE" > "$CODEX_CONFIG_FILE.tmp" - mv "$CODEX_CONFIG_FILE.tmp" "$CODEX_CONFIG_FILE" - fi -else - if [[ ! -f "$CODEX_CONFIG_FILE" ]]; then - mkdir -p "$(dirname "$CODEX_CONFIG_FILE")" || true - cat <<'EOF' > "$CODEX_CONFIG_FILE" -# docker-git codex config -model = "gpt-5.3-codex" -model_reasoning_effort = "xhigh" -personality = "pragmatic" - -approval_policy = "never" -sandbox_mode = "danger-full-access" -web_search = "live" - -[features] -shell_snapshot = true -collab = true -apps = true -shell_tool = true -EOF - chown 1000:1000 "$CODEX_CONFIG_FILE" || true - fi - - if [[ -z "$MCP_PLAYWRIGHT_CDP_ENDPOINT" ]]; then - MCP_PLAYWRIGHT_CDP_ENDPOINT="http://dg-hello-world-issue-1-browser:9223" - fi - - # Replace the docker-git Playwright block to allow upgrades via --force without manual edits. - if grep -q "^\[mcp_servers\.playwright" "$CODEX_CONFIG_FILE" 2>/dev/null; then - awk ' - BEGIN { skip=0 } - /^# docker-git: Playwright MCP/ { next } - /^\[mcp_servers[.]playwright([.]|\])/ { skip=1; next } - skip==1 && /^\[/ { skip=0 } - skip==0 { print } - ' "$CODEX_CONFIG_FILE" > "$CODEX_CONFIG_FILE.tmp" - mv "$CODEX_CONFIG_FILE.tmp" "$CODEX_CONFIG_FILE" - fi - - cat <> "$CODEX_CONFIG_FILE" - -# docker-git: Playwright MCP (connects to Chromium via CDP) -[mcp_servers.playwright] -command = "docker-git-playwright-mcp" -args = [] -EOF -fi - -# Prefer zsh for dev when available -if command -v zsh >/dev/null 2>&1; then - usermod -s /usr/bin/zsh dev || true -fi - -# Ensure dev has a zshrc and disable newuser wizard -ZSHENV_PATH="/etc/zsh/zshenv" -if [[ -f "$ZSHENV_PATH" ]]; then - if ! grep -q "ZSH_DISABLE_NEWUSER_INSTALL" "$ZSHENV_PATH"; then - printf "%s\n" "export ZSH_DISABLE_NEWUSER_INSTALL=1" >> "$ZSHENV_PATH" - fi -else - printf "%s\n" "export ZSH_DISABLE_NEWUSER_INSTALL=1" > "$ZSHENV_PATH" -fi -USER_ZSHRC="/home/dev/.zshrc" -if [[ ! -f "$USER_ZSHRC" ]]; then - cat <<'EOF' > "$USER_ZSHRC" -# docker-git default zshrc -if [ -f /etc/zsh/zshrc ]; then - source /etc/zsh/zshrc -fi -EOF - chown 1000:1000 "$USER_ZSHRC" || true -fi - -# Ensure docker-git prompt is configured for interactive shells -PROMPT_PATH="/etc/profile.d/zz-prompt.sh" -if [[ ! -s "$PROMPT_PATH" ]]; then - cat <<'EOF' > "$PROMPT_PATH" -docker_git_branch() { git rev-parse --abbrev-ref HEAD 2>/dev/null; } -docker_git_prompt_apply() { - local b - b="$(docker_git_branch)" - local base="[\t] \w" - if [ -n "$b" ]; then - PS1="${base} (${b})> " - else - PS1="${base}> " - fi -} -if [ -n "$PROMPT_COMMAND" ]; then - PROMPT_COMMAND="docker_git_prompt_apply;$PROMPT_COMMAND" -else - PROMPT_COMMAND="docker_git_prompt_apply" -fi -EOF - chmod 0644 "$PROMPT_PATH" -fi -if ! grep -q "zz-prompt.sh" /etc/bash.bashrc 2>/dev/null; then - printf "%s\n" "if [ -f /etc/profile.d/zz-prompt.sh ]; then . /etc/profile.d/zz-prompt.sh; fi" >> /etc/bash.bashrc -fi - -# Ensure bash completion is configured for interactive shells -COMPLETION_PATH="/etc/profile.d/zz-bash-completion.sh" -if [[ ! -s "$COMPLETION_PATH" ]]; then - cat <<'EOF' > "$COMPLETION_PATH" -if ! shopt -oq posix; then - if [ -f /usr/share/bash-completion/bash_completion ]; then - . /usr/share/bash-completion/bash_completion - elif [ -f /etc/bash_completion ]; then - . /etc/bash_completion - fi -fi -EOF - chmod 0644 "$COMPLETION_PATH" -fi -if ! grep -q "zz-bash-completion.sh" /etc/bash.bashrc 2>/dev/null; then - printf "%s\n" "if [ -f /etc/profile.d/zz-bash-completion.sh ]; then . /etc/profile.d/zz-bash-completion.sh; fi" >> /etc/bash.bashrc -fi - -# Ensure bash history is configured for interactive shells -HISTORY_PATH="/etc/profile.d/zz-bash-history.sh" -if [[ ! -s "$HISTORY_PATH" ]]; then - cat <<'EOF' > "$HISTORY_PATH" -if [ -n "$BASH_VERSION" ]; then - case "$-" in - *i*) - HISTFILE="${HISTFILE:-$HOME/.bash_history}" - HISTSIZE="${HISTSIZE:-10000}" - HISTFILESIZE="${HISTFILESIZE:-20000}" - HISTCONTROL="${HISTCONTROL:-ignoredups:erasedups}" - export HISTFILE HISTSIZE HISTFILESIZE HISTCONTROL - shopt -s histappend - if [ -n "${PROMPT_COMMAND-}" ]; then - PROMPT_COMMAND="history -a; ${PROMPT_COMMAND}" - else - PROMPT_COMMAND="history -a" - fi - ;; - esac -fi -EOF - chmod 0644 "$HISTORY_PATH" -fi -if ! grep -q "zz-bash-history.sh" /etc/bash.bashrc 2>/dev/null; then - printf "%s\n" "if [ -f /etc/profile.d/zz-bash-history.sh ]; then . /etc/profile.d/zz-bash-history.sh; fi" >> /etc/bash.bashrc -fi - -# Ensure readline history search bindings for dev -INPUTRC_PATH="/home/dev/.inputrc" -if [[ ! -f "$INPUTRC_PATH" ]]; then - cat <<'EOF' > "$INPUTRC_PATH" -set show-all-if-ambiguous on -set completion-ignore-case on -"\e[A": history-search-backward -"\e[B": history-search-forward -EOF - chown 1000:1000 "$INPUTRC_PATH" || true -fi - -# Ensure zsh config exists for autosuggestions -ZSHRC_PATH="/etc/zsh/zshrc" -if [[ ! -s "$ZSHRC_PATH" ]]; then - mkdir -p /etc/zsh - cat <<'EOF' > "$ZSHRC_PATH" -setopt PROMPT_SUBST - -# Terminal compatibility: if terminfo for $TERM is missing (common over SSH), -# fall back to xterm-256color so ZLE doesn't garble the display. -if command -v infocmp >/dev/null 2>&1; then - if ! infocmp "$TERM" >/dev/null 2>&1; then - export TERM=xterm-256color - fi -fi - -autoload -Uz compinit -compinit - -# Completion UX: cycle matches instead of listing them into scrollback. -setopt AUTO_MENU -setopt MENU_COMPLETE -unsetopt AUTO_LIST -unsetopt LIST_BEEP - -# Command completion ordering: prefer real commands/builtins over internal helper functions. -zstyle ':completion:*' tag-order builtins commands aliases reserved-words functions - -autoload -Uz add-zsh-hook -docker_git_branch() { git rev-parse --abbrev-ref HEAD 2>/dev/null; } -docker_git_prompt_apply() { - local b - b="$(docker_git_branch)" - local base="[%*] %~" - if [[ -n "$b" ]]; then - PROMPT="$base ($b)> " - else - PROMPT="$base> " - fi -} -add-zsh-hook precmd docker_git_prompt_apply - -HISTFILE="${HISTFILE:-$HOME/.zsh_history}" -HISTSIZE="${HISTSIZE:-10000}" -SAVEHIST="${SAVEHIST:-20000}" -setopt HIST_IGNORE_ALL_DUPS -setopt SHARE_HISTORY -setopt INC_APPEND_HISTORY - -if [ -f "$HISTFILE" ]; then - fc -R "$HISTFILE" 2>/dev/null || true -fi -if [ -f "$HOME/.bash_history" ] && [ "$HISTFILE" != "$HOME/.bash_history" ]; then - fc -R "$HOME/.bash_history" 2>/dev/null || true -fi - -bindkey '^[[A' history-search-backward -bindkey '^[[B' history-search-forward - -if [[ "${DOCKER_GIT_ZSH_AUTOSUGGEST:-1}" == "1" ]] && [ -f /usr/share/zsh-autosuggestions/zsh-autosuggestions.zsh ]; then - # Suggest from history first, then fall back to completion (commands + paths). - # This gives "ghost text" suggestions without needing to press . - ZSH_AUTOSUGGEST_HIGHLIGHT_STYLE="${DOCKER_GIT_ZSH_AUTOSUGGEST_STYLE:-fg=8,italic}" - if [[ -n "${DOCKER_GIT_ZSH_AUTOSUGGEST_STRATEGY-}" ]]; then - ZSH_AUTOSUGGEST_STRATEGY=(${=DOCKER_GIT_ZSH_AUTOSUGGEST_STRATEGY}) - else - ZSH_AUTOSUGGEST_STRATEGY=(history completion) - fi - source /usr/share/zsh-autosuggestions/zsh-autosuggestions.zsh -fi -EOF -fi - -# Ensure codex resume hint is shown for interactive shells -CODEX_HINT_PATH="/etc/profile.d/zz-codex-resume.sh" -if [[ ! -s "$CODEX_HINT_PATH" ]]; then - cat <<'EOF' > "$CODEX_HINT_PATH" -if [ -n "$BASH_VERSION" ]; then - case "$-" in - *i*) - if [ -z "${CODEX_RESUME_HINT_SHOWN-}" ]; then - echo "Старые сессии можно запустить с помощью codex resume или codex resume , если знаешь айди." - export CODEX_RESUME_HINT_SHOWN=1 - fi - ;; - esac -fi -if [ -n "$ZSH_VERSION" ]; then - if [[ "$-" == *i* ]]; then - if [[ -z "${CODEX_RESUME_HINT_SHOWN-}" ]]; then - echo "Старые сессии можно запустить с помощью codex resume или codex resume , если знаешь айди." - export CODEX_RESUME_HINT_SHOWN=1 - fi - fi -fi -EOF - chmod 0644 "$CODEX_HINT_PATH" -fi -if ! grep -q "zz-codex-resume.sh" /etc/bash.bashrc 2>/dev/null; then - printf "%s\n" "if [ -f /etc/profile.d/zz-codex-resume.sh ]; then . /etc/profile.d/zz-codex-resume.sh; fi" >> /etc/bash.bashrc -fi -if [[ -s /etc/zsh/zshrc ]] && ! grep -q "zz-codex-resume.sh" /etc/zsh/zshrc 2>/dev/null; then - printf "%s\n" "if [ -f /etc/profile.d/zz-codex-resume.sh ]; then source /etc/profile.d/zz-codex-resume.sh; fi" >> /etc/zsh/zshrc -fi - -# Ensure global AGENTS.md exists for container context -AGENTS_PATH="/home/dev/.codex/AGENTS.md" -LEGACY_AGENTS_PATH="/home/dev/AGENTS.md" -PROJECT_LINE="Рабочая папка проекта (git clone): /home/dev/octocat/hello-world/issue-1" -WORKSPACES_LINE="Доступные workspace пути: /home/dev/octocat/hello-world/issue-1" -WORKSPACE_INFO_LINE="Контекст workspace: repository" -FOCUS_LINE="Фокус задачи: работай только в workspace, который запрашивает пользователь. Текущий workspace: /home/dev/octocat/hello-world/issue-1" -ISSUE_AGENTS_HINT_LINE="Issue AGENTS.md: n/a" -INTERNET_LINE="Доступ к интернету: есть. Если чего-то не знаешь — ищи в интернете или по кодовой базе." -if [[ "$REPO_REF" == issue-* ]]; then - ISSUE_ID="$(printf "%s" "$REPO_REF" | sed -E 's#^issue-##')" - ISSUE_URL="" - if [[ "$REPO_URL" == https://github.com/* ]]; then - ISSUE_REPO="$(printf "%s" "$REPO_URL" | sed -E 's#^https://github.com/##; s#[.]git$##; s#/*$##')" - if [[ -n "$ISSUE_REPO" ]]; then - ISSUE_URL="https://github.com/$ISSUE_REPO/issues/$ISSUE_ID" - fi - fi - if [[ -n "$ISSUE_URL" ]]; then - WORKSPACE_INFO_LINE="Контекст workspace: issue #$ISSUE_ID ($ISSUE_URL)" - else - WORKSPACE_INFO_LINE="Контекст workspace: issue #$ISSUE_ID" - fi - ISSUE_AGENTS_HINT_LINE="Issue AGENTS.md: /home/dev/octocat/hello-world/issue-1/AGENTS.md" -elif [[ "$REPO_REF" == refs/pull/*/head ]]; then - PR_ID="$(printf "%s" "$REPO_REF" | sed -E 's#^refs/pull/([0-9]+)/head$#\1#')" - if [[ -n "$PR_ID" ]]; then - WORKSPACE_INFO_LINE="Контекст workspace: PR #$PR_ID" - else - WORKSPACE_INFO_LINE="Контекст workspace: pull request ($REPO_REF)" - fi -fi -if [[ ! -f "$AGENTS_PATH" ]]; then - MANAGED_START="" - MANAGED_END="" - MANAGED_BLOCK="$(cat < "$AGENTS_PATH" -Ты автономный агент, который имеет полностью все права управления контейнером. У тебя есть доступ к командам sudo, gh, codex, git, node, pnpm и всем остальным другим. Проекты с которыми идёт работа лежат по пути ~ -$MANAGED_BLOCK -Если ты видишь файлы AGENTS.md внутри проекта, ты обязан их читать и соблюдать инструкции. -EOF - chown 1000:1000 "$AGENTS_PATH" || true -fi -if [[ -f "$AGENTS_PATH" ]]; then - MANAGED_START="" - MANAGED_END="" - MANAGED_BLOCK="$(cat < "$TMP_AGENTS_PATH" - else - sed \ - -e '/^Рабочая папка проекта (git clone):/d' \ - -e '/^Доступные workspace пути:/d' \ - -e '/^Контекст workspace:/d' \ - -e '/^Фокус задачи:/d' \ - -e '/^Issue AGENTS.md:/d' \ - -e '/^Доступ к интернету:/d' \ - "$AGENTS_PATH" > "$TMP_AGENTS_PATH" - if [[ -s "$TMP_AGENTS_PATH" ]]; then - printf "\n" >> "$TMP_AGENTS_PATH" - fi - printf "%s\n" "$MANAGED_BLOCK" >> "$TMP_AGENTS_PATH" - fi - mv "$TMP_AGENTS_PATH" "$AGENTS_PATH" - chown 1000:1000 "$AGENTS_PATH" || true -fi -if [[ -f "$LEGACY_AGENTS_PATH" && -f "$AGENTS_PATH" ]]; then - LEGACY_SUM="$(cksum "$LEGACY_AGENTS_PATH" 2>/dev/null | awk '{print $1 \":\" $2}')" - CODEX_SUM="$(cksum "$AGENTS_PATH" 2>/dev/null | awk '{print $1 \":\" $2}')" - if [[ -n "$LEGACY_SUM" && "$LEGACY_SUM" == "$CODEX_SUM" ]]; then - rm -f "$LEGACY_AGENTS_PATH" - fi -fi - -# Ensure docker socket access for dev -if [[ -S /var/run/docker.sock ]]; then - DOCKER_SOCK_GID="$(stat -c "%g" /var/run/docker.sock)" - DOCKER_GROUP="$(getent group "$DOCKER_SOCK_GID" | cut -d: -f1 || true)" - if [[ -z "$DOCKER_GROUP" ]]; then - DOCKER_GROUP="docker" - groupadd -g "$DOCKER_SOCK_GID" "$DOCKER_GROUP" || true - fi - usermod -aG "$DOCKER_GROUP" dev || true - printf "export DOCKER_HOST=unix:///var/run/docker.sock -" > /etc/profile.d/docker-host.sh -fi - -# 2) Ensure GitHub auth vars are available for SSH sessions if provided -if [[ -n "$GH_TOKEN" || -n "$GITHUB_TOKEN" ]]; then - EFFECTIVE_GITHUB_TOKEN="$GITHUB_TOKEN" - if [[ -z "$EFFECTIVE_GITHUB_TOKEN" ]]; then - EFFECTIVE_GITHUB_TOKEN="$GH_TOKEN" - fi - - EFFECTIVE_GH_TOKEN="$GH_TOKEN" - if [[ -z "$EFFECTIVE_GH_TOKEN" ]]; then - EFFECTIVE_GH_TOKEN="$EFFECTIVE_GITHUB_TOKEN" - fi - - printf "export GH_TOKEN=%q\n" "$EFFECTIVE_GH_TOKEN" > /etc/profile.d/gh-token.sh - printf "export GITHUB_TOKEN=%q\n" "$EFFECTIVE_GITHUB_TOKEN" >> /etc/profile.d/gh-token.sh - chmod 0644 /etc/profile.d/gh-token.sh - SSH_ENV_PATH="/home/dev/.ssh/environment" - printf "%s\n" "GH_TOKEN=$EFFECTIVE_GH_TOKEN" > "$SSH_ENV_PATH" - printf "%s\n" "GITHUB_TOKEN=$EFFECTIVE_GITHUB_TOKEN" >> "$SSH_ENV_PATH" - chmod 600 "$SSH_ENV_PATH" - chown 1000:1000 "$SSH_ENV_PATH" || true - - SAFE_GH_TOKEN="$(printf "%q" "$GH_TOKEN")" - # Keep git+https auth in sync with gh auth so push/pull works without manual setup. - su - dev -c "GH_TOKEN=$SAFE_GH_TOKEN gh auth setup-git --hostname github.com --force" || true - - GH_LOGIN="$(su - dev -c "GH_TOKEN=$SAFE_GH_TOKEN gh api user --jq .login" 2>/dev/null || true)" - GH_ID="$(su - dev -c "GH_TOKEN=$SAFE_GH_TOKEN gh api user --jq .id" 2>/dev/null || true)" - GH_LOGIN="$(printf "%s" "$GH_LOGIN" | tr -d '\r\n')" - GH_ID="$(printf "%s" "$GH_ID" | tr -d '\r\n')" - - if [[ -z "$GIT_USER_NAME" && -n "$GH_LOGIN" ]]; then - GIT_USER_NAME="$GH_LOGIN" - fi - if [[ -z "$GIT_USER_EMAIL" && -n "$GH_LOGIN" && -n "$GH_ID" ]]; then - GIT_USER_EMAIL="${GH_ID}+${GH_LOGIN}@users.noreply.github.com" - fi -fi - -# 3) Configure git credential helper for HTTPS remotes -GIT_CREDENTIAL_HELPER_PATH="/usr/local/bin/docker-git-credential-helper" -cat <<'EOF' > "$GIT_CREDENTIAL_HELPER_PATH" -#!/usr/bin/env bash -set -euo pipefail - -if [[ "$#" -lt 1 || "$1" != "get" ]]; then - exit 0 -fi - -token="$GITHUB_TOKEN" -if [[ -z "$token" ]]; then - token="$GH_TOKEN" -fi - -if [[ -z "$token" ]]; then - exit 0 -fi - -printf "%s\n" "username=x-access-token" -printf "%s\n" "password=$token" -EOF -chmod 0755 "$GIT_CREDENTIAL_HELPER_PATH" -su - dev -c "git config --global credential.helper '$GIT_CREDENTIAL_HELPER_PATH'" - -# 4) Configure git identity for the dev user if provided -if [[ -n "$GIT_USER_NAME" ]]; then - SAFE_GIT_USER_NAME="$(printf "%q" "$GIT_USER_NAME")" - su - dev -c "git config --global user.name $SAFE_GIT_USER_NAME" -fi - -if [[ -n "$GIT_USER_EMAIL" ]]; then - SAFE_GIT_USER_EMAIL="$(printf "%q" "$GIT_USER_EMAIL")" - su - dev -c "git config --global user.email $SAFE_GIT_USER_EMAIL" -fi - -# 3) Install global git hooks to protect main/master -HOOKS_DIR="/opt/docker-git/hooks" -PRE_PUSH_HOOK="$HOOKS_DIR/pre-push" -mkdir -p "$HOOKS_DIR" -if [[ ! -f "$PRE_PUSH_HOOK" ]]; then - cat <<'EOF' > "$PRE_PUSH_HOOK" -#!/usr/bin/env bash -set -euo pipefail - -protected_branches=("refs/heads/main" "refs/heads/master") -allow_delete="${DOCKER_GIT_ALLOW_DELETE:-}" - -while read -r local_ref local_sha remote_ref remote_sha; do - if [[ -z "$remote_ref" ]]; then - continue - fi - for protected in "${protected_branches[@]}"; do - if [[ "$remote_ref" == "$protected" || "$local_ref" == "$protected" ]]; then - echo "docker-git: push to protected branch '${protected##*/}' is disabled." - echo "docker-git: create a new branch: git checkout -b " - exit 1 - fi - done - if [[ "$local_sha" == "0000000000000000000000000000000000000000" && "$remote_ref" == refs/heads/* ]]; then - if [[ "$allow_delete" != "1" ]]; then - echo "docker-git: deleting remote branches is disabled (set DOCKER_GIT_ALLOW_DELETE=1 to override)." - exit 1 - fi - fi -done -EOF - chmod 0755 "$PRE_PUSH_HOOK" -fi -git config --system core.hooksPath "$HOOKS_DIR" || true -git config --global core.hooksPath "$HOOKS_DIR" || true - -# 4) Start background tasks so SSH can come up immediately -( -# 1) Keep Codex CLI up to date if requested (bun only) -if [[ "$CODEX_AUTO_UPDATE" == "1" ]]; then - if command -v bun >/dev/null 2>&1; then - echo "[codex] updating via bun..." - script -q -e -c "bun add -g @openai/codex@latest" /dev/null || true - else - echo "[codex] bun not found, skipping auto-update" - fi -fi - -# 2) Auto-clone repo if not already present -mkdir -p /run/docker-git -CLONE_DONE_PATH="/run/docker-git/clone.done" -CLONE_FAIL_PATH="/run/docker-git/clone.failed" -rm -f "$CLONE_DONE_PATH" "$CLONE_FAIL_PATH" - -CLONE_OK=1 - -if [[ -z "$REPO_URL" ]]; then - echo "[clone] skip (no repo url)" -elif [[ -d "$TARGET_DIR/.git" ]]; then - echo "[clone] skip (already cloned)" -else - mkdir -p "$TARGET_DIR" - if [[ "$TARGET_DIR" != "/" ]]; then - chown -R 1000:1000 "$TARGET_DIR" - fi - chown -R 1000:1000 /home/dev - - AUTH_REPO_URL="$REPO_URL" - if [[ -n "$GIT_AUTH_TOKEN" && "$REPO_URL" == https://* ]]; then - AUTH_REPO_URL="$(printf "%s" "$REPO_URL" | sed "s#^https://#https://${GIT_AUTH_USER}:${GIT_AUTH_TOKEN}@#")" - fi - if [[ -n "$REPO_REF" ]]; then - if [[ "$REPO_REF" == refs/pull/* ]]; then - REF_BRANCH="pr-$(printf "%s" "$REPO_REF" | tr '/:' '--')" - if ! su - dev -c "GIT_TERMINAL_PROMPT=0 git clone --progress '$AUTH_REPO_URL' '$TARGET_DIR'"; then - echo "[clone] git clone failed for $REPO_URL" - CLONE_OK=0 - else - if ! su - dev -c "cd '$TARGET_DIR' && GIT_TERMINAL_PROMPT=0 git fetch --progress origin '$REPO_REF':'$REF_BRANCH' && git checkout '$REF_BRANCH'"; then - echo "[clone] git fetch failed for $REPO_REF" - CLONE_OK=0 - fi - fi - else - if ! su - dev -c "GIT_TERMINAL_PROMPT=0 git clone --progress --branch '$REPO_REF' '$AUTH_REPO_URL' '$TARGET_DIR'"; then - DEFAULT_REF="$(git ls-remote --symref "$AUTH_REPO_URL" HEAD 2>/dev/null | awk '/^ref:/ {print $2}' | head -n 1 || true)" - DEFAULT_BRANCH="$(printf "%s" "$DEFAULT_REF" | sed 's#^refs/heads/##')" - if [[ -n "$DEFAULT_BRANCH" ]]; then - echo "[clone] branch '$REPO_REF' missing; retrying with '$DEFAULT_BRANCH'" - if ! su - dev -c "GIT_TERMINAL_PROMPT=0 git clone --progress --branch '$DEFAULT_BRANCH' '$AUTH_REPO_URL' '$TARGET_DIR'"; then - echo "[clone] git clone failed for $REPO_URL" - CLONE_OK=0 - elif [[ "$REPO_REF" == issue-* ]]; then - if ! su - dev -c "cd '$TARGET_DIR' && git checkout -B '$REPO_REF'"; then - echo "[clone] failed to create local branch '$REPO_REF'" - CLONE_OK=0 - fi - fi - else - echo "[clone] git clone failed for $REPO_URL" - CLONE_OK=0 - fi - fi - fi - else - if ! su - dev -c "GIT_TERMINAL_PROMPT=0 git clone --progress '$AUTH_REPO_URL' '$TARGET_DIR'"; then - echo "[clone] git clone failed for $REPO_URL" - CLONE_OK=0 - fi - fi -fi - -if [[ "$CLONE_OK" -eq 1 && -d "$TARGET_DIR/.git" ]]; then - if [[ -n "$FORK_REPO_URL" && "$FORK_REPO_URL" != "$REPO_URL" ]]; then - su - dev -c "cd '$TARGET_DIR' && git remote set-url origin '$FORK_REPO_URL'" || true - su - dev -c "cd '$TARGET_DIR' && git remote add upstream '$REPO_URL' 2>/dev/null || git remote set-url upstream '$REPO_URL'" || true - else - su - dev -c "cd '$TARGET_DIR' && git remote set-url origin '$REPO_URL'" || true - su - dev -c "cd '$TARGET_DIR' && git remote remove upstream >/dev/null 2>&1 || true" || true - fi -fi - -if [[ "$CLONE_OK" -eq 1 && "$REPO_REF" == issue-* && -d "$TARGET_DIR/.git" ]]; then -ISSUE_ID="$(printf "%s" "$REPO_REF" | sed -E 's#^issue-##')" -ISSUE_URL="" -if [[ "$REPO_URL" == https://github.com/* ]]; then - ISSUE_REPO="$(printf "%s" "$REPO_URL" | sed -E 's#^https://github.com/##; s#[.]git$##; s#/*$##')" - if [[ -n "$ISSUE_REPO" ]]; then - ISSUE_URL="https://github.com/$ISSUE_REPO/issues/$ISSUE_ID" - fi -fi -if [[ -z "$ISSUE_URL" ]]; then - ISSUE_URL="n/a" -fi - -ISSUE_AGENTS_PATH="$TARGET_DIR/AGENTS.md" -ISSUE_MANAGED_START="" -ISSUE_MANAGED_END="" -ISSUE_MANAGED_BLOCK="$(cat < "$ISSUE_AGENTS_PATH" -else - TMP_ISSUE_AGENTS_PATH="$(mktemp)" - if grep -qF "$ISSUE_MANAGED_START" "$ISSUE_AGENTS_PATH" && grep -qF "$ISSUE_MANAGED_END" "$ISSUE_AGENTS_PATH"; then - awk -v start="$ISSUE_MANAGED_START" -v end="$ISSUE_MANAGED_END" -v repl="$ISSUE_MANAGED_BLOCK" ' - BEGIN { in_block = 0 } - $0 == start { print repl; in_block = 1; next } - $0 == end { in_block = 0; next } - in_block == 0 { print } - ' "$ISSUE_AGENTS_PATH" > "$TMP_ISSUE_AGENTS_PATH" - else - sed -e '/^# docker-git issue workspace$/d' -e '/^Issue workspace: #/d' -e '/^Issue URL: /d' -e '/^Workspace path: /d' -e '/^Работай только над этим issue, если пользователь не попросил другое[.]$/d' -e '/^Если нужен первоисточник требований, открой Issue URL[.]$/d' "$ISSUE_AGENTS_PATH" > "$TMP_ISSUE_AGENTS_PATH" - if [[ -s "$TMP_ISSUE_AGENTS_PATH" ]]; then - printf " -" >> "$TMP_ISSUE_AGENTS_PATH" - fi - printf "%s -" "$ISSUE_MANAGED_BLOCK" >> "$TMP_ISSUE_AGENTS_PATH" - fi - mv "$TMP_ISSUE_AGENTS_PATH" "$ISSUE_AGENTS_PATH" -fi -if [[ -e "$ISSUE_AGENTS_PATH" ]]; then - chown 1000:1000 "$ISSUE_AGENTS_PATH" || true -fi - -EXCLUDE_PATH="$TARGET_DIR/.git/info/exclude" -if [[ -f "$ISSUE_AGENTS_PATH" ]]; then - touch "$EXCLUDE_PATH" - if ! grep -qx "AGENTS.md" "$EXCLUDE_PATH"; then - printf "%s -" "AGENTS.md" >> "$EXCLUDE_PATH" - fi -fi -fi - -if [[ "$CLONE_OK" -eq 1 ]]; then - echo "[clone] done" - touch "$CLONE_DONE_PATH" -else - echo "[clone] failed" - touch "$CLONE_FAIL_PATH" -fi -) & - -# 4.5) Snapshot baseline processes for terminal session filtering -mkdir -p /run/docker-git -BASELINE_PATH="/run/docker-git/terminal-baseline.pids" -if [[ ! -f "$BASELINE_PATH" ]]; then - ps -eo pid= > "$BASELINE_PATH" || true -fi - -# 4.75) Disable Ubuntu MOTD noise for SSH sessions -PAM_SSHD="/etc/pam.d/sshd" -if [[ -f "$PAM_SSHD" ]]; then - sed -i 's/^[[:space:]]*session[[:space:]]\+optional[[:space:]]\+pam_motd\.so/#&/' "$PAM_SSHD" || true - sed -i 's/^[[:space:]]*session[[:space:]]\+optional[[:space:]]\+pam_lastlog\.so/#&/' "$PAM_SSHD" || true -fi - -# Also disable sshd's own banners (e.g. "Last login") -mkdir -p /etc/ssh/sshd_config.d || true -DOCKER_GIT_SSHD_CONF="/etc/ssh/sshd_config.d/zz-docker-git-clean.conf" -cat <<'EOF' > "$DOCKER_GIT_SSHD_CONF" -PrintMotd no -PrintLastLog no -EOF -chmod 0644 "$DOCKER_GIT_SSHD_CONF" || true - -# 5) Run sshd in foreground -exec /usr/sbin/sshd -D \ No newline at end of file diff --git a/.gitignore b/.gitignore index 8a999b65..f5b65d74 100644 --- a/.gitignore +++ b/.gitignore @@ -8,6 +8,7 @@ dev_ssh_key.pub # Local docker-git work dirs .docker-git/ +.e2e/ effect-template1/ # Node / build artifacts diff --git a/scripts/e2e/ci.sh b/scripts/e2e/ci.sh index eb84c267..35dce05a 100644 --- a/scripts/e2e/ci.sh +++ b/scripts/e2e/ci.sh @@ -41,6 +41,7 @@ CODEX_SHARE_AUTH=0 EOF export DOCKER_GIT_PROJECTS_ROOT="$PROJECTS_ROOT" +export DOCKER_GIT_STATE_AUTO_SYNC=0 cat > "$BIN_DIR/ssh" <<'EOF' #!/usr/bin/env bash @@ -73,7 +74,7 @@ grep -q -- "-p " "$SSH_LOG" || fail "expected ssh args to include -p ; got docker ps --format '{{.Names}}' | grep -qx "$CONTAINER_NAME" || fail "expected container to be running: $CONTAINER_NAME" docker exec "$CONTAINER_NAME" bash -lc "test -d '$TARGET_DIR/.git'" || fail "expected repo to be cloned at: $TARGET_DIR" -branch="$(docker exec "$CONTAINER_NAME" bash -lc "cd '$TARGET_DIR' && git rev-parse --abbrev-ref HEAD")" +branch="$(docker exec -u dev "$CONTAINER_NAME" bash -lc "cd '$TARGET_DIR' && git rev-parse --abbrev-ref HEAD")" [[ "$branch" == "issue-1" ]] || fail "expected HEAD branch issue-1, got: $branch" [[ -f "$OUT_DIR/docker-git.json" ]] || fail "expected project config file: $OUT_DIR/docker-git.json" From 3ea0993c18d143aa5e080b9b35cb284f5a250e22 Mon Sep 17 00:00:00 2001 From: skulidropek <66840575+skulidropek@users.noreply.github.com> Date: Sun, 15 Feb 2026 17:24:57 +0000 Subject: [PATCH 5/7] test(e2e): assert auto-ssh after clone --- scripts/e2e/ci.sh | 82 ----------------------------- scripts/e2e/opencode-autoconnect.sh | 45 +++++++++++++--- 2 files changed, 37 insertions(+), 90 deletions(-) delete mode 100644 scripts/e2e/ci.sh diff --git a/scripts/e2e/ci.sh b/scripts/e2e/ci.sh deleted file mode 100644 index 35dce05a..00000000 --- a/scripts/e2e/ci.sh +++ /dev/null @@ -1,82 +0,0 @@ -#!/usr/bin/env bash -set -euo pipefail - -ROOT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/../.." && pwd)" - -E2E_ROOT="${ROOT_DIR}/.e2e" -BIN_DIR="${E2E_ROOT}/bin" -LOG_DIR="${E2E_ROOT}/logs" -PROJECTS_ROOT="${E2E_ROOT}/projects" - -REPO_URL="https://github.com/octocat/Hello-World/issues/1" -OUT_DIR="${PROJECTS_ROOT}/octocat/hello-world/issue-1" -CONTAINER_NAME="dg-hello-world-issue-1" -TARGET_DIR="/home/dev/octocat/hello-world/issue-1" - -SSH_LOG="${LOG_DIR}/ssh.log" -ENV_PROJECT="${E2E_ROOT}/project.env" -SSH_PORT="${E2E_SSH_PORT:-}" - -fail() { - echo "e2e: $*" >&2 - exit 1 -} - -cleanup() { - if [[ -d "$OUT_DIR" ]]; then - ( - cd "$OUT_DIR" && docker compose down -v >/dev/null 2>&1 || true - ) - fi -} -trap cleanup EXIT - -mkdir -p "$BIN_DIR" "$LOG_DIR" "$PROJECTS_ROOT" -rm -f "$SSH_LOG" - -cat > "$ENV_PROJECT" <<'EOF' -# Keep CI fast and deterministic (Codex auto-update hits the network on container start) -CODEX_AUTO_UPDATE=0 -CODEX_SHARE_AUTH=0 -EOF - -export DOCKER_GIT_PROJECTS_ROOT="$PROJECTS_ROOT" -export DOCKER_GIT_STATE_AUTO_SYNC=0 - -cat > "$BIN_DIR/ssh" <<'EOF' -#!/usr/bin/env bash -set -euo pipefail - -: "${SSH_LOG_PATH:?SSH_LOG_PATH is required}" -printf "ssh %s\n" "$*" >> "$SSH_LOG_PATH" -exit 0 -EOF -chmod +x "$BIN_DIR/ssh" - -export SSH_LOG_PATH="$SSH_LOG" -export PATH="$BIN_DIR:$PATH" - -cd "$ROOT_DIR" - -pnpm --filter ./packages/app build:docker-git - -command -v script >/dev/null 2>&1 || fail "missing 'script' command (util-linux)" - -if [[ -z "$SSH_PORT" ]]; then - SSH_PORT="$(node -e 'const net=require("net"); const s=net.createServer(); s.listen(0,"127.0.0.1",()=>{console.log(s.address().port); s.close();});')" -fi - -script -q -e -c "node packages/app/dist/src/docker-git/main.js clone \"$REPO_URL\" --force --ssh-port \"$SSH_PORT\" --env-project \"$ENV_PROJECT\"" /dev/null - -[[ -s "$SSH_LOG" ]] || fail "expected ssh to be invoked; log is empty: $SSH_LOG" -grep -q "dev@localhost" "$SSH_LOG" || fail "expected ssh args to include dev@localhost; got: $(cat "$SSH_LOG")" -grep -q -- "-p " "$SSH_LOG" || fail "expected ssh args to include -p ; got: $(cat "$SSH_LOG")" - -docker ps --format '{{.Names}}' | grep -qx "$CONTAINER_NAME" || fail "expected container to be running: $CONTAINER_NAME" -docker exec "$CONTAINER_NAME" bash -lc "test -d '$TARGET_DIR/.git'" || fail "expected repo to be cloned at: $TARGET_DIR" -branch="$(docker exec -u dev "$CONTAINER_NAME" bash -lc "cd '$TARGET_DIR' && git rev-parse --abbrev-ref HEAD")" -[[ "$branch" == "issue-1" ]] || fail "expected HEAD branch issue-1, got: $branch" - -[[ -f "$OUT_DIR/docker-git.json" ]] || fail "expected project config file: $OUT_DIR/docker-git.json" - -echo "e2e: OK" diff --git a/scripts/e2e/opencode-autoconnect.sh b/scripts/e2e/opencode-autoconnect.sh index d1459a0f..302ab28c 100755 --- a/scripts/e2e/opencode-autoconnect.sh +++ b/scripts/e2e/opencode-autoconnect.sh @@ -23,6 +23,17 @@ SSH_PORT="$(( (RANDOM % 1000) + 20000 ))" export DOCKER_GIT_PROJECTS_ROOT="$ROOT" export DOCKER_GIT_STATE_AUTO_SYNC=0 +REPO_URL="https://github.com/octocat/Hello-World/issues/1" +TARGET_DIR="/home/dev/octocat/hello-world/issue-1" + +SSH_LOG_PATH="$ROOT/ssh.log" +SSH_WRAPPER_BIN="$ROOT/.e2e-bin" + +fail() { + echo "e2e/opencode-autoconnect: $*" >&2 + exit 1 +} + on_error() { local line="$1" echo "e2e/opencode-autoconnect: failed at line $line" >&2 @@ -66,6 +77,20 @@ trap cleanup EXIT mkdir -p "$ROOT/.orch/auth/codex" "$ROOT/.orch/env" : > "$ROOT/authorized_keys" +# Wrap `ssh` so CI doesn't hang in interactive mode; we only assert the invocation. +mkdir -p "$SSH_WRAPPER_BIN" +cat > "$SSH_WRAPPER_BIN/ssh" <<'EOF' +#!/usr/bin/env bash +set -euo pipefail + +: "${SSH_LOG_PATH:?SSH_LOG_PATH is required}" +printf "ssh %s\n" "$*" >> "$SSH_LOG_PATH" +exit 0 +EOF +chmod +x "$SSH_WRAPPER_BIN/ssh" +export PATH="$SSH_WRAPPER_BIN:$PATH" +export SSH_LOG_PATH + # Seed a fake (but structurally valid) Codex auth.json so the entrypoint can # auto-connect OpenCode without manual /connect. node <<'NODE' > "$ROOT/.orch/auth/codex/auth.json" @@ -101,14 +126,18 @@ OPENCODE_SHARE_AUTH=1 OPENCODE_AUTO_CONNECT=1 EOF_ENV -pnpm run docker-git clone https://github.com/octocat/Hello-World \ - --force \ - --repo-ref master \ - --ssh-port "$SSH_PORT" \ - --out-dir "$OUT_DIR_REL" \ - --container-name "$CONTAINER_NAME" \ - --service-name "$SERVICE_NAME" \ - --volume-name "$VOLUME_NAME" +# Auto-open SSH happens only in an interactive TTY; wrap with `script` to allocate a pseudo-TTY. +command -v script >/dev/null 2>&1 || fail "missing 'script' command (util-linux)" +rm -f "$SSH_LOG_PATH" +script -q -e -c "pnpm run docker-git clone \"$REPO_URL\" --force --ssh-port \"$SSH_PORT\" --out-dir \"$OUT_DIR_REL\" --container-name \"$CONTAINER_NAME\" --service-name \"$SERVICE_NAME\" --volume-name \"$VOLUME_NAME\"" /dev/null + +[[ -s "$SSH_LOG_PATH" ]] || fail "expected ssh to be invoked; log is empty: $SSH_LOG_PATH" +grep -q -- "dev@localhost" "$SSH_LOG_PATH" || fail "expected ssh args to include dev@localhost" +grep -q -- "-p $SSH_PORT" "$SSH_LOG_PATH" || fail "expected ssh args to include -p $SSH_PORT" + +docker exec -u dev "$CONTAINER_NAME" bash -lc "test -d '$TARGET_DIR/.git'" || fail "expected repo to be cloned at: $TARGET_DIR" +branch="$(docker exec -u dev "$CONTAINER_NAME" bash -lc "cd '$TARGET_DIR' && git rev-parse --abbrev-ref HEAD")" +[[ "$branch" == "issue-1" ]] || fail "expected HEAD branch issue-1, got: $branch" # Basic sanity checks. docker ps --format '{{.Names}}' | grep -qx "$CONTAINER_NAME" From e0e56a8c132bb0b64da943dacea445c0ac010496 Mon Sep 17 00:00:00 2001 From: skulidropek <66840575+skulidropek@users.noreply.github.com> Date: Sun, 15 Feb 2026 18:50:34 +0000 Subject: [PATCH 6/7] fix(lint): satisfy effect-ts checks --- .../src/usecases/actions/create-project.ts | 7 +- .../usecases/create-project-open-ssh.test.ts | 65 +++++++++++++------ 2 files changed, 51 insertions(+), 21 deletions(-) diff --git a/packages/lib/src/usecases/actions/create-project.ts b/packages/lib/src/usecases/actions/create-project.ts index 19ec888d..6fd09e50 100644 --- a/packages/lib/src/usecases/actions/create-project.ts +++ b/packages/lib/src/usecases/actions/create-project.ts @@ -144,8 +144,11 @@ const openSshBestEffort = ( ) ) }).pipe( - Effect.catchAll((error) => Effect.logWarning(`SSH auto-open failed: ${renderError(error)}`)), - Effect.asVoid + Effect.asVoid, + Effect.matchEffect({ + onFailure: (error) => Effect.logWarning(`SSH auto-open failed: ${renderError(error)}`), + onSuccess: () => Effect.void + }) ) const runCreateProject = ( diff --git a/packages/lib/tests/usecases/create-project-open-ssh.test.ts b/packages/lib/tests/usecases/create-project-open-ssh.test.ts index 26be75e1..ae07708d 100644 --- a/packages/lib/tests/usecases/create-project-open-ssh.test.ts +++ b/packages/lib/tests/usecases/create-project-open-ssh.test.ts @@ -17,6 +17,12 @@ type RecordedCommand = { readonly args: ReadonlyArray } +type ProcessPatch = { + readonly prevProjectsRoot: string | undefined + readonly prevStdinTty: boolean | undefined + readonly prevStdoutTty: boolean | undefined +} + const withTempDir = ( use: (tempDir: string) => Effect.Effect ): Effect.Effect => @@ -32,6 +38,40 @@ const withTempDir = ( }) ) +const patchProcessForInteractiveSsh = (projectsRoot: string): Effect.Effect => + Effect.sync(() => { + const prevProjectsRoot = process.env["DOCKER_GIT_PROJECTS_ROOT"] + const prevStdinTty = process.stdin.isTTY + const prevStdoutTty = process.stdout.isTTY + + process.env["DOCKER_GIT_PROJECTS_ROOT"] = projectsRoot + Object.defineProperty(process.stdin, "isTTY", { value: true, configurable: true }) + Object.defineProperty(process.stdout, "isTTY", { value: true, configurable: true }) + + return { prevProjectsRoot, prevStdinTty, prevStdoutTty } + }) + +const restorePatchedProcess = (patch: ProcessPatch): Effect.Effect => + Effect.sync(() => { + if (patch.prevProjectsRoot === undefined) { + delete process.env["DOCKER_GIT_PROJECTS_ROOT"] + } else { + process.env["DOCKER_GIT_PROJECTS_ROOT"] = patch.prevProjectsRoot + } + Object.defineProperty(process.stdin, "isTTY", { value: patch.prevStdinTty, configurable: true }) + Object.defineProperty(process.stdout, "isTTY", { value: patch.prevStdoutTty, configurable: true }) + }) + +const withInteractiveProcess = ( + projectsRoot: string, + use: Effect.Effect +): Effect.Effect => + Effect.scoped( + Effect.acquireRelease(patchProcessForInteractiveSsh(projectsRoot), restorePatchedProcess).pipe( + Effect.flatMap(() => use) + ) + ) + const encode = (value: string): Uint8Array => new TextEncoder().encode(value) const commandIncludes = (args: ReadonlyArray, needle: string): boolean => args.includes(needle) @@ -140,25 +180,12 @@ describe("createProject (openSsh)", () => { const executor = makeFakeExecutor(recorded) const command = makeCommand(root, outDir, path) - const prevProjectsRoot = process.env["DOCKER_GIT_PROJECTS_ROOT"] - const prevStdinTty = process.stdin.isTTY - const prevStdoutTty = process.stdout.isTTY - - process.env["DOCKER_GIT_PROJECTS_ROOT"] = path.join(root, "state") - Object.defineProperty(process.stdin, "isTTY", { value: true, configurable: true }) - Object.defineProperty(process.stdout, "isTTY", { value: true, configurable: true }) - - try { - yield* _(createProject(command).pipe(Effect.provideService(CommandExecutor.CommandExecutor, executor))) - } finally { - if (prevProjectsRoot === undefined) { - delete process.env["DOCKER_GIT_PROJECTS_ROOT"] - } else { - process.env["DOCKER_GIT_PROJECTS_ROOT"] = prevProjectsRoot - } - Object.defineProperty(process.stdin, "isTTY", { value: prevStdinTty, configurable: true }) - Object.defineProperty(process.stdout, "isTTY", { value: prevStdoutTty, configurable: true }) - } + yield* _( + withInteractiveProcess( + path.join(root, "state"), + createProject(command).pipe(Effect.provideService(CommandExecutor.CommandExecutor, executor)) + ) + ) const sshInvocations = recorded.filter((entry) => entry.command === "ssh") expect(sshInvocations).toHaveLength(1) From 6f663637d504fa11cef8afcf3e03f259da7b7f39 Mon Sep 17 00:00:00 2001 From: skulidropek <66840575+skulidropek@users.noreply.github.com> Date: Sun, 15 Feb 2026 18:56:09 +0000 Subject: [PATCH 7/7] test(e2e): make ssh wrapper log writable --- scripts/e2e/opencode-autoconnect.sh | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/scripts/e2e/opencode-autoconnect.sh b/scripts/e2e/opencode-autoconnect.sh index 302ab28c..13168084 100755 --- a/scripts/e2e/opencode-autoconnect.sh +++ b/scripts/e2e/opencode-autoconnect.sh @@ -128,7 +128,8 @@ EOF_ENV # Auto-open SSH happens only in an interactive TTY; wrap with `script` to allocate a pseudo-TTY. command -v script >/dev/null 2>&1 || fail "missing 'script' command (util-linux)" -rm -f "$SSH_LOG_PATH" +: > "$SSH_LOG_PATH" +chmod 0666 "$SSH_LOG_PATH" || true script -q -e -c "pnpm run docker-git clone \"$REPO_URL\" --force --ssh-port \"$SSH_PORT\" --out-dir \"$OUT_DIR_REL\" --container-name \"$CONTAINER_NAME\" --service-name \"$SERVICE_NAME\" --volume-name \"$VOLUME_NAME\"" /dev/null [[ -s "$SSH_LOG_PATH" ]] || fail "expected ssh to be invoked; log is empty: $SSH_LOG_PATH"