跳到主要内容

数据截至 (上游 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_diragent / 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/Opackages/manifest-schema/src/index.ts
触发器解析器读 manifest、判版本、抽 triggersapps/api/src/projects/triggers.ts
连接器解析器connectors,守保留 slugapps/api/src/projects/connectors.ts
agent 解析器agents(v2 map / v1 数组),算出授权集合apps/api/src/projects/agents.ts
沙箱模板解析器sandbox.templatessandbox.defaultpackages/shared/src/sandbox/dockerfile-layer.ts
物化把连接器声明变成 connector_* 表里的行apps/api/src/connectors/sync.ts
脚手架新项目的第一份 kortix.yaml 从哪来packages/starter/templates/base/

主线走一遍(高层):

  1. 人在本地编辑 manifest,或在网页后台点几下(后台会改文件并提交,而不是改数据库)。
  2. kortix ship 推送前先跑校验器;非法就地报错,不推
  3. 绕过 CLI 直接 git push 也逃不掉:CR 合并时后端再跑一遍同一个校验器,不过就 422。
  4. 落到默认分支后,各个运行时解析器按需读文件,抽出自己那一块。
  5. 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-tomlyaml 解析,语法错误被归一成一条 path: '<toml>'/'<yaml>' 的诊断并立即返回(index.ts:216-236),不会继续往下跑出一堆连带错误。

诊断为什么要结构化。 ManifestIssue 有四个字段 + 两个可选(index.ts:180-192):path(点路径,如 triggers[1].cron)、messageseverityline/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 数组从头传到尾,没有任何一个 validateXxxthrowreturn——这是它能一次报出全部问题(而不是"改一个报一个")的原因。真实调度在 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")两边都拒。

字段别名也一样。运行时解析器为了历史兼容接受多种写法,闸门就得照单全收:

规范写法也接受校验器运行时
promptprompt_templateindex.ts:1050-1058triggers.ts:695-700
cronscheduleindex.ts:995(逐 key 扫)triggers.ts:825-829
run_atrunAtindex.ts:1076-1082triggers.ts:831-835
session_modesessionModeindex.ts:1162-1166triggers.ts:720-725
base_urlbaseUrlindex.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 threadaction_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 errorconnectors.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.tsRESERVED_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)处理(向后兼容)
非正整数errorthrow
高于已知版本error:升级 CLI 或钉住 manifest(闸门已知 2,index.ts:162)throw(运行时上限 MAX_SCHEMA_VERSION = 2,triggers.ts:69-83)
= 2 但文件是 .tomlerror: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 表。

这个函数里藏着三个值得抄的防御:

  1. manifest 读不出来时,绝不当成"零个连接器"。 readManifest 失败可能是"没有仓库",也可能只是一次 git 抖动;若按字面理解就会把项目的连接器全删光。所以代码显式分支:manifest 为 null 且没有安装驱动的 channel/computer 连接器 → 直接 bail,不删任何东西(sync.ts:473-478)。
  2. 删除对账要分清来源。 只有 manifest 可读时,它才是"声明列表"的真源;不可读时只清理 install 驱动的 channel / computer 行——"a disconnect still cleans up"(sync.ts:594-602)。
  3. 便宜 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)是这条规则的纯函数版本:

情况结果
项目根本没写 agentsnull —— 不加限制(老项目原样工作)
平台元 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:47STARTER_TEMPLATE_IDS)。

三个要点:

  1. 编辑模板 = 编辑文件。 getStarterFiles(starter/src/index.ts:159)走目录、做 {{var}} 替换、返回 [{path, content}],调用方(API 建仓流程、kortix init)拿去提交或落盘。
  2. 注释就是文档。 模板里 sandbox.templates 全是注释掉的示例(packages/starter/templates/base/kortix.yaml:44-65),agents: 区块头一段注释直接把"v2 只管治理、deny-by-default、想锁窄就从 all 改起"教给你(:76-86)。脚手架把"怎么写"直接教在文件里——这也解释了 CLI 为什么坚持文本手术(见 4.6)。
  3. 编译后仍可用。 磁盘模板在源码 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:skillregistry:agentregistry:commandregistry:toolregistry:triggerregistry: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. 巧妙之处(可借鉴的技术)

  1. 闸门与取用方分工,严格度反向。 闸门逐条报错、绝不放过;解析器逐条容错、绝不整份炸掉。同一份文件两种读法,是因为两者的失败代价不同(index.ts:208 vs connectors.ts:251)。
  2. 纯函数是"到处都能跑"的前提,不是洁癖。 零 I/O 让同一套规则同时活在离线 CLI、后端请求和 CI 里,并给出同一个答案(index.ts:13-17)。
  3. 能共享代码就 import,不能共享就共享测试。 平台枚举由运行时直接 import 闸门包(connectors.ts:40-42);跨包 provider 列表靠一致性测试,让漂移在 CI 里响(unit-connectors-parse.test.ts:871unit-agents-parse.test.ts:37-38)。
  4. 给平台自己的东西加命名空间前缀,而不是禁止用户用那个词。 slackkortix_slack,既堵住影子化又不伤存量(constants.ts:160 起)。
  5. "读不出来"≠"没有"。 一次 git 抖动不该删光项目的连接器(sync.ts:473-478)。分布式系统里最常见的破坏性 bug 之一。
  6. 哈希算什么,取决于它要触发什么。 manifestHashForConnector 排除改名与策略,因为它守的是"要不要重拉目录"这一件事;channel/computer 不吃这套缓存,否则动作列表会被冻结在物化那天(connectors.ts:375sync.ts:534-546)。
  7. 迁移期让闸门和启动路径唱红白脸。 闸门硬拒旧写法逼人改,启动路径继续接受旧写法保证不锁人(index.ts:739 vs dockerfile-layer.ts:988-991)。
  8. 默认值是安全设计的一部分。 未声明 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.tsmanifestCandidatePaths, parseManifestText, serializeManifestObject
校验器入口(纯函数)packages/manifest-schema/src/index.tsvalidateManifest
v2 类型与专属校验器packages/manifest-schema/src/index.v2.tsvalidateAgentsV2, validateDefaultAgentV2, rejectChannelsV2
诊断数据形状packages/manifest-schema/src/index.tsManifestIssue, ManifestValidationResult
诊断渲染(可选)packages/manifest-schema/src/index.tsformatIssues
版本门闩(闸门侧)packages/manifest-schema/src/index.tsKNOWN_SCHEMA_VERSION, validateRoot
枚举与正则(闸门侧)packages/manifest-schema/src/constants.tsCONNECTOR_PROVIDERS, CHANNEL_PLATFORMS, RESERVED_SLUG_PROVIDERS, SLUG_RE
可授予动作清单packages/manifest-schema/src/constants.tsGRANTABLE_KORTIX_CLI_ACTIONS
旧写法拒绝packages/manifest-schema/src/index.tsrejectLegacySandboxes, rejectRetiredApps
CLI 预检 shimapps/cli/src/manifest.tsloadLocalManifest, lintManifest, lintManifestText
CLI validate 子命令apps/cli/src/commands/validate.tsrunValidate
CLI 注释保留式编辑apps/cli/src/manifest-edit.tsappendArrayBlock, removeArrayBlock
CR-merge 闸门apps/api/src/projects/routes/r9.ts(MANIFEST_INVALID 分支,:69-95)
HTTP 校验端点apps/api/src/projects/routes/r2.tsPOST /{projectId}/manifest/validate
manifest 读/写/版本apps/api/src/projects/triggers.tsreadManifest, parseManifestString, serializeManifest, MAX_SCHEMA_VERSION
触发器抽取apps/api/src/projects/triggers.tsextractTriggers, parseTriggerEntry, coerceBool
连接器抽取 + 保留 slugapps/api/src/projects/connectors.tsextractConnectors, PROVIDERS, RESERVED_CONNECTOR_SLUGS
连接器变更哈希apps/api/src/projects/connectors.tsmanifestHashForConnector
agent 授权(声明侧)apps/api/src/projects/agents.tsextractAgents, grantFromLoadedAgents, parseGrantSet
授权天花板apps/api/src/projects/agents.tsGRANTABLE_KORTIX_CLI, validateKortixAction
项目级策略解析apps/api/src/projects/policies.tsextractProjectPolicies
沙箱模板抽取packages/shared/src/sandbox/dockerfile-layer.tsextractSandboxTemplates, extractSandboxDefault, DEFAULT_SANDBOX_SLUG
on_boot 读取(沙箱内)apps/kortix-sandbox-agent-server/src/config.tsresolveSandboxOnBoot, extractNestedString
连接器物化apps/api/src/connectors/sync.tssyncProjectConnectors, resolveCatalog, shouldReuseConnectorCatalog
纯映射层apps/api/src/connectors/materialize.tsconnectorConfig, toPolicyRows, toProjectPolicyRows
manifest CRUD(网页)apps/api/src/connectors/manifest-crud.ts保留 slug 拒绝块(:182-194)
平台反写 gitapps/api/src/connectors/channel-manifest.tsensureChannelConnectorDeclared, removeChannelConnectorDeclared
提交 manifestapps/api/src/projects/lib/triggers.tsloadManifestForEdit, 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.tsgetStarterFiles, STARTER_TEMPLATE_IDS
脚手架 manifest 原文packages/starter/templates/base/kortix.yaml
建仓种子文件apps/api/src/projects/seed-files.tsbuildProjectSeedFiles, buildProjectSeedFilesFromItem
registry 类型词汇packages/registry/src/schema.tsKORTIX_ITEM_TYPES, TARGET_ALIASES
registry 校验器packages/registry/src/validate.tsvalidateRegistry, validateRegistryItem
别名展开packages/registry/src/paths.tsexpandTarget
agent 驱动安装(锁引擎已退役)apps/api/src/projects/routes/r10.tsPOST /{projectId}/marketplace/install-session
config_dir 无依赖读取packages/registry/src/manifest.tsresolveOpencodeDir