Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@ dev_ssh_key.pub
.docker-git/
.e2e/
effect-template1/
.agent-plan.json

# Node / build artifacts
node_modules/
Expand Down
56 changes: 55 additions & 1 deletion packages/app/src/lib/core/templates-entrypoint/git-hooks.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,8 @@ const entrypointGitHooksTemplate = String
HOOKS_DIR="/opt/docker-git/hooks"
PRE_PUSH_HOOK="$HOOKS_DIR/pre-push"
POST_PUSH_ACTION="$HOOKS_DIR/post-push"
PLAN_TO_GIT_CODEX_HOOK="$HOOKS_DIR/plan-to-git-codex-hook"
CODEX_REQUIREMENTS_FILE="/etc/codex/requirements.toml"
mkdir -p "$HOOKS_DIR"

cat <<'EOF' > "$PRE_PUSH_HOOK"
Expand Down Expand Up @@ -136,13 +138,24 @@ cat <<'EOF' > "$POST_PUSH_ACTION"
#!/usr/bin/env bash
set -euo pipefail

# 5) Run session backup after successful push
# 5) Run plan sync and session backup after successful push
REPO_ROOT="${"${"}DOCKER_GIT_POST_PUSH_REPO_ROOT:-}"
if [[ -z "$REPO_ROOT" || ! -d "$REPO_ROOT" ]]; then
REPO_ROOT="$(git rev-parse --show-toplevel 2>/dev/null || pwd)"
fi
cd "$REPO_ROOT"

# CHANGE: sync captured Codex plans to the current branch PR after push.
# WHY: issue #369 requires the agent plan to be uploaded to PR discussion.
# REF: issue-369
if [ "${"${"}DOCKER_GIT_SKIP_PLAN_TO_GIT:-}" != "1" ]; then
if ! command -v plan-to-git >/dev/null 2>&1; then
echo "[plan-to-git] Error: plan-to-git not found" >&2
exit 1
fi
plan-to-git sync
fi

# CHANGE: keep post-push backup logic in a reusable action script
# WHY: git has no client-side post-push hook, so the global git wrapper
# invokes this after a successful git push
Expand All @@ -161,6 +174,47 @@ fi
EOF
chmod 0755 "$POST_PUSH_ACTION"

cat <<'EOF' > "$PLAN_TO_GIT_CODEX_HOOK"
#!/usr/bin/env bash
set -euo pipefail

if [ "${"${"}DOCKER_GIT_SKIP_PLAN_TO_GIT:-}" = "1" ]; then
exit 0
fi

if ! command -v plan-to-git >/dev/null 2>&1; then
echo "[plan-to-git] Error: plan-to-git not found" >&2
exit 1
fi

plan-to-git hook --source codex
EOF
chmod 0755 "$PLAN_TO_GIT_CODEX_HOOK"

mkdir -p "$(dirname "$CODEX_REQUIREMENTS_FILE")"
cat <<'EOF' > "$CODEX_REQUIREMENTS_FILE"
# docker-git managed Codex requirements

[features]
hooks = true

[hooks]
managed_dir = "/opt/docker-git/hooks"

[[hooks.UserPromptSubmit]]
[[hooks.UserPromptSubmit.hooks]]
type = "command"
command = "/opt/docker-git/hooks/plan-to-git-codex-hook"
statusMessage = "Capturing plan decision"

[[hooks.Stop]]
[[hooks.Stop.hooks]]
type = "command"
command = "/opt/docker-git/hooks/plan-to-git-codex-hook"
statusMessage = "Capturing agent plan"
EOF
chmod 0644 "$CODEX_REQUIREMENTS_FILE"

${renderEntrypointGitPostPushWrapperInstall()}

git config --system core.hooksPath "$HOOKS_DIR" || true
Expand Down
2 changes: 2 additions & 0 deletions packages/app/src/lib/core/templates.ts
Original file line number Diff line number Diff line change
Expand Up @@ -39,6 +39,7 @@ scripts/

# Volatile Codex artifacts (do not commit)
authorized_keys
.agent-plan.json
.orch/auth/codex/auth.json
.orch/auth/claude/
.orch/auth/codex/log/
Expand All @@ -50,6 +51,7 @@ authorized_keys
const renderDockerignore = (): string =>
`# docker-git build context
authorized_keys
.agent-plan.json
.orch/env/
.orch/auth/codex/
.orch/auth/claude/
Expand Down
25 changes: 21 additions & 4 deletions packages/app/src/lib/core/templates/dockerfile-prelude.ts
Original file line number Diff line number Diff line change
Expand Up @@ -83,16 +83,33 @@ RUN cargo install --git https://github.com/ProverCoderAI/rust-browser-connection
RUN printf "%s\\n" "ALL ALL=(ALL) NOPASSWD:ALL" > /etc/sudoers.d/zz-all \
&& chmod 0440 /etc/sudoers.d/zz-all`

const planToGitRevision = "06fe8bdf1d2e48a1f5a0218a3bb7af19e63deb5e"

// CHANGE: install plan-to-git in generated project containers.
// WHY: issue #369 requires agent plans to be captured and uploaded to pull requests.
// QUOTE(ТЗ): "Надо что бы у нас план загружался в PR"
// REF: issue-369
// SOURCE: https://github.com/ProverCoderAI/plan-to-git/tree/v0.19.0
// FORMAT THEOREM: image_build_success -> executable(/usr/local/bin/plan-to-git)
// PURITY: SHELL
// EFFECT: Docker build downloads and installs a pinned Rust CLI from GitHub.
// INVARIANT: plan-to-git is available on PATH before Codex hooks or git post-push actions run.
// COMPLEXITY: O(network + cargo_build)
const renderDockerfilePlanToGit = (): string =>
`# Install plan-to-git for Codex plan capture and PR sync (issue #369)
RUN cargo install --git https://github.com/ProverCoderAI/plan-to-git --rev ${planToGitRevision} --locked --bins --root /usr/local \
&& /usr/local/bin/plan-to-git --help >/dev/null`

/**
* Renders the base image, package prelude, Rust toolchain, and browser module install.
* Renders the base image, package prelude, Rust toolchain, browser module, and plan sync CLI install.
*
* @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 and installs the Rust browser lifecycle + MCP CLIs.
* @invariant the returned fragment starts from the configured shared JS box image and installs the Rust browser lifecycle, MCP CLIs, and plan-to-git.
* @precondition docker-git generated entrypoint remains the container entrypoint.
* @postcondition the fragment keeps root available for setup and publishes both Rust browser binaries on PATH.
* @postcondition the fragment keeps root available for setup and publishes Rust helper binaries on PATH.
* @complexity O(1) time / O(1) space.
*/
export const renderDockerfilePrelude = (): string =>
[renderDockerfileBase(), renderDockerfileRustBrowserConnection()].join("\n\n")
[renderDockerfileBase(), renderDockerfileRustBrowserConnection(), renderDockerfilePlanToGit()].join("\n\n")
97 changes: 76 additions & 21 deletions packages/app/tests/docker-git/core-templates.test.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import { describe, expect, it } from "@effect/vitest"
import * as fc from "fast-check"

import { defaultTemplateConfig, planFiles, type TemplateConfig } from "../../test-adapters/core-templates.js"

Expand Down Expand Up @@ -41,6 +42,32 @@ const getGeneratedFile = (files: ReadonlyArray<PlannedFile>, relativePath: strin
const getGeneratedFilePaths = (files: ReadonlyArray<PlannedFile>): ReadonlyArray<string> =>
files.flatMap((file) => file._tag === "File" ? [file.relativePath] : [])

const generatedTemplateConfigArbitrary: fc.Arbitrary<TemplateConfig> = fc
.record({
gpu: fc.constantFrom<TemplateConfig["gpu"]>("none", "all"),
projectIndex: fc.integer({ min: 1, max: 100_000 }),
sshPort: fc.integer({ min: 1024, max: 65_535 }),
sshUserIndex: fc.integer({ min: 1, max: 100_000 })
})
.map(({ gpu, projectIndex, sshPort, sshUserIndex }) => {
const sshUser = `dev${sshUserIndex}`
const projectName = `repo-${projectIndex}`
const home = `/home/${sshUser}`

return makeTemplateConfig({
codexHome: `${home}/.codex`,
containerName: `dg-test-${projectIndex}`,
geminiHome: `${home}/.gemini`,
gpu,
grokHome: `${home}/.grok`,
serviceName: `dg-test-${projectIndex}`,
sshPort,
sshUser,
targetDir: `${home}/org/${projectName}`,
volumeName: `dg-test-${projectIndex}-home`
})
})

describe("app planFiles", () => {
it("includes Grok auth bootstrap wiring in the generated entrypoint", () => {
const files = planFiles(makeTemplateConfig())
Expand All @@ -52,29 +79,57 @@ describe("app planFiles", () => {
})

it("uses the Rust browser connection module when Playwright is enabled", () => {
const files = planFiles(makeTemplateConfig({ enableMcpPlaywright: true }))
const filePaths = getGeneratedFilePaths(files)
const dockerfile = getGeneratedFile(files, "Dockerfile")
const entrypoint = getGeneratedFile(files, "entrypoint.sh")
fc.assert(
fc.property(generatedTemplateConfigArbitrary, (generatedConfig) => {
const files = planFiles({ ...generatedConfig, enableMcpPlaywright: true })
const filePaths = getGeneratedFilePaths(files)
const dockerfile = getGeneratedFile(files, "Dockerfile")
const entrypoint = getGeneratedFile(files, "entrypoint.sh")

expect(filePaths).not.toContain("Dockerfile.browser")
expect(filePaths).not.toContain("docker-git-cdp-guard")
expect(filePaths).not.toContain("docker-git-browser-runtime.sh")
expect(dockerfile.contents).toContain(
"cargo install --git https://github.com/ProverCoderAI/rust-browser-connection"
expect(filePaths).not.toContain("Dockerfile.browser")
expect(filePaths).not.toContain("docker-git-cdp-guard")
expect(filePaths).not.toContain("docker-git-browser-runtime.sh")
expect(dockerfile.contents).toContain(
"cargo install --git https://github.com/ProverCoderAI/rust-browser-connection"
)
expect(dockerfile.contents).toContain(
"cargo install --git https://github.com/ProverCoderAI/plan-to-git --rev 06fe8bdf1d2e48a1f5a0218a3bb7af19e63deb5e --locked --bins --root /usr/local"
)
expect(dockerfile.contents).toContain("/usr/local/bin/plan-to-git --help >/dev/null")
expect(dockerfile.contents).toContain("make build-essential docker.io")
expect(dockerfile.contents).toContain("/usr/local/bin/browser-connection --version")
expect(dockerfile.contents).not.toContain("docker-git-playwright-mcp")
expect(entrypoint.contents).not.toContain("docker_git_start_rust_browser_connection")
expect(entrypoint.contents).not.toContain("start --project")
expect(entrypoint.contents).not.toContain("--no-start-browser")
expect(entrypoint.contents).toContain("docker_git_stop_playwright_browser()")
expect(entrypoint.contents).toContain("docker-git-browser-connection")
expect(entrypoint.contents).toContain("stop --project \"$project_container\"")
expect(entrypoint.contents).toContain("command = \"browser-connection\"")
expect(entrypoint.contents).toContain(
"args = [\"--project\", \"$DOCKER_GIT_BROWSER_PROJECT\", \"--network\", \"$DOCKER_GIT_BROWSER_NETWORK\"]"
)
expect(entrypoint.contents).toContain("plan-to-git sync")
expect(entrypoint.contents).toContain("plan-to-git hook --source codex")
expect(entrypoint.contents).toContain("CODEX_REQUIREMENTS_FILE=\"/etc/codex/requirements.toml\"")
expect(entrypoint.contents).toContain("managed_dir = \"/opt/docker-git/hooks\"")
expect(entrypoint.contents).toContain("[[hooks.UserPromptSubmit]]")
expect(entrypoint.contents).toContain("[[hooks.Stop]]")
expect(entrypoint.contents).toContain("command = \"/opt/docker-git/hooks/plan-to-git-codex-hook\"")
})
)
expect(dockerfile.contents).toContain("make build-essential docker.io")
expect(dockerfile.contents).toContain("/usr/local/bin/browser-connection --version")
expect(dockerfile.contents).not.toContain("docker-git-playwright-mcp")
expect(entrypoint.contents).not.toContain("docker_git_start_rust_browser_connection")
expect(entrypoint.contents).not.toContain("start --project")
expect(entrypoint.contents).not.toContain("--no-start-browser")
expect(entrypoint.contents).toContain("docker_git_stop_playwright_browser()")
expect(entrypoint.contents).toContain("docker-git-browser-connection")
expect(entrypoint.contents).toContain("stop --project \"$project_container\"")
expect(entrypoint.contents).toContain("command = \"browser-connection\"")
expect(entrypoint.contents).toContain(
"args = [\"--project\", \"$DOCKER_GIT_BROWSER_PROJECT\", \"--network\", \"$DOCKER_GIT_BROWSER_NETWORK\"]"
})

it("keeps plan-to-git state out of generated git and docker contexts", () => {
fc.assert(
fc.property(generatedTemplateConfigArbitrary, (config) => {
const files = planFiles(config)
const gitignore = getGeneratedFile(files, ".gitignore")
const dockerignore = getGeneratedFile(files, ".dockerignore")

expect(gitignore.contents).toContain(".agent-plan.json")
expect(dockerignore.contents).toContain(".agent-plan.json")
})
)
})
})
56 changes: 55 additions & 1 deletion packages/lib/src/core/templates-entrypoint/git-hooks.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,8 @@ const entrypointGitHooksTemplate = String
HOOKS_DIR="/opt/docker-git/hooks"
PRE_PUSH_HOOK="$HOOKS_DIR/pre-push"
POST_PUSH_ACTION="$HOOKS_DIR/post-push"
PLAN_TO_GIT_CODEX_HOOK="$HOOKS_DIR/plan-to-git-codex-hook"
CODEX_REQUIREMENTS_FILE="/etc/codex/requirements.toml"
mkdir -p "$HOOKS_DIR"

cat <<'EOF' > "$PRE_PUSH_HOOK"
Expand Down Expand Up @@ -136,13 +138,24 @@ cat <<'EOF' > "$POST_PUSH_ACTION"
#!/usr/bin/env bash
set -euo pipefail

# 5) Run session backup after successful push
# 5) Run plan sync and session backup after successful push
REPO_ROOT="${"${"}DOCKER_GIT_POST_PUSH_REPO_ROOT:-}"
if [[ -z "$REPO_ROOT" || ! -d "$REPO_ROOT" ]]; then
REPO_ROOT="$(git rev-parse --show-toplevel 2>/dev/null || pwd)"
fi
cd "$REPO_ROOT"

# CHANGE: sync captured Codex plans to the current branch PR after push.
# WHY: issue #369 requires the agent plan to be uploaded to PR discussion.
# REF: issue-369
if [ "${"${"}DOCKER_GIT_SKIP_PLAN_TO_GIT:-}" != "1" ]; then
if ! command -v plan-to-git >/dev/null 2>&1; then
echo "[plan-to-git] Error: plan-to-git not found" >&2
exit 1
fi
plan-to-git sync
fi

# CHANGE: keep post-push backup logic in a reusable action script
# WHY: git has no client-side post-push hook, so the global git wrapper
# invokes this after a successful git push
Expand All @@ -161,6 +174,47 @@ fi
EOF
chmod 0755 "$POST_PUSH_ACTION"

cat <<'EOF' > "$PLAN_TO_GIT_CODEX_HOOK"
#!/usr/bin/env bash
set -euo pipefail

if [ "${"${"}DOCKER_GIT_SKIP_PLAN_TO_GIT:-}" = "1" ]; then
exit 0
fi

if ! command -v plan-to-git >/dev/null 2>&1; then
echo "[plan-to-git] Error: plan-to-git not found" >&2
exit 1
fi

plan-to-git hook --source codex
EOF
chmod 0755 "$PLAN_TO_GIT_CODEX_HOOK"

mkdir -p "$(dirname "$CODEX_REQUIREMENTS_FILE")"
cat <<'EOF' > "$CODEX_REQUIREMENTS_FILE"
# docker-git managed Codex requirements

[features]
hooks = true

[hooks]
managed_dir = "/opt/docker-git/hooks"

[[hooks.UserPromptSubmit]]
[[hooks.UserPromptSubmit.hooks]]
type = "command"
command = "/opt/docker-git/hooks/plan-to-git-codex-hook"
statusMessage = "Capturing plan decision"

[[hooks.Stop]]
[[hooks.Stop.hooks]]
type = "command"
command = "/opt/docker-git/hooks/plan-to-git-codex-hook"
statusMessage = "Capturing agent plan"
EOF
chmod 0644 "$CODEX_REQUIREMENTS_FILE"

${renderEntrypointGitPostPushWrapperInstall()}

git config --system core.hooksPath "$HOOKS_DIR" || true
Expand Down
2 changes: 2 additions & 0 deletions packages/lib/src/core/templates.ts
Original file line number Diff line number Diff line change
Expand Up @@ -38,6 +38,7 @@ scripts/

# Volatile Codex artifacts (do not commit)
authorized_keys
.agent-plan.json
.orch/auth/codex/auth.json
.orch/auth/claude/
.orch/auth/codex/log/
Expand All @@ -49,6 +50,7 @@ authorized_keys
const renderDockerignore = (): string =>
`# docker-git build context
authorized_keys
.agent-plan.json
.orch/env/
.orch/auth/codex/
.orch/auth/claude/
Expand Down
25 changes: 21 additions & 4 deletions packages/lib/src/core/templates/dockerfile-prelude.ts
Original file line number Diff line number Diff line change
Expand Up @@ -83,16 +83,33 @@ RUN cargo install --git https://github.com/ProverCoderAI/rust-browser-connection
RUN printf "%s\\n" "ALL ALL=(ALL) NOPASSWD:ALL" > /etc/sudoers.d/zz-all \
&& chmod 0440 /etc/sudoers.d/zz-all`

const planToGitRevision = "06fe8bdf1d2e48a1f5a0218a3bb7af19e63deb5e"

// CHANGE: install plan-to-git in generated project containers.
// WHY: issue #369 requires agent plans to be captured and uploaded to pull requests.
// QUOTE(ТЗ): "Надо что бы у нас план загружался в PR"
// REF: issue-369
// SOURCE: https://github.com/ProverCoderAI/plan-to-git/tree/v0.19.0
// FORMAT THEOREM: image_build_success -> executable(/usr/local/bin/plan-to-git)
// PURITY: SHELL
// EFFECT: Docker build downloads and installs a pinned Rust CLI from GitHub.
// INVARIANT: plan-to-git is available on PATH before Codex hooks or git post-push actions run.
// COMPLEXITY: O(network + cargo_build)
const renderDockerfilePlanToGit = (): string =>
`# Install plan-to-git for Codex plan capture and PR sync (issue #369)
RUN cargo install --git https://github.com/ProverCoderAI/plan-to-git --rev ${planToGitRevision} --locked --bins --root /usr/local \
&& /usr/local/bin/plan-to-git --help >/dev/null`

/**
* Renders the base image, package prelude, Rust toolchain, and browser module install.
* Renders the base image, package prelude, Rust toolchain, browser module, and plan sync CLI install.
*
* @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 and installs the Rust browser lifecycle + MCP CLIs.
* @invariant the returned fragment starts from the configured shared JS box image and installs the Rust browser lifecycle, MCP CLIs, and plan-to-git.
* @precondition docker-git generated entrypoint remains the container entrypoint.
* @postcondition the fragment keeps root available for setup and publishes both Rust browser binaries on PATH.
* @postcondition the fragment keeps root available for setup and publishes Rust helper binaries on PATH.
* @complexity O(1) time / O(1) space.
*/
export const renderDockerfilePrelude = (): string =>
[renderDockerfileBase(), renderDockerfileRustBrowserConnection()].join("\n\n")
[renderDockerfileBase(), renderDockerfileRustBrowserConnection(), renderDockerfilePlanToGit()].join("\n\n")
Loading
Loading