Compare commits
2
Commits
3a0b288dfa
..
v1.0.0
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
dfaf9130e1 | ||
|
|
98262e83e8 |
No files matched your search
@@ -1,21 +1,29 @@
|
||||
{
|
||||
"name": "updating-gitea-repositories",
|
||||
"version": "0.1.0",
|
||||
"description": "通过 HTTPS、Git Credential Manager 和 PAT 安全更新 Gitea 仓库",
|
||||
"version": "1.0.0",
|
||||
"description": "通过安全凭据持久复用、Git Credential Manager 和 HTTPS 更新 Gitea 仓库",
|
||||
"author": {
|
||||
"name": "Eapil"
|
||||
},
|
||||
"homepage": "https://git.playones.com/huzhifa/skill-git-push",
|
||||
"repository": "https://git.playones.com/huzhifa/skill-git-push.git",
|
||||
"keywords": ["gitea", "git", "gcm", "pat", "repository"],
|
||||
"keywords": [
|
||||
"gitea",
|
||||
"git",
|
||||
"gcm",
|
||||
"pat",
|
||||
"repository"
|
||||
],
|
||||
"skills": "./skills/",
|
||||
"interface": {
|
||||
"displayName": "Gitea 仓库安全更新",
|
||||
"shortDescription": "通过 GCM 和 PAT 安全提交及验证 Gitea 仓库更新",
|
||||
"longDescription": "在 macOS、Windows 和 WSL 中检查或安装 GCM,安全处理 Gitea PAT,精确暂存变更,并在推送后验证远程提交。",
|
||||
"shortDescription": "复用安全凭据并验证 Gitea 仓库更新",
|
||||
"longDescription": "在 Windows、macOS、WSL 和 Linux 上持久复用 Gitea 凭据,安全诊断认证,精确提交并验证远程更新。",
|
||||
"developerName": "Eapil",
|
||||
"category": "Developer Tools",
|
||||
"capabilities": ["Write"],
|
||||
"capabilities": [
|
||||
"Write"
|
||||
],
|
||||
"defaultPrompt": "使用 updating-gitea-repositories 安全更新这个 Gitea HTTPS 仓库。"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,2 @@
|
||||
__pycache__/
|
||||
*.py[cod]
|
||||
@@ -1,62 +1,38 @@
|
||||
---
|
||||
name: updating-gitea-repositories
|
||||
description: Use when publishing or updating prepared files in a Gitea repository over HTTPS on macOS, Windows, WSL, or Linux, including remote Linux desktops, Git Credential Manager, Personal Access Token authentication, SSO/OIDC web login, empty repositories, existing clones, and push verification.
|
||||
description: Use when publishing or updating a Gitea HTTPS repository, reusing Git credentials across repositories, or diagnosing repeated GCM, PAT, OAuth, and credential-store authorization prompts on Windows, macOS, WSL, or Linux.
|
||||
---
|
||||
|
||||
# Updating Gitea Repositories
|
||||
|
||||
## Purpose
|
||||
Publish only the intended changes, reuse valid credentials through a secure Git credential helper, keep secrets out of commands and logs, and verify the remote commit.
|
||||
|
||||
Publish only the intended changes to a Gitea repository through HTTPS, Git Credential Manager (GCM), and a least-privilege Personal Access Token (PAT). Preserve existing work, keep secrets out of commands and logs, and verify the remote commit after every push.
|
||||
## Required reading
|
||||
|
||||
## Required Reading
|
||||
1. Read [repository workflow](references/repository-workflow.md) and [credential persistence](references/credential-persistence.md).
|
||||
2. Read the platform reference for the machine running Git: [Windows and WSL](references/windows.md), [macOS](references/macos.md), or [Linux](references/linux.md).
|
||||
|
||||
1. Always read [repository workflow](references/repository-workflow.md).
|
||||
2. On macOS, also read [macOS GCM setup](references/macos.md).
|
||||
3. On native Windows or WSL, also read [Windows GCM setup](references/windows.md).
|
||||
4. On native Linux, including Linux controlled through a Windows remote desktop, also read [Linux GCM setup](references/linux.md).
|
||||
## Core contract
|
||||
|
||||
## Core Contract
|
||||
- Identify the clean HTTPS site and repository, account, source and destination paths, branch, and commit intent. Reuse information already established in the task.
|
||||
- Prefer an existing valid credential for the same machine, system user, site, and account. Reuse it across repositories when their access permissions allow. Do not require a new PAT or a new login merely because this is a different repository.
|
||||
- Inspect effective helper order, URL and path matching, account, and secure-store availability before modifying configuration. Change only the necessary site-specific current-user settings when authorized; preserve other sites and existing credential records.
|
||||
- Never print, request in chat, or place tokens or passwords in URLs, command arguments, environment variables, logs, or project files. User-entered secrets belong in a trusted local authentication prompt.
|
||||
- Diagnosis and migration must not erase or overwrite original credentials. Git can send `erase` after a failed authentication; use the read-only PAT probe in the credential reference or an equivalently verified get-only helper. Do not call `git credential reject` as a diagnostic.
|
||||
- Use Windows Credential Manager, macOS Keychain, an unlocked Secret Service collection, or GPG/pass as appropriate. Git `store`, GCM plaintext, and the 15-minute memory cache are not persistent secure solutions.
|
||||
- Retain PAT authentication. For users who sign in with OIDC, prefer a verified browser OAuth flow when supported; an OIDC account need not have a local password. Check the actual server, client, registration, scopes, secure store and refresh behavior; never put an OAuth client secret in Git configuration. Upgrade GCM only when a demonstrated feature requires a compatible version and the task authorizes it.
|
||||
- Select the platform according to where Git runs. Web SSO alone does not establish Git authentication.
|
||||
- Preserve unrelated work; stage exact task paths only. Never bypass TLS, hooks, branch protection, or non-fast-forward safety. Verify the pushed branch SHA after a successful push.
|
||||
|
||||
- Require a clean Gitea HTTPS URL, exact prepared source paths, intended repository paths, target branch, and commit intent. Infer only what the active task makes unambiguous.
|
||||
- Verify GCM by executing its version command. When it succeeds, reuse that installation without install, reinstall, or upgrade actions. Install only when no working GCM exists; repair only when installation records exist but the executable does not.
|
||||
- Select setup instructions from the operating system where the Git process runs. A Windows computer used only to control a remote Linux desktop does not make that Git session Windows or WSL.
|
||||
- Treat web SSO/OIDC login and Git HTTPS authentication as separate unless the Gitea instance explicitly documents an integrated Git flow. The web login can be used to create a PAT; it does not by itself authorize a command-line push.
|
||||
- The user creates a Gitea PAT with `write:repository`. The user enters the Gitea login and PAT only in a local GCM prompt. Never request or expose a PAT in chat, URLs, environment variables, command arguments, logs, or files.
|
||||
- Preserve unrelated work. Stage only exact task paths; never use unscoped `git add .` or `git add -A`.
|
||||
- Before committing, set the user-confirmed identity only in the target repository:
|
||||
|
||||
```bash
|
||||
git config --local user.name "<confirmed-name>"
|
||||
git config --local user.email "<confirmed-id>@eapil.com"
|
||||
```
|
||||
|
||||
- Never bypass TLS, branch protection, hooks, validation, or non-fast-forward safety. Never force-push as recovery.
|
||||
- Report success only after fetching the pushed branch and matching its remote SHA to local `HEAD`.
|
||||
|
||||
## Quick Decisions
|
||||
## Quick decisions
|
||||
|
||||
| State | Action |
|
||||
|---|---|
|
||||
| GCM version works | Reuse it unchanged |
|
||||
| Installer record exists but command fails | Repair or reinstall GCM |
|
||||
| Git runs on a remotely controlled Linux host | Use the Linux instructions |
|
||||
| Linux Secret Service has no graphical session | Use a repository-local GCM memory cache |
|
||||
| HTTPS remote has no refs | Initialize the confirmed default branch |
|
||||
| Remote has history | Clone or fetch before writing |
|
||||
| Dirty changes overlap task paths | Stop or use a clean clone/worktree |
|
||||
| Default branch rejects normal push | Push a task branch for review |
|
||||
|
||||
## Example Invocation
|
||||
|
||||
> Use `$updating-gitea-repositories` to publish the prepared skill to `https://gitea.example.com/team/skill-repo.git`, targeting `skills/updating-gitea-repositories` on `main`.
|
||||
|
||||
## Common Mistakes
|
||||
|
||||
- Trusting package-manager state instead of running the GCM version command.
|
||||
- Treating a PAT as ordinary text.
|
||||
- Assuming a successful browser SSO/OIDC login also authenticates Git HTTPS.
|
||||
- Applying Windows or WSL setup to Git that actually runs on a remote Linux host.
|
||||
- Staging the whole worktree for a narrow update.
|
||||
- Treating push rejection as permission to force-push.
|
||||
- Reporting the local commit without remote SHA verification.
|
||||
| A matching secure credential already works | Reuse it without another login |
|
||||
| Authentication fails | Diagnose configuration and server response without erasing the credential |
|
||||
| A matching old plaintext entry can be copied safely | Copy to a new secure namespace; keep the old entry and disclose its plaintext copy |
|
||||
| Linux has no unlocked graphical store | Set up persistent GPG/pass when authorized; allow for key unlock |
|
||||
| The user signs in to Gitea with OIDC | Check the browser OAuth path in the credential reference before asking for a PAT |
|
||||
| Required OAuth refresh is blocked by the installed version | Verify compatibility and update GCM only when authorized |
|
||||
| Authentication truly requires a new credential | Have the user enter it in a trusted local prompt; never ask for the token in chat |
|
||||
| Remote branch has advanced or protection rejects the push | Do not force; follow the repository workflow |
|
||||
@@ -1,4 +1,4 @@
|
||||
interface:
|
||||
display_name: "Updating Gitea Repositories"
|
||||
short_description: "通过 HTTPS、GCM 和 PAT 更新 Gitea 仓库"
|
||||
short_description: "持久复用安全凭据并更新 Gitea 仓库"
|
||||
default_prompt: "Use $updating-gitea-repositories to publish prepared changes to this Gitea HTTPS repository from the current host."
|
||||
@@ -0,0 +1,72 @@
|
||||
# Credential Persistence and Safe Diagnosis
|
||||
|
||||
## Scope and inspection
|
||||
|
||||
Work on the machine and OS user running Git. First identify a clean HTTPS site origin, the intended Gitea account, the repository remote, and any explicit account/path isolation. Do not print a remote URL before checking it for user information. Treat helper command strings and Git configuration values as potentially sensitive; capture and sanitize them before reporting.
|
||||
|
||||
Inspect system, user, repository, worktree, command-line, included, and URL-scoped configuration. `credential.helper` can be multivalued: an empty value resets preceding helpers, later matching values can add helpers again, and `--get-urlmatch credential.helper` alone does not show the full effective chain. Check `credential.useHttpPath`, `credential.username`, `credential.provider`, `credential.credentialStore`, and `credential.namespace` as applicable. Respect intentional per-repository account overrides and URL paths.
|
||||
|
||||
For one account shared by repositories on the same site, a site-scoped `credential.useHttpPath=false` allows host-level matching. Do not force this when repository-specific credentials or another account are intentionally isolated. Confirm the effective helper uses a usable persistent store; never silently replace it with plaintext or a short-lived cache. A site-scoped empty helper followed by a secure helper can override earlier helpers, but later or more-specific configuration can change the result. A later repository-local generic helper reset can also replace the user-level site chain. Verify the complete effective sequence in each affected repository before using it. Preserve other sites and existing vault entries.
|
||||
|
||||
When the task authorizes configuration changes, adjust only the necessary `credential.<https-site>.*` keys in the current user's Git configuration. Preserve existing nonconflicting settings. If changes would replace an ambiguous account, site-specific setting, or credential, stop and resolve the ambiguity. Do not run a global GCM `configure` command as a blind repair: it can change unrelated helper configuration.
|
||||
|
||||
## Reuse and diagnosis
|
||||
|
||||
Try the matching existing secure credential before asking the user to create or enter a PAT. When the account and site are known and the existing credential is a PAT, this bundled script checks a Gitea API identity endpoint and Git read advertisement without calling Git's `credential reject` or `erase`:
|
||||
|
||||
```text
|
||||
python <skill-directory>/scripts/credential_probe.py --repo <repository-directory> --site https://gitea.example.com --account <confirmed-account>
|
||||
```
|
||||
|
||||
On Windows use `py -3` if `python` is unavailable. The program accepts a clean origin and a same-site HTTPS `.git` remote, never follows redirects, and captures helper output internally. It does not support Gitea installations under a URL subpath or OAuth refresh. A matching API identity establishes the returned account when the token has the required API scope. A successful Git read does not prove push permission; a public repository may permit anonymous reads. API denial can also reflect insufficient API scope. HTTP 401 establishes rejection for that request, not why it was rejected. HTTP 403, 404, TLS failures, and network errors likewise need their own diagnosis. Do not repeatedly retry rejected credentials.
|
||||
|
||||
Normal Git authentication can send `erase` to every active helper after a 401, even when the intent was only to inspect a remote. Do not run a normal failing fetch, `ls-remote`, or push against an original credential that must be preserved. The probe uses direct read-only HTTP requests; use another independently verified get-only helper for cases it cannot handle. Never display `git credential fill` output, enable secret tracing, or run `git credential reject` as a probe.
|
||||
|
||||
## Copy a known old credential
|
||||
|
||||
When a saved entry's site and account are unambiguous, Windows can copy one `git-credential-store` entry into a newly generated Windows Credential Manager namespace without displaying it:
|
||||
|
||||
```text
|
||||
py -3 <skill-directory>/scripts/credential_migrate.py --site https://gitea.example.com --account <confirmed-account> --source-file <existing-credential-store-file>
|
||||
```
|
||||
|
||||
Inspect which helper and file are actually in use first; specify that file explicitly. The script refuses multiple matching entries or path-scoped entries. The script selects one helper, validates the returned account, refuses a populated new namespace, copies via pipes, verifies secure-store readback, and does not remove the source or change Git configuration. The saved username is not proof that the token belongs to that account; validate it with the server. The old plaintext file remains on disk: tell the user. The program does not migrate OAuth refresh tokens or path-specific selections, and automatic migration to macOS or Linux stores is not provided; use a separately verified safe mechanism or a local login.
|
||||
|
||||
To verify the copy before using it, run the PAT probe with `GCM_NAMESPACE` set to the non-secret namespace the script printed. Only after a successful and unambiguous verification, update the target site's `credential.namespace` and other necessary site-scoped settings. Preserve the old namespace and credential. If validation is inconclusive or fails, leave existing Git settings unchanged. A secret is never an environment variable; the namespace is only a non-secret identifier.
|
||||
|
||||
## First login and renewal
|
||||
|
||||
If the credential is absent, rejected after configuration and account checks, or authorization is genuinely required, use a supported browser OAuth flow when appropriate, or ask the user to authenticate in a trusted local GCM prompt. An existing valid PAT can be entered; create a new least-privilege PAT only if needed. Do not claim an existing token is expired or revoked based only on 401. To preserve a rejected old entry, use a fresh isolated namespace for the interactive attempt, verify it, then configure that namespace for the site. Do not replace or delete the old record as a side effect.
|
||||
|
||||
## Browser OAuth for OIDC accounts
|
||||
|
||||
Gitea web SSO and Git OAuth are distinct. An OIDC-only account does not need a local Gitea password: the user can authorize a Git OAuth application through their usual browser login. Check that the site supports the registered public client, PKCE, a browser callback, and refresh tokens; an administrator may disable Gitea's preconfigured applications. An initial OIDC sign-in can land on the Gitea home page instead of returning to the authorization request. If that happens, restart the OAuth request while the browser session is signed in. Use a suitable PAT only when the OAuth path cannot be used.
|
||||
|
||||
On native Windows, an option verified with Git 2.52 and `git-credential-oauth` 0.17.2 uses the official OAuth helper for browser authorization and the bundled Windows vault helper for subsequent Git requests. Git 2.45 or later is recommended for refresh-token protocol support. Install the official OAuth helper when needed and authorized. The following tool opens the browser, checks the returned Gitea account and Git read advertisement, then saves a refreshable credential to a newly generated isolated Windows vault namespace. It never changes Git configuration or displays tokens. A public repository's read advertisement alone does not prove that Git accepted the credential or that the user can push:
|
||||
|
||||
```text
|
||||
py -3 <skill-directory>/scripts/credential_oauth_bootstrap.py --site https://gitea.example.com --account <confirmed-account> --repo <repository-directory>
|
||||
```
|
||||
|
||||
The default client is Gitea's preconfigured public `git-credential-oauth` application; use `--client-id` for another compatible public client. This bootstrap handles site-root HTTPS installations. It requests `read:user` and `write:repository`: the latter can include repository API changes and deletion as well as Git writes, within the user's existing permissions. Explain the scope when the user authorizes. The API identity scope is needed for account verification. If the server, scope policy, network, or client does not support this flow, diagnose the actual response and retain the PAT path. Never configure a client secret in Git.
|
||||
|
||||
The script prints non-secret metadata, including the isolated namespace, verified account, public client ID, installed paths, and a shell-escaped `helper_command`. Configure the target site's effective helper sequence using Git config commands or an included file:
|
||||
|
||||
1. Reset preceding helpers with an empty `credential.<site>.helper`.
|
||||
2. Add the exact `helper_command` from the bootstrap metadata as the sole helper after that reset. Pass it to Git as one argument, for example through a subprocess argument list; do not manually build a shell command with double-quoted paths, since `$()` and backticks can still be evaluated. The command uses POSIX shell quoting for each argument. The installed, content-addressed vault helper lives under the user's local application data, not the transient skill cache.
|
||||
3. Set site-specific `credential.username=oauth2`, `credential.useHttpPath=false`, `credential.oauthClientId` to the metadata's public client ID, `credential.oauthAuthURL=/login/oauth/authorize`, `credential.oauthTokenURL=/login/oauth/access_token`, and `credential.oauthScopes=read:user write:repository`.
|
||||
4. Check that no later generic or repository-local helpers add a plaintext store or reset this sequence. When a repository-local override is necessary, add the same site-specific reset and helper there without removing unrelated settings. Verify from each actual Git environment without exposing `git credential fill` output.
|
||||
|
||||
`oauth2` is the Git protocol username, not proof of the user's identity. In a later session, safely query the current OAuth account before reuse:
|
||||
|
||||
```text
|
||||
py -3 <skill-directory>/scripts/credential_oauth_identity.py --site https://gitea.example.com --namespace <namespace> --repo <repository-directory>
|
||||
```
|
||||
|
||||
The identity check can refresh and persist an expired credential; it prints only the account name. Do not use a shared host-level setup when multiple accounts for the same site cannot be unambiguously isolated. An empty namespace fails closed; only the bootstrap may create a verified entry.
|
||||
|
||||
The vault helper stores access/refresh/expiry as a compressed Windows vault blob. Under a cross-process lock it refreshes an expired or rejected token, saves the rotated refresh token before returning access to Git, and ignores stale `store` calls. A matching automatic `erase` marks the access token rejected without deleting the vault record; the next operation attempts one refresh. Do not repeatedly retry 401s without diagnosing permissions or server errors. If the blob exceeds the Windows vault limit it fails without falling back to plaintext. A process interruption between server-side token rotation and the vault write can still require reauthorization. The refresh request uses Python's TLS trust and proxy environment; Git-only private CA or proxy settings may require compatible system/Python configuration. Keep TLS verification enabled. A simulated refresh does not prove the live server's eventual refresh behavior.
|
||||
|
||||
GCM's generic OAuth is another option when its actual version and store are compatible. GCM 2.7.0 fixes storage of rotated generic OAuth refresh tokens; assess whether that specific issue affects the requested flow before an authorized upgrade. Do not assume every GCM version refreshes proactively. Never use `git-credential-oauth -verbose` with real credentials: its debug output can contain tokens.
|
||||
|
||||
References: [Git credential configuration](https://git-scm.com/docs/gitcredentials), [Gitea OAuth provider and scopes](https://docs.gitea.com/development/oauth2-provider/), [git-credential-oauth](https://github.com/hickford/git-credential-oauth), [GCM credential stores](https://github.com/git-ecosystem/git-credential-manager/blob/main/docs/credstores.md), [generic OAuth](https://github.com/git-ecosystem/git-credential-manager/blob/main/docs/generic-oauth.md), [GCM 2.7.0](https://github.com/git-ecosystem/git-credential-manager/releases/tag/v2.7.0).
|
||||
@@ -1,102 +1,27 @@
|
||||
# Linux GCM Setup
|
||||
|
||||
Use this reference when Git runs on native Linux. This includes a Linux desktop controlled from Windows through RDP, VNC, or similar software. Use the Windows reference only when Git itself runs on Windows or WSL is intentionally using the Windows GCM executable.
|
||||
Use this reference when Git runs on Linux, including a remote Linux desktop controlled from Windows. Read [credential persistence](credential-persistence.md) before authentication or configuration changes.
|
||||
|
||||
## Identify the Session
|
||||
## Inspect and install
|
||||
|
||||
Verify Git, GCM, the executable paths, and the configured helper:
|
||||
Verify Git and GCM with their version commands and identify the effective helper, site account, path policy, and credential store. Reuse a working GCM installation. Linux GCM requires an explicitly selected store. If GCM or required components are missing, use an official verified installation method for the distribution, consistent with task authorization. Do not place administrator passwords in chat or use an unverified download.
|
||||
|
||||
```bash
|
||||
uname -s
|
||||
git --version
|
||||
git-credential-manager --version
|
||||
command -v git
|
||||
command -v git-credential-manager
|
||||
git config --show-origin --get-all credential.helper
|
||||
```
|
||||
A visible remote desktop does not guarantee that a background agent has its D-Bus session or an unlocked keyring. Check availability in the exact session where Git runs.
|
||||
|
||||
If the GCM version command succeeds, reuse that installation. Do not install or upgrade it during an ordinary repository update.
|
||||
## Persistent stores
|
||||
|
||||
A visible remote desktop does not guarantee that an agent or background shell inherited the desktop session. Check without printing unrelated environment values:
|
||||
Prefer an existing unlocked Secret Service collection when it is accessible to that session. On a headless host, prefer GCM's `gpg` store backed by `gpg` and `pass` when the graphical keyring is unavailable. Install missing components and guide initialization when the task authorizes it. Do not use GCM plaintext or Git `store` for new credentials.
|
||||
|
||||
```bash
|
||||
test -n "${DISPLAY:-}" || test -n "${WAYLAND_DISPLAY:-}"
|
||||
```
|
||||
Before initializing `pass`, inspect the existing password-store directory, its `.gpg-id`, available secret keys, and any configured store path. Reuse a suitable existing setup. Do not run `pass init` over an existing store without understanding its recipients. If no suitable key or store exists, have the user create a GPG key through a trusted local interaction, confirm the full fingerprint, and initialize the intended new password store with `pass init <confirmed-full-fingerprint>`. Do not place a key passphrase in commands, environment variables, logs, or chat. Existing keyring or GPG-agent unlock policies may require the user to unlock the key again.
|
||||
|
||||
## Install When GCM Is Missing
|
||||
When authorized, configure `credential.<https-site>.credentialStore` as `secretservice` or `gpg` for the target site, and set the site helper/account/path policy only as needed. Preserve unrelated site settings and existing records. Verify secure readback and a subsequent session; a correctly initialized store alone does not prove that an agent can unlock it unattended.
|
||||
|
||||
Linux needs both GCM and an explicitly selected credential store. Use an official installation method appropriate for the distribution: the .NET global tool, a verified Debian package, or a verified release tarball. Run `git-credential-manager configure` afterward.
|
||||
GCM's `cache` store is memory-only and expires by default after 900 seconds. It can be an explicitly requested temporary fallback, but is not long-term persistence and must not be represented as cross-day reuse.
|
||||
|
||||
Do not download an unpinned executable from an unofficial source. Verify the release signature or checksum before installation. If the normal package needs administrator rights, ask the user to run the installation locally or use an official current-user method; never ask for an administrator password in chat.
|
||||
## Authentication and errors
|
||||
|
||||
On a Debian-based host without administrator access, a verified official `.deb` can be unpacked into the current user's directories. Inspect the package first and stop if its layout differs from `usr/local/share/gcm-core`:
|
||||
Try a matching stored credential first. For PATs, use the read-only probe in the credential reference before any potentially failing Git operation that could erase a credential. When a prompt is genuinely necessary, use the user's local GCM interaction; never ask the user to send a token or passphrase in chat. If no secure store can be set up or unlocked, explain the remaining local action instead of silently choosing plaintext or a short cache.
|
||||
|
||||
```bash
|
||||
gcm_package="/absolute/path/to/verified-gcm.deb"
|
||||
gcm_unpack_dir=$(mktemp -d)
|
||||
gcm_install_dir="${XDG_DATA_HOME:-$HOME/.local/share}/gcm-core"
|
||||
gcm_bin_dir="$HOME/.local/bin"
|
||||
A web SSO/OIDC session does not by itself authorize Git. Use generic OAuth only after checking actual client/server compatibility and refresh behavior; otherwise use a PAT. Keep TLS verification enabled.
|
||||
|
||||
dpkg-deb -c "$gcm_package"
|
||||
test ! -e "$gcm_install_dir"
|
||||
test ! -e "$gcm_bin_dir/git-credential-manager"
|
||||
test ! -L "$gcm_bin_dir/git-credential-manager"
|
||||
dpkg-deb -x "$gcm_package" "$gcm_unpack_dir"
|
||||
test -x "$gcm_unpack_dir/usr/local/share/gcm-core/git-credential-manager"
|
||||
install -d "$(dirname "$gcm_install_dir")" "$gcm_bin_dir"
|
||||
cp -a "$gcm_unpack_dir/usr/local/share/gcm-core" "$gcm_install_dir"
|
||||
ln -s "$gcm_install_dir/git-credential-manager" "$gcm_bin_dir/git-credential-manager"
|
||||
"$gcm_bin_dir/git-credential-manager" --version
|
||||
"$gcm_bin_dir/git-credential-manager" configure
|
||||
```
|
||||
|
||||
Use the full executable path until `$HOME/.local/bin` is available on `PATH`. If an install directory or link already exists, inspect it and repair only the known broken installation; do not overwrite it blindly.
|
||||
|
||||
## Choose a Credential Store
|
||||
|
||||
For a graphical Linux session with a working Secret Service collection:
|
||||
|
||||
```bash
|
||||
git config --global credential.credentialStore secretservice
|
||||
git-credential-manager configure
|
||||
```
|
||||
|
||||
Secret Service requires a graphical session to unlock its collection. If Git runs from an agent, background shell, or terminal without access to that graphical session, configure GCM's in-memory cache only in the target repository:
|
||||
|
||||
```bash
|
||||
git config --local credential.credentialStore cache
|
||||
git config --local credential.cacheOptions "--timeout 900"
|
||||
```
|
||||
|
||||
This keeps the PAT in memory for 15 minutes and leaves GCM as the Git credential helper. Do not replace it with a plaintext credential store. Do not change a working global Secret Service configuration merely because one repository is being updated from a non-graphical shell.
|
||||
|
||||
For persistent use without a graphical session, GCM also supports the `gpg` store after `gpg`, `pass`, and a key pair have been set up. Do not initialize those tools or keys as a side effect of a repository update unless the user requests it.
|
||||
|
||||
## OIDC Web Login and Git Push
|
||||
|
||||
An organization may use DingTalk or another OIDC provider for the Gitea website. Treat that as the way to open the account settings and create a PAT. Unless the Gitea instance explicitly documents another Git flow, the HTTPS push still uses:
|
||||
|
||||
- Username: the Gitea login accepted by the instance.
|
||||
- Password: a PAT with `write:repository`, entered only in the user's local terminal prompt.
|
||||
|
||||
Run the push from an interactive terminal:
|
||||
|
||||
```bash
|
||||
cd <prepared-repository>
|
||||
git push -u origin <target-branch>
|
||||
```
|
||||
|
||||
If the agent cannot expose an interactive prompt to the user, stop at this command and ask the user to run it. Resume with fetch and SHA verification after the user reports success. Never place the PAT in the remote URL, shell command, environment variable, chat, log, or repository file.
|
||||
|
||||
## Troubleshooting
|
||||
|
||||
If GCM reports that `secretservice` cannot be used without a graphical interface, first confirm where the setting came from:
|
||||
|
||||
```bash
|
||||
git config --show-origin --get-all credential.credentialStore
|
||||
git config --show-origin --get-all credential.helper
|
||||
```
|
||||
|
||||
Then use the repository-local `cache` configuration above for this push, or rerun Git inside the real graphical session. Do not disable TLS verification. Install the organization's trusted CA correctly or stop and report the certificate issue.
|
||||
|
||||
Official references: [GCM installation](https://github.com/git-ecosystem/git-credential-manager/blob/main/docs/install.md), [credential stores](https://github.com/git-ecosystem/git-credential-manager/blob/main/docs/credstores.md), and [GCM configuration](https://github.com/git-ecosystem/git-credential-manager/blob/main/docs/configuration.md).
|
||||
References: [GCM installation](https://github.com/git-ecosystem/git-credential-manager/blob/main/docs/install.md), [credential stores](https://github.com/git-ecosystem/git-credential-manager/blob/main/docs/credstores.md), and [configuration](https://github.com/git-ecosystem/git-credential-manager/blob/main/docs/configuration.md).
|
||||
@@ -1,59 +1,11 @@
|
||||
# macOS GCM Setup
|
||||
|
||||
Use this reference only on macOS.
|
||||
Read [credential persistence](credential-persistence.md) before authentication or configuration changes.
|
||||
|
||||
## Verify
|
||||
Verify `git --version` and `git-credential-manager --version`. A working GCM installation should be reused. Install or repair using an official package only if it is missing or broken; upgrade only when a demonstrated required feature and task authorization justify it. Never request a macOS administrator password in chat.
|
||||
|
||||
```bash
|
||||
git --version
|
||||
git-credential-manager --version
|
||||
git config --show-origin --get-all credential.helper
|
||||
```
|
||||
GCM normally uses the current user's macOS Keychain. Confirm the keychain is accessible and unlocked in the session that runs Git. A locked keychain may require the user to unlock it. Inspect the full effective helper chain and URL-scoped account, store, namespace, and path settings. When authorized and necessary, adjust only the target site's helper and account settings; preserve unrelated helpers and stored credentials. Do not run global `git-credential-manager configure` blindly or remove a repository-local override before understanding why it exists.
|
||||
|
||||
Treat `command not found` as missing or broken even when Homebrew says the cask is installed.
|
||||
Reuse an existing matching credential. Diagnose a PAT using the read-only probe described in the credential reference. Automatic migration from an old store to Keychain is not provided by the bundled Windows migration script. Use a separately verified safe migration method, or have the user enter an existing valid PAT in a local GCM prompt when necessary. Keep the old record unless the user explicitly requests its removal.
|
||||
|
||||
If the version command succeeds, GCM is already available. Do not run Homebrew install, reinstall, or upgrade commands; continue to configuration and repository work.
|
||||
|
||||
## Install or Repair
|
||||
|
||||
Run the preferred installation only when the version command confirms that no working GCM exists:
|
||||
|
||||
```bash
|
||||
brew install --cask git-credential-manager
|
||||
```
|
||||
|
||||
If Homebrew records the cask but the command is unavailable:
|
||||
|
||||
```bash
|
||||
brew reinstall --cask git-credential-manager
|
||||
git-credential-manager --version
|
||||
```
|
||||
|
||||
The package installer may require a macOS administrator password. Never ask the user to send that password. Have the user enter it in their own terminal or the macOS installer. If installation cannot be completed, stop rather than silently switching away from GCM.
|
||||
|
||||
If reinstall succeeds but the shell still cannot find the command, start a new shell and check:
|
||||
|
||||
```bash
|
||||
command -v git-credential-manager
|
||||
ls -l /usr/local/share/gcm-core/git-credential-manager
|
||||
```
|
||||
|
||||
Use the reported installed path to diagnose PATH configuration; do not create speculative symlinks.
|
||||
|
||||
## Configure
|
||||
|
||||
```bash
|
||||
git-credential-manager configure
|
||||
git config --global --get-all credential.helper
|
||||
```
|
||||
|
||||
GCM uses the macOS Keychain as its default secure credential store. Remove a repository-local helper override only after inspecting its origin and confirming it conflicts with GCM:
|
||||
|
||||
```bash
|
||||
git config --show-origin --get-all credential.helper
|
||||
git config --local --unset-all credential.helper
|
||||
```
|
||||
|
||||
Do not replace GCM with `osxkeychain` when the requested workflow is standardized on GCM.
|
||||
|
||||
Official references: [GCM installation](https://github.com/git-ecosystem/git-credential-manager/blob/main/docs/install.md) and [credential stores](https://github.com/git-ecosystem/git-credential-manager/blob/main/docs/credstores.md).
|
||||
References: [GCM installation](https://github.com/git-ecosystem/git-credential-manager/blob/main/docs/install.md) and [credential stores](https://github.com/git-ecosystem/git-credential-manager/blob/main/docs/credstores.md).
|
||||
@@ -1,67 +1,34 @@
|
||||
# Repository Update Workflow
|
||||
|
||||
Read this reference for every Gitea repository update.
|
||||
Read this reference and [credential persistence](credential-persistence.md) for every Gitea update.
|
||||
|
||||
## 1. Resolve Inputs
|
||||
## 1. Resolve and inspect
|
||||
|
||||
Identify the clean Gitea HTTPS URL, prepared source paths, repository target paths, target branch, commit intent, and the operating system where Git actually runs. If a Windows machine only displays or controls a remote Linux desktop, use the Linux flow. If a source-to-target mapping is ambiguous, stop and ask before writing.
|
||||
Identify the clean HTTPS site and repository, account, prepared source paths, destination paths, target branch, commit intent, and the host running Git. Reuse details already established by the task. Ask only when a required mapping or identity remains ambiguous.
|
||||
|
||||
## 2. Inspect Before Editing
|
||||
Read applicable `AGENTS.md` and `CONTRIBUTING*`. Inspect repository status, staged changes, branch, history, and remote configuration without exposing credentials embedded in URLs or helper commands. Preserve unrelated changes. Determine the effective helper chain, username, `useHttpPath`, namespace, and secure store, including site- and repository-specific overrides. Reuse a working installation and valid credential; do not require a new PAT for every repository.
|
||||
|
||||
Read applicable `AGENTS.md` and `CONTRIBUTING*`. Inspect the repository root, current branch, `git status`, staged diff, remotes, recent history, and remote refs. Do not print a remote URL that may contain credentials; reject URLs with embedded user information.
|
||||
Before diagnosing a questionable PAT, use the read-only probe in the credential reference. Do not let a failing Git authentication erase the original credential. Diagnose account, configuration, network, TLS, server authentication, and repository permissions before requesting local reauthorization. A 401 does not identify an expired or revoked token.
|
||||
|
||||
Verify GCM using the active platform reference. A successful version command means reuse the existing installation without installing or upgrading it. Configure GCM only when its helper is absent or conflicting.
|
||||
## 2. Select the repository state
|
||||
|
||||
Web login and Git HTTPS authentication are separate concerns. For example, an organization may use DingTalk or another OIDC provider to sign in to the Gitea web interface, while Git HTTPS still expects a Gitea username and PAT. Do not assume that a browser login authorizes the command line unless the instance explicitly documents that behavior.
|
||||
|
||||
The user signs in to the Gitea web interface, creates a PAT with `write:repository`, and enters the Gitea login plus PAT only in the local GCM prompt. The account must already have repository write access. Never ask the user to paste a PAT into chat.
|
||||
|
||||
## 3. Choose the Repository Path
|
||||
|
||||
- Existing clone: `git fetch --prune` first; do not merge automatically.
|
||||
- Existing clone: fetch the relevant branch after the safe credential preflight; do not merge automatically.
|
||||
- Existing remote without a clone: clone the target branch before copying prepared files.
|
||||
- Empty remote: only after `git ls-remote` succeeds with no refs, initialize the confirmed default branch, normally `main`, and add the clean HTTPS remote.
|
||||
- Dirty clone: preserve unrelated changes. If they overlap task paths or prevent a safe update, use a fresh clone or isolated worktree based on the latest remote branch.
|
||||
- Empty remote: initialize the confirmed default branch only after an authenticated remote check confirms no refs.
|
||||
- Dirty clone: preserve unrelated changes. If task paths overlap or safe updates are blocked, use a suitable isolated worktree or clone.
|
||||
|
||||
Do not use force, reset, clean, stash, or destructive checkout as a shortcut.
|
||||
|
||||
## 4. Prepare and Validate the Commit
|
||||
## 3. Prepare and validate
|
||||
|
||||
Set the user-confirmed Eapil identity only in the target repository:
|
||||
Reuse an identity already explicitly authorized for this repository. If an identity must be set, configure the confirmed name and email only in the target repository. Ask only when the intended identity is missing or ambiguous.
|
||||
|
||||
```bash
|
||||
git config --local user.name "<confirmed-name>"
|
||||
git config --local user.email "<confirmed-id>@eapil.com"
|
||||
```
|
||||
Copy or edit only the intended paths. Stage with explicit pathspecs and inspect staged names, statistics, whitespace checks, and the actual diff. Confirm that deletions are intentional, no secret or transient file is present, and required tests pass. Do not create an empty commit unless the user requested it.
|
||||
|
||||
Copy or edit only the intended paths. Stage with an explicit pathspec:
|
||||
## 4. Commit, push, verify
|
||||
|
||||
```bash
|
||||
git add -- <path-1> <path-2>
|
||||
git diff --cached --name-status
|
||||
git diff --cached --stat
|
||||
git diff --cached --check
|
||||
git diff --cached
|
||||
```
|
||||
Commit with an intent-based message. Recheck the remote when it may have advanced. Push normally to the intended branch. If the default branch is protected, follow the user's review workflow; do not use force, hook bypasses, or TLS bypasses.
|
||||
|
||||
Confirm every staged path is in scope, deletions are intentional, no secret or transient file is present, and repository-required tests pass. If there is no staged change, report that the remote already contains the prepared content instead of creating an empty commit, unless the user explicitly requested an empty commit.
|
||||
If local authentication is required, use a supported browser OAuth flow or have the user enter the username and an existing suitable PAT in a trusted local prompt; a new PAT is needed only if no valid authorized credential is available. Preserve rejected old records through a separate namespace. Never ask for a secret in chat or put it in a command, URL, environment variable, or repository file.
|
||||
|
||||
## 5. Commit, Push, and Verify
|
||||
|
||||
Commit with an intent-based message. Re-fetch before pushing when the remote may have advanced. Push normally to the target branch. If the default branch is protected, push a task branch and report that review or merge is required. Do not use `--force`, `--force-with-lease`, `--no-verify`, or TLS bypasses.
|
||||
|
||||
When an interactive prompt is required but the agent cannot share it safely, give the user the exact working directory and `git push` command. The user enters the username and PAT in their own terminal. Do not replace this handoff with credentials in a command, URL, environment variable, or file. After the user reports that the push finished, continue with the remote verification below.
|
||||
|
||||
After a successful push, fetch the pushed branch and compare:
|
||||
|
||||
```bash
|
||||
git rev-parse HEAD
|
||||
git rev-parse "origin/<pushed-branch>"
|
||||
git ls-remote origin "refs/heads/<pushed-branch>"
|
||||
```
|
||||
|
||||
Report completion only when the local and remote SHAs match. Include the repository, branch, commit SHA and subject, exact changed paths, validation performed, and remaining user action.
|
||||
|
||||
## Stop Conditions
|
||||
|
||||
Stop on ambiguous paths, missing Git identity, authentication or TLS failure, insufficient account/PAT permissions, unexpected staged files, validation failure, overlapping user changes, non-fast-forward history requiring judgment, protected-branch rejection, server-hook rejection, or a request to overwrite remote history. A required local credential prompt is a user handoff, not permission to weaken authentication or store the PAT insecurely.
|
||||
After the push, fetch the pushed branch and compare its remote SHA with local `HEAD`. Report the repository, branch, commit, scope, validation, and any remaining action. A read-only credential probe does not prove write permission. A source-repository push does not by itself prove that a separate plugin marketplace or installed cache has synchronized.
|
||||
@@ -1,55 +1,25 @@
|
||||
# Windows GCM Setup
|
||||
# Windows and WSL Credential Setup
|
||||
|
||||
Use this reference on native Windows or Windows Subsystem for Linux (WSL).
|
||||
Use the environment where Git executes. Read [credential persistence](credential-persistence.md) before any authentication attempt or configuration change.
|
||||
|
||||
## Native Windows
|
||||
|
||||
In PowerShell, verify Git, GCM, and the configured helper:
|
||||
Verify Git and the selected credential helper by running their version commands. Reuse a working GCM installation for the GCM/PAT path; do not install or upgrade it for an ordinary update. When that path is needed and no working installation exists, use Git for Windows with GCM or the official GCM installer. A feature-specific upgrade is permitted only when the need and task authorization are established.
|
||||
|
||||
```powershell
|
||||
git --version
|
||||
git credential-manager --version
|
||||
Get-Command git-credential-manager -ErrorAction SilentlyContinue
|
||||
git config --show-origin --get-all credential.helper
|
||||
```
|
||||
GCM normally uses Windows Credential Manager. Inspect the effective helper chain and the site-specific `credentialStore`, `namespace`, username, and path policy. Do not blindly run `git-credential-manager configure`, remove unrelated helpers, or enter a PAT while a plaintext helper is active for this site. An earlier generic `store` can remain configured for other sites if the complete chain for this site excludes it.
|
||||
|
||||
Git for Windows is the preferred GCM installation method and includes GCM by default. If GCM is missing or broken, repair or reinstall the current Git for Windows release and keep Git Credential Manager selected. The official standalone GCM user installer is an alternative that does not require administrator rights; the system installer does.
|
||||
When authorized and needed, configure only the target site's helper sequence with an empty reset followed by `manager`, set `credential.<site>.credentialStore` to `wincredman`, and set the site username or `useHttpPath` only when the intended account and sharing policy are clear. Preserve other sites and existing records; check for later system, include, user, and repository overrides. Do not assume `git config --get-urlmatch` shows the full helper chain.
|
||||
|
||||
If either version command succeeds, reuse the installed GCM. Do not reinstall Git for Windows, install standalone GCM, or upgrade GCM as part of an ordinary repository update.
|
||||
|
||||
After installation, start a new PowerShell session and run:
|
||||
|
||||
```powershell
|
||||
git-credential-manager --version
|
||||
git-credential-manager configure
|
||||
git config --global --get-all credential.helper
|
||||
```
|
||||
|
||||
GCM uses Windows Credential Manager by default. If `credential.helper=store` is active, stop before entering a PAT and replace the plaintext helper through GCM configuration. Never edit `%USERPROFILE%\.git-credentials` to add a PAT.
|
||||
For an unambiguous old plaintext Git credential-store entry, the migration script in [credential persistence](credential-persistence.md) can create a separate Windows namespace and preserve the old file. Verify the credential against the server before changing the site's namespace. If the old credential is already in Windows Credential Manager, reuse it if valid. If it is rejected, diagnose first; use a fresh namespace for any necessary interactive reauthorization so the old record remains.
|
||||
|
||||
## WSL
|
||||
|
||||
Prefer Git for Windows plus its GCM executable. Inside WSL, configure the helper using the actual installed path, commonly:
|
||||
Choose deliberately between Linux GCM with a Linux store and the installed Windows GCM executable with Windows Credential Manager. WSL is a separate Git environment even when it uses a Windows vault. Inspect the actual GCM path and quoting; do not guess paths or replace all global helpers. A Windows-backed GCM setup requires a suitable Windows user session. Verify it from the same WSL context in which Git will run.
|
||||
|
||||
```bash
|
||||
git config --global credential.helper "/mnt/c/Program\ Files/Git/mingw64/bin/git-credential-manager.exe"
|
||||
```
|
||||
## Local authentication
|
||||
|
||||
A current-user standalone installer may instead place GCM under the user's `AppData/Local/Programs` directory. Inspect the real location rather than guessing it. GCM backed by Windows Credential Manager requires an interactive Windows desktop session and does not persist credentials from a remote SSH-only Windows session.
|
||||
For OIDC users, follow the browser OAuth and Windows vault flow in [credential persistence](credential-persistence.md) when the site supports it. No local Gitea password or PAT is needed for that flow. The standard Windows `wincred` helper can fail to save access and refresh tokens together when their UTF-16 representation exceeds the vault's blob limit; the bundled isolated vault helper addresses this without a plaintext fallback. Validate account identity, readback, helper order, and refresh support before relying on it.
|
||||
|
||||
## Authentication and Verification
|
||||
When a PAT is required, the user enters the confirmed Gitea username and an existing suitable PAT in a trusted prompt, or creates a new least-privilege PAT only when needed. Never ask for a PAT in chat, command arguments, environment variables, URLs, logs, or files. Inspect remote URLs without displaying embedded credentials. Keep TLS verification enabled and configure the organization's trusted CA if necessary.
|
||||
|
||||
At the first `git ls-remote`, clone, fetch, or push prompt:
|
||||
|
||||
- Username: the Gitea login name or email accepted by the instance.
|
||||
- Password: the PAT with `write:repository`, entered locally.
|
||||
|
||||
Confirm the remote URL contains no credentials:
|
||||
|
||||
```powershell
|
||||
git remote -v
|
||||
```
|
||||
|
||||
Do not disable TLS verification for internal certificates. Install the organization's trusted CA correctly or stop and report the certificate issue.
|
||||
|
||||
Official references: [GCM installation](https://github.com/git-ecosystem/git-credential-manager/blob/main/docs/install.md), [Windows credential store](https://github.com/git-ecosystem/git-credential-manager/blob/main/docs/credstores.md), and [WSL configuration](https://github.com/git-ecosystem/git-credential-manager/blob/main/docs/wsl.md).
|
||||
References: [GCM installation](https://github.com/git-ecosystem/git-credential-manager/blob/main/docs/install.md), [credential stores](https://github.com/git-ecosystem/git-credential-manager/blob/main/docs/credstores.md), and [WSL configuration](https://github.com/git-ecosystem/git-credential-manager/blob/main/docs/wsl.md).
|
||||
@@ -0,0 +1,242 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Copy one explicitly selected Git credential-store entry to a new Windows GCM namespace.
|
||||
|
||||
The secret travels only over subprocess pipes. This program never removes the
|
||||
source entry or changes Git configuration. A successful copy is not proof that
|
||||
the server accepts the token; validate that separately without erasing it.
|
||||
"""
|
||||
|
||||
import argparse
|
||||
import ctypes
|
||||
import hmac
|
||||
import os
|
||||
from pathlib import Path
|
||||
import secrets
|
||||
import subprocess
|
||||
import sys
|
||||
import tempfile
|
||||
from urllib.parse import unquote, urlsplit
|
||||
|
||||
|
||||
class MigrationError(Exception):
|
||||
"""An error whose message contains no credential data."""
|
||||
|
||||
|
||||
def safe_environment():
|
||||
env = os.environ.copy()
|
||||
for key in list(env):
|
||||
if key.startswith("GIT_TRACE") or key.startswith("GCM_TRACE") or key == "GIT_CURL_VERBOSE":
|
||||
env.pop(key, None)
|
||||
no_askpass = os.path.join(tempfile.gettempdir(), "gitea-no-askpass-" + secrets.token_hex(16))
|
||||
env.update({
|
||||
"GIT_TRACE": "0",
|
||||
"GIT_TRACE2": "0",
|
||||
"GIT_TRACE2_EVENT": "0",
|
||||
"GIT_TRACE2_PERF": "0",
|
||||
"GIT_TRACE_CURL": "0",
|
||||
"GIT_TRACE_REDACT": "1",
|
||||
"GCM_TRACE": "0",
|
||||
"GCM_TRACE_SECRETS": "0",
|
||||
"GCM_TRACE_MSAUTH": "0",
|
||||
"GIT_TERMINAL_PROMPT": "0",
|
||||
"GIT_ASKPASS": no_askpass,
|
||||
"SSH_ASKPASS": no_askpass,
|
||||
"GCM_INTERACTIVE": "never",
|
||||
"GCM_PROVIDER": "generic",
|
||||
})
|
||||
return env
|
||||
|
||||
|
||||
def checked_request(site, account):
|
||||
try:
|
||||
if not site or any(ord(c) < 33 or ord(c) == 127 for c in site):
|
||||
raise ValueError()
|
||||
parsed = urlsplit(site)
|
||||
if (parsed.scheme.lower() != "https" or not parsed.hostname or parsed.username is not None
|
||||
or parsed.password is not None or parsed.path not in ("", "/")
|
||||
or parsed.query or parsed.fragment):
|
||||
raise ValueError()
|
||||
hostname = parsed.hostname.encode("idna").decode("ascii").lower()
|
||||
if ":" in hostname:
|
||||
hostname = "[" + hostname + "]"
|
||||
port = parsed.port
|
||||
host = hostname + ((":" + str(port)) if port is not None else "")
|
||||
if not account or len(account) > 1024 or any(c in account for c in "\r\n\x00"):
|
||||
raise ValueError()
|
||||
return {"protocol": b"https", "host": host.encode("ascii"), "username": account.encode("utf-8")}
|
||||
except (ValueError, UnicodeError):
|
||||
raise MigrationError("Use a clean HTTPS site origin and a valid account name.") from None
|
||||
|
||||
|
||||
def inspect_source_file(site, account, source_file):
|
||||
"""Refuse ambiguous and path-scoped entries before asking credential-store."""
|
||||
expected = checked_request(site, account)["host"]
|
||||
matches = 0
|
||||
try:
|
||||
for line in Path(source_file).read_text(encoding="utf-8").splitlines():
|
||||
parts = urlsplit(line)
|
||||
if parts.scheme.lower() != "https" or not parts.hostname or parts.username is None or parts.password is None:
|
||||
continue
|
||||
hostname = parts.hostname.encode("idna").decode("ascii").lower()
|
||||
if ":" in hostname:
|
||||
hostname = "[" + hostname + "]"
|
||||
host = (hostname + ((":" + str(parts.port)) if parts.port is not None else "")).encode("ascii")
|
||||
if host != expected or unquote(parts.username, errors="strict") != account:
|
||||
continue
|
||||
if parts.path not in ("", "/") or parts.query or parts.fragment:
|
||||
raise MigrationError("The source contains a path-scoped entry; automatic migration is unsafe.")
|
||||
matches += 1
|
||||
except MigrationError:
|
||||
raise
|
||||
except (OSError, ValueError, UnicodeError):
|
||||
raise MigrationError("The selected credential-store file cannot be inspected safely.") from None
|
||||
if matches != 1:
|
||||
raise MigrationError("The selected file does not contain exactly one unambiguous entry for this site and account.")
|
||||
|
||||
def _encode(fields):
|
||||
lines = []
|
||||
for key, value in fields.items():
|
||||
if key not in {"protocol", "host", "username", "password"}:
|
||||
raise MigrationError("Unsupported credential field.")
|
||||
if not isinstance(value, bytes) or any(c in value for c in (b"\r", b"\n", b"\x00")):
|
||||
raise MigrationError("Invalid credential field.")
|
||||
lines.append(key.encode("ascii") + b"=" + value)
|
||||
return b"\n".join(lines) + b"\n\n"
|
||||
|
||||
|
||||
def _decode(output):
|
||||
result = {}
|
||||
for line in output.splitlines():
|
||||
if not line:
|
||||
break
|
||||
if b"=" not in line:
|
||||
raise MigrationError("Credential helper returned an invalid response.")
|
||||
raw_key, value = line.split(b"=", 1)
|
||||
try:
|
||||
key = raw_key.decode("ascii")
|
||||
except UnicodeError:
|
||||
raise MigrationError("Credential helper returned an invalid response.") from None
|
||||
if key in result:
|
||||
raise MigrationError("Credential helper returned duplicate fields.")
|
||||
if key in {"protocol", "host", "username", "password"}:
|
||||
result[key] = value
|
||||
return result or None
|
||||
|
||||
|
||||
class GitHelper:
|
||||
"""Invoke exactly one helper, without Git's multi-helper fill/approve chain."""
|
||||
|
||||
def __init__(self, command, env, cwd):
|
||||
self.command, self.env, self.cwd = command, env, cwd
|
||||
|
||||
def _run(self, operation, fields):
|
||||
try:
|
||||
proc = subprocess.run(self.command + [operation], input=_encode(fields),
|
||||
stdout=subprocess.PIPE, stderr=subprocess.PIPE,
|
||||
env=self.env, cwd=self.cwd, timeout=30, check=False)
|
||||
except (OSError, subprocess.SubprocessError):
|
||||
raise MigrationError("Credential helper could not complete the operation.") from None
|
||||
if proc.returncode != 0:
|
||||
raise MigrationError("Credential helper failed; inspect its configuration without exposing credentials.")
|
||||
return proc.stdout
|
||||
|
||||
def get(self, request):
|
||||
return _decode(self._run("get", request))
|
||||
|
||||
def store(self, credential):
|
||||
self._run("store", credential)
|
||||
|
||||
|
||||
def _assert_windows_namespace_empty(namespace):
|
||||
"""Check a fresh namespace without reading any Windows credential values."""
|
||||
if sys.platform != "win32":
|
||||
raise MigrationError("Automatic migration currently requires native Windows.")
|
||||
from ctypes import wintypes
|
||||
api = ctypes.WinDLL("Advapi32.dll", use_last_error=True)
|
||||
api.CredEnumerateW.argtypes = [wintypes.LPCWSTR, wintypes.DWORD,
|
||||
ctypes.POINTER(wintypes.DWORD), ctypes.POINTER(ctypes.c_void_p)]
|
||||
api.CredEnumerateW.restype = wintypes.BOOL
|
||||
api.CredFree.argtypes = [ctypes.c_void_p]
|
||||
count, entries = wintypes.DWORD(), ctypes.c_void_p()
|
||||
if api.CredEnumerateW(namespace + ":*", 0, ctypes.byref(count), ctypes.byref(entries)):
|
||||
try:
|
||||
if count.value:
|
||||
raise MigrationError("The new credential namespace is already in use.")
|
||||
finally:
|
||||
api.CredFree(entries)
|
||||
elif ctypes.get_last_error() != 1168: # ERROR_NOT_FOUND means no entries match.
|
||||
raise MigrationError("Cannot check whether the Windows credential namespace is empty.")
|
||||
|
||||
|
||||
class NewWindowsNamespace(GitHelper):
|
||||
def __init__(self, command, env, cwd, namespace):
|
||||
super().__init__(command, env, cwd)
|
||||
self.namespace, self.first_lookup = namespace, True
|
||||
|
||||
def get(self, request):
|
||||
if self.first_lookup:
|
||||
self.first_lookup = False
|
||||
_assert_windows_namespace_empty(self.namespace)
|
||||
return None
|
||||
return super().get(request)
|
||||
|
||||
|
||||
def migrate(site, account, source, target):
|
||||
request = checked_request(site, account)
|
||||
try:
|
||||
found = source.get(request)
|
||||
if not found or not found.get("username") or not found.get("password"):
|
||||
raise MigrationError("No complete credential was returned by the selected source.")
|
||||
if found["username"] != request["username"]:
|
||||
raise MigrationError("The source credential account does not match.")
|
||||
for field in ("protocol", "host"):
|
||||
if field in found and found[field] != request[field]:
|
||||
raise MigrationError("The source credential site does not match.")
|
||||
credential = dict(request)
|
||||
credential["password"] = found["password"]
|
||||
_encode(credential)
|
||||
target_request = {"protocol": request["protocol"], "host": request["host"]}
|
||||
if target.get(target_request):
|
||||
raise MigrationError("The target already contains a credential; nothing was overwritten.")
|
||||
target.store(credential)
|
||||
result = target.get(target_request)
|
||||
if (not result or result.get("username") != credential["username"]
|
||||
or not hmac.compare_digest(result.get("password", b""), credential["password"])):
|
||||
raise MigrationError("The secure-store copy could not be verified; no Git configuration was changed.")
|
||||
except MigrationError:
|
||||
raise
|
||||
except Exception:
|
||||
raise MigrationError("Credential migration failed; no sensitive details are displayed.") from None
|
||||
|
||||
|
||||
def main(argv=None):
|
||||
parser = argparse.ArgumentParser(description=__doc__)
|
||||
parser.add_argument("--site", required=True, help="HTTPS site origin without userinfo or a repository path")
|
||||
parser.add_argument("--account", required=True, help="confirmed Gitea account")
|
||||
parser.add_argument("--source-file", required=True, help="existing git-credential-store file")
|
||||
args = parser.parse_args(argv)
|
||||
try:
|
||||
checked_request(args.site, args.account)
|
||||
if sys.platform != "win32":
|
||||
raise MigrationError("Automatic migration currently requires native Windows.")
|
||||
source_file = Path(args.source_file).expanduser().resolve()
|
||||
inspect_source_file(args.site, args.account, source_file)
|
||||
source_command = ["git", "credential-store", "--file=" + str(source_file)]
|
||||
namespace = "gitea-migrated-" + secrets.token_hex(16)
|
||||
source_env = safe_environment()
|
||||
target_env = safe_environment()
|
||||
target_env.update({"GCM_CREDENTIAL_STORE": "wincredman", "GCM_NAMESPACE": namespace})
|
||||
source = GitHelper(source_command, source_env, os.getcwd())
|
||||
target = NewWindowsNamespace(["git", "credential-manager"], target_env, os.getcwd(), namespace)
|
||||
print("New Windows credential namespace: " + namespace)
|
||||
migrate(args.site, args.account, source, target)
|
||||
print("Copied and read back the credential. The source remains unchanged and may still be plaintext.")
|
||||
print("The server has not been contacted; verify authentication before changing Git configuration.")
|
||||
return 0
|
||||
except MigrationError as exc:
|
||||
print("Migration stopped: " + str(exc), file=sys.stderr)
|
||||
return 1
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
raise SystemExit(main())
|
||||
@@ -0,0 +1,158 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Authorize one Gitea OAuth identity and save it in an isolated Windows vault.
|
||||
|
||||
This program does not change Git configuration. The agent must inspect helper
|
||||
precedence and install the resulting site-specific helper chain separately.
|
||||
"""
|
||||
|
||||
import argparse
|
||||
import hashlib
|
||||
import hmac
|
||||
import json
|
||||
import os
|
||||
from pathlib import Path
|
||||
import secrets
|
||||
import shlex
|
||||
import shutil
|
||||
import subprocess
|
||||
import sys
|
||||
import tempfile
|
||||
from urllib.request import build_opener
|
||||
|
||||
import credential_migrate as migration
|
||||
import credential_probe as probe
|
||||
import credential_win_oauth as vault
|
||||
|
||||
|
||||
GITEA_PUBLIC_CLIENT = "a4792ccc-144e-407e-86c9-5e7d8d9c3269"
|
||||
|
||||
|
||||
class BootstrapError(Exception):
|
||||
"""Contains no secret values."""
|
||||
|
||||
|
||||
def helper_command(python, installed, namespace):
|
||||
"""Build a Git shell helper without interpreting path metacharacters."""
|
||||
parts = (python.replace("\\", "/"), installed.replace("\\", "/"), "--namespace", namespace)
|
||||
return "!" + " ".join(shlex.quote(part) for part in parts)
|
||||
|
||||
|
||||
def persist_verified(site, account, remote, fields, namespace, opener):
|
||||
if not vault.complete(fields):
|
||||
raise BootstrapError("The server did not return a complete refreshable OAuth credential.")
|
||||
try:
|
||||
access = fields["password"].encode("ascii")
|
||||
fields["oauth_refresh_token"].encode("ascii")
|
||||
api, _ = probe.check_requests(site, account, access, remote, opener)
|
||||
_, git = probe.check_requests(site, "oauth2", access, remote, opener)
|
||||
if api != "matched_account" or git != "read_advertisement":
|
||||
raise BootstrapError("The returned account or Git read access could not be verified.")
|
||||
host = migration.checked_request(site, account)["host"].decode("ascii")
|
||||
target = vault.target_name(namespace, "https", host, "oauth2")
|
||||
if vault.read_record(target) is not None:
|
||||
raise BootstrapError("The new vault namespace is already occupied.")
|
||||
record = {key: fields[key] for key in vault.FIELDS}
|
||||
vault.write_record(target, record)
|
||||
saved = vault.read_record(target)
|
||||
if not saved or any(not hmac.compare_digest(saved.get(key, ""), record[key]) for key in vault.FIELDS):
|
||||
raise BootstrapError("The secure-store readback did not match; Git configuration was not changed.")
|
||||
except (UnicodeError, ValueError, migration.MigrationError, vault.VaultError, probe.ProbeError):
|
||||
raise BootstrapError("The OAuth credential could not be checked or saved safely.") from None
|
||||
|
||||
|
||||
def find_oauth_helper(explicit):
|
||||
candidate = explicit or shutil.which("git-credential-oauth")
|
||||
if not candidate and os.environ.get("LOCALAPPDATA"):
|
||||
candidate = str(Path(os.environ["LOCALAPPDATA"]) / "Microsoft" / "WinGet" / "Links" / "git-credential-oauth.exe")
|
||||
if not candidate or not Path(candidate).is_file():
|
||||
raise BootstrapError("Install the official git-credential-oauth helper first.")
|
||||
# Keep package-manager shims stable across upgrades instead of resolving the
|
||||
# current versioned target of a symlink.
|
||||
return os.path.abspath(candidate)
|
||||
|
||||
|
||||
def install_vault_helper():
|
||||
source = (Path(__file__).resolve().parent / "credential_win_oauth.py").read_bytes()
|
||||
digest = hashlib.sha256(source).hexdigest()[:20]
|
||||
directory = Path(os.environ["LOCALAPPDATA"]) / "updating-gitea-repositories"
|
||||
directory.mkdir(parents=True, exist_ok=True)
|
||||
target = directory / ("credential_win_oauth-" + digest + ".py")
|
||||
try:
|
||||
with target.open("xb") as handle:
|
||||
handle.write(source)
|
||||
except FileExistsError:
|
||||
if target.read_bytes() != source:
|
||||
raise BootstrapError("The helper installation path is occupied by different content.") from None
|
||||
return str(target)
|
||||
|
||||
|
||||
def bootstrap(args):
|
||||
if sys.platform != "win32" or not os.environ.get("LOCALAPPDATA"):
|
||||
raise BootstrapError("This OAuth vault bootstrap currently requires native Windows.")
|
||||
request = migration.checked_request(args.site, args.account)
|
||||
host = request["host"].decode("ascii")
|
||||
site = "https://" + host
|
||||
oauth_helper = find_oauth_helper(args.oauth_helper)
|
||||
env = migration.safe_environment()
|
||||
remote_process = subprocess.run(["git", "config", "--get", "remote.origin.url"], cwd=args.repo, env=env,
|
||||
stdout=subprocess.PIPE, stderr=subprocess.PIPE, timeout=10, check=True)
|
||||
remote = remote_process.stdout.decode("utf-8").strip()
|
||||
probe.git_read_url(site, remote)
|
||||
for key in list(env):
|
||||
if key.startswith("GIT_CONFIG_"):
|
||||
env.pop(key, None)
|
||||
with tempfile.TemporaryDirectory(prefix="gitea-oauth-") as directory:
|
||||
config = Path(directory) / "config"
|
||||
env.update({"GIT_CONFIG_NOSYSTEM": "1", "GIT_CONFIG_GLOBAL": str(config), "XDG_CONFIG_HOME": directory})
|
||||
settings = {"oauthClientId": args.client_id, "oauthAuthURL": "/login/oauth/authorize",
|
||||
"oauthTokenURL": "/login/oauth/access_token", "oauthScopes": "read:user write:repository"}
|
||||
for key, value in settings.items():
|
||||
subprocess.run(["git", "config", "--file", str(config), "credential." + site + "." + key, value],
|
||||
cwd=directory, env=env, stdout=subprocess.PIPE, stderr=subprocess.PIPE, timeout=10, check=True)
|
||||
data = b"protocol=https\nhost=" + request["host"] + b"\nusername=oauth2\n\n"
|
||||
print("Complete the Gitea browser authorization; do not share tokens or passwords.", flush=True)
|
||||
result = subprocess.run([oauth_helper, "get"], input=data, cwd=directory, env=env,
|
||||
stdout=subprocess.PIPE, stderr=subprocess.PIPE, timeout=300, check=True)
|
||||
fields = {"username": "oauth2"}
|
||||
for line in result.stdout.splitlines():
|
||||
if b"=" in line:
|
||||
key, value = line.split(b"=", 1)
|
||||
if key in {b"password", b"password_expiry_utc", b"oauth_refresh_token"}:
|
||||
key = key.decode("ascii")
|
||||
if key in fields:
|
||||
raise BootstrapError("The OAuth helper returned duplicate fields.")
|
||||
fields[key] = value.decode("ascii")
|
||||
installed = install_vault_helper()
|
||||
namespace = "gitea-oauth-" + secrets.token_hex(16)
|
||||
persist_verified(site, args.account, remote, fields, namespace, build_opener(probe.NoRedirect()))
|
||||
return {"site": site, "account": args.account, "namespace": namespace, "python": sys.executable,
|
||||
"vault_helper": installed, "oauth_helper": oauth_helper, "client_id": args.client_id,
|
||||
"helper_command": helper_command(sys.executable, installed, namespace)}
|
||||
|
||||
|
||||
def main(argv=None):
|
||||
parser = argparse.ArgumentParser(description=__doc__)
|
||||
parser.add_argument("--site", required=True, help="clean HTTPS site origin")
|
||||
parser.add_argument("--account", required=True, help="confirmed Gitea account")
|
||||
parser.add_argument("--repo", required=True, help="local repository on the site")
|
||||
parser.add_argument("--oauth-helper", help="path to the official git-credential-oauth executable")
|
||||
parser.add_argument("--client-id", default=GITEA_PUBLIC_CLIENT, help="registered public OAuth client ID")
|
||||
args = parser.parse_args(argv)
|
||||
try:
|
||||
info = bootstrap(args)
|
||||
print("Account verified and Git read advertisement received; credential saved in Windows Credential Manager.")
|
||||
print("A public repository can allow anonymous reads; this does not prove push access.")
|
||||
print("Non-secret configuration metadata: " + json.dumps(info, ensure_ascii=True))
|
||||
print("Git configuration is unchanged; follow the OAuth helper-chain instructions in the skill.")
|
||||
return 0
|
||||
except BootstrapError as exc:
|
||||
print("OAuth setup stopped: " + str(exc), file=sys.stderr)
|
||||
return 1
|
||||
except (migration.MigrationError, probe.ProbeError, vault.VaultError,
|
||||
OSError, subprocess.SubprocessError, UnicodeError, ValueError, KeyError):
|
||||
print("OAuth setup could not complete; no secrets are displayed and Git configuration is unchanged.", file=sys.stderr)
|
||||
return 1
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
raise SystemExit(main())
|
||||
@@ -0,0 +1,63 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Report the Gitea account for an isolated Windows OAuth credential."""
|
||||
import argparse
|
||||
import io
|
||||
import json
|
||||
import os
|
||||
import sys
|
||||
from urllib.request import Request, build_opener
|
||||
|
||||
import credential_migrate as migration
|
||||
import credential_probe as probe
|
||||
import credential_win_oauth as vault
|
||||
|
||||
|
||||
class IdentityError(Exception):
|
||||
"""Contains no secret values."""
|
||||
|
||||
|
||||
def lookup(site, namespace, opener):
|
||||
try:
|
||||
request = migration.checked_request(site, "account")
|
||||
host = request["host"].decode("ascii")
|
||||
output = io.BytesIO()
|
||||
vault.run(namespace, "get", {"protocol": "https", "host": host, "username": "oauth2"}, output)
|
||||
fields = vault.parse_request(io.BytesIO(output.getvalue()))
|
||||
token = fields.get("password", "")
|
||||
if not token or not token.isascii() or any(ord(c) < 33 or ord(c) > 126 for c in token):
|
||||
raise IdentityError("The OAuth credential is unavailable.")
|
||||
api_request = Request("https://" + host + "/api/v1/user", headers={"Authorization": "token " + token, "Accept": "application/json"})
|
||||
with opener.open(api_request, timeout=15) as response:
|
||||
body = response.read(65537)
|
||||
if len(body) > 65536:
|
||||
raise IdentityError("The identity response is too large.")
|
||||
data = json.loads(body)
|
||||
account = data.get("login") if isinstance(data, dict) else None
|
||||
if not isinstance(account, str) or not account or len(account) > 256 or any(ord(c) < 32 or ord(c) == 127 for c in account):
|
||||
raise IdentityError("The identity response is invalid.")
|
||||
return account
|
||||
except IdentityError:
|
||||
raise
|
||||
except Exception:
|
||||
raise IdentityError("The OAuth account could not be verified safely.") from None
|
||||
|
||||
|
||||
def main(argv=None):
|
||||
parser = argparse.ArgumentParser(description=__doc__)
|
||||
parser.add_argument("--site", required=True, help="clean HTTPS site origin")
|
||||
parser.add_argument("--namespace", required=True, help="non-secret isolated vault namespace")
|
||||
parser.add_argument("--repo", help="repository context for site-specific refresh configuration")
|
||||
args = parser.parse_args(argv)
|
||||
try:
|
||||
if args.repo:
|
||||
os.chdir(args.repo)
|
||||
account = lookup(args.site, args.namespace, build_opener(probe.NoRedirect()))
|
||||
print("Verified Gitea account: " + json.dumps(account, ensure_ascii=True))
|
||||
return 0
|
||||
except (IdentityError, OSError):
|
||||
print("OAuth identity check could not complete; no secrets are displayed.", file=sys.stderr)
|
||||
return 1
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
raise SystemExit(main())
|
||||
@@ -0,0 +1,118 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Check one stored GCM PAT without asking Git to erase or replace it."""
|
||||
|
||||
import argparse
|
||||
import base64
|
||||
import json
|
||||
import os
|
||||
from pathlib import Path
|
||||
import subprocess
|
||||
import sys
|
||||
from urllib.error import HTTPError, URLError
|
||||
from urllib.parse import unquote, urlsplit
|
||||
from urllib.request import HTTPRedirectHandler, Request, build_opener
|
||||
|
||||
sys.path.insert(0, str(Path(__file__).resolve().parent))
|
||||
from credential_migrate import GitHelper, MigrationError, checked_request, safe_environment
|
||||
|
||||
|
||||
class ProbeError(Exception):
|
||||
pass
|
||||
|
||||
|
||||
class NoRedirect(HTTPRedirectHandler):
|
||||
def redirect_request(self, req, fp, code, msg, headers, newurl):
|
||||
return None
|
||||
|
||||
|
||||
def git_read_url(site, remote):
|
||||
try:
|
||||
expected = checked_request(site, "account")["host"]
|
||||
parts = urlsplit(remote)
|
||||
if (parts.scheme.lower() != "https" or not parts.hostname or parts.username is not None
|
||||
or parts.password is not None or parts.query or parts.fragment):
|
||||
raise ValueError()
|
||||
actual = checked_request("https://" + parts.netloc, "account")["host"]
|
||||
path = unquote(parts.path)
|
||||
if (actual != expected or not path.endswith(".git") or ".." in path.split("/")
|
||||
or any(ord(c) < 33 or ord(c) == 127 for c in path)):
|
||||
raise ValueError()
|
||||
return site.rstrip("/") + parts.path + "/info/refs?service=git-upload-pack"
|
||||
except (ValueError, UnicodeError, MigrationError):
|
||||
raise ProbeError("The remote is not a clean repository URL on the selected HTTPS site.") from None
|
||||
|
||||
|
||||
def load_credential(site, account, helper):
|
||||
try:
|
||||
request = checked_request(site, account)
|
||||
result = helper.get(request)
|
||||
if (not result or result.get("username") != request["username"] or not result.get("password")):
|
||||
raise ProbeError("No matching complete credential was returned by GCM.")
|
||||
for field in ("protocol", "host"):
|
||||
if field in result and result[field] != request[field]:
|
||||
raise ProbeError("The GCM credential site does not match.")
|
||||
token = result["password"]
|
||||
decoded = token.decode("ascii")
|
||||
if any(ord(c) < 33 or ord(c) > 126 for c in decoded):
|
||||
raise ValueError()
|
||||
return token
|
||||
except ProbeError:
|
||||
raise
|
||||
except (MigrationError, UnicodeError, ValueError):
|
||||
raise ProbeError("The stored credential is unavailable or unsuitable for a PAT check.") from None
|
||||
|
||||
|
||||
def check_requests(site, account, token, remote, opener):
|
||||
git_url = git_read_url(site, remote)
|
||||
token_text = token.decode("ascii")
|
||||
api_request = Request(site.rstrip("/") + "/api/v1/user", headers={"Authorization": "token " + token_text, "Accept": "application/json"})
|
||||
basic = base64.b64encode(account.encode("utf-8") + b":" + token).decode("ascii")
|
||||
git_request = Request(git_url, headers={"Authorization": "Basic " + basic, "Accept": "application/x-git-upload-pack-advertisement"})
|
||||
try:
|
||||
with opener.open(api_request, timeout=15) as response:
|
||||
data = json.loads(response.read(65536))
|
||||
api_status = ("matched_account" if isinstance(data, dict) and str(data.get("login", "")).casefold() == account.casefold()
|
||||
else "identity_mismatch_or_unexpected_response")
|
||||
except HTTPError as exc:
|
||||
api_status = "http_" + str(exc.code)
|
||||
except (URLError, TimeoutError, OSError, ValueError):
|
||||
api_status = "network_tls_or_invalid_response"
|
||||
try:
|
||||
with opener.open(git_request, timeout=15) as response:
|
||||
git_status = ("read_advertisement" if response.headers.get_content_type() == "application/x-git-upload-pack-advertisement"
|
||||
else "unexpected_response")
|
||||
except HTTPError as exc:
|
||||
git_status = "http_" + str(exc.code)
|
||||
except (URLError, TimeoutError, OSError, ValueError):
|
||||
git_status = "network_tls_or_invalid_response"
|
||||
return api_status, git_status
|
||||
|
||||
|
||||
def main(argv=None):
|
||||
parser = argparse.ArgumentParser(description=__doc__)
|
||||
parser.add_argument("--repo", required=True, help="local repository path")
|
||||
parser.add_argument("--site", required=True, help="confirmed HTTPS site origin")
|
||||
parser.add_argument("--account", required=True, help="confirmed Gitea account")
|
||||
args = parser.parse_args(argv)
|
||||
try:
|
||||
env = safe_environment()
|
||||
result = subprocess.run(["git", "config", "--get", "remote.origin.url"], cwd=args.repo, env=env,
|
||||
stdout=subprocess.PIPE, stderr=subprocess.PIPE, timeout=10, check=False)
|
||||
if result.returncode:
|
||||
raise ProbeError("Could not read the repository's origin URL.")
|
||||
remote = result.stdout.decode("utf-8").strip()
|
||||
git_read_url(args.site, remote)
|
||||
helper = GitHelper(["git", "credential-manager"], env, os.path.abspath(args.repo))
|
||||
token = load_credential(args.site, args.account, helper)
|
||||
api, git = check_requests(args.site, args.account, token, remote, build_opener(NoRedirect()))
|
||||
print("API authentication: " + api)
|
||||
print("Git read access: " + git)
|
||||
print("Read access does not prove permission to push.")
|
||||
return 0 if api == "matched_account" and git == "read_advertisement" else 2
|
||||
except (ProbeError, OSError, subprocess.SubprocessError, UnicodeError):
|
||||
print("Credential check could not complete; no sensitive details are displayed.", file=sys.stderr)
|
||||
return 1
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
raise SystemExit(main())
|
||||
@@ -0,0 +1,285 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Store one Git OAuth credential in an isolated Windows vault namespace.
|
||||
|
||||
Git invokes this as a credential helper. The access token, expiry and refresh
|
||||
token are compressed into one UTF-8 vault blob; Git for Windows' wincred helper
|
||||
can exceed the Windows blob limit when it concatenates long tokens as UTF-16.
|
||||
This helper never deletes a credential in response to Git's automatic erase.
|
||||
"""
|
||||
|
||||
import argparse
|
||||
from contextlib import contextmanager
|
||||
import ctypes
|
||||
from ctypes import wintypes
|
||||
import hashlib
|
||||
import hmac
|
||||
import json
|
||||
import os
|
||||
import re
|
||||
import subprocess
|
||||
import sys
|
||||
import time
|
||||
from urllib.parse import urlencode, urljoin, urlsplit
|
||||
from urllib.request import HTTPRedirectHandler, Request, build_opener
|
||||
import zlib
|
||||
|
||||
|
||||
MAX_BLOB = 2560
|
||||
MAX_INPUT = 131072
|
||||
FIELDS = ("username", "password", "password_expiry_utc", "oauth_refresh_token")
|
||||
|
||||
|
||||
class VaultError(Exception):
|
||||
"""Contains no sensitive data."""
|
||||
|
||||
|
||||
class CREDENTIAL_ATTRIBUTE(ctypes.Structure):
|
||||
_fields_ = [("Keyword", wintypes.LPWSTR), ("Flags", wintypes.DWORD),
|
||||
("ValueSize", wintypes.DWORD), ("Value", ctypes.POINTER(ctypes.c_ubyte))]
|
||||
|
||||
|
||||
class CREDENTIAL(ctypes.Structure):
|
||||
_fields_ = [("Flags", wintypes.DWORD), ("Type", wintypes.DWORD),
|
||||
("TargetName", wintypes.LPWSTR), ("Comment", wintypes.LPWSTR),
|
||||
("LastWritten", wintypes.FILETIME), ("CredentialBlobSize", wintypes.DWORD),
|
||||
("CredentialBlob", ctypes.POINTER(ctypes.c_ubyte)), ("Persist", wintypes.DWORD),
|
||||
("AttributeCount", wintypes.DWORD), ("Attributes", ctypes.POINTER(CREDENTIAL_ATTRIBUTE)),
|
||||
("TargetAlias", wintypes.LPWSTR), ("UserName", wintypes.LPWSTR)]
|
||||
|
||||
|
||||
def target_name(namespace, protocol, host, username):
|
||||
if not re.fullmatch(r"gitea-oauth-[0-9a-f]{32}", namespace):
|
||||
raise VaultError("Invalid isolated vault namespace.")
|
||||
if (protocol != "https" and not (protocol == "http" and re.fullmatch(r"127\.0\.0\.1:[0-9]+", host))):
|
||||
raise VaultError("Only HTTPS sites are supported.")
|
||||
if username != "oauth2" or not host or any(c in host for c in "\x00\r\n/@"):
|
||||
raise VaultError("Invalid OAuth credential context.")
|
||||
context = "\0".join((protocol.lower(), host.lower(), username)).encode("utf-8")
|
||||
return namespace + ":" + hashlib.sha256(context).hexdigest()
|
||||
|
||||
|
||||
def api():
|
||||
if sys.platform != "win32":
|
||||
raise VaultError("This helper requires Windows Credential Manager.")
|
||||
library = ctypes.WinDLL("Advapi32.dll", use_last_error=True)
|
||||
library.CredReadW.argtypes = [wintypes.LPCWSTR, wintypes.DWORD, wintypes.DWORD,
|
||||
ctypes.POINTER(ctypes.POINTER(CREDENTIAL))]
|
||||
library.CredReadW.restype = wintypes.BOOL
|
||||
library.CredWriteW.argtypes = [ctypes.POINTER(CREDENTIAL), wintypes.DWORD]
|
||||
library.CredWriteW.restype = wintypes.BOOL
|
||||
library.CredFree.argtypes = [ctypes.c_void_p]
|
||||
return library
|
||||
|
||||
|
||||
def read_record(target):
|
||||
library = api()
|
||||
pointer = ctypes.POINTER(CREDENTIAL)()
|
||||
if not library.CredReadW(target, 1, 0, ctypes.byref(pointer)):
|
||||
if ctypes.get_last_error() == 1168:
|
||||
return None
|
||||
raise VaultError("Could not read the Windows credential vault.")
|
||||
try:
|
||||
size = pointer.contents.CredentialBlobSize
|
||||
if not size or size > MAX_BLOB:
|
||||
raise VaultError("Stored credential has an invalid size.")
|
||||
raw = ctypes.string_at(pointer.contents.CredentialBlob, size)
|
||||
decompressor = zlib.decompressobj()
|
||||
decoded = decompressor.decompress(raw, MAX_INPUT + 1)
|
||||
if len(decoded) > MAX_INPUT or not decompressor.eof or decompressor.unused_data:
|
||||
raise VaultError("Stored credential has invalid data.")
|
||||
result = json.loads(decoded.decode("utf-8"))
|
||||
if not isinstance(result, dict):
|
||||
raise VaultError("Stored credential has invalid data.")
|
||||
return result
|
||||
except (ValueError, UnicodeError, zlib.error):
|
||||
raise VaultError("Stored credential has invalid data.") from None
|
||||
finally:
|
||||
library.CredFree(pointer)
|
||||
|
||||
|
||||
def write_record(target, record):
|
||||
data = json.dumps(record, ensure_ascii=True, separators=(",", ":")).encode("ascii")
|
||||
blob = zlib.compress(data, 9)
|
||||
if len(blob) > MAX_BLOB:
|
||||
raise VaultError("OAuth credential exceeds the Windows vault size limit; existing data was preserved.")
|
||||
buffer = (ctypes.c_ubyte * len(blob)).from_buffer_copy(blob)
|
||||
credential = CREDENTIAL()
|
||||
credential.Type = 1 # CRED_TYPE_GENERIC
|
||||
credential.TargetName = target
|
||||
credential.Comment = "Gitea OAuth credential (isolated Git helper namespace)"
|
||||
credential.CredentialBlobSize = len(blob)
|
||||
credential.CredentialBlob = ctypes.cast(buffer, ctypes.POINTER(ctypes.c_ubyte))
|
||||
credential.Persist = 2 # CRED_PERSIST_LOCAL_MACHINE: same Windows user and machine
|
||||
credential.UserName = "oauth2"
|
||||
if not api().CredWriteW(ctypes.byref(credential), 0):
|
||||
raise VaultError("Could not write the Windows credential vault; existing data was preserved.")
|
||||
|
||||
|
||||
def parse_request(stream):
|
||||
fields, consumed = {}, 0
|
||||
for line in stream:
|
||||
consumed += len(line)
|
||||
if consumed > MAX_INPUT:
|
||||
raise VaultError("Git credential request is too large.")
|
||||
line = line.rstrip(b"\r\n")
|
||||
if not line:
|
||||
break
|
||||
if b"=" not in line:
|
||||
raise VaultError("Invalid Git credential request.")
|
||||
key, value = line.split(b"=", 1)
|
||||
try:
|
||||
key, value = key.decode("ascii"), value.decode("utf-8")
|
||||
except UnicodeError:
|
||||
raise VaultError("Invalid Git credential request.") from None
|
||||
if key in ("protocol", "host", *FIELDS):
|
||||
if key in fields or "\x00" in value or "\r" in value:
|
||||
raise VaultError("Invalid Git credential request.")
|
||||
fields[key] = value
|
||||
return fields
|
||||
|
||||
|
||||
def complete(fields):
|
||||
if not all(isinstance(fields.get(k), str) and fields[k] and "\n" not in fields[k] and "\r" not in fields[k] and "\x00" not in fields[k] for k in FIELDS):
|
||||
return False
|
||||
expiry = fields["password_expiry_utc"]
|
||||
return fields["username"] == "oauth2" and expiry.isascii() and expiry.isdigit() and len(expiry) <= 20
|
||||
|
||||
|
||||
@contextmanager
|
||||
def credential_lock(target):
|
||||
"""Serialize refresh and store across Git processes using this namespace."""
|
||||
if sys.platform != "win32":
|
||||
raise VaultError("This helper requires Windows Credential Manager.")
|
||||
kernel = ctypes.WinDLL("kernel32.dll", use_last_error=True)
|
||||
kernel.CreateMutexW.argtypes = [ctypes.c_void_p, wintypes.BOOL, wintypes.LPCWSTR]
|
||||
kernel.CreateMutexW.restype = wintypes.HANDLE
|
||||
kernel.WaitForSingleObject.argtypes = [wintypes.HANDLE, wintypes.DWORD]
|
||||
kernel.WaitForSingleObject.restype = wintypes.DWORD
|
||||
kernel.ReleaseMutex.argtypes = [wintypes.HANDLE]
|
||||
kernel.CloseHandle.argtypes = [wintypes.HANDLE]
|
||||
name = "Global\\gitea-oauth-" + hashlib.sha256(target.encode("utf-8")).hexdigest()
|
||||
handle = kernel.CreateMutexW(None, False, name)
|
||||
if not handle:
|
||||
raise VaultError("Could not lock the OAuth credential.")
|
||||
acquired = False
|
||||
try:
|
||||
# WAIT_ABANDONED also grants ownership; re-read the vault after acquiring.
|
||||
status = kernel.WaitForSingleObject(handle, 50000)
|
||||
if status not in (0, 0x80):
|
||||
raise VaultError("Could not lock the OAuth credential.")
|
||||
acquired = True
|
||||
yield
|
||||
finally:
|
||||
if acquired:
|
||||
kernel.ReleaseMutex(handle)
|
||||
kernel.CloseHandle(handle)
|
||||
|
||||
|
||||
class NoRedirect(HTTPRedirectHandler):
|
||||
def redirect_request(self, req, fp, code, msg, headers, newurl):
|
||||
return None
|
||||
|
||||
|
||||
def oauth_setting(site, name):
|
||||
env = {key: value for key, value in os.environ.items()
|
||||
if not key.startswith(("GIT_TRACE", "GCM_TRACE")) and key != "GIT_CURL_VERBOSE"}
|
||||
env.update({"GIT_TRACE": "0", "GIT_TRACE_CURL": "0", "GCM_TRACE": "0"})
|
||||
try:
|
||||
result = subprocess.run(["git", "config", "--get-urlmatch", "credential." + name, site],
|
||||
env=env, stdout=subprocess.PIPE, stderr=subprocess.PIPE, timeout=10, check=True)
|
||||
value = result.stdout.decode("utf-8").strip()
|
||||
except (OSError, subprocess.SubprocessError, UnicodeError):
|
||||
raise VaultError("OAuth refresh configuration is unavailable.") from None
|
||||
if not value or any(c in value for c in "\r\n\x00"):
|
||||
raise VaultError("OAuth refresh configuration is invalid.")
|
||||
return value
|
||||
|
||||
|
||||
def refresh_record(protocol, host, record):
|
||||
"""Rotate, then return the new record; caller persists it while holding the lock."""
|
||||
site = protocol + "://" + host
|
||||
client_id = oauth_setting(site, "oauthClientId")
|
||||
endpoint = urljoin(site + "/", oauth_setting(site, "oauthTokenURL"))
|
||||
parsed = urlsplit(endpoint)
|
||||
if (parsed.scheme != protocol or parsed.netloc.lower() != host.lower() or parsed.username is not None
|
||||
or parsed.password is not None or parsed.query or parsed.fragment or not parsed.path):
|
||||
raise VaultError("OAuth token endpoint must remain on the same site.")
|
||||
body = urlencode({"grant_type": "refresh_token", "client_id": client_id,
|
||||
"refresh_token": record["oauth_refresh_token"]}).encode("utf-8")
|
||||
request = Request(endpoint, data=body, headers={"Content-Type": "application/x-www-form-urlencoded",
|
||||
"Accept": "application/json"}, method="POST")
|
||||
try:
|
||||
with build_opener(NoRedirect()).open(request, timeout=20) as response:
|
||||
data = response.read(MAX_INPUT + 1)
|
||||
if len(data) > MAX_INPUT:
|
||||
raise ValueError()
|
||||
result = json.loads(data)
|
||||
lifetime = int(result["expires_in"])
|
||||
updated = dict(record)
|
||||
updated.update({"password": result["access_token"], "password_expiry_utc": str(int(time.time()) + lifetime),
|
||||
"oauth_refresh_token": result.get("refresh_token") or record["oauth_refresh_token"],
|
||||
"rejected": False})
|
||||
if lifetime <= 0 or not complete(updated):
|
||||
raise ValueError()
|
||||
return updated
|
||||
except Exception:
|
||||
# HTTP errors can include token-bearing response text. Never print it.
|
||||
raise VaultError("OAuth refresh failed; preserved the existing vault record.") from None
|
||||
|
||||
|
||||
def run(namespace, operation, fields, output):
|
||||
if not all(fields.get(k) for k in ("protocol", "host", "username")):
|
||||
raise VaultError("Git did not supply a complete OAuth credential context.")
|
||||
target = target_name(namespace, fields["protocol"], fields["host"], fields["username"])
|
||||
with credential_lock(target):
|
||||
record = read_record(target)
|
||||
if record is not None and not complete(record):
|
||||
raise VaultError("Stored OAuth credential is incomplete.")
|
||||
if operation == "store":
|
||||
if record is None:
|
||||
# Only the identity-verifying bootstrap may create a record.
|
||||
raise VaultError("An unverified OAuth credential cannot be stored.")
|
||||
elif fields.get("password") and hmac.compare_digest(fields["password"], record["password"]):
|
||||
if record.get("rejected") or record.get("rejections"):
|
||||
record.update({"rejected": False, "rejections": 0})
|
||||
write_record(target, record)
|
||||
# A delayed approve must never overwrite a newer rotated token.
|
||||
elif operation == "erase" and record is not None:
|
||||
if fields.get("password") and hmac.compare_digest(fields["password"], record["password"]):
|
||||
record.update({"rejected": True})
|
||||
write_record(target, record)
|
||||
elif operation == "get":
|
||||
if record is None:
|
||||
raise VaultError("No verified OAuth credential exists in this namespace.")
|
||||
if record.get("rejected") or int(record["password_expiry_utc"]) <= int(time.time()) + 30:
|
||||
record = refresh_record(fields["protocol"], fields["host"], record)
|
||||
# Persist before returning to Git, even when the Git operation fails.
|
||||
write_record(target, record)
|
||||
for key in FIELDS:
|
||||
output.write((key + "=" + record[key] + "\n").encode("utf-8"))
|
||||
|
||||
|
||||
def main():
|
||||
parser = argparse.ArgumentParser(description=__doc__)
|
||||
parser.add_argument("--namespace", required=True)
|
||||
parser.add_argument("operation", choices=("get", "store", "erase"))
|
||||
args = parser.parse_args()
|
||||
try:
|
||||
run(args.namespace, args.operation, parse_request(sys.stdin.buffer), sys.stdout.buffer)
|
||||
except VaultError as exc:
|
||||
if args.operation == "get":
|
||||
# Prevent a failed refresh from falling through to an interactive
|
||||
# helper or returning an obsolete token.
|
||||
sys.stdout.buffer.write(b"quit=1\n")
|
||||
print("Gitea OAuth vault helper stopped: " + str(exc), file=sys.stderr)
|
||||
return 1
|
||||
except (OSError, ValueError, TypeError):
|
||||
if args.operation == "get":
|
||||
sys.stdout.buffer.write(b"quit=1\n")
|
||||
print("Gitea OAuth vault helper failed; inspect its configuration without exposing secrets.", file=sys.stderr)
|
||||
return 1
|
||||
return 0
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
raise SystemExit(main())
|
||||
Reference in new issue
Block a user