diff --git a/.github/workflows/check.yml b/.github/workflows/check.yml index 26e06470..4afb36a1 100644 --- a/.github/workflows/check.yml +++ b/.github/workflows/check.yml @@ -136,3 +136,16 @@ jobs: run: docker version && docker compose version - name: Login context notice run: bash scripts/e2e/login-context.sh + + e2e-runtime-volumes-ssh: + name: E2E (Runtime volumes + SSH) + runs-on: ubuntu-latest + timeout-minutes: 25 + steps: + - uses: actions/checkout@v6 + - name: Install dependencies + uses: ./.github/actions/setup + - name: Docker info + run: docker version && docker compose version + - name: Runtime volumes + host SSH CLI + run: bash scripts/e2e/runtime-volumes-ssh.sh diff --git a/README.md b/README.md index 48a0eb0e..2d5c1b5f 100644 --- a/README.md +++ b/README.md @@ -1,49 +1,71 @@ # docker-git `docker-git` создаёт отдельную Docker-среду для каждого репозитория, issue или PR. -По умолчанию проекты лежат в `~/.docker-git`. + +Теперь есть API-first controller mode: +- хосту нужен только Docker +- поднимается `docker-git-api` controller container +- его state живёт в Docker volume `docker-git-projects` +- controller через Docker API создаёт и обслуживает дочерние project containers +- снаружи ты общаешься с системой через HTTP API или `./ctl` ## Что нужно -- Docker Engine или Docker Desktop +- Для controller mode: Docker Engine или Docker Desktop - Доступ к Docker без `sudo` -- Node.js и `npm` +- Node.js и `npm` нужны только для legacy host CLI mode -## Установка +## API Controller Mode ```bash -npm i -g @prover-coder-ai/docker-git -docker-git --help +./ctl up +./ctl health +./ctl projects ``` -## Авторизация +API публикуется на `http://127.0.0.1:3334` по умолчанию. ```bash -docker-git auth github login --web -docker-git auth codex login --web -docker-git auth claude login --web +./ctl request GET /projects +./ctl request POST /projects '{"repoUrl":"https://github.com/ProverCoderAI/docker-git.git","repoRef":"main"}' +``` + +Важно: +- `./ctl` не требует `curl`, `node` или `pnpm` на хосте +- запросы к API выполняются через `curl` внутри controller container +- `.docker-git` больше не обязан лежать на host filesystem: controller хранит его в Docker volume + +## Legacy Host CLI + +```bash +npm i -g @prover-coder-ai/docker-git +docker-git --help ``` ## Пример -Можно передавать ссылку на репозиторий, ветку (`/tree/...`), issue или PR. +Через API controller можно создать проект и потом поднять его отдельно: ```bash -docker-git clone https://github.com/ProverCoderAI/docker-git/issues/122 --force --mcp-playwright +./ctl request POST /projects '{"repoUrl":"https://github.com/ProverCoderAI/docker-git.git","repoRef":"main","up":false}' +./ctl projects ``` -- `--force` пересоздаёт окружение и удаляет volumes проекта. -- `--mcp-playwright` включает Playwright MCP и Chromium sidecar для браузерной автоматизации. +API возвращает `projectId`, после чего можно: + +```bash +./ctl request POST /projects//up +./ctl request GET /projects//logs +./ctl request POST /projects//down +``` -Автоматический запуск агента: +## Проверка Docker runtime ```bash -docker-git clone https://github.com/ProverCoderAI/docker-git/issues/122 --force --auto +pnpm run e2e:runtime-volumes-ssh ``` -- `--auto` сам выбирает Claude или Codex по доступной авторизации. Если доступны оба, выбор случайный. -- `--auto=claude` или `--auto=codex` принудительно выбирает агента. -- В auto-режиме агент сам выполняет задачу, создаёт PR и после завершения контейнер очищается. +Сценарий доказывает, что контейнер стартует через Docker, runtime state живёт в named volumes, а SSH реально заходит в дочерний project container. ## Подробности diff --git a/ctl b/ctl index 6dcb70a8..5e2b59f3 100755 --- a/ctl +++ b/ctl @@ -1,52 +1,188 @@ #!/usr/bin/env bash -# CHANGE: provide a minimal local orchestrator for the dev container and auth helpers -# WHY: single command to manage the container and login flows -# QUOTE(TZ): "команда с помощью которой можно полностью контролировать этими докер образами" -# REF: user-request-2026-01-07 +# CHANGE: control the API-first docker-git controller container from the host +# WHY: host should only need Docker while all orchestration runs inside the API controller +# QUOTE(TZ): "Поднимается сервер и ты через него можешь общаться с контейнером" +# REF: user-request-2026-03-15-api-controller # SOURCE: n/a -# FORMAT THEOREM: forall cmd: valid(cmd) -> action(cmd) terminates +# FORMAT THEOREM: forall cmd: valid(cmd) -> controller_action(cmd) terminates # PURITY: SHELL # EFFECT: Effect -# INVARIANT: uses repo-local docker-compose.yml and dev-ssh container -# COMPLEXITY: O(1) +# INVARIANT: every API request is executed from inside the controller container; host does not need curl/node/pnpm +# COMPLEXITY: O(1) + network/docker set -euo pipefail ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" COMPOSE_FILE="$ROOT/docker-compose.yml" -CONTAINER_NAME="dev-ssh" -SSH_KEY="$ROOT/dev_ssh_key" -SSH_PORT="2222" -SSH_USER="dev" -SSH_HOST="localhost" +CONTAINER_NAME="docker-git-api" +API_PORT="${DOCKER_GIT_API_PORT:-3334}" +API_HOST="${DOCKER_GIT_API_BIND_HOST:-127.0.0.1}" +API_BASE_URL="http://127.0.0.1:${API_PORT}" +DOCKER_CMD=() usage() { cat <<'USAGE' Usage: ./ctl -Container: - up Build and start the container - down Stop and remove the container - ps Show container status - logs Tail logs - restart Restart the container - exec Shell into the container - ssh SSH into the container +Controller: + up Build and start the API controller + down Stop and remove the API controller + ps Show controller status + logs Tail controller logs + restart Restart the controller + shell Open a shell inside the controller + url Print the published API URL + health GET /health through curl running inside the controller -Codex auth: - codex-login Device-code login flow (headless-friendly) - codex-status Show auth status (exit 0 when logged in) - codex-logout Remove cached credentials +API: + projects GET /projects + request request [JSON_BODY] + examples: + ./ctl request GET /projects + ./ctl request POST /projects '{"repoUrl":"https://github.com/org/repo.git"}' + ./ctl request POST /projects//up USAGE } compose() { - docker compose -f "$COMPOSE_FILE" "$@" + "${DOCKER_CMD[@]}" compose -f "$COMPOSE_FILE" "$@" } +require_running() { + if ! "${DOCKER_CMD[@]}" ps --format '{{.Names}}' | grep -Fxq "$CONTAINER_NAME"; then + echo "Controller is not running. Start it with: ./ctl up" >&2 + exit 1 + fi +} + +api_exec() { + "${DOCKER_CMD[@]}" exec "$CONTAINER_NAME" "$@" +} + +normalize_api_path() { + local raw_path="$1" + + if [[ "$raw_path" != /projects/* ]]; then + printf '%s' "$raw_path" + return + fi + + local normalized + normalized="$("${DOCKER_CMD[@]}" exec -i "$CONTAINER_NAME" node - "$raw_path" <<'NODE' +const raw = process.argv[2] ?? "" +const [pathname, query = ""] = raw.split(/\?(.*)/s, 2) +const prefix = "/projects/" + +const joinWithQuery = (path) => query.length > 0 ? `${path}?${query}` : path +const encodeProjectPath = (projectId, suffix = "") => + joinWithQuery(`${prefix}${encodeURIComponent(projectId)}${suffix}`) + +if (!pathname.startsWith(prefix)) { + process.stdout.write(raw) + process.exit(0) +} + +const remainder = pathname.slice(prefix.length) +if (!remainder.startsWith("/")) { + process.stdout.write(raw) + process.exit(0) +} + +const patterns = [ + { + regex: /^(.*)\/agents\/([^/]+)\/(attach|stop|logs)$/u, + render: ([, projectId, agentId, action]) => + encodeProjectPath(projectId, `/agents/${encodeURIComponent(agentId)}/${action}`) + }, + { + regex: /^(.*)\/agents\/([^/]+)$/u, + render: ([, projectId, agentId]) => + encodeProjectPath(projectId, `/agents/${encodeURIComponent(agentId)}`) + }, + { + regex: /^(.*)\/agents$/u, + render: ([, projectId]) => encodeProjectPath(projectId, "/agents") + }, + { + regex: /^(.*)\/(up|down|recreate|ps|logs|events)$/u, + render: ([, projectId, action]) => encodeProjectPath(projectId, `/${action}`) + }, + { + regex: /^(.*)$/u, + render: ([, projectId]) => encodeProjectPath(projectId) + } +] + +for (const { regex, render } of patterns) { + const match = remainder.match(regex) + if (match !== null) { + process.stdout.write(render(match)) + process.exit(0) + } +} + +process.stdout.write(raw) +NODE +)" + printf '%s' "$normalized" +} + +api_request() { + local method="$1" + local path="$2" + local body="${3:-}" + + require_running + local normalized_path + normalized_path="$(normalize_api_path "$path")" + + if [[ -n "$body" ]]; then + printf '%s' "$body" | "${DOCKER_CMD[@]}" exec -i "$CONTAINER_NAME" sh -lc \ + "curl -fsS -X '$method' '$API_BASE_URL$normalized_path' -H 'content-type: application/json' --data-binary @-" + printf '\n' + return + fi + + "${DOCKER_CMD[@]}" exec "$CONTAINER_NAME" sh -lc "curl -fsS -X '$method' '$API_BASE_URL$normalized_path'" + printf '\n' +} + +wait_for_health() { + require_running + local attempts=30 + local delay_seconds=2 + local attempt=1 + while (( attempt <= attempts )); do + if "${DOCKER_CMD[@]}" exec "$CONTAINER_NAME" sh -lc "curl -fsS '$API_BASE_URL/health' >/dev/null"; then + return 0 + fi + sleep "$delay_seconds" + attempt=$((attempt + 1)) + done + + echo "Controller did not become healthy in time." >&2 + return 1 +} + +resolve_docker_cmd() { + if docker info >/dev/null 2>&1; then + DOCKER_CMD=(docker) + return + fi + if sudo -n docker info >/dev/null 2>&1; then + DOCKER_CMD=(sudo docker) + return + fi + DOCKER_CMD=(docker) +} + +resolve_docker_cmd + case "${1:-}" in up) compose up -d --build + wait_for_health + echo "Controller API: http://${API_HOST}:${API_PORT}" ;; down) compose down @@ -59,21 +195,27 @@ case "${1:-}" in ;; restart) compose restart + wait_for_health ;; - exec) - docker exec -it "$CONTAINER_NAME" bash + shell) + require_running + "${DOCKER_CMD[@]}" exec -it "$CONTAINER_NAME" bash ;; - ssh) - ssh -i "$SSH_KEY" -p "$SSH_PORT" "$SSH_USER@$SSH_HOST" + url) + echo "http://${API_HOST}:${API_PORT}" ;; - codex-login) - docker exec -it "$CONTAINER_NAME" codex login --device-auth + health) + api_request GET /health ;; - codex-status) - docker exec "$CONTAINER_NAME" codex login status + projects) + api_request GET /projects ;; - codex-logout) - docker exec -it "$CONTAINER_NAME" codex logout + request) + if [[ $# -lt 3 ]]; then + echo "Usage: ./ctl request [JSON_BODY]" >&2 + exit 1 + fi + api_request "$2" "$3" "${4:-}" ;; help|--help|-h|"") usage @@ -83,4 +225,4 @@ case "${1:-}" in usage >&2 exit 1 ;; - esac +esac diff --git a/docker-compose.api.yml b/docker-compose.api.yml index 4f68d18e..82828bda 100644 --- a/docker-compose.api.yml +++ b/docker-compose.api.yml @@ -7,11 +7,20 @@ services: environment: DOCKER_GIT_API_PORT: ${DOCKER_GIT_API_PORT:-3334} DOCKER_GIT_PROJECTS_ROOT: ${DOCKER_GIT_PROJECTS_ROOT:-/home/dev/.docker-git} + DOCKER_GIT_PROJECTS_ROOT_VOLUME: ${DOCKER_GIT_PROJECTS_ROOT_VOLUME:-docker-git-projects} DOCKER_GIT_FEDERATION_PUBLIC_ORIGIN: ${DOCKER_GIT_FEDERATION_PUBLIC_ORIGIN:-} DOCKER_GIT_FEDERATION_ACTOR: ${DOCKER_GIT_FEDERATION_ACTOR:-docker-git} ports: - "${DOCKER_GIT_API_BIND_HOST:-127.0.0.1}:${DOCKER_GIT_API_PORT:-3334}:${DOCKER_GIT_API_PORT:-3334}" + dns: + - 8.8.8.8 + - 8.8.4.4 + - 1.1.1.1 volumes: - /var/run/docker.sock:/var/run/docker.sock - - ${DOCKER_GIT_PROJECTS_ROOT_HOST:-/home/dev/.docker-git}:${DOCKER_GIT_PROJECTS_ROOT:-/home/dev/.docker-git} + - docker_git_projects:${DOCKER_GIT_PROJECTS_ROOT:-/home/dev/.docker-git} restart: unless-stopped + +volumes: + docker_git_projects: + name: ${DOCKER_GIT_PROJECTS_ROOT_VOLUME:-docker-git-projects} diff --git a/docker-compose.yml b/docker-compose.yml index e297fde4..82828bda 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -1,22 +1,26 @@ services: - dev: - build: . - container_name: dev-ssh + api: + build: + context: . + dockerfile: packages/api/Dockerfile + container_name: docker-git-api environment: - REPO_URL: "https://github.com/ProverCoderAI/eslint-plugin-suggest-members.git" - REPO_REF: "main" - TARGET_DIR: "/home/dev/app" - CODEX_HOME: "/home/dev/.codex" + DOCKER_GIT_API_PORT: ${DOCKER_GIT_API_PORT:-3334} + DOCKER_GIT_PROJECTS_ROOT: ${DOCKER_GIT_PROJECTS_ROOT:-/home/dev/.docker-git} + DOCKER_GIT_PROJECTS_ROOT_VOLUME: ${DOCKER_GIT_PROJECTS_ROOT_VOLUME:-docker-git-projects} + DOCKER_GIT_FEDERATION_PUBLIC_ORIGIN: ${DOCKER_GIT_FEDERATION_PUBLIC_ORIGIN:-} + DOCKER_GIT_FEDERATION_ACTOR: ${DOCKER_GIT_FEDERATION_ACTOR:-docker-git} ports: - - "127.0.0.1:2222:22" + - "${DOCKER_GIT_API_BIND_HOST:-127.0.0.1}:${DOCKER_GIT_API_PORT:-3334}:${DOCKER_GIT_API_PORT:-3334}" dns: - 8.8.8.8 - 8.8.4.4 - 1.1.1.1 volumes: - - dev_home:/home/dev - - ./authorized_keys:/authorized_keys:ro - - ./.orch/auth/codex:/home/dev/.codex + - /var/run/docker.sock:/var/run/docker.sock + - docker_git_projects:${DOCKER_GIT_PROJECTS_ROOT:-/home/dev/.docker-git} + restart: unless-stopped volumes: - dev_home: + docker_git_projects: + name: ${DOCKER_GIT_PROJECTS_ROOT_VOLUME:-docker-git-projects} diff --git a/package.json b/package.json index e5c7b676..7ae1aa64 100644 --- a/package.json +++ b/package.json @@ -27,6 +27,7 @@ "e2e": "bash scripts/e2e/run-all.sh", "e2e:clone-cache": "bash scripts/e2e/clone-cache.sh", "e2e:login-context": "bash scripts/e2e/login-context.sh", + "e2e:runtime-volumes-ssh": "bash scripts/e2e/runtime-volumes-ssh.sh", "e2e:opencode-autoconnect": "bash scripts/e2e/opencode-autoconnect.sh", "list": "pnpm --filter ./packages/app build && node packages/app/dist/main.js list", "dev": "pnpm --filter ./packages/app dev", diff --git a/packages/api/README.md b/packages/api/README.md index 1875623a..6be1d4b5 100644 --- a/packages/api/README.md +++ b/packages/api/README.md @@ -2,6 +2,12 @@ HTTP API for docker-git orchestration (projects, agents, logs/events, federation). +This is now the intended controller plane: +- the API runs inside `docker-git-api` +- `.docker-git` state lives in the Docker volume `docker-git-projects` +- the API talks to Docker through `/var/run/docker.sock` +- child project containers no longer depend on host bind mounts for bootstrap auth/env + ## UI wrapper After API startup open: @@ -22,8 +28,8 @@ pnpm --filter ./packages/api start From repository root: ```bash -docker compose -f docker-compose.api.yml up -d --build -curl -s http://127.0.0.1:3334/health +docker compose up -d --build +./ctl health ``` Default port mapping: @@ -35,8 +41,8 @@ Optional env: - `DOCKER_GIT_API_BIND_HOST` (default: `127.0.0.1`) - `DOCKER_GIT_API_PORT` (default: `3334`) -- `DOCKER_GIT_PROJECTS_ROOT_HOST` (host path with docker-git projects, default: `/home/dev/.docker-git`) - `DOCKER_GIT_PROJECTS_ROOT` (container path, default: `/home/dev/.docker-git`) +- `DOCKER_GIT_PROJECTS_ROOT_VOLUME` (Docker volume name for controller state, default: `docker-git-projects`) - `DOCKER_GIT_FEDERATION_PUBLIC_ORIGIN` (optional public ActivityPub origin) - `DOCKER_GIT_FEDERATION_ACTOR` (default: `docker-git`) @@ -74,20 +80,18 @@ Optional env: 1. Read actor profile (contains `inbox/outbox/followers/following/liked`): ```bash -curl -s http://127.0.0.1:3334/federation/actor +./ctl request GET /federation/actor ``` 2. Create follow subscription: ```bash -curl -sS -X POST http://127.0.0.1:3334/federation/follows \ - -H 'content-type: application/json' \ - -d '{ - "domain":"https://social.provercoder.ai", - "actor":"https://dev.example/users/bot", - "object":"https://tracker.example/issues/followers", - "capability":"https://tracker.example/caps/follow" - }' +./ctl request POST /federation/follows '{ + "domain":"https://social.provercoder.ai", + "actor":"https://dev.example/users/bot", + "object":"https://tracker.example/issues/followers", + "capability":"https://tracker.example/caps/follow" +}' ``` `domain` is used as public origin. `.example` hosts in `actor/object/capability` are normalized to that domain. @@ -95,45 +99,41 @@ curl -sS -X POST http://127.0.0.1:3334/federation/follows \ 3. Confirm subscription by sending `Accept` into inbox: ```bash -curl -sS -X POST http://127.0.0.1:3334/federation/inbox \ - -H 'content-type: application/json' \ - -d '{ - "@context":"https://www.w3.org/ns/activitystreams", - "type":"Accept", - "object":"https://social.provercoder.ai/federation/activities/follows/" - }' +./ctl request POST /federation/inbox '{ + "@context":"https://www.w3.org/ns/activitystreams", + "type":"Accept", + "object":"https://social.provercoder.ai/federation/activities/follows/" +}' ``` 4. Verify follow state and collections: ```bash -curl -s http://127.0.0.1:3334/federation/follows -curl -s http://127.0.0.1:3334/federation/following -curl -s http://127.0.0.1:3334/federation/outbox +./ctl request GET /federation/follows +./ctl request GET /federation/following +./ctl request GET /federation/outbox ``` 5. Push issue offer through ForgeFed inbox: ```bash -curl -sS -X POST http://127.0.0.1:3334/federation/inbox \ - -H 'content-type: application/json' \ - -d '{ - "@context":["https://www.w3.org/ns/activitystreams","https://forgefed.org/ns"], - "id":"https://social.provercoder.ai/offers/42", - "type":"Offer", - "target":"https://social.provercoder.ai/issues", - "object":{ - "type":"Ticket", - "id":"https://social.provercoder.ai/issues/42", - "attributedTo":"https://origin.provercoder.ai/users/alice", - "summary":"Need reproducible CI parity", - "content":"Implement API behavior matching CLI." - } - }' +./ctl request POST /federation/inbox '{ + "@context":["https://www.w3.org/ns/activitystreams","https://forgefed.org/ns"], + "id":"https://social.provercoder.ai/offers/42", + "type":"Offer", + "target":"https://social.provercoder.ai/issues", + "object":{ + "type":"Ticket", + "id":"https://social.provercoder.ai/issues/42", + "attributedTo":"https://origin.provercoder.ai/users/alice", + "summary":"Need reproducible CI parity", + "content":"Implement API behavior matching CLI." + } +}' ``` 6. Verify persisted issues: ```bash -curl -s http://127.0.0.1:3334/federation/issues +./ctl request GET /federation/issues ``` diff --git a/packages/api/src/services/projects.ts b/packages/api/src/services/projects.ts index c91f7e4c..42e4a159 100644 --- a/packages/api/src/services/projects.ts +++ b/packages/api/src/services/projects.ts @@ -1,4 +1,11 @@ -import { buildCreateCommand, createProject, formatParseError, listProjectItems, readProjectConfig } from "@effect-template/lib" +import { + buildCreateCommand, + createProject, + formatParseError, + listProjectItems, + readProjectConfig, + runDockerComposeUpWithPortCheck +} from "@effect-template/lib" import { runCommandCapture } from "@effect-template/lib/shell/command-runner" import { CommandFailedError } from "@effect-template/lib/shell/errors" import { deleteDockerGitProject } from "@effect-template/lib/usecases/projects" @@ -281,7 +288,7 @@ export const upProject = ( Effect.gen(function*(_) { const project = yield* _(findProjectById(projectId)) yield* _(markDeployment(projectId, "build", "docker compose up -d --build")) - yield* _(runComposeCapture(projectId, project.projectDir, ["up", "-d", "--build"])) + yield* _(runDockerComposeUpWithPortCheck(project.projectDir)) yield* _(markDeployment(projectId, "running", "Container running")) }) @@ -318,7 +325,7 @@ export const recreateProject = ( ) yield* _(runComposeCapture(projectId, project.projectDir, ["down"], [0, 1])) - yield* _(runComposeCapture(projectId, project.projectDir, ["up", "-d", "--build"])) + yield* _(runDockerComposeUpWithPortCheck(project.projectDir)) yield* _(markDeployment(projectId, "running", "Recreate completed")) }) diff --git a/packages/app/tests/docker-git/fixtures/project-item.ts b/packages/app/tests/docker-git/fixtures/project-item.ts index 0b12c346..ce06260d 100644 --- a/packages/app/tests/docker-git/fixtures/project-item.ts +++ b/packages/app/tests/docker-git/fixtures/project-item.ts @@ -14,11 +14,11 @@ export const makeProjectItem = ( targetDir: "/home/dev/org/repo", sshCommand: "ssh -p 2222 dev@localhost", sshKeyPath: null, - authorizedKeysPath: "/home/dev/.docker-git/org-repo/.docker-git/authorized_keys", + authorizedKeysPath: "/home/dev/.docker-git/org-repo/authorized_keys", authorizedKeysExists: true, - envGlobalPath: "/home/dev/.orch/env/global.env", + envGlobalPath: "/home/dev/.docker-git/org-repo/.orch/env/global.env", envProjectPath: "/home/dev/.docker-git/org-repo/.orch/env/project.env", - codexAuthPath: "/home/dev/.orch/auth/codex", + codexAuthPath: "/home/dev/.docker-git/org-repo/.orch/auth/codex", codexHome: "/home/dev/.codex", ...overrides }) diff --git a/packages/app/tests/docker-git/menu-select-connect.test.ts b/packages/app/tests/docker-git/menu-select-connect.test.ts index 84ad5a6d..89bb3f09 100644 --- a/packages/app/tests/docker-git/menu-select-connect.test.ts +++ b/packages/app/tests/docker-git/menu-select-connect.test.ts @@ -20,10 +20,10 @@ const makeConnectDeps = (events: Array) => ({ const workspaceProject = () => makeProjectItem({ projectDir: "/home/dev/provercoderai/docker-git/workspaces/org/repo", - authorizedKeysPath: "/home/dev/provercoderai/docker-git/workspaces/org/repo/.docker-git/authorized_keys", - envGlobalPath: "/home/dev/provercoderai/docker-git/.orch/env/global.env", + authorizedKeysPath: "/home/dev/provercoderai/docker-git/workspaces/org/repo/authorized_keys", + envGlobalPath: "/home/dev/provercoderai/docker-git/workspaces/org/repo/.orch/env/global.env", envProjectPath: "/home/dev/provercoderai/docker-git/workspaces/org/repo/.orch/env/project.env", - codexAuthPath: "/home/dev/provercoderai/docker-git/.orch/auth/codex" + codexAuthPath: "/home/dev/provercoderai/docker-git/workspaces/org/repo/.orch/auth/codex" }) describe("menu-select-connect", () => { diff --git a/packages/lib/src/core/domain.ts b/packages/lib/src/core/domain.ts index 0c9f9083..f50bd7c1 100644 --- a/packages/lib/src/core/domain.ts +++ b/packages/lib/src/core/domain.ts @@ -11,6 +11,8 @@ export type DockerNetworkMode = "shared" | "project" export const defaultDockerNetworkMode: DockerNetworkMode = "shared" export const defaultDockerSharedNetworkName = "docker-git-shared" +export const dockerGitSharedCacheVolumeName = "docker-git-shared-cache" +export const dockerGitSharedCodexVolumeName = "docker-git-shared-codex" export const defaultCpuLimit = "30%" @@ -358,6 +360,20 @@ export const resolveComposeNetworkName = ( ? config.dockerSharedNetworkName : `${config.serviceName}-net` +// CHANGE: derive a stable bootstrap volume name for per-project runtime bootstrap data +// WHY: API/controller mode cannot rely on host bind mounts for auth/env material +// QUOTE(ТЗ): "У нас есть CLI который вызывает docker ? ... Поднимается сервер и ты через него можешь общаться с контейнером" +// REF: user-request-2026-03-15-api-controller +// SOURCE: n/a +// FORMAT THEOREM: ∀cfg: resolveProjectBootstrapVolumeName(cfg) = v -> deterministic(v) +// PURITY: CORE +// EFFECT: n/a +// INVARIANT: bootstrap volume name is derived solely from project volumeName +// COMPLEXITY: O(1) +export const resolveProjectBootstrapVolumeName = ( + config: Pick +): string => `${config.volumeName}-bootstrap` + export const defaultTemplateConfig = { containerName: "dev-ssh", serviceName: "dev", diff --git a/packages/lib/src/core/templates-entrypoint.ts b/packages/lib/src/core/templates-entrypoint.ts index 8c13276b..0ac3c051 100644 --- a/packages/lib/src/core/templates-entrypoint.ts +++ b/packages/lib/src/core/templates-entrypoint.ts @@ -1,4 +1,5 @@ import type { TemplateConfig } from "./domain.js" +import { renderEntrypointAgentsNotice } from "./templates-entrypoint/agents-notice.js" import { renderEntrypointAuthorizedKeys, renderEntrypointBaseline, @@ -11,15 +12,14 @@ import { renderEntrypointZshShell, renderEntrypointZshUserRc } from "./templates-entrypoint/base.js" -import { renderEntrypointDnsRepair } from "./templates-entrypoint/dns-repair.js" import { renderEntrypointClaudeConfig } from "./templates-entrypoint/claude.js" import { - renderEntrypointAgentsNotice, renderEntrypointCodexHome, renderEntrypointCodexResumeHint, renderEntrypointCodexSharedAuth, renderEntrypointMcpPlaywright } from "./templates-entrypoint/codex.js" +import { renderEntrypointDnsRepair } from "./templates-entrypoint/dns-repair.js" import { renderEntrypointGeminiConfig } from "./templates-entrypoint/gemini.js" import { renderEntrypointGitConfig, renderEntrypointGitHooks } from "./templates-entrypoint/git.js" import { renderEntrypointDockerGitBootstrap } from "./templates-entrypoint/nested-docker-git.js" @@ -37,11 +37,11 @@ export const renderEntrypoint = (config: TemplateConfig): string => renderEntrypointHeader(config), renderEntrypointDnsRepair(), renderEntrypointPackageCache(config), + renderEntrypointDockerGitBootstrap(config), renderEntrypointAuthorizedKeys(config), renderEntrypointCodexHome(config), renderEntrypointCodexSharedAuth(config), renderEntrypointOpenCodeConfig(config), - renderEntrypointDockerGitBootstrap(config), renderEntrypointMcpPlaywright(config), renderEntrypointZshShell(config), renderEntrypointZshUserRc(config), diff --git a/packages/lib/src/core/templates-entrypoint/agents-notice.ts b/packages/lib/src/core/templates-entrypoint/agents-notice.ts new file mode 100644 index 00000000..a4bd55e6 --- /dev/null +++ b/packages/lib/src/core/templates-entrypoint/agents-notice.ts @@ -0,0 +1,115 @@ +import type { TemplateConfig } from "../domain.js" + +const entrypointAgentsNoticeTemplate = String.raw`# Ensure global AGENTS.md exists for container context +AGENTS_PATH="__CODEX_HOME__/AGENTS.md" +LEGACY_AGENTS_PATH="/home/__SSH_USER__/AGENTS.md" +PROJECT_LINE="Рабочая папка проекта (git clone): __TARGET_DIR__" +WORKSPACES_LINE="Доступные workspace пути: __TARGET_DIR__" +WORKSPACE_INFO_LINE="Контекст workspace: repository" +FOCUS_LINE="Фокус задачи: работай только в workspace, который запрашивает пользователь. Текущий workspace: __TARGET_DIR__" +INTERNET_LINE="Доступ к интернету: есть. Если чего-то не знаешь — ищи в интернете или по кодовой базе." +SUBAGENTS_LINE="Для решения задач обязательно используй subagents. Сам агент обязан выполнять финальную проверку, интеграцию и валидацию результата перед ответом пользователю." +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 +elif [[ "$REPO_REF" == refs/pull/*/head ]]; then + PR_ID="$(printf "%s" "$REPO_REF" | sed -nE 's#^refs/pull/([0-9]+)/head$#\1#p')" + PR_URL="" + if [[ "$REPO_URL" == https://github.com/* && -n "$PR_ID" ]]; then + PR_REPO="$(printf "%s" "$REPO_URL" | sed -E 's#^https://github.com/##; s#[.]git$##; s#/*$##')" + if [[ -n "$PR_REPO" ]]; then + PR_URL="https://github.com/$PR_REPO/pull/$PR_ID" + fi + fi + if [[ -n "$PR_ID" && -n "$PR_URL" ]]; then + WORKSPACE_INFO_LINE="Контекст workspace: PR #$PR_ID ($PR_URL)" + elif [[ -n "$PR_ID" ]]; then + WORKSPACE_INFO_LINE="Контекст workspace: PR #$PR_ID" + else + WORKSPACE_INFO_LINE="Контекст workspace: pull request ($REPO_REF)" + fi +fi +MANAGED_START="" +MANAGED_END="" +if [[ ! -f "$AGENTS_PATH" ]]; then + MANAGED_BLOCK="$(cat < "$AGENTS_PATH" +Ты автономный агент, который имеет полностью все права управления контейнером. У тебя есть доступ к командам sudo, gh, codex, opencode, oh-my-opencode, sshpass, git, node, pnpm и всем остальным другим. Проекты с которыми идёт работа лежат по пути ~ +$MANAGED_BLOCK +Если ты видишь файлы AGENTS.md внутри проекта, ты обязан их читать и соблюдать инструкции. +EOF + chown 1000:1000 "$AGENTS_PATH" || true +fi +if [[ -f "$AGENTS_PATH" ]]; then + 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' \ + -e '/^Для решения задач обязательно используй subagents[.]/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` + +export const renderEntrypointAgentsNotice = (config: TemplateConfig): string => + entrypointAgentsNoticeTemplate.replaceAll("__CODEX_HOME__", config.codexHome).replaceAll( + "__SSH_USER__", + config.sshUser + ) + .replaceAll("__TARGET_DIR__", config.targetDir) diff --git a/packages/lib/src/core/templates-entrypoint/base.ts b/packages/lib/src/core/templates-entrypoint/base.ts index 9b3f7a8f..32126342 100644 --- a/packages/lib/src/core/templates-entrypoint/base.ts +++ b/packages/lib/src/core/templates-entrypoint/base.ts @@ -52,7 +52,7 @@ docker_git_upsert_ssh_env() { }` export const renderEntrypointPackageCache = (config: TemplateConfig): string => - `# Share package manager caches across all docker-git containers + `# Keep package manager caches inside the project home volume PACKAGE_CACHE_ROOT="/home/${config.sshUser}/.docker-git/.cache/packages" PACKAGE_PNPM_STORE="\${npm_config_store_dir:-\${PNPM_STORE_DIR:-$PACKAGE_CACHE_ROOT/pnpm/store}}" PACKAGE_NPM_CACHE="\${npm_config_cache:-\${NPM_CONFIG_CACHE:-$PACKAGE_CACHE_ROOT/npm}}" @@ -77,12 +77,13 @@ docker_git_upsert_ssh_env "npm_config_cache" "$PACKAGE_NPM_CACHE" docker_git_upsert_ssh_env "YARN_CACHE_FOLDER" "$PACKAGE_YARN_CACHE"` export const renderEntrypointAuthorizedKeys = (config: TemplateConfig): string => - `# 1) Authorized keys are mounted from host at /authorized_keys + `# 1) Mirror authorized_keys from the project home volume into ~/.ssh +DOCKER_GIT_AUTH_KEYS="/home/${config.sshUser}/.docker-git/authorized_keys" mkdir -p /home/${config.sshUser}/.ssh chmod 700 /home/${config.sshUser}/.ssh -if [[ -f /authorized_keys ]]; then - cp /authorized_keys /home/${config.sshUser}/.ssh/authorized_keys +if [[ -f "$DOCKER_GIT_AUTH_KEYS" ]]; then + cp "$DOCKER_GIT_AUTH_KEYS" /home/${config.sshUser}/.ssh/authorized_keys chmod 600 /home/${config.sshUser}/.ssh/authorized_keys fi @@ -163,4 +164,5 @@ PrintLastLog no EOF chmod 0644 "$DOCKER_GIT_SSHD_CONF" || true` -export const renderEntrypointSshd = (): string => `# 5) Run sshd in foreground\nexec /usr/sbin/sshd -D` +export const renderEntrypointSshd = (): string => + `# 5) Run sshd in foreground (log to stderr for CI/debuggability)\nexec /usr/sbin/sshd -D -e` diff --git a/packages/lib/src/core/templates-entrypoint/codex.ts b/packages/lib/src/core/templates-entrypoint/codex.ts index 2a1cb2b8..38fa3f04 100644 --- a/packages/lib/src/core/templates-entrypoint/codex.ts +++ b/packages/lib/src/core/templates-entrypoint/codex.ts @@ -2,8 +2,10 @@ import type { TemplateConfig } from "../domain.js" export const renderEntrypointCodexHome = (config: TemplateConfig): string => `# Ensure Codex home exists if mounted -mkdir -p ${config.codexHome} -chown -R 1000:1000 ${config.codexHome} +mkdir -p ${config.codexHome} && chown -R 1000:1000 ${config.codexHome} + +DOCKER_GIT_CODEX_BOOTSTRAP="/home/${config.sshUser}/.docker-git/.orch/auth/codex/config.toml" +if [[ -f "$DOCKER_GIT_CODEX_BOOTSTRAP" && ! -f "${config.codexHome}/config.toml" ]]; then cp "$DOCKER_GIT_CODEX_BOOTSTRAP" "${config.codexHome}/config.toml"; chown 1000:1000 "${config.codexHome}/config.toml" || true; fi # Ensure home ownership matches the dev UID/GID (volumes may be stale) HOME_OWNER="$(stat -c "%u:%g" /home/${config.sshUser} 2>/dev/null || echo "")" @@ -22,14 +24,18 @@ if [[ "$CODEX_SHARE_AUTH" == "1" ]]; then | sed -E 's/[^a-z0-9]+/-/g; s/^-+//; s/-+$//')" if [[ -z "$CODEX_LABEL_NORM" ]]; then CODEX_LABEL_NORM="default"; fi CODEX_AUTH_LABEL="$CODEX_LABEL_NORM" - CODEX_SHARED_HOME="${config.codexHome}-shared" - mkdir -p "$CODEX_SHARED_HOME" - chown -R 1000:1000 "$CODEX_SHARED_HOME" || true - AUTH_FILE="${config.codexHome}/auth.json" - SHARED_AUTH_FILE="$CODEX_SHARED_HOME/auth.json" + DOCKER_GIT_CODEX_AUTH_ROOT="/home/${config.sshUser}/.docker-git/.orch/auth/codex"; CODEX_SHARED_HOME="${config.codexHome}-shared" + mkdir -p "$CODEX_SHARED_HOME" && chown -R 1000:1000 "$CODEX_SHARED_HOME" || true + AUTH_FILE="${config.codexHome}/auth.json"; SHARED_AUTH_FILE="$CODEX_SHARED_HOME/auth.json"; SHARED_AUTH_SEED="$DOCKER_GIT_CODEX_AUTH_ROOT/auth.json" if [[ "$CODEX_LABEL_NORM" != "default" ]]; then - SHARED_AUTH_FILE="$CODEX_SHARED_HOME/$CODEX_LABEL_NORM/auth.json" - mkdir -p "$(dirname "$SHARED_AUTH_FILE")" + SHARED_AUTH_FILE="$CODEX_SHARED_HOME/$CODEX_LABEL_NORM/auth.json"; SHARED_AUTH_SEED="$DOCKER_GIT_CODEX_AUTH_ROOT/$CODEX_LABEL_NORM/auth.json"; mkdir -p "$(dirname "$SHARED_AUTH_FILE")" + fi + if [[ -f "$SHARED_AUTH_SEED" ]]; then + cp "$SHARED_AUTH_SEED" "$SHARED_AUTH_FILE" + chmod 600 "$SHARED_AUTH_FILE" || true + chown 1000:1000 "$SHARED_AUTH_FILE" || true + else + rm -f "$SHARED_AUTH_FILE" || true fi # Guard against a bad bind mount creating a directory at auth.json. if [[ -d "$AUTH_FILE" ]]; then @@ -207,116 +213,3 @@ export const renderEntrypointCodexResumeHint = (config: TemplateConfig): string entrypointCodexResumeHintTemplate .replaceAll("__REPO_REF_DEFAULT__", escapeForDoubleQuotes(config.repoRef)) .replaceAll("__REPO_URL_DEFAULT__", escapeForDoubleQuotes(config.repoUrl)) - -const entrypointAgentsNoticeTemplate = String.raw`# Ensure global AGENTS.md exists for container context -AGENTS_PATH="__CODEX_HOME__/AGENTS.md" -LEGACY_AGENTS_PATH="/home/__SSH_USER__/AGENTS.md" -PROJECT_LINE="Рабочая папка проекта (git clone): __TARGET_DIR__" -WORKSPACES_LINE="Доступные workspace пути: __TARGET_DIR__" -WORKSPACE_INFO_LINE="Контекст workspace: repository" -FOCUS_LINE="Фокус задачи: работай только в workspace, который запрашивает пользователь. Текущий workspace: __TARGET_DIR__" -INTERNET_LINE="Доступ к интернету: есть. Если чего-то не знаешь — ищи в интернете или по кодовой базе." -SUBAGENTS_LINE="Для решения задач обязательно используй subagents. Сам агент обязан выполнять финальную проверку, интеграцию и валидацию результата перед ответом пользователю." -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 -elif [[ "$REPO_REF" == refs/pull/*/head ]]; then - PR_ID="$(printf "%s" "$REPO_REF" | sed -nE 's#^refs/pull/([0-9]+)/head$#\1#p')" - PR_URL="" - if [[ "$REPO_URL" == https://github.com/* && -n "$PR_ID" ]]; then - PR_REPO="$(printf "%s" "$REPO_URL" | sed -E 's#^https://github.com/##; s#[.]git$##; s#/*$##')" - if [[ -n "$PR_REPO" ]]; then - PR_URL="https://github.com/$PR_REPO/pull/$PR_ID" - fi - fi - if [[ -n "$PR_ID" && -n "$PR_URL" ]]; then - WORKSPACE_INFO_LINE="Контекст workspace: PR #$PR_ID ($PR_URL)" - elif [[ -n "$PR_ID" ]]; then - WORKSPACE_INFO_LINE="Контекст workspace: PR #$PR_ID" - else - WORKSPACE_INFO_LINE="Контекст workspace: pull request ($REPO_REF)" - fi -fi -MANAGED_START="" -MANAGED_END="" -if [[ ! -f "$AGENTS_PATH" ]]; then - MANAGED_BLOCK="$(cat < "$AGENTS_PATH" -Ты автономный агент, который имеет полностью все права управления контейнером. У тебя есть доступ к командам sudo, gh, codex, opencode, oh-my-opencode, sshpass, git, node, pnpm и всем остальным другим. Проекты с которыми идёт работа лежат по пути ~ -$MANAGED_BLOCK -Если ты видишь файлы AGENTS.md внутри проекта, ты обязан их читать и соблюдать инструкции. -EOF - chown 1000:1000 "$AGENTS_PATH" || true -fi -if [[ -f "$AGENTS_PATH" ]]; then - 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' \ - -e '/^Для решения задач обязательно используй subagents[.]/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` - -export const renderEntrypointAgentsNotice = (config: TemplateConfig): string => - entrypointAgentsNoticeTemplate.replaceAll("__CODEX_HOME__", config.codexHome).replaceAll( - "__SSH_USER__", - config.sshUser - ).replaceAll("__TARGET_DIR__", config.targetDir) diff --git a/packages/lib/src/core/templates-entrypoint/dns-repair.ts b/packages/lib/src/core/templates-entrypoint/dns-repair.ts index b4a44ead..d5e52b80 100644 --- a/packages/lib/src/core/templates-entrypoint/dns-repair.ts +++ b/packages/lib/src/core/templates-entrypoint/dns-repair.ts @@ -10,7 +10,7 @@ // INVARIANT: after execution, at least one nameserver in /etc/resolv.conf resolves external domains // COMPLEXITY: O(1) per probe attempt, O(max_attempts) worst case export const renderEntrypointDnsRepair = (): string => - `# 0) Ensure DNS resolution works; repair /etc/resolv.conf if Docker DNS is broken + String.raw`# 0) Ensure DNS resolution works; repair /etc/resolv.conf if Docker DNS is broken docker_git_repair_dns() { local test_domain="github.com" local resolv="/etc/resolv.conf" @@ -32,7 +32,7 @@ docker_git_repair_dns() { if [[ "$has_external" -eq 0 ]]; then for ns in $fallback_dns; do - printf "nameserver %s\\n" "$ns" >> "$resolv" + printf "nameserver %s\n" "$ns" >> "$resolv" done echo "[dns-repair] appended fallback nameservers to $resolv" fi diff --git a/packages/lib/src/core/templates-entrypoint/nested-docker-git.ts b/packages/lib/src/core/templates-entrypoint/nested-docker-git.ts index 996f441f..4e8cdf60 100644 --- a/packages/lib/src/core/templates-entrypoint/nested-docker-git.ts +++ b/packages/lib/src/core/templates-entrypoint/nested-docker-git.ts @@ -4,28 +4,109 @@ const entrypointDockerGitBootstrapTemplate = String .raw`# Bootstrap ~/.docker-git for nested docker-git usage inside this container. DOCKER_GIT_HOME="/home/__SSH_USER__/.docker-git" DOCKER_GIT_AUTH_DIR="$DOCKER_GIT_HOME/.orch/auth/codex" +DOCKER_GIT_CLAUDE_AUTH_DIR="$DOCKER_GIT_HOME/.orch/auth/claude" 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" +BOOTSTRAP_ROOT="/opt/docker-git/bootstrap" +BOOTSTRAP_SOURCE_ROOT="$BOOTSTRAP_ROOT/source" +BOOTSTRAP_AUTH_KEYS="$BOOTSTRAP_SOURCE_ROOT/authorized-keys/__AUTHORIZED_KEYS_BASENAME__" +BOOTSTRAP_CODEX_AUTH_DIR="$BOOTSTRAP_SOURCE_ROOT/project-auth/codex" +BOOTSTRAP_CODEX_SHARED_AUTH_DIR="$BOOTSTRAP_SOURCE_ROOT/shared-auth/codex" +BOOTSTRAP_CLAUDE_AUTH_DIR="$BOOTSTRAP_SOURCE_ROOT/project-auth/claude" +BOOTSTRAP_ENV_GLOBAL="$BOOTSTRAP_SOURCE_ROOT/env-global/__ENV_GLOBAL_BASENAME__" +BOOTSTRAP_ENV_PROJECT="$BOOTSTRAP_SOURCE_ROOT/env-project/__ENV_PROJECT_BASENAME__" -mkdir -p "$DOCKER_GIT_AUTH_DIR" "$DOCKER_GIT_ENV_DIR" "$DOCKER_GIT_HOME/.orch/auth/gh" +mkdir -p "$DOCKER_GIT_AUTH_DIR" "$DOCKER_GIT_CLAUDE_AUTH_DIR" "$DOCKER_GIT_ENV_DIR" "$DOCKER_GIT_HOME/.orch/auth/gh" -if [[ -f "/home/__SSH_USER__/.ssh/authorized_keys" ]]; then +sync_file_if_present() { + local source="$1" + local target="$2" + if [[ ! -f "$source" ]]; then + return 1 + fi + mkdir -p "$(dirname "$target")" + cp "$source" "$target" + return 0 +} + +sync_file_or_remove() { + local source="$1" + local target="$2" + if [[ -f "$source" ]]; then + sync_file_if_present "$source" "$target" + return 0 + fi + rm -f "$target" || true + return 1 +} + +sync_dir_entries() { + local source="$1" + local target="$2" + if [[ ! -d "$source" ]]; then + return 0 + fi + mkdir -p "$target" + ( + cd "$source" + find . -mindepth 1 -print + ) | while IFS= read -r entry; do + local source_entry="$source/$entry" + local target_entry="$target/$entry" + if [[ -d "$source_entry" ]]; then + mkdir -p "$target_entry" + elif [[ -f "$source_entry" ]]; then + mkdir -p "$(dirname "$target_entry")" + cp "$source_entry" "$target_entry" + fi + done +} + +sync_labeled_auth_files() { + local source_root="$1" + local target_root="$2" + + sync_file_or_remove "$source_root/auth.json" "$target_root/auth.json" || true + + if [[ -d "$source_root" ]]; then + ( + cd "$source_root" + find . -mindepth 1 -maxdepth 1 -type d -print + ) | while IFS= read -r entry; do + sync_file_or_remove "$source_root/$entry/auth.json" "$target_root/$entry/auth.json" || true + done + fi + + if [[ -d "$target_root" ]]; then + ( + cd "$target_root" + find . -mindepth 1 -maxdepth 1 -type d -print + ) | while IFS= read -r entry; do + if [[ ! -d "$source_root/$entry" ]]; then + rm -f "$target_root/$entry/auth.json" || true + fi + done + fi +} + +if [[ ! -f "$DOCKER_GIT_AUTH_KEYS" && -f "/home/__SSH_USER__/.ssh/authorized_keys" ]]; then cp "/home/__SSH_USER__/.ssh/authorized_keys" "$DOCKER_GIT_AUTH_KEYS" -elif [[ -f /authorized_keys ]]; then - cp /authorized_keys "$DOCKER_GIT_AUTH_KEYS" fi +sync_file_if_present "$BOOTSTRAP_AUTH_KEYS" "$DOCKER_GIT_AUTH_KEYS" || true if [[ -f "$DOCKER_GIT_AUTH_KEYS" ]]; then chmod 600 "$DOCKER_GIT_AUTH_KEYS" || true fi +sync_file_if_present "$BOOTSTRAP_ENV_GLOBAL" "$DOCKER_GIT_ENV_GLOBAL" || true if [[ ! -f "$DOCKER_GIT_ENV_GLOBAL" ]]; then cat <<'EOF' > "$DOCKER_GIT_ENV_GLOBAL" # docker-git env # KEY=value EOF fi +sync_file_if_present "$BOOTSTRAP_ENV_PROJECT" "$DOCKER_GIT_ENV_PROJECT" || true if [[ ! -f "$DOCKER_GIT_ENV_PROJECT" ]]; then cat <<'EOF' > "$DOCKER_GIT_ENV_PROJECT" # docker-git project env defaults @@ -49,6 +130,46 @@ upsert_env_var() { mv "$tmp" "$file" } +docker_git_export_env_if_unset() { + local key="$1" + local value="$2" + + if [[ -n "${"$"}{!key+x}" ]]; then + docker_git_upsert_ssh_env "$key" "${"$"}{!key}" + return 0 + fi + + export "$key=$value" + docker_git_upsert_ssh_env "$key" "$value" + return 0 +} + +docker_git_load_env_file() { + local file="$1" + if [[ ! -f "$file" ]]; then + return 0 + fi + + while IFS= read -r line || [[ -n "$line" ]]; do + case "$line" in + ""|\#*) + continue + ;; + esac + if [[ "$line" != *=* ]]; then + continue + fi + + local key="${"$"}{line%%=*}" + local value="${"$"}{line#*=}" + if [[ ! "$key" =~ ^[A-Za-z_][A-Za-z0-9_]*$ ]]; then + continue + fi + + docker_git_export_env_if_unset "$key" "$value" + done < "$file" +} + copy_if_distinct_file() { local source="$1" local target="$2" @@ -66,6 +187,10 @@ copy_if_distinct_file() { return 0 } +sync_dir_entries "$BOOTSTRAP_CODEX_AUTH_DIR" "$DOCKER_GIT_AUTH_DIR" +sync_labeled_auth_files "$BOOTSTRAP_CODEX_SHARED_AUTH_DIR" "$DOCKER_GIT_AUTH_DIR" +sync_dir_entries "$BOOTSTRAP_CLAUDE_AUTH_DIR" "$DOCKER_GIT_CLAUDE_AUTH_DIR" + if [[ -n "$GH_TOKEN" ]]; then upsert_env_var "$DOCKER_GIT_ENV_GLOBAL" "GH_TOKEN" "$GH_TOKEN" fi @@ -75,6 +200,21 @@ elif [[ -n "$GH_TOKEN" ]]; then upsert_env_var "$DOCKER_GIT_ENV_GLOBAL" "GITHUB_TOKEN" "$GH_TOKEN" fi +docker_git_load_env_file "$DOCKER_GIT_ENV_GLOBAL" +docker_git_load_env_file "$DOCKER_GIT_ENV_PROJECT" +if [[ -z "$GIT_AUTH_TOKEN" ]]; then + GIT_AUTH_TOKEN="$GITHUB_TOKEN" +fi +if [[ -z "$GIT_AUTH_TOKEN" ]]; then + GIT_AUTH_TOKEN="$GH_TOKEN" +fi +if [[ -z "$GH_TOKEN" ]]; then + GH_TOKEN="$GIT_AUTH_TOKEN" +fi +if [[ -z "$GITHUB_TOKEN" ]]; then + GITHUB_TOKEN="$GH_TOKEN" +fi + SOURCE_CODEX_CONFIG="__CODEX_HOME__/config.toml" copy_if_distinct_file "$SOURCE_CODEX_CONFIG" "$DOCKER_GIT_AUTH_DIR/config.toml" || true @@ -94,4 +234,13 @@ chown -R 1000:1000 "$DOCKER_GIT_HOME" || true` export const renderEntrypointDockerGitBootstrap = (config: TemplateConfig): string => entrypointDockerGitBootstrapTemplate .replaceAll("__SSH_USER__", config.sshUser) + .replaceAll( + "__AUTHORIZED_KEYS_BASENAME__", + config.authorizedKeysPath.replaceAll("\\", "/").split("/").at(-1) ?? "authorized_keys" + ) + .replaceAll("__ENV_GLOBAL_BASENAME__", config.envGlobalPath.replaceAll("\\", "/").split("/").at(-1) ?? "global.env") + .replaceAll( + "__ENV_PROJECT_BASENAME__", + config.envProjectPath.replaceAll("\\", "/").split("/").at(-1) ?? "project.env" + ) .replaceAll("__CODEX_HOME__", config.codexHome) diff --git a/packages/lib/src/core/templates.ts b/packages/lib/src/core/templates.ts index 356215cb..27d0f281 100644 --- a/packages/lib/src/core/templates.ts +++ b/packages/lib/src/core/templates.ts @@ -11,10 +11,12 @@ export type FileSpec = const renderGitignore = (): string => `# 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. +# NOTE: bootstrap secrets stay local-only and should not be committed. # Volatile Codex artifacts (do not commit) +authorized_keys +.orch/auth/codex/auth.json +.orch/auth/claude/ .orch/auth/codex/log/ .orch/auth/codex/tmp/ .orch/auth/codex/sessions/ @@ -23,8 +25,14 @@ const renderGitignore = (): string => const renderDockerignore = (): string => `# docker-git build context -.orch/ authorized_keys +.orch/env/ +.orch/auth/codex/ +.orch/auth/claude/ +.orch/auth/codex/log/ +.orch/auth/codex/tmp/ +.orch/auth/codex/sessions/ +.orch/auth/codex/models_cache.json ` const renderConfigJson = (config: TemplateConfig): string => @@ -60,6 +68,7 @@ export const planFiles = ( { _tag: "File", relativePath: ".gitignore", contents: renderGitignore() }, ...maybePlaywrightFiles, { _tag: "Dir", relativePath: ".orch/auth/codex" }, + { _tag: "Dir", relativePath: ".orch/auth/claude" }, { _tag: "Dir", relativePath: ".orch/env" } ] } diff --git a/packages/lib/src/core/templates/docker-compose.ts b/packages/lib/src/core/templates/docker-compose.ts index 473bd613..2548bf84 100644 --- a/packages/lib/src/core/templates/docker-compose.ts +++ b/packages/lib/src/core/templates/docker-compose.ts @@ -1,4 +1,10 @@ -import { resolveComposeNetworkName, type TemplateConfig } from "../domain.js" +import { + dockerGitSharedCacheVolumeName, + dockerGitSharedCodexVolumeName, + resolveComposeNetworkName, + resolveProjectBootstrapVolumeName, + type TemplateConfig +} from "../domain.js" import type { ResolvedComposeResourceLimits } from "../resource-limits.js" type ComposeFragments = { @@ -13,6 +19,7 @@ type ComposeFragments = { readonly maybePlaywrightEnv: string readonly maybeBrowserService: string readonly maybeBrowserVolume: string + readonly maybeBootstrapMounts: string readonly forkRepoUrl: string } @@ -21,6 +28,10 @@ type PlaywrightFragments = Pick< "maybeDependsOn" | "maybePlaywrightEnv" | "maybeBrowserService" | "maybeBrowserVolume" > +const sharedCodexVolumeKey = "docker_git_shared_codex" +const sharedCacheVolumeKey = "docker_git_shared_cache" +const bootstrapVolumeKey = "docker_git_bootstrap" + const renderGitTokenLabelEnv = (gitTokenLabel: string): string => gitTokenLabel.length > 0 ? ` GITHUB_AUTH_LABEL: "${gitTokenLabel}"\n GIT_AUTH_LABEL: "${gitTokenLabel}"\n` @@ -46,17 +57,13 @@ const renderAgentAutoEnv = (agentAuto: boolean | undefined): string => ? ` AGENT_AUTO: "1"\n` : "" -const renderProjectsRootHostMount = (projectsRoot: string): string => - `\${DOCKER_GIT_PROJECTS_ROOT_HOST:-${projectsRoot}}` - -const renderSharedCodexHostMount = (projectsRoot: string): string => - `\${DOCKER_GIT_PROJECTS_ROOT_HOST:-${projectsRoot}}/.orch/auth/codex` - const renderResourceLimits = (resourceLimits: ResolvedComposeResourceLimits | undefined): string => resourceLimits === undefined ? "" : ` cpus: ${resourceLimits.cpuLimit}\n mem_limit: "${resourceLimits.ramLimit}"\n memswap_limit: "${resourceLimits.ramLimit}"\n` +const renderBootstrapMounts = (): string => ` - ${bootstrapVolumeKey}:/opt/docker-git/bootstrap/source:ro` + const buildPlaywrightFragments = ( config: TemplateConfig, networkName: string, @@ -85,7 +92,7 @@ const buildPlaywrightFragments = ( `\n ${browserServiceName}:\n build:\n context: .\n dockerfile: ${browserDockerfile}\n container_name: ${browserContainerName}\n restart: unless-stopped\n${ renderResourceLimits(resourceLimits) } environment:\n VNC_NOPW: "1"\n shm_size: "2gb"\n expose:\n - "9223"\n dns:\n - 8.8.8.8\n - 8.8.4.4\n - 1.1.1.1\n volumes:\n - ${browserVolumeName}:/data\n networks:\n - ${networkName}\n`, - maybeBrowserVolume: ` ${browserVolumeName}:\n` + maybeBrowserVolume: ` ${browserVolumeName}:` } } @@ -118,6 +125,7 @@ const buildComposeFragments = ( maybePlaywrightEnv: playwright.maybePlaywrightEnv, maybeBrowserService: playwright.maybeBrowserService, maybeBrowserVolume: playwright.maybeBrowserVolume, + maybeBootstrapMounts: renderBootstrapMounts(), forkRepoUrl } } @@ -141,17 +149,14 @@ ${fragments.maybeCodexAuthLabelEnv} # Optional Codex account label selector ${fragments.maybeClaudeAuthLabelEnv}${fragments.maybeAgentModeEnv}${fragments.maybeAgentAutoEnv} # Optional Claude account label selector (maps to CLAUDE_AUTH_LABEL) TARGET_DIR: "${config.targetDir}" CODEX_HOME: "${config.codexHome}" -${fragments.maybePlaywrightEnv}${fragments.maybeDependsOn} env_file: - - ${config.envGlobalPath} - - ${config.envProjectPath} +${fragments.maybePlaywrightEnv}${fragments.maybeDependsOn} # bootstrap auth/env arrives through docker_git_bootstrap ports: - "127.0.0.1:${config.sshPort}:22" ${renderResourceLimits(resourceLimits)} volumes: - ${config.volumeName}:/home/${config.sshUser} - - ${renderProjectsRootHostMount(config.dockerGitPath)}:/home/${config.sshUser}/.docker-git - - ${config.authorizedKeysPath}:/authorized_keys:ro - - ${config.codexAuthPath}:${config.codexHome} - - ${renderSharedCodexHostMount(config.dockerGitPath)}:${config.codexHome}-shared + - ${sharedCacheVolumeKey}:/home/${config.sshUser}/.docker-git/.cache + - ${sharedCodexVolumeKey}:${config.codexHome}-shared +${fragments.maybeBootstrapMounts} - /var/run/docker.sock:/var/run/docker.sock dns: - 8.8.8.8 @@ -174,9 +179,19 @@ const renderComposeNetworks = ( driver: bridge` const renderComposeVolumes = (config: TemplateConfig, maybeBrowserVolume: string): string => - `volumes: - ${config.volumeName}: -${maybeBrowserVolume}` + [ + "volumes:", + ` ${config.volumeName}:`, + ` ${bootstrapVolumeKey}:`, + ` name: ${resolveProjectBootstrapVolumeName(config)}`, + ` ${sharedCacheVolumeKey}:`, + " external: true", + ` name: ${dockerGitSharedCacheVolumeName}`, + ` ${sharedCodexVolumeKey}:`, + " external: true", + ` name: ${dockerGitSharedCodexVolumeName}`, + maybeBrowserVolume + ].filter((entry) => entry.length > 0).join("\n") export const renderDockerCompose = ( config: TemplateConfig, diff --git a/packages/lib/src/core/templates/dockerfile.ts b/packages/lib/src/core/templates/dockerfile.ts index 9ff9c7ed..40f12bdd 100644 --- a/packages/lib/src/core/templates/dockerfile.ts +++ b/packages/lib/src/core/templates/dockerfile.ts @@ -69,11 +69,31 @@ const openCodeVersion = "1.2.27" const renderDockerfileOpenCode = (): string => `# Tooling: OpenCode (binary) RUN set -eu; \ + ARCH="$(uname -m)"; \ + case "$ARCH" in \ + x86_64|amd64) OPENCODE_ARCH="x64" ;; \ + aarch64|arm64) OPENCODE_ARCH="arm64" ;; \ + *) echo "Unsupported arch for OpenCode: $ARCH" >&2; exit 1 ;; \ + esac; \ + OPENCODE_TARGET="linux-$OPENCODE_ARCH"; \ + if [ "$OPENCODE_ARCH" = "x64" ] && ! grep -qwi avx2 /proc/cpuinfo 2>/dev/null; then \ + OPENCODE_TARGET="$OPENCODE_TARGET-baseline"; \ + fi; \ + if [ -f /etc/alpine-release ] || { command -v ldd >/dev/null 2>&1 && ldd --version 2>&1 | grep -qi musl; }; then \ + OPENCODE_TARGET="$OPENCODE_TARGET-musl"; \ + fi; \ + OPENCODE_ARCHIVE="opencode-$OPENCODE_TARGET.tar.gz"; \ + mkdir -p /usr/local/.opencode/bin; \ for attempt in 1 2 3 4 5; do \ - if curl -fsSL --retry 5 --retry-all-errors --retry-delay 2 https://opencode.ai/install \ - | HOME=/usr/local bash -s -- --version ${openCodeVersion} --no-modify-path; then \ + tmp_archive="$(mktemp)"; \ + if curl -fsSL --retry 5 --retry-all-errors --retry-delay 2 \ + "https://github.com/anomalyco/opencode/releases/download/v${openCodeVersion}/$OPENCODE_ARCHIVE" \ + -o "$tmp_archive" \ + && tar -xzf "$tmp_archive" -C /usr/local/.opencode/bin opencode; then \ + rm -f "$tmp_archive"; \ exit 0; \ fi; \ + rm -f "$tmp_archive"; \ echo "opencode install attempt \${attempt} failed; retrying..." >&2; \ sleep $((attempt * 2)); \ done; \ @@ -226,6 +246,14 @@ RUN mkdir -p ${config.targetDir} \ && chown -R 1000:1000 /home/${config.sshUser} \ && if [ "${config.targetDir}" != "/" ]; then chown -R 1000:1000 "${config.targetDir}"; fi +RUN mkdir -p /opt/docker-git/bootstrap/.orch/auth/codex \ + /opt/docker-git/bootstrap/.orch/auth/codex-shared \ + /opt/docker-git/bootstrap/.orch/auth/claude \ + /opt/docker-git/bootstrap/.orch/env \ + && touch /opt/docker-git/bootstrap/authorized_keys \ + /opt/docker-git/bootstrap/.orch/env/global.env \ + /opt/docker-git/bootstrap/.orch/env/project.env + COPY entrypoint.sh /entrypoint.sh RUN sed -i 's/\\r$//' /entrypoint.sh && chmod +x /entrypoint.sh diff --git a/packages/lib/src/shell/docker-compose-env.ts b/packages/lib/src/shell/docker-compose-env.ts index a2743ad4..69336790 100644 --- a/packages/lib/src/shell/docker-compose-env.ts +++ b/packages/lib/src/shell/docker-compose-env.ts @@ -24,10 +24,18 @@ export const resolveDockerComposeEnv = ( ): Effect.Effect>, never, CommandExecutor.CommandExecutor> => Effect.gen(function*(_) { const projectsRoot = resolveProjectsRootCandidate() + const remappedProjectDir = yield* _(resolveDockerVolumeHostPath(cwd, cwd)) if (projectsRoot === null) { - return {} + return remappedProjectDir === cwd ? {} : { DOCKER_GIT_PROJECT_DIR_HOST: remappedProjectDir } } const remappedProjectsRoot = yield* _(resolveDockerVolumeHostPath(cwd, projectsRoot)) - return remappedProjectsRoot === projectsRoot ? {} : { DOCKER_GIT_PROJECTS_ROOT_HOST: remappedProjectsRoot } + const env: Record = {} + if (remappedProjectsRoot !== projectsRoot) { + env["DOCKER_GIT_PROJECTS_ROOT_HOST"] = remappedProjectsRoot + } + if (remappedProjectDir !== cwd) { + env["DOCKER_GIT_PROJECT_DIR_HOST"] = remappedProjectDir + } + return env }) diff --git a/packages/lib/src/shell/docker-volume.ts b/packages/lib/src/shell/docker-volume.ts new file mode 100644 index 00000000..c81d0c07 --- /dev/null +++ b/packages/lib/src/shell/docker-volume.ts @@ -0,0 +1,49 @@ +import type * as CommandExecutor from "@effect/platform/CommandExecutor" +import { ExitCode } from "@effect/platform/CommandExecutor" +import type { PlatformError } from "@effect/platform/Error" +import type { Effect } from "effect" + +import { runCommandWithExitCodes } from "./command-runner.js" +import { DockerCommandError } from "./errors.js" + +const escapedSingleQuote = String.raw`'\''` + +const shellEscape = (value: string): string => `'${value.replaceAll("'", escapedSingleQuote)}'` + +export const runDockerVolumeCreate = ( + cwd: string, + volumeName: string +): Effect.Effect => + runCommandWithExitCodes({ cwd, command: "docker", args: ["volume", "create", volumeName] }, [Number(ExitCode(0))], ( + exitCode + ) => new DockerCommandError({ exitCode })) + +// CHANGE: replace a Docker volume with staged bootstrap files from the local filesystem +// WHY: controller/API mode must sync auth/env into Docker-managed storage without host bind mounts +// QUOTE(ТЗ): "Поднимается сервер и ты через него можешь общаться с контейнером" +// REF: user-request-2026-03-15-api-controller +// SOURCE: n/a +// FORMAT THEOREM: ∀v,d: seed(v,d) → contents(v)=snapshot(d) +// PURITY: SHELL +// EFFECT: Effect +// INVARIANT: previous bootstrap contents are removed before the new snapshot is extracted +// COMPLEXITY: O(size(sourceDir)) +export const runDockerVolumeReplaceFromDirectory = ( + cwd: string, + volumeName: string, + sourceDir: string +): Effect.Effect => { + const targetVolume = `${volumeName}:/target` + const replaceCommand = shellEscape( + "mkdir -p /target && find /target -mindepth 1 -maxdepth 1 -exec rm -rf -- {} + && tar -xf - -C /target" + ) + const command = `tar -C ${shellEscape(sourceDir)} -cf - . | ` + + `docker run --rm -i -v ${shellEscape(targetVolume)} alpine:3.20 ` + + `sh -euc ${replaceCommand}` + + return runCommandWithExitCodes( + { cwd, command: "bash", args: ["-c", command] }, + [Number(ExitCode(0))], + (exitCode) => new DockerCommandError({ exitCode }) + ) +} diff --git a/packages/lib/src/usecases/actions/docker-up.ts b/packages/lib/src/usecases/actions/docker-up.ts index aadebeb3..9411c673 100644 --- a/packages/lib/src/usecases/actions/docker-up.ts +++ b/packages/lib/src/usecases/actions/docker-up.ts @@ -20,6 +20,7 @@ import { AgentFailedError, CloneFailedError } from "../../shell/errors.js" import { ensureComposeNetworkReady } from "../docker-network-gc.js" import { findSshPrivateKey, resolveAuthorizedKeysPath } from "../path-helpers.js" import { buildSshCommand } from "../projects.js" +import { ensureSharedCodexVolumeReady } from "../shared-volume-seed.js" const maxPortAttempts = 25 const clonePollInterval = Duration.seconds(1) @@ -188,17 +189,19 @@ const runDockerComposeUpByMode = ( projectConfig: CreateCommand["config"], force: boolean, forceEnv: boolean -): Effect.Effect => +): Effect.Effect => Effect.gen(function*(_) { yield* _(ensureComposeNetworkReady(resolvedOutDir, projectConfig)) if (force) { yield* _(Effect.log("Force enabled: wiping docker compose volumes (docker compose down -v)...")) yield* _(runDockerComposeDownVolumes(resolvedOutDir)) + yield* _(ensureSharedCodexVolumeReady(resolvedOutDir, projectConfig)) yield* _(Effect.log("Running: docker compose up -d --build")) yield* _(runDockerComposeUp(resolvedOutDir)) return } + yield* _(ensureSharedCodexVolumeReady(resolvedOutDir, projectConfig)) if (forceEnv) { yield* _(Effect.log("Force env enabled: resetting env defaults and recreating containers (volumes preserved)...")) yield* _(runDockerComposeUpRecreate(resolvedOutDir)) diff --git a/packages/lib/src/usecases/actions/paths.ts b/packages/lib/src/usecases/actions/paths.ts index 5656e1e2..f89ee1c7 100644 --- a/packages/lib/src/usecases/actions/paths.ts +++ b/packages/lib/src/usecases/actions/paths.ts @@ -53,15 +53,15 @@ export const buildProjectConfigs = ( } const projectConfig = { ...resolvedConfig, - dockerGitPath: relativeFromOutDir(globalConfig.dockerGitPath), - authorizedKeysPath: relativeFromOutDir(globalConfig.authorizedKeysPath), + dockerGitPath: "./.docker-git", + authorizedKeysPath: "./authorized_keys", envGlobalPath: "./.orch/env/global.env", envProjectPath: path.isAbsolute(resolvedConfig.envProjectPath) ? relativeFromOutDir(resolvedConfig.envProjectPath) : toPosixPath(resolvedConfig.envProjectPath), // Project-local Codex state (sessions/logs/etc) is kept under .orch. codexAuthPath: "./.orch/auth/codex", - // Shared credentials root is mounted separately; entrypoint links auth.json into CODEX_HOME. + // Keep the global auth source path so runtime can seed the shared Docker volume when containers start. codexSharedAuthPath: relativeFromOutDir(globalConfig.codexSharedAuthPath) } return { globalConfig, projectConfig } diff --git a/packages/lib/src/usecases/actions/prepare-files.ts b/packages/lib/src/usecases/actions/prepare-files.ts index 7d7062fb..d17c2c9a 100644 --- a/packages/lib/src/usecases/actions/prepare-files.ts +++ b/packages/lib/src/usecases/actions/prepare-files.ts @@ -85,9 +85,92 @@ const resolveAuthorizedKeysSource = ( : matchingPublicKey }) +const resolveManagedAuthorizedKeysSource = ( + fs: FileSystem.FileSystem, + path: Path.Path, + baseDir: string, + preferredSource: string, + resolved: string +): Effect.Effect => + Effect.gen(function*(_) { + const preferred = resolvePathFromBase(path, baseDir, preferredSource) + const preferredExists = yield* _(fs.exists(preferred)) + if (preferredExists && preferred !== resolved) { + return preferred + } + + return yield* _(resolveAuthorizedKeysSource(fs, path, process.cwd())) + }) + +const ensureMissingAuthorizedKeysPlaceholder = ( + fs: FileSystem.FileSystem, + path: Path.Path, + resolved: string, + state: ExistingFileState +): Effect.Effect => + Effect.gen(function*(_) { + if (state === "missing") { + yield* _(fs.makeDirectory(path.dirname(resolved), { recursive: true })) + yield* _(fs.writeFileString(resolved, "")) + } + + yield* _( + Effect.logError( + `Authorized keys not found. Create ${resolved} with your public key to enable SSH.` + ) + ) + }) + +const readAuthorizedKeysContents = ( + fs: FileSystem.FileSystem, + source: string +): Effect.Effect => + Effect.gen(function*(_) { + const desiredContents = (yield* _(fs.readFileString(source))).trim() + if (desiredContents.length === 0) { + yield* _(Effect.logWarning(`Authorized keys source ${source} is empty. Skipping SSH key sync.`)) + return null + } + + return desiredContents + }) + +type AuthorizedKeysSyncTarget = { + readonly fs: FileSystem.FileSystem + readonly path: Path.Path + readonly state: ExistingFileState + readonly resolved: string + readonly managedDefaultAuthorizedKeys: string + readonly source: string + readonly desiredContents: string +} + +const syncAuthorizedKeysTarget = ({ + desiredContents, + fs, + managedDefaultAuthorizedKeys, + path, + resolved, + source, + state +}: AuthorizedKeysSyncTarget): Effect.Effect => + Effect.gen(function*(_) { + if (state === "exists") { + if (resolved === managedDefaultAuthorizedKeys) { + yield* _(appendKeyIfMissing(fs, resolved, source, desiredContents)) + } + return + } + + yield* _(fs.makeDirectory(path.dirname(resolved), { recursive: true })) + yield* _(fs.copyFile(source, resolved)) + yield* _(Effect.log(`Authorized keys copied from ${source} to ${resolved}`)) + }) + const ensureAuthorizedKeys = ( baseDir: string, - authorizedKeysPath: string + authorizedKeysPath: string, + preferredSource: string ): Effect.Effect => withFsPathContext(({ fs, path }) => Effect.gen(function*(_) { @@ -102,32 +185,34 @@ const ensureAuthorizedKeys = ( ) ) - const source = yield* _(resolveAuthorizedKeysSource(fs, path, process.cwd())) - if (source === null) { - yield* _( - Effect.logError( - `Authorized keys not found. Create ${resolved} with your public key to enable SSH.` - ) - ) + if (state === "exists" && resolved !== managedDefaultAuthorizedKeys) { return } - const desiredContents = (yield* _(fs.readFileString(source))).trim() - if (desiredContents.length === 0) { - yield* _(Effect.logWarning(`Authorized keys source ${source} is empty. Skipping SSH key sync.`)) + const source = yield* _( + resolveManagedAuthorizedKeysSource(fs, path, baseDir, preferredSource, resolved) + ) + if (source === null) { + yield* _(ensureMissingAuthorizedKeysPlaceholder(fs, path, resolved, state)) return } - if (state === "exists") { - if (resolved === managedDefaultAuthorizedKeys) { - yield* _(appendKeyIfMissing(fs, resolved, source, desiredContents)) - } + const desiredContents = yield* _(readAuthorizedKeysContents(fs, source)) + if (desiredContents === null) { return } - yield* _(fs.makeDirectory(path.dirname(resolved), { recursive: true })) - yield* _(fs.copyFile(source, resolved)) - yield* _(Effect.log(`Authorized keys copied from ${source} to ${resolved}`)) + yield* _( + syncAuthorizedKeysTarget({ + fs, + path, + state, + resolved, + managedDefaultAuthorizedKeys, + source, + desiredContents + }) + ) }) ) @@ -189,7 +274,7 @@ export const prepareProjectFiles = ( const createdFiles = yield* _( writeProjectFiles(resolvedOutDir, projectConfig, rewriteManagedFiles) ) - yield* _(ensureAuthorizedKeys(resolvedOutDir, projectConfig.authorizedKeysPath)) + yield* _(ensureAuthorizedKeys(resolvedOutDir, projectConfig.authorizedKeysPath, globalConfig.authorizedKeysPath)) yield* _(ensureEnvFile(resolvedOutDir, projectConfig.envGlobalPath, defaultGlobalEnvContents)) yield* _( ensureEnvFile( @@ -209,12 +294,14 @@ export const prepareProjectFiles = ( source: { envGlobalPath: globalConfig.envGlobalPath, envProjectPath: globalConfig.envProjectPath, - codexAuthPath: globalConfig.codexAuthPath + codexAuthPath: globalConfig.codexAuthPath, + claudeAuthPath: globalClaudeAuthPath }, target: { envGlobalPath: projectConfig.envGlobalPath, envProjectPath: projectConfig.envProjectPath, - codexAuthPath: projectConfig.codexAuthPath + codexAuthPath: projectConfig.codexAuthPath, + claudeAuthPath: "./.orch/auth/claude" } }) ) diff --git a/packages/lib/src/usecases/auth-copy.ts b/packages/lib/src/usecases/auth-copy.ts index 543514ed..f2cba4c0 100644 --- a/packages/lib/src/usecases/auth-copy.ts +++ b/packages/lib/src/usecases/auth-copy.ts @@ -35,6 +35,23 @@ type CodexFileCopySpec = { readonly label: string } +const sourceDirReady = ( + fs: FileSystem.FileSystem, + sourceDir: string, + targetDir: string +): Effect.Effect => + Effect.gen(function*(_) { + if (sourceDir === targetDir) { + return false + } + const sourceExists = yield* _(fs.exists(sourceDir)) + if (!sourceExists) { + return false + } + const sourceInfo = yield* _(fs.stat(sourceDir)) + return sourceInfo.type === "Directory" + }) + export const copyCodexFile = ( fs: FileSystem.FileSystem, path: Path.Path, @@ -63,15 +80,8 @@ export const copyDirIfEmpty = ( label: string ): Effect.Effect => Effect.gen(function*(_) { - if (sourceDir === targetDir) { - return - } - const sourceExists = yield* _(fs.exists(sourceDir)) - if (!sourceExists) { - return - } - const sourceInfo = yield* _(fs.stat(sourceDir)) - if (sourceInfo.type !== "Directory") { + const ready = yield* _(sourceDirReady(fs, sourceDir, targetDir)) + if (!ready) { return } yield* _(fs.makeDirectory(targetDir, { recursive: true })) @@ -82,3 +92,50 @@ export const copyDirIfEmpty = ( yield* _(copyDirRecursive(fs, path, sourceDir, targetDir)) yield* _(Effect.log(`Copied ${label} from ${sourceDir} to ${targetDir}`)) }) + +const copyMissingRecursive = ( + fs: FileSystem.FileSystem, + path: Path.Path, + sourcePath: string, + targetPath: string +): Effect.Effect => + Effect.gen(function*(_) { + const sourceInfo = yield* _(fs.stat(sourcePath)) + if (sourceInfo.type === "Directory") { + yield* _(fs.makeDirectory(targetPath, { recursive: true })) + const entries = yield* _(fs.readDirectory(sourcePath)) + for (const entry of entries) { + yield* _(copyMissingRecursive(fs, path, path.join(sourcePath, entry), path.join(targetPath, entry))) + } + return + } + + if (sourceInfo.type !== "File") { + return + } + + const targetExists = yield* _(fs.exists(targetPath)) + if (targetExists) { + return + } + + yield* _(fs.makeDirectory(path.dirname(targetPath), { recursive: true })) + yield* _(fs.copyFile(sourcePath, targetPath)) + }) + +export const copyDirMissingEntries = ( + fs: FileSystem.FileSystem, + path: Path.Path, + sourceDir: string, + targetDir: string, + label: string +): Effect.Effect => + Effect.gen(function*(_) { + const ready = yield* _(sourceDirReady(fs, sourceDir, targetDir)) + if (!ready) { + return + } + + yield* _(copyMissingRecursive(fs, path, sourceDir, targetDir)) + yield* _(Effect.log(`Seeded missing ${label} entries from ${sourceDir} to ${targetDir}`)) + }) diff --git a/packages/lib/src/usecases/auth-sync-helpers.ts b/packages/lib/src/usecases/auth-sync-helpers.ts index b3cc994a..300368ea 100644 --- a/packages/lib/src/usecases/auth-sync-helpers.ts +++ b/packages/lib/src/usecases/auth-sync-helpers.ts @@ -148,6 +148,7 @@ export type AuthPaths = { readonly envGlobalPath: string readonly envProjectPath: string readonly codexAuthPath: string + readonly claudeAuthPath: string } export type AuthSyncSpec = { @@ -159,6 +160,5 @@ export type AuthSyncSpec = { export type LegacyOrchPaths = AuthPaths & { readonly ghAuthPath: string - readonly claudeAuthPath: string readonly geminiAuthPath?: string } diff --git a/packages/lib/src/usecases/auth-sync.ts b/packages/lib/src/usecases/auth-sync.ts index d4824006..9748ad35 100644 --- a/packages/lib/src/usecases/auth-sync.ts +++ b/packages/lib/src/usecases/auth-sync.ts @@ -3,7 +3,7 @@ import type * as FileSystem from "@effect/platform/FileSystem" import type * as Path from "@effect/platform/Path" import { Effect } from "effect" -import { copyCodexFile, copyDirIfEmpty } from "./auth-copy.js" +import { copyCodexFile, copyDirIfEmpty, copyDirMissingEntries } from "./auth-copy.js" import { type AuthSyncSpec, defaultCodexConfig, @@ -164,33 +164,24 @@ export const syncAuthArtifacts = ( const targetProject = resolvePathFromBase(path, spec.targetBase, spec.target.envProjectPath) const sourceCodex = resolvePathFromBase(path, spec.sourceBase, spec.source.codexAuthPath) const targetCodex = resolvePathFromBase(path, spec.targetBase, spec.target.codexAuthPath) + const sourceClaude = resolvePathFromBase(path, spec.sourceBase, spec.source.claudeAuthPath) + const targetClaude = resolvePathFromBase(path, spec.targetBase, spec.target.claudeAuthPath) yield* _(copyFileIfNeeded(sourceGlobal, targetGlobal)) yield* _(syncGithubTokenKeysInFile(sourceGlobal, targetGlobal)) yield* _(copyFileIfNeeded(sourceProject, targetProject)) yield* _(fs.makeDirectory(targetCodex, { recursive: true })) if (sourceCodex !== targetCodex) { - const sourceExists = yield* _(fs.exists(sourceCodex)) - if (sourceExists) { - const sourceInfo = yield* _(fs.stat(sourceCodex)) - if (sourceInfo.type === "Directory") { - const targetExists = yield* _(fs.exists(targetCodex)) - if (!targetExists) { - yield* _(fs.makeDirectory(targetCodex, { recursive: true })) - } - // NOTE: We intentionally do not copy auth.json. - // ChatGPT refresh tokens are rotating; copying them into each project causes refresh_token_reused. - yield* _( - copyCodexFile(fs, path, { - sourceDir: sourceCodex, - targetDir: targetCodex, - fileName: "config.toml", - label: "config" - }) - ) - } - } + yield* _( + copyCodexFile(fs, path, { + sourceDir: sourceCodex, + targetDir: targetCodex, + fileName: "config.toml", + label: "config" + }) + ) } + yield* _(copyDirMissingEntries(fs, path, sourceClaude, targetClaude, "Claude auth bootstrap")) }) ) diff --git a/packages/lib/src/usecases/projects-up.ts b/packages/lib/src/usecases/projects-up.ts index ea80f4dd..5d424183 100644 --- a/packages/lib/src/usecases/projects-up.ts +++ b/packages/lib/src/usecases/projects-up.ts @@ -26,6 +26,7 @@ import { ensureComposeNetworkReady } from "./docker-network-gc.js" import { loadReservedPorts, selectAvailablePort } from "./ports-reserve.js" import { parseComposePsOutput } from "./projects-core.js" import { resolveTemplateResourceLimits } from "./resource-limits.js" +import { ensureSharedCodexVolumeReady } from "./shared-volume-seed.js" const maxPortAttempts = 25 @@ -191,6 +192,7 @@ export const runDockerComposeUpWithPortCheck = ( // Keep generated templates in sync with the running CLI version. yield* _(syncManagedProjectFiles(projectDir, resolvedTemplate)) yield* _(ensureComposeNetworkReady(projectDir, resolvedTemplate)) + yield* _(ensureSharedCodexVolumeReady(projectDir, resolvedTemplate)) yield* _(runDockerComposeUp(projectDir)) yield* _(ensureClaudeCliReady(projectDir, resolvedTemplate.containerName)) diff --git a/packages/lib/src/usecases/projects.ts b/packages/lib/src/usecases/projects.ts index 0bad9f49..060f8b3a 100644 --- a/packages/lib/src/usecases/projects.ts +++ b/packages/lib/src/usecases/projects.ts @@ -11,3 +11,4 @@ export { deleteDockerGitProject } from "./projects-delete.js" export { downAllDockerGitProjects } from "./projects-down.js" export { listProjectItems, listProjects, listProjectSummaries, listRunningProjectItems } from "./projects-list.js" export { connectProjectSsh, connectProjectSshWithUp, listProjectStatus } from "./projects-ssh.js" +export { runDockerComposeUpWithPortCheck } from "./projects-up.js" diff --git a/packages/lib/src/usecases/shared-volume-seed.ts b/packages/lib/src/usecases/shared-volume-seed.ts new file mode 100644 index 00000000..cc9e24a6 --- /dev/null +++ b/packages/lib/src/usecases/shared-volume-seed.ts @@ -0,0 +1,216 @@ +import type { CommandExecutor } from "@effect/platform/CommandExecutor" +import type { PlatformError } from "@effect/platform/Error" +import * as FileSystem from "@effect/platform/FileSystem" +import * as Path from "@effect/platform/Path" +import { Effect } from "effect" + +import { + dockerGitSharedCacheVolumeName, + dockerGitSharedCodexVolumeName, + resolveProjectBootstrapVolumeName, + type TemplateConfig +} from "../core/domain.js" +import { runDockerVolumeCreate, runDockerVolumeReplaceFromDirectory } from "../shell/docker-volume.js" +import type { DockerCommandError } from "../shell/errors.js" + +type SharedVolumeSeedEnvironment = CommandExecutor | FileSystem.FileSystem | Path.Path + +const resolvePathFromBase = ( + path: Path.Path, + baseDir: string, + targetPath: string +): string => (path.isAbsolute(targetPath) ? targetPath : path.resolve(baseDir, targetPath)) + +const copyDirRecursive = ( + fs: FileSystem.FileSystem, + path: Path.Path, + sourceDir: string, + targetDir: string +): Effect.Effect => + Effect.gen(function*(_) { + const exists = yield* _(fs.exists(sourceDir)) + if (!exists) { + return + } + const info = yield* _(fs.stat(sourceDir)) + if (info.type !== "Directory") { + return + } + + yield* _(fs.makeDirectory(targetDir, { recursive: true })) + const entries = yield* _(fs.readDirectory(sourceDir)) + for (const entry of entries) { + const sourceEntry = path.join(sourceDir, entry) + const targetEntry = path.join(targetDir, entry) + const entryInfo = yield* _(fs.stat(sourceEntry)) + if (entryInfo.type === "Directory") { + yield* _(copyDirRecursive(fs, path, sourceEntry, targetEntry)) + } else if (entryInfo.type === "File") { + yield* _(fs.makeDirectory(path.dirname(targetEntry), { recursive: true })) + yield* _(fs.copyFile(sourceEntry, targetEntry)) + } + } + }) + +const copyFileIfPresent = ( + fs: FileSystem.FileSystem, + path: Path.Path, + sourcePath: string, + targetPath: string +): Effect.Effect => + Effect.gen(function*(_) { + const exists = yield* _(fs.exists(sourcePath)) + if (!exists) { + return + } + const info = yield* _(fs.stat(sourcePath)) + if (info.type !== "File") { + return + } + yield* _(fs.makeDirectory(path.dirname(targetPath), { recursive: true })) + yield* _(fs.copyFile(sourcePath, targetPath)) + }) + +type BootstrapSeedConfig = Pick< + TemplateConfig, + "authorizedKeysPath" | "envGlobalPath" | "envProjectPath" | "codexAuthPath" | "codexSharedAuthPath" +> + +type BootstrapSnapshotSources = { + readonly authorizedKeysSource: string + readonly envGlobalSource: string + readonly envProjectSource: string + readonly codexAuthSource: string + readonly codexSharedAuthSource: string + readonly claudeAuthSource: string +} + +type BootstrapSnapshotTargets = { + readonly authorizedKeysTarget: string + readonly envGlobalTarget: string + readonly envProjectTarget: string + readonly projectCodexTarget: string + readonly projectClaudeTarget: string + readonly sharedCodexTarget: string +} + +const resolveBootstrapSnapshotSources = ( + path: Path.Path, + projectDir: string, + config: BootstrapSeedConfig +): BootstrapSnapshotSources => { + const codexAuthSource = resolvePathFromBase(path, projectDir, config.codexAuthPath) + return { + authorizedKeysSource: resolvePathFromBase(path, projectDir, config.authorizedKeysPath), + envGlobalSource: resolvePathFromBase(path, projectDir, config.envGlobalPath), + envProjectSource: resolvePathFromBase(path, projectDir, config.envProjectPath), + codexAuthSource, + codexSharedAuthSource: resolvePathFromBase(path, projectDir, config.codexSharedAuthPath), + claudeAuthSource: path.join(path.dirname(codexAuthSource), "claude") + } +} + +const resolveBootstrapSnapshotTargets = ( + path: Path.Path, + stagingDir: string, + config: BootstrapSeedConfig +): BootstrapSnapshotTargets => { + const authorizedKeysBase = config.authorizedKeysPath.replaceAll("\\", "/").split("/").at(-1) ?? "authorized_keys" + const envGlobalBase = config.envGlobalPath.replaceAll("\\", "/").split("/").at(-1) ?? "global.env" + const envProjectBase = config.envProjectPath.replaceAll("\\", "/").split("/").at(-1) ?? "project.env" + + return { + authorizedKeysTarget: path.join(stagingDir, "authorized-keys", authorizedKeysBase), + envGlobalTarget: path.join(stagingDir, "env-global", envGlobalBase), + envProjectTarget: path.join(stagingDir, "env-project", envProjectBase), + projectCodexTarget: path.join(stagingDir, "project-auth", "codex"), + projectClaudeTarget: path.join(stagingDir, "project-auth", "claude"), + sharedCodexTarget: path.join(stagingDir, "shared-auth", "codex") + } +} + +const ensureBootstrapSnapshotLayout = ( + path: Path.Path, + fs: FileSystem.FileSystem, + targets: BootstrapSnapshotTargets +): Effect.Effect => + Effect.gen(function*(_) { + yield* _(fs.makeDirectory(path.dirname(targets.authorizedKeysTarget), { recursive: true })) + yield* _(fs.makeDirectory(path.dirname(targets.envGlobalTarget), { recursive: true })) + yield* _(fs.makeDirectory(path.dirname(targets.envProjectTarget), { recursive: true })) + yield* _(fs.makeDirectory(targets.projectCodexTarget, { recursive: true })) + yield* _(fs.makeDirectory(targets.projectClaudeTarget, { recursive: true })) + yield* _(fs.makeDirectory(targets.sharedCodexTarget, { recursive: true })) + }) + +const copyBootstrapSnapshotFiles = ( + fs: FileSystem.FileSystem, + path: Path.Path, + sources: BootstrapSnapshotSources, + targets: BootstrapSnapshotTargets +): Effect.Effect => + Effect.gen(function*(_) { + yield* _(copyFileIfPresent(fs, path, sources.authorizedKeysSource, targets.authorizedKeysTarget)) + yield* _(copyFileIfPresent(fs, path, sources.envGlobalSource, targets.envGlobalTarget)) + yield* _(copyFileIfPresent(fs, path, sources.envProjectSource, targets.envProjectTarget)) + }) + +const copyBootstrapSnapshotAuthDirs = ( + fs: FileSystem.FileSystem, + path: Path.Path, + sources: BootstrapSnapshotSources, + targets: BootstrapSnapshotTargets +): Effect.Effect => + Effect.gen(function*(_) { + yield* _(copyDirRecursive(fs, path, sources.codexAuthSource, targets.projectCodexTarget)) + yield* _(copyDirRecursive(fs, path, sources.claudeAuthSource, targets.projectClaudeTarget)) + yield* _(copyDirRecursive(fs, path, sources.codexSharedAuthSource, targets.sharedCodexTarget)) + }) + +const stageBootstrapSnapshot = ( + stagingDir: string, + projectDir: string, + config: BootstrapSeedConfig +): Effect.Effect => + Effect.gen(function*(_) { + const fs = yield* _(FileSystem.FileSystem) + const path = yield* _(Path.Path) + + const sources = resolveBootstrapSnapshotSources(path, projectDir, config) + const targets = resolveBootstrapSnapshotTargets(path, stagingDir, config) + + yield* _(ensureBootstrapSnapshotLayout(path, fs, targets)) + yield* _(copyBootstrapSnapshotFiles(fs, path, sources, targets)) + yield* _(copyBootstrapSnapshotAuthDirs(fs, path, sources, targets)) + }) + +export const ensureProjectBootstrapVolumeReady = ( + projectDir: string, + config: Pick< + TemplateConfig, + "volumeName" | "authorizedKeysPath" | "envGlobalPath" | "envProjectPath" | "codexAuthPath" | "codexSharedAuthPath" + > +): Effect.Effect => + Effect.scoped( + Effect.gen(function*(_) { + const fs = yield* _(FileSystem.FileSystem) + const bootstrapVolumeName = resolveProjectBootstrapVolumeName(config) + yield* _(runDockerVolumeCreate(projectDir, bootstrapVolumeName)) + const stagingDir = yield* _(fs.makeTempDirectoryScoped({ prefix: "docker-git-bootstrap-" })) + yield* _(stageBootstrapSnapshot(stagingDir, projectDir, config)) + yield* _(runDockerVolumeReplaceFromDirectory(projectDir, bootstrapVolumeName, stagingDir)) + }).pipe(Effect.asVoid) + ) + +export const ensureSharedCodexVolumeReady = ( + cwd: string, + config: Pick< + TemplateConfig, + "volumeName" | "authorizedKeysPath" | "envGlobalPath" | "envProjectPath" | "codexAuthPath" | "codexSharedAuthPath" + > +): Effect.Effect => + Effect.gen(function*(_) { + yield* _(runDockerVolumeCreate(cwd, dockerGitSharedCacheVolumeName)) + yield* _(runDockerVolumeCreate(cwd, dockerGitSharedCodexVolumeName)) + yield* _(ensureProjectBootstrapVolumeReady(cwd, config)) + }).pipe(Effect.asVoid) diff --git a/packages/lib/tests/core/templates.test.ts b/packages/lib/tests/core/templates.test.ts index 866c13b5..a65801dd 100644 --- a/packages/lib/tests/core/templates.test.ts +++ b/packages/lib/tests/core/templates.test.ts @@ -38,9 +38,7 @@ describe("renderEntrypointDnsRepair", () => { const entrypoint = renderEntrypoint(makeTemplateConfig()) const dnsRepair = renderEntrypointDnsRepair() const dnsRepairIndex = entrypoint.indexOf(dnsRepair) - const packageCacheIndex = entrypoint.indexOf( - "# Share package manager caches across all docker-git containers" - ) + const packageCacheIndex = entrypoint.indexOf('PACKAGE_CACHE_ROOT="/home/dev/.docker-git/.cache/packages"') expect(dnsRepairIndex).toBeGreaterThanOrEqual(0) expect(packageCacheIndex).toBeGreaterThan(dnsRepairIndex) 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 26b618ba..3f807f40 100644 --- a/packages/lib/tests/usecases/create-project-open-ssh.test.ts +++ b/packages/lib/tests/usecases/create-project-open-ssh.test.ts @@ -81,6 +81,34 @@ const encode = (value: string): Uint8Array => new TextEncoder().encode(value) const commandIncludes = (args: ReadonlyArray, needle: string): boolean => args.includes(needle) +const includesArgsInOrder = ( + args: ReadonlyArray, + expectedSequence: ReadonlyArray +): boolean => { + let searchFrom = 0 + for (const expected of expectedSequence) { + const foundAt = args.indexOf(expected, searchFrom) + if (foundAt === -1) { + return false + } + searchFrom = foundAt + 1 + } + return true +} + +const isDockerComposeDownVolumes = (cmd: RecordedCommand): boolean => + cmd.command === "docker" && + includesArgsInOrder(cmd.args, ["compose", "--ansi", "never", "--progress", "plain", "down", "-v"]) + +const isDockerComposeUp = (cmd: RecordedCommand): boolean => + cmd.command === "docker" && + includesArgsInOrder(cmd.args, ["compose", "--ansi", "never", "--progress", "plain", "up", "-d", "--build"]) + +const isBootstrapSeed = (cmd: RecordedCommand): boolean => + cmd.command === "bash" && + (cmd.args[0] === "-c" || cmd.args[0] === "-lc") && + (cmd.args[1] ?? "").includes("docker run --rm -i -v 'dg-test-home-bootstrap:/target' alpine:3.20") + const decideExitCode = (cmd: RecordedCommand): number => { if (cmd.command === "git" && cmd.args[0] === "rev-parse") { // Auto-sync should detect "not a repo" and exit early. @@ -211,4 +239,33 @@ describe("createProject (openSsh)", () => { ) .pipe(Effect.provide(NodeContext.layer)) ) + + it.effect("re-seeds bootstrap volume after force teardown", () => + 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) + + yield* _( + withInteractiveProcess( + path.join(root, "state"), + createProject(command).pipe(Effect.provideService(CommandExecutor.CommandExecutor, executor)) + ) + ) + + const downVolumesIndex = recorded.findIndex((entry) => isDockerComposeDownVolumes(entry)) + const bootstrapSeedIndex = recorded.findIndex((entry) => isBootstrapSeed(entry)) + const composeUpIndex = recorded.findIndex((entry) => isDockerComposeUp(entry)) + + expect(downVolumesIndex).toBeGreaterThanOrEqual(0) + expect(bootstrapSeedIndex).toBeGreaterThan(downVolumesIndex) + expect(composeUpIndex).toBeGreaterThan(bootstrapSeedIndex) + }) + ) + .pipe(Effect.provide(NodeContext.layer)) + ) }) diff --git a/packages/lib/tests/usecases/prepare-files.test.ts b/packages/lib/tests/usecases/prepare-files.test.ts index ec6a23d7..07fea914 100644 --- a/packages/lib/tests/usecases/prepare-files.test.ts +++ b/packages/lib/tests/usecases/prepare-files.test.ts @@ -165,7 +165,18 @@ describe("prepareProjectFiles", () => { "curl -fsSL --retry 5 --retry-all-errors --retry-delay 2 https://bun.sh/install -o /tmp/bun-install.sh" ) expect(dockerfile).toContain("bun install attempt ${attempt} failed; retrying...") + expect(dockerfile).not.toContain("COPY authorized_keys /opt/docker-git/bootstrap/authorized_keys") + expect(dockerfile).not.toContain("COPY .orch /opt/docker-git/bootstrap/.orch") + expect(dockerfile).toContain("RUN mkdir -p /opt/docker-git/bootstrap/.orch/auth/codex") expect(entrypoint).toContain('DOCKER_GIT_HOME="/home/dev/.docker-git"') + expect(entrypoint).toContain('BOOTSTRAP_ROOT="/opt/docker-git/bootstrap"') + expect(entrypoint).toContain('BOOTSTRAP_CODEX_SHARED_AUTH_DIR="$BOOTSTRAP_SOURCE_ROOT/shared-auth/codex"') + expect(entrypoint).toContain("docker_git_export_env_if_unset()") + expect(entrypoint).toContain('if [[ -n "${!key+x}" ]]; then') + expect(entrypoint).toContain('docker_git_upsert_ssh_env "$key" "${!key}"') + expect(entrypoint).toContain('docker_git_load_env_file "$DOCKER_GIT_ENV_GLOBAL"') + expect(entrypoint).toContain('docker_git_load_env_file "$DOCKER_GIT_ENV_PROJECT"') + expect(entrypoint).not.toContain('export "$line"') expect(entrypoint).toContain('SOURCE_SHARED_AUTH="/home/dev/.codex-shared/auth.json"') expect(entrypoint).toContain('CODEX_LABEL_RAW="$CODEX_AUTH_LABEL"') expect(entrypoint).toContain('OPENCODE_DATA_DIR="/home/dev/.local/share/opencode"') @@ -186,13 +197,23 @@ describe("prepareProjectFiles", () => { expect(entrypoint).toContain("cat > \"$MOVE_SCRIPT\" << 'EOFMOVE'") expect(entrypoint).toMatch(/\nEOFMOVE\n\s*chmod \+x "\$MOVE_SCRIPT"/) expect(entrypoint).not.toContain("\n EOFMOVE\n") + expect(entrypoint).toContain('sync_file_if_present "$BOOTSTRAP_AUTH_KEYS" "$DOCKER_GIT_AUTH_KEYS" || true') + expect(entrypoint).toContain('sync_labeled_auth_files "$BOOTSTRAP_CODEX_SHARED_AUTH_DIR" "$DOCKER_GIT_AUTH_DIR"') + expect(entrypoint).toContain('rm -f "$SHARED_AUTH_FILE" || true') expect(composeBefore).toContain("container_name: dg-test") expect(composeBefore).toContain("restart: unless-stopped") - expect(composeBefore).toContain(":/home/dev/.docker-git") + expect(composeBefore).not.toContain(":/home/dev/.docker-git\n") + expect(composeBefore).toContain("docker_git_shared_cache:/home/dev/.docker-git/.cache") + expect(composeBefore).toContain("docker_git_shared_codex:/home/dev/.codex-shared") + expect(composeBefore).toContain("docker_git_bootstrap:/opt/docker-git/bootstrap/source:ro") + expect(composeBefore).toContain("docker_git_bootstrap:") + expect(composeBefore).toContain("name: dg-test-home-bootstrap") + expect(composeBefore).not.toContain("env_file:") expect(composeBefore).toContain("cpus:") expect(composeBefore).toContain('mem_limit: "') expect(composeBefore).not.toContain("dg-test-browser") expect(composeBefore).toContain("docker-git-shared") + expect(composeBefore).toContain("docker-git-shared-codex") expect(composeBefore).toContain("external: true") expect(countOccurrences(composeBefore, dnsBlock)).toBe(1) @@ -247,7 +268,7 @@ describe("prepareProjectFiles", () => { const compose = yield* _(fs.readFileString(path.join(outDir, "docker-compose.yml"))) expect(compose).toContain("dg-test-net") expect(compose).toContain("driver: bridge") - expect(compose).not.toContain("external: true") + expect(compose).not.toContain("dg-test-net:\n external: true") }) ).pipe(Effect.provide(NodeContext.layer))) diff --git a/packages/lib/tests/usecases/projects-up.test.ts b/packages/lib/tests/usecases/projects-up.test.ts index 6d477d1f..a3070fc9 100644 --- a/packages/lib/tests/usecases/projects-up.test.ts +++ b/packages/lib/tests/usecases/projects-up.test.ts @@ -58,6 +58,15 @@ const isDockerComposeUp = (cmd: RecordedCommand): boolean => cmd.command === "docker" && includesArgsInOrder(cmd.args, ["compose", "--ansi", "never", "--progress", "plain", "up", "-d", "--build"]) +const isDockerVolumeCreate = (cmd: RecordedCommand): boolean => + cmd.command === "docker" && + includesArgsInOrder(cmd.args, ["volume", "create"]) + +const isBootstrapSeed = (cmd: RecordedCommand): boolean => + cmd.command === "bash" && + (cmd.args[0] === "-c" || cmd.args[0] === "-lc") && + (cmd.args[1] ?? "").includes("docker run --rm -i -v 'dg-test-home-bootstrap:/target' alpine:3.20") + const isDockerInspectBridgeIp = (cmd: RecordedCommand): boolean => cmd.command === "docker" && includesArgsInOrder(cmd.args, ["inspect", "-f"]) && @@ -201,6 +210,8 @@ describe("runDockerComposeUpWithPortCheck", () => { expect(configAfter).toContain('"ramLimit": "30%"') expect(recorded.some((entry) => isDockerComposePsFormatted(entry))).toBe(true) + expect(recorded.some((entry) => isDockerVolumeCreate(entry))).toBe(true) + expect(recorded.some((entry) => isBootstrapSeed(entry))).toBe(true) expect(recorded.some((entry) => isDockerComposeUp(entry))).toBe(true) }) ).pipe(Effect.provide(NodeContext.layer))) diff --git a/scripts/e2e/_lib.sh b/scripts/e2e/_lib.sh index 3d250af4..b419b2c6 100644 --- a/scripts/e2e/_lib.sh +++ b/scripts/e2e/_lib.sh @@ -32,11 +32,15 @@ EOF dg_write_docker_host_file() { local host_path="$1" local mode="${2:-}" + local host_uid + local host_gid local host_dir local host_name host_dir="$(dirname "$host_path")" host_name="$(basename "$host_path")" + host_uid="$(id -u)" + host_gid="$(id -g)" if [[ -n "$mode" ]] && [[ ! "$mode" =~ ^[0-7]{3,4}$ ]]; then echo "e2e: invalid file mode: $mode" >&2 @@ -44,13 +48,19 @@ dg_write_docker_host_file() { fi if [[ -n "$mode" ]]; then - docker run --rm -i -v "$host_dir":/mnt ubuntu:24.04 \ - bash -lc "cat > \"/mnt/$host_name\" && chmod \"$mode\" \"/mnt/$host_name\"" + docker run --rm -i \ + -e HOST_UID="$host_uid" \ + -e HOST_GID="$host_gid" \ + -v "$host_dir":/mnt ubuntu:24.04 \ + bash -lc "cat > \"/mnt/$host_name\" && chmod \"$mode\" \"/mnt/$host_name\" && chown \"\$HOST_UID:\$HOST_GID\" \"/mnt/$host_name\"" return 0 fi - docker run --rm -i -v "$host_dir":/mnt ubuntu:24.04 \ - bash -lc "cat > \"/mnt/$host_name\"" + docker run --rm -i \ + -e HOST_UID="$host_uid" \ + -e HOST_GID="$host_gid" \ + -v "$host_dir":/mnt ubuntu:24.04 \ + bash -lc "cat > \"/mnt/$host_name\" && chown \"\$HOST_UID:\$HOST_GID\" \"/mnt/$host_name\"" } # Ensure the calling script can run `docker` (and therefore docker-git) in a diff --git a/scripts/e2e/run-all.sh b/scripts/e2e/run-all.sh index b575dfe8..c5e7773f 100755 --- a/scripts/e2e/run-all.sh +++ b/scripts/e2e/run-all.sh @@ -5,7 +5,7 @@ SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" cases=("$@") if [[ "${#cases[@]}" -eq 0 ]]; then - cases=("local-package-cli" "clone-cache" "login-context" "opencode-autoconnect") + cases=("local-package-cli" "clone-cache" "login-context" "runtime-volumes-ssh" "opencode-autoconnect") fi for case_name in "${cases[@]}"; do diff --git a/scripts/e2e/runtime-volumes-ssh.sh b/scripts/e2e/runtime-volumes-ssh.sh new file mode 100755 index 00000000..c002c1a3 --- /dev/null +++ b/scripts/e2e/runtime-volumes-ssh.sh @@ -0,0 +1,250 @@ +#!/usr/bin/env bash +set -euo pipefail + +RUN_ID="$(date +%s)-$RANDOM" +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +REPO_ROOT="$(cd "$SCRIPT_DIR/../.." && pwd)" +source "$REPO_ROOT/scripts/e2e/_lib.sh" +ROOT_BASE="${DOCKER_GIT_E2E_ROOT_BASE:-$REPO_ROOT/.docker-git/e2e-root}" +mkdir -p "$ROOT_BASE" +ROOT="$(mktemp -d "$ROOT_BASE/runtime-volumes-ssh.XXXXXX")" +# docker-git containers may `chown -R` the project root when seeding runtime data. +# Keep host-side temp dirs writable for assertions and cleanup. +chmod 0777 "$ROOT" +mkdir -p "$ROOT/e2e" +chmod 0777 "$ROOT/e2e" +KEEP="${KEEP:-0}" + +dg_ensure_docker "$ROOT/.e2e-bin" + +export DOCKER_GIT_PROJECTS_ROOT="$ROOT" +export DOCKER_GIT_STATE_AUTO_SYNC=0 + +REPO_URL="https://github.com/octocat/Hello-World/pull/1" +TARGET_DIR="/home/dev/workspaces/octocat/hello-world/pr-1" +OUT_DIR_REL=".docker-git/e2e/runtime-volumes-ssh-$RUN_ID" +OUT_DIR="$ROOT/e2e/runtime-volumes-ssh-$RUN_ID" +CONTAINER_NAME="dg-e2e-runtime-$RUN_ID" +SERVICE_NAME="dg-e2e-runtime-$RUN_ID" +VOLUME_NAME="dg-e2e-runtime-$RUN_ID-home" +SSH_PORT="$(( (RANDOM % 1000) + 23000 ))" +SSH_KEY="$ROOT/dev_ssh_key" +SSH_PUB_KEY="$ROOT/dev_ssh_key.pub" +CLONE_LOG="$ROOT/clone.log" +SSH_LOG="$ROOT/runtime-volumes-ssh.log" +HELPER_IMAGE="" + +fail() { + echo "e2e/runtime-volumes-ssh: $*" >&2 + exit 1 +} + +on_error() { + local line="$1" + echo "e2e/runtime-volumes-ssh: failed at line $line" >&2 + docker ps -a --format 'table {{.Names}}\t{{.Status}}\t{{.Ports}}' | head -n 80 || true + if docker ps -a --format '{{.Names}}' | grep -qx "$CONTAINER_NAME" 2>/dev/null; then + docker inspect "$CONTAINER_NAME" || true + docker logs "$CONTAINER_NAME" --tail 200 || true + fi + if [[ -f "$CLONE_LOG" ]]; then + echo "--- clone log ---" >&2 + cat "$CLONE_LOG" >&2 || true + fi + if [[ -f "$SSH_LOG" ]]; then + echo "--- host ssh log ---" >&2 + cat "$SSH_LOG" >&2 || true + fi + if [[ -d "$OUT_DIR" ]] && [[ -f "$OUT_DIR/docker-compose.yml" ]]; then + (cd "$OUT_DIR" && docker compose ps) || true + (cd "$OUT_DIR" && docker compose logs --no-color --tail 200) || true + fi +} + +cleanup() { + if [[ "$KEEP" == "1" ]]; then + echo "e2e/runtime-volumes-ssh: KEEP=1 set; preserving temp dir: $ROOT" >&2 + echo "e2e/runtime-volumes-ssh: container name: $CONTAINER_NAME" >&2 + echo "e2e/runtime-volumes-ssh: out dir: $OUT_DIR" >&2 + return + fi + if [[ -d "$OUT_DIR" ]] && [[ -f "$OUT_DIR/docker-compose.yml" ]]; then + (cd "$OUT_DIR" && docker compose down -v --remove-orphans) >/dev/null 2>&1 || true + fi + rm -rf "$ROOT" >/dev/null 2>&1 || true +} + +trap 'on_error $LINENO' ERR +trap cleanup EXIT + +command -v script >/dev/null 2>&1 || fail "missing 'script' command (util-linux)" +command -v timeout >/dev/null 2>&1 || fail "missing 'timeout' command" +command -v ssh-keygen >/dev/null 2>&1 || fail "missing 'ssh-keygen' command" + +mkdir -p "$ROOT/.orch/auth/codex" +ssh-keygen -q -t ed25519 -N "" -C "docker-git-e2e" -f "$SSH_KEY" >/dev/null +cp "$SSH_PUB_KEY" "$ROOT/authorized_keys" +chmod 0644 "$ROOT/authorized_keys" || true +dg_write_docker_host_file "$SSH_KEY" 600 < "$SSH_KEY" +dg_write_docker_host_file "$SSH_PUB_KEY" 644 < "$SSH_PUB_KEY" +dg_write_docker_host_file "$ROOT/authorized_keys" 644 < "$SSH_PUB_KEY" + +# Seed a structurally valid auth.json so the shared Codex volume must be created +# and wired into the container runtime. +node <<'NODE' | dg_write_docker_host_file "$ROOT/.orch/auth/codex/auth.json" 600 +const now = Math.floor(Date.now() / 1000) +const b64 = (obj) => Buffer.from(JSON.stringify(obj)).toString("base64url") +const jwt = (payload) => `${b64({ alg: "none", typ: "JWT" })}.${b64(payload)}.sig` + +const access = jwt({ exp: now + 3600, chatgpt_account_id: "org_test" }) +const idToken = jwt({ exp: now + 3600, email: "ci@example.com" }) + +const auth = { + auth_mode: "chatgpt", + OPENAI_API_KEY: null, + tokens: { + id_token: idToken, + access_token: access, + refresh_token: "refresh_test", + account_id: "org_test" + }, + last_refresh: new Date().toISOString() +} + +process.stdout.write(JSON.stringify(auth, null, 2)) +NODE + +mkdir -p "$OUT_DIR/.orch/env" +chmod 0777 "$OUT_DIR" "$OUT_DIR/.orch" "$OUT_DIR/.orch/env" +dg_write_docker_host_file "$OUT_DIR/authorized_keys" 644 < "$SSH_PUB_KEY" +cat > "$OUT_DIR/.orch/env/project.env" <<'EOF_ENV' +# docker-git project env (e2e) +CODEX_AUTO_UPDATE=0 +CODEX_SHARE_AUTH=1 +EOF_ENV + +( + cd "$REPO_ROOT" + pnpm run docker-git clone "$REPO_URL" \ + --force \ + --no-ssh \ + --authorized-keys "$ROOT/authorized_keys" \ + --ssh-port "$SSH_PORT" \ + --out-dir "$OUT_DIR_REL" \ + --container-name "$CONTAINER_NAME" \ + --service-name "$SERVICE_NAME" \ + --volume-name "$VOLUME_NAME" +) >"$CLONE_LOG" 2>&1 + +grep -Fq -- "Docker environment is up" "$CLONE_LOG" \ + || fail "expected clone log to confirm docker startup" + +grep -Fq -- "SSH access: ssh -i $SSH_KEY" "$CLONE_LOG" \ + || fail "expected clone log to print SSH access command" + +grep -Fq -- " -p $SSH_PORT dev@localhost" "$CLONE_LOG" \ + || fail "expected clone log to print the published SSH port" + +docker exec -u dev "$CONTAINER_NAME" bash -lc "test -d '$TARGET_DIR/.git'" \ + || fail "expected cloned repo at: $TARGET_DIR" + +MOUNTS_JSON="$(docker inspect --format '{{json .Mounts}}' "$CONTAINER_NAME")" +MOUNTS_JSON="$MOUNTS_JSON" HOME_VOLUME_NAME="$VOLUME_NAME" node <<'NODE' +const mounts = JSON.parse(process.env.MOUNTS_JSON) +const byDestination = new Map(mounts.map((mount) => [mount.Destination, mount])) + +const expect = (condition, message) => { + if (!condition) { + console.error(message) + process.exit(1) + } +} + +const homeMount = byDestination.get("/home/dev") +expect(homeMount && homeMount.Type === "volume", "expected /home/dev to be a named volume") +expect( + homeMount.Name === process.env.HOME_VOLUME_NAME || + homeMount.Name.endsWith(`_${process.env.HOME_VOLUME_NAME}`), + `unexpected /home/dev volume: ${homeMount && homeMount.Name}` +) + +const cacheMount = byDestination.get("/home/dev/.docker-git/.cache") +expect(cacheMount && cacheMount.Type === "volume", "expected shared cache to be a named volume") +expect(cacheMount.Name === "docker-git-shared-cache", `unexpected cache volume: ${cacheMount && cacheMount.Name}`) + +const codexSharedMount = byDestination.get("/home/dev/.codex-shared") +expect(codexSharedMount && codexSharedMount.Type === "volume", "expected shared Codex auth to be a named volume") +expect(codexSharedMount.Name === "docker-git-shared-codex", `unexpected Codex shared volume: ${codexSharedMount && codexSharedMount.Name}`) + +expect(!byDestination.has("/home/dev/.docker-git"), "did not expect a direct bind mount for /home/dev/.docker-git") +expect(!byDestination.has("/home/dev/.codex"), "did not expect a direct bind mount for /home/dev/.codex") +expect(!byDestination.has("/home/dev/.ssh/authorized_keys"), "did not expect a direct bind mount for authorized_keys") +NODE + +docker exec -u dev "$CONTAINER_NAME" bash -lc 'test -f ~/.docker-git/authorized_keys' \ + || fail "expected authorized_keys to be mirrored into the home volume" + +docker exec -u dev "$CONTAINER_NAME" bash -lc 'test -f ~/.docker-git/.orch/env/global.env' \ + || fail "expected global env in docker-git runtime state" + +docker exec -u dev "$CONTAINER_NAME" bash -lc 'test -f ~/.docker-git/.orch/env/project.env' \ + || fail "expected project env in docker-git runtime state" + +docker exec -u dev "$CONTAINER_NAME" bash -lc 'test -d ~/.docker-git/.orch/auth/codex' \ + || fail "expected bootstrap Codex auth directory inside docker-git runtime state" + +docker exec -u dev "$CONTAINER_NAME" bash -lc 'test -d ~/.codex-shared' \ + || fail "expected shared Codex auth volume to be mounted" + +docker exec -u dev "$CONTAINER_NAME" bash -lc \ + 'test -L ~/.codex/auth.json && test "$(readlink ~/.codex/auth.json)" = "/home/dev/.codex-shared/auth.json"' \ + || fail "expected ~/.codex/auth.json to point at the shared Codex volume" + +HELPER_IMAGE="$(docker inspect --format '{{.Config.Image}}' "$CONTAINER_NAME")" + +wait_for_ssh_ready() { + local attempts=60 + local attempt=1 + + while [[ "$attempt" -le "$attempts" ]]; do + if docker run --rm --network host \ + -v "$ROOT":/mnt \ + --entrypoint bash \ + "$HELPER_IMAGE" \ + -lc "ssh -i /mnt/dev_ssh_key -T -o BatchMode=yes -o ConnectTimeout=2 -o ConnectionAttempts=1 -o LogLevel=ERROR -o StrictHostKeyChecking=no -o UserKnownHostsFile=/dev/null -p $SSH_PORT dev@localhost true" \ + >/dev/null 2>&1; then + return 0 + fi + + sleep 1 + attempt="$((attempt + 1))" + done + + return 1 +} + +wait_for_ssh_ready || fail "ssh did not become ready on localhost:$SSH_PORT" + +set +e +timeout 45s script -q -e -c \ + "docker run --rm -i -t --network host -v \"$ROOT\":/mnt --entrypoint bash \"$HELPER_IMAGE\" -lc \"ssh -i /mnt/dev_ssh_key -tt -Y -o LogLevel=ERROR -o StrictHostKeyChecking=no -o UserKnownHostsFile=/dev/null -p $SSH_PORT dev@localhost \\\"bash -lic 'codex --version >/dev/null && exit'\\\"\"" \ + /dev/null >"$SSH_LOG" 2>&1 +ssh_exit=$? +set -e + +case "$ssh_exit" in + 0) + ;; + *) + cat "$SSH_LOG" >&2 || true + fail "host ssh probe failed (exit: $ssh_exit)" + ;; +esac + +grep -Fq -- "Контекст workspace: PR #1 (https://github.com/octocat/Hello-World/pull/1)" "$SSH_LOG" \ + || fail "expected PR workspace context in host ssh output" + +grep -Fq -- "Старые сессии можно запустить с помощью codex resume" "$SSH_LOG" \ + || fail "expected codex resume hint in host ssh output" + +echo "e2e/runtime-volumes-ssh: named volumes + host SSH CLI flow verified" >&2