From 9d7038d325b29a7d59e7a4ec06ef80d76e160c42 Mon Sep 17 00:00:00 2001 From: mosaic-coder Date: Thu, 23 Jul 2026 12:54:45 -0500 Subject: [PATCH] feat(tools/git): per-agent Gitea identity (git-credential-mosaic + get_gitea_token) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds an opt-in per-agent Gitea identity so a fleet agent can push/commit/open PRs under its own token instead of the single shared account, giving cryptographic author-!=-reviewer separation (Gate-16). Resolution priority (both tools): MOSAIC_GIT_IDENTITY env > git config mosaic.gitIdentity (per-worktree, persists on disk) > git-supplied username (git-credential-mosaic only). If the resolved identity has a token file at ~/.config/mosaic/secrets/gitea-tokens/gitea-{usc,mosaicstack}-.token, that identity is used; otherwise both tools fall through to the existing shared-account path unchanged, so this is a no-op on any host without per-slot tokens configured. - tools/git/git-credential-mosaic: new git credential helper (get verb). - tools/git/detect-platform.sh: get_gitea_token() gains the same identity resolution, prepended ahead of the existing shared-token logic, so API tooling (pr-create.sh, issue-create.sh, ...) authors under the same identity as git push/fetch. - install.sh: explicit chmod +x for git-credential-mosaic (it ships without a .sh suffix, so the existing *.sh glob does not cover it); tools/** is already framework-owned so the file syncs automatically. - tools/git/README.md: documents the feature, the one-time `git config credential.helper` registration step (deliberately not auto-wired — see README for why), and the PowerShell-parity decision (detect-platform.ps1 authenticates via tea logins, not a raw-token function, so there is nothing to port there). - tools/git/test-git-credential-mosaic.sh, tools/git/test-gitea-token-identity.sh: new regression harnesses (red verified against the pre-patch code) covering identity-resolution priority, per-host token path selection, and shared-account fallback. Wired into package.json's test:framework-shell. Upstreams Mos host-local tooling-patch kit (2026-07-23), Patches 1+2. Closes #873 Co-Authored-By: Claude Opus 4.8 --- packages/mosaic/framework/install.sh | 4 + packages/mosaic/framework/tools/git/README.md | 61 +++++++ .../framework/tools/git/detect-platform.sh | 22 +++ .../framework/tools/git/git-credential-mosaic | 69 ++++++++ .../tools/git/test-git-credential-mosaic.sh | 161 ++++++++++++++++++ .../tools/git/test-gitea-token-identity.sh | 122 +++++++++++++ packages/mosaic/package.json | 2 +- 7 files changed, 440 insertions(+), 1 deletion(-) create mode 100644 packages/mosaic/framework/tools/git/git-credential-mosaic create mode 100644 packages/mosaic/framework/tools/git/test-git-credential-mosaic.sh create mode 100644 packages/mosaic/framework/tools/git/test-gitea-token-identity.sh diff --git a/packages/mosaic/framework/install.sh b/packages/mosaic/framework/install.sh index 612b0027..d0d8df5b 100755 --- a/packages/mosaic/framework/install.sh +++ b/packages/mosaic/framework/install.sh @@ -639,6 +639,10 @@ reconcile_framework_files # Ensure tool scripts are executable find "$TARGET_DIR/tools" -name "*.sh" -exec chmod +x {} + 2>/dev/null || true find "$TARGET_DIR/tools/_scripts" -type f -exec chmod +x {} + 2>/dev/null || true +# git-credential-mosaic (per-agent Gitea identity helper) ships without a .sh +# suffix — git resolves credential helpers by exact name/path, not extension — +# so the *.sh glob above does not cover it; chmod it explicitly. +[[ -f "$TARGET_DIR/tools/git/git-credential-mosaic" ]] && chmod +x "$TARGET_DIR/tools/git/git-credential-mosaic" 2>/dev/null || true ok "Framework synced to $TARGET_DIR" diff --git a/packages/mosaic/framework/tools/git/README.md b/packages/mosaic/framework/tools/git/README.md index 74f15b55..729c5e32 100644 --- a/packages/mosaic/framework/tools/git/README.md +++ b/packages/mosaic/framework/tools/git/README.md @@ -33,3 +33,64 @@ The Gitea API token is **never passed on a curl command line.** An `Authorizatio Both `pr-review.sh` and `issue-comment.sh` accept an optional `--login ` flag that overrides the automatically detected Gitea login for that single invocation. The override selects **which credential the REST write, the `/user` identity lookup, and the read-back all use** — its token is resolved from the tea config for that login name (`get_gitea_token_for_login`), falling back to the repo host's credential when no login is named. The resolved login is **host- and port-bound**: the login's configured URL host **and effective port** (the scheme's default port — 80 for `http`, 443 for `https` — applies when a port is omitted, symmetrically on both sides) must match the repo remote's, so a login name shared across hosts (or an override configured for a different Gitea, including one on a different port of the same host) can never send one host's credential to another — a host or port mismatch fails closed rather than leaking a cross-host token. Resolving the acting identity and the read-back from the _same_ login that performs the write is essential: a write performed under an overridden login must be verified against that login's identity, not the host default's. Callers who need a different login than the host default should pass `--login `. As a durable successor to this mechanism, consider giving each reviewer/approver slot its own dedicated Gitea login credential, so that author≠reviewer holds at the credential level rather than relying on wrapper-level `--login` bookkeeping. This is a recommendation for future hardening, not something implemented by this flag. + +## Per-agent Gitea identity (Gate-16 author≠reviewer) + +By default, git push/fetch (via `git-credential-mosaic`) and the API wrappers above (via +`detect-platform.sh`'s `get_gitea_token`) all authenticate as the single shared Gitea +account/token configured through `tools/_lib/credentials.sh`. That means every agent in a +fleet commits, pushes, and opens PRs under one identity — with no cryptographic +separation between an author and a reviewer. + +Both `git-credential-mosaic` and `get_gitea_token()` resolve an optional **per-agent +identity** before falling back to the shared account: + +1. `MOSAIC_GIT_IDENTITY` environment variable, or +2. `git config --get mosaic.gitIdentity` (set per-worktree; persists on disk across + non-persistent shells — `git config mosaic.gitIdentity `), or +3. (git-credential-mosaic only) the username git itself supplies for the credential + request. + +If the resolved identity has a token file at +`~/.config/mosaic/secrets/gitea-tokens/gitea-{usc,mosaicstack}-.token`, that +identity + token is used. **Nothing configured → nothing changes**: with no per-slot +token file present, both tools fall through to the existing shared-account path +unchanged, so this feature is a no-op on any host that hasn't provisioned per-slot +tokens. + +### Enabling it for a clone + +The framework installer syncs `git-credential-mosaic` to +`~/.config/mosaic/tools/git/git-credential-mosaic` (executable) on every install/update, +but does **not** register it as git's credential helper automatically. Registration is a +one-time, explicit step: + +```bash +# Per-repo (recommended — scopes the helper to this clone only): +git config credential.helper "$HOME/.config/mosaic/tools/git/git-credential-mosaic" + +# Per-worktree identity pin (Gate-16 separation): +git config mosaic.gitIdentity +``` + +This is deliberately **not** auto-registered on install/update: `credential.helper` is +global, order-sensitive git config (`~/.gitconfig`) that can already hold an +operator-chosen credential manager (keychain, `store`, `manager-core`, …) for +repositories unrelated to Mosaic. Silently inserting an entry on every framework +install/upgrade risks reordering or shadowing that operator-owned surface across the +whole host — the same operator-owned config the installer's manifest system is +otherwise careful never to touch. Because identity is already resolved per-worktree +(`mosaic.gitIdentity`), the correct granularity for registering the helper is per-clone +too, so a documented manual step is the right shape here, not a global auto-write. + +### PowerShell parity + +`detect-platform.ps1`'s Gitea wrappers authenticate through `tea` CLI logins +(`Get-GiteaLoginForHost`), not a raw-token `get_gitea_token`-equivalent function — there +is nothing to prepend the identity-resolution block to on the PowerShell side. A native +PowerShell git-credential helper is also unnecessary: `git-credential-mosaic` is invoked +by git's credential-helper protocol (stdin/stdout), which works identically under Git for +Windows' bundled `bash`/`sh` when configured via `credential.helper`, without a `.ps1` +counterpart. A `tea`-login-based per-agent identity for the PowerShell wrappers is a +separate, larger design (mapping identities to `tea login` profiles) and is out of scope +here. diff --git a/packages/mosaic/framework/tools/git/detect-platform.sh b/packages/mosaic/framework/tools/git/detect-platform.sh index 25d8cf8f..e12f9d40 100755 --- a/packages/mosaic/framework/tools/git/detect-platform.sh +++ b/packages/mosaic/framework/tools/git/detect-platform.sh @@ -505,6 +505,28 @@ get_gitea_token() { script_dir="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" local cred_loader="$script_dir/../_lib/credentials.sh" + # 0. Per-agent identity (Gate-16 author≠reviewer). If MOSAIC_GIT_IDENTITY, or the + # per-worktree `git config mosaic.gitIdentity`, resolves to an agent that has a + # stored per-slot token for this host, act AS that agent so API tooling + # (pr-create, issue-create, …) authors under the right identity — matching the + # git credential helper. Backward-compatible: nothing resolvable → shared logic below. + local _ident="${MOSAIC_GIT_IDENTITY:-}" + [[ -z "$_ident" ]] && _ident="$(git config --get mosaic.gitIdentity 2>/dev/null || true)" + if [[ -n "$_ident" ]]; then + local _idpfx="" + case "$host" in + git.uscllc.com) _idpfx=gitea-usc ;; + git.mosaicstack.dev) _idpfx=gitea-mosaicstack ;; + esac + if [[ -n "$_idpfx" ]]; then + local _idtok="$HOME/.config/mosaic/secrets/gitea-tokens/${_idpfx}-${_ident}.token" + if [[ -r "$_idtok" ]]; then + cat "$_idtok" + return 0 + fi + fi + fi + # 1. Mosaic credential loader (host → service mapping, run in subshell to avoid polluting env) if [[ -f "$cred_loader" ]]; then local token diff --git a/packages/mosaic/framework/tools/git/git-credential-mosaic b/packages/mosaic/framework/tools/git/git-credential-mosaic new file mode 100644 index 00000000..915fc7bc --- /dev/null +++ b/packages/mosaic/framework/tools/git/git-credential-mosaic @@ -0,0 +1,69 @@ +#!/bin/bash +# git-credential-mosaic — git credential helper — resolves Gitea tokens from +# the Mosaic credential store at runtime so remote URLs never embed secrets. +# +# Install (one-time, per clone or globally): +# git config credential.helper "$HOME/.config/mosaic/tools/git/git-credential-mosaic" +# # or, fleet-wide: git config --global credential.helper "$HOME/.config/mosaic/tools/git/git-credential-mosaic" +# +# Per-agent Gate-16 identity (author != reviewer separation): +# git config mosaic.gitIdentity # per-worktree, persists on disk +# # or: export MOSAIC_GIT_IDENTITY= +# +# Resolution priority: MOSAIC_GIT_IDENTITY env > git config mosaic.gitIdentity +# (per-worktree, survives across non-persistent shells) > git-supplied username +# (credential.username / URL). When the resolved identity has a matching +# per-agent token file, use it instead of the shared account. Backward +# compatible: nothing resolvable -> shared token (unchanged behavior). +[ "$1" = "get" ] || exit 0 +host=""; username_in="" +while IFS= read -r line; do + [ -z "$line" ] && break + case "$line" in + host=*) host=${line#host=};; + username=*) username_in=${line#username=};; + esac +done +# Per-agent identity resolution (Gate-16 author≠reviewer separation). +# Priority: MOSAIC_GIT_IDENTITY env > git config mosaic.gitIdentity (per-worktree, +# survives across non-persistent shells) > git-supplied username (credential.username +# / URL). When the resolved identity has a matching per-agent token, use it instead of +# the shared account. Backward-compatible: nothing resolvable → shared token. +ident="$MOSAIC_GIT_IDENTITY" +[ -z "$ident" ] && ident=$(git config --get mosaic.gitIdentity 2>/dev/null) +[ -z "$ident" ] && ident="$username_in" +if [ -n "$ident" ]; then + case "$host" in + git.uscllc.com) idpfx=gitea-usc;; + git.mosaicstack.dev) idpfx=gitea-mosaicstack;; + *) idpfx="";; + esac + if [ -n "$idpfx" ]; then + idtok="$HOME/.config/mosaic/secrets/gitea-tokens/${idpfx}-${ident}.token" + if [ -r "$idtok" ]; then + echo "username=${ident}" + echo "password=$(cat "$idtok")" + exit 0 + fi + fi +fi +case "$host" in + git.uscllc.com) svc=gitea-usc;; + git.mosaicstack.dev) svc=gitea-mosaicstack;; + *) exit 0;; +esac +# Script-relative (not $HOME-absolute) so this resolves correctly regardless +# of where the framework installer places tools/ under $HOME — mirrors +# detect-platform.sh's own cred_loader resolution in this same directory. +script_dir="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +# shellcheck source=../_lib/credentials.sh +source "$script_dir/../_lib/credentials.sh" +load_credentials "$svc" >/dev/null 2>&1 || exit 0 +# GITEA_USER is not populated by load_credentials (it only exports +# GITEA_URL/GITEA_TOKEN for gitea-*), so this fallback is normally taken. Gitea's +# git-over-HTTP auth authenticates from the token itself (the password field), +# not from the username string, so any non-empty placeholder works here — this +# is deliberately NOT a real account name (framework files must stay +# operator-agnostic; see tools/quality/scripts/verify-sanitized.sh). +echo "username=${GITEA_USER:-git}" +echo "password=$GITEA_TOKEN" diff --git a/packages/mosaic/framework/tools/git/test-git-credential-mosaic.sh b/packages/mosaic/framework/tools/git/test-git-credential-mosaic.sh new file mode 100644 index 00000000..e84778b6 --- /dev/null +++ b/packages/mosaic/framework/tools/git/test-git-credential-mosaic.sh @@ -0,0 +1,161 @@ +#!/usr/bin/env bash +# Regression harness for `git-credential-mosaic` — per-agent Gitea identity +# resolution (Gate-16 author≠reviewer separation). +# +# Covers: +# 1. Identity resolution priority: MOSAIC_GIT_IDENTITY env > git config +# mosaic.gitIdentity (per-worktree) > git-supplied username. +# 2. Correct per-slot token file path chosen per host +# (gitea-usc-.token vs gitea-mosaicstack-.token). +# 3. Per-slot token present -> emits that identity + token. +# 4. Per-slot token absent -> falls back to the shared account +# (backward-compat / no-op for hosts without per-slot tokens). +# 5. Unknown/unrelated host -> exits 0 with no output (passthrough). +# +# Uses stubbed token files under a fake HOME + a real (throwaway) git repo. +# NEVER reads real secrets or touches the real ~/.config/mosaic/secrets. + +set -euo pipefail + +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +WORK_DIR="${MOSAIC_TEST_WORK_DIR:-$PWD/.mosaic-test-work/git-credential-mosaic}" +FAKE_HOME="$WORK_DIR/home" +REPO_DIR="$WORK_DIR/repo" +# Mirror the real deployed layout (~/.config/mosaic/tools/{git,_lib}/) under the +# fake HOME: git-credential-mosaic resolves its credentials.sh sibling via a +# script-relative path (BASH_SOURCE), so the copy must live next to a stubbed +# _lib/credentials.sh, not the real one, to keep this test hermetic. +HELPER="$FAKE_HOME/.config/mosaic/tools/git/git-credential-mosaic" + +rm -rf "$WORK_DIR" +mkdir -p "$FAKE_HOME/.config/mosaic/secrets/gitea-tokens" \ + "$FAKE_HOME/.config/mosaic/tools/git" \ + "$FAKE_HOME/.config/mosaic/tools/_lib" \ + "$REPO_DIR" + +cp "$SCRIPT_DIR/git-credential-mosaic" "$HELPER" +chmod +x "$HELPER" + +git -C "$REPO_DIR" init -q +git -C "$REPO_DIR" config user.email "test@example.invalid" +git -C "$REPO_DIR" config user.name "Test" + +# Fake shared-account credential loader — stands in for +# tools/_lib/credentials.sh's load_credentials(), scoped to this test only. +cat > "$FAKE_HOME/.config/mosaic/tools/_lib/credentials.sh" <<'SH' +load_credentials() { + case "$1" in + gitea-mosaicstack) GITEA_URL="https://git.mosaicstack.dev"; GITEA_TOKEN="shared-mosaicstack-token"; export GITEA_URL GITEA_TOKEN; return 0 ;; + gitea-usc) GITEA_URL="https://git.uscllc.com"; GITEA_TOKEN="shared-usc-token"; export GITEA_URL GITEA_TOKEN; return 0 ;; + *) return 1 ;; + esac +} +SH + +fail=0 +assert_eq() { + local desc="$1" expected="$2" actual="$3" + if [[ "$expected" != "$actual" ]]; then + echo "FAIL: $desc — expected '$expected', got '$actual'" >&2 + fail=1 + fi +} + +# Feed "host=\nusername=\n\n" on stdin (mirrors git's credential protocol) +# and run the helper with the fake HOME, inside REPO_DIR (so `git config +# mosaic.gitIdentity` resolves per-worktree), plus any extra env passed in $@. +run_helper() { + local host="$1" username_in="$2"; shift 2 + ( + cd "$REPO_DIR" + env -i HOME="$FAKE_HOME" PATH="$PATH" "$@" bash "$HELPER" get < shared fallback +# (backward-compat: unchanged behavior when nothing is configured). +# --------------------------------------------------------------------------- +git -C "$REPO_DIR" config --unset mosaic.gitIdentity 2>/dev/null || true +out=$(run_helper "git.mosaicstack.dev" "") +assert_eq "shared fallback: username" "username=git" "$(echo "$out" | grep '^username=')" +assert_eq "shared fallback: password" "password=shared-mosaicstack-token" "$(echo "$out" | grep '^password=')" + +# --------------------------------------------------------------------------- +# 2. git-supplied username resolves to an identity WITH a per-slot token -> +# that identity + token wins over the shared account. +# --------------------------------------------------------------------------- +echo -n "agentA-mosaicstack-token" > "$FAKE_HOME/.config/mosaic/secrets/gitea-tokens/gitea-mosaicstack-agentA.token" +out=$(run_helper "git.mosaicstack.dev" "agentA") +assert_eq "username-resolved identity: username" "username=agentA" "$(echo "$out" | grep '^username=')" +assert_eq "username-resolved identity: password" "password=agentA-mosaicstack-token" "$(echo "$out" | grep '^password=')" + +# --------------------------------------------------------------------------- +# 3. git config mosaic.gitIdentity (per-worktree) beats git-supplied username. +# --------------------------------------------------------------------------- +echo -n "agentB-mosaicstack-token" > "$FAKE_HOME/.config/mosaic/secrets/gitea-tokens/gitea-mosaicstack-agentB.token" +git -C "$REPO_DIR" config mosaic.gitIdentity agentB +out=$(run_helper "git.mosaicstack.dev" "agentA") +assert_eq "git-config beats username: username" "username=agentB" "$(echo "$out" | grep '^username=')" +assert_eq "git-config beats username: password" "password=agentB-mosaicstack-token" "$(echo "$out" | grep '^password=')" + +# --------------------------------------------------------------------------- +# 4. MOSAIC_GIT_IDENTITY env beats git config mosaic.gitIdentity. +# --------------------------------------------------------------------------- +echo -n "agentC-mosaicstack-token" > "$FAKE_HOME/.config/mosaic/secrets/gitea-tokens/gitea-mosaicstack-agentC.token" +out=$(run_helper "git.mosaicstack.dev" "agentA" MOSAIC_GIT_IDENTITY=agentC) +assert_eq "env beats git-config: username" "username=agentC" "$(echo "$out" | grep '^username=')" +assert_eq "env beats git-config: password" "password=agentC-mosaicstack-token" "$(echo "$out" | grep '^password=')" +git -C "$REPO_DIR" config --unset mosaic.gitIdentity + +# --------------------------------------------------------------------------- +# 5. Identity resolves, but no matching per-slot token file -> falls back to +# the shared account (per-agent identity is opt-in, not a hard requirement). +# --------------------------------------------------------------------------- +out=$(run_helper "git.mosaicstack.dev" "no-such-agent") +assert_eq "no per-slot token: username" "username=git" "$(echo "$out" | grep '^username=')" +assert_eq "no per-slot token: password" "password=shared-mosaicstack-token" "$(echo "$out" | grep '^password=')" + +# --------------------------------------------------------------------------- +# 6. Correct per-slot token PATH is chosen per host: same agent id, different +# host prefix (gitea-usc- vs gitea-mosaicstack-). +# --------------------------------------------------------------------------- +echo -n "agentD-usc-token" > "$FAKE_HOME/.config/mosaic/secrets/gitea-tokens/gitea-usc-agentD.token" +out=$(run_helper "git.uscllc.com" "agentD") +assert_eq "host-scoped token path (usc): username" "username=agentD" "$(echo "$out" | grep '^username=')" +assert_eq "host-scoped token path (usc): password" "password=agentD-usc-token" "$(echo "$out" | grep '^password=')" +# agentD has NO mosaicstack token -> must fall back to shared mosaicstack, not +# leak the usc token across hosts. +out=$(run_helper "git.mosaicstack.dev" "agentD") +assert_eq "host-scoped token path (cross-host must not leak): username" "username=git" "$(echo "$out" | grep '^username=')" +assert_eq "host-scoped token path (cross-host must not leak): password" "password=shared-mosaicstack-token" "$(echo "$out" | grep '^password=')" + +# --------------------------------------------------------------------------- +# 7. Unrelated/unknown host -> exit 0, no output (passthrough for non-Gitea +# remotes, e.g. github.com via a different credential helper). +# --------------------------------------------------------------------------- +out=$(run_helper "github.com" "agentA") +assert_eq "unknown host: no output" "" "$out" + +# --------------------------------------------------------------------------- +# 8. Non-"get" verb (store/erase) -> exit 0, no output (git-credential +# protocol: this helper only implements get). +# --------------------------------------------------------------------------- +store_out=$(cd "$REPO_DIR" && env -i HOME="$FAKE_HOME" PATH="$PATH" bash "$HELPER" store < git config +# mosaic.gitIdentity (per-worktree). +# 2. Correct per-slot token file path chosen per host +# (gitea-usc-.token vs gitea-mosaicstack-.token). +# 3. Per-slot token present -> that token is returned (agent-authored calls). +# 4. Per-slot token absent -> falls back to the shared credential-loader +# token (backward-compat / no-op for hosts without per-slot tokens). +# 5. Unrelated host with no shared credentials configured -> failure +# (unchanged, existing behavior). +# +# Uses a stubbed credentials.json + stubbed per-slot token files under a fake +# HOME. NEVER reads real secrets or touches the real ~/.config/mosaic/secrets. + +set -euo pipefail + +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +WORK_DIR="${MOSAIC_TEST_WORK_DIR:-$PWD/.mosaic-test-work/gitea-token-identity}" +FAKE_HOME="$WORK_DIR/home" +REPO_DIR="$WORK_DIR/repo" +CREDENTIALS_FILE="$FAKE_HOME/.config/mosaic/credentials.json" + +rm -rf "$WORK_DIR" +mkdir -p "$FAKE_HOME/.config/mosaic/secrets/gitea-tokens" "$REPO_DIR" + +git -C "$REPO_DIR" init -q +git -C "$REPO_DIR" remote add origin https://git.mosaicstack.dev/mosaicstack/stack.git + +cat > "$CREDENTIALS_FILE" <<'JSON' +{ + "gitea": { + "mosaicstack": { + "url": "https://git.mosaicstack.dev", + "token": "shared-mosaicstack-token" + }, + "usc": { + "url": "https://git.uscllc.com", + "token": "shared-usc-token" + } + } +} +JSON + +fail=0 +assert_eq() { + local desc="$1" expected="$2" actual="$3" + if [[ "$expected" != "$actual" ]]; then + echo "FAIL: $desc — expected '$expected', got '$actual'" >&2 + fail=1 + fi +} + +# Runs get_gitea_token for $1=host inside REPO_DIR (per-worktree git config +# resolves there) with a fake HOME + the stub credentials.json, plus any +# extra env passed in $@. +call_get_gitea_token() { + local host="$1"; shift + ( + cd "$REPO_DIR" + # shellcheck disable=SC2016 # deliberately deferred: $DETECT_PLATFORM_SH is + # expanded by the INNER bash -c (via the exported env var below), not here. + env -i HOME="$FAKE_HOME" PATH="$PATH" MOSAIC_CREDENTIALS_FILE="$CREDENTIALS_FILE" \ + DETECT_PLATFORM_SH="$SCRIPT_DIR/detect-platform.sh" "$@" \ + bash -c 'source "$DETECT_PLATFORM_SH"; get_gitea_token "$1"' _ "$host" + ) +} + +# --------------------------------------------------------------------------- +# 1. No identity resolvable -> shared credential-loader token (unchanged). +# --------------------------------------------------------------------------- +git -C "$REPO_DIR" config --unset mosaic.gitIdentity 2>/dev/null || true +out=$(call_get_gitea_token "git.mosaicstack.dev") +assert_eq "shared fallback (no identity)" "shared-mosaicstack-token" "$out" + +# --------------------------------------------------------------------------- +# 2. git config mosaic.gitIdentity resolves to an agent WITH a per-slot +# token -> that token wins over the shared account. +# --------------------------------------------------------------------------- +echo -n "agentA-mosaicstack-token" > "$FAKE_HOME/.config/mosaic/secrets/gitea-tokens/gitea-mosaicstack-agentA.token" +git -C "$REPO_DIR" config mosaic.gitIdentity agentA +out=$(call_get_gitea_token "git.mosaicstack.dev") +assert_eq "git-config identity token" "agentA-mosaicstack-token" "$out" + +# --------------------------------------------------------------------------- +# 3. MOSAIC_GIT_IDENTITY env beats git config mosaic.gitIdentity. +# --------------------------------------------------------------------------- +echo -n "agentB-mosaicstack-token" > "$FAKE_HOME/.config/mosaic/secrets/gitea-tokens/gitea-mosaicstack-agentB.token" +out=$(call_get_gitea_token "git.mosaicstack.dev" MOSAIC_GIT_IDENTITY=agentB) +assert_eq "env beats git-config identity token" "agentB-mosaicstack-token" "$out" + +# --------------------------------------------------------------------------- +# 4. Identity resolves but has no per-slot token for THIS host -> falls back +# to the shared token (per-agent identity is opt-in per host). +# --------------------------------------------------------------------------- +git -C "$REPO_DIR" config mosaic.gitIdentity no-such-agent +out=$(call_get_gitea_token "git.mosaicstack.dev") +assert_eq "no per-slot token falls back to shared" "shared-mosaicstack-token" "$out" + +# --------------------------------------------------------------------------- +# 5. Correct per-slot token PATH per host: same agent id, only a usc token +# exists -> usc host returns it, mosaicstack host must NOT leak it and +# instead falls back to the shared mosaicstack token. +# --------------------------------------------------------------------------- +echo -n "agentD-usc-token" > "$FAKE_HOME/.config/mosaic/secrets/gitea-tokens/gitea-usc-agentD.token" +git -C "$REPO_DIR" config mosaic.gitIdentity agentD +out=$(call_get_gitea_token "git.uscllc.com") +assert_eq "host-scoped token path (usc)" "agentD-usc-token" "$out" +out=$(call_get_gitea_token "git.mosaicstack.dev") +assert_eq "host-scoped token path (no cross-host leak)" "shared-mosaicstack-token" "$out" +git -C "$REPO_DIR" config --unset mosaic.gitIdentity + +if [[ "$fail" -eq 0 ]]; then + echo "get_gitea_token identity resolution regression passed" +fi + +exit "$fail" diff --git a/packages/mosaic/package.json b/packages/mosaic/package.json index 1235a350..c32e3f57 100644 --- a/packages/mosaic/package.json +++ b/packages/mosaic/package.json @@ -25,7 +25,7 @@ "lint": "eslint src", "typecheck": "tsc --noEmit", "test": "vitest run --passWithNoTests && pnpm run test:framework-shell", - "test:framework-shell": "python3 src/lease-broker/daemon_deadline_unittest.py && python3 src/lease-broker/normative_fragments_unittest.py && python3 src/lease-broker/receipt_challenge_unittest.py && python3 src/lease-broker/context_recovery_unittest.py && python3 src/lease-broker/recovery_runtime_unittest.py && python3 src/lease-broker/recovery_b1_adversarial_unittest.py && python3 src/lease-broker/framework_skill_portability_unittest.py && python3 src/mutator-gate/runtime_tools_unittest.py && python3 src/mutator-gate/runtime_launch_guard_unittest.py && python3 framework/tools/lease-broker/check-runtime-launches.py --root ../.. && bash framework/tools/codex/test-pr-diff-context.sh && bash framework/tools/qa/test-deps-preflight.sh && bash framework/tools/git/test-pr-review-gitea-comment.sh && bash framework/tools/git/test-ci-queue-wait-branch-absent.sh" + "test:framework-shell": "python3 src/lease-broker/daemon_deadline_unittest.py && python3 src/lease-broker/normative_fragments_unittest.py && python3 src/lease-broker/receipt_challenge_unittest.py && python3 src/lease-broker/context_recovery_unittest.py && python3 src/lease-broker/recovery_runtime_unittest.py && python3 src/lease-broker/recovery_b1_adversarial_unittest.py && python3 src/lease-broker/framework_skill_portability_unittest.py && python3 src/mutator-gate/runtime_tools_unittest.py && python3 src/mutator-gate/runtime_launch_guard_unittest.py && python3 framework/tools/lease-broker/check-runtime-launches.py --root ../.. && bash framework/tools/codex/test-pr-diff-context.sh && bash framework/tools/qa/test-deps-preflight.sh && bash framework/tools/git/test-pr-review-gitea-comment.sh && bash framework/tools/git/test-ci-queue-wait-branch-absent.sh && bash framework/tools/git/test-git-credential-mosaic.sh && bash framework/tools/git/test-gitea-token-identity.sh" }, "dependencies": { "@mosaicstack/brain": "workspace:*",