Skip to content

Commit 5741fd1

Browse files
authored
Add login-context e2e for issue/PR URL notice (#46)
* test(e2e): verify login context notice for issue/pr workspaces * fix(e2e): keep login-context logs writable after container chown * fix(e2e): keep ssh private key outside chowned workspace * fix(shell): make login context hint independent from ssh env
1 parent 86c9fec commit 5741fd1

File tree

5 files changed

+307
-13
lines changed

5 files changed

+307
-13
lines changed

.github/workflows/check.yml

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -97,3 +97,16 @@ jobs:
9797
run: docker version && docker compose version
9898
- name: OpenCode autoconnect
9999
run: bash scripts/e2e/opencode-autoconnect.sh
100+
101+
e2e-login-context:
102+
name: E2E (Login context)
103+
runs-on: ubuntu-latest
104+
timeout-minutes: 20
105+
steps:
106+
- uses: actions/checkout@v6
107+
- name: Install dependencies
108+
uses: ./.github/actions/setup
109+
- name: Docker info
110+
run: docker version && docker compose version
111+
- name: Login context notice
112+
run: bash scripts/e2e/login-context.sh

packages/docker-git/tests/core/templates.test.ts

Lines changed: 46 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -139,6 +139,52 @@ describe("planFiles", () => {
139139
expect(entrypointSpec.contents).toContain("Issue AGENTS.md:")
140140
expect(entrypointSpec.contents).toContain("ISSUE_AGENTS_PATH=\"$TARGET_DIR/AGENTS.md\"")
141141
expect(entrypointSpec.contents).toContain("grep -qx \"AGENTS.md\" \"$EXCLUDE_PATH\"")
142+
expect(entrypointSpec.contents).toContain("docker_git_workspace_context_line()")
143+
expect(entrypointSpec.contents).toContain("REPO_REF_VALUE=\"${REPO_REF:-issue-5}\"")
144+
expect(entrypointSpec.contents).toContain("REPO_URL_VALUE=\"${REPO_URL:-https://github.com/org/repo.git}\"")
145+
expect(entrypointSpec.contents).toContain("Контекст workspace: issue #$ISSUE_ID_VALUE ($ISSUE_URL_VALUE)")
146+
}
147+
}))
148+
149+
it.effect("embeds PR workspace URL context in entrypoint", () =>
150+
Effect.sync(() => {
151+
const config: TemplateConfig = {
152+
containerName: "dg-repo-pr-42",
153+
serviceName: "dg-repo-pr-42",
154+
sshUser: "dev",
155+
sshPort: 2222,
156+
repoUrl: "https://github.com/org/repo.git",
157+
repoRef: "refs/pull/42/head",
158+
targetDir: "/home/dev/org/repo/pr-42",
159+
volumeName: "dg-repo-pr-42-home",
160+
authorizedKeysPath: "./authorized_keys",
161+
envGlobalPath: "./.orch/env/global.env",
162+
envProjectPath: "./.orch/env/project.env",
163+
codexAuthPath: "./.orch/auth/codex",
164+
codexSharedAuthPath: "../../.orch/auth/codex",
165+
codexHome: "/home/dev/.codex",
166+
enableMcpPlaywright: false,
167+
pnpmVersion: "10.27.0"
168+
}
169+
170+
const specs = planFiles(config)
171+
const entrypointSpec = specs.find(
172+
(spec) => spec._tag === "File" && spec.relativePath === "entrypoint.sh"
173+
)
174+
expect(entrypointSpec !== undefined && entrypointSpec._tag === "File").toBe(true)
175+
if (entrypointSpec && entrypointSpec._tag === "File") {
176+
expect(entrypointSpec.contents).toContain("REPO_REF_VALUE=\"${REPO_REF:-refs/pull/42/head}\"")
177+
expect(entrypointSpec.contents).toContain("REPO_URL_VALUE=\"${REPO_URL:-https://github.com/org/repo.git}\"")
178+
expect(entrypointSpec.contents).toContain(
179+
"PR_ID=\"$(printf \"%s\" \"$REPO_REF\" | sed -nE 's#^refs/pull/([0-9]+)/head$#\\1#p')\""
180+
)
181+
expect(entrypointSpec.contents).toContain(
182+
"PR_URL=\"https://github.com/$PR_REPO/pull/$PR_ID\""
183+
)
184+
expect(entrypointSpec.contents).toContain(
185+
"WORKSPACE_INFO_LINE=\"Контекст workspace: PR #$PR_ID ($PR_URL)\""
186+
)
187+
expect(entrypointSpec.contents).toContain("Контекст workspace: PR #$PR_ID_VALUE ($PR_URL_VALUE)")
142188
}
143189
}))
144190
})

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

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -44,7 +44,7 @@ export const renderEntrypoint = (config: TemplateConfig): string =>
4444
renderEntrypointBashHistory(),
4545
renderEntrypointInputRc(config),
4646
renderEntrypointZshConfig(),
47-
renderEntrypointCodexResumeHint(),
47+
renderEntrypointCodexResumeHint(config),
4848
renderEntrypointAgentsNotice(config),
4949
renderEntrypointDockerSocket(config),
5050
renderEntrypointGitConfig(config),

packages/lib/src/core/templates-entrypoint/codex.ts

Lines changed: 85 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -102,27 +102,76 @@ export const renderEntrypointMcpPlaywright = (config: TemplateConfig): string =>
102102
.replaceAll("__CODEX_HOME__", config.codexHome)
103103
.replaceAll("__SERVICE_NAME__", config.serviceName)
104104

105-
export const renderEntrypointCodexResumeHint = (): string =>
106-
`# Ensure codex resume hint is shown for interactive shells
105+
const entrypointCodexResumeHintTemplate = `# Ensure codex resume hint is shown for interactive shells
107106
CODEX_HINT_PATH="/etc/profile.d/zz-codex-resume.sh"
108107
if [[ ! -s "$CODEX_HINT_PATH" ]]; then
109108
cat <<'EOF' > "$CODEX_HINT_PATH"
109+
docker_git_workspace_context_line() {
110+
REPO_REF_VALUE="\${REPO_REF:-__REPO_REF_DEFAULT__}"
111+
REPO_URL_VALUE="\${REPO_URL:-__REPO_URL_DEFAULT__}"
112+
113+
if [[ "$REPO_REF_VALUE" == issue-* ]]; then
114+
ISSUE_ID_VALUE="$(printf "%s" "$REPO_REF_VALUE" | sed -E 's#^issue-##')"
115+
ISSUE_URL_VALUE=""
116+
if [[ "$REPO_URL_VALUE" == https://github.com/* ]]; then
117+
ISSUE_REPO_VALUE="$(printf "%s" "$REPO_URL_VALUE" | sed -E 's#^https://github.com/##; s#[.]git$##; s#/*$##')"
118+
if [[ -n "$ISSUE_REPO_VALUE" ]]; then
119+
ISSUE_URL_VALUE="https://github.com/$ISSUE_REPO_VALUE/issues/$ISSUE_ID_VALUE"
120+
fi
121+
fi
122+
if [[ -n "$ISSUE_URL_VALUE" ]]; then
123+
printf "%s\n" "Контекст workspace: issue #$ISSUE_ID_VALUE ($ISSUE_URL_VALUE)"
124+
else
125+
printf "%s\n" "Контекст workspace: issue #$ISSUE_ID_VALUE"
126+
fi
127+
return
128+
fi
129+
130+
if [[ "$REPO_REF_VALUE" == refs/pull/*/head ]]; then
131+
PR_ID_VALUE="$(printf "%s" "$REPO_REF_VALUE" | sed -nE 's#^refs/pull/([0-9]+)/head$#\\1#p')"
132+
PR_URL_VALUE=""
133+
if [[ "$REPO_URL_VALUE" == https://github.com/* && -n "$PR_ID_VALUE" ]]; then
134+
PR_REPO_VALUE="$(printf "%s" "$REPO_URL_VALUE" | sed -E 's#^https://github.com/##; s#[.]git$##; s#/*$##')"
135+
if [[ -n "$PR_REPO_VALUE" ]]; then
136+
PR_URL_VALUE="https://github.com/$PR_REPO_VALUE/pull/$PR_ID_VALUE"
137+
fi
138+
fi
139+
if [[ -n "$PR_ID_VALUE" && -n "$PR_URL_VALUE" ]]; then
140+
printf "%s\n" "Контекст workspace: PR #$PR_ID_VALUE ($PR_URL_VALUE)"
141+
elif [[ -n "$PR_ID_VALUE" ]]; then
142+
printf "%s\n" "Контекст workspace: PR #$PR_ID_VALUE"
143+
elif [[ -n "$REPO_REF_VALUE" ]]; then
144+
printf "%s\n" "Контекст workspace: pull request ($REPO_REF_VALUE)"
145+
fi
146+
return
147+
fi
148+
149+
if [[ -n "$REPO_URL_VALUE" ]]; then
150+
printf "%s\n" "Контекст workspace: $REPO_URL_VALUE"
151+
fi
152+
}
153+
154+
docker_git_print_codex_resume_hint() {
155+
if [ -z "\${CODEX_RESUME_HINT_SHOWN-}" ]; then
156+
DOCKER_GIT_CONTEXT_LINE="$(docker_git_workspace_context_line)"
157+
if [[ -n "$DOCKER_GIT_CONTEXT_LINE" ]]; then
158+
echo "$DOCKER_GIT_CONTEXT_LINE"
159+
fi
160+
echo "Старые сессии можно запустить с помощью codex resume или codex resume <id>, если знаешь айди."
161+
export CODEX_RESUME_HINT_SHOWN=1
162+
fi
163+
}
164+
110165
if [ -n "$BASH_VERSION" ]; then
111166
case "$-" in
112167
*i*)
113-
if [ -z "\${CODEX_RESUME_HINT_SHOWN-}" ]; then
114-
echo "Старые сессии можно запустить с помощью codex resume или codex resume <id>, если знаешь айди."
115-
export CODEX_RESUME_HINT_SHOWN=1
116-
fi
168+
docker_git_print_codex_resume_hint
117169
;;
118170
esac
119171
fi
120172
if [ -n "$ZSH_VERSION" ]; then
121173
if [[ "$-" == *i* ]]; then
122-
if [[ -z "\${CODEX_RESUME_HINT_SHOWN-}" ]]; then
123-
echo "Старые сессии можно запустить с помощью codex resume или codex resume <id>, если знаешь айди."
124-
export CODEX_RESUME_HINT_SHOWN=1
125-
fi
174+
docker_git_print_codex_resume_hint
126175
fi
127176
fi
128177
EOF
@@ -135,6 +184,21 @@ if [[ -s /etc/zsh/zshrc ]] && ! grep -q "zz-codex-resume.sh" /etc/zsh/zshrc 2>/d
135184
printf "%s\\n" "if [ -f /etc/profile.d/zz-codex-resume.sh ]; then source /etc/profile.d/zz-codex-resume.sh; fi" >> /etc/zsh/zshrc
136185
fi`
137186

187+
const escapeForDoubleQuotes = (value: string): string => {
188+
const backslash = String.fromCodePoint(92)
189+
const quote = String.fromCodePoint(34)
190+
const escapedBackslash = `${backslash}${backslash}`
191+
const escapedQuote = `${backslash}${quote}`
192+
return value
193+
.replaceAll(backslash, escapedBackslash)
194+
.replaceAll(quote, escapedQuote)
195+
}
196+
197+
export const renderEntrypointCodexResumeHint = (config: TemplateConfig): string =>
198+
entrypointCodexResumeHintTemplate
199+
.replaceAll("__REPO_REF_DEFAULT__", escapeForDoubleQuotes(config.repoRef))
200+
.replaceAll("__REPO_URL_DEFAULT__", escapeForDoubleQuotes(config.repoUrl))
201+
138202
const entrypointAgentsNoticeTemplate = String.raw`# Ensure global AGENTS.md exists for container context
139203
AGENTS_PATH="__CODEX_HOME__/AGENTS.md"
140204
LEGACY_AGENTS_PATH="/home/__SSH_USER__/AGENTS.md"
@@ -160,8 +224,17 @@ if [[ "$REPO_REF" == issue-* ]]; then
160224
fi
161225
ISSUE_AGENTS_HINT_LINE="Issue AGENTS.md: __TARGET_DIR__/AGENTS.md"
162226
elif [[ "$REPO_REF" == refs/pull/*/head ]]; then
163-
PR_ID="$(printf "%s" "$REPO_REF" | sed -E 's#^refs/pull/([0-9]+)/head$#\1#')"
164-
if [[ -n "$PR_ID" ]]; then
227+
PR_ID="$(printf "%s" "$REPO_REF" | sed -nE 's#^refs/pull/([0-9]+)/head$#\1#p')"
228+
PR_URL=""
229+
if [[ "$REPO_URL" == https://github.com/* && -n "$PR_ID" ]]; then
230+
PR_REPO="$(printf "%s" "$REPO_URL" | sed -E 's#^https://github.com/##; s#[.]git$##; s#/*$##')"
231+
if [[ -n "$PR_REPO" ]]; then
232+
PR_URL="https://github.com/$PR_REPO/pull/$PR_ID"
233+
fi
234+
fi
235+
if [[ -n "$PR_ID" && -n "$PR_URL" ]]; then
236+
WORKSPACE_INFO_LINE="Контекст workspace: PR #$PR_ID ($PR_URL)"
237+
elif [[ -n "$PR_ID" ]]; then
165238
WORKSPACE_INFO_LINE="Контекст workspace: PR #$PR_ID"
166239
else
167240
WORKSPACE_INFO_LINE="Контекст workspace: pull request ($REPO_REF)"

scripts/e2e/login-context.sh

Lines changed: 162 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,162 @@
1+
#!/usr/bin/env bash
2+
set -euo pipefail
3+
4+
RUN_ID="$(date +%s)-$RANDOM"
5+
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
6+
REPO_ROOT="$(cd "$SCRIPT_DIR/../.." && pwd)"
7+
ROOT_BASE="${DOCKER_GIT_E2E_ROOT_BASE:-$REPO_ROOT/.docker-git/e2e-root}"
8+
mkdir -p "$ROOT_BASE"
9+
ROOT="$(mktemp -d "$ROOT_BASE/login-context.XXXXXX")"
10+
SSH_KEY_BASE="$(mktemp -d /tmp/docker-git-login-context-key.XXXXXX)"
11+
# docker-git containers may `chown -R` the `.docker-git` bind mount to UID 1000.
12+
# Use world-writable permissions so the host runner can still create files
13+
# even if ownership changes inside the container.
14+
chmod 0777 "$ROOT"
15+
mkdir -p "$ROOT/e2e"
16+
chmod 0777 "$ROOT/e2e"
17+
KEEP="${KEEP:-0}"
18+
19+
export DOCKER_GIT_PROJECTS_ROOT="$ROOT"
20+
export DOCKER_GIT_STATE_AUTO_SYNC=0
21+
22+
ACTIVE_OUT_DIR=""
23+
ACTIVE_CONTAINER=""
24+
ACTIVE_SERVICE=""
25+
26+
fail() {
27+
echo "e2e/login-context: $*" >&2
28+
exit 1
29+
}
30+
31+
on_error() {
32+
local line="$1"
33+
echo "e2e/login-context: failed at line $line" >&2
34+
docker ps -a --format 'table {{.Names}}\t{{.Status}}\t{{.Ports}}' | head -n 80 || true
35+
if [[ -n "$ACTIVE_OUT_DIR" ]] && [[ -f "$ACTIVE_OUT_DIR/docker-compose.yml" ]]; then
36+
(cd "$ACTIVE_OUT_DIR" && docker compose ps) || true
37+
(cd "$ACTIVE_OUT_DIR" && docker compose logs --no-color --tail 200) || true
38+
fi
39+
}
40+
41+
cleanup_active_case() {
42+
if [[ -n "$ACTIVE_OUT_DIR" ]] && [[ -f "$ACTIVE_OUT_DIR/docker-compose.yml" ]]; then
43+
(cd "$ACTIVE_OUT_DIR" && docker compose down -v --remove-orphans) >/dev/null 2>&1 || true
44+
fi
45+
ACTIVE_OUT_DIR=""
46+
ACTIVE_CONTAINER=""
47+
ACTIVE_SERVICE=""
48+
}
49+
50+
cleanup() {
51+
if [[ "$KEEP" == "1" ]]; then
52+
echo "e2e/login-context: KEEP=1 set; preserving temp dir: $ROOT" >&2
53+
if [[ -n "$ACTIVE_CONTAINER" ]]; then
54+
echo "e2e/login-context: active container: $ACTIVE_CONTAINER" >&2
55+
fi
56+
if [[ -n "$ACTIVE_OUT_DIR" ]]; then
57+
echo "e2e/login-context: active out dir: $ACTIVE_OUT_DIR" >&2
58+
fi
59+
return
60+
fi
61+
cleanup_active_case
62+
rm -rf "$ROOT" >/dev/null 2>&1 || true
63+
rm -rf "$SSH_KEY_BASE" >/dev/null 2>&1 || true
64+
}
65+
66+
trap 'on_error $LINENO' ERR
67+
trap cleanup EXIT
68+
69+
command -v ssh >/dev/null 2>&1 || fail "missing 'ssh' command"
70+
command -v timeout >/dev/null 2>&1 || fail "missing 'timeout' command"
71+
command -v ssh-keygen >/dev/null 2>&1 || fail "missing 'ssh-keygen' command"
72+
73+
ssh-keygen -q -t ed25519 -N "" -f "$SSH_KEY_BASE/dev_ssh_key" >/dev/null
74+
cp "$SSH_KEY_BASE/dev_ssh_key.pub" "$ROOT/authorized_keys"
75+
chmod 0600 "$SSH_KEY_BASE/dev_ssh_key"
76+
chmod 0644 "$ROOT/authorized_keys"
77+
78+
wait_for_ssh() {
79+
local ssh_port="$1"
80+
local attempts=30
81+
local attempt=1
82+
83+
while [[ "$attempt" -le "$attempts" ]]; do
84+
if timeout 1 bash -lc "cat < /dev/null > /dev/tcp/127.0.0.1/$ssh_port" >/dev/null 2>&1; then
85+
return 0
86+
fi
87+
sleep 1
88+
attempt="$((attempt + 1))"
89+
done
90+
91+
return 1
92+
}
93+
94+
run_case() {
95+
local case_name="$1"
96+
local repo_url="$2"
97+
local expected_context_line="$3"
98+
local out_dir_rel=".docker-git/e2e/login-context-${case_name}-${RUN_ID}"
99+
local out_dir="$ROOT/e2e/login-context-${case_name}-${RUN_ID}"
100+
local container_name="dg-e2e-login-${case_name}-${RUN_ID}"
101+
local service_name="dg-e2e-login-${case_name}-${RUN_ID}"
102+
local volume_name="dg-e2e-login-${case_name}-${RUN_ID}-home"
103+
local ssh_port="$(( (RANDOM % 1000) + 21000 ))"
104+
local login_log="/tmp/docker-git-login-context-${RUN_ID}-${case_name}.log"
105+
106+
mkdir -p "$out_dir/.orch/env"
107+
chmod 0777 "$out_dir" "$out_dir/.orch" "$out_dir/.orch/env"
108+
cat > "$out_dir/.orch/env/project.env" <<'EOF_ENV'
109+
# docker-git project env (e2e)
110+
CODEX_AUTO_UPDATE=0
111+
CODEX_SHARE_AUTH=1
112+
EOF_ENV
113+
114+
ACTIVE_OUT_DIR="$out_dir"
115+
ACTIVE_CONTAINER="$container_name"
116+
ACTIVE_SERVICE="$service_name"
117+
118+
(
119+
cd "$REPO_ROOT"
120+
pnpm run docker-git clone "$repo_url" \
121+
--force \
122+
--no-ssh \
123+
--authorized-keys "$ROOT/authorized_keys" \
124+
--ssh-port "$ssh_port" \
125+
--out-dir "$out_dir_rel" \
126+
--container-name "$container_name" \
127+
--service-name "$service_name" \
128+
--volume-name "$volume_name"
129+
)
130+
131+
wait_for_ssh "$ssh_port" || fail "ssh port did not open for $case_name (port: $ssh_port)"
132+
133+
rm -f "$login_log"
134+
135+
set +e
136+
timeout 30s bash -lc "printf 'exit\n' | ssh -i \"$SSH_KEY_BASE/dev_ssh_key\" -tt -o LogLevel=ERROR -o StrictHostKeyChecking=no -o UserKnownHostsFile=/dev/null -p \"$ssh_port\" dev@localhost" > "$login_log" 2>&1
137+
local ssh_exit=$?
138+
set -e
139+
140+
if [[ "$ssh_exit" -ne 0 ]]; then
141+
cat "$login_log" >&2 || true
142+
fail "ssh login failed for $case_name (exit: $ssh_exit)"
143+
fi
144+
145+
grep -Fq -- "$expected_context_line" "$login_log" \
146+
|| fail "expected context line not found for $case_name: $expected_context_line"
147+
148+
grep -Fq -- "Старые сессии можно запустить с помощью codex resume" "$login_log" \
149+
|| fail "expected codex resume hint for $case_name"
150+
151+
cleanup_active_case
152+
}
153+
154+
run_case \
155+
"issue" \
156+
"https://github.com/octocat/Hello-World/issues/1" \
157+
"Контекст workspace: issue #1 (https://github.com/octocat/Hello-World/issues/1)"
158+
159+
run_case \
160+
"pr" \
161+
"https://github.com/octocat/Hello-World/pull/1" \
162+
"Контекст workspace: PR #1 (https://github.com/octocat/Hello-World/pull/1)"

0 commit comments

Comments
 (0)