feat: add repository git credential fixer

This commit is contained in:
Agent committed 2026-10-18 10:55:21 +00:00
1 parent 25cea75eb5
commit c6e042db86
7 files changed
+120 -10

No files matched your search

+4 -1
View File
@@ -20,8 +20,11 @@ GitHub and Gitea/Forgejo integration for the DeepSeek Harness (DSH): **clone rep
| `forge_pulls` | list / get / diff / create / comment / merge / close / reopen. | | `forge_pulls` | list / get / diff / create / comment / merge / close / reopen. |
| `forge_clone` | Clone with account credentials (private repos; SSH URLs switched to HTTPS+token when no SSH key). | | `forge_clone` | Clone with account credentials (private repos; SSH URLs switched to HTTPS+token when no SSH key). |
| `forge_push` | Push with the account token (sets upstream; `--force-with-lease` only on request). | | `forge_push` | Push with the account token (sets upstream; `--force-with-lease` only on request). |
| `forge_fix_credentials` | Configure the local repository’s `.git/config` to use the bundled credential helper, replacing stale or broken local `credential.helper` entries. |
| `forge_api` | Raw authenticated REST call (GitHub `https://api.github.com`, Gitea/Forgejo `/api/v1`). | | `forge_api` | Raw authenticated REST call (GitHub `https://api.github.com`, Gitea/Forgejo `/api/v1`). |
`forge_fix_credentials` changes only the target repository’s local config; it does not modify global git configuration or expose tokens. Omit `path` to target the session working directory, or pass a repository path (including a nested path) to update that repository.
Clone and push use a bundled git **credential helper** (`credential-helper.mjs`) passed via `git -c credential.helper=…`; it answers with the token of the gh/tea account whose host matches, and prints nothing otherwise (git falls through to your normal helpers). Your global git config is not modified. Clone and push use a bundled git **credential helper** (`credential-helper.mjs`) passed via `git -c credential.helper=…`; it answers with the token of the gh/tea account whose host matches, and prints nothing otherwise (git falls through to your normal helpers). Your global git config is not modified.
## Requirements ## Requirements
@@ -38,7 +41,7 @@ At least one of `gh` / `tea` must be installed and logged in for the account fea
| File | Role | | File | Role |
| --- | --- | | --- | --- |
| `index.js` | Host plugin: HTTP routes under `/git-forge/*` (accounts, repos, auth, ls, clone) and the `forge_*` tools. Injects `webServer`, `connection`, `tools`. | | `index.js` | Host plugin: HTTP routes under `/git-forge/*` (accounts, repos, auth, ls, clone) and the `forge_*` tools. Injects `webServer`, `connection`, `tools`. |
| `forge.mjs` | Account discovery (gh + tea), REST access, git URL parsing, clone and push. | | `forge.mjs` | Account discovery (gh + tea), REST access, git URL parsing, clone, push, and repository credential-helper configuration. |
| `auth.mjs` | Drives `gh auth login` / `tea login add` / logout for the settings panel. | | `auth.mjs` | Drives `gh auth login` / `tea login add` / logout for the settings panel. |
| `credential-helper.mjs` | git credential helper used by clone/push. | | `credential-helper.mjs` | git credential helper used by clone/push. |
| `client.js` | Web client: clone dialog, local folder picker, accounts settings panel. | | `client.js` | Web client: clone dialog, local folder picker, accounts settings panel. |
+46 -3
View File
@@ -297,9 +297,13 @@ export async function listRepos(account, { query, owner, max = 300, signal } = {
// ---- git operations --------------------------------------------------------- // ---- git operations ---------------------------------------------------------
export function credentialHelperCmd() {
return `!'${process.execPath.replace(/'/g, "'\\''")}' '${HELPER_PATH.replace(/'/g, "'\\''")}'`
}
/** git -c options that make git ask our helper for credentials of known accounts. */ /** git -c options that make git ask our helper for credentials of known accounts. */
export function credentialArgs() { export function credentialArgs() {
const helper = `!'${process.execPath.replace(/'/g, "'\\''")}' '${HELPER_PATH.replace(/'/g, "'\\''")}'` const helper = credentialHelperCmd()
return ['-c', 'credential.helper=', '-c', `credential.helper=${helper}`] return ['-c', 'credential.helper=', '-c', `credential.helper=${helper}`]
} }
@@ -347,8 +351,7 @@ export async function cloneRepo({ url, parent, name, branch, depth, signal }) {
} }
// Keep the repo's own pushes/fetches authenticated through the helper. // Keep the repo's own pushes/fetches authenticated through the helper.
if (account) { if (account) {
await run('git', ['-C', target, 'config', '--local', '--add', 'credential.helper', '']) await fixRepoConfig(target)
await run('git', ['-C', target, 'config', '--local', '--add', 'credential.helper', credentialArgs()[3].slice('credential.helper='.length)])
} }
return { path: target, url: effective, account: account ? account.id : null } return { path: target, url: effective, account: account ? account.id : null }
} }
@@ -421,3 +424,43 @@ export async function resolveRepo({ repo, account: accountId, cwd, remote = 'ori
if (!account) throw new ForgeError(`no authorized account for host ${parsed.host}; available: ${accounts.map((a) => `${a.id} (${a.host})`).join(', ')}`, 400) if (!account) throw new ForgeError(`no authorized account for host ${parsed.host}; available: ${accounts.map((a) => `${a.id} (${a.host})`).join(', ')}`, 400)
return { account, owner: parsed.owner, name: parsed.name } return { account, owner: parsed.owner, name: parsed.name }
} }
/** Configure or update a local repository to use the git-forge credential helper. */
export async function fixRepoConfig(repoPath) {
const top = await gitIn(repoPath, ['rev-parse', '--show-toplevel'])
if (top.code !== 0) throw new ForgeError(`not a git repository: ${repoPath}`, 400)
const root = top.stdout.trim()
const values = (result) => {
if (!result || result.code !== 0 || result.stdout === '') return []
const text = String(result.stdout)
return (text.endsWith('\n') ? text.slice(0, -1) : text).split('\n')
}
const existing = await gitIn(root, ['config', '--local', '--get-all', 'credential.helper'])
const previous = values(existing)
const cmd = credentialHelperCmd()
const unset = await gitIn(root, ['config', '--local', '--unset-all', 'credential.helper'])
// git config exits with 5 when there was no value to unset; that is expected.
if (unset.code !== 0 && unset.code !== 5) {
throw new ForgeError(`could not update local git credentials in ${root}: ${unset.stderr || 'failed to clear credential.helper'}`, 422)
}
const reset = await gitIn(root, ['config', '--local', '--add', 'credential.helper', ''])
if (reset.code !== 0) {
throw new ForgeError(`could not update local git credentials in ${root}: ${reset.stderr || 'failed to reset credential.helper'}`, 422)
}
const add = await gitIn(root, ['config', '--local', '--add', 'credential.helper', cmd])
if (add.code !== 0) {
throw new ForgeError(`could not update local git credentials in ${root}: ${add.stderr || 'failed to install credential.helper'}`, 422)
}
const check = await gitIn(root, ['config', '--local', '--get-all', 'credential.helper'])
if (check.code !== 0) throw new ForgeError(`could not verify local git credentials in ${root}`, 422)
return {
path: root,
fixed: true,
helper: cmd,
previous,
current: values(check),
}
}
+17 -2
View File
@@ -4,8 +4,7 @@
import os from 'node:os' import os from 'node:os'
import path from 'node:path' import path from 'node:path'
import { import {
ForgeError, api, apiList, cloneRepo, deriveName, gitIn, listAccounts, listRepos, ForgeError, api, apiList, cloneRepo, deriveName, fixRepoConfig, gitIn, listAccounts, listRepos, normRepo, publicAccount, pushRepo, resolveRepo,
normRepo, publicAccount, pushRepo, resolveRepo,
} from './forge.mjs' } from './forge.mjs'
import { import {
accountsWithStatus, cliStatus, giteaLogin, githubDeviceCancel, githubDeviceStart, githubDeviceStatus, accountsWithStatus, cliStatus, giteaLogin, githubDeviceCancel, githubDeviceStart, githubDeviceStatus,
@@ -348,6 +347,15 @@ async function toolPush(args, exec) {
return pushRepo({ cwd, remote: args.remote, branch: args.branch, force: !!args.force, tags: !!args.tags, signal: exec?.signal }) return pushRepo({ cwd, remote: args.remote, branch: args.branch, force: !!args.force, tags: !!args.tags, signal: exec?.signal })
} }
async function toolFixCredentials(args, exec) {
const cwd = sessionCwd(exec)
const requested = String(args.path ?? '').trim()
const repoPath = requested
? path.resolve(cwd, requested.replace(/^~(?=\/|$)/, os.homedir()))
: cwd
return fixRepoConfig(repoPath)
}
// ---- tool definitions --------------------------------------------------------- // ---- tool definitions ---------------------------------------------------------
const S = (type, description, extra = {}) => ({ type, description, ...extra }) const S = (type, description, extra = {}) => ({ type, description, ...extra })
@@ -482,6 +490,13 @@ const TOOLS = [
}, },
required: ['path'], required: ['path'],
}, toolApi), }, toolApi),
defineTool({
name: 'forge_fix_credentials',
description: 'Fix the local git credential helper configuration inside a repository. Replaces only that repository’s .git/config credential.helper values with the git-forge helper, so plain git fetch, pull, and push can authenticate through authorized GitHub and Gitea accounts. Does not change global git configuration.',
properties: {
path: S('string', 'Repository directory (default: the session working directory). Relative paths use the session working directory; a nested path resolves to its repository root.'),
},
}, toolFixCredentials),
] ]
// ---- plugin ----------------------------------------------------------------- // ---- plugin -----------------------------------------------------------------
+1 -1
View File
@@ -1 +1 @@
{ "meta": { "title": "Git Forge", "description": "Clone repositories from your GitHub (gh) and Gitea/Forgejo (tea-cli) accounts or any git URL straight into a new workspace, and give agents forge_* tools for repos, issues, pull requests and authenticated push." } } { "meta": { "title": "Git Forge", "description": "Clone repositories from your GitHub (gh) and Gitea/Forgejo (tea-cli) accounts or any git URL straight into a new workspace, and give agents forge_* tools for repos, issues, pull requests, credentials and authenticated push." } }
+1 -1
View File
@@ -1 +1 @@
{ "meta": { "title": "Git Forge", "description": "从 GitHub(gh)和 Gitea/Forgejo(tea-cli)账号或任意 git 地址克隆仓库为新工作区,并为智能体提供仓库、议题、拉取请求和认证推送等 forge_* 工具。" } } { "meta": { "title": "Git Forge", "description": "从 GitHub(gh)和 Gitea/Forgejo(tea-cli)账号或任意 git 地址克隆仓库为新工作区,并为智能体提供仓库、议题、拉取请求、凭据修复和认证推送等 forge_* 工具。" } }
+2 -2
View File
@@ -1,7 +1,7 @@
{ {
"name": "@local/git-forge", "name": "@local/git-forge",
"version": "1.0.1", "version": "1.1.0",
"description": "Clone repositories from GitHub (gh) and Gitea/Forgejo (tea-cli) accounts into new workspaces, plus forge_* agent tools for repos, issues, pull requests and authenticated push.", "description": "Clone repositories from GitHub (gh) and Gitea/Forgejo (tea-cli) accounts into new workspaces, plus forge_* agent tools for repos, issues, pull requests, authenticated push, and fixing git credentials.",
"private": true, "private": true,
"type": "module", "type": "module",
"exports": { "exports": {
+49
View File
@@ -0,0 +1,49 @@
import test from 'node:test'
import assert from 'node:assert/strict'
import fs from 'node:fs'
import os from 'node:os'
import path from 'node:path'
import { execFileSync } from 'node:child_process'
import { credentialHelperCmd, fixRepoConfig } from '../forge.mjs'
function git(cwd, ...args) {
return execFileSync('git', ['-C', cwd, ...args], { encoding: 'utf8' }).trimEnd()
}
function tempRepo() {
const root = fs.mkdtempSync(path.join(os.tmpdir(), 'git-forge-credentials-'))
const repo = path.join(root, 'repo')
fs.mkdirSync(repo)
execFileSync('git', ['init', '--quiet', repo])
return { root, repo }
}
test('forge_fix_credentials replaces local helpers and resolves nested paths', async () => {
const { root, repo } = tempRepo()
try {
git(repo, 'config', '--local', '--add', 'credential.helper', 'store')
git(repo, 'config', '--local', '--add', 'credential.helper', 'old-helper')
const nested = path.join(repo, 'nested')
fs.mkdirSync(nested)
const result = await fixRepoConfig(nested)
const expected = credentialHelperCmd()
assert.equal(result.path, repo)
assert.equal(result.fixed, true)
assert.deepEqual(result.previous, ['store', 'old-helper'])
assert.deepEqual(result.current, ['', expected])
assert.deepEqual(git(repo, 'config', '--local', '--get-all', 'credential.helper').split('\n'), ['', expected])
} finally {
fs.rmSync(root, { recursive: true, force: true })
}
})
test('forge_fix_credentials rejects a non-repository path', async () => {
const root = fs.mkdtempSync(path.join(os.tmpdir(), 'git-forge-not-repo-'))
try {
await assert.rejects(() => fixRepoConfig(root), /not a git repository/)
} finally {
fs.rmSync(root, { recursive: true, force: true })
}
})