feat: add repository git credential fixer
This commit is contained in:
1 parent
25cea75eb5
commit
c6e042db86
7 files changed
+120
-10
No files matched your search
@@ -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. |
|
||||||
|
|||||||
@@ -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),
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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
@@ -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
@@ -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
@@ -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": {
|
||||||
|
|||||||
@@ -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 })
|
||||||
|
}
|
||||||
|
})
|
||||||
Reference in new issue
Block a user