fix(init): allow existing HTTP origin safely (#552)

* fix(init): redact invalid business remote

* fix(init): allow existing HTTP origin
This commit is contained in:
onyX
2026-09-15 10:43:25 +08:00
committed by GitHub
parent c83b9b1331
commit b9cd9ea6dc
5 changed files with 87 additions and 11 deletions
+2
View File
@@ -50,6 +50,8 @@ teamai init git@code.qschou.com:Enterprise/arb-workflow-kit.git --scope user
- HTTPS:预先配置 Git Credential Helper;不要把用户名、密码或 Token 写进 URL。
- SSH:预先配置 SSH Key,并确保 `ssh-agent` 能访问私钥。
`teamai init .` 是一个受限例外:它只读取当前业务仓已经配置的 `origin`。若该 origin 是遗留的 HTTP Basic URL(例如 `http://user:token@host/group/repo.git`),初始化会使用其 host/path 识别仓库,但会在写入 `.teamai/teamai.yaml`、本地 TeamAI 配置和日志前移除用户名与 Token。普通 `teamai init <url>`、clone 和其他通用 Git URL 输入仍拒绝 HTTP 与 URL 内嵌凭据。HTTP 本身不会加密 Git 传输;应尽快迁移到 HTTPS + Credential Helper 或 SSH。
clone、pull、push 均可正常使用。平台 API 操作(自动建仓、自动创建 MR/PR)无法跨不同服务统一实现,因此暂不支持;`teamai push` 会先推送分支,再提示用户到对应平台手动创建 MR,并以非零退出码表明自动 PR/MR 创建未完成。
若已有 `provider: git` 的仓库在创建 PR 时失败,CLI 会额外检查该 host;确认是 GitLab 后,会提示设置实例地址和 token,并将团队仓库 `teamai.yaml` 的 `provider` 改为 `gitlab`。这个提示不会自动修改配置,也不会撤回已经推送的分支。
+12 -1
View File
@@ -6,7 +6,7 @@ vi.mock('node:child_process', () => ({
import { spawnSync } from 'node:child_process';
import { GenericGitProvider, normalizeGitIdentity } from '../providers/git/index.js';
import { parseGenericGitRepoInput } from '../providers/git/repo-url.js';
import { parseGenericGitExistingRemote, parseGenericGitRepoInput } from '../providers/git/repo-url.js';
import { detectProvider, getProvider } from '../providers/registry.js';
const mockedSpawnSync = spawnSync as Mock;
@@ -95,6 +95,17 @@ describe('parseGenericGitRepoInput', () => {
expect(message).toMatch(/query strings and fragments are not supported/);
expect(message).not.toContain('secret-value');
});
it('parses an existing insecure origin without preserving its credentials', () => {
expect(parseGenericGitExistingRemote(
'http://user:token-must-not-persist@git.example.com/group/repo.git',
)).toEqual({
owner: 'group',
repo: 'repo',
httpsUrl: 'http://git.example.com/group/repo.git',
projectId: encodeURIComponent('group/repo'),
});
});
});
describe('generic Git identity', () => {
+38
View File
@@ -12,6 +12,7 @@ const mockGit = {
commit: vi.fn(),
push: vi.fn(),
revparse: vi.fn().mockResolvedValue('main'),
raw: vi.fn(),
};
vi.mock('simple-git', () => ({
@@ -718,4 +719,41 @@ describe('init', () => {
);
});
});
describe('single-repo mode', () => {
it('accepts an existing HTTP origin without persisting its credentials', async () => {
pathExistsFn = (p: string) => p.endsWith(`${path.sep}.git`) || p.endsWith('/.git');
mockGit.raw.mockResolvedValue(
'http://user:token-must-not-appear@git.example.com/group/repo.git\n',
);
const { log } = await import('../utils/logger.js');
const { loadTeamConfig, saveLocalConfigForScope } = await import('../config.js');
vi.mocked(loadTeamConfig).mockResolvedValue({
team: 'repo',
description: '',
repo: 'http://git.example.com/group/repo.git',
provider: 'git',
reviewers: [],
sharing: { rules: { enforced: [] }, docs: {}, env: { injectShellProfile: true } },
toolPaths: {},
} as never);
await init({ repo: '.', dryRun: true });
const errorCalls = vi.mocked(log.error).mock.calls.map(([message]) => String(message));
const debugCalls = vi.mocked(log.debug).mock.calls.map(([message]) => String(message));
expect(mockExit).not.toHaveBeenCalled();
expect(errorCalls).not.toContainEqual(expect.stringContaining('Could not parse the business repo remote'));
expect(errorCalls.join('\n')).not.toContain('token-must-not-appear');
expect(debugCalls.join('\n')).not.toContain('token-must-not-appear');
expect(saveLocalConfigForScope).toHaveBeenCalledWith(
expect.objectContaining({
repo: expect.objectContaining({ remote: 'http://git.example.com/group/repo.git' }),
}),
'project',
process.cwd(),
);
});
});
});
+6 -3
View File
@@ -6,6 +6,7 @@ import { reconcileTeamHooksForConfig } from './hooks.js';
import { configureGitUser, initRepo, isGitRepo, getRemoteUrl, remotesMatch, redactGitCredentials } from './utils/git.js';
import { pushRepoDirectly } from './utils/git.js';
import { getProvider, detectProviderForInit, RepoNotFoundError, OrganizationNotFoundError, RepoCreatePermissionError } from './providers/index.js';
import { parseGenericGitExistingRemote } from './providers/git/repo-url.js';
import { ensureDir, writeFile, pathExists, expandHome, readFileSafe, remove } from './utils/fs.js';
import { log, spinner } from './utils/logger.js';
import {
@@ -744,13 +745,15 @@ export async function initSelfRepo(options: GlobalOptions & {
return;
}
const provider = getProvider(providerName);
log.debug(`Detected provider: ${providerName} (from ${remoteUrl})`);
log.debug(`Detected provider: ${providerName} (from ${redactGitCredentials(remoteUrl)})`);
let repoInfo;
try {
repoInfo = provider.parseRepoInput(remoteUrl);
repoInfo = providerName === 'git'
? parseGenericGitExistingRemote(remoteUrl)
: provider.parseRepoInput(remoteUrl);
} catch (e) {
log.error(`Could not parse the business repo remote "${remoteUrl}": ${(e as Error).message}`);
log.error(`Could not parse the business repo remote "${redactGitCredentials(remoteUrl)}": ${(e as Error).message}`);
process.exit(1);
return;
}
+29 -7
View File
@@ -39,15 +39,19 @@ function buildRepoInfo(owner: string, repo: string, remoteUrl: string): RepoInfo
};
}
/** Parse a full HTTPS or SSH clone URL for an arbitrary Git host. */
export function parseGenericGitRepoInput(input: string): RepoInfo {
const trimmed = input.trim();
interface ParseOptions {
allowExistingInsecureOrigin?: boolean;
}
if (/^http:\/\//i.test(trimmed)) {
function parseGenericGitRepoInputWithOptions(input: string, options: ParseOptions = {}): RepoInfo {
const trimmed = input.trim();
const allowExistingInsecureOrigin = options.allowExistingInsecureOrigin === true;
if (/^http:\/\//i.test(trimmed) && !allowExistingInsecureOrigin) {
throw invalidRepoUrl('plain HTTP is not supported; use HTTPS or SSH');
}
if (/^https:\/\//i.test(trimmed) || /^ssh:\/\//i.test(trimmed)) {
if (/^https?:\/\//i.test(trimmed) || /^ssh:\/\//i.test(trimmed)) {
let parsed: URL;
try {
parsed = new URL(trimmed);
@@ -56,7 +60,7 @@ export function parseGenericGitRepoInput(input: string): RepoInfo {
}
const httpCredentials = /^https?:$/i.test(parsed.protocol) && (parsed.username || parsed.password);
if (!parsed.hostname || parsed.password || httpCredentials) {
if (!parsed.hostname || (!allowExistingInsecureOrigin && (parsed.password || httpCredentials))) {
throw new Error(
'Invalid Git repo URL. Do not embed credentials in the URL; '
+ 'configure a Git credential helper or SSH key instead.',
@@ -67,7 +71,9 @@ export function parseGenericGitRepoInput(input: string): RepoInfo {
}
const { owner, repo, fullPath } = parsePath(parsed.pathname);
const auth = parsed.username ? `${parsed.username}@` : '';
// A pre-existing local origin may contain HTTP Basic credentials. They are
// needed only by the user's Git config, never by TeamAI configuration.
const auth = /^ssh:$/i.test(parsed.protocol) && parsed.username ? `${parsed.username}@` : '';
const remoteUrl = `${parsed.protocol}//${auth}${parsed.host}/${fullPath}.git`;
return buildRepoInfo(owner, repo, remoteUrl);
}
@@ -89,3 +95,19 @@ export function parseGenericGitRepoInput(input: string): RepoInfo {
throw invalidRepoUrl();
}
/** Parse a full HTTPS or SSH clone URL for an arbitrary Git host. */
export function parseGenericGitRepoInput(input: string): RepoInfo {
return parseGenericGitRepoInputWithOptions(input);
}
/**
* Parse the already-configured `origin` of a single-repo installation.
*
* This accepts legacy HTTP and URL userinfo only to identify the existing
* repository. The returned canonical URL deliberately strips userinfo so
* TeamAI never persists credentials into config or teamai.yaml.
*/
export function parseGenericGitExistingRemote(input: string): RepoInfo {
return parseGenericGitRepoInputWithOptions(input, { allowExistingInsecureOrigin: true });
}