feat(provisioner): inject GIT_ASKPASS for env-driven HTTPS git auth #1525
@@ -17,6 +17,17 @@ var gitIdentitySlugPattern = regexp.MustCompile(`[^a-z0-9]+`)
|
||||
// docs/authorship.md (when it exists).
|
||||
const gitIdentityEmailDomain = "agents.moleculesai.app"
|
||||
|
||||
// gitAskpassHelperPath is the in-container path of the askpass helper
|
||||
// installed by every workspace runtime image (workspace/Dockerfile in
|
||||
// molecule-core; scripts/git-askpass.sh → /usr/local/bin/molecule-askpass
|
||||
// in each external template-* repo). The helper reads GIT_HTTP_USERNAME
|
||||
// / GIT_HTTP_PASSWORD (falling back to GITEA_USER / GITEA_TOKEN) from
|
||||
// env and emits them on the git credential-prompt protocol. Setting
|
||||
// GIT_ASKPASS to this path is what wires container-side HTTPS git auth
|
||||
// to the persona credentials already arriving via workspace_secrets,
|
||||
// with no on-disk .gitconfig / .git-credentials mutation required.
|
||||
const gitAskpassHelperPath = "/usr/local/bin/molecule-askpass"
|
||||
|
||||
// applyAgentGitIdentity sets GIT_AUTHOR_* / GIT_COMMITTER_* env vars so
|
||||
// every commit from this workspace container carries a distinct author
|
||||
// in `git log` and `git blame`. Git reads these env vars before falling
|
||||
@@ -50,6 +61,34 @@ func applyAgentGitIdentity(envVars map[string]string, workspaceName string) {
|
||||
setIfEmpty(envVars, "GIT_AUTHOR_EMAIL", authorEmail)
|
||||
setIfEmpty(envVars, "GIT_COMMITTER_NAME", authorName)
|
||||
setIfEmpty(envVars, "GIT_COMMITTER_EMAIL", authorEmail)
|
||||
|
||||
applyGitAskpass(envVars)
|
||||
}
|
||||
|
||||
// applyGitAskpass points git at the in-image askpass helper so that any
|
||||
// HTTPS git operation against a remote without a pre-configured
|
||||
// credential.helper picks up the persona credentials already present in
|
||||
// the container env (GIT_HTTP_USERNAME / GIT_HTTP_PASSWORD, or
|
||||
// GITEA_USER / GITEA_TOKEN as fallback — the latter pair is what
|
||||
// loadPersonaEnvFile delivers from the operator-host bootstrap kit).
|
||||
//
|
||||
// Idempotent: if GIT_ASKPASS is already set (e.g. by an operator-
|
||||
// supplied workspace_secret or an env-mutator plugin), the existing
|
||||
// value wins. This lets a workspace opt out by setting GIT_ASKPASS=""
|
||||
// or pointing at a different helper.
|
||||
//
|
||||
// No vendor-specific behaviour lives in this function — the host the
|
||||
// credentials apply to is determined entirely by the deployer choosing
|
||||
// when to populate GIT_HTTP_USERNAME / GIT_HTTP_PASSWORD (or
|
||||
// GITEA_USER / GITEA_TOKEN). The helper script itself is generic and
|
||||
// has no hardcoded hostnames, so it's safe to ship inside the
|
||||
// open-source workspace template images alongside the platform-managed
|
||||
// claude-code image.
|
||||
func applyGitAskpass(envVars map[string]string) {
|
||||
if envVars == nil {
|
||||
return
|
||||
}
|
||||
setIfEmpty(envVars, "GIT_ASKPASS", gitAskpassHelperPath)
|
||||
}
|
||||
|
||||
// slugifyForEmail collapses a workspace name to a safe email localpart:
|
||||
|
||||
@@ -75,6 +75,53 @@ func TestApplyAgentGitIdentity_NilMapIsSafe(t *testing.T) {
|
||||
applyAgentGitIdentity(nil, "PM")
|
||||
}
|
||||
|
||||
func TestApplyAgentGitIdentity_SetsGitAskpass(t *testing.T) {
|
||||
// GIT_ASKPASS is what wires container-side HTTPS git auth to the
|
||||
// persona credentials (GITEA_USER/GITEA_TOKEN, etc.) that
|
||||
// loadPersonaEnvFile delivers via workspace_secrets. Without this,
|
||||
// `git push` inside the container would fall through to interactive
|
||||
// prompts (impossible) or a missing credential.helper (401).
|
||||
env := map[string]string{}
|
||||
applyAgentGitIdentity(env, "Frontend Engineer")
|
||||
if env["GIT_ASKPASS"] != "/usr/local/bin/molecule-askpass" {
|
||||
t.Errorf("GIT_ASKPASS: got %q, want %q",
|
||||
env["GIT_ASKPASS"], "/usr/local/bin/molecule-askpass")
|
||||
}
|
||||
}
|
||||
|
||||
func TestApplyAgentGitIdentity_RespectsAskpassOverride(t *testing.T) {
|
||||
// A workspace_secret or env-mutator plugin must be able to point at
|
||||
// a custom askpass helper without us clobbering it. Symmetric with
|
||||
// the GIT_AUTHOR_NAME override test above.
|
||||
env := map[string]string{
|
||||
"GIT_ASKPASS": "/opt/custom/askpass",
|
||||
}
|
||||
applyAgentGitIdentity(env, "Backend Engineer")
|
||||
if env["GIT_ASKPASS"] != "/opt/custom/askpass" {
|
||||
t.Errorf("GIT_ASKPASS should not be overwritten, got %q", env["GIT_ASKPASS"])
|
||||
}
|
||||
}
|
||||
|
||||
func TestApplyAgentGitIdentity_AskpassSkippedOnEmptyName(t *testing.T) {
|
||||
// The empty-name early-return covers GIT_ASKPASS too — a provisioning
|
||||
// glitch that dropped the workspace name shouldn't half-configure the
|
||||
// container (identity vars empty but askpass wired). All-or-nothing.
|
||||
env := map[string]string{}
|
||||
applyAgentGitIdentity(env, "")
|
||||
if _, ok := env["GIT_ASKPASS"]; ok {
|
||||
t.Errorf("empty name should not set GIT_ASKPASS, got %q", env["GIT_ASKPASS"])
|
||||
}
|
||||
}
|
||||
|
||||
func TestApplyGitAskpass_NilMapIsSafe(t *testing.T) {
|
||||
defer func() {
|
||||
if r := recover(); r != nil {
|
||||
t.Errorf("applyGitAskpass panicked on nil map: %v", r)
|
||||
}
|
||||
}()
|
||||
applyGitAskpass(nil)
|
||||
}
|
||||
|
||||
func TestSlugifyForEmail(t *testing.T) {
|
||||
cases := []struct {
|
||||
in, want string
|
||||
|
||||
@@ -218,6 +218,14 @@ func loadWorkspaceEnv(orgBaseDir, filesDir string) map[string]string {
|
||||
// check, or when the env file does not exist (workspaces without a role —
|
||||
// or running on hosts that don't ship the bootstrap dir — keep their old
|
||||
// behavior).
|
||||
//
|
||||
// Token-file fallback: the newer prod-team personas (agent-dev-a,
|
||||
// agent-dev-b, agent-pm) ship `token` + `universal-auth.env` only — no
|
||||
// legacy plaintext `env` file. When the env-file load produces zero rows,
|
||||
// loadPersonaTokenFile fills in GITEA_TOKEN / GITEA_USER / GITEA_USER_EMAIL
|
||||
// from the token file so the GIT_ASKPASS helper has something to emit.
|
||||
// The env-file form remains authoritative when present (it may carry
|
||||
// richer rows like GITEA_TOKEN_SCOPES / GITEA_SSH_KEY_PATH).
|
||||
func loadPersonaEnvFile(role string, out map[string]string) {
|
||||
if !isSafeRoleName(role) {
|
||||
if role != "" {
|
||||
@@ -229,7 +237,61 @@ func loadPersonaEnvFile(role string, out map[string]string) {
|
||||
if root == "" {
|
||||
root = "/etc/molecule-bootstrap/personas"
|
||||
}
|
||||
before := len(out)
|
||||
parseEnvFile(filepath.Join(root, role, "env"), out)
|
||||
if len(out) == before {
|
||||
// No env-file rows landed (file absent, or present-but-empty).
|
||||
// Try the token-only persona shape used by the prod-team
|
||||
// identities. Existing keys in out are preserved.
|
||||
loadPersonaTokenFile(role, out)
|
||||
}
|
||||
}
|
||||
|
||||
// loadPersonaTokenFile populates GITEA_TOKEN / GITEA_USER / GITEA_USER_EMAIL
|
||||
// from a persona dir that ships only the bare `token` file — the shape used
|
||||
// by the production agent personas (agent-dev-a, agent-dev-b, agent-pm).
|
||||
// Those dirs do not carry an `env` file because their non-Gitea creds come
|
||||
// from Infisical Universal Auth at runtime (universal-auth.env), so the
|
||||
// historical loadPersonaEnvFile path silently no-ops on them.
|
||||
//
|
||||
// File layout: $MOLECULE_PERSONA_ROOT/<role>/token (mode 600, plain text).
|
||||
// The token contents become GITEA_TOKEN (whitespace-trimmed); the role
|
||||
// name becomes GITEA_USER; GITEA_USER_EMAIL is synthesised as
|
||||
// <role>@<gitIdentityEmailDomain> to match the email shape that
|
||||
// applyAgentGitIdentity uses for its slug-derived authorship addresses.
|
||||
//
|
||||
// Silent no-op when the role fails the safe-segment check, when the
|
||||
// token file does not exist, or when its contents are empty after
|
||||
// trimming. Existing keys in out are not overwritten — the caller's
|
||||
// later .env layers and any prior loadPersonaEnvFile rows always win.
|
||||
func loadPersonaTokenFile(role string, out map[string]string) {
|
||||
if out == nil {
|
||||
return
|
||||
}
|
||||
if !isSafeRoleName(role) {
|
||||
return
|
||||
}
|
||||
root := os.Getenv("MOLECULE_PERSONA_ROOT")
|
||||
if root == "" {
|
||||
root = "/etc/molecule-bootstrap/personas"
|
||||
}
|
||||
data, err := os.ReadFile(filepath.Join(root, role, "token"))
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
token := strings.TrimSpace(string(data))
|
||||
if token == "" {
|
||||
return
|
||||
}
|
||||
if _, ok := out["GITEA_TOKEN"]; !ok {
|
||||
out["GITEA_TOKEN"] = token
|
||||
}
|
||||
if _, ok := out["GITEA_USER"]; !ok {
|
||||
out["GITEA_USER"] = role
|
||||
}
|
||||
if _, ok := out["GITEA_USER_EMAIL"]; !ok {
|
||||
out["GITEA_USER_EMAIL"] = role + "@" + gitIdentityEmailDomain
|
||||
}
|
||||
}
|
||||
|
||||
// isSafeRoleName accepts a single path segment of [A-Za-z0-9_-]+. Rejects
|
||||
|
||||
@@ -164,3 +164,181 @@ func TestIsSafeRoleName_Acceptance(t *testing.T) {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// TestLoadPersonaTokenFile_TokenOnlyPersona: the prod-team personas
|
||||
// (agent-dev-a / agent-dev-b / agent-pm) ship `token` only — no `env`
|
||||
// file. loadPersonaEnvFile's fallback path must populate GITEA_TOKEN /
|
||||
// GITEA_USER / GITEA_USER_EMAIL from the token contents + role name so
|
||||
// the GIT_ASKPASS helper has something to emit.
|
||||
func TestLoadPersonaTokenFile_TokenOnlyPersona(t *testing.T) {
|
||||
root := t.TempDir()
|
||||
roleDir := filepath.Join(root, "agent-dev-a")
|
||||
if err := os.MkdirAll(roleDir, 0o755); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := os.WriteFile(filepath.Join(roleDir, "token"),
|
||||
[]byte("token-bytes-redacted\n"), 0o600); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
t.Setenv("MOLECULE_PERSONA_ROOT", root)
|
||||
|
||||
out := map[string]string{}
|
||||
loadPersonaEnvFile("agent-dev-a", out)
|
||||
|
||||
want := map[string]string{
|
||||
"GITEA_TOKEN": "token-bytes-redacted",
|
||||
"GITEA_USER": "agent-dev-a",
|
||||
"GITEA_USER_EMAIL": "agent-dev-a@" + gitIdentityEmailDomain,
|
||||
}
|
||||
if len(out) != len(want) {
|
||||
t.Fatalf("got %d keys, want %d: %#v", len(out), len(want), out)
|
||||
}
|
||||
for k, v := range want {
|
||||
if out[k] != v {
|
||||
t.Errorf("out[%q] = %q; want %q", k, out[k], v)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// TestLoadPersonaTokenFile_EnvFileWins: when BOTH an env file and a
|
||||
// token file exist in the same persona dir, the env file is the more-
|
||||
// specific declaration and wins outright — the fallback must not fire
|
||||
// at all. This pins precedence so a persona later migrated to the
|
||||
// richer env-file form (carrying GITEA_TOKEN_SCOPES / GITEA_SSH_KEY_PATH)
|
||||
// doesn't get its token silently overridden by the fallback.
|
||||
func TestLoadPersonaTokenFile_EnvFileWins(t *testing.T) {
|
||||
root := t.TempDir()
|
||||
roleDir := filepath.Join(root, "agent-dev-b")
|
||||
if err := os.MkdirAll(roleDir, 0o755); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
envBody := "GITEA_USER=env-form-user\nGITEA_TOKEN=env-form-token\n" +
|
||||
"GITEA_USER_EMAIL=env-form@example.invalid\nGITEA_TOKEN_SCOPES=write:repository\n"
|
||||
if err := os.WriteFile(filepath.Join(roleDir, "env"), []byte(envBody), 0o600); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := os.WriteFile(filepath.Join(roleDir, "token"),
|
||||
[]byte("token-form-token\n"), 0o600); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
t.Setenv("MOLECULE_PERSONA_ROOT", root)
|
||||
|
||||
out := map[string]string{}
|
||||
loadPersonaEnvFile("agent-dev-b", out)
|
||||
|
||||
if out["GITEA_USER"] != "env-form-user" {
|
||||
t.Errorf("env file should win for GITEA_USER; got %q", out["GITEA_USER"])
|
||||
}
|
||||
if out["GITEA_TOKEN"] != "env-form-token" {
|
||||
t.Errorf("env file should win for GITEA_TOKEN; got %q", out["GITEA_TOKEN"])
|
||||
}
|
||||
if out["GITEA_USER_EMAIL"] != "env-form@example.invalid" {
|
||||
t.Errorf("env file should win for GITEA_USER_EMAIL; got %q", out["GITEA_USER_EMAIL"])
|
||||
}
|
||||
if out["GITEA_TOKEN_SCOPES"] != "write:repository" {
|
||||
t.Errorf("env file extras must be preserved; got GITEA_TOKEN_SCOPES=%q", out["GITEA_TOKEN_SCOPES"])
|
||||
}
|
||||
}
|
||||
|
||||
// TestLoadPersonaTokenFile_NeitherFile: persona dir exists but ships
|
||||
// neither env nor token — silent no-op. This is the legitimate case
|
||||
// for a partially-provisioned persona during bootstrap; callers expect
|
||||
// an empty map, no error, no log noise.
|
||||
func TestLoadPersonaTokenFile_NeitherFile(t *testing.T) {
|
||||
root := t.TempDir()
|
||||
roleDir := filepath.Join(root, "agent-pm")
|
||||
if err := os.MkdirAll(roleDir, 0o755); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
t.Setenv("MOLECULE_PERSONA_ROOT", root)
|
||||
|
||||
out := map[string]string{}
|
||||
loadPersonaEnvFile("agent-pm", out)
|
||||
if len(out) != 0 {
|
||||
t.Errorf("expected empty out when neither env nor token exists; got %#v", out)
|
||||
}
|
||||
}
|
||||
|
||||
// TestLoadPersonaTokenFile_EmptyToken: a token file with only
|
||||
// whitespace must be treated as absent — never emit
|
||||
// GITEA_TOKEN="" / GITEA_USER=<role> / GITEA_USER_EMAIL=<role>@... because
|
||||
// that would set GITEA_USER without a usable token, and the askpass
|
||||
// helper would then prompt with an empty password. Silent no-op is the
|
||||
// correct behavior — let downstream auth fall through to its existing
|
||||
// "no credentials available" path.
|
||||
func TestLoadPersonaTokenFile_EmptyToken(t *testing.T) {
|
||||
root := t.TempDir()
|
||||
roleDir := filepath.Join(root, "agent-dev-a")
|
||||
if err := os.MkdirAll(roleDir, 0o755); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
// Whitespace-only contents: spaces, tabs, newlines.
|
||||
if err := os.WriteFile(filepath.Join(roleDir, "token"),
|
||||
[]byte(" \t\n \n"), 0o600); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
t.Setenv("MOLECULE_PERSONA_ROOT", root)
|
||||
|
||||
out := map[string]string{}
|
||||
loadPersonaEnvFile("agent-dev-a", out)
|
||||
if len(out) != 0 {
|
||||
t.Errorf("expected empty out when token file is whitespace-only; got %#v", out)
|
||||
}
|
||||
}
|
||||
|
||||
// TestLoadPersonaTokenFile_TrimsWhitespace: tokens shipped from the
|
||||
// operator-host bootstrap kit may have a trailing newline (the
|
||||
// canonical `printf "%s\n" "$token" > token` shape). The fallback must
|
||||
// trim leading + trailing whitespace so the askpass helper emits the
|
||||
// raw token bytes — Gitea's PAT validator rejects tokens with embedded
|
||||
// whitespace.
|
||||
func TestLoadPersonaTokenFile_TrimsWhitespace(t *testing.T) {
|
||||
root := t.TempDir()
|
||||
roleDir := filepath.Join(root, "agent-dev-b")
|
||||
if err := os.MkdirAll(roleDir, 0o755); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := os.WriteFile(filepath.Join(roleDir, "token"),
|
||||
[]byte("\n raw-token-bytes \n\n"), 0o600); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
t.Setenv("MOLECULE_PERSONA_ROOT", root)
|
||||
|
||||
out := map[string]string{}
|
||||
loadPersonaEnvFile("agent-dev-b", out)
|
||||
if out["GITEA_TOKEN"] != "raw-token-bytes" {
|
||||
t.Errorf("token whitespace not trimmed; got %q", out["GITEA_TOKEN"])
|
||||
}
|
||||
}
|
||||
|
||||
// TestLoadPersonaTokenFile_RejectsUnsafeRole: defense-in-depth — even
|
||||
// in the fallback path, role names that fail isSafeRoleName must not
|
||||
// touch the filesystem. Mirrors TestLoadPersonaEnvFile_RejectsTraversal.
|
||||
func TestLoadPersonaTokenFile_RejectsUnsafeRole(t *testing.T) {
|
||||
root := t.TempDir()
|
||||
// Plant a token at /tmp/.../token so a bad traversal would reach it.
|
||||
if err := os.WriteFile(filepath.Join(root, "token"),
|
||||
[]byte("stolen-token\n"), 0o600); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
t.Setenv("MOLECULE_PERSONA_ROOT", filepath.Join(root, "personas"))
|
||||
|
||||
for _, bad := range []string{"..", "../personas", "/abs", "with/slash", "."} {
|
||||
out := map[string]string{}
|
||||
loadPersonaTokenFile(bad, out)
|
||||
if len(out) != 0 {
|
||||
t.Errorf("role %q should have been rejected; got %#v", bad, out)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// TestLoadPersonaTokenFile_NilMapSafe: callers pass a fresh map in
|
||||
// practice, but defense-in-depth — a nil map must not panic.
|
||||
func TestLoadPersonaTokenFile_NilMapSafe(t *testing.T) {
|
||||
defer func() {
|
||||
if r := recover(); r != nil {
|
||||
t.Fatalf("nil map caused panic: %v", r)
|
||||
}
|
||||
}()
|
||||
loadPersonaTokenFile("agent-dev-a", nil)
|
||||
}
|
||||
|
||||
@@ -62,6 +62,19 @@ RUN chmod +x ./scripts/molecule-git-token-helper.sh
|
||||
COPY scripts/molecule-gh-token-refresh.sh ./scripts/
|
||||
RUN chmod +x ./scripts/molecule-gh-token-refresh.sh
|
||||
|
||||
# Generic GIT_ASKPASS helper. Reads HTTPS Basic-Auth credentials from env
|
||||
# vars (GIT_HTTP_USERNAME / GIT_HTTP_PASSWORD, with GITEA_USER / GITEA_TOKEN
|
||||
# as fallback) and emits them on the git credential-prompt protocol so
|
||||
# container-side `git` can authenticate to any private HTTPS remote
|
||||
# without on-disk .gitconfig / .git-credentials mutation. The platform
|
||||
# provisioner sets GIT_ASKPASS=/usr/local/bin/molecule-askpass via
|
||||
# applyAgentGitIdentity (workspace-server/internal/handlers/agent_git_identity.go).
|
||||
# Filename is the only project-specific marker; the script body contains
|
||||
# no vendor literals and is identical to the script shipped in each
|
||||
# open-source workspace template (scripts/git-askpass.sh).
|
||||
COPY scripts/molecule-askpass /usr/local/bin/molecule-askpass
|
||||
RUN chmod +x /usr/local/bin/molecule-askpass
|
||||
|
||||
# Dirs and permissions
|
||||
RUN mkdir -p /workspace /plugins /home/agent/.claude /home/agent/.config /home/agent/.local \
|
||||
/home/agent/.molecule-token-cache && \
|
||||
|
||||
Executable
+35
@@ -0,0 +1,35 @@
|
||||
#!/bin/sh
|
||||
# git-askpass helper. Reads HTTPS Basic-Auth credentials from env vars so
|
||||
# the deployer can wire git authentication for any private remote without
|
||||
# touching ~/.gitconfig or ~/.git-credentials inside the container.
|
||||
#
|
||||
# Wire-up: set GIT_ASKPASS=/usr/local/bin/molecule-askpass in the
|
||||
# container env, then export GIT_HTTP_USERNAME / GIT_HTTP_PASSWORD (or the
|
||||
# GITEA_USER / GITEA_TOKEN fallback pair). When git encounters an HTTPS
|
||||
# auth challenge on a host that has no credential.helper configured for
|
||||
# it, git invokes GIT_ASKPASS twice — once with a "Username for ..."
|
||||
# prompt and once with a "Password for ..." prompt. We pattern-match on
|
||||
# that prompt and emit the matching env var.
|
||||
#
|
||||
# No hardcoded hostnames or vendor names — the deployer decides which
|
||||
# host these credentials apply to by virtue of setting GIT_ASKPASS only
|
||||
# when the target remote is in scope. The helper itself is reusable for
|
||||
# any HTTPS git remote.
|
||||
#
|
||||
# Failure mode: if the env vars are unset, we emit an empty string and
|
||||
# let git surface "Authentication failed" — this is intentional, so a
|
||||
# misconfigured deployment fails loudly at first push instead of silently
|
||||
# falling through to an unrelated credential chain.
|
||||
|
||||
case "$1" in
|
||||
Username*)
|
||||
printf '%s\n' "${GIT_HTTP_USERNAME:-${GITEA_USER:-}}"
|
||||
;;
|
||||
Password*)
|
||||
printf '%s\n' "${GIT_HTTP_PASSWORD:-${GITEA_TOKEN:-}}"
|
||||
;;
|
||||
*)
|
||||
# Unknown prompt — emit empty and let git decide.
|
||||
printf '\n'
|
||||
;;
|
||||
esac
|
||||
Reference in New Issue
Block a user