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..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-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..26f50f9a 100644 --- a/packages/app/src/lib/core/templates/dockerfile.ts +++ b/packages/app/src/lib/core/templates/dockerfile.ts @@ -3,9 +3,31 @@ 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 +// 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 JS/runtime tooling from link-foundation/box while preserving docker-git bootstrap layers +// 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 => - `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,8 +209,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 +237,53 @@ 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. + * @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 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 \ 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} @@ -261,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/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-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..26f50f9a 100644 --- a/packages/lib/src/core/templates/dockerfile.ts +++ b/packages/lib/src/core/templates/dockerfile.ts @@ -3,9 +3,31 @@ 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 +// 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 JS/runtime tooling from link-foundation/box while preserving docker-git bootstrap layers +// 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 => - `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,8 +209,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 +237,53 @@ 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. + * @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 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 \ 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} @@ -261,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/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/core/templates.test.ts b/packages/lib/tests/core/templates.test.ts index 9dbdeb40..bcbcffb5 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,80 @@ 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: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("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 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()) @@ -82,6 +183,42 @@ 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("refreshes mirrors without broad remote refs", () => { const entrypoint = renderEntrypoint(makeTemplateConfig()) @@ -93,6 +230,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 +464,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 +474,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..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/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") 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 '