Skip to content

Commit e187543

Browse files
authored
Merge pull request #1216 from getlarge/fix/gh-pem-env-normalization
fix(ci): normalize GitHub App PEM before agent materialization
2 parents a1689e1 + 90ddbca commit e187543

5 files changed

Lines changed: 154 additions & 2 deletions

File tree

.github/actions/legreffier-run-task/action.yml

Lines changed: 21 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -74,6 +74,26 @@ runs:
7474
set -euo pipefail
7575
: "${MOLTNET_AGENT_NAME:?MOLTNET_AGENT_NAME must be set at the job level}"
7676
AGENT_DIR="$GITHUB_WORKSPACE/.moltnet/$MOLTNET_AGENT_NAME"
77+
if [ -n "${MOLTNET_GITHUB_APP_PRIVATE_KEY:-}" ]; then
78+
pem_normalized="${MOLTNET_GITHUB_APP_PRIVATE_KEY#"${MOLTNET_GITHUB_APP_PRIVATE_KEY%%[![:space:]]*}"}"
79+
pem_normalized="${pem_normalized%"${pem_normalized##*[![:space:]]}"}"
80+
if [[ "$pem_normalized" == \"*\" && "$pem_normalized" == *\" ]]; then
81+
pem_normalized="${pem_normalized#\"}"
82+
pem_normalized="${pem_normalized%\"}"
83+
elif [[ "$pem_normalized" == \'*\' ]]; then
84+
pem_normalized="${pem_normalized#\'}"
85+
pem_normalized="${pem_normalized%\'}"
86+
fi
87+
pem_normalized="${pem_normalized//$'\r\n'/$'\n'}"
88+
pem_normalized="${pem_normalized//$'\r'/$'\n'}"
89+
pem_normalized="${pem_normalized//\\r\\n/$'\n'}"
90+
pem_normalized="${pem_normalized//\\n/$'\n'}"
91+
pem_normalized="${pem_normalized//\\r/$'\n'}"
92+
if [[ "$pem_normalized" == *$'\n'* && "$pem_normalized" != *$'\n' ]]; then
93+
pem_normalized+=$'\n'
94+
fi
95+
export MOLTNET_GITHUB_APP_PRIVATE_KEY="$pem_normalized"
96+
fi
7797
# Idempotent: a caller workflow that needs the agent dir earlier
7898
# (e.g. to run an imposer script) may already have materialized it.
7999
if [ ! -f "$AGENT_DIR/moltnet.json" ]; then
@@ -104,7 +124,7 @@ runs:
104124
# past a regex check).
105125
if ! openssl rsa -in "$PEM" -noout -check >/dev/null 2>&1 \
106126
&& ! openssl pkey -in "$PEM" -noout >/dev/null 2>&1; then
107-
echo "::error::GitHub App PEM at $PEM failed openssl parse. This usually means MOLTNET_GITHUB_APP_PRIVATE_KEY was passed through a composite-action input, which collapses newlines. Set it as a JOB env var instead."
127+
echo "::error::GitHub App PEM at $PEM failed openssl parse. Common causes: MOLTNET_GITHUB_APP_PRIVATE_KEY passed through a composite-action input (newline collapse), or the stored secret contains literal \\n escapes / surrounding quotes instead of raw PEM lines. Prefer a JOB env var and upload the raw PEM block as the secret value."
108128
exit 1
109129
fi
110130
echo "::notice::GitHub App PEM at $PEM parses cleanly."

apps/moltnet-cli/config_init_from_env.go

Lines changed: 28 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@ import (
44
"fmt"
55
"os"
66
"path/filepath"
7+
"strconv"
78
"strings"
89
"time"
910

@@ -26,6 +27,30 @@ func getenv(key string, fileVars map[string]string, override bool) string {
2627
return fileVars[key]
2728
}
2829

30+
func normalizePEMEnvValue(raw string) string {
31+
value := strings.TrimSpace(raw)
32+
// Best effort: accept a full Go-style quoted string when present, but
33+
// fall back to a simple outer-quote strip so dotenv-style PEM payloads
34+
// normalize even when they are not valid Go string literals.
35+
if unquoted, err := strconv.Unquote(value); err == nil {
36+
value = unquoted
37+
} else if len(value) >= 2 {
38+
if (value[0] == '"' && value[len(value)-1] == '"') ||
39+
(value[0] == '\'' && value[len(value)-1] == '\'') {
40+
value = value[1 : len(value)-1]
41+
}
42+
}
43+
value = strings.ReplaceAll(value, "\r\n", "\n")
44+
value = strings.ReplaceAll(value, "\r", "\n")
45+
value = strings.ReplaceAll(value, "\\r\\n", "\n")
46+
value = strings.ReplaceAll(value, "\\n", "\n")
47+
value = strings.ReplaceAll(value, "\\r", "\n")
48+
if strings.Contains(value, "\n") && !strings.HasSuffix(value, "\n") {
49+
value += "\n"
50+
}
51+
return value
52+
}
53+
2954
// runConfigInitFromEnvCmd reconstructs an agent's .moltnet/<agent>/ directory
3055
// from environment variables. Designed for ephemeral CI/cloud environments
3156
// (e.g. Claude Code web) where legreffier init cannot run interactively.
@@ -129,7 +154,9 @@ func runConfigInitFromEnvCmd(dir, agentName string, skipGit bool, envFile string
129154
// Optional GitHub App section
130155
ghAppID := getenv("MOLTNET_GITHUB_APP_ID", fileVars, override)
131156
ghInstallID := getenv("MOLTNET_GITHUB_APP_INSTALLATION_ID", fileVars, override)
132-
ghAppPEM := getenv("MOLTNET_GITHUB_APP_PRIVATE_KEY", fileVars, override)
157+
ghAppPEM := normalizePEMEnvValue(
158+
getenv("MOLTNET_GITHUB_APP_PRIVATE_KEY", fileVars, override),
159+
)
133160
ghAppSlug := getenv("MOLTNET_GITHUB_APP_SLUG", fileVars, override)
134161
if ghAppID != "" && ghInstallID != "" && ghAppPEM != "" {
135162
pemPath := filepath.Join(agentDir, ghAppSlug+".pem")

apps/moltnet-cli/config_init_from_env_test.go

Lines changed: 81 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -584,3 +584,84 @@ func TestConfigInitFromEnvWithGitHubApp(t *testing.T) {
584584
t.Errorf("env file missing installation ID, got:\n%s", envContent)
585585
}
586586
}
587+
588+
func TestConfigInitFromEnvNormalizesGitHubAppPEM(t *testing.T) {
589+
tmpDir := t.TempDir()
590+
const escapedPEM = "\"-----BEGIN RSA PRIVATE KEY-----\\r\\nline-one\\nline-two\\r\\n-----END RSA PRIVATE KEY-----\""
591+
592+
t.Setenv("MOLTNET_IDENTITY_ID", "gh-app-identity")
593+
t.Setenv("MOLTNET_CLIENT_ID", "gh-app-client-id")
594+
t.Setenv("MOLTNET_CLIENT_SECRET", "gh-app-client-secret")
595+
t.Setenv("MOLTNET_PUBLIC_KEY", testPublicKey)
596+
t.Setenv("MOLTNET_PRIVATE_KEY", testPrivateKey)
597+
t.Setenv("MOLTNET_FINGERPRINT", "SHA256:ghappfingerprint")
598+
t.Setenv("MOLTNET_GITHUB_APP_ID", "123456")
599+
t.Setenv("MOLTNET_GITHUB_APP_INSTALLATION_ID", "78901234")
600+
t.Setenv("MOLTNET_GITHUB_APP_PRIVATE_KEY", escapedPEM)
601+
t.Setenv("MOLTNET_GITHUB_APP_SLUG", "my-gh-app")
602+
603+
root := NewRootCmd("test", "")
604+
_, _, err := executeCommand(root, "config", "init-from-env",
605+
"--agent", "gh-app-agent",
606+
"--dir", tmpDir,
607+
"--skip-git",
608+
)
609+
if err != nil {
610+
t.Fatalf("unexpected error: %v", err)
611+
}
612+
613+
pemPath := filepath.Join(tmpDir, ".moltnet", "gh-app-agent", "my-gh-app.pem")
614+
pemData, err := os.ReadFile(pemPath)
615+
if err != nil {
616+
t.Fatalf("failed to read PEM: %v", err)
617+
}
618+
619+
const want = "-----BEGIN RSA PRIVATE KEY-----\nline-one\nline-two\n-----END RSA PRIVATE KEY-----\n"
620+
if string(pemData) != want {
621+
t.Fatalf("unexpected PEM contents:\nwant:\n%q\ngot:\n%q", want, string(pemData))
622+
}
623+
}
624+
625+
func TestNormalizePEMEnvValue(t *testing.T) {
626+
t.Parallel()
627+
628+
tests := []struct {
629+
name string
630+
input string
631+
want string
632+
}{
633+
{
634+
name: "raw multiline gets trailing newline",
635+
input: "-----BEGIN KEY-----\nline\n-----END KEY-----",
636+
want: "-----BEGIN KEY-----\nline\n-----END KEY-----\n",
637+
},
638+
{
639+
name: "single quoted escaped pem",
640+
input: "'-----BEGIN KEY-----\\r\\nline\\n-----END KEY-----'",
641+
want: "-----BEGIN KEY-----\nline\n-----END KEY-----\n",
642+
},
643+
{
644+
name: "actual crlf bytes normalize",
645+
input: "-----BEGIN KEY-----\r\nline\r\n-----END KEY-----\r\n",
646+
want: "-----BEGIN KEY-----\nline\n-----END KEY-----\n",
647+
},
648+
{
649+
name: "bare cr bytes normalize",
650+
input: "-----BEGIN KEY-----\rline\r-----END KEY-----\r",
651+
want: "-----BEGIN KEY-----\nline\n-----END KEY-----\n",
652+
},
653+
{
654+
name: "already normalized is idempotent",
655+
input: "-----BEGIN KEY-----\nline\n-----END KEY-----\n",
656+
want: "-----BEGIN KEY-----\nline\n-----END KEY-----\n",
657+
},
658+
}
659+
660+
for _, tt := range tests {
661+
t.Run(tt.name, func(t *testing.T) {
662+
if got := normalizePEMEnvValue(tt.input); got != tt.want {
663+
t.Fatalf("normalizePEMEnvValue() mismatch:\nwant: %q\ngot: %q", tt.want, got)
664+
}
665+
})
666+
}
667+
}

docs/reference/agent-configuration.md

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -167,6 +167,10 @@ The output contains all `MOLTNET_*` variables needed to reconstruct the agent
167167
directory. Store the file securely; it contains private keys and OAuth2
168168
secrets.
169169

170+
When copying `MOLTNET_GITHUB_APP_PRIVATE_KEY` into a GitHub Actions secret,
171+
paste the raw PEM block as the secret value. Do not keep the surrounding
172+
dotenv quotes and do not convert newlines to literal `\n` sequences.
173+
170174
### Reconstruct agent config
171175

172176
Set the `MOLTNET_*` variables in the target environment, then run:

packages/agent-daemon-action/action.yml

Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -210,6 +210,26 @@ runs:
210210
echo "::error::MOLTNET_AGENT_NAME is required (set as env on the caller step or pass agent-name input)" >&2
211211
exit 1
212212
fi
213+
if [ -n "${MOLTNET_GITHUB_APP_PRIVATE_KEY:-}" ]; then
214+
pem_normalized="${MOLTNET_GITHUB_APP_PRIVATE_KEY#"${MOLTNET_GITHUB_APP_PRIVATE_KEY%%[![:space:]]*}"}"
215+
pem_normalized="${pem_normalized%"${pem_normalized##*[![:space:]]}"}"
216+
if [[ "$pem_normalized" == \"*\" && "$pem_normalized" == *\" ]]; then
217+
pem_normalized="${pem_normalized#\"}"
218+
pem_normalized="${pem_normalized%\"}"
219+
elif [[ "$pem_normalized" == \'*\' ]]; then
220+
pem_normalized="${pem_normalized#\'}"
221+
pem_normalized="${pem_normalized%\'}"
222+
fi
223+
pem_normalized="${pem_normalized//$'\r\n'/$'\n'}"
224+
pem_normalized="${pem_normalized//$'\r'/$'\n'}"
225+
pem_normalized="${pem_normalized//\\r\\n/$'\n'}"
226+
pem_normalized="${pem_normalized//\\n/$'\n'}"
227+
pem_normalized="${pem_normalized//\\r/$'\n'}"
228+
if [[ "$pem_normalized" == *$'\n'* && "$pem_normalized" != *$'\n' ]]; then
229+
pem_normalized+=$'\n'
230+
fi
231+
export MOLTNET_GITHUB_APP_PRIVATE_KEY="$pem_normalized"
232+
fi
213233
# Reconstruct the agent's .moltnet/<agent>/ tree (moltnet.json,
214234
# SSH keys, gitconfig, optional GitHub App PEM) from the
215235
# caller-supplied MOLTNET_* env vars using the canonical CLI

0 commit comments

Comments
 (0)