Skip to content

Commit c19e33e

Browse files
committed
fix(shell): move docker-git runtime state into volumes
1 parent 25b269d commit c19e33e

20 files changed

Lines changed: 208 additions & 102 deletions

README.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11
# docker-git
22

33
`docker-git` создаёт отдельную Docker-среду для каждого репозитория, issue или PR.
4-
По умолчанию проекты лежат в `~/.docker-git`.
4+
По умолчанию управляющие файлы проекта лежат в `~/.docker-git`, а runtime workspace, `.docker-git` state и auth живут внутри Docker-managed volumes контейнера.
55

66
## Что нужно
77

packages/app/tests/docker-git/fixtures/project-item.ts

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -14,11 +14,11 @@ export const makeProjectItem = (
1414
targetDir: "/home/dev/org/repo",
1515
sshCommand: "ssh -p 2222 dev@localhost",
1616
sshKeyPath: null,
17-
authorizedKeysPath: "/home/dev/.docker-git/org-repo/.docker-git/authorized_keys",
17+
authorizedKeysPath: "/home/dev/.docker-git/org-repo/authorized_keys",
1818
authorizedKeysExists: true,
19-
envGlobalPath: "/home/dev/.orch/env/global.env",
19+
envGlobalPath: "/home/dev/.docker-git/org-repo/.orch/env/global.env",
2020
envProjectPath: "/home/dev/.docker-git/org-repo/.orch/env/project.env",
21-
codexAuthPath: "/home/dev/.orch/auth/codex",
21+
codexAuthPath: "/home/dev/.docker-git/org-repo/.orch/auth/codex",
2222
codexHome: "/home/dev/.codex",
2323
...overrides
2424
})

packages/app/tests/docker-git/menu-select-connect.test.ts

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -20,10 +20,10 @@ const makeConnectDeps = (events: Array<string>) => ({
2020
const workspaceProject = () =>
2121
makeProjectItem({
2222
projectDir: "/home/dev/provercoderai/docker-git/workspaces/org/repo",
23-
authorizedKeysPath: "/home/dev/provercoderai/docker-git/workspaces/org/repo/.docker-git/authorized_keys",
24-
envGlobalPath: "/home/dev/provercoderai/docker-git/.orch/env/global.env",
23+
authorizedKeysPath: "/home/dev/provercoderai/docker-git/workspaces/org/repo/authorized_keys",
24+
envGlobalPath: "/home/dev/provercoderai/docker-git/workspaces/org/repo/.orch/env/global.env",
2525
envProjectPath: "/home/dev/provercoderai/docker-git/workspaces/org/repo/.orch/env/project.env",
26-
codexAuthPath: "/home/dev/provercoderai/docker-git/.orch/auth/codex"
26+
codexAuthPath: "/home/dev/provercoderai/docker-git/workspaces/org/repo/.orch/auth/codex"
2727
})
2828

2929
describe("menu-select-connect", () => {

packages/lib/src/core/domain.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,7 @@ export type DockerNetworkMode = "shared" | "project"
99
export const defaultDockerNetworkMode: DockerNetworkMode = "shared"
1010

1111
export const defaultDockerSharedNetworkName = "docker-git-shared"
12+
export const dockerGitSharedCodexVolumeName = "docker-git-shared-codex"
1213

1314
export interface TemplateConfig {
1415
readonly containerName: string

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

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -34,11 +34,11 @@ export const renderEntrypoint = (config: TemplateConfig): string =>
3434
[
3535
renderEntrypointHeader(config),
3636
renderEntrypointPackageCache(config),
37+
renderEntrypointDockerGitBootstrap(config),
3738
renderEntrypointAuthorizedKeys(config),
3839
renderEntrypointCodexHome(config),
3940
renderEntrypointCodexSharedAuth(config),
4041
renderEntrypointOpenCodeConfig(config),
41-
renderEntrypointDockerGitBootstrap(config),
4242
renderEntrypointMcpPlaywright(config),
4343
renderEntrypointZshShell(config),
4444
renderEntrypointZshUserRc(config),

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

Lines changed: 5 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -51,7 +51,7 @@ docker_git_upsert_ssh_env() {
5151
}`
5252

5353
export const renderEntrypointPackageCache = (config: TemplateConfig): string =>
54-
`# Share package manager caches across all docker-git containers
54+
`# Keep package manager caches inside the project home volume
5555
PACKAGE_CACHE_ROOT="/home/${config.sshUser}/.docker-git/.cache/packages"
5656
PACKAGE_PNPM_STORE="\${npm_config_store_dir:-\${PNPM_STORE_DIR:-$PACKAGE_CACHE_ROOT/pnpm/store}}"
5757
PACKAGE_NPM_CACHE="\${npm_config_cache:-\${NPM_CONFIG_CACHE:-$PACKAGE_CACHE_ROOT/npm}}"
@@ -76,12 +76,13 @@ docker_git_upsert_ssh_env "npm_config_cache" "$PACKAGE_NPM_CACHE"
7676
docker_git_upsert_ssh_env "YARN_CACHE_FOLDER" "$PACKAGE_YARN_CACHE"`
7777

7878
export const renderEntrypointAuthorizedKeys = (config: TemplateConfig): string =>
79-
`# 1) Authorized keys are mounted from host at /authorized_keys
79+
`# 1) Mirror authorized_keys from the project home volume into ~/.ssh
80+
DOCKER_GIT_AUTH_KEYS="/home/${config.sshUser}/.docker-git/authorized_keys"
8081
mkdir -p /home/${config.sshUser}/.ssh
8182
chmod 700 /home/${config.sshUser}/.ssh
8283
83-
if [[ -f /authorized_keys ]]; then
84-
cp /authorized_keys /home/${config.sshUser}/.ssh/authorized_keys
84+
if [[ -f "$DOCKER_GIT_AUTH_KEYS" ]]; then
85+
cp "$DOCKER_GIT_AUTH_KEYS" /home/${config.sshUser}/.ssh/authorized_keys
8586
chmod 600 /home/${config.sshUser}/.ssh/authorized_keys
8687
fi
8788

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

Lines changed: 11 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -2,8 +2,10 @@ import type { TemplateConfig } from "../domain.js"
22

33
export const renderEntrypointCodexHome = (config: TemplateConfig): string =>
44
`# Ensure Codex home exists if mounted
5-
mkdir -p ${config.codexHome}
6-
chown -R 1000:1000 ${config.codexHome}
5+
mkdir -p ${config.codexHome} && chown -R 1000:1000 ${config.codexHome}
6+
7+
DOCKER_GIT_CODEX_BOOTSTRAP="/home/${config.sshUser}/.docker-git/.orch/auth/codex/config.toml"
8+
if [[ -f "$DOCKER_GIT_CODEX_BOOTSTRAP" && ! -f "${config.codexHome}/config.toml" ]]; then cp "$DOCKER_GIT_CODEX_BOOTSTRAP" "${config.codexHome}/config.toml"; chown 1000:1000 "${config.codexHome}/config.toml" || true; fi
79
810
# Ensure home ownership matches the dev UID/GID (volumes may be stale)
911
HOME_OWNER="$(stat -c "%u:%g" /home/${config.sshUser} 2>/dev/null || echo "")"
@@ -22,15 +24,13 @@ if [[ "$CODEX_SHARE_AUTH" == "1" ]]; then
2224
| sed -E 's/[^a-z0-9]+/-/g; s/^-+//; s/-+$//')"
2325
if [[ -z "$CODEX_LABEL_NORM" ]]; then CODEX_LABEL_NORM="default"; fi
2426
CODEX_AUTH_LABEL="$CODEX_LABEL_NORM"
25-
CODEX_SHARED_HOME="${config.codexHome}-shared"
26-
mkdir -p "$CODEX_SHARED_HOME"
27-
chown -R 1000:1000 "$CODEX_SHARED_HOME" || true
28-
AUTH_FILE="${config.codexHome}/auth.json"
29-
SHARED_AUTH_FILE="$CODEX_SHARED_HOME/auth.json"
27+
DOCKER_GIT_CODEX_AUTH_ROOT="/home/${config.sshUser}/.docker-git/.orch/auth/codex"; CODEX_SHARED_HOME="${config.codexHome}-shared"
28+
mkdir -p "$CODEX_SHARED_HOME" && chown -R 1000:1000 "$CODEX_SHARED_HOME" || true
29+
AUTH_FILE="${config.codexHome}/auth.json"; SHARED_AUTH_FILE="$CODEX_SHARED_HOME/auth.json"; SHARED_AUTH_SEED="$DOCKER_GIT_CODEX_AUTH_ROOT/auth.json"
3030
if [[ "$CODEX_LABEL_NORM" != "default" ]]; then
31-
SHARED_AUTH_FILE="$CODEX_SHARED_HOME/$CODEX_LABEL_NORM/auth.json"
32-
mkdir -p "$(dirname "$SHARED_AUTH_FILE")"
31+
SHARED_AUTH_FILE="$CODEX_SHARED_HOME/$CODEX_LABEL_NORM/auth.json"; SHARED_AUTH_SEED="$DOCKER_GIT_CODEX_AUTH_ROOT/$CODEX_LABEL_NORM/auth.json"; mkdir -p "$(dirname "$SHARED_AUTH_FILE")"
3332
fi
33+
if [[ ! -f "$SHARED_AUTH_FILE" && -f "$SHARED_AUTH_SEED" ]]; then cp "$SHARED_AUTH_SEED" "$SHARED_AUTH_FILE"; chmod 600 "$SHARED_AUTH_FILE" || true; chown 1000:1000 "$SHARED_AUTH_FILE" || true; fi
3434
# Guard against a bad bind mount creating a directory at auth.json.
3535
if [[ -d "$AUTH_FILE" ]]; then
3636
mv "$AUTH_FILE" "$AUTH_FILE.bak-$(date +%s)" || true
@@ -319,4 +319,5 @@ export const renderEntrypointAgentsNotice = (config: TemplateConfig): string =>
319319
entrypointAgentsNoticeTemplate.replaceAll("__CODEX_HOME__", config.codexHome).replaceAll(
320320
"__SSH_USER__",
321321
config.sshUser
322-
).replaceAll("__TARGET_DIR__", config.targetDir)
322+
)
323+
.replaceAll("__TARGET_DIR__", config.targetDir)

packages/lib/src/core/templates-entrypoint/nested-docker-git.ts

Lines changed: 49 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -4,28 +4,70 @@ const entrypointDockerGitBootstrapTemplate = String
44
.raw`# Bootstrap ~/.docker-git for nested docker-git usage inside this container.
55
DOCKER_GIT_HOME="/home/__SSH_USER__/.docker-git"
66
DOCKER_GIT_AUTH_DIR="$DOCKER_GIT_HOME/.orch/auth/codex"
7+
DOCKER_GIT_CLAUDE_AUTH_DIR="$DOCKER_GIT_HOME/.orch/auth/claude"
78
DOCKER_GIT_ENV_DIR="$DOCKER_GIT_HOME/.orch/env"
89
DOCKER_GIT_ENV_GLOBAL="$DOCKER_GIT_ENV_DIR/global.env"
910
DOCKER_GIT_ENV_PROJECT="$DOCKER_GIT_ENV_DIR/project.env"
1011
DOCKER_GIT_AUTH_KEYS="$DOCKER_GIT_HOME/authorized_keys"
12+
BOOTSTRAP_ROOT="/opt/docker-git/bootstrap"
13+
BOOTSTRAP_ORCH_ROOT="$BOOTSTRAP_ROOT/.orch"
14+
BOOTSTRAP_AUTH_KEYS="$BOOTSTRAP_ROOT/authorized_keys"
15+
BOOTSTRAP_CODEX_AUTH_DIR="$BOOTSTRAP_ORCH_ROOT/auth/codex"
16+
BOOTSTRAP_CLAUDE_AUTH_DIR="$BOOTSTRAP_ORCH_ROOT/auth/claude"
17+
BOOTSTRAP_ENV_GLOBAL="$BOOTSTRAP_ORCH_ROOT/env/global.env"
18+
BOOTSTRAP_ENV_PROJECT="$BOOTSTRAP_ORCH_ROOT/env/project.env"
1119
12-
mkdir -p "$DOCKER_GIT_AUTH_DIR" "$DOCKER_GIT_ENV_DIR" "$DOCKER_GIT_HOME/.orch/auth/gh"
20+
mkdir -p "$DOCKER_GIT_AUTH_DIR" "$DOCKER_GIT_CLAUDE_AUTH_DIR" "$DOCKER_GIT_ENV_DIR" "$DOCKER_GIT_HOME/.orch/auth/gh"
1321
14-
if [[ -f "/home/__SSH_USER__/.ssh/authorized_keys" ]]; then
22+
copy_if_missing_file() {
23+
local source="$1"
24+
local target="$2"
25+
if [[ ! -f "$source" || -e "$target" ]]; then
26+
return 1
27+
fi
28+
mkdir -p "$(dirname "$target")"
29+
cp "$source" "$target"
30+
return 0
31+
}
32+
33+
copy_dir_missing_entries() {
34+
local source="$1"
35+
local target="$2"
36+
if [[ ! -d "$source" ]]; then
37+
return 0
38+
fi
39+
mkdir -p "$target"
40+
(
41+
cd "$source"
42+
find . -mindepth 1 -print
43+
) | while IFS= read -r entry; do
44+
local source_entry="$source/$entry"
45+
local target_entry="$target/$entry"
46+
if [[ -d "$source_entry" ]]; then
47+
mkdir -p "$target_entry"
48+
elif [[ -f "$source_entry" && ! -e "$target_entry" ]]; then
49+
mkdir -p "$(dirname "$target_entry")"
50+
cp "$source_entry" "$target_entry"
51+
fi
52+
done
53+
}
54+
55+
if [[ ! -f "$DOCKER_GIT_AUTH_KEYS" && -f "/home/__SSH_USER__/.ssh/authorized_keys" ]]; then
1556
cp "/home/__SSH_USER__/.ssh/authorized_keys" "$DOCKER_GIT_AUTH_KEYS"
16-
elif [[ -f /authorized_keys ]]; then
17-
cp /authorized_keys "$DOCKER_GIT_AUTH_KEYS"
1857
fi
58+
copy_if_missing_file "$BOOTSTRAP_AUTH_KEYS" "$DOCKER_GIT_AUTH_KEYS" || true
1959
if [[ -f "$DOCKER_GIT_AUTH_KEYS" ]]; then
2060
chmod 600 "$DOCKER_GIT_AUTH_KEYS" || true
2161
fi
2262
63+
copy_if_missing_file "$BOOTSTRAP_ENV_GLOBAL" "$DOCKER_GIT_ENV_GLOBAL" || true
2364
if [[ ! -f "$DOCKER_GIT_ENV_GLOBAL" ]]; then
2465
cat <<'EOF' > "$DOCKER_GIT_ENV_GLOBAL"
2566
# docker-git env
2667
# KEY=value
2768
EOF
2869
fi
70+
copy_if_missing_file "$BOOTSTRAP_ENV_PROJECT" "$DOCKER_GIT_ENV_PROJECT" || true
2971
if [[ ! -f "$DOCKER_GIT_ENV_PROJECT" ]]; then
3072
cat <<'EOF' > "$DOCKER_GIT_ENV_PROJECT"
3173
# docker-git project env defaults
@@ -66,6 +108,9 @@ copy_if_distinct_file() {
66108
return 0
67109
}
68110
111+
copy_dir_missing_entries "$BOOTSTRAP_CODEX_AUTH_DIR" "$DOCKER_GIT_AUTH_DIR"
112+
copy_dir_missing_entries "$BOOTSTRAP_CLAUDE_AUTH_DIR" "$DOCKER_GIT_CLAUDE_AUTH_DIR"
113+
69114
if [[ -n "$GH_TOKEN" ]]; then
70115
upsert_env_var "$DOCKER_GIT_ENV_GLOBAL" "GH_TOKEN" "$GH_TOKEN"
71116
fi

packages/lib/src/core/templates.ts

Lines changed: 9 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -10,10 +10,12 @@ export type FileSpec =
1010

1111
const renderGitignore = (): string =>
1212
`# docker-git project files
13-
# NOTE: this directory is intended to be committed to the docker-git state repository.
14-
# It intentionally does not ignore .orch/ or auth files; keep the state repo private.
13+
# NOTE: bootstrap secrets stay local-only and should not be committed.
1514
1615
# Volatile Codex artifacts (do not commit)
16+
authorized_keys
17+
.orch/auth/codex/auth.json
18+
.orch/auth/claude/
1719
.orch/auth/codex/log/
1820
.orch/auth/codex/tmp/
1921
.orch/auth/codex/sessions/
@@ -22,8 +24,10 @@ const renderGitignore = (): string =>
2224

2325
const renderDockerignore = (): string =>
2426
`# docker-git build context
25-
.orch/
26-
authorized_keys
27+
.orch/auth/codex/log/
28+
.orch/auth/codex/tmp/
29+
.orch/auth/codex/sessions/
30+
.orch/auth/codex/models_cache.json
2731
`
2832

2933
const renderConfigJson = (config: TemplateConfig): string =>
@@ -52,6 +56,7 @@ export const planFiles = (config: TemplateConfig): ReadonlyArray<FileSpec> => {
5256
{ _tag: "File", relativePath: ".gitignore", contents: renderGitignore() },
5357
...maybePlaywrightFiles,
5458
{ _tag: "Dir", relativePath: ".orch/auth/codex" },
59+
{ _tag: "Dir", relativePath: ".orch/auth/claude" },
5560
{ _tag: "Dir", relativePath: ".orch/env" }
5661
]
5762
}

packages/lib/src/core/templates/docker-compose.ts

Lines changed: 17 additions & 26 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
import { resolveComposeNetworkName, type TemplateConfig } from "../domain.js"
1+
import { dockerGitSharedCodexVolumeName, resolveComposeNetworkName, type TemplateConfig } from "../domain.js"
22

33
type ComposeFragments = {
44
readonly networkMode: TemplateConfig["dockerNetworkMode"]
@@ -11,14 +11,12 @@ type ComposeFragments = {
1111
readonly maybeDependsOn: string
1212
readonly maybePlaywrightEnv: string
1313
readonly maybeBrowserService: string
14-
readonly maybeBrowserVolume: string
1514
readonly forkRepoUrl: string
1615
}
1716

18-
type PlaywrightFragments = Pick<
19-
ComposeFragments,
20-
"maybeDependsOn" | "maybePlaywrightEnv" | "maybeBrowserService" | "maybeBrowserVolume"
21-
>
17+
type PlaywrightFragments = Pick<ComposeFragments, "maybeDependsOn" | "maybePlaywrightEnv" | "maybeBrowserService">
18+
19+
const sharedCodexVolumeKey = "docker_git_shared_codex"
2220

2321
const renderGitTokenLabelEnv = (gitTokenLabel: string): string =>
2422
gitTokenLabel.length > 0
@@ -45,12 +43,6 @@ const renderAgentAutoEnv = (agentAuto: boolean | undefined): string =>
4543
? ` AGENT_AUTO: "1"\n`
4644
: ""
4745

48-
const renderProjectsRootHostMount = (projectsRoot: string): string =>
49-
`\${DOCKER_GIT_PROJECTS_ROOT_HOST:-${projectsRoot}}`
50-
51-
const renderSharedCodexHostMount = (projectsRoot: string): string =>
52-
`\${DOCKER_GIT_PROJECTS_ROOT_HOST:-${projectsRoot}}/.orch/auth/codex`
53-
5446
const buildPlaywrightFragments = (
5547
config: TemplateConfig,
5648
networkName: string
@@ -59,8 +51,7 @@ const buildPlaywrightFragments = (
5951
return {
6052
maybeDependsOn: "",
6153
maybePlaywrightEnv: "",
62-
maybeBrowserService: "",
63-
maybeBrowserVolume: ""
54+
maybeBrowserService: ""
6455
}
6556
}
6657

@@ -75,8 +66,7 @@ const buildPlaywrightFragments = (
7566
maybePlaywrightEnv:
7667
` MCP_PLAYWRIGHT_ENABLE: "1"\n MCP_PLAYWRIGHT_CDP_ENDPOINT: "${browserCdpEndpoint}"\n`,
7768
maybeBrowserService:
78-
`\n ${browserServiceName}:\n build:\n context: .\n dockerfile: ${browserDockerfile}\n container_name: ${browserContainerName}\n restart: unless-stopped\n environment:\n VNC_NOPW: "1"\n shm_size: "2gb"\n expose:\n - "9223"\n volumes:\n - ${browserVolumeName}:/data\n networks:\n - ${networkName}\n`,
79-
maybeBrowserVolume: ` ${browserVolumeName}:\n`
69+
`\n ${browserServiceName}:\n build:\n context: .\n dockerfile: ${browserDockerfile}\n container_name: ${browserContainerName}\n restart: unless-stopped\n environment:\n VNC_NOPW: "1"\n shm_size: "2gb"\n expose:\n - "9223"\n volumes:\n - ${browserVolumeName}:/data\n networks:\n - ${networkName}\n`
8070
}
8171
}
8272

@@ -105,7 +95,6 @@ const buildComposeFragments = (config: TemplateConfig): ComposeFragments => {
10595
maybeDependsOn: playwright.maybeDependsOn,
10696
maybePlaywrightEnv: playwright.maybePlaywrightEnv,
10797
maybeBrowserService: playwright.maybeBrowserService,
108-
maybeBrowserVolume: playwright.maybeBrowserVolume,
10998
forkRepoUrl
11099
}
111100
}
@@ -132,10 +121,7 @@ ${fragments.maybePlaywrightEnv}${fragments.maybeDependsOn} env_file:
132121
- "127.0.0.1:${config.sshPort}:22"
133122
volumes:
134123
- ${config.volumeName}:/home/${config.sshUser}
135-
- ${renderProjectsRootHostMount(config.dockerGitPath)}:/home/${config.sshUser}/.docker-git
136-
- ${config.authorizedKeysPath}:/authorized_keys:ro
137-
- ${config.codexAuthPath}:${config.codexHome}
138-
- ${renderSharedCodexHostMount(config.dockerGitPath)}:${config.codexHome}-shared
124+
- ${sharedCodexVolumeKey}:${config.codexHome}-shared
139125
- /var/run/docker.sock:/var/run/docker.sock
140126
networks:
141127
- ${fragments.networkName}
@@ -153,16 +139,21 @@ const renderComposeNetworks = (
153139
${networkName}:
154140
driver: bridge`
155141

156-
const renderComposeVolumes = (config: TemplateConfig, maybeBrowserVolume: string): string =>
157-
`volumes:
158-
${config.volumeName}:
159-
${maybeBrowserVolume}`
142+
const renderComposeVolumes = (config: TemplateConfig, enableMcpPlaywright: boolean): string =>
143+
[
144+
"volumes:",
145+
` ${config.volumeName}:`,
146+
` ${sharedCodexVolumeKey}:`,
147+
" external: true",
148+
` name: ${dockerGitSharedCodexVolumeName}`,
149+
...(enableMcpPlaywright ? [` ${config.volumeName}-browser:`] : [])
150+
].join("\n")
160151

161152
export const renderDockerCompose = (config: TemplateConfig): string => {
162153
const fragments = buildComposeFragments(config)
163154
return [
164155
renderComposeServices(config, fragments),
165156
renderComposeNetworks(fragments.networkMode, fragments.networkName),
166-
renderComposeVolumes(config, fragments.maybeBrowserVolume)
157+
renderComposeVolumes(config, config.enableMcpPlaywright)
167158
].join("\n\n")
168159
}

0 commit comments

Comments
 (0)