Skip to content

Commit 9d0f77b

Browse files
committed
feat(git): 三端 git 缺凭据统一走容器 credential helper 文件 IPC 弹窗
新增自定义 git credential helper(assets/aicode/git-credential-aicode),经 PRoot -b 绑定 即容器内 /root/.aicode/git-credential-aicode,由 ContainerInstaller 启动即提取并赋可执行位; LinuxContainerEngine provisioning 在 .gitconfig 登记为第二个 credential.helper,排在 store 之后 兜底(delete 重配幂等:先 --replace-all 清旧 helper 值再 --add aicode)。 新增 CredentialRequestBridge:@singleton 文件 IPC 桥,FileObserver + 兜底低频轮询监听 cred-req-*(凭据去重防双触发)。helper 自查 store 未命中该 host 时写请求文件 → app 收到 置 request StateFlow → GlobalCredentialDialogHost 在任意页面弹 CredentialPromptDialog → 用户回填写 cred-resp 明文 KV → helper 轮询取走喂 git 自动续跑。回填凭据同时落 Room 默认条 + syncAll 落 store,后续免填。三端(UI / AI Bash / 交互终端)裸 git 缺凭据统一走此链,不再逐路径适配。 凭据请求在途计数:bridge 收到请求 inc、写回响应 dec,配合 LinuxContainerEngine.launchKillWatchdog 改造——helper 在途时 watchdog 暂缓超时(每轮宽限 1min 重查,绝对上限 30min 即在途也兜底杀), 避免用户离开几分钟填凭据导致推送被 120s 强杀重来。 后端收敛:移除 GitRepository/UI 旧的 resolveRemote + ensureHasCredential + CredentialMissingException 缺凭据前置检查链路(拉取改裸 git pull、凭据由 helper 链兜底);GitViewModel 移除 pendingCredential/ saveCredentialAndRetry 等手写重试态。顺手:push 无上游自动 git push --set-upstream 建关联(仿 Win/Mac 客户端首推体验);GitRepository/GitViewModel 新增 initRepo(UI 非仓态一键初始化)。 冒烟 :app:assembleUniversalDebug 通过。
1 parent a916f43 commit 9d0f77b

11 files changed

Lines changed: 525 additions & 137 deletions

File tree

Lines changed: 65 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,65 @@
1+
#!/bin/sh
2+
# 自定义 git credential helper:三端(UI / AI Bash / 交互终端)git 缺凭据时的统一兜底入口。
3+
#
4+
# 配置在 .gitconfig 的 credential.helper,排在 `store` 之后(双保险):
5+
# git config --global credential.helper 'store --file=/root/.aicode/git-credentials'
6+
# git config --global credential.helper '/root/.aicode/git-credential-aicode'
7+
# git 的多 helper 是顺序串行、凭据累加——本 helper **总会被调**,故被调用 ≠ 未登录。
8+
# 必须在此自查 /root/.aicode/git-credentials 是否已有该 host 的条目:
9+
# 命中 → 空输出退出,让链上 store 已返回的凭据生效(或 store 未给也无所谓,git 自然失败)。
10+
# 未命中 → 经文件 IPC 通知 app 弹窗(写 cred-req-<id>)→ 阻塞轮询 cred-resp-<id> →
11+
# 取回明文 KV 喂给 git(git 自己处理),随后顺手清理两个临时文件。
12+
#
13+
# 经 PRoot -b 绑定:容器内 /root/.aicode ⇔ 宿主 filesDir/aicode 同一 inode,
14+
# 故 app 侧 FileObserver 监听 filesDir/aicode 的 cred-req-* 创建事件即可触发弹窗。
15+
# 仅 HTTPS 生效:git 只对 http/https remote 调 credential helper 的 get,SSH 走 ssh-agent 不触发。
16+
#
17+
# shebang 用 /bin/sh:PRoot Alpine 自带 busybox ash 永在,provisioning 失败时也得能跑(不依赖 bash)。
18+
19+
20+
op="$1"
21+
# 只处理 get;store/erase 空退出(凭据落盘由 app 侧 GitCredentialsFileSync 负责)。
22+
[ "$op" = "get" ] || exit 0
23+
24+
# 读 stdin 全部 KV,拿 host(git credential 协议字段已小写归一)。
25+
host=""
26+
while IFS= read -r line; do
27+
case "$line" in
28+
host=*) host="${line#host=}" ;;
29+
esac
30+
done
31+
[ -n "$host" ] || exit 0 # 没 host,让 store 兜
32+
33+
# 自查 store 文件:每行形如 https://user:token@host(URL 编码的 user:token),host 在 @ 后、行尾。
34+
CREDS=/root/.aicode/git-credentials
35+
if [ -f "$CREDS" ]; then
36+
# @host 行尾精确匹配,避免 gist.github.com 误命中 github.com。
37+
if grep -q "@${host}\$" "$CREDS" 2>/dev/null; then
38+
exit 0 # 已有凭据:静默退出,让链上 store 已返回的凭据生效。
39+
fi
40+
fi
41+
42+
# 未命中 → 写请求文件(原子写:先 .tmp 再 mv,触发 inotify 且避免 app 读半截),阻塞轮询响应。
43+
AICODE=/root/.aicode
44+
RID="$(date +%s)-$$"
45+
REQ="$AICODE/cred-req-$RID"
46+
RESP="$AICODE/cred-resp-$RID"
47+
printf 'host=%s\n' "$host" > "$REQ.tmp"
48+
mv "$REQ.tmp" "$REQ"
49+
50+
# 轮询响应(200ms)。绝对上限 ~25min(配合 watchdog 的 30min 兜底),超时放弃,删请求退出非零。
51+
i=0
52+
while [ ! -f "$RESP" ]; do
53+
sleep 0.2
54+
i=$((i + 1))
55+
[ "$i" -gt 7500 ] && { rm -f "$REQ"; exit 1; }
56+
done
57+
58+
# 响应文件内容:明文 KV username=...\npassword=... 或 cancel=1。
59+
if grep -q '^cancel=1' "$RESP" 2>/dev/null; then
60+
rm -f "$REQ" "$RESP"
61+
exit 1 # 用户取消:退出非零让 git 报认证失败(与无凭据行为一致)。
62+
fi
63+
64+
cat "$RESP" # 明文 KV 直接喂回 git(git 自己处理百分号解码与否)。
65+
rm -f "$REQ" "$RESP"

app/src/main/java/com/aicode/AIEditorApp.kt

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -41,6 +41,11 @@ class AIEditorApp : Application() {
4141
@Inject
4242
lateinit var gitCredentialsFileSync: GitCredentialsFileSync
4343

44+
/** 三端 git 缺凭据的统一弹窗桥:监听容器内 credential helper 经文件 IPC 发来的未登录请求,
45+
* 暴露 StateFlow 供全局弹窗回填后回喂 git。必须在主线程启动(FileObserver 绑定主 Looper)。 */
46+
@Inject
47+
lateinit var credentialRequestBridge: com.aicode.feature.credentials.data.CredentialRequestBridge
48+
4449
/** 长驻作用域:持续把持久化的日志等级同步到 FileLogger。 */
4550
private val appScope = CoroutineScope(SupervisorJob() + Dispatchers.Default)
4651

@@ -50,6 +55,9 @@ class AIEditorApp : Application() {
5055
AILogger.init(this)
5156
installCrashHandler()
5257
createNotificationChannels()
58+
// 主线程启动凭据请求监听(FileObserver 必须主线程创建与 startWatching),
59+
// 监听容器内 credential helper 写来的 cred-req-* → 全局弹窗回填 → 回喂 git 续跑。
60+
credentialRequestBridge.start()
5361
// 启动即把最新的内置指南手册提取到私有配置目录
5462
appScope.launch {
5563
ContainerInstaller.extractDocs(this@AIEditorApp)

app/src/main/java/com/aicode/MainActivity.kt

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -68,6 +68,10 @@ class MainActivity : ComponentActivity() {
6868
@Inject
6969
lateinit var themeSettings: ThemeSettingsRepository
7070

71+
/** 三端(UI/AI Bash/交互终端)git 缺凭据统一弹窗桥:在 AIEditorApp 启动后监听 helper 的文件 IPC 请求。 */
72+
@Inject
73+
lateinit var credentialRequestBridge: com.aicode.feature.credentials.data.CredentialRequestBridge
74+
7175
private val storagePermissionLauncher = registerForActivityResult(
7276
ActivityResultContracts.RequestMultiplePermissions()
7377
) { grants ->
@@ -114,6 +118,10 @@ class MainActivity : ComponentActivity() {
114118
color = MaterialTheme.colorScheme.background
115119
) {
116120
AppNavigation()
121+
// 全局凭据弹窗:覆盖所有页面,命令行 git 缺凭据在任意页面都能弹。
122+
com.aicode.feature.credentials.presentation.component.GlobalCredentialDialogHost(
123+
bridge = credentialRequestBridge
124+
)
117125
}
118126
}
119127
}

app/src/main/java/com/aicode/feature/agent/domain/container/ContainerInstaller.kt

Lines changed: 31 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -47,6 +47,33 @@ class ContainerInstaller @Inject constructor(
4747
}
4848
}
4949

50+
/**
51+
* 从 assets 提取自定义 git credential helper 到 ~/.aicode/git-credential-aicode 并赋可执行位。
52+
*
53+
* 经 [LinuxContainerEngine] 的 -b 绑定即容器内 /root/.aicode/git-credential-aicode,
54+
* 由 [LinuxContainerEngine.provisionIfNeeded] 在 `.gitconfig` 里登记为第二个 credential.helper,
55+
* 排在 `store` 之后兜底未登录(双保险)。helper 详行为见 assets/aicode/git-credential-aicode。
56+
*
57+
* 启动即提取、独立于 provisioning 成败:provisioning 失败时 git 没装上,helper 配置不存在也无所谓;
58+
* 一旦 git 装好且配置登记,helper 立即可用。提取失败仅告警不抛(helper 缺席仅导致未登录时无弹窗,
59+
* git 仍能裸跑报认证失败,不致命)。
60+
*/
61+
fun extractCredentialHelper(context: Context) {
62+
val dest = File(File(context.filesDir, "aicode"), "git-credential-aicode")
63+
runCatching {
64+
dest.parentFile?.mkdirs()
65+
context.assets.open("aicode/git-credential-aicode").use { input ->
66+
dest.outputStream().use { output -> input.copyTo(output) }
67+
}
68+
// 对所有用户赋可执行位(proot 进程以 App uid 运行,参照 [copyAsset] 的 0o111 模式)。
69+
if (!dest.setExecutable(true, false)) {
70+
FileLogger.w(TAG, "setExecutable 返回 false: ${dest.absolutePath}")
71+
}
72+
}.onFailure {
73+
FileLogger.w(TAG, "提取 git credential helper 失败: ${it.message}", it)
74+
}
75+
}
76+
5077
/**
5178
* 与 assets 里 alpine-rootfs 版本对应的 apk 分支,用于拼镜像源地址。
5279
* 固定 v3.21:与 [INSTALL_VERSION](alpine-3.21.3)一致;该版本 apk-tools 2.14 在 proot 下可靠。
@@ -170,12 +197,16 @@ class ContainerInstaller @Inject constructor(
170197
init {
171198
CoroutineScope(Dispatchers.IO).launch {
172199
extractDocs(context)
200+
extractCredentialHelper(context)
173201
}
174202
}
175203

176204
/** 从 assets 提取文档到 ~/.aicode/docs (内置使用指导) */
177205
fun extractDocs() = extractDocs(context)
178206

207+
/** 从 assets 提取 git credential helper 到 ~/.aicode/git-credential-aicode 并赋可执行位。 */
208+
fun extractCredentialHelper() = extractCredentialHelper(context)
209+
179210
/** 从 assets 复制 proot 全套(二进制 + loader + 动态依赖库)到私有目录并赋权限 */
180211
private fun installProot() {
181212
// 二进制与 loader:需可执行

app/src/main/java/com/aicode/feature/agent/domain/container/LinuxContainerEngine.kt

Lines changed: 45 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -76,6 +76,21 @@ class LinuxContainerEngine @Inject constructor(
7676
/** 串行化 ensureInstalled,避免多入口并发触发重复解压/配置;后到者等待后看到就绪直接置 Ready。 */
7777
private val initMutex = Mutex()
7878

79+
/**
80+
* 当前正在途的凭据请求计数(自定义 credential helper 阻塞等 app 弹窗回填的对数)。
81+
*
82+
* 由 [com.aicode.feature.credentials.data.CredentialRequestBridge] 在收到 helper 的 cred-req 时 inc、
83+
* 写回 cred-resp 时 dec。[launchKillWatchdog] 据此放宽超时——helper 在途时用户的 git 命令是「正等
84+
* 用户填凭据」而非「卡死」,watchdog 不应按常规超时强杀,详见其改造注释。
85+
*/
86+
private val credentialPromptInFlight = java.util.concurrent.atomic.AtomicInteger(0)
87+
88+
/** 凭据请求进入途(bridge 收到 helper 的 cred-req 时调)。 */
89+
fun incPromptInFlight() { credentialPromptInFlight.incrementAndGet() }
90+
91+
/** 凭据请求结束途(bridge 写回 cred-resp 时调)。 */
92+
fun decPromptInFlight() { credentialPromptInFlight.decrementAndGet() }
93+
7994
companion object {
8095
private const val TAG = "LinuxContainerEngine"
8196

@@ -103,7 +118,7 @@ class LinuxContainerEngine @Inject constructor(
103118
* 独立于 [ContainerInstaller] 的 rootfs INSTALL_VERSION:rootfs 版本升级会删 rootfs
104119
* (连带清掉本标记),故新 rootfs 必然重跑配置;同 rootfs 下改包清单则靠本版本号触发。
105120
*/
106-
private const val PROVISION_VERSION = "py3.12-pip-node-bash-curl-rg-gitcredhelper-v3"
121+
private const val PROVISION_VERSION = "py3.12-pip-node-bash-curl-rg-gitcredhelper-v4"
107122

108123
/** `apk add` 下载基础包的超时(毫秒):首次配置需联网拉包,给足时间。 */
109124
private const val PROVISION_TIMEOUT_MS = 600_000L
@@ -339,9 +354,13 @@ class LinuxContainerEngine @Inject constructor(
339354
append("EOF\n")
340355
append("apk update\n")
341356
append("apk add --no-cache $PROVISION_PACKAGES\n")
342-
// 装好 git 后配置全局 credential.helper,指向持久挂载的凭据文件,让终端/AI/UI 三端裸 git
343-
// 自动带凭据(GIT_CONFIG_GLOBAL 已指 /root/.aicode/.gitconfig,写入不随升级丢失)。
344-
append("git config --global credential.helper 'store --file=/root/.aicode/git-credentials'\n")
357+
// 装好 git 后配置两个 credential.helper:store(命中已有凭据秒过)+ aicode 自定义 helper
358+
// (store 未命中时经文件 IPC 通知 app 弹窗回填),让终端/AI/UI 三端裸 git 共用同一注入链。
359+
// credential.helper 是 multi-valued,存量设备重配时若直接 `--add` 会与旧 store 行重复,
360+
// 故先 `--replace-all` 清掉已有 helper 值再 `--add` aicode,保证顺序幂等(store 唯一在前、aicode 唯一在后)。
361+
// 脚本由 [ContainerInstaller.extractCredentialHelper] 启动即提取到 /root/.aicode/git-credential-aicode。
362+
append("git config --global --replace-all credential.helper 'store --file=/root/.aicode/git-credentials'\n")
363+
append("git config --global --add credential.helper '/root/.aicode/git-credential-aicode'\n")
345364
}
346365

347366
var exitCode: Int? = null
@@ -368,6 +387,12 @@ class LinuxContainerEngine @Inject constructor(
368387
/**
369388
* 启动看门狗:等待 [timeoutMs] 后若进程仍存活,则标记超时并优雅→强制终止,借此解除
370389
* 调用方阻塞中的 readLine。返回的 [Job] 由调用方在正常结束时 cancel 掉。
390+
*
391+
* **凭据弹窗在途时暂停**:到点若 [credentialPromptInFlight] > 0(自定义 git credential helper
392+
* 正阻塞等 app 弹窗回填用户的凭据请求),watchdog 不杀——这条 git 命令是「正等用户填凭据」
393+
* 而非「卡死」,按常规超时强杀会让用户离开几分钟回来发现推送已失败、得重来。改为每轮重查:
394+
* 在途则宽限一段(1min)再查,直至不在途才杀;绝对上限 [MAX_TIMEOUT_MS](30min)即便在途
395+
* 也兜底杀,避免事实无限等待。终端 PTY 路径不经 watchdog([TerminalSessionManager] 裸 tty),天然最耐等。
371396
*/
372397
private fun launchKillWatchdog(
373398
scope: CoroutineScope,
@@ -376,13 +401,26 @@ class LinuxContainerEngine @Inject constructor(
376401
timedOut: AtomicBoolean,
377402
command: String
378403
): Job = scope.launch {
379-
delay(timeoutMs)
380-
if (process.isAlive) {
404+
var remaining = timeoutMs
405+
var totalWaited = 0L
406+
while (true) {
407+
delay(remaining)
408+
if (!process.isAlive) return@launch
409+
totalWaited += remaining
410+
val inflight = credentialPromptInFlight.get()
411+
if (inflight > 0 && totalWaited < MAX_TIMEOUT_MS) {
412+
// 凭据弹窗在途:宽限 1min(不超过绝对上限),再回查。
413+
FileLogger.i(TAG, "凭据弹窗在途(${inflight}),watchdog 暂缓,再等 60000ms: $command")
414+
remaining = minOf(60_000L, MAX_TIMEOUT_MS - totalWaited)
415+
continue
416+
}
417+
// 不在途,或已达 30min 绝对上限:正常超时终止。
381418
timedOut.set(true)
382-
FileLogger.w(TAG, "命令执行超过 ${timeoutMs}ms,终止进程: $command")
419+
FileLogger.w(TAG, "命令执行超过 ${timeoutMs}ms(累计等待 ${totalWaited}ms,inflight=${inflight},终止进程: $command")
383420
runCatching { process.destroy() }
384421
delay(TIMEOUT_KILL_GRACE_MS)
385422
if (process.isAlive) runCatching { process.destroyForcibly() }
423+
return@launch
386424
}
387425
}
388426

0 commit comments

Comments
 (0)