跳到主要内容

数据截至 (上游 commit e55b2a12c9a5)

git 是唯一底座:代理、镜像与 change request 闸门

30 秒导读: Kortix 把"一家 AI 公司"的全部状态——声明、代码、agent 产出——都存在一个普通 git 仓库里。 本章讲两件事:沙箱和 CLI 如何在不持有任何真实 GitHub 凭据的前提下读写这个仓库(git 代理 + 服务端凭据铸造), 以及 agent 干完的活如何经过一道闸门被审进 main(change request)。

本章属于「状态与治理」。想知道 manifest(kortix.yaml,v1 叫 kortix.toml)这份声明怎么被校验,看 01-manifest-control-plane; 想知道 session 分支是何时、被谁开出来的,看 02-session-lifecycle


1. 这是什么(零基础也能懂)

  • 一句话定义: Kortix 里没有"数据库存着 agent 的工作成果"这回事——成果就是 git 上的 commit, 平台只提供三样东西:一个统一的 git 入口、一份服务端的仓库副本、一层叫 change request 的合并闸门。

  • 解决什么问题: 假设你让一个 agent 在云端沙箱里改你公司的代码库。三个尴尬问题马上来了。

    问题朴素做法为什么不行
    沙箱怎么 clone 私有仓库?把 GitHub token 塞进沙箱 → agent(和它跑的任何代码)就拿到了你整个组织的写权限
    平台怎么给你看 diff?每次点开都去调 GitHub API → 慢、有限流、还得为每个 host 写一套适配
    agent 改完的东西怎么进 main?让 agent 直接 push main → 没有任何人类复核点
  • 它给出的三个答案:

    1. 一个 origin、一个 token。 所有客户端(沙箱守护进程、kortix CLI、你本机的 git)都 clone 同一个 URL:https://<KORTIX_URL>/v1/git/<projectId>.git,密码填 Kortix 自己的 token。 真正的 GitHub 凭据由服务端临时铸造、只存在于 API 进程内。
    2. 服务端裸镜像(bare mirror)。 每个项目在 API 机器上有一份 --bare 克隆,文件浏览、 commit 历史、CR 的 diff 与 merge全在这份本地副本上跑 git 命令,不碰 host API。
    3. change request(CR)。 Kortix 自己的 PR:一条"把 head_ref 合进 base_ref"的记录, 合并动作由服务端执行,合并前要过 manifest 校验和权限闸门。
  • 用起来什么样:

    $ kortix cr ls
    #3 ● open feat: 给 支持 agent 加上退款工具 a1b2c3d… → main 2h ago
    #2 ✔ merged chore: 收紧 kortix.toml 的连接器白名单 9f8e7d6… → main 1d ago

    $ kortix cr merge 3

    同一个仓库,你在本机也可以直接:

    $ git clone https://api.kortix.com/v1/git/8f3a…-e21b.git company
    Username: x-access-token
    Password: kortix_pat_… # ← 你的 Kortix token,不是 GitHub token
  • 一句话直觉: 把 Kortix 的 git 代理想成公司前台。外面的人(沙箱、CLI)只认识前台的地址和自己的工牌, 永远拿不到仓库大楼的钥匙;钥匙由前台在每次放行时临时配一把,用完即弃。


2. 顶层全景(它大概怎么转)

怎么读这张图:左边是拿着 Kortix token 的客户端,中间是 API 进程,右边是真正的 git host。 注意两条完全不同的路径—— 是流式代理(协议原样转发), 是服务端自己跑 git(读 + 合并)。

客户端(只有 Kortix token) API 进程(唯一持有真凭据的地方) 真实 host
┌──────────────────────┐ ┌───────────────────────────────┐ ┌──────────┐
│ 沙箱守护进程 │ │ ① git 代理 /v1/git/<id>.git │ │ │
│ kortix CLI (ship/cr) │──git 协议───▶│ 认 token → 定项目 → 铸凭据 │──────▶ │ GitHub │
│ 你本机的 git │ │ 原样流转 pack 数据 │ │ (或别的 │
└──────────────────────┘ ├───────────────────────────────┤ │ host) │
│ ② 裸镜像 /tmp/kortix/…​.git │ │ │
┌──────────────────────┐ │ clone --bare + 节流 fetch │◀─fetch─│ │
│ 网页 / 移动端 │──HTTP JSON──▶│ 所有 diff / merge 在此执行 │──push─▶│ │
│ (文件浏览 / CR 面板) │ ├───────────────────────────────┤ └──────────┘
└──────────────────────┘ │ ③ change_requests 表(元数据) │
└───────────────────────────────┘

部件一句话职责:

部件干什么在哪个文件
git 代理把 3 个 git smart-HTTP 端点原样转发给真上游,途中换凭据apps/api/src/git-proxy/index.ts
代理解析层从 git 的 Basic/Bearer 头里抠 token、判读写、决定转发哪些头apps/api/src/git-proxy/parse.ts
授权 + 上游解析校验 token 属不属于这个项目;把项目解析成"真 URL + 短时凭据头"apps/api/src/projects/lib/git.ts
host 后端provider 相关的建仓/删仓/凭据格式,registry 按 provider 分派apps/api/src/projects/git-backends/
裸镜像每项目一份 --bare 本地副本 + 刷新锁 + 60s 节流apps/api/src/projects/git/mirror.ts
diff / merge三点 diff、冲突预演、无工作区合并apps/api/src/projects/git/merge.ts
CR 层open/merged/closed 三态元数据 + HTTP 面 + CLI 面apps/api/src/projects/change-requests.tsroutes/r8.tsroutes/r9.ts
ref 校验挡住把 ref 写成 --option 的参数注入apps/api/src/projects/git-ref.ts

主线走一遍(一次 agent 产出落到 main):

沙箱 agent 在 session 分支上 commit

├─ git push origin HEAD ──▶ ① 代理 ──▶ 真 host(session 分支)


kortix cr open --head <session 分支> ──▶ ③ 写一行 change_requests


人类在网页/CLI 看 diff、看冲突预演 ──▶ ② 裸镜像里跑 git diff / merge-tree


POST …/change-requests/:id/merge
│ ├─ 权限闸门(人类 capability + agent scope)
│ ├─ manifest 闸门(head 上的 manifest 必须合法)
│ ├─ ② 裸镜像里 merge-tree + commit-tree + update-ref,然后 push
│ └─ 连锁:作废镜像缓存 / 重建模板快照 / 重烤 warm 快照 / 重同步连接器

main 前进;下一个 session 从新 main 开始

3. 核心原理

3.1 一个 Kortix token,一个 origin

它要解决的小问题: 沙箱是一台跑着 LLM 驱动的 agent 的机器。给它一个 GitHub token, 等于把 token 交给一个会读任意网页、会执行任意命令的东西。

思路: 让沙箱从头到尾不知道真上游是谁。它只知道一个 URL 和自己的 KORTIX_TOKEN; 凭据替换发生在服务端,一次一换。

代理只暴露 git smart-HTTP 的三个端点,多一个都不给:

GET /v1/git/<projectId>.git/info/refs?service=git-upload-pack → 读
GET /v1/git/<projectId>.git/info/refs?service=git-receive-pack → 写
POST /v1/git/<projectId>.git/git-upload-pack → clone / fetch(读)
POST /v1/git/<projectId>.git/git-receive-pack → push(写)

服务名到读写权限的映射就一行,scopeForService(apps/api/src/git-proxy/parse.ts:47): git-receive-pack ⇒ write,其余一律 read

原理演示(示意,非源码):

// 一次代理转发的骨架:认自己人 → 换凭据 → 原样流转
async function forwardGit(req, projectId, scope, suffix) {
const token = extractToken(req.headers.authorization); // git 的 Basic 密码位
const auth = await authorizeGitProxy(token, projectId, scope); // 这个 token 是这个项目的吗
if (!auth.ok) return challenge401();

const upstream = await resolveProjectUpstream(auth.project, scope); // 真 URL + 临时凭据头
return fetch(upstream.url + suffix, {
headers: { ...forwardedHeaders(req), ...upstream.headers }, // 覆盖掉客户端的 Authorization
body: req.body, // pack 数据不落盘,直接流
});
}

真实实现: forward()apps/api/src/git-proxy/index.ts:99。三个细节值得盯:

  • 凭据是"覆盖"不是"追加":先按白名单 FORWARD_REQUEST_HEADERS 复制客户端的头 (content-type/git-protocol/user-agent 等,没有 authorization), 再 Object.assign(headers, upstream.headers) 盖上服务端铸的那把(index.ts:99-104)。
  • body 全程流式:body: c.req.raw.body + Bun 的 duplex: 'half' + decompress: false (index.ts:112-116),几百 MB 的 pack 不会在 API 内存里堆起来。
  • 回程要剥头:STRIP_RESPONSE_HEADERS(parse.ts:62)扔掉 transfer-encodingcontent-length 这些逐跳头,也扔掉上游的 www-authenticate——否则上游的 401 挑战会把客户端的 git 引到 GitHub 去登录。 代理自己在 401 时发 Basic realm="Kortix Git"(index.ts:67 unauthorized)。

token 到底怎么取的? git 走 Basic 认证时把 token 塞在密码位,用户名是约定俗成但被忽略的 x-access-tokenextractToken(parse.ts:18)同时接受 Bearer 和 Basic,Basic 时 base64 解码后 按第一个 : 切开取右半边——所以密码里含冒号也不会被切坏。

授权的信任边界是"账户",authorizeGitProxy(apps/api/src/projects/lib/git.ts:669)分三类 token:

token 类型判据通过条件
CLI PAT(kortix_pat_…)isAccountToken账户拥有该项目;项目级 PAT 还必须正好是这个项目
账户 API key(kortix_…)isKortixToken + 非 sandbox账户拥有该项目
沙箱运行时 tokenisKortixToken + type === 'sandbox'该 sandbox 必须是这个项目provisioning/active 的沙箱

注意检查顺序:isKortixToken 的前缀匹配也命中 kortix_pat_,所以 PAT 分支必须写在前面 (lib/git.ts:596-599 的注释点破了这点)。沙箱那条最严——它去 sessionSandboxes 表里做三元组匹配 (sandboxId + projectId + accountId),一个项目的沙箱 token 拿去动另一个项目的仓库直接 403(lib/git.ts:618-635)。

沙箱那一侧长什么样: 守护进程发现 KORTIX_REPO_URL 里含 /v1/git/,就直接短路—— git 凭据就是自己的 KORTIX_TOKEN,连一次控制面往返都省了 (apps/kortix-sandbox-agent-server/src/git.ts:235-237)。它还把自己注册成 git 的 credential helper (configureGitCredentialHelper,git.ts:333;仓库级的 configureRepoCredentialHelper,git.ts:373), 这样 agent 在任意 shell 里敲 git push 都不会被要求输密码,而且拿到的永远是当场解析的新 token, 不是 clone 时烙进 .git/config 的旧 token。

CLI 走同一条路:kortix ship 看到项目的 git_origin_url 是代理 URL,就把 push 凭据设成 credentialMode: 'kortix-token',直接用登录用的 CLI token 推 (apps/cli/src/project-git.ts:69-70ship.ts:720-722)。

3.2 真凭据在服务端铸造,而且尽量短命

它要解决的小问题: "服务端持有凭据"只是把风险搬了个地方。真正的问题是:这把钥匙有多大、活多久。

resolveProjectGitAuth(apps/api/src/projects/lib/git.ts:477)按项目的连接方式分四条路:

项目类型凭据来源作用域 / 寿命
托管 GitHub 仓(Kortix 建的)MANAGED_GIT_GITHUB_TOKEN 组织 PAT组织级、长期——"一把服务端总钥匙"模式,操作简单但权限大
托管 GitHub 仓(未配 PAT)GitHub App installation token,限定到这一个 repo短时、自动轮转、最小权限
用户自己的 GitHub 仓(装了 App)App installation token,同样限定到那一个 repo短时;且 owner/repo 三重比对不一致就返回 none
用户提供 PAT 的仓project_git_credentials 里加密存的 PAT,用时解密长期,但只在服务端解密

App 路径的关键在 createInstallationToken(installId, [repoName])——第二个参数把 token 钉死在单个仓库上 (lib/git.ts:434lib/git.ts:487)。所以就算这条链上某处漏了 token,它也只能碰这一个项目的仓库, 碰不到同组织里别人的项目。

拿到 token 之后,交给 provider 后端格式化成 { url, headers }。GitHub 后端的实现短得可以全文引: buildUpstream(apps/api/src/projects/git-backends/github.ts:195)只是把 token 包成 basicAuthHeader(git-backends/types.ts:95,即 base64 的 x-access-token:<token>)。 getBackend 对未知 provider 回落到 GitHub 后端(git-backends/registry.ts:36), 因为这套 x-access-token basic 方案对任意 HTTPS git 远端都成立——这就是"backend-agnostic"的物理基础

一处刻意的例外: authedPushUrl(git-backends/types.ts:79,实现在 github.ts:189) 把凭据直接烙进 URL。注释里明说这是给 header 方式穿不进去的外部场景(legacy 迁移 VM)用的, 结果是个 secret、绝不可日志。看到这种"例外通道"要意识到:它是安全模型上的一个开口, 所以被限制在最窄的场景里。

3.3 裸镜像:只读快路径,也是所有 diff/merge 的执行地

它要解决的小问题: 网页上点开一个文件、翻一页 commit、看一个 CR 的 diff——如果每次都调 GitHub API, 既慢又受限流,而且换个 host 就得重写一套。

思路: 干脆在服务端存一份 git clone --bare,然后所有读操作都变成本地 git 子进程。 git 本身就是最好的 git API。

镜像路径按 projectId 的 sha256 前 32 位命名(repoCachePath,mirror.ts:72),落在 KORTIX_GIT_CACHE_DIR(默认 /tmp/kortix/git-cache)。刷新逻辑有三层保护:

refreshMirror(project, force?)

├─ 有同项目的刷新在飞? ── 是 ─▶ 直接复用那个 Promise(refreshLocks,去重)
│ 否

doRefreshMirror
├─ 目录不存在 ─▶ git clone --bare(全分支)
├─ 距上次刷新 < 60s 且非 force ─▶ 直接返回热缓存,零网络
└─ 否则 ─▶ remote set-url + 修宽 refspec + git fetch --prune
  • 去重锁:refreshLocksMap<projectId, { promise, forced }>(mirror.ts:19mirror.ts:441)。
  • 节流:KORTIX_GIT_REFRESH_INTERVAL_MS,默认 60 秒(mirror.ts:332-335);需要读到最新的调用方传 force=true (比如 previewMerge/mergeBranches 都是 refreshMirror(project, true))。
  • 自愈:老版本克隆过 --single-branch,刷新时用 git config remote.origin.fetch '+refs/heads/*:refs/heads/*' 把 refspec 改宽(mirror.ts:434); 发现 shallow 文件就整个删掉重来(mirror.ts:372-374)。

一个很值得学的坑:去重锁和凭据的相互作用。 镜像是十几条代码路径共享的资源, 其中有些调用方合法地拿不到 token(.catch(() => null) 之类)。一旦这样一个"无 token 调用方"抢到了刷新锁, 私有仓的冷克隆就会以匿名身份跑,失败信息是 fatal: could not read Username for 'https://github.com', 而所有搭这趟车的并发调用方一起失败。修法是在任何网络 git 操作之前兜底解析一次: ensureMirrorAccess(mirror.ts:46)——调用方没带 token 就用动态 import 懒调 resolveProjectGitAccessById (lib/git.ts:918)从项目存的凭据里解析,resolver 承诺永不抛异常,最坏退化成"没 token"。

凭据怎么喂给 git 子进程? 不写 .git/config、不进程环境里放明文 URL,而是用 git 的 GIT_CONFIG_COUNT/GIT_CONFIG_KEY_0 三件套注入一条 http.https://<host>/.extraheader (gitAuthEnv,mirror.ts:103-120)。host 由 hostFromRepoUrl(mirror.ts:94)从 repoUrl 里解析, 保证这条 Authorization 头只对那一个 origin 生效;解析不出来就退回 github.com

另外两个执行原语:runGit(mirror.ts:270,非零退出即抛,30s 超时,10MB 缓冲)和 runGitCapture(mirror.ts:303,返回 exitCode 不抛)。后者存在的理由很具体—— git merge-tree --write-tree 在有冲突时正常退出码就是 1,那是控制流不是错误。

镜像层还导出了两个给快照子系统预留的原语,但要看清它们今天的接线状态:

原语它能做什么今天的实际状态
resolveTreeOid(mirror.ts:571)取某个 commit 下子树的 git tree OID,也就是 git 自己的内容寻址哈希生产路径上没有任何调用方:全仓只有 projects/git.ts:42 的 re-export 和 e2e 测试桩
materializeRepoContext(mirror.ts:607)把某个 commit 的子树物化成本地目录,喂给镜像构建同上,只有 projects/git.ts:43 的 re-export

所以"拿 tree OID 当快照失效判据"是写在函数注释里的意图,不是当前的事实:快照身份里那一项 contextTreeOid 实际拿到的是 templates.ts:601 的逐模板常量(platform-defaulttemplate:<slug>), 真正让"内容变了就换镜像名"成立的是 runtime fingerprint 的逐字节遍历。完整论证见 03-sandbox-runtime §10.3

3.4 无工作区的合并:merge-tree + commit-tree + update-ref

它要解决的小问题: 裸仓库没有工作区,跑不了 git merge。可服务端就是想在裸镜像里把两个分支合了。

思路: git 2.38 起 merge-tree --write-tree纯在对象库里做三路合并,直接写出结果树; 再用 commit-tree 造一个双亲 commit,最后 update-ref 挪分支。全程不落一个工作文件。

base 分支 head 分支
│ │
└──── merge-base ────────┘

git merge-tree --write-tree base head

退出码 0 ─┴─ 非 0 → 有冲突,解析出冲突路径列表

<tree sha>

git commit-tree <tree> -p base -p head -m "…"

<merge commit sha>

git update-ref refs/heads/base <new> <old> ← 带旧值 = 乐观锁

git push origin <new>:refs/heads/base

真实实现: mergeBranches(apps/api/src/projects/git/merge.ts:311)。

  • 能快进就快进:mergeBase === baseShaBefore 时不造合并 commit,直接把 base 推到 head (merge.ts:334-356),历史更干净。
  • update-ref 的第三个参数是旧值(merge.ts:336merge.ts:397)——这是 git 自带的 compare-and-swap,本地 ref 在这期间被别人动过就会失败,而不是悄悄覆盖。
  • 合并 commit 的身份是固定的 Kortix <[email protected]>(merge.ts:375-389,通过 GIT_AUTHOR_*/GIT_COMMITTER_* 环境变量),所以合并动作在 git 历史里可辨认。
  • 每一步的输出都要验形:tree sha 和 commit sha 都用 /^[0-9a-f]{40}$/ 校验后才继续 (merge.ts:371merge.ts:392)——git 子进程的 stdout 不当成可信结构化数据。

合并前先预演: previewMerge(merge.ts:258)用 --name-only 变体跑同一个 merge-tree, 非零退出时按一个明确的输出格式解析冲突文件名:tree sha 那行之后、第一个空行(或诊断行)之前的每一行就是冲突路径 (merge.ts:222-241,注释交代了 git 的输出布局)。UI 拿这个列表渲染"哪些文件冲突了"。

diff 用三点语义: getBranchDiff(merge.ts:196)走 base...head, 这样"base 上有、head 上没有"的提交不会混进来——和 GitHub PR 的 diff 语义一致。 已合并的 CR 就麻烦了:此时 base...head 是空的。所以 CR 在合并时把当时的两个 SHA 存下来, 读 diff 时改走 getDiffBetweenShas(merge.ts:212)。

3.5 ref 校验是安全边界,不是格式洁癖

它要解决的小问题: 上面这些函数里,用户给的 ref 名会作为位置参数拼进 git 命令行。

git 的很多子命令把"看起来像选项的位置参数"当选项解析。于是一个叫 --open-files-in-pager=<cmd> 的"分支名"传给 git grep,或 --output=<path> 传给 git archive, 就是一次参数注入——不需要 shell,光靠 git 自己就能读写任意文件。

validateRef(apps/api/src/projects/git-ref.ts:14)用一条正则把这条路封死:

if (!/^[A-Za-z0-9._\-\/]+$/.test(ref) || ref.includes('..') || ref.startsWith('-')) {
throw new Error('Invalid ref');
}

三个条件各管一件事:保守字符集(挡空格、@{、反斜杠、控制字符)、 ..(挡路径穿越和 revision range 语义)、禁开头的 -(挡选项注入,这条最要命)。 validateSha(git-ref.ts:23)同理,只放行 4–64 位十六进制。

这两个函数在 merge/diff/browse 的每个入口都先跑:getMergeBasegetBranchDiffpreviewMergemergeBranchesresolveBranchTip(git/commits.ts:343)、resolveTreeOid…… ——不是"在某一层集中校验",而是每个会把 ref 递给 git 的函数自己校验。防御性重复在这里是对的。

3.6 CR v1:刻意做得极简

它要解决的小问题: 需要一个人类复核点,但不想重造一个 GitHub。

刻意不做的东西(apps/api/src/projects/change-requests.ts:1-14 的模块注释直说了): 没有评论、没有 review、没有镜像一份 commit 历史到数据库。理由是一句话—— git 仍然是"谁改了什么"的唯一真相,数据库只存"有人提议把 A 合进 B"这一点点元数据。

表结构(packages/db/src/schema/kortix.ts:3977,迁移在 packages/db/migrations/20260621094136410_baseline.sql):

作用
number每项目单调递增的展示号(CR #1、#2…),(project_id, number) 上有唯一索引(kortix.ts:2490)
base_ref / head_ref合并方向,就是两个分支名
status枚举三态:open / merged / closed
head_commit_sha / base_commit_sha缓存的分支尖端,读接口顺手刷新
origin_session_id从哪个 session 提出来的(外键 on delete set null)
merge_commit_sha / merged_by / closed_by合并/关闭的落款

编号怎么分配: getNextCrNumber(change-requests.ts:55)就是一句 coalesce(max(number), 0) + 1——没加锁。并发开 CR 会撞唯一索引报 23505, 所以创建接口把"取号 + 插入"放在最多 3 次的重试循环里,只对 duplicate key 重试 (apps/api/src/projects/routes/r8.ts:1257-1282)。用唯一索引兜底、用重试消化冲突, 比先上一把分布式锁简单得多。

SHA 为什么要"缓存 + 每次读刷新": refreshCrTips(apps/api/src/projects/routes/shared.ts:1214) 在 GET 单个 CR 时重新解析两个分支尖端,变了才写回;只对 open 状态做, 解析失败就只打个 warn 不动数据(仓库暂时不可达时 UI 仍能渲染已有元数据)。

CR 的 HTTP 面分布在两个文件:

端点位置
GET/POST /:projectId/change-requestsroutes/r8.ts:1109r8.ts:1154
GET/PATCH /:projectId/change-requests/:crIdr8.ts:1438r8.ts:1475
GET …/:crId/diffr8.ts:1619
GET …/:crId/merge-previewr8.ts:1677
POST …/:crId/merge /close /reopenroutes/r9.ts:21r9.ts:196r9.ts:242

CLI 面是 kortix cr(apps/cli/src/commands/cr.ts:43 runCr),子命令一一对应上表。 一个小设计:resolveCr(cr.ts:126)允许你写 kortix cr merge 3——数字就列一遍全部 CR 去匹配 number, uuid 才直接打详情接口。v1 阶段"多一次列表请求"换"用户不用记 uuid",是笔划算买卖。


4. 深入实现:merge 这一下,连锁反应有多长

合并是整个系统里唯一真正不可逆的动作,所以它前面有闸门、后面有连锁。全在 apps/api/src/projects/routes/r9.ts:21 这一个 handler 里,按顺序:

POST …/change-requests/:crId/merge

├─ ① 人类权限闸门 assertProjectCapability(project.gitops.merge) r9.ts:47
│ └─ 自定义角色可以单独摘掉这一条,等于收走某部门的合并权
├─ ② agent 权限闸门 assertAgentScope('project.cr.merge') r9.ts:58
│ └─ 默认拒绝;人类 token / 笔记本 CLI 直接放行
├─ ③ 状态闸门 cr.status !== 'open' → 409 r9.ts:62
├─ ④ manifest 闸门 读 head 分支的 manifest → validateManifest r9.ts:69-104
│ ├─ 候选路径 kortix.yaml 优先、kortix.toml 兜底
│ ├─ 不合法 → 422 + issues 列表(code: MANIFEST_INVALID)
│ ├─ head 上根本没有 manifest → 放行(.kortix/ 布局的项目合法)
│ └─ 读文件报了别的错 → 502(别把真故障当"文件不存在"吞了)
├─ ⑤ 真合并 mergeBranches(...),冲突 → 409 r9.ts:107
├─ ⑥ 记账 status=merged + 三个 SHA 快照 r9.ts:132-148

└─ 合并后连锁(全部 best-effort,绝不阻塞响应)
├─ invalidateProjectMirror(projectId) r9.ts:151
├─ kickProjectTemplatePrebuilds(…, source: 'cr-merge') r9.ts:158
└─ syncProjectConnectors(projectId, accountId) r9.ts:168

几个点值得展开:

  • manifest 闸门读的是 head 分支(r9.ts:83-87,readManifestFromRepo(projectForGit, manifestCandidatePaths(…), cr.headRef)), 也就是"即将被合进来的那份";候选路径 kortix.yaml 优先、kortix.toml 兜底(r9.ts:73-78 的注释)。 用的是和 CLI kortix ship 预检同一个 validateManifest (@kortix/manifest-schema),所以 CLI 用户在本机就能提前看到一模一样的诊断。校验规则本身见 01-manifest-control-plane

  • head_commit_sha 的写法有讲究(r9.ts:143-148):非快进合并时它故意留在 head 分支的尖端而不是合并 commit—— 因为已合并 CR 的 diff 要靠 base_sha_before ... head_sha 这对快照重放(对应 3.4 里的 getDiffBetweenShas)。写成合并 commit 的话,diff 就废了。

  • 为什么合并完要重建镜像/快照/连接器: 因为一份 manifest(v2 kortix.yaml,v1 kortix.toml)同时决定了 沙箱模板(sandbox.templates 里的 Dockerfile)和连接器白名单(connectors)。 CR 一合,这些声明就变了,于是:'cr-merge' 作为一种构建来源触发模板预构建 (apps/api/src/snapshots/builder.ts:72-79SnapshotBuildSource 枚举), 连接器缓存从新尖端重新同步 (apps/api/src/connectors/sync.ts:396 syncProjectConnectors,详见 05-executor-connectors)。 这些事都用 void/不 await,任何一件失败只打 warn——合并的响应绝不为它们等待, 周期性巡检是兜底。(旧版连锁里还有一步 kickProjectWarmBake 按新尖端重烤温快照, 已随温快照子系统重做而移除——现在每项目暖镜像由 snapshots/ppwarm-names.ts 命名、 在 session 启动路径上惰性补齐,见 03-sandbox-runtime。)

沙箱那边怎么跟上: 已经在跑的 session 不会自动感知 main 前进了。守护进程暴露一个刷新端点 (apps/kortix-sandbox-agent-server/src/routes/refresh.ts:23),两种模式:

调用行为实现
POST /refreshfetch --prune + pull --ff-only 当前分支,然后重启 OpenCodesrc/git.ts:1330 refreshRepo
POST /refresh?base=1&restart=0把工作区硬对齐到 base 最新尖端,不重启(靠 OpenCode 的文件监听)src/git.ts:1407 syncWorkspaceToBase

--ff-only 是关键(git.ts:1178-1188):沙箱这一侧永远不做合并。要么能快进,要么报错退出, 不会在沙箱里生出一个谁都没审过的合并 commit。同一时刻只允许一个刷新在飞(refresh.ts:41-43,重复请求 409)。

另外一条被留着的路: POST /:projectId/sessions/:sessionId/commit-push(r8.ts:1301) 让宿主直接驱动沙箱提交并推分支。源码注释诚实地写着:当前 UI 没用它—— 实际发货的流程是 agent 在对话里一次性完成 commit + 开 CR。它作为"纯 UI 流程"的原语被保留着。


5. 巧妙之处(可借鉴的技术)

  1. 把"换凭据"做成协议级代理,而不是 SDK 封装。 代理只转发 3 个端点、原样流 pack 数据,所以客户端那边完全是标准 git—— 沙箱、CLI、你本机的 git,一份实现通吃。妙在它没有引入任何新协议 (git-proxy/index.ts:99)。

  2. 凭据的作用域被压到"单个 repo"。 App installation token 传第二个参数就能钉死到一个仓库(lib/git.ts:434lib/git.ts:487)。 一个项目的沙箱就算漏了 token,也碰不到同组织别的项目。

  3. 把 Authorization 头钉死在一个 origin 上,而不是写进仓库配置。 gitAuthEnv(mirror.ts:103-120)用 GIT_CONFIG_COUNT/GIT_CONFIG_KEY_0 注入一条 http.https://<host>/.extraheader,host 由 hostFromRepoUrl(mirror.ts:94)从 repoUrl 现算。 于是凭据既不落 .git/config、也不会在仓库里有第三方 URL 时被顺手带去别人家。

  4. update-ref 的三参数形式当乐观锁用。 git update-ref <ref> <new> <old>(merge.ts:336)自带 CAS 语义, 比在应用层加锁便宜也可靠。

  5. "取号 + 唯一索引 + 重试"代替分布式锁。 CR 编号分配没有任何锁,靠 (project_id, number) 唯一索引把并发暴露成 23505, 再重试最多 3 次(r8.ts:1257-1282)。简单、无状态、正确。

  6. 确定性 root commit 换来 delta 克隆。 建仓种子把第一个 commit 的作者、邮箱、日期全部钉死(git-backends/seed.ts:55-59 PINNED), 于是同一个 starter 出来的每个项目共享字节级相同的根提交。 沙箱镜像里烤了一份同根的 scaffold,冷启动就能"本地克隆 + 只拉增量", 而不是走慢的 git 通道全量克隆(apps/kortix-sandbox-agent-server/src/git.ts:1069 tryScaffoldDeltaFetch)。

  7. 把"非零退出是正常控制流"单独封一个函数。 runGitCapture(mirror.ts:303)与 runGit 并存,就为了 merge-tree 冲突时的退出码 1。 不这么分,冲突检测就得靠捕获异常再解析错误消息——脆弱得多。


6. 边界与局限

诚实地说清它不做什么、以及哪里会硌手:

  • 代理不做细粒度的 scope 授权。 authorizeGitProxy 的第三个参数叫 _scope—— 带下划线,函数体里根本没用(lib/git.ts:582-586)。也就是说读写在代理这一层是同一道门: 账户拥有项目就同时拥有读和写。源码注释承认这一点,说更细的 per-project 角色门控"随 M2 落地" (lib/git.ts:576-580)。真正的分权目前发生在 HTTP 层(project.gitops.merge/project.cr.open 这些 capability)。

  • git 代理默认关着。 KORTIX_GIT_PROXY 是布尔开关且默认 false(apps/api/src/config.ts:325)。 关着时沙箱走的是老路:调 /git/clone-credential(routes/r3.ts:363)拿真 provider token 直接克隆真上游—— 也就是本章 3.1 想消灭的那件事。所以"沙箱里没有真凭据"是开了开关之后的性质。

  • 镜像的新鲜度有 60 秒天花板,而经代理直推这条路根本不触发失效。 invalidateProjectMirror 在克隆里有 8 处生产调用,可以分成三类——

    类别调用点
    CR 合并后routes/r9.ts:155
    宿主驱动的 commit-push 路由routes/r8.ts:1422
    所有"平台自己写 git"的路径git/branches.ts:308:142(createRemoteSessionBranch 的 GitHub 快路径与 git-CLI 兜底)、git/branches.ts:594(commitMultipleFilesToBranch)、lib/triggers.ts:1862:1240(commitManifest 的两条提交路径)、lib/triggers.ts:610(runProjectConnectorSweep 开扫之前)

    共同点是它们全在服务端。沙箱或你本机经 /v1/git 代理直接 push 的提交不经过其中任何一条, 所以外部 push 之后,服务端的文件浏览/历史最多滞后一个刷新周期(60 秒) (inferred)。

  • 镜像层的 tree OID 原语目前未接线。 resolveTreeOid/materializeRepoContext(mirror.ts:571:275) 在生产路径上没有调用方,只有 projects/git.ts 的 re-export 和测试桩;快照身份里的 contextTreeOid 是逐模板常量(snapshots/templates.ts:610)。详见 3.3 与 03-sandbox-runtime §10.3

  • CR 里没有讨论。 没有评论、没有 review 状态、没有 approver 记录。想留下"为什么合"的痕迹, 只能写在 CR 标题/描述或 commit message 里。

  • 合并前的冲突检查和真正合并之间有窗口。 previewMergemergeBranches 是两次独立调用, 中间 head 被 push 了新提交就可能预演说"能合"、实际合失败。mergeBranches 会抛错、 路由转成 409(r9.ts:118-132),所以不会合错,但用户会看到一次意外失败。

  • merge-tree --write-tree 要求 git ≥ 2.38。 这是对 API 宿主环境的硬依赖, 源码注释里明确写了(merge.ts:252-257)。

  • runGit 的 30 秒超时 + 10MB 缓冲是全局的(mirror.ts:124:285)。 超大仓库的某些操作可能撞到这两个上限。


7. 横向对比与本组其它章

同组的 Kortix 章节里,本章是"状态"那一层;它与其它章的边界是这样划的:

关切归哪一章
manifest(kortix.yaml)的字段与校验规则01-manifest-control-plane
session 分支何时开出、沙箱何时就绪02-session-lifecycle
沙箱内部的守护进程、镜像身份与温快照03-sandbox-runtime
agent 调外部系统的凭据闸门05-executor-connectors
模型调用与计费06-llm-gateway-and-metering

值得对照的取舍:多数"AI 写代码"产品把 PR 直接委托给 GitHub API,好处是白送评论/review/CI 集成, 代价是绑死一个 host、并且每个 host 都要写一套。Kortix 选了反方向—— 只依赖 git 协议本身(三个 smart-HTTP 端点 + 本地 git 二进制), 换来的是 CR 层对 host 完全中立(change-requests.ts:1-8 的模块注释就是这么讲的), 代价是评论/review/CI 这些都得自己做,v1 干脆先不做。


8. 代码地图(导航索引)

主题文件路径符号名
git 代理的三个端点 + 转发apps/api/src/git-proxy/index.tsgitProxyAppforwardunauthorized
代理的纯解析层apps/api/src/git-proxy/parse.tsextractTokennormalizeProjectIdscopeForServiceFORWARD_REQUEST_HEADERSSTRIP_RESPONSE_HEADERS
代理挂载点apps/api/src/index.tsapp.route('/v1/git', gitProxyApp)
代理授权(信任边界)apps/api/src/projects/lib/git.tsauthorizeGitProxyGitProxyAuth
上游解析 + 凭据铸造apps/api/src/projects/lib/git.tsresolveProjectUpstreamresolveProjectGitAuthresolveUpstreamUrlbuildConnectionRefwithProjectGitAuthresolveProjectGitAuthTokenById
客户端 origin 生成apps/api/src/projects/lib/sessions.tsproxyGitUrl
后端接口与分派apps/api/src/projects/git-backends/{types,registry}.tsGitHostBackendGitScopebasicAuthHeadergetBackendgetDefaultManagedBackend
GitHub 后端apps/api/src/projects/git-backends/github.tsgithubBackendbuildUpstreammintManagedWriteTokenmanagedAdminAuthauthedPushUrl
建仓种子(确定性 root)apps/api/src/projects/git-backends/seed.tsseedRepoViaGitPushPINNED
裸镜像与 git 执行原语apps/api/src/projects/git/mirror.tsrefreshMirrordoRefreshMirrorensureMirrorAuthTokenrunGitrunGitCapturegitAuthEnvhostFromRepoUrlrepoCachePathinvalidateProjectMirrorresolveTreeOidmaterializeRepoContext
diff / 冲突预演 / 合并apps/api/src/projects/git/merge.tsgetBranchDiffgetDiffBetweenShasgetMergeBasepreviewMergemergeBranchesdiffStat
分支尖端与状态字符apps/api/src/projects/git/commits.tsresolveBranchTipdecodeStatusChar
session 分支创建与平台侧提交apps/api/src/projects/git/branches.tscreateRemoteSessionBranchdeleteRemoteSessionBranchcommitFileToBranchcommitMultipleFilesToBranch
ref/SHA 注入防护apps/api/src/projects/git-ref.tsvalidateRefvalidateSha
CR 元数据层apps/api/src/projects/change-requests.tsChangeRequestStatusserializeChangeRequestgetNextCrNumbergetCrById
CR 读接口 + 创建apps/api/src/projects/routes/r8.tslist/create/get/patch/diff/merge-preview 路由
CR 尖端刷新apps/api/src/projects/routes/shared.tsrefreshCrTips
CR 合并 / 关闭 / 重开apps/api/src/projects/routes/r9.tsmerge 路由(manifest 闸门 + 合并后连锁)、close、reopen
平台改写 manifest 时的镜像失效apps/api/src/projects/lib/triggers.tscommitManifestrunProjectConnectorSweep
运行时凭据接口(代理关闭时的老路)apps/api/src/projects/routes/r3.tsGET /:projectId/git/clone-credential
CLI 的 CR 面apps/cli/src/commands/cr.tsrunCrresolveCrcrLscrMerge
CLI 的 push 凭据选择apps/cli/src/commands/ship.tsproject-git.tsresolveExistingShipGitTargetisGitProxyUrl
沙箱侧 git(克隆/凭据助手/刷新)apps/kortix-sandbox-agent-server/src/git.tsresolveCloneTokenbuildGitAuthArgsconfigureGitCredentialHelperconfigureRepoCredentialHelperrunGitCredentialHelpermaterializeRepotryScaffoldDeltaFetchrefreshReposyncWorkspaceToBasecommitAndPushWorkingTree
沙箱刷新端点apps/kortix-sandbox-agent-server/src/routes/refresh.tscreateRefreshRouter
合并后的构建来源apps/api/src/snapshots/builder.tsSnapshotBuildSource(含 'cr-merge')、kickProjectTemplatePrebuilds
合并后的连接器重同步apps/api/src/connectors/sync.tssyncProjectConnectors
数据模型packages/db/src/schema/kortix.tschangeRequestschangeRequestsRelationsprojectGitConnectionsprojectGitCredentials
建表迁移packages/db/migrations/20260621094136410_baseline.sqlchange_requests
代理开关apps/api/src/config.tsKORTIX_GIT_PROXYMANAGED_GIT_GITHUB_*

继续读: 声明怎么被校验 → 01 章;session 分支是谁开的 → 02 章; 镜像身份与温快照 → 03 章;工具侧的凭据闸门 → 05 章; 模型这条路 → 06 章;全书导读 → index