diff --git a/workspace-server/internal/handlers/agent_git_identity.go b/workspace-server/internal/handlers/agent_git_identity.go index 929160dfb..abd44592c 100644 --- a/workspace-server/internal/handlers/agent_git_identity.go +++ b/workspace-server/internal/handlers/agent_git_identity.go @@ -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: diff --git a/workspace-server/internal/handlers/agent_git_identity_test.go b/workspace-server/internal/handlers/agent_git_identity_test.go index 1d7b7dc04..30ff136a0 100644 --- a/workspace-server/internal/handlers/agent_git_identity_test.go +++ b/workspace-server/internal/handlers/agent_git_identity_test.go @@ -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 diff --git a/workspace-server/internal/handlers/org_helpers.go b/workspace-server/internal/handlers/org_helpers.go index a7fdd52c8..eb13ca246 100644 --- a/workspace-server/internal/handlers/org_helpers.go +++ b/workspace-server/internal/handlers/org_helpers.go @@ -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//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 +// @ 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 diff --git a/workspace-server/internal/handlers/org_persona_env_test.go b/workspace-server/internal/handlers/org_persona_env_test.go index 0a48c66eb..1bca7be73 100644 --- a/workspace-server/internal/handlers/org_persona_env_test.go +++ b/workspace-server/internal/handlers/org_persona_env_test.go @@ -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= / GITEA_USER_EMAIL=@... 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) +} diff --git a/workspace/Dockerfile b/workspace/Dockerfile index 400f7c805..7a8c909fd 100644 --- a/workspace/Dockerfile +++ b/workspace/Dockerfile @@ -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 && \ diff --git a/workspace/scripts/molecule-askpass b/workspace/scripts/molecule-askpass new file mode 100755 index 000000000..925e56736 --- /dev/null +++ b/workspace/scripts/molecule-askpass @@ -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