数据截至 (上游 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 → 没有任何人类复核点 -
它给出的三个答案:
- 一个 origin、一个 token。 所有客户端(沙箱守护进程、
kortixCLI、你本机的 git)都 clone 同一个 URL:https://<KORTIX_URL>/v1/git/<projectId>.git,密码填 Kortix 自己的 token。 真正的 GitHub 凭据由服务端临时铸造、只存在于 API 进程内。 - 服务端裸镜像(bare mirror)。 每个项目在 API 机器上有一份
--bare克隆,文件浏览、 commit 历史、CR 的 diff 与 merge全在这份本地副本上跑 git 命令,不碰 host API。 - change request(CR)。 Kortix 自己的 PR:一条"把
head_ref合进base_ref"的记录, 合并动作由服务端执行,合并前要过 manifest 校验和权限闸门。
- 一个 origin、一个 token。 所有客户端(沙箱守护进程、
-
用起来什么样:
$ 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 companyUsername: x-access-tokenPassword: 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.ts、routes/r8.ts、routes/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-encoding、content-length这些逐跳头,也扔掉上游的www-authenticate——否则上游的 401 挑战会把客户端的 git 引到 GitHub 去登录。 代理自己在 401 时发Basic realm="Kortix Git"(index.ts:67unauthorized)。
token 到底怎么取的? git 走 Basic 认证时把 token 塞在密码位,用户名是约定俗成但被忽略的
x-access-token。extractToken(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 | 账户拥有该项目 |
| 沙箱运行时 token | isKortixToken + 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-70、ship.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:434、lib/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
- 去重锁:
refreshLocks是Map<projectId, { promise, forced }>(mirror.ts:19、mirror.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-default 或 template:<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:336、merge.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:371、merge.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 的每个入口都先跑:getMergeBase、getBranchDiff、
previewMerge、mergeBranches、resolveBranchTip(git/commits.ts:343)、resolveTreeOid……
——不是"在某一层集中校验",而是每个会把 ref 递给 git 的函数自己校验。防御性重复在这里是对的。