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
52 changes: 52 additions & 0 deletions .kanban/changes/ABC-2/README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,52 @@
# ABC-2 Audit Trail

## Summary

ABC-2 implements repository clone caching for GitHub issue #138.

Requirement:

> "Что бы один и тот же репозиторий лежал бы в кеше и мы бы грузили данные из кеша + git pull под нужную нам ветку"

Final behavior:

- Project containers mount the shared cache volume at `/home/dev/.docker-git/.cache`.
- Repository cache mirrors are stored under `/home/dev/.docker-git/.cache/git-mirrors/<sha256(repoUrl)>.git`.
- Warm-cache clones use the refreshed bare mirror as the clone source.
- The mirror is used only after a successful authenticated refresh from the real remote.
- Before using a mirror as clone source, the generated entrypoint verifies and repairs bare mirror `HEAD` to an existing `refs/heads/*` ref.
- PR/MR refs still fetch the requested ref from the authenticated upstream URL after clone.

## Commits

- `1cb29d1 fix(core): clone repositories from warm mirror cache`
- `90b98a8 fix(core): guard warm mirror clone reuse`

## Changed Files

- `packages/lib/src/core/templates-entrypoint/tasks.ts`
- `packages/app/src/lib/core/templates-entrypoint/tasks.ts`
- `packages/lib/tests/core/templates.test.ts`

## Invariants

- `refresh_success(cache, remote) -> may_clone_from(cache)`
- `refresh_failure(cache, remote) -> clone_source = authenticated_remote`
- `may_clone_from(cache) -> exists(cache.HEAD) && cache.HEAD in refs/heads/*`
- `repoUrl equality -> same mirror key`
- `requested repoRef preserved in final working tree`

## Review Closure

The first implementation introduced two P1 risks:

- A mirror bootstrapped from an `issue-*` fallback could retain `HEAD` pointing to a local-only branch that later gets pruned.
- A private or stale mirror could be used after authenticated refresh failure, bypassing remote access checks.

Commit `90b98a8` closes both risks:

- Cache source assignment now lives only in the successful refresh branch.
- The mirror `HEAD` is validated with `show-ref` and repaired with `symbolic-ref` before use.

See `review.md` and `verification.md` for details.

23 changes: 23 additions & 0 deletions .kanban/changes/ABC-2/files.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,23 @@
# ABC-2 File Trace

## Runtime Template

`packages/lib/src/core/templates-entrypoint/tasks.ts`

- Defines clone-cache initialization and mirror refresh.
- Ensures mirror source is used only after successful refresh.
- Repairs mirror `HEAD` before using it as clone source.

`packages/app/src/lib/core/templates-entrypoint/tasks.ts`

- Synchronized application copy of the runtime template.

## Test Coverage

`packages/lib/tests/core/templates.test.ts`

- Captures generated shell invariants for clone-cache behavior.
- Guards against broad remote refs.
- Guards against reintroducing cache use after refresh failure.
- Guards mirror `HEAD` validation/repair before cache source reuse.

56 changes: 56 additions & 0 deletions .kanban/changes/ABC-2/review.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,56 @@
# ABC-2 Review Notes

## Issue Alignment

The implementation matches issue #138 by keeping a single shared bare mirror per repository URL and using that mirror for warm clone data. The generated runtime still refreshes the mirror from the authenticated remote first, which preserves access checks and remote freshness.

## Risk Review

### P1: Invalid Mirror HEAD

Risk:

An `issue-*` fallback clone can create a local branch and bootstrap the bare mirror with `HEAD` pointing at that local-only branch. A later mirror refresh can prune that ref, after which cloning directly from the mirror can produce an unborn or empty checkout.

Resolution:

The entrypoint now computes `CACHE_HEAD_REF`, verifies it exists with:

```bash
git --git-dir "$CACHE_REPO_DIR" show-ref --verify --quiet "$CACHE_HEAD_REF"
```

If it is missing, the entrypoint selects the first existing branch from:

```bash
refs/heads/main refs/heads/master refs/heads
```

Then it repairs `HEAD` via:

```bash
git --git-dir "$CACHE_REPO_DIR" symbolic-ref HEAD "$CACHE_HEAD_REF"
```
Comment on lines +17 to +33
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🔴 Critical | ⚡ Quick win

🧩 Analysis chain

🏁 Script executed:

#!/bin/bash
# Поиск использования переменных в shell-командах без валидации

echo "=== Checking for variable usage in git commands ==="
rg -n -A3 -B3 'git.*\$CACHE_REPO_DIR|symbolic-ref.*\$CACHE_HEAD_REF' \
  packages/lib/src/core/templates-entrypoint/tasks.ts \
  packages/app/src/lib/core/templates-entrypoint/tasks.ts

echo ""
echo "=== Checking for sha256 hashing and validation ==="
rg -n 'sha256|CACHE_REPO_DIR.*=|CACHE_HEAD_REF.*=' \
  packages/lib/src/core/templates-entrypoint/tasks.ts \
  packages/app/src/lib/core/templates-entrypoint/tasks.ts

echo ""
echo "=== Checking for input sanitization patterns ==="
rg -n 'validate|sanitize|escape|refs/heads' \
  packages/lib/src/core/templates-entrypoint/tasks.ts \
  packages/app/src/lib/core/templates-entrypoint/tasks.ts

Repository: ProverCoderAI/docker-git

Length of output: 8521


🏁 Script executed:

#!/bin/bash
set -euo pipefail

files=(
  "packages/app/src/lib/core/templates-entrypoint/tasks.ts"
  "packages/lib/src/core/templates-entrypoint/tasks.ts"
)

for f in "${files[@]}"; do
  echo "===== $f (lines 110-180) ====="
  nl -ba "$f" | sed -n '110,180p'
  echo
done

echo "===== Search: CACHE_HEAD_REF assignments ====="
rg -n "CACHE_HEAD_REF" packages/app/src/lib/core/templates-entrypoint/tasks.ts packages/lib/src/core/templates-entrypoint/tasks.ts

echo
echo "===== Search: symbolic-ref usage ====="
rg -n "symbolic-ref" packages/app/src/lib/core/templates-entrypoint/tasks.ts packages/lib/src/core/templates-entrypoint/tasks.ts

echo
echo "===== Search: REPO_URL / AUTH_REPO_URL usage ====="
rg -n "REPO_URL|AUTH_REPO_URL|GIT_TERMINAL_PROMPT|cloneCacheRefreshRefspecs" packages/app/src/lib/core/templates-entrypoint/tasks.ts packages/lib/src/core/templates-entrypoint/tasks.ts

echo
echo "===== Search: refspec injection points ====="
rg -n "for-each-ref|show-ref --verify|--format='%\(\)?" packages/app/src/lib/core/templates-entrypoint/tasks.ts packages/lib/src/core/templates-entrypoint/tasks.ts || true

Repository: ProverCoderAI/docker-git

Length of output: 214


🏁 Script executed:

#!/bin/bash
set -euo pipefail

files=(
  "packages/app/src/lib/core/templates-entrypoint/tasks.ts"
  "packages/lib/src/core/templates-entrypoint/tasks.ts"
)

for f in "${files[@]}"; do
  echo "===== $f (lines 110-180) ====="
  nl -ba "$f" | sed -n '110,180p'
  echo
done

echo "===== Search: CACHE_HEAD_REF assignments ====="
rg -n "CACHE_HEAD_REF" packages/app/src/lib/core/templates-entrypoint/tasks.ts packages/lib/src/core/templates-entrypoint/tasks.ts

echo
echo "===== Search: symbolic-ref usage ====="
rg -n "symbolic-ref" packages/app/src/lib/core/templates-entrypoint/tasks.ts packages/lib/src/core/templates-entrypoint/tasks.ts

echo
echo "===== Search: REPO_URL / AUTH_REPO_URL usage ====="
rg -n "REPO_URL|AUTH_REPO_URL|GIT_TERMINAL_PROMPT|cloneCacheRefreshRefspecs" packages/app/src/lib/core/templates-entrypoint/tasks.ts packages/lib/src/core/templates-entrypoint/tasks.ts

echo
echo "===== Search: refspec injection points ====="
rg -n "for-each-ref|show-ref --verify|--format='%(refname)'" packages/app/src/lib/core/templates-entrypoint/tasks.ts packages/lib/src/core/templates-entrypoint/tasks.ts || true

Repository: ProverCoderAI/docker-git

Length of output: 214


🏁 Script executed:

#!/bin/bash
set -euo pipefail

files=(
  "packages/app/src/lib/core/templates-entrypoint/tasks.ts"
  "packages/lib/src/core/templates-entrypoint/tasks.ts"
)

show_range() {
  local f="$1" start="$2" end="$3"
  echo "===== $f (lines ${start}-${end}) ====="
  awk -v s="$start" -v e="$end" '{ if (NR>=s && NR<=e) printf "%6d: %s\n", NR, $0 }' "$f"
  echo
}

for f in "${files[@]}"; do
  show_range "$f" 110 200
done

echo "===== Search: CACHE_HEAD_REF assignments ====="
rg -n "CACHE_HEAD_REF" packages/app/src/lib/core/templates-entrypoint/tasks.ts packages/lib/src/core/templates-entrypoint/tasks.ts

echo
echo "===== Search: symbolic-ref usage ====="
rg -n "symbolic-ref" packages/app/src/lib/core/templates-entrypoint/tasks.ts packages/lib/src/core/templates-entrypoint/tasks.ts

echo
echo "===== Search: REPO_URL / AUTH_REPO_URL usage ====="
rg -n "REPO_URL|AUTH_REPO_URL|GIT_TERMINAL_PROMPT|cloneCacheRefreshRefspecs" packages/app/src/lib/core/templates-entrypoint/tasks.ts packages/lib/src/core/templates-entrypoint/tasks.ts

echo
echo "===== Search: refspec injection points ====="
rg -n "for-each-ref|show-ref --verify|for-each-ref|--format='%\(\)?" packages/app/src/lib/core/templates-entrypoint/tasks.ts packages/lib/src/core/templates-entrypoint/tasks.ts || true

Repository: ProverCoderAI/docker-git

Length of output: 24979


🏁 Script executed:

#!/bin/bash
set -euo pipefail

rg -n "REPO_URL|repoUrl|repo_url" packages -S --glob='*.ts' --glob='*.tsx' --glob='*.js' --glob='*.mjs' --glob='*.cjs'

echo
echo "=== Search for sanitization/validation of REPO_URL ==="
rg -n "sanitize|escape|replace\\(|replaceAll\\(|encode|urlencode|decode|regex|match|RegExp|allowed|whitelist|blacklist|validate|assert" packages -S --glob='*.ts' --glob='*.tsx' --glob='*.js'

echo
echo "=== Search where '$AUTH_REPO_URL' is injected into bash templates ==="
rg -n "\\$AUTH_REPO_URL" packages -S --glob='*.ts'

echo
echo "=== Search for validation/formatting of RESOLVED_GIT_AUTH_TOKEN/user (single quotes) ==="
rg -n "RESOLVED_GIT_AUTH_TOKEN|RESOLVED_GIT_AUTH_USER|token" packages/lib packages/app -S --glob='*.ts'

Repository: ProverCoderAI/docker-git

Length of output: 50451


🏁 Script executed:

#!/bin/bash
set -euo pipefail

# 1) Найти реализации парсинга repoUrl
rg -n "parseGithubRepoUrl" packages/app -S --glob='*.ts'
rg -n "parseGitlabRepoUrl" packages/app -S --glob='*.ts'

echo "----"

# 2) Показать ключевые участки репо-парсеров (ограничим выдачу)
# (Используем awk, т.к. nl нет.)
for f in \
  "$(rg -l "parseGithubRepoUrl" packages/app --glob='*.ts' | head -n 1)" \
  "$(rg -l "parseGitlabRepoUrl" packages/app --glob='*.ts' | head -n 1)"
do
  echo "===== $f (пробуем показать 1-260 строк) ====="
  awk 'NR>=1 && NR<=260 {printf "%6d: %s\n", NR, $0}' "$f"
  echo
done

echo "----"

# 3) Проверить, есть ли явные ограничения/валидация URL на уровне API/схем
rg -n "Schema\\.(String|.*)=|zod|superstruct|refine|regex|pattern|whitelist|sanitize|escape|reject" packages/api packages/app -S --glob='*.ts' --glob='*.tsx'

Repository: ProverCoderAI/docker-git

Length of output: 45395


🏁 Script executed:

#!/bin/bash
set -euo pipefail

# helper to print a line range with line numbers without `nl`
print_range() {
  local f="$1" a="$2" b="$3"
  echo "===== $f ($a-$b) ====="
  awk -v s="$a" -v e="$b" '{ if (NR>=s && NR<=e) printf "%6d: %s\n", NR, $0 }' "$f"
  echo
}

# repo parsing / normalization
print_range "packages/app/src/lib/core/repo.ts" 180 420

# entrypoint tasks: how tokens are selected / how bash templates set REPO_URL/AUTH vars
print_range "packages/app/src/lib/core/templates-entrypoint/tasks.ts" 1 220

Repository: ProverCoderAI/docker-git

Length of output: 21529


🏁 Script executed:

#!/bin/bash
set -euo pipefail

rg -n "clone-cache|cloneCacheRefreshRefspecs|symbolic-ref HEAD|CACHE_HEAD_REF|mirror refresh|git --git-dir '.*CACHE_REPO_DIR' fetch" packages/lib/tests packages/app/tests -S --glob='*.ts' --glob='*.tsx' --glob='*.js' --glob='*.mjs'

echo "----"

rg -n "templates\\.test\\.ts" -S packages/lib/tests/core/templates.test.ts
sed -n '600,760p' packages/lib/tests/core/templates.test.ts 2>/dev/null || true
sed -n '280,360p' packages/lib/tests/core/templates.test.ts 2>/dev/null || true

Repository: ProverCoderAI/docker-git

Length of output: 1688


🏁 Script executed:

#!/bin/bash
set -euo pipefail

rg -n "REPO_URL\\s*:|AUTH_REPO_URL|GIT_AUTH_TOKEN|GIT_AUTH_USER|RESOLVED_GIT_AUTH_TOKEN|RESOLVED_GIT_AUTH_USER|GITHUB_AUTH_SKIP|GITLAB_TOKEN|GIT_AUTH_LABEL|GIT_AUTH_USER" packages/app/src/lib/core/templates packages/app/src/lib/core/templates-entrypoint packages/app/src/lib/core/templates/docker-compose.ts packages/app/src/lib/core -S --glob='*.ts'

echo "----"

# show docker-compose env blocks around REPO_URL
f="packages/app/src/lib/core/templates/docker-compose.ts"
if [ -f "$f" ]; then
  awk 'NR>=200 && NR<=280 {printf "%6d: %s\n", NR, $0}' "$f"
fi

echo "----"

# find where entrypoint variables are exported in entrypoint scripts
rg -n "export .*REPO_URL|REPO_URL=\"\\$\\{?config\\.repoUrl\\}?\"|REPO_URL:|GIT_AUTH_TOKEN:|GITLAB_TOKEN:|GITHUB_AUTH_LABEL:" packages/app/src/lib/core -S --glob='*.ts' --glob='*.tsx'

Repository: ProverCoderAI/docker-git

Length of output: 26151


🏁 Script executed:

#!/bin/bash
set -euo pipefail

rg -n "GIT_AUTH_TOKEN|GITHUB_TOKEN|LABELED_GIT_TOKEN|RESOLVED_GIT_AUTH_TOKEN|GIT_AUTH_USER|RESOLVED_GIT_AUTH_USER|REPO_URL" packages/app/src packages/api/src -S --glob='*.ts' --glob='*.tsx'

echo "----"

rg -n "token.*(regex|pattern|whitelist|sanitize|escape|reject|shell|metachar|quote|single quote|%27)|'\\s*\\+|%27|escapeForSingleQuotes" packages/app/src -S --glob='*.ts' --glob='*.tsx'

echo "----"

# Ключевая проверка: есть ли запрет на `'`/shell metachar в parser'ах для токенов/репо
rg -n "rejects|disallow|forbid|shell metachar|metachar|unsafe|\\bquote\\b|single quote|\\x27|%27" packages/app/tests packages/app/src -S --glob='*.ts' --glob='*.tsx'

Repository: ProverCoderAI/docker-git

Length of output: 50380


🏁 Script executed:

#!/bin/bash
set -euo pipefail

# 1) Показать блок тестов про clone-cache около найденных строк ~285-350 и ~630-670
f="packages/lib/tests/core/templates.test.ts"
echo "===== $f: clone-cache tests (~260-360) ====="
awk 'NR>=260 && NR<=370 {printf "%6d: %s\n", NR, $0}' "$f"

echo
echo "===== $f: clone-cache refspecs test (~620-690) ====="
awk 'NR>=620 && NR<=690 {printf "%6d: %s\n", NR, $0}' "$f"

echo
# 2) Поиск тестов/валидаций по токенам и запрету кавычек/метасимволов
echo "===== Search: token validation / rejecting quotes/metacharacters ====="
rg -n "rejects|invalid|validate|whitelist|pattern|regex|metachar|single quote|\\x27|%27|shell|command injection|unsafe" packages/app/tests packages/app/src/lib/usecases packages/api/src -S --glob='*.ts' --glob='*.tsx'

Repository: ProverCoderAI/docker-git

Length of output: 50380


🏁 Script executed:

#!/bin/bash
set -euo pipefail

# Показать компактно реализации валидации токенов
show_range() {
  f="$1"; a="$2"; b="$3";
  echo "===== $f ($a-$b) ====="
  awk -v s="$a" -v e="$b" '{ if (NR>=s && NR<=e) printf "%6d: %s\n", NR, $0 }' "$f"
  echo
}

show_range "packages/app/src/lib/usecases/github-token-validation.ts" 1 220
show_range "packages/app/src/lib/usecases/gitlab-token-validation.ts" 1 240

echo "----"

# Иногда валидация токена завернута в preflight; покажем обвязку
show_range "packages/app/src/lib/usecases/github-token-preflight.ts" 1 260
show_range "packages/app/src/lib/usecases/gitlab-token-preflight.ts" 1 260

Repository: ProverCoderAI/docker-git

Length of output: 24478


Проведена оценка защиты clone-cache от path traversal/инъекций — подтвердить полностью не получается из‑за AUTH_REPO_URL

  • Path traversal: CACHE_REPO_DIR строится из REPO_CACHE_KEY как sha256sum/shasum от "$REPO_URL" (а при отсутствии — fallback с чисткой до [:alnum:]_.-), затем используется как подкаталог "$CACHE_ROOT". В этом виде записи не допускают выход из cache-директории.
  • Инъекция через CACHE_HEAD_REF: значение берётся из Git (symbolic-ref/for-each-ref) внутри конкретного mirror-репозитория и дополнительно проверяется git show-ref --verify --quiet "$CACHE_HEAD_REF"; дальнейшее использование symbolic-ref HEAD "$CACHE_HEAD_REF" идёт в double quotes, поэтому классический shell-injection из значения ref не просматривается.
  • Остаётся уязвимый участок (command injection) через AUTH_REPO_URL: в su - <sshUser> -c "... '$AUTH_REPO_URL' ..." AUTH_REPO_URL подставляется внутрь single quotes. Валидация токенов (validateGithubToken/validateGitlabToken) проверяет доступность токена сетью, но не ограничивает формат (например, отсутствие '/shell-метасимволов). Если RESOLVED_GIT_AUTH_TOKEN/RESOLVED_GIT_AUTH_USER или сам REPO_URL может содержать ', это может разорвать single-quoted фрагмент в -c и привести к инъекции.
  • Тесты clone-cache: они проверяют “repair HEAD” через symbolic-ref/show-ref и ограничение refspec только refs/heads/* + refs/tags/*, но не покрывают кейсы с кавычками/опасными символами в AUTH_REPO_URL.
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In @.kanban/changes/ABC-2/review.md around lines 17 - 33, The review found a
command-injection risk where AUTH_REPO_URL (and derived
RESOLVED_GIT_AUTH_TOKEN/RESOLVED_GIT_AUTH_USER) get interpolated into su -
<sshUser> -c "... '$AUTH_REPO_URL' ..." inside single quotes; update the code to
eliminate that injection vector by never placing untrusted values directly into
a shell -c string: either (a) fully validate/sanitize AUTH_REPO_URL and tokens
to disallow any single-quote or shell metacharacters (reject on invalid chars)
in validateGithubToken/validateGitlabToken and the REPO_URL handling, or (b)
avoid shell interpolation altogether by invoking git via exec/array args or a
safe API under su (e.g., use sudo -u/exec with argument array) so that
AUTH_REPO_URL, RESOLVED_GIT_AUTH_TOKEN and RESOLVED_GIT_AUTH_USER are passed as
arguments rather than embedded in a single-quoted command; apply this change to
the code paths that form the su -c command that include AUTH_REPO_URL and to any
helpers that build that command.


The cache is used as clone source only if this succeeds.

### P1: Cache Use After Auth/Refresh Failure

Risk:

Using a warm mirror after `git fetch` fails can bypass private repo access checks and return stale data.

Resolution:

`CLONE_SOURCE_REPO_URL="$CACHE_REPO_DIR"` is assigned only inside the successful mirror refresh branch. On refresh failure, the clone source remains `CLONE_SOURCE_REPO_URL="$AUTH_REPO_URL"`.

## Regression Coverage

`packages/lib/tests/core/templates.test.ts` asserts:

- mirror refresh uses branch/tag-only refspecs;
- clone source defaults to `$AUTH_REPO_URL`;
- `$CACHE_REPO_DIR` becomes clone source only in the successful fetch path;
- mirror `HEAD` is checked and repaired before use;
- `--reference-if-able` is not used by the warm-cache path.

46 changes: 46 additions & 0 deletions .kanban/changes/ABC-2/verification.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,46 @@
# ABC-2 Verification

## Passed Checks

The following checks were run in the workspace and passed:

```bash
bun run --cwd packages/lib test -- core/templates.test.ts
bun run --cwd packages/lib typecheck
bun run --cwd packages/app typecheck
bun run --cwd packages/app build:docker-git
bun run typecheck
bun run check
```

## Docker Runtime Verification

The stock clone-cache e2e script was run against the reachable docker-git
controller in this remote-Docker environment:

```bash
DOCKER_GIT_API_URL=http://172.18.0.3:3336 \
DOCKER_GIT_API_CONTAINER_NAME=docker-git-api-cloudflared \
DOCKER_GIT_E2E_CLONE_CACHE_TIMEOUT=900s \
bash scripts/e2e/clone-cache.sh
```

Result:

```text
e2e/clone-cache: cache reuse verified for https://github.com/octocat/Hello-World/issues/1
```

Environment notes:

- `DOCKER_HOST=tcp://host.docker.internal:2375` requires an explicit `DOCKER_GIT_API_URL`.
- The controller container is named `docker-git-api-cloudflared`; setting `DOCKER_GIT_API_CONTAINER_NAME` lets the e2e helper inspect the nested project Docker daemon.
- A shorter `300s` first attempt expired while cold-pulling/building the base runtime image, before clone-cache assertions could run.

## Current Workspace State

At archive time:

- working tree was clean before creating this audit trail;
- final code commits were present on branch `vk/2562-github-138`;
- archive artifacts are stored under `.kanban/changes/ABC-2`.
27 changes: 19 additions & 8 deletions packages/app/src/lib/core/templates-entrypoint/tasks.ts
Original file line number Diff line number Diff line change
Expand Up @@ -130,6 +130,7 @@ const cloneCacheRefreshRefspecs = "'+refs/heads/*:refs/heads/*' '+refs/tags/*:re

const renderCloneCacheInit = (config: TemplateConfig): string =>
` CLONE_CACHE_ARGS=""
CLONE_SOURCE_REPO_URL="$AUTH_REPO_URL"
CACHE_REPO_DIR=""
CACHE_ROOT="/home/${config.sshUser}/.docker-git/.cache/git-mirrors"
if command -v sha256sum >/dev/null 2>&1; then
Expand All @@ -146,11 +147,21 @@ const renderCloneCacheInit = (config: TemplateConfig): string =>
chown 1000:1000 "$CACHE_ROOT" || true
if [[ -d "$CACHE_REPO_DIR" ]]; then
if su - ${config.sshUser} -c "git --git-dir '$CACHE_REPO_DIR' rev-parse --is-bare-repository >/dev/null 2>&1"; then
if ! su - ${config.sshUser} -c "GIT_TERMINAL_PROMPT=0 git --git-dir '$CACHE_REPO_DIR' fetch --progress --prune '$AUTH_REPO_URL' ${cloneCacheRefreshRefspecs}"; then
if su - ${config.sshUser} -c "GIT_TERMINAL_PROMPT=0 git --git-dir '$CACHE_REPO_DIR' fetch --progress --prune '$AUTH_REPO_URL' ${cloneCacheRefreshRefspecs}"; then
CACHE_HEAD_REF="$(git --git-dir "$CACHE_REPO_DIR" symbolic-ref -q HEAD 2>/dev/null || true)"
if [[ -z "$CACHE_HEAD_REF" ]] || ! git --git-dir "$CACHE_REPO_DIR" show-ref --verify --quiet "$CACHE_HEAD_REF"; then
CACHE_HEAD_REF="$(git --git-dir "$CACHE_REPO_DIR" for-each-ref --format='%(refname)' refs/heads/main refs/heads/master refs/heads | head -n 1 || true)"
fi
if [[ -n "$CACHE_HEAD_REF" ]] && git --git-dir "$CACHE_REPO_DIR" symbolic-ref HEAD "$CACHE_HEAD_REF"; then
Comment on lines +151 to +155
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major | ⚡ Quick win

Несогласованность прав доступа при работе с git-командами кэша.

Команда fetch на строке 150 выполняется от имени ${config.sshUser} через su -, однако последующие команды symbolic-ref, show-ref и for-each-ref (строки 151-155) выполняются от root. Это может привести к проблемам с правами доступа, если директория кэша принадлежит пользователю dev.

🐛 Предлагаемое исправление: выполнять git-команды от того же пользователя
-          CACHE_HEAD_REF="$(git --git-dir "$CACHE_REPO_DIR" symbolic-ref -q HEAD 2>/dev/null || true)"
-          if [[ -z "$CACHE_HEAD_REF" ]] || ! git --git-dir "$CACHE_REPO_DIR" show-ref --verify --quiet "$CACHE_HEAD_REF"; then
-            CACHE_HEAD_REF="$(git --git-dir "$CACHE_REPO_DIR" for-each-ref --format='%(refname)' refs/heads/main refs/heads/master refs/heads | head -n 1 || true)"
-          fi
-          if [[ -n "$CACHE_HEAD_REF" ]] && git --git-dir "$CACHE_REPO_DIR" symbolic-ref HEAD "$CACHE_HEAD_REF"; then
+          CACHE_HEAD_REF="$(su - ${config.sshUser} -c "git --git-dir '$CACHE_REPO_DIR' symbolic-ref -q HEAD 2>/dev/null || true")"
+          if [[ -z "$CACHE_HEAD_REF" ]] || ! su - ${config.sshUser} -c "git --git-dir '$CACHE_REPO_DIR' show-ref --verify --quiet '$CACHE_HEAD_REF'"; then
+            CACHE_HEAD_REF="$(su - ${config.sshUser} -c "git --git-dir '$CACHE_REPO_DIR' for-each-ref --format='%(refname)' refs/heads/main refs/heads/master refs/heads | head -n 1 || true")"
+          fi
+          if [[ -n "$CACHE_HEAD_REF" ]] && su - ${config.sshUser} -c "git --git-dir '$CACHE_REPO_DIR' symbolic-ref HEAD '$CACHE_HEAD_REF'"; then
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@packages/app/src/lib/core/templates-entrypoint/tasks.ts` around lines 151 -
155, The git commands that read refs (commands setting and checking
CACHE_HEAD_REF using symbolic-ref, show-ref, for-each-ref against
CACHE_REPO_DIR) are being run as root while the prior fetch runs under
${config.sshUser}, causing permission issues; change the script so those
commands run as the same user (use the same su - ${config.sshUser} context or
run them via sudo -u ${config.sshUser}) when operating on CACHE_REPO_DIR and
when assigning CACHE_HEAD_REF, ensuring all git invocations (fetch,
symbolic-ref, show-ref, for-each-ref) execute with the repository owner’s
privileges.

CLONE_SOURCE_REPO_URL="$CACHE_REPO_DIR"
CLONE_CACHE_ARGS="--no-local"
echo "[clone-cache] using mirror: $CACHE_REPO_DIR"
else
echo "[clone-cache] mirror has no usable HEAD for $REPO_URL"
fi
else
echo "[clone-cache] mirror refresh failed for $REPO_URL"
fi
CLONE_CACHE_ARGS="--reference-if-able '$CACHE_REPO_DIR' --dissociate"
echo "[clone-cache] using mirror: $CACHE_REPO_DIR"
else
echo "[clone-cache] invalid mirror removed: $CACHE_REPO_DIR"
rm -rf "$CACHE_REPO_DIR"
Expand All @@ -170,19 +181,19 @@ const renderCloneBodyRef = (config: TemplateConfig): string =>
String.raw` if [[ -n "$REPO_REF" ]]; then
if [[ "$REPO_REF" == refs/pull/* || "$REPO_REF" == refs/merge-requests/* ]]; then
REF_BRANCH="$(printf "%s" "$REPO_REF" | sed -E 's#^refs/pull/([^/]+)/head$#pr-\1#; s#^refs/merge-requests/([^/]+)/head$#mr-\1#')"
if ! su - ${config.sshUser} -c "GIT_TERMINAL_PROMPT=0 git clone --progress $CLONE_CACHE_ARGS '$AUTH_REPO_URL' '$TARGET_DIR'"; then
if ! su - ${config.sshUser} -c "GIT_TERMINAL_PROMPT=0 git clone --progress $CLONE_CACHE_ARGS '$CLONE_SOURCE_REPO_URL' '$TARGET_DIR'"; then
echo "[clone] git clone failed for $REPO_URL"
CLONE_OK=0
else
if ! su - ${config.sshUser} -c "cd '$TARGET_DIR' && GIT_TERMINAL_PROMPT=0 git fetch --progress origin '$REPO_REF':'$REF_BRANCH' && git checkout '$REF_BRANCH'"; then
if ! su - ${config.sshUser} -c "cd '$TARGET_DIR' && GIT_TERMINAL_PROMPT=0 git fetch --progress '$AUTH_REPO_URL' '$REPO_REF':'$REF_BRANCH' && git checkout '$REF_BRANCH'"; then
echo "[clone] git fetch failed for $REPO_REF"
CLONE_OK=0
fi
fi
else
if ! su - ${config.sshUser} -c "GIT_TERMINAL_PROMPT=0 git clone --progress $CLONE_CACHE_ARGS --branch '$REPO_REF' '$AUTH_REPO_URL' '$TARGET_DIR'"; then
if ! su - ${config.sshUser} -c "GIT_TERMINAL_PROMPT=0 git clone --progress $CLONE_CACHE_ARGS --branch '$REPO_REF' '$CLONE_SOURCE_REPO_URL' '$TARGET_DIR'"; then
echo "[clone] branch '$REPO_REF' missing; retrying without --branch"
if ! su - ${config.sshUser} -c "GIT_TERMINAL_PROMPT=0 git clone --progress $CLONE_CACHE_ARGS '$AUTH_REPO_URL' '$TARGET_DIR'"; then
if ! su - ${config.sshUser} -c "GIT_TERMINAL_PROMPT=0 git clone --progress $CLONE_CACHE_ARGS '$CLONE_SOURCE_REPO_URL' '$TARGET_DIR'"; then
echo "[clone] git clone failed for $REPO_URL"
CLONE_OK=0
elif [[ "$REPO_REF" == issue-* ]]; then
Expand All @@ -194,7 +205,7 @@ const renderCloneBodyRef = (config: TemplateConfig): string =>
fi
fi
else
if ! su - ${config.sshUser} -c "GIT_TERMINAL_PROMPT=0 git clone --progress $CLONE_CACHE_ARGS '$AUTH_REPO_URL' '$TARGET_DIR'"; then
if ! su - ${config.sshUser} -c "GIT_TERMINAL_PROMPT=0 git clone --progress $CLONE_CACHE_ARGS '$CLONE_SOURCE_REPO_URL' '$TARGET_DIR'"; then
echo "[clone] git clone failed for $REPO_URL"
CLONE_OK=0
fi
Expand Down
27 changes: 19 additions & 8 deletions packages/lib/src/core/templates-entrypoint/tasks.ts
Original file line number Diff line number Diff line change
Expand Up @@ -130,6 +130,7 @@ const cloneCacheRefreshRefspecs = "'+refs/heads/*:refs/heads/*' '+refs/tags/*:re

const renderCloneCacheInit = (config: TemplateConfig): string =>
` CLONE_CACHE_ARGS=""
CLONE_SOURCE_REPO_URL="$AUTH_REPO_URL"
CACHE_REPO_DIR=""
CACHE_ROOT="/home/${config.sshUser}/.docker-git/.cache/git-mirrors"
if command -v sha256sum >/dev/null 2>&1; then
Expand All @@ -146,11 +147,21 @@ const renderCloneCacheInit = (config: TemplateConfig): string =>
chown 1000:1000 "$CACHE_ROOT" || true
if [[ -d "$CACHE_REPO_DIR" ]]; then
if su - ${config.sshUser} -c "git --git-dir '$CACHE_REPO_DIR' rev-parse --is-bare-repository >/dev/null 2>&1"; then
if ! su - ${config.sshUser} -c "GIT_TERMINAL_PROMPT=0 git --git-dir '$CACHE_REPO_DIR' fetch --progress --prune '$AUTH_REPO_URL' ${cloneCacheRefreshRefspecs}"; then
if su - ${config.sshUser} -c "GIT_TERMINAL_PROMPT=0 git --git-dir '$CACHE_REPO_DIR' fetch --progress --prune '$AUTH_REPO_URL' ${cloneCacheRefreshRefspecs}"; then
CACHE_HEAD_REF="$(git --git-dir "$CACHE_REPO_DIR" symbolic-ref -q HEAD 2>/dev/null || true)"
if [[ -z "$CACHE_HEAD_REF" ]] || ! git --git-dir "$CACHE_REPO_DIR" show-ref --verify --quiet "$CACHE_HEAD_REF"; then
CACHE_HEAD_REF="$(git --git-dir "$CACHE_REPO_DIR" for-each-ref --format='%(refname)' refs/heads/main refs/heads/master refs/heads | head -n 1 || true)"
fi
if [[ -n "$CACHE_HEAD_REF" ]] && git --git-dir "$CACHE_REPO_DIR" symbolic-ref HEAD "$CACHE_HEAD_REF"; then
Comment on lines +151 to +155
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major | ⚡ Quick win

Несогласованность прав доступа при работе с git-командами кэша.

Аналогичная проблема с правами доступа: команды symbolic-ref, show-ref и for-each-ref выполняются от root, тогда как fetch выполняется от ${config.sshUser}. Директория кэша создаётся и заполняется от имени пользователя, поэтому git-команды также должны выполняться от того же пользователя для согласованности.

🐛 Предлагаемое исправление
-          CACHE_HEAD_REF="$(git --git-dir "$CACHE_REPO_DIR" symbolic-ref -q HEAD 2>/dev/null || true)"
-          if [[ -z "$CACHE_HEAD_REF" ]] || ! git --git-dir "$CACHE_REPO_DIR" show-ref --verify --quiet "$CACHE_HEAD_REF"; then
-            CACHE_HEAD_REF="$(git --git-dir "$CACHE_REPO_DIR" for-each-ref --format='%(refname)' refs/heads/main refs/heads/master refs/heads | head -n 1 || true)"
-          fi
-          if [[ -n "$CACHE_HEAD_REF" ]] && git --git-dir "$CACHE_REPO_DIR" symbolic-ref HEAD "$CACHE_HEAD_REF"; then
+          CACHE_HEAD_REF="$(su - ${config.sshUser} -c "git --git-dir '$CACHE_REPO_DIR' symbolic-ref -q HEAD 2>/dev/null || true")"
+          if [[ -z "$CACHE_HEAD_REF" ]] || ! su - ${config.sshUser} -c "git --git-dir '$CACHE_REPO_DIR' show-ref --verify --quiet '$CACHE_HEAD_REF'"; then
+            CACHE_HEAD_REF="$(su - ${config.sshUser} -c "git --git-dir '$CACHE_REPO_DIR' for-each-ref --format='%(refname)' refs/heads/main refs/heads/master refs/heads | head -n 1 || true")"
+          fi
+          if [[ -n "$CACHE_HEAD_REF" ]] && su - ${config.sshUser} -c "git --git-dir '$CACHE_REPO_DIR' symbolic-ref HEAD '$CACHE_HEAD_REF'"; then
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@packages/lib/src/core/templates-entrypoint/tasks.ts` around lines 151 - 155,
The git cache commands (symbolic-ref, show-ref, for-each-ref) are being executed
as root while fetch runs as ${config.sshUser}, causing permission
inconsistencies; update the calls that set and check CACHE_HEAD_REF (the git
--git-dir "$CACHE_REPO_DIR" symbolic-ref, show-ref and for-each-ref invocations)
to run under the same user as fetch (e.g., prefix those git commands with the
same sudo/runuser invocation used for fetch or otherwise switch to
${config.sshUser} before executing them) so the cache directory is always
accessed by the same SSH user.

CLONE_SOURCE_REPO_URL="$CACHE_REPO_DIR"
CLONE_CACHE_ARGS="--no-local"
echo "[clone-cache] using mirror: $CACHE_REPO_DIR"
else
echo "[clone-cache] mirror has no usable HEAD for $REPO_URL"
fi
else
echo "[clone-cache] mirror refresh failed for $REPO_URL"
fi
CLONE_CACHE_ARGS="--reference-if-able '$CACHE_REPO_DIR' --dissociate"
echo "[clone-cache] using mirror: $CACHE_REPO_DIR"
else
echo "[clone-cache] invalid mirror removed: $CACHE_REPO_DIR"
rm -rf "$CACHE_REPO_DIR"
Expand All @@ -170,19 +181,19 @@ const renderCloneBodyRef = (config: TemplateConfig): string =>
String.raw` if [[ -n "$REPO_REF" ]]; then
if [[ "$REPO_REF" == refs/pull/* || "$REPO_REF" == refs/merge-requests/* ]]; then
REF_BRANCH="$(printf "%s" "$REPO_REF" | sed -E 's#^refs/pull/([^/]+)/head$#pr-\1#; s#^refs/merge-requests/([^/]+)/head$#mr-\1#')"
if ! su - ${config.sshUser} -c "GIT_TERMINAL_PROMPT=0 git clone --progress $CLONE_CACHE_ARGS '$AUTH_REPO_URL' '$TARGET_DIR'"; then
if ! su - ${config.sshUser} -c "GIT_TERMINAL_PROMPT=0 git clone --progress $CLONE_CACHE_ARGS '$CLONE_SOURCE_REPO_URL' '$TARGET_DIR'"; then
echo "[clone] git clone failed for $REPO_URL"
CLONE_OK=0
else
if ! su - ${config.sshUser} -c "cd '$TARGET_DIR' && GIT_TERMINAL_PROMPT=0 git fetch --progress origin '$REPO_REF':'$REF_BRANCH' && git checkout '$REF_BRANCH'"; then
if ! su - ${config.sshUser} -c "cd '$TARGET_DIR' && GIT_TERMINAL_PROMPT=0 git fetch --progress '$AUTH_REPO_URL' '$REPO_REF':'$REF_BRANCH' && git checkout '$REF_BRANCH'"; then
echo "[clone] git fetch failed for $REPO_REF"
CLONE_OK=0
fi
fi
else
if ! su - ${config.sshUser} -c "GIT_TERMINAL_PROMPT=0 git clone --progress $CLONE_CACHE_ARGS --branch '$REPO_REF' '$AUTH_REPO_URL' '$TARGET_DIR'"; then
if ! su - ${config.sshUser} -c "GIT_TERMINAL_PROMPT=0 git clone --progress $CLONE_CACHE_ARGS --branch '$REPO_REF' '$CLONE_SOURCE_REPO_URL' '$TARGET_DIR'"; then
echo "[clone] branch '$REPO_REF' missing; retrying without --branch"
if ! su - ${config.sshUser} -c "GIT_TERMINAL_PROMPT=0 git clone --progress $CLONE_CACHE_ARGS '$AUTH_REPO_URL' '$TARGET_DIR'"; then
if ! su - ${config.sshUser} -c "GIT_TERMINAL_PROMPT=0 git clone --progress $CLONE_CACHE_ARGS '$CLONE_SOURCE_REPO_URL' '$TARGET_DIR'"; then
echo "[clone] git clone failed for $REPO_URL"
CLONE_OK=0
elif [[ "$REPO_REF" == issue-* ]]; then
Expand All @@ -194,7 +205,7 @@ const renderCloneBodyRef = (config: TemplateConfig): string =>
fi
fi
else
if ! su - ${config.sshUser} -c "GIT_TERMINAL_PROMPT=0 git clone --progress $CLONE_CACHE_ARGS '$AUTH_REPO_URL' '$TARGET_DIR'"; then
if ! su - ${config.sshUser} -c "GIT_TERMINAL_PROMPT=0 git clone --progress $CLONE_CACHE_ARGS '$CLONE_SOURCE_REPO_URL' '$TARGET_DIR'"; then
echo "[clone] git clone failed for $REPO_URL"
CLONE_OK=0
fi
Expand Down
18 changes: 18 additions & 0 deletions packages/lib/tests/core/templates.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -299,11 +299,29 @@ describe("renderEntrypoint clone cache", () => {
const entrypoint = renderEntrypoint(makeTemplateConfig())

expect(entrypoint).toContain("git --git-dir '$CACHE_REPO_DIR' fetch")
expect(entrypoint).toContain('CLONE_SOURCE_REPO_URL="$AUTH_REPO_URL"')
expect(entrypoint).toContain('CLONE_SOURCE_REPO_URL="$CACHE_REPO_DIR"')
expect(entrypoint).toContain('CLONE_CACHE_ARGS="--no-local"')
expect(entrypoint).toContain("if su - dev -c \"GIT_TERMINAL_PROMPT=0 git --git-dir '$CACHE_REPO_DIR' fetch")
expect(entrypoint).toContain('CACHE_HEAD_REF="$(git --git-dir "$CACHE_REPO_DIR" symbolic-ref -q HEAD')
expect(entrypoint).toContain('git --git-dir "$CACHE_REPO_DIR" show-ref --verify --quiet "$CACHE_HEAD_REF"')
expect(entrypoint).toContain("for-each-ref --format='%(refname)' refs/heads/main refs/heads/master refs/heads")
expect(entrypoint).toContain('git --git-dir "$CACHE_REPO_DIR" symbolic-ref HEAD "$CACHE_HEAD_REF"')
expect(entrypoint).toContain("git clone --progress $CLONE_CACHE_ARGS '$CLONE_SOURCE_REPO_URL' '$TARGET_DIR'")
expect(entrypoint).toContain("'+refs/heads/*:refs/heads/*'")
expect(entrypoint).toContain("'+refs/tags/*:refs/tags/*'")
expect(entrypoint).toContain("git fetch --progress '$AUTH_REPO_URL' '$REPO_REF':'$REF_BRANCH'")
expect(entrypoint).not.toContain("'+refs/*:refs/*'")
expect(entrypoint).not.toContain("'+refs/pull/*:refs/pull/*'")
expect(entrypoint).not.toContain("'+refs/merge-requests/*:refs/merge-requests/*'")
expect(entrypoint).not.toContain("--reference-if-able")
expect(entrypoint).not.toContain("if ! su - dev -c \"GIT_TERMINAL_PROMPT=0 git --git-dir '$CACHE_REPO_DIR' fetch")

const refreshFailureBlock = entrypoint.slice(
entrypoint.indexOf('echo "[clone-cache] mirror refresh failed for $REPO_URL"'),
entrypoint.indexOf('echo "[clone-cache] invalid mirror removed: $CACHE_REPO_DIR"')
)
expect(refreshFailureBlock).not.toContain('CLONE_SOURCE_REPO_URL="$CACHE_REPO_DIR"')
})

it("preserves branch/tag-only clone-cache refspecs for generated configs", () => {
Expand Down
Loading