From 45f4f3dfbe1538d0cf815c5c125c6bff12a4be58 Mon Sep 17 00:00:00 2001 From: konard Date: Tue, 12 May 2026 09:49:34 +0000 Subject: [PATCH 01/19] Initial commit with task details Adding .gitkeep for PR creation (default mode). This file will be removed when the task is complete. Issue: https://github.com/ProverCoderAI/docker-git/issues/267 --- .gitkeep | 1 + 1 file changed, 1 insertion(+) create mode 100644 .gitkeep diff --git a/.gitkeep b/.gitkeep new file mode 100644 index 00000000..6e69ac14 --- /dev/null +++ b/.gitkeep @@ -0,0 +1 @@ +# .gitkeep file auto-generated at 2026-05-12T09:49:34.468Z for PR creation at branch issue-267-99a3d92349cd for issue https://github.com/ProverCoderAI/docker-git/issues/267 \ No newline at end of file From 4ee4bb130ffc390e17dea74275a6e1f494cb1091 Mon Sep 17 00:00:00 2001 From: konard Date: Tue, 12 May 2026 10:16:39 +0000 Subject: [PATCH 02/19] feat(core): use shared box image for project containers --- .../app/src/lib/core/templates/dockerfile.ts | 30 ++++++++++++++----- packages/lib/src/core/templates/dockerfile.ts | 30 ++++++++++++++----- packages/lib/tests/core/templates.test.ts | 17 +++++++++++ 3 files changed, 63 insertions(+), 14 deletions(-) diff --git a/packages/app/src/lib/core/templates/dockerfile.ts b/packages/app/src/lib/core/templates/dockerfile.ts index 753841b4..4aa187f3 100644 --- a/packages/app/src/lib/core/templates/dockerfile.ts +++ b/packages/app/src/lib/core/templates/dockerfile.ts @@ -3,9 +3,22 @@ import { renderDockerfilePrompt } from "../templates-prompt.js" import { renderDockerfileGlab } from "./glab.js" import { renderDockerfileGitleaks, renderDockerfileOpenCode } from "./tools.js" +// CHANGE: use the shared link-foundation box as the generated project base image +// WHY: issue #267 asks docker-git to reuse unified box containers instead of maintaining a raw Ubuntu workspace base +// QUOTE(ТЗ): "Что бы не зависить только от своих обновлений, а иметь единую инфраструктру есть смысл юзать готовый репозиторий" +// REF: issue-267 +// SOURCE: https://github.com/link-foundation/box +// FORMAT THEOREM: renderDockerfile(config) -> base_image(rendered) = DOCKER_GIT_BASE_IMAGE +// PURITY: CORE +// INVARIANT: the rendered Dockerfile inherits language/runtime tooling from link-foundation/box while preserving docker-git bootstrap layers +// COMPLEXITY: O(1)/O(1) +const dockerGitBaseImage = "ghcr.io/link-foundation/box:latest" + const renderDockerfilePrelude = (): string => - `FROM ubuntu:24.04 + `ARG DOCKER_GIT_BASE_IMAGE=${dockerGitBaseImage} +FROM \${DOCKER_GIT_BASE_IMAGE} +USER root ARG UBUNTU_APT_MIRROR= ENV DEBIAN_FRONTEND=noninteractive ENV NVM_DIR=/usr/local/nvm @@ -187,13 +200,16 @@ const renderDockerfileBun = (config: TemplateConfig): string => const renderDockerfileUsers = (config: TemplateConfig): string => `# Create non-root user for SSH (align UID/GID with host user 1000) -RUN if id -u ubuntu >/dev/null 2>&1; then \ - if getent group 1000 >/dev/null 2>&1; then \ - EXISTING_GROUP="$(getent group 1000 | cut -d: -f1)"; \ - if [ "$EXISTING_GROUP" != "${config.sshUser}" ]; then groupmod -n ${config.sshUser} "$EXISTING_GROUP" || true; fi; \ +RUN for BASE_USER in box ubuntu; do \ + if [ "$BASE_USER" != "${config.sshUser}" ] && id -u "$BASE_USER" >/dev/null 2>&1; then \ + if getent group 1000 >/dev/null 2>&1; then \ + EXISTING_GROUP="$(getent group 1000 | cut -d: -f1)"; \ + if [ "$EXISTING_GROUP" != "${config.sshUser}" ]; then groupmod -n ${config.sshUser} "$EXISTING_GROUP" || true; fi; \ + fi; \ + usermod -l ${config.sshUser} -d /home/${config.sshUser} -m -s /usr/bin/zsh "$BASE_USER" || true; \ + break; \ fi; \ - usermod -l ${config.sshUser} -d /home/${config.sshUser} -m -s /usr/bin/zsh ubuntu || true; \ - fi + done RUN if id -u ${config.sshUser} >/dev/null 2>&1; then \ usermod -u 1000 -g 1000 -o ${config.sshUser}; \ else \ diff --git a/packages/lib/src/core/templates/dockerfile.ts b/packages/lib/src/core/templates/dockerfile.ts index 753841b4..4aa187f3 100644 --- a/packages/lib/src/core/templates/dockerfile.ts +++ b/packages/lib/src/core/templates/dockerfile.ts @@ -3,9 +3,22 @@ import { renderDockerfilePrompt } from "../templates-prompt.js" import { renderDockerfileGlab } from "./glab.js" import { renderDockerfileGitleaks, renderDockerfileOpenCode } from "./tools.js" +// CHANGE: use the shared link-foundation box as the generated project base image +// WHY: issue #267 asks docker-git to reuse unified box containers instead of maintaining a raw Ubuntu workspace base +// QUOTE(ТЗ): "Что бы не зависить только от своих обновлений, а иметь единую инфраструктру есть смысл юзать готовый репозиторий" +// REF: issue-267 +// SOURCE: https://github.com/link-foundation/box +// FORMAT THEOREM: renderDockerfile(config) -> base_image(rendered) = DOCKER_GIT_BASE_IMAGE +// PURITY: CORE +// INVARIANT: the rendered Dockerfile inherits language/runtime tooling from link-foundation/box while preserving docker-git bootstrap layers +// COMPLEXITY: O(1)/O(1) +const dockerGitBaseImage = "ghcr.io/link-foundation/box:latest" + const renderDockerfilePrelude = (): string => - `FROM ubuntu:24.04 + `ARG DOCKER_GIT_BASE_IMAGE=${dockerGitBaseImage} +FROM \${DOCKER_GIT_BASE_IMAGE} +USER root ARG UBUNTU_APT_MIRROR= ENV DEBIAN_FRONTEND=noninteractive ENV NVM_DIR=/usr/local/nvm @@ -187,13 +200,16 @@ const renderDockerfileBun = (config: TemplateConfig): string => const renderDockerfileUsers = (config: TemplateConfig): string => `# Create non-root user for SSH (align UID/GID with host user 1000) -RUN if id -u ubuntu >/dev/null 2>&1; then \ - if getent group 1000 >/dev/null 2>&1; then \ - EXISTING_GROUP="$(getent group 1000 | cut -d: -f1)"; \ - if [ "$EXISTING_GROUP" != "${config.sshUser}" ]; then groupmod -n ${config.sshUser} "$EXISTING_GROUP" || true; fi; \ +RUN for BASE_USER in box ubuntu; do \ + if [ "$BASE_USER" != "${config.sshUser}" ] && id -u "$BASE_USER" >/dev/null 2>&1; then \ + if getent group 1000 >/dev/null 2>&1; then \ + EXISTING_GROUP="$(getent group 1000 | cut -d: -f1)"; \ + if [ "$EXISTING_GROUP" != "${config.sshUser}" ]; then groupmod -n ${config.sshUser} "$EXISTING_GROUP" || true; fi; \ + fi; \ + usermod -l ${config.sshUser} -d /home/${config.sshUser} -m -s /usr/bin/zsh "$BASE_USER" || true; \ + break; \ fi; \ - usermod -l ${config.sshUser} -d /home/${config.sshUser} -m -s /usr/bin/zsh ubuntu || true; \ - fi + done RUN if id -u ${config.sshUser} >/dev/null 2>&1; then \ usermod -u 1000 -g 1000 -o ${config.sshUser}; \ else \ diff --git a/packages/lib/tests/core/templates.test.ts b/packages/lib/tests/core/templates.test.ts index 4de72f9e..7366ea7d 100644 --- a/packages/lib/tests/core/templates.test.ts +++ b/packages/lib/tests/core/templates.test.ts @@ -54,6 +54,23 @@ describe("renderEntrypointDnsRepair", () => { }) describe("renderDockerfile", () => { + it("uses the shared box image as the project container base", () => { + const dockerfile = renderDockerfile(makeTemplateConfig()) + + expect(dockerfile).toContain("ARG DOCKER_GIT_BASE_IMAGE=ghcr.io/link-foundation/box:latest") + expect(dockerfile).toContain("FROM ${DOCKER_GIT_BASE_IMAGE}") + expect(dockerfile).toContain("USER root") + expect(dockerfile).not.toContain("FROM ubuntu:24.04") + }) + + it("renames the box base user to the configured SSH user", () => { + const dockerfile = renderDockerfile(makeTemplateConfig()) + + expect(dockerfile).toContain("for BASE_USER in box ubuntu; do") + expect(dockerfile).toContain('if [ "$BASE_USER" != "dev" ] && id -u "$BASE_USER" >/dev/null 2>&1; then') + expect(dockerfile).toContain('usermod -l dev -d /home/dev -m -s /usr/bin/zsh "$BASE_USER" || true') + }) + it("installs session sync from npmjs with a local fallback", () => { const dockerfile = renderDockerfile(makeTemplateConfig()) From aa8867f2a1b13e47c645a9fabec61215cc37d5fe Mon Sep 17 00:00:00 2001 From: konard Date: Tue, 12 May 2026 10:31:48 +0000 Subject: [PATCH 03/19] fix(core): use public box image alias --- packages/app/src/lib/core/templates/dockerfile.ts | 6 +++--- packages/lib/src/core/templates/dockerfile.ts | 6 +++--- packages/lib/tests/core/templates.test.ts | 2 +- 3 files changed, 7 insertions(+), 7 deletions(-) diff --git a/packages/app/src/lib/core/templates/dockerfile.ts b/packages/app/src/lib/core/templates/dockerfile.ts index 4aa187f3..c3caae08 100644 --- a/packages/app/src/lib/core/templates/dockerfile.ts +++ b/packages/app/src/lib/core/templates/dockerfile.ts @@ -4,15 +4,15 @@ import { renderDockerfileGlab } from "./glab.js" import { renderDockerfileGitleaks, renderDockerfileOpenCode } from "./tools.js" // CHANGE: use the shared link-foundation box as the generated project base image -// WHY: issue #267 asks docker-git to reuse unified box containers instead of maintaining a raw Ubuntu workspace base +// WHY: issue #267 asks docker-git to reuse unified box containers instead of maintaining a raw Ubuntu workspace base; the Docker Hub alias is publicly pullable in CI // QUOTE(ТЗ): "Что бы не зависить только от своих обновлений, а иметь единую инфраструктру есть смысл юзать готовый репозиторий" // REF: issue-267 -// SOURCE: https://github.com/link-foundation/box +// SOURCE: https://github.com/link-foundation/box#docker-hub---combo-boxes // FORMAT THEOREM: renderDockerfile(config) -> base_image(rendered) = DOCKER_GIT_BASE_IMAGE // PURITY: CORE // INVARIANT: the rendered Dockerfile inherits language/runtime tooling from link-foundation/box while preserving docker-git bootstrap layers // COMPLEXITY: O(1)/O(1) -const dockerGitBaseImage = "ghcr.io/link-foundation/box:latest" +const dockerGitBaseImage = "konard/box:latest" const renderDockerfilePrelude = (): string => `ARG DOCKER_GIT_BASE_IMAGE=${dockerGitBaseImage} diff --git a/packages/lib/src/core/templates/dockerfile.ts b/packages/lib/src/core/templates/dockerfile.ts index 4aa187f3..c3caae08 100644 --- a/packages/lib/src/core/templates/dockerfile.ts +++ b/packages/lib/src/core/templates/dockerfile.ts @@ -4,15 +4,15 @@ import { renderDockerfileGlab } from "./glab.js" import { renderDockerfileGitleaks, renderDockerfileOpenCode } from "./tools.js" // CHANGE: use the shared link-foundation box as the generated project base image -// WHY: issue #267 asks docker-git to reuse unified box containers instead of maintaining a raw Ubuntu workspace base +// WHY: issue #267 asks docker-git to reuse unified box containers instead of maintaining a raw Ubuntu workspace base; the Docker Hub alias is publicly pullable in CI // QUOTE(ТЗ): "Что бы не зависить только от своих обновлений, а иметь единую инфраструктру есть смысл юзать готовый репозиторий" // REF: issue-267 -// SOURCE: https://github.com/link-foundation/box +// SOURCE: https://github.com/link-foundation/box#docker-hub---combo-boxes // FORMAT THEOREM: renderDockerfile(config) -> base_image(rendered) = DOCKER_GIT_BASE_IMAGE // PURITY: CORE // INVARIANT: the rendered Dockerfile inherits language/runtime tooling from link-foundation/box while preserving docker-git bootstrap layers // COMPLEXITY: O(1)/O(1) -const dockerGitBaseImage = "ghcr.io/link-foundation/box:latest" +const dockerGitBaseImage = "konard/box:latest" const renderDockerfilePrelude = (): string => `ARG DOCKER_GIT_BASE_IMAGE=${dockerGitBaseImage} diff --git a/packages/lib/tests/core/templates.test.ts b/packages/lib/tests/core/templates.test.ts index 7366ea7d..b28f2690 100644 --- a/packages/lib/tests/core/templates.test.ts +++ b/packages/lib/tests/core/templates.test.ts @@ -57,7 +57,7 @@ describe("renderDockerfile", () => { it("uses the shared box image as the project container base", () => { const dockerfile = renderDockerfile(makeTemplateConfig()) - expect(dockerfile).toContain("ARG DOCKER_GIT_BASE_IMAGE=ghcr.io/link-foundation/box:latest") + expect(dockerfile).toContain("ARG DOCKER_GIT_BASE_IMAGE=konard/box:latest") expect(dockerfile).toContain("FROM ${DOCKER_GIT_BASE_IMAGE}") expect(dockerfile).toContain("USER root") expect(dockerfile).not.toContain("FROM ubuntu:24.04") From 393b70dbabeb64c8e77656a2eef63b7a77495e7e Mon Sep 17 00:00:00 2001 From: konard Date: Tue, 12 May 2026 10:55:42 +0000 Subject: [PATCH 04/19] fix(core): use lightweight shared box image --- packages/app/src/lib/core/templates/dockerfile.ts | 8 ++++---- packages/lib/src/core/templates/dockerfile.ts | 8 ++++---- packages/lib/tests/core/templates.test.ts | 4 ++-- 3 files changed, 10 insertions(+), 10 deletions(-) diff --git a/packages/app/src/lib/core/templates/dockerfile.ts b/packages/app/src/lib/core/templates/dockerfile.ts index c3caae08..778322ef 100644 --- a/packages/app/src/lib/core/templates/dockerfile.ts +++ b/packages/app/src/lib/core/templates/dockerfile.ts @@ -3,16 +3,16 @@ import { renderDockerfilePrompt } from "../templates-prompt.js" import { renderDockerfileGlab } from "./glab.js" import { renderDockerfileGitleaks, renderDockerfileOpenCode } from "./tools.js" -// CHANGE: use the shared link-foundation box as the generated project base image -// WHY: issue #267 asks docker-git to reuse unified box containers instead of maintaining a raw Ubuntu workspace base; the Docker Hub alias is publicly pullable in CI +// CHANGE: use the shared link-foundation JS box as the generated project base image +// WHY: issue #267 asks docker-git to reuse unified box containers instead of maintaining a raw Ubuntu workspace base; the Docker Hub JS alias is public and keeps CI pull size bounded // QUOTE(ТЗ): "Что бы не зависить только от своих обновлений, а иметь единую инфраструктру есть смысл юзать готовый репозиторий" // REF: issue-267 // SOURCE: https://github.com/link-foundation/box#docker-hub---combo-boxes // FORMAT THEOREM: renderDockerfile(config) -> base_image(rendered) = DOCKER_GIT_BASE_IMAGE // PURITY: CORE -// INVARIANT: the rendered Dockerfile inherits language/runtime tooling from link-foundation/box while preserving docker-git bootstrap layers +// INVARIANT: the rendered Dockerfile inherits JS/runtime tooling from link-foundation/box while preserving docker-git bootstrap layers // COMPLEXITY: O(1)/O(1) -const dockerGitBaseImage = "konard/box:latest" +const dockerGitBaseImage = "konard/box-js:latest" const renderDockerfilePrelude = (): string => `ARG DOCKER_GIT_BASE_IMAGE=${dockerGitBaseImage} diff --git a/packages/lib/src/core/templates/dockerfile.ts b/packages/lib/src/core/templates/dockerfile.ts index c3caae08..778322ef 100644 --- a/packages/lib/src/core/templates/dockerfile.ts +++ b/packages/lib/src/core/templates/dockerfile.ts @@ -3,16 +3,16 @@ import { renderDockerfilePrompt } from "../templates-prompt.js" import { renderDockerfileGlab } from "./glab.js" import { renderDockerfileGitleaks, renderDockerfileOpenCode } from "./tools.js" -// CHANGE: use the shared link-foundation box as the generated project base image -// WHY: issue #267 asks docker-git to reuse unified box containers instead of maintaining a raw Ubuntu workspace base; the Docker Hub alias is publicly pullable in CI +// CHANGE: use the shared link-foundation JS box as the generated project base image +// WHY: issue #267 asks docker-git to reuse unified box containers instead of maintaining a raw Ubuntu workspace base; the Docker Hub JS alias is public and keeps CI pull size bounded // QUOTE(ТЗ): "Что бы не зависить только от своих обновлений, а иметь единую инфраструктру есть смысл юзать готовый репозиторий" // REF: issue-267 // SOURCE: https://github.com/link-foundation/box#docker-hub---combo-boxes // FORMAT THEOREM: renderDockerfile(config) -> base_image(rendered) = DOCKER_GIT_BASE_IMAGE // PURITY: CORE -// INVARIANT: the rendered Dockerfile inherits language/runtime tooling from link-foundation/box while preserving docker-git bootstrap layers +// INVARIANT: the rendered Dockerfile inherits JS/runtime tooling from link-foundation/box while preserving docker-git bootstrap layers // COMPLEXITY: O(1)/O(1) -const dockerGitBaseImage = "konard/box:latest" +const dockerGitBaseImage = "konard/box-js:latest" const renderDockerfilePrelude = (): string => `ARG DOCKER_GIT_BASE_IMAGE=${dockerGitBaseImage} diff --git a/packages/lib/tests/core/templates.test.ts b/packages/lib/tests/core/templates.test.ts index b28f2690..3bd86a0b 100644 --- a/packages/lib/tests/core/templates.test.ts +++ b/packages/lib/tests/core/templates.test.ts @@ -54,10 +54,10 @@ describe("renderEntrypointDnsRepair", () => { }) describe("renderDockerfile", () => { - it("uses the shared box image as the project container base", () => { + it("uses the shared JS box image as the project container base", () => { const dockerfile = renderDockerfile(makeTemplateConfig()) - expect(dockerfile).toContain("ARG DOCKER_GIT_BASE_IMAGE=konard/box:latest") + expect(dockerfile).toContain("ARG DOCKER_GIT_BASE_IMAGE=konard/box-js:latest") expect(dockerfile).toContain("FROM ${DOCKER_GIT_BASE_IMAGE}") expect(dockerfile).toContain("USER root") expect(dockerfile).not.toContain("FROM ubuntu:24.04") From bc9bf4ce84d5a91fb7f49f89480ba6f5d08ce3af Mon Sep 17 00:00:00 2001 From: konard Date: Tue, 12 May 2026 11:33:48 +0000 Subject: [PATCH 05/19] fix(core): keep prompt inert for non-interactive shells --- packages/app/src/lib/core/templates-prompt.ts | 19 +++++++++----- packages/lib/src/core/templates-prompt.ts | 19 +++++++++----- packages/lib/tests/core/templates.test.ts | 26 ++++++++++++++++++- 3 files changed, 49 insertions(+), 15 deletions(-) diff --git a/packages/app/src/lib/core/templates-prompt.ts b/packages/app/src/lib/core/templates-prompt.ts index ff913ca4..d372566d 100644 --- a/packages/app/src/lib/core/templates-prompt.ts +++ b/packages/app/src/lib/core/templates-prompt.ts @@ -9,11 +9,11 @@ import { renderZshConfig as renderZshConfigTemplate } from "./templates-zsh.js" // FORMAT THEOREM: forall s in InteractiveShells: prompt(s) -> includes(time, path, branch|empty) // PURITY: CORE // EFFECT: n/a -// INVARIANT: script is deterministic +// INVARIANT: script is deterministic and does not touch TTY state outside interactive shells // COMPLEXITY: O(1) const dockerGitTerminalSanitizeShell = String.raw`docker_git_terminal_write_escape() { if [ -c /dev/tty ]; then - printf "\033[0m\033[?25h\033[?1l\033>\033[?1000l\033[?1002l\033[?1003l\033[?1005l\033[?1006l\033[?1015l\033[?1007l\033[?1004l\033[?2004l\033[>4;0m\033[>4m\033[ /dev/tty 2>/dev/null && return 0 + { printf "\033[0m\033[?25h\033[?1l\033>\033[?1000l\033[?1002l\033[?1003l\033[?1005l\033[?1006l\033[?1015l\033[?1007l\033[?1004l\033[?2004l\033[>4;0m\033[>4m\033[ /dev/tty; } 2>/dev/null && return 0 fi if [ -t 1 ]; then printf "\033[0m\033[?25h\033[?1l\033>\033[?1000l\033[?1002l\033[?1003l\033[?1005l\033[?1006l\033[?1015l\033[?1007l\033[?1004l\033[?2004l\033[>4;0m\033[>4m\033[ /dev/tty 2>/dev/null || stty sane < /dev/tty 2>/dev/null || true + { stty sane < /dev/tty > /dev/tty; } 2>/dev/null || { stty sane < /dev/tty; } 2>/dev/null || true elif [ -t 0 ]; then stty sane 2>/dev/null || true fi @@ -32,6 +32,11 @@ docker_git_terminal_sanitize() { }` const dockerGitPromptScript = `${dockerGitTerminalSanitizeShell} +case "$-" in + *i*) ;; + *) return 0 2>/dev/null || exit 0 ;; +esac + docker_git_branch() { git rev-parse --abbrev-ref HEAD 2>/dev/null; } docker_git_short_pwd() { local full_path @@ -97,8 +102,8 @@ docker_git_prompt_apply() { PS1="\${base}> " fi } -if [ -n "$PROMPT_COMMAND" ]; then - PROMPT_COMMAND="docker_git_prompt_apply;$PROMPT_COMMAND" +if [ -n "\${PROMPT_COMMAND-}" ]; then + PROMPT_COMMAND="docker_git_prompt_apply;\${PROMPT_COMMAND}" else PROMPT_COMMAND="docker_git_prompt_apply" fi @@ -191,7 +196,7 @@ export const renderZshConfig = (): string => renderZshConfigTemplate(dockerGitTe // FORMAT THEOREM: forall s in InteractiveShells: prompt(s) -> includes(time, path, branch|empty) // PURITY: CORE // EFFECT: n/a -// INVARIANT: only interactive shells source /etc/profile.d/zz-prompt.sh +// INVARIANT: only interactive shells mutate prompt or TTY state // COMPLEXITY: O(1) export const renderDockerfilePrompt = (): string => String.raw`# Shell prompt: show git branch for interactive sessions @@ -229,7 +234,7 @@ EOF` // FORMAT THEOREM: forall s in InteractiveShells: prompt(s) -> includes(time, path, branch|empty) // PURITY: CORE // EFFECT: n/a -// INVARIANT: /etc/profile.d/zz-prompt.sh is non-empty after entrypoint +// INVARIANT: /etc/profile.d/zz-prompt.sh is non-empty after entrypoint and inert for non-interactive shells // COMPLEXITY: O(1) export const renderEntrypointPrompt = (): string => String.raw`# Ensure docker-git prompt is configured for interactive shells diff --git a/packages/lib/src/core/templates-prompt.ts b/packages/lib/src/core/templates-prompt.ts index 5d890872..61358844 100644 --- a/packages/lib/src/core/templates-prompt.ts +++ b/packages/lib/src/core/templates-prompt.ts @@ -8,11 +8,11 @@ import { renderZshConfig as renderZshConfigTemplate } from "./templates-zsh.js" // FORMAT THEOREM: forall s in InteractiveShells: prompt(s) -> includes(time, path, branch|empty) // PURITY: CORE // EFFECT: n/a -// INVARIANT: script is deterministic +// INVARIANT: script is deterministic and does not touch TTY state outside interactive shells // COMPLEXITY: O(1) const dockerGitTerminalSanitizeShell = String.raw`docker_git_terminal_write_escape() { if [ -c /dev/tty ]; then - printf "\033[0m\033[?25h\033[?1l\033>\033[?1000l\033[?1002l\033[?1003l\033[?1005l\033[?1006l\033[?1015l\033[?1007l\033[?1004l\033[?2004l\033[>4;0m\033[>4m\033[ /dev/tty 2>/dev/null && return 0 + { printf "\033[0m\033[?25h\033[?1l\033>\033[?1000l\033[?1002l\033[?1003l\033[?1005l\033[?1006l\033[?1015l\033[?1007l\033[?1004l\033[?2004l\033[>4;0m\033[>4m\033[ /dev/tty; } 2>/dev/null && return 0 fi if [ -t 1 ]; then printf "\033[0m\033[?25h\033[?1l\033>\033[?1000l\033[?1002l\033[?1003l\033[?1005l\033[?1006l\033[?1015l\033[?1007l\033[?1004l\033[?2004l\033[>4;0m\033[>4m\033[ /dev/tty 2>/dev/null || stty sane < /dev/tty 2>/dev/null || true + { stty sane < /dev/tty > /dev/tty; } 2>/dev/null || { stty sane < /dev/tty; } 2>/dev/null || true elif [ -t 0 ]; then stty sane 2>/dev/null || true fi @@ -31,6 +31,11 @@ docker_git_terminal_sanitize() { }` const dockerGitPromptScript = `${dockerGitTerminalSanitizeShell} +case "$-" in + *i*) ;; + *) return 0 2>/dev/null || exit 0 ;; +esac + docker_git_branch() { git rev-parse --abbrev-ref HEAD 2>/dev/null; } docker_git_short_pwd() { local full_path @@ -96,8 +101,8 @@ docker_git_prompt_apply() { PS1="\${base}> " fi } -if [ -n "$PROMPT_COMMAND" ]; then - PROMPT_COMMAND="docker_git_prompt_apply;$PROMPT_COMMAND" +if [ -n "\${PROMPT_COMMAND-}" ]; then + PROMPT_COMMAND="docker_git_prompt_apply;\${PROMPT_COMMAND}" else PROMPT_COMMAND="docker_git_prompt_apply" fi @@ -190,7 +195,7 @@ export const renderZshConfig = (): string => renderZshConfigTemplate(dockerGitTe // FORMAT THEOREM: forall s in InteractiveShells: prompt(s) -> includes(time, path, branch|empty) // PURITY: CORE // EFFECT: n/a -// INVARIANT: only interactive shells source /etc/profile.d/zz-prompt.sh +// INVARIANT: only interactive shells mutate prompt or TTY state // COMPLEXITY: O(1) export const renderDockerfilePrompt = (): string => String.raw`# Shell prompt: show git branch for interactive sessions @@ -228,7 +233,7 @@ EOF` // FORMAT THEOREM: forall s in InteractiveShells: prompt(s) -> includes(time, path, branch|empty) // PURITY: CORE // EFFECT: n/a -// INVARIANT: /etc/profile.d/zz-prompt.sh is non-empty after entrypoint +// INVARIANT: /etc/profile.d/zz-prompt.sh is non-empty after entrypoint and inert for non-interactive shells // COMPLEXITY: O(1) export const renderEntrypointPrompt = (): string => String.raw`# Ensure docker-git prompt is configured for interactive shells diff --git a/packages/lib/tests/core/templates.test.ts b/packages/lib/tests/core/templates.test.ts index 3bd86a0b..41fc9e53 100644 --- a/packages/lib/tests/core/templates.test.ts +++ b/packages/lib/tests/core/templates.test.ts @@ -1,4 +1,7 @@ +import * as Command from "@effect/platform/Command" +import { NodeContext } from "@effect/platform-node" import { describe, expect, it } from "@effect/vitest" +import { Effect, pipe } from "effect" import { defaultTemplateConfig, type TemplateConfig } from "../../src/core/domain.js" import { renderDockerCompose } from "../../src/core/templates/docker-compose.js" @@ -6,6 +9,7 @@ import { renderDockerfile } from "../../src/core/templates/dockerfile.js" import { renderEntrypoint } from "../../src/core/templates-entrypoint.js" import { renderEntrypointDnsRepair } from "../../src/core/templates-entrypoint/dns-repair.js" import { renderEntrypointGitHooks } from "../../src/core/templates-entrypoint/git.js" +import { renderPromptScript } from "../../src/core/templates-prompt.js" const makeTemplateConfig = (overrides: Partial = {}): TemplateConfig => ({ ...defaultTemplateConfig, @@ -91,6 +95,25 @@ describe("renderDockerfile", () => { }) }) +describe("renderPromptScript", () => { + it.effect("is silent when sourced by a non-interactive shell without a controlling TTY", () => + pipe( + Command.make( + "bash", + "-lc", + String.raw`set -euo pipefail; { source <(printf '%s' "$DOCKER_GIT_PROMPT_SCRIPT"); } 2>&1; printf ok` + ), + Command.env({ DOCKER_GIT_PROMPT_SCRIPT: renderPromptScript() }), + Command.stdout("pipe"), + Command.stderr("pipe"), + Command.string, + Effect.tap((output) => Effect.sync(() => expect(output).toBe("ok"))), + Effect.asVoid, + Effect.provide(NodeContext.layer) + ) + ) +}) + describe("renderEntrypointGitHooks", () => { it("installs pre-push protection checks and a global git post-push runtime", () => { const hooks = renderEntrypointGitHooks() @@ -295,7 +318,8 @@ describe("renderEntrypoint auth bridge", () => { const entrypoint = renderAuthEntrypoint() expectContainsAll(entrypoint, [ - "stty sane < /dev/tty > /dev/tty 2>/dev/null", + "{ stty sane < /dev/tty > /dev/tty; } 2>/dev/null", + '*) return 0 2>/dev/null || exit 0 ;;', "docker_git_terminal_sanitize", "trap 'docker_git_terminal_sanitize' EXIT INT TERM", "add-zsh-hook zshexit docker_git_terminal_on_exit", From 601164a04b3c2b73f9a10fe064995d31866da76b Mon Sep 17 00:00:00 2001 From: konard Date: Tue, 12 May 2026 11:56:13 +0000 Subject: [PATCH 06/19] fix(core): normalize box image home environment --- packages/app/src/lib/core/templates/dockerfile.ts | 12 ++++++++++++ packages/lib/src/core/templates/dockerfile.ts | 12 ++++++++++++ packages/lib/tests/core/templates.test.ts | 10 ++++++++++ 3 files changed, 34 insertions(+) diff --git a/packages/app/src/lib/core/templates/dockerfile.ts b/packages/app/src/lib/core/templates/dockerfile.ts index 778322ef..661727d3 100644 --- a/packages/app/src/lib/core/templates/dockerfile.ts +++ b/packages/app/src/lib/core/templates/dockerfile.ts @@ -198,6 +198,15 @@ const renderDockerfileBun = (config: TemplateConfig): string => .filter((chunk) => chunk.trim().length > 0) .join("\n") +// CHANGE: normalize inherited box image HOME/PATH/WORKDIR after the SSH user rewrite +// WHY: box-js publishes HOME=/home/box; docker exec -u dev inherits image env, so user-relative paths must be re-bound to the mounted /home/dev volume +// QUOTE(ТЗ): "юзать готовый репозиторий" +// REF: issue-267 +// SOURCE: n/a +// FORMAT THEOREM: forall u = config.sshUser: HOME(rendered) = /home/u and WORKDIR(rendered) = /home/u +// PURITY: CORE +// INVARIANT: tilde-expanded runtime paths for the SSH user resolve inside the configured home volume +// COMPLEXITY: O(1)/O(1) const renderDockerfileUsers = (config: TemplateConfig): string => `# Create non-root user for SSH (align UID/GID with host user 1000) RUN for BASE_USER in box ubuntu; do \ @@ -216,6 +225,9 @@ RUN if id -u ${config.sshUser} >/dev/null 2>&1; then \ groupadd -g 1000 ${config.sshUser} || true; \ useradd -m -s /usr/bin/zsh -u 1000 -g 1000 -o ${config.sshUser}; \ fi +ENV HOME=/home/${config.sshUser} +ENV PATH=/usr/local/bun/bin:/home/${config.sshUser}/.deno/bin:/home/${config.sshUser}/.bun/bin:/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin +WORKDIR /home/${config.sshUser} RUN printf "%s\\n" "${config.sshUser} ALL=(ALL) NOPASSWD:ALL" > /etc/sudoers.d/${config.sshUser} \ && chmod 0440 /etc/sudoers.d/${config.sshUser} diff --git a/packages/lib/src/core/templates/dockerfile.ts b/packages/lib/src/core/templates/dockerfile.ts index 778322ef..661727d3 100644 --- a/packages/lib/src/core/templates/dockerfile.ts +++ b/packages/lib/src/core/templates/dockerfile.ts @@ -198,6 +198,15 @@ const renderDockerfileBun = (config: TemplateConfig): string => .filter((chunk) => chunk.trim().length > 0) .join("\n") +// CHANGE: normalize inherited box image HOME/PATH/WORKDIR after the SSH user rewrite +// WHY: box-js publishes HOME=/home/box; docker exec -u dev inherits image env, so user-relative paths must be re-bound to the mounted /home/dev volume +// QUOTE(ТЗ): "юзать готовый репозиторий" +// REF: issue-267 +// SOURCE: n/a +// FORMAT THEOREM: forall u = config.sshUser: HOME(rendered) = /home/u and WORKDIR(rendered) = /home/u +// PURITY: CORE +// INVARIANT: tilde-expanded runtime paths for the SSH user resolve inside the configured home volume +// COMPLEXITY: O(1)/O(1) const renderDockerfileUsers = (config: TemplateConfig): string => `# Create non-root user for SSH (align UID/GID with host user 1000) RUN for BASE_USER in box ubuntu; do \ @@ -216,6 +225,9 @@ RUN if id -u ${config.sshUser} >/dev/null 2>&1; then \ groupadd -g 1000 ${config.sshUser} || true; \ useradd -m -s /usr/bin/zsh -u 1000 -g 1000 -o ${config.sshUser}; \ fi +ENV HOME=/home/${config.sshUser} +ENV PATH=/usr/local/bun/bin:/home/${config.sshUser}/.deno/bin:/home/${config.sshUser}/.bun/bin:/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin +WORKDIR /home/${config.sshUser} RUN printf "%s\\n" "${config.sshUser} ALL=(ALL) NOPASSWD:ALL" > /etc/sudoers.d/${config.sshUser} \ && chmod 0440 /etc/sudoers.d/${config.sshUser} diff --git a/packages/lib/tests/core/templates.test.ts b/packages/lib/tests/core/templates.test.ts index 41fc9e53..4231e943 100644 --- a/packages/lib/tests/core/templates.test.ts +++ b/packages/lib/tests/core/templates.test.ts @@ -75,6 +75,16 @@ describe("renderDockerfile", () => { expect(dockerfile).toContain('usermod -l dev -d /home/dev -m -s /usr/bin/zsh "$BASE_USER" || true') }) + it("normalizes inherited box image HOME and workdir to the configured SSH user", () => { + const dockerfile = renderDockerfile(makeTemplateConfig()) + + expectContainsAll(dockerfile, [ + "ENV HOME=/home/dev", + "ENV PATH=/usr/local/bun/bin:/home/dev/.deno/bin:/home/dev/.bun/bin:/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin", + "WORKDIR /home/dev" + ]) + }) + it("installs session sync from npmjs with a local fallback", () => { const dockerfile = renderDockerfile(makeTemplateConfig()) From cb90cb18c610f6d675a6d3b8e961aa928db7173c Mon Sep 17 00:00:00 2001 From: konard Date: Tue, 12 May 2026 12:17:52 +0000 Subject: [PATCH 07/19] fix(core): rewrite inherited box home references --- .../app/src/lib/core/templates/dockerfile.ts | 16 +++++++++++----- packages/lib/src/core/templates/dockerfile.ts | 16 +++++++++++----- packages/lib/tests/core/templates.test.ts | 17 +++++++++++++++++ 3 files changed, 39 insertions(+), 10 deletions(-) diff --git a/packages/app/src/lib/core/templates/dockerfile.ts b/packages/app/src/lib/core/templates/dockerfile.ts index 661727d3..3f6e67f3 100644 --- a/packages/app/src/lib/core/templates/dockerfile.ts +++ b/packages/app/src/lib/core/templates/dockerfile.ts @@ -182,7 +182,7 @@ EOF RUN chmod +x /usr/local/bin/docker-git-playwright-mcp` const renderDockerfileBunProfile = (): string => - `RUN printf "export PATH=/usr/local/bun/bin:$PATH\\n" \ + `RUN printf "export PATH=/usr/local/bun/bin:\\$PATH\\n" \ > /etc/profile.d/bun.sh && chmod 0644 /etc/profile.d/bun.sh` const renderDockerfileBun = (config: TemplateConfig): string => @@ -198,14 +198,14 @@ const renderDockerfileBun = (config: TemplateConfig): string => .filter((chunk) => chunk.trim().length > 0) .join("\n") -// CHANGE: normalize inherited box image HOME/PATH/WORKDIR after the SSH user rewrite -// WHY: box-js publishes HOME=/home/box; docker exec -u dev inherits image env, so user-relative paths must be re-bound to the mounted /home/dev volume +// CHANGE: normalize inherited box image HOME/PATH/WORKDIR and moved login files after the SSH user rewrite +// WHY: box-js publishes HOME=/home/box and login rc files may contain absolute /home/box references; runtime user paths must be re-bound to the mounted /home/dev volume // QUOTE(ТЗ): "юзать готовый репозиторий" // REF: issue-267 // SOURCE: n/a -// FORMAT THEOREM: forall u = config.sshUser: HOME(rendered) = /home/u and WORKDIR(rendered) = /home/u +// FORMAT THEOREM: forall u = config.sshUser: HOME(rendered) = /home/u and forall p in login_rc(u): not contains(p, "/home/box") // PURITY: CORE -// INVARIANT: tilde-expanded runtime paths for the SSH user resolve inside the configured home volume +// INVARIANT: tilde-expanded and login-shell runtime paths for the SSH user resolve inside the configured home volume // COMPLEXITY: O(1)/O(1) const renderDockerfileUsers = (config: TemplateConfig): string => `# Create non-root user for SSH (align UID/GID with host user 1000) @@ -225,6 +225,12 @@ RUN if id -u ${config.sshUser} >/dev/null 2>&1; then \ groupadd -g 1000 ${config.sshUser} || true; \ useradd -m -s /usr/bin/zsh -u 1000 -g 1000 -o ${config.sshUser}; \ fi +RUN set -eu; \ + if [ -d /home/${config.sshUser} ]; then \ + find /home/${config.sshUser} -maxdepth 2 -type f \ + \\( -name ".profile" -o -name ".bash_profile" -o -name ".bashrc" -o -name ".zprofile" -o -name ".zshenv" -o -name ".zshrc" \\) \ + -exec sed -i -e "s|/home/box|/home/${config.sshUser}|g" -e "s|/home/ubuntu|/home/${config.sshUser}|g" {} +; \ + fi ENV HOME=/home/${config.sshUser} ENV PATH=/usr/local/bun/bin:/home/${config.sshUser}/.deno/bin:/home/${config.sshUser}/.bun/bin:/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin WORKDIR /home/${config.sshUser} diff --git a/packages/lib/src/core/templates/dockerfile.ts b/packages/lib/src/core/templates/dockerfile.ts index 661727d3..3f6e67f3 100644 --- a/packages/lib/src/core/templates/dockerfile.ts +++ b/packages/lib/src/core/templates/dockerfile.ts @@ -182,7 +182,7 @@ EOF RUN chmod +x /usr/local/bin/docker-git-playwright-mcp` const renderDockerfileBunProfile = (): string => - `RUN printf "export PATH=/usr/local/bun/bin:$PATH\\n" \ + `RUN printf "export PATH=/usr/local/bun/bin:\\$PATH\\n" \ > /etc/profile.d/bun.sh && chmod 0644 /etc/profile.d/bun.sh` const renderDockerfileBun = (config: TemplateConfig): string => @@ -198,14 +198,14 @@ const renderDockerfileBun = (config: TemplateConfig): string => .filter((chunk) => chunk.trim().length > 0) .join("\n") -// CHANGE: normalize inherited box image HOME/PATH/WORKDIR after the SSH user rewrite -// WHY: box-js publishes HOME=/home/box; docker exec -u dev inherits image env, so user-relative paths must be re-bound to the mounted /home/dev volume +// CHANGE: normalize inherited box image HOME/PATH/WORKDIR and moved login files after the SSH user rewrite +// WHY: box-js publishes HOME=/home/box and login rc files may contain absolute /home/box references; runtime user paths must be re-bound to the mounted /home/dev volume // QUOTE(ТЗ): "юзать готовый репозиторий" // REF: issue-267 // SOURCE: n/a -// FORMAT THEOREM: forall u = config.sshUser: HOME(rendered) = /home/u and WORKDIR(rendered) = /home/u +// FORMAT THEOREM: forall u = config.sshUser: HOME(rendered) = /home/u and forall p in login_rc(u): not contains(p, "/home/box") // PURITY: CORE -// INVARIANT: tilde-expanded runtime paths for the SSH user resolve inside the configured home volume +// INVARIANT: tilde-expanded and login-shell runtime paths for the SSH user resolve inside the configured home volume // COMPLEXITY: O(1)/O(1) const renderDockerfileUsers = (config: TemplateConfig): string => `# Create non-root user for SSH (align UID/GID with host user 1000) @@ -225,6 +225,12 @@ RUN if id -u ${config.sshUser} >/dev/null 2>&1; then \ groupadd -g 1000 ${config.sshUser} || true; \ useradd -m -s /usr/bin/zsh -u 1000 -g 1000 -o ${config.sshUser}; \ fi +RUN set -eu; \ + if [ -d /home/${config.sshUser} ]; then \ + find /home/${config.sshUser} -maxdepth 2 -type f \ + \\( -name ".profile" -o -name ".bash_profile" -o -name ".bashrc" -o -name ".zprofile" -o -name ".zshenv" -o -name ".zshrc" \\) \ + -exec sed -i -e "s|/home/box|/home/${config.sshUser}|g" -e "s|/home/ubuntu|/home/${config.sshUser}|g" {} +; \ + fi ENV HOME=/home/${config.sshUser} ENV PATH=/usr/local/bun/bin:/home/${config.sshUser}/.deno/bin:/home/${config.sshUser}/.bun/bin:/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin WORKDIR /home/${config.sshUser} diff --git a/packages/lib/tests/core/templates.test.ts b/packages/lib/tests/core/templates.test.ts index 4231e943..8c594ee9 100644 --- a/packages/lib/tests/core/templates.test.ts +++ b/packages/lib/tests/core/templates.test.ts @@ -85,6 +85,23 @@ describe("renderDockerfile", () => { ]) }) + it("rewrites inherited login rc files away from the base image home", () => { + const dockerfile = renderDockerfile(makeTemplateConfig()) + + expectContainsAll(dockerfile, [ + "find /home/dev -maxdepth 2 -type f", + '-name ".profile" -o -name ".bash_profile" -o -name ".bashrc" -o -name ".zprofile" -o -name ".zshenv" -o -name ".zshrc"', + '-exec sed -i -e "s|/home/box|/home/dev|g" -e "s|/home/ubuntu|/home/dev|g" {} +;' + ]) + }) + + it("keeps the runtime PATH extension relative to the login shell environment", () => { + const dockerfile = renderDockerfile(makeTemplateConfig()) + + expect(dockerfile).toContain('RUN printf "export PATH=/usr/local/bun/bin:\\$PATH\\n"') + expect(dockerfile).not.toContain('RUN printf "export PATH=/usr/local/bun/bin:$PATH\\n"') + }) + it("installs session sync from npmjs with a local fallback", () => { const dockerfile = renderDockerfile(makeTemplateConfig()) From bf18af78780766c71e0ded18b9305884305a66a1 Mon Sep 17 00:00:00 2001 From: konard Date: Tue, 12 May 2026 13:15:20 +0000 Subject: [PATCH 08/19] fix(core): narrow clone cache refresh refs --- .../app/src/lib/core/templates-entrypoint/tasks.ts | 12 +++++++++++- packages/lib/src/core/templates-entrypoint/tasks.ts | 12 +++++++++++- packages/lib/tests/core/templates.test.ts | 9 +++++++++ 3 files changed, 31 insertions(+), 2 deletions(-) diff --git a/packages/app/src/lib/core/templates-entrypoint/tasks.ts b/packages/app/src/lib/core/templates-entrypoint/tasks.ts index d6264455..35170425 100644 --- a/packages/app/src/lib/core/templates-entrypoint/tasks.ts +++ b/packages/app/src/lib/core/templates-entrypoint/tasks.ts @@ -117,6 +117,16 @@ const renderCloneAuthRepoUrl = (): string => AUTH_REPO_URL="$(printf "%s" "$REPO_URL" | sed "s#^https://#https://\${RESOLVED_GIT_AUTH_USER}:\${RESOLVED_GIT_AUTH_TOKEN}@#")" fi` +// CHANGE: refresh shared git mirrors with branch/tag refs only. +// WHY: GitHub exposes refs/pull/* under refs/*; fetching all refs is unbounded for public repos. +// QUOTE(TZ): n/a +// REF: issue-267 +// SOURCE: n/a +// FORMAT THEOREM: forall r in fetchedRefs: r in refs/heads/* union refs/tags/* +// PURITY: SHELL +// EFFECT: generated shell performs git fetch through the configured container user +// INVARIANT: mirror refresh excludes refs/pull/* while preserving branch/tag object reuse. +// COMPLEXITY: O(|heads| + |tags|) remote refs const renderCloneCacheInit = (config: TemplateConfig): string => ` CLONE_CACHE_ARGS="" CACHE_REPO_DIR="" @@ -135,7 +145,7 @@ 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' '+refs/*:refs/*'"; then + if ! su - ${config.sshUser} -c "GIT_TERMINAL_PROMPT=0 git --git-dir '$CACHE_REPO_DIR' fetch --progress --prune '$AUTH_REPO_URL' '+refs/heads/*:refs/heads/*' '+refs/tags/*:refs/tags/*'"; then echo "[clone-cache] mirror refresh failed for $REPO_URL" fi CLONE_CACHE_ARGS="--reference-if-able '$CACHE_REPO_DIR' --dissociate" diff --git a/packages/lib/src/core/templates-entrypoint/tasks.ts b/packages/lib/src/core/templates-entrypoint/tasks.ts index d6264455..35170425 100644 --- a/packages/lib/src/core/templates-entrypoint/tasks.ts +++ b/packages/lib/src/core/templates-entrypoint/tasks.ts @@ -117,6 +117,16 @@ const renderCloneAuthRepoUrl = (): string => AUTH_REPO_URL="$(printf "%s" "$REPO_URL" | sed "s#^https://#https://\${RESOLVED_GIT_AUTH_USER}:\${RESOLVED_GIT_AUTH_TOKEN}@#")" fi` +// CHANGE: refresh shared git mirrors with branch/tag refs only. +// WHY: GitHub exposes refs/pull/* under refs/*; fetching all refs is unbounded for public repos. +// QUOTE(TZ): n/a +// REF: issue-267 +// SOURCE: n/a +// FORMAT THEOREM: forall r in fetchedRefs: r in refs/heads/* union refs/tags/* +// PURITY: SHELL +// EFFECT: generated shell performs git fetch through the configured container user +// INVARIANT: mirror refresh excludes refs/pull/* while preserving branch/tag object reuse. +// COMPLEXITY: O(|heads| + |tags|) remote refs const renderCloneCacheInit = (config: TemplateConfig): string => ` CLONE_CACHE_ARGS="" CACHE_REPO_DIR="" @@ -135,7 +145,7 @@ 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' '+refs/*:refs/*'"; then + if ! su - ${config.sshUser} -c "GIT_TERMINAL_PROMPT=0 git --git-dir '$CACHE_REPO_DIR' fetch --progress --prune '$AUTH_REPO_URL' '+refs/heads/*:refs/heads/*' '+refs/tags/*:refs/tags/*'"; then echo "[clone-cache] mirror refresh failed for $REPO_URL" fi CLONE_CACHE_ARGS="--reference-if-able '$CACHE_REPO_DIR' --dissociate" diff --git a/packages/lib/tests/core/templates.test.ts b/packages/lib/tests/core/templates.test.ts index 8c594ee9..ecee3a23 100644 --- a/packages/lib/tests/core/templates.test.ts +++ b/packages/lib/tests/core/templates.test.ts @@ -355,6 +355,15 @@ describe("renderEntrypoint auth bridge", () => { "DOCKER_GIT_ZSH_AUTOSUGGEST=0" ]) }) + + it("refreshes clone cache mirrors without fetching GitHub pull request refs", () => { + const entrypoint = renderEntrypoint(makeTemplateConfig()) + + expect(entrypoint).toContain( + "git --git-dir '$CACHE_REPO_DIR' fetch --progress --prune '$AUTH_REPO_URL' '+refs/heads/*:refs/heads/*' '+refs/tags/*:refs/tags/*'" + ) + expect(entrypoint).not.toContain("'+refs/*:refs/*'") + }) }) describe("renderDockerCompose", () => { From 0ab5a452ea7d83d45ee2f999d67594d20b6d2793 Mon Sep 17 00:00:00 2001 From: konard Date: Tue, 12 May 2026 13:35:23 +0000 Subject: [PATCH 09/19] Revert "Initial commit with task details" This reverts commit 45f4f3dfbe1538d0cf815c5c125c6bff12a4be58. --- .gitkeep | 1 - 1 file changed, 1 deletion(-) delete mode 100644 .gitkeep diff --git a/.gitkeep b/.gitkeep deleted file mode 100644 index 6e69ac14..00000000 --- a/.gitkeep +++ /dev/null @@ -1 +0,0 @@ -# .gitkeep file auto-generated at 2026-05-12T09:49:34.468Z for PR creation at branch issue-267-99a3d92349cd for issue https://github.com/ProverCoderAI/docker-git/issues/267 \ No newline at end of file From 223ca99abf361f80d0d3951cac5cf1a92aa54f2a Mon Sep 17 00:00:00 2001 From: skulidropek <66840575+skulidropek@users.noreply.github.com> Date: Thu, 14 May 2026 09:03:01 +0000 Subject: [PATCH 10/19] fix(e2e): keep opencode temp state out of build context --- scripts/e2e/opencode-autoconnect.sh | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/scripts/e2e/opencode-autoconnect.sh b/scripts/e2e/opencode-autoconnect.sh index 2fcb067e..fb8b361b 100755 --- a/scripts/e2e/opencode-autoconnect.sh +++ b/scripts/e2e/opencode-autoconnect.sh @@ -5,7 +5,7 @@ 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}" +ROOT_BASE="${DOCKER_GIT_E2E_ROOT_BASE:-/tmp/docker-git-e2e-root}" mkdir -p "$ROOT_BASE" ROOT="$(mktemp -d "$ROOT_BASE/opencode-autoconnect.XXXXXX")" # docker-git containers may `chown -R` the `.docker-git` bind mount to UID 1000. @@ -42,6 +42,10 @@ fail() { on_error() { local line="$1" echo "e2e/opencode-autoconnect: failed at line $line" >&2 + if [[ -f "${AUTH_LOG:-}" ]]; then + echo "--- codex auth log ---" >&2 + cat "$AUTH_LOG" >&2 || true + fi docker ps -a --format 'table {{.Names}}\t{{.Status}}\t{{.Ports}}' | head -n 50 || true if dg_project_docker ps -a --format '{{.Names}}' | grep -qx "$CONTAINER_NAME" 2>/dev/null; then dg_project_docker exec -u dev "$CONTAINER_NAME" bash -lc ' From 25b7283ca86214e7c7a6790dc58f457132ddad6e Mon Sep 17 00:00:00 2001 From: skulidropek <66840575+skulidropek@users.noreply.github.com> Date: Thu, 14 May 2026 09:17:37 +0000 Subject: [PATCH 11/19] fix(e2e): keep login context temp state out of checkout --- scripts/e2e/login-context.sh | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/scripts/e2e/login-context.sh b/scripts/e2e/login-context.sh index 59ec7cd0..d3d6adf2 100755 --- a/scripts/e2e/login-context.sh +++ b/scripts/e2e/login-context.sh @@ -5,7 +5,7 @@ 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}" +ROOT_BASE="${DOCKER_GIT_E2E_ROOT_BASE:-/tmp/docker-git-e2e-root}" mkdir -p "$ROOT_BASE" ROOT="$(mktemp -d "$ROOT_BASE/login-context.XXXXXX")" # docker-git containers may `chown -R` the `.docker-git` bind mount to UID 1000. From dd273e0cd5a700d0ad4abb6dd74b3e46dd9fe784 Mon Sep 17 00:00:00 2001 From: skulidropek <66840575+skulidropek@users.noreply.github.com> Date: Thu, 14 May 2026 09:20:04 +0000 Subject: [PATCH 12/19] docs(core): document bun profile renderer --- packages/app/src/lib/core/templates/dockerfile.ts | 11 +++++++++++ packages/lib/src/core/templates/dockerfile.ts | 11 +++++++++++ 2 files changed, 22 insertions(+) diff --git a/packages/app/src/lib/core/templates/dockerfile.ts b/packages/app/src/lib/core/templates/dockerfile.ts index cc6ea649..2bbcc320 100644 --- a/packages/app/src/lib/core/templates/dockerfile.ts +++ b/packages/app/src/lib/core/templates/dockerfile.ts @@ -200,6 +200,17 @@ exec playwright-mcp --cdp-endpoint "$WS_REWRITTEN" "\${EXTRA_ARGS[@]}" "$@" EOF RUN chmod +x /usr/local/bin/docker-git-playwright-mcp` +/** + * Renders /etc/profile.d/bun.sh with a runtime-relative PATH extension. + * + * @returns Dockerfile RUN directive that prepends Bun to PATH at container runtime. + * @pure true + * @effect none; CORE template renderer only constructs a string. + * @invariant output contains /usr/local/bun/bin and escaped \$PATH, preserving shell-time expansion. + * @precondition no inputs are required. + * @postcondition returned Dockerfile command writes /etc/profile.d/bun.sh and chmods it to 0644. + * @complexity O(1) time / O(1) space. + */ const renderDockerfileBunProfile = (): string => `RUN printf "export PATH=/usr/local/bun/bin:\\$PATH\\n" \ > /etc/profile.d/bun.sh && chmod 0644 /etc/profile.d/bun.sh` diff --git a/packages/lib/src/core/templates/dockerfile.ts b/packages/lib/src/core/templates/dockerfile.ts index cc6ea649..2bbcc320 100644 --- a/packages/lib/src/core/templates/dockerfile.ts +++ b/packages/lib/src/core/templates/dockerfile.ts @@ -200,6 +200,17 @@ exec playwright-mcp --cdp-endpoint "$WS_REWRITTEN" "\${EXTRA_ARGS[@]}" "$@" EOF RUN chmod +x /usr/local/bin/docker-git-playwright-mcp` +/** + * Renders /etc/profile.d/bun.sh with a runtime-relative PATH extension. + * + * @returns Dockerfile RUN directive that prepends Bun to PATH at container runtime. + * @pure true + * @effect none; CORE template renderer only constructs a string. + * @invariant output contains /usr/local/bun/bin and escaped \$PATH, preserving shell-time expansion. + * @precondition no inputs are required. + * @postcondition returned Dockerfile command writes /etc/profile.d/bun.sh and chmods it to 0644. + * @complexity O(1) time / O(1) space. + */ const renderDockerfileBunProfile = (): string => `RUN printf "export PATH=/usr/local/bun/bin:\\$PATH\\n" \ > /etc/profile.d/bun.sh && chmod 0644 /etc/profile.d/bun.sh` From ba0a2979ae8424f46e299cbe13e9a6b0c1beb6cd Mon Sep 17 00:00:00 2001 From: skulidropek <66840575+skulidropek@users.noreply.github.com> Date: Thu, 14 May 2026 09:32:31 +0000 Subject: [PATCH 13/19] fix(api): retry skiller install in controller image --- packages/api/Dockerfile | 13 ++++++++++++- 1 file changed, 12 insertions(+), 1 deletion(-) diff --git a/packages/api/Dockerfile b/packages/api/Dockerfile index 11e02e07..27e81e93 100644 --- a/packages/api/Dockerfile +++ b/packages/api/Dockerfile @@ -74,7 +74,18 @@ RUN bun run --cwd packages/api build RUN bun scripts/skiller-apply-docker-git-patches.mjs RUN test -f third_party/skiller-desktop-skills-manager/package.json \ && cd third_party/skiller-desktop-skills-manager \ - && bun install --frozen-lockfile --silent \ + && for attempt in 1 2 3 4 5; do \ + if bun install --frozen-lockfile --silent; then \ + break; \ + fi; \ + if [ "$attempt" = "5" ]; then \ + echo "skiller bun install failed after retries" >&2; \ + exit 1; \ + fi; \ + echo "skiller bun install attempt ${attempt} failed; retrying..." >&2; \ + rm -rf /root/.bun/install/cache node_modules; \ + sleep $((attempt * 2)); \ + done \ && bun run build \ && touch out/.docker-git-browser-folder-picker.patch \ && mkdir -p out/preload \ From 6f23fe2a911aedb43fd8715143a2af5d283573e0 Mon Sep 17 00:00:00 2001 From: skulidropek <66840575+skulidropek@users.noreply.github.com> Date: Thu, 14 May 2026 09:52:14 +0000 Subject: [PATCH 14/19] fix(core): validate generated ssh user names --- .../core/command-builders-shared.ts | 30 ++++++++- .../frontend-lib/core/command-builders.ts | 4 +- .../docker-git/frontend-lib/core/domain.ts | 15 +++++ .../src/lib/core/command-builders-shared.ts | 30 ++++++++- packages/app/src/lib/core/command-builders.ts | 4 +- packages/app/src/lib/core/domain.ts | 15 +++++ .../app/src/lib/core/templates/dockerfile.ts | 20 ++++++ packages/app/src/lib/shell/config.ts | 22 ++++++- packages/app/tests/docker-git/parser.test.ts | 14 ++++ .../lib/src/core/command-builders-shared.ts | 30 ++++++++- packages/lib/src/core/command-builders.ts | 4 +- packages/lib/src/core/domain.ts | 15 +++++ packages/lib/src/core/templates/dockerfile.ts | 20 ++++++ packages/lib/src/shell/config.ts | 22 ++++++- .../lib/tests/core/command-builders.test.ts | 34 ++++++++++ packages/lib/tests/shell/config.test.ts | 64 +++++++++++++++++++ scripts/e2e/login-context.sh | 2 + 17 files changed, 332 insertions(+), 13 deletions(-) create mode 100644 packages/lib/tests/core/command-builders.test.ts create mode 100644 packages/lib/tests/shell/config.test.ts diff --git a/packages/app/src/docker-git/frontend-lib/core/command-builders-shared.ts b/packages/app/src/docker-git/frontend-lib/core/command-builders-shared.ts index 23d8af67..602a38ac 100644 --- a/packages/app/src/docker-git/frontend-lib/core/command-builders-shared.ts +++ b/packages/app/src/docker-git/frontend-lib/core/command-builders-shared.ts @@ -1,7 +1,15 @@ /* jscpd:ignore-start */ import { Either } from "effect" -import { type CreateCommand, defaultTemplateConfig, isDockerNetworkMode, isGpuMode, type ParseError } from "./domain.js" +import { + type CreateCommand, + defaultTemplateConfig, + isDockerNetworkMode, + isGpuMode, + isUnixUserName, + type ParseError, + sshUserNamePatternDescription +} from "./domain.js" const parsePort = (value: string): Either.Either => { const parsed = Number(value) @@ -24,6 +32,26 @@ const parsePort = (value: string): Either.Either => { export const parseSshPort = (value: string): Either.Either => parsePort(value) +export const parseSshUser = ( + value: string | undefined +): Either.Either => { + const candidate = value?.trim() ?? defaultTemplateConfig.sshUser + if (candidate.length === 0) { + return Either.left({ + _tag: "MissingRequiredOption", + option: "--ssh-user" + }) + } + if (!isUnixUserName(candidate)) { + return Either.left({ + _tag: "InvalidOption", + option: "--ssh-user", + reason: `expected Linux user name matching ${sshUserNamePatternDescription}` + }) + } + return Either.right(candidate) +} + export const parseDockerNetworkMode = ( value: string | undefined ): Either.Either => { diff --git a/packages/app/src/docker-git/frontend-lib/core/command-builders.ts b/packages/app/src/docker-git/frontend-lib/core/command-builders.ts index 19ca2d32..68ebebae 100644 --- a/packages/app/src/docker-git/frontend-lib/core/command-builders.ts +++ b/packages/app/src/docker-git/frontend-lib/core/command-builders.ts @@ -3,7 +3,7 @@ import { Either } from "effect" import { expandContainerHome } from "../usecases/scrap-path.js" import { resolveAutoAgentFlags } from "./auto-agent-flags.js" -import { nonEmpty, parseDockerNetworkMode, parseGpuMode, parseSshPort } from "./command-builders-shared.js" +import { nonEmpty, parseDockerNetworkMode, parseGpuMode, parseSshPort, parseSshUser } from "./command-builders-shared.js" import { type RawOptions } from "./command-options.js" import { type AgentMode, @@ -46,7 +46,7 @@ const resolveRepoBasics = (raw: RawOptions): Either.Either not contains_shell_metacharacters(u) +// PURITY: CORE +// INVARIANT: accepted user names contain only lowercase Linux account-name characters +// COMPLEXITY: O(n)/O(1) where n = |value| +export const isUnixUserName = (value: string): boolean => unixUserNamePattern.test(value) + export interface TemplateConfig { readonly containerName: string readonly serviceName: string diff --git a/packages/app/src/lib/core/command-builders-shared.ts b/packages/app/src/lib/core/command-builders-shared.ts index 23d8af67..602a38ac 100644 --- a/packages/app/src/lib/core/command-builders-shared.ts +++ b/packages/app/src/lib/core/command-builders-shared.ts @@ -1,7 +1,15 @@ /* jscpd:ignore-start */ import { Either } from "effect" -import { type CreateCommand, defaultTemplateConfig, isDockerNetworkMode, isGpuMode, type ParseError } from "./domain.js" +import { + type CreateCommand, + defaultTemplateConfig, + isDockerNetworkMode, + isGpuMode, + isUnixUserName, + type ParseError, + sshUserNamePatternDescription +} from "./domain.js" const parsePort = (value: string): Either.Either => { const parsed = Number(value) @@ -24,6 +32,26 @@ const parsePort = (value: string): Either.Either => { export const parseSshPort = (value: string): Either.Either => parsePort(value) +export const parseSshUser = ( + value: string | undefined +): Either.Either => { + const candidate = value?.trim() ?? defaultTemplateConfig.sshUser + if (candidate.length === 0) { + return Either.left({ + _tag: "MissingRequiredOption", + option: "--ssh-user" + }) + } + if (!isUnixUserName(candidate)) { + return Either.left({ + _tag: "InvalidOption", + option: "--ssh-user", + reason: `expected Linux user name matching ${sshUserNamePatternDescription}` + }) + } + return Either.right(candidate) +} + export const parseDockerNetworkMode = ( value: string | undefined ): Either.Either => { diff --git a/packages/app/src/lib/core/command-builders.ts b/packages/app/src/lib/core/command-builders.ts index 19ca2d32..68ebebae 100644 --- a/packages/app/src/lib/core/command-builders.ts +++ b/packages/app/src/lib/core/command-builders.ts @@ -3,7 +3,7 @@ import { Either } from "effect" import { expandContainerHome } from "../usecases/scrap-path.js" import { resolveAutoAgentFlags } from "./auto-agent-flags.js" -import { nonEmpty, parseDockerNetworkMode, parseGpuMode, parseSshPort } from "./command-builders-shared.js" +import { nonEmpty, parseDockerNetworkMode, parseGpuMode, parseSshPort, parseSshUser } from "./command-builders-shared.js" import { type RawOptions } from "./command-options.js" import { type AgentMode, @@ -46,7 +46,7 @@ const resolveRepoBasics = (raw: RawOptions): Either.Either not contains_shell_metacharacters(u) +// PURITY: CORE +// INVARIANT: accepted user names contain only lowercase Linux account-name characters +// COMPLEXITY: O(n)/O(1) where n = |value| +export const isUnixUserName = (value: string): boolean => unixUserNamePattern.test(value) + export interface TemplateConfig { readonly containerName: string readonly serviceName: string diff --git a/packages/app/src/lib/core/templates/dockerfile.ts b/packages/app/src/lib/core/templates/dockerfile.ts index 2bbcc320..faeb0020 100644 --- a/packages/app/src/lib/core/templates/dockerfile.ts +++ b/packages/app/src/lib/core/templates/dockerfile.ts @@ -14,6 +14,15 @@ import { renderDockerfileGitleaks, renderDockerfileOpenCode } from "./tools.js" // COMPLEXITY: O(1)/O(1) const dockerGitBaseImage = "konard/box-js:latest" +/** + * Renders the base image, root user, apt mirror, core packages, and sudo prelude. + * + * @returns Dockerfile fragment that establishes the shared project container base. + * @pure true + * @effect none; CORE template renderer only constructs a string. + * @invariant the returned fragment starts from the configured shared JS box image. + * @complexity O(1) time / O(1) space. + */ const renderDockerfilePrelude = (): string => `ARG DOCKER_GIT_BASE_IMAGE=${dockerGitBaseImage} FROM \${DOCKER_GIT_BASE_IMAGE} @@ -237,6 +246,17 @@ const renderDockerfileBun = (config: TemplateConfig): string => // PURITY: CORE // INVARIANT: tilde-expanded and login-shell runtime paths for the SSH user resolve inside the configured home volume // COMPLEXITY: O(1)/O(1) +/** + * Renders user, home, PATH, workdir, sudo, and sshd configuration for the project account. + * + * @param config - Template configuration whose sshUser is validated before rendering. + * @returns Dockerfile fragment that creates or rewrites the non-root SSH user. + * @pure true + * @effect none; CORE template renderer only constructs a string. + * @invariant rendered HOME, PATH, WORKDIR, sudoers, and AllowUsers entries target config.sshUser. + * @precondition config.sshUser satisfies the Linux user-name invariant. + * @complexity O(1) time / O(1) space. + */ const renderDockerfileUsers = (config: TemplateConfig): string => `# Create non-root user for SSH (align UID/GID with host user 1000) RUN for BASE_USER in box ubuntu; do \ diff --git a/packages/app/src/lib/shell/config.ts b/packages/app/src/lib/shell/config.ts index 380e7f93..605387eb 100644 --- a/packages/app/src/lib/shell/config.ts +++ b/packages/app/src/lib/shell/config.ts @@ -7,7 +7,12 @@ import * as Schema from "@effect/schema/Schema" import * as TreeFormatter from "@effect/schema/TreeFormatter" import { Effect, Either } from "effect" -import { defaultTemplateConfig, type ProjectConfig } from "../core/domain.js" +import { + defaultTemplateConfig, + isUnixUserName, + type ProjectConfig, + sshUserNamePatternDescription +} from "../core/domain.js" import { ConfigDecodeError, ConfigNotFoundError } from "./errors.js" import { resolveBaseDir } from "./paths.js" @@ -86,6 +91,19 @@ const normalizeLegacyProjectConfig = ( } } +const validateProjectConfig = ( + path: string, + config: ProjectConfig +): Effect.Effect => + isUnixUserName(config.template.sshUser) + ? Effect.succeed(config) + : Effect.fail( + new ConfigDecodeError({ + path, + message: `template.sshUser must match ${sshUserNamePatternDescription}` + }) + ) + const ProjectConfigInputSchema = Schema.Struct({ schemaVersion: Schema.Literal(1), template: TemplateConfigInputSchema @@ -105,7 +123,7 @@ const decodeProjectConfig = ( message: TreeFormatter.formatIssueSync(issue) }) ), - onRight: (value) => Effect.succeed(normalizeLegacyProjectConfig(value)) + onRight: (value) => validateProjectConfig(path, normalizeLegacyProjectConfig(value)) }) // CHANGE: read and decode docker-git.json from disk diff --git a/packages/app/tests/docker-git/parser.test.ts b/packages/app/tests/docker-git/parser.test.ts index 5fa97f0d..40b9dcae 100644 --- a/packages/app/tests/docker-git/parser.test.ts +++ b/packages/app/tests/docker-git/parser.test.ts @@ -65,6 +65,20 @@ describe("parseArgs", () => { } )) + it.effect("parses Linux SSH user names for create", () => + expectCreateCommand( + ["create", "--repo-url", "https://github.com/org/repo.git", "--ssh-user", "dev_user-1"], + (command) => { + expect(command.config.sshUser).toBe("dev_user-1") + } + )) + + it.effect("rejects shell metacharacters in SSH user names for create", () => + expectParseErrorTag( + ["create", "--repo-url", "https://github.com/org/repo.git", "--ssh-user", "dev;touch-pwned"], + "InvalidOption" + )) + it.effect("rejects unitless RAM absolute limit", () => expectParseErrorTag(["create", "--repo-url", "https://github.com/org/repo.git", "--ram", "4096"], "InvalidOption")) diff --git a/packages/lib/src/core/command-builders-shared.ts b/packages/lib/src/core/command-builders-shared.ts index ed4a1ed8..eeaf5c52 100644 --- a/packages/lib/src/core/command-builders-shared.ts +++ b/packages/lib/src/core/command-builders-shared.ts @@ -1,6 +1,14 @@ import { Either } from "effect" -import { type CreateCommand, defaultTemplateConfig, isDockerNetworkMode, isGpuMode, type ParseError } from "./domain.js" +import { + type CreateCommand, + defaultTemplateConfig, + isDockerNetworkMode, + isGpuMode, + isUnixUserName, + type ParseError, + sshUserNamePatternDescription +} from "./domain.js" const parsePort = (value: string): Either.Either => { const parsed = Number(value) @@ -23,6 +31,26 @@ const parsePort = (value: string): Either.Either => { export const parseSshPort = (value: string): Either.Either => parsePort(value) +export const parseSshUser = ( + value: string | undefined +): Either.Either => { + const candidate = value?.trim() ?? defaultTemplateConfig.sshUser + if (candidate.length === 0) { + return Either.left({ + _tag: "MissingRequiredOption", + option: "--ssh-user" + }) + } + if (!isUnixUserName(candidate)) { + return Either.left({ + _tag: "InvalidOption", + option: "--ssh-user", + reason: `expected Linux user name matching ${sshUserNamePatternDescription}` + }) + } + return Either.right(candidate) +} + export const parseDockerNetworkMode = ( value: string | undefined ): Either.Either => { diff --git a/packages/lib/src/core/command-builders.ts b/packages/lib/src/core/command-builders.ts index 11d2cdf8..5da6a8f0 100644 --- a/packages/lib/src/core/command-builders.ts +++ b/packages/lib/src/core/command-builders.ts @@ -3,7 +3,7 @@ import { hostname } from "node:os" import { expandContainerHome } from "../usecases/scrap-path.js" import { resolveAutoAgentFlags } from "./auto-agent-flags.js" -import { nonEmpty, parseDockerNetworkMode, parseGpuMode, parseSshPort } from "./command-builders-shared.js" +import { nonEmpty, parseDockerNetworkMode, parseGpuMode, parseSshPort, parseSshUser } from "./command-builders-shared.js" import { type RawOptions } from "./command-options.js" import { type AgentMode, @@ -46,7 +46,7 @@ const resolveRepoBasics = (raw: RawOptions): Either.Either not contains_shell_metacharacters(u) +// PURITY: CORE +// INVARIANT: accepted user names contain only lowercase Linux account-name characters +// COMPLEXITY: O(n)/O(1) where n = |value| +export const isUnixUserName = (value: string): boolean => unixUserNamePattern.test(value) + export interface TemplateConfig { readonly containerName: string readonly serviceName: string diff --git a/packages/lib/src/core/templates/dockerfile.ts b/packages/lib/src/core/templates/dockerfile.ts index 2bbcc320..faeb0020 100644 --- a/packages/lib/src/core/templates/dockerfile.ts +++ b/packages/lib/src/core/templates/dockerfile.ts @@ -14,6 +14,15 @@ import { renderDockerfileGitleaks, renderDockerfileOpenCode } from "./tools.js" // COMPLEXITY: O(1)/O(1) const dockerGitBaseImage = "konard/box-js:latest" +/** + * Renders the base image, root user, apt mirror, core packages, and sudo prelude. + * + * @returns Dockerfile fragment that establishes the shared project container base. + * @pure true + * @effect none; CORE template renderer only constructs a string. + * @invariant the returned fragment starts from the configured shared JS box image. + * @complexity O(1) time / O(1) space. + */ const renderDockerfilePrelude = (): string => `ARG DOCKER_GIT_BASE_IMAGE=${dockerGitBaseImage} FROM \${DOCKER_GIT_BASE_IMAGE} @@ -237,6 +246,17 @@ const renderDockerfileBun = (config: TemplateConfig): string => // PURITY: CORE // INVARIANT: tilde-expanded and login-shell runtime paths for the SSH user resolve inside the configured home volume // COMPLEXITY: O(1)/O(1) +/** + * Renders user, home, PATH, workdir, sudo, and sshd configuration for the project account. + * + * @param config - Template configuration whose sshUser is validated before rendering. + * @returns Dockerfile fragment that creates or rewrites the non-root SSH user. + * @pure true + * @effect none; CORE template renderer only constructs a string. + * @invariant rendered HOME, PATH, WORKDIR, sudoers, and AllowUsers entries target config.sshUser. + * @precondition config.sshUser satisfies the Linux user-name invariant. + * @complexity O(1) time / O(1) space. + */ const renderDockerfileUsers = (config: TemplateConfig): string => `# Create non-root user for SSH (align UID/GID with host user 1000) RUN for BASE_USER in box ubuntu; do \ diff --git a/packages/lib/src/shell/config.ts b/packages/lib/src/shell/config.ts index df581561..b4b2a70a 100644 --- a/packages/lib/src/shell/config.ts +++ b/packages/lib/src/shell/config.ts @@ -6,7 +6,12 @@ import * as Schema from "@effect/schema/Schema" import * as TreeFormatter from "@effect/schema/TreeFormatter" import { Effect, Either } from "effect" -import { defaultTemplateConfig, type ProjectConfig } from "../core/domain.js" +import { + defaultTemplateConfig, + isUnixUserName, + type ProjectConfig, + sshUserNamePatternDescription +} from "../core/domain.js" import { ConfigDecodeError, ConfigNotFoundError } from "./errors.js" import { resolveBaseDir } from "./paths.js" @@ -85,6 +90,19 @@ const normalizeLegacyProjectConfig = ( } } +const validateProjectConfig = ( + path: string, + config: ProjectConfig +): Effect.Effect => + isUnixUserName(config.template.sshUser) + ? Effect.succeed(config) + : Effect.fail( + new ConfigDecodeError({ + path, + message: `template.sshUser must match ${sshUserNamePatternDescription}` + }) + ) + const ProjectConfigInputSchema = Schema.Struct({ schemaVersion: Schema.Literal(1), template: TemplateConfigInputSchema @@ -104,7 +122,7 @@ const decodeProjectConfig = ( message: TreeFormatter.formatIssueSync(issue) }) ), - onRight: (value) => Effect.succeed(normalizeLegacyProjectConfig(value)) + onRight: (value) => validateProjectConfig(path, normalizeLegacyProjectConfig(value)) }) // CHANGE: read and decode docker-git.json from disk diff --git a/packages/lib/tests/core/command-builders.test.ts b/packages/lib/tests/core/command-builders.test.ts new file mode 100644 index 00000000..9797de24 --- /dev/null +++ b/packages/lib/tests/core/command-builders.test.ts @@ -0,0 +1,34 @@ +import { describe, expect, it } from "@effect/vitest" +import { Either } from "effect" + +import { buildCreateCommand } from "../../src/core/command-builders.js" + +describe("buildCreateCommand", () => { + it("rejects shell metacharacters in sshUser before template rendering", () => { + const result = buildCreateCommand({ + repoUrl: "https://github.com/org/repo.git", + sshUser: "dev;touch-pwned" + }) + + expect(Either.isLeft(result)).toBe(true) + if (Either.isLeft(result)) { + expect(result.left).toEqual({ + _tag: "InvalidOption", + option: "--ssh-user", + reason: "expected Linux user name matching ^[a-z_][a-z0-9_-]{0,31}$" + }) + } + }) + + it("accepts Linux user names used by generated project configs", () => { + const result = buildCreateCommand({ + repoUrl: "https://github.com/org/repo.git", + sshUser: "dev_user-1" + }) + + expect(Either.isRight(result)).toBe(true) + if (Either.isRight(result)) { + expect(result.right.config.sshUser).toBe("dev_user-1") + } + }) +}) diff --git a/packages/lib/tests/shell/config.test.ts b/packages/lib/tests/shell/config.test.ts new file mode 100644 index 00000000..0a19adad --- /dev/null +++ b/packages/lib/tests/shell/config.test.ts @@ -0,0 +1,64 @@ +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" + +import { defaultTemplateConfig, type TemplateConfig } from "../../src/core/domain.js" +import { readProjectConfig } from "../../src/shell/config.js" + +const makeTemplateConfig = (overrides: Partial = {}): TemplateConfig => ({ + ...defaultTemplateConfig, + containerName: "dg-test", + serviceName: "dg-test", + repoUrl: "https://github.com/org/repo.git", + targetDir: "/home/dev/org/repo", + volumeName: "dg-test-home", + authorizedKeysPath: "/tmp/authorized_keys", + codexAuthPath: "/tmp/.orch/auth/codex", + codexSharedAuthPath: "/tmp/.orch/auth/codex-shared", + codexHome: "/home/dev/.codex", + ...overrides +}) + +const withTempDir = ( + use: (tempDir: string) => Effect.Effect +): Effect.Effect => + Effect.scoped( + Effect.gen(function*(_) { + const fs = yield* _(FileSystem.FileSystem) + const tempDir = yield* _( + fs.makeTempDirectoryScoped({ + prefix: "docker-git-config-" + }) + ) + return yield* _(use(tempDir)) + }) + ) + +describe("readProjectConfig", () => { + it.effect("rejects persisted configs with unsafe sshUser values", () => + withTempDir((tempDir) => + Effect.gen(function*(_) { + const fs = yield* _(FileSystem.FileSystem) + const path = yield* _(Path.Path) + const configPath = path.join(tempDir, "docker-git.json") + const config = { + schemaVersion: 1, + template: makeTemplateConfig({ + sshUser: "dev;touch-pwned" + }) + } + + yield* _(fs.writeFileString(configPath, JSON.stringify(config))) + + const result = yield* _(Effect.either(readProjectConfig(tempDir))) + + expect(result._tag).toBe("Left") + if (result._tag === "Left") { + expect(result.left._tag).toBe("ConfigDecodeError") + expect(result.left.message).toContain("template.sshUser must match") + } + }) + ).pipe(Effect.provide(NodeContext.layer))) +}) diff --git a/scripts/e2e/login-context.sh b/scripts/e2e/login-context.sh index d3d6adf2..9beb357a 100755 --- a/scripts/e2e/login-context.sh +++ b/scripts/e2e/login-context.sh @@ -5,6 +5,8 @@ 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" +# Keep transient project state outside the checkout so docker bind-mount +# ownership changes cannot interfere with git operations during E2E runs. ROOT_BASE="${DOCKER_GIT_E2E_ROOT_BASE:-/tmp/docker-git-e2e-root}" mkdir -p "$ROOT_BASE" ROOT="$(mktemp -d "$ROOT_BASE/login-context.XXXXXX")" From 8a99997fb6b56561957b2ec711f387e160c393a3 Mon Sep 17 00:00:00 2001 From: skulidropek <66840575+skulidropek@users.noreply.github.com> Date: Thu, 14 May 2026 10:02:52 +0000 Subject: [PATCH 15/19] fix(e2e): avoid recursive home chown in image build --- packages/app/src/lib/core/templates/dockerfile.ts | 9 ++++++--- packages/lib/src/core/templates/dockerfile.ts | 9 ++++++--- packages/lib/tests/core/templates.test.ts | 9 +++++++++ 3 files changed, 21 insertions(+), 6 deletions(-) diff --git a/packages/app/src/lib/core/templates/dockerfile.ts b/packages/app/src/lib/core/templates/dockerfile.ts index faeb0020..26f50f9a 100644 --- a/packages/app/src/lib/core/templates/dockerfile.ts +++ b/packages/app/src/lib/core/templates/dockerfile.ts @@ -326,9 +326,12 @@ RUN set -eu; \ const renderDockerfileWorkspace = (config: TemplateConfig): string => `# Workspace path (supports root-level dirs like /repo) -RUN mkdir -p ${config.targetDir} \ - && chown -R 1000:1000 /home/${config.sshUser} \ - && if [ "${config.targetDir}" != "/" ]; then chown -R 1000:1000 "${config.targetDir}"; fi +RUN set -eu; \ + HOME_DIR="/home/${config.sshUser}"; \ + TARGET_DIR="${config.targetDir}"; \ + mkdir -p "$HOME_DIR" "$TARGET_DIR"; \ + chown 1000:1000 "$HOME_DIR"; \ + if [ "$TARGET_DIR" != "/" ] && [ "$TARGET_DIR" != "$HOME_DIR" ]; then chown -R 1000:1000 "$TARGET_DIR"; fi RUN mkdir -p /opt/docker-git/bootstrap/.orch/auth/codex \ /opt/docker-git/bootstrap/.orch/auth/codex-shared \ diff --git a/packages/lib/src/core/templates/dockerfile.ts b/packages/lib/src/core/templates/dockerfile.ts index faeb0020..26f50f9a 100644 --- a/packages/lib/src/core/templates/dockerfile.ts +++ b/packages/lib/src/core/templates/dockerfile.ts @@ -326,9 +326,12 @@ RUN set -eu; \ const renderDockerfileWorkspace = (config: TemplateConfig): string => `# Workspace path (supports root-level dirs like /repo) -RUN mkdir -p ${config.targetDir} \ - && chown -R 1000:1000 /home/${config.sshUser} \ - && if [ "${config.targetDir}" != "/" ]; then chown -R 1000:1000 "${config.targetDir}"; fi +RUN set -eu; \ + HOME_DIR="/home/${config.sshUser}"; \ + TARGET_DIR="${config.targetDir}"; \ + mkdir -p "$HOME_DIR" "$TARGET_DIR"; \ + chown 1000:1000 "$HOME_DIR"; \ + if [ "$TARGET_DIR" != "/" ] && [ "$TARGET_DIR" != "$HOME_DIR" ]; then chown -R 1000:1000 "$TARGET_DIR"; fi RUN mkdir -p /opt/docker-git/bootstrap/.orch/auth/codex \ /opt/docker-git/bootstrap/.orch/auth/codex-shared \ diff --git a/packages/lib/tests/core/templates.test.ts b/packages/lib/tests/core/templates.test.ts index 4a44fef8..bcbcffb5 100644 --- a/packages/lib/tests/core/templates.test.ts +++ b/packages/lib/tests/core/templates.test.ts @@ -147,6 +147,15 @@ describe("renderDockerfile", () => { expect(dockerfile).not.toContain('RUN printf "export PATH=/usr/local/bun/bin:$PATH\\n"') }) + it("does not recursively chown the inherited home directory from the base image", () => { + const dockerfile = renderDockerfile(makeTemplateConfig()) + + expect(dockerfile).toContain('chown 1000:1000 "$HOME_DIR"') + expect(dockerfile).toContain('chown -R 1000:1000 "$TARGET_DIR"') + expect(dockerfile).not.toContain("chown -R 1000:1000 /home/dev") + expect(dockerfile).not.toContain("chown -R 1000:1000 /home/${config.sshUser}") + }) + it("installs session sync from npmjs with a local fallback", () => { const dockerfile = renderDockerfile(makeTemplateConfig()) From 107800a7d7cb27bedb9e612f9cf9e087d7b88039 Mon Sep 17 00:00:00 2001 From: skulidropek <66840575+skulidropek@users.noreply.github.com> Date: Thu, 14 May 2026 10:08:06 +0000 Subject: [PATCH 16/19] test(core): update workspace dockerfile assertion --- packages/lib/tests/usecases/apply.test.ts | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/packages/lib/tests/usecases/apply.test.ts b/packages/lib/tests/usecases/apply.test.ts index 73065b34..73727c16 100644 --- a/packages/lib/tests/usecases/apply.test.ts +++ b/packages/lib/tests/usecases/apply.test.ts @@ -191,7 +191,8 @@ describe("applyProjectFiles", () => { expect(configAfter).toContain('"ramLimit": "30%"') const dockerfileAfter = yield* _(fs.readFileString(path.join(outDir, "Dockerfile"))) - expect(dockerfileAfter).toContain(`RUN mkdir -p ${updatedTargetDir}`) + expect(dockerfileAfter).toContain(`TARGET_DIR="${updatedTargetDir}"`) + expect(dockerfileAfter).toContain('mkdir -p "$HOME_DIR" "$TARGET_DIR"') const envAfter = yield* _(fs.readFileString(envProjectPath)) expect(envAfter).toContain("CUSTOM_KEY=1") From c537c6424db63a4a555d20ce986193899548d426 Mon Sep 17 00:00:00 2001 From: skulidropek <66840575+skulidropek@users.noreply.github.com> Date: Thu, 14 May 2026 12:07:59 +0000 Subject: [PATCH 17/19] fix(core): address box review invariants --- .../core/command-builders-shared.ts | 62 ++++++++++ .../frontend-lib/core/command-builders.ts | 8 +- .../src/lib/core/command-builders-shared.ts | 62 ++++++++++ packages/app/src/lib/core/command-builders.ts | 8 +- packages/app/src/lib/core/shell-literals.ts | 22 ++++ .../src/lib/core/templates-entrypoint/base.ts | 9 +- .../app/src/lib/core/templates/dockerfile.ts | 20 +++- .../lib/src/core/command-builders-shared.ts | 62 ++++++++++ packages/lib/src/core/command-builders.ts | 8 +- packages/lib/src/core/shell-literals.ts | 22 ++++ .../lib/src/core/templates-entrypoint/base.ts | 9 +- packages/lib/src/core/templates/dockerfile.ts | 20 +++- .../lib/tests/core/command-builders.test.ts | 110 ++++++++++++++++++ packages/lib/tests/core/templates.test.ts | 26 ++++- packages/lib/tests/shell/config.test.ts | 64 +++++++++- packages/lib/tests/usecases/apply.test.ts | 2 +- 16 files changed, 494 insertions(+), 20 deletions(-) create mode 100644 packages/app/src/lib/core/shell-literals.ts create mode 100644 packages/lib/src/core/shell-literals.ts diff --git a/packages/app/src/docker-git/frontend-lib/core/command-builders-shared.ts b/packages/app/src/docker-git/frontend-lib/core/command-builders-shared.ts index 602a38ac..b741c110 100644 --- a/packages/app/src/docker-git/frontend-lib/core/command-builders-shared.ts +++ b/packages/app/src/docker-git/frontend-lib/core/command-builders-shared.ts @@ -30,8 +30,32 @@ const parsePort = (value: string): Either.Either => { return Either.right(parsed) } +/** + * Parses a raw SSH port value into the valid Docker host-port range. + * + * @param value - Raw textual value for `--ssh-port`. + * @returns Either a valid integer port or a typed parse error for `--ssh-port`. + * @pure true + * @effect none; CORE parser only evaluates the provided string. + * @invariant Right(port) implies Number.isInteger(port) and 1 <= port <= 65535. + * @precondition value is untrusted CLI or config text. + * @postcondition the function returns a typed Either and never throws. + * @complexity O(1) time / O(1) space. + */ export const parseSshPort = (value: string): Either.Either => parsePort(value) +/** + * Parses and validates the SSH user used by generated Dockerfiles and entrypoints. + * + * @param value - Optional raw value for `--ssh-user`; undefined falls back to the default template user. + * @returns Either a Linux user name matching the docker-git invariant or a typed parse error. + * @pure true + * @effect none; CORE parser only trims and validates the candidate string. + * @invariant Right(user) implies user matches ^[a-z_][a-z0-9_-]{0,31}$. + * @precondition value is untrusted CLI or config text. + * @postcondition empty candidates fail as MissingRequiredOption; unsafe candidates fail as InvalidOption. + * @complexity O(n) time / O(1) space where n = |value|. + */ export const parseSshUser = ( value: string | undefined ): Either.Either => { @@ -52,6 +76,18 @@ export const parseSshUser = ( return Either.right(candidate) } +/** + * Parses the Docker network mode selector used by generated compose files. + * + * @param value - Optional raw value for `--network-mode`; undefined falls back to the template default. + * @returns Either a supported network mode or a typed parse error for `--network-mode`. + * @pure true + * @effect none; CORE parser only trims and checks a finite domain. + * @invariant Right(mode) implies mode is either "shared" or "project". + * @precondition value is untrusted CLI or config text. + * @postcondition unsupported modes fail as InvalidOption. + * @complexity O(n) time / O(1) space where n = |value|. + */ export const parseDockerNetworkMode = ( value: string | undefined ): Either.Either => { @@ -66,6 +102,18 @@ export const parseDockerNetworkMode = ( }) } +/** + * Parses the GPU mode selector used by generated compose files. + * + * @param value - Optional raw value for `--gpu`; undefined falls back to the template default. + * @returns Either a supported GPU mode or a typed parse error for `--gpu`. + * @pure true + * @effect none; CORE parser only trims and checks a finite domain. + * @invariant Right(mode) implies mode is either "none" or "all". + * @precondition value is untrusted CLI or config text. + * @postcondition unsupported modes fail as InvalidOption. + * @complexity O(n) time / O(1) space where n = |value|. + */ export const parseGpuMode = ( value: string | undefined ): Either.Either => { @@ -80,6 +128,20 @@ export const parseGpuMode = ( }) } +/** + * Parses a required non-empty string option with an optional fallback. + * + * @param option - CLI option name reported in typed parse errors. + * @param value - Optional raw value supplied by the user. + * @param fallback - Optional default used when value is undefined. + * @returns Either the trimmed non-empty candidate or a typed missing-option error. + * @pure true + * @effect none; CORE parser only trims and checks string length. + * @invariant Right(candidate) implies candidate.length > 0. + * @precondition option names the boundary field being decoded. + * @postcondition missing or empty candidates fail as MissingRequiredOption. + * @complexity O(n) time / O(1) space where n = |value|. + */ export const nonEmpty = ( option: string, value: string | undefined, diff --git a/packages/app/src/docker-git/frontend-lib/core/command-builders.ts b/packages/app/src/docker-git/frontend-lib/core/command-builders.ts index 68ebebae..44c0bd88 100644 --- a/packages/app/src/docker-git/frontend-lib/core/command-builders.ts +++ b/packages/app/src/docker-git/frontend-lib/core/command-builders.ts @@ -3,7 +3,13 @@ import { Either } from "effect" import { expandContainerHome } from "../usecases/scrap-path.js" import { resolveAutoAgentFlags } from "./auto-agent-flags.js" -import { nonEmpty, parseDockerNetworkMode, parseGpuMode, parseSshPort, parseSshUser } from "./command-builders-shared.js" +import { + nonEmpty, + parseDockerNetworkMode, + parseGpuMode, + parseSshPort, + parseSshUser +} from "./command-builders-shared.js" import { type RawOptions } from "./command-options.js" import { type AgentMode, diff --git a/packages/app/src/lib/core/command-builders-shared.ts b/packages/app/src/lib/core/command-builders-shared.ts index 602a38ac..b741c110 100644 --- a/packages/app/src/lib/core/command-builders-shared.ts +++ b/packages/app/src/lib/core/command-builders-shared.ts @@ -30,8 +30,32 @@ const parsePort = (value: string): Either.Either => { return Either.right(parsed) } +/** + * Parses a raw SSH port value into the valid Docker host-port range. + * + * @param value - Raw textual value for `--ssh-port`. + * @returns Either a valid integer port or a typed parse error for `--ssh-port`. + * @pure true + * @effect none; CORE parser only evaluates the provided string. + * @invariant Right(port) implies Number.isInteger(port) and 1 <= port <= 65535. + * @precondition value is untrusted CLI or config text. + * @postcondition the function returns a typed Either and never throws. + * @complexity O(1) time / O(1) space. + */ export const parseSshPort = (value: string): Either.Either => parsePort(value) +/** + * Parses and validates the SSH user used by generated Dockerfiles and entrypoints. + * + * @param value - Optional raw value for `--ssh-user`; undefined falls back to the default template user. + * @returns Either a Linux user name matching the docker-git invariant or a typed parse error. + * @pure true + * @effect none; CORE parser only trims and validates the candidate string. + * @invariant Right(user) implies user matches ^[a-z_][a-z0-9_-]{0,31}$. + * @precondition value is untrusted CLI or config text. + * @postcondition empty candidates fail as MissingRequiredOption; unsafe candidates fail as InvalidOption. + * @complexity O(n) time / O(1) space where n = |value|. + */ export const parseSshUser = ( value: string | undefined ): Either.Either => { @@ -52,6 +76,18 @@ export const parseSshUser = ( return Either.right(candidate) } +/** + * Parses the Docker network mode selector used by generated compose files. + * + * @param value - Optional raw value for `--network-mode`; undefined falls back to the template default. + * @returns Either a supported network mode or a typed parse error for `--network-mode`. + * @pure true + * @effect none; CORE parser only trims and checks a finite domain. + * @invariant Right(mode) implies mode is either "shared" or "project". + * @precondition value is untrusted CLI or config text. + * @postcondition unsupported modes fail as InvalidOption. + * @complexity O(n) time / O(1) space where n = |value|. + */ export const parseDockerNetworkMode = ( value: string | undefined ): Either.Either => { @@ -66,6 +102,18 @@ export const parseDockerNetworkMode = ( }) } +/** + * Parses the GPU mode selector used by generated compose files. + * + * @param value - Optional raw value for `--gpu`; undefined falls back to the template default. + * @returns Either a supported GPU mode or a typed parse error for `--gpu`. + * @pure true + * @effect none; CORE parser only trims and checks a finite domain. + * @invariant Right(mode) implies mode is either "none" or "all". + * @precondition value is untrusted CLI or config text. + * @postcondition unsupported modes fail as InvalidOption. + * @complexity O(n) time / O(1) space where n = |value|. + */ export const parseGpuMode = ( value: string | undefined ): Either.Either => { @@ -80,6 +128,20 @@ export const parseGpuMode = ( }) } +/** + * Parses a required non-empty string option with an optional fallback. + * + * @param option - CLI option name reported in typed parse errors. + * @param value - Optional raw value supplied by the user. + * @param fallback - Optional default used when value is undefined. + * @returns Either the trimmed non-empty candidate or a typed missing-option error. + * @pure true + * @effect none; CORE parser only trims and checks string length. + * @invariant Right(candidate) implies candidate.length > 0. + * @precondition option names the boundary field being decoded. + * @postcondition missing or empty candidates fail as MissingRequiredOption. + * @complexity O(n) time / O(1) space where n = |value|. + */ export const nonEmpty = ( option: string, value: string | undefined, diff --git a/packages/app/src/lib/core/command-builders.ts b/packages/app/src/lib/core/command-builders.ts index 68ebebae..44c0bd88 100644 --- a/packages/app/src/lib/core/command-builders.ts +++ b/packages/app/src/lib/core/command-builders.ts @@ -3,7 +3,13 @@ import { Either } from "effect" import { expandContainerHome } from "../usecases/scrap-path.js" import { resolveAutoAgentFlags } from "./auto-agent-flags.js" -import { nonEmpty, parseDockerNetworkMode, parseGpuMode, parseSshPort, parseSshUser } from "./command-builders-shared.js" +import { + nonEmpty, + parseDockerNetworkMode, + parseGpuMode, + parseSshPort, + parseSshUser +} from "./command-builders-shared.js" import { type RawOptions } from "./command-options.js" import { type AgentMode, diff --git a/packages/app/src/lib/core/shell-literals.ts b/packages/app/src/lib/core/shell-literals.ts new file mode 100644 index 00000000..36b42d19 --- /dev/null +++ b/packages/app/src/lib/core/shell-literals.ts @@ -0,0 +1,22 @@ +// CHANGE: centralize POSIX shell literal rendering for generated scripts +// WHY: Dockerfile RUN and entrypoint fragments share the same shell injection boundary +// QUOTE(ТЗ): n/a +// REF: PR-281-coderabbit-targetDir-shell-escape +// SOURCE: n/a +// FORMAT THEOREM: forall s: shell_eval(shellSingleQuote(s)) = s +// PURITY: CORE +// INVARIANT: single quotes in the source value are represented by the POSIX '"'"' sequence +// COMPLEXITY: O(n)/O(n) where n = |value| +/** + * Renders a POSIX single-quoted shell literal. + * + * @param value - Untrusted string that will be embedded into generated shell code. + * @returns Shell literal that evaluates back to `value`. + * @pure true + * @effect none; CORE renderer only transforms a string. + * @invariant returned literals never leave source single quotes unescaped. + * @precondition the output is consumed by POSIX-compatible shell syntax. + * @postcondition command substitution characters remain data, not executable syntax. + * @complexity O(n) time / O(n) space where n = |value|. + */ +export const shellSingleQuote = (value: string): string => `'${value.replaceAll("'", "'\"'\"'")}'` diff --git a/packages/app/src/lib/core/templates-entrypoint/base.ts b/packages/app/src/lib/core/templates-entrypoint/base.ts index 71a98942..659d38a2 100644 --- a/packages/app/src/lib/core/templates-entrypoint/base.ts +++ b/packages/app/src/lib/core/templates-entrypoint/base.ts @@ -1,6 +1,13 @@ import type { TemplateConfig } from "../domain.js" +import { shellSingleQuote } from "../shell-literals.js" import { renderInputRc } from "../templates-prompt.js" +const renderTargetDirDefault = (config: TemplateConfig): string => + `TARGET_DIR="\${TARGET_DIR:-}" +if [[ -z "$TARGET_DIR" ]]; then + TARGET_DIR=${shellSingleQuote(config.targetDir)} +fi` + export const renderEntrypointHeader = (config: TemplateConfig): string => `#!/usr/bin/env bash set -euo pipefail @@ -8,7 +15,7 @@ set -euo pipefail REPO_URL="\${REPO_URL:-}" REPO_REF="\${REPO_REF:-}" FORK_REPO_URL="\${FORK_REPO_URL:-}" -TARGET_DIR="\${TARGET_DIR:-${config.targetDir}}" +${renderTargetDirDefault(config)} if [[ "$TARGET_DIR" == "~" ]]; then TARGET_DIR="$HOME" elif [[ "$TARGET_DIR" == "~/"* ]]; then diff --git a/packages/app/src/lib/core/templates/dockerfile.ts b/packages/app/src/lib/core/templates/dockerfile.ts index 26f50f9a..1449a210 100644 --- a/packages/app/src/lib/core/templates/dockerfile.ts +++ b/packages/app/src/lib/core/templates/dockerfile.ts @@ -1,18 +1,19 @@ import type { TemplateConfig } from "../domain.js" +import { shellSingleQuote } from "../shell-literals.js" import { renderDockerfilePrompt } from "../templates-prompt.js" import { renderDockerfileGlab } from "./glab.js" import { renderDockerfileGitleaks, renderDockerfileOpenCode } from "./tools.js" // CHANGE: use the shared link-foundation JS box as the generated project base image -// WHY: issue #267 asks docker-git to reuse unified box containers instead of maintaining a raw Ubuntu workspace base; the Docker Hub JS alias is public and keeps CI pull size bounded +// WHY: issue #267 asks docker-git to reuse unified box containers instead of maintaining a raw Ubuntu workspace base; the Docker Hub JS image is public and version-pinned to avoid latest drift // QUOTE(ТЗ): "Что бы не зависить только от своих обновлений, а иметь единую инфраструктру есть смысл юзать готовый репозиторий" // REF: issue-267 // SOURCE: https://github.com/link-foundation/box#docker-hub---combo-boxes -// FORMAT THEOREM: renderDockerfile(config) -> base_image(rendered) = DOCKER_GIT_BASE_IMAGE +// FORMAT THEOREM: renderDockerfile(config) -> base_image_default(rendered) = konard/box-js:2.1.1 // PURITY: CORE // INVARIANT: the rendered Dockerfile inherits JS/runtime tooling from link-foundation/box while preserving docker-git bootstrap layers // COMPLEXITY: O(1)/O(1) -const dockerGitBaseImage = "konard/box-js:latest" +const dockerGitBaseImage = "konard/box-js:2.1.1" /** * Renders the base image, root user, apt mirror, core packages, and sudo prelude. @@ -21,12 +22,15 @@ const dockerGitBaseImage = "konard/box-js:latest" * @pure true * @effect none; CORE template renderer only constructs a string. * @invariant the returned fragment starts from the configured shared JS box image. + * @precondition docker-git generated entrypoint remains the container entrypoint. + * @postcondition the fragment keeps root available for setup and runtime bootstrap. * @complexity O(1) time / O(1) space. */ const renderDockerfilePrelude = (): string => `ARG DOCKER_GIT_BASE_IMAGE=${dockerGitBaseImage} FROM \${DOCKER_GIT_BASE_IMAGE} +#checkov:skip=CKV_DOCKER_8: docker-git entrypoint must start as root to prepare SSH/auth/bootstrap and run sshd USER root ARG UBUNTU_APT_MIRROR= ENV DEBIAN_FRONTEND=noninteractive @@ -255,6 +259,7 @@ const renderDockerfileBun = (config: TemplateConfig): string => * @effect none; CORE template renderer only constructs a string. * @invariant rendered HOME, PATH, WORKDIR, sudoers, and AllowUsers entries target config.sshUser. * @precondition config.sshUser satisfies the Linux user-name invariant. + * @postcondition inherited box or ubuntu accounts resolve to config.sshUser when present. * @complexity O(1) time / O(1) space. */ const renderDockerfileUsers = (config: TemplateConfig): string => @@ -324,11 +329,13 @@ RUN set -eu; \ docker-git-session-sync --help >/dev/null; \ fi` -const renderDockerfileWorkspace = (config: TemplateConfig): string => - `# Workspace path (supports root-level dirs like /repo) +const renderDockerfileWorkspace = (config: TemplateConfig): string => { + const targetDirLiteral = shellSingleQuote(config.targetDir) + + return `# Workspace path (supports root-level dirs like /repo) RUN set -eu; \ HOME_DIR="/home/${config.sshUser}"; \ - TARGET_DIR="${config.targetDir}"; \ + TARGET_DIR=${targetDirLiteral}; \ mkdir -p "$HOME_DIR" "$TARGET_DIR"; \ chown 1000:1000 "$HOME_DIR"; \ if [ "$TARGET_DIR" != "/" ] && [ "$TARGET_DIR" != "$HOME_DIR" ]; then chown -R 1000:1000 "$TARGET_DIR"; fi @@ -346,6 +353,7 @@ RUN sed -i 's/\\r$//' /entrypoint.sh && chmod +x /entrypoint.sh EXPOSE 22 ENTRYPOINT ["/entrypoint.sh"]` +} export const renderDockerfile = (config: TemplateConfig): string => [ diff --git a/packages/lib/src/core/command-builders-shared.ts b/packages/lib/src/core/command-builders-shared.ts index eeaf5c52..56209281 100644 --- a/packages/lib/src/core/command-builders-shared.ts +++ b/packages/lib/src/core/command-builders-shared.ts @@ -29,8 +29,32 @@ const parsePort = (value: string): Either.Either => { return Either.right(parsed) } +/** + * Parses a raw SSH port value into the valid Docker host-port range. + * + * @param value - Raw textual value for `--ssh-port`. + * @returns Either a valid integer port or a typed parse error for `--ssh-port`. + * @pure true + * @effect none; CORE parser only evaluates the provided string. + * @invariant Right(port) implies Number.isInteger(port) and 1 <= port <= 65535. + * @precondition value is untrusted CLI or config text. + * @postcondition the function returns a typed Either and never throws. + * @complexity O(1) time / O(1) space. + */ export const parseSshPort = (value: string): Either.Either => parsePort(value) +/** + * Parses and validates the SSH user used by generated Dockerfiles and entrypoints. + * + * @param value - Optional raw value for `--ssh-user`; undefined falls back to the default template user. + * @returns Either a Linux user name matching the docker-git invariant or a typed parse error. + * @pure true + * @effect none; CORE parser only trims and validates the candidate string. + * @invariant Right(user) implies user matches ^[a-z_][a-z0-9_-]{0,31}$. + * @precondition value is untrusted CLI or config text. + * @postcondition empty candidates fail as MissingRequiredOption; unsafe candidates fail as InvalidOption. + * @complexity O(n) time / O(1) space where n = |value|. + */ export const parseSshUser = ( value: string | undefined ): Either.Either => { @@ -51,6 +75,18 @@ export const parseSshUser = ( return Either.right(candidate) } +/** + * Parses the Docker network mode selector used by generated compose files. + * + * @param value - Optional raw value for `--network-mode`; undefined falls back to the template default. + * @returns Either a supported network mode or a typed parse error for `--network-mode`. + * @pure true + * @effect none; CORE parser only trims and checks a finite domain. + * @invariant Right(mode) implies mode is either "shared" or "project". + * @precondition value is untrusted CLI or config text. + * @postcondition unsupported modes fail as InvalidOption. + * @complexity O(n) time / O(1) space where n = |value|. + */ export const parseDockerNetworkMode = ( value: string | undefined ): Either.Either => { @@ -65,6 +101,18 @@ export const parseDockerNetworkMode = ( }) } +/** + * Parses the GPU mode selector used by generated compose files. + * + * @param value - Optional raw value for `--gpu`; undefined falls back to the template default. + * @returns Either a supported GPU mode or a typed parse error for `--gpu`. + * @pure true + * @effect none; CORE parser only trims and checks a finite domain. + * @invariant Right(mode) implies mode is either "none" or "all". + * @precondition value is untrusted CLI or config text. + * @postcondition unsupported modes fail as InvalidOption. + * @complexity O(n) time / O(1) space where n = |value|. + */ export const parseGpuMode = ( value: string | undefined ): Either.Either => { @@ -79,6 +127,20 @@ export const parseGpuMode = ( }) } +/** + * Parses a required non-empty string option with an optional fallback. + * + * @param option - CLI option name reported in typed parse errors. + * @param value - Optional raw value supplied by the user. + * @param fallback - Optional default used when value is undefined. + * @returns Either the trimmed non-empty candidate or a typed missing-option error. + * @pure true + * @effect none; CORE parser only trims and checks string length. + * @invariant Right(candidate) implies candidate.length > 0. + * @precondition option names the boundary field being decoded. + * @postcondition missing or empty candidates fail as MissingRequiredOption. + * @complexity O(n) time / O(1) space where n = |value|. + */ export const nonEmpty = ( option: string, value: string | undefined, diff --git a/packages/lib/src/core/command-builders.ts b/packages/lib/src/core/command-builders.ts index 5da6a8f0..a875ebfa 100644 --- a/packages/lib/src/core/command-builders.ts +++ b/packages/lib/src/core/command-builders.ts @@ -3,7 +3,13 @@ import { hostname } from "node:os" import { expandContainerHome } from "../usecases/scrap-path.js" import { resolveAutoAgentFlags } from "./auto-agent-flags.js" -import { nonEmpty, parseDockerNetworkMode, parseGpuMode, parseSshPort, parseSshUser } from "./command-builders-shared.js" +import { + nonEmpty, + parseDockerNetworkMode, + parseGpuMode, + parseSshPort, + parseSshUser +} from "./command-builders-shared.js" import { type RawOptions } from "./command-options.js" import { type AgentMode, diff --git a/packages/lib/src/core/shell-literals.ts b/packages/lib/src/core/shell-literals.ts new file mode 100644 index 00000000..36b42d19 --- /dev/null +++ b/packages/lib/src/core/shell-literals.ts @@ -0,0 +1,22 @@ +// CHANGE: centralize POSIX shell literal rendering for generated scripts +// WHY: Dockerfile RUN and entrypoint fragments share the same shell injection boundary +// QUOTE(ТЗ): n/a +// REF: PR-281-coderabbit-targetDir-shell-escape +// SOURCE: n/a +// FORMAT THEOREM: forall s: shell_eval(shellSingleQuote(s)) = s +// PURITY: CORE +// INVARIANT: single quotes in the source value are represented by the POSIX '"'"' sequence +// COMPLEXITY: O(n)/O(n) where n = |value| +/** + * Renders a POSIX single-quoted shell literal. + * + * @param value - Untrusted string that will be embedded into generated shell code. + * @returns Shell literal that evaluates back to `value`. + * @pure true + * @effect none; CORE renderer only transforms a string. + * @invariant returned literals never leave source single quotes unescaped. + * @precondition the output is consumed by POSIX-compatible shell syntax. + * @postcondition command substitution characters remain data, not executable syntax. + * @complexity O(n) time / O(n) space where n = |value|. + */ +export const shellSingleQuote = (value: string): string => `'${value.replaceAll("'", "'\"'\"'")}'` diff --git a/packages/lib/src/core/templates-entrypoint/base.ts b/packages/lib/src/core/templates-entrypoint/base.ts index 71a98942..659d38a2 100644 --- a/packages/lib/src/core/templates-entrypoint/base.ts +++ b/packages/lib/src/core/templates-entrypoint/base.ts @@ -1,6 +1,13 @@ import type { TemplateConfig } from "../domain.js" +import { shellSingleQuote } from "../shell-literals.js" import { renderInputRc } from "../templates-prompt.js" +const renderTargetDirDefault = (config: TemplateConfig): string => + `TARGET_DIR="\${TARGET_DIR:-}" +if [[ -z "$TARGET_DIR" ]]; then + TARGET_DIR=${shellSingleQuote(config.targetDir)} +fi` + export const renderEntrypointHeader = (config: TemplateConfig): string => `#!/usr/bin/env bash set -euo pipefail @@ -8,7 +15,7 @@ set -euo pipefail REPO_URL="\${REPO_URL:-}" REPO_REF="\${REPO_REF:-}" FORK_REPO_URL="\${FORK_REPO_URL:-}" -TARGET_DIR="\${TARGET_DIR:-${config.targetDir}}" +${renderTargetDirDefault(config)} if [[ "$TARGET_DIR" == "~" ]]; then TARGET_DIR="$HOME" elif [[ "$TARGET_DIR" == "~/"* ]]; then diff --git a/packages/lib/src/core/templates/dockerfile.ts b/packages/lib/src/core/templates/dockerfile.ts index 26f50f9a..1449a210 100644 --- a/packages/lib/src/core/templates/dockerfile.ts +++ b/packages/lib/src/core/templates/dockerfile.ts @@ -1,18 +1,19 @@ import type { TemplateConfig } from "../domain.js" +import { shellSingleQuote } from "../shell-literals.js" import { renderDockerfilePrompt } from "../templates-prompt.js" import { renderDockerfileGlab } from "./glab.js" import { renderDockerfileGitleaks, renderDockerfileOpenCode } from "./tools.js" // CHANGE: use the shared link-foundation JS box as the generated project base image -// WHY: issue #267 asks docker-git to reuse unified box containers instead of maintaining a raw Ubuntu workspace base; the Docker Hub JS alias is public and keeps CI pull size bounded +// WHY: issue #267 asks docker-git to reuse unified box containers instead of maintaining a raw Ubuntu workspace base; the Docker Hub JS image is public and version-pinned to avoid latest drift // QUOTE(ТЗ): "Что бы не зависить только от своих обновлений, а иметь единую инфраструктру есть смысл юзать готовый репозиторий" // REF: issue-267 // SOURCE: https://github.com/link-foundation/box#docker-hub---combo-boxes -// FORMAT THEOREM: renderDockerfile(config) -> base_image(rendered) = DOCKER_GIT_BASE_IMAGE +// FORMAT THEOREM: renderDockerfile(config) -> base_image_default(rendered) = konard/box-js:2.1.1 // PURITY: CORE // INVARIANT: the rendered Dockerfile inherits JS/runtime tooling from link-foundation/box while preserving docker-git bootstrap layers // COMPLEXITY: O(1)/O(1) -const dockerGitBaseImage = "konard/box-js:latest" +const dockerGitBaseImage = "konard/box-js:2.1.1" /** * Renders the base image, root user, apt mirror, core packages, and sudo prelude. @@ -21,12 +22,15 @@ const dockerGitBaseImage = "konard/box-js:latest" * @pure true * @effect none; CORE template renderer only constructs a string. * @invariant the returned fragment starts from the configured shared JS box image. + * @precondition docker-git generated entrypoint remains the container entrypoint. + * @postcondition the fragment keeps root available for setup and runtime bootstrap. * @complexity O(1) time / O(1) space. */ const renderDockerfilePrelude = (): string => `ARG DOCKER_GIT_BASE_IMAGE=${dockerGitBaseImage} FROM \${DOCKER_GIT_BASE_IMAGE} +#checkov:skip=CKV_DOCKER_8: docker-git entrypoint must start as root to prepare SSH/auth/bootstrap and run sshd USER root ARG UBUNTU_APT_MIRROR= ENV DEBIAN_FRONTEND=noninteractive @@ -255,6 +259,7 @@ const renderDockerfileBun = (config: TemplateConfig): string => * @effect none; CORE template renderer only constructs a string. * @invariant rendered HOME, PATH, WORKDIR, sudoers, and AllowUsers entries target config.sshUser. * @precondition config.sshUser satisfies the Linux user-name invariant. + * @postcondition inherited box or ubuntu accounts resolve to config.sshUser when present. * @complexity O(1) time / O(1) space. */ const renderDockerfileUsers = (config: TemplateConfig): string => @@ -324,11 +329,13 @@ RUN set -eu; \ docker-git-session-sync --help >/dev/null; \ fi` -const renderDockerfileWorkspace = (config: TemplateConfig): string => - `# Workspace path (supports root-level dirs like /repo) +const renderDockerfileWorkspace = (config: TemplateConfig): string => { + const targetDirLiteral = shellSingleQuote(config.targetDir) + + return `# Workspace path (supports root-level dirs like /repo) RUN set -eu; \ HOME_DIR="/home/${config.sshUser}"; \ - TARGET_DIR="${config.targetDir}"; \ + TARGET_DIR=${targetDirLiteral}; \ mkdir -p "$HOME_DIR" "$TARGET_DIR"; \ chown 1000:1000 "$HOME_DIR"; \ if [ "$TARGET_DIR" != "/" ] && [ "$TARGET_DIR" != "$HOME_DIR" ]; then chown -R 1000:1000 "$TARGET_DIR"; fi @@ -346,6 +353,7 @@ RUN sed -i 's/\\r$//' /entrypoint.sh && chmod +x /entrypoint.sh EXPOSE 22 ENTRYPOINT ["/entrypoint.sh"]` +} export const renderDockerfile = (config: TemplateConfig): string => [ diff --git a/packages/lib/tests/core/command-builders.test.ts b/packages/lib/tests/core/command-builders.test.ts index 9797de24..3cf91019 100644 --- a/packages/lib/tests/core/command-builders.test.ts +++ b/packages/lib/tests/core/command-builders.test.ts @@ -1,8 +1,41 @@ import { describe, expect, it } from "@effect/vitest" import { Either } from "effect" +import * as fc from "fast-check" import { buildCreateCommand } from "../../src/core/command-builders.js" +const validFirstChar = "abcdefghijklmnopqrstuvwxyz_".split("") +const validTailChar = "abcdefghijklmnopqrstuvwxyz0123456789_-".split("") + +const validSshUserArbitrary = fc + .tuple( + fc.constantFrom(...validFirstChar), + fc.array(fc.constantFrom(...validTailChar), { minLength: 0, maxLength: 31 }) + ) + .map(([first, tail]) => `${first}${tail.join("")}`) + +const invalidNonEmptySshUserArbitrary = fc.oneof( + fc.constantFrom( + "1dev", + "-dev", + "Dev", + "dev user", + "dev;touch-pwned", + "dev$(touch-pwned)", + "dev`touch-pwned`", + "dev/foo", + "dev.foo", + "dev:foo", + "dev\nfoo" + ), + fc + .tuple( + fc.constantFrom(...validFirstChar), + fc.array(fc.constantFrom(...validTailChar), { minLength: 32, maxLength: 64 }) + ) + .map(([first, tail]) => `${first}${tail.join("")}`) +) + describe("buildCreateCommand", () => { it("rejects shell metacharacters in sshUser before template rendering", () => { const result = buildCreateCommand({ @@ -31,4 +64,81 @@ describe("buildCreateCommand", () => { expect(result.right.config.sshUser).toBe("dev_user-1") } }) + + it("preserves generated Linux user names matching the sshUser invariant", () => { + fc.assert( + fc.property(validSshUserArbitrary, (sshUser) => { + const result = buildCreateCommand({ + repoUrl: "https://github.com/org/repo.git", + sshUser + }) + + expect(Either.isRight(result)).toBe(true) + if (Either.isRight(result)) { + expect(result.right.config.sshUser).toBe(sshUser) + } + }) + ) + }) + + it("rejects generated non-empty unsafe sshUser values as InvalidOption", () => { + fc.assert( + fc.property(invalidNonEmptySshUserArbitrary, (sshUser) => { + const result = buildCreateCommand({ + repoUrl: "https://github.com/org/repo.git", + sshUser + }) + + expect(Either.isLeft(result)).toBe(true) + if (Either.isLeft(result)) { + expect(result.left).toEqual({ + _tag: "InvalidOption", + option: "--ssh-user", + reason: "expected Linux user name matching ^[a-z_][a-z0-9_-]{0,31}$" + }) + } + }) + ) + }) + + it("covers sshUser regex boundary lengths and first-character constraints", () => { + const validLength32 = `_${"a".repeat(31)}` + const invalidLength33 = `_${"a".repeat(32)}` + + const accepted = buildCreateCommand({ + repoUrl: "https://github.com/org/repo.git", + sshUser: validLength32 + }) + const empty = buildCreateCommand({ + repoUrl: "https://github.com/org/repo.git", + sshUser: "" + }) + const tooLong = buildCreateCommand({ + repoUrl: "https://github.com/org/repo.git", + sshUser: invalidLength33 + }) + const invalidFirstChar = buildCreateCommand({ + repoUrl: "https://github.com/org/repo.git", + sshUser: "1dev" + }) + + expect(Either.isRight(accepted)).toBe(true) + if (Either.isRight(accepted)) { + expect(accepted.right.config.sshUser).toBe(validLength32) + } + expect(Either.isLeft(empty)).toBe(true) + if (Either.isLeft(empty)) { + expect(empty.left).toEqual({ + _tag: "MissingRequiredOption", + option: "--ssh-user" + }) + } + for (const result of [tooLong, invalidFirstChar]) { + expect(Either.isLeft(result)).toBe(true) + if (Either.isLeft(result)) { + expect(result.left._tag).toBe("InvalidOption") + expect(result.left.option).toBe("--ssh-user") + } + } + }) }) diff --git a/packages/lib/tests/core/templates.test.ts b/packages/lib/tests/core/templates.test.ts index bcbcffb5..2837ee91 100644 --- a/packages/lib/tests/core/templates.test.ts +++ b/packages/lib/tests/core/templates.test.ts @@ -85,8 +85,11 @@ describe("renderDockerfile", () => { it("uses the shared JS box image as the project container base", () => { const dockerfile = renderDockerfile(makeTemplateConfig()) - expect(dockerfile).toContain("ARG DOCKER_GIT_BASE_IMAGE=konard/box-js:latest") + expect(dockerfile).toContain("ARG DOCKER_GIT_BASE_IMAGE=konard/box-js:2.1.1") expect(dockerfile).toContain("FROM ${DOCKER_GIT_BASE_IMAGE}") + expect(dockerfile).toContain( + "#checkov:skip=CKV_DOCKER_8: docker-git entrypoint must start as root to prepare SSH/auth/bootstrap and run sshd" + ) expect(dockerfile).toContain("USER root") expect(dockerfile).not.toContain("FROM ubuntu:24.04") }) @@ -156,6 +159,16 @@ describe("renderDockerfile", () => { expect(dockerfile).not.toContain("chown -R 1000:1000 /home/${config.sshUser}") }) + it("renders targetDir as a single-quoted shell literal in workspace setup", () => { + const config = makeTemplateConfig({ + targetDir: "/home/dev/org/repo'$(touch-pwned)`echo-pwned`" + }) + const dockerfile = renderDockerfile(config) + + expect(dockerfile).toContain("TARGET_DIR='/home/dev/org/repo'\"'\"'$(touch-pwned)`echo-pwned`';") + expect(dockerfile).not.toContain("TARGET_DIR=\"/home/dev/org/repo'$(touch-pwned)`echo-pwned`\"") + }) + it("installs session sync from npmjs with a local fallback", () => { const dockerfile = renderDockerfile(makeTemplateConfig()) @@ -220,6 +233,17 @@ describe("renderPromptScript", () => { }) describe("renderEntrypoint clone cache", () => { + it("renders the default targetDir as a shell literal without evaluating substitutions", () => { + const config = makeTemplateConfig({ + targetDir: "/home/dev/org/repo'$(touch-pwned)`echo-pwned`" + }) + const entrypoint = renderEntrypoint(config) + + expect(entrypoint).toContain('TARGET_DIR="${TARGET_DIR:-}"') + expect(entrypoint).toContain("TARGET_DIR='/home/dev/org/repo'\"'\"'$(touch-pwned)`echo-pwned`'") + expect(entrypoint).not.toContain('TARGET_DIR="${TARGET_DIR:-/home/dev/org/repo') + }) + it("refreshes mirrors without broad remote refs", () => { const entrypoint = renderEntrypoint(makeTemplateConfig()) diff --git a/packages/lib/tests/shell/config.test.ts b/packages/lib/tests/shell/config.test.ts index 0a19adad..b0b3ed74 100644 --- a/packages/lib/tests/shell/config.test.ts +++ b/packages/lib/tests/shell/config.test.ts @@ -3,8 +3,9 @@ import * as Path from "@effect/platform/Path" import { NodeContext } from "@effect/platform-node" import { describe, expect, it } from "@effect/vitest" import { Effect } from "effect" +import * as fc from "fast-check" -import { defaultTemplateConfig, type TemplateConfig } from "../../src/core/domain.js" +import { defaultTemplateConfig, isUnixUserName, type TemplateConfig } from "../../src/core/domain.js" import { readProjectConfig } from "../../src/shell/config.js" const makeTemplateConfig = (overrides: Partial = {}): TemplateConfig => ({ @@ -36,6 +37,25 @@ const withTempDir = ( }) ) +const invalidPersistedSshUserArbitrary = fc.oneof( + fc.constantFrom( + "", + " ", + "1dev", + "-dev", + "Dev", + "dev user", + "dev;touch-pwned", + "dev$(touch-pwned)", + "dev`touch-pwned`", + "dev/foo", + "dev.foo", + "dev:foo", + "dev\nfoo" + ), + fc.string({ minLength: 33, maxLength: 96 }).filter((value) => !isUnixUserName(value)) +) + describe("readProjectConfig", () => { it.effect("rejects persisted configs with unsafe sshUser values", () => withTempDir((tempDir) => @@ -61,4 +81,46 @@ describe("readProjectConfig", () => { } }) ).pipe(Effect.provide(NodeContext.layer))) + + it("generates only invalid persisted sshUser candidates", () => { + fc.assert( + fc.property(invalidPersistedSshUserArbitrary, (sshUser) => { + expect(isUnixUserName(sshUser)).toBe(false) + }) + ) + }) + + it.effect("rejects generated persisted configs with unsafe sshUser values", () => + Effect.tryPromise({ + catch: (error) => error, + try: () => + fc.assert( + fc.asyncProperty(invalidPersistedSshUserArbitrary, (sshUser) => + Effect.runPromise( + withTempDir((tempDir) => + Effect.gen(function*(_) { + const fs = yield* _(FileSystem.FileSystem) + const path = yield* _(Path.Path) + const configPath = path.join(tempDir, "docker-git.json") + const config = { + schemaVersion: 1, + template: makeTemplateConfig({ sshUser }) + } + + yield* _(fs.writeFileString(configPath, JSON.stringify(config))) + + const result = yield* _(Effect.either(readProjectConfig(tempDir))) + + expect(result._tag).toBe("Left") + if (result._tag === "Left") { + expect(result.left._tag).toBe("ConfigDecodeError") + expect(result.left.message).toContain("template.sshUser must match") + } + }) + ).pipe(Effect.provide(NodeContext.layer)) + ) + ), + { numRuns: 50 } + ) + })) }) diff --git a/packages/lib/tests/usecases/apply.test.ts b/packages/lib/tests/usecases/apply.test.ts index 73727c16..9e8be4f1 100644 --- a/packages/lib/tests/usecases/apply.test.ts +++ b/packages/lib/tests/usecases/apply.test.ts @@ -191,7 +191,7 @@ describe("applyProjectFiles", () => { expect(configAfter).toContain('"ramLimit": "30%"') const dockerfileAfter = yield* _(fs.readFileString(path.join(outDir, "Dockerfile"))) - expect(dockerfileAfter).toContain(`TARGET_DIR="${updatedTargetDir}"`) + expect(dockerfileAfter).toContain(`TARGET_DIR='${updatedTargetDir}'`) expect(dockerfileAfter).toContain('mkdir -p "$HOME_DIR" "$TARGET_DIR"') const envAfter = yield* _(fs.readFileString(envProjectPath)) From ffa9f804c61768c23ba4bbb0e70a716851b90931 Mon Sep 17 00:00:00 2001 From: skulidropek <66840575+skulidropek@users.noreply.github.com> Date: Thu, 14 May 2026 12:24:34 +0000 Subject: [PATCH 18/19] ci(e2e): free disk before docker builds --- .github/actions/free-docker-disk/action.yml | 25 +++++++++++++++++++++ .github/workflows/check.yml | 12 ++++++++++ 2 files changed, 37 insertions(+) create mode 100644 .github/actions/free-docker-disk/action.yml diff --git a/.github/actions/free-docker-disk/action.yml b/.github/actions/free-docker-disk/action.yml new file mode 100644 index 00000000..b466eef5 --- /dev/null +++ b/.github/actions/free-docker-disk/action.yml @@ -0,0 +1,25 @@ +name: Free Docker disk +description: Remove unused hosted-runner toolchains before Docker image E2E builds. + +runs: + using: composite + steps: + - name: Free disk for Docker builds + shell: bash + run: | + set -euxo pipefail + + df -h + docker system df || true + + sudo rm -rf \ + /opt/ghc \ + /opt/hostedtoolcache/CodeQL \ + /usr/local/.ghcup \ + /usr/local/lib/android \ + /usr/share/dotnet + + docker system prune -af --volumes || true + + df -h + docker system df || true diff --git a/.github/workflows/check.yml b/.github/workflows/check.yml index 40f2d29c..3011af06 100644 --- a/.github/workflows/check.yml +++ b/.github/workflows/check.yml @@ -159,6 +159,8 @@ jobs: submodules: true - name: Install dependencies uses: ./.github/actions/setup + - name: Free Docker disk + uses: ./.github/actions/free-docker-disk - name: Docker info run: docker version && docker compose version - name: Browser command startup @@ -174,6 +176,8 @@ jobs: submodules: true - name: Install dependencies uses: ./.github/actions/setup + - name: Free Docker disk + uses: ./.github/actions/free-docker-disk - name: Docker info run: docker version && docker compose version - name: OpenCode autoconnect @@ -189,6 +193,8 @@ jobs: submodules: true - name: Install dependencies uses: ./.github/actions/setup + - name: Free Docker disk + uses: ./.github/actions/free-docker-disk - name: Docker info run: docker version && docker compose version - name: Clone cache reuse @@ -204,6 +210,8 @@ jobs: submodules: true - name: Install dependencies uses: ./.github/actions/setup + - name: Free Docker disk + uses: ./.github/actions/free-docker-disk - name: Docker info run: docker version && docker compose version - name: Login context notice @@ -219,6 +227,8 @@ jobs: submodules: true - name: Install dependencies uses: ./.github/actions/setup + - name: Free Docker disk + uses: ./.github/actions/free-docker-disk - name: Docker info run: docker version && docker compose version - name: Runtime volumes + host SSH CLI @@ -234,6 +244,8 @@ jobs: submodules: true - name: Install dependencies uses: ./.github/actions/setup + - name: Free Docker disk + uses: ./.github/actions/free-docker-disk - name: Docker info run: docker version && docker compose version - name: Clone auto-open SSH From 578de65abb6ef0a567fdc87073dec6cc99b23675 Mon Sep 17 00:00:00 2001 From: skulidropek <66840575+skulidropek@users.noreply.github.com> Date: Thu, 14 May 2026 12:35:58 +0000 Subject: [PATCH 19/19] test(core): assert rendered chown path --- packages/lib/tests/core/templates.test.ts | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/packages/lib/tests/core/templates.test.ts b/packages/lib/tests/core/templates.test.ts index 2837ee91..04dcf4b4 100644 --- a/packages/lib/tests/core/templates.test.ts +++ b/packages/lib/tests/core/templates.test.ts @@ -151,12 +151,13 @@ describe("renderDockerfile", () => { }) it("does not recursively chown the inherited home directory from the base image", () => { - const dockerfile = renderDockerfile(makeTemplateConfig()) + const config = makeTemplateConfig() + const dockerfile = renderDockerfile(config) expect(dockerfile).toContain('chown 1000:1000 "$HOME_DIR"') expect(dockerfile).toContain('chown -R 1000:1000 "$TARGET_DIR"') expect(dockerfile).not.toContain("chown -R 1000:1000 /home/dev") - expect(dockerfile).not.toContain("chown -R 1000:1000 /home/${config.sshUser}") + expect(dockerfile).not.toContain(`chown -R 1000:1000 /home/${config.sshUser}`) }) it("renders targetDir as a single-quoted shell literal in workspace setup", () => {