Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
12 changes: 12 additions & 0 deletions .changeset/entrypoint-clone-app-folder.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,12 @@
---
"@prover-coder-ai/docker-git": patch
---

Fix the standalone base image cloning the repo outside the prepared `app` folder.

The Dockerfile prepares and chowns `/home/dev/app` to the unprivileged `dev`
user, but `entrypoint.sh` defaulted `TARGET_DIR` to `/work/app`. Because the
auto-clone runs as `su - dev`, cloning into the root-created `/work/app` failed
with permission denied, so the repository never landed in the `app` folder.
The default now points at `/home/dev/app`, and the resolved `TARGET_DIR` is
chowned to `dev` so overrides outside `/home/dev` keep working too.
7 changes: 6 additions & 1 deletion entrypoint.sh
Original file line number Diff line number Diff line change
Expand Up @@ -49,7 +49,7 @@ docker_git_repair_dns || true

REPO_URL="${REPO_URL:-}"
REPO_REF="${REPO_REF:-}"
TARGET_DIR="${TARGET_DIR:-/work/app}"
TARGET_DIR="${TARGET_DIR:-/home/dev/app}"

# 1) Authorized keys are mounted from host at /authorized_keys
mkdir -p /home/dev/.ssh
Expand All @@ -70,6 +70,11 @@ chown -R dev:dev /home/dev/.codex
if [[ -n "$REPO_URL" && ! -d "$TARGET_DIR/.git" ]]; then
mkdir -p "$TARGET_DIR"
chown -R dev:dev /home/dev
# git clone runs as `su - dev`, so the target must be writable by dev even when
# TARGET_DIR is overridden to a path outside /home/dev (otherwise clone fails silently).
if [[ "$TARGET_DIR" != "/" ]]; then
chown -R dev:dev "$TARGET_DIR"
fi

if [[ -n "$REPO_REF" ]]; then
su - dev -c "git clone --branch '$REPO_REF' '$REPO_URL' '$TARGET_DIR'"
Expand Down
69 changes: 69 additions & 0 deletions packages/lib/tests/shell/entrypoint-clone-target.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,69 @@
// CHANGE: pin the standalone base-image clone target to the dev-owned app folder
// WHY: entrypoint runs `git clone` as `su - dev`; cloning into a root-owned dir (e.g. /work/app)
// fails with permission denied, so the repo never lands in the prepared `app` folder
// QUOTE(ТЗ): "Почему-то при docker-git clone не делается git clone в папку app"
// REF: issue-408
// SOURCE: n/a
// FORMAT THEOREM: defaultTargetDir(entrypoint) = appDir(Dockerfile) ∧ appDir ⊂ chown(dev, /home/dev)
// PURITY: SHELL (reads repository source files via @effect/platform FileSystem)
// INVARIANT: clone target is owned by the unprivileged clone user
// COMPLEXITY: O(|file|)
import * as FileSystem from "@effect/platform/FileSystem"
import * as Path from "@effect/platform/Path"
import { NodeContext } from "@effect/platform-node"
import { describe, expect, it } from "@effect/vitest"
import { Effect } from "effect"

// CHANGE: derive the dev home that the Dockerfile recursively chowns to the clone user
// WHY: `git clone` runs as `su - dev`, so the target must live under a dev-owned path
// PURITY: CORE
// INVARIANT: returns the `chown -R dev:dev <home>` argument, or "" when absent
const matchChownedHome = (dockerfile: string): string =>
dockerfile.match(/chown -R dev:dev (\/home\/dev)\b/)?.[1] ?? ""

// CHANGE: derive the default TARGET_DIR the entrypoint clones into
// WHY: the bug is a wrong default that points outside the dev-owned home
// PURITY: CORE
// INVARIANT: returns the `TARGET_DIR="${TARGET_DIR:-<default>}"` value, or "" when absent
const matchDefaultTargetDir = (entrypoint: string): string =>
entrypoint.match(/TARGET_DIR="\$\{TARGET_DIR:-([^}]+)\}"/)?.[1] ?? ""

// CHANGE: read the repo-root entrypoint + Dockerfile through the Effect FileSystem service
// WHY: lint forbids node:* imports; @effect/platform is the sanctioned IO boundary
// PURITY: SHELL
// EFFECT: Effect<{ entrypoint, dockerfile }, PlatformError | BadArgument, FileSystem | Path>
const readRepoSources = Effect.gen(function*(_) {
const fs = yield* _(FileSystem.FileSystem)
const path = yield* _(Path.Path)
const here = yield* _(path.fromFileUrl(new URL(import.meta.url)))
// packages/lib/tests/shell -> repository root
const repoRoot = path.resolve(path.dirname(here), "..", "..", "..", "..")
const entrypoint = yield* _(fs.readFileString(path.join(repoRoot, "entrypoint.sh")))
const dockerfile = yield* _(fs.readFileString(path.join(repoRoot, "Dockerfile")))
return { dockerfile, entrypoint }
})

describe("standalone base-image clone target", () => {
it.effect("prepares an app folder under the dev home in the Dockerfile", () =>
Effect.gen(function*(_) {
const { dockerfile } = yield* _(readRepoSources)
expect(dockerfile).toContain("mkdir -p /home/dev/app")
expect(matchChownedHome(dockerfile)).toBe("/home/dev")
}).pipe(Effect.provide(NodeContext.layer)))

it.effect("defaults the clone target to the prepared app folder", () =>
Effect.gen(function*(_) {
const { entrypoint } = yield* _(readRepoSources)
expect(matchDefaultTargetDir(entrypoint)).toBe("/home/dev/app")
}).pipe(Effect.provide(NodeContext.layer)))

it.effect("keeps the clone target under the dev-owned home so `su - dev` can write into it", () =>
Effect.gen(function*(_) {
const { dockerfile, entrypoint } = yield* _(readRepoSources)
const chownedHome = matchChownedHome(dockerfile)
const defaultTargetDir = matchDefaultTargetDir(entrypoint)
expect(chownedHome).not.toBe("")
expect(defaultTargetDir).not.toBe("")
expect(`${defaultTargetDir}/`.startsWith(`${chownedHome}/`)).toBe(true)
}).pipe(Effect.provide(NodeContext.layer)))
})