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 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 \ 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..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 @@ -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) @@ -22,8 +30,64 @@ 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 => { + 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) +} + +/** + * 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 => { @@ -38,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 => { @@ -52,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 19ca2d32..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 } 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 +52,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..b741c110 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) @@ -22,8 +30,64 @@ 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 => { + 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) +} + +/** + * 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 => { @@ -38,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 => { @@ -52,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 19ca2d32..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 } 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 +52,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/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-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/app/src/lib/core/templates/dockerfile.ts b/packages/app/src/lib/core/templates/dockerfile.ts index 0071eedd..693715fb 100644 --- a/packages/app/src/lib/core/templates/dockerfile.ts +++ b/packages/app/src/lib/core/templates/dockerfile.ts @@ -1,11 +1,37 @@ 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 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_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:2.1.1" + +/** + * 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. + * @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 => - `FROM ubuntu:24.04 + `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 ENV NVM_DIR=/usr/local/nvm @@ -187,8 +213,19 @@ 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" \ + `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 => @@ -204,21 +241,54 @@ const renderDockerfileBun = (config: TemplateConfig): string => .filter((chunk) => chunk.trim().length > 0) .join("\n") +// 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 forall p in login_rc(u): not contains(p, "/home/box") +// 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. + * @postcondition inherited box or ubuntu accounts resolve to config.sshUser when present. + * @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 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 ubuntu box; 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 \ 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} RUN printf "%s\\n" "${config.sshUser} ALL=(ALL) NOPASSWD:ALL" > /etc/sudoers.d/${config.sshUser} \ && chmod 0440 /etc/sudoers.d/${config.sshUser} @@ -259,11 +329,22 @@ RUN set -eu; \ docker-git-session-sync --help >/dev/null; \ fi` -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 +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=${targetDirLiteral}; \ + HOME_DIR_CANON="$HOME_DIR"; \ + TARGET_DIR_CANON="$TARGET_DIR"; \ + while [ "\${HOME_DIR_CANON%/}" != "$HOME_DIR_CANON" ]; do HOME_DIR_CANON="\${HOME_DIR_CANON%/}"; done; \ + while [ "\${TARGET_DIR_CANON%/}" != "$TARGET_DIR_CANON" ]; do TARGET_DIR_CANON="\${TARGET_DIR_CANON%/}"; done; \ + [ -n "$HOME_DIR_CANON" ] || HOME_DIR_CANON="/"; \ + [ -n "$TARGET_DIR_CANON" ] || TARGET_DIR_CANON="/"; \ + mkdir -p "$HOME_DIR" "$TARGET_DIR"; \ + chown 1000:1000 "$HOME_DIR"; \ + if [ "$TARGET_DIR_CANON" != "/" ] && [ "$TARGET_DIR_CANON" != "$HOME_DIR_CANON" ]; 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 \ @@ -278,6 +359,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/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/src/lib/usecases/errors.ts b/packages/app/src/lib/usecases/errors.ts index 22dca9df..8b169b6d 100644 --- a/packages/app/src/lib/usecases/errors.ts +++ b/packages/app/src/lib/usecases/errors.ts @@ -80,6 +80,22 @@ const renderDockerAccessActionPlan = (issue: DockerAccessError["issue"]): string return issue === "PermissionDenied" ? permissionDeniedPlan.join("\n") : daemonUnavailablePlan.join("\n") } +// CHANGE: classify Docker build apt signature noise that is commonly caused by storage exhaustion. +// WHY: when Docker's backing filesystem is full, apt can report every repository as "not signed"; surfacing disk-pressure recovery avoids chasing false GPG/key fixes. +// QUOTE(ТЗ): "Все пробелмы с котоырми ты сталкиваешься должны быть потом покрыты тестами" +// REF: runtime-2026-05-14-docker-disk-full +// SOURCE: n/a +// FORMAT THEOREM: contains_apt_invalid_signature(details) -> render_error(details) includes disk_space_recovery_hint +// PURITY: CORE +// INVARIANT: the classifier only adds guidance and does not alter command execution semantics +// COMPLEXITY: O(n) time / O(1) space where n = |details|. +const isAptInvalidSignatureFailure = (details: string | undefined): boolean => { + const normalized = details?.toLowerCase() ?? "" + return normalized.includes("invalid signature") && + normalized.includes("repository") && + normalized.includes("not signed") +} + const renderDockerCommandError = ({ details, exitCode }: DockerCommandError): string => [ `docker compose failed with exit code ${exitCode}`, @@ -92,6 +108,11 @@ const renderDockerCommandError = ({ details, exitCode }: DockerCommandError): st "Hint: NVIDIA GPU access is enabled but Docker cannot load the host NVIDIA runtime; run with GPU disabled (`--gpu none`) or install the NVIDIA driver and NVIDIA Container Toolkit." ] : []), + ...(isAptInvalidSignatureFailure(details) + ? [ + "Hint: apt reported invalid signatures for multiple repositories during Docker build; this can be caused by low Docker host disk space. Check `df -h` and reclaim unused Docker cache with `docker builder prune -af` and `docker image prune -af` before retrying." + ] + : []), "Hint: if output above contains 'lookup auth.docker.io' or 'read udp ... [::1]:53 ... connection refused', fix Docker DNS resolver (set working DNS in host/daemon config) and retry." ].join("\n") 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..56209281 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) @@ -21,8 +29,64 @@ 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 => { + 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) +} + +/** + * 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 => { @@ -37,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 => { @@ -51,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 11d2cdf8..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 } 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 +52,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/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-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/src/core/templates/dockerfile.ts b/packages/lib/src/core/templates/dockerfile.ts index 0071eedd..693715fb 100644 --- a/packages/lib/src/core/templates/dockerfile.ts +++ b/packages/lib/src/core/templates/dockerfile.ts @@ -1,11 +1,37 @@ 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 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_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:2.1.1" + +/** + * 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. + * @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 => - `FROM ubuntu:24.04 + `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 ENV NVM_DIR=/usr/local/nvm @@ -187,8 +213,19 @@ 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" \ + `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 => @@ -204,21 +241,54 @@ const renderDockerfileBun = (config: TemplateConfig): string => .filter((chunk) => chunk.trim().length > 0) .join("\n") +// 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 forall p in login_rc(u): not contains(p, "/home/box") +// 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. + * @postcondition inherited box or ubuntu accounts resolve to config.sshUser when present. + * @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 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 ubuntu box; 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 \ 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} RUN printf "%s\\n" "${config.sshUser} ALL=(ALL) NOPASSWD:ALL" > /etc/sudoers.d/${config.sshUser} \ && chmod 0440 /etc/sudoers.d/${config.sshUser} @@ -259,11 +329,22 @@ RUN set -eu; \ docker-git-session-sync --help >/dev/null; \ fi` -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 +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=${targetDirLiteral}; \ + HOME_DIR_CANON="$HOME_DIR"; \ + TARGET_DIR_CANON="$TARGET_DIR"; \ + while [ "\${HOME_DIR_CANON%/}" != "$HOME_DIR_CANON" ]; do HOME_DIR_CANON="\${HOME_DIR_CANON%/}"; done; \ + while [ "\${TARGET_DIR_CANON%/}" != "$TARGET_DIR_CANON" ]; do TARGET_DIR_CANON="\${TARGET_DIR_CANON%/}"; done; \ + [ -n "$HOME_DIR_CANON" ] || HOME_DIR_CANON="/"; \ + [ -n "$TARGET_DIR_CANON" ] || TARGET_DIR_CANON="/"; \ + mkdir -p "$HOME_DIR" "$TARGET_DIR"; \ + chown 1000:1000 "$HOME_DIR"; \ + if [ "$TARGET_DIR_CANON" != "/" ] && [ "$TARGET_DIR_CANON" != "$HOME_DIR_CANON" ]; 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 \ @@ -278,6 +359,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/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/src/usecases/errors.ts b/packages/lib/src/usecases/errors.ts index 0fde7c65..d84f1414 100644 --- a/packages/lib/src/usecases/errors.ts +++ b/packages/lib/src/usecases/errors.ts @@ -79,6 +79,22 @@ const renderDockerAccessActionPlan = (issue: DockerAccessError["issue"]): string return issue === "PermissionDenied" ? permissionDeniedPlan.join("\n") : daemonUnavailablePlan.join("\n") } +// CHANGE: classify Docker build apt signature noise that is commonly caused by storage exhaustion. +// WHY: when Docker's backing filesystem is full, apt can report every repository as "not signed"; surfacing disk-pressure recovery avoids chasing false GPG/key fixes. +// QUOTE(ТЗ): "Все пробелмы с котоырми ты сталкиваешься должны быть потом покрыты тестами" +// REF: runtime-2026-05-14-docker-disk-full +// SOURCE: n/a +// FORMAT THEOREM: contains_apt_invalid_signature(details) -> render_error(details) includes disk_space_recovery_hint +// PURITY: CORE +// INVARIANT: the classifier only adds guidance and does not alter command execution semantics +// COMPLEXITY: O(n) time / O(1) space where n = |details|. +const isAptInvalidSignatureFailure = (details: string | undefined): boolean => { + const normalized = details?.toLowerCase() ?? "" + return normalized.includes("invalid signature") && + normalized.includes("repository") && + normalized.includes("not signed") +} + const renderDockerCommandError = ({ details, exitCode }: DockerCommandError): string => [ `docker compose failed with exit code ${exitCode}`, @@ -91,6 +107,11 @@ const renderDockerCommandError = ({ details, exitCode }: DockerCommandError): st "Hint: NVIDIA GPU access is enabled but Docker cannot load the host NVIDIA runtime; run with GPU disabled (`--gpu none`) or install the NVIDIA driver and NVIDIA Container Toolkit." ] : []), + ...(isAptInvalidSignatureFailure(details) + ? [ + "Hint: apt reported invalid signatures for multiple repositories during Docker build; this can be caused by low Docker host disk space. Check `df -h` and reclaim unused Docker cache with `docker builder prune -af` and `docker image prune -af` before retrying." + ] + : []), "Hint: if output above contains 'lookup auth.docker.io' or 'read udp ... [::1]:53 ... connection refused', fix Docker DNS resolver (set working DNS in host/daemon config) and retry." ].join("\n") 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..3cf91019 --- /dev/null +++ b/packages/lib/tests/core/command-builders.test.ts @@ -0,0 +1,144 @@ +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({ + 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") + } + }) + + 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 9dbdeb40..707b7526 100644 --- a/packages/lib/tests/core/templates.test.ts +++ b/packages/lib/tests/core/templates.test.ts @@ -1,4 +1,8 @@ +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 * as fc from "fast-check" import { defaultTemplateConfig, type TemplateConfig } from "../../src/core/domain.js" import { renderDockerCompose } from "../../src/core/templates/docker-compose.js" @@ -6,6 +10,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, @@ -32,6 +37,28 @@ const expectContainsAll = (value: string, snippets: ReadonlyArray): void } } +const generatedTemplateConfigArbitrary: fc.Arbitrary = fc + .record({ + gpu: fc.constantFrom("none", "all"), + projectIndex: fc.integer({ min: 1, max: 100_000 }), + sshPort: fc.integer({ min: 1_024, max: 65_535 }), + sshUserIndex: fc.integer({ min: 1, max: 100_000 }) + }) + .map(({ gpu, projectIndex, sshPort, sshUserIndex }) => { + const sshUser = `dev${sshUserIndex}` + const projectName = `repo-${projectIndex}` + + return makeTemplateConfig({ + containerName: `dg-test-${projectIndex}`, + gpu, + serviceName: `dg-test-${projectIndex}`, + sshPort, + sshUser, + targetDir: `/home/${sshUser}/org/${projectName}`, + volumeName: `dg-test-${projectIndex}-home` + }) + }) + describe("renderEntrypointDnsRepair", () => { it("renders the fallback nameserver repair block", () => { const dnsRepair = renderEntrypointDnsRepair() @@ -55,6 +82,111 @@ describe("renderEntrypointDnsRepair", () => { }) 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: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") + }) + + it("renames the UID 1000 base user to the configured SSH user before the box fallback", () => { + const dockerfile = renderDockerfile(makeTemplateConfig()) + + expect(dockerfile).toContain("for BASE_USER in ubuntu box; 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("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("preserves HOME/PATH/WORKDIR normalization for generated configs", () => { + fc.assert( + fc.property(generatedTemplateConfigArbitrary, (config) => { + const dockerfile = renderDockerfile(config) + const home = `/home/${config.sshUser}` + + expectContainsAll(dockerfile, [ + `ENV HOME=${home}`, + `ENV PATH=/usr/local/bun/bin:${home}/.deno/bin:${home}/.bun/bin:/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin`, + `WORKDIR ${home}` + ]) + expect(dockerfile).not.toContain("ENV HOME=/home/box") + expect(dockerfile).not.toContain("ENV HOME=/home/ubuntu") + expect(dockerfile).not.toContain("WORKDIR /home/box") + expect(dockerfile).not.toContain("WORKDIR /home/ubuntu") + expect(dockerfile).not.toContain("ENV PATH=/usr/local/bun/bin:/home/box/") + expect(dockerfile).not.toContain("ENV PATH=/usr/local/bun/bin:/home/ubuntu/") + }) + ) + }) + + 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("does not recursively chown the inherited home directory from the base image", () => { + const config = makeTemplateConfig() + const dockerfile = renderDockerfile(config) + + expect(dockerfile).toContain('chown 1000:1000 "$HOME_DIR"') + expect(dockerfile).toContain('TARGET_DIR_CANON="$TARGET_DIR"') + expect(dockerfile).toContain('HOME_DIR_CANON="$HOME_DIR"') + expect(dockerfile).toContain('chown -R 1000:1000 "$TARGET_DIR"') + expect(dockerfile).toContain( + 'if [ "$TARGET_DIR_CANON" != "/" ] && [ "$TARGET_DIR_CANON" != "$HOME_DIR_CANON" ]; then chown -R 1000:1000 "$TARGET_DIR"; fi' + ) + 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('if [ "$TARGET_DIR" != "/" ] && [ "$TARGET_DIR" != "$HOME_DIR" ]') + }) + + it("normalizes trailing slashes before deciding whether to chown the target directory", () => { + const dockerfile = renderDockerfile(makeTemplateConfig({ targetDir: "/home/dev/" })) + + expectContainsAll(dockerfile, [ + "TARGET_DIR='/home/dev/';", + 'while [ "${TARGET_DIR_CANON%/}" != "$TARGET_DIR_CANON" ]; do TARGET_DIR_CANON="${TARGET_DIR_CANON%/}"; done;', + '[ -n "$TARGET_DIR_CANON" ] || TARGET_DIR_CANON="/";', + 'if [ "$TARGET_DIR_CANON" != "/" ] && [ "$TARGET_DIR_CANON" != "$HOME_DIR_CANON" ]; then chown -R 1000:1000 "$TARGET_DIR"; fi' + ]) + }) + + 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()) @@ -82,7 +214,54 @@ 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) + ) + ) + + it("keeps interactive prompt mutations behind the non-interactive guard", () => { + const nonInteractiveGuard = "*) return 0 2>/dev/null || exit 0 ;;" + + fc.assert( + fc.property( + fc.constantFrom("PROMPT_COMMAND=", "PS1=", "trap 'docker_git_terminal_sanitize' EXIT INT TERM"), + (interactiveMutation) => { + const script = renderPromptScript() + const guardIndex = script.indexOf(nonInteractiveGuard) + + expect(guardIndex).toBeGreaterThanOrEqual(0) + expect(script.indexOf(interactiveMutation)).toBeGreaterThan(guardIndex) + } + ) + ) + }) +}) + 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()) @@ -93,6 +272,24 @@ describe("renderEntrypoint clone cache", () => { expect(entrypoint).not.toContain("'+refs/pull/*:refs/pull/*'") expect(entrypoint).not.toContain("'+refs/merge-requests/*:refs/merge-requests/*'") }) + + it("preserves branch/tag-only clone-cache refspecs for generated configs", () => { + fc.assert( + fc.property(generatedTemplateConfigArbitrary, (config) => { + const entrypoint = renderEntrypoint(config) + const cloneCacheFetch = entrypoint + .split("\n") + .find((line) => line.includes("git --git-dir '$CACHE_REPO_DIR' fetch")) + + expect(cloneCacheFetch).toBeDefined() + expect(cloneCacheFetch).toContain("'+refs/heads/*:refs/heads/*'") + expect(cloneCacheFetch).toContain("'+refs/tags/*:refs/tags/*'") + expect(cloneCacheFetch).not.toContain("'+refs/*:refs/*'") + expect(cloneCacheFetch).not.toContain("refs/pull") + expect(cloneCacheFetch).not.toContain("refs/merge-requests") + }) + ) + }) }) describe("renderEntrypointGitHooks", () => { @@ -309,7 +506,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", @@ -318,6 +516,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", () => { diff --git a/packages/lib/tests/shell/config.test.ts b/packages/lib/tests/shell/config.test.ts new file mode 100644 index 00000000..b0b3ed74 --- /dev/null +++ b/packages/lib/tests/shell/config.test.ts @@ -0,0 +1,126 @@ +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 * as fc from "fast-check" + +import { defaultTemplateConfig, isUnixUserName, 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)) + }) + ) + +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) => + 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))) + + 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 73065b34..9e8be4f1 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") diff --git a/packages/lib/tests/usecases/errors.test.ts b/packages/lib/tests/usecases/errors.test.ts index 81ac3dd9..42913b34 100644 --- a/packages/lib/tests/usecases/errors.test.ts +++ b/packages/lib/tests/usecases/errors.test.ts @@ -42,6 +42,45 @@ describe("renderError", () => { expect(message).toContain("NVIDIA Container Toolkit") }) + it("includes disk pressure recovery hint for apt invalid signature build failures", () => { + const message = renderError( + new DockerCommandError({ + exitCode: 1, + details: [ + "W: GPG error: http://archive.ubuntu.com/ubuntu noble InRelease: At least one invalid signature was encountered.", + "E: The repository 'http://archive.ubuntu.com/ubuntu noble InRelease' is not signed.", + "E: The repository 'http://security.ubuntu.com/ubuntu noble-security InRelease' is not signed." + ].join("\n") + }) + ) + + expect(message).toContain("low Docker host disk space") + expect(message).toContain("df -h") + expect(message).toContain("docker builder prune -af") + expect(message).toContain("docker image prune -af") + }) + + it("does not include disk pressure recovery hint when apt signature pattern is incomplete", () => { + const incompleteDetails: ReadonlyArray = [ + "W: GPG error: http://archive.ubuntu.com/ubuntu noble InRelease: At least one invalid signature was encountered.", + "E: The repository 'http://archive.ubuntu.com/ubuntu noble InRelease' is not signed." + ] + + for (const details of incompleteDetails) { + const message = renderError( + new DockerCommandError({ + exitCode: 1, + details + }) + ) + + expect(message).not.toContain("low Docker host disk space") + expect(message).not.toContain("df -h") + expect(message).not.toContain("docker builder prune -af") + expect(message).not.toContain("docker image prune -af") + } + }) + it("shows NVIDIA hint iff docker output contains NVIDIA runtime markers", () => { const nvidiaContainerCliMarker = "nvidia-container-cli" const libNvidiaMlMarker = "libnvidia-ml.so.1" diff --git a/scripts/e2e/login-context.sh b/scripts/e2e/login-context.sh index 59ec7cd0..9beb357a 100755 --- a/scripts/e2e/login-context.sh +++ b/scripts/e2e/login-context.sh @@ -5,7 +5,9 @@ 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}" +# 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")" # docker-git containers may `chown -R` the `.docker-git` bind mount to UID 1000. 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 '