From c19e33e064bc4baa9f7c5157522e354c12a03aee Mon Sep 17 00:00:00 2001 From: skulidropek <66840575+skulidropek@users.noreply.github.com> Date: Fri, 13 Mar 2026 09:32:06 +0000 Subject: [PATCH 01/12] fix(shell): move docker-git runtime state into volumes --- README.md | 2 +- .../tests/docker-git/fixtures/project-item.ts | 6 +- .../docker-git/menu-select-connect.test.ts | 6 +- packages/lib/src/core/domain.ts | 1 + packages/lib/src/core/templates-entrypoint.ts | 2 +- .../lib/src/core/templates-entrypoint/base.ts | 9 ++- .../src/core/templates-entrypoint/codex.ts | 21 +++--- .../templates-entrypoint/nested-docker-git.ts | 53 ++++++++++++- packages/lib/src/core/templates.ts | 13 +++- .../lib/src/core/templates/docker-compose.ts | 43 +++++------ packages/lib/src/core/templates/dockerfile.ts | 4 + packages/lib/src/shell/docker.ts | 8 ++ .../lib/src/usecases/actions/docker-up.ts | 6 +- packages/lib/src/usecases/actions/paths.ts | 8 +- .../lib/src/usecases/actions/prepare-files.ts | 6 +- packages/lib/src/usecases/auth-copy.ts | 75 ++++++++++++++++--- .../lib/src/usecases/auth-sync-helpers.ts | 6 +- packages/lib/src/usecases/auth-sync.ts | 26 ++----- packages/lib/src/usecases/projects-up.ts | 6 +- .../lib/tests/usecases/prepare-files.test.ts | 9 ++- 20 files changed, 208 insertions(+), 102 deletions(-) diff --git a/README.md b/README.md index 48a0eb0..eab3375 100644 --- a/README.md +++ b/README.md @@ -1,7 +1,7 @@ # docker-git `docker-git` создаёт отдельную Docker-среду для каждого репозитория, issue или PR. -По умолчанию проекты лежат в `~/.docker-git`. +По умолчанию управляющие файлы проекта лежат в `~/.docker-git`, а runtime workspace, `.docker-git` state и auth живут внутри Docker-managed volumes контейнера. ## Что нужно diff --git a/packages/app/tests/docker-git/fixtures/project-item.ts b/packages/app/tests/docker-git/fixtures/project-item.ts index 0b12c34..ce06260 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 84ad5a6..89bb3f0 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 7944f0c..143be23 100644 --- a/packages/lib/src/core/domain.ts +++ b/packages/lib/src/core/domain.ts @@ -9,6 +9,7 @@ export type DockerNetworkMode = "shared" | "project" export const defaultDockerNetworkMode: DockerNetworkMode = "shared" export const defaultDockerSharedNetworkName = "docker-git-shared" +export const dockerGitSharedCodexVolumeName = "docker-git-shared-codex" export interface TemplateConfig { readonly containerName: string diff --git a/packages/lib/src/core/templates-entrypoint.ts b/packages/lib/src/core/templates-entrypoint.ts index 2cddd82..254b97b 100644 --- a/packages/lib/src/core/templates-entrypoint.ts +++ b/packages/lib/src/core/templates-entrypoint.ts @@ -34,11 +34,11 @@ export const renderEntrypoint = (config: TemplateConfig): string => [ renderEntrypointHeader(config), 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/base.ts b/packages/lib/src/core/templates-entrypoint/base.ts index e96fb28..bbe3fd7 100644 --- a/packages/lib/src/core/templates-entrypoint/base.ts +++ b/packages/lib/src/core/templates-entrypoint/base.ts @@ -51,7 +51,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}}" @@ -76,12 +76,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 diff --git a/packages/lib/src/core/templates-entrypoint/codex.ts b/packages/lib/src/core/templates-entrypoint/codex.ts index 2a1cb2b..5befd15 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,15 +24,13 @@ 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_FILE" && -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; fi # Guard against a bad bind mount creating a directory at auth.json. if [[ -d "$AUTH_FILE" ]]; then mv "$AUTH_FILE" "$AUTH_FILE.bak-$(date +%s)" || true @@ -319,4 +319,5 @@ export const renderEntrypointAgentsNotice = (config: TemplateConfig): string => entrypointAgentsNoticeTemplate.replaceAll("__CODEX_HOME__", config.codexHome).replaceAll( "__SSH_USER__", config.sshUser - ).replaceAll("__TARGET_DIR__", config.targetDir) + ) + .replaceAll("__TARGET_DIR__", config.targetDir) 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 996f441..8e686e7 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,70 @@ 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_ORCH_ROOT="$BOOTSTRAP_ROOT/.orch" +BOOTSTRAP_AUTH_KEYS="$BOOTSTRAP_ROOT/authorized_keys" +BOOTSTRAP_CODEX_AUTH_DIR="$BOOTSTRAP_ORCH_ROOT/auth/codex" +BOOTSTRAP_CLAUDE_AUTH_DIR="$BOOTSTRAP_ORCH_ROOT/auth/claude" +BOOTSTRAP_ENV_GLOBAL="$BOOTSTRAP_ORCH_ROOT/env/global.env" +BOOTSTRAP_ENV_PROJECT="$BOOTSTRAP_ORCH_ROOT/env/project.env" -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 +copy_if_missing_file() { + local source="$1" + local target="$2" + if [[ ! -f "$source" || -e "$target" ]]; then + return 1 + fi + mkdir -p "$(dirname "$target")" + cp "$source" "$target" + return 0 +} + +copy_dir_missing_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" && ! -e "$target_entry" ]]; then + mkdir -p "$(dirname "$target_entry")" + cp "$source_entry" "$target_entry" + fi + done +} + +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 +copy_if_missing_file "$BOOTSTRAP_AUTH_KEYS" "$DOCKER_GIT_AUTH_KEYS" || true if [[ -f "$DOCKER_GIT_AUTH_KEYS" ]]; then chmod 600 "$DOCKER_GIT_AUTH_KEYS" || true fi +copy_if_missing_file "$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 +copy_if_missing_file "$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 @@ -66,6 +108,9 @@ copy_if_distinct_file() { return 0 } +copy_dir_missing_entries "$BOOTSTRAP_CODEX_AUTH_DIR" "$DOCKER_GIT_AUTH_DIR" +copy_dir_missing_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 diff --git a/packages/lib/src/core/templates.ts b/packages/lib/src/core/templates.ts index 00b3b61..d6c55cf 100644 --- a/packages/lib/src/core/templates.ts +++ b/packages/lib/src/core/templates.ts @@ -10,10 +10,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/ @@ -22,8 +24,10 @@ const renderGitignore = (): string => const renderDockerignore = (): string => `# docker-git build context -.orch/ -authorized_keys +.orch/auth/codex/log/ +.orch/auth/codex/tmp/ +.orch/auth/codex/sessions/ +.orch/auth/codex/models_cache.json ` const renderConfigJson = (config: TemplateConfig): string => @@ -52,6 +56,7 @@ export const planFiles = (config: TemplateConfig): ReadonlyArray => { { _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 a17b02b..df2e53c 100644 --- a/packages/lib/src/core/templates/docker-compose.ts +++ b/packages/lib/src/core/templates/docker-compose.ts @@ -1,4 +1,4 @@ -import { resolveComposeNetworkName, type TemplateConfig } from "../domain.js" +import { dockerGitSharedCodexVolumeName, resolveComposeNetworkName, type TemplateConfig } from "../domain.js" type ComposeFragments = { readonly networkMode: TemplateConfig["dockerNetworkMode"] @@ -11,14 +11,12 @@ type ComposeFragments = { readonly maybeDependsOn: string readonly maybePlaywrightEnv: string readonly maybeBrowserService: string - readonly maybeBrowserVolume: string readonly forkRepoUrl: string } -type PlaywrightFragments = Pick< - ComposeFragments, - "maybeDependsOn" | "maybePlaywrightEnv" | "maybeBrowserService" | "maybeBrowserVolume" -> +type PlaywrightFragments = Pick + +const sharedCodexVolumeKey = "docker_git_shared_codex" const renderGitTokenLabelEnv = (gitTokenLabel: string): string => gitTokenLabel.length > 0 @@ -45,12 +43,6 @@ 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 buildPlaywrightFragments = ( config: TemplateConfig, networkName: string @@ -59,8 +51,7 @@ const buildPlaywrightFragments = ( return { maybeDependsOn: "", maybePlaywrightEnv: "", - maybeBrowserService: "", - maybeBrowserVolume: "" + maybeBrowserService: "" } } @@ -75,8 +66,7 @@ const buildPlaywrightFragments = ( maybePlaywrightEnv: ` MCP_PLAYWRIGHT_ENABLE: "1"\n MCP_PLAYWRIGHT_CDP_ENDPOINT: "${browserCdpEndpoint}"\n`, maybeBrowserService: - `\n ${browserServiceName}:\n build:\n context: .\n dockerfile: ${browserDockerfile}\n container_name: ${browserContainerName}\n restart: unless-stopped\n environment:\n VNC_NOPW: "1"\n shm_size: "2gb"\n expose:\n - "9223"\n volumes:\n - ${browserVolumeName}:/data\n networks:\n - ${networkName}\n`, - maybeBrowserVolume: ` ${browserVolumeName}:\n` + `\n ${browserServiceName}:\n build:\n context: .\n dockerfile: ${browserDockerfile}\n container_name: ${browserContainerName}\n restart: unless-stopped\n environment:\n VNC_NOPW: "1"\n shm_size: "2gb"\n expose:\n - "9223"\n volumes:\n - ${browserVolumeName}:/data\n networks:\n - ${networkName}\n` } } @@ -105,7 +95,6 @@ const buildComposeFragments = (config: TemplateConfig): ComposeFragments => { maybeDependsOn: playwright.maybeDependsOn, maybePlaywrightEnv: playwright.maybePlaywrightEnv, maybeBrowserService: playwright.maybeBrowserService, - maybeBrowserVolume: playwright.maybeBrowserVolume, forkRepoUrl } } @@ -132,10 +121,7 @@ ${fragments.maybePlaywrightEnv}${fragments.maybeDependsOn} env_file: - "127.0.0.1:${config.sshPort}:22" 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 + - ${sharedCodexVolumeKey}:${config.codexHome}-shared - /var/run/docker.sock:/var/run/docker.sock networks: - ${fragments.networkName} @@ -153,16 +139,21 @@ const renderComposeNetworks = ( ${networkName}: driver: bridge` -const renderComposeVolumes = (config: TemplateConfig, maybeBrowserVolume: string): string => - `volumes: - ${config.volumeName}: -${maybeBrowserVolume}` +const renderComposeVolumes = (config: TemplateConfig, enableMcpPlaywright: boolean): string => + [ + "volumes:", + ` ${config.volumeName}:`, + ` ${sharedCodexVolumeKey}:`, + " external: true", + ` name: ${dockerGitSharedCodexVolumeName}`, + ...(enableMcpPlaywright ? [` ${config.volumeName}-browser:`] : []) + ].join("\n") export const renderDockerCompose = (config: TemplateConfig): string => { const fragments = buildComposeFragments(config) return [ renderComposeServices(config, fragments), renderComposeNetworks(fragments.networkMode, fragments.networkName), - renderComposeVolumes(config, fragments.maybeBrowserVolume) + renderComposeVolumes(config, config.enableMcpPlaywright) ].join("\n\n") } diff --git a/packages/lib/src/core/templates/dockerfile.ts b/packages/lib/src/core/templates/dockerfile.ts index d39a2e0..674167b 100644 --- a/packages/lib/src/core/templates/dockerfile.ts +++ b/packages/lib/src/core/templates/dockerfile.ts @@ -222,6 +222,10 @@ 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 +COPY authorized_keys /opt/docker-git/bootstrap/authorized_keys +COPY .orch /opt/docker-git/bootstrap/.orch + COPY entrypoint.sh /entrypoint.sh RUN sed -i 's/\r$//' /entrypoint.sh && chmod +x /entrypoint.sh diff --git a/packages/lib/src/shell/docker.ts b/packages/lib/src/shell/docker.ts index 8d78b11..30b4be9 100644 --- a/packages/lib/src/shell/docker.ts +++ b/packages/lib/src/shell/docker.ts @@ -86,6 +86,14 @@ export const runDockerComposeUp = ( runCompose(cwd, ["up", "-d", "--build"], [Number(ExitCode(0))]) ) +export const runDockerVolumeCreate = ( + cwd: string, + volumeName: string +): Effect.Effect => + runCommandWithExitCodes({ cwd, command: "docker", args: ["volume", "create", volumeName] }, [Number(ExitCode(0))], ( + exitCode + ) => new DockerCommandError({ exitCode })) + export const dockerComposeUpRecreateArgs: ReadonlyArray = [ "up", "-d", diff --git a/packages/lib/src/usecases/actions/docker-up.ts b/packages/lib/src/usecases/actions/docker-up.ts index 79d7605..c4bdfe0 100644 --- a/packages/lib/src/usecases/actions/docker-up.ts +++ b/packages/lib/src/usecases/actions/docker-up.ts @@ -4,7 +4,7 @@ import * as FileSystem from "@effect/platform/FileSystem" import * as Path from "@effect/platform/Path" import { Duration, Effect, Fiber, Schedule } from "effect" -import type { CreateCommand } from "../../core/domain.js" +import { type CreateCommand, dockerGitSharedCodexVolumeName } from "../../core/domain.js" import { runDockerComposeDownVolumes, runDockerComposeLogsFollow, @@ -12,7 +12,8 @@ import { runDockerComposeUpRecreate, runDockerExecExitCode, runDockerInspectContainerBridgeIp, - runDockerNetworkConnectBridge + runDockerNetworkConnectBridge, + runDockerVolumeCreate } from "../../shell/docker.js" import type { DockerCommandError } from "../../shell/errors.js" import { AgentFailedError, CloneFailedError } from "../../shell/errors.js" @@ -175,6 +176,7 @@ const runDockerComposeUpByMode = ( ): Effect.Effect => Effect.gen(function*(_) { yield* _(ensureComposeNetworkReady(resolvedOutDir, projectConfig)) + yield* _(runDockerVolumeCreate(resolvedOutDir, dockerGitSharedCodexVolumeName)) if (force) { yield* _(Effect.log("Force enabled: wiping docker compose volumes (docker compose down -v)...")) diff --git a/packages/lib/src/usecases/actions/paths.ts b/packages/lib/src/usecases/actions/paths.ts index 5656e1e..a768dd9 100644 --- a/packages/lib/src/usecases/actions/paths.ts +++ b/packages/lib/src/usecases/actions/paths.ts @@ -53,16 +53,16 @@ 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. - codexSharedAuthPath: relativeFromOutDir(globalConfig.codexSharedAuthPath) + // Bootstrap auth snapshots stay project-local; runtime links auth.json from the shared Docker volume. + codexSharedAuthPath: "./.orch/auth/codex" } return { globalConfig, projectConfig } } diff --git a/packages/lib/src/usecases/actions/prepare-files.ts b/packages/lib/src/usecases/actions/prepare-files.ts index 51514fc..a683d71 100644 --- a/packages/lib/src/usecases/actions/prepare-files.ts +++ b/packages/lib/src/usecases/actions/prepare-files.ts @@ -153,12 +153,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 543514e..f2cba4c 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 a55aef1..d14dd46 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 = { @@ -157,7 +158,4 @@ export type AuthSyncSpec = { readonly target: AuthPaths } -export type LegacyOrchPaths = AuthPaths & { - readonly ghAuthPath: string - readonly claudeAuthPath: string -} +export type LegacyOrchPaths = AuthPaths & { readonly ghAuthPath: string } diff --git a/packages/lib/src/usecases/auth-sync.ts b/packages/lib/src/usecases/auth-sync.ts index d482400..b999f16 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 { copyDirIfEmpty, copyDirMissingEntries } from "./auth-copy.js" import { type AuthSyncSpec, defaultCodexConfig, @@ -164,33 +164,17 @@ 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* _(copyDirMissingEntries(fs, path, sourceCodex, targetCodex, "Codex auth bootstrap")) } + 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 0797203..4f445b9 100644 --- a/packages/lib/src/usecases/projects-up.ts +++ b/packages/lib/src/usecases/projects-up.ts @@ -4,14 +4,15 @@ import type { FileSystem } from "@effect/platform/FileSystem" import type { Path } from "@effect/platform/Path" import { Effect, pipe } from "effect" -import type { ProjectConfig, TemplateConfig } from "../core/domain.js" +import { dockerGitSharedCodexVolumeName, type ProjectConfig, type TemplateConfig } from "../core/domain.js" import { readProjectConfig } from "../shell/config.js" import { runDockerComposePsFormatted, runDockerComposeUp, runDockerExecExitCode, runDockerInspectContainerBridgeIp, - runDockerNetworkConnectBridge + runDockerNetworkConnectBridge, + runDockerVolumeCreate } from "../shell/docker.js" import type { ConfigDecodeError, @@ -189,6 +190,7 @@ export const runDockerComposeUpWithPortCheck = ( // Keep generated templates in sync with the running CLI version. yield* _(syncManagedProjectFiles(projectDir, updated)) yield* _(ensureComposeNetworkReady(projectDir, updated)) + yield* _(runDockerVolumeCreate(projectDir, dockerGitSharedCodexVolumeName)) yield* _(runDockerComposeUp(projectDir)) yield* _(ensureClaudeCliReady(projectDir, updated.containerName)) diff --git a/packages/lib/tests/usecases/prepare-files.test.ts b/packages/lib/tests/usecases/prepare-files.test.ts index 99e13c9..6c75d3f 100644 --- a/packages/lib/tests/usecases/prepare-files.test.ts +++ b/packages/lib/tests/usecases/prepare-files.test.ts @@ -131,7 +131,10 @@ 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).toContain("COPY authorized_keys /opt/docker-git/bootstrap/authorized_keys") + expect(dockerfile).toContain("COPY .orch /opt/docker-git/bootstrap/.orch") expect(entrypoint).toContain('DOCKER_GIT_HOME="/home/dev/.docker-git"') + expect(entrypoint).toContain('BOOTSTRAP_ROOT="/opt/docker-git/bootstrap"') 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"') @@ -149,9 +152,11 @@ describe("prepareProjectFiles", () => { expect(entrypoint).not.toContain("\n EOFMOVE\n") 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") + expect(composeBefore).toContain("docker_git_shared_codex:/home/dev/.codex-shared") 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") yield* _( @@ -202,7 +207,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))) }) From 5023b4701fb85b513d3c9f6f91381775c906d42a Mon Sep 17 00:00:00 2001 From: skulidropek <66840575+skulidropek@users.noreply.github.com> Date: Fri, 13 Mar 2026 09:47:58 +0000 Subject: [PATCH 02/12] fix(shell): seed shared auth and cache volumes --- packages/lib/src/core/domain.ts | 1 + .../lib/src/core/templates/docker-compose.ts | 12 ++++- packages/lib/src/shell/docker-volume.ts | 48 +++++++++++++++++++ packages/lib/src/shell/docker.ts | 8 ---- .../lib/src/usecases/actions/docker-up.ts | 10 ++-- packages/lib/src/usecases/actions/paths.ts | 4 +- .../lib/src/usecases/actions/prepare-files.ts | 13 +++-- packages/lib/src/usecases/auth-sync.ts | 11 ++++- packages/lib/src/usecases/projects-up.ts | 8 ++-- .../lib/src/usecases/shared-volume-seed.ts | 32 +++++++++++++ .../lib/tests/usecases/prepare-files.test.ts | 3 +- 11 files changed, 124 insertions(+), 26 deletions(-) create mode 100644 packages/lib/src/shell/docker-volume.ts create mode 100644 packages/lib/src/usecases/shared-volume-seed.ts diff --git a/packages/lib/src/core/domain.ts b/packages/lib/src/core/domain.ts index 143be23..0b22670 100644 --- a/packages/lib/src/core/domain.ts +++ b/packages/lib/src/core/domain.ts @@ -9,6 +9,7 @@ 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 interface TemplateConfig { diff --git a/packages/lib/src/core/templates/docker-compose.ts b/packages/lib/src/core/templates/docker-compose.ts index df2e53c..adf99b6 100644 --- a/packages/lib/src/core/templates/docker-compose.ts +++ b/packages/lib/src/core/templates/docker-compose.ts @@ -1,4 +1,9 @@ -import { dockerGitSharedCodexVolumeName, resolveComposeNetworkName, type TemplateConfig } from "../domain.js" +import { + dockerGitSharedCacheVolumeName, + dockerGitSharedCodexVolumeName, + resolveComposeNetworkName, + type TemplateConfig +} from "../domain.js" type ComposeFragments = { readonly networkMode: TemplateConfig["dockerNetworkMode"] @@ -17,6 +22,7 @@ type ComposeFragments = { type PlaywrightFragments = Pick const sharedCodexVolumeKey = "docker_git_shared_codex" +const sharedCacheVolumeKey = "docker_git_shared_cache" const renderGitTokenLabelEnv = (gitTokenLabel: string): string => gitTokenLabel.length > 0 @@ -121,6 +127,7 @@ ${fragments.maybePlaywrightEnv}${fragments.maybeDependsOn} env_file: - "127.0.0.1:${config.sshPort}:22" volumes: - ${config.volumeName}:/home/${config.sshUser} + - ${sharedCacheVolumeKey}:/home/${config.sshUser}/.docker-git/.cache - ${sharedCodexVolumeKey}:${config.codexHome}-shared - /var/run/docker.sock:/var/run/docker.sock networks: @@ -143,6 +150,9 @@ const renderComposeVolumes = (config: TemplateConfig, enableMcpPlaywright: boole [ "volumes:", ` ${config.volumeName}:`, + ` ${sharedCacheVolumeKey}:`, + " external: true", + ` name: ${dockerGitSharedCacheVolumeName}`, ` ${sharedCodexVolumeKey}:`, " external: true", ` name: ${dockerGitSharedCodexVolumeName}`, diff --git a/packages/lib/src/shell/docker-volume.ts b/packages/lib/src/shell/docker-volume.ts new file mode 100644 index 0000000..63c0c8a --- /dev/null +++ b/packages/lib/src/shell/docker-volume.ts @@ -0,0 +1,48 @@ +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" + +export const runDockerVolumeCreate = ( + cwd: string, + volumeName: string +): Effect.Effect => + runCommandWithExitCodes({ cwd, command: "docker", args: ["volume", "create", volumeName] }, [Number(ExitCode(0))], ( + exitCode + ) => new DockerCommandError({ exitCode })) + +const seedDockerVolumeScript = String.raw`set -eu +mkdir -p /dest +if [[ -d /src ]]; then + cp -an /src/. /dest/ 2>/dev/null || true + find /dest -type f -name auth.json -exec chmod 600 {} + >/dev/null 2>&1 || true +fi` + +export const runDockerVolumeSeedFromDir = ( + cwd: string, + volumeName: string, + sourceDir: string +): Effect.Effect => + runCommandWithExitCodes( + { + cwd, + command: "docker", + args: [ + "run", + "--rm", + "-v", + `${volumeName}:/dest`, + "-v", + `${sourceDir}:/src:ro`, + "ubuntu:24.04", + "bash", + "-lc", + seedDockerVolumeScript + ] + }, + [Number(ExitCode(0))], + (exitCode) => new DockerCommandError({ exitCode }) + ) diff --git a/packages/lib/src/shell/docker.ts b/packages/lib/src/shell/docker.ts index 30b4be9..8d78b11 100644 --- a/packages/lib/src/shell/docker.ts +++ b/packages/lib/src/shell/docker.ts @@ -86,14 +86,6 @@ export const runDockerComposeUp = ( runCompose(cwd, ["up", "-d", "--build"], [Number(ExitCode(0))]) ) -export const runDockerVolumeCreate = ( - cwd: string, - volumeName: string -): Effect.Effect => - runCommandWithExitCodes({ cwd, command: "docker", args: ["volume", "create", volumeName] }, [Number(ExitCode(0))], ( - exitCode - ) => new DockerCommandError({ exitCode })) - export const dockerComposeUpRecreateArgs: ReadonlyArray = [ "up", "-d", diff --git a/packages/lib/src/usecases/actions/docker-up.ts b/packages/lib/src/usecases/actions/docker-up.ts index c4bdfe0..93ec242 100644 --- a/packages/lib/src/usecases/actions/docker-up.ts +++ b/packages/lib/src/usecases/actions/docker-up.ts @@ -4,7 +4,7 @@ import * as FileSystem from "@effect/platform/FileSystem" import * as Path from "@effect/platform/Path" import { Duration, Effect, Fiber, Schedule } from "effect" -import { type CreateCommand, dockerGitSharedCodexVolumeName } from "../../core/domain.js" +import type { CreateCommand } from "../../core/domain.js" import { runDockerComposeDownVolumes, runDockerComposeLogsFollow, @@ -12,14 +12,14 @@ import { runDockerComposeUpRecreate, runDockerExecExitCode, runDockerInspectContainerBridgeIp, - runDockerNetworkConnectBridge, - runDockerVolumeCreate + runDockerNetworkConnectBridge } from "../../shell/docker.js" import type { DockerCommandError } from "../../shell/errors.js" 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) @@ -173,10 +173,10 @@ const runDockerComposeUpByMode = ( projectConfig: CreateCommand["config"], force: boolean, forceEnv: boolean -): Effect.Effect => +): Effect.Effect => Effect.gen(function*(_) { yield* _(ensureComposeNetworkReady(resolvedOutDir, projectConfig)) - yield* _(runDockerVolumeCreate(resolvedOutDir, dockerGitSharedCodexVolumeName)) + yield* _(ensureSharedCodexVolumeReady(resolvedOutDir, projectConfig)) if (force) { yield* _(Effect.log("Force enabled: wiping docker compose volumes (docker compose down -v)...")) diff --git a/packages/lib/src/usecases/actions/paths.ts b/packages/lib/src/usecases/actions/paths.ts index a768dd9..f89ee1c 100644 --- a/packages/lib/src/usecases/actions/paths.ts +++ b/packages/lib/src/usecases/actions/paths.ts @@ -61,8 +61,8 @@ export const buildProjectConfigs = ( : toPosixPath(resolvedConfig.envProjectPath), // Project-local Codex state (sessions/logs/etc) is kept under .orch. codexAuthPath: "./.orch/auth/codex", - // Bootstrap auth snapshots stay project-local; runtime links auth.json from the shared Docker volume. - codexSharedAuthPath: "./.orch/auth/codex" + // 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 a683d71..3520c24 100644 --- a/packages/lib/src/usecases/actions/prepare-files.ts +++ b/packages/lib/src/usecases/actions/prepare-files.ts @@ -42,7 +42,8 @@ const ensureFileReady = ( const ensureAuthorizedKeys = ( baseDir: string, - authorizedKeysPath: string + authorizedKeysPath: string, + preferredSource: string ): Effect.Effect => withFsPathContext(({ fs, path }) => Effect.gen(function*(_) { @@ -59,8 +60,14 @@ const ensureAuthorizedKeys = ( return } - const source = yield* _(findAuthorizedKeysSource(fs, path, process.cwd())) + const preferred = path.isAbsolute(preferredSource) || preferredSource.startsWith(".") + ? path.resolve(baseDir, preferredSource) + : preferredSource + const preferredExists = yield* _(fs.exists(preferred)) + const source = preferredExists ? preferred : yield* _(findAuthorizedKeysSource(fs, path, process.cwd())) if (source === null) { + 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.` @@ -133,7 +140,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( diff --git a/packages/lib/src/usecases/auth-sync.ts b/packages/lib/src/usecases/auth-sync.ts index b999f16..9748ad3 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 { copyDirIfEmpty, copyDirMissingEntries } from "./auth-copy.js" +import { copyCodexFile, copyDirIfEmpty, copyDirMissingEntries } from "./auth-copy.js" import { type AuthSyncSpec, defaultCodexConfig, @@ -172,7 +172,14 @@ export const syncAuthArtifacts = ( yield* _(copyFileIfNeeded(sourceProject, targetProject)) yield* _(fs.makeDirectory(targetCodex, { recursive: true })) if (sourceCodex !== targetCodex) { - yield* _(copyDirMissingEntries(fs, path, sourceCodex, targetCodex, "Codex auth bootstrap")) + 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 4f445b9..941c386 100644 --- a/packages/lib/src/usecases/projects-up.ts +++ b/packages/lib/src/usecases/projects-up.ts @@ -4,15 +4,14 @@ import type { FileSystem } from "@effect/platform/FileSystem" import type { Path } from "@effect/platform/Path" import { Effect, pipe } from "effect" -import { dockerGitSharedCodexVolumeName, type ProjectConfig, type TemplateConfig } from "../core/domain.js" +import type { ProjectConfig, TemplateConfig } from "../core/domain.js" import { readProjectConfig } from "../shell/config.js" import { runDockerComposePsFormatted, runDockerComposeUp, runDockerExecExitCode, runDockerInspectContainerBridgeIp, - runDockerNetworkConnectBridge, - runDockerVolumeCreate + runDockerNetworkConnectBridge } from "../shell/docker.js" import type { ConfigDecodeError, @@ -26,6 +25,7 @@ import { ensureCodexConfigFile } from "./auth-sync.js" import { ensureComposeNetworkReady } from "./docker-network-gc.js" import { loadReservedPorts, selectAvailablePort } from "./ports-reserve.js" import { parseComposePsOutput } from "./projects-core.js" +import { ensureSharedCodexVolumeReady } from "./shared-volume-seed.js" const maxPortAttempts = 25 @@ -190,7 +190,7 @@ export const runDockerComposeUpWithPortCheck = ( // Keep generated templates in sync with the running CLI version. yield* _(syncManagedProjectFiles(projectDir, updated)) yield* _(ensureComposeNetworkReady(projectDir, updated)) - yield* _(runDockerVolumeCreate(projectDir, dockerGitSharedCodexVolumeName)) + yield* _(ensureSharedCodexVolumeReady(projectDir, updated)) yield* _(runDockerComposeUp(projectDir)) yield* _(ensureClaudeCliReady(projectDir, updated.containerName)) 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 0000000..3c209af --- /dev/null +++ b/packages/lib/src/usecases/shared-volume-seed.ts @@ -0,0 +1,32 @@ +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, type TemplateConfig } from "../core/domain.js" +import { runDockerVolumeCreate, runDockerVolumeSeedFromDir } from "../shell/docker-volume.js" +import type { DockerCommandError } from "../shell/errors.js" +import { resolvePathFromCwd } from "./path-helpers.js" + +type SharedVolumeSeedEnvironment = FileSystem.FileSystem | Path.Path | CommandExecutor + +export const ensureSharedCodexVolumeReady = ( + cwd: string, + config: Pick +): Effect.Effect => + Effect.gen(function*(_) { + const fs = yield* _(FileSystem.FileSystem) + const path = yield* _(Path.Path) + const sourceDir = resolvePathFromCwd(path, cwd, config.codexSharedAuthPath) + + yield* _(runDockerVolumeCreate(cwd, dockerGitSharedCacheVolumeName)) + yield* _(runDockerVolumeCreate(cwd, dockerGitSharedCodexVolumeName)) + + const sourceExists = yield* _(fs.exists(sourceDir)) + if (!sourceExists) { + return + } + + yield* _(runDockerVolumeSeedFromDir(cwd, dockerGitSharedCodexVolumeName, sourceDir)) + }).pipe(Effect.asVoid) diff --git a/packages/lib/tests/usecases/prepare-files.test.ts b/packages/lib/tests/usecases/prepare-files.test.ts index 6c75d3f..b269ee3 100644 --- a/packages/lib/tests/usecases/prepare-files.test.ts +++ b/packages/lib/tests/usecases/prepare-files.test.ts @@ -152,7 +152,8 @@ describe("prepareProjectFiles", () => { expect(entrypoint).not.toContain("\n EOFMOVE\n") expect(composeBefore).toContain("container_name: dg-test") expect(composeBefore).toContain("restart: unless-stopped") - expect(composeBefore).not.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).not.toContain("dg-test-browser") expect(composeBefore).toContain("docker-git-shared") From 3e34496ba9ba365891672442f4b93f24c7cd9d34 Mon Sep 17 00:00:00 2001 From: skulidropek <66840575+skulidropek@users.noreply.github.com> Date: Sat, 14 Mar 2026 20:25:07 +0000 Subject: [PATCH 03/12] test(e2e): prove docker runtime and ssh access --- .github/workflows/check.yml | 13 ++ README.md | 10 ++ package.json | 1 + scripts/e2e/run-all.sh | 2 +- scripts/e2e/runtime-volumes-ssh.sh | 249 +++++++++++++++++++++++++++++ 5 files changed, 274 insertions(+), 1 deletion(-) create mode 100755 scripts/e2e/runtime-volumes-ssh.sh diff --git a/.github/workflows/check.yml b/.github/workflows/check.yml index a6780b5..d424394 100644 --- a/.github/workflows/check.yml +++ b/.github/workflows/check.yml @@ -134,3 +134,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 eab3375..a4e4932 100644 --- a/README.md +++ b/README.md @@ -45,6 +45,16 @@ docker-git clone https://github.com/ProverCoderAI/docker-git/issues/122 --force - `--auto=claude` или `--auto=codex` принудительно выбирает агента. - В auto-режиме агент сам выполняет задачу, создаёт PR и после завершения контейнер очищается. +## Проверка Docker runtime + +Воспроизводимая smoke-проверка для Docker runtime и host CLI: + +```bash +pnpm run e2e:runtime-volumes-ssh +``` + +Сценарий доказывает, что контейнер стартует через Docker, runtime state живёт в named volumes, а `docker-git clone --no-ssh` печатает готовую host CLI команду `SSH access: ...`, которая реально подключает в контейнер, показывает workspace context и видит установленный `codex`. + ## Подробности `docker-git --help` diff --git a/package.json b/package.json index e5c7b67..7ae1aa6 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/scripts/e2e/run-all.sh b/scripts/e2e/run-all.sh index b575dfe..c5e7773 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 0000000..3b492b7 --- /dev/null +++ b/scripts/e2e/runtime-volumes-ssh.sh @@ -0,0 +1,249 @@ +#!/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/.docker-git/.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/.docker-git/.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" +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 -f ~/.docker-git/.orch/auth/codex/auth.json' \ + || fail "expected bootstrap Codex auth inside docker-git runtime state" + +docker exec -u dev "$CONTAINER_NAME" bash -lc 'test -f ~/.codex-shared/auth.json' \ + || fail "expected shared Codex auth volume to contain auth.json" + +docker exec -u dev "$CONTAINER_NAME" bash -lc \ + '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 From 525ad86a269fa59119d926dc1811025911445a78 Mon Sep 17 00:00:00 2001 From: skulidropek <66840575+skulidropek@users.noreply.github.com> Date: Sat, 14 Mar 2026 22:08:22 +0000 Subject: [PATCH 04/12] test(e2e): relax codex bootstrap assertion --- scripts/e2e/runtime-volumes-ssh.sh | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/scripts/e2e/runtime-volumes-ssh.sh b/scripts/e2e/runtime-volumes-ssh.sh index 3b492b7..69e26c8 100755 --- a/scripts/e2e/runtime-volumes-ssh.sh +++ b/scripts/e2e/runtime-volumes-ssh.sh @@ -189,8 +189,8 @@ docker exec -u dev "$CONTAINER_NAME" bash -lc 'test -f ~/.docker-git/.orch/env/g 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 -f ~/.docker-git/.orch/auth/codex/auth.json' \ - || fail "expected bootstrap Codex auth inside docker-git runtime state" +docker exec -u dev "$CONTAINER_NAME" bash -lc 'test -f ~/.docker-git/.orch/auth/codex/config.toml' \ + || fail "expected bootstrap Codex config inside docker-git runtime state" docker exec -u dev "$CONTAINER_NAME" bash -lc 'test -f ~/.codex-shared/auth.json' \ || fail "expected shared Codex auth volume to contain auth.json" From 53cbb78b5a7007432260fae1b1d85d8a300e44ef Mon Sep 17 00:00:00 2001 From: skulidropek <66840575+skulidropek@users.noreply.github.com> Date: Sat, 14 Mar 2026 22:13:03 +0000 Subject: [PATCH 05/12] test(e2e): relax shared codex auth expectation --- scripts/e2e/runtime-volumes-ssh.sh | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/scripts/e2e/runtime-volumes-ssh.sh b/scripts/e2e/runtime-volumes-ssh.sh index 69e26c8..31a762e 100755 --- a/scripts/e2e/runtime-volumes-ssh.sh +++ b/scripts/e2e/runtime-volumes-ssh.sh @@ -192,11 +192,11 @@ docker exec -u dev "$CONTAINER_NAME" bash -lc 'test -f ~/.docker-git/.orch/env/p docker exec -u dev "$CONTAINER_NAME" bash -lc 'test -f ~/.docker-git/.orch/auth/codex/config.toml' \ || fail "expected bootstrap Codex config inside docker-git runtime state" -docker exec -u dev "$CONTAINER_NAME" bash -lc 'test -f ~/.codex-shared/auth.json' \ - || fail "expected shared Codex auth volume to contain auth.json" +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 "$(readlink ~/.codex/auth.json)" = "/home/dev/.codex-shared/auth.json"' \ + '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")" From eb0c3640e5e14cb84082335da8078afdd1df2268 Mon Sep 17 00:00:00 2001 From: skulidropek <66840575+skulidropek@users.noreply.github.com> Date: Sat, 14 Mar 2026 22:13:30 +0000 Subject: [PATCH 06/12] test(e2e): seed shared auth from projects root --- scripts/e2e/runtime-volumes-ssh.sh | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/scripts/e2e/runtime-volumes-ssh.sh b/scripts/e2e/runtime-volumes-ssh.sh index 31a762e..5c1a920 100755 --- a/scripts/e2e/runtime-volumes-ssh.sh +++ b/scripts/e2e/runtime-volumes-ssh.sh @@ -81,7 +81,7 @@ 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/.docker-git/.orch/auth/codex" +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 @@ -91,7 +91,7 @@ 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/.docker-git/.orch/auth/codex/auth.json" 600 +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` From 3e64dcbd593a6930b0febfecc2bf5a3213db627d Mon Sep 17 00:00:00 2001 From: skulidropek <66840575+skulidropek@users.noreply.github.com> Date: Sat, 14 Mar 2026 22:45:34 +0000 Subject: [PATCH 07/12] fix(shell): keep bootstrap auth and keys out of image layers --- .../src/core/templates-entrypoint/codex.ts | 8 +- .../templates-entrypoint/nested-docker-git.ts | 75 +++++++++++++++---- packages/lib/src/core/templates.ts | 4 + .../lib/src/core/templates/docker-compose.ts | 68 +++++++++++++++-- packages/lib/src/core/templates/dockerfile.ts | 10 ++- packages/lib/src/shell/docker-compose-env.ts | 12 ++- packages/lib/src/shell/docker-volume.ts | 33 -------- .../lib/src/usecases/shared-volume-seed.ts | 20 +---- .../lib/tests/usecases/prepare-files.test.ts | 15 +++- scripts/e2e/runtime-volumes-ssh.sh | 5 +- 10 files changed, 168 insertions(+), 82 deletions(-) diff --git a/packages/lib/src/core/templates-entrypoint/codex.ts b/packages/lib/src/core/templates-entrypoint/codex.ts index 5befd15..f1825c8 100644 --- a/packages/lib/src/core/templates-entrypoint/codex.ts +++ b/packages/lib/src/core/templates-entrypoint/codex.ts @@ -30,7 +30,13 @@ if [[ "$CODEX_SHARE_AUTH" == "1" ]]; then if [[ "$CODEX_LABEL_NORM" != "default" ]]; then 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_FILE" && -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; 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 mv "$AUTH_FILE" "$AUTH_FILE.bak-$(date +%s)" || true 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 8e686e7..af5ff20 100644 --- a/packages/lib/src/core/templates-entrypoint/nested-docker-git.ts +++ b/packages/lib/src/core/templates-entrypoint/nested-docker-git.ts @@ -10,19 +10,20 @@ 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_ORCH_ROOT="$BOOTSTRAP_ROOT/.orch" -BOOTSTRAP_AUTH_KEYS="$BOOTSTRAP_ROOT/authorized_keys" -BOOTSTRAP_CODEX_AUTH_DIR="$BOOTSTRAP_ORCH_ROOT/auth/codex" -BOOTSTRAP_CLAUDE_AUTH_DIR="$BOOTSTRAP_ORCH_ROOT/auth/claude" -BOOTSTRAP_ENV_GLOBAL="$BOOTSTRAP_ORCH_ROOT/env/global.env" -BOOTSTRAP_ENV_PROJECT="$BOOTSTRAP_ORCH_ROOT/env/project.env" +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_CLAUDE_AUTH_DIR" "$DOCKER_GIT_ENV_DIR" "$DOCKER_GIT_HOME/.orch/auth/gh" -copy_if_missing_file() { +sync_file_if_present() { local source="$1" local target="$2" - if [[ ! -f "$source" || -e "$target" ]]; then + if [[ ! -f "$source" ]]; then return 1 fi mkdir -p "$(dirname "$target")" @@ -30,7 +31,18 @@ copy_if_missing_file() { return 0 } -copy_dir_missing_entries() { +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 @@ -45,29 +57,56 @@ copy_dir_missing_entries() { local target_entry="$target/$entry" if [[ -d "$source_entry" ]]; then mkdir -p "$target_entry" - elif [[ -f "$source_entry" && ! -e "$target_entry" ]]; then + 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" fi -copy_if_missing_file "$BOOTSTRAP_AUTH_KEYS" "$DOCKER_GIT_AUTH_KEYS" || true +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 -copy_if_missing_file "$BOOTSTRAP_ENV_GLOBAL" "$DOCKER_GIT_ENV_GLOBAL" || true +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 -copy_if_missing_file "$BOOTSTRAP_ENV_PROJECT" "$DOCKER_GIT_ENV_PROJECT" || true +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 @@ -108,8 +147,9 @@ copy_if_distinct_file() { return 0 } -copy_dir_missing_entries "$BOOTSTRAP_CODEX_AUTH_DIR" "$DOCKER_GIT_AUTH_DIR" -copy_dir_missing_entries "$BOOTSTRAP_CLAUDE_AUTH_DIR" "$DOCKER_GIT_CLAUDE_AUTH_DIR" +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" @@ -129,6 +169,8 @@ if [[ -f "$SOURCE_SHARED_AUTH" ]]; then copy_if_distinct_file "$SOURCE_SHARED_AUTH" "$DOCKER_GIT_AUTH_DIR/auth.json" || true elif [[ -f "$SOURCE_LOCAL_AUTH" ]]; then copy_if_distinct_file "$SOURCE_LOCAL_AUTH" "$DOCKER_GIT_AUTH_DIR/auth.json" || true +else + rm -f "$DOCKER_GIT_AUTH_DIR/auth.json" || true fi if [[ -f "$DOCKER_GIT_AUTH_DIR/auth.json" ]]; then chmod 600 "$DOCKER_GIT_AUTH_DIR/auth.json" || true @@ -139,4 +181,7 @@ 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 9fbfd3b..27d0f28 100644 --- a/packages/lib/src/core/templates.ts +++ b/packages/lib/src/core/templates.ts @@ -25,6 +25,10 @@ authorized_keys const renderDockerignore = (): string => `# docker-git build context +authorized_keys +.orch/env/ +.orch/auth/codex/ +.orch/auth/claude/ .orch/auth/codex/log/ .orch/auth/codex/tmp/ .orch/auth/codex/sessions/ diff --git a/packages/lib/src/core/templates/docker-compose.ts b/packages/lib/src/core/templates/docker-compose.ts index 0870ef3..59eab1d 100644 --- a/packages/lib/src/core/templates/docker-compose.ts +++ b/packages/lib/src/core/templates/docker-compose.ts @@ -17,10 +17,15 @@ type ComposeFragments = { readonly maybeDependsOn: string readonly maybePlaywrightEnv: string readonly maybeBrowserService: string + readonly maybeBrowserVolume: string + readonly maybeBootstrapMounts: string readonly forkRepoUrl: string } -type PlaywrightFragments = Pick +type PlaywrightFragments = Pick< + ComposeFragments, + "maybeDependsOn" | "maybePlaywrightEnv" | "maybeBrowserService" | "maybeBrowserVolume" +> const sharedCodexVolumeKey = "docker_git_shared_codex" const sharedCacheVolumeKey = "docker_git_shared_cache" @@ -54,6 +59,50 @@ const renderResourceLimits = (resourceLimits: ResolvedComposeResourceLimits | un resourceLimits === undefined ? "" : ` cpus: ${resourceLimits.cpuLimit}\n mem_limit: "${resourceLimits.ramLimit}"\n memswap_limit: "${resourceLimits.ramLimit}"\n` + +const renderProjectHostPath = (value: string): string => { + if (value.startsWith("/")) { + return value + } + + const normalized = value.startsWith("./") ? value.slice(2) : value + return `\${DOCKER_GIT_PROJECT_DIR_HOST:-.}/${normalized}` +} + +const splitPath = (value: string): { readonly dir: string; readonly base: string } => { + const normalized = value.replaceAll("\\", "/") + const separatorIndex = normalized.lastIndexOf("/") + if (separatorIndex === -1) { + return { dir: ".", base: normalized } + } + return { + dir: separatorIndex === 0 ? "/" : normalized.slice(0, separatorIndex), + base: normalized.slice(separatorIndex + 1) + } +} + +const renderClaudeBootstrapSourceDir = (codexAuthPath: string): string => { + const normalized = codexAuthPath.replaceAll("\\", "/") + const separatorIndex = normalized.lastIndexOf("/") + const authRoot = separatorIndex === -1 ? ".orch/auth" : normalized.slice(0, separatorIndex) + return `${authRoot}/claude` +} + +const renderBootstrapMounts = (config: TemplateConfig): string => { + const authorizedKeys = splitPath(config.authorizedKeysPath) + const envGlobal = splitPath(config.envGlobalPath) + const envProject = splitPath(config.envProjectPath) + + return [ + ` - ${renderProjectHostPath(authorizedKeys.dir)}:/opt/docker-git/bootstrap/source/authorized-keys:ro`, + ` - ${renderProjectHostPath(envGlobal.dir)}:/opt/docker-git/bootstrap/source/env-global:ro`, + ` - ${renderProjectHostPath(envProject.dir)}:/opt/docker-git/bootstrap/source/env-project:ro`, + ` - ${renderProjectHostPath(config.codexAuthPath)}:/opt/docker-git/bootstrap/source/project-auth/codex:ro`, + ` - ${renderProjectHostPath(renderClaudeBootstrapSourceDir(config.codexAuthPath))}:/opt/docker-git/bootstrap/source/project-auth/claude:ro`, + ` - ${renderProjectHostPath(config.codexSharedAuthPath)}:/opt/docker-git/bootstrap/source/shared-auth/codex:ro` + ].join("\n") +} + const buildPlaywrightFragments = ( config: TemplateConfig, networkName: string, @@ -63,7 +112,8 @@ const buildPlaywrightFragments = ( return { maybeDependsOn: "", maybePlaywrightEnv: "", - maybeBrowserService: "" + maybeBrowserService: "", + maybeBrowserVolume: "" } } @@ -80,7 +130,8 @@ const buildPlaywrightFragments = ( maybeBrowserService: `\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 volumes:\n - ${browserVolumeName}:/data\n networks:\n - ${networkName}\n` + } environment:\n VNC_NOPW: "1"\n shm_size: "2gb"\n expose:\n - "9223"\n volumes:\n - ${browserVolumeName}:/data\n networks:\n - ${networkName}\n`, + maybeBrowserVolume: ` ${browserVolumeName}:` } } @@ -112,6 +163,8 @@ const buildComposeFragments = ( maybeDependsOn: playwright.maybeDependsOn, maybePlaywrightEnv: playwright.maybePlaywrightEnv, maybeBrowserService: playwright.maybeBrowserService, + maybeBrowserVolume: playwright.maybeBrowserVolume, + maybeBootstrapMounts: renderBootstrapMounts(config), forkRepoUrl } } @@ -144,6 +197,7 @@ ${renderResourceLimits(resourceLimits)} volumes: - ${config.volumeName}:/home/${config.sshUser} - ${sharedCacheVolumeKey}:/home/${config.sshUser}/.docker-git/.cache - ${sharedCodexVolumeKey}:${config.codexHome}-shared +${fragments.maybeBootstrapMounts} - /var/run/docker.sock:/var/run/docker.sock networks: - ${fragments.networkName} @@ -161,7 +215,7 @@ const renderComposeNetworks = ( ${networkName}: driver: bridge` -const renderComposeVolumes = (config: TemplateConfig, enableMcpPlaywright: boolean): string => +const renderComposeVolumes = (config: TemplateConfig, maybeBrowserVolume: string): string => [ "volumes:", ` ${config.volumeName}:`, @@ -171,8 +225,8 @@ const renderComposeVolumes = (config: TemplateConfig, enableMcpPlaywright: boole ` ${sharedCodexVolumeKey}:`, " external: true", ` name: ${dockerGitSharedCodexVolumeName}`, - ...(enableMcpPlaywright ? [` ${config.volumeName}-browser:`] : []) - ].join("\n") + maybeBrowserVolume + ].filter((entry) => entry.length > 0).join("\n") export const renderDockerCompose = ( config: TemplateConfig, @@ -182,6 +236,6 @@ export const renderDockerCompose = ( return [ renderComposeServices(config, fragments, resourceLimits), renderComposeNetworks(fragments.networkMode, fragments.networkName), - renderComposeVolumes(config, config.enableMcpPlaywright) + renderComposeVolumes(config, fragments.maybeBrowserVolume) ].join("\n\n") } diff --git a/packages/lib/src/core/templates/dockerfile.ts b/packages/lib/src/core/templates/dockerfile.ts index 674167b..f19dd47 100644 --- a/packages/lib/src/core/templates/dockerfile.ts +++ b/packages/lib/src/core/templates/dockerfile.ts @@ -222,9 +222,13 @@ 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 -COPY authorized_keys /opt/docker-git/bootstrap/authorized_keys -COPY .orch /opt/docker-git/bootstrap/.orch +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 a2743ad..6933679 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 index 63c0c8a..9311d08 100644 --- a/packages/lib/src/shell/docker-volume.ts +++ b/packages/lib/src/shell/docker-volume.ts @@ -13,36 +13,3 @@ export const runDockerVolumeCreate = ( runCommandWithExitCodes({ cwd, command: "docker", args: ["volume", "create", volumeName] }, [Number(ExitCode(0))], ( exitCode ) => new DockerCommandError({ exitCode })) - -const seedDockerVolumeScript = String.raw`set -eu -mkdir -p /dest -if [[ -d /src ]]; then - cp -an /src/. /dest/ 2>/dev/null || true - find /dest -type f -name auth.json -exec chmod 600 {} + >/dev/null 2>&1 || true -fi` - -export const runDockerVolumeSeedFromDir = ( - cwd: string, - volumeName: string, - sourceDir: string -): Effect.Effect => - runCommandWithExitCodes( - { - cwd, - command: "docker", - args: [ - "run", - "--rm", - "-v", - `${volumeName}:/dest`, - "-v", - `${sourceDir}:/src:ro`, - "ubuntu:24.04", - "bash", - "-lc", - seedDockerVolumeScript - ] - }, - [Number(ExitCode(0))], - (exitCode) => new DockerCommandError({ exitCode }) - ) diff --git a/packages/lib/src/usecases/shared-volume-seed.ts b/packages/lib/src/usecases/shared-volume-seed.ts index 3c209af..75b274c 100644 --- a/packages/lib/src/usecases/shared-volume-seed.ts +++ b/packages/lib/src/usecases/shared-volume-seed.ts @@ -1,32 +1,18 @@ 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, type TemplateConfig } from "../core/domain.js" -import { runDockerVolumeCreate, runDockerVolumeSeedFromDir } from "../shell/docker-volume.js" +import { runDockerVolumeCreate } from "../shell/docker-volume.js" import type { DockerCommandError } from "../shell/errors.js" -import { resolvePathFromCwd } from "./path-helpers.js" -type SharedVolumeSeedEnvironment = FileSystem.FileSystem | Path.Path | CommandExecutor +type SharedVolumeSeedEnvironment = CommandExecutor export const ensureSharedCodexVolumeReady = ( cwd: string, - config: Pick + _config: Pick ): Effect.Effect => Effect.gen(function*(_) { - const fs = yield* _(FileSystem.FileSystem) - const path = yield* _(Path.Path) - const sourceDir = resolvePathFromCwd(path, cwd, config.codexSharedAuthPath) - yield* _(runDockerVolumeCreate(cwd, dockerGitSharedCacheVolumeName)) yield* _(runDockerVolumeCreate(cwd, dockerGitSharedCodexVolumeName)) - - const sourceExists = yield* _(fs.exists(sourceDir)) - if (!sourceExists) { - return - } - - yield* _(runDockerVolumeSeedFromDir(cwd, dockerGitSharedCodexVolumeName, sourceDir)) }).pipe(Effect.asVoid) diff --git a/packages/lib/tests/usecases/prepare-files.test.ts b/packages/lib/tests/usecases/prepare-files.test.ts index b83d299..565300d 100644 --- a/packages/lib/tests/usecases/prepare-files.test.ts +++ b/packages/lib/tests/usecases/prepare-files.test.ts @@ -131,10 +131,12 @@ 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).toContain("COPY authorized_keys /opt/docker-git/bootstrap/authorized_keys") - expect(dockerfile).toContain("COPY .orch /opt/docker-git/bootstrap/.orch") + 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('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"') @@ -150,11 +152,20 @@ 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).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(':/opt/docker-git/bootstrap/source/authorized-keys:ro') + expect(composeBefore).toContain(':/opt/docker-git/bootstrap/source/env-global:ro') + expect(composeBefore).toContain(':/opt/docker-git/bootstrap/source/env-project:ro') + expect(composeBefore).toContain(':/opt/docker-git/bootstrap/source/project-auth/codex:ro') + expect(composeBefore).toContain(':/opt/docker-git/bootstrap/source/shared-auth/codex:ro') + expect(composeBefore).toContain(':/opt/docker-git/bootstrap/source/project-auth/claude:ro') expect(composeBefore).toContain("cpus:") expect(composeBefore).toContain('mem_limit: "') expect(composeBefore).not.toContain("dg-test-browser") diff --git a/scripts/e2e/runtime-volumes-ssh.sh b/scripts/e2e/runtime-volumes-ssh.sh index 5c1a920..c002c1a 100755 --- a/scripts/e2e/runtime-volumes-ssh.sh +++ b/scripts/e2e/runtime-volumes-ssh.sh @@ -116,6 +116,7 @@ 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 @@ -189,8 +190,8 @@ docker exec -u dev "$CONTAINER_NAME" bash -lc 'test -f ~/.docker-git/.orch/env/g 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 -f ~/.docker-git/.orch/auth/codex/config.toml' \ - || fail "expected bootstrap Codex config inside 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" From bd15b814a310184c02c9769d238fb373da0b4293 Mon Sep 17 00:00:00 2001 From: skulidropek <66840575+skulidropek@users.noreply.github.com> Date: Sat, 14 Mar 2026 22:50:51 +0000 Subject: [PATCH 08/12] fix(lint): split codex entrypoint templates --- packages/lib/src/core/templates-entrypoint.ts | 2 +- .../templates-entrypoint/agents-notice.ts | 115 ++++++++++++++++++ .../src/core/templates-entrypoint/codex.ts | 114 ----------------- .../templates-entrypoint/nested-docker-git.ts | 10 +- .../lib/src/core/templates/docker-compose.ts | 4 +- packages/lib/src/usecases/projects-up.ts | 2 +- 6 files changed, 128 insertions(+), 119 deletions(-) create mode 100644 packages/lib/src/core/templates-entrypoint/agents-notice.ts diff --git a/packages/lib/src/core/templates-entrypoint.ts b/packages/lib/src/core/templates-entrypoint.ts index 254b97b..d858b6b 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, @@ -13,7 +14,6 @@ import { } from "./templates-entrypoint/base.js" import { renderEntrypointClaudeConfig } from "./templates-entrypoint/claude.js" import { - renderEntrypointAgentsNotice, renderEntrypointCodexHome, renderEntrypointCodexResumeHint, renderEntrypointCodexSharedAuth, 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 0000000..a4bd55e --- /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/codex.ts b/packages/lib/src/core/templates-entrypoint/codex.ts index f1825c8..38fa3f0 100644 --- a/packages/lib/src/core/templates-entrypoint/codex.ts +++ b/packages/lib/src/core/templates-entrypoint/codex.ts @@ -213,117 +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/nested-docker-git.ts b/packages/lib/src/core/templates-entrypoint/nested-docker-git.ts index af5ff20..81e11b0 100644 --- a/packages/lib/src/core/templates-entrypoint/nested-docker-git.ts +++ b/packages/lib/src/core/templates-entrypoint/nested-docker-git.ts @@ -181,7 +181,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( + "__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( + "__ENV_PROJECT_BASENAME__", + config.envProjectPath.replaceAll("\\", "/").split("/").at(-1) ?? "project.env" + ) .replaceAll("__CODEX_HOME__", config.codexHome) diff --git a/packages/lib/src/core/templates/docker-compose.ts b/packages/lib/src/core/templates/docker-compose.ts index 59eab1d..f31c262 100644 --- a/packages/lib/src/core/templates/docker-compose.ts +++ b/packages/lib/src/core/templates/docker-compose.ts @@ -98,7 +98,9 @@ const renderBootstrapMounts = (config: TemplateConfig): string => { ` - ${renderProjectHostPath(envGlobal.dir)}:/opt/docker-git/bootstrap/source/env-global:ro`, ` - ${renderProjectHostPath(envProject.dir)}:/opt/docker-git/bootstrap/source/env-project:ro`, ` - ${renderProjectHostPath(config.codexAuthPath)}:/opt/docker-git/bootstrap/source/project-auth/codex:ro`, - ` - ${renderProjectHostPath(renderClaudeBootstrapSourceDir(config.codexAuthPath))}:/opt/docker-git/bootstrap/source/project-auth/claude:ro`, + ` - ${ + renderProjectHostPath(renderClaudeBootstrapSourceDir(config.codexAuthPath)) + }:/opt/docker-git/bootstrap/source/project-auth/claude:ro`, ` - ${renderProjectHostPath(config.codexSharedAuthPath)}:/opt/docker-git/bootstrap/source/shared-auth/codex:ro` ].join("\n") } diff --git a/packages/lib/src/usecases/projects-up.ts b/packages/lib/src/usecases/projects-up.ts index 6da5447..5d42418 100644 --- a/packages/lib/src/usecases/projects-up.ts +++ b/packages/lib/src/usecases/projects-up.ts @@ -25,8 +25,8 @@ import { ensureCodexConfigFile } from "./auth-sync.js" import { ensureComposeNetworkReady } from "./docker-network-gc.js" import { loadReservedPorts, selectAvailablePort } from "./ports-reserve.js" import { parseComposePsOutput } from "./projects-core.js" -import { ensureSharedCodexVolumeReady } from "./shared-volume-seed.js" import { resolveTemplateResourceLimits } from "./resource-limits.js" +import { ensureSharedCodexVolumeReady } from "./shared-volume-seed.js" const maxPortAttempts = 25 From 22102a365111bbecb9571457212a53cee8197ffd Mon Sep 17 00:00:00 2001 From: skulidropek <66840575+skulidropek@users.noreply.github.com> Date: Sun, 15 Mar 2026 07:41:20 +0000 Subject: [PATCH 09/12] fix(e2e): restore shared auth bootstrap sync --- packages/lib/src/core/templates-entrypoint/nested-docker-git.ts | 2 -- 1 file changed, 2 deletions(-) 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 81e11b0..8e65074 100644 --- a/packages/lib/src/core/templates-entrypoint/nested-docker-git.ts +++ b/packages/lib/src/core/templates-entrypoint/nested-docker-git.ts @@ -169,8 +169,6 @@ if [[ -f "$SOURCE_SHARED_AUTH" ]]; then copy_if_distinct_file "$SOURCE_SHARED_AUTH" "$DOCKER_GIT_AUTH_DIR/auth.json" || true elif [[ -f "$SOURCE_LOCAL_AUTH" ]]; then copy_if_distinct_file "$SOURCE_LOCAL_AUTH" "$DOCKER_GIT_AUTH_DIR/auth.json" || true -else - rm -f "$DOCKER_GIT_AUTH_DIR/auth.json" || true fi if [[ -f "$DOCKER_GIT_AUTH_DIR/auth.json" ]]; then chmod 600 "$DOCKER_GIT_AUTH_DIR/auth.json" || true From 00ffe39a9381e8ee51f5e05c13cd97e883802c63 Mon Sep 17 00:00:00 2001 From: skulidropek <66840575+skulidropek@users.noreply.github.com> Date: Sun, 15 Mar 2026 18:37:23 +0000 Subject: [PATCH 10/12] feat(api): move docker-git control plane into controller --- README.md | 58 +++-- ctl | 214 +++++++++++++++--- docker-compose.api.yml | 7 +- docker-compose.yml | 28 ++- packages/api/README.md | 76 +++---- packages/api/src/services/projects.ts | 13 +- packages/lib/src/core/domain.ts | 14 ++ .../templates-entrypoint/nested-docker-git.ts | 55 +++++ .../lib/src/core/templates/docker-compose.ts | 56 +---- packages/lib/src/shell/docker-volume.ts | 29 +++ .../lib/src/usecases/actions/docker-up.ts | 3 +- packages/lib/src/usecases/projects.ts | 1 + .../lib/src/usecases/shared-volume-seed.ts | 155 ++++++++++++- .../usecases/create-project-open-ssh.test.ts | 57 +++++ .../lib/tests/usecases/prepare-files.test.ts | 16 +- .../lib/tests/usecases/projects-up.test.ts | 11 + 16 files changed, 621 insertions(+), 172 deletions(-) diff --git a/README.md b/README.md index a4e4932..2d5c1b5 100644 --- a/README.md +++ b/README.md @@ -1,59 +1,71 @@ # docker-git `docker-git` создаёт отдельную Docker-среду для каждого репозитория, issue или PR. -По умолчанию управляющие файлы проекта лежат в `~/.docker-git`, а runtime workspace, `.docker-git` state и auth живут внутри Docker-managed volumes контейнера. + +Теперь есть 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 -Можно передавать ссылку на репозиторий, ветку (`/tree/...`), issue или PR. +## Legacy Host CLI ```bash -docker-git clone https://github.com/ProverCoderAI/docker-git/issues/122 --force --mcp-playwright +npm i -g @prover-coder-ai/docker-git +docker-git --help ``` -- `--force` пересоздаёт окружение и удаляет volumes проекта. -- `--mcp-playwright` включает Playwright MCP и Chromium sidecar для браузерной автоматизации. +## Пример -Автоматический запуск агента: +Через API controller можно создать проект и потом поднять его отдельно: ```bash -docker-git clone https://github.com/ProverCoderAI/docker-git/issues/122 --force --auto +./ctl request POST /projects '{"repoUrl":"https://github.com/ProverCoderAI/docker-git.git","repoRef":"main","up":false}' +./ctl projects ``` -- `--auto` сам выбирает Claude или Codex по доступной авторизации. Если доступны оба, выбор случайный. -- `--auto=claude` или `--auto=codex` принудительно выбирает агента. -- В auto-режиме агент сам выполняет задачу, создаёт PR и после завершения контейнер очищается. +API возвращает `projectId`, после чего можно: -## Проверка Docker runtime +```bash +./ctl request POST /projects//up +./ctl request GET /projects//logs +./ctl request POST /projects//down +``` -Воспроизводимая smoke-проверка для Docker runtime и host CLI: +## Проверка Docker runtime ```bash pnpm run e2e:runtime-volumes-ssh ``` -Сценарий доказывает, что контейнер стартует через Docker, runtime state живёт в named volumes, а `docker-git clone --no-ssh` печатает готовую host CLI команду `SSH access: ...`, которая реально подключает в контейнер, показывает workspace context и видит установленный `codex`. +Сценарий доказывает, что контейнер стартует через Docker, runtime state живёт в named volumes, а SSH реально заходит в дочерний project container. ## Подробности diff --git a/ctl b/ctl index 6dcb70a..5e2b59f 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 4f68d18..962c1ef 100644 --- a/docker-compose.api.yml +++ b/docker-compose.api.yml @@ -7,11 +7,16 @@ 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}" 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 768e63c..962c1ef 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -1,18 +1,22 @@ 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}" 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/packages/api/README.md b/packages/api/README.md index 1875623..6be1d4b 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 c91f7e4..42e4a15 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/lib/src/core/domain.ts b/packages/lib/src/core/domain.ts index db9c855..af6ac29 100644 --- a/packages/lib/src/core/domain.ts +++ b/packages/lib/src/core/domain.ts @@ -314,6 +314,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/nested-docker-git.ts b/packages/lib/src/core/templates-entrypoint/nested-docker-git.ts index 8e65074..898cd84 100644 --- a/packages/lib/src/core/templates-entrypoint/nested-docker-git.ts +++ b/packages/lib/src/core/templates-entrypoint/nested-docker-git.ts @@ -130,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 1 + 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" @@ -160,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 diff --git a/packages/lib/src/core/templates/docker-compose.ts b/packages/lib/src/core/templates/docker-compose.ts index f31c262..8036821 100644 --- a/packages/lib/src/core/templates/docker-compose.ts +++ b/packages/lib/src/core/templates/docker-compose.ts @@ -2,6 +2,7 @@ import { dockerGitSharedCacheVolumeName, dockerGitSharedCodexVolumeName, resolveComposeNetworkName, + resolveProjectBootstrapVolumeName, type TemplateConfig } from "../domain.js" import type { ResolvedComposeResourceLimits } from "../resource-limits.js" @@ -29,6 +30,7 @@ type PlaywrightFragments = Pick< 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 @@ -60,50 +62,8 @@ const renderResourceLimits = (resourceLimits: ResolvedComposeResourceLimits | un ? "" : ` cpus: ${resourceLimits.cpuLimit}\n mem_limit: "${resourceLimits.ramLimit}"\n memswap_limit: "${resourceLimits.ramLimit}"\n` -const renderProjectHostPath = (value: string): string => { - if (value.startsWith("/")) { - return value - } - - const normalized = value.startsWith("./") ? value.slice(2) : value - return `\${DOCKER_GIT_PROJECT_DIR_HOST:-.}/${normalized}` -} - -const splitPath = (value: string): { readonly dir: string; readonly base: string } => { - const normalized = value.replaceAll("\\", "/") - const separatorIndex = normalized.lastIndexOf("/") - if (separatorIndex === -1) { - return { dir: ".", base: normalized } - } - return { - dir: separatorIndex === 0 ? "/" : normalized.slice(0, separatorIndex), - base: normalized.slice(separatorIndex + 1) - } -} - -const renderClaudeBootstrapSourceDir = (codexAuthPath: string): string => { - const normalized = codexAuthPath.replaceAll("\\", "/") - const separatorIndex = normalized.lastIndexOf("/") - const authRoot = separatorIndex === -1 ? ".orch/auth" : normalized.slice(0, separatorIndex) - return `${authRoot}/claude` -} - -const renderBootstrapMounts = (config: TemplateConfig): string => { - const authorizedKeys = splitPath(config.authorizedKeysPath) - const envGlobal = splitPath(config.envGlobalPath) - const envProject = splitPath(config.envProjectPath) - - return [ - ` - ${renderProjectHostPath(authorizedKeys.dir)}:/opt/docker-git/bootstrap/source/authorized-keys:ro`, - ` - ${renderProjectHostPath(envGlobal.dir)}:/opt/docker-git/bootstrap/source/env-global:ro`, - ` - ${renderProjectHostPath(envProject.dir)}:/opt/docker-git/bootstrap/source/env-project:ro`, - ` - ${renderProjectHostPath(config.codexAuthPath)}:/opt/docker-git/bootstrap/source/project-auth/codex:ro`, - ` - ${ - renderProjectHostPath(renderClaudeBootstrapSourceDir(config.codexAuthPath)) - }:/opt/docker-git/bootstrap/source/project-auth/claude:ro`, - ` - ${renderProjectHostPath(config.codexSharedAuthPath)}:/opt/docker-git/bootstrap/source/shared-auth/codex:ro` - ].join("\n") -} +const renderBootstrapMounts = (): string => + ` - ${bootstrapVolumeKey}:/opt/docker-git/bootstrap/source:ro` const buildPlaywrightFragments = ( config: TemplateConfig, @@ -166,7 +126,7 @@ const buildComposeFragments = ( maybePlaywrightEnv: playwright.maybePlaywrightEnv, maybeBrowserService: playwright.maybeBrowserService, maybeBrowserVolume: playwright.maybeBrowserVolume, - maybeBootstrapMounts: renderBootstrapMounts(config), + maybeBootstrapMounts: renderBootstrapMounts(), forkRepoUrl } } @@ -190,9 +150,7 @@ ${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: @@ -221,6 +179,8 @@ const renderComposeVolumes = (config: TemplateConfig, maybeBrowserVolume: string [ "volumes:", ` ${config.volumeName}:`, + ` ${bootstrapVolumeKey}:`, + ` name: ${resolveProjectBootstrapVolumeName(config)}`, ` ${sharedCacheVolumeKey}:`, " external: true", ` name: ${dockerGitSharedCacheVolumeName}`, diff --git a/packages/lib/src/shell/docker-volume.ts b/packages/lib/src/shell/docker-volume.ts index 9311d08..05d6b78 100644 --- a/packages/lib/src/shell/docker-volume.ts +++ b/packages/lib/src/shell/docker-volume.ts @@ -6,6 +6,8 @@ import type { Effect } from "effect" import { runCommandWithExitCodes } from "./command-runner.js" import { DockerCommandError } from "./errors.js" +const shellEscape = (value: string): string => `'${value.replaceAll("'", "'\\''")}'` + export const runDockerVolumeCreate = ( cwd: string, volumeName: string @@ -13,3 +15,30 @@ export const runDockerVolumeCreate = ( 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 command = + `tar -C ${shellEscape(sourceDir)} -cf - . | ` + + `docker run --rm -i -v ${shellEscape(`${volumeName}:/target`)} alpine:3.20 ` + + `sh -euc ${shellEscape("mkdir -p /target && find /target -mindepth 1 -maxdepth 1 -exec rm -rf -- {} + && tar -xf - -C /target")}` + + return runCommandWithExitCodes( + { cwd, command: "bash", args: ["-lc", 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 93ec242..50eba6b 100644 --- a/packages/lib/src/usecases/actions/docker-up.ts +++ b/packages/lib/src/usecases/actions/docker-up.ts @@ -176,15 +176,16 @@ const runDockerComposeUpByMode = ( ): Effect.Effect => Effect.gen(function*(_) { yield* _(ensureComposeNetworkReady(resolvedOutDir, projectConfig)) - yield* _(ensureSharedCodexVolumeReady(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/projects.ts b/packages/lib/src/usecases/projects.ts index 0bad9f4..060f8b3 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 index 75b274c..c143851 100644 --- a/packages/lib/src/usecases/shared-volume-seed.ts +++ b/packages/lib/src/usecases/shared-volume-seed.ts @@ -1,18 +1,165 @@ 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, type TemplateConfig } from "../core/domain.js" -import { runDockerVolumeCreate } from "../shell/docker-volume.js" +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 +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)) + }) + +const stageBootstrapSnapshot = ( + stagingDir: string, + projectDir: string, + config: Pick< + TemplateConfig, + "authorizedKeysPath" | "envGlobalPath" | "envProjectPath" | "codexAuthPath" | "codexSharedAuthPath" + > +): Effect.Effect => + Effect.gen(function*(_) { + const fs = yield* _(FileSystem.FileSystem) + const path = yield* _(Path.Path) + + const authorizedKeysSource = resolvePathFromBase(path, projectDir, config.authorizedKeysPath) + const envGlobalSource = resolvePathFromBase(path, projectDir, config.envGlobalPath) + const envProjectSource = resolvePathFromBase(path, projectDir, config.envProjectPath) + const codexAuthSource = resolvePathFromBase(path, projectDir, config.codexAuthPath) + const codexSharedAuthSource = resolvePathFromBase(path, projectDir, config.codexSharedAuthPath) + const claudeAuthSource = path.join(path.dirname(codexAuthSource), "claude") + + 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" + + yield* _(fs.makeDirectory(path.join(stagingDir, "authorized-keys"), { recursive: true })) + yield* _(fs.makeDirectory(path.join(stagingDir, "env-global"), { recursive: true })) + yield* _(fs.makeDirectory(path.join(stagingDir, "env-project"), { recursive: true })) + yield* _(fs.makeDirectory(path.join(stagingDir, "project-auth", "codex"), { recursive: true })) + yield* _(fs.makeDirectory(path.join(stagingDir, "project-auth", "claude"), { recursive: true })) + yield* _(fs.makeDirectory(path.join(stagingDir, "shared-auth", "codex"), { recursive: true })) + + yield* _( + copyFileIfPresent( + fs, + path, + authorizedKeysSource, + path.join(stagingDir, "authorized-keys", authorizedKeysBase) + ) + ) + yield* _( + copyFileIfPresent( + fs, + path, + envGlobalSource, + path.join(stagingDir, "env-global", envGlobalBase) + ) + ) + yield* _( + copyFileIfPresent( + fs, + path, + envProjectSource, + path.join(stagingDir, "env-project", envProjectBase) + ) + ) + yield* _(copyDirRecursive(fs, path, codexAuthSource, path.join(stagingDir, "project-auth", "codex"))) + yield* _(copyDirRecursive(fs, path, claudeAuthSource, path.join(stagingDir, "project-auth", "claude"))) + yield* _(copyDirRecursive(fs, path, codexSharedAuthSource, path.join(stagingDir, "shared-auth", "codex"))) + }) + +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 + 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/usecases/create-project-open-ssh.test.ts b/packages/lib/tests/usecases/create-project-open-ssh.test.ts index 26b618b..bde4ab5 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] === "-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 565300d..ee1e647 100644 --- a/packages/lib/tests/usecases/prepare-files.test.ts +++ b/packages/lib/tests/usecases/prepare-files.test.ts @@ -137,6 +137,12 @@ describe("prepareProjectFiles", () => { 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"') @@ -160,12 +166,10 @@ describe("prepareProjectFiles", () => { 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(':/opt/docker-git/bootstrap/source/authorized-keys:ro') - expect(composeBefore).toContain(':/opt/docker-git/bootstrap/source/env-global:ro') - expect(composeBefore).toContain(':/opt/docker-git/bootstrap/source/env-project:ro') - expect(composeBefore).toContain(':/opt/docker-git/bootstrap/source/project-auth/codex:ro') - expect(composeBefore).toContain(':/opt/docker-git/bootstrap/source/shared-auth/codex:ro') - expect(composeBefore).toContain(':/opt/docker-git/bootstrap/source/project-auth/claude:ro') + 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") diff --git a/packages/lib/tests/usecases/projects-up.test.ts b/packages/lib/tests/usecases/projects-up.test.ts index 6d477d1..3d2052a 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] === "-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))) From 712cec13eda9acf7eb8033ea1dee8b85620b12d6 Mon Sep 17 00:00:00 2001 From: skulidropek <66840575+skulidropek@users.noreply.github.com> Date: Sun, 15 Mar 2026 19:21:02 +0000 Subject: [PATCH 11/12] fix(ci): restore bootstrap and e2e flows --- .../lib/src/core/templates-entrypoint/base.ts | 3 +- .../templates-entrypoint/nested-docker-git.ts | 2 +- .../lib/src/core/templates/docker-compose.ts | 3 +- packages/lib/src/core/templates/dockerfile.ts | 24 ++- packages/lib/src/shell/docker-volume.ts | 17 +- .../lib/src/usecases/shared-volume-seed.ts | 157 ++++++++++++------ .../usecases/create-project-open-ssh.test.ts | 2 +- .../lib/tests/usecases/projects-up.test.ts | 2 +- scripts/e2e/_lib.sh | 18 +- 9 files changed, 157 insertions(+), 71 deletions(-) diff --git a/packages/lib/src/core/templates-entrypoint/base.ts b/packages/lib/src/core/templates-entrypoint/base.ts index bbe3fd7..763469a 100644 --- a/packages/lib/src/core/templates-entrypoint/base.ts +++ b/packages/lib/src/core/templates-entrypoint/base.ts @@ -163,4 +163,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/nested-docker-git.ts b/packages/lib/src/core/templates-entrypoint/nested-docker-git.ts index 898cd84..4e8cdf6 100644 --- a/packages/lib/src/core/templates-entrypoint/nested-docker-git.ts +++ b/packages/lib/src/core/templates-entrypoint/nested-docker-git.ts @@ -136,7 +136,7 @@ docker_git_export_env_if_unset() { if [[ -n "${"$"}{!key+x}" ]]; then docker_git_upsert_ssh_env "$key" "${"$"}{!key}" - return 1 + return 0 fi export "$key=$value" diff --git a/packages/lib/src/core/templates/docker-compose.ts b/packages/lib/src/core/templates/docker-compose.ts index 8036821..850e010 100644 --- a/packages/lib/src/core/templates/docker-compose.ts +++ b/packages/lib/src/core/templates/docker-compose.ts @@ -62,8 +62,7 @@ const renderResourceLimits = (resourceLimits: ResolvedComposeResourceLimits | un ? "" : ` 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 renderBootstrapMounts = (): string => ` - ${bootstrapVolumeKey}:/opt/docker-git/bootstrap/source:ro` const buildPlaywrightFragments = ( config: TemplateConfig, diff --git a/packages/lib/src/core/templates/dockerfile.ts b/packages/lib/src/core/templates/dockerfile.ts index f19dd47..391dbab 100644 --- a/packages/lib/src/core/templates/dockerfile.ts +++ b/packages/lib/src/core/templates/dockerfile.ts @@ -65,11 +65,31 @@ RUN claude --version` 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 -- --no-modify-path; then \ + tmp_archive="$(mktemp)"; \ + if curl -fsSL --retry 5 --retry-all-errors --retry-delay 2 \ + "https://github.com/anomalyco/opencode/releases/latest/download/$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; \ diff --git a/packages/lib/src/shell/docker-volume.ts b/packages/lib/src/shell/docker-volume.ts index 05d6b78..c81d0c0 100644 --- a/packages/lib/src/shell/docker-volume.ts +++ b/packages/lib/src/shell/docker-volume.ts @@ -6,7 +6,9 @@ import type { Effect } from "effect" import { runCommandWithExitCodes } from "./command-runner.js" import { DockerCommandError } from "./errors.js" -const shellEscape = (value: string): string => `'${value.replaceAll("'", "'\\''")}'` +const escapedSingleQuote = String.raw`'\''` + +const shellEscape = (value: string): string => `'${value.replaceAll("'", escapedSingleQuote)}'` export const runDockerVolumeCreate = ( cwd: string, @@ -31,13 +33,16 @@ export const runDockerVolumeReplaceFromDirectory = ( volumeName: string, sourceDir: string ): Effect.Effect => { - const command = - `tar -C ${shellEscape(sourceDir)} -cf - . | ` + - `docker run --rm -i -v ${shellEscape(`${volumeName}:/target`)} alpine:3.20 ` + - `sh -euc ${shellEscape("mkdir -p /target && find /target -mindepth 1 -maxdepth 1 -exec rm -rf -- {} + && tar -xf - -C /target")}` + 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: ["-lc", command] }, + { cwd, command: "bash", args: ["-c", command] }, [Number(ExitCode(0))], (exitCode) => new DockerCommandError({ exitCode }) ) diff --git a/packages/lib/src/usecases/shared-volume-seed.ts b/packages/lib/src/usecases/shared-volume-seed.ts index c143851..cc9e24a 100644 --- a/packages/lib/src/usecases/shared-volume-seed.ts +++ b/packages/lib/src/usecases/shared-volume-seed.ts @@ -10,10 +10,7 @@ import { resolveProjectBootstrapVolumeName, type TemplateConfig } from "../core/domain.js" -import { - runDockerVolumeCreate, - runDockerVolumeReplaceFromDirectory -} from "../shell/docker-volume.js" +import { runDockerVolumeCreate, runDockerVolumeReplaceFromDirectory } from "../shell/docker-volume.js" import type { DockerCommandError } from "../shell/errors.js" type SharedVolumeSeedEnvironment = CommandExecutor | FileSystem.FileSystem | Path.Path @@ -74,63 +71,117 @@ const copyFileIfPresent = ( 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: Pick< - TemplateConfig, - "authorizedKeysPath" | "envGlobalPath" | "envProjectPath" | "codexAuthPath" | "codexSharedAuthPath" - > + config: BootstrapSeedConfig ): Effect.Effect => Effect.gen(function*(_) { const fs = yield* _(FileSystem.FileSystem) const path = yield* _(Path.Path) - const authorizedKeysSource = resolvePathFromBase(path, projectDir, config.authorizedKeysPath) - const envGlobalSource = resolvePathFromBase(path, projectDir, config.envGlobalPath) - const envProjectSource = resolvePathFromBase(path, projectDir, config.envProjectPath) - const codexAuthSource = resolvePathFromBase(path, projectDir, config.codexAuthPath) - const codexSharedAuthSource = resolvePathFromBase(path, projectDir, config.codexSharedAuthPath) - const claudeAuthSource = path.join(path.dirname(codexAuthSource), "claude") - - 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" - - yield* _(fs.makeDirectory(path.join(stagingDir, "authorized-keys"), { recursive: true })) - yield* _(fs.makeDirectory(path.join(stagingDir, "env-global"), { recursive: true })) - yield* _(fs.makeDirectory(path.join(stagingDir, "env-project"), { recursive: true })) - yield* _(fs.makeDirectory(path.join(stagingDir, "project-auth", "codex"), { recursive: true })) - yield* _(fs.makeDirectory(path.join(stagingDir, "project-auth", "claude"), { recursive: true })) - yield* _(fs.makeDirectory(path.join(stagingDir, "shared-auth", "codex"), { recursive: true })) - - yield* _( - copyFileIfPresent( - fs, - path, - authorizedKeysSource, - path.join(stagingDir, "authorized-keys", authorizedKeysBase) - ) - ) - yield* _( - copyFileIfPresent( - fs, - path, - envGlobalSource, - path.join(stagingDir, "env-global", envGlobalBase) - ) - ) - yield* _( - copyFileIfPresent( - fs, - path, - envProjectSource, - path.join(stagingDir, "env-project", envProjectBase) - ) - ) - yield* _(copyDirRecursive(fs, path, codexAuthSource, path.join(stagingDir, "project-auth", "codex"))) - yield* _(copyDirRecursive(fs, path, claudeAuthSource, path.join(stagingDir, "project-auth", "claude"))) - yield* _(copyDirRecursive(fs, path, codexSharedAuthSource, path.join(stagingDir, "shared-auth", "codex"))) + 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 = ( 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 bde4ab5..3f807f4 100644 --- a/packages/lib/tests/usecases/create-project-open-ssh.test.ts +++ b/packages/lib/tests/usecases/create-project-open-ssh.test.ts @@ -106,7 +106,7 @@ const isDockerComposeUp = (cmd: RecordedCommand): boolean => const isBootstrapSeed = (cmd: RecordedCommand): boolean => cmd.command === "bash" && - cmd.args[0] === "-lc" && + (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 => { diff --git a/packages/lib/tests/usecases/projects-up.test.ts b/packages/lib/tests/usecases/projects-up.test.ts index 3d2052a..a3070fc 100644 --- a/packages/lib/tests/usecases/projects-up.test.ts +++ b/packages/lib/tests/usecases/projects-up.test.ts @@ -64,7 +64,7 @@ const isDockerVolumeCreate = (cmd: RecordedCommand): boolean => const isBootstrapSeed = (cmd: RecordedCommand): boolean => cmd.command === "bash" && - cmd.args[0] === "-lc" && + (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 => diff --git a/scripts/e2e/_lib.sh b/scripts/e2e/_lib.sh index 3d250af..b419b2c 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 From f0f5daf9511fb20ac18baa437ba86ac39a9ca034 Mon Sep 17 00:00:00 2001 From: skulidropek <66840575+skulidropek@users.noreply.github.com> Date: Sat, 21 Mar 2026 20:45:46 +0000 Subject: [PATCH 12/12] fix(ci): satisfy lib lint after merge --- packages/lib/src/core/templates-entrypoint.ts | 2 +- .../core/templates-entrypoint/dns-repair.ts | 4 +- .../lib/src/usecases/actions/prepare-files.ts | 127 ++++++++++++++---- 3 files changed, 102 insertions(+), 31 deletions(-) diff --git a/packages/lib/src/core/templates-entrypoint.ts b/packages/lib/src/core/templates-entrypoint.ts index d22f836..0ac3c05 100644 --- a/packages/lib/src/core/templates-entrypoint.ts +++ b/packages/lib/src/core/templates-entrypoint.ts @@ -12,7 +12,6 @@ import { renderEntrypointZshShell, renderEntrypointZshUserRc } from "./templates-entrypoint/base.js" -import { renderEntrypointDnsRepair } from "./templates-entrypoint/dns-repair.js" import { renderEntrypointClaudeConfig } from "./templates-entrypoint/claude.js" import { renderEntrypointCodexHome, @@ -20,6 +19,7 @@ import { 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" diff --git a/packages/lib/src/core/templates-entrypoint/dns-repair.ts b/packages/lib/src/core/templates-entrypoint/dns-repair.ts index b4a44ea..d5e52b8 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/usecases/actions/prepare-files.ts b/packages/lib/src/usecases/actions/prepare-files.ts index dc94748..d17c2c9 100644 --- a/packages/lib/src/usecases/actions/prepare-files.ts +++ b/packages/lib/src/usecases/actions/prepare-files.ts @@ -85,6 +85,88 @@ 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, @@ -107,41 +189,30 @@ const ensureAuthorizedKeys = ( return } - const preferred = resolvePathFromBase(path, baseDir, preferredSource) - const preferredExists = yield* _(fs.exists(preferred)) - const preferredManagedSource = preferredExists && preferred !== resolved ? preferred : null - const source = preferredManagedSource === null - ? yield* _(resolveAuthorizedKeysSource(fs, path, process.cwd())) - : preferredManagedSource + const source = yield* _( + resolveManagedAuthorizedKeysSource(fs, path, baseDir, preferredSource, resolved) + ) if (source === null) { - 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.` - ) - ) - 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.`)) + 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 + }) + ) }) )