数据截至 (上游 commit e55b2a12c9a5)
kortix.yaml:把公司写成一份声明
30 秒导读: Kortix 把"这家 AI 公司有哪些员工、在什么机器上干活、几点上班、能碰哪些外部系统"全部写进仓库根目录的一份 manifest——v2 是
kortix.yaml,v1 老项目继续用kortix.toml,两种格式并存、YAML 优先。本章只讲声明面:这个文件怎么写、怎么被校验、怎么变成平台里的一行行 状态。
本章属于 Kortix (Suna) — 架构与原理 的第 1 章。触发器的运行时调度与签名校验见 第 2 章;镜像的实际构建见 第 3 章;连接器的执行与凭据见 第 5 章。
1. 这是什么(零基础也能懂)
一句话定义: manifest(kortix.yaml,v1 时代叫 kortix.toml)是一个 Kortix 项目的章程文件——把这家"AI 公司"的组织结构写成声明式配置,和代码一起躺在 git 里。
它解决什么问题。 传统 SaaS 平台把这类配置放在网页后台的数据库里:谁改的看不出来、没法 code review、没法跟着分支走、回滚要靠人手点。Kortix 反过来——配置是文件,改配置就是提交,审配置就是审 PR。
v2 是一次"职责搬家"。 2026-07-05 的 agent-first 改版(docs/specs/2026-07-05-agent-first-config-unification.md)把 manifest 收窄成只管治理:
agents:从 v1 的[[agents]]数组变成一个 name → block 的 map,只放授权(connectors / secrets / kortix_cli / skills / workspace / enabled);- agent 的行为(description、model、temperature、prompt 本身)全部搬进各 agent 自己的原生
.kortix/opencode/agents/<name>.md,文件名就是 join key; - v2 是 YAML-only:TOML 写不出 v2 的嵌套授权树,
kortix_version = 2+.toml直接被判错(packages/manifest-schema/src/index.ts:501-511)。
它管哪些事。 一份清单(以 v2 为主):
| 声明块 | 一句话 |
|---|---|
kortix_version | 锁定 schema 版本,让平台能安全演进 |
default_agent | 没点名 agent 的会话/触发器开谁(v2 新增,必须命中一个已声明的 agent) |
project: / env: | 项目身份;运行时需要哪些环境变量名(值永不入 git) |
sandbox: + sandbox.templates[] | 会话从哪个镜像开机、默认用哪个、开机跑什么命令 |
opencode: config_dir | agent / skill / command 的定义文件住在仓库哪个目录 |
triggers: | 定时(cron)或 webhook 入口,每次触发开一个新会话 |
connectors: | agent 能调的外部集成(Pipedream / MCP / OpenAPI / Postman / GraphQL / HTTP / 聊天平台) |
agents: | 每个 agent 被授予哪些连接器、密钥、哪些 Kortix 自身的 API 动作 |
policies: | 工具调用的准入策略(直接跑 / 要审批 / 禁止) |
apps: | v2 的应用部署(static / bundle / dockerfile / oci_image) |
v1 的 [[channels]] 在 v2 里被移除——频道 ↔ agent 路由改在仪表盘现场管理(packages/starter/templates/base/kortix.yaml:178-183);v1 的托管 [[apps]] 也已退役,写了就报错「The hosted apps manifest section has been removed」(packages/manifest-schema/src/index.ts:749-756)。
用起来什么样。 脚手架模板(kortix init 的产物)就是最好的样例——它自带一个被授予全部权限的 kortix agent 和一个每日自省的 harness-reflector 触发器:
# packages/starter/templates/base/kortix.yaml:15-16, 87-92(节选)
kortix_version: 2
agents:
kortix:
connectors: all
secrets: all
kortix_cli: all
改完之后,一条命令就能知道它合不合法:
$ kortix validate
✓ manifest is valid
一句话直觉。 把它当成 package.json(依赖声明)+ crontab(定时)+ docker-compose.yml(运行环境)三者合体,再额外配一道"合不上就不许 merge"的 CI 闸门。
2. 顶层全景(它大概怎么转)
怎么读这张图: 从左到右是一次配置变更的生命周期;最下面那条反向箭头是平台回写 git 的通道(只在少数场景发生)。
① 声明 ② 闸门 ③ 解析 ④ 物化 ⑤ 生效
┌────────────┐ ┌──────────────┐ ┌───────────────┐ ┌────────────┐ ┌──────────────┐
│ kortix.yaml│───>│ 纯函数校验器 │───>│ 运行时解析器 │──>│ 写平台状态 │──>│ 调度器/网关 │
│ (git 文本) │ │ 拦下非法声明 │ │ 宽容·转成结构 │ │ (DB 行) │ │ 沙箱/触发器 │
└────────────┘ └──────────────┘ └───────────────┘ └────────────┘ └──────────────┘
▲ │
└────────────────── ⑥ 平台反写 git(连接聊天平台时) ──── ─────────────────────┘
部件一句话职责:
| 部件 | 干什么 | 在哪个文件 |
|---|---|---|
| 双格式层 | 一个地方知道 manifest 可写成 yaml 或 toml,YAML 优先解析 | packages/manifest-schema/src/format.ts |
| 校验器 | 纯函数,(文本 or 对象, format) → 诊断列表,零 I/O | packages/manifest-schema/src/index.ts |
| 触发器解析器 | 读 manifest、判版本、抽 triggers | apps/api/src/projects/triggers.ts |
| 连接器解析器 | 抽 connectors,守保留 slug | apps/api/src/projects/connectors.ts |
| agent 解析器 | 抽 agents(v2 map / v1 数组),算出授权集合 | apps/api/src/projects/agents.ts |
| 沙箱模板解析器 | 抽 sandbox.templates 和 sandbox.default | packages/shared/src/sandbox/dockerfile-layer.ts |
| 物化 | 把连接器声明变成 connector_* 表里的行 | apps/api/src/connectors/sync.ts |
| 脚手架 | 新项目的第一份 kortix.yaml 从哪来 | packages/starter/templates/base/ |
主线走一遍(高层):
- 人在本地编辑 manifest,或在网页后台点几下(后台会改文件并提交,而不是改数据库)。
kortix ship推送前先跑校验器;非法就地报错,不推。- 绕过 CLI 直接
git push也逃不掉:CR 合并时后端再跑一遍同一个校验器,不过就 422。 - 落到默认分支后,各个运行时解析器按需读文件,抽出自己那一块。
syncProjectConnectors把连接器声明写成 DB 行,网关和面板从 DB 读。
3. 一个文件,两类读者:严格校验器 vs 宽容解析器
这套设计里最容易看漏的一点:同一份 manifest 被读两遍,读法故意不同。
- 校验器(
validateManifest)是闸门。它的任务是"这份文件能不能进主干",输出是诊断列表,不产出业务对象。 - 运行时解析器(
extractTriggers/extractConnectors/extractAgents/extractSandboxTemplates)是取用方。它的任务是"把能用的部分变成对象",坏条目丢进errors让 UI 显示,绝不整份抛异常。
为什么要分开?因为它们的失败代价不同。闸门失守 = 一份坏配置能让整个项目起不来;解析器过严 = 一个拼错的触发器把其它九个也一起干掉。所以解析器一律"逐条容错",校验器一律"逐条报错"。
看解析器的容错约定(apps/api/src/projects/connectors.ts:251 extractConnectors):数组里每一项走 parseConnectorEntry,失败的进 errors,成功的进 specs,最后两个数组各自按 slug 排序返回。触发器(apps/api/src/projects/triggers.ts:514 extractTriggers)、agent(apps/api/src/projects/agents.ts:154 extractAgents)是一模一样的形状——源码注释里直说"Parser mirrors projects/agents.ts + projects/triggers.ts"。
4. 核心原理
4.1 为什么校验器必须是纯函数
它要解决的小问题: 同一套规则,要在三个完全不同的宿主里跑出完全一样的结论。
三个宿主的处境差得很远:
| 调用点 | 在哪跑 | 能不能访问 DB / 网络 | 入口 |
|---|---|---|---|
kortix ship 预检 | 用户笔记本,可能离线 | 不能 | apps/cli/src/commands/ship.ts:224(lintManifest) |
| CR-merge 闸门 | 后端 HTTP 请求内 | 能,但不该 | apps/api/src/projects/routes/r9.ts:73-99 |
kortix validate | 用户笔记本 / CI | 不能 | apps/cli/src/commands/validate.ts:164 |
只要校验器碰一下 I/O——读个 DB 查项目是否存在、发个请求确认某个 URL 活着——CLI 那两路立刻废掉,或者三路给出不同答案。所以源码开头把这件事写死了:
// packages/manifest-schema/src/index.ts:13-17
// Errors are structured (path + severity + message + optional line/col) so
// callers can render them however they want. The validator is pure: no I/O,
// no DB calls, just (rawToml: string | object) → ManifestValidationResult.
签名本身就是这条纪律的体现(index.ts:208 validateManifest):入参是字符串或已解析对象 + 格式,出参是 { valid, parsed, issues }。字符串路径自己按格式调 smol-toml 或 yaml 解析,语法错误被归一成一条 path: '<toml>'/'<yaml>' 的诊断并立即返回(index.ts:216-236),不会继续往下跑出一堆连带错误。
诊断为什么要结构化。 ManifestIssue 有四个字段 + 两个可选(index.ts:180-192):path(点路径,如 triggers[1].cron)、message、severity、line/column。渲染不在校验器里——formatIssues(index.ts:307)只是一个可选的着色打印器。这样 CLI 可以打彩色行号、后端可以把整个 issues 数组塞进 422 的 JSON body、网页可以在编辑器里画红波浪线,三方共用一份判断。
原理演示(示意,非源码):
// 示意,非源码 —— 校验器的形状
function validateManifest(input, format) {
const issues = [];
const parsed = typeof input === 'string' ? parseText(input, format) : input; // 唯一的"输入"
const version = validateRoot(parsed, format, issues); // 判版本 → 选 v1/v2 规则集
if (version === 2) validateBodyV2(parsed, issues); // 每个 section 一个纯函数,只往 issues 里推
else validateBodyV1(parsed, issues);
return { valid: !issues.some(i => i.severity === 'error'), parsed, issues };
}
重点看:issues 数组从头传到尾,没有任何一个 validateXxx 会 throw 或 return——这是它能一次报出全部问题(而不是"改一个报一个")的原因。真实调度在 index.ts:237-247。
其实是四处调用点。 文件头注释写的是三处,但后端还有第四个:POST /v1/projects/:projectId/manifest/validate(apps/api/src/projects/routes/r2.ts:447-454)——给那些"文件不在本地磁盘上"的界面(网页编辑器)用,永远返回 200,把裁决放在 body 里。
4.2 闸门不能比运行时更严:coerceBool 与别名兼容
它要解决的小问题: 一份能正常跑的 manifest,绝不能被闸门拦下。
这是个不对称的错误代价。闸门放过一条运行时会拒的配置,顶多是那一条不生效(而且解析器会把它显示为错误);但闸门拦下一条运行时明明接受的配置,用户就merge 不了、无法上线,而且一头雾水。
于是校验器里有一整套"抄运行时"的代码。最典型的是布尔值:
// packages/manifest-schema/src/index.ts:171-177 isEnabledValue
if (typeof v === 'boolean' || typeof v === 'number') return true;
if (typeof v === 'string') {
return ['true','false','1','0','yes','no','on','off'].includes(v.trim().toLowerCase());
}
它对着的是运行时的 coerceBool(apps/api/src/projects/triggers.ts:948)——同一组字面量,同样的 trim().toLowerCase()。真正的垃圾(比如 enabled = "maybe")两边都拒。
字段别名也一样。运行时解析器为了历史兼容接受多种写法,闸门就得照单全收:
| 规范写法 | 也接受 | 校验器 | 运行时 |
|---|---|---|---|
prompt | prompt_template | index.ts:1050-1058 | triggers.ts:695-700 |
cron | schedule | index.ts:995(逐 key 扫) | triggers.ts:825-829 |
run_at | runAt | index.ts:1076-1082 | triggers.ts:831-835 |
session_mode | sessionMode | index.ts:1162-1166 | triggers.ts:720-725 |
base_url | baseUrl | index.ts(connectors 段) | connectors.ts:619 |
反方向:运行时更严的地方降级成 warning。 有些约束运行时会静默丢弃或拒绝,闸门若也报 error 就太凶了,于是记成 warning——不拦路,但把"这条到时候不会生效"说清楚:
| 情况 | 后果 | 位置 |
|---|---|---|
| 模板 slug 超过 64 字符 | 同步时被静默丢弃(运行时 SLUG_RE 只允许 64) | index.ts:681-686 |
image 用 :latest | 镜像不可复现 | index.ts:706-710 |
openapi/postman 连接器缺 spec | 连接器物化失败 | index.ts:1330-1338 |
声明了 gpu | 该版本不支持,运行时忽略 | index.ts:729-733 |
时区那条升级了:v1 时代非法 IANA 时区只是 warning,现在是硬 error——"the runtime rejects it and the trigger would never fire"(index.ts:1123-1131)。
那个 64 字符的 warning 值得单看:校验器自己的 SLUG_RE 允许 128 字符(packages/manifest-schema/src/constants.ts:15),而沙箱模板解析器的 SLUG_RE 只允许 64(packages/shared/src/sandbox/dockerfile-layer.ts:972)。两个正则不一致是已知的,所以校验器专门加了一条 warning 把"你这条会被悄悄丢掉"喊出来,而不是假装无事发生。
4.3 schema 与运行时解析器:能 import 就 import,不能 import 就共享测试
它要解决的小问题: 同一批枚举(闸门的和运行时的)分居两个包,天然会漂。
分居是被迫的。packages/manifest-schema 是个独立包,要能被 CLI 单独打包进二进制,不能 import apps/api;apps/api 也不该反向依赖闸门。于是有些枚举必须写两遍——比如连接器的 provider 列表:
// packages/manifest-schema/src/constants.ts:139
export const CONNECTOR_PROVIDERS = ['pipedream', 'mcp', 'openapi', 'postman', 'graphql', 'http', 'channel'] as const;
// apps/api/src/projects/connectors.ts:63-72
const PROVIDERS: readonly ConnectorProvider[] = [
'pipedream', 'mcp', 'openapi', 'postman', 'graphql', 'http', 'channel', 'computer',
];
运行时多出的 computer 是刻意的:机器连接器是"连上一台机器"时平台自动合成的,用户永远不该手写——闸门里它是一条专门的 error(index.ts:1252-1257)。
第一道保险:直接 import。 上一版文档记录过两份 CHANNEL_PLATFORMS / RESERVED_SLUG_PROVIDERS 漂移的隐患;如今这批枚举由运行时解析器直接 import 闸门包(apps/api/src/projects/connectors.ts:40-42),想漂都没得漂。
第二道保险:跨包一致性测试。 真正没法共享的 provider 列表,由测试文件里的漂移守卫看守(apps/api/src/__tests__/unit-connectors-parse.test.ts:842-880),注释里还记录着当年的事故——channel 加进了运行时解析器和写回逻辑,却没加进 schema,结果 CR-merge 闸门开始拒绝平台自己生成的 manifest:
// apps/api/src/__tests__/unit-connectors-parse.test.ts:871-875(节选)
test(`${name}: parser and schema agree (accept=${accept})`, () => {
const runtimeOk = parseAndExtract(body).errors.length === 0;
const schemaOk = schemaConnectorErrors(body).length === 0;
expect(runtimeOk).toBe(accept);
expect(schemaOk).toBe(accept);
});
逐个 provider 跑,要求两边给出同一个 accept/reject。agents: 那边有对应的守卫:GRANTABLE_KORTIX_CLI_ACTIONS(packages/manifest-schema/src/constants.ts:202)是手抄的动作清单,而 apps/api/src/__tests__/unit-agents-parse.test.ts:37-38 断言它排序后必须等于 apps/api 那份 GRANTABLE_KORTIX_CLI(apps/api/src/projects/agents.ts:80,由 PROJECT_ACTIONS 生成)。
4.4 保留 slug:不让用户的连接器影子化内置目录
它要解决的小问题: 平台内置的 Slack 连接器占着 slack 这个名字;用户如果自己加一个叫 slack 的 Pipedream 连接器,agent 调 slack thread 到底走谁?
历史答案是"走用户那个",于是 slack thread 报 action_not_found 404——内置目录被影子化了。源码里对这个 bug 有明确记录(apps/api/src/projects/connectors.ts:78-90,引 KORTIX-206 与 PR #3670)。
修法是给平台自有的功能保留专属 slug,并规定每个保留 slug 只能配一个 provider:
// packages/manifest-schema/src/constants.ts:160 起 RESERVED_SLUG_PROVIDERS
kortix_slack: 'channel',
kortix_email: 'channel',
kortix_voice: 'channel',
computer: 'computer',
(这份表由 apps/api/src/projects/connectors.ts:95 直接 re-export,运行时与闸门同一份。)注意 slug 从公开的 slack 改成了带前缀的 kortix_slack——把平台的东西挪进自己的命名空间,而不是禁止用户用 slack 这个词。
执法在三层,缺一不可:
| 层 | 做什么 | 位置 |
|---|---|---|
| 运行时解析器 | 保留 slug 配错 provider → 该条目变成 parse error | connectors.ts:451-457 |
| 闸门 | 同一条规则,在 merge 前拦下 | index.ts:1266-1274 |
| CRUD | 新建时连公开名 slack/email/voice 也一并拒 | connectors.ts:100-107 RESERVED_CONNECTOR_SLUGS + apps/api/src/connectors/manifest-crud.ts:182-194 |
第三层是补第一二层的漏:parser 层故意放行公开的 slack 名字(注释:"so existing manifests don't break"),历史 manifest 不会因为升级而炸;但新建路径直接堵死,不给新的影子化机会。
内置频道连接器的 slug 常量集中在 apps/api/src/connectors/channels.ts:33-37(SLACK/TEAMS/EMAIL/VOICE_CHANNEL_CONNECTOR_SLUG),由 channelDefaultSlug(:38)按平台选出。
还有一类相反的保留:只有平台能生成、用户永远不能手写。provider = "computer"(Agent Computer Tunnel)在闸门里是硬错误(index.ts:1252-1257),但运行时解析器接受它(connectors.ts:63-72 的 PROVIDERS 里有)——因为它是连上一台机器时自动合成出来的,要过同一条解析管线。沙箱那边同理,slug = "default" 被留给平台共享镜像(constants.ts 的 RESERVED_SANDBOX_SLUG;packages/shared/src/sandbox/dockerfile-layer.ts:909 DEFAULT_SANDBOX_SLUG),用户抢名会被闸门拒(index.ts:663-668)、被解析器丢弃并打日志(dockerfile-layer.ts:1001-1005)。
4.5 版本这道门闩
kortix_version 看着最不起眼,却是让平台敢改 schema 的那件东西——v2 的整个改版就靠它无破坏地落地。
两边的判定规则一致但分工不同:
| 情况 | 闸门(index.ts:464-517) | 运行时(triggers.ts:460-486) |
|---|---|---|
| 缺失 | error:必须写 kortix_version | 默认按 KNOWN_SCHEMA_VERSION(=1)处理(向后兼容) |
| 非正整数 | error | throw |
| 高于已知版本 | error:升级 CLI 或钉住 manifest(闸门已知 2,index.ts:162) | throw(运行时上限 MAX_SCHEMA_VERSION = 2,triggers.ts:69-83) |
= 2 但文件是 .toml | error:TOML 只支持到 v1,指路 kortix migrate(index.ts:501-511) | — |
两行"已知版本"故意不同:闸门的 KNOWN_SCHEMA_VERSION = 2 是它会校验的版本;运行时那份刻意停在 1——它是所有 v1 测试夹具钉的值,改了会把整批 v1 形状的夹具悄悄切到 v2 读取器上,真正的接受上限由 MAX_SCHEMA_VERSION 表达(triggers.ts:60-83 的注释把这笔账算得很细)。
最后一行是关键:遇到不认识的高版本,宁可拒绝解析也不猜。否则一个装着 v3 字段的文件被 v2 平台"半懂不懂"地读进去,行为是不可预期的。
4.6 声明 → 平台状态:物化与反写
物化(声明 → DB)。 连接器的入口是 syncProjectConnectors(apps/api/src/connectors/sync.ts:396),流程:读 manifest → extractConnectors → 逐个拉目录(catalog)→ upsert 进 connectors / *_actions / *_policies 表。
这个函数里藏着三个值得抄的防御:
- manifest 读不出来时,绝不当成"零个连接器"。
readManifest失败可能是"没有仓库",也可能只是一次 git 抖动;若按字面理解就会把项目的连接器全删光。所以代码显式分支:manifest 为 null 且没有安装驱动的 channel/computer 连接器 → 直接 bail,不删任何东西(sync.ts:473-478)。 - 删除对账要分清来源。 只有 manifest 可读时,它才是"声明列表"的真源;不可读时只清理 install 驱动的
channel/computer行——"a disconnect still cleans up"(sync.ts:594-602)。 - 便宜 reconcile。
manifestHashForConnector(apps/api/src/projects/connectors.ts:375)对"会影响目录内容"的字段做哈希;哈希没变且上次同步没出错就跳过网络抓取(shouldReuseConnectorCatalog,sync.ts:75-94)。channel/computer 连接器永远不跳——它们的目录来自平台自己的代码而非网络,跳过会把动作列表冻结在物化那天(sync.ts:534-546的注释记录了 voice 动作加不上线的事故)。
映射本身是纯的,单独放在 apps/api/src/connectors/materialize.ts:connectorConfig(:25)按 provider 拼出存进 DB 的配置块,toPolicyRows(:89)把策略数组按书写顺序编号成 position——顺序即优先级,这一点直接由声明的行序决定。
沙箱那边同构:extractSandboxTemplates(packages/shared/src/sandbox/dockerfile-layer.ts:986)抽出模板列表(同时吃 v2 的 sandbox: {templates: [...]} 和 v1 的 [[sandbox.templates]],两者解析成同一形状),extractSandboxDefault(:1025)解析 sandbox.default。里面还留了一条迁移安全网——旧的 [[sandboxes]] 写法在启动路径上仍然能解析(dockerfile-layer.ts:988-991),但闸门会硬拒并给出改名提示(index.ts:739 rejectLegacySandboxes)。分工很清楚:闸门负责推进迁移,启动路径负责别把人锁在门外。
on_boot 是个特例——它由沙箱内的守护进程直接读,而且不带 manifest 解析器,靠两条正则(按格式各一套:先抠 section 块、再抠 key)从 sandbox 段里取出命令(apps/kortix-sandbox-agent-server/src/config.ts:285 resolveSandboxOnBoot + :231 extractNestedString)。同样的手法也用在 [opencode] config_dir 上(packages/registry/src/manifest.ts:52 resolveOpencodeDir),注释说明它是刻意"mirror" API 侧的实现,好让扫工作树的 CLI 和扫 git tree 的 API 对"东西在哪"达成一致。
反写(平台 → 声明)。 有一条通道是反向的:在网页里连接 Slack / Email,平台会替你提交一次 manifest,把频道登记成一等公民的连接器:
连接 Slack ──> ensureChannelConnectorDeclared(projectId, 'slack')
└─> 读 manifest → 加一条 connectors 条目 provider="channel"
→ commitManifest("chore: register slack channel connector (kortix_slack)")
→ syncProjectConnectors 立刻物化
代码在 apps/api/src/connectors/channel-manifest.ts:34(登记)与 :68(注销),由 sync.ts:306 reconcileChannelConnectors 驱动。它刻意是 best-effort:仓库只读或不可达时整段静默失败,因为 synthesizeChannelConnectors 还会从 install 记录里合成同一个连接器——写文件只是让这份声明变得显式可见,不是功能的必要条件。
同一件事,两种写法。 谁在改文件,决定了改法:
| 改写方 | 手法 | 注释保留? | 位置 |
|---|---|---|---|
CLI(kortix 命令) | 纯文本手术:整块追加 / 整块切除 / 定点替换标量(yaml、toml 各一套) | 保留 | apps/cli/src/manifest-edit.ts:111/:131 |
| 后端(网页 CRUD、频道登记) | parse → 改对象 → stringify | 丢失 | triggers.ts:496 serializeManifest + apps/api/src/projects/lib/triggers.ts:1928 commitManifest |
CLI 那边的取舍写在文件头:脚手架文件里塞满了教学注释,parse→stringify 会把它们全冲掉,所以宁可做正则文本手术。后端不在乎——它面对的是已经被人编辑过的文件,而且要保证输出可预测,所以 serializeManifest 还额外把 kortix_version 强制排到第一个 key(triggers.ts:496-506)。
4.7 agents: 的声明侧:默认拒绝,以及那个非绑定哨兵
agents 是唯一一个"写下去会改变安全语义"的块,所以它的默认值设计值得单讲。本章只谈声明侧,执行侧见第 5 章。
v2 里一个 agent 的行为(prompt / model / tools)来自它的 OpenCode .md;agents: map 补的是 .md 表达不了的那些治理项(apps/api/src/projects/agents.ts:2-18 的文件头注释):
connectors—— 能调哪些连接器(按 slug),v1 默认[];v2 改成 deny-by-default:省略即none(packages/starter/templates/base/kortix.yaml:82-86的注释)。secrets/kortix_cli—— 能读哪些项目密钥、能对 Kortix 平台自身做什么(project 作用域的 IAM 动作)。
授权值有三种写法,由 parseGrantSet 归一(agents.ts:801):字符串 "all" → 全给;"none" 或 "" → 全不给;数组 → 逐项校验后的具体清单;数组里出现 "*" 等同 "all"。
天花板是硬的。 kortix_cli 的每一项都要过 validateKortixAction(agents.ts:841),而可授予集合 GRANTABLE_KORTIX_CLI 只由 PROJECT_ACTIONS 组成(agents.ts:80)。账号级的动作不在里面,而且错误信息会区分两种失败——"这个动作是账号级的,永远不能授予 agent" vs "根本没这个动作"。也就是说:声明里写不出一个超过项目边界的 agent。
真正的授权是 declared ∩ 启动人角色(agent ≤ 人)。声明侧只负责给出 declared 那一半,交集在路由层自然发生。grantFromLoadedAgents(agents.ts:334)是这条规则的纯函数版本:
| 情况 | 结果 |
|---|---|
项目根本没写 agents | null —— 不加限制(老项目原样工作) |
| 平台元 agent(协调者) | 平台自有的 grant,不经 manifest 解析(agents.ts:335-344) |
| agent 在列表里且 enabled | 它声明的那份 grant(连接器名先做 canonical 化) |
项目写了 agents 但这个 agent 不在列表 | 默认拒绝:连接器、CLI 动作、secrets 全空 |
中间还有一个特例:名字 default 是非绑定哨兵(agents.ts:58 DEFAULT_AGENT_SENTINEL),不能按"未列出 → 拒绝"处理。注释记录了教训(agents.ts:363-372):default 其实指向 OpenCode 配置的默认 agent(通常是被授予 "all" 的 kortix),一刀切默认拒绝会把这类会话的全部连接器剥光( "kortix connectors ls → []" bug)。v2 给了结构化解法:顶层 default_agent 必须指向一个已声明的具体 agent,grantFromLoadedAgents 遇到哨兵就改查 default_agent 指的那位(agents.ts:380-395);闸门侧也有对应的引用完整性检查 validateDefaultAgentV2(packages/manifest-schema/src/index.ts:301)。
还有一个易漏的坑写在 readManifest 里(triggers.ts:352-375):必须尊重项目自定义的 manifest_path,而且主动探测 .yaml/.yml 兄弟文件——硬编码 kortix.toml 会让 yaml-only 项目读到一个不存在的文件 → 解析不出 agents: → 授权解析成 null → 每 agent 的作用域限制直接关闭。一个路径 fallback 写错,安全特性就静默失效了。
5. 声明是从哪来的:脚手架
新项目的第一份 kortix.yaml 不是凭空生成的字符串拼接,而是一棵真实的模板文件树:packages/starter/templates/base/(packages/starter/src/index.ts:47 的 STARTER_TEMPLATE_IDS)。
三个要点:
- 编辑模板 = 编辑文件。
getStarterFiles(starter/src/index.ts:159)走目录、做{{var}}替换、返回[{path, content}],调用方(API 建仓流程、kortix init)拿去提交或落盘。 - 注释就是文档。 模板里
sandbox.templates全是注释掉的示例(packages/starter/templates/base/kortix.yaml:44-65),agents:区块头一段注释直接把"v2 只管治理、deny-by-default、想锁窄就从all改起"教给你(:76-86)。脚手架把"怎么写"直接教在文件里——这也解释了 CLI 为什么坚持文本手术(见 4.6)。 - 编译后仍可用。 磁盘模板在源码 checkout 里是真源,但
bun build --compile之后文件没了,所以有一份embedded.generated.json快照兜底(starter/src/index.ts:25)。
种子文件的组装在 apps/api/src/projects/seed-files.ts:47 buildProjectSeedFiles:starter 文件 + 市场条目(若从模板建仓)按路径合并后一次性提交。注意 v1 时代随种子附带的 registry-lock.json 没有了——确定性安装/锁引擎整体退役(见下一节)。
6. 另一套声明:registry 的可安装单元
manifest 声明的是"这个项目是什么";packages/registry 声明的是"能往项目里装什么"。两者形状相似(都是声明 + 校验),但边界清楚:registry 装的是文件,装完之后这些文件才可能被 manifest 引用。
格式选型很务实: 它是 shadcn registry 格式的超集(packages/registry/src/schema.ts:1-14)。理由写得很直白——任何 Kortix 仓库丢一个 registry.json 就是一个 registry;已经懂 shadcn 的工具能直接读;命名空间 / include / registryDependencies 白捡。
在它之上加的是 Kortix 自己的类型词汇(schema.ts:38-45):registry:skill、registry:agent、registry:command、registry:tool、registry:trigger、registry:connector 等。安装机制对所有类型是同一套——把文件复制到 target;类型只是驱动分类、图标和校验的元数据。
target 用别名寻址(schema.ts:83-90),而别名展开时读的正是 opencode.config_dir(schema.ts:69-80 的注释,展开在 packages/registry/src/paths.ts:19 expandTarget):
| 别名 | 展开为 |
|---|---|
@opencode/<path> | <configDir>/<path> |
@skills/<path> | <configDir>/skills/<path> |
@agents/<path> | <configDir>/agents/<path> |
@commands/<path> | <configDir>/commands/<path> |
@tools/<path> | <configDir>/tools/<path> |
@memory/<path> | .kortix/memory/<path> |
~/<path> | 仓库根(shadcn 兼容) |
校验器同构。 validateRegistryItem / validateRegistry(packages/registry/src/validate.ts:28/:93)返回的也是 {valid, issues}、也分 error/warning——一个仓库里两套配置,报错手感一致。
锁文件引擎已退役(2026-07-13)。 上一版这里讲的 registry-lock.json + 内容哈希漂移检测(parseLockContent / applyInstall / planInstall)已被整体移除,安装改成 agent 驱动:POST /:projectId/marketplace/install-session 开一个会话,由 agent 读取并接线市场条目的文件,最后开 CR——"no file is ever committed without the agent reading + wiring it in first"(apps/api/src/projects/routes/r10.ts:8-11)。schema 里只剩两个文件名常量作历史标记(schema.ts:215-217);仓库根的 skills-lock.json 是更早的 v1 遗留。
7. 巧妙之处(可借鉴的技术)
- 闸门与取用方分工,严格度反向。 闸门逐条报错、绝不放过;解析器逐条容错、绝不整份炸掉。同一份文件两种读法,是因为两者的失败代价不同(
index.ts:208vsconnectors.ts:251)。 - 纯函数是"到处都能跑"的前提,不是洁癖。 零 I/O 让同一套规则同时活在离线 CLI、后端请求和 CI 里,并给出同一个答案(
index.ts:13-17)。 - 能共享代码就 import,不能共享就共享测试。 平台枚举由运行时直接 import 闸门包(
connectors.ts:40-42);跨包 provider 列表靠一致性测试,让漂移在 CI 里响(unit-connectors-parse.test.ts:871、unit-agents-parse.test.ts:37-38)。 - 给平台自己的东西加命名空间前缀,而不是禁止用户用那个词。
slack→kortix_slack,既堵住影子化又不伤存量(constants.ts:160起)。 - "读不出来"≠"没有"。 一次 git 抖动不该删光项目的 连接器(
sync.ts:473-478)。分布式系统里最常见的破坏性 bug 之一。 - 哈希算什么,取决于它要触发什么。
manifestHashForConnector排除改名与策略,因为它守的是"要不要重拉目录"这一件事;channel/computer 不吃这套缓存,否则动作列表会被冻结在物化那天(connectors.ts:375、sync.ts:534-546)。 - 迁移期让闸门和启动路径唱红白脸。 闸门硬拒旧写法逼人改,启动路径继续接受旧写法保证不锁人(
index.ts:739vsdockerfile-layer.ts:988-991)。 - 默认值是安全设计的一部分。 未声明
agents→ 不限制(兼容);声明了但没列到某 agent → 默认拒绝(安全);v2 用必填的default_agent把哨兵漏洞结构化堵死(agents.ts:334-401)。三条规则都写在一个纯函数里,可测。
8. 边界与局限
诚实清单:
- 项目级
policies:没有闸门。 v1/v2 的调度列表(index.ts:262-303)里没有它——只有connectors.policies(连接器内嵌那层)被校验(index.ts:1470-1490)。项目级策略只由运行时extractProjectPolicies(apps/api/src/projects/policies.ts:78)容错解析,写错的规则会被静默跳过,不会挡住 merge。 - 两个
SLUG_RE长度不同。 128(闸门/连接器/触发器)vs 64(沙箱模板)。用 warning 打了补丁,没有统一。 - v1 的引用完整性不查,v2 查了一半。 v1 不 会验证
triggers[].agent指向的 agent 真的存在;v2 补上了default_agent与 trigger agent 引用(index.ts:301-302),但agents.<name>.connectors里的 slug 是否在connectors:里有定义,两边都不查。 - 校验器不碰网络,所以"能不能真的用"验不了。 镜像存不存在、OpenAPI spec 拉不拉得到、MCP 服务活不活着,统统要到物化时才知道——
resolveCatalog(apps/api/src/connectors/sync.ts:869)对此的处理是把失败连接器存成status='error'而非让整次同步失败(sync.ts:126-128)。 - 后端改 manifest 会吃掉注释。
serializeManifest是 parse→stringify(triggers.ts:496);在网页上点一次"改连接器",脚手架里那些教学注释就没了。CLI 侧的文本手术只覆盖 CLI 自己的编辑路径。 env:只声明名字,不声明值。 值必须先进 Secrets Manager;闸门只能验名字合法(ENV_NAME_RE),验不了"设没设"。
9. 代码地图(导航索引)
| 主题 | 文件路径 | 符号名 |
|---|---|---|
| 双格式解析(yaml/toml) | packages/manifest-schema/src/format.ts | manifestCandidatePaths, parseManifestText, serializeManifestObject |
| 校验器入口(纯函数) | packages/manifest-schema/src/index.ts | validateManifest |
| v2 类型与专属校验器 | packages/manifest-schema/src/index.v2.ts | validateAgentsV2, validateDefaultAgentV2, rejectChannelsV2 |
| 诊断数据形状 | packages/manifest-schema/src/index.ts | ManifestIssue, ManifestValidationResult |
| 诊断渲染(可选) | packages/manifest-schema/src/index.ts | formatIssues |
| 版本门闩(闸门侧) | packages/manifest-schema/src/index.ts | KNOWN_SCHEMA_VERSION, validateRoot |
| 枚举与正则(闸门侧) | packages/manifest-schema/src/constants.ts | CONNECTOR_PROVIDERS, CHANNEL_PLATFORMS, RESERVED_SLUG_PROVIDERS, SLUG_RE |
| 可授予动作清单 | packages/manifest-schema/src/constants.ts | GRANTABLE_KORTIX_CLI_ACTIONS |
| 旧写法拒绝 | packages/manifest-schema/src/index.ts | rejectLegacySandboxes, rejectRetiredApps |
| CLI 预检 shim | apps/cli/src/manifest.ts | loadLocalManifest, lintManifest, lintManifestText |
CLI validate 子命令 | apps/cli/src/commands/validate.ts | runValidate |
| CLI 注释保留式编辑 | apps/cli/src/manifest-edit.ts | appendArrayBlock, removeArrayBlock |
| CR-merge 闸门 | apps/api/src/projects/routes/r9.ts | (MANIFEST_INVALID 分支,:69-95) |
| HTTP 校验 端点 | apps/api/src/projects/routes/r2.ts | POST /{projectId}/manifest/validate |
| manifest 读/写/版本 | apps/api/src/projects/triggers.ts | readManifest, parseManifestString, serializeManifest, MAX_SCHEMA_VERSION |
| 触发器抽取 | apps/api/src/projects/triggers.ts | extractTriggers, parseTriggerEntry, coerceBool |
| 连接器抽取 + 保留 slug | apps/api/src/projects/connectors.ts | extractConnectors, PROVIDERS, RESERVED_CONNECTOR_SLUGS |
| 连接器变更哈希 | apps/api/src/projects/connectors.ts | manifestHashForConnector |
| agent 授权(声明侧) | apps/api/src/projects/agents.ts | extractAgents, grantFromLoadedAgents, parseGrantSet |
| 授权天花板 | apps/api/src/projects/agents.ts | GRANTABLE_KORTIX_CLI, validateKortixAction |
| 项目级策略解析 | apps/api/src/projects/policies.ts | extractProjectPolicies |
| 沙箱模板抽取 | packages/shared/src/sandbox/dockerfile-layer.ts | extractSandboxTemplates, extractSandboxDefault, DEFAULT_SANDBOX_SLUG |
on_boot 读取(沙箱内) | apps/kortix-sandbox-agent-server/src/config.ts | resolveSandboxOnBoot, extractNestedString |
| 连接器物化 | apps/api/src/connectors/sync.ts | syncProjectConnectors, resolveCatalog, shouldReuseConnectorCatalog |
| 纯映射层 | apps/api/src/connectors/materialize.ts | connectorConfig, toPolicyRows, toProjectPolicyRows |
| manifest CRUD(网页) | apps/api/src/connectors/manifest-crud.ts | 保留 slug 拒绝块(:182-194) |
| 平台反写 git | apps/api/src/connectors/channel-manifest.ts | ensureChannelConnectorDeclared, removeChannelConnectorDeclared |
| 提交 manifest | apps/api/src/projects/lib/triggers.ts | loadManifestForEdit, commitManifest |
| 跨包一致性守卫 | apps/api/src/__tests__/unit-connectors-parse.test.ts | (provider agreement describe 块,:842-880) |
| 授权枚举守卫 | apps/api/src/__tests__/unit-agents-parse.test.ts | (GRANTABLE 相等断言,:37-38) |
| 脚手架模板树 | packages/starter/src/index.ts | getStarterFiles, STARTER_TEMPLATE_IDS |
| 脚手架 manifest 原文 | packages/starter/templates/base/kortix.yaml | — |
| 建仓种子文件 | apps/api/src/projects/seed-files.ts | buildProjectSeedFiles, buildProjectSeedFilesFromItem |
| registry 类型词汇 | packages/registry/src/schema.ts | KORTIX_ITEM_TYPES, TARGET_ALIASES |
| registry 校验器 | packages/registry/src/validate.ts | validateRegistry, validateRegistryItem |
| 别名展开 | packages/registry/src/paths.ts | expandTarget |
| agent 驱动安装(锁引擎已退役) | apps/api/src/projects/routes/r10.ts | POST /{projectId}/marketplace/install-session |
config_dir 无依赖读取 | packages/registry/src/manifest.ts | resolveOpencodeDir |